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 +1 -1
- package/src/bridge/board.js +145 -0
- package/src/bridge/install.js +6 -2
- package/src/bridge/pair.js +3 -0
- package/src/bridge/service.js +83 -9
- package/src/protocol.js +21 -1
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/bridge/install.js
CHANGED
|
@@ -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
|
-
|
|
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";
|
package/src/bridge/pair.js
CHANGED
|
@@ -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; }
|
package/src/bridge/service.js
CHANGED
|
@@ -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 <
|
|
406
|
-
try { return await
|
|
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. */
|