merge: slice 05 — rigbundles first-class (5 cross-primitive kinds + gstack HYBRID proof) (release-0.3.2)

This commit is contained in:
mvschwarz committed 2026-05-31 21:40:02 -07:00
commit 0c9e63f10e
27 files changed
+8353 -17

No files matched your search

+120 -4
View File
@@ -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;
}
+217
View File
@@ -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 };
}
+29 -2
View File
@@ -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(
+135
View File
@@ -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 };
}
+432
View File
@@ -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");
});
});
+126 -1
View File
@@ -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:");
});
});
+172
View File
@@ -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`);
});
});
+629
View File
@@ -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 && (
<>
+23
View File
@@ -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"));