@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,370 @@
1
+ /**
2
+ * browser-state-store.ts — S3 externalization of a hosted browser profile's
3
+ * bb-browser state (design hosted-browser-multitenant §3.2, PR4).
4
+ *
5
+ * A hosted browser pod is ephemeral: its Chromium user-data dir (BB_BROWSER_HOME /
6
+ * `homeDir`, where cookies / localStorage / IndexedDB persist) lives on a pod
7
+ * `emptyDir` that vanishes on restart. To make the profile durable across pod
8
+ * restarts we snapshot that dir to S3, so a pod HYDRATES on start and CHECKPOINTS
9
+ * periodically + on drain. Layout (SSOT shared with the Go controller's
10
+ * state_store.go): `s3://{bucket}/browser-state/{org_id}/{profile_id}/state.tgz`.
11
+ *
12
+ * Correctness under pod churn (the hard part):
13
+ * - Integrity: each object carries a sha256 + the generation in metadata. Hydrate
14
+ * verifies the checksum; a mismatch is treated as "no state" (start fresh),
15
+ * never a crash.
16
+ * - Fencing (anti-clobber): a stale/fenced pod (older activation generation) must
17
+ * NOT overwrite a newer pod's state. checkpoint() reads the current object's
18
+ * generation; if it is newer than ours we abort. The write is additionally a
19
+ * conditional PUT (IfMatch on the read ETag / IfNoneMatch on create) so a CAS
20
+ * race fails closed (412 → abort).
21
+ *
22
+ * Graceful degradation: with no bucket configured (feature not provisioned) the
23
+ * store is DISABLED — hydrate/checkpoint are no-ops and the pod runs stateless.
24
+ * Read/credential failures never crash the pod; they degrade to "fresh start" on
25
+ * hydrate and "checkpoint failed" (logged) on write. Durability is best-effort +
26
+ * on-close (design §3.2 "simplified durability"): a failed checkpoint is bounded
27
+ * loss (the delta since the last successful checkpoint), logged at the pod — there
28
+ * is no controller-side final-checkpoint verify or auto-repair.
29
+ *
30
+ * This module is imported ONLY by the browser-pod entrypoint, so @aws-sdk/client-s3
31
+ * lands only in the parall-browser-pod bundle, never the BYOC daemon bundle.
32
+ */
33
+ import { spawn } from 'node:child_process';
34
+ import { createHash } from 'node:crypto';
35
+ import { mkdirSync } from 'node:fs';
36
+ import { gunzipSync, gzipSync } from 'node:zlib';
37
+ import { GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client, } from '@aws-sdk/client-s3';
38
+ /** S3 object user-metadata keys. Lowercase; the Go controller reads them
39
+ * case-insensitively (state_store.go metaValue). SSOT — keep in lockstep. */
40
+ const META_GENERATION = 'generation';
41
+ const META_SHA256 = 'sha256';
42
+ const META_FINAL = 'final';
43
+ const STATE_KEY_PREFIX = 'browser-state';
44
+ const STATE_OBJECT = 'state.tgz';
45
+ /**
46
+ * Chromium user-data cruft excluded from the snapshot: singleton locks (must never
47
+ * be restored — Chrome recreates them, and a stale lock blocks startup) and
48
+ * regenerable caches (large, never login state). Login state — Cookies, Local
49
+ * Storage, IndexedDB, Local State, Login Data — is deliberately NOT excluded.
50
+ */
51
+ const ARCHIVE_EXCLUDES = [
52
+ 'SingletonLock',
53
+ 'SingletonCookie',
54
+ 'SingletonSocket',
55
+ 'lockfile',
56
+ '*/Cache/*',
57
+ '*/Code Cache/*',
58
+ '*/GPUCache/*',
59
+ '*/ShaderCache/*',
60
+ '*/GrShaderCache/*',
61
+ '*/DawnCache/*',
62
+ '*/component_crx_cache/*',
63
+ '*/Crashpad/*',
64
+ ];
65
+ /** buildStateKey is the SSOT key layout, mirrored by the Go controller. */
66
+ export function buildStateKey(orgId, profileId) {
67
+ return `${STATE_KEY_PREFIX}/${orgId}/${profileId}/${STATE_OBJECT}`;
68
+ }
69
+ /** Thrown by a StateBackend.put when a conditional write precondition fails (S3
70
+ * 412). The store treats it as "lost the CAS race" and aborts the checkpoint. */
71
+ export class PreconditionFailedError extends Error {
72
+ constructor(message = 'precondition failed') {
73
+ super(message);
74
+ this.name = 'PreconditionFailedError';
75
+ }
76
+ }
77
+ /**
78
+ * BrowserStateStore orchestrates hydrate/checkpoint over a StateBackend. All
79
+ * checkpoints (periodic + final) are serialized through a single promise chain so
80
+ * a drain's final checkpoint can never race an in-flight periodic one.
81
+ */
82
+ export class BrowserStateStore {
83
+ cfg;
84
+ backend;
85
+ key;
86
+ chain = Promise.resolve();
87
+ lastUploadedSha = null;
88
+ constructor(cfg, backend) {
89
+ this.cfg = cfg;
90
+ this.key = buildStateKey(cfg.orgId, cfg.profileId);
91
+ // Injected backend wins (tests). Otherwise build a live S3 backend iff a
92
+ // bucket is configured; no bucket → disabled (stateless pod).
93
+ this.backend = backend ?? (cfg.bucket ? new S3Backend(buildS3Client(cfg), cfg.bucket) : null);
94
+ }
95
+ enabled() {
96
+ return this.backend !== null;
97
+ }
98
+ /**
99
+ * Download + verify + extract the profile's snapshot into homeDir. MUST run
100
+ * before bb-browser can launch (the pod calls it at startup, before the provider
101
+ * stream opens). Never throws: any failure (no object, checksum mismatch,
102
+ * network/cred error) degrades to a fresh start, since a read failure should not
103
+ * keep the browser from coming up.
104
+ */
105
+ async hydrate() {
106
+ if (!this.backend) {
107
+ this.cfg.log.info('browser-state: disabled (no bucket) — fresh start, stateless');
108
+ return { hydrated: false, disabled: true };
109
+ }
110
+ try {
111
+ const head = await this.backend.head(this.key);
112
+ if (!head) {
113
+ this.cfg.log.info(`browser-state: no prior snapshot at ${this.key} — fresh start`);
114
+ return { hydrated: false };
115
+ }
116
+ const buf = await this.backend.get(this.key);
117
+ const expected = head.metadata[META_SHA256];
118
+ const actual = sha256(buf);
119
+ if (!expected) {
120
+ // The object format REQUIRES a sha256 in metadata. A snapshot without it is
121
+ // unverifiable (corrupt / non-canonical / partial write) — do NOT extract
122
+ // unchecked content into homeDir. Treat as not-hydratable (fresh start) and,
123
+ // like a mismatch, do not overwrite the existing object (see
124
+ // shouldCheckpointAfterHydrate).
125
+ this.cfg.log.warn(`browser-state: snapshot at ${this.key} is missing the ${META_SHA256} metadata — cannot verify, ignoring snapshot, fresh start`);
126
+ return { hydrated: false, checksumMismatch: true };
127
+ }
128
+ if (expected !== actual) {
129
+ this.cfg.log.warn(`browser-state: checksum mismatch (expected ${expected.slice(0, 12)}…, got ${actual.slice(0, 12)}…) — ignoring snapshot, fresh start`);
130
+ return { hydrated: false, checksumMismatch: true };
131
+ }
132
+ mkdirSync(this.cfg.homeDir, { recursive: true });
133
+ await extractArchive(buf, this.cfg.homeDir);
134
+ this.lastUploadedSha = actual;
135
+ this.cfg.log.info(`browser-state: hydrated ${buf.length} bytes from ${this.key} (gen=${head.metadata[META_GENERATION] ?? '?'})`);
136
+ return { hydrated: true };
137
+ }
138
+ catch (err) {
139
+ this.cfg.log.warn(`browser-state: hydrate failed, fresh start: ${errMsg(err)}`);
140
+ return { hydrated: false, error: err };
141
+ }
142
+ }
143
+ /**
144
+ * Snapshot homeDir to S3. `final` marks the drain (on-close) checkpoint — the
145
+ * last best-effort flush before the pod exits. Serialized via the chain so
146
+ * periodic and final checkpoints never overlap. Never throws — returns a result
147
+ * the caller logs; a failure is bounded loss, not enforced (simplified durability:
148
+ * no controller-side verify or auto-repair).
149
+ */
150
+ checkpoint(opts) {
151
+ const run = this.chain.then(() => this.doCheckpoint(opts));
152
+ // Keep the chain alive regardless of this checkpoint's outcome.
153
+ this.chain = run.then(() => undefined, () => undefined);
154
+ return run;
155
+ }
156
+ async doCheckpoint({ final }) {
157
+ if (!this.backend)
158
+ return { ok: false, disabled: true };
159
+ try {
160
+ // Fencing read: if S3 already holds a NEWER generation, we are a stale/fenced
161
+ // pod and must not clobber it.
162
+ const head = await this.backend.head(this.key);
163
+ if (head) {
164
+ const currentGen = Number.parseInt(head.metadata[META_GENERATION] ?? '', 10);
165
+ if (Number.isFinite(currentGen) && currentGen > this.cfg.generation) {
166
+ this.cfg.log.warn(`browser-state: FENCED — S3 generation ${currentGen} > ours ${this.cfg.generation}; aborting checkpoint (this pod is superseded)`);
167
+ return { ok: false, fenced: true };
168
+ }
169
+ }
170
+ const body = await archiveDir(this.cfg.homeDir);
171
+ const sha = sha256(body);
172
+ // Skip a redundant PERIODIC upload when nothing changed; a FINAL (drain)
173
+ // checkpoint always writes — the on-close flush is unconditional so the last
174
+ // state is guaranteed committed before the pod exits, even if the bytes match
175
+ // the last periodic upload. (The `final` metadata marker is informational only:
176
+ // under simplified durability nothing reads it back — there is no controller-
177
+ // side release-time guard.)
178
+ if (!final && this.lastUploadedSha !== null && this.lastUploadedSha === sha) {
179
+ return { ok: true, unchanged: true };
180
+ }
181
+ const metadata = {
182
+ [META_GENERATION]: String(this.cfg.generation),
183
+ [META_SHA256]: sha,
184
+ [META_FINAL]: String(final),
185
+ };
186
+ try {
187
+ await this.backend.put(this.key, body, head ? { metadata, ifMatch: head.etag } : { metadata, ifNoneMatch: '*' });
188
+ }
189
+ catch (err) {
190
+ if (err instanceof PreconditionFailedError || isPreconditionFailed(err)) {
191
+ this.cfg.log.warn('browser-state: checkpoint lost the conditional-write race (precondition failed); aborting — another pod holds the object');
192
+ return { ok: false, raced: true };
193
+ }
194
+ throw err;
195
+ }
196
+ this.lastUploadedSha = sha;
197
+ this.cfg.log.info(`browser-state: checkpoint ok (gen=${this.cfg.generation} final=${final} bytes=${body.length})`);
198
+ return { ok: true };
199
+ }
200
+ catch (err) {
201
+ this.cfg.log.error(`browser-state: checkpoint failed: ${errMsg(err)}`);
202
+ return { ok: false, error: err };
203
+ }
204
+ }
205
+ }
206
+ /** S3Backend is the live StateBackend over @aws-sdk/client-s3. */
207
+ class S3Backend {
208
+ client;
209
+ bucket;
210
+ constructor(client, bucket) {
211
+ this.client = client;
212
+ this.bucket = bucket;
213
+ }
214
+ async head(key) {
215
+ try {
216
+ const r = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
217
+ return { etag: r.ETag ?? '', metadata: lowerKeys(r.Metadata ?? {}) };
218
+ }
219
+ catch (err) {
220
+ // A profile-scoped pod has no s3:ListBucket, so S3 answers a missing object
221
+ // with 403 rather than 404. Treat both as "no object" — see isAccessDenied.
222
+ if (isNotFound(err) || isAccessDenied(err))
223
+ return null;
224
+ throw err;
225
+ }
226
+ }
227
+ async get(key) {
228
+ const r = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
229
+ const body = r.Body;
230
+ if (!body?.transformToByteArray) {
231
+ throw new Error('S3 GetObject returned no readable body');
232
+ }
233
+ return Buffer.from(await body.transformToByteArray());
234
+ }
235
+ async put(key, body, opts) {
236
+ try {
237
+ await this.client.send(new PutObjectCommand({
238
+ Bucket: this.bucket,
239
+ Key: key,
240
+ Body: body,
241
+ Metadata: opts.metadata,
242
+ ContentType: 'application/gzip',
243
+ // Server-side encryption (design §3.2). SSE-S3 (AES256) is universally
244
+ // available on AWS; bucket default encryption makes this explicit, not
245
+ // conflicting.
246
+ ServerSideEncryption: 'AES256',
247
+ ...(opts.ifMatch ? { IfMatch: opts.ifMatch } : {}),
248
+ ...(opts.ifNoneMatch ? { IfNoneMatch: opts.ifNoneMatch } : {}),
249
+ }));
250
+ }
251
+ catch (err) {
252
+ if (isPreconditionFailed(err))
253
+ throw new PreconditionFailedError(errMsg(err));
254
+ throw err;
255
+ }
256
+ }
257
+ }
258
+ function buildS3Client(cfg) {
259
+ // Credentials come from the default provider chain — the controller injects
260
+ // short-lived, profile-scoped STS creds as AWS_* env. forcePathStyle is needed
261
+ // for MinIO/dev endpoints.
262
+ return new S3Client({
263
+ region: cfg.region || undefined,
264
+ ...(cfg.endpoint ? { endpoint: cfg.endpoint, forcePathStyle: true } : {}),
265
+ });
266
+ }
267
+ /** sha256 hex of a buffer — the snapshot integrity checksum. */
268
+ export function sha256(buf) {
269
+ return createHash('sha256').update(buf).digest('hex');
270
+ }
271
+ /**
272
+ * archiveDir tars a directory (excluding lock/cache cruft) then gzips it in-memory.
273
+ * Uses the system `tar` (present in the browser-docker image and on dev machines —
274
+ * no extra npm dep) for the tar, and node:zlib for gzip. Gzip is done by zlib (not
275
+ * `tar -z`) deliberately: zlib's gzip header has a zeroed mtime, so identical
276
+ * directory content produces byte-identical archives — which lets a periodic
277
+ * checkpoint skip a redundant upload when nothing changed. Buffering in memory is
278
+ * fine for browser state (cookies/localStorage/IndexedDB are small); streaming
279
+ * multipart upload is a fast-follow if profiles grow large.
280
+ */
281
+ export async function archiveDir(dir) {
282
+ const args = ['-cf', '-', '-C', dir];
283
+ for (const ex of ARCHIVE_EXCLUDES)
284
+ args.push(`--exclude=${ex}`);
285
+ args.push('.');
286
+ const tar = await runTar(args, undefined);
287
+ return gzipSync(tar);
288
+ }
289
+ /** extractArchive ungzips then untars a buffer into destDir (created if missing). */
290
+ export async function extractArchive(buf, destDir) {
291
+ mkdirSync(destDir, { recursive: true });
292
+ await runTar(['-xf', '-', '-C', destDir], gunzipSync(buf));
293
+ }
294
+ /**
295
+ * runTar spawns the system tar, optionally feeding stdin, and resolves stdout as a
296
+ * Buffer. Rejects on spawn error or non-zero exit (with captured stderr) so a
297
+ * partial/failed archive can never be silently uploaded or extracted.
298
+ */
299
+ function runTar(args, stdin) {
300
+ return new Promise((resolve, reject) => {
301
+ const child = spawn('tar', args, { stdio: ['pipe', 'pipe', 'pipe'] });
302
+ const out = [];
303
+ const errOut = [];
304
+ let settled = false;
305
+ const fail = (err) => {
306
+ if (settled)
307
+ return;
308
+ settled = true;
309
+ reject(err);
310
+ };
311
+ child.once('error', fail); // ENOENT (no tar) etc.
312
+ child.stdout.on('data', (c) => out.push(c));
313
+ child.stderr.on('data', (c) => errOut.push(c));
314
+ child.once('close', (code) => {
315
+ if (settled)
316
+ return;
317
+ settled = true;
318
+ if (code === 0) {
319
+ resolve(Buffer.concat(out));
320
+ }
321
+ else {
322
+ reject(new Error(`tar exited ${code}: ${Buffer.concat(errOut).toString('utf8').trim()}`));
323
+ }
324
+ });
325
+ if (stdin !== undefined) {
326
+ child.stdin.on('error', fail); // EPIPE if tar dies early
327
+ child.stdin.end(stdin);
328
+ }
329
+ else {
330
+ child.stdin.end();
331
+ }
332
+ });
333
+ }
334
+ /** Lowercase metadata keys so lookups are case-insensitive regardless of how the
335
+ * SDK/backend canonicalizes them. */
336
+ function lowerKeys(m) {
337
+ const out = {};
338
+ for (const [k, v] of Object.entries(m))
339
+ out[k.toLowerCase()] = v;
340
+ return out;
341
+ }
342
+ /** A 404 from HeadObject/GetObject — the object does not exist. */
343
+ function isNotFound(err) {
344
+ const e = err;
345
+ return e?.name === 'NotFound' || e?.name === 'NoSuchKey' || e?.$metadata?.httpStatusCode === 404;
346
+ }
347
+ /**
348
+ * A 403 from HeadObject. A profile-scoped pod deliberately lacks s3:ListBucket (the
349
+ * per-profile tenant-isolation boundary), and S3 answers a MISSING object with 403
350
+ * (not 404) when the caller can't list the bucket — it won't reveal existence. The
351
+ * pod still holds s3:GetObject on its own prefix, so an existing object HEADs as 200;
352
+ * a 403 there therefore means "no object", not a real denial. Treating it as
353
+ * not-found lets a brand-new profile hydrate fresh AND still checkpoint. This can
354
+ * never overwrite good state: an existing object never 403s (it 200s), and a
355
+ * genuinely bad/expired credential 403s the subsequent PutObject too, so the
356
+ * checkpoint fails — bounded loss, logged at the pod (simplified durability: no
357
+ * controller-side verify or auto-repair).
358
+ */
359
+ export function isAccessDenied(err) {
360
+ const e = err;
361
+ return e?.name === 'AccessDenied' || e?.$metadata?.httpStatusCode === 403;
362
+ }
363
+ /** A 412 from a conditional write — the IfMatch/IfNoneMatch precondition failed. */
364
+ function isPreconditionFailed(err) {
365
+ const e = err;
366
+ return e?.name === 'PreconditionFailed' || e?.$metadata?.httpStatusCode === 412;
367
+ }
368
+ function errMsg(err) {
369
+ return err instanceof Error ? (err.stack ?? err.message) : String(err);
370
+ }
@@ -0,0 +1,222 @@
1
+ /** Optional managed TURN relay config carried on a `stream.start` command. */
2
+ export interface ViewerTurnConfig {
3
+ url: string;
4
+ username?: string;
5
+ credential?: string;
6
+ }
7
+ /**
8
+ * The slice of BrowserProfileManager the viewer streamer depends on: the
9
+ * account-scoped bb-browser /command path and the daemon lifecycle helpers.
10
+ * One-directional — the manager constructs the streamer; the streamer never
11
+ * imports the manager.
12
+ */
13
+ export interface ViewerStreamerHost {
14
+ log: {
15
+ info(msg: string): void;
16
+ warn(msg: string): void;
17
+ error(msg: string): void;
18
+ };
19
+ /** Account-scoped bb-browser `/command` (BrowserProfileManager.sendCommand). */
20
+ sendBrowserCommand(request: Record<string, unknown> & {
21
+ method: string;
22
+ account?: string;
23
+ }): Promise<Record<string, unknown>>;
24
+ ensureAccount(account: string): Promise<boolean>;
25
+ withDaemonRecovery<T>(operation: () => Promise<T>, label: string): Promise<T>;
26
+ /** cdpHost/cdpPort from the bb-browser daemon `GET /status`. */
27
+ cdpEndpoint(): Promise<{
28
+ host: string;
29
+ port: number;
30
+ }>;
31
+ }
32
+ /**
33
+ * bb-viewer's /command control plane is UNAUTHENTICATED, so on the shared agents
34
+ * cluster its bind host MUST be loopback — a 0.0.0.0 / pod-IP / typo'd value would
35
+ * silently re-open it cross-tenant. Only these exact loopback forms are accepted;
36
+ * anything else is rejected (fail fast) at spawn.
37
+ */
38
+ export declare function isLoopbackBindHost(host: string): boolean;
39
+ /**
40
+ * Whether a kick targets the CURRENT live streamer (so its process is torn down). A
41
+ * kick with no explicit target hits whoever is live; an explicit target tears down the
42
+ * current streamer ONLY when it matches — a stale/replayed session_id must not kill a
43
+ * newer live viewer (it is only recorded for straggler rejection). currentSessionId
44
+ * undefined (no live streamer) → never a current-kill.
45
+ */
46
+ export declare function kickHitsCurrentSession(requested: string | undefined, currentSessionId: string | undefined): boolean;
47
+ export declare class BrowserViewerStreamer {
48
+ private readonly host;
49
+ private readonly streamers;
50
+ private readonly kickedSessions;
51
+ private stopping;
52
+ constructor(host: ViewerStreamerHost);
53
+ /**
54
+ * Handle a viewer control command for a profile. Throws on any failure (the
55
+ * supervisor maps a throw to a `{error:{message}}` reply); never returns a
56
+ * partial result. `data.input` / `data.turn` / `data.session_id` come from
57
+ * MachineBrowserProfileViewerData.
58
+ */
59
+ handleViewerCommand(profileId: string, sessionId: string, command: string, input?: Record<string, unknown>, turn?: ViewerTurnConfig): Promise<Record<string, unknown>>;
60
+ /**
61
+ * kick — server-side per-viewer disconnect (design §3.6 PR7). Terminates ONE
62
+ * viewer's WebRTC session: stop its bb-viewer streamer (killing the process
63
+ * tears down the peer connection + datachannel) and reject the kicked session's
64
+ * subsequent commands — while bb-browser + Chromium (the agent's live browser)
65
+ * keep running. This is explicitly DISTINCT from stopping the pod / profile,
66
+ * which would kill the agent's browser too; a viewer session is a sub-session of
67
+ * the profile lease, so a kick does NOT release the lease.
68
+ *
69
+ * Targets `input.session_id` if given, else the current streamer's session.
70
+ * Idempotent: kicking with no live streamer still records the kicked session.
71
+ */
72
+ private viewerKick;
73
+ /**
74
+ * stream.start — spawn a FRESH bb-viewer for this profile (killing any prior
75
+ * one), resolve the profile's account-scoped page-target CDP ws URL, and run
76
+ * the streamer's `connect` command. Records sessionId on the streamer entry.
77
+ */
78
+ private viewerStreamStart;
79
+ /**
80
+ * stream.answer — apply the client's WebRTC answer to the profile's streamer.
81
+ * Rejects if the streamer's sessionId no longer matches (a stale viewer
82
+ * session from a superseded stream.start).
83
+ */
84
+ private viewerStreamAnswer;
85
+ /**
86
+ * stream.close — best-effort stop the streamer, then kill + remove it. A
87
+ * stale session's close (independent HTTP legs can arrive out of order after
88
+ * a retry) must NOT tear down a successor session's live streamer, so a
89
+ * mismatched sessionId is ignored. An EMPTY sessionId gets the same stale
90
+ * treatment — a client closed mid-handshake never received a session id, and
91
+ * its unscoped close racing the next session's stream.start would kill that
92
+ * session's live streamer (React StrictMode's dev double-mount exercises
93
+ * exactly this ordering). The only unscoped close is an explicit
94
+ * `{force:true}` input, reserved for a server-initiated admin disconnect.
95
+ */
96
+ private viewerStreamClose;
97
+ /**
98
+ * stream.switch — point the existing streamer at a different account-scoped
99
+ * tab. input {tab} is the bb-browser short tab id / target id. Guarded by
100
+ * sessionId like stream.answer: a superseded session must not steer the
101
+ * current streamer, and an EMPTY sessionId is rejected the same way — every
102
+ * real caller has its session id by the time it can switch (it arrives with
103
+ * the stream.start response), so a session-less switch is by definition not
104
+ * the current viewer.
105
+ */
106
+ private viewerStreamSwitch;
107
+ /**
108
+ * close — close an account tab via bb-browser, then, if the closed tab was
109
+ * the one being streamed, re-point the streamer at the profile's next active
110
+ * tab. Without the re-switch the streamer stays bound to a destroyed CDP
111
+ * target and the viewer goes black. Returns `streamed_tab_id` when a
112
+ * re-switch happened so the client can adopt the new active tab.
113
+ */
114
+ private viewerCloseTab;
115
+ /**
116
+ * Bring a CDP page target to the foreground via Chrome's DevTools HTTP
117
+ * endpoint (`/json/activate/{targetId}`, same host:port as the CDP ws). Chrome
118
+ * does not composite backgrounded tabs, so `Page.startScreencast` on a
119
+ * background tab yields zero frames (black viewer). Best-effort: a failure
120
+ * must not block the stream. host:port + targetId are parsed from the cdpUrl
121
+ * (`ws://host:port/devtools/page/<targetId>`) to avoid an extra /status call.
122
+ */
123
+ private bringTargetToFront;
124
+ /**
125
+ * Forward a navigation command (tab_list/tab_new/open/reload/back/forward/
126
+ * close) to bb-browser scoped to the profile's account. This lets the human
127
+ * in the live viewer navigate and handle OAuth popups directly. Reuses the
128
+ * same account-scoped sendCommand path the agent invokes use.
129
+ *
130
+ * SECURITY — the `account` field alone does NOT scope tab addressing:
131
+ * bb-browser's ensurePageTarget resolves a client-supplied tab/tabId against
132
+ * EVERY page target in the shared Chrome (all accounts), and tab_list
133
+ * returns every tab with `account` as a mere annotation (verified in
134
+ * @pinixai/bb-browser-pro@0.15.0 dist/daemon.js ensurePageTarget/tab_list).
135
+ * On a multi-profile host that would let one profile's viewer list,
136
+ * navigate, and close other profiles' logged-in tabs. So before forwarding:
137
+ * (a) any tab ref must resolve through accountTabTargetId, which throws on
138
+ * tabs the profile's account does not own; (b) ref-less tab-addressed
139
+ * commands are pinned to the profile's own active tab instead of
140
+ * bb-browser's account-blind global current tab; (c) tab_list output is
141
+ * filtered to the profile's own rows; and (d) open/tab_new URLs must be
142
+ * http(s)/about:blank — file:// or chrome:// would read the host
143
+ * filesystem / browser internals straight into the video stream.
144
+ */
145
+ private viewerForwardNav;
146
+ /**
147
+ * Resolve `ws://<cdpHost>:<cdpPort>/devtools/page/<targetId>` for a PAGE
148
+ * target owned by THIS profile's account — NOT bb-browser's global current
149
+ * tab (which `getCurrentTabCdpUrl`/`getTabCdpUrl` in the bundled daemon use
150
+ * via `cdp.currentTargetId`, an account-blind global; see daemon.js
151
+ * line ~10389/10410). When `tabRef` is given (stream.switch) we resolve that
152
+ * specific account tab; otherwise the profile's active page tab.
153
+ *
154
+ * Evidence from the installed @pinixai/bb-browser-pro@0.15.0 dist/daemon.js:
155
+ * - GET /status (handleStatus, line ~1659-1668) returns `cdpHost` + `cdpPort`
156
+ * — the CDP host/port the daemon's Chrome listens on.
157
+ * - The CDP page ws URL format is `ws://<cdp.host>:<cdp.port>/devtools/page/<page.id>`
158
+ * (getCurrentTabCdpUrl, line ~10392), where `page.id` is the full CDP
159
+ * targetId (getTargets maps `t.targetId` → `.id`, line ~2461-2466).
160
+ * - tab_list (command handler, line ~1041-1059) returns per-tab
161
+ * `{ tabId: t.id, tab: <shortId>, account: <accountName>, url, ... }`, so
162
+ * `tabId` is exactly the full targetId for the devtools URL and `account`
163
+ * is the per-tab attribution we filter on (reused by findAccountTabOnHost).
164
+ */
165
+ private resolveAccountPageCdpUrl;
166
+ /**
167
+ * Full CDP targetId of the profile account's active page tab (the owned tab
168
+ * Chrome marks active, else the first owned tab). If the account owns no
169
+ * page tab yet, open `about:blank` for it first (mirrors the agent-invoke
170
+ * path's "always give a fresh account an owned tab" rule).
171
+ */
172
+ private accountActivePageTargetId;
173
+ /**
174
+ * Full CDP targetId (`tabId`) of the account's active page tab: the owned tab
175
+ * Chrome marks `active` when it belongs to this account, else the first owned
176
+ * tab (the global active flag is account-blind — another profile's tab may
177
+ * hold it, which must not leak here).
178
+ */
179
+ private firstAccountPageTargetId;
180
+ /**
181
+ * Full CDP targetId for a specific account tab, addressed by the bb-browser
182
+ * short tab id (`tab`) or full target id (`tabId`). Used by stream.switch.
183
+ */
184
+ private accountTabTargetId;
185
+ /** `tab_list {account}` rows, typed to the fields we read (tabId/tab/account/active). */
186
+ private accountTabs;
187
+ /**
188
+ * Spawn a bb-viewer streamer for the profile and wait until healthy. Binary
189
+ * is PRLL_BB_VIEWER_BIN (our image sets it to /usr/local/bin/bb-viewer),
190
+ * defaulting to `bb-viewer` on PATH — we NEVER download it. A free port is
191
+ * allocated per streamer. Stores the entry in `this.streamers` once healthy.
192
+ */
193
+ private spawnStreamer;
194
+ /**
195
+ * SIGTERM with a SIGKILL escalation: a bb-viewer that hangs or ignores TERM
196
+ * would otherwise outlive its teardown holding the port + CDP session (the
197
+ * shell-level pattern-kill only exists on the hosted pod, not BYOC). The
198
+ * escalation timer is unref'd so it never holds the daemon open; it is
199
+ * cleared on exit. Caveat: a daemon process that exits immediately after
200
+ * shutdown() abandons the timer — acceptable, the TERM was still sent.
201
+ */
202
+ private killChild;
203
+ /**
204
+ * POST a command to the profile's bb-viewer `/command` endpoint. Parses the
205
+ * `{result}` / `{error:{message}}` envelope (bb-viewer api.go) and returns
206
+ * the inner result, throwing the error message on failure.
207
+ */
208
+ private streamerCommand;
209
+ /**
210
+ * Kill + remove the profile's bb-viewer streamer, if any. Idempotent. Also
211
+ * clears any kick marker for the profile: a (re)kill establishes a clean
212
+ * streamer slot, so a subsequent stream.start starts un-kicked. viewerKick
213
+ * re-records the kicked session AFTER calling this.
214
+ */
215
+ killProfileStreamer(profileId: string): void;
216
+ /**
217
+ * Tear down every profile streamer and refuse new spawns (daemon stop).
218
+ * Idempotent.
219
+ */
220
+ shutdown(): void;
221
+ }
222
+ //# sourceMappingURL=browser-viewer-streamer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser-viewer-streamer.d.ts","sourceRoot":"","sources":["../../src/clip-runtime/browser-viewer-streamer.ts"],"names":[],"mappings":"AAsCA,8EAA8E;AAC9E,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAcD;;;;;GAKG;AACH,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE;QAAE,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IACpF,gFAAgF;IAChF,kBAAkB,CAChB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,GACtE,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACpC,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACjD,kBAAkB,CAAC,CAAC,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IAC9E,gEAAgE;IAChE,WAAW,IAAI,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACxD;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAExD;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,gBAAgB,EAAE,MAAM,GAAG,SAAS,GACnC,OAAO,CAGT;AAED,qBAAa,qBAAqB;IAiBpB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAbjC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAoC;IAO9D,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA6B;IAI5D,OAAO,CAAC,QAAQ,CAAS;gBAEI,IAAI,EAAE,kBAAkB;IAErD;;;;;OAKG;IACG,mBAAmB,CACvB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,EACf,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,IAAI,CAAC,EAAE,gBAAgB,GACtB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAuCnC;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,UAAU;IAwBlB;;;;OAIG;YACW,iBAAiB;IA+C/B;;;;OAIG;YACW,kBAAkB;IAgBhC;;;;;;;;;;OAUG;YACW,iBAAiB;IAuB/B;;;;;;;;OAQG;YACW,kBAAkB;IAuBhC;;;;;;OAMG;YACW,cAAc;IAiC5B;;;;;;;OAOG;YACW,kBAAkB;IAqBhC;;;;;;;;;;;;;;;;;;;;OAoBG;YACW,gBAAgB;IA6C9B;;;;;;;;;;;;;;;;;;OAkBG;YACW,wBAAwB;IAetC;;;;;OAKG;YACW,yBAAyB;IAevC;;;;;OAKG;YACW,wBAAwB;IAatC;;;OAGG;YACW,kBAAkB;IAehC,yFAAyF;YAC3E,WAAW;IAgBzB;;;;;OAKG;YACW,aAAa;IAiH3B;;;;;;;OAOG;IACH,OAAO,CAAC,SAAS;IAqBjB;;;;OAIG;YACW,eAAe;IA+B7B;;;;;OAKG;IACH,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAQ5C;;;OAGG;IACH,QAAQ,IAAI,IAAI;CAMjB"}