@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.70
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 +125 -7
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access.ts +267 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -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 +26 -10
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +348 -29
- 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/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- 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 +89 -1
- package/src/create-node-validate.ts +89 -7
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.ts +51 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +762 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -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/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +73 -0
- package/src/http-gzip.ts +60 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +149 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2331
- package/src/lifecycle-guard.ts +50 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -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-id-alias.test.ts +188 -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-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 +429 -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/push.ts +204 -7
- 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-tags.ts +16 -0
- package/src/requirements-http.test.ts +708 -0
- package/src/requirements-mcp.test.ts +142 -0
- package/src/requirements-migrate.ts +83 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +85 -0
- package/src/requirements.ts +865 -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 +216 -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.ts +10 -1
- 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-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +574 -0
- package/src/scheduled-tasks.ts +596 -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 +4279 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/side-thread-command-transport.test.ts +302 -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 +2495 -0
- package/src/skillhub-http.test.ts +92 -0
- package/src/start-node.test.ts +109 -0
- package/src/stop-delete-node.test.ts +1164 -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-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/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3445 -257
- package/src/update-provider.test.ts +65 -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 +130 -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
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// REST from_session identity binding.
|
|
2
|
+
//
|
|
3
|
+
// Both transports resolve the same `api_tokens` row through resolveToken, and
|
|
4
|
+
// auth.ts already states the invariant:
|
|
5
|
+
//
|
|
6
|
+
// "Network-bound node tokens are an identity boundary: they must not spoof
|
|
7
|
+
// another node via from_session."
|
|
8
|
+
//
|
|
9
|
+
// tools.ts enforced it for MCP (defaultFrom + fromIdentityMismatchReply).
|
|
10
|
+
// POST /api/task did not — it took `body.from` verbatim, so any node holding
|
|
11
|
+
// its own ntok_ could claim another node's alias. That value is stored on the
|
|
12
|
+
// task row and, once sender labels reach the copresence TUIs, rendered to a
|
|
13
|
+
// human as an attribution.
|
|
14
|
+
//
|
|
15
|
+
// This is the same decision expressed as a pure function so every credential
|
|
16
|
+
// kind is a directly constructible test input rather than a hand-assembled
|
|
17
|
+
// Request (the #503 convention in this codebase).
|
|
18
|
+
|
|
19
|
+
export interface RestIdentityInput {
|
|
20
|
+
/** Raw bearer token, only its prefix matters (`ntok_` = network-bound). */
|
|
21
|
+
token: string;
|
|
22
|
+
/** `api_tokens.name`; for node tokens it is `node:<alias>`. */
|
|
23
|
+
tokenName: string | null | undefined;
|
|
24
|
+
/** Username resolved from the authenticated token. */
|
|
25
|
+
authenticatedUsername?: string | null;
|
|
26
|
+
/** Caller-supplied `body.from`. */
|
|
27
|
+
requestedFrom: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type RestIdentityResult =
|
|
31
|
+
| { ok: true; fromSession: string }
|
|
32
|
+
| {
|
|
33
|
+
ok: false;
|
|
34
|
+
error: "from_session_identity_mismatch";
|
|
35
|
+
message: string;
|
|
36
|
+
tokenAlias: string;
|
|
37
|
+
requestedFromSession: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Alias a token is bound to, or null when it is not node-scoped. */
|
|
41
|
+
export function nodeAliasForToken(token: string, tokenName: string | null | undefined): string | null {
|
|
42
|
+
if (!token.startsWith("ntok_")) return null;
|
|
43
|
+
if (typeof tokenName !== "string" || !tokenName.startsWith("node:")) return null;
|
|
44
|
+
const alias = tokenName.slice("node:".length).trim();
|
|
45
|
+
return alias || null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveRestFromSession(input: RestIdentityInput): RestIdentityResult {
|
|
49
|
+
const alias = nodeAliasForToken(input.token, input.tokenName);
|
|
50
|
+
const requested = typeof input.requestedFrom === "string" ? input.requestedFrom.trim() : "";
|
|
51
|
+
|
|
52
|
+
// A network-bound token whose name we cannot resolve to an alias (`node:`,
|
|
53
|
+
// `node: `, or a name that was never node-scoped) is the degenerate case.
|
|
54
|
+
// We know it IS a node credential; we just cannot tell WHICH node. Letting it
|
|
55
|
+
// claim anything is the worst available option, so refuse the claim outright
|
|
56
|
+
// rather than falling through to the unbound path.
|
|
57
|
+
//
|
|
58
|
+
// Production always writes `node:${nodeName}`, so this costs nothing real —
|
|
59
|
+
// but "we couldn't identify you, so you may call yourself anything" is
|
|
60
|
+
// exactly the fail-open shape this change exists to remove.
|
|
61
|
+
if (!alias && input.token.startsWith("ntok_")) {
|
|
62
|
+
if (requested) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
error: "from_session_identity_mismatch",
|
|
66
|
+
message: "network token has no resolvable node alias and may not claim a from_session",
|
|
67
|
+
tokenAlias: "",
|
|
68
|
+
requestedFromSession: requested,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return { ok: true, fromSession: "api" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A user-token client normally does not need to repeat its identity in every
|
|
75
|
+
// request. Prefer an explicit, backwards-compatible claim when present;
|
|
76
|
+
// otherwise attribute the task to the authenticated username. Falling back
|
|
77
|
+
// to the transport label `api` is reserved for legacy auth contexts where
|
|
78
|
+
// the server genuinely has no user identity.
|
|
79
|
+
if (!alias) {
|
|
80
|
+
const authenticatedUsername = typeof input.authenticatedUsername === "string"
|
|
81
|
+
? input.authenticatedUsername.trim()
|
|
82
|
+
: "";
|
|
83
|
+
return { ok: true, fromSession: requested || authenticatedUsername || "api" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Node token claiming somebody else — refuse rather than silently rewrite,
|
|
87
|
+
// so a misconfigured caller is visible instead of quietly relabelled.
|
|
88
|
+
if (requested && requested !== alias) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
error: "from_session_identity_mismatch",
|
|
92
|
+
message: "network token from_session does not match token-bound node alias",
|
|
93
|
+
tokenAlias: alias,
|
|
94
|
+
requestedFromSession: requested,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Node token, no claim or a matching one: bind to the token's own alias.
|
|
99
|
+
return { ok: true, fromSession: alias };
|
|
100
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable REST row projections.
|
|
3
|
+
*
|
|
4
|
+
* These lists deliberately mirror the fields already exposed by the public
|
|
5
|
+
* endpoints before #311. Keeping them in one place makes the wire contract
|
|
6
|
+
* independent from future ALTER TABLE migrations: adding a storage-only
|
|
7
|
+
* column must never silently add a REST response key.
|
|
8
|
+
*/
|
|
9
|
+
export const NETWORK_REST_COLUMNS = [
|
|
10
|
+
"network_id", "network_name", "owner_id", "description", "settings",
|
|
11
|
+
"created_at", "updated_at", "visibility", "max_members",
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
export const SESSION_REST_COLUMNS = [
|
|
15
|
+
"resume_id", "alias", "tmux_name", "server", "ip", "hostname", "agent",
|
|
16
|
+
"project_dir", "version", "status", "task", "output", "progress", "score",
|
|
17
|
+
"cpu_load_1min", "cpu_cores", "mem_total_gb", "mem_used_gb", "mem_avail_gb",
|
|
18
|
+
"disk_total_gb", "disk_used_gb", "disk_avail_gb", "process_rss_bytes",
|
|
19
|
+
"process_rss_mb", "process_cpu_pct", "process_uptime_seconds",
|
|
20
|
+
"process_in_flight_count", "network_id", "registered_at", "updated_at",
|
|
21
|
+
"node_id", "session_id", "config_path", "channels", "last_seen_at", "model",
|
|
22
|
+
"external_schedules", "rules_file_capable", "skills_capable", "files_capable",
|
|
23
|
+
"logs_capable",
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
export const AUDIT_LOG_REST_COLUMNS = [
|
|
27
|
+
"id", "user_id", "username", "action", "target_type", "target_id", "detail",
|
|
28
|
+
"ip", "network_id", "created_at",
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
export const TASK_EVENT_REST_COLUMNS = [
|
|
32
|
+
"id", "task_id", "from_status", "to_status", "event_type", "actor", "detail",
|
|
33
|
+
"created_at", "network_id",
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
export const TASK_REST_COLUMNS = [
|
|
37
|
+
"task_id", "from_node_id", "from_name", "to_node_id", "to_name", "priority",
|
|
38
|
+
"status", "content", "result", "in_reply_to", "requires_response", "scope",
|
|
39
|
+
"created_at", "delivered_at", "started_at", "runtime_submitted_at", "consumed_at", "completed_at", "expires_at",
|
|
40
|
+
"network_id", "parent_task_id", "meta_json", "thread_id", "turn_id",
|
|
41
|
+
] as const;
|
|
42
|
+
|
|
43
|
+
export const COMPLETION_REST_COLUMNS = [
|
|
44
|
+
"id", "session_name", "task", "result", "artifacts", "score",
|
|
45
|
+
"duration_minutes", "network_id", "completed_at",
|
|
46
|
+
] as const;
|
|
47
|
+
|
|
48
|
+
// Storage projection for scheduled-task rows. `created_by` and
|
|
49
|
+
// `schedule_json` are intentionally selected for server-side auth/decoding,
|
|
50
|
+
// then removed by decodeRow before the REST response is serialized.
|
|
51
|
+
export const SCHEDULED_TASK_STORAGE_COLUMNS = [
|
|
52
|
+
"schedule_id", "network_id", "created_by", "name", "target_node_id",
|
|
53
|
+
"target_alias", "task_content", "priority", "schedule_type", "schedule_json",
|
|
54
|
+
"timezone", "overlap_policy", "misfire_policy", "status", "next_run_at",
|
|
55
|
+
"last_run_at", "revision", "created_at", "updated_at",
|
|
56
|
+
] as const;
|
|
57
|
+
|
|
58
|
+
export function sqlColumns(columns: readonly string[], qualifier?: string): string {
|
|
59
|
+
return columns.map((column) => qualifier ? `${qualifier}.${column}` : column).join(", ");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const NETWORK_REST_SELECT = sqlColumns(NETWORK_REST_COLUMNS);
|
|
63
|
+
export const SESSION_REST_SELECT = sqlColumns(SESSION_REST_COLUMNS);
|
|
64
|
+
export const AUDIT_LOG_REST_SELECT = sqlColumns(AUDIT_LOG_REST_COLUMNS);
|
|
65
|
+
export const TASK_EVENT_REST_SELECT = sqlColumns(TASK_EVENT_REST_COLUMNS);
|
|
66
|
+
export const TASK_REST_SELECT = sqlColumns(TASK_REST_COLUMNS);
|
|
67
|
+
export const COMPLETION_REST_SELECT = sqlColumns(COMPLETION_REST_COLUMNS);
|
|
68
|
+
export const SCHEDULED_TASK_STORAGE_SELECT = sqlColumns(SCHEDULED_TASK_STORAGE_COLUMNS);
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// #819 — `resolveRestWriteNetworkId` 是 network-scope.ts 里唯一没有任何测试点名的
|
|
2
|
+
// 导出函数。同文件的五个兄弟各有 1-2 个测试文件点名它们:
|
|
3
|
+
//
|
|
4
|
+
// resolveRestNetworkScope 1 canRestWriteNetwork 1
|
|
5
|
+
// singleNetworkId 2 addNetworkScope 1
|
|
6
|
+
// getUserNetworkIds 1 resolveRestWriteNetworkId 🔴 0
|
|
7
|
+
//
|
|
8
|
+
// 而它决定的是**一次 REST 写入落到哪个网络**。它的 docstring 说明了一条不显然的
|
|
9
|
+
// 规则:管理员的**读**作用域按设计是全局的(networkIds=null),所以它本身表达不了
|
|
10
|
+
// 「这个管理员恰好只属于一个网络」;而**写**不能继承这个歧义 —— 只有在确实只有
|
|
11
|
+
// 一个成员关系时才用它,0 个或 ≥2 个都必须显式指定网络。
|
|
12
|
+
//
|
|
13
|
+
// 🔴 这条规则的两半都要钉:
|
|
14
|
+
// - **放行那半**(admin + 恰好 1 个成员关系 → 用它)如果坏了,会退化成「管理员
|
|
15
|
+
// 写任何东西都要显式带 network_id」——很吵,但**安全**,所以没人会急着修;
|
|
16
|
+
// - **收紧那半**(admin + 0 或 ≥2 → null)如果坏了,一次写入会**落到一个没被
|
|
17
|
+
// 指定的网络里**,而调用方看到的是成功。
|
|
18
|
+
//
|
|
19
|
+
// 只写反向断言是不够的:一个「永远返回 null」的实现能通过所有「收紧」用例。
|
|
20
|
+
// 所以每一条收紧断言都配了正控。
|
|
21
|
+
//
|
|
22
|
+
// Run: COMMHUB_DB=/tmp/819-rest-write-scope.db bun test src/rest-write-network-resolution.test.ts
|
|
23
|
+
|
|
24
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
25
|
+
import { db } from "./db.js";
|
|
26
|
+
import { resolveRestNetworkScope, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
27
|
+
|
|
28
|
+
const NET_A = "net_819_a";
|
|
29
|
+
const NET_B = "net_819_b";
|
|
30
|
+
const U_SINGLE = "u819_single"; // 只属于 NET_A
|
|
31
|
+
const U_MULTI = "u819_multi"; // 属于 NET_A + NET_B
|
|
32
|
+
const U_NONE = "u819_none"; // 一个都不属于
|
|
33
|
+
const ALL_USERS = [U_SINGLE, U_MULTI, U_NONE];
|
|
34
|
+
|
|
35
|
+
function cleanup() {
|
|
36
|
+
try { db.run("DELETE FROM network_members WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
|
|
37
|
+
try { db.run("DELETE FROM networks WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
|
|
38
|
+
for (const u of ALL_USERS) {
|
|
39
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [u]); } catch {}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function seed() {
|
|
44
|
+
for (const u of ALL_USERS) {
|
|
45
|
+
db.run(
|
|
46
|
+
`INSERT INTO users (user_id, username, password_hash, role, created_at)
|
|
47
|
+
VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
|
|
48
|
+
[u, u],
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_A, U_SINGLE]);
|
|
52
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_B, U_MULTI]);
|
|
53
|
+
const member = (u: string, net: string, role: string) =>
|
|
54
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, ?3, datetime('now'))`, [u, net, role]);
|
|
55
|
+
member(U_SINGLE, NET_A, "owner");
|
|
56
|
+
member(U_MULTI, NET_A, "member");
|
|
57
|
+
member(U_MULTI, NET_B, "owner");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
beforeEach(() => { cleanup(); seed(); });
|
|
61
|
+
afterAll(() => { cleanup(); });
|
|
62
|
+
|
|
63
|
+
/** 管理员的读作用域:全局(networkIds=null),这正是歧义的来源。 */
|
|
64
|
+
const ADMIN_SCOPE: RestNetworkScope = { networkIds: null };
|
|
65
|
+
const scopeOf = (...ids: string[]): RestNetworkScope => ({ networkIds: ids });
|
|
66
|
+
const ctx = (userId: string) => ({ userId, networkId: null });
|
|
67
|
+
|
|
68
|
+
describe("#819 resolveRestWriteNetworkId — 作用域里就能确定时,直接用它", () => {
|
|
69
|
+
test("作用域恰好一个网络 → 用那一个(与是不是 admin 无关)", () => {
|
|
70
|
+
expect(resolveRestWriteNetworkId(scopeOf(NET_A), ctx(U_MULTI), false)).toBe(NET_A);
|
|
71
|
+
expect(resolveRestWriteNetworkId(scopeOf(NET_A), ctx(U_MULTI), true)).toBe(NET_A);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("作用域两个网络 → 歧义,null(即使调用者是 admin)", () => {
|
|
75
|
+
expect(resolveRestWriteNetworkId(scopeOf(NET_A, NET_B), ctx(U_MULTI), true)).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// 🔴 这一条我一开始断错了,留下来当记录:我以为「作用域为空数组 → null」,
|
|
79
|
+
// 实测是 NET_A。原因是 `[]` 过不了 singleNetworkId,于是落到 admin 的成员关系
|
|
80
|
+
// 回退分支,而 U_SINGLE 恰好只有一个成员关系。
|
|
81
|
+
//
|
|
82
|
+
// 而 `networkIds: []` 在 resolveRestNetworkScope 里有确切含义:`:46` 那一行,
|
|
83
|
+
// **非 admin 请求了一个自己没有角色的网络** —— 它带着 `denied: "access denied
|
|
84
|
+
// to requested network"`。也就是说 `[]` 不是「没指定」,是「明确被拒」。
|
|
85
|
+
//
|
|
86
|
+
// 现在**没有**问题,因为那一行只对非 admin 产生(admin 在 `:42` 就提前返回
|
|
87
|
+
// networkIds:null 了),而非 admin 走到这里必然 null —— 见下一条。
|
|
88
|
+
//
|
|
89
|
+
// 但它是一个潜伏的形状:如果将来有任何路径让 admin 拿到 networkIds: [],
|
|
90
|
+
// 这次写入会**忽略那条明确的拒绝**,回退到他的单一成员关系。所以两条都钉住:
|
|
91
|
+
// 当前行为,以及那条让它安全的前提。
|
|
92
|
+
test("作用域为空数组 + admin + 恰好一个成员关系 → 回退到成员关系(当前行为,记录)", () => {
|
|
93
|
+
expect(resolveRestWriteNetworkId(scopeOf(), ctx(U_SINGLE), true)).toBe(NET_A);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("🔴 让上一条安全的前提:空数组作用域只由非 admin 产生,而非 admin → null", () => {
|
|
97
|
+
expect(resolveRestWriteNetworkId(scopeOf(), ctx(U_SINGLE), false)).toBeNull();
|
|
98
|
+
// 前提本身也断一次:resolveRestNetworkScope 对 admin 从不产出空数组。
|
|
99
|
+
const adminScope = resolveRestNetworkScope(NET_B, ctx(U_NONE), true);
|
|
100
|
+
expect(adminScope.networkIds).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("#819 admin 的全局读作用域不能替写入决定网络", () => {
|
|
105
|
+
test("🔴 admin + 恰好 1 个成员关系 → 用那一个(放行那半)", () => {
|
|
106
|
+
expect(resolveRestWriteNetworkId(ADMIN_SCOPE, ctx(U_SINGLE), true)).toBe(NET_A);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("🔴 admin + 2 个成员关系 → null,必须显式指定(收紧那半)", () => {
|
|
110
|
+
expect(resolveRestWriteNetworkId(ADMIN_SCOPE, ctx(U_MULTI), true)).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("🔴 admin + 0 个成员关系 → null(不能因为读是全局的就随便挑一个)", () => {
|
|
114
|
+
expect(resolveRestWriteNetworkId(ADMIN_SCOPE, ctx(U_NONE), true)).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("非 admin + 全局作用域 → null(这种组合本身就不该发生,fail closed)", () => {
|
|
118
|
+
// 正控在上面那条「admin + 单成员 → NET_A」:如果实现改成永远 null,那条会红。
|
|
119
|
+
expect(resolveRestWriteNetworkId(ADMIN_SCOPE, ctx(U_SINGLE), false)).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("没有 authCtx + 全局作用域 → null", () => {
|
|
123
|
+
expect(resolveRestWriteNetworkId(ADMIN_SCOPE, null, true)).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("#819 作用域优先于成员关系回退", () => {
|
|
128
|
+
test("作用域说 NET_B,而该用户唯一的成员关系是 NET_A → 结果是 NET_B", () => {
|
|
129
|
+
// 归属由作用域决定,不由「他碰巧属于哪个网络」决定。
|
|
130
|
+
// 如果实现把两者的优先级搞反,这条会返回 NET_A。
|
|
131
|
+
expect(resolveRestWriteNetworkId(scopeOf(NET_B), ctx(U_SINGLE), true)).toBe(NET_B);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// 多用户 Agent 权限 —— 受限成员与上传文件。
|
|
2
|
+
// 文件按网络归属;受限成员(只看授权 Agent 的成员)在受限网络里不能凭 file_id 读网络里任意文件,
|
|
3
|
+
// 也不能把自己看不见的 file_id 塞进发给授权 Agent 的任务(那等于借 Agent 的网络令牌去读)。
|
|
4
|
+
import { existsSync, readFileSync } from "fs";
|
|
5
|
+
import { db } from "./db.js";
|
|
6
|
+
import { addOwnTrafficScope, type RestNetworkScope } from "./network-scope.js";
|
|
7
|
+
import { FILE_ID_REGEX, indexEntryPath, validateIndexEntry } from "./uploads.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 受限成员能不能看见这个文件:只认**对方(授权 Agent / 给他发私信的人)放进来**的附件 ——
|
|
11
|
+
* user_inbox 里发给自己的私信附件;
|
|
12
|
+
* 授权 Agent 发给自己的任务 / inbox 行的 attachments;
|
|
13
|
+
* 自己发给授权 Agent 的任务上,Agent 回复写进的 reply_attachments。
|
|
14
|
+
* 自己随任务带出去的 attachments **不算**:否则把任意 file_id 塞进自己的任务就能「解锁」别人的文件。
|
|
15
|
+
*/
|
|
16
|
+
export function restrictedMemberSeesFile(userId: string, username: string, networkId: string, fileId: string): boolean {
|
|
17
|
+
if (!FILE_ID_REGEX.test(fileId)) return false;
|
|
18
|
+
const listed = (metaJson: string | null | undefined, key: "attachments" | "reply_attachments"): boolean => {
|
|
19
|
+
if (!metaJson) return false;
|
|
20
|
+
try {
|
|
21
|
+
const list = JSON.parse(metaJson)?.[key];
|
|
22
|
+
return Array.isArray(list) && list.some((a: any) => a && typeof a === "object" && a.file_id === fileId);
|
|
23
|
+
} catch { return false; }
|
|
24
|
+
};
|
|
25
|
+
// LIKE 只做预筛(file_id 只含 [A-Za-z0-9_-],`_` 转义后逐字匹配),真正的判定在 listed() 里按键解析。
|
|
26
|
+
const needle = `%"${fileId.replace(/_/g, "\\_")}"%`;
|
|
27
|
+
for (const row of db.all<{ meta_json: string | null }>(
|
|
28
|
+
"SELECT meta_json FROM user_inbox WHERE user_id = ?1 AND network_id = ?2 AND meta_json LIKE ?3 ESCAPE '\\' LIMIT 50",
|
|
29
|
+
userId, networkId, needle,
|
|
30
|
+
)) {
|
|
31
|
+
if (listed(row.meta_json, "attachments")) return true;
|
|
32
|
+
}
|
|
33
|
+
const scope: RestNetworkScope = { networkId, networkIds: null, agentRestriction: { userId, username, networkIds: [networkId] } };
|
|
34
|
+
const taskParams: unknown[] = [needle];
|
|
35
|
+
const taskSql = addOwnTrafficScope("SELECT from_name, to_name, meta_json FROM tasks WHERE meta_json LIKE ?1 ESCAPE '\\'", taskParams, scope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
|
|
36
|
+
for (const row of db.all<{ from_name: string; to_name: string; meta_json: string | null }>(taskSql + " LIMIT 50", ...taskParams)) {
|
|
37
|
+
if (row.to_name === username && listed(row.meta_json, "attachments")) return true;
|
|
38
|
+
if (row.from_name === username && listed(row.meta_json, "reply_attachments")) return true;
|
|
39
|
+
}
|
|
40
|
+
const inboxParams: unknown[] = [needle];
|
|
41
|
+
const inboxSql = addOwnTrafficScope("SELECT session_name, meta_json FROM inbox WHERE meta_json LIKE ?1 ESCAPE '\\'", inboxParams, scope, { from: "from_session", to: "session_name" });
|
|
42
|
+
for (const row of db.all<{ session_name: string; meta_json: string | null }>(inboxSql + " LIMIT 50", ...inboxParams)) {
|
|
43
|
+
if (row.session_name === username && listed(row.meta_json, "attachments")) return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 受限成员能不能用这个文件(下载 / 当附件转给 Agent):自己传的,或 restrictedMemberSeesFile。 */
|
|
49
|
+
export function restrictedMemberCanUseFile(userId: string, username: string, networkId: string, fileId: string): boolean {
|
|
50
|
+
if (!FILE_ID_REGEX.test(fileId)) return false;
|
|
51
|
+
const path = indexEntryPath(fileId);
|
|
52
|
+
if (!path || !existsSync(path)) return false;
|
|
53
|
+
let entry: any;
|
|
54
|
+
try { entry = JSON.parse(readFileSync(path, "utf8")); } catch { return false; }
|
|
55
|
+
if (!validateIndexEntry(entry) || entry.file_id !== fileId) return false;
|
|
56
|
+
if (entry.network_id !== networkId) return false;
|
|
57
|
+
if (entry.owner_id === userId) return true;
|
|
58
|
+
return restrictedMemberSeesFile(userId, username, networkId, fileId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 受限成员发给 Agent 的任务 / 消息里的附件:每个 file_id 都必须是他自己能用的文件。
|
|
63
|
+
* 返回第一个不合格的 file_id(调用方回 403),全部合格返回 null。
|
|
64
|
+
*/
|
|
65
|
+
export function restrictedMemberAttachmentsDenied(userId: string, username: string, networkId: string, raw: unknown): string | null {
|
|
66
|
+
if (!Array.isArray(raw)) return null;
|
|
67
|
+
for (const item of raw) {
|
|
68
|
+
const fileId = item && typeof item === "object" ? (item as { file_id?: unknown }).file_id : undefined;
|
|
69
|
+
if (fileId === undefined) continue;
|
|
70
|
+
if (typeof fileId !== "string" || !restrictedMemberCanUseFile(userId, username, networkId, fileId)) return String(fileId);
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
package/src/retention.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// COMMHUB_DB=/tmp/test-retention.db bun test src/retention.test.ts
|
|
21
21
|
|
|
22
22
|
import { db } from "./db.js";
|
|
23
|
+
import { sweepNodeRequestContent, type NodeRequestSweepResult } from "./node-request-retention.js";
|
|
23
24
|
|
|
24
25
|
export type RetentionConfig = {
|
|
25
26
|
// High-frequency telemetry; default = 7 days.
|
|
@@ -52,6 +53,9 @@ export type SweepResult = {
|
|
|
52
53
|
tasks: number;
|
|
53
54
|
audit_log: number;
|
|
54
55
|
};
|
|
56
|
+
// node_rules_requests: file bytes purged by age / after read, rows deleted
|
|
57
|
+
// after 30 days. Fixed policy (privacy, not disk) — no env opt-out.
|
|
58
|
+
nodeRequests: NodeRequestSweepResult;
|
|
55
59
|
vacuum: {
|
|
56
60
|
walCheckpointPagesMoved: number | null;
|
|
57
61
|
incrementalFreedPages: number | null;
|
|
@@ -178,6 +182,8 @@ export function sweepRetention(cfg: RetentionConfig = readRetentionConfig()): Sw
|
|
|
178
182
|
cfg.auditLogDays,
|
|
179
183
|
);
|
|
180
184
|
|
|
185
|
+
const nodeRequests = sweepNodeRequestContent();
|
|
186
|
+
|
|
181
187
|
// VACUUM:
|
|
182
188
|
// - wal_checkpoint(TRUNCATE): trims the WAL file. Works on every
|
|
183
189
|
// SQLite DB regardless of auto_vacuum setting. Useful after
|
|
@@ -230,6 +236,7 @@ export function sweepRetention(cfg: RetentionConfig = readRetentionConfig()): Sw
|
|
|
230
236
|
tasks,
|
|
231
237
|
audit_log: auditLog,
|
|
232
238
|
},
|
|
239
|
+
nodeRequests,
|
|
233
240
|
vacuum: { walCheckpointPagesMoved, incrementalFreedPages, errored },
|
|
234
241
|
};
|
|
235
242
|
}
|
|
@@ -249,11 +256,13 @@ export function startRetentionSweeper(
|
|
|
249
256
|
r.deletes.inbox +
|
|
250
257
|
r.deletes.tasks +
|
|
251
258
|
r.deletes.audit_log;
|
|
252
|
-
|
|
259
|
+
const nr = r.nodeRequests;
|
|
260
|
+
if (totalDeletes > 0 || nr.purged > 0 || nr.deleted > 0 || nr.timedOut > 0 || r.vacuum.errored) {
|
|
253
261
|
console.log(
|
|
254
262
|
`[commhub retention] swept in ${r.durationMs}ms — ` +
|
|
255
263
|
`telemetry=${r.deletes.agent_telemetry} events=${r.deletes.task_events} ` +
|
|
256
264
|
`inbox=${r.deletes.inbox} tasks=${r.deletes.tasks} audit=${r.deletes.audit_log} ` +
|
|
265
|
+
`nodeReqPurged=${nr.purged} nodeReqDeleted=${nr.deleted} nodeReqTimedOut=${nr.timedOut} ` +
|
|
257
266
|
`walPages=${r.vacuum.walCheckpointPagesMoved} freedPages=${r.vacuum.incrementalFreedPages}`,
|
|
258
267
|
);
|
|
259
268
|
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Duplicate-alias node rows vs the node pull/ack paths (field report).
|
|
2
|
+
//
|
|
3
|
+
// Re-creating a node under the same alias leaves the old `nodes` row behind, so
|
|
4
|
+
// one alias can own several rows in a network. The pull/ack tools used to
|
|
5
|
+
// resolve the caller by `WHERE alias = ? AND network_id = ?` + `db.get`, i.e.
|
|
6
|
+
// the first row SQLite yields — the stale one. Field shape: the client queued a
|
|
7
|
+
// rules-file read for the live node, the node pulled with its own (bound)
|
|
8
|
+
// token, the hub looked under the stale node_id, answered `request: null`, and
|
|
9
|
+
// the read timed out with only `[rules-file] doorbell received` in the node log.
|
|
10
|
+
//
|
|
11
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/rules-dup.db bun test src/rules-file-duplicate-alias.test.ts
|
|
12
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
14
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
15
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
|
+
import { db } from "./db.js";
|
|
17
|
+
import { registerTools } from "./tools.js";
|
|
18
|
+
|
|
19
|
+
const NET = "net_rules_dup";
|
|
20
|
+
const USER = "u_rules_dup_owner";
|
|
21
|
+
const ALIAS = "rules-dup-node";
|
|
22
|
+
// Inserted first → the row a plain `db.get` on the alias returns.
|
|
23
|
+
const STALE_ID = "node_rules_dup_stale";
|
|
24
|
+
const LIVE_ID = "node_rules_dup_live";
|
|
25
|
+
const STALE_TOKEN = "tok_rules_dup_stale";
|
|
26
|
+
const LIVE_TOKEN = "tok_rules_dup_live";
|
|
27
|
+
const UNBOUND_TOKEN = "tok_rules_dup_unbound";
|
|
28
|
+
|
|
29
|
+
function cleanup() {
|
|
30
|
+
try { db.run("DELETE FROM node_rules_requests WHERE network_id = ?1", [NET]); } catch {}
|
|
31
|
+
try { db.run("DELETE FROM node_config_updates WHERE network_id = ?1", [NET]); } catch {}
|
|
32
|
+
try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [NET]); } catch {}
|
|
33
|
+
try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET]); } catch {}
|
|
34
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET]); } catch {}
|
|
35
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET]); } catch {}
|
|
36
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
|
|
37
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
|
|
38
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function seedWorld(opts: { liveSession?: boolean } = {}) {
|
|
42
|
+
db.run(
|
|
43
|
+
`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
|
|
44
|
+
[USER, USER],
|
|
45
|
+
);
|
|
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
|
+
// Stale row first (older created_at AND lower rowid), live row second.
|
|
49
|
+
db.run(
|
|
50
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, '2026-09-16 01:40:40', '2026-09-16 01:40:40')`,
|
|
51
|
+
[STALE_ID, ALIAS, ALIAS, NET],
|
|
52
|
+
);
|
|
53
|
+
db.run(
|
|
54
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, '2026-09-16 01:42:46', '2026-09-16 01:42:46')`,
|
|
55
|
+
[LIVE_ID, ALIAS, ALIAS, NET],
|
|
56
|
+
);
|
|
57
|
+
for (const [tok, bound, hash] of [
|
|
58
|
+
[STALE_TOKEN, STALE_ID, "hash_rules_dup_stale"],
|
|
59
|
+
[LIVE_TOKEN, LIVE_ID, "hash_rules_dup_live"],
|
|
60
|
+
[UNBOUND_TOKEN, null, "hash_rules_dup_unbound"],
|
|
61
|
+
] as const) {
|
|
62
|
+
db.run(
|
|
63
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at, bound_node_id) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL, ?6)`,
|
|
64
|
+
[tok, USER, NET, `node:${ALIAS}`, hash, bound],
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (opts.liveSession !== false) {
|
|
68
|
+
db.run(
|
|
69
|
+
`INSERT INTO sessions (resume_id, alias, network_id, node_id, status, updated_at) VALUES (?1, ?2, ?3, ?4, 'idle', datetime('now'))`,
|
|
70
|
+
[`sdk-${LIVE_ID}`, ALIAS, NET, LIVE_ID],
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type Identity = { alias: string; isNetworkToken: boolean; tokenId: string };
|
|
76
|
+
const asUser: Identity = { alias: USER, isNetworkToken: false, tokenId: "tok_rules_dup_user" };
|
|
77
|
+
const asLive: Identity = { alias: ALIAS, isNetworkToken: true, tokenId: LIVE_TOKEN };
|
|
78
|
+
const asStale: Identity = { alias: ALIAS, isNetworkToken: true, tokenId: STALE_TOKEN };
|
|
79
|
+
const asUnbound: Identity = { alias: ALIAS, isNetworkToken: true, tokenId: UNBOUND_TOKEN };
|
|
80
|
+
|
|
81
|
+
async function connect(id: Identity) {
|
|
82
|
+
const server = new McpServer({ name: "rules-dup-test", version: "1" });
|
|
83
|
+
registerTools(server, undefined, NET, USER, id.alias, id.isNetworkToken, id.tokenId);
|
|
84
|
+
const client = new Client({ name: "rules-dup-client", version: "1" });
|
|
85
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
86
|
+
await server.connect(st);
|
|
87
|
+
await client.connect(ct);
|
|
88
|
+
const call = async (name: string, args: Record<string, unknown>) => {
|
|
89
|
+
const r: any = await client.callTool({ name, arguments: args });
|
|
90
|
+
const first = r.content?.[0];
|
|
91
|
+
if (!first || first.type !== "text") throw new Error(`no text result from ${name}`);
|
|
92
|
+
return JSON.parse(first.text);
|
|
93
|
+
};
|
|
94
|
+
const close = async () => { await client.close(); await server.close(); };
|
|
95
|
+
return { call, close };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function queuedNodeId(requestId: string): string | undefined {
|
|
99
|
+
return db.get<{ node_id: string }>("SELECT node_id FROM node_rules_requests WHERE request_id = ?1", requestId)?.node_id;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
beforeEach(() => { cleanup(); });
|
|
103
|
+
afterAll(cleanup);
|
|
104
|
+
|
|
105
|
+
describe("duplicate alias — rules file pull/ack", () => {
|
|
106
|
+
test("field case: read queued for the live node_id is pulled and acked by the live node's bound token", async () => {
|
|
107
|
+
seedWorld();
|
|
108
|
+
const u = await connect(asUser);
|
|
109
|
+
const n = await connect(asLive);
|
|
110
|
+
try {
|
|
111
|
+
const enq = await u.call("read_node_rules_file", { node_id: LIVE_ID });
|
|
112
|
+
expect(enq).toMatchObject({ ok: true, op: "read" });
|
|
113
|
+
const pulled = await n.call("get_rules_file_request", {});
|
|
114
|
+
expect(pulled).toEqual({ ok: true, request: { request_id: enq.request_id, op: "read" } });
|
|
115
|
+
const ack = await n.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "AGENTS.md", exists: true, content: "# live" });
|
|
116
|
+
expect(ack).toMatchObject({ ok: true, status: "done" });
|
|
117
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "done", content: "# live" });
|
|
118
|
+
} finally { await u.close(); await n.close(); }
|
|
119
|
+
}, 20_000);
|
|
120
|
+
|
|
121
|
+
test("a token bound to the stale row can neither pull nor ack the live node's request", async () => {
|
|
122
|
+
seedWorld();
|
|
123
|
+
const u = await connect(asUser);
|
|
124
|
+
const stale = await connect(asStale);
|
|
125
|
+
try {
|
|
126
|
+
const enq = await u.call("read_node_rules_file", { node_id: LIVE_ID });
|
|
127
|
+
expect(await stale.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
|
|
128
|
+
expect(await stale.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "# forged" }))
|
|
129
|
+
.toEqual({ ok: true, ignored: "unknown_or_foreign_request" });
|
|
130
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "pending" });
|
|
131
|
+
} finally { await u.close(); await stale.close(); }
|
|
132
|
+
}, 20_000);
|
|
133
|
+
|
|
134
|
+
test("the live token does not pull a request queued for the stale row", async () => {
|
|
135
|
+
seedWorld();
|
|
136
|
+
const u = await connect(asUser);
|
|
137
|
+
const n = await connect(asLive);
|
|
138
|
+
try {
|
|
139
|
+
await u.call("read_node_rules_file", { node_id: STALE_ID });
|
|
140
|
+
expect(await n.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
|
|
141
|
+
} finally { await u.close(); await n.close(); }
|
|
142
|
+
}, 20_000);
|
|
143
|
+
|
|
144
|
+
test("client targeting by alias queues under the row the alias's session points at", async () => {
|
|
145
|
+
seedWorld();
|
|
146
|
+
const u = await connect(asUser);
|
|
147
|
+
try {
|
|
148
|
+
const enq = await u.call("read_node_rules_file", { alias: ALIAS });
|
|
149
|
+
expect(enq).toMatchObject({ ok: true, op: "read" });
|
|
150
|
+
expect(queuedNodeId(enq.request_id)).toBe(LIVE_ID);
|
|
151
|
+
const sk = await u.call("list_node_skills", { alias: ALIAS });
|
|
152
|
+
expect(sk).toMatchObject({ ok: true });
|
|
153
|
+
expect(queuedNodeId(sk.request_id)).toBe(LIVE_ID);
|
|
154
|
+
} finally { await u.close(); }
|
|
155
|
+
}, 20_000);
|
|
156
|
+
|
|
157
|
+
test("without a session row, alias targeting falls back to the newest row", async () => {
|
|
158
|
+
seedWorld({ liveSession: false });
|
|
159
|
+
const u = await connect(asUser);
|
|
160
|
+
try {
|
|
161
|
+
const enq = await u.call("read_node_rules_file", { alias: ALIAS });
|
|
162
|
+
expect(queuedNodeId(enq.request_id)).toBe(LIVE_ID);
|
|
163
|
+
} finally { await u.close(); }
|
|
164
|
+
}, 20_000);
|
|
165
|
+
|
|
166
|
+
test("the session mapping wins over recency: alias targeting picks the older row when the session points at it", async () => {
|
|
167
|
+
seedWorld({ liveSession: false });
|
|
168
|
+
db.run(
|
|
169
|
+
`INSERT INTO sessions (resume_id, alias, network_id, node_id, status, updated_at) VALUES (?1, ?2, ?3, ?4, 'idle', datetime('now'))`,
|
|
170
|
+
[`sdk-${STALE_ID}`, ALIAS, NET, STALE_ID],
|
|
171
|
+
);
|
|
172
|
+
const u = await connect(asUser);
|
|
173
|
+
try {
|
|
174
|
+
const enq = await u.call("read_node_rules_file", { alias: ALIAS });
|
|
175
|
+
expect(queuedNodeId(enq.request_id)).toBe(STALE_ID);
|
|
176
|
+
} finally { await u.close(); }
|
|
177
|
+
}, 20_000);
|
|
178
|
+
|
|
179
|
+
test("a token bound to a node whose row is gone does not fall back to another row with its alias", async () => {
|
|
180
|
+
seedWorld();
|
|
181
|
+
db.run("UPDATE api_tokens SET bound_node_id = 'node_rules_dup_deleted' WHERE token_id = ?1", [STALE_TOKEN]);
|
|
182
|
+
const u = await connect(asUser);
|
|
183
|
+
const stale = await connect(asStale);
|
|
184
|
+
try {
|
|
185
|
+
const enq = await u.call("read_node_rules_file", { node_id: LIVE_ID });
|
|
186
|
+
expect(await stale.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
|
|
187
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "pending" });
|
|
188
|
+
} finally { await u.close(); await stale.close(); }
|
|
189
|
+
}, 20_000);
|
|
190
|
+
|
|
191
|
+
test("an unbound legacy token resolves its alias to the session-mapped row", async () => {
|
|
192
|
+
seedWorld();
|
|
193
|
+
const u = await connect(asUser);
|
|
194
|
+
const n = await connect(asUnbound);
|
|
195
|
+
try {
|
|
196
|
+
const enq = await u.call("read_node_rules_file", { node_id: LIVE_ID });
|
|
197
|
+
expect(await n.call("get_rules_file_request", {})).toEqual({ ok: true, request: { request_id: enq.request_id, op: "read" } });
|
|
198
|
+
} finally { await u.close(); await n.close(); }
|
|
199
|
+
}, 20_000);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("duplicate alias — config update pull/ack", () => {
|
|
203
|
+
function queueUpdate(nodeId: string, updateId: string) {
|
|
204
|
+
db.run(
|
|
205
|
+
`INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token) VALUES (?1, ?2, ?3, '{"model":"m"}', 'hot', 0, 'pending', ?4, 'tok_rules_dup_user')`,
|
|
206
|
+
[updateId, nodeId, NET, Date.now()],
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
test("the live node's bound token pulls and acks its own pending update", async () => {
|
|
211
|
+
seedWorld();
|
|
212
|
+
queueUpdate(LIVE_ID, "cu_rules_dup_live");
|
|
213
|
+
const n = await connect(asLive);
|
|
214
|
+
try {
|
|
215
|
+
const pulled = await n.call("get_config_update", {});
|
|
216
|
+
expect(pulled.update?.update_id).toBe("cu_rules_dup_live");
|
|
217
|
+
const ack = await n.call("ack_config_update", { update_id: "cu_rules_dup_live", status: "applied", new_revision: 1 });
|
|
218
|
+
expect(ack.ok).toBe(true);
|
|
219
|
+
expect(ack.ignored).toBeUndefined();
|
|
220
|
+
expect(db.get<{ status: string }>("SELECT status FROM node_config_updates WHERE update_id = 'cu_rules_dup_live'")?.status).toBe("applied");
|
|
221
|
+
} finally { await n.close(); }
|
|
222
|
+
}, 20_000);
|
|
223
|
+
|
|
224
|
+
test("a token bound to the stale row cannot pull or ack the live node's update", async () => {
|
|
225
|
+
seedWorld();
|
|
226
|
+
queueUpdate(LIVE_ID, "cu_rules_dup_live2");
|
|
227
|
+
const stale = await connect(asStale);
|
|
228
|
+
try {
|
|
229
|
+
expect((await stale.call("get_config_update", {})).update).toBeNull();
|
|
230
|
+
await stale.call("ack_config_update", { update_id: "cu_rules_dup_live2", status: "applied", new_revision: 1 });
|
|
231
|
+
expect(db.get<{ status: string }>("SELECT status FROM node_config_updates WHERE update_id = 'cu_rules_dup_live2'")?.status).toBe("pending");
|
|
232
|
+
} finally { await stale.close(); }
|
|
233
|
+
}, 20_000);
|
|
234
|
+
});
|