@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.
Files changed (35) hide show
  1. package/README.md +8 -5
  2. package/package.json +2 -2
  3. package/packages/agent-client/src/cli.js +56 -31
  4. package/packages/agent-client/src/client.js +81 -65
  5. package/packages/agent-client/src/command-registry.js +4 -15
  6. package/packages/agent-client/src/detect.js +3 -3
  7. package/packages/agent-client/src/install.js +3 -7
  8. package/packages/agent-client/src/mcp-config.js +20 -5
  9. package/packages/agent-client/src/runtime.js +7 -41
  10. package/packages/agent-client/src/setup-status.js +3 -13
  11. package/packages/agent-client/src/types.ts +139 -0
  12. package/packages/mcp-server/src/guidance.js +241 -0
  13. package/packages/mcp-server/src/handlers-capture.js +91 -16
  14. package/packages/mcp-server/src/handlers-dom.js +59 -4
  15. package/packages/mcp-server/src/handlers-navigation.js +22 -2
  16. package/packages/mcp-server/src/handlers-page.js +6 -11
  17. package/packages/mcp-server/src/handlers-utils.js +69 -1
  18. package/packages/mcp-server/src/server.js +111 -28
  19. package/packages/native-host/bin/postinstall.js +42 -21
  20. package/packages/native-host/src/auth-token.js +92 -0
  21. package/packages/native-host/src/daemon-process.js +1 -2
  22. package/packages/native-host/src/daemon.js +199 -30
  23. package/packages/native-host/src/framing.js +13 -0
  24. package/packages/native-host/src/native-host.js +25 -7
  25. package/packages/protocol/src/defaults.js +3 -0
  26. package/packages/protocol/src/json-lines.js +29 -1
  27. package/packages/protocol/src/protocol.js +43 -0
  28. package/packages/protocol/src/registry.js +3 -9
  29. package/packages/protocol/src/types.ts +574 -0
  30. package/skills/browser-bridge/SKILL.md +21 -5
  31. package/skills/browser-bridge/agents/openai.yaml +1 -1
  32. package/skills/browser-bridge/references/interaction.md +6 -6
  33. package/skills/browser-bridge/references/protocol.md +57 -54
  34. package/skills/browser-bridge/references/ui-workflows.md +1 -1
  35. 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
- try {
20
- await installNativeManifest({ repoRoot, preserveCustomExtensionId: true });
21
- process.stdout.write('Browser Bridge: native host installed. Run `bbx doctor` to verify.\n');
22
- } catch (err) {
23
- // Non-fatal - user can run `bbx install` manually.
24
- const message = err instanceof Error ? err.message : String(err);
25
- process.stderr.write(
26
- `Browser Bridge: native host auto-install skipped (${message}).\nRun \`bbx install\` manually if needed.\n`
27
- );
28
- process.exit(0);
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
- const restartResult = await restartBridgeDaemonIfRunning();
33
- if (restartResult) {
34
- process.stdout.write(
35
- 'Browser Bridge: restarted the local daemon to use the updated install.\n'
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
- } catch (err) {
39
- const message = err instanceof Error ? err.message : String(err);
40
- process.stderr.write(
41
- `Browser Bridge: native host installed, but daemon restart failed (${message}).\nRun \`bbx restart\` if needed.\n`
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(getBridgeDir(), { recursive: true });
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(typedSocket, (raw) => {
377
- const message = /** @type {DaemonMessage} */ (raw);
378
- void this.handleClientMessage(typedSocket, message).catch((err) => {
379
- this.logger.error('handler error', {
380
- message: err instanceof Error ? err.message : String(err),
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
- const request = validateBridgeRequest(message.request);
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(-DEFAULT_LOG_TAIL_LIMIT),
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(targets.map((extSocket) => writeJsonLine(extSocket, broadcastPayload)));
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.delete(socket.__clientId);
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
- const timeout = setTimeout(() => {
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(false);
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
- clearTimeout(timeout);
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
- if (buf.includes('\n')) {
956
- clearTimeout(timeout);
957
- socket.destroy();
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(buf.slice(0, buf.indexOf('\n')).trim());
960
- resolve(msg?.response?.result?.daemon === 'ok');
1118
+ const msg = JSON.parse(line);
1119
+ if (msg?.type === 'agent.response') {
1120
+ finish(msg?.response?.result?.daemon === 'ok');
1121
+ }
961
1122
  } catch {
962
- resolve(false);
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 === 'uninstall' ? 'uninstall' : 'install';
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