feat(slice-05): /install conflict gate + CLI --force override (Item 3 Checkpoint 4.2) (OPR.0.3.2.5)

Integrates the bundle-conflict-detector from 5b00abf7 (Item 3 Checkpoint
4.1) into the /api/bundles/install pre-delegation gate. Bundles whose
rig name collides with a currently-running rig fail with the 3-part
error shape (error + conflicts[] + resolutions[]) BEFORE bootstrap
delegation. Operator override via --force (request body force=true).

Ordering preserved: lock acquisition → compat check (Item 2) →
conflict check (Item 3) → bootstrap delegation. The conflict check
runs from the same safe-extract pass as compat (one unpack per
install), so the unpack-safety boundary holds for both checks.

Helper rename: extractManifestForCompatCheck wraps a new
extractInstallTimeMetadata helper that returns both bundleManifest
(for compat) and rigName (from the bundle's rig.yaml, for conflict).
Old helper kept as a thin shim so callers don't churn; new helper is
the single-pass safe-extract path. The compat extraction failure
shape is generalized to cover both pre-checks.

Files touched:
- packages/daemon/src/routes/bundles.ts: import detectBundleConflicts
  + RigRepository type; new extractInstallTimeMetadata helper
  (single-pass extract returning bundleManifest + rigName via safe
  unpack — banked safety-boundary lesson applied); getDeps extended
  with rigRepo from Hono context; /install reads force from body and
  invokes detectBundleConflicts after the compat check, mapping
  RigRepository.Rig.id -> detector input rigId (guard 4.1 watchpoint
  applied); 3-part conflict error returned with 400 status; fallback
  extraction-failure shape generalized to cover both pre-checks
- packages/cli/src/commands/bundle.ts: new --force flag on install;
  force field included in /api/bundles/install request body
- packages/daemon/test/bundle-routes.test.ts: 3 new tests — conflict
  detected with running rig of same name (3-part shape asserted);
  force=true bypasses on same conflict; no conflict when no rig name
  matches
- packages/cli/test/bundle.test.ts: 2 new tests — --force sets
  force=true in body; no flag sets force=false (operator-explicit
  opt-in only)

Diff: 4 files, +211 / -25 lines.

Verified:
- 113/113 daemon bundle suite green (was 110; +3 new conflict route
  tests)
- 19/19 CLI bundle suite green (was 17; +2 new --force flag tests)
- 132/132 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 (detector pure; helper reads from extracted
  manifest; route reads from rigRepo via DI; CLI reads opts from
  commander)

Pre-commit discipline applied (banked lesson from 5b00abf7 incident):
git diff --cached --stat inspected after explicit git add of the 4
intended files; staged scope confirmed to match intent BEFORE commit.
No unexpected staged content swept into this commit.

PRD bounce conditions verified:
- No schema bump
- No new SQLite migrations
- No event types
- No core-routing or bootstrap-orchestrator mutation (bootstrap
  delegation unchanged; conflict check is a pre-delegation gate in
  the route handler only)
- No sidebar entries touched
- Extends, does NOT replace, the existing 409 / "install already in
  progress" concurrency lock; the new check is additive

Watched risks applied:
- Audit-every-layer: zero new module-level constants. detector reads
  from pure input; helper reads from extracted manifest; route reads
  rigRepo via DI; CLI reads from commander opts
- Optional-field backward-compat: rigName undefined (bundle has no
  rig.yaml or no name field) → detector fail-opens (no conflict to
  compare); pre-Item-3 CLIs that don't send force → force defaults
  to false (conflict check runs as designed); force=true is the
  operator-explicit opt-in NOT the silent default
- Safety boundary preserved: single unpack call shared between compat
  + conflict pre-checks; banked lesson from Item 2 B1 applied

Conflict kinds NOT in this commit (graduate to Checkpoint 4.3):
- Agent-name collisions (v2 pod-aware: per-rig agent set check)
- Port collisions (where bundle declares ports)
- Managed-app project-name collisions
- Sibling-primitive collisions (fold with Item 6 cross-primitive
  bundling)

CLI --target rename semantic NOT in this commit: PRD spec used
--target <newname> for rename, but the existing CLI install --target
already ships as --target <root> (targetRoot). Resolving that flag-
name collision is a separable design decision; surfaced as a
follow-up question rather than introducing a breaking CLI change
mid-slice.

Item 3 sub-chain on this branch:
- 97803a8f (Item 3 4.1 detector + CLI restore, CLEAR) <- prior tip
- THIS COMMIT — Item 3 part 2: /install conflict gate + --force
- Next (4.3) — agent + port + managed-app collisions
- Next (4.4) — sibling-primitive collisions (folds with Item 6)

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:46:22 -07:00
1 parent 97803a8fb3
commit 8752bfe972
4 files changed
+211 -25

No files matched your search

+5 -1
View File
@@ -141,8 +141,9 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
.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; skipVersionCheck?: boolean; 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; }
@@ -154,6 +155,9 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
// 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,
});
if (opts.json) {
+28
View File
@@ -323,6 +323,34 @@ describe("Bundle CLI", () => {
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 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 = [];
+90 -24
View File
@@ -17,6 +17,8 @@ import { RigSpecSchema } from "../domain/rigspec-schema.js";
import { parseLegacyBundleManifest as parseBundleManifest, normalizeLegacyBundleManifest as normalizeBundleManifest, serializePodBundleManifest, parsePodBundleManifest, validatePodBundleManifest, normalizeProvenanceBlock, normalizeCompatibilityBlock } from "../domain/bundle-types.js";
import type { PodBundleManifest, BundleProvenance, BundleCompatibility } from "../domain/bundle-types.js";
import { fileURLToPath } from "node:url";
import { detectBundleConflicts, type BundleConflict } from "../domain/bundle-conflict-detector.js";
import type { RigRepository } from "../domain/rig-repository.js";
/**
* Read the daemon's own package.json version at call time (Item 1 / slice-05).
@@ -105,14 +107,46 @@ function checkBundleCompatibility(
* / parse failures; the caller converts these into a 3-part 400 response.
*/
async function extractManifestForCompatCheck(bundlePath: string): Promise<Record<string, unknown>> {
const tmpDir = fs.mkdtempSync(nodePath.join(os.tmpdir(), "bundle-compat-"));
const meta = await extractInstallTimeMetadata(bundlePath);
return meta.bundleManifest;
}
/**
* Extract both the bundle.yaml manifest AND the rig name from the bundle's
* rig.yaml in one safe pass (Item 3 / slice-05 Checkpoint 4.2). The /install
* handler uses bundleManifest for the compat check (Item 2) and rigName for
* the conflict check (Item 3). Reuses unpack — single trust boundary.
*/
async function extractInstallTimeMetadata(bundlePath: string): Promise<{
bundleManifest: Record<string, unknown>;
rigName: string | undefined;
}> {
const tmpDir = fs.mkdtempSync(nodePath.join(os.tmpdir(), "bundle-meta-"));
try {
await unpack(bundlePath, 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>;
const bundleManifest = parsePodBundleManifest(manifestYaml) as Record<string, unknown>;
// Rig name lives in the bundle's rig.yaml (path referenced by bundle.yaml's
// rig_spec field; defaults to rig.yaml for legacy bundles). Read + parse;
// missing-or-malformed rig name leaves rigName undefined which the detector
// fail-opens on (no rig name to compare).
let rigName: string | undefined;
const rigSpecRel = typeof bundleManifest["rig_spec"] === "string" ? bundleManifest["rig_spec"] : "rig.yaml";
const rigSpecPath = nodePath.join(tmpDir, rigSpecRel);
if (fs.existsSync(rigSpecPath)) {
try {
const rigYaml = fs.readFileSync(rigSpecPath, "utf-8");
const rigParsed = parsePodBundleManifest(rigYaml) as Record<string, unknown>;
if (typeof rigParsed["name"] === "string" && rigParsed["name"].length > 0) {
rigName = rigParsed["name"];
}
} catch {
// rig.yaml malformed — leave rigName undefined; conflict check skips
}
}
return { bundleManifest, rigName };
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
@@ -160,6 +194,7 @@ function getDeps(c: { get: (key: string) => unknown }) {
eventBus: c.get("eventBus" as never) as EventBus,
bootstrapOrchestrator: c.get("bootstrapOrchestrator" as never) as BootstrapOrchestrator,
bootstrapRepo: c.get("bootstrapRepo" as never) as BootstrapRepository,
rigRepo: c.get("rigRepo" as never) as RigRepository | undefined,
};
}
@@ -426,7 +461,7 @@ bundleRoutes.post("/inspect", async (c) => {
// POST /api/bundles/install — reuses full bootstrap lifecycle
bundleRoutes.post("/install", async (c) => {
const { bootstrapOrchestrator, bootstrapRepo, eventBus } = getDeps(c);
const { bootstrapOrchestrator, bootstrapRepo, eventBus, rigRepo } = getDeps(c);
const body: Record<string, unknown> = await c.req.json().catch(() => ({}));
const bundlePath = typeof body["bundlePath"] === "string" ? body["bundlePath"] : "";
const plan = body["plan"] === true;
@@ -435,6 +470,8 @@ bundleRoutes.post("/install", async (c) => {
// 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;
// Item 3 / slice-05 Checkpoint 4.2: install-time conflict check inputs
const force = body["force"] === true;
if (!bundlePath) return c.json({ error: "bundlePath is required" }, 400);
if (!plan && !targetRoot) return c.json({ error: "targetRoot is required for apply mode" }, 400);
@@ -451,32 +488,61 @@ bundleRoutes.post("/install", async (c) => {
// 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) {
// Item 2 + Item 3 / slice-05: single safe extract pass yields both the
// bundle manifest (for compat check) and the rig name (for conflict check).
// Caller can skip the compat check via skipVersionCheck; the conflict check
// also runs from this same extract pass unless --force bypasses it.
let installMeta: { bundleManifest: Record<string, unknown>; rigName: string | undefined } | null = null;
if (!skipVersionCheck || !force) {
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);
}
installMeta = await extractInstallTimeMetadata(bundlePath);
} 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)",
error: "Bundle install pre-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)",
"pass --skip-version-check and --force to bypass both pre-checks (NOT recommended unless intentional)",
],
}, 400);
}
}
if (!skipVersionCheck && installMeta) {
const compatibility = normalizeCompatibilityBlock(installMeta.bundleManifest["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);
}
}
// Item 3 / slice-05 Checkpoint 4.2: install-time conflict check
// Runs AFTER the compat check + BEFORE bootstrap delegation. Mismatch
// returns a 400 with the 3-part error shape (error + conflicts[] +
// resolutions[]). Operator override via --force (request body force=true).
// The check fails CLOSED on extraction failure (handled above) and
// fail-OPEN on missing rig name in the bundle (no rig name to compare).
if (!force && installMeta && rigRepo) {
const runningRigs = rigRepo.listRigs().map((r) => ({ rigId: r.id, name: r.name }));
const report = detectBundleConflicts({
bundleRigName: installMeta.rigName ?? "",
runningRigs,
});
if (report.hasConflicts) {
return c.json({
error: "Bundle install conflict check failed",
conflicts: report.conflicts,
resolutions: [
"stop the conflicting running rig and re-attempt install",
"use --force to bypass for an operator-explicit override (NOT recommended for routine use; conflicts may produce partial install state)",
],
}, 400);
}
@@ -820,6 +820,94 @@ describe("Bundle API routes", () => {
}
});
// Item 3 / slice-05 Checkpoint 4.2: install conflict gate. Conflict path:
// a running rig with the same name as the bundle's rig must produce a
// 3-part error response from /install BEFORE bootstrap delegation.
// Force-bypass path: same bundle with force=true must skip the conflict
// check.
it("POST /api/bundles/install fails with 3-part conflict error when bundle rig name collides with a running rig", async () => {
const { specPath } = seedPackage();
const bundlePath = path.join(tmpDir, "conflict-test.rigbundle");
// Create bundle whose rig.yaml declares name 'test-rig' (matches VALID_SPEC)
const createRes = await app.request("/api/bundles/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ specPath, bundleName: "conflict-test", bundleVersion: "0.1.0", outputPath: bundlePath }),
});
expect(createRes.status).toBe(201);
// Seed a running rig with the same name as the bundle's rig
setup.rigRepo.createRig("test-rig");
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 install conflict check failed");
expect(Array.isArray(body.conflicts)).toBe(true);
expect(body.conflicts.length).toBeGreaterThan(0);
const rigConflict = body.conflicts.find((c: { kind: string }) => c.kind === "rig_name_collision");
expect(rigConflict).toBeDefined();
expect(rigConflict.bundleRigName).toBe("test-rig");
expect(typeof rigConflict.collisionWith.rigId).toBe("string");
expect(rigConflict.collisionWith.rigName).toBe("test-rig");
expect(Array.isArray(body.resolutions)).toBe(true);
expect(body.resolutions.length).toBeGreaterThanOrEqual(2);
});
it("POST /api/bundles/install with force=true bypasses the conflict check on a name collision", async () => {
const { specPath } = seedPackage();
const bundlePath = path.join(tmpDir, "conflict-force.rigbundle");
await app.request("/api/bundles/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ specPath, bundleName: "conflict-force", bundleVersion: "0.1.0", outputPath: bundlePath }),
});
setup.rigRepo.createRig("test-rig");
const installRes = await app.request("/api/bundles/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bundlePath, plan: true, force: true }),
});
// Conflict check was bypassed. Bootstrap may return any status; what we
// assert is the absence of the conflict-check failure shape.
if (installRes.status === 400) {
const body = await installRes.json();
expect(body.error).not.toBe("Bundle install conflict check failed");
}
});
it("POST /api/bundles/install passes the conflict check when no running rig matches the bundle's rig name", async () => {
const { specPath } = seedPackage();
const bundlePath = path.join(tmpDir, "no-conflict.rigbundle");
await app.request("/api/bundles/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ specPath, bundleName: "no-conflict", bundleVersion: "0.1.0", outputPath: bundlePath }),
});
// No rigs created — running set is empty
const installRes = await app.request("/api/bundles/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bundlePath, plan: true }),
});
// Must NOT be the conflict-fail shape. Bootstrap may return any status.
if (installRes.status === 400) {
const body = await installRes.json();
expect(body.error).not.toBe("Bundle install conflict check failed");
}
});
// T11: Install concurrency lock
it("concurrent bundle install returns 409", async () => {
// Acquire lock manually