@sleep2agi/commhub-server 0.9.0-preview.59 → 0.9.0-preview.60

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": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.59",
3
+ "version": "0.9.0-preview.60",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/db.ts CHANGED
@@ -580,6 +580,16 @@ db.exec(`
580
580
  CREATE INDEX IF NOT EXISTS idx_nrr_node_status ON node_rules_requests(node_id, status);
581
581
  CREATE INDEX IF NOT EXISTS idx_nrr_network ON node_rules_requests(network_id);
582
582
  `);
583
+ // Content retention (node-request-retention.ts): file bytes in content /
584
+ // result_content are purged after the first terminal read (+grace) or a TTL;
585
+ // these two columns record when, so the row itself survives as audit metadata.
586
+ for (const col of ["first_read_at INTEGER", "content_purged_at INTEGER"]) {
587
+ try { db.exec(`ALTER TABLE node_rules_requests ADD COLUMN ${col}`); } catch {}
588
+ }
589
+ db.exec(`
590
+ CREATE INDEX IF NOT EXISTS idx_nrr_created ON node_rules_requests(created_at);
591
+ CREATE INDEX IF NOT EXISTS idx_nrr_first_read ON node_rules_requests(first_read_at);
592
+ `);
583
593
 
584
594
  // ── V3: networks table ──
585
595
  db.exec(`
@@ -0,0 +1,281 @@
1
+ // node_rules_requests content retention — privacy purge of node file bytes.
2
+ //
3
+ // Real MCP in-process transport, same harness as rules-file-transport.test.ts.
4
+ // Covers:
5
+ // 1. read result is handed out on the first terminal read, still handed out to
6
+ // a second reader inside the grace window (the desktop app's
7
+ // request_in_flight → follow path), then purged: status + metadata stay,
8
+ // `content_purged: true` replaces content.
9
+ // 2. a caller outside the row's network can neither read nor stamp/purge it.
10
+ // 3. TTL sweep: >24h content cleared (stale pending flipped to timeout first),
11
+ // >30d rows deleted, fresh unread rows untouched.
12
+ // 4. write payload (`content` column) is cleared; skill_read name is kept.
13
+ //
14
+ // 跑法:cd server && COMMHUB_DB=/tmp/nrr.db bun test src/node-request-retention.test.ts
15
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
16
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
17
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
18
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
19
+ import { db } from "./db.js";
20
+ import { registerTools } from "./tools.js";
21
+ import { CONTENT_GRACE_MS, CONTENT_TTL_MS, ROW_TTL_MS, sweepNodeRequestContent } from "./node-request-retention.js";
22
+ import { sweepRetention } from "./retention.js";
23
+
24
+ const NET = "net_nrr";
25
+ const OTHER_NET = "net_nrr_other";
26
+ const USER = "u_nrr_owner";
27
+ const NODE_ID = "node_nrr_claude";
28
+ const NODE_ALIAS = "nrr-claude";
29
+ const NODE_TOKEN = "tok_nrr_node";
30
+
31
+ function cleanup() {
32
+ for (const net of [NET, OTHER_NET]) {
33
+ try { db.run("DELETE FROM node_rules_requests WHERE network_id = ?1", [net]); } catch {}
34
+ try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [net]); } catch {}
35
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [net]); } catch {}
36
+ try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [net]); } catch {}
37
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [net]); } catch {}
38
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [net]); } catch {}
39
+ }
40
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
41
+ }
42
+
43
+ function seedWorld() {
44
+ db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [USER, USER]);
45
+ for (const net of [NET, OTHER_NET]) {
46
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [net, net, USER]);
47
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER, net]);
48
+ }
49
+ db.run(
50
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
51
+ [NODE_ID, NODE_ALIAS, NODE_ALIAS, NET],
52
+ );
53
+ db.run(
54
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
55
+ [NODE_TOKEN, USER, NET, `node:${NODE_ALIAS}`, "hash_nrr_node"],
56
+ );
57
+ }
58
+
59
+ type Identity = { net: string; alias: string; isNetworkToken: boolean; tokenId: string };
60
+ const asUser: Identity = { net: NET, alias: USER, isNetworkToken: false, tokenId: "tok_nrr_user" };
61
+ // Same human, scoped to the OTHER network: passes canWrite there, but the row
62
+ // lives in NET — SEC-1 must answer request_not_found and touch nothing.
63
+ const asUserOtherScope: Identity = { net: OTHER_NET, alias: USER, isNetworkToken: false, tokenId: "tok_nrr_user_other" };
64
+ const asNode: Identity = { net: NET, alias: NODE_ALIAS, isNetworkToken: true, tokenId: NODE_TOKEN };
65
+ // Same user, same network, a different login token: #1999 makes project-file
66
+ // results readable only by the token that asked.
67
+ const asUserOtherLogin: Identity = { net: NET, alias: USER, isNetworkToken: false, tokenId: "tok_nrr_user_other_login" };
68
+
69
+ async function connect(id: Identity) {
70
+ const server = new McpServer({ name: "nrr-test", version: "1" });
71
+ registerTools(server, undefined, id.net, USER, id.alias, id.isNetworkToken, id.tokenId);
72
+ const client = new Client({ name: "nrr-client", version: "1" });
73
+ const [ct, st] = InMemoryTransport.createLinkedPair();
74
+ await server.connect(st);
75
+ await client.connect(ct);
76
+ const call = async (name: string, args: Record<string, unknown>) => {
77
+ const r: any = await client.callTool({ name, arguments: args });
78
+ const first = r.content?.[0];
79
+ if (!first || first.type !== "text") throw new Error(`no text result from ${name}`);
80
+ return JSON.parse(first.text);
81
+ };
82
+ const close = async () => { await client.close(); await server.close(); };
83
+ return { call, close };
84
+ }
85
+
86
+ type Row = { status: string; content: string | null; result_content: string | null; file_name: string | null; first_read_at: number | null; content_purged_at: number | null; op: string };
87
+ const rowOf = (id: string) => db.get<Row>(
88
+ "SELECT status, content, result_content, file_name, first_read_at, content_purged_at, op FROM node_rules_requests WHERE request_id = ?1", id,
89
+ );
90
+ const ageFirstRead = (id: string, ms: number) => db.run("UPDATE node_rules_requests SET first_read_at = first_read_at - ?1 WHERE request_id = ?2", [ms, id]);
91
+ const ageCreated = (id: string, ms: number) => db.run("UPDATE node_rules_requests SET created_at = created_at - ?1 WHERE request_id = ?2", [ms, id]);
92
+
93
+ async function readRoundTrip(u: Awaited<ReturnType<typeof connect>>, n: Awaited<ReturnType<typeof connect>>, body: string) {
94
+ const enq = await u.call("read_node_rules_file", { node_id: NODE_ID });
95
+ expect(enq.ok).toBe(true);
96
+ await n.call("get_rules_file_request", {});
97
+ await n.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "CLAUDE.md", exists: true, content: body });
98
+ return enq.request_id as string;
99
+ }
100
+
101
+ beforeEach(() => { cleanup(); seedWorld(); });
102
+ afterAll(cleanup);
103
+
104
+ describe("node_rules_requests — read once, purge after grace", () => {
105
+ test("content returned on first terminal read and to a follower inside the grace window, then purged", async () => {
106
+ const u = await connect(asUser);
107
+ const n = await connect(asNode);
108
+ try {
109
+ const body = "# secret rules\n";
110
+ const rid = await readRoundTrip(u, n, body);
111
+ expect(rowOf(rid)!.first_read_at).toBeNull();
112
+
113
+ const first = await u.call("get_rules_file_result", { request_id: rid });
114
+ expect(first).toMatchObject({ ok: true, status: "done", content: body, file_name: "CLAUDE.md", exists: true });
115
+ expect(first.content_purged).toBeUndefined();
116
+ const stamped = rowOf(rid)!.first_read_at;
117
+ expect(stamped).not.toBeNull();
118
+
119
+ // Second reader (app follow path) inside the grace window still gets it,
120
+ // and the stamp is NOT pushed forward (a poller can't keep it alive).
121
+ sweepNodeRequestContent();
122
+ const follower = await u.call("get_rules_file_result", { request_id: rid });
123
+ expect(follower.content).toBe(body);
124
+ expect(rowOf(rid)!.first_read_at).toBe(stamped);
125
+
126
+ ageFirstRead(rid, CONTENT_GRACE_MS + 1);
127
+ // Any enqueue runs the opportunistic sweep.
128
+ await u.call("list_node_skills", { node_id: NODE_ID });
129
+ const row = rowOf(rid)!;
130
+ expect(row.result_content).toBeNull();
131
+ expect(row.content_purged_at).not.toBeNull();
132
+ expect(row.status).toBe("done");
133
+ expect(row.file_name).toBe("CLAUDE.md");
134
+
135
+ const after = await u.call("get_rules_file_result", { request_id: rid });
136
+ expect(after).toMatchObject({ ok: true, status: "done", file_name: "CLAUDE.md", exists: true, content_purged: true });
137
+ expect("content" in after).toBe(false);
138
+ } finally { await u.close(); await n.close(); }
139
+ }, 20_000);
140
+
141
+ test("non-terminal polls do not stamp; a never-read result keeps its content until TTL", async () => {
142
+ const u = await connect(asUser);
143
+ const n = await connect(asNode);
144
+ try {
145
+ const enq = await u.call("read_node_rules_file", { node_id: NODE_ID });
146
+ expect((await u.call("get_rules_file_result", { request_id: enq.request_id })).status).toBe("pending");
147
+ expect(rowOf(enq.request_id)!.first_read_at).toBeNull();
148
+ await n.call("get_rules_file_request", {});
149
+ await n.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "CLAUDE.md", exists: true, content: "x" });
150
+ sweepNodeRequestContent(Date.now() + CONTENT_GRACE_MS * 10);
151
+ expect(rowOf(enq.request_id)!.result_content).toBe("x");
152
+ } finally { await u.close(); await n.close(); }
153
+ }, 20_000);
154
+ });
155
+
156
+ describe("node_rules_requests — authorization", () => {
157
+ test("a caller outside the row's network gets request_not_found and cannot stamp or purge it", async () => {
158
+ const u = await connect(asUser);
159
+ const n = await connect(asNode);
160
+ const o = await connect(asUserOtherScope);
161
+ try {
162
+ const rid = await readRoundTrip(u, n, "# mine\n");
163
+ for (let i = 0; i < 3; i++) {
164
+ expect(await o.call("get_rules_file_result", { request_id: rid })).toMatchObject({ ok: false, error: "request_not_found" });
165
+ }
166
+ // Also pass the owner's network_id explicitly — scope is the token's, not the arg's.
167
+ expect((await o.call("get_rules_file_result", { request_id: rid, network_id: NET })).content).toBeUndefined();
168
+ expect(rowOf(rid)!.first_read_at).toBeNull();
169
+ sweepNodeRequestContent(Date.now() + CONTENT_GRACE_MS * 10);
170
+ expect(rowOf(rid)!.result_content).toBe("# mine\n");
171
+ // The rightful requester still gets it.
172
+ expect((await u.call("get_rules_file_result", { request_id: rid })).content).toBe("# mine\n");
173
+ } finally { await u.close(); await n.close(); await o.close(); }
174
+ }, 20_000);
175
+ });
176
+
177
+ describe("node_rules_requests — project files (#1999)", () => {
178
+ test("another login token can't read or stamp a file_read row; the requester's read starts the grace clock; path is kept", async () => {
179
+ const u = await connect(asUser);
180
+ const n = await connect(asNode);
181
+ const other = await connect(asUserOtherLogin);
182
+ try {
183
+ const enq = await u.call("read_node_file", { node_id: NODE_ID, path: "docs/plan.md" });
184
+ expect(enq.ok).toBe(true);
185
+ await n.call("get_rules_file_request", {});
186
+ const body = JSON.stringify({ path: "docs/plan.md", kind: "text", content: "TOP SECRET PLAN" });
187
+ await n.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: body });
188
+
189
+ expect(await other.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: false, error: "request_not_found" });
190
+ expect(rowOf(enq.request_id)!.first_read_at).toBeNull();
191
+
192
+ expect((await u.call("get_rules_file_result", { request_id: enq.request_id })).content).toBe(body);
193
+ ageFirstRead(enq.request_id, CONTENT_GRACE_MS + 1);
194
+ sweepNodeRequestContent();
195
+ const row = rowOf(enq.request_id)!;
196
+ expect(row.result_content).toBeNull();
197
+ expect(row.content).toBe("docs/plan.md");
198
+ expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "done", content_purged: true });
199
+ } finally { await u.close(); await n.close(); await other.close(); }
200
+ }, 20_000);
201
+ });
202
+
203
+ describe("node_rules_requests — write payload", () => {
204
+ test("write body in `content` is cleared after the grace window; skill_read name is kept", async () => {
205
+ const u = await connect(asUser);
206
+ const n = await connect(asNode);
207
+ try {
208
+ const body = "# new rules with a secret\n";
209
+ const w = await u.call("write_node_rules_file", { node_id: NODE_ID, content: body });
210
+ await n.call("get_rules_file_request", {});
211
+ await n.call("ack_rules_file_request", { request_id: w.request_id, status: "done", file_name: "CLAUDE.md", exists: true });
212
+ expect(rowOf(w.request_id)!.content).toBe(body);
213
+
214
+ const s = await u.call("read_node_skill", { node_id: NODE_ID, name: "my-skill" });
215
+ await n.call("get_rules_file_request", {});
216
+ await n.call("ack_rules_file_request", { request_id: s.request_id, status: "done", content: JSON.stringify({ name: "my-skill", content: "SKILL BODY" }) });
217
+
218
+ const wr = await u.call("get_rules_file_result", { request_id: w.request_id });
219
+ expect(wr).toMatchObject({ status: "done", op: "write" });
220
+ expect(wr.content).toBeUndefined();
221
+ expect((await u.call("get_rules_file_result", { request_id: s.request_id })).content).toContain("SKILL BODY");
222
+
223
+ ageFirstRead(w.request_id, CONTENT_GRACE_MS + 1);
224
+ ageFirstRead(s.request_id, CONTENT_GRACE_MS + 1);
225
+ sweepNodeRequestContent();
226
+
227
+ const wRow = rowOf(w.request_id)!;
228
+ expect(wRow.content).toBeNull();
229
+ expect(wRow.content_purged_at).not.toBeNull();
230
+ expect(wRow.status).toBe("done");
231
+ const sRow = rowOf(s.request_id)!;
232
+ expect(sRow.result_content).toBeNull();
233
+ expect(sRow.content).toBe("my-skill");
234
+ expect(await u.call("get_rules_file_result", { request_id: w.request_id })).toMatchObject({ status: "done", op: "write", content_purged: true });
235
+ } finally { await u.close(); await n.close(); }
236
+ }, 20_000);
237
+ });
238
+
239
+ describe("node_rules_requests — TTL sweep", () => {
240
+ test(">24h content cleared (stale pending write expired first, so no node can pull an emptied write); >30d rows deleted; fresh rows kept", async () => {
241
+ const u = await connect(asUser);
242
+ const n = await connect(asNode);
243
+ try {
244
+ // Create everything first, age afterwards: every enqueue runs the sweep.
245
+ const oldRead = await readRoundTrip(u, n, "# old, never fetched\n");
246
+ const ancient = await readRoundTrip(u, n, "# ancient\n");
247
+ const fresh = await readRoundTrip(u, n, "# fresh, never fetched\n");
248
+ // A write the node never pulled (offline for a day). Enqueued last so
249
+ // single-flight doesn't refuse it.
250
+ const w = await u.call("write_node_rules_file", { node_id: NODE_ID, content: "# stale write body\n" });
251
+ expect(w.ok).toBe(true);
252
+ ageCreated(oldRead, CONTENT_TTL_MS + 60_000);
253
+ ageCreated(ancient, ROW_TTL_MS + 60_000);
254
+ ageCreated(w.request_id, CONTENT_TTL_MS + 60_000);
255
+
256
+ const r = sweepRetention();
257
+ expect(r.nodeRequests.deleted).toBe(1);
258
+ expect(r.nodeRequests.timedOut).toBe(1);
259
+ expect(r.nodeRequests.purged).toBe(2);
260
+
261
+ expect(rowOf(ancient)).toBeNull();
262
+ const o = rowOf(oldRead)!;
263
+ expect(o.result_content).toBeNull();
264
+ expect(o.status).toBe("done");
265
+ expect(o.file_name).toBe("CLAUDE.md");
266
+ expect(rowOf(fresh)!.result_content).toBe("# fresh, never fetched\n");
267
+
268
+ const wr = rowOf(w.request_id)!;
269
+ expect(wr.status).toBe("timeout");
270
+ expect(wr.content).toBeNull();
271
+ // The node coming back must not receive the emptied write.
272
+ expect(await n.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
273
+
274
+ expect(await u.call("get_rules_file_result", { request_id: oldRead })).toMatchObject({ status: "done", content_purged: true });
275
+
276
+ // Idempotent.
277
+ const again = sweepNodeRequestContent();
278
+ expect(again).toEqual({ timedOut: 0, purged: 0, deleted: 0 });
279
+ } finally { await u.close(); await n.close(); }
280
+ }, 20_000);
281
+ });
@@ -0,0 +1,104 @@
1
+ // node_rules_requests content retention (privacy).
2
+ //
3
+ // The rules-file / skills (and project-folder) queue carries **node file
4
+ // contents** through the hub: `result_content` holds what the node sent back
5
+ // (CLAUDE.md / AGENTS.md, SKILL.md, directory listings, file bodies) and
6
+ // `content` holds write payloads (the full new rules file). Before this module
7
+ // nothing ever cleared or deleted those rows — every file anyone ever opened in
8
+ // the desktop app stayed in the hub DB forever.
9
+ //
10
+ // Policy (metadata — status, op, file_name, file_exists, error, timestamps,
11
+ // requester — is kept for audit; only file bytes are dropped):
12
+ //
13
+ // 1. Read-once + grace: the first time an authorized requester reads a
14
+ // TERMINAL result (done/failed/timeout) the row is stamped `first_read_at`.
15
+ // CONTENT_GRACE_MS later the content is purged. Not immediate, because the
16
+ // desktop app has a real second-reader path: when enqueue is rejected with
17
+ // `request_in_flight` the app FOLLOWS the existing request id
18
+ // (requestIdToFollow → waitForRulesFileResult, NodeRulesSection /
19
+ // NodeSkillsSection / NodeFilesSection). Two windows (or a remount) polling
20
+ // the same request reach terminal within one poll interval (≤ 3 s) of each
21
+ // other; an immediate purge would hand the second one `done` with no
22
+ // content and NodeRulesSection would load an EMPTY editor (`content ?? ''`)
23
+ // that a Save would then write back to the node. 60 s is 20× the slowest
24
+ // poll interval.
25
+ // 2. TTL: anything older than CONTENT_TTL_MS gets its content purged whether
26
+ // or not anyone ever read it (e.g. the app was closed mid-request). A
27
+ // still-pending/in_progress row that old is first flipped to `timeout`, so
28
+ // a node that comes back after a day can never pull a write whose payload
29
+ // was just nulled (it would otherwise receive `content: ""` and write an
30
+ // empty rules file).
31
+ // 3. Rows older than ROW_TTL_MS are deleted outright.
32
+ //
33
+ // Every statement is bounded (LIMIT via `request_id IN (SELECT … LIMIT n)` —
34
+ // SQLite's UPDATE/DELETE … LIMIT needs a compile flag we don't control) and
35
+ // uses an index (idx_nrr_created / idx_nrr_first_read). It runs
36
+ // opportunistically on enqueue and on terminal reads, from a one-shot timer
37
+ // armed at the first terminal read, and from the hourly retention sweeper
38
+ // (retention.ts) — so the first sweep after deploy also clears every old row
39
+ // that predates this module.
40
+
41
+ import { db } from "./db.js";
42
+
43
+ export const CONTENT_GRACE_MS = 60_000;
44
+ export const CONTENT_TTL_MS = 24 * 60 * 60 * 1000;
45
+ export const ROW_TTL_MS = 30 * 24 * 60 * 60 * 1000;
46
+ export const SWEEP_BATCH = 500;
47
+
48
+ export type NodeRequestSweepResult = { timedOut: number; purged: number; deleted: number };
49
+
50
+ // `content` is only a file body for op=write. For skill_read / file_read it is
51
+ // the skill name / relative path the requester asked for — audit metadata, kept.
52
+ const PURGE_SET =
53
+ "result_content = NULL, content = CASE WHEN op = 'write' THEN NULL ELSE content END, content_purged_at = ?1";
54
+
55
+ function purgeWhere(where: string, params: (string | number)[], now: number): number {
56
+ const r = db.run(
57
+ `UPDATE node_rules_requests SET ${PURGE_SET} WHERE request_id IN (SELECT request_id FROM node_rules_requests WHERE content_purged_at IS NULL AND ${where} LIMIT ${SWEEP_BATCH})`,
58
+ [now, ...params],
59
+ );
60
+ return r.changes ?? 0;
61
+ }
62
+
63
+ export function sweepNodeRequestContent(now: number = Date.now()): NodeRequestSweepResult {
64
+ const out: NodeRequestSweepResult = { timedOut: 0, purged: 0, deleted: 0 };
65
+ try {
66
+ // Delete first so rows past the row TTL aren't pointlessly purged, then
67
+ // expire stale non-terminal rows, then purge.
68
+ out.deleted = db.run(
69
+ `DELETE FROM node_rules_requests WHERE request_id IN (SELECT request_id FROM node_rules_requests WHERE created_at < ?1 LIMIT ${SWEEP_BATCH})`,
70
+ [now - ROW_TTL_MS],
71
+ ).changes ?? 0;
72
+ const ttlCutoff = now - CONTENT_TTL_MS;
73
+ out.timedOut = db.run(
74
+ `UPDATE node_rules_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id IN (SELECT request_id FROM node_rules_requests WHERE created_at < ?3 AND status IN ('pending', 'in_progress') LIMIT ${SWEEP_BATCH})`,
75
+ [now, "expired by retention sweep (node never answered)", ttlCutoff],
76
+ ).changes ?? 0;
77
+ // ?1 = now (PURGE_SET), ?2… = where params.
78
+ out.purged += purgeWhere("first_read_at IS NOT NULL AND first_read_at <= ?2 AND status IN ('done', 'failed', 'timeout')", [now - CONTENT_GRACE_MS], now);
79
+ out.purged += purgeWhere("created_at < ?2 AND status IN ('done', 'failed', 'timeout')", [ttlCutoff], now);
80
+ } catch (e: any) {
81
+ console.log(`[commhub retention] node_rules_requests sweep failed: ${e?.message ?? e}`);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /** Called by get_rules_file_result AFTER the requester passed every auth check
87
+ * and only for a terminal status. Stamps the first terminal read, arms a
88
+ * one-shot purge for after the grace window, and reports whether this row's
89
+ * content is already gone. */
90
+ export function noteTerminalResultRead(requestId: string, now: number = Date.now()): { purged: boolean } {
91
+ const r = db.run(
92
+ "UPDATE node_rules_requests SET first_read_at = ?1 WHERE request_id = ?2 AND first_read_at IS NULL",
93
+ [now, requestId],
94
+ );
95
+ if ((r.changes ?? 0) > 0) {
96
+ const t = setTimeout(() => { sweepNodeRequestContent(); }, CONTENT_GRACE_MS + 1_000);
97
+ (t as any).unref?.();
98
+ }
99
+ const row = db.get<{ content_purged_at: number | null }>(
100
+ "SELECT content_purged_at FROM node_rules_requests WHERE request_id = ?1",
101
+ requestId,
102
+ );
103
+ return { purged: row?.content_purged_at != null };
104
+ }
package/src/retention.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  // COMMHUB_DB=/tmp/test-retention.db bun test src/retention.test.ts
21
21
 
22
22
  import { db } from "./db.js";
23
+ import { sweepNodeRequestContent, type NodeRequestSweepResult } from "./node-request-retention.js";
23
24
 
24
25
  export type RetentionConfig = {
25
26
  // High-frequency telemetry; default = 7 days.
@@ -52,6 +53,9 @@ export type SweepResult = {
52
53
  tasks: number;
53
54
  audit_log: number;
54
55
  };
56
+ // node_rules_requests: file bytes purged by age / after read, rows deleted
57
+ // after 30 days. Fixed policy (privacy, not disk) — no env opt-out.
58
+ nodeRequests: NodeRequestSweepResult;
55
59
  vacuum: {
56
60
  walCheckpointPagesMoved: number | null;
57
61
  incrementalFreedPages: number | null;
@@ -178,6 +182,8 @@ export function sweepRetention(cfg: RetentionConfig = readRetentionConfig()): Sw
178
182
  cfg.auditLogDays,
179
183
  );
180
184
 
185
+ const nodeRequests = sweepNodeRequestContent();
186
+
181
187
  // VACUUM:
182
188
  // - wal_checkpoint(TRUNCATE): trims the WAL file. Works on every
183
189
  // SQLite DB regardless of auto_vacuum setting. Useful after
@@ -230,6 +236,7 @@ export function sweepRetention(cfg: RetentionConfig = readRetentionConfig()): Sw
230
236
  tasks,
231
237
  audit_log: auditLog,
232
238
  },
239
+ nodeRequests,
233
240
  vacuum: { walCheckpointPagesMoved, incrementalFreedPages, errored },
234
241
  };
235
242
  }
@@ -249,11 +256,13 @@ export function startRetentionSweeper(
249
256
  r.deletes.inbox +
250
257
  r.deletes.tasks +
251
258
  r.deletes.audit_log;
252
- if (totalDeletes > 0 || r.vacuum.errored) {
259
+ const nr = r.nodeRequests;
260
+ if (totalDeletes > 0 || nr.purged > 0 || nr.deleted > 0 || nr.timedOut > 0 || r.vacuum.errored) {
253
261
  console.log(
254
262
  `[commhub retention] swept in ${r.durationMs}ms — ` +
255
263
  `telemetry=${r.deletes.agent_telemetry} events=${r.deletes.task_events} ` +
256
264
  `inbox=${r.deletes.inbox} tasks=${r.deletes.tasks} audit=${r.deletes.audit_log} ` +
265
+ `nodeReqPurged=${nr.purged} nodeReqDeleted=${nr.deleted} nodeReqTimedOut=${nr.timedOut} ` +
257
266
  `walPages=${r.vacuum.walCheckpointPagesMoved} freedPages=${r.vacuum.incrementalFreedPages}`,
258
267
  );
259
268
  }
package/src/tools.ts CHANGED
@@ -62,6 +62,7 @@ import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
62
62
  import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
63
63
  import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
64
64
  import { parseHubTimestamp } from "./hub-timestamp";
65
+ import { noteTerminalResultRead, sweepNodeRequestContent } from "./node-request-retention.js";
65
66
 
66
67
  function ts(): string {
67
68
  return new Date().toTimeString().slice(0, 8);
@@ -3286,6 +3287,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3286
3287
  );
3287
3288
  }
3288
3289
 
3290
+ // Opportunistic content retention (bounded, indexed) — node-request-retention.ts.
3291
+ sweepNodeRequestContent();
3289
3292
  const requestId = `rf_${uuidv4()}`;
3290
3293
  const networkId = node.network_id || "default";
3291
3294
  db.run(
@@ -3490,6 +3493,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3490
3493
  );
3491
3494
  row.error = `node did not answer within ${RULES_REQUEST_STALE_MS}ms (offline, or running an agent-node without app#225 support)`;
3492
3495
  }
3496
+ // Privacy: node file bytes are handed out for a short grace window after the
3497
+ // first terminal read, then purged (node-request-retention.ts). Only reached
3498
+ // after the SEC-1 scope check above — a foreign caller can't stamp/purge.
3499
+ const terminal = status === "done" || status === "failed" || status === "timeout";
3500
+ const purged = terminal ? noteTerminalResultRead(requestId).purged : false;
3493
3501
  return {
3494
3502
  content: [{
3495
3503
  type: "text" as const,
@@ -3501,7 +3509,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3501
3509
  status,
3502
3510
  file_name: row.file_name ?? null,
3503
3511
  exists: row.file_exists === null || row.file_exists === undefined ? null : row.file_exists === 1,
3504
- ...(status === "done" && row.op !== "write" ? { content: row.result_content ?? "" } : {}),
3512
+ ...(status === "done" && row.op !== "write" && !purged ? { content: row.result_content ?? "" } : {}),
3513
+ ...(purged ? { content_purged: true } : {}),
3505
3514
  error: row.error ?? null,
3506
3515
  age_ms: ageMs,
3507
3516
  }),