@reclaimprotocol/agent 0.1.24 → 0.1.25

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.
Files changed (46) hide show
  1. package/README.md +3 -2
  2. package/lib/cdp/claims.d.ts +21 -0
  3. package/lib/cdp/claims.js +129 -0
  4. package/lib/cdp/client.d.ts +0 -10
  5. package/lib/cdp/container.d.ts +85 -0
  6. package/lib/cdp/container.js +481 -0
  7. package/lib/live/tunnel.d.ts +6 -3
  8. package/lib/live/tunnel.js +57 -17
  9. package/lib/mcp/instructions.js +2 -1
  10. package/lib/mcp/start.js +58 -3
  11. package/lib/mcp/tools/agent/attach.d.ts +22 -2
  12. package/lib/mcp/tools/agent/attach.js +197 -55
  13. package/lib/mcp/tools/agent/build.d.ts +5 -0
  14. package/lib/mcp/tools/agent/build.js +9 -2
  15. package/lib/mcp/tools/agent/index.d.ts +1 -2
  16. package/lib/mcp/tools/agent/index.js +1 -1
  17. package/lib/mcp/tools/agent/live-view.d.ts +3 -7
  18. package/lib/mcp/tools/agent/live-view.js +74 -81
  19. package/lib/mcp/tools/agent/session.d.ts +14 -0
  20. package/lib/mcp/tools/agent/session.js +14 -8
  21. package/package.json +2 -2
  22. package/skill/SKILL.common.md +115 -38
  23. package/skill/builder-mode.md +9 -7
  24. package/skill/interception.md +3 -2
  25. package/skill/old-mode.md +10 -8
  26. package/src/cdp/claims.ts +153 -0
  27. package/src/cdp/client.ts +0 -8
  28. package/src/cdp/container.ts +593 -0
  29. package/src/live/tunnel.ts +71 -20
  30. package/src/mcp/instructions.ts +2 -1
  31. package/src/mcp/start.ts +65 -2
  32. package/src/mcp/tools/agent/attach.ts +229 -60
  33. package/src/mcp/tools/agent/build.ts +10 -2
  34. package/src/mcp/tools/agent/index.ts +6 -2
  35. package/src/mcp/tools/agent/live-view.ts +77 -93
  36. package/src/mcp/tools/agent/session.ts +14 -8
  37. package/lib/cdp/browser-proxy.d.ts +0 -78
  38. package/lib/cdp/browser-proxy.js +0 -186
  39. package/lib/live/page.d.ts +0 -20
  40. package/lib/live/page.js +0 -134
  41. package/lib/live/viewer.d.ts +0 -28
  42. package/lib/live/viewer.js +0 -232
  43. package/lib/mcp/tools/agent/proxy.d.ts +0 -3
  44. package/lib/mcp/tools/agent/proxy.js +0 -199
  45. package/src/live/page.ts +0 -145
  46. package/src/live/viewer.ts +0 -293
@@ -0,0 +1,593 @@
1
+ /**
2
+ * Run the Reclaim browser runtime in a container.
3
+ *
4
+ * This is the same image Popcorn serves for `builder` mode, run on the
5
+ * developer's own Docker instead of on quota. It brings its own Chromium and
6
+ * its own live view — a real VNC viewer with an IME, gesture handling and an
7
+ * OS-level pointer path — so nothing here renders anything. It starts the
8
+ * container, waits for it to mean it, and hands back the two URLs.
9
+ *
10
+ * Three ports exist inside; we publish two, and the distinction matters:
11
+ *
12
+ * - **6080, the viewer.** Safe to put behind a tunnel, which is what
13
+ * `share_browser_view` does.
14
+ * - **9226, full CDP.** Unfiltered, unauthenticated browser control. Bound to
15
+ * loopback and NEVER tunnelled: upstream's own warning is that exposing it
16
+ * "would hand anyone control of the browser".
17
+ * - **9222, restricted CDP.** Not published at all. Its allowlist is 19
18
+ * methods with no `Runtime.*` and no `Network.*`, so it cannot drive the
19
+ * authoring loop — verified against a running container.
20
+ */
21
+ import { execFile } from 'node:child_process'
22
+ import { mkdirSync } from 'node:fs'
23
+ import { createServer } from 'node:http'
24
+ import type { AddressInfo } from 'node:net'
25
+ import { join } from 'node:path'
26
+ import { promisify } from 'node:util'
27
+ import { LOGGER } from '../logger.ts'
28
+ import { reclaimHomeDirPath } from '../paths.ts'
29
+ import {
30
+ busySlots,
31
+ claim,
32
+ ownSlots,
33
+ processAlive,
34
+ release,
35
+ } from './claims.ts'
36
+
37
+ const execFileAsync = promisify(execFile)
38
+
39
+ /**
40
+ * The pinned runtime image.
41
+ *
42
+ * A digest, not a tag. Upstream publishes 40-char git SHAs plus moving
43
+ * `main`/`latest` aliases and no semver at all, and its own docs say to
44
+ * "prefer immutable digests". Pinning one means an agent release cannot have
45
+ * the browser changed underneath it.
46
+ *
47
+ * Digest of `latest` as of 2026-08-22, built from popcorn-oss
48
+ * `4df751c25c7b329699033957b93f6486837e4acb`.
49
+ */
50
+ export const BROWSER_RUNTIME_IMAGE =
51
+ 'ghcr.io/reclaimprotocol/popcorn-oss/browser-runtime'
52
+ + '@sha256:6858c30a37886bc30d8f658e31aeb0bb253ad22eece16e4e41c8883345b399e1'
53
+
54
+ /**
55
+ * The image is amd64-only and cannot be otherwise: the browser is Tilion
56
+ * Fortress, pinned by digest upstream, and Fortress publishes no arm64
57
+ * manifest. On Apple Silicon it therefore runs translated — which works under
58
+ * Docker Desktop's Rosetta and OrbStack, and crashes under QEMU. Pinning the
59
+ * platform is what stops Docker quietly trying to find an arm64 variant that
60
+ * does not exist.
61
+ */
62
+ const PLATFORM = 'linux/amd64'
63
+
64
+ /** Ports inside the container. */
65
+ const VIEWER_PORT = 6080
66
+ const CDP_PORT = 9226
67
+
68
+ /**
69
+ * How many browsers may run at once.
70
+ *
71
+ * Each is about 2 GB of RAM, so this is a guard against a runaway rather than a
72
+ * capability. Sessions normally SHARE one: a slot is only added when another
73
+ * session is actively holding the previous one.
74
+ */
75
+ const MAX_SLOTS = 4
76
+
77
+ /**
78
+ * Marks a container as ours and records which process owns it.
79
+ *
80
+ * The PID makes orphan cleanup safe. A container outlives its owner whenever a
81
+ * session is killed, so something has to remove it — but never one another
82
+ * session is still using. Whether the owning PID is alive answers that, and
83
+ * errs harmlessly: a recycled PID leaves a container behind rather than killing
84
+ * a live one.
85
+ */
86
+ const OWNER_LABEL = 'org.reclaim.agent.owner-pid'
87
+
88
+ /**
89
+ * Two ports nobody is using, guaranteed different from each other.
90
+ *
91
+ * A fixed port would give concurrent sessions the same container — the name
92
+ * derives from the viewer port — so session two would silently drive session
93
+ * one's browser.
94
+ *
95
+ * Both listeners are held open together and released together. Asking twice in
96
+ * sequence can return the same port, since the first is already closed by the
97
+ * time the second asks, and one port cannot carry both the viewer and CDP.
98
+ *
99
+ * Between releasing a port and Docker publishing it, another process can take
100
+ * it. That race is unavoidable, and losing it fails the `docker run` loudly
101
+ * rather than doing anything silent.
102
+ */
103
+ async function freePortPair(): Promise<{ viewer: number, cdp: number }> {
104
+ const servers = [createServer(), createServer()]
105
+ try {
106
+ const ports = await Promise.all(servers.map(async(server) => {
107
+ await new Promise<void>((resolve) => {
108
+ server.listen(0, '127.0.0.1', resolve)
109
+ })
110
+ return (server.address() as AddressInfo).port
111
+ }))
112
+ return { viewer: ports[0], cdp: ports[1] }
113
+ } finally {
114
+ await Promise.all(servers.map((server) => {
115
+ return new Promise<void>((resolve) => server.close(() => resolve()))
116
+ }))
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Readiness deadline. The entrypoint gives Chromium 30s to put a window on the
122
+ * X display before failing, and a cold start measured 6.6s here with the geoip
123
+ * lookup disabled. 90s leaves room for a slow machine without hanging forever.
124
+ */
125
+ const READY_TIMEOUT_MS = 90_000
126
+ const READY_POLL_MS = 250
127
+
128
+ export interface ContainerBrowser {
129
+ /** Local viewer origin — what `share_browser_view` tunnels. */
130
+ viewerUrl: string
131
+ /** Browser-level CDP websocket, for `connectCdpUrl`. */
132
+ cdpUrl: string
133
+ /** Loopback port the viewer is published on. */
134
+ viewerPort: number
135
+ /** Docker's name for this container. */
136
+ name: string
137
+ /** Whether the image had to be pulled first — worth telling the developer. */
138
+ pulled: boolean
139
+ /** Which slot this browser occupies, for diagnostics. */
140
+ slot: number
141
+ stop: () => Promise<void>
142
+ }
143
+
144
+ export interface ContainerOptions {
145
+ /** Loopback port for the viewer. Allocated when omitted. */
146
+ viewerPort?: number
147
+ /** Loopback port for full CDP. Allocated when omitted. */
148
+ cdpPort?: number
149
+ /** Override the pinned image, e.g. a local `build.sh` output. */
150
+ image?: string
151
+ /** Framebuffer size, which is also the browser window's size. */
152
+ width?: number
153
+ height?: number
154
+ /** Page to open on start. */
155
+ url?: string
156
+ }
157
+
158
+ /**
159
+ * Where the container's Chromium profile lives on the host.
160
+ *
161
+ * Mounted, unlike the reference dev script, for two reasons. Provider authoring
162
+ * means signing in once and capturing an authenticated request, which a profile
163
+ * that dies with the container makes impossible. And the profile holds the
164
+ * persisted fingerprint identity — canvas/audio seed, timezone, locale — which
165
+ * upstream warns must stay stable, since "flipping any of them against the same
166
+ * cookie jar is itself a bot signal".
167
+ */
168
+ export function containerProfileDir(slot = 0): string {
169
+ // Slot 0 keeps the original path, so an existing profile — and the login in
170
+ // it — survives this change. Later slots get their own, because two
171
+ // Chromiums on one `user-data-dir` corrupt it silently.
172
+ const suffix = slot === 0 ? '' : `-${slot}`
173
+ return join(reclaimHomeDirPath(), `browser-runtime-profile${suffix}`)
174
+ }
175
+
176
+ /** Docker CLI present AND its daemon reachable. */
177
+ export async function dockerAvailable(): Promise<boolean> {
178
+ try {
179
+ // `--format {{.Server.Version}}` talks to the daemon, so unlike
180
+ // `docker --version` it fails when Docker is installed but not running.
181
+ const { stdout } = await execFileAsync(
182
+ 'docker', ['version', '--format', '{{.Server.Version}}'],
183
+ { timeout: 15_000 },
184
+ )
185
+ return stdout.trim().length > 0
186
+ } catch(err) {
187
+ LOGGER.debug({ err }, 'docker is unavailable')
188
+ return false
189
+ }
190
+ }
191
+
192
+ /** Message for the case we cannot fix for them. */
193
+ export function dockerMissingMessage(): string {
194
+ return 'Container mode needs Docker, and its daemon is not reachable. '
195
+ + 'Install Docker Desktop (https://docs.docker.com/get-started/get-docker/)'
196
+ + ' or OrbStack and start it, then retry. On Apple Silicon, enable Rosetta '
197
+ + 'for x86/amd64 emulation — the runtime image is amd64-only. '
198
+ + 'Or pick another browser: `dedicated` uses local Chrome, and `builder` '
199
+ + 'is a remote browser that comes with its own hosted live view.'
200
+ }
201
+
202
+ async function imagePresent(image: string): Promise<boolean> {
203
+ try {
204
+ await execFileAsync('docker', ['image', 'inspect', image],
205
+ { timeout: 20_000 })
206
+ return true
207
+ } catch{
208
+ return false
209
+ }
210
+ }
211
+
212
+ /** Fetch the image. About 432 MB, so `startContainer` reports it happened. */
213
+ async function pullImage(image: string): Promise<void> {
214
+ await execFileAsync(
215
+ 'docker', ['pull', '--platform', PLATFORM, image],
216
+ { timeout: 900_000, maxBuffer: 1024 * 1024 * 8 },
217
+ )
218
+ }
219
+
220
+ /**
221
+ * The `docker run` argv.
222
+ *
223
+ * A pure function because every flag here is load bearing and none of it is
224
+ * obvious from the outside: which ports are loopback-only, that the image is
225
+ * digest-pinned, that the profile is mounted. Tests assert on this directly
226
+ * rather than trying to observe a running container.
227
+ */
228
+ export function buildRunArgs(
229
+ opts: ContainerOptions & { viewerPort: number, cdpPort: number },
230
+ image: string,
231
+ name: string,
232
+ profileDir: string,
233
+ ): string[] {
234
+ const args = [
235
+ 'run', '--rm', '-d',
236
+ '--name', name,
237
+ '--platform', PLATFORM,
238
+ // Whose container this is, so an orphan can be told from a container
239
+ // another session is still driving.
240
+ '--label', `${OWNER_LABEL}=${process.pid}`,
241
+ // Loopback only. Both CDP proxies listen on 0.0.0.0 INSIDE the container,
242
+ // so what is reachable is entirely down to this mapping.
243
+ '-p', `127.0.0.1:${opts.viewerPort}:${VIEWER_PORT}`,
244
+ '-p', `127.0.0.1:${opts.cdpPort}:${CDP_PORT}`,
245
+ // Chromium on Docker's default 64 MB /dev/shm is a crash waiting for a
246
+ // heavy page. The image README and the upstream Helm chart both give it
247
+ // 1-2 GiB; only the dev script leaves the default.
248
+ '--shm-size=1g',
249
+ // The runtime looks up its own geolocation at boot, twice, with an 8s
250
+ // timeout each — up to 16s added to every cold start. We are not
251
+ // impersonating a locale (that is what `builder` mode's country routing is
252
+ // for), so skip it. Measured: 6.6s to ready with this off.
253
+ '-e', 'CLOAK_GEOIP=false',
254
+ // Cookies, localStorage and the persisted fingerprint identity. Without
255
+ // it, signing in once — the entire point of authoring — cannot survive a
256
+ // restart.
257
+ '-v', `${profileDir}:/home/kernel/user-data`,
258
+ ]
259
+ if(opts.width) {
260
+ args.push('-e', `WIDTH=${opts.width}`)
261
+ }
262
+
263
+ if(opts.height) {
264
+ args.push('-e', `HEIGHT=${opts.height}`)
265
+ }
266
+
267
+ if(opts.url) {
268
+ args.push('-e', `APP_URL=${opts.url}`)
269
+ }
270
+
271
+ args.push(image)
272
+ return args
273
+ }
274
+
275
+ /**
276
+ * Start a browser container and wait until it is actually serving.
277
+ *
278
+ * Ports are allocated per call, so concurrent sessions never land on the same
279
+ * container. Pass `viewerPort` to pin one — re-attaching with the same port
280
+ * reuses the container that is already there.
281
+ */
282
+ export async function startContainer(
283
+ options: ContainerOptions = {},
284
+ ): Promise<ContainerBrowser> {
285
+ // Containers whose session died without cleaning up, cleared before we look
286
+ // for one to adopt. Deliberately here rather than at server startup: a
287
+ // short-lived server exits before a fire-and-forget reap finishes, and nobody
288
+ // who never uses container mode should pay for a `docker ps` at launch.
289
+ await reapOrphanedContainers()
290
+
291
+ const image = options.image ?? BROWSER_RUNTIME_IMAGE
292
+ const slot = await pickSlot()
293
+ const name = containerName(slot)
294
+ const token = claim(name)
295
+
296
+ // Adopt a container that is already serving on this slot. This is the common
297
+ // case for one developer working across sessions: the browser, and whatever
298
+ // they signed into, is still there.
299
+ const adopted = await adopt(name)
300
+ if(adopted) {
301
+ LOGGER.info({ name }, 'adopted the browser container already running')
302
+ return describe(adopted, name, false, slot, token)
303
+ }
304
+
305
+ // Anything left under this name is wreckage — the slot has no live claimant.
306
+ if(await inspectContainer(name)) {
307
+ LOGGER.info({ name }, 'removing a container that is no longer serving')
308
+ await execFileAsync('docker', ['rm', '-f', name], { timeout: 60_000 })
309
+ .catch(() => {})
310
+ }
311
+
312
+ const ports = await freePortPair()
313
+ const opts = {
314
+ ...options,
315
+ viewerPort: options.viewerPort ?? ports.viewer,
316
+ cdpPort: options.cdpPort ?? ports.cdp,
317
+ }
318
+ const profileDir = containerProfileDir(slot)
319
+ mkdirSync(profileDir, { recursive: true })
320
+
321
+ const pulled = !await imagePresent(image)
322
+ if(pulled) {
323
+ LOGGER.info({ image }, 'pulling the browser runtime image')
324
+ await pullImage(image)
325
+ }
326
+
327
+ await execFileAsync('docker', buildRunArgs(opts, image, name, profileDir),
328
+ { timeout: 120_000 })
329
+ try {
330
+ await waitUntilServing(opts.cdpPort)
331
+ } catch(err) {
332
+ await captureLogs(name)
333
+ release(name, token)
334
+ await stopContainer(name)
335
+ throw err
336
+ }
337
+
338
+ return describe(opts, name, pulled, slot, token)
339
+ }
340
+
341
+ /** `reclaim-browser-0`, `-1`, … — one name per slot, so a slot is findable. */
342
+ function containerName(slot: number): string {
343
+ return `reclaim-browser-${slot}`
344
+ }
345
+
346
+ /**
347
+ * A slot this session may use.
348
+ *
349
+ * Prefers one we ALREADY hold, so re-attaching within a session adopts the
350
+ * browser it is already running instead of paying 2 GB and twenty seconds for a
351
+ * second one. Then the lowest slot nobody holds. A new slot only appears when
352
+ * ANOTHER session is actively using the earlier ones, because two containers on
353
+ * one profile would corrupt it.
354
+ */
355
+ async function pickSlot(): Promise<number> {
356
+ const held = ownSlots()
357
+ for(let slot = 0; slot < MAX_SLOTS; slot++) {
358
+ if(held.has(containerName(slot))) {
359
+ return slot
360
+ }
361
+ }
362
+
363
+ const busy = busySlots()
364
+ for(let slot = 0; slot < MAX_SLOTS; slot++) {
365
+ if(!busy.has(containerName(slot))) {
366
+ return slot
367
+ }
368
+ }
369
+
370
+ throw new Error(
371
+ `All ${MAX_SLOTS} browser containers are in use by other sessions, which `
372
+ + 'is as many as this runs at once — each costs about 2 GB of RAM. Close '
373
+ + 'one of those sessions, or attach with mode "dedicated" to use local '
374
+ + 'Chrome instead.',
375
+ )
376
+ }
377
+
378
+ /**
379
+ * The ports a running container publishes, or undefined when there is nothing
380
+ * healthy to adopt.
381
+ *
382
+ * Adoption has to DISCOVER the ports rather than allocate them: the container
383
+ * was started by a previous session that chose its own.
384
+ */
385
+ async function adopt(
386
+ name: string,
387
+ ): Promise<{ viewerPort: number, cdpPort: number } | undefined> {
388
+ if(await inspectContainer(name) !== 'running') {
389
+ return undefined
390
+ }
391
+
392
+ let mapped = ''
393
+ try {
394
+ const { stdout } = await execFileAsync('docker', ['port', name],
395
+ { timeout: 20_000 })
396
+ mapped = stdout
397
+ } catch{
398
+ return undefined
399
+ }
400
+
401
+ // Lines look like `6080/tcp -> 127.0.0.1:49987`.
402
+ const portFor = (containerPort: number) => {
403
+ const line = mapped.split('\n')
404
+ .find((l) => l.startsWith(`${containerPort}/tcp`))
405
+ const host = line?.split(':').pop()?.trim()
406
+ return host ? Number(host) : undefined
407
+ }
408
+
409
+ const viewerPort = portFor(VIEWER_PORT)
410
+ const cdpPort = portFor(CDP_PORT)
411
+ if(!viewerPort || !cdpPort || !await isServing(cdpPort)) {
412
+ return undefined
413
+ }
414
+
415
+ return { viewerPort, cdpPort }
416
+ }
417
+
418
+ /** Read the container's CDP websocket and assemble the handle. */
419
+ async function describe(
420
+ opts: { viewerPort: number, cdpPort: number },
421
+ name: string,
422
+ pulled: boolean,
423
+ slot: number,
424
+ token: string,
425
+ ): Promise<ContainerBrowser> {
426
+ const version = await fetch(`http://127.0.0.1:${opts.cdpPort}/json/version`)
427
+ .then((res) => res.json()) as { webSocketDebuggerUrl?: string }
428
+ if(!version.webSocketDebuggerUrl) {
429
+ await stopContainer(name)
430
+ throw new Error(
431
+ 'The browser container is serving but reported no CDP websocket.',
432
+ )
433
+ }
434
+
435
+ return {
436
+ viewerUrl: `http://127.0.0.1:${opts.viewerPort}/liveview.html?magnify=1`,
437
+ cdpUrl: version.webSocketDebuggerUrl,
438
+ viewerPort: opts.viewerPort,
439
+ name,
440
+ pulled,
441
+ slot,
442
+ // Stopping is really "I am done with it". The container only goes when
443
+ // this was the last session holding the slot; otherwise it keeps serving
444
+ // whoever else is using it.
445
+ stop: async() => {
446
+ if(release(name, token)) {
447
+ await stopContainer(name)
448
+ return
449
+ }
450
+
451
+ LOGGER.info({ name }, 'left the container running for another session')
452
+ },
453
+ }
454
+ }
455
+
456
+ /** Stop a container. `--rm` means stopping it also removes it. Best effort:
457
+ * teardown must not throw on the way out. */
458
+ async function stopContainer(name: string): Promise<void> {
459
+ try {
460
+ await execFileAsync('docker', ['stop', '-t', '2', name],
461
+ { timeout: 60_000 })
462
+ } catch(err) {
463
+ // Already gone is the outcome we wanted, not a problem to report.
464
+ const message = err instanceof Error ? err.message : String(err)
465
+ if(/No such container/i.test(message)) {
466
+ LOGGER.debug({ name }, 'container was already stopped')
467
+ return
468
+ }
469
+
470
+ LOGGER.warn({ err, name }, 'could not stop the browser container')
471
+ }
472
+ }
473
+
474
+ /** Docker's state for a container, or undefined when there is no such name. */
475
+ async function inspectContainer(name: string): Promise<string | undefined> {
476
+ try {
477
+ const { stdout } = await execFileAsync('docker',
478
+ ['inspect', '--format', '{{.State.Status}}', name], { timeout: 20_000 })
479
+ return stdout.trim() || undefined
480
+ } catch{
481
+ return undefined
482
+ }
483
+ }
484
+
485
+ /** One shot of the readiness check, for deciding whether to reuse. */
486
+ async function isServing(cdpPort: number): Promise<boolean> {
487
+ try {
488
+ const res = await fetch(`http://127.0.0.1:${cdpPort}/json/version`, {
489
+ signal: AbortSignal.timeout(2_000),
490
+ })
491
+ return res.ok
492
+ } catch{
493
+ return false
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Wait until the runtime is serving, not merely listening.
499
+ *
500
+ * The proxy binds its ports early and answers `503 app is not ready` on EVERY
501
+ * route until the entrypoint has seen a Chromium window and touched
502
+ * `/tmp/minimal-vnc-ready`. So a plain "did the socket accept" check passes
503
+ * while there is no browser — which is exactly the trap the reference script
504
+ * falls into, polling with `curl -sS` and no `-f`. Requiring a 2xx is the whole
505
+ * point.
506
+ */
507
+ async function waitUntilServing(cdpPort: number): Promise<void> {
508
+ const deadline = Date.now() + READY_TIMEOUT_MS
509
+ let lastStatus = 'no response'
510
+ while(Date.now() < deadline) {
511
+ try {
512
+ const res = await fetch(`http://127.0.0.1:${cdpPort}/json/version`, {
513
+ signal: AbortSignal.timeout(2_000),
514
+ })
515
+ if(res.ok) {
516
+ return
517
+ }
518
+
519
+ lastStatus = `HTTP ${res.status}`
520
+ } catch(err) {
521
+ lastStatus = err instanceof Error ? err.message : String(err)
522
+ }
523
+
524
+ await new Promise((resolve) => setTimeout(resolve, READY_POLL_MS))
525
+ }
526
+
527
+ throw new Error(
528
+ 'The browser container never became ready within '
529
+ + `${READY_TIMEOUT_MS / 1000}s (last: ${lastStatus}). On Apple Silicon `
530
+ + 'this usually means x86 emulation is unavailable — enable Rosetta in '
531
+ + 'Docker Desktop, or use OrbStack.',
532
+ )
533
+ }
534
+
535
+ /** Log the container's own account of a failed start, which is where the
536
+ * reason lives (a SIGTRAP under QEMU, say). */
537
+ async function captureLogs(name: string): Promise<void> {
538
+ try {
539
+ const { stdout, stderr } = await execFileAsync(
540
+ 'docker', ['logs', '--tail', '40', name], { timeout: 20_000 },
541
+ )
542
+ LOGGER.warn({ name, logs: `${stdout}\n${stderr}`.trim().slice(-4000) },
543
+ 'browser container failed to start')
544
+ } catch(err) {
545
+ LOGGER.debug({ err }, 'could not read the container logs')
546
+ }
547
+ }
548
+
549
+ /**
550
+ * Remove containers whose owning process is gone.
551
+ *
552
+ * Shutdown cleanup covers an orderly exit, but a SIGKILL or a crash runs
553
+ * nothing — and a 1.3 GB container with a live browser in it should not outlive
554
+ * the session that made it. Reaping at startup catches those, and the owner-PID
555
+ * label keeps it safe: a container whose owner still runs belongs to another
556
+ * session and is left alone.
557
+ */
558
+ export async function reapOrphanedContainers(): Promise<string[]> {
559
+ let listed = ''
560
+ try {
561
+ const { stdout } = await execFileAsync('docker', [
562
+ 'ps', '--filter', `label=${OWNER_LABEL}`,
563
+ '--format', `{{.Names}} {{.Label "${OWNER_LABEL}"}}`,
564
+ ], { timeout: 20_000 })
565
+ listed = stdout
566
+ } catch{
567
+ // No Docker, or no daemon. Nothing to reap and nothing to report.
568
+ return []
569
+ }
570
+
571
+ const reaped: string[] = []
572
+ for(const line of listed.split('\n')) {
573
+ const [name, pid] = line.trim().split(/\s+/)
574
+ if(!name || !pid) {
575
+ continue
576
+ }
577
+
578
+ if(processAlive(Number(pid))) {
579
+ continue
580
+ }
581
+
582
+ try {
583
+ await execFileAsync('docker', ['rm', '-f', name], { timeout: 60_000 })
584
+ reaped.push(name)
585
+ LOGGER.info({ name, pid }, 'removed an orphaned browser container')
586
+ } catch(err) {
587
+ // Someone else may have removed it first. Not worth failing a start.
588
+ LOGGER.debug({ err, name }, 'could not remove an orphan')
589
+ }
590
+ }
591
+
592
+ return reaped
593
+ }