chore: version packages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-20 02:36:02 +02:00
parent 9046b95b7e
commit a21400614a
27 changed files with 638 additions and 92 deletions
+22
View File
@@ -1,5 +1,27 @@
# aicodeman # aicodeman
## 1.4.2
### Patch Changes
- Docker session-mode deep-review fixes (all e2e-verified against a real daemon), a new Auto permission mode, and docs sync.
**Docker resume actually works now.** `DockerCase.lastClaudeSessionId` was read at quick-start but never written anywhere, so the documented resume-after-container-stop never fired. Claude-mode docker panes now pin a deterministic conversation id (`claudeDockerPaneCommand()` in tmux-manager.ts): fresh launches run `claude --session-id <sessionId> || claude --resume <sessionId>` (a duplicate `--session-id` exits 1 "already in use", so the fallback resumes after a container stop/reboot; verified CLI behavior), explicit resumes run `--resume <rid> || --session-id <sid>` so a stale id never dead-panes; the leading `exec ` is stripped so the fallback can run. The id is persisted via `persistDockerCaseClaudeSessionId()` at quick-start launch and again on hook / last-response conversation-id adoption (post-`/clear` switches track). Verified end-to-end: a conversation survives `docker stop` + relaunch AND a full container recreate.
**Config drift detection + recreate (was documented but entirely missing).** The `codeman.confighash` label was stamped but never read, so docker-host config edits silently never applied. Quick-start now compares via `checkDockerConfigDrift()` and refuses a drifted launch with `CONFLICT`; the UI shows a confirm and calls the new `POST /api/docker-cases/:name/recreate` (refused while sessions of the case are live), then relaunches — verified e2e that an edited `--memory` cap is applied after recreate and the conversation resumes. New SSE event `docker:containerRecreated` (both registries).
**Model picker now applies to docker sessions.** `modelOverride` was absent from `QuickStartSchema`, so the App Settings Claude Model choice was silently inert for docker runs. It is now accepted, applied via `updateCaseModel` for local and docker quick-starts, sent by the frontend docker run path, and explicitly rejected for remote quick-starts (the settings file would land on the wrong machine).
**Import hardening.** `importDockerBundle` now validates the (cross-machine, untrusted) manifest before trusting any field (`validateImportManifest`: engine/image/containerWorkdir/network/caseName/schemaVersion — a hostile `engine` could previously select the probe binary); the outer bundle tar gets the same member traversal guard as the inner workspace tar; the quarantine image tag derives from the schema-validated `newCaseName` instead of the manifest's; re-importing a case name now refreshes the auto-created `imported-<name>` host instead of leaving it pinned to the previous import's image tag.
**Remote-daemon correctness.** All docker probes and the base-image auto-build now honor a host's `context`/`daemonHost` (`dockerEngineArgv`); previously they always probed the local daemon.
**Smaller fixes:** commas are now rejected in docker workspace/workdir/destination paths (a comma corrupts the `--mount type=bind,src=...` CSV spec, which shell escaping cannot protect); `refreshDockerExports` used a never-defined `this.escapeHtml` (dead escaping, now the real `escapeHtml`); `docker:importComplete` and `docker:containerRecreated` now have frontend SSE listeners so other open tabs refresh; the opt-in File Viewer header button is hidden on phone headers like its siblings; the Run-in-Docker hint notes that Docker/Podman must be installed; `docs/docker-cases.md` documents the resume + drift-recreate lifecycle.
**New: Auto permission mode.** App Settings → Startup Mode gains `auto` (`claude --permission-mode auto`, Claude Code 2.1.207+): no routine prompts, with the background safety classifier guarding destructive actions. Threaded through both spawn paths (direct PTY and tmux) with tests; the default stays `--dangerously-skip-permissions`.
**Docs:** multi-user mode design plan added; CLAUDE.md + READMEs (incl. zh-CN full re-translation) synced with the 1.4.1 feature set.
## 1.4.1 ## 1.4.1
### Patch Changes ### Patch Changes
+2 -2
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -52,8 +52,9 @@ curl -X POST localhost:3000/api/quick-start -d '{"caseName":"sandbox","mode":"cl
## Lifecycle ## Lifecycle
- **Reconnect after a Codeman restart** lands back in the same live agent (the in-container tmux survives). - **Reconnect after a Codeman restart** lands back in the same live agent (the in-container tmux survives).
- **Container stop / host reboot** recreates the container and, when a resume id was captured, **resumes** the last conversation from the bind-mounted transcript. - **Container stop / host reboot** restarts the container and **resumes** the last conversation from the bind-mounted transcript. Claude sessions launch with a pinned conversation id (`--session-id <sessionId>`, with a `--resume` fallback when the transcript already exists), and the case remembers its last conversation (`lastClaudeSessionId`), so a relaunch after the container was stopped, rebooted, or recreated continues where it left off.
- **Killing one session** only kills that session's in-container tmux session; the shared container stays up for sibling sessions. - **Killing one session** only kills that session's in-container tmux session; the shared container stays up for sibling sessions.
- **Editing the docker host config** (image, memory, network, ...) is detected on the next launch: the desired config hash is compared against the container's `codeman.confighash` label, and a mismatch refuses the launch with a "config changed, recreate?" confirm. Confirming calls `POST /api/docker-cases/:name/recreate` (refused while sessions of the case are live), which removes the container so the next launch recreates it with the new config; the workspace and the conversation survive.
- **Deleting the case** `docker rm -f`s the container (the bind-mounted workspace on the host survives). An instance-scoped boot reaper removes containers whose case is gone. - **Deleting the case** `docker rm -f`s the container (the bind-mounted workspace on the host survives). An instance-scoped boot reaper removes containers whose case is gone.
## Isolation & security ## Isolation & security
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "aicodeman", "name": "aicodeman",
"version": "1.4.1", "version": "1.4.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "aicodeman", "name": "aicodeman",
"version": "1.4.1", "version": "1.4.2",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aicodeman", "name": "aicodeman",
"version": "1.4.1", "version": "1.4.2",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
+57 -8
View File
@@ -105,6 +105,45 @@ export function parseLoadedImageRef(loadOutput: string): string | null {
return null; return null;
} }
/**
* Validate an imported bundle's manifest BEFORE any of its fields are trusted.
* A bundle is cross-machine input (potentially authored by someone else), and its
* fields flow into stored host/case config that the schema layer never sees:
* `engine` becomes the probe/launch binary selector, `image`/`containerWorkdir`
* reach the shellescaped launch string, `network` is a create arg. Mirror the
* DockerHostSchema/DockerCaseLinkSchema constraints here (throwing, since this is
* not a web-layer module). Exported for unit tests.
*/
export function validateImportManifest(manifest: DockerExportManifest): void {
const fail = (msg: string): never => {
throw new Error(`invalid bundle manifest: ${msg}`);
};
if (manifest.schemaVersion !== DOCKER_EXPORT_SCHEMA) {
fail(`unsupported export schema version ${manifest.schemaVersion} (expected ${DOCKER_EXPORT_SCHEMA})`);
}
if (manifest.mode !== 'full' && manifest.mode !== 'workspace') fail(`unknown mode ${String(manifest.mode)}`);
if (manifest.engine !== 'docker' && manifest.engine !== 'podman') fail(`unknown engine ${String(manifest.engine)}`);
if (typeof manifest.caseName !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(manifest.caseName)) fail('bad caseName');
if (
typeof manifest.image !== 'string' ||
manifest.image.length > 512 ||
!/^[a-zA-Z0-9][\w./:@-]*$/.test(manifest.image)
) {
fail('bad image reference');
}
if (
typeof manifest.containerWorkdir !== 'string' ||
manifest.containerWorkdir.length > 2000 ||
!manifest.containerWorkdir.startsWith('/') ||
// comma: --mount specs are comma-delimited CSV; shell escaping cannot protect it
/[`$\\"'\n\r;&|<>,]/.test(manifest.containerWorkdir)
) {
fail('bad containerWorkdir');
}
if (!['bridge', 'none', 'custom'].includes(manifest.network)) fail(`unknown network ${String(manifest.network)}`);
if (typeof manifest.checksums !== 'object' || manifest.checksums === null) fail('missing checksums');
}
// ========== IO helpers ========== // ========== IO helpers ==========
function run( function run(
@@ -338,25 +377,34 @@ export async function importDockerBundle(params: {
destWorkspace: string; destWorkspace: string;
engine: DockerEngine; engine: DockerEngine;
timestamp: number; timestamp: number;
/** Schema-validated destination case name; the quarantine tag derives from THIS,
* never from the (attacker-authored) manifest.caseName. */
newCaseName: string;
}): Promise<ImportResult> { }): Promise<ImportResult> {
const { bundlePath, destWorkspace, engine, timestamp } = params; const { bundlePath, destWorkspace, engine, timestamp, newCaseName } = params;
const argv: string[] = [engine === 'podman' ? 'podman' : 'docker']; const argv: string[] = [engine === 'podman' ? 'podman' : 'docker'];
if (IS_TEST_MODE) { if (IS_TEST_MODE) {
const raw = await fs.readFile(bundlePath, 'utf-8').catch(() => '{}'); const raw = await fs.readFile(bundlePath, 'utf-8').catch(() => '{}');
return { manifest: JSON.parse(raw) as DockerExportManifest, workspacePath: destWorkspace }; const manifest = JSON.parse(raw) as DockerExportManifest;
validateImportManifest(manifest);
return { manifest, workspacePath: destWorkspace };
} }
const stageDir = `${destWorkspace}.import-stage-${timestamp}`; const stageDir = `${destWorkspace}.import-stage-${timestamp}`;
mkdirSync(stageDir, { recursive: true }); mkdirSync(stageDir, { recursive: true });
try { try {
await run('tar', ['-xzf', bundlePath, '-C', stageDir], { timeout: 300_000 }); // Outer-bundle traversal guard (defense in depth: GNU/bsd tar already refuse
// `..`/absolute members by default, but the bundle is cross-machine input).
const { stdout: bundleMembers } = await run('tar', ['-tzf', bundlePath], { timeout: 60_000 });
for (const member of bundleMembers.split('\n').filter(Boolean)) {
if (!isSafeTarMember(member)) throw new Error(`unsafe path in bundle archive: ${member}`);
}
await run('tar', ['--no-same-owner', '-xzf', bundlePath, '-C', stageDir], { timeout: 300_000 });
const manifestRaw = await fs.readFile(join(stageDir, 'manifest.json'), 'utf-8'); const manifestRaw = await fs.readFile(join(stageDir, 'manifest.json'), 'utf-8');
const manifest = JSON.parse(manifestRaw) as DockerExportManifest; const manifest = JSON.parse(manifestRaw) as DockerExportManifest;
if (manifest.schemaVersion !== DOCKER_EXPORT_SCHEMA) { validateImportManifest(manifest);
throw new Error(`unsupported export schema version ${manifest.schemaVersion} (expected ${DOCKER_EXPORT_SCHEMA})`);
}
// Integrity: verify checksums before trusting any member. // Integrity: verify checksums before trusting any member.
const workspaceTar = join(stageDir, 'workspace.tar'); const workspaceTar = join(stageDir, 'workspace.tar');
@@ -385,8 +433,9 @@ export async function importDockerBundle(params: {
const { stdout } = await run(argv[0], [...argv.slice(1), 'load', '-i', imageTar], { timeout: 300_000 }); const { stdout } = await run(argv[0], [...argv.slice(1), 'load', '-i', imageTar], { timeout: 300_000 });
const loadedRef = parseLoadedImageRef(stdout); const loadedRef = parseLoadedImageRef(stdout);
if (!loadedRef) throw new Error('could not determine loaded image ref'); if (!loadedRef) throw new Error('could not determine loaded image ref');
// Quarantine: re-tag by the loaded ref/id, never trusting the bundle's original tag. // Quarantine: re-tag by the loaded ref/id, never trusting the bundle's original
importedImage = importedImageTag(manifest.caseName, timestamp); // tag; the tag name derives from the caller's schema-validated newCaseName.
importedImage = importedImageTag(newCaseName, timestamp);
await run(argv[0], [...argv.slice(1), 'tag', loadedRef, importedImage], { timeout: 60_000 }); await run(argv[0], [...argv.slice(1), 'tag', loadedRef, importedImage], { timeout: 60_000 });
} }
+120 -21
View File
@@ -109,6 +109,24 @@ export async function writeDockerCases(configDir: string, cases: DockerCase[]):
await writeJsonArray(configDir, dockerCasesPath(configDir), cases); await writeJsonArray(configDir, dockerCasesPath(configDir), cases);
} }
/**
* Persist the case's last Claude conversation id (the `--resume` seed for the
* container-recreated relaunch, docs/docker-cases-plan.md two-layer durability).
* Keyed by container name so callers that only hold a SessionDocker can update it.
* No-op when the id is unchanged or the case is gone.
*/
export async function persistDockerCaseClaudeSessionId(
configDir: string,
containerName: string,
claudeSessionId: string
): Promise<void> {
const cases = await readDockerCases(configDir);
const idx = cases.findIndex((c) => (c.container ?? dockerContainerName(c.name)) === containerName);
if (idx === -1 || cases[idx].lastClaudeSessionId === claudeSessionId) return;
cases[idx] = { ...cases[idx], lastClaudeSessionId: claudeSessionId };
await writeDockerCases(configDir, cases);
}
// ========== Naming / display / defaults ========== // ========== Naming / display / defaults ==========
/** Per-case container name. Mirrors how remote derives a stable name from the case. */ /** Per-case container name. Mirrors how remote derives a stable name from the case. */
@@ -624,6 +642,73 @@ export function resolveDockerCredentialArtifacts(home: string = homedir()): Dock
// ========== Daemon probes (IO; no-op under VITEST) ========== // ========== Daemon probes (IO; no-op under VITEST) ==========
/**
* UNESCAPED argv prefix for execFile-based probes. The shellescaped
* buildDockerBaseArgs variant is for interpolation into the `bash -c` launch
* string; argv arrays must NOT carry literal quotes (mirror of docker-export's
* dockerArgv).
*/
function dockerEngineArgv(docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>): string[] {
const argv: string[] = [docker.engine === 'podman' ? 'podman' : 'docker'];
if (docker.context) argv.push('--context', docker.context);
if (docker.daemonHost) argv.push('-H', docker.daemonHost);
return argv;
}
export interface DockerDriftStatus {
/** Container exists (daemon reachable AND a container with this name is present). */
exists: boolean;
running: boolean;
/** The desired configHash no longer matches the container's codeman.confighash label. */
drifted: boolean;
currentHash?: string;
}
/**
* Drift check (docs/docker-cases-plan.md §4): compare the DESIRED configHash
* against the existing container's `codeman.confighash` label so docker-host
* config edits actually take effect instead of being silently ignored by the
* idempotent inspect-or-create launch chain. `exists:false` (no container /
* daemon down) means there is nothing to drift. No-op under VITEST.
*/
export async function checkDockerConfigDrift(
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName' | 'configHash'>
): Promise<DockerDriftStatus> {
if (IS_TEST_MODE) return { exists: false, running: false, drifted: false };
const argv = dockerEngineArgv(docker);
try {
const { stdout } = await execFileAsync(
argv[0],
[
...argv.slice(1),
'inspect',
'-f',
'{{.State.Running}}\t{{index .Config.Labels "codeman.confighash"}}',
docker.containerName,
],
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
);
const [running = '', hash = ''] = stdout.trim().split('\t');
return { exists: true, running: running === 'true', drifted: hash !== docker.configHash, currentHash: hash };
} catch {
return { exists: false, running: false, drifted: false };
}
}
/**
* `docker rm -f` the case container (the recreate-on-drift confirm action; the
* launch chain recreates it with the new config on next start). Workspace +
* transcripts ride bind mounts and survive; the conversation resumes via the
* case's lastClaudeSessionId. No-op under VITEST.
*/
export async function removeDockerContainer(
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName'>
): Promise<void> {
if (IS_TEST_MODE) return;
const argv = dockerEngineArgv(docker);
await execFileAsync(argv[0], [...argv.slice(1), 'rm', '-f', docker.containerName], { timeout: 30_000 });
}
export interface DockerAvailability { export interface DockerAvailability {
ok: boolean; ok: boolean;
engine: DockerEngine; engine: DockerEngine;
@@ -702,11 +787,16 @@ export async function checkDockerAvailable(engine?: DockerEngine): Promise<Docke
}; };
} }
/** Is the base image present locally? (never triggers an auto-pull). */ /** Is the base image present on the host's daemon? (never triggers an auto-pull).
export async function checkDockerImagePresent(engine: DockerEngine, image: string): Promise<boolean> { * Honors context/daemonHost so a remote-daemon host is probed on the RIGHT daemon. */
export async function checkDockerImagePresent(
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>,
image: string
): Promise<boolean> {
if (IS_TEST_MODE) return true; if (IS_TEST_MODE) return true;
const argv = dockerEngineArgv(docker);
try { try {
await execFileAsync(engine, ['image', 'inspect', '--format', '{{.Id}}', image], { await execFileAsync(argv[0], [...argv.slice(1), 'image', 'inspect', '--format', '{{.Id}}', image], {
timeout: DOCKER_PROBE_TIMEOUT_MS, timeout: DOCKER_PROBE_TIMEOUT_MS,
}); });
return true; return true;
@@ -748,12 +838,12 @@ function resolveAgentDockerfile(): { dockerfile: string; contextDir: string } |
* lines for SSE surfacing. * lines for SSE surfacing.
*/ */
export async function ensureAgentBaseImage( export async function ensureAgentBaseImage(
engine: DockerEngine, docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>,
image: string, image: string,
opts: { onProgress?: (line: string) => void; noCache?: boolean } = {} opts: { onProgress?: (line: string) => void; noCache?: boolean } = {}
): Promise<EnsureImageResult> { ): Promise<EnsureImageResult> {
if (IS_TEST_MODE) return { ok: true, built: false, alreadyPresent: true }; if (IS_TEST_MODE) return { ok: true, built: false, alreadyPresent: true };
if (await checkDockerImagePresent(engine, image)) { if (await checkDockerImagePresent(docker, image)) {
return { ok: true, built: false, alreadyPresent: true }; return { ok: true, built: false, alreadyPresent: true };
} }
if (image !== DEFAULT_AGENT_IMAGE) { if (image !== DEFAULT_AGENT_IMAGE) {
@@ -764,16 +854,16 @@ export async function ensureAgentBaseImage(
error: `image ${image} is not present and only ${DEFAULT_AGENT_IMAGE} is auto-built. Build or pull ${image} yourself.`, error: `image ${image} is not present and only ${DEFAULT_AGENT_IMAGE} is auto-built. Build or pull ${image} yourself.`,
}; };
} }
const key = `${engine}:${image}`; const key = `${docker.engine}:${image}`;
const existing = inFlightImageBuilds.get(key); const existing = inFlightImageBuilds.get(key);
if (existing) return existing; if (existing) return existing;
const build = buildAgentImage(engine, image, opts).finally(() => inFlightImageBuilds.delete(key)); const build = buildAgentImage(docker, image, opts).finally(() => inFlightImageBuilds.delete(key));
inFlightImageBuilds.set(key, build); inFlightImageBuilds.set(key, build);
return build; return build;
} }
function buildAgentImage( function buildAgentImage(
engine: DockerEngine, docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>,
image: string, image: string,
opts: { onProgress?: (line: string) => void; noCache?: boolean } opts: { onProgress?: (line: string) => void; noCache?: boolean }
): Promise<EnsureImageResult> { ): Promise<EnsureImageResult> {
@@ -786,10 +876,14 @@ function buildAgentImage(
error: `docker/agent.Dockerfile not found in this install; clone the repo or build ${image} manually`, error: `docker/agent.Dockerfile not found in this install; clone the repo or build ${image} manually`,
}); });
} }
const args = agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache); const argv = dockerEngineArgv(docker);
const args = [
...argv.slice(1),
...agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache),
];
return new Promise<EnsureImageResult>((resolve) => { return new Promise<EnsureImageResult>((resolve) => {
// async spawn (NEVER spawnSync) so a multi-minute build never wedges the event loop. // async spawn (NEVER spawnSync) so a multi-minute build never wedges the event loop.
const child = spawn(engine, args, { stdio: ['ignore', 'pipe', 'pipe'] }); const child = spawn(argv[0], args, { stdio: ['ignore', 'pipe', 'pipe'] });
const forward = (buf: Buffer) => { const forward = (buf: Buffer) => {
for (const line of buf.toString('utf-8').split('\n')) { for (const line of buf.toString('utf-8').split('\n')) {
const trimmed = line.trimEnd(); const trimmed = line.trimEnd();
@@ -803,12 +897,12 @@ function buildAgentImage(
ok: false, ok: false,
built: false, built: false,
alreadyPresent: false, alreadyPresent: false,
error: `could not spawn ${engine} build: ${err.message}`, error: `could not spawn ${argv[0]} build: ${err.message}`,
}); });
}); });
child.on('exit', (code) => { child.on('exit', (code) => {
if (code === 0) resolve({ ok: true, built: true, alreadyPresent: false }); if (code === 0) resolve({ ok: true, built: true, alreadyPresent: false });
else resolve({ ok: false, built: false, alreadyPresent: false, error: `${engine} build failed (exit ${code})` }); else resolve({ ok: false, built: false, alreadyPresent: false, error: `${argv[0]} build failed (exit ${code})` });
}); });
}); });
} }
@@ -827,21 +921,21 @@ export interface DockerTmuxCheckResult {
* (`--pull=never`). No-op under VITEST. Mirror of checkRemoteTmuxAvailable. * (`--pull=never`). No-op under VITEST. Mirror of checkRemoteTmuxAvailable.
*/ */
export async function checkDockerTmuxAvailable( export async function checkDockerTmuxAvailable(
docker: Pick<SessionDocker, 'engine' | 'image'> docker: Pick<SessionDocker, 'engine' | 'image' | 'context' | 'daemonHost'>
): Promise<DockerTmuxCheckResult> { ): Promise<DockerTmuxCheckResult> {
if (IS_TEST_MODE) return { ok: true, tmuxPath: '/usr/bin/tmux' }; if (IS_TEST_MODE) return { ok: true, tmuxPath: '/usr/bin/tmux' };
const engine = docker.engine; if (!(await checkDockerImagePresent(docker, docker.image))) {
if (!(await checkDockerImagePresent(engine, docker.image))) {
return { return {
ok: false, ok: false,
imageMissing: true, imageMissing: true,
error: `image ${docker.image} not present (the default image is auto-built on first use; a custom image must be built or pulled first)`, error: `image ${docker.image} not present (the default image is auto-built on first use; a custom image must be built or pulled first)`,
}; };
} }
const argv = dockerEngineArgv(docker);
try { try {
const { stdout } = await execFileAsync( const { stdout } = await execFileAsync(
engine, argv[0],
['run', '--rm', '--pull=never', docker.image, 'sh', '-lc', 'command -v tmux'], [...argv.slice(1), 'run', '--rm', '--pull=never', docker.image, 'sh', '-lc', 'command -v tmux'],
{ timeout: DOCKER_PROBE_TIMEOUT_MS } { timeout: DOCKER_PROBE_TIMEOUT_MS }
); );
const tmuxPath = stdout.trim(); const tmuxPath = stdout.trim();
@@ -938,16 +1032,21 @@ export async function reapOrphanedDockerContainers(
* Returns undefined on any failure. No-op under VITEST. * Returns undefined on any failure. No-op under VITEST.
*/ */
export async function probeDockerCliVersion( export async function probeDockerCliVersion(
docker: Pick<SessionDocker, 'engine' | 'containerName'>, docker: Pick<SessionDocker, 'engine' | 'containerName' | 'context' | 'daemonHost'>,
mode: SessionMode mode: SessionMode
): Promise<string | undefined> { ): Promise<string | undefined> {
if (IS_TEST_MODE) return undefined; if (IS_TEST_MODE) return undefined;
const bin = mode === 'shell' ? null : mode; const bin = mode === 'shell' ? null : mode;
if (!bin) return undefined; if (!bin) return undefined;
const argv = dockerEngineArgv(docker);
try { try {
const { stdout } = await execFileAsync(docker.engine, ['exec', docker.containerName, bin, '--version'], { const { stdout } = await execFileAsync(
timeout: DOCKER_PROBE_TIMEOUT_MS, argv[0],
}); [...argv.slice(1), 'exec', docker.containerName, bin, '--version'],
{
timeout: DOCKER_PROBE_TIMEOUT_MS,
}
);
const match = stdout.trim().match(/\d+\.\d+\.\d+/); const match = stdout.trim().match(/\d+\.\d+\.\d+/);
return match ? match[0] : stdout.trim() || undefined; return match ? match[0] : stdout.trim() || undefined;
} catch { } catch {
+2
View File
@@ -21,6 +21,8 @@ function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): str
switch (claudeMode) { switch (claudeMode) {
case 'dangerously-skip-permissions': case 'dangerously-skip-permissions':
return ['--dangerously-skip-permissions']; return ['--dangerously-skip-permissions'];
case 'auto':
return ['--permission-mode', 'auto'];
case 'allowedTools': case 'allowedTools':
if (allowedTools) { if (allowedTools) {
return ['--allowedTools', allowedTools]; return ['--allowedTools', allowedTools];
+1 -1
View File
@@ -1523,7 +1523,7 @@ export class Session extends EventEmitter {
// === Auto-accept workspace trust dialog === // === Auto-accept workspace trust dialog ===
// Claude CLI 2.x shows "Yes, I trust this folder" prompt on first launch per directory. // Claude CLI 2.x shows "Yes, I trust this folder" prompt on first launch per directory.
// Codeman sessions always use --dangerously-skip-permissions, so auto-accept. // Codeman sessions run permission-skipping or classifier-guarded (auto) modes, so auto-accept.
if (!this._trustDialogAccepted && data.includes('trust this folder')) { if (!this._trustDialogAccepted && data.includes('trust this folder')) {
this._trustDialogAccepted = true; this._trustDialogAccepted = true;
console.log(`[Session] Auto-accepting workspace trust dialog for: ${this.id}`); console.log(`[Session] Auto-accepting workspace trust dialog for: ${this.id}`);
+37 -7
View File
@@ -563,6 +563,8 @@ function buildClaudePermissionFlags(claudeMode?: ClaudeMode, allowedTools?: stri
switch (mode) { switch (mode) {
case 'dangerously-skip-permissions': case 'dangerously-skip-permissions':
return ' --dangerously-skip-permissions'; return ' --dangerously-skip-permissions';
case 'auto':
return ' --permission-mode auto';
case 'allowedTools': case 'allowedTools':
if (allowedTools) { if (allowedTools) {
// Sanitize: allow tool names with patterns like Bash(git:*), space/comma-separated // Sanitize: allow tool names with patterns like Bash(git:*), space/comma-separated
@@ -674,7 +676,7 @@ function buildEffortSettingsFlag(effort?: EffortLevel): string {
return flag && value ? ` ${flag} '${value}'` : ''; return flag && value ? ` ${flag} '${value}'` : '';
} }
function buildSpawnCommand(options: { export function buildSpawnCommand(options: {
mode: SessionMode; mode: SessionMode;
sessionId: string; sessionId: string;
model?: string; model?: string;
@@ -858,15 +860,15 @@ export function dockerTmuxSessionName(sessionId: string): string {
const RESUME_ID_SAFE = /^[A-Za-z0-9._-]+$/; const RESUME_ID_SAFE = /^[A-Za-z0-9._-]+$/;
/** /**
* Append the CLI-specific resume flag to a pane command. Only fires when the * Append the CLI-specific resume flag to a pane command (codex/gemini). Only fires
* in-container tmux is RE-CREATED (`new-session -A` makes the flag inert on a * when the in-container tmux is RE-CREATED (`new-session -A` makes the flag inert
* live reattach), i.e. exactly when the previous live agent was lost and we want * on a live reattach), i.e. exactly when the previous live agent was lost and we
* to resume the conversation from the bind-mounted transcript. * want to resume the conversation from the bind-mounted transcript. Claude mode
* uses claudeDockerPaneCommand instead.
*/ */
function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: string): string { function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: string): string {
if (!RESUME_ID_SAFE.test(resumeId)) return modeCommand; if (!RESUME_ID_SAFE.test(resumeId)) return modeCommand;
switch (mode) { switch (mode) {
case 'claude':
case 'gemini': case 'gemini':
return `${modeCommand} --resume ${resumeId}`; return `${modeCommand} --resume ${resumeId}`;
case 'codex': case 'codex':
@@ -876,6 +878,30 @@ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: stri
} }
} }
/**
* Claude-mode pane command with a DETERMINISTIC conversation id (the docker analog
* of buildSpawnCommand's --resume/--session-id logic). A fresh launch passes
* `--session-id <sessionId>`, so the in-container conversation id is knowable
* host-side (resume-id capture + subagent/workflow correlation) WITHOUT relying on
* hook reachability. When the in-container tmux was re-created after a container
* stop/reboot, the same command re-runs against the surviving transcript:
* `--session-id` exits 1 ("already in use") and the `||` fallback RESUMES that
* conversation (verified CLI behavior). An explicit resumeId gets the local
* builder's shape — resume first, session-id fallback — so a stale id never
* dead-panes. The leading `exec ` is stripped: an exec'd first branch could never
* fall back.
*/
function claudeDockerPaneCommand(modeCommand: string, sessionId: string, resumeId?: string): string {
if (!RESUME_ID_SAFE.test(sessionId)) return modeCommand; // defensive — ids are server-minted uuids
const cmd = modeCommand.replace(/^exec\s+/, '');
const rid = resumeId && RESUME_ID_SAFE.test(resumeId) ? resumeId : undefined;
if (rid && rid !== sessionId) {
return `${cmd} --resume ${rid} || ${cmd} --session-id ${sessionId}`;
}
const cid = rid ?? sessionId;
return `${cmd} --session-id ${cid} || ${cmd} --resume ${cid}`;
}
/** Fully-resolved inputs for buildDockerLaunchCommand (pure). */ /** Fully-resolved inputs for buildDockerLaunchCommand (pure). */
export interface DockerLaunchOptions { export interface DockerLaunchOptions {
mode: SessionMode; mode: SessionMode;
@@ -915,7 +941,11 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
const sid = sessionId.slice(0, 8); const sid = sessionId.slice(0, 8);
let modeCommand = docker.commands?.[mode as DockerCommandMode] || defaultDockerCommandForMode(mode); let modeCommand = docker.commands?.[mode as DockerCommandMode] || defaultDockerCommandForMode(mode);
if (resumeSessionId) modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId); if (mode === 'claude') {
modeCommand = claudeDockerPaneCommand(modeCommand, sessionId, resumeSessionId);
} else if (resumeSessionId) {
modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId);
}
// Run by tmux via /bin/sh -c, so the path is shell-quoted here. `exec` makes the // Run by tmux via /bin/sh -c, so the path is shell-quoted here. `exec` makes the
// pane PID the agent itself. // pane PID the agent itself.
const paneCommand = `cd ${workdir} && ${modeCommand}`; const paneCommand = `cd ${workdir} && ${modeCommand}`;
+4 -2
View File
@@ -9,7 +9,7 @@
* - SessionOutput — captured stdout/stderr/exitCode * - SessionOutput — captured stdout/stderr/exitCode
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error' * - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' (which CLI backend) * - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' (which CLI backend)
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'normal' | 'allowedTools') * - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
* - SessionColor — visual differentiation color * - SessionColor — visual differentiation color
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession) * - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
* - CodexConfig — Codex (OpenAI CLI)-specific settings (model, resumeSessionId) * - CodexConfig — Codex (OpenAI CLI)-specific settings (model, resumeSessionId)
@@ -35,10 +35,12 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
/** /**
* Claude CLI startup permission mode. * Claude CLI startup permission mode.
* - `'dangerously-skip-permissions'`: Bypass all permission prompts (default) * - `'dangerously-skip-permissions'`: Bypass all permission prompts (default)
* - `'auto'`: `--permission-mode auto`: no routine prompts, background safety
* classifier blocks destructive actions (Claude Code 2.1.207+, recent models)
* - `'normal'`: Standard mode with permission prompts * - `'normal'`: Standard mode with permission prompts
* - `'allowedTools'`: Only allow specific tools (requires allowedTools list) * - `'allowedTools'`: Only allow specific tools (requires allowedTools list)
*/ */
export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedTools'; export type ClaudeMode = 'dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools';
/** Session mode: which CLI backend a session runs */ /** Session mode: which CLI backend a session runs */
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini'; export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini';
+20
View File
@@ -1452,6 +1452,26 @@ class CodemanApp {
console.error('[SSE] docker export failed:', err); console.error('[SSE] docker export failed:', err);
} }
}); });
// Import + drift-recreate completions: refresh case lists in EVERY open tab
// (the initiating tab already refreshes via its own fetch response).
addListener(SSE_EVENTS.DOCKER_IMPORT_COMPLETE, (e) => {
try {
const d = e.data ? JSON.parse(e.data) : {};
this.showToast(`Docker bundle imported as case "${d.name}"`, 'success');
this.loadQuickStartCases?.();
this.refreshDockerExports?.();
} catch (err) {
console.error('[SSE] docker import complete:', err);
}
});
addListener(SSE_EVENTS.DOCKER_CONTAINER_RECREATED, (e) => {
try {
const d = e.data ? JSON.parse(e.data) : {};
this.showToast(`Container for "${d.name}" removed — next launch recreates it with the new config`, 'info');
} catch (err) {
console.error('[SSE] docker container recreated:', err);
}
});
// Base image auto-build on first Docker case (build-on-first-use). A single // Base image auto-build on first Docker case (build-on-first-use). A single
// multi-minute event; surface start/finish so the Run spinner is explained. // multi-minute event; surface start/finish so the Run spinner is explained.
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_STARTED, () => { addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_STARTED, () => {
+1
View File
@@ -481,6 +481,7 @@ const SSE_EVENTS = {
DOCKER_IMAGE_BUILD_PROGRESS: 'docker:imageBuildProgress', DOCKER_IMAGE_BUILD_PROGRESS: 'docker:imageBuildProgress',
DOCKER_IMAGE_BUILD_COMPLETE: 'docker:imageBuildComplete', DOCKER_IMAGE_BUILD_COMPLETE: 'docker:imageBuildComplete',
DOCKER_IMAGE_BUILD_FAILED: 'docker:imageBuildFailed', DOCKER_IMAGE_BUILD_FAILED: 'docker:imageBuildFailed',
DOCKER_CONTAINER_RECREATED: 'docker:containerRecreated',
}; };
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
+3 -2
View File
@@ -1449,10 +1449,11 @@
<label>Startup Mode</label> <label>Startup Mode</label>
<select id="appSettingsClaudeMode" class="form-select"> <select id="appSettingsClaudeMode" class="form-select">
<option value="dangerously-skip-permissions">Skip Permissions (default)</option> <option value="dangerously-skip-permissions">Skip Permissions (default)</option>
<option value="auto">Auto Mode (safety classifier, recommended by Anthropic)</option>
<option value="normal">Normal (with prompts)</option> <option value="normal">Normal (with prompts)</option>
<option value="allowedTools">Allowed Tools Only</option> <option value="allowedTools">Allowed Tools Only</option>
</select> </select>
<span class="form-hint">How Claude CLI is started in screen sessions</span> <span class="form-hint">How Claude CLI is started in screen sessions. Auto Mode runs without routine prompts behind a background safety classifier (needs Claude Code 2.1.207+ and Opus 4.6+/Sonnet 4.6+/Fable 5)</span>
</div> </div>
<div class="form-row" id="allowedToolsRow" style="display: none;"> <div class="form-row" id="allowedToolsRow" style="display: none;">
<label>Allowed Tools</label> <label>Allowed Tools</label>
@@ -1874,7 +1875,7 @@
</div> </div>
<div class="form-row docker-quick-row"> <div class="form-row docker-quick-row">
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label> <label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label>
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use.</span> <span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.</span>
</div> </div>
<details class="advanced-options docker-quick-settings" id="dockerQuickSettings"> <details class="advanced-options docker-quick-settings" id="dockerQuickSettings">
<summary>Container settings (optional, sensible defaults)</summary> <summary>Container settings (optional, sensible defaults)</summary>
+7 -5
View File
@@ -459,16 +459,18 @@ html.mobile-init .file-browser-panel {
height: 12px; height: 12px;
} }
/* Hide header settings gear, lifecycle log, away digest, and session manager on /* Hide header settings gear, lifecycle log, away digest, session manager, and
mobile - settings moved to toolbar; away digest and the session manager are file viewer on mobile - settings moved to toolbar; the others are secondary /
secondary controls that don't belong on the cramped phone header (the session desktop-oriented controls that don't belong on the cramped phone header (the
manager stays reachable via the Ctrl+K palette's "Browse all sessions" item). session manager stays reachable via the Ctrl+K palette's "Browse all sessions"
item; the file viewer button is opt-in but its panel is desktop-sized).
(The attachments button is opt-in / default-hidden everywhere via its own (The attachments button is opt-in / default-hidden everywhere via its own
--hidden marker, so it needs no mobile-specific rule here.) */ --hidden marker, so it needs no mobile-specific rule here.) */
.btn-icon-header.btn-settings, .btn-icon-header.btn-settings,
.btn-icon-header.btn-lifecycle-log, .btn-icon-header.btn-lifecycle-log,
.btn-icon-header.btn-away-digest, .btn-icon-header.btn-away-digest,
.btn-icon-header.btn-session-manager { .btn-icon-header.btn-session-manager,
.btn-icon-header.btn-file-viewer {
display: none !important; display: none !important;
} }
+43 -6
View File
@@ -551,14 +551,50 @@ Object.assign(CodemanApp.prototype, {
// Name remote/docker tabs with the same w<n>-<case> convention as local // Name remote/docker tabs with the same w<n>-<case> convention as local
// sessions (quick-start would otherwise auto-generate codeman-<id>). // sessions (quick-start would otherwise auto-generate codeman-<id>).
const startNumber = this._nextCaseSessionStartNumber(caseName); const startNumber = this._nextCaseSessionStartNumber(caseName);
// Docker (NOT remote): the App Settings Claude Model choice applies — the
// workspace is a real host dir, so quick-start writes it to the case's
// .claude/settings.local.json and the in-container claude reads it.
// Remote quick-starts REJECT modelOverride (the file would land on the
// wrong machine), so never send it there.
let dockerModelOverride;
if (caseData.location === 'docker') {
const dockerGlobalSettings = this.loadAppSettingsFromStorage();
const dockerCaseSettings = this.getCaseSettings(caseName);
const dockerUseOpus1m = dockerCaseSettings.opusContext1m || dockerGlobalSettings.opusContext1mEnabled;
dockerModelOverride = dockerGlobalSettings.claudeModel || (dockerUseOpus1m ? 'opus[1m]' : '');
}
const remoteIds = []; const remoteIds = [];
let driftHandled = false;
for (let i = 0; i < tabCount; i++) { for (let i = 0; i < tabCount; i++) {
const res = await fetch('/api/quick-start', { const quickStartBody = JSON.stringify({
method: 'POST', caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}`,
headers: { 'Content-Type': 'application/json' }, ...(dockerModelOverride !== undefined ? { modelOverride: dockerModelOverride } : {})
body: JSON.stringify({ caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}` })
}); });
const data = await res.json(); const doQuickStart = async () => {
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: quickStartBody
});
return res.json();
};
let data = await doQuickStart();
// Docker config drift: the host config changed since the container was
// created (CONFLICT from quick-start). Confirm once, recreate, retry.
if (!data.success && data.errorCode === 'CONFLICT' && caseData.location === 'docker' && !driftHandled) {
driftHandled = true;
const recreate = confirm(
`Container config for "${caseName}" changed since its container was created.\n\n` +
'Recreate the container to apply the new config? Workspace files and the ' +
'conversation survive (the conversation resumes on launch).'
);
if (recreate) {
const recRes = await fetch(`/api/docker-cases/${encodeURIComponent(caseName)}/recreate`, { method: 'POST' });
const recData = await recRes.json();
if (!recData.success) throw new Error(recData.error || 'Failed to recreate container');
data = await doQuickStart();
}
}
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session'); if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session');
remoteIds.push(data.data.sessionId); remoteIds.push(data.data.sessionId);
} }
@@ -1958,7 +1994,8 @@ Object.assign(CodemanApp.prototype, {
listEl.innerHTML = exports listEl.innerHTML = exports
.map(e => { .map(e => {
const mb = (e.sizeBytes / 1e6).toFixed(1); const mb = (e.sizeBytes / 1e6).toFixed(1);
const nm = this.escapeHtml ? this.escapeHtml(e.name) : e.name; // escapeHtml is the free function from constants.js (never a method on `this`)
const nm = escapeHtml(e.name);
return `<div class="case-manage-item" style="display:flex; align-items:center; gap:8px; justify-content:space-between;"> return `<div class="case-manage-item" style="display:flex; align-items:center; gap:8px; justify-content:space-between;">
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${nm}">${nm} <span class="form-hint">(${mb} MB)</span></span> <span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${nm}">${nm} <span class="form-hint">(${mb} MB)</span></span>
<span style="flex-shrink:0;"> <span style="flex-shrink:0;">
+73 -21
View File
@@ -30,7 +30,7 @@ import { generateClaudeMd } from '../../templates/claude-md.js';
import { writeHooksConfig } from '../../hooks-config.js'; import { writeHooksConfig } from '../../hooks-config.js';
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js'; import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
import { SseEvent } from '../sse-events.js'; import { SseEvent } from '../sse-events.js';
import type { EventPort, ConfigPort } from '../ports/index.js'; import type { EventPort, ConfigPort, SessionPort } from '../ports/index.js';
import { dataPath, getDataDir } from '../../config/instance.js'; import { dataPath, getDataDir } from '../../config/instance.js';
import { import {
checkDockerAvailable, checkDockerAvailable,
@@ -42,6 +42,7 @@ import {
dockerDisplayPath, dockerDisplayPath,
readDockerCases, readDockerCases,
readDockerHosts, readDockerHosts,
removeDockerContainer,
toSessionDocker, toSessionDocker,
writeDockerCases, writeDockerCases,
writeDockerHosts, writeDockerHosts,
@@ -99,7 +100,7 @@ async function ensureCaseImage(
sessionDocker: SessionDocker, sessionDocker: SessionDocker,
name: string name: string
): Promise<{ ok: true; imageBuilding: boolean } | { ok: false; error: string }> { ): Promise<{ ok: true; imageBuilding: boolean } | { ok: false; error: string }> {
if (await checkDockerImagePresent(sessionDocker.engine, sessionDocker.image)) { if (await checkDockerImagePresent(sessionDocker, sessionDocker.image)) {
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker); const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
if (!tmuxCheck.ok) return { ok: false, error: tmuxCheck.error || 'base image is missing tmux' }; if (!tmuxCheck.ok) return { ok: false, error: tmuxCheck.error || 'base image is missing tmux' };
return { ok: true, imageBuilding: false }; return { ok: true, imageBuilding: false };
@@ -111,7 +112,7 @@ async function ensureCaseImage(
}; };
} }
broadcast(SseEvent.DockerImageBuildStarted, { name, image: sessionDocker.image }); broadcast(SseEvent.DockerImageBuildStarted, { name, image: sessionDocker.image });
void ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, { void ensureAgentBaseImage(sessionDocker, sessionDocker.image, {
onProgress: (line) => broadcast(SseEvent.DockerImageBuildProgress, { name, line }), onProgress: (line) => broadcast(SseEvent.DockerImageBuildProgress, { name, line }),
}) })
.then((r) => .then((r) =>
@@ -127,7 +128,7 @@ async function ensureCaseImage(
return { ok: true, imageBuilding: true }; return { ok: true, imageBuilding: true };
} }
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void { export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort & SessionPort): void {
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// Case CRUD (list, create, link, detail, fix-plan) // Case CRUD (list, create, link, detail, fix-plan)
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
@@ -652,29 +653,39 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const timestamp = Date.now(); const timestamp = Date.now();
let result; let result;
try { try {
result = await importDockerBundle({ bundlePath, destWorkspace: destWorkspacePath, engine: 'docker', timestamp }); result = await importDockerBundle({
bundlePath,
destWorkspace: destWorkspacePath,
engine: 'docker',
timestamp,
newCaseName,
});
} catch (err) { } catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Import failed: ${getErrorMessage(err)}`); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Import failed: ${getErrorMessage(err)}`);
} }
// Create a dedicated docker host pointing at the quarantined imported image // Create (or REFRESH) the dedicated docker host pointing at the quarantined
// (full mode) or the manifest's base image (workspace-only). // imported image (full mode) or the manifest's base image (workspace-only).
// Refresh matters: after a case-delete + re-import of the same name, a stale
// `imported-<name>` host would silently pin the PREVIOUS import's image tag.
const hostId = `imported-${newCaseName}`; const hostId = `imported-${newCaseName}`;
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR); const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
if (!hosts.some((h) => h.id === hostId)) { const importedHost = {
await writeDockerHosts(CODEMAN_CONFIG_DIR, [ id: hostId,
...hosts, label: `Imported: ${newCaseName}`,
{ engine: result.manifest.engine,
id: hostId, image: result.importedImage ?? result.manifest.image,
label: `Imported: ${newCaseName}`, network: (['bridge', 'none', 'custom'].includes(result.manifest.network) ? result.manifest.network : 'bridge') as
engine: result.manifest.engine, | 'bridge'
image: result.importedImage ?? result.manifest.image, | 'none'
network: (['bridge', 'none', 'custom'].includes(result.manifest.network) | 'custom',
? result.manifest.network };
: 'bridge') as 'bridge' | 'none' | 'custom', await writeDockerHosts(
}, CODEMAN_CONFIG_DIR,
]); hosts.some((h) => h.id === hostId)
} ? hosts.map((h) => (h.id === hostId ? { ...h, ...importedHost } : h))
: [...hosts, importedHost]
);
const newCase = { const newCase = {
name: newCaseName, name: newCaseName,
type: 'docker' as const, type: 'docker' as const,
@@ -687,6 +698,47 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
return { success: true, data: { case: newCase } }; return { success: true, data: { case: newCase } };
}); });
// Recreate-on-drift confirm (docs/docker-cases-plan.md §4): remove the case
// container so the next launch recreates it with the CURRENT host config. The
// workspace + transcripts ride bind mounts and survive; the conversation resumes
// via the case's lastClaudeSessionId. Refused while sessions of the case are live
// (removal would yank the container out from under their panes).
app.post(
'/api/docker-cases/:name/recreate',
async (req): Promise<ApiResponse<{ name: string; container: string }>> => {
const { name } = req.params as { name: string };
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name);
if (!dockerCase) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker case not found');
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
const sessionDocker = toSessionDocker(host, dockerCase);
for (const session of ctx.sessions.values()) {
if (session.docker?.containerName === sessionDocker.containerName && session.pid) {
return createErrorResponse(
ApiErrorCode.CONFLICT,
`Sessions of case "${name}" are still running — stop them first, then recreate the container.`
);
}
}
try {
await removeDockerContainer(sessionDocker);
} catch (err) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
`Failed to remove container: ${getErrorMessage(err)}`
);
}
// Drop the per-container claude-config seed; it is regenerated at next launch.
await fs
.rm(join(dataPath('docker-seeds'), `${sessionDocker.containerName}.json`), { force: true })
.catch(() => {});
ctx.broadcast(SseEvent.DockerContainerRecreated, { name, container: sessionDocker.containerName });
return { success: true, data: { name, container: sessionDocker.containerName } };
}
);
// Link an existing folder as a case // Link an existing folder as a case
app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => { app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body'); const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
+13
View File
@@ -8,6 +8,8 @@ import { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse } from '../../types.js'; import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { HookEventSchema, isValidWorkingDir } from '../schemas.js'; import { HookEventSchema, isValidWorkingDir } from '../schemas.js';
import { sanitizeHookData, parseBody } from '../route-helpers.js'; import { sanitizeHookData, parseBody } from '../route-helpers.js';
import { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js';
import { getDataDir } from '../../config/instance.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js'; import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
export function registerHookEventRoutes( export function registerHookEventRoutes(
@@ -48,7 +50,18 @@ export function registerHookEventRoutes(
// the user ran `/clear` (which spins up a new conversation jsonl). // the user ran `/clear` (which spins up a new conversation jsonl).
if (data && typeof data.session_id === 'string' && data.session_id) { if (data && typeof data.session_id === 'string' && data.session_id) {
const session = ctx.sessions.get(sessionId); const session = ctx.sessions.get(sessionId);
const prevClaudeSessionId = session?.claudeSessionId;
session?.adoptClaudeSessionId(data.session_id); session?.adoptClaudeSessionId(data.session_id);
// Docker sessions: keep the case's resume seed following the LIVE
// conversation (post-/clear id switches), so a container stop/reboot
// relaunch resumes the right transcript.
if (session?.docker && session.claudeSessionId && session.claudeSessionId !== prevClaudeSessionId) {
void persistDockerCaseClaudeSessionId(
getDataDir(),
session.docker.containerName,
session.claudeSessionId
).catch(() => {});
}
} }
// Sanitize forwarded data: only include known safe fields, limit size // Sanitize forwarded data: only include known safe fields, limit size
+45 -2
View File
@@ -76,9 +76,11 @@ import { dataPath, getDataDir } from '../../config/instance.js';
import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.js'; import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.js';
import { import {
checkDockerAvailable, checkDockerAvailable,
checkDockerConfigDrift,
checkDockerTmuxAvailable, checkDockerTmuxAvailable,
ensureAgentBaseImage, ensureAgentBaseImage,
DEFAULT_AGENT_IMAGE, DEFAULT_AGENT_IMAGE,
persistDockerCaseClaudeSessionId,
readDockerCases, readDockerCases,
readDockerHosts, readDockerHosts,
toSessionDocker, toSessionDocker,
@@ -936,6 +938,12 @@ export function registerSessionRoutes(
const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir); const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir);
if (activeId && activeId !== session.claudeSessionId) { if (activeId && activeId !== session.claudeSessionId) {
session.adoptClaudeSessionId(activeId); session.adoptClaudeSessionId(activeId);
// Docker sessions: keep the case's resume seed following the live conversation.
if (session.docker) {
void persistDockerCaseClaudeSessionId(CODEMAN_CONFIG_DIR, session.docker.containerName, activeId).catch(
() => {}
);
}
} }
// The Claude conversation ID (used as JSONL filename) // The Claude conversation ID (used as JSONL filename)
@@ -1683,6 +1691,7 @@ export function registerSessionRoutes(
caseName = 'testcase', caseName = 'testcase',
sessionName, sessionName,
mode = 'claude', mode = 'claude',
modelOverride,
openCodeConfig, openCodeConfig,
codexConfig, codexConfig,
geminiConfig, geminiConfig,
@@ -1713,13 +1722,14 @@ export function registerSessionRoutes(
if ( if (
(envOverrides && Object.keys(envOverrides).length > 0) || (envOverrides && Object.keys(envOverrides).length > 0) ||
effort || effort ||
modelOverride !== undefined ||
codexConfig || codexConfig ||
geminiConfig || geminiConfig ||
openCodeConfig openCodeConfig
) { ) {
return createErrorResponse( return createErrorResponse(
ApiErrorCode.INVALID_INPUT, ApiErrorCode.INVALID_INPUT,
'envOverrides, effort, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.' 'envOverrides, effort, modelOverride, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
); );
} }
@@ -1764,7 +1774,7 @@ export function registerSessionRoutes(
// Ensure the base image exists, auto-building the default image on first use so // Ensure the base image exists, auto-building the default image on first use so
// it is never a blocker. Dedup'd with any build kicked off at case-create, so // it is never a blocker. Dedup'd with any build kicked off at case-create, so
// this awaits the SAME in-flight build rather than starting a second one. // this awaits the SAME in-flight build rather than starting a second one.
const ensured = await ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, { const ensured = await ensureAgentBaseImage(sessionDocker, sessionDocker.image, {
onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }), onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }),
}); });
if (!ensured.ok) { if (!ensured.ok) {
@@ -1783,6 +1793,19 @@ export function registerSessionRoutes(
} }
} }
// Config drift (docs/docker-cases-plan.md §4): the desired create-config no
// longer matches the existing container's codeman.confighash label. Refuse to
// silently launch into the stale container — the frontend confirms a recreate
// (POST /api/docker-cases/:name/recreate; workspace + transcripts ride bind
// mounts and the conversation resumes), or the user reverts the host edit.
const drift = await checkDockerConfigDrift(sessionDocker);
if (drift.exists && drift.drifted) {
return createErrorResponse(
ApiErrorCode.CONFLICT,
`Container config for case "${dockerCase.name}" changed since the container was created. Recreate the container to apply it (workspace and conversation survive), or revert the docker host edit.`
);
}
casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container) casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container)
docker = sessionDocker; docker = sessionDocker;
// Seed resume so a relaunch resumes the case's last conversation from the // Seed resume so a relaunch resumes the case's last conversation from the
@@ -1894,6 +1917,13 @@ export function registerSessionRoutes(
} }
} }
// Model override → <case>/.claude/settings.local.json (claude-mode; local AND
// docker — the docker workspace is a real host dir, so the settings file crosses
// the bind mount and the in-container claude reads it). Remote was rejected above.
if (mode === 'claude' && modelOverride !== undefined) {
await updateCaseModel(resolvedCasePath, modelOverride || null);
}
// Strip stale disk entries for keys this request is actively setting (Claude only — // Strip stale disk entries for keys this request is actively setting (Claude only —
// see POST /api/sessions for full rationale). // see POST /api/sessions for full rationale).
if ( if (
@@ -1990,6 +2020,19 @@ export function registerSessionRoutes(
} }
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) }); ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
// Docker + claude: the pane command pins the conversation id (--session-id /
// --resume, claudeDockerPaneCommand), so persist it as the case's resume seed
// NOW — a later container stop/reboot relaunch resumes this conversation even
// if no in-container hook ever reaches the host (loopback bind, no bridge
// listener). Hook/last-response adoption updates it again after /clear.
if (docker && mode === 'claude') {
void persistDockerCaseClaudeSessionId(
CODEMAN_CONFIG_DIR,
docker.containerName,
session.claudeSessionId || session.id
).catch(() => {});
}
// Save lastUsedCase to settings for TUI/web sync // Save lastUsedCase to settings for TUI/web sync
try { try {
const settingsFilePath = SETTINGS_PATH; const settingsFilePath = SETTINGS_PATH;
+11
View File
@@ -449,17 +449,22 @@ export const DockerHostSchema = z.object({
export const DockerCaseLinkSchema = z.object({ export const DockerCaseLinkSchema = z.object({
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'), name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'), hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'),
// No commas: the path is embedded in a `--mount type=bind,src=<path>,dst=<path>`
// CSV spec, and docker's --mount parser splits fields on commas (shell escaping
// cannot protect it). Spaces are fine.
hostWorkspacePath: z hostWorkspacePath: z
.string() .string()
.min(1) .min(1)
.max(2000) .max(2000)
.regex(/^\//, 'Workspace path must be absolute') .regex(/^\//, 'Workspace path must be absolute')
.regex(/^[^,]*$/, 'Workspace path must not contain commas (docker --mount is comma-delimited)')
.regex(NO_SHELL_META, 'Invalid characters in workspace path'), .regex(NO_SHELL_META, 'Invalid characters in workspace path'),
containerWorkdir: z containerWorkdir: z
.string() .string()
.min(1) .min(1)
.max(2000) .max(2000)
.regex(/^\//, 'Container workdir must be absolute') .regex(/^\//, 'Container workdir must be absolute')
.regex(/^[^,]*$/, 'Container workdir must not contain commas (docker --mount is comma-delimited)')
.regex(NO_SHELL_META, 'Invalid characters in container workdir') .regex(NO_SHELL_META, 'Invalid characters in container workdir')
.optional(), .optional(),
container: z container: z
@@ -487,6 +492,7 @@ export const DockerImportSchema = z.object({
.min(1) .min(1)
.max(2000) .max(2000)
.regex(/^\//, 'Destination path must be absolute') .regex(/^\//, 'Destination path must be absolute')
.regex(/^[^,]*$/, 'Destination path must not contain commas (docker --mount is comma-delimited)')
.regex(NO_SHELL_META, 'Invalid characters in destination path'), .regex(NO_SHELL_META, 'Invalid characters in destination path'),
}); });
@@ -539,6 +545,11 @@ export const QuickStartSchema = z.object({
/** Display name for the created session tab (e.g. w1-mycase). Cosmetic; the durable /** Display name for the created session tab (e.g. w1-mycase). Cosmetic; the durable
* mux/container names derive from the session id, not this. Defaults server-side. */ * mux/container names derive from the session id, not this. Defaults server-side. */
sessionName: z.string().max(128).optional(), sessionName: z.string().max(128).optional(),
/** Model override written to <case>/.claude/settings.local.json (e.g. "opus[1m]").
* Empty string clears. Applied for local AND docker cases (the docker workspace is
* a real host dir, so the settings file crosses the bind mount); rejected for
* remote cases (the file would be written on the WRONG machine). */
modelOverride: z.string().max(50).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(), mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
openCodeConfig: OpenCodeConfigSchema, openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema, codexConfig: CodexConfigSchema,
+6 -1
View File
@@ -1514,7 +1514,12 @@ export class WebServer extends EventEmitter {
const claudeMode = settings.claudeMode as string | undefined; const claudeMode = settings.claudeMode as string | undefined;
const allowedTools = settings.allowedTools as string | undefined; const allowedTools = settings.allowedTools as string | undefined;
// Only return valid modes // Only return valid modes
if (claudeMode === 'dangerously-skip-permissions' || claudeMode === 'normal' || claudeMode === 'allowedTools') { if (
claudeMode === 'dangerously-skip-permissions' ||
claudeMode === 'auto' ||
claudeMode === 'normal' ||
claudeMode === 'allowedTools'
) {
return { claudeMode, allowedTools }; return { claudeMode, allowedTools };
} }
return {}; return {};
+3
View File
@@ -385,6 +385,8 @@ export const DockerImageBuildProgress = 'docker:imageBuildProgress' as const;
export const DockerImageBuildComplete = 'docker:imageBuildComplete' as const; export const DockerImageBuildComplete = 'docker:imageBuildComplete' as const;
/** The agent base image build failed. */ /** The agent base image build failed. */
export const DockerImageBuildFailed = 'docker:imageBuildFailed' as const; export const DockerImageBuildFailed = 'docker:imageBuildFailed' as const;
/** A case container was removed after a config-drift confirm (recreated with the new config on next launch). */
export const DockerContainerRecreated = 'docker:containerRecreated' as const;
// ─── Namespace Re-export ───────────────────────────────────────────────────── // ─── Namespace Re-export ─────────────────────────────────────────────────────
@@ -576,4 +578,5 @@ export const SseEvent = {
DockerImageBuildProgress, DockerImageBuildProgress,
DockerImageBuildComplete, DockerImageBuildComplete,
DockerImageBuildFailed, DockerImageBuildFailed,
DockerContainerRecreated,
} as const; } as const;
+83
View File
@@ -0,0 +1,83 @@
/**
* @fileoverview Tests for Claude CLI startup permission modes, focused on the
* 'auto' mode (`--permission-mode auto`, Anthropic's recommended low-prompt mode)
* added alongside the default `--dangerously-skip-permissions`.
*
* Covers BOTH spawn paths, which build the permission flags independently:
* - session-cli-builder.buildInteractiveArgs (direct PTY, non-mux fallback)
* - tmux-manager.buildSpawnCommand (tmux pane command string)
* The default must stay 'dangerously-skip-permissions' when the setting is unset.
*/
import { describe, it, expect } from 'vitest';
import { buildInteractiveArgs } from '../src/session-cli-builder.js';
import { buildSpawnCommand } from '../src/tmux-manager.js';
describe('buildInteractiveArgs permission modes (direct PTY path)', () => {
it('keeps --dangerously-skip-permissions as the skip-mode flag', () => {
const args = buildInteractiveArgs('sid-1', 'dangerously-skip-permissions');
expect(args).toContain('--dangerously-skip-permissions');
expect(args).not.toContain('--permission-mode');
});
it('auto mode emits --permission-mode auto and never the skip flag', () => {
const args = buildInteractiveArgs('sid-1', 'auto');
const idx = args.indexOf('--permission-mode');
expect(idx).toBeGreaterThanOrEqual(0);
expect(args[idx + 1]).toBe('auto');
expect(args).not.toContain('--dangerously-skip-permissions');
});
it('normal mode emits no permission flag at all', () => {
const args = buildInteractiveArgs('sid-1', 'normal');
expect(args).not.toContain('--dangerously-skip-permissions');
expect(args).not.toContain('--permission-mode');
});
it('allowedTools mode is unchanged by the auto addition', () => {
const args = buildInteractiveArgs('sid-1', 'allowedTools', undefined, 'Read,Grep');
expect(args).toEqual(expect.arrayContaining(['--allowedTools', 'Read,Grep']));
expect(args).not.toContain('--permission-mode');
});
it('auto mode composes with model and effort flags', () => {
const args = buildInteractiveArgs('sid-1', 'auto', 'opus', undefined, 'high');
expect(args).toEqual(expect.arrayContaining(['--permission-mode', 'auto', '--model', 'opus', '--effort', 'high']));
});
});
describe('buildSpawnCommand permission modes (tmux path)', () => {
it('unset claudeMode defaults to --dangerously-skip-permissions', () => {
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1' });
expect(cmd).toContain('claude --dangerously-skip-permissions --session-id "sid-1"');
expect(cmd).not.toContain('--permission-mode');
});
it('auto mode emits --permission-mode auto and never the skip flag', () => {
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', claudeMode: 'auto' });
expect(cmd).toContain('claude --permission-mode auto --session-id "sid-1"');
expect(cmd).not.toContain('--dangerously-skip-permissions');
});
it('auto mode carries into BOTH legs of the resume fallback command', () => {
const cmd = buildSpawnCommand({
mode: 'claude',
sessionId: 'sid-1',
claudeMode: 'auto',
resumeSessionId: 'abc-123',
});
const [resumeLeg, fallbackLeg] = cmd.split('||');
expect(resumeLeg).toContain('--permission-mode auto');
expect(resumeLeg).toContain('--resume "abc-123"');
expect(fallbackLeg).toContain('--permission-mode auto');
expect(fallbackLeg).toContain('--session-id "sid-1"');
expect(cmd).not.toContain('--dangerously-skip-permissions');
});
it('normal mode emits no permission flag', () => {
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', claudeMode: 'normal' });
expect(cmd).toContain('claude --session-id "sid-1"');
expect(cmd).not.toContain('--permission-mode');
expect(cmd).not.toContain('--dangerously-skip-permissions');
});
});
+26 -7
View File
@@ -88,11 +88,25 @@ describe('buildDockerLaunchCommand', () => {
expect(cmd).toContain("sh -lc '"); 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' })); const withResume = buildDockerLaunchCommand(launchOpts({ resumeSessionId: 'abc-123-def' }));
expect(withResume).toContain('exec claude --dangerously-skip-permissions --resume abc-123-def'); expect(withResume).toContain(
const without = buildDockerLaunchCommand(launchOpts()); 'claude --dangerously-skip-permissions --resume abc-123-def || ' +
expect(without).not.toContain('--resume'); 'claude --dangerously-skip-permissions --session-id 1a2b3c4d5e6f'
);
}); });
it('uses codex resume syntax and drops an unsafe resume id', () => { 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'); expect(codex).toContain('exec codex resume 01H-codex-id');
const unsafe = buildDockerLaunchCommand(launchOpts({ resumeSessionId: 'x; rm -rf /' })); 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'); 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)', () => { 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'); 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 docker = { ...toSessionDocker(HOST, CASE), commands: { claude: 'exec claude --model opus' } };
const cmd = buildDockerLaunchCommand(launchOpts({ docker })); 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', () => { it('seeds writable config (guarded copies, mkdir -p parent) from the read-only seed mounts', () => {
+39
View File
@@ -12,7 +12,9 @@ import {
isSafeTarMember, isSafeTarMember,
parseLoadedImageRef, parseLoadedImageRef,
exportDockerCase, exportDockerCase,
validateImportManifest,
DOCKER_EXPORT_SCHEMA, DOCKER_EXPORT_SCHEMA,
type DockerExportManifest,
} from '../src/docker-export.js'; } from '../src/docker-export.js';
import { toSessionDocker } from '../src/docker-hosts.js'; import { toSessionDocker } from '../src/docker-hosts.js';
import type { DockerCase, DockerHost } from '../src/types.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', () => { describe('parseLoadedImageRef', () => {
it('parses "Loaded image ID: sha256:..."', () => { it('parses "Loaded image ID: sha256:..."', () => {
expect(parseLoadedImageRef('Loaded image ID: sha256:abc123def')).toBe('sha256:abc123def'); expect(parseLoadedImageRef('Loaded image ID: sha256:abc123def')).toBe('sha256:abc123def');
+14 -2
View File
@@ -25,6 +25,7 @@ import {
defaultDockerCommandForMode, defaultDockerCommandForMode,
ensureAgentBaseImage, ensureAgentBaseImage,
hostGatewayAlias, hostGatewayAlias,
persistDockerCaseClaudeSessionId,
probeDockerCliVersion, probeDockerCliVersion,
readDockerCases, readDockerCases,
readDockerHosts, readDockerHosts,
@@ -67,6 +68,17 @@ describe('docker-hosts storage', () => {
expect(await readDockerHosts(dir)).toEqual([]); expect(await readDockerHosts(dir)).toEqual([]);
expect(await readDockerCases(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', () => { describe('naming / display / defaults', () => {
@@ -427,7 +439,7 @@ describe('agentImageBuildArgs', () => {
describe('ensureAgentBaseImage (no-op under VITEST)', () => { describe('ensureAgentBaseImage (no-op under VITEST)', () => {
it('reports the image as already present without spawning a build', async () => { 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 }); 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 () => { it('checkDockerTmuxAvailable + image present are canned-true', async () => {
expect((await checkDockerTmuxAvailable({ engine: 'docker', image: DEFAULT_AGENT_IMAGE })).ok).toBe(true); 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 () => { 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 // 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 // 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. // 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 { function attrOf(openTag: string, name: string): string {
const m = openTag.match(new RegExp(`${name}="([^"]*)"`)); const m = openTag.match(new RegExp(`${name}="([^"]*)"`));