feat(docker): add the attach-an-existing-container panel

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.
This commit is contained in:
d fei
2026-08-29 21:02:19 -07:00
parent 15eebde832
commit bc55b6b0da
4 changed files with 153 additions and 7 deletions
+12 -3
View File
@@ -2831,6 +2831,15 @@
<h2>Docker</h2>
</div>
<p class="set-section-blurb">Run the case inside a container: one per case, shared by all its sessions.</p>
<div class="form-row">
<label class="checkbox-row"><input type="checkbox" id="dockerAdoptExisting"> Attach to an existing container</label>
<span class="form-hint">On: Codeman only <code>docker exec</code>s 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.</span>
</div>
<div class="form-row docker-adopt-only">
<label>Container Name</label>
<input type="text" id="dockerContainerName" placeholder="my-dev-box" pattern="[a-zA-Z0-9][a-zA-Z0-9_.-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Must be running already. <button type="button" class="btn-inline-check" id="dockerAdoptCheckBtn">Check container</button></span>
</div>
<div class="form-row">
<label>Case Name</label>
<input type="text" id="dockerCaseName" placeholder="sandbox" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
@@ -2846,12 +2855,12 @@
<input type="text" id="dockerHostId" placeholder="local" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">A reusable docker host profile. Reuse the same ID across cases to share settings.</span>
</div>
<div class="form-row">
<div class="form-row docker-create-only">
<label>Image</label>
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini/opencode/agy/pi/grok/dsh + tmux.</span>
</div>
<div class="form-row">
<div class="form-row docker-create-only">
<label>Network</label>
<select id="dockerNetwork">
<option value="bridge">bridge (internet on, default)</option>
@@ -2859,7 +2868,7 @@
<option value="custom">custom bridge</option>
</select>
</div>
<details class="advanced-options">
<details class="advanced-options docker-create-only">
<summary><svg class="set-adv-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg><span>Advanced container settings</span></summary>
<div class="advanced-options-content">
<div class="form-row">
+79 -4
View File
@@ -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 {
+26
View File
@@ -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;
+36
View File
@@ -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