mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
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:
1 parent
aec4867821
commit
09ccffd32e
11 files changed
+954
-46
No files matched your search
@@ -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,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;
|
||||
|
||||
Reference in new issue
Block a user