mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
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.
125 lines
4.8 KiB
TypeScript
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(() => {});
|
|
});
|
|
});
|