@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,14 @@
|
|
|
1
|
+
// Board #668. A dispatch preview is what the hub shows before the node
|
|
2
|
+
// reports. Once the session is already working, that text is the task in
|
|
3
|
+
// flight — a newer message waits in the inbox and must not replace it.
|
|
4
|
+
// Idle and every other status still take the preview.
|
|
5
|
+
|
|
6
|
+
const PLACEHOLDER = /^\?\d+$/;
|
|
7
|
+
|
|
8
|
+
/** SQL expression for `sessions.task` on dispatch. `placeholder` is a `?N` bind. */
|
|
9
|
+
export function sessionTaskOnDispatch(placeholder: string): string {
|
|
10
|
+
if (!PLACEHOLDER.test(placeholder)) {
|
|
11
|
+
throw new Error("sessionTaskOnDispatch placeholder must be a ?N bind");
|
|
12
|
+
}
|
|
13
|
+
return `CASE WHEN status = 'working' THEN task ELSE ${placeholder} END`;
|
|
14
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { beforeAll, afterAll, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
process.env.COMMHUB_DB ||= `${mkdtempSync("/tmp/session689-")}/hub.db`;
|
|
5
|
+
let db: any, hub: ReturnType<typeof Bun.serve>, base: string;
|
|
6
|
+
let owner: any, holder: any, member: any, admin: any, outsider: any;
|
|
7
|
+
let generateNetworkToken: () => string, hashToken: (token: string) => string;
|
|
8
|
+
async function mint(actor: any, alias: string, nodeId?: string, network = owner.network_id) {
|
|
9
|
+
const response = await fetch(`${base}/api/auth/node-token`, {
|
|
10
|
+
method: "POST", headers: { Authorization: `Bearer ${actor.token}`, "Content-Type": "application/json" },
|
|
11
|
+
body: JSON.stringify({ network_id: network, node_name: alias, ...(nodeId ? { node_id: nodeId } : {}) }),
|
|
12
|
+
});
|
|
13
|
+
return { status: response.status, body: await response.json() as any };
|
|
14
|
+
}
|
|
15
|
+
function session(alias: string, network = owner.network_id, nodeId: string | null = null) {
|
|
16
|
+
db.run("INSERT INTO sessions(resume_id,alias,network_id,node_id,status) VALUES(?1,?2,?3,?4,'offline')",
|
|
17
|
+
[crypto.randomUUID(), alias, network, nodeId]);
|
|
18
|
+
}
|
|
19
|
+
function credential(alias: string, actor = holder, options: { epoch?: number; revoked?: boolean; expired?: boolean; network?: string; bound?: string; scope?: string } = {}) {
|
|
20
|
+
const id = crypto.randomUUID();
|
|
21
|
+
db.run(`INSERT INTO api_tokens(token_id,token_hash,user_id,network_id,name,scope,node_identity_epoch,bound_node_id,revoked_at,expires_at)
|
|
22
|
+
VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)`,
|
|
23
|
+
[id, hashToken(generateNetworkToken()), actor.user.user_id, options.network ?? owner.network_id,
|
|
24
|
+
`node:${alias}`, options.scope ?? "network", options.epoch ?? 0, options.bound ?? null,
|
|
25
|
+
options.revoked ? "2000-01-01 00:00:00" : null, options.expired ? "2000-01-01 00:00:00" : null]);
|
|
26
|
+
}
|
|
27
|
+
function snapshot() {
|
|
28
|
+
return {
|
|
29
|
+
tokens: db.all("SELECT token_id,user_id,name,bound_node_id FROM api_tokens ORDER BY token_id"),
|
|
30
|
+
sessions: db.all("SELECT resume_id,alias,network_id,node_id,status FROM sessions ORDER BY resume_id"),
|
|
31
|
+
nodes: db.all("SELECT node_id,alias,network_id,owner_user_id FROM nodes ORDER BY node_id"),
|
|
32
|
+
audits: db.all("SELECT id,action,target_id FROM audit_log ORDER BY id"),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
async function denied(actor: any, alias: string, nodeId?: string) {
|
|
36
|
+
const before = snapshot();
|
|
37
|
+
expect(await mint(actor, alias, nodeId)).toEqual({ status: 400, body: { ok: false, error: "node_owner_mismatch" } });
|
|
38
|
+
expect(snapshot()).toEqual(before);
|
|
39
|
+
}
|
|
40
|
+
beforeAll(async () => {
|
|
41
|
+
({ db, generateNetworkToken, hashToken } = await import("./db.js"));
|
|
42
|
+
const { register } = await import("./auth.js");
|
|
43
|
+
owner = register("owner689", "Fixture-Strong1!");
|
|
44
|
+
holder = register("holder689", "Fixture-Strong1!");
|
|
45
|
+
member = register("member689", "Fixture-Strong1!");
|
|
46
|
+
admin = register("admin689", "Fixture-Strong1!");
|
|
47
|
+
outsider = register("outside689", "Fixture-Strong1!");
|
|
48
|
+
for (const actor of [holder, member, admin]) {
|
|
49
|
+
db.run("INSERT INTO network_members(network_id,user_id,role,agent_access) VALUES(?1,?2,?3,'all')",
|
|
50
|
+
[owner.network_id, actor.user.user_id, actor === admin ? "admin" : "member"]);
|
|
51
|
+
}
|
|
52
|
+
const { bootServer } = await import("./server.js");
|
|
53
|
+
hub = bootServer({ port: 0, hostname: "127.0.0.1" }); base = `http://127.0.0.1:${hub.port}`;
|
|
54
|
+
}, 60000);
|
|
55
|
+
afterAll(() => hub?.stop(true));
|
|
56
|
+
|
|
57
|
+
test("existing session requires authority for both request shapes without writes", async () => {
|
|
58
|
+
session("existing689");
|
|
59
|
+
await denied(member, "existing689");
|
|
60
|
+
await denied(member, "existing689", "n_existing689");
|
|
61
|
+
});
|
|
62
|
+
test("network owner and administrator can restore a legacy identity", async () => {
|
|
63
|
+
for (const [actor, alias] of [[owner, "restore-owner689"], [admin, "restore-admin689"]]) {
|
|
64
|
+
session(alias);
|
|
65
|
+
expect((await mint(actor, alias)).status).toBe(200);
|
|
66
|
+
const withId = await mint(actor, alias, `n_${alias}`);
|
|
67
|
+
expect(withId.status).toBe(200);
|
|
68
|
+
expect(db.get("SELECT owner_user_id FROM nodes WHERE node_id=?1", `n_${alias}`).owner_user_id).toBe(actor.user.user_id);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
test("unique legacy holder can refresh without rewriting the session", async () => {
|
|
72
|
+
session("holder-session689"); credential("holder-session689");
|
|
73
|
+
await denied(member, "holder-session689");
|
|
74
|
+
const before = db.get("SELECT * FROM sessions WHERE alias='holder-session689'");
|
|
75
|
+
expect((await mint(holder, "holder-session689")).status).toBe(200);
|
|
76
|
+
expect((await mint(holder, "holder-session689", "n_holder-session689")).status).toBe(200);
|
|
77
|
+
expect(db.get("SELECT * FROM sessions WHERE alias='holder-session689'")).toEqual(before);
|
|
78
|
+
});
|
|
79
|
+
test("current name-only holder can refresh while registration is pending", async () => {
|
|
80
|
+
expect((await mint(holder, "pending689")).status).toBe(200);
|
|
81
|
+
session("pending689");
|
|
82
|
+
await denied(member, "pending689", "n_pending689");
|
|
83
|
+
expect((await mint(holder, "pending689")).status).toBe(200);
|
|
84
|
+
});
|
|
85
|
+
for (const [label, options] of [
|
|
86
|
+
["revoked", { revoked: true }], ["expired", { expired: true }],
|
|
87
|
+
["renamed-general", { epoch: 1 }], ["wrong-scope", { scope: "full" }],
|
|
88
|
+
["missing-bound-row", { bound: "n_missing689" }],
|
|
89
|
+
] as const) {
|
|
90
|
+
test(`${label} credential is not session holder evidence`, async () => {
|
|
91
|
+
const alias = `${label}689`; session(alias); credential(alias, holder, options);
|
|
92
|
+
await denied(holder, alias);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
test("conflicting legacy holders need administrator recovery", async () => {
|
|
96
|
+
session("ambiguous689"); credential("ambiguous689"); credential("ambiguous689", member);
|
|
97
|
+
await denied(holder, "ambiguous689"); await denied(member, "ambiguous689", "n_ambiguous689");
|
|
98
|
+
expect((await mint(admin, "ambiguous689")).status).toBe(200);
|
|
99
|
+
});
|
|
100
|
+
test("session and holder evidence stay in the requested network", async () => {
|
|
101
|
+
session("foreign-session689", outsider.network_id);
|
|
102
|
+
expect((await mint(member, "foreign-session689")).status).toBe(200);
|
|
103
|
+
session("foreign-holder689"); credential("foreign-holder689", holder, { network: holder.network_id });
|
|
104
|
+
await denied(holder, "foreign-holder689");
|
|
105
|
+
expect((await mint(outsider, "foreign-holder689")).body.error).toBe("no write access to this network");
|
|
106
|
+
});
|
|
107
|
+
test("administrators do not bypass an existing node owner", async () => {
|
|
108
|
+
db.run("INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id) VALUES('n_owned689','owned689','owned689',?1,?2)",
|
|
109
|
+
[owner.network_id, holder.user.user_id]);
|
|
110
|
+
session("owned689", owner.network_id, "n_owned689");
|
|
111
|
+
await denied(admin, "owned689"); await denied(owner, "owned689", "n_owned689");
|
|
112
|
+
expect((await mint(holder, "owned689")).body.node_id).toBe("n_owned689");
|
|
113
|
+
});
|
|
114
|
+
test("viewer and restricted member gates still precede session recovery", async () => {
|
|
115
|
+
session("restricted689"); credential("restricted689");
|
|
116
|
+
db.run("UPDATE network_members SET agent_access='granted' WHERE network_id=?1 AND user_id=?2", [owner.network_id, holder.user.user_id]);
|
|
117
|
+
try { expect((await mint(holder, "restricted689")).body.error).toBe("restricted members cannot create network tokens"); }
|
|
118
|
+
finally { db.run("UPDATE network_members SET agent_access='all' WHERE network_id=?1 AND user_id=?2", [owner.network_id, holder.user.user_id]); }
|
|
119
|
+
db.run("UPDATE network_members SET role='viewer' WHERE network_id=?1 AND user_id=?2", [owner.network_id, member.user.user_id]);
|
|
120
|
+
try { expect((await mint(member, "restricted689")).body.error).toBe("no write access to this network"); }
|
|
121
|
+
finally { db.run("UPDATE network_members SET role='member' WHERE network_id=?1 AND user_id=?2", [owner.network_id, member.user.user_id]); }
|
|
122
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// #906 key-free existing Codex inventory. Identical public contract in each package.
|
|
2
|
+
export function validCodexInventory(v: any): boolean {
|
|
3
|
+
const fields = (o: any, allowed: string[]) => o && typeof o === 'object' && !Array.isArray(o)
|
|
4
|
+
&& Object.keys(o).every(k => allowed.includes(k));
|
|
5
|
+
const id = (s: any) => typeof s === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(s);
|
|
6
|
+
const nullable = (s: any) => s === null || id(s);
|
|
7
|
+
if (!fields(v, ['observed_at', 'scope', 'rows', 'installation', 'local_discovery']) || !Number.isSafeInteger(v.observed_at) || v.observed_at <= 0
|
|
8
|
+
|| v.scope !== 'hub_bound_nodes' || !Array.isArray(v.rows) || v.rows.length > 200
|
|
9
|
+
|| new Set(v.rows.map((r: any) => r?.node_id)).size !== v.rows.length) return false;
|
|
10
|
+
if (v.installation !== undefined && (!fields(v.installation, ['status', 'version'])
|
|
11
|
+
|| !['found', 'missing', 'unknown'].includes(v.installation.status)
|
|
12
|
+
|| !(v.installation.version === null || (typeof v.installation.version === 'string' && /^\d+\.\d+\.\d+(?:[-+.][0-9A-Za-z.-]+)?$/.test(v.installation.version) && v.installation.version.length <= 64)))) return false;
|
|
13
|
+
if (v.local_discovery !== undefined) {
|
|
14
|
+
const local = v.local_discovery;
|
|
15
|
+
if (!fields(local, ['status', 'rows']) || !['observed', 'unavailable', 'limit'].includes(local.status)
|
|
16
|
+
|| !Array.isArray(local.rows) || (local.status !== 'observed' && local.rows.length !== 0)
|
|
17
|
+
|| !validCodexInventory({ observed_at: v.observed_at, scope: v.scope, rows: local.rows })
|
|
18
|
+
|| local.rows.some((r: any) => r.status !== 'observed' || r.config_revision !== null || v.rows.some((b: any) => b.node_id === r.node_id))) return false;
|
|
19
|
+
}
|
|
20
|
+
for (const r of v.rows) {
|
|
21
|
+
if (!r || !id(r.node_id) || typeof r.alias !== 'string' || !/^[\p{L}\p{N}][\p{L}\p{N}_.-]{0,99}$/u.test(r.alias)) return false;
|
|
22
|
+
if (['unavailable', 'not_codex'].includes(r.status)) {
|
|
23
|
+
if (!fields(r, ['node_id', 'alias', 'status'])) return false;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (r.status !== 'observed' || !fields(r, ['node_id', 'alias', 'status', 'runtime', 'home_ref', 'home_source',
|
|
27
|
+
'config_status', 'configured_provider', 'configured_model', 'provider_ids', 'auth_kind', 'credential_status',
|
|
28
|
+
'account_fingerprint', 'verification', 'effective_state', 'node_configured_model', 'config_revision'])) return false;
|
|
29
|
+
if (r.config_revision !== undefined && r.config_revision !== null && (typeof r.config_revision !== 'string' || !/^[a-f0-9]{64}$/.test(r.config_revision))) return false;
|
|
30
|
+
if (!nullable(r.runtime) || !/^[a-f0-9]{16}$/.test(r.home_ref)
|
|
31
|
+
|| !['host_default', 'config.codexHome', 'node-codex-home', 'config.env'].includes(r.home_source)
|
|
32
|
+
|| !['read', 'missing', 'unreadable'].includes(r.config_status) || !nullable(r.configured_provider)
|
|
33
|
+
|| !nullable(r.configured_model) || !nullable(r.node_configured_model)
|
|
34
|
+
|| !Array.isArray(r.provider_ids) || r.provider_ids.length > 101 || !r.provider_ids.every(id)
|
|
35
|
+
|| !['unknown', 'chatgpt', 'api_key'].includes(r.auth_kind)
|
|
36
|
+
|| !['unknown', 'present', 'unreadable'].includes(r.credential_status)
|
|
37
|
+
|| !(r.account_fingerprint === null || /^[a-f0-9]{16}$/.test(r.account_fingerprint))
|
|
38
|
+
|| r.verification !== 'not_checked' || r.effective_state !== 'not_checked') return false;
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// #906 managed login transport: only explicit actions and secret-free projections.
|
|
2
|
+
export type LoginIntent = { action: 'start' | 'read' | 'cancel' | 'save' | 'accounts'; session_id?: string; label?: string };
|
|
3
|
+
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
|
4
|
+
const object = (v: any) => v && typeof v === 'object' && !Array.isArray(v);
|
|
5
|
+
const keys = (v: any, names: string[]) => Object.keys(v).every(k => names.includes(k));
|
|
6
|
+
export function validLoginIntent(v: any): v is LoginIntent {
|
|
7
|
+
return object(v) && keys(v, ['action','session_id','label'])
|
|
8
|
+
&& ['start','read','cancel','save','accounts'].includes(v.action)
|
|
9
|
+
&& (v.action === 'accounts' ? v.session_id === undefined : typeof v.session_id === 'string' && uuid.test(v.session_id))
|
|
10
|
+
&& (v.action === 'save' ? typeof v.label === 'string' && !!v.label.trim() && v.label.length <= 100 && !/[\x00-\x1f\x7f]/.test(v.label) : v.label === undefined);
|
|
11
|
+
}
|
|
12
|
+
export function validLoginReceipt(v: any, intent: LoginIntent, network: string, daemon: string): boolean {
|
|
13
|
+
if (!object(v)) return false;
|
|
14
|
+
if (intent.action === 'accounts') return keys(v, ['accounts']) && Array.isArray(v.accounts) && v.accounts.length <= 100
|
|
15
|
+
&& v.accounts.every((a: any) => object(a) && keys(a, ['version','network_id','daemon_node_id','account_id','label','created_at','auth_state'])
|
|
16
|
+
&& a.version === 1 && a.network_id === network && a.daemon_node_id === daemon
|
|
17
|
+
&& typeof a.account_id === 'string' && uuid.test(a.account_id)
|
|
18
|
+
&& typeof a.label === 'string' && a.label.length <= 100
|
|
19
|
+
&& Number.isSafeInteger(a.created_at) && a.auth_state === 'not_checked');
|
|
20
|
+
if (!keys(v, ['request_id','status','error','challenge','account_id']) || v.request_id !== intent.session_id
|
|
21
|
+
|| !['idle','starting','awaiting_user','verifying','authenticated','cancelled','failed'].includes(v.status)) return false;
|
|
22
|
+
if (v.error !== undefined && !['login_start_failed','login_rejected','account_read_failed','login_process_exited','login_expired'].includes(v.error)) return false;
|
|
23
|
+
if (v.account_id !== undefined && (v.status !== 'authenticated' || typeof v.account_id !== 'string' || !uuid.test(v.account_id))) return false;
|
|
24
|
+
if (intent.action === 'cancel' && v.status !== 'cancelled') return false;
|
|
25
|
+
if (intent.action === 'save' && (v.status !== 'authenticated' || !v.account_id)) return false;
|
|
26
|
+
if (v.status === 'awaiting_user') return object(v.challenge) && keys(v.challenge, ['loginId','verificationUrl','userCode'])
|
|
27
|
+
&& typeof v.challenge.loginId === 'string' && uuid.test(v.challenge.loginId)
|
|
28
|
+
&& v.challenge.verificationUrl === 'https://auth.openai.com/codex/device'
|
|
29
|
+
&& typeof v.challenge.userCode === 'string' && /^[A-Z0-9-]{4,32}$/i.test(v.challenge.userCode);
|
|
30
|
+
return v.challenge === undefined;
|
|
31
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// #906 public Provider snapshot contract. Mirrored across separately published
|
|
2
|
+
// server/agent-node packages; drift is tested. No secret-shaped extension fields.
|
|
3
|
+
import { validCodexInventory } from './codex-inventory.js';
|
|
4
|
+
export function validateDaemonProviderSnapshot(value: unknown, networkId: string, daemonId: string): unknown {
|
|
5
|
+
const fail = (): never => { throw new Error("invalid_provider_snapshot"); };
|
|
6
|
+
const obj = (v: any, fields: string[]) => {
|
|
7
|
+
if (!v || typeof v !== "object" || Array.isArray(v) || Object.keys(v).some(k => !fields.includes(k))) fail();
|
|
8
|
+
return v;
|
|
9
|
+
};
|
|
10
|
+
const id = (v: unknown) => typeof v === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(v);
|
|
11
|
+
const unique = (xs: any[], key: string) => { if (new Set(xs.map(x => x[key])).size !== xs.length) fail(); };
|
|
12
|
+
const arr = (v: any, max: number, empty = false): any[] => {
|
|
13
|
+
if (!Array.isArray(v) || (!empty && !v.length) || v.length > max) fail();
|
|
14
|
+
return v;
|
|
15
|
+
};
|
|
16
|
+
const s = obj(value, ["network_id", "daemon_node_id", "revision", "source", "providers", "codex_inventory"]);
|
|
17
|
+
if (s.codex_inventory !== undefined && !validCodexInventory(s.codex_inventory)) fail();
|
|
18
|
+
if (s.network_id !== networkId || s.daemon_node_id !== daemonId || s.source !== "daemon"
|
|
19
|
+
|| !Number.isSafeInteger(s.revision) || s.revision < 0) fail();
|
|
20
|
+
const providers = arr(s.providers, 100, true);
|
|
21
|
+
unique(providers, "id");
|
|
22
|
+
for (const p of providers) {
|
|
23
|
+
obj(p, ["id", "label", "enabled", "runtimes"]);
|
|
24
|
+
if (!id(p.id) || typeof p.label !== "string" || !p.label.trim() || p.label.length > 100
|
|
25
|
+
|| /[\x00-\x1f\x7f]/.test(p.label) || typeof p.enabled !== "boolean") fail();
|
|
26
|
+
const runtimes = arr(p.runtimes, 8); unique(runtimes, "runtime");
|
|
27
|
+
for (const r of runtimes) {
|
|
28
|
+
obj(r, ["runtime", "auth"]); if (r.runtime !== "codex-tui") fail();
|
|
29
|
+
const auths = arr(r.auth, 32); unique(auths, "id");
|
|
30
|
+
for (const a of auths) {
|
|
31
|
+
obj(a, ["id", "kind", "models", "baseUrl", "account_id", "credential_present", "verification", "application"]);
|
|
32
|
+
if (!id(a.id) || a.verification !== "not_checked" || a.application !== "not_applied") fail();
|
|
33
|
+
const models = arr(a.models, 200);
|
|
34
|
+
if (new Set(models).size !== models.length || models.some(m => typeof m !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(m))) fail();
|
|
35
|
+
if (a.kind === "chatgpt") {
|
|
36
|
+
if (p.id !== "openai" || a.baseUrl !== undefined || typeof a.credential_present !== 'boolean') fail();
|
|
37
|
+
if (a.account_id !== undefined && (typeof a.account_id !== 'string' || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(a.account_id))) fail();
|
|
38
|
+
if (a.credential_present && !a.account_id) fail();
|
|
39
|
+
} else if (a.kind === "api_key") {
|
|
40
|
+
if (a.account_id !== undefined) fail();
|
|
41
|
+
if (a.credential_present !== true || typeof a.baseUrl !== "string" || a.baseUrl.length > 2048) fail();
|
|
42
|
+
let u: URL; try { u = new URL(a.baseUrl); } catch { return fail(); }
|
|
43
|
+
if (!["https:", "http:"].includes(u.protocol) || u.username || u.password || u.search || u.hash) fail();
|
|
44
|
+
} else fail();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// JSON roundtrip detaches the validated object from mutable caller references.
|
|
49
|
+
const encoded = JSON.stringify(s);
|
|
50
|
+
if (new TextEncoder().encode(encoded).byteLength > 1024 * 1024) fail();
|
|
51
|
+
return JSON.parse(encoded);
|
|
52
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const FIELD_BOUNDS = [
|
|
2
|
+
[0, 59],
|
|
3
|
+
[0, 23],
|
|
4
|
+
[1, 31],
|
|
5
|
+
[1, 12],
|
|
6
|
+
[0, 7],
|
|
7
|
+
] as const;
|
|
8
|
+
|
|
9
|
+
function integer(text: string, min: number, max: number): number {
|
|
10
|
+
if (!/^\d+$/.test(text)) throw new Error("invalid_cron");
|
|
11
|
+
const value = Number(text);
|
|
12
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) throw new Error("invalid_cron");
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function validateAtom(atom: string, min: number, max: number): void {
|
|
17
|
+
const [base, step, extra] = atom.split("/");
|
|
18
|
+
if (extra !== undefined || !base) throw new Error("invalid_cron");
|
|
19
|
+
if (step !== undefined) integer(step, 1, max - min + 1);
|
|
20
|
+
if (base === "*") return;
|
|
21
|
+
const range = base.split("-");
|
|
22
|
+
if (range.length === 1) {
|
|
23
|
+
integer(range[0], min, max);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (range.length !== 2) throw new Error("invalid_cron");
|
|
27
|
+
const start = integer(range[0], min, max);
|
|
28
|
+
const end = integer(range[1], min, max);
|
|
29
|
+
if (start > end) throw new Error("invalid_cron");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Strict five-field cron timing. It deliberately excludes commands, names,
|
|
33
|
+
* @aliases, a sixth seconds field and every control character. */
|
|
34
|
+
export function parseManagedCronExpression(raw: unknown): string {
|
|
35
|
+
if (typeof raw !== "string" || raw.length < 5 || raw.length > 120) throw new Error("invalid_cron");
|
|
36
|
+
if (/[^\x20-\x7e]/.test(raw) || /[\r\n]/.test(raw)) throw new Error("invalid_cron");
|
|
37
|
+
const fields = raw.trim().split(/ +/);
|
|
38
|
+
if (fields.length !== 5) throw new Error("invalid_cron");
|
|
39
|
+
fields.forEach((field, index) => {
|
|
40
|
+
if (!field || !/^[0-9*/,-]+$/.test(field)) throw new Error("invalid_cron");
|
|
41
|
+
for (const atom of field.split(",")) validateAtom(atom, FIELD_BOUNDS[index][0], FIELD_BOUNDS[index][1]);
|
|
42
|
+
});
|
|
43
|
+
return fields.join(" ");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ExternalSchedulePatch = { enabled?: boolean; cron?: string };
|
|
47
|
+
|
|
48
|
+
export function parseExternalSchedulePatch(raw: unknown): ExternalSchedulePatch {
|
|
49
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid_patch");
|
|
50
|
+
const obj = raw as Record<string, unknown>;
|
|
51
|
+
const keys = Object.keys(obj);
|
|
52
|
+
if (keys.length === 0 || keys.some((key) => key !== "enabled" && key !== "cron")) throw new Error("invalid_patch");
|
|
53
|
+
const patch: ExternalSchedulePatch = {};
|
|
54
|
+
if (obj.enabled !== undefined) {
|
|
55
|
+
if (typeof obj.enabled !== "boolean") throw new Error("invalid_enabled");
|
|
56
|
+
patch.enabled = obj.enabled;
|
|
57
|
+
}
|
|
58
|
+
if (obj.cron !== undefined) patch.cron = parseManagedCronExpression(obj.cron);
|
|
59
|
+
return patch;
|
|
60
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
NODE_FOLDER_RE, NODE_NAME_CASES, LEGACY_NODE_NAME_RE,
|
|
6
|
+
checkNodeName, nodeDirNameFor, nodeFolderSlug, fnv1aHex,
|
|
7
|
+
} from "./node-name.js";
|
|
8
|
+
|
|
9
|
+
// Board #652 — one name rule for hub + daemon (+ the app's pinned copy).
|
|
10
|
+
|
|
11
|
+
describe("#652 checkNodeName — shared vectors", () => {
|
|
12
|
+
for (const c of NODE_NAME_CASES) {
|
|
13
|
+
test(`${JSON.stringify(c.input).slice(0, 24)} → ${c.ok ? "ok" : c.error}`, () => {
|
|
14
|
+
const r = checkNodeName(c.input);
|
|
15
|
+
expect(r.ok).toBe(c.ok);
|
|
16
|
+
if (!r.ok) {
|
|
17
|
+
expect(r.error).toBe(c.error!);
|
|
18
|
+
expect(r.message.length).toBeGreaterThan(0);
|
|
19
|
+
}
|
|
20
|
+
if (c.slug !== undefined) expect(nodeFolderSlug(c.input)).toBe(c.slug);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("the vector table really contains Chinese names that pass (old rule would refuse them)", () => {
|
|
25
|
+
const cjkOk = NODE_NAME_CASES.filter(c => c.ok && /\p{Script=Han}/u.test(c.input));
|
|
26
|
+
expect(cjkOk.length).toBeGreaterThan(0);
|
|
27
|
+
for (const c of cjkOk) expect(LEGACY_NODE_NAME_RE.test(c.input)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("#652 folder slug", () => {
|
|
32
|
+
test("every slug — incl. every accepted vector — matches the ASCII folder rule", () => {
|
|
33
|
+
for (const c of NODE_NAME_CASES.filter(x => x.ok)) {
|
|
34
|
+
const s = nodeFolderSlug(c.input);
|
|
35
|
+
expect(NODE_FOLDER_RE.test(s)).toBe(true);
|
|
36
|
+
expect(/^[\x21-\x7e]+$/.test(s)).toBe(true);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
test("non-ASCII → node-<6 hex>, deterministic", () => {
|
|
40
|
+
expect(nodeFolderSlug("测试")).toMatch(/^node-[0-9a-f]{6}$/);
|
|
41
|
+
expect(nodeFolderSlug("测试")).toBe(nodeFolderSlug(" 测试 "));
|
|
42
|
+
expect(nodeFolderSlug("测试")).not.toBe(nodeFolderSlug("测验"));
|
|
43
|
+
});
|
|
44
|
+
test("fnv1a known answers (pins the app reimplementation)", () => {
|
|
45
|
+
expect(fnv1aHex("")).toBe("811c9dc5");
|
|
46
|
+
expect(fnv1aHex("a")).toBe("e40c292c");
|
|
47
|
+
expect(fnv1aHex("foobar")).toBe("bf9cf968");
|
|
48
|
+
});
|
|
49
|
+
test("nodeDirNameFor keeps legacy names as-is (existing nodes never move)", () => {
|
|
50
|
+
for (const n of ["a", "demo-bot", "node_1", "my_agent"]) expect(nodeDirNameFor(n)).toBe(n);
|
|
51
|
+
expect(nodeDirNameFor("测试")).toBe(nodeFolderSlug("测试"));
|
|
52
|
+
expect(nodeDirNameFor("MyBot")).toBe("mybot");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("#652 hub/daemon node-name drift guard", () => {
|
|
57
|
+
test("byte-identical source files", () => {
|
|
58
|
+
const hub = readFileSync(join(import.meta.dir, "node-name.ts"), "utf-8");
|
|
59
|
+
const daemon = readFileSync(join(import.meta.dir, "..", "..", "..", "agent-node", "src", "shared", "node-name.ts"), "utf-8");
|
|
60
|
+
expect(daemon).toBe(hub);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Board #652 — node name (= network alias) rules and the ASCII folder slug derived from it.
|
|
2
|
+
//
|
|
3
|
+
// Single source of truth for hub + daemon. This file is byte-identical in
|
|
4
|
+
// server/src/shared/node-name.ts and agent-node/src/shared/node-name.ts
|
|
5
|
+
// (node-name-drift.test.ts fails the build on any difference), and the app
|
|
6
|
+
// (sleep2agi/agent-network-app) keeps a copy pinned to NODE_NAME_CASES below.
|
|
7
|
+
//
|
|
8
|
+
// Before #652 the name had to match /^[a-z][a-z0-9_-]{0,63}$/, so the app
|
|
9
|
+
// wizard refused 「测试」 while the real fleet runs Chinese aliases started by
|
|
10
|
+
// hand. House rule: the alias may be Chinese, the working directory may not.
|
|
11
|
+
// So the name is now any Unicode letters/digits, and every directory derived
|
|
12
|
+
// from it uses nodeFolderSlug() (or the legacy name, for names the old rule
|
|
13
|
+
// already accepted — see nodeDirNameFor).
|
|
14
|
+
|
|
15
|
+
/** The old rule. Every name it accepts keeps its old directory (nodeDirNameFor). */
|
|
16
|
+
export const LEGACY_NODE_NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
17
|
+
|
|
18
|
+
/** Folder / slug rule: what a working directory or `.anet/nodes/<dir>` may be called. */
|
|
19
|
+
export const NODE_FOLDER_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
20
|
+
|
|
21
|
+
export const NODE_NAME_MAX_CHARS = 64;
|
|
22
|
+
|
|
23
|
+
// First char: a letter, a digit or `_` (never `-`: `anet node start -x` reads as a flag;
|
|
24
|
+
// never `.`: hidden files, `..`). Rest: letters, digits, combining marks (Devanagari,
|
|
25
|
+
// Thai… need them), `_`, `-`. Nothing else — no whitespace, `/ \ :`, `.`, quotes,
|
|
26
|
+
// `$`, backticks, control characters: the name ends up in argv, tmux session names
|
|
27
|
+
// (tmux rewrites `.` and `:`), JSON, logs and file names in the trash directory.
|
|
28
|
+
const NODE_NAME_RE = /^[\p{L}\p{N}_][\p{L}\p{M}\p{N}_-]*$/u;
|
|
29
|
+
|
|
30
|
+
export type NodeNameError =
|
|
31
|
+
| "empty"
|
|
32
|
+
| "too_long"
|
|
33
|
+
| "leading_dash"
|
|
34
|
+
| "forbidden_char"
|
|
35
|
+
| "not_nfc";
|
|
36
|
+
|
|
37
|
+
export type NodeNameCheck =
|
|
38
|
+
| { ok: true; name: string }
|
|
39
|
+
| { ok: false; error: NodeNameError; char?: string; message: string };
|
|
40
|
+
|
|
41
|
+
/** Trim + NFC. Callers store the returned `name`, not the raw input. */
|
|
42
|
+
export function normalizeNodeName(raw: string): string {
|
|
43
|
+
return raw.trim().normalize("NFC");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function describeChar(ch: string): string {
|
|
47
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
48
|
+
if (cp < 0x20 || cp === 0x7f) return `U+${cp.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
49
|
+
if (/\s/u.test(ch)) return "空格";
|
|
50
|
+
return ch;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Check a node name. `raw` is trimmed first; the trimmed, NFC form must be
|
|
55
|
+
* 1..64 characters (code points) of letters / digits / `_` / `-`, not starting with `-`.
|
|
56
|
+
*/
|
|
57
|
+
export function checkNodeName(raw: unknown): NodeNameCheck {
|
|
58
|
+
if (typeof raw !== "string") return { ok: false, error: "empty", message: "名字不能为空" };
|
|
59
|
+
const trimmed = raw.trim();
|
|
60
|
+
if (trimmed.length === 0) return { ok: false, error: "empty", message: "名字不能为空" };
|
|
61
|
+
const chars = [...trimmed];
|
|
62
|
+
if (chars.length > NODE_NAME_MAX_CHARS) {
|
|
63
|
+
return { ok: false, error: "too_long", message: `名字最多 ${NODE_NAME_MAX_CHARS} 个字符` };
|
|
64
|
+
}
|
|
65
|
+
if (trimmed !== trimmed.normalize("NFC")) {
|
|
66
|
+
return { ok: false, error: "not_nfc", message: "名字需要是 Unicode NFC 规范形式" };
|
|
67
|
+
}
|
|
68
|
+
if (chars[0] === "-") {
|
|
69
|
+
return { ok: false, error: "leading_dash", char: "-", message: "名字不能以 - 开头" };
|
|
70
|
+
}
|
|
71
|
+
if (!NODE_NAME_RE.test(trimmed)) {
|
|
72
|
+
const bad = chars.find((ch, i) => !(i === 0 ? /^[\p{L}\p{N}_]$/u : /^[\p{L}\p{M}\p{N}_-]$/u).test(ch)) ?? chars[0]!;
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
error: "forbidden_char",
|
|
76
|
+
char: bad,
|
|
77
|
+
message: `名字里不能有「${describeChar(bad)}」:只能用文字、字母、数字、_ 和 -`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return { ok: true, name: trimmed };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isValidNodeName(raw: unknown): boolean {
|
|
84
|
+
return checkNodeName(raw).ok;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** FNV-1a 32-bit over the UTF-8 bytes, 8 hex digits. Tiny, dependency-free, and the
|
|
88
|
+
* app reimplements it byte for byte (no crypto module needed in the browser / RN). */
|
|
89
|
+
export function fnv1aHex(s: string): string {
|
|
90
|
+
const bytes = new TextEncoder().encode(s);
|
|
91
|
+
let h = 0x811c9dc5;
|
|
92
|
+
for (const b of bytes) {
|
|
93
|
+
h ^= b;
|
|
94
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
95
|
+
}
|
|
96
|
+
return h.toString(16).padStart(8, "0");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* ASCII folder name for a node name. Pure-ASCII names are transliterated
|
|
101
|
+
* (lowercase, runs of anything outside [a-z0-9] become one `-`, trimmed, `node-`
|
|
102
|
+
* prefixed when they do not start with a letter); anything else — and anything the
|
|
103
|
+
* transliteration empties — becomes `node-<first 6 hex of fnv1a(name)>`.
|
|
104
|
+
* The result always matches NODE_FOLDER_RE.
|
|
105
|
+
*/
|
|
106
|
+
export function nodeFolderSlug(raw: string): string {
|
|
107
|
+
const name = normalizeNodeName(raw);
|
|
108
|
+
// eslint-disable-next-line no-control-regex
|
|
109
|
+
if (/^[\x00-\x7f]*$/.test(name)) {
|
|
110
|
+
let s = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
111
|
+
if (s && !/^[a-z]/.test(s)) s = `node-${s}`;
|
|
112
|
+
s = s.slice(0, NODE_NAME_MAX_CHARS).replace(/-+$/, "");
|
|
113
|
+
if (NODE_FOLDER_RE.test(s)) return s;
|
|
114
|
+
}
|
|
115
|
+
return `node-${fnv1aHex(name).slice(0, 6)}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The directory under `.anet/nodes/` a daemon-created node lives in.
|
|
120
|
+
* Names the old rule accepted keep their old directory (= the name) so nothing that
|
|
121
|
+
* worked before #652 moves; every other name gets nodeFolderSlug(name).
|
|
122
|
+
*/
|
|
123
|
+
export function nodeDirNameFor(name: string): string {
|
|
124
|
+
return LEGACY_NODE_NAME_RE.test(name) ? name : nodeFolderSlug(name);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Shared test vectors — the app's copy must produce the same verdicts and slugs. */
|
|
128
|
+
export const NODE_NAME_CASES: ReadonlyArray<{ input: string; ok: boolean; error?: NodeNameError; slug?: string }> = [
|
|
129
|
+
{ input: "测试", ok: true, slug: "node-f78149" },
|
|
130
|
+
{ input: "研发助手A", ok: true, slug: "node-1d348f" },
|
|
131
|
+
{ input: "AB测试牛", ok: true, slug: "node-048250" },
|
|
132
|
+
{ input: "my-bot", ok: true, slug: "my-bot" },
|
|
133
|
+
{ input: "MyBot_2", ok: true, slug: "mybot-2" },
|
|
134
|
+
{ input: " spaced ", ok: true, slug: "spaced" },
|
|
135
|
+
{ input: "123", ok: true, slug: "node-123" },
|
|
136
|
+
{ input: "_x", ok: true, slug: "x" },
|
|
137
|
+
{ input: "Ünïcödé", ok: true, slug: "node-030667" },
|
|
138
|
+
{ input: "a".repeat(64), ok: true, slug: "a".repeat(64) },
|
|
139
|
+
{ input: "测".repeat(64), ok: true, slug: "node-50c3fb" },
|
|
140
|
+
{ input: "", ok: false, error: "empty" },
|
|
141
|
+
{ input: " ", ok: false, error: "empty" },
|
|
142
|
+
{ input: "a".repeat(65), ok: false, error: "too_long" },
|
|
143
|
+
{ input: "测".repeat(65), ok: false, error: "too_long" },
|
|
144
|
+
{ input: "-x", ok: false, error: "leading_dash" },
|
|
145
|
+
{ input: "e\u0301", ok: false, error: "not_nfc" },
|
|
146
|
+
{ input: ".hidden", ok: false, error: "forbidden_char" },
|
|
147
|
+
{ input: "..", ok: false, error: "forbidden_char" },
|
|
148
|
+
{ input: "a/b", ok: false, error: "forbidden_char" },
|
|
149
|
+
{ input: "a\\b", ok: false, error: "forbidden_char" },
|
|
150
|
+
{ input: "a:b", ok: false, error: "forbidden_char" },
|
|
151
|
+
{ input: "a.b", ok: false, error: "forbidden_char" },
|
|
152
|
+
{ input: "a b", ok: false, error: "forbidden_char" },
|
|
153
|
+
{ input: "a\u0000b", ok: false, error: "forbidden_char" },
|
|
154
|
+
{ input: "a\nb", ok: false, error: "forbidden_char" },
|
|
155
|
+
{ input: "a$b", ok: false, error: "forbidden_char" },
|
|
156
|
+
{ input: "a`b", ok: false, error: "forbidden_char" },
|
|
157
|
+
{ input: "a'b", ok: false, error: "forbidden_char" },
|
|
158
|
+
{ input: "a\"b", ok: false, error: "forbidden_char" },
|
|
159
|
+
{ input: "a;b", ok: false, error: "forbidden_char" },
|
|
160
|
+
{ input: "a*b", ok: false, error: "forbidden_char" },
|
|
161
|
+
];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// #906 public application protocol. No paths, endpoints or credentials.
|
|
2
|
+
export type ProviderApplicationSelection = {
|
|
3
|
+
node_id: string; node_revision: string; provider_id: string; auth_id: string;
|
|
4
|
+
model: string; confirm_restart: true;
|
|
5
|
+
account_id?: string;
|
|
6
|
+
};
|
|
7
|
+
const fields = (v: any, names: string[]) => v && typeof v === 'object' && !Array.isArray(v)
|
|
8
|
+
&& Object.keys(v).length === names.length && Object.keys(v).every(k => names.includes(k));
|
|
9
|
+
const id = (v: any) => typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(v);
|
|
10
|
+
const hash = (v: any) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
|
|
11
|
+
const account = (v: any) => typeof v === 'string' && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(v);
|
|
12
|
+
export function validProviderApplicationSelection(v: any): v is ProviderApplicationSelection {
|
|
13
|
+
return fields(v, ['node_id', 'node_revision', 'provider_id', 'auth_id', 'model', 'confirm_restart',
|
|
14
|
+
...(v?.account_id !== undefined ? ['account_id'] : [])])
|
|
15
|
+
&& (v.account_id === undefined || (v.provider_id === 'openai' && account(v.account_id)))
|
|
16
|
+
&& id(v.node_id) && hash(v.node_revision) && id(v.provider_id) && id(v.auth_id) && id(v.model)
|
|
17
|
+
&& v.confirm_restart === true;
|
|
18
|
+
}
|
|
19
|
+
export function validProviderApplicationReceipt(v: any, requestId: string, revision: number,
|
|
20
|
+
selection: ProviderApplicationSelection): boolean {
|
|
21
|
+
return fields(v, ['status', 'request_id', 'node_id', 'provider_id', 'auth_id', 'model', 'runtime_provider',
|
|
22
|
+
'node_revision', 'provider_revision', 'verification', 'applied_at', ...(selection.account_id ? ['account_id'] : [])])
|
|
23
|
+
&& v.status === 'applied' && v.request_id === requestId && v.node_id === selection.node_id
|
|
24
|
+
&& v.provider_id === selection.provider_id && v.auth_id === selection.auth_id && v.model === selection.model
|
|
25
|
+
&& (selection.account_id ? v.runtime_provider === 'openai' && v.account_id === selection.account_id
|
|
26
|
+
: typeof v.runtime_provider === 'string' && /^anet_[a-f0-9]{20}$/.test(v.runtime_provider))
|
|
27
|
+
&& hash(v.node_revision) && v.provider_revision === revision && v.verification === 'runtime_confirmed'
|
|
28
|
+
&& Number.isSafeInteger(v.applied_at) && v.applied_at > 0 && v.applied_at <= Date.now() + 30_000;
|
|
29
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// #906 intent contains identifiers only. API keys remain on the owning daemon.
|
|
2
|
+
export type ProviderCollection = {
|
|
3
|
+
node_id: string; alias: string; node_revision: string;
|
|
4
|
+
provider_id: string; auth_id: string; label: string;
|
|
5
|
+
};
|
|
6
|
+
const id = (v: unknown) => typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(v);
|
|
7
|
+
export function validProviderCollection(v: any): v is ProviderCollection {
|
|
8
|
+
return !!v && typeof v === 'object' && !Array.isArray(v)
|
|
9
|
+
&& Object.keys(v).every(k => ['node_id', 'alias', 'node_revision', 'provider_id', 'auth_id', 'label'].includes(k))
|
|
10
|
+
&& id(v.node_id) && id(v.provider_id) && id(v.auth_id)
|
|
11
|
+
&& typeof v.alias === 'string' && /^[\p{L}\p{N}][\p{L}\p{N}_.-]{0,99}$/u.test(v.alias)
|
|
12
|
+
&& typeof v.node_revision === 'string' && /^[a-f0-9]{64}$/.test(v.node_revision)
|
|
13
|
+
&& typeof v.label === 'string' && !!v.label.trim() && v.label.length <= 100 && !/[\x00-\x1f\x7f]/.test(v.label);
|
|
14
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type ProviderInitialization = { revision: number; provider_id: string; auth_id: string; model: string };
|
|
2
|
+
export function validProviderInitialization(value: any): value is ProviderInitialization {
|
|
3
|
+
const id = (v: unknown) => typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(v);
|
|
4
|
+
return !!value && typeof value === 'object' && !Array.isArray(value)
|
|
5
|
+
&& Object.keys(value).every(key => ['revision', 'provider_id', 'auth_id', 'model'].includes(key))
|
|
6
|
+
&& Number.isSafeInteger(value.revision) && value.revision >= 0 && id(value.provider_id) && id(value.auth_id)
|
|
7
|
+
&& typeof value.model === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/.test(value.model);
|
|
8
|
+
}
|