From 8768ca4a5a2d47bbc4c73a359fca0bababb94225 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 19 Jul 2026 15:28:44 +0200 Subject: [PATCH] feat(docker): docker-hosts CRUD, docker-link, and quick-start branch - case-routes: GET/POST/PUT/DELETE /api/docker-hosts, POST /api/cases/docker-link (creates workspace, probes daemon + tmux-in-image), docker listing in GET /api/cases, docker-unlink (best-effort docker rm -f) in DELETE, single GET - session-routes: /api/quick-start docker branch (rejects envOverrides/effort/ per-CLI config, probes availability + tmux, casePath=hostWorkspacePath, seeds resume id, scaffolds hooks+CLAUDE.md if missing, threads docker into Session, Ralph auto-config skipped for docker) - CaseInfo gains location:'docker' + docker{} block - typecheck clean; 157 route+docker tests pass Co-Authored-By: Claude Opus 4.8 (1M context) --- src/types/api.ts | 10 +- src/web/routes/case-routes.ts | 185 +++++++++++++++++++++++++++++++ src/web/routes/session-routes.ts | 84 +++++++++++++- 3 files changed, 274 insertions(+), 5 deletions(-) diff --git a/src/types/api.ts b/src/types/api.ts index b7201022..d5a4578a 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -124,7 +124,7 @@ export interface CaseInfo { /** Whether CLAUDE.md exists */ hasClaudeMd?: boolean; /** Case storage/execution location */ - location?: 'local' | 'linked-local' | 'remote'; + location?: 'local' | 'linked-local' | 'remote' | 'docker'; /** Whether this is a linked local folder */ linked?: boolean; /** Remote case metadata for display and session creation */ @@ -134,6 +134,14 @@ export interface CaseInfo { username: string; path: string; }; + /** Docker case metadata for display and session creation */ + docker?: { + hostId: string; + container: string; + image?: string; + path: string; + network?: string; + }; } // ========== Error Handling Utilities ========== diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index ac45385c..0d551028 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -6,6 +6,7 @@ import { FastifyInstance } from 'fastify'; import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs'; +import { exec } from 'node:child_process'; import fs from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { homedir } from 'node:os'; @@ -17,6 +18,8 @@ import { CaseOrderSchema, RemoteCaseLinkSchema, RemoteHostSchema, + DockerCaseLinkSchema, + DockerHostSchema, } from '../schemas.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { writeHooksConfig } from '../../hooks-config.js'; @@ -24,6 +27,18 @@ import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonCo import { SseEvent } from '../sse-events.js'; import type { EventPort, ConfigPort } from '../ports/index.js'; import { dataPath, getDataDir } from '../../config/instance.js'; +import { + checkDockerAvailable, + checkDockerTmuxAvailable, + dockerContainerName, + dockerDisplayPath, + readDockerCases, + readDockerHosts, + toSessionDocker, + writeDockerCases, + writeDockerHosts, +} from '../../docker-hosts.js'; +import { buildDockerRemoveCommand } from '../../tmux-manager.js'; import { checkRemoteTmuxAvailable, readRemoteCases, @@ -118,6 +133,35 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config } } + // Get docker cases + const dockerHosts = await readDockerHosts(CODEMAN_CONFIG_DIR); + const dockerHostMap = new Map(dockerHosts.map((host) => [host.id, host])); + for (const dockerCase of await readDockerCases(CODEMAN_CONFIG_DIR)) { + const host = dockerHostMap.get(dockerCase.hostId); + if (!host || !SAFE_CASE_NAME.test(dockerCase.name)) continue; + existingNames.add(dockerCase.name); + const container = dockerCase.container ?? dockerContainerName(dockerCase.name); + const dockerCaseInfo: CaseInfo = { + name: dockerCase.name, + path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }), + hasClaudeMd: existsSync(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')), + location: 'docker', + docker: { + hostId: host.id, + container, + image: host.image, + path: dockerCase.hostWorkspacePath, + network: host.network ?? 'bridge', + }, + }; + const existingIndex = cases.findIndex((item) => item.name === dockerCase.name); + if (existingIndex === -1) { + cases.push(dockerCaseInfo); + } else { + cases[existingIndex] = dockerCaseInfo; + } + } + // Sort by persisted caseOrder from settings.json const settings = await readJsonConfig>(SETTINGS_PATH, 'settings', {}); const caseOrder = Array.isArray(settings.caseOrder) ? (settings.caseOrder as string[]) : []; @@ -232,6 +276,106 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config return { success: true, data: { case: remoteCase } }; }); + // ========== Docker hosts + docker cases (COD-Docker) ========== + + app.get('/api/docker-hosts', async () => readDockerHosts(CODEMAN_CONFIG_DIR)); + + app.post('/api/docker-hosts', async (req): Promise> => { + const host = parseBody(DockerHostSchema, req.body); + const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR); + if (hosts.some((item) => item.id === host.id)) { + return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Docker host already exists'); + } + await writeDockerHosts(CODEMAN_CONFIG_DIR, [...hosts, host]); + return { success: true, data: { host } }; + }); + + app.put('/api/docker-hosts/:id', async (req): Promise> => { + const { id } = req.params as { id: string }; + const host = parseBody(DockerHostSchema, { ...(req.body as object), id }); + const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR); + const index = hosts.findIndex((item) => item.id === id); + if (index === -1) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found'); + const next = [...hosts]; + next[index] = host; + await writeDockerHosts(CODEMAN_CONFIG_DIR, next); + return { success: true, data: { host } }; + }); + + app.delete('/api/docker-hosts/:id', async (req): Promise> => { + const { id } = req.params as { id: string }; + const cases = await readDockerCases(CODEMAN_CONFIG_DIR); + if (cases.some((item) => item.hostId === id)) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Docker host is still used by docker cases'); + } + const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR); + await writeDockerHosts( + CODEMAN_CONFIG_DIR, + hosts.filter((item) => item.id !== id) + ); + return { success: true, data: { id } }; + }); + + app.post( + '/api/cases/docker-link', + async (req): Promise> => { + const dockerCase = { ...parseBody(DockerCaseLinkSchema, req.body), type: 'docker' as const }; + const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR); + const host = hosts.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(CASES_DIR, dockerCase.name)) + ) { + return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists'); + } + + // The workspace is a REAL host directory (bind-mounted into the container), so + // create it now if missing. Scaffolding (.claude/settings.local.json + CLAUDE.md) + // is written by quick-start on first launch, matching local-case behaviour. + if (!existsSync(dockerCase.hostWorkspacePath)) { + try { + mkdirSync(dockerCase.hostWorkspacePath, { recursive: true }); + } catch (err) { + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + `Could not create workspace: ${getErrorMessage(err)}` + ); + } + } + + // Courtesy validation: docker daemon must be reachable AND the base image must + // contain tmux (a hard prerequisite for durable in-container sessions). Surfaces + // a clear error at link time instead of a dead pane on first launch. + const availability = await checkDockerAvailable(host.engine); + if (!availability.ok) { + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + availability.error || 'docker daemon is not available' + ); + } + const tmuxCheck = await checkDockerTmuxAvailable(toSessionDocker(host, dockerCase)); + if (!tmuxCheck.ok) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux'); + } + + 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, capsEnforced: availability.capsEnforced, isDesktop: availability.isDesktop }, + }; + } + ); + // Link an existing folder as a case app.post('/api/cases/link', async (req): Promise> => { const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body'); @@ -295,6 +439,27 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config return { success: true, data: { name } }; } + const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR); + const dockerCase = dockerCases.find((item) => item.name === name); + if (dockerCase) { + await writeDockerCases( + CODEMAN_CONFIG_DIR, + dockerCases.filter((item) => item.name !== name) + ); + // 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); + if (host) { + try { + exec(buildDockerRemoveCommand(toSessionDocker(host, dockerCase)), { timeout: 15_000 }, () => {}); + } catch { + /* best-effort — never blocks the unlink */ + } + } + ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'docker-unlinked' }); + return { success: true, data: { name } }; + } + // Check linked cases first — unlink only, don't delete the actual directory const linkedCases = await readLinkedCases(); if (linkedCases[name]) { @@ -374,6 +539,26 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config }; } + const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name); + if (dockerCase) { + 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 container = dockerCase.container ?? dockerContainerName(dockerCase.name); + return { + name, + path: dockerDisplayPath({ container, path: dockerCase.hostWorkspacePath }), + hasClaudeMd: existsSync(join(dockerCase.hostWorkspacePath, 'CLAUDE.md')), + location: 'docker', + docker: { + hostId: host.id, + container, + image: host.image, + path: dockerCase.hostWorkspacePath, + network: host.network ?? 'bridge', + }, + }; + } + const casePath = await resolveCasePath(name); if (!existsSync(casePath)) { diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 6da2cf52..38cd99bc 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -74,6 +74,13 @@ import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js'; import { dataPath, getDataDir } from '../../config/instance.js'; import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.js'; +import { + checkDockerAvailable, + checkDockerTmuxAvailable, + readDockerCases, + readDockerHosts, + toSessionDocker, +} from '../../docker-hosts.js'; import { LRUMap } from '../../utils/lru-map.js'; // Path to linked-cases registry (same file used by case-routes resolveCasePath) @@ -1684,9 +1691,14 @@ export function registerSessionRoutes( // so the LOCAL availability gates below (isCodexAvailable() etc.) don't apply and // would wrongly reject a machine that hasn't got the CLI installed locally. let remote = undefined; + let docker = undefined; + let dockerResumeId: string | undefined; let casePath: string | null = null; const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR); const remoteCase = remoteCases.find((item) => item.name === caseName); + const dockerCase = remoteCase + ? undefined + : (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === caseName); if (remoteCase) { const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId); if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found'); @@ -1718,6 +1730,47 @@ export function registerSessionRoutes( casePath = remoteCase.remotePath; remote = toSessionRemote(host, remoteCase); + } else if (dockerCase) { + // Docker case: the CLI executes INSIDE a container via local tmux + `docker + // exec`, so the LOCAL availability gates below don't apply. Mirror the remote + // branch's rejection of per-session config that would not cross into the + // container (it would silently no-op). + const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId); + if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found'); + if ( + (envOverrides && Object.keys(envOverrides).length > 0) || + effort || + codexConfig || + geminiConfig || + openCodeConfig + ) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'envOverrides, effort, and per-CLI config are not supported for docker cases (they do not cross into the container). Configure the container via the docker host command override instead.' + ); + } + + const availability = await checkDockerAvailable(host.engine); + if (!availability.ok) { + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + availability.error || 'docker daemon is not available' + ); + } + const sessionDocker = toSessionDocker(host, dockerCase); + // tmux is a hard prerequisite (the in-container tmux makes reconnect durable). + const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker); + if (!tmuxCheck.ok) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux'); + } + + casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container) + docker = sessionDocker; + // Seed resume so a relaunch resumes the case's last conversation from the + // bind-mounted transcript (decision: resume-on-start default ON). + if (sessionDocker.resumeOnStart && dockerCase.lastClaudeSessionId) { + dockerResumeId = dockerCase.lastClaudeSessionId; + } } else { // Check OpenCode availability if requested if (mode === 'opencode') { @@ -1772,8 +1825,9 @@ export function registerSessionRoutes( // for local cases the !casePath guard above returned early. TypeScript can't narrow across the if/else. const resolvedCasePath = casePath as string; - // Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote cases) - if (!remote && !existsSync(resolvedCasePath)) { + // Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote, + // non-docker cases — docker workspaces are scaffolded in their own block below) + if (!remote && !docker && !existsSync(resolvedCasePath)) { try { mkdirSync(resolvedCasePath, { recursive: true }); mkdirSync(join(resolvedCasePath, 'src'), { recursive: true }); @@ -1793,7 +1847,7 @@ export function registerSessionRoutes( } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`); } - } else if (!remote && mode !== 'opencode') { + } else if (!remote && !docker && mode !== 'opencode') { // COD-91 self-heal for an EXISTING case: refresh a pre-secret hooks block so the // now-unconditional hook-secret gate keeps accepting its hook events. No-op when // the hooks aren't ours or already carry the secret. Skipped for remote cases — @@ -1801,6 +1855,26 @@ export function registerSessionRoutes( await refreshStaleHookSecret(resolvedCasePath).catch(() => {}); } + // Docker cases: the workspace is a REAL host dir bind-mounted into the container. + // Scaffold hooks (+ a CLAUDE.md) if MISSING so in-container permission prompts and + // hook-idle detection fire (decision: wire hooks now). Never clobbers an existing + // configured project. Skipped for external CLIs (they use their own systems). + if (docker && docker.hooksEnabled && mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini') { + try { + if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) { + const templatePath = await ctx.getDefaultClaudeMdPath(); + writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), generateClaudeMd(caseName, '', templatePath)); + } + if (!existsSync(join(resolvedCasePath, '.claude', 'settings.local.json'))) { + await writeHooksConfig(resolvedCasePath); + } else { + await refreshStaleHookSecret(resolvedCasePath).catch(() => {}); + } + } catch { + /* non-fatal — the session still runs, hooks may be degraded */ + } + } + // Strip stale disk entries for keys this request is actively setting (Claude only — // see POST /api/sessions for full rationale). if ( @@ -1845,12 +1919,14 @@ export function registerSessionRoutes( envOverrides, effort, remote, + docker, + resumeSessionId: dockerResumeId, tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit, }); // Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting // so the initial state already has the phrase configured (only if globally enabled) - if (mode === 'claude' && !remote && ctx.store.getConfig().ralphEnabled) { + if (mode === 'claude' && !remote && !docker && ctx.store.getConfig().ralphEnabled) { autoConfigureRalph(session, resolvedCasePath, ctx); if (!session.ralphTracker.enabled) { session.ralphTracker.enable();