@sleep2agi/commhub-server 0.9.0-preview.1 → 0.9.0-preview.101
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 +92 -11
- package/package.json +8 -7
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -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 +520 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +190 -0
- package/src/agent-notice.ts +48 -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-nodes-shape.test.ts +188 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +493 -35
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +270 -1
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +275 -0
- package/src/create-node-validate.ts +288 -0
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.test.ts +354 -0
- package/src/create-node.ts +349 -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 +984 -34
- 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 +329 -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-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2172
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-context-cost-http.test.ts +119 -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 +429 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +206 -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 +224 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-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 +72 -0
- package/src/node-id-alias.test.ts +188 -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 +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +242 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/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 +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-due-reminders-http.test.ts +518 -0
- package/src/requirement-due-reminders.ts +369 -0
- package/src/requirement-events-http.test.ts +257 -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-comment-http.test.ts +165 -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 +344 -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 +225 -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 +164 -0
- package/src/requirements-status-participants-http.test.ts +220 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1761 -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 +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.ts +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/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -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 +650 -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 +5038 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/shared/reserved-env-drift.test.ts +43 -0
- package/src/shared/reserved-env.test.ts +35 -0
- package/src/shared/reserved-env.ts +31 -0
- package/src/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 +109 -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 +1164 -0
- package/src/task-access-http.test.ts +379 -0
- package/src/task-access.ts +413 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +313 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +211 -0
- package/src/task-queue-ahead.ts +186 -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 +195 -0
- package/src/tool-audience.ts +109 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +4955 -300
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +138 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// RFC-026 v4 §4.2.2 (F2 structured) + §4.4.7 (C1+B1 env_refs) + §4.2.6
|
|
2
|
+
// (B2 fork wrapper) — pure validators + arg builder. No DB access. Used
|
|
3
|
+
// by hub create_node MCP tool (RPC entry) AND by daemon get_create_request
|
|
4
|
+
// (re-check, defense in depth).
|
|
5
|
+
//
|
|
6
|
+
// All validation errors throw ValidationError with a stable `code` field
|
|
7
|
+
// that the MCP tool surface maps to a JSON {ok:false, error: <code>}
|
|
8
|
+
// payload (RFC §4.6).
|
|
9
|
+
|
|
10
|
+
import { isReservedEnvKey } from "./shared/reserved-env.js";
|
|
11
|
+
|
|
12
|
+
export class ValidationError extends Error {
|
|
13
|
+
constructor(public code: string, public detail?: Record<string, unknown>) {
|
|
14
|
+
super(`${code}${detail ? ` ${JSON.stringify(detail)}` : ""}`);
|
|
15
|
+
this.name = "ValidationError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// §4.2.2 — structural enums.
|
|
20
|
+
// #1298 —— daemon 远程创建允许的 runtime。2026-08-28 从 3 个放开到 7 个。
|
|
21
|
+
//
|
|
22
|
+
// 放开之前挡着的问题是「daemon 建出来的是**无人值守**的后台进程,而三个共存 runtime
|
|
23
|
+
// 的本质是人和 agent 共用一个 TUI 会话 —— 建出来给谁用?」
|
|
24
|
+
//
|
|
25
|
+
// Vincent 2026-08-28 定,理由是**兜底排错**:
|
|
26
|
+
// 「某个节点突然不动了,人可以进去看。在 daemon 还没那么靠谱的情况下,
|
|
27
|
+
// 这非常重要 —— 至少可以跟 Codex 和 Claude Code 对话。」
|
|
28
|
+
// 🔴 关键在于 **attach 是「建好之后」的动作,不是「建的时候」的前提**。
|
|
29
|
+
//
|
|
30
|
+
// 支撑读数(2026-08-28 实测舰队):`通信牛` 跑 codex-app-server(共存 runtime),
|
|
31
|
+
// **764 次成功完成任务**,当天仍在跑 —— 比两个普通 runtime 的节点(61 次 / 3 次)
|
|
32
|
+
// 都多一个量级。⇒ 任务链路不需要人坐在屏幕前。
|
|
33
|
+
//
|
|
34
|
+
// 🔴 这七个名字与 `agent-network/src/normalize-runtime.ts` 的 `RuntimeName` 一致。
|
|
35
|
+
// 那边是 CLI 侧的全集;两边分叉过一次(daemon 侧的 VALID_RUNTIMES 早就是 7 个,
|
|
36
|
+
// 只有这里还是 3 个,而记录此事的 #1298 正文写的是「三道闸都关着」——
|
|
37
|
+
// **一份快照式的盘点,只在写下的那一刻是真的**)。
|
|
38
|
+
export const RUNTIMES = [
|
|
39
|
+
"claude-agent-sdk",
|
|
40
|
+
"claude-code-cli",
|
|
41
|
+
"codex-sdk",
|
|
42
|
+
"codex-app-server",
|
|
43
|
+
"grok-build-acp",
|
|
44
|
+
"grok-build-cli",
|
|
45
|
+
"opencode-cli",
|
|
46
|
+
] as const;
|
|
47
|
+
export type Runtime = typeof RUNTIMES[number];
|
|
48
|
+
|
|
49
|
+
export const FLAG_KEYS = ["permissionMode", "dangerouslySkipPermissions", "maxTurns", "budget", "timeout"] as const;
|
|
50
|
+
export type FlagKey = typeof FLAG_KEYS[number];
|
|
51
|
+
|
|
52
|
+
export const PERMISSION_MODES = ["default", "acceptEdits", "plan", "bypassPermissions"] as const;
|
|
53
|
+
|
|
54
|
+
// §4.4.7 — env_refs limits.
|
|
55
|
+
export const MAX_ENV_KEYS_PER_NODE = 32;
|
|
56
|
+
export const MAX_ENV_VALUE_BYTES = 16 * 1024;
|
|
57
|
+
const ENV_KEY_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
58
|
+
|
|
59
|
+
// §4.2.2 name + model.
|
|
60
|
+
const NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
61
|
+
// OpenCode 共存的模型带 provider 前缀(`opencode/mimo-v2.5-free`,agent-node parseModelRef 按第一个 `/` 切),
|
|
62
|
+
// 桌面向导 0.2.61 起就发这种形状 —— 允许**恰好一个**斜杠分隔的两段;每段仍是原字符集,且不能是纯点(`..`/`.`)。
|
|
63
|
+
const MODEL_SEGMENT = "[a-zA-Z0-9._:\\-]+";
|
|
64
|
+
const MODEL_RE = new RegExp(`^${MODEL_SEGMENT}(?:/${MODEL_SEGMENT})?$`);
|
|
65
|
+
const MODEL_DOT_ONLY_SEGMENT = /(^|\/)\.+(\/|$)/;
|
|
66
|
+
|
|
67
|
+
export function validateName(s: unknown): asserts s is string {
|
|
68
|
+
if (typeof s !== "string" || !NAME_RE.test(s)) {
|
|
69
|
+
throw new ValidationError("node_name_invalid", { value: typeof s === "string" ? s.slice(0, 80) : typeof s });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function validateRuntime(s: unknown): asserts s is Runtime {
|
|
74
|
+
if (typeof s !== "string" || !(RUNTIMES as readonly string[]).includes(s)) {
|
|
75
|
+
// 🔴 原来只回一个 `runtime_invalid` + 用户传进来的那个值 —— **不说允许哪些,
|
|
76
|
+
// 也不说还有没有别的路**。2026-08-28 实测:从 Dashboard 建一个
|
|
77
|
+
// `codex-app-server` 节点,用户拿到的就是
|
|
78
|
+
// {"ok":false,"error":"runtime_invalid","value":"codex-app-server"}
|
|
79
|
+
// 而目标机器的 daemon 日志里一行都没有(请求根本没到)。
|
|
80
|
+
// 用户无从知道:①哪些是允许的 ②这个名字是不是打错了 ③有没有别的办法。
|
|
81
|
+
//
|
|
82
|
+
// `allowed` 直接由 RUNTIMES 展开,不是手写的第二份清单 ——
|
|
83
|
+
// 🔴 手写一份会漂移,而漂移之后报错会**理直气壮地告诉用户一组错的名字**。
|
|
84
|
+
throw new ValidationError("runtime_invalid", {
|
|
85
|
+
value: typeof s === "string" ? s.slice(0, 80) : typeof s,
|
|
86
|
+
allowed: [...RUNTIMES],
|
|
87
|
+
// 这句提示的前提是查过的:`agent-network/src/normalize-runtime.ts` 的
|
|
88
|
+
// `RuntimeName` / `SUPPORTED_RUNTIME_NAMES` 是 7 个,包含此处不放行的那几个,
|
|
89
|
+
// 所以「在目标机器上直接建」确实是一条真实存在的路,不是安慰话。
|
|
90
|
+
hint: "daemon 远程创建目前只放行以上 runtime;其余 runtime 可以在目标机器上直接 `anet node create --runtime <name>` 创建。",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function validateModel(s: unknown): asserts s is string | undefined | null {
|
|
96
|
+
if (s === undefined || s === null) return;
|
|
97
|
+
if (typeof s !== "string" || s.length === 0 || s.length > 100 || !MODEL_RE.test(s) || MODEL_DOT_ONLY_SEGMENT.test(s)) {
|
|
98
|
+
throw new ValidationError("model_invalid", { value: typeof s === "string" ? s.slice(0, 80) : typeof s });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function validateFlagValue(k: string, v: unknown): void {
|
|
103
|
+
switch (k) {
|
|
104
|
+
case "permissionMode":
|
|
105
|
+
if (typeof v !== "string" || !(PERMISSION_MODES as readonly string[]).includes(v)) {
|
|
106
|
+
throw new ValidationError("flag_value_invalid", { field: k, reason: "must be one of default/acceptEdits/plan/bypassPermissions" });
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
case "dangerouslySkipPermissions":
|
|
110
|
+
if (typeof v !== "boolean") throw new ValidationError("flag_value_invalid", { field: k, reason: "must be boolean" });
|
|
111
|
+
return;
|
|
112
|
+
case "maxTurns":
|
|
113
|
+
if (!Number.isInteger(v) || (v as number) < 1 || (v as number) > 9999) {
|
|
114
|
+
throw new ValidationError("flag_value_invalid", { field: k, reason: "must be integer 1..9999" });
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
case "budget":
|
|
118
|
+
if (typeof v !== "number" || !Number.isFinite(v) || (v as number) < 0 || (v as number) > 1000) {
|
|
119
|
+
throw new ValidationError("flag_value_invalid", { field: k, reason: "must be number 0..1000 (decimals OK)" });
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
case "timeout":
|
|
123
|
+
if (!Number.isInteger(v) || (v as number) < 1 || (v as number) > 86400) {
|
|
124
|
+
throw new ValidationError("flag_value_invalid", { field: k, reason: "must be integer 1..86400" });
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
default:
|
|
128
|
+
throw new ValidationError("flag_key_unknown", { field: k });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// §4.4.7 — env_refs 7-step gate. networkSecretsGet is injected so
|
|
133
|
+
// callers can supply either a real DB lookup (hub) or a fixture (tests).
|
|
134
|
+
// The 6th step also reports the resolved value size; we return the
|
|
135
|
+
// resolved-but-not-yet-used map keyed by env-key so the caller can build
|
|
136
|
+
// env_blob without re-querying.
|
|
137
|
+
export interface EnvRefsContext {
|
|
138
|
+
callerNetworkId: string;
|
|
139
|
+
daemonAllowList: ReadonlySet<string>;
|
|
140
|
+
networkSecretsGet: (networkId: string, key: string) => string | undefined;
|
|
141
|
+
}
|
|
142
|
+
export function validateEnvRefs(
|
|
143
|
+
refs: unknown,
|
|
144
|
+
ctx: EnvRefsContext,
|
|
145
|
+
): Record<string, string> {
|
|
146
|
+
if (!Array.isArray(refs)) {
|
|
147
|
+
if (refs === undefined || refs === null) return {};
|
|
148
|
+
throw new ValidationError("env_refs_invalid", { reason: "must be array" });
|
|
149
|
+
}
|
|
150
|
+
// ① regex
|
|
151
|
+
for (const k of refs) {
|
|
152
|
+
if (typeof k !== "string" || !ENV_KEY_RE.test(k)) {
|
|
153
|
+
throw new ValidationError("env_key_invalid", { key: typeof k === "string" ? k.slice(0, 64) : typeof k });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// ② reserved denylist (v4 B1)
|
|
157
|
+
for (const k of refs) {
|
|
158
|
+
if (isReservedEnvKey(k)) {
|
|
159
|
+
throw new ValidationError("env_key_reserved", { key: k });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// ③ duplicate detection (must come before ④ count gate to give a
|
|
163
|
+
// truthful error code rather than "too many" for dup-spam).
|
|
164
|
+
const seen = new Set<string>();
|
|
165
|
+
for (const k of refs as string[]) {
|
|
166
|
+
if (seen.has(k)) throw new ValidationError("env_key_duplicate", { key: k });
|
|
167
|
+
seen.add(k);
|
|
168
|
+
}
|
|
169
|
+
// ④ count cap
|
|
170
|
+
if (seen.size > MAX_ENV_KEYS_PER_NODE) {
|
|
171
|
+
throw new ValidationError("env_key_too_many", { count: seen.size, max: MAX_ENV_KEYS_PER_NODE });
|
|
172
|
+
}
|
|
173
|
+
const out: Record<string, string> = {};
|
|
174
|
+
// ⑤ vault presence + ⑥ value size cap
|
|
175
|
+
for (const k of seen) {
|
|
176
|
+
const v = ctx.networkSecretsGet(ctx.callerNetworkId, k);
|
|
177
|
+
if (v === undefined) throw new ValidationError("secret_not_in_vault", { key: k });
|
|
178
|
+
if (Buffer.byteLength(v, "utf8") > MAX_ENV_VALUE_BYTES) {
|
|
179
|
+
throw new ValidationError("secret_too_large", { key: k, max: MAX_ENV_VALUE_BYTES });
|
|
180
|
+
}
|
|
181
|
+
out[k] = v;
|
|
182
|
+
}
|
|
183
|
+
// ⑦ daemon allowlist (per-host whitelist of which secrets this
|
|
184
|
+
// machine is allowed to receive; declared in daemon config by the
|
|
185
|
+
// host operator)
|
|
186
|
+
for (const k of seen) {
|
|
187
|
+
if (!ctx.daemonAllowList.has(k)) {
|
|
188
|
+
throw new ValidationError("secret_not_in_daemon_allowlist", { key: k });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// §4.4.7 — safe serializer for .env.local. Dotenv-quoted with escape
|
|
195
|
+
// for backslash / double-quote / newline / carriage-return so that a
|
|
196
|
+
// value containing `\n"evil=KEY2"` cannot pollute the next line.
|
|
197
|
+
export function serializeEnvLocal(env: Record<string, string>): string {
|
|
198
|
+
return Object.entries(env).map(([k, v]) => {
|
|
199
|
+
const esc = String(v)
|
|
200
|
+
.replace(/\\/g, "\\\\") // 1) backslashes first (or later escapes get un-escaped)
|
|
201
|
+
.replace(/"/g, '\\"') // 2) double-quotes
|
|
202
|
+
.replace(/\n/g, "\\n") // 3) newline → literal \n
|
|
203
|
+
.replace(/\r/g, "\\r"); // 4) carriage return → literal \r
|
|
204
|
+
return `${k}="${esc}"`;
|
|
205
|
+
}).join("\n") + "\n";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// app「新建节点」工作目录 —— hub 只判**形状**(daemon 才知道那台机器的 $HOME、系统目录、
|
|
209
|
+
// 目录里住没住别的节点,那几条在 agent-node/src/runtime/child-workdir.ts)。
|
|
210
|
+
// 这里挡的是「根本不像一条路径」的输入,让它在派发前就失败,而不是占一个请求行再被 daemon 拒。
|
|
211
|
+
export const MAX_WORKDIR_LEN = 1024;
|
|
212
|
+
export function validateWorkdir(s: unknown): asserts s is string | undefined | null {
|
|
213
|
+
if (s === undefined || s === null) return;
|
|
214
|
+
if (typeof s !== "string") throw new ValidationError("workdir_invalid", { reason: "must be string" });
|
|
215
|
+
const v = s.trim();
|
|
216
|
+
if (v.length === 0 || v.length > MAX_WORKDIR_LEN) {
|
|
217
|
+
throw new ValidationError("workdir_invalid", { reason: `length 1..${MAX_WORKDIR_LEN}` });
|
|
218
|
+
}
|
|
219
|
+
if (/[\u0000-\u001f\u007f]/.test(v)) throw new ValidationError("workdir_invalid", { reason: "control character" });
|
|
220
|
+
const shapeOk = v.startsWith("/") || v === "~" || v.startsWith("~/") || /^[A-Za-z]:[\\/]/.test(v);
|
|
221
|
+
if (!shapeOk) {
|
|
222
|
+
throw new ValidationError("workdir_invalid", { reason: "must be absolute (/…, ~/…, or C:\\…)", value: v.slice(0, 80) });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** daemon 自报的默认工作目录根(`daemon_capabilities.default_workdir_root`),消毒后返回;
|
|
227
|
+
* 没报 / 形状不对 = null。**非 null 即表示该 daemon 认 `node_spec.workdir`** ——
|
|
228
|
+
* create_node 的「老 daemon 不收 workdir」门和 /api/host-supervisors 的透出共用这一个判据。 */
|
|
229
|
+
export function daemonDefaultWorkdirRoot(configSnapshot: unknown): string | null {
|
|
230
|
+
try {
|
|
231
|
+
const snap = typeof configSnapshot === "string" ? JSON.parse(configSnapshot) : configSnapshot;
|
|
232
|
+
const v = (snap as any)?.daemon_capabilities?.default_workdir_root;
|
|
233
|
+
if (typeof v !== "string" || v.length === 0 || v.length > MAX_WORKDIR_LEN) return null;
|
|
234
|
+
if (/[\u0000-\u001f\u007f]/.test(v)) return null;
|
|
235
|
+
if (!(v.startsWith("/") || /^[A-Za-z]:[\\/]/.test(v))) return null;
|
|
236
|
+
return v;
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// §4.2.5 — channels fail-closed in P1.
|
|
243
|
+
export function validateChannelsP1(channels: unknown): void {
|
|
244
|
+
if (channels === undefined || channels === null) return;
|
|
245
|
+
if (!Array.isArray(channels)) {
|
|
246
|
+
throw new ValidationError("channels_not_supported_in_p1", { reason: "expected array or omitted" });
|
|
247
|
+
}
|
|
248
|
+
if (channels.length > 0) {
|
|
249
|
+
throw new ValidationError("channels_not_supported_in_p1", { received: channels.length, p3_tracker: "RFC-026 §5 P3" });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// §4.2.2 — fully validated NodeSpec, ready to feed into buildAnetArgs.
|
|
254
|
+
export interface NodeSpec {
|
|
255
|
+
name: string;
|
|
256
|
+
runtime: Runtime;
|
|
257
|
+
model?: string | null;
|
|
258
|
+
flags?: Record<string, unknown>;
|
|
259
|
+
channels?: unknown;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// kebab-case helper for flag names. permissionMode → permission-mode.
|
|
263
|
+
function kebab(k: string): string {
|
|
264
|
+
return k.replace(/([A-Z])/g, "-$1").toLowerCase();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// §4.2.2 — build the argv that gets fed verbatim to execFile (no
|
|
268
|
+
// shell, no string concat). Each value has already passed type/enum
|
|
269
|
+
// validation, so String() coercion is safe.
|
|
270
|
+
export function buildAnetArgs(spec: NodeSpec): string[] {
|
|
271
|
+
validateName(spec.name);
|
|
272
|
+
validateRuntime(spec.runtime);
|
|
273
|
+
validateModel(spec.model);
|
|
274
|
+
validateChannelsP1(spec.channels);
|
|
275
|
+
|
|
276
|
+
const args: string[] = ["node", "create", spec.name, "--runtime", spec.runtime];
|
|
277
|
+
if (spec.model) args.push("--model", spec.model);
|
|
278
|
+
if (spec.flags && typeof spec.flags === "object") {
|
|
279
|
+
for (const [k, v] of Object.entries(spec.flags)) {
|
|
280
|
+
if (!(FLAG_KEYS as readonly string[]).includes(k)) {
|
|
281
|
+
throw new ValidationError("flag_key_unknown", { field: k });
|
|
282
|
+
}
|
|
283
|
+
validateFlagValue(k, v);
|
|
284
|
+
args.push(`--${kebab(k)}`, String(v));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return args;
|
|
288
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import "./require-explicit-test-db.js";
|
|
3
|
+
import { db } from "./db.js";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { registerTools } from "./tools.js";
|
|
6
|
+
import { daemonDefaultWorkdirRoot, validateWorkdir, ValidationError } from "./create-node-validate.js";
|
|
7
|
+
import { addNetworkMember, login, register } from "./auth.js";
|
|
8
|
+
|
|
9
|
+
// app「新建节点」工作目录 —— hub 这一层管三件事:
|
|
10
|
+
// ① node_spec.workdir 的**形状**(绝对 / ~/… / C:\…),派发前就拒
|
|
11
|
+
// ② daemon 没自报 default_workdir_root(= 老 daemon,会静默忽略 workdir)⇒ 拒,不派
|
|
12
|
+
// ③ 请求行存下 workdir,get_create_request 原样交给 daemon;不带就**不出现**这个键
|
|
13
|
+
// 以及 /api/host-supervisors 只给 admin/owner 透出默认根(它是那台机器的家目录路径)。
|
|
14
|
+
|
|
15
|
+
const NET = "net_workdir_gate";
|
|
16
|
+
const USER_ID = "u_workdir_admin";
|
|
17
|
+
const DAEMON_ID = "node_workdir_daemon";
|
|
18
|
+
const DAEMON_ALIAS = "workdir-daemon";
|
|
19
|
+
const DAEMON_TOK = "tok_workdir_daemon";
|
|
20
|
+
const ROOT = "/home/user";
|
|
21
|
+
|
|
22
|
+
interface ToolHandler { (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>; }
|
|
23
|
+
interface Reply { ok?: boolean; error?: string; request_id?: string; node_spec?: any; [k: string]: unknown; }
|
|
24
|
+
|
|
25
|
+
function cleanup() {
|
|
26
|
+
for (const t of ["node_create_requests", "audit_log", "nodes", "sessions", "api_tokens", "network_members"]) {
|
|
27
|
+
try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET]); } catch {}
|
|
28
|
+
}
|
|
29
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
|
|
30
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER_ID]); } catch {}
|
|
31
|
+
}
|
|
32
|
+
beforeEach(cleanup);
|
|
33
|
+
afterAll(cleanup);
|
|
34
|
+
|
|
35
|
+
function seed(caps: Record<string, unknown>) {
|
|
36
|
+
db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'user', datetime('now'))`, [USER_ID]);
|
|
37
|
+
db.run(`INSERT OR REPLACE INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET, USER_ID]);
|
|
38
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'admin', datetime('now'))`, [USER_ID, NET]);
|
|
39
|
+
db.run(
|
|
40
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, hostname, created_at, updated_at, lifecycle_state)
|
|
41
|
+
VALUES (?1, ?2, ?2, ?3, ?4, 'host-x', datetime('now'), datetime('now'), 'active')`,
|
|
42
|
+
[DAEMON_ID, DAEMON_ALIAS, NET, JSON.stringify({ role: "host_supervisor", daemon_capabilities: { runtimes_supported: ["claude-agent-sdk"], ...caps } })],
|
|
43
|
+
);
|
|
44
|
+
db.run(
|
|
45
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
|
|
46
|
+
VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
|
|
47
|
+
[DAEMON_TOK, USER_ID, NET, `node:${DAEMON_ALIAS}`, `hash_${DAEMON_TOK}`],
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function tools(callerTokenId: string | null = null): Record<string, ToolHandler> {
|
|
52
|
+
const server = new McpServer({ name: "test", version: "0" }) as any;
|
|
53
|
+
const out: Record<string, ToolHandler> = {};
|
|
54
|
+
const origTool = server.tool.bind(server);
|
|
55
|
+
server.tool = (name: string, d: string, schema: any, handler: ToolHandler) => { out[name] = handler; return origTool(name, d, schema, handler); };
|
|
56
|
+
const origRegisterTool = server.registerTool?.bind(server);
|
|
57
|
+
if (origRegisterTool) {
|
|
58
|
+
server.registerTool = (name: string, cfg: any, handler: ToolHandler) => { out[name] = handler; return origRegisterTool(name, cfg, handler); };
|
|
59
|
+
}
|
|
60
|
+
registerTools(server, undefined, NET, USER_ID, null, true, callerTokenId);
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function call(h: ToolHandler, args: any): Promise<Reply> {
|
|
65
|
+
return JSON.parse((await h(args)).content[0].text) as Reply;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const baseSpec = { name: "wd-child", runtime: "claude-agent-sdk", model: "claude-sonnet-4-5", flags: {} };
|
|
69
|
+
|
|
70
|
+
function rowCount(): number {
|
|
71
|
+
return db.get<{ n: number }>("SELECT COUNT(*) AS n FROM node_create_requests WHERE daemon_node_id = ?1", DAEMON_ID)?.n ?? -1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("validateWorkdir — shape only", () => {
|
|
75
|
+
const ok = ["/home/user/proj", "/srv/x", "~", "~/proj", "C:\\Users\\alice\\proj", "D:/work", " /padded "];
|
|
76
|
+
for (const v of ok) test(`accepts ${JSON.stringify(v)}`, () => { expect(() => validateWorkdir(v)).not.toThrow(); });
|
|
77
|
+
test("accepts undefined / null (field omitted)", () => {
|
|
78
|
+
expect(() => validateWorkdir(undefined)).not.toThrow();
|
|
79
|
+
expect(() => validateWorkdir(null)).not.toThrow();
|
|
80
|
+
});
|
|
81
|
+
const bad: unknown[] = ["", " ", "proj", "./proj", "../x", "~alice/x", "C:proj", "/a\nb", "/a\u0000b", "x".repeat(10) + "/", "/" + "a".repeat(1024), 42, {}];
|
|
82
|
+
for (const v of bad) {
|
|
83
|
+
test(`rejects ${JSON.stringify(v)?.slice(0, 40)}`, () => {
|
|
84
|
+
let code = "";
|
|
85
|
+
try { validateWorkdir(v); } catch (e) { code = (e as ValidationError).code; }
|
|
86
|
+
expect(code).toBe("workdir_invalid");
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("daemonDefaultWorkdirRoot — sanitize the self-report", () => {
|
|
92
|
+
const snap = (v: unknown) => JSON.stringify({ daemon_capabilities: { default_workdir_root: v } });
|
|
93
|
+
test("absolute posix root passes through", () => { expect(daemonDefaultWorkdirRoot(snap(ROOT))).toBe(ROOT); });
|
|
94
|
+
test("windows root passes through", () => { expect(daemonDefaultWorkdirRoot(snap("C:\\Users\\alice"))).toBe("C:\\Users\\alice"); });
|
|
95
|
+
test("object snapshot (not string) also accepted", () => {
|
|
96
|
+
expect(daemonDefaultWorkdirRoot({ daemon_capabilities: { default_workdir_root: ROOT } })).toBe(ROOT);
|
|
97
|
+
});
|
|
98
|
+
for (const v of [undefined, null, "", "relative", "~/x", 7, "/a\nb", "/" + "a".repeat(1100)]) {
|
|
99
|
+
test(`not reported / malformed ${JSON.stringify(v)?.slice(0, 30)} → null`, () => {
|
|
100
|
+
expect(daemonDefaultWorkdirRoot(snap(v))).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
test("null / garbage snapshot → null", () => {
|
|
104
|
+
expect(daemonDefaultWorkdirRoot(null)).toBeNull();
|
|
105
|
+
expect(daemonDefaultWorkdirRoot("{not json")).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("create_node + workdir", () => {
|
|
110
|
+
test("🔴 old daemon (no default_workdir_root) + workdir → workdir_not_supported_by_daemon, NO row", async () => {
|
|
111
|
+
seed({});
|
|
112
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: { ...baseSpec, workdir: `${ROOT}/wd-child` } });
|
|
113
|
+
expect(r.ok).toBe(false);
|
|
114
|
+
expect(r.error).toBe("workdir_not_supported_by_daemon");
|
|
115
|
+
expect(rowCount()).toBe(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("old daemon WITHOUT workdir still dispatches (no regression for existing callers)", async () => {
|
|
119
|
+
seed({});
|
|
120
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: baseSpec });
|
|
121
|
+
expect(r.ok).toBe(true);
|
|
122
|
+
const row = db.get<{ workdir: string | null }>("SELECT workdir FROM node_create_requests WHERE request_id = ?1", r.request_id);
|
|
123
|
+
expect(row?.workdir).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("bad shape rejected before dispatch → workdir_invalid, NO row", async () => {
|
|
127
|
+
seed({ default_workdir_root: ROOT });
|
|
128
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: { ...baseSpec, workdir: "relative/dir" } });
|
|
129
|
+
expect(r.error).toBe("workdir_invalid");
|
|
130
|
+
expect(rowCount()).toBe(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("new daemon + workdir → row stores it and get_create_request hands it to the daemon", async () => {
|
|
134
|
+
seed({ default_workdir_root: ROOT });
|
|
135
|
+
const wd = `${ROOT}/wd-child`;
|
|
136
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: { ...baseSpec, workdir: wd } });
|
|
137
|
+
expect(r.ok).toBe(true);
|
|
138
|
+
const row = db.get<{ workdir: string | null }>("SELECT workdir FROM node_create_requests WHERE request_id = ?1", r.request_id);
|
|
139
|
+
expect(row?.workdir).toBe(wd);
|
|
140
|
+
const g = await call(tools(DAEMON_TOK).get_create_request, { request_id: r.request_id });
|
|
141
|
+
expect(g.ok).toBe(true);
|
|
142
|
+
expect(g.node_spec.workdir).toBe(wd);
|
|
143
|
+
expect(g.node_spec.name).toBe(baseSpec.name);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("workdir is trimmed before storage", async () => {
|
|
147
|
+
seed({ default_workdir_root: ROOT });
|
|
148
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: { ...baseSpec, workdir: ` ~/wd-child ` } });
|
|
149
|
+
expect(r.ok).toBe(true);
|
|
150
|
+
const row = db.get<{ workdir: string | null }>("SELECT workdir FROM node_create_requests WHERE request_id = ?1", r.request_id);
|
|
151
|
+
expect(row?.workdir).toBe("~/wd-child");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("new daemon WITHOUT workdir → get_create_request node_spec has NO workdir key (daemon keeps legacy layout)", async () => {
|
|
155
|
+
seed({ default_workdir_root: ROOT });
|
|
156
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: baseSpec });
|
|
157
|
+
expect(r.ok).toBe(true);
|
|
158
|
+
const g = await call(tools(DAEMON_TOK).get_create_request, { request_id: r.request_id });
|
|
159
|
+
expect(g.ok).toBe(true);
|
|
160
|
+
expect("workdir" in g.node_spec).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("audit detail records the requested workdir", async () => {
|
|
164
|
+
seed({ default_workdir_root: ROOT });
|
|
165
|
+
const wd = `${ROOT}/wd-child`;
|
|
166
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: { ...baseSpec, workdir: wd } });
|
|
167
|
+
const a = db.get<{ detail: string }>(
|
|
168
|
+
"SELECT detail FROM audit_log WHERE target_id = ?1 AND action = 'create_node_dispatch_attempted'", r.request_id,
|
|
169
|
+
);
|
|
170
|
+
expect(JSON.parse(a?.detail || "{}").workdir).toBe(wd);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("list_host_supervisors (MCP) surfaces default_workdir_root for admin", async () => {
|
|
174
|
+
seed({ default_workdir_root: ROOT });
|
|
175
|
+
const r = await call(tools().list_host_supervisors, {});
|
|
176
|
+
const d = (r as any).daemons.find((x: any) => x.daemon_node_id === DAEMON_ID);
|
|
177
|
+
expect(d.default_workdir_root).toBe(ROOT);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("list_host_supervisors (MCP) omits the key for an old daemon", async () => {
|
|
181
|
+
seed({});
|
|
182
|
+
const r = await call(tools().list_host_supervisors, {});
|
|
183
|
+
const d = (r as any).daemons.find((x: any) => x.daemon_node_id === DAEMON_ID);
|
|
184
|
+
expect(d).toBeDefined();
|
|
185
|
+
expect("default_workdir_root" in d).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ── REST /api/host-supervisors: admin sees the root, member does not ──────────
|
|
190
|
+
let BASE = "";
|
|
191
|
+
let server: any = null;
|
|
192
|
+
let ownerTok = "", memberTok = "", restNet = "", ownerId = "";
|
|
193
|
+
describe("/api/host-supervisors — default_workdir_root visibility", () => {
|
|
194
|
+
beforeAll(async () => {
|
|
195
|
+
process.env.HOST = "127.0.0.1";
|
|
196
|
+
const sfx = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
197
|
+
const pw = "BootstrapPw123Aa!";
|
|
198
|
+
const s = register(`wd_seed_${sfx}`, pw, undefined, "seed");
|
|
199
|
+
if (!s.ok) throw new Error("seed failed");
|
|
200
|
+
db.run("UPDATE users SET role = 'admin' WHERE username = ?1", [`wd_seed_${sfx}`]);
|
|
201
|
+
const o = register(`wd_owner_${sfx}`, pw, undefined, "seed");
|
|
202
|
+
if (!o.ok || !o.token) throw new Error("owner register failed");
|
|
203
|
+
ownerTok = o.token; restNet = o.network_id ?? "";
|
|
204
|
+
ownerId = login(`wd_owner_${sfx}`, pw).user!.user_id;
|
|
205
|
+
const m = register(`wd_member_${sfx}`, pw, undefined, "seed");
|
|
206
|
+
if (!m.ok || !m.token) throw new Error("member register failed");
|
|
207
|
+
memberTok = m.token;
|
|
208
|
+
const memberId = login(`wd_member_${sfx}`, pw).user!.user_id;
|
|
209
|
+
// 多用户 Agent 权限:新成员默认只看授权 Agent;本测试测的是「完全信任的成员」旧语义,显式 agent_access=all。
|
|
210
|
+
const add = addNetworkMember(restNet, memberId, "member", undefined, { agentAccess: "all" });
|
|
211
|
+
if (!add.ok) throw new Error("add member failed: " + add.error);
|
|
212
|
+
const { bootServer } = await import("./server.js");
|
|
213
|
+
server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
214
|
+
BASE = `http://127.0.0.1:${server.port}`;
|
|
215
|
+
for (const [alias, caps] of [["wd-new", { default_workdir_root: ROOT }], ["wd-old", {}]] as const) {
|
|
216
|
+
db.run(
|
|
217
|
+
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, runtime, model, config_path, channels, server, hostname, network_id, config_revision, config_snapshot)
|
|
218
|
+
VALUES (?1, ?2, ?2, 'claude-agent-sdk', 'm', '/tmp/cfg.json', '[]', 'h', 'h', ?3, 0, ?4)`,
|
|
219
|
+
[`wd_n_${alias}`, alias, restNet, JSON.stringify({ role: "host_supervisor", daemon_capabilities: caps })],
|
|
220
|
+
);
|
|
221
|
+
db.run(
|
|
222
|
+
`INSERT OR REPLACE INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, 'network')`,
|
|
223
|
+
[`wd_t_${alias}`, `hash_wd_${alias}`, ownerId, restNet, `node:${alias}`],
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
afterAll(() => {
|
|
228
|
+
try { server?.stop?.(true); } catch {}
|
|
229
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [restNet]); } catch {}
|
|
230
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1 AND name LIKE 'node:%'", [restNet]); } catch {}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
async function list(tok: string): Promise<any[]> {
|
|
234
|
+
const res = await fetch(`${BASE}/api/host-supervisors?network_id=${encodeURIComponent(restNet)}`, { headers: { Authorization: `Bearer ${tok}` } });
|
|
235
|
+
const b = await res.json();
|
|
236
|
+
if (!b.ok) throw new Error("list failed: " + JSON.stringify(b));
|
|
237
|
+
return b.daemons;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
test("owner sees default_workdir_root on the new daemon", async () => {
|
|
241
|
+
const d = (await list(ownerTok)).find(x => x.alias === "wd-new");
|
|
242
|
+
expect(d?.default_workdir_root).toBe(ROOT);
|
|
243
|
+
});
|
|
244
|
+
test("old daemon row has NO default_workdir_root key (app hides the row)", async () => {
|
|
245
|
+
const d = (await list(ownerTok)).find(x => x.alias === "wd-old");
|
|
246
|
+
expect(d).toBeDefined();
|
|
247
|
+
expect("default_workdir_root" in d).toBe(false);
|
|
248
|
+
});
|
|
249
|
+
test("🔴 member does NOT see the host's home path", async () => {
|
|
250
|
+
const rows = await list(memberTok);
|
|
251
|
+
const d = rows.find(x => x.alias === "wd-new");
|
|
252
|
+
expect(d).toBeDefined();
|
|
253
|
+
expect("default_workdir_root" in d).toBe(false);
|
|
254
|
+
});
|
|
255
|
+
});
|