@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.131
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -10
- package/bin/commhub.ts +99 -11
- package/package.json +7 -7
- package/src/ack-create-request-transport.test.ts +182 -0
- package/src/ack-create-request.test.ts +50 -7
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/adoption-candidates-http.test.ts +137 -0
- package/src/adoption-candidates.ts +158 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +487 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +659 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +223 -0
- package/src/agent-notice.ts +48 -0
- package/src/agent-team-whoami-http.test.ts +98 -0
- package/src/agent-teams-http.test.ts +323 -0
- package/src/agent-teams.ts +244 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +42 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth-weak-password-login.test.ts +176 -0
- package/src/auth.ts +598 -49
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/binding-generation-http.test.ts +121 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/codex-config-flags.ts +77 -0
- package/src/codex-fork-contract.ts +23 -0
- package/src/codex-fork-migration.test.ts +38 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +134 -1
- package/src/config-apply-validate.ts +111 -5
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-network-id.test.ts +79 -0
- package/src/create-node-tool-schema.test.ts +48 -0
- package/src/create-node-unicode-name.test.ts +120 -0
- package/src/create-node-validate.test.ts +193 -7
- package/src/create-node-validate.ts +227 -15
- package/src/create-node-workdir.test.ts +289 -0
- package/src/create-node.ts +77 -23
- package/src/daemon-provider-snapshots.test.ts +421 -0
- package/src/daemon-provider-snapshots.ts +327 -0
- package/src/daemon-token-owner-http.test.ts +236 -0
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +933 -36
- package/src/delivered-without-push.test.ts +140 -0
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/department-heads-http.test.ts +330 -0
- package/src/department-heads.ts +226 -0
- package/src/departments-http.test.ts +185 -0
- package/src/departments.ts +260 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-provider-config.test.ts +139 -0
- package/src/hub-provider-config.ts +105 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-caller.ts +49 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2362
- package/src/lifecycle-guard.ts +1 -1
- package/src/mcp-context-cost-http.test.ts +123 -0
- package/src/mcp-projects-events-http.test.ts +162 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +432 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +210 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/model-auth-notify-http.test.ts +181 -0
- package/src/model-auth-notify.ts +116 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +276 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-alias-control.ts +12 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-caller-identity-http.test.ts +781 -0
- package/src/node-create-request-status-http.test.ts +105 -0
- package/src/node-daemon-bindings.test.ts +132 -0
- package/src/node-daemon-bindings.ts +130 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-codex-login.test.ts +120 -0
- package/src/node-health-dispatch-http.test.ts +279 -0
- package/src/node-health-guard.ts +105 -0
- package/src/node-health-passthrough.test.ts +115 -0
- package/src/node-health-store.ts +101 -0
- package/src/node-id-alias.test.ts +190 -0
- package/src/node-identity-conflict-http.test.ts +269 -0
- package/src/node-identity-conflict.ts +142 -0
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +53 -0
- package/src/node-lifecycle-read-http.test.ts +245 -0
- package/src/node-lifecycle-read.ts +121 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +285 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-ownership.ts +51 -0
- package/src/node-token-privilege-http.test.ts +158 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/opencode-v2-catalog.test.ts +268 -0
- package/src/opencode-v2-catalog.ts +352 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/project-dir-warn-clear-http.test.ts +132 -0
- package/src/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +429 -10
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +22 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-handover-capabilities-http.test.ts +124 -0
- package/src/report-status-host-schema.test.ts +235 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-due-reminders-http.test.ts +620 -0
- package/src/requirement-due-reminders.ts +416 -0
- package/src/requirement-events-http.test.ts +268 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +144 -0
- package/src/requirement-notify.ts +165 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-abandoned-http.test.ts +236 -0
- package/src/requirements-comment-http.test.ts +167 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-errors-mcp-http.test.ts +164 -0
- package/src/requirements-errors.ts +143 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-last-event-http.test.ts +349 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-list-slim-http.test.ts +319 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +259 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +145 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +167 -0
- package/src/requirements-status-participants-http.test.ts +222 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1806 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +222 -0
- package/src/rest-identity.test.ts +130 -0
- package/src/rest-identity.ts +139 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +76 -0
- package/src/retention.ts +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/runtime-readiness.ts +90 -0
- package/src/schedule-agent-mcp-http.test.ts +381 -0
- package/src/schedule-agent.ts +192 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-offline.test.ts +131 -0
- package/src/scheduled-offline.ts +66 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-stuck-http.test.ts +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +766 -0
- package/src/scoped-secrets.ts +124 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +5257 -0
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/session-token-ownership-http.test.ts +122 -0
- package/src/shared/codex-inventory.ts +41 -0
- package/src/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +52 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/node-name.test.ts +62 -0
- package/src/shared/node-name.ts +161 -0
- package/src/shared/provider-application.ts +29 -0
- package/src/shared/provider-collection.ts +14 -0
- package/src/shared/provider-initialization.ts +8 -0
- package/src/shared/reserved-env-drift.test.ts +10 -0
- package/src/shared/scoped-secret-contract.ts +5 -0
- package/src/shared/task-content-limit-drift.test.ts +28 -0
- package/src/shared/task-content-limit.ts +25 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +264 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-alias-resolver-http.test.ts +163 -0
- package/src/status-alias-resolver.ts +19 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +122 -0
- package/src/stop-delete-node.test.ts +509 -1
- package/src/task-access-http.test.ts +402 -0
- package/src/task-access.ts +420 -0
- package/src/task-auth-origin-http.test.ts +93 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +315 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-late-replies.ts +54 -0
- package/src/task-late-reply-http.test.ts +152 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +140 -0
- package/src/task-orphan-http.test.ts +287 -0
- package/src/task-orphan.ts +119 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +236 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-rejection-log-http.test.ts +160 -0
- package/src/task-rejection-log.ts +49 -0
- package/src/task-sender-http.test.ts +143 -0
- package/src/task-stale-open-http.test.ts +290 -0
- package/src/task-stale-open.ts +103 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tool-audience-http.test.ts +220 -0
- package/src/tool-audience.ts +118 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +3940 -325
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.ts +103 -6
- package/src/user-avatar-http.test.ts +74 -0
- package/src/user-avatar-migration.test.ts +25 -0
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Hub 管理员删别人的**空**网络(清理旧测试账号留下的个人网络)。
|
|
2
|
+
// 真 HTTP:走 DELETE /api/networks/:id,库和上传目录都是本文件私有的临时目录。
|
|
3
|
+
// bun test server/src/admin-delete-network-http.test.ts
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
5
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
const PRIVATE_DIR = mkdtempSync(join(tmpdir(), "anet-admin-delete-network-"));
|
|
10
|
+
process.env.COMMHUB_DB ||= join(PRIVATE_DIR, "hub.db");
|
|
11
|
+
process.env.COMMHUB_UPLOADS_DIR = join(PRIVATE_DIR, "uploads");
|
|
12
|
+
|
|
13
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
14
|
+
let base = "";
|
|
15
|
+
let db: typeof import("./db.js").db;
|
|
16
|
+
let auth: typeof import("./auth.js");
|
|
17
|
+
let adminToken = "";
|
|
18
|
+
let adminUserId = "";
|
|
19
|
+
let adminNetworkId = "";
|
|
20
|
+
let memberToken = "";
|
|
21
|
+
let memberUserId = "";
|
|
22
|
+
let memberNodeToken = "";
|
|
23
|
+
const stamp = Date.now();
|
|
24
|
+
|
|
25
|
+
function del(networkId: string, token: string) {
|
|
26
|
+
return fetch(`${base}/api/networks/${networkId}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` } });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function memberNetwork(name: string): string {
|
|
30
|
+
const created = auth.createNetwork(memberUserId, `${name}-${stamp}`);
|
|
31
|
+
expect(created.ok).toBe(true);
|
|
32
|
+
return created.network_id!;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function rowCount(table: string, networkId: string): number {
|
|
36
|
+
return db.get<{ cnt: number }>(`SELECT COUNT(*) AS cnt FROM ${table} WHERE network_id = ?1`, networkId)!.cnt;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 同 admin-networks-http.test.ts:register() 跑 KDF + bootServer,给 30s 而不是 bun 默认 5s。
|
|
40
|
+
beforeAll(async () => {
|
|
41
|
+
({ db } = await import("./db.js"));
|
|
42
|
+
auth = await import("./auth.js");
|
|
43
|
+
|
|
44
|
+
// 第一个注册的是 Hub 管理员;第二个是普通用户,不在管理员的网络里。
|
|
45
|
+
const admin = auth.register(`admin_delnet_${stamp}`, "AdminDelNet-Strong-1!", undefined, "admin");
|
|
46
|
+
expect(admin.ok).toBe(true);
|
|
47
|
+
adminToken = admin.token!;
|
|
48
|
+
adminNetworkId = admin.network_id!;
|
|
49
|
+
adminUserId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `admin_delnet_${stamp}`)!.user_id;
|
|
50
|
+
|
|
51
|
+
const member = auth.register(`member_delnet_${stamp}`, "MemberDelNet-Strong-1!", undefined, "member");
|
|
52
|
+
expect(member.ok).toBe(true);
|
|
53
|
+
memberToken = member.token!;
|
|
54
|
+
memberUserId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `member_delnet_${stamp}`)!.user_id;
|
|
55
|
+
expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", adminUserId)!.role).toBe("admin");
|
|
56
|
+
expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", memberUserId)!.role).not.toBe("admin");
|
|
57
|
+
// free 档只能拥有 2 个网络;本文件要给它建七八个。plan 只影响配额,不影响任何权限判定。
|
|
58
|
+
db.run("UPDATE users SET plan = 'pro' WHERE user_id = ?1", [memberUserId]);
|
|
59
|
+
|
|
60
|
+
const minted = auth.createNetworkTokenForNode(memberUserId, member.network_id!, "delnet-node");
|
|
61
|
+
expect(minted.ok).toBe(true);
|
|
62
|
+
memberNodeToken = minted.token!;
|
|
63
|
+
|
|
64
|
+
const mod = await import("./server.js");
|
|
65
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
66
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
67
|
+
}, 30_000);
|
|
68
|
+
|
|
69
|
+
afterAll(() => {
|
|
70
|
+
try { server?.stop(true); } catch {}
|
|
71
|
+
try { rmSync(PRIVATE_DIR, { recursive: true, force: true }); } catch {}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("DELETE /api/networks/:id — hub admin override for empty foreign networks", () => {
|
|
75
|
+
test("admin deletes an empty foreign network: 200, network + members + scoped tokens gone, audit has admin_override", async () => {
|
|
76
|
+
const netId = memberNetwork("empty");
|
|
77
|
+
const minted = auth.createNetworkTokenForNode(memberUserId, netId, "leftover-node");
|
|
78
|
+
expect(minted.ok).toBe(true);
|
|
79
|
+
expect(rowCount("network_members", netId)).toBe(1);
|
|
80
|
+
expect(rowCount("api_tokens", netId)).toBe(1);
|
|
81
|
+
|
|
82
|
+
const res = await del(netId, adminToken);
|
|
83
|
+
const body = await res.json() as any;
|
|
84
|
+
expect(res.status).toBe(200);
|
|
85
|
+
expect(body.ok).toBe(true);
|
|
86
|
+
expect(body.admin_override).toBe(true);
|
|
87
|
+
|
|
88
|
+
expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)).toBeFalsy();
|
|
89
|
+
expect(rowCount("network_members", netId)).toBe(0);
|
|
90
|
+
expect(rowCount("api_tokens", netId)).toBe(0);
|
|
91
|
+
// 被删网络的节点令牌不再能解析。
|
|
92
|
+
expect(auth.resolveToken(minted.token!)).toBeFalsy();
|
|
93
|
+
|
|
94
|
+
const audit = db.get<{ user_id: string; detail: string; network_id: string }>(
|
|
95
|
+
"SELECT user_id, detail, network_id FROM audit_log WHERE action = 'network_deleted' AND target_id = ?1", netId);
|
|
96
|
+
expect(audit).toBeTruthy();
|
|
97
|
+
expect(audit!.user_id).toBe(adminUserId);
|
|
98
|
+
const detail = JSON.parse(audit!.detail);
|
|
99
|
+
expect(detail.admin_override).toBe(true);
|
|
100
|
+
expect(detail.owner_id).toBe(memberUserId);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("admin is refused with 409 and counts when the network has a node and a session", async () => {
|
|
104
|
+
const netId = memberNetwork("busy");
|
|
105
|
+
db.run("INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?2, ?3, datetime('now'), datetime('now'))",
|
|
106
|
+
[`node-${stamp}`, `busy-node-${stamp}`, netId]);
|
|
107
|
+
db.run("INSERT INTO sessions (resume_id, alias, network_id) VALUES (?1, ?2, ?3)", [`resume-${stamp}`, `busy-node-${stamp}`, netId]);
|
|
108
|
+
|
|
109
|
+
const res = await del(netId, adminToken);
|
|
110
|
+
const body = await res.json() as any;
|
|
111
|
+
expect(res.status).toBe(409);
|
|
112
|
+
expect(body.ok).toBe(false);
|
|
113
|
+
expect(body.counts).toEqual({ nodes: 1, sessions: 1 });
|
|
114
|
+
expect(body.error).toContain("nodes=1");
|
|
115
|
+
expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)).toBeTruthy();
|
|
116
|
+
expect(rowCount("network_members", netId)).toBe(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("admin is refused when the only content left is an uploaded file (disk index, not the DB)", async () => {
|
|
120
|
+
const netId = memberNetwork("files");
|
|
121
|
+
const indexDir = join(process.env.COMMHUB_UPLOADS_DIR!, ".index");
|
|
122
|
+
mkdirSync(indexDir, { recursive: true });
|
|
123
|
+
writeFileSync(join(indexDir, `f${stamp}.json`), JSON.stringify({ file_id: `f${stamp}`, network_id: netId }));
|
|
124
|
+
|
|
125
|
+
const res = await del(netId, adminToken);
|
|
126
|
+
const body = await res.json() as any;
|
|
127
|
+
expect(res.status).toBe(409);
|
|
128
|
+
expect(body.counts).toEqual({ files: 1 });
|
|
129
|
+
expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)).toBeTruthy();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("admin cannot delete the 'default' network even when it is empty", async () => {
|
|
133
|
+
db.run("INSERT INTO networks (network_id, network_name, owner_id) VALUES ('default', ?1, ?2)", [`legacy-default-${stamp}`, memberUserId]);
|
|
134
|
+
const res = await del("default", adminToken);
|
|
135
|
+
expect(res.status).toBe(409);
|
|
136
|
+
expect(((await res.json()) as any).error).toContain("default");
|
|
137
|
+
expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = 'default'")).toBeTruthy();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("a non-admin user cannot delete someone else's network", async () => {
|
|
141
|
+
const res = await del(adminNetworkId, memberToken);
|
|
142
|
+
expect(res.status).toBe(400);
|
|
143
|
+
expect(((await res.json()) as any).error).toBe("not your network");
|
|
144
|
+
expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", adminNetworkId)).toBeTruthy();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("a node token is refused with user_token_required", async () => {
|
|
148
|
+
const netId = memberNetwork("ntok");
|
|
149
|
+
const res = await del(netId, memberNodeToken);
|
|
150
|
+
expect(res.status).toBe(403);
|
|
151
|
+
expect(((await res.json()) as any).error).toBe("user_token_required");
|
|
152
|
+
expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)).toBeTruthy();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("owner path is unchanged: owner deletes own network (even with a node row), sessions still block it", async () => {
|
|
156
|
+
const withNode = memberNetwork("owner-node");
|
|
157
|
+
db.run("INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?2, ?3, datetime('now'), datetime('now'))",
|
|
158
|
+
[`owner-node-${stamp}`, `owner-node-${stamp}`, withNode]);
|
|
159
|
+
const ok = await del(withNode, memberToken);
|
|
160
|
+
expect(ok.status).toBe(200);
|
|
161
|
+
expect(await ok.json()).toEqual({ ok: true });
|
|
162
|
+
|
|
163
|
+
const withSession = memberNetwork("owner-session");
|
|
164
|
+
db.run("INSERT INTO sessions (resume_id, alias, network_id) VALUES (?1, ?2, ?3)", [`owner-resume-${stamp}`, `owner-s-${stamp}`, withSession]);
|
|
165
|
+
const blocked = await del(withSession, memberToken);
|
|
166
|
+
expect(blocked.status).toBe(400);
|
|
167
|
+
expect(((await blocked.json()) as any).error).toContain("active session");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("admin deleting its OWN network goes through the owner path (no admin_override)", async () => {
|
|
171
|
+
const created = auth.createNetwork(adminUserId, `admin-own-${stamp}`);
|
|
172
|
+
expect(created.ok).toBe(true);
|
|
173
|
+
const res = await del(created.network_id!, adminToken);
|
|
174
|
+
expect(res.status).toBe(200);
|
|
175
|
+
expect(await res.json()).toEqual({ ok: true });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// #2144 —— Agent 分组(#2131)的三张表。
|
|
179
|
+
function addGroup(netId: string, tag: string): string {
|
|
180
|
+
const groupId = `grp-${tag}-${stamp}`;
|
|
181
|
+
db.run("INSERT INTO agent_groups (group_id, network_id, name) VALUES (?1, ?2, ?3)", [groupId, netId, `g-${tag}`]);
|
|
182
|
+
db.run("INSERT INTO agent_group_members (group_id, node_id) VALUES (?1, ?2)", [groupId, `node-${tag}-${stamp}`]);
|
|
183
|
+
db.run("INSERT INTO network_member_group_grants (network_id, user_id, group_id) VALUES (?1, ?2, ?3)", [netId, memberUserId, groupId]);
|
|
184
|
+
return groupId;
|
|
185
|
+
}
|
|
186
|
+
function groupRows(netId: string, groupId: string) {
|
|
187
|
+
return {
|
|
188
|
+
agent_groups: rowCount("agent_groups", netId),
|
|
189
|
+
agent_group_members: db.get<{ cnt: number }>("SELECT COUNT(*) AS cnt FROM agent_group_members WHERE group_id = ?1", groupId)!.cnt,
|
|
190
|
+
network_member_group_grants: rowCount("network_member_group_grants", netId),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
test("#2144 admin is refused with 409 counts.agent_groups when the network has an agent group", async () => {
|
|
195
|
+
const netId = memberNetwork("grouped");
|
|
196
|
+
const groupId = addGroup(netId, "admin-blocked");
|
|
197
|
+
const res = await del(netId, adminToken);
|
|
198
|
+
const body = await res.json() as any;
|
|
199
|
+
expect(res.status).toBe(409);
|
|
200
|
+
expect(body.counts).toEqual({ agent_groups: 1 });
|
|
201
|
+
expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)).toBeTruthy();
|
|
202
|
+
expect(groupRows(netId, groupId)).toEqual({ agent_groups: 1, agent_group_members: 1, network_member_group_grants: 1 });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("#2144 admin delete of an otherwise-empty network cleans orphan group grants", async () => {
|
|
206
|
+
const netId = memberNetwork("orphan-grant");
|
|
207
|
+
db.run("INSERT INTO network_member_group_grants (network_id, user_id, group_id) VALUES (?1, ?2, ?3)", [netId, memberUserId, `grp-gone-${stamp}`]);
|
|
208
|
+
const res = await del(netId, adminToken);
|
|
209
|
+
const body = await res.json() as any;
|
|
210
|
+
expect(res.status).toBe(200);
|
|
211
|
+
expect(body.cleaned.network_member_group_grants).toBe(1);
|
|
212
|
+
expect(rowCount("network_member_group_grants", netId)).toBe(0);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("#2144 owner delete removes the network's groups, group members and group grants, and leaves other networks' groups", async () => {
|
|
216
|
+
const netId = memberNetwork("owner-grouped");
|
|
217
|
+
const groupId = addGroup(netId, "owner");
|
|
218
|
+
const otherNet = memberNetwork("owner-grouped-other");
|
|
219
|
+
const otherGroup = addGroup(otherNet, "other");
|
|
220
|
+
|
|
221
|
+
const res = await del(netId, memberToken);
|
|
222
|
+
expect(res.status).toBe(200);
|
|
223
|
+
expect(await res.json()).toEqual({ ok: true });
|
|
224
|
+
expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)).toBeFalsy();
|
|
225
|
+
expect(groupRows(netId, groupId)).toEqual({ agent_groups: 0, agent_group_members: 0, network_member_group_grants: 0 });
|
|
226
|
+
expect(groupRows(otherNet, otherGroup)).toEqual({ agent_groups: 1, agent_group_members: 1, network_member_group_grants: 1 });
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -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,137 @@
|
|
|
1
|
+
import { afterAll, beforeAll, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
6
|
+
const dir = mkdtempSync(join(tmpdir(), "adoption-candidates-"));
|
|
7
|
+
process.env.COMMHUB_DB ||= join(dir, "hub.db");
|
|
8
|
+
process.env.COMMHUB_AUTH_TOKEN ||= "adoption-candidates-master-fixture";
|
|
9
|
+
const WORKDIR = "/home/example/adoption-candidate-workdir-654";
|
|
10
|
+
const SECRET = "ntok_should_not_surface";
|
|
11
|
+
let hub: { stop: (closeActiveConnections?: boolean) => void; port: number }, base: string;
|
|
12
|
+
let db: any, owner: any, member: any, nodeOwner: any, other: any, daemonToken: string;
|
|
13
|
+
const ids = { daemon: "n_cand_daemon", hand: "n_cand_hand", foreign: "n_cand_foreign" };
|
|
14
|
+
|
|
15
|
+
const get = async (path: string, token = owner.token) => {
|
|
16
|
+
const res = await fetch(base + path, { headers: { Authorization: `Bearer ${token}` } });
|
|
17
|
+
return { status: res.status, body: await res.json() as any, text: "" };
|
|
18
|
+
};
|
|
19
|
+
const raw = async (path: string, token = owner.token) => {
|
|
20
|
+
const res = await fetch(base + path, { headers: { Authorization: `Bearer ${token}` } });
|
|
21
|
+
const text = await res.text();
|
|
22
|
+
return { status: res.status, text, body: JSON.parse(text) };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function snapshot(candidates: unknown, adopt = true) {
|
|
26
|
+
return JSON.stringify({
|
|
27
|
+
role: "host_supervisor",
|
|
28
|
+
daemon_capabilities: { adopt_capable: adopt, adoption_candidates: candidates },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
({ db } = await import("./db.js"));
|
|
34
|
+
const { register } = await import("./auth.js");
|
|
35
|
+
const suffix = Date.now();
|
|
36
|
+
owner = register(`cand_owner_${suffix}`, "CandFixture-Strong1!");
|
|
37
|
+
member = register(`cand_member_${suffix}`, "CandFixture-Strong1!");
|
|
38
|
+
nodeOwner = register(`cand_node_owner_${suffix}`, "CandFixture-Strong1!");
|
|
39
|
+
other = register(`cand_other_${suffix}`, "CandFixture-Strong1!");
|
|
40
|
+
expect(owner.ok && member.ok && nodeOwner.ok && other.ok).toBe(true);
|
|
41
|
+
for (const user of [owner, member, nodeOwner, other]) db.run("UPDATE users SET role='user' WHERE user_id=?1", [user.user.user_id]);
|
|
42
|
+
db.run("INSERT INTO network_members(network_id,user_id,role) VALUES(?1,?2,'member')", [owner.network_id, member.user.user_id]);
|
|
43
|
+
db.run("INSERT INTO network_members(network_id,user_id,role) VALUES(?1,?2,'member')", [owner.network_id, nodeOwner.user.user_id]);
|
|
44
|
+
const candidate = { node_id: ids.hand, alias: "hand-node", workdir: WORKDIR, runtime: "claude-agent-sdk", launch_hint: "tmux", token: SECRET, env: { API_KEY: SECRET } };
|
|
45
|
+
db.run("INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id,hostname,config_snapshot) VALUES(?1,?2,?2,?3,?4,'fixture-host',?5)",
|
|
46
|
+
[ids.daemon, "cand-daemon", owner.network_id, owner.user.user_id, snapshot([candidate, { node_id: "n_missing", alias: "nope", workdir: "/tmp/nope", launch_hint: "bare" }])]);
|
|
47
|
+
db.run("INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id,hostname,config_snapshot) VALUES(?1,?2,?2,?3,?4,'fixture-host','{}')",
|
|
48
|
+
[ids.hand, "hand-node", owner.network_id, nodeOwner.user.user_id]);
|
|
49
|
+
db.run("INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id,hostname) VALUES(?1,?2,?2,?3,?4,'other-host')",
|
|
50
|
+
[ids.foreign, "foreign-node", other.network_id, other.user.user_id]);
|
|
51
|
+
db.run("INSERT INTO sessions(resume_id,alias,node_id,network_id,status,last_seen_at) VALUES('cand-daemon-session','cand-daemon',?1,?2,'idle',datetime('now'))",
|
|
52
|
+
[ids.daemon, owner.network_id]);
|
|
53
|
+
const { generateNetworkToken, hashToken } = await import("./db.js");
|
|
54
|
+
daemonToken = generateNetworkToken();
|
|
55
|
+
db.run("INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash) VALUES('cand-daemon-token',?1,?2,'network','node:cand-daemon',?3)",
|
|
56
|
+
[owner.user.user_id, owner.network_id, hashToken(daemonToken)]);
|
|
57
|
+
const { bootServer } = await import("./server.js");
|
|
58
|
+
hub = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
59
|
+
base = `http://127.0.0.1:${hub.port}`;
|
|
60
|
+
}, 30_000);
|
|
61
|
+
afterAll(() => hub?.stop(true));
|
|
62
|
+
|
|
63
|
+
test("owner and node owner see the candidate; the report token stays out", async () => {
|
|
64
|
+
for (const token of [owner.token, nodeOwner.token]) {
|
|
65
|
+
const r = await raw(`/api/adoption-candidates?network_id=${owner.network_id}`, token);
|
|
66
|
+
expect(r.status).toBe(200);
|
|
67
|
+
expect(r.body.count).toBe(1);
|
|
68
|
+
expect(r.body.candidates).toEqual([{
|
|
69
|
+
node_id: ids.hand, alias: "hand-node", daemon_node_id: ids.daemon, daemon_alias: "cand-daemon",
|
|
70
|
+
hostname: "fixture-host", workdir: WORKDIR, runtime: "claude-agent-sdk", launch_hint: "tmux",
|
|
71
|
+
}]);
|
|
72
|
+
expect(r.text).not.toContain(SECRET);
|
|
73
|
+
expect(r.text).toContain(WORKDIR);
|
|
74
|
+
}
|
|
75
|
+
const supervisors = await raw(`/api/host-supervisors?network_id=${owner.network_id}`);
|
|
76
|
+
expect(supervisors.body.daemons.find((d: any) => d.daemon_node_id === ids.daemon).adoption_discovery).toBe(true);
|
|
77
|
+
expect(supervisors.text).not.toContain(WORKDIR);
|
|
78
|
+
expect(supervisors.text).not.toContain(SECRET);
|
|
79
|
+
const nodes = await raw(`/api/nodes?network_id=${owner.network_id}`);
|
|
80
|
+
expect(nodes.text).not.toContain("adoption_candidate");
|
|
81
|
+
expect(nodes.text).not.toContain(WORKDIR);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("member, other network, daemon token, and query token do not receive the workdir", async () => {
|
|
85
|
+
const memberRes = await raw(`/api/adoption-candidates?network_id=${owner.network_id}`, member.token);
|
|
86
|
+
expect(memberRes.status).toBe(200);
|
|
87
|
+
expect(memberRes.body.candidates).toEqual([]);
|
|
88
|
+
expect(memberRes.text).not.toContain(WORKDIR);
|
|
89
|
+
const otherRes = await raw(`/api/adoption-candidates?network_id=${owner.network_id}`, other.token);
|
|
90
|
+
expect(otherRes.status).toBe(403);
|
|
91
|
+
expect(otherRes.text).not.toContain(WORKDIR);
|
|
92
|
+
expect((await get("/api/adoption-candidates", daemonToken)).status).toBe(403);
|
|
93
|
+
expect((await get("/api/adoption-candidates", process.env.COMMHUB_AUTH_TOKEN!)).status).toBe(403);
|
|
94
|
+
const queryOnly = await fetch(base + `/api/adoption-candidates?network_id=${owner.network_id}&token=${encodeURIComponent(owner.token)}`);
|
|
95
|
+
expect([401, 403]).toContain(queryOnly.status);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("offline, hostname, alias, binding, and created-node filters drop the hint", async () => {
|
|
99
|
+
const path = `/api/adoption-candidates?network_id=${owner.network_id}`;
|
|
100
|
+
db.run("UPDATE sessions SET last_seen_at='2000-01-01 00:00:00' WHERE alias='cand-daemon'");
|
|
101
|
+
expect((await get(path)).body.candidates).toEqual([]);
|
|
102
|
+
db.run("UPDATE sessions SET last_seen_at=datetime('now') WHERE alias='cand-daemon'");
|
|
103
|
+
expect((await get(path)).body.count).toBe(1);
|
|
104
|
+
|
|
105
|
+
db.run("UPDATE nodes SET hostname='elsewhere' WHERE node_id=?1", [ids.hand]);
|
|
106
|
+
expect((await get(path)).body.candidates).toEqual([]);
|
|
107
|
+
db.run("UPDATE nodes SET hostname='fixture-host' WHERE node_id=?1", [ids.hand]);
|
|
108
|
+
|
|
109
|
+
db.run("UPDATE nodes SET config_snapshot=?1 WHERE node_id=?2", [snapshot([{ node_id: ids.hand, alias: "other-name", workdir: WORKDIR, launch_hint: "stopped" }]), ids.daemon]);
|
|
110
|
+
expect((await get(path)).body.candidates).toEqual([]);
|
|
111
|
+
db.run("UPDATE nodes SET config_snapshot=?1 WHERE node_id=?2", [snapshot([{ node_id: ids.hand, alias: "hand-node", workdir: WORKDIR, runtime: "claude-agent-sdk", launch_hint: "tmux", token: SECRET }]), ids.daemon]);
|
|
112
|
+
expect((await get(path)).body.count).toBe(1);
|
|
113
|
+
|
|
114
|
+
db.run(`INSERT INTO node_daemon_bindings(request_id,network_id,node_id,daemon_node_id,workdir,requested_by,status,created_at,updated_at)
|
|
115
|
+
VALUES('adopt_cand_pending',?1,?2,?3,?4,?5,'pending',1,1)`, [owner.network_id, ids.hand, ids.daemon, WORKDIR, owner.user.user_id]);
|
|
116
|
+
expect((await get(path)).body.candidates).toEqual([]);
|
|
117
|
+
db.run("UPDATE node_daemon_bindings SET status='revoked' WHERE request_id='adopt_cand_pending'");
|
|
118
|
+
expect((await get(path)).body.count).toBe(1);
|
|
119
|
+
|
|
120
|
+
db.run(`INSERT INTO node_create_requests(request_id,daemon_node_id,child_node_id,child_name,network_id,runtime,flags_json,env_keys,status,created_at,created_by_token)
|
|
121
|
+
VALUES('cr_cand_hand',?1,?2,'hand-node',?3,'claude-agent-sdk','{}','[]','succeeded',1,'private')`, [ids.daemon, ids.hand, owner.network_id]);
|
|
122
|
+
expect((await get(path)).body.candidates).toEqual([]);
|
|
123
|
+
db.run("DELETE FROM node_create_requests WHERE request_id='cr_cand_hand'");
|
|
124
|
+
expect((await get(path)).body.count).toBe(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("discovery flag is absent unless the daemon actually reported a list", async () => {
|
|
128
|
+
const path = `/api/host-supervisors?network_id=${owner.network_id}`;
|
|
129
|
+
db.run("UPDATE nodes SET config_snapshot=?1 WHERE node_id=?2", [JSON.stringify({ role: "host_supervisor", daemon_capabilities: { adopt_capable: true } }), ids.daemon]);
|
|
130
|
+
expect((await get(path)).body.daemons[0].adoption_discovery).toBeUndefined();
|
|
131
|
+
expect((await get(path)).body.daemons[0].adopt_capable).toBe(true);
|
|
132
|
+
db.run("UPDATE nodes SET config_snapshot=?1 WHERE node_id=?2", [snapshot([], true), ids.daemon]);
|
|
133
|
+
expect((await get(path)).body.daemons[0].adoption_discovery).toBe(true);
|
|
134
|
+
expect((await get(`/api/adoption-candidates?network_id=${owner.network_id}`)).body.candidates).toEqual([]);
|
|
135
|
+
const limited = await get(path, member.token);
|
|
136
|
+
expect(limited.body.daemons.find((d: any) => d.daemon_node_id === ids.daemon).adoption_discovery).toBe(true);
|
|
137
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Board #654 — project daemon-reported hand-started adoption candidates.
|
|
2
|
+
//
|
|
3
|
+
// The daemon report is a hint, not authority. This module does not adopt,
|
|
4
|
+
// bind, signal, start, or stop. Co-presence / three-stage nodes are not
|
|
5
|
+
// part of the report contract: discovery on the daemon excludes them.
|
|
6
|
+
// `GET /api/nodes` managed/adoption and `/api/node-lifecycle-requests`
|
|
7
|
+
// stay records of requests that already exist; they are not this list.
|
|
8
|
+
import { z } from "zod/v4";
|
|
9
|
+
import { db } from "./db.js";
|
|
10
|
+
import { getUserNetworkRole } from "./auth.js";
|
|
11
|
+
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
12
|
+
import { addAgentNetworkScope, addHumanNetworkScope, type RestNetworkScope } from "./network-scope.js";
|
|
13
|
+
|
|
14
|
+
const HINTS = new Set(["bare", "tmux", "stopped", "unverified"]);
|
|
15
|
+
const ONLINE_MS = 5 * 60_000;
|
|
16
|
+
const MAX_PUBLIC = 64;
|
|
17
|
+
|
|
18
|
+
export interface AdoptionCandidateReport {
|
|
19
|
+
node_id: string;
|
|
20
|
+
alias: string;
|
|
21
|
+
workdir: string;
|
|
22
|
+
runtime: string | null;
|
|
23
|
+
launch_hint: "bare" | "tmux" | "stopped" | "unverified";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function sanitizeAdoptionCandidate(raw: unknown): AdoptionCandidateReport | null {
|
|
27
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
28
|
+
const row = raw as Record<string, unknown>;
|
|
29
|
+
const { node_id, alias, workdir, launch_hint } = row;
|
|
30
|
+
if (typeof node_id !== "string" || node_id.length < 1 || node_id.length > 128 || /[\x00-\x1f\x7f]/.test(node_id)) return null;
|
|
31
|
+
if (typeof alias !== "string" || alias.length < 1 || alias.length > 128 || /[\x00-\x1f\x7f/\\]/.test(alias)
|
|
32
|
+
|| alias === "." || alias === ".." || alias.startsWith("-")) return null;
|
|
33
|
+
if (typeof workdir !== "string" || !workdir.startsWith("/") || workdir.length > 1024 || /[\x00-\x1f]/.test(workdir)) return null;
|
|
34
|
+
if (typeof launch_hint !== "string" || !HINTS.has(launch_hint)) return null;
|
|
35
|
+
let runtime: string | null = null;
|
|
36
|
+
if (row.runtime != null) {
|
|
37
|
+
if (typeof row.runtime !== "string" || row.runtime.length < 1 || row.runtime.length > 64 || !/^[A-Za-z0-9._-]+$/.test(row.runtime)) return null;
|
|
38
|
+
runtime = row.runtime;
|
|
39
|
+
}
|
|
40
|
+
return { node_id, alias, workdir, runtime, launch_hint: launch_hint as AdoptionCandidateReport["launch_hint"] };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const adoptionCandidateItemSchema = z.object({
|
|
44
|
+
node_id: z.string().min(1).max(128),
|
|
45
|
+
alias: z.string().min(1).max(128),
|
|
46
|
+
workdir: z.string().regex(/^\/[^\u0000-\u001f]{0,1023}$/),
|
|
47
|
+
runtime: z.string().min(1).max(64).nullable().optional(),
|
|
48
|
+
launch_hint: z.enum(["bare", "tmux", "stopped", "unverified"]),
|
|
49
|
+
}).strip().catch(null);
|
|
50
|
+
|
|
51
|
+
/** Ingest path for report_status. One bad item becomes null; a bad list never fails the heartbeat.
|
|
52
|
+
* No transform: tools/list JSON Schema cannot represent one. Readers drop nulls. */
|
|
53
|
+
export const adoptionCandidatesReportSchema = z.array(adoptionCandidateItemSchema).max(32).optional().catch(undefined);
|
|
54
|
+
|
|
55
|
+
function snapshotValue(snapshot: unknown): Record<string, any> | null {
|
|
56
|
+
try {
|
|
57
|
+
const parsed = typeof snapshot === "string" ? JSON.parse(snapshot) : snapshot;
|
|
58
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
59
|
+
} catch { return null; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function snapshotReportsAdoptionDiscovery(snapshot: unknown): boolean {
|
|
63
|
+
const caps = snapshotValue(snapshot)?.daemon_capabilities;
|
|
64
|
+
return caps?.adopt_capable === true && Array.isArray(caps.adoption_candidates);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function reportedCandidates(snapshot: unknown): AdoptionCandidateReport[] {
|
|
68
|
+
const raw = snapshotValue(snapshot)?.daemon_capabilities?.adoption_candidates;
|
|
69
|
+
if (!Array.isArray(raw)) return [];
|
|
70
|
+
const out: AdoptionCandidateReport[] = [];
|
|
71
|
+
for (const item of raw) {
|
|
72
|
+
const clean = sanitizeAdoptionCandidate(item);
|
|
73
|
+
if (clean) out.push(clean);
|
|
74
|
+
if (out.length >= 32) break;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type NodeRow = {
|
|
80
|
+
node_id: string; alias: string; hostname: string | null; network_id: string;
|
|
81
|
+
owner_user_id?: string | null; config_snapshot?: string | null; last_seen_at?: string | null;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export function listAdoptionCandidates(
|
|
85
|
+
scope: RestNetworkScope,
|
|
86
|
+
viewer: { userId: string; isAdmin: boolean },
|
|
87
|
+
nowMs = Date.now(),
|
|
88
|
+
): { ok: true; candidates: Array<AdoptionCandidateReport & { daemon_node_id: string; daemon_alias: string; hostname: string }>; count: number } {
|
|
89
|
+
const daemonParams: any[] = [];
|
|
90
|
+
let daemonSql = `SELECT n.node_id, n.alias, n.hostname, n.network_id, n.config_snapshot, s.last_seen_at
|
|
91
|
+
FROM nodes n
|
|
92
|
+
LEFT JOIN sessions s ON s.alias = n.alias AND (s.network_id = n.network_id OR s.network_id IS NULL)
|
|
93
|
+
WHERE EXISTS (
|
|
94
|
+
SELECT 1 FROM api_tokens t
|
|
95
|
+
WHERE t.network_id = n.network_id AND t.name = 'node:' || n.alias AND t.revoked_at IS NULL
|
|
96
|
+
)`;
|
|
97
|
+
daemonSql = addHumanNetworkScope(daemonSql, daemonParams, scope, "n.network_id");
|
|
98
|
+
const daemonRows = db.all<NodeRow>(daemonSql, ...daemonParams);
|
|
99
|
+
const daemons = new Map<string, NodeRow>();
|
|
100
|
+
for (const row of daemonRows) {
|
|
101
|
+
const prev = daemons.get(row.node_id);
|
|
102
|
+
if (!prev || parseDbTimestampMs(row.last_seen_at ?? "") > parseDbTimestampMs(prev.last_seen_at ?? "")) daemons.set(row.node_id, row);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const nodeParams: any[] = [];
|
|
106
|
+
let nodeSql = "SELECT n.node_id, n.alias, n.hostname, n.network_id, n.owner_user_id, n.config_snapshot FROM nodes n WHERE 1=1";
|
|
107
|
+
nodeSql = addAgentNetworkScope(nodeSql, nodeParams, scope, { network: "n.network_id", alias: "n.alias", nodeId: "n.node_id" });
|
|
108
|
+
const visible = new Map(db.all<NodeRow>(nodeSql, ...nodeParams).map((n) => [n.node_id, n]));
|
|
109
|
+
|
|
110
|
+
const managed = new Set<string>();
|
|
111
|
+
const bindingParams: any[] = [];
|
|
112
|
+
let bindingSql = `SELECT b.node_id FROM node_daemon_bindings b
|
|
113
|
+
JOIN nodes n ON n.node_id = b.node_id AND n.network_id = b.network_id
|
|
114
|
+
WHERE b.status IN ('pending','active')`;
|
|
115
|
+
bindingSql = addHumanNetworkScope(bindingSql, bindingParams, scope, "b.network_id");
|
|
116
|
+
for (const row of db.all<{ node_id: string }>(bindingSql, ...bindingParams)) managed.add(row.node_id);
|
|
117
|
+
const createdParams: any[] = [];
|
|
118
|
+
let createdSql = `SELECT COALESCE(c.child_node_id, CASE WHEN substr(c.request_id,1,3)='cr_' THEN 'node_' || substr(c.request_id,4) END) AS node_id
|
|
119
|
+
FROM node_create_requests c WHERE 1=1`;
|
|
120
|
+
createdSql = addHumanNetworkScope(createdSql, createdParams, scope, "c.network_id");
|
|
121
|
+
for (const row of db.all<{ node_id: string | null }>(createdSql, ...createdParams)) if (row.node_id) managed.add(row.node_id);
|
|
122
|
+
|
|
123
|
+
const roles = new Map<string, string | null>();
|
|
124
|
+
const maySee = (node: NodeRow) => {
|
|
125
|
+
if (viewer.isAdmin) return true;
|
|
126
|
+
if (node.owner_user_id && node.owner_user_id === viewer.userId) return true;
|
|
127
|
+
if (!roles.has(node.network_id)) roles.set(node.network_id, getUserNetworkRole(viewer.userId, node.network_id));
|
|
128
|
+
const role = roles.get(node.network_id);
|
|
129
|
+
return role === "owner" || role === "admin";
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const candidates: Array<AdoptionCandidateReport & { daemon_node_id: string; daemon_alias: string; hostname: string }> = [];
|
|
133
|
+
const seen = new Set<string>();
|
|
134
|
+
for (const daemon of daemons.values()) {
|
|
135
|
+
const snap = snapshotValue(daemon.config_snapshot);
|
|
136
|
+
if (snap?.role !== "host_supervisor" || snap.daemon_capabilities?.adopt_capable !== true) continue;
|
|
137
|
+
if (!daemon.hostname) continue;
|
|
138
|
+
const seenAt = parseDbTimestampMs(daemon.last_seen_at ?? "");
|
|
139
|
+
if (!Number.isFinite(seenAt) || nowMs - seenAt > ONLINE_MS) continue;
|
|
140
|
+
for (const reported of reportedCandidates(daemon.config_snapshot)) {
|
|
141
|
+
if (candidates.length >= MAX_PUBLIC) break;
|
|
142
|
+
const node = visible.get(reported.node_id);
|
|
143
|
+
if (!node || node.network_id !== daemon.network_id || node.alias !== reported.alias) continue;
|
|
144
|
+
if (!node.hostname || node.hostname !== daemon.hostname) continue;
|
|
145
|
+
if (snapshotValue(node.config_snapshot)?.role === "host_supervisor") continue;
|
|
146
|
+
if (managed.has(node.node_id) || !maySee(node)) continue;
|
|
147
|
+
const key = `${daemon.node_id}\0${node.node_id}`;
|
|
148
|
+
if (seen.has(key)) continue;
|
|
149
|
+
seen.add(key);
|
|
150
|
+
candidates.push({
|
|
151
|
+
node_id: node.node_id, alias: node.alias, daemon_node_id: daemon.node_id, daemon_alias: daemon.alias,
|
|
152
|
+
hostname: daemon.hostname, workdir: reported.workdir, runtime: reported.runtime, launch_hint: reported.launch_hint,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
candidates.sort((a, b) => a.alias.localeCompare(b.alias) || a.node_id.localeCompare(b.node_id) || a.daemon_node_id.localeCompare(b.daemon_node_id));
|
|
157
|
+
return { ok: true, candidates, count: candidates.length };
|
|
158
|
+
}
|