Files
Codeman/test/docker-adopted-container.test.ts
T
Codeman maintainer 3d8ffcb9a2 Merge pull request #357 from dignfei/feat/docker-adopt-existing-container
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
2026-09-05 16:21:51 +02:00

391 lines
18 KiB
TypeScript

/**
* @fileoverview Adopting an ALREADY-RUNNING container (`DockerCase.owned === false`).
*
* The whole point of adoption is a negative guarantee: Codeman execs into a
* container the user built and runs, and never creates, starts, stops, restarts
* or removes it. A negative guarantee cannot be observed by using the feature —
* only by asserting that the mutating verbs are absent — so these tests read the
* generated command strings and assert on what is NOT in them.
*
* Mirror of the `owned:false` remote-SSH contract (COD-105).
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import {
defaultDockerCommandForMode,
toSessionDocker,
isAdoptedContainer,
removeDockerContainer,
checkDockerConfigDrift,
dockerConfigHash,
dockerAdoptProbeModes,
} from '../src/docker-hosts.js';
import { enabledCliIds, getCli } from '../src/config/cli-registry/index.js';
import {
buildDockerLaunchCommand,
buildDockerStopCommand,
buildDockerRemoveCommand,
buildDockerKillCommand,
} from '../src/tmux-manager.js';
import type { DockerCase, DockerHost, SessionDocker } from '../src/types.js';
const HOST: DockerHost = { id: 'h1', label: 'local', engine: 'docker', image: 'codeman/agent:base' };
function caseFor(owned: boolean | undefined): DockerCase {
return {
name: 'adopted',
type: 'docker',
hostId: 'h1',
hostWorkspacePath: '/srv/work',
container: 'my-own-container',
...(owned === undefined ? {} : { owned }),
};
}
function launchFor(docker: SessionDocker): string {
return buildDockerLaunchCommand({
mode: 'codex',
docker,
sessionId: '11111111-2222-3333-4444-555555555555',
createContext: {
docker,
sessionId: '11111111-2222-3333-4444-555555555555',
instance: 'default',
userArgs: ['--user', '1000:0'],
credentialMounts: [],
extraMounts: [],
envCreate: { HOME: '/home/agent' },
addHostGateway: true,
gatewayAlias: 'host.docker.internal',
},
execEnv: { TERM: 'xterm-256color' },
execEnvNames: [],
seedCopies: [{ from: '/seed/creds.json', to: '/home/agent/.claude/.credentials.json' }],
});
}
describe('adopted container: ownership plumbing', () => {
it('carries owned:false from the case onto the live session metadata', () => {
expect(toSessionDocker(HOST, caseFor(false)).owned).toBe(false);
expect(isAdoptedContainer(toSessionDocker(HOST, caseFor(false)))).toBe(true);
});
it('treats an absent flag as owned, so existing cases are unchanged', () => {
const docker = toSessionDocker(HOST, caseFor(undefined));
expect(docker.owned).toBeUndefined();
expect(isAdoptedContainer(docker)).toBe(false);
});
it('keeps ownership OUT of the config hash so adoption cannot mass-trip drift', () => {
// A drift-hash that moved with `owned` would flag every pre-existing case the
// moment this field shipped, and the remedy the UI offers is "recreate".
const owned = toSessionDocker(HOST, caseFor(undefined));
const adopted = toSessionDocker(HOST, caseFor(false));
expect(adopted.configHash).toBe(owned.configHash);
expect(dockerConfigHash({ ...owned, owned: false } as never)).toBe(owned.configHash);
});
});
describe('adopted container: the launch chain never mutates lifecycle', () => {
const adopted = launchFor(toSessionDocker(HOST, caseFor(false)));
const owned = launchFor(toSessionDocker(HOST, caseFor(undefined)));
it('never creates the container', () => {
expect(owned).toContain('docker create');
expect(adopted).not.toContain('docker create');
});
it('never starts the container', () => {
expect(owned).toContain('docker start');
expect(adopted).not.toContain('docker start');
});
it('never stops or removes the container', () => {
for (const verb of ['docker stop', 'docker rm', 'docker restart', 'docker kill']) {
expect(adopted).not.toContain(verb);
}
});
it('fails closed when the container is missing instead of creating it', () => {
expect(adopted).toContain('docker inspect');
expect(adopted).toMatch(/not found.*start it yourself/i);
});
it('fails closed when the container is stopped instead of starting it', () => {
expect(adopted).toMatch(/\{\{\.State\.Running\}\}/);
expect(adopted).toMatch(/not running.*never starts a container it does not own/i);
});
it('uses no double quote and no command substitution in the launch chain', () => {
// The whole chain is embedded in an outer `bash -c "…"`. An unescaped `"`
// closes that string early, the remainder is re-tokenized, and tmux fails to
// exec with a bare `execvp(3) failed: No such file or directory` — no hint
// that the command was ever malformed. `$(…)` is banned with it because it
// is then evaluated by the wrong shell at the wrong time.
expect(adopted).not.toContain('"');
expect(adopted).not.toContain('$(');
// Every other line already quotes with the single-quote helper.
expect(adopted).toContain('grep -qx true');
});
it('skips the base-image gate, which describes an image adoption never uses', () => {
expect(owned).toContain('image inspect');
expect(adopted).not.toContain('image inspect');
});
it('never seeds host credentials into a container it does not own', () => {
expect(owned).toContain('.credentials.json');
expect(adopted).not.toContain('.credentials.json');
});
it('still execs into the in-container tmux, which is the whole point', () => {
expect(adopted).toContain('docker exec -it');
expect(adopted).toContain('new-session -A');
});
});
describe('adopted container: the probe request must reach the server', () => {
const ui = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf8');
const api = readFileSync(new URL('../src/web/public/api-client.js', import.meta.url), 'utf8');
it('never hands _apiJson an already-stringified body', () => {
// _api serializes `body` and sets Content-Type itself. Passing a string
// double-encodes it, the server sees a JSON string where it expects an
// object, and answers 400 INVALID_INPUT — which the caller reads as "the
// container could not be probed", so the menu silently showed every mode.
expect(api).toContain('fetchOpts.body = JSON.stringify(body)');
const calls = [...ui.matchAll(/_apiJson\([^)]*\{[\s\S]{0,400}?\}\s*\)/g)].map((m) => m[0]);
expect(calls.length).toBeGreaterThan(0);
for (const call of calls) expect(call).not.toContain('body: JSON.stringify');
});
it('hides every agent mode and says why when the container cannot be read', () => {
// Offering claude on a container that is not running is a click that can
// only fail, with the reason visible nowhere.
// Brace-matched, not a character window: slicing between two call sites
// silently yields '' when the second one appears ABOVE the first, and the
// assertion then passes over nothing. That has bitten this file twice.
const start = ui.indexOf('async _probeDockerCaseModes(activeCase, menu) {');
expect(start).toBeGreaterThan(-1);
const open = ui.indexOf('{', start);
let depth = 0;
let fn = '';
for (let i = open; i < ui.length; i++) {
if (ui[i] === '{') depth++;
else if (ui[i] === '}' && --depth === 0) {
fn = ui.slice(start, i + 1);
break;
}
}
expect(fn).toContain('_dockerCaseProbeError');
expect(ui).toContain('_renderRunModeNotice');
});
});
describe('adopted container: claude as root', () => {
it('drops --dangerously-skip-permissions when the container runs as root', () => {
// Claude Code refuses the flag as root ("cannot be used with root/sudo
// privileges"), so keeping it kills the pane with a message only visible
// inside the container. Our base image runs a non-root user, which is why an
// owned container never hit this.
expect(defaultDockerCommandForMode('claude', true)).toBe('exec claude');
expect(defaultDockerCommandForMode('claude', false)).toContain('--dangerously-skip-permissions');
expect(defaultDockerCommandForMode('claude')).toContain('--dangerously-skip-permissions');
});
it('leaves every other mode unchanged as root', () => {
for (const mode of ['codex', 'shell', 'pi'] as const) {
expect(defaultDockerCommandForMode(mode, true)).toBe(defaultDockerCommandForMode(mode, false));
}
});
});
describe('adopted container: the host is not required to have the CLI', () => {
const src = readFileSync(new URL('../src/tmux-manager.ts', import.meta.url), 'utf8');
it('skips the host CLI requirement for a docker session', () => {
// A docker session runs its CLI inside the container. Demanding it on the
// host threw, the catch fell back to a direct PTY, and that PTY tried to
// exec the CLI on the HOST — surfacing as a bare `execvp(3) failed` with
// nothing naming the real cause.
//
// The CLI registry collapsed the old per-mode `mode === 'claude' && !cliDir` chain
// into ONE `missingCliMessage(mode)` gate, so the guarantee is now that the single
// gate carries the docker exemption and that no per-mode arm has grown back.
expect(src).toContain('if (!cliRunsInContainer && !cliDir) {');
expect(src.match(/if \(mode === '[a-z]+' && !cliDir\)/g)).toBeNull();
});
it('derives the flag from the docker metadata the session already carries', () => {
expect(src).toContain('const cliRunsInContainer = !!docker;');
});
});
describe('adopted container: mutating verbs fail closed at the builder', () => {
const docker = toSessionDocker(HOST, caseFor(false));
it('refuses to build a stop command', () => {
expect(() => buildDockerStopCommand(docker)).toThrow(/does not own its lifecycle/);
});
it('refuses to build a remove command', () => {
expect(() => buildDockerRemoveCommand(docker)).toThrow(/does not own its lifecycle/);
});
it('refuses to remove the container', async () => {
await expect(removeDockerContainer(docker)).rejects.toThrow(/does not own its lifecycle/);
});
it('still allows killing THIS session in-container tmux, never the container', () => {
const kill = buildDockerKillCommand({ docker, sessionId: 'abcdef12-0000-0000-0000-000000000000' });
expect(kill).toContain('tmux');
expect(kill).toContain('kill-session');
expect(kill).not.toContain('docker stop');
expect(kill).not.toContain('docker rm');
});
it('still permits every verb for an owned container', () => {
const ownedDocker = toSessionDocker(HOST, caseFor(undefined));
expect(buildDockerStopCommand(ownedDocker)).toContain('stop -t 10');
expect(buildDockerRemoveCommand(ownedDocker)).toContain('rm -f');
});
});
describe('adopted container: the Add Case panel id contract', () => {
// The modal's load/save contract is getElementById by fixed id, so a renamed or
// dropped id stops the control working with no error anywhere. Static guard in
// the style of app-settings-structure / session-options-structure.
const html = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf8');
const ui = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf8');
const css = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf8');
it('ships every id session-ui.js reads back', () => {
for (const id of ['dockerAdoptExisting', 'dockerContainerName', 'dockerAdoptCheckBtn']) {
expect(html).toContain(`id="${id}"`);
expect(ui).toContain(`'${id}'`);
}
});
it('routes adoption to the endpoint that never creates a container', () => {
expect(ui).toContain('/api/cases/docker-adopt');
expect(ui).toContain('/api/docker-cases/adopt-preflight');
// The create path must survive untouched beside it.
expect(ui).toContain('/api/cases/docker-link');
});
it('hides the adopt-only row until the toggle is on, so the panel is unchanged by default', () => {
expect(css).toContain('#createCaseModal .docker-adopt-only');
expect(css).toMatch(/#createCaseModal \.docker-adopt-only \{\s*display: none/);
expect(css).toContain("#createCaseModal[data-docker-adopt='1'] .docker-adopt-only");
});
it('marks the create-time rows so adoption hides the fields it never uses', () => {
// image / network / advanced describe a `docker create` adoption never runs.
expect(html.match(/docker-create-only/g)?.length).toBeGreaterThanOrEqual(3);
expect(css).toContain("#createCaseModal[data-docker-adopt='1'] .docker-create-only");
});
});
describe('adopted container: run modes come from the CONTAINER, not the host', () => {
const ui = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf8');
/** Slice the method BODY. Anchored on the definition, not a call site: the
* menu opener calls _loadRunModeHistory() ABOVE this definition, so slicing
* between call sites silently yields an empty string and passes nothing. */
/**
* The method BODY, delimited by brace depth rather than a character budget.
* A fixed window silently truncates the moment the method grows — which is
* exactly what happened twice: a comment added above the assertion pushed the
* asserted line past the cutoff and CI failed on a test that was still true.
*/
const refreshFn = (src) => {
const start = src.indexOf('_refreshRunModeAvailability(menu) {');
expect(start).toBeGreaterThan(-1);
const open = src.indexOf('{', start);
let depth = 0;
for (let i = open; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1);
}
throw new Error('unbalanced braces in _refreshRunModeAvailability');
};
it('gates a docker case on availableModes instead of host CLI probes', () => {
// The sandbox host had codex but no claude while the adopted container had
// claude and no codex; gating on the host hid the only mode that worked.
const fn = refreshFn(ui);
expect(fn).toContain("location === 'docker'");
expect(fn).toContain('availableModes');
// Non-docker cases must keep the original host probe (#201).
expect(fn).toContain('this.isCliAvailable(mode)');
});
it('leaves an owned container ungated when nothing was probed', () => {
// Our base image ships every CLI, so an absent list means "unknown", and
// treating unknown as "nothing available" would empty the menu.
expect(refreshFn(ui)).toMatch(/containerModes \?[^:]*:\s*true/);
});
});
describe('adopted container: both path fields get a folder picker', () => {
const html = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf8');
const ui = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf8');
const picker = readFileSync(new URL('../src/web/public/keyboard-accessory.js', import.meta.url), 'utf8');
it('wires a Browse button to each of the two paths', () => {
expect(html).toContain('app.openDockerWorkspacePathPicker()');
expect(html).toContain('app.openDockerWorkdirPicker()');
// Same markup Link Existing uses, so the two look and behave alike.
expect(html.match(/path-input-browse/g)?.length).toBeGreaterThanOrEqual(3);
});
it('browses the CONTAINER for the container workdir, not the host', () => {
// For an adopted container nothing is mounted at a matching host path, so a
// host listing would be a different filesystem — and typing this field blind
// is what makes the launch fail with an OCI chdir error.
const fn = ui.slice(ui.indexOf('openDockerWorkdirPicker()'), ui.indexOf('async linkRemoteCase()'));
expect(fn).toContain('/api/docker-cases/browse');
expect(fn).not.toContain('/api/filesystem/browse');
expect(fn).toContain('fetchListing');
});
it('keeps the host picker for the host workspace path', () => {
const fn = ui.slice(ui.indexOf('openDockerWorkspacePathPicker()'), ui.indexOf('openDockerWorkdirPicker()'));
expect(fn).toContain('PathPicker.open');
expect(fn).not.toContain('fetchListing');
});
it('reuses one PathPicker via an optional source rather than forking it', () => {
expect(picker).toContain('this._options.fetchListing');
expect(picker).toContain('/api/filesystem/browse');
});
});
describe('adopted container: drift is not evaluated', () => {
it('reports no drift rather than demanding a recreate we may not perform', async () => {
// An adopted container carries no codeman.confighash label, so a real
// comparison would always report drift and the launch gate would 409 forever.
const status = await checkDockerConfigDrift(toSessionDocker(HOST, caseFor(false)));
expect(status.drifted).toBe(false);
});
});
describe('adopted container: probe modes come from the CLI registry', () => {
it('probes every enabled CLI, so a newly-enabled one needs no second list', () => {
// A hand-written list here silently froze: `omp` shipped in 1.24.0 and was
// missing from it, which hid the omp run mode on EVERY docker case — owned
// ones included, since the run menu gates on this same probe.
const modes = dockerAdoptProbeModes();
expect(modes).toEqual(enabledCliIds());
expect(modes).toContain('omp');
expect(modes).toContain('shell');
});
it('resolves the real binary name, not the mode name', () => {
// `antigravity` ships as `agy` and `deepseek` as `dsh`, so a mode-name probe
// would report both as missing on a container that has them.
expect(getCli('antigravity')?.discovery.binaries[0]).toBe('agy');
expect(getCli('deepseek')?.discovery.binaries[0]).toBe('dsh');
expect(getCli('shell')?.discovery.binaries[0]).toBeUndefined();
});
});