Skip to content

feat(docker): restore in-app self-update in the Compose dep - #373

Merged
Ark0N merged 1 commit into
Ark0N:masterfrom
opticon454:feature/docker-self-update
Sep 4, 2026
Merged

feat(docker): restore in-app self-update in the Compose dep#373
Ark0N merged 1 commit into
Ark0N:masterfrom
opticon454:feature/docker-self-update

Conversation

@opticon454

Copy link
Copy Markdown
Contributor

Codeman running under docker/docker-compose.yaml lost the ability to update itself from App Settings -> Updates. The image had no .git (excluded by .dockerignore), so the install reported as "unknown"; there was no init system for detectSupervisor() to find; the runtime stage had neither devDependencies nor a build toolchain; and a pull into the baked /opt/codeman would have landed in the container's writable layer and been discarded by the next up.

Restore it through configuration rather than a second updater, so the release channel, auto-stash, status file and boot reconcile are all reused unchanged:

  • The checkout Compose builds from is bind-mounted over /opt/codeman, so the update's git checkout and rebuild land on the host and survive recreation.
  • The restart is the server exiting; restart: unless-stopped relaunches the container on the new dist/. This is the one supervisor whose updater does NOT outlive the restart, which is safe only because the terminal "restarting" marker is written first.
  • node_modules and dist are named volumes over the bind mount, so container-compiled native modules never enter the host checkout.
  • The runtime image keeps devDependencies and gains python3/make/g++, since npm run build is tsc + esbuild and node-pty has no Linux prebuild.

An in-place container update applies code only, because a restart reuses the existing image and config. evaluateEnvironmentGate() reads the target release's own files with git show <tag>:<path> and refuses when server.Dockerfile or docker-compose.yaml changed, when .env.example gained keys the user's .env lacks, or when the restart policy would not bring the container back. The missing-key check matters most: Compose resolves an unset ${VAR} to the empty string and starts anyway, so a new required setting would otherwise arrive as a silently blank variable. Every unknown fails open, and the gate is re-evaluated server-side on POST /api/system/update.

The four global agent CLIs are pinned, because an unpinned CLI bump is the one environment change no diff-derived gate can see; pinning turns it into a Dockerfile change the gate already detects.

Adds test/docker-compose-env-parity.test.ts as the merge-side guard (every compose ${VAR} has an .env.example entry and the reverse) and test/docker-self-update.test.ts for the pure gate decisions.

Documented in docs/docker-self-update.md.

Codeman running under docker/docker-compose.yaml lost the ability to update
itself from App Settings -> Updates. The image had no .git (excluded by
.dockerignore), so the install reported as "unknown"; there was no init system
for detectSupervisor() to find; the runtime stage had neither devDependencies
nor a build toolchain; and a pull into the baked /opt/codeman would have landed
in the container's writable layer and been discarded by the next `up`.
Restore it through configuration rather than a second updater, so the release
channel, auto-stash, status file and boot reconcile are all reused unchanged:
- The checkout Compose builds from is bind-mounted over /opt/codeman, so the
update's git checkout and rebuild land on the host and survive recreation.
- The restart is the server exiting; `restart: unless-stopped` relaunches the
container on the new dist/. This is the one supervisor whose updater does NOT
outlive the restart, which is safe only because the terminal "restarting"
marker is written first.
- node_modules and dist are named volumes over the bind mount, so
container-compiled native modules never enter the host checkout.
- The runtime image keeps devDependencies and gains python3/make/g++, since
`npm run build` is tsc + esbuild and node-pty has no Linux prebuild.
An in-place container update applies code only, because a restart reuses the
existing image and config. evaluateEnvironmentGate() reads the target release's
own files with `git show <tag>:<path>` and refuses when server.Dockerfile or
docker-compose.yaml changed, when .env.example gained keys the user's .env
lacks, or when the restart policy would not bring the container back. The
missing-key check matters most: Compose resolves an unset ${VAR} to the empty
string and starts anyway, so a new required setting would otherwise arrive as a
silently blank variable. Every unknown fails open, and the gate is re-evaluated
server-side on POST /api/system/update.
The four global agent CLIs are pinned, because an unpinned CLI bump is the one
environment change no diff-derived gate can see; pinning turns it into a
Dockerfile change the gate already detects.
Adds test/docker-compose-env-parity.test.ts as the merge-side guard (every
compose ${VAR} has an .env.example entry and the reverse) and
test/docker-self-update.test.ts for the pure gate decisions.
Documented in docs/docker-self-update.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yAQ2y9t81jzSfpStUxx5T
Ark0N pushed a commit that referenced this pull request Sep 4, 2026
…rt it
#373 restarts the Compose container by exiting the server, which is right for
the shipped deployment: `restart: unless-stopped` relaunches it. The updater
verified that policy through the Docker socket and, when it could not (no
socket mounted), failed open and exited anyway. Failing open is the correct
choice for the GATE, where refusing would block every install without a
socket, but not for the kill: a container the daemon does not restart goes
down for good, with no UI left to recover it from. That is exactly the case a
plain `docker run` of this image without `--restart` produces, and the image
sets CODEMAN_IN_CONTAINER=1 itself, so it takes the container path.
The decision now happens server-side, where both the socket and the Compose
env are reachable, and rides down to the script as `--restart-by-exit 0|1`.
It is 1 when the Compose file declared `CODEMAN_RESTART_BY_EXIT=1` (added there
and only there, since that file is what sets the restart policy; the image ENV
deliberately does not) or when the daemon confirmed an auto-restart policy.
Otherwise the build still lands, the status becomes
`completed-needs-manual-restart` with the `docker restart` hint, and the
server keeps running. The shipped deployment is unchanged in effect: with the
socket it was already confirmed, and without it the declaration now covers it.
Also: a root-run `Start-Codeman.sh` (common on Unraid) created the
fingerprint baseline's `.codeman` directory before the container's first start
and left it root-owned, which the unprivileged server could then never write
its own state into. It is chowned to PUID:PGID when running as root.
Verified with a real image build of the merged tree (classic builder; this
box's BuildKit lacks buildx): runs as uid 1000, tsc/esbuild and the toolchain
present, the four CLIs at their pins, docker/.env absent, and `docker inspect
$HOSTNAME` returns the restart policy through the mounted socket as that user.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg6bcATm1pNNY4kQWGwzgu
@Ark0N
Ark0N merged commit 823f56a into Ark0N:masterSep 4, 2026
2 checks passed
@Ark0N

Ark0N commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Merged, thank you. This is a clean way to get updates back without a second updater, and the env-keys check is the part I would not have thought to add. I built the image from the merged tree (2.64 GB, classic builder), checked it runs as the unprivileged user with tsc/esbuild, the toolchain and the four pinned CLIs in place and no docker/.env baked in, and confirmed docker inspect $HOSTNAME answers the restart policy through the mounted socket as that user. Full gate green on the merge (334 files, 6512 tests).

One change went in right behind it (99ad9cb), and I want to explain it rather than bury it. The gate failing open on an unknown restart policy is right, but the kill inherited that: with no socket the script exited the server regardless, and a container nothing restarts (a plain docker run of this image without --restart, which takes the container path because the image sets CODEMAN_IN_CONTAINER=1 itself) goes down with no UI left to recover it. The decision now happens server-side and rides down as --restart-by-exit 0|1: 1 when the Compose file declares CODEMAN_RESTART_BY_EXIT=1 (added there and only there, since that file is what sets restart: unless-stopped) or the daemon confirms an auto-restart policy, otherwise the build lands as completed-needs-manual-restart with the docker restart hint. The shipped deployment behaves exactly as you designed it. Same commit chowns the fingerprint baseline's .codeman dir to PUID:PGID when Start-Codeman.sh runs as root, since on a first start it would otherwise be created root-owned before the container ever gets to it.

Follow-ups, none blocking:

  • ~600 MB of the image is devDependencies the build never needs (remotion, rspack, rolldown, changesets, playwright-core). Since the updater runs npm install --include=dev anyway, keeping npm prune --omit=dev in the image and letting the first in-app update fetch them into the named volume would give most of that back.
  • The pins are already a step behind npm (claude 2.1.258 vs 2.1.260, codex 0.152.1 vs 0.153.2, opencode 1.18.26 vs 1.18.27). Fine, and it proves the point of pinning; worth a bump line in the release checklist.
  • The changeset is marked minor; it will get folded into the next release note like the others.

@Ark0NArk0N mentioned this pull request Sep 4, 2026
opticon454 added a commit to opticon454/Codeman that referenced this pull request Sep 4, 2026
docker/agent.Dockerfile hardcoded the four npm-published CLIs it installs, one
of the several lists that had to be kept in step with the registry by hand.
It now takes them as `ARG CLI_NPM_PACKAGES`, supplied by
scripts/build-agent-image.mjs from config/clis.stock.json, with the default set
to today's list so a bare `docker build` still produces the same image. The arg
is expanded unquoted because word splitting is what turns the list into several
arguments, which is exactly why every token is validated against
^[@A-Za-z0-9][@A-Za-z0-9/._-]*$ on the producing side; a package name carrying a
space or a metacharacter is refused rather than reaching the RUN line. Verified
by building the layer: four packages in, four arguments out, and the default
still applies with no arg.
The list is filtered on each entry's `enabled` flag — the field whose absence
was the maintainer's §3 finding, where a CLI shipping disabled still got baked
into every image. No stock entry is disabled today, so that assertion would pass
vacuously; a unit test feeds the pure helper a fabricated disabled entry so the
fix is covered now rather than the first time someone ships one.
⚠️ It reads the STOCK catalogue, never the merged registry. A user's
~/.codeman/clis.json must not change what is inside an image tagged
codeman/agent:base, or two machines holding that tag hold different images.
Four CLIs keep hand-written layers because the registry cannot describe what
makes them special: pi's --ignore-scripts, deepseek's pnpm companion and dsh-tui
profile, and the three standalone installers. Rather than extend the schema for
a Docker-only benefit, the coverage test requires each to carry a written reason
AND still be present, so an exclusion cannot quietly become an omission.
There are two producers of this command line and there have to be — the .mjs
cannot import TypeScript, and src/docker-hosts.ts builds the same argv for the
in-app auto-build — so a parity test pins them together, package list, arg pairs
and rendered argv. Their order is pinned too: a different order is a different
RUN string and so a needless cache miss between the two build paths.
docker/server.Dockerfile is deliberately NOT edited (PRs Ark0N#373 and Ark0N#377 both
modify it); its narrower list is asserted as a declared omission list instead, so
the divergence is reviewable without touching the file.
Also fixes the in-app hint at index.html, which the new coverage test caught
still omitting omp.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015EMxQreQUZX5ZyybxAGh12
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@opticon454@Ark0N