@parall/daemon 1.34.0 → 1.36.0

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,765 @@
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
+ };
118
+ }
119
+ /**
120
+ * Resolve a warm pod's identity-less config. Unlike resolveBrowserPodConfig this
121
+ * requires PRLL_POOL_TOKEN (not PRLL_ASSIGNMENT_TOKEN) and NO profile/org — those
122
+ * arrive later via /assign. PRLL_CLIP_RPC is injected at pool creation (stable per
123
+ * cluster) so the assigned path needs no extra wiring.
124
+ */
125
+ export function resolvePoolPodConfig(env = process.env) {
126
+ const poolToken = env.PRLL_POOL_TOKEN?.trim() || '';
127
+ const podId = env.PRLL_POD_ID?.trim() || '';
128
+ const clipRpcUrl = env.PRLL_CLIP_RPC?.trim() || '';
129
+ const missing = [];
130
+ if (!poolToken)
131
+ missing.push('PRLL_POOL_TOKEN');
132
+ if (!podId)
133
+ missing.push('PRLL_POD_ID');
134
+ if (!clipRpcUrl)
135
+ missing.push('PRLL_CLIP_RPC');
136
+ if (missing.length > 0) {
137
+ throw new Error(`browser-pod (pool): missing required env: ${missing.join(', ')}`);
138
+ }
139
+ if (!poolToken.startsWith(ASSIGNMENT_TOKEN_PREFIX)) {
140
+ throw new Error(`browser-pod (pool): PRLL_POOL_TOKEN is not a platform token (expected "${ASSIGNMENT_TOKEN_PREFIX}" prefix).`);
141
+ }
142
+ return {
143
+ poolToken,
144
+ podId,
145
+ clipRpcUrl,
146
+ healthPort: parsePort(env.PRLL_HEALTH_PORT, DEFAULT_HEALTH_PORT),
147
+ };
148
+ }
149
+ /**
150
+ * Validate + normalize an /assign body (already JSON-parsed). Rejects a missing /
151
+ * non-pba_ assignment token, a missing profile/lease, and PARTIAL S3 creds (a
152
+ * half-set would build a misconfigured S3 client — worse than stateless). State is
153
+ * optional (stateless profile); when present it must be complete.
154
+ */
155
+ export function parseAssignRequest(body) {
156
+ if (typeof body !== 'object' || body === null) {
157
+ return { ok: false, error: 'body must be a JSON object' };
158
+ }
159
+ const b = body;
160
+ const str = (v) => (typeof v === 'string' ? v.trim() : '');
161
+ const assignmentToken = str(b.assignment_token);
162
+ const profileId = str(b.profile_id);
163
+ const orgId = str(b.org_id);
164
+ const leaseId = str(b.lease_id);
165
+ const generation = typeof b.generation === 'number' && Number.isFinite(b.generation) ? b.generation : 0;
166
+ if (!assignmentToken)
167
+ return { ok: false, error: 'assignment_token required' };
168
+ if (!assignmentToken.startsWith(ASSIGNMENT_TOKEN_PREFIX)) {
169
+ return { ok: false, error: `assignment_token must be a "${ASSIGNMENT_TOKEN_PREFIX}" token` };
170
+ }
171
+ if (!profileId)
172
+ return { ok: false, error: 'profile_id required' };
173
+ if (!leaseId)
174
+ return { ok: false, error: 'lease_id required' };
175
+ let state;
176
+ if (b.state !== undefined && b.state !== null) {
177
+ if (typeof b.state !== 'object')
178
+ return { ok: false, error: 'state must be an object' };
179
+ const s = b.state;
180
+ const bucket = str(s.bucket);
181
+ const accessKeyId = str(s.access_key_id);
182
+ const secretAccessKey = str(s.secret_access_key);
183
+ const sessionToken = str(s.session_token);
184
+ if (!bucket || !accessKeyId || !secretAccessKey || !sessionToken) {
185
+ return {
186
+ ok: false,
187
+ error: 'state requires bucket + access_key_id + secret_access_key + session_token',
188
+ };
189
+ }
190
+ state = {
191
+ bucket,
192
+ region: str(s.region),
193
+ endpoint: str(s.endpoint) || undefined,
194
+ accessKeyId,
195
+ secretAccessKey,
196
+ sessionToken,
197
+ };
198
+ }
199
+ return { ok: true, value: { assignmentToken, profileId, orgId, leaseId, generation, state } };
200
+ }
201
+ /**
202
+ * Apply an /assign payload to `env` (process.env in prod) so the assigned path
203
+ * reads identity + S3 creds EXACTLY as the controller injects them into a cold
204
+ * pod's spec. The AWS_* creds must land in process.env: the S3 client
205
+ * (browser-state-store buildS3Client) reads the default AWS provider chain.
206
+ */
207
+ export function applyAssignmentEnv(env, req) {
208
+ env.PRLL_ASSIGNMENT_TOKEN = req.assignmentToken;
209
+ env.PRLL_PROFILE_ID = req.profileId;
210
+ if (req.orgId)
211
+ env.PRLL_ORG_ID = req.orgId;
212
+ if (req.state) {
213
+ env.AWS_ACCESS_KEY_ID = req.state.accessKeyId;
214
+ env.AWS_SECRET_ACCESS_KEY = req.state.secretAccessKey;
215
+ env.AWS_SESSION_TOKEN = req.state.sessionToken;
216
+ if (req.state.region)
217
+ env.AWS_REGION = req.state.region;
218
+ env.PRLL_BROWSER_STATE_BUCKET = req.state.bucket;
219
+ env.PRLL_STATE_GENERATION = String(req.generation);
220
+ if (req.state.endpoint)
221
+ env.PRLL_BROWSER_STATE_ENDPOINT = req.state.endpoint;
222
+ }
223
+ }
224
+ /** Constant-time bearer comparison against the injected pool token. The pod cannot
225
+ * verify the JWT signature (no shared secret), so it string-compares the bearer to
226
+ * its own PRLL_POOL_TOKEN — the controller reads that same value back from the pod
227
+ * spec when it POSTs /assign. */
228
+ function poolTokenMatches(bearer, expected) {
229
+ if (!bearer)
230
+ return false;
231
+ const a = Buffer.from(bearer);
232
+ const b = Buffer.from(expected);
233
+ return a.length === b.length && timingSafeEqual(a, b);
234
+ }
235
+ function errString(err) {
236
+ return err instanceof Error ? (err.stack ?? err.message) : String(err);
237
+ }
238
+ /**
239
+ * Build the warm pod's /assign controller. The cardinal rules:
240
+ * - auth: bearer MUST equal the pool token (constant-time).
241
+ * - single-assignment: a warm pod serves ONE profile for life — a repeat for the
242
+ * same lease is idempotent (200), a different lease is a conflict (409). The
243
+ * lease is claimed SYNCHRONOUSLY so a duplicate/concurrent POST conflicts.
244
+ * - eventual readiness: identity is applied then the real runtime is started in
245
+ * the BACKGROUND (hydrate + connect take seconds); /readyz is the gate, exactly
246
+ * as a cold pod whose Create returns fast and becomes Ready later.
247
+ * `startRuntime` is injected so the assign flow is testable without a real hub.
248
+ */
249
+ export function createPoolAssign(opts) {
250
+ let runtime = null;
251
+ let leaseId = null;
252
+ const handler = async (bearer, body) => {
253
+ if (!poolTokenMatches(bearer, opts.poolToken)) {
254
+ return { status: 401, message: 'unauthorized' };
255
+ }
256
+ const parsed = parseAssignRequest(body);
257
+ if (!parsed.ok)
258
+ return { status: 400, message: parsed.error };
259
+ const req = parsed.value;
260
+ if (leaseId !== null) {
261
+ if (leaseId === req.leaseId)
262
+ return { status: 200, message: 'already assigned (idempotent)' };
263
+ return { status: 409, message: 'pod already assigned to a different lease' };
264
+ }
265
+ leaseId = req.leaseId; // claim synchronously: a duplicate/concurrent POST now conflicts
266
+ applyAssignmentEnv(opts.env, req);
267
+ opts.log.info(`assigned: profile=${req.profileId} lease=${req.leaseId} gen=${req.generation} state=${req.state ? 'on' : 'off'}`);
268
+ void (async () => {
269
+ try {
270
+ runtime = await opts.startRuntime(opts.env);
271
+ opts.log.info(`runtime started for lease=${req.leaseId}`);
272
+ }
273
+ catch (err) {
274
+ opts.log.error(`runtime start failed for lease=${req.leaseId}: ${errString(err)} — /readyz stays 503; controller releases on the registration deadline`);
275
+ }
276
+ })();
277
+ return { status: 200, message: 'assignment accepted' };
278
+ };
279
+ return {
280
+ handler,
281
+ isReady: () => runtime?.isReady() ?? false,
282
+ drain: async () => {
283
+ if (runtime)
284
+ await runtime.drain();
285
+ },
286
+ assignedLeaseId: () => leaseId,
287
+ };
288
+ }
289
+ /**
290
+ * BrowserPodRuntime owns the pod's readiness state and the ordered teardown. It
291
+ * is deliberately decoupled from the concrete ClipProvider / BrowserProfileManager
292
+ * (only the narrow PodProvider / PodBrowser surfaces) so the readiness gate and
293
+ * drain ORDER are unit-testable without a real browser or hub.
294
+ */
295
+ export class BrowserPodRuntime {
296
+ provider;
297
+ browser;
298
+ log;
299
+ checkpointer;
300
+ checkpointIntervalMs;
301
+ draining = false;
302
+ drainPromise = null;
303
+ checkpointTimer = null;
304
+ constructor(provider, browser, log,
305
+ // Optional S3 state checkpointer (PR4). Absent => stateless pod (no
306
+ // hydrate/checkpoint); the drain order then matches the pre-PR4 contract.
307
+ checkpointer, checkpointIntervalMs = DEFAULT_CHECKPOINT_INTERVAL_MS) {
308
+ this.provider = provider;
309
+ this.browser = browser;
310
+ this.log = log;
311
+ this.checkpointer = checkpointer;
312
+ this.checkpointIntervalMs = checkpointIntervalMs;
313
+ }
314
+ /**
315
+ * Route-readiness: registered to the hub AND not draining. `draining` is checked
316
+ * first so the readiness gate flips false the instant a drain begins — before
317
+ * the (async) ProviderStream teardown completes. The hub fences the stream on
318
+ * the pba_ token + live lease, so isConnected() already encodes "token/lease
319
+ * still valid".
320
+ */
321
+ isReady() {
322
+ return !this.draining && this.provider.isConnected();
323
+ }
324
+ /** Open the ProviderStream, register as the "browser" provider, and start the
325
+ * periodic checkpoint loop (if a checkpointer was supplied). */
326
+ async start() {
327
+ await this.provider.connect();
328
+ this.startCheckpointLoop();
329
+ }
330
+ /** Periodic S3 checkpoints while serving. Best-effort: a checkpoint error is
331
+ * logged but never throws (it must not kill the pod); the timer is unref'd so it
332
+ * never keeps the process alive on its own. */
333
+ startCheckpointLoop() {
334
+ if (!this.checkpointer || this.checkpointIntervalMs <= 0)
335
+ return;
336
+ const cp = this.checkpointer;
337
+ this.checkpointTimer = setInterval(() => {
338
+ if (this.draining)
339
+ return;
340
+ void cp.checkpointPeriodic().catch((err) => {
341
+ this.log.warn(`periodic checkpoint error: ${String(err)}`);
342
+ });
343
+ }, this.checkpointIntervalMs);
344
+ this.checkpointTimer.unref?.();
345
+ }
346
+ /**
347
+ * Graceful drain (SIGTERM / lease release): fail readiness FIRST, stop the
348
+ * periodic checkpoint loop, then tear down the hub stream and the browser, and
349
+ * finally take ONE last checkpoint AFTER bb-browser is stopped (a consistent
350
+ * snapshot of the settled state — design §3.2). Idempotent — a second SIGTERM
351
+ * joins the first.
352
+ */
353
+ async drain() {
354
+ if (this.drainPromise)
355
+ return this.drainPromise;
356
+ this.draining = true; // readiness -> false BEFORE we close the stream
357
+ this.log.info('draining (readiness -> false)');
358
+ if (this.checkpointTimer) {
359
+ clearInterval(this.checkpointTimer);
360
+ this.checkpointTimer = null;
361
+ }
362
+ this.drainPromise = (async () => {
363
+ try {
364
+ await this.provider.disconnect();
365
+ }
366
+ catch (err) {
367
+ this.log.warn(`provider disconnect during drain failed: ${String(err)}`);
368
+ }
369
+ try {
370
+ await this.browser.stop();
371
+ }
372
+ catch (err) {
373
+ this.log.warn(`browser stop during drain failed: ${String(err)}`);
374
+ }
375
+ // Final checkpoint AFTER bb-browser has torn down — Chromium has flushed and
376
+ // released its files, so the snapshot is consistent. A failure here is bounded
377
+ // loss (the delta since the last successful checkpoint), logged below —
378
+ // durability is best-effort + on-close, with no controller-side verify/repair
379
+ // (design §3.2 "simplified durability").
380
+ if (this.checkpointer) {
381
+ try {
382
+ await this.checkpointer.checkpointFinal();
383
+ }
384
+ catch (err) {
385
+ this.log.error(`final checkpoint during drain failed: ${String(err)}`);
386
+ }
387
+ }
388
+ })();
389
+ return this.drainPromise;
390
+ }
391
+ }
392
+ /**
393
+ * Create the liveness/readiness HTTP server. `/livez` is mere process liveness;
394
+ * `/readyz` is route-readiness driven by `isReady`. Mirrors the Go stub's health
395
+ * contract so the controller's probes are runtime-agnostic.
396
+ */
397
+ export function createHealthServer(opts) {
398
+ const server = http.createServer((req, res) => {
399
+ const url = req.url ?? '';
400
+ if (url === '/livez') {
401
+ res.writeHead(opts.isLive() ? 200 : 503).end();
402
+ return;
403
+ }
404
+ if (url === '/readyz') {
405
+ res.writeHead(opts.isReady() ? 200 : 503).end();
406
+ return;
407
+ }
408
+ if (opts.onAssign && url === '/assign' && req.method === 'POST') {
409
+ handleAssignRequest(req, res, opts.onAssign, opts.log);
410
+ return;
411
+ }
412
+ res.writeHead(404).end();
413
+ });
414
+ // Bound header read so a slow/half-open probe connection can't pin a slot.
415
+ server.headersTimeout = 5_000;
416
+ server.requestTimeout = 10_000;
417
+ return server;
418
+ }
419
+ /** Cap the /assign body — the payload is a small JSON blob (token + creds); a
420
+ * larger body is malformed/hostile and must not buffer unbounded. */
421
+ const MAX_ASSIGN_BODY_BYTES = 64 * 1024;
422
+ /** Read + JSON-parse a bounded POST /assign body, dispatch to the handler, and
423
+ * write its outcome. All failures map to an HTTP status (never crash the server). */
424
+ function handleAssignRequest(req, res, onAssign, log) {
425
+ const chunks = [];
426
+ let size = 0;
427
+ let aborted = false;
428
+ const fail = (status) => {
429
+ if (aborted)
430
+ return;
431
+ aborted = true;
432
+ res.writeHead(status).end();
433
+ };
434
+ req.on('data', (c) => {
435
+ if (aborted)
436
+ return;
437
+ size += c.length;
438
+ if (size > MAX_ASSIGN_BODY_BYTES) {
439
+ fail(413);
440
+ req.destroy();
441
+ return;
442
+ }
443
+ chunks.push(c);
444
+ });
445
+ req.on('error', () => fail(400));
446
+ req.on('end', () => {
447
+ if (aborted)
448
+ return;
449
+ let body;
450
+ try {
451
+ body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
452
+ }
453
+ catch {
454
+ res
455
+ .writeHead(400, { 'content-type': 'application/json' })
456
+ .end(JSON.stringify({ ok: false, error: 'invalid JSON' }));
457
+ return;
458
+ }
459
+ void onAssign(bearerFromHeader(req.headers.authorization), body)
460
+ .then((outcome) => {
461
+ res.writeHead(outcome.status, { 'content-type': 'application/json' }).end(JSON.stringify({
462
+ ok: outcome.status >= 200 && outcome.status < 300,
463
+ message: outcome.message,
464
+ }));
465
+ })
466
+ .catch((err) => {
467
+ log.error(`/assign handler error: ${errString(err)}`);
468
+ res
469
+ .writeHead(500, { 'content-type': 'application/json' })
470
+ .end(JSON.stringify({ ok: false }));
471
+ });
472
+ });
473
+ }
474
+ /** Strip a leading "Bearer " from an Authorization header (case-sensitive scheme,
475
+ * matching the hub). Returns the raw credential, or undefined when absent. */
476
+ function bearerFromHeader(authorization) {
477
+ if (!authorization)
478
+ return undefined;
479
+ return authorization.startsWith('Bearer ') ? authorization.slice(7) : authorization;
480
+ }
481
+ /**
482
+ * Build the S3 state store from env, or return null (stateless pod) ONLY when no
483
+ * bucket is configured (the feature is off). When a bucket IS set, state was
484
+ * REQUESTED, so the org id (part of the S3 key + the pod's STS prefix scope) and the
485
+ * activation generation (the single-writer fence) are required: a missing/invalid
486
+ * value is a controller-injection misconfiguration and FAILS CLOSED (throws) rather
487
+ * than silently registering a non-persisted profile — silent stateless serving when
488
+ * state was configured is data loss. The thrown error propagates to pod startup, so
489
+ * the pod never reaches readiness and the controller releases it on the deadline.
490
+ */
491
+ export function buildStateStore(config, env, log) {
492
+ const bucket = env.PRLL_BROWSER_STATE_BUCKET?.trim();
493
+ if (!bucket)
494
+ return null; // no bucket = intentionally stateless
495
+ if (!config.orgId) {
496
+ throw new Error(`browser state enabled (bucket=${bucket}) but PRLL_ORG_ID is empty — refusing to run a non-persisted profile (fail closed)`);
497
+ }
498
+ const genRaw = env.PRLL_STATE_GENERATION?.trim();
499
+ if (!genRaw) {
500
+ // Missing: never silently default to generation 0 — that would un-fence the pod
501
+ // (gen 0 is the lowest, so any real pod clobbers it). State configured but invalid
502
+ // = refuse to run BEFORE the provider opens (this throw propagates to startup).
503
+ throw new Error(`browser state enabled (bucket=${bucket}) but PRLL_STATE_GENERATION is unset — refusing to run a non-persisted profile (fail closed)`);
504
+ }
505
+ const gen = Number(genRaw);
506
+ if (!Number.isSafeInteger(gen) || gen < 0) {
507
+ 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)`);
508
+ }
509
+ return new BrowserStateStore({
510
+ bucket,
511
+ region: env.AWS_REGION?.trim() || env.PRLL_BROWSER_STATE_REGION?.trim() || '',
512
+ endpoint: env.PRLL_BROWSER_STATE_ENDPOINT?.trim() || undefined,
513
+ orgId: config.orgId,
514
+ profileId: config.profileId,
515
+ generation: gen,
516
+ homeDir: config.homeDir,
517
+ log,
518
+ });
519
+ }
520
+ /** Resolve the periodic checkpoint cadence (ms) from env, clamped to ≥1s. */
521
+ export function resolveCheckpointIntervalMs(env) {
522
+ const raw = env.PRLL_BROWSER_STATE_CHECKPOINT_INTERVAL_MS?.trim();
523
+ if (!raw)
524
+ return DEFAULT_CHECKPOINT_INTERVAL_MS;
525
+ const n = Number(raw);
526
+ return Number.isFinite(n) && n >= 1_000 ? n : DEFAULT_CHECKPOINT_INTERVAL_MS;
527
+ }
528
+ /**
529
+ * Whether it is SAFE to checkpoint after a hydrate. Checkpoint ONLY when the prior
530
+ * snapshot was loaded (hydrated) or there was genuinely none (a new profile —
531
+ * head returned null, so no flags set). If a snapshot EXISTED but could not be
532
+ * loaded (checksum mismatch, or a download/extract/HEAD error), checkpointing must
533
+ * be disabled: an empty homeDir would otherwise overwrite the good state, and the
534
+ * generation fence permits it (this pod's gen is legitimately higher than the
535
+ * snapshot's). Returns false in that case so the pod runs checkpoint-less, leaving
536
+ * the existing snapshot untouched rather than clobbering it (simplified durability:
537
+ * the prior state is preserved in place — there is no controller-side repair).
538
+ */
539
+ export function shouldCheckpointAfterHydrate(result) {
540
+ return !(result.checksumMismatch === true || result.error !== undefined);
541
+ }
542
+ /** Adapt a BrowserStateStore to the narrow PodStateCheckpointer the runtime drives. */
543
+ function makeCheckpointer(store) {
544
+ return {
545
+ checkpointPeriodic: async () => {
546
+ await store.checkpoint({ final: false });
547
+ },
548
+ checkpointFinal: async () => {
549
+ await store.checkpoint({ final: true });
550
+ },
551
+ };
552
+ }
553
+ /**
554
+ * Wire the real clip-runtime stack for a hosted browser pod from a resolved config
555
+ * and return the {@link BrowserPodRuntime} — WITHOUT creating the health server,
556
+ * binding a port, or parking on a signal. Splitting this out lets the warm-pool
557
+ * path (runPoolPod) reuse the EXACT assigned-pod wiring after a `/assign` arrives,
558
+ * while keeping a single control/health server bound on healthPort (the pool path
559
+ * already owns it). hydrate() runs here — before the provider opens — because
560
+ * bb-browser launches lazily on first invoke, so the snapshot must be on disk first.
561
+ */
562
+ export async function preparePodRuntime(config, env, log) {
563
+ // Real browser runtime — starts bb-browser + Chromium lazily on first invoke,
564
+ // ensures the single profile's account, persists under homeDir (BB_BROWSER_HOME).
565
+ const browser = new BrowserProfileManager({ homeDir: config.homeDir, log });
566
+ // The process manager hosts no installable clips in pod mode — its only job is
567
+ // to advertise the synthetic "browser" capability (getProviderClips appends it
568
+ // because a BrowserProfileManager is present) and route inbound browser invokes
569
+ // to bb-browser. The clip/data dirs are required but unused (no subprocess).
570
+ const clipManager = new ClipProcessManager({
571
+ clipsDir: path.join(config.stateDir, 'clips'),
572
+ dataDir: path.join(config.stateDir, 'clip-data'),
573
+ browserProfileManager: browser,
574
+ });
575
+ // Hosted live-viewer handler (design §3.4): clip-service relays viewer commands
576
+ // down the ProviderStream and the pod replies over it. Serialize them — they
577
+ // mutate the single profile's bb-viewer streamer + account state, so a client
578
+ // retrying stream.start must not interleave with the in-flight one (the BYOC
579
+ // supervisor serializes per profile for the same reason). One pod = one
580
+ // profile, so a single chain suffices.
581
+ let viewerChain = Promise.resolve();
582
+ const onViewerCommand = (cmd) => {
583
+ const run = viewerChain
584
+ .catch(() => { }) // keep the chain moving past a prior failure
585
+ .then(() => browser.handleViewerCommand(cmd.profileId, cmd.sessionId, cmd.command, cmd.input, cmd.turn));
586
+ viewerChain = run.catch(() => { });
587
+ return run;
588
+ };
589
+ // ClipProvider passes authKey straight through as the ProviderStream bearer —
590
+ // it does not assume mck_ — so the pba_ assignment token authenticates the pod.
591
+ // providerName is cosmetic for routing (the hub keys the session by the token's
592
+ // profile_id), but we use the pod id so logs/registration map to this pod.
593
+ const provider = new ClipProvider({
594
+ serviceUrl: config.clipRpcUrl,
595
+ authKey: config.assignmentToken,
596
+ orgId: config.orgId,
597
+ providerName: config.podId,
598
+ clipManager,
599
+ log,
600
+ onViewerCommand,
601
+ });
602
+ // S3 state externalization (PR4, design §3.2). Disabled (stateless pod) when no
603
+ // bucket is configured. HYDRATE here — before the provider opens and any invoke
604
+ // can land — because bb-browser launches lazily on first invoke, so the snapshot
605
+ // must already be on disk. hydrate() never throws (a read failure → fresh start).
606
+ const stateStore = buildStateStore(config, env, log);
607
+ let checkpointer;
608
+ if (stateStore) {
609
+ const result = await stateStore.hydrate();
610
+ // Data-loss guard: only attach a checkpointer when it is safe to overwrite the
611
+ // snapshot (we loaded it, or there was genuinely none). If a snapshot existed
612
+ // but could not be hydrated, run checkpoint-less to preserve it — see
613
+ // shouldCheckpointAfterHydrate.
614
+ if (shouldCheckpointAfterHydrate(result)) {
615
+ checkpointer = makeCheckpointer(stateStore);
616
+ }
617
+ else {
618
+ log.error('browser-state: prior snapshot present but un-hydratable — running WITHOUT checkpoint to preserve the existing snapshot in place');
619
+ }
620
+ }
621
+ else {
622
+ log.info('browser state externalization disabled (no PRLL_BROWSER_STATE_BUCKET) — running stateless');
623
+ }
624
+ return new BrowserPodRuntime(provider, browser, log, checkpointer, resolveCheckpointIntervalMs(env));
625
+ }
626
+ /**
627
+ * Wire the real clip-runtime stack for a hosted browser pod and run until the
628
+ * abort signal fires (SIGTERM). Returns after the ordered drain completes. This is
629
+ * the ASSIGNED path: PRLL_ASSIGNMENT_TOKEN + profile are already in env (the
630
+ * controller's cold-create pod spec). The warm-pool path (runPoolPod) reaches the
631
+ * same runtime via preparePodRuntime once a `/assign` delivers the identity.
632
+ */
633
+ export async function runBrowserPod(env, signal, log) {
634
+ const config = resolveBrowserPodConfig(env);
635
+ log.info(`starting: profile=${config.profileId} pod=${config.podId} ` +
636
+ `org=${config.orgId || '(unset)'} clip_rpc=${config.clipRpcUrl} health_port=${config.healthPort}`);
637
+ const runtime = await preparePodRuntime(config, env, log);
638
+ const health = createHealthServer({
639
+ isLive: () => true,
640
+ isReady: () => runtime.isReady(),
641
+ log,
642
+ });
643
+ await new Promise((resolve, reject) => {
644
+ health.once('error', reject);
645
+ health.listen(config.healthPort, () => {
646
+ health.removeListener('error', reject);
647
+ log.info(`health server listening on :${config.healthPort}`);
648
+ resolve();
649
+ });
650
+ });
651
+ try {
652
+ await runtime.start();
653
+ log.info('provider stream opened — awaiting hub registration');
654
+ // Run until SIGTERM. ClipProvider keeps the stream alive (heartbeat +
655
+ // reconnect) internally; we just park here until told to drain.
656
+ await new Promise((resolve) => {
657
+ if (signal.aborted) {
658
+ resolve();
659
+ return;
660
+ }
661
+ signal.addEventListener('abort', () => resolve(), { once: true });
662
+ });
663
+ }
664
+ finally {
665
+ await runtime.drain();
666
+ await new Promise((resolve) => health.close(() => resolve()));
667
+ log.info('shut down');
668
+ }
669
+ }
670
+ /**
671
+ * Run a WARM pod: bind ONE control/health server (livez/readyz/assign) on
672
+ * healthPort and park — no Chromium, no profile, no hub registration — until a
673
+ * `POST /assign` delivers an identity, at which point the pod transitions into the
674
+ * exact assigned-pod runtime (preparePodRuntime, hydrating S3 like a cold pod). On
675
+ * SIGTERM, drain the started runtime (if any) then close the server. The single
676
+ * server is the reason runPoolPod reuses preparePodRuntime rather than runBrowserPod
677
+ * (which would bind its own health port).
678
+ */
679
+ export async function runPoolPod(env, signal, log) {
680
+ const pool = resolvePoolPodConfig(env);
681
+ log.info(`pool mode: pod=${pool.podId} clip_rpc=${pool.clipRpcUrl} health_port=${pool.healthPort} — awaiting /assign`);
682
+ const assign = createPoolAssign({
683
+ poolToken: pool.poolToken,
684
+ env,
685
+ log,
686
+ // The assigned path, unchanged: env now carries the identity + S3 creds, so
687
+ // resolveBrowserPodConfig + preparePodRuntime behave exactly as a cold pod.
688
+ startRuntime: async (e) => {
689
+ const config = resolveBrowserPodConfig(e);
690
+ log.info(`pool→assigned: profile=${config.profileId} pod=${config.podId} org=${config.orgId || '(unset)'}`);
691
+ const runtime = await preparePodRuntime(config, e, log);
692
+ await runtime.start();
693
+ return runtime;
694
+ },
695
+ });
696
+ const control = createHealthServer({
697
+ isLive: () => true,
698
+ isReady: () => assign.isReady(),
699
+ log,
700
+ onAssign: assign.handler,
701
+ });
702
+ await new Promise((resolve, reject) => {
703
+ control.once('error', reject);
704
+ control.listen(pool.healthPort, () => {
705
+ control.removeListener('error', reject);
706
+ log.info(`pool control server listening on :${pool.healthPort}`);
707
+ resolve();
708
+ });
709
+ });
710
+ try {
711
+ await new Promise((resolve) => {
712
+ if (signal.aborted) {
713
+ resolve();
714
+ return;
715
+ }
716
+ signal.addEventListener('abort', () => resolve(), { once: true });
717
+ });
718
+ }
719
+ finally {
720
+ await assign.drain();
721
+ await new Promise((resolve) => control.close(() => resolve()));
722
+ log.info('pool pod shut down');
723
+ }
724
+ }
725
+ /** Process entrypoint: wire SIGINT/SIGTERM → abort, then run the pod to drain.
726
+ * Mode is selected by which token is present: PRLL_ASSIGNMENT_TOKEN → assigned
727
+ * (cold-create), else PRLL_POOL_TOKEN → warm pool. The controller's pod spec
728
+ * injects exactly one. */
729
+ async function main() {
730
+ const log = createLogger('browser-pod');
731
+ const abort = new AbortController();
732
+ const onSignal = (sig) => {
733
+ log.info(`received ${sig} — initiating shutdown`);
734
+ abort.abort();
735
+ };
736
+ process.on('SIGINT', () => onSignal('SIGINT'));
737
+ process.on('SIGTERM', () => onSignal('SIGTERM'));
738
+ try {
739
+ if (process.env.PRLL_ASSIGNMENT_TOKEN?.trim()) {
740
+ await runBrowserPod(process.env, abort.signal, log);
741
+ }
742
+ else if (process.env.PRLL_POOL_TOKEN?.trim()) {
743
+ await runPoolPod(process.env, abort.signal, log);
744
+ }
745
+ else {
746
+ throw new Error('browser-pod: set PRLL_ASSIGNMENT_TOKEN (assigned pod) or PRLL_POOL_TOKEN (warm pool pod)');
747
+ }
748
+ }
749
+ catch (err) {
750
+ log.error(`fatal: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
751
+ process.exitCode = 1;
752
+ }
753
+ }
754
+ // Run only when invoked as the entrypoint (bundled bin), not when imported by a
755
+ // test. We are the entry iff import.meta.url points at the same file as argv[1].
756
+ // CRUCIAL: resolve argv[1] through realpathSync first. The container runs this via
757
+ // the npm `parall-browser-pod` bin, which is a SYMLINK — Node sets argv[1] to the
758
+ // symlink path but resolves import.meta.url to the real file, so a raw comparison is
759
+ // always false and main() never runs (the pod then exits 0 immediately, doing
760
+ // nothing). realpathSync collapses the symlink so the entry check holds; when this
761
+ // module is merely imported (tests) argv[1] is the test runner, so it stays false.
762
+ const entry = process.argv[1];
763
+ if (entry && import.meta.url === pathToFileURL(realpathSync(entry)).href) {
764
+ void main();
765
+ }