diff --git a/.changeset/docker-proxmox-lxc-rail.md b/.changeset/docker-proxmox-lxc-rail.md new file mode 100644 index 000000000..3484226a9 --- /dev/null +++ b/.changeset/docker-proxmox-lxc-rail.md @@ -0,0 +1,7 @@ +--- +"@internal/server": minor +"@internal/docs": patch +"@internal/website": patch +--- + +Docker release image and Proxmox LXC rails: a signed-release GHCR image (tmux + the five harness CLIs, per-install secret minted at first boot), the proxmox-server.sh host helper with install/update verbs, a proxmox-node.sh helper whose container enrolls as a node through the instance's own setup-keyed rail, and an update rail that names the image as the unit of update inside containers (UPDATE_CONTAINERIZED, the containerized deployment fact, and `update --check` reporting it). diff --git a/.dockerignore b/.dockerignore index f730bbf40..68f2397a0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,3 +9,5 @@ apps/server/web/node_modules apps/server/api/node_modules packages/*/node_modules packages/*/dist +# The release shards land here mid-verify; docker/bin is the build context. +docker/dl/ diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 000000000..d713b5745 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,168 @@ +name: docker-image + +# Package the published cli-server release into the GHCR image (spec +# 2026-09-28 § 4). The release shards are the input: this workflow downloads, +# signature-verifies, bakes, and pushes. It never compiles the server. +# Dispatchable with a version to rebuild an older release into a new image. +# :latest is NOT part of that rebuild: only a release event whose smoke job +# passed moves the floating tag (the `latest` job, the run's last step). + +on: + release: + types: [published] + tags: ["cli-server-v*"] + workflow_dispatch: + inputs: + version: + description: "cli-server version to package (e.g. 2.3.1)" + required: true + type: string + +permissions: + contents: read + packages: write + +concurrency: + group: docker-image + cancel-in-progress: false + +env: + IMAGE: ghcr.io/subshell-ai/subshell + +jobs: + build: + # One job per architecture, each on a NATIVE runner: the harness installers + # run inside the image build and must execute real arm64/amd64 code. + strategy: + matrix: + include: + - arch: amd64 + asset: linux-x64 + runner: ubuntu-24.04 + - arch: arm64 + asset: linux-arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + outputs: + # Both matrix legs validate the same event-derived version with the same + # guard, so they emit the identical string and last-finisher-wins here + # is by construction, not a race. + version: ${{ steps.plan.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.2 + - name: Resolve the version + id: plan + # Both values arrive from the event payload, so they cross into the + # script as env (the docs.yml / website.yml / release.yml convention), + # never as interpolated text the shell would then parse. + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + DISPATCH_VERSION: ${{ inputs.version }} + run: | + if [ "$GITHUB_EVENT_NAME" = "release" ]; then + TAG="$RELEASE_TAG" + else + TAG="cli-server-v$DISPATCH_VERSION" + fi + VERSION="${TAG#cli-server-v}" + # A pre-release tag (1.6.0-rc.1) fails this guard on purpose: the + # image rail packages full releases only. + [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::not a semver: $VERSION"; exit 1; } + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + - name: Download the release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p docker/dl docker/bin + gh release download "${{ steps.plan.outputs.tag }}" -R subshell-ai/subshell \ + -p 'release-manifest.json' -p 'release-manifest.json.sig' \ + -p "subshell-server-cli-${{ matrix.asset }}" -D docker/dl + - name: Verify signature and digests (refused by name on anything else) + run: bun run scripts/docker-release-verify.ts docker/dl "${{ steps.plan.outputs.version }}" + - name: Stage the verified binary + run: cp "docker/dl/subshell-server-cli-${{ matrix.asset }}" "docker/bin/subshell-server-${{ matrix.arch }}" + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + push: true + platforms: linux/${{ matrix.arch }} + tags: ${{ env.IMAGE }}:${{ steps.plan.outputs.version }}-${{ matrix.arch }} + provenance: false + cache-from: type=gha,scope=docker-image-${{ matrix.arch }} + cache-to: type=gha,scope=docker-image-${{ matrix.arch }},mode=max + + manifest: + needs: build + runs-on: ubuntu-24.04 + steps: + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # same pin as above + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Compose and push the multi-arch version tag + # :latest is deliberately NOT one of these tags: it moves only in the + # `latest` job, after the smoke job has run this exact version. + run: | + V="${{ needs.build.outputs.version }}" + docker buildx imagetools create \ + -t "$IMAGE:$V" \ + "$IMAGE:$V-amd64" "$IMAGE:$V-arm64" + env: + IMAGE: ${{ env.IMAGE }} + - name: Ensure the package is public (idempotent; first push would otherwise land private) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api -X PATCH /orgs/subshell-ai/packages/container/subshell -f visibility=public \ + || echo "::warning::could not flip package visibility - set it once at https://github.com/orgs/subshell-ai/packages" + + smoke: + needs: [build, manifest] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Logged in even though the package ends up public: the FIRST run pushes + # before visibility is confirmed flipped, and a private package is + # otherwise unreadable here. + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # same pin as above + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Drive the container scenario against the pushed image + env: + IMAGE: ${{ env.IMAGE }} + V: ${{ needs.build.outputs.version }} + run: bash scripts/cli-e2e/docker-image.sh "$IMAGE:$V" "$V" + + latest: + # Last, and only for release events: :latest moves to bytes the smoke job + # just passed. A dispatch rebuild of an OLDER release repoints the version + # tag it names but must never regress the floating tag. + needs: [manifest, smoke] + if: github.event_name == 'release' + runs-on: ubuntu-24.04 + steps: + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # same pin as above + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Move :latest to the smoke-tested version + env: + IMAGE: ${{ env.IMAGE }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + V="${RELEASE_TAG#cli-server-v}" + docker buildx imagetools create -t "$IMAGE:latest" "$IMAGE:$V" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d9bbfa6ef..5ee9874ba 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -112,6 +112,12 @@ jobs: - name: Run linting run: bun run lint:check + - name: "shellcheck (Proxmox helpers, image entrypoint, docker scenario)" + run: | + sudo apt-get update -q + sudo apt-get install -y -q shellcheck + shellcheck -S warning proxmox-server.sh proxmox-node.sh docker-entrypoint.sh scripts/cli-e2e/docker-image.sh + # Dependency advisories, gated. `bun audit` on its own is informational: it # prints advisories and would have left this file green through every one of # them. dep-audit requires the live advisory set to be a subset of diff --git a/.gitignore b/.gitignore index 912a0b729..3a26b51e3 100644 --- a/.gitignore +++ b/.gitignore @@ -183,3 +183,7 @@ apps/client/desktop/src-tauri/gen/ apps/client/desktop/ui/dist/ apps/server/desktop/ui/dist/ crates/desktop-core/target/ + +# Staged release binaries for the image build (docker-image.yml stages these) +docker/bin/ +docker/dl/ diff --git a/Dockerfile b/Dockerfile index 295b2cfd6..f3631898d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,94 +1,74 @@ # syntax=docker/dockerfile:1 -# Subshell — agent harness manager +# Subshell Server - the release image (spec 2026-09-28 § 3). # -# Multi-stage build: stage 1 installs deps + builds all workspace packages, -# stage 2 is a slim runtime with tmux. The app serves API + WS + built -# frontend on a single port (default 127.0.0.1:3080). +# PACKAGING, not compiling: docker/bin/subshell-server- is the published, +# signature-verified cli-server release binary, staged by docker-image.yml +# (scripts/docker-release-verify.ts refuses bad bytes). This image therefore +# carries the same artifact the install one-liner installs, and +# `subshell-server version` answers identically inside Docker and outside it. +# +# debian:trixie-slim: the Linux release floor is glibc 2.39 (the ubuntu-24.04 +# build shards) and trixie carries 2.41; trixie is also what the Proxmox +# helper script's LXCs run, so host and container agree on the distro. +# +# The five harness CLIs are baked with the vendor one-liners the product's own +# install rails offer. Their versions float with build day; refreshing one +# inside a RUNNING container works through the admin UI's agent-install route +# without an image rebuild. -FROM oven/bun:1.4 AS base -WORKDIR /app +FROM debian:trixie-slim -# ---- Dependencies (workspace manifests only, for layer caching) ---- -# The list must cover every workspace the root package.json globs -# (apps/*, apps/*/*, packages/*, e2e, brand) — `--frozen-lockfile` fails on a -# missing one. It is already short of that, and only ONE absence matters: a -# retained workspace (@internal/server) depends on @internal/mcp-core, so this -# stage fails with "listed in bun.lock but not on disk" until -# `COPY packages/mcp-core/package.json packages/mcp-core/` is added. The others -# (apps/mobile, apps/client/*, apps/server/desktop, brand) are inert — nothing -# kept here depends on them. Pre-existing: this image predates those workspaces -# and is not built in CI, so fixing it is its own change rather than part of -# the apps/ taxonomy move. -FROM base AS deps -COPY package.json bun.lock turbo.json ./ -COPY apps/server/api/package.json apps/server/api/ -COPY apps/server/web/package.json apps/server/web/ -COPY e2e/package.json e2e/ -COPY packages/harnesses/package.json packages/harnesses/ -COPY packages/subshell-protocol/package.json packages/subshell-protocol/ -COPY packages/backend-client/package.json packages/backend-client/ -COPY packages/backend-errors/package.json packages/backend-errors/ -COPY packages/tsconfig/package.json packages/tsconfig/ -# --ignore-scripts: bun would run the root `prepare` (lefthook install) here — -# dev tooling that needs git and a .git dir, neither of which exists in the -# image (dependency lifecycle scripts are not affected by this flag in bun). -RUN bun install --frozen-lockfile --ignore-scripts +RUN apt-get update \ + && apt-get install -y --no-install-recommends tmux git openssh-client ca-certificates curl unzip libatomic1 \ + && rm -rf /var/lib/apt/lists/* -# ---- Build all workspace code ---- -FROM deps AS build -COPY . . -# Workspace packages must build first (backend imports their dist output). -RUN bun run --cwd packages/subshell-protocol build \ - && bun run --cwd packages/harnesses build \ - && bun run --cwd packages/backend-errors build \ - && bun run --cwd packages/backend-client build \ - && bun run --cwd apps/server/web build \ - && bun run --cwd apps/server/api build +# The pi vendor installer (and any npm-routed agent-install/update rail run +# inside the container) hard-requires Node >= 22.19; trixie's nodejs is 20.x +# (spec 2026-09-28 § 3, operator ruling 2026-09-29). NodeSource covers both +# shipped architectures. +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* -# ---- Runtime (slim) ---- -FROM oven/bun:1.4-slim AS runtime -WORKDIR /app +RUN useradd --create-home --uid 1000 --shell /bin/bash subshell \ + && mkdir -p /data \ + && chown subshell:subshell /data \ + # A NAMED volume initializes from this dir's ownership AND mode, so 777 + # here lets any --user write it. Docker reaches no bind mount from the + # image: there the host dir's owner decides (proxmox-server.sh chowns + # /var/lib/subshell to 1000:1000, compose users own theirs). + && chmod 777 /data -# tmux backs sessions (per-session socket, pipe-pane streaming); git is what -# the harnesses run against the mounted projects; openssh-client provides -# ssh (for git push over ssh remotes with the mounted ~/.ssh) and ssh-keygen -# (for git's ssh-format commit signing). -RUN apt-get update \ - && apt-get install -y --no-install-recommends tmux git ca-certificates openssh-client \ - && rm -rf /var/lib/apt/lists/* +COPY --chmod=0755 docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh -# Non-root by default. Compose normally overrides `user:` with the host uid, -# so /home/subshell and /data are world-writable; HOME is pinned because the -# numeric host uid has no passwd entry of its own and the claude CLI writes -# under $HOME. When the host uid IS in passwd (1000 = the base image's `bun` -# user) its home is repointed at /home/subshell too — ssh resolves ~/.ssh and -# ssh_config via the passwd entry, not $HOME, so a mismatch there silently -# defeats the mounted keys. -RUN useradd --create-home --uid 1001 subshell \ - && usermod -d /home/subshell bun \ - && mkdir -p /data /home/subshell \ - && chown subshell:subshell /data /home/subshell \ - && chmod 777 /data /home/subshell -ENV HOME=/home/subshell USER subshell +ENV HOME=/home/subshell +# System dirs FIRST so a host bind-mount at /usr/local/bin/claude (the compose +# rail's CLAUDE_BIN override) shadows the baked CLI. The harnesses live in the +# home dirs, which stay on PATH, so the gate below still passes. +ENV PATH="/usr/local/bin:/usr/bin:/bin:/home/subshell/.npm-global/bin:/home/subshell/.local/bin:/home/subshell/.opencode/bin:/home/subshell/.bun/bin" -# Install production deps from the same manifest snapshot as the build stage -# (this recreates the full bun workspace layout — including the top-level -# @internal/* and hoisted symlinks that bun's installer creates — which a -# COPY of node_modules breaks). Runs as root, then drops to `subshell`. -COPY --from=deps /app/. ./ +RUN set -eux; \ + curl -fsSL https://claude.ai/install.sh | bash; \ + curl -fsSL https://chatgpt.com/codex/install.sh | sh; \ + curl -fsSL https://opencode.ai/install | bash; \ + curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash; \ + curl -fsSL https://pi.dev/install.sh | sh; \ + command -v claude codex opencode hermes pi -# Built artifacts (backend imports @internal/* from the workspace root). -COPY --from=build /app/apps/server/api/dist ./apps/server/api/dist -COPY --from=build /app/apps/server/web/dist ./apps/server/web/dist -COPY --from=build /app/packages ./packages +# The server binary lands LAST: a release refresh must not bust the five +# installer layers above it. --chmod does in the COPY what the old RUN chmod +# did as a layer of its own. +ARG TARGETARCH +COPY --chmod=0755 docker/bin/subshell-server-${TARGETARCH} /usr/local/bin/subshell-server -# SQLite + session logs live here (mount a volume). -ENV DATABASE_PATH=/data/subshell.db -ENV HOST=0.0.0.0 -ENV NODE_ENV=production +ENV HOST=0.0.0.0 \ + NODE_ENV=production \ + DATABASE_PATH=/data/subshell.db \ + SUBSHELL_SERVER_CONFIG_DIR=/data \ + SUBSHELL_CONTAINER=1 EXPOSE 3080 -CMD ["bun", "run", "./apps/server/api/dist/index.js"] +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] diff --git a/README.md b/README.md index 4e7c6f931..a2595a69f 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,9 @@ open the server in any browser or use the - [Server](https://docs.subshell.sh/server): install, configuration, service, networking, users, backups, updates -Everything this README used to carry (the desktop apps, Docker, the env-var -tables, the security and remote-operation notes) lives on the site now. +Everything this README used to carry (the desktop apps, the env-var tables, the +security and remote-operation notes) moved to the site. Docker and Proxmox LXC +setup live in the docs (docs.subshell.sh). ## Contributing diff --git a/apps/docs/content/docs/get-started/add-a-machine.mdx b/apps/docs/content/docs/get-started/add-a-machine.mdx index c784fd8f5..5157260ea 100644 --- a/apps/docs/content/docs/get-started/add-a-machine.mdx +++ b/apps/docs/content/docs/get-started/add-a-machine.mdx @@ -52,6 +52,7 @@ If the machine has no terminal, the dialog's reveal switches from **Terminal** t - A node started by hand with `subshell run` dies when you close the SSH session: that is the foreground daemon. Install the background service, as step 3 describes. - Re-enrolling an already enrolled machine is destructive: a second enrollment overwrites the node's saved configuration, registers a second node row, and discards the current node key. Reaching a plane that moved to a different address is repointing, which keeps the machine's identity ([Manage a node](/nodes/manage)). - Two fleet decisions are worth making deliberately: [who may launch on it](/nodes/sharing), and whether to [restrict its directories](/nodes/directory-allowlist). +- On a Proxmox VE host the target machine may be a plain unprivileged LXC instead of a VM or a bare host: [Install on Proxmox](/server/proxmox) documents a helper (`proxmox-node.sh`) that automates the create-and-enroll shape. ## Next steps diff --git a/apps/docs/content/docs/server/docker.mdx b/apps/docs/content/docs/server/docker.mdx index 073b10670..415f6ecbd 100644 --- a/apps/docs/content/docs/server/docker.mdx +++ b/apps/docs/content/docs/server/docker.mdx @@ -1,45 +1,52 @@ --- title: Run in Docker -description: "Run the control plane in a container: compose setup, volume layout, and what ends when the container restarts." +description: "Run the control plane in the published image: first boot, the data volume, updating by pull, and the compose rail." --- -Run Subshell Server in a Docker container with the compose recipe the repository ships. +Run Subshell Server from the published image, or with the compose file the repository ships. -The recipe is a multi-stage build ending in a slim image with the built SPA and tmux, plus a compose file that wires it to your disk. The [compose file](https://github.com/subshell-ai/subshell/blob/main/docker-compose.yaml) is the source of truth for the mounts. This page explains what each mount is for and what this deployment shape costs you. +## The image -## Before you start +The image is packaging, not compiling. It carries the published, signature-verified `cli-server` release binary, the same artifact the install one-liner installs, on `debian:trixie-slim` together with tmux, Node 22, and the five harness CLIs (`claude`, `codex`, `opencode`, `hermes`, `pi`), so panes work on a fresh install. `subshell-server version` answers the same inside the container as outside it, and the server listens on port 3080 on all interfaces. The price is size: the image is roughly 6.5 GB, and the harness CLIs and the Node runtime they run under are most of it. -Two prerequisites the recipe cannot work around: +## First run -- **`BETTER_AUTH_SECRET` is hard-required.** The compose file refuses to start without it (`NODE_ENV=production`, and production will not sign sessions with the public placeholder). It lives in `.env` beside the compose file; set `APP_BASE_URL` there too. Put the same value here as the old instance used and existing browser sessions survive the move. -- **`docker/gitconfig` must exist before `up`.** It is a bind-mount source, and Docker silently creates a missing source as an empty directory. Copy the example and edit it: your commit identity, and ssh-format signing uses the `ssh` binary in the image. The host's `~/.gitconfig` is deliberately not used. An external signing program configured there does not exist inside the image and would break commits. +Everything persistent lives under `/data`, so the container needs exactly one volume: -## Bring it up +```bash +docker run -d --name subshell -p 3080:3080 -v subshell-data:/data ghcr.io/subshell-ai/subshell:latest +``` -1. Prepare the files and build: +No environment is required. On a fresh volume the entrypoint runs `subshell-server init` and mints a unique `BETTER_AUTH_SECRET` into `/data/config.env` at `0600`, so each install signs its own sessions, and the file stays on the volume through every later update. The image sets `NODE_ENV=production`, where a boot would refuse to run on the public placeholder secret; the per-install minting is what a fresh container does instead. Pass `APP_BASE_URL` when people open the instance at a URL other than the host address, as covered on [Networking and addresses](/server/networking). Then open the address and create the admin account. - ```bash - cp .env.example .env # set BETTER_AUTH_SECRET (>= 32 chars) + APP_BASE_URL - cp docker/gitconfig.example docker/gitconfig # your git identity + signing key - mkdir -p ~/.config/subshell-server - docker compose build - docker compose up -d - ``` +> [!warning] Create the admin account promptly +> The setup endpoints are public until the first account exists, and that is what lets you create it. On a LAN, the first browser through becomes the admin. The window closes when the first account exists. - The `mkdir` is there for the same reason as the gitconfig: the data directory is a bind-mount source too. +## Updating -2. Open `http://localhost:3080` and create the admin account. +The image is the unit of update. Pull the new image and recreate the container; the published tags are `latest` and the version of each server release: -> [!warning] Create the admin account promptly -> The setup endpoints are public until the first account exists, and that is what lets you create it. On a LAN, the first browser through becomes the admin. The window closes when the first account exists. +```bash +docker pull ghcr.io/subshell-ai/subshell:latest +docker rm -f subshell +docker run -d --name subshell -p 3080:3080 -v subshell-data:/data ghcr.io/subshell-ai/subshell:latest +``` + +The dashboard's update refuses on a containerized install and says exactly this: the image is the unit of update, pull and recreate. `subshell-server update --check` inside the container prints the same answer, and [Update the server](/server/updating) carries the full story of why. + +What survives the recreate follows the volume: the database, browser sessions, settings, and the minted secret continue unchanged. Running subshells do not: the container owns its tmux server, and tmux state is process state, so a recreate ends every running pane. The rows surface as dead and you restart them from the UI. On a Proxmox host the same pull-and-recreate runs with a rollback if the new container fails its health check, on [Install on Proxmox](/server/proxmox). + +## The compose rail -## What the container sees +[docker-compose.yaml](https://github.com/subshell-ai/subshell/blob/main/docker-compose.yaml) pulls this same image, so `docker compose up -d` gives the identical server with your host's files wired in. The file's header comment describes building the image locally from a staged release binary instead. One file must exist before the first up: `docker/gitconfig`. It is a bind-mount source, and Docker silently creates a missing source as an empty directory. Copy the example and set your commit identity; ssh-format signing uses the `ssh` binary in the image, and the host's `~/.gitconfig` is deliberately not used because an external signing program configured there does not exist inside the image. + +The harness CLIs are baked into the image, and the compose mounts override them with host binaries and state when that is what you want. The mounts are: | Mount | Host side | Why | |---|---|---| -| `/data` | `~/.config/subshell-server` (`SUBSHELL_DATA_HOST_DIR`) | SQLite, pane logs, channel keypairs: everything that must survive. | +| `/data` | `~/.config/subshell-server` (`SUBSHELL_DATA_HOST_DIR`) | Database, pane logs, channel keypairs, `config.env`: everything that must survive. | | your projects | `~/projects` at its real path (`PROJECTS_DIR`) | Stored working-directory paths in the database resolve unchanged inside the container. | -| `claude` binary | `CLAUDE_BIN`, read-only | The harness CLI is not bundled in the image; the image ships neither Claude Code nor the other agents. Adjust these three mounts for a different harness. | +| `claude` binary | `CLAUDE_BIN`, read-only | Overrides the harness CLI the image bakes; adjust these three mounts for a different harness. | | `~/.claude`, `~/.claude.json` | read-write | Where the harness keeps its session records. | | `~/.ssh` | read-only, plus `docker/ssh-config` over `config` | Git push and signed commits from panes. | | `docker/gitconfig` | read-only | Commit identity, as above. | @@ -50,31 +57,15 @@ Three details that were measured in the field rather than assumed: - **Harness and SSH state mount at the container's home** (`/home/subshell`), not at your host paths. The image pins `HOME` there because the numeric uid has no passwd entry, and panes inherit it. Mounting at the host spelling instead leaves every pane reading an empty directory. - **Any harness pane can read the mounted SSH keys.** This is deliberate on the same trusted-host posture as everything else here, not an oversight. Decide it before enrolling this machine to a shared instance. -## Ports and boot - -`3080:3080` is published, not loopback-bound. Remote access runs through a reverse proxy that connects to this host's reachable IP, and a `127.0.0.1` publish makes every proxied request a 502. Inside, the server binds `0.0.0.0`. `APP_BASE_URL` from `.env` should be the URL people actually open, with any other names they browse on added as trusted origins; a container is no exception to [Networking and addresses](/server/networking). `restart: unless-stopped` brings the service back with the Docker daemon, which assumes the daemon itself is enabled. Firewall and port specifics are on [Ports and firewalls](/reference/ports). - -## What does not survive a restart +`3080:3080` is published, not loopback-bound: remote access runs through a reverse proxy that connects to this host's reachable IP, and a `127.0.0.1` publish makes every proxied request a 502. `APP_BASE_URL` (from `.env`, or `-e` on a plain `docker run`) should be the URL people actually open, with any other names they browse on added as trusted origins; a container is no exception. `restart: unless-stopped` brings the compose service back with the Docker daemon, which assumes the daemon itself is enabled. Firewall and port specifics are on [Ports and firewalls](/reference/ports). -The container owns its tmux server, and tmux state is process state: a container restart ends every running subshell. The database row survives; the pane does not. Running subshells surface as dead rows and you restart them from the UI. Everything persistent (database, logs, keypairs) is on the bind mounts and survives; the live sessions are the cost of this shape. If restarts that spare your panes matter more to you than container discipline, the [Service and autostart](/server/service) deployment is the same binary with that property. +To move an existing host-run instance in, stop the old server first. An instance installed with `subshell-server service install` already keeps its database, its pane logs and its `config.env` inside `~/.config/subshell-server/`, which is the default host side of the `/data` mount, so for that case there is nothing to copy: the compose file's `DATABASE_PATH: /data/subshell.db` lands on the very file the old instance used. Any other layout: copy the database and the pane-log directory into the host directory that will mount at `/data`, and bring the old instance's `config.env` (or its `BETTER_AUTH_SECRET` line) along too; the same secret is what lets existing browser sessions continue on the container, and a supplied value is persisted verbatim. New `ssh` hosts need an `ssh-keyscan` on the host first, since the mounted `known_hosts` is all the container trusts. Two file-level details: after editing `docker/gitconfig`, recreate the container. A single-file bind mount pins the inode, so edits to the file never arrive. Host-specific extras (a `gh` config, say) belong in `docker-compose.override.yaml` beside the base file: it is gitignored and merged automatically by every `docker compose` command. -## Moving a host-run instance in - -Stop the instance holding `:3080`, copy the two things that are state, and keep the secret identical: - -```bash -sqlite3 data/subshell.db ".backup ~/.config/subshell-server/subshell.db" -cp -a data/subshells ~/.config/subshell-server/ -``` - -Both commands assume a from-source instance, whose default layout puts the database at `./data/subshell.db`. An instance installed with `subshell-server service install` already keeps its database and pane logs inside `~/.config/subshell-server/`, the very directory that gets bind mounted, so for it there is nothing to copy. - -With the same `BETTER_AUTH_SECRET` in `.env`, existing browser sessions keep working on the new container. A pane log the container has never seen is just a file: nothing needs to be done about it. New `ssh` hosts need an `ssh-keyscan` on the host first, since the mounted `known_hosts` is all the container trusts. - -## Other pages +## See also +- [Install on Proxmox](/server/proxmox): the helper script that builds this image into an LXC on a Proxmox host - [Networking and addresses](/server/networking): `APP_BASE_URL` and trusted origins for a container behind a proxy - [Service and autostart](/server/service): the non-container deployment, and why its restarts spare the panes - [Environment variables](/reference/environment-variables): every setting the container reads diff --git a/apps/docs/content/docs/server/meta.json b/apps/docs/content/docs/server/meta.json index bb0b06ea1..478b843bc 100644 --- a/apps/docs/content/docs/server/meta.json +++ b/apps/docs/content/docs/server/meta.json @@ -1,4 +1,4 @@ { "title": "Run the Server", - "pages": ["index", "desktop-app", "headless", "docker", "configuration", "networking", "service", "reset", "users", "sign-in-providers", "registration", "backups", "updating", "logs", "audit-log", "network-plugins"] + "pages": ["index", "desktop-app", "headless", "docker", "proxmox", "configuration", "networking", "service", "reset", "users", "sign-in-providers", "registration", "backups", "updating", "logs", "audit-log", "network-plugins"] } diff --git a/apps/docs/content/docs/server/proxmox.mdx b/apps/docs/content/docs/server/proxmox.mdx new file mode 100644 index 000000000..5e712a18e --- /dev/null +++ b/apps/docs/content/docs/server/proxmox.mdx @@ -0,0 +1,60 @@ +--- +title: Install on Proxmox +description: "Create an unprivileged LXC running Subshell Server on a Proxmox VE host with the helper script, and update it by image pull." +--- + +One command on the Proxmox host creates an unprivileged LXC container with Docker inside it and runs the official Subshell image. + +The helper script follows the Proxmox community-scripts convention: it runs on the host as root, asks its questions in the terminal, and offers the standard verbs `install`, `update`, `remove`, `backup`, and `restore`. The script at the repository root ([proxmox-server.sh](https://github.com/subshell-ai/subshell/blob/main/proxmox-server.sh)) is the source; the site serves the same file. + +## Install + +```bash +curl -fsSL https://subshell.sh/proxmox-server.sh | bash +``` + +With no argument the script opens a menu; with the file saved on the host, `bash proxmox-server.sh install` runs the install directly. The prompts ask for the container ID (the next free one is suggested), the hostname, cores (default 1), memory in MB (default 2048), disk in GB (default 8), the network bridge (default `vmbr0`), and the port the web UI maps on the container (default 3080). Set `PVE_NO_PROMPT=1` to take every default for an unattended run. + +The script fetches the newest Debian 13 standard template if the host does not already have one, creates the container unprivileged with the `nesting=1` feature, copies in the host's ed25519 or rsa public key when one exists, and installs Docker inside the container through the vendor script. The container's root password is generated on the way and printed once at install; note it down. + +## First run + +When the install finishes it prints an address like `http://10.0.0.5:3080`. Open it and register the first account, which becomes the admin. The same window that exists on any fresh instance applies here: the setup endpoints stay public until the first account exists, so claim it before opening the address to a wider network. + +## Updating + +```bash +bash /root/proxmox-server.sh update +``` + +A successful install saves the script to `/root/proxmox-server.sh` for exactly this, because a `curl | bash` install leaves no file on the host. A copy you kept works the same (`bash proxmox-server.sh update`), and so does re-running the one-liner with the verb: `curl -fsSL https://subshell.sh/proxmox-server.sh | bash -s -- update`. An update re-pulls the tag the install recorded; to change it, edit the `IMAGE=` line in `/etc/default/subshell-docker` inside the LXC and run the update, or reinstall with a new pin. + +The script asks which container to update, pulls the new image inside it, renames the old container aside, and starts the new one. If the new container does not answer the server's setup status within two minutes, the old one is restored and the failure is reported; a pull that fails changes nothing at all. Data survives every update, running panes do not: the Subshell container owns its tmux server, and recreating it ends that process. [Run in Docker](/server/docker) covers the same shape in the plain-container spelling, and the dashboard's update card on a containerized install names this remedy. + +## What lives where + +The data sits at `/var/lib/subshell` inside the LXC, mounted into the Subshell container as `/data`: the database, `config.env` with its minted sign-in secret, plugins, and backups. An update replaces only the image and the container; nothing in that directory is touched. The image itself is disposable. + +`bash proxmox-server.sh backup` takes a whole-container snapshot with `vzdump` (snapshot mode, zstd compression). Restore is Proxmox's own flow, Datacenter → Backup → the saved snapshot; because a backup is a full-CT snapshot, it brings back the container, its Docker, and the data directory as one. `bash proxmox-server.sh remove` destroys the container after a typed confirmation, data included. An unattended run with `PVE_NO_PROMPT=1` skips that confirmation. + +## Security note + +Docker inside an LXC needs the `nesting=1` container feature; the container stays unprivileged, and the instance follows the same trusted-network posture as any other deployment, described on the [security model](/concepts/security-model). + +## As a node + +The container above is the control plane, and it runs panes on its own host: the server hosts subshells through its in-process `local` node, no agent involved. A second machine - or a second LXC - that runs panes for your instance enrolls as a node, and [add-a-machine](/get-started/add-a-machine) is the path. + +On Proxmox the shape is a plain unprivileged Debian 13 container (no Docker, no nesting) running that same install command inside, and `proxmox-node.sh` automates exactly that. Run it on the Proxmox host as root: + +```bash +curl -fsSL https://subshell.sh/proxmox-node.sh | bash +``` + +Paste your instance's install URL once at install, and the container enrolls, names itself after the CT hostname, and runs the agent as its `subshell` user. Updating the agent is the product's own rail - the in-app node update, or `bash /root/proxmox-node.sh update`. Destroying the CT does not remove the node from the instance: [delete it in the app](/nodes/manage) as well. + +## See also + +- [Run in Docker](/server/docker): the image, the volume, and the compose rail +- [Update the server](/server/updating): the transaction other shapes use, and why a containerized install points at the pull instead +- [Backups](/server/backups): the instance's own database snapshots diff --git a/apps/docs/content/docs/server/updating.mdx b/apps/docs/content/docs/server/updating.mdx index 0a9890324..859b38b48 100644 --- a/apps/docs/content/docs/server/updating.mdx +++ b/apps/docs/content/docs/server/updating.mdx @@ -51,6 +51,10 @@ The whole verb is ten steps, and nine of them are refusals: a checkout-run serve - Audited twice: once when you press it, with you as the actor, and once at the completing boot with no human actor: the row records the new binary's boot, not a person. A download that dies mid-flight still leaves the first half. - It answers 202 and a job, because the last thing the job does is exit the process serving the request. The page watches the phases and then waits for the server to come back. And there is a refusal for exactly the host this fails on: with no service manager to respawn the process, the swap would leave an old server running beside a new file, so the route says no rather than exit into nothing. +## Inside a container + +A server running in the Subshell Docker image updates by pull, not by self-swap: the image is the unit of update. The dashboard refuses there with exactly that spelled out, and `subshell-server update --check` inside the container prints the same answer, that updates happen by pulling a new image rather than with that verb. A Proxmox helper install automates the pair: `bash proxmox-server.sh update` on the Proxmox host pulls, recreates, and rolls the old container back if the new one fails its health check ([Install on Proxmox](/server/proxmox)). + ## What the trust actually is, and the air gap The plane downloads and executes code from the release source, but it no longer has to believe it. Every release carries `release-manifest.json` and a detached minisign signature over its exact bytes (`release-manifest.json.sig`), made with the publisher keypair, the same one the desktop apps' updater uses, so one key guards all four components. Every update path verifies that signature against a public key compiled into the product, and the digest your bytes are compared against comes from that signed manifest's `assets` map, never from the `.sha256` sidecar the release host serves beside the file. diff --git a/apps/server/api/src/api/admin-server/__tests__/get-server.route.test.ts b/apps/server/api/src/api/admin-server/__tests__/get-server.route.test.ts index 8eed34a66..159eac57c 100644 --- a/apps/server/api/src/api/admin-server/__tests__/get-server.route.test.ts +++ b/apps/server/api/src/api/admin-server/__tests__/get-server.route.test.ts @@ -21,6 +21,7 @@ const VIEW_KEYS = [ "paths", "service", "restart", + "containerized", "logging", "tmuxPath", "mcp", diff --git a/apps/server/api/src/api/admin-server/__tests__/restart.route.test.ts b/apps/server/api/src/api/admin-server/__tests__/restart.route.test.ts index 483e1bf07..f2e46382b 100644 --- a/apps/server/api/src/api/admin-server/__tests__/restart.route.test.ts +++ b/apps/server/api/src/api/admin-server/__tests__/restart.route.test.ts @@ -5,7 +5,7 @@ import { restartSeams } from "@/api/admin-server/restart.route.js"; import { db } from "@/db/index.js"; import { AuditRepository } from "@/db/repositories/audit.repository.js"; import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; -import type { DeploymentView } from "@/services/server-deployment.js"; +import { type DeploymentView, RESTART_CONTAINERIZED_REASON } from "@/services/server-deployment.js"; import { authedRequest } from "../../__tests__/helpers/auth-tables.js"; import { type AdminServerFixture, bearerRequest, setupAdminServerFixture } from "./fixture.js"; @@ -100,6 +100,20 @@ describe("POST /api/admin/server/restart", () => { expect(events.some((e) => e.action === "server.restart")).toBe(true); }); + it("the unsupervised refusal NAMES the image-pull remedy when the container marker is set", async () => { + restartSeams.deployment = () => ({ + ...viewWith({ supervised: false, paneSafety: "keeps" }), + containerized: true, + restart: { available: false, reason: RESTART_CONTAINERIZED_REASON }, + }); + const res = await app.fetch(post(fx.adminCookie, {})); + expect(res.status).toBe(409); + const body = (await res.json()) as { code: string; message: string }; + expect(body.code).toBe("RESTART_UNAVAILABLE"); + expect(body.message).toContain("proxmox-server.sh update"); + expect(performed).toBe(0); + }); + it("bearer 403, non-admin 403, and neither restarts anything", async () => { restartSeams.deployment = () => viewWith({ supervised: true, paneSafety: "keeps" }); const asBearer = await app.fetch( diff --git a/apps/server/api/src/api/admin-server/__tests__/update.route.test.ts b/apps/server/api/src/api/admin-server/__tests__/update.route.test.ts index f6091eac8..b1ed7031a 100644 --- a/apps/server/api/src/api/admin-server/__tests__/update.route.test.ts +++ b/apps/server/api/src/api/admin-server/__tests__/update.route.test.ts @@ -10,7 +10,7 @@ import { AuditRepository } from "@/db/repositories/audit.repository.js"; import { errorHandlerPlugin } from "@/plugins/error-handler.plugin.js"; import type { CliReleaseCheck, ResolvedRelease } from "@/services/releases.js"; import { setReleaseUrlForTests } from "@/services/releases.js"; -import type { DeploymentView } from "@/services/server-deployment.js"; +import { type DeploymentView, RESTART_CONTAINERIZED_REASON } from "@/services/server-deployment.js"; import { resetUpdateJobForTests } from "@/services/server-update.js"; import { clearPending, updateDir } from "@/services/update-transaction.js"; import { SERVER_VERSION } from "@/version.js"; @@ -141,6 +141,19 @@ describe("POST /api/admin/server/update", () => { expect(started).toEqual([]); }); + it("409 UPDATE_CONTAINERIZED when the image marker says the container is the unit of update", async () => { + updateSeams.deployment = () => ({ + ...viewWith({ supervised: false, paneSafety: "keeps" }), + containerized: true, + restart: { available: false, reason: RESTART_CONTAINERIZED_REASON }, + }); + const res = await app.fetch(post(fx.adminCookie, {})); + expect(await code(res)).toBe("UPDATE_CONTAINERIZED"); + expect(started).toEqual([]); + }); + // (The route's message flows from restart.reason - Task 1 set that string; + // this test pins the CODE swap.) + it("409 UPDATE_BINARY_UNKNOWN for a checkout and for an unwritable path", async () => { updateSeams.installed = () => ({ kind: "source", diff --git a/apps/server/api/src/api/admin-server/schemas.ts b/apps/server/api/src/api/admin-server/schemas.ts index 066c4745c..293ab41c9 100644 --- a/apps/server/api/src/api/admin-server/schemas.ts +++ b/apps/server/api/src/api/admin-server/schemas.ts @@ -81,6 +81,9 @@ export const DeploymentViewSchema = t.Object({ available: t.Boolean({ description: "Whether POST /api/admin/server/restart would work" }), reason: t.Nullable(t.String(), { description: "Why not, when it would not" }), }), + containerized: t.Boolean({ + description: "True when this process runs inside the Subshell release container (SUBSHELL_CONTAINER=1)", + }), logging: t.Object({ debug: t.Boolean({ description: "Whether debug logging (and with it HTTP request logging) is on" }), source: t.Union([t.Literal("process env"), t.Literal("setting"), t.Literal("default")], { diff --git a/apps/server/api/src/api/admin-server/update.route.ts b/apps/server/api/src/api/admin-server/update.route.ts index 670e00172..f264b18bd 100644 --- a/apps/server/api/src/api/admin-server/update.route.ts +++ b/apps/server/api/src/api/admin-server/update.route.ts @@ -110,7 +110,12 @@ export const updateRoute = new Elysia() return status( 409, apiErrorBody({ - code: BackendErrorCodes.RESTART_UNAVAILABLE, + // Inside the image the swap itself is the wrong act, not just the + // missing restart; the container's name replaces the manager's + // absence (spec 2026-09-28 § 6). + code: deployment.containerized + ? BackendErrorCodes.UPDATE_CONTAINERIZED + : BackendErrorCodes.RESTART_UNAVAILABLE, message: deployment.restart.reason ?? "This server is not running under a service manager", }), ); diff --git a/apps/server/api/src/commands/__tests__/update.test.ts b/apps/server/api/src/commands/__tests__/update.test.ts index 8dacc10d7..5b00d6033 100644 --- a/apps/server/api/src/commands/__tests__/update.test.ts +++ b/apps/server/api/src/commands/__tests__/update.test.ts @@ -237,6 +237,40 @@ describe("update --check", () => { expect(await run({ from: incoming, check: true })).toBe(0); expect(logs.join("\n")).toContain("9.9.9 is available"); }); + + it("marks check output containerized when the image marker is set", async () => { + process.env.SUBSHELL_CONTAINER = "1"; + try { + expect(await run({ from: incoming, check: true, json: true })).toBe(0); + expect(JSON.parse(logs[0] ?? "{}")).toEqual({ + installed: SERVER_VERSION, + latest: "9.9.9", + updateAvailable: true, + containerized: true, + }); + } finally { + delete process.env.SUBSHELL_CONTAINER; + } + }); + + it("names the container remedy when a locate refusal lands non-json inside the image", async () => { + // The prose form must be as honest as the json one: in the image the + // binary's directory is not writable, locate refuses, and the refusal + // alone would never name the pull-and-recreate that IS the update. + process.env.SUBSHELL_CONTAINER = "1"; + try { + expect( + await run( + { check: true }, + { installed: () => ({ kind: "compiled", path: "/nowhere/subshell-server", source: "this process" }) }, + ), + ).toBe(1); + expect(errors.join("\n")).toMatch(/cannot replace/); + expect(errors.join("\n")).toContain("updates happen by pulling a new image"); + } finally { + delete process.env.SUBSHELL_CONTAINER; + } + }); }); describe("update --rollback", () => { diff --git a/apps/server/api/src/commands/update.ts b/apps/server/api/src/commands/update.ts index dca75e4dd..90575b79e 100644 --- a/apps/server/api/src/commands/update.ts +++ b/apps/server/api/src/commands/update.ts @@ -37,6 +37,7 @@ import { releaseSourceUrl, signedAssetDigest, } from "@/services/releases.js"; +import { isContainerized } from "@/services/server-deployment.js"; import { beginUpdate, clearPending, @@ -127,7 +128,9 @@ function defaultProbeVersion(file: string): string | null { * Run the verb. * * @returns the process exit code — 0 for a completed (or already-current) - * update, 1 for every refusal and every failure + * update, 1 for every refusal and every failure, EXCEPT the two `--check + * --json` refusal paths (locate and release pick), which exit 0 with the + * refusal carried as `reason` in the JSON: a check reports, it does not fail. */ export async function runUpdate(opts: UpdateOpts, deps: UpdateDeps): Promise { return opts.rollback ? runRollback(opts, deps) : runInstall(opts, deps); @@ -182,15 +185,38 @@ export interface UpdateCheck { updateAvailable: boolean; /** Why `latest` is null; absent when it is not. */ reason?: string; + /** Present-when-true: this binary lives in an image and updates by pull, not swap (spec 2026-09-28 § 6). */ + containerized?: true; } +/** The marker every --check JSON gains inside the image; absent elsewhere (no key churn outside). */ +const CONTAINER_FLAG = (): { containerized?: true } => (isContainerized() ? { containerized: true as const } : {}); + async function runInstall(opts: UpdateOpts, deps: UpdateDeps): Promise { const { log, error } = deps; // 1. Where is the installed binary. const located = locate(deps); if ("refusal" in located) { + if (opts.check && opts.json) { + log( + JSON.stringify({ + installed: SERVER_VERSION, + latest: null, + updateAvailable: false, + reason: located.refusal, + ...CONTAINER_FLAG(), + } satisfies UpdateCheck), + ); + return 0; + } error(`subshell-server: ${located.refusal}`); + // Inside the image the locate ALWAYS refuses (the binary's directory is + // not writable), and the refusal alone never names the remedy. The json + // branch carries `containerized`; the prose branch must be as honest. + if (isContainerized()) { + error("This install runs in a container; updates happen by pulling a new image, not with this verb."); + } return 1; } const { path: binary, dir: binaryDir } = located; @@ -224,7 +250,13 @@ async function runInstall(opts: UpdateOpts, deps: UpdateDeps): Promise { if ("refusal" in picked) { if (opts.check && opts.json) { log( - JSON.stringify({ installed: SERVER_VERSION, latest: null, updateAvailable: false, reason: picked.refusal }), + JSON.stringify({ + installed: SERVER_VERSION, + latest: null, + updateAvailable: false, + reason: picked.refusal, + ...CONTAINER_FLAG(), + } satisfies UpdateCheck), ); return 0; } @@ -268,11 +300,17 @@ async function runInstall(opts: UpdateOpts, deps: UpdateDeps): Promise { installed: SERVER_VERSION, latest: target.version, updateAvailable: semverLt(SERVER_VERSION, target.version), + ...CONTAINER_FLAG(), }; if (opts.check) { if (opts.json) log(JSON.stringify(check)); - else if (check.updateAvailable) log(`${target.version} is available. Running ${SERVER_VERSION}.`); - else log(`Running ${SERVER_VERSION}, the newest release.`); + else { + if (check.updateAvailable) log(`${target.version} is available. Running ${SERVER_VERSION}.`); + else log(`Running ${SERVER_VERSION}, the newest release.`); + if (isContainerized()) { + log("This install runs in a container; updates happen by pulling a new image, not with this verb."); + } + } return 0; } if (target.version === SERVER_VERSION) { diff --git a/apps/server/api/src/services/__tests__/server-deployment.test.ts b/apps/server/api/src/services/__tests__/server-deployment.test.ts index 4ce36c38d..776bbac8c 100644 --- a/apps/server/api/src/services/__tests__/server-deployment.test.ts +++ b/apps/server/api/src/services/__tests__/server-deployment.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { appSupervised, collectDeployment, isSupervised, settingSource } from "@/services/server-deployment.js"; +import { + appSupervised, + collectDeployment, + isContainerized, + isSupervised, + settingSource, +} from "@/services/server-deployment.js"; import { originRegistry } from "@/services/trusted-origins.js"; describe("isSupervised", () => { @@ -98,6 +104,26 @@ describe("collectDeployment", () => { expect(view.restart.reason).toContain("service manager"); }); + it("names the image as the unit of update when the container marker is set (spec 2026-09-28 § 6)", () => { + const view = collectDeployment({ + platform: "linux", + pid: 1, + env: { SUBSHELL_CONTAINER: "1" }, + applied: new Set(), + queryService: () => service as never, + }); + expect(view.containerized).toBe(true); + expect(view.restart.available).toBe(false); + expect(view.restart.reason).toContain("container"); + expect(view.restart.reason).toContain("proxmox-server.sh update"); + }); + + it("reads the container fact from the marker only, not from anything else in the env", () => { + expect(isContainerized({})).toBe(false); + expect(isContainerized({ SUBSHELL_CONTAINER: "true" })).toBe(false); + expect(isContainerized({ SUBSHELL_CONTAINER: "1" })).toBe(true); + }); + it("carries no secret in any form", () => { const view = collectDeployment({ platform: "linux", applied: new Set(), queryService: () => service as never }); expect(["set", "missing"]).toContain(view.authSecret.state); diff --git a/apps/server/api/src/services/__tests__/server-update.test.ts b/apps/server/api/src/services/__tests__/server-update.test.ts index 5879b198f..05fb95e46 100644 --- a/apps/server/api/src/services/__tests__/server-update.test.ts +++ b/apps/server/api/src/services/__tests__/server-update.test.ts @@ -13,6 +13,7 @@ import { backupsDir } from "@/services/db-backup.js"; import { readView, resetForTests } from "@/services/nodes/update-tracker.js"; import type { ResolvedRelease } from "@/services/releases.js"; import { releaseSeams, resetReleaseCacheForTests, setReleaseUrlForTests } from "@/services/releases.js"; +import { resetDeploymentCache } from "@/services/server-deployment.js"; import { collectServerUpdateView, currentUpdateJob, @@ -494,6 +495,21 @@ describe("collectServerUpdateView", () => { expect(view.canApply.reasons).toContain("an update is already in progress"); }); + it("lists the image-pull remedy among the blockers inside a container (spec 2026-09-28 § 6)", async () => { + process.env.SUBSHELL_CONTAINER = "1"; + resetDeploymentCache(); + try { + const view = await collectServerUpdateView(true); + expect(view.canApply.ok).toBe(false); + expect(view.canApply.reasons.some((r) => r.includes("container") && r.includes("proxmox-server.sh update"))).toBe( + true, + ); + } finally { + delete process.env.SUBSHELL_CONTAINER; + resetDeploymentCache(); + } + }); + /** * The card's whole claim about backups is "the newest one is from THEN", and * it is the sentence an operator reads before pressing a button that diff --git a/apps/server/api/src/services/server-deployment.ts b/apps/server/api/src/services/server-deployment.ts index 58f407a86..3046cbf77 100644 --- a/apps/server/api/src/services/server-deployment.ts +++ b/apps/server/api/src/services/server-deployment.ts @@ -99,6 +99,8 @@ export interface DeploymentView { service: DeploymentService; /** Whether `POST /api/admin/server/restart` would work, and why not when it would not. */ restart: { available: boolean; reason: string | null }; + /** True when this process runs inside the Subshell release image (`SUBSHELL_CONTAINER=1`). */ + containerized: boolean; /** § 3.4: the effective debug state, where it comes from, and the file it writes. */ logging: DeploymentLogging; /** Absolute path to tmux, or null when it is not on PATH. */ @@ -191,6 +193,15 @@ export function settingSource( const RESTART_UNSUPERVISED_REASON = "This server is not running under a service manager; restart it where you started it."; +/** + * Why the image, not this process, is the unit of update (spec 2026-09-28 § 6). + * + * Exported so the route tests build their fixtures FROM the string the route + * produces: a hand-copied reason drifts and the test passes silently. + */ +export const RESTART_CONTAINERIZED_REASON = + "This server runs inside a container, where the image is the unit of update. Pull a new image and recreate the container; from the Proxmox helper install, that is: bash proxmox-server.sh update on the host."; + /** * The value THIS process runs with, per key: the boot-time constant for the * four keys really read at boot, and the LIVE registry's operator list for @@ -255,6 +266,19 @@ export const SUPERVISOR_LOG_ENV = "SUBSHELL_SUPERVISOR_LOG"; /** The only value of {@link SUPERVISOR_ENV} this server recognises. */ export const DESKTOP_SUPERVISOR = "subshell-desktop-server"; +/** + * The Subshell release image bakes this (spec 2026-09-28 § 3). It is the + * server's only containerization signal: a marker the image sets, not a + * guessed heuristic, because the fact it gates ("is the image the unit of + * update?") is true exactly where our image said it was. + */ +export const CONTAINER_ENV = "SUBSHELL_CONTAINER"; + +/** Whether this process runs inside the Subshell release image. */ +export function isContainerized(env: NodeJS.ProcessEnv = process.env): boolean { + return env[CONTAINER_ENV] === "1"; +} + /** * Whether the Subshell Server app is running this process — a claim in the * environment, VERIFIED against our actual parent. @@ -383,6 +407,7 @@ function buildDeployment(deps: DeploymentDeps): DeploymentView { const manager = byApp ? "app" : platform === "darwin" ? "launchd" : platform === "linux" ? "systemd" : null; /** Everything that does not depend on WHO supervises this process. */ const base = { + containerized: isContainerized(env), configEnv: status.configEnv, settings, restartRequired: DEPLOYMENT_SETTING_KEYS.some((key) => settings[key].saved !== settings[key].running), @@ -445,6 +470,9 @@ function buildDeployment(deps: DeploymentDeps): DeploymentView { logHint: manager === "systemd" ? `journalctl --user -u ${SYSTEMD_UNIT_NAME} -f` : null, supervised, }, - restart: { available: supervised, reason: supervised ? null : RESTART_UNSUPERVISED_REASON }, + restart: { + available: supervised, + reason: supervised ? null : isContainerized(env) ? RESTART_CONTAINERIZED_REASON : RESTART_UNSUPERVISED_REASON, + }, }; } diff --git a/apps/website/.gitignore b/apps/website/.gitignore index 1f75c25ee..b9d9094cf 100644 --- a/apps/website/.gitignore +++ b/apps/website/.gitignore @@ -6,3 +6,5 @@ out/ # the repo root is the source. public/install-server.sh public/install-client.sh +public/proxmox-server.sh +public/proxmox-node.sh diff --git a/apps/website/scripts/prepare-data.ts b/apps/website/scripts/prepare-data.ts index 3e17b0be0..3e8b761cd 100644 --- a/apps/website/scripts/prepare-data.ts +++ b/apps/website/scripts/prepare-data.ts @@ -29,7 +29,7 @@ console.log("data/releases.json refreshed from the repo root"); const publicDir = join(root, "apps", "website", "public"); mkdirSync(publicDir, { recursive: true }); -for (const script of ["install-server.sh", "install-client.sh"]) { +for (const script of ["install-server.sh", "install-client.sh", "proxmox-server.sh", "proxmox-node.sh"]) { copyFileSync(join(root, script), join(publicDir, script)); console.log(`public/${script} refreshed from the repo root`); } diff --git a/docker-compose.yaml b/docker-compose.yaml index a19cefd49..8a82671dc 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,9 +1,9 @@ services: subshell: - build: - context: . - dockerfile: Dockerfile - image: subshell:latest + # The published release image (spec 2026-09-28). To build from a staged + # binary instead: mkdir -p docker/bin, drop subshell-server- there, + # and `docker build -t subshell:local .` + image: ghcr.io/subshell-ai/subshell:latest container_name: subshell # Run as the host user so bind mounts stay uid-aligned (SQLite, session # logs and harness file edits all keep sane ownership without fixups). @@ -20,10 +20,10 @@ services: # Session working dirs, mounted at their REAL host path so bookmarks, # recent paths and session paths stored in the DB resolve unchanged. - ${PROJECTS_DIR:-$HOME/projects}:${PROJECTS_DIR:-$HOME/projects}:rw - # The harness binary is not bundled in the image; mount the host's - # claude CLI and its state (claude writes session records into - # ~/.claude, so these are rw — same-uid sharing matches running the - # CLI natively). + # The image now bakes harness CLIs; these mounts OVERRIDE them with the + # host's binaries/state when that is what you want. The baked CLIs live + # under uid 1000's home: running as a different host UID, mount your own + # CLIs the way CLAUDE_BIN below does. # # DESTINATIONS ARE THE CONTAINER'S HOME (/home/subshell), not the host's: # the Dockerfile pins HOME=/home/subshell (the runtime uid has no passwd @@ -64,10 +64,13 @@ services: # Auth cookies/redirects; this origin is trusted automatically. Set it # to the URL users actually open (behind a TLS-terminating proxy). APP_BASE_URL: ${APP_BASE_URL:-http://localhost:3080} - # Required: the signing secret for session cookies. Put the SAME value - # as the old instance used in the .env beside this file and existing - # browser sessions survive the move. (Prod refuses the placeholder.) - BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in .env (>= 32 chars)} + # No secret needed: first boot mints a unique one into + # $SUBSHELL_SERVER_CONFIG_DIR/config.env on the data volume. To pin one + # explicitly (moving an existing instance's sessions), uncomment the next + # line - init persists a supplied value verbatim - and set it in .env. + # At boot the env value takes precedence over the file, and an existing + # config.env is kept as-is: + # BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?set it in .env or delete this line} NODE_ENV: production # The service comes back with the Docker daemon — i.e. on boot. restart: unless-stopped diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 000000000..f3f20608b --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# First boot provisions config.env - and with it a unique BETTER_AUTH_SECRET +# (32 random bytes, 0600) - through the SAME init rail the install one-liner +# hands off to. setsid is what makes "no matter how the container was started" +# true: since 2026-09-26 init reads /dev/tty when stdin is /dev/null, so +# `docker run -it` alone would still prompt. Detaching the session makes +# /dev/tty answer ENXIO and init takes its skip branches on every spelling +# (setsid ships in util-linux, essential on trixie). The volume then keeps the +# file forever, so updates (which replace the container, never the volume) +# never rotate the secret. (spec 2026-09-28 § 3) +set -euo pipefail +if [ ! -f "${SUBSHELL_SERVER_CONFIG_DIR:-/data}/config.env" ]; then + setsid -w subshell-server init < /dev/null +fi +exec subshell-server "$@" diff --git a/docs/superpowers/plans/2026-09-28-docker-proxmox-lxc.md b/docs/superpowers/plans/2026-09-28-docker-proxmox-lxc.md new file mode 100644 index 000000000..570707296 --- /dev/null +++ b/docs/superpowers/plans/2026-09-28-docker-proxmox-lxc.md @@ -0,0 +1,1227 @@ +# Docker image + Proxmox LXC install rail - Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a GHCR-published Subshell Server image built from the signed `cli-server` release, plus a Proxmox helper script that installs and updates it inside an unprivileged LXC, with the in-app update rail pointing at the image instead of a self-swap it cannot perform. + +**Architecture:** The image packages (never compiles) the published release binary onto `debian:trixie-slim` with tmux and the five harness CLIs; a first-boot `init` mints the per-install `BETTER_AUTH_SECRET` on the data volume. CI verifies manifest signature and digests before any push. The server learns one new deployment fact (`containerized`, env-marked by the image) which flows through the existing refusal chain so every update surface names the image-pull remedy. + +**Tech Stack:** Docker/buildx/GHCR, GitHub Actions, Bun/TypeScript (Elysia routes, bun:test), bash (helper script, cli-e2e scenario), fumadocs MDX. + +**Spec:** `docs/superpowers/specs/2026-09-28-docker-proxmox-lxc-design.md` (all section refs below are to it). + +## Global Constraints + +- No U+2014 em dash anywhere in prose or shipped strings (`bun run lint:prose` enforces; every string in this plan is already clean). +- UI sentences: at most two, no backticks (they render literally in the plain-text reason lines); explanation lines are `detail` role. +- No dynamic imports; static top-level imports only (`.claude/rules/code-style.md`), and relative `../packages/...src/...js` imports inside root `scripts/` match `scripts/site-releases.ts`. +- All actions in new workflows pinned by full commit SHA with a human-readable `# vX.Y.Z` comment, the repo convention. +- Every `bun test` path must be verified to exist in the run output (bun silently skips typos). +- The compiled linux binaries need glibc >= 2.39; the base image is `debian:trixie-slim` (2.41). No other base is sanctioned. +- Task boundaries run `bun run verify-types && bun run lint:check && bun run lint:prose && bun run test` plus `bunx turbo build` (packages/ changes feed the Eden client); full set runs in Task 9. +- Commit messages: terse, conventional (`feat(...)`, `chore(...)`), author email theo@suteki.nu. + +--- + +### Task 1: The `containerized` deployment fact + +**Files:** +- Modify: `apps/server/api/src/services/server-deployment.ts` (near `SUPERVISOR_ENV` at :250-256; `DeploymentView` at :87-115; `buildDeployment` base block ~:384-402; final return ~:448) +- Modify: `apps/server/api/src/api/admin-server/schemas.ts` (the `restart: t.Object({...})` property at :80-83) +- Test: `apps/server/api/src/services/__tests__/server-deployment.test.ts` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `CONTAINER_ENV = "SUBSHELL_CONTAINER"`, `isContainerized(env?: NodeJS.ProcessEnv): boolean` exported from `@/services/server-deployment.js`; `DeploymentView.containerized: boolean` and a container-aware `restart.reason` string consumed by Tasks 2-3 and by `GET /api/admin/server` (schemas.ts). + +- [ ] **Step 1: Write the failing test** + +Append to `apps/server/api/src/services/__tests__/server-deployment.test.ts` (follow the existing `collectDeployment({...})` fake pattern from the "names the reason when not supervised" test at :90): + +```ts + it("names the image as the unit of update when the container marker is set (spec 2026-09-28 § 6)", () => { + const view = collectDeployment({ + platform: "linux", + pid: 1, + env: { SUBSHELL_CONTAINER: "1" }, + applied: new Set(), + queryService: () => service as never, + }); + expect(view.containerized).toBe(true); + expect(view.restart.available).toBe(false); + expect(view.restart.reason).toContain("container"); + expect(view.restart.reason).toContain("proxmox.sh update"); + }); + + it("reads the container fact from the marker only, not from anything else in the env", () => { + expect(isContainerized({})).toBe(false); + expect(isContainerized({ SUBSHELL_CONTAINER: "true" })).toBe(false); + expect(isContainerized({ SUBSHELL_CONTAINER: "1" })).toBe(true); + }); +``` + +Add `isContainerized` to the test file's import from `@/services/server-deployment.js`. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `bun test apps/server/api/src/services/__tests__/server-deployment.test.ts` +Expected: FAIL, `isContainerized` is not exported / `containerized` is not on the view. + +- [ ] **Step 3: Implement in `server-deployment.ts`** + +Beside the `SUPERVISOR_ENV` constants (~:250): + +```ts +/** + * The Subshell release image bakes this (spec 2026-09-28 § 3). It is the + * server's only containerization signal: a marker the image sets, not a + * guessed heuristic, because the fact it gates ("is the image the unit of + * update?") is true exactly where our image said it was. + */ +export const CONTAINER_ENV = "SUBSHELL_CONTAINER"; + +/** Whether this process runs inside the Subshell release image. */ +export function isContainerized(env: NodeJS.ProcessEnv = process.env): boolean { + return env[CONTAINER_ENV] === "1"; +} +``` + +Beside `RESTART_UNSUPERVISED_REASON` (:191): + +```ts +/** Why the image, not this process, is the unit of update (spec 2026-09-28 § 6). */ +const RESTART_CONTAINERIZED_REASON = + "This server runs inside a container, where the image is the unit of update. Pull a new image and recreate the container; from the Proxmox helper install, that is: bash proxmox.sh update on the host."; +``` + +In `DeploymentView` after the `restart` property (:99): + +```ts + /** True when this process runs inside the Subshell release image (`SUBSHELL_CONTAINER=1`). */ + containerized: boolean; +``` + +In `buildDeployment`, inside the `base` object literal (it holds everything independent of WHO supervises): + +```ts + containerized: isContainerized(env), +``` + +And in the final (non-`byApp`) return, replace the `restart:` line so the container sentence flows into every consumer (the restart route, `collectServerUpdateView`'s `canApply.reasons`, and the update route all read `restart.reason`): + +```ts + restart: { + available: supervised, + reason: supervised ? null : isContainerized(env) ? RESTART_CONTAINERIZED_REASON : RESTART_UNSUPERVISED_REASON, + }, +``` + +In `apps/server/api/src/api/admin-server/schemas.ts`, immediately after the `restart: t.Object({...})` property: + +```ts + containerized: t.Boolean({ + description: "True when this process runs inside the Subshell release container (SUBSHELL_CONTAINER=1)", + }), +``` + +- [ ] **Step 4: Run tests, fix any key-set assertions the new field trips** + +Run: `bun test apps/server/api/src/services/__tests__/server-deployment.test.ts apps/server/api/src/services/__tests__/server-deployment-cache.test.ts apps/server/api/src/api/admin-server/` +If a test asserts the serialized key set of the deployment view, add `"containerized"` to it. + +- [ ] **Step 5: Typecheck and commit** + +```bash +bunx turbo verify-types --filter=@internal/server +git add apps/server/api/src/services/server-deployment.ts apps/server/api/src/api/admin-server/schemas.ts apps/server/api/src/services/__tests__/server-deployment.test.ts +git commit -m "feat(deployment): containerized fact with the image-pull restart reason" +``` + +--- + +### Task 2: `UPDATE_CONTAINERIZED` refusal on the update route + +**Files:** +- Modify: `packages/backend-errors/src/error-codes.ts` (enum beside `UPDATE_BINARY_UNKNOWN`; message table beside the other `UPDATE_*` entries at :346+) +- Modify: `apps/server/api/src/api/admin-server/update.route.ts:108-117` (refusal #2) +- Test: `apps/server/api/src/api/admin-server/__tests__/update.route.test.ts`, `restart.route.test.ts`, `apps/server/api/src/services/__tests__/server-update.test.ts` + +**Interfaces:** +- Consumes: `DeploymentView.containerized` and `restart.reason` (Task 1). +- Produces: `BackendErrorCodes.UPDATE_CONTAINERIZED` (409) rendered by the POST refusals; `canApply.reasons` carries the same sentence for the updates page (flows automatically via `restart.reason`, asserted here). The spec's "Web: the admin update card's containerized state" therefore lands as ZERO SPA DIFF: `server-row.tsx` already renders every `canApply.reasons` entry as a `detail` line and disables the button on `!canApply.ok`; the copy lives server-side, which is why the test for it is a server test. + +- [ ] **Step 1: Failing tests** + +In `update.route.test.ts`, beside the existing `409 RESTART_UNAVAILABLE` test (:137): + +```ts + it("409 UPDATE_CONTAINERIZED when the image marker says the container is the unit of update", async () => { + updateSeams.deployment = () => ({ + ...viewWith({ supervised: false, paneSafety: "keeps" }), + containerized: true, + restart: { available: false, reason: "This server runs inside a container, where the image is the unit of update." }, + }); + const res = await app.fetch(post(fx.adminCookie, {})); + expect(await code(res)).toBe("UPDATE_CONTAINERIZED"); + expect(started).toEqual([]); + }); + // (The route's message flows from restart.reason - Task 1 set that string; + // this test pins the CODE swap.) +``` + +In `restart.route.test.ts`, append inside the main describe (its `viewWith`/`post`/`fx` fixtures are reused): + +```ts + it("the unsupervised refusal NAMES the image-pull remedy when the container marker is set", async () => { + restartSeams.deployment = () => ({ + ...viewWith({ supervised: false, paneSafety: "keeps" }), + containerized: true, + restart: { + available: false, + reason: + "This server runs inside a container, where the image is the unit of update. Pull a new image and recreate the container; from the Proxmox helper install, that is: bash proxmox.sh update on the host.", + }, + }); + const res = await app.fetch(post(fx.adminCookie, {})); + expect(res.status).toBe(409); + const body = (await res.json()) as { code: string; message: string }; + expect(body.code).toBe("RESTART_UNAVAILABLE"); + expect(body.message).toContain("proxmox.sh update"); + expect(performed).toBe(0); + }); +``` + +In `apps/server/api/src/services/__tests__/server-update.test.ts`, inside the `collectServerUpdateView` describe: + +```ts + it("lists the image-pull remedy among the blockers inside a container (spec 2026-09-28 § 6)", async () => { + process.env.SUBSHELL_CONTAINER = "1"; + resetDeploymentCache(); + try { + const view = await collectServerUpdateView(true); + expect(view.canApply.ok).toBe(false); + expect(view.canApply.reasons.some((r) => r.includes("container") && r.includes("proxmox.sh update"))).toBe(true); + } finally { + delete process.env.SUBSHELL_CONTAINER; + resetDeploymentCache(); + } + }); +``` + +Add `resetDeploymentCache` to that file's imports from `@/services/server-deployment.js` (create the import if the file lacks one). + +- [ ] **Step 2: Run them to verify they fail** + +Run: `bun test apps/server/api/src/api/admin-server/__tests__/update.route.test.ts apps/server/api/src/api/admin-server/__tests__/restart.route.test.ts apps/server/api/src/services/__tests__/server-update.test.ts` +Expected: FAIL (unknown code / no such reason). + +- [ ] **Step 3: Implement** + +`packages/backend-errors/src/error-codes.ts`: add to the enum beside the other UPDATE_* members: + +```ts + UPDATE_CONTAINERIZED = "UPDATE_CONTAINERIZED", +``` + +and to the message table beside `UPDATE_SOURCE_DISABLED` (same 409 family): + +```ts + [BackendErrorCodes.UPDATE_CONTAINERIZED]: { + message: "This server runs inside a container; the image is the unit of update", + statusCode: 409, + }, +``` + +`apps/server/api/src/api/admin-server/update.route.ts`, refusal #2 (:108-117) becomes: + +```ts + const deployment = updateSeams.deployment(); + if (!deployment.service.supervised) { + return status( + 409, + apiErrorBody({ + // Inside the image the swap itself is the wrong act, not just the + // missing restart; the container's name replaces the manager's + // absence (spec 2026-09-28 § 6). + code: deployment.containerized ? BackendErrorCodes.UPDATE_CONTAINERIZED : BackendErrorCodes.RESTART_UNAVAILABLE, + message: deployment.restart.reason ?? "This server is not running under a service manager", + }), + ); + } +``` + +The restart route needs no code change: its message already flows from `view.restart.reason` (Task 1 set it). + +- [ ] **Step 4: Run the focused tests to green** + +Run: the Step 2 command. Then `bunx turbo verify-types --filter=` and `bun test packages/backend-errors`. + +- [ ] **Step 5: Commit** + +```bash +git add packages/backend-errors apps/server/api/src/api/admin-server +git commit -m "feat(updates): UPDATE_CONTAINERIZED refusal naming the image-pull remedy" +``` + +--- + +### Task 3: `subshell-server update --check` speaks containerized + +**Files:** +- Modify: `apps/server/api/src/commands/update.ts` (`UpdateCheck` at :178-186; the `locate` refusal in `runInstall` ~:190-196; the `pickRelease`-refusal JSON at :225-230; the success `check` at :267-275) +- Test: `apps/server/api/src/commands/__tests__/update.test.ts` + +**Interfaces:** +- Consumes: `isContainerized` from `@/services/server-deployment.js`. +- Produces: `"containerized":true` in every `--check --json` object emitted by a containerized host (the docker e2e scenario, Task 5, asserts it); a prose line on `--check` without `--json`. + +- [ ] **Step 1: Failing test** + +Append to the `describe("update --check", ...)` block in `update.test.ts` (reuse its `run`, `logs` harness): + +```ts + it("marks check output containerized when the image marker is set", async () => { + process.env.SUBSHELL_CONTAINER = "1"; + try { + expect(await run({ from: incoming, check: true, json: true })).toBe(0); + expect(JSON.parse(logs[0] ?? "{}")).toEqual({ + installed: SERVER_VERSION, + latest: "9.9.9", + updateAvailable: true, + containerized: true, + }); + } finally { + delete process.env.SUBSHELL_CONTAINER; + } + }); +``` + +- [ ] **Step 2: Verify it fails** (`bun test apps/server/api/src/commands/__tests__/update.test.ts`) + +- [ ] **Step 3: Implement** + +Add the static import `import { isContainerized } from "@/services/server-deployment.js";`. Extend the interface: + +```ts +export interface UpdateCheck { + installed: string; + latest: string | null; + updateAvailable: boolean; + /** Why `latest` is null; absent when it is not. */ + reason?: string; + /** Present-when-true: this binary lives in an image and updates by pull, not swap (spec 2026-09-28 § 6). */ + containerized?: true; +} +``` + +Define one helper beside it so all three emission sites share it: + +```ts +/** The marker every --check JSON gains inside the image; absent elsewhere (no key churn outside). */ +const CONTAINER_FLAG = (): { containerized?: true } => (isContainerized() ? { containerized: true as const } : {}); +``` + +Site 1 - the `locate` refusal in `runInstall` currently answers `--check --json` with plain stderr text; give it the refusal-shaped JSON (mirroring the `pickRelease` refusal's existing treatment, exit 0 on the json form, unchanged otherwise): + +```ts + const located = locate(deps); + if ("refusal" in located) { + if (opts.check && opts.json) { + log( + JSON.stringify({ + installed: SERVER_VERSION, + latest: null, + updateAvailable: false, + reason: located.refusal, + ...CONTAINER_FLAG(), + } satisfies UpdateCheck), + ); + return 0; + } + error(`subshell-server: ${located.refusal}`); + return 1; + } +``` + +Site 2 - the existing `pickRelease` refusal JSON (`if (opts.check && opts.json)` at :225): add `...CONTAINER_FLAG(),` inside the stringified object. + +Site 3 - the success path at :267: + +```ts + const check: UpdateCheck = { + installed: SERVER_VERSION, + latest: target.version, + updateAvailable: semverLt(SERVER_VERSION, target.version), + ...CONTAINER_FLAG(), + }; +``` + +And in the non-json `--check` rendering (right after the `log(\`Running ${SERVER_VERSION}...\`)` / "\`.x is available\`" branches), one honest sentence when flagged: + +```ts + if (isContainerized()) { + log("This install runs in a container; updates happen by pulling a new image, not with this verb."); + } +``` + +- [ ] **Step 4: Run to green** (`bun test apps/server/api/src/commands/__tests__/update.test.ts`) + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/api/src/commands/update.ts apps/server/api/src/commands/__tests__/update.test.ts +git commit -m "feat(cli): update --check names the container as the unit of update" +``` + +--- + +### Task 4: The release image (Dockerfile, entrypoint, compose repoint) + +**Files:** +- Replace: `Dockerfile` +- Create: `docker-entrypoint.sh` +- Modify: `docker-compose.yaml` (drop the `build:` block, repoint `image:`, relax `BETTER_AUTH_SECRET`) +- Modify: `.gitignore` (ignore `docker/bin/`) + +**Interfaces:** +- Consumes: `subshell-server init`'s no-TTY branch (init.ts:223-273) and the release asset naming `subshell-server-cli-linux-`. +- Produces: an image with `/usr/local/bin/subshell-server`, baked harnesses on `PATH`, env `SUBSHELL_CONTAINER=1` (Tasks 1-6 read it), and `docker/bin/subshell-server-` as its build-input convention (Task 6 stages it). + +- [ ] **Step 1: Write `docker-entrypoint.sh`** + +```bash +#!/bin/bash +# First boot provisions config.env - and with it a unique BETTER_AUTH_SECRET +# (32 random bytes, 0600) - through the SAME init rail the install one-liner +# hands off to. Stdin is forced off-a-TTY so every question takes its skip +# branch no matter how the container was started; the volume then keeps the +# file forever, so updates (which replace the container, never the volume) +# never rotate the secret. (spec 2026-09-28 § 3) +set -euo pipefail +if [ ! -f "${SUBSHELL_SERVER_CONFIG_DIR:-/data}/config.env" ]; then + subshell-server init < /dev/null +fi +exec subshell-server "$@" +``` + +`chmod +x docker-entrypoint.sh`. + +- [ ] **Step 2: Replace the root `Dockerfile`** + +Full content: + +```docker +# syntax=docker/dockerfile:1 + +# Subshell Server - the release image (spec 2026-09-28 § 3). +# +# PACKAGING, not compiling: docker/bin/subshell-server- is the published, +# signature-verified cli-server release binary, staged by docker-image.yml +# (scripts/docker-release-verify.ts refuses bad bytes). This image therefore +# carries the same artifact the install one-liner installs, and +# `subshell-server version` answers identically inside Docker and outside it. +# +# debian:trixie-slim: the Linux release floor is glibc 2.39 (the ubuntu-24.04 +# build shards) and trixie carries 2.41; trixie is also what the Proxmox +# helper script's LXCs run, so host and container agree on the distro. +# +# The five harness CLIs are baked with the vendor one-liners the product's own +# install rails offer. Their versions float with build day; refreshing one +# inside a RUNNING container works through the admin UI's agent-install route +# without an image rebuild. + +FROM debian:trixie-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends tmux git openssh-client ca-certificates curl unzip \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd --create-home --uid 1000 --shell /bin/bash subshell \ + && mkdir -p /data \ + && chown subshell:subshell /data \ + # 777 so a bind mount whose host dir arrives root-owned (a fresh + # /var/lib/subshell, say) still becomes writable after docker chowns the + # MOUNT, not the image dir. Named volumes inherit the image's ownership. + && chmod 777 /data + +COPY --chmod=0755 docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh + +ARG TARGETARCH +COPY docker/bin/subshell-server-${TARGETARCH} /usr/local/bin/subshell-server +RUN chmod 0755 /usr/local/bin/subshell-server + +USER subshell +ENV HOME=/home/subshell +ENV PATH="/home/subshell/.npm-global/bin:/home/subshell/.local/bin:/home/subshell/.opencode/bin:/home/subshell/.bun/bin:/usr/local/bin:/usr/bin:/bin" + +RUN set -eux; \ + curl -fsSL https://claude.ai/install.sh | bash; \ + curl -fsSL https://chatgpt.com/codex/install.sh | sh; \ + curl -fsSL https://opencode.ai/install | bash; \ + curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash; \ + curl -fsSL https://pi.dev/install.sh | sh; \ + command -v claude codex opencode hermes pi + +ENV HOST=0.0.0.0 \ + NODE_ENV=production \ + DATABASE_PATH=/data/subshell.db \ + SUBSHELL_SERVER_CONFIG_DIR=/data \ + SUBSHELL_CONTAINER=1 + +EXPOSE 3080 + +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] +``` + +If a vendor installer needs a runtime the slim base lacks, add the package to the apt list - that repair belongs to THIS step (the `command -v` line is the gate), not a later fix. + +- [ ] **Step 3: Repoint `docker-compose.yaml`** + +Replace the `build:` + `image:` pair at the top of the service with: + +```yaml + # The published release image (spec 2026-09-28). To build from a staged + # binary instead: mkdir -p docker/bin, drop subshell-server- there, + # and `docker build -t subshell:local .` + image: ghcr.io/subshell-ai/subshell:latest +``` + +Replace the `BETTER_AUTH_SECRET` environment entry (and its comment) with: + +```yaml + # No secret needed: first boot mints a unique one into + # $SUBSHELL_SERVER_CONFIG_DIR/config.env on the data volume. To pin one + # explicitly (moving an existing instance's sessions), uncomment the next + # line - init persists a supplied value verbatim - and set it in .env: + # BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} +``` + +Update the stale comment above the `CLAUDE_BIN` mount block's first line to one sentence: the image now bakes harness CLIs; these mounts OVERRIDE them with the host's binaries/state when that is what you want. Leave every other mount and env line as is. + +- [ ] **Step 4: Ignore the staging dir** + +Append to `.gitignore`: + +``` +# Staged release binaries for the image build (docker-image.yml stages these) +docker/bin/ +``` + +- [ ] **Step 5: Local build smoke (needs docker + network + `gh`)** + +```bash +mkdir -p docker/bin +LATEST=$(gh release list -R subshell-ai/subshell --exclude-drafts --exclude-pre-releases --limit 100 \ + --json tagName --jq '.[].tagName' | grep '^cli-server-v' | sed 's/^cli-server-v//' | sort -V | tail -1) +gh release download "cli-server-v${LATEST}" -R subshell-ai/subshell \ + -p 'subshell-server-cli-linux-x64' -p 'subshell-server-cli-linux-x64.sha256' -D docker/bin/ +sha256sum -c <(printf '%s docker/bin/subshell-server-cli-linux-x64\n' "$(cat docker/bin/subshell-server-cli-linux-x64.sha256 | tr -d '[:space:]' | cut -c1-64)") +mv docker/bin/subshell-server-cli-linux-x64 docker/bin/subshell-server-amd64 +docker build -t subshell:local . +docker run -d --name subshell-smoke -p 31998:3080 subshell:local +until curl -sf http://127.0.0.1:31998/api/setup/status >/dev/null; do sleep 1; done +docker exec subshell-smoke subshell-server version | grep -qF "subshell-server ${LATEST}" +docker exec subshell-smoke bash -lc 'command -v tmux claude codex opencode hermes pi' +docker exec subshell-smoke bash -c 'test -f /data/config.env && grep -q "^BETTER_AUTH_SECRET=.\{32,\}" /data/config.env' +docker rm -f subshell-smoke +``` + +Expected: build succeeds (the harness `command -v` gate passes inside the image build itself), every assertion prints nothing (success), final `docker rm` names the container. + +- [ ] **Step 6: Commit** + +```bash +git add Dockerfile docker-entrypoint.sh docker-compose.yaml .gitignore +git commit -m "feat(docker): release image packaging the signed cli-server binary with tmux and the five harnesses" +``` + +--- + +### Task 5: `scripts/cli-e2e/docker-image.sh` scenario + +**Files:** +- Create: `scripts/cli-e2e/docker-image.sh` (executable) + +**Interfaces:** +- Consumes: a built/pushed image ref; `subshell-server update --check --json`'s `containerized` field (Task 3). +- Produces: `bash scripts/cli-e2e/docker-image.sh []` - the same contract run standalone by an operator and called by docker-image.yml's smoke job (Task 6). + +- [ ] **Step 1: Write the scenario** + +```bash +#!/usr/bin/env bash +# +# The container end-to-end scenario: boots a BUILT Subshell image and asserts +# the container contract from the outside - version, PATH, per-install secret, +# the image-pull update story, and a recreate that keeps config.env +# byte-identical. (spec 2026-09-28 § 8) +# +# bash scripts/cli-e2e/docker-image.sh ghcr.io/subshell-ai/subshell:1.2.3 [1.2.3] +# +# Takes an image ref (any locally-built tag works too) and optionally the +# version its `version` verb must print. Needs docker and network (the update +# --check hits the release source, and a fresh pull needs GHCR). Deliberately +# NOT wired into run.sh: the suite there builds from source; this one tests a +# finished image. Port 31998 (the 31992-31999 family). +set -euo pipefail +IMAGE="${1:?usage: docker-image.sh [version]}" +EXPECTED_VERSION="${2:-}" +NAME="subshell-e2e-$$" +VOL="subshell-e2e-$$" +PORT=31998 +cleanup() { docker rm -f "$NAME" >/dev/null 2>&1 || true; docker volume rm "$VOL" >/dev/null 2>&1 || true; } +trap cleanup EXIT +docker volume create "$VOL" >/dev/null + +boot() { + docker run -d --name "$NAME" --restart unless-stopped -p "$PORT:3080" -v "$VOL:/data" "$IMAGE" >/dev/null + for _ in $(seq 1 60); do + curl -sf "http://127.0.0.1:$PORT/api/setup/status" >/dev/null && return 0 + sleep 1 + done + echo "FAIL: the server never answered /api/setup/status" >&2 + docker logs "$NAME" >&2 + return 1 +} + +echo "==> boot + readiness" +docker pull "$IMAGE" >/dev/null 2>&1 || true # a local build has nothing to pull; fine +boot + +echo "==> version" +VERSION_OUT=$(docker exec "$NAME" subshell-server version) +echo " $VERSION_OUT" +if [ -n "$EXPECTED_VERSION" ]; then + [ "$VERSION_OUT" = "subshell-server $EXPECTED_VERSION" ] || { echo "FAIL: expected subshell-server $EXPECTED_VERSION" >&2; exit 1; } +fi + +echo "==> tmux + the five harnesses resolve in-container" +docker exec "$NAME" bash -lc 'command -v tmux claude codex opencode hermes pi' >/dev/null + +echo "==> the update rail names the image as the unit of update" +CHECK_JSON=$(docker exec "$NAME" subshell-server update --check --json || true) +echo "$CHECK_JSON" | grep -q '"containerized":true' || { echo "FAIL: --check did not report containerized: $CHECK_JSON" >&2; exit 1; } + +echo "==> secret minted once, stable across a recreate" +SECRET1=$(docker exec "$NAME" sha256sum /data/config.env) +docker rm -f "$NAME" >/dev/null +boot +SECRET2=$(docker exec "$NAME" sha256sum /data/config.env) +# The volume survived the recreate, so config.env must be byte-identical - the +# same secret, not a re-mint one: init's keep-existing branch, verified. +[ "$SECRET1" = "$SECRET2" ] || { echo "FAIL: config.env changed across a container recreate" >&2; exit 1; } +docker exec "$NAME" bash -c 'grep -q "^BETTER_AUTH_SECRET=.\{32,\}" /data/config.env' + +echo "OK: docker-image scenario passed for $IMAGE" +``` + +- [ ] **Step 2: Run it against a real artifact** + +```bash +chmod +x scripts/cli-e2e/docker-image.sh +bash scripts/cli-e2e/docker-image.sh subshell:local "$LATEST" # the image and version from Task 4 Step 5, rebuilt if pruned +``` +Expected: all five sections print, final `OK:` line, exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add scripts/cli-e2e/docker-image.sh +git commit -m "test(cli-e2e): docker image scenario (version, PATH, containerized rail, secret stability)" +``` + +--- + +### Task 6: Asset verification + `docker-image.yml` + +**Files:** +- Create: `scripts/docker-release-verify.ts` +- Create: `.github/workflows/docker-image.yml` + +**Interfaces:** +- Consumes: release assets `release-manifest.json` (+`.sig`) and `subshell-server-cli-linux-{x64,arm64}`; `verifyReleaseManifest` and `RELEASE_PUBKEY` from the protocol package's src (relative import, the `site-releases.ts` pattern); the image convention `docker/bin/subshell-server-` (Task 4). +- Produces: exit 0 + "verified:" lines on disk (CI refuses to push on anything else); a pushed multi-arch `ghcr.io/subshell-ai/subshell:` and `:latest`; a smoke job that runs the Task 5 scenario. + +- [ ] **Step 1: Write the verify script** + +```ts +#!/usr/bin/env bun +// Verify staged release assets before they are baked into the Docker image +// (spec 2026-09-28 § 4). The rule is the product's own: the minisign signature +// over the manifest's exact bytes FIRST, then digests from the SIGNED assets +// map - never a .sha256 sidecar. Bad sig / bad digest / no manifest: refuse BY +// NAME, exit 1; the workflow pushes only on exit 0. +// +// bun run scripts/docker-release-verify.ts +// +// must hold release-manifest.json + .sig; either or both linux server +// binaries may be present (each per-arch CI job stages its own), and every +// present one is digest-checked. + +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { RELEASE_MANIFEST_NAME, RELEASE_MANIFEST_SIG_NAME, RELEASE_PUBKEY } from "../packages/subshell-protocol/src/releases.js"; +import { verifyReleaseManifest } from "../packages/subshell-protocol/src/release-signature.js"; + +const [dir, version] = process.argv.slice(2); +if (!dir || !version) { + console.error("usage: docker-release-verify.ts "); + process.exit(1); +} + +const manifestPath = join(dir, RELEASE_MANIFEST_NAME); +const sigPath = join(dir, RELEASE_MANIFEST_SIG_NAME); +if (!existsSync(manifestPath) || !existsSync(sigPath)) { + console.error(`refused: ${dir} holds no ${RELEASE_MANIFEST_NAME} (+ .sig); nothing is trusted without the signature`); + process.exit(1); +} + +const verdict = await verifyReleaseManifest(readFileSync(manifestPath), readFileSync(sigPath, "utf8"), RELEASE_PUBKEY, { + component: "cli-server", + version, +}); +if (!verdict.ok) { + console.error(`refused: ${verdict.reason}`); + process.exit(1); +} + +const names = ["subshell-server-cli-linux-x64", "subshell-server-cli-linux-arm64"].filter((n) => existsSync(join(dir, n))); +if (names.length === 0) { + console.error("refused: the staged dir holds no linux server binary to bake"); + process.exit(1); +} +for (const name of names) { + const expected = verdict.manifest.assets[name]; + const actual = createHash("sha256").update(readFileSync(join(dir, name))).digest("hex"); + if (expected === undefined || expected !== actual) { + console.error(`refused: ${name} digest ${actual} is not the signed ${expected ?? "(absent from the manifest)"}`); + process.exit(1); + } + console.log(`verified ${name} ${actual.slice(0, 12)}...`); +} +console.log(`verified: release-manifest.json for cli-server ${version} + ${names.length} linux binary file(s)`); +``` + +- [ ] **Step 2: Prove the script refuses** + +Against the real latest release (dir from Task 4 Step 5 plus `gh release download ... -p 'release-manifest.json*' -D docker/bin/` and the arm64 binary), then: + +```bash +cp docker/bin/subshell-server-cli-linux-x64 docker/bin/subshell-server-cli-linux-arm64 +bun run scripts/docker-release-verify.ts docker/bin "$LATEST" # EXPECT: verified lines, exit 0 +printf '{"tampered":true}' > /tmp/bad-manifest/release-manifest.json # (mkdir -p first; copy the real .sig) +# tamper check: flip one byte of the real manifest copy -> EXPECT "refused: signature verification failed..." exit 1 +``` + +Expected exact: the clean run ends with the "verified: release-manifest.json for cli-server" line; the tampered run exits 1 naming the refusal. (The arm64 slot holding an x64 copy still passes: the check is digest-vs-manifest on the STAGED bytes, and per-arch jobs download their real asset; this copy is only to exercise both name branches.) + +- [ ] **Step 3: Write the workflow** + +`.github/workflows/docker-image.yml` (pin every action to a full commit SHA discovered at landing time via `gh api repos///git/ref/tags/`; the checkout/setup-bun SHAs below are copied from `website.yml` and already pin-identical): + +```yaml +name: docker-image + +# Package the published cli-server release into the GHCR image (spec +# 2026-09-28 § 4). The release shards are the input: this workflow downloads, +# signature-verifies, bakes, and pushes. It never compiles the server. +# Dispatchable with a version to rebuild an older release into a new image. + +on: + release: + types: [published] + tags: ["cli-server-v*"] + workflow_dispatch: + inputs: + version: + description: "cli-server version to package (e.g. 2.3.1)" + required: true + type: string + +permissions: + contents: read + packages: write + +concurrency: + group: docker-image + cancel-in-progress: false + +env: + IMAGE: ghcr.io/subshell-ai/subshell + +jobs: + build: + # One job per architecture, each on a NATIVE runner: the harness installers + # run inside the image build and must execute real arm64/amd64 code. + strategy: + matrix: + include: + - arch: amd64 + asset: linux-x64 + runner: ubuntu-24.04 + - arch: arm64 + asset: linux-arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + outputs: + version: ${{ steps.plan.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.2 + - name: Resolve the version + id: plan + run: | + if [ "$GITHUB_EVENT_NAME" = "release" ]; then + TAG="${{ github.event.release.tag_name }}" + else + TAG="cli-server-v${{ inputs.version }}" + fi + VERSION="${TAG#cli-server-v}" + printf '%s\n' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || { echo "::error::not a semver: $VERSION"; exit 1; } + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + - name: Download the release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p docker/dl docker/bin + gh release download "${{ steps.plan.outputs.tag }}" -R subshell-ai/subshell \ + -p 'release-manifest.json' -p 'release-manifest.json.sig' \ + -p "subshell-server-cli-${{ matrix.asset }}" -D docker/dl + - name: Verify signature and digests (refused by name on anything else) + run: bun run scripts/docker-release-verify.ts docker/dl "${{ steps.plan.outputs.version }}" + - name: Stage the verified binary + run: cp "docker/dl/subshell-server-cli-${{ matrix.asset }}" "docker/bin/subshell-server-${{ matrix.arch }}" + - uses: docker/setup-buildx-action@ # + - uses: docker/login-action@ # + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@ # + with: + context: . + push: true + platforms: linux/${{ matrix.arch }} + tags: ${{ env.IMAGE }}:${{ steps.plan.outputs.version }}-${{ matrix.arch }} + provenance: false + cache-from: type=gha,scope=docker-image-${{ matrix.arch }} + cache-to: type=gha,scope=docker-image-${{ matrix.arch }},mode=max + + manifest: + needs: build + runs-on: ubuntu-24.04 + steps: + - uses: docker/login-action@ # same pin as above + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Compose and push the multi-arch tags + run: | + V="${{ needs.build.outputs.version }}" + docker buildx imagetools create \ + -t "$IMAGE:$V" -t "$IMAGE:latest" \ + "$IMAGE:$V-amd64" "$IMAGE:$V-arm64" + env: + IMAGE: ${{ env.IMAGE }} + - name: Ensure the package is public (idempotent; first push would otherwise land private) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api -X PATCH /orgs/subshell-ai/packages/container/subshell -f visibility=public \ + || echo "::warning::could not flip package visibility - set it once at https://github.com/orgs/subshell-ai/packages" + + smoke: + needs: [build, manifest] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Logged in even though the package ends up public: the FIRST run pushes + # before visibility is confirmed flipped, and a private package is + # otherwise unreadable here. + - uses: docker/login-action@ # same pin as above + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Drive the container scenario against the pushed image + env: + IMAGE: ${{ env.IMAGE }} + V: ${{ needs.build.outputs.version }} + run: bash scripts/cli-e2e/docker-image.sh "$IMAGE:$V" "$V" +``` + +The `` markers are the plan's ONLY discovery work, and it is exact: run `gh api repos/docker/setup-buildx-action/git/ref/tags/v3 --jq .object.sha` (and likewise for `docker/login-action` v3 and `docker/build-push-action` v6, or the newest tags at landing time), paste the SHAs with their version comments. A landing-time `grep -l PIN-FULL-SHA .github/workflows/` must come up empty. + +- [ ] **Step 4: Land it** + +The workflow triggers on `release: published` - it cannot be dry-run on a PR. Verification available here: `bun run scripts/docker-release-verify.ts` (Step 2) covers the trust gate; the build itself is proven by Task 4's local `docker build`. Push the branch, open the PR, and record in the PR body that the first FULLY GREEN smoke requires a `cli-server` release cut from the merged branch: Task 5 measured that the published `cli-server-v1.5.0` binary predates the containerized `--check` code by ~20 hours, so a dispatch on the current newest version legitimately passes build+verify but fails the scenario's section 4. A dispatch on the newest version stays sanctioned as a partial (build+verify+boot) check. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/docker-release-verify.ts .github/workflows/docker-image.yml +git commit -m "feat(ci): docker-image workflow packages the verified release into GHCR per-arch" +``` + +--- + +### Task 7: `proxmox.sh` helper script (+ serving, + shellcheck) + +**Files:** +- Create: `proxmox.sh` (repo root, the `install-server.sh` placement precedent) +- Modify: `apps/website/scripts/prepare-data.ts:32` (the script copy list) +- Modify: `.github/workflows/lint.yml` (one shellcheck step in the `lint` job) + +**Interfaces:** +- Consumes: the GHCR image (`ghcr.io/subshell-ai/subshell:latest`, public by Task 6), Docker CE inside the CT, port 3080 in-container. +- Produces: `curl -fsSL https://subshell.sh/proxmox.sh | bash` and `bash proxmox.sh update` (the remedy string Tasks 1-2 print). Standard verbs install / update / remove / backup / restore. +- Note: the spec's § 6 illustrative sentence `bash subshell.sh update` resolves here to the shipped file name, `bash proxmox.sh update`; the server-side string (Task 1) is the single truth and every test asserts against it. + +- [ ] **Step 1: Write `proxmox.sh`** + +```bash +#!/usr/bin/env bash +# +# Subshell - Proxmox VE LXC helper (spec 2026-09-28 section 5), the +# community-scripts convention: run on the PROXMOX HOST as root. Creates an +# unprivileged Debian trixie container with Docker inside it, runs the official +# Subshell image from GHCR, and updates it later with: bash proxmox.sh update +# +# Data (database, config.env with its minted secret, plugins, backups) lives on +# the CT's /var/lib/subshell, so updates never touch it. Running panes do not +# survive an update: the container owns its tmux server. + +set -u + +APP="Subshell" +IMG="ghcr.io/subshell-ai/subshell:latest" +CT_ID="" +CT_HOSTNAME="${CT_HOSTNAME:-subshell}" +CT_CORES="${CT_CORES:-1}" +CT_RAM_MB="${CT_RAM_MB:-2048}" +CT_DISK_GB="${CT_DISK_GB:-8}" +CT_BRIDGE="${CT_BRIDGE:-vmbr0}" +APP_PORT="${APP_PORT:-3080}" +CT_DATA="/var/lib/subshell" +CT_RUN_ENV="/etc/default/subshell-docker" +CT_NAME="subshell" +TEMPLATE_CACHE="/var/lib/vz/template/cache" + +msg_ok() { echo -e "\e[32m[OK]\e[0m $*"; } +msg_err() { echo -e "\e[31m[ERROR]\e[0m $*" >&2; exit 1; } +header() { echo -e "\e[32m ==>\e[0m \e[1m$1\e[0m"; } + +# fn_prompt VAR QUESTION DEFAULT (PVE_NO_PROMPT=1 takes every default, for +# unattended runs, exactly like the community scripts' var_check posture) +fn_prompt() { + local var="$1" question="$2" def="$3" answer="" + if [[ "${PVE_NO_PROMPT:-0}" == "1" ]]; then + eval "$var=\"\$def\"" + return + fi + read -rp "${question} [${def}]: " answer < /dev/tty || answer="" + eval "$var=\"${answer:-$def}\"" +} + +preflight() { + [[ $EUID -eq 0 ]] || msg_err "run this on the Proxmox host as root" + command -v pct >/dev/null 2>&1 || msg_err "pct not found: this is not a Proxmox host" +} + +need_ct_id() { + [[ -n "$CT_ID" ]] || read -rp "Container ID: " CT_ID < /dev/tty + [[ -n "$CT_ID" ]] || msg_err "no container id given" + pct status "$CT_ID" >/dev/null 2>&1 || msg_err "no CT $CT_ID on this host" +} + +default_storage() { + local s + s=$(pvesm status -content images 2>/dev/null | awk 'NR>1 {print $1; exit}') + echo "${s:-local}" +} + +# Newest debian-13 standard template filename for this host's arch. +latest_template() { + local arch file + [[ "$(uname -m)" == "x86_64" ]] && arch="amd64" || arch="arm64" + file=$(curl -fsSL "https://download.proxmox.com/images/system/" | + grep -oE "debian-13-standard_[0-9.]+-[0-9]+_${arch}\.tar\.zst" | sort -V | tail -1) + [[ -n "$file" ]] || msg_err "no Debian 13 CT template found on download.proxmox.com" + echo "$file" +} + +# Waits for the in-CT web UI; prints nothing. +wait_up() { + local port="$1" _ + for _ in $(seq 1 60); do + pct exec "$CT_ID" -- curl -sf "http://127.0.0.1:${port}/api/setup/status" >/dev/null 2>&1 && return 0 + sleep 2 + done + return 1 +} + +# ---------- install ---------- +install_ct() { + preflight + header "Create the ${APP} container" + local last + last=$(pct list 2>/dev/null | tail -n +2 | awk '{print $1}' | sort -n | tail -1) + fn_prompt CT_ID "Container ID" "$(( ${last:-100} + 1 ))" + fn_prompt CT_HOSTNAME "Hostname" "$CT_HOSTNAME" + fn_prompt CT_CORES "Cores" "$CT_CORES" + fn_prompt CT_RAM_MB "Memory MB" "$CT_RAM_MB" + fn_prompt CT_DISK_GB "Disk GB" "$CT_DISK_GB" + fn_prompt CT_BRIDGE "Bridge" "$CT_BRIDGE" + fn_prompt APP_PORT "Port the web UI maps on the CT" "$APP_PORT" + + local tmpl st rootpass + st=$(default_storage) + tmpl=$(latest_template) + if [[ ! -f "${TEMPLATE_CACHE}/${tmpl}" ]]; then + header "Downloading ${tmpl} (this can take a minute)" + ( cd "$TEMPLATE_CACHE" && wget -q --show-progress "https://download.proxmox.com/images/system/${tmpl}" ) \ + || msg_err "template fetch failed" + fi + rootpass=$(openssl rand -base64 12) + + local sshkey="" args + [[ -f /root/.ssh/id_ed25519.pub ]] && sshkey=$(cat /root/.ssh/id_ed25519.pub) + [[ -z "$sshkey" && -f /root/.ssh/id_rsa.pub ]] && sshkey=$(cat /root/.ssh/id_rsa.pub) + args=("$CT_ID" "local:vztmpl/${tmpl}" + --unprivileged 1 --features nesting=1 + --hostname "$CT_HOSTNAME" --ostype debian + --memory "$CT_RAM_MB" --swap 512 --cores "$CT_CORES" + --disk "size=${CT_DISK_GB}G" --storage "$st" + --net0 "name=eth0,bridge=${CT_BRIDGE},ip=dhcp" + --password "$rootpass") + [[ -n "$sshkey" ]] && args+=(--ssh-public-keys "$sshkey") + pct create "${args[@]}" || msg_err "pct create failed" + pct start "$CT_ID" || msg_err "pct start failed" + msg_ok "CT $CT_ID created (CT root password: ${rootpass})" +} + +install_docker_in_ct() { + header "Install Docker inside the CT" + local _ + for _ in $(seq 1 60); do pct exec "$CT_ID" -- true 2>/dev/null && break; sleep 1; done + pct exec "$CT_ID" -- bash -ec ' + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq ca-certificates curl >/dev/null + curl -fsSL https://get.docker.com | sh >/dev/null 2>&1 + systemctl enable --now docker + ' || msg_err "Docker install inside the CT failed" + + header "Run the ${APP} container" + pct exec "$CT_ID" -- bash -s -- "$APP_PORT" "$IMG" "$CT_DATA" "$CT_NAME" "$CT_RUN_ENV" <<'REMOTE' + set -eu + port="$1"; img="$2"; data="$3"; name="$4"; runenv="$5" + mkdir -p "$data" + # The image runs as uid 1000; a fresh host dir must be its to write. + chown 1000:1000 "$data" + printf 'APP_PORT=%s\nIMAGE=%s\nDATA=%s\nNAME=%s\n' "$port" "$img" "$data" "$name" > "$runenv" + docker rm -f "$name" >/dev/null 2>&1 || true + docker run -d --name "$name" --restart unless-stopped \ + -p "$port:3080" -v "$data:/data" "$img" >/dev/null +REMOTE + [[ $? -eq 0 ]] || msg_err "the ${APP} container failed to start" + wait_up "$APP_PORT" || msg_err "the container never answered inside the CT (check: pct exec $CT_ID -- docker logs subshell)" + local ip + ip=$(pct exec "$CT_ID" -- hostname -I | awk '{print $1}') + msg_ok "${APP} is up: http://${ip}:${APP_PORT}" + echo "Register the first account there - it becomes the admin." +} + +# ---------- update ---------- +update_app() { + preflight + need_ct_id + header "Pull the new image and recreate the container (running panes end; data survives)" + pct exec "$CT_ID" -- bash -s -- "$CT_RUN_ENV" <<'REMOTE' + set -eu + runenv="$1" + set -a; . "$runenv"; set +a + rollback() { + docker rm -f "$NAME" >/dev/null 2>&1 || true + docker rename "$NAME-old" "$NAME" >/dev/null 2>&1 || true + docker start "$NAME" >/dev/null 2>&1 || true + echo "update failed: the previous container is restored" >&2 + exit 1 + } + # A failed pull changed NOTHING yet - the rollback below is for the + # post-rename steps, where it restores correctly. Deleting the running + # container here would strand the instance on a registry hiccup + # (review finding, operator ruling 2026-09-29). + docker pull "$IMAGE" || { echo "pull failed: the running container was left alone" >&2; exit 1; } + docker rename "$NAME" "$NAME-old" || rollback + docker stop "$NAME-old" >/dev/null || rollback + docker run -d --name "$NAME" --restart unless-stopped \ + -p "$APP_PORT:3080" -v "$DATA:/data" "$IMAGE" >/dev/null || rollback + ok="" + for _ in $(seq 1 60); do + curl -sf "http://127.0.0.1:$APP_PORT/api/setup/status" >/dev/null && { ok=1; break; } + sleep 2 + done + [[ -n "$ok" ]] || rollback + docker rm -f "$NAME-old" >/dev/null 2>&1 || true + echo "updated: $(docker exec "$NAME" subshell-server version)" +REMOTE + [[ $? -eq 0 ]] || msg_err "update failed inside CT $CT_ID" + msg_ok "${APP} updated" +} + +# ---------- remove / backup / restore ---------- +remove_ct() { + preflight + need_ct_id + if [[ "${PVE_NO_PROMPT:-0}" != "1" ]]; then + echo "This DESTROYS CT $CT_ID and everything in it." + read -rp "Type yes to confirm: " a < /dev/tty + [[ "$a" == "yes" ]] || msg_err "cancelled" + fi + pct shutdown "$CT_ID" 2>/dev/null || true + sleep 3 + pct stop "$CT_ID" 2>/dev/null || true + pct destroy "$CT_ID" + msg_ok "CT $CT_ID destroyed" +} + +backup_ct() { + preflight + need_ct_id + vzdump "$CT_ID" --mode snapshot --compress zstd --storage "$(default_storage)" +} + +restore_help() { + preflight + echo "Restore is Proxmox's own flow: Datacenter -> Backup -> select the vzdump -> Restore." + echo "A ${APP} backup is a full-CT snapshot, so restoring brings back the container, its Docker, and ${CT_DATA} as one." +} + +# ---------- menu ---------- +case "${1:-}" in + install) install_ct; install_docker_in_ct ;; + update) update_app ;; + remove) remove_ct ;; + backup) backup_ct ;; + restore) restore_help ;; + "") + header "Menu" + echo " 1) install 2) update 3) backup 4) restore help 5) remove" + read -rp "Select: " sel < /dev/tty + case "$sel" in + 1) install_ct; install_docker_in_ct ;; + 2) update_app ;; + 3) need_ct_id; backup_ct ;; + 4) restore_help ;; + 5) need_ct_id; remove_ct ;; + *) msg_err "no such option" ;; + esac + ;; + *) msg_err "usage: proxmox.sh [install|update|remove|backup|restore]" ;; +esac +``` + +Landing notes (real review points, keep them in mind while transcribing): the remote heredocs are QUOTED (`<<'REMOTE'`) so host variables never expand inside them - runtime values arrive only as `bash -s --` arguments; `pct create` takes `--ssh-public-keys` (plural) and there is no public-key option worth passing when empty, hence the array; `wait_up` is the single health gate both verbs share; and every INTERACTIVE read takes `< /dev/tty`, because the advertised entry point is `curl ... | bash` and stdin then holds the script's own remaining text (operator ruling 2026-09-29, with the failed-pull stranding fix above it). + +- [ ] **Step 2: Serve it at `subshell.sh/proxmox.sh`** + +In `apps/website/scripts/prepare-data.ts:32`: + +```ts +for (const script of ["install-server.sh", "install-client.sh", "proxmox.sh"]) { +``` + +Run `bun test apps/website/lib/__tests__/install.test.ts apps/website/lib/__tests__/releases.test.ts` - if a test pins the script list (it asserts the copy set today), add `"proxmox.sh"` there too. + +- [ ] **Step 3: shellcheck (the CI gate)** + +In `.github/workflows/lint.yml`, inside the `lint` job after the existing steps: + +```yaml + - name: shellcheck (new shell surfaces: Proxmox helper, image entrypoint, docker scenario) + run: | + sudo apt-get update -q + sudo apt-get install -y -q shellcheck + shellcheck -S warning proxmox.sh docker-entrypoint.sh scripts/cli-e2e/docker-image.sh +``` + +Run it locally now: `shellcheck -S warning proxmox.sh docker-entrypoint.sh scripts/cli-e2e/docker-image.sh` (install via `apt`/`brew` if absent). Expected: clean, zero warnings after fixing the markers from Step 1's notes. Also: `bash -n proxmox.sh && bash -n docker-entrypoint.sh && bash -n scripts/cli-e2e/docker-image.sh`. + +- [ ] **Step 4: Commit** + +```bash +git add proxmox.sh apps/website/scripts/prepare-data.ts .github/workflows/lint.yml +git commit -m "feat(proxmox): host helper script (install/update/remove/backup/restore) served at subshell.sh/proxmox.sh" +``` + +--- + +### Task 8: Docs + +**Files:** +- Rewrite: `apps/docs/content/docs/server/docker.mdx` +- Create: `apps/docs/content/docs/server/proxmox.mdx` +- Modify: `apps/docs/content/docs/server/meta.json` (page list) +- Modify: `apps/docs/content/docs/server/updating.mdx` (one container section) +- Modify: `README.md` (the pointer line near L68) + +**Interfaces:** +- Consumes: everything shipped by Tasks 1-7. +- Produces: the pages the update-card remedy sentence implies; no code changes. + +- [ ] **Step 1: `server/docker.mdx`** + +Rewrite around the shipped shape, keeping the existing mount table for the compose rail. The replacement must cover, each in a short section: what the image is (the release binary packaged on debian:trixie-slim with tmux + the five harness CLIs, `subshell-server version` answers the same as outside); first run (one volume at `/data`; first boot runs `init` and mints a unique `BETTER_AUTH_SECRET` into `/data/config.env` at 0600, so `docker run -d -v subshell-data:/data ghcr.io/subshell-ai/subshell:latest` needs no env except optionally `APP_BASE_URL`; prod boots fail loudly on the placeholder secret); updating (pull + recreate; sessions survive because the volume does; running panes do not survive because the container owns its tmux server; in-app update points here); the compose dev rail (now pulls the GHCR image, harness CLIs baked and host mounts overriding them); the pointer to the Proxmox page. Voice rules: neutral, one-sentence openers, no em dashes, no backticks in UI strings (docs prose may use them). + +- [ ] **Step 2: `server/proxmox.mdx`** + +New page: what it is (one command on the Proxmox host creates an unprivileged LXC with Docker inside and runs the Subshell image; the community-scripts convention with install/update/remove/backup/restore verbs); the one-liner `curl -fsSL https://subshell.sh/proxmox.sh | bash` (script source: `proxmox.sh` at the repo root); requirements (a Proxmox host, root, ~2 GB RAM / 8 GB disk defaults, all prompted and overridable); updating (`bash proxmox.sh update ` pulls the new image and recreates, rolling the old container back if the new one fails its health gate; data survives, panes do not); what lives where (host-side CT `/var/lib/subshell`: database, config.env with the minted secret, plugins, backups; the image itself is disposable); first run (open the printed URL, the first account becomes admin); security note in one sentence: Docker-in-LXC needs `nesting=1`, the CT stays unprivileged, and the instance follows the same trusted-network posture as any other deployment. + +- [ ] **Step 3: Wire and cross-point** + +`apps/docs/content/docs/server/meta.json`: insert `"proxmox"` after `"docker"` in the pages array. +`apps/docs/content/docs/server/updating.mdx`: add a short "Inside a container" section (three sentences): the image is the unit of update; `subshell-server update` refuses a containerized install with this spelled out; the Proxmox one-liner's `update` verb. +`README.md`: beside the existing docs pointer for Docker (~L68), extend to name the Proxmox page: `docker and proxmox lxc setup live in the docs (docs.subshell.sh)`. + +- [ ] **Step 4: Verify and commit** + +Run `bun run lint:prose` (the new MDX must be clean) and, if the docs app has a lint/build, `bunx turbo build --filter=` to keep the nav check honest. + +```bash +git add apps/docs README.md +git commit -m "docs: docker rewrite, proxmox lxc guide, updating pointer" +``` + +--- + +### Task 9: Boundary verification, PR, and the operator gate + +**Files:** none (verification + handoff). + +- [ ] **Step 1: Full suite** + +```bash +bun run verify-types +bun run lint:check +bun run lint:prose +bun run test +bunx turbo build +``` + +All green (and if `bun test` shows an admin-server key-set failure, fix it and re-run). + +- [ ] **Step 2: CLI e2e (update.ts changed)** + +`bash scripts/cli-e2e/server-update.sh` per `verification.md` (it refuses on hosts with an installed per-user server; on this machine run it only if it refuses clean). Then the image scenario end-to-end once more: `bash scripts/cli-e2e/docker-image.sh subshell:local "$LATEST"`. + +- [ ] **Step 3: PR and first-run gate** + +Push `worktree-docker-image` (or a renamed branch `feat/docker-proxmox-rail`), open the PR, run `gh run watch` to green (memory: `gh pr merge --auto` merges immediately in this repo, do not use it). The PR body records the two things CI cannot test here: (1) the first `docker-image.yml` run happens on the next `cli-server` release, or dispatch it with the current newest version, (2) the Proxmox acceptance pass is manual: run `proxmox.sh install` on Theo's host, complete first-run registration, open a pane, `proxmox.sh update`, confirm the pane died and data survived. Do not merge before those are scheduled/named in review. + +- [ ] **Step 4: Record the manual acceptance as done/blocked in the PR thread before merging.** diff --git a/docs/superpowers/plans/2026-09-29-proxmox-node-rail.md b/docs/superpowers/plans/2026-09-29-proxmox-node-rail.md new file mode 100644 index 000000000..1ef7d773f --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-proxmox-node-rail.md @@ -0,0 +1,282 @@ +# Proxmox node rail - Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `proxmox-node.sh` (an unprivileged LXC that enrolls as a node using the operator's pasted per-instance install URL) plus the two docs cross-links, so the Proxmox audience gets the node story as well as the server one. + +**Architecture:** The helper creates a plain Debian 13 CT (no Docker, no nesting), installs tmux, makes a uid-1000 `subshell` user with lingering, and runs the instance's OWN server-served install rail inside it as that user (`SUBSHELL_NO_SERVICE=1` + `SUBSHELL_NODE_NAME`), then `subshell service install` (which enables and starts). Nothing about enrollment is reinterpreted - the pasted URL is the product's command. + +**Tech Stack:** bash (shellcheck-gated), fumadocs MDX. + +**Spec:** `docs/superpowers/specs/2026-09-29-proxmox-node-rail-design.md`. + +## Global Constraints + +- No U+2014 em dash anywhere in prose or shipped strings; `bash -n` + `shellcheck -S warning` clean on every shell file. +- The script's remote blocks are QUOTED heredocs (`<<'REMOTE'`); host values arrive only as `bash -s --` arguments; the setup key is read with `read -rsp` (silent) and written to NO file. +- Every interactive read takes `< /dev/tty` and its variable is pre-initialized (the sibling script's settled pattern). +- Docs voice: neutral, one-sentence openers, no restructures of existing pages - additions only. +- Boundary gates per `verification.md`; commits terse conventional, author theo@suteki.nu, lefthook must pass. + +--- + +### Task 1: `proxmox-node.sh` + serving + lint wiring + +**Files:** +- Create: `proxmox-node.sh` (repo root, mode 755) +- Modify: `.github/workflows/lint.yml` (the shellcheck step's file list) +- Modify: `apps/website/scripts/prepare-data.ts` (copy list, line ~32) +- Modify: `apps/website/.gitignore` (ignore the generated `public/proxmox-node.sh` copy) + +**Interfaces:** +- Consumes: the server-served `/install.sh?setup_key=...` rail with `SUBSHELL_NO_SERVICE=1` and `SUBSHELL_NODE_NAME` knobs (install-script.ts:99-307); `subshell service install` which enables AND starts (node agent service.ts:343); node CLI `update --yes` (cli.ts:181,360). +- Produces: `curl -fsSL https://subshell.sh/proxmox-node.sh | bash` and `bash /root/proxmox-node.sh update` (the self-save print); verbs install/update/remove/backup/restore. + +- [ ] **Step 1: Write the script** + +Full content (the header helpers `msg_ok`, `msg_err`, `header`, `fn_prompt`, `preflight`, `need_ct_id`, `default_storage`, `latest_template` are byte-identical to the same-named functions in `proxmox.sh` - copy them from the committed file, changing nothing; where a line below shows `# (same as proxmox.sh: )` expand it from the source): + +```bash +#!/usr/bin/env bash +# +# Subshell - Proxmox VE NODE helper (spec 2026-09-29), run on the PROXMOX +# HOST as root. Creates an unprivileged Debian trixie container and runs +# YOUR Subshell instance's own node install rail inside it - the exact +# command the app shows under its Nodes screen. Paste that URL once; it +# carries a one-time setup key, so there is nothing to save and nothing to +# reuse. No Docker and no image: a node wants plain paths and tmux. +# +# The agent runs as the CT's `subshell` user and its panes inherit that +# account. Updating the AGENT is the product's own rail; this script's +# `update` verb runs it (the in-app node update is the other door). + +set -u + +APP="Subshell node" +CT_ID="${CT_ID:-}" +CT_HOSTNAME="${CT_HOSTNAME:-subshell-node}" +CT_CORES="${CT_CORES:-1}" +CT_RAM_MB="${CT_RAM_MB:-1024}" +CT_DISK_GB="${CT_DISK_GB:-4}" +CT_BRIDGE="${CT_BRIDGE:-vmbr0}" +SETUP_URL="${SETUP_URL:-}" +TEMPLATE_CACHE="/var/lib/vz/template/cache" + +# msg_ok / msg_err / header / fn_prompt / preflight / need_ct_id / +# default_storage / latest_template: copy verbatim from proxmox.sh. + +# The exact shape the app renders: http(s) origin, /install.sh, one +# setup_key=nsk_... param. Validated before anything is created; the key +# value is never echoed. +validate_setup_url() { + local url="$1" + [[ "$url" =~ ^https?://[^/[:space:]]+/install\.sh\?setup_key=nsk_[A-Za-z0-9_-]{8,}$ ]] +} + +# ---------- install ---------- +install_ct() { + preflight + header "Create the ${APP} container" + local last + last=$(pct list 2>/dev/null | tail -n +2 | awk '{print $1}' | sort -n | tail -1) + if [[ -n "$CT_ID" ]]; then + msg_ok "using CT_ID=$CT_ID from the environment" + else + fn_prompt CT_ID "Container ID" "$(( ${last:-100} + 1 ))" + fi + fn_prompt CT_HOSTNAME "Hostname (the node names itself after it)" "$CT_HOSTNAME" + fn_prompt CT_CORES "Cores" "$CT_CORES" + fn_prompt CT_RAM_MB "Memory MB" "$CT_RAM_MB" + fn_prompt CT_DISK_GB "Disk GB" "$CT_DISK_GB" + fn_prompt CT_BRIDGE "Bridge" "$CT_BRIDGE" + + if [[ -z "$SETUP_URL" ]]; then + echo "Paste the install URL from your Subshell instance (its Nodes screen)." + echo "It carries a one-time key: this run gets one shot at it." + read -rsp "Install URL: " SETUP_URL < /dev/tty || SETUP_URL="" + echo + fi + validate_setup_url "$SETUP_URL" || msg_err "not an install URL from the app (expected http(s)://host/install.sh?setup_key=nsk_...); nothing was created" + + local tmpl st rootpass + st=$(default_storage) + tmpl=$(latest_template) + if [[ ! -f "${TEMPLATE_CACHE}/${tmpl}" ]]; then + header "Downloading ${tmpl} (this can take a minute)" + ( cd "$TEMPLATE_CACHE" && wget -q --show-progress "https://download.proxmox.com/images/system/${tmpl}" ) \ + || msg_err "template fetch failed" + fi + rootpass=$(openssl rand -base64 12) + + local args + args=("$CT_ID" "local:vztmpl/${tmpl}" + --unprivileged 1 + --hostname "$CT_HOSTNAME" --ostype debian + --memory "$CT_RAM_MB" --swap 512 --cores "$CT_CORES" + --disk "size=${CT_DISK_GB}G" --storage "$st" + --net0 "name=eth0,bridge=${CT_BRIDGE},ip=dhcp" + --password "$rootpass") + pct create "${args[@]}" || msg_err "pct create failed" + pct start "$CT_ID" || msg_err "pct start failed" + msg_ok "CT $CT_ID created (CT root password: ${rootpass})" +} + +install_node_in_ct() { + header "Install tmux and the node agent inside the CT" + local _ + for _ in $(seq 1 60); do pct exec "$CT_ID" -- true 2>/dev/null && break; sleep 1; done + pct exec "$CT_ID" -- bash -ec ' + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq ca-certificates curl tmux >/dev/null + useradd --create-home --uid 1000 --shell /bin/bash subshell + loginctl enable-linger subshell + ' || msg_err "base setup inside the CT failed" + + # The rail itself: the instance's own install script, as the node user, + # non-interactive, naming the node after the CT hostname. NO_SERVICE + # because the service is installed explicitly below (enable AND start). + pct exec "$CT_ID" -- bash -s -- "$SETUP_URL" "$CT_HOSTNAME" <<'REMOTE' + set -eu + url="$1"; name="$2" + uid=$(id -u subshell) + for _ in $(seq 1 30); do [ -d "/run/user/$uid" ] && break; sleep 1; done + [ -d "/run/user/$uid" ] || { echo "logind never came up inside the CT" >&2; exit 1; } + runuser -u subshell -- env HOME=/home/subshell USER=subshell LOGNAME=subshell \ + XDG_RUNTIME_DIR="/run/user/$uid" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$uid/bus" \ + SUBSHELL_NO_SERVICE=1 SUBSHELL_NODE_NAME="$name" \ + bash -c 'curl -fsSL "$1" | bash' _ "$url" + runuser -u subshell -- env HOME=/home/subshell USER=subshell LOGNAME=subshell \ + PATH="/home/subshell/.local/bin:/usr/local/bin:/usr/bin:/bin" \ + XDG_RUNTIME_DIR="/run/user/$uid" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$uid/bus" \ + subshell service install + echo "agent: $(runuser -u subshell -- env HOME=/home/subshell PATH="/home/subshell/.local/bin:/usr/local/bin:/usr/bin:/bin" subshell version)" +REMOTE + [[ $? -eq 0 ]] || msg_err "the node install failed inside CT $CT_ID (a spent or expired key is NOT retryable: mint a fresh setup link and re-run on a clean CT)" + + # A `curl | bash` install leaves no file to re-run; save our own copy + # best-effort (the update verb needs it) so the print below is a promise. + local update_hint="" + if curl -fsSL https://subshell.sh/proxmox-node.sh -o /root/proxmox-node.sh && chmod 755 /root/proxmox-node.sh; then + update_hint="Update the agent later with: bash /root/proxmox-node.sh update (a copy you kept works too)" + else + update_hint="WARN: could not save /root/proxmox-node.sh - keep your copy for the update verb" + fi + msg_ok "${APP} enrolled - watch it come online in your instance's Nodes list" + echo "$update_hint" +} + +# ---------- update ---------- +update_agent() { + preflight + need_ct_id + header "Run the agent's own updater inside the CT (its service restarts it)" + pct exec "$CT_ID" -- bash -ec ' + uid=$(id -u subshell) + runuser -u subshell -- env HOME=/home/subshell USER=subshell LOGNAME=subshell \ + PATH="/home/subshell/.local/bin:/usr/local/bin:/usr/bin:/bin" \ + XDG_RUNTIME_DIR="/run/user/$uid" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$uid/bus" \ + subshell update --yes + ' || msg_err "update failed inside CT $CT_ID" + msg_ok "${APP} updated" +} + +# ---------- remove / backup / restore ---------- +# remove_ct, backup_ct, restore_help: copy verbatim from proxmox.sh, +# substituting ${APP} in the strings as they already read there. + +# ---------- menu ---------- +case "${1:-}" in + install) install_ct; install_node_in_ct ;; + update) update_agent ;; + remove) remove_ct ;; + backup) backup_ct ;; + restore) restore_help ;; + "") + header "Menu" + echo " 1) install 2) update 3) backup 4) restore help 5) remove" + sel="" + read -rp "Select: " sel < /dev/tty + case "$sel" in + 1) install_ct; install_node_in_ct ;; + 2) update_agent ;; + 3) need_ct_id; backup_ct ;; + 4) restore_help ;; + 5) need_ct_id; remove_ct ;; + *) msg_err "no such option" ;; + esac + ;; + *) msg_err "usage: proxmox-node.sh [install|update|remove|backup|restore]" ;; +esac +``` + +The knobs are set once on `env` and reach the pipe's right-hand `bash` by inheritance (the inner `bash` is a child of the `bash -c` that already carries them). + +- [ ] **Step 2: Gates** + +```bash +chmod +x proxmox-node.sh +bash -n proxmox-node.sh +shellcheck -S warning proxmox-node.sh # the v0.11.0 static binary or apt's +``` +Expected: both silent/exit 0. The copy-from-proxmox.sh instruction means the helpers MUST be byte-identical (diff them). + +- [ ] **Step 3: Wire serving + lint** + +`apps/website/scripts/prepare-data.ts`: copy list becomes `["install-server.sh", "install-client.sh", "proxmox.sh", "proxmox-node.sh"]`. `apps/website/.gitignore`: add `public/proxmox-node.sh` beside the proxmox.sh line. `.github/workflows/lint.yml`: append `proxmox-node.sh` to the shellcheck step's file list. + +Run: `bun test apps/website/lib/__tests__/install.test.ts apps/website/lib/__tests__/releases.test.ts` (from the apps/website package dir; the 14 root-level DOM failures are pre-existing - run per package as apps/website/AGENTS.md prescribes). + +- [ ] **Step 4: Commit** + +```bash +git add proxmox-node.sh apps/website/scripts/prepare-data.ts apps/website/.gitignore .github/workflows/lint.yml +git commit -m "feat(proxmox): node helper - plain LXC enrolling through the instance's own rail" +``` + +--- + +### Task 2: Docs cross-links + +**Files:** +- Modify: `apps/docs/content/docs/server/proxmox.mdx` (new "As a node" section + one opener clause) +- Modify: `apps/docs/content/docs/get-started/add-a-machine.mdx` (one Proxmox note where it fits its existing shape) + +**Interfaces:** +- Consumes: Task 1's script (its verbs, its one-time-key posture, the /root save). +- Produces: the pages the helper's and the server page's audiences route through; no code. + +- [ ] **Step 1: proxmox.mdx** + +Add one new section near the end, before any Next-steps block, and change nothing else on the page: + +```mdx +## As a node + +The container above is the control plane, and it runs panes on its own host: the server hosts subshells through its in-process `local` node, no agent involved. A second machine - or a second LXC - that runs panes for your instance enrolls as a node, and [add-a-machine](/get-started/add-a-machine) is the path. + +On Proxmox the shape is a plain unprivileged Debian 13 container (no Docker, no nesting) running that same install command inside; `proxmox-node.sh` automates exactly that: paste your instance's install URL once at install, and the container enrolls, names itself after the CT hostname, and runs the agent as its `subshell` user. Updating the agent is the product's own rail - the in-app node update, or `bash /root/proxmox-node.sh update`. +``` + +- [ ] **Step 2: add-a-machine.mdx** + +Read the page; add ONE note (a list item under Notes, or a short paragraph in step 2 where the command is run) - Proxmox people: the target machine may be a plain unprivileged LXC; the server rail's page documents a helper that automates the create-and-enroll shape. Point at `/server/proxmox`. Nothing else changes on the page. + +- [ ] **Step 3: Gates + commit** + +`bun run lint:prose`; docs tests from the apps/docs package dir. + +```bash +git add apps/docs/content/docs/server/proxmox.mdx apps/docs/content/docs/get-started/add-a-machine.mdx +git commit -m "docs: node-on-proxmox cross-links" +``` + +--- + +### Task 3: Boundary + push + +- [ ] `bun run verify-types && bun run lint:check && bun run lint:prose && bun run test && bunx turbo build` - all green. +- [ ] `bun test scripts/__tests__/docker-release-verify.test.ts` unaffected-green sanity (same suite covers it). +- [ ] Website changeset: the existing `.changeset/docker-proxmox-lxc-rail.md` already carries `@internal/website": patch` - extend its summary sentence with the node helper; no new file. +- [ ] `git push` to the PR branch; `gh pr checks 273 --watch` until green. +- [ ] PR body: extend the What section with the node rail (script + docs) and the note that node installs stay key-per-instance by design. diff --git a/docs/superpowers/specs/2026-09-28-docker-proxmox-lxc-design.md b/docs/superpowers/specs/2026-09-28-docker-proxmox-lxc-design.md new file mode 100644 index 000000000..fbb658224 --- /dev/null +++ b/docs/superpowers/specs/2026-09-28-docker-proxmox-lxc-design.md @@ -0,0 +1,174 @@ +# Docker image + Proxmox LXC install rail + +Date: 2026-09-28 +Status: approved design, pre-implementation +Builds on: `2026-08-31-docker-deploy-design.md` (the dev compose rail), `docs/release-and-ci.md` (release shards and signing) + +## 1. Problem + +Self-hosters on Proxmox have no supported way to install Subshell. The product ships +a compiled binary plus a one-liner installer and a systemd-user service; none of that +matches how Proxmox users install apps, which is helper scripts that create an LXC. +The repo's existing root `Dockerfile` is a dev artifact: it runs built sources under +bun instead of the release binary, its deps stage no longer resolves, and its own +header says it is broken and unbuilt. + +Goal: people can install Subshell on a Proxmox host with one curl command, and update +it later with one command, with data and sessions surviving the update. + +## 2. Decisions + +| question | ruling | +|---|---| +| Install shape | Proxmox helper-script convention: root script on the host creates an unprivileged LXC with Docker inside (nesting=1) running our GHCR image. Standard verbs (install/update/remove/backup/restore). | +| Update path | Image-pull. The helper script's `update` verb pulls the new image and recreates the container. The in-place binary updater stays refused inside a container; the UI points at the script instead. | +| Image contents | Server binary + tmux + all five harness CLIs (claude-code, codex, opencode, hermes, pi), so panes work on a fresh install. | +| Architectures | linux/amd64 and linux/arm64, both from the already-published release shards. | +| Binary source | The image packages the published, signed release binary. It does not compile. One artifact, one source of truth; `subshell-server version` answers the same inside Docker and outside it. | +| Secrets | Per-install generation, no shared value in the image. Entrypoint runs `init` when the volume has no config (see §3). | + +## 3. The image + +- Base `debian:trixie-slim`. The Linux release floor is glibc 2.39; trixie carries + 2.41, and trixie is what the helper script's LXCs run, so host and container agree. +- A `subshell` runtime user (uid 1000), `HOME=/home/subshell`, config and DB under a + single `/data` volume. +- Baked at build time: `/usr/local/bin/subshell-server` (verified release binary), + apt `tmux git openssh-client ca-certificates curl`, then the five vendor one-liners + from the harness manifests run as the runtime user, landing in `~/.local/bin` and + `~/.npm-global/bin`, both on `PATH`. Harness versions therefore float with build + day; the existing per-agent install route (`POST /api/setup/agents/:id/install`) + refreshes any harness inside a running container without an image rebuild. +- Baked env: `HOST=0.0.0.0`, `NODE_ENV=production`, `DATABASE_PATH=/data/subshell.db`, + `SUBSHELL_SERVER_CONFIG_DIR=/data`, `SUBSHELL_CONTAINER=1` (the marker §6 uses). + `EXPOSE 3080`. +- Entrypoint `docker-entrypoint.sh`: if `/data/config.env` is absent, run + `subshell-server init < /dev/null`, then `exec subshell-server "$@"`. + Stdin is not a TTY, so every `init` question takes its skip-when-no-TTY branch + (`init.ts` three-way rule); the tmux preflight passes because tmux is baked. + The secret ladder then mints a unique 32-byte base64url `BETTER_AUTH_SECRET` into + `/data/config.env` at 0600, on the customer's machine, once. +- Secrets lifecycle: the file lives only on the data volume (never in image, `docker + inspect` env, host files, or script output); updates recreate the container but not + the volume, so `init` sees the file and keeps it, and sessions survive every update; + an operator who supplies `BETTER_AUTH_SECRET` as env gets the persist-env branch, + same as the one-liner rail; and because `NODE_ENV=production` hard-fails the + placeholder secret, no two installs can accidentally boot on a shared value. +- One volume at `/data` covers everything durable: config.env, the DB, backups, + plugins, server logs, node artifacts (`SUBSHELL_SERVER_DATA_DIR` defaults to the DB + dirname). Documented consequence, same as `docker.mdx` says today: a container + restart ends every running subshell, because the container owns its tmux server. +- Root `Dockerfile` is replaced by this one; `.dockerignore` follows. + `docker-compose.yaml` stays the dev/local rail, repointed at the GHCR image, with + the `BETTER_AUTH_SECRET:?` hard requirement relaxed to optional now that the + entrypoint covers it. `docker/desktop-builder.Dockerfile` is CI's test image and is + untouched. + +## 4. CI and publishing + +- New workflow `docker-image.yml`: triggers on `release: published` with tags + `cli-server-v*`, plus `workflow_dispatch` (a `version` input) for rebuilds. +- Steps: download `subshell-server-cli-linux-x64`, `-linux-arm64`, + `release-manifest.json` and `.sig` from the release; verify the minisign signature + against `RELEASE_PUBKEY` and both digests against the manifest's `assets` map, the + same rule every update path obeys; refuse to push on any mismatch. +- buildx per-arch builds (amd64 and arm64 each on a native runner where available, + QEMU as the fallback) that COPY the verified binaries and run the harness + installers, then a manifest list pushed to `ghcr.io/subshell-ai/subshell:X.Y.Z` + and `:latest`. The job carries a `version` smoke check: run the candidate image and + require `subshell-server version` to print exactly `subshell-server X.Y.Z`, the same + byte-exact contract the CLI e2e update scenarios use. +- The image build is packaging: no toolchain layers, and the only build-time network + content is apt plus the five vendor installer rails. That is the product's existing + posture (`docs/security.md` §0 does not defend the harness binaries' supply chain); + the server half is signature-verified, and the GHCR image inherits GitHub's access + controls. Image signing (cosign) is out of scope. + +## 5. Proxmox helper script + +- Root `proxmox.sh` (mirrors the `install-server.sh` placement precedent), served at + `subshell.sh/proxmox.sh` through the existing website.yml root-file hosting, plus + the repo path for raw GitHub use. It runs on the Proxmox host as root. +- `install` flow: prompts (hostname, cores=1, RAM=2048, disk=8, port=3080, all + overridable); downloads the Debian trixie CT template; `pct create` unprivileged + with `features nesting=1`; inside the CT, installs Docker via `get.docker.com`, + generates nothing secret, and runs the container as + `docker run --name subshell --restart unless-stopped -p PORT:3080 + -v /var/lib/subshell:/data ghcr.io/subshell-ai/subshell:latest` + (a `SUBSHELL_VERSION` pin knob holds a tag instead); prints the instance URL. +- `update` verb: `docker pull`, recreate with the same port/volume/tag (read back + from the existing container's config before replacing it), done. Data and secret + live on the volume, so identity, sessions and settings ride through; running + subshells do not (container owns its tmux server), and the docs say so. +- Plus `remove`, `backup`, `restore` in the convention's shape. No `updateapp` + split-verb subtlety beyond `update`; the script is our file, not a community repo + submission (getting listed on community-scripts.org is a later, separate step). + +## 6. Update UX inside the container + +Today the POST refuses unsupervised hosts with `RESTART_UNAVAILABLE`, which is true +but not helpful to someone reading a container. Small rail change: + +- `server-deployment.ts` reports a `containerized` fact when `SUBSHELL_CONTAINER=1`. +- The update route's refusal carries `CONTAINERIZED` as its reason with the remedy + string (`bash subshell.sh update` on the Proxmox host); the restart route's refusal + gains the same reason. `--check` output mentions it in JSON so the CLI story is + honest too. +- The admin update card, when the reason is `CONTAINERIZED`, shows two sentences of + `detail`-role copy: the instance runs in a container; update it by pulling a new + image from the host. The self-swap button is not offered there. Copy obeys the + UI rules (two sentences, no em dashes, detail role). + +## 7. Error handling + +- Image build fails before any push on: missing manifest or bad signature (refused by + name, per the signed-manifest rule), digest mismatch, or the version smoke check. +- Entrypoint: `init` failing propagates and the container exits nonzero rather than + booting without config; `exec` replaces the shell so signals reach the server. +- `proxmox.sh`: `set -u`; fails loudly if the template download, `pct create`, the + nesting feature, or the GHCR pull fails; the recreate step keeps the previous + container (`docker rename` aside, then remove on success) so a bad `:latest` can be + rolled back by name. +- A container booted with an older image tag still answers the admin UI, and the + in-app check says "update by pulling a new image", never offering a swap that + cannot restart correctly. + +## 8. Testing + +- `scripts/cli-e2e/docker-image.sh` (manual + a CI job with docker available; joins + the `test:cli` family, not `bun run test`): boot the freshly built image on a free + port; poll `/api/setup/status`; assert `version` prints the built tag; assert tmux + plus the five harness binaries resolve on PATH in-container; assert the update + route answers `CONTAINERIZED`; recreate the container and assert `config.env` is + byte-identical and the instance boots. +- Server unit tests: the `CONTAINERIZED` refusal payload on the update and restart + routes, and the deployment fact's env parsing. SPA test for the card copy. +- `proxmox.sh` gets shellcheck in `lint.yml`; it cannot be e2e'd without a PVE host, + so acceptance includes one documented manual pass on Theo's Proxmox box (install, + open first run, create a pane, `update`, pane dies, data survives) before docs + point at it. +- Recorded 2026-09-29: the scenario asserts the `update --check --json` + `containerized` field, and the route refusals are pinned by the unit tests above, + so the "the update route answers `CONTAINERIZED`" wording is superseded by this. + +## 9. Out of scope + +- In-place self-update inside the container (the swap would be lost on recreate and + is invisible to the image tag); ruled in §2. +- Multi-stage build-from-source images; drift from the release rail, see §2. +- Running the node agent inside the server container (the `local` node is in-process; + agents are for other machines). +- Community-scripts.org submission, cosign image signing, Windows containers, + `SUBSHELL_SECRETS_KEY` (designed, not built, env-only anyway). + +## 10. Touch list + +- Replace `Dockerfile`; adjust `.dockerignore`; repoint `docker-compose.yaml`. +- New: `docker-entrypoint.sh`, `.github/workflows/docker-image.yml`, `proxmox.sh`. +- Server: `services/server-deployment.ts` (fact), `api/admin/server/update` and + `restart` routes (reason + remedy), CLI `update --check --json` field. +- Web: the admin update card's containerized state. +- Website: host `proxmox.sh` at `subshell.sh/proxmox.sh` (website.yml asset list). +- Docs: refresh `server/docker.mdx`, new `server/proxmox.mdx`; README pointer line. +- CI: `lint.yml` shellcheck entry; `docker-image.yml`; smoke check job. +- Tests: the files named in §8. diff --git a/docs/superpowers/specs/2026-09-29-proxmox-node-rail-design.md b/docs/superpowers/specs/2026-09-29-proxmox-node-rail-design.md new file mode 100644 index 000000000..833abe849 --- /dev/null +++ b/docs/superpowers/specs/2026-09-29-proxmox-node-rail-design.md @@ -0,0 +1,55 @@ +# Node story: docs page, site line, Proxmox node helper + +Date: 2026-09-29 +Status: approved design, pre-implementation +Builds on: `2026-09-28-docker-proxmox-lxc-design.md` (the server rail this mirrors), security model §6 (node enrollment) + +## 1. Problem + +The public site's install column already carries the general node story (the add-a-machine line and page), but the Proxmox rail is server-only end to end: an operator who wants a second LXC running panes as a NODE finds no Proxmox-shaped path, and the server page never says how its container relates to the node concept. The node install command stays per-instance by design (the server renders `/install.sh?setup_key=...` with a single-use key), so what can be published is the PROXMOX SHAPE of that install, not a global command. + +## 2. Decisions + +| question | ruling | +|---|---| +| Public one-liner for nodes? | NO. A key-less public node installer would weaken the enrollment gate. The command's home stays the app. | +| Docs | Already exist: `get-started/add-a-machine.mdx` is the node install page and the website's install column already links it ("Already running Subshell? Add another machine...") - verified during planning, so this spec's original "new nodes page + new site line" scope collapsed to cross-links. Remaining: a plain-LXC note in add-a-machine, and a "node on Proxmox" section in `server/proxmox.mdx` covering the helper. | +| Website | Nothing to build (the line exists); verified `add-a-machine` is its target. | +| Helper script | `proxmox-node.sh`: an unprivileged Debian 13 LXC that enrolls as a node. The operator pastes the per-instance install URL (with its key) once at install; nothing else about the node is invented by the script. | + +## 3. proxmox-node.sh + +- Sibling of `proxmox.sh`, same conventions (root on the host, prompts overridable, `PVE_NO_PROMPT=1`, standard verbs install/update/remove/backup/restore, self-save note at install end, served at `subshell.sh/proxmox-node.sh` via the prepare-data copy list). +- `install` prompts for the SETUP URL - the exact `http://host:3099/install.sh?setup_key=nsk_...` string the app shows - and validates it parses (http(s) origin, path `/install.sh`, a non-empty `setup_key` param) before touching Proxmox. It is single-use: the script says so and refuses to reuse it (one `docker`-free CT per key; a failed install tells the operator to mint a fresh key rather than retry the spent one). +- The CT: unprivileged, NO nesting (nothing here runs Docker), smaller defaults than the server rail (1 core / 1024 MB / 4 GB), hostname prompts the node NAME (the agent names itself via `--name`; `normalizeNodeName` semantics live server-side, the script just passes the hostname). +- Inside the CT: apt curl + tmux, a `subshell` user (uid 1000) with lingering enabled, then as that user: `curl -fsSL "$SETUP_URL" | bash` - the server-served rail does install, enroll, and the systemd-user service, exactly as it does on any machine. The script adds nothing to that path and reimplements none of it. +- Key hygiene: the URL is read from the tty (never argv of the host command), passed into `pct exec` as a here-doc argument, and never written to any file; host shell history stays clean. Residual exposure is the documented posture (the key rides the URL to the server's access log by design). +- `update` runs `subshell update` inside the CT as the node user (the agent's own rail; the in-app node update remains the primary lever and the docs say so). `remove`/`backup`/`restore` match the server script. +- No image, no registry: this rail touches no GHCR asset. + +## 4. Website line + +Under both install kinds (it is equally true from either tab): one sentence, `detail` density on the site's scale, "Want panes on another machine? Add a node inside Subshell - the install command lives with the node." linking `/docs/server/nodes`. No command text, no version, nothing derived from releases.json. + +## 5. Docs + +- `get-started/add-a-machine.mdx`: one short Proxmox note (a plain LXC is a fine target machine: `pct create` unprivileged, run the pasted command inside; the helper automates this) pointing at the server's Proxmox page. +- `server/proxmox.mdx`: a "As a node" section: the server container runs panes on its own host (in-process `local` node); another LXC as a node uses `proxmox-node.sh` (or the plain-LXC path via add-a-machine). Update the page opener so the page is still honestly about the server rail. +- No new page, no meta.json change, no website change (the site line and add-a-machine already cover the general node story). + +## 6. Error handling + +- Setup URL validation failure: refuse before any `pct` call, name what is missing (https origin / /install.sh path / setup_key param). +- Enroll failure inside the CT (bad/expired key): the server-served script reports it; the helper surfaces stderr and says "mint a fresh setup link and re-run install on a clean CT" (never auto-retries a spent key). +- CT-already-exists: install refuses; the update verb is the right lever. + +## 7. Testing + +- shellcheck + `bash -n` in the existing lint.yml step (file list grows). +- Unit-level: the setup-URL validator is a shell function; a small bats-free check is out of convention here - the validator is pinned instead by a documented manual matrix in the PR (valid, no key param, non-http, wrong path). CI cannot run PVE; same acceptance posture as the server rail. +- Docs: content tests for the docs package (existing shipped-copy/voice tests must stay green); lint:prose. +- Manual acceptance (operator, one box): install a node CT against a live instance, see it online in the app, run a pane on it, `subshell update` via the helper. + +## 8. Out of scope + +Public key-less node one-liner (ruled in §2); enrolling over an existing CT; anything that stores the setup key; a Docker-based node image (a node wants host paths to work on, plain LXC is the right shape). diff --git a/packages/backend-errors/src/error-codes.ts b/packages/backend-errors/src/error-codes.ts index 6dfd39abd..9f909e206 100644 --- a/packages/backend-errors/src/error-codes.ts +++ b/packages/backend-errors/src/error-codes.ts @@ -128,6 +128,8 @@ export enum BackendErrorCodes { * installed one. The message carries the reason. */ UPDATE_BINARY_UNKNOWN = "UPDATE_BINARY_UNKNOWN", + /** `POST /api/admin/server/update`: the image marker says this install updates by pulling a new image, not by swapping a binary; the message names the image as the unit of update (spec 2026-09-28 § 6). */ + UPDATE_CONTAINERIZED = "UPDATE_CONTAINERIZED", /** `POST /api/admin/server/update`: a marker is already on disk, or a job is running. */ UPDATE_IN_PROGRESS = "UPDATE_IN_PROGRESS", /** `POST /api/admin/server/update`: no newer release, and the body named no version. */ @@ -348,6 +350,10 @@ export const BackendErrorCodeDefs = { message: "This server does not fetch releases", statusCode: 409, }, + [BackendErrorCodes.UPDATE_CONTAINERIZED]: { + message: "This server runs inside a container; the image is the unit of update", + statusCode: 409, + }, [BackendErrorCodes.UPDATE_BINARY_UNKNOWN]: { message: "No installed binary this server could replace", statusCode: 409, diff --git a/proxmox-node.sh b/proxmox-node.sh new file mode 100755 index 000000000..a027cd478 --- /dev/null +++ b/proxmox-node.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +# +# Subshell - Proxmox VE NODE helper (spec 2026-09-29), run on the PROXMOX +# HOST as root. Creates an unprivileged Debian trixie container and runs +# YOUR Subshell instance's own node install rail inside it - the exact +# command the app shows under its Nodes screen. Paste that URL once; it +# carries a one-time setup key, so there is nothing to save and nothing to +# reuse. No Docker and no image: a node wants plain paths and tmux. +# +# The agent runs as the CT's `subshell` user and its panes inherit that +# account. Updating the AGENT is the product's own rail; this script's +# `update` verb runs it (the in-app node update is the other door). + +set -u + +APP="Subshell node" +CT_ID="${CT_ID:-}" +CT_HOSTNAME="${CT_HOSTNAME:-subshell-node}" +CT_CORES="${CT_CORES:-1}" +CT_RAM_MB="${CT_RAM_MB:-1024}" +CT_DISK_GB="${CT_DISK_GB:-4}" +CT_BRIDGE="${CT_BRIDGE:-vmbr0}" +SETUP_URL="${SETUP_URL:-}" +TEMPLATE_CACHE="/var/lib/vz/template/cache" + +msg_ok() { echo -e "\e[32m[OK]\e[0m $*"; } +msg_err() { echo -e "\e[31m[ERROR]\e[0m $*" >&2; exit 1; } +header() { echo -e "\e[32m ==>\e[0m \e[1m$1\e[0m"; } + +# fn_prompt VAR QUESTION DEFAULT (PVE_NO_PROMPT=1 takes every default, for +# unattended runs, exactly like the community scripts' var_check posture) +fn_prompt() { + local var="$1" question="$2" def="$3" answer="" + if [[ "${PVE_NO_PROMPT:-0}" == "1" ]]; then + eval "$var=\"\$def\"" + return + fi + read -rp "${question} [${def}]: " answer < /dev/tty || answer="" + eval "$var=\"${answer:-$def}\"" +} + +preflight() { + [[ $EUID -eq 0 ]] || msg_err "run this on the Proxmox host as root" + command -v pct >/dev/null 2>&1 || msg_err "pct not found: this is not a Proxmox host" +} + +need_ct_id() { + [[ -n "$CT_ID" ]] || read -rp "Container ID: " CT_ID < /dev/tty + [[ -n "$CT_ID" ]] || msg_err "no container id given" + pct status "$CT_ID" >/dev/null 2>&1 || msg_err "no CT $CT_ID on this host" +} + +default_storage() { + local s + s=$(pvesm status -content images 2>/dev/null | awk 'NR>1 {print $1; exit}') + echo "${s:-local}" +} + +# Newest debian-13 standard template filename for this host's arch. +latest_template() { + local arch file + [[ "$(uname -m)" == "x86_64" ]] && arch="amd64" || arch="arm64" + file=$(curl -fsSL "https://download.proxmox.com/images/system/" | + grep -oE "debian-13-standard_[0-9.]+-[0-9]+_${arch}\.tar\.zst" | sort -V | tail -1) + [[ -n "$file" ]] || msg_err "no Debian 13 CT template found on download.proxmox.com" + echo "$file" +} + +# The exact shape the app renders: http(s) origin, /install.sh, one +# setup_key=nsk_... param. The trailing &server= clause the app adds +# when you picked a non-canonical address is part of the pasted command and +# passes through to curl untouched, so it is accepted here. Loopback is +# refused at the same point in EITHER position: a CT curl-ing localhost +# reaches the CT, not the instance, and the carried clause bakes the node's +# dial-home URL - loopback there leaves the node dialing its own container. +# Validated before anything is created; the key value is never echoed. +validate_setup_url() { + local url="$1" + [[ "$url" =~ ^https?://[^/[:space:]]+/install\.sh\?setup_key=nsk_[A-Za-z0-9_-]{8,}(&server=[^?#[:space:]]*)?$ ]] || return 1 + local origin="${url%%/install.sh*}" + origin="${origin,,}" + [[ "$origin" != *"://localhost"* && "$origin" != *"://127."* && "$origin" != *"://[::1]"* ]] || return 1 + if [[ "$url" == *"&server="* ]]; then + local carried="${url#*&server=}" + carried="${carried,,}" + [[ "$carried" != *"://localhost"* && "$carried" != *"://127."* && "$carried" != *"://[::1]"* ]] + fi +} + +# ---------- install ---------- +install_ct() { + preflight + header "Create the ${APP} container" + local last + last=$(pct list 2>/dev/null | tail -n +2 | awk '{print $1}' | sort -n | tail -1) + if [[ -n "$CT_ID" ]]; then + msg_ok "using CT_ID=$CT_ID from the environment" + else + fn_prompt CT_ID "Container ID" "$(( ${last:-100} + 1 ))" + fi + fn_prompt CT_HOSTNAME "Hostname (the node names itself after it)" "$CT_HOSTNAME" + fn_prompt CT_CORES "Cores" "$CT_CORES" + fn_prompt CT_RAM_MB "Memory MB" "$CT_RAM_MB" + fn_prompt CT_DISK_GB "Disk GB" "$CT_DISK_GB" + fn_prompt CT_BRIDGE "Bridge" "$CT_BRIDGE" + + if [[ -z "$SETUP_URL" ]]; then + echo "Paste the install URL from your Subshell instance (its Nodes screen)." + echo "It carries a one-time key: this run gets one shot at it." + read -rsp "Install URL: " SETUP_URL < /dev/tty || SETUP_URL="" + echo + fi + validate_setup_url "$SETUP_URL" || msg_err "not a usable install URL from the app (expected http(s):///install.sh?setup_key=nsk_...[&server=...]); nothing was created" + + local tmpl st rootpass + st=$(default_storage) + tmpl=$(latest_template) + if [[ ! -f "${TEMPLATE_CACHE}/${tmpl}" ]]; then + header "Downloading ${tmpl} (this can take a minute)" + ( cd "$TEMPLATE_CACHE" && wget -q --show-progress "https://download.proxmox.com/images/system/${tmpl}" ) \ + || msg_err "template fetch failed" + fi + rootpass=$(openssl rand -base64 12) + + local args + args=("$CT_ID" "local:vztmpl/${tmpl}" + --unprivileged 1 + --hostname "$CT_HOSTNAME" --ostype debian + --memory "$CT_RAM_MB" --swap 512 --cores "$CT_CORES" + --disk "size=${CT_DISK_GB}G" --storage "$st" + --net0 "name=eth0,bridge=${CT_BRIDGE},ip=dhcp" + --password "$rootpass") + pct create "${args[@]}" || msg_err "pct create failed" + pct start "$CT_ID" || msg_err "pct start failed" + msg_ok "CT $CT_ID created (CT root password: ${rootpass})" +} + +install_node_in_ct() { + header "Install tmux and the node agent inside the CT" + local _ + for _ in $(seq 1 60); do pct exec "$CT_ID" -- true 2>/dev/null && break; sleep 1; done + pct exec "$CT_ID" -- bash -ec ' + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq ca-certificates curl tmux >/dev/null + useradd --create-home --uid 1000 --shell /bin/bash subshell + loginctl enable-linger subshell + ' || msg_err "base setup inside the CT failed" + + # The rail itself: the instance's own install script, as the node user, + # non-interactive, naming the node after the CT hostname. NO_SERVICE + # because the service is installed explicitly below (enable AND start). + pct exec "$CT_ID" -- bash -s -- "$SETUP_URL" "$CT_HOSTNAME" <<'REMOTE' + set -eu + url="$1"; name="$2" + uid=$(id -u subshell) + for _ in $(seq 1 30); do [ -d "/run/user/$uid" ] && break; sleep 1; done + [ -d "/run/user/$uid" ] || { echo "logind never came up inside the CT" >&2; exit 1; } + runuser -u subshell -- env HOME=/home/subshell USER=subshell LOGNAME=subshell \ + XDG_RUNTIME_DIR="/run/user/$uid" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$uid/bus" \ + SUBSHELL_NO_SERVICE=1 SUBSHELL_NODE_NAME="$name" \ + bash -c 'set -o pipefail; curl -fsSLg "$1" | bash' _ "$url" + runuser -u subshell -- env HOME=/home/subshell USER=subshell LOGNAME=subshell \ + PATH="/home/subshell/.local/bin:/usr/local/bin:/usr/bin:/bin" \ + XDG_RUNTIME_DIR="/run/user/$uid" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$uid/bus" \ + subshell service install + echo "agent: $(runuser -u subshell -- env HOME=/home/subshell PATH="/home/subshell/.local/bin:/usr/local/bin:/usr/bin:/bin" subshell version)" +REMOTE + [[ $? -eq 0 ]] || msg_err "the node install failed inside CT $CT_ID (a spent or expired key is NOT retryable: mint a fresh setup link and re-run on a clean CT)" + + # A `curl | bash` install leaves no file to re-run; save our own copy + # best-effort (the update verb needs it) so the print below is a promise. + local update_hint="" + if curl -fsSL https://subshell.sh/proxmox-node.sh -o /root/proxmox-node.sh && chmod 755 /root/proxmox-node.sh; then + update_hint="Update the agent later with: bash /root/proxmox-node.sh update (a copy you kept works too)" + else + update_hint="WARN: could not save /root/proxmox-node.sh - keep your copy for the update verb" + fi + msg_ok "${APP} enrolled - watch it come online in your instance's Nodes list" + echo "$update_hint" +} + +# ---------- update ---------- +update_agent() { + preflight + need_ct_id + header "Run the agent's own updater inside the CT (its service restarts it)" + pct exec "$CT_ID" -- bash -ec ' + uid=$(id -u subshell) + runuser -u subshell -- env HOME=/home/subshell USER=subshell LOGNAME=subshell \ + PATH="/home/subshell/.local/bin:/usr/local/bin:/usr/bin:/bin" \ + XDG_RUNTIME_DIR="/run/user/$uid" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$uid/bus" \ + subshell update --yes + ' || msg_err "update failed inside CT $CT_ID" + msg_ok "${APP} updated" +} + +# ---------- remove / backup / restore ---------- +remove_ct() { + preflight + need_ct_id + if [[ "${PVE_NO_PROMPT:-0}" != "1" ]]; then + echo "This DESTROYS CT $CT_ID and everything in it." + local a="" + read -rp "Type yes to confirm: " a < /dev/tty + [[ "$a" == "yes" ]] || msg_err "cancelled" + fi + pct shutdown "$CT_ID" 2>/dev/null || true + sleep 3 + pct stop "$CT_ID" 2>/dev/null || true + pct destroy "$CT_ID" + msg_ok "CT $CT_ID destroyed" +} + +backup_ct() { + preflight + need_ct_id + vzdump "$CT_ID" --mode snapshot --compress zstd --storage "$(default_storage)" +} + +restore_help() { + preflight + echo "Restore is Proxmox's own flow: Datacenter -> Backup -> select the vzdump -> Restore." + echo "A ${APP} backup is a full-CT snapshot, so restoring brings back the container and its agent as one." +} + +# ---------- menu ---------- +case "${1:-}" in + install) install_ct; install_node_in_ct ;; + update) update_agent ;; + remove) remove_ct ;; + backup) backup_ct ;; + restore) restore_help ;; + "") + header "Menu" + echo " 1) install 2) update 3) backup 4) restore help 5) remove" + sel="" + read -rp "Select: " sel < /dev/tty + case "$sel" in + 1) install_ct; install_node_in_ct ;; + 2) update_agent ;; + 3) need_ct_id; backup_ct ;; + 4) restore_help ;; + 5) need_ct_id; remove_ct ;; + *) msg_err "no such option" ;; + esac + ;; + *) msg_err "usage: proxmox-node.sh [install|update|remove|backup|restore]" ;; +esac diff --git a/proxmox-server.sh b/proxmox-server.sh new file mode 100755 index 000000000..324fba668 --- /dev/null +++ b/proxmox-server.sh @@ -0,0 +1,267 @@ +#!/usr/bin/env bash +# +# Subshell - Proxmox VE LXC helper (spec 2026-09-28 section 5), the +# community-scripts convention: run on the PROXMOX HOST as root. Creates an +# unprivileged Debian trixie container with Docker inside it, runs the official +# Subshell image from GHCR, and updates it later with: bash /root/proxmox-server.sh +# update (the install saves itself there; a copy you kept works too). Pin at +# install with SUBSHELL_VERSION= (or set IMG); updates re-pull the tag the +# install recorded in /etc/default/subshell-docker - repin by editing that +# file's IMAGE= line (or reinstalling). +# +# Data (database, config.env with its minted secret, plugins, backups) lives on +# the CT's /var/lib/subshell, so updates never touch it. Running panes do not +# survive an update: the container owns its tmux server. + +set -u + +APP="Subshell" +IMG="${IMG:-ghcr.io/subshell-ai/subshell:${SUBSHELL_VERSION:-latest}}" +CT_ID="${CT_ID:-}" +CT_HOSTNAME="${CT_HOSTNAME:-subshell}" +CT_CORES="${CT_CORES:-1}" +CT_RAM_MB="${CT_RAM_MB:-2048}" +CT_DISK_GB="${CT_DISK_GB:-8}" +CT_BRIDGE="${CT_BRIDGE:-vmbr0}" +APP_PORT="${APP_PORT:-3080}" +CT_DATA="/var/lib/subshell" +CT_RUN_ENV="/etc/default/subshell-docker" +CT_NAME="subshell" +TEMPLATE_CACHE="/var/lib/vz/template/cache" + +msg_ok() { echo -e "\e[32m[OK]\e[0m $*"; } +msg_err() { echo -e "\e[31m[ERROR]\e[0m $*" >&2; exit 1; } +header() { echo -e "\e[32m ==>\e[0m \e[1m$1\e[0m"; } + +# fn_prompt VAR QUESTION DEFAULT (PVE_NO_PROMPT=1 takes every default, for +# unattended runs, exactly like the community scripts' var_check posture) +fn_prompt() { + local var="$1" question="$2" def="$3" answer="" + if [[ "${PVE_NO_PROMPT:-0}" == "1" ]]; then + eval "$var=\"\$def\"" + return + fi + read -rp "${question} [${def}]: " answer < /dev/tty || answer="" + eval "$var=\"${answer:-$def}\"" +} + +preflight() { + [[ $EUID -eq 0 ]] || msg_err "run this on the Proxmox host as root" + command -v pct >/dev/null 2>&1 || msg_err "pct not found: this is not a Proxmox host" +} + +need_ct_id() { + [[ -n "$CT_ID" ]] || read -rp "Container ID: " CT_ID < /dev/tty + [[ -n "$CT_ID" ]] || msg_err "no container id given" + pct status "$CT_ID" >/dev/null 2>&1 || msg_err "no CT $CT_ID on this host" +} + +default_storage() { + local s + s=$(pvesm status -content images 2>/dev/null | awk 'NR>1 {print $1; exit}') + echo "${s:-local}" +} + +# Newest debian-13 standard template filename for this host's arch. +latest_template() { + local arch file + [[ "$(uname -m)" == "x86_64" ]] && arch="amd64" || arch="arm64" + file=$(curl -fsSL "https://download.proxmox.com/images/system/" | + grep -oE "debian-13-standard_[0-9.]+-[0-9]+_${arch}\.tar\.zst" | sort -V | tail -1) + [[ -n "$file" ]] || msg_err "no Debian 13 CT template found on download.proxmox.com" + echo "$file" +} + +# Waits for the in-CT web UI; prints nothing. +wait_up() { + local port="$1" _ + for _ in $(seq 1 60); do + pct exec "$CT_ID" -- curl -sf "http://127.0.0.1:${port}/api/setup/status" >/dev/null 2>&1 && return 0 + sleep 2 + done + return 1 +} + +# ---------- install ---------- +install_ct() { + preflight + header "Create the ${APP} container" + local last + last=$(pct list 2>/dev/null | tail -n +2 | awk '{print $1}' | sort -n | tail -1) + if [[ -n "$CT_ID" ]]; then + msg_ok "Container ID ${CT_ID} from the environment" + else + fn_prompt CT_ID "Container ID" "$(( ${last:-100} + 1 ))" + fi + fn_prompt CT_HOSTNAME "Hostname" "$CT_HOSTNAME" + fn_prompt CT_CORES "Cores" "$CT_CORES" + fn_prompt CT_RAM_MB "Memory MB" "$CT_RAM_MB" + fn_prompt CT_DISK_GB "Disk GB" "$CT_DISK_GB" + fn_prompt CT_BRIDGE "Bridge" "$CT_BRIDGE" + fn_prompt APP_PORT "Port the web UI maps on the CT" "$APP_PORT" + + local tmpl st rootpass + st=$(default_storage) + tmpl=$(latest_template) + if [[ ! -f "${TEMPLATE_CACHE}/${tmpl}" ]]; then + header "Downloading ${tmpl} (this can take a minute)" + ( cd "$TEMPLATE_CACHE" && wget -q --show-progress "https://download.proxmox.com/images/system/${tmpl}" ) \ + || msg_err "template fetch failed" + fi + rootpass=$(openssl rand -base64 12) + + local sshkey="" args + [[ -f /root/.ssh/id_ed25519.pub ]] && sshkey=$(cat /root/.ssh/id_ed25519.pub) + [[ -z "$sshkey" && -f /root/.ssh/id_rsa.pub ]] && sshkey=$(cat /root/.ssh/id_rsa.pub) + args=("$CT_ID" "local:vztmpl/${tmpl}" + --unprivileged 1 --features nesting=1 + --hostname "$CT_HOSTNAME" --ostype debian + --memory "$CT_RAM_MB" --swap 512 --cores "$CT_CORES" + --disk "size=${CT_DISK_GB}G" --storage "$st" + --net0 "name=eth0,bridge=${CT_BRIDGE},ip=dhcp" + --password "$rootpass") + [[ -n "$sshkey" ]] && args+=(--ssh-public-keys "$sshkey") + pct create "${args[@]}" || msg_err "pct create failed" + pct start "$CT_ID" || msg_err "pct start failed" + msg_ok "CT $CT_ID created (CT root password: ${rootpass})" +} + +install_docker_in_ct() { + header "Install Docker inside the CT" + local _ + for _ in $(seq 1 60); do pct exec "$CT_ID" -- true 2>/dev/null && break; sleep 1; done + pct exec "$CT_ID" -- bash -ec ' + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq ca-certificates curl >/dev/null + curl -fsSL https://get.docker.com | sh >/dev/null 2>&1 + systemctl enable --now docker + ' || msg_err "Docker install inside the CT failed" + + header "Run the ${APP} container" + pct exec "$CT_ID" -- bash -s -- "$APP_PORT" "$IMG" "$CT_DATA" "$CT_NAME" "$CT_RUN_ENV" <<'REMOTE' + set -eu + port="$1"; img="$2"; data="$3"; name="$4"; runenv="$5" + mkdir -p "$data" + # The image runs as uid 1000; a fresh host dir must be its to write. + chown 1000:1000 "$data" + printf 'APP_PORT=%s\nIMAGE=%s\nDATA=%s\nNAME=%s\n' "$port" "$img" "$data" "$name" > "$runenv" + docker rm -f "$name" >/dev/null 2>&1 || true + docker run -d --name "$name" --restart unless-stopped \ + -p "$port:3080" -v "$data:/data" "$img" >/dev/null +REMOTE + [[ $? -eq 0 ]] || msg_err "the ${APP} container failed to start" + wait_up "$APP_PORT" || msg_err "the container never answered inside the CT (check: pct exec $CT_ID -- docker logs subshell)" + local ip + ip=$(pct exec "$CT_ID" -- hostname -I | awk '{print $1}') + # A `curl | bash` install leaves no file to re-run for the update verb, so + # save our own copy best-effort (the network this needs is a precondition of + # the install anyway; a failure only warns). Held as a string so the URL + # block still prints first. + local update_hint="" + if curl -fsSL https://subshell.sh/proxmox-server.sh -o /root/proxmox-server.sh && chmod 755 /root/proxmox-server.sh; then + update_hint="Update later with: bash /root/proxmox-server.sh update (a copy you kept works too: bash proxmox-server.sh update)" + else + update_hint="WARN: could not save /root/proxmox-server.sh - update later with your own copy: bash proxmox-server.sh update" + fi + msg_ok "${APP} is up: http://${ip}:${APP_PORT}" + echo "Register the first account there - it becomes the admin." + echo "$update_hint" +} + +# ---------- update ---------- +update_app() { + preflight + need_ct_id + header "Pull the new image and recreate the container (running panes end; data survives)" + pct exec "$CT_ID" -- bash -s -- "$CT_RUN_ENV" <<'REMOTE' + set -eu + runenv="$1" + set -a; . "$runenv"; set +a + rollback() { + docker rm -f "$NAME" >/dev/null 2>&1 || true + docker rename "$NAME-old" "$NAME" >/dev/null 2>&1 || true + docker start "$NAME" >/dev/null 2>&1 || true + echo "update failed: the previous container is restored" >&2 + exit 1 + } + # A failed pull changed NOTHING yet - the rollback below is for the + # post-rename steps, where it restores correctly. Deleting the running + # container here would strand the instance on a registry hiccup + # (review finding, operator ruling 2026-09-29). + docker pull "$IMAGE" || { echo "pull failed: the running container was left alone" >&2; exit 1; } + # Clear a stale -old from an interrupted earlier run first: the rollback + # below rm -f's $NAME to resurrect $NAME-old, so a leftover -old would + # make it destroy the HEALTHY container to resurrect garbage. + docker rm -f "$NAME-old" >/dev/null 2>&1 || true + # With any stale -old cleared above, a failed rename means nothing changed + # - the rollback would destroy the healthy container to restore nothing. + docker rename "$NAME" "$NAME-old" || { echo "rename failed: the running container was left alone" >&2; exit 1; } + docker stop "$NAME-old" >/dev/null || rollback + docker run -d --name "$NAME" --restart unless-stopped \ + -p "$APP_PORT:3080" -v "$DATA:/data" "$IMAGE" >/dev/null || rollback + ok="" + for _ in $(seq 1 60); do + curl -sf "http://127.0.0.1:$APP_PORT/api/setup/status" >/dev/null && { ok=1; break; } + sleep 2 + done + [[ -n "$ok" ]] || rollback + docker rm -f "$NAME-old" >/dev/null 2>&1 || true + echo "updated: $(docker exec "$NAME" subshell-server version)" +REMOTE + [[ $? -eq 0 ]] || msg_err "update failed inside CT $CT_ID" + msg_ok "${APP} updated" +} + +# ---------- remove / backup / restore ---------- +remove_ct() { + preflight + need_ct_id + if [[ "${PVE_NO_PROMPT:-0}" != "1" ]]; then + echo "This DESTROYS CT $CT_ID and everything in it." + local a="" + read -rp "Type yes to confirm: " a < /dev/tty + [[ "$a" == "yes" ]] || msg_err "cancelled" + fi + pct shutdown "$CT_ID" 2>/dev/null || true + sleep 3 + pct stop "$CT_ID" 2>/dev/null || true + pct destroy "$CT_ID" + msg_ok "CT $CT_ID destroyed" +} + +backup_ct() { + preflight + need_ct_id + vzdump "$CT_ID" --mode snapshot --compress zstd --storage "$(default_storage)" +} + +restore_help() { + preflight + echo "Restore is Proxmox's own flow: Datacenter -> Backup -> select the vzdump -> Restore." + echo "A ${APP} backup is a full-CT snapshot, so restoring brings back the container, its Docker, and ${CT_DATA} as one." +} + +# ---------- menu ---------- +case "${1:-}" in + install) install_ct; install_docker_in_ct ;; + update) update_app ;; + remove) remove_ct ;; + backup) backup_ct ;; + restore) restore_help ;; + "") + header "Menu" + echo " 1) install 2) update 3) backup 4) restore help 5) remove" + sel="" + read -rp "Select: " sel < /dev/tty + case "$sel" in + 1) install_ct; install_docker_in_ct ;; + 2) update_app ;; + 3) need_ct_id; backup_ct ;; + 4) restore_help ;; + 5) need_ct_id; remove_ct ;; + *) msg_err "no such option" ;; + esac + ;; + *) msg_err "usage: proxmox-server.sh [install|update|remove|backup|restore]" ;; +esac diff --git a/scripts/__tests__/docker-release-verify.test.ts b/scripts/__tests__/docker-release-verify.test.ts new file mode 100644 index 000000000..0be180659 --- /dev/null +++ b/scripts/__tests__/docker-release-verify.test.ts @@ -0,0 +1,148 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { verifyReleaseManifest } from "../../packages/subshell-protocol/src/release-signature.js"; +import { RELEASE_MANIFEST_NAME, RELEASE_MANIFEST_SIG_NAME } from "../../packages/subshell-protocol/src/releases.js"; +import { digestRefusals, stagedBinaryNames } from "../docker-release-verify.ts"; + +/** + * The Docker bake gate (`scripts/docker-release-verify.ts`): it must refuse by + * NAME, and only the signature gate standing between the CLI and the digest + * layer decides what is reachable end-to-end. The publisher private key lives + * only in the release shards' CI secrets, so no test here can sign a manifest + * the REAL pubkey accepts. What is pinned accordingly: + * + * - spawn level: (a) a dir with no manifest, (b) tampered manifest bytes with + * the fixture signature copied in - both exit 1 naming the refusal; + * - fixture-key level for the layers below the signature gate: (c) the + * version/component claims `verifyReleaseManifest` binds after the sig + * (using the protocol fixture's own pubkey, where the fixture signature IS + * valid), and (d) the exported `digestRefusals` loop against a supplied + * signed-assets map, plus `stagedBinaryNames`' derivation. + */ + +const SCRIPT = join(import.meta.dir, "..", "docker-release-verify.ts"); +const FIXTURES = join( + import.meta.dir, + "..", + "..", + "packages/subshell-protocol/src/__tests__/fixtures/release-signature", +); +const fixtureManifest = readFileSync(join(FIXTURES, "release-manifest.json")); +const fixtureSig = readFileSync(join(FIXTURES, "release-manifest.sig"), "utf8"); +const fixturePubkey = readFileSync(join(FIXTURES, "publisher-pubkey.txt"), "utf8"); + +let root: string; + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), "docker-verify-")); +}); +afterAll(() => { + rmSync(root, { recursive: true, force: true }); +}); + +/** Run the CLI as CI does; returns exit code + combined stderr. */ +async function run(dir: string, version: string): Promise<{ code: number; stderr: string }> { + const proc = Bun.spawn([process.execPath, "run", SCRIPT, dir, version], { + cwd: join(import.meta.dir, "..", ".."), + stdout: "pipe", + stderr: "pipe", + }); + // `proc.exited`, not `proc.exitCode`: on bun 1.4.0 the latter resolves null + // for a piped spawn (measured here), and the refusal's exit code is the + // whole assertion. + const code = await proc.exited; + const stderr = await new Response(proc.stderr).text(); + return { code, stderr }; +} + +describe("docker-release-verify CLI refusals (spawned, as the workflow runs it)", () => { + test("missing manifest: refused by name", async () => { + const dir = join(root, "empty"); + mkdirSync(dir, { recursive: true }); + const res = await run(dir, "9.9.9"); + expect(res.code).toBe(1); + expect(res.stderr).toContain(RELEASE_MANIFEST_NAME); + }); + + test("tampered manifest bytes with the signature copied in: refused, naming the signature", async () => { + const dir = join(root, "tampered"); + mkdirSync(dir, { recursive: true }); + const tampered = JSON.parse(fixtureManifest.toString("utf8")); + tampered.version = "9.9.10"; + writeFileSync(join(dir, RELEASE_MANIFEST_NAME), JSON.stringify(tampered, null, 2)); + writeFileSync(join(dir, RELEASE_MANIFEST_SIG_NAME), fixtureSig); + const res = await run(dir, "9.9.10"); + expect(res.code).toBe(1); + expect(res.stderr).toContain("signature"); + }); + + test("no version argument: usage refusal", async () => { + const res = await run(root, ""); + expect(res.code).toBe(1); + expect(res.stderr).toContain("usage"); + }); +}); + +describe("the layers under the signature gate", () => { + test("the fixture is self-consistent under ITS pubkey, and wrong claims are refused", async () => { + // Control: the pair verifies, so the refusals below are the claims failing, + // not broken fixture bytes. + const good = await verifyReleaseManifest(fixtureManifest, fixtureSig, fixturePubkey, { + component: "cli-node", + version: "9.9.9", + }); + expect(good.ok).toBe(true); + + const wrongVersion = await verifyReleaseManifest(fixtureManifest, fixtureSig, fixturePubkey, { + component: "cli-node", + version: "1.2.3", + }); + expect(wrongVersion.ok).toBe(false); + + // The script's own call pins component cli-server; a node release's + // signature can never carry it (the replay defense). + const wrongComponent = await verifyReleaseManifest(fixtureManifest, fixtureSig, fixturePubkey, { + component: "cli-server", + version: "9.9.9", + }); + expect(wrongComponent.ok).toBe(false); + }); + + test("staged names derive from SERVER_TARGETS, not a hardcoded spelling", () => { + const dir = join(root, "staged"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "subshell-server-cli-linux-x64"), "binary"); + expect(stagedBinaryNames(dir)).toEqual(["subshell-server-cli-linux-x64"]); + }); + + test("digest layer: mismatch refused, name absent from the signed map refused, true digest accepted", () => { + const dir = join(root, "digests"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "subshell-server-cli-linux-x64"), "one"); + writeFileSync(join(dir, "subshell-server-cli-linux-arm64"), "two"); + const hex = (s: string) => createHash("sha256").update(s).digest("hex"); + + const wrong = digestRefusals(dir, { + "subshell-server-cli-linux-x64": hex("one"), + "subshell-server-cli-linux-arm64": hex("tampered"), + }); + expect(wrong.length).toBe(1); + expect(wrong[0]).toContain("subshell-server-cli-linux-arm64"); + expect(wrong[0]).toContain("is not the signed"); + + const absent = digestRefusals(dir, { "subshell-server-cli-linux-x64": hex("one") }); + expect(absent.length).toBe(1); + expect(absent[0]).toContain("subshell-server-cli-linux-arm64"); + expect(absent[0]).toContain("(absent from the manifest)"); + + expect( + digestRefusals(dir, { + "subshell-server-cli-linux-x64": hex("one"), + "subshell-server-cli-linux-arm64": hex("two"), + }), + ).toEqual([]); + }); +}); diff --git a/scripts/cli-e2e/docker-image.sh b/scripts/cli-e2e/docker-image.sh new file mode 100755 index 000000000..cbd3c0304 --- /dev/null +++ b/scripts/cli-e2e/docker-image.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# +# The container end-to-end scenario: boots a BUILT Subshell image and asserts +# the container contract from the outside - version, PATH, per-install secret, +# the image-pull update story, and a recreate that keeps config.env +# byte-identical. (spec 2026-09-28 § 8) +# +# bash scripts/cli-e2e/docker-image.sh ghcr.io/subshell-ai/subshell:1.2.3 [1.2.3] +# +# Takes an image ref (any locally-built tag works too) and optionally the +# version its `version` verb must print. Needs docker and network (the update +# --check hits the release source, and a fresh pull needs GHCR). Deliberately +# NOT wired into run.sh: the suite there builds from source; this one tests a +# finished image. Port 31998 (the 31992-31999 family). +set -euo pipefail +IMAGE="${1:?usage: docker-image.sh [version]}" +EXPECTED_VERSION="${2:-}" +NAME="subshell-e2e-$$" +VOL="subshell-e2e-$$" +PORT=31998 +cleanup() { docker rm -f "$NAME" >/dev/null 2>&1 || true; docker volume rm "$VOL" >/dev/null 2>&1 || true; } +trap cleanup EXIT +docker volume create "$VOL" >/dev/null + +boot() { + docker run -d --name "$NAME" --restart unless-stopped -p "$PORT:3080" -v "$VOL:/data" "$IMAGE" >/dev/null + for _ in $(seq 1 60); do + curl -sf "http://127.0.0.1:$PORT/api/setup/status" >/dev/null && return 0 + sleep 1 + done + echo "FAIL: the server never answered /api/setup/status" >&2 + docker logs "$NAME" >&2 + return 1 +} + +echo "==> boot + readiness" +docker pull "$IMAGE" >/dev/null 2>&1 || true # a local build has nothing to pull; fine +boot + +echo "==> version" +VERSION_OUT=$(docker exec "$NAME" subshell-server version) +echo " $VERSION_OUT" +if [ -n "$EXPECTED_VERSION" ]; then + [ "$VERSION_OUT" = "subshell-server $EXPECTED_VERSION" ] || { echo "FAIL: expected subshell-server $EXPECTED_VERSION" >&2; exit 1; } +fi + +echo "==> tmux + the five harnesses resolve in-container" +docker exec "$NAME" bash -c 'command -v tmux claude codex opencode hermes pi' >/dev/null + +echo "==> the update rail names the image as the unit of update" +CHECK_JSON=$(docker exec "$NAME" subshell-server update --check --json || true) +echo "$CHECK_JSON" | grep -q '"containerized":true' || { echo "FAIL: --check did not report containerized: $CHECK_JSON" >&2; exit 1; } + +echo "==> secret minted once, stable across a recreate" +SECRET1=$(docker exec "$NAME" sha256sum /data/config.env) +docker rm -f "$NAME" >/dev/null +boot +SECRET2=$(docker exec "$NAME" sha256sum /data/config.env) +# The volume survived the recreate, so config.env must be byte-identical - the +# same secret, not a re-mint one: init's keep-existing branch, verified. +[ "$SECRET1" = "$SECRET2" ] || { echo "FAIL: config.env changed across a container recreate" >&2; exit 1; } +docker exec "$NAME" bash -c 'grep -q "^BETTER_AUTH_SECRET=.\{32,\}" /data/config.env' + +echo "OK: docker-image scenario passed for $IMAGE" diff --git a/scripts/dep-audit.ignore.json b/scripts/dep-audit.ignore.json index dc7740989..84c2916dd 100644 --- a/scripts/dep-audit.ignore.json +++ b/scripts/dep-audit.ignore.json @@ -4,6 +4,11 @@ "advisories": ["GHSA-vcc3-ghjq-m6fr"], "reason": "mobile runtime: query-string 7.1.3 pins ^0.2.2 and the fix is 0.5.0, so the upgrade is upstream's to make — tracked 2026-09-23" }, + { + "package": "fast-uri", + "advisories": ["GHSA-qw65-cvwx-89v3", "GHSA-58mr-gqgx-xq4g"], + "reason": "dev-only tooling: the only consumer is ajv 8.20.0 under @commitlint/config-validator (the commit hook), which validates this repo's own commitlint config; the advisories are authority handling in serialize/normalize, which never sees untrusted URIs here — tracked 2026-09-29" + }, { "package": "glob", "advisories": ["GHSA-5j98-mcp5-4vw2"], @@ -14,6 +19,11 @@ "advisories": ["GHSA-w3rx-r6r6-pgpr", "GHSA-5p2g-fcmc-qvqq"], "reason": "mobile build tooling: metro 0.87.0 pins ^1.0.2 and the fix is the 2.0.3 major — upstream's to take, tracked 2026-09-23" }, + { + "package": "undici", + "advisories": ["GHSA-3wwx-pv8p-q78v"], + "reason": "server runtime, wrong surface: the only consumer is expo-server-sdk 7.2.0 (push), which uses undici's fetch against exp.host; the advisory is the WebSocket client's permessage-deflate path, nothing here dials an outbound WebSocket through undici (the node agent is a compiled Bun binary on Bun's own WebSocket), and server-side /ws is Elysia's — tracked 2026-09-29" + }, { "package": "uuid", "advisories": ["GHSA-w5hq-g745-h8pq"], diff --git a/scripts/docker-release-verify.ts b/scripts/docker-release-verify.ts new file mode 100644 index 000000000..117242b23 --- /dev/null +++ b/scripts/docker-release-verify.ts @@ -0,0 +1,104 @@ +#!/usr/bin/env bun +// Verify staged release assets before they are baked into the Docker image +// (spec 2026-09-28 § 4). The rule is the product's own: the minisign signature +// over the manifest's exact bytes FIRST, then digests from the SIGNED assets +// map - never a .sha256 sidecar. Bad sig / bad digest / no manifest: refuse BY +// NAME, exit 1; the workflow pushes only on exit 0. +// +// bun run scripts/docker-release-verify.ts +// +// must hold release-manifest.json + .sig; either or both linux server +// binaries may be present (each per-arch CI job stages its own), and every +// present one is digest-checked. + +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { SERVER_TARGETS, serverArtifactFileName } from "../packages/subshell-protocol/src/paths.js"; +import { verifyReleaseManifest } from "../packages/subshell-protocol/src/release-signature.js"; +import { + RELEASE_MANIFEST_NAME, + RELEASE_MANIFEST_SIG_NAME, + RELEASE_PUBKEY, +} from "../packages/subshell-protocol/src/releases.js"; + +/** + * The linux server binaries present in `dir`, named from the protocol's own + * naming (`serverArtifactFileName` over the linux half of `SERVER_TARGETS`), + * so a rename in paths.ts can never leave this script looking for a file the + * pipeline no longer publishes. + */ +export function stagedBinaryNames(dir: string): string[] { + return SERVER_TARGETS.filter((target) => target.startsWith("linux-")) + .map((target) => serverArtifactFileName(target)) + .filter((n) => existsSync(join(dir, n))); +} + +/** + * Refusal lines for every staged binary whose digest is not the one the + * SIGNED assets map names; empty means everything staged matches (and each + * match is logged as it checks). `assets` is the verified manifest's map, so + * a name absent from it is a refusal, not a skip. + * + * Exported so `scripts/__tests__/docker-release-verify.test.ts` can drive the + * digest layer directly: reaching it through `main` end-to-end would need a + * manifest signed by the real publisher key, whose secret exists only in the + * release shards' CI secrets. + */ +export function digestRefusals(dir: string, assets: Record): string[] { + const refusals: string[] = []; + for (const name of stagedBinaryNames(dir)) { + const expected = assets[name]; + const actual = createHash("sha256") + .update(readFileSync(join(dir, name))) + .digest("hex"); + if (expected === undefined || expected !== actual) { + refusals.push(`${name} digest ${actual} is not the signed ${expected ?? "(absent from the manifest)"}`); + } else { + console.log(`verified ${name} ${actual.slice(0, 12)}...`); + } + } + return refusals; +} + +async function main(): Promise { + const [dir, version] = process.argv.slice(2); + if (!dir || !version) { + console.error("usage: docker-release-verify.ts "); + process.exit(1); + } + + const manifestPath = join(dir, RELEASE_MANIFEST_NAME); + const sigPath = join(dir, RELEASE_MANIFEST_SIG_NAME); + if (!existsSync(manifestPath) || !existsSync(sigPath)) { + console.error( + `refused: ${dir} holds no ${RELEASE_MANIFEST_NAME} (+ .sig); nothing is trusted without the signature`, + ); + process.exit(1); + } + + const verdict = await verifyReleaseManifest( + readFileSync(manifestPath), + readFileSync(sigPath, "utf8"), + RELEASE_PUBKEY, + { component: "cli-server", version }, + ); + if (!verdict.ok) { + console.error(`refused: ${verdict.reason}`); + process.exit(1); + } + + const names = stagedBinaryNames(dir); + if (names.length === 0) { + console.error("refused: the staged dir holds no linux server binary to bake"); + process.exit(1); + } + const refusals = digestRefusals(dir, verdict.manifest.assets); + if (refusals.length > 0) { + for (const line of refusals) console.error(`refused: ${line}`); + process.exit(1); + } + console.log(`verified: release-manifest.json for cli-server ${version} + ${names.length} linux binary file(s)`); +} + +if (import.meta.main) await main();