@sleep2agi/commhub-server 0.9.0-preview.0 → 0.9.0-preview.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/ack-create-request.test.ts +331 -0
- package/src/api-nodes-shape.test.ts +172 -0
- package/src/auth-kdf-migration.test.ts +378 -0
- package/src/auth-tokens.test.ts +18 -9
- package/src/auth.ts +65 -5
- package/src/config-apply-sec1.test.ts +723 -0
- package/src/config-apply-validate.test.ts +260 -0
- package/src/config-apply-validate.ts +171 -0
- package/src/create-node-validate.test.ts +187 -0
- package/src/create-node-validate.ts +206 -0
- package/src/create-node.test.ts +354 -0
- package/src/create-node.ts +346 -0
- package/src/cross-tenant-injection.test.ts +110 -0
- package/src/db.ts +499 -8
- package/src/index.ts +247 -19
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/retention.test.ts +450 -0
- package/src/retention.ts +269 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/shared/reserved-env-drift.test.ts +43 -0
- package/src/shared/reserved-env.test.ts +35 -0
- package/src/shared/reserved-env.ts +31 -0
- package/src/stale-sweeper.test.ts +181 -0
- package/src/stale-sweeper.ts +114 -0
- package/src/stop-delete-node.test.ts +720 -0
- package/src/tools.ts +2285 -64
- package/src/update-provider.test.ts +234 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// RFC-026 v4 §4.2.2 (F2 structured) + §4.4.7 (C1+B1 env_refs) + §4.2.6
|
|
2
|
+
// (B2 fork wrapper) — pure validators + arg builder. No DB access. Used
|
|
3
|
+
// by hub create_node MCP tool (RPC entry) AND by daemon get_create_request
|
|
4
|
+
// (re-check, defense in depth).
|
|
5
|
+
//
|
|
6
|
+
// All validation errors throw ValidationError with a stable `code` field
|
|
7
|
+
// that the MCP tool surface maps to a JSON {ok:false, error: <code>}
|
|
8
|
+
// payload (RFC §4.6).
|
|
9
|
+
|
|
10
|
+
import { isReservedEnvKey } from "./shared/reserved-env.js";
|
|
11
|
+
|
|
12
|
+
export class ValidationError extends Error {
|
|
13
|
+
constructor(public code: string, public detail?: Record<string, unknown>) {
|
|
14
|
+
super(`${code}${detail ? ` ${JSON.stringify(detail)}` : ""}`);
|
|
15
|
+
this.name = "ValidationError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// §4.2.2 — structural enums.
|
|
20
|
+
export const RUNTIMES = ["claude-agent-sdk", "codex-sdk", "grok-build-acp"] as const;
|
|
21
|
+
export type Runtime = typeof RUNTIMES[number];
|
|
22
|
+
|
|
23
|
+
export const FLAG_KEYS = ["permissionMode", "dangerouslySkipPermissions", "maxTurns", "budget", "timeout"] as const;
|
|
24
|
+
export type FlagKey = typeof FLAG_KEYS[number];
|
|
25
|
+
|
|
26
|
+
export const PERMISSION_MODES = ["default", "acceptEdits", "plan", "bypassPermissions"] as const;
|
|
27
|
+
|
|
28
|
+
// §4.4.7 — env_refs limits.
|
|
29
|
+
export const MAX_ENV_KEYS_PER_NODE = 32;
|
|
30
|
+
export const MAX_ENV_VALUE_BYTES = 16 * 1024;
|
|
31
|
+
const ENV_KEY_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
32
|
+
|
|
33
|
+
// §4.2.2 name + model.
|
|
34
|
+
const NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
35
|
+
const MODEL_RE = /^[a-zA-Z0-9._:\-]+$/;
|
|
36
|
+
|
|
37
|
+
export function validateName(s: unknown): asserts s is string {
|
|
38
|
+
if (typeof s !== "string" || !NAME_RE.test(s)) {
|
|
39
|
+
throw new ValidationError("node_name_invalid", { value: typeof s === "string" ? s.slice(0, 80) : typeof s });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function validateRuntime(s: unknown): asserts s is Runtime {
|
|
44
|
+
if (typeof s !== "string" || !(RUNTIMES as readonly string[]).includes(s)) {
|
|
45
|
+
throw new ValidationError("runtime_invalid", { value: typeof s === "string" ? s.slice(0, 80) : typeof s });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function validateModel(s: unknown): asserts s is string {
|
|
50
|
+
if (typeof s !== "string" || s.length === 0 || s.length > 100 || !MODEL_RE.test(s)) {
|
|
51
|
+
throw new ValidationError("model_invalid", { value: typeof s === "string" ? s.slice(0, 80) : typeof s });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function validateFlagValue(k: string, v: unknown): void {
|
|
56
|
+
switch (k) {
|
|
57
|
+
case "permissionMode":
|
|
58
|
+
if (typeof v !== "string" || !(PERMISSION_MODES as readonly string[]).includes(v)) {
|
|
59
|
+
throw new ValidationError("flag_value_invalid", { field: k, reason: "must be one of default/acceptEdits/plan/bypassPermissions" });
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
case "dangerouslySkipPermissions":
|
|
63
|
+
if (typeof v !== "boolean") throw new ValidationError("flag_value_invalid", { field: k, reason: "must be boolean" });
|
|
64
|
+
return;
|
|
65
|
+
case "maxTurns":
|
|
66
|
+
if (!Number.isInteger(v) || (v as number) < 1 || (v as number) > 9999) {
|
|
67
|
+
throw new ValidationError("flag_value_invalid", { field: k, reason: "must be integer 1..9999" });
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
case "budget":
|
|
71
|
+
if (typeof v !== "number" || !Number.isFinite(v) || (v as number) < 0 || (v as number) > 1000) {
|
|
72
|
+
throw new ValidationError("flag_value_invalid", { field: k, reason: "must be number 0..1000 (decimals OK)" });
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
case "timeout":
|
|
76
|
+
if (!Number.isInteger(v) || (v as number) < 1 || (v as number) > 86400) {
|
|
77
|
+
throw new ValidationError("flag_value_invalid", { field: k, reason: "must be integer 1..86400" });
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
default:
|
|
81
|
+
throw new ValidationError("flag_key_unknown", { field: k });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// §4.4.7 — env_refs 7-step gate. networkSecretsGet is injected so
|
|
86
|
+
// callers can supply either a real DB lookup (hub) or a fixture (tests).
|
|
87
|
+
// The 6th step also reports the resolved value size; we return the
|
|
88
|
+
// resolved-but-not-yet-used map keyed by env-key so the caller can build
|
|
89
|
+
// env_blob without re-querying.
|
|
90
|
+
export interface EnvRefsContext {
|
|
91
|
+
callerNetworkId: string;
|
|
92
|
+
daemonAllowList: ReadonlySet<string>;
|
|
93
|
+
networkSecretsGet: (networkId: string, key: string) => string | undefined;
|
|
94
|
+
}
|
|
95
|
+
export function validateEnvRefs(
|
|
96
|
+
refs: unknown,
|
|
97
|
+
ctx: EnvRefsContext,
|
|
98
|
+
): Record<string, string> {
|
|
99
|
+
if (!Array.isArray(refs)) {
|
|
100
|
+
if (refs === undefined || refs === null) return {};
|
|
101
|
+
throw new ValidationError("env_refs_invalid", { reason: "must be array" });
|
|
102
|
+
}
|
|
103
|
+
// ① regex
|
|
104
|
+
for (const k of refs) {
|
|
105
|
+
if (typeof k !== "string" || !ENV_KEY_RE.test(k)) {
|
|
106
|
+
throw new ValidationError("env_key_invalid", { key: typeof k === "string" ? k.slice(0, 64) : typeof k });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// ② reserved denylist (v4 B1)
|
|
110
|
+
for (const k of refs) {
|
|
111
|
+
if (isReservedEnvKey(k)) {
|
|
112
|
+
throw new ValidationError("env_key_reserved", { key: k });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// ③ duplicate detection (must come before ④ count gate to give a
|
|
116
|
+
// truthful error code rather than "too many" for dup-spam).
|
|
117
|
+
const seen = new Set<string>();
|
|
118
|
+
for (const k of refs as string[]) {
|
|
119
|
+
if (seen.has(k)) throw new ValidationError("env_key_duplicate", { key: k });
|
|
120
|
+
seen.add(k);
|
|
121
|
+
}
|
|
122
|
+
// ④ count cap
|
|
123
|
+
if (seen.size > MAX_ENV_KEYS_PER_NODE) {
|
|
124
|
+
throw new ValidationError("env_key_too_many", { count: seen.size, max: MAX_ENV_KEYS_PER_NODE });
|
|
125
|
+
}
|
|
126
|
+
const out: Record<string, string> = {};
|
|
127
|
+
// ⑤ vault presence + ⑥ value size cap
|
|
128
|
+
for (const k of seen) {
|
|
129
|
+
const v = ctx.networkSecretsGet(ctx.callerNetworkId, k);
|
|
130
|
+
if (v === undefined) throw new ValidationError("secret_not_in_vault", { key: k });
|
|
131
|
+
if (Buffer.byteLength(v, "utf8") > MAX_ENV_VALUE_BYTES) {
|
|
132
|
+
throw new ValidationError("secret_too_large", { key: k, max: MAX_ENV_VALUE_BYTES });
|
|
133
|
+
}
|
|
134
|
+
out[k] = v;
|
|
135
|
+
}
|
|
136
|
+
// ⑦ daemon allowlist (per-host whitelist of which secrets this
|
|
137
|
+
// machine is allowed to receive; declared in daemon config by the
|
|
138
|
+
// host operator)
|
|
139
|
+
for (const k of seen) {
|
|
140
|
+
if (!ctx.daemonAllowList.has(k)) {
|
|
141
|
+
throw new ValidationError("secret_not_in_daemon_allowlist", { key: k });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// §4.4.7 — safe serializer for .env.local. Dotenv-quoted with escape
|
|
148
|
+
// for backslash / double-quote / newline / carriage-return so that a
|
|
149
|
+
// value containing `\n"evil=KEY2"` cannot pollute the next line.
|
|
150
|
+
export function serializeEnvLocal(env: Record<string, string>): string {
|
|
151
|
+
return Object.entries(env).map(([k, v]) => {
|
|
152
|
+
const esc = String(v)
|
|
153
|
+
.replace(/\\/g, "\\\\") // 1) backslashes first (or later escapes get un-escaped)
|
|
154
|
+
.replace(/"/g, '\\"') // 2) double-quotes
|
|
155
|
+
.replace(/\n/g, "\\n") // 3) newline → literal \n
|
|
156
|
+
.replace(/\r/g, "\\r"); // 4) carriage return → literal \r
|
|
157
|
+
return `${k}="${esc}"`;
|
|
158
|
+
}).join("\n") + "\n";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// §4.2.5 — channels fail-closed in P1.
|
|
162
|
+
export function validateChannelsP1(channels: unknown): void {
|
|
163
|
+
if (channels === undefined || channels === null) return;
|
|
164
|
+
if (!Array.isArray(channels)) {
|
|
165
|
+
throw new ValidationError("channels_not_supported_in_p1", { reason: "expected array or omitted" });
|
|
166
|
+
}
|
|
167
|
+
if (channels.length > 0) {
|
|
168
|
+
throw new ValidationError("channels_not_supported_in_p1", { received: channels.length, p3_tracker: "RFC-026 §5 P3" });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// §4.2.2 — fully validated NodeSpec, ready to feed into buildAnetArgs.
|
|
173
|
+
export interface NodeSpec {
|
|
174
|
+
name: string;
|
|
175
|
+
runtime: Runtime;
|
|
176
|
+
model: string;
|
|
177
|
+
flags?: Record<string, unknown>;
|
|
178
|
+
channels?: unknown;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// kebab-case helper for flag names. permissionMode → permission-mode.
|
|
182
|
+
function kebab(k: string): string {
|
|
183
|
+
return k.replace(/([A-Z])/g, "-$1").toLowerCase();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// §4.2.2 — build the argv that gets fed verbatim to execFile (no
|
|
187
|
+
// shell, no string concat). Each value has already passed type/enum
|
|
188
|
+
// validation, so String() coercion is safe.
|
|
189
|
+
export function buildAnetArgs(spec: NodeSpec): string[] {
|
|
190
|
+
validateName(spec.name);
|
|
191
|
+
validateRuntime(spec.runtime);
|
|
192
|
+
validateModel(spec.model);
|
|
193
|
+
validateChannelsP1(spec.channels);
|
|
194
|
+
|
|
195
|
+
const args: string[] = ["node", "create", spec.name, "--runtime", spec.runtime, "--model", spec.model];
|
|
196
|
+
if (spec.flags && typeof spec.flags === "object") {
|
|
197
|
+
for (const [k, v] of Object.entries(spec.flags)) {
|
|
198
|
+
if (!(FLAG_KEYS as readonly string[]).includes(k)) {
|
|
199
|
+
throw new ValidationError("flag_key_unknown", { field: k });
|
|
200
|
+
}
|
|
201
|
+
validateFlagValue(k, v);
|
|
202
|
+
args.push(`--${kebab(k)}`, String(v));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return args;
|
|
206
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// RFC-026 v4 P1 — unit tests for state + sweeper (covers H + J live-
|
|
2
|
+
// test deferment per 通信牛 PR #299 review). These exercise the same
|
|
3
|
+
// pure functions the daemon-facing MCP tools call into; the e2e
|
|
4
|
+
// scenarios H/J would only re-verify these same code paths through
|
|
5
|
+
// multi-daemon or crash-sim plumbing, which is Phase 3 work.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test, beforeEach, afterAll } from "bun:test";
|
|
8
|
+
import { db } from "./db.js";
|
|
9
|
+
import {
|
|
10
|
+
putPendingEnvBlob,
|
|
11
|
+
takePendingEnvBlob,
|
|
12
|
+
peekPendingEnvBlob,
|
|
13
|
+
runOrphanSweepOnce,
|
|
14
|
+
evictExpired,
|
|
15
|
+
finalizeCreateOnFirstRegister,
|
|
16
|
+
newRequestId,
|
|
17
|
+
stopBackgroundTimersForTest,
|
|
18
|
+
resolveCallerDaemonTokenBound,
|
|
19
|
+
} from "./create-node.js";
|
|
20
|
+
import { hashToken } from "./db.js";
|
|
21
|
+
|
|
22
|
+
// ── helpers ────────────────────────────────────────────────────────
|
|
23
|
+
function freshFixtures() {
|
|
24
|
+
// Wipe RFC-026 state between tests. Other suites use the same DB;
|
|
25
|
+
// we only delete from tables RFC-026 owns.
|
|
26
|
+
db.run("DELETE FROM node_create_requests");
|
|
27
|
+
db.run("DELETE FROM api_tokens WHERE role = 'child' OR role = 'test_child' OR (request_id IS NOT NULL AND request_id LIKE 'cr_test_%')");
|
|
28
|
+
stopBackgroundTimersForTest();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function insertRequestRow(
|
|
32
|
+
request_id: string,
|
|
33
|
+
daemon_node_id: string,
|
|
34
|
+
child_name: string,
|
|
35
|
+
status: "pending" | "delivered" | "succeeded" | "failed" | "expired",
|
|
36
|
+
created_at_ms: number,
|
|
37
|
+
child_token_id: string | null = null,
|
|
38
|
+
network_id: string = "test_net",
|
|
39
|
+
) {
|
|
40
|
+
db.run(
|
|
41
|
+
`INSERT INTO node_create_requests
|
|
42
|
+
(request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
|
|
43
|
+
VALUES (?1, ?2, ?3, ?4, 'claude-agent-sdk', 'x', '{}', '[]', ?5, ?6, ?7, 'tok_test')`,
|
|
44
|
+
[request_id, daemon_node_id, child_name, network_id, status, child_token_id, created_at_ms],
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function insertChildTokenRow(token_id: string, request_id: string) {
|
|
49
|
+
db.run(
|
|
50
|
+
`INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, role, request_id)
|
|
51
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'child', ?7)`,
|
|
52
|
+
[token_id, `hash_${token_id}`, "u_test_creator", "test_net", `node:test_child_${token_id}`, "network", request_id],
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── tests ──────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
beforeEach(() => freshFixtures());
|
|
59
|
+
afterAll(() => stopBackgroundTimersForTest());
|
|
60
|
+
|
|
61
|
+
describe("§4.4 F1 mint-stream-evict — pendingEnvBlobs Map (covers H C2)", () => {
|
|
62
|
+
test("takePendingEnvBlob with WRONG daemon_node_id returns null (C2 cross-daemon guard)", () => {
|
|
63
|
+
const id = newRequestId();
|
|
64
|
+
putPendingEnvBlob({
|
|
65
|
+
request_id: id,
|
|
66
|
+
daemon_node_id: "node_daemon_A",
|
|
67
|
+
env_blob: { ANTHROPIC_API_KEY: "secret-A" },
|
|
68
|
+
child_token: "ntok_test_child_A",
|
|
69
|
+
child_token_id: "tok_test_child_A",
|
|
70
|
+
});
|
|
71
|
+
// daemonB attempts to take daemonA's request → null
|
|
72
|
+
expect(takePendingEnvBlob(id, "node_daemon_B")).toBeNull();
|
|
73
|
+
// But the entry IS NOT consumed by the wrong-caller attempt — daemonA
|
|
74
|
+
// can still take it. (Otherwise an attacker could DoS legit dispatch
|
|
75
|
+
// by spamming wrong-daemon takes.)
|
|
76
|
+
const blob = takePendingEnvBlob(id, "node_daemon_A");
|
|
77
|
+
expect(blob).not.toBeNull();
|
|
78
|
+
expect(blob!.env_blob).toEqual({ ANTHROPIC_API_KEY: "secret-A" });
|
|
79
|
+
expect(blob!.child_token).toBe("ntok_test_child_A");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("takePendingEnvBlob with RIGHT daemon_node_id evicts immediately (F1 one-shot consume)", () => {
|
|
83
|
+
const id = newRequestId();
|
|
84
|
+
putPendingEnvBlob({
|
|
85
|
+
request_id: id,
|
|
86
|
+
daemon_node_id: "node_daemon_X",
|
|
87
|
+
env_blob: { K1: "v1" },
|
|
88
|
+
child_token: "ntok_x",
|
|
89
|
+
child_token_id: "tok_x",
|
|
90
|
+
});
|
|
91
|
+
expect(peekPendingEnvBlob(id)).not.toBeNull();
|
|
92
|
+
expect(takePendingEnvBlob(id, "node_daemon_X")).not.toBeNull();
|
|
93
|
+
// Second take is null — entry was evicted on first successful take
|
|
94
|
+
expect(takePendingEnvBlob(id, "node_daemon_X")).toBeNull();
|
|
95
|
+
expect(peekPendingEnvBlob(id)).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("takePendingEnvBlob with unknown id returns null", () => {
|
|
99
|
+
expect(takePendingEnvBlob("cr_never_existed", "node_anything")).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("evictExpired drops entries past TTL", () => {
|
|
103
|
+
const id = newRequestId();
|
|
104
|
+
putPendingEnvBlob({
|
|
105
|
+
request_id: id,
|
|
106
|
+
daemon_node_id: "node_z",
|
|
107
|
+
env_blob: {},
|
|
108
|
+
child_token: "ntok_z",
|
|
109
|
+
child_token_id: "tok_z",
|
|
110
|
+
});
|
|
111
|
+
// Fast-forward past 60s TTL
|
|
112
|
+
const past = Date.now() + 60_001;
|
|
113
|
+
expect(evictExpired(past)).toBeGreaterThan(0);
|
|
114
|
+
expect(takePendingEnvBlob(id, "node_z")).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("§4.4.8 C4 orphan sweeper — runOrphanSweepOnce (covers J F-1 + F-2)", () => {
|
|
119
|
+
test("F-1: status='pending' age > TTL → revoke child-ntok + mark status='failed'", () => {
|
|
120
|
+
const reqId = `cr_test_${Date.now()}_F1`;
|
|
121
|
+
const tokId = `tok_test_${Date.now()}_F1`;
|
|
122
|
+
const longAgo = Date.now() - 90_000; // 90s past — well > 60s TTL
|
|
123
|
+
insertChildTokenRow(tokId, reqId);
|
|
124
|
+
insertRequestRow(reqId, "node_daemon_F1", "child_F1", "pending", longAgo, tokId);
|
|
125
|
+
const result = runOrphanSweepOnce();
|
|
126
|
+
expect(result.swept).toBeGreaterThanOrEqual(1);
|
|
127
|
+
expect(result.revoked).toBeGreaterThanOrEqual(1);
|
|
128
|
+
// Verify api_tokens.revoked_at is now set
|
|
129
|
+
const tokRow = db.get<{ revoked_at: string | null }>(
|
|
130
|
+
"SELECT revoked_at FROM api_tokens WHERE token_id = ?1", tokId,
|
|
131
|
+
);
|
|
132
|
+
expect(tokRow?.revoked_at).not.toBeNull();
|
|
133
|
+
// Verify request status flipped to 'failed'
|
|
134
|
+
const reqRow = db.get<{ status: string; error: string | null }>(
|
|
135
|
+
"SELECT status, error FROM node_create_requests WHERE request_id = ?1", reqId,
|
|
136
|
+
);
|
|
137
|
+
expect(reqRow?.status).toBe("failed");
|
|
138
|
+
expect(reqRow?.error).toContain("sweeper_revoked_before_delivery");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("F-2: status='delivered' age > TTL → revoke child-ntok + mark status='expired'", () => {
|
|
142
|
+
const reqId = `cr_test_${Date.now()}_F2`;
|
|
143
|
+
const tokId = `tok_test_${Date.now()}_F2`;
|
|
144
|
+
const longAgo = Date.now() - 120_000; // 120s past
|
|
145
|
+
insertChildTokenRow(tokId, reqId);
|
|
146
|
+
insertRequestRow(reqId, "node_daemon_F2", "child_F2", "delivered", longAgo, tokId);
|
|
147
|
+
const result = runOrphanSweepOnce();
|
|
148
|
+
expect(result.swept).toBeGreaterThanOrEqual(1);
|
|
149
|
+
expect(result.revoked).toBeGreaterThanOrEqual(1);
|
|
150
|
+
const tokRow = db.get<{ revoked_at: string | null }>(
|
|
151
|
+
"SELECT revoked_at FROM api_tokens WHERE token_id = ?1", tokId,
|
|
152
|
+
);
|
|
153
|
+
expect(tokRow?.revoked_at).not.toBeNull();
|
|
154
|
+
const reqRow = db.get<{ status: string; error: string | null }>(
|
|
155
|
+
"SELECT status, error FROM node_create_requests WHERE request_id = ?1", reqId,
|
|
156
|
+
);
|
|
157
|
+
expect(reqRow?.status).toBe("expired");
|
|
158
|
+
expect(reqRow?.error).toContain("sweeper_revoked_after_delivery_no_ack");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("does NOT sweep recent pending/delivered rows (within TTL)", () => {
|
|
162
|
+
const reqId = `cr_test_${Date.now()}_fresh`;
|
|
163
|
+
const tokId = `tok_test_${Date.now()}_fresh`;
|
|
164
|
+
insertChildTokenRow(tokId, reqId);
|
|
165
|
+
// created 5s ago — well within 60s TTL
|
|
166
|
+
insertRequestRow(reqId, "node_daemon_fresh", "child_fresh", "pending", Date.now() - 5_000, tokId);
|
|
167
|
+
const result = runOrphanSweepOnce();
|
|
168
|
+
expect(result.swept).toBe(0);
|
|
169
|
+
expect(result.revoked).toBe(0);
|
|
170
|
+
const tokRow = db.get<{ revoked_at: string | null }>(
|
|
171
|
+
"SELECT revoked_at FROM api_tokens WHERE token_id = ?1", tokId,
|
|
172
|
+
);
|
|
173
|
+
expect(tokRow?.revoked_at).toBeNull();
|
|
174
|
+
const reqRow = db.get<{ status: string }>(
|
|
175
|
+
"SELECT status FROM node_create_requests WHERE request_id = ?1", reqId,
|
|
176
|
+
);
|
|
177
|
+
expect(reqRow?.status).toBe("pending");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("does NOT sweep terminal rows (succeeded/failed/expired)", () => {
|
|
181
|
+
const reqId = `cr_test_${Date.now()}_terminal`;
|
|
182
|
+
const tokId = `tok_test_${Date.now()}_terminal`;
|
|
183
|
+
insertChildTokenRow(tokId, reqId);
|
|
184
|
+
insertRequestRow(reqId, "node_daemon_t", "child_t", "succeeded", Date.now() - 120_000, tokId);
|
|
185
|
+
const result = runOrphanSweepOnce();
|
|
186
|
+
expect(result.swept).toBe(0);
|
|
187
|
+
expect(result.revoked).toBe(0);
|
|
188
|
+
const tokRow = db.get<{ revoked_at: string | null }>(
|
|
189
|
+
"SELECT revoked_at FROM api_tokens WHERE token_id = ?1", tokId,
|
|
190
|
+
);
|
|
191
|
+
expect(tokRow?.revoked_at).toBeNull();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("sweeper is idempotent — second run on same stale row is a no-op", () => {
|
|
195
|
+
const reqId = `cr_test_${Date.now()}_idem`;
|
|
196
|
+
const tokId = `tok_test_${Date.now()}_idem`;
|
|
197
|
+
insertChildTokenRow(tokId, reqId);
|
|
198
|
+
insertRequestRow(reqId, "node_daemon_i", "child_i", "pending", Date.now() - 120_000, tokId);
|
|
199
|
+
const r1 = runOrphanSweepOnce();
|
|
200
|
+
expect(r1.swept).toBe(1);
|
|
201
|
+
const r2 = runOrphanSweepOnce();
|
|
202
|
+
expect(r2.swept).toBe(0); // already terminal after first sweep
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("§4.1.4 C2 token-bound daemon resolution regression (PR #299 BLOCKER #1)", () => {
|
|
207
|
+
// Calls the REAL resolveCallerDaemonTokenBound helper exported from
|
|
208
|
+
// create-node.ts — same code path the daemon-facing MCP tools take
|
|
209
|
+
// (per 通信龙 PR #299 nit 1, no inline-mirror SQL: anti-pattern that
|
|
210
|
+
// would let helper-drift slip past). Setup: two daemons same alias
|
|
211
|
+
// in different networks; two ntoks bound to each; confirm each
|
|
212
|
+
// ntok's resolution returns ITS OWN node row (not the other).
|
|
213
|
+
test("two daemons same alias different networks: helper resolves token to correct node", () => {
|
|
214
|
+
db.run("DELETE FROM nodes WHERE alias = 'daemon-shared'");
|
|
215
|
+
db.run("DELETE FROM api_tokens WHERE token_id IN ('tok_test_A', 'tok_test_B')");
|
|
216
|
+
// daemonA in netA
|
|
217
|
+
db.run(
|
|
218
|
+
`INSERT INTO nodes (node_id, node_name, alias, runtime, model, network_id, updated_at)
|
|
219
|
+
VALUES ('node_test_daemonA', 'daemon-shared', 'daemon-shared', 'claude-agent-sdk', 'm', 'net_test_A', datetime('now'))`,
|
|
220
|
+
);
|
|
221
|
+
// daemonB in netB — SAME alias
|
|
222
|
+
db.run(
|
|
223
|
+
`INSERT INTO nodes (node_id, node_name, alias, runtime, model, network_id, updated_at)
|
|
224
|
+
VALUES ('node_test_daemonB', 'daemon-shared', 'daemon-shared', 'claude-agent-sdk', 'm', 'net_test_B', datetime('now'))`,
|
|
225
|
+
);
|
|
226
|
+
// ntok rows bound to each daemon (name='node:daemon-shared'; network_id distinguishes)
|
|
227
|
+
db.run(
|
|
228
|
+
`INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope)
|
|
229
|
+
VALUES ('tok_test_A', ?1, 'u_test_creator', 'net_test_A', 'node:daemon-shared', 'network')`,
|
|
230
|
+
[hashToken("ntok_test_A")],
|
|
231
|
+
);
|
|
232
|
+
db.run(
|
|
233
|
+
`INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope)
|
|
234
|
+
VALUES ('tok_test_B', ?1, 'u_test_creator', 'net_test_B', 'node:daemon-shared', 'network')`,
|
|
235
|
+
[hashToken("ntok_test_B")],
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
// ntok A → must resolve to node_test_daemonA (NOT daemonB)
|
|
239
|
+
const resA = resolveCallerDaemonTokenBound({
|
|
240
|
+
callerTokenIsNetwork: true,
|
|
241
|
+
callerTokenId: "tok_test_A",
|
|
242
|
+
enforceNetworkId: "net_test_A",
|
|
243
|
+
});
|
|
244
|
+
expect(resA.ok).toBe(true);
|
|
245
|
+
if (resA.ok) {
|
|
246
|
+
expect(resA.daemonNodeId).toBe("node_test_daemonA");
|
|
247
|
+
expect(resA.networkId).toBe("net_test_A");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ntok B → must resolve to node_test_daemonB
|
|
251
|
+
const resB = resolveCallerDaemonTokenBound({
|
|
252
|
+
callerTokenIsNetwork: true,
|
|
253
|
+
callerTokenId: "tok_test_B",
|
|
254
|
+
enforceNetworkId: "net_test_B",
|
|
255
|
+
});
|
|
256
|
+
expect(resB.ok).toBe(true);
|
|
257
|
+
if (resB.ok) expect(resB.daemonNodeId).toBe("node_test_daemonB");
|
|
258
|
+
|
|
259
|
+
// Pre-blocker buggy form (alias only) would be ambiguous —
|
|
260
|
+
// confirm there really ARE 2 rows so the SQL was un-scoped before.
|
|
261
|
+
const all = db.all<{ node_id: string }>(
|
|
262
|
+
`SELECT node_id FROM nodes WHERE alias = ?1`, "daemon-shared",
|
|
263
|
+
);
|
|
264
|
+
expect(all.length).toBe(2);
|
|
265
|
+
|
|
266
|
+
// Mismatched scope: tok_test_A presented with enforceNetworkId=B
|
|
267
|
+
// (compromised caller trying to pivot to wrong net) → reject.
|
|
268
|
+
const resCross = resolveCallerDaemonTokenBound({
|
|
269
|
+
callerTokenIsNetwork: true,
|
|
270
|
+
callerTokenId: "tok_test_A",
|
|
271
|
+
enforceNetworkId: "net_test_B",
|
|
272
|
+
});
|
|
273
|
+
expect(resCross.ok).toBe(false);
|
|
274
|
+
|
|
275
|
+
// utok (callerTokenIsNetwork=false) → reject as not-a-daemon
|
|
276
|
+
const resUtok = resolveCallerDaemonTokenBound({
|
|
277
|
+
callerTokenIsNetwork: false,
|
|
278
|
+
callerTokenId: "tok_test_A",
|
|
279
|
+
enforceNetworkId: "net_test_A",
|
|
280
|
+
});
|
|
281
|
+
expect(resUtok.ok).toBe(false);
|
|
282
|
+
|
|
283
|
+
// Revoked token → reject
|
|
284
|
+
db.run("UPDATE api_tokens SET revoked_at = datetime('now') WHERE token_id = 'tok_test_A'");
|
|
285
|
+
const resRevoked = resolveCallerDaemonTokenBound({
|
|
286
|
+
callerTokenIsNetwork: true,
|
|
287
|
+
callerTokenId: "tok_test_A",
|
|
288
|
+
enforceNetworkId: "net_test_A",
|
|
289
|
+
});
|
|
290
|
+
expect(resRevoked.ok).toBe(false);
|
|
291
|
+
|
|
292
|
+
db.run("DELETE FROM nodes WHERE node_id IN ('node_test_daemonA', 'node_test_daemonB')");
|
|
293
|
+
db.run("DELETE FROM api_tokens WHERE token_id IN ('tok_test_A', 'tok_test_B')");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("daemon B cannot take/ack daemon A's request even with same alias (Map-level guard)", () => {
|
|
297
|
+
const id = newRequestId();
|
|
298
|
+
putPendingEnvBlob({
|
|
299
|
+
request_id: id,
|
|
300
|
+
daemon_node_id: "node_legit_daemonA",
|
|
301
|
+
env_blob: { ANTHROPIC_API_KEY: "s" },
|
|
302
|
+
child_token: "ntok_a",
|
|
303
|
+
child_token_id: "tok_a",
|
|
304
|
+
});
|
|
305
|
+
// daemonB-token resolves to node_id = node_attacker (different node_id)
|
|
306
|
+
expect(takePendingEnvBlob(id, "node_attacker")).toBeNull();
|
|
307
|
+
// Original entry still there for daemonA
|
|
308
|
+
expect(takePendingEnvBlob(id, "node_legit_daemonA")).not.toBeNull();
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe("finalizeCreateOnFirstRegister — content-match on child register", () => {
|
|
313
|
+
test("matching child_name + network_id flips status='succeeded' + stamps child_node_id", () => {
|
|
314
|
+
const reqId = `cr_test_${Date.now()}_finalize`;
|
|
315
|
+
insertRequestRow(reqId, "node_daemon_F", "child_finalize_test", "delivered", Date.now() - 1_000, null, "test_net_finalize");
|
|
316
|
+
const r = finalizeCreateOnFirstRegister({
|
|
317
|
+
node_id: "node_child_real_F",
|
|
318
|
+
alias: "child_finalize_test",
|
|
319
|
+
network_id: "test_net_finalize",
|
|
320
|
+
});
|
|
321
|
+
expect(r.finalized).toBe(1);
|
|
322
|
+
const row = db.get<{ status: string; child_node_id: string | null }>(
|
|
323
|
+
"SELECT status, child_node_id FROM node_create_requests WHERE request_id = ?1", reqId,
|
|
324
|
+
);
|
|
325
|
+
expect(row?.status).toBe("succeeded");
|
|
326
|
+
expect(row?.child_node_id).toBe("node_child_real_F");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("non-matching alias is a no-op", () => {
|
|
330
|
+
const reqId = `cr_test_${Date.now()}_nomatch`;
|
|
331
|
+
insertRequestRow(reqId, "node_daemon_nm", "expected_alias", "pending", Date.now(), null, "test_net_nm");
|
|
332
|
+
const r = finalizeCreateOnFirstRegister({
|
|
333
|
+
node_id: "node_intruder",
|
|
334
|
+
alias: "different_alias",
|
|
335
|
+
network_id: "test_net_nm",
|
|
336
|
+
});
|
|
337
|
+
expect(r.finalized).toBe(0);
|
|
338
|
+
const row = db.get<{ status: string }>(
|
|
339
|
+
"SELECT status FROM node_create_requests WHERE request_id = ?1", reqId,
|
|
340
|
+
);
|
|
341
|
+
expect(row?.status).toBe("pending");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("cross-network: same child_name in different network is a no-op (network_id mismatch)", () => {
|
|
345
|
+
const reqId = `cr_test_${Date.now()}_crossnet`;
|
|
346
|
+
insertRequestRow(reqId, "node_daemon_xn", "shared_name", "delivered", Date.now(), null, "test_net_A");
|
|
347
|
+
const r = finalizeCreateOnFirstRegister({
|
|
348
|
+
node_id: "node_evil",
|
|
349
|
+
alias: "shared_name",
|
|
350
|
+
network_id: "test_net_B", // different network
|
|
351
|
+
});
|
|
352
|
+
expect(r.finalized).toBe(0);
|
|
353
|
+
});
|
|
354
|
+
});
|