@browserbridge/bbx 1.7.3 → 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.
Files changed (29) hide show
  1. package/package.json +7 -7
  2. package/packages/agent-client/src/atomic-write.js +99 -0
  3. package/packages/agent-client/src/cli-helpers.js +20 -3
  4. package/packages/agent-client/src/cli.js +273 -76
  5. package/packages/agent-client/src/command-registry.js +12 -6
  6. package/packages/agent-client/src/install.js +39 -4
  7. package/packages/agent-client/src/mcp-config.js +28 -14
  8. package/packages/agent-client/src/remotes.js +93 -15
  9. package/packages/agent-client/src/runtime.js +6 -3
  10. package/packages/mcp-server/src/bin.js +10 -1
  11. package/packages/mcp-server/src/guidance.js +2 -2
  12. package/packages/mcp-server/src/handlers-capture.js +62 -32
  13. package/packages/mcp-server/src/handlers-dom.js +2 -2
  14. package/packages/mcp-server/src/handlers-navigation.js +30 -9
  15. package/packages/mcp-server/src/handlers-page.js +170 -143
  16. package/packages/mcp-server/src/handlers-utils.js +277 -8
  17. package/packages/mcp-server/src/handlers.js +31 -10
  18. package/packages/mcp-server/src/lifecycle.js +413 -0
  19. package/packages/mcp-server/src/server.js +59 -30
  20. package/packages/native-host/src/daemon.js +3 -1
  21. package/packages/protocol/src/capabilities.js +1 -1
  22. package/packages/protocol/src/defaults.js +2 -0
  23. package/packages/protocol/src/protocol.js +122 -9
  24. package/packages/protocol/src/registry.js +14 -14
  25. package/packages/protocol/src/summary.js +61 -30
  26. package/packages/protocol/src/types.ts +30 -1
  27. package/skills/browser-bridge/SKILL.md +4 -4
  28. package/skills/browser-bridge/references/interaction.md +1 -1
  29. package/skills/browser-bridge/references/protocol.md +3 -3
@@ -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,12 +1035,11 @@ 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
- ? ' (unchanged — already-configured clients keep working; pass --rotate-token to generate a new secret)'
1040
+ ? ' (unchanged - already-configured clients keep working; pass --rotate-token to generate a new secret)'
970
1041
  : tokenSource === 'generated' && existing
971
- ? ' (rotated — update every configured client with `bbx remote add`)'
1042
+ ? ' (rotated - update every configured client with `bbx remote add`)'
972
1043
  : '';
973
1044
  const lines = [
974
1045
  `Browser Bridge proxy enabled on ${bindHost}:${port}.`,
@@ -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
  /**
@@ -1,7 +1,12 @@
1
1
  // @ts-check
2
2
 
3
3
  import { BRIDGE_METHOD_REGISTRY, BRIDGE_METHODS } from '../../protocol/src/index.js';
4
- import { parseCommaList, parseIntArg, parsePropertyAssignments } from './cli-helpers.js';
4
+ import {
5
+ parseCommaList,
6
+ parseIntArg,
7
+ parseNumberArg,
8
+ parsePropertyAssignments,
9
+ } from './cli-helpers.js';
5
10
 
6
11
  /** @typedef {import('./types.js').BridgeMethod} BridgeMethod */
7
12
  /** @typedef {import('./types.js').ShortcutCommand} ShortcutCommand */
@@ -200,8 +205,8 @@ export const SHORTCUT_COMMANDS = {
200
205
  scroll: createShortcutCommand('viewport.scroll', 'bbx scroll <top> [left]', (r) => {
201
206
  if (!r[0] && !r[1]) throw new Error('Usage: scroll <top> [left]');
202
207
  return {
203
- top: r[0] ? parseIntArg(r[0], 'top') : undefined,
204
- left: r[1] ? parseIntArg(r[1], 'left') : undefined,
208
+ top: r[0] ? parseNumberArg(r[0], 'top') : undefined,
209
+ left: r[1] ? parseNumberArg(r[1], 'left') : undefined,
205
210
  };
206
211
  }),
207
212
  resize: createShortcutCommand('viewport.resize', 'bbx resize <width> <height>', (r) => {
@@ -256,11 +261,12 @@ export const CLI_HELP_SECTIONS = Object.freeze([
256
261
  'bbx install-mcp [client|all] [--local] Write MCP config for codex|claude|cursor|copilot|opencode|antigravity|windsurf|agents',
257
262
  'bbx status Check bridge connection',
258
263
  'bbx doctor Diagnose install, daemon, extension, and access readiness',
259
- 'bbx restart Restart the local Browser Bridge daemon',
260
- 'bbx proxy enable [--port 9223] [--bind-host 0.0.0.0] [--rotate-token] Enable opt-in TCP proxy mode (idempotent: re-running keeps settings and token)',
264
+ 'bbx restart Restart the local daemon and running MCP servers',
265
+ 'bbx proxy enable [--port 9223] [--bind-host 127.0.0.1] [--rotate-token] Enable loopback TCP proxy mode for SSH tunneling',
266
+ ' Non-loopback binds require --unsafe-plaintext because raw TCP is unencrypted.',
261
267
  'bbx proxy disable Disable proxy mode and restart the daemon',
262
268
  'bbx proxy status Show proxy config and daemon reachability',
263
- 'bbx remote add <name> <host:port> --token <token> Add a remote Browser Bridge destination (example: remote-bbx 192.168.56.20:9223)',
269
+ 'bbx remote add <name> <host:port> (--token <token>|--token-file <path>) Add a remote Browser Bridge destination',
264
270
  'bbx remote list List remote destinations',
265
271
  'bbx remote test <name> Ping a remote destination',
266
272
  'bbx remote remove <name> Remove a remote destination',
@@ -3,6 +3,7 @@
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
+ import { randomUUID } from 'node:crypto';
6
7
  import { fileURLToPath } from 'node:url';
7
8
 
8
9
  import { installMcpConfig } from './mcp-config.js';
@@ -452,10 +453,44 @@ async function installManagedSkill(skillName, target, targetDir) {
452
453
  throw new Error(`Refusing to overwrite unmanaged skill directory: ${targetDir}`);
453
454
  }
454
455
 
455
- await fs.promises.rm(targetDir, { recursive: true, force: true });
456
- await copyDir(sourceDir, targetDir);
457
- await applyManagedSkillPatches(skillName, target, targetDir);
458
- await fs.promises.writeFile(sentinelPath, formatManagedSkillSentinel(skillName), 'utf8');
456
+ await fs.promises.mkdir(path.dirname(targetDir), { recursive: true });
457
+ const suffix = `${process.pid}-${randomUUID()}`;
458
+ const temporaryDir = `${targetDir}.tmp-${suffix}`;
459
+ const backupDir = `${targetDir}.backup-${suffix}`;
460
+ let backupCreated = false;
461
+
462
+ try {
463
+ await copyDir(sourceDir, temporaryDir);
464
+ await applyManagedSkillPatches(skillName, target, temporaryDir);
465
+ await fs.promises.writeFile(
466
+ path.join(temporaryDir, managedSentinelFilename),
467
+ formatManagedSkillSentinel(skillName),
468
+ 'utf8'
469
+ );
470
+
471
+ if (targetExists) {
472
+ await fs.promises.rename(targetDir, backupDir);
473
+ backupCreated = true;
474
+ }
475
+ try {
476
+ await fs.promises.rename(temporaryDir, targetDir);
477
+ } catch (error) {
478
+ if (backupCreated) {
479
+ await fs.promises.rename(backupDir, targetDir);
480
+ backupCreated = false;
481
+ }
482
+ throw error;
483
+ }
484
+ if (backupCreated) {
485
+ await fs.promises.rm(backupDir, { recursive: true, force: true }).catch(() => {});
486
+ backupCreated = false;
487
+ }
488
+ } finally {
489
+ await fs.promises.rm(temporaryDir, { recursive: true, force: true }).catch(() => {});
490
+ if (backupCreated && !(await pathExists(targetDir))) {
491
+ await fs.promises.rename(backupDir, targetDir).catch(() => {});
492
+ }
493
+ }
459
494
  }
460
495
 
461
496
  /**