mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
Attaching a container, picking claude and hitting Run gave one line — `execvp(3) failed.: No such file or directory` — and the run-mode menu offered every mode. Three separate defects, found on a real deployment. TmuxManager.createSession resolved the CLI directory without distinguishing a docker session, so a host with no claude threw, the catch fell back to a direct PTY, and that PTY exec'd the CLI on the HOST. The failure surfaced as a bare execvp error naming nothing. A docker session runs its CLI inside the container; the host does not need it. All eight modes now sit behind a cliRunsInContainer guard, and whether the container has the CLI is settled by the adoption preflight or the image gate before launch. The running check used a bare double quote and command substitution. The whole chain is embedded in an outer `bash -c "…"`, so the unescaped quote closed that string early and the remainder was re-tokenized. It is now a `grep -qx` pipeline using only the single-quote form every other line in the builder already uses. Claude Code refuses --dangerously-skip-permissions as root. Our base image runs a non-root user, so an owned container never hit this; an adopted container's user belongs to its owner and is frequently root, and keeping the flag killed the pane with a message visible only inside the container. The preflight now reports runsAsRoot and the launch chain drops the flag for it. The menu also showed every mode because the container CLI probe only started when the menu opened. It is warmed when the case is selected instead.
283 lines
12 KiB
TypeScript
283 lines
12 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: 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);
|
|
});
|
|
});
|