Merge feat/docker-session-mode into master (docker deep-review fixes)

Brings the docker session-mode deep-review work (intended for the skipped
1.4.2) onto the 1.5.x line: deterministic-conversation-id resume across
container stop/recreate, config-drift detection + POST /api/docker-cases/:name/recreate,
docker model-picker support, import-manifest hardening, remote-daemon (context/
daemonHost) correctness, comma-in-path rejection, and the zh-CN README re-translation.

Conflicts resolved to preserve BOTH the multi-user security scoping already on
master (ownership checks, workingDir confinement, permission downgrade) AND the
docker features. Version kept at master's 1.5.0 (the 1.4.2 bump is superseded;
a fresh changeset bumps to 1.5.1). tsc, eslint, and test:ci all green (3548 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-20 13:45:37 +02:00
22 changed files with 815 additions and 185 deletions
+26 -7
View File
@@ -88,11 +88,25 @@ describe('buildDockerLaunchCommand', () => {
expect(cmd).toContain("sh -lc '");
});
it('injects the resume flag ONLY when a resume id is passed', () => {
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('exec claude --dangerously-skip-permissions --resume abc-123-def');
const without = buildDockerLaunchCommand(launchOpts());
expect(without).not.toContain('--resume');
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', () => {
@@ -101,8 +115,10 @@ describe('buildDockerLaunchCommand', () => {
);
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('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)', () => {
@@ -128,10 +144,13 @@ describe('buildDockerLaunchCommand', () => {
expect(cmd).toContain('/home/arkon/my cases/proj');
});
it('honors a per-host command override', () => {
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('exec claude --model opus');
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', () => {
+39
View File
@@ -12,7 +12,9 @@ import {
isSafeTarMember,
parseLoadedImageRef,
exportDockerCase,
validateImportManifest,
DOCKER_EXPORT_SCHEMA,
type DockerExportManifest,
} from '../src/docker-export.js';
import { toSessionDocker } from '../src/docker-hosts.js';
import type { DockerCase, DockerHost } from '../src/types.js';
@@ -63,6 +65,43 @@ describe('isSafeTarMember (import traversal guard)', () => {
});
});
describe('validateImportManifest (untrusted cross-machine input)', () => {
const good = (): DockerExportManifest => ({
schemaVersion: DOCKER_EXPORT_SCHEMA,
caseName: 'myproj',
mode: 'full',
engine: 'docker',
image: 'codeman/agent:base',
containerWorkdir: '/home/arkon/cases/myproj',
network: 'bridge',
createdAt: 1,
codemanVersion: '1.4.1',
mountCredentials: true,
secretFree: true,
checksums: {},
});
it('accepts a well-formed manifest', () => {
expect(() => validateImportManifest(good())).not.toThrow();
});
it('rejects a hostile engine (would select the probe/launch binary)', () => {
expect(() => validateImportManifest({ ...good(), engine: 'rm' as never })).toThrow(/engine/);
});
it('rejects shell metacharacters in containerWorkdir', () => {
expect(() => validateImportManifest({ ...good(), containerWorkdir: '/w; rm -rf ~' })).toThrow(/containerWorkdir/);
expect(() => validateImportManifest({ ...good(), containerWorkdir: 'relative/path' })).toThrow(/containerWorkdir/);
});
it('rejects bad image refs, case names, networks, and schema versions', () => {
expect(() => validateImportManifest({ ...good(), image: '-bad$(x)' })).toThrow(/image/);
expect(() => validateImportManifest({ ...good(), caseName: '../evil' })).toThrow(/caseName/);
expect(() => validateImportManifest({ ...good(), network: 'host' })).toThrow(/network/);
expect(() => validateImportManifest({ ...good(), schemaVersion: 99 })).toThrow(/schema version/);
});
});
describe('parseLoadedImageRef', () => {
it('parses "Loaded image ID: sha256:..."', () => {
expect(parseLoadedImageRef('Loaded image ID: sha256:abc123def')).toBe('sha256:abc123def');
+14 -2
View File
@@ -25,6 +25,7 @@ import {
defaultDockerCommandForMode,
ensureAgentBaseImage,
hostGatewayAlias,
persistDockerCaseClaudeSessionId,
probeDockerCliVersion,
readDockerCases,
readDockerHosts,
@@ -67,6 +68,17 @@ describe('docker-hosts storage', () => {
expect(await readDockerHosts(dir)).toEqual([]);
expect(await readDockerCases(dir)).toEqual([]);
});
it('persists the last Claude conversation id keyed by container name', async () => {
await writeDockerCases(dir, [CASE, { ...CASE, name: 'other', container: 'custom-name' }]);
await persistDockerCaseClaudeSessionId(dir, dockerContainerName(CASE.name), 'conv-1');
await persistDockerCaseClaudeSessionId(dir, 'custom-name', 'conv-2');
await persistDockerCaseClaudeSessionId(dir, 'no-such-container', 'conv-3'); // no-op
const cases = await readDockerCases(dir);
expect(cases.find((c) => c.name === 'myproj')?.lastClaudeSessionId).toBe('conv-1');
expect(cases.find((c) => c.name === 'other')?.lastClaudeSessionId).toBe('conv-2');
expect(cases.some((c) => c.lastClaudeSessionId === 'conv-3')).toBe(false);
});
});
describe('naming / display / defaults', () => {
@@ -427,7 +439,7 @@ describe('agentImageBuildArgs', () => {
describe('ensureAgentBaseImage (no-op under VITEST)', () => {
it('reports the image as already present without spawning a build', async () => {
const r = await ensureAgentBaseImage('docker', DEFAULT_AGENT_IMAGE);
const r = await ensureAgentBaseImage({ engine: 'docker' }, DEFAULT_AGENT_IMAGE);
expect(r).toEqual({ ok: true, built: false, alreadyPresent: true });
});
});
@@ -442,7 +454,7 @@ describe('daemon probes (no-op under VITEST)', () => {
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);
expect(await checkDockerImagePresent({ engine: 'docker' }, DEFAULT_AGENT_IMAGE)).toBe(true);
});
it('probeDockerCliVersion is undefined under test', async () => {
+1 -1
View File
@@ -38,7 +38,7 @@ const MOBILE_VISIBLE_ALLOWLIST = new Set<string>([]);
// that removes a hide rule fails loudly (not silently). The attachments button is
// NOT here: it's opt-in (default-hidden everywhere via its own --hidden marker), so
// it's excluded from the default-visible enumeration rather than mobile-hidden.
const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log', 'btn-session-manager'];
const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log', 'btn-session-manager', 'btn-file-viewer'];
function attrOf(openTag: string, name: string): string {
const m = openTag.match(new RegExp(`${name}="([^"]*)"`));