feat(docker): harden session mode + File Viewer button (v1.4.1)

Docker cases: seamless Claude auth (seed ~/.claude.json instead of the
corruption-prone single-file mount), full credential-store isolation for
claude + codex/gemini/gcloud/opencode (share only transcripts/rollouts,
seed the rest), auto-build the base image on first use, C.UTF-8 locale
(fixes box-drawing), collapsed/shortened Create-Case UI + short "(docker)"
case-menu tags, and w<n>-<case> tab naming for docker/remote sessions.
Also: opt-in File Viewer header button; fix a TZ-boundary flaky test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-20 01:36:21 +02:00
parent 82825cbfb3
commit ca731c67b3
25 changed files with 2607 additions and 177 deletions
+24
View File
@@ -1452,6 +1452,30 @@ class CodemanApp {
console.error('[SSE] docker export failed:', err);
}
});
// Base image auto-build on first Docker case (build-on-first-use). A single
// multi-minute event; surface start/finish so the Run spinner is explained.
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_STARTED, () => {
this.showToast('Building the Codeman agent image (first Docker case, a few minutes)...', 'info', {
duration: 8000,
});
});
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_COMPLETE, (e) => {
try {
const d = e.data ? JSON.parse(e.data) : {};
if (d.error) this.showToast(`Agent image build failed: ${d.error}`, 'error');
else this.showToast('Agent image ready. Starting the container...', 'success');
} catch (err) {
console.error('[SSE] docker image build complete:', err);
}
});
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_FAILED, (e) => {
try {
const d = e.data ? JSON.parse(e.data) : {};
this.showToast(`Agent image build failed: ${d.error || 'unknown error'}`, 'error');
} catch (err) {
console.error('[SSE] docker image build failed:', err);
}
});
}
// ═══════════════════════════════════════════════════════════════
+4
View File
@@ -477,6 +477,10 @@ const SSE_EVENTS = {
DOCKER_EXPORT_COMPLETE: 'docker:exportComplete',
DOCKER_EXPORT_FAILED: 'docker:exportFailed',
DOCKER_IMPORT_COMPLETE: 'docker:importComplete',
DOCKER_IMAGE_BUILD_STARTED: 'docker:imageBuildStarted',
DOCKER_IMAGE_BUILD_PROGRESS: 'docker:imageBuildProgress',
DOCKER_IMAGE_BUILD_COMPLETE: 'docker:imageBuildComplete',
DOCKER_IMAGE_BUILD_FAILED: 'docker:imageBuildFailed',
};
// ═══════════════════════════════════════════════════════════════
+14 -6
View File
@@ -124,6 +124,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
</button>
<button class="btn-icon-header btn-file-viewer btn-file-viewer--hidden" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude plan usage limits">—</div>
@@ -1268,6 +1269,13 @@
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show the file viewer button in header (opens the file browser panel for the active session)">
<span class="settings-item-label">File Viewer</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowFileViewerButton">
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show the attachments button in header (opens the attachment history drawer)">
<span class="settings-item-label">Attachments Button</span>
<label class="switch switch-sm">
@@ -1864,12 +1872,12 @@
<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" onchange="app.toggleDockerQuickSettings()"> 🐳 Run in an isolated Docker container</label>
<span class="form-hint">One checkbox is enough — it creates the case folder AND a hardened container with default settings, then starts the session inside it. Click to expand for optional presets. Requires the base image (<code>node scripts/build-agent-image.mjs</code>).</span>
<div class="form-row docker-quick-row">
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label>
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use.</span>
</div>
<details class="advanced-options" id="dockerQuickSettings" style="display:none;">
<summary>Container settings (optional — sensible defaults)</summary>
<details class="advanced-options docker-quick-settings" id="dockerQuickSettings">
<summary>Container settings (optional, sensible defaults)</summary>
<div class="advanced-options-content">
<div class="form-row">
<label>Template</label>
@@ -1880,7 +1888,7 @@
<option value="gpu">GPU — 8 GB RAM, 4 CPU, all GPUs</option>
<option value="custom">Custom</option>
</select>
<span class="form-hint">Disk is elastic — storage grows automatically as data flows in (no fixed cap).</span>
<span class="form-hint">Disk is elastic: storage grows automatically as data flows in (no fixed cap).</span>
</div>
<div class="form-row">
<label>Memory</label>
+30
View File
@@ -3098,6 +3098,32 @@ Object.assign(CodemanApp.prototype, {
}
},
// Header "File Viewer" button (opt-in via App Settings → Header Displays →
// File Viewer). Toggles the file browser panel open/closed without a trip
// through settings. Persists via the same `showFileBrowser` flag the Panels
// section + the panel's own close (X) use, so the three stay in sync.
toggleFileBrowserButton() {
const panel = this.$('fileBrowserPanel');
const isOpen = panel?.classList.contains('visible');
const btn = document.querySelector('.btn-file-viewer');
if (isOpen) {
this.closeFileBrowserPanel();
if (btn) btn.setAttribute('aria-expanded', 'false');
return;
}
if (!this.activeSessionId) {
this.showToast('Open a session to browse its files', 'info');
return;
}
const settings = this.loadAppSettingsFromStorage();
settings.showFileBrowser = true;
this.saveAppSettingsToStorage(settings);
const checkbox = document.getElementById('appSettingsShowFileBrowser');
if (checkbox) checkbox.checked = true;
this.applyMonitorVisibility();
if (btn) btn.setAttribute('aria-expanded', 'true');
},
closeFileBrowserPanel() {
const panel = this.$('fileBrowserPanel');
if (panel) {
@@ -3130,6 +3156,10 @@ Object.assign(CodemanApp.prototype, {
const settings = this.loadAppSettingsFromStorage();
settings.showFileBrowser = false;
this.saveAppSettingsToStorage(settings);
const checkbox = document.getElementById('appSettingsShowFileBrowser');
if (checkbox) checkbox.checked = false;
const headerBtn = document.querySelector('.btn-file-viewer');
if (headerBtn) headerBtn.setAttribute('aria-expanded', 'false');
},
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) {
+37 -30
View File
@@ -46,10 +46,19 @@ Object.assign(CodemanApp.prototype, {
formatCasePickerLabel(c) {
if (c?.location === 'remote' && c.remote?.hostId) return `${c.name} @ ${c.remote.hostId}`;
if (c?.location === 'docker' && c.docker?.container) return `${c.name} @ ${c.docker.container}`;
if (c?.location === 'docker') return `${c.name} (${this.dockerCaseTag(c.docker?.hostId)})`;
return c?.name || '';
},
// Short parenthetical tag for a dockerized case: '(docker)' for the default /
// auto-provisioned host (one-click "Run in Docker", the Docker-tab 'local'
// default, or a per-case 'q-<name>' resource-override host), otherwise the custom
// docker host id the user named (e.g. '(gpu-box)'). Keeps the case name short.
dockerCaseTag(hostId) {
if (!hostId || hostId === 'default' || hostId === 'local' || /^q-/.test(hostId)) return 'docker';
return hostId;
},
buildCasePickerOptions(cases = []) {
const normalized = [];
const seen = new Set();
@@ -485,6 +494,22 @@ Object.assign(CodemanApp.prototype, {
input.value = Math.max(1, current - 1);
},
// Next free <prefix><n> index for a case's session tabs (e.g. w1-<case>,
// w2-<case> for agents, s1-<case> for shells), shared by the local and
// remote/docker launch paths so all tabs follow the same naming convention.
_nextCaseSessionStartNumber(caseName, prefix = 'w') {
const re = new RegExp(`^${prefix}(\\d+)-([a-zA-Z0-9_-]+)`);
let startNumber = 1;
for (const [, session] of this.sessions || []) {
const match = session.name && session.name.match(re);
if (match && match[2] === caseName) {
const num = parseInt(match[1]);
if (num >= startNumber) startNumber = num + 1;
}
}
return startNumber;
},
async runClaude() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
const tabCount = Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1));
@@ -523,12 +548,15 @@ Object.assign(CodemanApp.prototype, {
// the LOCAL fs (a remote user@host:/path never exists locally), so route them
// through /api/quick-start, which resolves the remote case + launches via ssh.
if (caseData.location === 'remote' || caseData.location === 'docker') {
// Name remote/docker tabs with the same w<n>-<case> convention as local
// sessions (quick-start would otherwise auto-generate codeman-<id>).
const startNumber = this._nextCaseSessionStartNumber(caseName);
const remoteIds = [];
for (let i = 0; i < tabCount; i++) {
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caseName, mode: 'claude' })
body: JSON.stringify({ caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}` })
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session');
@@ -546,16 +574,7 @@ Object.assign(CodemanApp.prototype, {
let firstSessionId = null;
// Find the highest existing w-number for THIS case to avoid duplicates
let startNumber = 1;
for (const [, session] of this.sessions) {
const match = session.name && session.name.match(/^w(\d+)-([a-zA-Z0-9_-]+)/);
if (match && match[2] === caseName) {
const num = parseInt(match[1]);
if (num >= startNumber) {
startNumber = num + 1;
}
}
}
const startNumber = this._nextCaseSessionStartNumber(caseName);
// Get global Ralph tracker setting
const ralphEnabled = this.isRalphTrackerEnabledByDefault();
@@ -709,12 +728,13 @@ Object.assign(CodemanApp.prototype, {
// Remote cases run over ssh — route through /api/quick-start (see runClaude).
if (caseData.location === 'remote' || caseData.location === 'docker') {
const startNumber = this._nextCaseSessionStartNumber(caseName, 's');
const remoteIds = [];
for (let i = 0; i < shellCount; i++) {
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caseName, mode: 'shell' })
body: JSON.stringify({ caseName, mode: 'shell', sessionName: `s${startNumber + i}-${caseName}` })
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start remote shell session');
@@ -730,16 +750,7 @@ Object.assign(CodemanApp.prototype, {
}
// Find the highest existing s-number for THIS case to avoid duplicates
let startNumber = 1;
for (const [, session] of this.sessions) {
const match = session.name && session.name.match(/^s(\d+)-([a-zA-Z0-9_-]+)/);
if (match && match[2] === caseName) {
const num = parseInt(match[1]);
if (num >= startNumber) {
startNumber = num + 1;
}
}
}
const startNumber = this._nextCaseSessionStartNumber(caseName, 's');
// Create all shell sessions in parallel
const sessionNames = [];
@@ -828,6 +839,7 @@ Object.assign(CodemanApp.prototype, {
body: JSON.stringify({
caseName,
mode: 'opencode',
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
...(isRemote ? {} : {
openCodeConfig: { autoAllowTools: true },
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
@@ -880,6 +892,7 @@ Object.assign(CodemanApp.prototype, {
body: JSON.stringify({
caseName,
mode: 'codex',
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
...(isRemote ? {} : {
codexConfig: {
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
@@ -934,6 +947,7 @@ Object.assign(CodemanApp.prototype, {
body: JSON.stringify({
caseName,
mode: 'gemini',
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
...(isRemote ? {} : {
geminiConfig: { approvalMode: 'yolo' },
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
@@ -1681,13 +1695,6 @@ Object.assign(CodemanApp.prototype, {
}
},
// Show/hide the expandable container-settings section under the Docker checkbox.
toggleDockerQuickSettings() {
const on = document.getElementById('newCaseDocker')?.checked;
const el = document.getElementById('dockerQuickSettings');
if (el) el.style.display = on ? '' : 'none';
},
// Fill the memory/cpu/gpu fields from a resource template. `medium` clears them so
// the server uses its defaults (no per-case host); `custom` leaves them editable.
applyDockerTemplate() {
+13 -1
View File
@@ -307,6 +307,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true;
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true;
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? false;
document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue';
// WebGL renderer (desktop only — mobile always uses the DOM renderer, so hide
@@ -1426,6 +1427,7 @@ Object.assign(CodemanApp.prototype, {
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
showFileViewerButton: document.getElementById('appSettingsShowFileViewerButton').checked,
showAttachmentsButton: document.getElementById('appSettingsShowAttachmentsButton').checked,
showMonitor: document.getElementById('appSettingsShowMonitor').checked,
showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked,
@@ -1618,6 +1620,7 @@ Object.assign(CodemanApp.prototype, {
skin: _skin,
showPlanUsageLimits: _pul,
showAttachmentsButton: _ahb,
showFileViewerButton: _fvb,
webglRendererEnabled: _wgl,
terminalWheelLocalScrollback: _twls,
// Per-device header/toolbar button toggles — client-only, and absent from
@@ -1781,6 +1784,7 @@ Object.assign(CodemanApp.prototype, {
showMultiMonitorButton: false,
showPlanUsageLimits: false,
showAttachmentsButton: false,
showFileViewerButton: false,
showRedrawButton: false,
showSessionButton: false,
showAwayDigestButton: false,
@@ -1897,6 +1901,14 @@ Object.assign(CodemanApp.prototype, {
attachmentsBtn.classList.toggle('btn-attachments-history--hidden', !showAttachmentsButton);
}
// File Viewer header button — opt-in, default OFF. Marker class (base is
// display:inline-flex !important); clicking it toggles the file browser panel.
const showFileViewerButton = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? false;
const fileViewerBtn = document.querySelector('.btn-file-viewer');
if (fileViewerBtn) {
fileViewerBtn.classList.toggle('btn-file-viewer--hidden', !showFileViewerButton);
}
// Multi-monitor button — hidden by default (App Settings → Display → "Header
// Displays"). The server renders the correct initial state on every reload;
// this handles a live toggle from a settings save (no reload). Toggle the
@@ -2191,7 +2203,7 @@ Object.assign(CodemanApp.prototype, {
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'webglRendererEnabled',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalWheelLocalScrollback',
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
]);
+29
View File
@@ -9447,6 +9447,24 @@ kbd {
padding-left: 0.5rem;
}
/* "Run in Docker" quick option — the primary one-click entry point, so its
label + hint read larger and brighter than the standard form label/hint. */
.docker-quick-row .checkbox-row {
font-size: 0.9rem;
color: var(--text);
}
.docker-quick-row .form-hint {
font-size: 0.8rem;
line-height: 1.45;
}
/* The container-settings panel is always shown + expanded (no longer gated on
the checkbox), so its summary header reads a touch larger too. */
.docker-quick-settings > summary {
font-size: 0.85rem;
}
/* ═══════════════════════════════════════════════════════════════
Response Viewer — native-scroll overlay for reading Claude responses
═══════════════════════════════════════════════════════════════ */
@@ -9489,6 +9507,17 @@ kbd {
display: none !important;
}
/* "File Viewer" header button — opt-in (App Settings → Header Displays), hidden
by default. Clicking it toggles the file browser panel. Same marker pattern as
the response viewer: a base inline-flex !important so an inline style can't
override it, and a more-specific marker rule to hide. */
.btn-file-viewer {
display: inline-flex !important;
}
.btn-file-viewer.btn-file-viewer--hidden {
display: none !important;
}
/* "Cron" footer-toolbar button — shown by default (App Settings → Display can
hide it). Toolbar button, not a header icon, so only the hide marker is
needed; out-specify any base .btn-toolbar display. */
+85 -20
View File
@@ -11,7 +11,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 } from '../../types.js';
import type { ApiResponse, CaseInfo, DockerHost, SessionDocker } from '../../types.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import {
CreateCaseSchema,
@@ -34,7 +34,9 @@ import type { EventPort, ConfigPort } from '../ports/index.js';
import { dataPath, getDataDir } from '../../config/instance.js';
import {
checkDockerAvailable,
checkDockerImagePresent,
checkDockerTmuxAvailable,
ensureAgentBaseImage,
DEFAULT_AGENT_IMAGE,
dockerContainerName,
dockerDisplayPath,
@@ -83,6 +85,48 @@ async function resolveCasePath(name: string): Promise<string> {
return join(CASES_DIR, name);
}
/**
* Gate a docker case on its base image, AUTO-BUILDING the default image on first
* use so a missing image is never a blocker (the user's ask: "create it when it's
* used for the first time"). Present image → verify tmux (hard prerequisite).
* Default image missing → kick off a BACKGROUND build with SSE progress and return
* `imageBuilding: true` (the case is created regardless; first launch awaits the
* same dedup'd build). Custom image missing → a real error (we can't build a
* foreign ref, and `--pull=never` forbids pulling).
*/
async function ensureCaseImage(
broadcast: EventPort['broadcast'],
sessionDocker: SessionDocker,
name: string
): Promise<{ ok: true; imageBuilding: boolean } | { ok: false; error: string }> {
if (await checkDockerImagePresent(sessionDocker.engine, sessionDocker.image)) {
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
if (!tmuxCheck.ok) return { ok: false, error: tmuxCheck.error || 'base image is missing tmux' };
return { ok: true, imageBuilding: false };
}
if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) {
return {
ok: false,
error: `base image ${sessionDocker.image} not present; only ${DEFAULT_AGENT_IMAGE} is auto-built. Build or pull it first.`,
};
}
broadcast(SseEvent.DockerImageBuildStarted, { name, image: sessionDocker.image });
void ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, {
onProgress: (line) => broadcast(SseEvent.DockerImageBuildProgress, { name, line }),
})
.then((r) =>
broadcast(r.ok ? SseEvent.DockerImageBuildComplete : SseEvent.DockerImageBuildFailed, {
name,
image: sessionDocker.image,
error: r.error,
})
)
.catch((err) =>
broadcast(SseEvent.DockerImageBuildFailed, { name, image: sessionDocker.image, error: getErrorMessage(err) })
);
return { ok: true, imageBuilding: true };
}
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void {
// ═══════════════════════════════════════════════════════════════
// Case CRUD (list, create, link, detail, fix-plan)
@@ -337,7 +381,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
app.post(
'/api/cases/docker-link',
async (req): Promise<ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean }>> => {
async (
req
): Promise<
ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean; imageBuilding?: 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);
@@ -367,9 +415,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
}
}
// 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.
// Courtesy validation: docker daemon must be reachable. The base image is
// auto-built on first use (default image) rather than being a link-time
// blocker, so a missing image kicks off a background build instead of erroring.
const availability = await checkDockerAvailable(host.engine);
if (!availability.ok) {
return createErrorResponse(
@@ -377,9 +425,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
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');
const imageGate = await ensureCaseImage(ctx.broadcast, toSessionDocker(host, dockerCase), dockerCase.name);
if (!imageGate.ok) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, imageGate.error);
}
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, dockerCase]);
@@ -390,7 +438,12 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
});
return {
success: true,
data: { case: dockerCase, capsEnforced: availability.capsEnforced, isDesktop: availability.isDesktop },
data: {
case: dockerCase,
capsEnforced: availability.capsEnforced,
isDesktop: availability.isDesktop,
imageBuilding: imageGate.imageBuilding,
},
};
}
);
@@ -400,7 +453,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
// 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 }>> => {
async (
req
): Promise<
ApiResponse<{ case: unknown; capsEnforced?: boolean; isDesktop?: boolean; imageBuilding?: boolean }>
> => {
const body = parseBody(DockerQuickCreateSchema, req.body);
const { name, description } = body;
const casePath = validatePathWithinBase(name, CASES_DIR);
@@ -458,8 +515,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
);
}
// Probe the daemon + base image BEFORE scaffolding, so a missing docker/image
// surfaces a clear error instead of leaving an orphaned case folder.
// Probe the daemon BEFORE scaffolding so a missing docker surfaces a clear
// error instead of leaving an orphaned case folder. The base image is NOT a
// blocker: a missing default image auto-builds in the background on first use.
const availability = await checkDockerAvailable(host.engine);
if (!availability.ok) {
return createErrorResponse(
@@ -468,12 +526,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
);
}
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)'
);
const imageGate = await ensureCaseImage(ctx.broadcast, toSessionDocker(host, dockerCase), name);
if (!imageGate.ok) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, imageGate.error);
}
// Scaffold the case folder exactly like a normal case.
@@ -492,7 +547,12 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
ctx.broadcast(SseEvent.CaseLinked, { name, path: casePath, type: 'docker' });
return {
success: true,
data: { case: dockerCase, capsEnforced: availability.capsEnforced, isDesktop: availability.isDesktop },
data: {
case: dockerCase,
capsEnforced: availability.capsEnforced,
isDesktop: availability.isDesktop,
imageBuilding: imageGate.imageBuilding,
},
};
}
);
@@ -701,11 +761,16 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
// 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) {
const sessionDocker = toSessionDocker(host, dockerCase);
try {
exec(buildDockerRemoveCommand(toSessionDocker(host, dockerCase)), { timeout: 15_000 }, () => {});
exec(buildDockerRemoveCommand(sessionDocker), { timeout: 15_000 }, () => {});
} catch {
/* best-effort — never blocks the unlink */
}
// Remove the per-container claude-config seed file (account metadata copy).
await fs
.rm(join(dataPath('docker-seeds'), `${sessionDocker.containerName}.json`), { force: true })
.catch(() => {});
}
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'docker-unlinked' });
return { success: true, data: { name } };
+23 -3
View File
@@ -77,6 +77,8 @@ import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRe
import {
checkDockerAvailable,
checkDockerTmuxAvailable,
ensureAgentBaseImage,
DEFAULT_AGENT_IMAGE,
readDockerCases,
readDockerHosts,
toSessionDocker,
@@ -1679,6 +1681,7 @@ export function registerSessionRoutes(
const {
caseName = 'testcase',
sessionName,
mode = 'claude',
openCodeConfig,
codexConfig,
@@ -1758,10 +1761,26 @@ 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.engine, 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).
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
if (!tmuxCheck.ok) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux');
// 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');
}
}
casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container)
@@ -1906,6 +1925,7 @@ export function registerSessionRoutes(
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({
workingDir: resolvedCasePath,
name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
mux: ctx.mux,
useMux: true,
mode: mode,
+3
View File
@@ -536,6 +536,9 @@ export const QuickStartSchema = z.object({
.string()
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.')
.optional(),
/** Display name for the created session tab (e.g. w1-mycase). Cosmetic; the durable
* mux/container names derive from the session id, not this. Defaults server-side. */
sessionName: z.string().max(128).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
+12
View File
@@ -377,6 +377,14 @@ export const DockerExportComplete = 'docker:exportComplete' as const;
export const DockerExportFailed = 'docker:exportFailed' as const;
/** A docker bundle was imported into a new case. */
export const DockerImportComplete = 'docker:importComplete' as const;
/** The agent base image started building (first Docker case; auto-build on first use). */
export const DockerImageBuildStarted = 'docker:imageBuildStarted' as const;
/** A line of agent base-image build output (progress surfacing). */
export const DockerImageBuildProgress = 'docker:imageBuildProgress' as const;
/** The agent base image finished building successfully. */
export const DockerImageBuildComplete = 'docker:imageBuildComplete' as const;
/** The agent base image build failed. */
export const DockerImageBuildFailed = 'docker:imageBuildFailed' as const;
// ─── Namespace Re-export ─────────────────────────────────────────────────────
@@ -564,4 +572,8 @@ export const SseEvent = {
DockerExportComplete,
DockerExportFailed,
DockerImportComplete,
DockerImageBuildStarted,
DockerImageBuildProgress,
DockerImageBuildComplete,
DockerImageBuildFailed,
} as const;