@kontextmind/kxm 0.7.128 → 0.7.131
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/.claude-plugin/marketplace.json +1 -1
- package/.kxm/README.md +4 -1
- package/.kxm/agents/coordinator.yaml +1 -0
- package/.kxm/agents/critic-arch.yaml +1 -4
- package/.kxm/agents/critic-cli.yaml +1 -4
- package/.kxm/agents/implementer.yaml +1 -4
- package/.kxm/workflows/implement-only.yaml +1 -1
- package/.kxm/workflows/review-arch-only.yaml +1 -1
- package/.kxm/workflows/review-cli-only.yaml +1 -1
- package/CHANGELOG.md +42 -1
- package/docs/architecture/inventory.md +1 -1
- package/docs/concepts/architecture.md +1 -1
- package/docs/concepts/data-and-storage.md +1 -1
- package/docs/contracts/README.md +1 -1
- package/docs/contracts/migration.md +12 -8
- package/docs/contracts/routing.md +3 -3
- package/docs/contributing/assignment-runner.md +14 -15
- package/docs/contributing/development.md +22 -6
- package/docs/contributing/harness-routing-internals.md +8 -8
- package/docs/glossary.md +1 -1
- package/docs/guides/agent-skills.md +1 -1
- package/docs/operations/backup-and-restore.md +7 -7
- package/docs/operations/upgrade.md +2 -2
- package/docs/reference/cli-reference.md +37 -14
- package/docs/reference/config-reference.md +81 -155
- package/docs/reference/harness-routing.md +29 -21
- package/docs/reference/workflow-catalog.md +4 -4
- package/docs/start/first-workflow.md +1 -1
- package/examples/project/.kxm/agents/coordinator.yaml +1 -2
- package/examples/project/.kxm/agents/critic-1.yaml +1 -3
- package/examples/project/.kxm/agents/critic-2.yaml +1 -2
- package/examples/project/.kxm/agents/critic-3.yaml +2 -3
- package/examples/project/.kxm/agents/implementer.yaml +1 -2
- package/examples/project/.kxm/agents/planner.yaml +1 -2
- package/examples/project/.kxm/agents/reproducer.yaml +1 -2
- package/examples/project/.kxm/agents/reviewer.yaml +1 -2
- package/examples/project/.kxm/workflows/fix.yaml +0 -12
- package/package.json +8 -8
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +2565 -465
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime-supervisor.js +683 -348
- package/plugins/kxm/dist/runtime.js +694 -358
- package/plugins/kxm/dist/server.js +17 -14
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm/SKILL.md +1 -1
- package/plugins/kxm/skills/kxm-definitions/SKILL.md +8 -0
- package/plugins/kxm/src/cli/lanes.ts +109 -4
- package/plugins/kxm/src/cli/project.ts +6 -6
- package/plugins/kxm/src/database.ts +25 -19
- package/plugins/kxm/src/engine.ts +168 -163
- package/plugins/kxm/src/init-guide-setup.ts +75 -15
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/oneshot-producer.ts +3 -15
- package/plugins/kxm/src/project-config.ts +83 -70
- package/plugins/kxm/src/routes.ts +26 -18
- package/plugins/kxm/src/runtime-service.ts +1 -0
- package/plugins/kxm/src/runtime-store.ts +390 -77
- package/plugins/kxm/src/runtime-supervisor.ts +87 -21
- package/plugins/kxm/src/suggest.ts +2 -2
- package/plugins/kxm/src/template.ts +2 -14
- package/schemas/agent.schema.json +8 -0
- package/scripts/roster-policy.d.mts +2 -1
- package/scripts/roster-policy.mjs +119 -22
- package/scripts/run-bounded.mjs +53 -0
|
@@ -6,13 +6,13 @@ import { dirname, isAbsolute, join } from "node:path";
|
|
|
6
6
|
import { findKxmRepoRoot } from "./repo-root.ts";
|
|
7
7
|
import { loadKxmProject, KxmConfigError, type KxmConfigOptions } from "./project-config.ts";
|
|
8
8
|
import {
|
|
9
|
+
KxmRunEventStore,
|
|
9
10
|
KxmRuntimeRegistry,
|
|
10
11
|
projectRuntimeKey,
|
|
11
12
|
readKxmSupervisorRecord,
|
|
12
13
|
runtimeError,
|
|
13
14
|
verifyKxmDriveReceipt,
|
|
14
15
|
kxmRuntimePaths,
|
|
15
|
-
type KxmRunEventStore,
|
|
16
16
|
type KxmRuntimePaths,
|
|
17
17
|
type KxmSupervisorRecord,
|
|
18
18
|
} from "./runtime-store.ts";
|
|
@@ -28,7 +28,6 @@ import {
|
|
|
28
28
|
type KxmRuntimeContext,
|
|
29
29
|
} from "./runtime-service.ts";
|
|
30
30
|
import { createKxmOneShotProducer } from "./oneshot-producer.ts";
|
|
31
|
-
import { isRouteAdmitted } from "./routes.ts";
|
|
32
31
|
import { KxmRunScheduler, createKxmSimulatedProducer, recordDriveReceipt, recoverKxmRun, kxmDrivePollProjection } from "./engine.ts";
|
|
33
32
|
import { kxmDriveSession, kxmOpenDriveSessions } from "./runtime-owner.ts";
|
|
34
33
|
import { RuntimeHubClient, HubHttpError, type SyncPushResponse } from "./client.ts";
|
|
@@ -126,6 +125,28 @@ function readRecentSupervisorError(paths: KxmRuntimePaths): string | undefined {
|
|
|
126
125
|
}
|
|
127
126
|
}
|
|
128
127
|
|
|
128
|
+
const SETTLED_RUN_STATUS = new Set(["completed", "failed", "cancelled"]);
|
|
129
|
+
|
|
130
|
+
/** Same liveness rule as `kxmSupervisorStatus`: running, a fresh heartbeat, and a live pid. */
|
|
131
|
+
export function kxmSupervisorRecordIsLive(record: KxmSupervisorRecord | undefined): boolean {
|
|
132
|
+
return supervisorStatusOf(record).running;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function unsettledRunIds(context: KxmRuntimeContext): string[] {
|
|
136
|
+
const ids: string[] = [];
|
|
137
|
+
for (const stored of context.eventStore.runsForProject(context.projectId, 10_000)) {
|
|
138
|
+
let status: string = stored.status;
|
|
139
|
+
try {
|
|
140
|
+
status = projectKxmRunReadOnly(context, stored.runId).status;
|
|
141
|
+
} catch {
|
|
142
|
+
ids.push(stored.runId);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (!SETTLED_RUN_STATUS.has(status)) ids.push(stored.runId);
|
|
146
|
+
}
|
|
147
|
+
return ids;
|
|
148
|
+
}
|
|
149
|
+
|
|
129
150
|
function supervisorStatusOf(record: KxmSupervisorRecord | undefined): KxmSupervisorStatus {
|
|
130
151
|
if (!record) return { running: false };
|
|
131
152
|
// Pid-only liveness is not enough: after a crash the pid may be reused by
|
|
@@ -781,6 +802,65 @@ async function startKxmRuntimeSupervisorInner(
|
|
|
781
802
|
return;
|
|
782
803
|
}
|
|
783
804
|
|
|
805
|
+
if (request.method === "POST" && url.pathname === "/v1/projects/unregister") {
|
|
806
|
+
const body = await readJsonBody(request);
|
|
807
|
+
const projectRoot = typeof body.projectRoot === "string" ? body.projectRoot : "";
|
|
808
|
+
const force = body.force === true;
|
|
809
|
+
if (!projectRoot || !isAbsolute(projectRoot)) {
|
|
810
|
+
sendJson(response, 400, { ok: false, error: "runtime_request_invalid", message: "projectRoot must be an absolute path" });
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (!force) {
|
|
814
|
+
const key = projectRuntimeKey(projectRoot);
|
|
815
|
+
const existing = contexts.get(key);
|
|
816
|
+
let openRunIds: string[] = [];
|
|
817
|
+
if (existing) {
|
|
818
|
+
openRunIds = unsettledRunIds(existing);
|
|
819
|
+
} else {
|
|
820
|
+
const registration = registry.projectByRoot(projectRoot);
|
|
821
|
+
const eventsPath = join(paths.projectsDir, registration?.projectKey ?? projectRuntimeKey(projectRoot), "run-events.db");
|
|
822
|
+
if (registration && existsSync(eventsPath)) {
|
|
823
|
+
const store = new KxmRunEventStore(eventsPath);
|
|
824
|
+
try {
|
|
825
|
+
openRunIds = store.runsForProject(registration.projectId, 10_000)
|
|
826
|
+
.filter((run) => !SETTLED_RUN_STATUS.has(run.status))
|
|
827
|
+
.map((run) => run.runId);
|
|
828
|
+
} finally {
|
|
829
|
+
store.close();
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
if (openRunIds.length > 0) {
|
|
834
|
+
sendJson(response, 409, {
|
|
835
|
+
ok: false,
|
|
836
|
+
error: "runtime_project_busy",
|
|
837
|
+
message: `unsettled run ${openRunIds.join(", ")}`,
|
|
838
|
+
runIds: openRunIds,
|
|
839
|
+
});
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
const laneCount = registry.homeLaneCount(projectRoot);
|
|
843
|
+
if (laneCount > 0) {
|
|
844
|
+
sendJson(response, 409, {
|
|
845
|
+
ok: false,
|
|
846
|
+
error: "runtime_project_has_lanes",
|
|
847
|
+
message: `home root still has ${laneCount} lane(s)`,
|
|
848
|
+
});
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
const key = projectRuntimeKey(projectRoot);
|
|
853
|
+
const existing = contexts.get(key);
|
|
854
|
+
const unregistered = registry.unregisterProject(projectRoot);
|
|
855
|
+
if (existing) {
|
|
856
|
+
contexts.delete(key);
|
|
857
|
+
syncStatuses.delete(key);
|
|
858
|
+
closeKxmRuntimeContext(existing);
|
|
859
|
+
}
|
|
860
|
+
sendJson(response, 200, { ok: true, unregistered });
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
|
|
784
864
|
if (request.method === "POST" && url.pathname === "/v1/runs") {
|
|
785
865
|
const body = await readJsonBody(request);
|
|
786
866
|
const projectRoot = typeof body.projectRoot === "string" ? body.projectRoot : "";
|
|
@@ -838,23 +918,6 @@ async function startKxmRuntimeSupervisorInner(
|
|
|
838
918
|
? createKxmOneShotProducer({
|
|
839
919
|
projectRoot,
|
|
840
920
|
timeoutMs: kxmProjectAdmissionLimits(bundle).agentStepTimeoutMs,
|
|
841
|
-
defaultHarness: String(bundle.project.value.defaultHarness ?? "pi"),
|
|
842
|
-
resolveHarness: (agentId) => {
|
|
843
|
-
const agent = bundle.agents.get(agentId);
|
|
844
|
-
return typeof agent?.value.harness === "string" ? agent.value.harness : undefined;
|
|
845
|
-
},
|
|
846
|
-
resolveModel: (agentId) => {
|
|
847
|
-
const agent = bundle.agents.get(agentId);
|
|
848
|
-
const model = agent?.value.model;
|
|
849
|
-
if (!model || typeof model !== "object" || Array.isArray(model)) return undefined;
|
|
850
|
-
const value = model as Record<string, unknown>;
|
|
851
|
-
const provider = typeof value.provider === "string" ? value.provider : undefined;
|
|
852
|
-
const modelName = typeof value.model === "string" ? value.model : undefined;
|
|
853
|
-
if (!provider || !modelName || !isRouteAdmitted(projectRoot, `${provider}/${modelName}`)) {
|
|
854
|
-
throw new Error("producer_route_not_admitted");
|
|
855
|
-
}
|
|
856
|
-
return { provider, model: modelName };
|
|
857
|
-
},
|
|
858
921
|
})
|
|
859
922
|
: createKxmSimulatedProducer(async () => {
|
|
860
923
|
if (delayMs > 0) {
|
|
@@ -1026,9 +1089,12 @@ async function startKxmRuntimeSupervisorInner(
|
|
|
1026
1089
|
// that returned raw rows would let every consumer — including the portal's
|
|
1027
1090
|
// tenant read — present cached status as authoritative. Folding replays each
|
|
1028
1091
|
// run's events; workflows are transition-bounded, so this stays cheap at the
|
|
1029
|
-
// 50-run cap. A run that refuses to fold is returned with its cached row plus
|
|
1092
|
+
// default 50-run cap (`limit` may ask for up to 10000). A run that refuses to fold is returned with its cached row plus
|
|
1030
1093
|
// `projectionError`, so one corrupt run cannot make the listing lie by omission.
|
|
1031
|
-
const
|
|
1094
|
+
const limitText = url.searchParams.get("limit");
|
|
1095
|
+
const parsedLimit = limitText === null ? 50 : Number(limitText);
|
|
1096
|
+
const limit = Number.isInteger(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 10_000) : 50;
|
|
1097
|
+
const runs = context.eventStore.runsForProject(requestedProjectId, limit).map((stored) => {
|
|
1032
1098
|
try {
|
|
1033
1099
|
return projectKxmRunReadOnly(context, stored.runId);
|
|
1034
1100
|
} catch (error) {
|
|
@@ -191,10 +191,10 @@ export function suggestWorkflowAndRoles(
|
|
|
191
191
|
supported: true,
|
|
192
192
|
prerequisites: [
|
|
193
193
|
"Run kxm init in the target repository if needed, then install the exact template with the suggested command. If that workflow ID already exists, stop and review it; the commands below apply only to a newly installed template, not an existing definition with potentially different agents or permissions.",
|
|
194
|
-
...roles.map((role) => `Set
|
|
194
|
+
...roles.map((role) => `Set role on .kxm/agents/${role.agent}.yaml to a role whose roster names an admitted route for harness ${role.harness}. Admit that route's selector in .kxm/routes.yaml and ensure it is not disabled; this recommendation does not choose a model or change routing.`),
|
|
195
195
|
...(writeStep ? [
|
|
196
196
|
`Keep each writer step at assignments.maximum: 1 and set limits.maxConcurrentRuns: 1 in .kxm/project.yaml; the live ${selected.id} writer profile requires a lone writer in the checkout.`,
|
|
197
|
-
`
|
|
197
|
+
`The writer role roster must name a route whose harness is ${selected.id}, whose status is admitted, and whose permissions contain edit.`,
|
|
198
198
|
"Configure the test gate in .kxm/gates.yaml with kind: command and argv for this repository's actual verification command. A scaffold/example command or simulation is not evidence that the implementation works.",
|
|
199
199
|
] : []),
|
|
200
200
|
`Validate the installed workflow with kxm gate validate --file .kxm/workflows/${bestPattern.id}.yaml and the project configuration with kxm init --dry-run before creating a run.`,
|
|
@@ -175,21 +175,9 @@ function coreTemplate(projectId: string, projectName: string, variant: KxmTempla
|
|
|
175
175
|
]);
|
|
176
176
|
if (variant === "v4-registry") {
|
|
177
177
|
const coordinator = files.get(".kxm/agents/coordinator.yaml");
|
|
178
|
-
if (coordinator) {
|
|
179
|
-
files.set(".kxm/agents/coordinator.yaml", {
|
|
180
|
-
...coordinator,
|
|
181
|
-
harness: "claude",
|
|
182
|
-
model: { provider: "anthropic", model: "fable" },
|
|
183
|
-
});
|
|
184
|
-
}
|
|
178
|
+
if (coordinator) files.set(".kxm/agents/coordinator.yaml", { ...coordinator, role: "planner" });
|
|
185
179
|
const implementer = files.get(".kxm/agents/implementer.yaml");
|
|
186
|
-
if (implementer) {
|
|
187
|
-
files.set(".kxm/agents/implementer.yaml", {
|
|
188
|
-
...implementer,
|
|
189
|
-
harness: "grok",
|
|
190
|
-
model: { provider: "xai", model: "grok-4.6" },
|
|
191
|
-
});
|
|
192
|
-
}
|
|
180
|
+
if (implementer) files.set(".kxm/agents/implementer.yaml", { ...implementer, role: "writer" });
|
|
193
181
|
const workflow = files.get(".kxm/workflows/default.yaml");
|
|
194
182
|
if (workflow) {
|
|
195
183
|
const limits = { ...(workflow.limits as JsonObject) };
|
|
@@ -12,6 +12,14 @@
|
|
|
12
12
|
"model": { "$ref": "common.schema.json#/$defs/modelSelector" },
|
|
13
13
|
"executor": { "$ref": "common.schema.json#/$defs/identifier" },
|
|
14
14
|
"harness": { "$ref": "common.schema.json#/$defs/identifier" },
|
|
15
|
+
"role": { "$ref": "common.schema.json#/$defs/identifier" },
|
|
16
|
+
"effort": { "enum": ["off", "minimal", "low", "medium", "high", "xhigh", "max"] },
|
|
17
|
+
"skills": {
|
|
18
|
+
"type": "array",
|
|
19
|
+
"items": { "$ref": "common.schema.json#/$defs/identifier" },
|
|
20
|
+
"maxItems": 64,
|
|
21
|
+
"uniqueItems": true
|
|
22
|
+
},
|
|
15
23
|
"tools": { "$ref": "common.schema.json#/$defs/toolPolicy" },
|
|
16
24
|
"defaultRepositoryAccess": { "$ref": "common.schema.json#/$defs/repositoryAccess" },
|
|
17
25
|
"repositories": {
|
|
@@ -7,7 +7,6 @@ export interface RosterRoute {
|
|
|
7
7
|
readonly status: 'admitted' | 'retired';
|
|
8
8
|
}
|
|
9
9
|
export interface RosterPolicy {
|
|
10
|
-
readonly schema: 'kxm.developer-roster.v1';
|
|
11
10
|
readonly routes: Readonly<Record<string, RosterRoute>>;
|
|
12
11
|
readonly lineup: Readonly<Record<string, readonly string[]>>;
|
|
13
12
|
readonly required_critics: Readonly<Record<'review-arch' | 'review-cli', string>>;
|
|
@@ -15,9 +14,11 @@ export interface RosterPolicy {
|
|
|
15
14
|
}
|
|
16
15
|
export interface PolicyIdentity { readonly commit: string; readonly blob: string; readonly sha256: string }
|
|
17
16
|
export interface LoadedPolicy { readonly identity: PolicyIdentity; readonly policy: RosterPolicy }
|
|
17
|
+
export function refuseRetiredRosterFile(root: string): void;
|
|
18
18
|
export function loadTrustedRosterPolicy(): LoadedPolicy;
|
|
19
19
|
export function resolveBoundPolicy(identity: PolicyIdentity): LoadedPolicy;
|
|
20
20
|
export function canonicalVendor(value: string): string;
|
|
21
|
+
export function assembleRosterPolicy(models: readonly Record<string, unknown>[], roles: readonly Record<string, unknown>[]): RosterPolicy;
|
|
21
22
|
export function validateRosterDocument(
|
|
22
23
|
policy: RosterPolicy,
|
|
23
24
|
readBlob: (source: string) => string | Uint8Array | null | undefined,
|
|
@@ -8,9 +8,11 @@ import YAML from 'yaml';
|
|
|
8
8
|
import { NATIVE_PI_BRAKE_PROVIDERS, PI_ALLOWED_PROVIDERS, PI_ANTIGRAVITY_MODEL_ID, PI_NATIVE_VENDOR_PROVIDERS, ROUTES } from './harness-run.mjs';
|
|
9
9
|
|
|
10
10
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
11
|
-
const
|
|
12
|
-
const
|
|
11
|
+
const MODEL_DIR = '.kxm/models';
|
|
12
|
+
const ROLE_DIR = '.kxm/roles';
|
|
13
|
+
const RETIRED_ROSTER_FILES = ['.kxm/roster.json', '.kxm/roster.yaml'];
|
|
13
14
|
const TRUSTED = 'refs/remotes/origin/main';
|
|
15
|
+
const SKIP_MODELS = new Set(['inventory.yaml']);
|
|
14
16
|
const ROLES = ['writer', 'planner', 'reviewer-arch', 'reviewer-cli', 'experiment'];
|
|
15
17
|
const ALIASES = Object.freeze({ 'x-ai': 'xai', moonshotai: 'moonshot', 'google-ai': 'google', qwen: 'alibaba' });
|
|
16
18
|
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
@@ -55,14 +57,19 @@ function frozen(value) {
|
|
|
55
57
|
if (value && typeof value === 'object') { for (const child of Object.values(value)) frozen(child); Object.freeze(value); }
|
|
56
58
|
return value;
|
|
57
59
|
}
|
|
60
|
+
export function refuseRetiredRosterFile(root) {
|
|
61
|
+
for (const retired of RETIRED_ROSTER_FILES) {
|
|
62
|
+
if (existsSync(path.join(root, retired))) refuse(`retired_roster_file: retired ${retired} present; use ${MODEL_DIR} and ${ROLE_DIR}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
58
65
|
function control() {
|
|
59
|
-
|
|
66
|
+
refuseRetiredRosterFile(ROOT);
|
|
60
67
|
if (realpathSync(gitText('rev-parse', '--show-toplevel')) !== realpathSync(ROOT)) refuse('module is outside its control repository');
|
|
61
68
|
const head = gitText('rev-parse', '--verify', 'HEAD^{commit}');
|
|
62
69
|
const trusted = gitText('rev-parse', '--verify', `${TRUSTED}^{commit}`);
|
|
63
70
|
git('merge-base', '--is-ancestor', head, trusted);
|
|
64
71
|
const flags = git('ls-files', '-v', '-z').toString('utf8').split('\0').filter(Boolean);
|
|
65
|
-
if (flags.some(entry => entry
|
|
72
|
+
if (flags.some(entry => entry[0] === 'S' || entry[0] === entry[0].toLowerCase())) refuse('hidden index flags on control source');
|
|
66
73
|
if (git('status', '--porcelain=v1', '--untracked-files=all').length) refuse('dirty or untracked control source');
|
|
67
74
|
return { head, trusted };
|
|
68
75
|
}
|
|
@@ -88,8 +95,7 @@ function workingBytes(source) {
|
|
|
88
95
|
return readFileSync(current);
|
|
89
96
|
}
|
|
90
97
|
export function validateRosterDocument(policy, readBlob) {
|
|
91
|
-
keys(policy, ['
|
|
92
|
-
if (policy.schema !== 'kxm.developer-roster.v1') refuse('unsupported schema');
|
|
98
|
+
keys(policy, ['routes', 'lineup', 'required_critics', 'model_origins'], 'policy');
|
|
93
99
|
record(policy.routes, 'routes'); record(policy.lineup, 'lineup'); record(policy.model_origins, 'model origins');
|
|
94
100
|
if (!Object.keys(policy.routes).length) refuse('empty routes');
|
|
95
101
|
if (typeof readBlob !== 'function') refuse('origin evidence reader required');
|
|
@@ -162,35 +168,126 @@ export function validateRosterDocument(policy, readBlob) {
|
|
|
162
168
|
}
|
|
163
169
|
return policy;
|
|
164
170
|
}
|
|
165
|
-
function
|
|
166
|
-
let
|
|
167
|
-
try {
|
|
168
|
-
if (
|
|
171
|
+
function parsePolicyYaml(bytes, label) {
|
|
172
|
+
let value;
|
|
173
|
+
try { value = YAML.parse(bytes.toString('utf8')); } catch { refuse(`invalid policy YAML (${label})`); }
|
|
174
|
+
if (value === undefined || value === null || typeof value !== 'object' || Array.isArray(value)) refuse(`invalid policy YAML (${label})`);
|
|
175
|
+
return value;
|
|
176
|
+
}
|
|
177
|
+
function listedPolicyFiles(commit) {
|
|
178
|
+
const raw = git('ls-tree', '-r', '-z', commit, '--', MODEL_DIR, ROLE_DIR).toString('utf8');
|
|
179
|
+
const files = [];
|
|
180
|
+
for (const entry of raw.split('\0').filter(Boolean)) {
|
|
181
|
+
const match = /^(100644|100755) blob ([a-f0-9]{40}(?:[a-f0-9]{24})?)\t(.+)$/u.exec(entry);
|
|
182
|
+
if (!match) refuse('source is missing or not a regular committed file');
|
|
183
|
+
const source = match[3];
|
|
184
|
+
if (!source.endsWith('.yaml')) continue;
|
|
185
|
+
const name = source.slice(source.lastIndexOf('/') + 1);
|
|
186
|
+
if (source.startsWith(`${MODEL_DIR}/`) && SKIP_MODELS.has(name)) continue;
|
|
187
|
+
if (source.startsWith(`${MODEL_DIR}/`) || source.startsWith(`${ROLE_DIR}/`)) files.push(source);
|
|
188
|
+
}
|
|
189
|
+
if (!files.some(source => source.startsWith(`${ROLE_DIR}/`)) || !files.some(source => source.startsWith(`${MODEL_DIR}/`))) {
|
|
190
|
+
refuse('source is missing or not a regular committed file');
|
|
191
|
+
}
|
|
192
|
+
return files.sort();
|
|
193
|
+
}
|
|
194
|
+
/** Build the runner policy object from parsed model and role documents. */
|
|
195
|
+
export function assembleRosterPolicy(models, roles) {
|
|
196
|
+
if (!Array.isArray(models) || !Array.isArray(roles)) refuse('invalid policy YAML');
|
|
197
|
+
const lineup = {};
|
|
198
|
+
const rolesByRoute = {};
|
|
199
|
+
for (const role of [...roles].sort((left, right) => String(left?.id).localeCompare(String(right?.id)))) {
|
|
200
|
+
record(role, 'role');
|
|
201
|
+
text(role.id, 'role id');
|
|
202
|
+
if (!Array.isArray(role.roster)) refuse('lineup contains unsupported or duplicate values');
|
|
203
|
+
const ids = [];
|
|
204
|
+
for (const entry of role.roster) {
|
|
205
|
+
record(entry, 'roster entry');
|
|
206
|
+
text(entry.route, 'roster route');
|
|
207
|
+
ids.push(entry.route);
|
|
208
|
+
if (!rolesByRoute[entry.route]) rolesByRoute[entry.route] = [];
|
|
209
|
+
if (!rolesByRoute[entry.route].includes(role.id)) rolesByRoute[entry.route].push(role.id);
|
|
210
|
+
}
|
|
211
|
+
lineup[role.id] = ids;
|
|
212
|
+
}
|
|
213
|
+
const routes = {};
|
|
214
|
+
const model_origins = {};
|
|
215
|
+
for (const doc of models) {
|
|
216
|
+
record(doc, 'model');
|
|
217
|
+
text(doc.id, 'route id');
|
|
218
|
+
if (!rolesByRoute[doc.id]) continue;
|
|
219
|
+
routes[doc.id] = {
|
|
220
|
+
harness: doc.harness,
|
|
221
|
+
model: doc.model,
|
|
222
|
+
vendor: doc.vendor,
|
|
223
|
+
roles: rolesByRoute[doc.id],
|
|
224
|
+
permissions: doc.permissions,
|
|
225
|
+
status: doc.status,
|
|
226
|
+
};
|
|
227
|
+
if (doc.origin !== undefined) {
|
|
228
|
+
record(doc.origin, 'origin');
|
|
229
|
+
const evidence = { source: doc.origin.source, sha256: doc.origin.sha256 };
|
|
230
|
+
if (doc.origin.commit !== undefined) evidence.commit = doc.origin.commit;
|
|
231
|
+
model_origins[doc.model] = { vendor: doc.vendor, evidence };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
routes,
|
|
236
|
+
lineup,
|
|
237
|
+
required_critics: {
|
|
238
|
+
'review-arch': lineup['reviewer-arch']?.[0],
|
|
239
|
+
'review-cli': lineup['reviewer-cli']?.[0],
|
|
240
|
+
},
|
|
241
|
+
model_origins,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
function readPolicySet(commit, checkWorking) {
|
|
245
|
+
const files = listedPolicyFiles(commit);
|
|
246
|
+
const models = [];
|
|
247
|
+
const roles = [];
|
|
248
|
+
const parts = [];
|
|
249
|
+
for (const source of files) {
|
|
250
|
+
const { bytes } = blobAt(commit, source);
|
|
251
|
+
if (checkWorking && !workingBytes(source).equals(bytes)) refuse('working policy differs from committed bytes');
|
|
252
|
+
const doc = parsePolicyYaml(bytes, source);
|
|
253
|
+
if (source.startsWith(`${MODEL_DIR}/`)) models.push(doc);
|
|
254
|
+
else roles.push(doc);
|
|
255
|
+
parts.push({ source, bytes });
|
|
256
|
+
}
|
|
257
|
+
return { models, roles, digest: compositeDigest(parts) };
|
|
258
|
+
}
|
|
259
|
+
function compositeDigest(parts) {
|
|
260
|
+
const hash = createHash('sha256');
|
|
261
|
+
for (const part of [...parts].sort((left, right) => left.source < right.source ? -1 : left.source > right.source ? 1 : 0)) {
|
|
262
|
+
hash.update(part.source);
|
|
263
|
+
hash.update('\0');
|
|
264
|
+
hash.update(part.bytes);
|
|
265
|
+
hash.update('\0');
|
|
266
|
+
}
|
|
267
|
+
return hash.digest('hex');
|
|
268
|
+
}
|
|
269
|
+
function policyAt(commit, trusted, checkWorking) {
|
|
270
|
+
const loaded = readPolicySet(commit, checkWorking);
|
|
169
271
|
const readBlob = (source, pinnedCommit) => {
|
|
170
272
|
if (pinnedCommit === undefined) return blobAt(commit, source).bytes;
|
|
171
|
-
// A pinned evidence commit decouples the digest from later edits to the
|
|
172
|
-
// evidence file; the pinned commit itself must be part of trusted history.
|
|
173
273
|
git('merge-base', '--is-ancestor', pinnedCommit, trusted);
|
|
174
274
|
return blobAt(pinnedCommit, source).bytes;
|
|
175
275
|
};
|
|
176
|
-
return validateRosterDocument(
|
|
276
|
+
return { policy: validateRosterDocument(assembleRosterPolicy(loaded.models, loaded.roles), readBlob), digest: loaded.digest };
|
|
177
277
|
}
|
|
178
278
|
export function loadTrustedRosterPolicy() {
|
|
179
279
|
const snapshot = control();
|
|
180
|
-
const {
|
|
181
|
-
if (!workingBytes(POLICY).equals(bytes)) refuse('working policy differs from committed bytes');
|
|
182
|
-
const policy = validate(bytes, snapshot.head, snapshot.trusted);
|
|
280
|
+
const { policy, digest } = policyAt(snapshot.head, snapshot.trusted, true);
|
|
183
281
|
unchanged(snapshot);
|
|
184
|
-
return frozen({ identity: { commit: snapshot.head, blob, sha256:
|
|
282
|
+
return frozen({ identity: { commit: snapshot.head, blob: digest, sha256: digest }, policy });
|
|
185
283
|
}
|
|
186
284
|
export function resolveBoundPolicy(identity) {
|
|
187
285
|
keys(identity, ['commit', 'blob', 'sha256'], 'identity');
|
|
188
|
-
objectId(identity.commit, 'commit');
|
|
286
|
+
objectId(identity.commit, 'commit'); digest(identity.blob); digest(identity.sha256);
|
|
189
287
|
const snapshot = control();
|
|
190
288
|
git('merge-base', '--is-ancestor', identity.commit, snapshot.trusted);
|
|
191
|
-
const
|
|
192
|
-
if (
|
|
193
|
-
const policy = validate(bytes, identity.commit, snapshot.trusted);
|
|
289
|
+
const loaded = policyAt(identity.commit, snapshot.trusted, false);
|
|
290
|
+
if (loaded.digest !== identity.blob || loaded.digest !== identity.sha256) refuse('bound identity mismatch');
|
|
194
291
|
unchanged(snapshot);
|
|
195
|
-
return frozen({ identity: { ...identity }, policy });
|
|
292
|
+
return frozen({ identity: { ...identity }, policy: loaded.policy });
|
|
196
293
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Wall-clock guard for a test suite. Node's --test-timeout also bounds each
|
|
4
|
+
// file (each file is a test), so the suite scripts do not pass it. This
|
|
5
|
+
// bounds the process that runs them.
|
|
6
|
+
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
const timeoutMs = Number(process.argv[2]);
|
|
10
|
+
const command = process.argv[3];
|
|
11
|
+
const args = process.argv.slice(4);
|
|
12
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || command === undefined) {
|
|
13
|
+
console.error("usage: run-bounded.mjs <timeout-ms> <command> [args...]");
|
|
14
|
+
process.exit(2);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const child = spawn(command, args, { stdio: "inherit", detached: true });
|
|
18
|
+
let timedOut = false;
|
|
19
|
+
|
|
20
|
+
function signalGroup(signal) {
|
|
21
|
+
if (child.pid === undefined) return;
|
|
22
|
+
try {
|
|
23
|
+
process.kill(-child.pid, signal);
|
|
24
|
+
} catch {
|
|
25
|
+
try { child.kill(signal); } catch { /* already gone */ }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const timer = setTimeout(() => {
|
|
30
|
+
timedOut = true;
|
|
31
|
+
console.error(`wall clock ${timeoutMs}ms exceeded`);
|
|
32
|
+
signalGroup("SIGTERM");
|
|
33
|
+
setTimeout(() => signalGroup("SIGKILL"), 2000).unref();
|
|
34
|
+
}, timeoutMs);
|
|
35
|
+
|
|
36
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
37
|
+
process.on(signal, () => {
|
|
38
|
+
signalGroup(signal);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
child.on("error", (error) => {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
console.error(error.message);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
child.on("exit", (code, signal) => {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
if (timedOut) process.exit(124);
|
|
51
|
+
if (signal) process.exit(1);
|
|
52
|
+
process.exit(code ?? 1);
|
|
53
|
+
});
|