@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,268 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import "./require-explicit-test-db.js";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import { db } from "./db.js";
|
|
8
|
+
import { registerTools } from "./tools.js";
|
|
9
|
+
import {
|
|
10
|
+
ackOpenCodeCatalog,
|
|
11
|
+
expireOpenCodeCatalogForTest,
|
|
12
|
+
getOpenCodeCatalogResult,
|
|
13
|
+
queueOpenCodeCatalogRead,
|
|
14
|
+
resetOpenCodeCatalogForTest,
|
|
15
|
+
takeOpenCodeCatalogRequest,
|
|
16
|
+
} from "./opencode-v2-catalog.js";
|
|
17
|
+
import { readOwnedOpenCodeV2Catalog } from "../../agent-node/src/runtime/opencode-v2-catalog-daemon.ts";
|
|
18
|
+
import { readOpenCodeV2CatalogEndpoint, writeOpenCodeV2CatalogEndpoint } from "../../agent-node/src/runtime/opencode-v2-catalog-endpoint.ts";
|
|
19
|
+
|
|
20
|
+
const NET = "net_oc_catalog";
|
|
21
|
+
const OTHER = "net_oc_catalog_other";
|
|
22
|
+
const USER = "u_oc_catalog";
|
|
23
|
+
const VIEWER = "u_oc_catalog_viewer";
|
|
24
|
+
const OUTSIDER = "u_oc_catalog_out";
|
|
25
|
+
const DAEMON = "node_oc_daemon";
|
|
26
|
+
const DAEMON_ALIAS = "oc-daemon";
|
|
27
|
+
const DAEMON_TOK = "tok_oc_daemon";
|
|
28
|
+
const V2_REQ = "cr_ocv2child";
|
|
29
|
+
const V2 = "node_ocv2child";
|
|
30
|
+
const V1 = "node_ocv1child";
|
|
31
|
+
const UNSAFE = "node_ocunsafechild";
|
|
32
|
+
const NULLDIR = "node_ocnullchild";
|
|
33
|
+
const PROJECT = resolve(mkdtempSync(join(tmpdir(), "oc-hub-proj-")));
|
|
34
|
+
|
|
35
|
+
function cleanup() {
|
|
36
|
+
resetOpenCodeCatalogForTest();
|
|
37
|
+
for (const net of [NET, OTHER]) {
|
|
38
|
+
for (const table of ["node_create_requests", "audit_log", "nodes", "sessions", "api_tokens", "network_members"]) {
|
|
39
|
+
try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [net]); } catch {}
|
|
40
|
+
}
|
|
41
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [net]); } catch {}
|
|
42
|
+
}
|
|
43
|
+
for (const user of [USER, VIEWER, OUTSIDER]) {
|
|
44
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [user]); } catch {}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
beforeEach(cleanup);
|
|
48
|
+
afterAll(() => { cleanup(); rmSync(PROJECT, { recursive: true, force: true }); });
|
|
49
|
+
|
|
50
|
+
function userRow(id: string) {
|
|
51
|
+
db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'user', datetime('now'))`, [id]);
|
|
52
|
+
}
|
|
53
|
+
function member(id: string, net: string, role: string) {
|
|
54
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, ?3, datetime('now'))`, [id, net, role]);
|
|
55
|
+
}
|
|
56
|
+
function node(id: string, alias: string, snapshot: unknown) {
|
|
57
|
+
db.run(
|
|
58
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, hostname, created_at, updated_at, lifecycle_state)
|
|
59
|
+
VALUES (?1, ?2, ?2, ?3, ?4, 'host-x', datetime('now'), datetime('now'), 'active')`,
|
|
60
|
+
[id, alias, NET, JSON.stringify(snapshot)],
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
function created(requestId: string, nodeId: string, flags: unknown, workdir: string | null) {
|
|
64
|
+
node(nodeId, nodeId, {});
|
|
65
|
+
db.run(
|
|
66
|
+
`INSERT INTO node_create_requests
|
|
67
|
+
(request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, created_at, created_by_token, workdir)
|
|
68
|
+
VALUES (?1, ?2, ?3, ?4, 'opencode-cli', 'fixture/fixture-model', ?5, '[]', 'succeeded', ?6, 'tok', ?7)`,
|
|
69
|
+
[requestId, DAEMON, nodeId, NET, JSON.stringify(flags), Date.now(), workdir],
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function seed() {
|
|
74
|
+
userRow(USER); userRow(VIEWER); userRow(OUTSIDER);
|
|
75
|
+
db.run(`INSERT OR REPLACE INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET, USER]);
|
|
76
|
+
db.run(`INSERT OR REPLACE INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [OTHER, OUTSIDER]);
|
|
77
|
+
member(USER, NET, "admin");
|
|
78
|
+
member(VIEWER, NET, "viewer");
|
|
79
|
+
member(OUTSIDER, OTHER, "admin");
|
|
80
|
+
node(DAEMON, DAEMON_ALIAS, { role: "host_supervisor" });
|
|
81
|
+
db.run(
|
|
82
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
|
|
83
|
+
VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
|
|
84
|
+
[DAEMON_TOK, USER, NET, `node:${DAEMON_ALIAS}`, `hash_${DAEMON_TOK}`],
|
|
85
|
+
);
|
|
86
|
+
created(V2_REQ, V2, { opencodeGeneration: "v2", opencodeUnsafeTools: true }, PROJECT);
|
|
87
|
+
created("cr_ocv1child", V1, { opencodeGeneration: "v1" }, PROJECT);
|
|
88
|
+
created("cr_ocunsafechild", UNSAFE, { opencodeGeneration: "v2", opencodeUnsafeTools: false }, PROJECT);
|
|
89
|
+
created("cr_ocnullchild", NULLDIR, { opencodeGeneration: "v2", opencodeUnsafeTools: true }, null);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function queue(userId: string, nodeId: string, networkId: string | null = NET, callerIsNode = false) {
|
|
93
|
+
return queueOpenCodeCatalogRead({ callerUserId: userId, callerIsNode, networkId, daemonNodeId: DAEMON, nodeId });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function handlers(userId: string, isNode: boolean, tokenId: string | null) {
|
|
97
|
+
const server = new McpServer({ name: "oc-catalog-test", version: "0" }) as any;
|
|
98
|
+
const out: Record<string, (args: any) => Promise<{ content: Array<{ text: string }> }>> = {};
|
|
99
|
+
const origTool = server.tool.bind(server);
|
|
100
|
+
server.tool = (name: string, d: string, schema: any, handler: any) => { out[name] = handler; return origTool(name, d, schema, handler); };
|
|
101
|
+
const origRegister = server.registerTool.bind(server);
|
|
102
|
+
server.registerTool = (name: string, cfg: any, handler: any) => { out[name] = handler; return origRegister(name, cfg, handler); };
|
|
103
|
+
registerTools(server, undefined, NET, userId, isNode ? DAEMON_ALIAS : null, isNode, tokenId);
|
|
104
|
+
return { out, server };
|
|
105
|
+
}
|
|
106
|
+
async function call(handler: (args: any) => Promise<{ content: Array<{ text: string }> }>, args: any) {
|
|
107
|
+
return JSON.parse((await handler(args)).content[0]!.text);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function snapshot(directory = PROJECT, overrides: Record<string, unknown> = {}) {
|
|
111
|
+
return {
|
|
112
|
+
scope: { networkId: NET, daemonId: DAEMON, directory },
|
|
113
|
+
source: "opencode-v2-api",
|
|
114
|
+
state: "snapshot",
|
|
115
|
+
invocationVerified: false,
|
|
116
|
+
completeness: "unknown",
|
|
117
|
+
observedAt: "2026-10-11T00:00:00.000Z",
|
|
118
|
+
providers: [{ id: "custom", activation: "enabled" }],
|
|
119
|
+
models: [{ providerId: "custom", id: "nested/model" }],
|
|
120
|
+
defaultModel: { providerId: "custom", id: "nested/model" },
|
|
121
|
+
...overrides,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe("hub authorizes an owned OpenCode V2 catalog read", () => {
|
|
126
|
+
test("member queues, daemon reads the owned loopback, ready snapshot stays unverified", async () => {
|
|
127
|
+
seed();
|
|
128
|
+
const user = handlers(USER, false, null);
|
|
129
|
+
const queued = await call(user.out.read_opencode_catalog!, { daemon_node_id: DAEMON, node_id: V2 });
|
|
130
|
+
expect(queued).toEqual({ ok: true, status: "pending", request_id: queued.request_id });
|
|
131
|
+
expect(JSON.stringify(queued)).not.toContain("daemon_alias");
|
|
132
|
+
const strict = await (user.server as any)._registeredTools.read_opencode_catalog.inputSchema.safeParseAsync({
|
|
133
|
+
daemon_node_id: DAEMON, node_id: V2, settings: { apiKey: "sk-FAKE" }, headers: {}, body: {},
|
|
134
|
+
});
|
|
135
|
+
expect(strict.success).toBe(false);
|
|
136
|
+
|
|
137
|
+
expect(takeOpenCodeCatalogRequest(queued.request_id, "node_other")).toEqual({ ok: false, error: "catalog_request_unavailable" });
|
|
138
|
+
const daemon = handlers(USER, true, DAEMON_TOK);
|
|
139
|
+
const pulled = await call(daemon.out.get_opencode_catalog_request!, { request_id: queued.request_id });
|
|
140
|
+
expect(pulled.ok).toBe(true);
|
|
141
|
+
expect(pulled.directory).toBe(PROJECT);
|
|
142
|
+
for (const key of ["url", "authorization", "settings", "headers", "body"]) expect(pulled[key]).toBeUndefined();
|
|
143
|
+
|
|
144
|
+
let requests = 0;
|
|
145
|
+
const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch(req) {
|
|
146
|
+
requests++;
|
|
147
|
+
const url = new URL(req.url);
|
|
148
|
+
const row = url.pathname === "/api/provider"
|
|
149
|
+
? [{ id: "custom", activation: "enabled", settings: { apiKey: "sk-FAKE" } }]
|
|
150
|
+
: [{ providerID: "custom", id: "nested/model", headers: { Authorization: "sk-FAKE" } }];
|
|
151
|
+
return Response.json({ location: { directory: PROJECT }, data: url.pathname === "/api/model/default" ? row[0] : row });
|
|
152
|
+
} });
|
|
153
|
+
const nodeDir = mkdtempSync(join(tmpdir(), "oc-hub-node-"));
|
|
154
|
+
try {
|
|
155
|
+
writeOpenCodeV2CatalogEndpoint(nodeDir, { url: `http://127.0.0.1:${server.port}`, authorization: "Basic fake", directory: PROJECT });
|
|
156
|
+
const read = await readOwnedOpenCodeV2Catalog({
|
|
157
|
+
networkId: NET, daemonId: DAEMON, authorizedDirectory: pulled.directory,
|
|
158
|
+
ownedProjectDirectory: PROJECT, endpoint: readOpenCodeV2CatalogEndpoint(nodeDir),
|
|
159
|
+
});
|
|
160
|
+
expect(read.ok).toBe(true);
|
|
161
|
+
if (!read.ok) return;
|
|
162
|
+
const acked = await call(daemon.out.ack_opencode_catalog!, { request_id: queued.request_id, snapshot: read.snapshot });
|
|
163
|
+
expect(acked).toEqual({ ok: true, status: "ready" });
|
|
164
|
+
const got = await call(user.out.get_opencode_catalog!, { request_id: queued.request_id });
|
|
165
|
+
expect(got.status).toBe("ready");
|
|
166
|
+
expect(got.snapshot.invocationVerified).toBe(false);
|
|
167
|
+
expect(got.snapshot.completeness).toBe("unknown");
|
|
168
|
+
expect(JSON.stringify(got.snapshot)).not.toContain("sk-FAKE");
|
|
169
|
+
expect(requests).toBe(3);
|
|
170
|
+
} finally {
|
|
171
|
+
server.stop(true);
|
|
172
|
+
rmSync(nodeDir, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("viewer may queue; outsider, node token, and non-v2 children may not", () => {
|
|
177
|
+
seed();
|
|
178
|
+
expect(queue(VIEWER, V2).ok).toBe(true);
|
|
179
|
+
expect(queue(OUTSIDER, V2, NET)).toEqual({ ok: false, error: "access_denied" });
|
|
180
|
+
expect(queue(OUTSIDER, V2, OTHER)).toEqual({ ok: false, error: "daemon_not_found" });
|
|
181
|
+
expect(queue(USER, V2, NET, true)).toEqual({ ok: false, error: "user_token_required" });
|
|
182
|
+
expect(queue(USER, V1)).toEqual({ ok: false, error: "not_opencode_v2" });
|
|
183
|
+
expect(queue(USER, UNSAFE)).toEqual({ ok: false, error: "not_opencode_v2" });
|
|
184
|
+
expect(queue(USER, "node_missing")).toEqual({ ok: false, error: "node_not_found" });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("pending is not a snapshot; expiry, bad acks, and a missing endpoint stay closed", async () => {
|
|
188
|
+
seed();
|
|
189
|
+
const queued = queue(USER, V2);
|
|
190
|
+
expect(queued.ok).toBe(true);
|
|
191
|
+
if (!queued.ok) return;
|
|
192
|
+
expect(getOpenCodeCatalogResult({ callerUserId: USER, callerIsNode: false, networkId: NET, requestId: queued.request_id })).toEqual({
|
|
193
|
+
ok: true, status: "pending", request_id: queued.request_id,
|
|
194
|
+
});
|
|
195
|
+
const pulled = takeOpenCodeCatalogRequest(queued.request_id, DAEMON);
|
|
196
|
+
expect(pulled.ok).toBe(true);
|
|
197
|
+
|
|
198
|
+
expect(ackOpenCodeCatalog(DAEMON, { request_id: queued.request_id, snapshot: snapshot(), settings: { apiKey: "sk-FAKE" } })).toEqual({ ok: false, error: "invalid_ack" });
|
|
199
|
+
expect(ackOpenCodeCatalog(DAEMON, { request_id: queued.request_id, snapshot: snapshot(PROJECT, { invocationVerified: true }) })).toEqual({ ok: false, error: "invalid_ack" });
|
|
200
|
+
expect(getOpenCodeCatalogResult({ callerUserId: USER, callerIsNode: false, networkId: NET, requestId: queued.request_id }).status).toBe("pending");
|
|
201
|
+
|
|
202
|
+
const wrongDir = ackOpenCodeCatalog(DAEMON, { request_id: queued.request_id, snapshot: snapshot("/tmp/other-project") });
|
|
203
|
+
expect(wrongDir).toEqual({ ok: true, status: "failed", error: "invalid_response" });
|
|
204
|
+
expect(getOpenCodeCatalogResult({ callerUserId: USER, callerIsNode: false, networkId: NET, requestId: queued.request_id })).toMatchObject({
|
|
205
|
+
ok: false, status: "failed", error: "invalid_response",
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const again = queue(USER, V2);
|
|
209
|
+
expect(again.ok).toBe(true);
|
|
210
|
+
if (!again.ok) return;
|
|
211
|
+
takeOpenCodeCatalogRequest(again.request_id, DAEMON);
|
|
212
|
+
expect(ackOpenCodeCatalog(DAEMON, { request_id: again.request_id, error: "unavailable" })).toEqual({ ok: true, status: "failed", error: "unavailable" });
|
|
213
|
+
expect(getOpenCodeCatalogResult({ callerUserId: USER, callerIsNode: false, networkId: NET, requestId: again.request_id })).toMatchObject({
|
|
214
|
+
ok: false, status: "failed", error: "unavailable",
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const waiting = queue(USER, NULLDIR);
|
|
218
|
+
expect(waiting.ok).toBe(true);
|
|
219
|
+
if (!waiting.ok) return;
|
|
220
|
+
expireOpenCodeCatalogForTest(waiting.request_id);
|
|
221
|
+
expect(getOpenCodeCatalogResult({ callerUserId: USER, callerIsNode: false, networkId: NET, requestId: waiting.request_id })).toMatchObject({
|
|
222
|
+
ok: false, status: "expired", error: "unavailable",
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("acks a Windows absolute directory without Hub path.resolve", () => {
|
|
227
|
+
seed();
|
|
228
|
+
db.run(`UPDATE node_create_requests SET workdir = ?1 WHERE request_id = ?2`, ["C:\\project", V2_REQ]);
|
|
229
|
+
const queued = queue(USER, V2);
|
|
230
|
+
expect(queued.ok).toBe(true);
|
|
231
|
+
if (!queued.ok) return;
|
|
232
|
+
takeOpenCodeCatalogRequest(queued.request_id, DAEMON);
|
|
233
|
+
expect(ackOpenCodeCatalog(DAEMON, { request_id: queued.request_id, snapshot: snapshot("project") })).toEqual({ ok: false, error: "invalid_ack" });
|
|
234
|
+
expect(ackOpenCodeCatalog(DAEMON, { request_id: queued.request_id, snapshot: snapshot("C:relative") })).toEqual({ ok: false, error: "invalid_ack" });
|
|
235
|
+
expect(getOpenCodeCatalogResult({ callerUserId: USER, callerIsNode: false, networkId: NET, requestId: queued.request_id }).status).toBe("pending");
|
|
236
|
+
expect(ackOpenCodeCatalog(DAEMON, { request_id: queued.request_id, snapshot: snapshot("C:/project") })).toEqual({ ok: true, status: "ready" });
|
|
237
|
+
const got = getOpenCodeCatalogResult({ callerUserId: USER, callerIsNode: false, networkId: NET, requestId: queued.request_id });
|
|
238
|
+
expect(got.ok).toBe(true);
|
|
239
|
+
if (!got.ok || got.status !== "ready") return;
|
|
240
|
+
expect(got.snapshot.scope.directory).toBe("C:/project");
|
|
241
|
+
|
|
242
|
+
const other = queue(USER, V2);
|
|
243
|
+
expect(other.ok).toBe(true);
|
|
244
|
+
if (!other.ok) return;
|
|
245
|
+
takeOpenCodeCatalogRequest(other.request_id, DAEMON);
|
|
246
|
+
expect(ackOpenCodeCatalog(DAEMON, { request_id: other.request_id, snapshot: snapshot("D:\\other") })).toEqual({
|
|
247
|
+
ok: true, status: "failed", error: "invalid_response",
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("a tilde workdir matches a native absolute report, and lexical .. collapses", () => {
|
|
252
|
+
seed();
|
|
253
|
+
db.run(`UPDATE node_create_requests SET workdir = ?1 WHERE request_id = ?2`, ["~/project", V2_REQ]);
|
|
254
|
+
const queued = queue(USER, V2);
|
|
255
|
+
expect(queued.ok).toBe(true);
|
|
256
|
+
if (!queued.ok) return;
|
|
257
|
+
takeOpenCodeCatalogRequest(queued.request_id, DAEMON);
|
|
258
|
+
expect(ackOpenCodeCatalog(DAEMON, { request_id: queued.request_id, snapshot: snapshot("/home/user/project") })).toEqual({ ok: true, status: "ready" });
|
|
259
|
+
|
|
260
|
+
db.run(`UPDATE node_create_requests SET workdir = ?1 WHERE request_id = ?2`, ["/tmp/foo/../bar", V2_REQ]);
|
|
261
|
+
const lexical = queue(USER, V2);
|
|
262
|
+
expect(lexical.ok).toBe(true);
|
|
263
|
+
if (!lexical.ok) return;
|
|
264
|
+
takeOpenCodeCatalogRequest(lexical.request_id, DAEMON);
|
|
265
|
+
expect(ackOpenCodeCatalog(DAEMON, { request_id: lexical.request_id, snapshot: snapshot("/tmp/foo/../bar") })).toEqual({ ok: false, error: "invalid_ack" });
|
|
266
|
+
expect(ackOpenCodeCatalog(DAEMON, { request_id: lexical.request_id, snapshot: snapshot("/tmp/bar") })).toEqual({ ok: true, status: "ready" });
|
|
267
|
+
});
|
|
268
|
+
});
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// Hub authorization for a native OpenCode V2 catalog read.
|
|
2
|
+
//
|
|
3
|
+
// The caller names a daemon and a child node. Identity, generation, and the
|
|
4
|
+
// project directory come from Hub rows. The queued payload has no URL,
|
|
5
|
+
// authorization, settings, headers, or body. The daemon reads its own
|
|
6
|
+
// already-running loopback and acks a whitelist snapshot.
|
|
7
|
+
//
|
|
8
|
+
// `status: "pending"` means the read was authorized and stored. It does not
|
|
9
|
+
// mean a daemon received the doorbell or produced a snapshot. Only
|
|
10
|
+
// `status: "ready"` is a snapshot, and that snapshot stays completeness
|
|
11
|
+
// unknown / invocation unverified.
|
|
12
|
+
|
|
13
|
+
import { posix, win32 } from "node:path";
|
|
14
|
+
import { z } from "zod/v4";
|
|
15
|
+
import { db, uuidv4 } from "./db.js";
|
|
16
|
+
import { getUserNetworkRole } from "./auth.js";
|
|
17
|
+
|
|
18
|
+
const TTL_MS = 60_000;
|
|
19
|
+
const MAX_PENDING = 100;
|
|
20
|
+
const SECRET = /^(sk-|gh[pousr]_|github_pat_|xox[baprs]-|AKIA|AIza|ya29\.|glpat-)/i;
|
|
21
|
+
|
|
22
|
+
export const CATALOG_ERROR_CODES = [
|
|
23
|
+
"invalid_target", "unauthorized", "unavailable", "invalid_response", "scope_mismatch", "too_large",
|
|
24
|
+
] as const;
|
|
25
|
+
export type CatalogErrorCode = (typeof CATALOG_ERROR_CODES)[number];
|
|
26
|
+
|
|
27
|
+
const identifier = z.string().min(1).max(512).refine(value => !/[\s\u0000-\u001f\u007f]/.test(value) && !SECRET.test(value));
|
|
28
|
+
const CONTROL = /[\u0000-\u001f\u007f]/;
|
|
29
|
+
const WINDOWS_DRIVE = /^[A-Za-z]:[\\/]/;
|
|
30
|
+
|
|
31
|
+
/** Absolute in the path's own format. Hub `path.resolve` is not used: on a
|
|
32
|
+
* POSIX Hub it treats `C:\project` as a relative path under the process cwd. */
|
|
33
|
+
function windowsStrictAbsolute(value: string): boolean {
|
|
34
|
+
if (!WINDOWS_DRIVE.test(value)) return false;
|
|
35
|
+
const parts = value.slice(2).split(/[\\/]/);
|
|
36
|
+
if (parts[0] !== "" || parts.length < 2) return false;
|
|
37
|
+
return parts.slice(1).every(part => part !== "" && part !== "." && part !== "..");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function strictNativeAbsoluteDirectory(value: string): boolean {
|
|
41
|
+
if (value.length < 1 || value.length > 1024 || CONTROL.test(value)) return false;
|
|
42
|
+
if (WINDOWS_DRIVE.test(value)) return windowsStrictAbsolute(value);
|
|
43
|
+
if (value.startsWith("/")) return posix.resolve(value) === value;
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function tildeWorkdir(value: string): boolean {
|
|
48
|
+
const trimmed = value.trim();
|
|
49
|
+
return trimmed === "~" || trimmed.startsWith("~/") || trimmed.startsWith("~\\");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Compare a stored create workdir with the directory the daemon reported.
|
|
53
|
+
* Windows keys ignore slash style and case. POSIX keys are lexical (`..` and
|
|
54
|
+
* a trailing slash collapse) and are not realpath'd — Hub does not see the
|
|
55
|
+
* daemon disk. A `~` / `~/…` workdir has no Hub-side home to expand; a
|
|
56
|
+
* native-absolute report is accepted because the daemon already refused the
|
|
57
|
+
* read unless that path is the endpoint it owns. A compromised daemon can
|
|
58
|
+
* name another absolute path for a tilde row, same as a null workdir. */
|
|
59
|
+
export function catalogDirectoriesMatch(stored: string, reported: string): boolean {
|
|
60
|
+
if (!strictNativeAbsoluteDirectory(reported)) return false;
|
|
61
|
+
const storedTrim = stored.trim();
|
|
62
|
+
if (tildeWorkdir(storedTrim)) return true;
|
|
63
|
+
const storedWindows = WINDOWS_DRIVE.test(storedTrim);
|
|
64
|
+
const reportedWindows = WINDOWS_DRIVE.test(reported);
|
|
65
|
+
if (storedWindows !== reportedWindows) return false;
|
|
66
|
+
if (storedWindows) {
|
|
67
|
+
const left = win32.resolve(storedTrim);
|
|
68
|
+
const right = win32.resolve(reported);
|
|
69
|
+
return /^[A-Za-z]:\\/.test(left) && left.toLowerCase() === right.toLowerCase();
|
|
70
|
+
}
|
|
71
|
+
if (storedTrim.startsWith("/") && reported.startsWith("/")) return posix.resolve(storedTrim) === posix.resolve(reported);
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const directoryText = z.string().min(1).max(1024).refine(strictNativeAbsoluteDirectory);
|
|
76
|
+
|
|
77
|
+
export const opencodeCatalogSnapshotSchema = z.object({
|
|
78
|
+
scope: z.object({
|
|
79
|
+
networkId: z.string().min(1).max(200),
|
|
80
|
+
daemonId: z.string().min(1).max(200),
|
|
81
|
+
directory: directoryText,
|
|
82
|
+
}).strict(),
|
|
83
|
+
source: z.literal("opencode-v2-api"),
|
|
84
|
+
state: z.literal("snapshot"),
|
|
85
|
+
invocationVerified: z.literal(false),
|
|
86
|
+
completeness: z.literal("unknown"),
|
|
87
|
+
observedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/),
|
|
88
|
+
providers: z.array(z.object({
|
|
89
|
+
id: identifier,
|
|
90
|
+
activation: z.enum(["auto", "enabled", "disabled"]),
|
|
91
|
+
}).strict()).max(20_000),
|
|
92
|
+
models: z.array(z.object({
|
|
93
|
+
providerId: identifier,
|
|
94
|
+
id: identifier,
|
|
95
|
+
}).strict()).max(20_000),
|
|
96
|
+
defaultModel: z.object({ providerId: identifier, id: identifier }).strict().nullable(),
|
|
97
|
+
}).strict();
|
|
98
|
+
|
|
99
|
+
export type OpenCodeCatalogSnapshotWire = z.infer<typeof opencodeCatalogSnapshotSchema>;
|
|
100
|
+
|
|
101
|
+
export const opencodeCatalogAckSchema = z.object({
|
|
102
|
+
request_id: z.string().min(1).max(200),
|
|
103
|
+
error: z.enum(CATALOG_ERROR_CODES).optional(),
|
|
104
|
+
snapshot: opencodeCatalogSnapshotSchema.optional(),
|
|
105
|
+
}).strict();
|
|
106
|
+
|
|
107
|
+
interface PendingCatalogRead {
|
|
108
|
+
requestId: string;
|
|
109
|
+
daemonNodeId: string;
|
|
110
|
+
daemonAlias: string;
|
|
111
|
+
networkId: string;
|
|
112
|
+
nodeId: string;
|
|
113
|
+
alias: string;
|
|
114
|
+
directory: string | null;
|
|
115
|
+
expiresAt: number;
|
|
116
|
+
taken: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface StoredCatalogRead {
|
|
120
|
+
requestId: string;
|
|
121
|
+
networkId: string;
|
|
122
|
+
daemonNodeId: string;
|
|
123
|
+
nodeId: string;
|
|
124
|
+
status: "pending" | "ready" | "failed" | "expired";
|
|
125
|
+
error?: CatalogErrorCode;
|
|
126
|
+
snapshot?: OpenCodeCatalogSnapshotWire;
|
|
127
|
+
expiresAt: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const pending = new Map<string, PendingCatalogRead>();
|
|
131
|
+
const stored = new Map<string, StoredCatalogRead>();
|
|
132
|
+
|
|
133
|
+
export function resetOpenCodeCatalogForTest(): void {
|
|
134
|
+
pending.clear();
|
|
135
|
+
stored.clear();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function expireOpenCodeCatalogForTest(requestId: string): void {
|
|
139
|
+
// Just past the read deadline, still inside the retention window. Setting
|
|
140
|
+
// the deadline to 0 would also drop the stored row and look like not_found.
|
|
141
|
+
const expiredAt = Date.now() - 1;
|
|
142
|
+
const row = pending.get(requestId);
|
|
143
|
+
if (row) row.expiresAt = expiredAt;
|
|
144
|
+
const result = stored.get(requestId);
|
|
145
|
+
if (result) result.expiresAt = expiredAt;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function childNodeId(requestId: string): string | null {
|
|
149
|
+
return requestId.startsWith("cr_") ? `node_${requestId.slice(3)}` : null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function supervisorRole(snapshot: string | null): boolean {
|
|
153
|
+
if (!snapshot) return false;
|
|
154
|
+
try {
|
|
155
|
+
const parsed = JSON.parse(snapshot) as { role?: unknown };
|
|
156
|
+
return parsed.role === "host_supervisor";
|
|
157
|
+
} catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function v2Flags(flagsJson: string): boolean {
|
|
163
|
+
try {
|
|
164
|
+
const flags = JSON.parse(flagsJson) as { opencodeGeneration?: unknown; opencodeUnsafeTools?: unknown };
|
|
165
|
+
return flags.opencodeGeneration === "v2" && flags.opencodeUnsafeTools === true;
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface QueueCatalogInput {
|
|
172
|
+
callerUserId: string | null;
|
|
173
|
+
callerIsNode: boolean;
|
|
174
|
+
networkId: string | null;
|
|
175
|
+
daemonNodeId: string;
|
|
176
|
+
nodeId: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function evictExpired(now = Date.now()): void {
|
|
180
|
+
for (const [id, row] of pending) {
|
|
181
|
+
if (now > row.expiresAt) pending.delete(id);
|
|
182
|
+
}
|
|
183
|
+
for (const [id, row] of stored) {
|
|
184
|
+
if (row.status === "pending" && now > row.expiresAt) row.status = "expired";
|
|
185
|
+
if (now > row.expiresAt + TTL_MS) stored.delete(id);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function queueOpenCodeCatalogRead(input: QueueCatalogInput):
|
|
190
|
+
| { ok: true; request_id: string; status: "pending"; daemon_alias: string; network_id: string }
|
|
191
|
+
| { ok: false; error: string } {
|
|
192
|
+
evictExpired();
|
|
193
|
+
if (input.callerIsNode) return { ok: false, error: "user_token_required" };
|
|
194
|
+
if (!input.callerUserId) return { ok: false, error: "auth_required" };
|
|
195
|
+
if (!input.networkId) return { ok: false, error: "network_id_required" };
|
|
196
|
+
const role = getUserNetworkRole(input.callerUserId, input.networkId);
|
|
197
|
+
if (!role) return { ok: false, error: "access_denied" };
|
|
198
|
+
if (pending.size >= MAX_PENDING) return { ok: false, error: "catalog_busy" };
|
|
199
|
+
|
|
200
|
+
const daemon = db.get<{ node_id: string; alias: string; config_snapshot: string | null }>(
|
|
201
|
+
`SELECT node_id, alias, config_snapshot FROM nodes WHERE node_id = ?1 AND network_id = ?2`,
|
|
202
|
+
input.daemonNodeId, input.networkId,
|
|
203
|
+
);
|
|
204
|
+
if (!daemon) return { ok: false, error: "daemon_not_found" };
|
|
205
|
+
if (!supervisorRole(daemon.config_snapshot)) return { ok: false, error: "daemon_not_supervisor" };
|
|
206
|
+
|
|
207
|
+
const child = db.get<{ node_id: string }>(
|
|
208
|
+
`SELECT node_id FROM nodes WHERE node_id = ?1 AND network_id = ?2`,
|
|
209
|
+
input.nodeId, input.networkId,
|
|
210
|
+
);
|
|
211
|
+
if (!child) return { ok: false, error: "node_not_found" };
|
|
212
|
+
|
|
213
|
+
const creates = db.all<{
|
|
214
|
+
request_id: string; daemon_node_id: string; child_name: string; runtime: string;
|
|
215
|
+
flags_json: string; workdir: string | null; status: string;
|
|
216
|
+
}>(
|
|
217
|
+
`SELECT request_id, daemon_node_id, child_name, runtime, flags_json, workdir, status
|
|
218
|
+
FROM node_create_requests
|
|
219
|
+
WHERE network_id = ?1 AND daemon_node_id = ?2 AND status IN ('succeeded', 'delivered')`,
|
|
220
|
+
input.networkId, input.daemonNodeId,
|
|
221
|
+
);
|
|
222
|
+
const owned = creates.filter(row => childNodeId(row.request_id) === input.nodeId && row.daemon_node_id === input.daemonNodeId);
|
|
223
|
+
if (owned.length !== 1) return { ok: false, error: "node_not_found" };
|
|
224
|
+
const row = owned[0]!;
|
|
225
|
+
if (row.runtime !== "opencode-cli" || !v2Flags(row.flags_json)) return { ok: false, error: "not_opencode_v2" };
|
|
226
|
+
|
|
227
|
+
const requestId = `cat_${uuidv4()}`;
|
|
228
|
+
const directory = typeof row.workdir === "string" && row.workdir.length > 0 ? row.workdir : null;
|
|
229
|
+
const record: PendingCatalogRead = {
|
|
230
|
+
requestId,
|
|
231
|
+
daemonNodeId: daemon.node_id,
|
|
232
|
+
daemonAlias: daemon.alias,
|
|
233
|
+
networkId: input.networkId,
|
|
234
|
+
nodeId: input.nodeId,
|
|
235
|
+
alias: row.child_name,
|
|
236
|
+
directory,
|
|
237
|
+
expiresAt: Date.now() + TTL_MS,
|
|
238
|
+
taken: false,
|
|
239
|
+
};
|
|
240
|
+
pending.set(requestId, record);
|
|
241
|
+
stored.set(requestId, {
|
|
242
|
+
requestId,
|
|
243
|
+
networkId: input.networkId,
|
|
244
|
+
daemonNodeId: daemon.node_id,
|
|
245
|
+
nodeId: input.nodeId,
|
|
246
|
+
status: "pending",
|
|
247
|
+
expiresAt: record.expiresAt,
|
|
248
|
+
});
|
|
249
|
+
return { ok: true, request_id: requestId, status: "pending", daemon_alias: daemon.alias, network_id: input.networkId };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function takeOpenCodeCatalogRequest(requestId: string, daemonNodeId: string):
|
|
253
|
+
| { ok: true; request_id: string; node_id: string; alias: string; directory: string | null; scope: { networkId: string; daemonId: string } }
|
|
254
|
+
| { ok: false; error: "catalog_request_unavailable" } {
|
|
255
|
+
const row = pending.get(requestId);
|
|
256
|
+
if (!row || row.daemonNodeId !== daemonNodeId || row.taken) return { ok: false, error: "catalog_request_unavailable" };
|
|
257
|
+
if (Date.now() > row.expiresAt) {
|
|
258
|
+
row.taken = true;
|
|
259
|
+
const result = stored.get(requestId);
|
|
260
|
+
if (result && result.status === "pending") result.status = "expired";
|
|
261
|
+
return { ok: false, error: "catalog_request_unavailable" };
|
|
262
|
+
}
|
|
263
|
+
row.taken = true;
|
|
264
|
+
return {
|
|
265
|
+
ok: true,
|
|
266
|
+
request_id: row.requestId,
|
|
267
|
+
node_id: row.nodeId,
|
|
268
|
+
alias: row.alias,
|
|
269
|
+
directory: row.directory,
|
|
270
|
+
scope: { networkId: row.networkId, daemonId: row.daemonNodeId },
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function consistent(snapshot: OpenCodeCatalogSnapshotWire): boolean {
|
|
275
|
+
const providers = new Set<string>();
|
|
276
|
+
for (const row of snapshot.providers) {
|
|
277
|
+
if (providers.has(row.id)) return false;
|
|
278
|
+
providers.add(row.id);
|
|
279
|
+
}
|
|
280
|
+
const models = new Set<string>();
|
|
281
|
+
for (const row of snapshot.models) {
|
|
282
|
+
if (!providers.has(row.providerId)) return false;
|
|
283
|
+
const key = JSON.stringify([row.providerId, row.id]);
|
|
284
|
+
if (models.has(key)) return false;
|
|
285
|
+
models.add(key);
|
|
286
|
+
}
|
|
287
|
+
if (snapshot.defaultModel && !models.has(JSON.stringify([snapshot.defaultModel.providerId, snapshot.defaultModel.id]))) return false;
|
|
288
|
+
return Buffer.byteLength(JSON.stringify(snapshot), "utf8") <= 2 * 1024 * 1024;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function ackOpenCodeCatalog(daemonNodeId: string, body: unknown):
|
|
292
|
+
| { ok: true; status: "ready" | "failed"; error?: CatalogErrorCode }
|
|
293
|
+
| { ok: false; error: "catalog_request_unavailable" | "invalid_ack" } {
|
|
294
|
+
const parsed = opencodeCatalogAckSchema.safeParse(body);
|
|
295
|
+
if (!parsed.success) return { ok: false, error: "invalid_ack" };
|
|
296
|
+
const ack = parsed.data;
|
|
297
|
+
const hasSnapshot = ack.snapshot !== undefined;
|
|
298
|
+
const hasError = ack.error !== undefined;
|
|
299
|
+
if (hasSnapshot === hasError) return { ok: false, error: "invalid_ack" };
|
|
300
|
+
const row = pending.get(ack.request_id);
|
|
301
|
+
const result = stored.get(ack.request_id);
|
|
302
|
+
if (!row || !result || row.daemonNodeId !== daemonNodeId || !row.taken) {
|
|
303
|
+
return { ok: false, error: "catalog_request_unavailable" };
|
|
304
|
+
}
|
|
305
|
+
if (Date.now() > row.expiresAt || result.status !== "pending") {
|
|
306
|
+
if (result.status === "pending") result.status = "expired";
|
|
307
|
+
return { ok: false, error: "catalog_request_unavailable" };
|
|
308
|
+
}
|
|
309
|
+
if (ack.error) {
|
|
310
|
+
result.status = "failed";
|
|
311
|
+
result.error = ack.error;
|
|
312
|
+
return { ok: true, status: "failed", error: ack.error };
|
|
313
|
+
}
|
|
314
|
+
const snapshot = ack.snapshot!;
|
|
315
|
+
// A stored workdir must match. A null workdir (legacy create) has no
|
|
316
|
+
// hub-side path to compare; the daemon binds that read to the project
|
|
317
|
+
// directory it already owns. Hub does not see that filesystem.
|
|
318
|
+
const directoryOk = row.directory ? catalogDirectoriesMatch(row.directory, snapshot.scope.directory) : true;
|
|
319
|
+
if (snapshot.scope.networkId !== row.networkId || snapshot.scope.daemonId !== row.daemonNodeId || !directoryOk || !consistent(snapshot)) {
|
|
320
|
+
result.status = "failed";
|
|
321
|
+
result.error = "invalid_response";
|
|
322
|
+
return { ok: true, status: "failed", error: "invalid_response" };
|
|
323
|
+
}
|
|
324
|
+
result.status = "ready";
|
|
325
|
+
result.snapshot = snapshot;
|
|
326
|
+
return { ok: true, status: "ready" };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function getOpenCodeCatalogResult(input: {
|
|
330
|
+
callerUserId: string | null;
|
|
331
|
+
callerIsNode: boolean;
|
|
332
|
+
networkId: string | null;
|
|
333
|
+
requestId: string;
|
|
334
|
+
}):
|
|
335
|
+
| { ok: true; status: "pending"; request_id: string }
|
|
336
|
+
| { ok: true; status: "ready"; request_id: string; snapshot: OpenCodeCatalogSnapshotWire }
|
|
337
|
+
| { ok: false; status?: "failed" | "expired"; error: string; request_id?: string } {
|
|
338
|
+
if (input.callerIsNode) return { ok: false, error: "user_token_required" };
|
|
339
|
+
if (!input.callerUserId) return { ok: false, error: "auth_required" };
|
|
340
|
+
if (!input.networkId) return { ok: false, error: "network_id_required" };
|
|
341
|
+
if (!getUserNetworkRole(input.callerUserId, input.networkId)) return { ok: false, error: "access_denied" };
|
|
342
|
+
evictExpired();
|
|
343
|
+
const result = stored.get(input.requestId);
|
|
344
|
+
if (!result || result.networkId !== input.networkId) return { ok: false, error: "not_found" };
|
|
345
|
+
if (result.status === "pending" && Date.now() > result.expiresAt) result.status = "expired";
|
|
346
|
+
if (result.status === "pending") return { ok: true, status: "pending", request_id: result.requestId };
|
|
347
|
+
if (result.status === "ready" && result.snapshot) {
|
|
348
|
+
return { ok: true, status: "ready", request_id: result.requestId, snapshot: result.snapshot };
|
|
349
|
+
}
|
|
350
|
+
if (result.status === "expired") return { ok: false, status: "expired", error: "unavailable", request_id: result.requestId };
|
|
351
|
+
return { ok: false, status: "failed", error: result.error || "unavailable", request_id: result.requestId };
|
|
352
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
type PackageManifest = { version: string };
|
|
6
|
+
type PackageLock = {
|
|
7
|
+
version: string;
|
|
8
|
+
packages: { "": { version: string } };
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const readJson = <T>(name: string): T =>
|
|
12
|
+
JSON.parse(readFileSync(join(import.meta.dir, "..", name), "utf8")) as T;
|
|
13
|
+
|
|
14
|
+
describe("server package version consistency", () => {
|
|
15
|
+
const manifest = readJson<PackageManifest>("package.json");
|
|
16
|
+
const lock = readJson<PackageLock>("package-lock.json");
|
|
17
|
+
|
|
18
|
+
test("package-lock top-level version matches package.json", () => {
|
|
19
|
+
expect(lock.version).toBe(manifest.version);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("package-lock root package version matches package.json", () => {
|
|
23
|
+
expect(lock.packages[""].version).toBe(manifest.version);
|
|
24
|
+
});
|
|
25
|
+
});
|