@browserbridge/bbx 1.12.5 → 1.14.1
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/PRIVACY.md +4 -0
- package/README.md +2 -0
- package/SECURITY.md +10 -0
- package/package.json +6 -6
- package/packages/agent-client/src/cli-output.js +8 -2
- package/packages/agent-client/src/cli.js +132 -3
- package/packages/agent-client/src/command-registry.js +6 -0
- package/packages/agent-client/src/file-transfer.js +88 -0
- package/packages/agent-client/src/record-session.js +54 -0
- package/packages/agent-client/src/replay.js +124 -0
- package/packages/mcp-server/src/handlers-capture.js +9 -0
- package/packages/mcp-server/src/handlers-page.js +6 -0
- package/packages/mcp-server/src/handlers-utils.js +96 -0
- package/packages/mcp-server/src/server.js +22 -2
- package/packages/native-host/src/artifact-store.js +42 -4
- package/packages/native-host/src/auth-token.js +12 -0
- package/packages/native-host/src/daemon.js +142 -6
- package/packages/native-host/src/native-host.js +10 -0
- package/packages/native-host/src/recording-store.js +136 -0
- package/packages/protocol/src/downloads.js +35 -0
- package/packages/protocol/src/emulation.js +155 -0
- package/packages/protocol/src/files.js +94 -0
- package/packages/protocol/src/index.js +1 -0
- package/packages/protocol/src/protocol.js +44 -0
- package/packages/protocol/src/recording.js +70 -0
- package/packages/protocol/src/registry.js +224 -0
- package/packages/protocol/src/replay.js +144 -0
- package/packages/protocol/src/summary.js +49 -0
- package/packages/protocol/src/types.ts +37 -1
- package/packages/protocol/src/workflows.ts +22 -0
- package/skills/browser-bridge/SKILL.md +10 -0
- package/skills/browser-bridge/references/capture-context.md +9 -0
- package/skills/browser-bridge/references/emulation.md +18 -0
- package/skills/browser-bridge/references/files.md +50 -0
- package/skills/browser-bridge/references/playbooks.md +48 -0
- package/skills/browser-bridge/references/recording.md +54 -0
|
@@ -58,6 +58,73 @@ const MCP_AGENT_SESSION = process.env.BBX_SESSION?.trim() || MCP_CLIENT_ID;
|
|
|
58
58
|
const MCP_REQUEST_ERA = new AsyncLocalStorage();
|
|
59
59
|
/** @type {AsyncLocalStorage<import('../../protocol/src/types.js').BrowserTarget>} */
|
|
60
60
|
const MCP_REQUEST_TARGET = new AsyncLocalStorage();
|
|
61
|
+
/** @typedef {import('../../agent-client/src/client.js').BridgeClient} McpBridgeClient */
|
|
62
|
+
/** @typedef {{ clients: Map<string, Promise<McpBridgeClient>>, clientId: string, agentSession: string, closed: boolean }} McpClientSession */
|
|
63
|
+
/** @type {AsyncLocalStorage<McpClientSession>} */
|
|
64
|
+
const MCP_CLIENT_SESSION = new AsyncLocalStorage();
|
|
65
|
+
/** @type {WeakSet<McpBridgeClient>} */
|
|
66
|
+
const PERSISTENT_CLIENTS = new WeakSet();
|
|
67
|
+
/** @type {WeakMap<McpBridgeClient, Promise<void>>} */
|
|
68
|
+
const CONNECTING_CLIENTS = new WeakMap();
|
|
69
|
+
|
|
70
|
+
/** @returns {McpClientSession} */
|
|
71
|
+
export function createMcpClientSession() {
|
|
72
|
+
const clientId = `mcp_${randomUUID()}`;
|
|
73
|
+
return {
|
|
74
|
+
clients: new Map(),
|
|
75
|
+
clientId,
|
|
76
|
+
agentSession: process.env.BBX_SESSION?.trim() || clientId,
|
|
77
|
+
closed: false,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @template T
|
|
83
|
+
* @param {McpClientSession} session
|
|
84
|
+
* @param {() => T} callback
|
|
85
|
+
* @returns {T}
|
|
86
|
+
*/
|
|
87
|
+
export function runWithMcpClientSession(session, callback) {
|
|
88
|
+
return MCP_CLIENT_SESSION.run(session, callback);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @param {McpClientSession} session @returns {Promise<void>} */
|
|
92
|
+
export async function closeMcpClientSession(session) {
|
|
93
|
+
session.closed = true;
|
|
94
|
+
const clients = [...session.clients.values()];
|
|
95
|
+
session.clients.clear();
|
|
96
|
+
await Promise.allSettled(clients.map(async (pending) => (await pending).close()));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {McpClientSession} session
|
|
101
|
+
* @param {string | null | undefined} destinationId
|
|
102
|
+
* @returns {Promise<McpBridgeClient>}
|
|
103
|
+
*/
|
|
104
|
+
async function getSessionClient(session, destinationId) {
|
|
105
|
+
if (session.closed) throw new Error('MCP connection is closed.');
|
|
106
|
+
const key = destinationId || 'local';
|
|
107
|
+
let pending = session.clients.get(key);
|
|
108
|
+
if (!pending) {
|
|
109
|
+
pending = createBridgeClientForDestination(destinationId, {
|
|
110
|
+
checkProtocolOnConnect: false,
|
|
111
|
+
clientId: session.clientId,
|
|
112
|
+
agentSession: session.agentSession,
|
|
113
|
+
}).then(async (client) => {
|
|
114
|
+
if (session.closed) {
|
|
115
|
+
await client.close();
|
|
116
|
+
throw new Error('MCP connection is closed.');
|
|
117
|
+
}
|
|
118
|
+
PERSISTENT_CLIENTS.add(client);
|
|
119
|
+
return client;
|
|
120
|
+
});
|
|
121
|
+
session.clients.set(key, pending);
|
|
122
|
+
void pending.catch(() => {
|
|
123
|
+
if (session.clients.get(key) === pending) session.clients.delete(key);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return pending;
|
|
127
|
+
}
|
|
61
128
|
|
|
62
129
|
/**
|
|
63
130
|
* Scope routing to one tool invocation, including selector resolution and retries.
|
|
@@ -188,6 +255,23 @@ function waitForMcpRequest(delayMs) {
|
|
|
188
255
|
* @returns {Promise<void>}
|
|
189
256
|
*/
|
|
190
257
|
function connectMcpClient(client, timeoutMs) {
|
|
258
|
+
if (PERSISTENT_CLIENTS.has(client)) {
|
|
259
|
+
if (client.connected) return Promise.resolve();
|
|
260
|
+
let connecting = CONNECTING_CLIENTS.get(client);
|
|
261
|
+
if (!connecting) {
|
|
262
|
+
// A cancelled call must not close a connection shared by other calls.
|
|
263
|
+
connecting = MCP_REQUEST_ERA.exit(() =>
|
|
264
|
+
awaitMcpOperation(() => client.connect(), {
|
|
265
|
+
timeoutMs,
|
|
266
|
+
onCancel: () => {
|
|
267
|
+
void client.close().catch(() => {});
|
|
268
|
+
},
|
|
269
|
+
})
|
|
270
|
+
).finally(() => CONNECTING_CLIENTS.delete(client));
|
|
271
|
+
CONNECTING_CLIENTS.set(client, connecting);
|
|
272
|
+
}
|
|
273
|
+
return awaitMcpOperation(() => connecting);
|
|
274
|
+
}
|
|
191
275
|
return awaitMcpOperation(() => client.connect(), {
|
|
192
276
|
timeoutMs,
|
|
193
277
|
onCancel: () => {
|
|
@@ -412,6 +496,18 @@ export async function withToolClient(callback, options = {}) {
|
|
|
412
496
|
*/
|
|
413
497
|
export async function withMcpRequestClient(callback, options = {}) {
|
|
414
498
|
throwIfMcpRequestCancelled();
|
|
499
|
+
const session = MCP_CLIENT_SESSION.getStore();
|
|
500
|
+
if (session) {
|
|
501
|
+
return awaitMcpOperation(async () => {
|
|
502
|
+
const client = await getSessionClient(session, options.destinationId);
|
|
503
|
+
throwIfMcpRequestCancelled();
|
|
504
|
+
if (session.closed) throw new Error('MCP connection is closed.');
|
|
505
|
+
await connectMcpClient(client, client.defaultTimeoutMs);
|
|
506
|
+
throwIfMcpRequestCancelled();
|
|
507
|
+
if (session.closed) throw new Error('MCP connection is closed.');
|
|
508
|
+
return callback(client);
|
|
509
|
+
});
|
|
510
|
+
}
|
|
415
511
|
const client = await createBridgeClientForDestination(options.destinationId, {
|
|
416
512
|
checkProtocolOnConnect: false,
|
|
417
513
|
clientId: MCP_CLIENT_ID,
|
|
@@ -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 {
|
|
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
|
-
() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -235,6 +235,18 @@ async function ensureStoredBridgeAuthToken(tokenPath, options) {
|
|
|
235
235
|
owner = await readFile(lockPath, 'utf8');
|
|
236
236
|
} catch (error) {
|
|
237
237
|
if (isMissingFileError(error)) continue;
|
|
238
|
+
// Windows can report EPERM while a competing owner deletes the lock.
|
|
239
|
+
// Retry within the same bounded acquisition loop, then recheck the winner.
|
|
240
|
+
if (
|
|
241
|
+
process.platform === 'win32' &&
|
|
242
|
+
error &&
|
|
243
|
+
typeof error === 'object' &&
|
|
244
|
+
'code' in error &&
|
|
245
|
+
error.code === 'EPERM'
|
|
246
|
+
) {
|
|
247
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
238
250
|
throw error;
|
|
239
251
|
}
|
|
240
252
|
const match = /^(\d+):([0-9a-f-]{36})\n$/u.exec(owner);
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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, {
|