@browserbridge/bbx 1.8.0 → 1.10.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/PRIVACY.md +29 -3
- package/README.md +10 -3
- package/SECURITY.md +12 -0
- package/package.json +7 -1
- package/packages/agent-client/src/cli-args.js +70 -6
- package/packages/agent-client/src/cli-batch.js +127 -59
- package/packages/agent-client/src/cli-helpers.js +8 -0
- package/packages/agent-client/src/cli-output.js +8 -1
- package/packages/agent-client/src/cli-setup-commands.js +3 -1
- package/packages/agent-client/src/cli.js +254 -26
- package/packages/agent-client/src/client.js +156 -3
- package/packages/agent-client/src/command-registry.js +14 -4
- package/packages/agent-client/src/config.js +195 -0
- package/packages/agent-client/src/mcp-config.js +8 -8
- package/packages/agent-client/src/npm-self-update.js +305 -0
- package/packages/agent-client/src/remotes.js +4 -2
- package/packages/agent-client/src/runtime.js +99 -7
- package/packages/agent-client/src/types.ts +38 -0
- package/packages/mcp-server/src/guidance.js +37 -6
- package/packages/mcp-server/src/handlers-capture.js +29 -2
- package/packages/mcp-server/src/handlers-dom.js +54 -3
- package/packages/mcp-server/src/handlers-page.js +333 -60
- package/packages/mcp-server/src/handlers-utils.js +141 -151
- package/packages/mcp-server/src/handlers.js +17 -4
- package/packages/mcp-server/src/server.js +269 -51
- package/packages/mcp-server/src/toolset.js +48 -0
- package/packages/native-host/src/artifact-store.js +353 -0
- package/packages/native-host/src/config.js +9 -1
- package/packages/native-host/src/daemon-logger.js +3 -1
- package/packages/native-host/src/daemon.js +457 -27
- package/packages/native-host/src/dom-baseline-owners.js +210 -0
- package/packages/native-host/src/native-host.js +38 -29
- package/packages/protocol/src/budget-presets.js +182 -0
- package/packages/protocol/src/capability-values.js +2 -0
- package/packages/protocol/src/defaults.js +28 -0
- package/packages/protocol/src/errors.js +48 -5
- package/packages/protocol/src/har.js +34 -0
- package/packages/protocol/src/incidental-sanitizer.js +197 -0
- package/packages/protocol/src/index.js +4 -0
- package/packages/protocol/src/json-lines.js +3 -2
- package/packages/protocol/src/protocol.js +316 -6
- package/packages/protocol/src/recovery-telemetry.js +302 -0
- package/packages/protocol/src/registry.js +226 -6
- package/packages/protocol/src/summary.js +116 -25
- package/packages/protocol/src/types.ts +490 -4
- package/skills/browser-bridge/SKILL.md +26 -8
- package/skills/browser-bridge/references/protocol.md +125 -15
- package/skills/browser-bridge/references/token-efficiency.md +17 -7
|
@@ -29,6 +29,9 @@ import {
|
|
|
29
29
|
getSupportedProtocolVersions,
|
|
30
30
|
MAX_DAEMON_PENDING_TIMEOUT_MS,
|
|
31
31
|
parseJsonLines,
|
|
32
|
+
RecoveryTelemetryCollector,
|
|
33
|
+
normalizeRecoveryTelemetrySummary,
|
|
34
|
+
sanitizeIncidentalValue,
|
|
32
35
|
setProtocolPackageVersion,
|
|
33
36
|
validateBridgeRequest,
|
|
34
37
|
} from '../../protocol/src/index.js';
|
|
@@ -37,10 +40,13 @@ import {
|
|
|
37
40
|
formatBridgeTransport,
|
|
38
41
|
getBridgeListenTarget,
|
|
39
42
|
getBridgeTransport,
|
|
43
|
+
getArtifactStorePath,
|
|
40
44
|
getSocketPath,
|
|
41
45
|
} from './config.js';
|
|
46
|
+
import { ArtifactStore } from './artifact-store.js';
|
|
42
47
|
import { bridgeAuthTokensEqual, ensureBridgeAuthToken, readBridgeAuthToken } from './auth-token.js';
|
|
43
48
|
import { normalizeDaemonLogger } from './daemon-logger.js';
|
|
49
|
+
import { DomBaselineOwnerRegistry, isValidBaselineId } from './dom-baseline-owners.js';
|
|
44
50
|
import { writeJsonLine } from './framing.js';
|
|
45
51
|
|
|
46
52
|
const DAEMON_VERSION = loadDaemonVersion();
|
|
@@ -54,7 +60,7 @@ setProtocolPackageVersion(DAEMON_VERSION);
|
|
|
54
60
|
/** @typedef {import('./daemon-logger.js').DaemonLoggerLike} DaemonLoggerLike */
|
|
55
61
|
/** @typedef {'agent' | 'extension'} SocketRole */
|
|
56
62
|
/** @typedef {import('node:net').Socket & { readonly __role?: SocketRole, __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __browserExtensionId?: string, __accessEnabled?: boolean, __lastActiveAt?: number }} ClientSocket */
|
|
57
|
-
/** @typedef {{ socket: ClientSocket, timeoutId: NodeJS.Timeout, source?: string, method?: string, protocolVersion?: string, targets: Set<ClientSocket>, lastErrorResponse?: import('../../protocol/src/types.js').BridgeResponse }} PendingEntry */
|
|
63
|
+
/** @typedef {{ socket: ClientSocket, timeoutId: NodeJS.Timeout, source?: string, method?: string, protocolVersion?: string, baselineId?: string | null, automaticMcpRetry?: boolean, targets: Set<ClientSocket>, lastErrorResponse?: import('../../protocol/src/types.js').BridgeResponse }} PendingEntry */
|
|
58
64
|
/**
|
|
59
65
|
* @typedef {{
|
|
60
66
|
* installAgentFiles: typeof import('../../agent-client/src/install.js').installAgentFiles,
|
|
@@ -110,6 +116,13 @@ const HEALTH_DEBUGGER_REASONS = new Set([
|
|
|
110
116
|
'target_closed',
|
|
111
117
|
]);
|
|
112
118
|
const HEALTH_CAPTURE_STATES = new Set(['stopped', 'armed', 'active', 'stop_failed']);
|
|
119
|
+
const DIAGNOSTIC_METHODS = new Set([
|
|
120
|
+
'health.ping',
|
|
121
|
+
'daemon.metrics',
|
|
122
|
+
'log.tail',
|
|
123
|
+
'setup.get_status',
|
|
124
|
+
'setup.install',
|
|
125
|
+
]);
|
|
113
126
|
|
|
114
127
|
/**
|
|
115
128
|
* @param {unknown} value
|
|
@@ -146,6 +159,12 @@ function normalizeExtensionHealthResult(value) {
|
|
|
146
159
|
/** @type {Record<string, unknown>} */
|
|
147
160
|
const normalized = {};
|
|
148
161
|
if (result.extension === 'ok') normalized.extension = 'ok';
|
|
162
|
+
if (
|
|
163
|
+
typeof result.extensionVersion === 'string' &&
|
|
164
|
+
/^\d+\.\d+\.\d+$/u.test(result.extensionVersion)
|
|
165
|
+
) {
|
|
166
|
+
normalized.extensionVersion = result.extensionVersion;
|
|
167
|
+
}
|
|
149
168
|
|
|
150
169
|
const access = asHealthRecord(result.access);
|
|
151
170
|
if (access) {
|
|
@@ -216,6 +235,49 @@ function normalizeExtensionHealthResult(value) {
|
|
|
216
235
|
normalized.capture = normalizedCapture;
|
|
217
236
|
}
|
|
218
237
|
|
|
238
|
+
const domBaselines = asHealthRecord(result.domBaselines);
|
|
239
|
+
if (domBaselines) {
|
|
240
|
+
/** @type {Record<string, unknown>} */
|
|
241
|
+
const normalizedBaselines = {};
|
|
242
|
+
for (const key of ['baselineCount', 'bytes', 'tabCount']) {
|
|
243
|
+
const count = normalizeHealthCount(domBaselines[key]);
|
|
244
|
+
if (count !== undefined) normalizedBaselines[key] = count;
|
|
245
|
+
}
|
|
246
|
+
const operationSource = asHealthRecord(domBaselines.operations);
|
|
247
|
+
if (operationSource) {
|
|
248
|
+
/** @type {Record<string, unknown>} */
|
|
249
|
+
const operations = {};
|
|
250
|
+
for (const name of [
|
|
251
|
+
'create',
|
|
252
|
+
'get',
|
|
253
|
+
'compare',
|
|
254
|
+
'release',
|
|
255
|
+
'invalidate',
|
|
256
|
+
'clearTab',
|
|
257
|
+
'clearWindow',
|
|
258
|
+
'clearAll',
|
|
259
|
+
'invalidateNavigation',
|
|
260
|
+
'pruneExpired',
|
|
261
|
+
]) {
|
|
262
|
+
const source = asHealthRecord(operationSource[name]);
|
|
263
|
+
if (!source) continue;
|
|
264
|
+
const calls = normalizeHealthCount(source.calls);
|
|
265
|
+
const totalLatencyMs = normalizeHealthCount(source.totalLatencyMs);
|
|
266
|
+
const maxLatencyMs = normalizeHealthCount(source.maxLatencyMs);
|
|
267
|
+
operations[name] = {
|
|
268
|
+
...(calls === undefined ? {} : { calls }),
|
|
269
|
+
...(totalLatencyMs === undefined ? {} : { totalLatencyMs }),
|
|
270
|
+
...(maxLatencyMs === undefined ? {} : { maxLatencyMs }),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
normalizedBaselines.operations = operations;
|
|
274
|
+
}
|
|
275
|
+
normalized.domBaselines = normalizedBaselines;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const recovery = normalizeRecoveryTelemetrySummary(result.recovery, 'routedExtension');
|
|
279
|
+
if (recovery) normalized.recovery = recovery;
|
|
280
|
+
|
|
219
281
|
const extensionSupportedVersions = Array.isArray(result.supported_versions)
|
|
220
282
|
? result.supported_versions
|
|
221
283
|
.filter(
|
|
@@ -230,6 +292,19 @@ function normalizeExtensionHealthResult(value) {
|
|
|
230
292
|
return normalized;
|
|
231
293
|
}
|
|
232
294
|
|
|
295
|
+
/**
|
|
296
|
+
* @param {import('../../protocol/src/types.js').RecoveryTelemetrySummary} daemon
|
|
297
|
+
* @param {unknown} extensionResult
|
|
298
|
+
*/
|
|
299
|
+
function createHealthRecoveryPayload(daemon, extensionResult) {
|
|
300
|
+
const source = asHealthRecord(extensionResult);
|
|
301
|
+
const routedExtension = normalizeRecoveryTelemetrySummary(source?.recovery, 'routedExtension');
|
|
302
|
+
return {
|
|
303
|
+
daemon,
|
|
304
|
+
...(routedExtension ? { routedExtension } : {}),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
233
308
|
/**
|
|
234
309
|
* @param {string | undefined} requestedVersion
|
|
235
310
|
* @returns {{ supported_versions: readonly string[], deprecated_since?: string, migration_hint?: string }}
|
|
@@ -289,9 +364,15 @@ export function isWindowsNamedPipePath(socketPath) {
|
|
|
289
364
|
* accessEnabled?: boolean,
|
|
290
365
|
* authToken?: string,
|
|
291
366
|
* at?: number
|
|
367
|
+
* artifactId?: string,
|
|
368
|
+
* artifact?: Record<string, unknown>,
|
|
369
|
+
* chunkIndex?: number,
|
|
370
|
+
* data?: string
|
|
292
371
|
* }} DaemonMessage
|
|
293
372
|
*/
|
|
294
373
|
|
|
374
|
+
/** @typedef {import('../../protocol/src/types.js').ArtifactKind} ArtifactKind */
|
|
375
|
+
|
|
295
376
|
export class BridgeDaemon {
|
|
296
377
|
/**
|
|
297
378
|
* @param {{
|
|
@@ -301,7 +382,8 @@ export class BridgeDaemon {
|
|
|
301
382
|
* setupStatusLoader?: () => Promise<SetupStatus>,
|
|
302
383
|
* setupInstaller?: (params: Record<string, unknown>) => Promise<SetupInstallResult>,
|
|
303
384
|
* logger?: DaemonLoggerLike | Pick<Console, 'log' | 'error'>,
|
|
304
|
-
* authToken?: string | null
|
|
385
|
+
* authToken?: string | null,
|
|
386
|
+
* artifactStore?: ArtifactStore
|
|
305
387
|
* }} [options={}]
|
|
306
388
|
*/
|
|
307
389
|
constructor({
|
|
@@ -312,6 +394,7 @@ export class BridgeDaemon {
|
|
|
312
394
|
setupInstaller = installSetupTarget,
|
|
313
395
|
logger = undefined,
|
|
314
396
|
authToken = undefined,
|
|
397
|
+
artifactStore = new ArtifactStore(getArtifactStorePath()),
|
|
315
398
|
} = {}) {
|
|
316
399
|
this.transport = socketPath ? createSocketBridgeTransport(socketPath) : transport;
|
|
317
400
|
this.socketPath =
|
|
@@ -354,6 +437,13 @@ export class BridgeDaemon {
|
|
|
354
437
|
this.requestStartTimes = new Map();
|
|
355
438
|
/** @type {string | null | undefined} */
|
|
356
439
|
this.authToken = authToken;
|
|
440
|
+
this.artifactStore = artifactStore;
|
|
441
|
+
this.artifactStoreInitialized = false;
|
|
442
|
+
this.domBaselines = new DomBaselineOwnerRegistry({
|
|
443
|
+
isCurrentExtensionSocket: (socket) =>
|
|
444
|
+
this.extensionSockets.get(socket.__extensionId ?? '') === socket,
|
|
445
|
+
});
|
|
446
|
+
this.recoveryTelemetry = new RecoveryTelemetryCollector();
|
|
357
447
|
}
|
|
358
448
|
|
|
359
449
|
/**
|
|
@@ -421,11 +511,17 @@ export class BridgeDaemon {
|
|
|
421
511
|
* @param {PendingEntry | undefined} [pending]
|
|
422
512
|
* @returns {PendingEntry | undefined}
|
|
423
513
|
*/
|
|
424
|
-
clearPendingRequest(
|
|
514
|
+
clearPendingRequest(
|
|
515
|
+
requestId,
|
|
516
|
+
pending = this.pendingRequests.get(requestId),
|
|
517
|
+
preserveArtifacts = false
|
|
518
|
+
) {
|
|
425
519
|
if (!pending) {
|
|
426
520
|
return undefined;
|
|
427
521
|
}
|
|
428
522
|
clearTimeout(pending.timeoutId);
|
|
523
|
+
if (preserveArtifacts) this.artifactStore.abortRequest(requestId);
|
|
524
|
+
else this.artifactStore.deleteRequest(requestId);
|
|
429
525
|
this.pendingRequests.delete(requestId);
|
|
430
526
|
this.removePendingRequestIndex(this.pendingRequestsByOwnerSocket, pending.socket, requestId);
|
|
431
527
|
for (const targetSocket of pending.targets) {
|
|
@@ -618,6 +714,9 @@ export class BridgeDaemon {
|
|
|
618
714
|
await fs.promises.chmod(this.socketPath, 0o600);
|
|
619
715
|
}
|
|
620
716
|
|
|
717
|
+
this.artifactStore.reset();
|
|
718
|
+
this.artifactStoreInitialized = true;
|
|
719
|
+
|
|
621
720
|
this.logger.info('Daemon listening', {
|
|
622
721
|
transport: formatBridgeTransport(this.transport),
|
|
623
722
|
socketPath: this.socketPath ?? null,
|
|
@@ -665,6 +764,11 @@ export class BridgeDaemon {
|
|
|
665
764
|
}
|
|
666
765
|
this.extensionSockets.clear();
|
|
667
766
|
this.invalidateConnectedExtensionsCache();
|
|
767
|
+
this.domBaselines.clear();
|
|
768
|
+
if (this.artifactStoreInitialized) {
|
|
769
|
+
this.artifactStore.reset();
|
|
770
|
+
this.artifactStoreInitialized = false;
|
|
771
|
+
}
|
|
668
772
|
|
|
669
773
|
if (this.server) {
|
|
670
774
|
const server = this.server;
|
|
@@ -737,6 +841,13 @@ export class BridgeDaemon {
|
|
|
737
841
|
return this.handleExtensionActivity(socket, message);
|
|
738
842
|
}
|
|
739
843
|
|
|
844
|
+
if (message?.type?.startsWith('extension.artifact.')) {
|
|
845
|
+
if (socket.__role !== 'extension') {
|
|
846
|
+
return this.rejectMessageForRole(socket, message);
|
|
847
|
+
}
|
|
848
|
+
return this.handleExtensionArtifact(socket, message);
|
|
849
|
+
}
|
|
850
|
+
|
|
740
851
|
if (message?.type === 'extension.setup_status.request') {
|
|
741
852
|
if (socket.__role !== 'extension') {
|
|
742
853
|
return this.rejectMessageForRole(socket, message);
|
|
@@ -745,11 +856,12 @@ export class BridgeDaemon {
|
|
|
745
856
|
}
|
|
746
857
|
|
|
747
858
|
if (message?.type === 'agent.request') {
|
|
748
|
-
const
|
|
859
|
+
const isExtensionLocalRequest =
|
|
749
860
|
socket.__role === 'extension' &&
|
|
750
|
-
(message.request?.method === '
|
|
861
|
+
(message.request?.method === 'health.ping' ||
|
|
862
|
+
message.request?.method === 'setup.get_status' ||
|
|
751
863
|
message.request?.method === 'setup.install');
|
|
752
|
-
if (socket.__role !== 'agent' && !
|
|
864
|
+
if (socket.__role !== 'agent' && !isExtensionLocalRequest) {
|
|
753
865
|
return this.rejectMessageForRole(socket, message);
|
|
754
866
|
}
|
|
755
867
|
return this.handleAgentRequest(socket, message);
|
|
@@ -848,6 +960,7 @@ export class BridgeDaemon {
|
|
|
848
960
|
`Request id ${JSON.stringify(request.id)} is already in flight.`
|
|
849
961
|
);
|
|
850
962
|
await writeJsonLine(socket, { type: 'agent.response', response });
|
|
963
|
+
this.recordDaemonRequestOutcome(request, false);
|
|
851
964
|
return;
|
|
852
965
|
}
|
|
853
966
|
|
|
@@ -861,6 +974,9 @@ export class BridgeDaemon {
|
|
|
861
974
|
transport: formatBridgeTransport(this.transport),
|
|
862
975
|
proxy: this.getProxyStatusPayload(),
|
|
863
976
|
connectedExtensions: [],
|
|
977
|
+
recovery: {
|
|
978
|
+
daemon: this.recoveryTelemetry.snapshot('daemon'),
|
|
979
|
+
},
|
|
864
980
|
daemon_supported_versions: getSupportedProtocolVersions(),
|
|
865
981
|
...getVersionNegotiationPayload(request.meta?.protocol_version),
|
|
866
982
|
});
|
|
@@ -880,6 +996,7 @@ export class BridgeDaemon {
|
|
|
880
996
|
}
|
|
881
997
|
|
|
882
998
|
if (request.method === 'daemon.metrics') {
|
|
999
|
+
this.domBaselines.prune();
|
|
883
1000
|
const now = Date.now();
|
|
884
1001
|
const uptimeMs = this.startedAt > 0 ? now - this.startedAt : 0;
|
|
885
1002
|
const avgResponseTimeMs =
|
|
@@ -894,11 +1011,62 @@ export class BridgeDaemon {
|
|
|
894
1011
|
requestsProcessed: this.requestsProcessed,
|
|
895
1012
|
requestsFailed: this.requestsFailed,
|
|
896
1013
|
avgResponseTimeMs,
|
|
1014
|
+
domBaselineOwners: this.domBaselines.size,
|
|
1015
|
+
recovery: this.recoveryTelemetry.snapshot('daemon'),
|
|
897
1016
|
});
|
|
898
1017
|
await writeJsonLine(socket, { type: 'agent.response', response });
|
|
899
1018
|
return;
|
|
900
1019
|
}
|
|
901
1020
|
|
|
1021
|
+
if (request.method === 'artifact.read' || request.method === 'artifact.delete') {
|
|
1022
|
+
const ownerId = socket.__clientId;
|
|
1023
|
+
if (!ownerId) {
|
|
1024
|
+
const response = createFailure(
|
|
1025
|
+
request.id,
|
|
1026
|
+
ERROR_CODES.ACCESS_DENIED,
|
|
1027
|
+
'Artifact access requires a registered agent client.',
|
|
1028
|
+
null,
|
|
1029
|
+
{ method: request.method }
|
|
1030
|
+
);
|
|
1031
|
+
await writeJsonLine(socket, { type: 'agent.response', response });
|
|
1032
|
+
this.recordDaemonRequestOutcome(request, false);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
try {
|
|
1036
|
+
const result =
|
|
1037
|
+
request.method === 'artifact.read'
|
|
1038
|
+
? this.artifactStore.read(
|
|
1039
|
+
String(request.params.artifactId),
|
|
1040
|
+
ownerId,
|
|
1041
|
+
Number(request.params.offset),
|
|
1042
|
+
Number(request.params.maxBytes)
|
|
1043
|
+
)
|
|
1044
|
+
: this.artifactStore.delete(String(request.params.artifactId), ownerId);
|
|
1045
|
+
await writeJsonLine(socket, {
|
|
1046
|
+
type: 'agent.response',
|
|
1047
|
+
response: createSuccess(request.id, result, { method: request.method }),
|
|
1048
|
+
});
|
|
1049
|
+
this.recordDaemonRequestOutcome(request, true);
|
|
1050
|
+
} catch (error) {
|
|
1051
|
+
const code =
|
|
1052
|
+
error && typeof error === 'object' && 'code' in error
|
|
1053
|
+
? /** @type {import('../../protocol/src/types.js').ErrorCode} */ (error.code)
|
|
1054
|
+
: ERROR_CODES.INTERNAL_ERROR;
|
|
1055
|
+
await writeJsonLine(socket, {
|
|
1056
|
+
type: 'agent.response',
|
|
1057
|
+
response: createFailure(
|
|
1058
|
+
request.id,
|
|
1059
|
+
code,
|
|
1060
|
+
error instanceof Error ? error.message : String(error),
|
|
1061
|
+
null,
|
|
1062
|
+
{ method: request.method }
|
|
1063
|
+
),
|
|
1064
|
+
});
|
|
1065
|
+
this.recordDaemonRequestOutcome(request, false);
|
|
1066
|
+
}
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
902
1070
|
if (request.method === 'setup.get_status') {
|
|
903
1071
|
const response = createSuccess(request.id, await this.setupStatusLoader(), {
|
|
904
1072
|
method: request.method,
|
|
@@ -947,17 +1115,48 @@ export class BridgeDaemon {
|
|
|
947
1115
|
typeof request.meta?.target_profile === 'string' ? request.meta.target_profile : null;
|
|
948
1116
|
const hasExplicitTarget = Boolean(targetBrowser || targetProfile);
|
|
949
1117
|
|
|
950
|
-
const
|
|
1118
|
+
const baselineId =
|
|
1119
|
+
typeof request.params.baselineId === 'string' ? request.params.baselineId : null;
|
|
1120
|
+
const usesBaselineOwner =
|
|
1121
|
+
request.method === 'dom.baseline.compare' ||
|
|
1122
|
+
request.method === 'dom.baseline.describe' ||
|
|
1123
|
+
request.method === 'dom.baseline.release';
|
|
1124
|
+
const baselineOwner = usesBaselineOwner ? this.domBaselines.get(baselineId ?? '') : null;
|
|
1125
|
+
const target = usesBaselineOwner
|
|
1126
|
+
? baselineOwner &&
|
|
1127
|
+
(!targetBrowser || baselineOwner.__browserName === targetBrowser) &&
|
|
1128
|
+
(!targetProfile || baselineOwner.__profileLabel === targetProfile)
|
|
1129
|
+
? baselineOwner
|
|
1130
|
+
: null
|
|
1131
|
+
: this.selectExtensionTarget(targetBrowser, targetProfile);
|
|
951
1132
|
|
|
952
1133
|
if (!target) {
|
|
1134
|
+
if (usesBaselineOwner) {
|
|
1135
|
+
const response =
|
|
1136
|
+
request.method === 'dom.baseline.release'
|
|
1137
|
+
? createSuccess(request.id, { baselineId, released: false }, { method: request.method })
|
|
1138
|
+
: createFailure(
|
|
1139
|
+
request.id,
|
|
1140
|
+
ERROR_CODES.DOM_BASELINE_NOT_FOUND,
|
|
1141
|
+
'DOM baseline is missing, expired, released, or its browser destination disconnected.',
|
|
1142
|
+
{ baselineId },
|
|
1143
|
+
{ method: request.method }
|
|
1144
|
+
);
|
|
1145
|
+
await writeJsonLine(socket, { type: 'agent.response', response });
|
|
1146
|
+
this.recordDaemonRequestOutcome(request, response.ok);
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
953
1149
|
const response = createFailure(
|
|
954
1150
|
request.id,
|
|
955
1151
|
ERROR_CODES.EXTENSION_DISCONNECTED,
|
|
956
1152
|
hasExplicitTarget
|
|
957
1153
|
? `No connected extension matches target_browser="${targetBrowser ?? '*'}" target_profile="${targetProfile ?? '*'}".`
|
|
958
|
-
: 'The Chrome extension is not connected to the local bridge daemon.'
|
|
1154
|
+
: 'The Chrome extension is not connected to the local bridge daemon.',
|
|
1155
|
+
null,
|
|
1156
|
+
{ method: request.method }
|
|
959
1157
|
);
|
|
960
1158
|
await writeJsonLine(socket, { type: 'agent.response', response });
|
|
1159
|
+
this.recordDaemonRequestOutcome(request, false);
|
|
961
1160
|
return;
|
|
962
1161
|
}
|
|
963
1162
|
|
|
@@ -966,16 +1165,21 @@ export class BridgeDaemon {
|
|
|
966
1165
|
method: request.method,
|
|
967
1166
|
protocolVersion: request.meta?.protocol_version,
|
|
968
1167
|
source: typeof request.meta?.source === 'string' ? request.meta.source : '',
|
|
1168
|
+
baselineId,
|
|
1169
|
+
automaticMcpRetry: isAutomaticMcpRetry(request.meta),
|
|
969
1170
|
targets: new Set([target]),
|
|
970
1171
|
timeoutId: setTimeout(() => {
|
|
971
1172
|
const pending = this.pendingRequests.get(request.id);
|
|
972
1173
|
if (!pending) return;
|
|
1174
|
+
this.domBaselines.markAbandonedCreate(request.id, pending);
|
|
973
1175
|
this.clearPendingRequest(request.id, pending);
|
|
974
|
-
this.recordRequestCompletion(request.id, false);
|
|
1176
|
+
this.recordRequestCompletion(request.id, false, pending);
|
|
975
1177
|
const response = createFailure(
|
|
976
1178
|
request.id,
|
|
977
1179
|
ERROR_CODES.TIMEOUT,
|
|
978
|
-
'Extension did not respond in time.'
|
|
1180
|
+
'Extension did not respond in time.',
|
|
1181
|
+
null,
|
|
1182
|
+
{ method: pending.method }
|
|
979
1183
|
);
|
|
980
1184
|
void writeJsonLine(pending.socket, {
|
|
981
1185
|
type: 'agent.response',
|
|
@@ -1066,6 +1270,34 @@ export class BridgeDaemon {
|
|
|
1066
1270
|
return candidates[0]?.[1] ?? null;
|
|
1067
1271
|
}
|
|
1068
1272
|
|
|
1273
|
+
/**
|
|
1274
|
+
* @param {ClientSocket} socket
|
|
1275
|
+
* @param {import('../../protocol/src/types.js').BridgeResponse} response
|
|
1276
|
+
*/
|
|
1277
|
+
async releaseOrphanDomBaseline(socket, response) {
|
|
1278
|
+
const result =
|
|
1279
|
+
response.result && typeof response.result === 'object'
|
|
1280
|
+
? /** @type {Record<string, unknown>} */ (response.result)
|
|
1281
|
+
: {};
|
|
1282
|
+
const baselineId = typeof result.baselineId === 'string' ? result.baselineId : '';
|
|
1283
|
+
if (!isValidBaselineId(baselineId)) return;
|
|
1284
|
+
const scope =
|
|
1285
|
+
result.scope && typeof result.scope === 'object'
|
|
1286
|
+
? /** @type {Record<string, unknown>} */ (result.scope)
|
|
1287
|
+
: {};
|
|
1288
|
+
const tabId = Number.isSafeInteger(scope.tabId) ? Number(scope.tabId) : null;
|
|
1289
|
+
await writeJsonLine(socket, {
|
|
1290
|
+
type: 'extension.request',
|
|
1291
|
+
request: {
|
|
1292
|
+
id: `orphan_release_${randomUUID().replaceAll('-', '')}`,
|
|
1293
|
+
method: 'dom.baseline.release',
|
|
1294
|
+
tab_id: tabId,
|
|
1295
|
+
params: { baselineId },
|
|
1296
|
+
meta: { protocol_version: getProtocolVersion(), token_budget: null },
|
|
1297
|
+
},
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1069
1301
|
/**
|
|
1070
1302
|
* @param {BridgeRequest} request
|
|
1071
1303
|
* @returns {number}
|
|
@@ -1140,6 +1372,10 @@ export class BridgeDaemon {
|
|
|
1140
1372
|
*/
|
|
1141
1373
|
handleExtensionAccessUpdate(socket, message) {
|
|
1142
1374
|
const accessEnabled = Boolean(message.accessEnabled);
|
|
1375
|
+
if (!accessEnabled && socket.__extensionId) {
|
|
1376
|
+
this.artifactStore.deleteByExtension(socket.__extensionId);
|
|
1377
|
+
this.domBaselines.clearForSocket(socket);
|
|
1378
|
+
}
|
|
1143
1379
|
if (socket.__accessEnabled !== accessEnabled) {
|
|
1144
1380
|
socket.__accessEnabled = accessEnabled;
|
|
1145
1381
|
this.invalidateConnectedExtensionsCache();
|
|
@@ -1148,6 +1384,58 @@ export class BridgeDaemon {
|
|
|
1148
1384
|
socket.__accessEnabled = accessEnabled;
|
|
1149
1385
|
}
|
|
1150
1386
|
|
|
1387
|
+
/**
|
|
1388
|
+
* @param {ClientSocket} socket
|
|
1389
|
+
* @param {DaemonMessage} message
|
|
1390
|
+
* @returns {void}
|
|
1391
|
+
*/
|
|
1392
|
+
handleExtensionArtifact(socket, message) {
|
|
1393
|
+
const artifact =
|
|
1394
|
+
message.artifact && typeof message.artifact === 'object' ? message.artifact : {};
|
|
1395
|
+
const requestId = typeof artifact.requestId === 'string' ? artifact.requestId : '';
|
|
1396
|
+
const pending = this.pendingRequests.get(requestId);
|
|
1397
|
+
if (!pending || !pending.targets.has(socket) || !socket.__extensionId) return;
|
|
1398
|
+
const ownerId = pending.socket.__clientId;
|
|
1399
|
+
if (!ownerId) return;
|
|
1400
|
+
try {
|
|
1401
|
+
if (message.type === 'extension.artifact.begin') {
|
|
1402
|
+
this.artifactStore.begin({
|
|
1403
|
+
artifactId: String(artifact.artifactId ?? ''),
|
|
1404
|
+
requestId,
|
|
1405
|
+
ownerId,
|
|
1406
|
+
extensionId: socket.__extensionId,
|
|
1407
|
+
kind: /** @type {ArtifactKind} */ (artifact.kind),
|
|
1408
|
+
mimeType: String(artifact.mimeType ?? ''),
|
|
1409
|
+
totalBytes: Number(artifact.byteLength),
|
|
1410
|
+
sha256: String(artifact.sha256 ?? ''),
|
|
1411
|
+
chunkCount: Number(artifact.chunkCount),
|
|
1412
|
+
createdAt: String(artifact.createdAt ?? ''),
|
|
1413
|
+
expiresAt: String(artifact.expiresAt ?? ''),
|
|
1414
|
+
});
|
|
1415
|
+
} else if (message.type === 'extension.artifact.chunk') {
|
|
1416
|
+
this.artifactStore.writeChunk(
|
|
1417
|
+
String(message.artifactId ?? ''),
|
|
1418
|
+
Number(message.chunkIndex),
|
|
1419
|
+
String(message.data ?? '')
|
|
1420
|
+
);
|
|
1421
|
+
} else if (message.type === 'extension.artifact.commit') {
|
|
1422
|
+
this.artifactStore.commit(String(message.artifactId ?? ''));
|
|
1423
|
+
}
|
|
1424
|
+
} catch (error) {
|
|
1425
|
+
const code =
|
|
1426
|
+
error && typeof error === 'object' && 'code' in error
|
|
1427
|
+
? /** @type {import('../../protocol/src/types.js').ErrorCode} */ (error.code)
|
|
1428
|
+
: ERROR_CODES.ARTIFACT_TRANSFER_INVALID;
|
|
1429
|
+
pending.lastErrorResponse = createFailure(
|
|
1430
|
+
requestId,
|
|
1431
|
+
code,
|
|
1432
|
+
error instanceof Error ? error.message : String(error),
|
|
1433
|
+
null,
|
|
1434
|
+
{ method: pending.method }
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1151
1439
|
/**
|
|
1152
1440
|
* Structured remote-exposure status for health payloads. The proxy counts
|
|
1153
1441
|
* as enabled only when the daemon listens on a TCP address other than
|
|
@@ -1188,22 +1476,83 @@ export class BridgeDaemon {
|
|
|
1188
1476
|
}
|
|
1189
1477
|
|
|
1190
1478
|
const pending = this.pendingRequests.get(responseMessage.id);
|
|
1191
|
-
if (!pending
|
|
1479
|
+
if (!pending) {
|
|
1480
|
+
if (this.domBaselines.takeAbandonedCreate(responseMessage.id, socket)) {
|
|
1481
|
+
if (responseMessage.ok) {
|
|
1482
|
+
await this.releaseOrphanDomBaseline(socket, responseMessage).catch((error) => {
|
|
1483
|
+
this.logger.error('orphan DOM baseline release failed', {
|
|
1484
|
+
requestId: responseMessage.id,
|
|
1485
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1486
|
+
});
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
if (!pending.targets.has(socket)) {
|
|
1192
1493
|
return;
|
|
1193
1494
|
}
|
|
1194
1495
|
|
|
1195
1496
|
this.removePendingTarget(responseMessage.id, pending, socket);
|
|
1196
1497
|
|
|
1197
1498
|
if (responseMessage.ok) {
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1499
|
+
const result =
|
|
1500
|
+
responseMessage.result && typeof responseMessage.result === 'object'
|
|
1501
|
+
? /** @type {Record<string, unknown>} */ (responseMessage.result)
|
|
1502
|
+
: {};
|
|
1503
|
+
const artifact =
|
|
1504
|
+
result.artifact && typeof result.artifact === 'object'
|
|
1505
|
+
? /** @type {Record<string, unknown>} */ (result.artifact)
|
|
1506
|
+
: null;
|
|
1507
|
+
const expectedArtifactKind = getArtifactKindForMethod(pending.method);
|
|
1508
|
+
if (
|
|
1509
|
+
result.delivery === 'artifact' &&
|
|
1510
|
+
(!artifact ||
|
|
1511
|
+
artifact.kind !== expectedArtifactKind ||
|
|
1512
|
+
!pending.socket.__clientId ||
|
|
1513
|
+
!this.artifactStore.matchesCommitted(
|
|
1514
|
+
String(artifact.artifactId ?? ''),
|
|
1515
|
+
pending.socket.__clientId,
|
|
1516
|
+
responseMessage.id,
|
|
1517
|
+
artifact,
|
|
1518
|
+
expectedArtifactKind ?? undefined
|
|
1519
|
+
))
|
|
1520
|
+
) {
|
|
1521
|
+
pending.lastErrorResponse ??= createFailure(
|
|
1522
|
+
responseMessage.id,
|
|
1523
|
+
ERROR_CODES.ARTIFACT_TRANSFER_INVALID,
|
|
1524
|
+
'Artifact metadata does not match the request or was not committed before the response.',
|
|
1525
|
+
null,
|
|
1526
|
+
{ method: pending.method }
|
|
1527
|
+
);
|
|
1528
|
+
}
|
|
1529
|
+
if (pending.lastErrorResponse) {
|
|
1530
|
+
await this.finishPendingRequestIfExhausted(responseMessage.id, pending);
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
if (pending.method === 'dom.baseline.create') {
|
|
1534
|
+
const baselineId = typeof result.baselineId === 'string' ? result.baselineId : '';
|
|
1535
|
+
const evicted = Array.isArray(result.evicted) ? result.evicted : [];
|
|
1536
|
+
for (const item of evicted) {
|
|
1537
|
+
if (item && typeof item === 'object' && 'baselineId' in item) {
|
|
1538
|
+
this.domBaselines.deleteIfOwnedBy(String(item.baselineId), socket);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
if (!this.domBaselines.register(baselineId, socket, result.expiresAt)) {
|
|
1542
|
+
await this.releaseOrphanDomBaseline(socket, responseMessage);
|
|
1543
|
+
pending.lastErrorResponse = createFailure(
|
|
1544
|
+
responseMessage.id,
|
|
1545
|
+
ERROR_CODES.DOM_BASELINE_INVALIDATED,
|
|
1546
|
+
'DOM baseline owner metadata is invalid.',
|
|
1547
|
+
null,
|
|
1548
|
+
{ method: pending.method }
|
|
1549
|
+
);
|
|
1550
|
+
await this.finishPendingRequestIfExhausted(responseMessage.id, pending);
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
} else if (pending.method === 'dom.baseline.release' && pending.baselineId) {
|
|
1554
|
+
this.domBaselines.delete(pending.baselineId);
|
|
1555
|
+
}
|
|
1207
1556
|
const response =
|
|
1208
1557
|
pending.method === 'health.ping'
|
|
1209
1558
|
? createSuccess(
|
|
@@ -1219,6 +1568,10 @@ export class BridgeDaemon {
|
|
|
1219
1568
|
daemonVersion: DAEMON_VERSION,
|
|
1220
1569
|
daemon_supported_versions: getSupportedProtocolVersions(),
|
|
1221
1570
|
proxy: this.getProxyStatusPayload(),
|
|
1571
|
+
recovery: createHealthRecoveryPayload(
|
|
1572
|
+
this.recoveryTelemetry.snapshot('daemon'),
|
|
1573
|
+
responseMessage.result
|
|
1574
|
+
),
|
|
1222
1575
|
},
|
|
1223
1576
|
{
|
|
1224
1577
|
method: pending.method,
|
|
@@ -1226,14 +1579,38 @@ export class BridgeDaemon {
|
|
|
1226
1579
|
)
|
|
1227
1580
|
: responseMessage;
|
|
1228
1581
|
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1582
|
+
try {
|
|
1583
|
+
await writeJsonLine(pending.socket, {
|
|
1584
|
+
type: 'agent.response',
|
|
1585
|
+
response,
|
|
1586
|
+
});
|
|
1587
|
+
} catch (error) {
|
|
1588
|
+
if (pending.method === 'dom.baseline.create') {
|
|
1589
|
+
const baselineId =
|
|
1590
|
+
result && typeof result.baselineId === 'string' ? result.baselineId : null;
|
|
1591
|
+
if (baselineId) this.domBaselines.delete(baselineId);
|
|
1592
|
+
await this.releaseOrphanDomBaseline(socket, responseMessage).catch(() => {});
|
|
1593
|
+
}
|
|
1594
|
+
this.clearPendingRequest(responseMessage.id, pending);
|
|
1595
|
+
this.recordRequestCompletion(responseMessage.id, false, pending);
|
|
1596
|
+
throw error;
|
|
1597
|
+
}
|
|
1598
|
+
this.clearPendingRequest(responseMessage.id, pending, true);
|
|
1599
|
+
this.recordRequestCompletion(responseMessage.id, true, pending);
|
|
1600
|
+
this.pushLog({
|
|
1601
|
+
at: new Date().toISOString(),
|
|
1602
|
+
method: responseMessage.meta?.method ?? null,
|
|
1603
|
+
ok: true,
|
|
1604
|
+
id: responseMessage.id,
|
|
1605
|
+
source: pending.source || null,
|
|
1232
1606
|
});
|
|
1233
1607
|
return;
|
|
1234
1608
|
}
|
|
1235
1609
|
|
|
1236
1610
|
// A routed request has one target, so its error is final.
|
|
1611
|
+
if (pending.baselineId && responseMessage.error?.code === ERROR_CODES.DOM_BASELINE_NOT_FOUND) {
|
|
1612
|
+
this.domBaselines.delete(pending.baselineId);
|
|
1613
|
+
}
|
|
1237
1614
|
pending.lastErrorResponse = responseMessage;
|
|
1238
1615
|
|
|
1239
1616
|
await this.finishPendingRequestIfExhausted(responseMessage.id, pending);
|
|
@@ -1245,6 +1622,8 @@ export class BridgeDaemon {
|
|
|
1245
1622
|
*/
|
|
1246
1623
|
handleSocketClose(socket) {
|
|
1247
1624
|
if (socket.__extensionId) {
|
|
1625
|
+
this.artifactStore.deleteByExtension(socket.__extensionId);
|
|
1626
|
+
this.domBaselines.clearForSocket(socket);
|
|
1248
1627
|
this.logger.info('extension disconnected', { extensionId: socket.__extensionId });
|
|
1249
1628
|
if (this.extensionSockets.get(socket.__extensionId) === socket) {
|
|
1250
1629
|
this.extensionSockets.delete(socket.__extensionId);
|
|
@@ -1266,8 +1645,9 @@ export class BridgeDaemon {
|
|
|
1266
1645
|
if (!pending) {
|
|
1267
1646
|
continue;
|
|
1268
1647
|
}
|
|
1648
|
+
this.domBaselines.markAbandonedCreate(id, pending);
|
|
1269
1649
|
this.clearPendingRequest(id, pending);
|
|
1270
|
-
this.recordRequestCompletion(id, false);
|
|
1650
|
+
this.recordRequestCompletion(id, false, pending);
|
|
1271
1651
|
}
|
|
1272
1652
|
}
|
|
1273
1653
|
|
|
@@ -1304,7 +1684,7 @@ export class BridgeDaemon {
|
|
|
1304
1684
|
}
|
|
1305
1685
|
|
|
1306
1686
|
this.clearPendingRequest(requestId, pending);
|
|
1307
|
-
this.recordRequestCompletion(requestId, false);
|
|
1687
|
+
this.recordRequestCompletion(requestId, false, pending);
|
|
1308
1688
|
|
|
1309
1689
|
const response =
|
|
1310
1690
|
pending.lastErrorResponse ??
|
|
@@ -1333,9 +1713,10 @@ export class BridgeDaemon {
|
|
|
1333
1713
|
/**
|
|
1334
1714
|
* @param {string} requestId
|
|
1335
1715
|
* @param {boolean} ok
|
|
1716
|
+
* @param {PendingEntry | undefined} [pending]
|
|
1336
1717
|
* @returns {void}
|
|
1337
1718
|
*/
|
|
1338
|
-
recordRequestCompletion(requestId, ok) {
|
|
1719
|
+
recordRequestCompletion(requestId, ok, pending) {
|
|
1339
1720
|
const startedAt = this.requestStartTimes.get(requestId);
|
|
1340
1721
|
this.requestStartTimes.delete(requestId);
|
|
1341
1722
|
this.requestsProcessed += 1;
|
|
@@ -1345,6 +1726,34 @@ export class BridgeDaemon {
|
|
|
1345
1726
|
if (typeof startedAt === 'number') {
|
|
1346
1727
|
this.totalResponseTimeMs += Date.now() - startedAt;
|
|
1347
1728
|
}
|
|
1729
|
+
if (pending?.method && !DIAGNOSTIC_METHODS.has(pending.method)) {
|
|
1730
|
+
this.recordDaemonRequestOutcome(
|
|
1731
|
+
{
|
|
1732
|
+
method: /** @type {BridgeRequest['method']} */ (pending.method),
|
|
1733
|
+
meta: {
|
|
1734
|
+
protocol_version: pending.protocolVersion ?? getProtocolVersion(),
|
|
1735
|
+
token_budget: null,
|
|
1736
|
+
...(pending.automaticMcpRetry
|
|
1737
|
+
? { source: 'mcp', automatic_retry: { attempt: 2, reason: 'retryable_error' } }
|
|
1738
|
+
: {}),
|
|
1739
|
+
},
|
|
1740
|
+
},
|
|
1741
|
+
ok
|
|
1742
|
+
);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
/**
|
|
1747
|
+
* @param {Pick<BridgeRequest, 'method' | 'meta'>} request
|
|
1748
|
+
* @param {boolean} ok
|
|
1749
|
+
*/
|
|
1750
|
+
recordDaemonRequestOutcome(request, ok) {
|
|
1751
|
+
if (DIAGNOSTIC_METHODS.has(request.method)) return;
|
|
1752
|
+
const outcome = ok ? 'success' : 'failure';
|
|
1753
|
+
this.recoveryTelemetry.record('request_outcome', outcome, request.method);
|
|
1754
|
+
if (isAutomaticMcpRetry(request.meta)) {
|
|
1755
|
+
this.recoveryTelemetry.record('automatic_mcp_retry', outcome, request.method);
|
|
1756
|
+
}
|
|
1348
1757
|
}
|
|
1349
1758
|
|
|
1350
1759
|
/**
|
|
@@ -1352,13 +1761,34 @@ export class BridgeDaemon {
|
|
|
1352
1761
|
* @returns {void}
|
|
1353
1762
|
*/
|
|
1354
1763
|
pushLog(entry) {
|
|
1355
|
-
this.recentLog.push(entry);
|
|
1764
|
+
this.recentLog.push(/** @type {Record<string, unknown>} */ (sanitizeIncidentalValue(entry)));
|
|
1356
1765
|
if (this.recentLog.length > DAEMON_RECENT_LOG_LIMIT) {
|
|
1357
1766
|
this.recentLog.shift();
|
|
1358
1767
|
}
|
|
1359
1768
|
}
|
|
1360
1769
|
}
|
|
1361
1770
|
|
|
1771
|
+
/** @param {Record<string, unknown>} meta */
|
|
1772
|
+
function isAutomaticMcpRetry(meta) {
|
|
1773
|
+
const marker = asHealthRecord(meta.automatic_retry);
|
|
1774
|
+
if (meta.source !== 'mcp' || !marker) return false;
|
|
1775
|
+
const keys = Object.keys(marker).sort();
|
|
1776
|
+
return (
|
|
1777
|
+
keys.length === 2 &&
|
|
1778
|
+
keys[0] === 'attempt' &&
|
|
1779
|
+
keys[1] === 'reason' &&
|
|
1780
|
+
marker.attempt === 2 &&
|
|
1781
|
+
marker.reason === 'retryable_error'
|
|
1782
|
+
);
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
/** @param {string | undefined} method @returns {ArtifactKind | null} */
|
|
1786
|
+
function getArtifactKindForMethod(method) {
|
|
1787
|
+
if (method?.startsWith('screenshot.')) return 'screenshot';
|
|
1788
|
+
if (method === 'network.export_har') return 'har';
|
|
1789
|
+
return null;
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1362
1792
|
/**
|
|
1363
1793
|
* Check whether a daemon is already listening on the given transport.
|
|
1364
1794
|
* Connects, sends a health.ping, and waits up to 500 ms for a response.
|