@parall/daemon 1.35.0 → 1.36.1

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.
@@ -0,0 +1,835 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `parall-browser-pod` — the V2 hosted-browser pod entrypoint.
4
+ *
5
+ * This is the production replacement for the Go echo-stub (server/cmd/browser-stub):
6
+ * a single-profile pod that runs the REAL browser stack (Chromium under Xvfb +
7
+ * bb-browser, started lazily by BrowserProfileManager) and registers to the Clip
8
+ * Service hub as the "browser" provider for exactly ONE profile.
9
+ *
10
+ * It is deliberately NOT the daemon supervisor. The daemon (`index.ts`) is a
11
+ * Machine: it authenticates with a 90-day `mck_`, polls `/machines/me/*`, and
12
+ * supervises per-agent subprocesses. A hosted browser pod has none of that — it
13
+ * is platform infrastructure with a short-lived, controller-minted platform
14
+ * assignment token (`pba_`) scoped to `(org_id, profile_id, pod_id)`, and serves
15
+ * a single profile until its lease is released. Per the V2 design (§3.3, §6) this
16
+ * is an **image/entrypoint-level** slim mode: it reuses the shared clip-runtime
17
+ * building blocks (ClipProvider / ClipProcessManager / BrowserProfileManager)
18
+ * without deleting anything from the shared package, so the BYOC daemon path and
19
+ * its `mck_` identity are untouched.
20
+ *
21
+ * Contract (mirrors server/cmd/browser-stub/main.go, the reference implementation):
22
+ *
23
+ * Env (injected by browser-profile-controller's pod spec):
24
+ * PRLL_ASSIGNMENT_TOKEN the pba_ platform assignment token (required)
25
+ * PRLL_CLIP_RPC in-cluster clip-service URL, e.g. http://clip-service:8095 (required)
26
+ * PRLL_PROFILE_ID the profile this pod serves (required)
27
+ * PRLL_POD_ID this pod's name → provider_name; must match the token's pod_id (required)
28
+ * PRLL_ORG_ID owning org (logging only; the hub derives org from the token)
29
+ * PRLL_HEALTH_PORT liveness/readiness listen port (default 8080)
30
+ * PRLL_BROWSER_STATE_DIR root for on-disk browser state (default $HOME/.parall-agent)
31
+ *
32
+ * Health (the contract the controller's K8s probes consume):
33
+ * GET /livez -> 200 always (process is alive; a liveness restart trigger).
34
+ * GET /readyz -> 200 only while registered to the hub AND not draining, else 503.
35
+ * Readiness is ROUTE-readiness, not mere liveness: a started-but-not-yet-
36
+ * registered (or disconnected, or token/lease-invalidated) pod reads
37
+ * NotReady, so the controller never treats pod existence as routable. The
38
+ * hub fences the ProviderStream on the pba_ token + live lease, so loss of
39
+ * either drops the registration → isConnected() → false → NotReady.
40
+ *
41
+ * Shutdown (SIGTERM, on pod deletion / lease release): fail readiness FIRST so
42
+ * K8s/controller stop routing, THEN tear down the ProviderStream (hub
43
+ * unregisters) and the bb-browser daemon — all within terminationGracePeriod.
44
+ * The authoritative lease state transition (lease -> released, quota freed) is
45
+ * still owned by the controller/api-server, never inferred from the pod exiting.
46
+ */
47
+ import { timingSafeEqual } from 'node:crypto';
48
+ import { realpathSync } from 'node:fs';
49
+ import * as http from 'node:http';
50
+ import * as os from 'node:os';
51
+ import * as path from 'node:path';
52
+ import { pathToFileURL } from 'node:url';
53
+ import { createLogger } from '@parall/agent-core';
54
+ import { BrowserProfileManager, } from './clip-runtime/browser-profile-manager.js';
55
+ import { BrowserStateStore } from './clip-runtime/browser-state-store.js';
56
+ import { ClipProvider } from './clip-runtime/clip-provider.js';
57
+ import { ClipProcessManager } from './clip-runtime/process-manager.js';
58
+ /**
59
+ * Platform assignment token prefix (SSOT: server/pkg/platformauth/token.go). A
60
+ * hosted browser pod authenticates ONLY with a controller-minted `pba_` token —
61
+ * never an `mck_` (Machine) or `agk_` (agent) key. We fail fast on the wrong
62
+ * prefix, symmetric with the daemon's `mck_` guard in config.ts, to catch a
63
+ * misconfigured pod spec before it opens a doomed session loop.
64
+ */
65
+ const ASSIGNMENT_TOKEN_PREFIX = 'pba_';
66
+ const DEFAULT_HEALTH_PORT = 8080;
67
+ /** Default cadence for periodic S3 checkpoints while the pod runs (design §3.2). */
68
+ const DEFAULT_CHECKPOINT_INTERVAL_MS = 60_000;
69
+ /** Parse a TCP port from env, falling back when absent or out of the 1–65535 range. */
70
+ function parsePort(value, fallback) {
71
+ if (!value)
72
+ return fallback;
73
+ const n = Number(value);
74
+ return Number.isInteger(n) && n > 0 && n < 65536 ? n : fallback;
75
+ }
76
+ /**
77
+ * Resolve and validate the pod's runtime config from the environment. Throws on
78
+ * any missing required var (fail-fast: a pod missing its token / clip-rpc /
79
+ * profile / pod id can never register, so we crash now instead of starting a
80
+ * doomed reconnect loop).
81
+ */
82
+ export function resolveBrowserPodConfig(env = process.env) {
83
+ const assignmentToken = env.PRLL_ASSIGNMENT_TOKEN?.trim() || '';
84
+ const clipRpcUrl = env.PRLL_CLIP_RPC?.trim() || '';
85
+ const profileId = env.PRLL_PROFILE_ID?.trim() || '';
86
+ const podId = env.PRLL_POD_ID?.trim() || '';
87
+ const orgId = env.PRLL_ORG_ID?.trim() || '';
88
+ const missing = [];
89
+ if (!assignmentToken)
90
+ missing.push('PRLL_ASSIGNMENT_TOKEN');
91
+ if (!clipRpcUrl)
92
+ missing.push('PRLL_CLIP_RPC');
93
+ if (!profileId)
94
+ missing.push('PRLL_PROFILE_ID');
95
+ if (!podId)
96
+ missing.push('PRLL_POD_ID');
97
+ if (missing.length > 0) {
98
+ throw new Error(`browser-pod: missing required env: ${missing.join(', ')}`);
99
+ }
100
+ if (!assignmentToken.startsWith(ASSIGNMENT_TOKEN_PREFIX)) {
101
+ throw new Error(`browser-pod: PRLL_ASSIGNMENT_TOKEN is not a platform assignment token ` +
102
+ `(expected "${ASSIGNMENT_TOKEN_PREFIX}" prefix). Hosted browser pods authenticate ` +
103
+ `with a controller-minted assignment token, never an mck_/agk_ key.`);
104
+ }
105
+ const home = env.HOME?.trim() || os.homedir();
106
+ const stateDir = env.PRLL_BROWSER_STATE_DIR?.trim() || path.join(home, '.parall-agent');
107
+ const homeDir = path.join(stateDir, 'bb-browser');
108
+ return {
109
+ assignmentToken,
110
+ clipRpcUrl,
111
+ profileId,
112
+ podId,
113
+ orgId,
114
+ healthPort: parsePort(env.PRLL_HEALTH_PORT, DEFAULT_HEALTH_PORT),
115
+ stateDir,
116
+ homeDir,
117
+ proxy: parseProxyEnv(env),
118
+ };
119
+ }
120
+ /** Parse the controller-injected PRLL_BROWSER_PROXY_* env into a proxy config, or
121
+ * null when no proxy is set (PRLL_BROWSER_PROXY_SERVER absent → direct egress).
122
+ * The cold pod gets these as env; the warm pod's /assign writes the same vars. */
123
+ function parseProxyEnv(env) {
124
+ const server = env.PRLL_BROWSER_PROXY_SERVER?.trim();
125
+ // username + password are credential-like passthrough — kept VERBATIM (never
126
+ // trimmed), because residential-proxy usernames carry session/geo selectors and
127
+ // a provider could legitimately use surrounding whitespace. Empty string → undefined.
128
+ const username = env.PRLL_BROWSER_PROXY_USERNAME || undefined;
129
+ const password = env.PRLL_BROWSER_PROXY_PASSWORD || undefined;
130
+ if (!server) {
131
+ // Orphan credentials with no server must FAIL CLOSED, not silently fall through
132
+ // to direct egress: a misconfigured pod spec that drops the server var while
133
+ // keeping creds should crash here, not leak traffic past the intended proxy.
134
+ if (username !== undefined || password !== undefined) {
135
+ throw new Error('browser-pod: PRLL_BROWSER_PROXY_USERNAME/PASSWORD set without PRLL_BROWSER_PROXY_SERVER');
136
+ }
137
+ return null; // no proxy at all → direct egress
138
+ }
139
+ // Reject half-auth at the pod boundary too (the server already enforces the
140
+ // user+pass pair). A version skew / bad pod spec should fail fast here, not
141
+ // forward incomplete creds to account_create where it becomes a confusing
142
+ // runtime auth failure.
143
+ if ((username === undefined) !== (password === undefined)) {
144
+ throw new Error('browser-pod: PRLL_BROWSER_PROXY_USERNAME and PRLL_BROWSER_PROXY_PASSWORD must be set together');
145
+ }
146
+ return { server, username, password };
147
+ }
148
+ /**
149
+ * Resolve a warm pod's identity-less config. Unlike resolveBrowserPodConfig this
150
+ * requires PRLL_POOL_TOKEN (not PRLL_ASSIGNMENT_TOKEN) and NO profile/org — those
151
+ * arrive later via /assign. PRLL_CLIP_RPC is injected at pool creation (stable per
152
+ * cluster) so the assigned path needs no extra wiring.
153
+ */
154
+ export function resolvePoolPodConfig(env = process.env) {
155
+ const poolToken = env.PRLL_POOL_TOKEN?.trim() || '';
156
+ const podId = env.PRLL_POD_ID?.trim() || '';
157
+ const clipRpcUrl = env.PRLL_CLIP_RPC?.trim() || '';
158
+ const missing = [];
159
+ if (!poolToken)
160
+ missing.push('PRLL_POOL_TOKEN');
161
+ if (!podId)
162
+ missing.push('PRLL_POD_ID');
163
+ if (!clipRpcUrl)
164
+ missing.push('PRLL_CLIP_RPC');
165
+ if (missing.length > 0) {
166
+ throw new Error(`browser-pod (pool): missing required env: ${missing.join(', ')}`);
167
+ }
168
+ if (!poolToken.startsWith(ASSIGNMENT_TOKEN_PREFIX)) {
169
+ throw new Error(`browser-pod (pool): PRLL_POOL_TOKEN is not a platform token (expected "${ASSIGNMENT_TOKEN_PREFIX}" prefix).`);
170
+ }
171
+ return {
172
+ poolToken,
173
+ podId,
174
+ clipRpcUrl,
175
+ healthPort: parsePort(env.PRLL_HEALTH_PORT, DEFAULT_HEALTH_PORT),
176
+ };
177
+ }
178
+ /**
179
+ * Validate + normalize an /assign body (already JSON-parsed). Rejects a missing /
180
+ * non-pba_ assignment token, a missing profile/lease, and PARTIAL S3 creds (a
181
+ * half-set would build a misconfigured S3 client — worse than stateless). State is
182
+ * optional (stateless profile); when present it must be complete.
183
+ */
184
+ export function parseAssignRequest(body) {
185
+ if (typeof body !== 'object' || body === null) {
186
+ return { ok: false, error: 'body must be a JSON object' };
187
+ }
188
+ const b = body;
189
+ const str = (v) => (typeof v === 'string' ? v.trim() : '');
190
+ const assignmentToken = str(b.assignment_token);
191
+ const profileId = str(b.profile_id);
192
+ const orgId = str(b.org_id);
193
+ const leaseId = str(b.lease_id);
194
+ const generation = typeof b.generation === 'number' && Number.isFinite(b.generation) ? b.generation : 0;
195
+ if (!assignmentToken)
196
+ return { ok: false, error: 'assignment_token required' };
197
+ if (!assignmentToken.startsWith(ASSIGNMENT_TOKEN_PREFIX)) {
198
+ return { ok: false, error: `assignment_token must be a "${ASSIGNMENT_TOKEN_PREFIX}" token` };
199
+ }
200
+ if (!profileId)
201
+ return { ok: false, error: 'profile_id required' };
202
+ if (!leaseId)
203
+ return { ok: false, error: 'lease_id required' };
204
+ let state;
205
+ if (b.state !== undefined && b.state !== null) {
206
+ if (typeof b.state !== 'object')
207
+ return { ok: false, error: 'state must be an object' };
208
+ const s = b.state;
209
+ const bucket = str(s.bucket);
210
+ const accessKeyId = str(s.access_key_id);
211
+ const secretAccessKey = str(s.secret_access_key);
212
+ const sessionToken = str(s.session_token);
213
+ if (!bucket || !accessKeyId || !secretAccessKey || !sessionToken) {
214
+ return {
215
+ ok: false,
216
+ error: 'state requires bucket + access_key_id + secret_access_key + session_token',
217
+ };
218
+ }
219
+ state = {
220
+ bucket,
221
+ region: str(s.region),
222
+ endpoint: str(s.endpoint) || undefined,
223
+ accessKeyId,
224
+ secretAccessKey,
225
+ sessionToken,
226
+ };
227
+ }
228
+ let proxy;
229
+ if (b.proxy !== undefined && b.proxy !== null) {
230
+ if (typeof b.proxy !== 'object')
231
+ return { ok: false, error: 'proxy must be an object' };
232
+ const p = b.proxy;
233
+ const server = str(p.server);
234
+ if (!server)
235
+ return { ok: false, error: 'proxy requires server' };
236
+ // username + password are credentials — preserved VERBATIM (never trimmed);
237
+ // empty string → undefined. (server is a structured host:port, so it is trimmed.)
238
+ const username = (typeof p.username === 'string' ? p.username : '') || undefined;
239
+ const password = (typeof p.password === 'string' ? p.password : '') || undefined;
240
+ // Reject half-auth (same invariant the server enforces) before it reaches
241
+ // account_create as a confusing runtime auth failure.
242
+ if ((username === undefined) !== (password === undefined)) {
243
+ return { ok: false, error: 'proxy username and password must be provided together' };
244
+ }
245
+ proxy = { server, username, password };
246
+ }
247
+ return {
248
+ ok: true,
249
+ value: { assignmentToken, profileId, orgId, leaseId, generation, state, proxy },
250
+ };
251
+ }
252
+ /**
253
+ * Apply an /assign payload to `env` (process.env in prod) so the assigned path
254
+ * reads identity + S3 creds EXACTLY as the controller injects them into a cold
255
+ * pod's spec. The AWS_* creds must land in process.env: the S3 client
256
+ * (browser-state-store buildS3Client) reads the default AWS provider chain.
257
+ */
258
+ export function applyAssignmentEnv(env, req) {
259
+ env.PRLL_ASSIGNMENT_TOKEN = req.assignmentToken;
260
+ env.PRLL_PROFILE_ID = req.profileId;
261
+ if (req.orgId)
262
+ env.PRLL_ORG_ID = req.orgId;
263
+ if (req.state) {
264
+ env.AWS_ACCESS_KEY_ID = req.state.accessKeyId;
265
+ env.AWS_SECRET_ACCESS_KEY = req.state.secretAccessKey;
266
+ env.AWS_SESSION_TOKEN = req.state.sessionToken;
267
+ if (req.state.region)
268
+ env.AWS_REGION = req.state.region;
269
+ env.PRLL_BROWSER_STATE_BUCKET = req.state.bucket;
270
+ env.PRLL_STATE_GENERATION = String(req.generation);
271
+ if (req.state.endpoint)
272
+ env.PRLL_BROWSER_STATE_ENDPOINT = req.state.endpoint;
273
+ }
274
+ // Clear any prior proxy env BEFORE applying this assignment's proxy, so a pod
275
+ // env reused across assignments never inherits a stale credential (a no-proxy
276
+ // or different-proxy reassignment must not keep the previous profile's creds).
277
+ delete env.PRLL_BROWSER_PROXY_SERVER;
278
+ delete env.PRLL_BROWSER_PROXY_USERNAME;
279
+ delete env.PRLL_BROWSER_PROXY_PASSWORD;
280
+ if (req.proxy) {
281
+ env.PRLL_BROWSER_PROXY_SERVER = req.proxy.server;
282
+ if (req.proxy.username)
283
+ env.PRLL_BROWSER_PROXY_USERNAME = req.proxy.username;
284
+ if (req.proxy.password)
285
+ env.PRLL_BROWSER_PROXY_PASSWORD = req.proxy.password;
286
+ }
287
+ }
288
+ /** Constant-time bearer comparison against the injected pool token. The pod cannot
289
+ * verify the JWT signature (no shared secret), so it string-compares the bearer to
290
+ * its own PRLL_POOL_TOKEN — the controller reads that same value back from the pod
291
+ * spec when it POSTs /assign. */
292
+ function poolTokenMatches(bearer, expected) {
293
+ if (!bearer)
294
+ return false;
295
+ const a = Buffer.from(bearer);
296
+ const b = Buffer.from(expected);
297
+ return a.length === b.length && timingSafeEqual(a, b);
298
+ }
299
+ function errString(err) {
300
+ return err instanceof Error ? (err.stack ?? err.message) : String(err);
301
+ }
302
+ /**
303
+ * Build the warm pod's /assign controller. The cardinal rules:
304
+ * - auth: bearer MUST equal the pool token (constant-time).
305
+ * - single-assignment: a warm pod serves ONE profile for life — a repeat for the
306
+ * same lease is idempotent (200), a different lease is a conflict (409). The
307
+ * lease is claimed SYNCHRONOUSLY so a duplicate/concurrent POST conflicts.
308
+ * - eventual readiness: identity is applied then the real runtime is started in
309
+ * the BACKGROUND (hydrate + connect take seconds); /readyz is the gate, exactly
310
+ * as a cold pod whose Create returns fast and becomes Ready later.
311
+ * `startRuntime` is injected so the assign flow is testable without a real hub.
312
+ */
313
+ export function createPoolAssign(opts) {
314
+ let runtime = null;
315
+ let leaseId = null;
316
+ const handler = async (bearer, body) => {
317
+ if (!poolTokenMatches(bearer, opts.poolToken)) {
318
+ return { status: 401, message: 'unauthorized' };
319
+ }
320
+ const parsed = parseAssignRequest(body);
321
+ if (!parsed.ok)
322
+ return { status: 400, message: parsed.error };
323
+ const req = parsed.value;
324
+ if (leaseId !== null) {
325
+ if (leaseId === req.leaseId)
326
+ return { status: 200, message: 'already assigned (idempotent)' };
327
+ return { status: 409, message: 'pod already assigned to a different lease' };
328
+ }
329
+ leaseId = req.leaseId; // claim synchronously: a duplicate/concurrent POST now conflicts
330
+ applyAssignmentEnv(opts.env, req);
331
+ opts.log.info(`assigned: profile=${req.profileId} lease=${req.leaseId} gen=${req.generation} state=${req.state ? 'on' : 'off'}`);
332
+ void (async () => {
333
+ try {
334
+ runtime = await opts.startRuntime(opts.env);
335
+ opts.log.info(`runtime started for lease=${req.leaseId}`);
336
+ }
337
+ catch (err) {
338
+ opts.log.error(`runtime start failed for lease=${req.leaseId}: ${errString(err)} — /readyz stays 503; controller releases on the registration deadline`);
339
+ }
340
+ })();
341
+ return { status: 200, message: 'assignment accepted' };
342
+ };
343
+ return {
344
+ handler,
345
+ isReady: () => runtime?.isReady() ?? false,
346
+ drain: async () => {
347
+ if (runtime)
348
+ await runtime.drain();
349
+ },
350
+ assignedLeaseId: () => leaseId,
351
+ };
352
+ }
353
+ /**
354
+ * BrowserPodRuntime owns the pod's readiness state and the ordered teardown. It
355
+ * is deliberately decoupled from the concrete ClipProvider / BrowserProfileManager
356
+ * (only the narrow PodProvider / PodBrowser surfaces) so the readiness gate and
357
+ * drain ORDER are unit-testable without a real browser or hub.
358
+ */
359
+ export class BrowserPodRuntime {
360
+ provider;
361
+ browser;
362
+ log;
363
+ checkpointer;
364
+ checkpointIntervalMs;
365
+ draining = false;
366
+ drainPromise = null;
367
+ checkpointTimer = null;
368
+ constructor(provider, browser, log,
369
+ // Optional S3 state checkpointer (PR4). Absent => stateless pod (no
370
+ // hydrate/checkpoint); the drain order then matches the pre-PR4 contract.
371
+ checkpointer, checkpointIntervalMs = DEFAULT_CHECKPOINT_INTERVAL_MS) {
372
+ this.provider = provider;
373
+ this.browser = browser;
374
+ this.log = log;
375
+ this.checkpointer = checkpointer;
376
+ this.checkpointIntervalMs = checkpointIntervalMs;
377
+ }
378
+ /**
379
+ * Route-readiness: registered to the hub AND not draining. `draining` is checked
380
+ * first so the readiness gate flips false the instant a drain begins — before
381
+ * the (async) ProviderStream teardown completes. The hub fences the stream on
382
+ * the pba_ token + live lease, so isConnected() already encodes "token/lease
383
+ * still valid".
384
+ */
385
+ isReady() {
386
+ return !this.draining && this.provider.isConnected();
387
+ }
388
+ /** Open the ProviderStream, register as the "browser" provider, and start the
389
+ * periodic checkpoint loop (if a checkpointer was supplied). */
390
+ async start() {
391
+ await this.provider.connect();
392
+ this.startCheckpointLoop();
393
+ }
394
+ /** Periodic S3 checkpoints while serving. Best-effort: a checkpoint error is
395
+ * logged but never throws (it must not kill the pod); the timer is unref'd so it
396
+ * never keeps the process alive on its own. */
397
+ startCheckpointLoop() {
398
+ if (!this.checkpointer || this.checkpointIntervalMs <= 0)
399
+ return;
400
+ const cp = this.checkpointer;
401
+ this.checkpointTimer = setInterval(() => {
402
+ if (this.draining)
403
+ return;
404
+ void cp.checkpointPeriodic().catch((err) => {
405
+ this.log.warn(`periodic checkpoint error: ${String(err)}`);
406
+ });
407
+ }, this.checkpointIntervalMs);
408
+ this.checkpointTimer.unref?.();
409
+ }
410
+ /**
411
+ * Graceful drain (SIGTERM / lease release): fail readiness FIRST, stop the
412
+ * periodic checkpoint loop, then tear down the hub stream and the browser, and
413
+ * finally take ONE last checkpoint AFTER bb-browser is stopped (a consistent
414
+ * snapshot of the settled state — design §3.2). Idempotent — a second SIGTERM
415
+ * joins the first.
416
+ */
417
+ async drain() {
418
+ if (this.drainPromise)
419
+ return this.drainPromise;
420
+ this.draining = true; // readiness -> false BEFORE we close the stream
421
+ this.log.info('draining (readiness -> false)');
422
+ if (this.checkpointTimer) {
423
+ clearInterval(this.checkpointTimer);
424
+ this.checkpointTimer = null;
425
+ }
426
+ this.drainPromise = (async () => {
427
+ try {
428
+ await this.provider.disconnect();
429
+ }
430
+ catch (err) {
431
+ this.log.warn(`provider disconnect during drain failed: ${String(err)}`);
432
+ }
433
+ try {
434
+ await this.browser.stop();
435
+ }
436
+ catch (err) {
437
+ this.log.warn(`browser stop during drain failed: ${String(err)}`);
438
+ }
439
+ // Final checkpoint AFTER bb-browser has torn down — Chromium has flushed and
440
+ // released its files, so the snapshot is consistent. A failure here is bounded
441
+ // loss (the delta since the last successful checkpoint), logged below —
442
+ // durability is best-effort + on-close, with no controller-side verify/repair
443
+ // (design §3.2 "simplified durability").
444
+ if (this.checkpointer) {
445
+ try {
446
+ await this.checkpointer.checkpointFinal();
447
+ }
448
+ catch (err) {
449
+ this.log.error(`final checkpoint during drain failed: ${String(err)}`);
450
+ }
451
+ }
452
+ })();
453
+ return this.drainPromise;
454
+ }
455
+ }
456
+ /**
457
+ * Create the liveness/readiness HTTP server. `/livez` is mere process liveness;
458
+ * `/readyz` is route-readiness driven by `isReady`. Mirrors the Go stub's health
459
+ * contract so the controller's probes are runtime-agnostic.
460
+ */
461
+ export function createHealthServer(opts) {
462
+ const server = http.createServer((req, res) => {
463
+ const url = req.url ?? '';
464
+ if (url === '/livez') {
465
+ res.writeHead(opts.isLive() ? 200 : 503).end();
466
+ return;
467
+ }
468
+ if (url === '/readyz') {
469
+ res.writeHead(opts.isReady() ? 200 : 503).end();
470
+ return;
471
+ }
472
+ if (opts.onAssign && url === '/assign' && req.method === 'POST') {
473
+ handleAssignRequest(req, res, opts.onAssign, opts.log);
474
+ return;
475
+ }
476
+ res.writeHead(404).end();
477
+ });
478
+ // Bound header read so a slow/half-open probe connection can't pin a slot.
479
+ server.headersTimeout = 5_000;
480
+ server.requestTimeout = 10_000;
481
+ return server;
482
+ }
483
+ /** Cap the /assign body — the payload is a small JSON blob (token + creds); a
484
+ * larger body is malformed/hostile and must not buffer unbounded. */
485
+ const MAX_ASSIGN_BODY_BYTES = 64 * 1024;
486
+ /** Read + JSON-parse a bounded POST /assign body, dispatch to the handler, and
487
+ * write its outcome. All failures map to an HTTP status (never crash the server). */
488
+ function handleAssignRequest(req, res, onAssign, log) {
489
+ const chunks = [];
490
+ let size = 0;
491
+ let aborted = false;
492
+ const fail = (status) => {
493
+ if (aborted)
494
+ return;
495
+ aborted = true;
496
+ res.writeHead(status).end();
497
+ };
498
+ req.on('data', (c) => {
499
+ if (aborted)
500
+ return;
501
+ size += c.length;
502
+ if (size > MAX_ASSIGN_BODY_BYTES) {
503
+ fail(413);
504
+ req.destroy();
505
+ return;
506
+ }
507
+ chunks.push(c);
508
+ });
509
+ req.on('error', () => fail(400));
510
+ req.on('end', () => {
511
+ if (aborted)
512
+ return;
513
+ let body;
514
+ try {
515
+ body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
516
+ }
517
+ catch {
518
+ res
519
+ .writeHead(400, { 'content-type': 'application/json' })
520
+ .end(JSON.stringify({ ok: false, error: 'invalid JSON' }));
521
+ return;
522
+ }
523
+ void onAssign(bearerFromHeader(req.headers.authorization), body)
524
+ .then((outcome) => {
525
+ res.writeHead(outcome.status, { 'content-type': 'application/json' }).end(JSON.stringify({
526
+ ok: outcome.status >= 200 && outcome.status < 300,
527
+ message: outcome.message,
528
+ }));
529
+ })
530
+ .catch((err) => {
531
+ log.error(`/assign handler error: ${errString(err)}`);
532
+ res
533
+ .writeHead(500, { 'content-type': 'application/json' })
534
+ .end(JSON.stringify({ ok: false }));
535
+ });
536
+ });
537
+ }
538
+ /** Strip a leading "Bearer " from an Authorization header (case-sensitive scheme,
539
+ * matching the hub). Returns the raw credential, or undefined when absent. */
540
+ function bearerFromHeader(authorization) {
541
+ if (!authorization)
542
+ return undefined;
543
+ return authorization.startsWith('Bearer ') ? authorization.slice(7) : authorization;
544
+ }
545
+ /**
546
+ * Build the S3 state store from env, or return null (stateless pod) ONLY when no
547
+ * bucket is configured (the feature is off). When a bucket IS set, state was
548
+ * REQUESTED, so the org id (part of the S3 key + the pod's STS prefix scope) and the
549
+ * activation generation (the single-writer fence) are required: a missing/invalid
550
+ * value is a controller-injection misconfiguration and FAILS CLOSED (throws) rather
551
+ * than silently registering a non-persisted profile — silent stateless serving when
552
+ * state was configured is data loss. The thrown error propagates to pod startup, so
553
+ * the pod never reaches readiness and the controller releases it on the deadline.
554
+ */
555
+ export function buildStateStore(config, env, log) {
556
+ const bucket = env.PRLL_BROWSER_STATE_BUCKET?.trim();
557
+ if (!bucket)
558
+ return null; // no bucket = intentionally stateless
559
+ if (!config.orgId) {
560
+ throw new Error(`browser state enabled (bucket=${bucket}) but PRLL_ORG_ID is empty — refusing to run a non-persisted profile (fail closed)`);
561
+ }
562
+ const genRaw = env.PRLL_STATE_GENERATION?.trim();
563
+ if (!genRaw) {
564
+ // Missing: never silently default to generation 0 — that would un-fence the pod
565
+ // (gen 0 is the lowest, so any real pod clobbers it). State configured but invalid
566
+ // = refuse to run BEFORE the provider opens (this throw propagates to startup).
567
+ throw new Error(`browser state enabled (bucket=${bucket}) but PRLL_STATE_GENERATION is unset — refusing to run a non-persisted profile (fail closed)`);
568
+ }
569
+ const gen = Number(genRaw);
570
+ if (!Number.isSafeInteger(gen) || gen < 0) {
571
+ throw new Error(`browser state enabled (bucket=${bucket}) but PRLL_STATE_GENERATION="${genRaw}" is not a non-negative safe integer — refusing to run a non-persisted profile (fail closed)`);
572
+ }
573
+ return new BrowserStateStore({
574
+ bucket,
575
+ region: env.AWS_REGION?.trim() || env.PRLL_BROWSER_STATE_REGION?.trim() || '',
576
+ endpoint: env.PRLL_BROWSER_STATE_ENDPOINT?.trim() || undefined,
577
+ orgId: config.orgId,
578
+ profileId: config.profileId,
579
+ generation: gen,
580
+ homeDir: config.homeDir,
581
+ log,
582
+ });
583
+ }
584
+ /** Resolve the periodic checkpoint cadence (ms) from env, clamped to ≥1s. */
585
+ export function resolveCheckpointIntervalMs(env) {
586
+ const raw = env.PRLL_BROWSER_STATE_CHECKPOINT_INTERVAL_MS?.trim();
587
+ if (!raw)
588
+ return DEFAULT_CHECKPOINT_INTERVAL_MS;
589
+ const n = Number(raw);
590
+ return Number.isFinite(n) && n >= 1_000 ? n : DEFAULT_CHECKPOINT_INTERVAL_MS;
591
+ }
592
+ /**
593
+ * Whether it is SAFE to checkpoint after a hydrate. Checkpoint ONLY when the prior
594
+ * snapshot was loaded (hydrated) or there was genuinely none (a new profile —
595
+ * head returned null, so no flags set). If a snapshot EXISTED but could not be
596
+ * loaded (checksum mismatch, or a download/extract/HEAD error), checkpointing must
597
+ * be disabled: an empty homeDir would otherwise overwrite the good state, and the
598
+ * generation fence permits it (this pod's gen is legitimately higher than the
599
+ * snapshot's). Returns false in that case so the pod runs checkpoint-less, leaving
600
+ * the existing snapshot untouched rather than clobbering it (simplified durability:
601
+ * the prior state is preserved in place — there is no controller-side repair).
602
+ */
603
+ export function shouldCheckpointAfterHydrate(result) {
604
+ return !(result.checksumMismatch === true || result.error !== undefined);
605
+ }
606
+ /** Adapt a BrowserStateStore to the narrow PodStateCheckpointer the runtime drives. */
607
+ function makeCheckpointer(store) {
608
+ return {
609
+ checkpointPeriodic: async () => {
610
+ await store.checkpoint({ final: false });
611
+ },
612
+ checkpointFinal: async () => {
613
+ await store.checkpoint({ final: true });
614
+ },
615
+ };
616
+ }
617
+ /**
618
+ * Wire the real clip-runtime stack for a hosted browser pod from a resolved config
619
+ * and return the {@link BrowserPodRuntime} — WITHOUT creating the health server,
620
+ * binding a port, or parking on a signal. Splitting this out lets the warm-pool
621
+ * path (runPoolPod) reuse the EXACT assigned-pod wiring after a `/assign` arrives,
622
+ * while keeping a single control/health server bound on healthPort (the pool path
623
+ * already owns it). hydrate() runs here — before the provider opens — because
624
+ * bb-browser launches lazily on first invoke, so the snapshot must be on disk first.
625
+ */
626
+ export async function preparePodRuntime(config, env, log) {
627
+ // Real browser runtime — starts bb-browser + Chromium lazily on first invoke,
628
+ // ensures the single profile's account, persists under homeDir (BB_BROWSER_HOME).
629
+ // A hosted pod serves exactly one profile, so resolveProxy ignores the id and
630
+ // returns the controller-injected proxy (env on a cold pod, /assign on a warm one).
631
+ const browser = new BrowserProfileManager({
632
+ homeDir: config.homeDir,
633
+ log,
634
+ resolveProxy: () => config.proxy,
635
+ });
636
+ // The process manager hosts no installable clips in pod mode — its only job is
637
+ // to advertise the synthetic "browser" capability (getProviderClips appends it
638
+ // because a BrowserProfileManager is present) and route inbound browser invokes
639
+ // to bb-browser. The clip/data dirs are required but unused (no subprocess).
640
+ const clipManager = new ClipProcessManager({
641
+ clipsDir: path.join(config.stateDir, 'clips'),
642
+ dataDir: path.join(config.stateDir, 'clip-data'),
643
+ browserProfileManager: browser,
644
+ });
645
+ // Hosted live-viewer handler (design §3.4): clip-service relays viewer commands
646
+ // down the ProviderStream and the pod replies over it. Serialize them — they
647
+ // mutate the single profile's bb-viewer streamer + account state, so a client
648
+ // retrying stream.start must not interleave with the in-flight one (the BYOC
649
+ // supervisor serializes per profile for the same reason). One pod = one
650
+ // profile, so a single chain suffices.
651
+ let viewerChain = Promise.resolve();
652
+ const onViewerCommand = (cmd) => {
653
+ const run = viewerChain
654
+ .catch(() => { }) // keep the chain moving past a prior failure
655
+ .then(() => browser.handleViewerCommand(cmd.profileId, cmd.sessionId, cmd.command, cmd.input, cmd.turn));
656
+ viewerChain = run.catch(() => { });
657
+ return run;
658
+ };
659
+ // ClipProvider passes authKey straight through as the ProviderStream bearer —
660
+ // it does not assume mck_ — so the pba_ assignment token authenticates the pod.
661
+ // providerName is cosmetic for routing (the hub keys the session by the token's
662
+ // profile_id), but we use the pod id so logs/registration map to this pod.
663
+ const provider = new ClipProvider({
664
+ serviceUrl: config.clipRpcUrl,
665
+ authKey: config.assignmentToken,
666
+ orgId: config.orgId,
667
+ providerName: config.podId,
668
+ clipManager,
669
+ log,
670
+ onViewerCommand,
671
+ });
672
+ // S3 state externalization (PR4, design §3.2). Disabled (stateless pod) when no
673
+ // bucket is configured. HYDRATE here — before the provider opens and any invoke
674
+ // can land — because bb-browser launches lazily on first invoke, so the snapshot
675
+ // must already be on disk. hydrate() never throws (a read failure → fresh start).
676
+ const stateStore = buildStateStore(config, env, log);
677
+ let checkpointer;
678
+ if (stateStore) {
679
+ const result = await stateStore.hydrate();
680
+ // Data-loss guard: only attach a checkpointer when it is safe to overwrite the
681
+ // snapshot (we loaded it, or there was genuinely none). If a snapshot existed
682
+ // but could not be hydrated, run checkpoint-less to preserve it — see
683
+ // shouldCheckpointAfterHydrate.
684
+ if (shouldCheckpointAfterHydrate(result)) {
685
+ checkpointer = makeCheckpointer(stateStore);
686
+ }
687
+ else {
688
+ log.error('browser-state: prior snapshot present but un-hydratable — running WITHOUT checkpoint to preserve the existing snapshot in place');
689
+ }
690
+ }
691
+ else {
692
+ log.info('browser state externalization disabled (no PRLL_BROWSER_STATE_BUCKET) — running stateless');
693
+ }
694
+ return new BrowserPodRuntime(provider, browser, log, checkpointer, resolveCheckpointIntervalMs(env));
695
+ }
696
+ /**
697
+ * Wire the real clip-runtime stack for a hosted browser pod and run until the
698
+ * abort signal fires (SIGTERM). Returns after the ordered drain completes. This is
699
+ * the ASSIGNED path: PRLL_ASSIGNMENT_TOKEN + profile are already in env (the
700
+ * controller's cold-create pod spec). The warm-pool path (runPoolPod) reaches the
701
+ * same runtime via preparePodRuntime once a `/assign` delivers the identity.
702
+ */
703
+ export async function runBrowserPod(env, signal, log) {
704
+ const config = resolveBrowserPodConfig(env);
705
+ log.info(`starting: profile=${config.profileId} pod=${config.podId} ` +
706
+ `org=${config.orgId || '(unset)'} clip_rpc=${config.clipRpcUrl} health_port=${config.healthPort}`);
707
+ const runtime = await preparePodRuntime(config, env, log);
708
+ const health = createHealthServer({
709
+ isLive: () => true,
710
+ isReady: () => runtime.isReady(),
711
+ log,
712
+ });
713
+ await new Promise((resolve, reject) => {
714
+ health.once('error', reject);
715
+ health.listen(config.healthPort, () => {
716
+ health.removeListener('error', reject);
717
+ log.info(`health server listening on :${config.healthPort}`);
718
+ resolve();
719
+ });
720
+ });
721
+ try {
722
+ await runtime.start();
723
+ log.info('provider stream opened — awaiting hub registration');
724
+ // Run until SIGTERM. ClipProvider keeps the stream alive (heartbeat +
725
+ // reconnect) internally; we just park here until told to drain.
726
+ await new Promise((resolve) => {
727
+ if (signal.aborted) {
728
+ resolve();
729
+ return;
730
+ }
731
+ signal.addEventListener('abort', () => resolve(), { once: true });
732
+ });
733
+ }
734
+ finally {
735
+ await runtime.drain();
736
+ await new Promise((resolve) => health.close(() => resolve()));
737
+ log.info('shut down');
738
+ }
739
+ }
740
+ /**
741
+ * Run a WARM pod: bind ONE control/health server (livez/readyz/assign) on
742
+ * healthPort and park — no Chromium, no profile, no hub registration — until a
743
+ * `POST /assign` delivers an identity, at which point the pod transitions into the
744
+ * exact assigned-pod runtime (preparePodRuntime, hydrating S3 like a cold pod). On
745
+ * SIGTERM, drain the started runtime (if any) then close the server. The single
746
+ * server is the reason runPoolPod reuses preparePodRuntime rather than runBrowserPod
747
+ * (which would bind its own health port).
748
+ */
749
+ export async function runPoolPod(env, signal, log) {
750
+ const pool = resolvePoolPodConfig(env);
751
+ log.info(`pool mode: pod=${pool.podId} clip_rpc=${pool.clipRpcUrl} health_port=${pool.healthPort} — awaiting /assign`);
752
+ const assign = createPoolAssign({
753
+ poolToken: pool.poolToken,
754
+ env,
755
+ log,
756
+ // The assigned path, unchanged: env now carries the identity + S3 creds, so
757
+ // resolveBrowserPodConfig + preparePodRuntime behave exactly as a cold pod.
758
+ startRuntime: async (e) => {
759
+ const config = resolveBrowserPodConfig(e);
760
+ log.info(`pool→assigned: profile=${config.profileId} pod=${config.podId} org=${config.orgId || '(unset)'}`);
761
+ const runtime = await preparePodRuntime(config, e, log);
762
+ await runtime.start();
763
+ return runtime;
764
+ },
765
+ });
766
+ const control = createHealthServer({
767
+ isLive: () => true,
768
+ isReady: () => assign.isReady(),
769
+ log,
770
+ onAssign: assign.handler,
771
+ });
772
+ await new Promise((resolve, reject) => {
773
+ control.once('error', reject);
774
+ control.listen(pool.healthPort, () => {
775
+ control.removeListener('error', reject);
776
+ log.info(`pool control server listening on :${pool.healthPort}`);
777
+ resolve();
778
+ });
779
+ });
780
+ try {
781
+ await new Promise((resolve) => {
782
+ if (signal.aborted) {
783
+ resolve();
784
+ return;
785
+ }
786
+ signal.addEventListener('abort', () => resolve(), { once: true });
787
+ });
788
+ }
789
+ finally {
790
+ await assign.drain();
791
+ await new Promise((resolve) => control.close(() => resolve()));
792
+ log.info('pool pod shut down');
793
+ }
794
+ }
795
+ /** Process entrypoint: wire SIGINT/SIGTERM → abort, then run the pod to drain.
796
+ * Mode is selected by which token is present: PRLL_ASSIGNMENT_TOKEN → assigned
797
+ * (cold-create), else PRLL_POOL_TOKEN → warm pool. The controller's pod spec
798
+ * injects exactly one. */
799
+ async function main() {
800
+ const log = createLogger('browser-pod');
801
+ const abort = new AbortController();
802
+ const onSignal = (sig) => {
803
+ log.info(`received ${sig} — initiating shutdown`);
804
+ abort.abort();
805
+ };
806
+ process.on('SIGINT', () => onSignal('SIGINT'));
807
+ process.on('SIGTERM', () => onSignal('SIGTERM'));
808
+ try {
809
+ if (process.env.PRLL_ASSIGNMENT_TOKEN?.trim()) {
810
+ await runBrowserPod(process.env, abort.signal, log);
811
+ }
812
+ else if (process.env.PRLL_POOL_TOKEN?.trim()) {
813
+ await runPoolPod(process.env, abort.signal, log);
814
+ }
815
+ else {
816
+ throw new Error('browser-pod: set PRLL_ASSIGNMENT_TOKEN (assigned pod) or PRLL_POOL_TOKEN (warm pool pod)');
817
+ }
818
+ }
819
+ catch (err) {
820
+ log.error(`fatal: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
821
+ process.exitCode = 1;
822
+ }
823
+ }
824
+ // Run only when invoked as the entrypoint (bundled bin), not when imported by a
825
+ // test. We are the entry iff import.meta.url points at the same file as argv[1].
826
+ // CRUCIAL: resolve argv[1] through realpathSync first. The container runs this via
827
+ // the npm `parall-browser-pod` bin, which is a SYMLINK — Node sets argv[1] to the
828
+ // symlink path but resolves import.meta.url to the real file, so a raw comparison is
829
+ // always false and main() never runs (the pod then exits 0 immediately, doing
830
+ // nothing). realpathSync collapses the symlink so the entry check holds; when this
831
+ // module is merely imported (tests) argv[1] is the test runner, so it stays false.
832
+ const entry = process.argv[1];
833
+ if (entry && import.meta.url === pathToFileURL(realpathSync(entry)).href) {
834
+ void main();
835
+ }