@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.
- package/dist/build-root.js +1 -0
- package/dist/clients/attach/attach-cmd.js +124 -124
- package/dist/commands/capture.d.ts +2 -0
- package/dist/commands/capture.js +28 -0
- package/dist/core/__tests__/revive.test.js +70 -1
- package/dist/core/command.d.ts +15 -0
- package/dist/core/command.js +72 -0
- package/dist/core/profiles/select.d.ts +4 -1
- package/dist/core/profiles/select.js +356 -81
- package/dist/core/runtime/revive.js +176 -139
- package/dist/core/runtime/tmux.js +4 -1
- package/dist/core/view/transport-local.js +12 -3
- package/dist/web-client/assets/{index-BvzxXXGU.js → index-CnF5r8ky.js} +18 -18
- package/dist/web-client/index.html +1 -1
- package/dist/web-client/sw.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
+
});
|
package/dist/core/command.d.ts
CHANGED
|
@@ -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
|
package/dist/core/command.js
CHANGED
|
@@ -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
|
|
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>;
|