@staix/agent-hub 0.20.0 → 0.21.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 +59 -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 +2 -2
- package/docs/agent-notes/daemon.md +15 -6
- 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 +37 -4
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +565 -62
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +14 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +54 -10
- package/docs/security.md +396 -34
- package/docs/smoke.md +127 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1070 -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/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 +28 -16
- package/src/cli/identity.ts +23 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/main.ts +314 -71
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- 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 +33 -0
- package/src/cli/smoke-pty.ts +145 -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 +98 -16
- package/src/cli/upgrade.ts +66 -7
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +399 -56
- 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 +32 -5
- 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 +1150 -171
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +123 -17
- 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 +85 -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 +76 -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 +6 -1
- 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 +910 -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,76 @@
|
|
|
1
|
+
import type { SettingDef } from "./settings.ts";
|
|
2
|
+
|
|
3
|
+
/** Numeric operational metadata only: existing schema, settings and reload retain all authority. */
|
|
4
|
+
export interface OperationalNumberSetting extends Pick<SettingDef, "key" | "group" | "label" | "path" | "applies" | "restartReason"> {
|
|
5
|
+
type: "number" | "integer";
|
|
6
|
+
store: "config";
|
|
7
|
+
risk: "raises";
|
|
8
|
+
min: number;
|
|
9
|
+
max: number;
|
|
10
|
+
defaultValue: number;
|
|
11
|
+
unit: string;
|
|
12
|
+
/** The independently classified H0 item, not a safety floor or wire/storage contract. */
|
|
13
|
+
inventory: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const GROOM_FYI_WINDOW_SETTING = {
|
|
17
|
+
key: "tasks.groom_fyi_window_min",
|
|
18
|
+
path: ["tasks", "groom_fyi_window_min"],
|
|
19
|
+
group: "Routing",
|
|
20
|
+
label: "Planner state-update FYI interval (minutes)",
|
|
21
|
+
type: "number",
|
|
22
|
+
store: "config",
|
|
23
|
+
risk: "raises",
|
|
24
|
+
applies: "live",
|
|
25
|
+
min: 1,
|
|
26
|
+
max: 1440,
|
|
27
|
+
defaultValue: 10,
|
|
28
|
+
unit: "minutes",
|
|
29
|
+
inventory: "src/hub/tasks.ts:GROOM_FYI_WINDOW_MS",
|
|
30
|
+
} as const satisfies OperationalNumberSetting;
|
|
31
|
+
|
|
32
|
+
const taskNumber = <const T extends Omit<OperationalNumberSetting, "group" | "store" | "risk" | "applies">>(def: T) => ({ group: "Routing", store: "config", risk: "raises", applies: "live", ...def }) as const satisfies OperationalNumberSetting;
|
|
33
|
+
|
|
34
|
+
/** #489 H2, tasks and routing. Each default equals the constant it replaces (pinned in test/operational-settings.test.ts); none is a safety floor or a contract. */
|
|
35
|
+
export const DEMOTE_AT_SETTING = taskNumber({
|
|
36
|
+
key: "tasks.demote_at", path: ["tasks", "demote_at"], label: "Peer demotion threshold (decayed recent failures)",
|
|
37
|
+
type: "number", min: 0.5, max: 20, defaultValue: 1.5, unit: "failures", inventory: "src/hub/tasks.ts:DEMOTE_AT",
|
|
38
|
+
});
|
|
39
|
+
export const DEMOTION_HALF_LIFE_SETTING = taskNumber({
|
|
40
|
+
key: "tasks.demotion_half_life_h", path: ["tasks", "demotion_half_life_h"], label: "Demotion half-life: a failure counts half after (hours)",
|
|
41
|
+
type: "number", min: 1, max: 168, defaultValue: 24, unit: "hours", inventory: "src/hub/tasks.ts:DEMOTION_HALF_LIFE_MS",
|
|
42
|
+
});
|
|
43
|
+
export const CONTRADICTION_WINDOW_SETTING = taskNumber({
|
|
44
|
+
key: "tasks.contradiction_window_days", path: ["tasks", "contradiction_window_days"], label: "Approval contradiction window (days)",
|
|
45
|
+
type: "number", min: 1, max: 90, defaultValue: 7, unit: "days", inventory: "src/hub/tasks.ts:CONTRADICTION_WINDOW_MS",
|
|
46
|
+
});
|
|
47
|
+
export const RESCREEN_MAX_SETTING = taskNumber({
|
|
48
|
+
key: "tasks.rescreen_max", path: ["tasks", "rescreen_max"], label: "PII re-screen attempts per task in a hub run",
|
|
49
|
+
type: "integer", min: 1, max: 100, defaultValue: 10, unit: "attempts", inventory: "src/hub/tasks.ts:RESCREEN_MAX",
|
|
50
|
+
});
|
|
51
|
+
export const SPLIT_MIN_SETTING = taskNumber({
|
|
52
|
+
key: "tasks.split_min_observations", path: ["tasks", "split_min_observations"], label: "Shadow split prediction: measured tasks a peer needs",
|
|
53
|
+
type: "integer", min: 3, max: 100, defaultValue: 5, unit: "tasks", inventory: "src/hub/routing.ts:SPLIT_MIN",
|
|
54
|
+
});
|
|
55
|
+
export const SPLIT_FAILURE_SHARE_SETTING = taskNumber({
|
|
56
|
+
key: "tasks.split_max_failure_share", path: ["tasks", "split_max_failure_share"], label: "Shadow split prediction: failed share a history may hold",
|
|
57
|
+
type: "number", min: 0, max: 1, defaultValue: 0.3, unit: "share", inventory: "src/hub/routing.ts:SPLIT_FAILURE_SHARE",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** Fixed metadata; never registration from a file, request, adapter or environment variable. */
|
|
61
|
+
export const OPERATIONAL_NUMBERS: readonly OperationalNumberSetting[] = [GROOM_FYI_WINDOW_SETTING, DEMOTE_AT_SETTING, DEMOTION_HALF_LIFE_SETTING, CONTRADICTION_WINDOW_SETTING, RESCREEN_MAX_SETTING, SPLIT_MIN_SETTING, SPLIT_FAILURE_SHARE_SETTING];
|
|
62
|
+
/** The `tasks` leaves the six #489 H2 readers need; `HubConfig.tasks` has them all. */
|
|
63
|
+
export interface TaskOperationalConfig { tasks: { demote_at?: number; demotion_half_life_h?: number; contradiction_window_days?: number; rescreen_max?: number; split_min_observations?: number; split_max_failure_share?: number } }
|
|
64
|
+
/**
|
|
65
|
+
* The one place the daemon turns the six task settings into `TasksDeps` readers. `config` is asked at every use, so a reload applies at the next demotion read, verdict, re-screen or prediction; an unset leaf is the row's default,
|
|
66
|
+
* which is the constant it replaced.
|
|
67
|
+
*/
|
|
68
|
+
export const taskOperationalReaders = (config: () => TaskOperationalConfig) => ({
|
|
69
|
+
demoteAt: () => config().tasks.demote_at ?? DEMOTE_AT_SETTING.defaultValue,
|
|
70
|
+
demotionHalfLifeH: () => config().tasks.demotion_half_life_h ?? DEMOTION_HALF_LIFE_SETTING.defaultValue,
|
|
71
|
+
contradictionWindowDays: () => config().tasks.contradiction_window_days ?? CONTRADICTION_WINDOW_SETTING.defaultValue,
|
|
72
|
+
rescreenMax: () => config().tasks.rescreen_max ?? RESCREEN_MAX_SETTING.defaultValue,
|
|
73
|
+
splitMinObservations: () => config().tasks.split_min_observations ?? SPLIT_MIN_SETTING.defaultValue,
|
|
74
|
+
splitMaxFailureShare: () => config().tasks.split_max_failure_share ?? SPLIT_FAILURE_SHARE_SETTING.defaultValue,
|
|
75
|
+
});
|
|
76
|
+
export const operationalNumber = (key: string): OperationalNumberSetting | undefined => OPERATIONAL_NUMBERS.find(def => def.key === key);
|
|
@@ -66,7 +66,7 @@ function metadataValue(type:string,key:string,value:unknown):boolean {
|
|
|
66
66
|
if(key==="item")return oneOf(value,["task","summary","review note","handoff","note"]);
|
|
67
67
|
if(key==="category")return oneOf(value,["name","student_id","phone","address","grade","health","other"]);
|
|
68
68
|
if(key==="miss")return oneOf(value,["too long","off campus","timeout","unreadable","failed"]);
|
|
69
|
-
if(key==="source")return type==="pii_screen"?oneOf(value,["regex","screen","unknown","inherited"]):type==="usage"?oneOf(value,["omniroute","claude_transcript","claude_result","codex_appserver","kimi_wire"]):identifier(value);
|
|
69
|
+
if(key==="source")return type==="pii_screen"?oneOf(value,["regex","screen","unknown","inherited"]):type==="usage"?oneOf(value,["omniroute","claude_transcript","claude_result","codex_appserver","kimi_wire","jcode_acp"]):identifier(value);
|
|
70
70
|
if(key==="measuredAt")return type==="context_pressure" || type==="quota_reset"?typeof value==="number"&&Number.isFinite(value):typeof value==="string"&&Number.isFinite(Date.parse(value));
|
|
71
71
|
if(key==="window")return value===null || typeof value==="number"&&Number.isFinite(value)&&value>=0 || identifier(value);
|
|
72
72
|
if(key==="priority")return value===null || typeof value==="number"&&Number.isFinite(value) || oneOf(value,["status","important","fyi"]);
|
package/src/hub/packs.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { PROFILE_NAMES, resolveProfile, type ProfileName } from "./profiles.ts";
|
|
4
|
+
import { loadRoleCatalog } from "./roles.ts";
|
|
5
|
+
import { SHIPPED_PACKS } from "./shipped-packs.ts";
|
|
6
|
+
import { criterionId } from "./workflow.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A domain pack (#513a): a profile plus the roles it expects, a review rubric and example checks, in one file. It is data, never authority: it selects an
|
|
10
|
+
* existing profile, names roles that must already be in the catalog, and contributes only `profile_overrides` text (`review_checklist`, `example_checks`)
|
|
11
|
+
* that `resolveProfile` already bounds. Checks are display text exactly like `example_checks`: nothing here selects or runs a command.
|
|
12
|
+
* The rubric ids follow #507's criterion ids (`criterionId`), so a reviewer names an unmet item by the same id the task's checklist uses.
|
|
13
|
+
*/
|
|
14
|
+
export interface DomainPack {
|
|
15
|
+
name: string; profile: ProfileName; summary: string; roles: string[];
|
|
16
|
+
rubric: { id: string; text: string; required: boolean }[];
|
|
17
|
+
checks: { id: string; note: string }[];
|
|
18
|
+
layer: "shipped" | "project";
|
|
19
|
+
}
|
|
20
|
+
export interface PackCatalog { packs: Map<string, DomainPack>; errors: { id: string; reason: string }[] }
|
|
21
|
+
export const PACK_SUFFIX = ".pack.json";
|
|
22
|
+
const NAME = /^[a-z][a-z0-9-]{0,47}$/;
|
|
23
|
+
const MAX_BYTES = 8192, MAX_FILES = 16, MAX_ROLES = 8, MAX_RUBRIC = 12, MAX_CHECKS = 8;
|
|
24
|
+
const KEYS = ["name", "profile", "summary", "roles", "rubric", "checks"];
|
|
25
|
+
|
|
26
|
+
const plain = (value: unknown, cap: number, what: string): string => {
|
|
27
|
+
if (typeof value !== "string" || !value.trim() || value.length > cap || value !== value.trim()) throw new Error(`${what} must be trimmed text of at most ${cap} characters`);
|
|
28
|
+
for (const char of value) if (/[\x00-\x1f\x7f]/.test(char) || (char.codePointAt(0)! > 127 && /[\p{P}\p{S}\p{Z}\p{C}]/u.test(char))) throw new Error(`${what} must use plain punctuation and no control characters`);
|
|
29
|
+
return value;
|
|
30
|
+
};
|
|
31
|
+
const object = (value: unknown, keys: string[], what: string): Record<string, unknown> => {
|
|
32
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some(key => !keys.includes(key))) throw new Error(`${what}: unexpected shape or field`);
|
|
33
|
+
return value as Record<string, unknown>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** One pack file. `stem` is the file name without `.pack.json`; the name inside must equal it. */
|
|
37
|
+
export function parsePack(text: string, stem: string, layer: DomainPack["layer"]): DomainPack {
|
|
38
|
+
if (Buffer.byteLength(text) > MAX_BYTES || text.includes("\r") || !NAME.test(stem)) throw new Error("invalid pack size, encoding or file name");
|
|
39
|
+
let raw: unknown;
|
|
40
|
+
try { raw = JSON.parse(text); } catch { throw new Error("pack is not valid JSON"); }
|
|
41
|
+
const data = object(raw, KEYS, "pack");
|
|
42
|
+
if (KEYS.some(key => data[key] === undefined)) throw new Error("pack needs name, profile, summary, roles, rubric and checks");
|
|
43
|
+
if (data.name !== stem) throw new Error("pack name must equal its file name");
|
|
44
|
+
if ((PROFILE_NAMES as readonly string[]).includes(stem)) throw new Error("a pack cannot take a profile's name");
|
|
45
|
+
if (typeof data.profile !== "string" || !(PROFILE_NAMES as readonly string[]).includes(data.profile)) throw new Error(`pack profile must be one of ${PROFILE_NAMES.join(", ")}`);
|
|
46
|
+
const summary = plain(data.summary, 160, "summary");
|
|
47
|
+
if (!Array.isArray(data.roles) || data.roles.length > MAX_ROLES || new Set(data.roles).size !== data.roles.length || data.roles.some(id => typeof id !== "string" || !NAME.test(id))) throw new Error(`pack roles must be at most ${MAX_ROLES} distinct role ids`);
|
|
48
|
+
if (!Array.isArray(data.rubric) || !data.rubric.length || data.rubric.length > MAX_RUBRIC) throw new Error(`pack rubric needs 1 to ${MAX_RUBRIC} criteria`);
|
|
49
|
+
const rubric = data.rubric.map((entry, index) => {
|
|
50
|
+
const item = object(entry, ["id", "text", "required"], `rubric ${index + 1}`);
|
|
51
|
+
if (!criterionId(item.id) || typeof item.required !== "boolean") throw new Error(`rubric ${index + 1} needs a criterion id and a required flag`);
|
|
52
|
+
return { id: item.id, text: plain(item.text, 300, `rubric ${item.id} text`), required: item.required };
|
|
53
|
+
});
|
|
54
|
+
if (new Set(rubric.map(item => item.id)).size !== rubric.length) throw new Error("rubric criterion ids must be distinct");
|
|
55
|
+
if (!Array.isArray(data.checks) || data.checks.length > MAX_CHECKS) throw new Error(`pack checks must be at most ${MAX_CHECKS} entries`);
|
|
56
|
+
const checks = data.checks.map((entry, index) => {
|
|
57
|
+
const item = object(entry, ["id", "note"], `check ${index + 1}`);
|
|
58
|
+
if (typeof item.id !== "string" || !NAME.test(item.id)) throw new Error(`check ${index + 1} needs a check id`);
|
|
59
|
+
return { id: item.id, note: plain(item.note, 500, `check ${item.id} note`) };
|
|
60
|
+
});
|
|
61
|
+
if (new Set(checks.map(item => item.id)).size !== checks.length) throw new Error("check ids must be distinct");
|
|
62
|
+
return { name: stem, profile: data.profile as ProfileName, summary, roles: [...data.roles as string[]], rubric, checks, layer };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Read one bounded regular file without following links, as `loadRoleCatalog` reads a project role. */
|
|
66
|
+
function readPack(file: string): string {
|
|
67
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
68
|
+
try {
|
|
69
|
+
const before = fstatSync(fd);
|
|
70
|
+
if (!before.isFile() || before.nlink !== 1 || before.size > MAX_BYTES) throw new Error("pack must be a bounded regular file");
|
|
71
|
+
const bytes = Buffer.alloc(MAX_BYTES + 1); let length = 0, n = 0;
|
|
72
|
+
while ((n = readSync(fd, bytes, length, bytes.length - length, null)) > 0) { length += n; if (length > MAX_BYTES) throw new Error("pack exceeds file cap"); }
|
|
73
|
+
const after = fstatSync(fd);
|
|
74
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || length !== after.size) throw new Error("pack changed during read");
|
|
75
|
+
return bytes.subarray(0, length).toString("utf8");
|
|
76
|
+
} finally { closeSync(fd); }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Project-layer packs, `.agenthub/roles/<name>.pack.json` (#513a AC4): the same directory the role catalog reads, which ignores these files (it takes `.md` only).
|
|
81
|
+
* A pack whose roles are not all in the catalog (shipped or project) is refused with the reason, never loaded half-usable. Reasons carry ids, never file contents.
|
|
82
|
+
*/
|
|
83
|
+
export function loadPacks(cwd?: string, shippedPacks: Readonly<Record<string, string>> = SHIPPED_PACKS): PackCatalog {
|
|
84
|
+
const packs = new Map<string, DomainPack>(), errors: PackCatalog["errors"] = [];
|
|
85
|
+
// The shipped layer comes first and a project pack of the same name replaces it, as a project role replaces a shipped one. Its roles are checked against the shipped catalog only: it must not depend on a project file.
|
|
86
|
+
const shipped = loadRoleCatalog();
|
|
87
|
+
for (const [id, text] of Object.entries(shippedPacks)) {
|
|
88
|
+
try {
|
|
89
|
+
const pack = parsePack(text, id, "shipped");
|
|
90
|
+
const missing = pack.roles.filter(role => !shipped.roles.has(role));
|
|
91
|
+
if (missing.length) throw new Error(`unknown roles: ${missing.join(", ")}`);
|
|
92
|
+
packs.set(id, pack);
|
|
93
|
+
} catch { errors.push({ id, reason: "invalid shipped pack" }); }
|
|
94
|
+
}
|
|
95
|
+
if (!cwd) return { packs, errors };
|
|
96
|
+
const catalog = loadRoleCatalog(cwd);
|
|
97
|
+
try {
|
|
98
|
+
const config = join(cwd, ".agenthub"), dir = join(config, "roles");
|
|
99
|
+
// lstat, never stat: a link is not a directory to lstat, so a symlinked `.agenthub` or `roles` is refused before anything is read through it.
|
|
100
|
+
for (const path of [config, dir]) if (!lstatSync(path).isDirectory()) throw new Error("unsafe role directory");
|
|
101
|
+
// ponytail: 16 flat pack files, no recursive scan; an explicit pack index before larger collections.
|
|
102
|
+
const files = readdirSync(dir).filter(name => name.endsWith(PACK_SUFFIX)).sort();
|
|
103
|
+
if (files.length > MAX_FILES) throw new Error("too many project packs");
|
|
104
|
+
for (const file of files) {
|
|
105
|
+
const id = file.slice(0, -PACK_SUFFIX.length);
|
|
106
|
+
try {
|
|
107
|
+
const pack = parsePack(readPack(join(dir, file)), id, "project");
|
|
108
|
+
const missing = pack.roles.filter(role => !catalog.roles.has(role));
|
|
109
|
+
if (missing.length) throw new Error(`unknown roles: ${missing.join(", ")}`);
|
|
110
|
+
packs.set(id, pack);
|
|
111
|
+
} catch (error) { errors.push({ id: NAME.test(id) ? id : "invalid-file-id", reason: (error as Error).message.slice(0, 200) }); }
|
|
112
|
+
}
|
|
113
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") errors.push({ id: "project", reason: "project pack directory unavailable or unsafe" }); }
|
|
114
|
+
return { packs, errors };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A stable text of plain JSON data: object keys sorted at every depth, array order kept (a checklist's order is its meaning). Two values with the same data compare equal whatever order their keys were written in. */
|
|
118
|
+
export function canonicalJson(value: unknown): string {
|
|
119
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
120
|
+
if (value && typeof value === "object") return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalJson((value as Record<string, unknown>)[key])}`).join(",")}}`;
|
|
121
|
+
return JSON.stringify(value) ?? "null";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** What a pack contributes to the project's `profile_overrides`: the rubric as the profile's review checklist, the checks as advisory examples. Validated by `resolveProfile`. */
|
|
125
|
+
export function packOverrides(pack: DomainPack): Record<string, unknown> {
|
|
126
|
+
const overrides: Record<string, unknown> = {
|
|
127
|
+
review_checklist: [...pack.rubric.map(item => `${item.id}${item.required ? "" : " (optional)"}: ${item.text}`), "{check}"],
|
|
128
|
+
...(pack.checks.length ? { example_checks: pack.checks.map(check => `${check.id}: ${check.note}`) } : {}),
|
|
129
|
+
};
|
|
130
|
+
resolveProfile(pack.profile, overrides);
|
|
131
|
+
return overrides;
|
|
132
|
+
}
|
package/src/hub/path-policy.ts
CHANGED
|
@@ -2,6 +2,13 @@ import { accessSync, constants, lstatSync, opendirSync, readlinkSync, statSync }
|
|
|
2
2
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { realPath } from "./project.ts";
|
|
4
4
|
import { foldSegment } from "./permission-mode.ts";
|
|
5
|
+
/**
|
|
6
|
+
* Whether `rel`, the result of `relative(root, path)`, names something inside the root: not `..`, not a path that starts with a `..` SEGMENT, and not absolute.
|
|
7
|
+
* A string prefix test is wrong here: `..ignored/f.txt` is a valid directory named `..ignored` inside the project (#552), while `../f.txt` leaves it.
|
|
8
|
+
* An empty `rel` is the root itself, which is inside; callers that need a file name check for it themselves.
|
|
9
|
+
*/
|
|
10
|
+
export const insideRoot = (rel: string): boolean => rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
11
|
+
|
|
5
12
|
/** Bounded project-relative exact paths/subtrees or *, ? and whole-segment ** globs. */
|
|
6
13
|
export function policyPaths(raw: unknown, field: string): string[] {
|
|
7
14
|
if (raw === undefined) return [];
|
|
@@ -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
|
+
}
|