@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,331 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { registerTools } from "./tools.js";
|
|
5
|
+
|
|
6
|
+
// RFC-026 §9.3 D2 / #338 PR3 — ack_create_request HANDLER tests.
|
|
7
|
+
//
|
|
8
|
+
// 通信龙 pre-review caught B1 + B2: my first cut of PR3 added a new daemon
|
|
9
|
+
// ack status `runtime_capability_check_failed` to create-node-daemon.ts but
|
|
10
|
+
// the hub's zod enum only accepted ["started", "failed", "rejected"] — the
|
|
11
|
+
// new status was silently zod-rejected on the hub side and the daemon's
|
|
12
|
+
// `.catch(()=>{})` swallowed the rejection. The PR body also claimed
|
|
13
|
+
// `daemon_capability_lied` audit was written when in fact the action wasn't
|
|
14
|
+
// in the audit enum and no insert ran.
|
|
15
|
+
//
|
|
16
|
+
// This handler-driven suite captures and invokes the registered callback
|
|
17
|
+
// directly. It validates handler branches and DB effects, but deliberately
|
|
18
|
+
// does NOT claim to exercise the MCP SDK's zod transport gate. The
|
|
19
|
+
// complementary in-process transport coverage lives in
|
|
20
|
+
// ack-create-request-transport.test.ts (#344).
|
|
21
|
+
|
|
22
|
+
const NET = "net_ack_t";
|
|
23
|
+
const DAEMON_NODE_ID = "node_ack_daemon";
|
|
24
|
+
const DAEMON_ALIAS = "ack-daemon";
|
|
25
|
+
const DAEMON_USER = "u_ack_owner";
|
|
26
|
+
const DAEMON_TOKEN_ID = "tok_ack_daemon";
|
|
27
|
+
const DAEMON_TOKEN_HASH = "hash_ack_daemon";
|
|
28
|
+
|
|
29
|
+
interface ToolHandler { (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>; }
|
|
30
|
+
interface AckReply { ok?: boolean; error?: string; status?: string; }
|
|
31
|
+
|
|
32
|
+
function cleanup() {
|
|
33
|
+
try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [NET]); } catch {}
|
|
34
|
+
try { db.run("DELETE FROM node_create_requests 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
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [DAEMON_USER]); } catch {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
beforeEach(cleanup);
|
|
43
|
+
afterAll(cleanup);
|
|
44
|
+
|
|
45
|
+
function seedDaemonWorld() {
|
|
46
|
+
db.run(
|
|
47
|
+
`INSERT INTO users (user_id, username, password_hash, role, created_at)
|
|
48
|
+
VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
|
|
49
|
+
[DAEMON_USER, DAEMON_USER],
|
|
50
|
+
);
|
|
51
|
+
db.run(
|
|
52
|
+
`INSERT OR REPLACE INTO networks (network_id, network_name, owner_id, created_at)
|
|
53
|
+
VALUES (?1, ?2, ?3, datetime('now'))`,
|
|
54
|
+
[NET, NET, DAEMON_USER],
|
|
55
|
+
);
|
|
56
|
+
db.run(
|
|
57
|
+
`INSERT INTO network_members (user_id, network_id, role, joined_at)
|
|
58
|
+
VALUES (?1, ?2, 'owner', datetime('now'))`,
|
|
59
|
+
[DAEMON_USER, NET],
|
|
60
|
+
);
|
|
61
|
+
// daemon node — must exist for resolveCallerDaemonTokenBound to bind.
|
|
62
|
+
db.run(
|
|
63
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at)
|
|
64
|
+
VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
|
|
65
|
+
[DAEMON_NODE_ID, DAEMON_ALIAS, DAEMON_ALIAS, NET],
|
|
66
|
+
);
|
|
67
|
+
// daemon ntok — bound to the daemon node via name='node:<alias>'.
|
|
68
|
+
db.run(
|
|
69
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
|
|
70
|
+
VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
|
|
71
|
+
[DAEMON_TOKEN_ID, DAEMON_USER, NET, `node:${DAEMON_ALIAS}`, DAEMON_TOKEN_HASH],
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function seedCreateRequest(opts: {
|
|
76
|
+
request_id: string,
|
|
77
|
+
child_token_id?: string,
|
|
78
|
+
status?: "pending" | "delivered" | "succeeded" | "failed",
|
|
79
|
+
child_name?: string,
|
|
80
|
+
runtime?: string,
|
|
81
|
+
}) {
|
|
82
|
+
const childName = opts.child_name ?? `child_${opts.request_id}`;
|
|
83
|
+
if (opts.child_token_id) {
|
|
84
|
+
db.run(
|
|
85
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
|
|
86
|
+
VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
|
|
87
|
+
[opts.child_token_id, DAEMON_USER, NET, `node:${childName}`, `hash_${opts.child_token_id}`],
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
db.run(
|
|
91
|
+
`INSERT INTO node_create_requests
|
|
92
|
+
(request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
|
|
93
|
+
VALUES (?1, ?2, ?3, ?4, ?5, 'x', '{}', '[]', ?6, ?7, ?8, ?9)`,
|
|
94
|
+
[
|
|
95
|
+
opts.request_id, DAEMON_NODE_ID, childName, NET,
|
|
96
|
+
opts.runtime ?? "claude-agent-sdk",
|
|
97
|
+
opts.status ?? "delivered",
|
|
98
|
+
opts.child_token_id ?? null,
|
|
99
|
+
Date.now(),
|
|
100
|
+
DAEMON_TOKEN_ID,
|
|
101
|
+
],
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Build a server with registerTools wired as if the daemon's ntok bound. */
|
|
106
|
+
function buildAckHandler(): ToolHandler {
|
|
107
|
+
const server = new McpServer({ name: "test", version: "0" }) as any;
|
|
108
|
+
const tools: Record<string, ToolHandler> = {};
|
|
109
|
+
const origTool = server.tool.bind(server);
|
|
110
|
+
server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => {
|
|
111
|
+
tools[name] = handler;
|
|
112
|
+
return origTool(name, _desc, _schema, handler);
|
|
113
|
+
};
|
|
114
|
+
const origRegisterTool = server.registerTool?.bind(server);
|
|
115
|
+
if (origRegisterTool) {
|
|
116
|
+
server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => {
|
|
117
|
+
tools[name] = handler;
|
|
118
|
+
return origRegisterTool(name, _cfg, handler);
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
registerTools(
|
|
122
|
+
server,
|
|
123
|
+
/* clientIP */ undefined,
|
|
124
|
+
/* enforceNetworkId */ NET,
|
|
125
|
+
/* enforceUserId */ DAEMON_USER,
|
|
126
|
+
/* callerAlias */ DAEMON_ALIAS,
|
|
127
|
+
/* callerTokenIsNetwork */ true,
|
|
128
|
+
/* callerTokenId */ DAEMON_TOKEN_ID,
|
|
129
|
+
);
|
|
130
|
+
const h = tools["ack_create_request"];
|
|
131
|
+
if (!h) throw new Error("ack_create_request tool not registered");
|
|
132
|
+
return h;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function callAck(handler: ToolHandler, args: any): Promise<AckReply> {
|
|
136
|
+
const r = await handler(args);
|
|
137
|
+
return JSON.parse(r.content[0].text) as AckReply;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readRequest(request_id: string) {
|
|
141
|
+
return db.get<{ status: string; error: string | null; acked_at: number | null }>(
|
|
142
|
+
`SELECT status, error, acked_at FROM node_create_requests WHERE request_id = ?1`,
|
|
143
|
+
request_id,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function readToken(token_id: string) {
|
|
148
|
+
return db.get<{ revoked_at: string | null }>(
|
|
149
|
+
`SELECT revoked_at FROM api_tokens WHERE token_id = ?1`,
|
|
150
|
+
token_id,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readAuditRows(action: string, target_id: string) {
|
|
155
|
+
return db.all<{ action: string; network_id: string; target_id: string; detail: string }>(
|
|
156
|
+
`SELECT action, network_id, target_id, detail FROM audit_log
|
|
157
|
+
WHERE action = ?1 AND target_id = ?2 ORDER BY id DESC`,
|
|
158
|
+
[action, target_id],
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
describe("ack_create_request HANDLER — B1 schema accepts runtime_capability_check_failed", () => {
|
|
163
|
+
test("schema accepts runtime_capability_check_failed + runtime field (no zod reject)", async () => {
|
|
164
|
+
seedDaemonWorld();
|
|
165
|
+
const reqId = "cr_ack_b1_accept";
|
|
166
|
+
seedCreateRequest({ request_id: reqId });
|
|
167
|
+
const handler = buildAckHandler();
|
|
168
|
+
const reply = await callAck(handler, {
|
|
169
|
+
request_id: reqId,
|
|
170
|
+
status: "runtime_capability_check_failed",
|
|
171
|
+
error: "child died within 5000ms post-spawn",
|
|
172
|
+
runtime: "codex-sdk",
|
|
173
|
+
});
|
|
174
|
+
// PR3 v1 BUG: hub zod enum rejected this status → reply.ok=false /
|
|
175
|
+
// tool would even throw before getting here. Now must accept.
|
|
176
|
+
expect(reply.ok).toBe(true);
|
|
177
|
+
expect(reply.status).toBe("runtime_capability_check_failed");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("request flips to terminal runtime_capability_check_failed status", async () => {
|
|
181
|
+
seedDaemonWorld();
|
|
182
|
+
const reqId = "cr_ack_b1_terminal";
|
|
183
|
+
seedCreateRequest({ request_id: reqId, status: "delivered" });
|
|
184
|
+
const handler = buildAckHandler();
|
|
185
|
+
await callAck(handler, {
|
|
186
|
+
request_id: reqId,
|
|
187
|
+
status: "runtime_capability_check_failed",
|
|
188
|
+
error: "boom",
|
|
189
|
+
runtime: "codex-sdk",
|
|
190
|
+
});
|
|
191
|
+
const row = readRequest(reqId);
|
|
192
|
+
expect(row?.status).toBe("runtime_capability_check_failed");
|
|
193
|
+
expect(row?.error).toBe("boom");
|
|
194
|
+
expect(row?.acked_at).toBeGreaterThan(0);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("child ntok is revoked on capability-check-failed (same teardown as 'failed')", async () => {
|
|
198
|
+
seedDaemonWorld();
|
|
199
|
+
const reqId = "cr_ack_b1_revoke";
|
|
200
|
+
const childTok = "tok_ack_child_b1";
|
|
201
|
+
seedCreateRequest({ request_id: reqId, child_token_id: childTok });
|
|
202
|
+
expect(readToken(childTok)?.revoked_at).toBeNull();
|
|
203
|
+
const handler = buildAckHandler();
|
|
204
|
+
await callAck(handler, {
|
|
205
|
+
request_id: reqId,
|
|
206
|
+
status: "runtime_capability_check_failed",
|
|
207
|
+
runtime: "codex-sdk",
|
|
208
|
+
});
|
|
209
|
+
expect(readToken(childTok)?.revoked_at).not.toBeNull();
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("ack_create_request HANDLER — B2 audit_log daemon_capability_lied", () => {
|
|
214
|
+
test("writes daemon_capability_lied audit row with runtime + daemon + error in detail", async () => {
|
|
215
|
+
seedDaemonWorld();
|
|
216
|
+
const reqId = "cr_ack_b2_audit";
|
|
217
|
+
seedCreateRequest({ request_id: reqId });
|
|
218
|
+
const handler = buildAckHandler();
|
|
219
|
+
await callAck(handler, {
|
|
220
|
+
request_id: reqId,
|
|
221
|
+
status: "runtime_capability_check_failed",
|
|
222
|
+
error: "OPENAI_API_KEY unset",
|
|
223
|
+
runtime: "codex-sdk",
|
|
224
|
+
});
|
|
225
|
+
const rows = readAuditRows("daemon_capability_lied", reqId);
|
|
226
|
+
// PR3 v1 BUG: action wasn't in auditCreateNode enum → 0 rows + no
|
|
227
|
+
// surface for ops to spot lying daemons. Now must write exactly 1.
|
|
228
|
+
expect(rows.length).toBe(1);
|
|
229
|
+
expect(rows[0].network_id).toBe(NET);
|
|
230
|
+
const detail = JSON.parse(rows[0].detail);
|
|
231
|
+
expect(detail.runtime).toBe("codex-sdk");
|
|
232
|
+
expect(detail.daemon_node_id).toBe(DAEMON_NODE_ID);
|
|
233
|
+
expect(detail.error).toBe("OPENAI_API_KEY unset");
|
|
234
|
+
expect(typeof detail.acked_at).toBe("number");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("does NOT write daemon_capability_lied on generic 'failed' ack (status discriminates)", async () => {
|
|
238
|
+
seedDaemonWorld();
|
|
239
|
+
const reqId = "cr_ack_b2_no_audit_on_failed";
|
|
240
|
+
seedCreateRequest({ request_id: reqId });
|
|
241
|
+
const handler = buildAckHandler();
|
|
242
|
+
await callAck(handler, {
|
|
243
|
+
request_id: reqId,
|
|
244
|
+
status: "failed",
|
|
245
|
+
error: "child died immediately after spawn",
|
|
246
|
+
});
|
|
247
|
+
const rows = readAuditRows("daemon_capability_lied", reqId);
|
|
248
|
+
expect(rows.length).toBe(0);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("does NOT write daemon_capability_lied on 'started' (happy path silence)", async () => {
|
|
252
|
+
seedDaemonWorld();
|
|
253
|
+
const reqId = "cr_ack_b2_no_audit_on_started";
|
|
254
|
+
seedCreateRequest({ request_id: reqId });
|
|
255
|
+
const handler = buildAckHandler();
|
|
256
|
+
await callAck(handler, { request_id: reqId, status: "started", child_pid: 12345 });
|
|
257
|
+
const rows = readAuditRows("daemon_capability_lied", reqId);
|
|
258
|
+
expect(rows.length).toBe(0);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("runtime field nullable if daemon omitted it (older daemon back-compat)", async () => {
|
|
262
|
+
// Older daemon (preview.10 and earlier) wouldn't send `runtime`.
|
|
263
|
+
// The hub must still accept + audit, just with runtime=null.
|
|
264
|
+
seedDaemonWorld();
|
|
265
|
+
const reqId = "cr_ack_b2_nullable_runtime";
|
|
266
|
+
seedCreateRequest({ request_id: reqId });
|
|
267
|
+
const handler = buildAckHandler();
|
|
268
|
+
const reply = await callAck(handler, {
|
|
269
|
+
request_id: reqId,
|
|
270
|
+
status: "runtime_capability_check_failed",
|
|
271
|
+
error: "child died",
|
|
272
|
+
});
|
|
273
|
+
expect(reply.ok).toBe(true);
|
|
274
|
+
const rows = readAuditRows("daemon_capability_lied", reqId);
|
|
275
|
+
expect(rows.length).toBe(1);
|
|
276
|
+
const detail = JSON.parse(rows[0].detail);
|
|
277
|
+
expect(detail.runtime).toBeNull();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("error string is truncated to 500 chars in audit detail (defense vs giant payload)", async () => {
|
|
281
|
+
seedDaemonWorld();
|
|
282
|
+
const reqId = "cr_ack_b2_trunc";
|
|
283
|
+
seedCreateRequest({ request_id: reqId });
|
|
284
|
+
const handler = buildAckHandler();
|
|
285
|
+
const giant = "x".repeat(900);
|
|
286
|
+
await callAck(handler, {
|
|
287
|
+
request_id: reqId,
|
|
288
|
+
status: "runtime_capability_check_failed",
|
|
289
|
+
error: giant,
|
|
290
|
+
runtime: "codex-sdk",
|
|
291
|
+
});
|
|
292
|
+
const rows = readAuditRows("daemon_capability_lied", reqId);
|
|
293
|
+
expect(rows.length).toBe(1);
|
|
294
|
+
const detail = JSON.parse(rows[0].detail);
|
|
295
|
+
expect(detail.error.length).toBe(500);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("ack_create_request HANDLER — pre-existing happy-path stays green (regression)", () => {
|
|
300
|
+
test("status='started' still flips acked_at without terminal teardown", async () => {
|
|
301
|
+
seedDaemonWorld();
|
|
302
|
+
const reqId = "cr_ack_started_regression";
|
|
303
|
+
const childTok = "tok_ack_child_started";
|
|
304
|
+
seedCreateRequest({ request_id: reqId, child_token_id: childTok });
|
|
305
|
+
const handler = buildAckHandler();
|
|
306
|
+
const reply = await callAck(handler, { request_id: reqId, status: "started", child_pid: 7777 });
|
|
307
|
+
expect(reply.ok).toBe(true);
|
|
308
|
+
expect(reply.status).toBe("awaiting_register");
|
|
309
|
+
const row = readRequest(reqId);
|
|
310
|
+
// 'started' doesn't flip to terminal; status stays 'delivered'.
|
|
311
|
+
expect(row?.status).toBe("delivered");
|
|
312
|
+
expect(row?.acked_at).toBeGreaterThan(0);
|
|
313
|
+
// child ntok is NOT revoked on 'started'.
|
|
314
|
+
expect(readToken(childTok)?.revoked_at).toBeNull();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("status='failed' still revokes child + flips to failed (no audit_log daemon_capability_lied)", async () => {
|
|
318
|
+
seedDaemonWorld();
|
|
319
|
+
const reqId = "cr_ack_failed_regression";
|
|
320
|
+
const childTok = "tok_ack_child_failed";
|
|
321
|
+
seedCreateRequest({ request_id: reqId, child_token_id: childTok });
|
|
322
|
+
const handler = buildAckHandler();
|
|
323
|
+
const reply = await callAck(handler, { request_id: reqId, status: "failed", error: "fork failed" });
|
|
324
|
+
expect(reply.ok).toBe(true);
|
|
325
|
+
const row = readRequest(reqId);
|
|
326
|
+
expect(row?.status).toBe("failed");
|
|
327
|
+
expect(row?.error).toBe("fork failed");
|
|
328
|
+
expect(readToken(childTok)?.revoked_at).not.toBeNull();
|
|
329
|
+
expect(readAuditRows("daemon_capability_lied", reqId).length).toBe(0);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { NETWORK_REST_COLUMNS } from "./rest-projections.js";
|
|
6
|
+
|
|
7
|
+
const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-admin-networks-"));
|
|
8
|
+
process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
|
|
9
|
+
|
|
10
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
11
|
+
let base = "";
|
|
12
|
+
let adminToken = "";
|
|
13
|
+
let memberToken = "";
|
|
14
|
+
let nodeToken = "";
|
|
15
|
+
let adminNetworkId = "";
|
|
16
|
+
let memberNetworkId = "";
|
|
17
|
+
|
|
18
|
+
async function listNetworks(token: string): Promise<any[]> {
|
|
19
|
+
const response = await fetch(`${base}/api/networks`, {
|
|
20
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
21
|
+
});
|
|
22
|
+
expect(response.status).toBe(200);
|
|
23
|
+
const body = await response.json() as any;
|
|
24
|
+
expect(body.ok).toBe(true);
|
|
25
|
+
return body.networks;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
29
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
30
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
31
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
const { db } = await import("./db.js");
|
|
34
|
+
const { createNetworkTokenForNode, register } = await import("./auth.js");
|
|
35
|
+
|
|
36
|
+
// The first registered user is the global Hub admin. The second user owns a
|
|
37
|
+
// separate network and is intentionally not a member of the admin's network.
|
|
38
|
+
const admin = register(`admin_networks_${Date.now()}`, "AdminNetworks-Strong-1!", undefined, "admin");
|
|
39
|
+
expect(admin.ok).toBe(true);
|
|
40
|
+
adminToken = admin.token!;
|
|
41
|
+
adminNetworkId = admin.network_id!;
|
|
42
|
+
|
|
43
|
+
const member = register(`member_networks_${Date.now()}`, "MemberNetworks-Strong-1!", undefined, "member");
|
|
44
|
+
expect(member.ok).toBe(true);
|
|
45
|
+
memberToken = member.token!;
|
|
46
|
+
memberNetworkId = member.network_id!;
|
|
47
|
+
|
|
48
|
+
const memberUserId = db.get<{ user_id: string }>(
|
|
49
|
+
"SELECT user_id FROM users WHERE username LIKE 'member_networks_%' ORDER BY created_at DESC LIMIT 1",
|
|
50
|
+
)!.user_id;
|
|
51
|
+
const minted = createNetworkTokenForNode(memberUserId, memberNetworkId, "admin-networks-node");
|
|
52
|
+
expect(minted.ok).toBe(true);
|
|
53
|
+
nodeToken = minted.token!;
|
|
54
|
+
|
|
55
|
+
const mod = await import("./server.js");
|
|
56
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
57
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
58
|
+
}, 30_000);
|
|
59
|
+
|
|
60
|
+
afterAll(() => {
|
|
61
|
+
try { server?.stop(true); } catch {}
|
|
62
|
+
try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("GET /api/networks global-admin visibility (#94)", () => {
|
|
66
|
+
test("global admin utok sees every network without losing public shape", async () => {
|
|
67
|
+
const rows = await listNetworks(adminToken);
|
|
68
|
+
expect(new Set(rows.map((row) => row.network_id))).toEqual(new Set([adminNetworkId, memberNetworkId]));
|
|
69
|
+
|
|
70
|
+
const own = rows.find((row) => row.network_id === adminNetworkId);
|
|
71
|
+
const foreign = rows.find((row) => row.network_id === memberNetworkId);
|
|
72
|
+
expect(own.member_role).toBe("owner");
|
|
73
|
+
expect(foreign.member_role).toBe("admin");
|
|
74
|
+
expect(Object.keys(foreign).sort()).toEqual([...NETWORK_REST_COLUMNS, "member_role", "name"].sort());
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("ordinary utok remains limited to member networks", async () => {
|
|
78
|
+
const rows = await listNetworks(memberToken);
|
|
79
|
+
expect(rows.map((row) => row.network_id)).toEqual([memberNetworkId]);
|
|
80
|
+
expect(rows.some((row) => row.network_id === adminNetworkId)).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("network token remains limited to its bound network", async () => {
|
|
84
|
+
const rows = await listNetworks(nodeToken);
|
|
85
|
+
expect(rows.map((row) => row.network_id)).toEqual([memberNetworkId]);
|
|
86
|
+
expect(rows.some((row) => row.network_id === adminNetworkId)).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// 多用户账号与 Agent 权限 —— 「这个人在这个网络里能看见 / 能联系哪些 Agent」的唯一实现。
|
|
2
|
+
//
|
|
3
|
+
// 受限成员(restricted member):网络里 role 为 member/viewer、agent_access 不是 'all'、
|
|
4
|
+
// 且不是 Hub 管理员的用户。他只能看见 network_member_agent_grants 里授权给他的 Agent,
|
|
5
|
+
// 只能给其中 can_message=1 的发任务/消息;人类之间的私信不受影响。
|
|
6
|
+
//
|
|
7
|
+
// 判定是 fail-closed 的:agent_access 只有字面 'all' 才放开;role 只有 owner/admin 才豁免。
|
|
8
|
+
// 读到任何别的值(包括未来新增的角色)都按受限处理。
|
|
9
|
+
//
|
|
10
|
+
// 这里只回答「能不能」,不管「是不是成员」——非成员返回 false(不受限),
|
|
11
|
+
// 成员资格仍由调用方原有的 getUserNetworkRole 判定。两件事分开,别合并成一个判据。
|
|
12
|
+
|
|
13
|
+
import { db } from "./db.js";
|
|
14
|
+
|
|
15
|
+
const UNRESTRICTED_ROLES = new Set(["owner", "admin"]);
|
|
16
|
+
|
|
17
|
+
export type AgentAccessMode = "all" | "granted";
|
|
18
|
+
|
|
19
|
+
export type AgentGrant = {
|
|
20
|
+
node_id: string | null;
|
|
21
|
+
alias: string | null;
|
|
22
|
+
can_message: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** 某受限成员在某网络里能看见 / 能联系的 Agent,已经展开成 alias 与 node_id 两份清单。 */
|
|
26
|
+
export type VisibleAgents = {
|
|
27
|
+
aliases: string[];
|
|
28
|
+
nodeIds: string[];
|
|
29
|
+
messageAliases: string[];
|
|
30
|
+
messageNodeIds: string[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function isHubAdmin(userId: string): boolean {
|
|
34
|
+
return db.get<{ role: string | null }>("SELECT role FROM users WHERE user_id = ?1", userId)?.role === "admin";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function restrictedRow(row: { member_role: string | null; agent_access: string | null; user_role: string | null } | null | undefined): boolean {
|
|
38
|
+
if (!row) return false;
|
|
39
|
+
if (row.user_role === "admin") return false;
|
|
40
|
+
if (UNRESTRICTED_ROLES.has(String(row.member_role))) return false;
|
|
41
|
+
return row.agent_access !== "all";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isAgentRestricted(userId: string, networkId: string | null | undefined): boolean {
|
|
45
|
+
if (!userId || !networkId) return false;
|
|
46
|
+
const row = db.get<{ member_role: string | null; agent_access: string | null; user_role: string | null }>(
|
|
47
|
+
`SELECT nm.role AS member_role, nm.agent_access, u.role AS user_role
|
|
48
|
+
FROM network_members nm JOIN users u ON u.user_id = nm.user_id
|
|
49
|
+
WHERE nm.network_id = ?1 AND nm.user_id = ?2`,
|
|
50
|
+
networkId, userId,
|
|
51
|
+
);
|
|
52
|
+
return restrictedRow(row);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 该用户所有「只看授权 Agent」的网络。Hub 管理员恒为空。 */
|
|
56
|
+
export function restrictedNetworkIds(userId: string): string[] {
|
|
57
|
+
if (!userId) return [];
|
|
58
|
+
return db.all<{ network_id: string; member_role: string | null; agent_access: string | null; user_role: string | null }>(
|
|
59
|
+
`SELECT nm.network_id, nm.role AS member_role, nm.agent_access, u.role AS user_role
|
|
60
|
+
FROM network_members nm
|
|
61
|
+
JOIN users u ON u.user_id = nm.user_id
|
|
62
|
+
JOIN networks n ON n.network_id = nm.network_id
|
|
63
|
+
WHERE nm.user_id = ?1`,
|
|
64
|
+
userId,
|
|
65
|
+
).filter(restrictedRow).map((row) => row.network_id);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getAgentAccessMode(networkId: string, userId: string): AgentAccessMode | null {
|
|
69
|
+
const row = db.get<{ agent_access: string | null }>(
|
|
70
|
+
"SELECT agent_access FROM network_members WHERE network_id = ?1 AND user_id = ?2",
|
|
71
|
+
networkId, userId,
|
|
72
|
+
);
|
|
73
|
+
if (!row) return null;
|
|
74
|
+
return row.agent_access === "all" ? "all" : "granted";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function listAgentGrants(networkId: string, userId: string): AgentGrant[] {
|
|
78
|
+
return db.all<{ node_id: string | null; alias: string | null; can_message: number }>(
|
|
79
|
+
`SELECT node_id, alias, can_message FROM network_member_agent_grants
|
|
80
|
+
WHERE network_id = ?1 AND user_id = ?2
|
|
81
|
+
ORDER BY COALESCE(node_id, alias)`,
|
|
82
|
+
networkId, userId,
|
|
83
|
+
).map((row) => ({ node_id: row.node_id, alias: row.alias, can_message: row.can_message === 1 }));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 把授权展开成 alias / node_id 两份清单。
|
|
88
|
+
* node_id 授权同时带出:nodes 表里这个节点的 alias、sessions 表里同 node_id 的会话 alias
|
|
89
|
+
* (改名后 sessions 先于 nodes 更新的窗口里两者可能不同,两个都算)。都限定在同一网络。
|
|
90
|
+
*/
|
|
91
|
+
export function visibleAgents(userId: string, networkId: string): VisibleAgents {
|
|
92
|
+
const aliases = new Set<string>();
|
|
93
|
+
const nodeIds = new Set<string>();
|
|
94
|
+
const messageAliases = new Set<string>();
|
|
95
|
+
const messageNodeIds = new Set<string>();
|
|
96
|
+
for (const grant of listAgentGrants(networkId, userId)) {
|
|
97
|
+
const grantAliases: string[] = [];
|
|
98
|
+
if (grant.node_id) {
|
|
99
|
+
nodeIds.add(grant.node_id);
|
|
100
|
+
if (grant.can_message) messageNodeIds.add(grant.node_id);
|
|
101
|
+
for (const row of db.all<{ alias: string | null }>(
|
|
102
|
+
`SELECT alias FROM nodes WHERE node_id = ?1 AND network_id = ?2
|
|
103
|
+
UNION SELECT alias FROM sessions WHERE node_id = ?1 AND network_id = ?2`,
|
|
104
|
+
grant.node_id, networkId,
|
|
105
|
+
)) {
|
|
106
|
+
if (row.alias) grantAliases.push(row.alias);
|
|
107
|
+
}
|
|
108
|
+
} else if (grant.alias) {
|
|
109
|
+
grantAliases.push(grant.alias);
|
|
110
|
+
}
|
|
111
|
+
for (const alias of grantAliases) {
|
|
112
|
+
aliases.add(alias);
|
|
113
|
+
if (grant.can_message) messageAliases.add(alias);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
aliases: [...aliases],
|
|
118
|
+
nodeIds: [...nodeIds],
|
|
119
|
+
messageAliases: [...messageAliases],
|
|
120
|
+
messageNodeIds: [...messageNodeIds],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 这个用户名在该网络里是否同时是某个 Agent 的 alias(sessions 或 nodes)。 */
|
|
125
|
+
export function usernameIsAgentAlias(networkId: string, username: string): boolean {
|
|
126
|
+
if (!username) return false;
|
|
127
|
+
return !!db.get(
|
|
128
|
+
`SELECT 1 WHERE EXISTS (SELECT 1 FROM sessions WHERE network_id = ?1 AND alias = ?2)
|
|
129
|
+
OR EXISTS (SELECT 1 FROM nodes WHERE network_id = ?1 AND alias = ?2)`,
|
|
130
|
+
networkId, username,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type AgentRef = { alias?: string | null; nodeId?: string | null };
|
|
135
|
+
|
|
136
|
+
function refMatches(ref: AgentRef, aliases: string[], nodeIds: string[]): boolean {
|
|
137
|
+
if (ref.nodeId && nodeIds.includes(ref.nodeId)) return true;
|
|
138
|
+
if (ref.alias && aliases.includes(ref.alias)) return true;
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 不受限的成员恒为 true;受限成员只对授权的 Agent 为 true。不判断成员资格。 */
|
|
143
|
+
export function canSeeAgent(userId: string, networkId: string | null | undefined, ref: AgentRef): boolean {
|
|
144
|
+
if (!networkId || !isAgentRestricted(userId, networkId)) return true;
|
|
145
|
+
const visible = visibleAgents(userId, networkId);
|
|
146
|
+
return refMatches(ref, visible.aliases, visible.nodeIds);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function canMessageAgent(userId: string, networkId: string | null | undefined, ref: AgentRef): boolean {
|
|
150
|
+
if (!networkId || !isAgentRestricted(userId, networkId)) return true;
|
|
151
|
+
const visible = visibleAgents(userId, networkId);
|
|
152
|
+
return refMatches(ref, visible.messageAliases, visible.messageNodeIds);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type AgentGrantInput = { node_id?: unknown; alias?: unknown; can_message?: unknown };
|
|
156
|
+
|
|
157
|
+
export type ReplaceGrantsResult =
|
|
158
|
+
| { ok: true; agent_access: AgentAccessMode; grants: AgentGrant[] }
|
|
159
|
+
| { ok: false; error: string; status: number; detail?: unknown };
|
|
160
|
+
|
|
161
|
+
const MAX_GRANTS = 1000;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 整体替换某成员的授权(PUT 语义)。node_id 必须是本网络里存在的节点;alias 只用于没有
|
|
165
|
+
* node_id 的旧会话,也必须是本网络里存在的会话。任何一条不合法 ⇒ 整批拒绝,不做部分写入。
|
|
166
|
+
*/
|
|
167
|
+
export function replaceAgentGrants(input: {
|
|
168
|
+
networkId: string;
|
|
169
|
+
userId: string;
|
|
170
|
+
grants: unknown;
|
|
171
|
+
agentAccess?: unknown;
|
|
172
|
+
actorUserId: string;
|
|
173
|
+
}): ReplaceGrantsResult {
|
|
174
|
+
const member = db.get<{ role: string }>(
|
|
175
|
+
"SELECT role FROM network_members WHERE network_id = ?1 AND user_id = ?2",
|
|
176
|
+
input.networkId, input.userId,
|
|
177
|
+
);
|
|
178
|
+
if (!member) return { ok: false, error: "member_not_found", status: 404 };
|
|
179
|
+
|
|
180
|
+
let mode: AgentAccessMode | undefined;
|
|
181
|
+
if (input.agentAccess !== undefined) {
|
|
182
|
+
if (input.agentAccess !== "all" && input.agentAccess !== "granted") {
|
|
183
|
+
return { ok: false, error: "invalid_agent_access", status: 400 };
|
|
184
|
+
}
|
|
185
|
+
mode = input.agentAccess;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let rows: Array<{ node_id: string | null; alias: string | null; can_message: number }> | undefined;
|
|
189
|
+
if (input.grants !== undefined) {
|
|
190
|
+
if (!Array.isArray(input.grants)) return { ok: false, error: "grants_must_be_array", status: 400 };
|
|
191
|
+
if (input.grants.length > MAX_GRANTS) return { ok: false, error: "too_many_grants", status: 400, detail: { limit: MAX_GRANTS } };
|
|
192
|
+
const seen = new Set<string>();
|
|
193
|
+
rows = [];
|
|
194
|
+
for (const [index, raw] of input.grants.entries()) {
|
|
195
|
+
// 兼容直接传 node_id 字符串数组。
|
|
196
|
+
const item: AgentGrantInput = typeof raw === "string" ? { node_id: raw } : (raw && typeof raw === "object" && !Array.isArray(raw) ? raw as AgentGrantInput : {});
|
|
197
|
+
const nodeId = typeof item.node_id === "string" && item.node_id.trim() ? item.node_id.trim() : null;
|
|
198
|
+
const alias = typeof item.alias === "string" && item.alias.trim() ? item.alias.trim() : null;
|
|
199
|
+
if ((nodeId === null) === (alias === null)) {
|
|
200
|
+
return { ok: false, error: "grant_needs_exactly_one_of_node_id_or_alias", status: 400, detail: { index } };
|
|
201
|
+
}
|
|
202
|
+
if (item.can_message !== undefined && typeof item.can_message !== "boolean") {
|
|
203
|
+
return { ok: false, error: "can_message_must_be_boolean", status: 400, detail: { index } };
|
|
204
|
+
}
|
|
205
|
+
const exists = nodeId
|
|
206
|
+
? db.get("SELECT 1 FROM nodes WHERE node_id = ?1 AND network_id = ?2", nodeId, input.networkId)
|
|
207
|
+
: db.get("SELECT 1 FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, input.networkId);
|
|
208
|
+
if (!exists) {
|
|
209
|
+
return { ok: false, error: "agent_not_in_network", status: 400, detail: { index, ...(nodeId ? { node_id: nodeId } : { alias }) } };
|
|
210
|
+
}
|
|
211
|
+
const key = nodeId ? `n:${nodeId}` : `a:${alias}`;
|
|
212
|
+
if (seen.has(key)) continue;
|
|
213
|
+
seen.add(key);
|
|
214
|
+
rows.push({ node_id: nodeId, alias, can_message: item.can_message === false ? 0 : 1 });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
db.transaction(() => {
|
|
219
|
+
if (mode) {
|
|
220
|
+
db.run("UPDATE network_members SET agent_access = ?1 WHERE network_id = ?2 AND user_id = ?3", [mode, input.networkId, input.userId]);
|
|
221
|
+
}
|
|
222
|
+
if (rows) {
|
|
223
|
+
db.run("DELETE FROM network_member_agent_grants WHERE network_id = ?1 AND user_id = ?2", [input.networkId, input.userId]);
|
|
224
|
+
for (const row of rows) {
|
|
225
|
+
db.run(
|
|
226
|
+
`INSERT INTO network_member_agent_grants (network_id, user_id, node_id, alias, can_message, created_by)
|
|
227
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
|
|
228
|
+
[input.networkId, input.userId, row.node_id, row.alias, row.can_message, input.actorUserId],
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
ok: true,
|
|
236
|
+
agent_access: getAgentAccessMode(input.networkId, input.userId) ?? "granted",
|
|
237
|
+
grants: listAgentGrants(input.networkId, input.userId),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** 成员被移出网络时一并清掉授权,重新加入时从零开始。 */
|
|
242
|
+
export function deleteAgentGrants(networkId: string, userId: string): void {
|
|
243
|
+
db.run("DELETE FROM network_member_agent_grants WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 多用户 Agent 权限 —— 受限成员(只看授权 Agent 的成员)能调的 MCP 工具。每一个都逐条审过:
|
|
247
|
+
// 要么自己按授权过滤 / 准入(get_all_status、get_session_status、get_task、list_tasks、send_task、
|
|
248
|
+
// send_message),要么是人类侧(send_desktop_message、需求看板),要么只读且对受限网络 fail-closed
|
|
249
|
+
// (get_completions)。其余工具:受限成员只能在**不受限的**网络里调(必须显式传 network_id),
|
|
250
|
+
// 在受限网络里、或没说是哪个网络时一律拒绝 —— 新增的工具默认落在拒绝这边。
|
|
251
|
+
export const RESTRICTED_MEMBER_TOOLS: ReadonlySet<string> = new Set([
|
|
252
|
+
"get_all_status",
|
|
253
|
+
"get_session_status",
|
|
254
|
+
"get_task",
|
|
255
|
+
"list_tasks",
|
|
256
|
+
"get_completions",
|
|
257
|
+
"send_task",
|
|
258
|
+
"send_message",
|
|
259
|
+
"send_desktop_message",
|
|
260
|
+
"requirements_list",
|
|
261
|
+
"requirements_get",
|
|
262
|
+
"requirements_create",
|
|
263
|
+
"requirements_update",
|
|
264
|
+
"requirements_checklist_toggle",
|
|
265
|
+
"requirements_upsert_by_external_ref",
|
|
266
|
+
"projects_list",
|
|
267
|
+
]);
|