@staix/agent-hub 0.20.0 → 0.22.0
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/CHANGELOG.md +66 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/benchmarks.md +27 -0
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +3 -3
- package/docs/agent-notes/daemon.md +17 -8
- package/docs/agent-notes/local-worker.md +4 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +17 -9
- package/docs/agent-notes/tests.md +9 -2
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +39 -4
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +610 -62
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +18 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +54 -10
- package/docs/security.md +396 -34
- package/docs/smoke.md +133 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1101 -22
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
- package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
- package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
- package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
- package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
- package/docs/verification/2026-10-29-0.21.0.md +61 -0
- package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
- package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
- package/docs/verification/2026-10-30-0.22.0.md +49 -0
- package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
- package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
- package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
- package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
- package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
- package/docs/verification/claude-implementer-permission-triage.ts +160 -0
- package/docs/verification/claude-permission-transport-native.py +71 -14
- package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
- package/docs/verified.json +15 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1016 -280
- package/src/adapters/acp.ts +79 -3
- package/src/adapters/channel-peer.ts +4 -0
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +60 -7
- package/src/adapters/codex-appserver.ts +77 -4
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +3 -9
- package/src/adapters/definitions/builtin-metadata.ts +3 -114
- package/src/adapters/definitions/native-config-defaults.ts +1 -1
- package/src/adapters/definitions/native-config-policy.ts +3 -2
- package/src/adapters/definitions/native-context.ts +12 -0
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-modules.ts +63 -3
- package/src/adapters/jcode.ts +6 -2
- package/src/adapters/local-worker.ts +40 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/broker/protocol.ts +203 -0
- package/src/broker/server.ts +314 -0
- package/src/broker/store.ts +548 -0
- package/src/cli/bench.ts +3 -2
- package/src/cli/broker.ts +58 -0
- package/src/cli/conductor-recovery.ts +142 -0
- package/src/cli/configured-acp-entry.ts +26 -6
- package/src/cli/console-state.ts +59 -17
- package/src/cli/console.ts +17 -2
- package/src/cli/facts-hook.ts +3 -1
- package/src/cli/help.ts +29 -16
- package/src/cli/identity.ts +25 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/learn-output.ts +38 -0
- package/src/cli/main.ts +334 -73
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- package/src/cli/recovery-waits.ts +26 -0
- package/src/cli/role-holders.ts +35 -0
- package/src/cli/room.ts +304 -0
- package/src/cli/say-input.ts +16 -0
- package/src/cli/schedule-output.ts +49 -0
- package/src/cli/sdlc-output.ts +44 -6
- package/src/cli/smoke-pty-bridge.ts +82 -0
- package/src/cli/smoke-pty.ts +153 -0
- package/src/cli/smoke-run.ts +139 -0
- package/src/cli/smoke-target.ts +130 -0
- package/src/cli/source-output.ts +29 -0
- package/src/cli/task-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +112 -28
- package/src/cli/upgrade.ts +68 -8
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +416 -65
- package/src/hub/child-process.ts +2 -0
- package/src/hub/ci-watch.ts +42 -20
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-shell-wrapper.ts +11 -5
- package/src/hub/conductor.ts +17 -4
- package/src/hub/config-apply.ts +34 -6
- package/src/hub/config-reload.ts +7 -5
- package/src/hub/config-trust.ts +7 -1
- package/src/hub/context-window.ts +33 -3
- package/src/hub/control-client.ts +14 -2
- package/src/hub/corpus-config.ts +34 -0
- package/src/hub/corpus-evidence.ts +184 -0
- package/src/hub/corpus.ts +373 -0
- package/src/hub/cost-governance.ts +26 -0
- package/src/hub/daemon.ts +1209 -183
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +200 -73
- package/src/hub/dogfood-profile.ts +32 -5
- package/src/hub/dogfood-read.ts +6 -2
- package/src/hub/envelope.ts +11 -0
- package/src/hub/eval-gate.ts +131 -0
- package/src/hub/events.ts +1 -1
- package/src/hub/external-instance.ts +69 -0
- package/src/hub/facts.ts +15 -5
- package/src/hub/folder-trigger.ts +223 -0
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/inference.ts +20 -5
- package/src/hub/instance-config.ts +69 -3
- package/src/hub/instance-execution.ts +6 -2
- package/src/hub/instances.ts +32 -4
- package/src/hub/intake.ts +125 -0
- package/src/hub/learning.ts +117 -0
- package/src/hub/lifecycle.ts +7 -3
- package/src/hub/limits.ts +12 -5
- package/src/hub/native-permission-request.ts +77 -0
- package/src/hub/operational-authority.ts +63 -5
- package/src/hub/operational-settings.ts +154 -0
- package/src/hub/operational-view.ts +1 -1
- package/src/hub/packs.ts +132 -0
- package/src/hub/path-policy.ts +7 -0
- package/src/hub/peer-doing.ts +130 -0
- package/src/hub/peers.ts +12 -0
- package/src/hub/queue-backpressure.ts +58 -0
- package/src/hub/read-tools.ts +2 -0
- package/src/hub/recovery-authority.ts +151 -0
- package/src/hub/recovery-policy.ts +220 -0
- package/src/hub/research.ts +24 -14
- package/src/hub/restart.ts +7 -0
- package/src/hub/routing.ts +54 -11
- package/src/hub/schedules.ts +266 -0
- package/src/hub/sdlc-merge.ts +38 -14
- package/src/hub/sdlc-metrics.ts +32 -10
- package/src/hub/sdlc-queue.ts +7 -1
- package/src/hub/sdlc-release.ts +1 -1
- package/src/hub/sdlc.ts +37 -7
- package/src/hub/security-baseline.ts +38 -0
- package/src/hub/settings.ts +27 -2
- package/src/hub/shipped-packs.ts +8 -0
- package/src/hub/snapshots.ts +72 -6
- package/src/hub/source-read.ts +160 -0
- package/src/hub/sources.ts +311 -0
- package/src/hub/tasks.ts +987 -110
- package/src/hub/workflow.ts +1 -1
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/tools.ts +37 -3
- package/src/local/web-fetch-config.ts +43 -0
- package/src/local/web-fetch.ts +183 -0
- package/src/memory/brief.ts +85 -13
- package/src/memory/capture.ts +1 -1
- package/src/rooms/application.ts +114 -0
- package/src/rooms/boundary.ts +103 -0
- package/src/rooms/commands.ts +294 -0
- package/src/rooms/crypto.ts +371 -0
- package/src/rooms/link.ts +584 -0
- package/src/rooms/policy.ts +82 -0
- package/src/rooms/remote-peer.ts +80 -0
- package/src/rooms/service.ts +426 -0
- package/src/rooms/status.ts +146 -0
- package/src/rooms/store.ts +1210 -0
- package/src/rooms/wire.ts +465 -0
- package/src/ui/index.html +91 -4
- package/templates/packs/example.pack.json +13 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { closeSync, constants as fsConstants, fstatSync, openSync, readSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #878: what a peer is doing, derived from signals the hub already receives (a Claude session's title and todo
|
|
5
|
+
* list, a Codex thread name and plan, an ACP plan). Daemon memory only: never persisted, logged, captured or relayed.
|
|
6
|
+
*/
|
|
7
|
+
export interface PeerDoing { text: string; source: "title" | "todo"; at: number }
|
|
8
|
+
export type DoingKind = "title" | "todo" | "reset";
|
|
9
|
+
|
|
10
|
+
export const DOING_MAX = 120;
|
|
11
|
+
export const DOING_STUB = "[private]";
|
|
12
|
+
/** At most this much of a transcript is read per signal; lines over TITLE_LINE_MAX are skipped unparsed. */
|
|
13
|
+
export const TITLE_READ_MAX = 1024 * 1024;
|
|
14
|
+
export const TITLE_LINE_MAX = 64 * 1024;
|
|
15
|
+
|
|
16
|
+
/** One line of display text: controls and line breaks become spaces, cut to DOING_MAX code points. */
|
|
17
|
+
export function doingText(value: unknown): string | undefined {
|
|
18
|
+
if (typeof value !== "string") return undefined;
|
|
19
|
+
const text = value.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\s]+/gu, " ").trim();
|
|
20
|
+
return text ? [...text].slice(0, DOING_MAX).join("") : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Observed = PeerDoing & { private: boolean };
|
|
24
|
+
|
|
25
|
+
/** The current item if there is one, else the title. Privacy is decided when a value is observed and again when shown. */
|
|
26
|
+
export class DoingTracker {
|
|
27
|
+
private title: Observed | undefined;
|
|
28
|
+
private item: Observed | undefined;
|
|
29
|
+
|
|
30
|
+
/** `isPrivate` is the hub's screen at observation time; a throw counts as private. An empty text clears that kind. */
|
|
31
|
+
observe(kind: "title" | "todo", value: unknown, isPrivate: (text: string) => boolean, at = Date.now()): void {
|
|
32
|
+
const text = doingText(value);
|
|
33
|
+
let hidden = true;
|
|
34
|
+
if (text !== undefined) try { hidden = isPrivate(text); } catch { /* a failed screen is a stub */ }
|
|
35
|
+
const next = text === undefined ? undefined : { text, source: kind, at, private: hidden };
|
|
36
|
+
if (kind === "title") this.title = next; else this.item = next;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `privateNow`: the peer owns or reviews a PII task, or is in a private turn. */
|
|
40
|
+
view(privateNow: boolean): PeerDoing | undefined {
|
|
41
|
+
const value = this.item ?? this.title;
|
|
42
|
+
if (!value) return undefined;
|
|
43
|
+
return { text: value.private || privateNow ? DOING_STUB : value.text, source: value.source, at: value.at };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Where the last read of a transcript stopped; `skip` drops the rest of a line too long for one read. */
|
|
48
|
+
export interface TitleCursor { path: string; offset: number; skip: boolean }
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The latest `{"type":"ai-title","aiTitle":...}` row appended since `cursor`, reading at most TITLE_READ_MAX bytes and
|
|
52
|
+
* only whole lines. Undefined when none was appended; a missing, foreign or unreadable file reads nothing.
|
|
53
|
+
*/
|
|
54
|
+
export function readAiTitle(cursor: TitleCursor): string | undefined {
|
|
55
|
+
let fd: number | undefined;
|
|
56
|
+
try {
|
|
57
|
+
fd = openSync(cursor.path, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK);
|
|
58
|
+
const st = fstatSync(fd);
|
|
59
|
+
if (!st.isFile()) return undefined;
|
|
60
|
+
if (st.size < cursor.offset) { cursor.offset = 0; cursor.skip = false; } // replaced or truncated: start over
|
|
61
|
+
const length = Math.min(st.size - cursor.offset, TITLE_READ_MAX);
|
|
62
|
+
if (length <= 0) return undefined;
|
|
63
|
+
const buf = Buffer.alloc(length);
|
|
64
|
+
const read = readSync(fd, buf, 0, length, cursor.offset);
|
|
65
|
+
// Lines end at a newline byte: decoding first would misplace the offset after a split multi-byte character.
|
|
66
|
+
const end = buf.subarray(0, read).lastIndexOf(10);
|
|
67
|
+
if (end === -1) {
|
|
68
|
+
if (read === TITLE_READ_MAX) { cursor.offset += read; cursor.skip = true; } // inside a line longer than one read
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
let at = cursor.skip ? buf.indexOf(10) + 1 : 0, title: string | undefined;
|
|
72
|
+
cursor.skip = false;
|
|
73
|
+
while (at <= end) {
|
|
74
|
+
const next = buf.indexOf(10, at);
|
|
75
|
+
if (next - at <= TITLE_LINE_MAX) {
|
|
76
|
+
const line = buf.subarray(at, next).toString("utf8");
|
|
77
|
+
if (line.includes("\"ai-title\"")) {
|
|
78
|
+
try {
|
|
79
|
+
const row = JSON.parse(line);
|
|
80
|
+
if (row?.type === "ai-title" && typeof row.aiTitle === "string") title = row.aiTitle;
|
|
81
|
+
} catch { /* a partial or foreign line */ }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
at = next + 1;
|
|
85
|
+
}
|
|
86
|
+
cursor.offset += end + 1;
|
|
87
|
+
return title;
|
|
88
|
+
} catch { return undefined; }
|
|
89
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const SUBJECT_CAP = 256;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* One Claude session's current item, from the tool input its PreToolUse hook already sends: TodoWrite's in-progress
|
|
96
|
+
* todo, or the task TaskUpdate puts in progress. Answers undefined for no change, null for no current item. A cached
|
|
97
|
+
* subject keeps the privacy verdict it was learned with (`isPrivate(subject)`, asked for every cached subject; a throw is
|
|
98
|
+
* private), so one learned during PII work, or refused by the screen then, stays private when a later TaskUpdate selects it.
|
|
99
|
+
*/
|
|
100
|
+
export type ClaudeTodoItem = { text: string; private: boolean };
|
|
101
|
+
export class ClaudeTodoSource {
|
|
102
|
+
private readonly subjects = new Map<string, ClaudeTodoItem>();
|
|
103
|
+
private task: string | undefined;
|
|
104
|
+
|
|
105
|
+
item(tool: string, input: Record<string, unknown>, isPrivate: (subject: string) => boolean): ClaudeTodoItem | null | undefined {
|
|
106
|
+
if (tool === "TodoWrite") {
|
|
107
|
+
if (!Array.isArray(input.todos)) return undefined;
|
|
108
|
+
this.task = undefined;
|
|
109
|
+
const todo = input.todos.find((t: unknown) => (t as { status?: unknown } | null)?.status === "in_progress") as { activeForm?: unknown; content?: unknown } | undefined;
|
|
110
|
+
const text = doingText(todo?.activeForm) ?? doingText(todo?.content);
|
|
111
|
+
return text ? { text, private: false } : null;
|
|
112
|
+
}
|
|
113
|
+
if (tool !== "TaskUpdate" || (typeof input.taskId !== "string" && typeof input.taskId !== "number")) return undefined;
|
|
114
|
+
const id = String(input.taskId), subject = doingText(input.subject);
|
|
115
|
+
if (subject) {
|
|
116
|
+
let hidden = true;
|
|
117
|
+
try { hidden = isPrivate(subject); } catch { /* unknown verdict: private */ }
|
|
118
|
+
this.subjects.delete(id); this.subjects.set(id, { text: subject, private: hidden });
|
|
119
|
+
if (this.subjects.size > SUBJECT_CAP) this.subjects.delete(this.subjects.keys().next().value as string);
|
|
120
|
+
}
|
|
121
|
+
// ponytail: a task's subject is known only from a TaskUpdate that names it (TaskCreate's id is in its result, which
|
|
122
|
+
// the hook does not send); an unknown one shows no current item. Forward the PostToolUse result if that matters.
|
|
123
|
+
const text = (): ClaudeTodoItem | null => { const form = doingText(input.activeForm); return form ? { text: form, private: false } : this.subjects.get(id) ?? null; };
|
|
124
|
+
if (input.status === "in_progress") { this.task = id; return text(); }
|
|
125
|
+
if (id !== this.task) return undefined;
|
|
126
|
+
if (input.status === undefined) return text();
|
|
127
|
+
this.task = undefined;
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
package/src/hub/peers.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Envelope, EnvelopeOpts, PeerId, PeerState } from "./envelope.ts";
|
|
2
2
|
import type { PermissionMode } from "./permission-mode.ts";
|
|
3
3
|
import type { StartForm } from "../adapters/registry-types.ts";
|
|
4
|
+
import type { DoingKind } from "./peer-doing.ts";
|
|
4
5
|
|
|
5
6
|
export interface DeliveryReceipt {
|
|
6
7
|
id: string;
|
|
@@ -26,6 +27,14 @@ export interface FrontendProxyPort {
|
|
|
26
27
|
|
|
27
28
|
export interface PeerAdapter {
|
|
28
29
|
readonly id: PeerId;
|
|
30
|
+
/** Host-owned room transport binding; remote ids never gain local role/console authority. */
|
|
31
|
+
readonly remoteRoom?: string;
|
|
32
|
+
/** Verified roster fingerprint, pinned separately from the reusable display label. */
|
|
33
|
+
readonly remoteMember?: string;
|
|
34
|
+
/** Host arrival TTL for queued room traffic, default 24 hours. */
|
|
35
|
+
readonly remoteQueueTtlMs?: number;
|
|
36
|
+
/** Host-only original enqueue deadline, handed over before one remote transport call. */
|
|
37
|
+
setRemoteDeadline?(envelopeId: string, expiresAt: number): void;
|
|
29
38
|
ownedTools?(launchId: unknown): OwnedToolsLaunch | undefined;
|
|
30
39
|
keepApprovalAlive?(): void;
|
|
31
40
|
readonly state: PeerState;
|
|
@@ -79,6 +88,8 @@ export interface PeerAdapter {
|
|
|
79
88
|
onFailed?: (envs: Envelope[], failure?: { quota?: true }) => void;
|
|
80
89
|
/** Correlated durable-delivery lifecycle, emitted only when the bus supplied an id. */
|
|
81
90
|
onDelivery?: (receipt: DeliveryReceipt) => void;
|
|
91
|
+
/** Set by the bus (#878): a native session title or current plan item the adapter observed; `reset` on a new session. */
|
|
92
|
+
onDoing?: (kind: DoingKind, text?: unknown) => void;
|
|
82
93
|
/** Safe restart metadata only: ids and launch parameters, never prompt/message text. */
|
|
83
94
|
recoveryMetadata?(): Record<string, unknown>;
|
|
84
95
|
/** Current native TUI ownership, correlated to one launcher. A start acknowledgement is never evidence. */
|
|
@@ -94,6 +105,7 @@ export abstract class BasePeer implements PeerAdapter {
|
|
|
94
105
|
onState?: (state: PeerState) => void;
|
|
95
106
|
onFailed?: (envs: Envelope[], failure?: { quota?: true }) => void;
|
|
96
107
|
onDelivery?: (receipt: DeliveryReceipt) => void;
|
|
108
|
+
onDoing?: (kind: DoingKind, text?: unknown) => void;
|
|
97
109
|
private _state: PeerState = "offline";
|
|
98
110
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
99
111
|
private nextWatchdogMs: number | undefined;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Opt-in admission data only. This module never reads a queue, clock or delivery body. */
|
|
2
|
+
export interface QueueBackpressurePolicy { max_queued: number; max_oldest_s: number }
|
|
3
|
+
export interface QueuePressure {
|
|
4
|
+
queued: number;
|
|
5
|
+
/** null proves an empty queue; absent means the oldest pending timestamp is unknown. */
|
|
6
|
+
oldestQueuedAt?: number | null;
|
|
7
|
+
}
|
|
8
|
+
export type QueueBackpressureCode = "queue-count" | "queue-age" | "queue-age-unknown";
|
|
9
|
+
export interface QueueBackpressureRefusal { peer: string; code: QueueBackpressureCode; reason: string }
|
|
10
|
+
export interface QueuePressurePeerStatus {
|
|
11
|
+
queued: number | null;
|
|
12
|
+
oldestQueuedAt: number | null;
|
|
13
|
+
oldestAgeMs: number | null;
|
|
14
|
+
blocked: boolean;
|
|
15
|
+
code?: QueueBackpressureCode;
|
|
16
|
+
reason?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface QueueBackpressureStatus {
|
|
19
|
+
policy: QueueBackpressurePolicy;
|
|
20
|
+
peers: Record<string, QueuePressurePeerStatus>;
|
|
21
|
+
truncated?: true;
|
|
22
|
+
}
|
|
23
|
+
export const QUEUE_BACKPRESSURE_LIMITS = Object.freeze({ max_queued: 10_000, max_oldest_s: 604_800 });
|
|
24
|
+
const DEFAULT_POLICY: Readonly<QueueBackpressurePolicy> = Object.freeze({ max_queued: 0, max_oldest_s: 0 });
|
|
25
|
+
const validLimit = (value: unknown, max: number): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= max;
|
|
26
|
+
|
|
27
|
+
/** Config owns validation; omission and each zero preserve the existing disabled behaviour. */
|
|
28
|
+
export function queueBackpressurePolicy(raw: unknown): QueueBackpressurePolicy {
|
|
29
|
+
if (raw === undefined) return { ...DEFAULT_POLICY };
|
|
30
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw) || ![Object.prototype, null].includes(Object.getPrototypeOf(raw))) throw new Error("tasks.queue_backpressure must be a bounded policy object");
|
|
31
|
+
const row = raw as Record<string, unknown>;
|
|
32
|
+
if (Object.keys(row).some(key => !Object.hasOwn(DEFAULT_POLICY, key))) throw new Error("tasks.queue_backpressure contains an unknown field");
|
|
33
|
+
const result = { ...DEFAULT_POLICY };
|
|
34
|
+
for (const key of ["max_queued", "max_oldest_s"] as const) {
|
|
35
|
+
if (!Object.hasOwn(row, key)) continue;
|
|
36
|
+
const value = row[key];
|
|
37
|
+
if (!validLimit(value, QUEUE_BACKPRESSURE_LIMITS[key])) throw new Error(`tasks.queue_backpressure.${key} must be an integer from 0 to ${QUEUE_BACKPRESSURE_LIMITS[key]}`);
|
|
38
|
+
result[key] = value;
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A pure admission verdict: thresholds are exceeded strictly; unknown never becomes age zero. */
|
|
44
|
+
export function queueBackpressure(peer: string, policy: QueueBackpressurePolicy | undefined, queue: QueuePressure | undefined, now: number): QueueBackpressureRefusal | undefined {
|
|
45
|
+
if (policy === undefined) return undefined;
|
|
46
|
+
const refuse = (code: QueueBackpressureCode, reason: string): QueueBackpressureRefusal => ({ peer, code, reason: `queue backpressure: ${reason}` });
|
|
47
|
+
if (!validLimit(policy.max_queued, QUEUE_BACKPRESSURE_LIMITS.max_queued) || !validLimit(policy.max_oldest_s, QUEUE_BACKPRESSURE_LIMITS.max_oldest_s)) return refuse("queue-count", "policy unavailable");
|
|
48
|
+
if (policy.max_queued === 0 && policy.max_oldest_s === 0) return undefined;
|
|
49
|
+
if (!queue || !Number.isSafeInteger(queue.queued) || queue.queued < 0) return refuse("queue-count", "queued count unknown");
|
|
50
|
+
if (policy.max_queued > 0 && queue.queued > policy.max_queued) return refuse("queue-count", `queued ${queue.queued} exceeds max ${policy.max_queued}`);
|
|
51
|
+
if (policy.max_oldest_s === 0) return undefined;
|
|
52
|
+
if (queue.queued === 0 && queue.oldestQueuedAt === null) return undefined;
|
|
53
|
+
const oldest = queue.oldestQueuedAt;
|
|
54
|
+
if (queue.queued === 0 || typeof oldest !== "number" || !Number.isSafeInteger(oldest) || oldest < 0 || !Number.isSafeInteger(now) || now < oldest) return refuse("queue-age-unknown", "oldest queued age unknown");
|
|
55
|
+
const age = now - oldest, maxAge = policy.max_oldest_s * 1000;
|
|
56
|
+
if (age > maxAge) return refuse("queue-age", `oldest queued age ${age}ms exceeds max ${maxAge}ms`);
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
package/src/hub/read-tools.ts
CHANGED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/** Host-only conductor continuity. Public operation JSON, actor labels and audits grant nothing.
|
|
2
|
+
* The pin is this registered project's canonical host-owned private key, never a key carried
|
|
3
|
+
* by a record. Like existing host state, it does not resist a same-OS-user process able to
|
|
4
|
+
* replace both key and record. Fresh host policy/privacy proof remains mandatory per effect.
|
|
5
|
+
*/
|
|
6
|
+
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
7
|
+
import { closeSync, constants, fstatSync, linkSync, lstatSync, openSync, readSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { isAbsolute, join } from "node:path";
|
|
9
|
+
import { realPath } from "./project.ts";
|
|
10
|
+
|
|
11
|
+
export interface ConductorRecoveryBinding {
|
|
12
|
+
schema: 1; operationId: string; peer: string; kind: "restart" | "upgrade";
|
|
13
|
+
projectId: string; projectRoot: string; stateDir: string; sourceInstanceId: string;
|
|
14
|
+
targetRoot: string; targetDigest: string; planFingerprint: string; policyDigest: string; boardDigest: string;
|
|
15
|
+
}
|
|
16
|
+
export interface VerifiedRecoveryAuthority { readonly binding: Readonly<ConductorRecoveryBinding> }
|
|
17
|
+
export class RecoveryAuthorityError extends Error {
|
|
18
|
+
readonly code = "AHUB_RECOVERY_AUTHORITY";
|
|
19
|
+
constructor() { super("conductor recovery authority unavailable; the person runs it in ahub console or a terminal"); this.name = "RecoveryAuthorityError"; }
|
|
20
|
+
}
|
|
21
|
+
type Current = (binding: Readonly<ConductorRecoveryBinding>) => boolean;
|
|
22
|
+
type PrivateRecord = { binding: Readonly<ConductorRecoveryBinding>; retired: boolean; seal: string };
|
|
23
|
+
type FileRead = { bytes: Buffer; identity: string };
|
|
24
|
+
type Grant = { stateDir: string; directory: string; key: string; record: string; current: Current };
|
|
25
|
+
const grants = new WeakMap<VerifiedRecoveryAuthority, Grant>();
|
|
26
|
+
const KEYS: readonly (keyof ConductorRecoveryBinding)[] = ["schema", "operationId", "peer", "kind", "projectId", "projectRoot", "stateDir", "sourceInstanceId", "targetRoot", "targetDigest", "planFingerprint", "policyDigest", "boardDigest"];
|
|
27
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
|
|
28
|
+
const HASH = /^[a-f0-9]{64}$/;
|
|
29
|
+
const MAX_RECORD = 16 * 1024;
|
|
30
|
+
function refuse(): never { throw new RecoveryAuthorityError(); }
|
|
31
|
+
function guarded<T>(work: () => T): T { try { return work(); } catch { return refuse(); } }
|
|
32
|
+
function object(value: unknown, keys: readonly string[]): Record<string, unknown> {
|
|
33
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) refuse();
|
|
34
|
+
const fields = Object.getOwnPropertyDescriptors(value);
|
|
35
|
+
if (Reflect.ownKeys(fields).length !== keys.length || keys.some(key => !fields[key] || !Object.hasOwn(fields[key]!, "value"))) refuse();
|
|
36
|
+
return value as Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
function binding(value: unknown): Readonly<ConductorRecoveryBinding> {
|
|
39
|
+
const row = object(value, KEYS);
|
|
40
|
+
if (row.schema !== 1 || typeof row.operationId !== "string" || !UUID.test(row.operationId) || typeof row.peer !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(row.peer)
|
|
41
|
+
|| ["user", "hub", "digest"].includes(row.peer) || row.kind !== "restart" && row.kind !== "upgrade") refuse();
|
|
42
|
+
for (const key of ["projectId", "sourceInstanceId"] as const) if (typeof row[key] !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(row[key])) refuse();
|
|
43
|
+
for (const key of ["projectRoot", "stateDir", "targetRoot"] as const) {
|
|
44
|
+
const value = row[key]; if (typeof value !== "string" || value.length > 4096 || !isAbsolute(value) || /[\p{Cc}]/u.test(value) || realPath(value) !== value) refuse();
|
|
45
|
+
}
|
|
46
|
+
for (const key of ["targetDigest", "planFingerprint", "policyDigest", "boardDigest"] as const) if (typeof row[key] !== "string" || !HASH.test(row[key])) refuse();
|
|
47
|
+
return Object.freeze(Object.fromEntries(KEYS.map(key => [key, row[key]])) as unknown as ConductorRecoveryBinding);
|
|
48
|
+
}
|
|
49
|
+
function identity(stat: NonNullable<ReturnType<typeof lstatSync>>): string {
|
|
50
|
+
return JSON.stringify([stat.dev, stat.ino, stat.mode, stat.uid, stat.nlink, stat.size, stat.mtimeMs, stat.ctimeMs]);
|
|
51
|
+
}
|
|
52
|
+
function directory(stateDir: string): string {
|
|
53
|
+
if (!isAbsolute(stateDir) || realPath(stateDir) !== stateDir) refuse();
|
|
54
|
+
const stat = lstatSync(stateDir);
|
|
55
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || !process.getuid || stat.uid !== process.getuid() || (stat.mode & 0o022) !== 0) refuse();
|
|
56
|
+
// Directory contents change during publication; pin its inode, owner and permission, not mtime.
|
|
57
|
+
return JSON.stringify([stat.dev, stat.ino, stat.uid, stat.mode]);
|
|
58
|
+
}
|
|
59
|
+
function readPrivate(file: string, maximum: number, exact?: number): FileRead {
|
|
60
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
61
|
+
try {
|
|
62
|
+
const before = fstatSync(fd);
|
|
63
|
+
if (!before.isFile() || before.nlink !== 1 || (before.mode & 0o777) !== 0o600 || !process.getuid || before.uid !== process.getuid()
|
|
64
|
+
|| before.size < 1 || before.size > maximum || exact !== undefined && before.size !== exact) refuse();
|
|
65
|
+
const bytes = Buffer.alloc(before.size + 1); let size = 0;
|
|
66
|
+
while (size < bytes.length) { const n = readSync(fd, bytes, size, bytes.length - size, null); if (!n) break; size += n; }
|
|
67
|
+
if (size !== before.size || identity(fstatSync(fd)) !== identity(before) || identity(lstatSync(file)) !== identity(before) || realPath(file) !== file) refuse();
|
|
68
|
+
return { bytes: bytes.subarray(0, size), identity: identity(before) };
|
|
69
|
+
} finally { closeSync(fd); }
|
|
70
|
+
}
|
|
71
|
+
const keyPath = (stateDir: string) => join(stateDir, "conductor-recovery.key");
|
|
72
|
+
const recordPath = (stateDir: string, operationId: string) => join(stateDir, `conductor-recovery.${operationId}.json`);
|
|
73
|
+
function seal(key: Buffer, value: Omit<PrivateRecord, "seal">): string {
|
|
74
|
+
return createHmac("sha256", key).update("agent-hub/conductor-recovery/v1\n").update(JSON.stringify(value)).digest("hex");
|
|
75
|
+
}
|
|
76
|
+
function encodeRecord(key: Buffer, value: Omit<PrivateRecord, "seal">): Buffer {
|
|
77
|
+
const bytes = Buffer.from(JSON.stringify({ ...value, seal: seal(key, value) })); if (bytes.length > MAX_RECORD) refuse(); return bytes;
|
|
78
|
+
}
|
|
79
|
+
function checkRecord(stateDir: string, expected: Readonly<ConductorRecoveryBinding>): { key: FileRead; record: FileRead; value: PrivateRecord } {
|
|
80
|
+
const key = readPrivate(keyPath(stateDir), 32, 32), record = readPrivate(recordPath(stateDir, expected.operationId), MAX_RECORD);
|
|
81
|
+
const row = object(JSON.parse(record.bytes.toString("utf8")), ["binding", "retired", "seal"]), bound = binding(row.binding);
|
|
82
|
+
if (typeof row.retired !== "boolean" || typeof row.seal !== "string" || !HASH.test(row.seal) || JSON.stringify(bound) !== JSON.stringify(expected)) refuse();
|
|
83
|
+
const computed = seal(key.bytes, { binding: bound, retired: row.retired });
|
|
84
|
+
if (!timingSafeEqual(Buffer.from(row.seal, "hex"), Buffer.from(computed, "hex"))) refuse();
|
|
85
|
+
return { key, record, value: { binding: bound, retired: row.retired, seal: row.seal } };
|
|
86
|
+
}
|
|
87
|
+
function fresh(current: Current, value: Readonly<ConductorRecoveryBinding>): void { if (current(value) !== true) refuse(); }
|
|
88
|
+
function stillPrivate(file: string, stamp: string): void { if (identity(lstatSync(file)) !== stamp || realPath(file) !== file) refuse(); }
|
|
89
|
+
/** Publish a whole private file, never an empty capability visible to another reader. */
|
|
90
|
+
function publishNew(stateDir: string, file: string, bytes: Buffer): void {
|
|
91
|
+
const temporary = join(stateDir, `.conductor-recovery.${randomUUID()}.tmp`);
|
|
92
|
+
try { writeFileSync(temporary, bytes, { flag: "wx", mode: 0o600 }); linkSync(temporary, file); }
|
|
93
|
+
finally { try { unlinkSync(temporary); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Call only at the authenticated source's current-conductor boundary. A verifier never creates a key. */
|
|
97
|
+
export function issueConductorRecoveryAuthority(stateDir: string, input: ConductorRecoveryBinding, current: () => boolean): void {
|
|
98
|
+
return guarded(() => {
|
|
99
|
+
const value = binding(input), pin = directory(stateDir); if (value.stateDir !== stateDir) refuse(); fresh(current, value);
|
|
100
|
+
try { readPrivate(keyPath(stateDir), 32, 32); }
|
|
101
|
+
catch (error) {
|
|
102
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
103
|
+
fresh(current, value); publishNew(stateDir, keyPath(stateDir), randomBytes(32));
|
|
104
|
+
}
|
|
105
|
+
const key = readPrivate(keyPath(stateDir), 32, 32);
|
|
106
|
+
try {
|
|
107
|
+
const existing = checkRecord(stateDir, value); if (existing.value.retired) refuse();
|
|
108
|
+
fresh(current, value); stillPrivate(keyPath(stateDir), key.identity); stillPrivate(recordPath(stateDir, value.operationId), existing.record.identity);
|
|
109
|
+
if (directory(stateDir) !== pin) refuse(); return;
|
|
110
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
111
|
+
fresh(current, value); if (directory(stateDir) !== pin) refuse(); stillPrivate(keyPath(stateDir), key.identity);
|
|
112
|
+
publishNew(stateDir, recordPath(stateDir, value.operationId), encodeRecord(key.bytes, { binding: value, retired: false }));
|
|
113
|
+
fresh(current, value); if (directory(stateDir) !== pin) refuse();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/** The host supplies expected registry/package binding and a fresh live-RPC or conservative offline policy check. */
|
|
117
|
+
export function authorizeConductorRecovery(stateDir: string, expected: ConductorRecoveryBinding, current: Current): VerifiedRecoveryAuthority {
|
|
118
|
+
return guarded(() => {
|
|
119
|
+
const value = binding(expected), pin = directory(stateDir); if (value.stateDir !== stateDir) refuse();
|
|
120
|
+
const proof = checkRecord(stateDir, value); if (proof.value.retired) refuse(); fresh(current, value);
|
|
121
|
+
stillPrivate(keyPath(stateDir), proof.key.identity); stillPrivate(recordPath(stateDir, value.operationId), proof.record.identity); if (directory(stateDir) !== pin) refuse();
|
|
122
|
+
const authority = Object.freeze({ binding: value });
|
|
123
|
+
grants.set(authority, { stateDir, directory: pin, key: proof.key.identity, record: proof.record.identity, current });
|
|
124
|
+
return authority;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/** Recheck immediately before every host effect, and after any awaited work. */
|
|
128
|
+
export function assertConductorRecovery(authority: VerifiedRecoveryAuthority): void {
|
|
129
|
+
return guarded(() => {
|
|
130
|
+
const grant = grants.get(authority); if (!grant) refuse();
|
|
131
|
+
if (directory(grant.stateDir) !== grant.directory) refuse();
|
|
132
|
+
const proof = checkRecord(grant.stateDir, authority.binding);
|
|
133
|
+
if (proof.value.retired || proof.key.identity !== grant.key || proof.record.identity !== grant.record) refuse();
|
|
134
|
+
fresh(grant.current, authority.binding);
|
|
135
|
+
stillPrivate(keyPath(grant.stateDir), grant.key); stillPrivate(recordPath(grant.stateDir, authority.binding.operationId), grant.record); if (directory(grant.stateDir) !== grant.directory) refuse();
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/** Root calls only at an authorized terminal phase. A sealed retirement never reactivates through issuance. */
|
|
139
|
+
export function retireConductorRecovery(authority: VerifiedRecoveryAuthority): void {
|
|
140
|
+
return guarded(() => {
|
|
141
|
+
assertConductorRecovery(authority);
|
|
142
|
+
const grant = grants.get(authority)!;
|
|
143
|
+
const key = readPrivate(keyPath(grant.stateDir), 32, 32), file = recordPath(grant.stateDir, authority.binding.operationId);
|
|
144
|
+
const temporary = join(grant.stateDir, `.conductor-recovery.${randomUUID()}.tmp`);
|
|
145
|
+
try {
|
|
146
|
+
writeFileSync(temporary, encodeRecord(key.bytes, { binding: authority.binding, retired: true }), { flag: "wx", mode: 0o600 });
|
|
147
|
+
assertConductorRecovery(authority); renameSync(temporary, file);
|
|
148
|
+
} finally { try { unlinkSync(temporary); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } }
|
|
149
|
+
grants.delete(authority);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/** Fresh, read-only privacy and policy proof for a host-sealed conductor recovery.
|
|
2
|
+
* This does not reconstruct the daemon's memory-only private-turn flag: the live
|
|
3
|
+
* admission path must also refuse contextPii before minting a recovery authority.
|
|
4
|
+
*/
|
|
5
|
+
import { Database } from "bun:sqlite";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
|
|
8
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
9
|
+
import { types } from "node:util";
|
|
10
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
11
|
+
import type { HubConfig } from "./daemon.ts";
|
|
12
|
+
import type { ConfigSourceTexts } from "./config-reload.ts";
|
|
13
|
+
import { conductorPeer } from "./conductor.ts";
|
|
14
|
+
import { DEFAULT_ROLES } from "./hub-tools.ts";
|
|
15
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
16
|
+
import { readInstanceConfig, type InstanceConfigLayer } from "./instance-config.ts";
|
|
17
|
+
import { effectiveInstanceRoles } from "./instances.ts";
|
|
18
|
+
import { realPath } from "./project.ts";
|
|
19
|
+
import { RecoveryAuthorityError } from "./recovery-authority.ts";
|
|
20
|
+
import { peerRosterConfig, rosterAllows } from "./roster.ts";
|
|
21
|
+
import { parseOverlay, parseRouting, type Routing } from "./routing.ts";
|
|
22
|
+
|
|
23
|
+
export interface RecoveryPolicyProject { root: string; id: string; stateDir: string }
|
|
24
|
+
export interface RecoveryPolicyCapture { policyDigest: string; boardDigest: string }
|
|
25
|
+
/** Host-captured inputs, shared with loadConfig before any module evaluation. */
|
|
26
|
+
export interface RecoveryPolicyInputs { layers: InstanceConfigLayer[]; routing: Routing; texts?: ConfigSourceTexts; routingFallback?: string }
|
|
27
|
+
const MAX_POLICY_BYTES = 1024 * 1024;
|
|
28
|
+
const MAX_ACTOR_TASKS = 4096;
|
|
29
|
+
const MAX_SIGNALS_BYTES = 65536;
|
|
30
|
+
function refuse(): never { throw new RecoveryAuthorityError(); }
|
|
31
|
+
const hash = (text: string): string => createHash("sha256").update(text).digest("hex");
|
|
32
|
+
const stamp = (s: NonNullable<ReturnType<typeof lstatSync>>): string => JSON.stringify([s.dev, s.ino, s.size, s.mode, s.nlink, s.mtimeMs, s.ctimeMs]);
|
|
33
|
+
|
|
34
|
+
/** Only bounded, regular, stable files; an absent optional policy is different from an unreadable one. */
|
|
35
|
+
function boundedText(file: string, optional = false): string | undefined {
|
|
36
|
+
let fd: number;
|
|
37
|
+
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
38
|
+
catch (error) { if (optional && (error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
|
|
39
|
+
try {
|
|
40
|
+
const before = fstatSync(fd);
|
|
41
|
+
if (!before.isFile() || before.nlink !== 1 || before.size > MAX_POLICY_BYTES || realPath(file) !== file) refuse();
|
|
42
|
+
const bytes = Buffer.alloc(before.size + 1);
|
|
43
|
+
let length = 0;
|
|
44
|
+
while (length < bytes.length) { const n = readSync(fd, bytes, length, bytes.length - length, null); if (!n) break; length += n; }
|
|
45
|
+
if (length !== before.size || stamp(before) !== stamp(fstatSync(fd)) || stamp(before) !== stamp(lstatSync(file))) refuse();
|
|
46
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, length));
|
|
47
|
+
} finally { closeSync(fd); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Derive compatibility from the verified target bytes, never the caller's mutable feature claim. */
|
|
51
|
+
export function readRecoveryTargetFeatures(targetRoot: string): readonly string[] {
|
|
52
|
+
try {
|
|
53
|
+
const source = boundedText(join(targetRoot, "src/hub/control-client.ts"))!;
|
|
54
|
+
// ponytail: one literal array, at most 128 fixed labels. A computed export needs a versioned target manifest.
|
|
55
|
+
const declarations = [...source.matchAll(/export const DAEMON_FEATURES\s*=\s*(\[[^\]]*\])(?:\s+as const)?\s*;/g)];
|
|
56
|
+
if (declarations.length !== 1) refuse();
|
|
57
|
+
const values: unknown = JSON.parse(declarations[0]![1]!);
|
|
58
|
+
if (!Array.isArray(values) || !values.length || values.length > 128 || values.some(value => typeof value !== "string" || !/^[a-z][a-z0-9_]{0,63}$/.test(value))
|
|
59
|
+
|| new Set(values).size !== values.length || !values.includes("conductor_recovery")) refuse();
|
|
60
|
+
return Object.freeze([...values]);
|
|
61
|
+
} catch { return refuse(); }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Stable semantic encoding; never evaluate config accessors or serialize runtime objects. */
|
|
65
|
+
function canonical(value: unknown, depth = 0): string {
|
|
66
|
+
if (depth > 32) refuse();
|
|
67
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") return JSON.stringify(value);
|
|
68
|
+
if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
|
|
69
|
+
if (types.isProxy(value)) refuse();
|
|
70
|
+
if (Array.isArray(value)) {
|
|
71
|
+
if (Object.getPrototypeOf(value) !== Array.prototype || value.length > MAX_POLICY_BYTES) refuse();
|
|
72
|
+
const fields = Object.getOwnPropertyDescriptors(value);
|
|
73
|
+
if (Reflect.ownKeys(value).length !== value.length + 1 || Object.values(fields).some(field => !Object.hasOwn(field, "value"))) refuse();
|
|
74
|
+
const parts: string[] = [];
|
|
75
|
+
for (let index = 0; index < value.length; index++) {
|
|
76
|
+
const field = fields[String(index)]; if (!field) refuse();
|
|
77
|
+
parts.push(canonical(field.value, depth + 1));
|
|
78
|
+
}
|
|
79
|
+
return `[${parts.join(",")}]`;
|
|
80
|
+
}
|
|
81
|
+
if (!value || typeof value !== "object" || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) refuse();
|
|
82
|
+
const fields = Object.getOwnPropertyDescriptors(value);
|
|
83
|
+
if (Reflect.ownKeys(value).some(key => typeof key !== "string") || Object.values(fields).some(field => !Object.hasOwn(field, "value"))) refuse();
|
|
84
|
+
return `{${Object.keys(fields).sort().filter(key => fields[key]!.value !== undefined).map(key => `${JSON.stringify(key)}:${canonical(fields[key]!.value, depth + 1)}`).join(",")}}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function freshRoles(layers: readonly InstanceConfigLayer[], scope: ReturnType<typeof readInstanceConfig>): Record<string, string[]> {
|
|
88
|
+
const roles = structuredClone(DEFAULT_ROLES);
|
|
89
|
+
for (const { raw } of layers) if (raw.roles !== undefined) {
|
|
90
|
+
conductorPeer(raw.roles);
|
|
91
|
+
for (const [id, list] of Object.entries(raw.roles as Record<string, string[]>)) {
|
|
92
|
+
const definition = definitionOf(id), ceiling = definition?.routing?.allowedRoles;
|
|
93
|
+
roles[id] = (ceiling ? list.filter(role => ceiling.includes(role)) : list).filter(role => role !== "planner" || !definition || definition.projection?.plannerEligible === true);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const configured = Object.fromEntries(scope.catalog.definitions.filter(d => !definitionOf(d.kind)).map(d => [d.kind, [...d.roles]]));
|
|
97
|
+
const extras = Object.fromEntries(Object.keys(scope.instances ?? {}).map(id => [id, effectiveInstanceRoles(id, scope.instances, roles, scope.catalog)]));
|
|
98
|
+
return { ...configured, ...roles, ...extras };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function publicActorBoard(stateDir: string, peer: string, routing: Routing): string {
|
|
102
|
+
const file = join(stateDir, "hub.db"), before = lstatSync(file);
|
|
103
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || realPath(file) !== file) refuse();
|
|
104
|
+
const db = new Database(file, { readonly: true });
|
|
105
|
+
db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
106
|
+
try {
|
|
107
|
+
db.run("BEGIN");
|
|
108
|
+
// Metadata only. In particular no unrelated task titles, bodies, history or refs are read.
|
|
109
|
+
const rows = db.query(`SELECT id, state, owner, reviewer,
|
|
110
|
+
CASE WHEN typeof(signals) = 'text' AND length(CAST(signals AS BLOB)) <= ${MAX_SIGNALS_BYTES} THEN signals ELSE NULL END AS signals
|
|
111
|
+
FROM tasks WHERE (owner = ? OR reviewer = ?) AND (state NOT IN ('approved', 'cancelled') OR state IS NULL)
|
|
112
|
+
ORDER BY id LIMIT ${MAX_ACTOR_TASKS + 1}`).all(peer, peer) as { id: number; state: string; owner: string | null; reviewer: string | null; signals: string | null }[];
|
|
113
|
+
if (rows.length > MAX_ACTOR_TASKS) refuse();
|
|
114
|
+
let total = 0;
|
|
115
|
+
for (const row of rows) {
|
|
116
|
+
if (!Number.isSafeInteger(row.id) || row.id <= 0 || !["proposed", "in_progress", "in_review", "changes_requested"].includes(row.state)
|
|
117
|
+
|| row.signals === null || (total += Buffer.byteLength(row.signals)) > MAX_POLICY_BYTES) refuse();
|
|
118
|
+
const signals: unknown = JSON.parse(row.signals);
|
|
119
|
+
if (!Array.isArray(signals) || signals.length > 1024 || signals.some(signal => typeof signal !== "string" || signal.length > 1024)) refuse();
|
|
120
|
+
if (signals.includes("pii_path") || signals.includes("pii") && routing.constraints.pii === "local_only") refuse();
|
|
121
|
+
}
|
|
122
|
+
db.run("COMMIT");
|
|
123
|
+
const after = lstatSync(file);
|
|
124
|
+
if (after.dev !== before.dev || after.ino !== before.ino || realPath(file) !== file) refuse();
|
|
125
|
+
// Unrelated public board activity must not revoke a public conductor's offline continuity.
|
|
126
|
+
return hash(canonical({ schema: 1, peer, private: false }));
|
|
127
|
+
} finally { db.close(); }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A protected pending edit is not authorization to apply it through a restart. */
|
|
131
|
+
export function assertRecoveryConfiguration(accepted: HubConfig, candidate: HubConfig): void {
|
|
132
|
+
try {
|
|
133
|
+
const previous = canonical(accepted), next = canonical(candidate);
|
|
134
|
+
if (Buffer.byteLength(previous) > 8 * MAX_POLICY_BYTES || Buffer.byteLength(next) > 8 * MAX_POLICY_BYTES || previous !== next) refuse();
|
|
135
|
+
} catch { return refuse(); }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function assertRecoveryRouting(accepted: Routing, candidate: Routing): void {
|
|
139
|
+
try { if (canonical(accepted) !== canonical(candidate)) refuse(); }
|
|
140
|
+
catch { refuse(); }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Unknown future keys are still protected inputs; an older source may not silently authorize a pending edit. */
|
|
144
|
+
export function assertRecoverySourceInputs(accepted: readonly { name: string; raw: Record<string, unknown> }[], candidate: RecoveryPolicyInputs): void {
|
|
145
|
+
try {
|
|
146
|
+
const actual: { name: string; raw: Record<string, unknown> }[] = [...candidate.layers];
|
|
147
|
+
for (const name of ["routing.toml", "routing.local.toml"] as const) {
|
|
148
|
+
const text = candidate.texts?.[name];
|
|
149
|
+
if (text != null) {
|
|
150
|
+
const raw = Bun.TOML.parse(text);
|
|
151
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) refuse();
|
|
152
|
+
actual.push({ name, raw: raw as Record<string, unknown> });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const projection = (rows: readonly { name: string; raw: Record<string, unknown> }[]) => Object.fromEntries(rows.filter(row => Object.keys(row.raw).length > 0).map(row => [row.name, row.raw]));
|
|
156
|
+
if (canonical(projection(accepted)) !== canonical(projection(actual))) refuse();
|
|
157
|
+
} catch { refuse(); }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** One bounded source capture feeds both loadConfig and the recovery proof. No module is evaluated. */
|
|
161
|
+
export function recoveryRoutingFallback(): string {
|
|
162
|
+
try { return boundedText(resolve(import.meta.dir, "..", "..", "templates", "routing.toml"))!; } catch { return refuse(); }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function readRecoveryPolicyInputs(root: string, fallback?: string): RecoveryPolicyInputs {
|
|
166
|
+
try {
|
|
167
|
+
if (!isAbsolute(root) || realPath(root) !== root) refuse();
|
|
168
|
+
const layers: InstanceConfigLayer[] = [];
|
|
169
|
+
const texts: ConfigSourceTexts = { "config.json": null, "config.local.json": null, "routing.toml": null, "routing.local.toml": null };
|
|
170
|
+
for (const name of ["config.json", "config.local.json"] as const) {
|
|
171
|
+
const text = boundedText(join(root, ".agenthub", name), true);
|
|
172
|
+
texts[name] = text ?? null;
|
|
173
|
+
if (text === undefined) continue;
|
|
174
|
+
const raw: unknown = JSON.parse(text);
|
|
175
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) refuse();
|
|
176
|
+
layers.push({ name, raw: raw as Record<string, unknown> });
|
|
177
|
+
}
|
|
178
|
+
const ownMain = boundedText(join(root, ".agenthub", "routing.toml"), true);
|
|
179
|
+
const main = ownMain ?? fallback ?? recoveryRoutingFallback();
|
|
180
|
+
if (Buffer.byteLength(main) > MAX_POLICY_BYTES) refuse();
|
|
181
|
+
const overlay = boundedText(join(root, ".agenthub", "routing.local.toml"), true);
|
|
182
|
+
texts["routing.toml"] = ownMain ?? null; texts["routing.local.toml"] = overlay ?? null;
|
|
183
|
+
return { layers, routing: parseRouting(main, overlay === undefined ? undefined : parseOverlay(overlay)), texts, ...(ownMain === undefined ? { routingFallback: main } : {}) };
|
|
184
|
+
} catch { return refuse(); }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** currentConfig must be host-loaded from these exact inputs and compared with the running accepted config.
|
|
188
|
+
* Supplied inputs are never reread from raw config/routing files. Board privacy is always fresh.
|
|
189
|
+
*/
|
|
190
|
+
export function captureRecoveryPolicy(project: RecoveryPolicyProject, peer: string, currentConfig: HubConfig, inputs?: RecoveryPolicyInputs): RecoveryPolicyCapture {
|
|
191
|
+
try {
|
|
192
|
+
if (!project.id || !isAbsolute(project.root) || !isAbsolute(project.stateDir) || realPath(project.root) !== project.root || realPath(project.stateDir) !== project.stateDir) refuse();
|
|
193
|
+
const snapshot = canonical(inputs ?? readRecoveryPolicyInputs(project.root));
|
|
194
|
+
if (Buffer.byteLength(snapshot) > 8 * MAX_POLICY_BYTES) refuse();
|
|
195
|
+
// Copy host own-data fields once, so proof evaluation cannot run callbacks or observe mutation mid-capture.
|
|
196
|
+
const captured = JSON.parse(snapshot) as RecoveryPolicyInputs;
|
|
197
|
+
const { layers, routing } = captured;
|
|
198
|
+
if (!Array.isArray(layers) || layers.length > 2 || new Set(layers.map(layer => layer.name)).size !== layers.length
|
|
199
|
+
|| layers.some(layer => !["config.json", "config.local.json"].includes(layer.name) || !layer.raw || typeof layer.raw !== "object" || Array.isArray(layer.raw))) refuse();
|
|
200
|
+
// Explicit empty runtime definitions: never import a module or trust a retained display catalog.
|
|
201
|
+
const scope = readInstanceConfig(project.root, layers, []);
|
|
202
|
+
if (scope.adapter_modules?.length || scope.declaredExternalInstances?.length || currentConfig.adapter_modules?.length) refuse();
|
|
203
|
+
const roles = freshRoles(layers, scope);
|
|
204
|
+
if (conductorPeer(roles) !== peer || conductorPeer(currentConfig.roles) !== peer || canonical(roles) !== canonical(currentConfig.roles)
|
|
205
|
+
|| canonical(scope.instances ?? {}) !== canonical(currentConfig.instances ?? {}) || scope.instances?.[peer]?.readOnly === true
|
|
206
|
+
|| !scope.catalog.definitionOf(peer, scope.instances)) refuse();
|
|
207
|
+
// roster is merged key by key by loadConfig; independently refresh the exact fields that admit the actor.
|
|
208
|
+
const rawRoster = Object.assign({}, ...layers.map(layer => layer.raw.roster ?? {}));
|
|
209
|
+
const roster = peerRosterConfig(rawRoster, scope.instances, scope.catalog);
|
|
210
|
+
if (canonical(roster) !== canonical(currentConfig.roster) || !rosterAllows(roster, peer, currentConfig.pi.enabled, scope.instances, scope.catalog)) refuse();
|
|
211
|
+
// ponytail: at most 2 MiB config, 2 MiB routing and 4096 actor-bound open rows (1 MiB signals).
|
|
212
|
+
// A larger board fails closed; the upgrade path is an indexed durable per-actor privacy generation.
|
|
213
|
+
// Seal version-neutral inputs and the fresh actor decision. Defaults and registry projections may evolve in the verified target.
|
|
214
|
+
const policy = canonical({ schema: 1, project: { root: project.root, id: project.id, stateDir: project.stateDir }, peer,
|
|
215
|
+
actor: { conductor: true, rostered: true, writable: true }, sources: layers, texts: captured.texts ?? null,
|
|
216
|
+
routingFallback: captured.routingFallback ?? null });
|
|
217
|
+
if (Buffer.byteLength(policy) > 8 * MAX_POLICY_BYTES) refuse();
|
|
218
|
+
return { policyDigest: hash(policy), boardDigest: publicActorBoard(project.stateDir, peer, routing) };
|
|
219
|
+
} catch { return refuse(); }
|
|
220
|
+
}
|