fix(docker): close the three adoption gaps the negative guarantee missed

Review follow-ups to #357. Each is a path that still touched, or still hid, a
container Codeman does not own.

**Export still mutated it.** The four fail-closed layers cover create/start/
stop/remove, but `POST /api/docker-cases/:name/export` reaches the container
twice through neither: a full export `docker commit`s it, and even a
workspace-only export `docker pause`s it first for snapshot consistency. Pause
freezes the owner's processes for as long as the tar takes, on a container we
promised not to touch. Full export is refused for an adopted case (it packages
someone else's container, with their logins, into a bundle Codeman hands out);
workspace-only keeps working and no longer pauses, accepting a live filesystem
the way `tar` does on any running host directory.

**A freshly linked OWNED case became unusable.** The run menu now probes the
container for its CLIs, and a failed probe hides every agent mode behind the
reason. For an adopted case that is right. For an owned one the container does
not exist until the first session launches it, so every newly linked Docker case
answered `container "codeman-case-x" not found (adoption never creates a
container — start it yourself first)` and offered nothing but Shell, for a
container the launch chain was about to create itself. A failed probe is
recorded only when the case is adopted; `CaseInfo.docker.owned` is on the wire
so the frontend can tell them apart. Verified in a browser: owned-with-no-
container offers all ten modes and no notice, adopted-but-stopped offers Shell
and says why.

**Multi-user gating.** Adoption is admin-only, unlike `docker-link` beside it.
Linking creates OUR container, whose sole bind mount `isWorkingDirAllowed` has
already confined to the caller's space; an adopted container's mounts are
whatever its owner gave it, so one mounting `/` hands the adopter a shell over
the whole host — exactly the workspace scoping multi-user mode exists to
enforce. Listing the engine's containers and browsing directories inside an
arbitrary one are machine-level reads and follow the docker-HOST policy for the
same reason. The preflight is deliberately not admin-only: the run menu fires it
for every docker case, so it admits a non-admin for a container already linked
to a case they can access, and nothing else.

Verified end to end against a real pre-existing root container (alpine + tmux,
no bind mounts): adopt, claude session inside it, workspace export, session
close and case unlink all left `StartedAt`, `RestartCount`, `Pid` and `Paused`
untouched; the pane ran the CONTAINER's claude, without
`--dangerously-skip-permissions`; a stopped container was refused at both
preflight and launch and was never started.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TecFD9hvPYJ1mkkMtBQbT1
This commit is contained in:
Codeman maintainer
2026-09-05 16:22:54 +02:00
parent 3d8ffcb9a2
commit 8ad2215118
8 changed files with 253 additions and 37 deletions
+36 -21
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+58 -3
View File
@@ -2,7 +2,7 @@
Run a case inside an **isolated Docker container** instead of directly on the host. Any number of Codeman sessions can share one container (it is scoped to the case, not the session), so a whole project lives in a sandbox with its own network, resource caps, and filesystem, and you can **export the container to move it to another machine**.
Docker mode is a **location overlay on cases**, the direct analog of [remote SSH cases](./remote-hosts.md): where a remote case runs a local tmux pane doing `ssh host` into a durable remote tmux server, a docker case runs a local tmux pane doing `docker exec -it` into a durable **in-container** tmux server. It is not a separate `SessionMode`, so `claude` / `shell` / `opencode` / `codex` / `gemini` / `antigravity` / `pi` / `grok` all work inside the container.
Docker mode is a **location overlay on cases**, the direct analog of [remote SSH cases](./remote-hosts.md): where a remote case runs a local tmux pane doing `ssh host` into a durable remote tmux server, a docker case runs a local tmux pane doing `docker exec -it` into a durable **in-container** tmux server. It is not a separate `SessionMode`, so `claude` / `shell` / `opencode` / `codex` / `gemini` / `antigravity` / `pi` / `grok` / `deepseek` / `omp` all work inside the container.
## One-time setup: build the base image
@@ -25,12 +25,24 @@ A zero exit code only proves the layers ran, not that the toolchain works. Verif
```bash
docker run --rm codeman/agent:base bash -lc \
'for c in claude codex gemini opencode agy pi grok; do printf "%-9s " $c; $c --version 2>&1 | head -1; done'
'for c in claude codex gemini opencode agy pi grok dsh omp; do printf "%-9s " $c; $c --version 2>&1 | head -1; done'
```
⚠️ `dsh --version` is the one line above that answers a different question than the
others: `dsh` is a profile launcher, so a working binary says nothing about whether
the image can actually run a DeepSeek session. Check the profile the Dockerfile
installs into the agent's HOME as well, or a `mode: 'deepseek'` case starts a pane
that dies on arrival:
```bash
docker run --rm codeman/agent:base ls ~/.dsh/profiles/dsh-tui/package.json
```
Building that profile is also why `pnpm` is in the image: `dsh plugin` forwards straight to a literal `pnpm` and exits 127 without it (issue #352), and pnpm — unlike npm — blocks dependency lifecycle scripts by default and fails the install over it, so the profile step passes `--config.dangerouslyAllowAllBuilds=true`.
Antigravity (`agy`) and Grok (`grok`) are the two CLIs not installed from npm (Google and xAI ship standalone binaries), so each has its own Dockerfile step, adding roughly 190MB and 160MB respectively. Pi also gets its own step, because upstream documents installing it with `--ignore-scripts` and that flag must not silently change how the other npm CLIs install.
Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json` out of `~/.pi/agent`), because that directory also holds `sessions/`, `extensions/`, `skills/` and the installed package trees — gigabytes on an active host. Consequence: in-container pi sessions are invisible host-side, so `pi -c` inside a Docker case only sees that container's own history. See [`pi-integration.md`](./pi-integration.md). Grok is seeded per-file for the same reason (`auth.json`, `config.toml`, `pager.toml` out of `~/.grok`, which also holds `sessions/`, `memory/` and the ~160MB binary under `downloads/`), with the same consequence for `grok -c`. See [`grok-integration.md`](./grok-integration.md).
Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json` out of `~/.pi/agent`), because that directory also holds `sessions/`, `extensions/`, `skills/` and the installed package trees — gigabytes on an active host. Consequence: in-container pi sessions are invisible host-side, so `pi -c` inside a Docker case only sees that container's own history. See [`pi-integration.md`](./pi-integration.md). Grok is seeded per-file for the same reason (`auth.json`, `config.toml`, `pager.toml` out of `~/.grok`, which also holds `sessions/`, `memory/` and the ~160MB binary under `downloads/`), with the same consequence for `grok -c`. See [`grok-integration.md`](./grok-integration.md). OMP is the one CLI in this family where `sessions/` is the EXCEPTION rather than the rule: `~/.omp/agent/{config.yml,mcp.json,models.yml,settings.yml}` are seeded per-file (the dir also holds SQLite caches and `terminal-sessions/`), but `~/.omp/agent/sessions/` is shared RW like codex's, not seeded, because Codeman reads it host-side for history recovery and `--resume` pinning. See [`omp-integration.md`](./omp-integration.md).
## Quickest path: one-click "Run in Docker"
@@ -66,6 +78,49 @@ curl -X POST localhost:3000/api/cases/docker-link -d '{"name":"sandbox","hostId"
curl -X POST localhost:3000/api/quick-start -d '{"caseName":"sandbox","mode":"claude"}'
```
## Attach to a container you already run
The tab's **Attach to an existing container** toggle points a case at a container **you**
built and run. Codeman only ever `docker exec`s into it: it never creates, starts, stops,
restarts or removes it, and it seeds no credentials into it, so the CLIs inside must already
be installed and logged in. A missing or stopped container is an error to report, not a state
to fix — start it yourself and reopen the session.
- **Container Name** is a picker over the engine's containers that you can also type into
(the engine may be remote, or the container may not exist yet when you fill the form).
Stopped containers are listed too, sorted last and labelled, so "mine isn't here" is never
a dead end.
- **Container Workdir** is a path that must already exist **inside** the container. Adoption
mounts nothing, so it need not match the host workspace path; **Browse** lists directories
inside the container itself. Without this check, a wrong path fails at launch as a bare
`execvp failed` inside the pane.
- **Workspace Path** is still a real host directory. It backs file previews, attachments and
watchers exactly as it does for an owned case, but here it is only a mirror: nothing is
bind-mounted, so point it at whatever host directory your container already exposes.
- **Check container** runs a read-only preflight and reports what is inside before you commit
to a case name (running or not, tmux present, which CLIs resolved).
- **Run modes come from the container**, not the host: a host with no `claude` still offers
Claude if the container ships it, and a mode the container lacks is hidden.
- Claude is launched **without** `--dangerously-skip-permissions` when the container's exec
user is root, because Claude Code refuses that flag as root and the refusal is only visible
inside the container.
- Image, network and resource settings disappear from the form: they describe a
`docker create` that adoption never runs.
Recreate is refused for an adopted case, full-image export is refused (it would commit a
container that is not ours), unlinking the case leaves the container running, and the boot
reaper skips it. Workspace-only export still works and never pauses the container.
Equivalent API:
```bash
curl -X POST localhost:3000/api/docker-cases/adopt-preflight -d '{"hostId":"local","container":"my-dev-box","containerWorkdir":"/workspace"}'
curl -X POST localhost:3000/api/cases/docker-adopt -d '{"name":"devbox","hostId":"local","container":"my-dev-box","hostWorkspacePath":"/home/you/projects/devbox","containerWorkdir":"/workspace"}'
```
In multi-user mode adoption is **admin-only**, unlike `docker-link`: an adopted container's
mounts belong to whoever built it, so one mounting `/` would hand the adopter the whole host.
## Lifecycle
- **Reconnect after a Codeman restart** lands back in the same live agent (the in-container tmux survives).
+8 -1
View File
@@ -30,6 +30,7 @@ import { spawn } from 'node:child_process';
import { pipeline } from 'node:stream/promises';
import type { DockerEngine, SessionDocker } from './types.js';
import { runWithConversionLimit } from './document-conversion-limiter.js';
import { isAdoptedContainer } from './docker-hosts.js';
const IS_TEST_MODE = !!process.env.VITEST;
@@ -287,7 +288,13 @@ export async function exportDockerCase(params: {
const bundlePath = join(exportsDir, exportBundleName(caseName, timestamp, mode));
const stageDir = join(exportsDir, `.stage-${caseName}-${timestamp}`);
mkdirSync(stageDir, { recursive: true });
const wasRunning = await isContainerRunning(argv, docker.containerName);
// ⚠️ NEVER pause an ADOPTED container. The freeze exists only to make the committed
// image and the workspace tar mutually consistent, and it is a lifecycle mutation on a
// container that belongs to the user — it stops their processes for however long the
// tar takes. A workspace-only export of an adopted case therefore accepts a live
// filesystem, the same guarantee `tar` gives on any running host directory. Full-image
// export is refused for an adopted case at the route, before reaching here.
const wasRunning = !isAdoptedContainer(docker) && (await isContainerRunning(argv, docker.containerName));
let commitTag: string | undefined;
try {
+11
View File
@@ -174,6 +174,17 @@ export interface CaseInfo {
* every CLI), which the UI reads as "do not gate".
*/
availableModes?: string[];
/**
* `false` for an ADOPTED container (mirror of `DockerCase.owned`); absent = owned.
*
* ⚠️ The UI needs this to read a FAILED container probe correctly. For an adopted
* case a missing container is a real fault worth reporting, because the user is the
* only one who can start it. For an owned case it is the NORMAL state before the
* first session: the container is created on demand by the launch chain, so treating
* "not found" as a fault there hid every agent mode behind an error telling the user
* to start a container Codeman was about to create itself.
*/
owned?: boolean;
};
}
+16 -4
View File
@@ -503,6 +503,11 @@ Object.assign(CodemanApp.prototype, {
if (isDocker && !this._dockerCaseModes?.[caseName]) void this._probeDockerCaseModes(activeCase, menu);
// An unreachable container hides every agent mode and explains why, instead
// of silently offering modes that cannot start.
//
// ⚠️ ADOPTED cases only. For an OWNED case a missing container is the normal
// state before the first session — the launch chain creates and starts it — so
// reporting it as a fault hid every agent mode on a freshly linked Docker case
// behind "start it yourself first", for a container Codeman was about to create.
const probeError = isDocker ? this._dockerCaseProbeError?.[caseName] : null;
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']) {
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
@@ -707,12 +712,19 @@ Object.assign(CodemanApp.prototype, {
this._dockerCaseModes[name] = probe.availableModes;
delete this._dockerCaseProbeError?.[name];
} else {
// A container that cannot be probed — recreated, stopped, engine down —
// must NOT fall through to "show everything". Offering claude on a
// An ADOPTED container that cannot be probed — recreated, stopped, engine
// down — must NOT fall through to "show everything". Offering claude on a
// container that is not running is a click that can only fail, with the
// reason visible nowhere. Record the reason and say it in the menu.
this._dockerCaseProbeError = this._dockerCaseProbeError || {};
this._dockerCaseProbeError[name] = probe?.error || `Could not read container "${container}".`;
//
// ⚠️ An OWNED container gets no error: it does not exist until the first
// session launches it, so "not found" is the expected answer for every
// newly linked Docker case, and gating on it made those cases unusable.
// Leaving the cache empty reads as "unknown", which does not gate.
if (activeCase?.docker?.owned === false) {
this._dockerCaseProbeError = this._dockerCaseProbeError || {};
this._dockerCaseProbeError[name] = probe?.error || `Could not read container "${container}".`;
}
delete this._dockerCaseModes[name];
}
// Only repaint while the menu the user opened is still on screen.
+49 -4
View File
@@ -300,6 +300,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
path: dockerCase.hostWorkspacePath,
network: host.network ?? 'bridge',
...(dockerCase.availableModes ? { availableModes: dockerCase.availableModes } : {}),
...(dockerCase.owned === false ? { owned: false } : {}),
},
};
const existingIndex = cases.findIndex((item) => item.name === dockerCase.name);
@@ -794,7 +795,15 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
*/
app.post(
'/api/cases/docker-adopt',
async (req): Promise<ApiResponse<{ case: unknown; image?: string; availableModes?: SessionMode[] }>> => {
async (req, reply): Promise<ApiResponse<{ case: unknown; image?: string; availableModes?: SessionMode[] }>> => {
// ⚠️ Admin-only in multi-user mode, unlike `docker-link` right above. Linking
// creates OUR container, whose only bind mount is a workspace `isWorkingDirAllowed`
// has already confined. Adoption names a container someone else built, and its
// mounts are whatever its owner gave it — a container mounting `/` hands the
// adopter a shell over the whole host, which is exactly the workspace scoping this
// mode exists to enforce. Same machine-level reasoning as the docker HOST routes.
const denied = adminOnly(req, reply);
if (denied) return denied;
const dockerCase = {
...parseBody(DockerCaseAdoptSchema, req.body),
type: 'docker' as const,
@@ -880,7 +889,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
*/
app.get(
'/api/docker-hosts/:hostId/containers',
async (req): Promise<ApiResponse<{ containers: DockerContainerInfo[] }>> => {
async (req, reply): Promise<ApiResponse<{ containers: DockerContainerInfo[] }>> => {
// Enumerating every container on the engine is machine-level information (names,
// images, uptime), so it follows the docker-host policy rather than the case one.
const denied = adminOnly(req, reply);
if (denied) return denied;
const { hostId } = req.params as { hostId: string };
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
@@ -899,7 +912,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
* container nothing is mounted at a matching host path, so the field would
* otherwise be typed blind. Read-only — one `ls` through `docker exec`.
*/
app.post('/api/docker-cases/browse', async (req): Promise<ApiResponse<DockerBrowseResult>> => {
app.post('/api/docker-cases/browse', async (req, reply): Promise<ApiResponse<DockerBrowseResult>> => {
// Reads a directory listing inside an ARBITRARY named container, so it is gated with
// the adopt flow it serves rather than with the (owner-scoped) case file routes.
const denied = adminOnly(req, reply);
if (denied) return denied;
const body = parseBody(DockerBrowseSchema, req.body);
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === body.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
@@ -915,8 +932,25 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
return { success: true, data: result };
});
app.post('/api/docker-cases/adopt-preflight', async (req): Promise<ApiResponse<AdoptedContainerProbe>> => {
app.post('/api/docker-cases/adopt-preflight', async (req, reply): Promise<ApiResponse<AdoptedContainerProbe>> => {
const body = parseBody(DockerAdoptPreflightSchema, req.body);
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
// ⚠️ NOT plain `adminOnly`, unlike the two routes above: the run menu probes this for
// every docker case to learn which CLIs the CONTAINER has, so an admin-only gate would
// hide every agent mode from a non-admin's own docker case. A non-admin may therefore
// probe a container ALREADY linked to a case they can access — never an arbitrary one,
// which is the adopt-time question and stays admin-only with the rest of that flow.
if (!isAdmin(req)) {
const owns = dockerCases.some(
(item) =>
(item.container ?? dockerContainerName(item.name)) === body.container &&
canAccessOwned(getAuthUser(req), item.owner)
);
if (!owns) {
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
}
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === body.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
const probe = await probeAdoptableContainer(
@@ -1060,6 +1094,17 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
const sessionDocker = toSessionDocker(host, dockerCase);
// A full export `docker commit`s the container into an image. For an ADOPTED
// container that means packaging someone else's container — with whatever
// credentials its owner logged in with — into a bundle Codeman then hands out,
// and it is the one export step that touches the container at all. The
// workspace-only export is a plain host-directory tar and stays available.
if (mode === 'full' && dockerCase.owned === false) {
return createErrorResponse(
ApiErrorCode.FORBIDDEN,
`Case "${name}" adopted an existing container. Codeman does not own it and will not commit it to an image — use a workspace-only export, or build the image yourself.`
);
}
if (mode === 'full' && !sessionDocker.mountCredentials) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
+63
View File
@@ -388,3 +388,66 @@ describe('adopted container: probe modes come from the CLI registry', () => {
expect(getCli('shell')?.discovery.binaries[0]).toBeUndefined();
});
});
describe('adopted container: export never touches the container', () => {
const routes = readFileSync(new URL('../src/web/routes/case-routes.ts', import.meta.url), 'utf8');
const exporter = readFileSync(new URL('../src/docker-export.ts', import.meta.url), 'utf8');
it('refuses a full-image export, which would commit a container we do not own', () => {
expect(routes).toContain("if (mode === 'full' && dockerCase.owned === false)");
});
it('never pauses an adopted container for the workspace tar', () => {
// `docker pause` freezes the owner's processes for as long as the tar takes.
// It is the one export step that touches the container at all.
expect(exporter).toContain('!isAdoptedContainer(docker) && (await isContainerRunning(');
});
});
describe('adopted container: naming a foreign container is machine-level', () => {
const routes = readFileSync(new URL('../src/web/routes/case-routes.ts', import.meta.url), 'utf8');
const routeFor = (marker: string) => routes.slice(routes.indexOf(marker), routes.indexOf(marker) + 1400);
it('admin-gates adoption in multi-user mode, unlike docker-link', () => {
// docker-link only ever creates OUR container, whose sole bind mount is a
// workspace isWorkingDirAllowed already confined. An adopted container's
// mounts belong to its owner — one mounting `/` hands the adopter the host.
expect(routeFor("'/api/cases/docker-adopt'")).toContain('adminOnly(req, reply)');
});
it('admin-gates enumerating and browsing containers', () => {
expect(routeFor("'/api/docker-hosts/:hostId/containers'")).toContain('adminOnly(req, reply)');
expect(routeFor("'/api/docker-cases/browse'")).toContain('adminOnly(req, reply)');
});
it('lets a non-admin preflight only a container linked to a case they own', () => {
// NOT plain adminOnly: the run menu probes this for every docker case to learn
// which CLIs the container has, so an admin-only gate would hide every agent
// mode from a non-admin's own docker case.
const route = routeFor("'/api/docker-cases/adopt-preflight'");
expect(route).toContain('if (!isAdmin(req))');
expect(route).toContain('canAccessOwned(getAuthUser(req), item.owner)');
expect(route).not.toContain('adminOnly(req, reply)');
});
});
describe('adopted container: a missing container means different things per ownership', () => {
const ui = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf8');
const routes = readFileSync(new URL('../src/web/routes/case-routes.ts', import.meta.url), 'utf8');
it('records a probe failure only for an adopted case', () => {
// An OWNED container does not exist until the first session launches it, so
// "not found" is the expected answer for every freshly linked Docker case.
// Treating it as a fault hid every agent mode behind an error telling the user
// to start a container the launch chain was about to create itself.
const probe = ui.slice(ui.indexOf('async _probeDockerCaseModes('), ui.indexOf('async _loadRunModeHistory('));
expect(probe).toContain('if (activeCase?.docker?.owned === false) {');
expect(probe.indexOf('if (activeCase?.docker?.owned === false) {')).toBeLessThan(
probe.indexOf('this._dockerCaseProbeError[name] =')
);
});
it('ships the ownership flag the UI reads that decision from', () => {
expect(routes).toContain('...(dockerCase.owned === false ? { owned: false } : {}),');
});
});