@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.131
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -10
- package/bin/commhub.ts +99 -11
- package/package.json +7 -7
- package/src/ack-create-request-transport.test.ts +182 -0
- package/src/ack-create-request.test.ts +50 -7
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/adoption-candidates-http.test.ts +137 -0
- package/src/adoption-candidates.ts +158 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +487 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +659 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +223 -0
- package/src/agent-notice.ts +48 -0
- package/src/agent-team-whoami-http.test.ts +98 -0
- package/src/agent-teams-http.test.ts +323 -0
- package/src/agent-teams.ts +244 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +42 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth-weak-password-login.test.ts +176 -0
- package/src/auth.ts +598 -49
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/binding-generation-http.test.ts +121 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/codex-config-flags.ts +77 -0
- package/src/codex-fork-contract.ts +23 -0
- package/src/codex-fork-migration.test.ts +38 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +134 -1
- package/src/config-apply-validate.ts +111 -5
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-network-id.test.ts +79 -0
- package/src/create-node-tool-schema.test.ts +48 -0
- package/src/create-node-unicode-name.test.ts +120 -0
- package/src/create-node-validate.test.ts +193 -7
- package/src/create-node-validate.ts +227 -15
- package/src/create-node-workdir.test.ts +289 -0
- package/src/create-node.ts +77 -23
- package/src/daemon-provider-snapshots.test.ts +421 -0
- package/src/daemon-provider-snapshots.ts +327 -0
- package/src/daemon-token-owner-http.test.ts +236 -0
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +933 -36
- package/src/delivered-without-push.test.ts +140 -0
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/department-heads-http.test.ts +330 -0
- package/src/department-heads.ts +226 -0
- package/src/departments-http.test.ts +185 -0
- package/src/departments.ts +260 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-provider-config.test.ts +139 -0
- package/src/hub-provider-config.ts +105 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-caller.ts +49 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2362
- package/src/lifecycle-guard.ts +1 -1
- package/src/mcp-context-cost-http.test.ts +123 -0
- package/src/mcp-projects-events-http.test.ts +162 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +432 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +210 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/model-auth-notify-http.test.ts +181 -0
- package/src/model-auth-notify.ts +116 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +276 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-alias-control.ts +12 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-caller-identity-http.test.ts +781 -0
- package/src/node-create-request-status-http.test.ts +105 -0
- package/src/node-daemon-bindings.test.ts +132 -0
- package/src/node-daemon-bindings.ts +130 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-codex-login.test.ts +120 -0
- package/src/node-health-dispatch-http.test.ts +279 -0
- package/src/node-health-guard.ts +105 -0
- package/src/node-health-passthrough.test.ts +115 -0
- package/src/node-health-store.ts +101 -0
- package/src/node-id-alias.test.ts +190 -0
- package/src/node-identity-conflict-http.test.ts +269 -0
- package/src/node-identity-conflict.ts +142 -0
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +53 -0
- package/src/node-lifecycle-read-http.test.ts +245 -0
- package/src/node-lifecycle-read.ts +121 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +285 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-ownership.ts +51 -0
- package/src/node-token-privilege-http.test.ts +158 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/opencode-v2-catalog.test.ts +268 -0
- package/src/opencode-v2-catalog.ts +352 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/project-dir-warn-clear-http.test.ts +132 -0
- package/src/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +429 -10
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +22 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-handover-capabilities-http.test.ts +124 -0
- package/src/report-status-host-schema.test.ts +235 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-due-reminders-http.test.ts +620 -0
- package/src/requirement-due-reminders.ts +416 -0
- package/src/requirement-events-http.test.ts +268 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +144 -0
- package/src/requirement-notify.ts +165 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-abandoned-http.test.ts +236 -0
- package/src/requirements-comment-http.test.ts +167 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-errors-mcp-http.test.ts +164 -0
- package/src/requirements-errors.ts +143 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-last-event-http.test.ts +349 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-list-slim-http.test.ts +319 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +259 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +145 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +167 -0
- package/src/requirements-status-participants-http.test.ts +222 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1806 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +222 -0
- package/src/rest-identity.test.ts +130 -0
- package/src/rest-identity.ts +139 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +76 -0
- package/src/retention.ts +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/runtime-readiness.ts +90 -0
- package/src/schedule-agent-mcp-http.test.ts +381 -0
- package/src/schedule-agent.ts +192 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-offline.test.ts +131 -0
- package/src/scheduled-offline.ts +66 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-stuck-http.test.ts +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +766 -0
- package/src/scoped-secrets.ts +124 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +5257 -0
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/session-token-ownership-http.test.ts +122 -0
- package/src/shared/codex-inventory.ts +41 -0
- package/src/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +52 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/node-name.test.ts +62 -0
- package/src/shared/node-name.ts +161 -0
- package/src/shared/provider-application.ts +29 -0
- package/src/shared/provider-collection.ts +14 -0
- package/src/shared/provider-initialization.ts +8 -0
- package/src/shared/reserved-env-drift.test.ts +10 -0
- package/src/shared/scoped-secret-contract.ts +5 -0
- package/src/shared/task-content-limit-drift.test.ts +28 -0
- package/src/shared/task-content-limit.ts +25 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +264 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-alias-resolver-http.test.ts +163 -0
- package/src/status-alias-resolver.ts +19 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +122 -0
- package/src/stop-delete-node.test.ts +509 -1
- package/src/task-access-http.test.ts +402 -0
- package/src/task-access.ts +420 -0
- package/src/task-auth-origin-http.test.ts +93 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +315 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-late-replies.ts +54 -0
- package/src/task-late-reply-http.test.ts +152 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +140 -0
- package/src/task-orphan-http.test.ts +287 -0
- package/src/task-orphan.ts +119 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +236 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-rejection-log-http.test.ts +160 -0
- package/src/task-rejection-log.ts +49 -0
- package/src/task-sender-http.test.ts +143 -0
- package/src/task-stale-open-http.test.ts +290 -0
- package/src/task-stale-open.ts +103 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tool-audience-http.test.ts +220 -0
- package/src/tool-audience.ts +118 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +3940 -325
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.ts +103 -6
- package/src/user-avatar-http.test.ts +74 -0
- package/src/user-avatar-migration.test.ts +25 -0
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import "./require-explicit-test-db";
|
|
2
|
+
import { afterAll, beforeEach, expect, test } from "bun:test";
|
|
3
|
+
import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync, chmodSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { EventEmitter } from 'node:events';
|
|
8
|
+
import { CodexManagedLogin } from '../../agent-node/src/runtime/codex-managed-login';
|
|
9
|
+
import { DaemonCodexLogins } from '../../agent-node/src/runtime/daemon-codex-logins';
|
|
10
|
+
import { executeProviderLogin, closeProviderLogins } from '../../agent-node/src/runtime/daemon-provider-login';
|
|
11
|
+
import { db } from "./db";
|
|
12
|
+
import { registerTools } from "./tools";
|
|
13
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
|
+
import { reconcileProviderSnapshots, readLocalProviderSnapshot } from "../../agent-node/src/runtime/daemon-provider-snapshot";
|
|
15
|
+
import { DaemonProviderStore } from "../../agent-network/src/daemon-provider-store";
|
|
16
|
+
import { providerNodeRevision } from '../../agent-network/src/daemon-provider-application';
|
|
17
|
+
import { register, createNetworkTokenForNode } from "./auth";
|
|
18
|
+
|
|
19
|
+
const net = "net_dps906", user = "u_dps906", node = "node_dps906", token = "token_dps906", alias = "daemon906";
|
|
20
|
+
const root = mkdtempSync(join(tmpdir(), "dps906-"));
|
|
21
|
+
function cleanup() {
|
|
22
|
+
db.run('DELETE FROM daemon_provider_logins WHERE request_id IN (SELECT request_id FROM daemon_provider_snapshots WHERE network_id=?1)', [net]);
|
|
23
|
+
db.run('DELETE FROM daemon_provider_applications WHERE request_id IN (SELECT request_id FROM daemon_provider_snapshots WHERE network_id=?1)', [net]);
|
|
24
|
+
db.run('DELETE FROM daemon_provider_writes WHERE request_id IN (SELECT request_id FROM daemon_provider_snapshots WHERE network_id=?1)', [net]);
|
|
25
|
+
db.run('DELETE FROM node_daemon_bindings WHERE network_id=?1', [net]);
|
|
26
|
+
for (const t of ["daemon_provider_snapshots", "nodes", "api_tokens", "network_members"]) db.run(`DELETE FROM ${t} WHERE network_id=?1`, [net]);
|
|
27
|
+
db.run("DELETE FROM networks WHERE network_id=?1", [net]);
|
|
28
|
+
db.run("DELETE FROM users WHERE user_id=?1", [user]);
|
|
29
|
+
}
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
cleanup();
|
|
32
|
+
db.run("INSERT INTO users (user_id,username,password_hash,role) VALUES (?1,?1,'x','user')", [user]);
|
|
33
|
+
db.run("INSERT INTO networks (network_id,network_name,owner_id) VALUES (?1,?1,?2)", [net, user]);
|
|
34
|
+
db.run("INSERT INTO network_members (user_id,network_id,role) VALUES (?1,?2,'owner')", [user, net]);
|
|
35
|
+
db.run("INSERT INTO nodes (node_id,node_name,alias,network_id,config_snapshot) VALUES (?1,?2,?2,?3,?4)", [node, alias, net, JSON.stringify({ role: "host_supervisor" })]);
|
|
36
|
+
db.run("INSERT INTO api_tokens (token_id,user_id,network_id,scope,name,token_hash) VALUES (?1,?2,?3,'network',?4,?5)", [token, user, net, `node:${alias}`, `hash_${token}`]);
|
|
37
|
+
});
|
|
38
|
+
afterAll(() => { cleanup(); rmSync(root, { recursive: true, force: true }); });
|
|
39
|
+
function client(kind = "human", network = net) {
|
|
40
|
+
const handlers: Record<string, any> = {};
|
|
41
|
+
// Capture the actual registerTools handlers, including production guards.
|
|
42
|
+
const server = new McpServer({ name: "test906", version: "0" }) as any;
|
|
43
|
+
const tool = server.tool.bind(server), register = server.registerTool.bind(server);
|
|
44
|
+
server.tool = (name: string, description: any, schema: any, handler: any) => { handlers[name] = handler; return tool(name, description, schema, handler); };
|
|
45
|
+
server.registerTool = (name: string, config: any, handler: any) => { handlers[name] = handler; return register(name, config, handler); };
|
|
46
|
+
registerTools(server, undefined, kind === "daemon" ? network : null, kind === "anonymous" ? null : user,
|
|
47
|
+
alias, kind === "daemon", kind === "daemon" ? token : null);
|
|
48
|
+
return async (name: string, args: any = {}) => JSON.parse((await handlers[name](args)).content[0].text);
|
|
49
|
+
}
|
|
50
|
+
const snapshot = () => ({ network_id: net, daemon_node_id: node, revision: 0, source: "daemon", providers: [] });
|
|
51
|
+
const request = () => client()("daemon_provider_snapshot", { action: "refresh", network_id: net, id: node });
|
|
52
|
+
|
|
53
|
+
test('saved ChatGPT account binding traverses Hub and compiled daemon CLI without credentials', async () => {
|
|
54
|
+
process.env.ANET_HUB_SECRET_VAULT_KEY = '42'.repeat(32);
|
|
55
|
+
const work = mkdtempSync(join(root, 'bind-')), dir = join(work, '.anet/nodes', alias);
|
|
56
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
57
|
+
writeFileSync(join(dir, 'config.json'), JSON.stringify({ role: 'host_supervisor', network_id: net, node_id: node }), { mode: 0o600 });
|
|
58
|
+
const prepared = new DaemonProviderStore(dir).prepareLoginDirectory();
|
|
59
|
+
const home = join(prepared.root, 'login-fixture'); mkdirSync(home, { mode: 0o700 });
|
|
60
|
+
const account = '11111111-1111-4111-8111-111111111111';
|
|
61
|
+
writeFileSync(join(home, 'anet-account.json'), JSON.stringify({ version: 1, network_id: net, daemon_node_id: node, account_id: account }), { mode: 0o600 });
|
|
62
|
+
writeFileSync(join(home, 'auth.json'), JSON.stringify({ auth_mode: 'chatgpt', tokens: { access_token: 'TEST-ONLY-access', refresh_token: 'TEST-ONLY-refresh' } }), { mode: 0o600 });
|
|
63
|
+
const provider = { id: 'openai', label: 'OpenAI', enabled: true, runtimes: [{ runtime: 'codex-tui', auth: [
|
|
64
|
+
{ id: 'work', kind: 'chatgpt', account_id: account, models: ['model-fixture'] },
|
|
65
|
+
] }] };
|
|
66
|
+
const human = client(), daemon = client('daemon');
|
|
67
|
+
const r = await human('daemon_provider_snapshot', { action: 'put', id: node, network_id: net, revision: 0, provider });
|
|
68
|
+
expect(r.ok).toBe(true);
|
|
69
|
+
await reconcileProviderSnapshots({ workDir: work, alias, networkId: net, daemonId: node, serverUrl: 'https://hub.example.test', warn: () => {}, callCommHub: daemon });
|
|
70
|
+
const read = await human('daemon_provider_snapshot', { action: 'read', id: r.request_id, network_id: net });
|
|
71
|
+
expect(read.status).toBe('succeeded');
|
|
72
|
+
expect(read.snapshot.providers[0].runtimes[0].auth[0]).toMatchObject({ account_id: account, credential_present: true, verification: 'not_checked', application: 'not_applied' });
|
|
73
|
+
expect(JSON.stringify(read)).not.toContain('TEST-ONLY-access');
|
|
74
|
+
expect(JSON.stringify(read)).not.toContain('TEST-ONLY-refresh');
|
|
75
|
+
expect(JSON.stringify(read)).not.toContain(home);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('managed login: admin intent → capable target daemon → session manager → bounded public receipt', async () => {
|
|
79
|
+
const work = mkdtempSync(join(root, 'login-'));
|
|
80
|
+
const dir = join(work, '.anet/nodes', alias); mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
81
|
+
writeFileSync(join(dir, 'config.json'), JSON.stringify({ role: 'host_supervisor', network_id: net, node_id: node }), { mode: 0o600 });
|
|
82
|
+
const prepared = new DaemonProviderStore(dir).prepareLoginDirectory();
|
|
83
|
+
const session = 'b86c3ca5-5022-434d-bc9f-aa1c82ecf419';
|
|
84
|
+
class Rpc extends EventEmitter {
|
|
85
|
+
account: unknown = null;
|
|
86
|
+
async request<R>(method: string): Promise<R> {
|
|
87
|
+
return (method === 'account/read' ? { account: this.account } : { type: 'chatgptDeviceCode', loginId: session,
|
|
88
|
+
verificationUrl: 'https://auth.openai.com/codex/device', userCode: 'TEST-ONLY' }) as R;
|
|
89
|
+
}
|
|
90
|
+
async close() {}
|
|
91
|
+
}
|
|
92
|
+
const rpc = new Rpc(); let home = '';
|
|
93
|
+
const logins = new DaemonCodexLogins(prepared.root, { network_id: net, daemon_node_id: node }, async () => {
|
|
94
|
+
home = mkdtempSync(join(prepared.root, 'login-'));
|
|
95
|
+
const login = new CodexManagedLogin(rpc);
|
|
96
|
+
return { home, login, discard: async () => { await login.close(); rmSync(home, { recursive: true, force: true }); } };
|
|
97
|
+
});
|
|
98
|
+
const human = client(), daemon = client('daemon');
|
|
99
|
+
const deps = { workDir: work, alias, networkId: net, daemonId: node, serverUrl: 'https://hub.example.test', warn: () => {},
|
|
100
|
+
callCommHub: daemon, login: (intent: any) => executeProviderLogin(logins, intent) };
|
|
101
|
+
const command = (intent: any) => human('daemon_provider_snapshot', { action: 'login', id: node, network_id: net, provider: intent });
|
|
102
|
+
const read = (id: string) => human('daemon_provider_snapshot', { action: 'read', id, network_id: net });
|
|
103
|
+
try {
|
|
104
|
+
expect((await daemon('daemon_provider_snapshot', { action: 'login', id: node, provider: { action: 'start', session_id: session } })).ok).toBe(false);
|
|
105
|
+
expect((await command({ action: 'start', session_id: session, token: 'TEST-ONLY' })).ok).toBe(false);
|
|
106
|
+
const start = await command({ action: 'start', session_id: session });
|
|
107
|
+
expect(start.ok).toBe(true);
|
|
108
|
+
expect((await daemon('pull_daemon_provider_snapshots', { accept_writes: true, accept_applications: true })).requests).toEqual([]);
|
|
109
|
+
expect((await client('daemon', 'other')('pull_daemon_provider_snapshots', { accept_logins: true })).ok).toBe(false);
|
|
110
|
+
expect((await daemon('ack_daemon_provider_snapshot', { request_id: start.request_id, login: { request_id: session, status: 'authenticated', token: 'TEST-ONLY' } })).ok).toBe(false);
|
|
111
|
+
await reconcileProviderSnapshots(deps);
|
|
112
|
+
const challenge = await read(start.request_id);
|
|
113
|
+
expect(challenge.status).toBe('succeeded');
|
|
114
|
+
expect(challenge.login.status).toBe('awaiting_user');
|
|
115
|
+
expect(challenge.snapshot).toBeNull();
|
|
116
|
+
writeFileSync(join(home, 'auth.json'), '{"TEST_ONLY":"SECRET"}', { mode: 0o600 });
|
|
117
|
+
rpc.account = { type: 'chatgpt', email: 'PRIVATE', token: 'SECRET' };
|
|
118
|
+
rpc.emit('account/login/completed', { loginId: session, success: true });
|
|
119
|
+
await Promise.resolve();
|
|
120
|
+
const save = await command({ action: 'save', session_id: session, label: 'Work' });
|
|
121
|
+
await reconcileProviderSnapshots(deps);
|
|
122
|
+
expect((await read(save.request_id)).login.account_id).toBeString();
|
|
123
|
+
const list = await command({ action: 'accounts' });
|
|
124
|
+
// Listing uses the actual daemon handler and compiled pinned CLI bootstrap,
|
|
125
|
+
// not the injected OAuth fixture. It does not start a login or run inference.
|
|
126
|
+
await reconcileProviderSnapshots({ ...deps, login: undefined });
|
|
127
|
+
const result = await read(list.request_id);
|
|
128
|
+
expect(result.login.accounts).toHaveLength(1);
|
|
129
|
+
expect(result.login.accounts[0].auth_state).toBe('not_checked');
|
|
130
|
+
expect(JSON.stringify(result)).not.toMatch(/SECRET|PRIVATE|auth.json/);
|
|
131
|
+
db.run('UPDATE daemon_provider_snapshots SET expires_at=?1 WHERE request_id=?2', [Date.now() - 1, start.request_id]);
|
|
132
|
+
expect((await read(start.request_id)).login).toBeNull();
|
|
133
|
+
expect(db.get<any>('SELECT snapshot_json FROM daemon_provider_snapshots WHERE request_id=?1', start.request_id).snapshot_json).toBeNull();
|
|
134
|
+
} finally { await logins.close(); await closeProviderLogins(); }
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('application transport: scoped intent, capability gate, exact public receipt and changed binding refusal', async () => {
|
|
138
|
+
const human = client(), daemon = client('daemon');
|
|
139
|
+
const child = 'node_apply906';
|
|
140
|
+
db.run("INSERT INTO nodes (node_id,node_name,alias,network_id,runtime) VALUES (?1,'child906','child906',?2,'codex-app-server')", [child, net]);
|
|
141
|
+
db.run("INSERT INTO node_daemon_bindings (request_id,network_id,node_id,daemon_node_id,workdir,requested_by,status,created_at,updated_at) VALUES ('binding_apply906',?1,?2,?3,?4,?5,'active',1,1)", [net, child, node, root, user]);
|
|
142
|
+
const selection = { node_id: child, node_revision: 'a'.repeat(64), provider_id: 'custom', auth_id: 'work', model: 'model-a', confirm_restart: true };
|
|
143
|
+
const args = { action: 'apply', id: node, network_id: net, revision: 1, provider: selection };
|
|
144
|
+
expect((await human('daemon_provider_snapshot', { ...args, provider: { ...selection, key: 'TEST-ONLY-key' } })).error).toBe('invalid_application');
|
|
145
|
+
expect((await human('daemon_provider_snapshot', { ...args, provider: { ...selection, node_id: node } })).error).toBe('application_target_not_bound');
|
|
146
|
+
expect((await daemon('daemon_provider_snapshot', args)).ok).toBe(false);
|
|
147
|
+
const r = await human('daemon_provider_snapshot', args);
|
|
148
|
+
expect(r.ok).toBe(true); expect(r.request_id).toMatch(/^dpa_/);
|
|
149
|
+
expect((await daemon('pull_daemon_provider_snapshots', { accept_writes: true })).requests).toEqual([]);
|
|
150
|
+
const dispatch = await daemon('pull_daemon_provider_snapshots', { accept_applications: true });
|
|
151
|
+
expect(dispatch.requests[0]).toMatchObject({ request_id: r.request_id, operation: 'apply', alias: 'child906', expected_revision: 1, selection });
|
|
152
|
+
const receipt = { status: 'applied', request_id: r.request_id, node_id: child, provider_id: 'custom', auth_id: 'work', model: 'model-a',
|
|
153
|
+
runtime_provider: 'anet_' + createHash('sha256').update(JSON.stringify(['custom', 'work'])).digest('hex').slice(0, 20), node_revision: 'c'.repeat(64), provider_revision: 1, verification: 'runtime_confirmed', applied_at: Date.now() };
|
|
154
|
+
// This is a protocol fixture, NOT evidence a runtime has switched.
|
|
155
|
+
for (const bad of [{ ...receipt, model: 'wrong' }, { ...receipt, key: 'TEST-ONLY-key' }, { ...receipt, provider_revision: 2 }, { ...receipt, runtime_provider: 'anet_' + 'b'.repeat(20) }]) {
|
|
156
|
+
expect((await daemon('ack_daemon_provider_snapshot', { request_id: r.request_id, application: bad })).error).toBe('invalid_application_receipt');
|
|
157
|
+
}
|
|
158
|
+
expect((await daemon('ack_daemon_provider_snapshot', { request_id: r.request_id, snapshot: snapshot() })).ok).toBe(false);
|
|
159
|
+
expect((await daemon('ack_daemon_provider_snapshot', { request_id: r.request_id, application: receipt })).status).toBe('succeeded');
|
|
160
|
+
await daemon('ack_daemon_provider_snapshot', { request_id: r.request_id, failed: true });
|
|
161
|
+
const read = await human('daemon_provider_snapshot', { action: 'read', id: r.request_id, network_id: net });
|
|
162
|
+
expect(read.status).toBe('succeeded'); expect(read.application).toEqual(receipt); expect(read.snapshot).toBeNull();
|
|
163
|
+
expect(db.get('SELECT * FROM daemon_provider_applications WHERE request_id=?1', r.request_id)).toBeNull();
|
|
164
|
+
const accountId = 'aaaaaaaa-1111-4111-8111-111111111111';
|
|
165
|
+
const officialSelection = { ...selection, provider_id: 'openai', auth_id: 'work-login', account_id: accountId };
|
|
166
|
+
const official = await human('daemon_provider_snapshot', { ...args, provider: officialSelection });
|
|
167
|
+
expect(official.ok).toBe(true);
|
|
168
|
+
const officialDispatch = await daemon('pull_daemon_provider_snapshots', { accept_applications: true });
|
|
169
|
+
expect(officialDispatch.requests[0].selection).toEqual(officialSelection);
|
|
170
|
+
const officialReceipt = { ...receipt, request_id: official.request_id, provider_id: 'openai', auth_id: 'work-login',
|
|
171
|
+
account_id: accountId, runtime_provider: 'openai', applied_at: Date.now() };
|
|
172
|
+
for (const wrong of [{ ...officialReceipt, account_id: 'bbbbbbbb-1111-4111-8111-111111111111' },
|
|
173
|
+
{ ...officialReceipt, account_id: undefined }, { ...officialReceipt, runtime_provider: receipt.runtime_provider }]) {
|
|
174
|
+
expect((await daemon('ack_daemon_provider_snapshot', { request_id: official.request_id, application: wrong })).error).toBe('invalid_application_receipt');
|
|
175
|
+
}
|
|
176
|
+
expect((await daemon('ack_daemon_provider_snapshot', { request_id: official.request_id, application: officialReceipt })).status).toBe('succeeded');
|
|
177
|
+
expect((await human('daemon_provider_snapshot', { action: 'read', id: official.request_id, network_id: net })).application).toEqual(officialReceipt);
|
|
178
|
+
const stale = await human('daemon_provider_snapshot', args);
|
|
179
|
+
db.run("UPDATE node_daemon_bindings SET daemon_node_id='other' WHERE request_id='binding_apply906'");
|
|
180
|
+
expect((await daemon('pull_daemon_provider_snapshots', { accept_applications: true })).requests).toEqual([]);
|
|
181
|
+
expect((await human('daemon_provider_snapshot', { action: 'read', id: stale.request_id, network_id: net })).error).toBe('application_target_not_bound');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('write core chain: encrypted Hub request → target daemon CLI → durable replay → public readback', async () => {
|
|
185
|
+
process.env.ANET_HUB_SECRET_VAULT_KEY = '42'.repeat(32); // isolated fixture only
|
|
186
|
+
const work = mkdtempSync(join(root, 'write-'));
|
|
187
|
+
const dir = join(work, '.anet/nodes', alias); mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
188
|
+
writeFileSync(join(dir, 'config.json'), JSON.stringify({ role: 'host_supervisor', network_id: net, node_id: node }), { mode: 0o600 });
|
|
189
|
+
const key = 'TEST-ONLY-private-write-key';
|
|
190
|
+
const provider = { id: 'deepseek', label: 'DeepSeek', enabled: true, runtimes: [{ runtime: 'codex-tui', auth: [
|
|
191
|
+
{ id: 'work', kind: 'api_key', models: ['model-a'], baseUrl: 'https://api.example.test/v1', key },
|
|
192
|
+
] }] };
|
|
193
|
+
const human = client(), daemon = client('daemon');
|
|
194
|
+
const r = await human('daemon_provider_snapshot', { action: 'put', id: node, network_id: net, revision: 0, provider });
|
|
195
|
+
expect(r.ok).toBe(true); expect(JSON.stringify(r)).not.toContain(key);
|
|
196
|
+
const encrypted = db.get<any>('SELECT * FROM daemon_provider_writes WHERE request_id=?1', r.request_id);
|
|
197
|
+
expect(Buffer.from(encrypted.ciphertext).toString()).not.toContain(key);
|
|
198
|
+
expect((await daemon('pull_daemon_provider_snapshots')).requests).toEqual([]); // old/insecure readers receive no key
|
|
199
|
+
expect((await client('daemon', 'other')('pull_daemon_provider_snapshots', { accept_writes: true })).ok).toBe(false);
|
|
200
|
+
let drop = true;
|
|
201
|
+
const deps = { workDir: work, alias, networkId: net, daemonId: node, serverUrl: 'https://hub.example.test', warn: () => {},
|
|
202
|
+
callCommHub: async (name: string, args: any) => {
|
|
203
|
+
if (name === 'ack_daemon_provider_snapshot' && drop) { drop = false; throw Error('lost acknowledgement'); }
|
|
204
|
+
return daemon(name, args);
|
|
205
|
+
} };
|
|
206
|
+
await reconcileProviderSnapshots(deps);
|
|
207
|
+
expect(new DaemonProviderStore(dir).list().revision).toBe(1);
|
|
208
|
+
await reconcileProviderSnapshots(deps); // same request must not write revision 2 or lose success
|
|
209
|
+
const read = await human('daemon_provider_snapshot', { action: 'read', id: r.request_id, network_id: net });
|
|
210
|
+
expect(read.status).toBe('succeeded'); expect(read.snapshot.revision).toBe(1);
|
|
211
|
+
expect(read.snapshot.providers[0].runtimes[0].auth[0].models).toEqual(['model-a']);
|
|
212
|
+
expect(JSON.stringify(read)).not.toContain(key);
|
|
213
|
+
expect(db.get('SELECT * FROM daemon_provider_writes WHERE request_id=?1', r.request_id)).toBeNull();
|
|
214
|
+
const conflict = await human('daemon_provider_snapshot', { action: 'put', id: node, network_id: net, revision: 0, provider: { ...provider, label: 'must not overwrite' } });
|
|
215
|
+
await reconcileProviderSnapshots(deps);
|
|
216
|
+
expect((await human('daemon_provider_snapshot', { action: 'read', id: conflict.request_id, network_id: net })).status).toBe('failed');
|
|
217
|
+
expect(new DaemonProviderStore(dir).list().providers[0].label).toBe('DeepSeek');
|
|
218
|
+
db.run("UPDATE network_members SET role='member' WHERE user_id=?1 AND network_id=?2", [user, net]);
|
|
219
|
+
expect((await human('daemon_provider_snapshot', { action: 'put', id: node, network_id: net, revision: 1, provider })).error).toBe('provider_admin_required');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('collection core chain: bound source → capable daemon CLI → durable replay → redacted Provider', async () => {
|
|
223
|
+
process.env.ANET_HUB_SECRET_VAULT_KEY = '42'.repeat(32);
|
|
224
|
+
const work = mkdtempSync(join(root, 'collect-')), dir = join(work, '.anet/nodes', alias);
|
|
225
|
+
const childId = 'n_collect906', childAlias = 'source906', source = join(work, '.anet/nodes', childAlias);
|
|
226
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 }); mkdirSync(join(source, 'codex-home'), { recursive: true, mode: 0o700 });
|
|
227
|
+
writeFileSync(join(dir, 'config.json'), JSON.stringify({ role: 'host_supervisor', network_id: net, node_id: node }), { mode: 0o600 });
|
|
228
|
+
const key = 'TEST-ONLY-collect-private-key';
|
|
229
|
+
writeFileSync(join(source, 'config.json'), JSON.stringify({ node_id: childId, alias: childAlias, network_id: net,
|
|
230
|
+
runtime: 'codex-app-server', codexHome: join(source, 'codex-home'), env: { TEST_PROVIDER_KEY: { _envRef: 'TEST_PROVIDER_KEY' } } }), { mode: 0o600 });
|
|
231
|
+
writeFileSync(join(source, '.env'), `TEST_PROVIDER_KEY=${key}\n`, { mode: 0o600 });
|
|
232
|
+
writeFileSync(join(source, 'codex-home/config.toml'), 'model_provider = "deepseek"\nmodel = "fixture-model"\n[model_providers.deepseek]\nbase_url = "https://api.example.test/v1"\nenv_key = "TEST_PROVIDER_KEY"\nwire_api = "responses"\n', { mode: 0o600 });
|
|
233
|
+
db.run("INSERT INTO nodes (node_id,node_name,alias,network_id,runtime) VALUES (?1,?2,?2,?3,'codex-app-server')", [childId, childAlias, net]);
|
|
234
|
+
db.run("INSERT INTO node_daemon_bindings (request_id,network_id,node_id,daemon_node_id,workdir,requested_by,status,created_at,updated_at) VALUES ('binding_collect906',?1,?2,?3,?4,?5,'active',1,1)", [net, childId, node, work, user]);
|
|
235
|
+
const selection = { node_id: childId, alias: childAlias, node_revision: providerNodeRevision(source), provider_id: 'deepseek', auth_id: 'imported', label: 'DeepSeek' };
|
|
236
|
+
const before = providerNodeRevision(source), human = client(), daemon = client('daemon');
|
|
237
|
+
const requestArgs = { action: 'collect', id: node, network_id: net, revision: 0, provider: selection };
|
|
238
|
+
expect((await daemon('daemon_provider_snapshot', requestArgs)).error).toBe('user_token_required');
|
|
239
|
+
expect((await human('daemon_provider_snapshot', { ...requestArgs, provider: { ...selection, alias: 'another' } })).error).toBe('collection_target_not_bound');
|
|
240
|
+
expect((await human('daemon_provider_snapshot', { ...requestArgs, provider: { ...selection, key } })).error).toBe('invalid_provider_collection');
|
|
241
|
+
const r = await human('daemon_provider_snapshot', requestArgs); expect(r.ok).toBe(true);
|
|
242
|
+
expect((await daemon('pull_daemon_provider_snapshots', { accept_writes: true })).requests).toEqual([]);
|
|
243
|
+
expect((await daemon('pull_daemon_provider_snapshots', { accept_collections: true })).requests[0]).toMatchObject({ operation: 'collect', selection });
|
|
244
|
+
let drop = true;
|
|
245
|
+
const deps = { workDir: work, alias, networkId: net, daemonId: node, serverUrl: 'https://hub.example.test', warn: () => {},
|
|
246
|
+
callCommHub: async (name: string, args: any) => {
|
|
247
|
+
expect(JSON.stringify(args)).not.toContain(key);
|
|
248
|
+
if (name === 'ack_daemon_provider_snapshot' && drop) { drop = false; throw Error('lost acknowledgement'); }
|
|
249
|
+
return daemon(name, args);
|
|
250
|
+
} };
|
|
251
|
+
await reconcileProviderSnapshots(deps);
|
|
252
|
+
expect(new DaemonProviderStore(dir).list().revision).toBe(1);
|
|
253
|
+
await reconcileProviderSnapshots(deps);
|
|
254
|
+
const read = await human('daemon_provider_snapshot', { action: 'read', id: r.request_id, network_id: net });
|
|
255
|
+
expect(read.status).toBe('succeeded'); expect(read.snapshot.revision).toBe(1);
|
|
256
|
+
expect(read.snapshot.providers[0].runtimes[0].auth[0]).toMatchObject({ id: 'imported', kind: 'api_key', models: ['fixture-model'], credential_present: true, verification: 'not_checked', application: 'not_applied' });
|
|
257
|
+
expect(JSON.stringify(read)).not.toContain(key);
|
|
258
|
+
expect(db.get('SELECT * FROM daemon_provider_writes WHERE request_id=?1', r.request_id)).toBeNull();
|
|
259
|
+
expect(providerNodeRevision(source)).toBe(before); // source config/session/credential is never changed
|
|
260
|
+
const stale = await human('daemon_provider_snapshot', { ...requestArgs, revision: 1, provider: { ...selection, auth_id: 'second' } });
|
|
261
|
+
db.run("UPDATE node_daemon_bindings SET daemon_node_id='other' WHERE request_id='binding_collect906'");
|
|
262
|
+
expect((await daemon('pull_daemon_provider_snapshots', { accept_collections: true })).requests).toEqual([]);
|
|
263
|
+
expect((await human('daemon_provider_snapshot', { action: 'read', id: stale.request_id, network_id: net })).error).toBe('collection_target_not_bound');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('collection intent contract mirrors CLI, daemon and Hub exactly', () => {
|
|
267
|
+
const hub = readFileSync(new URL('./shared/provider-collection.ts', import.meta.url), 'utf8');
|
|
268
|
+
expect(hub).toBe(readFileSync(new URL('../../agent-node/src/shared/provider-collection.ts', import.meta.url), 'utf8'));
|
|
269
|
+
expect(hub).toBe(readFileSync(new URL('../../agent-network/src/shared/provider-collection.ts', import.meta.url), 'utf8'));
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("human authorization precedes dispatch; unknown, nodes and non-admins cannot request", async () => {
|
|
273
|
+
expect((await client("anonymous")("daemon_provider_snapshot", { action: "refresh", network_id: net, id: node })).error).toBe("user_token_required");
|
|
274
|
+
expect((await client("daemon")("daemon_provider_snapshot", { action: "refresh", network_id: net, id: node })).ok).toBe(false);
|
|
275
|
+
db.run("UPDATE network_members SET role='member' WHERE user_id=?1 AND network_id=?2", [user, net]);
|
|
276
|
+
expect((await request()).error).toBe("provider_admin_required");
|
|
277
|
+
expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM daemon_provider_snapshots WHERE network_id=?1", net)?.n).toBe(0);
|
|
278
|
+
});
|
|
279
|
+
test("normal nodes cannot impersonate a daemon; revoked token cannot pull or ack", async () => {
|
|
280
|
+
const r = await request(); expect(r.ok).toBe(true);
|
|
281
|
+
db.run("UPDATE nodes SET config_snapshot='{}' WHERE node_id=?1", [node]);
|
|
282
|
+
expect((await client("daemon")("pull_daemon_provider_snapshots")).error).toBe("caller_not_a_daemon");
|
|
283
|
+
db.run("UPDATE nodes SET config_snapshot=?1 WHERE node_id=?2", [JSON.stringify({ role: "host_supervisor" }), node]);
|
|
284
|
+
db.run("UPDATE api_tokens SET revoked_at=datetime('now') WHERE token_id=?1", [token]);
|
|
285
|
+
expect((await client("daemon")("pull_daemon_provider_snapshots")).ok).toBe(false);
|
|
286
|
+
expect((await client("daemon")("ack_daemon_provider_snapshot", { request_id: r.request_id, snapshot: snapshot() })).ok).toBe(false);
|
|
287
|
+
});
|
|
288
|
+
test("request/pull/ack/read lifecycle; duplicate click and ack cannot rewrite receipt", async () => {
|
|
289
|
+
const r = await request(); expect((await request()).request_id).toBe(r.request_id);
|
|
290
|
+
const human = client(), daemon = client("daemon");
|
|
291
|
+
expect((await human("daemon_provider_snapshot", { action: "read", id: r.request_id, network_id: net })).snapshot).toBeNull();
|
|
292
|
+
expect((await daemon("pull_daemon_provider_snapshots")).requests[0].request_id).toBe(r.request_id);
|
|
293
|
+
expect((await daemon("ack_daemon_provider_snapshot", { request_id: r.request_id, snapshot: snapshot() })).status).toBe("succeeded");
|
|
294
|
+
await daemon("ack_daemon_provider_snapshot", { request_id: r.request_id, failed: true });
|
|
295
|
+
const read = await human("daemon_provider_snapshot", { action: "read", id: r.request_id, network_id: net });
|
|
296
|
+
expect(read.status).toBe("succeeded"); expect(read.snapshot).toEqual(snapshot()); expect(read.observed_at).toBeGreaterThan(0);
|
|
297
|
+
expect((await daemon("pull_daemon_provider_snapshots")).requests).toEqual([]);
|
|
298
|
+
});
|
|
299
|
+
test("network and daemon mismatch cannot read or acknowledge another scope", async () => {
|
|
300
|
+
const r = await request();
|
|
301
|
+
expect((await client()("daemon_provider_snapshot", { action: "read", id: r.request_id, network_id: "foreign" })).ok).toBe(false);
|
|
302
|
+
expect((await client("daemon", "foreign")("pull_daemon_provider_snapshots")).ok).toBe(false);
|
|
303
|
+
const d = client("daemon");
|
|
304
|
+
expect((await d("ack_daemon_provider_snapshot", { request_id: r.request_id, snapshot: { ...snapshot(), daemon_node_id: "other" } })).error).toBe("invalid_provider_snapshot");
|
|
305
|
+
db.run("UPDATE daemon_provider_snapshots SET daemon_node_id='other' WHERE request_id=?1", [r.request_id]);
|
|
306
|
+
expect((await d("ack_daemon_provider_snapshot", { request_id: r.request_id, snapshot: snapshot() })).error).toBe("request_not_found");
|
|
307
|
+
});
|
|
308
|
+
test("timeout is explicit; late daemon reply cannot overwrite it", async () => {
|
|
309
|
+
const r = await request();
|
|
310
|
+
db.run("UPDATE daemon_provider_snapshots SET expires_at=?1 WHERE request_id=?2", [Date.now() - 1, r.request_id]);
|
|
311
|
+
expect((await client("daemon")("ack_daemon_provider_snapshot", { request_id: r.request_id, snapshot: snapshot() })).status).toBe("expired");
|
|
312
|
+
expect((await client()("daemon_provider_snapshot", { action: "read", id: r.request_id, network_id: net })).snapshot).toBeNull();
|
|
313
|
+
});
|
|
314
|
+
test("secret fields / misleading verification are rejected, never saved in Hub DB", async () => {
|
|
315
|
+
const r = await request(); const secret = "TEST-ONLY-key-never-in-hub";
|
|
316
|
+
const auth = { id: "work", kind: "api_key", models: ["model-a"], baseUrl: "https://api.example.test/v1", credential_present: true, verification: "not_checked", application: "not_applied" };
|
|
317
|
+
const nested = (change: object) => ({ ...snapshot(), providers: [{ id: "deepseek", label: "DeepSeek", enabled: true, runtimes: [{ runtime: "codex-tui", auth: [{ ...auth, ...change }] }] }] });
|
|
318
|
+
for (const bad of [{ ...snapshot(), key: secret }, { ...snapshot(), source: "hub" }, { ...snapshot(), revision: -1 },
|
|
319
|
+
nested({ key: secret }), nested({ verification: "verified" }), nested({ application: "applied" }), nested({ baseUrl: `https://user:${secret}@api.example.test/v1` })]) {
|
|
320
|
+
const reply = await client("daemon")("ack_daemon_provider_snapshot", { request_id: r.request_id, snapshot: bad });
|
|
321
|
+
expect(reply.error).toBe("invalid_provider_snapshot"); expect(JSON.stringify(reply)).not.toContain(secret);
|
|
322
|
+
}
|
|
323
|
+
expect(db.get<{ snapshot_json: string | null }>("SELECT snapshot_json FROM daemon_provider_snapshots WHERE request_id=?1", r.request_id)?.snapshot_json).toBeNull();
|
|
324
|
+
});
|
|
325
|
+
test("mirrored contract cannot drift", () => {
|
|
326
|
+
expect(readFileSync(new URL("./shared/daemon-provider-snapshot.ts", import.meta.url), "utf8"))
|
|
327
|
+
.toBe(readFileSync(new URL("../../agent-node/src/shared/daemon-provider-snapshot.ts", import.meta.url), "utf8"));
|
|
328
|
+
});
|
|
329
|
+
test("daemon reconnection executes actual pinned CLI against private store and acks redacted data", async () => {
|
|
330
|
+
const dir = join(root, ".anet/nodes", alias); mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
331
|
+
writeFileSync(join(dir, "config.json"), JSON.stringify({ role: "host_supervisor", network_id: net, node_id: node }), { mode: 0o600 });
|
|
332
|
+
const key = "TEST-ONLY-private-906";
|
|
333
|
+
new DaemonProviderStore(dir).upsert({ id: "deepseek", label: "DeepSeek", enabled: true, runtimes: [{ runtime: "codex-tui", auth: [{ id: "work", kind: "api_key", models: ["model-a"], baseUrl: "https://api.example.test/v1", key }] }] }, 0);
|
|
334
|
+
const deps = { workDir: root, alias, networkId: net, daemonId: node, callCommHub: client("daemon"), warn: () => { throw Error("unexpected_warning"); } };
|
|
335
|
+
// Atomic local read must pass before the Hub/daemon round trip.
|
|
336
|
+
expect((await readLocalProviderSnapshot(deps) as any).revision).toBe(1);
|
|
337
|
+
const r = await request();
|
|
338
|
+
await Promise.all([reconcileProviderSnapshots(deps), reconcileProviderSnapshots(deps)]);
|
|
339
|
+
const read = await client()("daemon_provider_snapshot", { action: "read", id: r.request_id, network_id: net });
|
|
340
|
+
expect(read.status).toBe("succeeded"); expect(read.snapshot.providers[0].runtimes[0].auth[0].credential_present).toBe(true);
|
|
341
|
+
expect(JSON.stringify(read)).not.toContain(key); expect(read.snapshot.providers[0].runtimes[0].auth[0].verification).toBe("not_checked");
|
|
342
|
+
});
|
|
343
|
+
test("daemon raw errors are replaced with fixed failure receipt", async () => {
|
|
344
|
+
const r = await request();
|
|
345
|
+
await reconcileProviderSnapshots({ workDir: root, alias, networkId: net, daemonId: node, callCommHub: client("daemon"), warn: () => {},
|
|
346
|
+
readSnapshot: async () => { throw Error("TEST-ONLY-sensitive-error"); } });
|
|
347
|
+
const read = await client()("daemon_provider_snapshot", { action: "read", id: r.request_id, network_id: net });
|
|
348
|
+
expect(read.status).toBe("failed"); expect(read.error).toBe("provider_snapshot_unavailable"); expect(JSON.stringify(read)).not.toContain("TEST-ONLY-sensitive-error");
|
|
349
|
+
});
|
|
350
|
+
test("lost acknowledgement retries on reconnect, without swallowing a new doorbell", async () => {
|
|
351
|
+
const r = await request(); const real = client("daemon"); let loseAck = true, reads = 0;
|
|
352
|
+
const deps = { workDir: root, alias, networkId: net, daemonId: node, warn: () => {},
|
|
353
|
+
readSnapshot: async () => { reads++; return snapshot(); },
|
|
354
|
+
callCommHub: async (name: string, args: any) => {
|
|
355
|
+
if (name === "ack_daemon_provider_snapshot" && loseAck) { loseAck = false; throw Error("TEST-ONLY-transport-down"); }
|
|
356
|
+
return real(name, args);
|
|
357
|
+
} };
|
|
358
|
+
await reconcileProviderSnapshots(deps);
|
|
359
|
+
expect((await client()("daemon_provider_snapshot", { action: "read", id: r.request_id, network_id: net })).status).toBe("pending");
|
|
360
|
+
await reconcileProviderSnapshots(deps);
|
|
361
|
+
expect((await client()("daemon_provider_snapshot", { action: "read", id: r.request_id, network_id: net })).status).toBe("succeeded");
|
|
362
|
+
expect(reads).toBe(2);
|
|
363
|
+
});
|
|
364
|
+
test("real HTTP authentication -> daemon handler -> pinned CLI -> scoped public receipt", async () => {
|
|
365
|
+
process.env.COMMHUB_UPLOADS_DIR = join(root, "uploads");
|
|
366
|
+
const owner = register("provider906_http", "TestOnly-Provider906-Password!1");
|
|
367
|
+
expect(owner.ok).toBe(true);
|
|
368
|
+
const netId = owner.network_id!, nodeId = "node_dps_http", daemonAlias = "daemon-http";
|
|
369
|
+
const issued = createNetworkTokenForNode(owner.user!.user_id, netId, daemonAlias, nodeId);
|
|
370
|
+
expect(issued.ok).toBe(true);
|
|
371
|
+
db.run("UPDATE nodes SET config_snapshot=?1 WHERE node_id=?2", [JSON.stringify({ role: "host_supervisor" }), nodeId]);
|
|
372
|
+
const dir = join(root, ".anet/nodes", daemonAlias); mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
373
|
+
writeFileSync(join(dir, "config.json"), JSON.stringify({ role: "host_supervisor", network_id: netId, node_id: nodeId }), { mode: 0o600 });
|
|
374
|
+
for (const child of ['bound', 'neighbour', 'manual']) {
|
|
375
|
+
const childId = `n_scan_${child}`;
|
|
376
|
+
db.run('INSERT INTO nodes (node_id,node_name,alias,network_id,runtime) VALUES (?1,?2,?2,?3,\'codex-app-server\')', [childId, child, netId]);
|
|
377
|
+
if (child !== 'manual') db.run('INSERT INTO node_daemon_bindings (request_id,network_id,node_id,daemon_node_id,workdir,requested_by,status,created_at,updated_at) VALUES (?1,?2,?3,?4,?5,?6,\'active\',1,1)',
|
|
378
|
+
[`binding_scan_${child}`, netId, childId, child === 'bound' ? nodeId : 'another-daemon', root, owner.user!.user_id]);
|
|
379
|
+
const home = join(root, '.anet/nodes', child, 'codex-home'); mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
380
|
+
writeFileSync(join(home, '../config.json'), JSON.stringify({ node_id: childId, alias: child, runtime: 'codex-app-server', network_id: netId }), { mode: 0o600 });
|
|
381
|
+
writeFileSync(join(home, 'config.toml'), 'model_provider = "deepseek"\nmodel = "fixture-model"\n[model_providers.deepseek]\nenv_key = "TEST_API_KEY"\n', { mode: 0o600 });
|
|
382
|
+
}
|
|
383
|
+
chmodSync(join(root, '.anet'), 0o700);
|
|
384
|
+
chmodSync(join(root, '.anet/nodes'), 0o775);
|
|
385
|
+
const hub = (await import("./server")).bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
386
|
+
const url = `http://127.0.0.1:${hub.port}/mcp`;
|
|
387
|
+
const rpc = (credential: string) => async (name: string, args: any) => {
|
|
388
|
+
const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${credential}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
|
|
389
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) });
|
|
390
|
+
expect(response.status).toBe(200);
|
|
391
|
+
const raw = await response.text(); const line = raw.split("\n").filter(x => x.startsWith("data:")).at(-1);
|
|
392
|
+
const message = JSON.parse(line ? line.slice(5) : raw);
|
|
393
|
+
expect(message.error).toBeUndefined();
|
|
394
|
+
return JSON.parse(message.result.content[0].text);
|
|
395
|
+
};
|
|
396
|
+
try {
|
|
397
|
+
const human = rpc(owner.token!), daemon = rpc(issued.token!);
|
|
398
|
+
const refresh = await human("daemon_provider_snapshot", { action: "refresh", id: nodeId, network_id: netId });
|
|
399
|
+
expect(refresh.status).toBe("pending");
|
|
400
|
+
expect((await human("pull_daemon_provider_snapshots", {})).ok).toBe(false);
|
|
401
|
+
expect((await daemon("daemon_provider_snapshot", { action: "refresh", id: nodeId, network_id: netId })).ok).toBe(false);
|
|
402
|
+
await reconcileProviderSnapshots({ workDir: root, alias: daemonAlias, networkId: netId, daemonId: nodeId, callCommHub: daemon,
|
|
403
|
+
warn: () => { throw Error("unexpected_HTTP_readback_failure"); } });
|
|
404
|
+
const read = await human("daemon_provider_snapshot", { action: "read", id: refresh.request_id, network_id: netId });
|
|
405
|
+
expect(read.status).toBe("succeeded"); expect(read.snapshot).toMatchObject({ network_id: netId, daemon_node_id: nodeId, revision: 0, source: "daemon", providers: [], codex_inventory: { scope: 'hub_bound_nodes' } });
|
|
406
|
+
expect(read.snapshot.codex_inventory.rows).toHaveLength(1);
|
|
407
|
+
expect(read.snapshot.codex_inventory.rows[0]).toMatchObject({ node_id: 'n_scan_bound', configured_provider: 'deepseek', configured_model: 'fixture-model', credential_status: 'unknown' });
|
|
408
|
+
expect(read.snapshot.codex_inventory.local_discovery).toBeUndefined(); // old client projection
|
|
409
|
+
const discovered = await human('daemon_provider_snapshot', { action: 'read', id: refresh.request_id, network_id: netId, include_discovery: true });
|
|
410
|
+
expect(discovered.snapshot.codex_inventory.local_discovery).toMatchObject({ status: 'observed', rows: [
|
|
411
|
+
{ node_id: 'n_scan_manual', alias: 'manual', configured_model: 'fixture-model', config_revision: null },
|
|
412
|
+
] });
|
|
413
|
+
expect(discovered.snapshot.codex_inventory.local_discovery.rows).toHaveLength(1); // other daemon filtered
|
|
414
|
+
const deniedApply = await human('daemon_provider_snapshot', { action: 'apply', id: nodeId, network_id: netId, revision: 0,
|
|
415
|
+
provider: { node_id: 'n_scan_manual', node_revision: 'a'.repeat(64), provider_id: 'deepseek', auth_id: 'key', model: 'fixture-model', confirm_restart: true } });
|
|
416
|
+
expect(deniedApply).toMatchObject({ ok: false, error: 'application_target_not_bound' });
|
|
417
|
+
const foreign = await human('daemon_provider_snapshot', { action: 'refresh', id: nodeId, network_id: netId });
|
|
418
|
+
const bad = structuredClone(read.snapshot); bad.codex_inventory.rows[0].node_id = 'n_scan_neighbour';
|
|
419
|
+
expect((await daemon('ack_daemon_provider_snapshot', { request_id: foreign.request_id, snapshot: bad })).ok).toBe(false);
|
|
420
|
+
} finally { hub.stop(true); }
|
|
421
|
+
});
|