Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
1c192a7
docs(spec): docker image + proxmox lxc install rail
theogravity Sep 29, 2026
daeba65
docs(plan): docker image + proxmox lxc implementation plan
theogravity Sep 29, 2026
e7b57bb
feat(deployment): containerized fact with the image-pull restart reason
theogravity Sep 29, 2026
92afcb0
feat(updates): UPDATE_CONTAINERIZED refusal naming the image-pull remedy
theogravity Sep 29, 2026
8a6a8e3
feat(cli): update --check names the container as the unit of update
theogravity Sep 29, 2026
b22aaab
feat(docker): release image packaging the signed cli-server binary wi…
theogravity Sep 29, 2026
0eea6fa
test(cli-e2e): docker image scenario (version, PATH, containerized ra…
theogravity Sep 29, 2026
c286491
docs(plan): first green smoke needs a post-merge release cut
theogravity Sep 29, 2026
af4d9d4
feat(ci): docker-image workflow packages the verified release into GH…
theogravity Sep 29, 2026
4fcf3d6
feat(proxmox): host helper script (install/update/remove/backup/resto…
theogravity Sep 29, 2026
8154a7c
docs(plan): proxmox update must not strand a running container; tty r…
theogravity Sep 29, 2026
6caad30
fix(proxmox): leave the running container alone on pull failure; tty …
theogravity Sep 29, 2026
0fd1045
docs: docker rewrite, proxmox lxc guide, updating pointer
theogravity Sep 29, 2026
f85cab9
chore(release): changeset for the docker + proxmox rail (server minor…
theogravity Sep 29, 2026
be01ee2
fix(docker-image): CI ordering, interpolation hardening, PATH precedence
theogravity Sep 29, 2026
ed842a1
fix(rail): entrypoint setsid, --check container prose, docs and scrip…
theogravity Sep 29, 2026
dfe4d27
fix(proxmox): a failed rename left unmutated must not roll back
theogravity Sep 29, 2026
3314a64
fix(compose): the pinned-secret example must not expand empty
theogravity Sep 29, 2026
a1f7a76
fix(proxmox): pin knob, self-saving install, tty-init guards
theogravity Sep 29, 2026
344dc88
fix(rail): website changeset, verify-script test, CI and docs sweep
theogravity Sep 29, 2026
d6ecc65
fix(rail): comment truth, CT_ID env honor, gitignore, test wording pin
theogravity Sep 29, 2026
812e2d0
docs(docker): the 777 serves named volumes, never bind mounts
theogravity Sep 29, 2026
2c2e698
docs(spec): node story - docs page, site line, proxmox node helper
theogravity Sep 29, 2026
aed808e
docs(spec): node rail scoped to what is actually missing (site line a…
theogravity Sep 29, 2026
c40fbde
docs(plan): proxmox node rail
theogravity Sep 29, 2026
5d1ef4b
feat(proxmox): node helper - plain LXC enrolling through the instance…
theogravity Sep 29, 2026
29683d8
fix(proxmox-node): pipefail on the rail pipe; plural step name
theogravity Sep 29, 2026
f17bedc
docs: node-on-proxmox cross-links
theogravity Sep 29, 2026
23be190
chore(release): changeset names the node helper
theogravity Sep 29, 2026
6b052a9
fix(proxmox-node): accept the app's server= suffix; refuse loopback; …
theogravity Sep 29, 2026
68d2faa
fix(proxmox-node): carried server= clause refuses loopback too; manag…
theogravity Sep 29, 2026
fac8c80
rename: proxmox.sh -> proxmox-server.sh (pair with proxmox-node.sh); …
theogravity Sep 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/docker-proxmox-lxc-rail.md
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
168 changes: 168 additions & 0 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
@@ -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"
6 changes: 6 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
136 changes: 58 additions & 78 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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-<arch> 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"]
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/get-started/add-a-machine.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading