From bc55b6b0daa352929b08e626f1aee91d68459616 Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 21:02:19 -0700 Subject: [PATCH] feat(docker): add the attach-an-existing-container panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Docker tab gains an "Attach to an existing container" toggle. Ticking it swaps the create-time fields (image, network, advanced) — which describe a `docker create` attaching never runs — for the container name, and routes the submit to the adopt endpoint. Reuses the existing linkDockerCase flow end to end: only the final call differs. The docker-host upsert still applies, since it is what resolves the engine/context/daemon for `docker exec`; its create-time fields are simply never read for an attached case. --- src/web/public/index.html | 15 ++++- src/web/public/session-ui.js | 83 +++++++++++++++++++++++++-- src/web/public/styles.css | 26 +++++++++ test/docker-adopted-container.test.ts | 36 ++++++++++++ 4 files changed, 153 insertions(+), 7 deletions(-) diff --git a/src/web/public/index.html b/src/web/public/index.html index d9491d45..bb5757b0 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2831,6 +2831,15 @@

Docker

Run the case inside a container: one per case, shared by all its sessions.

+
+ + On: Codeman only docker execs into a container you already built and run — it never creates, starts, stops or removes it. The CLIs must already be installed and logged in inside it. +
+
+ + + Must be running already. +
@@ -2846,12 +2855,12 @@ A reusable docker host profile. Reuse the same ID across cases to share settings.
-
+
Build it once with node scripts/build-agent-image.mjs. Contains node + claude/codex/gemini/opencode/agy/pi/grok/dsh + tmux.
-
+
-
+
Advanced container settings
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 6de0507c..24c1f49b 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2334,6 +2334,14 @@ Object.assign(CodemanApp.prototype, { modal.querySelectorAll('.set-rail-item').forEach(btn => { btn.onclick = () => this.switchCaseModalTab(btn.dataset.tab); }); + // Adopt-an-existing-container toggle + its read-only preflight. Assigned (not + // addEventListener) so reopening the modal cannot stack duplicate handlers, + // matching the rail wiring right above. + const adoptToggle = document.getElementById('dockerAdoptExisting'); + if (adoptToggle) adoptToggle.onchange = () => this._syncDockerAdoptMode(); + const adoptCheck = document.getElementById('dockerAdoptCheckBtn'); + if (adoptCheck) adoptCheck.onclick = () => this._dockerAdoptPreflight(); + this._syncDockerAdoptMode(); // Scroll-into-view on focus for mobile keyboard visibility modal.querySelectorAll('input[type="text"]').forEach(input => { if (!input._mobileScrollWired) { @@ -2890,10 +2898,64 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * Reflect the "attach to an existing container" checkbox onto the modal so CSS + * can swap which half of the Docker panel applies. An attribute rather than + * per-row inline styles: the panel is rebuilt by nothing, but the create-time + * rows are a SET (image, network, advanced block) and one attribute keeps them + * in lockstep with the container-name row. + */ + _syncDockerAdoptMode() { + const modal = document.getElementById('createCaseModal'); + if (!modal) return; + const adopting = document.getElementById('dockerAdoptExisting')?.checked; + if (adopting) modal.setAttribute('data-docker-adopt', '1'); + else modal.removeAttribute('data-docker-adopt'); + }, + + /** + * Read-only preflight against an existing container. It links nothing, so the + * user can find out "not running" / "no tmux" / "codex present, claude missing" + * before committing to a case name — the same reason the server refuses at link + * time rather than at session launch. + */ + async _dockerAdoptPreflight() { + const statusEl = document.getElementById('dockerLinkStatus'); + const container = document.getElementById('dockerContainerName')?.value.trim(); + const hostId = document.getElementById('dockerHostId').value.trim() || 'local'; + if (!container) { + if (statusEl) statusEl.textContent = 'Enter a container name first.'; + return; + } + if (statusEl) statusEl.textContent = 'Inspecting container...'; + // _apiJson folds every failure to null, and a preflight's whole value is the + // reason it failed, so the envelope is unwrapped by hand here. + const probe = await this._apiJson('/api/docker-cases/adopt-preflight', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hostId, container }), + }); + if (!statusEl) return; + if (!probe) { + statusEl.textContent = 'Could not reach the docker host profile. Save a Host ID first.'; + return; + } + if (!probe.ok) { + statusEl.textContent = probe.error || 'Container is not adoptable.'; + return; + } + const modes = (probe.availableModes || []).filter((m) => m !== 'shell'); + statusEl.textContent = modes.length + ? `Running (${probe.image || 'unknown image'}). Available: ${modes.join(', ')}.` + : `Running (${probe.image || 'unknown image'}), but no agent CLI found inside — only Shell will work.`; + }, + async linkDockerCase() { const name = document.getElementById('dockerCaseName').value.trim(); const hostWorkspacePath = document.getElementById('dockerWorkspacePath').value.trim(); const hostId = document.getElementById('dockerHostId').value.trim() || 'local'; + const adopting = !!document.getElementById('dockerAdoptExisting')?.checked; + const container = document.getElementById('dockerContainerName')?.value.trim() || ''; const image = document.getElementById('dockerImage').value.trim() || 'codeman/agent:base'; const network = document.getElementById('dockerNetwork').value; const memory = document.getElementById('dockerMemory').value.trim(); @@ -2914,9 +2976,15 @@ Object.assign(CodemanApp.prototype, { this.showToast('Workspace path must be absolute', 'error'); return; } + if (adopting && !container) { + this.showToast('Enter the name of the running container to attach to', 'error'); + return; + } try { - if (statusEl) statusEl.textContent = 'Checking docker daemon + base image...'; + if (statusEl) { + statusEl.textContent = adopting ? 'Inspecting the existing container...' : 'Checking docker daemon + base image...'; + } // omitted optionals sent as UNDEFINED (never null — Zod .optional() rejects null) const resources = {}; if (memory) resources.memory = memory; @@ -2947,16 +3015,23 @@ Object.assign(CodemanApp.prototype, { } if (!hostData.success) throw new Error(hostData.error || 'Failed to save docker host'); - const caseRes = await fetch('/api/cases/docker-link', { + // Adoption reuses this whole flow and differs only in the final call: a + // different endpoint (which never creates a container) plus the container + // name. The host upsert above still applies — it is what resolves the + // engine/context/daemon for the `docker exec`; its create-time fields are + // simply never read for an adopted case. + const caseRes = await fetch(adopting ? '/api/cases/docker-adopt' : '/api/cases/docker-link', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, hostId, hostWorkspacePath }), + body: JSON.stringify(adopting ? { name, hostId, hostWorkspacePath, container } : { name, hostId, hostWorkspacePath }), }); const caseData = await caseRes.json(); if (caseData.success) { this.closeCreateCaseModal(); const caps = caseData.data?.capsEnforced === false ? ' (resource caps are advisory on this engine)' : ''; - this.showToast(`Docker case "${name}" linked${caps}`, 'success'); + const modes = (caseData.data?.availableModes || []).filter((m) => m !== 'shell'); + const found = adopting && modes.length ? ` — found ${modes.join(', ')}` : ''; + this.showToast(`Docker case "${name}" ${adopting ? 'attached' : 'linked'}${caps}${found}`, 'success'); await this.loadQuickStartCases(name); await this.saveLastUsedCase(name); } else { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 8efb776e..7ca4edc3 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -16601,6 +16601,32 @@ html[data-tab-orientation='vertical'] .home-sessions { label as a row label, its `.form-hint` as a row description. Scoped to the document, so `.form-row` everywhere else is untouched. ─────────────────────────────────────────────────────────────────────────── */ +/* Adopt-an-existing-container mode swaps which half of the Docker panel applies: + the create-time fields (image, network, resources, credential mounts) describe + a `docker create` that adoption never runs, and the container name is the one + field only adoption needs. `.docker-adopt-only` is hidden by default so the + panel stays exactly as it was until the checkbox is ticked. Rules carry + `!important` because the adapter block above paints `.form-row` as a row card + and `details.advanced-options` has its own display. */ +#createCaseModal .docker-adopt-only { + display: none !important; +} +#createCaseModal[data-docker-adopt='1'] .docker-adopt-only { + display: block !important; +} +#createCaseModal[data-docker-adopt='1'] .docker-create-only { + display: none !important; +} +#createCaseModal .btn-inline-check { + background: none; + border: none; + padding: 0; + font: inherit; + color: var(--accent, #4a9eff); + cursor: pointer; + text-decoration: underline; +} + #createCaseModal .set-doc .form-row { margin: 0 0 3px; padding: 7px 10px; diff --git a/test/docker-adopted-container.test.ts b/test/docker-adopted-container.test.ts index 9561bf49..cd15a443 100644 --- a/test/docker-adopted-container.test.ts +++ b/test/docker-adopted-container.test.ts @@ -10,6 +10,7 @@ * Mirror of the `owned:false` remote-SSH contract (COD-105). */ import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; import { toSessionDocker, isAdoptedContainer, @@ -158,6 +159,41 @@ describe('adopted container: mutating verbs fail closed at the builder', () => { }); }); +describe('adopted container: the Add Case panel id contract', () => { + // The modal's load/save contract is getElementById by fixed id, so a renamed or + // dropped id stops the control working with no error anywhere. Static guard in + // the style of app-settings-structure / session-options-structure. + const html = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf8'); + const ui = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf8'); + const css = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf8'); + + it('ships every id session-ui.js reads back', () => { + for (const id of ['dockerAdoptExisting', 'dockerContainerName', 'dockerAdoptCheckBtn']) { + expect(html).toContain(`id="${id}"`); + expect(ui).toContain(`'${id}'`); + } + }); + + it('routes adoption to the endpoint that never creates a container', () => { + expect(ui).toContain('/api/cases/docker-adopt'); + expect(ui).toContain('/api/docker-cases/adopt-preflight'); + // The create path must survive untouched beside it. + expect(ui).toContain('/api/cases/docker-link'); + }); + + it('hides the adopt-only row until the toggle is on, so the panel is unchanged by default', () => { + expect(css).toContain('#createCaseModal .docker-adopt-only'); + expect(css).toMatch(/#createCaseModal \.docker-adopt-only \{\s*display: none/); + expect(css).toContain("#createCaseModal[data-docker-adopt='1'] .docker-adopt-only"); + }); + + it('marks the create-time rows so adoption hides the fields it never uses', () => { + // image / network / advanced describe a `docker create` adoption never runs. + expect(html.match(/docker-create-only/g)?.length).toBeGreaterThanOrEqual(3); + expect(css).toContain("#createCaseModal[data-docker-adopt='1'] .docker-create-only"); + }); +}); + describe('adopted container: drift is not evaluated', () => { it('reports no drift rather than demanding a recreate we may not perform', async () => { // An adopted container carries no codeman.confighash label, so a real