@crouton-kit/crouter 0.3.67 → 0.3.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ import type { BranchDef } from '../core/command.js';
2
+ export declare function registerCapture(): BranchDef;
@@ -0,0 +1,28 @@
1
+ // `crtr capture` — transparent passthrough to the external Capture CLI
2
+ // (`@crouton-kit/capture`, bin `capture`). Capture stays a separately
3
+ // published, open-source package: crouter takes no build-time dependency on
4
+ // it and does not mirror its command tree. Every argument after `capture` is
5
+ // forwarded verbatim (see the `passthrough` field on BranchDef in
6
+ // core/command.ts) — including `crtr capture -h`, which shows CAPTURE's own
7
+ // help, not crtr's. crtr's only framing of this command lives in the
8
+ // rootEntry below (surfaced at `crtr -h`); there is no separate crtr-level
9
+ // help body to keep in sync with capture's schema.
10
+ import { defineBranch } from '../core/command.js';
11
+ const INSTALL_HINT = 'Install the Capture CLI: npm install -g @crouton-kit/capture';
12
+ export function registerCapture() {
13
+ return defineBranch({
14
+ name: 'capture',
15
+ passthrough: { bin: 'capture', installHint: INSTALL_HINT },
16
+ rootEntry: {
17
+ concept: 'browser automation for agents via the external Capture CLI (CDP screenshots, HAR, a11y, JS-exec, and site libs)',
18
+ desc: 'forwards verbatim to the external `capture` binary',
19
+ useWhen: 'you need headless browser automation — screenshot or inspect a page, record a HAR, or run JS in a real browser to reach a site\'s backend (e.g. Reddit/X via the bundled libs). `crtr capture <args>` forwards every argument verbatim to the external `capture` CLI, including `-h` — run `crtr capture -h` for capture\'s own command schema. Requires `capture` on PATH (npm install -g @crouton-kit/capture).',
20
+ },
21
+ help: {
22
+ name: 'capture',
23
+ summary: 'transparent alias for the external `capture` CLI — every argument forwards verbatim',
24
+ model: 'This branch owns no schema of its own: it execs `capture` with whatever args follow, inherits stdio, and propagates its exit code. Run `crtr capture -h` for capture\'s real command tree.',
25
+ },
26
+ children: [],
27
+ });
28
+ }
@@ -34,9 +34,11 @@ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
34
34
  import { tmpdir } from 'node:os';
35
35
  import { join } from 'node:path';
36
36
  import { spawn, spawnSync } from 'node:child_process';
37
+ import { DatabaseSync } from 'node:sqlite';
37
38
  import { createNode, subscribe } from '../canvas/canvas.js';
38
39
  import { readInboxSince } from '../feed/inbox.js';
39
- import { closeDb } from '../canvas/db.js';
40
+ import { closeDb, openDb } from '../canvas/db.js';
41
+ import { canvasDbPath } from '../canvas/paths.js';
40
42
  import { buildPiArgv } from '../runtime/launch.js';
41
43
  import { headlessBrokerHost } from '../runtime/host.js';
42
44
  import { resumeArgs, reviveNode } from '../runtime/revive.js';
@@ -350,3 +352,70 @@ test('reviveNode fans a doctrine wake to subscribers when launch/preflight throw
350
352
  await h.dispose();
351
353
  }
352
354
  });
355
+ // ---------------------------------------------------------------------------
356
+ // Duplicate-broker leak (live-incident regression): reviveNode's guard-check-
357
+ // then-launch-then-recordPid critical section must be a single atomic write
358
+ // transaction, so a concurrent reviveNode call for the SAME node (a second OS
359
+ // process racing this one against the same canvas.db, e.g. a retried web-relay
360
+ // revive under socket thrash) cannot read the same stale pid, launch its own
361
+ // broker, and overwrite this call's freshly-recorded pid — leaking this call's
362
+ // broker as an untracked live process. Proven here by holding a competing raw
363
+ // write transaction open on a SECOND connection to the same db file before
364
+ // calling reviveNode: with the fix, reviveNode's own BEGIN IMMEDIATE cannot
365
+ // acquire the write lock while the other connection holds it, so it fails
366
+ // closed (throws) rather than silently interleaving with the held lock and
367
+ // corrupting the guard's read-then-write. Releasing the competing lock lets an
368
+ // identical reviveNode call proceed and record a fresh pid normally — the
369
+ // serialization is transient contention, not a permanent wedge.
370
+ // ---------------------------------------------------------------------------
371
+ test('reviveNode\'s guard-check-through-recordPid section is one atomic write transaction (duplicate-broker-leak regression)', async () => {
372
+ const h = await createHarness({ headless: true, sessionPrefix: 'crtr-revive-lock' });
373
+ let contender;
374
+ try {
375
+ const sessionFile = join(home, 'sessions', 'crtr-revive-lock.jsonl');
376
+ mkdirSync(join(home, 'sessions'), { recursive: true });
377
+ writeFileSync(sessionFile, '{}\n', 'utf8');
378
+ const stalePid = deadPid();
379
+ const M = h.fabricateBrokerNode({
380
+ status: 'active',
381
+ intent: null,
382
+ pi_pid: stalePid,
383
+ pi_session_id: 'uuid-lock',
384
+ pi_session_file: sessionFile,
385
+ });
386
+ // Shorten THIS test's connection's busy_timeout (default 5000ms, db.ts) so
387
+ // the contended call below fails fast instead of waiting out the real
388
+ // production window — it's the ACQUIRING side's timeout that governs how
389
+ // long `BEGIN IMMEDIATE` waits, not the holder's.
390
+ openDb().exec('PRAGMA busy_timeout = 200;');
391
+ // A second raw connection to the SAME db file holds an exclusive write
392
+ // transaction open — modeling a concurrent reviveNode call (a different OS
393
+ // process, in the real incident) that already won the race and is mid-launch.
394
+ contender = new DatabaseSync(canvasDbPath());
395
+ contender.exec('BEGIN IMMEDIATE');
396
+ assert.throws(() => reviveNode(M, { resume: true }), /locked|busy/i, 'a reviveNode call cannot acquire the write lock while a concurrent revive holds it — no interleaved double-launch');
397
+ assert.equal(h.node(M).pi_pid, stalePid, 'blocked-out call recorded nothing — the held pid is untouched');
398
+ // Release the contender's lock — an identical reviveNode call now proceeds
399
+ // normally, exactly like the uncontended case.
400
+ contender.exec('COMMIT');
401
+ contender.close();
402
+ contender = undefined;
403
+ reviveNode(M, { resume: true });
404
+ const after = h.node(M);
405
+ assert.notEqual(after.pi_pid, stalePid, 'once uncontended, revive proceeds and records a fresh pid');
406
+ assert.equal(after.cycles, 1, 'the uncontended revive bumped the cycle counter');
407
+ }
408
+ finally {
409
+ if (contender !== undefined) {
410
+ try {
411
+ contender.exec('ROLLBACK');
412
+ }
413
+ catch { /* best-effort */ }
414
+ try {
415
+ contender.close();
416
+ }
417
+ catch { /* best-effort */ }
418
+ }
419
+ await h.dispose();
420
+ }
421
+ });
@@ -56,6 +56,17 @@ export interface BranchDef {
56
56
  rootEntry?: RootEntry;
57
57
  /** Opt into editor slash-command exposure (see SlashSpec). */
58
58
  slash?: SlashSpec;
59
+ /** Opt this branch out of the tree model entirely: every token after this
60
+ * branch's name is forwarded VERBATIM (raw argv, not the `--json`-filtered
61
+ * tokens) to an external binary via spawn, with stdio inherited and the
62
+ * child's exit code propagated. A deliberate, documented exception for
63
+ * wrapping an external CLI whose own schema crtr cannot and must not
64
+ * duplicate (see `crtr capture`) — not a general escape hatch. A
65
+ * passthrough branch should declare no children. */
66
+ passthrough?: {
67
+ bin: string;
68
+ installHint: string;
69
+ };
59
70
  children: (LeafDef | BranchDef)[];
60
71
  }
61
72
  export interface RootDef {
@@ -81,6 +92,10 @@ export declare function defineBranch(opts: {
81
92
  help: BranchHelp;
82
93
  rootEntry?: RootEntry;
83
94
  slash?: SlashSpec;
95
+ passthrough?: {
96
+ bin: string;
97
+ installHint: string;
98
+ };
84
99
  children: (LeafDef | BranchDef)[];
85
100
  }): BranchDef;
86
101
  /** Walk the whole tree and collect every node's SlashSpec (depth-first). Used
@@ -10,6 +10,8 @@ import { renderResult } from './render.js';
10
10
  import { CrtrError } from './errors.js';
11
11
  import { ExitCode } from '../types.js';
12
12
  import { readFileSync } from 'node:fs';
13
+ import { spawnSync } from 'node:child_process';
14
+ import { constants as osConstants } from 'node:os';
13
15
  // ---------------------------------------------------------------------------
14
16
  // Factory functions
15
17
  // ---------------------------------------------------------------------------
@@ -34,6 +36,14 @@ function visibleSubCount(def) {
34
36
  return (def.help.listing ?? []).filter((c) => c.tier !== 'hidden').length;
35
37
  }
36
38
  export function defineBranch(opts) {
39
+ // A passthrough branch must be childless: walk() only stops at the deepest
40
+ // matched node, so a passthrough branch with children would let some
41
+ // invocations descend into a child (tree dispatch) while others hit the
42
+ // passthrough interception — silently inconsistent transparency. Fail loudly
43
+ // at construction instead of letting that split happen at runtime.
44
+ if (opts.passthrough !== undefined && opts.children.length > 0) {
45
+ throw new Error(`defineBranch("${opts.name}"): a passthrough branch must declare no children (found ${opts.children.length})`);
46
+ }
37
47
  // Assemble the parent-level listing straight from the child defs — each node
38
48
  // owns its own description/whenToUse/tier, so the parent copies nothing
39
49
  // (principle 16). Bottom-up construction guarantees a child branch's listing
@@ -57,6 +67,7 @@ export function defineBranch(opts) {
57
67
  help: opts.help,
58
68
  rootEntry: opts.rootEntry,
59
69
  slash: opts.slash,
70
+ passthrough: opts.passthrough,
60
71
  children: opts.children,
61
72
  };
62
73
  }
@@ -169,6 +180,59 @@ function renderNode(node) {
169
180
  function helpRequested(remaining) {
170
181
  return remaining.some((t) => t === '-h' || t === '--help');
171
182
  }
183
+ /** Recover the RAW argv slice after a matched passthrough branch's path,
184
+ * unaffected by the `--json`-stripping done to build `tokens`. `path` is a
185
+ * prefix of non-`--json` tokens in order, so walking `rawTokens` while
186
+ * skipping `--json` occurrences until `path` is fully matched lands the
187
+ * cursor exactly where the branch's own args begin — including a literal
188
+ * `--json` the external binary itself accepts. */
189
+ function rawArgsAfterPath(rawTokens, path) {
190
+ let matched = 0;
191
+ for (let i = 0; i < rawTokens.length; i++) {
192
+ if (matched === path.length)
193
+ return rawTokens.slice(i);
194
+ if (rawTokens[i] === '--json')
195
+ continue;
196
+ if (rawTokens[i] === path[matched])
197
+ matched++;
198
+ }
199
+ return [];
200
+ }
201
+ /** Forward `args` to an external binary verbatim: stdio inherited (streaming
202
+ * preserved), and the child's exit code propagated. Never returns — either
203
+ * exits with the child's status or throws a structured CrtrError (ENOENT ->
204
+ * install hint) for the caller's existing error handler to render. */
205
+ function runPassthrough(passthrough, args) {
206
+ const result = spawnSync(passthrough.bin, args, { stdio: 'inherit' });
207
+ if (result.error !== undefined) {
208
+ // Any launch-time failure (not found, not executable, ...) means this
209
+ // binary isn't usable from here — surface one structured error with the
210
+ // install hint rather than branching on a specific errno. (Node/libuv can
211
+ // report EACCES instead of ENOENT for a genuinely-missing binary when PATH
212
+ // contains malformed entries — e.g. a literal `~/...` segment — so a
213
+ // ENOENT-only check would miss real missing-binary cases on such hosts.)
214
+ const err = result.error;
215
+ throw new CrtrError('binary_missing', `${passthrough.bin} could not be run (${err.code ?? err.message})`, ExitCode.USAGE, {
216
+ received: passthrough.bin,
217
+ next: passthrough.installHint,
218
+ });
219
+ }
220
+ if (result.status !== null) {
221
+ process.exit(result.status);
222
+ }
223
+ if (result.signal !== null) {
224
+ // Signal-terminated: exit(1) would break the transparent exit-propagation
225
+ // contract (a direct shell invocation would show 128+signal, e.g. 143 for
226
+ // SIGTERM). Map to the standard 128+signal-number convention.
227
+ const signalNumber = osConstants.signals[result.signal];
228
+ if (signalNumber !== undefined) {
229
+ process.exit(128 + signalNumber);
230
+ }
231
+ }
232
+ // Truly impossible per Node's spawnSync contract (status and signal are
233
+ // mutually exclusive and one is always set on a completed run) — fallback only.
234
+ process.exit(1);
235
+ }
172
236
  /** Build a structured unknown-path error. Names valid children of the deepest
173
237
  * matched node and names the entry command per the spec. The entry command is
174
238
  * the full path to the matched node (not just its local name), so the recovery
@@ -367,6 +431,14 @@ export async function runCli(root, argv) {
367
431
  }
368
432
  const { node, path, remaining } = walk(root, tokens);
369
433
  try {
434
+ // Passthrough branches (e.g. `crtr capture`) opt out of the tree model
435
+ // entirely: forward every token after the branch verbatim to an external
436
+ // binary before any of the normal unknown-path / help / dispatch logic
437
+ // runs, so `crtr capture -h`, `crtr capture <args>`, and bare `crtr capture`
438
+ // all reach the external CLI untouched.
439
+ if (node.kind === 'branch' && node.passthrough !== undefined) {
440
+ runPassthrough(node.passthrough, rawArgsAfterPath(rawTokens, path));
441
+ }
370
442
  // A root/branch node's `remaining` is only non-empty because walk hit
371
443
  // -h/--help or an unmatched child name (a leaf always consumes the rest
372
444
  // of the path itself). So a non-help leading token here is always the
@@ -27,7 +27,10 @@ export declare function profileCoversCwd(entry: ProfileEntry, cwd: string): bool
27
27
  * is covered). Default is the MRU covering profile.
28
28
  * - Non-interactive: auto-pick the MRU covering profile, no prompt.
29
29
  * 4. Else — nothing covers cwd walking all the way up. Interactive: prompt to
30
- * create a profile here or use the root profile. Non-interactive (no TTY):
30
+ * create a profile here, select an existing profile (a searchable list of
31
+ * every profile, for when you started somewhere none of them cover and
32
+ * can't recall the exact name — the pick then offers to adopt cwd), or use
33
+ * the root profile. Non-interactive (no TTY):
31
34
  * default to the root profile and print the recovery instruction to STDERR —
32
35
  * never stdout, which the caller may be piping. */
33
36
  export declare function selectProfileForCwd(cwd: string, explicitProfile?: string | null, forcePicker?: boolean): Promise<string>;