@browserbridge/bbx 1.12.5 → 1.14.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.
Files changed (34) hide show
  1. package/PRIVACY.md +4 -0
  2. package/README.md +2 -0
  3. package/SECURITY.md +10 -0
  4. package/package.json +6 -6
  5. package/packages/agent-client/src/cli.js +130 -1
  6. package/packages/agent-client/src/command-registry.js +6 -0
  7. package/packages/agent-client/src/file-transfer.js +88 -0
  8. package/packages/agent-client/src/record-session.js +54 -0
  9. package/packages/agent-client/src/replay.js +124 -0
  10. package/packages/mcp-server/src/handlers-capture.js +9 -0
  11. package/packages/mcp-server/src/handlers-page.js +6 -0
  12. package/packages/mcp-server/src/handlers-utils.js +96 -0
  13. package/packages/mcp-server/src/server.js +22 -2
  14. package/packages/native-host/src/artifact-store.js +42 -4
  15. package/packages/native-host/src/daemon.js +142 -6
  16. package/packages/native-host/src/native-host.js +10 -0
  17. package/packages/native-host/src/recording-store.js +136 -0
  18. package/packages/protocol/src/downloads.js +35 -0
  19. package/packages/protocol/src/emulation.js +155 -0
  20. package/packages/protocol/src/files.js +94 -0
  21. package/packages/protocol/src/index.js +1 -0
  22. package/packages/protocol/src/protocol.js +44 -0
  23. package/packages/protocol/src/recording.js +70 -0
  24. package/packages/protocol/src/registry.js +224 -0
  25. package/packages/protocol/src/replay.js +144 -0
  26. package/packages/protocol/src/summary.js +49 -0
  27. package/packages/protocol/src/types.ts +37 -1
  28. package/packages/protocol/src/workflows.ts +22 -0
  29. package/skills/browser-bridge/SKILL.md +10 -0
  30. package/skills/browser-bridge/references/capture-context.md +9 -0
  31. package/skills/browser-bridge/references/emulation.md +18 -0
  32. package/skills/browser-bridge/references/files.md +50 -0
  33. package/skills/browser-bridge/references/playbooks.md +48 -0
  34. package/skills/browser-bridge/references/recording.md +54 -0
@@ -54,7 +54,12 @@ import {
54
54
  import { applyWindowsTcpTransportDefaults } from '../../native-host/src/config.js';
55
55
  import { getMcpServerInstructions } from './guidance.js';
56
56
  import { isToolEnabledForEra, LOADABLE_TOOLSET_TOOLS } from './toolset.js';
57
- import { runWithMcpRequestTarget } from './handlers-utils.js';
57
+ import {
58
+ runWithMcpRequestTarget,
59
+ createMcpClientSession,
60
+ runWithMcpClientSession,
61
+ closeMcpClientSession,
62
+ } from './handlers-utils.js';
58
63
 
59
64
  export const BUDGET_PRESET_DESCRIPTION = `Budget preset: "quick", "normal", or "deep" (defaults: query ${BUDGET_PRESETS.normal.maxNodes} nodes / depth ${BUDGET_PRESETS.normal.maxDepth} / text ${BUDGET_PRESETS.normal.textBudget}). Numeric fields override the preset when both are provided.`;
60
65
  export const TAB_ID_DESCRIPTION =
@@ -181,6 +186,18 @@ export function createBridgeMcpServer(options = {}) {
181
186
 
182
187
  /** @type {Map<string, ReturnType<typeof server.registerTool>>} */
183
188
  const registrations = new Map();
189
+ const clientSession = createMcpClientSession();
190
+ server.server.onclose = () => {
191
+ void closeMcpClientSession(clientSession);
192
+ };
193
+ const closeServer = server.close.bind(server);
194
+ server.close = async () => {
195
+ try {
196
+ await closeServer();
197
+ } finally {
198
+ await closeMcpClientSession(clientSession);
199
+ }
200
+ };
184
201
 
185
202
  /**
186
203
  * Register a tool, then hide it when it is not part of the era's compact surface.
@@ -206,7 +223,10 @@ export function createBridgeMcpServer(options = {}) {
206
223
  (args, context) =>
207
224
  runWithMcpRequestEra(
208
225
  era,
209
- () => runWithMcpRequestTarget(args, () => handler(args, context)),
226
+ () =>
227
+ runWithMcpClientSession(clientSession, () =>
228
+ runWithMcpRequestTarget(args, () => handler(args, context))
229
+ ),
210
230
  context.mcpReq.signal
211
231
  )
212
232
  );
@@ -58,7 +58,7 @@ export class ArtifactStore {
58
58
  }
59
59
  if (
60
60
  !Number.isInteger(input.totalBytes) ||
61
- input.totalBytes < 1 ||
61
+ input.totalBytes < (input.kind === 'resource' ? 0 : 1) ||
62
62
  input.totalBytes > MAX_ARTIFACT_BYTES ||
63
63
  input.chunkCount !== Math.ceil(input.totalBytes / ARTIFACT_CHUNK_BYTES)
64
64
  ) {
@@ -128,6 +128,12 @@ export class ArtifactStore {
128
128
  'Artifact chunk order is invalid.'
129
129
  );
130
130
  }
131
+ if (data.length > Math.ceil(ARTIFACT_CHUNK_BYTES / 3) * 4) {
132
+ throw new BridgeError(
133
+ ERROR_CODES.ARTIFACT_TRANSFER_INVALID,
134
+ 'Artifact chunk size is invalid.'
135
+ );
136
+ }
131
137
  const bytes = decodeBase64(data);
132
138
  if (bytes.length < 1 || bytes.length > ARTIFACT_CHUNK_BYTES) {
133
139
  throw new BridgeError(
@@ -141,7 +147,17 @@ export class ArtifactStore {
141
147
  'Artifact exceeds declared size.'
142
148
  );
143
149
  }
144
- fs.writeSync(transfer.fd, bytes);
150
+ try {
151
+ let offset = 0;
152
+ while (offset < bytes.length) {
153
+ const written = fs.writeSync(transfer.fd, bytes, offset, bytes.length - offset);
154
+ if (written <= 0) throw new Error('Artifact write made no progress.');
155
+ offset += written;
156
+ }
157
+ } catch (error) {
158
+ this.abort(artifactId);
159
+ throw error;
160
+ }
145
161
  transfer.hash.update(bytes);
146
162
  transfer.written += bytes.length;
147
163
  transfer.nextChunk += 1;
@@ -198,7 +214,16 @@ export class ArtifactStore {
198
214
  const bytes = Buffer.alloc(byteLength);
199
215
  const fd = fs.openSync(record.filePath, 'r');
200
216
  try {
201
- fs.readSync(fd, bytes, 0, byteLength, start);
217
+ let offset = 0;
218
+ while (offset < byteLength) {
219
+ const read = fs.readSync(fd, bytes, offset, byteLength - offset, start + offset);
220
+ if (read <= 0)
221
+ throw new BridgeError(
222
+ ERROR_CODES.ARTIFACT_TRANSFER_INVALID,
223
+ 'Artifact was truncated on disk.'
224
+ );
225
+ offset += read;
226
+ }
202
227
  } finally {
203
228
  fs.closeSync(fd);
204
229
  }
@@ -286,6 +311,16 @@ export class ArtifactStore {
286
311
  }
287
312
  }
288
313
 
314
+ /** @param {string} ownerId */
315
+ deleteByOwner(ownerId) {
316
+ for (const transfer of this.transfers.values()) {
317
+ if (transfer.ownerId === ownerId) this.abort(transfer.artifactId);
318
+ }
319
+ for (const record of this.artifacts.values()) {
320
+ if (record.ownerId === ownerId) this.removeCommitted(record.artifactId);
321
+ }
322
+ }
323
+
289
324
  pruneExpired() {
290
325
  const now = this.now();
291
326
  for (const transfer of [...this.transfers.values()]) {
@@ -333,7 +368,10 @@ export class ArtifactStore {
333
368
  function isValidKindAndMimeType(kind, mimeType) {
334
369
  return (
335
370
  (kind === 'screenshot' && mimeType.startsWith('image/')) ||
336
- (kind === 'har' && mimeType === 'application/json')
371
+ (kind === 'har' && mimeType === 'application/json') ||
372
+ (kind === 'resource' &&
373
+ /^[a-zA-Z0-9!#$&^_.+-]+\/[a-zA-Z0-9!#$&^_.+-]+$/u.test(mimeType) &&
374
+ mimeType.length <= 120)
337
375
  );
338
376
  }
339
377
 
@@ -46,6 +46,8 @@ import {
46
46
  getSocketPath,
47
47
  } from './config.js';
48
48
  import { ArtifactStore } from './artifact-store.js';
49
+ import { RecordingStore } from './recording-store.js';
50
+ import { BridgeError } from '../../protocol/src/errors.js';
49
51
  import {
50
52
  bridgeAuthTokensEqual,
51
53
  ensureBridgeAuthToken,
@@ -211,6 +213,23 @@ function normalizeExtensionHealthResult(value) {
211
213
  /** @type {Record<string, unknown>} */
212
214
  const normalized = {};
213
215
  if (result.extension === 'ok') normalized.extension = 'ok';
216
+ const build = asHealthRecord(result.loadedBuild);
217
+ if (
218
+ build &&
219
+ typeof build.workerId === 'string' &&
220
+ /^[a-f0-9-]{36}$/u.test(build.workerId) &&
221
+ typeof build.startedAt === 'string' &&
222
+ build.startedAt.length <= 32 &&
223
+ Number.isFinite(Date.parse(build.startedAt)) &&
224
+ (build.sourceDigest === null ||
225
+ (typeof build.sourceDigest === 'string' && /^[a-f0-9]{64}$/u.test(build.sourceDigest)))
226
+ ) {
227
+ normalized.loadedBuild = {
228
+ workerId: build.workerId,
229
+ startedAt: build.startedAt,
230
+ sourceDigest: build.sourceDigest,
231
+ };
232
+ }
214
233
  if (
215
234
  typeof result.extensionVersion === 'string' &&
216
235
  /^\d+\.\d+\.\d+$/u.test(result.extensionVersion)
@@ -432,12 +451,30 @@ export function isWindowsNamedPipePath(socketPath) {
432
451
  * artifact?: Record<string, unknown>,
433
452
  * chunkIndex?: number,
434
453
  * data?: string
454
+ * journalId?: string,
455
+ * offset?: number,
456
+ * messageId?: string,
435
457
  * }} DaemonMessage
436
458
  */
437
459
 
438
460
  /** @typedef {import('../../protocol/src/types.js').ArtifactKind} ArtifactKind */
439
461
 
440
462
  export class BridgeDaemon {
463
+ /** File data belongs to a transport connection, never a caller-selected label.
464
+ * @type {WeakMap<ClientSocket, string>} */
465
+ dataOwners = new WeakMap();
466
+ /** @type {WeakMap<ClientSocket, Set<ClientSocket>>} */
467
+ dataOwnerTargets = new WeakMap();
468
+
469
+ /** @param {ClientSocket} socket */
470
+ getDataOwner(socket) {
471
+ let owner = this.dataOwners.get(socket);
472
+ if (!owner) {
473
+ owner = randomUUID();
474
+ this.dataOwners.set(socket, owner);
475
+ }
476
+ return owner;
477
+ }
441
478
  /**
442
479
  * @param {{
443
480
  * transport?: BridgeTransport,
@@ -448,7 +485,8 @@ export class BridgeDaemon {
448
485
  * logger?: DaemonLoggerLike | Pick<Console, 'log' | 'error'>,
449
486
  * authToken?: string | null,
450
487
  * extensionAuthToken?: string | null,
451
- * artifactStore?: ArtifactStore
488
+ * artifactStore?: ArtifactStore,
489
+ * recordingStore?: RecordingStore,
452
490
  * }} [options={}]
453
491
  */
454
492
  constructor({
@@ -461,6 +499,9 @@ export class BridgeDaemon {
461
499
  authToken = undefined,
462
500
  extensionAuthToken = undefined,
463
501
  artifactStore = new ArtifactStore(getArtifactStorePath()),
502
+ recordingStore = new RecordingStore(
503
+ path.join(path.dirname(getArtifactStorePath()), 'recordings')
504
+ ),
464
505
  } = {}) {
465
506
  this.transport = socketPath ? createSocketBridgeTransport(socketPath) : transport;
466
507
  this.socketPath =
@@ -512,6 +553,7 @@ export class BridgeDaemon {
512
553
  /** @type {string | null | undefined} */
513
554
  this.extensionAuthToken = extensionAuthToken;
514
555
  this.artifactStore = artifactStore;
556
+ this.recordingStore = recordingStore;
515
557
  this.artifactStoreInitialized = false;
516
558
  this.domBaselines = new DomBaselineOwnerRegistry({
517
559
  isCurrentExtensionSocket: (socket) =>
@@ -1016,6 +1058,40 @@ export class BridgeDaemon {
1016
1058
  }
1017
1059
  return this.handleExtensionArtifact(socket, message);
1018
1060
  }
1061
+ if (
1062
+ message?.type === 'extension.journal.begin' ||
1063
+ message?.type === 'extension.journal.append'
1064
+ ) {
1065
+ if (socket.__role !== 'extension' || !socket.__extensionId)
1066
+ return this.rejectMessageForRole(socket, message);
1067
+ try {
1068
+ let result;
1069
+ if (message.type === 'extension.journal.begin') {
1070
+ const pending = this.pendingRequests.get(String(message.requestId));
1071
+ if (!pending || pending.method !== 'session.start' || !pending.targets.has(socket))
1072
+ throw new Error('Journal must be created by a pending session.start request.');
1073
+ result = this.recordingStore.begin(socket.__extensionId);
1074
+ } else
1075
+ result = this.recordingStore.append(
1076
+ String(message.journalId),
1077
+ socket.__extensionId,
1078
+ Number(message.offset),
1079
+ String(message.data)
1080
+ );
1081
+ await writeJsonLine(socket, {
1082
+ type: 'extension.journal.ack',
1083
+ messageId: message.messageId,
1084
+ result,
1085
+ });
1086
+ } catch (error) {
1087
+ await writeJsonLine(socket, {
1088
+ type: 'extension.journal.ack',
1089
+ messageId: message.messageId,
1090
+ error: error instanceof Error ? error.message : String(error),
1091
+ });
1092
+ }
1093
+ return;
1094
+ }
1019
1095
 
1020
1096
  if (message?.type === 'extension.setup_status.request') {
1021
1097
  if (socket.__role !== 'extension') {
@@ -1187,8 +1263,36 @@ export class BridgeDaemon {
1187
1263
  return;
1188
1264
  }
1189
1265
 
1266
+ if (request.method === 'session.journal_read' || request.method === 'session.journal_delete') {
1267
+ try {
1268
+ const result =
1269
+ request.method === 'session.journal_read'
1270
+ ? this.recordingStore.read(
1271
+ String(request.params.journalId),
1272
+ Number(request.params.offset),
1273
+ Number(request.params.maxBytes)
1274
+ )
1275
+ : this.recordingStore.delete(String(request.params.journalId));
1276
+ await writeJsonLine(socket, {
1277
+ type: 'agent.response',
1278
+ response: createSuccess(request.id, result, { method: request.method }),
1279
+ });
1280
+ } catch (error) {
1281
+ await writeJsonLine(socket, {
1282
+ type: 'agent.response',
1283
+ response: createFailure(
1284
+ request.id,
1285
+ error instanceof BridgeError ? error.code : ERROR_CODES.ARTIFACT_NOT_FOUND,
1286
+ error instanceof BridgeError ? error.message : 'Recording journal unavailable.',
1287
+ null,
1288
+ { method: request.method }
1289
+ ),
1290
+ });
1291
+ }
1292
+ return;
1293
+ }
1190
1294
  if (request.method === 'artifact.read' || request.method === 'artifact.delete') {
1191
- const ownerId = socket.__clientId;
1295
+ const ownerId = socket.__clientId ? this.getDataOwner(socket) : null;
1192
1296
  if (!ownerId) {
1193
1297
  const response = createFailure(
1194
1298
  request.id,
@@ -1424,7 +1528,20 @@ export class BridgeDaemon {
1424
1528
  targetCount: 1,
1425
1529
  });
1426
1530
  try {
1427
- await writeJsonLine(target, { type: 'extension.request', request });
1531
+ if (
1532
+ /^(?:files\.|session\.|download\.|input\.(?:set_files|paste_image)$)/u.test(request.method)
1533
+ ) {
1534
+ const targets = this.dataOwnerTargets.get(socket) ?? new Set();
1535
+ targets.add(target);
1536
+ this.dataOwnerTargets.set(socket, targets);
1537
+ }
1538
+ await writeJsonLine(target, {
1539
+ type: 'extension.request',
1540
+ request: {
1541
+ ...request,
1542
+ meta: { ...request.meta, transport_owner: this.getDataOwner(socket) },
1543
+ },
1544
+ });
1428
1545
  } catch (error) {
1429
1546
  this.logger.error('request route failed', {
1430
1547
  requestId: request.id,
@@ -1609,6 +1726,7 @@ export class BridgeDaemon {
1609
1726
  : null;
1610
1727
  if (!accessEnabled && socket.__extensionId) {
1611
1728
  this.artifactStore.deleteByExtension(socket.__extensionId);
1729
+ this.recordingStore.disconnect(socket.__extensionId);
1612
1730
  this.domBaselines.clearForSocket(socket);
1613
1731
  }
1614
1732
  if (socket.__accessEnabled !== accessEnabled) {
@@ -1653,7 +1771,7 @@ export class BridgeDaemon {
1653
1771
  const requestId = typeof artifact.requestId === 'string' ? artifact.requestId : '';
1654
1772
  const pending = this.pendingRequests.get(requestId);
1655
1773
  if (!pending || !pending.targets.has(socket) || !socket.__extensionId) return;
1656
- const ownerId = pending.socket.__clientId;
1774
+ const ownerId = pending.socket.__clientId ? this.getDataOwner(pending.socket) : null;
1657
1775
  if (!ownerId) return;
1658
1776
  try {
1659
1777
  if (
@@ -1671,6 +1789,9 @@ export class BridgeDaemon {
1671
1789
  }
1672
1790
  }
1673
1791
  if (message.type === 'extension.artifact.begin') {
1792
+ if (artifact.kind !== getArtifactKindForMethod(pending.method)) {
1793
+ throw new Error('Artifact kind does not match the routed operation.');
1794
+ }
1674
1795
  this.artifactStore.begin({
1675
1796
  artifactId: String(artifact.artifactId ?? ''),
1676
1797
  requestId,
@@ -1698,7 +1819,7 @@ export class BridgeDaemon {
1698
1819
  error && typeof error === 'object' && 'code' in error
1699
1820
  ? /** @type {import('../../protocol/src/types.js').ErrorCode} */ (error.code)
1700
1821
  : ERROR_CODES.ARTIFACT_TRANSFER_INVALID;
1701
- pending.lastErrorResponse = createFailure(
1822
+ pending.lastErrorResponse ??= createFailure(
1702
1823
  requestId,
1703
1824
  code,
1704
1825
  error instanceof Error ? error.message : String(error),
@@ -1784,7 +1905,7 @@ export class BridgeDaemon {
1784
1905
  !pending.socket.__clientId ||
1785
1906
  !this.artifactStore.matchesCommitted(
1786
1907
  String(artifact.artifactId ?? ''),
1787
- pending.socket.__clientId,
1908
+ this.getDataOwner(pending.socket),
1788
1909
  responseMessage.id,
1789
1910
  artifact,
1790
1911
  expectedArtifactKind ?? undefined
@@ -1895,6 +2016,19 @@ export class BridgeDaemon {
1895
2016
  * @returns {void}
1896
2017
  */
1897
2018
  handleSocketClose(socket) {
2019
+ const dataOwner = this.dataOwners.get(socket);
2020
+ if (dataOwner) {
2021
+ this.artifactStore.deleteByOwner(dataOwner);
2022
+ for (const target of this.dataOwnerTargets.get(socket) ?? []) {
2023
+ if (!target.destroyed)
2024
+ void writeJsonLine(target, {
2025
+ type: 'extension.owner_disconnected',
2026
+ ownerId: dataOwner,
2027
+ }).catch(() => {});
2028
+ }
2029
+ }
2030
+ this.dataOwnerTargets.delete(socket);
2031
+ this.dataOwners.delete(socket);
1898
2032
  this.windowActions.disconnect(socket);
1899
2033
  this.clientSockets.delete(socket);
1900
2034
  if (socket.__registrationTimeoutId) {
@@ -2068,6 +2202,8 @@ function isAutomaticMcpRetry(meta) {
2068
2202
  function getArtifactKindForMethod(method) {
2069
2203
  if (method?.startsWith('screenshot.')) return 'screenshot';
2070
2204
  if (method === 'network.export_har') return 'har';
2205
+ if (method === 'download.resource') return 'resource';
2206
+ if (method === 'session.export') return 'resource';
2071
2207
  return null;
2072
2208
  }
2073
2209
 
@@ -183,6 +183,13 @@ export async function runNativeHost({
183
183
  await writeNativeMessageQueued(message.request);
184
184
  return;
185
185
  }
186
+ if (message.type === 'extension.owner_disconnected') {
187
+ await writeNativeMessageQueued({
188
+ type: 'host.owner_disconnected',
189
+ ownerId: message.ownerId,
190
+ });
191
+ return;
192
+ }
186
193
  if (message.type === 'agent.response') {
187
194
  await writeNativeMessageQueued({
188
195
  type: 'host.bridge_response',
@@ -199,6 +206,7 @@ export async function runNativeHost({
199
206
  }
200
207
  if (
201
208
  message.type === 'extension.window_action.command' ||
209
+ message.type === 'extension.journal.ack' ||
202
210
  message.type === 'extension.window_action.response'
203
211
  ) {
204
212
  await writeNativeMessageQueued({
@@ -290,6 +298,8 @@ export async function runNativeHost({
290
298
  const record = /** @type {Record<string, unknown>} */ (message);
291
299
  if (
292
300
  record.type === 'host.window_action.request' ||
301
+ record.type === 'host.journal.begin' ||
302
+ record.type === 'host.journal.append' ||
293
303
  record.type === 'host.window_action.result'
294
304
  ) {
295
305
  await writeJsonLine(socket, {
@@ -0,0 +1,136 @@
1
+ // @ts-check
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { BridgeError, ERROR_CODES } from '../../protocol/src/index.js';
6
+
7
+ const MAX_BYTES = 128 * 1024 * 1024;
8
+ const TOTAL_BYTES = 256 * 1024 * 1024;
9
+ const TTL = 24 * 60 * 60 * 1000;
10
+ const validId = /^journal_[a-f0-9]{64}$/u;
11
+
12
+ /** Durable append-only journals. The random ID is a read/delete capability, never a path.
13
+ * Writes require the original extension connection as well as the capability.
14
+ * Existing files survive process restarts; the last partial line is reported by replay readers. */
15
+ export class RecordingStore {
16
+ /** @param {string} directory */
17
+ constructor(directory) {
18
+ this.directory = directory;
19
+ /** @type {Map<string, string>} */
20
+ this.writers = new Map();
21
+ }
22
+ /** @param {string} id */
23
+ filename(id) {
24
+ if (!validId.test(id))
25
+ throw new BridgeError(ERROR_CODES.ARTIFACT_NOT_FOUND, 'Unknown recording journal.');
26
+ return path.join(this.directory, `${id}.jsonl`);
27
+ }
28
+ /** @returns {number} */
29
+ prune() {
30
+ fs.mkdirSync(this.directory, { recursive: true, mode: 0o700 });
31
+ let bytes = 0;
32
+ for (const name of fs.readdirSync(this.directory)) {
33
+ const id = name.replace(/\.jsonl$/u, '');
34
+ if (!validId.test(id)) continue;
35
+ const filename = this.filename(id);
36
+ const stat = fs.lstatSync(filename);
37
+ if (!stat.isFile() || stat.isSymbolicLink()) continue;
38
+ if (Date.now() - stat.mtimeMs > TTL) {
39
+ fs.unlinkSync(filename);
40
+ this.writers.delete(id);
41
+ } else bytes += stat.size;
42
+ }
43
+ return bytes;
44
+ }
45
+ /** @param {string} extensionId */
46
+ begin(extensionId) {
47
+ const used = this.prune();
48
+ if (used >= TOTAL_BYTES || fs.readdirSync(this.directory).length >= 32)
49
+ throw new BridgeError(
50
+ ERROR_CODES.ARTIFACT_QUOTA_EXCEEDED,
51
+ 'Recording journal quota exceeded. Delete old journals.'
52
+ );
53
+ const journalId = `journal_${randomBytes(32).toString('hex')}`;
54
+ const fd = fs.openSync(this.filename(journalId), 'wx', 0o600);
55
+ fs.fsyncSync(fd);
56
+ fs.closeSync(fd);
57
+ this.writers.set(journalId, extensionId);
58
+ return { journalId, nextOffset: 0, expiresAt: new Date(Date.now() + TTL).toISOString() };
59
+ }
60
+ /** @param {string} id @param {string} extensionId @param {number} offset @param {string} data */
61
+ append(id, extensionId, offset, data) {
62
+ if (this.writers.get(id) !== extensionId)
63
+ throw new BridgeError(ERROR_CODES.ACCESS_DENIED, 'Journal writer connection does not match.');
64
+ if (
65
+ !Number.isSafeInteger(offset) ||
66
+ offset < 0 ||
67
+ data.length > 262144 ||
68
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(data)
69
+ )
70
+ throw new BridgeError(ERROR_CODES.INVALID_REQUEST, 'Invalid journal chunk.');
71
+ const bytes = Buffer.from(data, 'base64');
72
+ if (this.prune() + bytes.length > TOTAL_BYTES || offset + bytes.length > MAX_BYTES)
73
+ throw new BridgeError(
74
+ ERROR_CODES.ARTIFACT_QUOTA_EXCEEDED,
75
+ 'Recording journal byte quota exceeded.'
76
+ );
77
+ const fd = fs.openSync(
78
+ this.filename(id),
79
+ fs.constants.O_WRONLY | (fs.constants.O_NOFOLLOW ?? 0)
80
+ );
81
+ try {
82
+ if (fs.fstatSync(fd).size !== offset)
83
+ throw new BridgeError(ERROR_CODES.INVALID_REQUEST, 'Journal offset mismatch.');
84
+ let written = 0;
85
+ while (written < bytes.length) {
86
+ const count = fs.writeSync(fd, bytes, written, bytes.length - written, offset + written);
87
+ if (!count) throw new Error('Journal write made no progress.');
88
+ written += count;
89
+ }
90
+ fs.fsyncSync(fd);
91
+ return { journalId: id, nextOffset: offset + written };
92
+ } finally {
93
+ fs.closeSync(fd);
94
+ }
95
+ }
96
+ /** @param {string} id @param {number} offset @param {number} limit */
97
+ read(id, offset, limit) {
98
+ this.prune();
99
+ const filename = this.filename(id);
100
+ const fd = fs.openSync(filename, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
101
+ try {
102
+ const size = fs.fstatSync(fd).size;
103
+ if (
104
+ !Number.isSafeInteger(offset) ||
105
+ offset < 0 ||
106
+ offset > size ||
107
+ !Number.isInteger(limit) ||
108
+ limit < 1 ||
109
+ limit > 196608
110
+ )
111
+ throw new BridgeError(ERROR_CODES.INVALID_REQUEST, 'Invalid journal read range.');
112
+ const data = Buffer.alloc(Math.min(limit, size - offset));
113
+ const count = fs.readSync(fd, data, 0, data.length, offset);
114
+ return {
115
+ journalId: id,
116
+ offset,
117
+ byteLength: count,
118
+ totalBytes: size,
119
+ nextOffset: offset + count < size ? offset + count : null,
120
+ data: data.subarray(0, count).toString('base64'),
121
+ };
122
+ } finally {
123
+ fs.closeSync(fd);
124
+ }
125
+ }
126
+ /** @param {string} id */
127
+ delete(id) {
128
+ fs.unlinkSync(this.filename(id));
129
+ this.writers.delete(id);
130
+ return { deleted: true };
131
+ }
132
+ /** @param {string} extensionId */
133
+ disconnect(extensionId) {
134
+ for (const [id, owner] of this.writers) if (owner === extensionId) this.writers.delete(id);
135
+ }
136
+ }
@@ -0,0 +1,35 @@
1
+ // @ts-check
2
+ import { booleanParam, invalidWorkflow, onlyKeys, stringParam } from './emulation.js';
3
+
4
+ /** @param {string} method @param {Record<string, unknown>} params */
5
+ export function normalizeDownload(method, params) {
6
+ if (method !== 'download.start') {
7
+ onlyKeys(params, ['downloadId']);
8
+ return { downloadId: stringParam(params.downloadId, 'downloadId', 100) };
9
+ }
10
+ onlyKeys(params, ['url', 'filename', 'saveAs']);
11
+ const url = stringParam(params.url, 'url', 8192);
12
+ let parsed;
13
+ try {
14
+ parsed = new URL(url);
15
+ } catch {
16
+ return invalidWorkflow('Invalid download URL.');
17
+ }
18
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password)
19
+ invalidWorkflow('Downloads require HTTP(S) without URL credentials.');
20
+ const filename =
21
+ params.filename === undefined ? undefined : stringParam(params.filename, 'filename', 240);
22
+ if (
23
+ filename &&
24
+ (/[\\/:]/u.test(filename) ||
25
+ [...filename].some((character) => character.charCodeAt(0) < 32) ||
26
+ filename === '.' ||
27
+ filename === '..')
28
+ )
29
+ invalidWorkflow('filename must be a single filename, not a path.');
30
+ return {
31
+ url,
32
+ ...(filename ? { filename } : {}),
33
+ saveAs: booleanParam(params.saveAs ?? false, 'saveAs'),
34
+ };
35
+ }