Skip to content

Feature/docker compose - #349

Merged
Ark0N merged 2 commits into
Ark0N:masterfrom
opticon454:feature/docker-compose
Sep 1, 2026
Merged

Feature/docker compose#349
Ark0N merged 2 commits into
Ark0N:masterfrom
opticon454:feature/docker-compose

Conversation

@opticon454

Copy link
Copy Markdown
Contributor

Summary

This PR adds first-party Docker Compose support for Codeman while preserving its ability to create and manage isolated agent containers.

With this PR Codeman itself runs inside a container & it can still spawn isolated Claude, Codex, Gemini, OpenCode, and shell case containers through the host Docker daemon. These are sibling containers, not nested Docker-in-Docker containers.

The Compose deployment:

  • Runs Codeman and local agent sessions as an unprivileged user.
  • Mounts the host Docker socket for isolated container management.
  • Translates container-side credential and seed paths into paths visible to the host Docker daemon.
  • Uses a shared host-absolute workspace path for isolated cases.
  • Retains the existing isolated-container security and resource settings.

Motivation

Codeman was originally designed to run directly on a host where Codeman and the Docker daemon shared the same filesystem namespace.

Not everyone want's to run applications directly on their desktop and docker is a perfect way to isolate systems. It also allows end-users to run linux applications on Windows using WSL2.

Docker containers also run as a system level so you don't even have to log into your desktop in order to use CM because docker is running as a service.

Future development of CM can also be adapted to spin up named containers based on the branch name to test multiple feature and bugfix additons with AI. Simply ask your AI to spin up a new instance on a new branch with a prefix.

Docker build vs published Image

This setup is building the docker image manually, there's no requirement for actions/pipelines to create an image and publish it to dockerhub (although this can be added at any point in time).

Creating the CM container this way at this point of CM's development allows for a simple git pull and then re-rerun the Start-Container.sh script to rebuild the image with the latest version or use CM's built-ni self update tool.

CodeMan can also self-update within the container and just requires a restart to use the latest version. A future idea would be to add in a self-terminate and restart capability to the container doesn't even need to be restarted.

Application Data

Codemans' user folder is passed mapped through to a docker host volume or folder, preserving all history during rebuilds or updates.

User running context

Running Codeman as root was not a suitable workaround because some agent CLIs, particularly Claude Code, refuse or fail to operate correctly as root. A dedicated user is created during the container build, username is set as per the .env file.

Isolated container support

The Compose deployment uses Docker-outside-of-Docker:

  1. Codeman connects to the host Docker daemon through /var/run/docker.sock.
  2. Codeman creates each isolated case as a sibling container on that daemon.
  3. CODEMAN_CASES_PATH provides a workspace path that is identical inside Codeman and on the Docker host.
  4. CODEMAN_DOCKER_HOST_HOME translates generated credential, seed, transcript, and hook-secret paths from Codeman's container filesystem into host-visible paths.
  5. The isolated container is then started and accessed using the existing Docker session implementation.

Docker itself is not run as a daemon inside the Codeman container. Only the Docker CLI is installed in the image.

Direct host installations continue using the existing behaviour because path translation is enabled only when CODEMAN_DOCKER_HOST_HOME is configured.

Changes

  • docker/server.Dockerfile
  • docker/.env.example
  • docker/Start-Codeman.sh
  • Run Codeman and local agent sessions as an unprivileged account, defaulting to opencode.
  • Made the runtime username configurable through CODEMAN_RUNTIME_USER.
  • Detect the application-data directory's PUID and PGID in the Linux start script.
  • Create a matching image account and group when the requested numeric IDs do not exist.
  • Detect the Docker socket group ID so the unprivileged account can access the host daemon.
  • Added CODEMAN_CASES_PATH for host-visible isolated workspaces.
  • Added CODEMAN_DOCKER_HOST_HOME translation for:
    • Agent credentials
    • Docker seed files
    • Claude transcripts
    • Hook configuration and secrets
  • Added optional CODEMAN_DOCKER_DISABLE_SWAP_LIMIT compatibility for hosts without swap accounting:
    • Retains the configured memory limit.
    • Omits the unsupported --memory-swap option.
    • Filters only Docker's exact swap-capability warning.
    • Preserves all other Docker errors and exit statuses.
  • Handled concurrent attempts to create the same isolated case container without hiding genuine creation failures.
  • Prevented saved Claude conversation IDs from being passed to Codex, Gemini, or other agent CLIs.
  • Added deployment, storage, networking, and migration documentation.
  • Added regression tests for the Compose-specific Docker behaviour.

Security

  • The Codeman application container does not run as root.
  • Linux capabilities are dropped.
  • no-new-privileges is enabled.
  • Agent credentials remain in the configured host bind mount and are not baked into the image.
  • Isolated case containers retain the existing security, process, CPU, and memory restrictions.
  • Docker socket access is highly privileged by nature. Anyone with administrative access to Codeman or its runtime account should be considered capable of controlling the connected Docker daemon.
  • The password in .env.example must be replaced before exposing Codeman to a network.

Backwards compatibility

Direct host installations retain their existing behaviour when the new Compose-specific environment variables are absent.

Existing isolated Docker cases continue using the normal host-native path handling outside Compose deployments.

Swap limiting retains its current behaviour unless CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1 is explicitly configured.

Validation

The complete validation suite passed in a clean Linux container with Node.js 22 and tmux installed:

Test Files 316 passed | 1 skipped (317)
Tests 6192 passed | 12 skipped (6204)

The following checks passed:

npm run typecheck
npm run lint
npm run format:check
npm run check:frontend-syntax
npm test
bash -n docker/Start-Codeman.sh
docker compose --env-file docker/.env.example \
-f docker/docker-compose.yaml config --quiet

The server image was built and validated with multiple host identity combinations:

PUID=1000 PGID=12345
PUID=99 PGID=100

Manual end-to-end validation was performed on an Unraid Docker host, including:

  • Starting Codeman as the unprivileged opencode account.
  • Creating new isolated case containers through the host Docker socket.
  • Mounting case workspaces, credentials, and seed files successfully.
  • Starting and reconnecting agent sessions inside existing isolated containers.
  • Running Claude, Codex, and Gemini without incorrectly sharing Claude resume identifiers.

@Ark0N

Copy link
Copy Markdown
Owner

Thanks for this, and thanks especially for the write-up. The motivation section, the storage/migration notes and the explicit Docker-outside-of-Docker framing made this much faster to review than most infrastructure PRs.

How I verified: merged the PR head into current master (clean merge; the branch is roughly 87 lines behind in session-routes.ts but applies), then ran tsc --noEmit (exit 0) and the four affected test files (test/docker-hosts.test.ts, test/docker-exec-options.test.ts, test/routes/session-routes-workspace-hooks.test.ts, test/cli-skill-target.test.ts): 91 tests, all passing. Everything below marked "verified" was reproduced with a real docker / docker compose run rather than read off the diff.

What is clearly right

The Claude-resume fix is a genuine bug and it is not Compose-specific.appendResumeFlag() (tmux-manager.ts:1237) maps a resume id onto codex resume, gemini --resume, pi --session, grok / deepseek / omp --resume and antigravity --conversation, and RESUME_ID_SAFE accepts a UUID, so dockerCase.lastClaudeSessionId really was being handed to every non-Claude CLI in a Docker case. The mode === 'claude' guard is the right fix and the two new tests pin it in both directions. Please split this into its own small PR; it can merge immediately, independently of the Compose work.

resolveDockerDaemonMountSource() is pure, unit-tested and sits next to the other resolvers where it belongs. I traced the call site: every mount that reaches docker create through resolveDockerLaunchOptions (Claude artifacts, credential artifacts, hook secret) lands in extraMounts, and the .map() runs after all the pushes, so the coverage there is complete.

The root .dockerignore also shrinks the agent image build context as a side effect (build-agent-image.mjs passes REPO_ROOT, and agent.Dockerfile has no COPY, so no behaviour change, just a much smaller tar). Nice bonus.

The swap-limit compatibility path preserves the real exit status and re-inspects on a lost create race, and the comment explaining why command substitution would expand too early through the nested bash/tmux layers is correct.

Blocking

1. docker/.env is baked into the image, password included..dockerignore patterns match the whole context-relative path, so a bare .env excludes only the root file. Reproduced with a minimal build:

/ctx/docker: .env
--- docker/.env contents:
CODEMAN_PASSWORD=hunter2

COPY . . picks it up in the build stage, and COPY --from=build /opt/codeman /opt/codeman carries it into the final image at /opt/codeman/docker/.env, where it is readable by every agent session running in that container and by anyone the image is ever docker saved or pushed to. Fix: add **/.env (keep the existing .env line too).

2. Nothing refuses to start with the placeholder password..env.example ships CODEMAN_PASSWORD=changeme right next to CODEMAN_HOST=0.0.0.0 and a published port. Start-Codeman.sh already validates appdata ownership, socket type and root-owned directories, all of which is good, but not this one. A hard failure on empty-or-changeme matches what install.sh does for the LAN path and costs about three lines.

Correctness

3. CASES_DIR now has two sources of truth.src/cli.ts:149 (resolveCliCasePath()) still hardcodes join(homedir(), 'codeman-cases', name), and its own docstring says it "Mirrors resolveCasePath() in case-routes.ts, which is what the web UI and quick-start use". Under Compose it no longer does: codeman skill install --case <name> run from a shell session inside the container looks in /home/opencode/codeman-cases while the server uses $CODEMAN_CASES_PATH, and reports "Case not found". Either read the same env var there, or export one resolver that both call.

4. A missing CODEMAN_CASES_PATH is created root-owned by Compose. I expected the long-form bind to refuse, so I tested it, and it does not:

probe-1 | drwxr-xr-x 2 root root /mnt/target
host side: drwxr-xr-x 2 root root ./missing-dir

So a first run against a not-yet-existing cases path leaves the unprivileged runtime account unable to write cases into it. The start script already does exactly the right checks for CODEMAN_APPDATA_PATH; CODEMAN_CASES_PATH deserves the same three (exists, absolute, not root-owned).

5. The concurrent-create tolerance only exists in swap-compatibility mode. The elif ... inspect fallback lives inside the disableSwapLimit branch, so the ordinary path keeps the previous behaviour. The PR description lists "Handled concurrent attempts to create the same isolated case container" as a general change. Either lift it out of the branch or adjust the wording.

6. hostWorkspacePath stays untranslated, which is worth reconsidering.buildDockerCreateArgs mounts docker.hostWorkspacePath verbatim, which is exactly why CODEMAN_CASES_PATH has to be identical inside and outside the container, and why the docs need the "do not set it to /home/${CODEMAN_RUNTIME_USER}/codeman-cases" warning. The failure mode is at least loud (verified: --mount type=bind errors with bind source path does not exist, it does not silently create the directory). But two supported configurations break under Compose today:

  • POST /api/cases/docker-link pointed at a path under the container HOME (documented in docs/docker-cases.md)
  • multi-user mode, where casesDirForUser() returns ~/codeman-users/<user>/cases, which lives inside the appdata mount and has no identical host path

Since the appdata bind is an exact HOME-to-host mapping, running the workspace src through the same helper (keeping dst at the container-visible path so the transcript projHash still correlates) would cover all three cases and would remove both the second bind mount and the identical-path rule. Was that considered and rejected, or simply not reached? I am not asking you to change it blind, I would just like to understand the trade-off you saw.

Image and packaging

7. docker.io is not "only the Docker CLI". Verified on node:22-bookworm-slim with --no-install-recommends; it still pulls 15 packages:

dmsetup libdevmapper1.02.1 runc containerd libip4tc2 libip6tc2 libxtables12
netbase libmnl0 libnfnetlink0 libnetfilter-conntrack3 libnftnl11 iptables tini docker.io

That is the full engine plus containerd and runc, and on bookworm it is Docker 20.10.24 (2023). COPY --from=docker:cli /usr/local/bin/docker /usr/local/bin/docker is one line, is provably CLI-only, and tracks a current release.

8. CLI coverage is behind master. The image installs claude-code, codex, gemini-cli and opencode-ai. master now supports ten run modes, so pi, grok, deepseek, antigravity and omp are missing and those Run entries will fail inside the container. docker/agent.Dockerfile already carries working recipes for all of them, including grok's non-npm installer and the dsh identity probe. At minimum, document which CLIs the server image ships.

9. COPY . . ships the whole repository into the runtime image (docs, tests, packages, plus any untracked working files in the builder's checkout). Copying only dist/, node_modules/ and package.json out of the build stage would be smaller, and it would close the .env class of leak structurally rather than by pattern.

10. The self-update claim does not hold..git is excluded from the build context and there is no systemd or launchd inside the container, so detectSupervisor() returns none and the updater reports the install as not updatable. The description says Codeman "can also self-update within the container and just requires a restart". Worth verifying or dropping; the git pull plus re-run-the-script flow described just above it is the accurate story.

Documentation and polish

  • The three new environment variables (CODEMAN_CASES_PATH, CODEMAN_DOCKER_HOST_HOME, CODEMAN_DOCKER_DISABLE_SWAP_LIMIT) appear only in the two new Docker documents. CLAUDE.md holds the env-var inventory and docs/docker-cases.md is the Docker guide; a variable that changes how CASES_DIR resolves belongs in both.
  • docker/README.md and docs/docker-compose.md overlap by roughly 80%. Pick one home and link to it from the other.
  • .env.example defaults are specific to your host: TZ=Australia/Perth, /mnt/user/appdata/Coding/codeman, 10.10.11.236 and a concrete MAC address. Someone who copies it verbatim and runs the script gets an Unraid path they do not have. Neutral defaults (TZ=UTC, a generic path) would make the first run work more often.
  • docker compose config --environment needs Compose 2.24 or newer; the prerequisites just say "Compose v2". On an older Compose the script fails with the misleading "CODEMAN_APPDATA_PATH is not set".
  • Security section: worth stating plainly that the socket mount gives every agent session running inside the Codeman container host-root-equivalent access, not only whoever administers the stack. Shipping the socket mount commented out, for people who do not want Docker cases, would be a reasonable default. Related: the README currently says Docker cases never get --privileged or the docker socket, which is still true of the case containers but now benefits from the distinction being spelled out.
  • CODEMAN_ALLOWED_HOSTS is not in .env.example; anyone putting this behind a reverse proxy will hit the Host guard.
  • The healthcheck targets 127.0.0.1, which is right for 0.0.0.0 but breaks for anyone who follows the macvlan example and pins CODEMAN_HOST.
  • Minor: /tmp/codeman-create-<sessionId>.log is left behind if the pane dies between create and rm. Piping (2>&1 | sed '/.../d') gets the same filtering with no file, and the existing docker start failure message already covers the lost exit status. The exact-string sed also stops filtering silently the day Docker rewords the warning, which is fine since it is cosmetic, but it is worth a note in the comment.

Summary

Solid work with real end-to-end validation behind it, and it fixes a genuine bug on the way past. Suggested path:

  1. Split the mode === 'claude' resume guard into its own PR; that one is ready.
  2. Land the Compose work after items 1 to 4 (the .env leak, the placeholder password, the resolveCliCasePath divergence, the root-owned cases directory), with item 6 answered one way or the other.

Items 7 to 10 and the documentation list are not blockers, though 7 and 8 are cheap and would make the image match what the description promises.

@Ark0N
Ark0N merged commit 2a32b50 into Ark0N:masterSep 1, 2026
@opticon454
opticon454 deleted the feature/docker-compose branch September 1, 2026 12:15
Ark0N pushed a commit that referenced this pull request Sep 5, 2026
…iner
feat(docker): attach a case to an already-running container
Conflicts came from work that landed after the PR was opened, and each is
resolved onto the newer abstraction rather than by keeping the older code:
- `defaultDockerCommandForMode` is registry-driven since #347, so the PR's
`runsAsRoot` arm became `overlays.docker.rootCommand` (claude only). Claude
Code still refuses `--dangerously-skip-permissions` as root in 2.1.261 and the
refusal is visible only inside the container, so an adopted root container
otherwise just shows a dead pane. Which flag to drop is a per-CLI fact, and
`test/cli-registry-no-id-branching.test.ts` forbids expressing it as a branch.
- The probe's mode list and its mode -> binary table both duplicated the
registry. They now read `enabledCliIds()` / `discovery.binaries[0]`, which is
also what fixes the merge's silent regression: the hand-written list predates
`omp`, and the run menu gates every docker case on this probe, so owned
containers would have lost that mode. `shell` needs no arm — it declares no
binary, so it is dropped from the lookup and reported available regardless.
- The per-mode `mode === 'claude' && !cliDir` chain in `tmux-manager.ts` is one
`missingCliMessage(mode)` gate since #347; the PR's docker exemption moved onto
it. Its test now pins the single gate instead of counting seven arms.
- The create arm keeps #349's swap-limit warning filter, which the adopted arm
never reaches; the run-mode list gains `omp` from #353.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TecFD9hvPYJ1mkkMtBQbT1
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