mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
209 lines
9.5 KiB
TypeScript
209 lines
9.5 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('pins a deterministic conversation id with a reboot-surviving fallback (fresh launch)', () => {
|
|
const cmd = buildDockerLaunchCommand(launchOpts());
|
|
// --session-id first (fresh start), || --resume so a container stop/reboot
|
|
// relaunch of the SAME session resumes instead of dead-paning on
|
|
// "Session ID already in use".
|
|
expect(cmd).toContain(
|
|
'claude --dangerously-skip-permissions --session-id 1a2b3c4d5e6f || ' +
|
|
'claude --dangerously-skip-permissions --resume 1a2b3c4d5e6f'
|
|
);
|
|
// exec is stripped from the claude pane command — an exec'd first branch could never fall back.
|
|
expect(cmd).not.toContain('exec claude');
|
|
});
|
|
|
|
it('resumes an explicit id with a --session-id fallback (stale id never dead-panes)', () => {
|
|
const withResume = buildDockerLaunchCommand(launchOpts({ resumeSessionId: 'abc-123-def' }));
|
|
expect(withResume).toContain(
|
|
'claude --dangerously-skip-permissions --resume abc-123-def || ' +
|
|
'claude --dangerously-skip-permissions --session-id 1a2b3c4d5e6f'
|
|
);
|
|
});
|
|
|
|
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('x; rm'); // unsafe id dropped entirely
|
|
expect(unsafe).not.toContain('rm -rf');
|
|
// falls back to the deterministic fresh-launch chain on the session's own id
|
|
expect(unsafe).toContain('--session-id 1a2b3c4d5e6f');
|
|
});
|
|
|
|
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 (exec stripped for the session-id chain)', () => {
|
|
const docker = { ...toSessionDocker(HOST, CASE), commands: { claude: 'exec claude --model opus' } };
|
|
const cmd = buildDockerLaunchCommand(launchOpts({ docker }));
|
|
expect(cmd).toContain('claude --model opus --session-id 1a2b3c4d5e6f');
|
|
const shellOverride = { ...toSessionDocker(HOST, CASE), commands: { shell: 'exec zsh -l' } };
|
|
const shellCmd = buildDockerLaunchCommand(launchOpts({ mode: 'shell' as SessionMode, docker: shellOverride }));
|
|
expect(shellCmd).toContain('exec zsh -l'); // non-claude overrides keep their exec
|
|
});
|
|
|
|
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'");
|
|
});
|
|
});
|