@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.130
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 +930 -35
- 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 +129 -0
- package/src/rest-identity.ts +125 -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 +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 +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 +141 -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-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 +3927 -323
- 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,85 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
// commhub-server must refuse what it does not understand instead of starting a
|
|
8
|
+
// Hub on the defaults (the live ~/.commhub/commhub.db). Every child gets a
|
|
9
|
+
// throwaway HOME and a throwaway PORT, so even a regression binds nothing real.
|
|
10
|
+
const BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "commhub.ts");
|
|
11
|
+
const PKG_VERSION = JSON.parse(readFileSync(join(dirname(BIN), "..", "package.json"), "utf8")).version as string;
|
|
12
|
+
const home = mkdtempSync(join(tmpdir(), "anet-bin-args-"));
|
|
13
|
+
const PORT = String(26000 + Math.floor(Math.random() * 3000));
|
|
14
|
+
|
|
15
|
+
afterAll(() => rmSync(home, { recursive: true, force: true }));
|
|
16
|
+
|
|
17
|
+
function run(args: string[], extraEnv: Record<string, string> = {}, timeoutMs = 15_000) {
|
|
18
|
+
const proc = Bun.spawnSync(["bun", BIN, ...args], {
|
|
19
|
+
env: { PATH: process.env.PATH ?? "", HOME: home, PORT, HOST: "127.0.0.1", ...extraEnv },
|
|
20
|
+
stdout: "pipe", stderr: "pipe", timeout: timeoutMs,
|
|
21
|
+
});
|
|
22
|
+
return { code: proc.exitCode, stdout: proc.stdout.toString(), stderr: proc.stderr.toString() };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function listening(port: string): Promise<boolean> {
|
|
26
|
+
try { await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(500) }); return true; } catch { return false; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("commhub-server argument handling", () => {
|
|
30
|
+
for (const [args, message] of [
|
|
31
|
+
[["bogus"], "unknown command bogus"],
|
|
32
|
+
[["--port=29999"], "unknown option --port=29999"],
|
|
33
|
+
[["--bogus-flag"], "unknown option --bogus-flag"],
|
|
34
|
+
[["--port"], "--port needs a value"],
|
|
35
|
+
[["--db", "--dev-open"], "--db needs a value"],
|
|
36
|
+
] as const) {
|
|
37
|
+
test(`${args.join(" ")} → exit 2, no database, nothing listening`, async () => {
|
|
38
|
+
const r = run([...args]);
|
|
39
|
+
expect(r.code).toBe(2);
|
|
40
|
+
expect(r.stderr).toContain(message);
|
|
41
|
+
expect(r.stderr).toContain("commhub-server --help");
|
|
42
|
+
expect(existsSync(join(home, ".commhub"))).toBe(false);
|
|
43
|
+
expect(await listening(PORT)).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test("migrate-to-pg with missing or unknown arguments exits 2 and starts nothing", async () => {
|
|
48
|
+
const missing = run(["migrate-to-pg", "--from", "/x.db"]);
|
|
49
|
+
expect(missing.code).toBe(2);
|
|
50
|
+
expect(missing.stdout).toContain("commhub-server migrate-to-pg --from <sqlite file> --to <postgres url>");
|
|
51
|
+
const unknown = run(["migrate-to-pg", "--from", "/x.db", "--to", "postgres://u@127.0.0.1/anet_x_test", "--bogus"]);
|
|
52
|
+
expect(unknown.code).toBe(2);
|
|
53
|
+
expect(unknown.stderr).toContain("unknown argument --bogus");
|
|
54
|
+
expect(existsSync(join(home, ".commhub"))).toBe(false);
|
|
55
|
+
expect(await listening(PORT)).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("--help and --version exit 0 without starting", async () => {
|
|
59
|
+
const help = run(["--help"]);
|
|
60
|
+
expect(help.code).toBe(0);
|
|
61
|
+
expect(help.stdout).toContain("--port, -p <port>");
|
|
62
|
+
const version = run(["--version"]);
|
|
63
|
+
expect(version.code).toBe(0);
|
|
64
|
+
expect(version.stdout.trim()).toBe(PKG_VERSION);
|
|
65
|
+
expect(existsSync(join(home, ".commhub"))).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("the known forms still start a Hub", async () => {
|
|
69
|
+
const db = join(home, "known.db");
|
|
70
|
+
const port = String(Number(PORT) + 1);
|
|
71
|
+
const child = Bun.spawn(["bun", BIN, "--port", port, "--host", "127.0.0.1", "--db", db, "--dev-open"], {
|
|
72
|
+
env: { PATH: process.env.PATH ?? "", HOME: home }, stdout: "pipe", stderr: "pipe",
|
|
73
|
+
});
|
|
74
|
+
try {
|
|
75
|
+
let up = false;
|
|
76
|
+
for (let i = 0; i < 100 && !up; i++) { up = await listening(port); if (!up) await Bun.sleep(100); }
|
|
77
|
+
expect(up).toBe(true);
|
|
78
|
+
expect(existsSync(db)).toBe(true);
|
|
79
|
+
expect(existsSync(join(home, ".commhub", "commhub.db"))).toBe(false);
|
|
80
|
+
} finally {
|
|
81
|
+
child.kill();
|
|
82
|
+
await child.exited;
|
|
83
|
+
}
|
|
84
|
+
}, 30_000);
|
|
85
|
+
});
|
|
@@ -721,3 +721,110 @@ describe("SEC-1 — update lifecycle: single-flight + cross-network pending look
|
|
|
721
721
|
expect(inFlight).toBeDefined();
|
|
722
722
|
});
|
|
723
723
|
});
|
|
724
|
+
|
|
725
|
+
// ── #260 P5 codex-catch regression: channels finalize ─────────────
|
|
726
|
+
describe("finalizePendingMatchingUpdates — #260 P5 channels content-match", () => {
|
|
727
|
+
function insertPendingUpdate(opts: {
|
|
728
|
+
update_id?: string;
|
|
729
|
+
node_id: string;
|
|
730
|
+
network_id: string;
|
|
731
|
+
patch: any;
|
|
732
|
+
apply_mode: "hot" | "restart" | "restart_only";
|
|
733
|
+
status?: "pending" | "restarting";
|
|
734
|
+
}): string {
|
|
735
|
+
const id = opts.update_id ?? `cu_${uuidv4()}`;
|
|
736
|
+
db.run(
|
|
737
|
+
`INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token) VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?7, 'test')`,
|
|
738
|
+
[id, opts.node_id, opts.network_id, JSON.stringify(opts.patch), opts.apply_mode, opts.status ?? "pending", Date.now()],
|
|
739
|
+
);
|
|
740
|
+
return id;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
test("channels-only patch: snapshot BEFORE apply (first boot) must NOT finalize", () => {
|
|
744
|
+
const nid = insertNode({ alias: "n-chan-1", network_id: "netA" });
|
|
745
|
+
const uid = insertPendingUpdate({
|
|
746
|
+
node_id: nid, network_id: "netA",
|
|
747
|
+
// Note the empty flags object — the pre-fix bug was that this
|
|
748
|
+
// trivially matched the flags loop and hub falsely finalized.
|
|
749
|
+
patch: { flags: {}, channels: ["feishu"] },
|
|
750
|
+
apply_mode: "restart",
|
|
751
|
+
});
|
|
752
|
+
// Node's startup snapshot BEFORE it consumed the update — no
|
|
753
|
+
// channels forked yet, so buildConfigSnapshot emits channels=[].
|
|
754
|
+
const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: [] });
|
|
755
|
+
expect(r.finalizedCount).toBe(0);
|
|
756
|
+
expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("pending");
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test("channels-only patch: snapshot AFTER apply (restart complete) finalizes", () => {
|
|
760
|
+
const nid = insertNode({ alias: "n-chan-2", network_id: "netA" });
|
|
761
|
+
const uid = insertPendingUpdate({
|
|
762
|
+
node_id: nid, network_id: "netA",
|
|
763
|
+
patch: { flags: {}, channels: ["feishu"] },
|
|
764
|
+
apply_mode: "restart",
|
|
765
|
+
});
|
|
766
|
+
const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: ["feishu"] });
|
|
767
|
+
expect(r.finalizedCount).toBe(1);
|
|
768
|
+
expect(r.finalizedIds).toContain(uid);
|
|
769
|
+
expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("applied");
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
test("channels set-equality: order doesn't matter", () => {
|
|
773
|
+
const nid = insertNode({ alias: "n-chan-3", network_id: "netA" });
|
|
774
|
+
insertPendingUpdate({
|
|
775
|
+
node_id: nid, network_id: "netA",
|
|
776
|
+
patch: { flags: {}, channels: ["telegram", "feishu"] },
|
|
777
|
+
apply_mode: "restart",
|
|
778
|
+
});
|
|
779
|
+
// Snapshot order swapped — still matches.
|
|
780
|
+
const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: ["feishu", "telegram"] });
|
|
781
|
+
expect(r.finalizedCount).toBe(1);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
test("channels: [] disable-all — finalize when snapshot also [] (post-restart)", () => {
|
|
785
|
+
const nid = insertNode({ alias: "n-chan-4", network_id: "netA" });
|
|
786
|
+
insertPendingUpdate({
|
|
787
|
+
node_id: nid, network_id: "netA",
|
|
788
|
+
patch: { flags: {}, channels: [] },
|
|
789
|
+
apply_mode: "restart",
|
|
790
|
+
});
|
|
791
|
+
const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: [] });
|
|
792
|
+
expect(r.finalizedCount).toBe(1);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
test("channels: [] disable-all — do NOT finalize when snapshot still has channels", () => {
|
|
796
|
+
const nid = insertNode({ alias: "n-chan-5", network_id: "netA" });
|
|
797
|
+
insertPendingUpdate({
|
|
798
|
+
node_id: nid, network_id: "netA",
|
|
799
|
+
patch: { flags: {}, channels: [] },
|
|
800
|
+
apply_mode: "restart",
|
|
801
|
+
});
|
|
802
|
+
// Node hasn't restarted yet — old telegram worker still forked.
|
|
803
|
+
const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: ["telegram"] });
|
|
804
|
+
expect(r.finalizedCount).toBe(0);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
test("snapshot omits channels field entirely (pre-P5 agent-node) → NOT finalized on channels-carrying patch", () => {
|
|
808
|
+
const nid = insertNode({ alias: "n-chan-6", network_id: "netA" });
|
|
809
|
+
insertPendingUpdate({
|
|
810
|
+
node_id: nid, network_id: "netA",
|
|
811
|
+
patch: { flags: {}, channels: ["feishu"] },
|
|
812
|
+
apply_mode: "restart",
|
|
813
|
+
});
|
|
814
|
+
const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {} }); // no channels key
|
|
815
|
+
expect(r.finalizedCount).toBe(0);
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
test("model + channels compound match: both must satisfy", () => {
|
|
819
|
+
const nid = insertNode({ alias: "n-chan-7", network_id: "netA" });
|
|
820
|
+
insertPendingUpdate({
|
|
821
|
+
node_id: nid, network_id: "netA",
|
|
822
|
+
patch: { model: "opus-new", flags: {}, channels: ["telegram"] },
|
|
823
|
+
apply_mode: "restart",
|
|
824
|
+
});
|
|
825
|
+
// Wrong channels → not finalized even though model matches.
|
|
826
|
+
expect(finalizePendingMatchingUpdates(nid, { model: "opus-new", flags: {}, channels: [] }).finalizedCount).toBe(0);
|
|
827
|
+
// Both match → finalized.
|
|
828
|
+
expect(finalizePendingMatchingUpdates(nid, { model: "opus-new", flags: {}, channels: ["telegram"] }).finalizedCount).toBe(1);
|
|
829
|
+
});
|
|
830
|
+
});
|
|
@@ -9,9 +9,11 @@ import { describe, expect, test } from "bun:test";
|
|
|
9
9
|
import {
|
|
10
10
|
ALLOWED_FLAGS,
|
|
11
11
|
SECURITY_SENSITIVE_FLAGS,
|
|
12
|
+
EDITABLE_CHANNELS,
|
|
12
13
|
isAllowedToChangeFlag,
|
|
13
14
|
computeApplyMode,
|
|
14
15
|
validatePatch,
|
|
16
|
+
narrowChannelsPatch,
|
|
15
17
|
} from "./config-apply-validate";
|
|
16
18
|
|
|
17
19
|
describe("ALLOWED_FLAGS — exact contract (no silent extension)", () => {
|
|
@@ -24,20 +26,28 @@ describe("ALLOWED_FLAGS — exact contract (no silent extension)", () => {
|
|
|
24
26
|
// the BLOCKER 2 schema-mismatch issue. P2 to add a claude-code-cli
|
|
25
27
|
// config-apply path.
|
|
26
28
|
expect([...ALLOWED_FLAGS].sort()).toEqual([
|
|
29
|
+
"approvalPolicy",
|
|
27
30
|
"budget",
|
|
31
|
+
"copresenceFullAccess",
|
|
28
32
|
"dangerouslySkipPermissions",
|
|
29
33
|
"maxTurns",
|
|
34
|
+
"modelReasoningEffort",
|
|
30
35
|
"permissionMode",
|
|
36
|
+
"sandboxMode",
|
|
37
|
+
"skipGitRepoCheck",
|
|
31
38
|
"timeout",
|
|
32
39
|
]);
|
|
33
40
|
});
|
|
34
41
|
});
|
|
35
42
|
|
|
36
43
|
describe("SECURITY_SENSITIVE_FLAGS — privilege-elevation surface", () => {
|
|
37
|
-
test("contains
|
|
44
|
+
test("contains privilege-elevation flags (teammateMode dropped — see ALLOWED_FLAGS comment)", () => {
|
|
38
45
|
expect([...SECURITY_SENSITIVE_FLAGS].sort()).toEqual([
|
|
46
|
+
"approvalPolicy",
|
|
47
|
+
"copresenceFullAccess",
|
|
39
48
|
"dangerouslySkipPermissions",
|
|
40
49
|
"permissionMode",
|
|
50
|
+
"sandboxMode",
|
|
41
51
|
]);
|
|
42
52
|
});
|
|
43
53
|
|
|
@@ -159,6 +169,10 @@ describe("computeApplyMode — tier classifier (RFC-024 §4)", () => {
|
|
|
159
169
|
expect(computeApplyMode("gpt-5", { maxTurns: 50 })).toBe("restart");
|
|
160
170
|
});
|
|
161
171
|
|
|
172
|
+
test("modelReasoningEffort flag alone → restart tier", () => {
|
|
173
|
+
expect(computeApplyMode(undefined, { modelReasoningEffort: "medium" })).toBe("restart");
|
|
174
|
+
});
|
|
175
|
+
|
|
162
176
|
test("mixed (only hot flags) → hot", () => {
|
|
163
177
|
expect(computeApplyMode(undefined, { maxTurns: 50, budget: 100 })).toBe("hot");
|
|
164
178
|
});
|
|
@@ -247,6 +261,15 @@ describe("validatePatch — per-flag enum / range", () => {
|
|
|
247
261
|
expect(validatePatch(undefined, { timeout: 3_600_001 })?.field).toBe("flags.timeout");
|
|
248
262
|
expect(validatePatch(undefined, { timeout: 60.5 })?.field).toBe("flags.timeout");
|
|
249
263
|
});
|
|
264
|
+
test("timeout: 0 = no limit; 1..999 is seconds-shaped and rejected (#605)", () => {
|
|
265
|
+
expect(validatePatch(undefined, { timeout: 0 })).toBeNull();
|
|
266
|
+
expect(validatePatch(undefined, { timeout: 1000 })).toBeNull();
|
|
267
|
+
for (const v of [1, 600, 999]) {
|
|
268
|
+
const r = validatePatch(undefined, { timeout: v });
|
|
269
|
+
expect(r?.field).toBe("flags.timeout");
|
|
270
|
+
expect(r?.reason).toContain("milliseconds");
|
|
271
|
+
}
|
|
272
|
+
});
|
|
250
273
|
});
|
|
251
274
|
|
|
252
275
|
describe("validatePatch — combinations", () => {
|
|
@@ -258,3 +281,113 @@ describe("validatePatch — combinations", () => {
|
|
|
258
281
|
expect(r?.field).toBe("flags.maxTurns");
|
|
259
282
|
});
|
|
260
283
|
});
|
|
284
|
+
|
|
285
|
+
// ── #260 P5 — channels support (restart-tier, non-security-sensitive) ─
|
|
286
|
+
describe("EDITABLE_CHANNELS — mirrors agent-node runtime capability (cli.ts:671-676)", () => {
|
|
287
|
+
test("contains exactly telegram + feishu (channels agent-node actually forks workers for)", () => {
|
|
288
|
+
expect(EDITABLE_CHANNELS.size).toBe(2);
|
|
289
|
+
expect(EDITABLE_CHANNELS.has("telegram")).toBe(true);
|
|
290
|
+
expect(EDITABLE_CHANNELS.has("feishu")).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
test("commhub is NOT editable — it's the RPC transport, not a per-node channel worker", () => {
|
|
293
|
+
expect(EDITABLE_CHANNELS.has("commhub")).toBe(false);
|
|
294
|
+
});
|
|
295
|
+
test("wechat is NOT editable — roadmap only on dashboard", () => {
|
|
296
|
+
expect(EDITABLE_CHANNELS.has("wechat")).toBe(false);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("narrowChannelsPatch — hostile input hygiene at the wire boundary", () => {
|
|
301
|
+
test("straight allow-listed input passes verbatim", () => {
|
|
302
|
+
expect(narrowChannelsPatch(["telegram", "feishu"])).toEqual(["telegram", "feishu"]);
|
|
303
|
+
});
|
|
304
|
+
test("empty array preserved — 'disable all editable channels' is a real state", () => {
|
|
305
|
+
expect(narrowChannelsPatch([])).toEqual([]);
|
|
306
|
+
});
|
|
307
|
+
test("non-array returns null (patch didn't touch channels)", () => {
|
|
308
|
+
expect(narrowChannelsPatch(undefined)).toBeNull();
|
|
309
|
+
expect(narrowChannelsPatch("telegram" as any)).toBeNull();
|
|
310
|
+
expect(narrowChannelsPatch({} as any)).toBeNull();
|
|
311
|
+
});
|
|
312
|
+
test("case-folded to lower", () => {
|
|
313
|
+
expect(narrowChannelsPatch(["FEISHU", "Telegram"])).toEqual(["feishu", "telegram"]);
|
|
314
|
+
});
|
|
315
|
+
test("duplicates deduped (Set)", () => {
|
|
316
|
+
expect(narrowChannelsPatch(["telegram", "telegram", "feishu", "TELEGRAM"])).toEqual(["telegram", "feishu"]);
|
|
317
|
+
});
|
|
318
|
+
test("unknown channel keys dropped silently (roadmap wechat, transport commhub, evil keys)", () => {
|
|
319
|
+
expect(narrowChannelsPatch(["wechat", "telegram", "evil-hacker", "commhub"])).toEqual(["telegram"]);
|
|
320
|
+
});
|
|
321
|
+
test("non-string entries dropped (numbers, objects, nulls)", () => {
|
|
322
|
+
expect(narrowChannelsPatch(["telegram", 42, {}, null, "feishu"] as any)).toEqual(["telegram", "feishu"]);
|
|
323
|
+
});
|
|
324
|
+
test("SQL-injection-shaped value dropped (not exact key match)", () => {
|
|
325
|
+
expect(narrowChannelsPatch(["telegram; drop table users;", "feishu"])).toEqual(["feishu"]);
|
|
326
|
+
});
|
|
327
|
+
test("whitespace around a key still trims to the key", () => {
|
|
328
|
+
expect(narrowChannelsPatch([" feishu "])).toEqual(["feishu"]);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe("computeApplyMode — channels tier (restart)", () => {
|
|
333
|
+
test("channels-only patch → restart (boot forks per-channel workers)", () => {
|
|
334
|
+
expect(computeApplyMode(undefined, {}, ["feishu"])).toBe("restart");
|
|
335
|
+
});
|
|
336
|
+
test("empty-array channels → restart (still a state change)", () => {
|
|
337
|
+
expect(computeApplyMode(undefined, {}, [])).toBe("restart");
|
|
338
|
+
});
|
|
339
|
+
test("no channels key + no flags + no model → restart_only", () => {
|
|
340
|
+
expect(computeApplyMode(undefined, {}, undefined)).toBe("restart_only");
|
|
341
|
+
});
|
|
342
|
+
test("channels + hot-tier flag together stays restart (upgrade rules)", () => {
|
|
343
|
+
expect(computeApplyMode(undefined, { maxTurns: 5 }, ["telegram"])).toBe("restart");
|
|
344
|
+
});
|
|
345
|
+
test("channels + model → restart (model was already restart)", () => {
|
|
346
|
+
expect(computeApplyMode("gpt-5", {}, ["feishu"])).toBe("restart");
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe("validatePatch — channels defensive gate", () => {
|
|
351
|
+
test("valid channels array passes", () => {
|
|
352
|
+
expect(validatePatch(undefined, {}, ["telegram", "feishu"])).toBeNull();
|
|
353
|
+
expect(validatePatch(undefined, {}, [])).toBeNull();
|
|
354
|
+
expect(validatePatch(undefined, {}, ["telegram"])).toBeNull();
|
|
355
|
+
});
|
|
356
|
+
test("commhub rejected — RPC transport, not a per-node channel", () => {
|
|
357
|
+
const r = validatePatch(undefined, {}, ["commhub"]);
|
|
358
|
+
expect(r?.field).toBe("channels.commhub");
|
|
359
|
+
});
|
|
360
|
+
test("out-of-allowlist channel rejected (caller bypassed narrowing)", () => {
|
|
361
|
+
const r = validatePatch(undefined, {}, ["wechat"]);
|
|
362
|
+
expect(r?.field).toBe("channels.wechat");
|
|
363
|
+
expect(r?.reason).toMatch(/allowlist/);
|
|
364
|
+
});
|
|
365
|
+
test("non-string entry rejected", () => {
|
|
366
|
+
const r = validatePatch(undefined, {}, [42] as any);
|
|
367
|
+
expect(r?.field).toBe("channels");
|
|
368
|
+
});
|
|
369
|
+
test("non-array rejected", () => {
|
|
370
|
+
const r = validatePatch(undefined, {}, "telegram" as any);
|
|
371
|
+
expect(r?.field).toBe("channels");
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
describe("Codex config flags (#815)", () => {
|
|
376
|
+
test("yolo bundle on codex-app-server passes", () => {
|
|
377
|
+
expect(validatePatch(undefined, {
|
|
378
|
+
approvalPolicy: "never",
|
|
379
|
+
sandboxMode: "danger-full-access",
|
|
380
|
+
skipGitRepoCheck: true,
|
|
381
|
+
copresenceFullAccess: true,
|
|
382
|
+
modelReasoningEffort: "low",
|
|
383
|
+
}, undefined, "codex-app-server")).toBeNull();
|
|
384
|
+
});
|
|
385
|
+
test("modelReasoningEffort invalid enum rejected", () => {
|
|
386
|
+
expect(validatePatch(undefined, { modelReasoningEffort: "max" }, undefined, "codex-app-server")?.field)
|
|
387
|
+
.toBe("flags.modelReasoningEffort");
|
|
388
|
+
});
|
|
389
|
+
test("codex flags on claude runtime rejected", () => {
|
|
390
|
+
expect(validatePatch(undefined, { modelReasoningEffort: "low" }, undefined, "claude-agent-sdk")?.field)
|
|
391
|
+
.toBe("flags.modelReasoningEffort");
|
|
392
|
+
});
|
|
393
|
+
});
|
|
@@ -5,6 +5,15 @@
|
|
|
5
5
|
// tools.ts wrap these with auth checks (SEC-1 network scope, ntok_/utok_
|
|
6
6
|
// caller identity, role gates).
|
|
7
7
|
|
|
8
|
+
import { isValidTimeoutMs, TIMEOUT_MS_REASON } from "./create-node-validate.js";
|
|
9
|
+
import {
|
|
10
|
+
REASONING_EFFORT_VALUES,
|
|
11
|
+
validateCodexConfigFlag,
|
|
12
|
+
type ModelReasoningEffort,
|
|
13
|
+
} from "./codex-config-flags.js";
|
|
14
|
+
|
|
15
|
+
export { REASONING_EFFORT_VALUES, type ModelReasoningEffort } from "./codex-config-flags.js";
|
|
16
|
+
|
|
8
17
|
/**
|
|
9
18
|
* Fields the dashboard may change. Anything not in this list is rejected
|
|
10
19
|
* by hub-side validation regardless of role — the UI cannot smuggle
|
|
@@ -16,6 +25,12 @@ export const ALLOWED_FLAGS = new Set<string>([
|
|
|
16
25
|
"maxTurns",
|
|
17
26
|
"budget",
|
|
18
27
|
"timeout",
|
|
28
|
+
// Codex (#815 / client): posture + co-presence grant + thinking level
|
|
29
|
+
"approvalPolicy",
|
|
30
|
+
"sandboxMode",
|
|
31
|
+
"skipGitRepoCheck",
|
|
32
|
+
"copresenceFullAccess",
|
|
33
|
+
"modelReasoningEffort",
|
|
19
34
|
]);
|
|
20
35
|
// NB on teammateMode (dropped from P1 scope per #290 cross-agent
|
|
21
36
|
// review): teammateMode is consumed ONLY by the claude-code-cli
|
|
@@ -43,6 +58,9 @@ export const ALLOWED_FLAGS = new Set<string>([
|
|
|
43
58
|
export const SECURITY_SENSITIVE_FLAGS = new Set<string>([
|
|
44
59
|
"permissionMode",
|
|
45
60
|
"dangerouslySkipPermissions",
|
|
61
|
+
"approvalPolicy",
|
|
62
|
+
"sandboxMode",
|
|
63
|
+
"copresenceFullAccess",
|
|
46
64
|
]);
|
|
47
65
|
|
|
48
66
|
/**
|
|
@@ -55,6 +73,40 @@ export const RESTART_REQUIRED_FLAGS = new Set<string>([
|
|
|
55
73
|
"permissionMode",
|
|
56
74
|
"dangerouslySkipPermissions",
|
|
57
75
|
"timeout",
|
|
76
|
+
"approvalPolicy",
|
|
77
|
+
"sandboxMode",
|
|
78
|
+
"skipGitRepoCheck",
|
|
79
|
+
"copresenceFullAccess",
|
|
80
|
+
"modelReasoningEffort",
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Channels the dashboard may enable/disable on a node via
|
|
85
|
+
* update_node_config. Intentionally narrow — the panel only exposes
|
|
86
|
+
* enable/disable, per-channel secrets stay in the node's local
|
|
87
|
+
* config.json (see #260 wire discipline).
|
|
88
|
+
*
|
|
89
|
+
* The list mirrors agent-node's actual runtime capability. See
|
|
90
|
+
* agent-node/src/cli.ts:671-676 — CHANNELS are parsed into per-type
|
|
91
|
+
* worker inits (initTelegramChannel + initFeishuChannel), and any
|
|
92
|
+
* other channel type triggers `process.exit(1)` at boot. So the
|
|
93
|
+
* hub-side allow-list here is telegram + feishu ONLY. `commhub` is
|
|
94
|
+
* the RPC transport that every node speaks unconditionally; treating
|
|
95
|
+
* it as a per-node channel would be a UX lie (toggle looks off but
|
|
96
|
+
* commhub is always on).
|
|
97
|
+
*
|
|
98
|
+
* Dashboard PR #31 currently includes `commhub` in its own
|
|
99
|
+
* EDITABLE_CHANNELS whitelist — that follow-up narrowing is filed for
|
|
100
|
+
* the next dashboard rally, and until then hub silently drops any
|
|
101
|
+
* `commhub` entry via narrowChannelsPatch.
|
|
102
|
+
*
|
|
103
|
+
* Not part of SECURITY_SENSITIVE_FLAGS (通信龙 P5 派工): flipping a
|
|
104
|
+
* channel on/off is a lifecycle-tier operation like `restart_node`, not
|
|
105
|
+
* a privilege elevation. Cross-tenant reach is already gated by SEC-1.
|
|
106
|
+
*/
|
|
107
|
+
export const EDITABLE_CHANNELS = new Set<string>([
|
|
108
|
+
"telegram",
|
|
109
|
+
"feishu",
|
|
58
110
|
]);
|
|
59
111
|
|
|
60
112
|
/**
|
|
@@ -99,22 +151,53 @@ export function isAllowedToChangeFlag(
|
|
|
99
151
|
|
|
100
152
|
/**
|
|
101
153
|
* Classify the patch's required apply mode. Empty patch (model + flags
|
|
102
|
-
* both empty) → "restart_only" (used by restart_node tool).
|
|
103
|
-
* restart-required field present
|
|
154
|
+
* both empty, no channels) → "restart_only" (used by restart_node tool).
|
|
155
|
+
* Any restart-required field present, OR a channels change → "restart"
|
|
156
|
+
* (channels is restart-tier by design: the node reads its channel set
|
|
157
|
+
* once at boot in agent-node/src/cli.ts and forks per-channel workers
|
|
158
|
+
* from it, so a channel enable/disable takes effect on process restart
|
|
159
|
+
* only — see #260 P5 wire discipline). Otherwise → "hot".
|
|
104
160
|
*/
|
|
105
161
|
export function computeApplyMode(
|
|
106
162
|
model: string | undefined,
|
|
107
163
|
flags: Record<string, unknown>,
|
|
164
|
+
channels?: string[] | undefined,
|
|
108
165
|
): "hot" | "restart" | "restart_only" {
|
|
109
|
-
const fieldCount = (model !== undefined ? 1 : 0) + Object.keys(flags).length;
|
|
166
|
+
const fieldCount = (model !== undefined ? 1 : 0) + Object.keys(flags).length + (channels !== undefined ? 1 : 0);
|
|
110
167
|
if (fieldCount === 0) return "restart_only";
|
|
111
168
|
if (model !== undefined) return "restart";
|
|
169
|
+
if (channels !== undefined) return "restart";
|
|
112
170
|
for (const key of Object.keys(flags)) {
|
|
113
171
|
if (RESTART_REQUIRED_FLAGS.has(key)) return "restart";
|
|
114
172
|
}
|
|
115
173
|
return "hot";
|
|
116
174
|
}
|
|
117
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Narrow + allow-list `channels` from untrusted patch JSON. Returns the
|
|
178
|
+
* final channel key list (case-folded, deduped, in EDITABLE_CHANNELS
|
|
179
|
+
* order-preserving), OR null if the input isn't an array. Non-strings,
|
|
180
|
+
* unknown keys, empty strings, and duplicates are silently dropped —
|
|
181
|
+
* same fail-shape as the dashboard route's whitelist so the two sides
|
|
182
|
+
* behave identically end-to-end. Callers pass `undefined` through when
|
|
183
|
+
* the patch didn't include a channels key at all (a distinct case from
|
|
184
|
+
* `channels: []` which means "disable all editable channels").
|
|
185
|
+
*/
|
|
186
|
+
export function narrowChannelsPatch(raw: unknown): string[] | null {
|
|
187
|
+
if (!Array.isArray(raw)) return null;
|
|
188
|
+
const allow = EDITABLE_CHANNELS;
|
|
189
|
+
const seen = new Set<string>();
|
|
190
|
+
const out: string[] = [];
|
|
191
|
+
for (const v of raw) {
|
|
192
|
+
if (typeof v !== "string") continue;
|
|
193
|
+
const key = v.trim().toLowerCase();
|
|
194
|
+
if (!allow.has(key) || seen.has(key)) continue;
|
|
195
|
+
seen.add(key);
|
|
196
|
+
out.push(key);
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
118
201
|
/**
|
|
119
202
|
* Validate the patch shape — allowlist + per-field range / enum. Returns
|
|
120
203
|
* null on pass, or `{ field, reason }` on reject. Caller wraps in error
|
|
@@ -123,16 +206,37 @@ export function computeApplyMode(
|
|
|
123
206
|
export function validatePatch(
|
|
124
207
|
model: string | undefined,
|
|
125
208
|
flags: Record<string, unknown>,
|
|
209
|
+
channels?: string[] | undefined,
|
|
210
|
+
nodeRuntime?: string | null,
|
|
126
211
|
): { field: string; reason: string } | null {
|
|
127
212
|
if (model !== undefined) {
|
|
128
213
|
if (typeof model !== "string" || model.length === 0 || model.length > 200) {
|
|
129
214
|
return { field: "model", reason: "must be a non-empty string ≤ 200 chars" };
|
|
130
215
|
}
|
|
131
216
|
}
|
|
217
|
+
if (channels !== undefined) {
|
|
218
|
+
// Zod already enforced string[], and narrowChannelsPatch dropped
|
|
219
|
+
// unknown keys before this point — but re-verify defensively so a
|
|
220
|
+
// future caller that skips narrowChannelsPatch can't smuggle
|
|
221
|
+
// unknown keys through validatePatch alone.
|
|
222
|
+
if (!Array.isArray(channels)) {
|
|
223
|
+
return { field: "channels", reason: "must be an array of strings" };
|
|
224
|
+
}
|
|
225
|
+
for (const c of channels) {
|
|
226
|
+
if (typeof c !== "string") {
|
|
227
|
+
return { field: "channels", reason: "must contain only strings" };
|
|
228
|
+
}
|
|
229
|
+
if (!EDITABLE_CHANNELS.has(c)) {
|
|
230
|
+
return { field: `channels.${c}`, reason: "not in editable channels allowlist" };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
132
234
|
for (const [key, val] of Object.entries(flags)) {
|
|
133
235
|
if (!ALLOWED_FLAGS.has(key)) {
|
|
134
236
|
return { field: `flags.${key}`, reason: "not in allowlist" };
|
|
135
237
|
}
|
|
238
|
+
const codexFail = validateCodexConfigFlag(key, val, nodeRuntime ?? null);
|
|
239
|
+
if (codexFail) return codexFail;
|
|
136
240
|
switch (key) {
|
|
137
241
|
case "permissionMode":
|
|
138
242
|
if (
|
|
@@ -161,8 +265,10 @@ export function validatePatch(
|
|
|
161
265
|
}
|
|
162
266
|
break;
|
|
163
267
|
case "timeout":
|
|
164
|
-
|
|
165
|
-
|
|
268
|
+
// #605 —— 与 create_node 同一把尺子(create-node-validate.ts isValidTimeoutMs):
|
|
269
|
+
// 毫秒,0 或 1000..3_600_000;1..999 是「按秒填了」的形状,拒绝而不是给一个亚秒超时。
|
|
270
|
+
if (!isValidTimeoutMs(val)) {
|
|
271
|
+
return { field: "flags.timeout", reason: TIMEOUT_MS_REASON };
|
|
166
272
|
}
|
|
167
273
|
break;
|
|
168
274
|
}
|