feat(docker): one-click "Run in Docker" case creation + Export button

- New POST /api/cases/docker-quickcreate: creates a normal case (folder in
  CASES_DIR, scaffolded CLAUDE.md + hooks) AND links it to a hardened container
  with default settings, auto-provisioning a shared `default` docker host — the
  user never touches host/image/network fields.
- Create New tab gains a "Run in isolated Docker container" checkbox; on submit it
  calls docker-quickcreate then auto-starts a claude session inside the container.
- Case Manage list gains an Export (full-image) button per docker case.
- SSE listeners for docker:exportComplete/exportFailed toast + refresh the exports
  list.

Verified end-to-end on the live instance: one-click create put the case in
~/codeman-cases/<name>, auto-created the default host, launched claude in the
container; export button produces a bundle; checkbox + button render (Playwright).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-19 19:28:13 +02:00
parent 66a41f5aa9
commit db6cd838b1
4 changed files with 124 additions and 2 deletions
+19
View File
@@ -1433,6 +1433,25 @@ class CodemanApp {
for (const event of [SSE_EVENTS.SESSION_CREATED, SSE_EVENTS.SESSION_DELETED]) {
addListener(event, () => this._onSessionListMaybeChanged());
}
// Docker export/import: toast + refresh the Manage-tab exports list on completion.
addListener(SSE_EVENTS.DOCKER_EXPORT_COMPLETE, (e) => {
try {
const d = e.data ? JSON.parse(e.data) : {};
this.showToast(`Docker export ready: ${d.bundle} (${Math.round((d.sizeBytes || 0) / 1e6)} MB)`, 'success');
this.refreshDockerExports?.();
} catch (err) {
console.error('[SSE] docker export complete:', err);
}
});
addListener(SSE_EVENTS.DOCKER_EXPORT_FAILED, (e) => {
try {
const d = e.data ? JSON.parse(e.data) : {};
this.showToast(`Docker export failed: ${d.error || 'unknown error'}`, 'error');
} catch (err) {
console.error('[SSE] docker export failed:', err);
}
});
}
// ═══════════════════════════════════════════════════════════════
+4
View File
@@ -1864,6 +1864,10 @@
<label>Description (optional)</label>
<input type="text" id="newCaseDescription" placeholder="A brief description..." autocomplete="off">
</div>
<div class="form-row">
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label>
<span class="form-hint">One click: creates the case folder AND a hardened container with default settings, then starts the session inside it. Requires the base image (<code>node scripts/build-agent-image.mjs</code>).</span>
</div>
</div>
<!-- Link Existing Tab -->
<div class="modal-tab-content hidden" id="case-link">
+20 -2
View File
@@ -1642,8 +1642,13 @@ Object.assign(CodemanApp.prototype, {
return;
}
// One-click "Run in Docker": create the case folder AND a container (default
// settings), then start a session inside it.
const inDocker = document.getElementById('newCaseDocker')?.checked;
const endpoint = inDocker ? '/api/cases/docker-quickcreate' : '/api/cases';
try {
const res = await fetch('/api/cases', {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description })
@@ -1652,11 +1657,18 @@ Object.assign(CodemanApp.prototype, {
const data = await res.json();
if (data.success) {
this.closeCreateCaseModal();
this.showToast(`Case "${name}" created`, 'success');
// Reload cases and select the new one
await this.loadQuickStartCases(name);
// Save as last used case
await this.saveLastUsedCase(name);
if (inDocker) {
const caps = data.data?.capsEnforced === false ? ' (resource caps advisory on this engine)' : '';
this.showToast(`Docker case "${name}" created${caps} — starting session…`, 'success');
// Start a session INSIDE the container (routes through quick-start).
await this.runClaude();
} else {
this.showToast(`Case "${name}" created`, 'success');
}
} else {
this.showToast(data.error || 'Failed to create case', 'error');
}
@@ -1985,6 +1997,12 @@ Object.assign(CodemanApp.prototype, {
<span class="case-manage-path">${escapeHtml(pathDisplay)}</span>
</div>
<div class="case-manage-actions">
${
c.location === 'docker'
? `<button class="case-manage-btn" onclick="app.exportDockerCaseBundle(${escapeHtml(JSON.stringify(c.name))}, 'full')"
title="Export container (full image + workspace) to move to another machine">&#x1F4E6;</button>`
: ''
}
<button class="case-manage-btn" onclick="app.moveCaseUp(${escapeHtml(JSON.stringify(c.name))})"
title="Move up" ${isFirst ? 'disabled' : ''}>&#x25B2;</button>
<button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})"
+81
View File
@@ -34,6 +34,7 @@ import { dataPath, getDataDir } from '../../config/instance.js';
import {
checkDockerAvailable,
checkDockerTmuxAvailable,
DEFAULT_AGENT_IMAGE,
dockerContainerName,
dockerDisplayPath,
readDockerCases,
@@ -56,6 +57,8 @@ const LINKED_CASES_FILE = dataPath('linked-cases.json');
const CODEMAN_CONFIG_DIR = getDataDir();
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
const DOCKER_EXPORTS_DIR = dataPath('docker-exports');
/** Auto-created host profile for the one-click "Run in Docker" case flow. */
const DEFAULT_DOCKER_HOST_ID = 'default';
/** App version for export manifests (best-effort read of package.json). */
const APP_VERSION = (() => {
@@ -391,6 +394,84 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
}
);
// 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.
app.post(
'/api/cases/docker-quickcreate',
async (req): Promise<ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean }>> => {
const { name, description } = parseBody(CreateCaseSchema, req.body);
const casePath = validatePathWithinBase(name, CASES_DIR);
if (!casePath) return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
// Collision across every case kind.
const linkedCases = await readLinkedCases();
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
if (
existsSync(casePath) ||
dockerCases.some((item) => item.name === name) ||
remoteCases.some((item) => item.name === name) ||
linkedCases[name]
) {
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
}
// Auto-provision the shared `default` docker host (convenient, hardened defaults).
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
let host = hosts.find((item) => item.id === DEFAULT_DOCKER_HOST_ID);
if (!host) {
host = {
id: DEFAULT_DOCKER_HOST_ID,
label: 'Default',
image: DEFAULT_AGENT_IMAGE,
network: 'bridge',
mountCredentials: true,
resumeOnStart: true,
hooksEnabled: true,
};
await writeDockerHosts(CODEMAN_CONFIG_DIR, [...hosts, host]);
}
// Probe the daemon + base image BEFORE scaffolding, so a missing docker/image
// surfaces a clear error instead of leaving an orphaned case folder.
const availability = await checkDockerAvailable(host.engine);
if (!availability.ok) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
availability.error || 'docker daemon is not available'
);
}
const dockerCase = { name, type: 'docker' as const, hostId: host.id, hostWorkspacePath: casePath };
const tmuxCheck = await checkDockerTmuxAvailable(toSessionDocker(host, dockerCase));
if (!tmuxCheck.ok) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
tmuxCheck.error || 'base image is missing tmux (build it: node scripts/build-agent-image.mjs)'
);
}
// Scaffold the case folder exactly like a normal case.
try {
mkdirSync(casePath, { recursive: true });
mkdirSync(join(casePath, 'src'), { recursive: true });
const templatePath = await ctx.getDefaultClaudeMdPath();
writeFileSync(join(casePath, 'CLAUDE.md'), generateClaudeMd(name, description || '', templatePath));
await writeHooksConfig(casePath);
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
}
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, dockerCase]);
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
ctx.broadcast(SseEvent.CaseLinked, { name, path: casePath, type: 'docker' });
return {
success: true,
data: { case: dockerCase, capsEnforced: availability.capsEnforced, isDesktop: availability.isDesktop },
};
}
);
// ========== Docker export / import ==========
// Export a docker case to a portable bundle. Runs in the BACKGROUND (a full image