mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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>
190 lines
8.3 KiB
TypeScript
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'");
|
|
});
|
|
});
|