mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
merge: slice 05 — rigbundles first-class (5 cross-primitive kinds + gstack HYBRID proof) (release-0.3.2)
This commit is contained in:
commit
0c9e63f10e
27 files changed
+8353
-17
No files matched your search
@@ -1,10 +1,46 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import nodePath from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
/**
|
||||
* Read the CLI's own package.json version at call time (Item 1 / slice-05).
|
||||
* Function-level read on purpose: module-level constants would mask test
|
||||
* isolation per the audit-every-layer discipline.
|
||||
*/
|
||||
function getCliVersion(): string {
|
||||
try {
|
||||
const here = fileURLToPath(import.meta.url);
|
||||
const pkgPath = nodePath.join(nodePath.dirname(here), "..", "..", "package.json");
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version?: unknown };
|
||||
return typeof pkg.version === "string" ? pkg.version : "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the provenance block the CLI sends to /api/bundles/create. Reads
|
||||
* hostname, session name (from canonical OPENRIG_SESSION_NAME env), and
|
||||
* CLI version at call time. Operator notes come from the --notes flag.
|
||||
* Daemon adds daemonVersion server-side.
|
||||
*/
|
||||
function buildClientProvenance(notes: string | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {
|
||||
sourceHost: os.hostname(),
|
||||
cliVersion: getCliVersion(),
|
||||
};
|
||||
const session = process.env.OPENRIG_SESSION_NAME;
|
||||
if (typeof session === "string" && session.length > 0) out.authorSession = session;
|
||||
if (typeof notes === "string" && notes.length > 0) out.notes = notes;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("bundle").description("Manage rig bundles");
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
@@ -26,17 +62,38 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
.option("--bundle-version <ver>", "Bundle version", "0.1.0")
|
||||
.option("--include-packages <refs...>", "Package refs to include (default: all from spec)")
|
||||
.option("--rig-root <root>", "Root directory for pod-aware resolution")
|
||||
.option("--notes <text>", "Operator notes captured in bundle provenance metadata")
|
||||
.option("--min-daemon-version <ver>", "Minimum daemon version required to install this bundle (Item 2 compatibility)")
|
||||
.option("--min-cli-version <ver>", "Minimum CLI version required to install this bundle (Item 2 compatibility)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (spec: string, opts: { output: string; name: string; bundleVersion: string; includePackages?: string[]; rigRoot?: string; json?: boolean }) => {
|
||||
.action(async (spec: string, opts: { output: string; name: string; bundleVersion: string; includePackages?: string[]; rigRoot?: string; notes?: string; minDaemonVersion?: string; minCliVersion?: string; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
const client = await getClient(deps);
|
||||
if (!client) { process.exitCode = 1; return; }
|
||||
|
||||
// Item 2 / slice-05: build compatibility from operator flags. Only included
|
||||
// in the request body when at least one of the two flags is set.
|
||||
const compatibility: Record<string, string> = {};
|
||||
if (opts.minDaemonVersion) compatibility.minDaemonVersion = opts.minDaemonVersion;
|
||||
if (opts.minCliVersion) compatibility.minCliVersion = opts.minCliVersion;
|
||||
const hasCompatibility = Object.keys(compatibility).length > 0;
|
||||
|
||||
// QA-20260601 A1 repair (banked banked-create-install-long-running):
|
||||
// /create can take seconds-to-minutes (pod-aware assembler walk +
|
||||
// Item-6 cross-primitive vendoring + integrity-over-all-content +
|
||||
// tar pack). Default 5000ms CLI timeout (client.ts:31) is too short
|
||||
// for real-world bundles; daemon completes work but CLI reports
|
||||
// failure. Per-call 120s upper bound covers reasonable real-world
|
||||
// bundle sizes; if a bundle author hits this, the daemon-side
|
||||
// operation is async-completable in a future /bundles/jobs/<id>
|
||||
// surface (not in slice-05 scope).
|
||||
const res = await client.post<Record<string, unknown>>("/api/bundles/create", {
|
||||
specPath: spec, bundleName: opts.name, bundleVersion: opts.bundleVersion, outputPath: opts.output,
|
||||
includePackages: opts.includePackages,
|
||||
rigRoot: opts.rigRoot ? nodePath.resolve(opts.rigRoot) : undefined,
|
||||
});
|
||||
provenance: buildClientProvenance(opts.notes),
|
||||
...(hasCompatibility ? { compatibility } : {}),
|
||||
}, { timeoutMs: 120_000 });
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
@@ -92,15 +149,31 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
.option("--plan", "Plan mode")
|
||||
.option("--yes", "Auto-approve")
|
||||
.option("--target <root>", "Target root directory")
|
||||
.option("--skip-version-check", "Operator-explicit override of the Item-2 install-time compatibility check (NOT recommended for routine use)")
|
||||
.option("--force", "Operator-explicit override of the Item-3 install-time conflict check (NOT recommended; conflicts may produce partial install state)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (bundlePath: string, opts: { plan?: boolean; yes?: boolean; target?: string; json?: boolean }) => {
|
||||
.action(async (bundlePath: string, opts: { plan?: boolean; yes?: boolean; target?: string; skipVersionCheck?: boolean; force?: boolean; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
const client = await getClient(deps);
|
||||
if (!client) { process.exitCode = 1; return; }
|
||||
|
||||
// QA-20260601 A1 repair: /install completes a full bootstrap run
|
||||
// (resolve → vendor sibling primitives → boot rig sessions) which
|
||||
// can take many seconds for real bundles. Default 5000ms CLI
|
||||
// timeout (client.ts:31) caused CLI to report failure while
|
||||
// daemon completed the mutating install — operator-unsafe retry
|
||||
// path (rig_name_collision on second attempt). Bumped to 120s.
|
||||
const res = await client.post<Record<string, unknown>>("/api/bundles/install", {
|
||||
bundlePath, plan: opts.plan ?? false, autoApprove: opts.yes ?? false, targetRoot: opts.target,
|
||||
});
|
||||
// Item 2 / slice-05 Checkpoint 3.3: send CLI version + skip flag for the
|
||||
// daemon-side install-time compatibility check. CLI version read at call
|
||||
// time (no module-level constant) via the existing getCliVersion helper.
|
||||
cliVersion: getCliVersion(),
|
||||
skipVersionCheck: opts.skipVersionCheck ?? false,
|
||||
// Item 3 / slice-05 Checkpoint 4.2: send force flag for the daemon-side
|
||||
// install-time conflict check. Operator-explicit override only.
|
||||
force: opts.force ?? false,
|
||||
}, { timeoutMs: 120_000 });
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
@@ -118,5 +191,48 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
if (res.data["rigId"]) console.log(`Rig: ${res.data["rigId"]}`);
|
||||
});
|
||||
|
||||
// rig bundle history — Item 4 / slice-05 Checkpoint 5.2
|
||||
cmd.command("history")
|
||||
.description("List bundle install audit records from ~/.openrig/bundle-audit.jsonl")
|
||||
.option("--rig <name>", "Filter to records whose targetRigName matches")
|
||||
.option("--since <iso>", "Filter to records installedAt >= this ISO timestamp")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (opts: { rig?: string; since?: string; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
const client = await getClient(deps);
|
||||
if (!client) { process.exitCode = 1; return; }
|
||||
|
||||
const qs = new URLSearchParams();
|
||||
if (opts.rig) qs.set("rig", opts.rig);
|
||||
if (opts.since) qs.set("since", opts.since);
|
||||
const query = qs.toString();
|
||||
const path = query.length > 0 ? `/api/bundles/history?${query}` : "/api/bundles/history";
|
||||
|
||||
const res = await client.get<Record<string, unknown>>(path);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
if (res.status >= 400) process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
console.error(res.data["error"] ?? "History fetch failed");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
const records = Array.isArray(res.data["records"]) ? res.data["records"] as Array<Record<string, unknown>> : [];
|
||||
if (records.length === 0) {
|
||||
console.log("No bundle install audit records found.");
|
||||
return;
|
||||
}
|
||||
console.log(`Bundle install history (${records.length} record${records.length === 1 ? "" : "s"}):`);
|
||||
for (const r of records) {
|
||||
const at = r["installedAt"] ?? "?";
|
||||
const rig = r["targetRigName"] ?? "?";
|
||||
const outcome = r["outcome"] ?? "?";
|
||||
const bundle = r["bundlePath"] ?? "?";
|
||||
console.log(` ${at} ${outcome.toString().padEnd(8)} rig=${rig} ${bundle}`);
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -54,6 +54,8 @@ function runningDeps(port: number): StatusDeps {
|
||||
|
||||
// Captured create bodies for assertion
|
||||
let capturedCreateBodies: Record<string, unknown>[] = [];
|
||||
// Captured install bodies for assertion (Item 2 Checkpoint 3.3)
|
||||
let capturedInstallBodies: Record<string, unknown>[] = [];
|
||||
|
||||
describe("Bundle CLI", () => {
|
||||
let server: http.Server;
|
||||
@@ -90,6 +92,7 @@ describe("Bundle CLI", () => {
|
||||
res.end(JSON.stringify({ manifest: { name: "test", version: "0.1.0" }, digestValid: true, integrityResult: { passed: true } }));
|
||||
} else if (req.url === "/api/bundles/install" && req.method === "POST") {
|
||||
const parsed = JSON.parse(body);
|
||||
capturedInstallBodies.push(parsed);
|
||||
if (String(parsed.bundlePath ?? "").includes("blocked")) {
|
||||
res.writeHead(409, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "blocked" }));
|
||||
@@ -102,6 +105,17 @@ describe("Bundle CLI", () => {
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "completed", runId: "run-2", rigId: "rig-1" }));
|
||||
}
|
||||
} else if (req.url?.startsWith("/api/bundles/history") && req.method === "GET") {
|
||||
// Capture query for assertion if needed; for now return a static fixture
|
||||
const url = new URL(req.url, "http://x");
|
||||
const rigFilter = url.searchParams.get("rig");
|
||||
const allRecs = [
|
||||
{ installedAt: "2026-05-18T10:00:00Z", bundlePath: "/tmp/a.rigbundle", targetRigName: "alpha", outcome: "success" },
|
||||
{ installedAt: "2026-05-18T11:00:00Z", bundlePath: "/tmp/b.rigbundle", targetRigName: "beta", outcome: "failed" },
|
||||
];
|
||||
const recs = rigFilter ? allRecs.filter((r) => r.targetRigName === rigFilter) : allRecs;
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ records: recs, total: recs.length }));
|
||||
} else {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
@@ -211,4 +225,207 @@ describe("Bundle CLI", () => {
|
||||
expect(createBody).toBeTruthy();
|
||||
expect(createBody!["rigRoot"]).toMatch(/\/my\/project/);
|
||||
});
|
||||
|
||||
// Item 1 / slice-05: --notes flag is captured into provenance in the request body
|
||||
it("bundle create --notes wires the operator note into provenance.notes in the request body", async () => {
|
||||
capturedCreateBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
"--notes", "checkpoint-2-part-3 fixture",
|
||||
]);
|
||||
});
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
const provenance = createBody!["provenance"] as Record<string, unknown> | undefined;
|
||||
expect(provenance).toBeTruthy();
|
||||
expect(provenance!["notes"]).toBe("checkpoint-2-part-3 fixture");
|
||||
});
|
||||
|
||||
// Item 1 / slice-05: provenance auto-includes hostname + cliVersion at invoke time
|
||||
it("bundle create automatically includes hostname + cliVersion in provenance (no flag needed)", async () => {
|
||||
capturedCreateBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
]);
|
||||
});
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
const provenance = createBody!["provenance"] as Record<string, unknown> | undefined;
|
||||
expect(provenance).toBeTruthy();
|
||||
// hostname and cliVersion auto-populate (real os.hostname() + CLI package.json read)
|
||||
expect(typeof provenance!["sourceHost"]).toBe("string");
|
||||
expect((provenance!["sourceHost"] as string).length).toBeGreaterThan(0);
|
||||
expect(typeof provenance!["cliVersion"]).toBe("string");
|
||||
expect((provenance!["cliVersion"] as string).length).toBeGreaterThan(0);
|
||||
// notes is undefined when --notes not passed (no empty string sent)
|
||||
expect(provenance!["notes"]).toBeUndefined();
|
||||
});
|
||||
|
||||
// Item 2 / slice-05: --min-daemon-version + --min-cli-version flags wire into request body compatibility
|
||||
it("bundle create --min-daemon-version and --min-cli-version wire into request body compatibility", async () => {
|
||||
capturedCreateBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
"--min-daemon-version", "0.3.2",
|
||||
"--min-cli-version", "0.3.2",
|
||||
]);
|
||||
});
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
const compatibility = createBody!["compatibility"] as Record<string, unknown> | undefined;
|
||||
expect(compatibility).toBeTruthy();
|
||||
expect(compatibility!["minDaemonVersion"]).toBe("0.3.2");
|
||||
expect(compatibility!["minCliVersion"]).toBe("0.3.2");
|
||||
});
|
||||
|
||||
// Item 2 / slice-05: --min-daemon-version alone (partial) still wires
|
||||
it("bundle create --min-daemon-version alone wires partial compatibility into request body", async () => {
|
||||
capturedCreateBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
"--min-daemon-version", "0.3.2",
|
||||
]);
|
||||
});
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
const compatibility = createBody!["compatibility"] as Record<string, unknown> | undefined;
|
||||
expect(compatibility).toBeTruthy();
|
||||
expect(compatibility!["minDaemonVersion"]).toBe("0.3.2");
|
||||
expect(compatibility!["minCliVersion"]).toBeUndefined();
|
||||
});
|
||||
|
||||
// Item 2 / slice-05 Checkpoint 3.3: bundle install --skip-version-check wires through
|
||||
it("bundle install --skip-version-check sets skipVersionCheck=true in request body", async () => {
|
||||
capturedInstallBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "install", "/tmp/test.rigbundle",
|
||||
"--yes", "--target", "/tmp/target",
|
||||
"--skip-version-check",
|
||||
]);
|
||||
});
|
||||
const installBody = capturedInstallBodies[capturedInstallBodies.length - 1];
|
||||
expect(installBody).toBeTruthy();
|
||||
expect(installBody!["skipVersionCheck"]).toBe(true);
|
||||
// cliVersion auto-included (read at call time via getCliVersion)
|
||||
expect(typeof installBody!["cliVersion"]).toBe("string");
|
||||
expect((installBody!["cliVersion"] as string).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("bundle install without --skip-version-check sets skipVersionCheck=false and still sends cliVersion", async () => {
|
||||
capturedInstallBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "install", "/tmp/test.rigbundle",
|
||||
"--yes", "--target", "/tmp/target",
|
||||
]);
|
||||
});
|
||||
const installBody = capturedInstallBodies[capturedInstallBodies.length - 1];
|
||||
expect(installBody).toBeTruthy();
|
||||
expect(installBody!["skipVersionCheck"]).toBe(false);
|
||||
expect(typeof installBody!["cliVersion"]).toBe("string");
|
||||
});
|
||||
|
||||
// Item 3 / slice-05 Checkpoint 4.2: --force flag wires through to request body
|
||||
it("bundle install --force sets force=true in request body", async () => {
|
||||
capturedInstallBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "install", "/tmp/test.rigbundle",
|
||||
"--yes", "--target", "/tmp/target",
|
||||
"--force",
|
||||
]);
|
||||
});
|
||||
const installBody = capturedInstallBodies[capturedInstallBodies.length - 1];
|
||||
expect(installBody).toBeTruthy();
|
||||
expect(installBody!["force"]).toBe(true);
|
||||
});
|
||||
|
||||
it("bundle install without --force sets force=false in request body (operator-explicit opt-in only)", async () => {
|
||||
capturedInstallBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "install", "/tmp/test.rigbundle",
|
||||
"--yes", "--target", "/tmp/target",
|
||||
]);
|
||||
});
|
||||
const installBody = capturedInstallBodies[capturedInstallBodies.length - 1];
|
||||
expect(installBody).toBeTruthy();
|
||||
expect(installBody!["force"]).toBe(false);
|
||||
});
|
||||
|
||||
// Item 4 / slice-05 Checkpoint 5.2: rig bundle history subcommand
|
||||
it("bundle history renders records as text by default", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "bundle", "history"]);
|
||||
});
|
||||
expect(logs.some((l) => l.includes("Bundle install history"))).toBe(true);
|
||||
expect(logs.some((l) => l.includes("alpha"))).toBe(true);
|
||||
expect(logs.some((l) => l.includes("beta"))).toBe(true);
|
||||
expect(logs.some((l) => l.includes("success"))).toBe(true);
|
||||
});
|
||||
|
||||
it("bundle history --json outputs parseable JSON with records array", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "bundle", "history", "--json"]);
|
||||
});
|
||||
const parsed = JSON.parse(logs.join(""));
|
||||
expect(Array.isArray(parsed.records)).toBe(true);
|
||||
expect(parsed.records.length).toBe(2);
|
||||
expect(parsed.total).toBe(2);
|
||||
});
|
||||
|
||||
it("bundle history --rig filter passes through to the query string", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "bundle", "history", "--rig", "alpha", "--json"]);
|
||||
});
|
||||
const parsed = JSON.parse(logs.join(""));
|
||||
expect(parsed.records).toHaveLength(1);
|
||||
expect(parsed.records[0].targetRigName).toBe("alpha");
|
||||
});
|
||||
|
||||
// Item 2 / slice-05: no flags → compatibility omitted (no empty object sent)
|
||||
it("bundle create with neither min-version flag omits compatibility from request body", async () => {
|
||||
capturedCreateBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
]);
|
||||
});
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
expect(createBody!["compatibility"]).toBeUndefined();
|
||||
});
|
||||
|
||||
// Item 1 / slice-05: authorSession populates when OPENRIG_SESSION_NAME env is set
|
||||
it("bundle create includes authorSession when OPENRIG_SESSION_NAME env is set", async () => {
|
||||
capturedCreateBodies = [];
|
||||
const origEnv = process.env.OPENRIG_SESSION_NAME;
|
||||
process.env.OPENRIG_SESSION_NAME = "velocity-driver@openrig-velocity";
|
||||
try {
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
]);
|
||||
});
|
||||
} finally {
|
||||
if (origEnv === undefined) delete process.env.OPENRIG_SESSION_NAME;
|
||||
else process.env.OPENRIG_SESSION_NAME = origEnv;
|
||||
}
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
const provenance = createBody!["provenance"] as Record<string, unknown> | undefined;
|
||||
expect(provenance).toBeTruthy();
|
||||
expect(provenance!["authorSession"]).toBe("velocity-driver@openrig-velocity");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Bundle agent_images router (Item 6 / slice-05 Checkpoint 7.3g step 2).
|
||||
*
|
||||
* Pure function. Copies agent-image directories declared in a bundle
|
||||
* manifest's agent_images[] block from the bundle's extracted tree to the
|
||||
* operator agent-images library. No daemon dependencies — fully
|
||||
* unit-testable via FsOps injection.
|
||||
*
|
||||
* Per PRD §Item 6 line 197: agent_images entries are paths to the
|
||||
* agent-image DIRECTORY (not manifest paths — distinct shape from
|
||||
* context_packs per the e7a0b253 PRD-coherent repair). The router copies
|
||||
* the declared directory to <targetAgentImagesDir>/<basename(sourceAbs)>/
|
||||
* — the operator-host canonical layout per agent-image-types.ts:9-10.
|
||||
*
|
||||
* Consumer contract (agent-image-library-service.ts:77-95): the consumer
|
||||
* walks roots whose immediate children are image DIRS, requires each
|
||||
* image dir to be a directory + contain a manifest.yaml FILE. The router
|
||||
* enforces these consumer-visibility invariants at copy time so
|
||||
* routedCount stays truthful at the consumer-visible boundary.
|
||||
*
|
||||
* All four banked router-level catches from the context_packs cycle
|
||||
* (d491eca9 + 3cd581e3 + 16ebb8af + a0e7e0e1) apply identically and are
|
||||
* built in from the first commit:
|
||||
* 1. source path containment (mirrors all sibling routers)
|
||||
* 2. sourceAbs exists + isDirectory (declared path IS the image dir)
|
||||
* 3. manifest.yaml inside exists AS A FILE (consumer requires
|
||||
* readable manifest.yaml; rejects manifest-missing AND manifest-
|
||||
* is-a-directory cases — file-vs-dir discrimination axis)
|
||||
* 4. basename(sourceAbs) collision detection (first wins, second
|
||||
* flagged status=conflict)
|
||||
*
|
||||
* /install handler integration lands at Checkpoint 7.3g step 3 with a
|
||||
* real consumer-scan() proof against the routed dir (mirror of the
|
||||
* cb0bf7b9 context_packs ContextPackLibraryService.scan reachability
|
||||
* proof).
|
||||
*/
|
||||
|
||||
import nodePath from "node:path";
|
||||
|
||||
/** Filesystem injection point — real impl wraps node:fs. Tests substitute in-memory. */
|
||||
export interface AgentImagesRouterFsOps {
|
||||
exists: (path: string) => boolean;
|
||||
isDirectory: (path: string) => boolean;
|
||||
mkdirp: (path: string) => void;
|
||||
copyDir: (src: string, dest: string) => void;
|
||||
}
|
||||
|
||||
/** Inputs to routeAgentImages. */
|
||||
export interface RouteAgentImagesInput {
|
||||
/** Absolute path to the bundle's extracted root (tmp dir from unpack). */
|
||||
bundleRoot: string;
|
||||
/** Relative paths to agent-image DIRECTORIES declared in the bundle
|
||||
* manifest's agent_images[] block (per PRD §Item 6 line 197). */
|
||||
declaredAgentImages: string[];
|
||||
/** Absolute path to the operator agent-images library (per
|
||||
* agent-image-types.ts:9-10: typically `<openrigHome>/agent-images/`
|
||||
* or workspace-local `.openrig/agent-images/`). Caller resolves. */
|
||||
targetAgentImagesDir: string;
|
||||
}
|
||||
|
||||
/** One routed agent_image (or one rejection). */
|
||||
export interface RoutedAgentImageRecord {
|
||||
/** Declared path from manifest.agent_images[]. */
|
||||
declaredPath: string;
|
||||
/** "routed" = image dir copied successfully and consumer-visible.
|
||||
* "missing" = declared image dir not in bundle (honest skip).
|
||||
* "unsafe" = source escapes bundleRoot OR target escapes
|
||||
* targetAgentImagesDir (structurally impossible given basename, kept
|
||||
* defensive).
|
||||
* "not_directory" = declared path exists but is not a directory.
|
||||
* "not_manifest" = image dir exists but manifest.yaml inside is
|
||||
* absent OR is itself a directory — consumer requires a readable
|
||||
* manifest.yaml file at the image dir root.
|
||||
* "conflict" = basename collides with an earlier routed image; first
|
||||
* wins, second flagged so routedCount stays truthful at the consumer-
|
||||
* visible boundary (banked 16ebb8af lesson). */
|
||||
status: "routed" | "missing" | "unsafe" | "not_directory" | "not_manifest" | "conflict";
|
||||
/** Where the image landed in the target library (absolute image dir
|
||||
* path), if routed. */
|
||||
installedAt?: string;
|
||||
/** Human-readable detail (3-part error shape input for caller). */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Aggregate routing result. */
|
||||
export interface RouteAgentImagesResult {
|
||||
records: RoutedAgentImageRecord[];
|
||||
routedCount: number;
|
||||
rejectedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route each declared agent_image from the bundle tree to the operator
|
||||
* agent-images library. Per-entry safety: SOURCE containment under
|
||||
* bundleRoot + TARGET containment under targetAgentImagesDir. Consumer-
|
||||
* visibility invariants (declared dir IS a directory + manifest.yaml
|
||||
* inside exists AS A FILE) enforced BEFORE write so routedCount stays
|
||||
* truthful at the consumer-visible boundary. Caller writes the install
|
||||
* audit record using the records returned here.
|
||||
*/
|
||||
export function routeAgentImages(
|
||||
input: RouteAgentImagesInput,
|
||||
fs: AgentImagesRouterFsOps,
|
||||
): RouteAgentImagesResult {
|
||||
const records: RoutedAgentImageRecord[] = [];
|
||||
const bundleRootResolved = nodePath.resolve(input.bundleRoot);
|
||||
const targetRootResolved = nodePath.resolve(input.targetAgentImagesDir);
|
||||
fs.mkdirp(input.targetAgentImagesDir);
|
||||
// Track basenames already routed so collisions surface as conflict
|
||||
// records, not silent overwrites (banked workflow_specs +
|
||||
// context_packs lessons).
|
||||
const routedDirNames = new Set<string>();
|
||||
|
||||
for (const declared of input.declaredAgentImages) {
|
||||
const sourceAbs = nodePath.resolve(input.bundleRoot, declared);
|
||||
// Defense-in-depth path-containment on SOURCE (mirrors sibling routers).
|
||||
if (sourceAbs !== bundleRootResolved && !sourceAbs.startsWith(bundleRootResolved + nodePath.sep)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "unsafe",
|
||||
detail: `agent_image path '${declared}' escapes bundle workspace; rejected`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!fs.exists(sourceAbs)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "missing",
|
||||
detail: `agent_image dir '${declared}' not present in bundle; skipped`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!fs.isDirectory(sourceAbs)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "not_directory",
|
||||
detail: `agent_image '${declared}' exists but is not a directory; consumer requires the declared path to be an image dir`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Manifest-file existence check (banked d491eca9 lesson on
|
||||
// context_packs): the consumer (agent-image-library-service.ts:93)
|
||||
// skips any image dir whose manifest.yaml is absent. Reject pre-write.
|
||||
const manifestPath = nodePath.join(sourceAbs, "manifest.yaml");
|
||||
if (!fs.exists(manifestPath)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "not_manifest",
|
||||
detail: `agent_image '${declared}' is missing manifest.yaml; consumer-invisible (agent-image-library scan requires it)`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Manifest-type check (banked 3cd581e3 lesson on context_packs):
|
||||
// exists() returns true for both files and directories. If manifest.yaml
|
||||
// is a directory, the consumer's readFileSync would throw and scan()
|
||||
// records an error diagnostic instead of indexing the image. Reject.
|
||||
if (fs.isDirectory(manifestPath)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "not_manifest",
|
||||
detail: `agent_image '${declared}' has manifest.yaml as a directory, not a file; consumer requires a readable file`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const dirName = nodePath.basename(sourceAbs);
|
||||
const targetAbs = nodePath.resolve(input.targetAgentImagesDir, dirName);
|
||||
// Sanity check (basename is structurally safe but resolve could
|
||||
// theoretically be a no-op for "" → keep the check, expected always-
|
||||
// true).
|
||||
if (targetAbs !== targetRootResolved && !targetAbs.startsWith(targetRootResolved + nodePath.sep)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "unsafe",
|
||||
detail: `agent_image target path for '${declared}' escapes target agent-images library; rejected`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (routedDirNames.has(dirName)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "conflict",
|
||||
detail: `agent_image basename '${dirName}' collides with an earlier declared path; only the first is routed (banked collision-detection lesson)`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
fs.copyDir(sourceAbs, targetAbs);
|
||||
routedDirNames.add(dirName);
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "routed",
|
||||
installedAt: targetAbs,
|
||||
});
|
||||
}
|
||||
|
||||
const routedCount = records.filter((r) => r.status === "routed").length;
|
||||
const rejectedCount = records.length - routedCount;
|
||||
return { records, routedCount, rejectedCount };
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import nodePath from "node:path";
|
||||
import { LegacyRigSpecCodec as RigSpecCodec } from "./rigspec-codec.js"; // TODO: AS-T08b — migrate to pod-aware RigSpec
|
||||
import { LegacyRigSpecSchema as RigSpecSchema } from "./rigspec-schema.js"; // TODO: AS-T08b — migrate to pod-aware RigSpec
|
||||
// TODO: AS-T12 — migrate to pod-aware bundle types
|
||||
import { serializeLegacyBundleManifest as serializeBundleManifest, type LegacyBundleManifest as BundleManifest } from "./bundle-types.js";
|
||||
import { serializeLegacyBundleManifest as serializeBundleManifest, type LegacyBundleManifest as BundleManifest, type BundleProvenance, type BundleCompatibility } from "./bundle-types.js";
|
||||
|
||||
export interface AssemblerFsOps {
|
||||
readFile: (path: string) => string;
|
||||
@@ -26,6 +26,23 @@ export interface AssembleOptions {
|
||||
outputDir: string;
|
||||
bundleName: string;
|
||||
bundleVersion: string;
|
||||
/**
|
||||
* Optional Item-1 provenance input. Caller fills the fields it knows
|
||||
* (sourceHost, authorSession, daemonVersion, cliVersion, sourceRigId,
|
||||
* sourceRigName, notes). The assembler mirrors root createdAt into
|
||||
* provenance.createdAt unless caller pre-set it (caller value wins
|
||||
* for test determinism). Missing block = no provenance recorded
|
||||
* (backward compat).
|
||||
*/
|
||||
provenance?: BundleProvenance;
|
||||
/**
|
||||
* Optional Item-2 compatibility input. Caller declares minimum
|
||||
* daemon + CLI versions and optional schema_version reaffirmation.
|
||||
* Missing block = no compatibility recorded (backward compat); the
|
||||
* install-time version check at /api/bundles/install (Checkpoint 3.3)
|
||||
* is a no-op for bundles without compatibility.
|
||||
*/
|
||||
compatibility?: BundleCompatibility;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,14 +121,24 @@ export class LegacyBundleAssembler {
|
||||
}
|
||||
|
||||
// Generate manifest (no integrity — P7-T02 adds it)
|
||||
const createdAt = new Date().toISOString();
|
||||
const manifest: BundleManifest = {
|
||||
schemaVersion: 1,
|
||||
name: opts.bundleName,
|
||||
version: opts.bundleVersion,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdAt,
|
||||
rigSpec: "rig.yaml",
|
||||
packages: packageEntries,
|
||||
};
|
||||
if (opts.provenance) {
|
||||
manifest.provenance = {
|
||||
...opts.provenance,
|
||||
createdAt: opts.provenance.createdAt ?? createdAt,
|
||||
};
|
||||
}
|
||||
if (opts.compatibility) {
|
||||
manifest.compatibility = { ...opts.compatibility };
|
||||
}
|
||||
|
||||
// Write bundle.yaml
|
||||
this.fs.writeFile(
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Bundle install audit trail (Item 4 / slice-05 Checkpoint 5.1).
|
||||
*
|
||||
* Append-only JSONL log of bundle install events. Matches the JSONL audit-
|
||||
* trail convention used by Transcript / Log Cleanup v0 and UI Enhancement
|
||||
* Pack v0 — no new SQLite schema or migration.
|
||||
*
|
||||
* Default location is ~/.openrig/bundle-audit.jsonl. The writer is a pure
|
||||
* data sink: callers (the /api/bundles/install route handler) compose the
|
||||
* record at the right moment in the install lifecycle and hand it to
|
||||
* BundleAuditWriter.append. The reader exposes a small filter API
|
||||
* (rig name + since timestamp) for the /api/bundles/history endpoint and
|
||||
* the `rig bundle history` CLI subcommand (both land at Checkpoint 5.2).
|
||||
*
|
||||
* FsOps injection mirrors the codebase pattern for testability: real
|
||||
* implementation reads/writes node:fs; tests substitute an in-memory mock.
|
||||
*/
|
||||
|
||||
import nodePath from "node:path";
|
||||
|
||||
/** A single bundle install audit row. */
|
||||
export interface BundleAuditRecord {
|
||||
/** ISO timestamp of when the install event was recorded. */
|
||||
installedAt: string;
|
||||
/** Source bundle path the install operated on. */
|
||||
bundlePath: string;
|
||||
/** SHA-256 archive hash (when available from bundle's sibling digest). */
|
||||
archiveHash?: string;
|
||||
/** ULID of the rig the install targeted (when applicable). */
|
||||
targetRigId?: string;
|
||||
/** Name of the rig the install targeted (when applicable). */
|
||||
targetRigName?: string;
|
||||
/** Hostname recorded in the bundle's provenance (Item 1), when present. */
|
||||
sourceHost?: string;
|
||||
/** Daemon version at install time. */
|
||||
daemonVersion?: string;
|
||||
/** CLI version at install time (when CLI sent it; Item 1 + Item 2 path). */
|
||||
cliVersion?: string;
|
||||
/** Install outcome — keep honest (partial = some stages ok, some not). */
|
||||
outcome: "success" | "failed" | "partial";
|
||||
}
|
||||
|
||||
/** Filesystem injection point — real impl wraps node:fs. Tests substitute in-memory. */
|
||||
export interface BundleAuditFsOps {
|
||||
appendFile: (path: string, content: string) => void;
|
||||
readFile: (path: string) => string;
|
||||
exists: (path: string) => boolean;
|
||||
mkdirp: (path: string) => void;
|
||||
}
|
||||
|
||||
/** Configuration for the audit writer + reader. */
|
||||
export interface BundleAuditOpts {
|
||||
/** Absolute path to the JSONL file (default location: ~/.openrig/bundle-audit.jsonl). */
|
||||
auditPath: string;
|
||||
}
|
||||
|
||||
/** Append-only writer for bundle install audit records. */
|
||||
export class BundleAuditWriter {
|
||||
private opts: BundleAuditOpts;
|
||||
private fs: BundleAuditFsOps;
|
||||
|
||||
constructor(deps: { opts: BundleAuditOpts; fsOps: BundleAuditFsOps }) {
|
||||
this.opts = deps.opts;
|
||||
this.fs = deps.fsOps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one record to the JSONL file. Creates the parent directory if
|
||||
* missing. Each record is one JSON object on its own line (operator can
|
||||
* tail -f for live monitoring). Throws on filesystem errors via fsOps.
|
||||
*/
|
||||
append(record: BundleAuditRecord): void {
|
||||
this.fs.mkdirp(nodePath.dirname(this.opts.auditPath));
|
||||
const line = `${JSON.stringify(record)}\n`;
|
||||
this.fs.appendFile(this.opts.auditPath, line);
|
||||
}
|
||||
}
|
||||
|
||||
/** Filters supported by the audit reader (subset of /api/bundles/history). */
|
||||
export interface BundleAuditFilters {
|
||||
/** When set, only return records whose targetRigName equals this value. */
|
||||
rig?: string;
|
||||
/** When set, only return records whose installedAt is >= this ISO timestamp. */
|
||||
since?: string;
|
||||
}
|
||||
|
||||
/** Reader for the bundle install audit JSONL file. */
|
||||
export class BundleAuditReader {
|
||||
private opts: BundleAuditOpts;
|
||||
private fs: BundleAuditFsOps;
|
||||
|
||||
constructor(deps: { opts: BundleAuditOpts; fsOps: BundleAuditFsOps }) {
|
||||
this.opts = deps.opts;
|
||||
this.fs = deps.fsOps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all audit records (optionally filtered). Malformed lines are
|
||||
* skipped silently — forward-compat with future record-shape evolutions
|
||||
* that don't parse under the current shape; the readable lines still
|
||||
* surface. Records are returned in append order (oldest first).
|
||||
*/
|
||||
list(filters?: BundleAuditFilters): BundleAuditRecord[] {
|
||||
if (!this.fs.exists(this.opts.auditPath)) return [];
|
||||
const content = this.fs.readFile(this.opts.auditPath);
|
||||
const lines = content.split("\n").filter((l) => l.length > 0);
|
||||
const records: BundleAuditRecord[] = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(line);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
records.push(parsed as BundleAuditRecord);
|
||||
}
|
||||
} catch {
|
||||
// Malformed line — skip silently.
|
||||
}
|
||||
}
|
||||
return applyFilters(records, filters);
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply the supported filters to an in-memory record array. */
|
||||
function applyFilters(records: BundleAuditRecord[], filters?: BundleAuditFilters): BundleAuditRecord[] {
|
||||
if (!filters) return records;
|
||||
let out = records;
|
||||
if (filters.rig !== undefined && filters.rig.length > 0) {
|
||||
const rigName = filters.rig;
|
||||
out = out.filter((r) => r.targetRigName === rigName);
|
||||
}
|
||||
if (filters.since !== undefined && filters.since.length > 0) {
|
||||
const since = filters.since;
|
||||
out = out.filter((r) => r.installedAt >= since);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Bundle conflict detector (Item 3 / slice-05 Checkpoint 4.1).
|
||||
*
|
||||
* Pure function. Compares a bundle's declared identifiers against the daemon's
|
||||
* current state and returns a structured conflict report. Callers (route
|
||||
* handlers in Checkpoint 4.2) provide the daemon state (listRigs() etc.); the
|
||||
* detector itself has no daemon dependencies — fully unit-testable.
|
||||
*
|
||||
* Fails CLOSED: the conflict list is the source of truth. Missing or ambiguous
|
||||
* input is treated as no-conflict-detectable, NOT as bypass — Items 4.2+ will
|
||||
* surface ambiguity as a conflict where appropriate. This commit ships the
|
||||
* rig-name collision check only; agent / port / managed-app / sibling-primitive
|
||||
* checks land in Checkpoint 4.3 + 4.4.
|
||||
*
|
||||
* Extends, does NOT replace, the existing 409 "install already in progress"
|
||||
* guard at /api/bundles/install (concurrency lock) per PRD Item 3 §
|
||||
* "extends, does NOT replace".
|
||||
*/
|
||||
|
||||
/** A rig-name collision: bundle declares a rig name that a currently-running rig already uses. */
|
||||
export interface RigNameCollision {
|
||||
kind: "rig_name_collision";
|
||||
/** Name declared by the bundle's rig spec. */
|
||||
bundleRigName: string;
|
||||
/** Identity of the running rig that holds the same name. */
|
||||
collisionWith: { rigId: string; rigName: string };
|
||||
/** Human-readable summary suitable for the 3-part error description line. */
|
||||
description: string;
|
||||
/** Operator-actionable resolutions (3-part error what-to-do line). */
|
||||
resolutions: string[];
|
||||
}
|
||||
|
||||
/** Discriminated union over all conflict kinds. Extends in Checkpoint 4.3+. */
|
||||
export type BundleConflict = RigNameCollision;
|
||||
|
||||
/** Result of a conflict check. */
|
||||
export interface ConflictReport {
|
||||
conflicts: BundleConflict[];
|
||||
hasConflicts: boolean;
|
||||
}
|
||||
|
||||
/** Input to detectBundleConflicts. Pure data; no daemon handles. */
|
||||
export interface DetectConflictsInput {
|
||||
/** Rig name declared in the bundle's rig spec (rig.yaml `name:` field). */
|
||||
bundleRigName: string;
|
||||
/** Snapshot of currently-running rigs from rigRepo.listRigs(). */
|
||||
runningRigs: Array<{ rigId: string; name: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the conflict checks for an install candidate. Returns the full conflict
|
||||
* list (Checkpoint 4.2 returns it via /install --plan; --apply blocks on
|
||||
* non-empty unless --force).
|
||||
*/
|
||||
export function detectBundleConflicts(input: DetectConflictsInput): ConflictReport {
|
||||
const conflicts: BundleConflict[] = [];
|
||||
|
||||
// Rig-name collision: bundle declares a name a running rig already holds.
|
||||
// O(n) scan over runningRigs — acceptable for the seat-scale rig counts the
|
||||
// daemon manages (tens, not thousands).
|
||||
if (input.bundleRigName && input.bundleRigName.length > 0) {
|
||||
for (const rig of input.runningRigs) {
|
||||
if (rig.name === input.bundleRigName) {
|
||||
conflicts.push({
|
||||
kind: "rig_name_collision",
|
||||
bundleRigName: input.bundleRigName,
|
||||
collisionWith: { rigId: rig.rigId, rigName: rig.name },
|
||||
description: `bundle declares rig name '${input.bundleRigName}' but a running rig with this name already exists (rigId: ${rig.rigId})`,
|
||||
resolutions: [
|
||||
"use --target <newname> on install to rename the rig on install (lands at Checkpoint 4.2)",
|
||||
`stop the running rig first (e.g. rig down ${rig.name}) and re-attempt install`,
|
||||
"use --force on install for an operator-explicit override (lands at Checkpoint 4.2; NOT recommended for routine use)",
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { conflicts, hasConflicts: conflicts.length > 0 };
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Bundle context_packs router (Item 6 / slice-05 Checkpoint 7.3f step 2).
|
||||
*
|
||||
* Pure function. Copies context-pack directories declared in a bundle
|
||||
* manifest's context_packs[] block from the bundle's extracted tree to
|
||||
* the operator context-packs library. No daemon dependencies — fully
|
||||
* unit-testable via FsOps injection.
|
||||
*
|
||||
* Per PRD §Item 6 line 196: context_packs entries are paths to a
|
||||
* context-pack's manifest.yaml file inside the bundle. The PACK is the
|
||||
* PARENT DIRECTORY of that manifest.yaml (per context-pack-types.ts:9-10
|
||||
* and context-pack-library-service.ts:50-77: a pack is a directory whose
|
||||
* immediate children include manifest.yaml + the referenced content
|
||||
* files). The router copies that parent dir to
|
||||
* <targetContextPacksDir>/<basename(parentDir)>/ — the operator-host
|
||||
* canonical layout the live consumer scans.
|
||||
*
|
||||
* Banked degenerate-input dogfood discipline (extended from the
|
||||
* workflow_specs basename-collision + non-YAML cycles): every router
|
||||
* commit explicitly probes the failure-class edges through the live
|
||||
* consumer's contract before claiming routedCount truthful. For
|
||||
* context_packs the consumer-invisibility classes are:
|
||||
* - Declared path basename is NOT "manifest.yaml" → consumer never
|
||||
* scans (it looks for manifest.yaml at the pack dir root). status=
|
||||
* not_manifest.
|
||||
* - Two declared paths whose parent-dir basename collide → second
|
||||
* would silently overwrite the first; status=conflict; first wins.
|
||||
* - Source pack dir not present in bundle → status=missing (honest skip).
|
||||
* - Source path escapes bundleRoot → status=unsafe.
|
||||
*
|
||||
* Both-sides containment (banked
|
||||
* feedback_pre_existing_trust_boundary_reuse_canonical_helper addendum):
|
||||
* source-side under bundleRoot, target-side under targetContextPacksDir
|
||||
* (target via basename so structurally safe; defensive check retained).
|
||||
*
|
||||
* /install handler integration lands at Checkpoint 7.3f step 3 with a
|
||||
* real consumer-scan() proof against the routed dir (mirror of the
|
||||
* workflow_specs scanWorkflowSpecFolder reachability proof at d81456dc).
|
||||
*/
|
||||
|
||||
import nodePath from "node:path";
|
||||
|
||||
/** Filesystem injection point — real impl wraps node:fs. Tests substitute in-memory. */
|
||||
export interface ContextPacksRouterFsOps {
|
||||
exists: (path: string) => boolean;
|
||||
isDirectory: (path: string) => boolean;
|
||||
mkdirp: (path: string) => void;
|
||||
copyDir: (src: string, dest: string) => void;
|
||||
}
|
||||
|
||||
/** Inputs to routeContextPacks. */
|
||||
export interface RouteContextPacksInput {
|
||||
/** Absolute path to the bundle's extracted root (tmp dir from unpack). */
|
||||
bundleRoot: string;
|
||||
/** Relative paths to context-pack manifest.yaml files declared in the
|
||||
* bundle manifest's context_packs[] block. */
|
||||
declaredContextPacks: string[];
|
||||
/** Absolute path to the operator context-packs library (per
|
||||
* context-pack-types.ts:9-10: typically `<openrigHome>/context-packs`
|
||||
* or a workspace-local `.openrig/context-packs`). Caller resolves. */
|
||||
targetContextPacksDir: string;
|
||||
}
|
||||
|
||||
/** One routed context_pack (or one rejection). */
|
||||
export interface RoutedContextPackRecord {
|
||||
/** Declared path from manifest.context_packs[]. */
|
||||
declaredPath: string;
|
||||
/** "routed" = pack dir copied successfully and consumer-visible.
|
||||
* "missing" = pack manifest source not in bundle (honest skip).
|
||||
* "unsafe" = source escapes bundleRoot OR target escapes
|
||||
* targetContextPacksDir (structurally impossible given basename, kept
|
||||
* defensive).
|
||||
* "not_manifest" = declared path basename is not "manifest.yaml" —
|
||||
* consumer (context-pack-library-service.scan) scans pack dirs for a
|
||||
* top-level manifest.yaml file; any other basename is invisible-by-
|
||||
* construction.
|
||||
* "not_directory" = parent of the declared manifest path is not a
|
||||
* directory in the bundle tree.
|
||||
* "conflict" = parent-dir basename collides with an earlier routed
|
||||
* pack; first wins, second flagged so routedCount stays truthful at
|
||||
* the consumer-visible boundary (banked 16ebb8af lesson). */
|
||||
status: "routed" | "missing" | "unsafe" | "not_manifest" | "not_directory" | "conflict";
|
||||
/** Where the pack landed in the target library (absolute pack dir
|
||||
* path), if routed. */
|
||||
installedAt?: string;
|
||||
/** Human-readable detail (3-part error shape input for caller). */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Aggregate routing result. */
|
||||
export interface RouteContextPacksResult {
|
||||
records: RoutedContextPackRecord[];
|
||||
routedCount: number;
|
||||
rejectedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route each declared context_pack from the bundle tree to the operator
|
||||
* context-packs library. Per-entry safety: SOURCE containment under
|
||||
* bundleRoot + TARGET containment under targetContextPacksDir.
|
||||
* Degenerate inputs the consumer ignores (non-manifest.yaml basename;
|
||||
* parent-dir basename collisions) are caught BEFORE write so the
|
||||
* routedCount stays truthful at the consumer-visible boundary. Caller
|
||||
* writes the install audit record using the records returned here.
|
||||
*/
|
||||
export function routeContextPacks(
|
||||
input: RouteContextPacksInput,
|
||||
fs: ContextPacksRouterFsOps,
|
||||
): RouteContextPacksResult {
|
||||
const records: RoutedContextPackRecord[] = [];
|
||||
const bundleRootResolved = nodePath.resolve(input.bundleRoot);
|
||||
const targetRootResolved = nodePath.resolve(input.targetContextPacksDir);
|
||||
fs.mkdirp(input.targetContextPacksDir);
|
||||
// Track parent-dir basenames already routed so collisions surface as
|
||||
// conflict records, not silent overwrites (banked workflow_specs lesson
|
||||
// 16ebb8af).
|
||||
const routedDirNames = new Set<string>();
|
||||
|
||||
for (const declared of input.declaredContextPacks) {
|
||||
// Consumer-visibility prefilter: context-pack-library-service scans
|
||||
// pack dirs whose immediate child is manifest.yaml. Any declared path
|
||||
// whose basename isn't "manifest.yaml" routes to a dir the consumer
|
||||
// will never recognize as a pack. Reject pre-write so routedCount
|
||||
// stays truthful at the consumer-visible boundary.
|
||||
if (nodePath.basename(declared) !== "manifest.yaml") {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "not_manifest",
|
||||
detail: `context_pack '${declared}' basename is not 'manifest.yaml'; context-pack-library scans pack dirs for a top-level manifest.yaml`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const sourceAbs = nodePath.resolve(input.bundleRoot, declared);
|
||||
// Defense-in-depth path-containment on SOURCE (mirrors skills/
|
||||
// workflow_specs routers).
|
||||
if (sourceAbs !== bundleRootResolved && !sourceAbs.startsWith(bundleRootResolved + nodePath.sep)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "unsafe",
|
||||
detail: `context_pack path '${declared}' escapes bundle workspace; rejected`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Manifest-file existence check (banked B1 from a0e7e0e1 guard catch):
|
||||
// the consumer (context-pack-library-service.scan, line 76) skips any
|
||||
// pack dir whose manifest.yaml is absent. A parent-dir-exists-but-
|
||||
// manifest-missing pack would route as status=routed but be invisible
|
||||
// to the consumer — false-positive routedCount class. Check the file
|
||||
// ITSELF, not just the parent, to keep routedCount truthful at the
|
||||
// consumer-visible boundary.
|
||||
if (!fs.exists(sourceAbs)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "missing",
|
||||
detail: `context_pack manifest '${declared}' not present in bundle; skipped (consumer requires the file itself)`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Manifest-type check (banked B2 from d491eca9 guard catch): exists()
|
||||
// returns true for both files AND directories. If sourceAbs exists as
|
||||
// a directory (named manifest.yaml, but a dir not a file), the live
|
||||
// consumer's readFileSync(manifestPath) throws and scan() records an
|
||||
// error diagnostic instead of indexing a visible pack — false-positive
|
||||
// routedCount class. Reject pre-write so routedCount stays truthful at
|
||||
// the consumer-visible boundary.
|
||||
if (fs.isDirectory(sourceAbs)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "not_manifest",
|
||||
detail: `context_pack manifest '${declared}' exists but is a directory, not a file; consumer requires manifest.yaml to be a readable file`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const sourcePackDir = nodePath.dirname(sourceAbs);
|
||||
if (!fs.isDirectory(sourcePackDir)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "not_directory",
|
||||
detail: `context_pack parent of '${declared}' is not a directory; skipped`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const dirName = nodePath.basename(sourcePackDir);
|
||||
// Target = <targetContextPacksDir>/<basename(parentDir)>/ (the operator-
|
||||
// host canonical layout per context-pack-types.ts:9-10).
|
||||
const targetAbs = nodePath.resolve(input.targetContextPacksDir, dirName);
|
||||
// Sanity check (basename is structurally safe but the resolve could
|
||||
// theoretically be a no-op for "" → keep the check, expected always-
|
||||
// true).
|
||||
if (targetAbs !== targetRootResolved && !targetAbs.startsWith(targetRootResolved + nodePath.sep)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "unsafe",
|
||||
detail: `context_pack target path for '${declared}' escapes target context-packs library; rejected`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (routedDirNames.has(dirName)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "conflict",
|
||||
detail: `context_pack parent-dir basename '${dirName}' collides with an earlier declared path; only the first is routed (banked collision-detection lesson)`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
fs.copyDir(sourcePackDir, targetAbs);
|
||||
routedDirNames.add(dirName);
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "routed",
|
||||
installedAt: targetAbs,
|
||||
});
|
||||
}
|
||||
|
||||
const routedCount = records.filter((r) => r.status === "routed").length;
|
||||
const rejectedCount = records.length - routedCount;
|
||||
return { records, routedCount, rejectedCount };
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Bundle plugins router (Item 6 / slice-05 Checkpoint 7.3c).
|
||||
*
|
||||
* Pure function. Copies plugin trees declared in a bundle manifest's
|
||||
* plugins[] block from the bundle's extracted tree to the operator plugins
|
||||
* library (per-id subdir). Mirrors the bundle-skills-router pattern: FsOps
|
||||
* injection; both-sides path containment (banked
|
||||
* feedback_pre_existing_trust_boundary_reuse_canonical_helper addendum:
|
||||
* contain BOTH source AND target when handling untrusted path input);
|
||||
* missing source surfaces as status=missing; unsafe paths rejected with
|
||||
* status=unsafe.
|
||||
*
|
||||
* Per orch-ratified HYBRID decision: v0 source.kind is local only (path
|
||||
* under the bundle's extracted tree). Other kinds (external operator-host
|
||||
* paths, remote fetch) reserved for future.
|
||||
*
|
||||
* /install handler integration lands at Checkpoint 7.3d.
|
||||
*/
|
||||
|
||||
import nodePath from "node:path";
|
||||
|
||||
/** Filesystem injection point — real impl wraps node:fs. Tests substitute in-memory. */
|
||||
export interface PluginsRouterFsOps {
|
||||
exists: (path: string) => boolean;
|
||||
isDirectory: (path: string) => boolean;
|
||||
mkdirp: (path: string) => void;
|
||||
copyDir: (src: string, dest: string) => void;
|
||||
}
|
||||
|
||||
/** A plugin reference in the bundle manifest. Matches the daemon's BundlePluginReference shape. */
|
||||
export interface PluginRoutingInput {
|
||||
id: string;
|
||||
source: { kind: "local"; path: string };
|
||||
}
|
||||
|
||||
/** Inputs to routePlugins. */
|
||||
export interface RoutePluginsInput {
|
||||
/** Absolute path to the bundle's extracted root (tmp dir from unpack). */
|
||||
bundleRoot: string;
|
||||
/** Plugin references declared in the manifest. */
|
||||
declaredPlugins: PluginRoutingInput[];
|
||||
/** Absolute path to the operator plugins library (default ~/.openrig/plugins). */
|
||||
targetPluginsDir: string;
|
||||
}
|
||||
|
||||
/** One routed plugin (or one rejection). */
|
||||
export interface RoutedPluginRecord {
|
||||
/** Plugin id from manifest.plugins[].id. */
|
||||
id: string;
|
||||
/** "routed" = directory copied; "missing" = source not in bundle; "unsafe"
|
||||
* = source or target escapes its bounding directory; "not_directory" =
|
||||
* source path is not a directory. */
|
||||
status: "routed" | "missing" | "unsafe" | "not_directory";
|
||||
/** Where the plugin landed in the target library (absolute path), if routed. */
|
||||
installedAt?: string;
|
||||
/** Human-readable detail. */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Aggregate routing result. */
|
||||
export interface RoutePluginsResult {
|
||||
records: RoutedPluginRecord[];
|
||||
routedCount: number;
|
||||
rejectedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route each declared plugin from the bundle tree to the operator plugins
|
||||
* library as <targetPluginsDir>/<id>/. Per-plugin safety: BOTH source path
|
||||
* (under bundleRoot) AND target path (under targetPluginsDir) are
|
||||
* containment-checked. Banked both-sides-of-trust-boundary lesson applied.
|
||||
*/
|
||||
export function routePlugins(input: RoutePluginsInput, fs: PluginsRouterFsOps): RoutePluginsResult {
|
||||
const records: RoutedPluginRecord[] = [];
|
||||
const bundleRootResolved = nodePath.resolve(input.bundleRoot);
|
||||
const targetRootResolved = nodePath.resolve(input.targetPluginsDir);
|
||||
fs.mkdirp(input.targetPluginsDir);
|
||||
|
||||
for (const plugin of input.declaredPlugins) {
|
||||
if (!plugin.id || plugin.source.kind !== "local") {
|
||||
records.push({
|
||||
id: plugin.id || "(unknown)",
|
||||
status: "unsafe",
|
||||
detail: `plugin reference invalid: id required and source.kind must be 'local' (other kinds reserved for future)`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Source containment: source.path must resolve inside bundleRoot
|
||||
const sourceAbs = nodePath.resolve(input.bundleRoot, plugin.source.path);
|
||||
if (sourceAbs !== bundleRootResolved && !sourceAbs.startsWith(bundleRootResolved + nodePath.sep)) {
|
||||
records.push({
|
||||
id: plugin.id,
|
||||
status: "unsafe",
|
||||
detail: `plugin source path '${plugin.source.path}' escapes bundle workspace; rejected`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!fs.exists(sourceAbs)) {
|
||||
records.push({
|
||||
id: plugin.id,
|
||||
status: "missing",
|
||||
detail: `plugin source '${plugin.source.path}' not present in bundle; skipped`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!fs.isDirectory(sourceAbs)) {
|
||||
records.push({
|
||||
id: plugin.id,
|
||||
status: "not_directory",
|
||||
detail: `plugin source '${plugin.source.path}' is not a directory; skipped`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Target containment: id must produce a path inside targetPluginsDir
|
||||
const targetAbs = nodePath.resolve(input.targetPluginsDir, plugin.id);
|
||||
if (targetAbs !== targetRootResolved && !targetAbs.startsWith(targetRootResolved + nodePath.sep)) {
|
||||
records.push({
|
||||
id: plugin.id,
|
||||
status: "unsafe",
|
||||
detail: `plugin id '${plugin.id}' would resolve outside target plugins library; rejected`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
fs.copyDir(sourceAbs, targetAbs);
|
||||
records.push({
|
||||
id: plugin.id,
|
||||
status: "routed",
|
||||
installedAt: targetAbs,
|
||||
});
|
||||
}
|
||||
|
||||
const routedCount = records.filter((r) => r.status === "routed").length;
|
||||
const rejectedCount = records.length - routedCount;
|
||||
return { records, routedCount, rejectedCount };
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Bundle skills router (Item 6 / slice-05 Checkpoint 7.2).
|
||||
*
|
||||
* Pure function. Copies skill files declared in a bundle manifest's skills[]
|
||||
* block from the bundle's extracted tree to the operator skills library.
|
||||
* No daemon dependencies — fully unit-testable via FsOps injection.
|
||||
*
|
||||
* Safety: each declared skill path is treated as untrusted manifest content;
|
||||
* the helper rejects paths that escape the bundle tree (path containment
|
||||
* check mirrors the bundle-source-resolver pattern). The output target
|
||||
* directory is created via mkdirp.
|
||||
*
|
||||
* Honest-scoping: missing source files surface as warnings in the result
|
||||
* (NOT thrown errors) so the install lifecycle can continue with what's
|
||||
* available. Same for absent skills library: caller decides to skip-or-fail.
|
||||
*
|
||||
* /install handler integration lands at Checkpoint 7.3.
|
||||
*/
|
||||
|
||||
import nodePath from "node:path";
|
||||
|
||||
/** Filesystem injection point — real impl wraps node:fs. Tests substitute in-memory. */
|
||||
export interface SkillsRouterFsOps {
|
||||
exists: (path: string) => boolean;
|
||||
readFile: (path: string) => string;
|
||||
writeFile: (path: string, content: string) => void;
|
||||
mkdirp: (path: string) => void;
|
||||
}
|
||||
|
||||
/** Inputs to routeSkills. */
|
||||
export interface RouteSkillsInput {
|
||||
/** Absolute path to the bundle's extracted root (tmp dir from unpack). */
|
||||
bundleRoot: string;
|
||||
/** Relative skill paths declared in the manifest's skills[] block. */
|
||||
declaredSkills: string[];
|
||||
/** Absolute path to the operator skills library (default ~/.openrig/skills). */
|
||||
targetSkillsDir: string;
|
||||
}
|
||||
|
||||
/** One routed skill (or one rejection). */
|
||||
export interface RoutedSkillRecord {
|
||||
/** Declared path from manifest.skills[]. */
|
||||
declaredPath: string;
|
||||
/** "routed" = copied successfully; "missing" = source not in bundle;
|
||||
* "unsafe" = escapes bundle workspace; "no_library" = target dir not
|
||||
* present and caller did not request creation (reserved for future
|
||||
* library-reachability mode). */
|
||||
status: "routed" | "missing" | "unsafe";
|
||||
/** Where the skill landed in the target library (absolute path), if routed. */
|
||||
installedAt?: string;
|
||||
/** Human-readable detail (3-part error shape input for caller). */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Aggregate routing result. */
|
||||
export interface RouteSkillsResult {
|
||||
records: RoutedSkillRecord[];
|
||||
routedCount: number;
|
||||
rejectedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route each declared skill from the bundle tree to the operator skills
|
||||
* library. Per-skill safety: resolved source path must stay inside bundleRoot;
|
||||
* skipped if missing from bundle. Caller writes the install audit record
|
||||
* (Item 4 chain) using the records returned here.
|
||||
*/
|
||||
export function routeSkills(input: RouteSkillsInput, fs: SkillsRouterFsOps): RouteSkillsResult {
|
||||
const records: RoutedSkillRecord[] = [];
|
||||
const bundleRootResolved = nodePath.resolve(input.bundleRoot);
|
||||
const targetRootResolved = nodePath.resolve(input.targetSkillsDir);
|
||||
fs.mkdirp(input.targetSkillsDir);
|
||||
|
||||
for (const declared of input.declaredSkills) {
|
||||
const sourceAbs = nodePath.resolve(input.bundleRoot, declared);
|
||||
// Defense-in-depth path-containment on SOURCE (mirrors bundle-source-
|
||||
// resolver pattern; the manifest validator already rejects unsafe paths
|
||||
// via isRelativeSafePath but we re-check here in case the input bypassed
|
||||
// validation upstream).
|
||||
if (sourceAbs !== bundleRootResolved && !sourceAbs.startsWith(bundleRootResolved + nodePath.sep)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "unsafe",
|
||||
detail: `skill path '${declared}' escapes bundle workspace; rejected`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!fs.exists(sourceAbs)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "missing",
|
||||
detail: `skill source '${declared}' not present in bundle; skipped`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Target path mirrors the declared path under the target skills directory.
|
||||
// Operator's skill library inherits the bundle's directory layout for the
|
||||
// declared skills (e.g., skills/foo/SKILL.md → <target>/foo/SKILL.md);
|
||||
// strip the leading "skills/" prefix if present so the target dir is the
|
||||
// root of the operator's skill tree.
|
||||
const declaredTrimmed = declared.startsWith("skills/") ? declared.slice("skills/".length) : declared;
|
||||
const targetAbs = nodePath.resolve(input.targetSkillsDir, declaredTrimmed);
|
||||
// Defense-in-depth path-containment on TARGET (B1 repair on
|
||||
// qitem-20260518215234-f84fff45). The leading "skills/" strip can promote
|
||||
// a relative segment that looks safe under bundleRoot (e.g.
|
||||
// "skills/../outside/SKILL.md" passes source-containment because the bundle
|
||||
// tree may contain "outside/SKILL.md", but stripping yields
|
||||
// "../outside/SKILL.md" which would escape targetSkillsDir). Reject if the
|
||||
// target resolves outside the target library. Banked lesson:
|
||||
// feedback_pre_existing_trust_boundary_reuse_canonical_helper — contain
|
||||
// BOTH source and target when handling untrusted path input.
|
||||
if (targetAbs !== targetRootResolved && !targetAbs.startsWith(targetRootResolved + nodePath.sep)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "unsafe",
|
||||
detail: `skill target path for '${declared}' escapes target skills library; rejected`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
fs.mkdirp(nodePath.dirname(targetAbs));
|
||||
const content = fs.readFile(sourceAbs);
|
||||
fs.writeFile(targetAbs, content);
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "routed",
|
||||
installedAt: targetAbs,
|
||||
});
|
||||
}
|
||||
|
||||
const routedCount = records.filter((r) => r.status === "routed").length;
|
||||
const rejectedCount = records.length - routedCount;
|
||||
return { records, routedCount, rejectedCount };
|
||||
}
|
||||
@@ -1,5 +1,359 @@
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
||||
|
||||
// -- Shared types --
|
||||
|
||||
/**
|
||||
* Provenance block — attribution metadata for a bundle artifact. All fields
|
||||
* optional for backward compat; bundles without provenance install unchanged.
|
||||
* Captured by the bundle-assembler at create time; surfaced in inspect output
|
||||
* and audit-trail records. Not cryptographically signed at this stage.
|
||||
*/
|
||||
export interface BundleProvenance {
|
||||
/** ISO timestamp; mirrors root createdAt at create time. */
|
||||
createdAt?: string;
|
||||
/** os.hostname() of the host that ran rig bundle create. */
|
||||
sourceHost?: string;
|
||||
/** Canonical session name of the creator (e.g. velocity-driver@openrig-velocity). */
|
||||
authorSession?: string;
|
||||
/** ULID of the source rig, if creating from a live rig. */
|
||||
sourceRigId?: string;
|
||||
/** Name of the source rig, if creating from a live rig. */
|
||||
sourceRigName?: string;
|
||||
/** Daemon version at create time (e.g. 0.3.2). */
|
||||
daemonVersion?: string;
|
||||
/** CLI version at create time (e.g. 0.3.2). */
|
||||
cliVersion?: string;
|
||||
/** Operator-authored notes from the --notes flag on rig bundle create. */
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
const PROVENANCE_STRING_FIELDS = [
|
||||
"created_at",
|
||||
"source_host",
|
||||
"author_session",
|
||||
"source_rig_id",
|
||||
"source_rig_name",
|
||||
"daemon_version",
|
||||
"cli_version",
|
||||
"notes",
|
||||
] as const;
|
||||
|
||||
/** Validate optional provenance block. Appends to errors if present-but-malformed. */
|
||||
function validateProvenanceBlock(raw: unknown, errors: string[]): void {
|
||||
if (raw === undefined) return;
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
errors.push("provenance must be an object");
|
||||
return;
|
||||
}
|
||||
const p = raw as Record<string, unknown>;
|
||||
for (const field of PROVENANCE_STRING_FIELDS) {
|
||||
if (field in p && typeof p[field] !== "string") {
|
||||
errors.push(`provenance.${field} must be a string`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialize a typed BundleProvenance to the snake_case YAML record shape. */
|
||||
function provenanceToYamlRecord(p: BundleProvenance): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
if (p.createdAt !== undefined) out["created_at"] = p.createdAt;
|
||||
if (p.sourceHost !== undefined) out["source_host"] = p.sourceHost;
|
||||
if (p.authorSession !== undefined) out["author_session"] = p.authorSession;
|
||||
if (p.sourceRigId !== undefined) out["source_rig_id"] = p.sourceRigId;
|
||||
if (p.sourceRigName !== undefined) out["source_rig_name"] = p.sourceRigName;
|
||||
if (p.daemonVersion !== undefined) out["daemon_version"] = p.daemonVersion;
|
||||
if (p.cliVersion !== undefined) out["cli_version"] = p.cliVersion;
|
||||
if (p.notes !== undefined) out["notes"] = p.notes;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-primitive bundling — Item 6 / slice-05 Checkpoint 7.1.
|
||||
*
|
||||
* Manifest may optionally declare typed sibling primitives the bundle should
|
||||
* route to their respective libraries on install. v0 ships the `skills` kind
|
||||
* (Checkpoint 7.1). Plugins + workflow_specs + context_packs + agent_images
|
||||
* land additively in subsequent checkpoints as their libraries are reachable.
|
||||
*
|
||||
* PRD §Item 6: bundles list these alongside the existing rig + agents +
|
||||
* packages fields (no `contents:` re-grouping — that would be a schema
|
||||
* reorg violating the no-bump constraint). Each kind is an optional top-
|
||||
* level field; missing kinds keep backward compat.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Plugin reference — Item 6 / slice-05 Checkpoint 7.3b. Bundle manifest may
|
||||
* declare plugin references the bundle includes (in HYBRID mode the bundle
|
||||
* REFERENCES the existing 0.3.1 plugin rather than forking its content; per
|
||||
* orch-ratified decision doc).
|
||||
*/
|
||||
export interface BundlePluginReference {
|
||||
/** Plugin id (matches the plugin primitive's id surface). */
|
||||
id: string;
|
||||
/** Where to resolve the plugin from. v0 supports local-path source. */
|
||||
source: { kind: "local"; path: string };
|
||||
}
|
||||
|
||||
/** Validate optional plugins[] block. Appends to errors if present-but-malformed. */
|
||||
function validatePluginsBlock(raw: unknown, errors: string[]): void {
|
||||
if (raw === undefined) return;
|
||||
if (!Array.isArray(raw)) {
|
||||
errors.push("plugins must be an array");
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const entry = raw[i];
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
errors.push(`plugins[${i}] must be an object`);
|
||||
continue;
|
||||
}
|
||||
const p = entry as Record<string, unknown>;
|
||||
if (typeof p["id"] !== "string" || !p["id"]) {
|
||||
errors.push(`plugins[${i}].id is required`);
|
||||
}
|
||||
const source = p["source"];
|
||||
if (!source || typeof source !== "object" || Array.isArray(source)) {
|
||||
errors.push(`plugins[${i}].source must be an object`);
|
||||
continue;
|
||||
}
|
||||
const s = source as Record<string, unknown>;
|
||||
if (s["kind"] !== "local") {
|
||||
errors.push(`plugins[${i}].source.kind must be 'local' (other kinds reserved for future)`);
|
||||
}
|
||||
if (typeof s["path"] !== "string" || !s["path"]) {
|
||||
errors.push(`plugins[${i}].source.path is required`);
|
||||
} else if (!isRelativeSafePath(s["path"] as string)) {
|
||||
errors.push(`plugins[${i}].source.path is not safe: '${s["path"]}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize raw plugins[] block (defensive copy). */
|
||||
function normalizePluginsBlock(raw: unknown): BundlePluginReference[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const result: BundlePluginReference[] = [];
|
||||
for (const entry of raw) {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
||||
const p = entry as Record<string, unknown>;
|
||||
const s = p["source"];
|
||||
if (typeof p["id"] !== "string" || !p["id"]) continue;
|
||||
if (!s || typeof s !== "object" || Array.isArray(s)) continue;
|
||||
const src = s as Record<string, unknown>;
|
||||
if (src["kind"] !== "local" || typeof src["path"] !== "string" || !src["path"]) continue;
|
||||
result.push({ id: p["id"], source: { kind: "local", path: src["path"] } });
|
||||
}
|
||||
return result.length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/** Validate optional skills[] block. Appends to errors if present-but-malformed. */
|
||||
function validateSkillsBlock(raw: unknown, errors: string[]): void {
|
||||
if (raw === undefined) return;
|
||||
if (!Array.isArray(raw)) {
|
||||
errors.push("skills must be an array");
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const entry = raw[i];
|
||||
if (typeof entry !== "string") {
|
||||
errors.push(`skills[${i}] must be a string`);
|
||||
continue;
|
||||
}
|
||||
if (!isRelativeSafePath(entry)) {
|
||||
errors.push(`skills[${i}] path is not safe: '${entry}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize raw skills[] block (defensive copy + string filter). */
|
||||
function normalizeSkillsBlock(raw: unknown): string[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const result: string[] = [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry === "string" && entry.length > 0) result.push(entry);
|
||||
}
|
||||
return result.length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/** Validate optional workflow_specs[] block. Appends to errors if present-but-malformed.
|
||||
* Item 6 / slice-05 Checkpoint 7.3e. Same shape as skills[]: array of relative-safe paths
|
||||
* to workflow spec YAML files inside the bundle. */
|
||||
function validateWorkflowSpecsBlock(raw: unknown, errors: string[]): void {
|
||||
if (raw === undefined) return;
|
||||
if (!Array.isArray(raw)) {
|
||||
errors.push("workflow_specs must be an array");
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const entry = raw[i];
|
||||
if (typeof entry !== "string") {
|
||||
errors.push(`workflow_specs[${i}] must be a string`);
|
||||
continue;
|
||||
}
|
||||
if (!isRelativeSafePath(entry)) {
|
||||
errors.push(`workflow_specs[${i}] path is not safe: '${entry}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize raw workflow_specs[] block (defensive copy + string filter). */
|
||||
function normalizeWorkflowSpecsBlock(raw: unknown): string[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const result: string[] = [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry === "string" && entry.length > 0) result.push(entry);
|
||||
}
|
||||
return result.length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/** Validate optional context_packs[] block. Item 6 / slice-05 Checkpoint 7.3f.
|
||||
* Each entry is a relative-safe path to a context-pack's manifest.yaml inside
|
||||
* the bundle (per PRD §Item 6). The router copies the parent directory of
|
||||
* each manifest path to the operator context-packs library on install. */
|
||||
function validateContextPacksBlock(raw: unknown, errors: string[]): void {
|
||||
if (raw === undefined) return;
|
||||
if (!Array.isArray(raw)) {
|
||||
errors.push("context_packs must be an array");
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const entry = raw[i];
|
||||
if (typeof entry !== "string") {
|
||||
errors.push(`context_packs[${i}] must be a string`);
|
||||
continue;
|
||||
}
|
||||
if (!isRelativeSafePath(entry)) {
|
||||
errors.push(`context_packs[${i}] path is not safe: '${entry}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize raw context_packs[] block (defensive copy + string filter). */
|
||||
function normalizeContextPacksBlock(raw: unknown): string[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const result: string[] = [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry === "string" && entry.length > 0) result.push(entry);
|
||||
}
|
||||
return result.length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/** Validate optional agent_images[] block. Item 6 / slice-05 Checkpoint 7.3g.
|
||||
* Each entry is a relative-safe path to an agent-image DIRECTORY inside the
|
||||
* bundle (per PRD §Item 6 line 197: agent_images: [path/to/agent-image-name/,
|
||||
* ...]). The router copies the declared directory itself to the operator
|
||||
* agent-images library on install. The consumer
|
||||
* (agent-image-library-service.ts:77-95) requires manifest.yaml to exist
|
||||
* inside each routed image dir; the router enforces that at copy time. */
|
||||
function validateAgentImagesBlock(raw: unknown, errors: string[]): void {
|
||||
if (raw === undefined) return;
|
||||
if (!Array.isArray(raw)) {
|
||||
errors.push("agent_images must be an array");
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const entry = raw[i];
|
||||
if (typeof entry !== "string") {
|
||||
errors.push(`agent_images[${i}] must be a string`);
|
||||
continue;
|
||||
}
|
||||
if (!isRelativeSafePath(entry)) {
|
||||
errors.push(`agent_images[${i}] path is not safe: '${entry}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize raw agent_images[] block (defensive copy + string filter). */
|
||||
function normalizeAgentImagesBlock(raw: unknown): string[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const result: string[] = [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry === "string" && entry.length > 0) result.push(entry);
|
||||
}
|
||||
return result.length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibility block — operator-declared install-time requirements for a
|
||||
* bundle artifact. All fields optional; missing block keeps backward compat
|
||||
* (bundles install unchanged). Install-time version check (Item 2 Checkpoint
|
||||
* 3.3) consults this block before bootstrap delegation; --skip-version-check
|
||||
* is the operator-explicit override.
|
||||
*/
|
||||
export interface BundleCompatibility {
|
||||
/** Minimum daemon version required to install this bundle (semver string). */
|
||||
minDaemonVersion?: string;
|
||||
/** Minimum CLI version required to install this bundle (semver string). */
|
||||
minCliVersion?: string;
|
||||
/** Schema version reaffirmed; mirrors root schemaVersion when set. */
|
||||
schemaVersion?: number;
|
||||
}
|
||||
|
||||
/** Validate optional compatibility block. Appends to errors if present-but-malformed. */
|
||||
function validateCompatibilityBlock(raw: unknown, errors: string[]): void {
|
||||
if (raw === undefined) return;
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
errors.push("compatibility must be an object");
|
||||
return;
|
||||
}
|
||||
const c = raw as Record<string, unknown>;
|
||||
if ("min_daemon_version" in c && typeof c["min_daemon_version"] !== "string") {
|
||||
errors.push("compatibility.min_daemon_version must be a string");
|
||||
}
|
||||
if ("min_cli_version" in c && typeof c["min_cli_version"] !== "string") {
|
||||
errors.push("compatibility.min_cli_version must be a string");
|
||||
}
|
||||
if ("schema_version" in c && typeof c["schema_version"] !== "number") {
|
||||
errors.push("compatibility.schema_version must be a number");
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialize a typed BundleCompatibility to the snake_case YAML record shape. */
|
||||
function compatibilityToYamlRecord(c: BundleCompatibility): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
if (c.minDaemonVersion !== undefined) out["min_daemon_version"] = c.minDaemonVersion;
|
||||
if (c.minCliVersion !== undefined) out["min_cli_version"] = c.minCliVersion;
|
||||
if (c.schemaVersion !== undefined) out["schema_version"] = c.schemaVersion;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize raw snake_case compatibility to typed camelCase BundleCompatibility.
|
||||
* Returns undefined when absent or empty. Exported alongside the provenance
|
||||
* normalizer so the v2 inspect-route projection can produce a single
|
||||
* camelCase shape for both manifest schemas.
|
||||
*/
|
||||
export function normalizeCompatibilityBlock(raw: unknown): BundleCompatibility | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||
const c = raw as Record<string, unknown>;
|
||||
const result: BundleCompatibility = {};
|
||||
if (typeof c["min_daemon_version"] === "string") result.minDaemonVersion = c["min_daemon_version"];
|
||||
if (typeof c["min_cli_version"] === "string") result.minCliVersion = c["min_cli_version"];
|
||||
if (typeof c["schema_version"] === "number") result.schemaVersion = c["schema_version"];
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize raw snake_case provenance (as parsed from YAML or received from
|
||||
* a request body) to typed camelCase BundleProvenance. Returns undefined when
|
||||
* absent or empty. Exported so both the v1 normalizer pipeline and the v2
|
||||
* inspect-route projection produce identical camelCase shapes — the
|
||||
* /api/bundles/inspect contract is one shape regardless of schema version.
|
||||
*/
|
||||
export function normalizeProvenanceBlock(raw: unknown): BundleProvenance | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||
const p = raw as Record<string, unknown>;
|
||||
const result: BundleProvenance = {};
|
||||
if (typeof p["created_at"] === "string") result.createdAt = p["created_at"];
|
||||
if (typeof p["source_host"] === "string") result.sourceHost = p["source_host"];
|
||||
if (typeof p["author_session"] === "string") result.authorSession = p["author_session"];
|
||||
if (typeof p["source_rig_id"] === "string") result.sourceRigId = p["source_rig_id"];
|
||||
if (typeof p["source_rig_name"] === "string") result.sourceRigName = p["source_rig_name"];
|
||||
if (typeof p["daemon_version"] === "string") result.daemonVersion = p["daemon_version"];
|
||||
if (typeof p["cli_version"] === "string") result.cliVersion = p["cli_version"];
|
||||
if (typeof p["notes"] === "string") result.notes = p["notes"];
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
// -- Pod-aware bundle types (AgentSpec reboot) --
|
||||
|
||||
export interface PodBundleAgentImportEntry {
|
||||
@@ -28,6 +382,18 @@ export interface PodBundleManifest {
|
||||
agents: PodBundleAgentEntry[];
|
||||
cultureFile?: string;
|
||||
integrity?: BundleIntegrity;
|
||||
provenance?: BundleProvenance;
|
||||
compatibility?: BundleCompatibility;
|
||||
/** Item 6 cross-primitive bundling: skill paths to route to the operator skills library on install. */
|
||||
skills?: string[];
|
||||
/** Item 6 cross-primitive bundling: plugin references to install via the plugin primitive (HYBRID-mode bundles reference existing plugins rather than forking content). */
|
||||
plugins?: BundlePluginReference[];
|
||||
/** Item 6 cross-primitive bundling: workflow spec YAML paths to route to the operator workflow-specs library on install (Checkpoint 7.3e). */
|
||||
workflowSpecs?: string[];
|
||||
/** Item 6 cross-primitive bundling: paths to context-pack manifest.yaml files; router copies the parent dir to the operator context-packs library on install (Checkpoint 7.3f). */
|
||||
contextPacks?: string[];
|
||||
/** Item 6 cross-primitive bundling: paths to agent-image DIRECTORIES (per PRD §Item 6 line 197); router copies the declared directory to the operator agent-images library on install. Consumer requires manifest.yaml inside each image dir. (Checkpoint 7.3g) */
|
||||
agentImages?: string[];
|
||||
}
|
||||
|
||||
export function validatePodBundleManifest(raw: unknown): { valid: boolean; errors: string[] } {
|
||||
@@ -54,6 +420,14 @@ export function validatePodBundleManifest(raw: unknown): { valid: boolean; error
|
||||
}
|
||||
}
|
||||
|
||||
validateProvenanceBlock(m["provenance"], errors);
|
||||
validateCompatibilityBlock(m["compatibility"], errors);
|
||||
validateSkillsBlock(m["skills"], errors);
|
||||
validatePluginsBlock(m["plugins"], errors);
|
||||
validateWorkflowSpecsBlock(m["workflow_specs"], errors);
|
||||
validateContextPacksBlock(m["context_packs"], errors);
|
||||
validateAgentImagesBlock(m["agent_images"], errors);
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
@@ -81,6 +455,13 @@ export function serializePodBundleManifest(manifest: PodBundleManifest): string
|
||||
};
|
||||
if (manifest.cultureFile) doc["culture_file"] = manifest.cultureFile;
|
||||
if (manifest.integrity) doc["integrity"] = { algorithm: manifest.integrity.algorithm, files: manifest.integrity.files };
|
||||
if (manifest.provenance) doc["provenance"] = provenanceToYamlRecord(manifest.provenance);
|
||||
if (manifest.compatibility) doc["compatibility"] = compatibilityToYamlRecord(manifest.compatibility);
|
||||
if (manifest.skills && manifest.skills.length > 0) doc["skills"] = manifest.skills;
|
||||
if (manifest.plugins && manifest.plugins.length > 0) doc["plugins"] = manifest.plugins.map((p) => ({ id: p.id, source: { kind: p.source.kind, path: p.source.path } }));
|
||||
if (manifest.workflowSpecs && manifest.workflowSpecs.length > 0) doc["workflow_specs"] = manifest.workflowSpecs;
|
||||
if (manifest.contextPacks && manifest.contextPacks.length > 0) doc["context_packs"] = manifest.contextPacks;
|
||||
if (manifest.agentImages && manifest.agentImages.length > 0) doc["agent_images"] = manifest.agentImages;
|
||||
return stringifyYaml(doc);
|
||||
}
|
||||
|
||||
@@ -116,6 +497,18 @@ export interface LegacyBundleManifest {
|
||||
rigSpec: string;
|
||||
packages: LegacyBundlePackageEntry[];
|
||||
integrity?: BundleIntegrity;
|
||||
provenance?: BundleProvenance;
|
||||
compatibility?: BundleCompatibility;
|
||||
/** Item 6 cross-primitive bundling: skill paths to route to the operator skills library on install. */
|
||||
skills?: string[];
|
||||
/** Item 6 cross-primitive bundling: plugin references to install via the plugin primitive (HYBRID-mode bundles reference existing plugins rather than forking content). */
|
||||
plugins?: BundlePluginReference[];
|
||||
/** Item 6 cross-primitive bundling: workflow spec YAML paths to route to the operator workflow-specs library on install (Checkpoint 7.3e). */
|
||||
workflowSpecs?: string[];
|
||||
/** Item 6 cross-primitive bundling: paths to context-pack manifest.yaml files; router copies the parent dir to the operator context-packs library on install (Checkpoint 7.3f). */
|
||||
contextPacks?: string[];
|
||||
/** Item 6 cross-primitive bundling: paths to agent-image DIRECTORIES (per PRD §Item 6 line 197); router copies the declared directory to the operator agent-images library on install. Consumer requires manifest.yaml inside each image dir. (Checkpoint 7.3g) */
|
||||
agentImages?: string[];
|
||||
}
|
||||
|
||||
/** Validation options */
|
||||
@@ -206,6 +599,14 @@ export function validateLegacyBundleManifest(
|
||||
}
|
||||
}
|
||||
|
||||
validateProvenanceBlock(m["provenance"], errors);
|
||||
validateCompatibilityBlock(m["compatibility"], errors);
|
||||
validateSkillsBlock(m["skills"], errors);
|
||||
validatePluginsBlock(m["plugins"], errors);
|
||||
validateWorkflowSpecsBlock(m["workflow_specs"], errors);
|
||||
validateContextPacksBlock(m["context_packs"], errors);
|
||||
validateAgentImagesBlock(m["agent_images"], errors);
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
@@ -247,6 +648,27 @@ export function normalizeLegacyBundleManifest(raw: unknown): LegacyBundleManifes
|
||||
};
|
||||
}
|
||||
|
||||
const provenance = normalizeProvenanceBlock(m["provenance"]);
|
||||
if (provenance) result.provenance = provenance;
|
||||
|
||||
const compatibility = normalizeCompatibilityBlock(m["compatibility"]);
|
||||
if (compatibility) result.compatibility = compatibility;
|
||||
|
||||
const skills = normalizeSkillsBlock(m["skills"]);
|
||||
if (skills) result.skills = skills;
|
||||
|
||||
const plugins = normalizePluginsBlock(m["plugins"]);
|
||||
if (plugins) result.plugins = plugins;
|
||||
|
||||
const workflowSpecs = normalizeWorkflowSpecsBlock(m["workflow_specs"]);
|
||||
if (workflowSpecs) result.workflowSpecs = workflowSpecs;
|
||||
|
||||
const contextPacks = normalizeContextPacksBlock(m["context_packs"]);
|
||||
if (contextPacks) result.contextPacks = contextPacks;
|
||||
|
||||
const agentImages = normalizeAgentImagesBlock(m["agent_images"]);
|
||||
if (agentImages) result.agentImages = agentImages;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -274,5 +696,15 @@ export function serializeLegacyBundleManifest(manifest: LegacyBundleManifest): s
|
||||
};
|
||||
}
|
||||
|
||||
if (manifest.provenance) doc["provenance"] = provenanceToYamlRecord(manifest.provenance);
|
||||
|
||||
if (manifest.compatibility) doc["compatibility"] = compatibilityToYamlRecord(manifest.compatibility);
|
||||
|
||||
if (manifest.skills && manifest.skills.length > 0) doc["skills"] = manifest.skills;
|
||||
if (manifest.plugins && manifest.plugins.length > 0) doc["plugins"] = manifest.plugins.map((p) => ({ id: p.id, source: { kind: p.source.kind, path: p.source.path } }));
|
||||
if (manifest.workflowSpecs && manifest.workflowSpecs.length > 0) doc["workflow_specs"] = manifest.workflowSpecs;
|
||||
if (manifest.contextPacks && manifest.contextPacks.length > 0) doc["context_packs"] = manifest.contextPacks;
|
||||
if (manifest.agentImages && manifest.agentImages.length > 0) doc["agent_images"] = manifest.agentImages;
|
||||
|
||||
return stringifyYaml(doc);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Bundle workflow_specs router (Item 6 / slice-05 Checkpoint 7.3e step 2).
|
||||
*
|
||||
* Pure function. Copies workflow spec YAML files declared in a bundle
|
||||
* manifest's workflow_specs[] block from the bundle's extracted tree to the
|
||||
* operator workflow-specs library. No daemon dependencies — fully
|
||||
* unit-testable via FsOps injection.
|
||||
*
|
||||
* Mirrors the bundle-skills-router pattern (file-paths, single-file copy
|
||||
* preserving directory layout, leading-prefix strip). Per orch-ratified
|
||||
* Candidate A on Item-6 completeness: workflow_specs is the 3rd v0
|
||||
* cross-primitive kind (after skills + plugins). Source primitive
|
||||
* reachable on main (WorkflowSpecCache + workflow-runtime + scanner).
|
||||
*
|
||||
* Safety (banked feedback_pre_existing_trust_boundary_reuse_canonical_helper
|
||||
* addendum): both-sides containment — declared source path must stay
|
||||
* inside bundleRoot; resolved target path (after leading "workflows/"
|
||||
* strip) must stay inside targetWorkflowSpecsDir. The leading-prefix
|
||||
* strip can promote intermediate ../ segments that bypass source check
|
||||
* but escape target.
|
||||
*
|
||||
* Honest-scoping: missing source files surface as warnings in the
|
||||
* result (NOT thrown errors) so the install lifecycle can continue
|
||||
* with what's available. Same as bundle-skills-router.
|
||||
*
|
||||
* /install handler integration lands at Checkpoint 7.3e step 3.
|
||||
*/
|
||||
|
||||
import nodePath from "node:path";
|
||||
|
||||
/** Filesystem injection point — real impl wraps node:fs. Tests substitute in-memory. */
|
||||
export interface WorkflowSpecsRouterFsOps {
|
||||
exists: (path: string) => boolean;
|
||||
readFile: (path: string) => string;
|
||||
writeFile: (path: string, content: string) => void;
|
||||
mkdirp: (path: string) => void;
|
||||
}
|
||||
|
||||
/** Inputs to routeWorkflowSpecs. */
|
||||
export interface RouteWorkflowSpecsInput {
|
||||
/** Absolute path to the bundle's extracted root (tmp dir from unpack). */
|
||||
bundleRoot: string;
|
||||
/** Relative workflow_spec paths declared in the manifest's workflow_specs[] block. */
|
||||
declaredWorkflowSpecs: string[];
|
||||
/**
|
||||
* Absolute path to the operator workflow-specs library that the
|
||||
* spec-library-workflow-scanner ACTUALLY reads from. **CALLER CONTRACT**:
|
||||
* this MUST be exactly
|
||||
* `nodePath.join(ContextPackSettingsStore.resolveConfig().workspaceSpecsRoot, "workflows")`
|
||||
* — the folder discovered by `scanWorkflowSpecFolder` in
|
||||
* `packages/daemon/src/domain/spec-library-workflow-scanner.ts:320-342`
|
||||
* and exposed via `deps.workflowsFolderDir` in
|
||||
* `packages/daemon/src/startup.ts:903-916`. SettingsStore is the sole
|
||||
* authority for the actual operator-host path; do NOT hardcode a default
|
||||
* here — settings layering (env > config > workspace-default) can shift
|
||||
* the resolved root, so any literal default repeated in this comment
|
||||
* would be a drift hazard. Writing anywhere else will succeed silently
|
||||
* but be invisible to the live workflow scanner — operator dead-end.
|
||||
* The /install integration helper (Checkpoint 7.3e step 3) MUST resolve
|
||||
* this path via the SettingsStore call shown above before invoking
|
||||
* routeWorkflowSpecs.
|
||||
*/
|
||||
targetWorkflowSpecsDir: string;
|
||||
}
|
||||
|
||||
/** One routed workflow_spec (or one rejection). */
|
||||
export interface RoutedWorkflowSpecRecord {
|
||||
/** Declared path from manifest.workflow_specs[]. */
|
||||
declaredPath: string;
|
||||
/** "routed" = copied successfully and scanner-visible.
|
||||
* "missing" = source not in bundle (honest skip).
|
||||
* "unsafe" = source escapes bundleRoot OR declared path has a non-YAML
|
||||
* suffix the scanner will ignore (operator-invisible-by-construction).
|
||||
* "conflict" = basename collision with an earlier routed entry — only
|
||||
* the first such declared path is written; later ones flagged so the
|
||||
* routed count stays truthful at the scanner-visible boundary (mirror
|
||||
* of the slice's broader 3-part collision-error posture). */
|
||||
status: "routed" | "missing" | "unsafe" | "conflict";
|
||||
/** Where the workflow_spec landed in the target library (absolute path), if routed. */
|
||||
installedAt?: string;
|
||||
/** Human-readable detail (3-part error shape input for caller). */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Aggregate routing result. */
|
||||
export interface RouteWorkflowSpecsResult {
|
||||
records: RoutedWorkflowSpecRecord[];
|
||||
routedCount: number;
|
||||
rejectedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route each declared workflow_spec from the bundle tree to the operator
|
||||
* workflow-specs library. Per-entry safety: BOTH source path (under
|
||||
* bundleRoot) AND target path (under targetWorkflowSpecsDir, post leading-
|
||||
* prefix strip) are containment-checked. Banked both-sides-trust-boundary
|
||||
* lesson applied. Caller writes the install audit record using the records
|
||||
* returned here.
|
||||
*/
|
||||
export function routeWorkflowSpecs(
|
||||
input: RouteWorkflowSpecsInput,
|
||||
fs: WorkflowSpecsRouterFsOps,
|
||||
): RouteWorkflowSpecsResult {
|
||||
const records: RoutedWorkflowSpecRecord[] = [];
|
||||
const bundleRootResolved = nodePath.resolve(input.bundleRoot);
|
||||
const targetRootResolved = nodePath.resolve(input.targetWorkflowSpecsDir);
|
||||
fs.mkdirp(input.targetWorkflowSpecsDir);
|
||||
// Track basenames already routed so duplicate-basename collisions (after
|
||||
// the basename-flatten) surface as conflict records rather than silent
|
||||
// overwrites with a false routedCount (banked B1 guard catch on d81456dc).
|
||||
const routedBasenames = new Set<string>();
|
||||
|
||||
for (const declared of input.declaredWorkflowSpecs) {
|
||||
// Scanner-visibility prefilter: spec-library-workflow-scanner reads only
|
||||
// .yaml/.yml top-level files (spec-library-workflow-scanner.ts:366 +
|
||||
// 390-397). Non-YAML declarations would route to disk but be invisible
|
||||
// to the operator via the Library UI — false-positive class. Reject
|
||||
// here so the routedCount stays truthful at the scanner-visible
|
||||
// boundary.
|
||||
if (!/\.ya?ml$/i.test(declared)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "unsafe",
|
||||
detail: `workflow_spec '${declared}' is not a .yaml/.yml file; scanner ignores non-YAML suffixes`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const sourceAbs = nodePath.resolve(input.bundleRoot, declared);
|
||||
// Defense-in-depth path-containment on SOURCE (mirrors skills router
|
||||
// pattern; the manifest validator already rejects unsafe paths via
|
||||
// isRelativeSafePath but we re-check here in case the input bypassed
|
||||
// validation upstream).
|
||||
if (sourceAbs !== bundleRootResolved && !sourceAbs.startsWith(bundleRootResolved + nodePath.sep)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "unsafe",
|
||||
detail: `workflow_spec path '${declared}' escapes bundle workspace; rejected`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!fs.exists(sourceAbs)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "missing",
|
||||
detail: `workflow_spec source '${declared}' not present in bundle; skipped`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Target = top-level basename under targetWorkflowSpecsDir.
|
||||
//
|
||||
// SCANNER-REACHABILITY CONTRACT: spec-library-workflow-scanner reads
|
||||
// ONLY top-level YAML files via readdirSync(folder) + isFile() (see
|
||||
// spec-library-workflow-scanner.ts:382-397) — no recursion. Nested
|
||||
// YAML inside subdirs is INVISIBLE to the scanner and Library UI.
|
||||
// Workflow specs MUST land at the top of <workspace.specs_root>/workflows
|
||||
// to become operator-visible. Basename-only target computation enforces
|
||||
// this; preserving the bundle's directory layout would silently route
|
||||
// specs to an unscanned location.
|
||||
//
|
||||
// Side benefit: basename can never escape targetWorkflowSpecsDir
|
||||
// (nodePath.basename(any-path) is a leaf name with no separators), so
|
||||
// the "target-side escape after prefix strip" hazard banked on the
|
||||
// skills router (595e9550 B1 repair) is structurally impossible here.
|
||||
//
|
||||
// Basename collisions caught BEFORE write: see the routedBasenames
|
||||
// dedup-set + the basename-collision branch below. Two declared paths
|
||||
// sharing the same filename → first wins (status=routed), later flagged
|
||||
// status=conflict so routedCount stays truthful at the scanner-visible
|
||||
// boundary (mirror of the slice's 3-part collision-error posture).
|
||||
// Non-YAML suffixes are pre-filtered (status=unsafe) since the scanner
|
||||
// ignores non-.yaml/.yml entries entirely.
|
||||
const basename = nodePath.basename(declared);
|
||||
const targetAbs = nodePath.resolve(input.targetWorkflowSpecsDir, basename);
|
||||
// Sanity check (defensive; basename is structurally safe but the resolve
|
||||
// could in theory be a no-op for "" → keep the check, expected always-true).
|
||||
if (targetAbs !== targetRootResolved && !targetAbs.startsWith(targetRootResolved + nodePath.sep)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "unsafe",
|
||||
detail: `workflow_spec target path for '${declared}' escapes target workflow-specs library; rejected`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Basename-collision detection: two declared paths that share a
|
||||
// basename would both target the same file after basename-flatten. Only
|
||||
// the FIRST is written; later ones surface as conflict records so the
|
||||
// routedCount equals scanner-visible specs (banked B1 guard catch on
|
||||
// d81456dc). Operator-actionable: rename the declared path or curate
|
||||
// the bundle manifest.
|
||||
if (routedBasenames.has(basename)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "conflict",
|
||||
detail: `workflow_spec basename '${basename}' collides with an earlier declared path; only the first is routed (basename-flatten conflict)`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFile(sourceAbs);
|
||||
fs.writeFile(targetAbs, content);
|
||||
routedBasenames.add(basename);
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "routed",
|
||||
installedAt: targetAbs,
|
||||
});
|
||||
}
|
||||
|
||||
const routedCount = records.filter((r) => r.status === "routed").length;
|
||||
const rejectedCount = records.length - routedCount;
|
||||
return { records, routedCount, rejectedCount };
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
|
||||
import { RigSpecCodec } from "./rigspec-codec.js";
|
||||
import { RigSpecSchema } from "./rigspec-schema.js";
|
||||
import { resolveAgentRef, type AgentResolverFsOps } from "./agent-resolver.js";
|
||||
import { serializePodBundleManifest, type PodBundleManifest, type PodBundleAgentEntry, type PodBundleAgentImportEntry } from "./bundle-types.js";
|
||||
import { serializePodBundleManifest, type PodBundleManifest, type PodBundleAgentEntry, type PodBundleAgentImportEntry, type BundleProvenance, type BundleCompatibility } from "./bundle-types.js";
|
||||
import type { RigSpec, StartupBlock } from "./types.js";
|
||||
|
||||
export interface PodAssemblerFsOps extends AgentResolverFsOps {
|
||||
@@ -19,6 +19,21 @@ export interface PodAssembleOptions {
|
||||
outputDir: string;
|
||||
bundleName: string;
|
||||
bundleVersion: string;
|
||||
/**
|
||||
* Optional Item-1 provenance input. Caller fills the fields it knows
|
||||
* (sourceHost, authorSession, daemonVersion, cliVersion, sourceRigId,
|
||||
* sourceRigName, notes). The assembler mirrors root createdAt into
|
||||
* provenance.createdAt unless caller pre-set it (caller value wins
|
||||
* for test determinism). Missing block = no provenance recorded
|
||||
* (backward compat).
|
||||
*/
|
||||
provenance?: BundleProvenance;
|
||||
/**
|
||||
* Optional Item-2 compatibility input. Caller declares minimum
|
||||
* daemon + CLI versions and optional schema_version reaffirmation.
|
||||
* Missing block = no compatibility recorded (backward compat).
|
||||
*/
|
||||
compatibility?: BundleCompatibility;
|
||||
}
|
||||
|
||||
export interface PodAssembleResult {
|
||||
@@ -163,15 +178,25 @@ export class PodBundleAssembler {
|
||||
this.fs.writeFile(nodePath.join(opts.outputDir, "rig.yaml"), rewrittenRigYaml);
|
||||
|
||||
// 4. Build manifest
|
||||
const createdAt = new Date().toISOString();
|
||||
const manifest: PodBundleManifest = {
|
||||
schemaVersion: 2,
|
||||
name: opts.bundleName,
|
||||
version: opts.bundleVersion,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdAt,
|
||||
rigSpec: "rig.yaml",
|
||||
agents: agentEntries,
|
||||
cultureFile: rigSpec.cultureFile,
|
||||
};
|
||||
if (opts.provenance) {
|
||||
manifest.provenance = {
|
||||
...opts.provenance,
|
||||
createdAt: opts.provenance.createdAt ?? createdAt,
|
||||
};
|
||||
}
|
||||
if (opts.compatibility) {
|
||||
manifest.compatibility = { ...opts.compatibility };
|
||||
}
|
||||
|
||||
// Write manifest
|
||||
this.fs.writeFile(
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,247 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { routeAgentImages, type AgentImagesRouterFsOps, type RouteAgentImagesInput } from "../src/domain/bundle-agent-images-router.js";
|
||||
|
||||
// Item 6 / slice-05 Checkpoint 7.3g step 2: bundle-agent-images-router
|
||||
// pure-function tests. PRD line 197: declared paths are agent-image
|
||||
// DIRECTORIES (not manifest paths — distinct from context_packs).
|
||||
// All 4 banked router-level catches (file-existence, isDirectory check
|
||||
// on manifest, declared-path-must-be-dir, basename collision) built in
|
||||
// from the first commit per banked discipline.
|
||||
|
||||
function mockFs(initial: { dirs?: string[]; files?: string[] } = {}): AgentImagesRouterFsOps & {
|
||||
_copyCalls: Array<{ src: string; dest: string }>;
|
||||
_mkdirpCalls: string[];
|
||||
_dirs: Set<string>;
|
||||
_files: Set<string>;
|
||||
} {
|
||||
const dirs = new Set<string>(initial.dirs ?? []);
|
||||
const files = new Set<string>(initial.files ?? []);
|
||||
const copyCalls: Array<{ src: string; dest: string }> = [];
|
||||
const mkdirpCalls: string[] = [];
|
||||
return {
|
||||
_copyCalls: copyCalls,
|
||||
_mkdirpCalls: mkdirpCalls,
|
||||
_dirs: dirs,
|
||||
_files: files,
|
||||
exists: (p: string) => dirs.has(p) || files.has(p),
|
||||
isDirectory: (p: string) => dirs.has(p),
|
||||
mkdirp: (p: string) => { mkdirpCalls.push(p); dirs.add(p); },
|
||||
copyDir: (src: string, dest: string) => { copyCalls.push({ src, dest }); dirs.add(dest); },
|
||||
};
|
||||
}
|
||||
|
||||
/** Convenience: build a complete agent-image fixture (image dir + manifest.yaml file inside). */
|
||||
function imageFixture(imageDir: string): { dirs: string[]; files: string[] } {
|
||||
return { dirs: [imageDir], files: [`${imageDir}/manifest.yaml`] };
|
||||
}
|
||||
|
||||
const BUNDLE_ROOT = "/bundle/root";
|
||||
const TARGET = "/operator/.openrig/agent-images";
|
||||
|
||||
function makeInput(overrides?: Partial<RouteAgentImagesInput>): RouteAgentImagesInput {
|
||||
return {
|
||||
bundleRoot: BUNDLE_ROOT,
|
||||
declaredAgentImages: [],
|
||||
targetAgentImagesDir: TARGET,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("routeAgentImages", () => {
|
||||
// A1: empty list → empty records + target dir mkdirp'd
|
||||
it("empty declaredAgentImages produces empty records but still mkdirp's target", () => {
|
||||
const fs = mockFs();
|
||||
const result = routeAgentImages(makeInput(), fs);
|
||||
expect(result.records).toEqual([]);
|
||||
expect(result.routedCount).toBe(0);
|
||||
expect(result.rejectedCount).toBe(0);
|
||||
expect(fs._mkdirpCalls).toContain(TARGET);
|
||||
});
|
||||
|
||||
// A2: routes one image: copyDir from sourceAbs (declared dir) to target/<basename>
|
||||
it("routes one agent_image: declared dir copied to target/<basename>", () => {
|
||||
const fs = mockFs(imageFixture(`${BUNDLE_ROOT}/agent-images/seat-a`));
|
||||
const result = routeAgentImages(
|
||||
makeInput({ declaredAgentImages: ["agent-images/seat-a"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.routedCount).toBe(1);
|
||||
expect(result.records[0]!.status).toBe("routed");
|
||||
expect(result.records[0]!.installedAt).toBe(`${TARGET}/seat-a`);
|
||||
expect(fs._copyCalls).toHaveLength(1);
|
||||
expect(fs._copyCalls[0]).toEqual({
|
||||
src: `${BUNDLE_ROOT}/agent-images/seat-a`,
|
||||
dest: `${TARGET}/seat-a`,
|
||||
});
|
||||
});
|
||||
|
||||
// A3: multiple distinct images route correctly
|
||||
it("routes multiple distinct agent_images each to target/<basename>", () => {
|
||||
const fs = mockFs({
|
||||
dirs: [`${BUNDLE_ROOT}/agent-images/seat-a`, `${BUNDLE_ROOT}/agent-images/seat-b`],
|
||||
files: [
|
||||
`${BUNDLE_ROOT}/agent-images/seat-a/manifest.yaml`,
|
||||
`${BUNDLE_ROOT}/agent-images/seat-b/manifest.yaml`,
|
||||
],
|
||||
});
|
||||
const result = routeAgentImages(
|
||||
makeInput({
|
||||
declaredAgentImages: [
|
||||
"agent-images/seat-a",
|
||||
"agent-images/seat-b",
|
||||
],
|
||||
}),
|
||||
fs,
|
||||
);
|
||||
expect(result.routedCount).toBe(2);
|
||||
expect(fs._copyCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
// A4: declared source dir absent → status=missing
|
||||
it("missing declared image dir → status=missing (honest-scoping)", () => {
|
||||
const fs = mockFs();
|
||||
const result = routeAgentImages(
|
||||
makeInput({ declaredAgentImages: ["agent-images/absent"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.routedCount).toBe(0);
|
||||
expect(result.records[0]!.status).toBe("missing");
|
||||
expect(result.records[0]!.detail).toContain("not present");
|
||||
expect(fs._copyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
// A5: unsafe source path escaping bundle workspace rejected
|
||||
it("unsafe source path escaping bundle workspace → status=unsafe", () => {
|
||||
const fs = mockFs();
|
||||
const result = routeAgentImages(
|
||||
makeInput({ declaredAgentImages: ["../escape"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.records[0]!.status).toBe("unsafe");
|
||||
expect(result.records[0]!.detail).toContain("escapes bundle workspace");
|
||||
expect(fs._copyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
// A6 (PRD-coherent discrimination): declared path exists but is a FILE
|
||||
// not a directory. PRD line 197 says declared path is an agent-image
|
||||
// DIRECTORY; if operator-declared path is a file, reject.
|
||||
it("declared path exists but is a file (not directory) → status=not_directory", () => {
|
||||
const fs = mockFs({ files: [`${BUNDLE_ROOT}/agent-images/seat-as-file`] });
|
||||
const result = routeAgentImages(
|
||||
makeInput({ declaredAgentImages: ["agent-images/seat-as-file"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.records[0]!.status).toBe("not_directory");
|
||||
expect(result.records[0]!.detail).toContain("not a directory");
|
||||
expect(fs._copyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
// A7 (banked d491eca9 lesson): image dir exists but manifest.yaml inside
|
||||
// is absent. Consumer skips the image — false-positive routedCount class.
|
||||
it("image dir exists but manifest.yaml inside is absent → status=not_manifest", () => {
|
||||
const fs = mockFs({
|
||||
dirs: [`${BUNDLE_ROOT}/agent-images/halfimage`],
|
||||
// NO manifest.yaml file
|
||||
});
|
||||
const result = routeAgentImages(
|
||||
makeInput({ declaredAgentImages: ["agent-images/halfimage"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.records[0]!.status).toBe("not_manifest");
|
||||
expect(result.records[0]!.detail).toContain("missing manifest.yaml");
|
||||
expect(fs._copyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
// A8 (banked 3cd581e3 lesson): image dir exists, manifest.yaml inside is
|
||||
// itself a DIRECTORY (not file). Consumer's readFileSync throws — false-
|
||||
// positive class. Reject.
|
||||
it("image dir exists but manifest.yaml inside is itself a directory → status=not_manifest", () => {
|
||||
const fs = mockFs({
|
||||
dirs: [
|
||||
`${BUNDLE_ROOT}/agent-images/dirmanifest`,
|
||||
`${BUNDLE_ROOT}/agent-images/dirmanifest/manifest.yaml`,
|
||||
],
|
||||
});
|
||||
const result = routeAgentImages(
|
||||
makeInput({ declaredAgentImages: ["agent-images/dirmanifest"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.records[0]!.status).toBe("not_manifest");
|
||||
expect(result.records[0]!.detail).toContain("directory");
|
||||
expect(fs._copyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
// A9 (banked 16ebb8af lesson): two declared images sharing basename →
|
||||
// first wins (status=routed), second flagged status=conflict.
|
||||
it("two declared images sharing basename → first routed, second conflict (truthful routedCount)", () => {
|
||||
const fs = mockFs({
|
||||
dirs: [
|
||||
`${BUNDLE_ROOT}/a/seat-a`,
|
||||
`${BUNDLE_ROOT}/b/seat-a`,
|
||||
],
|
||||
files: [
|
||||
`${BUNDLE_ROOT}/a/seat-a/manifest.yaml`,
|
||||
`${BUNDLE_ROOT}/b/seat-a/manifest.yaml`,
|
||||
],
|
||||
});
|
||||
const result = routeAgentImages(
|
||||
makeInput({
|
||||
declaredAgentImages: ["a/seat-a", "b/seat-a"],
|
||||
}),
|
||||
fs,
|
||||
);
|
||||
expect(result.records).toHaveLength(2);
|
||||
expect(result.routedCount).toBe(1);
|
||||
expect(result.rejectedCount).toBe(1);
|
||||
expect(result.records[0]!.status).toBe("routed");
|
||||
expect(result.records[0]!.installedAt).toBe(`${TARGET}/seat-a`);
|
||||
expect(result.records[1]!.status).toBe("conflict");
|
||||
expect(result.records[1]!.detail).toContain("seat-a");
|
||||
expect(result.records[1]!.detail).toContain("collides");
|
||||
expect(fs._copyCalls).toHaveLength(1);
|
||||
expect(fs._copyCalls[0]!.src).toBe(`${BUNDLE_ROOT}/a/seat-a`);
|
||||
});
|
||||
|
||||
// A10: mixed list — routed + missing + unsafe + not_directory +
|
||||
// not_manifest + conflict aggregate
|
||||
it("mixed declared list aggregates correctly across all rejection classes", () => {
|
||||
const fs = mockFs({
|
||||
dirs: [
|
||||
`${BUNDLE_ROOT}/agent-images/ok`,
|
||||
`${BUNDLE_ROOT}/agent-images/halfimage`, // no manifest.yaml inside
|
||||
`${BUNDLE_ROOT}/agent-images/dup`,
|
||||
`${BUNDLE_ROOT}/elsewhere/dup`,
|
||||
],
|
||||
files: [
|
||||
`${BUNDLE_ROOT}/agent-images/ok/manifest.yaml`,
|
||||
`${BUNDLE_ROOT}/agent-images/seat-as-file`, // file at declared location
|
||||
`${BUNDLE_ROOT}/agent-images/dup/manifest.yaml`,
|
||||
`${BUNDLE_ROOT}/elsewhere/dup/manifest.yaml`,
|
||||
],
|
||||
});
|
||||
const result = routeAgentImages(
|
||||
makeInput({
|
||||
declaredAgentImages: [
|
||||
"agent-images/ok", // routed
|
||||
"agent-images/absent", // missing
|
||||
"../escape", // unsafe
|
||||
"agent-images/seat-as-file", // not_directory
|
||||
"agent-images/halfimage", // not_manifest (no manifest.yaml inside)
|
||||
"agent-images/dup", // routed (1st dup)
|
||||
"elsewhere/dup", // conflict (basename dup)
|
||||
],
|
||||
}),
|
||||
fs,
|
||||
);
|
||||
expect(result.records).toHaveLength(7);
|
||||
expect(result.routedCount).toBe(2);
|
||||
expect(result.rejectedCount).toBe(5);
|
||||
expect(result.records[0]!.status).toBe("routed");
|
||||
expect(result.records[1]!.status).toBe("missing");
|
||||
expect(result.records[2]!.status).toBe("unsafe");
|
||||
expect(result.records[3]!.status).toBe("not_directory");
|
||||
expect(result.records[4]!.status).toBe("not_manifest");
|
||||
expect(result.records[5]!.status).toBe("routed");
|
||||
expect(result.records[6]!.status).toBe("conflict");
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import os from "node:os";
|
||||
// TODO: AS-T12 — migrate to pod-aware bundle assembler
|
||||
import { LegacyBundleAssembler as BundleAssembler, type AssemblerFsOps } from "../src/domain/bundle-assembler.js";
|
||||
// TODO: AS-T12 — migrate to pod-aware bundle types
|
||||
import { parseLegacyBundleManifest as parseBundleManifest, validateLegacyBundleManifest as validateBundleManifest, normalizeLegacyBundleManifest as normalizeBundleManifest } from "../src/domain/bundle-types.js";
|
||||
import { parseLegacyBundleManifest as parseBundleManifest, validateLegacyBundleManifest as validateBundleManifest, normalizeLegacyBundleManifest as normalizeBundleManifest, type BundleProvenance, type BundleCompatibility } from "../src/domain/bundle-types.js";
|
||||
|
||||
const VALID_SPEC = `
|
||||
schema_version: 1
|
||||
@@ -241,4 +241,129 @@ describe("BundleAssembler", () => {
|
||||
const normalized = normalizeBundleManifest(parsed);
|
||||
expect(normalized.packages[0]!.originalSources).toEqual(["local:./a", "local:./b"]);
|
||||
});
|
||||
|
||||
// Item 1 — provenance capture (slice-05 Checkpoint 2 part 2)
|
||||
it("captures full provenance from opts into manifest", () => {
|
||||
const specPath = writeSpec();
|
||||
const pkgDir = writePkg("pkg", { "package.yaml": "name: pkg" });
|
||||
const outputDir = path.join(tmpDir, "staging");
|
||||
const assembler = new BundleAssembler({ fsOps: realFsOps() });
|
||||
|
||||
const provenance: BundleProvenance = {
|
||||
sourceHost: "test-host.local",
|
||||
authorSession: "velocity-driver@openrig-velocity",
|
||||
sourceRigId: "01KQEQPN4MQJN0DHBM5CQ0N8D7",
|
||||
sourceRigName: "openrig-velocity",
|
||||
daemonVersion: "0.3.2",
|
||||
cliVersion: "0.3.2",
|
||||
notes: "checkpoint-2-part-2 fixture",
|
||||
};
|
||||
const manifest = assembler.assemble({
|
||||
specPath, outputDir, bundleName: "test", bundleVersion: "1.0",
|
||||
packages: [{ name: "pkg", version: "1.0", sourcePath: pkgDir, originalSource: "local:./pkg", manifestHash: "h1" }],
|
||||
provenance,
|
||||
});
|
||||
|
||||
expect(manifest.provenance).toBeDefined();
|
||||
expect(manifest.provenance?.sourceHost).toBe("test-host.local");
|
||||
expect(manifest.provenance?.authorSession).toBe("velocity-driver@openrig-velocity");
|
||||
expect(manifest.provenance?.daemonVersion).toBe("0.3.2");
|
||||
expect(manifest.provenance?.cliVersion).toBe("0.3.2");
|
||||
expect(manifest.provenance?.notes).toBe("checkpoint-2-part-2 fixture");
|
||||
// createdAt mirrors root when opts.provenance.createdAt unset
|
||||
expect(manifest.provenance?.createdAt).toBe(manifest.createdAt);
|
||||
|
||||
// Round-trip via the on-disk bundle.yaml
|
||||
const diskYaml = fs.readFileSync(path.join(outputDir, "bundle.yaml"), "utf-8");
|
||||
const parsed = parseBundleManifest(diskYaml);
|
||||
const validation = validateBundleManifest(parsed, { requireIntegrity: false });
|
||||
expect(validation.valid).toBe(true);
|
||||
const normalized = normalizeBundleManifest(parsed);
|
||||
expect(normalized.provenance?.sourceHost).toBe("test-host.local");
|
||||
expect(normalized.provenance?.notes).toBe("checkpoint-2-part-2 fixture");
|
||||
});
|
||||
|
||||
it("respects opts.provenance.createdAt when caller pre-sets it (test determinism)", () => {
|
||||
const specPath = writeSpec();
|
||||
const pkgDir = writePkg("pkg", { "package.yaml": "name: pkg" });
|
||||
const outputDir = path.join(tmpDir, "staging");
|
||||
const assembler = new BundleAssembler({ fsOps: realFsOps() });
|
||||
|
||||
const fixedCreatedAt = "2026-01-01T00:00:00Z";
|
||||
const manifest = assembler.assemble({
|
||||
specPath, outputDir, bundleName: "test", bundleVersion: "1.0",
|
||||
packages: [{ name: "pkg", version: "1.0", sourcePath: pkgDir, originalSource: "local:./pkg", manifestHash: "h1" }],
|
||||
provenance: { createdAt: fixedCreatedAt, sourceHost: "h" },
|
||||
});
|
||||
|
||||
expect(manifest.provenance?.createdAt).toBe(fixedCreatedAt);
|
||||
// root createdAt is independent (real-time stamp)
|
||||
expect(manifest.createdAt).not.toBe(fixedCreatedAt);
|
||||
});
|
||||
|
||||
it("omits provenance when opts.provenance not provided (backward compat)", () => {
|
||||
const specPath = writeSpec();
|
||||
const pkgDir = writePkg("pkg", { "package.yaml": "name: pkg" });
|
||||
const outputDir = path.join(tmpDir, "staging");
|
||||
const assembler = new BundleAssembler({ fsOps: realFsOps() });
|
||||
|
||||
const manifest = assembler.assemble({
|
||||
specPath, outputDir, bundleName: "test", bundleVersion: "1.0",
|
||||
packages: [{ name: "pkg", version: "1.0", sourcePath: pkgDir, originalSource: "local:./pkg", manifestHash: "h1" }],
|
||||
});
|
||||
|
||||
expect(manifest.provenance).toBeUndefined();
|
||||
// on-disk yaml must not contain provenance section
|
||||
const diskYaml = fs.readFileSync(path.join(outputDir, "bundle.yaml"), "utf-8");
|
||||
expect(diskYaml).not.toContain("provenance:");
|
||||
});
|
||||
|
||||
// Item 2 — compatibility capture (slice-05 Checkpoint 3.2)
|
||||
it("captures full compatibility from opts into manifest", () => {
|
||||
const specPath = writeSpec();
|
||||
const pkgDir = writePkg("pkg", { "package.yaml": "name: pkg" });
|
||||
const outputDir = path.join(tmpDir, "staging");
|
||||
const assembler = new BundleAssembler({ fsOps: realFsOps() });
|
||||
|
||||
const compatibility: BundleCompatibility = {
|
||||
minDaemonVersion: "0.3.2",
|
||||
minCliVersion: "0.3.2",
|
||||
schemaVersion: 1,
|
||||
};
|
||||
const manifest = assembler.assemble({
|
||||
specPath, outputDir, bundleName: "test", bundleVersion: "1.0",
|
||||
packages: [{ name: "pkg", version: "1.0", sourcePath: pkgDir, originalSource: "local:./pkg", manifestHash: "h1" }],
|
||||
compatibility,
|
||||
});
|
||||
|
||||
expect(manifest.compatibility).toBeDefined();
|
||||
expect(manifest.compatibility?.minDaemonVersion).toBe("0.3.2");
|
||||
expect(manifest.compatibility?.minCliVersion).toBe("0.3.2");
|
||||
expect(manifest.compatibility?.schemaVersion).toBe(1);
|
||||
|
||||
// Round-trip via the on-disk bundle.yaml
|
||||
const diskYaml = fs.readFileSync(path.join(outputDir, "bundle.yaml"), "utf-8");
|
||||
const parsed = parseBundleManifest(diskYaml);
|
||||
const validation = validateBundleManifest(parsed, { requireIntegrity: false });
|
||||
expect(validation.valid).toBe(true);
|
||||
const normalized = normalizeBundleManifest(parsed);
|
||||
expect(normalized.compatibility?.minDaemonVersion).toBe("0.3.2");
|
||||
expect(normalized.compatibility?.minCliVersion).toBe("0.3.2");
|
||||
});
|
||||
|
||||
it("omits compatibility when opts.compatibility not provided (backward compat)", () => {
|
||||
const specPath = writeSpec();
|
||||
const pkgDir = writePkg("pkg", { "package.yaml": "name: pkg" });
|
||||
const outputDir = path.join(tmpDir, "staging");
|
||||
const assembler = new BundleAssembler({ fsOps: realFsOps() });
|
||||
|
||||
const manifest = assembler.assemble({
|
||||
specPath, outputDir, bundleName: "test", bundleVersion: "1.0",
|
||||
packages: [{ name: "pkg", version: "1.0", sourcePath: pkgDir, originalSource: "local:./pkg", manifestHash: "h1" }],
|
||||
});
|
||||
|
||||
expect(manifest.compatibility).toBeUndefined();
|
||||
const diskYaml = fs.readFileSync(path.join(outputDir, "bundle.yaml"), "utf-8");
|
||||
expect(diskYaml).not.toContain("compatibility:");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
BundleAuditWriter,
|
||||
BundleAuditReader,
|
||||
type BundleAuditFsOps,
|
||||
type BundleAuditRecord,
|
||||
} from "../src/domain/bundle-audit.js";
|
||||
|
||||
// Item 4 / slice-05 Checkpoint 5.1: bundle-audit unit tests.
|
||||
// Discriminator pattern: any test asserting append + read round-trip must
|
||||
// fail if writer.append or reader.list is broken.
|
||||
|
||||
function mockFs(): BundleAuditFsOps & { _state: Map<string, string>; _mkdirpCalls: string[] } {
|
||||
const state = new Map<string, string>();
|
||||
const mkdirpCalls: string[] = [];
|
||||
return {
|
||||
_state: state,
|
||||
_mkdirpCalls: mkdirpCalls,
|
||||
appendFile: (p: string, c: string) => {
|
||||
state.set(p, (state.get(p) ?? "") + c);
|
||||
},
|
||||
readFile: (p: string) => state.get(p) ?? "",
|
||||
exists: (p: string) => state.has(p),
|
||||
mkdirp: (p: string) => {
|
||||
mkdirpCalls.push(p);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const FIXTURE_PATH = "/test/.openrig/bundle-audit.jsonl";
|
||||
|
||||
function makeWriter(fs: BundleAuditFsOps) {
|
||||
return new BundleAuditWriter({ opts: { auditPath: FIXTURE_PATH }, fsOps: fs });
|
||||
}
|
||||
|
||||
function makeReader(fs: BundleAuditFsOps) {
|
||||
return new BundleAuditReader({ opts: { auditPath: FIXTURE_PATH }, fsOps: fs });
|
||||
}
|
||||
|
||||
function record(overrides?: Partial<BundleAuditRecord>): BundleAuditRecord {
|
||||
return {
|
||||
installedAt: "2026-05-18T12:00:00Z",
|
||||
bundlePath: "/tmp/test.rigbundle",
|
||||
archiveHash: "a".repeat(64),
|
||||
targetRigId: "01H000000000000000000001",
|
||||
targetRigName: "test-rig",
|
||||
sourceHost: "test-host.local",
|
||||
daemonVersion: "0.3.2",
|
||||
cliVersion: "0.3.2",
|
||||
outcome: "success",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("BundleAuditWriter + BundleAuditReader", () => {
|
||||
// A1: empty file → empty list
|
||||
it("reading an empty audit file returns an empty array", () => {
|
||||
const fs = mockFs();
|
||||
const reader = makeReader(fs);
|
||||
expect(reader.list()).toEqual([]);
|
||||
});
|
||||
|
||||
// A2: append + read single record round-trips
|
||||
it("append + list round-trips a single record verbatim", () => {
|
||||
const fs = mockFs();
|
||||
const writer = makeWriter(fs);
|
||||
const reader = makeReader(fs);
|
||||
const r = record();
|
||||
writer.append(r);
|
||||
const out = reader.list();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toEqual(r);
|
||||
});
|
||||
|
||||
// A3: append creates the parent dir
|
||||
it("first append creates the parent directory via mkdirp", () => {
|
||||
const fs = mockFs();
|
||||
const writer = makeWriter(fs);
|
||||
writer.append(record());
|
||||
expect(fs._mkdirpCalls.length).toBeGreaterThan(0);
|
||||
expect(fs._mkdirpCalls[0]).toBe("/test/.openrig");
|
||||
});
|
||||
|
||||
// A4: each record is one JSONL line terminated by newline
|
||||
it("each append writes exactly one JSON object followed by a newline (JSONL format)", () => {
|
||||
const fs = mockFs();
|
||||
const writer = makeWriter(fs);
|
||||
writer.append(record({ targetRigName: "rig-a" }));
|
||||
writer.append(record({ targetRigName: "rig-b" }));
|
||||
const raw = fs._state.get(FIXTURE_PATH)!;
|
||||
const lines = raw.split("\n");
|
||||
// Two records → two non-empty lines + a trailing empty (from trailing \n)
|
||||
expect(lines.length).toBe(3);
|
||||
expect(lines[2]).toBe("");
|
||||
expect(JSON.parse(lines[0]!).targetRigName).toBe("rig-a");
|
||||
expect(JSON.parse(lines[1]!).targetRigName).toBe("rig-b");
|
||||
});
|
||||
|
||||
// A5: list returns records in append order
|
||||
it("list returns records in append order (oldest first)", () => {
|
||||
const fs = mockFs();
|
||||
const writer = makeWriter(fs);
|
||||
writer.append(record({ installedAt: "2026-05-18T10:00:00Z", targetRigName: "rig-a" }));
|
||||
writer.append(record({ installedAt: "2026-05-18T11:00:00Z", targetRigName: "rig-b" }));
|
||||
writer.append(record({ installedAt: "2026-05-18T12:00:00Z", targetRigName: "rig-c" }));
|
||||
const out = makeReader(fs).list();
|
||||
expect(out.map((r) => r.targetRigName)).toEqual(["rig-a", "rig-b", "rig-c"]);
|
||||
});
|
||||
|
||||
// A6: rig filter scopes to matching records
|
||||
it("rig filter returns only records whose targetRigName matches", () => {
|
||||
const fs = mockFs();
|
||||
const writer = makeWriter(fs);
|
||||
writer.append(record({ targetRigName: "alpha" }));
|
||||
writer.append(record({ targetRigName: "beta" }));
|
||||
writer.append(record({ targetRigName: "alpha" }));
|
||||
const out = makeReader(fs).list({ rig: "alpha" });
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out.every((r) => r.targetRigName === "alpha")).toBe(true);
|
||||
});
|
||||
|
||||
// A7: since filter scopes to records at-or-after the cutoff
|
||||
it("since filter returns only records whose installedAt is >= the cutoff", () => {
|
||||
const fs = mockFs();
|
||||
const writer = makeWriter(fs);
|
||||
writer.append(record({ installedAt: "2026-05-18T10:00:00Z" }));
|
||||
writer.append(record({ installedAt: "2026-05-18T11:00:00Z" }));
|
||||
writer.append(record({ installedAt: "2026-05-18T12:00:00Z" }));
|
||||
const out = makeReader(fs).list({ since: "2026-05-18T11:00:00Z" });
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out.map((r) => r.installedAt)).toEqual([
|
||||
"2026-05-18T11:00:00Z",
|
||||
"2026-05-18T12:00:00Z",
|
||||
]);
|
||||
});
|
||||
|
||||
// A8: combined rig + since filters
|
||||
it("rig + since filters both apply (AND semantics)", () => {
|
||||
const fs = mockFs();
|
||||
const writer = makeWriter(fs);
|
||||
writer.append(record({ installedAt: "2026-05-18T10:00:00Z", targetRigName: "alpha" }));
|
||||
writer.append(record({ installedAt: "2026-05-18T11:00:00Z", targetRigName: "beta" }));
|
||||
writer.append(record({ installedAt: "2026-05-18T12:00:00Z", targetRigName: "alpha" }));
|
||||
const out = makeReader(fs).list({ rig: "alpha", since: "2026-05-18T11:00:00Z" });
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]!.installedAt).toBe("2026-05-18T12:00:00Z");
|
||||
});
|
||||
|
||||
// A9: malformed lines are skipped silently (forward-compat)
|
||||
it("malformed JSONL lines are skipped silently; valid lines still surface", () => {
|
||||
const fs = mockFs();
|
||||
fs._state.set(FIXTURE_PATH, [
|
||||
JSON.stringify(record({ targetRigName: "a" })),
|
||||
"not-valid-json{",
|
||||
JSON.stringify(record({ targetRigName: "b" })),
|
||||
].join("\n") + "\n");
|
||||
const out = makeReader(fs).list();
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out.map((r) => r.targetRigName)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
// A10: outcome field is preserved literally (success / failed / partial)
|
||||
it("outcome field round-trips for all three values", () => {
|
||||
const fs = mockFs();
|
||||
const writer = makeWriter(fs);
|
||||
writer.append(record({ outcome: "success", targetRigName: "ok" }));
|
||||
writer.append(record({ outcome: "failed", targetRigName: "bad" }));
|
||||
writer.append(record({ outcome: "partial", targetRigName: "halfway" }));
|
||||
const out = makeReader(fs).list();
|
||||
expect(out.map((r) => r.outcome)).toEqual(["success", "failed", "partial"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { detectBundleConflicts, type DetectConflictsInput } from "../src/domain/bundle-conflict-detector.js";
|
||||
|
||||
// Item 3 / slice-05 Checkpoint 4.1: bundle-conflict-detector pure-function tests.
|
||||
// Discriminator pattern: any test that asserts a conflict was reported must
|
||||
// fail if the corresponding detection branch is commented out in the module.
|
||||
|
||||
describe("bundle-conflict-detector — rig name collision", () => {
|
||||
// R1: empty running rigs → no conflicts
|
||||
it("empty runningRigs produces no conflicts", () => {
|
||||
const input: DetectConflictsInput = { bundleRigName: "alpha", runningRigs: [] };
|
||||
const report = detectBundleConflicts(input);
|
||||
expect(report.hasConflicts).toBe(false);
|
||||
expect(report.conflicts).toHaveLength(0);
|
||||
});
|
||||
|
||||
// R2: running rigs with different names → no conflicts
|
||||
it("runningRigs with different names produces no conflicts", () => {
|
||||
const input: DetectConflictsInput = {
|
||||
bundleRigName: "alpha",
|
||||
runningRigs: [
|
||||
{ rigId: "01H000000000000000000001", name: "beta" },
|
||||
{ rigId: "01H000000000000000000002", name: "gamma" },
|
||||
],
|
||||
};
|
||||
const report = detectBundleConflicts(input);
|
||||
expect(report.hasConflicts).toBe(false);
|
||||
});
|
||||
|
||||
// R3: running rig with same name → conflict reported with correct shape
|
||||
it("running rig with same name produces a rig_name_collision conflict", () => {
|
||||
const input: DetectConflictsInput = {
|
||||
bundleRigName: "alpha",
|
||||
runningRigs: [
|
||||
{ rigId: "01H000000000000000000003", name: "alpha" },
|
||||
],
|
||||
};
|
||||
const report = detectBundleConflicts(input);
|
||||
expect(report.hasConflicts).toBe(true);
|
||||
expect(report.conflicts).toHaveLength(1);
|
||||
const c = report.conflicts[0]!;
|
||||
expect(c.kind).toBe("rig_name_collision");
|
||||
expect(c.bundleRigName).toBe("alpha");
|
||||
if (c.kind === "rig_name_collision") {
|
||||
expect(c.collisionWith.rigId).toBe("01H000000000000000000003");
|
||||
expect(c.collisionWith.rigName).toBe("alpha");
|
||||
expect(c.description).toContain("alpha");
|
||||
expect(c.description).toContain("01H000000000000000000003");
|
||||
expect(c.resolutions.length).toBeGreaterThanOrEqual(2);
|
||||
// Resolutions mention the --target and --force flags (Checkpoint 4.2 surfaces)
|
||||
expect(c.resolutions.some((r) => r.includes("--target"))).toBe(true);
|
||||
expect(c.resolutions.some((r) => r.includes("--force"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
// R4: collision detected even when other rigs precede the match
|
||||
it("collision found in a list with multiple rigs, only the matching one reported", () => {
|
||||
const input: DetectConflictsInput = {
|
||||
bundleRigName: "alpha",
|
||||
runningRigs: [
|
||||
{ rigId: "01H000000000000000000004", name: "beta" },
|
||||
{ rigId: "01H000000000000000000005", name: "gamma" },
|
||||
{ rigId: "01H000000000000000000006", name: "alpha" },
|
||||
{ rigId: "01H000000000000000000007", name: "delta" },
|
||||
],
|
||||
};
|
||||
const report = detectBundleConflicts(input);
|
||||
expect(report.conflicts).toHaveLength(1);
|
||||
if (report.conflicts[0]!.kind === "rig_name_collision") {
|
||||
expect(report.conflicts[0]!.collisionWith.rigId).toBe("01H000000000000000000006");
|
||||
}
|
||||
});
|
||||
|
||||
// R5: empty bundleRigName produces no conflict (fail-open on missing input)
|
||||
it("empty bundleRigName produces no conflict (no rig name = nothing to compare)", () => {
|
||||
const input: DetectConflictsInput = {
|
||||
bundleRigName: "",
|
||||
runningRigs: [{ rigId: "01H000000000000000000008", name: "alpha" }],
|
||||
};
|
||||
const report = detectBundleConflicts(input);
|
||||
expect(report.hasConflicts).toBe(false);
|
||||
});
|
||||
|
||||
// R6: case sensitivity — collision is exact-name match only
|
||||
it("name match is case-sensitive (case mismatch = no collision)", () => {
|
||||
const input: DetectConflictsInput = {
|
||||
bundleRigName: "alpha",
|
||||
runningRigs: [{ rigId: "01H000000000000000000009", name: "Alpha" }],
|
||||
};
|
||||
const report = detectBundleConflicts(input);
|
||||
expect(report.hasConflicts).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { routeContextPacks, type ContextPacksRouterFsOps, type RouteContextPacksInput } from "../src/domain/bundle-context-packs-router.js";
|
||||
|
||||
// Item 6 / slice-05 Checkpoint 7.3f step 2: bundle-context-packs-router
|
||||
// pure-function tests. Mirrors plugins router (dir-based) with the
|
||||
// context-pack-specific degenerate-input handling per the consumer
|
||||
// contract (context-pack-library-service.scan walks pack dirs whose
|
||||
// immediate child is manifest.yaml).
|
||||
|
||||
function mockFs(initial: { dirs?: string[]; files?: string[] } = {}): ContextPacksRouterFsOps & {
|
||||
_copyCalls: Array<{ src: string; dest: string }>;
|
||||
_mkdirpCalls: string[];
|
||||
_dirs: Set<string>;
|
||||
_files: Set<string>;
|
||||
} {
|
||||
const dirs = new Set<string>(initial.dirs ?? []);
|
||||
const files = new Set<string>(initial.files ?? []);
|
||||
const copyCalls: Array<{ src: string; dest: string }> = [];
|
||||
const mkdirpCalls: string[] = [];
|
||||
return {
|
||||
_copyCalls: copyCalls,
|
||||
_mkdirpCalls: mkdirpCalls,
|
||||
_dirs: dirs,
|
||||
_files: files,
|
||||
// exists() respects both dirs and files (matches node:fs.existsSync semantics)
|
||||
exists: (p: string) => dirs.has(p) || files.has(p),
|
||||
isDirectory: (p: string) => dirs.has(p),
|
||||
mkdirp: (p: string) => { mkdirpCalls.push(p); dirs.add(p); },
|
||||
copyDir: (src: string, dest: string) => { copyCalls.push({ src, dest }); dirs.add(dest); },
|
||||
};
|
||||
}
|
||||
|
||||
/** Convenience: build a complete pack fixture (parent dir + manifest.yaml file). */
|
||||
function packFixture(packParentDir: string): { dirs: string[]; files: string[] } {
|
||||
return { dirs: [packParentDir], files: [`${packParentDir}/manifest.yaml`] };
|
||||
}
|
||||
|
||||
const BUNDLE_ROOT = "/bundle/root";
|
||||
const TARGET = "/operator/.openrig/context-packs";
|
||||
|
||||
function makeInput(overrides?: Partial<RouteContextPacksInput>): RouteContextPacksInput {
|
||||
return {
|
||||
bundleRoot: BUNDLE_ROOT,
|
||||
declaredContextPacks: [],
|
||||
targetContextPacksDir: TARGET,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("routeContextPacks", () => {
|
||||
// C1: empty list → empty records + target dir mkdirp'd
|
||||
it("empty declaredContextPacks produces empty records but still mkdirp's target", () => {
|
||||
const fs = mockFs();
|
||||
const result = routeContextPacks(makeInput(), fs);
|
||||
expect(result.records).toEqual([]);
|
||||
expect(result.routedCount).toBe(0);
|
||||
expect(result.rejectedCount).toBe(0);
|
||||
expect(fs._mkdirpCalls).toContain(TARGET);
|
||||
});
|
||||
|
||||
// C2: routes one pack: copyDir from sourceParentDir to target/<dirname>
|
||||
it("routes one context_pack: parent dir copied to target/<dirname>", () => {
|
||||
const fs = mockFs(packFixture(`${BUNDLE_ROOT}/context-packs/intent`));
|
||||
const result = routeContextPacks(
|
||||
makeInput({ declaredContextPacks: ["context-packs/intent/manifest.yaml"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.routedCount).toBe(1);
|
||||
expect(result.records[0]!.status).toBe("routed");
|
||||
expect(result.records[0]!.installedAt).toBe(`${TARGET}/intent`);
|
||||
expect(fs._copyCalls).toHaveLength(1);
|
||||
expect(fs._copyCalls[0]).toEqual({
|
||||
src: `${BUNDLE_ROOT}/context-packs/intent`,
|
||||
dest: `${TARGET}/intent`,
|
||||
});
|
||||
});
|
||||
|
||||
// C3: multiple distinct packs route correctly
|
||||
it("routes multiple distinct context_packs each to target/<dirname>", () => {
|
||||
const fs = mockFs({
|
||||
dirs: [`${BUNDLE_ROOT}/context-packs/intent`, `${BUNDLE_ROOT}/context-packs/persona`],
|
||||
files: [`${BUNDLE_ROOT}/context-packs/intent/manifest.yaml`, `${BUNDLE_ROOT}/context-packs/persona/manifest.yaml`],
|
||||
});
|
||||
const result = routeContextPacks(
|
||||
makeInput({
|
||||
declaredContextPacks: [
|
||||
"context-packs/intent/manifest.yaml",
|
||||
"context-packs/persona/manifest.yaml",
|
||||
],
|
||||
}),
|
||||
fs,
|
||||
);
|
||||
expect(result.routedCount).toBe(2);
|
||||
expect(fs._copyCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
// C4: missing source pack dir → status=missing (the manifest.yaml file
|
||||
// itself is absent because the parent isn't there either)
|
||||
it("missing source pack dir → status=missing (honest-scoping)", () => {
|
||||
const fs = mockFs();
|
||||
const result = routeContextPacks(
|
||||
makeInput({ declaredContextPacks: ["context-packs/absent/manifest.yaml"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.routedCount).toBe(0);
|
||||
expect(result.records[0]!.status).toBe("missing");
|
||||
expect(result.records[0]!.detail).toContain("not present");
|
||||
expect(fs._copyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
// C5: unsafe source path escaping bundle workspace rejected
|
||||
it("unsafe source path escaping bundle workspace → status=unsafe", () => {
|
||||
const fs = mockFs();
|
||||
const result = routeContextPacks(
|
||||
makeInput({ declaredContextPacks: ["../escape/manifest.yaml"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.records[0]!.status).toBe("unsafe");
|
||||
expect(result.records[0]!.detail).toContain("escapes bundle workspace");
|
||||
expect(fs._copyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
// C6 (degenerate-input): basename not manifest.yaml — consumer-invisible
|
||||
// class. Per banked PRE-handoff degenerate-input dogfood discipline.
|
||||
it("declared path with non-manifest.yaml basename → status=not_manifest", () => {
|
||||
const fs = mockFs({
|
||||
dirs: [`${BUNDLE_ROOT}/context-packs/oddpack`],
|
||||
files: [`${BUNDLE_ROOT}/context-packs/oddpack/pack.yaml`, `${BUNDLE_ROOT}/context-packs/oddpack/manifest.txt`],
|
||||
});
|
||||
const result = routeContextPacks(
|
||||
makeInput({
|
||||
declaredContextPacks: [
|
||||
"context-packs/oddpack/pack.yaml",
|
||||
"context-packs/oddpack/manifest.txt",
|
||||
],
|
||||
}),
|
||||
fs,
|
||||
);
|
||||
expect(result.records).toHaveLength(2);
|
||||
expect(result.routedCount).toBe(0);
|
||||
expect(result.records[0]!.status).toBe("not_manifest");
|
||||
expect(result.records[0]!.detail).toContain("manifest.yaml");
|
||||
expect(result.records[1]!.status).toBe("not_manifest");
|
||||
expect(fs._copyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
// C7 (degenerate-input): parent-dir basename collision — first wins,
|
||||
// second flagged conflict. Banked workflow_specs B1 lesson applied.
|
||||
it("two declared packs sharing parent-dir basename → first routed, second conflict", () => {
|
||||
const fs = mockFs({
|
||||
dirs: [`${BUNDLE_ROOT}/a/intent`, `${BUNDLE_ROOT}/b/intent`],
|
||||
files: [`${BUNDLE_ROOT}/a/intent/manifest.yaml`, `${BUNDLE_ROOT}/b/intent/manifest.yaml`],
|
||||
});
|
||||
const result = routeContextPacks(
|
||||
makeInput({
|
||||
declaredContextPacks: [
|
||||
"a/intent/manifest.yaml",
|
||||
"b/intent/manifest.yaml",
|
||||
],
|
||||
}),
|
||||
fs,
|
||||
);
|
||||
expect(result.records).toHaveLength(2);
|
||||
expect(result.routedCount).toBe(1);
|
||||
expect(result.rejectedCount).toBe(1);
|
||||
expect(result.records[0]!.status).toBe("routed");
|
||||
expect(result.records[0]!.installedAt).toBe(`${TARGET}/intent`);
|
||||
expect(result.records[1]!.status).toBe("conflict");
|
||||
expect(result.records[1]!.detail).toContain("intent");
|
||||
expect(result.records[1]!.detail).toContain("collides");
|
||||
// Only the first copyDir fires.
|
||||
expect(fs._copyCalls).toHaveLength(1);
|
||||
expect(fs._copyCalls[0]!.src).toBe(`${BUNDLE_ROOT}/a/intent`);
|
||||
});
|
||||
|
||||
// C8: pack source path exists but is a file not a directory — edge case
|
||||
// (unusual; manifest.yaml dirname resolved to a file). Skipped honestly.
|
||||
it("source parent exists but is a file (not directory) → status=not_directory", () => {
|
||||
// Make manifest file exist (so the file-existence check passes) but the
|
||||
// parent is registered as a FILE not a directory.
|
||||
const fs = mockFs({ files: [`${BUNDLE_ROOT}/oddpath`, `${BUNDLE_ROOT}/oddpath/manifest.yaml`] });
|
||||
const result = routeContextPacks(
|
||||
makeInput({ declaredContextPacks: ["oddpath/manifest.yaml"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.records[0]!.status).toBe("not_directory");
|
||||
expect(fs._copyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
// C9: mixed list — routed + missing + unsafe + not_manifest + conflict
|
||||
// aggregate correctly
|
||||
it("mixed declared list aggregates correctly across all rejection classes", () => {
|
||||
const fs = mockFs({
|
||||
dirs: [
|
||||
`${BUNDLE_ROOT}/context-packs/ok`,
|
||||
`${BUNDLE_ROOT}/context-packs/dup`,
|
||||
`${BUNDLE_ROOT}/elsewhere/dup`,
|
||||
],
|
||||
files: [
|
||||
`${BUNDLE_ROOT}/context-packs/ok/manifest.yaml`,
|
||||
`${BUNDLE_ROOT}/context-packs/dup/manifest.yaml`,
|
||||
`${BUNDLE_ROOT}/elsewhere/dup/manifest.yaml`,
|
||||
],
|
||||
});
|
||||
const result = routeContextPacks(
|
||||
makeInput({
|
||||
declaredContextPacks: [
|
||||
"context-packs/ok/manifest.yaml", // routed
|
||||
"context-packs/absent/manifest.yaml", // missing
|
||||
"../escape/manifest.yaml", // unsafe
|
||||
"context-packs/odd/pack.yaml", // not_manifest
|
||||
"context-packs/dup/manifest.yaml", // routed (1st dup)
|
||||
"elsewhere/dup/manifest.yaml", // conflict (basename dup with above)
|
||||
],
|
||||
}),
|
||||
fs,
|
||||
);
|
||||
expect(result.records).toHaveLength(6);
|
||||
expect(result.routedCount).toBe(2);
|
||||
expect(result.rejectedCount).toBe(4);
|
||||
expect(result.records[0]!.status).toBe("routed");
|
||||
expect(result.records[1]!.status).toBe("missing");
|
||||
expect(result.records[2]!.status).toBe("unsafe");
|
||||
expect(result.records[3]!.status).toBe("not_manifest");
|
||||
expect(result.records[4]!.status).toBe("routed");
|
||||
expect(result.records[5]!.status).toBe("conflict");
|
||||
});
|
||||
|
||||
// C11 (degenerate-input / banked guard catch on d491eca9):
|
||||
// manifest.yaml exists at the declared path but is a DIRECTORY, not a
|
||||
// file. fs.exists returns true for both shapes; the live consumer
|
||||
// (context-pack-library-service.ts:135 readFileSync) throws on a dir
|
||||
// path and scan() records an error diagnostic instead of indexing the
|
||||
// pack. Router must reject pre-write so routedCount stays truthful.
|
||||
it("manifest.yaml exists but is a directory → status=not_manifest (no false-positive routed)", () => {
|
||||
const fs = mockFs({
|
||||
// Parent dir IS a directory, AND manifest.yaml is ALSO a directory
|
||||
// (not a file). The bare exists check would pass; isDirectory check
|
||||
// catches this.
|
||||
dirs: [
|
||||
`${BUNDLE_ROOT}/context-packs/dirpack`,
|
||||
`${BUNDLE_ROOT}/context-packs/dirpack/manifest.yaml`,
|
||||
],
|
||||
});
|
||||
const result = routeContextPacks(
|
||||
makeInput({ declaredContextPacks: ["context-packs/dirpack/manifest.yaml"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.records).toHaveLength(1);
|
||||
expect(result.routedCount).toBe(0);
|
||||
expect(result.rejectedCount).toBe(1);
|
||||
expect(result.records[0]!.status).toBe("not_manifest");
|
||||
expect(result.records[0]!.detail).toContain("directory");
|
||||
// CRUCIAL: no copyDir fires (consumer-invisible pack must not route)
|
||||
expect(fs._copyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
// C10 (degenerate-input / banked guard catch on a0e7e0e1):
|
||||
// parent dir exists but manifest.yaml file itself is absent —
|
||||
// ContextPackLibraryService.scan skips packs missing manifest.yaml
|
||||
// (context-pack-library-service.ts:76). The router must check the
|
||||
// manifest FILE existence, not just the parent dir, or routedCount
|
||||
// claims a consumer-invisible pack.
|
||||
it("parent dir exists but manifest.yaml file missing → status=missing (no false-positive routed)", () => {
|
||||
const fs = mockFs({
|
||||
// Parent dir is present + isDirectory, BUT manifest.yaml is NOT in
|
||||
// files (consumer requires the file itself).
|
||||
dirs: [`${BUNDLE_ROOT}/context-packs/halfpack`],
|
||||
});
|
||||
const result = routeContextPacks(
|
||||
makeInput({ declaredContextPacks: ["context-packs/halfpack/manifest.yaml"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.records).toHaveLength(1);
|
||||
expect(result.routedCount).toBe(0);
|
||||
expect(result.rejectedCount).toBe(1);
|
||||
expect(result.records[0]!.status).toBe("missing");
|
||||
expect(result.records[0]!.detail).toContain("not present");
|
||||
// CRUCIAL: no copyDir fires (operator-invisible pack must not route)
|
||||
expect(fs._copyCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { routePlugins, type PluginsRouterFsOps, type RoutePluginsInput } from "../src/domain/bundle-plugins-router.js";
|
||||
|
||||
// Item 6 / slice-05 Checkpoint 7.3c: bundle-plugins-router pure-function tests.
|
||||
// Banked both-sides trust-boundary lesson applied: source + target containment.
|
||||
|
||||
function mockFs(initial: { dirs?: string[]; files?: Record<string, string> } = {}): PluginsRouterFsOps & { _copyCalls: Array<{ src: string; dest: string }>; _mkdirpCalls: string[]; _dirs: Set<string> } {
|
||||
const dirs = new Set<string>(initial.dirs ?? []);
|
||||
const files = new Map<string, string>(Object.entries(initial.files ?? {}));
|
||||
const copyCalls: Array<{ src: string; dest: string }> = [];
|
||||
const mkdirpCalls: string[] = [];
|
||||
return {
|
||||
_copyCalls: copyCalls,
|
||||
_mkdirpCalls: mkdirpCalls,
|
||||
_dirs: dirs,
|
||||
exists: (p: string) => dirs.has(p) || files.has(p),
|
||||
isDirectory: (p: string) => dirs.has(p),
|
||||
mkdirp: (p: string) => { mkdirpCalls.push(p); dirs.add(p); },
|
||||
copyDir: (src: string, dest: string) => { copyCalls.push({ src, dest }); dirs.add(dest); },
|
||||
};
|
||||
}
|
||||
|
||||
const BUNDLE_ROOT = "/bundle/root";
|
||||
const TARGET = "/operator/.openrig/plugins";
|
||||
|
||||
function makeInput(overrides?: Partial<RoutePluginsInput>): RoutePluginsInput {
|
||||
return {
|
||||
bundleRoot: BUNDLE_ROOT,
|
||||
declaredPlugins: [],
|
||||
targetPluginsDir: TARGET,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("routePlugins", () => {
|
||||
// P1: empty plugin list
|
||||
it("empty declaredPlugins produces empty records but still mkdirp's target", () => {
|
||||
const fs = mockFs();
|
||||
const result = routePlugins(makeInput(), fs);
|
||||
expect(result.records).toEqual([]);
|
||||
expect(result.routedCount).toBe(0);
|
||||
expect(fs._mkdirpCalls).toContain(TARGET);
|
||||
});
|
||||
|
||||
// P2: single plugin routes end-to-end
|
||||
it("routes one plugin: copyDir called from source to target/<id>", () => {
|
||||
const fs = mockFs({ dirs: [`${BUNDLE_ROOT}/plugins/gstack`] });
|
||||
const result = routePlugins(
|
||||
makeInput({
|
||||
declaredPlugins: [{ id: "gstack", source: { kind: "local", path: "plugins/gstack" } }],
|
||||
}),
|
||||
fs,
|
||||
);
|
||||
expect(result.routedCount).toBe(1);
|
||||
expect(result.records[0]!.status).toBe("routed");
|
||||
expect(result.records[0]!.installedAt).toBe(`${TARGET}/gstack`);
|
||||
expect(fs._copyCalls).toHaveLength(1);
|
||||
expect(fs._copyCalls[0]).toEqual({
|
||||
src: `${BUNDLE_ROOT}/plugins/gstack`,
|
||||
dest: `${TARGET}/gstack`,
|
||||
});
|
||||
});
|
||||
|
||||
// P3: multiple plugins
|
||||
it("routes multiple plugins each landing at target/<id>", () => {
|
||||
const fs = mockFs({ dirs: [`${BUNDLE_ROOT}/plugins/a`, `${BUNDLE_ROOT}/plugins/b`] });
|
||||
const result = routePlugins(
|
||||
makeInput({
|
||||
declaredPlugins: [
|
||||
{ id: "a", source: { kind: "local", path: "plugins/a" } },
|
||||
{ id: "b", source: { kind: "local", path: "plugins/b" } },
|
||||
],
|
||||
}),
|
||||
fs,
|
||||
);
|
||||
expect(result.routedCount).toBe(2);
|
||||
expect(fs._copyCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
// P4: missing source skipped honestly
|
||||
it("missing source plugin is skipped with status=missing", () => {
|
||||
const fs = mockFs(); // no dirs
|
||||
const result = routePlugins(
|
||||
makeInput({
|
||||
declaredPlugins: [{ id: "absent", source: { kind: "local", path: "plugins/absent" } }],
|
||||
}),
|
||||
fs,
|
||||
);
|
||||
expect(result.routedCount).toBe(0);
|
||||
expect(result.records[0]!.status).toBe("missing");
|
||||
expect(fs._copyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
// P5: source path is a file (not a directory) is rejected
|
||||
it("source path that is a file (not directory) produces status=not_directory", () => {
|
||||
const fs = mockFs({ files: { [`${BUNDLE_ROOT}/plugins/notadir.txt`]: "content" } });
|
||||
const result = routePlugins(
|
||||
makeInput({
|
||||
declaredPlugins: [{ id: "x", source: { kind: "local", path: "plugins/notadir.txt" } }],
|
||||
}),
|
||||
fs,
|
||||
);
|
||||
expect(result.records[0]!.status).toBe("not_directory");
|
||||
expect(fs._copyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
// P6: SOURCE-side path escaping bundle workspace rejected
|
||||
it("source path escaping bundle workspace produces status=unsafe (source containment)", () => {
|
||||
const fs = mockFs();
|
||||
const result = routePlugins(
|
||||
makeInput({
|
||||
declaredPlugins: [{ id: "x", source: { kind: "local", path: "../escape" } }],
|
||||
}),
|
||||
fs,
|
||||
);
|
||||
expect(result.records[0]!.status).toBe("unsafe");
|
||||
expect(result.records[0]!.detail).toContain("escapes bundle workspace");
|
||||
expect(fs._copyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
// P7: TARGET-side containment — plugin id that resolves outside target rejected
|
||||
// (banked both-sides-trust-boundary lesson applied)
|
||||
it("plugin id with traversal escapes target plugins library — status=unsafe (target containment)", () => {
|
||||
const fs = mockFs({ dirs: [`${BUNDLE_ROOT}/plugins/legitsource`] });
|
||||
const result = routePlugins(
|
||||
makeInput({
|
||||
declaredPlugins: [{ id: "../escape-id", source: { kind: "local", path: "plugins/legitsource" } }],
|
||||
}),
|
||||
fs,
|
||||
);
|
||||
expect(result.records[0]!.status).toBe("unsafe");
|
||||
expect(result.records[0]!.detail).toContain("resolve outside target plugins library");
|
||||
expect(fs._copyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
// P8: invalid plugin entry (missing id or wrong source.kind) rejected
|
||||
it("plugin reference with non-local source.kind rejected", () => {
|
||||
const fs = mockFs({ dirs: [`${BUNDLE_ROOT}/plugins/x`] });
|
||||
const result = routePlugins(
|
||||
makeInput({
|
||||
declaredPlugins: [{ id: "x", source: { kind: "remote" as "local", path: "plugins/x" } }],
|
||||
}),
|
||||
fs,
|
||||
);
|
||||
expect(result.records[0]!.status).toBe("unsafe");
|
||||
expect(result.records[0]!.detail).toContain("source.kind must be 'local'");
|
||||
expect(fs._copyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
// P9: mixed list — routed + missing + unsafe + not_directory aggregate
|
||||
it("mixed plugin list aggregates by status correctly", () => {
|
||||
const fs = mockFs({
|
||||
dirs: [`${BUNDLE_ROOT}/plugins/ok`],
|
||||
files: { [`${BUNDLE_ROOT}/plugins/file.txt`]: "x" },
|
||||
});
|
||||
const result = routePlugins(
|
||||
makeInput({
|
||||
declaredPlugins: [
|
||||
{ id: "good", source: { kind: "local", path: "plugins/ok" } },
|
||||
{ id: "absent", source: { kind: "local", path: "plugins/missing" } },
|
||||
{ id: "escapes", source: { kind: "local", path: "../escape" } },
|
||||
{ id: "filething", source: { kind: "local", path: "plugins/file.txt" } },
|
||||
],
|
||||
}),
|
||||
fs,
|
||||
);
|
||||
expect(result.records).toHaveLength(4);
|
||||
expect(result.routedCount).toBe(1);
|
||||
expect(result.rejectedCount).toBe(3);
|
||||
expect(result.records[0]!.status).toBe("routed");
|
||||
expect(result.records[1]!.status).toBe("missing");
|
||||
expect(result.records[2]!.status).toBe("unsafe");
|
||||
expect(result.records[3]!.status).toBe("not_directory");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import nodePath from "node:path";
|
||||
import { routeSkills, type SkillsRouterFsOps, type RouteSkillsInput } from "../src/domain/bundle-skills-router.js";
|
||||
|
||||
// Item 6 / slice-05 Checkpoint 7.2: bundle-skills-router pure-function tests.
|
||||
|
||||
function mockFs(initialFiles: Record<string, string> = {}): SkillsRouterFsOps & { _written: Map<string, string>; _mkdirpCalls: string[] } {
|
||||
const written = new Map<string, string>(Object.entries(initialFiles));
|
||||
const mkdirpCalls: string[] = [];
|
||||
return {
|
||||
_written: written,
|
||||
_mkdirpCalls: mkdirpCalls,
|
||||
exists: (p: string) => written.has(p),
|
||||
readFile: (p: string) => {
|
||||
const v = written.get(p);
|
||||
if (v === undefined) throw new Error(`File not found in mock: ${p}`);
|
||||
return v;
|
||||
},
|
||||
writeFile: (p: string, c: string) => { written.set(p, c); },
|
||||
mkdirp: (p: string) => { mkdirpCalls.push(p); },
|
||||
};
|
||||
}
|
||||
|
||||
const BUNDLE_ROOT = "/bundle/root";
|
||||
const TARGET = "/operator/.openrig/skills";
|
||||
|
||||
function makeInput(overrides?: Partial<RouteSkillsInput>): RouteSkillsInput {
|
||||
return {
|
||||
bundleRoot: BUNDLE_ROOT,
|
||||
declaredSkills: [],
|
||||
targetSkillsDir: TARGET,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("routeSkills", () => {
|
||||
// R1: empty skills list → empty records + target dir mkdirp'd
|
||||
it("empty declaredSkills produces empty records but still mkdirp's target", () => {
|
||||
const fs = mockFs();
|
||||
const result = routeSkills(makeInput(), fs);
|
||||
expect(result.records).toEqual([]);
|
||||
expect(result.routedCount).toBe(0);
|
||||
expect(result.rejectedCount).toBe(0);
|
||||
expect(fs._mkdirpCalls).toContain(TARGET);
|
||||
});
|
||||
|
||||
// R2: routes one skill end-to-end
|
||||
it("routes one skill: source file copied to target with installedAt populated", () => {
|
||||
const fs = mockFs({
|
||||
[`${BUNDLE_ROOT}/skills/foo/SKILL.md`]: "# foo skill body",
|
||||
});
|
||||
const result = routeSkills(makeInput({ declaredSkills: ["skills/foo/SKILL.md"] }), fs);
|
||||
expect(result.routedCount).toBe(1);
|
||||
expect(result.rejectedCount).toBe(0);
|
||||
expect(result.records[0]!.status).toBe("routed");
|
||||
expect(result.records[0]!.installedAt).toBe(`${TARGET}/foo/SKILL.md`);
|
||||
expect(fs._written.get(`${TARGET}/foo/SKILL.md`)).toBe("# foo skill body");
|
||||
});
|
||||
|
||||
// R3: routes multiple skills; preserves directory layout
|
||||
it("routes multiple skills preserving the per-skill directory layout under target", () => {
|
||||
const fs = mockFs({
|
||||
[`${BUNDLE_ROOT}/skills/foo/SKILL.md`]: "foo",
|
||||
[`${BUNDLE_ROOT}/skills/bar/SKILL.md`]: "bar",
|
||||
[`${BUNDLE_ROOT}/skills/bar/helper.md`]: "bar-helper",
|
||||
});
|
||||
const result = routeSkills(
|
||||
makeInput({ declaredSkills: ["skills/foo/SKILL.md", "skills/bar/SKILL.md", "skills/bar/helper.md"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.routedCount).toBe(3);
|
||||
expect(fs._written.get(`${TARGET}/foo/SKILL.md`)).toBe("foo");
|
||||
expect(fs._written.get(`${TARGET}/bar/SKILL.md`)).toBe("bar");
|
||||
expect(fs._written.get(`${TARGET}/bar/helper.md`)).toBe("bar-helper");
|
||||
});
|
||||
|
||||
// R4: missing source file produces "missing" record (skipped, not error)
|
||||
it("missing source file is skipped with status=missing (honest-scoping)", () => {
|
||||
const fs = mockFs(); // empty — no skill files
|
||||
const result = routeSkills(
|
||||
makeInput({ declaredSkills: ["skills/absent/SKILL.md"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.routedCount).toBe(0);
|
||||
expect(result.rejectedCount).toBe(1);
|
||||
expect(result.records[0]!.status).toBe("missing");
|
||||
expect(result.records[0]!.detail).toContain("not present");
|
||||
});
|
||||
|
||||
// R5: unsafe path escaping bundle workspace is rejected
|
||||
it("unsafe declared path (../traversal) escapes bundle workspace and is rejected", () => {
|
||||
const fs = mockFs();
|
||||
const result = routeSkills(
|
||||
makeInput({ declaredSkills: ["../escape/SKILL.md"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.routedCount).toBe(0);
|
||||
expect(result.rejectedCount).toBe(1);
|
||||
expect(result.records[0]!.status).toBe("unsafe");
|
||||
expect(result.records[0]!.detail).toContain("escapes bundle workspace");
|
||||
});
|
||||
|
||||
// R6: mixed list — routed + missing + unsafe in one call
|
||||
it("mixed declared list aggregates correctly across routed/missing/unsafe", () => {
|
||||
const fs = mockFs({
|
||||
[`${BUNDLE_ROOT}/skills/ok/SKILL.md`]: "ok",
|
||||
});
|
||||
const result = routeSkills(
|
||||
makeInput({ declaredSkills: ["skills/ok/SKILL.md", "skills/absent/SKILL.md", "../escape/SKILL.md"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.records).toHaveLength(3);
|
||||
expect(result.routedCount).toBe(1);
|
||||
expect(result.rejectedCount).toBe(2);
|
||||
expect(result.records[0]!.status).toBe("routed");
|
||||
expect(result.records[1]!.status).toBe("missing");
|
||||
expect(result.records[2]!.status).toBe("unsafe");
|
||||
});
|
||||
|
||||
// R7-B1: target-side path containment (B1 repair on
|
||||
// qitem-20260518215234-f84fff45). Declared path "skills/../outside/SKILL.md"
|
||||
// passes SOURCE containment (resolves under bundleRoot since the leading
|
||||
// "skills/" segment is consumed before ../) but after the leading "skills/"
|
||||
// strip becomes "../outside/SKILL.md" which would escape targetSkillsDir.
|
||||
// Must be rejected.
|
||||
it("declared path that would escape target skills dir after prefix strip is rejected", () => {
|
||||
// Source file exists under bundleRoot (passes source containment) but the
|
||||
// resolved target after strip escapes target dir.
|
||||
const fs = mockFs({
|
||||
// Source is reachable from bundleRoot via "skills/../outside/SKILL.md"
|
||||
// which resolves to "<bundleRoot>/outside/SKILL.md".
|
||||
[`${BUNDLE_ROOT}/outside/SKILL.md`]: "would-escape-target",
|
||||
});
|
||||
const result = routeSkills(
|
||||
makeInput({ declaredSkills: ["skills/../outside/SKILL.md"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.routedCount).toBe(0);
|
||||
expect(result.rejectedCount).toBe(1);
|
||||
expect(result.records[0]!.status).toBe("unsafe");
|
||||
expect(result.records[0]!.detail).toContain("escapes target skills library");
|
||||
// Crucially: no write happened outside target.
|
||||
expect(fs._written.has(`${TARGET}/../outside/SKILL.md`)).toBe(false);
|
||||
expect(fs._written.has(nodePath.resolve(`${TARGET}/../outside/SKILL.md`))).toBe(false);
|
||||
});
|
||||
|
||||
// R8: non-"skills/" prefixed declared path is honored as-is (no leading strip)
|
||||
it("declared path without leading skills/ prefix routes verbatim", () => {
|
||||
const fs = mockFs({
|
||||
[`${BUNDLE_ROOT}/custom/path/X.md`]: "x",
|
||||
});
|
||||
const result = routeSkills(
|
||||
makeInput({ declaredSkills: ["custom/path/X.md"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.routedCount).toBe(1);
|
||||
expect(result.records[0]!.installedAt).toBe(`${TARGET}/custom/path/X.md`);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
serializeLegacyBundleManifest as serializeBundleManifest,
|
||||
isRelativeSafePath,
|
||||
type LegacyBundleManifest as BundleManifest,
|
||||
type BundleProvenance,
|
||||
type BundleCompatibility,
|
||||
type BundlePluginReference,
|
||||
} from "../src/domain/bundle-types.js";
|
||||
|
||||
const VALID_RAW = {
|
||||
@@ -135,6 +138,632 @@ describe("Bundle types", () => {
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("safe relative path"))).toBe(true);
|
||||
});
|
||||
|
||||
// T12: missing provenance is valid (backward compat — pre-Item-1 bundles install)
|
||||
it("missing provenance block passes validation (backward compat)", () => {
|
||||
const raw = { ...VALID_RAW };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
// T13: full provenance block passes validation
|
||||
it("full provenance block passes validation", () => {
|
||||
const raw = {
|
||||
...VALID_RAW,
|
||||
provenance: {
|
||||
created_at: "2026-05-18T00:00:00Z",
|
||||
source_host: "test-host.local",
|
||||
author_session: "velocity-driver@openrig-velocity",
|
||||
source_rig_id: "01KQEQPN4MQJN0DHBM5CQ0N8D7",
|
||||
source_rig_name: "openrig-velocity",
|
||||
daemon_version: "0.3.2",
|
||||
cli_version: "0.3.2",
|
||||
notes: "Test bundle for Item 1",
|
||||
},
|
||||
};
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
// T14: partial provenance (only notes) passes validation — all fields optional
|
||||
it("partial provenance block (only notes) passes validation", () => {
|
||||
const raw = { ...VALID_RAW, provenance: { notes: "ad-hoc" } };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
// T15: provenance must be an object when present (not null, not array, not string)
|
||||
it("provenance present but not an object rejected", () => {
|
||||
const raw1 = { ...VALID_RAW, provenance: "not-an-object" };
|
||||
const result1 = validateBundleManifest(raw1);
|
||||
expect(result1.valid).toBe(false);
|
||||
expect(result1.errors.some((e) => e.includes("provenance"))).toBe(true);
|
||||
|
||||
const raw2 = { ...VALID_RAW, provenance: [] };
|
||||
const result2 = validateBundleManifest(raw2);
|
||||
expect(result2.valid).toBe(false);
|
||||
expect(result2.errors.some((e) => e.includes("provenance"))).toBe(true);
|
||||
});
|
||||
|
||||
// T16: malformed provenance field type rejected (numeric created_at)
|
||||
it("provenance field with wrong type rejected", () => {
|
||||
const raw = { ...VALID_RAW, provenance: { created_at: 12345, source_host: "h" } };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("provenance.created_at"))).toBe(true);
|
||||
});
|
||||
|
||||
// T17: round-trip preserves provenance through serialize → parse → normalize
|
||||
it("round-trip preserves provenance through serialize → parse → normalize", () => {
|
||||
const provenance: BundleProvenance = {
|
||||
createdAt: "2026-05-18T12:00:00Z",
|
||||
sourceHost: "rt-host",
|
||||
authorSession: "velocity-driver@openrig-velocity",
|
||||
daemonVersion: "0.3.2",
|
||||
cliVersion: "0.3.2",
|
||||
notes: "round-trip fixture",
|
||||
};
|
||||
const manifest: BundleManifest = {
|
||||
schemaVersion: 1,
|
||||
name: "rt-bundle",
|
||||
version: "1.0.0",
|
||||
createdAt: "2026-05-18T12:00:00Z",
|
||||
rigSpec: "rig.yaml",
|
||||
packages: [
|
||||
{ name: "pkg", version: "1.0.0", path: "packages/pkg", originalSource: "local:./pkg" },
|
||||
],
|
||||
integrity: {
|
||||
algorithm: "sha256",
|
||||
files: { "rig.yaml": "e".repeat(64), "packages/pkg/package.yaml": "f".repeat(64) },
|
||||
},
|
||||
provenance,
|
||||
};
|
||||
|
||||
const yaml = serializeBundleManifest(manifest);
|
||||
expect(yaml).toContain("provenance:");
|
||||
expect(yaml).toContain("source_host: rt-host");
|
||||
expect(yaml).toContain("notes: round-trip fixture");
|
||||
|
||||
const parsed = parseBundleManifest(yaml);
|
||||
const validation = validateBundleManifest(parsed);
|
||||
expect(validation.valid).toBe(true);
|
||||
|
||||
const normalized = normalizeBundleManifest(parsed);
|
||||
expect(normalized.provenance).toBeDefined();
|
||||
expect(normalized.provenance?.sourceHost).toBe("rt-host");
|
||||
expect(normalized.provenance?.authorSession).toBe("velocity-driver@openrig-velocity");
|
||||
expect(normalized.provenance?.daemonVersion).toBe("0.3.2");
|
||||
expect(normalized.provenance?.cliVersion).toBe("0.3.2");
|
||||
expect(normalized.provenance?.notes).toBe("round-trip fixture");
|
||||
});
|
||||
|
||||
// T18: missing provenance round-trips as undefined (no field in YAML)
|
||||
it("missing provenance round-trips cleanly (no field emitted in YAML)", () => {
|
||||
const manifest: BundleManifest = {
|
||||
schemaVersion: 1,
|
||||
name: "no-prov",
|
||||
version: "1.0.0",
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
rigSpec: "rig.yaml",
|
||||
packages: [{ name: "pkg", version: "1.0", path: "packages/pkg", originalSource: "local:./pkg" }],
|
||||
};
|
||||
const yaml = serializeBundleManifest(manifest);
|
||||
expect(yaml).not.toContain("provenance:");
|
||||
const parsed = parseBundleManifest(yaml);
|
||||
const normalized = normalizeBundleManifest(parsed);
|
||||
expect(normalized.provenance).toBeUndefined();
|
||||
});
|
||||
|
||||
// -- Item 2 compatibility block tests (slice-05 Checkpoint 3.1) --
|
||||
|
||||
// C1: missing compatibility is valid (backward compat)
|
||||
it("missing compatibility block passes validation (backward compat)", () => {
|
||||
const raw = { ...VALID_RAW };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
// C2: full compatibility block passes validation
|
||||
it("full compatibility block passes validation", () => {
|
||||
const raw = {
|
||||
...VALID_RAW,
|
||||
compatibility: {
|
||||
min_daemon_version: "0.3.2",
|
||||
min_cli_version: "0.3.2",
|
||||
schema_version: 1,
|
||||
},
|
||||
};
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
// C3: partial compatibility (only min_daemon_version) passes — all fields optional
|
||||
it("partial compatibility block (only min_daemon_version) passes validation", () => {
|
||||
const raw = { ...VALID_RAW, compatibility: { min_daemon_version: "0.3.2" } };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
// C4: compatibility present but not an object rejected
|
||||
it("compatibility present but not an object rejected", () => {
|
||||
const raw1 = { ...VALID_RAW, compatibility: "0.3.2" };
|
||||
const r1 = validateBundleManifest(raw1);
|
||||
expect(r1.valid).toBe(false);
|
||||
expect(r1.errors.some((e) => e.includes("compatibility"))).toBe(true);
|
||||
|
||||
const raw2 = { ...VALID_RAW, compatibility: [] };
|
||||
const r2 = validateBundleManifest(raw2);
|
||||
expect(r2.valid).toBe(false);
|
||||
expect(r2.errors.some((e) => e.includes("compatibility"))).toBe(true);
|
||||
});
|
||||
|
||||
// C5: compatibility field with wrong type rejected
|
||||
it("compatibility field with wrong type rejected", () => {
|
||||
const raw1 = { ...VALID_RAW, compatibility: { min_daemon_version: 0.3 } };
|
||||
const r1 = validateBundleManifest(raw1);
|
||||
expect(r1.valid).toBe(false);
|
||||
expect(r1.errors.some((e) => e.includes("compatibility.min_daemon_version"))).toBe(true);
|
||||
|
||||
const raw2 = { ...VALID_RAW, compatibility: { schema_version: "1" } };
|
||||
const r2 = validateBundleManifest(raw2);
|
||||
expect(r2.valid).toBe(false);
|
||||
expect(r2.errors.some((e) => e.includes("compatibility.schema_version"))).toBe(true);
|
||||
});
|
||||
|
||||
// C6: round-trip preserves compatibility through serialize → parse → normalize
|
||||
it("round-trip preserves compatibility through serialize → parse → normalize", () => {
|
||||
const compatibility: BundleCompatibility = {
|
||||
minDaemonVersion: "0.3.2",
|
||||
minCliVersion: "0.3.2",
|
||||
schemaVersion: 1,
|
||||
};
|
||||
const manifest: BundleManifest = {
|
||||
schemaVersion: 1,
|
||||
name: "rt-compat",
|
||||
version: "1.0.0",
|
||||
createdAt: "2026-05-18T12:00:00Z",
|
||||
rigSpec: "rig.yaml",
|
||||
packages: [{ name: "pkg", version: "1.0.0", path: "packages/pkg", originalSource: "local:./pkg" }],
|
||||
integrity: {
|
||||
algorithm: "sha256",
|
||||
files: { "rig.yaml": "1".repeat(64), "packages/pkg/package.yaml": "2".repeat(64) },
|
||||
},
|
||||
compatibility,
|
||||
};
|
||||
|
||||
const yaml = serializeBundleManifest(manifest);
|
||||
expect(yaml).toContain("compatibility:");
|
||||
expect(yaml).toContain("min_daemon_version: 0.3.2");
|
||||
|
||||
const parsed = parseBundleManifest(yaml);
|
||||
const validation = validateBundleManifest(parsed);
|
||||
expect(validation.valid).toBe(true);
|
||||
|
||||
const normalized = normalizeBundleManifest(parsed);
|
||||
expect(normalized.compatibility).toBeDefined();
|
||||
expect(normalized.compatibility?.minDaemonVersion).toBe("0.3.2");
|
||||
expect(normalized.compatibility?.minCliVersion).toBe("0.3.2");
|
||||
expect(normalized.compatibility?.schemaVersion).toBe(1);
|
||||
});
|
||||
|
||||
// C7: missing compatibility round-trips cleanly (no field emitted in YAML)
|
||||
it("missing compatibility round-trips cleanly (no field emitted in YAML)", () => {
|
||||
const manifest: BundleManifest = {
|
||||
schemaVersion: 1,
|
||||
name: "no-compat",
|
||||
version: "1.0.0",
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
rigSpec: "rig.yaml",
|
||||
packages: [{ name: "pkg", version: "1.0", path: "packages/pkg", originalSource: "local:./pkg" }],
|
||||
};
|
||||
const yaml = serializeBundleManifest(manifest);
|
||||
expect(yaml).not.toContain("compatibility:");
|
||||
const parsed = parseBundleManifest(yaml);
|
||||
const normalized = normalizeBundleManifest(parsed);
|
||||
expect(normalized.compatibility).toBeUndefined();
|
||||
});
|
||||
|
||||
// -- Item 6 skills block tests (slice-05 Checkpoint 7.1) --
|
||||
|
||||
it("missing skills block passes validation (backward compat)", () => {
|
||||
const raw = { ...VALID_RAW };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("skills as a string array of safe relative paths passes validation", () => {
|
||||
const raw = { ...VALID_RAW, skills: ["skills/review-kit/SKILL.md", "skills/test-runner/SKILL.md"] };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("skills as a non-array rejected", () => {
|
||||
const raw = { ...VALID_RAW, skills: "skills/a.md" };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("skills must be an array"))).toBe(true);
|
||||
});
|
||||
|
||||
it("non-string skill entry rejected", () => {
|
||||
const raw = { ...VALID_RAW, skills: [123, "skills/ok.md"] };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("skills[0]"))).toBe(true);
|
||||
});
|
||||
|
||||
it("unsafe skill path (dot-dot traversal) rejected", () => {
|
||||
const raw = { ...VALID_RAW, skills: ["../escape/skill.md"] };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("skills[0]"))).toBe(true);
|
||||
expect(result.errors.some((e) => e.includes("not safe"))).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trip preserves skills through serialize → parse → normalize", () => {
|
||||
const manifest: BundleManifest = {
|
||||
schemaVersion: 1,
|
||||
name: "rt-skills",
|
||||
version: "1.0.0",
|
||||
createdAt: "2026-05-18T12:00:00Z",
|
||||
rigSpec: "rig.yaml",
|
||||
packages: [{ name: "pkg", version: "1.0.0", path: "packages/pkg", originalSource: "local:./pkg" }],
|
||||
integrity: {
|
||||
algorithm: "sha256",
|
||||
files: { "rig.yaml": "9".repeat(64), "packages/pkg/package.yaml": "a".repeat(64) },
|
||||
},
|
||||
skills: ["skills/foo/SKILL.md", "skills/bar/SKILL.md"],
|
||||
};
|
||||
const yaml = serializeBundleManifest(manifest);
|
||||
expect(yaml).toContain("skills:");
|
||||
expect(yaml).toContain("skills/foo/SKILL.md");
|
||||
const parsed = parseBundleManifest(yaml);
|
||||
const validation = validateBundleManifest(parsed);
|
||||
expect(validation.valid).toBe(true);
|
||||
const normalized = normalizeBundleManifest(parsed);
|
||||
expect(normalized.skills).toBeDefined();
|
||||
expect(normalized.skills).toEqual(["skills/foo/SKILL.md", "skills/bar/SKILL.md"]);
|
||||
});
|
||||
|
||||
it("missing skills round-trips cleanly (no field emitted in YAML)", () => {
|
||||
const manifest: BundleManifest = {
|
||||
schemaVersion: 1,
|
||||
name: "no-skills",
|
||||
version: "1.0.0",
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
rigSpec: "rig.yaml",
|
||||
packages: [{ name: "pkg", version: "1.0", path: "packages/pkg", originalSource: "local:./pkg" }],
|
||||
};
|
||||
const yaml = serializeBundleManifest(manifest);
|
||||
expect(yaml).not.toContain("skills:");
|
||||
const parsed = parseBundleManifest(yaml);
|
||||
const normalized = normalizeBundleManifest(parsed);
|
||||
expect(normalized.skills).toBeUndefined();
|
||||
});
|
||||
|
||||
// -- Item 6 plugins block tests (slice-05 Checkpoint 7.3b) --
|
||||
|
||||
it("missing plugins block passes validation (backward compat)", () => {
|
||||
const raw = { ...VALID_RAW };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("plugins as array of valid {id, source} entries passes validation", () => {
|
||||
const raw = {
|
||||
...VALID_RAW,
|
||||
plugins: [
|
||||
{ id: "gstack", source: { kind: "local", path: "plugins/gstack" } },
|
||||
{ id: "obra-superpowers", source: { kind: "local", path: "plugins/obra-superpowers" } },
|
||||
],
|
||||
};
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("plugins as non-array rejected", () => {
|
||||
const raw = { ...VALID_RAW, plugins: "gstack" };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("plugins must be an array"))).toBe(true);
|
||||
});
|
||||
|
||||
it("plugin entry missing id is rejected", () => {
|
||||
const raw = { ...VALID_RAW, plugins: [{ source: { kind: "local", path: "plugins/gstack" } }] };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("plugins[0].id"))).toBe(true);
|
||||
});
|
||||
|
||||
it("plugin entry with non-local source.kind rejected", () => {
|
||||
const raw = { ...VALID_RAW, plugins: [{ id: "x", source: { kind: "remote", path: "plugins/x" } }] };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("source.kind must be 'local'"))).toBe(true);
|
||||
});
|
||||
|
||||
it("plugin entry with unsafe source.path rejected", () => {
|
||||
const raw = { ...VALID_RAW, plugins: [{ id: "x", source: { kind: "local", path: "../escape" } }] };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("plugins[0].source.path"))).toBe(true);
|
||||
expect(result.errors.some((e) => e.includes("not safe"))).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trip preserves plugins through serialize → parse → normalize", () => {
|
||||
const plugins: BundlePluginReference[] = [
|
||||
{ id: "gstack", source: { kind: "local", path: "plugins/gstack" } },
|
||||
];
|
||||
const manifest: BundleManifest = {
|
||||
schemaVersion: 1,
|
||||
name: "rt-plugins",
|
||||
version: "1.0.0",
|
||||
createdAt: "2026-05-18T12:00:00Z",
|
||||
rigSpec: "rig.yaml",
|
||||
packages: [{ name: "pkg", version: "1.0.0", path: "packages/pkg", originalSource: "local:./pkg" }],
|
||||
integrity: {
|
||||
algorithm: "sha256",
|
||||
files: { "rig.yaml": "5".repeat(64), "packages/pkg/package.yaml": "6".repeat(64) },
|
||||
},
|
||||
plugins,
|
||||
};
|
||||
const yaml = serializeBundleManifest(manifest);
|
||||
expect(yaml).toContain("plugins:");
|
||||
expect(yaml).toContain("id: gstack");
|
||||
expect(yaml).toContain("plugins/gstack");
|
||||
const parsed = parseBundleManifest(yaml);
|
||||
const validation = validateBundleManifest(parsed);
|
||||
expect(validation.valid).toBe(true);
|
||||
const normalized = normalizeBundleManifest(parsed);
|
||||
expect(normalized.plugins).toBeDefined();
|
||||
expect(normalized.plugins).toEqual(plugins);
|
||||
});
|
||||
|
||||
it("missing plugins round-trips cleanly (no field emitted in YAML)", () => {
|
||||
const manifest: BundleManifest = {
|
||||
schemaVersion: 1,
|
||||
name: "no-plugins",
|
||||
version: "1.0.0",
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
rigSpec: "rig.yaml",
|
||||
packages: [{ name: "pkg", version: "1.0", path: "packages/pkg", originalSource: "local:./pkg" }],
|
||||
};
|
||||
const yaml = serializeBundleManifest(manifest);
|
||||
expect(yaml).not.toContain("plugins:");
|
||||
const parsed = parseBundleManifest(yaml);
|
||||
const normalized = normalizeBundleManifest(parsed);
|
||||
expect(normalized.plugins).toBeUndefined();
|
||||
});
|
||||
|
||||
// -- Item 6 workflow_specs block tests (slice-05 Checkpoint 7.3e) --
|
||||
|
||||
it("missing workflow_specs block passes validation (backward compat)", () => {
|
||||
const raw = { ...VALID_RAW };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("workflow_specs as a string array of safe relative paths passes validation", () => {
|
||||
const raw = { ...VALID_RAW, workflow_specs: ["workflows/onboarding.yaml", "workflows/release.yaml"] };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("workflow_specs as a non-array rejected", () => {
|
||||
const raw = { ...VALID_RAW, workflow_specs: "workflows/a.yaml" };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("workflow_specs must be an array"))).toBe(true);
|
||||
});
|
||||
|
||||
it("non-string workflow_specs entry rejected", () => {
|
||||
const raw = { ...VALID_RAW, workflow_specs: [123, "workflows/ok.yaml"] };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("workflow_specs[0]"))).toBe(true);
|
||||
});
|
||||
|
||||
it("unsafe workflow_specs path (dot-dot traversal) rejected", () => {
|
||||
const raw = { ...VALID_RAW, workflow_specs: ["../escape/spec.yaml"] };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("workflow_specs[0]"))).toBe(true);
|
||||
expect(result.errors.some((e) => e.includes("not safe"))).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trip preserves workflow_specs through serialize → parse → normalize", () => {
|
||||
const manifest: BundleManifest = {
|
||||
schemaVersion: 1,
|
||||
name: "rt-workflow-specs",
|
||||
version: "1.0.0",
|
||||
createdAt: "2026-05-18T12:00:00Z",
|
||||
rigSpec: "rig.yaml",
|
||||
packages: [{ name: "pkg", version: "1.0.0", path: "packages/pkg", originalSource: "local:./pkg" }],
|
||||
integrity: {
|
||||
algorithm: "sha256",
|
||||
files: { "rig.yaml": "7".repeat(64), "packages/pkg/package.yaml": "8".repeat(64) },
|
||||
},
|
||||
workflowSpecs: ["workflows/onboarding.yaml", "workflows/release.yaml"],
|
||||
};
|
||||
const yaml = serializeBundleManifest(manifest);
|
||||
expect(yaml).toContain("workflow_specs:");
|
||||
expect(yaml).toContain("workflows/onboarding.yaml");
|
||||
const parsed = parseBundleManifest(yaml);
|
||||
const validation = validateBundleManifest(parsed);
|
||||
expect(validation.valid).toBe(true);
|
||||
const normalized = normalizeBundleManifest(parsed);
|
||||
expect(normalized.workflowSpecs).toBeDefined();
|
||||
expect(normalized.workflowSpecs).toEqual(["workflows/onboarding.yaml", "workflows/release.yaml"]);
|
||||
});
|
||||
|
||||
it("missing workflow_specs round-trips cleanly (no field emitted in YAML)", () => {
|
||||
const manifest: BundleManifest = {
|
||||
schemaVersion: 1,
|
||||
name: "no-workflow-specs",
|
||||
version: "1.0.0",
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
rigSpec: "rig.yaml",
|
||||
packages: [{ name: "pkg", version: "1.0", path: "packages/pkg", originalSource: "local:./pkg" }],
|
||||
};
|
||||
const yaml = serializeBundleManifest(manifest);
|
||||
expect(yaml).not.toContain("workflow_specs:");
|
||||
const parsed = parseBundleManifest(yaml);
|
||||
const normalized = normalizeBundleManifest(parsed);
|
||||
expect(normalized.workflowSpecs).toBeUndefined();
|
||||
});
|
||||
|
||||
// -- Item 6 context_packs block tests (slice-05 Checkpoint 7.3f) --
|
||||
|
||||
it("missing context_packs block passes validation (backward compat)", () => {
|
||||
const raw = { ...VALID_RAW };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("context_packs as a string array of safe relative paths passes validation", () => {
|
||||
const raw = { ...VALID_RAW, context_packs: ["context-packs/intent/manifest.yaml", "context-packs/persona/manifest.yaml"] };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("context_packs as a non-array rejected", () => {
|
||||
const raw = { ...VALID_RAW, context_packs: "context-packs/intent/manifest.yaml" };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("context_packs must be an array"))).toBe(true);
|
||||
});
|
||||
|
||||
it("non-string context_packs entry rejected", () => {
|
||||
const raw = { ...VALID_RAW, context_packs: [42, "context-packs/ok/manifest.yaml"] };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("context_packs[0]") && e.includes("must be a string"))).toBe(true);
|
||||
});
|
||||
|
||||
it("unsafe context_packs path (dot-dot traversal) rejected", () => {
|
||||
const raw = { ...VALID_RAW, context_packs: ["../escape/manifest.yaml"] };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("context_packs[0]"))).toBe(true);
|
||||
expect(result.errors.some((e) => e.includes("not safe"))).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trip preserves context_packs through serialize → parse → normalize", () => {
|
||||
const manifest: BundleManifest = {
|
||||
schemaVersion: 1,
|
||||
name: "rt-context-packs",
|
||||
version: "1.0.0",
|
||||
createdAt: "2026-05-18T12:00:00Z",
|
||||
rigSpec: "rig.yaml",
|
||||
packages: [{ name: "pkg", version: "1.0.0", path: "packages/pkg", originalSource: "local:./pkg" }],
|
||||
integrity: {
|
||||
algorithm: "sha256",
|
||||
files: { "rig.yaml": "c".repeat(64), "packages/pkg/package.yaml": "d".repeat(64) },
|
||||
},
|
||||
contextPacks: ["context-packs/intent/manifest.yaml", "context-packs/persona/manifest.yaml"],
|
||||
};
|
||||
const yaml = serializeBundleManifest(manifest);
|
||||
expect(yaml).toContain("context_packs:");
|
||||
expect(yaml).toContain("context-packs/intent/manifest.yaml");
|
||||
const parsed = parseBundleManifest(yaml);
|
||||
const validation = validateBundleManifest(parsed);
|
||||
expect(validation.valid).toBe(true);
|
||||
const normalized = normalizeBundleManifest(parsed);
|
||||
expect(normalized.contextPacks).toBeDefined();
|
||||
expect(normalized.contextPacks).toEqual(["context-packs/intent/manifest.yaml", "context-packs/persona/manifest.yaml"]);
|
||||
});
|
||||
|
||||
it("missing context_packs round-trips cleanly (no field emitted in YAML)", () => {
|
||||
const manifest: BundleManifest = {
|
||||
schemaVersion: 1,
|
||||
name: "no-context-packs",
|
||||
version: "1.0.0",
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
rigSpec: "rig.yaml",
|
||||
packages: [{ name: "pkg", version: "1.0", path: "packages/pkg", originalSource: "local:./pkg" }],
|
||||
};
|
||||
const yaml = serializeBundleManifest(manifest);
|
||||
expect(yaml).not.toContain("context_packs:");
|
||||
const parsed = parseBundleManifest(yaml);
|
||||
const normalized = normalizeBundleManifest(parsed);
|
||||
expect(normalized.contextPacks).toBeUndefined();
|
||||
});
|
||||
|
||||
// -- Item 6 agent_images block tests (slice-05 Checkpoint 7.3g) --
|
||||
|
||||
it("missing agent_images block passes validation (backward compat)", () => {
|
||||
const raw = { ...VALID_RAW };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("agent_images as a string array of safe relative paths passes validation", () => {
|
||||
const raw = { ...VALID_RAW, agent_images: ["agent-images/seat-a", "agent-images/seat-b"] };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("agent_images as a non-array rejected", () => {
|
||||
const raw = { ...VALID_RAW, agent_images: "agent-images/x" };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("agent_images must be an array"))).toBe(true);
|
||||
});
|
||||
|
||||
it("non-string agent_images entry rejected", () => {
|
||||
const raw = { ...VALID_RAW, agent_images: [99, "agent-images/ok"] };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("agent_images[0]") && e.includes("must be a string"))).toBe(true);
|
||||
});
|
||||
|
||||
it("unsafe agent_images path (dot-dot traversal) rejected", () => {
|
||||
const raw = { ...VALID_RAW, agent_images: ["../escape"] };
|
||||
const result = validateBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("agent_images[0]"))).toBe(true);
|
||||
expect(result.errors.some((e) => e.includes("not safe"))).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trip preserves agent_images through serialize → parse → normalize", () => {
|
||||
const manifest: BundleManifest = {
|
||||
schemaVersion: 1,
|
||||
name: "rt-agent-images",
|
||||
version: "1.0.0",
|
||||
createdAt: "2026-05-18T12:00:00Z",
|
||||
rigSpec: "rig.yaml",
|
||||
packages: [{ name: "pkg", version: "1.0.0", path: "packages/pkg", originalSource: "local:./pkg" }],
|
||||
integrity: {
|
||||
algorithm: "sha256",
|
||||
files: { "rig.yaml": "e".repeat(64), "packages/pkg/package.yaml": "f".repeat(64) },
|
||||
},
|
||||
agentImages: ["agent-images/seat-a", "agent-images/seat-b"],
|
||||
};
|
||||
const yaml = serializeBundleManifest(manifest);
|
||||
expect(yaml).toContain("agent_images:");
|
||||
expect(yaml).toContain("agent-images/seat-a");
|
||||
const parsed = parseBundleManifest(yaml);
|
||||
const validation = validateBundleManifest(parsed);
|
||||
expect(validation.valid).toBe(true);
|
||||
const normalized = normalizeBundleManifest(parsed);
|
||||
expect(normalized.agentImages).toBeDefined();
|
||||
expect(normalized.agentImages).toEqual(["agent-images/seat-a", "agent-images/seat-b"]);
|
||||
});
|
||||
|
||||
it("missing agent_images round-trips cleanly (no field emitted in YAML)", () => {
|
||||
const manifest: BundleManifest = {
|
||||
schemaVersion: 1,
|
||||
name: "no-agent-images",
|
||||
version: "1.0.0",
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
rigSpec: "rig.yaml",
|
||||
packages: [{ name: "pkg", version: "1.0", path: "packages/pkg", originalSource: "local:./pkg" }],
|
||||
};
|
||||
const yaml = serializeBundleManifest(manifest);
|
||||
expect(yaml).not.toContain("agent_images:");
|
||||
const parsed = parseBundleManifest(yaml);
|
||||
const normalized = normalizeBundleManifest(parsed);
|
||||
expect(normalized.agentImages).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRelativeSafePath", () => {
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import nodePath from "node:path";
|
||||
import { routeWorkflowSpecs, type WorkflowSpecsRouterFsOps, type RouteWorkflowSpecsInput } from "../src/domain/bundle-workflow-specs-router.js";
|
||||
|
||||
// Item 6 / slice-05 Checkpoint 7.3e step 2: bundle-workflow-specs-router
|
||||
// pure-function tests. Mirrors the bundle-skills-router test pattern with
|
||||
// "workflows/" prefix and YAML file content shapes.
|
||||
|
||||
function mockFs(initialFiles: Record<string, string> = {}): WorkflowSpecsRouterFsOps & { _written: Map<string, string>; _mkdirpCalls: string[] } {
|
||||
const written = new Map<string, string>(Object.entries(initialFiles));
|
||||
const mkdirpCalls: string[] = [];
|
||||
return {
|
||||
_written: written,
|
||||
_mkdirpCalls: mkdirpCalls,
|
||||
exists: (p: string) => written.has(p),
|
||||
readFile: (p: string) => {
|
||||
const v = written.get(p);
|
||||
if (v === undefined) throw new Error(`File not found in mock: ${p}`);
|
||||
return v;
|
||||
},
|
||||
writeFile: (p: string, c: string) => { written.set(p, c); },
|
||||
mkdirp: (p: string) => { mkdirpCalls.push(p); },
|
||||
};
|
||||
}
|
||||
|
||||
const BUNDLE_ROOT = "/bundle/root";
|
||||
// TARGET is an arbitrary fixture path — the router is parametric on
|
||||
// targetWorkflowSpecsDir and the unit tests do not depend on any specific
|
||||
// operator-host layout. The CALLER CONTRACT documented in
|
||||
// bundle-workflow-specs-router.ts RouteWorkflowSpecsInput.targetWorkflowSpecsDir
|
||||
// requires step 3 integration to resolve this via
|
||||
// `nodePath.join(ContextPackSettingsStore.resolveConfig().workspaceSpecsRoot,
|
||||
// "workflows")` — SettingsStore is the sole authority. Hardcoding the
|
||||
// scanner-default path here would be a drift hazard if SettingsStore
|
||||
// changes its default; the integration-level dogfood proves the wiring.
|
||||
const TARGET = "/test/workflow-specs-target";
|
||||
|
||||
function makeInput(overrides?: Partial<RouteWorkflowSpecsInput>): RouteWorkflowSpecsInput {
|
||||
return {
|
||||
bundleRoot: BUNDLE_ROOT,
|
||||
declaredWorkflowSpecs: [],
|
||||
targetWorkflowSpecsDir: TARGET,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("routeWorkflowSpecs", () => {
|
||||
// W1: empty list → empty records + target dir mkdirp'd
|
||||
it("empty declaredWorkflowSpecs produces empty records but still mkdirp's target", () => {
|
||||
const fs = mockFs();
|
||||
const result = routeWorkflowSpecs(makeInput(), fs);
|
||||
expect(result.records).toEqual([]);
|
||||
expect(result.routedCount).toBe(0);
|
||||
expect(result.rejectedCount).toBe(0);
|
||||
expect(fs._mkdirpCalls).toContain(TARGET);
|
||||
});
|
||||
|
||||
// W2: routes one spec end-to-end at top-level basename
|
||||
it("routes one workflow_spec: source YAML copied to target/<basename> with installedAt populated", () => {
|
||||
const fs = mockFs({
|
||||
[`${BUNDLE_ROOT}/workflows/onboarding.yaml`]: "name: onboarding\nversion: 1.0",
|
||||
});
|
||||
const result = routeWorkflowSpecs(makeInput({ declaredWorkflowSpecs: ["workflows/onboarding.yaml"] }), fs);
|
||||
expect(result.routedCount).toBe(1);
|
||||
expect(result.rejectedCount).toBe(0);
|
||||
expect(result.records[0]!.status).toBe("routed");
|
||||
// Basename-only: declared "workflows/onboarding.yaml" → target/onboarding.yaml
|
||||
// (scanner-reachability contract: spec-library-workflow-scanner reads only
|
||||
// top-level YAML; nested paths would be invisible).
|
||||
expect(result.records[0]!.installedAt).toBe(`${TARGET}/onboarding.yaml`);
|
||||
expect(fs._written.get(`${TARGET}/onboarding.yaml`)).toBe("name: onboarding\nversion: 1.0");
|
||||
});
|
||||
|
||||
// W3: routes multiple specs ALL FLAT at top level (basename collapses layout)
|
||||
it("routes multiple workflow_specs all flat at top level (basename collapses directory layout)", () => {
|
||||
const fs = mockFs({
|
||||
[`${BUNDLE_ROOT}/workflows/onboarding.yaml`]: "yaml-1",
|
||||
[`${BUNDLE_ROOT}/workflows/release.yaml`]: "yaml-2",
|
||||
[`${BUNDLE_ROOT}/workflows/sub/maintenance.yaml`]: "yaml-3",
|
||||
});
|
||||
const result = routeWorkflowSpecs(
|
||||
makeInput({
|
||||
declaredWorkflowSpecs: ["workflows/onboarding.yaml", "workflows/release.yaml", "workflows/sub/maintenance.yaml"],
|
||||
}),
|
||||
fs,
|
||||
);
|
||||
expect(result.routedCount).toBe(3);
|
||||
// All land at top-level basename — the "sub/" prefix on entry 3 is stripped
|
||||
// by basename(). This matches scanner-reachability (readdirSync + isFile only).
|
||||
expect(fs._written.get(`${TARGET}/onboarding.yaml`)).toBe("yaml-1");
|
||||
expect(fs._written.get(`${TARGET}/release.yaml`)).toBe("yaml-2");
|
||||
expect(fs._written.get(`${TARGET}/maintenance.yaml`)).toBe("yaml-3");
|
||||
// Confirm the would-be-nested path is NOT created (basename flattened it).
|
||||
expect(fs._written.has(`${TARGET}/sub/maintenance.yaml`)).toBe(false);
|
||||
});
|
||||
|
||||
// W4: missing source file → "missing" record (skipped, not error)
|
||||
it("missing source file is skipped with status=missing (honest-scoping)", () => {
|
||||
const fs = mockFs();
|
||||
const result = routeWorkflowSpecs(
|
||||
makeInput({ declaredWorkflowSpecs: ["workflows/absent.yaml"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.routedCount).toBe(0);
|
||||
expect(result.rejectedCount).toBe(1);
|
||||
expect(result.records[0]!.status).toBe("missing");
|
||||
expect(result.records[0]!.detail).toContain("not present");
|
||||
});
|
||||
|
||||
// W5: unsafe source path escaping bundle workspace rejected
|
||||
it("unsafe declared path (../traversal) escapes bundle workspace and is rejected", () => {
|
||||
const fs = mockFs();
|
||||
const result = routeWorkflowSpecs(
|
||||
makeInput({ declaredWorkflowSpecs: ["../escape/spec.yaml"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.routedCount).toBe(0);
|
||||
expect(result.rejectedCount).toBe(1);
|
||||
expect(result.records[0]!.status).toBe("unsafe");
|
||||
expect(result.records[0]!.detail).toContain("escapes bundle workspace");
|
||||
});
|
||||
|
||||
// W6: mixed list — routed + missing + unsafe in one call
|
||||
it("mixed declared list aggregates correctly across routed/missing/unsafe", () => {
|
||||
const fs = mockFs({
|
||||
[`${BUNDLE_ROOT}/workflows/ok.yaml`]: "ok",
|
||||
});
|
||||
const result = routeWorkflowSpecs(
|
||||
makeInput({
|
||||
declaredWorkflowSpecs: ["workflows/ok.yaml", "workflows/absent.yaml", "../escape/spec.yaml"],
|
||||
}),
|
||||
fs,
|
||||
);
|
||||
expect(result.records).toHaveLength(3);
|
||||
expect(result.routedCount).toBe(1);
|
||||
expect(result.rejectedCount).toBe(2);
|
||||
expect(result.records[0]!.status).toBe("routed");
|
||||
expect(result.records[1]!.status).toBe("missing");
|
||||
expect(result.records[2]!.status).toBe("unsafe");
|
||||
});
|
||||
|
||||
// W7: target-side escape attempt lands SAFELY at basename (structural
|
||||
// containment via basename — replaces the prior prefix-strip target-escape
|
||||
// hazard from the skills router; basename guarantees no traversal).
|
||||
it("target-escape attempt via traversal still lands safely at basename (structural containment)", () => {
|
||||
const fs = mockFs({
|
||||
// Source is reachable from bundleRoot via "workflows/../outside/spec.yaml"
|
||||
// which resolves to "<bundleRoot>/outside/spec.yaml" — passes source check.
|
||||
[`${BUNDLE_ROOT}/outside/spec.yaml`]: "would-have-escaped-target",
|
||||
});
|
||||
const result = routeWorkflowSpecs(
|
||||
makeInput({ declaredWorkflowSpecs: ["workflows/../outside/spec.yaml"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.routedCount).toBe(1);
|
||||
expect(result.rejectedCount).toBe(0);
|
||||
expect(result.records[0]!.status).toBe("routed");
|
||||
// basename("workflows/../outside/spec.yaml") = "spec.yaml" → lands safely.
|
||||
expect(result.records[0]!.installedAt).toBe(`${TARGET}/spec.yaml`);
|
||||
expect(fs._written.get(`${TARGET}/spec.yaml`)).toBe("would-have-escaped-target");
|
||||
// Confirm: no write outside target dir.
|
||||
expect(fs._written.has(`${TARGET}/../outside/spec.yaml`)).toBe(false);
|
||||
expect(fs._written.has(nodePath.resolve(`${TARGET}/../outside/spec.yaml`))).toBe(false);
|
||||
});
|
||||
|
||||
// W8: non-prefixed declared path lands at its basename (prefix-strip is
|
||||
// obsoleted by basename — the rule is uniform).
|
||||
it("declared path without any leading prefix routes to target/<basename>", () => {
|
||||
const fs = mockFs({
|
||||
[`${BUNDLE_ROOT}/custom/path/spec.yaml`]: "custom",
|
||||
});
|
||||
const result = routeWorkflowSpecs(
|
||||
makeInput({ declaredWorkflowSpecs: ["custom/path/spec.yaml"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.routedCount).toBe(1);
|
||||
// basename("custom/path/spec.yaml") = "spec.yaml" → top-level under target.
|
||||
expect(result.records[0]!.installedAt).toBe(`${TARGET}/spec.yaml`);
|
||||
expect(fs._written.has(`${TARGET}/custom/path/spec.yaml`)).toBe(false);
|
||||
});
|
||||
|
||||
// W9: duplicate basenames — first wins (status=routed), later flagged
|
||||
// status=conflict so routedCount stays truthful at the scanner-visible
|
||||
// boundary (banked B1 guard catch d81456dc → this commit).
|
||||
it("duplicate basenames: first routes, later flagged status=conflict (truthful routedCount)", () => {
|
||||
const fs = mockFs({
|
||||
[`${BUNDLE_ROOT}/a/onboarding.yaml`]: "a-content",
|
||||
[`${BUNDLE_ROOT}/b/onboarding.yaml`]: "b-content",
|
||||
});
|
||||
const result = routeWorkflowSpecs(
|
||||
makeInput({ declaredWorkflowSpecs: ["a/onboarding.yaml", "b/onboarding.yaml"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.records).toHaveLength(2);
|
||||
expect(result.routedCount).toBe(1);
|
||||
expect(result.rejectedCount).toBe(1);
|
||||
expect(result.records[0]!.status).toBe("routed");
|
||||
expect(result.records[0]!.installedAt).toBe(`${TARGET}/onboarding.yaml`);
|
||||
expect(result.records[1]!.status).toBe("conflict");
|
||||
expect(result.records[1]!.detail).toContain("basename");
|
||||
expect(result.records[1]!.detail).toContain("collides");
|
||||
// Crucially: first content survives (no silent overwrite).
|
||||
expect(fs._written.get(`${TARGET}/onboarding.yaml`)).toBe("a-content");
|
||||
});
|
||||
|
||||
// W10: non-YAML suffix — scanner is YAML-only, route would be invisible.
|
||||
// Reject pre-write so routedCount stays truthful (banked B2 guard catch
|
||||
// d81456dc → this commit).
|
||||
it("non-YAML suffix declared path rejected status=unsafe (scanner-invisible class)", () => {
|
||||
const fs = mockFs({
|
||||
[`${BUNDLE_ROOT}/workflows/readme.txt`]: "not a workflow spec",
|
||||
[`${BUNDLE_ROOT}/workflows/good.yaml`]: "yaml-good",
|
||||
});
|
||||
const result = routeWorkflowSpecs(
|
||||
makeInput({ declaredWorkflowSpecs: ["workflows/readme.txt", "workflows/good.yaml"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.records).toHaveLength(2);
|
||||
expect(result.routedCount).toBe(1);
|
||||
expect(result.records[0]!.status).toBe("unsafe");
|
||||
expect(result.records[0]!.detail).toContain("not a .yaml/.yml");
|
||||
expect(result.records[1]!.status).toBe("routed");
|
||||
expect(result.records[1]!.installedAt).toBe(`${TARGET}/good.yaml`);
|
||||
// .txt file never written even though source exists in bundle
|
||||
expect(fs._written.has(`${TARGET}/readme.txt`)).toBe(false);
|
||||
// Also covers .yml as accepted suffix
|
||||
const fs2 = mockFs({ [`${BUNDLE_ROOT}/workflows/short.yml`]: "yml-too" });
|
||||
const r2 = routeWorkflowSpecs(makeInput({ declaredWorkflowSpecs: ["workflows/short.yml"] }), fs2);
|
||||
expect(r2.routedCount).toBe(1);
|
||||
expect(r2.records[0]!.status).toBe("routed");
|
||||
});
|
||||
});
|
||||
@@ -505,6 +505,129 @@ describe("PodBundleAssembler", () => {
|
||||
|
||||
// Deferred: full golden-path integration (assemble -> validate -> preflight -> instantiate)
|
||||
// will be verified at Checkpoint 2 when AS-T11 + AS-T08b land
|
||||
|
||||
// Item 1 — provenance capture (slice-05 Checkpoint 2 part 2)
|
||||
it("v2: captures provenance from opts into manifest", () => {
|
||||
const spec = makeRigSpec();
|
||||
const files: Record<string, string> = {
|
||||
[`${RIG_ROOT}/rig.yaml`]: rigSpecYaml(spec),
|
||||
[`${RIG_ROOT}/agents/impl/agent.yaml`]: validAgentYaml("impl"),
|
||||
};
|
||||
const fs = mockFs(files);
|
||||
const assembler = new PodBundleAssembler({ fsOps: fs });
|
||||
|
||||
const result = assembler.assemble({
|
||||
rigRoot: RIG_ROOT,
|
||||
rigSpecPath: `${RIG_ROOT}/rig.yaml`,
|
||||
outputDir: "/tmp/bundle-staging-prov",
|
||||
bundleName: "test-bundle",
|
||||
bundleVersion: "1.0.0",
|
||||
provenance: {
|
||||
sourceHost: "test-host",
|
||||
authorSession: "velocity-driver@openrig-velocity",
|
||||
daemonVersion: "0.3.2",
|
||||
cliVersion: "0.3.2",
|
||||
notes: "v2 capture fixture",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.manifest.provenance).toBeDefined();
|
||||
expect(result.manifest.provenance?.sourceHost).toBe("test-host");
|
||||
expect(result.manifest.provenance?.authorSession).toBe("velocity-driver@openrig-velocity");
|
||||
expect(result.manifest.provenance?.daemonVersion).toBe("0.3.2");
|
||||
expect(result.manifest.provenance?.notes).toBe("v2 capture fixture");
|
||||
// createdAt mirrors root
|
||||
expect(result.manifest.provenance?.createdAt).toBe(result.manifest.createdAt);
|
||||
});
|
||||
|
||||
it("v2: respects opts.provenance.createdAt when pre-set (test determinism)", () => {
|
||||
const spec = makeRigSpec();
|
||||
const files: Record<string, string> = {
|
||||
[`${RIG_ROOT}/rig.yaml`]: rigSpecYaml(spec),
|
||||
[`${RIG_ROOT}/agents/impl/agent.yaml`]: validAgentYaml("impl"),
|
||||
};
|
||||
const fs = mockFs(files);
|
||||
const assembler = new PodBundleAssembler({ fsOps: fs });
|
||||
|
||||
const fixedCreatedAt = "2026-01-01T00:00:00Z";
|
||||
const result = assembler.assemble({
|
||||
rigRoot: RIG_ROOT,
|
||||
rigSpecPath: `${RIG_ROOT}/rig.yaml`,
|
||||
outputDir: "/tmp/bundle-staging-fixed-createdat",
|
||||
bundleName: "test", bundleVersion: "1.0",
|
||||
provenance: { createdAt: fixedCreatedAt, sourceHost: "h" },
|
||||
});
|
||||
|
||||
expect(result.manifest.provenance?.createdAt).toBe(fixedCreatedAt);
|
||||
expect(result.manifest.createdAt).not.toBe(fixedCreatedAt);
|
||||
});
|
||||
|
||||
it("v2: omits provenance when opts.provenance not provided (backward compat)", () => {
|
||||
const spec = makeRigSpec();
|
||||
const files: Record<string, string> = {
|
||||
[`${RIG_ROOT}/rig.yaml`]: rigSpecYaml(spec),
|
||||
[`${RIG_ROOT}/agents/impl/agent.yaml`]: validAgentYaml("impl"),
|
||||
};
|
||||
const fs = mockFs(files);
|
||||
const assembler = new PodBundleAssembler({ fsOps: fs });
|
||||
|
||||
const result = assembler.assemble({
|
||||
rigRoot: RIG_ROOT,
|
||||
rigSpecPath: `${RIG_ROOT}/rig.yaml`,
|
||||
outputDir: "/tmp/bundle-staging-no-prov",
|
||||
bundleName: "test", bundleVersion: "1.0",
|
||||
});
|
||||
|
||||
expect(result.manifest.provenance).toBeUndefined();
|
||||
});
|
||||
|
||||
// Item 2 — compatibility capture (slice-05 Checkpoint 3.2)
|
||||
it("v2: captures compatibility from opts into manifest", () => {
|
||||
const spec = makeRigSpec();
|
||||
const files: Record<string, string> = {
|
||||
[`${RIG_ROOT}/rig.yaml`]: rigSpecYaml(spec),
|
||||
[`${RIG_ROOT}/agents/impl/agent.yaml`]: validAgentYaml("impl"),
|
||||
};
|
||||
const fs = mockFs(files);
|
||||
const assembler = new PodBundleAssembler({ fsOps: fs });
|
||||
|
||||
const result = assembler.assemble({
|
||||
rigRoot: RIG_ROOT,
|
||||
rigSpecPath: `${RIG_ROOT}/rig.yaml`,
|
||||
outputDir: "/tmp/bundle-staging-compat",
|
||||
bundleName: "test-bundle",
|
||||
bundleVersion: "1.0.0",
|
||||
compatibility: {
|
||||
minDaemonVersion: "0.3.2",
|
||||
minCliVersion: "0.3.2",
|
||||
schemaVersion: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.manifest.compatibility).toBeDefined();
|
||||
expect(result.manifest.compatibility?.minDaemonVersion).toBe("0.3.2");
|
||||
expect(result.manifest.compatibility?.minCliVersion).toBe("0.3.2");
|
||||
expect(result.manifest.compatibility?.schemaVersion).toBe(2);
|
||||
});
|
||||
|
||||
it("v2: omits compatibility when opts.compatibility not provided (backward compat)", () => {
|
||||
const spec = makeRigSpec();
|
||||
const files: Record<string, string> = {
|
||||
[`${RIG_ROOT}/rig.yaml`]: rigSpecYaml(spec),
|
||||
[`${RIG_ROOT}/agents/impl/agent.yaml`]: validAgentYaml("impl"),
|
||||
};
|
||||
const fs = mockFs(files);
|
||||
const assembler = new PodBundleAssembler({ fsOps: fs });
|
||||
|
||||
const result = assembler.assemble({
|
||||
rigRoot: RIG_ROOT,
|
||||
rigSpecPath: `${RIG_ROOT}/rig.yaml`,
|
||||
outputDir: "/tmp/bundle-staging-no-compat",
|
||||
bundleName: "test", bundleVersion: "1.0",
|
||||
});
|
||||
|
||||
expect(result.manifest.compatibility).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PodBundleManifest validation", () => {
|
||||
@@ -561,6 +684,555 @@ describe("PodBundleManifest validation", () => {
|
||||
expect(imports).toHaveLength(1);
|
||||
expect(imports[0]!["name"]).toBe("lib");
|
||||
});
|
||||
|
||||
// Item 1 — provenance block (slice-05): backward-compat + presence + round-trip
|
||||
it("v2: missing provenance block passes validation (backward compat)", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "no-prov", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{
|
||||
name: "impl", version: "1.0", path: "agents/impl",
|
||||
original_ref: "local:agents/impl", hash: "abc",
|
||||
import_entries: [],
|
||||
}],
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: full provenance block passes validation", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "with-prov", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{
|
||||
name: "impl", version: "1.0", path: "agents/impl",
|
||||
original_ref: "local:agents/impl", hash: "abc",
|
||||
import_entries: [],
|
||||
}],
|
||||
provenance: {
|
||||
created_at: "2026-05-18T00:00:00Z",
|
||||
source_host: "test-host.local",
|
||||
author_session: "velocity-driver@openrig-velocity",
|
||||
source_rig_id: "01KQEQPN4MQJN0DHBM5CQ0N8D7",
|
||||
source_rig_name: "openrig-velocity",
|
||||
daemon_version: "0.3.2",
|
||||
cli_version: "0.3.2",
|
||||
notes: "Test bundle for Item 1",
|
||||
},
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: provenance present but not an object rejected", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "bad-prov", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
provenance: "string-not-object",
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("provenance"))).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: provenance field with wrong type rejected", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "bad-prov-field", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
provenance: { source_host: 12345 },
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("provenance.source_host"))).toBe(true);
|
||||
});
|
||||
|
||||
// -- Item 2 compatibility block tests (slice-05 Checkpoint 3.1) --
|
||||
|
||||
it("v2: missing compatibility block passes validation (backward compat)", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "no-compat", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: full compatibility block passes validation", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "with-compat", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
compatibility: { min_daemon_version: "0.3.2", min_cli_version: "0.3.2", schema_version: 2 },
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: compatibility present but not an object rejected", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "bad-compat", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
compatibility: "0.3.2",
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("compatibility"))).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: compatibility field with wrong type rejected", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "bad-compat-field", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
compatibility: { min_daemon_version: 0.3 },
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("compatibility.min_daemon_version"))).toBe(true);
|
||||
});
|
||||
|
||||
// -- Item 6 skills block tests for v2 (slice-05 Checkpoint 7.1) --
|
||||
|
||||
it("v2: missing skills block passes validation (backward compat)", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "no-skills", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: skills as string array passes validation", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "with-skills", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
skills: ["skills/foo/SKILL.md", "skills/bar/SKILL.md"],
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: unsafe skill path rejected", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "bad-skills", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
skills: ["../escape/skill.md"],
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("skills[0]") && e.includes("not safe"))).toBe(true);
|
||||
});
|
||||
|
||||
// -- Item 6 plugins block tests for v2 (slice-05 Checkpoint 7.3b) --
|
||||
|
||||
it("v2: missing plugins block passes validation", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "no-plugins", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: plugins as valid {id, source} array passes validation", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "with-plugins", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
plugins: [{ id: "gstack", source: { kind: "local", path: "plugins/gstack" } }],
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: plugin entry with unsafe source.path rejected", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "bad-plugins", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
plugins: [{ id: "x", source: { kind: "local", path: "../escape" } }],
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("plugins[0].source.path") && e.includes("not safe"))).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: round-trip preserves plugins through serialize -> parse", () => {
|
||||
const manifest: PodBundleManifest = {
|
||||
schemaVersion: 2, name: "rt-plugins-v2", version: "2.0",
|
||||
createdAt: "2026-05-18T00:00:00Z", rigSpec: "rig.yaml",
|
||||
agents: [{
|
||||
name: "impl", version: "1.0", path: "agents/impl",
|
||||
originalRef: "local:agents/impl", hash: "def",
|
||||
importEntries: [],
|
||||
}],
|
||||
plugins: [{ id: "gstack", source: { kind: "local", path: "plugins/gstack" } }],
|
||||
};
|
||||
const yaml = serializePodBundleManifest(manifest);
|
||||
expect(yaml).toContain("plugins:");
|
||||
expect(yaml).toContain("id: gstack");
|
||||
const parsed = parsePodBundleManifest(yaml);
|
||||
const validation = validatePodBundleManifest(parsed);
|
||||
expect(validation.valid).toBe(true);
|
||||
const m = parsed as Record<string, unknown>;
|
||||
const ps = m["plugins"] as Array<Record<string, unknown>>;
|
||||
expect(ps).toHaveLength(1);
|
||||
expect(ps[0]!["id"]).toBe("gstack");
|
||||
expect((ps[0]!["source"] as Record<string, unknown>)["path"]).toBe("plugins/gstack");
|
||||
});
|
||||
|
||||
it("v2: round-trip preserves skills through serialize -> parse", () => {
|
||||
const manifest: PodBundleManifest = {
|
||||
schemaVersion: 2, name: "rt-skills-v2", version: "2.0",
|
||||
createdAt: "2026-05-18T00:00:00Z", rigSpec: "rig.yaml",
|
||||
agents: [{
|
||||
name: "impl", version: "1.0", path: "agents/impl",
|
||||
originalRef: "local:agents/impl", hash: "def",
|
||||
importEntries: [],
|
||||
}],
|
||||
skills: ["skills/v2-foo/SKILL.md", "skills/v2-bar/SKILL.md"],
|
||||
};
|
||||
const yaml = serializePodBundleManifest(manifest);
|
||||
expect(yaml).toContain("skills:");
|
||||
expect(yaml).toContain("skills/v2-foo/SKILL.md");
|
||||
const parsed = parsePodBundleManifest(yaml);
|
||||
const validation = validatePodBundleManifest(parsed);
|
||||
expect(validation.valid).toBe(true);
|
||||
const m = parsed as Record<string, unknown>;
|
||||
expect(m["skills"]).toEqual(["skills/v2-foo/SKILL.md", "skills/v2-bar/SKILL.md"]);
|
||||
});
|
||||
|
||||
// -- Item 6 workflow_specs block tests for v2 (slice-05 Checkpoint 7.3e) --
|
||||
|
||||
it("v2: missing workflow_specs block passes validation (backward compat)", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "no-workflow-specs", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: workflow_specs as string array passes validation", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "with-workflow-specs", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
workflow_specs: ["workflows/onboarding.yaml", "workflows/release.yaml"],
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: workflow_specs as non-array rejected", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "bad-workflow-specs", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
workflow_specs: "workflows/a.yaml",
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("workflow_specs must be an array"))).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: non-string workflow_specs entry rejected", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "bad-workflow-specs", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
workflow_specs: [123, "workflows/ok.yaml"],
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("workflow_specs[0]") && e.includes("must be a string"))).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: unsafe workflow_specs path rejected", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "bad-workflow-specs", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
workflow_specs: ["../escape/spec.yaml"],
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("workflow_specs[0]") && e.includes("not safe"))).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: round-trip preserves workflow_specs through serialize -> parse", () => {
|
||||
const manifest: PodBundleManifest = {
|
||||
schemaVersion: 2, name: "rt-workflow-specs-v2", version: "2.0",
|
||||
createdAt: "2026-05-18T00:00:00Z", rigSpec: "rig.yaml",
|
||||
agents: [{
|
||||
name: "impl", version: "1.0", path: "agents/impl",
|
||||
originalRef: "local:agents/impl", hash: "def",
|
||||
importEntries: [],
|
||||
}],
|
||||
workflowSpecs: ["workflows/v2-onboarding.yaml", "workflows/v2-release.yaml"],
|
||||
};
|
||||
const yaml = serializePodBundleManifest(manifest);
|
||||
expect(yaml).toContain("workflow_specs:");
|
||||
expect(yaml).toContain("workflows/v2-onboarding.yaml");
|
||||
const parsed = parsePodBundleManifest(yaml);
|
||||
const validation = validatePodBundleManifest(parsed);
|
||||
expect(validation.valid).toBe(true);
|
||||
const m = parsed as Record<string, unknown>;
|
||||
expect(m["workflow_specs"]).toEqual(["workflows/v2-onboarding.yaml", "workflows/v2-release.yaml"]);
|
||||
});
|
||||
|
||||
it("v2: round-trip preserves all 3 cross-primitive blocks together (skills + plugins + workflow_specs)", () => {
|
||||
const manifest: PodBundleManifest = {
|
||||
schemaVersion: 2, name: "rt-all-three", version: "2.0",
|
||||
createdAt: "2026-05-18T00:00:00Z", rigSpec: "rig.yaml",
|
||||
agents: [{
|
||||
name: "impl", version: "1.0", path: "agents/impl",
|
||||
originalRef: "local:agents/impl", hash: "def",
|
||||
importEntries: [],
|
||||
}],
|
||||
skills: ["skills/co-test/SKILL.md"],
|
||||
plugins: [{ id: "co-plugin", source: { kind: "local", path: "plugins/co-plugin" } }],
|
||||
workflowSpecs: ["workflows/co-flow.yaml"],
|
||||
};
|
||||
const yaml = serializePodBundleManifest(manifest);
|
||||
const parsed = parsePodBundleManifest(yaml);
|
||||
const validation = validatePodBundleManifest(parsed);
|
||||
expect(validation.valid).toBe(true);
|
||||
const m = parsed as Record<string, unknown>;
|
||||
expect(m["skills"]).toEqual(["skills/co-test/SKILL.md"]);
|
||||
expect(m["workflow_specs"]).toEqual(["workflows/co-flow.yaml"]);
|
||||
const ps = m["plugins"] as Array<Record<string, unknown>>;
|
||||
expect(ps[0]!["id"]).toBe("co-plugin");
|
||||
});
|
||||
|
||||
// -- Item 6 context_packs block tests for v2 (slice-05 Checkpoint 7.3f) --
|
||||
|
||||
it("v2: missing context_packs block passes validation (backward compat)", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "no-context-packs", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: context_packs as string array passes validation", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "with-context-packs", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
context_packs: ["context-packs/intent/manifest.yaml", "context-packs/persona/manifest.yaml"],
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: context_packs as non-array rejected", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "bad-context-packs", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
context_packs: "context-packs/a/manifest.yaml",
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("context_packs must be an array"))).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: non-string context_packs entry rejected", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "bad-context-packs", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
context_packs: [42, "context-packs/ok/manifest.yaml"],
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("context_packs[0]") && e.includes("must be a string"))).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: unsafe context_packs path rejected", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "bad-context-packs", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
context_packs: ["../escape/manifest.yaml"],
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("context_packs[0]") && e.includes("not safe"))).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: round-trip preserves context_packs through serialize -> parse", () => {
|
||||
const manifest: PodBundleManifest = {
|
||||
schemaVersion: 2, name: "rt-context-packs-v2", version: "2.0",
|
||||
createdAt: "2026-05-18T00:00:00Z", rigSpec: "rig.yaml",
|
||||
agents: [{
|
||||
name: "impl", version: "1.0", path: "agents/impl",
|
||||
originalRef: "local:agents/impl", hash: "def",
|
||||
importEntries: [],
|
||||
}],
|
||||
contextPacks: ["context-packs/v2-intent/manifest.yaml", "context-packs/v2-persona/manifest.yaml"],
|
||||
};
|
||||
const yaml = serializePodBundleManifest(manifest);
|
||||
expect(yaml).toContain("context_packs:");
|
||||
expect(yaml).toContain("context-packs/v2-intent/manifest.yaml");
|
||||
const parsed = parsePodBundleManifest(yaml);
|
||||
const validation = validatePodBundleManifest(parsed);
|
||||
expect(validation.valid).toBe(true);
|
||||
const m = parsed as Record<string, unknown>;
|
||||
expect(m["context_packs"]).toEqual(["context-packs/v2-intent/manifest.yaml", "context-packs/v2-persona/manifest.yaml"]);
|
||||
});
|
||||
|
||||
// -- Item 6 agent_images block tests for v2 (slice-05 Checkpoint 7.3g) --
|
||||
|
||||
it("v2: missing agent_images block passes validation (backward compat)", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "no-agent-images", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: agent_images as string array passes validation", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "with-agent-images", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
agent_images: ["agent-images/seat-a", "agent-images/seat-b"],
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: agent_images as non-array rejected", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "bad-agent-images", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
agent_images: "agent-images/a",
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("agent_images must be an array"))).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: non-string agent_images entry rejected", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "bad-agent-images", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
agent_images: [99, "agent-images/ok"],
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("agent_images[0]") && e.includes("must be a string"))).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: unsafe agent_images path rejected", () => {
|
||||
const raw = {
|
||||
schema_version: 2, name: "bad-agent-images", version: "1.0",
|
||||
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
|
||||
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
|
||||
agent_images: ["../escape"],
|
||||
};
|
||||
const result = validatePodBundleManifest(raw);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("agent_images[0]") && e.includes("not safe"))).toBe(true);
|
||||
});
|
||||
|
||||
it("v2: round-trip preserves agent_images through serialize -> parse", () => {
|
||||
const manifest: PodBundleManifest = {
|
||||
schemaVersion: 2, name: "rt-agent-images-v2", version: "2.0",
|
||||
createdAt: "2026-05-18T00:00:00Z", rigSpec: "rig.yaml",
|
||||
agents: [{
|
||||
name: "impl", version: "1.0", path: "agents/impl",
|
||||
originalRef: "local:agents/impl", hash: "def",
|
||||
importEntries: [],
|
||||
}],
|
||||
agentImages: ["agent-images/v2-seat-a", "agent-images/v2-seat-b"],
|
||||
};
|
||||
const yaml = serializePodBundleManifest(manifest);
|
||||
expect(yaml).toContain("agent_images:");
|
||||
expect(yaml).toContain("agent-images/v2-seat-a");
|
||||
const parsed = parsePodBundleManifest(yaml);
|
||||
const validation = validatePodBundleManifest(parsed);
|
||||
expect(validation.valid).toBe(true);
|
||||
const m = parsed as Record<string, unknown>;
|
||||
expect(m["agent_images"]).toEqual(["agent-images/v2-seat-a", "agent-images/v2-seat-b"]);
|
||||
});
|
||||
|
||||
it("v2: round-trip preserves compatibility through serialize -> parse", () => {
|
||||
const manifest: PodBundleManifest = {
|
||||
schemaVersion: 2, name: "rt-compat", version: "2.0",
|
||||
createdAt: "2026-05-18T00:00:00Z", rigSpec: "rig.yaml",
|
||||
agents: [{
|
||||
name: "impl", version: "1.0", path: "agents/impl",
|
||||
originalRef: "local:agents/impl", hash: "def",
|
||||
importEntries: [],
|
||||
}],
|
||||
compatibility: {
|
||||
minDaemonVersion: "0.3.2",
|
||||
minCliVersion: "0.3.2",
|
||||
schemaVersion: 2,
|
||||
},
|
||||
};
|
||||
const yaml = serializePodBundleManifest(manifest);
|
||||
expect(yaml).toContain("compatibility:");
|
||||
expect(yaml).toContain("min_daemon_version: 0.3.2");
|
||||
const parsed = parsePodBundleManifest(yaml);
|
||||
const validation = validatePodBundleManifest(parsed);
|
||||
expect(validation.valid).toBe(true);
|
||||
const m = parsed as Record<string, unknown>;
|
||||
const compat = m["compatibility"] as Record<string, unknown>;
|
||||
expect(compat["min_daemon_version"]).toBe("0.3.2");
|
||||
expect(compat["min_cli_version"]).toBe("0.3.2");
|
||||
expect(compat["schema_version"]).toBe(2);
|
||||
});
|
||||
|
||||
it("v2: round-trip preserves provenance through serialize -> parse", () => {
|
||||
const manifest: PodBundleManifest = {
|
||||
schemaVersion: 2, name: "rt-prov", version: "2.0",
|
||||
createdAt: "2026-05-18T00:00:00Z", rigSpec: "rig.yaml",
|
||||
agents: [{
|
||||
name: "impl", version: "1.0", path: "agents/impl",
|
||||
originalRef: "local:agents/impl", hash: "def",
|
||||
importEntries: [],
|
||||
}],
|
||||
provenance: {
|
||||
createdAt: "2026-05-18T00:00:00Z",
|
||||
sourceHost: "rt-host",
|
||||
authorSession: "velocity-driver@openrig-velocity",
|
||||
daemonVersion: "0.3.2",
|
||||
cliVersion: "0.3.2",
|
||||
notes: "v2 round-trip fixture",
|
||||
},
|
||||
};
|
||||
const yaml = serializePodBundleManifest(manifest);
|
||||
expect(yaml).toContain("provenance:");
|
||||
expect(yaml).toContain("source_host: rt-host");
|
||||
const parsed = parsePodBundleManifest(yaml);
|
||||
const validation = validatePodBundleManifest(parsed);
|
||||
expect(validation.valid).toBe(true);
|
||||
const m = parsed as Record<string, unknown>;
|
||||
const prov = m["provenance"] as Record<string, unknown>;
|
||||
expect(prov["source_host"]).toBe("rt-host");
|
||||
expect(prov["author_session"]).toBe("velocity-driver@openrig-velocity");
|
||||
expect(prov["daemon_version"]).toBe("0.3.2");
|
||||
expect(prov["notes"]).toBe("v2 round-trip fixture");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PodBundleSourceResolver", () => {
|
||||
|
||||
@@ -88,6 +88,75 @@ export function BundleInspector() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Item 1 / slice-05: Provenance block (rendered when bundle carries it) */}
|
||||
{result.manifest.provenance && (
|
||||
<>
|
||||
<h3 className="text-headline-md uppercase mb-spacing-3">PROVENANCE</h3>
|
||||
<div className="card-dark p-spacing-4 mb-spacing-4 space-y-spacing-1" data-testid="provenance-block">
|
||||
{result.manifest.provenance.createdAt && (
|
||||
<div className="text-label-sm font-mono" data-testid="provenance-createdAt">
|
||||
<span className="text-foreground-muted-on-dark">Created: </span>{result.manifest.provenance.createdAt}
|
||||
</div>
|
||||
)}
|
||||
{result.manifest.provenance.sourceHost && (
|
||||
<div className="text-label-sm font-mono" data-testid="provenance-sourceHost">
|
||||
<span className="text-foreground-muted-on-dark">Host: </span>{result.manifest.provenance.sourceHost}
|
||||
</div>
|
||||
)}
|
||||
{result.manifest.provenance.authorSession && (
|
||||
<div className="text-label-sm font-mono" data-testid="provenance-authorSession">
|
||||
<span className="text-foreground-muted-on-dark">Author: </span>{result.manifest.provenance.authorSession}
|
||||
</div>
|
||||
)}
|
||||
{result.manifest.provenance.sourceRigName && (
|
||||
<div className="text-label-sm font-mono" data-testid="provenance-sourceRigName">
|
||||
<span className="text-foreground-muted-on-dark">Rig: </span>{result.manifest.provenance.sourceRigName}
|
||||
{result.manifest.provenance.sourceRigId && (
|
||||
<span className="text-foreground-muted-on-dark"> ({result.manifest.provenance.sourceRigId})</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(result.manifest.provenance.daemonVersion || result.manifest.provenance.cliVersion) && (
|
||||
<div className="text-label-sm font-mono" data-testid="provenance-versions">
|
||||
<span className="text-foreground-muted-on-dark">Built with: </span>
|
||||
{result.manifest.provenance.daemonVersion && <>daemon {result.manifest.provenance.daemonVersion}</>}
|
||||
{result.manifest.provenance.daemonVersion && result.manifest.provenance.cliVersion && " / "}
|
||||
{result.manifest.provenance.cliVersion && <>cli {result.manifest.provenance.cliVersion}</>}
|
||||
</div>
|
||||
)}
|
||||
{result.manifest.provenance.notes && (
|
||||
<div className="text-label-sm font-mono whitespace-pre-wrap mt-spacing-2" data-testid="provenance-notes">
|
||||
<span className="text-foreground-muted-on-dark">Notes: </span>{result.manifest.provenance.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Item 2 / slice-05: Compatibility block (rendered when bundle carries it). Live "compatible against this daemon" comparison is deferred — UI surfaces the bundle's stated minimums; the install-time check at /api/bundles/install enforces actual compatibility with the running daemon. */}
|
||||
{result.manifest.compatibility && (
|
||||
<>
|
||||
<h3 className="text-headline-md uppercase mb-spacing-3">COMPATIBILITY</h3>
|
||||
<div className="card-dark p-spacing-4 mb-spacing-4 space-y-spacing-1" data-testid="compatibility-block">
|
||||
{result.manifest.compatibility.minDaemonVersion && (
|
||||
<div className="text-label-sm font-mono" data-testid="compatibility-minDaemonVersion">
|
||||
<span className="text-foreground-muted-on-dark">Min daemon: </span>{result.manifest.compatibility.minDaemonVersion}
|
||||
</div>
|
||||
)}
|
||||
{result.manifest.compatibility.minCliVersion && (
|
||||
<div className="text-label-sm font-mono" data-testid="compatibility-minCliVersion">
|
||||
<span className="text-foreground-muted-on-dark">Min CLI: </span>{result.manifest.compatibility.minCliVersion}
|
||||
</div>
|
||||
)}
|
||||
{result.manifest.compatibility.schemaVersion !== undefined && (
|
||||
<div className="text-label-sm font-mono" data-testid="compatibility-schemaVersion">
|
||||
<span className="text-foreground-muted-on-dark">Schema: </span>v{result.manifest.compatibility.schemaVersion}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Per-file integrity */}
|
||||
{result.integrityResult && (
|
||||
<>
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
/** Item 1 / slice-05: provenance metadata surfaced by /api/bundles/inspect (camelCase contract per both v1 + v2 routes). */
|
||||
export interface InspectProvenance {
|
||||
createdAt?: string;
|
||||
sourceHost?: string;
|
||||
authorSession?: string;
|
||||
sourceRigId?: string;
|
||||
sourceRigName?: string;
|
||||
daemonVersion?: string;
|
||||
cliVersion?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/** Item 2 / slice-05: compatibility block surfaced by /api/bundles/inspect (camelCase contract). */
|
||||
export interface InspectCompatibility {
|
||||
minDaemonVersion?: string;
|
||||
minCliVersion?: string;
|
||||
schemaVersion?: number;
|
||||
}
|
||||
|
||||
export interface InspectResult {
|
||||
manifest: {
|
||||
name: string;
|
||||
@@ -8,6 +27,10 @@ export interface InspectResult {
|
||||
schemaVersion?: number;
|
||||
packages?: Array<{ name: string; version: string; path: string }>;
|
||||
agents?: Array<{ name: string; version: string; path: string }>;
|
||||
/** Item 1 provenance block (when bundle carries it). */
|
||||
provenance?: InspectProvenance;
|
||||
/** Item 2 compatibility block (when bundle carries it). */
|
||||
compatibility?: InspectCompatibility;
|
||||
};
|
||||
digestValid: boolean;
|
||||
integrityResult: { passed: boolean; mismatches: string[]; missing: string[]; extra: string[]; errors: string[] };
|
||||
|
||||
@@ -117,6 +117,105 @@ describe("BundleInspector", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Item 5 / slice-05 Checkpoint 6.1 / guard B1 repair: provenance block renders
|
||||
// when manifest carries it. Discriminator: false && result.manifest.provenance
|
||||
// gate must fail this test.
|
||||
it("renders provenance block with all fields when manifest carries provenance", async () => {
|
||||
const responseWithProvenance = {
|
||||
manifest: {
|
||||
name: "with-prov",
|
||||
version: "0.1.0",
|
||||
rigSpec: "rig.yaml",
|
||||
packages: [{ name: "pkg", version: "1.0", path: "packages/pkg" }],
|
||||
provenance: {
|
||||
createdAt: "2026-05-18T12:00:00Z",
|
||||
sourceHost: "test-host.local",
|
||||
authorSession: "velocity-driver@openrig-velocity",
|
||||
sourceRigId: "01H000000000000000PROV001",
|
||||
sourceRigName: "openrig-velocity",
|
||||
daemonVersion: "0.3.2",
|
||||
cliVersion: "0.3.2",
|
||||
notes: "fixture for B1 repair",
|
||||
},
|
||||
},
|
||||
digestValid: true,
|
||||
integrityResult: { passed: true, mismatches: [], missing: [], extra: [], errors: [] },
|
||||
};
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, json: async () => responseWithProvenance });
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
render(<QueryClientProvider client={qc}><BundleInspector /></QueryClientProvider>);
|
||||
|
||||
act(() => { fireEvent.change(screen.getByTestId("bundle-path-input"), { target: { value: "/tmp/with-prov.rigbundle" } }); });
|
||||
act(() => { fireEvent.click(screen.getByTestId("inspect-btn")); });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("provenance-block")).toBeTruthy();
|
||||
expect(screen.getByTestId("provenance-createdAt").textContent).toContain("2026-05-18T12:00:00Z");
|
||||
expect(screen.getByTestId("provenance-sourceHost").textContent).toContain("test-host.local");
|
||||
expect(screen.getByTestId("provenance-authorSession").textContent).toContain("velocity-driver@openrig-velocity");
|
||||
expect(screen.getByTestId("provenance-sourceRigName").textContent).toContain("openrig-velocity");
|
||||
expect(screen.getByTestId("provenance-sourceRigName").textContent).toContain("01H000000000000000PROV001");
|
||||
expect(screen.getByTestId("provenance-versions").textContent).toContain("daemon 0.3.2");
|
||||
expect(screen.getByTestId("provenance-versions").textContent).toContain("cli 0.3.2");
|
||||
expect(screen.getByTestId("provenance-notes").textContent).toContain("fixture for B1 repair");
|
||||
});
|
||||
});
|
||||
|
||||
// Item 5 / slice-05 Checkpoint 6.1 / guard B1 repair: compatibility block renders
|
||||
// when manifest carries it. Discriminator: false && result.manifest.compatibility
|
||||
// gate must fail this test.
|
||||
it("renders compatibility block with all fields when manifest carries compatibility", async () => {
|
||||
const responseWithCompat = {
|
||||
manifest: {
|
||||
name: "with-compat",
|
||||
version: "0.1.0",
|
||||
rigSpec: "rig.yaml",
|
||||
packages: [{ name: "pkg", version: "1.0", path: "packages/pkg" }],
|
||||
compatibility: {
|
||||
minDaemonVersion: "0.3.2",
|
||||
minCliVersion: "0.3.2",
|
||||
schemaVersion: 1,
|
||||
},
|
||||
},
|
||||
digestValid: true,
|
||||
integrityResult: { passed: true, mismatches: [], missing: [], extra: [], errors: [] },
|
||||
};
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, json: async () => responseWithCompat });
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
render(<QueryClientProvider client={qc}><BundleInspector /></QueryClientProvider>);
|
||||
|
||||
act(() => { fireEvent.change(screen.getByTestId("bundle-path-input"), { target: { value: "/tmp/with-compat.rigbundle" } }); });
|
||||
act(() => { fireEvent.click(screen.getByTestId("inspect-btn")); });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("compatibility-block")).toBeTruthy();
|
||||
expect(screen.getByTestId("compatibility-minDaemonVersion").textContent).toContain("0.3.2");
|
||||
expect(screen.getByTestId("compatibility-minCliVersion").textContent).toContain("0.3.2");
|
||||
expect(screen.getByTestId("compatibility-schemaVersion").textContent).toContain("v1");
|
||||
});
|
||||
});
|
||||
|
||||
// Item 5 / slice-05 Checkpoint 6.1 / guard B1 repair: backward-compat — bundle
|
||||
// without provenance or compatibility blocks does NOT render those sections.
|
||||
// Pre-Item-1/Item-2 bundles install/inspect unchanged.
|
||||
it("does NOT render provenance or compatibility blocks when manifest omits them (backward compat)", async () => {
|
||||
// INSPECT_RESPONSE has no provenance + no compatibility — reuse it
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, json: async () => INSPECT_RESPONSE });
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
render(<QueryClientProvider client={qc}><BundleInspector /></QueryClientProvider>);
|
||||
|
||||
act(() => { fireEvent.change(screen.getByTestId("bundle-path-input"), { target: { value: "/tmp/bc.rigbundle" } }); });
|
||||
act(() => { fireEvent.click(screen.getByTestId("inspect-btn")); });
|
||||
|
||||
await waitFor(() => {
|
||||
// Manifest summary still renders (regression baseline)
|
||||
expect(screen.getByTestId("manifest-summary")).toBeTruthy();
|
||||
});
|
||||
// New blocks must NOT be present
|
||||
expect(screen.queryByTestId("provenance-block")).toBeNull();
|
||||
expect(screen.queryByTestId("compatibility-block")).toBeNull();
|
||||
});
|
||||
|
||||
// T6: Error state
|
||||
it("shows error on inspect failure", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("network error"));
|
||||
|
||||
Reference in new issue
Block a user