diff --git a/src/types/api.ts b/src/types/api.ts
index 7adf4272..11000e5e 100644
--- a/src/types/api.ts
+++ b/src/types/api.ts
@@ -184,6 +184,15 @@ export interface CaseInfo {
container: string;
image?: string;
path: string;
+ /** Directory INSIDE the container (defaults to `path` when unset). */
+ containerWorkdir?: string;
+ /**
+ * False = an ADOPTED container the user built and runs. Only those may back
+ * several cases at once (classifyAdoptContainerConflict), so this is what lets
+ * the UI offer "duplicate for another directory" on exactly the right cases —
+ * an owned container's lifecycle belongs to its one case.
+ */
+ owned?: boolean;
network?: string;
/**
* CLIs available INSIDE the container. A container case runs its agents in
diff --git a/src/web/public/index.html b/src/web/public/index.html
index c1166437..39e956f1 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -2906,6 +2906,13 @@
On: Codeman only runs docker exec 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.
+
+
+
+ Same container, another directory inside it. Picks up the container, host and workspace below — you only set a new name and container workdir. Adopted containers only: an owned container's lifecycle belongs to its one case.
+
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js
index 2cd99370..68e382f4 100644
--- a/src/web/public/session-ui.js
+++ b/src/web/public/session-ui.js
@@ -3153,7 +3153,10 @@ Object.assign(CodemanApp.prototype, {
const adopting = document.getElementById('dockerAdoptExisting')?.checked;
if (adopting) modal.setAttribute('data-docker-adopt', '1');
else modal.removeAttribute('data-docker-adopt');
- if (adopting) void this._loadDockerContainerOptions();
+ if (adopting) {
+ void this._loadDockerContainerOptions();
+ void this._loadDockerCloneOptions();
+ }
},
/**
@@ -3165,6 +3168,79 @@ Object.assign(CodemanApp.prototype, {
* Best-effort by design — the endpoint returns [] for an unreachable daemon,
* and an empty list simply leaves the field as plain text input.
*/
+ /**
+ * Fill the "Duplicate an Existing Case" picker with the ADOPTED docker cases.
+ *
+ * One adopted container can back several cases, each pointing at a different
+ * directory inside it (classifyAdoptContainerConflict) — but re-typing the
+ * container, host and workspace by hand for every directory is exactly the
+ * friction that makes the capability go unused. Picking a case here fills those
+ * three and leaves only the two fields that MUST differ: the case name and the
+ * container workdir.
+ *
+ * ⚠️ Adopted cases only (`docker.owned === false`). An owned container's
+ * lifecycle belongs to its one case — a second case on it would be destroyed
+ * out from under itself by that case's recreate or delete — and the server
+ * refuses it, so offering it here would only produce a confusing error.
+ */
+ async _loadDockerCloneOptions() {
+ const select = document.getElementById('dockerAdoptCloneFrom');
+ const row = document.getElementById('dockerAdoptCloneRow');
+ if (!select || !row) return;
+ let cases = [];
+ try {
+ const res = await fetch('/api/cases');
+ const data = await res.json();
+ cases = (Array.isArray(data) ? data : data?.data || []).filter(
+ (c) => c?.docker && c.docker.owned === false
+ );
+ } catch {
+ cases = [];
+ }
+ select.textContent = '';
+ const blank = document.createElement('option');
+ blank.value = '';
+ blank.textContent = 'Start from scratch';
+ select.appendChild(blank);
+ for (const c of cases) {
+ const option = document.createElement('option');
+ option.value = c.name;
+ // Server-supplied strings: textContent, never markup.
+ option.textContent = `${c.name} — ${c.docker.container}:${c.docker.containerWorkdir || c.docker.path}`;
+ option.dataset.container = c.docker.container;
+ option.dataset.hostId = c.docker.hostId;
+ option.dataset.path = c.docker.path;
+ select.appendChild(option);
+ }
+ // Nothing to duplicate yet: an empty picker is noise on the first adoption.
+ row.hidden = cases.length === 0;
+ },
+
+ /**
+ * Apply the picked case: carry over what STAYS the same, clear what must not.
+ *
+ * The two cleared fields are the point of the feature — a duplicate that kept
+ * the original's name would be rejected as an existing case, and one that kept
+ * its container workdir would be rejected as an exact twin (both by the server,
+ * with a clear message, but a form that pre-fills a value it knows will be
+ * refused is just a trap).
+ */
+ applyDockerCloneSource() {
+ const select = document.getElementById('dockerAdoptCloneFrom');
+ const option = select?.selectedOptions?.[0];
+ if (!option || !option.value) return;
+ const set = (id, value) => {
+ const el = document.getElementById(id);
+ if (el) el.value = value || '';
+ };
+ set('dockerContainerName', option.dataset.container);
+ set('dockerHostId', option.dataset.hostId);
+ set('dockerWorkspacePath', option.dataset.path);
+ set('dockerCaseName', '');
+ set('dockerAdoptWorkdir', '');
+ document.getElementById('dockerAdoptWorkdir')?.focus();
+ },
+
async _loadDockerContainerOptions() {
const list = document.getElementById('dockerContainerList');
if (!list) return;
diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts
index 31551df0..492b52a3 100644
--- a/src/web/routes/case-routes.ts
+++ b/src/web/routes/case-routes.ts
@@ -328,6 +328,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
container,
image: host.image,
path: dockerCase.hostWorkspacePath,
+ containerWorkdir: dockerCase.containerWorkdir ?? dockerCase.hostWorkspacePath,
+ owned: dockerCase.owned !== false,
network: host.network ?? 'bridge',
...(dockerCase.availableModes ? { availableModes: dockerCase.availableModes } : {}),
...(dockerCase.owned === false ? { owned: false } : {}),
@@ -1607,6 +1609,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
container,
image: host.image,
path: dockerCase.hostWorkspacePath,
+ containerWorkdir: dockerCase.containerWorkdir ?? dockerCase.hostWorkspacePath,
+ owned: dockerCase.owned !== false,
network: host.network ?? 'bridge',
},
};
diff --git a/test/docker-adopt-duplicate.test.ts b/test/docker-adopt-duplicate.test.ts
new file mode 100644
index 00000000..3196907d
--- /dev/null
+++ b/test/docker-adopt-duplicate.test.ts
@@ -0,0 +1,96 @@
+/**
+ * @fileoverview "Duplicate an existing case" in the container-adoption form.
+ *
+ * The server already allows one ADOPTED container to back several cases, each
+ * pointing at a different directory inside it (classifyAdoptContainerConflict).
+ * Re-typing the container, host and workspace by hand for every directory is the
+ * friction that would leave that capability unused, so the form carries them over
+ * and clears only the two fields that MUST differ.
+ */
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { describe, it, expect } from 'vitest';
+
+const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
+const ui = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
+const routes = readFileSync(resolve(import.meta.dirname, '../src/web/routes/case-routes.ts'), 'utf8');
+const apiTypes = readFileSync(resolve(import.meta.dirname, '../src/types/api.ts'), 'utf8');
+
+describe('the API exposes what the picker needs', () => {
+ it('reports each docker case s directory inside the container', () => {
+ // Without it the picker cannot show WHICH directory a case already uses, which
+ // is the one thing the user needs to see before choosing a different one.
+ expect(apiTypes).toMatch(/containerWorkdir\?: string;/);
+ expect(routes).toContain('containerWorkdir: dockerCase.containerWorkdir ?? dockerCase.hostWorkspacePath');
+ });
+
+ it('reports whether the container is owned, on EVERY case-shaped response', () => {
+ // Two sites build a docker CaseInfo (the list and the single-case lookup);
+ // filling only one leaves the picker blind depending on which the UI read.
+ expect(routes.match(/owned: dockerCase\.owned !== false,/g) ?? []).toHaveLength(2);
+ });
+
+ it('treats an ABSENT owned flag as owned, so legacy cases are not offered', () => {
+ // `owned` is optional and predates this field; truthiness would read a legacy
+ // case as adopted and offer a duplicate the server then refuses.
+ expect(routes).toContain('dockerCase.owned !== false');
+ });
+});
+
+describe('the picker only offers what the server would accept', () => {
+ const fn = ui.slice(ui.indexOf('async _loadDockerCloneOptions()'), ui.indexOf('applyDockerCloneSource()'));
+
+ it('filters to ADOPTED cases only', () => {
+ expect(fn).toMatch(/c\.docker\.owned === false/);
+ });
+
+ it('hides the row entirely when there is nothing to duplicate', () => {
+ expect(fn).toMatch(/row\.hidden = cases\.length === 0/);
+ });
+
+ it('builds options with textContent, never markup', () => {
+ // Case names and container names are user- and engine-supplied strings.
+ expect(fn).toContain('option.textContent =');
+ expect(fn).not.toContain('innerHTML');
+ });
+});
+
+describe('applying a source fills what stays and clears what must differ', () => {
+ const fn = ui.slice(ui.indexOf('applyDockerCloneSource()'), ui.indexOf('applyDockerCloneSource()') + 1400);
+
+ it('carries over container, host and workspace', () => {
+ for (const id of ['dockerContainerName', 'dockerHostId', 'dockerWorkspacePath']) {
+ expect(fn).toContain(`set('${id}', option.dataset.`);
+ }
+ });
+
+ it('clears the case name and the container workdir', () => {
+ // Keeping either would pre-fill a value the server is certain to refuse —
+ // the name as an existing case, the workdir as an exact twin.
+ expect(fn).toContain("set('dockerCaseName', '')");
+ expect(fn).toContain("set('dockerAdoptWorkdir', '')");
+ });
+
+ it('focuses the workdir, the field the user came here to change', () => {
+ expect(fn).toMatch(/getElementById\('dockerAdoptWorkdir'\)\?\.focus\(\)/);
+ });
+
+ it('does nothing for the blank "start from scratch" option', () => {
+ expect(fn).toMatch(/if \(!option \|\| !option\.value\) return;/);
+ });
+});
+
+describe('the row is wired into the adoption panel', () => {
+ it('lives in the adopt-only block and starts hidden', () => {
+ expect(html).toMatch(/id="dockerAdoptCloneRow"[^>]*hidden/);
+ expect(html).toMatch(/class="form-row docker-adopt-only" id="dockerAdoptCloneRow"/);
+ });
+
+ it('loads its options whenever adopt mode turns on', () => {
+ // Slice from the DEFINITION, not the first call site.
+ const start = ui.indexOf('_syncDockerAdoptMode() {');
+ expect(start).toBeGreaterThan(-1);
+ const sync = ui.slice(start, start + 900);
+ expect(sync).toContain('_loadDockerCloneOptions()');
+ });
+});