{
+ it('hides the footer on phones, so the header must carry a submit button', () => {
+ expect(mobileCss).toMatch(
+ /:is\(#appSettingsModal, #sessionOptionsModal, #createCaseModal\) \.set-foot \{\s*display: none;/
+ );
+ const modal = caseModal();
+ const head = modal.slice(0, modal.indexOf('
'));
+ const closeIdx = head.indexOf('class="modal-close"');
+ const saveIdx = head.indexOf('class="set-head-save" id="caseModalSubmitMobile" onclick="app.submitCaseModal()"');
+ expect(closeIdx).toBeGreaterThan(-1);
+ expect(saveIdx).toBeGreaterThan(-1);
+ // Close stays first in the DOM; row-reverse paints Save to its left.
+ expect(closeIdx).toBeLessThan(saveIdx);
+ expect(modal).toContain('id="caseModalSubmit" onclick="app.submitCaseModal()"');
+ });
+
+ it('drives the footer and header submit buttons together', () => {
+ for (const sig of ['switchCaseModalTab(tabName)', 'async submitCaseModal()']) {
+ const body = methodBody(sig);
+ expect(body, sig).toContain("'caseModalSubmit'");
+ expect(body, sig).toContain("'caseModalSubmitMobile'");
+ }
+ });
+
+ it('dims the header button while a submit is pending, where it is the only one visible', () => {
+ expect(mobileCss).toMatch(/#createCaseModal \.set-head-save\.loading \{\s*opacity: 0\.6;\s*pointer-events: none;/);
+ });
+});
diff --git a/test/daemon-control.test.ts b/test/daemon-control.test.ts
index 10651337..a5bb642f 100644
--- a/test/daemon-control.test.ts
+++ b/test/daemon-control.test.ts
@@ -52,6 +52,16 @@ describe('buildWebArgs', () => {
]);
});
+ it('forwards --base-url so a detached/service relaunch keeps the mount prefix', () => {
+ const args = buildWebArgs({ host: '127.0.0.1', port: 3000, https: false, basePath: '/codeman' });
+ expect(args).toContain('--base-url');
+ expect(args[args.indexOf('--base-url') + 1]).toBe('/codeman');
+ });
+
+ it('omits --base-url at root (empty basePath)', () => {
+ expect(buildWebArgs({ host: '127.0.0.1', port: 3000, https: false, basePath: '' })).not.toContain('--base-url');
+ });
+
it('never re-emits the daemon flags themselves (the child must not re-fork)', () => {
const args = buildWebArgs({ host: '127.0.0.1', port: 3000, https: false });
expect(args).not.toContain('--daemon');
diff --git a/test/docker-adopted-container.test.ts b/test/docker-adopted-container.test.ts
new file mode 100644
index 00000000..967c5237
--- /dev/null
+++ b/test/docker-adopted-container.test.ts
@@ -0,0 +1,453 @@
+/**
+ * @fileoverview Adopting an ALREADY-RUNNING container (`DockerCase.owned === false`).
+ *
+ * The whole point of adoption is a negative guarantee: Codeman execs into a
+ * container the user built and runs, and never creates, starts, stops, restarts
+ * or removes it. A negative guarantee cannot be observed by using the feature —
+ * only by asserting that the mutating verbs are absent — so these tests read the
+ * generated command strings and assert on what is NOT in them.
+ *
+ * Mirror of the `owned:false` remote-SSH contract (COD-105).
+ */
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'node:fs';
+import {
+ defaultDockerCommandForMode,
+ toSessionDocker,
+ isAdoptedContainer,
+ removeDockerContainer,
+ checkDockerConfigDrift,
+ dockerConfigHash,
+ dockerAdoptProbeModes,
+} from '../src/docker-hosts.js';
+import { enabledCliIds, getCli } from '../src/config/cli-registry/index.js';
+import {
+ buildDockerLaunchCommand,
+ buildDockerStopCommand,
+ buildDockerRemoveCommand,
+ buildDockerKillCommand,
+} from '../src/tmux-manager.js';
+import type { DockerCase, DockerHost, SessionDocker } from '../src/types.js';
+
+const HOST: DockerHost = { id: 'h1', label: 'local', engine: 'docker', image: 'codeman/agent:base' };
+
+function caseFor(owned: boolean | undefined): DockerCase {
+ return {
+ name: 'adopted',
+ type: 'docker',
+ hostId: 'h1',
+ hostWorkspacePath: '/srv/work',
+ container: 'my-own-container',
+ ...(owned === undefined ? {} : { owned }),
+ };
+}
+
+function launchFor(docker: SessionDocker): string {
+ return buildDockerLaunchCommand({
+ mode: 'codex',
+ docker,
+ sessionId: '11111111-2222-3333-4444-555555555555',
+ createContext: {
+ docker,
+ sessionId: '11111111-2222-3333-4444-555555555555',
+ instance: 'default',
+ userArgs: ['--user', '1000:0'],
+ credentialMounts: [],
+ extraMounts: [],
+ envCreate: { HOME: '/home/agent' },
+ addHostGateway: true,
+ gatewayAlias: 'host.docker.internal',
+ },
+ execEnv: { TERM: 'xterm-256color' },
+ execEnvNames: [],
+ seedCopies: [{ from: '/seed/creds.json', to: '/home/agent/.claude/.credentials.json' }],
+ });
+}
+
+describe('adopted container: ownership plumbing', () => {
+ it('carries owned:false from the case onto the live session metadata', () => {
+ expect(toSessionDocker(HOST, caseFor(false)).owned).toBe(false);
+ expect(isAdoptedContainer(toSessionDocker(HOST, caseFor(false)))).toBe(true);
+ });
+
+ it('treats an absent flag as owned, so existing cases are unchanged', () => {
+ const docker = toSessionDocker(HOST, caseFor(undefined));
+ expect(docker.owned).toBeUndefined();
+ expect(isAdoptedContainer(docker)).toBe(false);
+ });
+
+ it('keeps ownership OUT of the config hash so adoption cannot mass-trip drift', () => {
+ // A drift-hash that moved with `owned` would flag every pre-existing case the
+ // moment this field shipped, and the remedy the UI offers is "recreate".
+ const owned = toSessionDocker(HOST, caseFor(undefined));
+ const adopted = toSessionDocker(HOST, caseFor(false));
+ expect(adopted.configHash).toBe(owned.configHash);
+ expect(dockerConfigHash({ ...owned, owned: false } as never)).toBe(owned.configHash);
+ });
+});
+
+describe('adopted container: the launch chain never mutates lifecycle', () => {
+ const adopted = launchFor(toSessionDocker(HOST, caseFor(false)));
+ const owned = launchFor(toSessionDocker(HOST, caseFor(undefined)));
+
+ it('never creates the container', () => {
+ expect(owned).toContain('docker create');
+ expect(adopted).not.toContain('docker create');
+ });
+
+ it('never starts the container', () => {
+ expect(owned).toContain('docker start');
+ expect(adopted).not.toContain('docker start');
+ });
+
+ it('never stops or removes the container', () => {
+ for (const verb of ['docker stop', 'docker rm', 'docker restart', 'docker kill']) {
+ expect(adopted).not.toContain(verb);
+ }
+ });
+
+ it('fails closed when the container is missing instead of creating it', () => {
+ expect(adopted).toContain('docker inspect');
+ expect(adopted).toMatch(/not found.*start it yourself/i);
+ });
+
+ it('fails closed when the container is stopped instead of starting it', () => {
+ expect(adopted).toMatch(/\{\{\.State\.Running\}\}/);
+ expect(adopted).toMatch(/not running.*never starts a container it does not own/i);
+ });
+
+ it('uses no double quote and no command substitution in the launch chain', () => {
+ // The whole chain is embedded in an outer `bash -c "…"`. An unescaped `"`
+ // closes that string early, the remainder is re-tokenized, and tmux fails to
+ // exec with a bare `execvp(3) failed: No such file or directory` — no hint
+ // that the command was ever malformed. `$(…)` is banned with it because it
+ // is then evaluated by the wrong shell at the wrong time.
+ expect(adopted).not.toContain('"');
+ expect(adopted).not.toContain('$(');
+ // Every other line already quotes with the single-quote helper.
+ expect(adopted).toContain('grep -qx true');
+ });
+
+ it('skips the base-image gate, which describes an image adoption never uses', () => {
+ expect(owned).toContain('image inspect');
+ expect(adopted).not.toContain('image inspect');
+ });
+
+ it('never seeds host credentials into a container it does not own', () => {
+ expect(owned).toContain('.credentials.json');
+ expect(adopted).not.toContain('.credentials.json');
+ });
+
+ it('still execs into the in-container tmux, which is the whole point', () => {
+ expect(adopted).toContain('docker exec -it');
+ expect(adopted).toContain('new-session -A');
+ });
+});
+
+describe('adopted container: the probe request must reach the server', () => {
+ const ui = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf8');
+ const api = readFileSync(new URL('../src/web/public/api-client.js', import.meta.url), 'utf8');
+
+ it('never hands _apiJson an already-stringified body', () => {
+ // _api serializes `body` and sets Content-Type itself. Passing a string
+ // double-encodes it, the server sees a JSON string where it expects an
+ // object, and answers 400 INVALID_INPUT — which the caller reads as "the
+ // container could not be probed", so the menu silently showed every mode.
+ expect(api).toContain('fetchOpts.body = JSON.stringify(body)');
+ const calls = [...ui.matchAll(/_apiJson\([^)]*\{[\s\S]{0,400}?\}\s*\)/g)].map((m) => m[0]);
+ expect(calls.length).toBeGreaterThan(0);
+ for (const call of calls) expect(call).not.toContain('body: JSON.stringify');
+ });
+
+ it('hides every agent mode and says why when the container cannot be read', () => {
+ // Offering claude on a container that is not running is a click that can
+ // only fail, with the reason visible nowhere.
+ // Brace-matched, not a character window: slicing between two call sites
+ // silently yields '' when the second one appears ABOVE the first, and the
+ // assertion then passes over nothing. That has bitten this file twice.
+ const start = ui.indexOf('async _probeDockerCaseModes(activeCase, menu) {');
+ expect(start).toBeGreaterThan(-1);
+ const open = ui.indexOf('{', start);
+ let depth = 0;
+ let fn = '';
+ for (let i = open; i < ui.length; i++) {
+ if (ui[i] === '{') depth++;
+ else if (ui[i] === '}' && --depth === 0) {
+ fn = ui.slice(start, i + 1);
+ break;
+ }
+ }
+ expect(fn).toContain('_dockerCaseProbeError');
+ expect(ui).toContain('_renderRunModeNotice');
+ });
+});
+
+describe('adopted container: claude as root', () => {
+ it('drops --dangerously-skip-permissions when the container runs as root', () => {
+ // Claude Code refuses the flag as root ("cannot be used with root/sudo
+ // privileges"), so keeping it kills the pane with a message only visible
+ // inside the container. Our base image runs a non-root user, which is why an
+ // owned container never hit this.
+ expect(defaultDockerCommandForMode('claude', true)).toBe('exec claude');
+ expect(defaultDockerCommandForMode('claude', false)).toContain('--dangerously-skip-permissions');
+ expect(defaultDockerCommandForMode('claude')).toContain('--dangerously-skip-permissions');
+ });
+
+ it('leaves every other mode unchanged as root', () => {
+ for (const mode of ['codex', 'shell', 'pi'] as const) {
+ expect(defaultDockerCommandForMode(mode, true)).toBe(defaultDockerCommandForMode(mode, false));
+ }
+ });
+});
+
+describe('adopted container: the host is not required to have the CLI', () => {
+ const src = readFileSync(new URL('../src/tmux-manager.ts', import.meta.url), 'utf8');
+
+ it('skips the host CLI requirement for a docker session', () => {
+ // A docker session runs its CLI inside the container. Demanding it on the
+ // host threw, the catch fell back to a direct PTY, and that PTY tried to
+ // exec the CLI on the HOST — surfacing as a bare `execvp(3) failed` with
+ // nothing naming the real cause.
+ //
+ // The CLI registry collapsed the old per-mode `mode === 'claude' && !cliDir` chain
+ // into ONE `missingCliMessage(mode)` gate, so the guarantee is now that the single
+ // gate carries the docker exemption and that no per-mode arm has grown back.
+ expect(src).toContain('if (!cliRunsInContainer && !cliDir) {');
+ expect(src.match(/if \(mode === '[a-z]+' && !cliDir\)/g)).toBeNull();
+ });
+
+ it('derives the flag from the docker metadata the session already carries', () => {
+ expect(src).toContain('const cliRunsInContainer = !!docker;');
+ });
+});
+
+describe('adopted container: mutating verbs fail closed at the builder', () => {
+ const docker = toSessionDocker(HOST, caseFor(false));
+
+ it('refuses to build a stop command', () => {
+ expect(() => buildDockerStopCommand(docker)).toThrow(/does not own its lifecycle/);
+ });
+
+ it('refuses to build a remove command', () => {
+ expect(() => buildDockerRemoveCommand(docker)).toThrow(/does not own its lifecycle/);
+ });
+
+ it('refuses to remove the container', async () => {
+ await expect(removeDockerContainer(docker)).rejects.toThrow(/does not own its lifecycle/);
+ });
+
+ it('still allows killing THIS session in-container tmux, never the container', () => {
+ const kill = buildDockerKillCommand({ docker, sessionId: 'abcdef12-0000-0000-0000-000000000000' });
+ expect(kill).toContain('tmux');
+ expect(kill).toContain('kill-session');
+ expect(kill).not.toContain('docker stop');
+ expect(kill).not.toContain('docker rm');
+ });
+
+ it('still permits every verb for an owned container', () => {
+ const ownedDocker = toSessionDocker(HOST, caseFor(undefined));
+ expect(buildDockerStopCommand(ownedDocker)).toContain('stop -t 10');
+ expect(buildDockerRemoveCommand(ownedDocker)).toContain('rm -f');
+ });
+});
+
+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: run modes come from the CONTAINER, not the host', () => {
+ const ui = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf8');
+ /** Slice the method BODY. Anchored on the definition, not a call site: the
+ * menu opener calls _loadRunModeHistory() ABOVE this definition, so slicing
+ * between call sites silently yields an empty string and passes nothing. */
+ /**
+ * The method BODY, delimited by brace depth rather than a character budget.
+ * A fixed window silently truncates the moment the method grows — which is
+ * exactly what happened twice: a comment added above the assertion pushed the
+ * asserted line past the cutoff and CI failed on a test that was still true.
+ */
+ const refreshFn = (src) => {
+ const start = src.indexOf('_refreshRunModeAvailability(menu) {');
+ expect(start).toBeGreaterThan(-1);
+ const open = src.indexOf('{', start);
+ let depth = 0;
+ for (let i = open; i < src.length; i++) {
+ if (src[i] === '{') depth++;
+ else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1);
+ }
+ throw new Error('unbalanced braces in _refreshRunModeAvailability');
+ };
+
+ it('gates a docker case on availableModes instead of host CLI probes', () => {
+ // The sandbox host had codex but no claude while the adopted container had
+ // claude and no codex; gating on the host hid the only mode that worked.
+ const fn = refreshFn(ui);
+ expect(fn).toContain("location === 'docker'");
+ expect(fn).toContain('availableModes');
+ // Non-docker cases must keep the original host probe (#201).
+ expect(fn).toContain('this.isCliAvailable(mode)');
+ });
+
+ it('leaves an owned container ungated when nothing was probed', () => {
+ // Our base image ships every CLI, so an absent list means "unknown", and
+ // treating unknown as "nothing available" would empty the menu.
+ expect(refreshFn(ui)).toMatch(/containerModes \?[^:]*:\s*true/);
+ });
+});
+
+describe('adopted container: both path fields get a folder picker', () => {
+ 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 picker = readFileSync(new URL('../src/web/public/keyboard-accessory.js', import.meta.url), 'utf8');
+
+ it('wires a Browse button to each of the two paths', () => {
+ expect(html).toContain('app.openDockerWorkspacePathPicker()');
+ expect(html).toContain('app.openDockerWorkdirPicker()');
+ // Same markup Link Existing uses, so the two look and behave alike.
+ expect(html.match(/path-input-browse/g)?.length).toBeGreaterThanOrEqual(3);
+ });
+
+ it('browses the CONTAINER for the container workdir, not the host', () => {
+ // For an adopted container nothing is mounted at a matching host path, so a
+ // host listing would be a different filesystem — and typing this field blind
+ // is what makes the launch fail with an OCI chdir error.
+ const fn = ui.slice(ui.indexOf('openDockerWorkdirPicker()'), ui.indexOf('async linkRemoteCase()'));
+ expect(fn).toContain('/api/docker-cases/browse');
+ expect(fn).not.toContain('/api/filesystem/browse');
+ expect(fn).toContain('fetchListing');
+ });
+
+ it('keeps the host picker for the host workspace path', () => {
+ const fn = ui.slice(ui.indexOf('openDockerWorkspacePathPicker()'), ui.indexOf('openDockerWorkdirPicker()'));
+ expect(fn).toContain('PathPicker.open');
+ expect(fn).not.toContain('fetchListing');
+ });
+
+ it('reuses one PathPicker via an optional source rather than forking it', () => {
+ expect(picker).toContain('this._options.fetchListing');
+ expect(picker).toContain('/api/filesystem/browse');
+ });
+});
+
+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
+ // comparison would always report drift and the launch gate would 409 forever.
+ const status = await checkDockerConfigDrift(toSessionDocker(HOST, caseFor(false)));
+ expect(status.drifted).toBe(false);
+ });
+});
+
+describe('adopted container: probe modes come from the CLI registry', () => {
+ it('probes every enabled CLI, so a newly-enabled one needs no second list', () => {
+ // A hand-written list here silently froze: `omp` shipped in 1.24.0 and was
+ // missing from it, which hid the omp run mode on EVERY docker case — owned
+ // ones included, since the run menu gates on this same probe.
+ const modes = dockerAdoptProbeModes();
+ expect(modes).toEqual(enabledCliIds());
+ expect(modes).toContain('omp');
+ expect(modes).toContain('shell');
+ });
+
+ it('resolves the real binary name, not the mode name', () => {
+ // `antigravity` ships as `agy` and `deepseek` as `dsh`, so a mode-name probe
+ // would report both as missing on a container that has them.
+ expect(getCli('antigravity')?.discovery.binaries[0]).toBe('agy');
+ expect(getCli('deepseek')?.discovery.binaries[0]).toBe('dsh');
+ expect(getCli('shell')?.discovery.binaries[0]).toBeUndefined();
+ });
+});
+
+describe('adopted container: export never touches the container', () => {
+ const routes = readFileSync(new URL('../src/web/routes/case-routes.ts', import.meta.url), 'utf8');
+ const exporter = readFileSync(new URL('../src/docker-export.ts', import.meta.url), 'utf8');
+
+ it('refuses a full-image export, which would commit a container we do not own', () => {
+ expect(routes).toContain("if (mode === 'full' && dockerCase.owned === false)");
+ });
+
+ it('never pauses an adopted container for the workspace tar', () => {
+ // `docker pause` freezes the owner's processes for as long as the tar takes.
+ // It is the one export step that touches the container at all.
+ expect(exporter).toContain('!isAdoptedContainer(docker) && (await isContainerRunning(');
+ });
+});
+
+describe('adopted container: naming a foreign container is machine-level', () => {
+ const routes = readFileSync(new URL('../src/web/routes/case-routes.ts', import.meta.url), 'utf8');
+ const routeFor = (marker: string) => routes.slice(routes.indexOf(marker), routes.indexOf(marker) + 1400);
+
+ it('admin-gates adoption in multi-user mode, unlike docker-link', () => {
+ // docker-link only ever creates OUR container, whose sole bind mount is a
+ // workspace isWorkingDirAllowed already confined. An adopted container's
+ // mounts belong to its owner — one mounting `/` hands the adopter the host.
+ expect(routeFor("'/api/cases/docker-adopt'")).toContain('adminOnly(req, reply)');
+ });
+
+ it('admin-gates enumerating and browsing containers', () => {
+ expect(routeFor("'/api/docker-hosts/:hostId/containers'")).toContain('adminOnly(req, reply)');
+ expect(routeFor("'/api/docker-cases/browse'")).toContain('adminOnly(req, reply)');
+ });
+
+ it('lets a non-admin preflight only a container linked to a case they own', () => {
+ // NOT plain adminOnly: the run menu probes this for every docker case to learn
+ // which CLIs the container has, so an admin-only gate would hide every agent
+ // mode from a non-admin's own docker case.
+ const route = routeFor("'/api/docker-cases/adopt-preflight'");
+ expect(route).toContain('if (!isAdmin(req))');
+ expect(route).toContain('canAccessOwned(getAuthUser(req), item.owner)');
+ expect(route).not.toContain('adminOnly(req, reply)');
+ });
+});
+
+describe('adopted container: a missing container means different things per ownership', () => {
+ const ui = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf8');
+ const routes = readFileSync(new URL('../src/web/routes/case-routes.ts', import.meta.url), 'utf8');
+
+ it('records a probe failure only for an adopted case', () => {
+ // An OWNED container does not exist until the first session launches it, so
+ // "not found" is the expected answer for every freshly linked Docker case.
+ // Treating it as a fault hid every agent mode behind an error telling the user
+ // to start a container the launch chain was about to create itself.
+ const probe = ui.slice(ui.indexOf('async _probeDockerCaseModes('), ui.indexOf('async _loadRunModeHistory('));
+ expect(probe).toContain('if (activeCase?.docker?.owned === false) {');
+ expect(probe.indexOf('if (activeCase?.docker?.owned === false) {')).toBeLessThan(
+ probe.indexOf('this._dockerCaseProbeError[name] =')
+ );
+ });
+
+ it('ships the ownership flag the UI reads that decision from', () => {
+ expect(routes).toContain('...(dockerCase.owned === false ? { owned: false } : {}),');
+ });
+});
diff --git a/test/file-browser-search.test.ts b/test/file-browser-search.test.ts
index 13e804b8..d6e95a58 100644
--- a/test/file-browser-search.test.ts
+++ b/test/file-browser-search.test.ts
@@ -161,6 +161,8 @@ function loadPanel(options: { sessionId?: string | null; showHidden?: boolean }
CodemanApp,
console,
escapeHtml,
+ // Reverse-proxy route builder from constants.js (not loaded here); identity at root.
+ CodemanBase: { base: '', url: (p: string) => p },
localStorage: { getItem: () => null, setItem: vi.fn() },
document: {
getElementById: (id: string) => elements[id] ?? null,
@@ -222,6 +224,8 @@ function loadRealSelectSessionHarness(options: { terminalFailure?: boolean } = {
},
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 },
+ // Reverse-proxy route builder from constants.js (not loaded here); identity at root.
+ CodemanBase: { base: '', url: (p: string) => p },
MobileDetection: { isTouchDevice: () => false },
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
document: {
diff --git a/test/file-picker-root-home.test.ts b/test/file-picker-root-home.test.ts
new file mode 100644
index 00000000..c726237f
--- /dev/null
+++ b/test/file-picker-root-home.test.ts
@@ -0,0 +1,53 @@
+/**
+ * @fileoverview The picker must offer a root when Codeman runs as root.
+ *
+ * `/root` is a DEFAULT blocked tree in the attachment guard, and Codeman running
+ * as root — containers, plenty of servers — makes `homedir()` exactly `/root`.
+ * The picker's own allowlisted Home root was therefore blocked by the guard,
+ * every other candidate lives under it or does not exist, and the endpoint
+ * answered 403 "No filesystem browse roots are available" with nothing the user
+ * could open. The fix drops only the trees that would swallow a configured root
+ * whole; `isSensitivePath` still guards what is inside.
+ */
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { isBlockedAttachmentPath, isUnderTree } from '../src/config/attachment-guard.js';
+
+const TREES = ['/root', '/etc'];
+
+/** Mirror of pickerBlockedTrees in file-routes.ts. */
+const narrow = (trees: readonly string[], roots: readonly string[]) =>
+ roots.length === 0 ? trees : trees.filter((t) => !roots.some((r) => isUnderTree(r, t)));
+
+describe('file picker roots when the server runs as root', () => {
+ it('drops the tree that would swallow the configured Home root', () => {
+ expect(narrow(TREES, ['/root'])).toEqual(['/etc']);
+ });
+
+ it('keeps trees that hold no configured root', () => {
+ expect(narrow(TREES, ['/home/alice'])).toEqual(['/root', '/etc']);
+ expect(narrow(TREES, [])).toEqual(['/root', '/etc']);
+ });
+
+ it('also frees a root nested under the blocked tree', () => {
+ // ~/codeman-cases is /root/codeman-cases when running as root.
+ expect(narrow(TREES, ['/root/codeman-cases'])).toEqual(['/etc']);
+ });
+
+ it('still refuses secrets inside the freed tree', () => {
+ const trees = narrow(TREES, ['/root']);
+ for (const p of ['/root/.ssh/id_rsa', '/root/.aws/credentials', '/root/app/.env']) {
+ expect(isBlockedAttachmentPath(p, trees)).toBe(true);
+ }
+ // …while ordinary files under it become reachable, which is the point.
+ expect(isBlockedAttachmentPath('/root/projects/readme.md', trees)).toBe(false);
+ });
+
+ it('navigation reuses the same narrowed list the roots were chosen with', () => {
+ // Handing the raw trees to navigation would admit a root and then refuse
+ // every path inside it — a picker that opens and then does nothing.
+ const src = readFileSync(new URL('../src/web/routes/file-routes.ts', import.meta.url), 'utf8');
+ expect(src.match(/pickerBlockedTrees\(/g)?.length).toBeGreaterThanOrEqual(3);
+ expect(src).not.toMatch(/blockedTrees:\s*guard\.blockedTrees/);
+ });
+});
diff --git a/test/file-preview-detach.test.ts b/test/file-preview-detach.test.ts
index 9e31df9c..6773386c 100644
--- a/test/file-preview-detach.test.ts
+++ b/test/file-preview-detach.test.ts
@@ -38,6 +38,8 @@ function loadApp() {
console: { ...console, warn: vi.fn(), error: vi.fn() },
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
escapeHtml: (s: string) => String(s),
+ // Reverse-proxy route builder from constants.js (not loaded here); identity at root.
+ CodemanBase: { base: '', url: (p: string) => p },
document: { getElementById: () => null, addEventListener: vi.fn() },
window: windowStub,
setTimeout,
diff --git a/test/file-preview-media.test.ts b/test/file-preview-media.test.ts
index 1152ddf5..33af4591 100644
--- a/test/file-preview-media.test.ts
+++ b/test/file-preview-media.test.ts
@@ -64,6 +64,8 @@ function loadApp(media: FakeMedia[]) {
console: { ...console, warn: vi.fn() },
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
escapeHtml: (s: string) => String(s),
+ // Reverse-proxy route builder from constants.js (not loaded here); identity at root.
+ CodemanBase: { base: '', url: (p: string) => p },
document: { getElementById: () => null, addEventListener: vi.fn() },
window: { addEventListener: vi.fn() },
setTimeout,
diff --git a/test/frontend-public-tooling.test.ts b/test/frontend-public-tooling.test.ts
index 945754ac..6785409d 100644
--- a/test/frontend-public-tooling.test.ts
+++ b/test/frontend-public-tooling.test.ts
@@ -24,7 +24,15 @@ describe('frontend public asset tooling', () => {
const appJs = readFileSync(resolve(repoRoot, 'src/web/public/app.js'), 'utf8');
expect(appJs).toContain("body.appendChild(this._buildResponseViewerMessage(lastResponse, 'assistant'");
- expect(appJs).toContain('body.appendChild(this._buildResponseViewerMessage(msg.text, msg.role, agentLabel));');
+ expect(appJs).toContain(
+ 'body.appendChild(this._buildResponseViewerMessage(msg.text, msg.role, agentLabel, { ...msg, continuation }));'
+ );
+ // ⚠️ A numeric `turn` gates continuation rendering. Only the Claude reader
+ // emits turns; Codex, the external-CLI pane parser and an older server emit
+ // adjacent same-role messages with none, and must keep one badge per card.
+ expect(appJs).toContain(
+ "!!previous && previous.role === msg.role && typeof msg.turn === 'number' && previous.turn === msg.turn"
+ );
expect(appJs).toContain("div.className = 'rv-message ' + (isUser ? 'rv-msg-user' : 'rv-msg-assistant');");
expect(appJs).toContain("renderedText.className = 'rv-text';");
});
diff --git a/test/hooks-config.test.ts b/test/hooks-config.test.ts
index 9239bcbd..774774b3 100644
--- a/test/hooks-config.test.ts
+++ b/test/hooks-config.test.ts
@@ -42,6 +42,34 @@ describe('generateHooksConfig', () => {
expect(config.hooks.Stop).toHaveLength(1);
});
+ it('reports the live conversation id on every prompt, with stdout discarded', () => {
+ const config = generateHooksConfig();
+ expect(config.hooks.UserPromptSubmit).toBeInstanceOf(Array);
+ expect(config.hooks.UserPromptSubmit).toHaveLength(1);
+ const command = (config.hooks.UserPromptSubmit as Array<{ hooks: Array<{ command: string }> }>)[0].hooks[0].command;
+ expect(command).toContain('"event":"prompt_submitted"');
+ expect(command).toContain('$CODEMAN_SESSION_ID');
+ // ⚠️ Claude Code injects a UserPromptSubmit hook's stdout into the model's
+ // context ("Exit code 0 - stdout shown to Claude"), so without this the API
+ // envelope is pasted into the user's own prompt on every turn. Every other
+ // event's stdout is harmless (it feeds SSE).
+ // ⚠️ Assert curl's OWN flag, not a trailing redirect: the command already
+ // ends `… 2>/dev/null || true`, and in `pipeline || true >/dev/null` the
+ // shell binds the redirect to `true`, which never runs on the success path.
+ // A `endsWith('>/dev/null')` assertion passes on exactly that broken form.
+ expect(command).toContain('curl -sk -o /dev/null -X POST');
+ expect(command.trimEnd().endsWith('>/dev/null')).toBe(false);
+ });
+
+ it("leaves every other hook event's command text byte-identical", () => {
+ // The opt-in discard exists so the five SSE-fed events do not change shape:
+ // rewriting their command churns every workspace's settings.local.json.
+ const config = generateHooksConfig();
+ const stop = (config.hooks.Stop as Array<{ hooks: Array<{ command: string }> }>)[0].hooks[0].command;
+ expect(stop).toContain('curl -sk -X POST');
+ expect(stop).not.toContain('-o /dev/null');
+ });
+
it('should guard subagent stops while their background work is active', () => {
const config = generateHooksConfig();
const subagentHooks = config.hooks.SubagentStop as Array<{
@@ -324,6 +352,39 @@ describe('writeHooksConfig', () => {
expect(serialized).not.toContain('CODEMAN_BACKGROUND_REWAKE_V1');
});
+ it('heals a hooks block written before UserPromptSubmit existed', async () => {
+ const claudeDir = join(testDir, '.claude');
+ const settingsPath = join(claudeDir, 'settings.local.json');
+ mkdirSync(claudeDir, { recursive: true });
+ // An otherwise-current block from the previous release: the pane would keep
+ // guessing its conversation from ~/.claude/history.jsonl forever.
+ const hooks = generateHooksConfig().hooks;
+ delete hooks.UserPromptSubmit;
+ writeFileSync(settingsPath, JSON.stringify({ hooks }, null, 2));
+
+ await refreshStaleCodemanHooks(testDir);
+
+ const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
+ expect(JSON.stringify(parsed.hooks.UserPromptSubmit)).toContain('prompt_submitted');
+ });
+
+ it('leaves an already-current hooks block untouched', async () => {
+ // ⚠️ The staleness gate reads a JSON.stringify'd blob, so a marker written
+ // with surrounding quotes never matches and the gate is permanently false —
+ // which rewrites every workspace's settings.local.json on every Claude
+ // spawn instead of never. This asserts the no-op, which is the property a
+ // quoted needle silently breaks.
+ const claudeDir = join(testDir, '.claude');
+ const settingsPath = join(claudeDir, 'settings.local.json');
+ mkdirSync(claudeDir, { recursive: true });
+ writeFileSync(settingsPath, JSON.stringify({ hooks: generateHooksConfig().hooks }, null, 2));
+ const before = readFileSync(settingsPath, 'utf-8');
+
+ await refreshStaleCodemanHooks(testDir);
+
+ expect(readFileSync(settingsPath, 'utf-8')).toBe(before);
+ });
+
it('replaces the V2 background hook without duplicating it', async () => {
const claudeDir = join(testDir, '.claude');
const settingsPath = join(claudeDir, 'settings.local.json');
diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts
index 18990a2e..b43b2fcd 100644
--- a/test/mocks/mock-session.ts
+++ b/test/mocks/mock-session.ts
@@ -29,6 +29,32 @@ export class MockSession extends EventEmitter {
terminalBuffer: string = '';
/** Mirrors Session.lastSubmitAt — the response viewer credits history entries by it. */
lastSubmitAt: number = 0;
+ /** Mirrors Session.claudeSessionId — the conversation the viewer reads. */
+ claudeSessionId: string | null = null;
+ /** Mirrors Session.claudeSessionIdIsFirstHand — set only by a hook adoption. */
+ claudeSessionIdIsFirstHand: boolean = false;
+ /** Mirrors Session.claudeSessionChain — oldest first, current last. */
+ claudeSessionChain: string[] = [];
+
+ /** Mirrors Session.adoptClaudeSessionId, including the first-hand chain rule. */
+ adoptClaudeSessionId(newId: string, options: { firstHand?: boolean } = {}): void {
+ if (!newId) return;
+ if (options.firstHand) {
+ this.claudeSessionIdIsFirstHand = true;
+ if (this.claudeSessionChain[this.claudeSessionChain.length - 1] !== newId) {
+ const existing = this.claudeSessionChain.indexOf(newId);
+ if (existing !== -1) this.claudeSessionChain.splice(existing, 1);
+ this.claudeSessionChain.push(newId);
+ }
+ }
+ if (newId === this.claudeSessionId) return;
+ this.claudeSessionId = newId;
+ }
+
+ /** Mirrors Session.markPromptSubmitted. */
+ markPromptSubmitted(): void {
+ this.lastSubmitAt = Date.now();
+ }
private _muxName: string | null = null;
diff --git a/test/pr-bot-commands.test.ts b/test/pr-bot-commands.test.ts
new file mode 100644
index 00000000..8b5b7337
--- /dev/null
+++ b/test/pr-bot-commands.test.ts
@@ -0,0 +1,249 @@
+/**
+ * @fileoverview The PR bot's Telegram command and button handling, driven through
+ * `PrBot.handleUpdate` with a recording Telegram stub and a mocked `gh` layer. Pins
+ * the one property that matters most: a GitHub write (merge, close, post) happens only
+ * after the confirmation tap, exactly once, and never for a foreign chat or a stale
+ * nonce.
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { mkdtempSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+
+const gh = vi.hoisted(() => ({
+ listOpenPrs: vi.fn(async () => []),
+ getPrDetail: vi.fn(),
+ getCiStatus: vi.fn(async () => ({ state: 'passed', runs: [] })),
+ mergePr: vi.fn(async () => 'merged'),
+ closePr: vi.fn(async () => 'closed'),
+ commentPr: vi.fn(async () => 'commented'),
+ approveWorkflowRun: vi.fn(async () => undefined),
+ gh: vi.fn(async () => 'MERGED\n'),
+}));
+vi.mock('../scripts/pr-bot/github.js', () => gh);
+vi.mock('../scripts/pr-bot/worktree.js', () => ({
+ preparePrWorktree: vi.fn(),
+ removePrWorktree: vi.fn(async () => undefined),
+}));
+
+import { PrBot, type TelegramLike } from '../scripts/pr-bot/bot.js';
+import { buildConfig } from '../scripts/pr-bot/config.js';
+import type { CodemanClient } from '../scripts/pr-bot/codeman-client.js';
+import type { PrDetail } from '../scripts/pr-bot/github.js';
+import type { ReviewReport } from '../scripts/pr-bot/report.js';
+
+class FakeTelegram implements TelegramLike {
+ sent: { text: string; markup?: unknown; plain: boolean }[] = [];
+ edits: number[] = [];
+ private nextId = 100;
+ isOurChat(chatId: number | string | undefined): boolean {
+ return String(chatId) === '1';
+ }
+ async sendMessage(text: string, opts: { replyMarkup?: unknown } = {}): Promise {
+ this.sent.push({ text, markup: opts.replyMarkup, plain: false });
+ return this.nextId++;
+ }
+ async sendPlain(text: string): Promise {
+ this.sent.push({ text, plain: true });
+ return this.nextId++;
+ }
+ async editReplyMarkup(messageId: number): Promise {
+ this.edits.push(messageId);
+ }
+ async deleteMessage(): Promise {}
+ async answerCallback(): Promise {}
+ async sendDocument(): Promise {}
+ async getUpdates(): Promise<[]> {
+ return [];
+ }
+ async setMyCommands(): Promise