replica-bridge 0.3.5 → 0.3.8

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.5",
3
+ "version": "0.3.8",
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": {
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "qrcode": "1.5.4",
14
- "ws": "8.18.3"
14
+ "ws": "8.22.0"
15
15
  },
16
16
  "license": "UNLICENSED"
17
17
  }
@@ -1,6 +1,7 @@
1
1
  // Keeping the bridge running after its terminal closes, the way Codex keeps its app-server: a per-user background
2
2
  // service that starts at login and restarts when it fails. macOS: a launchd agent. Linux: a systemd user service.
3
- // Windows: a Task Scheduler task at logon that runs a hidden script, which restarts the bridge when it exits.
3
+ // Windows: a Task Scheduler task at logon (and checked every minute) that runs a hidden script, which restarts the
4
+ // bridge when it exits.
4
5
  // The service runs a copy installed under ~/.config/replica-bridge/app, not the npx cache, which npm may clear.
5
6
  import fs from "node:fs";
6
7
  import os from "node:os";
@@ -24,10 +25,15 @@ export const PACKAGE_VERSION = JSON.parse(fs.readFileSync(path.resolve(i
24
25
 
25
26
 
26
27
 
28
+
29
+
30
+
31
+
27
32
 
28
33
 
29
34
 
30
-
35
+
36
+
31
37
 
32
38
 
33
39
 
@@ -39,6 +45,7 @@ export const serviceLabel = () => process.env.REPLICA_BRIDGE_LABEL ?? "replica-b
39
45
  export const logPath = (home = bridgeHome()) => path.join(home, "bridge.log");
40
46
  const infoPath = () => path.join(bridgeHome(), "service.json");
41
47
  const pidPath = () => path.join(bridgeHome(), "bridge.pid");
48
+ const pausedPath = () => path.join(bridgeHome(), "service-paused");
42
49
 
43
50
  const xml = (text ) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
44
51
  /** systemd unit-file quoting: specifiers (%) doubled, backslashes and quotes escaped. */
@@ -126,6 +133,7 @@ WantedBy=default.target
126
133
  }
127
134
  case "win32": {
128
135
  const script = path.join(spec.bridgeHome, "run-bridge.vbs");
136
+ const task = path.join(spec.bridgeHome, "replica-bridge-task.xml");
129
137
  // wscript shows no window; the loop brings the bridge back ten seconds after it fails. Exit code 0 means another
130
138
  // bridge took this computer over (or it was told to stop), so the loop ends.
131
139
  const content = [
@@ -139,11 +147,42 @@ WantedBy=default.target
139
147
  `Loop`,
140
148
  "",
141
149
  ].join("\r\n");
150
+ // `schtasks /SC ONLOGON` left Task Scheduler's defaults, which stopped the bridge on a laptop that went on battery
151
+ // (and never started it on one), stopped it after three days, and started it again only at the next login: the
152
+ // computer went offline for the phones until someone logged in. The task XML turns those off, retries a failed
153
+ // start, and checks every minute that the task runs (IgnoreNew: never a second copy while one runs). The logon
154
+ // trigger is this account's alone; one for every user needs an elevated terminal.
155
+ const user = spec.account ? `<UserId>${xml(spec.account)}</UserId>` : "";
156
+ // Without a zone Task Scheduler reads it as local time; a UTC one would delay the first check by the offset.
157
+ const now = spec.now ?? new Date();
158
+ const start = new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 19);
159
+ const taskXML = `<?xml version="1.0" encoding="UTF-16"?>
160
+ <!-- Elements in the order Task Scheduler exports them. -->
161
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
162
+ <RegistrationInfo><Description>replica-bridge: ChatReplica phones use Codex on this computer. Made by "replica-bridge install".</Description></RegistrationInfo>
163
+ <Triggers>
164
+ <LogonTrigger><Enabled>true</Enabled>${user}</LogonTrigger>
165
+ <TimeTrigger><Repetition><Interval>PT1M</Interval><StopAtDurationEnd>false</StopAtDurationEnd></Repetition><StartBoundary>${start}</StartBoundary><Enabled>true</Enabled></TimeTrigger>
166
+ </Triggers>
167
+ <Principals><Principal id="Author">${user}<LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
168
+ <Settings>
169
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
170
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
171
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
172
+ <StartWhenAvailable>true</StartWhenAvailable>
173
+ <IdleSettings><StopOnIdleEnd>false</StopOnIdleEnd><RestartOnIdle>false</RestartOnIdle></IdleSettings>
174
+ <Enabled>true</Enabled>
175
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
176
+ <RestartOnFailure><Interval>PT1M</Interval><Count>3</Count></RestartOnFailure>
177
+ </Settings>
178
+ <Actions Context="Author"><Exec><Command>wscript.exe</Command><Arguments>${xml(`"${script}"`)}</Arguments></Exec></Actions>
179
+ </Task>
180
+ `;
142
181
  return {
143
- files: [{ path: script, content }],
182
+ files: [{ path: script, content }, { path: task, content: taskXML, encoding: "utf16le" }],
144
183
  install: [
145
184
  { command: "schtasks", args: ["/End", "/TN", spec.label], optional: true },
146
- { command: "schtasks", args: ["/Create", "/F", "/TN", spec.label, "/SC", "ONLOGON", "/RL", "LIMITED", "/TR", `wscript.exe "${script}"`] },
185
+ { command: "schtasks", args: ["/Create", "/F", "/TN", spec.label, "/XML", task] },
147
186
  { command: "schtasks", args: ["/Run", "/TN", spec.label] },
148
187
  ],
149
188
  uninstall: [
@@ -242,7 +281,8 @@ export function readServiceInfo() {
242
281
 
243
282
  function spec(program ) {
244
283
  const { uid, username } = os.userInfo();
245
- return { platform: process.platform, label: serviceLabel(), home: os.homedir(), bridgeHome: bridgeHome(), program, env: serviceEnv(), uid, user: username };
284
+ const account = process.env.USERDOMAIN ? `${process.env.USERDOMAIN}\\${username}` : username;
285
+ return { platform: process.platform, label: serviceLabel(), home: os.homedir(), bridgeHome: bridgeHome(), program, env: serviceEnv(), uid, user: username, account };
246
286
  }
247
287
 
248
288
  /** Installs (or replaces) the background service and starts it. `startArgs` are the options `start` keeps. */
@@ -254,8 +294,9 @@ export function installService(startArgs , say )
254
294
  fs.mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 });
255
295
  for (const file of plan.files) {
256
296
  fs.mkdirSync(path.dirname(file.path), { recursive: true });
257
- fs.writeFileSync(file.path, file.content);
297
+ fs.writeFileSync(file.path, file.encoding === "utf16le" ? Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(file.content, "utf16le")]) : file.content);
258
298
  }
299
+ fs.rmSync(pausedPath(), { force: true });
259
300
  run(plan.install);
260
301
  const info = { platform: process.platform, label: serviceLabel(), version: PACKAGE_VERSION, program, installedAt: new Date().toISOString() };
261
302
  fs.writeFileSync(infoPath(), JSON.stringify(info, null, 2) + "\n");
@@ -306,6 +347,20 @@ export function runningPid() {
306
347
  try { process.kill(pid, 0); return pid; } catch (error) { return (error ).code === "EPERM" ? pid : null; }
307
348
  }
308
349
 
350
+ /**
351
+ * Another bridge took this computer over at the relay. The Windows task's every-minute check would start this one
352
+ * again, and the two would take turns kicking each other off; this marks the service stopped until the next install
353
+ * or the next boot, the way launchd and systemd leave a service that exited 0.
354
+ */
355
+ export function pauseService() {
356
+ fs.mkdirSync(bridgeHome(), { recursive: true, mode: 0o700 });
357
+ fs.writeFileSync(pausedPath(), new Date().toISOString() + "\n");
358
+ }
359
+
360
+ export function servicePaused() {
361
+ try { return !writtenBeforeBoot(fs.statSync(pausedPath()).mtimeMs); } catch { return false; }
362
+ }
363
+
309
364
  /** The service manager stops its own process; on Windows the loop's bridge is left, so it goes by pid. */
310
365
  function stopRunning() {
311
366
  const pid = runningPid();
@@ -18,7 +18,7 @@ import { parseArgs, format } from "node:util";
18
18
  import { relayRequest } from "../http.js";
19
19
  import { spawnCommandSync } from "./command.js";
20
20
  import { defaultRelayURL, enroll, ensureEnrolled, ensureRelay } from "./enroll.js";
21
- import { PACKAGE_VERSION, clearPid, installService, logPath, readServiceInfo, runningPid, servicePlan, uninstallService, writePid } from "./install.js";
21
+ import { PACKAGE_VERSION, clearPid, installService, logPath, pauseService, readServiceInfo, runningPid, servicePaused, servicePlan, uninstallService, writePid } from "./install.js";
22
22
  import { pair, publicRelayURL } from "./pair.js";
23
23
  import { BridgeService } from "./service.js";
24
24
  import { loadState, saveState } from "./state.js";
@@ -73,7 +73,12 @@ function startArgs() {
73
73
 
74
74
  function startBridge() {
75
75
  // Exit 0: the background services restart the bridge only when it fails, so the newer bridge keeps the computer.
76
- const service = new BridgeService({ codexPath: values.codex, loginShell: !values["no-login-shell"], streamTTLSeconds: Number(values["stream-ttl"]), onReplaced: () => process.exit(0) });
76
+ // The Windows task also checks every minute, so there the service is marked paused first.
77
+ const onReplaced = () => {
78
+ if (process.env.REPLICA_BRIDGE_SERVICE === "1" && process.platform === "win32") pauseService();
79
+ process.exit(0);
80
+ };
81
+ const service = new BridgeService({ codexPath: values.codex, loginShell: !values["no-login-shell"], streamTTLSeconds: Number(values["stream-ttl"]), onReplaced });
77
82
  service.start();
78
83
  return service;
79
84
  }
@@ -210,6 +215,10 @@ async function main() {
210
215
  case "start": {
211
216
  await ensureEnrolled(values.relay, console.log, values.name);
212
217
  const asService = process.env.REPLICA_BRIDGE_SERVICE === "1";
218
+ if (asService && servicePaused()) {
219
+ console.log(`another bridge took this computer over; the background service stays stopped until "npx replica-bridge install" or the next restart`);
220
+ return;
221
+ }
213
222
  if (runningPid()) throw new Error(`a bridge already runs for this computer (pid ${runningPid()})${asService ? "" : '; "npx replica-bridge uninstall" stops the background one'}`);
214
223
  const bridge = startBridge();
215
224
  if (asService) { writePid(); process.on("exit", clearPid); }
@@ -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, RELAY_REPLY_LIMIT, StreamBuffer, b64url, fromB64url, generateKeyPair, hostSessionKeys,
13
+ CloseCode, FrameCipher, ChunkAssembler, MAX_FRAME_BYTES, REASSEMBLED_MAX_BYTES, RELAY_REPLY_LIMIT, StreamBuffer, encodeLine, b64url, fromB64url, generateKeyPair, hostSessionKeys,
14
14
  } from "../protocol.js";
15
15
 
16
16
  import { relaySocketURL } from "../http.js";
@@ -37,6 +37,8 @@ import { annotateWriterError } from "./writer.js";
37
37
 
38
38
 
39
39
 
40
+
41
+
40
42
 
41
43
 
42
44
 
@@ -50,6 +52,12 @@ import { annotateWriterError } from "./writer.js";
50
52
 
51
53
 
52
54
 
55
+
56
+
57
+
58
+
59
+
60
+
53
61
 
54
62
 
55
63
 
@@ -94,7 +102,18 @@ export class BridgeService {
94
102
  const state = loadState();
95
103
  const ws = new WebSocket(relaySocketURL(state.relay, "/v1/host"), { headers: { authorization: `Bearer ${state.hostToken}` }, maxPayload: MAX_FRAME_BYTES + 4096, perMessageDeflate: false });
96
104
  this.relay = ws;
97
- ws.on("open", () => { this.retry = 0; this.log(`connected to relay ${state.relay} as ${state.hostId}`); });
105
+ // A laptop that sleeps or changes networks keeps a socket the relay dropped long ago, and nothing here would
106
+ // notice until the bridge sent something: the computer stayed offline for the phones. A missed pong ends it.
107
+ let answered = true;
108
+ const ping = setInterval(() => {
109
+ if (!answered) { this.log("relay did not answer a ping; reconnecting"); return ws.terminate(); }
110
+ answered = false;
111
+ // Still connecting counts as a missed pong too, so a handshake that never finishes ends at the next tick.
112
+ if (ws.readyState === WebSocket.OPEN) ws.ping();
113
+ }, (this.options.relayPingSeconds ?? 30) * 1000);
114
+ ws.on("pong", () => { answered = true; });
115
+ ws.on("ping", () => { answered = true; });
116
+ ws.on("open", () => { answered = true; this.retry = 0; this.log(`connected to relay ${state.relay} as ${state.hostId}`); });
98
117
  ws.on("message", (data ) => {
99
118
  let envelope ;
100
119
  try { envelope = JSON.parse(data.toString()); } catch { return; }
@@ -110,6 +129,7 @@ export class BridgeService {
110
129
  });
111
130
  ws.on("error", (error) => this.log(`relay connection error: ${error.message}`));
112
131
  ws.on("close", (code) => {
132
+ clearInterval(ping);
113
133
  if (ws !== this.relay) return; // a socket this bridge already replaced itself
114
134
  for (const conn of this.conns.values()) this.detach(conn);
115
135
  this.conns.clear();
@@ -125,6 +145,14 @@ export class BridgeService {
125
145
  });
126
146
  }
127
147
 
148
+ /**
149
+ * Waits while the relay socket has more than a few chunks queued, so a long message goes out at the network's pace
150
+ * instead of tens of MiB piling up in memory ahead of everything else on this computer's socket.
151
+ */
152
+ async drained() {
153
+ while (this.relay?.readyState === WebSocket.OPEN && this.relay.bufferedAmount > RELAY_SEND_QUEUE_BYTES) await new Promise((resolve) => setTimeout(resolve, 10));
154
+ }
155
+
128
156
  sendEnvelope(envelope ) {
129
157
  if (this.relay?.readyState === WebSocket.OPEN) this.relay.send(JSON.stringify(envelope));
130
158
  }
@@ -191,12 +219,20 @@ export class BridgeService {
191
219
  const message = conn.cipher.openFrame(frame);
192
220
  if (message.t === "resume") {
193
221
  if (conn.stream) throw new Error("second resume");
194
- await this.resume(conn, message.stream, message.ack);
222
+ await this.resume(conn, message.stream, message.ack, message.seg === 1);
195
223
  } else if (message.t === "msg") {
196
224
  const stream = conn.stream;
197
225
  if (!stream) throw new Error("message before resume");
198
226
  if (!stream.in.accept(message.seq)) return;
199
- if (stream.appServer.readyState === WebSocket.OPEN) stream.appServer.send(message.line);
227
+ if (stream.chunks.busy) throw new Error("message in the middle of a chunked one");
228
+ this.sendToAppServer(stream, message.line);
229
+ this.scheduleAck(stream);
230
+ } else if (message.t === "chunk") {
231
+ const stream = conn.stream;
232
+ if (!stream) throw new Error("message before resume");
233
+ if (!stream.in.accept(message.seq)) return;
234
+ const line = stream.chunks.take(message);
235
+ if (line !== null) this.sendToAppServer(stream, line);
200
236
  this.scheduleAck(stream);
201
237
  } else if (message.t === "ack") {
202
238
  conn.stream?.out.ack(message.seq);
@@ -209,7 +245,7 @@ export class BridgeService {
209
245
 
210
246
  // MARK: - Streams
211
247
 
212
- async resume(conn , streamId , peerAck ) {
248
+ async resume(conn , streamId , peerAck , segmented ) {
213
249
  let stream = streamId ? this.streams.get(streamId) : undefined;
214
250
  const resumed = !!stream && stream.deviceId === conn.deviceId;
215
251
  if (!resumed) {
@@ -219,31 +255,40 @@ export class BridgeService {
219
255
  if (s.conn && s.conn !== conn) this.closeConn(s.conn, "resumed elsewhere");
220
256
  if (s.expiry) { clearTimeout(s.expiry); s.expiry = null; }
221
257
  s.conn = conn;
258
+ s.segmented = segmented;
222
259
  conn.stream = s;
223
- this.sendInner(conn, { t: "ready", stream: s.id, resumed, ack: s.in.received });
260
+ this.sendInner(conn, { t: "ready", stream: s.id, resumed, ack: s.in.received, seg: 1 });
224
261
  // A new stream's sequence numbers start over, whatever the phone acknowledged on its old one.
225
262
  const after = resumed ? peerAck : 0;
226
263
  s.out.ack(after);
227
- for (const item of s.out.pending(after)) this.sendInner(conn, { t: "msg", seq: item.seq, line: item.line });
264
+ for (const message of s.out.pending(after)) this.sendInner(conn, message);
228
265
  this.log(`${resumed ? "resumed" : "opened"} stream ${s.id} for ${conn.deviceId}`);
229
266
  }
230
267
 
231
268
  async openStream(deviceId ) {
232
- const appServer = await this.connectAppServer();
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() };
269
+ const { ws: appServer, frameBytes } = await this.connectAppServer();
270
+ const stream = {
271
+ id: crypto.randomBytes(12).toString("base64url"), deviceId, appServer, out: new StreamBuffer(STREAM_BUFFER_BYTES), in: new StreamBuffer(STREAM_BUFFER_BYTES),
272
+ conn: null, expiry: null, ackTimer: null, relayed: Promise.resolve(), segmented: false, chunks: new ChunkAssembler(), appServerFrameBytes: frameBytes,
273
+ };
234
274
  this.streams.set(stream.id, stream);
235
275
  appServer.on("message", (data ) => {
236
276
  const text = data.toString();
237
- const fitted = fitLine(text);
277
+ const segmented = stream.segmented;
278
+ const fitted = fitLine(text, segmented ? REASSEMBLED_MAX_BYTES : RELAY_REPLY_LIMIT);
238
279
  if (fitted === null) return this.log(`stream ${stream.id}: dropped a ${text.length}-byte app-server notification too large for the relay`);
239
280
  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
281
  const annotated = annotateWriterError(fitted, this.options.findWriter);
241
282
  stream.relayed = stream.relayed.then(async () => {
242
283
  const line = await annotated;
243
- if (!this.streams.has(stream.id)) return;
244
- let item;
245
- try { item = stream.out.push(line); } catch { return this.endStream(stream, "the phone stayed away too long and the buffer filled up"); }
246
- if (stream.conn) this.sendInner(stream.conn, { t: "msg", seq: item.seq, line: item.line });
284
+ const parts = segmented ? await encodeLine(line) : [line];
285
+ for (const part of parts) {
286
+ if (!this.streams.has(stream.id)) return;
287
+ let message;
288
+ try { message = stream.out.push(part); } catch { return this.endStream(stream, "the phone stayed away too long and the buffer filled up"); }
289
+ if (stream.conn) this.sendInner(stream.conn, message);
290
+ if (parts.length > 1) await this.drained();
291
+ }
247
292
  });
248
293
  });
249
294
  appServer.on("close", () => this.endStream(stream, "codex app-server closed the connection"));
@@ -282,11 +327,22 @@ export class BridgeService {
282
327
 
283
328
  // MARK: - codex app-server
284
329
 
330
+ /** Sends a line to the app-server, as WebSocket fragments when it is longer than the app-server takes in one frame. */
331
+ sendToAppServer(stream , line ) {
332
+ const ws = stream.appServer;
333
+ if (ws.readyState !== WebSocket.OPEN) return;
334
+ const bytes = Buffer.from(line, "utf8");
335
+ if (bytes.length <= stream.appServerFrameBytes) return ws.send(line);
336
+ for (let at = 0; at < bytes.length; at += stream.appServerFrameBytes) {
337
+ ws.send(bytes.subarray(at, at + stream.appServerFrameBytes), { binary: false, fin: at + stream.appServerFrameBytes >= bytes.length });
338
+ }
339
+ }
340
+
285
341
  /**
286
342
  * One `codex app-server proxy` per stream, with WebSocket over its stdin and stdout. The proxy only relays bytes to
287
343
  * the app-server's control socket, wherever Codex keeps it; it exits at once when no app-server is running.
288
344
  */
289
- openProxy() {
345
+ openProxy() {
290
346
  return new Promise((resolve, reject) => {
291
347
  const child = spawnCommand(this.options.codexPath ?? "codex", ["app-server", "proxy"], { stdio: ["pipe", "pipe", "pipe"] });
292
348
  let stderr = "";
@@ -297,13 +353,18 @@ export class BridgeService {
297
353
  // Read app-server messages well past what one relay frame holds, so fitLine answers a big reply with an error
298
354
  // rather than the oversized message closing the app-server connection.
299
355
  const ws = new WebSocket("ws://localhost/", { createConnection: () => pipe , perMessageDeflate: false, maxPayload: APP_SERVER_MAX_MESSAGE_BYTES });
356
+ let frameBytes = APP_SERVER_FRAME_BYTES;
357
+ ws.once("upgrade", (res) => {
358
+ const advertised = Number(res.headers["x-codex-websocket-max-unfragmented-message-bytes"]);
359
+ if (Number.isInteger(advertised) && advertised >= 64 * 1024) frameBytes = advertised;
360
+ });
300
361
  const fail = (why ) => { ws.removeAllListeners("open"); killCommand(child); reject(new Error(why)); };
301
362
  child.once("error", (error) => fail(`could not run codex: ${error.message}`));
302
363
  child.once("exit", (code) => { if (ws.readyState !== WebSocket.OPEN) fail(`codex app-server proxy exited (${code})${stderr ? `: ${stderr.trim()}` : ""}`); });
303
364
  ws.once("error", (error) => { if (ws.readyState !== WebSocket.OPEN) fail(error.message); });
304
365
  ws.once("open", () => {
305
366
  ws.on("close", () => killCommand(child));
306
- resolve(ws);
367
+ resolve({ ws, frameBytes });
307
368
  });
308
369
  });
309
370
  }
@@ -318,7 +379,7 @@ export class BridgeService {
318
379
  });
319
380
  }
320
381
 
321
- async connectAppServer() {
382
+ async connectAppServer() {
322
383
  try {
323
384
  return await this.openProxy();
324
385
  } catch (error) {
@@ -349,16 +410,24 @@ export class BridgeService {
349
410
  /** Largest app-server message the bridge reads before refusing it. */
350
411
  const APP_SERVER_MAX_MESSAGE_BYTES = 256 * 1024 * 1024;
351
412
 
413
+ /** What the bridge keeps per direction for a phone that is away: room for a REASSEMBLED_MAX_BYTES line deflate could not shrink. */
414
+ const STREAM_BUFFER_BYTES = 128 * 1024 * 1024;
415
+ /** Chunks queued on the relay socket before the next one waits. */
416
+ const RELAY_SEND_QUEUE_BYTES = 1024 * 1024;
417
+ /** What codex-cli 0.163.0 sends in x-codex-websocket-max-unfragmented-message-bytes, for an app-server that sends none. */
418
+ const APP_SERVER_FRAME_BYTES = 16 * 1024 * 1024;
419
+
352
420
  /**
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.
421
+ * An app-server line as it can cross the relay: unchanged when it fits `limit` (RELAY_REPLY_LIMIT, or
422
+ * REASSEMBLED_MAX_BYTES for a phone that takes chunks), a JSON-RPC error with the same id when it is a reply that does
423
+ * not (`error.data.replicaTooLarge: {bytes, limit}`, PROTOCOL.md), and null for a notification that does not, which
424
+ * has no one to answer.
356
425
  */
357
- export function fitLine(line ) {
426
+ export function fitLine(line , limit = RELAY_REPLY_LIMIT) {
358
427
  const bytes = Buffer.byteLength(line, "utf8");
359
- if (bytes <= RELAY_REPLY_LIMIT) return line;
428
+ if (bytes <= limit) return line;
360
429
  let id ;
361
430
  try { id = (JSON.parse(line) ).id; } catch { return null; }
362
431
  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 } } } });
432
+ return JSON.stringify({ id, error: { code: -32010, message: `the reply is ${bytes} bytes, more than the relay carries (${limit})`, data: { replicaTooLarge: { bytes, limit } } } });
364
433
  }
package/src/protocol.js CHANGED
@@ -2,6 +2,8 @@
2
2
  // Everything here is plain Node: X25519, HKDF-SHA256, ChaCha20-Poly1305 and HMAC-SHA256 match CryptoKit's
3
3
  // Curve25519.KeyAgreement, HKDF<SHA256>, ChaChaPoly and HMAC<SHA256>, so the Swift side can follow the same steps.
4
4
  import crypto from "node:crypto";
5
+ import zlib from "node:zlib";
6
+ import { promisify } from "node:util";
5
7
 
6
8
  export const PROTOCOL = "chatreplica-relay-v1";
7
9
  export const LINK_SCHEME = "chatreplica:";
@@ -15,6 +17,17 @@ export const MAX_FRAME_BYTES = 16 * 1024 * 1024;
15
17
  * of a big file) come back as a JSON-RPC error instead of a frame the relay would refuse by closing the connection.
16
18
  */
17
19
  export const RELAY_REPLY_LIMIT = 11 * 1024 * 1024;
20
+ /**
21
+ * Long messages, the way Codex remote-control splits them (openai/codex app-server-transport remote_control/segment.rs):
22
+ * when both sides say `seg` (the phone in resume, the bridge in ready), a line over SEGMENT_BYTES goes as `chunk`
23
+ * messages of at most SEGMENT_BYTES each, at most SEGMENT_COUNT_MAX of them, up to REASSEMBLED_MAX_BYTES; the receiver
24
+ * checks every chunk's segment, count and size. Unlike remote-control the payload is raw deflate of the line (`z`),
25
+ * since the relay's frames are encrypted and nothing on the way can compress them. A chunk travels sealed and
26
+ * base64url encoded in about 180 KiB of frame, so pings still get through while a long message is on its way.
27
+ */
28
+ export const SEGMENT_BYTES = 100 * 1024;
29
+ export const SEGMENT_COUNT_MAX = 1024;
30
+ export const REASSEMBLED_MAX_BYTES = 100 * 1024 * 1024;
18
31
 
19
32
  export const b64url = (data ) => Buffer.from(data).toString("base64url");
20
33
  export const fromB64url = (text ) => Buffer.from(text, "base64url");
@@ -208,12 +221,78 @@ export class FrameCipher {
208
221
 
209
222
  /** What is inside a data frame. */
210
223
 
211
-
212
-
213
-
224
+
225
+
226
+
227
+
214
228
 
215
229
 
216
230
 
231
+
232
+ /** One segment of a long line: `data` is base64 of bytes [segment * chunk size, …) of the payload, `size` its length. */
233
+
234
+
235
+ /** What a StreamBuffer keeps until the peer acknowledges it. */
236
+
237
+
238
+ /** The payload cut into chunks: SEGMENT_BYTES each, or larger only to stay within SEGMENT_COUNT_MAX. */
239
+ export function chunkParts(payload , z , segmentBytes = SEGMENT_BYTES) {
240
+ const size = payload.length;
241
+ const chunkSize = Math.max(segmentBytes, Math.ceil(size / SEGMENT_COUNT_MAX));
242
+ const segments = Math.max(1, Math.ceil(size / chunkSize));
243
+ return Array.from({ length: segments }, (_, segment) => ({
244
+ segment, segments, size, ...(z ? { z: 1 } : {}), data: payload.subarray(segment * chunkSize, (segment + 1) * chunkSize).toString("base64"),
245
+ }));
246
+ }
247
+
248
+ const deflateRaw = promisify(zlib.deflateRaw);
249
+
250
+ /** A line as a `seg` peer takes it: as it is up to SEGMENT_BYTES, otherwise deflated and in chunks. */
251
+ export async function encodeLine(line ) {
252
+ const bytes = Buffer.from(line, "utf8");
253
+ if (bytes.length <= SEGMENT_BYTES) return [line];
254
+ return chunkParts(await deflateRaw(bytes), true);
255
+ }
256
+
257
+ /** encodeLine without yielding, for the test phone. */
258
+ export function encodeLineSync(line ) {
259
+ const bytes = Buffer.from(line, "utf8");
260
+ if (bytes.length <= SEGMENT_BYTES) return [line];
261
+ return chunkParts(zlib.deflateRawSync(bytes), true);
262
+ }
263
+
264
+ /**
265
+ * Puts one stream's chunks back together. Chunks arrive in seq order, so a line's segments are consecutive; anything
266
+ * else (a skipped segment, a changed count or size, more bytes than announced, a `msg` in the middle) is a protocol
267
+ * error, since the peer is authenticated and only a bug would send it.
268
+ */
269
+ export class ChunkAssembler {
270
+ parts = [];
271
+ received = 0;
272
+ first = null;
273
+
274
+ get busy() { return this.first !== null; }
275
+
276
+ take(message ) {
277
+ const { segment, segments, size } = message;
278
+ if (!Number.isInteger(segments) || segments < 1 || segments > SEGMENT_COUNT_MAX || !Number.isInteger(size) || size < 1 || size > REASSEMBLED_MAX_BYTES) {
279
+ throw new Error("chunk count or size out of range");
280
+ }
281
+ const first = this.first ?? message;
282
+ if (segment !== this.parts.length || first.segments !== segments || first.size !== size || first.z !== message.z) throw new Error(`chunk ${segment} out of order`);
283
+ const data = Buffer.from(message.data, "base64");
284
+ this.received += data.length;
285
+ if (data.length === 0 || this.received > size) throw new Error("chunk carries more than its size");
286
+ this.first = first;
287
+ this.parts.push(data);
288
+ if (segment < segments - 1) return null;
289
+ if (this.received !== size) throw new Error("chunks carry less than their size");
290
+ const payload = Buffer.concat(this.parts);
291
+ this.parts = []; this.received = 0; this.first = null;
292
+ return (message.z ? zlib.inflateRawSync(payload, { maxOutputLength: REASSEMBLED_MAX_BYTES }) : payload).toString("utf8");
293
+ }
294
+ }
295
+
217
296
  /** Relay ↔ host multiplexing: one host WebSocket carries every phone connection. */
218
297
 
219
298
 
@@ -236,26 +315,28 @@ export const CloseCode = {
236
315
  export class StreamBuffer {
237
316
  nextSeq = 1;
238
317
  received = 0;
239
- unacked = [];
318
+ unacked = [];
240
319
  bytes = 0;
241
320
 
242
321
  maxBytes ;
243
322
  constructor(maxBytes = 32 * 1024 * 1024) { this.maxBytes = maxBytes; }
244
323
 
245
- push(line ) {
246
- const item = { seq: this.nextSeq++, line, size: Buffer.byteLength(line) };
324
+ push(part ) {
325
+ const seq = this.nextSeq++;
326
+ const message = typeof part === "string" ? { t: "msg", seq, line: part } : { t: "chunk", seq, ...part };
327
+ const item = { message, size: Buffer.byteLength(message.t === "msg" ? message.line : message.data) };
247
328
  this.unacked.push(item);
248
329
  this.bytes += item.size;
249
330
  if (this.bytes > this.maxBytes) throw new Error("unacknowledged buffer full");
250
- return item;
331
+ return message;
251
332
  }
252
333
 
253
334
  ack(seq ) {
254
- while (this.unacked.length && this.unacked[0].seq <= seq) this.bytes -= this.unacked.shift() .size;
335
+ while (this.unacked.length && this.unacked[0].message.seq <= seq) this.bytes -= this.unacked.shift() .size;
255
336
  }
256
337
 
257
- pending(after ) {
258
- return this.unacked.filter((item) => item.seq > after);
338
+ pending(after ) {
339
+ return this.unacked.filter((item) => item.message.seq > after).map((item) => item.message);
259
340
  }
260
341
 
261
342
  /** True when the line is new; duplicates from a resend are dropped. Gaps cannot happen on one ordered socket. */