Files
Codeman/test/docker-adopted-container.test.ts
T
d fei 5452ad5c5a feat(docker): add a folder picker to both path fields
Both paths in the adoption form had to be typed. Each gets a Browse button
using the same path-input-group markup Link Existing uses, so the two look and
behave alike.

What they can browse differs, and that is the point. The host workspace path
reuses the existing host picker. The container workdir cannot: an adopted
container has nothing mounted at a matching host path, so a host listing would
be a different filesystem — and getting this field wrong is the source of the
opaque OCI chdir error at launch, which makes it the field that most needs to
be clickable.

Adds a read-only POST /api/docker-cases/browse: one `ls` through docker exec, no
writes, no lifecycle, path shell-escaped like every other value. `ls -Ap` marks
directories with a trailing slash and keeps names with spaces intact.

PathPicker takes an optional fetchListing source rather than being forked: the
container variant only swaps where the rows come from, and reuses the rendering,
navigation, Up and Choose/Select unchanged.
2026-08-29 23:31:28 -07:00

317 lines
14 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,
} from '../src/docker-hosts.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: 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 every 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.
const guarded = src.match(/!cliRunsInContainer && mode === '/g) || [];
const unguarded = src.match(/\n if \(mode === '[a-z]+' && !cliDir\)/g) || [];
expect(guarded.length).toBeGreaterThanOrEqual(7);
expect(unguarded).toHaveLength(0);
});
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. */
const refreshFn = (src) => {
const start = src.indexOf('_refreshRunModeAvailability(menu) {');
expect(start).toBeGreaterThan(-1);
return src.slice(start, start + 2000);
};
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);
});
});