Files
Codeman/test/foreign-adoption-guards.test.ts
T
d fei 09ccffd32e 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.
2026-09-30 23:29:54 -07:00

125 lines
4.8 KiB
TypeScript

/**
* @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(() => {});
});
});