@ricsam/r5d-browser 0.0.137 → 0.0.138

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.
Files changed (44) hide show
  1. package/README.md +7 -33
  2. package/dist/cjs/package.json +1 -1
  3. package/dist/mjs/browser-input.mjs +56 -1
  4. package/dist/mjs/cli-options.mjs +1 -0
  5. package/dist/mjs/main.mjs +25 -396
  6. package/dist/mjs/package.json +1 -1
  7. package/dist/mjs/personal-diagnostics.mjs +24 -0
  8. package/dist/mjs/personal-main.mjs +159 -0
  9. package/dist/mjs/personal-port-forward.mjs +183 -0
  10. package/dist/mjs/personal-relay-client.mjs +33 -0
  11. package/dist/mjs/personal-relay-shutdown.mjs +46 -0
  12. package/dist/mjs/runtime/browser-executor.mjs +11 -0
  13. package/dist/mjs/runtime/chromium-factory.mjs +101 -0
  14. package/dist/mjs/runtime/ipc.mjs +128 -0
  15. package/dist/mjs/runtime/isolated-root.mjs +53 -0
  16. package/dist/mjs/runtime/keeper.mjs +169 -0
  17. package/dist/mjs/runtime/ledger.mjs +193 -0
  18. package/dist/mjs/runtime/main.mjs +105 -0
  19. package/dist/mjs/runtime/private-paths.mjs +48 -0
  20. package/dist/mjs/runtime/protocol.mjs +72 -0
  21. package/dist/types/browser-input.d.ts +4 -0
  22. package/dist/types/cli-options.d.ts +1 -0
  23. package/dist/types/personal-diagnostics.d.ts +13 -0
  24. package/dist/types/personal-main.d.ts +9 -0
  25. package/dist/types/personal-port-forward.d.ts +45 -0
  26. package/dist/types/personal-relay-client.d.ts +51 -0
  27. package/dist/types/personal-relay-shutdown.d.ts +19 -0
  28. package/dist/types/runtime/chromium-factory.d.ts +25 -0
  29. package/dist/types/runtime/fixtures/counter-main.d.ts +1 -0
  30. package/dist/types/runtime/fixtures/counter-runtime.d.ts +6 -2
  31. package/dist/types/runtime/isolated-root.d.ts +12 -0
  32. package/dist/types/runtime/main.d.ts +20 -13
  33. package/dist/types/runtime/private-paths.d.ts +4 -0
  34. package/package.json +6 -5
  35. package/dist/cjs/browser-input.cjs +0 -87
  36. package/dist/cjs/browser-runtime.cjs +0 -708
  37. package/dist/cjs/chrome-launcher.cjs +0 -701
  38. package/dist/cjs/cli-options.cjs +0 -95
  39. package/dist/cjs/main.cjs +0 -427
  40. package/dist/cjs/password-safe-snapshot.cjs +0 -150
  41. package/dist/cjs/port-forward-manager.cjs +0 -233
  42. package/dist/cjs/upload-store.cjs +0 -136
  43. package/dist/mjs/port-forward-manager.mjs +0 -199
  44. package/dist/types/port-forward-manager.d.ts +0 -23
@@ -0,0 +1,53 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { RuntimeId, canonicalJson } from "@ricsam/r5d-api/runtime-protocol";
5
+ import { assertPrivatePath, makePrivateDirectory, readPrivateFile } from "./private-paths.mjs";
6
+ import { BrowserKeeperError } from "./protocol.mjs";
7
+ function isolatedBrowserRoot(root, installationId, userId) {
8
+ RuntimeId.parse(installationId);
9
+ RuntimeId.parse(userId);
10
+ assertPrivatePath(root, "directory");
11
+ if (Buffer.byteLength(root) > 48) throw new BrowserKeeperError("browser_root_path_too_long");
12
+ const marker = path.join(root, "browser-keeper-root.json");
13
+ const identity = canonicalJson({ version: 1, purpose: "isolated-chromium-keeper", installationId, userId });
14
+ if (!fs.existsSync(marker)) {
15
+ if (fs.readdirSync(root).length !== 0) throw new BrowserKeeperError("root_not_empty");
16
+ const fd = fs.openSync(marker, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 256);
17
+ try {
18
+ fs.writeFileSync(fd, identity);
19
+ fs.fsyncSync(fd);
20
+ } finally {
21
+ fs.closeSync(fd);
22
+ }
23
+ const parent = fs.openSync(root, fs.constants.O_RDONLY);
24
+ try {
25
+ fs.fsyncSync(parent);
26
+ } finally {
27
+ fs.closeSync(parent);
28
+ }
29
+ }
30
+ if (readPrivateFile(marker, 2048, true).toString("utf8") !== identity) throw new BrowserKeeperError("wrong_root_identity");
31
+ const ledger = path.join(root, "ledger");
32
+ const runs = path.join(root, "runs");
33
+ makePrivateDirectory(ledger);
34
+ makePrivateDirectory(runs);
35
+ return { ledger, runs };
36
+ }
37
+ function freshBrowserRun(runs) {
38
+ assertPrivatePath(runs, "directory");
39
+ if (fs.readdirSync(runs).length >= 32) throw new BrowserKeeperError("browser_run_capacity");
40
+ const id = randomUUID();
41
+ const directory = path.join(runs, id);
42
+ fs.mkdirSync(directory, { mode: 448 });
43
+ const profile = path.join(directory, "profile");
44
+ const downloads = path.join(directory, "downloads");
45
+ const uploads = path.join(directory, "uploads");
46
+ const home = path.join(directory, "home");
47
+ for (const child of [profile, downloads, uploads, home]) fs.mkdirSync(child, { mode: 448 });
48
+ return { id, directory, profile, downloads, uploads, home };
49
+ }
50
+ export {
51
+ freshBrowserRun,
52
+ isolatedBrowserRoot
53
+ };
@@ -0,0 +1,169 @@
1
+ import {
2
+ canonicalJson,
3
+ negotiateProtocol,
4
+ operationPayloadHash,
5
+ RuntimeHello,
6
+ RUNTIME_PROTOCOL
7
+ } from "@ricsam/r5d-api/runtime-protocol";
8
+ import { BrowserLedger } from "./ledger.mjs";
9
+ import { BROWSER_KEEPER_CAPABILITY, BrowserKeeperError, BrowserKeeperMessage, BrowserOperation } from "./protocol.mjs";
10
+ class BrowserKeeper {
11
+ hello;
12
+ ledger;
13
+ executor;
14
+ inFlight = /* @__PURE__ */ new Map();
15
+ queues = /* @__PURE__ */ new Map();
16
+ maxInFlight;
17
+ maxPayloadBytes;
18
+ pruneTimer;
19
+ closing = false;
20
+ closePromise;
21
+ storageFailed = false;
22
+ constructor(options) {
23
+ this.hello = RuntimeHello.parse({
24
+ installationId: options.installationId,
25
+ instanceId: options.instanceId,
26
+ releaseId: options.releaseId,
27
+ role: "keeper",
28
+ protocol: { min: RUNTIME_PROTOCOL, max: RUNTIME_PROTOCOL },
29
+ capabilities: [BROWSER_KEEPER_CAPABILITY]
30
+ });
31
+ this.maxInFlight = options.maxInFlight ?? 128;
32
+ this.maxPayloadBytes = options.maxPayloadBytes ?? 2 * 1024 * 1024;
33
+ if (![this.maxInFlight, this.maxPayloadBytes].every((value) => Number.isSafeInteger(value) && value > 0)) {
34
+ throw new BrowserKeeperError("invalid_limits");
35
+ }
36
+ this.ledger = new BrowserLedger(options);
37
+ this.executor = options.executor;
38
+ this.pruneTimer = setInterval(
39
+ () => {
40
+ try {
41
+ this.ledger.prune();
42
+ } catch {
43
+ this.storageFailed = true;
44
+ }
45
+ },
46
+ Math.min(options.retentionMs ?? 6e4, 6e4)
47
+ );
48
+ this.pruneTimer.unref();
49
+ }
50
+ /** Callable versioned boundary. Transport disconnect is deliberately not an AbortSignal. */
51
+ async handle(input, principal) {
52
+ try {
53
+ return await this.handleRequest(input, principal);
54
+ } catch (error) {
55
+ if (error instanceof BrowserKeeperError) throw error;
56
+ this.storageFailed = true;
57
+ throw new BrowserKeeperError("storage_unavailable");
58
+ }
59
+ }
60
+ async handleRequest(input, principal) {
61
+ if (this.closing) throw new BrowserKeeperError("keeper_draining");
62
+ if (this.storageFailed) throw new BrowserKeeperError("storage_unavailable");
63
+ const parsed = BrowserKeeperMessage.safeParse(input);
64
+ if (!parsed.success) throw new BrowserKeeperError("invalid_request");
65
+ const { hello, request } = parsed.data;
66
+ this.ledger.assertIdentity(principal.installationId, principal.userId);
67
+ if (hello.instanceId !== principal.ownerId || hello.role !== "browser-adapter") throw new BrowserKeeperError("unauthorized_peer");
68
+ try {
69
+ negotiateProtocol(this.hello, hello, [BROWSER_KEEPER_CAPABILITY]);
70
+ } catch {
71
+ throw new BrowserKeeperError("incompatible_peer");
72
+ }
73
+ const operation = "operation" in request ? request.operation : null;
74
+ const fence = operation?.fence ?? ("fence" in request ? request.fence : null);
75
+ if (!fence) throw new BrowserKeeperError("invalid_request");
76
+ this.ledger.assertIdentity(fence.installationId, operation?.userId ?? ("userId" in request ? request.userId : ""));
77
+ if (fence.ownerId !== principal.ownerId) throw new BrowserKeeperError("unauthorized_owner");
78
+ if (fence.resourceType === "browser-user" && fence.resourceId !== principal.userId) throw new BrowserKeeperError("wrong_identity");
79
+ this.ledger.prune();
80
+ if (request.method === "claim" || request.method === "renew") {
81
+ this.ledger.claim(fence, request.expiresAt, request.method === "renew");
82
+ return { ok: true };
83
+ }
84
+ if (request.method === "release") {
85
+ this.ledger.release(fence);
86
+ return { ok: true };
87
+ }
88
+ if (!operation) throw new BrowserKeeperError("invalid_request");
89
+ let frozen;
90
+ try {
91
+ const json = canonicalJson(operation);
92
+ if (Buffer.byteLength(json) > this.maxPayloadBytes) throw new BrowserKeeperError("payload_too_large");
93
+ frozen = BrowserOperation.parse(JSON.parse(json));
94
+ } catch (error) {
95
+ if (error instanceof BrowserKeeperError) throw error;
96
+ throw new BrowserKeeperError("invalid_payload");
97
+ }
98
+ const hash = await operationPayloadHash(frozen);
99
+ if (this.closing || this.storageFailed) throw new BrowserKeeperError("keeper_unavailable");
100
+ this.ledger.assertFence(frozen.fence);
101
+ const existing = this.ledger.find(frozen, hash);
102
+ if (request.method === "inspect") return existing;
103
+ if (existing) {
104
+ const pending2 = this.inFlight.get(frozen.operationId);
105
+ return pending2 ? structuredClone(await pending2) : existing;
106
+ }
107
+ if (this.inFlight.size >= this.maxInFlight) throw new BrowserKeeperError("keeper_capacity");
108
+ this.ledger.admit(frozen, hash);
109
+ const queueKey = `${frozen.fence.resourceType}:${frozen.fence.resourceId}`;
110
+ const previous = this.queues.get(queueKey) ?? Promise.resolve();
111
+ const pending = previous.then(() => this.perform(frozen, hash));
112
+ this.inFlight.set(frozen.operationId, pending);
113
+ const tail = pending.then(
114
+ () => void 0,
115
+ () => void 0
116
+ ).then(() => {
117
+ this.inFlight.delete(frozen.operationId);
118
+ if (this.queues.get(queueKey) === tail) this.queues.delete(queueKey);
119
+ });
120
+ this.queues.set(queueKey, tail);
121
+ return structuredClone(await pending);
122
+ }
123
+ async perform(operation, hash) {
124
+ try {
125
+ if (this.storageFailed) throw new BrowserKeeperError("storage_unavailable");
126
+ try {
127
+ this.ledger.assertFence(operation.fence);
128
+ } catch {
129
+ this.ledger.finish(operation.operationId, "cancelled", null, "stale_before_start");
130
+ return this.ledger.find(operation, hash);
131
+ }
132
+ this.ledger.running(operation.operationId);
133
+ let resultJson = null;
134
+ let uncertain = false;
135
+ try {
136
+ resultJson = canonicalJson(await this.executor.execute(operation));
137
+ } catch {
138
+ uncertain = true;
139
+ }
140
+ this.ledger.finish(
141
+ operation.operationId,
142
+ uncertain ? "unknown" : "completed",
143
+ resultJson,
144
+ uncertain ? "executor_uncertain" : void 0
145
+ );
146
+ return this.ledger.find(operation, hash);
147
+ } catch {
148
+ this.storageFailed = true;
149
+ throw new BrowserKeeperError("storage_unavailable");
150
+ }
151
+ }
152
+ /** Maintenance only: adapter disconnection must NOT invoke close or executor disposal. */
153
+ close() {
154
+ if (this.closePromise) return this.closePromise;
155
+ this.closing = true;
156
+ clearInterval(this.pruneTimer);
157
+ return this.closePromise = (async () => {
158
+ await Promise.allSettled(this.inFlight.values());
159
+ try {
160
+ await this.executor.dispose?.();
161
+ } finally {
162
+ this.ledger.close();
163
+ }
164
+ })();
165
+ }
166
+ }
167
+ export {
168
+ BrowserKeeper
169
+ };
@@ -0,0 +1,193 @@
1
+ import { Database } from "bun:sqlite";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { RuntimeId } from "@ricsam/r5d-api/runtime-protocol";
5
+ import { BrowserKeeperError } from "./protocol.mjs";
6
+ class BrowserLedger {
7
+ db;
8
+ now;
9
+ installationId;
10
+ userId;
11
+ retentionMs;
12
+ maxReceiptBytes;
13
+ maxTotalReceiptBytes;
14
+ maxOperations;
15
+ maxClaims;
16
+ maxLeaseMs;
17
+ constructor(options) {
18
+ this.installationId = RuntimeId.parse(options.installationId);
19
+ this.userId = RuntimeId.parse(options.userId);
20
+ this.now = options.now ?? Date.now;
21
+ const positive = (value) => {
22
+ if (!Number.isSafeInteger(value) || value < 1) throw new BrowserKeeperError("invalid_limits");
23
+ return value;
24
+ };
25
+ this.retentionMs = positive(options.retentionMs ?? 24 * 60 * 60 * 1e3);
26
+ this.maxReceiptBytes = positive(options.maxReceiptBytes ?? 16 * 1024 * 1024);
27
+ this.maxTotalReceiptBytes = positive(options.maxTotalReceiptBytes ?? 64 * 1024 * 1024);
28
+ this.maxOperations = positive(options.maxOperations ?? 1e5);
29
+ this.maxClaims = positive(options.maxClaims ?? 4096);
30
+ this.maxLeaseMs = positive(options.maxLeaseMs ?? 5 * 60 * 1e3);
31
+ fs.mkdirSync(options.directory, { recursive: true, mode: 448 });
32
+ const directory = fs.lstatSync(options.directory);
33
+ if (!directory.isDirectory() || directory.isSymbolicLink() || (directory.mode & 63) !== 0 || directory.uid !== process.getuid?.()) {
34
+ throw new BrowserKeeperError("unsafe_state_directory");
35
+ }
36
+ const filename = path.join(options.directory, "operations.sqlite");
37
+ try {
38
+ const fd = fs.openSync(filename, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW, 384);
39
+ fs.closeSync(fd);
40
+ } catch (error) {
41
+ if (error.code !== "EEXIST") throw error;
42
+ }
43
+ for (const file of [filename, `${filename}-journal`, `${filename}-wal`, `${filename}-shm`]) {
44
+ if (!fs.existsSync(file)) {
45
+ try {
46
+ if (fs.lstatSync(file).isSymbolicLink()) throw new BrowserKeeperError("unsafe_ledger_file");
47
+ } catch (error) {
48
+ if (error.code !== "ENOENT") throw error;
49
+ }
50
+ continue;
51
+ }
52
+ const stats = fs.lstatSync(file);
53
+ if (!stats.isFile() || stats.isSymbolicLink() || stats.nlink !== 1 || (stats.mode & 63) !== 0 || stats.uid !== directory.uid) {
54
+ throw new BrowserKeeperError("unsafe_ledger_file");
55
+ }
56
+ }
57
+ this.db = new Database(filename, { strict: true });
58
+ try {
59
+ this.db.exec(
60
+ "PRAGMA busy_timeout=0; PRAGMA locking_mode=EXCLUSIVE; PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA secure_delete=ON;"
61
+ );
62
+ this.db.exec("BEGIN EXCLUSIVE; COMMIT;");
63
+ this.db.exec(`
64
+ CREATE TABLE IF NOT EXISTS identity (singleton INTEGER PRIMARY KEY CHECK(singleton=1), installation_id TEXT NOT NULL, user_id TEXT NOT NULL, version INTEGER NOT NULL);
65
+ CREATE TABLE IF NOT EXISTS claims (
66
+ resource_type TEXT NOT NULL, resource_id TEXT NOT NULL, owner_id TEXT NOT NULL,
67
+ epoch INTEGER NOT NULL, expires_at INTEGER NOT NULL, released INTEGER NOT NULL DEFAULT 0,
68
+ PRIMARY KEY(resource_type, resource_id)
69
+ );
70
+ CREATE TABLE IF NOT EXISTS operations (
71
+ operation_id TEXT PRIMARY KEY, payload_hash TEXT NOT NULL, resource_type TEXT NOT NULL,
72
+ resource_id TEXT NOT NULL, owner_id TEXT NOT NULL, epoch INTEGER NOT NULL, state TEXT NOT NULL,
73
+ result_json TEXT, result_bytes INTEGER NOT NULL DEFAULT 0, reason TEXT, accepted_at INTEGER NOT NULL, finished_at INTEGER
74
+ );
75
+ `);
76
+ this.db.query("INSERT OR IGNORE INTO identity VALUES (1, ?, ?, 1)").run(this.installationId, this.userId);
77
+ const identity = this.db.query("SELECT * FROM identity WHERE singleton=1").get();
78
+ if (identity.installation_id !== this.installationId || identity.user_id !== this.userId || identity.version !== 1) {
79
+ throw new BrowserKeeperError("wrong_ledger_identity");
80
+ }
81
+ this.db.query("UPDATE operations SET state='unknown', reason='keeper_restarted', finished_at=? WHERE state IN ('accepted','running')").run(this.now());
82
+ this.prune();
83
+ } catch (error) {
84
+ this.db.close();
85
+ throw error;
86
+ }
87
+ }
88
+ assertIdentity(installationId, userId) {
89
+ if (installationId !== this.installationId || userId !== this.userId) throw new BrowserKeeperError("wrong_identity");
90
+ }
91
+ claimRow(fence) {
92
+ return this.db.query("SELECT owner_id, epoch, expires_at, released FROM claims WHERE resource_type=? AND resource_id=?").get(fence.resourceType, fence.resourceId);
93
+ }
94
+ assertFence(fence) {
95
+ if (fence.installationId !== this.installationId) throw new BrowserKeeperError("wrong_identity");
96
+ const claim = this.claimRow(fence);
97
+ if (!claim || claim.released || claim.owner_id !== fence.ownerId || claim.epoch !== fence.epoch || claim.expires_at <= this.now()) {
98
+ throw new BrowserKeeperError("stale_fence");
99
+ }
100
+ }
101
+ claim(fence, expiresAt, renew = false) {
102
+ const now = this.now();
103
+ if (expiresAt <= now || expiresAt - now > this.maxLeaseMs) throw new BrowserKeeperError("invalid_lease");
104
+ const previous = this.claimRow(fence);
105
+ if (renew) this.assertFence(fence);
106
+ else if (previous && (fence.epoch < previous.epoch || fence.epoch === previous.epoch && (previous.owner_id !== fence.ownerId || previous.released || previous.expires_at <= now)))
107
+ throw new BrowserKeeperError("stale_fence");
108
+ if (previous && fence.epoch === previous.epoch && expiresAt < previous.expires_at) throw new BrowserKeeperError("lease_regression");
109
+ if (!previous && this.count("claims") >= this.maxClaims) throw new BrowserKeeperError("claim_capacity");
110
+ this.db.query(
111
+ `INSERT INTO claims VALUES (?, ?, ?, ?, ?, 0)
112
+ ON CONFLICT(resource_type, resource_id) DO UPDATE SET owner_id=excluded.owner_id, epoch=excluded.epoch, expires_at=excluded.expires_at, released=0`
113
+ ).run(fence.resourceType, fence.resourceId, fence.ownerId, fence.epoch, expiresAt);
114
+ }
115
+ release(fence) {
116
+ this.assertFence(fence);
117
+ this.db.query("UPDATE claims SET released=1 WHERE resource_type=? AND resource_id=?").run(fence.resourceType, fence.resourceId);
118
+ }
119
+ count(table) {
120
+ return this.db.query(`SELECT COUNT(*) AS count FROM ${table}`).get().count;
121
+ }
122
+ find(operation, hash) {
123
+ const row = this.db.query("SELECT * FROM operations WHERE operation_id=?").get(operation.operationId);
124
+ if (!row) return null;
125
+ if (row.payload_hash !== hash || row.resource_type !== operation.fence.resourceType || row.resource_id !== operation.fence.resourceId) {
126
+ throw new BrowserKeeperError("operation_conflict");
127
+ }
128
+ return this.receipt(row);
129
+ }
130
+ admit(operation, hash) {
131
+ if (this.count("operations") >= this.maxOperations) throw new BrowserKeeperError("operation_capacity");
132
+ this.db.query(
133
+ `INSERT INTO operations (operation_id, payload_hash, resource_type, resource_id, owner_id, epoch, state, accepted_at)
134
+ VALUES (?, ?, ?, ?, ?, ?, 'accepted', ?)`
135
+ ).run(
136
+ operation.operationId,
137
+ hash,
138
+ operation.fence.resourceType,
139
+ operation.fence.resourceId,
140
+ operation.fence.ownerId,
141
+ operation.fence.epoch,
142
+ this.now()
143
+ );
144
+ }
145
+ running(operationId) {
146
+ this.db.query("UPDATE operations SET state='running' WHERE operation_id=? AND state='accepted'").run(operationId);
147
+ }
148
+ finish(operationId, state, resultJson, reason) {
149
+ const bytes = resultJson === null ? 0 : Buffer.byteLength(resultJson);
150
+ if (bytes > this.maxReceiptBytes || bytes > this.maxTotalReceiptBytes) {
151
+ resultJson = null;
152
+ reason = "receipt_too_large";
153
+ }
154
+ this.db.query(
155
+ "UPDATE operations SET state=?, result_json=?, result_bytes=?, reason=?, finished_at=? WHERE operation_id=? AND state IN ('accepted','running')"
156
+ ).run(state, resultJson, resultJson === null ? 0 : bytes, reason ?? null, this.now(), operationId);
157
+ this.prune();
158
+ }
159
+ prune() {
160
+ this.db.query("UPDATE operations SET result_json=NULL, result_bytes=0 WHERE result_json IS NOT NULL AND finished_at<=?").run(this.now() - this.retentionMs);
161
+ let total = this.db.query("SELECT COALESCE(SUM(result_bytes), 0) AS total FROM operations").get().total;
162
+ if (total > this.maxTotalReceiptBytes) {
163
+ const rows = this.db.query("SELECT operation_id, result_bytes FROM operations WHERE result_bytes>0 ORDER BY finished_at, rowid").all();
164
+ const clear = this.db.query("UPDATE operations SET result_json=NULL, result_bytes=0 WHERE operation_id=?");
165
+ this.db.transaction(() => {
166
+ for (const row of rows) {
167
+ if (total <= this.maxTotalReceiptBytes) break;
168
+ clear.run(row.operation_id);
169
+ total -= row.result_bytes;
170
+ }
171
+ })();
172
+ }
173
+ }
174
+ receipt(row) {
175
+ return {
176
+ protocol: 1,
177
+ installationId: this.installationId,
178
+ userId: this.userId,
179
+ operationId: row.operation_id,
180
+ payloadHash: row.payload_hash,
181
+ state: row.state,
182
+ resultAvailable: row.result_json !== null,
183
+ ...row.result_json !== null ? { result: JSON.parse(row.result_json) } : {},
184
+ ...row.reason ? { reason: row.reason } : {}
185
+ };
186
+ }
187
+ close() {
188
+ this.db.close();
189
+ }
190
+ }
191
+ export {
192
+ BrowserLedger
193
+ };
@@ -0,0 +1,105 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import { createHash, timingSafeEqual } from "node:crypto";
4
+ import { z } from "zod";
5
+ import { RuntimeId } from "@ricsam/r5d-api/runtime-protocol";
6
+ import { BrowserKeeper } from "./keeper.mjs";
7
+ import { serveBrowserKeeper } from "./ipc.mjs";
8
+ import { BrowserKeeperError } from "./protocol.mjs";
9
+ import { readPrivateFile } from "./private-paths.mjs";
10
+ import { isolatedBrowserRoot } from "./isolated-root.mjs";
11
+ import { createIsolatedChromiumRuntime, loadApprovedChromiumFactory } from "./chromium-factory.mjs";
12
+ const BrowserKeeperConfig = z.object({
13
+ installationId: RuntimeId,
14
+ userId: RuntimeId,
15
+ instanceId: RuntimeId,
16
+ releaseId: RuntimeId,
17
+ root: z.string().refine(path.isAbsolute),
18
+ factory: z.object({ approvalPath: z.string().refine(path.isAbsolute), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict(),
19
+ adapters: z.array(z.object({ ownerId: RuntimeId, token: z.string().min(32).max(4096) }).strict()).min(1).max(16)
20
+ }).strict();
21
+ async function startConfiguredBrowserKeeper(configPath) {
22
+ const parsed = BrowserKeeperConfig.safeParse(JSON.parse(readPrivateFile(configPath, 128 * 1024).toString("utf8")));
23
+ if (!parsed.success) throw new BrowserKeeperError("invalid_keeper_config");
24
+ const config = parsed.data;
25
+ const approval = loadApprovedChromiumFactory(config.factory.approvalPath, config.factory.sha256);
26
+ const adapters = config.adapters.map((adapter) => ({
27
+ ownerId: adapter.ownerId,
28
+ hash: createHash("sha256").update(adapter.token).digest()
29
+ }));
30
+ if (new Set(adapters.map((adapter) => adapter.ownerId)).size !== adapters.length || new Set(adapters.map((adapter) => adapter.hash.toString("hex"))).size !== adapters.length)
31
+ throw new BrowserKeeperError("duplicate_adapter_identity");
32
+ const root = isolatedBrowserRoot(config.root, config.installationId, config.userId);
33
+ let runtime;
34
+ const keeper = new BrowserKeeper({
35
+ directory: root.ledger,
36
+ installationId: config.installationId,
37
+ userId: config.userId,
38
+ instanceId: config.instanceId,
39
+ releaseId: config.releaseId,
40
+ executor: {
41
+ execute: (operation) => {
42
+ if (!runtime) throw new BrowserKeeperError("runtime_unavailable");
43
+ return runtime.executor.execute(operation);
44
+ },
45
+ dispose: async () => {
46
+ await runtime?.executor.dispose?.();
47
+ }
48
+ }
49
+ });
50
+ const socketPath = path.join(root.ledger, "keeper.sock");
51
+ try {
52
+ try {
53
+ const socket = fs.lstatSync(socketPath);
54
+ if (!socket.isSocket() || socket.uid !== process.getuid?.()) throw new BrowserKeeperError("unsafe_socket_path");
55
+ fs.unlinkSync(socketPath);
56
+ } catch (error) {
57
+ if (error.code !== "ENOENT") throw error;
58
+ }
59
+ runtime = await createIsolatedChromiumRuntime({ runs: root.runs, approval });
60
+ const ipc = await serveBrowserKeeper({
61
+ keeper,
62
+ socketPath,
63
+ authenticate: (token) => {
64
+ const hash = createHash("sha256").update(token).digest();
65
+ const adapter = adapters.find((candidate) => timingSafeEqual(hash, candidate.hash));
66
+ return adapter ? { installationId: config.installationId, userId: config.userId, ownerId: adapter.ownerId } : null;
67
+ }
68
+ });
69
+ let closePromise;
70
+ return {
71
+ socketPath,
72
+ identity: runtime.identity,
73
+ close() {
74
+ return closePromise ??= (async () => {
75
+ await ipc.close();
76
+ await keeper.close();
77
+ })();
78
+ }
79
+ };
80
+ } catch (error) {
81
+ await keeper.close();
82
+ throw error;
83
+ }
84
+ }
85
+ if (import.meta.main) {
86
+ try {
87
+ if (process.argv.length !== 4 || process.argv[2] !== "--config") throw new BrowserKeeperError("invalid_arguments");
88
+ const service = await startConfiguredBrowserKeeper(process.argv[3]);
89
+ const stop = () => {
90
+ void service.close().then(
91
+ () => process.exit(0),
92
+ () => process.exit(1)
93
+ );
94
+ };
95
+ process.once("SIGTERM", stop);
96
+ process.once("SIGINT", stop);
97
+ } catch {
98
+ process.stderr.write("Browser keeper startup failed; check private approval/configuration and exclusive state ownership.\n");
99
+ process.exitCode = 1;
100
+ }
101
+ }
102
+ export {
103
+ BrowserKeeperConfig,
104
+ startConfiguredBrowserKeeper
105
+ };
@@ -0,0 +1,48 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { BrowserKeeperError } from "./protocol.mjs";
4
+ function assertPrivatePath(filename, kind, immutable = false) {
5
+ if (!path.isAbsolute(filename) || path.normalize(filename) !== filename) throw new BrowserKeeperError("noncanonical_private_path");
6
+ const components = filename.split(path.sep).filter(Boolean);
7
+ if (components.length === 0) throw new BrowserKeeperError("unsafe_private_path");
8
+ let current = path.parse(filename).root;
9
+ for (let i = 0; i < components.length; i++) {
10
+ current = path.join(current, components[i]);
11
+ const stat = fs.lstatSync(current);
12
+ if (stat.isSymbolicLink()) throw new BrowserKeeperError("linked_private_path");
13
+ const leaf = i === components.length - 1;
14
+ if (!leaf) {
15
+ if (!stat.isDirectory() || stat.uid !== 0 && stat.uid !== process.getuid?.() || (stat.mode & 18) !== 0 && (stat.mode & 512) === 0)
16
+ throw new BrowserKeeperError("unsafe_private_ancestor");
17
+ } else if (stat.uid !== process.getuid?.() || (stat.mode & 63) !== 0 || (kind === "directory" ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1) || immutable && (stat.mode & 146) !== 0) {
18
+ throw new BrowserKeeperError("unsafe_private_path");
19
+ }
20
+ }
21
+ }
22
+ function readPrivateFile(filename, maximumBytes, immutable = false) {
23
+ assertPrivatePath(filename, "file", immutable);
24
+ const fd = fs.openSync(filename, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
25
+ try {
26
+ const stat = fs.fstatSync(fd);
27
+ if (!stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid?.() || (stat.mode & 63) !== 0 || stat.size > maximumBytes || immutable && (stat.mode & 146) !== 0)
28
+ throw new BrowserKeeperError("unsafe_private_file");
29
+ const bytes = fs.readFileSync(fd);
30
+ if (bytes.byteLength > maximumBytes) throw new BrowserKeeperError("private_file_too_large");
31
+ return bytes;
32
+ } finally {
33
+ fs.closeSync(fd);
34
+ }
35
+ }
36
+ function makePrivateDirectory(directory) {
37
+ try {
38
+ fs.mkdirSync(directory, { mode: 448 });
39
+ } catch (error) {
40
+ if (error.code !== "EEXIST") throw error;
41
+ }
42
+ assertPrivatePath(directory, "directory");
43
+ }
44
+ export {
45
+ assertPrivatePath,
46
+ makePrivateDirectory,
47
+ readPrivateFile
48
+ };
@@ -0,0 +1,72 @@
1
+ import { z } from "zod";
2
+ import { OperationEnvelope, OwnershipFence, RuntimeHello, RuntimeId, RUNTIME_PROTOCOL } from "@ricsam/r5d-api/runtime-protocol";
3
+ const BROWSER_KEEPER_CAPABILITY = "browser-operations.v1";
4
+ const BrowserAction = z.enum([
5
+ "list_tabs",
6
+ "list_downloads",
7
+ "get_download",
8
+ "open_tab",
9
+ "close_tab",
10
+ "navigate",
11
+ "set_viewport",
12
+ "read_page",
13
+ "screenshot",
14
+ "move_mouse",
15
+ "mouse_click",
16
+ "scroll",
17
+ "wait_for",
18
+ "form_input",
19
+ "keyboard",
20
+ "keyboard_type",
21
+ "run_js",
22
+ "upload_begin",
23
+ "upload_chunk",
24
+ "upload_attach",
25
+ "upload_abort"
26
+ ]);
27
+ const userActions = /* @__PURE__ */ new Set(["list_tabs", "list_downloads", "get_download", "open_tab"]);
28
+ const BrowserFence = OwnershipFence.refine(
29
+ (fence) => fence.resourceType === "browser-tab" || fence.resourceType === "browser-user",
30
+ "Not a browser resource"
31
+ );
32
+ const BrowserOperation = OperationEnvelope.superRefine((operation, context) => {
33
+ if (!BrowserAction.safeParse(operation.kind).success) context.addIssue({ code: "custom", message: "Unsupported browser action" });
34
+ const payload = operation.payload;
35
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
36
+ context.addIssue({ code: "custom", message: "Browser input must be an object" });
37
+ return;
38
+ }
39
+ if (userActions.has(operation.kind)) {
40
+ if (operation.fence.resourceType !== "browser-user" || operation.fence.resourceId !== operation.userId || "tabId" in payload) {
41
+ context.addIssue({ code: "custom", message: "User action requires its user resource fence, without a tab target" });
42
+ }
43
+ } else if (operation.fence.resourceType !== "browser-tab" || !("tabId" in payload) || payload.tabId !== operation.fence.resourceId) {
44
+ context.addIssue({ code: "custom", message: "Tab target must match its fence" });
45
+ }
46
+ });
47
+ const lease = z.number().int().positive().max(Number.MAX_SAFE_INTEGER);
48
+ const BrowserKeeperRequest = z.discriminatedUnion("method", [
49
+ z.object({ method: z.literal("claim"), userId: RuntimeId, fence: BrowserFence, expiresAt: lease }).strict(),
50
+ z.object({ method: z.literal("renew"), userId: RuntimeId, fence: BrowserFence, expiresAt: lease }).strict(),
51
+ z.object({ method: z.literal("release"), userId: RuntimeId, fence: BrowserFence }).strict(),
52
+ z.object({ method: z.literal("execute"), operation: BrowserOperation }).strict(),
53
+ z.object({ method: z.literal("inspect"), operation: BrowserOperation }).strict()
54
+ ]);
55
+ const BrowserKeeperMessage = z.object({ hello: RuntimeHello, request: BrowserKeeperRequest }).strict();
56
+ class BrowserKeeperError extends Error {
57
+ constructor(code) {
58
+ super(code);
59
+ this.code = code;
60
+ this.name = "BrowserKeeperError";
61
+ }
62
+ code;
63
+ }
64
+ export {
65
+ BROWSER_KEEPER_CAPABILITY,
66
+ BrowserAction,
67
+ BrowserFence,
68
+ BrowserKeeperError,
69
+ BrowserKeeperMessage,
70
+ BrowserKeeperRequest,
71
+ BrowserOperation
72
+ };
@@ -108,4 +108,8 @@ export type BrowserViewport = {
108
108
  };
109
109
  export declare function parseViewportInput(input: Readonly<Record<string, unknown>>): BrowserViewport | null;
110
110
  export declare function normalizeBrowserInput(action: BrowserInputAction, input: Readonly<Record<string, unknown>>): Record<string, unknown>;
111
+ /** Only checks arguments whose validity is independent of browser state. Run
112
+ * before durable dispatch: a model may correct these errors without an effect
113
+ * having been admitted. Click/navigation failures after dispatch stay unknown. */
114
+ export declare function preflightBrowserInput(action: BrowserInputAction, raw: Readonly<Record<string, unknown>>): Record<string, unknown>;
111
115
  export {};
@@ -4,6 +4,7 @@ export type BrowserCliOptions = {
4
4
  apiKey?: string;
5
5
  configPath: string;
6
6
  profilePath?: string;
7
+ rootPath?: string;
7
8
  chromePath?: string;
8
9
  help: boolean;
9
10
  version: boolean;
@@ -0,0 +1,13 @@
1
+ export type PersonalBrowserDiagnostic = {
2
+ phase: "accepted" | "received" | "request" | "response" | "failure" | "closed" | "action-error";
3
+ method?: "connect" | "write" | "read" | "close";
4
+ bytes?: number;
5
+ status?: number;
6
+ code?: string;
7
+ action?: string;
8
+ category?: "timeout" | "disconnected" | "invalid-input" | "other";
9
+ };
10
+ export type PersonalBrowserObserver = (event: PersonalBrowserDiagnostic) => void;
11
+ export declare function browserErrorCategory(error: unknown): PersonalBrowserDiagnostic["category"];
12
+ /** Opt-in diagnostics contain counts/categories only, never requests or error text. */
13
+ export declare function personalBrowserDiagnosticObserver(enabled: boolean, write?: (line: string) => unknown): PersonalBrowserObserver;