@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.
- package/README.md +3 -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/package.json +2 -2
- package/skill/SKILL.common.md +115 -38
- package/skill/builder-mode.md +9 -7
- package/skill/interception.md +3 -2
- package/skill/old-mode.md +10 -8
- 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/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,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
|
+
}
|