@parall/daemon 1.35.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.
- package/bundle/manifest.json +13 -9
- package/bundle/parall-browser-pod.js +40665 -0
- package/bundle/parall-claude-agent.js +409 -12
- package/bundle/parall-codex-agent.js +409 -12
- package/bundle/parall-daemon.js +299 -29
- package/dist/browser-pod.d.ts +294 -0
- package/dist/browser-pod.d.ts.map +1 -0
- package/dist/browser-pod.js +765 -0
- package/dist/clip-runtime/browser-state-store.d.ts +157 -0
- package/dist/clip-runtime/browser-state-store.d.ts.map +1 -0
- package/dist/clip-runtime/browser-state-store.js +370 -0
- package/dist/clip-runtime/browser-viewer-streamer.d.ts +35 -1
- package/dist/clip-runtime/browser-viewer-streamer.d.ts.map +1 -1
- package/dist/clip-runtime/browser-viewer-streamer.js +97 -1
- package/dist/clip-runtime/clip-provider.d.ts +56 -0
- package/dist/clip-runtime/clip-provider.d.ts.map +1 -1
- package/dist/clip-runtime/clip-provider.js +141 -17
- package/dist/supervisor.d.ts +0 -1
- package/dist/supervisor.d.ts.map +1 -1
- package/dist/supervisor.js +13 -13
- package/package.json +11 -8
|
@@ -0,0 +1,157 @@
|
|
|
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
|
+
export interface BrowserStateLogger {
|
|
34
|
+
info(msg: string): void;
|
|
35
|
+
warn(msg: string): void;
|
|
36
|
+
error(msg: string): void;
|
|
37
|
+
}
|
|
38
|
+
/** buildStateKey is the SSOT key layout, mirrored by the Go controller. */
|
|
39
|
+
export declare function buildStateKey(orgId: string, profileId: string): string;
|
|
40
|
+
/** Thrown by a StateBackend.put when a conditional write precondition fails (S3
|
|
41
|
+
* 412). The store treats it as "lost the CAS race" and aborts the checkpoint. */
|
|
42
|
+
export declare class PreconditionFailedError extends Error {
|
|
43
|
+
constructor(message?: string);
|
|
44
|
+
}
|
|
45
|
+
export interface HeadResult {
|
|
46
|
+
etag: string;
|
|
47
|
+
metadata: Record<string, string>;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* StateBackend is the narrow object-store surface the store needs, so the CAS +
|
|
51
|
+
* checksum logic is unit-testable against an in-memory fake without the real SDK.
|
|
52
|
+
* S3Backend is the live implementation.
|
|
53
|
+
*/
|
|
54
|
+
export interface StateBackend {
|
|
55
|
+
/** Returns object metadata + ETag, or null when the object does not exist. */
|
|
56
|
+
head(key: string): Promise<HeadResult | null>;
|
|
57
|
+
get(key: string): Promise<Buffer>;
|
|
58
|
+
/** Conditional write: throws PreconditionFailedError on a 412. */
|
|
59
|
+
put(key: string, body: Buffer, opts: {
|
|
60
|
+
metadata: Record<string, string>;
|
|
61
|
+
ifMatch?: string;
|
|
62
|
+
ifNoneMatch?: string;
|
|
63
|
+
}): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
export interface BrowserStateStoreConfig {
|
|
66
|
+
bucket: string;
|
|
67
|
+
region: string;
|
|
68
|
+
orgId: string;
|
|
69
|
+
profileId: string;
|
|
70
|
+
/** Lease activation generation — the monotonic fencing token (design §3.2). */
|
|
71
|
+
generation: number;
|
|
72
|
+
/** bb-browser user-data dir (BB_BROWSER_HOME) to snapshot/restore. */
|
|
73
|
+
homeDir: string;
|
|
74
|
+
/** Optional S3 endpoint (MinIO/dev); empty → AWS S3. */
|
|
75
|
+
endpoint?: string;
|
|
76
|
+
log: BrowserStateLogger;
|
|
77
|
+
}
|
|
78
|
+
export interface CheckpointResult {
|
|
79
|
+
ok: boolean;
|
|
80
|
+
/** skipped because the store is disabled. */
|
|
81
|
+
disabled?: boolean;
|
|
82
|
+
/** aborted: a newer generation already holds the object (we are fenced). */
|
|
83
|
+
fenced?: boolean;
|
|
84
|
+
/** aborted: conditional-write CAS race (another writer won). */
|
|
85
|
+
raced?: boolean;
|
|
86
|
+
/** skipped: a periodic checkpoint whose content is unchanged since the last upload. */
|
|
87
|
+
unchanged?: boolean;
|
|
88
|
+
error?: unknown;
|
|
89
|
+
}
|
|
90
|
+
export interface HydrateResult {
|
|
91
|
+
hydrated: boolean;
|
|
92
|
+
disabled?: boolean;
|
|
93
|
+
checksumMismatch?: boolean;
|
|
94
|
+
error?: unknown;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* BrowserStateStore orchestrates hydrate/checkpoint over a StateBackend. All
|
|
98
|
+
* checkpoints (periodic + final) are serialized through a single promise chain so
|
|
99
|
+
* a drain's final checkpoint can never race an in-flight periodic one.
|
|
100
|
+
*/
|
|
101
|
+
export declare class BrowserStateStore {
|
|
102
|
+
private readonly cfg;
|
|
103
|
+
private readonly backend;
|
|
104
|
+
private readonly key;
|
|
105
|
+
private chain;
|
|
106
|
+
private lastUploadedSha;
|
|
107
|
+
constructor(cfg: BrowserStateStoreConfig, backend?: StateBackend);
|
|
108
|
+
enabled(): boolean;
|
|
109
|
+
/**
|
|
110
|
+
* Download + verify + extract the profile's snapshot into homeDir. MUST run
|
|
111
|
+
* before bb-browser can launch (the pod calls it at startup, before the provider
|
|
112
|
+
* stream opens). Never throws: any failure (no object, checksum mismatch,
|
|
113
|
+
* network/cred error) degrades to a fresh start, since a read failure should not
|
|
114
|
+
* keep the browser from coming up.
|
|
115
|
+
*/
|
|
116
|
+
hydrate(): Promise<HydrateResult>;
|
|
117
|
+
/**
|
|
118
|
+
* Snapshot homeDir to S3. `final` marks the drain (on-close) checkpoint — the
|
|
119
|
+
* last best-effort flush before the pod exits. Serialized via the chain so
|
|
120
|
+
* periodic and final checkpoints never overlap. Never throws — returns a result
|
|
121
|
+
* the caller logs; a failure is bounded loss, not enforced (simplified durability:
|
|
122
|
+
* no controller-side verify or auto-repair).
|
|
123
|
+
*/
|
|
124
|
+
checkpoint(opts: {
|
|
125
|
+
final: boolean;
|
|
126
|
+
}): Promise<CheckpointResult>;
|
|
127
|
+
private doCheckpoint;
|
|
128
|
+
}
|
|
129
|
+
/** sha256 hex of a buffer — the snapshot integrity checksum. */
|
|
130
|
+
export declare function sha256(buf: Buffer): string;
|
|
131
|
+
/**
|
|
132
|
+
* archiveDir tars a directory (excluding lock/cache cruft) then gzips it in-memory.
|
|
133
|
+
* Uses the system `tar` (present in the browser-docker image and on dev machines —
|
|
134
|
+
* no extra npm dep) for the tar, and node:zlib for gzip. Gzip is done by zlib (not
|
|
135
|
+
* `tar -z`) deliberately: zlib's gzip header has a zeroed mtime, so identical
|
|
136
|
+
* directory content produces byte-identical archives — which lets a periodic
|
|
137
|
+
* checkpoint skip a redundant upload when nothing changed. Buffering in memory is
|
|
138
|
+
* fine for browser state (cookies/localStorage/IndexedDB are small); streaming
|
|
139
|
+
* multipart upload is a fast-follow if profiles grow large.
|
|
140
|
+
*/
|
|
141
|
+
export declare function archiveDir(dir: string): Promise<Buffer>;
|
|
142
|
+
/** extractArchive ungzips then untars a buffer into destDir (created if missing). */
|
|
143
|
+
export declare function extractArchive(buf: Buffer, destDir: string): Promise<void>;
|
|
144
|
+
/**
|
|
145
|
+
* A 403 from HeadObject. A profile-scoped pod deliberately lacks s3:ListBucket (the
|
|
146
|
+
* per-profile tenant-isolation boundary), and S3 answers a MISSING object with 403
|
|
147
|
+
* (not 404) when the caller can't list the bucket — it won't reveal existence. The
|
|
148
|
+
* pod still holds s3:GetObject on its own prefix, so an existing object HEADs as 200;
|
|
149
|
+
* a 403 there therefore means "no object", not a real denial. Treating it as
|
|
150
|
+
* not-found lets a brand-new profile hydrate fresh AND still checkpoint. This can
|
|
151
|
+
* never overwrite good state: an existing object never 403s (it 200s), and a
|
|
152
|
+
* genuinely bad/expired credential 403s the subsequent PutObject too, so the
|
|
153
|
+
* checkpoint fails — bounded loss, logged at the pod (simplified durability: no
|
|
154
|
+
* controller-side verify or auto-repair).
|
|
155
|
+
*/
|
|
156
|
+
export declare function isAccessDenied(err: unknown): boolean;
|
|
157
|
+
//# sourceMappingURL=browser-state-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"browser-state-store.d.ts","sourceRoot":"","sources":["../../src/clip-runtime/browser-state-store.ts"],"names":[],"mappings":"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"}
|
|
@@ -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
|
+
}
|
|
@@ -29,9 +29,25 @@ export interface ViewerStreamerHost {
|
|
|
29
29
|
port: number;
|
|
30
30
|
}>;
|
|
31
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;
|
|
32
47
|
export declare class BrowserViewerStreamer {
|
|
33
48
|
private readonly host;
|
|
34
49
|
private readonly streamers;
|
|
50
|
+
private readonly kickedSessions;
|
|
35
51
|
private stopping;
|
|
36
52
|
constructor(host: ViewerStreamerHost);
|
|
37
53
|
/**
|
|
@@ -41,6 +57,19 @@ export declare class BrowserViewerStreamer {
|
|
|
41
57
|
* MachineBrowserProfileViewerData.
|
|
42
58
|
*/
|
|
43
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;
|
|
44
73
|
/**
|
|
45
74
|
* stream.start — spawn a FRESH bb-viewer for this profile (killing any prior
|
|
46
75
|
* one), resolve the profile's account-scoped page-target CDP ws URL, and run
|
|
@@ -177,7 +206,12 @@ export declare class BrowserViewerStreamer {
|
|
|
177
206
|
* the inner result, throwing the error message on failure.
|
|
178
207
|
*/
|
|
179
208
|
private streamerCommand;
|
|
180
|
-
/**
|
|
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
|
+
*/
|
|
181
215
|
killProfileStreamer(profileId: string): void;
|
|
182
216
|
/**
|
|
183
217
|
* Tear down every profile streamer and refuse new spawns (daemon stop).
|
|
@@ -1 +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,qBAAa,qBAAqB;
|
|
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"}
|