@crouton-kit/capture 1.3.18 → 1.3.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/capture",
3
- "version": "1.3.18",
3
+ "version": "1.3.19",
4
4
  "description": "Browser automation and UI validation via CDP — session-based screenshots, HAR, a11y, click/type, and JS exec",
5
5
  "bin": {
6
6
  "capture": "./bin/capture"
package/src/capture.ts CHANGED
@@ -56,7 +56,10 @@ I/O contract: flags and positionals on input; one rendered prose block on
56
56
  stdout. --json mirrors the same result as JSON, but the rendered block is
57
57
  the contract. stderr carries in-flight diagnostics only.
58
58
 
59
- A CDP-enabled browser must be running; \`capture tab list\` is the probe.
59
+ A CDP-enabled browser must be running; \`capture tab list\` is the probe, and
60
+ \`capture tab launch\` starts one capture owns and reaps when nothing is (never
61
+ hand-roll a detached browser — nothing reaps that). Capture never signals a
62
+ browser it did not start, so attaching to your own with --port is unchanged.
60
63
  CDP_PORT / CDP_TARGET pin the browser + tab for orchestrators — precedence:
61
64
  explicit flag > active session > env.`;
62
65
  }
package/src/cdp/args.ts CHANGED
@@ -60,7 +60,7 @@ function validateKnownLeaf(command: string, positional: readonly string[]): void
60
60
  const branches: Record<string, readonly string[]> = {
61
61
  session: ['start', 'stop', 'list', 'view', 'har', 'log'],
62
62
  page: ['click', 'type', 'scroll', 'navigate', 'exec', 'shot', 'elements'],
63
- tab: ['list', 'open', 'close', 'reset', 'network'],
63
+ tab: ['launch', 'quit', 'list', 'open', 'close', 'reset', 'network'],
64
64
  measure: ['snap', 'check', 'diff', 'census', 'explain', 'sweep', 'map'],
65
65
  motion: ['rec', 'mask', 'timeline', 'jank', 'response'],
66
66
  lib: ['list', 'search', 'show', 'read'],
@@ -130,7 +130,11 @@ export function validateCliInvocation(parsed: ParsedArgs): void {
130
130
  }
131
131
 
132
132
  if (parsed.command === 'tab') {
133
- requireCount(values, leaf === 'list' ? 0 : 1, leaf === 'list' ? 0 : 1, `tab ${leaf}`);
133
+ // `launch` takes no target at all (its browser does not exist yet) and
134
+ // `quit` takes an optional port; every other tab leaf names exactly one.
135
+ if (leaf === 'launch' || leaf === 'list') requireCount(values, 0, 0, `tab ${leaf}`);
136
+ else if (leaf === 'quit') requireCount(values, 0, 1, 'tab quit');
137
+ else requireCount(values, 1, 1, `tab ${leaf}`);
134
138
  if (leaf === 'network' && values[0] !== 'offline' && values[0] !== 'online') {
135
139
  throw invalidInput('tab network requires offline or online.');
136
140
  }
@@ -223,6 +227,7 @@ export function parseCliSyntax(argv: string[]): ParsedArgs {
223
227
  else if (arg === '--settle') { parsed.settle = integer(valueFor(arg, next), '--settle', 0, 2_147_483_647); i++; }
224
228
  else if (arg === '--file') { parsed.file = valueFor(arg, next); i++; }
225
229
  else if (arg === '--new') parsed.new = true;
230
+ else if (arg === '--headed') parsed.headed = true;
226
231
  else if (arg === '--target') { parsed.target = valueFor(arg, next); parsed.targetSource = 'flag'; i++; }
227
232
  else if (arg === '--url') { const value = valueFor(arg, next); parsed.url = value; (parsed.urls ??= []).push(value); i++; }
228
233
  else if (arg === '--into') { parsed.into = valueFor(arg, next); i++; }
@@ -246,6 +251,7 @@ export function parseCliSyntax(argv: string[]): ParsedArgs {
246
251
  else if (arg === '--freeze-animations') parsed.freezeAnimations = true;
247
252
  else if (arg === '--settle-timeout') { parsed.settleTimeout = integer(valueFor(arg, next), '--settle-timeout', 1, 2_147_483_647); i++; }
248
253
  else if (arg === '--capture-unsettled') parsed.captureUnsettled = true;
254
+ else if (arg === '--skip-collector') { (parsed.skipCollector ??= []).push(valueFor(arg, next)); i++; }
249
255
  else if (arg === '--pixels') parsed.pixels = true;
250
256
  else if (arg === '--state') { (parsed.state ??= []).push(valueFor(arg, next)); i++; }
251
257
  else if (arg === '--for') { parsed.for = valueFor(arg, next); i++; }
@@ -294,6 +300,12 @@ function needsEndpointResolution(parsed: ParsedArgs): boolean {
294
300
  && (parsed.positional.length === 0 || (parsed.command === 'measure' && parsed.positional.length === 1 && parsed.positional[0] === 'map'));
295
301
  if (branchOnly) return false;
296
302
  if (parsed.command === 'session' && parsed.positional[0] !== 'start') return false;
303
+ // `tab launch` CREATES an endpoint and `tab quit` addresses capture's own
304
+ // registry, so neither may inherit one: an ambient CDP_PORT (or a session's
305
+ // port) must never silently become the port a new browser is told to bind,
306
+ // nor the browser a quit reaches. Only an explicit --port flag, parsed in
307
+ // `parseCliSyntax`, applies to these two.
308
+ if (parsed.command === 'tab' && (parsed.positional[0] === 'launch' || parsed.positional[0] === 'quit')) return false;
297
309
  return true;
298
310
  }
299
311
 
@@ -0,0 +1,510 @@
1
+ /**
2
+ * The browsers capture itself starts, and the only place capture is allowed to
3
+ * signal one.
4
+ *
5
+ * capture's whole surface attaches to a CDP endpoint it does not own, so before
6
+ * this module the only way to get an endpoint was to hand-roll a detached
7
+ * Chrome (`chrome --remote-debugging-port=9334 --user-data-dir=/tmp/whatever &`)
8
+ * with an invented port and an invented scratch profile. Nothing reaped those:
9
+ * the spawning shell exits, the browser is detached, and it survives — one
10
+ * ~1GB resident orphan per agent turn, forever.
11
+ *
12
+ * `capture tab launch` replaces that with a browser capture owns end to end: a
13
+ * profile under `CAPTURE_ROOT`, a kernel-chosen free port, and a registry
14
+ * record naming the pid it started. Ownership is the whole point of the
15
+ * module, so it is enforced structurally, not by convention:
16
+ *
17
+ * - Capture signals a process ONLY when a registry record names it AND the
18
+ * live PID-birth identity still matches the recorded one (`sameBirth`, the
19
+ * same gate the private-lock owner check uses). A recycled pid can never
20
+ * make capture kill a stranger's process.
21
+ * - An endpoint reached with `--port` — the user's own CDP-enabled browser,
22
+ * or anything a previous agent started by hand — has no record here, so it
23
+ * is never swept, never signalled, and never cleaned up. Attaching to a
24
+ * browser capture did not start behaves exactly as it always has.
25
+ *
26
+ * Reaping has no daemon behind it. Three paths cover the lifecycle:
27
+ * 1. `tab quit` — explicit, immediate.
28
+ * 2. a launch that never reports readiness reaps the child it just spawned
29
+ * before it throws (the discipline `bridge/spawn.ts` already models).
30
+ * 3. `sweepOwnedBrowsers()`, run at the top of `tab launch`, `tab list`, and
31
+ * `tab quit` — drops records whose process is gone, and kills owned
32
+ * browsers that are unusable (endpoint unreachable) or idle past
33
+ * `IDLE_REAP_MS`. `tab list` is the probe agents already run constantly,
34
+ * so an abandoned browser is reaped by the next agent through the door
35
+ * rather than living until reboot.
36
+ */
37
+
38
+ import { spawn } from 'node:child_process';
39
+ import * as crypto from 'node:crypto';
40
+ import * as fs from 'node:fs';
41
+ import * as os from 'node:os';
42
+ import * as path from 'node:path';
43
+ import { captureError } from '../errors.js';
44
+ import {
45
+ CAPTURE_ROOT,
46
+ assertUnderCaptureRoot,
47
+ ensurePrivateDir,
48
+ exactKeys,
49
+ parseBirth,
50
+ processPidBirthProvider,
51
+ readPrivateFile,
52
+ sameBirth,
53
+ unlinkPrivateFile,
54
+ writeJsonPrivate,
55
+ type PidBirth,
56
+ } from '../session/artifacts.js';
57
+
58
+ /** How long an owned browser may sit unused before a sweep reaps it. Documented
59
+ * in `capture tab launch -h`; deliberately a constant, not a flag — a knob here
60
+ * would just be one more thing for a caller to get wrong, and an explicit
61
+ * `capture tab quit` is always available for a shorter life. */
62
+ export const IDLE_REAP_MS = 30 * 60 * 1000;
63
+ const READY_TIMEOUT_MS = 15_000;
64
+ const READY_POLL_MS = 50;
65
+ const ENDPOINT_PROBE_MS = 2_000;
66
+ const TERM_GRACE_MS = 3_000;
67
+ const KILL_GRACE_MS = 2_000;
68
+ const RECORD_TOKEN = /^[0-9a-f]{16}$/;
69
+
70
+ export type BrowserSource = 'CAPTURE_BROWSER' | 'puppeteer-cache' | 'system';
71
+
72
+ /** One launched browser, exactly as persisted at `CAPTURE_ROOT/browsers/<token>.json`.
73
+ * `port` is null only in the window between spawn and the readiness report — a
74
+ * record in that state is still a full ownership claim, so a capture process
75
+ * killed mid-launch still leaves the browser reapable by the next sweep. */
76
+ export interface OwnedBrowser {
77
+ version: 1;
78
+ token: string;
79
+ port: number | null;
80
+ pid: number;
81
+ birth: PidBirth;
82
+ profileDir: string;
83
+ executablePath: string;
84
+ source: BrowserSource;
85
+ headless: boolean;
86
+ startedAt: string;
87
+ lastUsedAt: string;
88
+ }
89
+
90
+ export interface ReapedBrowser {
91
+ port: number | null;
92
+ pid: number;
93
+ /** `exited` — the process was already gone; `unreachable` — alive but not
94
+ * answering CDP, so useless to capture; `idle` — unused past IDLE_REAP_MS;
95
+ * `requested` — an explicit `tab quit`. */
96
+ reason: 'exited' | 'unreachable' | 'idle' | 'requested';
97
+ }
98
+
99
+ /** The registry's pathname only. Deliberately NOT `ensurePrivateDir` — reading
100
+ * the registry (which every sweep does, on every `tab list`) must not create
101
+ * anything: a capture root that has never launched a browser stays empty. The
102
+ * private artifact transaction behind `writeJsonPrivate` establishes this
103
+ * directory when the first record is actually written. */
104
+ function browsersDir(): string {
105
+ return path.join(CAPTURE_ROOT, 'browsers');
106
+ }
107
+
108
+ function recordPath(token: string): string {
109
+ return path.join(browsersDir(), `${token}.json`);
110
+ }
111
+
112
+ function profilePath(token: string): string {
113
+ return path.join(CAPTURE_ROOT, 'browsers', 'profiles', token);
114
+ }
115
+
116
+ const SOURCES: readonly BrowserSource[] = ['CAPTURE_BROWSER', 'puppeteer-cache', 'system'];
117
+ const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
118
+
119
+ /** Strictly parses one persisted registry record; anything weaker is undefined
120
+ * (and therefore not an ownership claim, so nothing it names can be signalled). */
121
+ export function parseOwnedBrowser(value: unknown): OwnedBrowser | undefined {
122
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
123
+ const record = value as Record<string, unknown>;
124
+ const birth = parseBirth(record.birth);
125
+ const valid = birth !== undefined
126
+ && exactKeys(record, ['version', 'token', 'port', 'pid', 'birth', 'profileDir', 'executablePath', 'source', 'headless', 'startedAt', 'lastUsedAt'])
127
+ && record.version === 1
128
+ && typeof record.token === 'string' && RECORD_TOKEN.test(record.token)
129
+ && (record.port === null || (Number.isSafeInteger(record.port) && (record.port as number) >= 1 && (record.port as number) <= 65535))
130
+ && Number.isSafeInteger(record.pid) && (record.pid as number) > 0
131
+ && typeof record.profileDir === 'string' && record.profileDir === profilePath(record.token as string)
132
+ && typeof record.executablePath === 'string' && path.isAbsolute(record.executablePath)
133
+ && typeof record.source === 'string' && SOURCES.includes(record.source as BrowserSource)
134
+ && typeof record.headless === 'boolean'
135
+ && typeof record.startedAt === 'string' && ISO.test(record.startedAt)
136
+ && typeof record.lastUsedAt === 'string' && ISO.test(record.lastUsedAt);
137
+ // `valid` already implies a parsed birth; the explicit disjunct is what
138
+ // narrows `birth` for the return type — do not "simplify" it away.
139
+ if (!valid || birth === undefined) return undefined;
140
+ return {
141
+ version: 1,
142
+ token: record.token as string,
143
+ port: record.port as number | null,
144
+ pid: record.pid as number,
145
+ birth,
146
+ profileDir: record.profileDir as string,
147
+ executablePath: record.executablePath as string,
148
+ source: record.source as BrowserSource,
149
+ headless: record.headless as boolean,
150
+ startedAt: record.startedAt as string,
151
+ lastUsedAt: record.lastUsedAt as string,
152
+ };
153
+ }
154
+
155
+ /** Every well-formed registry record. A malformed or unreadable file names no
156
+ * process capture may signal, so it is dropped from the registry rather than
157
+ * interpreted. */
158
+ export function readOwnedBrowsers(): OwnedBrowser[] {
159
+ let entries: string[];
160
+ try {
161
+ entries = fs.readdirSync(browsersDir());
162
+ } catch {
163
+ return [];
164
+ }
165
+ const records: OwnedBrowser[] = [];
166
+ for (const entry of entries) {
167
+ if (!entry.endsWith('.json') || !RECORD_TOKEN.test(entry.slice(0, -'.json'.length))) continue;
168
+ const file = path.join(browsersDir(), entry);
169
+ let parsed: OwnedBrowser | undefined;
170
+ try {
171
+ parsed = parseOwnedBrowser(JSON.parse(readPrivateFile(file).toString('utf-8')));
172
+ } catch {
173
+ parsed = undefined;
174
+ }
175
+ if (parsed) records.push(parsed);
176
+ else forgetRecordFile(file);
177
+ }
178
+ return records.sort((a, b) => (a.port ?? 0) - (b.port ?? 0));
179
+ }
180
+
181
+ function forgetRecordFile(file: string): void {
182
+ try {
183
+ unlinkPrivateFile(file);
184
+ } catch {
185
+ // Already gone, or not ours to remove — either way it names nothing we act on.
186
+ }
187
+ }
188
+
189
+ /** True when the recorded process is still the process capture started. The
190
+ * ONLY gate that authorizes a signal; an `unknown` read (a provider that could
191
+ * not answer) is deliberately NOT a kill authorization. */
192
+ function stillOurs(record: OwnedBrowser): boolean {
193
+ const observed = processPidBirthProvider.read(record.pid);
194
+ return observed.status === 'found' && sameBirth(observed.identity, record.birth);
195
+ }
196
+
197
+ /**
198
+ * Removes an owned profile tree. Deliberately `fs.rmSync`, NOT the repo's
199
+ * pinned `removeArtifactTree`: Chrome writes `SingletonLock`/`SingletonSocket`
200
+ * (symlinks and unix sockets) into its user-data-dir, and the pinned remover
201
+ * refuses every non-regular, non-directory entry by design. The path is still
202
+ * proven to be inside `CAPTURE_ROOT` first, so this can only ever delete a
203
+ * profile capture created.
204
+ */
205
+ function removeProfile(profileDir: string): void {
206
+ fs.rmSync(assertUnderCaptureRoot(profileDir), { recursive: true, force: true });
207
+ }
208
+
209
+ function sleep(ms: number): Promise<void> {
210
+ return new Promise((resolve) => setTimeout(resolve, ms));
211
+ }
212
+
213
+ /** SIGTERM, then SIGKILL if it is still alive, then forget it. Every signal is
214
+ * re-gated on `stillOurs` so a pid that dies and is recycled mid-reap cannot
215
+ * receive the escalation. */
216
+ async function reap(record: OwnedBrowser): Promise<void> {
217
+ for (const [signal, graceMs] of [['SIGTERM', TERM_GRACE_MS], ['SIGKILL', KILL_GRACE_MS]] as const) {
218
+ if (!stillOurs(record)) break;
219
+ try {
220
+ process.kill(record.pid, signal);
221
+ } catch {
222
+ break; // Gone between the birth check and the signal.
223
+ }
224
+ const deadline = Date.now() + graceMs;
225
+ while (Date.now() < deadline && stillOurs(record)) await sleep(READY_POLL_MS);
226
+ }
227
+ forgetRecordFile(recordPath(record.token));
228
+ removeProfile(record.profileDir);
229
+ }
230
+
231
+ async function endpointAnswers(port: number): Promise<boolean> {
232
+ try {
233
+ const response = await fetch(`http://127.0.0.1:${port}/json/version`, {
234
+ signal: AbortSignal.timeout(ENDPOINT_PROBE_MS),
235
+ });
236
+ return response.ok;
237
+ } catch {
238
+ return false;
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Reconciles the registry with reality and reaps what no longer earns its
244
+ * memory. Run at the top of every `tab` leaf that touches owned browsers — this
245
+ * is what makes an abandoned browser die on its own instead of outliving the
246
+ * agent that started it.
247
+ */
248
+ export async function sweepOwnedBrowsers(): Promise<ReapedBrowser[]> {
249
+ const reaped: ReapedBrowser[] = [];
250
+ for (const record of readOwnedBrowsers()) {
251
+ if (!stillOurs(record)) {
252
+ // Crashed, or SIGKILLed by someone else. Nothing to signal — just drop
253
+ // the claim and the profile it left behind.
254
+ forgetRecordFile(recordPath(record.token));
255
+ removeProfile(record.profileDir);
256
+ reaped.push({ port: record.port, pid: record.pid, reason: 'exited' });
257
+ continue;
258
+ }
259
+ if (record.port === null || !(await endpointAnswers(record.port))) {
260
+ await reap(record);
261
+ reaped.push({ port: record.port, pid: record.pid, reason: 'unreachable' });
262
+ continue;
263
+ }
264
+ if (Date.now() - Date.parse(record.lastUsedAt) > IDLE_REAP_MS) {
265
+ await reap(record);
266
+ reaped.push({ port: record.port, pid: record.pid, reason: 'idle' });
267
+ }
268
+ }
269
+ return reaped;
270
+ }
271
+
272
+ /**
273
+ * Refreshes an owned browser's idle clock. Called from the one browser-level
274
+ * CDP connect (`getBrowserClient`), so any capture command that actually uses a
275
+ * browser keeps it alive; a port with no record — the user's own browser — is a
276
+ * cheap no-op with no writes at all.
277
+ */
278
+ export function touchOwnedBrowser(port: number): void {
279
+ const record = readOwnedBrowsers().find((candidate) => candidate.port === port);
280
+ if (!record) return;
281
+ writeJsonPrivate(recordPath(record.token), { ...record, lastUsedAt: new Date().toISOString() });
282
+ }
283
+
284
+ /** Owned browsers by port, for `tab list`'s capture-owned marker. */
285
+ export function ownedBrowsersByPort(): Map<number, OwnedBrowser> {
286
+ const byPort = new Map<number, OwnedBrowser>();
287
+ for (const record of readOwnedBrowsers()) if (record.port !== null) byPort.set(record.port, record);
288
+ return byPort;
289
+ }
290
+
291
+ /** Stops owned browsers: one port, or every one capture started. Only records
292
+ * are consulted, so a port capture never launched matches nothing and no signal
293
+ * is sent — including when that port is a live browser of the user's. */
294
+ export async function quitOwnedBrowsers(selector: { port?: number; all?: boolean }): Promise<ReapedBrowser[]> {
295
+ const records = readOwnedBrowsers().filter((record) => selector.all || record.port === selector.port);
296
+ const stopped: ReapedBrowser[] = [];
297
+ for (const record of records) {
298
+ await reap(record);
299
+ stopped.push({ port: record.port, pid: record.pid, reason: 'requested' });
300
+ }
301
+ return stopped;
302
+ }
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // Executable resolution
306
+ // ---------------------------------------------------------------------------
307
+
308
+ interface Candidate {
309
+ path: string;
310
+ source: BrowserSource;
311
+ /** A headless-shell build has no window and cannot serve `--headed`. */
312
+ headlessOnly: boolean;
313
+ }
314
+
315
+ const PUPPETEER_RELATIVE: Record<string, { chrome: string; shell: string } | undefined> = {
316
+ 'darwin-arm64': {
317
+ chrome: 'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
318
+ shell: 'chrome-headless-shell-mac-arm64/chrome-headless-shell',
319
+ },
320
+ 'darwin-x64': {
321
+ chrome: 'chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
322
+ shell: 'chrome-headless-shell-mac-x64/chrome-headless-shell',
323
+ },
324
+ 'linux-x64': { chrome: 'chrome-linux64/chrome', shell: 'chrome-headless-shell-linux64/chrome-headless-shell' },
325
+ 'linux-arm64': { chrome: 'chrome-linux64/chrome', shell: 'chrome-headless-shell-linux64/chrome-headless-shell' },
326
+ };
327
+
328
+ const SYSTEM_PATHS: Record<string, readonly string[]> = {
329
+ darwin: [
330
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
331
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
332
+ '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
333
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
334
+ ],
335
+ linux: [
336
+ '/usr/bin/google-chrome',
337
+ '/usr/bin/google-chrome-stable',
338
+ '/usr/bin/chromium',
339
+ '/usr/bin/chromium-browser',
340
+ '/usr/bin/microsoft-edge',
341
+ ],
342
+ };
343
+
344
+ function executable(file: string): boolean {
345
+ try {
346
+ fs.accessSync(file, fs.constants.X_OK);
347
+ return fs.statSync(file).isFile();
348
+ } catch {
349
+ return false;
350
+ }
351
+ }
352
+
353
+ /** Newest-first version directories under one `~/.cache/puppeteer/<product>` tree. */
354
+ function puppeteerVersionDirs(product: string): string[] {
355
+ const root = path.join(os.homedir(), '.cache', 'puppeteer', product);
356
+ try {
357
+ return fs.readdirSync(root).sort().reverse().map((entry) => path.join(root, entry));
358
+ } catch {
359
+ return [];
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Every browser capture would launch, best first. The puppeteer cache comes
365
+ * before the user's installed Chrome deliberately: Chrome for Testing is a
366
+ * pinned, updateless build meant to be driven, and it is exactly what the
367
+ * hand-rolled launches this command replaces were already reaching for.
368
+ */
369
+ function browserCandidates(): Candidate[] {
370
+ const configured = process.env.CAPTURE_BROWSER;
371
+ const candidates: Candidate[] = configured
372
+ ? [{ path: path.resolve(configured), source: 'CAPTURE_BROWSER', headlessOnly: false }]
373
+ : [];
374
+ const relative = PUPPETEER_RELATIVE[`${process.platform}-${process.arch}`];
375
+ if (relative) {
376
+ for (const dir of puppeteerVersionDirs('chrome-headless-shell')) {
377
+ candidates.push({ path: path.join(dir, relative.shell), source: 'puppeteer-cache', headlessOnly: true });
378
+ }
379
+ for (const dir of puppeteerVersionDirs('chrome')) {
380
+ candidates.push({ path: path.join(dir, relative.chrome), source: 'puppeteer-cache', headlessOnly: false });
381
+ }
382
+ }
383
+ for (const file of SYSTEM_PATHS[process.platform] ?? []) {
384
+ candidates.push({ path: file, source: 'system', headlessOnly: false });
385
+ }
386
+ return candidates;
387
+ }
388
+
389
+ /** The browser `tab launch` would start, with the provenance of that choice.
390
+ * Reported on every launch so the caller can see WHICH browser answered — a
391
+ * measurement, not a recommendation. */
392
+ export function resolveBrowserExecutable(headless: boolean): { path: string; source: BrowserSource } {
393
+ const candidates = browserCandidates().filter((candidate) => headless || !candidate.headlessOnly);
394
+ const found = candidates.find((candidate) => executable(candidate.path));
395
+ if (found) return { path: found.path, source: found.source };
396
+ throw captureError(
397
+ 'world',
398
+ 'no_browser_executable',
399
+ `received: no usable ${headless ? 'browser' : 'headed browser'} executable; expected: Chrome/Chromium at one of ` +
400
+ `${candidates.map((candidate) => candidate.path).join(', ') || '(no known location for this platform)'}. ` +
401
+ `Set CAPTURE_BROWSER=<absolute path> to name one explicitly, or install Chrome for Testing ` +
402
+ `(npx @puppeteer/browsers install chrome-headless-shell@stable).`,
403
+ );
404
+ }
405
+
406
+ // ---------------------------------------------------------------------------
407
+ // Launch
408
+ // ---------------------------------------------------------------------------
409
+
410
+ /** Chrome writes its chosen debugging port here once it is listening — the only
411
+ * readiness signal available when `--remote-debugging-port=0` picks a free port
412
+ * for us (stdio is `ignore`, so its stderr banner is not). */
413
+ function devToolsPort(profileDir: string): number | null {
414
+ try {
415
+ const first = fs.readFileSync(path.join(profileDir, 'DevToolsActivePort'), 'utf-8').split('\n')[0]?.trim();
416
+ if (!first || !/^[1-9]\d*$/.test(first)) return null;
417
+ const port = Number(first);
418
+ return port >= 1 && port <= 65535 ? port : null;
419
+ } catch {
420
+ return null;
421
+ }
422
+ }
423
+
424
+ export interface LaunchOptions {
425
+ /** First page to open; `about:blank` when unset. */
426
+ url?: string;
427
+ /** Pin the debugging port instead of letting the kernel choose a free one. */
428
+ port?: number;
429
+ headless: boolean;
430
+ }
431
+
432
+ /**
433
+ * Starts a browser capture owns and registers it before it is even ready, so no
434
+ * window exists in which a live browser has no ownership record. A launch that
435
+ * never reports readiness reaps its own child — the same discipline
436
+ * `startBridge()` applies to the bridge process it spawns.
437
+ */
438
+ export async function launchOwnedBrowser(options: LaunchOptions): Promise<OwnedBrowser> {
439
+ const executablePath = resolveBrowserExecutable(options.headless);
440
+ const token = crypto.randomBytes(8).toString('hex');
441
+ const profileDir = ensurePrivateDir(profilePath(token));
442
+ const args = [
443
+ `--remote-debugging-port=${options.port ?? 0}`,
444
+ `--user-data-dir=${profileDir}`,
445
+ '--no-first-run',
446
+ '--no-default-browser-check',
447
+ '--disable-gpu',
448
+ ...(options.headless ? ['--headless=new'] : []),
449
+ options.url ?? 'about:blank',
450
+ ];
451
+ const child = spawn(executablePath.path, args, { detached: true, stdio: 'ignore' });
452
+ child.unref();
453
+ const pid = child.pid;
454
+ if (pid === undefined) {
455
+ removeProfile(profileDir);
456
+ throw captureError('world', 'browser_spawn_failed', `received: ${executablePath.path}; spawning it produced no pid.`);
457
+ }
458
+
459
+ const observed = processPidBirthProvider.read(pid);
460
+ if (observed.status !== 'found') {
461
+ // Without a birth identity there is no gate that could ever authorize a
462
+ // signal to this pid, so it must not become a registry record. Reap it now,
463
+ // while the child handle still proves which process it is.
464
+ try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ }
465
+ removeProfile(profileDir);
466
+ throw captureError(
467
+ 'world',
468
+ 'browser_identity_unavailable',
469
+ `received: browser pid ${pid}; expected: a readable process-birth identity to own it by ` +
470
+ `(${observed.status === 'unknown' ? observed.reason : 'the process was already gone'}).`,
471
+ );
472
+ }
473
+
474
+ const now = new Date().toISOString();
475
+ const record: OwnedBrowser = {
476
+ version: 1,
477
+ token,
478
+ port: null,
479
+ pid,
480
+ birth: observed.identity,
481
+ profileDir,
482
+ executablePath: executablePath.path,
483
+ source: executablePath.source,
484
+ headless: options.headless,
485
+ startedAt: now,
486
+ lastUsedAt: now,
487
+ };
488
+ writeJsonPrivate(recordPath(token), record);
489
+
490
+ const deadline = Date.now() + READY_TIMEOUT_MS;
491
+ for (;;) {
492
+ const port = options.port ?? devToolsPort(profileDir);
493
+ if (port !== null && (await endpointAnswers(port))) {
494
+ const ready: OwnedBrowser = { ...record, port, lastUsedAt: new Date().toISOString() };
495
+ writeJsonPrivate(recordPath(token), ready);
496
+ return ready;
497
+ }
498
+ if (Date.now() > deadline) {
499
+ await reap(record);
500
+ throw captureError(
501
+ 'world',
502
+ 'browser_launch_timeout',
503
+ `received: ${executablePath.path} (pid ${pid}); it did not answer CDP on ` +
504
+ `${options.port === undefined ? 'a kernel-chosen port' : `port ${options.port}`} within ${READY_TIMEOUT_MS}ms. ` +
505
+ `The browser was stopped and its profile removed.`,
506
+ );
507
+ }
508
+ await sleep(READY_POLL_MS);
509
+ }
510
+ }
package/src/cdp/client.ts CHANGED
@@ -63,6 +63,7 @@ export class CDPClient {
63
63
  sessionId?: string,
64
64
  ): Promise<unknown> {
65
65
  await this.ready;
66
+ if (this.ws.readyState !== WebSocket.OPEN) throw new Error('CDP client is closed');
66
67
  const id = ++this.messageId;
67
68
 
68
69
  return new Promise((resolve, reject) => {
@@ -103,7 +104,15 @@ export class CDPClient {
103
104
  }
104
105
 
105
106
  close(): void {
106
- // Clear all pending timeouts to allow process to exit
107
+ // Reject pending requests before clearing their timers. Leaving their
108
+ // promises unresolved strands collectors that were interrupted by a
109
+ // bounded command timeout, so the CLI never reaches its structured
110
+ // partial-artifact result or exits after the socket closes.
111
+ const error = new Error('CDP client closed');
112
+ for (const [id, pending] of this.pendingRequests) {
113
+ pending.reject(error);
114
+ this.pendingRequests.delete(id);
115
+ }
107
116
  for (const timer of this.pendingTimeouts.values()) {
108
117
  clearTimeout(timer);
109
118
  }