@sleep2agi/commhub-server 0.9.0-preview.0 → 0.9.0-preview.100
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/README.md +130 -10
- package/bin/commhub.ts +92 -11
- package/package.json +8 -7
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +487 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +520 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +190 -0
- package/src/agent-notice.ts +48 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +42 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +188 -0
- package/src/auth-kdf-migration.test.ts +378 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-tokens.test.ts +18 -9
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +554 -38
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +830 -0
- package/src/config-apply-validate.test.ts +352 -0
- package/src/config-apply-validate.ts +249 -0
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +275 -0
- package/src/create-node-validate.ts +288 -0
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.test.ts +354 -0
- package/src/create-node.ts +349 -0
- package/src/cross-tenant-injection.test.ts +110 -0
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +1268 -42
- package/src/delivered-without-push.test.ts +140 -0
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/department-heads-http.test.ts +329 -0
- package/src/department-heads.ts +226 -0
- package/src/departments-http.test.ts +185 -0
- package/src/departments.ts +260 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2128
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-context-cost-http.test.ts +119 -0
- package/src/mcp-projects-events-http.test.ts +162 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +206 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/model-auth-notify-http.test.ts +181 -0
- package/src/model-auth-notify.ts +116 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-dispatch-http.test.ts +279 -0
- package/src/node-health-guard.ts +105 -0
- package/src/node-health-passthrough.test.ts +115 -0
- package/src/node-health-store.ts +72 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-identity-conflict-http.test.ts +269 -0
- package/src/node-identity-conflict.ts +142 -0
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +242 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +429 -10
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-due-reminders-http.test.ts +390 -0
- package/src/requirement-due-reminders.ts +310 -0
- package/src/requirement-events-http.test.ts +257 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +144 -0
- package/src/requirement-notify.ts +165 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-comment-http.test.ts +165 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-errors-mcp-http.test.ts +164 -0
- package/src/requirements-errors.ts +143 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-last-event-http.test.ts +344 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-list-slim-http.test.ts +319 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +225 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +145 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +164 -0
- package/src/requirements-status-participants-http.test.ts +220 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1761 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +222 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.test.ts +450 -0
- package/src/retention.ts +281 -0
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-stuck-http.test.ts +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +641 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +5038 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/shared/reserved-env-drift.test.ts +43 -0
- package/src/shared/reserved-env.test.ts +35 -0
- package/src/shared/reserved-env.ts +31 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/stale-sweeper.test.ts +181 -0
- package/src/stale-sweeper.ts +114 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-alias-resolver-http.test.ts +163 -0
- package/src/status-alias-resolver.ts +19 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +122 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-access-http.test.ts +379 -0
- package/src/task-access.ts +413 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +313 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +211 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-stale-open-http.test.ts +290 -0
- package/src/task-stale-open.ts +103 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tool-audience-http.test.ts +195 -0
- package/src/tool-audience.ts +109 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +5426 -275
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +138 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// RFC-041 第一阶段(board #487):节点(Agent)自己的权限 —— 先记录、后执行。
|
|
2
|
+
//
|
|
3
|
+
// 节点的权限 = 主人的权限 ∩ 节点的模式(nodes.permission_mode:normal 正常 / readonly 只读 / restricted 受限)。
|
|
4
|
+
//
|
|
5
|
+
// • 模式是主人主动设的,**设了就立刻生效**(不看开关):只读的节点写什么都拒(mode_readonly);
|
|
6
|
+
// 受限的节点只碰派给它的任务、派活只给主人授权的 Agent(mode_restricted_not_assigned / agent_not_granted_to_owner)。
|
|
7
|
+
// • 正常模式下的收紧(超出主人可见范围 beyond_owner_visibility、主人没被授权的 Agent agent_not_granted_to_owner、
|
|
8
|
+
// 只有人能做的事 human_only)由 Hub 开关 COMMHUB_NODE_PERMISSIONS 决定:
|
|
9
|
+
// log(默认)—— 照常放行,只把「本来会被拒」按 (节点, 路由, 原因, 小时) 合并记进 node_permission_log;
|
|
10
|
+
// enforce —— 记录并拒绝(REST 403 / MCP 错误 node_permission_denied + reason + hint);
|
|
11
|
+
// off —— 不判也不记(显式模式仍然生效)。
|
|
12
|
+
//
|
|
13
|
+
// 判定的入口:REST(requirements.ts、/api/task、/api/broadcast、/api/nodes/:id 写)、MCP(tools.ts 按工具名包一层)、
|
|
14
|
+
// SSE(/events/:session、/events/network/:id)。只对节点令牌(ntok_)生效;用户令牌一条不变。
|
|
15
|
+
// 只增不改:nodes 多一列 permission_mode(默认 normal),新表 node_permission_log。回滚到上一版安全:旧代码不读它们。
|
|
16
|
+
|
|
17
|
+
import { db } from "./db.js";
|
|
18
|
+
import { canMessageAgent, type AgentRef } from "./agent-access.js";
|
|
19
|
+
|
|
20
|
+
export type NodePermissionMode = "normal" | "readonly" | "restricted";
|
|
21
|
+
export const NODE_PERMISSION_MODES: readonly NodePermissionMode[] = ["normal", "readonly", "restricted"];
|
|
22
|
+
export type NodePermissionsFlag = "log" | "off" | "enforce";
|
|
23
|
+
export type NodePermissionReason =
|
|
24
|
+
| "beyond_owner_visibility"
|
|
25
|
+
| "agent_not_granted_to_owner"
|
|
26
|
+
| "mode_readonly"
|
|
27
|
+
| "mode_restricted_not_assigned"
|
|
28
|
+
| "human_only"
|
|
29
|
+
| "owner_unknown";
|
|
30
|
+
|
|
31
|
+
/** 显式模式的原因:不看开关,永远拒。 */
|
|
32
|
+
const MODE_REASONS: ReadonlySet<NodePermissionReason> = new Set(["mode_readonly", "mode_restricted_not_assigned"]);
|
|
33
|
+
|
|
34
|
+
try { db.exec("ALTER TABLE nodes ADD COLUMN permission_mode TEXT NOT NULL DEFAULT 'normal'"); } catch (e: any) {
|
|
35
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
36
|
+
}
|
|
37
|
+
db.exec(`
|
|
38
|
+
CREATE TABLE IF NOT EXISTS node_permission_log (
|
|
39
|
+
network_id TEXT NOT NULL,
|
|
40
|
+
node_id TEXT NOT NULL,
|
|
41
|
+
route TEXT NOT NULL,
|
|
42
|
+
reason TEXT NOT NULL,
|
|
43
|
+
hour TEXT NOT NULL,
|
|
44
|
+
hits INTEGER NOT NULL DEFAULT 1,
|
|
45
|
+
sample TEXT,
|
|
46
|
+
PRIMARY KEY (network_id, node_id, route, reason, hour)
|
|
47
|
+
);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_node_permission_log_hour ON node_permission_log(network_id, hour);
|
|
49
|
+
`);
|
|
50
|
+
|
|
51
|
+
/** 开关。未设 / 写错 → log(安全的默认:只记不拦)。每次现读:测试和运维改环境变量后无需重启模块。 */
|
|
52
|
+
export function nodePermissionsFlag(): NodePermissionsFlag {
|
|
53
|
+
const raw = (process.env.COMMHUB_NODE_PERMISSIONS ?? "").trim().toLowerCase();
|
|
54
|
+
return raw === "off" || raw === "enforce" ? raw : "log";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type NodeIdentity = {
|
|
58
|
+
networkId: string;
|
|
59
|
+
/** 绑定的节点;老的未绑定令牌 = null(按「正常」判,记录时用 token:<token_id>)。 */
|
|
60
|
+
nodeId: string | null;
|
|
61
|
+
logKey: string;
|
|
62
|
+
aliases: string[];
|
|
63
|
+
ownerUserId: string | null;
|
|
64
|
+
mode: NodePermissionMode;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function normalizeMode(raw: unknown): NodePermissionMode {
|
|
68
|
+
return raw === "readonly" || raw === "restricted" ? raw : "normal";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 节点令牌 → 节点身份(一次带索引的查询)。不是节点令牌 / 查不到 → null。 */
|
|
72
|
+
export function nodeIdentity(tokenId: string | null | undefined, networkId: string | null | undefined): NodeIdentity | null {
|
|
73
|
+
if (!tokenId || !networkId) return null;
|
|
74
|
+
const row = db.get<{ user_id: string | null; bound_node_id: string | null; name: string | null; owner_user_id: string | null; permission_mode: string | null; alias: string | null }>(
|
|
75
|
+
`SELECT t.user_id, t.bound_node_id, t.name, n.owner_user_id, n.permission_mode, n.alias
|
|
76
|
+
FROM api_tokens t LEFT JOIN nodes n ON n.node_id = t.bound_node_id
|
|
77
|
+
WHERE t.token_id = ?1 AND t.network_id = ?2`,
|
|
78
|
+
tokenId, networkId,
|
|
79
|
+
);
|
|
80
|
+
if (!row) return null;
|
|
81
|
+
const aliases = new Set<string>();
|
|
82
|
+
if (row.alias) aliases.add(row.alias);
|
|
83
|
+
if (row.name?.startsWith("node:")) aliases.add(row.name.slice(5));
|
|
84
|
+
return {
|
|
85
|
+
networkId,
|
|
86
|
+
nodeId: row.bound_node_id,
|
|
87
|
+
logKey: row.bound_node_id ?? `token:${tokenId}`,
|
|
88
|
+
aliases: [...aliases],
|
|
89
|
+
ownerUserId: row.owner_user_id || row.user_id || null,
|
|
90
|
+
mode: normalizeMode(row.permission_mode),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type Verdict = { reason: NodePermissionReason; sample?: string } | null;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 判一次:verdict=null → 放行;否则按原因和开关决定拒不拒,并记一行(off 时正常模式的原因不记)。
|
|
98
|
+
* 返回 true = 拒绝。
|
|
99
|
+
*/
|
|
100
|
+
export function nodeDecide(id: NodeIdentity, route: string, verdict: Verdict): boolean {
|
|
101
|
+
if (!verdict) return false;
|
|
102
|
+
const explicit = MODE_REASONS.has(verdict.reason);
|
|
103
|
+
const flag = nodePermissionsFlag();
|
|
104
|
+
if (!explicit && flag === "off") return false;
|
|
105
|
+
recordNodePermission(id, route, verdict.reason, verdict.sample);
|
|
106
|
+
return explicit || flag === "enforce";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const HINTS: Record<NodePermissionReason, string> = {
|
|
110
|
+
beyond_owner_visibility: "this node acts with its owner's permissions, and its owner cannot see or edit this; ask the owner for access, or assign the task to this node",
|
|
111
|
+
agent_not_granted_to_owner: "this Agent has not been granted to this node's owner; ask a network admin to grant it",
|
|
112
|
+
mode_readonly: "this node is in read-only mode; the node's owner can switch it to normal in the app",
|
|
113
|
+
mode_restricted_not_assigned: "this node is in restricted mode and only touches tasks assigned to it; assign the task to this node, or have the owner switch it to normal",
|
|
114
|
+
human_only: "only people can do this (members, projects, permissions, providers, secrets, other nodes); use a user token",
|
|
115
|
+
owner_unknown: "this node's owner is unknown; recreate the node token from the owner's account",
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export function nodePermissionDeniedBody(reason: NodePermissionReason, route: string): { ok: false; error: "node_permission_denied"; reason: NodePermissionReason; route: string; hint: string } {
|
|
119
|
+
return { ok: false, error: "node_permission_denied", reason, route, hint: HINTS[reason] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── 记录:按 (网络, 节点, 路由, 原因, 小时) 合并;有上限、会清理 ──
|
|
123
|
+
export const LOG_RETENTION_DAYS = 30;
|
|
124
|
+
export const LOG_MAX_ROWS = 20_000;
|
|
125
|
+
const PRUNE_EVERY_MS = 60 * 60 * 1000;
|
|
126
|
+
let lastPrune = 0;
|
|
127
|
+
let logFull = false;
|
|
128
|
+
|
|
129
|
+
function hourOf(now = new Date()): string {
|
|
130
|
+
return now.toISOString().slice(0, 13); // 2026-10-03T08
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 清掉保留期外的行,并重新判断是否到了上限(到上限后只给已有的行加次数,不再新开行)。 */
|
|
134
|
+
export function pruneNodePermissionLog(now = new Date()): void {
|
|
135
|
+
const cutoff = hourOf(new Date(now.getTime() - LOG_RETENTION_DAYS * 86_400_000));
|
|
136
|
+
db.run("DELETE FROM node_permission_log WHERE hour < ?1", [cutoff]);
|
|
137
|
+
const n = Number(db.get<{ n: number | string }>("SELECT COUNT(*) AS n FROM node_permission_log")?.n ?? 0);
|
|
138
|
+
logFull = n >= LOG_MAX_ROWS;
|
|
139
|
+
lastPrune = now.getTime();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function recordNodePermission(id: NodeIdentity, route: string, reason: NodePermissionReason, sample?: string): void {
|
|
143
|
+
try {
|
|
144
|
+
const now = new Date();
|
|
145
|
+
if (now.getTime() - lastPrune > PRUNE_EVERY_MS) pruneNodePermissionLog(now);
|
|
146
|
+
const hour = hourOf(now);
|
|
147
|
+
const s = sample ? sample.slice(0, 200) : null;
|
|
148
|
+
const updated = db.run(
|
|
149
|
+
"UPDATE node_permission_log SET hits = hits + 1, sample = COALESCE(?6, sample) WHERE network_id = ?1 AND node_id = ?2 AND route = ?3 AND reason = ?4 AND hour = ?5",
|
|
150
|
+
[id.networkId, id.logKey, route.slice(0, 120), reason, hour, s],
|
|
151
|
+
);
|
|
152
|
+
if (Number(updated?.changes ?? 0) > 0 || logFull) return;
|
|
153
|
+
db.run(
|
|
154
|
+
`INSERT INTO node_permission_log (network_id, node_id, route, reason, hour, hits, sample) VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6)
|
|
155
|
+
ON CONFLICT(network_id, node_id, route, reason, hour) DO UPDATE SET hits = node_permission_log.hits + 1`,
|
|
156
|
+
[id.networkId, id.logKey, route.slice(0, 120), reason, hour, s],
|
|
157
|
+
);
|
|
158
|
+
} catch (e) {
|
|
159
|
+
// 记录失败绝不影响请求本身。
|
|
160
|
+
console.error(`[node-permissions] log failed: ${(e as Error).message}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Test-only:让下一次记录先清理一遍,并可改上限判断。 */
|
|
165
|
+
export function __resetNodePermissionLogStateForTest(): void { lastPrune = 0; logFull = false; }
|
|
166
|
+
|
|
167
|
+
// ── 规则 ──
|
|
168
|
+
|
|
169
|
+
/** 派活 / 发消息 / 重派给 target。发给自己永远可以。 */
|
|
170
|
+
export function dispatchVerdict(id: NodeIdentity, target: AgentRef): Verdict {
|
|
171
|
+
if ((target.alias && id.aliases.includes(target.alias)) || (target.nodeId && id.nodeId && target.nodeId === id.nodeId)) return null;
|
|
172
|
+
const sample = target.alias ?? target.nodeId ?? undefined;
|
|
173
|
+
if (id.mode === "readonly") return { reason: "mode_readonly", sample };
|
|
174
|
+
if (!id.ownerUserId) return id.mode === "restricted" ? { reason: "mode_restricted_not_assigned", sample } : { reason: "owner_unknown" };
|
|
175
|
+
if (!canMessageAgent(id.ownerUserId, id.networkId, target)) {
|
|
176
|
+
// 受限是主人设的 → 必拒(模式原因);正常下是开关管的收紧。
|
|
177
|
+
return { reason: id.mode === "restricted" ? "mode_restricted_not_assigned" : "agent_not_granted_to_owner", sample };
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 广播:只读 / 受限都不行(会发给没派给它、也未必授权的 Agent)。 */
|
|
183
|
+
export function broadcastVerdict(id: NodeIdentity): Verdict {
|
|
184
|
+
if (id.mode === "readonly") return { reason: "mode_readonly" };
|
|
185
|
+
if (id.mode === "restricted") return { reason: "mode_restricted_not_assigned" };
|
|
186
|
+
if (!id.ownerUserId) return { reason: "owner_unknown" };
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 一般的写(管节点、提交技能、探测模型…):只读 / 受限拒;正常放行(由各自的既有规则管)。 */
|
|
191
|
+
export function writeVerdict(id: NodeIdentity, sample?: string): Verdict {
|
|
192
|
+
if (id.mode === "readonly") return { reason: "mode_readonly", sample };
|
|
193
|
+
if (id.mode === "restricted") return { reason: "mode_restricted_not_assigned", sample };
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** 只有人能做的事。只读 / 受限下报模式原因(那是主人设的、必拒);正常下报 human_only。 */
|
|
198
|
+
export function humanOnlyVerdict(id: NodeIdentity, sample?: string): Verdict {
|
|
199
|
+
return writeVerdict(id, sample) ?? { reason: "human_only", sample };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 订阅别的会话 / 整个网络的推送流:受限不行;正常按主人能不能看到那个 Agent。 */
|
|
203
|
+
export function streamVerdict(id: NodeIdentity, target: AgentRef | "network"): Verdict {
|
|
204
|
+
if (target !== "network" && ((target.alias && id.aliases.includes(target.alias)) || (target.nodeId && target.nodeId === id.nodeId))) return null;
|
|
205
|
+
if (id.mode === "restricted") return { reason: "mode_restricted_not_assigned", sample: target === "network" ? "network" : target.alias ?? undefined };
|
|
206
|
+
if (!id.ownerUserId) return { reason: "owner_unknown" };
|
|
207
|
+
if (target !== "network" && !canMessageAgent(id.ownerUserId, id.networkId, target)) {
|
|
208
|
+
return { reason: "beyond_owner_visibility", sample: target.alias ?? undefined };
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── MCP:每个工具归一类(test 钉住「每个注册的工具都归了类」,新工具忘了归类会红) ──
|
|
214
|
+
export type ToolClass = "always" | "read" | "dispatch" | "broadcast" | "requirements" | "node_write" | "human_only";
|
|
215
|
+
export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
|
|
216
|
+
// 回复 / 上报 / 自己的收件箱 / 生命周期协议(节点自己的请求)—— 任何模式都能做
|
|
217
|
+
report_status: "always", report_completion: "always", get_inbox: "always", ack_inbox: "always",
|
|
218
|
+
mark_tasks_runtime_submitted: "always", mark_tasks_consumed: "always", send_reply: "always", send_peer_reply: "always", send_ack: "always",
|
|
219
|
+
get_config_update: "always", ack_config_update: "always", get_rules_file_request: "always", ack_rules_file_request: "always", get_rules_file_result: "always",
|
|
220
|
+
get_create_request: "always", ack_create_request: "always", get_stop_request: "always", ack_stop_request: "always",
|
|
221
|
+
get_start_request: "always", ack_start_request: "always", get_probe_request: "always", ack_probe_request: "always",
|
|
222
|
+
list_my_pending_create_requests: "always", list_my_pending_lifecycle_requests: "always", list_my_children: "always",
|
|
223
|
+
send_desktop_message: "always", // 发给人的私信,不是派活
|
|
224
|
+
// 读
|
|
225
|
+
get_all_status: "read", get_session_status: "read", get_task: "read", list_tasks: "read", get_completions: "read",
|
|
226
|
+
list_skills: "read", get_skill: "read", list_host_supervisors: "read", list_network_secrets: "read", list_providers: "read",
|
|
227
|
+
get_probe_results: "read", read_node_rules_file: "read", list_node_skills: "read", read_node_skill: "read", list_node_files: "read",
|
|
228
|
+
read_node_file: "read", tail_node_logs: "read",
|
|
229
|
+
// 派活
|
|
230
|
+
send_task: "dispatch", send_message: "dispatch", retry_task: "dispatch", reassign_task: "dispatch", cancel_task: "dispatch",
|
|
231
|
+
broadcast: "broadcast",
|
|
232
|
+
// 任务看板:在 requirements.ts 里按卡判(REST 与 MCP 同一处)
|
|
233
|
+
requirements_list: "requirements", requirements_people: "requirements", requirements_get: "requirements", requirements_create: "requirements",
|
|
234
|
+
requirements_update: "requirements", requirements_checklist_toggle: "requirements", requirements_upsert_by_external_ref: "requirements",
|
|
235
|
+
requirements_events: "requirements", requirements_comment: "requirements", projects_list: "requirements",
|
|
236
|
+
// 写节点 / 技能 / 探测:只读、受限不行;正常由 RFC-036 等既有规则管
|
|
237
|
+
update_node_config: "node_write", write_node_rules_file: "node_write", restart_node: "node_write", create_node: "node_write",
|
|
238
|
+
stop_node: "node_write", delete_node: "node_write", start_node: "node_write", submit_skill: "node_write", probe_provider_model: "node_write",
|
|
239
|
+
// 只有人能做
|
|
240
|
+
upsert_network_secret: "human_only", upsert_provider: "human_only", update_provider: "human_only",
|
|
241
|
+
projects_create: "human_only", projects_update: "human_only", review_skill: "human_only",
|
|
242
|
+
};
|
|
@@ -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,120 @@
|
|
|
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
|
+
// 4. Run logs (op = logs_tail) are stricter: read-once with NO grace — the
|
|
33
|
+
// first authorized terminal read purges them in the same call
|
|
34
|
+
// (purgeLogsResultNow), because the app never follows another request's
|
|
35
|
+
// logs result (it simply asks again) and a process log is the most
|
|
36
|
+
// sensitive thing on this queue. Unread terminal results are purged after
|
|
37
|
+
// LOGS_CONTENT_TTL_MS (the requester closed the page). The filter JSON in
|
|
38
|
+
// `content` (it can hold a user-typed grep) goes with it.
|
|
39
|
+
//
|
|
40
|
+
// Every statement is bounded (LIMIT via `request_id IN (SELECT … LIMIT n)` —
|
|
41
|
+
// SQLite's UPDATE/DELETE … LIMIT needs a compile flag we don't control) and
|
|
42
|
+
// uses an index (idx_nrr_created / idx_nrr_first_read). It runs
|
|
43
|
+
// opportunistically on enqueue and on terminal reads, from a one-shot timer
|
|
44
|
+
// armed at the first terminal read, and from the hourly retention sweeper
|
|
45
|
+
// (retention.ts) — so the first sweep after deploy also clears every old row
|
|
46
|
+
// that predates this module.
|
|
47
|
+
|
|
48
|
+
import { db } from "./db.js";
|
|
49
|
+
|
|
50
|
+
export const CONTENT_GRACE_MS = 60_000;
|
|
51
|
+
export const CONTENT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
52
|
+
export const ROW_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
53
|
+
export const SWEEP_BATCH = 500;
|
|
54
|
+
export const LOGS_CONTENT_TTL_MS = 5 * 60 * 1000;
|
|
55
|
+
|
|
56
|
+
export type NodeRequestSweepResult = { timedOut: number; purged: number; deleted: number };
|
|
57
|
+
|
|
58
|
+
// `content` is only a file body for op=write. For skill_read / file_read it is
|
|
59
|
+
// the skill name / relative path the requester asked for — audit metadata, kept.
|
|
60
|
+
// For logs_tail it is the filter JSON, which may carry a user-typed grep — purged.
|
|
61
|
+
const PURGE_SET =
|
|
62
|
+
"result_content = NULL, content = CASE WHEN op IN ('write', 'logs_tail') THEN NULL ELSE content END, content_purged_at = ?1";
|
|
63
|
+
|
|
64
|
+
function purgeWhere(where: string, params: (string | number)[], now: number): number {
|
|
65
|
+
const r = db.run(
|
|
66
|
+
`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})`,
|
|
67
|
+
[now, ...params],
|
|
68
|
+
);
|
|
69
|
+
return r.changes ?? 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function sweepNodeRequestContent(now: number = Date.now()): NodeRequestSweepResult {
|
|
73
|
+
const out: NodeRequestSweepResult = { timedOut: 0, purged: 0, deleted: 0 };
|
|
74
|
+
try {
|
|
75
|
+
// Delete first so rows past the row TTL aren't pointlessly purged, then
|
|
76
|
+
// expire stale non-terminal rows, then purge.
|
|
77
|
+
out.deleted = db.run(
|
|
78
|
+
`DELETE FROM node_rules_requests WHERE request_id IN (SELECT request_id FROM node_rules_requests WHERE created_at < ?1 LIMIT ${SWEEP_BATCH})`,
|
|
79
|
+
[now - ROW_TTL_MS],
|
|
80
|
+
).changes ?? 0;
|
|
81
|
+
const ttlCutoff = now - CONTENT_TTL_MS;
|
|
82
|
+
out.timedOut = db.run(
|
|
83
|
+
`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})`,
|
|
84
|
+
[now, "expired by retention sweep (node never answered)", ttlCutoff],
|
|
85
|
+
).changes ?? 0;
|
|
86
|
+
// ?1 = now (PURGE_SET), ?2… = where params.
|
|
87
|
+
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);
|
|
88
|
+
out.purged += purgeWhere("created_at < ?2 AND status IN ('done', 'failed', 'timeout')", [ttlCutoff], now);
|
|
89
|
+
out.purged += purgeWhere("op = 'logs_tail' AND created_at < ?2 AND status IN ('done', 'failed', 'timeout')", [now - LOGS_CONTENT_TTL_MS], now);
|
|
90
|
+
} catch (e: any) {
|
|
91
|
+
console.log(`[commhub retention] node_rules_requests sweep failed: ${e?.message ?? e}`);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Called by get_rules_file_result AFTER the requester passed every auth check
|
|
97
|
+
* and only for a terminal status. Stamps the first terminal read, arms a
|
|
98
|
+
* one-shot purge for after the grace window, and reports whether this row's
|
|
99
|
+
* content is already gone. */
|
|
100
|
+
export function noteTerminalResultRead(requestId: string, now: number = Date.now()): { purged: boolean } {
|
|
101
|
+
const r = db.run(
|
|
102
|
+
"UPDATE node_rules_requests SET first_read_at = ?1 WHERE request_id = ?2 AND first_read_at IS NULL",
|
|
103
|
+
[now, requestId],
|
|
104
|
+
);
|
|
105
|
+
if ((r.changes ?? 0) > 0) {
|
|
106
|
+
const t = setTimeout(() => { sweepNodeRequestContent(); }, CONTENT_GRACE_MS + 1_000);
|
|
107
|
+
(t as any).unref?.();
|
|
108
|
+
}
|
|
109
|
+
const row = db.get<{ content_purged_at: number | null }>(
|
|
110
|
+
"SELECT content_purged_at FROM node_rules_requests WHERE request_id = ?1",
|
|
111
|
+
requestId,
|
|
112
|
+
);
|
|
113
|
+
return { purged: row?.content_purged_at != null };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Run-log results are read-once: called by get_rules_file_result right after it
|
|
117
|
+
* handed a terminal logs_tail result to its (authorized) requester. */
|
|
118
|
+
export function purgeLogsResultNow(requestId: string, now: number = Date.now()): void {
|
|
119
|
+
db.run(`UPDATE node_rules_requests SET ${PURGE_SET} WHERE request_id = ?2 AND op = 'logs_tail' AND content_purged_at IS NULL`, [now, requestId]);
|
|
120
|
+
}
|