feat(slice-05): provenance plumbing in /create + /inspect routes and rig bundle create CLI (Item 1 close-out) (OPR.0.3.2.5)

Closes Item 1 of slice-05: end-to-end provenance pipeline. CLI captures
operator-side context at invoke time and sends in the create request;
daemon injects its own version server-side; assembler persists into
the manifest (commits 82d39715 + 3b7432e4); inspect route surfaces
provenance back in the response JSON for downstream consumers
(UI inspector lands at Checkpoint 5).

CLI side (packages/cli/src/commands/bundle.ts):
- New --notes flag on rig bundle create for operator-authored notes
- New getCliVersion helper reads the CLI package.json at call time
  (no module-level constant — audit-every-layer discipline)
- New buildClientProvenance helper assembles the request-body
  provenance from os.hostname() + process.env.OPENRIG_SESSION_NAME +
  getCliVersion() + operator notes; reads all at call time
- create action wires provenance into the request body

Route side (packages/daemon/src/routes/bundles.ts):
- New getDaemonVersion helper reads the daemon package.json at call
  time (no module-level constant)
- New provenanceFromRequestBody helper sanitizes client-sent fields
  (only string-typed known fields accepted; unknown fields dropped)
- /create handler: extracts provenance from body, injects
  daemonVersion server-side, passes to both v1 and v2 assemblers
- /inspect handler: v2 path now includes provenance in the response
  manifest projection; v1 path already surfaces it via the normalizer
  extension from 82d39715

Tests:
- packages/daemon/test/bundle-routes.test.ts +2 tests: provenance
  round-trip through /create + /inspect, and backward-compat path
  (no provenance in request leaves manifest provenance undefined)
- packages/cli/test/bundle.test.ts +3 tests: --notes wires into
  request body provenance.notes; hostname + cliVersion auto-populate
  with no flag; OPENRIG_SESSION_NAME env populates authorSession

Diff: 4 files, +210 / -4 lines. Verified: 80/80 daemon bundle suite +
12/12 CLI bundle suite green; 0 regressions across changed surfaces.

Branch chain: 82d39715 (type layer, guard CLEAR) -> 3b7432e4
(assembler capture, guard CLEAR) -> this commit (CLI + route close-out).

PRD bounce conditions verified:
- No schema bump (manifest extended with optional field only, landed
  in 82d39715)
- No new SQLite migrations
- No event types
- No core-routing or bootstrap mutation
- No sidebar entries touched
- scope-and-versioning sections 1 and 3 coherence re-audit clean

Watched risks applied:
- Audit-every-layer (function-level call sites AND module-level
  constants reading env/host/version): zero module-level constants
  introduced. Both getCliVersion and getDaemonVersion read at function
  call time. provenanceFromRequestBody reads only from the typed
  request body. Verified via grep for top-level const reads of os,
  process.env, or JSON.parse(package.json) shapes in both edited
  files — zero hits.
- Optional-field backward-compat both directions: missing provenance
  in request body results in undefined provenance passed to assembler
  (manifest.provenance omitted from bundle.yaml). Bundles created
  pre-Item-1 install unchanged. Older daemons reading new manifests
  silently ignore provenance (validator accepts unknown fields).

What is NOT in this commit (graduates to Checkpoint 5 / Item 5):
- CLI rig bundle inspect text output renders a Provenance section
  alongside the existing Bundle / Digest valid / Integrity lines
- UI BundleInspector renders provenance block

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-18 12:39:45 -07:00
1 parent 3b7432e41c
commit 6b81d3b6d5
4 files changed
+210 -4

No files matched your search

+39 -1
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,8 +62,9 @@ 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("--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; json?: boolean }) => {
const deps = getDepsF();
const client = await getClient(deps);
if (!client) { process.exitCode = 1; return; }
@@ -36,6 +73,7 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
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),
});
if (opts.json) {
+62
View File
@@ -211,4 +211,66 @@ 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 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");
});
});
+49 -3
View File
@@ -15,7 +15,44 @@ import { LegacyRigSpecSchema } from "../domain/rigspec-schema.js";
import { RigSpecCodec } from "../domain/rigspec-codec.js";
import { RigSpecSchema } from "../domain/rigspec-schema.js";
import { parseLegacyBundleManifest as parseBundleManifest, normalizeLegacyBundleManifest as normalizeBundleManifest, serializePodBundleManifest, parsePodBundleManifest, validatePodBundleManifest } from "../domain/bundle-types.js";
import type { PodBundleManifest } from "../domain/bundle-types.js";
import type { PodBundleManifest, BundleProvenance } from "../domain/bundle-types.js";
import { fileURLToPath } from "node:url";
/**
* Read the daemon'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. The read is cheap and
* only happens on bundle create.
*/
function getDaemonVersion(): 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";
}
}
/**
* Normalize raw provenance from request body into a BundleProvenance object.
* Only string fields are accepted; unknown/non-string fields are dropped
* silently. Returns undefined if the input is missing or has no usable
* fields. Daemon-side daemonVersion injection is the caller's responsibility.
*/
function provenanceFromRequestBody(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["sourceHost"] === "string") result.sourceHost = p["sourceHost"];
if (typeof p["authorSession"] === "string") result.authorSession = p["authorSession"];
if (typeof p["sourceRigId"] === "string") result.sourceRigId = p["sourceRigId"];
if (typeof p["sourceRigName"] === "string") result.sourceRigName = p["sourceRigName"];
if (typeof p["cliVersion"] === "string") result.cliVersion = p["cliVersion"];
if (typeof p["notes"] === "string") result.notes = p["notes"];
return Object.keys(result).length > 0 ? result : undefined;
}
import type { FsOps } from "../domain/package-resolver.js";
export const bundleRoutes = new Hono();
@@ -86,6 +123,12 @@ bundleRoutes.post("/create", async (c) => {
const rigRoot = typeof body["rigRoot"] === "string" ? body["rigRoot"] : undefined;
const includePackages = Array.isArray(body["includePackages"]) ? body["includePackages"] as string[] : undefined;
// Item 1 / slice-05: build provenance from request body + inject daemonVersion server-side
const clientProvenance = provenanceFromRequestBody(body["provenance"]);
const provenance: BundleProvenance | undefined = clientProvenance
? { ...clientProvenance, daemonVersion: getDaemonVersion() }
: undefined;
if (!specPath || !bundleName || !bundleVersion || !outputPath) {
return c.json({ error: "specPath, bundleName, bundleVersion, and outputPath are required" }, 400);
}
@@ -105,7 +148,7 @@ bundleRoutes.post("/create", async (c) => {
const tmpStaging = fs.mkdtempSync(nodePath.join(os.tmpdir(), "pod-bundle-create-"));
try {
const assembler = new PodBundleAssembler({ fsOps: podAssemblerFsOps() });
const result = assembler.assemble({ rigRoot: effectiveRigRoot, rigSpecPath: nodePath.resolve(specPath), outputDir: tmpStaging, bundleName, bundleVersion });
const result = assembler.assemble({ rigRoot: effectiveRigRoot, rigSpecPath: nodePath.resolve(specPath), outputDir: tmpStaging, bundleName, bundleVersion, provenance });
const integrity = computeIntegrity(tmpStaging, integrityFsOps());
result.manifest.integrity = integrity;
@@ -162,7 +205,7 @@ bundleRoutes.post("/create", async (c) => {
try {
const assembler = new BundleAssembler({ fsOps: assemblerFsOps() });
const manifest = assembler.assemble({
specPath: nodePath.resolve(specPath), packages, outputDir: tmpStaging, bundleName, bundleVersion,
specPath: nodePath.resolve(specPath), packages, outputDir: tmpStaging, bundleName, bundleVersion, provenance,
});
const integrity = computeIntegrity(tmpStaging, integrityFsOps());
@@ -243,6 +286,9 @@ bundleRoutes.post("/inspect", async (c) => {
algorithm: integritySection.algorithm ?? "sha256",
files: integritySection.files ?? {},
} : undefined,
// Item 1 / slice-05: surface provenance raw from manifest so JSON-path
// consumers (UI inspector, jq pipelines) see it. Field is optional.
provenance: rawParsed["provenance"] as Record<string, unknown> | undefined,
};
const integrityCompat = integritySection ? {
schemaVersion: 2,
@@ -102,6 +102,66 @@ describe("Bundle API routes", () => {
expect(fs.existsSync(outputPath)).toBe(true);
});
// Item 1 / slice-05: provenance round-trip through /create + /inspect
it("POST /api/bundles/create accepts provenance + /inspect surfaces it (v1 round-trip)", async () => {
const { specPath } = seedPackage();
const bundlePath = path.join(tmpDir, "prov-test.rigbundle");
const createRes = await app.request("/api/bundles/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
specPath, bundleName: "prov-test", bundleVersion: "0.1.0", outputPath: bundlePath,
provenance: {
sourceHost: "route-test-host",
authorSession: "velocity-driver@openrig-velocity",
cliVersion: "0.3.2",
notes: "route-test fixture",
},
}),
});
expect(createRes.status).toBe(201);
const inspectRes = await app.request("/api/bundles/inspect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bundlePath }),
});
expect(inspectRes.status).toBe(200);
const inspectBody = await inspectRes.json();
expect(inspectBody.manifest.provenance).toBeDefined();
expect(inspectBody.manifest.provenance.sourceHost).toBe("route-test-host");
expect(inspectBody.manifest.provenance.authorSession).toBe("velocity-driver@openrig-velocity");
expect(inspectBody.manifest.provenance.cliVersion).toBe("0.3.2");
expect(inspectBody.manifest.provenance.notes).toBe("route-test fixture");
// Server-side daemonVersion injection — read from daemon package.json at call time
expect(typeof inspectBody.manifest.provenance.daemonVersion).toBe("string");
expect(inspectBody.manifest.provenance.daemonVersion.length).toBeGreaterThan(0);
// createdAt mirrored from root
expect(inspectBody.manifest.provenance.createdAt).toBe(inspectBody.manifest.createdAt);
});
it("POST /api/bundles/create with no provenance produces a bundle whose manifest omits provenance (backward compat)", async () => {
const { specPath } = seedPackage();
const bundlePath = path.join(tmpDir, "no-prov.rigbundle");
const createRes = await app.request("/api/bundles/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ specPath, bundleName: "no-prov", bundleVersion: "0.1.0", outputPath: bundlePath }),
});
expect(createRes.status).toBe(201);
const inspectRes = await app.request("/api/bundles/inspect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bundlePath }),
});
expect(inspectRes.status).toBe(200);
const inspectBody = await inspectRes.json();
expect(inspectBody.manifest.provenance).toBeUndefined();
});
// T2: Inspect returns manifest
it("POST /api/bundles/inspect returns manifest + integrity", async () => {
const { specPath } = seedPackage();