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. */