@sleep2agi/commhub-server 0.9.0-preview.0 → 0.9.0-preview.100
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-kdf-migration.test.ts +378 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-tokens.test.ts +18 -9
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +554 -38
- 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 +830 -0
- package/src/config-apply-validate.test.ts +352 -0
- package/src/config-apply-validate.ts +249 -0
- 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/cross-tenant-injection.test.ts +110 -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 +1268 -42
- 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 -2128
- 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 +390 -0
- package/src/requirement-due-reminders.ts +310 -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.test.ts +450 -0
- package/src/retention.ts +281 -0
- 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-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 +641 -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/stale-sweeper.test.ts +181 -0
- package/src/stale-sweeper.ts +114 -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 +5426 -275
- 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,352 @@
|
|
|
1
|
+
// Coverage for the RFC-024 patch validators / classifiers. Pinned at
|
|
2
|
+
// the helper layer so the contract (allowlist + security-flag gate +
|
|
3
|
+
// per-field range/enum + apply-mode classification) can't drift
|
|
4
|
+
// silently. SEC-2 fail-closed regression guard included — any future
|
|
5
|
+
// "convenience" change that lets non-admin remote-flip a security flag
|
|
6
|
+
// fails these tests.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import {
|
|
10
|
+
ALLOWED_FLAGS,
|
|
11
|
+
SECURITY_SENSITIVE_FLAGS,
|
|
12
|
+
EDITABLE_CHANNELS,
|
|
13
|
+
isAllowedToChangeFlag,
|
|
14
|
+
computeApplyMode,
|
|
15
|
+
validatePatch,
|
|
16
|
+
narrowChannelsPatch,
|
|
17
|
+
} from "./config-apply-validate";
|
|
18
|
+
|
|
19
|
+
describe("ALLOWED_FLAGS — exact contract (no silent extension)", () => {
|
|
20
|
+
test("contains exactly the 5 fields from RFC-024 §4 (teammateMode dropped per #290 review)", () => {
|
|
21
|
+
// teammateMode was originally listed in the 6-flag scope but
|
|
22
|
+
// investigation found it's only consumed by the claude-code-cli
|
|
23
|
+
// spawn path (agent-network/bin/cli.ts), NOT by the agent-node-
|
|
24
|
+
// driven runtimes the config-apply pipeline targets. Including it
|
|
25
|
+
// would silently ack `applied` for no-op changes — same class as
|
|
26
|
+
// the BLOCKER 2 schema-mismatch issue. P2 to add a claude-code-cli
|
|
27
|
+
// config-apply path.
|
|
28
|
+
expect([...ALLOWED_FLAGS].sort()).toEqual([
|
|
29
|
+
"budget",
|
|
30
|
+
"dangerouslySkipPermissions",
|
|
31
|
+
"maxTurns",
|
|
32
|
+
"permissionMode",
|
|
33
|
+
"timeout",
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("SECURITY_SENSITIVE_FLAGS — privilege-elevation surface", () => {
|
|
39
|
+
test("contains the 2 privilege-elevation flags (teammateMode dropped — see ALLOWED_FLAGS comment)", () => {
|
|
40
|
+
expect([...SECURITY_SENSITIVE_FLAGS].sort()).toEqual([
|
|
41
|
+
"dangerouslySkipPermissions",
|
|
42
|
+
"permissionMode",
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("every security-sensitive flag is also in ALLOWED_FLAGS (sanity)", () => {
|
|
47
|
+
for (const f of SECURITY_SENSITIVE_FLAGS) expect(ALLOWED_FLAGS.has(f)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("isAllowedToChangeFlag — SEC-2 admin-gate (final policy 2026-06-28)", () => {
|
|
52
|
+
// Security-sensitive flags require admin role on the caller's
|
|
53
|
+
// network. Any non-admin role is rejected. Harmless flags fall
|
|
54
|
+
// through (`canWrite` upstream handles role !== viewer).
|
|
55
|
+
|
|
56
|
+
test("member role + dangerouslySkipPermissions → reject", () => {
|
|
57
|
+
const r = isAllowedToChangeFlag("member", { dangerouslySkipPermissions: true });
|
|
58
|
+
expect(r).not.toBeNull();
|
|
59
|
+
expect(r?.field).toBe("flags.dangerouslySkipPermissions");
|
|
60
|
+
expect(r?.reason).toMatch(/admin or owner/i);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("admin role + dangerouslySkipPermissions → pass (allowed)", () => {
|
|
64
|
+
expect(isAllowedToChangeFlag("admin", { dangerouslySkipPermissions: false })).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("admin role + permissionMode → pass", () => {
|
|
68
|
+
expect(isAllowedToChangeFlag("admin", { permissionMode: "default" })).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("teammateMode dropped from schema (P1 scope) — no role check fires for it", () => {
|
|
72
|
+
// teammateMode no longer in SECURITY_SENSITIVE_FLAGS, so SEC-2 gate
|
|
73
|
+
// doesn't fire. The patch-allowlist gate (validatePatch) rejects it
|
|
74
|
+
// separately as "not in allowlist", which is the correct surface.
|
|
75
|
+
expect(isAllowedToChangeFlag("admin", { teammateMode: true } as any)).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("owner role + permissionMode → pass (owner is higher than admin in anet RBAC)", () => {
|
|
79
|
+
// owner > admin in the network_members hierarchy (auth.ts line 354
|
|
80
|
+
// explicitly treats owner as the highest tier; updateMemberRole
|
|
81
|
+
// refuses to assign owner). The SEC-2 gate uses an admin-or-above
|
|
82
|
+
// semantic so the highest-privilege user isn't accidentally locked
|
|
83
|
+
// out of a permission their subordinates can use.
|
|
84
|
+
expect(isAllowedToChangeFlag("owner", { permissionMode: "default" })).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("owner role + dangerouslySkipPermissions → pass", () => {
|
|
88
|
+
expect(isAllowedToChangeFlag("owner", { dangerouslySkipPermissions: true })).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("viewer role + any security flag → reject", () => {
|
|
92
|
+
expect(isAllowedToChangeFlag("viewer", { dangerouslySkipPermissions: true })).not.toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("null role (no network membership) + any security flag → reject", () => {
|
|
96
|
+
expect(isAllowedToChangeFlag(null, { dangerouslySkipPermissions: true })).not.toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("member role + mixed bag with security flag → reject (checks every flag, not just first)", () => {
|
|
100
|
+
const r = isAllowedToChangeFlag("member", { permissionMode: "default", dangerouslySkipPermissions: true });
|
|
101
|
+
expect(r).not.toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("admin role + mixed bag (security + harmless) → pass", () => {
|
|
105
|
+
expect(isAllowedToChangeFlag("admin", { dangerouslySkipPermissions: true, maxTurns: 50 })).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("member role + only harmless flags (maxTurns) → pass", () => {
|
|
109
|
+
expect(isAllowedToChangeFlag("member", { maxTurns: 50 })).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("member role + only harmless flags (maxTurns + budget) → pass", () => {
|
|
113
|
+
expect(isAllowedToChangeFlag("member", { maxTurns: 50, budget: 100 })).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("any role + empty flag bag → pass (no flags to gate)", () => {
|
|
117
|
+
expect(isAllowedToChangeFlag(null, {})).toBeNull();
|
|
118
|
+
expect(isAllowedToChangeFlag("viewer", {})).toBeNull();
|
|
119
|
+
expect(isAllowedToChangeFlag("member", {})).toBeNull();
|
|
120
|
+
expect(isAllowedToChangeFlag("admin", {})).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("computeApplyMode — tier classifier (RFC-024 §4)", () => {
|
|
125
|
+
test("empty patch (model + flags both empty) → restart_only", () => {
|
|
126
|
+
expect(computeApplyMode(undefined, {})).toBe("restart_only");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("model alone → restart (SDK reinit needed)", () => {
|
|
130
|
+
expect(computeApplyMode("claude-opus-4", {})).toBe("restart");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("flags.permissionMode alone → restart", () => {
|
|
134
|
+
expect(computeApplyMode(undefined, { permissionMode: "auto" })).toBe("restart");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("flags.dangerouslySkipPermissions alone → restart", () => {
|
|
138
|
+
expect(computeApplyMode(undefined, { dangerouslySkipPermissions: true })).toBe("restart");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("teammateMode dropped from RESTART_REQUIRED_FLAGS (no longer in scope)", () => {
|
|
142
|
+
// teammateMode no longer in RESTART_REQUIRED_FLAGS. computeApplyMode
|
|
143
|
+
// sees only unknown-to-tier-table flags → returns "hot" by default.
|
|
144
|
+
// The patch-allowlist gate (validatePatch) rejects it separately.
|
|
145
|
+
expect(computeApplyMode(undefined, { teammateMode: false } as any)).toBe("hot");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("flags.timeout alone → restart (per RFC-024 §4 — module-level const today)", () => {
|
|
149
|
+
expect(computeApplyMode(undefined, { timeout: 60000 })).toBe("restart");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("flags.maxTurns alone → hot", () => {
|
|
153
|
+
expect(computeApplyMode(undefined, { maxTurns: 50 })).toBe("hot");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("flags.budget alone → hot", () => {
|
|
157
|
+
expect(computeApplyMode(undefined, { budget: 100 })).toBe("hot");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("mixed (model + maxTurns) → restart (strictest wins)", () => {
|
|
161
|
+
expect(computeApplyMode("gpt-5", { maxTurns: 50 })).toBe("restart");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("mixed (only hot flags) → hot", () => {
|
|
165
|
+
expect(computeApplyMode(undefined, { maxTurns: 50, budget: 100 })).toBe("hot");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("mixed (hot + one restart flag) → restart", () => {
|
|
169
|
+
expect(computeApplyMode(undefined, { maxTurns: 50, timeout: 60000 })).toBe("restart");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("validatePatch — model field", () => {
|
|
174
|
+
test("undefined model passes (no change)", () => {
|
|
175
|
+
expect(validatePatch(undefined, {})).toBeNull();
|
|
176
|
+
});
|
|
177
|
+
test("valid model string passes", () => {
|
|
178
|
+
expect(validatePatch("claude-opus-4", {})).toBeNull();
|
|
179
|
+
});
|
|
180
|
+
test("empty model string rejected", () => {
|
|
181
|
+
const r = validatePatch("", {});
|
|
182
|
+
expect(r?.field).toBe("model");
|
|
183
|
+
});
|
|
184
|
+
test("model >200 chars rejected", () => {
|
|
185
|
+
const r = validatePatch("x".repeat(201), {});
|
|
186
|
+
expect(r?.field).toBe("model");
|
|
187
|
+
});
|
|
188
|
+
test("non-string model rejected", () => {
|
|
189
|
+
const r = validatePatch(123 as any, {});
|
|
190
|
+
expect(r?.field).toBe("model");
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("validatePatch — flag allowlist (anything not in ALLOWED_FLAGS rejected)", () => {
|
|
195
|
+
test("unknown flag rejected", () => {
|
|
196
|
+
const r = validatePatch(undefined, { mysteryFlag: 1 } as any);
|
|
197
|
+
expect(r?.field).toBe("flags.mysteryFlag");
|
|
198
|
+
expect(r?.reason).toMatch(/allowlist/);
|
|
199
|
+
});
|
|
200
|
+
test("typo of allowed flag rejected (no fuzzy match)", () => {
|
|
201
|
+
const r = validatePatch(undefined, { permission_mode: "auto" } as any);
|
|
202
|
+
expect(r?.field).toBe("flags.permission_mode");
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("validatePatch — per-flag enum / range", () => {
|
|
207
|
+
test("permissionMode valid values", () => {
|
|
208
|
+
for (const v of ["default", "auto", "bypassPermissions", "acceptEdits", "plan"]) {
|
|
209
|
+
expect(validatePatch(undefined, { permissionMode: v })).toBeNull();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
test("permissionMode invalid value rejected", () => {
|
|
213
|
+
const r = validatePatch(undefined, { permissionMode: "godmode" } as any);
|
|
214
|
+
expect(r?.field).toBe("flags.permissionMode");
|
|
215
|
+
});
|
|
216
|
+
test("dangerouslySkipPermissions must be boolean", () => {
|
|
217
|
+
expect(validatePatch(undefined, { dangerouslySkipPermissions: true })).toBeNull();
|
|
218
|
+
expect(validatePatch(undefined, { dangerouslySkipPermissions: false })).toBeNull();
|
|
219
|
+
expect(validatePatch(undefined, { dangerouslySkipPermissions: "true" } as any)?.field).toBe("flags.dangerouslySkipPermissions");
|
|
220
|
+
expect(validatePatch(undefined, { dangerouslySkipPermissions: 1 } as any)?.field).toBe("flags.dangerouslySkipPermissions");
|
|
221
|
+
});
|
|
222
|
+
test("teammateMode rejected as not-in-allowlist (dropped from P1 scope)", () => {
|
|
223
|
+
// teammateMode was originally allowed but dropped per #290 review
|
|
224
|
+
// (no consumer in agent-node runtimes). validatePatch now rejects
|
|
225
|
+
// it the same way it rejects any unknown flag.
|
|
226
|
+
const r = validatePatch(undefined, { teammateMode: true } as any);
|
|
227
|
+
expect(r?.field).toBe("flags.teammateMode");
|
|
228
|
+
expect(r?.reason).toMatch(/allowlist/);
|
|
229
|
+
});
|
|
230
|
+
test("maxTurns must be integer in [0, 10000]", () => {
|
|
231
|
+
expect(validatePatch(undefined, { maxTurns: 0 })).toBeNull();
|
|
232
|
+
expect(validatePatch(undefined, { maxTurns: 10000 })).toBeNull();
|
|
233
|
+
expect(validatePatch(undefined, { maxTurns: -1 })?.field).toBe("flags.maxTurns");
|
|
234
|
+
expect(validatePatch(undefined, { maxTurns: 10001 })?.field).toBe("flags.maxTurns");
|
|
235
|
+
expect(validatePatch(undefined, { maxTurns: 1.5 })?.field).toBe("flags.maxTurns");
|
|
236
|
+
expect(validatePatch(undefined, { maxTurns: "5" } as any)?.field).toBe("flags.maxTurns");
|
|
237
|
+
});
|
|
238
|
+
test("budget must be number in [0, 1_000_000]", () => {
|
|
239
|
+
expect(validatePatch(undefined, { budget: 0 })).toBeNull();
|
|
240
|
+
expect(validatePatch(undefined, { budget: 500.5 })).toBeNull();
|
|
241
|
+
expect(validatePatch(undefined, { budget: 1_000_000 })).toBeNull();
|
|
242
|
+
expect(validatePatch(undefined, { budget: -1 })?.field).toBe("flags.budget");
|
|
243
|
+
expect(validatePatch(undefined, { budget: 1_000_001 })?.field).toBe("flags.budget");
|
|
244
|
+
});
|
|
245
|
+
test("timeout must be integer ms in [0, 3_600_000]", () => {
|
|
246
|
+
expect(validatePatch(undefined, { timeout: 30000 })).toBeNull();
|
|
247
|
+
expect(validatePatch(undefined, { timeout: 3_600_000 })).toBeNull();
|
|
248
|
+
expect(validatePatch(undefined, { timeout: -1 })?.field).toBe("flags.timeout");
|
|
249
|
+
expect(validatePatch(undefined, { timeout: 3_600_001 })?.field).toBe("flags.timeout");
|
|
250
|
+
expect(validatePatch(undefined, { timeout: 60.5 })?.field).toBe("flags.timeout");
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe("validatePatch — combinations", () => {
|
|
255
|
+
test("valid model + valid flag bag passes", () => {
|
|
256
|
+
expect(validatePatch("claude-opus-4", { maxTurns: 50, budget: 100 })).toBeNull();
|
|
257
|
+
});
|
|
258
|
+
test("first invalid field surfaces (good model + bad flag)", () => {
|
|
259
|
+
const r = validatePatch("claude-opus-4", { maxTurns: -1 });
|
|
260
|
+
expect(r?.field).toBe("flags.maxTurns");
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// ── #260 P5 — channels support (restart-tier, non-security-sensitive) ─
|
|
265
|
+
describe("EDITABLE_CHANNELS — mirrors agent-node runtime capability (cli.ts:671-676)", () => {
|
|
266
|
+
test("contains exactly telegram + feishu (channels agent-node actually forks workers for)", () => {
|
|
267
|
+
expect(EDITABLE_CHANNELS.size).toBe(2);
|
|
268
|
+
expect(EDITABLE_CHANNELS.has("telegram")).toBe(true);
|
|
269
|
+
expect(EDITABLE_CHANNELS.has("feishu")).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
test("commhub is NOT editable — it's the RPC transport, not a per-node channel worker", () => {
|
|
272
|
+
expect(EDITABLE_CHANNELS.has("commhub")).toBe(false);
|
|
273
|
+
});
|
|
274
|
+
test("wechat is NOT editable — roadmap only on dashboard", () => {
|
|
275
|
+
expect(EDITABLE_CHANNELS.has("wechat")).toBe(false);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("narrowChannelsPatch — hostile input hygiene at the wire boundary", () => {
|
|
280
|
+
test("straight allow-listed input passes verbatim", () => {
|
|
281
|
+
expect(narrowChannelsPatch(["telegram", "feishu"])).toEqual(["telegram", "feishu"]);
|
|
282
|
+
});
|
|
283
|
+
test("empty array preserved — 'disable all editable channels' is a real state", () => {
|
|
284
|
+
expect(narrowChannelsPatch([])).toEqual([]);
|
|
285
|
+
});
|
|
286
|
+
test("non-array returns null (patch didn't touch channels)", () => {
|
|
287
|
+
expect(narrowChannelsPatch(undefined)).toBeNull();
|
|
288
|
+
expect(narrowChannelsPatch("telegram" as any)).toBeNull();
|
|
289
|
+
expect(narrowChannelsPatch({} as any)).toBeNull();
|
|
290
|
+
});
|
|
291
|
+
test("case-folded to lower", () => {
|
|
292
|
+
expect(narrowChannelsPatch(["FEISHU", "Telegram"])).toEqual(["feishu", "telegram"]);
|
|
293
|
+
});
|
|
294
|
+
test("duplicates deduped (Set)", () => {
|
|
295
|
+
expect(narrowChannelsPatch(["telegram", "telegram", "feishu", "TELEGRAM"])).toEqual(["telegram", "feishu"]);
|
|
296
|
+
});
|
|
297
|
+
test("unknown channel keys dropped silently (roadmap wechat, transport commhub, evil keys)", () => {
|
|
298
|
+
expect(narrowChannelsPatch(["wechat", "telegram", "evil-hacker", "commhub"])).toEqual(["telegram"]);
|
|
299
|
+
});
|
|
300
|
+
test("non-string entries dropped (numbers, objects, nulls)", () => {
|
|
301
|
+
expect(narrowChannelsPatch(["telegram", 42, {}, null, "feishu"] as any)).toEqual(["telegram", "feishu"]);
|
|
302
|
+
});
|
|
303
|
+
test("SQL-injection-shaped value dropped (not exact key match)", () => {
|
|
304
|
+
expect(narrowChannelsPatch(["telegram; drop table users;", "feishu"])).toEqual(["feishu"]);
|
|
305
|
+
});
|
|
306
|
+
test("whitespace around a key still trims to the key", () => {
|
|
307
|
+
expect(narrowChannelsPatch([" feishu "])).toEqual(["feishu"]);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe("computeApplyMode — channels tier (restart)", () => {
|
|
312
|
+
test("channels-only patch → restart (boot forks per-channel workers)", () => {
|
|
313
|
+
expect(computeApplyMode(undefined, {}, ["feishu"])).toBe("restart");
|
|
314
|
+
});
|
|
315
|
+
test("empty-array channels → restart (still a state change)", () => {
|
|
316
|
+
expect(computeApplyMode(undefined, {}, [])).toBe("restart");
|
|
317
|
+
});
|
|
318
|
+
test("no channels key + no flags + no model → restart_only", () => {
|
|
319
|
+
expect(computeApplyMode(undefined, {}, undefined)).toBe("restart_only");
|
|
320
|
+
});
|
|
321
|
+
test("channels + hot-tier flag together stays restart (upgrade rules)", () => {
|
|
322
|
+
expect(computeApplyMode(undefined, { maxTurns: 5 }, ["telegram"])).toBe("restart");
|
|
323
|
+
});
|
|
324
|
+
test("channels + model → restart (model was already restart)", () => {
|
|
325
|
+
expect(computeApplyMode("gpt-5", {}, ["feishu"])).toBe("restart");
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe("validatePatch — channels defensive gate", () => {
|
|
330
|
+
test("valid channels array passes", () => {
|
|
331
|
+
expect(validatePatch(undefined, {}, ["telegram", "feishu"])).toBeNull();
|
|
332
|
+
expect(validatePatch(undefined, {}, [])).toBeNull();
|
|
333
|
+
expect(validatePatch(undefined, {}, ["telegram"])).toBeNull();
|
|
334
|
+
});
|
|
335
|
+
test("commhub rejected — RPC transport, not a per-node channel", () => {
|
|
336
|
+
const r = validatePatch(undefined, {}, ["commhub"]);
|
|
337
|
+
expect(r?.field).toBe("channels.commhub");
|
|
338
|
+
});
|
|
339
|
+
test("out-of-allowlist channel rejected (caller bypassed narrowing)", () => {
|
|
340
|
+
const r = validatePatch(undefined, {}, ["wechat"]);
|
|
341
|
+
expect(r?.field).toBe("channels.wechat");
|
|
342
|
+
expect(r?.reason).toMatch(/allowlist/);
|
|
343
|
+
});
|
|
344
|
+
test("non-string entry rejected", () => {
|
|
345
|
+
const r = validatePatch(undefined, {}, [42] as any);
|
|
346
|
+
expect(r?.field).toBe("channels");
|
|
347
|
+
});
|
|
348
|
+
test("non-array rejected", () => {
|
|
349
|
+
const r = validatePatch(undefined, {}, "telegram" as any);
|
|
350
|
+
expect(r?.field).toBe("channels");
|
|
351
|
+
});
|
|
352
|
+
});
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Pure validators / classifiers for RFC-024 node-config-apply. Extracted
|
|
2
|
+
// from server/src/tools.ts so the contract (allowlist, security-sensitive
|
|
3
|
+
// flag set, range/enum rules, apply-mode classification) is unit-testable
|
|
4
|
+
// without spinning up an MCP server. The corresponding tool handlers in
|
|
5
|
+
// tools.ts wrap these with auth checks (SEC-1 network scope, ntok_/utok_
|
|
6
|
+
// caller identity, role gates).
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Fields the dashboard may change. Anything not in this list is rejected
|
|
10
|
+
* by hub-side validation regardless of role — the UI cannot smuggle
|
|
11
|
+
* extra keys past this gate.
|
|
12
|
+
*/
|
|
13
|
+
export const ALLOWED_FLAGS = new Set<string>([
|
|
14
|
+
"permissionMode",
|
|
15
|
+
"dangerouslySkipPermissions",
|
|
16
|
+
"maxTurns",
|
|
17
|
+
"budget",
|
|
18
|
+
"timeout",
|
|
19
|
+
]);
|
|
20
|
+
// NB on teammateMode (dropped from P1 scope per #290 cross-agent
|
|
21
|
+
// review): teammateMode is consumed ONLY by the claude-code-cli
|
|
22
|
+
// spawn path in agent-network/bin/cli.ts (passed as --teammate-mode
|
|
23
|
+
// CLI arg). The agent-node-driven runtimes (claude-agent-sdk /
|
|
24
|
+
// codex-sdk / grok-build-acp) that the config-apply pipeline targets
|
|
25
|
+
// do NOT consume it. Including it in the allowlist would silently
|
|
26
|
+
// ack `applied` for changes that have zero effect — same class as
|
|
27
|
+
// the BLOCKER 2 schema-mismatch issue. P2: add a claude-code-cli
|
|
28
|
+
// config-apply path that respawns the CLI process.
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Security-sensitive flags — remote changes are privilege-elevation
|
|
32
|
+
* operations. Final policy (decided 2026-06-28 by 通信龙 per Vincent
|
|
33
|
+
* autonomy grant): **caller role must equal `admin` to flip these
|
|
34
|
+
* flags remotely.** Non-admin requests are rejected with
|
|
35
|
+
* `insufficient_role_for_security_flag`. Cross-network requests are
|
|
36
|
+
* also blocked but via SEC-1 (network scope), not this gate.
|
|
37
|
+
*
|
|
38
|
+
* Dashboard is expected to grey out these inputs for non-admin
|
|
39
|
+
* sessions, but hub does NOT trust the dashboard's UI gate — every
|
|
40
|
+
* tool call is re-checked here. `curl` direct to `/mcp` is a real
|
|
41
|
+
* attack vector.
|
|
42
|
+
*/
|
|
43
|
+
export const SECURITY_SENSITIVE_FLAGS = new Set<string>([
|
|
44
|
+
"permissionMode",
|
|
45
|
+
"dangerouslySkipPermissions",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Flags that require a node process restart (not in-process hot reload).
|
|
50
|
+
* Used by computeApplyMode + the per-tool tier classifier. `timeout`
|
|
51
|
+
* sits here today because CLAUDE_TIMEOUT_MS is a module-level const at
|
|
52
|
+
* boot; P2 will move it to per-think read which would let it become hot.
|
|
53
|
+
*/
|
|
54
|
+
export const RESTART_REQUIRED_FLAGS = new Set<string>([
|
|
55
|
+
"permissionMode",
|
|
56
|
+
"dangerouslySkipPermissions",
|
|
57
|
+
"timeout",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Channels the dashboard may enable/disable on a node via
|
|
62
|
+
* update_node_config. Intentionally narrow — the panel only exposes
|
|
63
|
+
* enable/disable, per-channel secrets stay in the node's local
|
|
64
|
+
* config.json (see #260 wire discipline).
|
|
65
|
+
*
|
|
66
|
+
* The list mirrors agent-node's actual runtime capability. See
|
|
67
|
+
* agent-node/src/cli.ts:671-676 — CHANNELS are parsed into per-type
|
|
68
|
+
* worker inits (initTelegramChannel + initFeishuChannel), and any
|
|
69
|
+
* other channel type triggers `process.exit(1)` at boot. So the
|
|
70
|
+
* hub-side allow-list here is telegram + feishu ONLY. `commhub` is
|
|
71
|
+
* the RPC transport that every node speaks unconditionally; treating
|
|
72
|
+
* it as a per-node channel would be a UX lie (toggle looks off but
|
|
73
|
+
* commhub is always on).
|
|
74
|
+
*
|
|
75
|
+
* Dashboard PR #31 currently includes `commhub` in its own
|
|
76
|
+
* EDITABLE_CHANNELS whitelist — that follow-up narrowing is filed for
|
|
77
|
+
* the next dashboard rally, and until then hub silently drops any
|
|
78
|
+
* `commhub` entry via narrowChannelsPatch.
|
|
79
|
+
*
|
|
80
|
+
* Not part of SECURITY_SENSITIVE_FLAGS (通信龙 P5 派工): flipping a
|
|
81
|
+
* channel on/off is a lifecycle-tier operation like `restart_node`, not
|
|
82
|
+
* a privilege elevation. Cross-tenant reach is already gated by SEC-1.
|
|
83
|
+
*/
|
|
84
|
+
export const EDITABLE_CHANNELS = new Set<string>([
|
|
85
|
+
"telegram",
|
|
86
|
+
"feishu",
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Role-gate for the patch's flag set. SEC-2 enforcement lives here.
|
|
91
|
+
*
|
|
92
|
+
* Returns null on pass, or `{ field, reason }` on reject. The reject
|
|
93
|
+
* payload shape matches the tool handler's error envelope so the
|
|
94
|
+
* caller can forward it without re-shaping.
|
|
95
|
+
*
|
|
96
|
+
* Policy (final, 2026-06-28):
|
|
97
|
+
* - security-sensitive flags → caller role MUST be admin-or-above
|
|
98
|
+
* (admin OR owner; owner > admin in the anet RBAC, per auth.ts
|
|
99
|
+
* network_members hierarchy — viewer < member < admin < owner)
|
|
100
|
+
* - harmless flags → handled by upstream `canWrite` (role !== viewer);
|
|
101
|
+
* this helper passes them through (no per-flag gate beyond
|
|
102
|
+
* allowlist/range validation)
|
|
103
|
+
*
|
|
104
|
+
* Caller must pass the role string resolved from the user's
|
|
105
|
+
* network_members row (not the token's bearer-level role). Owners
|
|
106
|
+
* are NOT included via "admin ⊇ owner" inheritance — they're a
|
|
107
|
+
* distinct higher tier; the check is explicit OR so any future role
|
|
108
|
+
* (e.g. "super_admin") needs an explicit allowlist update.
|
|
109
|
+
*/
|
|
110
|
+
const SECURITY_ADMIN_ROLES = new Set<string>(["admin", "owner"]);
|
|
111
|
+
|
|
112
|
+
export function isAllowedToChangeFlag(
|
|
113
|
+
role: string | null,
|
|
114
|
+
patchFlags: Record<string, unknown>,
|
|
115
|
+
): { field: string; reason: string } | null {
|
|
116
|
+
for (const key of Object.keys(patchFlags)) {
|
|
117
|
+
if (SECURITY_SENSITIVE_FLAGS.has(key)) {
|
|
118
|
+
if (!role || !SECURITY_ADMIN_ROLES.has(role)) {
|
|
119
|
+
return {
|
|
120
|
+
field: `flags.${key}`,
|
|
121
|
+
reason: "remote change of security-sensitive flags requires admin or owner role on this network",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Classify the patch's required apply mode. Empty patch (model + flags
|
|
131
|
+
* both empty, no channels) → "restart_only" (used by restart_node tool).
|
|
132
|
+
* Any restart-required field present, OR a channels change → "restart"
|
|
133
|
+
* (channels is restart-tier by design: the node reads its channel set
|
|
134
|
+
* once at boot in agent-node/src/cli.ts and forks per-channel workers
|
|
135
|
+
* from it, so a channel enable/disable takes effect on process restart
|
|
136
|
+
* only — see #260 P5 wire discipline). Otherwise → "hot".
|
|
137
|
+
*/
|
|
138
|
+
export function computeApplyMode(
|
|
139
|
+
model: string | undefined,
|
|
140
|
+
flags: Record<string, unknown>,
|
|
141
|
+
channels?: string[] | undefined,
|
|
142
|
+
): "hot" | "restart" | "restart_only" {
|
|
143
|
+
const fieldCount = (model !== undefined ? 1 : 0) + Object.keys(flags).length + (channels !== undefined ? 1 : 0);
|
|
144
|
+
if (fieldCount === 0) return "restart_only";
|
|
145
|
+
if (model !== undefined) return "restart";
|
|
146
|
+
if (channels !== undefined) return "restart";
|
|
147
|
+
for (const key of Object.keys(flags)) {
|
|
148
|
+
if (RESTART_REQUIRED_FLAGS.has(key)) return "restart";
|
|
149
|
+
}
|
|
150
|
+
return "hot";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Narrow + allow-list `channels` from untrusted patch JSON. Returns the
|
|
155
|
+
* final channel key list (case-folded, deduped, in EDITABLE_CHANNELS
|
|
156
|
+
* order-preserving), OR null if the input isn't an array. Non-strings,
|
|
157
|
+
* unknown keys, empty strings, and duplicates are silently dropped —
|
|
158
|
+
* same fail-shape as the dashboard route's whitelist so the two sides
|
|
159
|
+
* behave identically end-to-end. Callers pass `undefined` through when
|
|
160
|
+
* the patch didn't include a channels key at all (a distinct case from
|
|
161
|
+
* `channels: []` which means "disable all editable channels").
|
|
162
|
+
*/
|
|
163
|
+
export function narrowChannelsPatch(raw: unknown): string[] | null {
|
|
164
|
+
if (!Array.isArray(raw)) return null;
|
|
165
|
+
const allow = EDITABLE_CHANNELS;
|
|
166
|
+
const seen = new Set<string>();
|
|
167
|
+
const out: string[] = [];
|
|
168
|
+
for (const v of raw) {
|
|
169
|
+
if (typeof v !== "string") continue;
|
|
170
|
+
const key = v.trim().toLowerCase();
|
|
171
|
+
if (!allow.has(key) || seen.has(key)) continue;
|
|
172
|
+
seen.add(key);
|
|
173
|
+
out.push(key);
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Validate the patch shape — allowlist + per-field range / enum. Returns
|
|
180
|
+
* null on pass, or `{ field, reason }` on reject. Caller wraps in error
|
|
181
|
+
* envelope.
|
|
182
|
+
*/
|
|
183
|
+
export function validatePatch(
|
|
184
|
+
model: string | undefined,
|
|
185
|
+
flags: Record<string, unknown>,
|
|
186
|
+
channels?: string[] | undefined,
|
|
187
|
+
): { field: string; reason: string } | null {
|
|
188
|
+
if (model !== undefined) {
|
|
189
|
+
if (typeof model !== "string" || model.length === 0 || model.length > 200) {
|
|
190
|
+
return { field: "model", reason: "must be a non-empty string ≤ 200 chars" };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (channels !== undefined) {
|
|
194
|
+
// Zod already enforced string[], and narrowChannelsPatch dropped
|
|
195
|
+
// unknown keys before this point — but re-verify defensively so a
|
|
196
|
+
// future caller that skips narrowChannelsPatch can't smuggle
|
|
197
|
+
// unknown keys through validatePatch alone.
|
|
198
|
+
if (!Array.isArray(channels)) {
|
|
199
|
+
return { field: "channels", reason: "must be an array of strings" };
|
|
200
|
+
}
|
|
201
|
+
for (const c of channels) {
|
|
202
|
+
if (typeof c !== "string") {
|
|
203
|
+
return { field: "channels", reason: "must contain only strings" };
|
|
204
|
+
}
|
|
205
|
+
if (!EDITABLE_CHANNELS.has(c)) {
|
|
206
|
+
return { field: `channels.${c}`, reason: "not in editable channels allowlist" };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
for (const [key, val] of Object.entries(flags)) {
|
|
211
|
+
if (!ALLOWED_FLAGS.has(key)) {
|
|
212
|
+
return { field: `flags.${key}`, reason: "not in allowlist" };
|
|
213
|
+
}
|
|
214
|
+
switch (key) {
|
|
215
|
+
case "permissionMode":
|
|
216
|
+
if (
|
|
217
|
+
typeof val !== "string" ||
|
|
218
|
+
!["default", "auto", "bypassPermissions", "acceptEdits", "plan"].includes(val)
|
|
219
|
+
) {
|
|
220
|
+
return {
|
|
221
|
+
field: "flags.permissionMode",
|
|
222
|
+
reason: "must be one of default/auto/bypassPermissions/acceptEdits/plan",
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
break;
|
|
226
|
+
case "dangerouslySkipPermissions":
|
|
227
|
+
if (typeof val !== "boolean") {
|
|
228
|
+
return { field: `flags.${key}`, reason: "must be boolean" };
|
|
229
|
+
}
|
|
230
|
+
break;
|
|
231
|
+
case "maxTurns":
|
|
232
|
+
if (typeof val !== "number" || !Number.isInteger(val) || val < 0 || val > 10000) {
|
|
233
|
+
return { field: "flags.maxTurns", reason: "must be an integer in [0, 10000]" };
|
|
234
|
+
}
|
|
235
|
+
break;
|
|
236
|
+
case "budget":
|
|
237
|
+
if (typeof val !== "number" || val < 0 || val > 1000000) {
|
|
238
|
+
return { field: "flags.budget", reason: "must be a number in [0, 1_000_000]" };
|
|
239
|
+
}
|
|
240
|
+
break;
|
|
241
|
+
case "timeout":
|
|
242
|
+
if (typeof val !== "number" || !Number.isInteger(val) || val < 0 || val > 3_600_000) {
|
|
243
|
+
return { field: "flags.timeout", reason: "must be an integer ms in [0, 3_600_000]" };
|
|
244
|
+
}
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|