@browserbridge/bbx 1.11.1 → 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.
- package/package.json +1 -1
- package/packages/agent-client/src/client.js +110 -37
- package/packages/agent-client/src/runtime.js +1 -1
- package/packages/mcp-server/src/handlers-capture.js +1 -1
- package/packages/mcp-server/src/handlers-navigation.js +14 -21
- package/packages/mcp-server/src/handlers-page.js +16 -20
- package/packages/mcp-server/src/handlers-utils.js +188 -44
- package/packages/mcp-server/src/server.js +2 -1
- package/packages/native-host/src/auth-token.js +90 -28
- package/packages/native-host/src/daemon-process.js +5 -2
- package/packages/native-host/src/daemon.js +117 -22
- package/packages/native-host/src/framing.js +107 -17
- package/packages/native-host/src/native-host.js +4 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
659
|
-
return
|
|
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) =>
|
|
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);
|
|
@@ -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
|
|
|
@@ -11,6 +11,12 @@ export const BRIDGE_AUTH_TOKEN_FILE_ENV = 'BBX_AUTH_TOKEN_FILE';
|
|
|
11
11
|
const TOKEN_FILENAME = 'daemon.auth';
|
|
12
12
|
const EXTENSION_TOKEN_FILENAME = 'daemon.extension.auth';
|
|
13
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
|
+
*/
|
|
14
20
|
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,256}$/u;
|
|
15
21
|
const UUID_TOKEN_PATTERN =
|
|
16
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;
|
|
@@ -155,14 +161,7 @@ export async function writeBridgeAuthToken(token, options = {}) {
|
|
|
155
161
|
}
|
|
156
162
|
|
|
157
163
|
/**
|
|
158
|
-
* @param {{
|
|
159
|
-
* tokenPath?: string,
|
|
160
|
-
* readFile?: typeof fs.promises.readFile,
|
|
161
|
-
* writeFile?: typeof fs.promises.writeFile,
|
|
162
|
-
* mkdir?: typeof fs.promises.mkdir,
|
|
163
|
-
* chmod?: typeof fs.promises.chmod,
|
|
164
|
-
* randomBytesFn?: typeof randomBytes
|
|
165
|
-
* }} [options={}]
|
|
164
|
+
* @param {TokenInitOptions} [options={}]
|
|
166
165
|
* @returns {Promise<string>}
|
|
167
166
|
*/
|
|
168
167
|
export async function ensureBridgeAuthToken(options = {}) {
|
|
@@ -171,14 +170,7 @@ export async function ensureBridgeAuthToken(options = {}) {
|
|
|
171
170
|
}
|
|
172
171
|
|
|
173
172
|
/**
|
|
174
|
-
* @param {{
|
|
175
|
-
* tokenPath?: string,
|
|
176
|
-
* readFile?: typeof fs.promises.readFile,
|
|
177
|
-
* writeFile?: typeof fs.promises.writeFile,
|
|
178
|
-
* mkdir?: typeof fs.promises.mkdir,
|
|
179
|
-
* chmod?: typeof fs.promises.chmod,
|
|
180
|
-
* randomBytesFn?: typeof randomBytes
|
|
181
|
-
* }} [options={}]
|
|
173
|
+
* @param {TokenInitOptions} [options={}]
|
|
182
174
|
* @returns {Promise<string>}
|
|
183
175
|
*/
|
|
184
176
|
export async function ensureBridgeExtensionAuthToken(options = {}) {
|
|
@@ -188,13 +180,7 @@ export async function ensureBridgeExtensionAuthToken(options = {}) {
|
|
|
188
180
|
|
|
189
181
|
/**
|
|
190
182
|
* @param {string} tokenPath
|
|
191
|
-
* @param {
|
|
192
|
-
* readFile?: typeof fs.promises.readFile,
|
|
193
|
-
* writeFile?: typeof fs.promises.writeFile,
|
|
194
|
-
* mkdir?: typeof fs.promises.mkdir,
|
|
195
|
-
* chmod?: typeof fs.promises.chmod,
|
|
196
|
-
* randomBytesFn?: typeof randomBytes
|
|
197
|
-
* }} options
|
|
183
|
+
* @param {TokenInitOptions} options
|
|
198
184
|
* @returns {Promise<string>}
|
|
199
185
|
*/
|
|
200
186
|
async function ensureStoredBridgeAuthToken(tokenPath, options) {
|
|
@@ -202,6 +188,8 @@ async function ensureStoredBridgeAuthToken(tokenPath, options) {
|
|
|
202
188
|
const writeFile = options.writeFile ?? fs.promises.writeFile.bind(fs.promises);
|
|
203
189
|
const mkdir = options.mkdir ?? fs.promises.mkdir.bind(fs.promises);
|
|
204
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);
|
|
205
193
|
const randomBytesFn = options.randomBytesFn ?? randomBytes;
|
|
206
194
|
const existing = await readStoredBridgeAuthToken(tokenPath, readFile);
|
|
207
195
|
if (existing) {
|
|
@@ -210,11 +198,85 @@ async function ensureStoredBridgeAuthToken(tokenPath, options) {
|
|
|
210
198
|
|
|
211
199
|
const token = randomBytesFn(TOKEN_BYTES).toString('base64url');
|
|
212
200
|
await mkdir(path.dirname(tokenPath), { recursive: true });
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
+
}
|
|
216
279
|
}
|
|
217
|
-
return token;
|
|
218
280
|
}
|
|
219
281
|
|
|
220
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;
|