2026-08-29 21:02:19 -07:00
|
|
|
/**
|
|
|
|
|
* @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';
|
2026-08-29 21:02:19 -07:00
|
|
|
import { readFileSync } from 'node:fs';
|
2026-08-29 21:02:19 -07:00
|
|
|
import {
|
2026-08-29 23:31:28 -07:00
|
|
|
defaultDockerCommandForMode,
|
2026-08-29 21:02:19 -07:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-29 23:31:28 -07:00
|
|
|
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");
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-29 21:02:19 -07:00
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-29 23:31:28 -07:00
|
|
|
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;');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-29 21:02:19 -07:00
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-29 21:02:19 -07:00
|
|
|
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");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-29 21:02:19 -07:00
|
|
|
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);
|
2026-08-29 23:31:28 -07:00
|
|
|
return src.slice(start, start + 2000);
|
2026-08-29 21:02:19 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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/);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-29 21:02:19 -07:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|