@staix/agent-hub 0.25.0 → 0.26.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 +10 -0
- package/README.md +10 -5
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +1 -1
- package/docs/agent-notes/daemon.md +6 -4
- package/docs/agent-notes/tasks.md +5 -4
- package/docs/agent-notes/tests.md +1 -0
- package/docs/dogfood.md +4 -0
- package/docs/operations.md +131 -14
- package/docs/quickstart.md +14 -4
- package/docs/security.md +51 -8
- package/docs/specs/2026-11-01-research-campaigns-design.md +9 -6
- package/docs/verification/2026-11-01-0.26.0.md +54 -0
- package/docs/verified.json +28 -16
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +182 -95
- package/src/adapters/channel-peer.ts +4 -2
- package/src/adapters/definitions/builtin-metadata.ts +6 -6
- package/src/adapters/definitions/qwen.ts +2 -0
- package/src/adapters/registry-types.ts +2 -0
- package/src/adapters/registry.ts +1 -0
- package/src/cli/conductor-recovery.ts +3 -2
- package/src/cli/help.ts +7 -7
- package/src/cli/main.ts +62 -23
- package/src/cli/recovery-package.ts +94 -4
- package/src/cli/reset.ts +14 -4
- package/src/cli/runtime-version-warning.ts +28 -0
- package/src/cli/start-agents-run.ts +125 -0
- package/src/cli/task-output.ts +5 -0
- package/src/cli/upgrade-interactive.ts +4 -4
- package/src/cli/upgrade-runtime.ts +105 -57
- package/src/cli/upgrade.ts +90 -28
- package/src/hub/autonomy-defaults.ts +32 -7
- package/src/hub/board.ts +129 -13
- package/src/hub/budget.ts +3 -4
- package/src/hub/child-process.ts +4 -3
- package/src/hub/conductor.ts +3 -4
- package/src/hub/config-apply.ts +13 -2
- package/src/hub/config-edit-host.ts +1 -0
- package/src/hub/config-tier.ts +3 -0
- package/src/hub/config-trust.ts +10 -2
- package/src/hub/control-client.ts +30 -10
- package/src/hub/daemon.ts +82 -17
- package/src/hub/db-schema.ts +143 -0
- package/src/hub/delivery-journal.ts +3 -4
- package/src/hub/execution-budget.ts +3 -4
- package/src/hub/folder-trigger.ts +3 -4
- package/src/hub/invocations.ts +3 -4
- package/src/hub/lifecycle.ts +87 -29
- package/src/hub/manager.ts +49 -33
- package/src/hub/orca-env.ts +19 -0
- package/src/hub/project-runtime-state.ts +139 -0
- package/src/hub/project-runtime.ts +62 -0
- package/src/hub/protocol-window.ts +19 -0
- package/src/hub/recovery-store.ts +102 -12
- package/src/hub/registry.ts +5 -0
- package/src/hub/research-experiments.ts +33 -4
- package/src/hub/research-runner.ts +172 -0
- package/src/hub/restart.ts +9 -0
- package/src/hub/runtime-capture-prune.ts +415 -0
- package/src/hub/runtime-prune-health.ts +112 -0
- package/src/hub/schedules.ts +3 -4
- package/src/hub/sdlc-merge.ts +7 -0
- package/src/hub/sdlc-queue.ts +13 -1
- package/src/hub/settings.ts +4 -0
- package/src/hub/snapshots.ts +3 -4
- package/src/hub/start-agents.ts +44 -0
- package/src/hub/task-approval.ts +23 -0
- package/src/hub/tasks.ts +133 -34
- package/src/hub/usage-rollup.ts +3 -4
- package/src/pi/tool-receipts.ts +3 -4
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { closeSync, constants, existsSync, fstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, normalize, relative, sep } from "node:path";
|
|
4
|
+
import type { Project } from "./registry.ts";
|
|
5
|
+
import { configRepositoryPresent } from "./config-trust.ts";
|
|
6
|
+
import { childEnv } from "./child-process.ts";
|
|
7
|
+
import { hubGitSync } from "./git.ts";
|
|
8
|
+
|
|
9
|
+
export type RuntimeProject = Pick<Project, "id" | "root" | "stateDir">;
|
|
10
|
+
export interface ProjectRuntimeSelection { version: string; root: string; digest: string; kind: "restart" | "upgrade" }
|
|
11
|
+
export interface ProjectRuntimeIdentity extends ProjectRuntimeSelection {
|
|
12
|
+
schema: 1; projectId: string; projectRoot: string; stateDir: string; instanceId: string | null;
|
|
13
|
+
}
|
|
14
|
+
export const PROJECT_RUNTIME_FILE = "runtime.json";
|
|
15
|
+
const LIMIT = 16 * 1024;
|
|
16
|
+
const version = (v: unknown): v is string => typeof v === "string" && v.length <= 64 && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.exec(v)?.[0] === v;
|
|
17
|
+
const pathText = (v: unknown): v is string => typeof v === "string" && v.length <= 4096 && isAbsolute(v) && normalize(v) === v && !/[\x00-\x1f\x7f]/.test(v);
|
|
18
|
+
export const projectRuntimeFile = (project: RuntimeProject): string => join(project.stateDir, PROJECT_RUNTIME_FILE);
|
|
19
|
+
const invalid = (): Error => new Error("project runtime record is invalid; inspect runtime.json before starting");
|
|
20
|
+
|
|
21
|
+
export function parseProjectRuntime(project: RuntimeProject, input: unknown): ProjectRuntimeIdentity {
|
|
22
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw invalid();
|
|
23
|
+
const row = input as Record<string, unknown>;
|
|
24
|
+
if (Object.keys(row).sort().join(",") !== "digest,instanceId,kind,projectId,projectRoot,root,schema,stateDir,version"
|
|
25
|
+
|| row.schema !== 1 || row.projectId !== project.id || row.projectRoot !== project.root || row.stateDir !== project.stateDir
|
|
26
|
+
|| !version(row.version) || !pathText(row.root) || typeof row.digest !== "string" || row.digest.length !== 64 || !/^[a-f0-9]{64}$/.test(row.digest)
|
|
27
|
+
|| row.kind !== "restart" && row.kind !== "upgrade"
|
|
28
|
+
|| row.instanceId !== null && (typeof row.instanceId !== "string" || /^[a-zA-Z0-9-]{1,128}$/.exec(row.instanceId)?.[0] !== row.instanceId)) throw invalid();
|
|
29
|
+
return { schema: 1, projectId: project.id, projectRoot: project.root, stateDir: project.stateDir,
|
|
30
|
+
version: row.version, root: row.root, digest: row.digest, kind: row.kind, instanceId: row.instanceId as string | null };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ACCESS = Symbol("project runtime access");
|
|
34
|
+
export interface ProjectRuntimeAccess {
|
|
35
|
+
readonly [ACCESS]: true;
|
|
36
|
+
readonly projectId: string; readonly projectRoot: string; readonly stateDir: string;
|
|
37
|
+
read(): ProjectRuntimeIdentity | undefined;
|
|
38
|
+
write(runtime: ProjectRuntimeSelection, instanceId: string | null): ProjectRuntimeIdentity;
|
|
39
|
+
}
|
|
40
|
+
const TRUST_GIT_MS = 2000;
|
|
41
|
+
const exactGlob = (value: string) => value.replace(/([*?[\]\\])/g, "\\$1");
|
|
42
|
+
const trustRefusal = (why: string): never => { throw new Error(`project runtime selection is machine-local: ${why}`); };
|
|
43
|
+
|
|
44
|
+
/** Plain folders remain supported. A repository must affirm this file is untracked using only its parent/ancestor paths. */
|
|
45
|
+
function requireRuntimeTrust(project: RuntimeProject): void {
|
|
46
|
+
const file = projectRuntimeFile(project), checked = new Set<string>(), deadline = performance.now() + TRUST_GIT_MS;
|
|
47
|
+
const git = (args: string[]) => {
|
|
48
|
+
const remaining = deadline - performance.now();
|
|
49
|
+
if (remaining <= 0) trustRefusal("git could not confirm runtime.json is untracked");
|
|
50
|
+
return hubGitSync(args, { encoding: "utf8", env: childEnv(process.env), timeout: Math.max(1, Math.ceil(remaining)), maxBuffer: 256 * 1024 });
|
|
51
|
+
};
|
|
52
|
+
for (const cwd of new Set([project.root, dirname(file)])) {
|
|
53
|
+
if (!configRepositoryPresent(cwd)) continue;
|
|
54
|
+
let at = cwd;
|
|
55
|
+
while (!existsSync(at) && dirname(at) !== at) at = dirname(at);
|
|
56
|
+
const top = git(["-C", at, "rev-parse", "--show-toplevel"]);
|
|
57
|
+
if (top.status !== 0 || !isAbsolute(top.stdout.trim())) trustRefusal("git could not confirm runtime.json is untracked");
|
|
58
|
+
const root = top.stdout.trim(), selected = relative(root, file).split(sep).join("/");
|
|
59
|
+
if (checked.has(root) || selected === ".." || selected.startsWith("../") || isAbsolute(selected)) continue;
|
|
60
|
+
checked.add(root);
|
|
61
|
+
const parts = selected.split("/"), parent = parts.slice(0, -1).join("/");
|
|
62
|
+
// ponytail: explicit custom stateDir ancestor listings can exceed 256 KiB (default unaffected); use an untracked dedicated state directory, then upgrade to exact ancestor index lookup.
|
|
63
|
+
const specs = [`:(icase,literal)${parent || selected}`];
|
|
64
|
+
for (let i = 1; i < parts.length - 1; i++) specs.push(`:(icase,glob)${exactGlob(parts.slice(0, i).join("/"))}`);
|
|
65
|
+
const tracked = git(["-C", root, "ls-files", "-s", "-z", "--", ...specs]);
|
|
66
|
+
if (tracked.status !== 0) trustRefusal("git could not confirm runtime.json is untracked");
|
|
67
|
+
let opened: { dev: number; ino: number } | undefined;
|
|
68
|
+
try { opened = statSync(file); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") trustRefusal("runtime.json identity is unavailable"); }
|
|
69
|
+
for (const row of tracked.stdout.split("\0").filter(Boolean)) {
|
|
70
|
+
const name = row.slice(row.indexOf("\t") + 1);
|
|
71
|
+
if (name === selected || selected.startsWith(`${name}/`)) trustRefusal("runtime.json or its parent is committed to git");
|
|
72
|
+
if (opened) {
|
|
73
|
+
let info; try { info = statSync(join(root, name)); } catch { continue; }
|
|
74
|
+
if (info.dev === opened.dev && info.ino === opened.ino) trustRefusal("runtime.json is committed to git");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** This reserved sibling namespace is exclusively for captures withdrawn for pruning. */
|
|
81
|
+
export function assertExecutableRuntimeRoot(root: string): void {
|
|
82
|
+
for (let at = root; ; at = dirname(at)) {
|
|
83
|
+
if (basename(at).startsWith(".runtime-prune-")) throw new Error("runtime root is quarantined for pruning");
|
|
84
|
+
if (dirname(at) === at) return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Host-only, project-bound admission for one call; callers create it before acquiring the registry lock. */
|
|
89
|
+
export function createProjectRuntimeAccess(project: RuntimeProject): ProjectRuntimeAccess {
|
|
90
|
+
requireRuntimeTrust(project);
|
|
91
|
+
const bound = { id: project.id, root: project.root, stateDir: project.stateDir };
|
|
92
|
+
return Object.freeze({ [ACCESS]: true as const, projectId: bound.id, projectRoot: bound.root, stateDir: bound.stateDir,
|
|
93
|
+
read: () => readProjectRuntimeReference(bound), write: (runtime: ProjectRuntimeSelection, instanceId: string | null) => writeRuntimeState(bound, runtime, instanceId) });
|
|
94
|
+
}
|
|
95
|
+
function accessFor(project: RuntimeProject, access?: ProjectRuntimeAccess): ProjectRuntimeAccess {
|
|
96
|
+
if (!access) return createProjectRuntimeAccess(project);
|
|
97
|
+
if (access[ACCESS] !== true || access.projectId !== project.id || access.projectRoot !== project.root || access.stateDir !== project.stateDir)
|
|
98
|
+
throw new Error("project runtime access belongs to another project");
|
|
99
|
+
return access;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function readProjectRuntime(project: RuntimeProject, access?: ProjectRuntimeAccess): ProjectRuntimeIdentity | undefined {
|
|
103
|
+
return accessFor(project, access).read();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Metadata-only retention reference: bounded binding/JSON checks confer no Git trust or execution authority. */
|
|
107
|
+
export function readProjectRuntimeReference(project: RuntimeProject): ProjectRuntimeIdentity | undefined {
|
|
108
|
+
let fd: number;
|
|
109
|
+
try { fd = openSync(projectRuntimeFile(project), constants.O_RDONLY | constants.O_NOFOLLOW); }
|
|
110
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw invalid(); }
|
|
111
|
+
try {
|
|
112
|
+
const before = fstatSync(fd);
|
|
113
|
+
if (!before.isFile() || before.nlink !== 1 || before.size > LIMIT) throw invalid();
|
|
114
|
+
const bytes = Buffer.alloc(LIMIT + 1);
|
|
115
|
+
const count = readSync(fd, bytes, 0, bytes.length, 0), after = fstatSync(fd);
|
|
116
|
+
if (count > LIMIT || count !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs) throw invalid();
|
|
117
|
+
return parseProjectRuntime(project, JSON.parse(bytes.subarray(0, count).toString("utf8")));
|
|
118
|
+
} catch { throw invalid(); }
|
|
119
|
+
finally { closeSync(fd); }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Caller holds the shared runtime-capture lock until publication and its launch/reference effects finish. */
|
|
123
|
+
function writeRuntimeState(project: RuntimeProject, runtime: ProjectRuntimeSelection, instanceId: string | null): ProjectRuntimeIdentity {
|
|
124
|
+
const record = parseProjectRuntime(project, { schema: 1, projectId: project.id, projectRoot: project.root, stateDir: project.stateDir,
|
|
125
|
+
version: runtime.version, root: runtime.root, digest: runtime.digest, kind: runtime.kind, instanceId });
|
|
126
|
+
assertExecutableRuntimeRoot(record.root);
|
|
127
|
+
readProjectRuntimeReference(project); // refuse to overwrite an unknown binding or an unsafe file
|
|
128
|
+
const body = JSON.stringify(record);
|
|
129
|
+
if (Buffer.byteLength(body) > LIMIT) throw invalid();
|
|
130
|
+
mkdirSync(project.stateDir, { recursive: true });
|
|
131
|
+
const file = projectRuntimeFile(project), temporary = `${file}.${randomUUID()}.tmp`;
|
|
132
|
+
try { writeFileSync(temporary, body, { mode: 0o600, flag: "wx" }); renameSync(temporary, file); }
|
|
133
|
+
finally { rmSync(temporary, { force: true }); }
|
|
134
|
+
return record;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function writeProjectRuntimeState(project: RuntimeProject, runtime: ProjectRuntimeSelection, instanceId: string | null, access?: ProjectRuntimeAccess): ProjectRuntimeIdentity {
|
|
138
|
+
return accessFor(project, access).write(runtime, instanceId);
|
|
139
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { stageCurrentRuntime, verifyRecoveryTarget, newerVersion, exactVersion } from "../cli/recovery-package.ts";
|
|
3
|
+
import { hubHome, realPath } from "./project.ts";
|
|
4
|
+
import { Registry, type Project } from "./registry.ts";
|
|
5
|
+
import { processLiveness } from "../pi/process-signature.ts";
|
|
6
|
+
import { assertExecutableRuntimeRoot, createProjectRuntimeAccess, readProjectRuntime, writeProjectRuntimeState, type ProjectRuntimeAccess, type ProjectRuntimeIdentity, type ProjectRuntimeSelection } from "./project-runtime-state.ts";
|
|
7
|
+
export { readProjectRuntime } from "./project-runtime-state.ts";
|
|
8
|
+
export type { ProjectRuntimeIdentity, ProjectRuntimeSelection } from "./project-runtime-state.ts";
|
|
9
|
+
|
|
10
|
+
export const projectRuntimeRefusal = (version: string, detail?: string): Error => new Error(`this project runs ${version}; use ${version} or later / ahub upgrade${detail ? ` (${detail})` : ""}`);
|
|
11
|
+
|
|
12
|
+
export function verifyProjectRuntime(runtime: ProjectRuntimeSelection): ProjectRuntimeSelection {
|
|
13
|
+
assertExecutableRuntimeRoot(runtime.root);
|
|
14
|
+
try {
|
|
15
|
+
if (realPath(runtime.root) !== runtime.root || verifyRecoveryTarget(runtime.kind, runtime.root, runtime.version) !== runtime.digest)
|
|
16
|
+
throw new Error("runtime changed");
|
|
17
|
+
return runtime;
|
|
18
|
+
} catch { throw projectRuntimeRefusal(runtime.version, "recorded runtime is missing or changed"); }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Read-only caller admission also covers an already-running daemon; no capture is staged or hashed here. */
|
|
22
|
+
export function readProjectRuntimeForCaller(project: Project, invokingVersion: string, access?: ProjectRuntimeAccess): ProjectRuntimeIdentity | undefined {
|
|
23
|
+
exactVersion(invokingVersion);
|
|
24
|
+
const retained = readProjectRuntime(project, access);
|
|
25
|
+
// ponytail: only updated resolver code honors runtime.json; bootstrap shared CLI to 0.26.0+ before relying on this floor.
|
|
26
|
+
if (retained && newerVersion(retained.version, invokingVersion)) throw projectRuntimeRefusal(retained.version);
|
|
27
|
+
return retained;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Selectors first ship in 0.26.0; unmodified earlier published CLIs cannot honor this record. */
|
|
31
|
+
export function resolveProjectRuntime(project: Project, invokingVersion: string, sourceRoot = resolve(import.meta.dir, "../.."), registry?: Registry, access?: ProjectRuntimeAccess): ProjectRuntimeSelection {
|
|
32
|
+
const retained = readProjectRuntimeForCaller(project, invokingVersion, access);
|
|
33
|
+
if (retained) return verifyProjectRuntime(retained);
|
|
34
|
+
const staged = stageCurrentRuntime(sourceRoot, invokingVersion, hubHome(), registry);
|
|
35
|
+
return { ...staged, kind: "restart" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function recordProjectRuntime(project: Project, runtime: ProjectRuntimeSelection, instanceId: string | null, registry: Registry, access?: ProjectRuntimeAccess): void {
|
|
39
|
+
const admitted = access ?? createProjectRuntimeAccess(project);
|
|
40
|
+
registry.runtimeCaptureLocked(() => {
|
|
41
|
+
const current = registry.get(project.id);
|
|
42
|
+
if (!current || current.root !== project.root || current.stateDir !== project.stateDir
|
|
43
|
+
|| instanceId !== null && current.instanceId !== instanceId
|
|
44
|
+
|| instanceId === null && current.instanceId && processLiveness(current.pid, current.pidSignature) !== "gone")
|
|
45
|
+
throw new Error("project runtime owner changed; refresh before starting");
|
|
46
|
+
verifyProjectRuntime(runtime);
|
|
47
|
+
writeProjectRuntimeState(project, runtime, instanceId, admitted);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A null instance is a pending selection, never consent to replace a later capture choice. */
|
|
52
|
+
export function confirmProjectRuntime(project: Project, runtime: ProjectRuntimeSelection, observed: { version?: unknown; instanceId?: unknown }, registry: Registry, access?: ProjectRuntimeAccess): void {
|
|
53
|
+
const admitted = access ?? createProjectRuntimeAccess(project);
|
|
54
|
+
registry.runtimeCaptureLocked(() => {
|
|
55
|
+
const saved = readProjectRuntime(project, admitted);
|
|
56
|
+
if (!saved || saved.version !== runtime.version || saved.root !== runtime.root || saved.digest !== runtime.digest || saved.kind !== runtime.kind
|
|
57
|
+
|| observed.version !== runtime.version || typeof observed.instanceId !== "string" || !observed.instanceId
|
|
58
|
+
|| saved.instanceId !== null && saved.instanceId !== observed.instanceId)
|
|
59
|
+
throw projectRuntimeRefusal(saved?.version ?? runtime.version, "runtime selection changed before readiness");
|
|
60
|
+
recordProjectRuntime(project, runtime, observed.instanceId, registry, admitted);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Inclusive supported control-wire interval; v is its highest version. */
|
|
2
|
+
export interface ProtocolWindow { v: number; vMin: number }
|
|
3
|
+
|
|
4
|
+
/** Older peers without a minimum advertise only their one explicit version. */
|
|
5
|
+
export function protocolWindow(v: unknown, vMin?: unknown): ProtocolWindow | undefined {
|
|
6
|
+
if (typeof v !== "number" || !Number.isSafeInteger(v) || v <= 0) return undefined;
|
|
7
|
+
const minimum = vMin === undefined ? v : vMin;
|
|
8
|
+
if (typeof minimum !== "number" || !Number.isSafeInteger(minimum) || minimum <= 0 || minimum > v) return undefined;
|
|
9
|
+
return { v, vMin: minimum };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Choose the highest common version. Invalid intervals and disjoint ranges negotiate nothing. */
|
|
13
|
+
export function negotiateProtocol(local: ProtocolWindow, remote: ProtocolWindow): number | undefined {
|
|
14
|
+
if (!local || typeof local !== "object" || Array.isArray(local) || !remote || typeof remote !== "object" || Array.isArray(remote)) return undefined;
|
|
15
|
+
const a = protocolWindow(local.v, local.vMin), b = protocolWindow(remote.v, remote.vMin);
|
|
16
|
+
if (!a || !b) return undefined;
|
|
17
|
+
const highest = Math.min(a.v, b.v);
|
|
18
|
+
return highest >= Math.max(a.vMin, b.vMin) ? highest : undefined;
|
|
19
|
+
}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { Database } from "bun:sqlite";
|
|
5
|
-
import { hubHome } from "./project.ts";
|
|
5
|
+
import { canonicalPath, hubHome } from "./project.ts";
|
|
6
|
+
import { isDatabaseBusy } from "./recovery-error.ts";
|
|
6
7
|
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
7
8
|
|
|
8
9
|
const RUNNER_DB_BUSY_TIMEOUT_MS = 3000;
|
|
9
10
|
export const operationIdPattern = /^[a-f0-9-]{36}$/;
|
|
10
11
|
export function operationPath(id: string, home = hubHome()): string {
|
|
11
|
-
if (!operationIdPattern.test(id)) throw new Error("invalid recovery operation ID");
|
|
12
|
+
if (typeof id !== "string" || id.length !== 36 || !operationIdPattern.test(id)) throw new Error("invalid recovery operation ID");
|
|
12
13
|
return join(home, "recovery", `${id}.json`);
|
|
13
14
|
}
|
|
14
15
|
|
|
@@ -31,11 +32,11 @@ export function writeOperation(id: string, value: unknown, home = hubHome()): vo
|
|
|
31
32
|
atomicPrivateJSON(operationPath(id, home), value);
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
function legacyRecoveryLock(home: string): string | undefined {
|
|
35
36
|
const file = join(home, "recovery.lock");
|
|
36
37
|
try {
|
|
37
38
|
const data = JSON.parse(readFileSync(file, "utf8"));
|
|
38
|
-
if (!operationIdPattern.test(data.operationId)) throw new Error("invalid recovery lock");
|
|
39
|
+
if (typeof data.operationId !== "string" || data.operationId.length !== 36 || !operationIdPattern.test(data.operationId)) throw new Error("invalid recovery lock");
|
|
39
40
|
return data.operationId;
|
|
40
41
|
} catch (error) {
|
|
41
42
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
@@ -43,23 +44,112 @@ export function recoveryLock(home = hubHome()): string | undefined {
|
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
const projectLocksPath = (home: string) => join(home, "recovery", "project-locks.db");
|
|
48
|
+
const projectLockError = () => new Error("project recovery coordination state is unreadable; inspect it before changing runtimes");
|
|
49
|
+
function recoveryProjectKey(root: string): string {
|
|
50
|
+
if (typeof root !== "string" || !isAbsolute(root) || root.includes(String.fromCharCode(0))) throw new Error("recovery scope needs a canonical project root");
|
|
51
|
+
try { return canonicalPath(root); }
|
|
52
|
+
catch (error) {
|
|
53
|
+
// Receipts retain the already-canonical registration spelling so a removed project can still be disposed.
|
|
54
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return resolve(root);
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function projectLocksExist(file: string): boolean {
|
|
59
|
+
try {
|
|
60
|
+
const info = lstatSync(file);
|
|
61
|
+
if (!info.isFile() || info.isSymbolicLink()) throw projectLockError();
|
|
62
|
+
return true;
|
|
63
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
|
|
64
|
+
}
|
|
65
|
+
function projectLocks(home: string, create = false): Database | undefined {
|
|
66
|
+
const file = projectLocksPath(home);
|
|
67
|
+
if (!projectLocksExist(file)) {
|
|
68
|
+
if (!create) return undefined;
|
|
69
|
+
mkdirSync(join(home, "recovery"), { recursive: true, mode: 0o700 });
|
|
70
|
+
// Publish a complete DELETE-journal database, never an empty coordination file another process could misread.
|
|
71
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
72
|
+
try {
|
|
73
|
+
const db = new Database(temporary, { create: true });
|
|
74
|
+
try {
|
|
75
|
+
db.run(`PRAGMA busy_timeout = ${RUNNER_DB_BUSY_TIMEOUT_MS}`);
|
|
76
|
+
db.run("CREATE TABLE project_locks (project_root TEXT PRIMARY KEY, operation_id TEXT NOT NULL)");
|
|
77
|
+
} finally { db.close(); }
|
|
78
|
+
chmodSync(temporary, 0o600);
|
|
79
|
+
try { linkSync(temporary, file); }
|
|
80
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
81
|
+
} finally { rmSync(temporary, { force: true }); }
|
|
82
|
+
}
|
|
83
|
+
if (!projectLocksExist(file)) throw projectLockError();
|
|
84
|
+
let db: Database | undefined;
|
|
85
|
+
try {
|
|
86
|
+
db = new Database(file, create ? { readwrite: true, create: false } : { readonly: true, create: false });
|
|
87
|
+
db.run(`PRAGMA busy_timeout = ${RUNNER_DB_BUSY_TIMEOUT_MS}`);
|
|
88
|
+
if (!db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name='project_locks'").get()) throw projectLockError();
|
|
89
|
+
return db;
|
|
90
|
+
} catch (error) { db?.close(); if (isDatabaseBusy(error)) throw error; throw projectLockError(); }
|
|
91
|
+
}
|
|
92
|
+
function projectLockOn(db: Database, key: string): string | undefined {
|
|
93
|
+
const rows = db.query("SELECT CASE WHEN typeof(operation_id)='text' AND length(operation_id)=36 THEN operation_id END AS id FROM project_locks WHERE project_root=? LIMIT 2").all(key) as { id: string | null }[];
|
|
94
|
+
if (!rows.length) return undefined;
|
|
95
|
+
if (rows.length !== 1 || typeof rows[0]!.id !== "string" || !operationIdPattern.test(rows[0]!.id)) throw projectLockError();
|
|
96
|
+
return rows[0]!.id;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Legacy machine holds remain authoritative; new project holds affect only their canonical project. */
|
|
100
|
+
export function recoveryLock(home = hubHome(), projectRoot?: string): string | undefined {
|
|
101
|
+
const legacy = legacyRecoveryLock(home);
|
|
102
|
+
if (legacy || projectRoot === undefined) return legacy;
|
|
103
|
+
const key = recoveryProjectKey(projectRoot), db = projectLocks(home);
|
|
104
|
+
if (!db) return undefined;
|
|
105
|
+
try { return projectLockOn(db, key); }
|
|
106
|
+
finally { db.close(); }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function acquireRecoveryLock(id: string, home = hubHome(), projectRoot?: string): void {
|
|
47
110
|
operationPath(id, home);
|
|
111
|
+
if (projectRoot !== undefined) {
|
|
112
|
+
const key = recoveryProjectKey(projectRoot), legacy = legacyRecoveryLock(home);
|
|
113
|
+
if (legacy && legacy !== id) throw new Error(`another recovery operation owns this machine: ${legacy}`);
|
|
114
|
+
const db = projectLocks(home, true)!;
|
|
115
|
+
try {
|
|
116
|
+
db.transaction(() => {
|
|
117
|
+
const existing = projectLockOn(db, key);
|
|
118
|
+
if (existing && existing !== id) throw new Error(`another recovery operation owns this project: ${existing}`);
|
|
119
|
+
if (!existing) db.query("INSERT INTO project_locks(project_root,operation_id) VALUES(?,?)").run(key, id);
|
|
120
|
+
}).immediate();
|
|
121
|
+
} finally { db.close(); }
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
48
124
|
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
49
125
|
const existing = recoveryLock(home);
|
|
50
126
|
if (existing === id) return;
|
|
51
|
-
|
|
52
|
-
|
|
127
|
+
const temporary = join(home, `.recovery-lock-${randomUUID()}.tmp`);
|
|
128
|
+
try {
|
|
129
|
+
writeFileSync(temporary, JSON.stringify({ operationId: id }), { mode: 0o600, flag: "wx" });
|
|
130
|
+
try { linkSync(temporary, join(home, "recovery.lock")); }
|
|
131
|
+
catch { throw new Error(`another recovery operation owns this machine: ${recoveryLock(home) ?? "unknown"}`); }
|
|
132
|
+
} finally { rmSync(temporary, { force: true }); }
|
|
53
133
|
}
|
|
54
134
|
|
|
55
|
-
export function releaseRecoveryLock(id: string, home = hubHome()): void {
|
|
135
|
+
export function releaseRecoveryLock(id: string, home = hubHome(), projectRoot?: string): void {
|
|
136
|
+
operationPath(id, home);
|
|
137
|
+
if (projectRoot !== undefined) {
|
|
138
|
+
const key = recoveryProjectKey(projectRoot), db = projectLocks(home, true)!;
|
|
139
|
+
try {
|
|
140
|
+
if (db.query("DELETE FROM project_locks WHERE project_root=? AND operation_id=?").run(key, id).changes !== 1)
|
|
141
|
+
throw new Error("recovery lock ownership changed");
|
|
142
|
+
} finally { db.close(); }
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// Legacy release is called under the operation's exclusive runner claim. Project releases compare/delete atomically.
|
|
56
146
|
if (recoveryLock(home) !== id) throw new Error("recovery lock ownership changed");
|
|
57
147
|
rmSync(join(home, "recovery.lock"));
|
|
58
148
|
}
|
|
59
149
|
|
|
60
150
|
/** Used by CLI and manager lifecycle paths. A detached operation carries its own ID. */
|
|
61
|
-
export function assertLifecycleAvailable(): void {
|
|
62
|
-
const owner = recoveryLock();
|
|
151
|
+
export function assertLifecycleAvailable(projectRoot?: string): void {
|
|
152
|
+
const owner = recoveryLock(undefined, projectRoot);
|
|
63
153
|
if (owner && process.env.AGENTHUB_RECOVERY_OPERATION !== owner) throw new Error(activeOperation(owner));
|
|
64
154
|
}
|
|
65
155
|
|
package/src/hub/registry.ts
CHANGED
|
@@ -106,6 +106,11 @@ export class Registry {
|
|
|
106
106
|
return (this.db.query(`SELECT ${PROJECT_COLUMNS}, pid_signature FROM projects ORDER BY root`).all() as Record<string, unknown>[]).map(projectFromRow);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/** Serialize capture publication, durable project references and pruning with registry writers. */
|
|
110
|
+
runtimeCaptureLocked<T>(fn: () => T): T {
|
|
111
|
+
return this.db.inTransaction ? fn() : this.db.transaction(fn).immediate();
|
|
112
|
+
}
|
|
113
|
+
|
|
109
114
|
allocate(root: string): number {
|
|
110
115
|
return this.register(root).basePort;
|
|
111
116
|
}
|
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { types } from "node:util";
|
|
3
3
|
import { looksSecret } from "@staix/agent-mem";
|
|
4
4
|
import { canonicalJson } from "./packs.ts";
|
|
5
|
+
import { parseAttemptReceipt, runnerSealing, type AttemptReceiptV1 } from "./research-runner.ts";
|
|
5
6
|
import { parseDeliverableClaims, parseDeliverableRecords, deliverableId, DELIVERABLE_LIMITS, type DeliverableClaim, type DeliverableRecord } from "./deliverables.ts";
|
|
6
7
|
|
|
7
8
|
export type ExperimentScalar = string | number | boolean | null;
|
|
@@ -46,6 +47,10 @@ export type DeclaredExperimentEventInput =
|
|
|
46
47
|
| { kind: "note"; body: { text: string } };
|
|
47
48
|
/** Host supplies ordering/time; this T1 parser never admits caller-written evidence elevation. */
|
|
48
49
|
export type DeclaredExperimentEvent = DeclaredExperimentEventInput & { seq: number; at: number; provenance: "declared" };
|
|
50
|
+
export interface RunnerAttestationEvent { kind: "attestation"; body: { receiptId: string }; seq: number; at: number; provenance: "runner-attested" }
|
|
51
|
+
export type ExperimentEvent = DeclaredExperimentEvent | RunnerAttestationEvent;
|
|
52
|
+
export const declaredExperimentEvents = (events: readonly ExperimentEvent[]): DeclaredExperimentEvent[] =>
|
|
53
|
+
events.filter((event): event is DeclaredExperimentEvent => event.provenance === "declared");
|
|
49
54
|
export type ExperimentStatus = "declared" | "running" | "succeeded" | "failed" | "cancelled" | "unknown";
|
|
50
55
|
|
|
51
56
|
const ID = /^[a-z0-9][a-z0-9_-]{0,63}$/, HASH = /^[0-9a-f]{64}$/, SOURCE = /^src:[0-9a-f]{16}$/;
|
|
@@ -302,12 +307,13 @@ export function deliverableObservation(record: DeliverableRecord): ExperimentHos
|
|
|
302
307
|
}
|
|
303
308
|
|
|
304
309
|
/** Keep conflicting declarations and measurements side by side; neither changes the attempt's declared terminal event. */
|
|
305
|
-
export function projectExperimentWithObservations(protocol: ExperimentProtocolContract, events: readonly
|
|
306
|
-
const
|
|
310
|
+
export function projectExperimentWithObservations(protocol: ExperimentProtocolContract, events: readonly ExperimentEvent[], observations: readonly { eventSeq: number; observation: ExperimentHostEvidence }[]) {
|
|
311
|
+
const declaredEvents = declaredExperimentEvents(events);
|
|
312
|
+
const declared = projectExperimentEvidence(protocol, declaredEvents), seqs = new Set(declaredEvents.map(event => event.seq));
|
|
307
313
|
return { declared, measured: observations.map(row => {
|
|
308
314
|
integer(row.eventSeq, "observation.eventSeq", 1);
|
|
309
315
|
if (!seqs.has(row.eventSeq)) fail("observation.eventSeq", "declared event is unavailable");
|
|
310
|
-
const observation = parseExperimentHostEvidence(row.observation), event =
|
|
316
|
+
const observation = parseExperimentHostEvidence(row.observation), event = declaredEvents.find(event => event.seq === row.eventSeq)!;
|
|
311
317
|
if (observation.source === "check" && (event.kind !== "end" || event.body.run !== observation.evidence)) fail("observation", "check evidence must name its declared end run");
|
|
312
318
|
if (observation.source === "deliverable" && (event.kind !== "output" || !("path" in event.body) || deliverableId(event.body.path, event.body.format) !== observation.evidence)) fail("observation", "output evidence must match a host hash request");
|
|
313
319
|
return { eventSeq: row.eventSeq, source: observation.source, evidence: observation.evidence,
|
|
@@ -315,6 +321,29 @@ export function projectExperimentWithObservations(protocol: ExperimentProtocolCo
|
|
|
315
321
|
}) };
|
|
316
322
|
}
|
|
317
323
|
|
|
324
|
+
export interface RunnerReceiptProjection {
|
|
325
|
+
receiptId: string;
|
|
326
|
+
runner: string;
|
|
327
|
+
provenance: "runner-attested";
|
|
328
|
+
values: Record<string, ExperimentValue<unknown>>;
|
|
329
|
+
sealing: ExperimentValue<boolean>;
|
|
330
|
+
codeMatch: ExperimentValue<boolean>;
|
|
331
|
+
}
|
|
332
|
+
const RUNNER_RECEIPT_FIELDS = ["codeCommit", "imageDigest", "containers", "mounts", "network", "resourceLimits", "startedAt", "endedAt", "exitStatus", "outputs", "metricsFile"] as const;
|
|
333
|
+
const runnerAttested = <T>(value: T | undefined, evidence: string): ExperimentValue<T> => value === undefined
|
|
334
|
+
? { value: null, provenance: "unknown" } : { value, provenance: "runner-attested", evidence };
|
|
335
|
+
|
|
336
|
+
/** Project stored signature-verified receipts only; this view authenticates nothing and changes no declared or host-observed evidence. */
|
|
337
|
+
export function projectRunnerReceipt(protocol: ExperimentProtocolContract, receipt: AttemptReceiptV1): RunnerReceiptProjection {
|
|
338
|
+
const parsed = parseAttemptReceipt(receipt);
|
|
339
|
+
if ("commit" in protocol.code && parsed.codeCommit !== undefined && parsed.codeCommit !== protocol.code.commit)
|
|
340
|
+
fail("receipt.codeCommit", "protocol commit mismatch");
|
|
341
|
+
return { receiptId: parsed.receiptId, runner: parsed.runner, provenance: "runner-attested",
|
|
342
|
+
values: Object.fromEntries(RUNNER_RECEIPT_FIELDS.map(field => [field, runnerAttested(parsed[field], parsed.receiptId)])),
|
|
343
|
+
sealing: runnerSealing(parsed) ? runnerAttested(true, parsed.receiptId) : declared(protocol.sealed),
|
|
344
|
+
codeMatch: "commit" in protocol.code && parsed.codeCommit !== undefined ? runnerAttested(true, parsed.receiptId) : runnerAttested<boolean>(undefined, parsed.receiptId) };
|
|
345
|
+
}
|
|
346
|
+
|
|
318
347
|
/** Storage records carry host-issued identity separately from the immutable caller body. */
|
|
319
348
|
export interface ExperimentProtocolRecord {
|
|
320
349
|
hash: string;
|
|
@@ -337,7 +366,7 @@ export interface ExperimentAttemptRecord {
|
|
|
337
366
|
export interface ExperimentEventRecord {
|
|
338
367
|
attemptId: string;
|
|
339
368
|
idempotencyKey: string;
|
|
340
|
-
event:
|
|
369
|
+
event: ExperimentEvent;
|
|
341
370
|
bodyHash: string;
|
|
342
371
|
by: string;
|
|
343
372
|
private: boolean;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { createPublicKey, verify } from "node:crypto";
|
|
2
|
+
import { types } from "node:util";
|
|
3
|
+
import { canonicalJson } from "./packs.ts";
|
|
4
|
+
|
|
5
|
+
export interface RegisteredRunnerV1 { publicKey: string; projects: string[]; revoked: boolean }
|
|
6
|
+
export interface RunnerContainer { id: string; role: "candidate" | "evaluator" | "other" }
|
|
7
|
+
export interface RunnerMount { container: string; path: string; readOnly: boolean; purpose: "evaluator-code" | "evaluator-data" | "candidate-output" | "other" }
|
|
8
|
+
export interface RunnerOutput { name: string; sha256: string }
|
|
9
|
+
export interface AttemptReceiptV1 {
|
|
10
|
+
receiptId: string; runner: string; projectId: string; attemptId: string; protocolHash: string;
|
|
11
|
+
codeCommit?: string; imageDigest?: string; containers?: RunnerContainer[]; mounts?: RunnerMount[];
|
|
12
|
+
network?: "none" | "restricted" | "open";
|
|
13
|
+
resourceLimits?: { name: string; value: number; unit: string }[];
|
|
14
|
+
startedAt?: number; endedAt?: number; exitStatus?: number; outputs?: RunnerOutput[]; metricsFile?: RunnerOutput;
|
|
15
|
+
}
|
|
16
|
+
export interface RunnerVerificationContext {
|
|
17
|
+
runners: unknown;
|
|
18
|
+
projectId: string;
|
|
19
|
+
attempt: { id: string; projectId: string; protocolHash: string; codeCommit?: string } | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ID = /^[a-z0-9][a-z0-9_-]{0,63}$/, HASH = /^[0-9a-f]{64}$/, ATTEMPT = /^ra-[0-9a-f]{12}$/;
|
|
23
|
+
const BODY = 64 * 1024, SHORT = 512;
|
|
24
|
+
/** Exactly the receipt domain and its NUL separator; no executable runner protocol. */
|
|
25
|
+
export const RUNNER_RECEIPT_DOMAIN = "agent-hub.runner-receipt/v1" + String.fromCharCode(0);
|
|
26
|
+
function fail(field: string, reason: string): never { throw new Error(`${field}: ${reason}`); }
|
|
27
|
+
function object(input: unknown, keys: readonly string[] | undefined, field: string): Record<string, unknown> {
|
|
28
|
+
if (!input || typeof input !== "object" || Array.isArray(input) || types.isProxy(input)
|
|
29
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(input)) || Object.getOwnPropertySymbols(input).length)
|
|
30
|
+
fail(field, "expected a plain data object");
|
|
31
|
+
const descriptors = Object.getOwnPropertyDescriptors(input);
|
|
32
|
+
if (Object.values(descriptors).some(value => !Object.hasOwn(value, "value"))) fail(field, "accessors are unavailable");
|
|
33
|
+
if (keys && Object.keys(descriptors).some(key => !keys.includes(key))) fail(field, "unknown field");
|
|
34
|
+
if (Object.values(descriptors).some(value => value.value === undefined)) fail(field, "omit absent fields");
|
|
35
|
+
return Object.fromEntries(Object.entries(descriptors).map(([key, value]) => [key, value.value]));
|
|
36
|
+
}
|
|
37
|
+
function array(input: unknown, max: number, field: string): unknown[] {
|
|
38
|
+
if (!Array.isArray(input) || types.isProxy(input) || Object.getPrototypeOf(input) !== Array.prototype
|
|
39
|
+
|| input.length > max || Object.getOwnPropertySymbols(input).length) fail(field, `expected a list of at most ${max} items`);
|
|
40
|
+
const descriptors = Object.getOwnPropertyDescriptors(input);
|
|
41
|
+
if (Object.keys(descriptors).some(key => key !== "length" && !/^(0|[1-9][0-9]*)$/.test(key))) fail(field, "unknown list fields");
|
|
42
|
+
return Array.from({ length: input.length }, (_, index) => {
|
|
43
|
+
const value = descriptors[String(index)];
|
|
44
|
+
if (!value || !Object.hasOwn(value, "value")) fail(field, "expected a dense data list");
|
|
45
|
+
return value.value;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function text(input: unknown, field: string): string {
|
|
49
|
+
if (typeof input !== "string" || !input.trim() || input.length > SHORT || Buffer.byteLength(input, "utf8") > SHORT
|
|
50
|
+
|| Buffer.from(input, "utf8").toString("utf8") !== input) fail(field, "expected non-empty UTF-8 text of at most 512 bytes");
|
|
51
|
+
return input;
|
|
52
|
+
}
|
|
53
|
+
function pattern(input: unknown, rule: RegExp, field: string): string {
|
|
54
|
+
if (typeof input !== "string" || rule.exec(input)?.[0] !== input) fail(field, "invalid identifier or hash");
|
|
55
|
+
return input;
|
|
56
|
+
}
|
|
57
|
+
function integer(input: unknown, field: string, min = 0): number {
|
|
58
|
+
if (typeof input !== "number" || !Number.isSafeInteger(input) || input < min) fail(field, "expected a bounded integer");
|
|
59
|
+
return input;
|
|
60
|
+
}
|
|
61
|
+
function choice<T extends string>(input: unknown, values: readonly T[], field: string): T {
|
|
62
|
+
if (typeof input !== "string" || !values.includes(input as T)) fail(field, "unsupported value");
|
|
63
|
+
return input as T;
|
|
64
|
+
}
|
|
65
|
+
function bounded<T>(value: T, field: string): T {
|
|
66
|
+
if (Buffer.byteLength(canonicalJson(value), "utf8") > BODY) fail(field, "stored body exceeds 64 KiB");
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
function base64(input: unknown, bytes: number, field: string): Buffer {
|
|
70
|
+
if (typeof input !== "string" || input.length !== 4 * Math.ceil(bytes / 3) || !/^[A-Za-z0-9+/]+={0,2}$/.test(input))
|
|
71
|
+
fail(field, "invalid canonical base64");
|
|
72
|
+
const decoded = Buffer.from(input, "base64");
|
|
73
|
+
if (decoded.length !== bytes || decoded.toString("base64") !== input) fail(field, "invalid canonical base64");
|
|
74
|
+
return decoded;
|
|
75
|
+
}
|
|
76
|
+
function publicKey(input: unknown, field: string): string {
|
|
77
|
+
const bytes = base64(input, 44, field);
|
|
78
|
+
try {
|
|
79
|
+
const key = createPublicKey({ key: bytes, format: "der", type: "spki" });
|
|
80
|
+
if (key.asymmetricKeyType !== "ed25519" || !key.export({ format: "der", type: "spki" }).equals(bytes))
|
|
81
|
+
fail(field, "expected an Ed25519 SPKI public key");
|
|
82
|
+
} catch { fail(field, "expected an Ed25519 SPKI public key"); }
|
|
83
|
+
return input as string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Data validation only. Person-only registration and machine-local placement belong to the host. */
|
|
87
|
+
export function parseResearchRunners(input: unknown): Record<string, RegisteredRunnerV1> {
|
|
88
|
+
const raw = object(input, undefined, "research.runners"), entries = Object.entries(raw);
|
|
89
|
+
if (entries.length > 16) fail("research.runners", "at most 16 runners");
|
|
90
|
+
return bounded(Object.fromEntries(entries.map(([name, value]) => {
|
|
91
|
+
const field = `research.runners.${pattern(name, ID, "research.runners.name")}`, row = object(value, ["publicKey", "projects", "revoked"], field);
|
|
92
|
+
const projects = array(row.projects, 64, `${field}.projects`).map(value => text(value, `${field}.projects`));
|
|
93
|
+
if (new Set(projects).size !== projects.length) fail(`${field}.projects`, "duplicate projects");
|
|
94
|
+
if (typeof row.revoked !== "boolean") fail(`${field}.revoked`, "expected a boolean");
|
|
95
|
+
return [name, { publicKey: publicKey(row.publicKey, `${field}.publicKey`), projects, revoked: row.revoked }];
|
|
96
|
+
})), "research.runners");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function output(input: unknown, field: string): RunnerOutput {
|
|
100
|
+
const row = object(input, ["name", "sha256"], field);
|
|
101
|
+
return { name: text(row.name, `${field}.name`), sha256: pattern(row.sha256, HASH, `${field}.sha256`) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Absent observations stay absent. Isolation inconsistencies are signed evidence, not schema failures. */
|
|
105
|
+
export function parseAttemptReceipt(input: unknown): AttemptReceiptV1 {
|
|
106
|
+
const raw = object(input, ["receiptId", "runner", "projectId", "attemptId", "protocolHash", "codeCommit", "imageDigest", "containers", "mounts", "network", "resourceLimits", "startedAt", "endedAt", "exitStatus", "outputs", "metricsFile"], "receipt");
|
|
107
|
+
const receipt: AttemptReceiptV1 = { receiptId: pattern(raw.receiptId, ID, "receipt.receiptId"), runner: pattern(raw.runner, ID, "receipt.runner"),
|
|
108
|
+
projectId: text(raw.projectId, "receipt.projectId"), attemptId: pattern(raw.attemptId, ATTEMPT, "receipt.attemptId"), protocolHash: pattern(raw.protocolHash, HASH, "receipt.protocolHash") };
|
|
109
|
+
if (raw.codeCommit !== undefined) receipt.codeCommit = pattern(raw.codeCommit, /^[0-9a-f]{40}$/, "receipt.codeCommit");
|
|
110
|
+
if (raw.imageDigest !== undefined) receipt.imageDigest = pattern(raw.imageDigest, /^sha256:[0-9a-f]{64}$/, "receipt.imageDigest");
|
|
111
|
+
if (raw.containers !== undefined) receipt.containers = array(raw.containers, 64, "receipt.containers").map((value, index) => {
|
|
112
|
+
const field = `receipt.containers[${index}]`, row = object(value, ["id", "role"], field);
|
|
113
|
+
return { id: pattern(row.id, ID, `${field}.id`), role: choice(row.role, ["candidate", "evaluator", "other"], `${field}.role`) };
|
|
114
|
+
});
|
|
115
|
+
if (raw.mounts !== undefined) receipt.mounts = array(raw.mounts, 256, "receipt.mounts").map((value, index) => {
|
|
116
|
+
const field = `receipt.mounts[${index}]`, row = object(value, ["container", "path", "readOnly", "purpose"], field);
|
|
117
|
+
if (typeof row.readOnly !== "boolean") fail(`${field}.readOnly`, "expected a boolean");
|
|
118
|
+
return { container: pattern(row.container, ID, `${field}.container`), path: text(row.path, `${field}.path`), readOnly: row.readOnly,
|
|
119
|
+
purpose: choice(row.purpose, ["evaluator-code", "evaluator-data", "candidate-output", "other"], `${field}.purpose`) };
|
|
120
|
+
});
|
|
121
|
+
if (raw.network !== undefined) receipt.network = choice<NonNullable<AttemptReceiptV1["network"]>>(raw.network, ["none", "restricted", "open"], "receipt.network");
|
|
122
|
+
if (raw.resourceLimits !== undefined) receipt.resourceLimits = array(raw.resourceLimits, 16, "receipt.resourceLimits").map((value, index) => {
|
|
123
|
+
const field = `receipt.resourceLimits[${index}]`, row = object(value, ["name", "value", "unit"], field);
|
|
124
|
+
if (typeof row.value !== "number" || !Number.isFinite(row.value) || row.value < 0) fail(`${field}.value`, "expected a nonnegative finite number");
|
|
125
|
+
return { name: text(row.name, `${field}.name`), value: row.value, unit: text(row.unit, `${field}.unit`) };
|
|
126
|
+
});
|
|
127
|
+
if (raw.startedAt !== undefined) receipt.startedAt = integer(raw.startedAt, "receipt.startedAt");
|
|
128
|
+
if (raw.endedAt !== undefined) receipt.endedAt = integer(raw.endedAt, "receipt.endedAt");
|
|
129
|
+
if (receipt.startedAt !== undefined && receipt.endedAt !== undefined && receipt.endedAt < receipt.startedAt) fail("receipt.endedAt", "precedes startedAt");
|
|
130
|
+
if (raw.exitStatus !== undefined) receipt.exitStatus = integer(raw.exitStatus, "receipt.exitStatus", Number.MIN_SAFE_INTEGER);
|
|
131
|
+
if (raw.outputs !== undefined) receipt.outputs = array(raw.outputs, 64, "receipt.outputs").map((value, index) => output(value, `receipt.outputs[${index}]`));
|
|
132
|
+
if (raw.metricsFile !== undefined) receipt.metricsFile = output(raw.metricsFile, "receipt.metricsFile");
|
|
133
|
+
return bounded(receipt, "receipt");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Predicate on evidence only; callers must verify its signature before treating sealing as attested. */
|
|
137
|
+
export function runnerSealing(receipt: AttemptReceiptV1): boolean {
|
|
138
|
+
const parsed = parseAttemptReceipt(receipt), containers = parsed.containers ?? [], mounts = parsed.mounts ?? [];
|
|
139
|
+
const roles = new Map(containers.map(container => [container.id, container.role]));
|
|
140
|
+
if (roles.size !== containers.length || !containers.some(container => container.role === "candidate")
|
|
141
|
+
|| !containers.some(container => container.role === "evaluator") || !mounts.length) return false;
|
|
142
|
+
let evaluatorEvidence = false;
|
|
143
|
+
for (const mount of mounts) {
|
|
144
|
+
const role = roles.get(mount.container);
|
|
145
|
+
if (!role) return false;
|
|
146
|
+
if (mount.purpose === "evaluator-code" || mount.purpose === "evaluator-data") {
|
|
147
|
+
if (role !== "evaluator" || !mount.readOnly) return false;
|
|
148
|
+
evaluatorEvidence = true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return evaluatorEvidence;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Pure verification against the current host-supplied registration and attempt identity. No host-observed or scientific claim. */
|
|
155
|
+
export function verifyAttemptReceipt(input: unknown, signature: unknown, context: RunnerVerificationContext): {
|
|
156
|
+
receipt: AttemptReceiptV1; provenance: "runner-attested"; sealed: boolean;
|
|
157
|
+
} {
|
|
158
|
+
const receipt = parseAttemptReceipt(input), runners = parseResearchRunners(context.runners);
|
|
159
|
+
if (!Object.hasOwn(runners, receipt.runner)) fail("receipt.runner", "unknown runner");
|
|
160
|
+
const runner = runners[receipt.runner]!;
|
|
161
|
+
if (runner.revoked) fail("receipt.runner", "revoked runner");
|
|
162
|
+
if (receipt.projectId !== context.projectId || !runner.projects.includes(receipt.projectId)) fail("receipt.projectId", "project is not allowed");
|
|
163
|
+
const attempt = context.attempt;
|
|
164
|
+
if (!attempt || attempt.id !== receipt.attemptId || attempt.projectId !== context.projectId || attempt.protocolHash !== receipt.protocolHash)
|
|
165
|
+
fail("receipt.attemptId", "attempt or protocol identity mismatch");
|
|
166
|
+
if (receipt.codeCommit !== undefined && attempt.codeCommit !== undefined && receipt.codeCommit !== attempt.codeCommit)
|
|
167
|
+
fail("receipt.codeCommit", "protocol commit mismatch");
|
|
168
|
+
const bytes = base64(signature, 64, "receipt.signature");
|
|
169
|
+
const key = createPublicKey({ key: Buffer.from(runner.publicKey, "base64"), format: "der", type: "spki" });
|
|
170
|
+
if (!verify(null, Buffer.from(RUNNER_RECEIPT_DOMAIN + canonicalJson(receipt), "utf8"), key, bytes)) fail("receipt.signature", "invalid signature");
|
|
171
|
+
return { receipt, provenance: "runner-attested", sealed: runnerSealing(receipt) };
|
|
172
|
+
}
|