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 +2 -2
- package/src/bridge/install.js +61 -6
- package/src/bridge/main.js +11 -2
- package/src/bridge/service.js +92 -23
- package/src/protocol.js +91 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "replica-bridge",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
14
|
+
"ws": "8.22.0"
|
|
15
15
|
},
|
|
16
16
|
"license": "UNLICENSED"
|
|
17
17
|
}
|
package/src/bridge/install.js
CHANGED
|
@@ -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
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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, "/
|
|
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
|
-
|
|
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();
|
package/src/bridge/main.js
CHANGED
|
@@ -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
|
-
|
|
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); }
|
package/src/bridge/service.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
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 = {
|
|
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
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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,
|
|
354
|
-
*
|
|
355
|
-
* notification that does not, which
|
|
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 <=
|
|
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 (${
|
|
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(
|
|
246
|
-
const
|
|
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
|
|
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. */
|