feat(slice-05): install-time compatibility check + --skip-version-check override (Item 2 close-out) (OPR.0.3.2.5)

Closes Item 2 of slice-05. Adds the install-time version gate: route
/install reads the bundle manifest's compatibility block and compares
against the running daemon version (server-side read) + CLI version
(client-sent in request body), failing with a 3-part error before
bootstrap delegation if either minimum is not met. Operator override
via --skip-version-check (request body skipVersionCheck=true).

Ordering: lock acquisition runs FIRST so the existing concurrent-install
409 semantic is preserved verbatim. Compat check runs inside the lock's
try block, BEFORE bootstrap delegation, with the outer finally
releasing the lock on either path.

Files touched:
- packages/daemon/src/routes/bundles.ts: new compareVersions helper
  (semver-ish numeric segment compare; coerces non-numeric to 0; pre-
  release / build metadata not interpreted — adequate for the 0.x.y
  range); new CompatibilityFailure interface; new
  checkBundleCompatibility function (returns null on pass, failures
  array on mismatch; missing fields no-op; pre-Item-2 CLIs that don't
  send cliVersion skip the CLI check silently — honest backward
  compat); new extractManifestForCompatCheck helper (tar-extracts to
  tmp dir, parses bundle.yaml, cleans up); /install handler reads
  skipVersionCheck + cliVersion from body, runs the check after lock
  acquisition + before bootstrap delegation; returns 400 with the
  3-part error shape (error + failures[] + resolutions[]) on mismatch
  or extraction failure
- packages/cli/src/commands/bundle.ts: --skip-version-check flag on
  install; cliVersion (from getCliVersion, call-time) + skipVersionCheck
  included in request body
- packages/daemon/test/bundle-routes.test.ts: 4 new tests — mismatch
  daemon version fails with 3-part error + asserts failures[] shape +
  resolutions[]; skipVersionCheck=true bypasses the check; mismatch
  CLI version fails with 3-part error; compatible bundle passes the
  check
- packages/cli/test/bundle.test.ts: 2 new tests — --skip-version-check
  sets skipVersionCheck=true + auto-sends cliVersion; no flag sets
  skipVersionCheck=false + still sends cliVersion. capturedInstallBodies
  fixture added (mirroring capturedCreateBodies)

Diff: 4 files, +276 / -2 lines.

Verified:
- 103/103 daemon bundle suite green (was 99; +4 new install tests)
- 17/17 CLI bundle suite green (was 15; +2 new install flag tests)
- 120/120 total bundle tests across both packages
- scope-and-versioning sections 1 and 3 coherence re-audit clean
- audit-every-layer: zero new module-level constants reading
  env/host/version

PRD bounce conditions verified:
- No schema bump (consumes optional manifest field from 1fd11b76)
- No new SQLite migrations
- No event types
- No core-routing or bootstrap-orchestrator mutation (bootstrap is
  delegated to unchanged; the compat check is a pre-delegation gate
  in the route handler only)
- No sidebar entries touched

Watched risks applied:
- Audit-every-layer: getDaemonVersion reads daemon package.json at
  call time (not at module load); checkBundleCompatibility reads its
  inputs from the parsed manifest + request body only; extraction is
  per-call to tmp dir, not a module-level cache. CLI getCliVersion
  same pattern. Verified via grep — no new top-level const reads.
- Optional-field backward-compat both directions: bundles without
  compatibility install unchanged (checkBundleCompatibility returns
  null when compat undefined); pre-Item-2 CLIs that don't send
  cliVersion skip CLI-side check silently (daemon-side check still
  runs against the daemon's own version); skipVersionCheck=true is
  the operator-explicit bypass, NOT the silent default
- Existing 409 concurrent-install behavior preserved: lock acquired
  BEFORE compat check; test "concurrent bundle install returns 409"
  passes unchanged

Item 2 fully shipped on this branch (3-commit chain):
- 1fd11b76 (CLEAR) — Item 2 part 1: type layer
- f10a1990 (CLEAR) — Item 2 part 2: assembler + route + CLI plumbing
- THIS COMMIT — Item 2 part 3: install-time check + override (close-out)

Slice-05 progress: Items 1 + 2 of 6 complete. Next: Item 3 (pre-install
conflict detection — PRD CATASTROPHIC-BOUNCE if dropped). Then Items
4 (audit history JSONL), 5 (inspector UI + Files browser composition),
6 (cross-primitive bundling + gstack HYBRID proof case).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-18 13:10:36 -07:00
1 parent f10a19901a
commit fbf92f9ce1
4 files changed
+276 -2

No files matched your search

+7 -1
View File
@@ -140,14 +140,20 @@ 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("--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; json?: boolean }) => {
const deps = getDepsF();
const client = await getClient(deps);
if (!client) { process.exitCode = 1; return; }
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,
});
if (opts.json) {
+35
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" }));
@@ -288,6 +291,38 @@ describe("Bundle CLI", () => {
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 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 = [];
+120 -1
View File
@@ -35,6 +35,85 @@ function getDaemonVersion(): string {
}
}
/**
* Compare two dotted numeric version strings (semver-ish). Returns -1 if
* a < b, 0 if equal, 1 if a > b. Non-numeric segments coerce to 0. Adequate
* for the 0.x.y / 1.x.y range; pre-release / build metadata not interpreted.
* Item-2 install-time version check (Checkpoint 3.3).
*/
function compareVersions(a: string, b: string): number {
const partsA = a.split(".").map((p) => parseInt(p, 10) || 0);
const partsB = b.split(".").map((p) => parseInt(p, 10) || 0);
const maxLen = Math.max(partsA.length, partsB.length);
for (let i = 0; i < maxLen; i++) {
const va = partsA[i] ?? 0;
const vb = partsB[i] ?? 0;
if (va !== vb) return va < vb ? -1 : 1;
}
return 0;
}
/** A single compatibility check failure surfaced in the 3-part error response. */
interface CompatibilityFailure {
reason: "daemon_version_mismatch" | "cli_version_mismatch";
required: string;
actual: string;
description: string;
}
/**
* Run the install-time compatibility check (Item 2 Checkpoint 3.3). Returns
* an array of failures (one per kind), or null if all checks pass. Missing
* fields in compat are no-ops (daemon-only check when min_cli_version absent;
* full pass when both absent). cliVersion undefined skips the CLI check
* silently (pre-Item-2 CLIs don't send it; honest backward compat).
*/
function checkBundleCompatibility(
compat: BundleCompatibility | undefined,
daemonVersion: string,
cliVersion: string | undefined,
): CompatibilityFailure[] | null {
if (!compat) return null;
const failures: CompatibilityFailure[] = [];
if (compat.minDaemonVersion && compareVersions(daemonVersion, compat.minDaemonVersion) < 0) {
failures.push({
reason: "daemon_version_mismatch",
required: compat.minDaemonVersion,
actual: daemonVersion,
description: `bundle requires daemon >= ${compat.minDaemonVersion}, current daemon is ${daemonVersion}`,
});
}
if (compat.minCliVersion && cliVersion && compareVersions(cliVersion, compat.minCliVersion) < 0) {
failures.push({
reason: "cli_version_mismatch",
required: compat.minCliVersion,
actual: cliVersion,
description: `bundle requires CLI >= ${compat.minCliVersion}, current CLI is ${cliVersion}`,
});
}
return failures.length > 0 ? failures : null;
}
/**
* Extract bundle.yaml manifest from a .rigbundle archive without doing the
* full bootstrap unpack. Used by the install-time compatibility check. Throws
* on archive / parse / safety failures.
*/
async function extractManifestForCompatCheck(bundlePath: string): Promise<Record<string, unknown>> {
const tmpDir = fs.mkdtempSync(nodePath.join(os.tmpdir(), "bundle-compat-"));
try {
const tar = await import("tar");
await tar.extract({ file: bundlePath, cwd: tmpDir });
const manifestPath = nodePath.join(tmpDir, "bundle.yaml");
if (!fs.existsSync(manifestPath)) throw new Error("Bundle missing bundle.yaml");
const manifestYaml = fs.readFileSync(manifestPath, "utf-8");
const parsed = parsePodBundleManifest(manifestYaml);
return parsed as Record<string, unknown>;
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
/**
* Sanitize raw compatibility from request body into a BundleCompatibility
* object. Only known typed fields accepted; unknown fields dropped silently.
@@ -349,16 +428,56 @@ bundleRoutes.post("/install", async (c) => {
const plan = body["plan"] === true;
const autoApprove = body["autoApprove"] === true;
const targetRoot = typeof body["targetRoot"] === "string" ? body["targetRoot"] : undefined;
// Item 2 / slice-05 Checkpoint 3.3: install-time compatibility check inputs
const skipVersionCheck = body["skipVersionCheck"] === true;
const clientCliVersion = typeof body["cliVersion"] === "string" ? body["cliVersion"] : undefined;
if (!bundlePath) return c.json({ error: "bundlePath is required" }, 400);
if (!plan && !targetRoot) return c.json({ error: "targetRoot is required for apply mode" }, 400);
// Concurrency lock
// Concurrency lock — runs BEFORE compat check so the existing 409
// semantic (concurrent install detection) is preserved verbatim.
if (!bootstrapOrchestrator.tryAcquire(bundlePath)) {
return c.json({ error: "Bundle install already in progress", code: "conflict" }, 409);
}
try {
// Item 2 / slice-05 Checkpoint 3.3: install-time compatibility check
// Runs AFTER the lock + BEFORE bootstrap delegation. Mismatch returns a
// 3-part error and exits the lifecycle (lock releases via the outer
// finally). Operator override via --skip-version-check (request body
// skipVersionCheck=true).
if (!skipVersionCheck) {
try {
const manifest = await extractManifestForCompatCheck(bundlePath);
const compatibility = normalizeCompatibilityBlock(manifest["compatibility"]);
const failures = checkBundleCompatibility(compatibility, getDaemonVersion(), clientCliVersion);
if (failures) {
return c.json({
error: "Bundle compatibility check failed",
failures,
resolutions: [
"upgrade the affected runtime to the required version (recommended)",
"use a bundle with relaxed minimum requirements",
"pass --skip-version-check to bypass for an operator-explicit override (NOT recommended for routine use)",
],
}, 400);
}
} catch (err) {
// Extraction failure here is treated as honest fast-fail rather than
// silently skipping the check; bootstrap would surface the same error
// anyway. 3-part shape preserved.
return c.json({
error: "Bundle compatibility check could not run (extraction failed)",
detail: (err as Error).message,
resolutions: [
"confirm the bundle path is correct and the archive is readable",
"pass --skip-version-check to bypass the check (NOT recommended unless intentional)",
],
}, 400);
}
}
if (plan) {
// Plan mode: no run lifecycle
try {
+114
View File
@@ -653,6 +653,120 @@ describe("Bundle API routes", () => {
expect(inspectBody.manifest.provenance.author_session).toBeUndefined();
});
// Item 2 / slice-05 Checkpoint 3.3: install-time version check
it("POST /api/bundles/install fails with 3-part error when min_daemon_version exceeds running daemon", async () => {
const { specPath } = seedPackage();
const bundlePath = path.join(tmpDir, "incompat.rigbundle");
// Create bundle with min_daemon_version way above current
const createRes = await app.request("/api/bundles/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
specPath, bundleName: "incompat", bundleVersion: "0.1.0", outputPath: bundlePath,
compatibility: { minDaemonVersion: "99.0.0" },
}),
});
expect(createRes.status).toBe(201);
const installRes = await app.request("/api/bundles/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bundlePath, plan: true }),
});
expect(installRes.status).toBe(400);
const body = await installRes.json();
expect(body.error).toBe("Bundle compatibility check failed");
expect(Array.isArray(body.failures)).toBe(true);
expect(body.failures.length).toBeGreaterThan(0);
const daemonFailure = body.failures.find((f: { reason: string }) => f.reason === "daemon_version_mismatch");
expect(daemonFailure).toBeDefined();
expect(daemonFailure.required).toBe("99.0.0");
expect(typeof daemonFailure.actual).toBe("string");
expect(typeof daemonFailure.description).toBe("string");
expect(Array.isArray(body.resolutions)).toBe(true);
expect(body.resolutions.length).toBeGreaterThanOrEqual(2);
});
it("POST /api/bundles/install with skipVersionCheck=true bypasses incompatible bundle's compat check", async () => {
const { specPath } = seedPackage();
const bundlePath = path.join(tmpDir, "incompat-skip.rigbundle");
await app.request("/api/bundles/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
specPath, bundleName: "incompat-skip", bundleVersion: "0.1.0", outputPath: bundlePath,
compatibility: { minDaemonVersion: "99.0.0" },
}),
});
const installRes = await app.request("/api/bundles/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bundlePath, plan: true, skipVersionCheck: true }),
});
// Compat check was skipped; we just need NOT to see the "Bundle compatibility
// check failed" error. Bootstrap may still return any other status; what we
// assert is the absence of the compat-check failure shape.
if (installRes.status === 400) {
const body = await installRes.json();
expect(body.error).not.toBe("Bundle compatibility check failed");
}
});
it("POST /api/bundles/install fails with 3-part error when min_cli_version exceeds the CLI version sent in body", async () => {
const { specPath } = seedPackage();
const bundlePath = path.join(tmpDir, "cli-incompat.rigbundle");
await app.request("/api/bundles/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
specPath, bundleName: "cli-incompat", bundleVersion: "0.1.0", outputPath: bundlePath,
compatibility: { minCliVersion: "99.0.0" },
}),
});
const installRes = await app.request("/api/bundles/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bundlePath, plan: true, cliVersion: "0.3.1" }),
});
expect(installRes.status).toBe(400);
const body = await installRes.json();
expect(body.error).toBe("Bundle compatibility check failed");
const cliFailure = body.failures.find((f: { reason: string }) => f.reason === "cli_version_mismatch");
expect(cliFailure).toBeDefined();
expect(cliFailure.required).toBe("99.0.0");
expect(cliFailure.actual).toBe("0.3.1");
});
it("POST /api/bundles/install passes the compat check when bundle requires versions <= current", async () => {
const { specPath } = seedPackage();
const bundlePath = path.join(tmpDir, "compat.rigbundle");
await app.request("/api/bundles/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
specPath, bundleName: "compat-ok", bundleVersion: "0.1.0", outputPath: bundlePath,
compatibility: { minDaemonVersion: "0.0.1", minCliVersion: "0.0.1" },
}),
});
const installRes = await app.request("/api/bundles/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bundlePath, plan: true, cliVersion: "0.3.1" }),
});
// Must NOT be the compat-fail shape. Bootstrap may return any status.
if (installRes.status === 400) {
const body = await installRes.json();
expect(body.error).not.toBe("Bundle compatibility check failed");
}
});
// T11: Install concurrency lock
it("concurrent bundle install returns 409", async () => {
// Acquire lock manually