@browserbridge/bbx 1.11.0 → 1.11.2

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.
@@ -47,17 +47,119 @@ import { annotateBridgeSummary, summarizeBridgeResponse } from '../../agent-clie
47
47
 
48
48
  export const REQUEST_SOURCE = 'mcp';
49
49
  const MCP_CLIENT_ID = `mcp_${randomUUID()}`;
50
- /** @type {AsyncLocalStorage<import('../../protocol/src/types.js').McpProtocolEra>} */
50
+ /** @type {AsyncLocalStorage<{ era: import('../../protocol/src/types.js').McpProtocolEra, signal?: AbortSignal }>} */
51
51
  const MCP_REQUEST_ERA = new AsyncLocalStorage();
52
52
 
53
53
  /**
54
54
  * @template T
55
55
  * @param {import('../../protocol/src/types.js').McpProtocolEra} era
56
56
  * @param {() => T} callback
57
+ * @param {AbortSignal} [signal]
57
58
  * @returns {T}
58
59
  */
59
- export function runWithMcpRequestEra(era, callback) {
60
- return MCP_REQUEST_ERA.run(era, callback);
60
+ export function runWithMcpRequestEra(era, callback, signal) {
61
+ return MCP_REQUEST_ERA.run({ era, signal }, callback);
62
+ }
63
+
64
+ /** @returns {void} */
65
+ export function throwIfMcpRequestCancelled() {
66
+ MCP_REQUEST_ERA.getStore()?.signal?.throwIfAborted();
67
+ }
68
+
69
+ /**
70
+ * Race request-owned work against cancellation and an optional absolute budget.
71
+ * Connection attempts can finish after close(), so dispose late completions too.
72
+ *
73
+ * @template T
74
+ * @param {() => Promise<T>} operation
75
+ * @param {{ timeoutMs?: number, onCancel?: () => void }} [options]
76
+ * @returns {Promise<T>}
77
+ */
78
+ function awaitMcpOperation(operation, options = {}) {
79
+ const signal = MCP_REQUEST_ERA.getStore()?.signal;
80
+ const deadline = options.timeoutMs === undefined ? null : Date.now() + options.timeoutMs;
81
+ return new Promise((resolve, reject) => {
82
+ let settled = false;
83
+ let cancelled = false;
84
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
85
+ let timer;
86
+ const cleanup = () => {
87
+ clearTimeout(timer);
88
+ signal?.removeEventListener('abort', onAbort);
89
+ };
90
+ /** @param {unknown} error */
91
+ const cancel = (error) => {
92
+ if (settled) return;
93
+ settled = true;
94
+ cancelled = true;
95
+ cleanup();
96
+ options.onCancel?.();
97
+ reject(error);
98
+ };
99
+ const onAbort = () => cancel(signal?.reason);
100
+ const onTimeout = () => cancel(new BridgeError('TIMEOUT', 'MCP connection deadline exceeded.'));
101
+ if (signal?.aborted) {
102
+ onAbort();
103
+ return;
104
+ }
105
+ signal?.addEventListener('abort', onAbort, { once: true });
106
+ if (deadline !== null) {
107
+ timer = setTimeout(onTimeout, Math.max(0, deadline - Date.now()));
108
+ }
109
+ Promise.resolve()
110
+ .then(() => {
111
+ if (deadline !== null && Date.now() >= deadline) onTimeout();
112
+ if (settled) return undefined;
113
+ return operation();
114
+ })
115
+ .then(
116
+ (result) => {
117
+ if (cancelled) options.onCancel?.();
118
+ if (!settled && deadline !== null && Date.now() >= deadline) onTimeout();
119
+ if (settled) return;
120
+ settled = true;
121
+ cleanup();
122
+ resolve(/** @type {T} */ (result));
123
+ },
124
+ (error) => {
125
+ if (cancelled) options.onCancel?.();
126
+ if (settled) return;
127
+ settled = true;
128
+ cleanup();
129
+ reject(error);
130
+ }
131
+ );
132
+ });
133
+ }
134
+
135
+ /**
136
+ * @param {number} delayMs
137
+ * @returns {Promise<void>}
138
+ */
139
+ function waitForMcpRequest(delayMs) {
140
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
141
+ let timer;
142
+ return awaitMcpOperation(
143
+ () =>
144
+ new Promise((resolve) => {
145
+ timer = setTimeout(resolve, delayMs);
146
+ }),
147
+ { onCancel: () => clearTimeout(timer) }
148
+ );
149
+ }
150
+
151
+ /**
152
+ * @param {import('../../agent-client/src/client.js').BridgeClient} client
153
+ * @param {number} timeoutMs
154
+ * @returns {Promise<void>}
155
+ */
156
+ function connectMcpClient(client, timeoutMs) {
157
+ return awaitMcpOperation(() => client.connect(), {
158
+ timeoutMs,
159
+ onCancel: () => {
160
+ void client.close().catch(() => {});
161
+ },
162
+ });
61
163
  }
62
164
 
63
165
  /** @type {ReadonlySet<BridgeMethod>} */
@@ -107,7 +209,7 @@ const RETRY_SAFE_METHODS = new Set([
107
209
  export function isRetrySafeBridgeMethod(method, params) {
108
210
  if (method === 'page.get_console' || method === 'page.get_network') {
109
211
  return (
110
- params.clear !== true &&
212
+ (params.clear === undefined || params.clear === false) &&
111
213
  (method !== 'page.get_network' ||
112
214
  params.source !== 'cdp' ||
113
215
  params.capture === undefined ||
@@ -227,27 +329,49 @@ export function summarizeToolError(error) {
227
329
  */
228
330
  export async function withToolClient(callback, options = {}) {
229
331
  try {
230
- if (!options.destinationId) {
231
- return await withBridgeClient(callback, {
232
- checkProtocolOnConnect: false,
233
- clientId: MCP_CLIENT_ID,
234
- });
235
- }
236
- const client = await createBridgeClientForDestination(options.destinationId, {
237
- checkProtocolOnConnect: false,
238
- clientId: MCP_CLIENT_ID,
239
- });
240
- await client.connect();
241
- try {
242
- return await callback(client);
243
- } finally {
244
- await client.close();
245
- }
332
+ return await withMcpRequestClient(callback, options);
246
333
  } catch (error) {
247
334
  return summarizeToolError(error);
248
335
  }
249
336
  }
250
337
 
338
+ /**
339
+ * @template T
340
+ * @param {(client: import('../../agent-client/src/client.js').BridgeClient) => Promise<T>} callback
341
+ * @param {{ destinationId?: string | null }} [options]
342
+ * @returns {Promise<T>}
343
+ */
344
+ export async function withMcpRequestClient(callback, options = {}) {
345
+ throwIfMcpRequestCancelled();
346
+ const client = await createBridgeClientForDestination(options.destinationId, {
347
+ checkProtocolOnConnect: false,
348
+ clientId: MCP_CLIENT_ID,
349
+ });
350
+ let completed = false;
351
+ try {
352
+ const result = await awaitMcpOperation(
353
+ async () => {
354
+ await connectMcpClient(client, client.defaultTimeoutMs);
355
+ throwIfMcpRequestCancelled();
356
+ return callback(client);
357
+ },
358
+ {
359
+ onCancel: () => {
360
+ void client.close().catch(() => {});
361
+ },
362
+ }
363
+ );
364
+ completed = true;
365
+ return result;
366
+ } finally {
367
+ if (completed) {
368
+ await client.close();
369
+ } else {
370
+ await client.close().catch(() => {});
371
+ }
372
+ }
373
+ }
374
+
251
375
  /**
252
376
  * @param {import('../../agent-client/src/client.js').BridgeClient} client
253
377
  * @param {{ elementRef?: string | undefined, selector?: string | undefined }} input
@@ -255,11 +379,33 @@ export async function withToolClient(callback, options = {}) {
255
379
  * @returns {Promise<string>}
256
380
  */
257
381
  export async function resolveToolRef(client, input, tabId = null) {
382
+ throwIfMcpRequestCancelled();
258
383
  if (typeof input.elementRef === 'string' && input.elementRef) {
259
384
  return input.elementRef;
260
385
  }
261
386
  if (typeof input.selector === 'string' && input.selector) {
262
- return resolveRef(client, input.selector, tabId, REQUEST_SOURCE);
387
+ const response = await requestBridgeWithRetry(
388
+ client,
389
+ 'dom.query',
390
+ {
391
+ selector: input.selector,
392
+ },
393
+ { tabId, source: REQUEST_SOURCE }
394
+ );
395
+ if (!response.ok) {
396
+ throw new BridgeError(response.error.code, response.error.message, response.error.details);
397
+ }
398
+ const result = /** @type {{ nodes?: Array<{ elementRef: string }> }} */ (response.result);
399
+ if (!result.nodes?.length) {
400
+ throw new BridgeError(
401
+ 'INVALID_REQUEST',
402
+ `No element found for selector "${input.selector}".`,
403
+ {
404
+ selector: input.selector,
405
+ }
406
+ );
407
+ }
408
+ return result.nodes[0].elementRef;
263
409
  }
264
410
  throw new BridgeError('INVALID_REQUEST', 'Provide either elementRef or selector.');
265
411
  }
@@ -570,7 +716,7 @@ export function isConnectionLossError(error) {
570
716
  * Wait, bounded, for the client to regain its daemon connection.
571
717
  *
572
718
  * When the client's own auto-reconnect loop is active it owns reconnecting and
573
- * emits `reconnected`; otherwise one manual connect is attempted per poll
719
+ * reconnects independently; otherwise one manual connect is attempted per poll
574
720
  * interval. Application state is never touched here - this only restores the
575
721
  * transport.
576
722
  *
@@ -580,26 +726,17 @@ export function isConnectionLossError(error) {
580
726
  */
581
727
  export async function waitForClientReconnect(client, timeoutMs = RECONNECT_WAIT_TIMEOUT_MS) {
582
728
  const deadline = Date.now() + timeoutMs;
729
+ throwIfMcpRequestCancelled();
583
730
  while (!client.connected && Date.now() < deadline) {
584
731
  if (client.autoReconnect) {
585
- await new Promise((resolve) => {
586
- const remaining = Math.max(0, deadline - Date.now());
587
- /** @type {ReturnType<typeof setTimeout>} */
588
- let timer;
589
- const cleanup = () => {
590
- clearTimeout(timer);
591
- client.off('reconnected', onReconnected);
592
- resolve(undefined);
593
- };
594
- const onReconnected = () => cleanup();
595
- timer = setTimeout(cleanup, Math.min(RECONNECT_POLL_INTERVAL_MS, remaining));
596
- client.on('reconnected', onReconnected);
597
- });
732
+ await waitForMcpRequest(Math.min(RECONNECT_POLL_INTERVAL_MS, deadline - Date.now()));
598
733
  continue;
599
734
  }
600
735
  try {
601
- await client.connect();
736
+ await connectMcpClient(client, deadline - Date.now());
602
737
  } catch (error) {
738
+ throwIfMcpRequestCancelled();
739
+ if (error instanceof BridgeError && error.code === 'TIMEOUT') return false;
603
740
  if (error instanceof Error && /already connected/u.test(error.message)) {
604
741
  break;
605
742
  }
@@ -607,9 +744,7 @@ export async function waitForClientReconnect(client, timeoutMs = RECONNECT_WAIT_
607
744
  }
608
745
  if (!client.connected) {
609
746
  const remaining = Math.max(0, deadline - Date.now());
610
- await new Promise((resolve) =>
611
- setTimeout(resolve, Math.min(RECONNECT_POLL_INTERVAL_MS, remaining))
612
- );
747
+ await waitForMcpRequest(Math.min(RECONNECT_POLL_INTERVAL_MS, remaining));
613
748
  }
614
749
  }
615
750
  return client.connected;
@@ -625,13 +760,22 @@ export async function waitForClientReconnect(client, timeoutMs = RECONNECT_WAIT_
625
760
  export async function requestBridgeWithRetry(client, method, params, options) {
626
761
  const requestOptions = {
627
762
  ...options,
628
- mcpEra: options.mcpEra ?? MCP_REQUEST_ERA.getStore(),
763
+ mcpEra: options.mcpEra ?? MCP_REQUEST_ERA.getStore()?.era,
629
764
  };
765
+ /** @param {typeof requestOptions} attemptOptions */
766
+ const attempt = (attemptOptions) =>
767
+ awaitMcpOperation(async () => {
768
+ throwIfMcpRequestCancelled();
769
+ if (!client.connected) await connectMcpClient(client, client.defaultTimeoutMs);
770
+ throwIfMcpRequestCancelled();
771
+ return requestBridge(client, method, params, attemptOptions);
772
+ });
630
773
  /** @type {BridgeResponse} */
631
774
  let response;
632
775
  try {
633
- response = await requestBridge(client, method, params, requestOptions);
776
+ response = await attempt(requestOptions);
634
777
  } catch (error) {
778
+ throwIfMcpRequestCancelled();
635
779
  if (!isConnectionLossError(error) || !isRetrySafeBridgeMethod(method, params)) {
636
780
  throw error;
637
781
  }
@@ -641,7 +785,7 @@ export async function requestBridgeWithRetry(client, method, params, options) {
641
785
  if (!(await waitForClientReconnect(client))) {
642
786
  throw error;
643
787
  }
644
- return requestBridge(client, method, params, {
788
+ return attempt({
645
789
  ...requestOptions,
646
790
  automaticRetry: 'mcp_second_attempt',
647
791
  });
@@ -655,8 +799,8 @@ export async function requestBridgeWithRetry(client, method, params, options) {
655
799
  process.stderr.write(
656
800
  `[bbx-mcp] Retrying ${method} after ${delay}ms (${response.error.code})\n`
657
801
  );
658
- await new Promise((r) => setTimeout(r, delay));
659
- return requestBridge(client, method, params, {
802
+ await waitForMcpRequest(delay);
803
+ return attempt({
660
804
  ...requestOptions,
661
805
  automaticRetry: 'mcp_second_attempt',
662
806
  });
@@ -172,7 +172,8 @@ export function createBridgeMcpServer(options = {}) {
172
172
  */
173
173
  const registerTool = (name, config, handler) => {
174
174
  const wrappedHandler = /** @type {typeof handler} */ (
175
- (args, context) => runWithMcpRequestEra(era, () => handler(args, context))
175
+ (args, context) =>
176
+ runWithMcpRequestEra(era, () => handler(args, context), context.mcpReq.signal)
176
177
  );
177
178
  const registration = server.registerTool(name, config, wrappedHandler);
178
179
  registrations.set(name, registration);
@@ -17,15 +17,17 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
17
  const repoRoot = path.resolve(__dirname, '../../..');
18
18
 
19
19
  /**
20
- * npm exec may run package lifecycle hooks while resolving the local bin. Keep
21
- * transient `npm exec -- bbx ...` invocations from rewriting host setup or
22
- * replacing a working daemon.
20
+ * Host setup is appropriate for a global CLI install, not when this package is
21
+ * an ordinary project dependency. npm exec is also always non-mutating. The
22
+ * explicit force switch is reserved for controlled packaging/tests.
23
23
  *
24
24
  * @param {NodeJS.ProcessEnv} [env=process.env]
25
25
  * @returns {boolean}
26
26
  */
27
- function shouldSkipPostinstall(env = process.env) {
28
- return env.npm_command === 'exec';
27
+ export function shouldSkipPostinstall(env = process.env) {
28
+ const globalInstall = env.npm_config_global === 'true' || env.npm_config_global === '1';
29
+ const explicitlyEnabled = env.BBX_POSTINSTALL_FORCE === '1';
30
+ return env.npm_command === 'exec' || (!globalInstall && !explicitlyEnabled);
29
31
  }
30
32
 
31
33
  /**
@@ -1,6 +1,6 @@
1
1
  // @ts-check
2
2
 
3
- import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
3
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
 
@@ -9,7 +9,14 @@ import { getBridgeDir } from './config.js';
9
9
  export const BRIDGE_AUTH_TOKEN_ENV = 'BBX_AUTH_TOKEN';
10
10
  export const BRIDGE_AUTH_TOKEN_FILE_ENV = 'BBX_AUTH_TOKEN_FILE';
11
11
  const TOKEN_FILENAME = 'daemon.auth';
12
+ const EXTENSION_TOKEN_FILENAME = 'daemon.extension.auth';
12
13
  const TOKEN_BYTES = 32;
14
+ /**
15
+ * @typedef {{ tokenPath?: string, readFile?: typeof fs.promises.readFile,
16
+ * writeFile?: typeof fs.promises.writeFile, mkdir?: typeof fs.promises.mkdir,
17
+ * chmod?: typeof fs.promises.chmod, link?: typeof fs.promises.link,
18
+ * unlink?: typeof fs.promises.unlink, randomBytesFn?: typeof randomBytes }} TokenInitOptions
19
+ */
13
20
  const TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,256}$/u;
14
21
  const UUID_TOKEN_PATTERN =
15
22
  /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
@@ -22,6 +29,17 @@ export function getBridgeAuthTokenPath(env = process.env) {
22
29
  return path.join(getBridgeDir(env), TOKEN_FILENAME);
23
30
  }
24
31
 
32
+ /**
33
+ * Private credential used only by the Chrome native host when registering the
34
+ * extension role. Remote agent configurations never receive this token.
35
+ *
36
+ * @param {NodeJS.ProcessEnv} [env=process.env]
37
+ * @returns {string}
38
+ */
39
+ export function getBridgeExtensionAuthTokenPath(env = process.env) {
40
+ return path.join(getBridgeDir(env), EXTENSION_TOKEN_FILENAME);
41
+ }
42
+
25
43
  /**
26
44
  * @param {unknown} value
27
45
  * @returns {string | null}
@@ -91,6 +109,25 @@ export async function readBridgeAuthToken(options = {}) {
91
109
  }
92
110
  const tokenPath = options.tokenPath ?? getBridgeAuthTokenPath();
93
111
  const readFile = options.readFile ?? fs.promises.readFile.bind(fs.promises);
112
+ return readStoredBridgeAuthToken(tokenPath, readFile);
113
+ }
114
+
115
+ /**
116
+ * @param {{ tokenPath?: string, readFile?: typeof fs.promises.readFile }} [options={}]
117
+ * @returns {Promise<string | null>}
118
+ */
119
+ export async function readBridgeExtensionAuthToken(options = {}) {
120
+ const tokenPath = options.tokenPath ?? getBridgeExtensionAuthTokenPath();
121
+ const readFile = options.readFile ?? fs.promises.readFile.bind(fs.promises);
122
+ return readStoredBridgeAuthToken(tokenPath, readFile);
123
+ }
124
+
125
+ /**
126
+ * @param {string} tokenPath
127
+ * @param {typeof fs.promises.readFile} readFile
128
+ * @returns {Promise<string | null>}
129
+ */
130
+ async function readStoredBridgeAuthToken(tokenPath, readFile) {
94
131
  try {
95
132
  return normalizeBridgeAuthToken(await readFile(tokenPath, 'utf8'));
96
133
  } catch (error) {
@@ -124,35 +161,122 @@ export async function writeBridgeAuthToken(token, options = {}) {
124
161
  }
125
162
 
126
163
  /**
127
- * @param {{
128
- * tokenPath?: string,
129
- * readFile?: typeof fs.promises.readFile,
130
- * writeFile?: typeof fs.promises.writeFile,
131
- * mkdir?: typeof fs.promises.mkdir,
132
- * chmod?: typeof fs.promises.chmod,
133
- * randomBytesFn?: typeof randomBytes
134
- * }} [options={}]
164
+ * @param {TokenInitOptions} [options={}]
135
165
  * @returns {Promise<string>}
136
166
  */
137
167
  export async function ensureBridgeAuthToken(options = {}) {
138
168
  const tokenPath = options.tokenPath ?? getBridgeAuthTokenPath();
169
+ return ensureStoredBridgeAuthToken(tokenPath, options);
170
+ }
171
+
172
+ /**
173
+ * @param {TokenInitOptions} [options={}]
174
+ * @returns {Promise<string>}
175
+ */
176
+ export async function ensureBridgeExtensionAuthToken(options = {}) {
177
+ const tokenPath = options.tokenPath ?? getBridgeExtensionAuthTokenPath();
178
+ return ensureStoredBridgeAuthToken(tokenPath, options);
179
+ }
180
+
181
+ /**
182
+ * @param {string} tokenPath
183
+ * @param {TokenInitOptions} options
184
+ * @returns {Promise<string>}
185
+ */
186
+ async function ensureStoredBridgeAuthToken(tokenPath, options) {
139
187
  const readFile = options.readFile ?? fs.promises.readFile.bind(fs.promises);
140
188
  const writeFile = options.writeFile ?? fs.promises.writeFile.bind(fs.promises);
141
189
  const mkdir = options.mkdir ?? fs.promises.mkdir.bind(fs.promises);
142
190
  const chmod = options.chmod ?? fs.promises.chmod.bind(fs.promises);
191
+ const link = options.link ?? fs.promises.link.bind(fs.promises);
192
+ const unlink = options.unlink ?? fs.promises.unlink.bind(fs.promises);
143
193
  const randomBytesFn = options.randomBytesFn ?? randomBytes;
144
- const existing = await readBridgeAuthToken({ tokenPath, readFile });
194
+ const existing = await readStoredBridgeAuthToken(tokenPath, readFile);
145
195
  if (existing) {
146
196
  return existing;
147
197
  }
148
198
 
149
199
  const token = randomBytesFn(TOKEN_BYTES).toString('base64url');
150
200
  await mkdir(path.dirname(tokenPath), { recursive: true });
151
- await writeFile(tokenPath, `${token}\n`, { encoding: 'utf8', mode: 0o600 });
152
- if (process.platform !== 'win32') {
153
- await chmod(tokenPath, 0o600).catch(() => {});
201
+ const nonce = randomUUID();
202
+ const temporaryPath = `${tokenPath}.${nonce}.tmp`;
203
+ const ownerPath = `${temporaryPath}.owner`;
204
+ let lockPath = `${tokenPath}.init.lock`;
205
+ let locked = false;
206
+ /** @param {string} filePath */
207
+ const remove = async (filePath) => {
208
+ try {
209
+ await unlink(filePath);
210
+ } catch (error) {
211
+ if (!isMissingFileError(error)) throw error;
212
+ }
213
+ };
214
+ try {
215
+ // Neither the credential nor lock name is visible until its complete contents
216
+ // have been written and closed. Hard links publish without replacing a winner.
217
+ await writeFile(temporaryPath, `${token}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
218
+ await writeFile(ownerPath, `${process.pid}:${nonce}\n`, {
219
+ encoding: 'utf8',
220
+ mode: 0o600,
221
+ flag: 'wx',
222
+ });
223
+ if (process.platform !== 'win32') await chmod(temporaryPath, 0o600).catch(() => {});
224
+ for (let attempt = 0; attempt < 100; attempt += 1) {
225
+ try {
226
+ await link(ownerPath, lockPath);
227
+ locked = true;
228
+ break;
229
+ } catch (error) {
230
+ if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'EEXIST')
231
+ throw error;
232
+ }
233
+ let owner;
234
+ try {
235
+ owner = await readFile(lockPath, 'utf8');
236
+ } catch (error) {
237
+ if (isMissingFileError(error)) continue;
238
+ throw error;
239
+ }
240
+ const match = /^(\d+):([0-9a-f-]{36})\n$/u.exec(owner);
241
+ if (!match || !Number.isSafeInteger(Number(match[1])) || Number(match[1]) <= 0) {
242
+ throw new Error('Bridge auth initialization lock has invalid ownership metadata.');
243
+ }
244
+ try {
245
+ process.kill(Number(match[1]), 0);
246
+ } catch (error) {
247
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ESRCH') {
248
+ // Never unlink a dead owner's lock: two stale-lock cleaners could delete
249
+ // a new winner's lock. All contenders instead follow the same successor.
250
+ lockPath = `${tokenPath}.init-${match[2]}.lock`;
251
+ continue;
252
+ }
253
+ if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'EPERM')
254
+ throw error;
255
+ }
256
+ await new Promise((resolve) => setTimeout(resolve, 10));
257
+ }
258
+ if (!locked) throw new Error('Timed out waiting for bridge auth initialization.');
259
+ const winner = await readStoredBridgeAuthToken(tokenPath, readFile);
260
+ if (winner) return winner;
261
+ await remove(tokenPath);
262
+ try {
263
+ await link(temporaryPath, tokenPath);
264
+ return token;
265
+ } catch (error) {
266
+ if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'EEXIST')
267
+ throw error;
268
+ const published = await readStoredBridgeAuthToken(tokenPath, readFile);
269
+ if (published) return published;
270
+ throw error;
271
+ }
272
+ } finally {
273
+ // Release the lock even if temporary-file cleanup fails.
274
+ try {
275
+ if (locked) await remove(lockPath);
276
+ } finally {
277
+ await Promise.all([remove(temporaryPath), remove(ownerPath)]);
278
+ }
154
279
  }
155
- return token;
156
280
  }
157
281
 
158
282
  /**
@@ -6,7 +6,7 @@ import path from 'node:path';
6
6
  import { promisify } from 'node:util';
7
7
  import { fileURLToPath } from 'node:url';
8
8
 
9
- import { pingExistingDaemon } from './daemon.js';
9
+ import { hasLiveListener, pingExistingDaemon } from './daemon.js';
10
10
  import {
11
11
  applyWindowsTcpTransportDefaults,
12
12
  createSocketBridgeTransport,
@@ -725,7 +725,6 @@ async function removeStaleSocket(transport, rmFn, pingDaemonFn) {
725
725
  if (await safePingDaemon(transport, pingDaemonFn)) {
726
726
  return false;
727
727
  }
728
-
729
728
  try {
730
729
  await fs.promises.access(transport.socketPath);
731
730
  } catch (error) {
@@ -735,6 +734,10 @@ async function removeStaleSocket(transport, rmFn, pingDaemonFn) {
735
734
  throw error;
736
735
  }
737
736
 
737
+ if (await hasLiveListener(transport)) {
738
+ return false;
739
+ }
740
+
738
741
  try {
739
742
  await rmFn(transport.socketPath, { force: true });
740
743
  return true;