@browserbridge/bbx 1.6.1 → 1.6.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserbridge/bbx",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "agent-tools",
@@ -4,12 +4,16 @@
4
4
  import fs from 'node:fs';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
+ import { randomUUID } from 'node:crypto';
7
8
  import { fileURLToPath } from 'node:url';
8
9
 
9
10
  import {
10
11
  applyWindowsTcpTransportDefaults,
12
+ getProxyConfigPath,
13
+ readProxyConfig,
11
14
  SUPPORTED_BROWSERS,
12
15
  } from '../../native-host/src/config.js';
16
+ import { writeBridgeAuthToken } from '../../native-host/src/auth-token.js';
13
17
  import { restartBridgeDaemon } from '../../native-host/src/daemon-process.js';
14
18
  import { uninstallNativeManifest } from '../../native-host/src/install-manifest.js';
15
19
  import {
@@ -47,6 +51,15 @@ import {
47
51
  removeMcpConfig,
48
52
  } from './mcp-config.js';
49
53
  import { getDoctorReport, requestBridge, resolveRef } from './runtime.js';
54
+ import {
55
+ addRemoteDestination,
56
+ createBridgeClientForDestination,
57
+ listBridgeDestinations,
58
+ normalizeDestinationId,
59
+ parseRemoteEndpoint,
60
+ readRemoteConfig,
61
+ removeRemoteDestination,
62
+ } from './remotes.js';
50
63
  import { collectSetupStatus } from './setup-status.js';
51
64
  import { annotateBridgeSummary, summarizeBridgeResponse } from './subagent.js';
52
65
 
@@ -382,6 +395,16 @@ if (command === 'mcp') {
382
395
  process.exit(1);
383
396
  }
384
397
 
398
+ if (command === 'proxy') {
399
+ await handleProxyCommand(rest);
400
+ process.exit(0);
401
+ }
402
+
403
+ if (command === 'remote') {
404
+ await handleRemoteCommand(rest);
405
+ process.exit(0);
406
+ }
407
+
385
408
  const clientTimeoutMs = getClientTimeoutOverride();
386
409
  applyWindowsTcpTransportDefaults();
387
410
  const client = new BridgeClient(
@@ -828,6 +851,247 @@ async function main() {
828
851
  }
829
852
  }
830
853
 
854
+ /**
855
+ * @param {string[]} args
856
+ * @returns {Promise<void>}
857
+ */
858
+ async function handleProxyCommand(args) {
859
+ const [subcommand, ...restArgs] = args;
860
+ if (subcommand === 'enable') {
861
+ const options = parseProxyEnableArgs(restArgs);
862
+ const token = options.token ?? randomUUID();
863
+ await writeBridgeAuthToken(token);
864
+ const configPath = getProxyConfigPath();
865
+ await fs.promises.mkdir(path.dirname(configPath), { recursive: true });
866
+ await fs.promises.writeFile(
867
+ configPath,
868
+ `${JSON.stringify(
869
+ {
870
+ enabled: true,
871
+ port: options.port,
872
+ bindHost: options.bindHost,
873
+ token,
874
+ },
875
+ null,
876
+ 2
877
+ )}\n`,
878
+ { encoding: 'utf8', mode: 0o600 }
879
+ );
880
+ const result = await restartBridgeDaemon();
881
+ const exampleHost = getProxyExampleHost(options.bindHost);
882
+ process.stdout.write(
883
+ [
884
+ `Browser Bridge proxy enabled on ${options.bindHost}:${options.port}.`,
885
+ '',
886
+ `Token: ${token}`,
887
+ `Config: ${configPath}`,
888
+ `Daemon: ${result.previouslyRunning ? 'restarted' : 'started'} (pid ${result.pid})`,
889
+ '',
890
+ 'On your dev machine, add this remote:',
891
+ '',
892
+ `bbx remote add <name> <host:port> --token ${token}`,
893
+ '',
894
+ 'Example:',
895
+ '',
896
+ `bbx remote add remote-bbx ${exampleHost}:${options.port} --token ${token}`,
897
+ '',
898
+ ].join('\n')
899
+ );
900
+ return;
901
+ }
902
+
903
+ if (subcommand === 'disable') {
904
+ const configPath = getProxyConfigPath();
905
+ await fs.promises.rm(configPath, { force: true });
906
+ const result = await restartBridgeDaemon();
907
+ process.stdout.write(
908
+ `Browser Bridge proxy disabled. Daemon ${result.previouslyRunning ? 'restarted' : 'started'}.\n`
909
+ );
910
+ return;
911
+ }
912
+
913
+ if (subcommand === 'status') {
914
+ const config = readProxyConfig();
915
+ if (!config) {
916
+ process.stdout.write('Browser Bridge proxy is disabled.\n');
917
+ return;
918
+ }
919
+ process.stdout.write(
920
+ [
921
+ `Browser Bridge proxy is enabled on ${config.bindHost}:${config.port}.`,
922
+ `Config: ${getProxyConfigPath()}`,
923
+ '',
924
+ ].join('\n')
925
+ );
926
+ return;
927
+ }
928
+
929
+ throw new Error('Usage: bbx proxy <enable|disable|status>');
930
+ }
931
+
932
+ /**
933
+ * @param {string[]} args
934
+ * @returns {{ port: number, bindHost: string, token?: string }}
935
+ */
936
+ function parseProxyEnableArgs(args) {
937
+ /** @type {{ port: number, bindHost: string, token?: string }} */
938
+ const options = { port: 9223, bindHost: '0.0.0.0' };
939
+ for (let index = 0; index < args.length; index += 1) {
940
+ const arg = args[index];
941
+ if (arg === '--port') {
942
+ options.port = parseIntArg(args[++index] || '', 'port');
943
+ continue;
944
+ }
945
+ if (arg === '--bind-host') {
946
+ options.bindHost = args[++index] || '';
947
+ if (!options.bindHost.trim()) {
948
+ throw new Error('--bind-host requires a value.');
949
+ }
950
+ continue;
951
+ }
952
+ if (arg === '--token') {
953
+ options.token = args[++index] || '';
954
+ if (!options.token.trim()) {
955
+ throw new Error('--token requires a value.');
956
+ }
957
+ continue;
958
+ }
959
+ throw new Error(`Unknown proxy enable option "${arg}".`);
960
+ }
961
+ if (options.port < 1 || options.port > 65535) {
962
+ throw new Error('port must be an integer between 1 and 65535.');
963
+ }
964
+ return options;
965
+ }
966
+
967
+ /**
968
+ * @param {string} bindHost
969
+ * @returns {string}
970
+ */
971
+ function getProxyExampleHost(bindHost) {
972
+ if (bindHost !== '0.0.0.0' && bindHost !== '::') {
973
+ return bindHost;
974
+ }
975
+
976
+ for (const entries of Object.values(os.networkInterfaces())) {
977
+ for (const entry of entries ?? []) {
978
+ if (!entry.internal && entry.family === 'IPv4') {
979
+ return entry.address;
980
+ }
981
+ }
982
+ }
983
+
984
+ return '<host>';
985
+ }
986
+
987
+ /**
988
+ * @param {string[]} args
989
+ * @returns {Promise<void>}
990
+ */
991
+ async function handleRemoteCommand(args) {
992
+ const [subcommand, ...restArgs] = args;
993
+ if (subcommand === 'add') {
994
+ const [name, endpoint, ...optionArgs] = restArgs;
995
+ if (!name || !endpoint) {
996
+ throw new Error('Usage: bbx remote add <name> <host:port> --token <token>');
997
+ }
998
+ const token = parseRemoteTokenOption(optionArgs);
999
+ const { host, port } = parseRemoteEndpoint(endpoint);
1000
+ const remote = await addRemoteDestination({
1001
+ id: normalizeDestinationId(name),
1002
+ host,
1003
+ port,
1004
+ token,
1005
+ });
1006
+ process.stdout.write(
1007
+ `Remote destination "${remote.id}" saved (${remote.host}:${remote.port}).\n`
1008
+ );
1009
+ return;
1010
+ }
1011
+
1012
+ if (subcommand === 'remove') {
1013
+ const [name] = restArgs;
1014
+ if (!name) {
1015
+ throw new Error('Usage: bbx remote remove <name>');
1016
+ }
1017
+ const removed = await removeRemoteDestination(name);
1018
+ process.stdout.write(
1019
+ removed
1020
+ ? `Remote destination "${name}" removed.\n`
1021
+ : `Remote destination "${name}" was not configured.\n`
1022
+ );
1023
+ return;
1024
+ }
1025
+
1026
+ if (subcommand === 'list') {
1027
+ const config = await readRemoteConfig();
1028
+ if (config.remotes.length === 0) {
1029
+ process.stdout.write('No remote destinations configured.\n');
1030
+ return;
1031
+ }
1032
+ process.stdout.write(
1033
+ `${config.remotes.map((remote) => `${remote.id}\t${remote.host}:${remote.port}`).join('\n')}\n`
1034
+ );
1035
+ return;
1036
+ }
1037
+
1038
+ if (subcommand === 'test') {
1039
+ const [name] = restArgs;
1040
+ if (!name) {
1041
+ throw new Error('Usage: bbx remote test <name>');
1042
+ }
1043
+ const remoteClient = await createBridgeClientForDestination(name);
1044
+ try {
1045
+ await remoteClient.connect();
1046
+ const response = await remoteClient.request({ method: 'health.ping' });
1047
+ process.stdout.write(
1048
+ response.ok
1049
+ ? `Remote destination "${name}" is reachable.\n`
1050
+ : `Remote destination "${name}" failed: ${response.error.message}\n`
1051
+ );
1052
+ } finally {
1053
+ await remoteClient.close();
1054
+ }
1055
+ return;
1056
+ }
1057
+
1058
+ if (subcommand === 'destinations') {
1059
+ const destinations = await listBridgeDestinations();
1060
+ process.stdout.write(
1061
+ `${destinations
1062
+ .map((destination) =>
1063
+ destination.local
1064
+ ? `${destination.id}\tlocal`
1065
+ : `${destination.id}\t${destination.host}:${destination.port}`
1066
+ )
1067
+ .join('\n')}\n`
1068
+ );
1069
+ return;
1070
+ }
1071
+
1072
+ throw new Error('Usage: bbx remote <add|remove|list|test|destinations>');
1073
+ }
1074
+
1075
+ /**
1076
+ * @param {string[]} args
1077
+ * @returns {string}
1078
+ */
1079
+ function parseRemoteTokenOption(args) {
1080
+ let token = '';
1081
+ for (let index = 0; index < args.length; index += 1) {
1082
+ const arg = args[index];
1083
+ if (arg === '--token') {
1084
+ token = args[++index] || '';
1085
+ continue;
1086
+ }
1087
+ throw new Error(`Unknown remote add option "${arg}".`);
1088
+ }
1089
+ if (!token.trim()) {
1090
+ throw new Error('Usage: bbx remote add <name> <host:port> --token <token>');
1091
+ }
1092
+ return token.trim();
1093
+ }
1094
+
831
1095
  /**
832
1096
  * @param {unknown} error
833
1097
  * @returns {string}
@@ -257,6 +257,10 @@ export const CLI_HELP_SECTIONS = Object.freeze([
257
257
  'bbx status Check bridge connection',
258
258
  'bbx doctor Diagnose install, daemon, extension, and access readiness',
259
259
  'bbx restart Restart the local Browser Bridge daemon',
260
+ 'bbx proxy enable [--port 9223] [--bind-host 0.0.0.0] Enable opt-in TCP proxy mode and print a token',
261
+ 'bbx proxy disable Disable proxy mode and restart the daemon',
262
+ 'bbx remote add <name> <host:port> --token <token> Add a remote Browser Bridge destination (example: remote-bbx 192.168.56.20:9223)',
263
+ 'bbx remote list List remote destinations',
260
264
  'bbx access-request Request Browser Bridge access for the focused window',
261
265
  'bbx logs Recent bridge logs',
262
266
  'bbx tabs List available tabs',
@@ -0,0 +1,202 @@
1
+ // @ts-check
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ import { createTcpBridgeTransport, getBridgeDir } from '../../native-host/src/config.js';
7
+ import { normalizeBridgeAuthToken } from '../../native-host/src/auth-token.js';
8
+ import { BridgeClient } from './client.js';
9
+
10
+ const REMOTES_FILENAME = 'remotes.json';
11
+ const LOCAL_DESTINATION_ID = 'local';
12
+ export const DEFAULT_REMOTE_PORT = 9223;
13
+
14
+ /**
15
+ * @typedef {{ id: string, host: string, port: number, token: string }} RemoteDestination
16
+ */
17
+
18
+ /**
19
+ * @typedef {{ remotes: RemoteDestination[] }} RemoteConfig
20
+ */
21
+
22
+ /**
23
+ * @param {NodeJS.ProcessEnv} [env=process.env]
24
+ * @returns {string}
25
+ */
26
+ export function getRemoteConfigPath(env = process.env) {
27
+ return path.join(getBridgeDir(env), REMOTES_FILENAME);
28
+ }
29
+
30
+ /**
31
+ * @param {string} id
32
+ * @returns {string}
33
+ */
34
+ export function normalizeDestinationId(id) {
35
+ const normalized = id.trim();
36
+ if (!/^[A-Za-z0-9_.-]{1,64}$/u.test(normalized) || normalized === LOCAL_DESTINATION_ID) {
37
+ throw new Error('Remote name must be 1-64 letters, numbers, dots, dashes, or underscores.');
38
+ }
39
+ return normalized;
40
+ }
41
+
42
+ /**
43
+ * @param {string} endpoint
44
+ * @returns {{ host: string, port: number }}
45
+ */
46
+ export function parseRemoteEndpoint(endpoint) {
47
+ const trimmed = endpoint.trim();
48
+ const match = /^(.+):(\d{1,5})$/u.exec(trimmed);
49
+ const host = (match ? match[1] : trimmed).trim().replace(/^\[|\]$/gu, '');
50
+ const port = match ? Number.parseInt(match[2], 10) : DEFAULT_REMOTE_PORT;
51
+ if (!host || /[\s/]/u.test(host)) {
52
+ throw new Error('Remote host must be a hostname or IP address.');
53
+ }
54
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
55
+ throw new Error('Remote port must be an integer between 1 and 65535.');
56
+ }
57
+ return { host, port };
58
+ }
59
+
60
+ /**
61
+ * @param {unknown} value
62
+ * @returns {RemoteDestination | null}
63
+ */
64
+ function normalizeRemote(value) {
65
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
66
+ return null;
67
+ }
68
+ const record = /** @type {Record<string, unknown>} */ (value);
69
+ if (
70
+ typeof record.id !== 'string' ||
71
+ typeof record.host !== 'string' ||
72
+ typeof record.port !== 'number' ||
73
+ typeof record.token !== 'string'
74
+ ) {
75
+ return null;
76
+ }
77
+ const token = normalizeBridgeAuthToken(record.token);
78
+ if (!token) {
79
+ return null;
80
+ }
81
+ try {
82
+ const id = normalizeDestinationId(record.id);
83
+ const { host, port } = parseRemoteEndpoint(`${record.host}:${record.port}`);
84
+ return { id, host, port, token };
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * @param {{ configPath?: string, readFile?: typeof fs.promises.readFile }} [options={}]
92
+ * @returns {Promise<RemoteConfig>}
93
+ */
94
+ export async function readRemoteConfig(options = {}) {
95
+ const configPath = options.configPath ?? getRemoteConfigPath();
96
+ const readFile = options.readFile ?? fs.promises.readFile.bind(fs.promises);
97
+ try {
98
+ const parsed = JSON.parse(await readFile(configPath, 'utf8'));
99
+ /** @type {RemoteDestination[]} */
100
+ const remotes = [];
101
+ if (Array.isArray(parsed?.remotes)) {
102
+ for (const entry of parsed.remotes) {
103
+ const remote = normalizeRemote(entry);
104
+ if (remote) {
105
+ remotes.push(remote);
106
+ }
107
+ }
108
+ }
109
+ return { remotes };
110
+ } catch (error) {
111
+ if (
112
+ error &&
113
+ typeof error === 'object' &&
114
+ /** @type {{ code?: unknown }} */ (error).code === 'ENOENT'
115
+ ) {
116
+ return { remotes: [] };
117
+ }
118
+ throw error;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * @param {RemoteConfig} config
124
+ * @param {{ configPath?: string, writeFile?: typeof fs.promises.writeFile, mkdir?: typeof fs.promises.mkdir }} [options={}]
125
+ * @returns {Promise<void>}
126
+ */
127
+ export async function writeRemoteConfig(config, options = {}) {
128
+ const configPath = options.configPath ?? getRemoteConfigPath();
129
+ const writeFile = options.writeFile ?? fs.promises.writeFile.bind(fs.promises);
130
+ const mkdir = options.mkdir ?? fs.promises.mkdir.bind(fs.promises);
131
+ await mkdir(path.dirname(configPath), { recursive: true });
132
+ await writeFile(configPath, `${JSON.stringify({ remotes: config.remotes }, null, 2)}\n`, {
133
+ encoding: 'utf8',
134
+ mode: 0o600,
135
+ });
136
+ }
137
+
138
+ /**
139
+ * @param {RemoteDestination} remote
140
+ * @returns {Promise<RemoteDestination>}
141
+ */
142
+ export async function addRemoteDestination(remote) {
143
+ const config = await readRemoteConfig();
144
+ const existing = config.remotes.findIndex((entry) => entry.id === remote.id);
145
+ if (existing === -1) {
146
+ config.remotes.push(remote);
147
+ } else {
148
+ config.remotes[existing] = remote;
149
+ }
150
+ await writeRemoteConfig(config);
151
+ return remote;
152
+ }
153
+
154
+ /**
155
+ * @param {string} id
156
+ * @returns {Promise<boolean>}
157
+ */
158
+ export async function removeRemoteDestination(id) {
159
+ const normalizedId = normalizeDestinationId(id);
160
+ const config = await readRemoteConfig();
161
+ const next = config.remotes.filter((remote) => remote.id !== normalizedId);
162
+ await writeRemoteConfig({ remotes: next });
163
+ return next.length !== config.remotes.length;
164
+ }
165
+
166
+ /**
167
+ * @returns {Promise<Array<{ id: string, local: boolean, host: string | null, port: number | null }>>}
168
+ */
169
+ export async function listBridgeDestinations() {
170
+ const config = await readRemoteConfig();
171
+ return [
172
+ { id: LOCAL_DESTINATION_ID, local: true, host: null, port: null },
173
+ ...config.remotes.map((remote) => ({
174
+ id: remote.id,
175
+ local: false,
176
+ host: remote.host,
177
+ port: remote.port,
178
+ })),
179
+ ];
180
+ }
181
+
182
+ /**
183
+ * @param {string | null | undefined} destinationId
184
+ * @param {{ defaultTimeoutMs?: number }} [options={}]
185
+ * @returns {Promise<BridgeClient>}
186
+ */
187
+ export async function createBridgeClientForDestination(destinationId, options = {}) {
188
+ if (!destinationId || destinationId === LOCAL_DESTINATION_ID) {
189
+ return new BridgeClient(options);
190
+ }
191
+ const config = await readRemoteConfig();
192
+ const remote = config.remotes.find((entry) => entry.id === destinationId);
193
+ if (!remote) {
194
+ throw new Error(`Unknown Browser Bridge destination "${destinationId}".`);
195
+ }
196
+ return new BridgeClient({
197
+ ...options,
198
+ transport: createTcpBridgeTransport(remote.port, remote.host),
199
+ authToken: remote.token,
200
+ restartDaemonOnVersionMismatch: false,
201
+ });
202
+ }