replica-bridge 0.3.10 → 0.3.12

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.10",
3
+ "version": "0.3.12",
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": {
@@ -0,0 +1,145 @@
1
+ // The optional 任务 board (PROTOCOL.md "看板"): reads AgensWork's agens-backend, which only listens on this computer, for
2
+ // a phone that asked. The bridge does not start the backend or care how it started: it knows a port, and a computer
3
+ // where nothing answers there simply has no board. Reads are a short allowlist, and the only background traffic is one
4
+ // poll of /v1/changes while a phone is subscribed, because reading the lists counts as the user having seen them.
5
+
6
+
7
+ /** agens-backend's default port, which its own AGENS_BACKEND_PORT changes. */
8
+ export const DEFAULT_BOARD_PORT = 4753;
9
+ /** agens-backend's own page polls every 10 s. */
10
+ const DEFAULT_POLL_SECONDS = 10;
11
+ /** Past this the backend is treated as gone, so a hung one cannot hold a phone waiting. */
12
+ const READ_TIMEOUT_MS = 5000;
13
+ /** Well under RELAY_REPLY_LIMIT, since the bridge sends board answers in one frame. */
14
+ const BODY_MAX_BYTES = 8 * 1024 * 1024;
15
+ const CHANGES_PAGE = 100;
16
+ /** Stops a catch-up on a change log that never ends; the backend keeps 30 days of it. */
17
+ const CATCH_UP_PAGES = 1000;
18
+
19
+ /** Read-only, and none of them does work on the backend. Not /session, /share, /actions, /assist, /mcp or /search. */
20
+ const READS = [/^\/v1\/status$/, /^\/v1\/today$/, /^\/v1\/recommendations$/, /^\/v1\/recommendations\/[A-Za-z0-9_.:-]+$/];
21
+
22
+ /** The path and query to read, or null when it is not on the allowlist. */
23
+ export function allowedBoardPath(path ) {
24
+ if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//") || path.includes("\\")) return null;
25
+ let target ;
26
+ try { target = new URL(path, "http://board.invalid"); } catch { return null; }
27
+ if (target.origin !== "http://board.invalid" || !READS.some((read) => read.test(target.pathname))) return null;
28
+ return target.pathname + target.search;
29
+ }
30
+
31
+ /** The port from AGENS_BACKEND_PORT, the backend's own setting, when it names a real one. */
32
+ export function boardPortFrom(env = process.env) {
33
+ const port = Number(env.AGENS_BACKEND_PORT);
34
+ return Number.isInteger(port) && port > 0 && port <= 65535 ? port : DEFAULT_BOARD_PORT;
35
+ }
36
+
37
+
38
+
39
+
40
+
41
+
42
+ export class BoardReader {
43
+ port ;
44
+ pollMs ;
45
+ listeners = new Set ();
46
+ timer = null;
47
+ polling = false;
48
+ /** Where the change log was read to; null until a poll has found its end. */
49
+ after = null;
50
+ up = null;
51
+ /** Bumped when polling stops, so a poll still on its way does not speak for the next round. */
52
+ round = 0;
53
+
54
+ constructor(options = {}) {
55
+ this.port = options.port ?? boardPortFrom();
56
+ this.pollMs = (options.pollSeconds ?? DEFAULT_POLL_SECONDS) * 1000;
57
+ }
58
+
59
+ /** The fetch adds the Host header the backend insists on (127.0.0.1:<port>) and no Origin, as a local caller. */
60
+ fetch(pathAndQuery ) {
61
+ return fetch(`http://127.0.0.1:${this.port}${pathAndQuery}`, { redirect: "error", signal: AbortSignal.timeout(READ_TIMEOUT_MS) });
62
+ }
63
+
64
+ async read(path ) {
65
+ const allowed = allowedBoardPath(path);
66
+ if (!allowed) return { error: "not_allowed" };
67
+ try {
68
+ const response = await this.fetch(allowed);
69
+ const body = await response.text();
70
+ if (Buffer.byteLength(body, "utf8") > BODY_MAX_BYTES) return { error: "too_large" };
71
+ return { status: response.status, body };
72
+ } catch {
73
+ return { error: "no_board" };
74
+ }
75
+ }
76
+
77
+ /** Tells `listener` about the board until the returned function is called. Polls only while someone listens. */
78
+ subscribe(listener ) {
79
+ this.listeners.add(listener);
80
+ if (this.up !== null) listener({ t: "board/state", up: this.up });
81
+ if (!this.timer) {
82
+ this.timer = setInterval(() => void this.poll(), this.pollMs);
83
+ this.timer.unref();
84
+ void this.poll();
85
+ }
86
+ return () => {
87
+ this.listeners.delete(listener);
88
+ if (!this.listeners.size) this.stop();
89
+ };
90
+ }
91
+
92
+ stop() {
93
+ this.listeners.clear();
94
+ if (this.timer) clearInterval(this.timer);
95
+ this.timer = null;
96
+ this.after = null;
97
+ this.up = null;
98
+ this.polling = false;
99
+ this.round++;
100
+ }
101
+
102
+ emit(event ) {
103
+ for (const listener of this.listeners) listener(event);
104
+ }
105
+
106
+ async changes(after ) {
107
+ const response = await this.fetch(`/v1/changes?after=${after}&limit=${CHANGES_PAGE}`);
108
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
109
+ const page = await response.json() ;
110
+ if (!Array.isArray(page.items)) throw new Error("not a page of changes");
111
+ return { items: page.items, next: typeof page.next === "number" ? page.next : after };
112
+ }
113
+
114
+ async poll() {
115
+ if (this.polling) return;
116
+ this.polling = true;
117
+ const round = this.round;
118
+ let changed = false, up = true, after = this.after;
119
+ try {
120
+ // The first poll only finds the end of the log: what happened before someone was listening is not news.
121
+ if (after === null) {
122
+ after = 0;
123
+ for (let page = 0; page < CATCH_UP_PAGES; page++) {
124
+ const { items, next } = await this.changes(after);
125
+ after = next;
126
+ if (items.length < CHANGES_PAGE) break;
127
+ }
128
+ } else {
129
+ const { items, next } = await this.changes(after);
130
+ after = next;
131
+ changed = items.length > 0;
132
+ }
133
+ } catch {
134
+ up = false;
135
+ }
136
+ if (round !== this.round) return;
137
+ this.polling = false;
138
+ if (up) this.after = after;
139
+ if (up !== this.up) {
140
+ this.up = up;
141
+ this.emit({ t: "board/state", up });
142
+ }
143
+ if (changed) this.emit({ t: "board/changed" });
144
+ }
145
+ }
@@ -9,7 +9,11 @@ import path from "node:path";
9
9
  import { spawnCommandSync } from "./command.js";
10
10
  import { bridgeHome } from "./state.js";
11
11
 
12
- export const PACKAGE_VERSION = JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, "../../package.json"), "utf8")).version;
12
+ /** Set by tools/build-embedded-bridge.ts: a single-file bundle has no package.json beside it. */
13
+
14
+ export const PACKAGE_VERSION = typeof EMBEDDED_BRIDGE_VERSION === "string"
15
+ ? EMBEDDED_BRIDGE_VERSION
16
+ : JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, "../../package.json"), "utf8")).version;
13
17
 
14
18
 
15
19
 
@@ -212,7 +216,7 @@ WantedBy=default.target
212
216
  function serviceEnv() {
213
217
  const env = {};
214
218
  for (const [key, value] of Object.entries(process.env)) {
215
- if (value !== undefined && (key === "PATH" || key === "Path" || /^(CODEX_|REPLICA_|RELAY_ENV$)/.test(key)) && key !== "REPLICA_BRIDGE_LOG") env[key] = value;
219
+ if (value !== undefined && (key === "PATH" || key === "Path" || /^(CODEX_|REPLICA_|RELAY_ENV$|AGENS_BACKEND_PORT$)/.test(key)) && key !== "REPLICA_BRIDGE_LOG") env[key] = value;
216
220
  }
217
221
  for (const key of ["PATH", "Path"]) if (env[key]) env[key] = servicePATH(env[key]);
218
222
  env.REPLICA_BRIDGE_SERVICE = "1";
@@ -46,6 +46,8 @@ export function publicRelayURL(state ) {
46
46
 
47
47
 
48
48
 
49
+
50
+
49
51
 
50
52
 
51
53
 
@@ -100,6 +102,7 @@ export async function pair(options )
100
102
 
101
103
  let claim = null;
102
104
  while (Date.now() < deadline) {
105
+ options.signal?.throwIfAborted();
103
106
  if (Date.now() >= expiresAt ) { await show(true); continue; }
104
107
  const view = await relayRequest (state.relay, "GET", `/v1/pair/claim?code=${code }`, { token: state.hostToken });
105
108
  if (view.status === "claimed") { claim = view; break; }
@@ -14,6 +14,7 @@ import {
14
14
  } from "../protocol.js";
15
15
 
16
16
  import { relaySocketURL } from "../http.js";
17
+ import { BoardReader } from "./board.js";
17
18
  import { killCommand, spawnCommand } from "./command.js";
18
19
  import { hostKeyPair, loadState } from "./state.js";
19
20
  import { annotateWriterError } from "./writer.js";
@@ -39,6 +40,23 @@ import { annotateWriterError } from "./writer.js";
39
40
 
40
41
 
41
42
 
43
+
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+
54
+
55
+
56
+
57
+
58
+
59
+
42
60
 
43
61
 
44
62
 
@@ -67,6 +85,8 @@ import { annotateWriterError } from "./writer.js";
67
85
 
68
86
 
69
87
 
88
+
89
+
70
90
 
71
91
 
72
92
  export class BridgeService {
@@ -74,13 +94,16 @@ export class BridgeService {
74
94
  conns = new Map ();
75
95
  streams = new Map ();
76
96
  stopped = false;
97
+ retryTimer = null;
77
98
  retry = 0;
78
99
  log ;
79
100
 
80
101
  options ;
102
+ board ;
81
103
 
82
104
  constructor(options = {}) {
83
105
  this.options = options;
106
+ this.board = new BoardReader({ port: options.boardPort, pollSeconds: options.boardPollSeconds });
84
107
  this.log = options.log ?? ((message) => console.log(`${new Date().toISOString()} ${message}`));
85
108
  }
86
109
 
@@ -90,7 +113,9 @@ export class BridgeService {
90
113
 
91
114
  stop() {
92
115
  this.stopped = true;
116
+ if (this.retryTimer) clearTimeout(this.retryTimer);
93
117
  this.relay?.close();
118
+ this.board.stop();
94
119
  for (const stream of this.streams.values()) this.endStream(stream, "bridge stopped");
95
120
  }
96
121
 
@@ -113,7 +138,7 @@ export class BridgeService {
113
138
  }, (this.options.relayPingSeconds ?? 30) * 1000);
114
139
  ws.on("pong", () => { answered = true; });
115
140
  ws.on("ping", () => { answered = true; });
116
- ws.on("open", () => { answered = true; this.retry = 0; this.log(`connected to relay ${state.relay} as ${state.hostId}`); });
141
+ ws.on("open", () => { answered = true; this.retry = 0; this.log(`connected to relay ${state.relay} as ${state.hostId}`); this.options.onRelayConnected?.(true); });
117
142
  ws.on("message", (data ) => {
118
143
  let envelope ;
119
144
  try { envelope = JSON.parse(data.toString()); } catch { return; }
@@ -131,6 +156,7 @@ export class BridgeService {
131
156
  ws.on("close", (code) => {
132
157
  clearInterval(ping);
133
158
  if (ws !== this.relay) return; // a socket this bridge already replaced itself
159
+ this.options.onRelayConnected?.(false);
134
160
  for (const conn of this.conns.values()) this.detach(conn);
135
161
  this.conns.clear();
136
162
  if (this.stopped) return;
@@ -141,7 +167,7 @@ export class BridgeService {
141
167
  return;
142
168
  }
143
169
  const delay = Math.min(30_000, 1000 * 2 ** this.retry++);
144
- setTimeout(() => this.connectRelay(), delay);
170
+ this.retryTimer = setTimeout(() => this.connectRelay(), delay);
145
171
  });
146
172
  }
147
173
 
@@ -173,7 +199,7 @@ export class BridgeService {
173
199
 
174
200
  onEnvelope(envelope ) {
175
201
  if (envelope.type === "open") {
176
- this.conns.set(envelope.conn, { id: envelope.conn, deviceId: envelope.device, cipher: null, stream: null, queue: Promise.resolve() });
202
+ this.conns.set(envelope.conn, { id: envelope.conn, deviceId: envelope.device, cipher: null, stream: null, queue: Promise.resolve(), boardUnsubscribe: null });
177
203
  } else if (envelope.type === "close") {
178
204
  const conn = this.conns.get(envelope.conn);
179
205
  if (conn) { this.detach(conn); this.conns.delete(conn.id); }
@@ -240,6 +266,14 @@ export class BridgeService {
240
266
  // The phone is done with this stream: stop its proxy now, so the app-server unloads the threads it followed and
241
267
  // the computer's other Codex apps can open them, rather than holding them for streamTTL.
242
268
  if (conn.stream) this.endStream(conn.stream, "the phone closed the stream");
269
+ } else if (message.t === "board/get") {
270
+ // Not awaited: a slow backend should not hold up the frames behind this one.
271
+ void this.board.read(message.path).then((answer) => this.sendInner(conn, { t: "board/res", id: message.id, ...answer }));
272
+ } else if (message.t === "board/sub") {
273
+ conn.boardUnsubscribe ??= this.board.subscribe((event) => this.sendInner(conn, event));
274
+ } else if (message.t === "board/unsub") {
275
+ conn.boardUnsubscribe?.();
276
+ conn.boardUnsubscribe = null;
243
277
  }
244
278
  }
245
279
 
@@ -268,6 +302,17 @@ export class BridgeService {
268
302
  this.log(`${resumed ? "resumed" : "opened"} stream ${s.id} for ${conn.deviceId}`);
269
303
  }
270
304
 
305
+ withheldFromPhone(text ) {
306
+ const forwards = this.options.forwardsAppServerRequest;
307
+ if (!forwards || text.length > MAX_REQUEST_CHARS_TO_INSPECT) return false;
308
+ try {
309
+ const { id, method } = JSON.parse(text);
310
+ return id !== undefined && typeof method === "string" && !forwards(method);
311
+ } catch {
312
+ return false;
313
+ }
314
+ }
315
+
271
316
  async openStream(deviceId ) {
272
317
  const { ws: appServer, frameBytes } = await this.connectAppServer();
273
318
  const stream = {
@@ -277,6 +322,7 @@ export class BridgeService {
277
322
  this.streams.set(stream.id, stream);
278
323
  appServer.on("message", (data ) => {
279
324
  const text = data.toString();
325
+ if (this.withheldFromPhone(text)) return;
280
326
  const segmented = stream.segmented;
281
327
  const fitted = fitLine(text, segmented ? REASSEMBLED_MAX_BYTES : RELAY_REPLY_LIMIT);
282
328
  if (fitted === null) return this.log(`stream ${stream.id}: dropped a ${text.length}-byte app-server notification too large for the relay`);
@@ -308,6 +354,8 @@ export class BridgeService {
308
354
  }
309
355
 
310
356
  detach(conn ) {
357
+ conn.boardUnsubscribe?.();
358
+ conn.boardUnsubscribe = null;
311
359
  const stream = conn.stream;
312
360
  conn.stream = null;
313
361
  if (!stream || stream.conn !== conn) return;
@@ -357,10 +405,7 @@ export class BridgeService {
357
405
  // rather than the oversized message closing the app-server connection.
358
406
  const ws = new WebSocket("ws://localhost/", { createConnection: () => pipe , perMessageDeflate: false, maxPayload: APP_SERVER_MAX_MESSAGE_BYTES });
359
407
  let frameBytes = APP_SERVER_FRAME_BYTES;
360
- ws.once("upgrade", (res) => {
361
- const advertised = Number(res.headers["x-codex-websocket-max-unfragmented-message-bytes"]);
362
- if (Number.isInteger(advertised) && advertised >= 64 * 1024) frameBytes = advertised;
363
- });
408
+ ws.once("upgrade", (res) => { frameBytes = advertisedFrameBytes(res.headers); });
364
409
  const fail = (why ) => { ws.removeAllListeners("open"); killCommand(child); reject(new Error(why)); };
365
410
  child.once("error", (error) => fail(`could not run codex: ${error.message}`));
366
411
  child.once("exit", (code) => { if (ws.readyState !== WebSocket.OPEN) fail(`codex app-server proxy exited (${code})${stderr ? `: ${stderr.trim()}` : ""}`); });
@@ -382,7 +427,21 @@ export class BridgeService {
382
427
  });
383
428
  }
384
429
 
430
+ /** A direct connection to the host's own app-server. */
431
+ async openEndpoint(endpoint ) {
432
+ const { url, token } = await endpoint();
433
+ return new Promise((resolve, reject) => {
434
+ const ws = new WebSocket(url, { headers: { authorization: `Bearer ${token}` }, perMessageDeflate: false, maxPayload: APP_SERVER_MAX_MESSAGE_BYTES });
435
+ let frameBytes = APP_SERVER_FRAME_BYTES;
436
+ ws.once("upgrade", (res) => { frameBytes = advertisedFrameBytes(res.headers); });
437
+ ws.once("error", (error) => { if (ws.readyState !== WebSocket.OPEN) reject(error); });
438
+ ws.once("open", () => resolve({ ws, frameBytes }));
439
+ });
440
+ }
441
+
385
442
  async connectAppServer() {
443
+ const endpoint = this.options.appServerEndpoint;
444
+ if (endpoint) return this.awaitAppServer(() => this.openEndpoint(endpoint), HOST_APP_SERVER_ATTEMPTS);
386
445
  try {
387
446
  return await this.openProxy();
388
447
  } catch (error) {
@@ -401,15 +460,30 @@ export class BridgeService {
401
460
  if (!started && !(await this.run(codex, ["app-server", "daemon", "start"]).catch(() => false))) {
402
461
  throw new Error("codex app-server daemon start failed");
403
462
  }
463
+ return this.awaitAppServer(() => this.openProxy(), 40);
464
+ }
465
+
466
+ async awaitAppServer(open , attempts ) {
404
467
  let last = null;
405
- for (let attempt = 0; attempt < 40; attempt++) {
406
- try { return await this.openProxy(); } catch (error) { last = error ; }
468
+ for (let attempt = 0; attempt < attempts; attempt++) {
469
+ try { return await open(); } catch (error) { last = error ; }
407
470
  await new Promise((resolve) => setTimeout(resolve, 250));
408
471
  }
409
472
  throw new Error(`codex app-server did not come up: ${last?.message}`);
410
473
  }
411
474
  }
412
475
 
476
+ /** Larger than any request the app-server makes of a client; only replies and notifications run this long. */
477
+ const MAX_REQUEST_CHARS_TO_INSPECT = 1024 * 1024;
478
+
479
+ /** Longest message the app-server takes in one frame, from its handshake. */
480
+ function advertisedFrameBytes(headers ) {
481
+ const advertised = Number(headers["x-codex-websocket-max-unfragmented-message-bytes"]);
482
+ return Number.isInteger(advertised) && advertised >= 64 * 1024 ? advertised : APP_SERVER_FRAME_BYTES;
483
+ }
484
+
485
+ /** A minute: the host may still be starting, or restarting its app-server to apply new settings. */
486
+ const HOST_APP_SERVER_ATTEMPTS = 240;
413
487
  /** Largest app-server message the bridge reads before refusing it. */
414
488
  const APP_SERVER_MAX_MESSAGE_BYTES = 256 * 1024 * 1024;
415
489
 
package/src/protocol.js CHANGED
@@ -176,7 +176,17 @@ export function frameNonce(counter ) {
176
176
  return nonce;
177
177
  }
178
178
 
179
+ /** ChaCha20-Poly1305 where the sealed bytes are the ciphertext followed by the 16-byte tag. */
180
+
181
+
182
+
183
+
184
+ let frameAEAD = null;
185
+ /** Replaces node:crypto for frames, on a runtime that does not have the cipher: Electron's BoringSSL (bridge/embedded.ts). */
186
+ export function useFrameAEAD(aead ) { frameAEAD = aead; }
187
+
179
188
  export function seal(key , counter , aad , plaintext ) {
189
+ if (frameAEAD) return Buffer.from(frameAEAD.seal(key, frameNonce(counter), aad, plaintext));
180
190
  const cipher = crypto.createCipheriv("chacha20-poly1305", key, frameNonce(counter), { authTagLength: 16 });
181
191
  cipher.setAAD(aad, { plaintextLength: plaintext.length });
182
192
  return Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
@@ -184,6 +194,7 @@ export function seal(key , counter , aad , plaintext
184
194
 
185
195
  export function open(key , counter , aad , sealed ) {
186
196
  if (sealed.length < 16) throw new Error("frame too short");
197
+ if (frameAEAD) return Buffer.from(frameAEAD.open(key, frameNonce(counter), aad, sealed));
187
198
  const decipher = crypto.createDecipheriv("chacha20-poly1305", key, frameNonce(counter), { authTagLength: 16 });
188
199
  decipher.setAAD(aad, { plaintextLength: sealed.length - 16 });
189
200
  decipher.setAuthTag(sealed.subarray(sealed.length - 16));
@@ -226,7 +237,16 @@ export class FrameCipher {
226
237
 
227
238
 
228
239
 
229
-
240
+
241
+
242
+
243
+
244
+
245
+
246
+
247
+ /** A read of the optional board: the backend's status and body, or why there is none (PROTOCOL.md "看板"). */
248
+
249
+
230
250
 
231
251
 
232
252
  /** One segment of a long line: `data` is base64 of bytes [segment * chunk size, …) of the payload, `size` its length. */