mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
feat(docker): attach a case to an already-running container
Docker cases could only run in a container Codeman created itself. Attaching to one the user already built and runs means Codeman must leave that container's lifecycle completely alone, which the launch chain could not do: it was `image inspect` -> `inspect || create` -> `start` -> `exec`. Adds `DockerCase.owned`, mirroring the `owned:false` contract remote-SSH already uses for attached sessions. Absent (every existing case) means owned, so current behaviour is byte-identical. `false` means the container belongs to the user and Codeman may only exec into it. The launch chain for an attached container only looks, then execs: no image gate (the image is theirs), no create, and no `start` — starting a container we do not own is the very mutation attaching promises not to perform. A missing or stopped container fails closed with an actionable message instead. Credential seeding is skipped too: those copies read from create-time read-only mounts that do not exist here, and writing host credentials into someone's container is not ours to do, so its CLIs must already be authenticated inside it. Four fail-closed guards. buildDockerStopCommand and buildDockerRemoveCommand throw during pure string construction, so no caller bug can turn into a `docker stop`/`rm` on a container we do not own; removeDockerContainer refuses again at the lowest layer; drift reports "none" for an attached container, which carries no `codeman.confighash` label and would otherwise always look drifted and 409 the launch gate forever; and the orphan reaper skips attached containers through a check deliberately independent of the two conditions already covering them. `owned` is applied AFTER the config hash is computed. dockerConfigHash takes an explicit field list, so ownership can never shift an existing case's hash — if it did, every pre-existing case would trip the drift gate at once, and the remedy the UI offers is "recreate the container". Adds POST /api/cases/docker-adopt and a read-only POST /api/docker-cases/adopt-preflight. The preflight refuses at LINK time rather than at session launch, where the only ways out would be a dead pane or starting a container we do not own. Tests assert the negative guarantee directly — that create, start, stop, rm, restart and kill are absent from the generated commands while `docker exec -it` and `new-session -A` remain — since it cannot be observed by using the feature.
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* @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 {
|
||||
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('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: 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: 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user