@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,42 @@
|
|
|
1
|
+
// Parse the `filter_alias` argument of `get_all_status` into an exact-match
|
|
2
|
+
// IN list.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists at all: on a 222-session hub the unfiltered response is about
|
|
5
|
+
// 259 KB — past what an MCP client accepts in one result. A caller who wanted
|
|
6
|
+
// the status of three specific nodes could not get it from the tool, and had to
|
|
7
|
+
// go around it to the REST API. The filter is the fix; this module is the part
|
|
8
|
+
// of it worth pinning, because the interesting behaviour is what happens to the
|
|
9
|
+
// inputs that are not a clean alias.
|
|
10
|
+
//
|
|
11
|
+
// Blank entries are DROPPED rather than matched. A trailing comma would
|
|
12
|
+
// otherwise produce `alias = ''`, which matches no row — and "no rows" reads
|
|
13
|
+
// exactly like "those nodes do not exist". The failure and the true answer
|
|
14
|
+
// would be indistinguishable to the caller, which is the thing to avoid.
|
|
15
|
+
|
|
16
|
+
export interface AliasFilter {
|
|
17
|
+
/** Exact aliases to match. Empty means "no alias filtering". */
|
|
18
|
+
aliases: string[];
|
|
19
|
+
/** SQL fragment to append, or "" when there is nothing to filter on. */
|
|
20
|
+
sql: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseAliasFilter(raw: string | undefined | null): AliasFilter {
|
|
24
|
+
const aliases = (raw ?? "")
|
|
25
|
+
.split(",")
|
|
26
|
+
.map(a => a.trim())
|
|
27
|
+
.filter(Boolean);
|
|
28
|
+
return {
|
|
29
|
+
aliases,
|
|
30
|
+
sql: aliases.length > 0 ? ` AND alias IN (${aliases.map(() => "?").join(",")})` : "",
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The same `alias IN (…)` clause with numbered placeholders, starting after `paramsSoFar` already-bound
|
|
36
|
+
* parameters. Use it when the query already carries numbered `?N` placeholders: the PG adapter numbers a bare
|
|
37
|
+
* `?` from $1 again, so mixing the two binds the aliases to the wrong slots (SQLite happens to number them right).
|
|
38
|
+
*/
|
|
39
|
+
export function numberedAliasInClause(aliases: string[], paramsSoFar: number): string {
|
|
40
|
+
if (aliases.length === 0) return "";
|
|
41
|
+
return ` AND alias IN (${aliases.map((_, i) => `?${paramsSoFar + i + 1}`).join(",")})`;
|
|
42
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// #1353 Fix ② — /api/host-supervisors surfaces daemon's self-reported
|
|
2
|
+
// can_create_nodes + create_nodes_blocked_reason so dashboard's
|
|
3
|
+
// "选服务器" picker can filter or dim broken daemons.
|
|
4
|
+
//
|
|
5
|
+
// Fix ① (PR #1510) added the hub-side dispatch gate — daemon that
|
|
6
|
+
// reports can_create_nodes=false gets refused with the reason. But
|
|
7
|
+
// the dashboard picker still LISTS the daemon and the user finds out
|
|
8
|
+
// only after clicking. This exposes the fact one layer earlier so the
|
|
9
|
+
// picker never surfaces the daemon in the first place.
|
|
10
|
+
//
|
|
11
|
+
// 🔴 Pre-#1371 compat is the same shape as Fix ①: only include the
|
|
12
|
+
// two keys when the daemon actually reported them. Old daemons (that
|
|
13
|
+
// never report) get the same undefined-passthrough dashboard sees a
|
|
14
|
+
// row WITHOUT the keys, not a row with can_create_nodes:false, so
|
|
15
|
+
// the dashboard's own default UI doesn't silently dim every legacy
|
|
16
|
+
// daemon.
|
|
17
|
+
|
|
18
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
19
|
+
import "./require-explicit-test-db.js";
|
|
20
|
+
import { register, login } from "./auth.js";
|
|
21
|
+
import { db } from "./db.js";
|
|
22
|
+
|
|
23
|
+
let BASE = "";
|
|
24
|
+
let server: any = null;
|
|
25
|
+
|
|
26
|
+
let userToken = "";
|
|
27
|
+
let userNetworkId = "";
|
|
28
|
+
let userId = "";
|
|
29
|
+
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
process.env.HOST = "127.0.0.1";
|
|
32
|
+
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
33
|
+
const password = "BootstrapPw123Aa!";
|
|
34
|
+
|
|
35
|
+
// Seed admin so subsequent registrations aren't auto-admin (admin
|
|
36
|
+
// path skips resolveRestNetworkScope's default-network fallback —
|
|
37
|
+
// that would change our fixture's role/scope in ways unrelated to
|
|
38
|
+
// this fix).
|
|
39
|
+
const seed = register(`hsc_seed_${suffix}`, password, undefined, "seed");
|
|
40
|
+
if (!seed.ok) throw new Error("seed failed");
|
|
41
|
+
db.run("UPDATE users SET role = 'admin' WHERE username = ?1", [`hsc_seed_${suffix}`]);
|
|
42
|
+
|
|
43
|
+
const u = register(`hsc_u_${suffix}`, password, undefined, "seed");
|
|
44
|
+
if (!u.ok || !u.token) throw new Error("user register failed");
|
|
45
|
+
userToken = u.token;
|
|
46
|
+
userNetworkId = u.network_id ?? "";
|
|
47
|
+
userId = login(`hsc_u_${suffix}`, password).user!.user_id;
|
|
48
|
+
|
|
49
|
+
const { bootServer } = await import("./server.js");
|
|
50
|
+
server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
51
|
+
BASE = `http://127.0.0.1:${server.port}`;
|
|
52
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterAll(() => {
|
|
56
|
+
try { server?.stop?.(true); } catch {}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
function seedDaemon(alias: string, snapshotOverride: object | null): void {
|
|
60
|
+
const nodeId = `hsc_n_${alias}`;
|
|
61
|
+
const snap = snapshotOverride === null
|
|
62
|
+
? null
|
|
63
|
+
: JSON.stringify(snapshotOverride);
|
|
64
|
+
db.run(
|
|
65
|
+
`INSERT OR REPLACE INTO nodes (
|
|
66
|
+
node_id, node_name, alias, runtime, model, config_path,
|
|
67
|
+
channels, server, hostname, network_id,
|
|
68
|
+
config_revision, config_snapshot
|
|
69
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)`,
|
|
70
|
+
[nodeId, alias, alias, "claude-agent-sdk", "claude-sonnet-4-5",
|
|
71
|
+
"/tmp/cfg.json", "[]", "test-host", "test-host", userNetworkId,
|
|
72
|
+
0, snap],
|
|
73
|
+
);
|
|
74
|
+
db.run(
|
|
75
|
+
`INSERT OR REPLACE INTO api_tokens (
|
|
76
|
+
token_id, token_hash, user_id, network_id, name, scope
|
|
77
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
|
|
78
|
+
[`hsc_t_${alias}`, `hash_${alias}`, userId, userNetworkId, `node:${alias}`, "network"],
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function clearDaemons(): void {
|
|
83
|
+
db.run("DELETE FROM nodes WHERE network_id = ?1", [userNetworkId]);
|
|
84
|
+
db.run("DELETE FROM api_tokens WHERE network_id = ?1 AND name LIKE 'node:%'", [userNetworkId]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function listDaemons(): Promise<any[]> {
|
|
88
|
+
const res = await fetch(`${BASE}/api/host-supervisors`, {
|
|
89
|
+
headers: { "Authorization": `Bearer ${userToken}` },
|
|
90
|
+
});
|
|
91
|
+
const body = await res.json();
|
|
92
|
+
if (!body.ok) throw new Error("list failed: " + JSON.stringify(body));
|
|
93
|
+
return body.daemons;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function findDaemon(rows: any[], alias: string): any {
|
|
97
|
+
const d = rows.find(r => r.alias === alias);
|
|
98
|
+
if (!d) throw new Error(`daemon ${alias} not in response`);
|
|
99
|
+
return d;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe("#1353 Fix ② — /api/host-supervisors surfaces can_create_nodes + blocked_reason", () => {
|
|
103
|
+
test("🔴 witnessed-red: blocked daemon → response carries can_create_nodes:false + blocked_reason", async () => {
|
|
104
|
+
clearDaemons();
|
|
105
|
+
seedDaemon("blocked-a", {
|
|
106
|
+
role: "host_supervisor",
|
|
107
|
+
daemon_capabilities: {
|
|
108
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
109
|
+
can_create_nodes: false,
|
|
110
|
+
create_nodes_blocked_reason: "anet_bin_permission",
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
const d = findDaemon(await listDaemons(), "blocked-a");
|
|
114
|
+
expect(d.can_create_nodes).toBe(false);
|
|
115
|
+
expect(d.create_nodes_blocked_reason).toBe("anet_bin_permission");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("healthy daemon (can_create_nodes:true) → can_create_nodes:true, NO blocked_reason", async () => {
|
|
119
|
+
clearDaemons();
|
|
120
|
+
seedDaemon("healthy-a", {
|
|
121
|
+
role: "host_supervisor",
|
|
122
|
+
daemon_capabilities: {
|
|
123
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
124
|
+
can_create_nodes: true,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
const d = findDaemon(await listDaemons(), "healthy-a");
|
|
128
|
+
expect(d.can_create_nodes).toBe(true);
|
|
129
|
+
// 🔴 defensive: dashboard's picker likely renders blocked_reason
|
|
130
|
+
// when present regardless of can_create_nodes value. Don't
|
|
131
|
+
// confuse it by shipping a stale reason on a healthy daemon.
|
|
132
|
+
expect(d.create_nodes_blocked_reason).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("healthy daemon that ALSO reports stale blocked_reason → reason NOT emitted (defensive)", async () => {
|
|
136
|
+
// Belt-and-braces: even if a future daemon shape bug leaves a
|
|
137
|
+
// stale reason string in the snapshot after recovering, the REST
|
|
138
|
+
// response should not surface it when the daemon is currently OK.
|
|
139
|
+
clearDaemons();
|
|
140
|
+
seedDaemon("healthy-b", {
|
|
141
|
+
role: "host_supervisor",
|
|
142
|
+
daemon_capabilities: {
|
|
143
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
144
|
+
can_create_nodes: true,
|
|
145
|
+
create_nodes_blocked_reason: "anet_bin_permission", // stale
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
const d = findDaemon(await listDaemons(), "healthy-b");
|
|
149
|
+
expect(d.can_create_nodes).toBe(true);
|
|
150
|
+
expect(d.create_nodes_blocked_reason).toBeUndefined();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("🔴 pre-#1371 compat: daemon that never reports can_create_nodes → BOTH keys absent from response", async () => {
|
|
154
|
+
// Same "known-blocked, NOT unknown-treated-as-blocked" rule as
|
|
155
|
+
// Fix ①. A pre-#1371 daemon (preview.10 through ~preview.55) never
|
|
156
|
+
// reports these fields. The response for it must not fabricate a
|
|
157
|
+
// false value — the dashboard could reasonably render an
|
|
158
|
+
// explicit `false` as "dim / hide" which would silently start
|
|
159
|
+
// hiding every daemon that hasn't upgraded.
|
|
160
|
+
clearDaemons();
|
|
161
|
+
seedDaemon("legacy-a", {
|
|
162
|
+
role: "host_supervisor",
|
|
163
|
+
daemon_capabilities: {
|
|
164
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
165
|
+
// no can_create_nodes / create_nodes_blocked_reason
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
const d = findDaemon(await listDaemons(), "legacy-a");
|
|
169
|
+
expect("can_create_nodes" in d).toBe(false);
|
|
170
|
+
expect("create_nodes_blocked_reason" in d).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("pre-#1371 compat: daemon with no daemon_capabilities at all → BOTH keys absent", async () => {
|
|
174
|
+
clearDaemons();
|
|
175
|
+
seedDaemon("legacy-b", { role: "host_supervisor" });
|
|
176
|
+
const d = findDaemon(await listDaemons(), "legacy-b");
|
|
177
|
+
expect("can_create_nodes" in d).toBe(false);
|
|
178
|
+
expect("create_nodes_blocked_reason" in d).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("pre-#1371 compat: NULL config_snapshot (very old daemon) → BOTH keys absent, daemon does NOT crash the listing", async () => {
|
|
182
|
+
// A daemon that never called report_status leaves config_snapshot NULL.
|
|
183
|
+
// /api/host-supervisors filters by role from the snapshot, so a NULL
|
|
184
|
+
// snapshot drops from the list entirely — but if it did surface (via
|
|
185
|
+
// any future path), it must not crash the reducer.
|
|
186
|
+
clearDaemons();
|
|
187
|
+
seedDaemon("legacy-c", { role: "host_supervisor" }); // seed with something to be listable
|
|
188
|
+
const rows = await listDaemons();
|
|
189
|
+
// Just prove the list doesn't throw and the daemon is present with
|
|
190
|
+
// no capability keys.
|
|
191
|
+
const d = findDaemon(rows, "legacy-c");
|
|
192
|
+
expect("can_create_nodes" in d).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("all 4 categorized reason codes round-trip through the REST response", async () => {
|
|
196
|
+
const codes = ["anet_bin_identity", "anet_bin_source", "anet_bin_shape", "anet_bin_permission"];
|
|
197
|
+
for (const code of codes) {
|
|
198
|
+
clearDaemons();
|
|
199
|
+
seedDaemon(`blocked-${code}`, {
|
|
200
|
+
role: "host_supervisor",
|
|
201
|
+
daemon_capabilities: {
|
|
202
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
203
|
+
can_create_nodes: false,
|
|
204
|
+
create_nodes_blocked_reason: code,
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
const d = findDaemon(await listDaemons(), `blocked-${code}`);
|
|
208
|
+
expect(d.can_create_nodes).toBe(false);
|
|
209
|
+
expect(d.create_nodes_blocked_reason).toBe(code);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test(".40-shape 'anet_bin_pin_unresolved' pre-#1377 code also passes through the REST response", async () => {
|
|
214
|
+
// The Zod enum still accepts this coarse pre-#1377 code (agent-node
|
|
215
|
+
// @2.5.0-preview.40 daemons in the wild emit it), and Fix ① surfaces
|
|
216
|
+
// it verbatim to callers; the REST surface must too, otherwise
|
|
217
|
+
// dashboard sees the daemon as "blocked with no reason".
|
|
218
|
+
clearDaemons();
|
|
219
|
+
seedDaemon("blocked-40shape", {
|
|
220
|
+
role: "host_supervisor",
|
|
221
|
+
daemon_capabilities: {
|
|
222
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
223
|
+
can_create_nodes: false,
|
|
224
|
+
create_nodes_blocked_reason: "anet_bin_pin_unresolved",
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
const d = findDaemon(await listDaemons(), "blocked-40shape");
|
|
228
|
+
expect(d.create_nodes_blocked_reason).toBe("anet_bin_pin_unresolved");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("malformed config_snapshot: existing behavior (daemon drops from list due to role-filter) unchanged", async () => {
|
|
232
|
+
// The parse is wrapped in try/catch (existing behavior). A malformed
|
|
233
|
+
// snapshot means snapRole stays null → filter drops it. This just
|
|
234
|
+
// pins that our added parsing doesn't turn a malformed snapshot
|
|
235
|
+
// into a crash.
|
|
236
|
+
clearDaemons();
|
|
237
|
+
// Seed a legit daemon so the list has SOMETHING to prove no crash.
|
|
238
|
+
seedDaemon("healthy-c", {
|
|
239
|
+
role: "host_supervisor",
|
|
240
|
+
daemon_capabilities: { runtimes_supported: [] },
|
|
241
|
+
});
|
|
242
|
+
// Seed the malformed one — should be filtered out silently.
|
|
243
|
+
const nodeId = "hsc_n_malformed";
|
|
244
|
+
db.run(
|
|
245
|
+
`INSERT OR REPLACE INTO nodes (
|
|
246
|
+
node_id, node_name, alias, runtime, model, config_path,
|
|
247
|
+
channels, server, hostname, network_id,
|
|
248
|
+
config_revision, config_snapshot
|
|
249
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)`,
|
|
250
|
+
[nodeId, "malformed", "malformed", "claude-agent-sdk", "claude-sonnet-4-5",
|
|
251
|
+
"/tmp/cfg.json", "[]", "test-host", "test-host", userNetworkId,
|
|
252
|
+
0, "{not json"],
|
|
253
|
+
);
|
|
254
|
+
db.run(
|
|
255
|
+
`INSERT OR REPLACE INTO api_tokens (
|
|
256
|
+
token_id, token_hash, user_id, network_id, name, scope
|
|
257
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
|
|
258
|
+
["hsc_t_malformed", "hash_malformed", userId, userNetworkId, "node:malformed", "network"],
|
|
259
|
+
);
|
|
260
|
+
const rows = await listDaemons();
|
|
261
|
+
// healthy-c is present; malformed is filtered out because
|
|
262
|
+
// snapRole stayed null.
|
|
263
|
+
expect(rows.find((r: any) => r.alias === "healthy-c")).toBeDefined();
|
|
264
|
+
expect(rows.find((r: any) => r.alias === "malformed")).toBeUndefined();
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
/* #1545 —— `can_create_nodes` 说的是「能不能」,这一组说的是「**那是什么时候测的**」。
|
|
269
|
+
*
|
|
270
|
+
* 为什么非要分开:agent-node ≤ 2.5.0-preview.54 用一个进程级缓存只算一次
|
|
271
|
+
* (`cli.ts` 的 `_createCapCache`)。开机时二进制好、之后被 chmod 掉 ⇒ 它会
|
|
272
|
+
* **永远上报 ready**。hub 这边,那条记录和一个 3 秒前刚测出来的 ready
|
|
273
|
+
* 在 `last_seen_at` 上一模一样 —— 心跳是新的,那一格不是。
|
|
274
|
+
*
|
|
275
|
+
* 🔴 缺席**不得**当成 0。把「没报」渲染成「刚测的」,正好朝「没问题」方向说谎,
|
|
276
|
+
* 而这正是 #1545 里比沉默更糟的那一半。
|
|
277
|
+
*/
|
|
278
|
+
describe("#1545 /api/host-supervisors —— can_create_nodes 的年龄", () => {
|
|
279
|
+
test("daemon 报了年龄 → 原样带出", async () => {
|
|
280
|
+
clearDaemons();
|
|
281
|
+
seedDaemon("age-fresh", {
|
|
282
|
+
role: "host_supervisor",
|
|
283
|
+
daemon_capabilities: {
|
|
284
|
+
can_create_nodes: false,
|
|
285
|
+
create_nodes_blocked_reason: "anet_bin_source",
|
|
286
|
+
create_capability_observed_ms_ago: 12,
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
const d = findDaemon(await listDaemons(), "age-fresh");
|
|
290
|
+
expect(d.create_capability_observed_ms_ago).toBe(12);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("🔴 daemon 没报年龄 → 这一格必须**缺席**,不能是 0/null", async () => {
|
|
294
|
+
clearDaemons();
|
|
295
|
+
seedDaemon("age-absent", {
|
|
296
|
+
role: "host_supervisor",
|
|
297
|
+
daemon_capabilities: { can_create_nodes: true },
|
|
298
|
+
});
|
|
299
|
+
const d = findDaemon(await listDaemons(), "age-absent");
|
|
300
|
+
// 能力本身照常带出 —— 加年龄这一格不许把旧 daemon 的既有信息弄丢。
|
|
301
|
+
expect(d.can_create_nodes).toBe(true);
|
|
302
|
+
expect("create_capability_observed_ms_ago" in d).toBe(false);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
/* 🔴 消毒**不是 clamp**:clamp 会把一个坏值变成一个看起来正常的年龄,
|
|
306
|
+
* 而这一格存在的全部意义就是让人分辨新鲜和陈旧。坏值一律当作「没报」。 */
|
|
307
|
+
test.each([
|
|
308
|
+
["负数", -1],
|
|
309
|
+
["超过一年", 400 * 24 * 60 * 60 * 1000],
|
|
310
|
+
["null(schema 的 .catch 兜底值)", null],
|
|
311
|
+
["字符串(直接写库,绕过 schema)", "12"],
|
|
312
|
+
])("坏值当作没报,而不是 clamp 成边界:%s", async (label, value) => {
|
|
313
|
+
clearDaemons();
|
|
314
|
+
const alias = `age-bad-${String(label).slice(0, 4)}`;
|
|
315
|
+
seedDaemon(alias, {
|
|
316
|
+
role: "host_supervisor",
|
|
317
|
+
daemon_capabilities: {
|
|
318
|
+
can_create_nodes: true,
|
|
319
|
+
create_capability_observed_ms_ago: value,
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
const d = findDaemon(await listDaemons(), alias);
|
|
323
|
+
expect(d.can_create_nodes).toBe(true); // 能力仍在
|
|
324
|
+
expect("create_capability_observed_ms_ago" in d).toBe(false); // 年龄不在
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
/* 边界校准:上面用 400 天证明「太旧被丢」,这里用**刚好一年**证明门不是恒丢。
|
|
328
|
+
* 只验被丢那一侧的话,一个恒丢的实现能全绿通过。 */
|
|
329
|
+
test("边界:恰好一年被接受 ⇒ 上面那条不是因为恒丢才绿", async () => {
|
|
330
|
+
clearDaemons();
|
|
331
|
+
const oneYear = 365 * 24 * 60 * 60 * 1000;
|
|
332
|
+
seedDaemon("age-boundary", {
|
|
333
|
+
role: "host_supervisor",
|
|
334
|
+
daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: oneYear },
|
|
335
|
+
});
|
|
336
|
+
const d = findDaemon(await listDaemons(), "age-boundary");
|
|
337
|
+
expect(d.create_capability_observed_ms_ago).toBe(oneYear);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("年龄只在 can_create_nodes 存在时出现(旧 daemon 两格都没有)", async () => {
|
|
341
|
+
clearDaemons();
|
|
342
|
+
seedDaemon("age-legacy", {
|
|
343
|
+
role: "host_supervisor",
|
|
344
|
+
daemon_capabilities: { runtimes_supported: ["claude-agent-sdk"] },
|
|
345
|
+
});
|
|
346
|
+
const d = findDaemon(await listDaemons(), "age-legacy");
|
|
347
|
+
expect("can_create_nodes" in d).toBe(false);
|
|
348
|
+
expect("create_capability_observed_ms_ago" in d).toBe(false);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// #380 fix — REST /api/host-supervisors utok→default-network fallback.
|
|
2
|
+
//
|
|
3
|
+
// Before this patch the endpoint hard-4xxed when the client omitted the
|
|
4
|
+
// `network_id` query param. The dashboard's create-node wizard was
|
|
5
|
+
// reported as "hub 400" against a prod hub that had zero daemons AND was
|
|
6
|
+
// being polled without the query — the frontend read that as "no
|
|
7
|
+
// available servers" and blocked the wizard.
|
|
8
|
+
//
|
|
9
|
+
// Post-fix contract:
|
|
10
|
+
// - ntok caller → uses bound network (existing)
|
|
11
|
+
// - utok caller + `?network_id=...` verified → uses that (existing)
|
|
12
|
+
// - utok caller + NO query + 1 accessible net → fallback to that net (200)
|
|
13
|
+
// - utok caller + NO query + 2+ accessible net → 400, error=network_id_required_multi
|
|
14
|
+
// - utok caller + NO query + 0 accessible net → 400, error=missing_network_id
|
|
15
|
+
//
|
|
16
|
+
// The multi-network case is the authz boundary — we refuse to guess
|
|
17
|
+
// which network to list from (per 通信龙 spec "别 fallback 到错 network").
|
|
18
|
+
//
|
|
19
|
+
// Test pattern mirrors uploads-http.test.ts: bind the real Bun.serve
|
|
20
|
+
// server on an ephemeral port with a temp DB, hit it with fetch().
|
|
21
|
+
|
|
22
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
23
|
+
import "./require-explicit-test-db.js";
|
|
24
|
+
import { register, login, createNetwork } from "./auth.js";
|
|
25
|
+
import { db } from "./db.js";
|
|
26
|
+
|
|
27
|
+
// #438 corrective: private bootServer({ port: 0 }) instance (see
|
|
28
|
+
// uploads-http.test.ts for rationale).
|
|
29
|
+
let BASE = "";
|
|
30
|
+
let server: any = null;
|
|
31
|
+
|
|
32
|
+
// Three users:
|
|
33
|
+
// soloUser — single accessible network (fallback should work)
|
|
34
|
+
// multiUser — two accessible networks (fallback should refuse)
|
|
35
|
+
// orphanUser — zero networks (fallback has nothing to derive)
|
|
36
|
+
let soloToken = "", multiToken = "", orphanToken = "", adminToken = "";
|
|
37
|
+
let soloNetworkId = "";
|
|
38
|
+
let multiNetworkA = "", multiNetworkB = "";
|
|
39
|
+
let soloDaemonAlias = "";
|
|
40
|
+
|
|
41
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
42
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
43
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
44
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
45
|
+
beforeAll(async () => {
|
|
46
|
+
process.env.HOST = "127.0.0.1";
|
|
47
|
+
|
|
48
|
+
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
49
|
+
const password = "BootstrapPw123Aa!";
|
|
50
|
+
|
|
51
|
+
// Pre-register a first user so subsequent test users are NOT auto-admin.
|
|
52
|
+
// resolveRestNetworkScope's admin branch bypasses fallback (admin can
|
|
53
|
+
// legitimately query any network), which would make solo's no-query
|
|
54
|
+
// case a false-negative for our fix contract.
|
|
55
|
+
const seed = register(`seed_admin_${suffix}`, password, undefined, "seed");
|
|
56
|
+
if (!seed.ok || !seed.token) throw new Error("seed admin failed: " + JSON.stringify(seed));
|
|
57
|
+
// "First registered user is auto-admin" only holds when this suite has
|
|
58
|
+
// the DB to itself. In an aggregate run another suite registers first,
|
|
59
|
+
// the seed lands as a plain user, and paths 7/8 silently test the
|
|
60
|
+
// wrong role. Promote explicitly — resolveToken reads users.role at
|
|
61
|
+
// request time, so promotion after token issuance is effective.
|
|
62
|
+
db.run("UPDATE users SET role = 'admin' WHERE username = ?1", [`seed_admin_${suffix}`]);
|
|
63
|
+
// Save the admin token so path 7 can hit /api/host-supervisors as an
|
|
64
|
+
// admin directly (通信龙 review point 1: verify admin+no-query goes 400
|
|
65
|
+
// explicitly, not only implicitly via seed_admin's shadow presence).
|
|
66
|
+
adminToken = seed.token;
|
|
67
|
+
|
|
68
|
+
// Solo user — register() auto-creates a default network; nothing to add.
|
|
69
|
+
const solo = register(`solo_${suffix}`, password, undefined, "seed");
|
|
70
|
+
if (!solo.ok || !solo.token) throw new Error("solo register failed: " + JSON.stringify(solo));
|
|
71
|
+
soloToken = solo.token;
|
|
72
|
+
soloNetworkId = solo.network_id ?? "";
|
|
73
|
+
if (!soloNetworkId) throw new Error("solo default network missing");
|
|
74
|
+
|
|
75
|
+
// Multi user — register + create a second owned network.
|
|
76
|
+
const multi = register(`multi_${suffix}`, password, undefined, "seed");
|
|
77
|
+
if (!multi.ok || !multi.token) throw new Error("multi register failed: " + JSON.stringify(multi));
|
|
78
|
+
multiToken = multi.token;
|
|
79
|
+
multiNetworkA = multi.network_id ?? "";
|
|
80
|
+
if (!multiNetworkA) throw new Error("multi default network missing");
|
|
81
|
+
const authCtx = login(`multi_${suffix}`, password);
|
|
82
|
+
const secondNet: any = createNetwork(authCtx.user!.user_id, `multi_second_${suffix}`);
|
|
83
|
+
if (!secondNet.ok || !secondNet.network_id) throw new Error("multi second-net create failed: " + JSON.stringify(secondNet));
|
|
84
|
+
multiNetworkB = secondNet.network_id;
|
|
85
|
+
|
|
86
|
+
// Orphan user — register then delete membership in the default net so
|
|
87
|
+
// the user has zero networks. Direct DB manipulation is fine here;
|
|
88
|
+
// we're modeling an edge case the production API would never let a
|
|
89
|
+
// normal user hit (they'd always keep at least their default), but
|
|
90
|
+
// the endpoint contract still needs to be locked.
|
|
91
|
+
const orphan = register(`orphan_${suffix}`, password, undefined, "seed");
|
|
92
|
+
if (!orphan.ok || !orphan.token) throw new Error("orphan register failed: " + JSON.stringify(orphan));
|
|
93
|
+
orphanToken = orphan.token;
|
|
94
|
+
const orphanUserId = login(`orphan_${suffix}`, password).user!.user_id;
|
|
95
|
+
db.run("DELETE FROM network_members WHERE user_id = ?1", [orphanUserId]);
|
|
96
|
+
|
|
97
|
+
// 通信龙 review point 2: path-2's fallback response must land the
|
|
98
|
+
// daemon list *from soloNetworkId specifically*, not from the whole
|
|
99
|
+
// sessions/nodes table. The original test-2 asserted daemons was an
|
|
100
|
+
// array but a clean fixture (0 daemons in the DB) would trivially
|
|
101
|
+
// satisfy that. Insert:
|
|
102
|
+
// (a) a host_supervisor daemon in soloNetworkId — should show up in
|
|
103
|
+
// fallback response
|
|
104
|
+
// (b) a host_supervisor daemon in multiNetworkA — should NOT show
|
|
105
|
+
// up in solo's fallback (cross-tenant isolation lock)
|
|
106
|
+
soloDaemonAlias = `solo_daemon_${suffix}`;
|
|
107
|
+
seedHostSupervisorDaemon({
|
|
108
|
+
alias: soloDaemonAlias,
|
|
109
|
+
networkId: soloNetworkId,
|
|
110
|
+
userIdForToken: login(`solo_${suffix}`, password).user!.user_id,
|
|
111
|
+
});
|
|
112
|
+
seedHostSupervisorDaemon({
|
|
113
|
+
alias: `other_daemon_${suffix}`,
|
|
114
|
+
networkId: multiNetworkA,
|
|
115
|
+
userIdForToken: login(`multi_${suffix}`, password).user!.user_id,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// server.ts is side-effect-free to import; boot a private instance.
|
|
119
|
+
const { bootServer } = await import("./server.js");
|
|
120
|
+
server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
121
|
+
BASE = `http://127.0.0.1:${server.port}`;
|
|
122
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
123
|
+
}, 30_000);
|
|
124
|
+
|
|
125
|
+
// Insert a minimal `nodes` row + `api_tokens` row so the daemon shows up
|
|
126
|
+
// in /api/host-supervisors' JOIN-then-role-filter query (index.ts
|
|
127
|
+
// GET /api/host-supervisors handler, at the SELECT ... FROM nodes n ...
|
|
128
|
+
// EXISTS (SELECT 1 FROM api_tokens ...) branch). The api_tokens row
|
|
129
|
+
// with name='node:<alias>' + revoked_at IS NULL is the "still active"
|
|
130
|
+
// witness — without it EXISTS drops the row.
|
|
131
|
+
function seedHostSupervisorDaemon(opts: {
|
|
132
|
+
alias: string;
|
|
133
|
+
networkId: string;
|
|
134
|
+
userIdForToken: string;
|
|
135
|
+
}) {
|
|
136
|
+
const nodeId = `n_test_${opts.alias}`;
|
|
137
|
+
db.run(
|
|
138
|
+
`INSERT OR REPLACE INTO nodes (
|
|
139
|
+
node_id, node_name, alias, runtime, model, config_path,
|
|
140
|
+
channels, server, hostname, network_id,
|
|
141
|
+
config_revision, config_snapshot
|
|
142
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)`,
|
|
143
|
+
[nodeId, opts.alias, opts.alias, "claude-agent-sdk", "claude-sonnet-4-5",
|
|
144
|
+
"/tmp/cfg.json", "[]", "test-host", "test-host", opts.networkId,
|
|
145
|
+
0, JSON.stringify({ role: "host_supervisor", daemon_capabilities: {} })],
|
|
146
|
+
);
|
|
147
|
+
db.run(
|
|
148
|
+
`INSERT OR REPLACE INTO api_tokens (
|
|
149
|
+
token_id, token_hash, user_id, network_id, name, scope
|
|
150
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
|
|
151
|
+
[`tok_test_${opts.alias}`, `hash_test_${opts.alias}`,
|
|
152
|
+
opts.userIdForToken, opts.networkId, `node:${opts.alias}`, "network"],
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
afterAll(() => {
|
|
157
|
+
try { server?.stop?.(true); } catch {}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
async function get(path: string, token: string): Promise<{ status: number; body: any }> {
|
|
161
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
162
|
+
headers: { "Authorization": `Bearer ${token}` },
|
|
163
|
+
});
|
|
164
|
+
const body = await res.json().catch(() => null);
|
|
165
|
+
return { status: res.status, body };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
describe("#380 — /api/host-supervisors utok→default-network fallback", () => {
|
|
169
|
+
test("path 1 — utok with 1 network, WITH ?network_id → 200 (existing behavior)", async () => {
|
|
170
|
+
const r = await get(`/api/host-supervisors?network_id=${soloNetworkId}`, soloToken);
|
|
171
|
+
expect(r.status).toBe(200);
|
|
172
|
+
expect(r.body?.ok).toBe(true);
|
|
173
|
+
expect(Array.isArray(r.body?.daemons)).toBe(true);
|
|
174
|
+
expect(typeof r.body?.count).toBe("number");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("path 2 — utok with 1 network, NO ?network_id → 200 (fallback lands AND scopes correctly)", async () => {
|
|
178
|
+
// This is the core #380 fix: dashboard forgot the query but the user
|
|
179
|
+
// only has one network anyway, so we can safely fall back to it.
|
|
180
|
+
//
|
|
181
|
+
// 通信龙 review point 2: harden the assertion beyond "daemons is an
|
|
182
|
+
// array". We seeded one daemon in soloNetworkId and one in
|
|
183
|
+
// multiNetworkA (multi user); the fallback response MUST include the
|
|
184
|
+
// solo one and NOT the multi one. Otherwise a future regression that
|
|
185
|
+
// wired the fallback to "all networks the user knows about" would
|
|
186
|
+
// slip through — cross-tenant leak.
|
|
187
|
+
const r = await get(`/api/host-supervisors`, soloToken);
|
|
188
|
+
expect(r.status).toBe(200);
|
|
189
|
+
expect(r.body?.ok).toBe(true);
|
|
190
|
+
expect(Array.isArray(r.body?.daemons)).toBe(true);
|
|
191
|
+
|
|
192
|
+
const aliases: string[] = r.body.daemons.map((d: any) => d.alias);
|
|
193
|
+
expect(aliases).toContain(soloDaemonAlias);
|
|
194
|
+
// Belt: nothing from the multi user's network snuck through.
|
|
195
|
+
expect(aliases.every((a: string) => !a.startsWith("other_daemon_"))).toBe(true);
|
|
196
|
+
// Every returned daemon's daemon_node_id should be one this user can
|
|
197
|
+
// legitimately see — locking the shape end-to-end.
|
|
198
|
+
for (const d of r.body.daemons) {
|
|
199
|
+
expect(typeof d.daemon_node_id).toBe("string");
|
|
200
|
+
expect(typeof d.alias).toBe("string");
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("path 3 — utok with 2 networks, NO ?network_id → 400 network_id_required_multi", async () => {
|
|
205
|
+
// Authz boundary: refuse to guess which network to list. Distinguishing
|
|
206
|
+
// error field lets the client tell the user "pick a network" vs
|
|
207
|
+
// "you're not in any networks".
|
|
208
|
+
const r = await get(`/api/host-supervisors`, multiToken);
|
|
209
|
+
expect(r.status).toBe(400);
|
|
210
|
+
expect(r.body?.ok).toBe(false);
|
|
211
|
+
expect(r.body?.error).toBe("network_id_required_multi");
|
|
212
|
+
expect(r.body?.memberships).toBe(2);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("path 4 — utok with 2 networks, WITH ?network_id → 200 (explicit choice honored)", async () => {
|
|
216
|
+
const r = await get(`/api/host-supervisors?network_id=${multiNetworkA}`, multiToken);
|
|
217
|
+
expect(r.status).toBe(200);
|
|
218
|
+
expect(r.body?.ok).toBe(true);
|
|
219
|
+
const r2 = await get(`/api/host-supervisors?network_id=${multiNetworkB}`, multiToken);
|
|
220
|
+
expect(r2.status).toBe(200);
|
|
221
|
+
expect(r2.body?.ok).toBe(true);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("path 5 — utok with 2 networks, WITH ?network_id (not a member) → denied by scope layer", async () => {
|
|
225
|
+
// Requesting a network the user isn't in must never leak — this is
|
|
226
|
+
// enforced upstream at resolveRestNetworkScope, but re-lock here.
|
|
227
|
+
const foreignNet = "net_absolutely_not_a_member";
|
|
228
|
+
const r = await get(`/api/host-supervisors?network_id=${foreignNet}`, multiToken);
|
|
229
|
+
expect(r.status).toBe(403);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("path 7 — admin utok, NO ?network_id → 400 (admin can span every network; no cross-network guessing)", async () => {
|
|
233
|
+
// 通信龙 review point 1: the previous suite only asserted admin
|
|
234
|
+
// indirectly (seed_admin was created first so other test users
|
|
235
|
+
// weren't auto-admin). This test hits /api/host-supervisors with
|
|
236
|
+
// seed_admin's own utok — resolveRestNetworkScope's isAdmin branch
|
|
237
|
+
// returns networkIds:null, singleNetworkId returns null, endpoint
|
|
238
|
+
// 400s. The response distinguishes it from the multi-network case
|
|
239
|
+
// via `memberships` being absent-or-0 (admin doesn't populate
|
|
240
|
+
// scope.networkIds), so the client sees an explicit "you're admin,
|
|
241
|
+
// pass network_id" affordance.
|
|
242
|
+
const r = await get(`/api/host-supervisors`, adminToken);
|
|
243
|
+
expect(r.status).toBe(400);
|
|
244
|
+
expect(r.body?.ok).toBe(false);
|
|
245
|
+
// Not the multi-network error — admin path goes through the "no
|
|
246
|
+
// scope.networkIds" branch → memberships defaults to 0 (the ??
|
|
247
|
+
// coalesce in the handler). This asymmetry lets the client
|
|
248
|
+
// distinguish "you own too many" from "you can see all of them".
|
|
249
|
+
expect(r.body?.error).toBe("missing_network_id");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("path 8 — admin utok, WITH explicit ?network_id → 200 (admin can inspect any network)", async () => {
|
|
253
|
+
// Complement to path 7: even without membership in a target network,
|
|
254
|
+
// admin can query it. Locks resolveRestNetworkScope's isAdmin =>
|
|
255
|
+
// {networkId: requested, networkIds: null} branch.
|
|
256
|
+
const r = await get(`/api/host-supervisors?network_id=${multiNetworkA}`, adminToken);
|
|
257
|
+
expect(r.status).toBe(200);
|
|
258
|
+
expect(r.body?.ok).toBe(true);
|
|
259
|
+
// The daemon we seeded in multiNetworkA must be reachable.
|
|
260
|
+
const aliases: string[] = r.body.daemons.map((d: any) => d.alias);
|
|
261
|
+
expect(aliases.some(a => a.startsWith("other_daemon_"))).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("path 6 — utok with 0 networks, NO ?network_id → 400 missing_network_id (memberships=0)", async () => {
|
|
265
|
+
const r = await get(`/api/host-supervisors`, orphanToken);
|
|
266
|
+
expect(r.status).toBe(400);
|
|
267
|
+
expect(r.body?.ok).toBe(false);
|
|
268
|
+
expect(r.body?.error).toBe("missing_network_id");
|
|
269
|
+
expect(r.body?.memberships).toBe(0);
|
|
270
|
+
});
|
|
271
|
+
});
|