@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,481 @@
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 { join } from 'node:path';
25
+ import { promisify } from 'node:util';
26
+ import { LOGGER } from "../logger.js";
27
+ import { reclaimHomeDirPath } from "../paths.js";
28
+ import { busySlots, claim, ownSlots, processAlive, release, } from "./claims.js";
29
+ const execFileAsync = promisify(execFile);
30
+ /**
31
+ * The pinned runtime image.
32
+ *
33
+ * A digest, not a tag. Upstream publishes 40-char git SHAs plus moving
34
+ * `main`/`latest` aliases and no semver at all, and its own docs say to
35
+ * "prefer immutable digests". Pinning one means an agent release cannot have
36
+ * the browser changed underneath it.
37
+ *
38
+ * Digest of `latest` as of 2026-08-22, built from popcorn-oss
39
+ * `4df751c25c7b329699033957b93f6486837e4acb`.
40
+ */
41
+ export const BROWSER_RUNTIME_IMAGE = 'ghcr.io/reclaimprotocol/popcorn-oss/browser-runtime'
42
+ + '@sha256:6858c30a37886bc30d8f658e31aeb0bb253ad22eece16e4e41c8883345b399e1';
43
+ /**
44
+ * The image is amd64-only and cannot be otherwise: the browser is Tilion
45
+ * Fortress, pinned by digest upstream, and Fortress publishes no arm64
46
+ * manifest. On Apple Silicon it therefore runs translated — which works under
47
+ * Docker Desktop's Rosetta and OrbStack, and crashes under QEMU. Pinning the
48
+ * platform is what stops Docker quietly trying to find an arm64 variant that
49
+ * does not exist.
50
+ */
51
+ const PLATFORM = 'linux/amd64';
52
+ /** Ports inside the container. */
53
+ const VIEWER_PORT = 6080;
54
+ const CDP_PORT = 9226;
55
+ /**
56
+ * How many browsers may run at once.
57
+ *
58
+ * Each is about 2 GB of RAM, so this is a guard against a runaway rather than a
59
+ * capability. Sessions normally SHARE one: a slot is only added when another
60
+ * session is actively holding the previous one.
61
+ */
62
+ const MAX_SLOTS = 4;
63
+ /**
64
+ * Marks a container as ours and records which process owns it.
65
+ *
66
+ * The PID makes orphan cleanup safe. A container outlives its owner whenever a
67
+ * session is killed, so something has to remove it — but never one another
68
+ * session is still using. Whether the owning PID is alive answers that, and
69
+ * errs harmlessly: a recycled PID leaves a container behind rather than killing
70
+ * a live one.
71
+ */
72
+ const OWNER_LABEL = 'org.reclaim.agent.owner-pid';
73
+ /**
74
+ * Two ports nobody is using, guaranteed different from each other.
75
+ *
76
+ * A fixed port would give concurrent sessions the same container — the name
77
+ * derives from the viewer port — so session two would silently drive session
78
+ * one's browser.
79
+ *
80
+ * Both listeners are held open together and released together. Asking twice in
81
+ * sequence can return the same port, since the first is already closed by the
82
+ * time the second asks, and one port cannot carry both the viewer and CDP.
83
+ *
84
+ * Between releasing a port and Docker publishing it, another process can take
85
+ * it. That race is unavoidable, and losing it fails the `docker run` loudly
86
+ * rather than doing anything silent.
87
+ */
88
+ async function freePortPair() {
89
+ const servers = [createServer(), createServer()];
90
+ try {
91
+ const ports = await Promise.all(servers.map(async (server) => {
92
+ await new Promise((resolve) => {
93
+ server.listen(0, '127.0.0.1', resolve);
94
+ });
95
+ return server.address().port;
96
+ }));
97
+ return { viewer: ports[0], cdp: ports[1] };
98
+ }
99
+ finally {
100
+ await Promise.all(servers.map((server) => {
101
+ return new Promise((resolve) => server.close(() => resolve()));
102
+ }));
103
+ }
104
+ }
105
+ /**
106
+ * Readiness deadline. The entrypoint gives Chromium 30s to put a window on the
107
+ * X display before failing, and a cold start measured 6.6s here with the geoip
108
+ * lookup disabled. 90s leaves room for a slow machine without hanging forever.
109
+ */
110
+ const READY_TIMEOUT_MS = 90000;
111
+ const READY_POLL_MS = 250;
112
+ /**
113
+ * Where the container's Chromium profile lives on the host.
114
+ *
115
+ * Mounted, unlike the reference dev script, for two reasons. Provider authoring
116
+ * means signing in once and capturing an authenticated request, which a profile
117
+ * that dies with the container makes impossible. And the profile holds the
118
+ * persisted fingerprint identity — canvas/audio seed, timezone, locale — which
119
+ * upstream warns must stay stable, since "flipping any of them against the same
120
+ * cookie jar is itself a bot signal".
121
+ */
122
+ export function containerProfileDir(slot = 0) {
123
+ // Slot 0 keeps the original path, so an existing profile — and the login in
124
+ // it — survives this change. Later slots get their own, because two
125
+ // Chromiums on one `user-data-dir` corrupt it silently.
126
+ const suffix = slot === 0 ? '' : `-${slot}`;
127
+ return join(reclaimHomeDirPath(), `browser-runtime-profile${suffix}`);
128
+ }
129
+ /** Docker CLI present AND its daemon reachable. */
130
+ export async function dockerAvailable() {
131
+ try {
132
+ // `--format {{.Server.Version}}` talks to the daemon, so unlike
133
+ // `docker --version` it fails when Docker is installed but not running.
134
+ const { stdout } = await execFileAsync('docker', ['version', '--format', '{{.Server.Version}}'], { timeout: 15000 });
135
+ return stdout.trim().length > 0;
136
+ }
137
+ catch (err) {
138
+ LOGGER.debug({ err }, 'docker is unavailable');
139
+ return false;
140
+ }
141
+ }
142
+ /** Message for the case we cannot fix for them. */
143
+ export function dockerMissingMessage() {
144
+ return 'Container mode needs Docker, and its daemon is not reachable. '
145
+ + 'Install Docker Desktop (https://docs.docker.com/get-started/get-docker/)'
146
+ + ' or OrbStack and start it, then retry. On Apple Silicon, enable Rosetta '
147
+ + 'for x86/amd64 emulation — the runtime image is amd64-only. '
148
+ + 'Or pick another browser: `dedicated` uses local Chrome, and `builder` '
149
+ + 'is a remote browser that comes with its own hosted live view.';
150
+ }
151
+ async function imagePresent(image) {
152
+ try {
153
+ await execFileAsync('docker', ['image', 'inspect', image], { timeout: 20000 });
154
+ return true;
155
+ }
156
+ catch {
157
+ return false;
158
+ }
159
+ }
160
+ /** Fetch the image. About 432 MB, so `startContainer` reports it happened. */
161
+ async function pullImage(image) {
162
+ await execFileAsync('docker', ['pull', '--platform', PLATFORM, image], { timeout: 900000, maxBuffer: 1024 * 1024 * 8 });
163
+ }
164
+ /**
165
+ * The `docker run` argv.
166
+ *
167
+ * A pure function because every flag here is load bearing and none of it is
168
+ * obvious from the outside: which ports are loopback-only, that the image is
169
+ * digest-pinned, that the profile is mounted. Tests assert on this directly
170
+ * rather than trying to observe a running container.
171
+ */
172
+ export function buildRunArgs(opts, image, name, profileDir) {
173
+ const args = [
174
+ 'run', '--rm', '-d',
175
+ '--name', name,
176
+ '--platform', PLATFORM,
177
+ // Whose container this is, so an orphan can be told from a container
178
+ // another session is still driving.
179
+ '--label', `${OWNER_LABEL}=${process.pid}`,
180
+ // Loopback only. Both CDP proxies listen on 0.0.0.0 INSIDE the container,
181
+ // so what is reachable is entirely down to this mapping.
182
+ '-p', `127.0.0.1:${opts.viewerPort}:${VIEWER_PORT}`,
183
+ '-p', `127.0.0.1:${opts.cdpPort}:${CDP_PORT}`,
184
+ // Chromium on Docker's default 64 MB /dev/shm is a crash waiting for a
185
+ // heavy page. The image README and the upstream Helm chart both give it
186
+ // 1-2 GiB; only the dev script leaves the default.
187
+ '--shm-size=1g',
188
+ // The runtime looks up its own geolocation at boot, twice, with an 8s
189
+ // timeout each — up to 16s added to every cold start. We are not
190
+ // impersonating a locale (that is what `builder` mode's country routing is
191
+ // for), so skip it. Measured: 6.6s to ready with this off.
192
+ '-e', 'CLOAK_GEOIP=false',
193
+ // Cookies, localStorage and the persisted fingerprint identity. Without
194
+ // it, signing in once — the entire point of authoring — cannot survive a
195
+ // restart.
196
+ '-v', `${profileDir}:/home/kernel/user-data`,
197
+ ];
198
+ if (opts.width) {
199
+ args.push('-e', `WIDTH=${opts.width}`);
200
+ }
201
+ if (opts.height) {
202
+ args.push('-e', `HEIGHT=${opts.height}`);
203
+ }
204
+ if (opts.url) {
205
+ args.push('-e', `APP_URL=${opts.url}`);
206
+ }
207
+ args.push(image);
208
+ return args;
209
+ }
210
+ /**
211
+ * Start a browser container and wait until it is actually serving.
212
+ *
213
+ * Ports are allocated per call, so concurrent sessions never land on the same
214
+ * container. Pass `viewerPort` to pin one — re-attaching with the same port
215
+ * reuses the container that is already there.
216
+ */
217
+ export async function startContainer(options = {}) {
218
+ // Containers whose session died without cleaning up, cleared before we look
219
+ // for one to adopt. Deliberately here rather than at server startup: a
220
+ // short-lived server exits before a fire-and-forget reap finishes, and nobody
221
+ // who never uses container mode should pay for a `docker ps` at launch.
222
+ await reapOrphanedContainers();
223
+ const image = options.image ?? BROWSER_RUNTIME_IMAGE;
224
+ const slot = await pickSlot();
225
+ const name = containerName(slot);
226
+ const token = claim(name);
227
+ // Adopt a container that is already serving on this slot. This is the common
228
+ // case for one developer working across sessions: the browser, and whatever
229
+ // they signed into, is still there.
230
+ const adopted = await adopt(name);
231
+ if (adopted) {
232
+ LOGGER.info({ name }, 'adopted the browser container already running');
233
+ return describe(adopted, name, false, slot, token);
234
+ }
235
+ // Anything left under this name is wreckage — the slot has no live claimant.
236
+ if (await inspectContainer(name)) {
237
+ LOGGER.info({ name }, 'removing a container that is no longer serving');
238
+ await execFileAsync('docker', ['rm', '-f', name], { timeout: 60000 })
239
+ .catch(() => { });
240
+ }
241
+ const ports = await freePortPair();
242
+ const opts = {
243
+ ...options,
244
+ viewerPort: options.viewerPort ?? ports.viewer,
245
+ cdpPort: options.cdpPort ?? ports.cdp,
246
+ };
247
+ const profileDir = containerProfileDir(slot);
248
+ mkdirSync(profileDir, { recursive: true });
249
+ const pulled = !await imagePresent(image);
250
+ if (pulled) {
251
+ LOGGER.info({ image }, 'pulling the browser runtime image');
252
+ await pullImage(image);
253
+ }
254
+ await execFileAsync('docker', buildRunArgs(opts, image, name, profileDir), { timeout: 120000 });
255
+ try {
256
+ await waitUntilServing(opts.cdpPort);
257
+ }
258
+ catch (err) {
259
+ await captureLogs(name);
260
+ release(name, token);
261
+ await stopContainer(name);
262
+ throw err;
263
+ }
264
+ return describe(opts, name, pulled, slot, token);
265
+ }
266
+ /** `reclaim-browser-0`, `-1`, … — one name per slot, so a slot is findable. */
267
+ function containerName(slot) {
268
+ return `reclaim-browser-${slot}`;
269
+ }
270
+ /**
271
+ * A slot this session may use.
272
+ *
273
+ * Prefers one we ALREADY hold, so re-attaching within a session adopts the
274
+ * browser it is already running instead of paying 2 GB and twenty seconds for a
275
+ * second one. Then the lowest slot nobody holds. A new slot only appears when
276
+ * ANOTHER session is actively using the earlier ones, because two containers on
277
+ * one profile would corrupt it.
278
+ */
279
+ async function pickSlot() {
280
+ const held = ownSlots();
281
+ for (let slot = 0; slot < MAX_SLOTS; slot++) {
282
+ if (held.has(containerName(slot))) {
283
+ return slot;
284
+ }
285
+ }
286
+ const busy = busySlots();
287
+ for (let slot = 0; slot < MAX_SLOTS; slot++) {
288
+ if (!busy.has(containerName(slot))) {
289
+ return slot;
290
+ }
291
+ }
292
+ throw new Error(`All ${MAX_SLOTS} browser containers are in use by other sessions, which `
293
+ + 'is as many as this runs at once — each costs about 2 GB of RAM. Close '
294
+ + 'one of those sessions, or attach with mode "dedicated" to use local '
295
+ + 'Chrome instead.');
296
+ }
297
+ /**
298
+ * The ports a running container publishes, or undefined when there is nothing
299
+ * healthy to adopt.
300
+ *
301
+ * Adoption has to DISCOVER the ports rather than allocate them: the container
302
+ * was started by a previous session that chose its own.
303
+ */
304
+ async function adopt(name) {
305
+ if (await inspectContainer(name) !== 'running') {
306
+ return undefined;
307
+ }
308
+ let mapped = '';
309
+ try {
310
+ const { stdout } = await execFileAsync('docker', ['port', name], { timeout: 20000 });
311
+ mapped = stdout;
312
+ }
313
+ catch {
314
+ return undefined;
315
+ }
316
+ // Lines look like `6080/tcp -> 127.0.0.1:49987`.
317
+ const portFor = (containerPort) => {
318
+ const line = mapped.split('\n')
319
+ .find((l) => l.startsWith(`${containerPort}/tcp`));
320
+ const host = line?.split(':').pop()?.trim();
321
+ return host ? Number(host) : undefined;
322
+ };
323
+ const viewerPort = portFor(VIEWER_PORT);
324
+ const cdpPort = portFor(CDP_PORT);
325
+ if (!viewerPort || !cdpPort || !await isServing(cdpPort)) {
326
+ return undefined;
327
+ }
328
+ return { viewerPort, cdpPort };
329
+ }
330
+ /** Read the container's CDP websocket and assemble the handle. */
331
+ async function describe(opts, name, pulled, slot, token) {
332
+ const version = await fetch(`http://127.0.0.1:${opts.cdpPort}/json/version`)
333
+ .then((res) => res.json());
334
+ if (!version.webSocketDebuggerUrl) {
335
+ await stopContainer(name);
336
+ throw new Error('The browser container is serving but reported no CDP websocket.');
337
+ }
338
+ return {
339
+ viewerUrl: `http://127.0.0.1:${opts.viewerPort}/liveview.html?magnify=1`,
340
+ cdpUrl: version.webSocketDebuggerUrl,
341
+ viewerPort: opts.viewerPort,
342
+ name,
343
+ pulled,
344
+ slot,
345
+ // Stopping is really "I am done with it". The container only goes when
346
+ // this was the last session holding the slot; otherwise it keeps serving
347
+ // whoever else is using it.
348
+ stop: async () => {
349
+ if (release(name, token)) {
350
+ await stopContainer(name);
351
+ return;
352
+ }
353
+ LOGGER.info({ name }, 'left the container running for another session');
354
+ },
355
+ };
356
+ }
357
+ /** Stop a container. `--rm` means stopping it also removes it. Best effort:
358
+ * teardown must not throw on the way out. */
359
+ async function stopContainer(name) {
360
+ try {
361
+ await execFileAsync('docker', ['stop', '-t', '2', name], { timeout: 60000 });
362
+ }
363
+ catch (err) {
364
+ // Already gone is the outcome we wanted, not a problem to report.
365
+ const message = err instanceof Error ? err.message : String(err);
366
+ if (/No such container/i.test(message)) {
367
+ LOGGER.debug({ name }, 'container was already stopped');
368
+ return;
369
+ }
370
+ LOGGER.warn({ err, name }, 'could not stop the browser container');
371
+ }
372
+ }
373
+ /** Docker's state for a container, or undefined when there is no such name. */
374
+ async function inspectContainer(name) {
375
+ try {
376
+ const { stdout } = await execFileAsync('docker', ['inspect', '--format', '{{.State.Status}}', name], { timeout: 20000 });
377
+ return stdout.trim() || undefined;
378
+ }
379
+ catch {
380
+ return undefined;
381
+ }
382
+ }
383
+ /** One shot of the readiness check, for deciding whether to reuse. */
384
+ async function isServing(cdpPort) {
385
+ try {
386
+ const res = await fetch(`http://127.0.0.1:${cdpPort}/json/version`, {
387
+ signal: AbortSignal.timeout(2000),
388
+ });
389
+ return res.ok;
390
+ }
391
+ catch {
392
+ return false;
393
+ }
394
+ }
395
+ /**
396
+ * Wait until the runtime is serving, not merely listening.
397
+ *
398
+ * The proxy binds its ports early and answers `503 app is not ready` on EVERY
399
+ * route until the entrypoint has seen a Chromium window and touched
400
+ * `/tmp/minimal-vnc-ready`. So a plain "did the socket accept" check passes
401
+ * while there is no browser — which is exactly the trap the reference script
402
+ * falls into, polling with `curl -sS` and no `-f`. Requiring a 2xx is the whole
403
+ * point.
404
+ */
405
+ async function waitUntilServing(cdpPort) {
406
+ const deadline = Date.now() + READY_TIMEOUT_MS;
407
+ let lastStatus = 'no response';
408
+ while (Date.now() < deadline) {
409
+ try {
410
+ const res = await fetch(`http://127.0.0.1:${cdpPort}/json/version`, {
411
+ signal: AbortSignal.timeout(2000),
412
+ });
413
+ if (res.ok) {
414
+ return;
415
+ }
416
+ lastStatus = `HTTP ${res.status}`;
417
+ }
418
+ catch (err) {
419
+ lastStatus = err instanceof Error ? err.message : String(err);
420
+ }
421
+ await new Promise((resolve) => setTimeout(resolve, READY_POLL_MS));
422
+ }
423
+ throw new Error('The browser container never became ready within '
424
+ + `${READY_TIMEOUT_MS / 1000}s (last: ${lastStatus}). On Apple Silicon `
425
+ + 'this usually means x86 emulation is unavailable — enable Rosetta in '
426
+ + 'Docker Desktop, or use OrbStack.');
427
+ }
428
+ /** Log the container's own account of a failed start, which is where the
429
+ * reason lives (a SIGTRAP under QEMU, say). */
430
+ async function captureLogs(name) {
431
+ try {
432
+ const { stdout, stderr } = await execFileAsync('docker', ['logs', '--tail', '40', name], { timeout: 20000 });
433
+ LOGGER.warn({ name, logs: `${stdout}\n${stderr}`.trim().slice(-4000) }, 'browser container failed to start');
434
+ }
435
+ catch (err) {
436
+ LOGGER.debug({ err }, 'could not read the container logs');
437
+ }
438
+ }
439
+ /**
440
+ * Remove containers whose owning process is gone.
441
+ *
442
+ * Shutdown cleanup covers an orderly exit, but a SIGKILL or a crash runs
443
+ * nothing — and a 1.3 GB container with a live browser in it should not outlive
444
+ * the session that made it. Reaping at startup catches those, and the owner-PID
445
+ * label keeps it safe: a container whose owner still runs belongs to another
446
+ * session and is left alone.
447
+ */
448
+ export async function reapOrphanedContainers() {
449
+ let listed = '';
450
+ try {
451
+ const { stdout } = await execFileAsync('docker', [
452
+ 'ps', '--filter', `label=${OWNER_LABEL}`,
453
+ '--format', `{{.Names}} {{.Label "${OWNER_LABEL}"}}`,
454
+ ], { timeout: 20000 });
455
+ listed = stdout;
456
+ }
457
+ catch {
458
+ // No Docker, or no daemon. Nothing to reap and nothing to report.
459
+ return [];
460
+ }
461
+ const reaped = [];
462
+ for (const line of listed.split('\n')) {
463
+ const [name, pid] = line.trim().split(/\s+/);
464
+ if (!name || !pid) {
465
+ continue;
466
+ }
467
+ if (processAlive(Number(pid))) {
468
+ continue;
469
+ }
470
+ try {
471
+ await execFileAsync('docker', ['rm', '-f', name], { timeout: 60000 });
472
+ reaped.push(name);
473
+ LOGGER.info({ name, pid }, 'removed an orphaned browser container');
474
+ }
475
+ catch (err) {
476
+ // Someone else may have removed it first. Not worth failing a start.
477
+ LOGGER.debug({ err, name }, 'could not remove an orphan');
478
+ }
479
+ }
480
+ return reaped;
481
+ }
@@ -2,10 +2,13 @@ import { type CloudflaredBinary } from './cloudflared.ts';
2
2
  export interface Tunnel {
3
3
  /** The public HTTPS origin Cloudflare assigned. */
4
4
  url: string;
5
- /** Whether the edge answered before {@link READY_TIMEOUT_MS} elapsed. False
6
- * means the tunnel is probably still propagating rather than broken — tell
7
- * the viewer to retry rather than treating the share as failed. */
5
+ /** Whether the tunnel was carrying traffic before {@link READY_TIMEOUT_MS}
6
+ * elapsed. False means it may still be propagating rather than broken — say
7
+ * so rather than treating the share as failed. */
8
8
  ready: boolean;
9
+ /** cloudflared's own last words, for when `ready` is false and someone has
10
+ * to work out why. Absent when the tunnel came up normally. */
11
+ diagnostics?: string;
9
12
  /** Terminate the tunnel. Safe to call more than once. */
10
13
  stop: () => Promise<void>;
11
14
  /** Which cloudflared ran, and whether we had to fetch it. */
@@ -1,15 +1,22 @@
1
1
  /**
2
- * Public HTTPS front door for the local live-view server, via a `cloudflared`
3
- * quick tunnel.
2
+ * Public HTTPS front door for a local port, via a `cloudflared` quick tunnel.
4
3
  *
5
4
  * A quick tunnel needs no Cloudflare account and no DNS: `cloudflared` dials
6
5
  * out, Cloudflare hands back a throwaway `*.trycloudflare.com` hostname, and
7
6
  * traffic arrives on the loopback port we pass in. That is the whole reason it
8
7
  * is used here — the developer's machine keeps no inbound port open.
9
8
  *
10
- * The tunnel itself carries NO authorization. Anyone holding the hostname can
11
- * reach the local server, so the server is the thing that must authenticate
12
- * (see `viewer.ts`, which puts an unguessable token in every path).
9
+ * **The hostname IS the secret, and it is the only one.** The tunnel carries no
10
+ * authorization of its own, and the browser runtime's viewer behind it has no
11
+ * login either — so whoever learns the URL has the browser. Cloudflare's
12
+ * hostnames are randomly generated, which is what makes that tolerable for a
13
+ * short, deliberate hand-off, and `stop_browser_view` is how it ends.
14
+ *
15
+ * That is weaker than what this file used to front. The agent's own screencast
16
+ * viewer added a 24-byte token to every path, so the hostname alone was not
17
+ * enough; the container's viewer has no equivalent. Callers must say plainly
18
+ * what the URL grants, and it must never front the container's CDP port, which
19
+ * is unauthenticated total control of the browser.
13
20
  */
14
21
  import { spawn } from 'node:child_process';
15
22
  import { LOGGER } from "../logger.js";
@@ -17,26 +24,49 @@ import { resolveCloudflared } from "./cloudflared.js";
17
24
  /** How long to wait for cloudflared to report its hostname before giving up. */
18
25
  const URL_TIMEOUT_MS = 30000;
19
26
  /** After the hostname is printed, Cloudflare needs a moment before the edge
20
- * routes to it — measured at ~3s. Poll until it answers so the caller never
21
- * hands out a URL that errors, but don't fail the share if it's slow. */
27
+ * routes to it — measured at ~3s. Wait for it so the caller never hands out a
28
+ * URL that errors, but don't fail the share if it's slow. */
22
29
  const READY_TIMEOUT_MS = 20000;
23
30
  const READY_POLL_MS = 1000;
24
31
  /** `https://<something>.trycloudflare.com`, as printed on cloudflared's log
25
32
  * stream. Anchored to the scheme so a hostname inside prose can't match. */
26
33
  const QUICK_TUNNEL_URL = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
27
34
  /**
28
- * Poll the public origin until Cloudflare's edge routes to it. ANY HTTP
29
- * response counts, including a 404: the live-view server 404s unknown paths,
30
- * so a status code at all proves the hop is live.
35
+ * cloudflared says this once per edge connection it establishes, and until it
36
+ * does, the hostname resolves but routes nowhere.
37
+ *
38
+ * This is the signal to trust, because it is LOCAL. Asking whether we can fetch
39
+ * the public URL ourselves conflates two questions — is the tunnel up, and does
40
+ * this machine have egress to Cloudflare's edge — and reports "not ready" when
41
+ * only the second fails. Observed: a tunnel printed its hostname and registered
42
+ * nothing for 12s, which is indistinguishable from a blocked fetch unless you
43
+ * read cloudflared's own account of it.
44
+ */
45
+ const REGISTERED = /Registered tunnel connection|connIndex=/i;
46
+ /** Tail of cloudflared's log kept for error messages. */
47
+ const LOG_TAIL_BYTES = 4096;
48
+ /**
49
+ * Wait until the tunnel is actually carrying traffic.
50
+ *
51
+ * Two independent signals, either of which is proof:
52
+ * - cloudflared reported an edge registration (see {@link REGISTERED}). Local,
53
+ * so it works regardless of this machine's own egress.
54
+ * - the public origin answered. ANY HTTP response counts, including a 404:
55
+ * the live-view server 404s unknown paths, so a status code at all proves
56
+ * the hop is live. Kept as a second opinion in case a cloudflared release
57
+ * rewords its log.
31
58
  */
32
- async function waitUntilRouted(url) {
59
+ async function waitUntilRouted(url, isRegistered) {
33
60
  const deadline = Date.now() + READY_TIMEOUT_MS;
34
61
  while (Date.now() < deadline) {
62
+ if (isRegistered()) {
63
+ return true;
64
+ }
35
65
  try {
36
66
  await fetch(url, {
37
67
  method: 'GET',
38
68
  redirect: 'manual',
39
- signal: AbortSignal.timeout(READY_POLL_MS * 4),
69
+ signal: AbortSignal.timeout(READY_POLL_MS * 2),
40
70
  });
41
71
  return true;
42
72
  }
@@ -44,7 +74,7 @@ async function waitUntilRouted(url) {
44
74
  await new Promise((r) => setTimeout(r, READY_POLL_MS));
45
75
  }
46
76
  }
47
- return false;
77
+ return isRegistered();
48
78
  }
49
79
  /**
50
80
  * Open a quick tunnel to `http://127.0.0.1:<port>` and resolve once
@@ -75,6 +105,8 @@ export async function startTunnel(port, { autoInstall = true } = {}) {
75
105
  + 'or run `cloudflared tunnel --url http://127.0.0.1:'
76
106
  + `${port}` + '` by hand to see its output.'));
77
107
  }, URL_TIMEOUT_MS);
108
+ let registered = false;
109
+ let tail = '';
78
110
  let settled = false;
79
111
  const finish = (fn) => {
80
112
  if (settled) {
@@ -88,15 +120,23 @@ export async function startTunnel(port, { autoInstall = true } = {}) {
88
120
  // both so a future version that switches streams still works.
89
121
  const onChunk = (chunk) => {
90
122
  const text = chunk.toString();
123
+ // Keep watching after the hostname: registration lands a beat later, and
124
+ // on a bad run it never lands at all, which is the case worth reporting.
125
+ tail = (tail + text).slice(-LOG_TAIL_BYTES);
126
+ if (REGISTERED.test(text)) {
127
+ registered = true;
128
+ }
91
129
  const match = QUICK_TUNNEL_URL.exec(text);
92
130
  if (match) {
93
131
  const url = match[0];
94
132
  finish(() => {
95
- void waitUntilRouted(url).then((ready) => {
96
- if (!ready) {
97
- LOGGER.warn({ url }, 'tunnel not routed yet; returning anyway');
133
+ void waitUntilRouted(url, () => registered).then((ready) => {
134
+ if (ready) {
135
+ resolve({ url, ready, stop, binary });
136
+ return;
98
137
  }
99
- resolve({ url, ready, stop, binary });
138
+ LOGGER.warn({ url, tail }, 'tunnel never registered with an edge; returning anyway');
139
+ resolve({ url, ready, stop, binary, diagnostics: tail });
100
140
  });
101
141
  });
102
142
  }
@@ -61,7 +61,8 @@ const SHARED_TOPIC_FILES = {
61
61
  const TOPIC_SUMMARIES = {
62
62
  'overview': 'Terminology, your stance, and the whole loop at a glance.',
63
63
  'auth': 'Signing in to the backend, and which token authorizes what.',
64
- 'browser': 'Choosing a browser source (local / remote / custom CDP), '
64
+ 'browser': 'Choosing a browser source (local Chrome, the Reclaim runtime in '
65
+ + 'Docker, a remote Popcorn browser, or a custom CDP endpoint), '
65
66
  + 'geolocation, live view, and launch troubleshooting.',
66
67
  'capture': 'Tabs, login detection, driving the page, and picking an '
67
68
  + 'authenticated request to prove.',