mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
feat(docker): Docker session mode foundation (types, storage, tmux builders)
Phase 0-2 of the Docker cases feature (docs/docker-cases-plan.md). Docker is a LOCATION OVERLAY on cases (not a 6th SessionMode), mirroring the remote-SSH feature: a local tmux pane runs `docker exec -it` into a durable in-container tmux server. The container is per-CASE, so multiple sessions share it. - types: DockerHost/DockerCase/SessionDocker + docker? on SessionState/MuxSession - src/docker-hosts.ts: storage, toSessionDocker, pure buildDockerBaseArgs/ buildDockerCreateArgs (cap-drop, no-new-privileges, --pull=never, mem==swap, never privileged/socket), containerApiUrl, hostGatewayAlias, config-hash, credential-mount resolution, daemon probes (VITEST no-op) - schemas: DockerHostSchema + DockerCaseLinkSchema (NO_SHELL_META guards) - tmux-manager: buildDockerLaunchCommand (image-check -> ensure -> start -> exec, resume-aware), buildDockerKillCommand (in-container tmux only, multi-session safe), stop/remove; wired into createSession/respawnPane/killSession - 40 unit tests (docker-hosts + docker-exec-options), typecheck clean Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
|
||||
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'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Unit tests for the Docker cases storage + pure command-arg builders + probes
|
||||
* (src/docker-hosts.ts). Mirrors test/remote-hosts.test.ts. All docker IO no-ops
|
||||
* under VITEST, so probes return canned values and never spawn a real daemon.
|
||||
*/
|
||||
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildDockerBaseArgs,
|
||||
buildDockerCreateArgs,
|
||||
checkDockerAvailable,
|
||||
checkDockerImagePresent,
|
||||
checkDockerTmuxAvailable,
|
||||
containerApiUrl,
|
||||
DEFAULT_AGENT_IMAGE,
|
||||
DEFAULT_DOCKER_RESOURCES,
|
||||
dockerConfigHash,
|
||||
dockerContainerName,
|
||||
dockerDisplayPath,
|
||||
defaultDockerCommandForMode,
|
||||
hostGatewayAlias,
|
||||
probeDockerCliVersion,
|
||||
readDockerCases,
|
||||
readDockerHosts,
|
||||
resolveCredentialMounts,
|
||||
toSessionDocker,
|
||||
writeDockerCases,
|
||||
writeDockerHosts,
|
||||
type DockerCreateContext,
|
||||
} from '../src/docker-hosts.js';
|
||||
import type { DockerCase, DockerHost, SessionDocker } from '../src/types.js';
|
||||
|
||||
const HOST: DockerHost = { id: 'local', label: 'Local Docker', image: DEFAULT_AGENT_IMAGE };
|
||||
const CASE: DockerCase = {
|
||||
name: 'myproj',
|
||||
type: 'docker',
|
||||
hostId: 'local',
|
||||
hostWorkspacePath: '/home/arkon/cases/myproj',
|
||||
};
|
||||
|
||||
describe('docker-hosts storage', () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'codeman-docker-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('round-trips hosts and cases through JSON storage', async () => {
|
||||
await writeDockerHosts(dir, [HOST]);
|
||||
await writeDockerCases(dir, [{ ...CASE, lastClaudeSessionId: 'abc-123' }]);
|
||||
expect(await readDockerHosts(dir)).toEqual([HOST]);
|
||||
const cases = await readDockerCases(dir);
|
||||
expect(cases[0].lastClaudeSessionId).toBe('abc-123');
|
||||
});
|
||||
|
||||
it('returns [] for a missing file', async () => {
|
||||
expect(await readDockerHosts(dir)).toEqual([]);
|
||||
expect(await readDockerCases(dir)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('naming / display / defaults', () => {
|
||||
it('derives a valid per-case container name', () => {
|
||||
expect(dockerContainerName('myproj')).toBe('codeman-case-myproj');
|
||||
// valid docker name charset: starts alnum, then [a-zA-Z0-9_.-]
|
||||
expect(dockerContainerName('my_proj-2')).toMatch(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/);
|
||||
});
|
||||
|
||||
it('maps each mode to a default pane command', () => {
|
||||
expect(defaultDockerCommandForMode('claude')).toBe('exec claude --dangerously-skip-permissions');
|
||||
expect(defaultDockerCommandForMode('shell')).toBe('exec bash -l');
|
||||
expect(defaultDockerCommandForMode('codex')).toBe('exec codex');
|
||||
expect(defaultDockerCommandForMode('gemini')).toBe('exec gemini');
|
||||
});
|
||||
|
||||
it('formats a container:workdir display path from both shapes', () => {
|
||||
expect(dockerDisplayPath({ container: 'codeman-case-x', path: '/w' })).toBe('codeman-case-x:/w');
|
||||
const sd = toSessionDocker(HOST, CASE);
|
||||
expect(dockerDisplayPath(sd)).toBe('codeman-case-myproj:/home/arkon/cases/myproj');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hostGatewayAlias / containerApiUrl', () => {
|
||||
it('returns the engine-specific gateway alias', () => {
|
||||
expect(hostGatewayAlias('docker')).toBe('host.docker.internal');
|
||||
expect(hostGatewayAlias('podman')).toBe('host.containers.internal');
|
||||
});
|
||||
|
||||
it('swaps only the hostname, preserving scheme and port', () => {
|
||||
expect(containerApiUrl('https://127.0.0.1:3000', 'docker')).toBe('https://host.docker.internal:3000');
|
||||
expect(containerApiUrl('http://127.0.0.1:3000', 'docker')).toBe('http://host.docker.internal:3000');
|
||||
expect(containerApiUrl('https://127.0.0.1:8443', 'docker')).toBe('https://host.docker.internal:8443');
|
||||
expect(containerApiUrl('https://127.0.0.1:3000', 'podman')).toBe('https://host.containers.internal:3000');
|
||||
});
|
||||
|
||||
it('falls back to https://<alias>:3000 for absent or unparseable input', () => {
|
||||
expect(containerApiUrl(undefined, 'docker')).toBe('https://host.docker.internal:3000');
|
||||
expect(containerApiUrl('not a url', 'podman')).toBe('https://host.containers.internal:3000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSessionDocker / dockerConfigHash', () => {
|
||||
it('resolves every default (convenient, bridge, resume-on-start)', () => {
|
||||
const sd = toSessionDocker(HOST, CASE);
|
||||
expect(sd.engine).toBe('docker');
|
||||
expect(sd.image).toBe(DEFAULT_AGENT_IMAGE);
|
||||
expect(sd.containerName).toBe('codeman-case-myproj');
|
||||
expect(sd.hostWorkspacePath).toBe('/home/arkon/cases/myproj');
|
||||
expect(sd.containerWorkdir).toBe('/home/arkon/cases/myproj'); // mirror
|
||||
expect(sd.network).toBe('bridge');
|
||||
expect(sd.resources).toEqual(DEFAULT_DOCKER_RESOURCES);
|
||||
expect(sd.mountCredentials).toBe(true);
|
||||
expect(sd.hooksEnabled).toBe(true);
|
||||
expect(sd.resumeOnStart).toBe(true);
|
||||
expect(sd.configHash).toMatch(/^[0-9a-f]{12}$/);
|
||||
});
|
||||
|
||||
it('honors host overrides and a custom container workdir', () => {
|
||||
const host: DockerHost = {
|
||||
...HOST,
|
||||
engine: 'podman',
|
||||
network: 'none',
|
||||
mountCredentials: false,
|
||||
hooksEnabled: false,
|
||||
resumeOnStart: false,
|
||||
};
|
||||
const sd = toSessionDocker(host, { ...CASE, containerWorkdir: '/work', container: 'my-box' });
|
||||
expect(sd.engine).toBe('podman');
|
||||
expect(sd.network).toBe('none');
|
||||
expect(sd.mountCredentials).toBe(false);
|
||||
expect(sd.containerName).toBe('my-box');
|
||||
expect(sd.containerWorkdir).toBe('/work');
|
||||
});
|
||||
|
||||
it('hash is stable for equal inputs and changes when a drift field changes', () => {
|
||||
const a = toSessionDocker(HOST, CASE);
|
||||
const b = toSessionDocker(HOST, CASE);
|
||||
expect(a.configHash).toBe(b.configHash);
|
||||
const c = toSessionDocker({ ...HOST, image: 'codeman/agent:other' }, CASE);
|
||||
expect(c.configHash).not.toBe(a.configHash);
|
||||
// lastClaudeSessionId is NOT a drift field
|
||||
expect(dockerConfigHash(a)).toBe(dockerConfigHash({ ...a }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDockerBaseArgs', () => {
|
||||
it('defaults to docker with no extra flags', () => {
|
||||
expect(buildDockerBaseArgs({ engine: 'docker' })).toEqual(['docker']);
|
||||
});
|
||||
it('emits podman + context + daemon host', () => {
|
||||
const args = buildDockerBaseArgs({ engine: 'podman', context: 'remote', daemonHost: 'ssh://u@h' });
|
||||
expect(args[0]).toBe('podman');
|
||||
expect(args.join(' ')).toContain("--context 'remote'");
|
||||
expect(args.join(' ')).toContain("-H 'ssh://u@h'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDockerCreateArgs', () => {
|
||||
function ctx(overrides: Partial<DockerCreateContext> = {}): DockerCreateContext {
|
||||
return {
|
||||
docker: toSessionDocker(HOST, CASE),
|
||||
sessionId: '1a2b3c4d5e6f',
|
||||
instance: '',
|
||||
userArgs: ['--user', '1000:0'],
|
||||
credentialMounts: [{ src: '/home/arkon/.claude', dst: '/home/agent/.claude' }],
|
||||
extraMounts: [
|
||||
{ src: '/home/arkon/.codeman/hook-secret', dst: '/home/agent/.codeman/hook-secret', readonly: true },
|
||||
],
|
||||
envCreate: { HOME: '/home/agent', CODEMAN_API_URL: 'https://host.docker.internal:3000' },
|
||||
addHostGateway: true,
|
||||
gatewayAlias: 'host.docker.internal',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('bakes in the security + lifecycle invariants', () => {
|
||||
const s = buildDockerCreateArgs(ctx()).join(' ');
|
||||
expect(s).toContain('--cap-drop ALL');
|
||||
expect(s).toContain('--security-opt no-new-privileges');
|
||||
expect(s).toContain('--pull=never');
|
||||
expect(s).toContain('--init');
|
||||
expect(s).toContain('--restart no');
|
||||
expect(s).toContain('--memory 4g --memory-swap 4g');
|
||||
expect(s).toContain('--pids-limit 512');
|
||||
expect(s).toContain('--ulimit nofile=4096:8192');
|
||||
expect(s).toContain('codeman.managed=1');
|
||||
expect(s).toContain("'codeman.session=1a2b3c4d'"); // first 8 chars only
|
||||
expect(s).toContain('--network bridge');
|
||||
});
|
||||
|
||||
it('NEVER emits privileged mode or a docker-socket mount', () => {
|
||||
const s = buildDockerCreateArgs(ctx()).join(' ');
|
||||
expect(s).not.toContain('--privileged');
|
||||
expect(s).not.toContain('docker.sock');
|
||||
});
|
||||
|
||||
it('ends with the image then the sleep-infinity CMD', () => {
|
||||
const args = buildDockerCreateArgs(ctx());
|
||||
expect(args.slice(-3)).toEqual([`'${DEFAULT_AGENT_IMAGE}'`, 'sleep', 'infinity']);
|
||||
});
|
||||
|
||||
it('includes the resolved user args and the workspace bind', () => {
|
||||
const s = buildDockerCreateArgs(ctx()).join(' ');
|
||||
expect(s).toContain('--user 1000:0');
|
||||
expect(s).toContain("--mount 'type=bind,src=/home/arkon/cases/myproj,dst=/home/arkon/cases/myproj'");
|
||||
});
|
||||
|
||||
it('shell-escapes a workspace path containing spaces into a single token', () => {
|
||||
const docker = toSessionDocker(HOST, { ...CASE, hostWorkspacePath: '/home/arkon/my cases/proj' });
|
||||
const args = buildDockerCreateArgs(ctx({ docker }));
|
||||
// the whole mount spec (with the space) is ONE single-quoted token
|
||||
expect(args).toContain("'type=bind,src=/home/arkon/my cases/proj,dst=/home/arkon/my cases/proj'");
|
||||
// and the workdir is single-quoted too
|
||||
expect(args).toContain("'/home/arkon/my cases/proj'");
|
||||
});
|
||||
|
||||
it('adds the host-gateway only when requested', () => {
|
||||
expect(buildDockerCreateArgs(ctx({ addHostGateway: true })).join(' ')).toContain(
|
||||
'--add-host host.docker.internal:host-gateway'
|
||||
);
|
||||
expect(buildDockerCreateArgs(ctx({ addHostGateway: false })).join(' ')).not.toContain('--add-host');
|
||||
});
|
||||
|
||||
it('omits credential mounts in sealed mode', () => {
|
||||
const s = buildDockerCreateArgs(ctx({ credentialMounts: [] })).join(' ');
|
||||
expect(s).not.toContain('/home/agent/.claude');
|
||||
});
|
||||
|
||||
it('emits create-time env flags', () => {
|
||||
const s = buildDockerCreateArgs(ctx()).join(' ');
|
||||
expect(s).toContain("--env 'HOME=/home/agent'");
|
||||
expect(s).toContain("--env 'CODEMAN_API_URL=https://host.docker.internal:3000'");
|
||||
});
|
||||
|
||||
it('uses the custom network name for custom mode', () => {
|
||||
const docker: SessionDocker = { ...toSessionDocker(HOST, CASE), network: 'custom', networkName: 'codeman-net-x' };
|
||||
expect(buildDockerCreateArgs(ctx({ docker })).join(' ')).toContain('--network codeman-net-x');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCredentialMounts', () => {
|
||||
let home: string;
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'codeman-home-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('only mounts credential paths that exist', () => {
|
||||
mkdirSync(join(home, '.claude'), { recursive: true });
|
||||
const mounts = resolveCredentialMounts(home);
|
||||
expect(mounts).toContainEqual({ src: join(home, '.claude'), dst: '/home/agent/.claude' });
|
||||
expect(mounts.find((m) => m.dst.endsWith('.codex'))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('daemon probes (no-op under VITEST)', () => {
|
||||
it('checkDockerAvailable returns a canned available result', async () => {
|
||||
const a = await checkDockerAvailable();
|
||||
expect(a.ok).toBe(true);
|
||||
expect(a.capsEnforced).toBe(true);
|
||||
expect(a.engine).toBe('docker');
|
||||
});
|
||||
|
||||
it('checkDockerTmuxAvailable + image present are canned-true', async () => {
|
||||
expect((await checkDockerTmuxAvailable({ engine: 'docker', image: DEFAULT_AGENT_IMAGE })).ok).toBe(true);
|
||||
expect(await checkDockerImagePresent('docker', DEFAULT_AGENT_IMAGE)).toBe(true);
|
||||
});
|
||||
|
||||
it('probeDockerCliVersion is undefined under test', async () => {
|
||||
expect(
|
||||
await probeDockerCliVersion({ engine: 'docker', containerName: 'codeman-case-x' }, 'claude')
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user