@browserbridge/bbx 1.7.4 → 1.7.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserbridge/bbx",
3
- "version": "1.7.4",
3
+ "version": "1.7.5",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "agent-tools",
@@ -0,0 +1,99 @@
1
+ // @ts-check
2
+
3
+ import { randomUUID } from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ /**
8
+ * Replace a file through a sibling temporary file so readers never observe a
9
+ * partial write. Existing permissions are preserved unless an explicit mode is
10
+ * requested.
11
+ *
12
+ * @param {string} targetPath
13
+ * @param {string | NodeJS.ArrayBufferView} data
14
+ * @param {{ encoding?: BufferEncoding, mode?: number, preserveMode?: boolean }} [options={ }]
15
+ * @returns {Promise<void>}
16
+ */
17
+ export async function atomicWriteFile(targetPath, data, options = {}) {
18
+ const directory = path.dirname(targetPath);
19
+ const temporaryPath = path.join(
20
+ directory,
21
+ `.${path.basename(targetPath)}.${process.pid}.${randomUUID()}.tmp`
22
+ );
23
+ await fs.promises.mkdir(directory, { recursive: true });
24
+
25
+ let existingMode;
26
+ if (options.preserveMode !== false && options.mode === undefined) {
27
+ try {
28
+ existingMode = (await fs.promises.stat(targetPath)).mode & 0o777;
29
+ } catch (error) {
30
+ if (!isMissingFileError(error)) {
31
+ throw error;
32
+ }
33
+ }
34
+ }
35
+
36
+ const mode = options.mode ?? existingMode;
37
+ try {
38
+ await fs.promises.writeFile(temporaryPath, data, {
39
+ ...(options.encoding ? { encoding: options.encoding } : {}),
40
+ ...(mode === undefined ? {} : { mode }),
41
+ flag: 'wx',
42
+ });
43
+ if (process.platform !== 'win32' && mode !== undefined) {
44
+ await fs.promises.chmod(temporaryPath, mode);
45
+ }
46
+ try {
47
+ await fs.promises.rename(temporaryPath, targetPath);
48
+ } catch (error) {
49
+ if (process.platform !== 'win32' || !isReplaceError(error)) {
50
+ throw error;
51
+ }
52
+ try {
53
+ await fs.promises.stat(targetPath);
54
+ } catch {
55
+ throw error;
56
+ }
57
+
58
+ const backupPath = `${temporaryPath}.backup`;
59
+ await fs.promises.rename(targetPath, backupPath);
60
+ try {
61
+ await fs.promises.rename(temporaryPath, targetPath);
62
+ } catch (replaceError) {
63
+ await fs.promises.rename(backupPath, targetPath).catch(() => {});
64
+ throw replaceError;
65
+ }
66
+ await fs.promises.rm(backupPath, { force: true }).catch(() => {});
67
+ }
68
+ if (process.platform !== 'win32' && options.mode !== undefined) {
69
+ await fs.promises.chmod(targetPath, options.mode);
70
+ }
71
+ } catch (error) {
72
+ await fs.promises.rm(temporaryPath, { force: true }).catch(() => {});
73
+ throw error;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Windows does not replace an existing file with rename().
79
+ *
80
+ * @param {unknown} error
81
+ * @returns {boolean}
82
+ */
83
+ function isReplaceError(error) {
84
+ const code =
85
+ error && typeof error === 'object' ? /** @type {{ code?: unknown }} */ (error).code : null;
86
+ return code === 'EEXIST' || code === 'EPERM' || code === 'EACCES';
87
+ }
88
+
89
+ /**
90
+ * @param {unknown} error
91
+ * @returns {boolean}
92
+ */
93
+ function isMissingFileError(error) {
94
+ return Boolean(
95
+ error &&
96
+ typeof error === 'object' &&
97
+ /** @type {{ code?: unknown }} */ (error).code === 'ENOENT'
98
+ );
99
+ }
@@ -96,7 +96,7 @@ export function parseJsonObject(value) {
96
96
 
97
97
  /**
98
98
  * Parse a CLI argument as a positive integer, throwing a user-friendly error
99
- * if the value is missing or not a finite number.
99
+ * if the value is missing or not a positive integer.
100
100
  *
101
101
  * @param {string | undefined} value
102
102
  * @param {string} argName - The argument name shown in the error message
@@ -104,12 +104,29 @@ export function parseJsonObject(value) {
104
104
  */
105
105
  export function parseIntArg(value, argName) {
106
106
  const n = Number(value);
107
- if (!value || !Number.isFinite(n)) {
108
- throw new Error(`${argName} must be a number (got ${JSON.stringify(value)}).`);
107
+ if (!value || !Number.isInteger(n) || n <= 0) {
108
+ throw new Error(`${argName} must be a positive integer (got ${JSON.stringify(value)}).`);
109
109
  }
110
110
  return n;
111
111
  }
112
112
 
113
+ /**
114
+ * Parse a CLI argument as a finite number. This is intentionally separate from
115
+ * positive integer parsing because viewport coordinates may be fractional or
116
+ * negative.
117
+ *
118
+ * @param {string | undefined} value
119
+ * @param {string} argName
120
+ * @returns {number}
121
+ */
122
+ export function parseNumberArg(value, argName) {
123
+ const number = Number(value);
124
+ if (value === undefined || value === '' || !Number.isFinite(number)) {
125
+ throw new Error(`${argName} must be a number (got ${JSON.stringify(value)}).`);
126
+ }
127
+ return number;
128
+ }
129
+
113
130
  /**
114
131
  * @param {string} method
115
132
  * @returns {boolean}
@@ -14,7 +14,7 @@ import {
14
14
  readProxyConfig,
15
15
  SUPPORTED_BROWSERS,
16
16
  } from '../../native-host/src/config.js';
17
- import { writeBridgeAuthToken } from '../../native-host/src/auth-token.js';
17
+ import { getBridgeAuthTokenPath } from '../../native-host/src/auth-token.js';
18
18
  import { pingExistingDaemon } from '../../native-host/src/daemon.js';
19
19
  import { restartBridgeDaemon } from '../../native-host/src/daemon-process.js';
20
20
  import { uninstallNativeManifest } from '../../native-host/src/install-manifest.js';
@@ -25,8 +25,13 @@ import {
25
25
  summarizeBatchErrorItem,
26
26
  summarizeBatchResponseItem,
27
27
  } from '../../protocol/src/index.js';
28
+ import {
29
+ restartRegisteredMcpProcesses,
30
+ tryStartMcpProcessControl,
31
+ } from '../../mcp-server/src/lifecycle.js';
28
32
  import { startBridgeMcpServer } from '../../mcp-server/src/server.js';
29
33
  import { CLI_HELP_SECTIONS, SHORTCUT_COMMANDS } from './command-registry.js';
34
+ import { atomicWriteFile } from './atomic-write.js';
30
35
  import {
31
36
  interactiveCheckbox,
32
37
  interactiveConfirm,
@@ -55,6 +60,7 @@ import {
55
60
  import { getDoctorReport, requestBridge, resolveRef } from './runtime.js';
56
61
  import {
57
62
  addRemoteDestination,
63
+ assertProxyBindSafety,
58
64
  createBridgeClientForDestination,
59
65
  listBridgeDestinations,
60
66
  normalizeDestinationId,
@@ -62,6 +68,7 @@ import {
62
68
  readRemoteConfig,
63
69
  removeRemoteDestination,
64
70
  resolveProxyEnableSettings,
71
+ isLoopbackHost,
65
72
  } from './remotes.js';
66
73
  import { collectSetupStatus } from './setup-status.js';
67
74
  import { annotateBridgeSummary, summarizeBridgeResponse } from './subagent.js';
@@ -72,6 +79,7 @@ import { annotateBridgeSummary, summarizeBridgeResponse } from './subagent.js';
72
79
  const REQUEST_SOURCE = 'cli';
73
80
  const TEST_TIMEOUT_ENV = 'BBX_CLIENT_REQUEST_TIMEOUT_MS';
74
81
  const TEST_DETECTED_MCP_CLIENTS_ENV = 'BBX_TEST_DETECTED_MCP_CLIENTS';
82
+ const TEST_DETECTED_SKILL_TARGETS_ENV = 'BBX_TEST_DETECTED_SKILL_TARGETS';
75
83
  const REMOTE_ENV = 'BBX_REMOTE';
76
84
 
77
85
  /**
@@ -178,9 +186,7 @@ if (command === 'uninstall') {
178
186
 
179
187
  if (command === 'install-skill') {
180
188
  // When no positional target is given, detect installed agents and prompt.
181
- const positional = rest.filter((a) => !a.startsWith('--'));
182
-
183
- if (positional.length === 0) {
189
+ if (!hasExplicitInstallSkillTarget(rest)) {
184
190
  let scopeOptions;
185
191
  try {
186
192
  scopeOptions = parseInstallAgentArgs(rest);
@@ -420,7 +426,13 @@ if (command === 'install-mcp') {
420
426
  if (command === 'mcp') {
421
427
  const [subcommand, clientName] = rest;
422
428
  if (subcommand === 'serve') {
423
- await startBridgeMcpServer();
429
+ const control = await tryStartMcpProcessControl();
430
+ try {
431
+ await startBridgeMcpServer();
432
+ } catch (error) {
433
+ await control?.dispose();
434
+ throw error;
435
+ }
424
436
  await new Promise(() => {});
425
437
  }
426
438
  if (subcommand === 'config') {
@@ -528,12 +540,26 @@ async function main() {
528
540
 
529
541
  if (command === 'restart') {
530
542
  const result = await restartBridgeDaemon();
543
+ const mcpProcesses = await restartRegisteredMcpProcesses();
544
+ if (mcpProcesses.restartFailed > 0) {
545
+ process.exitCode = 1;
546
+ }
531
547
  printJson({
532
- ok: true,
533
- summary: result.previouslyRunning
534
- ? 'Browser Bridge daemon restarted.'
535
- : 'Browser Bridge daemon started.',
536
- evidence: result,
548
+ ok: mcpProcesses.restartFailed === 0,
549
+ summary: `${
550
+ result.previouslyRunning
551
+ ? 'Browser Bridge daemon restarted.'
552
+ : 'Browser Bridge daemon started.'
553
+ }${
554
+ mcpProcesses.restartRequested > 0
555
+ ? ` Restart requested for ${mcpProcesses.restartRequested} MCP server(s).`
556
+ : ''
557
+ }${
558
+ mcpProcesses.restartFailed > 0
559
+ ? ` Could not contact ${mcpProcesses.restartFailed} MCP server(s).`
560
+ : ''
561
+ }`,
562
+ evidence: { ...result, mcpProcesses },
537
563
  });
538
564
  return;
539
565
  }
@@ -623,7 +649,25 @@ async function main() {
623
649
  await ensureClientConnection();
624
650
  const results = await Promise.all(
625
651
  calls.map(async (call) => {
626
- if (!call || typeof call !== 'object' || typeof call.method !== 'string') {
652
+ if (!call || typeof call !== 'object' || Array.isArray(call)) {
653
+ return {
654
+ method: '',
655
+ tabId: null,
656
+ ok: false,
657
+ summary: 'INVALID_REQUEST: Each batch call needs a method.',
658
+ evidence: null,
659
+ durationMs: 0,
660
+ approxTokens: 0,
661
+ meta: { protocol_version: getProtocolVersion() },
662
+ error: {
663
+ code: 'INVALID_REQUEST',
664
+ message: 'Each batch call needs a method.',
665
+ },
666
+ response: null,
667
+ };
668
+ }
669
+ const batchCall = /** @type {Record<string, unknown>} */ (call);
670
+ if (typeof batchCall.method !== 'string') {
627
671
  return {
628
672
  method: '',
629
673
  tabId: null,
@@ -640,33 +684,62 @@ async function main() {
640
684
  response: null,
641
685
  };
642
686
  }
643
- if (!METHODS.includes(/** @type {BridgeMethod} */ (call.method))) {
687
+ if (!METHODS.includes(/** @type {BridgeMethod} */ (batchCall.method))) {
688
+ return {
689
+ method: batchCall.method,
690
+ tabId: null,
691
+ ok: false,
692
+ summary: `INVALID_REQUEST: Unknown bridge method "${batchCall.method}".`,
693
+ evidence: null,
694
+ durationMs: 0,
695
+ approxTokens: 0,
696
+ meta: { protocol_version: getProtocolVersion() },
697
+ error: {
698
+ code: 'INVALID_REQUEST',
699
+ message: `Unknown bridge method "${batchCall.method}".`,
700
+ },
701
+ response: null,
702
+ };
703
+ }
704
+ const method = /** @type {BridgeMethod} */ (batchCall.method);
705
+ if (
706
+ batchCall.params !== undefined &&
707
+ (!batchCall.params ||
708
+ typeof batchCall.params !== 'object' ||
709
+ Array.isArray(batchCall.params))
710
+ ) {
644
711
  return {
645
- method: call.method,
712
+ method,
646
713
  tabId: null,
647
714
  ok: false,
648
- summary: `INVALID_REQUEST: Unknown bridge method "${call.method}".`,
715
+ summary: 'INVALID_REQUEST: Batch call params must be a JSON object.',
649
716
  evidence: null,
650
717
  durationMs: 0,
651
718
  approxTokens: 0,
652
719
  meta: { protocol_version: getProtocolVersion() },
653
720
  error: {
654
721
  code: 'INVALID_REQUEST',
655
- message: `Unknown bridge method "${call.method}".`,
722
+ message: 'Batch call params must be a JSON object.',
656
723
  },
657
724
  response: null,
658
725
  };
659
726
  }
660
- const method = /** @type {BridgeMethod} */ (call.method);
727
+ const params =
728
+ batchCall.params === undefined
729
+ ? {}
730
+ : /** @type {Record<string, unknown>} */ (batchCall.params);
661
731
  const tabId =
662
- methodNeedsTab(call.method) && typeof call.tabId === 'number' ? call.tabId : null;
732
+ methodNeedsTab(method) &&
733
+ typeof batchCall.tabId === 'number' &&
734
+ Number.isInteger(batchCall.tabId) &&
735
+ batchCall.tabId > 0
736
+ ? batchCall.tabId
737
+ : null;
663
738
  const startTime = Date.now();
664
739
  try {
665
- const response = await client.request({
666
- method,
740
+ const response = await requestBridge(client, method, params, {
667
741
  tabId,
668
- params: call.params || {},
669
- meta: { source: REQUEST_SOURCE },
742
+ source: REQUEST_SOURCE,
670
743
  });
671
744
  return summarizeBatchResponseItem({
672
745
  method,
@@ -684,6 +757,9 @@ async function main() {
684
757
  }
685
758
  })
686
759
  );
760
+ if (results.some((result) => !result.ok)) {
761
+ process.exitCode = 1;
762
+ }
687
763
  printJson(results);
688
764
  return;
689
765
  }
@@ -817,22 +893,7 @@ async function main() {
817
893
  const { tabId, rest: iArgs } = extractTabFlag(rest);
818
894
  const sub = iArgs[0];
819
895
  if (sub === 'add') {
820
- const pattern = iArgs[1];
821
- if (!pattern)
822
- throw new Error(
823
- 'Usage: intercept add <urlPattern> [--respond <body>] [--status <code>] [--block]'
824
- );
825
- const isBlock = iArgs.includes('--block');
826
- const statusIdx = iArgs.indexOf('--status');
827
- const statusCode = statusIdx !== -1 ? parseIntArg(iArgs[statusIdx + 1], 'status') : 200;
828
- const respondIdx = iArgs.indexOf('--respond');
829
- const body =
830
- respondIdx !== -1
831
- ? iArgs
832
- .slice(respondIdx + 1)
833
- .filter((a) => !a.startsWith('--'))
834
- .join(' ')
835
- : undefined;
896
+ const { pattern, isBlock, statusCode, body } = parseInterceptAddArgs(iArgs.slice(1));
836
897
  const response = await requestBridge(
837
898
  client,
838
899
  'network.intercept.add',
@@ -847,7 +908,9 @@ async function main() {
847
908
  await printSummary(response);
848
909
  } else if (sub === 'remove') {
849
910
  const ruleId = iArgs[1];
850
- if (!ruleId) throw new Error('Usage: intercept remove <ruleId>');
911
+ if (!ruleId || iArgs.length !== 2) {
912
+ throw new Error('Usage: intercept remove <ruleId>');
913
+ }
851
914
  const response = await requestBridge(
852
915
  client,
853
916
  'network.intercept.remove',
@@ -856,6 +919,9 @@ async function main() {
856
919
  );
857
920
  await printSummary(response);
858
921
  } else if (sub === 'list') {
922
+ if (iArgs.length !== 1) {
923
+ throw new Error('Usage: intercept list');
924
+ }
859
925
  const response = await requestBridge(
860
926
  client,
861
927
  'network.intercept.list',
@@ -864,6 +930,9 @@ async function main() {
864
930
  );
865
931
  await printSummary(response);
866
932
  } else if (sub === 'clear') {
933
+ if (iArgs.length !== 1) {
934
+ throw new Error('Usage: intercept clear');
935
+ }
867
936
  const response = await requestBridge(
868
937
  client,
869
938
  'network.intercept.clear',
@@ -945,10 +1014,13 @@ async function handleProxyCommand(args) {
945
1014
  options,
946
1015
  randomUUID
947
1016
  );
948
- await writeBridgeAuthToken(token);
1017
+ assertProxyBindSafety(existing, options, bindHost);
1018
+ await atomicWriteFile(getBridgeAuthTokenPath(), `${token}\n`, {
1019
+ encoding: 'utf8',
1020
+ mode: 0o600,
1021
+ });
949
1022
  const configPath = getProxyConfigPath();
950
- await fs.promises.mkdir(path.dirname(configPath), { recursive: true });
951
- await fs.promises.writeFile(
1023
+ await atomicWriteFile(
952
1024
  configPath,
953
1025
  `${JSON.stringify(
954
1026
  {
@@ -963,7 +1035,6 @@ async function handleProxyCommand(args) {
963
1035
  { encoding: 'utf8', mode: 0o600 }
964
1036
  );
965
1037
  const result = await restartBridgeDaemon();
966
- const exampleHost = getProxyExampleHost(bindHost);
967
1038
  const tokenNote =
968
1039
  tokenSource === 'existing'
969
1040
  ? ' (unchanged - already-configured clients keep working; pass --rotate-token to generate a new secret)'
@@ -978,16 +1049,26 @@ async function handleProxyCommand(args) {
978
1049
  `Daemon: ${result.previouslyRunning ? 'restarted' : 'started'} (pid ${result.pid})`,
979
1050
  ];
980
1051
  if (tokenSource !== 'existing') {
981
- lines.push(
982
- '',
983
- 'On your dev machine, add this remote:',
984
- '',
985
- `bbx remote add <name> <host:port> --token ${token}`,
986
- '',
987
- 'Example:',
988
- '',
989
- `bbx remote add remote-bbx ${exampleHost}:${port} --token ${token}`
990
- );
1052
+ if (isLoopbackHost(bindHost)) {
1053
+ lines.push(
1054
+ '',
1055
+ 'On your dev machine, open an SSH local-forward:',
1056
+ '',
1057
+ `ssh -N -L ${port}:127.0.0.1:${port} <user>@<browser-host>`,
1058
+ '',
1059
+ 'Save the token to a private file, then add the tunneled remote:',
1060
+ '',
1061
+ `bbx remote add remote-bbx 127.0.0.1:${port} --token-file <token-file>`
1062
+ );
1063
+ } else {
1064
+ lines.push(
1065
+ '',
1066
+ 'WARNING: raw TCP is exposed without transport encryption.',
1067
+ 'On your dev machine, add this direct remote:',
1068
+ '',
1069
+ `bbx remote add remote-bbx ${getProxyExampleHost(bindHost)}:${port} --token-file <token-file>`
1070
+ );
1071
+ }
991
1072
  }
992
1073
  lines.push('');
993
1074
  process.stdout.write(lines.join('\n'));
@@ -1059,6 +1140,10 @@ function parseProxyEnableArgs(args) {
1059
1140
  options.rotateToken = true;
1060
1141
  continue;
1061
1142
  }
1143
+ if (arg === '--unsafe-plaintext') {
1144
+ options.unsafePlaintext = true;
1145
+ continue;
1146
+ }
1062
1147
  throw new Error(`Unknown proxy enable option "${arg}".`);
1063
1148
  }
1064
1149
  if (options.token && options.rotateToken) {
@@ -1104,9 +1189,11 @@ async function handleRemoteCommand(args) {
1104
1189
  if (subcommand === 'add') {
1105
1190
  const [name, endpoint, ...optionArgs] = restArgs;
1106
1191
  if (!name || !endpoint) {
1107
- throw new Error('Usage: bbx remote add <name> <host:port> --token <token>');
1192
+ throw new Error(
1193
+ 'Usage: bbx remote add <name> <host:port> (--token <token>|--token-file <path>)'
1194
+ );
1108
1195
  }
1109
- const token = parseRemoteTokenOption(optionArgs);
1196
+ const token = await parseRemoteTokenOption(optionArgs);
1110
1197
  const { host, port } = parseRemoteEndpoint(endpoint);
1111
1198
  const remote = await addRemoteDestination({
1112
1199
  id: normalizeDestinationId(name),
@@ -1198,22 +1285,121 @@ async function handleRemoteCommand(args) {
1198
1285
 
1199
1286
  /**
1200
1287
  * @param {string[]} args
1201
- * @returns {string}
1288
+ * @returns {Promise<string>}
1202
1289
  */
1203
- function parseRemoteTokenOption(args) {
1290
+ async function parseRemoteTokenOption(args) {
1204
1291
  let token = '';
1292
+ let tokenFile = '';
1205
1293
  for (let index = 0; index < args.length; index += 1) {
1206
1294
  const arg = args[index];
1207
1295
  if (arg === '--token') {
1208
- token = args[++index] || '';
1296
+ const value = args[++index];
1297
+ if (!value || value.startsWith('--')) {
1298
+ throw new Error('--token requires a value.');
1299
+ }
1300
+ token = value;
1301
+ continue;
1302
+ }
1303
+ if (arg === '--token-file') {
1304
+ const value = args[++index];
1305
+ if (!value || value.startsWith('--')) {
1306
+ throw new Error('--token-file requires a path.');
1307
+ }
1308
+ tokenFile = value;
1209
1309
  continue;
1210
1310
  }
1211
1311
  throw new Error(`Unknown remote add option "${arg}".`);
1212
1312
  }
1213
- if (!token.trim()) {
1214
- throw new Error('Usage: bbx remote add <name> <host:port> --token <token>');
1313
+ if ((token && tokenFile) || (!token.trim() && !tokenFile.trim())) {
1314
+ throw new Error(
1315
+ 'Usage: bbx remote add <name> <host:port> (--token <token>|--token-file <path>)'
1316
+ );
1317
+ }
1318
+ return tokenFile ? (await fs.promises.readFile(tokenFile, 'utf8')).trim() : token.trim();
1319
+ }
1320
+
1321
+ /**
1322
+ * Determine whether install-skill includes an explicit target without treating
1323
+ * option values such as `--project <path>` as positional targets.
1324
+ *
1325
+ * @param {string[]} args
1326
+ * @returns {boolean}
1327
+ */
1328
+ function hasExplicitInstallSkillTarget(args) {
1329
+ for (let index = 0; index < args.length; index += 1) {
1330
+ const arg = args[index];
1331
+ if (arg === '--project') {
1332
+ index += 1;
1333
+ continue;
1334
+ }
1335
+ if (arg === '--agents' || arg === '--agent') {
1336
+ return true;
1337
+ }
1338
+ if (arg.startsWith('--agents=') || arg.startsWith('--agent=')) {
1339
+ return true;
1340
+ }
1341
+ if (!arg.startsWith('--')) {
1342
+ return true;
1343
+ }
1344
+ }
1345
+ return false;
1346
+ }
1347
+
1348
+ /**
1349
+ * @param {string[]} args
1350
+ * @returns {{ pattern: string, isBlock: boolean, statusCode: number, body: string | undefined }}
1351
+ */
1352
+ function parseInterceptAddArgs(args) {
1353
+ const [pattern, ...optionArgs] = args;
1354
+ if (!pattern || pattern.startsWith('--')) {
1355
+ throw new Error(
1356
+ 'Usage: intercept add <urlPattern> [--respond <body>] [--status <code>] [--block]'
1357
+ );
1358
+ }
1359
+
1360
+ let isBlock = false;
1361
+ let statusCode = 200;
1362
+ let hasStatus = false;
1363
+ let body;
1364
+ for (let index = 0; index < optionArgs.length; index += 1) {
1365
+ const option = optionArgs[index];
1366
+ if (option === '--block') {
1367
+ if (isBlock) {
1368
+ throw new Error('The --block option may only be specified once.');
1369
+ }
1370
+ isBlock = true;
1371
+ continue;
1372
+ }
1373
+ if (option === '--respond') {
1374
+ const value = optionArgs[++index];
1375
+ if (body !== undefined) {
1376
+ throw new Error('The --respond option may only be specified once.');
1377
+ }
1378
+ if (value === undefined || value.startsWith('--')) {
1379
+ throw new Error('--respond requires a body value.');
1380
+ }
1381
+ body = value;
1382
+ continue;
1383
+ }
1384
+ if (option === '--status') {
1385
+ if (hasStatus) {
1386
+ throw new Error('The --status option may only be specified once.');
1387
+ }
1388
+ const value = optionArgs[++index];
1389
+ statusCode = parseIntArg(value, 'status');
1390
+ if (statusCode < 100 || statusCode > 599) {
1391
+ throw new Error('status must be an integer between 100 and 599.');
1392
+ }
1393
+ hasStatus = true;
1394
+ continue;
1395
+ }
1396
+ throw new Error(`Unknown or extra intercept add option "${option}".`);
1397
+ }
1398
+
1399
+ if (isBlock && body !== undefined) {
1400
+ throw new Error('Use either --block or --respond, not both.');
1215
1401
  }
1216
- return token.trim();
1402
+ return { pattern, isBlock, statusCode, body };
1217
1403
  }
1218
1404
 
1219
1405
  /**
@@ -1249,25 +1435,36 @@ function getClientTimeoutOverride() {
1249
1435
  *
1250
1436
  * @returns {{
1251
1437
  * mcpDetectors?: Record<string, () => boolean>,
1438
+ * skillDetectors?: Record<string, () => boolean>,
1252
1439
  * }}
1253
1440
  */
1254
1441
  function getSetupStatusTestOverrides() {
1255
- if (!(TEST_DETECTED_MCP_CLIENTS_ENV in process.env)) {
1256
- return {};
1442
+ /** @type {{ mcpDetectors?: Record<string, () => boolean>, skillDetectors?: Record<string, () => boolean> }} */
1443
+ const overrides = {};
1444
+ if (TEST_DETECTED_MCP_CLIENTS_ENV in process.env) {
1445
+ const detectedClients = new Set(
1446
+ (process.env[TEST_DETECTED_MCP_CLIENTS_ENV] || '')
1447
+ .split(',')
1448
+ .map((value) => value.trim().toLowerCase())
1449
+ .filter(Boolean)
1450
+ );
1451
+ overrides.mcpDetectors = Object.fromEntries(
1452
+ MCP_CLIENT_NAMES.map((clientName) => [clientName, () => detectedClients.has(clientName)])
1453
+ );
1257
1454
  }
1258
1455
 
1259
- const detectedClients = new Set(
1260
- (process.env[TEST_DETECTED_MCP_CLIENTS_ENV] || '')
1261
- .split(',')
1262
- .map((value) => value.trim().toLowerCase())
1263
- .filter(Boolean)
1264
- );
1265
-
1266
- return {
1267
- mcpDetectors: Object.fromEntries(
1268
- MCP_CLIENT_NAMES.map((clientName) => [clientName, () => detectedClients.has(clientName)])
1269
- ),
1270
- };
1456
+ if (TEST_DETECTED_SKILL_TARGETS_ENV in process.env) {
1457
+ const detectedTargets = new Set(
1458
+ (process.env[TEST_DETECTED_SKILL_TARGETS_ENV] || '')
1459
+ .split(',')
1460
+ .map((value) => value.trim().toLowerCase())
1461
+ .filter(Boolean)
1462
+ );
1463
+ overrides.skillDetectors = Object.fromEntries(
1464
+ SUPPORTED_TARGETS.map((target) => [target, () => detectedTargets.has(target)])
1465
+ );
1466
+ }
1467
+ return overrides;
1271
1468
  }
1272
1469
 
1273
1470
  /**