@staix/agent-hub 0.12.31 → 0.12.32
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 +13 -1
- package/README.md +13 -7
- package/docs/agent-notes/adapters.md +7 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +10 -7
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +3 -2
- package/docs/agent-notes/tests.md +1 -0
- package/docs/cooperbench.md +1 -1
- package/docs/gsd-collaboration-analysis.md +1 -1
- package/docs/memory.md +1 -1
- package/docs/operations.md +114 -22
- package/docs/quickstart.md +10 -6
- package/docs/security.md +12 -8
- package/docs/smoke.md +34 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +204 -0
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +191 -0
- package/docs/verification/2026-10-18-0.12.32.md +54 -0
- package/docs/verified.json +20 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1296 -26
- package/src/adapters/acp.ts +5 -6
- package/src/adapters/definitions/builtin-metadata.ts +121 -0
- package/src/adapters/definitions/kimi-native.ts +4 -0
- package/src/adapters/definitions/qwen-native.ts +24 -0
- package/src/adapters/definitions/qwen.ts +71 -0
- package/src/adapters/pi.ts +6 -2
- package/src/adapters/registry-types.ts +174 -0
- package/src/adapters/registry.ts +36 -0
- package/src/cli/console-state.ts +74 -5
- package/src/cli/console.ts +35 -2
- package/src/cli/help.ts +3 -1
- package/src/cli/identity.ts +13 -3
- package/src/cli/launch.ts +1 -6
- package/src/cli/main.ts +200 -39
- package/src/cli/output.ts +2 -0
- package/src/cli/preview.ts +8 -5
- package/src/cli/recovery-guidance.ts +137 -0
- package/src/cli/recovery-presentation-options.ts +8 -0
- package/src/cli/recovery-session-file.ts +18 -0
- package/src/cli/terminal-recovery.ts +48 -0
- package/src/cli/upgrade-interactive.ts +129 -75
- package/src/cli/upgrade-labels.ts +30 -0
- package/src/cli/upgrade-presentation-data.ts +41 -0
- package/src/cli/upgrade-presentation-flags.ts +31 -0
- package/src/cli/upgrade-presentation.ts +217 -0
- package/src/cli/upgrade-restore-state.ts +6 -2
- package/src/cli/upgrade-runtime.ts +345 -194
- package/src/cli/upgrade.ts +276 -25
- package/src/hub/bus.ts +11 -0
- package/src/hub/cohorts.ts +2 -2
- package/src/hub/config-apply.ts +242 -0
- package/src/hub/config-reload.ts +120 -0
- package/src/hub/config-trust.ts +11 -2
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +559 -170
- package/src/hub/events.ts +2 -2
- package/src/hub/manager.ts +1 -1
- package/src/hub/peer-catalog.ts +10 -13
- package/src/hub/peers.ts +12 -2
- package/src/hub/permission-mode.ts +5 -3
- package/src/hub/recovery-continuity.ts +152 -0
- package/src/hub/recovery-store.ts +10 -4
- package/src/hub/sdlc.ts +5 -1
- package/src/hub/settings.ts +50 -36
- package/src/hub/settled-roles.ts +35 -0
- package/src/hub/supervision.ts +3 -2
- package/src/hub/tasks.ts +28 -8
- package/src/hub/ui.ts +1 -1
- package/src/memory/brief.ts +6 -5
- package/src/pi/session.ts +23 -4
- package/src/ui/index.html +23 -7
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import type { HubConfig } from "./daemon.ts";
|
|
2
|
+
import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked } from "./config-trust.ts";
|
|
3
|
+
import { SUPPORTED_PEERS, PLANNER_PEERS } from "./peer-catalog.ts";
|
|
4
|
+
import { CLASSES } from "./board.ts";
|
|
5
|
+
import { permissionDefaults } from "./permission-mode.ts";
|
|
6
|
+
import { peerStartConfig, terminalTemplate } from "./start-mode.ts";
|
|
7
|
+
import { catalogAssignments } from "./roles.ts";
|
|
8
|
+
import { resolveProfile } from "./profiles.ts";
|
|
9
|
+
import { taskSweepConfig } from "./task-sweep.ts";
|
|
10
|
+
import { projectionConfig } from "./text-projection.ts";
|
|
11
|
+
|
|
12
|
+
export type ApplyClass = "live" | "next use" | "restart";
|
|
13
|
+
export interface ApplyRule { applies: ApplyClass; reason?: string }
|
|
14
|
+
export interface ConfigChange extends ApplyRule { key: string; before: unknown; after: unknown }
|
|
15
|
+
export interface ConfigLayer { name: "config.json" | "config.local.json"; raw: Record<string, unknown> }
|
|
16
|
+
export interface ReloadGuard { key: string; path: readonly string[]; risk: "safe" | "raises"; floor?: string | boolean | number; store: "runtime" | "config" | "routing" }
|
|
17
|
+
export interface ConfigAuthorizationInput {
|
|
18
|
+
key: string;
|
|
19
|
+
before: unknown;
|
|
20
|
+
after: unknown;
|
|
21
|
+
source: "config" | "routing";
|
|
22
|
+
tracked: boolean | undefined;
|
|
23
|
+
authority: "ordinary" | "settings" | "terminal";
|
|
24
|
+
/** The daemon supplies this only for the selected setting or an explicitly confirmed reload key. */
|
|
25
|
+
confirmed?: boolean;
|
|
26
|
+
/** Root proved the destination equals an unchanged, effective startup declaration in this origin. */
|
|
27
|
+
acceptedFallback?: boolean;
|
|
28
|
+
/** The prospective settled planner/conductor holder sets change, even if the raw role list shrinks. */
|
|
29
|
+
settledHolderChange?: boolean;
|
|
30
|
+
guard?: ReloadGuard;
|
|
31
|
+
}
|
|
32
|
+
export interface ConfigAuthorizationDecision { key: string; allowed: boolean; guarded: boolean; reason: string }
|
|
33
|
+
|
|
34
|
+
/** Pure per-leaf authority. Schema validation and named-key selection precede this decision. */
|
|
35
|
+
export function authorizeConfigLeaf(input: ConfigAuthorizationInput): ConfigAuthorizationDecision {
|
|
36
|
+
const { key, before, after } = input;
|
|
37
|
+
const result = (allowed: boolean, guarded: boolean, reason: string): ConfigAuthorizationDecision => ({ key, allowed, guarded, reason });
|
|
38
|
+
if (same(before, after)) return result(true, false, "unchanged");
|
|
39
|
+
const supplied = input.guard;
|
|
40
|
+
const guard = supplied && (key === supplied.key || key === `${supplied.store === "routing" ? "routing." : ""}${supplied.path.join(".")}`) ? supplied : undefined;
|
|
41
|
+
const machine = input.source === "config" && ALL_MACHINE_LOCAL.some(path => key === path || key.startsWith(`${path}.`));
|
|
42
|
+
const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every(item => typeof item === "string");
|
|
43
|
+
const subset = (smaller: unknown, larger: unknown) => strings(larger) && (smaller === undefined || strings(smaller) && smaller.every(item => larger.includes(item)));
|
|
44
|
+
const numeric = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
|
|
45
|
+
let tightening = false;
|
|
46
|
+
if (guard?.floor !== undefined && same(after, guard.floor)) tightening = true;
|
|
47
|
+
if (key.startsWith("tasks.wip_limits.") && numeric(before) && numeric(after) && after <= before) tightening = true;
|
|
48
|
+
if (key === "routing.constraints.pii" && before === "off" && after === "local_only") tightening = true;
|
|
49
|
+
if (key === "routing.signals.pii_screen" && before === "off" && after === "local") tightening = true;
|
|
50
|
+
if (key === "routing.signals.pii_patterns" && strings(after) && (before === undefined || strings(before) && before.every(pattern => after.includes(pattern)))) tightening = true;
|
|
51
|
+
if (key.startsWith("roles.") && subset(after, before)) tightening = true;
|
|
52
|
+
if (key.startsWith("capabilities.") && strings(after) && (before === undefined || subset(after, before))) tightening = true;
|
|
53
|
+
if (["limits.sender_per_min", "limits.pair_per_min", "limits.important_per_hour"].includes(key)
|
|
54
|
+
&& numeric(before) && numeric(after) && after > 0 && (before === 0 || after <= before)) tightening = true;
|
|
55
|
+
if (key === "limits.repeat_window_s" && numeric(before) && numeric(after) && after >= before) tightening = true;
|
|
56
|
+
if (key === "conductor.feed") {
|
|
57
|
+
const levels = ["off", "own", "all"];
|
|
58
|
+
if (typeof before === "string" && typeof after === "string" && levels.includes(before) && levels.includes(after) && levels.indexOf(after) < levels.indexOf(before)) tightening = true;
|
|
59
|
+
}
|
|
60
|
+
if (["inference.enabled", "inference.triage", "review.adaptive"].includes(key) && before === true && after === false) tightening = true;
|
|
61
|
+
if (key === "review.min_reviews" && numeric(before) && numeric(after) && after >= before) tightening = true;
|
|
62
|
+
if (tightening && !input.settledHolderChange) return result(true, false, "semantic tightening");
|
|
63
|
+
if (guard?.risk === "safe" && !machine) return result(true, false, "safe registered setting");
|
|
64
|
+
if (input.acceptedFallback && !machine) {
|
|
65
|
+
const person = input.authority === "terminal" || input.authority === "settings" && !!guard;
|
|
66
|
+
return result(person && input.confirmed === true, true, "restoring an unchanged effective startup value needs this key to be explicitly confirmed");
|
|
67
|
+
}
|
|
68
|
+
// Every other policy change is conservative: person intent never turns tracked or unknown code into host authority.
|
|
69
|
+
if (input.tracked !== false) return result(false, true, input.tracked ? "guarded source is tracked; retain the running value" : "guarded source trust is unknown; retain the running value");
|
|
70
|
+
if (input.authority === "ordinary") return result(false, true, "guarded change needs a selected setting or a confirmed terminal reload");
|
|
71
|
+
if (input.authority === "settings" && !guard) return result(false, true, "nonregistered policy requires a confirmed terminal reload");
|
|
72
|
+
if (input.confirmed !== true) return result(false, true, "guarded change needs explicit confirmation for this key");
|
|
73
|
+
return result(true, true, guard ? "confirmed registered setting" : "confirmed terminal reload");
|
|
74
|
+
}
|
|
75
|
+
type TrustLayer = ConfigLayer | { name: "routing.toml" | "routing.local.toml"; raw: Record<string, unknown> };
|
|
76
|
+
type Authority = "ordinary" | "settings" | "terminal";
|
|
77
|
+
type Check = (value: unknown, key: string) => void;
|
|
78
|
+
const fail = (key: string, reason = "invalid value"): never => { throw new Error(`${key}: ${reason}`); };
|
|
79
|
+
const object = (value: unknown, key: string): Record<string, unknown> => {
|
|
80
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return fail(key, "must be an object");
|
|
81
|
+
return value as Record<string, unknown>;
|
|
82
|
+
};
|
|
83
|
+
const bool: Check = (v, k) => { if (typeof v !== "boolean") fail(k, "must be a boolean"); };
|
|
84
|
+
const text: Check = (v, k) => { if (typeof v !== "string" || v.length > 16_384 || /[\x00-\x1f\x7f]/.test(v)) fail(k, "must be bounded text"); };
|
|
85
|
+
const number = (min = 0, max = Number.MAX_SAFE_INTEGER, integer = false): Check => (v, k) => {
|
|
86
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < min || v > max || integer && !Number.isSafeInteger(v)) fail(k, "number outside bounds");
|
|
87
|
+
};
|
|
88
|
+
const words = (values: readonly string[]): Check => (v, k) => { if (typeof v !== "string" || !values.includes(v)) fail(k, "unknown choice"); };
|
|
89
|
+
const strings: Check = (v, k) => { if (!Array.isArray(v) || v.length > 1024) return fail(k, "must be a bounded string list"); for (const x of v) text(x, k); };
|
|
90
|
+
const argv: Check = (v, k) => { strings(v, k); if (!(v as string[]).length || (v as string[]).some(x => !x)) fail(k, "must be a nonempty argv"); };
|
|
91
|
+
const shapes = new WeakMap<Check, { fields?: Record<string, Check>; item?: Check; keys?: readonly string[] }>();
|
|
92
|
+
const map = (check: Check, keys?: readonly string[]): Check => {
|
|
93
|
+
const fn: Check = (v, k) => { for (const [key, value] of Object.entries(object(v, k))) {
|
|
94
|
+
if (["__proto__", "constructor", "prototype"].includes(key) || keys && !keys.includes(key)) fail(`${k}.${key}`, "unknown key"); check(value, `${k}.${key}`);
|
|
95
|
+
} };
|
|
96
|
+
shapes.set(fn, { item: check, keys }); return fn;
|
|
97
|
+
};
|
|
98
|
+
const block = (schema: Record<string, Check>): Check => {
|
|
99
|
+
const fn: Check = (v, k) => { for (const [key, value] of Object.entries(object(v, k))) {
|
|
100
|
+
if (!Object.hasOwn(schema, key)) fail(`${k}.${key}`, "unknown key"); if (value !== undefined) schema[key]!(value, `${k}.${key}`);
|
|
101
|
+
} };
|
|
102
|
+
shapes.set(fn, { fields: schema }); return fn;
|
|
103
|
+
};
|
|
104
|
+
const peerMap = (check: Check) => map(check, SUPPORTED_PEERS);
|
|
105
|
+
const timerSeconds = number(1, 2_147_483_647 / 1000), timerMinutes = number(1, 2_147_483_647 / 60_000);
|
|
106
|
+
const schema: Record<string, Check> = {
|
|
107
|
+
watchdog_ms: number(1, 2_147_483_647), kimi_cmd: argv, qwen_cmd: argv, jcode_cmd: argv, codex_bin: text, jcode_home: text, jcode_provider_profile: text,
|
|
108
|
+
permission_modes: v => { permissionDefaults(v); }, protect_instruction_files: bool,
|
|
109
|
+
batch_max: number(1, 100_000, true), batch_ms: number(0, 2_147_483_647), queue_cap: number(1, 1_000_000, true),
|
|
110
|
+
memory: block({ enabled: bool, worker_url: text, inject_tokens: number(0, 1_000_000, true), brief_items: number(0, 10_000, true), native: bool,
|
|
111
|
+
short_ttl_days: number(1, 3650, true), sync_interval_min: number(0, 1440, true), suggest_edges: bool }),
|
|
112
|
+
roster: peerMap(bool), roles: map(strings), planner: words(PLANNER_PEERS), profile: words(["dev", "research", "writing", "general"]),
|
|
113
|
+
profile_overrides: (v, k) => { map(() => {})(v, k); const fields = object(v, k); for (const name of ["classes", "role_text", "catalog_roles"]) if (fields[name] !== undefined) map(() => {})(fields[name], `${k}.${name}`); resolveProfile("dev", v); },
|
|
114
|
+
catalog_roles: (v, k) => { map(() => {})(v, k); catalogAssignments(v); },
|
|
115
|
+
pii: block({ paths: strings }), data: block({ readonly: strings }),
|
|
116
|
+
text_projection: v => { projectionConfig(v); },
|
|
117
|
+
conductor: block({ feed: words(["own", "all", "off"]), approval_wait_s: number(0, 2_147_483_647 / 1000) }),
|
|
118
|
+
budget: block({ gate: number(0, 1), stale_min: timerMinutes, poll_min: timerMinutes, checkpoint_timeout_s: timerSeconds, kimi_tokens_5h: number(0), wait_max_min: number(0) }),
|
|
119
|
+
context: block({ gate: number(0, 1), stale_min: timerMinutes }),
|
|
120
|
+
inference: block({ enabled: bool, digest_min_items: number(1, 100_000, true), digest_min_chars: number(1, 10_000_000, true), triage: bool }),
|
|
121
|
+
omniroute: block({ urls: strings, api_key_file: text, access_hosts: strings, cf_client_id_file: text, cf_client_secret_file: text }),
|
|
122
|
+
pi: block({ enabled: bool, auto_start: bool, cmd: argv, backend: words(["auto", "dgx", "mlx"]), dgx_coding: text, dgx_fast: text, max_steps: number(1, 1_000_000, true) }),
|
|
123
|
+
mlx: block({ enabled: bool, provider: words(["legacy", "ollama"]), host: text, runtimeDir: text, modelPath: text, bin: text, port: number(1, 65535, true), model: text, sourceModel: text,
|
|
124
|
+
contextWindow: number(1), maxInputTokens: number(1), maxTokens: number(1), maxConcurrency: number(1, 1024, true) }),
|
|
125
|
+
local: block({ deny: strings, bash_network: (v, k) => { if (v !== "direct") bool(v, k); }, network_allow: strings, max_steps: number(1, 1_000_000, true), read_allow: strings }),
|
|
126
|
+
approvals: block({ timeout_s: timerSeconds, notify: bool }), tasks: block({ release_after_min: number(0), wip_limits: map(number(0, 32, true)) }),
|
|
127
|
+
task_sweep: (v, k) => { block({ enabled: bool, auto_reassign: bool, interval_s: timerSeconds, unaccepted_min: timerMinutes, idle_min: timerMinutes, review_min: timerMinutes, ready_min: timerMinutes, ladder_min: timerMinutes })(v, k); taskSweepConfig(v); },
|
|
128
|
+
checks: (v, k) => { for (const [key, value] of Object.entries(object(v, k))) {
|
|
129
|
+
if (value === undefined) continue;
|
|
130
|
+
if (key === "timeout_s") timerSeconds(value, `${k}.${key}`);
|
|
131
|
+
else if (key === "runner") words(["container"])(value, `${k}.${key}`);
|
|
132
|
+
else if (/^[a-z][a-z0-9_]{0,47}$/.test(key)) text(value, `${k}.${key}`); else fail(`${k}.${key}`, "unknown key");
|
|
133
|
+
} },
|
|
134
|
+
snapshots: block({ enabled: bool, keep: number(1, 1_000_000, true) }), research: block({ enabled: bool }), bench: block({ enabled: bool }),
|
|
135
|
+
peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), ["pi", "codex", "kimi"])(v, k); peerStartConfig(v); },
|
|
136
|
+
terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
|
|
137
|
+
limits: block({ sender_per_min: number(0), pair_per_min: number(0), important_per_hour: number(0), repeat_window_s: number(0) }),
|
|
138
|
+
review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true) }), recovery: block({ auto_resume_after_crash: bool }),
|
|
139
|
+
capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
|
|
140
|
+
};
|
|
141
|
+
export function validateRawConfig(raw: unknown): asserts raw is Record<string, unknown> { block(schema)(raw, "config"); }
|
|
142
|
+
export function validateEffectiveConfig(value: unknown): asserts value is HubConfig {
|
|
143
|
+
const raw = { ...object(value, "config") };
|
|
144
|
+
delete raw.ignored; delete raw.retired; delete raw.permission_default_sources;
|
|
145
|
+
validateRawConfig(raw);
|
|
146
|
+
const profile = resolveProfile(raw.profile, raw.profile_overrides);
|
|
147
|
+
for (const key of Object.keys(object(raw.checks, "checks"))) if (!["timeout_s", "runner", ...CLASSES, ...Object.keys(profile.classes)].includes(key)) fail(`checks.${key}`, "unknown task class");
|
|
148
|
+
if (object(raw.mlx, "mlx").enabled === false && object(raw.pi, "pi").backend === "mlx") fail("pi.backend", "conflicts with disabled mlx");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const restart: Record<string, string> = {
|
|
152
|
+
"pi.dgx_coding": "Published Pi relay model catalogue requires a new daemon relay",
|
|
153
|
+
"pi.dgx_fast": "Published Pi relay model catalogue requires a new daemon relay",
|
|
154
|
+
"routing.pi": "Pi relay context and model publication are bound to the running relay",
|
|
155
|
+
memory: "Open memory consumers, selected profile, client and sync timer must be quiesced together",
|
|
156
|
+
omniroute: "Credentials and gateway clients are bound to relay ownership", mlx: "Model runtime ownership and device resources belong to this daemon",
|
|
157
|
+
checks: "Bounded runner and completion-check contracts are constructed together",
|
|
158
|
+
pii: "Existing native sessions retain their startup privacy boundary", data: "Existing native sessions retain their startup read-only policy",
|
|
159
|
+
"local.deny": "Existing native sessions retain their sandbox deny policy", "local.read_allow": "Existing native sessions retain their sandbox read grants",
|
|
160
|
+
"local.bash_network": "Existing sandbox and egress proxy contracts require a new instance", "local.network_allow": "Existing egress proxy ownership requires a new instance",
|
|
161
|
+
profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
|
|
162
|
+
text_projection: "Converter and cache ownership are bound at startup", terminal: "Terminal provider is supplied by the daemon lifecycle", bench: "Benchmark execution environment is selected before work starts",
|
|
163
|
+
};
|
|
164
|
+
const live = new Set(["permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "approvals.notify"]);
|
|
165
|
+
const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
|
|
166
|
+
/** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
|
|
167
|
+
export function configApply(key: string): ApplyRule {
|
|
168
|
+
const top = key.split(".")[0]!;
|
|
169
|
+
if (!(Object.hasOwn(schema, top) || top === "permission" || top === "routing")) return fail(key, "unknown configuration key");
|
|
170
|
+
if (top !== "routing" && top !== "permission" && key.includes(".")) {
|
|
171
|
+
const special: Record<string, readonly string[]> = {
|
|
172
|
+
task_sweep: ["enabled", "auto_reassign", "interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"],
|
|
173
|
+
checks: ["timeout_s", "runner", ...CLASSES], terminal: ["open"],
|
|
174
|
+
};
|
|
175
|
+
let check = schema[top];
|
|
176
|
+
for (const [index, part] of key.split(".").slice(1).entries()) {
|
|
177
|
+
const shape = check && shapes.get(check);
|
|
178
|
+
if (shape?.fields) { if (!Object.hasOwn(shape.fields, part)) return fail(key, "unknown configuration key"); check = shape.fields[part]; }
|
|
179
|
+
else if (shape?.item) { if (shape.keys && !shape.keys.includes(part)) return fail(key, "unknown configuration key"); check = shape.item; }
|
|
180
|
+
else if (index === 0 && (special[top]?.includes(part) || top === "checks" && /^[a-z][a-z0-9_]{0,47}$/.test(part))) break;
|
|
181
|
+
else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "peers"].includes(top)) break;
|
|
182
|
+
else return fail(key, "unknown configuration key");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (top === "routing" && !key.startsWith("routing.pi") && key !== "routing") {
|
|
186
|
+
return { applies: ["classes", "stay_switch", "signals", "constraints", "hub_routes", "max_switch_prefill_tokens"].includes(key.split(".")[1]!) ? "live" : "next use" };
|
|
187
|
+
}
|
|
188
|
+
if (nextMemory.has(key)) return { applies: "next use" };
|
|
189
|
+
for (const [path, reason] of Object.entries(restart)) if (key === path || key.startsWith(`${path}.`)) return { applies: "restart", reason };
|
|
190
|
+
if ([...live].some(path => key === path || key.startsWith(`${path}.`))) return { applies: "live" };
|
|
191
|
+
return { applies: "next use" };
|
|
192
|
+
}
|
|
193
|
+
const canonical = (v: unknown): unknown => Array.isArray(v) ? v.map(canonical) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical((v as Record<string, unknown>)[k])])) : v;
|
|
194
|
+
const same = (a: unknown, b: unknown): boolean => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|
|
195
|
+
const at = (raw: unknown, path: readonly string[]): unknown => path.reduce<unknown>((v, k) => v && typeof v === "object" && Object.hasOwn(v, k) ? (v as Record<string, unknown>)[k] : undefined, raw);
|
|
196
|
+
export function configChanges(running: HubConfig, next: HubConfig): ConfigChange[] {
|
|
197
|
+
const changes: ConfigChange[] = [];
|
|
198
|
+
const walk = (before: unknown, after: unknown, key: string) => {
|
|
199
|
+
if (same(before, after)) return;
|
|
200
|
+
if (before && after && typeof before === "object" && typeof after === "object" && !Array.isArray(before) && !Array.isArray(after)) {
|
|
201
|
+
for (const child of new Set([...Object.keys(before), ...Object.keys(after)])) walk(at(before, [child]), at(after, [child]), key ? `${key}.${child}` : child);
|
|
202
|
+
} else changes.push({ key, before, after, ...configApply(key) });
|
|
203
|
+
};
|
|
204
|
+
const clean = (v: HubConfig) => Object.fromEntries(Object.entries(v).filter(([key]) => !["ignored", "retired", "permission_default_sources"].includes(key)));
|
|
205
|
+
walk(clean(running), clean(next), ""); return changes.sort((a, b) => a.key.localeCompare(b.key));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Legacy all-or-nothing authority helper; daemon reload uses authorizeConfigLeaf for scoped publication. */
|
|
209
|
+
export function assertReloadTrust(cwd: string, layers: readonly TrustLayer[], previousLayers: readonly TrustLayer[], guardedRules: readonly ReloadGuard[], authority: Authority, defaults?: object): void {
|
|
210
|
+
const complete = [...layers, ...previousLayers.filter(prior => !layers.some(layer => layer.name === prior.name)).map(prior => ({ ...prior, raw: {} }))];
|
|
211
|
+
for (const layer of complete) {
|
|
212
|
+
const prior = previousLayers.find(p => p.name === layer.name)?.raw;
|
|
213
|
+
const routing = layer.name.endsWith(".toml");
|
|
214
|
+
const paths = new Map<string, { path: readonly string[]; floor?: unknown; machine: boolean; registered?: boolean }>();
|
|
215
|
+
if (!routing) for (const key of ALL_MACHINE_LOCAL) {
|
|
216
|
+
const path = key.split("."), value = at(layer.raw, path), old = at(prior, path);
|
|
217
|
+
const children = new Set([...(value && typeof value === "object" && !Array.isArray(value) ? Object.keys(value) : []), ...(old && typeof old === "object" && !Array.isArray(old) ? Object.keys(old) : [])]);
|
|
218
|
+
if (children.size) for (const child of children) paths.set(`${key}.${child}`, { path: [...path, child], machine: true });
|
|
219
|
+
else paths.set(key, { path, machine: true });
|
|
220
|
+
}
|
|
221
|
+
for (const rule of guardedRules) if (rule.risk === "raises" && rule.store === (routing ? "routing" : "config")) {
|
|
222
|
+
const key = rule.path.join(".");
|
|
223
|
+
paths.set(key, { path: rule.path, floor: rule.floor, registered: true, machine: !routing && ALL_MACHINE_LOCAL.some(p => key === p || key.startsWith(`${p}.`)) });
|
|
224
|
+
}
|
|
225
|
+
for (const [key, guard] of paths) {
|
|
226
|
+
const value = at(layer.raw, guard.path), old = at(prior, guard.path);
|
|
227
|
+
if (same(value, old)) continue;
|
|
228
|
+
const inherit = guard.registered && value === undefined && old !== undefined;
|
|
229
|
+
if (!inherit && guard.machine && defaults && same(value, at(defaults, guard.path))) continue;
|
|
230
|
+
// Removal can expose a lower layer: registry inherit is guarded just like a direct raise.
|
|
231
|
+
if (!guard.machine && value !== undefined && same(value, guard.floor)) continue;
|
|
232
|
+
// Empty machine-local declarations choose no program/path and are stripped at startup.
|
|
233
|
+
if (!inherit && guard.machine && (value === undefined || value === null || value === "" || Array.isArray(value) && !value.length)) continue;
|
|
234
|
+
let tracked = configTracked(cwd, layer.name);
|
|
235
|
+
if (!guard.machine && tracked === undefined && !configRepositoryPresent(cwd)) tracked = false;
|
|
236
|
+
if (tracked !== false) fail(`${layer.name}:${key}`, tracked ? "guarded change is tracked" : "guarded source trust is unknown");
|
|
237
|
+
if (guard.machine && authority === "settings" && !guard.registered) fail(`${layer.name}:${key}`, "non-editable machine policy requires terminal authority");
|
|
238
|
+
if (value !== undefined && guard.floor !== undefined && same(value, guard.floor)) continue;
|
|
239
|
+
if (authority === "ordinary") fail(`${layer.name}:${key}`, "guarded change requires settings or terminal authority");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { validateRawConfig, type ConfigLayer } from "./config-apply.ts";
|
|
5
|
+
import { parseOverlay, parseRouting, type Routing } from "./routing.ts";
|
|
6
|
+
import { CLASSES } from "./board.ts";
|
|
7
|
+
|
|
8
|
+
export const CONFIG_SOURCE_FILES = ["config.json", "config.local.json", "routing.toml", "routing.local.toml"] as const;
|
|
9
|
+
export type ConfigSourceName = typeof CONFIG_SOURCE_FILES[number];
|
|
10
|
+
export type ConfigSourceTexts = Record<ConfigSourceName, string | null>;
|
|
11
|
+
export interface ConfigSources {
|
|
12
|
+
texts: ConfigSourceTexts;
|
|
13
|
+
layers: ConfigLayer[];
|
|
14
|
+
trustLayers: { name: ConfigSourceName; raw: Record<string, unknown> }[];
|
|
15
|
+
routing: Routing;
|
|
16
|
+
fingerprint: string;
|
|
17
|
+
}
|
|
18
|
+
const MAX_CONFIG_BYTES = 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
/** Capture once; a missing file is distinct from a read failure. Errors never quote file contents. */
|
|
21
|
+
export function configSourceTexts(cwd: string): ConfigSourceTexts {
|
|
22
|
+
return Object.fromEntries(CONFIG_SOURCE_FILES.map(name => {
|
|
23
|
+
try {
|
|
24
|
+
const text = readFileSync(join(cwd, ".agenthub", name), "utf8");
|
|
25
|
+
if (Buffer.byteLength(text) > MAX_CONFIG_BYTES) throw new Error("oversized");
|
|
26
|
+
return [name, text];
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [name, null];
|
|
29
|
+
throw new Error(`.agenthub/${name}: configuration unavailable or oversized`);
|
|
30
|
+
}
|
|
31
|
+
})) as ConfigSourceTexts;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const configFingerprint = (texts: ConfigSourceTexts): string => createHash("sha256").update(JSON.stringify(texts)).digest("hex");
|
|
35
|
+
|
|
36
|
+
/** Validate only the hub-owned routing schema; Switchyard target/route fields remain extensible. */
|
|
37
|
+
function validateRoutingRaw(value: unknown): void {
|
|
38
|
+
const bad = (key: string): never => { throw new Error(`.agenthub/routing.toml: ${key} is invalid or unknown`); };
|
|
39
|
+
const table = (v: unknown, key: string): Record<string, unknown> => {
|
|
40
|
+
if (!v || typeof v !== "object" || Array.isArray(v) || Object.keys(v).length > 256) return bad(key);
|
|
41
|
+
if (Object.keys(v).some(name => ["__proto__", "constructor", "prototype"].includes(name))) return bad(key);
|
|
42
|
+
return v as Record<string, unknown>;
|
|
43
|
+
};
|
|
44
|
+
const text = (v: unknown, key: string) => { if (typeof v !== "string" || !v || v.length > 4096 || /[\x00-\x1f\x7f]/.test(v)) bad(key); };
|
|
45
|
+
const numeric = (v: unknown, key: string, min = 1, max = Number.MAX_SAFE_INTEGER) => { if (typeof v !== "number" || !Number.isSafeInteger(v) || v < min || v > max) bad(key); };
|
|
46
|
+
const choice = (v: unknown, key: string, choices: readonly string[]) => { if (typeof v !== "string" || !choices.includes(v)) bad(key); };
|
|
47
|
+
const closed = (v: unknown, key: string, fields: Record<string, (v: unknown, key: string) => void>) => {
|
|
48
|
+
for (const [name, item] of Object.entries(table(v, key))) { if (!Object.hasOwn(fields, name)) bad(`${key}.${name}`); fields[name]!(item, `${key}.${name}`); }
|
|
49
|
+
};
|
|
50
|
+
const peers = (v: unknown, key: string) => {
|
|
51
|
+
if (!Array.isArray(v) || v.length > 128 || new Set(v).size !== v.length || v.some(peer => typeof peer !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(peer))) bad(key);
|
|
52
|
+
};
|
|
53
|
+
const strings = (v: unknown, key: string) => { if (!Array.isArray(v) || v.length > 256 || v.some(x => typeof x !== "string" || x.length > 4096)) bad(key); };
|
|
54
|
+
const bool = (v: unknown, key: string) => { if (typeof v !== "boolean") bad(key); };
|
|
55
|
+
const openValue = (v: unknown, key: string, depth = 0): void => {
|
|
56
|
+
if (depth > 16) bad(key);
|
|
57
|
+
if (typeof v === "string") { if (v.length > 16_384) bad(key); }
|
|
58
|
+
else if (typeof v === "number") { if (!Number.isFinite(v)) bad(key); }
|
|
59
|
+
else if (typeof v === "boolean" || v === null) return;
|
|
60
|
+
else if (Array.isArray(v)) { if (v.length > 1024) bad(key); for (const item of v) openValue(item, key, depth + 1); }
|
|
61
|
+
else for (const item of Object.values(table(v, key))) openValue(item, key, depth + 1);
|
|
62
|
+
};
|
|
63
|
+
const switchyard = (v: unknown, key: string, required: "id" | "type") => {
|
|
64
|
+
for (const [name, item] of Object.entries(table(v, key))) {
|
|
65
|
+
if (!name || name.length > 128 || ["__proto__", "constructor", "prototype"].includes(name)) bad(key);
|
|
66
|
+
const entry = table(item, `${key}.${name}`); text(entry[required], `${key}.${name}.${required}`); openValue(entry, `${key}.${name}`);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
closed(value, "routing", {
|
|
70
|
+
local: (v, k) => closed(v, k, { route: text, fixed_model: text }),
|
|
71
|
+
targets: (v, k) => switchyard(v, k, "id"), routes: (v, k) => switchyard(v, k, "type"),
|
|
72
|
+
// parseRouting delegates these tables to the existing parseHubRoutes validator.
|
|
73
|
+
hub_routes: (v, k) => { table(v, k); },
|
|
74
|
+
stay_switch: (v, k) => choice(v, k, ["off", "shadow", "enforce"]), max_switch_prefill_tokens: numeric,
|
|
75
|
+
signals: (v, k) => closed(v, k, { pii_patterns: strings, long_context_tokens: numeric, pii_screen: (x, p) => choice(x, p, ["off", "local"]) }),
|
|
76
|
+
constraints: (v, k) => closed(v, k, { pii: (x, p) => choice(x, p, ["local_only", "off"]), long_context: (x, p) => choice(x, p, ["skip_local", "off"]), budget_paused: (x, p) => choice(x, p, ["skip_peer", "off"]) }),
|
|
77
|
+
pi: (v, k) => closed(v, k, { dgx_max_context_tokens: numeric, mlx_max_context_tokens: numeric, efficient_wait_ms: (x, p) => numeric(x, p, 0, 119999) }),
|
|
78
|
+
classes: (v, k) => { for (const [name, item] of Object.entries(table(v, k))) {
|
|
79
|
+
if (!(CLASSES as readonly string[]).includes(name)) bad(`${k}.${name}`);
|
|
80
|
+
closed(item, `${k}.${name}`, { peers, escalate_to: peers, route: text, fixed_model: text, local_allowed: bool, pi_backend: (x, p) => choice(x, p, ["dgx", "mlx"]) });
|
|
81
|
+
} },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Strict reloads never use routing's last-good fallback. Startup can retain its existing fallback explicitly. */
|
|
86
|
+
export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: Routing): ConfigSources {
|
|
87
|
+
const layers: ConfigLayer[] = [], trustLayers: ConfigSources["trustLayers"] = [];
|
|
88
|
+
for (const name of CONFIG_SOURCE_FILES) {
|
|
89
|
+
const text = texts[name];
|
|
90
|
+
if (text === null) continue;
|
|
91
|
+
let raw: Record<string, unknown>;
|
|
92
|
+
try {
|
|
93
|
+
const value: unknown = name.endsWith(".json") ? JSON.parse(text) : Bun.TOML.parse(text);
|
|
94
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
|
|
95
|
+
raw = value as Record<string, unknown>;
|
|
96
|
+
} catch {
|
|
97
|
+
if (!fallbackRouting) throw new Error(`.agenthub/${name}: invalid ${name.endsWith(".json") ? "JSON object" : "TOML"}`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (name === "config.json" || name === "config.local.json") {
|
|
101
|
+
if (!fallbackRouting) {
|
|
102
|
+
try { validateRawConfig(raw); }
|
|
103
|
+
catch (error) { throw new Error(`.agenthub/${name}: ${(error as Error).message}`); }
|
|
104
|
+
}
|
|
105
|
+
layers.push({ name, raw });
|
|
106
|
+
}
|
|
107
|
+
trustLayers.push({ name, raw });
|
|
108
|
+
}
|
|
109
|
+
let routing = fallbackRouting;
|
|
110
|
+
if (!routing) {
|
|
111
|
+
const base = texts["routing.toml"] ?? readFileSync(new URL("../../templates/routing.toml", import.meta.url), "utf8");
|
|
112
|
+
validateRoutingRaw(Bun.TOML.parse(base));
|
|
113
|
+
let overlay;
|
|
114
|
+
try { overlay = texts["routing.local.toml"] === null ? undefined : parseOverlay(texts["routing.local.toml"]); }
|
|
115
|
+
catch { throw new Error(".agenthub/routing.local.toml: invalid routing overlay"); }
|
|
116
|
+
try { routing = parseRouting(base, overlay); }
|
|
117
|
+
catch { throw new Error(".agenthub/routing.toml / routing.local.toml: invalid effective routing"); }
|
|
118
|
+
}
|
|
119
|
+
return { texts, layers, trustLayers, routing, fingerprint: configFingerprint(texts) };
|
|
120
|
+
}
|
package/src/hub/config-trust.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { statSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { lstatSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
3
|
import { childEnv } from "./child-process.ts";
|
|
4
4
|
import { hubGitSync } from "./git.ts";
|
|
5
5
|
|
|
@@ -31,6 +31,15 @@ export const MACHINE_LOCAL = [
|
|
|
31
31
|
/** Legacy exported field list stays compatible; new policies use the complete list below. */
|
|
32
32
|
export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "text_projection"] as const;
|
|
33
33
|
|
|
34
|
+
/** A known repository, including an unreadable marker, must never be mistaken for a plain directory. */
|
|
35
|
+
export function configRepositoryPresent(cwd: string): boolean {
|
|
36
|
+
for (let at = cwd; ; at = dirname(at)) {
|
|
37
|
+
try { lstatSync(join(at, ".git")); return true; }
|
|
38
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return true; }
|
|
39
|
+
if (dirname(at) === at) return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
34
43
|
/**
|
|
35
44
|
* Why `.agenthub/<name>` must not set machine-local fields, or undefined when git confirms nobody committed it.
|
|
36
45
|
* Unknown is not untracked: no repository, no git, or a git error all refuse. The directory itself counts too (a
|
package/src/hub/crash.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
1
2
|
import { chmodSync, existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import type { JournalDelivery } from "./delivery-journal.ts";
|
|
@@ -47,7 +48,7 @@ export function removeSessions(stateDir: string, instanceId?: string): void {
|
|
|
47
48
|
if (instanceId === undefined || readSessions(stateDir)?.instanceId === instanceId) rmSync(fileOf(stateDir), { force: true });
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
export
|
|
51
|
+
export { qwenSessionId } from "../adapters/definitions/qwen-native.ts";
|
|
51
52
|
|
|
52
53
|
const str = (v: unknown) => (typeof v === "string" && v ? v : undefined);
|
|
53
54
|
|
|
@@ -59,6 +60,12 @@ export function crashPlan(records: SessionRecord[]): { peer: string; resume?: Re
|
|
|
59
60
|
return records.map(({ peer, meta }) => {
|
|
60
61
|
const launch = (meta.launch && typeof meta.launch === "object" ? meta.launch : {}) as Record<string, unknown>;
|
|
61
62
|
const model = str(launch.model);
|
|
63
|
+
const adapter = definitionOf(peer);
|
|
64
|
+
if (adapter?.transport === "acp" && adapter.start.create) {
|
|
65
|
+
const sessionId = adapter.session.crashIdentity?.(meta).sessionId;
|
|
66
|
+
if (!sessionId) return { peer, how: `${peer}: saved ACP session identity unavailable; start it deliberately with ahub ${peer}` };
|
|
67
|
+
return { peer, resume: { sessionId, ...(model ? { model } : {}) }, how: `${peer}: the recorded conversation can be loaded again (ACP session/load)` };
|
|
68
|
+
}
|
|
62
69
|
switch (peer) {
|
|
63
70
|
case "claude":
|
|
64
71
|
return { peer, how: "claude: the Claude Code plugin reconnects by itself while that session is still open" };
|
|
@@ -66,11 +73,6 @@ export function crashPlan(records: SessionRecord[]): { peer: string; resume?: Re
|
|
|
66
73
|
return { peer, how: `codex: its app-server died with the hub; run ahub codex again${str(meta.threadId) ? ` (its conversation was thread ${meta.threadId})` : ""}` };
|
|
67
74
|
case "jcode":
|
|
68
75
|
return { peer, how: "jcode: native 0.94.0 load does not prove the restored id; reopen the original conversation with its native client, no automatic fresh replacement" };
|
|
69
|
-
case "qwen": {
|
|
70
|
-
const sessionId = meta.sessionId;
|
|
71
|
-
if (!qwenSessionId(sessionId)) return { peer, how: "qwen: saved ACP session identity unavailable; start it deliberately with ahub qwen" };
|
|
72
|
-
return { peer, resume: { sessionId, ...(model ? { model } : {}) }, how: "qwen: the recorded conversation can be loaded again (ACP session/load)" };
|
|
73
|
-
}
|
|
74
76
|
case "kimi": {
|
|
75
77
|
const sessionId = str(meta.sessionId);
|
|
76
78
|
if (!sessionId) return { peer, how: "kimi: no session id was recorded; start it again with ahub kimi" };
|