@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.131
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 +99 -11
- package/package.json +7 -7
- package/src/ack-create-request-transport.test.ts +182 -0
- package/src/ack-create-request.test.ts +50 -7
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/adoption-candidates-http.test.ts +137 -0
- package/src/adoption-candidates.ts +158 -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 +659 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +223 -0
- package/src/agent-notice.ts +48 -0
- package/src/agent-team-whoami-http.test.ts +98 -0
- package/src/agent-teams-http.test.ts +323 -0
- package/src/agent-teams.ts +244 -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-host-supervisors-runtime-readiness.test.ts +195 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth-weak-password-login.test.ts +176 -0
- package/src/auth.ts +598 -49
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/binding-generation-http.test.ts +121 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/codex-config-flags.ts +77 -0
- package/src/codex-fork-contract.ts +23 -0
- package/src/codex-fork-migration.test.ts +38 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +134 -1
- package/src/config-apply-validate.ts +111 -5
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-network-id.test.ts +79 -0
- package/src/create-node-tool-schema.test.ts +48 -0
- package/src/create-node-unicode-name.test.ts +120 -0
- package/src/create-node-validate.test.ts +193 -7
- package/src/create-node-validate.ts +227 -15
- package/src/create-node-workdir.test.ts +289 -0
- package/src/create-node.ts +77 -23
- package/src/daemon-provider-snapshots.test.ts +421 -0
- package/src/daemon-provider-snapshots.ts +327 -0
- package/src/daemon-token-owner-http.test.ts +236 -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 +933 -36
- 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 +330 -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-provider-config.test.ts +139 -0
- package/src/hub-provider-config.ts +105 -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-caller.ts +49 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2362
- package/src/lifecycle-guard.ts +1 -1
- package/src/mcp-context-cost-http.test.ts +123 -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 +432 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +210 -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 +276 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-alias-control.ts +12 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-caller-identity-http.test.ts +781 -0
- package/src/node-create-request-status-http.test.ts +105 -0
- package/src/node-daemon-bindings.test.ts +132 -0
- package/src/node-daemon-bindings.ts +130 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-codex-login.test.ts +120 -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 +101 -0
- package/src/node-id-alias.test.ts +190 -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 +53 -0
- package/src/node-lifecycle-read-http.test.ts +245 -0
- package/src/node-lifecycle-read.ts +121 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +285 -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-ownership.ts +51 -0
- package/src/node-token-privilege-http.test.ts +158 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/opencode-v2-catalog.test.ts +268 -0
- package/src/opencode-v2-catalog.ts +352 -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/project-dir-warn-clear-http.test.ts +132 -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 +22 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-handover-capabilities-http.test.ts +124 -0
- package/src/report-status-host-schema.test.ts +235 -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 +620 -0
- package/src/requirement-due-reminders.ts +416 -0
- package/src/requirement-events-http.test.ts +268 -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-abandoned-http.test.ts +236 -0
- package/src/requirements-comment-http.test.ts +167 -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 +349 -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 +259 -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 +167 -0
- package/src/requirements-status-participants-http.test.ts +222 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1806 -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 +130 -0
- package/src/rest-identity.ts +139 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +76 -0
- package/src/retention.ts +15 -3
- 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/runtime-readiness.ts +90 -0
- package/src/schedule-agent-mcp-http.test.ts +381 -0
- package/src/schedule-agent.ts +192 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-offline.test.ts +131 -0
- package/src/scheduled-offline.ts +66 -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 +766 -0
- package/src/scoped-secrets.ts +124 -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 +5257 -0
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/session-token-ownership-http.test.ts +122 -0
- package/src/shared/codex-inventory.ts +41 -0
- package/src/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +52 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/node-name.test.ts +62 -0
- package/src/shared/node-name.ts +161 -0
- package/src/shared/provider-application.ts +29 -0
- package/src/shared/provider-collection.ts +14 -0
- package/src/shared/provider-initialization.ts +8 -0
- package/src/shared/reserved-env-drift.test.ts +10 -0
- package/src/shared/scoped-secret-contract.ts +5 -0
- package/src/shared/task-content-limit-drift.test.ts +28 -0
- package/src/shared/task-content-limit.ts +25 -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/start-node.test.ts +264 -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 +509 -1
- package/src/task-access-http.test.ts +402 -0
- package/src/task-access.ts +420 -0
- package/src/task-auth-origin-http.test.ts +93 -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 +315 -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-late-replies.ts +54 -0
- package/src/task-late-reply-http.test.ts +152 -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 +140 -0
- package/src/task-orphan-http.test.ts +287 -0
- package/src/task-orphan.ts +119 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +236 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-rejection-log-http.test.ts +160 -0
- package/src/task-rejection-log.ts +49 -0
- package/src/task-sender-http.test.ts +143 -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 +220 -0
- package/src/tool-audience.ts +118 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +3940 -325
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.ts +103 -6
- package/src/user-avatar-http.test.ts +74 -0
- package/src/user-avatar-migration.test.ts +25 -0
- 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
|
@@ -0,0 +1,285 @@
|
|
|
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
|
+
import { resolveNodeCaller } from "./create-node.js";
|
|
20
|
+
|
|
21
|
+
export type NodePermissionMode = "normal" | "readonly" | "restricted";
|
|
22
|
+
export const NODE_PERMISSION_MODES: readonly NodePermissionMode[] = ["normal", "readonly", "restricted"];
|
|
23
|
+
export type NodePermissionsFlag = "log" | "off" | "enforce";
|
|
24
|
+
export type NodePermissionReason =
|
|
25
|
+
| "beyond_owner_visibility"
|
|
26
|
+
| "agent_not_granted_to_owner"
|
|
27
|
+
| "mode_readonly"
|
|
28
|
+
| "mode_restricted_not_assigned"
|
|
29
|
+
| "human_only"
|
|
30
|
+
| "owner_unknown";
|
|
31
|
+
|
|
32
|
+
/** 显式模式的原因:不看开关,永远拒。 */
|
|
33
|
+
const MODE_REASONS: ReadonlySet<NodePermissionReason> = new Set(["mode_readonly", "mode_restricted_not_assigned"]);
|
|
34
|
+
|
|
35
|
+
try { db.exec("ALTER TABLE nodes ADD COLUMN permission_mode TEXT NOT NULL DEFAULT 'normal'"); } catch (e: any) {
|
|
36
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
37
|
+
}
|
|
38
|
+
db.exec(`
|
|
39
|
+
CREATE TABLE IF NOT EXISTS node_permission_log (
|
|
40
|
+
network_id TEXT NOT NULL,
|
|
41
|
+
node_id TEXT NOT NULL,
|
|
42
|
+
route TEXT NOT NULL,
|
|
43
|
+
reason TEXT NOT NULL,
|
|
44
|
+
hour TEXT NOT NULL,
|
|
45
|
+
hits INTEGER NOT NULL DEFAULT 1,
|
|
46
|
+
sample TEXT,
|
|
47
|
+
PRIMARY KEY (network_id, node_id, route, reason, hour)
|
|
48
|
+
);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_node_permission_log_hour ON node_permission_log(network_id, hour);
|
|
50
|
+
`);
|
|
51
|
+
|
|
52
|
+
/** 开关。未设 / 写错 → log(安全的默认:只记不拦)。每次现读:测试和运维改环境变量后无需重启模块。 */
|
|
53
|
+
export function nodePermissionsFlag(): NodePermissionsFlag {
|
|
54
|
+
const raw = (process.env.COMMHUB_NODE_PERMISSIONS ?? "").trim().toLowerCase();
|
|
55
|
+
return raw === "off" || raw === "enforce" ? raw : "log";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type NodeIdentity = {
|
|
59
|
+
networkId: string;
|
|
60
|
+
/** 绑定的节点;老的未绑定令牌 = null(按「正常」判,记录时用 token:<token_id>)。 */
|
|
61
|
+
nodeId: string | null;
|
|
62
|
+
logKey: string;
|
|
63
|
+
aliases: string[];
|
|
64
|
+
ownerUserId: string | null;
|
|
65
|
+
mode: NodePermissionMode;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function normalizeMode(raw: unknown): NodePermissionMode {
|
|
69
|
+
return raw === "readonly" || raw === "restricted" ? raw : "normal";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 节点令牌 → 节点身份(一次带索引的查询)。不是节点令牌 / 查不到 → null。 */
|
|
73
|
+
export function nodeIdentity(tokenId: string | null | undefined, networkId: string | null | undefined): NodeIdentity | null {
|
|
74
|
+
if (!tokenId || !networkId) return null;
|
|
75
|
+
const row = db.get<{ user_id: string | null; name: string | null }>(
|
|
76
|
+
`SELECT user_id, name FROM api_tokens WHERE token_id = ?1 AND network_id = ?2`,
|
|
77
|
+
tokenId, networkId,
|
|
78
|
+
);
|
|
79
|
+
if (!row) return null;
|
|
80
|
+
const resolved = resolveNodeCaller(tokenId);
|
|
81
|
+
// Revoked or not node-shaped: this lookup is not a second revocation gate.
|
|
82
|
+
if (!resolved.ok && resolved.reason === "not_a_node_token") {
|
|
83
|
+
const aliases = row.name?.startsWith("node:") ? [row.name.slice(5)] : [];
|
|
84
|
+
return {
|
|
85
|
+
networkId,
|
|
86
|
+
nodeId: null,
|
|
87
|
+
logKey: `token:${tokenId}`,
|
|
88
|
+
aliases,
|
|
89
|
+
ownerUserId: row.user_id,
|
|
90
|
+
mode: "normal",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (!resolved.ok || resolved.networkId !== networkId) {
|
|
94
|
+
return {
|
|
95
|
+
networkId,
|
|
96
|
+
nodeId: null,
|
|
97
|
+
logKey: `token:${tokenId}`,
|
|
98
|
+
aliases: [],
|
|
99
|
+
ownerUserId: row.user_id,
|
|
100
|
+
mode: "normal",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (resolved.kind === "unregistered") {
|
|
104
|
+
return {
|
|
105
|
+
networkId,
|
|
106
|
+
nodeId: null,
|
|
107
|
+
logKey: `token:${tokenId}`,
|
|
108
|
+
aliases: resolved.alias ? [resolved.alias] : [],
|
|
109
|
+
ownerUserId: row.user_id,
|
|
110
|
+
mode: "normal",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const node = db.get<{ owner_user_id: string | null; permission_mode: string | null }>(
|
|
114
|
+
`SELECT owner_user_id, permission_mode FROM nodes WHERE node_id = ?1 AND network_id = ?2`,
|
|
115
|
+
resolved.nodeId, networkId,
|
|
116
|
+
);
|
|
117
|
+
return {
|
|
118
|
+
networkId,
|
|
119
|
+
nodeId: resolved.nodeId,
|
|
120
|
+
logKey: resolved.nodeId,
|
|
121
|
+
aliases: [resolved.alias],
|
|
122
|
+
ownerUserId: node?.owner_user_id || row.user_id || null,
|
|
123
|
+
mode: normalizeMode(node?.permission_mode),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type Verdict = { reason: NodePermissionReason; sample?: string } | null;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 判一次:verdict=null → 放行;否则按原因和开关决定拒不拒,并记一行(off 时正常模式的原因不记)。
|
|
131
|
+
* 返回 true = 拒绝。
|
|
132
|
+
*/
|
|
133
|
+
export function nodeDecide(id: NodeIdentity, route: string, verdict: Verdict): boolean {
|
|
134
|
+
if (!verdict) return false;
|
|
135
|
+
const explicit = MODE_REASONS.has(verdict.reason);
|
|
136
|
+
const flag = nodePermissionsFlag();
|
|
137
|
+
if (!explicit && flag === "off") return false;
|
|
138
|
+
recordNodePermission(id, route, verdict.reason, verdict.sample);
|
|
139
|
+
return explicit || flag === "enforce";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const HINTS: Record<NodePermissionReason, string> = {
|
|
143
|
+
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",
|
|
144
|
+
agent_not_granted_to_owner: "this Agent has not been granted to this node's owner; ask a network admin to grant it",
|
|
145
|
+
mode_readonly: "this node is in read-only mode; the node's owner can switch it to normal in the app",
|
|
146
|
+
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",
|
|
147
|
+
human_only: "only people can do this (members, projects, permissions, providers, secrets, other nodes); use a user token",
|
|
148
|
+
owner_unknown: "this node's owner is unknown; recreate the node token from the owner's account",
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export function nodePermissionDeniedBody(reason: NodePermissionReason, route: string): { ok: false; error: "node_permission_denied"; reason: NodePermissionReason; route: string; hint: string } {
|
|
152
|
+
return { ok: false, error: "node_permission_denied", reason, route, hint: HINTS[reason] };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── 记录:按 (网络, 节点, 路由, 原因, 小时) 合并;有上限、会清理 ──
|
|
156
|
+
export const LOG_RETENTION_DAYS = 30;
|
|
157
|
+
export const LOG_MAX_ROWS = 20_000;
|
|
158
|
+
const PRUNE_EVERY_MS = 60 * 60 * 1000;
|
|
159
|
+
let lastPrune = 0;
|
|
160
|
+
let logFull = false;
|
|
161
|
+
|
|
162
|
+
function hourOf(now = new Date()): string {
|
|
163
|
+
return now.toISOString().slice(0, 13); // 2026-10-03T08
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 清掉保留期外的行,并重新判断是否到了上限(到上限后只给已有的行加次数,不再新开行)。 */
|
|
167
|
+
export function pruneNodePermissionLog(now = new Date()): void {
|
|
168
|
+
const cutoff = hourOf(new Date(now.getTime() - LOG_RETENTION_DAYS * 86_400_000));
|
|
169
|
+
db.run("DELETE FROM node_permission_log WHERE hour < ?1", [cutoff]);
|
|
170
|
+
const n = Number(db.get<{ n: number | string }>("SELECT COUNT(*) AS n FROM node_permission_log")?.n ?? 0);
|
|
171
|
+
logFull = n >= LOG_MAX_ROWS;
|
|
172
|
+
lastPrune = now.getTime();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function recordNodePermission(id: NodeIdentity, route: string, reason: NodePermissionReason, sample?: string): void {
|
|
176
|
+
try {
|
|
177
|
+
const now = new Date();
|
|
178
|
+
if (now.getTime() - lastPrune > PRUNE_EVERY_MS) pruneNodePermissionLog(now);
|
|
179
|
+
const hour = hourOf(now);
|
|
180
|
+
const s = sample ? sample.slice(0, 200) : null;
|
|
181
|
+
const updated = db.run(
|
|
182
|
+
"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",
|
|
183
|
+
[id.networkId, id.logKey, route.slice(0, 120), reason, hour, s],
|
|
184
|
+
);
|
|
185
|
+
if (Number(updated?.changes ?? 0) > 0 || logFull) return;
|
|
186
|
+
db.run(
|
|
187
|
+
`INSERT INTO node_permission_log (network_id, node_id, route, reason, hour, hits, sample) VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6)
|
|
188
|
+
ON CONFLICT(network_id, node_id, route, reason, hour) DO UPDATE SET hits = node_permission_log.hits + 1`,
|
|
189
|
+
[id.networkId, id.logKey, route.slice(0, 120), reason, hour, s],
|
|
190
|
+
);
|
|
191
|
+
} catch (e) {
|
|
192
|
+
// 记录失败绝不影响请求本身。
|
|
193
|
+
console.error(`[node-permissions] log failed: ${(e as Error).message}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Test-only:让下一次记录先清理一遍,并可改上限判断。 */
|
|
198
|
+
export function __resetNodePermissionLogStateForTest(): void { lastPrune = 0; logFull = false; }
|
|
199
|
+
|
|
200
|
+
// ── 规则 ──
|
|
201
|
+
|
|
202
|
+
/** 派活 / 发消息 / 重派给 target。发给自己永远可以。 */
|
|
203
|
+
export function dispatchVerdict(id: NodeIdentity, target: AgentRef): Verdict {
|
|
204
|
+
if ((target.alias && id.aliases.includes(target.alias)) || (target.nodeId && id.nodeId && target.nodeId === id.nodeId)) return null;
|
|
205
|
+
const sample = target.alias ?? target.nodeId ?? undefined;
|
|
206
|
+
if (id.mode === "readonly") return { reason: "mode_readonly", sample };
|
|
207
|
+
if (!id.ownerUserId) return id.mode === "restricted" ? { reason: "mode_restricted_not_assigned", sample } : { reason: "owner_unknown" };
|
|
208
|
+
if (!canMessageAgent(id.ownerUserId, id.networkId, target)) {
|
|
209
|
+
// 受限是主人设的 → 必拒(模式原因);正常下是开关管的收紧。
|
|
210
|
+
return { reason: id.mode === "restricted" ? "mode_restricted_not_assigned" : "agent_not_granted_to_owner", sample };
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 广播:只读 / 受限都不行(会发给没派给它、也未必授权的 Agent)。 */
|
|
216
|
+
export function broadcastVerdict(id: NodeIdentity): Verdict {
|
|
217
|
+
if (id.mode === "readonly") return { reason: "mode_readonly" };
|
|
218
|
+
if (id.mode === "restricted") return { reason: "mode_restricted_not_assigned" };
|
|
219
|
+
if (!id.ownerUserId) return { reason: "owner_unknown" };
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** 一般的写(管节点、提交技能、探测模型…):只读 / 受限拒;正常放行(由各自的既有规则管)。 */
|
|
224
|
+
export function writeVerdict(id: NodeIdentity, sample?: string): Verdict {
|
|
225
|
+
if (id.mode === "readonly") return { reason: "mode_readonly", sample };
|
|
226
|
+
if (id.mode === "restricted") return { reason: "mode_restricted_not_assigned", sample };
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** 只有人能做的事。只读 / 受限下报模式原因(那是主人设的、必拒);正常下报 human_only。 */
|
|
231
|
+
export function humanOnlyVerdict(id: NodeIdentity, sample?: string): Verdict {
|
|
232
|
+
return writeVerdict(id, sample) ?? { reason: "human_only", sample };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** 订阅别的会话 / 整个网络的推送流:受限不行;正常按主人能不能看到那个 Agent。 */
|
|
236
|
+
export function streamVerdict(id: NodeIdentity, target: AgentRef | "network"): Verdict {
|
|
237
|
+
if (target !== "network" && ((target.alias && id.aliases.includes(target.alias)) || (target.nodeId && target.nodeId === id.nodeId))) return null;
|
|
238
|
+
if (id.mode === "restricted") return { reason: "mode_restricted_not_assigned", sample: target === "network" ? "network" : target.alias ?? undefined };
|
|
239
|
+
if (!id.ownerUserId) return { reason: "owner_unknown" };
|
|
240
|
+
if (target !== "network" && !canMessageAgent(id.ownerUserId, id.networkId, target)) {
|
|
241
|
+
return { reason: "beyond_owner_visibility", sample: target.alias ?? undefined };
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── MCP:每个工具归一类(test 钉住「每个注册的工具都归了类」,新工具忘了归类会红) ──
|
|
247
|
+
export type ToolClass = "always" | "read" | "dispatch" | "broadcast" | "requirements" | "schedule" | "node_write" | "human_only";
|
|
248
|
+
export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
|
|
249
|
+
daemon_provider_snapshot: "human_only",
|
|
250
|
+
pull_daemon_provider_snapshots: "always", ack_daemon_provider_snapshot: "always",
|
|
251
|
+
// 回复 / 上报 / 自己的收件箱 / 生命周期协议(节点自己的请求)—— 任何模式都能做
|
|
252
|
+
report_status: "always", report_completion: "always", get_inbox: "always", ack_inbox: "always",
|
|
253
|
+
mark_tasks_runtime_submitted: "always", mark_tasks_consumed: "always", send_reply: "always", send_peer_reply: "always", send_ack: "always",
|
|
254
|
+
get_config_update: "always", ack_config_update: "always", get_rules_file_request: "always", ack_rules_file_request: "always", get_rules_file_result: "always",
|
|
255
|
+
get_create_request: "always", ack_create_request: "always", get_stop_request: "always", ack_stop_request: "always",
|
|
256
|
+
get_start_request: "always", ack_start_request: "always", get_probe_request: "always", ack_probe_request: "always",
|
|
257
|
+
get_opencode_catalog_request: "always", ack_opencode_catalog: "always",
|
|
258
|
+
list_my_pending_create_requests: "always", list_my_pending_lifecycle_requests: "always", list_my_children: "always",
|
|
259
|
+
request_adopt_node: "human_only", unadopt_node: "human_only",
|
|
260
|
+
get_adopt_request: "always", ack_adopt_request: "always",
|
|
261
|
+
send_desktop_message: "always", // 发给人的私信,不是派活
|
|
262
|
+
// 读
|
|
263
|
+
get_all_status: "read", get_session_status: "read", get_task: "read", list_tasks: "read", get_completions: "read", org_whoami: "read",
|
|
264
|
+
list_skills: "read", get_skill: "read", list_host_supervisors: "read", list_network_secrets: "read", list_providers: "read",
|
|
265
|
+
get_probe_results: "read", read_node_rules_file: "read", list_node_skills: "read", read_node_skill: "read", list_node_files: "read",
|
|
266
|
+
read_node_file: "read", tail_node_logs: "read",
|
|
267
|
+
// 派活
|
|
268
|
+
send_task: "dispatch", send_message: "dispatch", retry_task: "dispatch", reassign_task: "dispatch", cancel_task: "dispatch",
|
|
269
|
+
broadcast: "broadcast",
|
|
270
|
+
// 任务看板:在 requirements.ts 里按卡判(REST 与 MCP 同一处)
|
|
271
|
+
requirements_list: "requirements", requirements_people: "requirements", requirements_get: "requirements", requirements_create: "requirements",
|
|
272
|
+
requirements_update: "requirements", requirements_checklist_toggle: "requirements", requirements_upsert_by_external_ref: "requirements",
|
|
273
|
+
requirements_events: "requirements", requirements_comment: "requirements", projects_list: "requirements",
|
|
274
|
+
// 定时任务(#733):在 schedule-agent.ts 里按排程判(范围 / 目标 = send_task 的 dispatchVerdict / 配额)
|
|
275
|
+
schedule_list: "read", schedule_get: "read", schedule_runs: "read",
|
|
276
|
+
schedule_create: "schedule", schedule_update: "schedule", schedule_cancel: "schedule", schedule_run_now: "schedule",
|
|
277
|
+
schedule_batch_interval: "schedule",
|
|
278
|
+
// 写节点 / 技能 / 探测:只读、受限不行;正常由 RFC-036 等既有规则管
|
|
279
|
+
update_node_config: "node_write", write_node_rules_file: "node_write", restart_node: "node_write", create_node: "node_write", clone_node: "node_write",
|
|
280
|
+
stop_node: "node_write", delete_node: "node_write", start_node: "node_write", submit_skill: "node_write", probe_provider_model: "node_write",
|
|
281
|
+
// 只有人能做
|
|
282
|
+
upsert_network_secret: "human_only", upsert_provider: "human_only", update_provider: "human_only",
|
|
283
|
+
projects_create: "human_only", projects_update: "human_only", review_skill: "human_only",
|
|
284
|
+
read_opencode_catalog: "human_only", get_opencode_catalog: "human_only",
|
|
285
|
+
};
|
|
@@ -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
|
+
});
|