Files
Codeman/test/docker-exec-options.test.ts
T
Codeman maintainer ca731c67b3 feat(docker): harden session mode + File Viewer button (v1.4.1)
Docker cases: seamless Claude auth (seed ~/.claude.json instead of the
corruption-prone single-file mount), full credential-store isolation for
claude + codex/gemini/gcloud/opencode (share only transcripts/rollouts,
seed the rest), auto-build the base image on first use, C.UTF-8 locale
(fixes box-drawing), collapsed/shortened Create-Case UI + short "(docker)"
case-menu tags, and w<n>-<case> tab naming for docker/remote sessions.
Also: opt-in File Viewer header button; fix a TZ-boundary flaky test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 01:36:21 +02:00

190 lines
8.3 KiB
TypeScript

/**
* Unit tests for the docker launch/kill command builders in tmux-manager.ts
* (mirror of test/remote-ssh-options.test.ts). Pure string assertions: the
* escaping must survive bash -c -> docker exec -> sh -lc -> tmux.
*/
import { describe, it, expect } from 'vitest';
import {
buildDockerLaunchCommand,
buildDockerKillCommand,
buildDockerStopCommand,
buildDockerRemoveCommand,
dockerTmuxSessionName,
type DockerLaunchOptions,
} from '../src/tmux-manager.js';
import { DEFAULT_AGENT_IMAGE, toSessionDocker, type DockerCreateContext } from '../src/docker-hosts.js';
import type { DockerCase, DockerHost, SessionMode } from '../src/types.js';
// The exact adopt-guard the in-container Codeman would use to discover its own sessions.
const SAFE_MUX_NAME_PATTERN = /^codeman-[a-f0-9-]+$/;
const HOST: DockerHost = { id: 'local', label: 'Local', image: DEFAULT_AGENT_IMAGE };
const CASE: DockerCase = {
name: 'myproj',
type: 'docker',
hostId: 'local',
hostWorkspacePath: '/home/arkon/cases/myproj',
};
function launchOpts(overrides: Partial<DockerLaunchOptions> = {}): DockerLaunchOptions {
const docker = overrides.docker ?? toSessionDocker(HOST, CASE);
const createContext: DockerCreateContext = {
docker,
sessionId: '1a2b3c4d5e6f',
instance: '',
userArgs: ['--user', '1000:0'],
credentialMounts: [{ src: '/home/arkon/.claude', dst: '/home/agent/.claude' }],
extraMounts: [],
envCreate: { HOME: '/home/agent', CODEMAN_API_URL: 'https://host.docker.internal:3000' },
addHostGateway: true,
gatewayAlias: 'host.docker.internal',
};
return {
mode: 'claude',
docker,
sessionId: '1a2b3c4d5e6f',
createContext,
execEnv: { TERM: 'xterm-256color', CODEMAN_SESSION_ID: '1a2b3c4d', CODEMAN_MUX: '1' },
execEnvNames: [],
...overrides,
};
}
describe('dockerTmuxSessionName', () => {
it('is stable from the first 8 chars of the sessionId', () => {
expect(dockerTmuxSessionName('1a2b3c4d5e6f')).toBe('codeman-dkr-1a2b3c4d');
});
it('deliberately FAILS the in-container adopt guard', () => {
// 'k'/'r' are not hex, so an in-container Codeman never adopts our session
expect(SAFE_MUX_NAME_PATTERN.test(dockerTmuxSessionName('1a2b3c4d5e6f'))).toBe(false);
});
});
describe('buildDockerLaunchCommand', () => {
it('image-check precedes ensure precedes start precedes exec', () => {
const cmd = buildDockerLaunchCommand(launchOpts());
const iImage = cmd.indexOf('docker image inspect');
const iEnsure = cmd.indexOf('docker inspect');
const iStart = cmd.indexOf('docker start');
const iExec = cmd.indexOf('exec docker exec -it');
expect(iImage).toBeGreaterThanOrEqual(0);
expect(iImage).toBeLessThan(iEnsure);
expect(iEnsure).toBeLessThan(iStart);
expect(iStart).toBeLessThan(iExec);
});
it('ensures the container idempotently (inspect-or-create) with --pull=never', () => {
const cmd = buildDockerLaunchCommand(launchOpts());
expect(cmd).toContain("docker inspect 'codeman-case-myproj' >/dev/null 2>&1 || docker create");
expect(cmd).toContain('--pull=never');
expect(cmd).toContain("docker start 'codeman-case-myproj'");
});
it('execs a TTY into the durable in-container tmux', () => {
const cmd = buildDockerLaunchCommand(launchOpts());
expect(cmd).toContain("exec docker exec -it --workdir '/home/arkon/cases/myproj'");
expect(cmd).toContain('tmux -L codeman-docker setenv -g CODEMAN_SESSION_ID');
expect(cmd).toContain('new-session -A -s codeman-dkr-1a2b3c4d');
expect(cmd).toContain("sh -lc '");
});
it('injects the resume flag ONLY when a resume id is passed', () => {
const withResume = buildDockerLaunchCommand(launchOpts({ resumeSessionId: 'abc-123-def' }));
expect(withResume).toContain('exec claude --dangerously-skip-permissions --resume abc-123-def');
const without = buildDockerLaunchCommand(launchOpts());
expect(without).not.toContain('--resume');
});
it('uses codex resume syntax and drops an unsafe resume id', () => {
const codex = buildDockerLaunchCommand(
launchOpts({ mode: 'codex' as SessionMode, resumeSessionId: '01H-codex-id' })
);
expect(codex).toContain('exec codex resume 01H-codex-id');
const unsafe = buildDockerLaunchCommand(launchOpts({ resumeSessionId: 'x; rm -rf /' }));
expect(unsafe).not.toContain('--resume');
expect(unsafe).not.toContain('rm -rf');
});
it('forwards codex/gemini keys NAME-ONLY (no value in argv)', () => {
const codex = buildDockerLaunchCommand(
launchOpts({ mode: 'codex' as SessionMode, execEnvNames: ['OPENAI_API_KEY', 'CODEX_API_KEY'] })
);
expect(codex).toContain('--env OPENAI_API_KEY');
expect(codex).not.toMatch(/--env OPENAI_API_KEY=/); // never a value
});
it('primes CODEMAN_SESSION_ID / CODEMAN_MUX at exec time', () => {
const cmd = buildDockerLaunchCommand(launchOpts());
expect(cmd).toContain("--env 'CODEMAN_SESSION_ID=1a2b3c4d'");
expect(cmd).toContain("--env 'CODEMAN_MUX=1'");
});
it('keeps a workspace path with spaces a single token through every layer', () => {
const docker = toSessionDocker(HOST, { ...CASE, hostWorkspacePath: '/home/arkon/my cases/proj' });
const cmd = buildDockerLaunchCommand(launchOpts({ docker }));
// workdir single-quoted at the docker exec layer
expect(cmd).toContain("--workdir '/home/arkon/my cases/proj'");
// and the cd inside the (nested-escaped) paneCommand still references the spaced path
expect(cmd).toContain('/home/arkon/my cases/proj');
});
it('honors a per-host command override', () => {
const docker = { ...toSessionDocker(HOST, CASE), commands: { claude: 'exec claude --model opus' } };
const cmd = buildDockerLaunchCommand(launchOpts({ docker }));
expect(cmd).toContain('exec claude --model opus');
});
it('seeds writable config (guarded copies, mkdir -p parent) from the read-only seed mounts', () => {
const cmd = buildDockerLaunchCommand(
launchOpts({
seedCopies: [
{ from: '/home/agent/.codeman/claude.seed.json', to: '/home/agent/.claude.json' },
{ from: '/home/agent/.codeman/claude-creds.seed.json', to: '/home/agent/.claude/.credentials.json' },
// whole-dir credential seed → cp -a
{ from: '/home/agent/.codeman/cred-seeds/.gemini', to: '/home/agent/.gemini', recursive: true },
],
})
);
// Each copy mkdir -p's its parent then is guarded so a reconnect never clobbers config.
expect(cmd).toContain(
'mkdir -p /home/agent 2>/dev/null; [ -e /home/agent/.claude.json ] || cp /home/agent/.codeman/claude.seed.json /home/agent/.claude.json'
);
expect(cmd).toContain(
'mkdir -p /home/agent/.claude 2>/dev/null; [ -e /home/agent/.claude/.credentials.json ] || cp /home/agent/.codeman/claude-creds.seed.json /home/agent/.claude/.credentials.json'
);
// recursive whole-dir seed uses cp -a
expect(cmd).toContain(
'[ -e /home/agent/.gemini ] || cp -a /home/agent/.codeman/cred-seeds/.gemini /home/agent/.gemini'
);
expect(cmd.indexOf('.claude.json')).toBeLessThan(cmd.indexOf('tmux -L codeman-docker'));
});
it('omits the seed-copy step when there are no seedCopies', () => {
const cmd = buildDockerLaunchCommand(launchOpts());
expect(cmd).not.toContain('claude.seed.json');
});
});
describe('buildDockerKillCommand (multi-session safe)', () => {
it('kills ONLY this session in-container tmux, never the shared container', () => {
const docker = toSessionDocker(HOST, CASE);
const cmd = buildDockerKillCommand({ docker, sessionId: '1a2b3c4d5e6f' });
expect(cmd).toBe("docker exec 'codeman-case-myproj' tmux -L codeman-docker kill-session -t 'codeman-dkr-1a2b3c4d'");
expect(cmd).not.toContain('docker stop');
expect(cmd).not.toContain('docker rm');
});
});
describe('explicit teardown commands', () => {
it('stop and remove target the whole container', () => {
const docker = toSessionDocker(HOST, CASE);
expect(buildDockerStopCommand(docker)).toBe("docker stop -t 10 'codeman-case-myproj'");
expect(buildDockerRemoveCommand(docker)).toBe("docker rm -f 'codeman-case-myproj'");
});
it('uses the podman engine prefix when configured', () => {
const docker = toSessionDocker({ ...HOST, engine: 'podman' }, CASE);
expect(buildDockerStopCommand(docker)).toBe("podman stop -t 10 'codeman-case-myproj'");
});
});