@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,74 @@
|
|
|
1
|
+
// report_status must not relabel a nodes row that belongs to another node.
|
|
2
|
+
//
|
|
3
|
+
// Shape seen in production: node A's config carries a node_id that another
|
|
4
|
+
// node B reported first (a copied config / a colliding hand-backfilled id).
|
|
5
|
+
// The row is B's alias + B's config_path, unowned (pre-RFC-036). When A starts
|
|
6
|
+
// reporting node_id, upsertNodeWithSec1Guard's COALESCE would overwrite the
|
|
7
|
+
// alias with A's — B's row silently becomes A's. identity_mismatch refuses it.
|
|
8
|
+
//
|
|
9
|
+
// Run with: COMMHUB_DB=/tmp/identity-mismatch.db bun test src/node-identity-mismatch.test.ts
|
|
10
|
+
|
|
11
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
import { db } from "./db.js";
|
|
13
|
+
import { upsertNodeWithSec1Guard } from "./tools.js";
|
|
14
|
+
|
|
15
|
+
const NET = "net_identity";
|
|
16
|
+
|
|
17
|
+
function row(node_id: string, alias: string, config_path: string | null, owner: string | null = null) {
|
|
18
|
+
db.run(
|
|
19
|
+
`INSERT INTO nodes (node_id, node_name, alias, runtime, config_path, network_id, owner_user_id) VALUES (?1, ?2, ?2, 'codex-app-server-sdk', ?3, ?4, ?5)`,
|
|
20
|
+
[node_id, alias, config_path, NET, owner],
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
const read = (id: string) => db.get<{ alias: string; runtime: string | null; config_path: string | null }>(
|
|
24
|
+
"SELECT alias, runtime, config_path FROM nodes WHERE node_id = ?1", id,
|
|
25
|
+
)!;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => { db.run("DELETE FROM nodes"); });
|
|
28
|
+
|
|
29
|
+
describe("upsertNodeWithSec1Guard — identity_mismatch", () => {
|
|
30
|
+
test("different alias + different config_path ⇒ refused, row untouched", () => {
|
|
31
|
+
row("n_shared01", "node-b", "/work/b/.anet/nodes/node-b/config.json");
|
|
32
|
+
const out = upsertNodeWithSec1Guard({
|
|
33
|
+
node_id: "n_shared01", callerNetworkId: NET, alias: "node-a", runtime: "claude-code",
|
|
34
|
+
config_path: "/work/a/.anet/nodes/node-a/config.json",
|
|
35
|
+
});
|
|
36
|
+
expect(out).toMatchObject({ result: "refused", reason: "identity_mismatch" });
|
|
37
|
+
expect(read("n_shared01")).toEqual({ alias: "node-b", runtime: "codex-app-server-sdk", config_path: "/work/b/.anet/nodes/node-b/config.json" });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("same alias, different config_path (the node moved dirs) ⇒ updated", () => {
|
|
41
|
+
row("n_moved01", "node-a", "/old/.anet/nodes/node-a/config.json");
|
|
42
|
+
const out = upsertNodeWithSec1Guard({ node_id: "n_moved01", callerNetworkId: NET, alias: "node-a", config_path: "/new/.anet/nodes/node-a/config.json" });
|
|
43
|
+
expect(out.result).toBe("updated");
|
|
44
|
+
expect(read("n_moved01").config_path).toBe("/new/.anet/nodes/node-a/config.json");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("different alias, same config_path ⇒ updated (not the collision shape)", () => {
|
|
48
|
+
row("n_same01", "old-name", "/w/.anet/nodes/x/config.json");
|
|
49
|
+
expect(upsertNodeWithSec1Guard({ node_id: "n_same01", callerNetworkId: NET, alias: "new-name", config_path: "/w/.anet/nodes/x/config.json" }).result).toBe("updated");
|
|
50
|
+
expect(read("n_same01").alias).toBe("new-name");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("row without config_path (token-mint row) ⇒ first reporter fills it in", () => {
|
|
54
|
+
row("n_mint01", "node-a", null);
|
|
55
|
+
expect(upsertNodeWithSec1Guard({ node_id: "n_mint01", callerNetworkId: NET, alias: "node-a", config_path: "/w/a/config.json" }).result).toBe("updated");
|
|
56
|
+
expect(read("n_mint01").config_path).toBe("/w/a/config.json");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("reporter without config_path ⇒ no evidence, legacy behaviour kept", () => {
|
|
60
|
+
row("n_nocfg01", "node-b", "/w/b/config.json");
|
|
61
|
+
expect(upsertNodeWithSec1Guard({ node_id: "n_nocfg01", callerNetworkId: NET, alias: "node-a" }).result).toBe("updated");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("new node_id ⇒ inserted", () => {
|
|
65
|
+
expect(upsertNodeWithSec1Guard({ node_id: "n_fresh01", callerNetworkId: NET, alias: "node-a", config_path: "/w/a/config.json" }).result).toBe("inserted");
|
|
66
|
+
expect(read("n_fresh01").alias).toBe("node-a");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("owner mismatch still wins first (existing gate unchanged)", () => {
|
|
70
|
+
row("n_owned01", "node-b", "/w/b/config.json", "usr_b");
|
|
71
|
+
expect(upsertNodeWithSec1Guard({ node_id: "n_owned01", callerNetworkId: NET, callerUserId: "usr_a", alias: "node-a", config_path: "/w/a/config.json" }))
|
|
72
|
+
.toMatchObject({ result: "refused", reason: "owner_mismatch" });
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
childNodeIdForCreateRequest,
|
|
4
|
+
buildControllableMap,
|
|
5
|
+
isLifecycleControllable,
|
|
6
|
+
} from "./node-lifecycle-controllable.js";
|
|
7
|
+
|
|
8
|
+
// app#196 —— 桌面端对 218 个节点里的 207 个(95%)显示了「停止/重启/删除」,
|
|
9
|
+
// 而那三个工具的 schema 是 regex(/^node_[a-z0-9_-]+$/),那 207 个是 n_ 开头,
|
|
10
|
+
// 点下去只会看到一段 zod 正则原文。
|
|
11
|
+
describe("childNodeIdForCreateRequest —— 推导规则", () => {
|
|
12
|
+
test("cr_ 前缀 → node_ 前缀,其余部分逐字保留", () => {
|
|
13
|
+
// 🔴 这一对是 2026-08-28 生产 hub 日志里真实的 create-node finalize 行,
|
|
14
|
+
// 不是我按注释造的。
|
|
15
|
+
expect(childNodeIdForCreateRequest("cr_59723b24-8372-4270-9b3c-1552e592a09f"))
|
|
16
|
+
.toBe("node_59723b24-8372-4270-9b3c-1552e592a09f");
|
|
17
|
+
});
|
|
18
|
+
test("不是 cr_ 开头的一律 null —— 不猜、不截断", () => {
|
|
19
|
+
expect(childNodeIdForCreateRequest("node_abc")).toBeNull();
|
|
20
|
+
expect(childNodeIdForCreateRequest("n_abc")).toBeNull();
|
|
21
|
+
expect(childNodeIdForCreateRequest("abc")).toBeNull();
|
|
22
|
+
expect(childNodeIdForCreateRequest("cr_")).toBeNull(); // 只有前缀,没有主体
|
|
23
|
+
});
|
|
24
|
+
test("非字符串输入不抛,返回 null", () => {
|
|
25
|
+
for (const v of [undefined, null, 123, {}, []]) {
|
|
26
|
+
expect(childNodeIdForCreateRequest(v as any)).toBeNull();
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("isLifecycleControllable —— 判据不是 id 前缀", () => {
|
|
32
|
+
const map = buildControllableMap([
|
|
33
|
+
{ request_id: "cr_aaa", daemon_node_id: "node_daemon_x" },
|
|
34
|
+
{ request_id: "cr_bbb", daemon_node_id: "node_daemon_y" },
|
|
35
|
+
{ request_id: "not-a-request", daemon_node_id: "node_daemon_z" }, // 应被丢弃
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
test("有创建记录的 → 可控", () => {
|
|
39
|
+
expect(isLifecycleControllable("node_aaa", null, map)).toBe(true);
|
|
40
|
+
expect(map.get("node_aaa")).toBe("node_daemon_x");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("🔴 光有 node_ 前缀但没有创建记录 → 不可控", () => {
|
|
44
|
+
// 这条是本模块存在的理由:前缀是实现细节,不是判据。
|
|
45
|
+
expect(isLifecycleControllable("node_never_created", null, map)).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("手工起的 n_ 节点 → 不可控(那 207 个)", () => {
|
|
49
|
+
expect(isLifecycleControllable("n_22b777bf", null, map)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("🔴 daemon 自己可控 —— 它在 node_create_requests 里没有记录", () => {
|
|
53
|
+
// 漏掉这条会把 3 个 daemon 从可控名单里误删。
|
|
54
|
+
expect(isLifecycleControllable("node_daemon_39bd1eeae3ee", "host_supervisor", map)).toBe(true);
|
|
55
|
+
// 正控:同一个 id 但 role 不是 host_supervisor ⇒ 仍不可控,
|
|
56
|
+
// 证明上面那条过是因为 role,不是因为 id 恰好带 node_ 前缀。
|
|
57
|
+
expect(isLifecycleControllable("node_daemon_39bd1eeae3ee", null, map)).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("坏的 request_id 不会污染映射", () => {
|
|
61
|
+
expect(map.has("node_not-a-request")).toBe(false);
|
|
62
|
+
expect(map.size).toBe(2);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* app#196 —— 哪些节点能走 stop_node / restart_node / delete_node。
|
|
3
|
+
*
|
|
4
|
+
* 抽成独立模块而不是写在 /api/nodes 的路由里,因为路由里的内联逻辑测不到 ——
|
|
5
|
+
* 现有的 api-nodes-shape.test.ts 是直跑 handler 的 SQL,拿不到映射步骤。
|
|
6
|
+
* 🔴 「不可测」本身就是一个设计信号,不是测试的问题。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** 子节点 id 由创建请求 id 确定性推导。
|
|
10
|
+
*
|
|
11
|
+
* 🔴 这条规则我用真实一对验过,不是只读注释:
|
|
12
|
+
* cr_59723b24-8372-4270-9b3c-1552e592a09f → node_59723b24-8372-4270-9b3c-…
|
|
13
|
+
* (2026-08-28 生产 hub 日志的 `create-node finalize` 行)
|
|
14
|
+
*
|
|
15
|
+
* 非 `cr_` 开头的一律返回 null —— 不猜,不截断。 */
|
|
16
|
+
export function childNodeIdForCreateRequest(requestId: unknown): string | null {
|
|
17
|
+
if (typeof requestId !== "string") return null;
|
|
18
|
+
if (!requestId.startsWith("cr_")) return null;
|
|
19
|
+
const rest = requestId.slice(3);
|
|
20
|
+
if (!rest) return null;
|
|
21
|
+
return `node_${rest}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 由创建记录建出「child node_id → daemon node_id」的映射。 */
|
|
25
|
+
export function buildControllableMap(
|
|
26
|
+
createRequests: Array<{ request_id?: unknown; daemon_node_id?: unknown }>,
|
|
27
|
+
bindings: Array<{ node_id: string; daemon_node_id: string }> = [],
|
|
28
|
+
): Map<string, string> {
|
|
29
|
+
const m = new Map<string, string>();
|
|
30
|
+
for (const binding of bindings) m.set(binding.node_id, binding.daemon_node_id);
|
|
31
|
+
for (const cr of createRequests) {
|
|
32
|
+
const child = childNodeIdForCreateRequest(cr?.request_id);
|
|
33
|
+
if (!child) continue;
|
|
34
|
+
m.set(child, typeof cr?.daemon_node_id === "string" ? cr.daemon_node_id : "");
|
|
35
|
+
}
|
|
36
|
+
return m;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 一个节点能不能走生命周期操作。
|
|
40
|
+
*
|
|
41
|
+
* 🔴 判据**不是 id 前缀**。前缀是实现细节;判据是 node_create_requests 里真有创建记录。
|
|
42
|
+
*
|
|
43
|
+
* 例外:daemon 自己(role=host_supervisor)也能被 restart/stop,而它在
|
|
44
|
+
* node_create_requests 里**没有**记录(它不是被别的 daemon 创建的)—— 单独放行。
|
|
45
|
+
* 漏掉这一条会把可控的 daemon 判成不可控。 */
|
|
46
|
+
export function isLifecycleControllable(
|
|
47
|
+
nodeId: unknown,
|
|
48
|
+
role: unknown,
|
|
49
|
+
controllable: Map<string, string>,
|
|
50
|
+
): boolean {
|
|
51
|
+
if (role === "host_supervisor") return true;
|
|
52
|
+
return typeof nodeId === "string" && controllable.has(nodeId);
|
|
53
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { beforeAll, afterAll, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
6
|
+
const dir = mkdtempSync(join(tmpdir(), "lifecycle-read-"));
|
|
7
|
+
process.env.COMMHUB_DB ||= join(dir, "hub.db");
|
|
8
|
+
process.env.COMMHUB_AUTH_TOKEN ||= "lifecycle-read-master-fixture";
|
|
9
|
+
let hub: ReturnType<typeof Bun.serve>, base: string;
|
|
10
|
+
let db: any, a: any, b: any, limited: any, daemonToken: string;
|
|
11
|
+
const ids = { active: "n_read_active", created: "node_read_created", manual: "node_read_manual", daemon: "n_read_daemon" };
|
|
12
|
+
const get = async (path: string, token = a.token) => {
|
|
13
|
+
const res = await fetch(base + path, { headers: { Authorization: `Bearer ${token}` } });
|
|
14
|
+
return { status: res.status, body: await res.json() as any };
|
|
15
|
+
};
|
|
16
|
+
const query = (kind: string, selector: string) => `/api/node-lifecycle-requests?kind=${kind}&${selector}`;
|
|
17
|
+
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
({ db } = await import("./db.js"));
|
|
20
|
+
const { register } = await import("./auth.js");
|
|
21
|
+
const suffix = Date.now();
|
|
22
|
+
a = register(`read_owner_${suffix}`, "ReadFixture-Strong1!");
|
|
23
|
+
b = register(`read_other_${suffix}`, "ReadFixture-Strong1!");
|
|
24
|
+
limited = register(`read_limited_${suffix}`, "ReadFixture-Strong1!");
|
|
25
|
+
expect(a.ok && b.ok && limited.ok).toBe(true);
|
|
26
|
+
for (const user of [a, b, limited]) db.run("UPDATE users SET role='user' WHERE user_id=?1", [user.user.user_id]);
|
|
27
|
+
db.run("INSERT INTO network_members(network_id,user_id,role,agent_access) VALUES(?1,?2,'viewer','granted')", [a.network_id, limited.user.user_id]);
|
|
28
|
+
for (const [key, id] of Object.entries(ids)) db.run(
|
|
29
|
+
"INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id,hostname,config_snapshot) VALUES(?1,?2,?2,?3,?4,'fixture-host',?5)",
|
|
30
|
+
[id, `read-${key}`, a.network_id, a.user.user_id, key === "daemon" ? JSON.stringify({ role: "host_supervisor", daemon_capabilities: { adopt_capable: true } }) : "{}"]);
|
|
31
|
+
for (const status of ["active", "pending", "refused", "revoked"]) {
|
|
32
|
+
const id = status === "active" ? ids.active : `n_read_${status}`;
|
|
33
|
+
if (status !== "active") db.run("INSERT INTO nodes(node_id,node_name,alias,network_id) VALUES(?1,?1,?2,?3)", [id, `read-${status}`, a.network_id]);
|
|
34
|
+
db.run(`INSERT INTO node_daemon_bindings(request_id,network_id,node_id,daemon_node_id,workdir,requested_by,status,error,created_at,updated_at)
|
|
35
|
+
VALUES(?1,?2,?3,?4,'/private/fixture',?5,?6,?7,1,2)`,
|
|
36
|
+
[`adopt_read_${status}`, a.network_id, id, ids.daemon, a.user.user_id, status, status === "refused" ? "adopt_start_evidence_missing" : null]);
|
|
37
|
+
}
|
|
38
|
+
db.run(`INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,network_id,runtime,flags_json,env_keys,status,created_at,created_by_token)
|
|
39
|
+
VALUES('cr_read_created',?1,'created',?2,'codex-sdk','{}','[]','succeeded',1,'private-token')`, [ids.daemon, a.network_id]);
|
|
40
|
+
db.run(`INSERT INTO node_stop_requests(request_id,network_id,daemon_node_id,child_node_id,child_alias,action,created_by_token,status,error,created_at,acked_at)
|
|
41
|
+
VALUES('stop_read_failed',?1,?2,?3,'read-active','stop','private-token','stop_failed','adopt_explicit_private_socket_required',3,4)`, [a.network_id, ids.daemon, ids.active]);
|
|
42
|
+
db.run(`INSERT INTO node_start_requests(request_id,network_id,daemon_node_id,child_node_id,child_alias,created_by_token,status,error,created_at,acked_at)
|
|
43
|
+
VALUES('start_read_failed',?1,?2,?3,'read-active','private-token','start_failed','adopt_start_evidence_missing',5,6)`, [a.network_id, ids.daemon, ids.active]);
|
|
44
|
+
db.run("INSERT INTO sessions(resume_id,alias,node_id,network_id,status,last_seen_at) VALUES('read-daemon-session','read-daemon',?1,?2,'idle',datetime('now'))", [ids.daemon, a.network_id]);
|
|
45
|
+
const { generateNetworkToken, hashToken } = await import("./db.js");
|
|
46
|
+
daemonToken = generateNetworkToken();
|
|
47
|
+
db.run("INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash) VALUES('read-daemon-token',?1,?2,'network','node:read-daemon',?3)", [a.user.user_id, a.network_id, hashToken(daemonToken)]);
|
|
48
|
+
const { bootServer } = await import("./server.js");
|
|
49
|
+
hub = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
50
|
+
base = `http://127.0.0.1:${hub.port}`;
|
|
51
|
+
}, 30_000);
|
|
52
|
+
afterAll(() => hub?.stop(true));
|
|
53
|
+
|
|
54
|
+
test("public errors redact free text for granted viewers on every projection", async () => {
|
|
55
|
+
db.run("INSERT INTO network_member_agent_grants(network_id,user_id,node_id,can_message) VALUES(?1,?2,?3,0)", [a.network_id, limited.user.user_id, ids.active]);
|
|
56
|
+
try {
|
|
57
|
+
for (const error of [
|
|
58
|
+
"/private/fixture/secret on fixture-host",
|
|
59
|
+
"not_my_child: daemon has no local record of fixture-node (fixture-id) — no children-map entry and no config at /private/fixture/fixture-node/config.json; refusing to signal processes by alias.",
|
|
60
|
+
"bin: ENOENT: /private/fixture/bin/anet on fixture-host",
|
|
61
|
+
"adopt_unknown_secret", "adopt_start_timeout: /private/fixture", "", "adopt_start_timeout", null,
|
|
62
|
+
"adopted_node_delete_unsupported", "adopt_codex_readopt_required", "adopt_stop_receipt_changed",
|
|
63
|
+
"adopt_codex_external_start_unproven", "adopt_codex_launch_failed", "adopt_codex_launch_timeout",
|
|
64
|
+
"adopt_codex_port_unproven", "adopt_codex_start_superseded", "adopt_codex_binding_generation_unproven",
|
|
65
|
+
"adopt_codex_start_evidence_missing", "adopt_codex_start_evidence_changed",
|
|
66
|
+
"adopt_codex_session_conflict", "adopt_codex_port_unavailable", "adopt_codex_stage_still_running",
|
|
67
|
+
"adopt_codex_identity_unproven",
|
|
68
|
+
"TUI session mismatch", "opencode_launch_health:TUI session mismatch", "opencode_tui_session_mismatch",
|
|
69
|
+
]) {
|
|
70
|
+
const publicCodes = ["adopt_start_timeout", "adopted_node_delete_unsupported", "adopt_codex_readopt_required", "adopt_stop_receipt_changed", "opencode_tui_session_mismatch",
|
|
71
|
+
"adopt_codex_external_start_unproven", "adopt_codex_launch_failed", "adopt_codex_launch_timeout",
|
|
72
|
+
"adopt_codex_port_unproven", "adopt_codex_start_superseded", "adopt_codex_binding_generation_unproven",
|
|
73
|
+
"adopt_codex_start_evidence_missing", "adopt_codex_start_evidence_changed",
|
|
74
|
+
"adopt_codex_session_conflict", "adopt_codex_port_unavailable", "adopt_codex_stage_still_running",
|
|
75
|
+
"adopt_codex_identity_unproven"];
|
|
76
|
+
const expected = error === null || publicCodes.includes(error) ? error : "lifecycle_error";
|
|
77
|
+
for (const [kind, table, request] of [
|
|
78
|
+
["adopt", "node_daemon_bindings", "adopt_read_active"],
|
|
79
|
+
["start", "node_start_requests", "start_read_failed"],
|
|
80
|
+
["stop", "node_stop_requests", "stop_read_failed"],
|
|
81
|
+
]) {
|
|
82
|
+
db.run(`UPDATE ${table} SET error=?1 WHERE request_id=?2`, [error, request]);
|
|
83
|
+
for (const selector of [`request_id=${request}`, `node_id=${ids.active}`]) {
|
|
84
|
+
const r = await get(query(kind, selector), limited.token);
|
|
85
|
+
expect(r.status).toBe(200);
|
|
86
|
+
expect(r.body.request.error).toBe(expected);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const nodes = (await get(`/api/nodes?network_id=${a.network_id}`, limited.token)).body.nodes;
|
|
90
|
+
expect(nodes[0].adoption.error).toBe(expected);
|
|
91
|
+
}
|
|
92
|
+
} finally {
|
|
93
|
+
db.run("DELETE FROM network_member_agent_grants WHERE user_id=?1 AND node_id=?2", [limited.user.user_id, ids.active]);
|
|
94
|
+
db.run("UPDATE node_daemon_bindings SET error=NULL WHERE request_id='adopt_read_active'");
|
|
95
|
+
db.run("UPDATE node_start_requests SET error='adopt_start_evidence_missing' WHERE request_id='start_read_failed'");
|
|
96
|
+
db.run("UPDATE node_stop_requests SET error='adopt_explicit_private_socket_required' WHERE request_id='stop_read_failed'");
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("created projection prefers recorded child id and only falls back for legacy null", async () => {
|
|
101
|
+
const read = async () => (await get(`/api/nodes?network_id=${a.network_id}`)).body.nodes;
|
|
102
|
+
try {
|
|
103
|
+
expect((await read()).find((n: any) => n.node_id === ids.created).managed).toBe("created");
|
|
104
|
+
db.run("UPDATE node_create_requests SET child_node_id=?1 WHERE request_id='cr_read_created'", [ids.manual]);
|
|
105
|
+
let nodes = await read();
|
|
106
|
+
expect(nodes.find((n: any) => n.node_id === ids.manual).managed).toBe("created");
|
|
107
|
+
expect(nodes.find((n: any) => n.node_id === ids.created).managed).toBe("none");
|
|
108
|
+
db.run("UPDATE node_create_requests SET child_node_id='missing-child' WHERE request_id='cr_read_created'");
|
|
109
|
+
expect((await read()).find((n: any) => n.node_id === ids.created).managed).toBe("none");
|
|
110
|
+
db.run("UPDATE node_create_requests SET child_node_id=?1,network_id=?2 WHERE request_id='cr_read_created'", [ids.manual, b.network_id]);
|
|
111
|
+
nodes = await read();
|
|
112
|
+
expect(nodes.find((n: any) => n.node_id === ids.manual).managed).toBe("none");
|
|
113
|
+
} finally {
|
|
114
|
+
db.run("UPDATE node_create_requests SET child_node_id=NULL,network_id=?1 WHERE request_id='cr_read_created'", [a.network_id]);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("authenticated port-0 HTTP projects actual authority, never an id prefix", async () => {
|
|
119
|
+
const { status, body } = await get(`/api/nodes?network_id=${a.network_id}`);
|
|
120
|
+
expect(status).toBe(200);
|
|
121
|
+
const rows = body.nodes;
|
|
122
|
+
expect(rows.find((r: any) => r.node_id === ids.created)).toMatchObject({ managed: "created", adoption: null, lifecycle_controllable: true });
|
|
123
|
+
expect(rows.find((r: any) => r.node_id === ids.manual)).toMatchObject({ managed: "none", adoption: null, lifecycle_controllable: false });
|
|
124
|
+
expect(rows.find((r: any) => r.node_id === ids.active)).toMatchObject({ managed: "adopted", adoption: { status: "active", request_id: "adopt_read_active" }, lifecycle_controllable: true });
|
|
125
|
+
for (const status of ["pending", "refused", "revoked"]) expect(rows.find((r: any) => r.node_id === `n_read_${status}`)).toMatchObject({ managed: "none", adoption: { status } });
|
|
126
|
+
});
|
|
127
|
+
test("adopt query preserves all persisted states, request id and bounded projection", async () => {
|
|
128
|
+
for (const status of ["pending", "active", "refused", "revoked"]) {
|
|
129
|
+
const r = await get(query("adopt", `request_id=adopt_read_${status}`));
|
|
130
|
+
expect(r.status).toBe(200);
|
|
131
|
+
expect(r.body.request).toMatchObject({ kind: "adopt", status, request_id: `adopt_read_${status}` });
|
|
132
|
+
expect(r.body.request.workdir).toBeUndefined();
|
|
133
|
+
expect(r.body.request.requested_by).toBeUndefined();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
test("start/stop returns daemon failure codes; node selector returns latest, not success fiction", async () => {
|
|
137
|
+
for (const [kind, error] of [["start", "adopt_start_evidence_missing"], ["stop", "adopt_explicit_private_socket_required"]]) {
|
|
138
|
+
const r = await get(query(kind, `node_id=${ids.active}`));
|
|
139
|
+
expect(r.status).toBe(200);
|
|
140
|
+
expect(r.body.request).toMatchObject({ status: `${kind}_failed`, error, node_id: ids.active });
|
|
141
|
+
expect(r.body.request.created_by_token).toBeUndefined();
|
|
142
|
+
}
|
|
143
|
+
expect((await get(query("start", `node_id=${ids.manual}`))).body.request).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
test("cross-network request and node guesses do not disclose data", async () => {
|
|
146
|
+
for (const kind of ["adopt", "start", "stop"]) {
|
|
147
|
+
const id = kind === "adopt" ? "adopt_read_active" : `${kind}_read_failed`;
|
|
148
|
+
const denied = await get(query(kind, `request_id=${id}`), b.token);
|
|
149
|
+
expect(denied.status).toBe(404);
|
|
150
|
+
expect(denied.body).toEqual((await get(query(kind, "request_id=nonexistent"), b.token)).body);
|
|
151
|
+
expect((await get(query(kind, `node_id=${ids.active}`), b.token)).status).toBe(404);
|
|
152
|
+
expect((await get(query(kind, `request_id=${id}&network_id=${a.network_id}`), b.token)).status).toBe(403);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
test("fork receipt uses the same authenticated node visibility and redacts corrupt storage", async () => {
|
|
156
|
+
const receipt = { state: "forked", old_thread_id: "01a11846-d796-72f1-af68-8d9215a65dc8", new_thread_id: "01a11900-0000-7000-8000-000000000001" };
|
|
157
|
+
const path = query("start", "request_id=start_read_failed");
|
|
158
|
+
try {
|
|
159
|
+
db.run("UPDATE node_start_requests SET fork_recovery_json=?1,fork_result_json=?2 WHERE request_id='start_read_failed'",
|
|
160
|
+
[JSON.stringify({ kind: "fork_on_missing_ordinal", confirmed: true }), JSON.stringify(receipt)]);
|
|
161
|
+
expect((await get(path)).body.request.fork_recovery).toEqual({ requested: true, result: receipt });
|
|
162
|
+
expect((await get(path, b.token)).status).toBe(404);
|
|
163
|
+
expect((await get(path, limited.token)).status).toBe(404);
|
|
164
|
+
expect((await get(path, daemonToken)).status).toBe(403);
|
|
165
|
+
db.run("INSERT INTO network_member_agent_grants(network_id,user_id,node_id,can_message) VALUES(?1,?2,?3,0)", [a.network_id, limited.user.user_id, ids.active]);
|
|
166
|
+
expect((await get(path, limited.token)).body.request.fork_recovery.result).toEqual(receipt);
|
|
167
|
+
db.run("UPDATE node_start_requests SET fork_result_json=?1 WHERE request_id='start_read_failed'", [JSON.stringify({ ...receipt, snapshot: "/private/secret" })]);
|
|
168
|
+
const corrupted = await get(path, limited.token);
|
|
169
|
+
expect(corrupted.body.request.fork_recovery.result).toEqual({ state: "unknown", reason: "result_invalid" });
|
|
170
|
+
expect(JSON.stringify(corrupted)).not.toContain("/private");
|
|
171
|
+
// Corrupt confirmation cannot assert requested:true, but must not erase an
|
|
172
|
+
// independently valid recorded fork (nor make the request look ordinary).
|
|
173
|
+
for (const raw of ["broken /private/secret", "null", "{}", JSON.stringify({ kind: "fork_on_missing_ordinal", confirmed: false }),
|
|
174
|
+
JSON.stringify({ kind: "fork_on_missing_ordinal", confirmed: true, snapshot: "/private/secret" })]) {
|
|
175
|
+
db.run("UPDATE node_start_requests SET fork_recovery_json=?1,fork_result_json=?2 WHERE request_id='start_read_failed'", [raw, JSON.stringify(receipt)]);
|
|
176
|
+
const invalid = await get(path, limited.token);
|
|
177
|
+
expect(invalid.body.request.fork_recovery).toEqual({ requested: null, result: receipt });
|
|
178
|
+
expect(JSON.stringify(invalid)).not.toContain("/private");
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
db.run("DELETE FROM network_member_agent_grants WHERE user_id=?1 AND node_id=?2", [limited.user.user_id, ids.active]);
|
|
182
|
+
db.run("UPDATE node_start_requests SET fork_recovery_json=NULL,fork_result_json=NULL WHERE request_id='start_read_failed'");
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
test("same-network restricted viewer without node grants cannot read requests", async () => {
|
|
186
|
+
for (const kind of ["adopt", "start", "stop"]) {
|
|
187
|
+
expect((await get(query(kind, `node_id=${ids.active}&network_id=${a.network_id}`), limited.token)).status).toBe(404);
|
|
188
|
+
const id = kind === "adopt" ? "adopt_read_active" : `${kind}_read_failed`;
|
|
189
|
+
expect((await get(query(kind, `request_id=${id}&network_id=${a.network_id}`), limited.token)).status).toBe(404);
|
|
190
|
+
}
|
|
191
|
+
expect((await get(`/api/nodes?network_id=${a.network_id}`, limited.token)).body.nodes).toEqual([]);
|
|
192
|
+
});
|
|
193
|
+
test("current node grant allows reads; revocation immediately closes them", async () => {
|
|
194
|
+
db.run("INSERT INTO network_member_agent_grants(network_id,user_id,node_id,can_message) VALUES(?1,?2,?3,0)", [a.network_id, limited.user.user_id, ids.active]);
|
|
195
|
+
try {
|
|
196
|
+
expect((await get(query("adopt", "request_id=adopt_read_active"), limited.token)).status).toBe(200);
|
|
197
|
+
const nodes = (await get(`/api/nodes?network_id=${a.network_id}`, limited.token)).body.nodes;
|
|
198
|
+
expect(nodes).toHaveLength(1);
|
|
199
|
+
expect(nodes[0].managed).toBe("adopted");
|
|
200
|
+
} finally { db.run("DELETE FROM network_member_agent_grants WHERE user_id=?1 AND node_id=?2", [limited.user.user_id, ids.active]); }
|
|
201
|
+
expect((await get(query("adopt", "request_id=adopt_read_active"), limited.token)).status).toBe(404);
|
|
202
|
+
});
|
|
203
|
+
test("daemon and legacy master do not gain the new user read surface", async () => {
|
|
204
|
+
for (const token of [daemonToken, process.env.COMMHUB_AUTH_TOKEN!]) {
|
|
205
|
+
expect((await get(query("adopt", "request_id=adopt_read_active"), token)).status).toBe(403);
|
|
206
|
+
const nodes = (await get(`/api/nodes?network_id=${a.network_id}`, token)).body.nodes;
|
|
207
|
+
expect(nodes.length).toBeGreaterThan(0);
|
|
208
|
+
expect(nodes.every((n: any) => !("managed" in n) && !("adoption" in n))).toBe(true);
|
|
209
|
+
}
|
|
210
|
+
expect((await get(query("adopt", "request_id=adopt_read_active"), "invalid")).status).toBe(401);
|
|
211
|
+
const queryOnly = await fetch(base + query("adopt", `request_id=adopt_read_active&token=${encodeURIComponent(a.token)}`));
|
|
212
|
+
expect([401, 403]).toContain(queryOnly.status);
|
|
213
|
+
});
|
|
214
|
+
test("host-supervisors capability respects user token and node visibility; absent stays absent", async () => {
|
|
215
|
+
const path = `/api/host-supervisors?network_id=${a.network_id}`;
|
|
216
|
+
expect((await get(path)).body.daemons.find((d: any) => d.daemon_node_id === ids.daemon).adopt_capable).toBe(true);
|
|
217
|
+
for (const token of [limited.token, daemonToken]) {
|
|
218
|
+
const r = await get(path, token);
|
|
219
|
+
expect((r.body.daemons ?? []).every((d: any) => !("adopt_capable" in d))).toBe(true);
|
|
220
|
+
}
|
|
221
|
+
db.run("UPDATE nodes SET config_snapshot=?1 WHERE node_id=?2", [JSON.stringify({ role: "host_supervisor", daemon_capabilities: { adopt_capable: false } }), ids.daemon]);
|
|
222
|
+
expect((await get(path)).body.daemons[0].adopt_capable).toBe(false);
|
|
223
|
+
db.run("UPDATE nodes SET config_snapshot=?1 WHERE node_id=?2", [JSON.stringify({ role: "host_supervisor" }), ids.daemon]);
|
|
224
|
+
expect((await get(path)).body.daemons[0].adopt_capable).toBeUndefined();
|
|
225
|
+
});
|
|
226
|
+
test("invalid selectors rejected rather than listing every lifecycle request", async () => {
|
|
227
|
+
for (const suffix of ["", "kind=adopt", "kind=other&node_id=x", "kind=start&request_id=x&node_id=y", "kind=stop&node_id=x&node_id=y", "kind=start&request_id=x&node_id=", "kind=adopt&node_id=%20"])
|
|
228
|
+
expect((await get(`/api/node-lifecycle-requests?${suffix}`)).status).toBe(400);
|
|
229
|
+
});
|
|
230
|
+
test("request state refresh exposes pending, completion and binding-missing failure without caching", async () => {
|
|
231
|
+
for (const [kind, statuses] of [["start", ["pending", "delivered", "started", "start_failed", "timeout"]], ["stop", ["pending", "delivered", "stopped", "stop_failed", "noop_not_my_child"]]] as const) {
|
|
232
|
+
for (const status of statuses) {
|
|
233
|
+
const error = status.endsWith("failed") ? "adopt_active_binding_required" : null;
|
|
234
|
+
db.run(`UPDATE node_${kind}_requests SET status=?1,error=?2 WHERE request_id=?3`, [status, error, `${kind}_read_failed`]);
|
|
235
|
+
expect((await get(query(kind, `request_id=${kind}_read_failed`))).body.request).toMatchObject({ status, error });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
test("join refuses a request whose stored network disagrees with its target node", async () => {
|
|
240
|
+
db.run("UPDATE node_start_requests SET network_id=?1 WHERE request_id='start_read_failed'", [b.network_id]);
|
|
241
|
+
try {
|
|
242
|
+
expect((await get(query("start", "request_id=start_read_failed"))).status).toBe(404);
|
|
243
|
+
expect((await get(query("start", "request_id=start_read_failed"), b.token)).status).toBe(404);
|
|
244
|
+
} finally { db.run("UPDATE node_start_requests SET network_id=?1 WHERE request_id='start_read_failed'", [a.network_id]); }
|
|
245
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
import { addAgentNetworkScope, type RestNetworkScope } from "./network-scope.js";
|
|
3
|
+
import { forkRecoveryRequestSchema, publicForkResult } from "./codex-fork-contract.js";
|
|
4
|
+
|
|
5
|
+
// Exact public codes only; daemon exception text must never reach node viewers.
|
|
6
|
+
const publicLifecycleErrors = new Set([
|
|
7
|
+
"codex_fork_confirmation_invalid", "codex_fork_recovery_unsupported", "codex_fork_cli_unsupported",
|
|
8
|
+
"codex_fork_result_invalid", "codex_fork_outcome_unknown",
|
|
9
|
+
"adopted_node_delete_unsupported", "adopt_codex_readopt_required", "adopt_stop_receipt_changed",
|
|
10
|
+
"adopt_codex_external_start_unproven", "adopt_codex_launch_failed", "adopt_codex_launch_timeout",
|
|
11
|
+
"adopt_codex_port_unproven", "adopt_codex_start_superseded", "adopt_codex_binding_generation_unproven",
|
|
12
|
+
"adopt_codex_start_evidence_missing", "adopt_codex_start_evidence_changed",
|
|
13
|
+
"adopt_codex_session_conflict", "adopt_codex_port_unavailable", "adopt_codex_stage_still_running",
|
|
14
|
+
"adopt_codex_identity_unproven",
|
|
15
|
+
"adopt_active_binding_required", "adopt_binding_unavailable",
|
|
16
|
+
"adopt_binding_revoked_during_start", "adopt_explicit_private_socket_required",
|
|
17
|
+
"adopt_start_evidence_missing", "adopt_stop_evidence_missing",
|
|
18
|
+
"adopt_launch_mode_mismatch", "adopt_lifecycle_verification_failed",
|
|
19
|
+
"adopt_process_generation_changed", "adopt_process_still_running",
|
|
20
|
+
"adopt_process_stop_timeout", "adopt_process_tree_unstable",
|
|
21
|
+
"adopt_registry_identity_mismatch", "adopt_self_process_refused",
|
|
22
|
+
"adopt_start_timeout", "adopt_tmux_session_still_exists",
|
|
23
|
+
"adopt_local_verification_failed", "adopt_roots_not_configured",
|
|
24
|
+
"adopt_workdir_outside_roots", "adopt_workdir_not_absolute",
|
|
25
|
+
"adopt_alias_invalid", "adopt_alias_mismatch", "adopt_config_invalid",
|
|
26
|
+
"adopt_config_network_mismatch", "adopt_network_mismatch",
|
|
27
|
+
"adopt_hub_invalid", "adopt_hub_missing", "adopt_hub_mismatch",
|
|
28
|
+
"adopt_identity_ambiguous", "adopt_identity_not_found", "adopt_identity_changed",
|
|
29
|
+
"adopt_path_not_regular", "adopt_path_owner_mismatch", "adopt_path_writable_by_others",
|
|
30
|
+
"adopt_process_argv_mismatch", "adopt_process_home_mismatch",
|
|
31
|
+
"adopt_process_identity_mismatch", "adopt_process_changed",
|
|
32
|
+
"adopt_tmux_socket_mismatch", "adopt_pane_invalid", "adopt_pane_unverified",
|
|
33
|
+
"adopt_pane_process_mismatch", "adopt_socket_directory_unsafe", "adopt_socket_unsafe",
|
|
34
|
+
"adopt_pid_changed", "adopt_pid_invalid", "adopt_pidfile_unsafe",
|
|
35
|
+
"opencode_tui_session_mismatch",
|
|
36
|
+
"adopt_proc_invalid", "adopt_proc_unreadable", "adopt_platform_unsupported",
|
|
37
|
+
"adopt_registry_conflict", "adopt_registry_entry_invalid", "adopt_registry_invalid",
|
|
38
|
+
"adopt_registry_unsafe", "adopt_config_env_invalid", "adopt_env_file_invalid",
|
|
39
|
+
"adopt_env_file_unsafe", "adopt_request_mismatch", "adopt_ack_rejected",
|
|
40
|
+
]);
|
|
41
|
+
function publicLifecycleError(error: unknown): string | null {
|
|
42
|
+
return error == null ? null
|
|
43
|
+
: typeof error === "string" && publicLifecycleErrors.has(error) ? error : "lifecycle_error";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type Adoption = { request_id: string; daemon_node_id: string; status: string; error: string | null };
|
|
47
|
+
export type LifecycleProjection = { managed: "created" | "adopted" | "none"; adoption: Adoption | null };
|
|
48
|
+
const scopeNodes = (sql: string, params: any[], scope: RestNetworkScope) =>
|
|
49
|
+
addAgentNetworkScope(sql, params, scope, { network: "n.network_id", nodeId: "n.node_id", alias: "n.alias" });
|
|
50
|
+
|
|
51
|
+
/** Called only for authenticated human readers. Same visibility as /api/nodes;
|
|
52
|
+
* constant query count, no id-prefix or host-name inference of authority. */
|
|
53
|
+
export function lifecycleProjections(scope: RestNetworkScope): Map<string, LifecycleProjection> {
|
|
54
|
+
const result = new Map<string, LifecycleProjection>();
|
|
55
|
+
const bp: any[] = [];
|
|
56
|
+
const bindings = db.all<Adoption & { node_id: string }>(scopeNodes(
|
|
57
|
+
`SELECT b.node_id,b.request_id,b.daemon_node_id,b.status,b.error
|
|
58
|
+
FROM node_daemon_bindings b JOIN nodes n ON n.node_id=b.node_id AND n.network_id=b.network_id
|
|
59
|
+
WHERE 1=1`, bp, scope) + " ORDER BY b.created_at DESC,b.request_id DESC", ...bp);
|
|
60
|
+
for (const b of bindings) {
|
|
61
|
+
let projection = result.get(b.node_id);
|
|
62
|
+
if (!projection) {
|
|
63
|
+
projection = { managed: "none", adoption: { request_id: b.request_id, daemon_node_id: b.daemon_node_id, status: b.status, error: publicLifecycleError(b.error) } };
|
|
64
|
+
result.set(b.node_id, projection);
|
|
65
|
+
}
|
|
66
|
+
if (b.status === "active") projection.managed = "adopted";
|
|
67
|
+
}
|
|
68
|
+
const cp: any[] = [];
|
|
69
|
+
const created = db.all<{ node_id: string }>(scopeNodes(
|
|
70
|
+
`SELECT n.node_id FROM node_create_requests c JOIN nodes n
|
|
71
|
+
ON n.node_id=COALESCE(c.child_node_id, CASE WHEN substr(c.request_id,1,3)='cr_'
|
|
72
|
+
THEN 'node_' || substr(c.request_id,4) END) AND n.network_id=c.network_id
|
|
73
|
+
WHERE 1=1`, cp, scope), ...cp);
|
|
74
|
+
for (const n of created) result.set(n.node_id, { managed: "created", adoption: result.get(n.node_id)?.adoption ?? null });
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Read-only, selector-bound lookup. Join the live node in the SAME network,
|
|
79
|
+
* then apply its current visibility (including restricted member grants).
|
|
80
|
+
* Result fields exclude credentials, PID and config; error values separately
|
|
81
|
+
* use the exact public-code allowlist (daemon text may contain workdir paths). */
|
|
82
|
+
export function lifecycleRequestResponse(url: URL, scope: RestNetworkScope): Response {
|
|
83
|
+
const kind = url.searchParams.get("kind");
|
|
84
|
+
const requestId = url.searchParams.get("request_id"), nodeId = url.searchParams.get("node_id");
|
|
85
|
+
const fail = (error: string, status: number) => Response.json({ ok: false, error }, { status });
|
|
86
|
+
if (!["adopt", "start", "stop"].includes(kind ?? "")
|
|
87
|
+
|| url.searchParams.has("request_id") === url.searchParams.has("node_id")
|
|
88
|
+
|| !(requestId ?? nodeId ?? "").trim()
|
|
89
|
+
|| ["kind", "request_id", "node_id"].some(k => url.searchParams.getAll(k).length > 1)
|
|
90
|
+
|| (requestId ?? nodeId ?? "").length > 200) return fail("invalid_lifecycle_query", 400);
|
|
91
|
+
// The table and column names below come only from this closed enum.
|
|
92
|
+
const table = kind === "adopt" ? "node_daemon_bindings" : kind === "start" ? "node_start_requests" : "node_stop_requests";
|
|
93
|
+
const nodeColumn = kind === "adopt" ? "node_id" : "child_node_id";
|
|
94
|
+
// A node lookup with no request must distinguish 'no requests' from 'not visible'.
|
|
95
|
+
if (nodeId) {
|
|
96
|
+
const params: any[] = [nodeId];
|
|
97
|
+
if (!db.get(scopeNodes("SELECT n.node_id FROM nodes n WHERE n.node_id=?1", params, scope), ...params))
|
|
98
|
+
return fail("node_not_found", 404);
|
|
99
|
+
}
|
|
100
|
+
const params: any[] = [requestId ?? nodeId];
|
|
101
|
+
const sql = scopeNodes(`SELECT r.request_id,r.${nodeColumn} AS node_id,r.network_id,r.daemon_node_id,
|
|
102
|
+
r.status,r.error,r.created_at,${kind === "adopt" ? "r.updated_at" : "r.acked_at,r.delivered_at"}
|
|
103
|
+
${kind === "start" ? ",r.fork_recovery_json,r.fork_result_json" : ""}
|
|
104
|
+
FROM ${table} r JOIN nodes n ON n.node_id=r.${nodeColumn} AND n.network_id=r.network_id
|
|
105
|
+
WHERE r.${requestId ? "request_id" : nodeColumn}=?1${kind === "stop" ? " AND r.action='stop'" : ""}`, params, scope);
|
|
106
|
+
const row = db.get<Record<string, unknown>>(sql + " ORDER BY r.created_at DESC,r.request_id DESC LIMIT 1", ...params);
|
|
107
|
+
if (!row && requestId) return fail("request_not_found", 404);
|
|
108
|
+
if (!row) return Response.json({ ok: true, request: null });
|
|
109
|
+
const { fork_recovery_json, fork_result_json, ...publicRow } = row;
|
|
110
|
+
let confirmed: true | null = null;
|
|
111
|
+
if (kind === "start" && typeof fork_recovery_json === "string") {
|
|
112
|
+
try {
|
|
113
|
+
if (forkRecoveryRequestSchema.safeParse(JSON.parse(fork_recovery_json)).success) confirmed = true;
|
|
114
|
+
} catch { /* damaged confirmation is unknown; keep any independently valid fork evidence */ }
|
|
115
|
+
}
|
|
116
|
+
return Response.json({ ok: true, request: { kind, ...publicRow, error: publicLifecycleError(row.error),
|
|
117
|
+
...(kind === "start" && fork_recovery_json != null ? { fork_recovery: {
|
|
118
|
+
requested: confirmed, result: publicForkResult(fork_result_json),
|
|
119
|
+
} } : {}),
|
|
120
|
+
} });
|
|
121
|
+
}
|