claude-bridge-cli 1.3.1 → 1.5.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/bin/cli.js CHANGED
@@ -2,9 +2,112 @@
2
2
  "use strict";
3
3
 
4
4
  const { parseArgs } = require("node:util");
5
+ const fs = require("node:fs");
6
+ const path = require("node:path");
7
+ const os = require("node:os");
5
8
  const { startBridge } = require("../lib/bridge");
6
9
  const { startRelay } = require("../lib/relay-client");
7
10
 
11
+ // ── Pair flow (browser-OTP based auth) ────────────────────────────────────
12
+ // When --token isn't supplied, the CLI:
13
+ // 1. POSTs /pair-cli/start to the relay's HTTP base (public endpoint)
14
+ // 2. Prints the short code + pair URL — user opens it, OTPs into CF Access,
15
+ // confirms in the browser
16
+ // 3. Long-polls /pair-cli/claim until the bearer arrives
17
+ // 4. Saves the bearer to ~/.claude-bridge/auth.json so subsequent starts
18
+ // pick it up automatically
19
+ // Replaces the old workflow of running `claude-relay-ctl provision <m>` on
20
+ // the host and copy-pasting a long bearer onto the CLI command line.
21
+
22
+ function authFilePath() {
23
+ return path.join(os.homedir(), ".claude-bridge", "auth.json");
24
+ }
25
+
26
+ function readAuthFile() {
27
+ try { return JSON.parse(fs.readFileSync(authFilePath(), "utf8")); }
28
+ catch { return { bearers: {} }; }
29
+ }
30
+
31
+ function writeAuthFile(data) {
32
+ const fp = authFilePath();
33
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
34
+ fs.writeFileSync(fp, JSON.stringify(data, null, 2), { mode: 0o600 });
35
+ }
36
+
37
+ function relayHttpBase(wsUrl) {
38
+ // wss://host/agent/ws → https://host
39
+ const u = new URL(wsUrl);
40
+ const proto = u.protocol === "wss:" ? "https:" : "http:";
41
+ return `${proto}//${u.host}`;
42
+ }
43
+
44
+ async function pairInteractive(relayWsUrl, machine, ephemeral) {
45
+ const base = relayHttpBase(relayWsUrl);
46
+ process.stdout.write(`[bridge] Starting pair flow for "${machine}" against ${base}\n`);
47
+ if (ephemeral) process.stdout.write(`[bridge] Ephemeral mode — bearer will NOT be saved to disk.\n`);
48
+
49
+ const startResp = await fetch(`${base}/pair-cli/start`, {
50
+ method: "POST",
51
+ headers: { "Content-Type": "application/json" },
52
+ body: JSON.stringify({ machine }),
53
+ });
54
+ if (!startResp.ok) {
55
+ const text = await startResp.text();
56
+ throw new Error(`pair start failed (${startResp.status}): ${text}`);
57
+ }
58
+ const { code, poll_token, pair_url, expires_in } = await startResp.json();
59
+
60
+ console.log("\n ┌─────────────────────────────────────────────────┐");
61
+ console.log(" │ Open this URL in a browser to pair the device │");
62
+ console.log(" ├─────────────────────────────────────────────────┤");
63
+ console.log(` │ ${pair_url.padEnd(47)} │`);
64
+ console.log(" │ │");
65
+ console.log(` │ Confirm this code matches: ${code.padEnd(17)}│`);
66
+ console.log(" └─────────────────────────────────────────────────┘");
67
+ console.log(` Code expires in ${expires_in}s. Waiting for confirmation…\n`);
68
+
69
+ const deadline = Date.now() + expires_in * 1000;
70
+ while (Date.now() < deadline) {
71
+ let resp;
72
+ try {
73
+ resp = await fetch(`${base}/pair-cli/claim`, {
74
+ method: "POST",
75
+ headers: { "Content-Type": "application/json" },
76
+ body: JSON.stringify({ poll_token }),
77
+ });
78
+ } catch (e) {
79
+ await new Promise(r => setTimeout(r, 2000));
80
+ continue;
81
+ }
82
+ if (resp.status === 202) continue; // still waiting, re-poll
83
+ if (resp.status === 404 || resp.status === 410) {
84
+ throw new Error("pair expired or invalidated — re-run to start over");
85
+ }
86
+ if (!resp.ok) {
87
+ const text = await resp.text();
88
+ throw new Error(`pair claim failed (${resp.status}): ${text}`);
89
+ }
90
+ const { bearer, machine: confirmed } = await resp.json();
91
+ if (ephemeral) {
92
+ console.log(`[bridge] ✓ Paired as "${confirmed}" (in-memory only, nothing written to disk).`);
93
+ } else {
94
+ console.log(`[bridge] ✓ Paired as "${confirmed}". Bearer saved to ${authFilePath()}`);
95
+ const auth = readAuthFile();
96
+ auth.bearers = auth.bearers || {};
97
+ auth.bearers[base] = auth.bearers[base] || {};
98
+ auth.bearers[base][confirmed] = bearer;
99
+ writeAuthFile(auth);
100
+ }
101
+ return bearer;
102
+ }
103
+ throw new Error("pair window expired — re-run to start over");
104
+ }
105
+
106
+ function lookupSavedBearer(relayWsUrl, machine) {
107
+ const auth = readAuthFile();
108
+ return auth.bearers?.[relayHttpBase(relayWsUrl)]?.[machine] || null;
109
+ }
110
+
8
111
  const HELP = `
9
112
  claude-code-bridge — Bridge server for Claude Code CLI
10
113
 
@@ -25,7 +128,12 @@ Options:
25
128
  Relay options (connect to a remote relay server):
26
129
  --relay-url <url> WebSocket URL of the relay server
27
130
  --machine <name> Machine name for the relay
28
- --token <bearer> Machine bearer token (when using relay, --token is the relay auth)
131
+ --token <bearer> Machine bearer (optional if omitted, the CLI starts
132
+ a browser-OTP pair flow and caches the bearer at
133
+ ~/.claude-bridge/auth.json for next time)
134
+ --ephemeral Pair on every start, never read/write auth.json.
135
+ Forces a fresh browser-OTP each launch — use on
136
+ machines you don't want to leave any saved bearer on.
29
137
  --cf-id <id> Cloudflare Access Client ID (optional)
30
138
  --cf-secret <secret> Cloudflare Access Client Secret (optional)
31
139
 
@@ -33,6 +141,7 @@ Environment variables:
33
141
  All options can be set via env vars with BRIDGE_ prefix:
34
142
  BRIDGE_PORT, BRIDGE_HOST, BRIDGE_CWD, BRIDGE_TIMEOUT,
35
143
  BRIDGE_RELAY_URL, BRIDGE_MACHINE_NAME, BRIDGE_MACHINE_TOKEN,
144
+ BRIDGE_EPHEMERAL=1 (force fresh pair every start, no saved bearer),
36
145
  BRIDGE_CF_ID, BRIDGE_CF_SECRET
37
146
  `;
38
147
 
@@ -60,6 +169,7 @@ function main() {
60
169
  "relay-url": { type: "string", default: env("RELAY_URL", "") },
61
170
  machine: { type: "string", default: env("MACHINE_NAME", "") },
62
171
  token: { type: "string", default: env("MACHINE_TOKEN", "") },
172
+ ephemeral: { type: "boolean", default: env("EPHEMERAL", "") === "1" },
63
173
  "cf-id": { type: "string", default: env("CF_ID", "") },
64
174
  "cf-secret": { type: "string", default: env("CF_SECRET", "") },
65
175
  },
@@ -76,6 +186,7 @@ function main() {
76
186
  url: values["relay-url"],
77
187
  machine: values.machine,
78
188
  token: values.token,
189
+ ephemeral: !!values.ephemeral,
79
190
  cfId: values["cf-id"],
80
191
  cfSecret: values["cf-secret"],
81
192
  } : null,
@@ -133,10 +244,32 @@ async function run(config) {
133
244
 
134
245
  // Start relay client if configured
135
246
  if (config.relay) {
136
- if (!config.relay.machine || !config.relay.token) {
137
- console.error("[bridge] ERROR: --machine and --token required when using --relay-url");
247
+ if (!config.relay.machine) {
248
+ console.error("[bridge] ERROR: --machine required when using --relay-url");
138
249
  process.exit(1);
139
250
  }
251
+ // Resolve a bearer:
252
+ // --token → use it directly (never look at auth.json)
253
+ // --ephemeral → always pair fresh, never read or write auth.json
254
+ // default → saved auth.json → fall back to pair flow on miss
255
+ if (!config.relay.token) {
256
+ const saved = config.relay.ephemeral
257
+ ? null
258
+ : lookupSavedBearer(config.relay.url, config.relay.machine);
259
+ if (saved) {
260
+ config.relay.token = saved;
261
+ console.log(`[bridge] Using saved bearer from ${authFilePath()}`);
262
+ } else {
263
+ try {
264
+ config.relay.token = await pairInteractive(
265
+ config.relay.url, config.relay.machine, config.relay.ephemeral
266
+ );
267
+ } catch (e) {
268
+ console.error(`[bridge] ERROR: ${e.message}`);
269
+ process.exit(1);
270
+ }
271
+ }
272
+ }
140
273
  console.log(`[bridge] Connecting to relay as "${config.relay.machine}"...`);
141
274
  startRelay(config);
142
275
  } else {
@@ -46,6 +46,25 @@ function startRelay(config) {
46
46
  });
47
47
 
48
48
  ws.on("close", (code, reason) => {
49
+ // 4003 = bearer rejected by the relay (verify_machine_token failed).
50
+ // The bearer has been rotated or admin-removed; the cached one in
51
+ // auth.json is useless. Stop the reconnect loop and tell the user
52
+ // exactly how to recover.
53
+ if (code === 4003) {
54
+ const reasonStr = reason && reason.toString ? reason.toString() : "unauthorized";
55
+ console.error("");
56
+ console.error(`[relay] Authorization rejected (${code} ${reasonStr}).`);
57
+ console.error("[relay] The saved bearer no longer matches what the relay expects — it was probably rotated or revoked.");
58
+ console.error("[relay] Recover by deleting the cached auth and re-pairing:");
59
+ if (process.platform === "win32") {
60
+ console.error(" del %USERPROFILE%\\.claude-bridge\\auth.json");
61
+ } else {
62
+ console.error(" rm ~/.claude-bridge/auth.json");
63
+ }
64
+ console.error(` claude-bridge-cli start --relay-url ${config.relay.url} --machine ${config.relay.machine}`);
65
+ console.error("");
66
+ process.exit(2);
67
+ }
49
68
  console.log(`[relay] Disconnected (${code}). Reconnecting in ${backoff / 1000}s...`);
50
69
  setTimeout(connect, backoff);
51
70
  backoff = Math.min(60000, backoff * 2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-bridge-cli",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
4
  "description": "Use Claude Code from your browser. Runs a local server that connects your browser tools to the Claude CLI.",
5
5
  "main": "lib/bridge.js",
6
6
  "bin": {