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