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) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-19 15:28:44 +02:00
parent df9214ba9a
commit 8768ca4a5a
3 changed files with 274 additions and 5 deletions
+9 -1
View File
@@ -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 ==========
+185
View File
@@ -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<Record<string, unknown>>(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<ApiResponse<{ host: unknown }>> => {
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<ApiResponse<{ host: unknown }>> => {
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<ApiResponse<{ id: string }>> => {
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<ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean }>> => {
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<ApiResponse<{ case: { name: string; path: string } }>> => {
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)) {
+80 -4
View File
@@ -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();