mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
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 from1fd11b76) - 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:
1 parent
f10a19901a
commit
fbf92f9ce1
4 files changed
+276
-2
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user