replica-bridge 0.3.2 → 0.3.3

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": "replica-bridge",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Lets the ChatReplica iPhone app use Codex on this computer through a relay, end-to-end encrypted.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,7 +2,7 @@
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import { b64url, generateKeyPair } from "../protocol.js";
5
- import { relayRequest } from "../http.js";
5
+ import { RelayHTTPError, relayRequest } from "../http.js";
6
6
  import { defaultEnvPath, loadRelayConfig } from "../relay/config.js";
7
7
  import { loadState, saveState } from "./state.js";
8
8
 
@@ -25,6 +25,56 @@ export async function enroll(relay , options
25
25
  return state;
26
26
  }
27
27
 
28
+ /** Public relays the project ran before; a computer still set up with one moves to the default relay. */
29
+ export const RETIRED_RELAYS = ["https://agens.office-cli.onl"];
30
+
31
+ const sameURL = (a , b ) => a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
32
+
33
+ /** Whether the saved relay still serves this computer. Throws when it cannot be reached, which may pass. */
34
+ async function checkRelay(state ) {
35
+ try {
36
+ await relayRequest(state.relay, "GET", "/v1/host/devices", { token: state.hostToken });
37
+ return "ok";
38
+ } catch (error) {
39
+ if (!(error instanceof RelayHTTPError)) throw new Error(`cannot reach the relay ${state.relay} (${(error ).message}); if it moved, run again with --relay <url>`);
40
+ if (error.status === 401) return "unknown computer";
41
+ // Something answered but not with the relay's own errors: is it a relay at all? Its / says so.
42
+ const home = await fetch(state.relay.endsWith("/") ? state.relay : `${state.relay}/`).then((response) => response.text()).catch(() => "");
43
+ if (home.trim() === "replica-relay") throw error;
44
+ return "not a relay";
45
+ }
46
+ }
47
+
48
+ /**
49
+ * The saved state, set up afresh whenever it cannot serve a phone: nothing saved yet, --relay or REPLICA_RELAY names
50
+ * another relay, the saved relay is retired or no longer a relay, or it no longer knows this computer. Pairings live
51
+ * on one relay, so a fresh set-up has none and the phone scans again; `moved` says a running bridge must restart.
52
+ */
53
+ export async function ensureRelay(relay , say , name ) {
54
+ let saved ;
55
+ try {
56
+ saved = loadState();
57
+ } catch (error) {
58
+ if (!/not set up yet/.test((error ).message)) throw error;
59
+ return { state: await ensureEnrolled(relay, say, name), moved: true };
60
+ }
61
+ const asked = relay ?? process.env.REPLICA_RELAY;
62
+ let target ;
63
+ let reason ;
64
+ if (asked && !sameURL(asked, saved.relay) && !(saved.publicRelay && sameURL(asked, saved.publicRelay))) {
65
+ [target, reason] = [asked, `it was set up with ${saved.relay}`];
66
+ } else if (RETIRED_RELAYS.some((url) => sameURL(url, saved.relay))) {
67
+ [target, reason] = [defaultRelayURL(), `${saved.relay} no longer runs a relay`];
68
+ } else {
69
+ const check = await checkRelay(saved);
70
+ if (check === "ok") return { state: saved, moved: false };
71
+ [target, reason] = check === "unknown computer" ? [saved.relay, `${saved.relay} no longer knows this computer`] : [defaultRelayURL(), `${saved.relay} is no longer a relay`];
72
+ }
73
+ const state = await enroll(target, { name: name ?? saved.hostName, publicRelay: sameURL(target, saved.relay) ? saved.publicRelay : undefined });
74
+ say(`Set up ${state.hostName} again with ${target} (${state.hostId}): ${reason}. Phones paired before scan the new QR code.`);
75
+ return { state, moved: true };
76
+ }
77
+
28
78
  /** The saved state, enrolling first if this computer never has. */
29
79
  export async function ensureEnrolled(relay , say , name ) {
30
80
  try {
@@ -16,7 +16,7 @@ import readline from "node:readline/promises";
16
16
  import { parseArgs, format } from "node:util";
17
17
  import { relayRequest } from "../http.js";
18
18
  import { spawnCommandSync } from "./command.js";
19
- import { defaultRelayURL, enroll, ensureEnrolled } from "./enroll.js";
19
+ import { defaultRelayURL, enroll, ensureEnrolled, ensureRelay } from "./enroll.js";
20
20
  import { PACKAGE_VERSION, clearPid, installService, logPath, readServiceInfo, runningPid, servicePlan, uninstallService, writePid } from "./install.js";
21
21
  import { pair, publicRelayURL } from "./pair.js";
22
22
  import { BridgeService } from "./service.js";
@@ -138,17 +138,17 @@ async function main() {
138
138
  case undefined:
139
139
  case "up": {
140
140
  const installed = readServiceInfo();
141
- if (installed && !values.pair && !values.install) {
142
- if (installed.version !== PACKAGE_VERSION) {
143
- console.log(`Updating the background service from ${installed.version} to ${PACKAGE_VERSION}.`);
144
- install();
145
- }
146
- status();
141
+ if (!installed || values.pair || values.install) checkCodex();
142
+ const { state, moved } = await ensureRelay(values.relay, console.log, values.name);
143
+ if (installed) {
144
+ // The background service serves the phone: restart it onto a new set-up or a newer version, then pair.
145
+ if (moved) console.log("Restarting the background service with the new set-up.");
146
+ else if (installed.version !== PACKAGE_VERSION) console.log(`Updating the background service from ${installed.version} to ${PACKAGE_VERSION}.`);
147
+ if (moved || values.install || installed.version !== PACKAGE_VERSION) install();
148
+ if (moved || values.pair || !state.devices.length) await pairOrExplain();
149
+ else status();
147
150
  break;
148
151
  }
149
- checkCodex();
150
- const state = await ensureEnrolled(values.relay, console.log, values.name);
151
- if (installed && !values.install) { await pairOrExplain(); break; } // the background service serves the phone
152
152
  if (runningPid()) throw new Error(`a bridge already runs for this computer (pid ${runningPid()}); stop it first`);
153
153
  let bridge = startBridge();
154
154
  if (values.pair || !state.devices.length) await pairOrExplain();
@@ -168,7 +168,7 @@ async function main() {
168
168
  break;
169
169
  }
170
170
  case "install":
171
- await ensureEnrolled(values.relay, console.log, values.name);
171
+ await ensureRelay(values.relay, console.log, values.name);
172
172
  install();
173
173
  break;
174
174
  case "uninstall": {
@@ -184,6 +184,7 @@ async function main() {
184
184
  const state = await enroll(relay, { name: values.name, publicRelay: values["public-url"] });
185
185
  console.log(`Set up ${state.hostName} as ${state.hostId}. Next: "npx replica-bridge".`);
186
186
  console.log("Nobody owns it yet: the first phone that scans its QR code and is approved here claims it.");
187
+ if (readServiceInfo()) { console.log("Restarting the background service with the new set-up."); install(); }
187
188
  break;
188
189
  }
189
190
  case "config": {
@@ -194,7 +195,10 @@ async function main() {
194
195
  break;
195
196
  }
196
197
  case "pair":
197
- await ensureEnrolled(values.relay, console.log, values.name);
198
+ if ((await ensureRelay(values.relay, console.log, values.name)).moved && readServiceInfo()) {
199
+ console.log("Restarting the background service with the new set-up.");
200
+ install();
201
+ }
198
202
  await pair({ confirm: values.confirm, yes: values.yes, timeoutSeconds: Number(values.timeout), pngPath: values.png });
199
203
  if (!runningPid()) console.log(`Nothing serves this computer right now: run "npx replica-bridge" so the phone can connect.`);
200
204
  break;
@@ -74,26 +74,36 @@ async function askTerminal(claim ) {
74
74
  export async function pair(options ) {
75
75
  const state = loadState();
76
76
  const say = (text ) => { if (!options.quiet) console.log(text); };
77
- const secret = crypto.randomBytes(32);
78
- const { code, expiresAt } = await relayRequest (state.relay, "POST", "/v1/pair/start", { token: state.hostToken, body: { secretHash: secretHash(secret) } });
79
- const link = buildPairingLink({ relay: publicRelayURL(state), hostId: state.hostId, hostPublicKey: fromB64url(state.hostPublicKey), code, secret, hostName: state.hostName });
80
- options.onLink?.(link, code);
81
- if (!options.quiet) {
82
- console.log(await QRCode.toString(link, { type: "terminal", small: true, errorCorrectionLevel: "M" }));
83
- console.log(`Scan with the app (远程 › 添加连接 › 配对新设备) or the iPhone camera, or type the code: ${formatCode(code)}`);
84
- console.log(`Link: ${link}`);
85
- console.log(`Expires at ${new Date(expiresAt).toLocaleTimeString()}. Waiting for the phone…`);
86
- }
87
- if (options.pngPath) {
88
- await QRCode.toFile(options.pngPath, link, { width: 512, errorCorrectionLevel: "M" });
89
- say(`QR code also saved to ${options.pngPath}`);
90
- }
77
+ // A code lives five minutes on the relay; until the timeout a fresh one replaces it, so a late scan still works.
78
+ const deadline = Date.now() + options.timeoutSeconds * 1000;
79
+ let secret ;
80
+ let code ;
81
+ let expiresAt ;
82
+ const show = async (renewed ) => {
83
+ secret = crypto.randomBytes(32);
84
+ ({ code, expiresAt } = await relayRequest (state.relay, "POST", "/v1/pair/start", { token: state.hostToken, body: { secretHash: secretHash(secret) } }));
85
+ const link = buildPairingLink({ relay: publicRelayURL(state), hostId: state.hostId, hostPublicKey: fromB64url(state.hostPublicKey), code, secret, hostName: state.hostName });
86
+ options.onLink?.(link, code);
87
+ if (!options.quiet) {
88
+ if (renewed) console.log("The code expired; scan this new one.");
89
+ console.log(await QRCode.toString(link, { type: "terminal", small: true, errorCorrectionLevel: "M" }));
90
+ console.log(`Scan with the app (远程 › 添加连接 › 配对新设备) or the iPhone camera, or type the code: ${formatCode(code)}`);
91
+ console.log(`Link: ${link}`);
92
+ console.log(`Expires at ${new Date(expiresAt).toLocaleTimeString()}${expiresAt < deadline ? " (a new code follows until " + new Date(deadline).toLocaleTimeString() + ")" : ""}. Waiting for the phone…`);
93
+ }
94
+ if (options.pngPath) {
95
+ await QRCode.toFile(options.pngPath, link, { width: 512, errorCorrectionLevel: "M" });
96
+ say(`QR code also saved to ${options.pngPath}`);
97
+ }
98
+ };
99
+ await show(false);
91
100
 
92
- const deadline = Math.min(expiresAt, Date.now() + options.timeoutSeconds * 1000);
93
101
  let claim = null;
94
102
  while (Date.now() < deadline) {
95
- const view = await relayRequest (state.relay, "GET", `/v1/pair/claim?code=${code}`, { token: state.hostToken });
103
+ if (Date.now() >= expiresAt ) { await show(true); continue; }
104
+ const view = await relayRequest (state.relay, "GET", `/v1/pair/claim?code=${code }`, { token: state.hostToken });
96
105
  if (view.status === "claimed") { claim = view; break; }
106
+ if (view.status === "expired") { expiresAt = 0; continue; }
97
107
  if (view.status !== "open") throw new Error(`pairing ${view.status}`);
98
108
  await new Promise((resolve) => setTimeout(resolve, 1000));
99
109
  }
@@ -106,7 +116,7 @@ export async function pair(options )
106
116
  };
107
117
  const decide = options.decide ?? askTerminal;
108
118
  let approve ;
109
- if (claim.proof && !verifyPairingProof(secret, code, devicePublicKey, fromB64url(claim.proof))) {
119
+ if (claim.proof && !verifyPairingProof(secret , code , devicePublicKey, fromB64url(claim.proof))) {
110
120
  say("The claim's proof does not match this QR code; rejecting it.");
111
121
  approve = false;
112
122
  } else if (info.safetyCode || options.confirm || (info.firstClaim && !options.yes)) {