@browserbridge/bbx 1.2.0 → 1.4.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/README.md +8 -5
- package/package.json +2 -2
- package/packages/agent-client/src/cli.js +56 -31
- package/packages/agent-client/src/client.js +81 -65
- package/packages/agent-client/src/command-registry.js +4 -15
- package/packages/agent-client/src/detect.js +3 -3
- package/packages/agent-client/src/install.js +3 -7
- package/packages/agent-client/src/mcp-config.js +20 -5
- package/packages/agent-client/src/runtime.js +7 -41
- package/packages/agent-client/src/setup-status.js +3 -13
- package/packages/agent-client/src/types.ts +139 -0
- package/packages/mcp-server/src/guidance.js +241 -0
- package/packages/mcp-server/src/handlers-capture.js +91 -16
- package/packages/mcp-server/src/handlers-dom.js +59 -4
- package/packages/mcp-server/src/handlers-navigation.js +22 -2
- package/packages/mcp-server/src/handlers-page.js +6 -11
- package/packages/mcp-server/src/handlers-utils.js +69 -1
- package/packages/mcp-server/src/server.js +111 -28
- package/packages/native-host/bin/postinstall.js +42 -21
- package/packages/native-host/src/auth-token.js +92 -0
- package/packages/native-host/src/daemon-process.js +1 -2
- package/packages/native-host/src/daemon.js +199 -30
- package/packages/native-host/src/framing.js +13 -0
- package/packages/native-host/src/native-host.js +25 -7
- package/packages/protocol/src/defaults.js +3 -0
- package/packages/protocol/src/json-lines.js +29 -1
- package/packages/protocol/src/protocol.js +43 -0
- package/packages/protocol/src/registry.js +3 -9
- package/packages/protocol/src/types.ts +574 -0
- package/skills/browser-bridge/SKILL.md +21 -5
- package/skills/browser-bridge/agents/openai.yaml +1 -1
- package/skills/browser-bridge/references/interaction.md +6 -6
- package/skills/browser-bridge/references/protocol.md +57 -54
- package/skills/browser-bridge/references/ui-workflows.md +1 -1
- package/packages/protocol/src/types.js +0 -626
|
@@ -16,28 +16,49 @@ import { installNativeManifest } from '../src/install-manifest.js';
|
|
|
16
16
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
17
|
const repoRoot = path.resolve(__dirname, '../../..');
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
19
|
+
/**
|
|
20
|
+
* @param {{
|
|
21
|
+
* installNativeManifestFn?: typeof installNativeManifest,
|
|
22
|
+
* restartBridgeDaemonIfRunningFn?: typeof restartBridgeDaemonIfRunning,
|
|
23
|
+
* stdout?: Pick<NodeJS.WriteStream, 'write'>,
|
|
24
|
+
* stderr?: Pick<NodeJS.WriteStream, 'write'>,
|
|
25
|
+
* exit?: (code?: number) => void,
|
|
26
|
+
* }} [deps]
|
|
27
|
+
* @returns {Promise<void>}
|
|
28
|
+
*/
|
|
29
|
+
export async function runPostinstall({
|
|
30
|
+
installNativeManifestFn = installNativeManifest,
|
|
31
|
+
restartBridgeDaemonIfRunningFn = restartBridgeDaemonIfRunning,
|
|
32
|
+
stdout = process.stdout,
|
|
33
|
+
stderr = process.stderr,
|
|
34
|
+
exit = (code) => process.exit(code),
|
|
35
|
+
} = {}) {
|
|
36
|
+
try {
|
|
37
|
+
await installNativeManifestFn({ repoRoot, preserveCustomExtensionId: true });
|
|
38
|
+
stdout.write('Browser Bridge: native host installed. Run `bbx doctor` to verify.\n');
|
|
39
|
+
} catch (err) {
|
|
40
|
+
// Non-fatal - user can run `bbx install` manually.
|
|
41
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
42
|
+
stderr.write(
|
|
43
|
+
`Browser Bridge: native host auto-install skipped (${message}).\nRun \`bbx install\` manually if needed.\n`
|
|
44
|
+
);
|
|
45
|
+
exit(0);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
30
48
|
|
|
31
|
-
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
49
|
+
try {
|
|
50
|
+
const restartResult = await restartBridgeDaemonIfRunningFn();
|
|
51
|
+
if (restartResult) {
|
|
52
|
+
stdout.write('Browser Bridge: restarted the local daemon to use the updated install.\n');
|
|
53
|
+
}
|
|
54
|
+
} catch (err) {
|
|
55
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
56
|
+
stderr.write(
|
|
57
|
+
`Browser Bridge: native host installed, but daemon restart failed (${message}).\nRun \`bbx restart\` if needed.\n`
|
|
36
58
|
);
|
|
37
59
|
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
63
|
+
await runPostinstall();
|
|
43
64
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { getBridgeDir } from './config.js';
|
|
8
|
+
|
|
9
|
+
const TOKEN_FILENAME = 'daemon.auth';
|
|
10
|
+
const TOKEN_BYTES = 32;
|
|
11
|
+
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,256}$/u;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {NodeJS.ProcessEnv} [env=process.env]
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
export function getBridgeAuthTokenPath(env = process.env) {
|
|
18
|
+
return path.join(getBridgeDir(env), TOKEN_FILENAME);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {unknown} value
|
|
23
|
+
* @returns {string | null}
|
|
24
|
+
*/
|
|
25
|
+
export function normalizeBridgeAuthToken(value) {
|
|
26
|
+
if (typeof value !== 'string') {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const token = value.trim();
|
|
30
|
+
return TOKEN_PATTERN.test(token) ? token : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {{ tokenPath?: string, readFile?: typeof fs.promises.readFile }} [options={}]
|
|
35
|
+
* @returns {Promise<string | null>}
|
|
36
|
+
*/
|
|
37
|
+
export async function readBridgeAuthToken(options = {}) {
|
|
38
|
+
const tokenPath = options.tokenPath ?? getBridgeAuthTokenPath();
|
|
39
|
+
const readFile = options.readFile ?? fs.promises.readFile.bind(fs.promises);
|
|
40
|
+
try {
|
|
41
|
+
return normalizeBridgeAuthToken(await readFile(tokenPath, 'utf8'));
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (isMissingFileError(error)) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {{
|
|
52
|
+
* tokenPath?: string,
|
|
53
|
+
* readFile?: typeof fs.promises.readFile,
|
|
54
|
+
* writeFile?: typeof fs.promises.writeFile,
|
|
55
|
+
* mkdir?: typeof fs.promises.mkdir,
|
|
56
|
+
* chmod?: typeof fs.promises.chmod,
|
|
57
|
+
* randomBytesFn?: typeof randomBytes
|
|
58
|
+
* }} [options={}]
|
|
59
|
+
* @returns {Promise<string>}
|
|
60
|
+
*/
|
|
61
|
+
export async function ensureBridgeAuthToken(options = {}) {
|
|
62
|
+
const tokenPath = options.tokenPath ?? getBridgeAuthTokenPath();
|
|
63
|
+
const readFile = options.readFile ?? fs.promises.readFile.bind(fs.promises);
|
|
64
|
+
const writeFile = options.writeFile ?? fs.promises.writeFile.bind(fs.promises);
|
|
65
|
+
const mkdir = options.mkdir ?? fs.promises.mkdir.bind(fs.promises);
|
|
66
|
+
const chmod = options.chmod ?? fs.promises.chmod.bind(fs.promises);
|
|
67
|
+
const randomBytesFn = options.randomBytesFn ?? randomBytes;
|
|
68
|
+
const existing = await readBridgeAuthToken({ tokenPath, readFile });
|
|
69
|
+
if (existing) {
|
|
70
|
+
return existing;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const token = randomBytesFn(TOKEN_BYTES).toString('base64url');
|
|
74
|
+
await mkdir(path.dirname(tokenPath), { recursive: true });
|
|
75
|
+
await writeFile(tokenPath, `${token}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
76
|
+
if (process.platform !== 'win32') {
|
|
77
|
+
await chmod(tokenPath, 0o600).catch(() => {});
|
|
78
|
+
}
|
|
79
|
+
return token;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {unknown} error
|
|
84
|
+
* @returns {boolean}
|
|
85
|
+
*/
|
|
86
|
+
function isMissingFileError(error) {
|
|
87
|
+
return Boolean(
|
|
88
|
+
error &&
|
|
89
|
+
typeof error === 'object' &&
|
|
90
|
+
/** @type {{ code?: unknown }} */ (error).code === 'ENOENT'
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -10,7 +10,6 @@ import { pingExistingDaemon } from './daemon.js';
|
|
|
10
10
|
import {
|
|
11
11
|
createSocketBridgeTransport,
|
|
12
12
|
formatBridgeTransport,
|
|
13
|
-
getBridgeDir,
|
|
14
13
|
getBridgeTransport,
|
|
15
14
|
getDaemonPidPath,
|
|
16
15
|
} from './config.js';
|
|
@@ -74,7 +73,7 @@ export function spawnBridgeDaemonProcess() {
|
|
|
74
73
|
* @returns {Promise<void>}
|
|
75
74
|
*/
|
|
76
75
|
export async function writeDaemonPidFile(pid = process.pid, pidPath = getDaemonPidPath()) {
|
|
77
|
-
await fs.promises.mkdir(
|
|
76
|
+
await fs.promises.mkdir(path.dirname(pidPath), { recursive: true });
|
|
78
77
|
await fs.promises.writeFile(pidPath, `${pid}\n`, 'utf8');
|
|
79
78
|
}
|
|
80
79
|
|
|
@@ -36,6 +36,11 @@ import {
|
|
|
36
36
|
getBridgeTransport,
|
|
37
37
|
getSocketPath,
|
|
38
38
|
} from './config.js';
|
|
39
|
+
import {
|
|
40
|
+
ensureBridgeAuthToken,
|
|
41
|
+
normalizeBridgeAuthToken,
|
|
42
|
+
readBridgeAuthToken,
|
|
43
|
+
} from './auth-token.js';
|
|
39
44
|
import { normalizeDaemonLogger } from './daemon-logger.js';
|
|
40
45
|
import { writeJsonLine } from './framing.js';
|
|
41
46
|
|
|
@@ -47,7 +52,7 @@ const DAEMON_VERSION = loadDaemonVersion();
|
|
|
47
52
|
/** @typedef {import('../../protocol/src/types.js').SetupStatus} SetupStatus */
|
|
48
53
|
/** @typedef {import('./config.js').BridgeTransport} BridgeTransport */
|
|
49
54
|
/** @typedef {import('./daemon-logger.js').DaemonLoggerLike} DaemonLoggerLike */
|
|
50
|
-
/** @typedef {import('node:net').Socket & { __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __accessEnabled?: boolean, __lastActiveAt?: number }} ClientSocket */
|
|
55
|
+
/** @typedef {import('node:net').Socket & { __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __accessEnabled?: boolean, __lastActiveAt?: number, __authenticated?: boolean }} ClientSocket */
|
|
51
56
|
/** @typedef {{ socket: ClientSocket, timeoutId: NodeJS.Timeout, source?: string, method?: string, targets: Set<ClientSocket>, lastErrorResponse?: import('../../protocol/src/types.js').BridgeResponse }} PendingEntry */
|
|
52
57
|
/**
|
|
53
58
|
* @typedef {{
|
|
@@ -134,6 +139,7 @@ export function isWindowsNamedPipePath(socketPath) {
|
|
|
134
139
|
* browserName?: string,
|
|
135
140
|
* profileLabel?: string,
|
|
136
141
|
* accessEnabled?: boolean,
|
|
142
|
+
* authToken?: string,
|
|
137
143
|
* at?: number
|
|
138
144
|
* }} DaemonMessage
|
|
139
145
|
*/
|
|
@@ -146,7 +152,8 @@ export class BridgeDaemon {
|
|
|
146
152
|
* listenOptions?: import('node:net').ListenOptions | null,
|
|
147
153
|
* setupStatusLoader?: () => Promise<SetupStatus>,
|
|
148
154
|
* setupInstaller?: (params: Record<string, unknown>) => Promise<SetupInstallResult>,
|
|
149
|
-
* logger?: DaemonLoggerLike | Pick<Console, 'log' | 'error'
|
|
155
|
+
* logger?: DaemonLoggerLike | Pick<Console, 'log' | 'error'>,
|
|
156
|
+
* authToken?: string | null
|
|
150
157
|
* }} [options={}]
|
|
151
158
|
*/
|
|
152
159
|
constructor({
|
|
@@ -156,6 +163,7 @@ export class BridgeDaemon {
|
|
|
156
163
|
setupStatusLoader = collectSetupStatus,
|
|
157
164
|
setupInstaller = installSetupTarget,
|
|
158
165
|
logger = undefined,
|
|
166
|
+
authToken = undefined,
|
|
159
167
|
} = {}) {
|
|
160
168
|
this.transport = socketPath ? createSocketBridgeTransport(socketPath) : transport;
|
|
161
169
|
this.socketPath =
|
|
@@ -196,6 +204,15 @@ export class BridgeDaemon {
|
|
|
196
204
|
this.totalResponseTimeMs = 0;
|
|
197
205
|
/** @type {Map<string, number>} */
|
|
198
206
|
this.requestStartTimes = new Map();
|
|
207
|
+
/** @type {string | null | undefined} */
|
|
208
|
+
this.authToken = authToken;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* @returns {boolean}
|
|
213
|
+
*/
|
|
214
|
+
isAuthRequired() {
|
|
215
|
+
return Boolean(this.authToken);
|
|
199
216
|
}
|
|
200
217
|
|
|
201
218
|
/**
|
|
@@ -260,8 +277,8 @@ export class BridgeDaemon {
|
|
|
260
277
|
if (!pending) {
|
|
261
278
|
return undefined;
|
|
262
279
|
}
|
|
280
|
+
clearTimeout(pending.timeoutId);
|
|
263
281
|
this.pendingRequests.delete(requestId);
|
|
264
|
-
this.requestStartTimes.delete(requestId);
|
|
265
282
|
this.removePendingRequestIndex(this.pendingRequestsByOwnerSocket, pending.socket, requestId);
|
|
266
283
|
for (const targetSocket of pending.targets) {
|
|
267
284
|
this.removePendingRequestIndex(this.pendingRequestsByTargetSocket, targetSocket, requestId);
|
|
@@ -307,7 +324,20 @@ export class BridgeDaemon {
|
|
|
307
324
|
* @returns {void}
|
|
308
325
|
*/
|
|
309
326
|
registerSocket(socket, message) {
|
|
327
|
+
if (this.isAuthRequired() && normalizeBridgeAuthToken(message.authToken) !== this.authToken) {
|
|
328
|
+
this.logger.error('socket registration rejected', { role: message.role ?? null });
|
|
329
|
+
void writeJsonLine(socket, {
|
|
330
|
+
type: 'registration_failed',
|
|
331
|
+
error: {
|
|
332
|
+
code: ERROR_CODES.ACCESS_DENIED,
|
|
333
|
+
message: 'Bridge daemon authentication failed.',
|
|
334
|
+
},
|
|
335
|
+
}).finally(() => socket.destroy());
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
310
339
|
if (message.role === 'extension') {
|
|
340
|
+
socket.__authenticated = true;
|
|
311
341
|
const extensionId = randomUUID();
|
|
312
342
|
socket.__extensionId = extensionId;
|
|
313
343
|
socket.__browserName =
|
|
@@ -327,6 +357,7 @@ export class BridgeDaemon {
|
|
|
327
357
|
}
|
|
328
358
|
|
|
329
359
|
if (message.role === 'agent') {
|
|
360
|
+
socket.__authenticated = true;
|
|
330
361
|
const clientId = message.clientId || randomUUID();
|
|
331
362
|
this.agentSockets.set(clientId, socket);
|
|
332
363
|
socket.__clientId = clientId;
|
|
@@ -343,6 +374,10 @@ export class BridgeDaemon {
|
|
|
343
374
|
* @returns {Promise<BridgeDaemon>}
|
|
344
375
|
*/
|
|
345
376
|
async start() {
|
|
377
|
+
if (this.authToken === undefined) {
|
|
378
|
+
this.authToken = this.transport.type === 'tcp' ? await ensureBridgeAuthToken() : null;
|
|
379
|
+
}
|
|
380
|
+
|
|
346
381
|
if (this.transport.type === 'socket' && !isWindowsNamedPipePath(this.socketPath)) {
|
|
347
382
|
const socketDir = path.dirname(this.socketPath);
|
|
348
383
|
await fs.promises.mkdir(socketDir, { recursive: true });
|
|
@@ -373,14 +408,22 @@ export class BridgeDaemon {
|
|
|
373
408
|
typedSocket.on('error', (err) => {
|
|
374
409
|
this.logger.error('socket error', { message: err.message });
|
|
375
410
|
});
|
|
376
|
-
parseJsonLines(
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
411
|
+
parseJsonLines(
|
|
412
|
+
typedSocket,
|
|
413
|
+
(raw) => {
|
|
414
|
+
const message = /** @type {DaemonMessage} */ (raw);
|
|
415
|
+
void this.handleClientMessage(typedSocket, message).catch((err) => {
|
|
416
|
+
this.logger.error('handler error', {
|
|
417
|
+
message: err instanceof Error ? err.message : String(err),
|
|
418
|
+
});
|
|
381
419
|
});
|
|
382
|
-
}
|
|
383
|
-
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
onProtocolError: (error) => {
|
|
423
|
+
this.logger.error('socket protocol error', { message: error.message });
|
|
424
|
+
},
|
|
425
|
+
}
|
|
426
|
+
);
|
|
384
427
|
typedSocket.on('close', () => this.handleSocketClose(typedSocket));
|
|
385
428
|
});
|
|
386
429
|
|
|
@@ -478,6 +521,10 @@ export class BridgeDaemon {
|
|
|
478
521
|
return this.registerSocket(socket, message);
|
|
479
522
|
}
|
|
480
523
|
|
|
524
|
+
if (this.isAuthRequired() && !socket.__authenticated) {
|
|
525
|
+
return this.rejectUnauthenticatedMessage(socket, message);
|
|
526
|
+
}
|
|
527
|
+
|
|
481
528
|
if (message?.type === 'log') {
|
|
482
529
|
this.pushLog(message.entry ?? {});
|
|
483
530
|
return;
|
|
@@ -516,13 +563,73 @@ export class BridgeDaemon {
|
|
|
516
563
|
});
|
|
517
564
|
}
|
|
518
565
|
|
|
566
|
+
/**
|
|
567
|
+
* @param {ClientSocket} socket
|
|
568
|
+
* @param {DaemonMessage} message
|
|
569
|
+
* @returns {Promise<void>}
|
|
570
|
+
*/
|
|
571
|
+
async rejectUnauthenticatedMessage(socket, message) {
|
|
572
|
+
if (message?.type === 'agent.request') {
|
|
573
|
+
const candidate =
|
|
574
|
+
message.request && typeof message.request === 'object'
|
|
575
|
+
? /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (message.request))
|
|
576
|
+
: {};
|
|
577
|
+
const response = createFailure(
|
|
578
|
+
typeof candidate.id === 'string' && candidate.id.trim() ? candidate.id : 'unauthenticated',
|
|
579
|
+
ERROR_CODES.ACCESS_DENIED,
|
|
580
|
+
'Register with the daemon auth token before sending bridge requests.',
|
|
581
|
+
null,
|
|
582
|
+
typeof candidate.method === 'string' ? { method: candidate.method } : {}
|
|
583
|
+
);
|
|
584
|
+
await writeJsonLine(socket, { type: 'agent.response', response });
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
await writeJsonLine(socket, {
|
|
589
|
+
type: 'error',
|
|
590
|
+
error: {
|
|
591
|
+
code: ERROR_CODES.ACCESS_DENIED,
|
|
592
|
+
message: 'Register with the daemon auth token before sending bridge messages.',
|
|
593
|
+
},
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
519
597
|
/**
|
|
520
598
|
* @param {ClientSocket} socket
|
|
521
599
|
* @param {DaemonMessage} message
|
|
522
600
|
* @returns {Promise<void>}
|
|
523
601
|
*/
|
|
524
602
|
async handleAgentRequest(socket, message) {
|
|
525
|
-
|
|
603
|
+
/** @type {BridgeRequest} */
|
|
604
|
+
let request;
|
|
605
|
+
try {
|
|
606
|
+
request = validateBridgeRequest(message.request);
|
|
607
|
+
} catch (error) {
|
|
608
|
+
const candidate =
|
|
609
|
+
message.request && typeof message.request === 'object'
|
|
610
|
+
? /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (message.request))
|
|
611
|
+
: {};
|
|
612
|
+
const response = createFailure(
|
|
613
|
+
typeof candidate.id === 'string' && candidate.id.trim() ? candidate.id : 'invalid_request',
|
|
614
|
+
ERROR_CODES.INVALID_REQUEST,
|
|
615
|
+
error instanceof Error ? error.message : String(error),
|
|
616
|
+
null,
|
|
617
|
+
typeof candidate.method === 'string' ? { method: candidate.method } : {}
|
|
618
|
+
);
|
|
619
|
+
await writeJsonLine(socket, { type: 'agent.response', response });
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (this.pendingRequests.has(request.id)) {
|
|
624
|
+
const response = createFailure(
|
|
625
|
+
request.id,
|
|
626
|
+
ERROR_CODES.INVALID_REQUEST,
|
|
627
|
+
`Request id ${JSON.stringify(request.id)} is already in flight.`
|
|
628
|
+
);
|
|
629
|
+
await writeJsonLine(socket, { type: 'agent.response', response });
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
526
633
|
if (request.method === 'health.ping') {
|
|
527
634
|
if (this.extensionSockets.size === 0) {
|
|
528
635
|
const response = createSuccess(request.id, {
|
|
@@ -541,8 +648,10 @@ export class BridgeDaemon {
|
|
|
541
648
|
}
|
|
542
649
|
|
|
543
650
|
if (request.method === 'log.tail') {
|
|
651
|
+
const limit =
|
|
652
|
+
typeof request.params.limit === 'number' ? request.params.limit : DEFAULT_LOG_TAIL_LIMIT;
|
|
544
653
|
const response = createSuccess(request.id, {
|
|
545
|
-
entries: this.recentLog.slice(-
|
|
654
|
+
entries: this.recentLog.slice(-limit),
|
|
546
655
|
});
|
|
547
656
|
await writeJsonLine(socket, { type: 'agent.response', response });
|
|
548
657
|
return;
|
|
@@ -668,6 +777,12 @@ export class BridgeDaemon {
|
|
|
668
777
|
void writeJsonLine(pending.socket, {
|
|
669
778
|
type: 'agent.response',
|
|
670
779
|
response,
|
|
780
|
+
}).catch((error) => {
|
|
781
|
+
this.logger.error('timeout response write failed', {
|
|
782
|
+
requestId: request.id,
|
|
783
|
+
method: pending.method,
|
|
784
|
+
message: error instanceof Error ? error.message : String(error),
|
|
785
|
+
});
|
|
671
786
|
});
|
|
672
787
|
}, this.pendingTimeoutMs),
|
|
673
788
|
});
|
|
@@ -678,7 +793,30 @@ export class BridgeDaemon {
|
|
|
678
793
|
targetCount: targets.length,
|
|
679
794
|
});
|
|
680
795
|
const broadcastPayload = { type: 'extension.request', request };
|
|
681
|
-
await Promise.all(
|
|
796
|
+
await Promise.all(
|
|
797
|
+
targets.map(async (extSocket) => {
|
|
798
|
+
try {
|
|
799
|
+
await writeJsonLine(extSocket, broadcastPayload);
|
|
800
|
+
} catch (error) {
|
|
801
|
+
this.logger.error('request route failed', {
|
|
802
|
+
requestId: request.id,
|
|
803
|
+
method: request.method,
|
|
804
|
+
message: error instanceof Error ? error.message : String(error),
|
|
805
|
+
});
|
|
806
|
+
const pending = this.pendingRequests.get(request.id);
|
|
807
|
+
if (!pending) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
this.removePendingTarget(request.id, pending, extSocket);
|
|
811
|
+
if (extSocket.__extensionId) {
|
|
812
|
+
this.extensionSockets.delete(extSocket.__extensionId);
|
|
813
|
+
this.invalidateConnectedExtensionsCache();
|
|
814
|
+
}
|
|
815
|
+
extSocket.destroy(error instanceof Error ? error : undefined);
|
|
816
|
+
await this.finishPendingRequestIfExhausted(request.id, pending);
|
|
817
|
+
}
|
|
818
|
+
})
|
|
819
|
+
);
|
|
682
820
|
}
|
|
683
821
|
|
|
684
822
|
/**
|
|
@@ -768,7 +906,6 @@ export class BridgeDaemon {
|
|
|
768
906
|
this.removePendingTarget(responseMessage.id, pending, socket);
|
|
769
907
|
|
|
770
908
|
if (responseMessage.ok) {
|
|
771
|
-
clearTimeout(pending.timeoutId);
|
|
772
909
|
this.clearPendingRequest(responseMessage.id, pending);
|
|
773
910
|
this.recordRequestCompletion(responseMessage.id, true);
|
|
774
911
|
this.pushLog({
|
|
@@ -825,7 +962,9 @@ export class BridgeDaemon {
|
|
|
825
962
|
|
|
826
963
|
if (socket.__clientId) {
|
|
827
964
|
this.logger.info('agent disconnected', { clientId: socket.__clientId });
|
|
828
|
-
this.agentSockets.
|
|
965
|
+
if (this.agentSockets.get(socket.__clientId) === socket) {
|
|
966
|
+
this.agentSockets.delete(socket.__clientId);
|
|
967
|
+
}
|
|
829
968
|
}
|
|
830
969
|
|
|
831
970
|
const ownedRequestIds = this.pendingRequestsByOwnerSocket.get(socket);
|
|
@@ -835,7 +974,6 @@ export class BridgeDaemon {
|
|
|
835
974
|
if (!pending) {
|
|
836
975
|
continue;
|
|
837
976
|
}
|
|
838
|
-
clearTimeout(pending.timeoutId);
|
|
839
977
|
this.clearPendingRequest(id, pending);
|
|
840
978
|
this.recordRequestCompletion(id, false);
|
|
841
979
|
}
|
|
@@ -849,7 +987,13 @@ export class BridgeDaemon {
|
|
|
849
987
|
continue;
|
|
850
988
|
}
|
|
851
989
|
this.removePendingTarget(id, pending, socket);
|
|
852
|
-
void this.finishPendingRequestIfExhausted(id, pending)
|
|
990
|
+
void this.finishPendingRequestIfExhausted(id, pending).catch((error) => {
|
|
991
|
+
this.logger.error('pending exhaustion response failed', {
|
|
992
|
+
requestId: id,
|
|
993
|
+
method: pending.method,
|
|
994
|
+
message: error instanceof Error ? error.message : String(error),
|
|
995
|
+
});
|
|
996
|
+
});
|
|
853
997
|
}
|
|
854
998
|
}
|
|
855
999
|
}
|
|
@@ -867,7 +1011,6 @@ export class BridgeDaemon {
|
|
|
867
1011
|
return;
|
|
868
1012
|
}
|
|
869
1013
|
|
|
870
|
-
clearTimeout(pending.timeoutId);
|
|
871
1014
|
this.clearPendingRequest(requestId, pending);
|
|
872
1015
|
this.recordRequestCompletion(requestId, false);
|
|
873
1016
|
|
|
@@ -902,6 +1045,7 @@ export class BridgeDaemon {
|
|
|
902
1045
|
*/
|
|
903
1046
|
recordRequestCompletion(requestId, ok) {
|
|
904
1047
|
const startedAt = this.requestStartTimes.get(requestId);
|
|
1048
|
+
this.requestStartTimes.delete(requestId);
|
|
905
1049
|
this.requestsProcessed += 1;
|
|
906
1050
|
if (!ok) {
|
|
907
1051
|
this.requestsFailed += 1;
|
|
@@ -933,10 +1077,22 @@ export class BridgeDaemon {
|
|
|
933
1077
|
export async function pingExistingDaemon(transport) {
|
|
934
1078
|
const resolvedTransport =
|
|
935
1079
|
typeof transport === 'string' ? createSocketBridgeTransport(transport) : transport;
|
|
1080
|
+
const authToken = resolvedTransport.type === 'tcp' ? await readBridgeAuthToken() : null;
|
|
936
1081
|
return new Promise((resolve) => {
|
|
937
|
-
|
|
1082
|
+
let settled = false;
|
|
1083
|
+
/** @param {boolean} value */
|
|
1084
|
+
function finish(value) {
|
|
1085
|
+
if (settled) {
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
settled = true;
|
|
1089
|
+
clearTimeout(timeout);
|
|
938
1090
|
socket.destroy();
|
|
939
|
-
resolve(
|
|
1091
|
+
resolve(value);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const timeout = setTimeout(() => {
|
|
1095
|
+
finish(false);
|
|
940
1096
|
}, DAEMON_EXISTING_SOCKET_PING_TIMEOUT_MS);
|
|
941
1097
|
|
|
942
1098
|
const socket =
|
|
@@ -944,27 +1100,37 @@ export async function pingExistingDaemon(transport) {
|
|
|
944
1100
|
? net.createConnection({ host: resolvedTransport.host, port: resolvedTransport.port })
|
|
945
1101
|
: net.createConnection(resolvedTransport.socketPath);
|
|
946
1102
|
socket.once('error', () => {
|
|
947
|
-
|
|
948
|
-
resolve(false);
|
|
1103
|
+
finish(false);
|
|
949
1104
|
});
|
|
950
1105
|
|
|
951
1106
|
let buf = '';
|
|
952
1107
|
socket.setEncoding('utf8');
|
|
953
1108
|
socket.on('data', (chunk) => {
|
|
954
1109
|
buf += chunk;
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1110
|
+
while (buf.includes('\n')) {
|
|
1111
|
+
const index = buf.indexOf('\n');
|
|
1112
|
+
const line = buf.slice(0, index).trim();
|
|
1113
|
+
buf = buf.slice(index + 1);
|
|
1114
|
+
if (!line) {
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
958
1117
|
try {
|
|
959
|
-
const msg = JSON.parse(
|
|
960
|
-
|
|
1118
|
+
const msg = JSON.parse(line);
|
|
1119
|
+
if (msg?.type === 'agent.response') {
|
|
1120
|
+
finish(msg?.response?.result?.daemon === 'ok');
|
|
1121
|
+
}
|
|
961
1122
|
} catch {
|
|
962
|
-
|
|
1123
|
+
finish(false);
|
|
963
1124
|
}
|
|
964
1125
|
}
|
|
965
1126
|
});
|
|
966
1127
|
|
|
967
1128
|
socket.once('connect', () => {
|
|
1129
|
+
if (authToken) {
|
|
1130
|
+
socket.write(
|
|
1131
|
+
`${JSON.stringify({ type: 'register', role: 'agent', clientId: 'ping_probe', authToken })}\n`
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
968
1134
|
socket.write(
|
|
969
1135
|
`${JSON.stringify({
|
|
970
1136
|
type: 'agent.request',
|
|
@@ -986,7 +1152,10 @@ export async function pingExistingDaemon(transport) {
|
|
|
986
1152
|
* @returns {SetupInstallParams & { action: 'install' | 'uninstall', kind: 'mcp' | 'skill', target: string }}
|
|
987
1153
|
*/
|
|
988
1154
|
export function normalizeSetupInstallParams(params) {
|
|
989
|
-
const action = params.action
|
|
1155
|
+
const action = params.action == null ? 'install' : params.action;
|
|
1156
|
+
if (action !== 'install' && action !== 'uninstall') {
|
|
1157
|
+
throw new Error('setup.install action must be "install" or "uninstall".');
|
|
1158
|
+
}
|
|
990
1159
|
const kind = params.kind === 'mcp' || params.kind === 'skill' ? params.kind : null;
|
|
991
1160
|
const target = typeof params.target === 'string' ? params.target.trim().toLowerCase() : '';
|
|
992
1161
|
if (!kind) {
|
|
@@ -995,7 +1164,7 @@ export function normalizeSetupInstallParams(params) {
|
|
|
995
1164
|
if (!target) {
|
|
996
1165
|
throw new Error('setup.install requires a target.');
|
|
997
1166
|
}
|
|
998
|
-
return { action, kind, target };
|
|
1167
|
+
return { action: /** @type {'install' | 'uninstall'} */ (action), kind, target };
|
|
999
1168
|
}
|
|
1000
1169
|
|
|
1001
1170
|
/**
|
|
@@ -24,6 +24,19 @@ export async function writeNativeMessage(stream, message) {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* @param {NodeJS.WritableStream} stream
|
|
29
|
+
* @returns {(message: unknown) => Promise<void>}
|
|
30
|
+
*/
|
|
31
|
+
export function createNativeMessageWriter(stream) {
|
|
32
|
+
let queue = Promise.resolve();
|
|
33
|
+
return (message) => {
|
|
34
|
+
const writePromise = queue.then(() => writeNativeMessage(stream, message));
|
|
35
|
+
queue = writePromise.catch(() => {});
|
|
36
|
+
return writePromise;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
/**
|
|
28
41
|
* @param {NodeJS.ReadableStream} stream
|
|
29
42
|
* @param {(message: unknown) => void} onMessage
|