Merge feat/docker-session-mode into master (docker deep-review fixes)

Brings the docker session-mode deep-review work (intended for the skipped
1.4.2) onto the 1.5.x line: deterministic-conversation-id resume across
container stop/recreate, config-drift detection + POST /api/docker-cases/:name/recreate,
docker model-picker support, import-manifest hardening, remote-daemon (context/
daemonHost) correctness, comma-in-path rejection, and the zh-CN README re-translation.

Conflicts resolved to preserve BOTH the multi-user security scoping already on
master (ownership checks, workingDir confinement, permission downgrade) AND the
docker features. Version kept at master's 1.5.0 (the 1.4.2 bump is superseded;
a fresh changeset bumps to 1.5.1). tsc, eslint, and test:ci all green (3548 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-20 13:45:37 +02:00
22 changed files with 815 additions and 185 deletions
+20
View File
@@ -1452,6 +1452,26 @@ class CodemanApp {
console.error('[SSE] docker export failed:', err);
}
});
// Import + drift-recreate completions: refresh case lists in EVERY open tab
// (the initiating tab already refreshes via its own fetch response).
addListener(SSE_EVENTS.DOCKER_IMPORT_COMPLETE, (e) => {
try {
const d = e.data ? JSON.parse(e.data) : {};
this.showToast(`Docker bundle imported as case "${d.name}"`, 'success');
this.loadQuickStartCases?.();
this.refreshDockerExports?.();
} catch (err) {
console.error('[SSE] docker import complete:', err);
}
});
addListener(SSE_EVENTS.DOCKER_CONTAINER_RECREATED, (e) => {
try {
const d = e.data ? JSON.parse(e.data) : {};
this.showToast(`Container for "${d.name}" removed — next launch recreates it with the new config`, 'info');
} catch (err) {
console.error('[SSE] docker container recreated:', 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, () => {
+1
View File
@@ -484,6 +484,7 @@ const SSE_EVENTS = {
// Multi-user (admin-only / targeted)
ADMIN_USERS_CHANGED: 'admin:usersChanged',
AUTH_PASSWORD_CHANGE_REQUIRED: 'auth:passwordChangeRequired',
DOCKER_CONTAINER_RECREATED: 'docker:containerRecreated',
};
// ═══════════════════════════════════════════════════════════════
+2 -2
View File
@@ -1453,7 +1453,7 @@
<option value="normal">Normal (with prompts)</option>
<option value="allowedTools">Allowed Tools Only</option>
</select>
<span class="form-hint">How Claude CLI is started in screen sessions</span>
<span class="form-hint">How Claude CLI is started in screen sessions. Auto Mode runs without routine prompts behind a background safety classifier (needs Claude Code 2.1.207+ and Opus 4.6+/Sonnet 4.6+/Fable 5)</span>
</div>
<div class="form-row" id="allowedToolsRow" style="display: none;">
<label>Allowed Tools</label>
@@ -1875,7 +1875,7 @@
</div>
<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>
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.</span>
</div>
<details class="advanced-options docker-quick-settings" id="dockerQuickSettings">
<summary>Container settings (optional, sensible defaults)</summary>
+7 -5
View File
@@ -459,16 +459,18 @@ html.mobile-init .file-browser-panel {
height: 12px;
}
/* Hide header settings gear, lifecycle log, away digest, and session manager on
mobile - settings moved to toolbar; away digest and the session manager are
secondary controls that don't belong on the cramped phone header (the session
manager stays reachable via the Ctrl+K palette's "Browse all sessions" item).
/* Hide header settings gear, lifecycle log, away digest, session manager, and
file viewer on mobile - settings moved to toolbar; the others are secondary /
desktop-oriented controls that don't belong on the cramped phone header (the
session manager stays reachable via the Ctrl+K palette's "Browse all sessions"
item; the file viewer button is opt-in but its panel is desktop-sized).
(The attachments button is opt-in / default-hidden everywhere via its own
--hidden marker, so it needs no mobile-specific rule here.) */
.btn-icon-header.btn-settings,
.btn-icon-header.btn-lifecycle-log,
.btn-icon-header.btn-away-digest,
.btn-icon-header.btn-session-manager {
.btn-icon-header.btn-session-manager,
.btn-icon-header.btn-file-viewer {
display: none !important;
}
+43 -6
View File
@@ -551,14 +551,50 @@ Object.assign(CodemanApp.prototype, {
// 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);
// Docker (NOT remote): the App Settings Claude Model choice applies — the
// workspace is a real host dir, so quick-start writes it to the case's
// .claude/settings.local.json and the in-container claude reads it.
// Remote quick-starts REJECT modelOverride (the file would land on the
// wrong machine), so never send it there.
let dockerModelOverride;
if (caseData.location === 'docker') {
const dockerGlobalSettings = this.loadAppSettingsFromStorage();
const dockerCaseSettings = this.getCaseSettings(caseName);
const dockerUseOpus1m = dockerCaseSettings.opusContext1m || dockerGlobalSettings.opusContext1mEnabled;
dockerModelOverride = dockerGlobalSettings.claudeModel || (dockerUseOpus1m ? 'opus[1m]' : '');
}
const remoteIds = [];
let driftHandled = false;
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', sessionName: `w${startNumber + i}-${caseName}` })
const quickStartBody = JSON.stringify({
caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}`,
...(dockerModelOverride !== undefined ? { modelOverride: dockerModelOverride } : {})
});
const data = await res.json();
const doQuickStart = async () => {
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: quickStartBody
});
return res.json();
};
let data = await doQuickStart();
// Docker config drift: the host config changed since the container was
// created (CONFLICT from quick-start). Confirm once, recreate, retry.
if (!data.success && data.errorCode === 'CONFLICT' && caseData.location === 'docker' && !driftHandled) {
driftHandled = true;
const recreate = confirm(
`Container config for "${caseName}" changed since its container was created.\n\n` +
'Recreate the container to apply the new config? Workspace files and the ' +
'conversation survive (the conversation resumes on launch).'
);
if (recreate) {
const recRes = await fetch(`/api/docker-cases/${encodeURIComponent(caseName)}/recreate`, { method: 'POST' });
const recData = await recRes.json();
if (!recData.success) throw new Error(recData.error || 'Failed to recreate container');
data = await doQuickStart();
}
}
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session');
remoteIds.push(data.data.sessionId);
}
@@ -1958,7 +1994,8 @@ Object.assign(CodemanApp.prototype, {
listEl.innerHTML = exports
.map(e => {
const mb = (e.sizeBytes / 1e6).toFixed(1);
const nm = this.escapeHtml ? this.escapeHtml(e.name) : e.name;
// escapeHtml is the free function from constants.js (never a method on `this`)
const nm = escapeHtml(e.name);
return `<div class="case-manage-item" style="display:flex; align-items:center; gap:8px; justify-content:space-between;">
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${nm}">${nm} <span class="form-hint">(${mb} MB)</span></span>
<span style="flex-shrink:0;">