mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
- 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>
290 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|