@reclaimprotocol/agent 0.1.24 → 0.1.26
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/README.md +5 -2
- package/lib/cdp/claims.d.ts +21 -0
- package/lib/cdp/claims.js +129 -0
- package/lib/cdp/client.d.ts +0 -10
- package/lib/cdp/container.d.ts +85 -0
- package/lib/cdp/container.js +481 -0
- package/lib/live/tunnel.d.ts +6 -3
- package/lib/live/tunnel.js +57 -17
- package/lib/mcp/instructions.js +2 -1
- package/lib/mcp/start.js +58 -3
- package/lib/mcp/tools/agent/attach.d.ts +22 -2
- package/lib/mcp/tools/agent/attach.js +197 -55
- package/lib/mcp/tools/agent/build.d.ts +5 -0
- package/lib/mcp/tools/agent/build.js +9 -2
- package/lib/mcp/tools/agent/index.d.ts +1 -2
- package/lib/mcp/tools/agent/index.js +1 -1
- package/lib/mcp/tools/agent/live-view.d.ts +3 -7
- package/lib/mcp/tools/agent/live-view.js +74 -81
- package/lib/mcp/tools/agent/session.d.ts +14 -0
- package/lib/mcp/tools/agent/session.js +14 -8
- package/lib/old/client.d.ts +83 -0
- package/lib/old/client.js +46 -0
- package/lib/old/tools/build.js +2 -0
- package/lib/old/tools/session-logs.d.ts +3 -0
- package/lib/old/tools/session-logs.js +310 -0
- package/package.json +2 -2
- package/skill/SKILL.common.md +115 -38
- package/skill/SKILL.old.md +28 -2
- package/skill/builder-mode.md +9 -7
- package/skill/interception.md +3 -2
- package/skill/old-mode.md +14 -10
- package/src/cdp/claims.ts +153 -0
- package/src/cdp/client.ts +0 -8
- package/src/cdp/container.ts +593 -0
- package/src/live/tunnel.ts +71 -20
- package/src/mcp/instructions.ts +2 -1
- package/src/mcp/start.ts +65 -2
- package/src/mcp/tools/agent/attach.ts +229 -60
- package/src/mcp/tools/agent/build.ts +10 -2
- package/src/mcp/tools/agent/index.ts +6 -2
- package/src/mcp/tools/agent/live-view.ts +77 -93
- package/src/mcp/tools/agent/session.ts +14 -8
- package/src/old/client.ts +128 -0
- package/src/old/tools/build.ts +2 -0
- package/src/old/tools/session-logs.ts +395 -0
- package/lib/cdp/browser-proxy.d.ts +0 -78
- package/lib/cdp/browser-proxy.js +0 -186
- package/lib/live/page.d.ts +0 -20
- package/lib/live/page.js +0 -134
- package/lib/live/viewer.d.ts +0 -28
- package/lib/live/viewer.js +0 -232
- package/lib/mcp/tools/agent/proxy.d.ts +0 -3
- package/lib/mcp/tools/agent/proxy.js +0 -199
- package/src/live/page.ts +0 -145
- 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
|
+
}
|
package/lib/live/tunnel.d.ts
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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. */
|
package/lib/live/tunnel.js
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Public HTTPS front door for
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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.
|
|
21
|
-
*
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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 *
|
|
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
|
|
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 (
|
|
97
|
-
|
|
133
|
+
void waitUntilRouted(url, () => registered).then((ready) => {
|
|
134
|
+
if (ready) {
|
|
135
|
+
resolve({ url, ready, stop, binary });
|
|
136
|
+
return;
|
|
98
137
|
}
|
|
99
|
-
|
|
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
|
}
|
package/lib/mcp/instructions.js
CHANGED
|
@@ -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
|
|
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.',
|