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 +1 -1
- package/src/bridge/board.js +68 -12
- package/src/bridge/service.js +2 -0
- package/src/protocol.js +3 -2
package/package.json
CHANGED
package/src/bridge/board.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
20
|
-
const
|
|
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
|
-
/**
|
|
23
|
-
|
|
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
|
-
|
|
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}`, {
|
|
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
|
-
|
|
110
|
+
read(path ) {
|
|
65
111
|
const allowed = allowedBoardPath(path);
|
|
66
|
-
|
|
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(
|
|
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 };
|
package/src/bridge/service.js
CHANGED
|
@@ -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
|
|