diff --git a/paseo/.env.example b/paseo/.env.example index 20af898..7bd1fcf 100644 --- a/paseo/.env.example +++ b/paseo/.env.example @@ -17,18 +17,11 @@ PASEO_HOSTNAMES= # `uniquelocal` covers the private ranges Docker bridge networks use. PASEO_TRUSTED_PROXIES=uniquelocal -# Shell for Paseo's terminals. Paseo reads $SHELL and otherwise falls back to -# /bin/sh (dash); it ignores the user's login shell, so `chsh` has no effect. -SHELL=/bin/zsh - # Agent CLIs to install on start, if not already present. Space- or # comma-separated, from: claude codex opencode copilot omp pi. Leave empty to # install none. The first start with a new paseo-home volume downloads a few # hundred MB per agent and takes a while; later starts only check. -AGENT_CLIS=claude codex - -# Timezone for logs and agent shells. -TZ=Asia/Ho_Chi_Minh +AGENTS=claude codex # Git identity for agents and terminals, as author and committer. git reads # these directly, so no `git config` step is needed. diff --git a/paseo/Dockerfile b/paseo/Dockerfile index 4ce1f6e..d805b36 100644 --- a/paseo/Dockerfile +++ b/paseo/Dockerfile @@ -1,4 +1,4 @@ -# Adds language toolchains and CLI tooling to the official image, which ships +# Adds Python, a C toolchain and CLI tooling to the official image, which ships # none of it. Agent CLIs are not among them -- see README.md, which carries the # reasoning for everything here. FROM ghcr.io/getpaseo/paseo:latest @@ -6,8 +6,8 @@ FROM ghcr.io/getpaseo/paseo:latest # --- system packages ------------------------------------------------------- RUN apt-get update \ && apt-get install -y --no-install-recommends \ - less nano jq unzip zip lsof psmisc ugrep bfs zsh sudo \ - build-essential \ + build-essential sudo \ + zsh nano \ && rm -rf /var/lib/apt/lists/* \ && usermod -aG sudo paseo @@ -19,14 +19,6 @@ ENV UV_INSTALL_DIR=/usr/local/bin \ RUN curl -fsSL https://astral.sh/uv/install.sh | sh \ && uv python install "${PYTHON_VERSION}" --default -# --- go -------------------------------------------------------------------- -ARG GO_VERSION=1.26.8 -ENV PATH=/usr/local/go/bin:$PATH -RUN curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-$(dpkg --print-architecture).tar.gz" \ - -o /tmp/go.tar.gz \ - && tar -C /usr/local -xzf /tmp/go.tar.gz \ - && rm /tmp/go.tar.gz - # --- gh and glab ----------------------------------------------------------- # gh from GitHub's signed apt repository, glab from the .deb on its releases # page. @@ -44,15 +36,9 @@ RUN install -d -m 0755 /etc/apt/keyrings \ && rm -f /tmp/glab.deb \ && rm -rf /var/lib/apt/lists/* -# --- agent cli and sdkman path --------------------------------------------- -# Puts the $HOME directories the agent installers and SDKMAN write to on PATH. -ENV SDKMAN_DIR=/home/paseo/.sdkman -ENV PATH=/home/paseo/.local/bin:/home/paseo/.opencode/bin:\ -$SDKMAN_DIR/candidates/java/current/bin:\ -$SDKMAN_DIR/candidates/scala/current/bin:\ -$SDKMAN_DIR/candidates/gradle/current/bin:\ -$SDKMAN_DIR/candidates/maven/current/bin:\ -$SDKMAN_DIR/candidates/sbt/current/bin:$PATH +# --- agent cli path -------------------------------------------------------- +# Puts the $HOME directories the agent installers write to on PATH. +ENV PATH=/home/paseo/.local/bin:/home/paseo/.opencode/bin:$PATH # --- entrypoint ------------------------------------------------------------ # Wraps the image's entrypoint with the root-stage setup -- see entrypoint.sh. diff --git a/paseo/README.md b/paseo/README.md index 09a29d0..ebe2304 100644 --- a/paseo/README.md +++ b/paseo/README.md @@ -1,11 +1,11 @@ # paseo [Paseo](https://paseo.sh) — self-hosted daemon and web UI for running coding -agents. Built from a local `Dockerfile` that adds `gh`, `glab`, Go, Python, -a C toolchain, and shell tooling to the +agents. Built from a local `Dockerfile` that adds `gh`, `glab`, Python, a C +toolchain, `zsh` and `nano` to the [official image](https://paseo.sh/docs/docker), which ships none of it. The -agent CLIs and [SDKMAN](#sdkman) are not baked in; `entrypoint.sh` installs -them into `$HOME` on start, see [Agents](#agents). +agent CLIs are not baked in; `entrypoint.sh` installs them into `$HOME` on +start, see [Agents](#agents). ## Setup @@ -18,7 +18,7 @@ them into `$HOME` on start, see [Agents](#agents). paseo.example.com:443 ``` -4. List the agents you want in `AGENT_CLIS`; the first start installs them. +4. List the agents you want in `AGENTS`; the first start installs them. Log in to each — see [Agents](#agents) — plus `gh auth login` and `glab auth login`. @@ -38,11 +38,9 @@ the old entry is cached in `localStorage`. | `PASEO_PASSWORD` | Web UI and API login, and the `paseo` user's `sudo` password. Generate with `openssl rand -base64 24`. | | `PASEO_HOSTNAMES` | Domains allowed to reach the daemon, comma-separated. Your domain must be listed. | | `PASEO_TRUSTED_PROXIES` | Set to `uniquelocal`, or the UI loads but never connects. | -| `AGENT_CLIS` | Agent CLIs to install on start if missing, space- or comma-separated. Empty installs none. See [Agents](#agents). | +| `AGENTS` | Agent CLIs to install on start if missing, space- or comma-separated. Empty installs none. See [Agents](#agents). | | `SERVICE_HOSTNAME` | Container hostname, shown as the host label in the UI and in the shell prompt. Without it the label is a random container ID. | | `GIT_NAME` / `GIT_EMAIL` | Git author and committer identity for agents and terminals. | -| `TZ` | Timezone for logs and agent shells. | -| `SHELL` | Shell for Paseo's terminals. Paseo reads `$SHELL` and falls back to `/bin/sh`, ignoring the login shell, so `chsh` has no effect. | `SERVICE_HOSTNAME` is used twice: as the container's `hostname:` and as the `HOST` variable inside it. Coolify injects `HOST=0.0.0.0` into every compose @@ -57,9 +55,22 @@ lets the deploying shell's environment win over the `.env` file, and `HOSTNAME` is set in every container -- including the one Coolify itself runs in. The container would silently take Coolify's hostname instead of this value. -`PASEO_LABEL` was this variable's old name. It was never a Paseo variable, -only ours -- the daemon reads none of `PASEO_LABEL`, `SERVICE_HOSTNAME` or -`HOST`, and takes the host label from the container hostname. +Neither name is a Paseo variable: the daemon reads neither `SERVICE_HOSTNAME` +nor `HOST`, and takes the host label from the container hostname. + +`SHELL` and `TZ` hit the same trap, which is why neither is in the table above +or in `.env.example`: they are written into `compose.yml` directly, the way the +`code-server` services do it. `SHELL` is the worse of the two, since every +interactive shell exports it -- a `docker compose up` from a terminal, the way +you would test this locally, would hand Paseo's terminals the *host's* shell. +Harmless when that is bash, which the image has; fatal to every terminal when +it is a path the image lacks. A UTC host would override `TZ` the same way. Both +are properties of this setup rather than of whoever deploys it, so there is +nothing to fill in per deployment. + +Paseo reads `$SHELL` for its terminals and falls back to `/bin/sh` (dash) +otherwise; it ignores the user's login shell, so `chsh` has no effect. That is +what pins it to `/bin/zsh` here. `PASEO_TRUSTED_PROXIES` matches the proxy's *source IP*, so hostnames are rejected. The daemon trusts `X-Forwarded-Proto` from loopback only, but @@ -78,16 +89,16 @@ Listens on `6767`, published nowhere — the platform maps the domain to it, so ## Agents The image installs none of them. `entrypoint.sh` does, on start, for every name -in `AGENT_CLIS` whose command is not already on `PATH`: +in `AGENTS` whose command does not already run: ``` -AGENT_CLIS=claude codex +AGENTS=claude codex ``` That is the default in `.env.example`. The other four are opt-in — add their names to install them too. -| Agent | Name in `AGENT_CLIS` | Installer it runs | Log in with | +| Agent | Name in `AGENTS` | Installer it runs | Log in with | | --- | --- | --- | --- | | Claude Code | `claude` | `claude.ai/install.sh` | `claude` | | Codex | `codex` | `chatgpt.com/codex/install.sh` | `codex login` | @@ -109,6 +120,16 @@ already present. An agent that fails to install is logged and skipped rather than taking the container with it, so a bad release or a network blip cannot leave you without a shell. +The check is whether the command *runs*, not whether the file exists: the start +asks it for `--version` and reinstalls only on the two exit codes a shell uses +for a binary it could not execute. A `curl | bash` cut short — by a network +drop, or by the platform stopping the container mid-download — leaves a +truncated binary on the volume, and a file-existence check would then skip the +reinstall on every later start while the daemon advertised an agent that fails +on every invocation. An agent that runs but does not understand `--version` +exits with some other code and counts as present, so nothing assumes all six +support the flag. + An unrecognised name is logged and skipped too. To install one by hand instead, run its installer in a terminal inside Paseo as `paseo` — never under `sudo`, where they target root's home and land outside the volume, and where Claude @@ -139,37 +160,11 @@ themselves in place afterwards. Pi and Oh My Pi are separate projects sharing an ancestor; their commands do not collide. -## SDKMAN - -`entrypoint.sh` installs [SDKMAN](https://sdkman.io) on start too, into -`~/.sdkman`, whenever that directory is missing. No variable gates it — the -JVM toolchain is small next to an agent CLI and the image ships no Java at all. - -Install what you need from a terminal: - -``` -sdk install java -sdk install gradle -``` - -`sdk` is a shell function, defined by the hook the installer appends to -`.bashrc` and `.zshrc`, so it exists in terminals only. The `current/bin` -directory of five candidates — `java`, `scala`, `gradle`, `maven`, `sbt` — is -on the image's `PATH` regardless, so the binaries themselves resolve for the -daemon and for commands an agent runs non-interactively, where no rc file is -read. Install a candidate outside that five and you get the `sdk` function in a -terminal but not the binary elsewhere; add its `current/bin` to the `PATH` line -in the `Dockerfile` if you want it there. - -`SDKMAN_DIR` is set in the image, to the same `~/.sdkman` the installer would -have picked on its own. It is what puts the candidate paths above and the -install location in one place. - ## Storage | Volume | Mount | Holds | | --- | --- | --- | -| `paseo-home` | `/home/paseo` | Daemon state, agent CLIs and their configs and credentials (`.claude`, `.codex`, `.config/*`), SDKMAN and its candidates | +| `paseo-home` | `/home/paseo` | Daemon state, agent CLIs and their configs and credentials (`.claude`, `.codex`, `.config/*`) | | `paseo-workspace` | `/workspace` | Code the agents work on | The agent CLIs, `gh` and `glab` all keep their config under `/home/paseo`, so @@ -185,52 +180,65 @@ oh-my-zsh install persists. Anything written outside `$HOME` (`chsh`, | --- | --- | --- | | `gh` | GitHub's signed apt repo | Debian does not package it | | `glab` (`GLAB_VERSION`) | The `.deb` on GitLab's releases page | Debian does not package it, and GitLab runs no apt repo | -| Go (`GO_VERSION`) | Official go.dev tarball | Debian 12 ships 1.19 | | Python (`PYTHON_VERSION`) | `uv python install` | Debian 12 ships 3.11 | -| `less nano jq unzip zip lsof psmisc ugrep bfs zsh sudo` | apt | — | -| `build-essential` | apt | — | +| `build-essential`, `sudo` | apt | — | +| `zsh`, `nano` | apt | — | -Bump a pinned version with a build arg, e.g. `--build-arg GO_VERSION=1.27.1`. -`uv` itself is installed too. `GLAB_VERSION` is pinned rather than tracking the -latest because GitLab's download URL carries the version in the path. +Bump a pinned version with a build arg, e.g. +`--build-arg PYTHON_VERSION=3.13`. `uv` itself is installed too. +`GLAB_VERSION` is pinned rather than tracking the latest because GitLab's +download URL carries the version in the path. The `Dockerfile` itself only says what each layer installs. The reasoning is all here: - `$HOME` is `/home/paseo`, a volume that masks anything the build writes - there. Hence `/opt/python` and `/usr/local/go` rather than the defaults. + there. Hence `/opt/python` rather than the default. - Do not add agent CLIs here, or a runtime only they need. Under `/usr/local` - they cannot self-update; in `$HOME` they can, and they persist anyway. Bun - used to be here for `omp` and went the same way. See [Agents](#agents). + they cannot self-update; in `$HOME` they can, and they persist anyway. `omp` + needs Bun for its source build, and that belongs in `$HOME` for the same + reason. See [Agents](#agents). - One concern per layer, cheapest and least-changing first, so bumping a version rebuilds as little as possible. - `build-essential` is the C toolchain the language layers assume but do not - ship: cgo, npm's node-gyp addons and Python C extensions all shell out to - `gcc` and `make`. It rides along in the apt layer so there is one - `apt-get update`. + ship: npm's node-gyp addons and Python C extensions both shell out to `gcc` + and `make`. It rides along in the apt layer so there is one `apt-get update`. +- No other language toolchain is in the image, because none is wanted often + enough to pay for a rebuild. Install Go, a JVM or anything else into `$HOME` + from a terminal, where it persists on the `paseo-home` volume like the agent + CLIs do. - `git` and `curl` are already in the base image. `sudo` is not, despite Debian's `base-passwd` shipping an empty `sudo` group, so the apt layer adds it and puts `paseo` in the group. -- The image stays root: the entrypoint chowns the volumes, then drops to the - `paseo` user (uid 1000) with `gosu`. +- The image stays root: `entrypoint.sh` chowns `/home/paseo`, then hands over + to the base entrypoint, which drops to the `paseo` user (uid 1000) with + `gosu`. - `entrypoint.sh` is installed as `/usr/local/bin/entrypoint`, next to the - base image's `paseo-docker-entrypoint`, which it wraps. It was - `paseo-sudo-entrypoint` when setting the `sudo` password was all it did. + base image's `paseo-docker-entrypoint`, which it wraps. - `entrypoint.sh` runs before the base entrypoint, not after: that one ends in `exec gosu paseo` and never returns, and by then is no longer root. Every job it has needs root — `chpasswd`, the `chown`, and `gosu paseo` for the - SDKMAN and agent installs. + agent installs. - It sets the `paseo` password on every start rather than at build, so the password never lands in an image layer, and because `/etc/shadow` is in the image rather than on a volume and reverts on each recreate. The password is piped, not passed as an argument, since arguments are visible in `ps`; - `chpasswd` splits on the first colon, so a colon in the password is fine. An - empty `PASEO_PASSWORD` leaves the account locked and `sudo` unusable. -- It also `chown`s `/home/paseo` before installing anything, agent CLI or - SDKMAN. A freshly created volume can arrive owned by root, and the base - entrypoint's own `chown` has not run yet at that point. -- `sudo` resets `PATH` to its `secure_path`, which excludes - `/usr/local/go/bin`. Use `sudo env PATH="$PATH" go ...` or the full path. + `chpasswd` splits on the first colon, so a colon in the password is fine. A + failure there is logged and the start continues, because `chpasswd` rejects a + multi-line value and under `set -e` that would otherwise take the whole + service down rather than just the password. An empty `PASEO_PASSWORD` leaves + the account locked and `sudo` unusable. +- It also `chown`s `/home/paseo` before installing any agent CLI. A freshly + created volume can arrive owned by root, and the base entrypoint's own + `chown` has not run yet at that point. +- `chpasswd`, `chown` and `gosu` are called by absolute path. The agent `PATH` + entries come first in the image's `PATH`, including root's, and they live on + a volume that anything running as `paseo` — an agent, by design — can write + to; a file planted there under one of those names would otherwise run as root + on the next start. +- Globbing is off around the `AGENTS` loop. The list is split unquoted, so + a `*` in it would otherwise expand against `/workspace`, the working + directory, and report every file in it as an unknown agent. - The agent `PATH` entries belong in the image, not in a shell rc: the daemon probes for each provider's binary with `which` in its own environment, which comes from the image and never sources an rc file. They are spelled diff --git a/paseo/compose.yml b/paseo/compose.yml index 99b0c0b..d3a22eb 100644 --- a/paseo/compose.yml +++ b/paseo/compose.yml @@ -6,9 +6,9 @@ services: - PASEO_PASSWORD=${PASEO_PASSWORD} - PASEO_HOSTNAMES=${PASEO_HOSTNAMES} - PASEO_TRUSTED_PROXIES=${PASEO_TRUSTED_PROXIES} - - SHELL=${SHELL} - - AGENT_CLIS=${AGENT_CLIS} - - TZ=${TZ} + - SHELL=/bin/zsh + - AGENTS=${AGENTS} + - TZ=Asia/Ho_Chi_Minh - GIT_AUTHOR_NAME=${GIT_NAME} - GIT_AUTHOR_EMAIL=${GIT_EMAIL} - GIT_COMMITTER_NAME=${GIT_NAME} diff --git a/paseo/entrypoint.sh b/paseo/entrypoint.sh index 6d0e0af..0e3ec80 100755 --- a/paseo/entrypoint.sh +++ b/paseo/entrypoint.sh @@ -1,11 +1,16 @@ #!/usr/bin/env bash # Runs as root ahead of the image's own entrypoint: sets the paseo user's -# login password, installs SDKMAN, then installs any agent CLI named in -# AGENT_CLIS that is not already on PATH. See README.md. +# login password, then installs any agent CLI named in AGENTS whose command +# does not already run. See README.md. set -euo pipefail -if [[ "$(id -u)" == "0" && -n "${PASEO_PASSWORD:-}" ]]; then - printf 'paseo:%s\n' "$PASEO_PASSWORD" | chpasswd +if [[ "$(id -u)" != "0" ]]; then + exec /usr/local/bin/paseo-docker-entrypoint "$@" +fi + +if [[ -n "${PASEO_PASSWORD:-}" ]]; then + printf 'paseo:%s\n' "$PASEO_PASSWORD" | /usr/sbin/chpasswd \ + || echo "entrypoint: could not set the paseo password, continuing" >&2 fi agent_installer() { @@ -19,33 +24,33 @@ agent_installer() { esac } -if [[ "$(id -u)" == "0" ]]; then - chown paseo:paseo /home/paseo +/usr/bin/chown paseo:paseo /home/paseo - if [[ ! -d "${SDKMAN_DIR:-/home/paseo/.sdkman}" ]]; then - echo "entrypoint: installing sdkman" - gosu paseo bash -c 'curl -fsSL https://get.sdkman.io | bash' \ - || echo "entrypoint: sdkman failed to install, continuing" >&2 +agents="${AGENTS:-}" + +set -f + +for agent in ${agents//,/ }; do + installer="$(agent_installer "$agent")" + + if [[ -z "$installer" ]]; then + echo "entrypoint: no such agent CLI: $agent" >&2 + continue fi - agents="${AGENT_CLIS:-}" + # 126 and 127 are the shell's own codes for a binary that is missing or + # cannot be executed; any other code means it ran. + rc=0 + /usr/sbin/gosu paseo bash -c '"$0" --version' "$agent" >/dev/null 2>&1 \ + || rc=$? - for agent in ${agents//,/ }; do - installer="$(agent_installer "$agent")" + if (( rc != 126 && rc != 127 )); then + continue + fi - if [[ -z "$installer" ]]; then - echo "entrypoint: no such agent CLI: $agent" >&2 - continue - fi - - if command -v "$agent" >/dev/null 2>&1; then - continue - fi - - echo "entrypoint: installing $agent" - gosu paseo bash -c "$installer" \ - || echo "entrypoint: $agent failed to install, continuing" >&2 - done -fi + echo "entrypoint: installing $agent" + /usr/sbin/gosu paseo bash -c "$installer" \ + || echo "entrypoint: $agent failed to install, continuing" >&2 +done exec /usr/local/bin/paseo-docker-entrypoint "$@"