Files
Codeman/test/docker-hosts.test.ts
T
Codeman maintainer 3e1272a675 feat(docker): resource templates, GPU, elastic disk, bridge-hooks listener
- One-click "Run in Docker" gains an expandable settings panel with a Template
  picker (Small 2G/1 · Medium 4G/2 default · Large 8G/4 · GPU 8G/4/all) plus
  memory/cpu/gpu/network/image/mount-creds overrides. Any tweak creates a dedicated
  per-case host; the plain checkbox keeps using the shared `default` host.
- GPU passthrough: `gpus` on DockerHost/SessionDocker -> `--gpus <value>` in create
  args (needs the NVIDIA container toolkit). Elastic disk: no `--storage-opt` cap,
  so container storage grows as data flows in.
- CODEMAN_DOCKER_BRIDGE_HOOKS=1: opt-in second listener on the docker bridge gateway
  (auto-detected 172.17.0.1, override CODEMAN_DOCKER_BRIDGE_HOST) that serves ONLY
  the hook endpoints and delegates into the secret-gated pipeline, so in-container
  hooks fire on a loopback-only server. Non-hook paths -> 403; host-internal, not LAN.

Verified live: Large template applies real 8GB/4CPU limits; a secret-authenticated
hook POST from inside a container now reaches the handler (was connection-refused);
non-hook paths return 403; template UI + GPU field verified via Playwright.

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

290 lines
12 KiB
TypeScript

/**
* 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');
});
it('emits --gpus only when GPUs are requested (and never a storage cap)', () => {
const withGpu: SessionDocker = { ...toSessionDocker(HOST, CASE), gpus: 'all' };
const s = buildDockerCreateArgs(ctx({ docker: withGpu })).join(' ');
expect(s).toContain("--gpus 'all'");
// elastic disk: no fixed storage cap is ever emitted
expect(s).not.toContain('--storage-opt');
expect(buildDockerCreateArgs(ctx()).join(' ')).not.toContain('--gpus');
});
});
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();
});
});