replica-bridge 0.3.2 → 0.3.4

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.4",
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 {
@@ -165,11 +165,24 @@ function serviceEnv() {
165
165
  * The script the service runs. From npx (inside node_modules) a copy of this version is installed under
166
166
  * ~/.config/replica-bridge/app, since npm may clear its npx cache; from a working copy, the working copy itself.
167
167
  */
168
- function installedScript(say ) {
169
- const current = process.argv[1] ? fs.realpathSync(process.argv[1]) : "";
170
- if (!current.split(path.sep).includes("node_modules")) return current;
168
+ export function installedScript(say , current = process.argv[1] ? fs.realpathSync(process.argv[1]) : "") {
169
+ const parts = current.split(path.sep);
170
+ const at = parts.lastIndexOf("node_modules");
171
+ if (at < 0) return current;
171
172
  const app = path.join(bridgeHome(), "app");
172
- const spec = process.env.REPLICA_BRIDGE_PACKAGE ?? `replica-bridge@${PACKAGE_VERSION}`;
173
+ if (!process.env.REPLICA_BRIDGE_PACKAGE) {
174
+ // Copy what npx already installed (this package and its dependencies) rather than asking npm again: npx may have
175
+ // used a registry the second npm would not (`npx --registry`, or a company feed without this package).
176
+ const from = parts.slice(0, at + 1).join(path.sep);
177
+ say(`Copying replica-bridge ${PACKAGE_VERSION} to ${app}…`);
178
+ const next = `${app}.${process.pid}.part`;
179
+ fs.rmSync(next, { recursive: true, force: true });
180
+ fs.cpSync(from, path.join(next, "node_modules"), { recursive: true, dereference: true });
181
+ fs.rmSync(app, { recursive: true, force: true });
182
+ fs.renameSync(next, app);
183
+ return path.join(app, "node_modules", ...parts.slice(at + 1));
184
+ }
185
+ const spec = process.env.REPLICA_BRIDGE_PACKAGE;
173
186
  say(`Installing ${spec} in ${app}…`);
174
187
  fs.mkdirSync(app, { recursive: true });
175
188
  // npx's own npm_* settings (a temporary prefix, for one) must not leak into this install.
@@ -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)) {