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
+70
-16
@@ -69,6 +69,7 @@
|
||||
*/
|
||||
|
||||
import { collectDescendants } from './proc-tree.js';
|
||||
import { listClis } from './config/cli-registry/registry.js';
|
||||
import { FOREIGN_MAX_PANES, FOREIGN_MAX_PROCS, FOREIGN_MAX_SOCKETS } from './config/foreign-tmux.js';
|
||||
import type { ForeignPaneProbe, ForeignTmuxLocationKind } from './types/foreign-tmux.js';
|
||||
import type { SessionMode } from './types/session.js';
|
||||
@@ -239,23 +240,69 @@ export function parseForeignProbeOutput(stdout: string): ForeignProbeOutput {
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* argv signatures for the CLIs Codeman knows, most specific first.
|
||||
* argv signatures, derived from the registry rather than written here.
|
||||
*
|
||||
* Matched against the WHOLE argv of a pane descendant, so `node .../bin/claude`
|
||||
* hits the claude rule via the path. `dsh` needs a tighter rule than the rest:
|
||||
* Debian ships an unrelated `dsh` (distributed shell), which is exactly the trap
|
||||
* `deepseek-cli-resolver.ts` guards against with an identity probe.
|
||||
* hits the claude rule via the path. The binary names come from each entry's
|
||||
* `discovery.binaries` — the one place that already knows a mode name is NOT
|
||||
* always a binary name (`antigravity` runs `agy`). The hand-written table this
|
||||
* replaces keyed on CLI ids, which CLAUDE.md forbids outside `stock.ts`, and it
|
||||
* had already fallen behind: `omp` was missing, so a hand-started omp pane read
|
||||
* as a plain shell.
|
||||
*
|
||||
* Resolved at CALL time, never into a module-level const: a CLI enabled (or a
|
||||
* custom entry added) at runtime must be recognised without a restart.
|
||||
*
|
||||
* ⚠️ Longest name first. `pi` is two characters and its "command itself" form
|
||||
* would otherwise be tried before `opencode`'s; length order is what keeps a
|
||||
* short generic name from claiming a pane that belongs to a longer one.
|
||||
*
|
||||
* ⚠️ Names are regex-ESCAPED. They reach here from `~/.codeman/clis.json`, and
|
||||
* while the schema's SHELL_TOKEN already refuses the dangerous shapes, building
|
||||
* a pattern out of an unescaped foreign string is how a config value turns into
|
||||
* a regex. Escaping means the worst a bad name can do is match nothing.
|
||||
*/
|
||||
export const FOREIGN_CLI_SIGNATURES: ReadonlyArray<{ mode: SessionMode; test: RegExp }> = [
|
||||
{ mode: 'claude', test: /(^|\/)claude(\s|$)|[/\\]\.?claude[/\\][^\s]*cli\.js|[/\\]bin[/\\]claude\b/ },
|
||||
{ mode: 'codex', test: /(^|\/)codex(\s|$)|[/\\]bin[/\\]codex\b|[/\\]@openai[/\\]codex/ },
|
||||
{ mode: 'opencode', test: /(^|\/)opencode(\s|$)|[/\\]bin[/\\]opencode\b/ },
|
||||
{ mode: 'antigravity', test: /(^|\/)agy(\s|$)|[/\\]bin[/\\]agy\b|antigravity/ },
|
||||
{ mode: 'gemini', test: /(^|\/)gemini(\s|$)|[/\\]bin[/\\]gemini\b/ },
|
||||
{ mode: 'grok', test: /(^|\/)grok(\s|$)|[/\\]bin[/\\]grok\b/ },
|
||||
{ mode: 'deepseek', test: /(^|\/)dsh(\s|$)|[/\\]bin[/\\]dsh\b|deepseek[-_]?harness/i },
|
||||
{ mode: 'pi', test: /(^|\/)pi(\s|$)|[/\\]bin[/\\]pi\b/ },
|
||||
];
|
||||
export function foreignCliSignatures(): ReadonlyArray<{ mode: SessionMode; test: RegExp }> {
|
||||
const seen = new Set<string>();
|
||||
const out: { mode: SessionMode; test: RegExp }[] = [];
|
||||
// `listClis()`, not `enabledClis()`: classification is a description of what is
|
||||
// ALREADY running in someone else's pane, not a decision to launch anything.
|
||||
// A disabled CLI still deserves its real name on the row.
|
||||
const named: { mode: SessionMode; name: string }[] = [];
|
||||
for (const entry of listClis()) {
|
||||
// ⚠️ Stock entries only, and this is a `stock` check rather than an id check
|
||||
// on purpose: `SessionMode` is the CLOSED union of the stock ids, so a custom
|
||||
// entry has no SessionMode to be. Letting one through would put a value the
|
||||
// union does not contain on `SessionState.mode`. A custom CLI's pane falls to
|
||||
// the `shell` answer below, which is the honest one — Codeman has no name for
|
||||
// it, and an adopted wrapper runs `attach` either way.
|
||||
if (!entry.stock) continue;
|
||||
for (const binary of entry.discovery.binaries) {
|
||||
// `shell` declares none, which is right: it is the FALLBACK answer, never a
|
||||
// signature match.
|
||||
if (!binary || seen.has(binary)) continue;
|
||||
seen.add(binary);
|
||||
named.push({ mode: entry.id as SessionMode, name: binary });
|
||||
}
|
||||
}
|
||||
named.sort((a, b) => b.name.length - a.name.length);
|
||||
for (const { mode, name } of named) {
|
||||
const n = escapeRegExp(name);
|
||||
out.push({
|
||||
mode,
|
||||
test: new RegExp(
|
||||
// the command itself · a `bin/` path · a node launcher under the CLI's
|
||||
// own dot-directory (`~/.claude/local/.../cli.js`)
|
||||
`(^|[/\\\\])${n}(\\s|$)|[/\\\\]bin[/\\\\]${n}\\b|[/\\\\]\\.?${n}[/\\\\][^\\s]*cli\\.js`
|
||||
),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/** Commands that mean "this is a plain shell", not an unidentified agent. */
|
||||
const SHELL_COMMANDS = new Set([
|
||||
@@ -300,7 +347,14 @@ export function classifyForeignPaneMode(
|
||||
const direct = matchSignature(own);
|
||||
if (direct) return { mode: direct, command: truncate(own) };
|
||||
|
||||
const descendants = collectDescendants(pane.panePid, probe.byParent);
|
||||
// ⚠️ Report truncation. A walk that stopped at a cap returns "nothing matched",
|
||||
// which is byte-identical to the answer for a real shell — without this line a
|
||||
// deep or wide tree silently downgrades an agent pane to `shell` and there is
|
||||
// nothing anywhere to tell the two apart.
|
||||
const descendants = collectDescendants(pane.panePid, probe.byParent, {
|
||||
onTruncated: (root, cap, reason) =>
|
||||
console.warn(`[foreign-tmux] descendant walk for ${root} hit the ${cap}-${reason} cap; truncating`),
|
||||
});
|
||||
for (const pid of descendants) {
|
||||
const argv = probe.argvByPid.get(pid);
|
||||
if (!argv) continue;
|
||||
@@ -325,7 +379,7 @@ function matchSignature(argv: string): SessionMode | null {
|
||||
if (!argv) return null;
|
||||
const base = basenameOf(argv);
|
||||
if (SHELL_COMMANDS.has(base)) return null;
|
||||
for (const sig of FOREIGN_CLI_SIGNATURES) {
|
||||
for (const sig of foreignCliSignatures()) {
|
||||
if (sig.test.test(argv)) return sig.mode;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -3028,6 +3028,14 @@ export class Session extends EventEmitter {
|
||||
*/
|
||||
private _maybeAcceptTrustDialog(): void {
|
||||
if (this._trustDialogAccepted) return;
|
||||
// ⚠️ Never answer a dialog in someone else's pane. An adopted session wraps a
|
||||
// claude the human started, and this scan reads the pane and SENDS KEYS for 90
|
||||
// seconds after `startInteractive()` — which boot recovery re-runs for every
|
||||
// wrapper, so it is not a one-time window at adoption.
|
||||
if (this._adopt) {
|
||||
this._trustDialogAccepted = true;
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (now - this._interactiveStartedAt > TRUST_DIALOG_WINDOW_MS) {
|
||||
this._trustDialogAccepted = true; // window closed; anything matching now is not the dialog
|
||||
|
||||
@@ -21,11 +21,17 @@
|
||||
* tab keeps sweeping tmux sockets forever. `startForeignPolling` /
|
||||
* `stopForeignPolling` are called from `showWelcome`/`hideWelcome`.
|
||||
*
|
||||
* ⚠️ Docker and remote locations are NOT polled. Each costs a `docker exec` or a
|
||||
* full ssh handshake per target, and doing that on every home-screen load is the
|
||||
* one cost the server-side design explicitly refuses. They are fetched only when
|
||||
* the user asks, via the "scan containers & hosts" toggle, which then rides along
|
||||
* with the same poll.
|
||||
* ⚠️ Docker and remote locations are NOT in that poll. Each costs a `docker exec`
|
||||
* or a full ssh handshake per target, each with a ~12s timeout, so riding them
|
||||
* along on an 8s tick stacks connections on a slow host — the one cost the
|
||||
* server-side design explicitly refuses. The wide scan is therefore ONE-SHOT:
|
||||
* turning the toggle on fetches once, and after that it is the user's Rescan
|
||||
* button. The periodic poll stays local-only forever.
|
||||
*
|
||||
* The wide rows are kept separately (`_foreignWideSessions`) and merged into
|
||||
* every local render, because a local-only poll's payload does not contain them
|
||||
* and would otherwise wipe the container/remote rows off the list one tick after
|
||||
* they appeared. Turning the toggle off discards them.
|
||||
*
|
||||
* ⚠️ Adoption creates a REAL session, so the button locks while in flight and the
|
||||
* result goes through the app's normal idempotent create path
|
||||
@@ -74,6 +80,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (this._foreignPollTimer) return;
|
||||
void this.loadForeignSessions();
|
||||
this._foreignPollTimer = setInterval(() => {
|
||||
// Local only — see @fileoverview. The wide scan never rides the timer.
|
||||
void this.loadForeignSessions();
|
||||
}, this._foreignPollMs || FOREIGN_POLL_FALLBACK_MS);
|
||||
},
|
||||
@@ -90,19 +97,46 @@ Object.assign(CodemanApp.prototype, {
|
||||
return this._foreignScanRemote === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Flip the wide scan. ON fetches containers and hosts ONCE; OFF discards what
|
||||
* that fetch found, because those rows are not re-confirmed by any later poll
|
||||
* and leaving them up would show a container session that may be long gone.
|
||||
*/
|
||||
toggleForeignScanRemote() {
|
||||
this._foreignScanRemote = !this._foreignScanRemote;
|
||||
void this.loadForeignSessions();
|
||||
if (!this._foreignScanRemote) {
|
||||
this._foreignWideSessions = [];
|
||||
this._foreignWideNotes = [];
|
||||
this.renderAllForeignSessions();
|
||||
return;
|
||||
}
|
||||
void this.loadForeignSessions({ wide: true });
|
||||
},
|
||||
|
||||
/** Re-run the expensive scan on demand. The only way wide rows ever refresh. */
|
||||
rescanForeignWide() {
|
||||
if (!this.foreignScanRemote()) return;
|
||||
void this.loadForeignSessions({ wide: true });
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Data
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
async loadForeignSessions() {
|
||||
const wide = this.foreignScanRemote();
|
||||
/**
|
||||
* Fetch the list. `wide` asks the server for containers and ssh hosts too, and
|
||||
* ONLY an explicit user action ever passes it — never the interval.
|
||||
*/
|
||||
async loadForeignSessions({ wide = false } = {}) {
|
||||
const qs = wide ? '?docker=1&remote=1' : '';
|
||||
const data = await this._apiJson(`/api/mux/foreign${qs}`);
|
||||
if (wide) this._foreignWideLoading = true;
|
||||
this.renderAllForeignSessions();
|
||||
let data;
|
||||
try {
|
||||
data = await this._apiJson(`/api/mux/foreign${qs}`);
|
||||
} finally {
|
||||
if (wide) this._foreignWideLoading = false;
|
||||
}
|
||||
if (!data) {
|
||||
// A failed poll must not blank a list the user is looking at: keep the
|
||||
// last good result and let the next tick recover.
|
||||
@@ -111,13 +145,34 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
this._foreignError = false;
|
||||
this._foreignSessions = Array.isArray(data.sessions) ? data.sessions : [];
|
||||
this._foreignNotes = Array.isArray(data.notes) ? data.notes : [];
|
||||
const rows = Array.isArray(data.sessions) ? data.sessions : [];
|
||||
const notes = Array.isArray(data.notes) ? data.notes : [];
|
||||
if (wide) {
|
||||
// Split by location: the local rows refresh on every tick, the rest only
|
||||
// here, so they have to be stored apart or the next local-only payload
|
||||
// (which cannot contain them) would read as "those sessions are gone".
|
||||
this._foreignSessions = rows.filter((r) => (r.location || 'local') === 'local');
|
||||
this._foreignWideSessions = rows.filter((r) => (r.location || 'local') !== 'local');
|
||||
this._foreignNotes = notes;
|
||||
this._foreignWideNotes = [];
|
||||
this._foreignWideAt = Date.now();
|
||||
} else {
|
||||
this._foreignSessions = rows;
|
||||
this._foreignNotes = notes;
|
||||
}
|
||||
this._foreignCanScanWide = data.canScanWide === true;
|
||||
if (Number.isFinite(data.pollIntervalMs)) this._foreignPollMs = data.pollIntervalMs;
|
||||
this.renderAllForeignSessions();
|
||||
},
|
||||
|
||||
/** Local rows plus whatever the last wide scan found. The render's one input. */
|
||||
_allForeignRows() {
|
||||
const local = Array.isArray(this._foreignSessions) ? this._foreignSessions : [];
|
||||
if (!this.foreignScanRemote()) return local;
|
||||
const wide = Array.isArray(this._foreignWideSessions) ? this._foreignWideSessions : [];
|
||||
return wide.length ? local.concat(wide) : local;
|
||||
},
|
||||
|
||||
/**
|
||||
* Adopt one candidate.
|
||||
*
|
||||
@@ -138,22 +193,30 @@ Object.assign(CodemanApp.prototype, {
|
||||
body.docker = true;
|
||||
body.remote = true;
|
||||
}
|
||||
const data = await this._apiJson('/api/sessions/adopt', { method: 'POST', body });
|
||||
if (!data || !data.session) {
|
||||
this.showToast?.(
|
||||
codemanT
|
||||
? codemanT('That session is gone. Refreshing the list.')
|
||||
: 'That session is gone. Refreshing the list.',
|
||||
'error'
|
||||
);
|
||||
// ⚠️ `_api`, not `_apiJson`: the latter folds a network failure, a non-2xx
|
||||
// and `success:false` all into null, and the server deliberately tells
|
||||
// two different stories here — "that tmux session is no longer there" vs
|
||||
// "could not reach it just now (…) — try again". Collapsing them showed a
|
||||
// live remote session as deleted every time the link flickered.
|
||||
const res = await this._api('/api/sessions/adopt', { method: 'POST', body });
|
||||
const payload = await this._readJsonBody(res);
|
||||
const session = payload && payload.success !== false ? payload.data?.session : null;
|
||||
if (!session) {
|
||||
const fallback = codemanT
|
||||
? codemanT('Could not open that session. Refreshing the list.')
|
||||
: 'Could not open that session. Refreshing the list.';
|
||||
this.showToast?.(payload?.error || fallback, 'error');
|
||||
await this.loadForeignSessions();
|
||||
return;
|
||||
}
|
||||
const data = { session };
|
||||
// Go through the app's normal create path so tab order, lineage lines and
|
||||
// SSE-vs-POST ordering behave exactly as they do for a Run.
|
||||
this._onSessionCreated?.(data.session);
|
||||
await this.selectSession(data.session.id);
|
||||
await this.loadForeignSessions();
|
||||
// Still one explicit user action, so a wide row's "Open" refreshes the
|
||||
// wide list too — otherwise the row just adopted keeps reading as free.
|
||||
await this.loadForeignSessions({ wide: this.foreignScanRemote() });
|
||||
} finally {
|
||||
this._foreignAdoptInFlight = false;
|
||||
if (buttonEl) {
|
||||
@@ -163,6 +226,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/** The error envelope, or null when there was no readable body at all. */
|
||||
async _readJsonBody(res) {
|
||||
if (!res) return null;
|
||||
try {
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Render
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -183,7 +256,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
* near `innerHTML`.
|
||||
*/
|
||||
renderForeignSessions(container) {
|
||||
const rows = Array.isArray(this._foreignSessions) ? this._foreignSessions : [];
|
||||
const rows = this._allForeignRows();
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!rows.length) {
|
||||
@@ -192,7 +265,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// A note is a REASON the list is empty, so a block carrying one must stay
|
||||
// visible — hiding it is exactly how "where did my session go" becomes
|
||||
// unanswerable.
|
||||
const hasNotes = Array.isArray(this._foreignNotes) && this._foreignNotes.length > 0;
|
||||
const hasNotes =
|
||||
(Array.isArray(this._foreignNotes) && this._foreignNotes.length > 0) ||
|
||||
(Array.isArray(this._foreignWideNotes) && this._foreignWideNotes.length > 0);
|
||||
// ⚠️ `canScanWide` must keep the block visible even with nothing to show.
|
||||
// The scan toggle lives in the header, so hiding an empty block also hides
|
||||
// the only control that could fill it — on a host with containers but no
|
||||
@@ -242,7 +317,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
* as text.
|
||||
*/
|
||||
_appendForeignNotes(container) {
|
||||
const notes = Array.isArray(this._foreignNotes) ? this._foreignNotes : [];
|
||||
const local = Array.isArray(this._foreignNotes) ? this._foreignNotes : [];
|
||||
const wide = this.foreignScanRemote() && Array.isArray(this._foreignWideNotes) ? this._foreignWideNotes : [];
|
||||
const notes = wide.length ? local.concat(wide) : local;
|
||||
if (!notes.length) return;
|
||||
const box = document.createElement('div');
|
||||
box.className = 'foreign-notes';
|
||||
@@ -284,6 +361,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
scan.textContent = this.foreignScanRemote() ? '⟳ all' : '⟳ local';
|
||||
header.appendChild(scan);
|
||||
|
||||
// The wide scan is one-shot, so without this button its rows could only
|
||||
// ever be refreshed by toggling off and on again.
|
||||
if (this.foreignScanRemote()) {
|
||||
const rescan = document.createElement('button');
|
||||
rescan.type = 'button';
|
||||
rescan.className = 'foreign-scan-toggle';
|
||||
rescan.dataset.foreignAction = 'rescan-wide';
|
||||
rescan.disabled = this._foreignWideLoading === true;
|
||||
rescan.title = codemanT
|
||||
? codemanT('Scan containers and remote hosts again')
|
||||
: 'Scan containers and remote hosts again';
|
||||
rescan.textContent = this._foreignWideLoading
|
||||
? codemanT
|
||||
? codemanT('Scanning…')
|
||||
: 'Scanning…'
|
||||
: codemanT
|
||||
? codemanT('Rescan')
|
||||
: 'Rescan';
|
||||
header.appendChild(rescan);
|
||||
}
|
||||
|
||||
return header;
|
||||
},
|
||||
|
||||
@@ -353,6 +451,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
void this.selectSession(el.dataset.foreignSession);
|
||||
} else if (action === 'toggle-scan') {
|
||||
this.toggleForeignScanRemote();
|
||||
} else if (action === 'rescan-wide') {
|
||||
this.rescanForeignWide();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1191,6 +1191,13 @@ export function registerSessionRoutes(
|
||||
* grant gates elsewhere. The admin gate subsumes it, so there is deliberately
|
||||
* no second grant check here.
|
||||
*/
|
||||
/**
|
||||
* The wrapper pane for an adopted session runs `tmux attach`, never the agent,
|
||||
* so it needs no workspace — and must not borrow the foreign one. Same value
|
||||
* `resolveMuxAttachCwd()` gives remote and docker panes.
|
||||
*/
|
||||
const ADOPTED_WRAPPER_WORKING_DIR = '/tmp';
|
||||
|
||||
app.post('/api/sessions/adopt', async (req, reply) => {
|
||||
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
|
||||
|
||||
@@ -1277,13 +1284,16 @@ export function registerSessionRoutes(
|
||||
|
||||
const adoptHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||
|
||||
// ⚠️ `workingDir` for an adopted session is the FOREIGN pane's cwd, which may
|
||||
// not exist on this host (a container path, a remote path). It is recorded as
|
||||
// an observation for display; the wrapper pane is never `cd`'d into it, and
|
||||
// the case-space confinement that guards a real workingDir does not apply
|
||||
// because nothing is created there.
|
||||
// ⚠️ The wrapper's `workingDir` is a NEUTRAL LOCAL path, never the foreign
|
||||
// pane's cwd. That cwd is an observation about another machine: it can carry
|
||||
// characters outside SAFE_PATH_PATTERN (`~/c++`, `Program Files (x86)`), which
|
||||
// makes `createSession` throw so the tab never starts at all, and when it is a
|
||||
// container or ssh path every local consumer of `workingDir` — the Files panel,
|
||||
// the boot hook sweep, the fs watchers — reads it as a path on THIS host. The
|
||||
// foreign cwd stays on `adopt.paneCurrentPath`, which is for display only.
|
||||
// `/tmp` is the same neutral cwd `resolveMuxAttachCwd` already gives the pane.
|
||||
const session = new Session({
|
||||
workingDir: target.workingDir || process.cwd(),
|
||||
workingDir: ADOPTED_WRAPPER_WORKING_DIR,
|
||||
mode: target.mode,
|
||||
name: body.name || target.sessionName,
|
||||
mux: ctx.mux,
|
||||
@@ -1712,6 +1722,10 @@ export function registerSessionRoutes(
|
||||
// and belongs in its own PR, not in a refactor that is meant to change nothing.
|
||||
if (
|
||||
!isExternalCliMode(session.mode) &&
|
||||
// ⚠️ And never for an adopted session, whose mode can be claude while the
|
||||
// agent in that pane belongs to someone else. Kept as its own clause for
|
||||
// the same reason the respawn and Ralph routes keep theirs separate.
|
||||
!session.isAdopted &&
|
||||
ctx.store.getConfig().ralphEnabled &&
|
||||
!session.ralphTracker.autoEnableDisabled
|
||||
) {
|
||||
@@ -3303,6 +3317,16 @@ export function registerSessionRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(AutoClearSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
// ⚠️ Separate from the external-CLI gate on purpose: an adopted session's mode
|
||||
// can perfectly well BE claude, and this drives the pane of a session someone
|
||||
// else is using. Merging the two checks means a future change to the
|
||||
// external-CLI rule silently reopens this one.
|
||||
if (session.isAdopted) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Auto-clear is not available for adopted sessions: Codeman did not start this agent and must not drive it'
|
||||
);
|
||||
}
|
||||
|
||||
session.setAutoClear(body.enabled, body.threshold);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
@@ -3324,6 +3348,16 @@ export function registerSessionRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(AutoCompactSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
// ⚠️ Separate from the external-CLI gate on purpose: an adopted session's mode
|
||||
// can perfectly well BE claude, and this drives the pane of a session someone
|
||||
// else is using. Merging the two checks means a future change to the
|
||||
// external-CLI rule silently reopens this one.
|
||||
if (session.isAdopted) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Auto-compact is not available for adopted sessions: Codeman did not start this agent and must not drive it'
|
||||
);
|
||||
}
|
||||
|
||||
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
@@ -3346,6 +3380,16 @@ export function registerSessionRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(AutoResumeSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
// ⚠️ Separate from the external-CLI gate on purpose: an adopted session's mode
|
||||
// can perfectly well BE claude, and this drives the pane of a session someone
|
||||
// else is using. Merging the two checks means a future change to the
|
||||
// external-CLI rule silently reopens this one.
|
||||
if (session.isAdopted) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Auto-resume is not available for adopted sessions: Codeman did not start this agent and must not drive it'
|
||||
);
|
||||
}
|
||||
|
||||
session.setAutoResume(body.enabled);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
|
||||
@@ -3796,6 +3796,13 @@ export class WebServer extends EventEmitter {
|
||||
const workspaces = new Set<string>();
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session.mode !== 'claude' || session.remote) continue;
|
||||
// ⚠️ An ADOPTED session is not caught by the `remote` check above: its
|
||||
// connection facts live on `adopt.remote` / `adopt.docker`, so a session
|
||||
// wrapping someone's in-container or over-ssh claude looks local here.
|
||||
// Its workspace was never Codeman's to begin with, so installing hooks
|
||||
// there writes `.claude/settings.local.json` into the human's own repo —
|
||||
// on EVERY restart, since this sweep runs from boot recovery.
|
||||
if (session.isAdopted) continue;
|
||||
if (session.docker && !session.docker.hooksEnabled) continue;
|
||||
if (session.workingDir) workspaces.add(session.workingDir);
|
||||
}
|
||||
|
||||
@@ -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(() => {});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user