replica-bridge 0.3.10 → 0.3.13

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.13",
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,201 @@
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 and writes are short allowlists, and the only background traffic
4
+ // is one poll of /v1/changes while a phone is subscribed, because reading the lists counts as the user having seen them.
5
+ // The bridge stays thin: it checks the shape of a write and leaves what it means to the backend, whose status and body
6
+ // (REC_NOT_FOUND, REC_STATE_CONFLICT, …) go back to the phone unchanged.
7
+
8
+
9
+ /** agens-backend's default port, which its own AGENS_BACKEND_PORT changes. */
10
+ export const DEFAULT_BOARD_PORT = 4753;
11
+ /** agens-backend's own page polls every 10 s. */
12
+ const DEFAULT_POLL_SECONDS = 10;
13
+ /** Past this the backend is treated as gone, so a hung one cannot hold a phone waiting. */
14
+ const READ_TIMEOUT_MS = 5000;
15
+ /** Well under RELAY_REPLY_LIMIT, since the bridge sends board answers in one frame. */
16
+ const BODY_MAX_BYTES = 8 * 1024 * 1024;
17
+ const CHANGES_PAGE = 100;
18
+ /** Stops a catch-up on a change log that never ends; the backend keeps 30 days of it. */
19
+ const CATCH_UP_PAGES = 1000;
20
+
21
+ /** The backend's own cap on a request body (agens-backend src/http.mjs BODY_MAX). */
22
+ export const POST_BODY_MAX_BYTES = 16 * 1024;
23
+ const REC = "[A-Za-z0-9_.:-]+";
24
+
25
+ /**
26
+ * GETs (#367). /session also writes TASK.md in the task's working folder, which is what 开工 asks for; /share and /me
27
+ * only read. Not /assist, /mcp, /search, /changes (the bridge's own poll) or anything under /ui.
28
+ */
29
+ const READS = [/^\/v1\/status$/, /^\/v1\/today$/, /^\/v1\/recommendations$/, new RegExp(`^/v1/recommendations/${REC}$`),
30
+ new RegExp(`^/v1/recommendations/${REC}/(share|session)$`), /^\/v1\/me$/];
31
+
32
+ /** POSTs (#367): a task's follow-up actions, cancelling its preparation note, and muting a conversation. Nothing else. */
33
+ const WRITES = [new RegExp(`^/v1/recommendations/${REC}/actions$`), new RegExp(`^/v1/recommendations/${REC}/prep/cancel$`), /^\/v1\/me\/muted$/];
34
+ /** agens-backend src/recs.mjs ACTIONS. */
35
+ export const BOARD_ACTIONS = ["accept", "snooze", "delegate", "dismiss", "done", "reopen"] ;
36
+
37
+ function localTarget(path ) {
38
+ if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//") || path.includes("\\")) return null;
39
+ let target ;
40
+ try { target = new URL(path, "http://board.invalid"); } catch { return null; }
41
+ return target.origin === "http://board.invalid" ? target : null;
42
+ }
43
+
44
+ /** The path and query to read, or null when it is not on the allowlist. */
45
+ export function allowedBoardPath(path ) {
46
+ const target = localTarget(path);
47
+ if (!target || !READS.some((read) => read.test(target.pathname))) return null;
48
+ return target.pathname + target.search;
49
+ }
50
+
51
+ /**
52
+ * The path and body to POST, or why not. The path is one of WRITES with no query; the body is JSON text of an object
53
+ * within the backend's size cap (absent or empty means `{}`, for prep/cancel), and on /actions names a known action.
54
+ */
55
+ export function allowedBoardPost(path , body ) {
56
+ const target = localTarget(path);
57
+ if (!target || target.search || !WRITES.some((write) => write.test(target.pathname))) return { error: "not_allowed" };
58
+ if (body === undefined || body === "") body = "{}";
59
+ if (typeof body !== "string") return { error: "bad_body" };
60
+ if (Buffer.byteLength(body, "utf8") > POST_BODY_MAX_BYTES) return { error: "too_large" };
61
+ let value ;
62
+ try { value = JSON.parse(body); } catch { return { error: "bad_body" }; }
63
+ if (!value || typeof value !== "object" || Array.isArray(value)) return { error: "bad_body" };
64
+ if (target.pathname.endsWith("/actions")) {
65
+ const action = (value ).action;
66
+ if (typeof action !== "string" || !(BOARD_ACTIONS ).includes(action)) return { error: "not_allowed" };
67
+ }
68
+ const sent = JSON.stringify(value);
69
+ // Rewriting can lengthen it (1e5 becomes 100000), and the backend counts what it receives.
70
+ if (Buffer.byteLength(sent, "utf8") > POST_BODY_MAX_BYTES) return { error: "too_large" };
71
+ return { path: target.pathname, body: sent };
72
+ }
73
+
74
+ /** The port from AGENS_BACKEND_PORT, the backend's own setting, when it names a real one. */
75
+ export function boardPortFrom(env = process.env) {
76
+ const port = Number(env.AGENS_BACKEND_PORT);
77
+ return Number.isInteger(port) && port > 0 && port <= 65535 ? port : DEFAULT_BOARD_PORT;
78
+ }
79
+
80
+
81
+
82
+
83
+
84
+
85
+ export class BoardReader {
86
+ port ;
87
+ pollMs ;
88
+ listeners = new Set ();
89
+ timer = null;
90
+ polling = false;
91
+ /** Where the change log was read to; null until a poll has found its end. */
92
+ after = null;
93
+ up = null;
94
+ /** Bumped when polling stops, so a poll still on its way does not speak for the next round. */
95
+ round = 0;
96
+
97
+ constructor(options = {}) {
98
+ this.port = options.port ?? boardPortFrom();
99
+ this.pollMs = (options.pollSeconds ?? DEFAULT_POLL_SECONDS) * 1000;
100
+ }
101
+
102
+ /** The fetch adds the Host header the backend insists on (127.0.0.1:<port>) and no Origin, as a local caller. */
103
+ fetch(pathAndQuery , post ) {
104
+ return fetch(`http://127.0.0.1:${this.port}${pathAndQuery}`, {
105
+ redirect: "error", signal: AbortSignal.timeout(READ_TIMEOUT_MS),
106
+ ...(post === undefined ? {} : { method: "POST", headers: { "content-type": "application/json" }, body: post }),
107
+ });
108
+ }
109
+
110
+ read(path ) {
111
+ const allowed = allowedBoardPath(path);
112
+ return allowed ? this.answer(allowed) : Promise.resolve({ error: "not_allowed" });
113
+ }
114
+
115
+ /** Sent once and never retried: a write that timed out may still have happened, so the phone reads the task again. */
116
+ post(path , body ) {
117
+ const allowed = allowedBoardPost(path, body);
118
+ return "error" in allowed ? Promise.resolve(allowed) : this.answer(allowed.path, allowed.body);
119
+ }
120
+
121
+ /** The backend's status and body as they are, errors included. */
122
+ async answer(pathAndQuery , post ) {
123
+ try {
124
+ const response = await this.fetch(pathAndQuery, post);
125
+ const body = await response.text();
126
+ if (Buffer.byteLength(body, "utf8") > BODY_MAX_BYTES) return { error: "too_large" };
127
+ return { status: response.status, body };
128
+ } catch {
129
+ return { error: "no_board" };
130
+ }
131
+ }
132
+
133
+ /** Tells `listener` about the board until the returned function is called. Polls only while someone listens. */
134
+ subscribe(listener ) {
135
+ this.listeners.add(listener);
136
+ if (this.up !== null) listener({ t: "board/state", up: this.up });
137
+ if (!this.timer) {
138
+ this.timer = setInterval(() => void this.poll(), this.pollMs);
139
+ this.timer.unref();
140
+ void this.poll();
141
+ }
142
+ return () => {
143
+ this.listeners.delete(listener);
144
+ if (!this.listeners.size) this.stop();
145
+ };
146
+ }
147
+
148
+ stop() {
149
+ this.listeners.clear();
150
+ if (this.timer) clearInterval(this.timer);
151
+ this.timer = null;
152
+ this.after = null;
153
+ this.up = null;
154
+ this.polling = false;
155
+ this.round++;
156
+ }
157
+
158
+ emit(event ) {
159
+ for (const listener of this.listeners) listener(event);
160
+ }
161
+
162
+ async changes(after ) {
163
+ const response = await this.fetch(`/v1/changes?after=${after}&limit=${CHANGES_PAGE}`);
164
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
165
+ const page = await response.json() ;
166
+ if (!Array.isArray(page.items)) throw new Error("not a page of changes");
167
+ return { items: page.items, next: typeof page.next === "number" ? page.next : after };
168
+ }
169
+
170
+ async poll() {
171
+ if (this.polling) return;
172
+ this.polling = true;
173
+ const round = this.round;
174
+ let changed = false, up = true, after = this.after;
175
+ try {
176
+ // The first poll only finds the end of the log: what happened before someone was listening is not news.
177
+ if (after === null) {
178
+ after = 0;
179
+ for (let page = 0; page < CATCH_UP_PAGES; page++) {
180
+ const { items, next } = await this.changes(after);
181
+ after = next;
182
+ if (items.length < CHANGES_PAGE) break;
183
+ }
184
+ } else {
185
+ const { items, next } = await this.changes(after);
186
+ after = next;
187
+ changed = items.length > 0;
188
+ }
189
+ } catch {
190
+ up = false;
191
+ }
192
+ if (round !== this.round) return;
193
+ this.polling = false;
194
+ if (up) this.after = after;
195
+ if (up !== this.up) {
196
+ this.up = up;
197
+ this.emit({ t: "board/state", up });
198
+ }
199
+ if (changed) this.emit({ t: "board/changed" });
200
+ }
201
+ }
@@ -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,16 @@ 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/post") {
273
+ void this.board.post(message.path, message.body).then((answer) => this.sendInner(conn, { t: "board/res", id: message.id, ...answer }));
274
+ } else if (message.t === "board/sub") {
275
+ conn.boardUnsubscribe ??= this.board.subscribe((event) => this.sendInner(conn, event));
276
+ } else if (message.t === "board/unsub") {
277
+ conn.boardUnsubscribe?.();
278
+ conn.boardUnsubscribe = null;
243
279
  }
244
280
  }
245
281
 
@@ -268,6 +304,17 @@ export class BridgeService {
268
304
  this.log(`${resumed ? "resumed" : "opened"} stream ${s.id} for ${conn.deviceId}`);
269
305
  }
270
306
 
307
+ withheldFromPhone(text ) {
308
+ const forwards = this.options.forwardsAppServerRequest;
309
+ if (!forwards || text.length > MAX_REQUEST_CHARS_TO_INSPECT) return false;
310
+ try {
311
+ const { id, method } = JSON.parse(text);
312
+ return id !== undefined && typeof method === "string" && !forwards(method);
313
+ } catch {
314
+ return false;
315
+ }
316
+ }
317
+
271
318
  async openStream(deviceId ) {
272
319
  const { ws: appServer, frameBytes } = await this.connectAppServer();
273
320
  const stream = {
@@ -277,6 +324,7 @@ export class BridgeService {
277
324
  this.streams.set(stream.id, stream);
278
325
  appServer.on("message", (data ) => {
279
326
  const text = data.toString();
327
+ if (this.withheldFromPhone(text)) return;
280
328
  const segmented = stream.segmented;
281
329
  const fitted = fitLine(text, segmented ? REASSEMBLED_MAX_BYTES : RELAY_REPLY_LIMIT);
282
330
  if (fitted === null) return this.log(`stream ${stream.id}: dropped a ${text.length}-byte app-server notification too large for the relay`);
@@ -308,6 +356,8 @@ export class BridgeService {
308
356
  }
309
357
 
310
358
  detach(conn ) {
359
+ conn.boardUnsubscribe?.();
360
+ conn.boardUnsubscribe = null;
311
361
  const stream = conn.stream;
312
362
  conn.stream = null;
313
363
  if (!stream || stream.conn !== conn) return;
@@ -357,10 +407,7 @@ export class BridgeService {
357
407
  // rather than the oversized message closing the app-server connection.
358
408
  const ws = new WebSocket("ws://localhost/", { createConnection: () => pipe , perMessageDeflate: false, maxPayload: APP_SERVER_MAX_MESSAGE_BYTES });
359
409
  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
- });
410
+ ws.once("upgrade", (res) => { frameBytes = advertisedFrameBytes(res.headers); });
364
411
  const fail = (why ) => { ws.removeAllListeners("open"); killCommand(child); reject(new Error(why)); };
365
412
  child.once("error", (error) => fail(`could not run codex: ${error.message}`));
366
413
  child.once("exit", (code) => { if (ws.readyState !== WebSocket.OPEN) fail(`codex app-server proxy exited (${code})${stderr ? `: ${stderr.trim()}` : ""}`); });
@@ -382,7 +429,21 @@ export class BridgeService {
382
429
  });
383
430
  }
384
431
 
432
+ /** A direct connection to the host's own app-server. */
433
+ async openEndpoint(endpoint ) {
434
+ const { url, token } = await endpoint();
435
+ return new Promise((resolve, reject) => {
436
+ const ws = new WebSocket(url, { headers: { authorization: `Bearer ${token}` }, perMessageDeflate: false, maxPayload: APP_SERVER_MAX_MESSAGE_BYTES });
437
+ let frameBytes = APP_SERVER_FRAME_BYTES;
438
+ ws.once("upgrade", (res) => { frameBytes = advertisedFrameBytes(res.headers); });
439
+ ws.once("error", (error) => { if (ws.readyState !== WebSocket.OPEN) reject(error); });
440
+ ws.once("open", () => resolve({ ws, frameBytes }));
441
+ });
442
+ }
443
+
385
444
  async connectAppServer() {
445
+ const endpoint = this.options.appServerEndpoint;
446
+ if (endpoint) return this.awaitAppServer(() => this.openEndpoint(endpoint), HOST_APP_SERVER_ATTEMPTS);
386
447
  try {
387
448
  return await this.openProxy();
388
449
  } catch (error) {
@@ -401,15 +462,30 @@ export class BridgeService {
401
462
  if (!started && !(await this.run(codex, ["app-server", "daemon", "start"]).catch(() => false))) {
402
463
  throw new Error("codex app-server daemon start failed");
403
464
  }
465
+ return this.awaitAppServer(() => this.openProxy(), 40);
466
+ }
467
+
468
+ async awaitAppServer(open , attempts ) {
404
469
  let last = null;
405
- for (let attempt = 0; attempt < 40; attempt++) {
406
- try { return await this.openProxy(); } catch (error) { last = error ; }
470
+ for (let attempt = 0; attempt < attempts; attempt++) {
471
+ try { return await open(); } catch (error) { last = error ; }
407
472
  await new Promise((resolve) => setTimeout(resolve, 250));
408
473
  }
409
474
  throw new Error(`codex app-server did not come up: ${last?.message}`);
410
475
  }
411
476
  }
412
477
 
478
+ /** Larger than any request the app-server makes of a client; only replies and notifications run this long. */
479
+ const MAX_REQUEST_CHARS_TO_INSPECT = 1024 * 1024;
480
+
481
+ /** Longest message the app-server takes in one frame, from its handshake. */
482
+ function advertisedFrameBytes(headers ) {
483
+ const advertised = Number(headers["x-codex-websocket-max-unfragmented-message-bytes"]);
484
+ return Number.isInteger(advertised) && advertised >= 64 * 1024 ? advertised : APP_SERVER_FRAME_BYTES;
485
+ }
486
+
487
+ /** A minute: the host may still be starting, or restarting its app-server to apply new settings. */
488
+ const HOST_APP_SERVER_ATTEMPTS = 240;
413
489
  /** Largest app-server message the bridge reads before refusing it. */
414
490
  const APP_SERVER_MAX_MESSAGE_BYTES = 256 * 1024 * 1024;
415
491
 
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,17 @@ export class FrameCipher {
226
237
 
227
238
 
228
239
 
229
-
240
+
241
+
242
+
243
+
244
+
245
+
246
+
247
+
248
+ /** A read or write of the optional board: the backend's status and body, or why there is none (PROTOCOL.md "看板"). */
249
+
250
+
230
251
 
231
252
 
232
253
  /** One segment of a long line: `data` is base64 of bytes [segment * chunk size, …) of the payload, `size` its length. */