From 15eebde83213b1b5def51c453b7b027bb684fe80 Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 21:02:19 -0700 Subject: [PATCH 01/26] feat(docker): attach a case to an already-running container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker cases could only run in a container Codeman created itself. Attaching to one the user already built and runs means Codeman must leave that container's lifecycle completely alone, which the launch chain could not do: it was `image inspect` -> `inspect || create` -> `start` -> `exec`. Adds `DockerCase.owned`, mirroring the `owned:false` contract remote-SSH already uses for attached sessions. Absent (every existing case) means owned, so current behaviour is byte-identical. `false` means the container belongs to the user and Codeman may only exec into it. The launch chain for an attached container only looks, then execs: no image gate (the image is theirs), no create, and no `start` — starting a container we do not own is the very mutation attaching promises not to perform. A missing or stopped container fails closed with an actionable message instead. Credential seeding is skipped too: those copies read from create-time read-only mounts that do not exist here, and writing host credentials into someone's container is not ours to do, so its CLIs must already be authenticated inside it. Four fail-closed guards. buildDockerStopCommand and buildDockerRemoveCommand throw during pure string construction, so no caller bug can turn into a `docker stop`/`rm` on a container we do not own; removeDockerContainer refuses again at the lowest layer; drift reports "none" for an attached container, which carries no `codeman.confighash` label and would otherwise always look drifted and 409 the launch gate forever; and the orphan reaper skips attached containers through a check deliberately independent of the two conditions already covering them. `owned` is applied AFTER the config hash is computed. dockerConfigHash takes an explicit field list, so ownership can never shift an existing case's hash — if it did, every pre-existing case would trip the drift gate at once, and the remedy the UI offers is "recreate the container". Adds POST /api/cases/docker-adopt and a read-only POST /api/docker-cases/adopt-preflight. The preflight refuses at LINK time rather than at session launch, where the only ways out would be a dead pane or starting a container we do not own. Tests assert the negative guarantee directly — that create, start, stop, rm, restart and kill are absent from the generated commands while `docker exec -it` and `new-session -A` remain — since it cannot be observed by using the feature. --- src/docker-hosts.ts | 157 +++++++++++++++++++++++- src/tmux-manager.ts | 53 +++++++- src/types/session.ts | 24 ++++ src/web/routes/case-routes.ts | 135 ++++++++++++++++++++- src/web/routes/session-routes.ts | 57 ++++++--- src/web/schemas.ts | 44 +++++++ test/docker-adopted-container.test.ts | 168 ++++++++++++++++++++++++++ 7 files changed, 604 insertions(+), 34 deletions(-) create mode 100644 test/docker-adopted-container.test.ts diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index e2a92486..b49502a5 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -55,6 +55,21 @@ export const DEFAULT_AGENT_IMAGE = 'codeman/agent:base'; /** HOME inside the base image (the `agent` user). Cred mounts + hook-secret land under it. */ export const CONTAINER_HOME = '/home/agent'; +/** + * Modes the adoption preflight probes for inside an existing container. `shell` + * is omitted deliberately: it needs no CLI binary and is always available, so it + * is reported as available without a `command -v` lookup. + */ +export const DOCKER_ADOPT_PROBE_MODES = [ + 'claude', + 'codex', + 'opencode', + 'gemini', + 'antigravity', + 'pi', + 'shell', +] as const satisfies readonly SessionMode[]; + /** Per-case container name prefix. The `case` letters deliberately do NOT matter to * tmux; this is a DOCKER name (`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`), and case names are * already validated `^[a-zA-Z0-9_-]+$`, so `codeman-case-` is always valid. */ @@ -251,7 +266,22 @@ export function toSessionDocker(host: DockerHost, dockerCase: DockerCase): Sessi extraCreateArgs: host.extraCreateArgs, extraExecArgs: host.extraExecArgs, }; - return { ...base, configHash: dockerConfigHash(base) }; + // `owned` is deliberately applied AFTER the hash: dockerConfigHash() picks an + // explicit field list, so ownership can never shift an existing case's hash and + // mass-trip the drift gate. + const session: SessionDocker = { ...base, configHash: dockerConfigHash(base) }; + if (dockerCase.owned === false) session.owned = false; + return session; +} + +/** + * An ADOPTED container is one the user built and runs themselves. Codeman may + * only exec into it; it must never create, start, stop, restart or remove it. + * Every lifecycle branch routes through this one predicate so a new call site + * cannot silently opt out. + */ +export function isAdoptedContainer(docker: Pick): boolean { + return docker.owned === false; } // ========== Shell escaping ========== @@ -713,9 +743,15 @@ export interface DockerDriftStatus { * daemon down) means there is nothing to drift. No-op under VITEST. */ export async function checkDockerConfigDrift( - docker: Pick + docker: Pick ): Promise { if (IS_TEST_MODE) return { exists: false, running: false, drifted: false }; + // An ADOPTED container carries no `codeman.confighash` label — it was never + // created from our config — so every comparison would report drift and the + // launch gate would demand a recreate we are not allowed to perform. Ownership + // of its configuration belongs to the user; report "no drift" and never offer + // to rebuild it. + if (isAdoptedContainer(docker)) return { exists: true, running: false, drifted: false }; const argv = dockerEngineArgv(docker); try { const { stdout } = await execFileAsync( @@ -743,8 +779,15 @@ export async function checkDockerConfigDrift( * case's lastClaudeSessionId. No-op under VITEST. */ export async function removeDockerContainer( - docker: Pick + docker: Pick ): Promise { + // Fail CLOSED at the lowest layer: an adopted container is the user's, and no + // caller — recreate-on-drift, case delete, a future teardown — may remove it. + if (isAdoptedContainer(docker)) { + throw new Error( + `Refusing to remove adopted container "${docker.containerName}": Codeman does not own its lifecycle.` + ); + } if (IS_TEST_MODE) return; const argv = dockerEngineArgv(docker); await execFileAsync(argv[0], [...argv.slice(1), 'rm', '-f', docker.containerName], { timeout: 30_000 }); @@ -990,6 +1033,105 @@ export async function checkDockerTmuxAvailable( } } +/** Preflight facts about an ALREADY-RUNNING container the user wants to adopt. */ +export interface AdoptedContainerProbe { + ok: boolean; + exists: boolean; + running: boolean; + /** The container's own image ref (informational — we never enforce ours on it). */ + image?: string; + /** `command -v tmux` inside the container; required for durable sessions. */ + tmuxPath?: string; + /** Modes whose CLI resolved inside the container (`command -v `). */ + availableModes?: SessionMode[]; + error?: string; +} + +/** + * Preflight an EXISTING container for adoption. Read-only by construction: it + * runs `inspect` plus one `exec` of `command -v`, and never creates, starts or + * modifies anything. Refusing here is what keeps the failure at link time — a + * clear message — instead of at session launch, where the only alternatives + * would be a dead pane or starting a container we do not own. + * + * `--pull=never` is irrelevant here: adoption never touches images. The image + * ref is reported only so the UI can show what the user is attaching to. + */ +export async function probeAdoptableContainer( + docker: Pick, + modes: SessionMode[] = [] +): Promise { + if (IS_TEST_MODE) { + return { ok: true, exists: true, running: true, tmuxPath: '/usr/bin/tmux', availableModes: modes }; + } + const argv = dockerEngineArgv(docker); + let running = false; + let image: string | undefined; + try { + const { stdout } = await execFileAsync( + argv[0], + [...argv.slice(1), 'inspect', '-f', '{{.State.Running}}\t{{.Config.Image}}', docker.containerName], + { timeout: DOCKER_PROBE_TIMEOUT_MS } + ); + const [state = '', img = ''] = stdout.trim().split('\t'); + running = state === 'true'; + image = img || undefined; + } catch { + return { + ok: false, + exists: false, + running: false, + error: `container "${docker.containerName}" not found (adoption never creates a container — start it yourself first)`, + }; + } + if (!running) { + return { + ok: false, + exists: true, + running: false, + image, + error: `container "${docker.containerName}" exists but is not running (Codeman never starts a container it does not own — start it yourself, then retry)`, + }; + } + // One exec resolves tmux plus every requested CLI, so adoption costs a single + // round trip. Binaries are fixed mode names, never user input. + const probes = ['tmux', ...modes.filter((m) => m !== 'shell')]; + const script = probes.map((bin) => `command -v ${bin} >/dev/null 2>&1 && echo ${bin}`).join('; '); + try { + const { stdout } = await execFileAsync( + argv[0], + [...argv.slice(1), 'exec', docker.containerName, 'sh', '-lc', script], + { timeout: DOCKER_PROBE_TIMEOUT_MS } + ); + const found = new Set( + stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + ); + if (!found.has('tmux')) { + return { + ok: false, + exists: true, + running: true, + image, + error: `container "${docker.containerName}" has no tmux (required for durable sessions; install it inside the container)`, + }; + } + return { + ok: true, + exists: true, + running: true, + image, + tmuxPath: 'tmux', + availableModes: modes.filter((m) => m === 'shell' || found.has(m)), + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, exists: true, running: true, image, error: `could not exec into the container: ${msg}` }; + } +} + /** * Resolve the host's IP on the default docker bridge (the address a container * reaches as `host.docker.internal`), so the server can bind a hooks-only listener @@ -1052,9 +1194,18 @@ export async function reapOrphanedDockerContainers( } const cases = await readDockerCases(configDir); const expected = new Set(cases.map((c) => c.container ?? dockerContainerName(c.name))); + // ADOPTED containers are never reapable, and this guard is deliberately + // independent of the two conditions that already cover them (we never applied + // the `codeman.managed=1` label filtered on above, and they are referenced by a + // live case so they are in `expected`). An adopted container is the user's + // property; it must survive even if a future edit narrows either condition. + const adopted = new Set( + cases.filter((item) => item.owned === false).map((item) => item.container ?? dockerContainerName(item.name)) + ); const reaped: string[] = []; for (const { name, inst } of rows) { if (inst !== instance) continue; // only THIS instance's containers + if (adopted.has(name)) continue; // never reap a container we do not own if (expected.has(name)) continue; // still referenced by a live case try { await execFileAsync(bin, ['rm', '-f', name], { timeout: DOCKER_PROBE_TIMEOUT_MS }); diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 5088793d..3e0c96cb 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -1275,7 +1275,15 @@ export interface DockerLaunchOptions { export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string { const { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies } = opts; const base = buildDockerBaseArgs(docker).join(' '); - const createArgs = buildDockerCreateArgs(createContext).join(' '); + // ADOPTED container (docker.owned === false): the user built it and runs it, so + // this chain may only LOOK and then exec. No image check (the image is theirs), + // no create, and above all no `start` — starting a container we do not own is + // exactly the lifecycle mutation adoption promises never to perform. A missing + // or stopped container fails closed with an actionable message instead. + const adopted = docker.owned === false; + // Built lazily: an adopted case has no meaningful create-config, so computing + // create args for it would demand a context the adopt path never assembles. + const createArgs = adopted ? '' : buildDockerCreateArgs(createContext).join(' '); const name = shellescape(docker.containerName); const workdir = shellescape(docker.containerWorkdir); const image = shellescape(docker.image); @@ -1318,16 +1326,33 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string { ); const startFailMsg = shellescape(`Codeman: container ${docker.containerName} failed to start (docker daemon down?)`); - const imageCheck = `${base} image inspect ${image} >/dev/null 2>&1 || { echo ${imageMissingMsg}; exit 1; }`; + const notFoundMsg = shellescape( + `Codeman: container ${docker.containerName} not found. Adopted containers are never created by Codeman — start it yourself, then reopen this session.` + ); + const notRunningMsg = shellescape( + `Codeman: container ${docker.containerName} is not running. Codeman never starts a container it does not own — start it yourself, then reopen this session.` + ); + + const imageCheck = adopted + ? '' + : `${base} image inspect ${image} >/dev/null 2>&1 || { echo ${imageMissingMsg}; exit 1; }`; // create-if-missing (idempotent): reconnect / boot recovery re-runs this exact chain. - const ensure = `${base} inspect ${name} >/dev/null 2>&1 || ${base} ${createArgs}`; - const start = `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`; + const ensure = adopted + ? `${base} inspect ${name} >/dev/null 2>&1 || { echo ${notFoundMsg}; exit 1; }` + : `${base} inspect ${name} >/dev/null 2>&1 || ${base} ${createArgs}`; + const start = adopted + ? `[ "$(${base} inspect -f '{{.State.Running}}' ${name} 2>/dev/null)" = true ] || { echo ${notRunningMsg}; exit 1; }` + : `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`; // Seed writable credential config from read-only host mounts ONCE per container // (guarded by [ -e ] so reconnects never clobber in-container config; `cp -a` for // whole-dir credential seeds). mkdir -p the parent so a file seed works even when // no sibling share-mount pre-created the dir. Paths are fixed CONTAINER_HOME // constants (no shell metachars), so the whole inner command is shell-quoted once. - const seedSteps = (seedCopies ?? []).map((s) => { + // An ADOPTED container gets NO seed copies: those read from create-time + // read-only mounts that do not exist here, and writing host credentials into a + // container the user owns is a mutation adoption does not permit. Its CLIs must + // already be authenticated inside it. + const seedSteps = (adopted ? [] : (seedCopies ?? [])).map((s) => { const cp = s.recursive ? 'cp -a' : 'cp'; const parent = s.to.slice(0, s.to.lastIndexOf('/')); return `mkdir -p ${parent} 2>/dev/null; [ -e ${s.to} ] || ${cp} ${s.from} ${s.to} 2>/dev/null || true`; @@ -1335,7 +1360,7 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string { const innerCmd = seedSteps.length ? `${seedSteps.join(' ; ')} ; ${tmuxInvocation}` : tmuxInvocation; const execCmd = `exec ${base} exec -it --workdir ${workdir} ${execEnvFlags.join(' ')} ${name} sh -lc ${shellescape(innerCmd)}`; - return [imageCheck, ensure, start, execCmd].join(' ; '); + return [imageCheck, ensure, start, execCmd].filter(Boolean).join(' ; '); } /** @@ -1351,13 +1376,29 @@ export function buildDockerKillCommand(options: { docker: SessionDocker; session return `${base} exec ${shellescape(docker.containerName)} tmux -L ${DOCKER_TMUX_SOCKET} kill-session -t ${shellescape(dkrName)}`; } +/** + * Guard for the two builders that mutate CONTAINER lifecycle. They are pure + * string builders, so refusing here means an adopted container cannot even have + * a stop/remove command constructed for it — there is no shape of caller bug + * that turns into a `docker stop`/`rm` on something we do not own. + */ +function assertOwnedContainer(docker: SessionDocker, action: string): void { + if (docker.owned === false) { + throw new Error( + `Refusing to ${action} adopted container "${docker.containerName}": Codeman does not own its lifecycle.` + ); + } +} + /** Explicit container stop (frees RAM/CPU; conversation resumes on next launch via --resume). */ export function buildDockerStopCommand(docker: SessionDocker): string { + assertOwnedContainer(docker, 'stop'); return `${buildDockerBaseArgs(docker).join(' ')} stop -t 10 ${shellescape(docker.containerName)}`; } /** Explicit container removal (case-delete). Destroys in-image state; bind mounts survive. */ export function buildDockerRemoveCommand(docker: SessionDocker): string { + assertOwnedContainer(docker, 'remove'); return `${buildDockerBaseArgs(docker).join(' ')} rm -f ${shellescape(docker.containerName)}`; } diff --git a/src/types/session.ts b/src/types/session.ts index 8e429459..7187dae4 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -243,6 +243,24 @@ export interface DockerCase { containerWorkdir?: string; /** Container name (default codeman-case-). */ container?: string; + /** + * Whether THIS Codeman created the container (mirror of `SessionRemote.owned`). + * + * - `true` (default for cases Codeman linked/quick-created): we own the + * container; drift may recreate it, case-delete may `docker rm -f` it, and + * the launch chain may create + start it. + * - `false` (ADOPTED: an already-running container the user built and runs + * themselves): Codeman must never create, start, stop, restart or remove it. + * The launch chain fails closed when the container is missing or not running + * instead of touching its lifecycle, drift is not evaluated (there is no + * `codeman.confighash` label to compare), and no credential seed is copied + * into its HOME. Only the in-container tmux session is ever created or + * killed — exactly the `owned:false` remote-SSH contract. + * + * Absent is treated as owned (cases persisted before this field existed were + * all created by us). + */ + owned?: boolean; /** Last captured Claude conversation id, replayed via --resume on a fresh launch. */ lastClaudeSessionId?: string; } @@ -275,6 +293,12 @@ export interface SessionDocker { extraExecArgs?: string[]; /** Stable hash of the drift-relevant create args (recreate-on-drift detection). */ configHash?: string; + /** + * Mirror of `DockerCase.owned`, flattened onto the live session so every + * lifecycle decision (launch chain, drift, stop, remove) can see it without + * re-reading docker-cases.json. Absent = owned. See `DockerCase.owned`. + */ + owned?: boolean; } /** diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 978ac941..49c94e2e 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -13,7 +13,7 @@ import fs from 'node:fs/promises'; import { join, resolve, basename } from 'node:path'; import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; -import type { ApiResponse, CaseInfo, DockerHost, RemoteSessionInfo, SessionDocker } from '../../types.js'; +import type { ApiResponse, CaseInfo, DockerHost, RemoteSessionInfo, SessionDocker, SessionMode } from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; import { CreateCaseSchema, @@ -24,6 +24,8 @@ import { RemoteCaseLinkSchema, RemoteHostSchema, DockerCaseLinkSchema, + DockerCaseAdoptSchema, + DockerAdoptPreflightSchema, DockerHostSchema, DockerExportSchema, DockerImportSchema, @@ -66,6 +68,8 @@ import { DEFAULT_AGENT_IMAGE, dockerContainerName, dockerDisplayPath, + probeAdoptableContainer, + DOCKER_ADOPT_PROBE_MODES, readDockerCases, readDockerHosts, removeDockerContainer, @@ -73,6 +77,7 @@ import { writeDockerCases, writeDockerHosts, } from '../../docker-hosts.js'; +import type { AdoptedContainerProbe } from '../../docker-hosts.js'; import { buildDockerRemoveCommand } from '../../tmux-manager.js'; import { checkRemoteTmuxAvailable, @@ -771,6 +776,106 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config } ); + /** + * ADOPT an already-running container (`owned: false`). The mirror of the + * remote-SSH attach path: Codeman execs into a container the user built and + * runs, and never creates, starts, stops, restarts or removes it. + * + * Everything here is read-only toward the container. The preflight refuses at + * LINK time — missing, stopped, or no tmux inside — because the alternative is + * failing at session launch, where the only ways out would be a dead pane or + * starting a container we do not own. There is no image gate and no + * `ensureCaseImage`: adoption never runs `docker create`, so the container's + * image is the user's business. + */ + app.post( + '/api/cases/docker-adopt', + async (req): Promise> => { + const dockerCase = { + ...parseBody(DockerCaseAdoptSchema, req.body), + type: 'docker' as const, + owner: ownerFor(req), + owned: false as const, + }; + 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 linkedCases = await readLinkedCases(); + const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR); + if ( + dockerCases.some((item) => item.name === dockerCase.name) || + linkedCases[dockerCase.name] || + existsSync(join(resolveCasesDir(getAuthUser(req)), dockerCase.name)) + ) { + return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists'); + } + // Two cases must never share one adopted container: session close kills the + // in-container tmux by session id, but a shared adoption would let one case's + // teardown and another's launch race over the same tmux server. + const container = dockerCase.container; + if (dockerCases.some((item) => (item.container ?? dockerContainerName(item.name)) === container)) { + return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `Container "${container}" is already linked to a case`); + } + + if (!isWorkingDirAllowed(getAuthUser(req), dockerCase.hostWorkspacePath)) { + return createErrorResponse(ApiErrorCode.FORBIDDEN, 'hostWorkspacePath is outside your workspace'); + } + // The workspace must ALREADY exist: it mirrors a path inside a container we + // did not create, so silently mkdir-ing it would invent a host directory that + // does not correspond to whatever is actually mounted there. + if (!existsSync(dockerCase.hostWorkspacePath)) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'hostWorkspacePath does not exist. Adoption mirrors an existing container, so point this at the real host directory already mounted into it.' + ); + } + + const availability = await checkDockerAvailable(host.engine); + if (!availability.ok) { + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + availability.error || 'docker daemon is not available' + ); + } + const probe = await probeAdoptableContainer(toSessionDocker(host, dockerCase), [...DOCKER_ADOPT_PROBE_MODES]); + if (!probe.ok) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, probe.error || 'container is not adoptable'); + } + + await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, dockerCase]); + ctx.broadcast(SseEvent.CaseLinked, { + name: dockerCase.name, + path: dockerCase.hostWorkspacePath, + type: 'docker', + }); + return { + success: true, + data: { case: dockerCase, image: probe.image, availableModes: probe.availableModes }, + }; + } + ); + + /** + * Preflight an existing container WITHOUT linking anything, so the UI can tell + * the user "not running" / "no tmux" / "codex present, claude missing" before + * they commit to a case name. Read-only; never touches container lifecycle. + */ + app.post('/api/docker-cases/adopt-preflight', async (req): Promise> => { + const body = parseBody(DockerAdoptPreflightSchema, req.body); + const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === body.hostId); + if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found'); + const probe = await probeAdoptableContainer( + { + engine: host.engine ?? 'docker', + context: host.context, + daemonHost: host.daemonHost, + containerName: body.container, + }, + [...DOCKER_ADOPT_PROBE_MODES] + ); + return { success: true, data: probe }; + }); + // One-click "Run in Docker": create a NORMAL case (folder in CASES_DIR, scaffolded) // AND link it to a hardened container with default settings, auto-provisioning a // shared `default` docker host so the user never touches host/image/network fields. @@ -1010,9 +1115,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config engine: result.manifest.engine, image: result.importedImage ?? result.manifest.image, network: (['bridge', 'none', 'custom'].includes(result.manifest.network) ? result.manifest.network : 'bridge') as - | 'bridge' - | 'none' - | 'custom', + 'bridge' | 'none' | 'custom', }; await writeDockerHosts( CODEMAN_CONFIG_DIR, @@ -1042,8 +1145,22 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config '/api/docker-cases/:name/recreate', async (req): Promise> => { const { name } = req.params as { name: string }; - const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name); + // Ownership gate: recreate DESTROYS a container, so it must be scoped like + // delete is (`canAccessOwned`). Without it any user could rebuild another + // user's container by name. + const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find( + (item) => item.name === name && canAccessOwned(getAuthUser(req), item.owner) + ); if (!dockerCase) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker case not found'); + // An ADOPTED container is the user's own: there is nothing to recreate it + // from (no create-config, no image gate) and destroying it is exactly what + // adoption promises never to do. + if (dockerCase.owned === false) { + return createErrorResponse( + ApiErrorCode.FORBIDDEN, + `Case "${name}" adopted an existing container. Codeman does not own its lifecycle and will not recreate it — rebuild it yourself, or unlink the case.` + ); + } 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); @@ -1154,7 +1271,13 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config ); // Best-effort `docker rm -f` the per-case container (case-delete is the // explicit teardown that removes it; the bind-mounted workspace survives). - const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId); + // An ADOPTED container is skipped entirely: unlinking the case must leave + // the user's own container running and untouched. The seed file is skipped + // with it — adoption never wrote one. + const host = + dockerCase.owned === false + ? undefined + : (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId); if (host) { const sessionDocker = toSessionDocker(host, dockerCase); try { diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 3e7c258f..36ccea31 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -127,6 +127,7 @@ import { import { checkDockerAvailable, checkDockerConfigDrift, + probeAdoptableContainer, checkDockerTmuxAvailable, ensureAgentBaseImage, DEFAULT_AGENT_IMAGE, @@ -2957,25 +2958,43 @@ export function registerSessionRoutes( ); } const sessionDocker = toSessionDocker(host, dockerCase); - // 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 - // this awaits the SAME in-flight build rather than starting a second one. - const ensured = await ensureAgentBaseImage(sessionDocker, sessionDocker.image, { - onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }), - }); - if (!ensured.ok) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, ensured.error || 'base image not available'); - } - if (ensured.built) { - ctx.broadcast(SseEvent.DockerImageBuildComplete, { name: dockerCase.name, image: sessionDocker.image }); - } - // tmux is a hard prerequisite (the in-container tmux makes reconnect durable). - // Skip the extra container-run probe for our OWN default image (the baked - // Dockerfile always contains tmux); still verify a custom image. - if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) { - const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker); - if (!tmuxCheck.ok) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux'); + // An ADOPTED container skips every image-side gate: we never run `docker + // create`, so the image is the user's business, and `ensureAgentBaseImage` + // would build/require an image that has nothing to do with their container. + // The prerequisite that DOES still hold is tmux inside it, so probe the live + // container (not the image) and refuse before launch rather than dead-paning. + if (sessionDocker.owned === false) { + const probe = await probeAdoptableContainer(sessionDocker, [mode]); + if (!probe.ok) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, probe.error || 'container is not usable'); + } + if (mode !== 'shell' && !probe.availableModes?.includes(mode)) { + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + `"${mode}" is not installed in container "${sessionDocker.containerName}". Adoption never modifies the container — install it inside, or pick another mode.` + ); + } + } else { + // 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 + // this awaits the SAME in-flight build rather than starting a second one. + const ensured = await ensureAgentBaseImage(sessionDocker, sessionDocker.image, { + onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }), + }); + if (!ensured.ok) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, ensured.error || 'base image not available'); + } + if (ensured.built) { + ctx.broadcast(SseEvent.DockerImageBuildComplete, { name: dockerCase.name, image: sessionDocker.image }); + } + // tmux is a hard prerequisite (the in-container tmux makes reconnect durable). + // Skip the extra container-run probe for our OWN default image (the baked + // Dockerfile always contains tmux); still verify a custom image. + if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) { + const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker); + if (!tmuxCheck.ok) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux'); + } } } diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 1641e61f..c5860388 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -792,6 +792,50 @@ export const DockerCaseLinkSchema = z.object({ .optional(), }); +/** + * ADOPT an already-running container the user built and runs themselves. The + * container name is REQUIRED (there is nothing to derive it from — we are not + * creating it), and `hostWorkspacePath` still points at real host bytes so the + * file routes, watchers and transcript correlation keep working exactly as they + * do for an owned case. Everything that only makes sense at container-create + * time (image, network, resources, gpus, credential mounts) is deliberately + * absent: adoption never runs `docker create`. + */ +export const DockerCaseAdoptSchema = z.object({ + name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'), + hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'), + container: z + .string() + .min(2) + .max(128) + .regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid container name'), + hostWorkspacePath: z + .string() + .min(1) + .max(2000) + .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'), + containerWorkdir: z + .string() + .min(1) + .max(2000) + .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') + .optional(), +}); + +/** Read-only adoption preflight: report on an existing container, link nothing. */ +export const DockerAdoptPreflightSchema = z.object({ + hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'), + container: z + .string() + .min(2) + .max(128) + .regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid container name'), +}); + export const DockerExportSchema = z.object({ mode: z.enum(['full', 'workspace']).optional(), }); diff --git a/test/docker-adopted-container.test.ts b/test/docker-adopted-container.test.ts new file mode 100644 index 00000000..9561bf49 --- /dev/null +++ b/test/docker-adopted-container.test.ts @@ -0,0 +1,168 @@ +/** + * @fileoverview Adopting an ALREADY-RUNNING container (`DockerCase.owned === false`). + * + * The whole point of adoption is a negative guarantee: Codeman execs into a + * container the user built and runs, and never creates, starts, stops, restarts + * or removes it. A negative guarantee cannot be observed by using the feature — + * only by asserting that the mutating verbs are absent — so these tests read the + * generated command strings and assert on what is NOT in them. + * + * Mirror of the `owned:false` remote-SSH contract (COD-105). + */ +import { describe, it, expect } from 'vitest'; +import { + toSessionDocker, + isAdoptedContainer, + removeDockerContainer, + checkDockerConfigDrift, + dockerConfigHash, +} from '../src/docker-hosts.js'; +import { + buildDockerLaunchCommand, + buildDockerStopCommand, + buildDockerRemoveCommand, + buildDockerKillCommand, +} from '../src/tmux-manager.js'; +import type { DockerCase, DockerHost, SessionDocker } from '../src/types.js'; + +const HOST: DockerHost = { id: 'h1', label: 'local', engine: 'docker', image: 'codeman/agent:base' }; + +function caseFor(owned: boolean | undefined): DockerCase { + return { + name: 'adopted', + type: 'docker', + hostId: 'h1', + hostWorkspacePath: '/srv/work', + container: 'my-own-container', + ...(owned === undefined ? {} : { owned }), + }; +} + +function launchFor(docker: SessionDocker): string { + return buildDockerLaunchCommand({ + mode: 'codex', + docker, + sessionId: '11111111-2222-3333-4444-555555555555', + createContext: { + docker, + sessionId: '11111111-2222-3333-4444-555555555555', + instance: 'default', + userArgs: ['--user', '1000:0'], + credentialMounts: [], + extraMounts: [], + envCreate: { HOME: '/home/agent' }, + addHostGateway: true, + gatewayAlias: 'host.docker.internal', + }, + execEnv: { TERM: 'xterm-256color' }, + execEnvNames: [], + seedCopies: [{ from: '/seed/creds.json', to: '/home/agent/.claude/.credentials.json' }], + }); +} + +describe('adopted container: ownership plumbing', () => { + it('carries owned:false from the case onto the live session metadata', () => { + expect(toSessionDocker(HOST, caseFor(false)).owned).toBe(false); + expect(isAdoptedContainer(toSessionDocker(HOST, caseFor(false)))).toBe(true); + }); + + it('treats an absent flag as owned, so existing cases are unchanged', () => { + const docker = toSessionDocker(HOST, caseFor(undefined)); + expect(docker.owned).toBeUndefined(); + expect(isAdoptedContainer(docker)).toBe(false); + }); + + it('keeps ownership OUT of the config hash so adoption cannot mass-trip drift', () => { + // A drift-hash that moved with `owned` would flag every pre-existing case the + // moment this field shipped, and the remedy the UI offers is "recreate". + const owned = toSessionDocker(HOST, caseFor(undefined)); + const adopted = toSessionDocker(HOST, caseFor(false)); + expect(adopted.configHash).toBe(owned.configHash); + expect(dockerConfigHash({ ...owned, owned: false } as never)).toBe(owned.configHash); + }); +}); + +describe('adopted container: the launch chain never mutates lifecycle', () => { + const adopted = launchFor(toSessionDocker(HOST, caseFor(false))); + const owned = launchFor(toSessionDocker(HOST, caseFor(undefined))); + + it('never creates the container', () => { + expect(owned).toContain('docker create'); + expect(adopted).not.toContain('docker create'); + }); + + it('never starts the container', () => { + expect(owned).toContain('docker start'); + expect(adopted).not.toContain('docker start'); + }); + + it('never stops or removes the container', () => { + for (const verb of ['docker stop', 'docker rm', 'docker restart', 'docker kill']) { + expect(adopted).not.toContain(verb); + } + }); + + it('fails closed when the container is missing instead of creating it', () => { + expect(adopted).toContain('docker inspect'); + expect(adopted).toMatch(/not found.*start it yourself/i); + }); + + it('fails closed when the container is stopped instead of starting it', () => { + expect(adopted).toMatch(/\{\{\.State\.Running\}\}/); + expect(adopted).toMatch(/not running.*never starts a container it does not own/i); + }); + + it('skips the base-image gate, which describes an image adoption never uses', () => { + expect(owned).toContain('image inspect'); + expect(adopted).not.toContain('image inspect'); + }); + + it('never seeds host credentials into a container it does not own', () => { + expect(owned).toContain('.credentials.json'); + expect(adopted).not.toContain('.credentials.json'); + }); + + it('still execs into the in-container tmux, which is the whole point', () => { + expect(adopted).toContain('docker exec -it'); + expect(adopted).toContain('new-session -A'); + }); +}); + +describe('adopted container: mutating verbs fail closed at the builder', () => { + const docker = toSessionDocker(HOST, caseFor(false)); + + it('refuses to build a stop command', () => { + expect(() => buildDockerStopCommand(docker)).toThrow(/does not own its lifecycle/); + }); + + it('refuses to build a remove command', () => { + expect(() => buildDockerRemoveCommand(docker)).toThrow(/does not own its lifecycle/); + }); + + it('refuses to remove the container', async () => { + await expect(removeDockerContainer(docker)).rejects.toThrow(/does not own its lifecycle/); + }); + + it('still allows killing THIS session in-container tmux, never the container', () => { + const kill = buildDockerKillCommand({ docker, sessionId: 'abcdef12-0000-0000-0000-000000000000' }); + expect(kill).toContain('tmux'); + expect(kill).toContain('kill-session'); + expect(kill).not.toContain('docker stop'); + expect(kill).not.toContain('docker rm'); + }); + + it('still permits every verb for an owned container', () => { + const ownedDocker = toSessionDocker(HOST, caseFor(undefined)); + expect(buildDockerStopCommand(ownedDocker)).toContain('stop -t 10'); + expect(buildDockerRemoveCommand(ownedDocker)).toContain('rm -f'); + }); +}); + +describe('adopted container: drift is not evaluated', () => { + it('reports no drift rather than demanding a recreate we may not perform', async () => { + // An adopted container carries no codeman.confighash label, so a real + // comparison would always report drift and the launch gate would 409 forever. + const status = await checkDockerConfigDrift(toSessionDocker(HOST, caseFor(false))); + expect(status.drifted).toBe(false); + }); +}); From bc55b6b0daa352929b08e626f1aee91d68459616 Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 21:02:19 -0700 Subject: [PATCH 02/26] feat(docker): add the attach-an-existing-container panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Docker tab gains an "Attach to an existing container" toggle. Ticking it swaps the create-time fields (image, network, advanced) — which describe a `docker create` attaching never runs — for the container name, and routes the submit to the adopt endpoint. Reuses the existing linkDockerCase flow end to end: only the final call differs. The docker-host upsert still applies, since it is what resolves the engine/context/daemon for `docker exec`; its create-time fields are simply never read for an attached case. --- src/web/public/index.html | 15 ++++- src/web/public/session-ui.js | 83 +++++++++++++++++++++++++-- src/web/public/styles.css | 26 +++++++++ test/docker-adopted-container.test.ts | 36 ++++++++++++ 4 files changed, 153 insertions(+), 7 deletions(-) diff --git a/src/web/public/index.html b/src/web/public/index.html index d9491d45..bb5757b0 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2831,6 +2831,15 @@

Docker

Run the case inside a container: one per case, shared by all its sessions.

+
+ + On: Codeman only docker execs into a container you already built and run — it never creates, starts, stops or removes it. The CLIs must already be installed and logged in inside it. +
+
+ + + Must be running already. +
@@ -2846,12 +2855,12 @@ A reusable docker host profile. Reuse the same ID across cases to share settings.
-
+
Build it once with node scripts/build-agent-image.mjs. Contains node + claude/codex/gemini/opencode/agy/pi/grok/dsh + tmux.
-
+
-
+
Advanced container settings
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 6de0507c..24c1f49b 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2334,6 +2334,14 @@ Object.assign(CodemanApp.prototype, { modal.querySelectorAll('.set-rail-item').forEach(btn => { btn.onclick = () => this.switchCaseModalTab(btn.dataset.tab); }); + // Adopt-an-existing-container toggle + its read-only preflight. Assigned (not + // addEventListener) so reopening the modal cannot stack duplicate handlers, + // matching the rail wiring right above. + const adoptToggle = document.getElementById('dockerAdoptExisting'); + if (adoptToggle) adoptToggle.onchange = () => this._syncDockerAdoptMode(); + const adoptCheck = document.getElementById('dockerAdoptCheckBtn'); + if (adoptCheck) adoptCheck.onclick = () => this._dockerAdoptPreflight(); + this._syncDockerAdoptMode(); // Scroll-into-view on focus for mobile keyboard visibility modal.querySelectorAll('input[type="text"]').forEach(input => { if (!input._mobileScrollWired) { @@ -2890,10 +2898,64 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * Reflect the "attach to an existing container" checkbox onto the modal so CSS + * can swap which half of the Docker panel applies. An attribute rather than + * per-row inline styles: the panel is rebuilt by nothing, but the create-time + * rows are a SET (image, network, advanced block) and one attribute keeps them + * in lockstep with the container-name row. + */ + _syncDockerAdoptMode() { + const modal = document.getElementById('createCaseModal'); + if (!modal) return; + const adopting = document.getElementById('dockerAdoptExisting')?.checked; + if (adopting) modal.setAttribute('data-docker-adopt', '1'); + else modal.removeAttribute('data-docker-adopt'); + }, + + /** + * Read-only preflight against an existing container. It links nothing, so the + * user can find out "not running" / "no tmux" / "codex present, claude missing" + * before committing to a case name — the same reason the server refuses at link + * time rather than at session launch. + */ + async _dockerAdoptPreflight() { + const statusEl = document.getElementById('dockerLinkStatus'); + const container = document.getElementById('dockerContainerName')?.value.trim(); + const hostId = document.getElementById('dockerHostId').value.trim() || 'local'; + if (!container) { + if (statusEl) statusEl.textContent = 'Enter a container name first.'; + return; + } + if (statusEl) statusEl.textContent = 'Inspecting container...'; + // _apiJson folds every failure to null, and a preflight's whole value is the + // reason it failed, so the envelope is unwrapped by hand here. + const probe = await this._apiJson('/api/docker-cases/adopt-preflight', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hostId, container }), + }); + if (!statusEl) return; + if (!probe) { + statusEl.textContent = 'Could not reach the docker host profile. Save a Host ID first.'; + return; + } + if (!probe.ok) { + statusEl.textContent = probe.error || 'Container is not adoptable.'; + return; + } + const modes = (probe.availableModes || []).filter((m) => m !== 'shell'); + statusEl.textContent = modes.length + ? `Running (${probe.image || 'unknown image'}). Available: ${modes.join(', ')}.` + : `Running (${probe.image || 'unknown image'}), but no agent CLI found inside — only Shell will work.`; + }, + async linkDockerCase() { const name = document.getElementById('dockerCaseName').value.trim(); const hostWorkspacePath = document.getElementById('dockerWorkspacePath').value.trim(); const hostId = document.getElementById('dockerHostId').value.trim() || 'local'; + const adopting = !!document.getElementById('dockerAdoptExisting')?.checked; + const container = document.getElementById('dockerContainerName')?.value.trim() || ''; const image = document.getElementById('dockerImage').value.trim() || 'codeman/agent:base'; const network = document.getElementById('dockerNetwork').value; const memory = document.getElementById('dockerMemory').value.trim(); @@ -2914,9 +2976,15 @@ Object.assign(CodemanApp.prototype, { this.showToast('Workspace path must be absolute', 'error'); return; } + if (adopting && !container) { + this.showToast('Enter the name of the running container to attach to', 'error'); + return; + } try { - if (statusEl) statusEl.textContent = 'Checking docker daemon + base image...'; + if (statusEl) { + statusEl.textContent = adopting ? 'Inspecting the existing container...' : 'Checking docker daemon + base image...'; + } // omitted optionals sent as UNDEFINED (never null — Zod .optional() rejects null) const resources = {}; if (memory) resources.memory = memory; @@ -2947,16 +3015,23 @@ Object.assign(CodemanApp.prototype, { } if (!hostData.success) throw new Error(hostData.error || 'Failed to save docker host'); - const caseRes = await fetch('/api/cases/docker-link', { + // Adoption reuses this whole flow and differs only in the final call: a + // different endpoint (which never creates a container) plus the container + // name. The host upsert above still applies — it is what resolves the + // engine/context/daemon for the `docker exec`; its create-time fields are + // simply never read for an adopted case. + const caseRes = await fetch(adopting ? '/api/cases/docker-adopt' : '/api/cases/docker-link', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, hostId, hostWorkspacePath }), + body: JSON.stringify(adopting ? { name, hostId, hostWorkspacePath, container } : { name, hostId, hostWorkspacePath }), }); const caseData = await caseRes.json(); if (caseData.success) { this.closeCreateCaseModal(); const caps = caseData.data?.capsEnforced === false ? ' (resource caps are advisory on this engine)' : ''; - this.showToast(`Docker case "${name}" linked${caps}`, 'success'); + const modes = (caseData.data?.availableModes || []).filter((m) => m !== 'shell'); + const found = adopting && modes.length ? ` — found ${modes.join(', ')}` : ''; + this.showToast(`Docker case "${name}" ${adopting ? 'attached' : 'linked'}${caps}${found}`, 'success'); await this.loadQuickStartCases(name); await this.saveLastUsedCase(name); } else { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 8efb776e..7ca4edc3 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -16601,6 +16601,32 @@ html[data-tab-orientation='vertical'] .home-sessions { label as a row label, its `.form-hint` as a row description. Scoped to the document, so `.form-row` everywhere else is untouched. ─────────────────────────────────────────────────────────────────────────── */ +/* Adopt-an-existing-container mode swaps which half of the Docker panel applies: + the create-time fields (image, network, resources, credential mounts) describe + a `docker create` that adoption never runs, and the container name is the one + field only adoption needs. `.docker-adopt-only` is hidden by default so the + panel stays exactly as it was until the checkbox is ticked. Rules carry + `!important` because the adapter block above paints `.form-row` as a row card + and `details.advanced-options` has its own display. */ +#createCaseModal .docker-adopt-only { + display: none !important; +} +#createCaseModal[data-docker-adopt='1'] .docker-adopt-only { + display: block !important; +} +#createCaseModal[data-docker-adopt='1'] .docker-create-only { + display: none !important; +} +#createCaseModal .btn-inline-check { + background: none; + border: none; + padding: 0; + font: inherit; + color: var(--accent, #4a9eff); + cursor: pointer; + text-decoration: underline; +} + #createCaseModal .set-doc .form-row { margin: 0 0 3px; padding: 7px 10px; diff --git a/test/docker-adopted-container.test.ts b/test/docker-adopted-container.test.ts index 9561bf49..cd15a443 100644 --- a/test/docker-adopted-container.test.ts +++ b/test/docker-adopted-container.test.ts @@ -10,6 +10,7 @@ * Mirror of the `owned:false` remote-SSH contract (COD-105). */ import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; import { toSessionDocker, isAdoptedContainer, @@ -158,6 +159,41 @@ describe('adopted container: mutating verbs fail closed at the builder', () => { }); }); +describe('adopted container: the Add Case panel id contract', () => { + // The modal's load/save contract is getElementById by fixed id, so a renamed or + // dropped id stops the control working with no error anywhere. Static guard in + // the style of app-settings-structure / session-options-structure. + const html = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf8'); + const ui = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf8'); + const css = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf8'); + + it('ships every id session-ui.js reads back', () => { + for (const id of ['dockerAdoptExisting', 'dockerContainerName', 'dockerAdoptCheckBtn']) { + expect(html).toContain(`id="${id}"`); + expect(ui).toContain(`'${id}'`); + } + }); + + it('routes adoption to the endpoint that never creates a container', () => { + expect(ui).toContain('/api/cases/docker-adopt'); + expect(ui).toContain('/api/docker-cases/adopt-preflight'); + // The create path must survive untouched beside it. + expect(ui).toContain('/api/cases/docker-link'); + }); + + it('hides the adopt-only row until the toggle is on, so the panel is unchanged by default', () => { + expect(css).toContain('#createCaseModal .docker-adopt-only'); + expect(css).toMatch(/#createCaseModal \.docker-adopt-only \{\s*display: none/); + expect(css).toContain("#createCaseModal[data-docker-adopt='1'] .docker-adopt-only"); + }); + + it('marks the create-time rows so adoption hides the fields it never uses', () => { + // image / network / advanced describe a `docker create` adoption never runs. + expect(html.match(/docker-create-only/g)?.length).toBeGreaterThanOrEqual(3); + expect(css).toContain("#createCaseModal[data-docker-adopt='1'] .docker-create-only"); + }); +}); + describe('adopted container: drift is not evaluated', () => { it('reports no drift rather than demanding a recreate we may not perform', async () => { // An adopted container carries no codeman.confighash label, so a real From c98a59d7090b5ee526fe1f71cf7fb4554d79764e Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 21:02:19 -0700 Subject: [PATCH 03/26] fix(docker): verify the container workdir and end the probe with exit 0 Two defects that only a real container exposes. The probe chained `command -v X && echo X` with semicolons, and a script's exit status is its last command's. A container without the last probed CLI made the whole `sh -lc` exit 1, so a perfectly healthy container with tmux and claude was reported as "could not exec into the container". A missing CLI is data here, not failure, so the script now ends with `exit 0`. containerWorkdir defaulted to hostWorkspacePath. That default holds for an owned container only because the create-time bind mount puts the host directory at that exact path; attaching mounts nothing, so the two are independent facts. A host path absent inside the container makes `docker exec --workdir` fail with an OCI chdir error that surfaces in the pane as a bare "execvp failed". The preflight now proves the directory exists inside the container and refuses at link time. --- src/docker-hosts.ts | 40 ++++++++++++++++++++++++++++++++--- src/web/public/index.html | 5 +++++ src/web/public/session-ui.js | 10 +++++++-- src/web/routes/case-routes.ts | 13 ++++++++++-- src/web/schemas.ts | 8 +++++++ 5 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index b49502a5..c039815d 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -1044,6 +1044,8 @@ export interface AdoptedContainerProbe { tmuxPath?: string; /** Modes whose CLI resolved inside the container (`command -v `). */ availableModes?: SessionMode[]; + /** Whether the requested working directory exists INSIDE the container. */ + workdirExists?: boolean; error?: string; } @@ -1059,10 +1061,18 @@ export interface AdoptedContainerProbe { */ export async function probeAdoptableContainer( docker: Pick, - modes: SessionMode[] = [] + modes: SessionMode[] = [], + containerWorkdir?: string ): Promise { if (IS_TEST_MODE) { - return { ok: true, exists: true, running: true, tmuxPath: '/usr/bin/tmux', availableModes: modes }; + return { + ok: true, + exists: true, + running: true, + tmuxPath: '/usr/bin/tmux', + availableModes: modes, + workdirExists: true, + }; } const argv = dockerEngineArgv(docker); let running = false; @@ -1096,7 +1106,19 @@ export async function probeAdoptableContainer( // One exec resolves tmux plus every requested CLI, so adoption costs a single // round trip. Binaries are fixed mode names, never user input. const probes = ['tmux', ...modes.filter((m) => m !== 'shell')]; - const script = probes.map((bin) => `command -v ${bin} >/dev/null 2>&1 && echo ${bin}`).join('; '); + // `; exit 0` is load-bearing: the script's status is its LAST command's, so a + // missing final CLI made the whole `sh -lc` exit 1 and the probe reported + // "could not exec into the container" for a container that was perfectly fine. + // Absence of a CLI is data here, not failure — only a real exec error is. + const steps = probes.map((bin) => `command -v ${bin} >/dev/null 2>&1 && echo ${bin}`); + // The workdir is checked INSIDE the container, and that is a fact independent + // of hostWorkspacePath: an owned container gets the host dir bind-mounted at the + // same absolute path at create time, but adoption mounts nothing, so the two + // paths only coincide if the user mounted it there themselves. `docker exec + // --workdir ` fails with an OCI chdir error the pane surfaces as a bare + // "execvp failed", so it is resolved here into an actionable message. + if (containerWorkdir) steps.push(`[ -d ${shellescape(containerWorkdir)} ] && echo __workdir__`); + const script = `${steps.join('; ')}; exit 0`; try { const { stdout } = await execFileAsync( argv[0], @@ -1118,6 +1140,17 @@ export async function probeAdoptableContainer( error: `container "${docker.containerName}" has no tmux (required for durable sessions; install it inside the container)`, }; } + const workdirExists = containerWorkdir ? found.has('__workdir__') : undefined; + if (containerWorkdir && !workdirExists) { + return { + ok: false, + exists: true, + running: true, + image, + workdirExists: false, + error: `"${containerWorkdir}" does not exist inside container "${docker.containerName}". Adoption mounts nothing, so the container workdir must already exist there — set it to a path inside the container (it need not match the host workspace path).`, + }; + } return { ok: true, exists: true, @@ -1125,6 +1158,7 @@ export async function probeAdoptableContainer( image, tmuxPath: 'tmux', availableModes: modes.filter((m) => m === 'shell' || found.has(m)), + workdirExists, }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); diff --git a/src/web/public/index.html b/src/web/public/index.html index bb5757b0..4d54096a 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2840,6 +2840,11 @@ Must be running already.
+
+ + + A path that already exists inside the container. Adoption mounts nothing, so this need not match the host workspace path — leave blank to reuse it only if you mounted it there yourself. +
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 24c1f49b..9da4990d 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2922,6 +2922,7 @@ Object.assign(CodemanApp.prototype, { async _dockerAdoptPreflight() { const statusEl = document.getElementById('dockerLinkStatus'); const container = document.getElementById('dockerContainerName')?.value.trim(); + const containerWorkdir = document.getElementById('dockerAdoptWorkdir')?.value.trim(); const hostId = document.getElementById('dockerHostId').value.trim() || 'local'; if (!container) { if (statusEl) statusEl.textContent = 'Enter a container name first.'; @@ -2933,7 +2934,7 @@ Object.assign(CodemanApp.prototype, { const probe = await this._apiJson('/api/docker-cases/adopt-preflight', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ hostId, container }), + body: JSON.stringify({ hostId, container, ...(containerWorkdir ? { containerWorkdir } : {}) }), }); if (!statusEl) return; if (!probe) { @@ -2956,6 +2957,7 @@ Object.assign(CodemanApp.prototype, { const hostId = document.getElementById('dockerHostId').value.trim() || 'local'; const adopting = !!document.getElementById('dockerAdoptExisting')?.checked; const container = document.getElementById('dockerContainerName')?.value.trim() || ''; + const adoptWorkdir = document.getElementById('dockerAdoptWorkdir')?.value.trim() || ''; const image = document.getElementById('dockerImage').value.trim() || 'codeman/agent:base'; const network = document.getElementById('dockerNetwork').value; const memory = document.getElementById('dockerMemory').value.trim(); @@ -3023,7 +3025,11 @@ Object.assign(CodemanApp.prototype, { const caseRes = await fetch(adopting ? '/api/cases/docker-adopt' : '/api/cases/docker-link', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(adopting ? { name, hostId, hostWorkspacePath, container } : { name, hostId, hostWorkspacePath }), + body: JSON.stringify( + adopting + ? { name, hostId, hostWorkspacePath, container, ...(adoptWorkdir ? { containerWorkdir: adoptWorkdir } : {}) } + : { name, hostId, hostWorkspacePath } + ), }); const caseData = await caseRes.json(); if (caseData.success) { diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 49c94e2e..6e526995 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -837,7 +837,15 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config availability.error || 'docker daemon is not available' ); } - const probe = await probeAdoptableContainer(toSessionDocker(host, dockerCase), [...DOCKER_ADOPT_PROBE_MODES]); + // The container workdir is validated INSIDE the container. It defaults to + // hostWorkspacePath only because that is what an owned container's bind + // mount guarantees; adoption mounts nothing, so the probe has to prove it. + const adoptDocker = toSessionDocker(host, dockerCase); + const probe = await probeAdoptableContainer( + adoptDocker, + [...DOCKER_ADOPT_PROBE_MODES], + adoptDocker.containerWorkdir + ); if (!probe.ok) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, probe.error || 'container is not adoptable'); } @@ -871,7 +879,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config daemonHost: host.daemonHost, containerName: body.container, }, - [...DOCKER_ADOPT_PROBE_MODES] + [...DOCKER_ADOPT_PROBE_MODES], + body.containerWorkdir ); return { success: true, data: probe }; }); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index c5860388..65ef1395 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -834,6 +834,14 @@ export const DockerAdoptPreflightSchema = z.object({ .min(2) .max(128) .regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid container name'), + /** Optional: also verify this path exists INSIDE the container. */ + containerWorkdir: z + .string() + .min(1) + .max(2000) + .regex(/^\//, 'Container workdir must be absolute') + .regex(NO_SHELL_META, 'Invalid characters in container workdir') + .optional(), }); export const DockerExportSchema = z.object({ From bb45909169a2df78c77db18ece60f6f35d36e229 Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 21:02:19 -0700 Subject: [PATCH 04/26] feat(docker): link to container attach from the Create New tab Attaching lived only on the Docker tab, but the place users look for anything container-shaped is the "Run in an isolated Docker container" checkbox on Create New. A feature nobody can find is a feature nobody has. Adds a one-click link there that switches to the Docker tab, turns the toggle on and focuses the container field. Reuses switchCaseModalTab and the existing sync helper; no new CSS. --- src/web/public/index.html | 1 + src/web/public/session-ui.js | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/web/public/index.html b/src/web/public/index.html index 4d54096a..9734ec06 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2629,6 +2629,7 @@
Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed. + Already have a container running? — Codeman only docker execs in and never touches its lifecycle.
Container settings (optional, sensible defaults) diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 9da4990d..8cedd647 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2341,6 +2341,8 @@ Object.assign(CodemanApp.prototype, { if (adoptToggle) adoptToggle.onchange = () => this._syncDockerAdoptMode(); const adoptCheck = document.getElementById('dockerAdoptCheckBtn'); if (adoptCheck) adoptCheck.onclick = () => this._dockerAdoptPreflight(); + const adoptJump = document.getElementById('dockerAdoptJumpBtn'); + if (adoptJump) adoptJump.onclick = () => this.jumpToDockerAdopt(); this._syncDockerAdoptMode(); // Scroll-into-view on focus for mobile keyboard visibility modal.querySelectorAll('input[type="text"]').forEach(input => { @@ -2913,6 +2915,20 @@ Object.assign(CodemanApp.prototype, { else modal.removeAttribute('data-docker-adopt'); }, + /** + * Cross-link from the Create New tab's "Run in an isolated Docker container" + * row. Adoption lives on the Docker tab, but the place users actually look for + * anything container-shaped is that checkbox, so this jumps them there with the + * toggle already on rather than leaving the feature undiscoverable. + */ + jumpToDockerAdopt() { + this.switchCaseModalTab('case-docker'); + const toggle = document.getElementById('dockerAdoptExisting'); + if (toggle) toggle.checked = true; + this._syncDockerAdoptMode(); + document.getElementById('dockerContainerName')?.focus(); + }, + /** * Read-only preflight against an existing container. It links nothing, so the * user can find out "not running" / "no tmux" / "codex present, claude missing" From e2f750cb304f79e7a30b5acc9e4c2540bcf5aada Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 21:02:19 -0700 Subject: [PATCH 05/26] i18n(docker): translate the attach panel, and unblock translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new strings were English only. Adding entries surfaced a deeper problem: the translator matches whole text nodes and skips `code`/`pre`, so an inline `` mid-sentence splits a hint into fragments that can never match an entry — which is why the panel's existing "Build it once with ..." hint was never translated either. Drops the inline markup from the new hints so each is a single text node, then adds the zh-CN entries. The brand name goes through the existing {name} placeholder. Server-side error bodies are deliberately not added: the client receives them already interpolated with a concrete container name, so a template key could never match. --- src/web/public/i18n.js | 13 +++++++++++++ src/web/public/index.html | 6 +++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index fbdcc296..f9d83ab8 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -713,6 +713,19 @@ 'Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.': '在加固的隔离容器中运行此案例。首次使用时会自动构建基础镜像;必须安装 Docker/Podman。', 'Run in an isolated Docker container': '在隔离的 Docker 容器中运行', + 'Attach to an existing container': '接入已在运行的容器', + 'On: Codeman only runs docker exec into a container you already built and run — it never creates, starts, stops or removes it. The CLIs must already be installed and logged in inside it.': + '开启后,{name}只会 docker exec 进入你自己构建并运行的容器,绝不创建、启动、停止或删除它;容器内必须已安装并登录好相应 CLI。', + 'Container Name': '容器名称', + 'Must be running already.': '该容器必须已在运行。', + 'Check container': '检查容器', + 'Container Workdir': '容器内工作目录', + 'A path that already exists inside the container. Adoption mounts nothing, so this need not match the host workspace path.': + '容器内已存在的路径。接入不挂载任何目录,因此它不必与主机工作区路径相同。', + 'Already have a container running?': '已经有正在运行的容器?', + 'Attach to it instead': '改为接入该容器', + 'Codeman only runs docker exec into it and never touches its lifecycle.': + '{name}只会 docker exec 进入它,绝不触碰其生命周期。', 'Absolute HOST directory, bind-mounted into the container. Codeman scaffolds CLAUDE.md + hooks into it.': '绑定挂载到容器中的主机绝对目录;{name}会在其中生成 CLAUDE.md 和 hooks。', 'A reusable docker host profile. Reuse the same ID across cases to share settings.': diff --git a/src/web/public/index.html b/src/web/public/index.html index 9734ec06..7e1024e4 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2629,7 +2629,7 @@
Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed. - Already have a container running? — Codeman only docker execs in and never touches its lifecycle. + Already have a container running? Codeman only runs docker exec into it and never touches its lifecycle.
Container settings (optional, sensible defaults) @@ -2834,7 +2834,7 @@

Run the case inside a container: one per case, shared by all its sessions.

- On: Codeman only docker execs into a container you already built and run — it never creates, starts, stops or removes it. The CLIs must already be installed and logged in inside it. + On: Codeman only runs docker exec into a container you already built and run — it never creates, starts, stops or removes it. The CLIs must already be installed and logged in inside it.
@@ -2844,7 +2844,7 @@
- A path that already exists inside the container. Adoption mounts nothing, so this need not match the host workspace path — leave blank to reuse it only if you mounted it there yourself. + A path that already exists inside the container. Adoption mounts nothing, so this need not match the host workspace path.
From 34c12ca18bc2b0827a7510517fb2c2fd33c9ec74 Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 21:02:19 -0700 Subject: [PATCH 06/26] feat(docker): make the container field a picker you can also type into Typing a container name from memory is error-prone. The field becomes a native datalist: pick from the engine's containers, type to filter, or type a name that is not listed (the engine may be remote, or the container may not exist yet). A datalist gives all three natively, so no dropdown state machine is introduced. Adds listDockerContainers and GET /api/docker-hosts/:hostId/containers, following the listRemoteCodemanSessions discovery precedent: read-only and never throwing, so an unreachable daemon returns an empty list and the field degrades to plain text instead of erroring. Stopped containers stay in the list, sorted after running ones and labelled. Attaching does require a running container, but hiding stopped ones turns "my container is not in the list" into a dead end, while showing `Exited (137) 8 days ago` says exactly what to fix. --- src/docker-hosts.ts | 49 +++++++++++++++++++++++++++++++++++ src/web/public/i18n.js | 2 +- src/web/public/index.html | 5 ++-- src/web/public/session-ui.js | 40 ++++++++++++++++++++++++++++ src/web/routes/case-routes.ts | 24 ++++++++++++++++- 5 files changed, 116 insertions(+), 4 deletions(-) diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index c039815d..7ad11346 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -1049,6 +1049,55 @@ export interface AdoptedContainerProbe { error?: string; } +/** One container on the engine, as offered to the adoption picker. */ +export interface DockerContainerInfo { + name: string; + image: string; + running: boolean; + /** Engine's own status string, e.g. "Up 3 hours" / "Exited (0) 2 days ago". */ + status: string; +} + +/** + * List the engine's containers for the adoption picker (mirror of + * `listRemoteCodemanSessions`). Read-only and NEVER throws: an unreachable + * daemon, a missing engine or zero containers all return `[]`, because this + * feeds a convenience picker whose input the user can always type by hand. + * + * Stopped containers ARE included, sorted after running ones and carrying their + * status: adoption requires a running container, but hiding a stopped one turns + * "my container is not in the list" into a dead end with no explanation, while + * showing `my-box (Exited (0) 2 days ago)` says exactly what to fix. + */ +export async function listDockerContainers( + docker: Pick +): Promise { + if (IS_TEST_MODE) return []; + const argv = dockerEngineArgv(docker); + try { + const { stdout } = await execFileAsync( + argv[0], + [...argv.slice(1), 'ps', '-a', '--format', '{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}'], + { timeout: DOCKER_PROBE_TIMEOUT_MS } + ); + const rows = stdout + .split('\n') + .map((line) => line.split('\t')) + .filter((parts) => parts.length >= 4 && parts[0]) + .map(([name, image, state, status]) => ({ + name, + image: image || '', + running: state === 'running', + status: status || '', + })); + // Running first, then by name, so the containers a user can actually adopt + // are the ones at the top of the list. + return rows.sort((a, b) => Number(b.running) - Number(a.running) || a.name.localeCompare(b.name)); + } catch { + return []; + } +} + /** * Preflight an EXISTING container for adoption. Read-only by construction: it * runs `inspect` plus one `exec` of `command -v`, and never creates, starts or diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index f9d83ab8..30ee874d 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -717,7 +717,7 @@ 'On: Codeman only runs docker exec into a container you already built and run — it never creates, starts, stops or removes it. The CLIs must already be installed and logged in inside it.': '开启后,{name}只会 docker exec 进入你自己构建并运行的容器,绝不创建、启动、停止或删除它;容器内必须已安装并登录好相应 CLI。', 'Container Name': '容器名称', - 'Must be running already.': '该容器必须已在运行。', + 'Pick from the running containers or type a name.': '从正在运行的容器中选择,或直接输入名称。', 'Check container': '检查容器', 'Container Workdir': '容器内工作目录', 'A path that already exists inside the container. Adoption mounts nothing, so this need not match the host workspace path.': diff --git a/src/web/public/index.html b/src/web/public/index.html index 7e1024e4..f83a17d9 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2838,8 +2838,9 @@
- - Must be running already. + + + Pick from the running containers or type a name.
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 8cedd647..2377c37d 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2343,6 +2343,18 @@ Object.assign(CodemanApp.prototype, { if (adoptCheck) adoptCheck.onclick = () => this._dockerAdoptPreflight(); const adoptJump = document.getElementById('dockerAdoptJumpBtn'); if (adoptJump) adoptJump.onclick = () => this.jumpToDockerAdopt(); + // Containers come from the host profile, so switching Host ID invalidates the + // suggestions. Dropping the marker (rather than refetching here) keeps the + // fetch lazy — it happens when adopt mode is actually on. + const hostIdInput = document.getElementById('dockerHostId'); + if (hostIdInput) { + hostIdInput.onchange = () => { + delete document.getElementById('dockerContainerList')?.dataset.loadedFor; + if (document.getElementById('dockerAdoptExisting')?.checked) void this._loadDockerContainerOptions(); + }; + } + // A fresh open re-reads the engine: containers start and stop between visits. + delete document.getElementById('dockerContainerList')?.dataset.loadedFor; this._syncDockerAdoptMode(); // Scroll-into-view on focus for mobile keyboard visibility modal.querySelectorAll('input[type="text"]').forEach(input => { @@ -2913,6 +2925,34 @@ Object.assign(CodemanApp.prototype, { const adopting = document.getElementById('dockerAdoptExisting')?.checked; if (adopting) modal.setAttribute('data-docker-adopt', '1'); else modal.removeAttribute('data-docker-adopt'); + if (adopting) void this._loadDockerContainerOptions(); + }, + + /** + * Fill the container-name ``. A native datalist is deliberate: the + * field must accept a free-typed name (the engine may be remote, or the + * container may not exist yet when the form is filled), and datalist gives + * type-to-filter over the suggestions without a custom dropdown. + * + * Best-effort by design — the endpoint returns [] for an unreachable daemon, + * and an empty list simply leaves the field as plain text input. + */ + async _loadDockerContainerOptions() { + const list = document.getElementById('dockerContainerList'); + if (!list) return; + const hostId = document.getElementById('dockerHostId')?.value.trim() || 'local'; + if (list.dataset.loadedFor === hostId) return; // one fetch per host per open + const data = await this._apiJson(`/api/docker-hosts/${encodeURIComponent(hostId)}/containers`); + const containers = data?.containers || []; + list.textContent = ''; + for (const c of containers) { + const option = document.createElement('option'); + option.value = c.name; + // Engine-supplied strings: set as text, never as markup. + option.textContent = c.running ? `${c.image} · ${c.status}` : `${c.image} · ${c.status} (not running)`; + list.appendChild(option); + } + list.dataset.loadedFor = hostId; }, /** diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 6e526995..fc2079d3 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -69,6 +69,7 @@ import { dockerContainerName, dockerDisplayPath, probeAdoptableContainer, + listDockerContainers, DOCKER_ADOPT_PROBE_MODES, readDockerCases, readDockerHosts, @@ -77,7 +78,7 @@ import { writeDockerCases, writeDockerHosts, } from '../../docker-hosts.js'; -import type { AdoptedContainerProbe } from '../../docker-hosts.js'; +import type { AdoptedContainerProbe, DockerContainerInfo } from '../../docker-hosts.js'; import { buildDockerRemoveCommand } from '../../tmux-manager.js'; import { checkRemoteTmuxAvailable, @@ -868,6 +869,27 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config * the user "not running" / "no tmux" / "codex present, claude missing" before * they commit to a case name. Read-only; never touches container lifecycle. */ + /** + * Containers on the host's engine, for the adoption picker. Read-only and + * best-effort (mirror of the remote `:hostId/sessions` discovery route): an + * unreachable daemon yields an empty list rather than an error, because the + * container name is a free-text field the user can always type by hand. + */ + app.get( + '/api/docker-hosts/:hostId/containers', + async (req): Promise> => { + const { hostId } = req.params as { hostId: string }; + const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId); + if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found'); + const containers = await listDockerContainers({ + engine: host.engine ?? 'docker', + context: host.context, + daemonHost: host.daemonHost, + }); + return { success: true, data: { containers } }; + } + ); + app.post('/api/docker-cases/adopt-preflight', async (req): Promise> => { const body = parseBody(DockerAdoptPreflightSchema, req.body); const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === body.hostId); From 2f83a37c6d69b7e25ffc3b81d914116f6df412fd Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 21:02:19 -0700 Subject: [PATCH 07/26] feat(docker): take run-mode availability from the container The run-mode dropdown hides CLIs that are not installed on the HOST (#201). That is right for local sessions and wrong for a container case, whose agents run inside the container: a host with no claude installed hides the mode while the container ships one, which is exactly what happened on a real deployment. The adoption preflight already probes what the container has, so that result is persisted on the case and surfaced through CaseInfo. Docker cases gate on it; every other case keeps the host probe unchanged. An absent list reads as "do not gate" rather than "nothing available": an owned container runs our base image, which ships every CLI, and treating unknown as empty would leave the menu with Shell alone. --- src/types/api.ts | 7 +++++++ src/types/session.ts | 9 +++++++++ src/web/public/session-ui.js | 16 ++++++++++++++- src/web/routes/case-routes.ts | 13 +++++++++---- test/docker-adopted-container.test.ts | 28 +++++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 5 deletions(-) diff --git a/src/types/api.ts b/src/types/api.ts index b63268af..a57b2795 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -167,6 +167,13 @@ export interface CaseInfo { image?: string; path: string; network?: string; + /** + * CLIs available INSIDE the container. A container case runs its agents in + * the container, so HOST CLI availability says nothing about what it can + * run. Absent = unknown (an owned container runs our base image, which ships + * every CLI), which the UI reads as "do not gate". + */ + availableModes?: string[]; }; } diff --git a/src/types/session.ts b/src/types/session.ts index 7187dae4..21e117b9 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -261,6 +261,15 @@ export interface DockerCase { * all created by us). */ owned?: boolean; + /** + * CLIs found INSIDE the container by the adoption preflight. A container case + * runs its agents in the container, so host CLI availability says nothing about + * what this case can run — the base image ships every CLI, and an adopted + * container ships whatever its owner installed. Absent = unknown (owned cases, + * or a case linked before this field existed), which callers read as "do not + * gate". + */ + availableModes?: SessionMode[]; /** Last captured Claude conversation id, replayed via --resume on a fresh launch. */ lastClaudeSessionId?: string; } diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 2377c37d..b43e4288 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -474,9 +474,23 @@ Object.assign(CodemanApp.prototype, { * run modes like the rest, and neither `agy` nor `pi` is likely to be installed. */ _refreshRunModeAvailability(menu) { + // A DOCKER case runs its agents INSIDE the container, so host CLI + // availability answers the wrong question: the host may have no claude at + // all while the container ships one, and gating on the host hides a mode + // that would have worked. Adoption records what the container really has + // (`availableModes`); an owned container runs our base image, which ships + // every CLI, so an absent list means "do not gate" rather than "nothing". + // Same source every run* path reads the selected case from. + const caseName = document.getElementById('quickStartCase')?.value; + const activeCase = caseName ? (this.cases || []).find((c) => c.name === caseName) : null; + const containerModes = activeCase?.location === 'docker' ? activeCase.docker?.availableModes : null; for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']) { const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`); - if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none'; + if (!btn) continue; + let available; + if (activeCase?.location === 'docker') available = containerModes ? containerModes.includes(mode) : true; + else available = this.isCliAvailable(mode); + btn.style.display = available ? 'flex' : 'none'; } // DeepSeek is the one mode whose availability has two halves: `dsh` can be // perfectly installed while no pane-capable profile exists, because DeepSeek diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index fc2079d3..11ac46e7 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -297,6 +297,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config image: host.image, path: dockerCase.hostWorkspacePath, network: host.network ?? 'bridge', + ...(dockerCase.availableModes ? { availableModes: dockerCase.availableModes } : {}), }, }; const existingIndex = cases.findIndex((item) => item.name === dockerCase.name); @@ -851,15 +852,19 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config return createErrorResponse(ApiErrorCode.OPERATION_FAILED, probe.error || 'container is not adoptable'); } - await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, dockerCase]); + // Persist what the container actually has: the run-mode picker gates on + // HOST CLIs, which is the wrong question for a case whose agents run inside + // a container the host knows nothing about. + const adoptedCase = { ...dockerCase, availableModes: probe.availableModes }; + await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, adoptedCase]); ctx.broadcast(SseEvent.CaseLinked, { - name: dockerCase.name, - path: dockerCase.hostWorkspacePath, + name: adoptedCase.name, + path: adoptedCase.hostWorkspacePath, type: 'docker', }); return { success: true, - data: { case: dockerCase, image: probe.image, availableModes: probe.availableModes }, + data: { case: adoptedCase, image: probe.image, availableModes: probe.availableModes }, }; } ); diff --git a/test/docker-adopted-container.test.ts b/test/docker-adopted-container.test.ts index cd15a443..1c14463c 100644 --- a/test/docker-adopted-container.test.ts +++ b/test/docker-adopted-container.test.ts @@ -194,6 +194,34 @@ describe('adopted container: the Add Case panel id contract', () => { }); }); +describe('adopted container: run modes come from the CONTAINER, not the host', () => { + const ui = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf8'); + /** Slice the method BODY. Anchored on the definition, not a call site: the + * menu opener calls _loadRunModeHistory() ABOVE this definition, so slicing + * between call sites silently yields an empty string and passes nothing. */ + const refreshFn = (src) => { + const start = src.indexOf('_refreshRunModeAvailability(menu) {'); + expect(start).toBeGreaterThan(-1); + return src.slice(start, start + 1600); + }; + + it('gates a docker case on availableModes instead of host CLI probes', () => { + // The sandbox host had codex but no claude while the adopted container had + // claude and no codex; gating on the host hid the only mode that worked. + const fn = refreshFn(ui); + expect(fn).toContain("location === 'docker'"); + expect(fn).toContain('availableModes'); + // Non-docker cases must keep the original host probe (#201). + expect(fn).toContain('this.isCliAvailable(mode)'); + }); + + it('leaves an owned container ungated when nothing was probed', () => { + // Our base image ships every CLI, so an absent list means "unknown", and + // treating unknown as "nothing available" would empty the menu. + expect(refreshFn(ui)).toMatch(/containerModes \?[^:]*:\s*true/); + }); +}); + describe('adopted container: drift is not evaluated', () => { it('reports no drift rather than demanding a recreate we may not perform', async () => { // An adopted container carries no codeman.confighash label, so a real From 8b20f5b1f8aa004bfd484a5df89dd4a32c8b811b Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 21:01:28 -0700 Subject: [PATCH 08/26] fix(docker): probe container CLIs by their real binary name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adoption preflight used the mode name as the binary name. claude, codex, opencode, gemini and pi happen to match, so it never showed — but antigravity ships as `agy` and deepseek as `dsh`, so a container that has either was reported as not having it, and the mode was silently dropped from the case. Adds a MODE_BINARIES map, single-sourced with defaultDockerCommandForMode, which launches those same binaries. Probing and result filtering share one `binaryFor` so the two cannot drift apart. --- src/docker-hosts.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index 7ad11346..d9d2de1b 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -67,9 +67,19 @@ export const DOCKER_ADOPT_PROBE_MODES = [ 'gemini', 'antigravity', 'pi', + 'grok', + 'deepseek', 'shell', ] as const satisfies readonly SessionMode[]; +/** + * The BINARY a mode looks for inside a container. Not always the mode name: + * `antigravity` ships as `agy` and `deepseek` as `dsh`, so probing by mode name + * would report those two as missing on a container that has them. Single source + * with `defaultDockerCommandForMode`, which launches the same binaries. + */ +const MODE_BINARIES: Partial> = { antigravity: 'agy', deepseek: 'dsh' }; + /** Per-case container name prefix. The `case` letters deliberately do NOT matter to * tmux; this is a DOCKER name (`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`), and case names are * already validated `^[a-zA-Z0-9_-]+$`, so `codeman-case-` is always valid. */ @@ -1154,7 +1164,9 @@ export async function probeAdoptableContainer( } // One exec resolves tmux plus every requested CLI, so adoption costs a single // round trip. Binaries are fixed mode names, never user input. - const probes = ['tmux', ...modes.filter((m) => m !== 'shell')]; + const wanted = modes.filter((m) => m !== 'shell'); + const binaryFor = (mode: SessionMode) => MODE_BINARIES[mode] ?? mode; + const probes = ['tmux', ...wanted.map(binaryFor)]; // `; exit 0` is load-bearing: the script's status is its LAST command's, so a // missing final CLI made the whole `sh -lc` exit 1 and the probe reported // "could not exec into the container" for a container that was perfectly fine. @@ -1206,7 +1218,7 @@ export async function probeAdoptableContainer( running: true, image, tmuxPath: 'tmux', - availableModes: modes.filter((m) => m === 'shell' || found.has(m)), + availableModes: modes.filter((m) => m === 'shell' || found.has(binaryFor(m))), workdirExists, }; } catch (err) { From 06e7cbe2860baa30c01eafde6610796e456226d4 Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 21:19:17 -0700 Subject: [PATCH 09/26] fix(docker): probe the container's CLIs live instead of trusting attach time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storing the container's CLIs on the case at attach time left two gaps: a case linked before that field existed has none at all, and a container's CLIs can be installed or removed long after it was linked. A real deployment hit the first one — the host had only codex, the container only claude, and with no stored list the menu still gated on the host and hid the mode that actually worked. The probe now runs when a container case is selected, reusing the existing adopt-preflight endpoint, so there is no new backend surface. Results are cached per case for the page's lifetime, since the menu opens often and the probe is a `docker exec` round trip; a concurrent probe for the same case is deduplicated with an in-flight marker. A failed probe leaves the cache empty, which the caller reads as "unknown" and therefore does not gate. Hiding every mode because one probe failed is worse than offering one that turns out to be missing, which the launch path already refuses with a specific message. The repaint only happens while the menu is still open, so a late answer cannot make the list jump under a user who already closed it. --- src/web/public/session-ui.js | 45 +++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index b43e4288..ebb9eec1 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -483,7 +483,14 @@ Object.assign(CodemanApp.prototype, { // Same source every run* path reads the selected case from. const caseName = document.getElementById('quickStartCase')?.value; const activeCase = caseName ? (this.cases || []).find((c) => c.name === caseName) : null; - const containerModes = activeCase?.location === 'docker' ? activeCase.docker?.availableModes : null; + const isDocker = activeCase?.location === 'docker'; + // Prefer a LIVE probe over the value stored at attach time: a container's + // CLIs can be installed or removed long after the case was linked, and a + // case linked before that field existed has none at all. + const containerModes = isDocker + ? this._dockerCaseModes?.[caseName] || activeCase.docker?.availableModes || null + : null; + if (isDocker && !this._dockerCaseModes?.[caseName]) void this._probeDockerCaseModes(activeCase, menu); for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']) { const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`); if (!btn) continue; @@ -633,6 +640,42 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * Ask the container which CLIs it actually has, and re-gate the menu once the + * answer lands. Cached per case for the page's lifetime: the menu re-opens + * often and the probe is a `docker exec` round trip. + * + * Best-effort by design — an unreachable daemon or a stopped container leaves + * the cache empty, which the caller reads as "unknown" and therefore does not + * gate. Hiding every mode because a probe failed would be worse than showing + * one that turns out to be missing, which the launch path already refuses with + * a specific message. + */ + async _probeDockerCaseModes(activeCase, menu) { + const name = activeCase?.name; + const container = activeCase?.docker?.container; + const hostId = activeCase?.docker?.hostId; + if (!name || !container || !hostId) return; + this._dockerCaseModes = this._dockerCaseModes || {}; + if (this._dockerModeProbeInFlight?.[name]) return; + this._dockerModeProbeInFlight = this._dockerModeProbeInFlight || {}; + this._dockerModeProbeInFlight[name] = true; + try { + const probe = await this._apiJson('/api/docker-cases/adopt-preflight', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hostId, container }), + }); + if (probe?.ok && Array.isArray(probe.availableModes)) { + this._dockerCaseModes[name] = probe.availableModes; + // Only repaint while the menu the user opened is still on screen. + if (menu?.classList.contains('active')) this._refreshRunModeAvailability(menu); + } + } finally { + delete this._dockerModeProbeInFlight[name]; + } + }, + async _loadRunModeHistory() { const container = document.getElementById('runModeHistory'); if (!container) return; From 3685ad85bc7a8dd97c8b7bfe53b9eacca7875847 Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 23:31:28 -0700 Subject: [PATCH 10/26] fix(docker): stop requiring the CLI on the host for a container session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attaching a container, picking claude and hitting Run gave one line — `execvp(3) failed.: No such file or directory` — and the run-mode menu offered every mode. Three separate defects, found on a real deployment. TmuxManager.createSession resolved the CLI directory without distinguishing a docker session, so a host with no claude threw, the catch fell back to a direct PTY, and that PTY exec'd the CLI on the HOST. The failure surfaced as a bare execvp error naming nothing. A docker session runs its CLI inside the container; the host does not need it. All eight modes now sit behind a cliRunsInContainer guard, and whether the container has the CLI is settled by the adoption preflight or the image gate before launch. The running check used a bare double quote and command substitution. The whole chain is embedded in an outer `bash -c "…"`, so the unescaped quote closed that string early and the remainder was re-tokenized. It is now a `grep -qx` pipeline using only the single-quote form every other line in the builder already uses. Claude Code refuses --dangerously-skip-permissions as root. Our base image runs a non-root user, so an owned container never hit this; an adopted container's user belongs to its owner and is frequently root, and keeping the flag killed the pane with a message visible only inside the container. The preflight now reports runsAsRoot and the launch chain drops the flag for it. The menu also showed every mode because the container CLI probe only started when the menu opened. It is warmed when the case is selected instead. --- src/docker-hosts.ts | 18 ++++++++-- src/tmux-manager.ts | 38 +++++++++++++------- src/types/session.ts | 17 +++++---- src/web/public/session-ui.js | 7 ++++ src/web/routes/session-routes.ts | 3 ++ test/docker-adopted-container.test.ts | 52 ++++++++++++++++++++++++++- 6 files changed, 110 insertions(+), 25 deletions(-) diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index d9d2de1b..a6ce66ad 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -160,11 +160,16 @@ export function dockerContainerName(caseName: string): string { } /** Default pane command per CLI mode (mirror of defaultRemoteCommandForMode). */ -export function defaultDockerCommandForMode(mode: SessionMode): string { +export function defaultDockerCommandForMode(mode: SessionMode, runsAsRoot = false): string { const commands: Record = { shell: 'exec bash -l', - // Mirror the LOCAL claude default so the in-container agent runs non-interactively. - claude: 'exec claude --dangerously-skip-permissions', + // Mirror the LOCAL claude default so the in-container agent runs + // non-interactively — EXCEPT as root, where Claude Code refuses the flag + // outright ("cannot be used with root/sudo privileges"). Our base image runs + // a non-root user so an owned container never hits this; an adopted + // container's user belongs to its owner and is frequently root, and keeping + // the flag there kills the pane with a message only visible inside it. + claude: runsAsRoot ? 'exec claude' : 'exec claude --dangerously-skip-permissions', opencode: 'exec opencode', codex: 'exec codex', gemini: 'exec gemini', @@ -1056,6 +1061,8 @@ export interface AdoptedContainerProbe { availableModes?: SessionMode[]; /** Whether the requested working directory exists INSIDE the container. */ workdirExists?: boolean; + /** Whether the container's exec user is root (uid 0). */ + runsAsRoot?: boolean; error?: string; } @@ -1179,6 +1186,10 @@ export async function probeAdoptableContainer( // --workdir ` fails with an OCI chdir error the pane surfaces as a bare // "execvp failed", so it is resolved here into an actionable message. if (containerWorkdir) steps.push(`[ -d ${shellescape(containerWorkdir)} ] && echo __workdir__`); + // Claude Code REFUSES --dangerously-skip-permissions as root. Our own base + // image runs a non-root user so an owned container never hits it; an adopted + // container's user belongs to its owner and is frequently root. + steps.push(`[ "$(id -u)" = 0 ] && echo __root__`); const script = `${steps.join('; ')}; exit 0`; try { const { stdout } = await execFileAsync( @@ -1220,6 +1231,7 @@ export async function probeAdoptableContainer( tmuxPath: 'tmux', availableModes: modes.filter((m) => m === 'shell' || found.has(binaryFor(m))), workdirExists, + runsAsRoot: found.has('__root__'), }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 3e0c96cb..f177d35a 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -1290,7 +1290,8 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string { const dkrName = dockerTmuxSessionName(sessionId); const sid = sessionId.slice(0, 8); - let modeCommand = docker.commands?.[mode as DockerCommandMode] || defaultDockerCommandForMode(mode); + let modeCommand = + docker.commands?.[mode as DockerCommandMode] || defaultDockerCommandForMode(mode, !!docker.runsAsRoot); if (mode === 'claude') { modeCommand = claudeDockerPaneCommand(modeCommand, sessionId, resumeSessionId); } else if (resumeSessionId) { @@ -1327,10 +1328,10 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string { const startFailMsg = shellescape(`Codeman: container ${docker.containerName} failed to start (docker daemon down?)`); const notFoundMsg = shellescape( - `Codeman: container ${docker.containerName} not found. Adopted containers are never created by Codeman — start it yourself, then reopen this session.` + `Codeman: container ${docker.containerName} not found. Adopted containers are never created by Codeman - start it yourself, then reopen this session.` ); const notRunningMsg = shellescape( - `Codeman: container ${docker.containerName} is not running. Codeman never starts a container it does not own — start it yourself, then reopen this session.` + `Codeman: container ${docker.containerName} is not running. Codeman never starts a container it does not own - start it yourself, then reopen this session.` ); const imageCheck = adopted @@ -1340,8 +1341,13 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string { const ensure = adopted ? `${base} inspect ${name} >/dev/null 2>&1 || { echo ${notFoundMsg}; exit 1; }` : `${base} inspect ${name} >/dev/null 2>&1 || ${base} ${createArgs}`; + // ⚠️ No double quotes and no `$(…)` here. This whole chain is embedded in an + // outer `bash -c "…"`, so an unescaped `"` closes that string early, the rest + // is re-tokenized, and tmux fails to exec with a bare `execvp(3) failed`. A + // `grep -qx` pipeline reads the same answer using only the single-quoted form + // every other line in this builder already uses. const start = adopted - ? `[ "$(${base} inspect -f '{{.State.Running}}' ${name} 2>/dev/null)" = true ] || { echo ${notRunningMsg}; exit 1; }` + ? `${base} inspect -f ${shellescape('{{.State.Running}}')} ${name} 2>/dev/null | grep -qx true || { echo ${notRunningMsg}; exit 1; }` : `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`; // Seed writable credential config from read-only host mounts ONCE per container // (guarded by [ -e ] so reconnects never clobber in-container config; `cp -a` for @@ -2115,29 +2121,37 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // from the resolvers (formatCliNotFoundMessage) so the error names WHERE it // looked — server PATH, login shell, checked directories — instead of just // asserting the CLI is missing (the classic systemd/launchd PATH trap). + // + // ⚠️ A DOCKER session runs its CLI INSIDE the container, so the host does not + // need it at all. Demanding it here threw for a host without the binary, the + // catch fell back to a direct PTY, and that PTY tried to exec the CLI on the + // HOST — surfacing as a bare `execvp(3) failed: No such file or directory` + // with nothing pointing at the real cause. The container's own CLIs are + // verified by the adoption preflight / image gate before launch instead. const { pathExport, dir: cliDir } = this.buildPathExport(mode); - if (mode === 'claude' && !cliDir) { + const cliRunsInContainer = !!docker; + if (!cliRunsInContainer && mode === 'claude' && !cliDir) { throw new Error(getClaudeNotFoundMessage()); } - if (mode === 'opencode' && !cliDir) { + if (!cliRunsInContainer && mode === 'opencode' && !cliDir) { throw new Error(getOpenCodeNotFoundMessage()); } - if (mode === 'codex' && !cliDir) { + if (!cliRunsInContainer && mode === 'codex' && !cliDir) { throw new Error(getCodexNotFoundMessage()); } - if (mode === 'gemini' && !cliDir) { + if (!cliRunsInContainer && mode === 'gemini' && !cliDir) { throw new Error(getGeminiNotFoundMessage()); } - if (mode === 'antigravity' && !cliDir) { + if (!cliRunsInContainer && mode === 'antigravity' && !cliDir) { throw new Error(getAntigravityNotFoundMessage()); } - if (mode === 'pi' && !cliDir) { + if (!cliRunsInContainer && mode === 'pi' && !cliDir) { throw new Error(getPiNotFoundMessage()); } - if (mode === 'deepseek' && !cliDir) { + if (!cliRunsInContainer && mode === 'deepseek' && !cliDir) { throw new Error(getDeepSeekNotFoundMessage()); } - if (mode === 'grok' && !cliDir) { + if (!cliRunsInContainer && mode === 'grok' && !cliDir) { throw new Error(getGrokNotFoundMessage()); } diff --git a/src/types/session.ts b/src/types/session.ts index 21e117b9..d3fe4565 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -47,15 +47,7 @@ export type ClaudeMode = 'dangerously-skip-permissions' | 'auto' | 'normal' | 'a /** Session mode: which CLI backend a session runs */ export type SessionMode = - | 'claude' - | 'shell' - | 'opencode' - | 'codex' - | 'gemini' - | 'antigravity' - | 'pi' - | 'grok' - | 'deepseek'; + 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek'; export type RemoteCommandMode = Extract< SessionMode, @@ -302,6 +294,13 @@ export interface SessionDocker { extraExecArgs?: string[]; /** Stable hash of the drift-relevant create args (recreate-on-drift detection). */ configHash?: string; + /** + * Whether the container's exec user is root. Claude Code REFUSES + * `--dangerously-skip-permissions` as root, and an adopted container's user + * belongs to its owner, so the flag is omitted rather than letting the pane + * die with a message only visible inside the container. + */ + runsAsRoot?: boolean; /** * Mirror of `DockerCase.owned`, flattened onto the live session so every * lifecycle decision (launch chain, drift, stop, remove) can see it without diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index ebb9eec1..dc4503bd 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -187,6 +187,13 @@ Object.assign(CodemanApp.prototype, { this.closeCasePicker(); this.updateDirDisplayForCase(select.value); this.updateMobileCaseLabel(select.value); + // Warm the container's CLI list HERE rather than when the run menu opens. + // The probe is a `docker exec` round trip, so gating it on the menu meant the + // menu painted every mode first and only narrowed a moment later — which + // reads as "it shows all of them" and lets a mode be picked that the + // container does not have. + const picked = (this.cases || []).find((c) => c.name === select.value); + if (picked?.location === 'docker') void this._probeDockerCaseModes(picked, null); if (save) { this.saveLastUsedCase(select.value); } diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 36ccea31..3fc80c94 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -2968,6 +2968,9 @@ export function registerSessionRoutes( if (!probe.ok) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, probe.error || 'container is not usable'); } + // The probe already exec'd into the container; carry its facts onto the + // live session so the launch chain does not have to re-ask. + sessionDocker.runsAsRoot = probe.runsAsRoot; if (mode !== 'shell' && !probe.availableModes?.includes(mode)) { return createErrorResponse( ApiErrorCode.OPERATION_FAILED, diff --git a/test/docker-adopted-container.test.ts b/test/docker-adopted-container.test.ts index 1c14463c..2d9c6c1b 100644 --- a/test/docker-adopted-container.test.ts +++ b/test/docker-adopted-container.test.ts @@ -12,6 +12,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { + defaultDockerCommandForMode, toSessionDocker, isAdoptedContainer, removeDockerContainer, @@ -113,6 +114,18 @@ describe('adopted container: the launch chain never mutates lifecycle', () => { expect(adopted).toMatch(/not running.*never starts a container it does not own/i); }); + it('uses no double quote and no command substitution in the launch chain', () => { + // The whole chain is embedded in an outer `bash -c "…"`. An unescaped `"` + // closes that string early, the remainder is re-tokenized, and tmux fails to + // exec with a bare `execvp(3) failed: No such file or directory` — no hint + // that the command was ever malformed. `$(…)` is banned with it because it + // is then evaluated by the wrong shell at the wrong time. + expect(adopted).not.toContain('"'); + expect(adopted).not.toContain('$('); + // Every other line already quotes with the single-quote helper. + expect(adopted).toContain("grep -qx true"); + }); + it('skips the base-image gate, which describes an image adoption never uses', () => { expect(owned).toContain('image inspect'); expect(adopted).not.toContain('image inspect'); @@ -129,6 +142,43 @@ describe('adopted container: the launch chain never mutates lifecycle', () => { }); }); +describe('adopted container: claude as root', () => { + it('drops --dangerously-skip-permissions when the container runs as root', () => { + // Claude Code refuses the flag as root ("cannot be used with root/sudo + // privileges"), so keeping it kills the pane with a message only visible + // inside the container. Our base image runs a non-root user, which is why an + // owned container never hit this. + expect(defaultDockerCommandForMode('claude', true)).toBe('exec claude'); + expect(defaultDockerCommandForMode('claude', false)).toContain('--dangerously-skip-permissions'); + expect(defaultDockerCommandForMode('claude')).toContain('--dangerously-skip-permissions'); + }); + + it('leaves every other mode unchanged as root', () => { + for (const mode of ['codex', 'shell', 'pi'] as const) { + expect(defaultDockerCommandForMode(mode, true)).toBe(defaultDockerCommandForMode(mode, false)); + } + }); +}); + +describe('adopted container: the host is not required to have the CLI', () => { + const src = readFileSync(new URL('../src/tmux-manager.ts', import.meta.url), 'utf8'); + + it('skips every host CLI requirement for a docker session', () => { + // A docker session runs its CLI inside the container. Demanding it on the + // host threw, the catch fell back to a direct PTY, and that PTY tried to + // exec the CLI on the HOST — surfacing as a bare `execvp(3) failed` with + // nothing naming the real cause. + const guarded = src.match(/!cliRunsInContainer && mode === '/g) || []; + const unguarded = src.match(/\n if \(mode === '[a-z]+' && !cliDir\)/g) || []; + expect(guarded.length).toBeGreaterThanOrEqual(7); + expect(unguarded).toHaveLength(0); + }); + + it('derives the flag from the docker metadata the session already carries', () => { + expect(src).toContain('const cliRunsInContainer = !!docker;'); + }); +}); + describe('adopted container: mutating verbs fail closed at the builder', () => { const docker = toSessionDocker(HOST, caseFor(false)); @@ -202,7 +252,7 @@ describe('adopted container: run modes come from the CONTAINER, not the host', ( const refreshFn = (src) => { const start = src.indexOf('_refreshRunModeAvailability(menu) {'); expect(start).toBeGreaterThan(-1); - return src.slice(start, start + 1600); + return src.slice(start, start + 2000); }; it('gates a docker case on availableModes instead of host CLI probes', () => { From 23ab2e77fd01394a47b9c2370042cf2d46d9a3c8 Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 23:31:28 -0700 Subject: [PATCH 11/26] fix(files): give the picker a root when the server runs as root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Link Existing's Browse did nothing: GET /api/filesystem/browse answered 403 "No filesystem browse roots are available". Two rules were fighting. /root is a default blocked tree in the attachment guard, and Codeman running as root — containers, plenty of servers — makes homedir() exactly /root, so the picker's own allowlisted Home root was blocked; the other candidates live under it or do not exist. The root list came out empty and there was nothing the user could open. The blocked trees exist to keep ~/.ssh and friends out of reach, not to seal off the user's own home. Only trees that would swallow a configured root whole are dropped now: /root goes when Home is it (or sits inside it), /etc holds no configured root and is untouched. Secrets stay protected — isSensitivePath independently matches .ssh/, .env and credentials* at any depth, and it is what the directory probe asks about. ⚠️ Navigation must reuse the same narrowed list the roots were chosen with. Handing the raw trees downstream admits a root and then refuses every path inside it, which reads as a picker that opens and does nothing. --- src/web/routes/file-routes.ts | 53 ++++++++++++++++++++++++++++-- test/file-picker-root-home.test.ts | 53 ++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 test/file-picker-root-home.test.ts diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index 20a567f3..adffc9dd 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -38,7 +38,7 @@ import { import { generateFirstPageThumbnail } from '../../document-thumbnailer.js'; import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js'; import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js'; -import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js'; +import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from '../../config/attachment-guard.js'; import { isMultiUserMode, userSpacePath } from '../../config/multiuser.js'; import { CASES_DIR, @@ -425,6 +425,40 @@ function getFilesystemPreviewKind(fileName: string): FilesystemPreviewKind | und return undefined; } +/** + * Blocked trees, minus any tree that would swallow a configured picker root + * whole. + * + * `/root` is a default blocked tree, and Codeman running as root (containers, + * plenty of servers) makes `homedir()` exactly `/root` — so the picker's own + * allowlisted Home root was blocked by the attachment guard, every other + * candidate lives under it or does not exist, and the endpoint answered 403 + * "No filesystem browse roots are available" with no root the user could reach. + * + * Dropping the tree does NOT expose secrets: `isSensitivePath` independently + * matches `.ssh/`, `.env`, `credentials*` and friends at any depth, and it is + * what the directory probe below asks about. Trees with no configured root + * beneath them (`/etc`) are untouched. + */ +function pickerBlockedTrees(blockedTrees: readonly string[], roots: readonly string[]): readonly string[] { + if (roots.length === 0) return blockedTrees; + return blockedTrees.filter((tree) => !roots.some((root) => isUnderTree(root, tree))); +} + +/** Resolve candidate roots to realpaths, dropping the ones that do not exist. */ +function resolveCandidateRootPaths(candidates: ReadonlyArray<{ path: string }>): string[] { + const out: string[] = []; + for (const candidate of candidates) { + if (!isAbsolute(candidate.path)) continue; + try { + out.push(realpathSync(candidate.path)); + } catch { + // Optional roots (for example /mnt/d on non-WSL hosts) are omitted. + } + } + return out; +} + function isBlockedPickerPath(path: string, blockedTrees: readonly string[], directory = false): boolean { if (isBlockedAttachmentPath(path, blockedTrees)) return true; // The shared sensitive-path matcher describes file locations such as @@ -491,13 +525,14 @@ async function resolveFilesystemPickerRoots( } const guard = await loadAttachmentGuardConfig(); + const trees = pickerBlockedTrees(guard.blockedTrees, resolveCandidateRootPaths(candidates)); const roots: FilesystemBrowseRoot[] = []; const seen = new Set(); for (const candidate of candidates) { if (!isAbsolute(candidate.path)) continue; try { const resolved = realpathSync(candidate.path); - if (seen.has(resolved) || isBlockedPickerPath(resolved, guard.blockedTrees, true)) continue; + if (seen.has(resolved) || isBlockedPickerPath(resolved, trees, true)) continue; const stat = await fs.stat(resolved); if (!stat.isDirectory()) continue; seen.add(resolved); @@ -556,7 +591,19 @@ async function resolveFilesystemPickerPath( } const guard = await loadAttachmentGuardConfig(); - return { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees: guard.blockedTrees }; + // Navigation must use the SAME narrowed list the roots were selected with. + // Handing the raw trees down here would admit a root and then refuse every + // path inside it, which reads as a picker that opens and then does nothing. + return { + candidatePath, + resolvedPath, + roots, + matchingRoot, + blockedTrees: pickerBlockedTrees( + guard.blockedTrees, + roots.map((root) => root.path) + ), + }; } function appendDownloadFlag(url: string): string { diff --git a/test/file-picker-root-home.test.ts b/test/file-picker-root-home.test.ts new file mode 100644 index 00000000..c726237f --- /dev/null +++ b/test/file-picker-root-home.test.ts @@ -0,0 +1,53 @@ +/** + * @fileoverview The picker must offer a root when Codeman runs as root. + * + * `/root` is a DEFAULT blocked tree in the attachment guard, and Codeman running + * as root — containers, plenty of servers — makes `homedir()` exactly `/root`. + * The picker's own allowlisted Home root was therefore blocked by the guard, + * every other candidate lives under it or does not exist, and the endpoint + * answered 403 "No filesystem browse roots are available" with nothing the user + * could open. The fix drops only the trees that would swallow a configured root + * whole; `isSensitivePath` still guards what is inside. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { isBlockedAttachmentPath, isUnderTree } from '../src/config/attachment-guard.js'; + +const TREES = ['/root', '/etc']; + +/** Mirror of pickerBlockedTrees in file-routes.ts. */ +const narrow = (trees: readonly string[], roots: readonly string[]) => + roots.length === 0 ? trees : trees.filter((t) => !roots.some((r) => isUnderTree(r, t))); + +describe('file picker roots when the server runs as root', () => { + it('drops the tree that would swallow the configured Home root', () => { + expect(narrow(TREES, ['/root'])).toEqual(['/etc']); + }); + + it('keeps trees that hold no configured root', () => { + expect(narrow(TREES, ['/home/alice'])).toEqual(['/root', '/etc']); + expect(narrow(TREES, [])).toEqual(['/root', '/etc']); + }); + + it('also frees a root nested under the blocked tree', () => { + // ~/codeman-cases is /root/codeman-cases when running as root. + expect(narrow(TREES, ['/root/codeman-cases'])).toEqual(['/etc']); + }); + + it('still refuses secrets inside the freed tree', () => { + const trees = narrow(TREES, ['/root']); + for (const p of ['/root/.ssh/id_rsa', '/root/.aws/credentials', '/root/app/.env']) { + expect(isBlockedAttachmentPath(p, trees)).toBe(true); + } + // …while ordinary files under it become reachable, which is the point. + expect(isBlockedAttachmentPath('/root/projects/readme.md', trees)).toBe(false); + }); + + it('navigation reuses the same narrowed list the roots were chosen with', () => { + // Handing the raw trees to navigation would admit a root and then refuse + // every path inside it — a picker that opens and then does nothing. + const src = readFileSync(new URL('../src/web/routes/file-routes.ts', import.meta.url), 'utf8'); + expect(src.match(/pickerBlockedTrees\(/g)?.length).toBeGreaterThanOrEqual(3); + expect(src).not.toMatch(/blockedTrees:\s*guard\.blockedTrees/); + }); +}); From 5452ad5c5ae314bec730a21688f3b74a443cdef4 Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 23:31:28 -0700 Subject: [PATCH 12/26] feat(docker): add a folder picker to both path fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both paths in the adoption form had to be typed. Each gets a Browse button using the same path-input-group markup Link Existing uses, so the two look and behave alike. What they can browse differs, and that is the point. The host workspace path reuses the existing host picker. The container workdir cannot: an adopted container has nothing mounted at a matching host path, so a host listing would be a different filesystem — and getting this field wrong is the source of the opaque OCI chdir error at launch, which makes it the field that most needs to be clickable. Adds a read-only POST /api/docker-cases/browse: one `ls` through docker exec, no writes, no lifecycle, path shell-escaped like every other value. `ls -Ap` marks directories with a trailing slash and keeps names with spaces intact. PathPicker takes an optional fetchListing source rather than being forked: the container variant only swaps where the rows come from, and reuses the rendering, navigation, Up and Choose/Select unchanged. --- src/docker-hosts.ts | 54 ++++++++++++++++++++++++++ src/web/public/index.html | 10 ++++- src/web/public/keyboard-accessory.js | 10 +++-- src/web/public/session-ui.js | 56 +++++++++++++++++++++++++++ src/web/routes/case-routes.ts | 26 ++++++++++++- src/web/schemas.ts | 16 ++++++++ test/docker-adopted-container.test.ts | 34 ++++++++++++++++ 7 files changed, 200 insertions(+), 6 deletions(-) diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index a6ce66ad..e2801b55 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -1239,6 +1239,60 @@ export async function probeAdoptableContainer( } } +/** One directory listing from INSIDE a container, shaped like the host picker's. */ +export interface DockerBrowseResult { + path: string; + parent: string | null; + entries: Array<{ name: string; path: string; type: 'directory' | 'file' }>; + error?: string; +} + +/** + * List a directory INSIDE a container, for the adoption form's container-workdir + * picker. The host filesystem picker cannot serve this: the path lives in the + * container, and for an adopted container nothing is mounted at a matching host + * location, so the user would otherwise be typing a path blind. + * + * Read-only: one `ls` through `docker exec`, no writes, no lifecycle. The path + * is shell-escaped like every other value this module interpolates, and output + * is parsed as NUL-free lines with a leading type marker so a filename with + * spaces survives. + */ +export async function browseInContainer( + docker: Pick, + path: string +): Promise { + const target = path && path.startsWith('/') ? path : '/'; + const parent = target === '/' ? null : target.replace(/\/+$/, '').split('/').slice(0, -1).join('/') || '/'; + if (IS_TEST_MODE) return { path: target, parent, entries: [] }; + const argv = dockerEngineArgv(docker); + // `-p` marks directories with a trailing slash; `-A` shows dotfiles but not + // the . and .. entries the picker navigates with its own Up control. + const script = `cd ${shellescape(target)} 2>/dev/null && ls -Ap 2>/dev/null || echo __ERR__`; + try { + const { stdout } = await execFileAsync( + argv[0], + [...argv.slice(1), 'exec', docker.containerName, 'sh', '-lc', script], + { timeout: DOCKER_PROBE_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 } + ); + if (stdout.includes('__ERR__')) return { path: target, parent, entries: [], error: 'Not a readable directory' }; + const base = target.endsWith('/') ? target : `${target}/`; + const entries = stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((name) => { + const isDir = name.endsWith('/'); + const clean = isDir ? name.slice(0, -1) : name; + return { name: clean, path: `${base}${clean}`, type: (isDir ? 'directory' : 'file') as 'directory' | 'file' }; + }) + .sort((a, b) => Number(b.type === 'directory') - Number(a.type === 'directory') || a.name.localeCompare(b.name)); + return { path: target, parent, entries }; + } catch (err) { + return { path: target, parent, entries: [], error: err instanceof Error ? err.message : String(err) }; + } +} + /** * Resolve the host's IP on the default docker bridge (the address a container * reaches as `host.docker.internal`), so the server can bind a hooks-only listener diff --git a/src/web/public/index.html b/src/web/public/index.html index f83a17d9..6df870b3 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2844,7 +2844,10 @@
- +
+ + +
A path that already exists inside the container. Adoption mounts nothing, so this need not match the host workspace path.
@@ -2854,7 +2857,10 @@
- +
+ + +
Absolute HOST directory, bind-mounted into the container. Codeman scaffolds CLAUDE.md + hooks into it.
diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index 35b67c3e..7ec9a890 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -185,9 +185,13 @@ const PathPicker = { if (this._options.sessionId) params.set('sessionId', this._options.sessionId); if (this._showHidden) params.set('showHidden', 'true'); try { - const response = await fetch(`/api/filesystem/browse?${params.toString()}`); - const result = await response.json(); - if (!response.ok || !result.success) throw new Error(result.error || 'Failed to browse this folder'); + // A caller may supply its own source (the container-workdir picker browses + // INSIDE a container, which the host filesystem endpoint cannot answer). + // It returns the same shape, so everything below is unchanged. + const result = this._options.fetchListing + ? await this._options.fetchListing(path) + : await (await fetch(`/api/filesystem/browse?${params.toString()}`)).json(); + if (!result?.success) throw new Error(result?.error || 'Failed to browse this folder'); if (!this.overlay || loadSequence !== this._loadSequence) return; this.render(result.data); } catch (error) { diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index dc4503bd..15109b16 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2895,6 +2895,62 @@ Object.assign(CodemanApp.prototype, { }); }, + /** HOST workspace directory — the same picker Link Existing uses. */ + openDockerWorkspacePathPicker() { + const pathInput = document.getElementById('dockerWorkspacePath'); + PathPicker.open({ + title: 'Select Host Workspace Folder', + initialPath: pathInput.value.trim(), + directoriesOnly: true, + onSelect: (path) => { + pathInput.value = path; + const nameInput = document.getElementById('dockerCaseName'); + if (nameInput && !nameInput.value.trim()) { + const folder = path.split('/').filter(Boolean).pop() || ''; + if (/^[a-zA-Z0-9_-]+$/.test(folder)) nameInput.value = folder; + } + }, + }); + }, + + /** + * Container workdir. Browses INSIDE the container, because for an adopted + * container nothing is mounted at a matching host path — the host picker would + * be listing a different filesystem, and typing this field blind is exactly + * what makes the launch fail with an OCI chdir error. + */ + openDockerWorkdirPicker() { + const pathInput = document.getElementById('dockerAdoptWorkdir'); + const container = document.getElementById('dockerContainerName')?.value.trim(); + const hostId = document.getElementById('dockerHostId')?.value.trim() || 'local'; + if (!container) { + this.showToast('Enter the container name first', 'error'); + return; + } + PathPicker.open({ + title: `Select Folder Inside ${container}`, + initialPath: pathInput.value.trim() || '/', + directoriesOnly: true, + fetchListing: async (path) => { + const data = await this._apiJson('/api/docker-cases/browse', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hostId, container, path: path || '/' }), + }); + if (!data) return { success: false, error: `Could not read ${container}. Is it running?` }; + if (data.error) return { success: false, error: data.error }; + // Shape it like the host endpoint: one root, so Up/Location behave. + return { + success: true, + data: { ...data, root: '/', roots: [{ label: container, path: '/' }], truncated: false }, + }; + }, + onSelect: (path) => { + pathInput.value = path; + }, + }); + }, + async linkRemoteCase() { const name = document.getElementById('remoteCaseName').value.trim(); const remotePath = document.getElementById('remoteCasePath').value.trim(); diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 11ac46e7..5aa4a2f0 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -26,6 +26,7 @@ import { DockerCaseLinkSchema, DockerCaseAdoptSchema, DockerAdoptPreflightSchema, + DockerBrowseSchema, DockerHostSchema, DockerExportSchema, DockerImportSchema, @@ -70,6 +71,7 @@ import { dockerDisplayPath, probeAdoptableContainer, listDockerContainers, + browseInContainer, DOCKER_ADOPT_PROBE_MODES, readDockerCases, readDockerHosts, @@ -78,7 +80,7 @@ import { writeDockerCases, writeDockerHosts, } from '../../docker-hosts.js'; -import type { AdoptedContainerProbe, DockerContainerInfo } from '../../docker-hosts.js'; +import type { AdoptedContainerProbe, DockerBrowseResult, DockerContainerInfo } from '../../docker-hosts.js'; import { buildDockerRemoveCommand } from '../../tmux-manager.js'; import { checkRemoteTmuxAvailable, @@ -895,6 +897,28 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config } ); + /** + * Browse a directory INSIDE a container, for the adoption form's + * container-workdir picker. The host picker cannot answer this: for an adopted + * container nothing is mounted at a matching host path, so the field would + * otherwise be typed blind. Read-only — one `ls` through `docker exec`. + */ + app.post('/api/docker-cases/browse', async (req): Promise> => { + const body = parseBody(DockerBrowseSchema, req.body); + const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === body.hostId); + if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found'); + const result = await browseInContainer( + { + engine: host.engine ?? 'docker', + context: host.context, + daemonHost: host.daemonHost, + containerName: body.container, + }, + body.path || '/' + ); + return { success: true, data: result }; + }); + app.post('/api/docker-cases/adopt-preflight', async (req): Promise> => { const body = parseBody(DockerAdoptPreflightSchema, req.body); const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === body.hostId); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 65ef1395..ffffd388 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -844,6 +844,22 @@ export const DockerAdoptPreflightSchema = z.object({ .optional(), }); +/** Read-only directory listing inside a container (adoption workdir picker). */ +export const DockerBrowseSchema = z.object({ + hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'), + container: z + .string() + .min(2) + .max(128) + .regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid container name'), + path: z + .string() + .max(2000) + .regex(/^\//, 'Path must be absolute') + .regex(NO_SHELL_META, 'Invalid characters in path') + .optional(), +}); + export const DockerExportSchema = z.object({ mode: z.enum(['full', 'workspace']).optional(), }); diff --git a/test/docker-adopted-container.test.ts b/test/docker-adopted-container.test.ts index 2d9c6c1b..36f86780 100644 --- a/test/docker-adopted-container.test.ts +++ b/test/docker-adopted-container.test.ts @@ -272,6 +272,40 @@ describe('adopted container: run modes come from the CONTAINER, not the host', ( }); }); +describe('adopted container: both path fields get a folder picker', () => { + const html = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf8'); + const ui = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf8'); + const picker = readFileSync(new URL('../src/web/public/keyboard-accessory.js', import.meta.url), 'utf8'); + + it('wires a Browse button to each of the two paths', () => { + expect(html).toContain('app.openDockerWorkspacePathPicker()'); + expect(html).toContain('app.openDockerWorkdirPicker()'); + // Same markup Link Existing uses, so the two look and behave alike. + expect(html.match(/path-input-browse/g)?.length).toBeGreaterThanOrEqual(3); + }); + + it('browses the CONTAINER for the container workdir, not the host', () => { + // For an adopted container nothing is mounted at a matching host path, so a + // host listing would be a different filesystem — and typing this field blind + // is what makes the launch fail with an OCI chdir error. + const fn = ui.slice(ui.indexOf('openDockerWorkdirPicker()'), ui.indexOf('async linkRemoteCase()')); + expect(fn).toContain('/api/docker-cases/browse'); + expect(fn).not.toContain('/api/filesystem/browse'); + expect(fn).toContain('fetchListing'); + }); + + it('keeps the host picker for the host workspace path', () => { + const fn = ui.slice(ui.indexOf('openDockerWorkspacePathPicker()'), ui.indexOf('openDockerWorkdirPicker()')); + expect(fn).toContain('PathPicker.open'); + expect(fn).not.toContain('fetchListing'); + }); + + it('reuses one PathPicker via an optional source rather than forking it', () => { + expect(picker).toContain('this._options.fetchListing'); + expect(picker).toContain('/api/filesystem/browse'); + }); +}); + describe('adopted container: drift is not evaluated', () => { it('reports no drift rather than demanding a recreate we may not perform', async () => { // An adopted container carries no codeman.confighash label, so a real From 8e5e207386f18c820679659466b58664a61c2a5f Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 23:31:28 -0700 Subject: [PATCH 13/26] fix(docker): send the probe body as an object, and explain an unreachable container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The run menu still offered every mode for an attached container. The browser's actual request showed why: POST /api/docker-cases/adopt-preflight -> 400 {"error":"Invalid input: expected object, received string"} _api serializes `body` and sets Content-Type itself, and three call sites each passed an already-stringified body, so it was encoded twice and the server saw a JSON string where it expects an object. curl was fine throughout, so nothing in the server logs pointed at it. Also fixes the design defect underneath: a failed probe fell through to "do not gate", which silently offered every mode. When the container has been recreated, is stopped, or the engine is unreachable, the user sees claude, clicks it, and it can only fail — with the reason visible nowhere. A failed probe now hides every agent mode (Shell needs no CLI and stays) and shows the server's own reason at the top of the menu. Two static guards switched from a character window to brace matching. They sliced between two call sites, and _loadRunModeHistory's call appears above its definition, so the slice came out empty and the assertion verified nothing — the same trap twice in one file. --- src/types/session.ts | 10 ++++- src/web/public/session-ui.js | 52 +++++++++++++++++++++----- src/web/public/styles.css | 12 ++++++ src/web/routes/case-routes.ts | 4 +- src/web/routes/session-routes.ts | 15 +++++--- src/web/routes/system-routes.ts | 5 ++- test/docker-adopted-container.test.ts | 54 ++++++++++++++++++++++++++- 7 files changed, 131 insertions(+), 21 deletions(-) diff --git a/src/types/session.ts b/src/types/session.ts index d3fe4565..39910bfc 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -47,7 +47,15 @@ export type ClaudeMode = 'dangerously-skip-permissions' | 'auto' | 'normal' | 'a /** Session mode: which CLI backend a session runs */ export type SessionMode = - 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek'; + | 'claude' + | 'shell' + | 'opencode' + | 'codex' + | 'gemini' + | 'antigravity' + | 'pi' + | 'grok' + | 'deepseek'; export type RemoteCommandMode = Extract< SessionMode, diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 15109b16..11fd15a0 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -498,14 +498,18 @@ Object.assign(CodemanApp.prototype, { ? this._dockerCaseModes?.[caseName] || activeCase.docker?.availableModes || null : null; if (isDocker && !this._dockerCaseModes?.[caseName]) void this._probeDockerCaseModes(activeCase, menu); + // An unreachable container hides every agent mode and explains why, instead + // of silently offering modes that cannot start. + const probeError = isDocker ? this._dockerCaseProbeError?.[caseName] : null; for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']) { const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`); if (!btn) continue; let available; - if (activeCase?.location === 'docker') available = containerModes ? containerModes.includes(mode) : true; + if (isDocker) available = probeError ? false : containerModes ? containerModes.includes(mode) : true; else available = this.isCliAvailable(mode); btn.style.display = available ? 'flex' : 'none'; } + this._renderRunModeNotice(menu, probeError); // DeepSeek is the one mode whose availability has two halves: `dsh` can be // perfectly installed while no pane-capable profile exists, because DeepSeek // ships no terminal front door. In that state the honest offer is "add one", @@ -647,6 +651,27 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * One-line explanation at the top of the run menu. Only a container that could + * not be read produces one; everything else removes it, so a stale reason can + * never outlive the condition that caused it. + */ + _renderRunModeNotice(menu, message) { + if (!menu) return; + let el = menu.querySelector('.run-mode-notice'); + if (!message) { + el?.remove(); + return; + } + if (!el) { + el = document.createElement('div'); + el.className = 'run-mode-notice'; + menu.prepend(el); + } + // Server-supplied text: set it, never parse it as markup. + el.textContent = message; + }, + /** * Ask the container which CLIs it actually has, and re-gate the menu once the * answer lands. Cached per case for the page's lifetime: the menu re-opens @@ -668,16 +693,27 @@ Object.assign(CodemanApp.prototype, { this._dockerModeProbeInFlight = this._dockerModeProbeInFlight || {}; this._dockerModeProbeInFlight[name] = true; try { + // ⚠️ _api serializes `body` and sets Content-Type itself. Passing an + // already-stringified body double-encodes it and the server rejects a + // JSON string where it expects an object (400 INVALID_INPUT). const probe = await this._apiJson('/api/docker-cases/adopt-preflight', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ hostId, container }), + body: { hostId, container }, }); if (probe?.ok && Array.isArray(probe.availableModes)) { this._dockerCaseModes[name] = probe.availableModes; - // Only repaint while the menu the user opened is still on screen. - if (menu?.classList.contains('active')) this._refreshRunModeAvailability(menu); + delete this._dockerCaseProbeError?.[name]; + } else { + // A container that cannot be probed — recreated, stopped, engine down — + // must NOT fall through to "show everything". Offering claude on a + // container that is not running is a click that can only fail, with the + // reason visible nowhere. Record the reason and say it in the menu. + this._dockerCaseProbeError = this._dockerCaseProbeError || {}; + this._dockerCaseProbeError[name] = probe?.error || `Could not read container "${container}".`; + delete this._dockerCaseModes[name]; } + // Only repaint while the menu the user opened is still on screen. + if (menu?.classList.contains('active')) this._refreshRunModeAvailability(menu); } finally { delete this._dockerModeProbeInFlight[name]; } @@ -2934,8 +2970,7 @@ Object.assign(CodemanApp.prototype, { fetchListing: async (path) => { const data = await this._apiJson('/api/docker-cases/browse', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ hostId, container, path: path || '/' }), + body: { hostId, container, path: path || '/' }, }); if (!data) return { success: false, error: `Could not read ${container}. Is it running?` }; if (data.error) return { success: false, error: data.error }; @@ -3109,8 +3144,7 @@ Object.assign(CodemanApp.prototype, { // reason it failed, so the envelope is unwrapped by hand here. const probe = await this._apiJson('/api/docker-cases/adopt-preflight', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ hostId, container, ...(containerWorkdir ? { containerWorkdir } : {}) }), + body: { hostId, container, ...(containerWorkdir ? { containerWorkdir } : {}) }, }); if (!statusEl) return; if (!probe) { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 7ca4edc3..50912082 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -16608,6 +16608,18 @@ html[data-tab-orientation='vertical'] .home-sessions { panel stays exactly as it was until the checkbox is ticked. Rules carry `!important` because the adapter block above paints `.form-row` as a row card and `details.advanced-options` has its own display. */ +/* Run-menu notice: why a container case is offering no agent modes. Lives at the + top of the menu so the reason is where the missing entries would have been. */ +.run-mode-notice { + padding: 8px 12px; + margin: 0 0 4px; + font-size: 12px; + line-height: 1.45; + color: var(--text-muted, #9aa0a6); + border-bottom: 1px solid var(--border, #333); + white-space: normal; +} + #createCaseModal .docker-adopt-only { display: none !important; } diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 5aa4a2f0..2024d994 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -1175,7 +1175,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config engine: result.manifest.engine, image: result.importedImage ?? result.manifest.image, network: (['bridge', 'none', 'custom'].includes(result.manifest.network) ? result.manifest.network : 'bridge') as - 'bridge' | 'none' | 'custom', + | 'bridge' + | 'none' + | 'custom', }; await writeDockerHosts( CODEMAN_CONFIG_DIR, diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 3fc80c94..c50ddcb3 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -944,8 +944,9 @@ export function registerSessionRoutes( } } if (body.mode === 'antigravity') { - const { isAntigravityAvailable, getAntigravityNotFoundMessage } = - await import('../../utils/antigravity-cli-resolver.js'); + const { isAntigravityAvailable, getAntigravityNotFoundMessage } = await import( + '../../utils/antigravity-cli-resolver.js' + ); if (!isAntigravityAvailable()) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage()); } @@ -3025,8 +3026,9 @@ export function registerSessionRoutes( // Check OpenCode availability if requested. Error text comes from the // resolver so it carries the resolution diagnostics; same for the modes below. if (mode === 'opencode') { - const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } = - await import('../../utils/opencode-cli-resolver.js'); + const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } = await import( + '../../utils/opencode-cli-resolver.js' + ); if (!isOpenCodeAvailable()) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage()); } @@ -3050,8 +3052,9 @@ export function registerSessionRoutes( // Check Antigravity availability if requested if (mode === 'antigravity') { - const { isAntigravityAvailable, getAntigravityNotFoundMessage } = - await import('../../utils/antigravity-cli-resolver.js'); + const { isAntigravityAvailable, getAntigravityNotFoundMessage } = await import( + '../../utils/antigravity-cli-resolver.js' + ); if (!isAntigravityAvailable()) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage()); } diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 4929e977..a23fb286 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -683,8 +683,9 @@ export function registerSystemRoutes( `Installing ${pkg} into profile "${profile}" failed: ${detail}` ); } - const { listDeepSeekProfiles, resolveDefaultDeepSeekProfile, isDeepSeekRunnable } = - await import('../../utils/deepseek-cli-resolver.js'); + const { listDeepSeekProfiles, resolveDefaultDeepSeekProfile, isDeepSeekRunnable } = await import( + '../../utils/deepseek-cli-resolver.js' + ); return { profile, package: pkg, diff --git a/test/docker-adopted-container.test.ts b/test/docker-adopted-container.test.ts index 36f86780..62d26cfd 100644 --- a/test/docker-adopted-container.test.ts +++ b/test/docker-adopted-container.test.ts @@ -123,7 +123,7 @@ describe('adopted container: the launch chain never mutates lifecycle', () => { expect(adopted).not.toContain('"'); expect(adopted).not.toContain('$('); // Every other line already quotes with the single-quote helper. - expect(adopted).toContain("grep -qx true"); + expect(adopted).toContain('grep -qx true'); }); it('skips the base-image gate, which describes an image adoption never uses', () => { @@ -142,6 +142,44 @@ describe('adopted container: the launch chain never mutates lifecycle', () => { }); }); +describe('adopted container: the probe request must reach the server', () => { + const ui = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf8'); + const api = readFileSync(new URL('../src/web/public/api-client.js', import.meta.url), 'utf8'); + + it('never hands _apiJson an already-stringified body', () => { + // _api serializes `body` and sets Content-Type itself. Passing a string + // double-encodes it, the server sees a JSON string where it expects an + // object, and answers 400 INVALID_INPUT — which the caller reads as "the + // container could not be probed", so the menu silently showed every mode. + expect(api).toContain('fetchOpts.body = JSON.stringify(body)'); + const calls = [...ui.matchAll(/_apiJson\([^)]*\{[\s\S]{0,400}?\}\s*\)/g)].map((m) => m[0]); + expect(calls.length).toBeGreaterThan(0); + for (const call of calls) expect(call).not.toContain('body: JSON.stringify'); + }); + + it('hides every agent mode and says why when the container cannot be read', () => { + // Offering claude on a container that is not running is a click that can + // only fail, with the reason visible nowhere. + // Brace-matched, not a character window: slicing between two call sites + // silently yields '' when the second one appears ABOVE the first, and the + // assertion then passes over nothing. That has bitten this file twice. + const start = ui.indexOf('async _probeDockerCaseModes(activeCase, menu) {'); + expect(start).toBeGreaterThan(-1); + const open = ui.indexOf('{', start); + let depth = 0; + let fn = ''; + for (let i = open; i < ui.length; i++) { + if (ui[i] === '{') depth++; + else if (ui[i] === '}' && --depth === 0) { + fn = ui.slice(start, i + 1); + break; + } + } + expect(fn).toContain('_dockerCaseProbeError'); + expect(ui).toContain('_renderRunModeNotice'); + }); +}); + describe('adopted container: claude as root', () => { it('drops --dangerously-skip-permissions when the container runs as root', () => { // Claude Code refuses the flag as root ("cannot be used with root/sudo @@ -249,10 +287,22 @@ describe('adopted container: run modes come from the CONTAINER, not the host', ( /** Slice the method BODY. Anchored on the definition, not a call site: the * menu opener calls _loadRunModeHistory() ABOVE this definition, so slicing * between call sites silently yields an empty string and passes nothing. */ + /** + * The method BODY, delimited by brace depth rather than a character budget. + * A fixed window silently truncates the moment the method grows — which is + * exactly what happened twice: a comment added above the assertion pushed the + * asserted line past the cutoff and CI failed on a test that was still true. + */ const refreshFn = (src) => { const start = src.indexOf('_refreshRunModeAvailability(menu) {'); expect(start).toBeGreaterThan(-1); - return src.slice(start, start + 2000); + const open = src.indexOf('{', start); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1); + } + throw new Error('unbalanced braces in _refreshRunModeAvailability'); }; it('gates a docker case on availableModes instead of host CLI probes', () => { From 47ee49128c1a2320b5c6ba3020cf893acbc76fff Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 23:49:04 -0700 Subject: [PATCH 14/26] style: match the prettier version the lockfile pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Format check failed twice, on different files each time, because three prettier versions were in play: package.json says ^3.4.0, package-lock pins 3.8.3 (CI runs npm ci, so that is the one CI uses), and the local node_modules had 3.9.6. Files formatted with 3.9.6 were then "fixed" with 3.4.2, pushing session-routes and system-routes onto a third style — every version change moved the failure to a different set of files. Line-break placement in `await import` and a union type only; no logic changes. --- src/web/routes/session-routes.ts | 15 ++++++--------- src/web/routes/system-routes.ts | 5 ++--- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index c50ddcb3..3fc80c94 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -944,9 +944,8 @@ export function registerSessionRoutes( } } if (body.mode === 'antigravity') { - const { isAntigravityAvailable, getAntigravityNotFoundMessage } = await import( - '../../utils/antigravity-cli-resolver.js' - ); + const { isAntigravityAvailable, getAntigravityNotFoundMessage } = + await import('../../utils/antigravity-cli-resolver.js'); if (!isAntigravityAvailable()) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage()); } @@ -3026,9 +3025,8 @@ export function registerSessionRoutes( // Check OpenCode availability if requested. Error text comes from the // resolver so it carries the resolution diagnostics; same for the modes below. if (mode === 'opencode') { - const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } = await import( - '../../utils/opencode-cli-resolver.js' - ); + const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } = + await import('../../utils/opencode-cli-resolver.js'); if (!isOpenCodeAvailable()) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage()); } @@ -3052,9 +3050,8 @@ export function registerSessionRoutes( // Check Antigravity availability if requested if (mode === 'antigravity') { - const { isAntigravityAvailable, getAntigravityNotFoundMessage } = await import( - '../../utils/antigravity-cli-resolver.js' - ); + const { isAntigravityAvailable, getAntigravityNotFoundMessage } = + await import('../../utils/antigravity-cli-resolver.js'); if (!isAntigravityAvailable()) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage()); } diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index a23fb286..4929e977 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -683,9 +683,8 @@ export function registerSystemRoutes( `Installing ${pkg} into profile "${profile}" failed: ${detail}` ); } - const { listDeepSeekProfiles, resolveDefaultDeepSeekProfile, isDeepSeekRunnable } = await import( - '../../utils/deepseek-cli-resolver.js' - ); + const { listDeepSeekProfiles, resolveDefaultDeepSeekProfile, isDeepSeekRunnable } = + await import('../../utils/deepseek-cli-resolver.js'); return { profile, package: pkg, From 5969a1df96a0c6b1f7c9a60029729ece807287de Mon Sep 17 00:00:00 2001 From: shenlvkang-collab Date: Tue, 1 Sep 2026 12:29:02 +0800 Subject: [PATCH 15/26] fix(mobile): give the Add Case modal a reachable submit button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mobile.css hides #createCaseModal's .set-foot below 860px, and that modal's header — unlike Settings' — carries no set-head-save. So on a phone the Create/Link button existed nowhere and the modal could not be submitted at all. Adds the header button and drives both together through switchCaseModalTab() and submitCaseModal(), so whichever one is pressed the other shows the same pending state and is equally unclickable. Following the Settings pattern also means Add Case picks up the existing .set-head-actions:has(.set-head-save) tray and .set-head-save sizing with no new CSS; the mobile.css comment that still listed Add Case as a lone-× sheet is corrected to match. --- .changeset/fix-mobile-add-case-submit.md | 16 +++++++++ src/web/public/index.html | 7 ++++ src/web/public/mobile.css | 6 ++-- src/web/public/session-ui.js | 41 ++++++++++++++++-------- 4 files changed, 55 insertions(+), 15 deletions(-) create mode 100644 .changeset/fix-mobile-add-case-submit.md diff --git a/.changeset/fix-mobile-add-case-submit.md b/.changeset/fix-mobile-add-case-submit.md new file mode 100644 index 00000000..e15097e6 --- /dev/null +++ b/.changeset/fix-mobile-add-case-submit.md @@ -0,0 +1,16 @@ +--- +"aicodeman": patch +--- + +fix(mobile): give the Add Case modal a reachable submit button + +`mobile.css` hides `#createCaseModal`'s `.set-foot` on phones, and unlike the Settings +modal that header carries no `set-head-save` — so the Create/Link button existed nowhere +on a phone and the modal could not be submitted at all. Adds the header button and drives +both together, so whichever one is pressed the other shows the same pending state and is +equally unclickable. + +Add Case follows the Settings modal's header-save pattern, so it picks up the existing +`.set-head-actions:has(.set-head-save)` tray and `.set-head-save` sizing below 860px with no +new CSS; the `mobile.css` comment that still listed Add Case as a lone-× sheet is updated to +match. diff --git a/src/web/public/index.html b/src/web/public/index.html index 78eff9d3..9391b9c2 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2582,8 +2582,15 @@