replica-bridge 0.3.4 → 0.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replica-bridge",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
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": {
@@ -22,6 +22,8 @@ export const PACKAGE_VERSION = JSON.parse(fs.readFileSync(path.resolve(i
22
22
 
23
23
 
24
24
 
25
+
26
+
25
27
 
26
28
 
27
29
 
@@ -62,6 +64,7 @@ export function servicePlan(spec ) {
62
64
  <array>
63
65
  ${spec.program.map((a) => ` <string>${xml(a)}</string>`).join("\n")}
64
66
  </array>
67
+ <key>WorkingDirectory</key><string>${xml(spec.home)}</string>
65
68
  <key>EnvironmentVariables</key>
66
69
  <dict>
67
70
  ${env}
@@ -110,6 +113,10 @@ WantedBy=default.target
110
113
  { command: "systemctl", args: ["--user", "daemon-reload"] },
111
114
  { command: "systemctl", args: ["--user", "enable", unit] },
112
115
  { command: "systemctl", args: ["--user", "restart", unit] },
116
+ // A user service starts with the user's session; lingering starts it at boot, before anyone logs in, so a
117
+ // computer that restarts unattended (a server, a Mac mini in a cupboard) comes back. Allowed for oneself
118
+ // on most distributions; where it is not, main.ts says how.
119
+ ...(spec.user ? [{ command: "loginctl", args: ["enable-linger", spec.user], optional: true }] : []),
113
120
  ],
114
121
  uninstall: [
115
122
  { command: "systemctl", args: ["--user", "disable", "--now", unit], optional: true },
@@ -157,10 +164,38 @@ function serviceEnv() {
157
164
  for (const [key, value] of Object.entries(process.env)) {
158
165
  if (value !== undefined && (key === "PATH" || key === "Path" || /^(CODEX_|REPLICA_|RELAY_ENV$)/.test(key)) && key !== "REPLICA_BRIDGE_LOG") env[key] = value;
159
166
  }
167
+ for (const key of ["PATH", "Path"]) if (env[key]) env[key] = servicePATH(env[key]);
160
168
  env.REPLICA_BRIDGE_SERVICE = "1";
161
169
  return env;
162
170
  }
163
171
 
172
+ /**
173
+ * PATH without what npx put in front of it: its temporary package's .bin (npm may clear that cache), the
174
+ * node_modules/.bin of the folder npx ran in and of every folder above it, and npm's node-gyp shim.
175
+ */
176
+ export function servicePATH(value , delimiter = path.delimiter) {
177
+ return value.split(delimiter).filter((dir) => dir && !/[\\/]_npx[\\/]|[\\/]node_modules[\\/]\.bin$|node-gyp-bin$/.test(dir)).join(delimiter);
178
+ }
179
+
180
+ /**
181
+ * The node the service runs. process.execPath has its links resolved: with Homebrew it is
182
+ * /opt/homebrew/Cellar/node/<version>/bin/node, which `brew upgrade node` deletes, so at the next login (after a
183
+ * restart) launchd had nothing to run and the bridge never came back. A `node` on PATH that is the same program under
184
+ * a path without the version (/opt/homebrew/bin/node, which brew points at the new version) is used instead.
185
+ */
186
+ export function stableNodePath(execPath , pathValue , delimiter = path.delimiter, realpath = (p ) => fs.realpathSync(p)) {
187
+ let real ;
188
+ try { real = realpath(execPath); } catch { return execPath; }
189
+ const name = path.basename(execPath);
190
+ for (const dir of servicePATH(pathValue, delimiter).split(delimiter)) {
191
+ if (!dir) continue;
192
+ const candidate = path.join(dir, name);
193
+ if (candidate === execPath) return execPath; // already the path PATH finds, as with nvm or the official installer
194
+ try { if (realpath(candidate) === real) return candidate; } catch { /* not there */ }
195
+ }
196
+ return execPath;
197
+ }
198
+
164
199
  /**
165
200
  * The script the service runs. From npx (inside node_modules) a copy of this version is installed under
166
201
  * ~/.config/replica-bridge/app, since npm may clear its npx cache; from a working copy, the working copy itself.
@@ -206,14 +241,15 @@ export function readServiceInfo() {
206
241
  }
207
242
 
208
243
  function spec(program ) {
209
- return { platform: process.platform, label: serviceLabel(), home: os.homedir(), bridgeHome: bridgeHome(), program, env: serviceEnv(), uid: os.userInfo().uid };
244
+ const { uid, username } = os.userInfo();
245
+ return { platform: process.platform, label: serviceLabel(), home: os.homedir(), bridgeHome: bridgeHome(), program, env: serviceEnv(), uid, user: username };
210
246
  }
211
247
 
212
248
  /** Installs (or replaces) the background service and starts it. `startArgs` are the options `start` keeps. */
213
249
  export function installService(startArgs , say ) {
214
250
  servicePlan(spec([])); // fails early on a platform without a service manager
215
251
  stopRunning();
216
- const program = [process.execPath, installedScript(say), "start", ...startArgs];
252
+ const program = [stableNodePath(process.execPath, process.env.PATH ?? process.env.Path ?? ""), installedScript(say), "start", ...startArgs];
217
253
  const plan = servicePlan(spec(program));
218
254
  fs.mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 });
219
255
  for (const file of plan.files) {
@@ -249,10 +285,23 @@ export function clearPid() {
249
285
  try { if (fs.readFileSync(pidPath(), "utf8").trim() === String(process.pid)) fs.rmSync(pidPath()); } catch { /* gone */ }
250
286
  }
251
287
 
288
+ /**
289
+ * Whether a pid file was written before this boot. A computer that loses power or is shut down hard leaves it behind,
290
+ * and after the restart its pid can belong to any other process: `start` took that for a running bridge, exited 1,
291
+ * and the service manager retried every ten seconds for as long as that process lived. Five seconds of slack, since
292
+ * the boot time is worked out from the uptime.
293
+ */
294
+ export function writtenBeforeBoot(mtimeMs , now = Date.now(), uptimeSeconds = os.uptime()) {
295
+ return mtimeMs < now - uptimeSeconds * 1000 - 5000;
296
+ }
297
+
252
298
  /** The background bridge's pid while it runs. */
253
299
  export function runningPid() {
254
300
  let pid ;
255
- try { pid = Number(fs.readFileSync(pidPath(), "utf8").trim()); } catch { return null; }
301
+ try {
302
+ pid = Number(fs.readFileSync(pidPath(), "utf8").trim());
303
+ if (writtenBeforeBoot(fs.statSync(pidPath()).mtimeMs)) return null;
304
+ } catch { return null; }
256
305
  if (!pid || pid === process.pid) return null;
257
306
  try { process.kill(pid, 0); return pid; } catch (error) { return (error ).code === "EPERM" ? pid : null; }
258
307
  }
@@ -12,6 +12,7 @@
12
12
  // devices
13
13
  // revoke <deviceId>
14
14
  import fs from "node:fs";
15
+ import os from "node:os";
15
16
  import readline from "node:readline/promises";
16
17
  import { parseArgs, format } from "node:util";
17
18
  import { relayRequest } from "../http.js";
@@ -109,7 +110,11 @@ function install() {
109
110
  const plan = installService(startArgs(), console.log);
110
111
  console.log(`Running in the background: ${plan.where}. It starts at login and comes back if it stops, so this terminal can be closed.`);
111
112
  console.log(`Log: ${logPath()}. Remove it with "npx replica-bridge uninstall".`);
112
- if (process.platform === "linux") console.log(`To keep it running while you are logged out: loginctl enable-linger ${process.env.USER ?? "$USER"}`);
113
+ // The install asked for lingering (the service starts at boot, not at login); some systems only let root grant it.
114
+ const user = os.userInfo().username;
115
+ if (process.platform === "linux" && !fs.existsSync(`/var/lib/systemd/linger/${user}`)) {
116
+ console.log(`To start it when the computer boots, before anyone logs in: sudo loginctl enable-linger ${user}`);
117
+ }
113
118
  }
114
119
 
115
120
  async function pairOrExplain() {
@@ -10,7 +10,7 @@ import { Duplex } from "node:stream";
10
10
  import { WebSocket } from "ws";
11
11
 
12
12
  import {
13
- CloseCode, FrameCipher, MAX_FRAME_BYTES, StreamBuffer, b64url, fromB64url, generateKeyPair, hostSessionKeys,
13
+ CloseCode, FrameCipher, MAX_FRAME_BYTES, RELAY_REPLY_LIMIT, StreamBuffer, b64url, fromB64url, generateKeyPair, hostSessionKeys,
14
14
  } from "../protocol.js";
15
15
 
16
16
  import { relaySocketURL } from "../http.js";
@@ -233,7 +233,11 @@ export class BridgeService {
233
233
  const stream = { id: crypto.randomBytes(12).toString("base64url"), deviceId, appServer, out: new StreamBuffer(), in: new StreamBuffer(), conn: null, expiry: null, ackTimer: null, relayed: Promise.resolve() };
234
234
  this.streams.set(stream.id, stream);
235
235
  appServer.on("message", (data ) => {
236
- const annotated = annotateWriterError(data.toString(), this.options.findWriter);
236
+ const text = data.toString();
237
+ const fitted = fitLine(text);
238
+ if (fitted === null) return this.log(`stream ${stream.id}: dropped a ${text.length}-byte app-server notification too large for the relay`);
239
+ if (fitted !== text) this.log(`stream ${stream.id}: answered a ${text.length}-byte app-server reply too large for the relay with an error`);
240
+ const annotated = annotateWriterError(fitted, this.options.findWriter);
237
241
  stream.relayed = stream.relayed.then(async () => {
238
242
  const line = await annotated;
239
243
  if (!this.streams.has(stream.id)) return;
@@ -290,7 +294,9 @@ export class BridgeService {
290
294
  const pipe = Duplex.from({ readable: child.stdout , writable: child.stdin }) ;
291
295
  // The HTTP client that carries the WebSocket handshake tunes its socket; a pipe has nothing to tune.
292
296
  for (const name of ["setNoDelay", "setKeepAlive", "setTimeout", "ref", "unref"]) pipe[name] = () => pipe;
293
- const ws = new WebSocket("ws://localhost/", { createConnection: () => pipe , perMessageDeflate: false, maxPayload: MAX_FRAME_BYTES });
297
+ // Read app-server messages well past what one relay frame holds, so fitLine answers a big reply with an error
298
+ // rather than the oversized message closing the app-server connection.
299
+ const ws = new WebSocket("ws://localhost/", { createConnection: () => pipe , perMessageDeflate: false, maxPayload: APP_SERVER_MAX_MESSAGE_BYTES });
294
300
  const fail = (why ) => { ws.removeAllListeners("open"); killCommand(child); reject(new Error(why)); };
295
301
  child.once("error", (error) => fail(`could not run codex: ${error.message}`));
296
302
  child.once("exit", (code) => { if (ws.readyState !== WebSocket.OPEN) fail(`codex app-server proxy exited (${code})${stderr ? `: ${stderr.trim()}` : ""}`); });
@@ -339,3 +345,20 @@ export class BridgeService {
339
345
  throw new Error(`codex app-server did not come up: ${last?.message}`);
340
346
  }
341
347
  }
348
+
349
+ /** Largest app-server message the bridge reads before refusing it. */
350
+ const APP_SERVER_MAX_MESSAGE_BYTES = 256 * 1024 * 1024;
351
+
352
+ /**
353
+ * An app-server line as it can cross the relay: unchanged when it fits RELAY_REPLY_LIMIT, a JSON-RPC error with the
354
+ * same id when it is a reply that does not (`error.data.replicaTooLarge: {bytes, limit}`, PROTOCOL.md), and null for a
355
+ * notification that does not, which has no one to answer.
356
+ */
357
+ export function fitLine(line ) {
358
+ const bytes = Buffer.byteLength(line, "utf8");
359
+ if (bytes <= RELAY_REPLY_LIMIT) return line;
360
+ let id ;
361
+ try { id = (JSON.parse(line) ).id; } catch { return null; }
362
+ if (id === undefined || id === null) return null;
363
+ return JSON.stringify({ id, error: { code: -32010, message: `the reply is ${bytes} bytes, more than the relay carries (${RELAY_REPLY_LIMIT})`, data: { replicaTooLarge: { bytes, limit: RELAY_REPLY_LIMIT } } } });
364
+ }
package/src/protocol.js CHANGED
@@ -9,6 +9,12 @@ export const LINK_SCHEME = "chatreplica:";
9
9
  export const CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTVWXYZ";
10
10
  export const CODE_LENGTH = 8;
11
11
  export const MAX_FRAME_BYTES = 16 * 1024 * 1024;
12
+ /**
13
+ * The longest app-server line the bridge forwards. A line travels as {t,seq,line} JSON, sealed (+16 bytes) and
14
+ * base64url encoded (x4/3) inside a frame envelope, so 11 MiB stays under MAX_FRAME_BYTES; longer replies (fs/readFile
15
+ * of a big file) come back as a JSON-RPC error instead of a frame the relay would refuse by closing the connection.
16
+ */
17
+ export const RELAY_REPLY_LIMIT = 11 * 1024 * 1024;
12
18
 
13
19
  export const b64url = (data ) => Buffer.from(data).toString("base64url");
14
20
  export const fromB64url = (text ) => Buffer.from(text, "base64url");