fix(adopt): never drive, write to, or answer for a session we did not start

Review items 3, 4 and the smaller ones on #510. An adopted session wraps a
process someone else launched, and its mode is whatever the probe saw — very
often `claude`. Every guard below is therefore its own `isAdopted` check rather
than a side effect of the external-CLI rule: the two ask different questions,
and merging them means the day the first loosens, Codeman starts acting on a
live conversation that is not ours.

- The boot workspace-hook sweep skipped only non-claude and `session.remote`.
  An adopted session keeps its connection on `adopt.remote` / `adopt.docker`,
  so a tab wrapping someone's in-container or over-ssh claude looked local and
  Codeman wrote `.claude/settings.local.json` into their repo on every restart.
- The workspace-trust auto-accept read the pane and pressed keys for 90s after
  `startInteractive()`. In an adopted pane that is someone else's dialog, and
  boot recovery re-opens the window on every restart.
- Auto-clear, auto-compact, auto-resume and the Ralph auto-enable in
  `POST /interactive` all drove the pane. Each now refuses with its own gate.
- The wrapper's `workingDir` was the foreign pane's cwd. That cwd can carry
  characters outside SAFE_PATH_PATTERN (`~/c++`, `Program Files (x86)`), which
  makes `createSession` throw so the tab never starts, and for a container or
  ssh candidate it is not a path on this host at all. It is now a neutral local
  `/tmp`, the same cwd `resolveMuxAttachCwd` already gives the pane; the foreign
  cwd stays on `adopt.paneCurrentPath` for display.
- The CLI signature table hardcoded ids, which CLAUDE.md forbids outside
  `stock.ts`, and had already gone stale: `omp` was missing, so a hand-started
  omp pane read as `shell`. It is now derived from each entry's
  `discovery.binaries` at call time, regex-escaped, longest name first. Stock
  entries only, since `SessionMode` is the closed union of stock ids.
- The descendant walk now passes `onTruncated`. A capped walk returns "nothing
  matched", which is byte-identical to the answer for a real shell.
- The home screen's wide scan is one-shot. Riding `docker exec` and a full ssh
  handshake per target, each with a ~12s timeout, on the 8s poll stacked
  connections on a slow host. Wide rows are kept apart and merged, because a
  local-only payload cannot contain them.
- Adoption failures report the server's own message. `_apiJson` folds a non-2xx
  to null, which collapsed "could not reach it just now" into "that session is
  gone" and reported a live remote session as deleted on every link flicker.

Tests: route tests for the refusals, the admin gate, the 404 re-resolve, the
one-wrapper-per-target reuse and the adopt round-trip; unit tests for the two
non-HTTP guards, the registry-derived signatures, truncation reporting and the
frontend's scan and error paths. Each guard was removed in turn to confirm its
own tests go red.
This commit is contained in:
d fei committed 2026-09-30 23:29:54 -07:00
1 parent aec4867821
commit 09ccffd32e
11 files changed
+954 -46

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
/**
* @fileoverview The two adoption guards that are not reachable over HTTP: the
* boot-time workspace-hook sweep, and the workspace-trust auto-accept.
*
* Both act on a session's WORKSPACE or its PANE, both run unattended, and both
* previously keyed off `session.remote` or the mode alone — neither of which
* sees an adopted session, whose connection facts live on `adopt.remote` /
* `adopt.docker`. So a tab wrapping someone's claude read as a plain local
* claude: Codeman wrote `.claude/settings.local.json` into their repo on every
* restart, and answered a trust dialog that was theirs to answer.
*
* Each one is paired with a session Codeman DID start — without that control,
* "nothing was written" is equally explained by a disabled setting or a loop
* that never ran.
*
* Port: none (no server is listened on).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const applyWorkspaceHooks = vi.fn(async () => {});
vi.mock('../src/hooks-config.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/hooks-config.js')>();
return { ...actual, applyWorkspaceHooks: (...args: unknown[]) => applyWorkspaceHooks(...(args as [])) };
});
const { WebServer } = await import('../src/web/server.js');
const { Session } = await import('../src/session.js');
type AnySession = Record<string, unknown>;
/** The minimum a session needs to reach the sweep's `workspaces` set. */
function fakeSession(over: AnySession): AnySession {
return { mode: 'claude', workingDir: '/srv/app', isAdopted: false, ...over };
}
describe('the boot workspace-hook sweep', () => {
let server: InstanceType<typeof WebServer>;
beforeEach(() => {
applyWorkspaceHooks.mockClear();
server = new WebServer({ port: 3197 });
(server as unknown as { getWorkspaceHooksEnabled: () => Promise<boolean> }).getWorkspaceHooksEnabled = async () =>
true;
});
afterEach(async () => {
await server.stop().catch(() => {});
});
const sweep = async (sessions: AnySession[]) => {
const map = new Map<string, AnySession>();
sessions.forEach((s, i) => map.set(`s${i}`, s));
(server as unknown as { sessions: Map<string, AnySession> }).sessions = map;
await (
server as unknown as { ensureHooksForRecoveredWorkspaces: () => Promise<void> }
).ensureHooksForRecoveredWorkspaces();
return applyWorkspaceHooks.mock.calls.map((c) => c[0]);
};
it('installs hooks into a workspace Codeman owns', async () => {
expect(await sweep([fakeSession({ workingDir: '/srv/ours' })])).toContain('/srv/ours');
});
it('skips an adopted session even though its mode is claude and `remote` is unset', async () => {
// This is the exact shape the bug had: the ssh/docker facts hang off
// `adopt`, so the pre-existing `session.remote` check sees nothing.
const adopted = fakeSession({
workingDir: '/home/them/repo',
isAdopted: true,
adopt: { location: 'remote', remote: { host: 'box' } },
});
expect(await sweep([adopted])).not.toContain('/home/them/repo');
});
it('still skips a plain remote session — the older rule is untouched', async () => {
expect(await sweep([fakeSession({ workingDir: '/srv/far', remote: { host: 'box' } })])).not.toContain('/srv/far');
});
});
describe('the workspace-trust auto-accept', () => {
const makeSession = (adopt?: Record<string, unknown>) =>
new Session({
id: adopt ? 'adopt-trust' : 'own-trust',
name: 't',
workingDir: '/tmp',
mode: 'claude',
...(adopt ? { adopt } : {}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const scan = (s: InstanceType<typeof Session>) => {
const priv = s as unknown as {
_interactiveStartedAt: number;
_trustDialogAccepted: boolean;
_maybeAcceptTrustDialog: () => void;
_trustDialogTimer: unknown;
};
priv._interactiveStartedAt = Date.now(); // inside the 90s startup window
priv._trustDialogAccepted = false;
priv._maybeAcceptTrustDialog();
return priv;
};
it('arms the scan for a session Codeman started', async () => {
const s = makeSession();
const priv = scan(s);
expect(priv._trustDialogAccepted).toBe(false);
await s.stop(true).catch(() => {});
});
it('never answers a dialog in an adopted pane', async () => {
// That pane holds SOMEONE ELSE'S claude. And boot recovery re-opens the 90s
// window on every restart, so this is not a one-shot at adoption time.
const s = makeSession({
location: 'local',
socketPath: '/tmp/tmux-0/default',
targetSession: 'work',
viewSession: '',
});
const priv = scan(s);
expect(priv._trustDialogAccepted).toBe(true); // short-circuited, never scans again
expect(priv._trustDialogTimer).toBeFalsy();
await s.stop(true).catch(() => {});
});
});
+184
View File
@@ -0,0 +1,184 @@
/**
* @fileoverview The home screen's foreign-session block: the wide scan is
* one-shot, and an adoption failure says what the server said.
*
* Containers and ssh hosts cost a `docker exec` or a full ssh handshake each,
* with a ~12s timeout. Riding them along on the 8s poll stacked connections on
* a slow host, so the wide scan now runs only when the user asks. That makes a
* second property load-bearing: the periodic poll's payload cannot contain
* those rows, so they are kept apart and merged — assert only "the request was
* right" and the real failure (the next tick wiping the container rows off the
* list) sails through.
*
* Port: none.
*/
import { describe, it, expect, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import vm from 'node:vm';
const SRC = readFileSync(fileURLToPath(new URL('../src/web/public/foreign-sessions.js', import.meta.url)), 'utf8');
function stubEl(): Record<string, unknown> {
const el: Record<string, unknown> = {
children: [] as unknown[],
dataset: {},
disabled: false,
textContent: '',
title: '',
type: '',
className: '',
hidden: false,
classList: { add() {}, remove() {}, contains: () => false },
appendChild(c: unknown) {
(el.children as unknown[]).push(c);
return c;
},
setAttribute() {},
};
Object.defineProperty(el, 'innerHTML', { get: () => '', set: () => void (el.children = []) });
return el;
}
/** The module is a classic script that mixes into CodemanApp.prototype. */
function loadApp() {
const host = stubEl();
const ctx: Record<string, unknown> = {
console,
Date,
Number,
Array,
Object,
JSON,
Math,
String,
setInterval: () => 1,
clearInterval() {},
codemanT: null,
document: {
createElement: () => stubEl(),
getElementById: (id: string) => (id === 'foreignSessions' ? host : null),
addEventListener() {},
},
CodemanApp: function () {} as unknown,
};
vm.createContext(ctx);
vm.runInContext(SRC, ctx);
const Ctor = ctx.CodemanApp as { prototype: object };
return Object.create(Ctor.prototype) as Record<string, unknown> & {
toggleForeignScanRemote(): void;
rescanForeignWide(): void;
loadForeignSessions(o?: { wide?: boolean }): Promise<void>;
adoptForeignSession(id: string, el?: unknown): Promise<void>;
_allForeignRows(): Array<{ id: string }>;
renderAllForeignSessions(): void;
};
}
const LOCAL = { id: 'L1', location: 'local', sessionName: 'work', mode: 'claude' };
const DOCKER = { id: 'D1', location: 'docker', sessionName: 'inbox', mode: 'codex' };
describe('the wide scan is one-shot', () => {
function wired() {
const app = loadApp();
const calls: string[] = [];
app._apiJson = vi.fn(async (path: string) => {
calls.push(path);
return path.includes('docker=1')
? { sessions: [LOCAL, DOCKER], notes: [], canScanWide: true }
: { sessions: [LOCAL], notes: [], canScanWide: true };
});
return { app, calls };
}
it('fetches containers and hosts once when the toggle goes on', async () => {
const { app, calls } = wired();
app.toggleForeignScanRemote();
// Wait on the STATE, not on the call count: the stub pushes its path before
// it resolves, so a call-count wait returns while the rows are still unset.
await vi.waitFor(() => expect(app._allForeignRows()).toHaveLength(2));
expect(calls[0]).toContain('docker=1');
expect(calls[0]).toContain('remote=1');
expect(app._allForeignRows().map((r) => r.id)).toEqual(['L1', 'D1']);
});
it('keeps the periodic poll local-only, and the container row survives it', async () => {
// Both halves matter. The first is the cost fix; the second is what makes it
// usable — a local-only payload has no container rows in it.
const { app, calls } = wired();
app.toggleForeignScanRemote();
await vi.waitFor(() => expect(app._allForeignRows()).toHaveLength(2));
await app.loadForeignSessions(); // the interval's call shape
expect(calls[1]).toBe('/api/mux/foreign');
expect(app._allForeignRows().map((r) => r.id)).toEqual(['L1', 'D1']);
});
it('discards the wide rows when the toggle goes off', async () => {
// Nothing re-confirms them, so leaving them up would show a container
// session that may be long gone.
const { app, calls } = wired();
app.toggleForeignScanRemote();
await vi.waitFor(() => expect(app._allForeignRows()).toHaveLength(2));
app.toggleForeignScanRemote();
expect(app._allForeignRows().map((r) => r.id)).toEqual(['L1']);
});
it('Rescan re-runs the wide scan, and does nothing while the toggle is off', async () => {
const { app, calls } = wired();
app.rescanForeignWide();
expect(calls).toHaveLength(0);
app.toggleForeignScanRemote();
await vi.waitFor(() => expect(app._allForeignRows()).toHaveLength(2));
app.rescanForeignWide();
await vi.waitFor(() => expect(calls).toHaveLength(2));
expect(calls[1]).toContain('docker=1');
});
});
describe('an adoption failure reports what the server said', () => {
function adoptHarness(status: number, body: unknown) {
const app = loadApp();
const toasts: string[] = [];
app.showToast = (msg: string) => void toasts.push(msg);
app._api = vi.fn(async () => ({ ok: status < 400, status, json: async () => body }));
app._apiJson = vi.fn(async () => ({ sessions: [], notes: [], canScanWide: false }));
app._onSessionCreated = vi.fn();
app.selectSession = vi.fn(async () => {});
return { app, toasts };
}
it('shows "could not reach it" verbatim instead of "that session is gone"', async () => {
// The server deliberately tells two different stories. `_apiJson` folds a
// non-2xx to null, which collapsed both into one message and reported a
// live remote session as deleted every time the link flickered.
const msg = 'Could not reach it just now (ssh box: timeout). It may still be running — try again.';
const { app, toasts } = adoptHarness(404, { success: false, error: msg, errorCode: 'NOT_FOUND' });
await app.adoptForeignSession('cand-1');
expect(toasts[0]).toBe(msg);
});
it('still shows the gone message when that is what the server said', async () => {
const msg = 'That tmux session is no longer there. Refresh the list and try again.';
const { app, toasts } = adoptHarness(404, { success: false, error: msg, errorCode: 'NOT_FOUND' });
await app.adoptForeignSession('cand-1');
expect(toasts[0]).toBe(msg);
});
it('falls back to its own wording when the server sent no readable body', async () => {
const app = loadApp();
const toasts: string[] = [];
app.showToast = (msg: string) => void toasts.push(msg);
app._api = vi.fn(async () => null); // network failure
app._apiJson = vi.fn(async () => ({ sessions: [], notes: [], canScanWide: false }));
await app.adoptForeignSession('cand-1');
expect(toasts[0]).toMatch(/Could not open that session/);
});
it('goes through the app’s normal create path on success', async () => {
const session = { id: 'new-1' };
const { app } = adoptHarness(200, { success: true, data: { session } });
await app.adoptForeignSession('cand-1');
expect(app._onSessionCreated).toHaveBeenCalledWith(session);
expect(app.selectSession).toHaveBeenCalledWith('new-1');
});
});
+92 -1
View File
@@ -6,7 +6,7 @@
* would quietly undo. Each one has a comment naming what actually went wrong.
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import {
buildForeignProbeScript,
parseForeignProbeOutput,
@@ -20,7 +20,11 @@ import {
buildForeignDockerAttachCommand,
buildForeignRemoteAttachCommand,
buildForeignTmuxInvocation,
foreignCliSignatures,
} from '../src/foreign-tmux.js';
import { PROC_WALK_MAX_NODES } from '../src/proc-tree.js';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import { hooksAvailableForMode, sessionHookOptions } from '../src/web/session-wait-registry.js';
// A probe transcript in exactly the shape a real run produces. The pane rows use
// the LITERAL backslash-t that tmux's `-F` emits (verified on next-3.7 and 3.3a),
@@ -385,3 +389,90 @@ describe('attach command builders', () => {
});
});
});
describe('foreignCliSignatures', () => {
it('covers every stock CLI that declares a binary — including the one the hand-written table missed', () => {
// The table this replaced listed eight CLI ids by hand and had already gone
// stale: `omp` shipped without a signature, so a hand-started omp pane was
// classified `shell`. Deriving from `discovery.binaries` makes that class of
// drift impossible rather than merely fixed once.
const covered = new Set(foreignCliSignatures().map((sig) => sig.mode));
const expected = STOCK_CLIS.filter((e) => e.discovery.binaries.length > 0).map((e) => e.id as string);
expect(expected).toContain('omp');
for (const id of expected) expect(covered).toContain(id);
});
it('leaves shell out — it declares no binary and is the FALLBACK answer', () => {
expect(foreignCliSignatures().some((sig) => sig.mode === 'shell')).toBe(false);
});
it('orders longest binary name first, so a two-letter name cannot win early', () => {
// `pi` matches `(^|/)pi(\s|$)`, which is the loosest rule in the table. If it
// were tried before `opencode`'s, nothing would break on these two names, but
// the ordering is the only thing standing between a future one-letter binary
// and every pane on the machine.
const names = foreignCliSignatures().map((sig) => sig.mode);
expect(names.indexOf('pi')).toBeGreaterThan(names.indexOf('opencode'));
});
it('classifies an omp pane as omp rather than shell', () => {
const empty = { byParent: new Map(), argvByPid: new Map() };
expect(classifyForeignPaneMode({ panePid: 1, paneCurrentCommand: 'omp' }, empty).mode).toBe('omp');
});
it('still finds claude under its own dot-directory node launcher', () => {
// The derived "node launcher" shape has to reproduce what claude's hand-written
// `[/\\]\.?claude[/\\][^\s]*cli\.js` rule covered, or every claude installed
// the normal way (a `node ~/.claude/local/.../cli.js` argv) silently reads as shell.
const argv = 'node /home/u/.claude/local/node_modules/@anthropic-ai/claude-code/cli.js';
const probe = { byParent: new Map([[7, [8]]]), argvByPid: new Map([[8, argv]]) };
expect(classifyForeignPaneMode({ panePid: 7, paneCurrentCommand: 'node' }, probe).mode).toBe('claude');
});
it('does not let a binary name match inside a longer command', () => {
const empty = { byParent: new Map(), argvByPid: new Map() };
expect(classifyForeignPaneMode({ panePid: 1, paneCurrentCommand: '/usr/bin/pip install x' }, empty).mode).toBe(
'shell'
);
});
});
describe('classifyForeignPaneMode truncation reporting', () => {
it('reports a truncated descendant walk instead of silently answering shell', () => {
// A capped walk returns "nothing matched", which is byte-identical to the
// answer for a real shell. Without the warning there is nothing anywhere to
// tell a wide process tree apart from a plain bash pane.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const byParent = new Map<number, number[]>();
byParent.set(
1,
Array.from({ length: PROC_WALK_MAX_NODES + 10 }, (_, i) => i + 100)
);
const result = classifyForeignPaneMode(
{ panePid: 1, paneCurrentCommand: 'bash' },
{ byParent, argvByPid: new Map() }
);
expect(result.mode).toBe('shell');
expect(warn).toHaveBeenCalled();
expect(String(warn.mock.calls[0]?.[0])).toMatch(/nodes cap|-nodes/);
} finally {
warn.mockRestore();
}
});
});
describe('hooksAvailableForMode for an adopted session', () => {
it('refuses claude when the process was adopted', () => {
// Adoption asks who STARTED the process. Codeman never installed its hooks
// block in that workspace, so answering by mode would dress an infinite wait
// up as a timeout.
expect(hooksAvailableForMode('claude')).toBe(true);
expect(hooksAvailableForMode('claude', { adopted: true })).toBe(false);
});
it('derives `adopted` from the session, so every call site inherits it', () => {
expect(sessionHookOptions({ adopt: { targetSession: 'work' } }).adopted).toBe(true);
expect(sessionHookOptions({}).adopted).toBe(false);
});
});
+4
View File
@@ -147,6 +147,10 @@ export function createMockRouteContext(options?: {
createSession: vi.fn(),
killSession: vi.fn(),
listSessions: vi.fn(() => []),
// A DIFFERENT method from listSessions, and the adoption routes call this
// one. Missing it is not a type error — the handler throws and the route
// answers 500, which reads as a broken endpoint rather than a broken mock.
getSessions: vi.fn(() => []),
getStats: vi.fn(() => ({})),
updateSessionName: vi.fn(() => true),
getSession: vi.fn(() => null),
+19
View File
@@ -19,6 +19,13 @@ export class MockSession extends EventEmitter {
* over ssh instead of with local `fs` (#415).
*/
remote?: SessionRemote;
/**
* Mirrors `Session.isAdopted` — true when this session wraps a process SOMEONE
* ELSE started (a hand-opened tmux session Codeman attached to). A plain field
* rather than a getter over `adopt`, because the routes only ever read the
* boolean and the tests only ever need to set it.
*/
isAdopted: boolean = false;
/** Mirrors Session.attachmentHistory (the attachment panel's source of truth). */
attachmentHistory: SessionAttachmentHistoryItem[] = [];
/** Mirrors Session.getAttachmentHistoryForPersist(). */
@@ -323,6 +330,18 @@ export class MockSession extends EventEmitter {
}
/** Auto-resume on usage limit (token pause control) */
autoClearEnabled: boolean = false;
autoClearThreshold: number = 0;
setAutoClear = vi.fn((enabled: boolean, threshold?: number) => {
this.autoClearEnabled = enabled;
if (typeof threshold === 'number') this.autoClearThreshold = threshold;
});
autoCompactEnabled: boolean = false;
autoCompactThreshold: number = 0;
setAutoCompact = vi.fn((enabled: boolean, threshold?: number) => {
this.autoCompactEnabled = enabled;
if (typeof threshold === 'number') this.autoCompactThreshold = threshold;
});
autoResumeEnabled: boolean = false;
autoResumeAt: number | null = null;
isLimitPaused: boolean = false;
+273
View File
@@ -0,0 +1,273 @@
/**
* @fileoverview The HTTP surface of foreign-tmux adoption, and the gates that
* keep Codeman from driving a session it did not start.
*
* An adopted session wraps a process SOMEONE ELSE launched. Its `mode` is
* whatever the probe saw — very often `claude` — so every refusal here has to
* be its own `isAdopted` check and not a side effect of the external-CLI rule.
* The two questions are different ("is this CLI one we drive?" vs "did we start
* this process?"), and folding them into one means the day the first loosens,
* Codeman silently starts sending `/clear` into someone's live conversation.
*
* Every refusal is paired with a session Codeman DID start, or a broken
* endpoint would satisfy the refusal assertion just as well.
*
* Port: none (app.inject()).
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
// The adopt route RE-RESOLVES the candidate rather than trusting the body, and
// real discovery is disabled under VITEST, so the success path is unreachable
// without standing in for it. Everything downstream of the lookup is real.
const discoverForeignSessions = vi.fn();
const invalidateForeignCache = vi.fn();
vi.mock('../../src/foreign-tmux-discovery.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/foreign-tmux-discovery.js')>();
return {
...actual,
discoverForeignSessions: (...a: unknown[]) => discoverForeignSessions(...(a as [])),
invalidateForeignCache: () => invalidateForeignCache(),
readAllDockerCases: async () => [],
readAllRemoteHosts: async () => [],
};
});
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { registerRespawnRoutes } from '../../src/web/routes/respawn-routes.js';
import { registerRalphRoutes } from '../../src/web/routes/ralph-routes.js';
import { registerMuxRoutes } from '../../src/web/routes/mux-routes.js';
const SID = 'adopted-1';
// The stand-in defaults to "found nothing", which is what real discovery gives
// under VITEST — so mocking the module does not change the tests that predate it.
beforeEach(() => {
discoverForeignSessions.mockReset();
discoverForeignSessions.mockResolvedValue({ sessions: [], notes: [] });
invalidateForeignCache.mockClear();
});
describe('adopted sessions refuse every auto-op that drives the pane', () => {
for (const route of ['auto-clear', 'auto-compact', 'auto-resume']) {
it(`refuses POST /api/sessions/:id/${route}`, async () => {
const h = await createRouteTestHarness(registerSessionRoutes, { sessionId: SID });
(h.ctx._session as unknown as { isAdopted: boolean }).isAdopted = true;
const res = await h.app.inject({
method: 'POST',
url: `/api/sessions/${SID}/${route}`,
payload: { enabled: true },
});
const body = res.json();
expect(body.success).toBe(false);
expect(body.error).toMatch(/adopted/i);
await h.app.close();
});
it(`still allows ${route} on a session Codeman started`, async () => {
const h = await createRouteTestHarness(registerSessionRoutes, { sessionId: SID });
const res = await h.app.inject({
method: 'POST',
url: `/api/sessions/${SID}/${route}`,
payload: { enabled: true },
});
expect(res.json().success).toBe(true);
await h.app.close();
});
}
it('refuses respawn, for a harder reason: it kills and restarts the pane', async () => {
const h = await createRouteTestHarness(registerRespawnRoutes, { sessionId: SID });
(h.ctx._session as unknown as { isAdopted: boolean }).isAdopted = true;
const res = await h.app.inject({ method: 'POST', url: `/api/sessions/${SID}/respawn/start`, payload: {} });
expect(res.json().success).toBe(false);
expect(res.json().error).toMatch(/adopted/i);
await h.app.close();
});
it('refuses Ralph config', async () => {
const h = await createRouteTestHarness(registerRalphRoutes, { sessionId: SID });
(h.ctx._session as unknown as { isAdopted: boolean }).isAdopted = true;
const res = await h.app.inject({
method: 'POST',
url: `/api/sessions/${SID}/ralph-config`,
payload: { maxTodos: 10 },
});
expect(res.json().success).toBe(false);
expect(res.json().error).toMatch(/adopted/i);
await h.app.close();
});
});
describe('GET /api/mux/foreign', () => {
let prev: string | undefined;
beforeEach(() => {
prev = process.env.CODEMAN_MULTIUSER;
});
afterEach(() => {
if (prev === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = prev;
});
it('is admin-only in multi-user mode — it exposes every user’s processes and cwds', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerMuxRoutes, {
authUser: { username: 'bob', role: 'user' },
});
const res = await app.inject({ method: 'GET', url: '/api/mux/foreign' });
expect(res.statusCode).toBeGreaterThanOrEqual(400);
await app.close();
});
it('answers an admin', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerMuxRoutes, {
authUser: { username: 'root', role: 'admin' },
});
expect((await app.inject({ method: 'GET', url: '/api/mux/foreign' })).statusCode).toBe(200);
await app.close();
});
it('answers in single-user mode, where the admin gate is a no-op', async () => {
delete process.env.CODEMAN_MULTIUSER;
const { app } = await createRouteTestHarness(registerMuxRoutes);
const res = await app.inject({ method: 'GET', url: '/api/mux/foreign' });
expect(res.statusCode).toBe(200);
// Real discovery is disabled under VITEST, so this pins the shape, not the
// content. The body is bare here: the uniform envelope is applied by the
// server's own hook, which a single-route harness does not install.
expect(res.json()).toHaveProperty('sessions');
expect(res.json()).toHaveProperty('canScanWide');
await app.close();
});
});
describe('POST /api/sessions/adopt', () => {
let prev: string | undefined;
beforeEach(() => {
prev = process.env.CODEMAN_MULTIUSER;
});
afterEach(() => {
if (prev === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = prev;
});
it('is admin-only in multi-user mode — adopting a shell is arbitrary host execution', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerSessionRoutes, {
authUser: { username: 'bob', role: 'user' },
});
const res = await app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'x' } });
expect(res.statusCode).toBeGreaterThanOrEqual(400);
await app.close();
});
it('404s when the re-resolve no longer sees the target', async () => {
// The browser hands back only an opaque id; the socket path and session name
// are re-derived server-side, so a vanished target is simply absent from the
// fresh list.
const { app } = await createRouteTestHarness(registerSessionRoutes);
const res = await app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'nope' } });
expect(res.json().success).toBe(false);
expect(res.json().errorCode).toBe('NOT_FOUND');
await app.close();
});
it('rejects a body that tries to supply a socket path or session name', async () => {
// The schema takes the opaque id and nothing else. That is why a path can
// never reach the launch chain from the browser.
const { app } = await createRouteTestHarness(registerSessionRoutes);
const res = await app.inject({
method: 'POST',
url: '/api/sessions/adopt',
payload: { id: 'x', socketPath: '/tmp/tmux-0/default', targetSession: 'work' },
});
expect(res.json().success).toBe(false);
expect(res.json().errorCode).toBe('INVALID_INPUT');
await app.close();
});
});
describe('a successful adoption', () => {
/** A candidate whose cwd is BOTH unsafe for SAFE_PATH_PATTERN and not on this host. */
const CANDIDATE = {
id: 'cand-1',
location: 'local' as const,
socketPath: '/tmp/tmux-0/default',
sessionName: 'work',
mode: 'claude' as const,
workingDir: '/home/them/Program Files (x86)/c++',
command: 'claude',
panePid: 4242,
paneCurrentCommand: 'node',
createdAt: 1,
windows: 1,
attached: false,
};
beforeEach(() => {
discoverForeignSessions.mockResolvedValue({ sessions: [CANDIDATE], notes: [] });
});
it('gives the wrapper a neutral LOCAL workingDir, never the foreign pane\u2019s cwd', async () => {
// That cwd carries `+`, spaces and parentheses, so using it would make
// createSession throw and the tab would never start; and when the candidate
// is a container or ssh pane it is not a path on this host at all, which is
// what sent the boot hook sweep into someone else's repo.
const { app, ctx } = await createRouteTestHarness(registerSessionRoutes);
const res = await app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'cand-1' } });
expect(res.statusCode).toBe(200);
expect(ctx.addSession).toHaveBeenCalledTimes(1);
const created = ctx.addSession.mock.calls[0][0] as unknown as {
workingDir: string;
adopt?: { paneCurrentPath?: string; targetSession?: string; viewSession?: string };
};
expect(created.workingDir).toBe('/tmp');
expect(created.workingDir).not.toBe(CANDIDATE.workingDir);
// The foreign cwd survives for display only.
expect(created.adopt?.paneCurrentPath).toBe(CANDIDATE.workingDir);
await app.close();
});
it('reuses the existing wrapper rather than making a second one for the same target', async () => {
// Keyed on (socket, session) and not on our opaque candidate id: a wrapper
// restored after a server restart does not carry that id.
const { app, ctx } = await createRouteTestHarness(registerSessionRoutes);
ctx.sessions.set('wrapper-1', ctx._session);
ctx.mux.getSessions = vi.fn(() => [
{ sessionId: 'wrapper-1', adopt: { socketPath: CANDIDATE.socketPath, targetSession: CANDIDATE.sessionName } },
]) as unknown as typeof ctx.mux.getSessions;
const res = await app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'cand-1' } });
expect(res.statusCode).toBe(200);
expect(res.json().alreadyAdopted).toBe(true);
expect(ctx.addSession).not.toHaveBeenCalled();
await app.close();
});
it('says "could not reach it" rather than "gone" when discovery left a note', async () => {
// On a flaky link the session is alive and sitting right there; reporting
// it as deleted is the wrong answer, not just an unhelpful one.
discoverForeignSessions.mockResolvedValue({ sessions: [], notes: ['ssh box: connection timed out'] });
const { app } = await createRouteTestHarness(registerSessionRoutes);
const res = await app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'cand-1' } });
expect(res.json().success).toBe(false);
expect(res.json().error).toMatch(/Could not reach it/);
expect(res.json().error).toContain('connection timed out');
await app.close();
});
it('carries the adopt metadata that a restart needs to rebuild the command', async () => {
const { app, ctx } = await createRouteTestHarness(registerSessionRoutes);
await app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'cand-1' } });
const created = ctx.addSession.mock.calls[0][0] as unknown as {
adopt?: { socketPath?: string; targetSession?: string; viewSession?: string };
isAdopted?: boolean;
};
expect(created.isAdopted).toBe(true);
expect(created.adopt?.socketPath).toBe(CANDIDATE.socketPath);
expect(created.adopt?.targetSession).toBe(CANDIDATE.sessionName);
// Derived from the Codeman session id, so it is provably ours to kill.
expect(created.adopt?.viewSession).toMatch(/^codeman-view-/);
await app.close();
});
});