replica-bridge 0.3.12 → 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.12",
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": {
@@ -1,7 +1,9 @@
1
1
  // The optional 任务 board (PROTOCOL.md "看板"): reads AgensWork's agens-backend, which only listens on this computer, for
2
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.
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.
5
7
 
6
8
 
7
9
  /** agens-backend's default port, which its own AGENS_BACKEND_PORT changes. */
@@ -16,18 +18,59 @@ const CHANGES_PAGE = 100;
16
18
  /** Stops a catch-up on a change log that never ends; the backend keeps 30 days of it. */
17
19
  const CATCH_UP_PAGES = 1000;
18
20
 
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
+ /** 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_.:-]+";
21
24
 
22
- /** The path and query to read, or null when it is not on the allowlist. */
23
- export function allowedBoardPath(path ) {
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 ) {
24
38
  if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//") || path.includes("\\")) return null;
25
39
  let target ;
26
40
  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;
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;
28
48
  return target.pathname + target.search;
29
49
  }
30
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
+
31
74
  /** The port from AGENS_BACKEND_PORT, the backend's own setting, when it names a real one. */
32
75
  export function boardPortFrom(env = process.env) {
33
76
  const port = Number(env.AGENS_BACKEND_PORT);
@@ -57,15 +100,28 @@ export class BoardReader {
57
100
  }
58
101
 
59
102
  /** 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) });
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
+ });
62
108
  }
63
109
 
64
- async read(path ) {
110
+ read(path ) {
65
111
  const allowed = allowedBoardPath(path);
66
- if (!allowed) return { error: "not_allowed" };
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 ) {
67
123
  try {
68
- const response = await this.fetch(allowed);
124
+ const response = await this.fetch(pathAndQuery, post);
69
125
  const body = await response.text();
70
126
  if (Buffer.byteLength(body, "utf8") > BODY_MAX_BYTES) return { error: "too_large" };
71
127
  return { status: response.status, body };
@@ -269,6 +269,8 @@ export class BridgeService {
269
269
  } else if (message.t === "board/get") {
270
270
  // Not awaited: a slow backend should not hold up the frames behind this one.
271
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 }));
272
274
  } else if (message.t === "board/sub") {
273
275
  conn.boardUnsubscribe ??= this.board.subscribe((event) => this.sendInner(conn, event));
274
276
  } else if (message.t === "board/unsub") {
package/src/protocol.js CHANGED
@@ -239,13 +239,14 @@ export class FrameCipher {
239
239
 
240
240
 
241
241
 
242
+
242
243
 
243
244
 
244
245
 
245
246
 
246
247
 
247
- /** A read of the optional board: the backend's status and body, or why there is none (PROTOCOL.md "看板"). */
248
-
248
+ /** A read or write of the optional board: the backend's status and body, or why there is none (PROTOCOL.md "看板"). */
249
+
249
250
 
250
251
 
251
252