fix(slice-05): workflow_specs basename-collision detection + non-YAML rejection (guard B1+B2 repair on d81456dc) (OPR.0.3.2.5)

Per velocity-guard BLOCKING-CONCERN on d81456dc (qitem-…304c8f1e): the
basename-flatten contract introduced two false-positive classes at the
scanner-visible boundary that defeated the truthfulness of routedCount:

(B1) duplicate-basename collisions: two declared paths a/onboarding.yaml
and b/onboarding.yaml both landed at <target>/onboarding.yaml. Both
records claimed status=routed + same installedAt, but only the second
file survived (silent overwrite). Scanner saw ONE spec, router claimed
TWO routed. Materially false at the scanner-visible boundary.

(B2) non-YAML suffix: declared path packages/test-pkg/workflows/readme.txt
would route as status=routed to <target>/readme.txt, but
spec-library-workflow-scanner.ts:390-397 filters to .yaml/.yml only via
isWorkflowYamlFile. Operator-invisible, router claimed success — same
false-positive shape as B1.

Both repaired at the router level (no /install handler change, no schema
change, contained to bundle-workflow-specs-router.ts):

- Added 4th status enum value "conflict" — distinct from unsafe/missing
  because the source was valid+present but lost the basename race.
- Non-YAML prefilter: `if (!/\.ya?ml$/i.test(declared))` → status=unsafe
  with explicit "scanner ignores non-YAML suffixes" detail. Rejects
  pre-write so routedCount stays truthful.
- Per-iteration routedBasenames Set; collision → status=conflict with
  explicit "basename collides with earlier declared path" detail. First
  wins (preserved); subsequent flagged. No silent overwrite.

Tests: 3 new + 1 doc-update:
- W9 router-unit: 2 paths sharing basename → first routed, second
  conflict, first content preserved (no silent overwrite)
- W10 router-unit: .txt rejected status=unsafe, .yaml accepted; .yml
  also accepted as suffix
- Integration test: bundle with 2 declared workflow_specs sharing
  basename "shared.yaml" → response routedCount=1, rejectedCount=1,
  records[1].status="conflict", target file contains FIRST declared
  source content ("content-A" not "content-B")
- RoutedWorkflowSpecRecord type doc upgraded for the new conflict
  status + the scanner-invisibility class for non-YAML

Discriminator-flips PROACTIVELY:

Flip A — remove collision detection (drop routedBasenames Set + the
conflict-return branch). Result: 2/55 fail — exactly W9 (router-unit
dup-basename) + the integration dup-basename test. The 8 other router
tests + 42 other integration tests stay green. Proves collision detection
specifically catches the duplicate-basename false-positive. Restored.

Flip B — remove non-YAML prefilter. Result: 1/10 fail — exactly W10.
The other 9 router tests stay green (the non-YAML rejection is a
separate code path from basename collision). Proves the prefilter
specifically catches the suffix-class false-positive. Restored.

Final: 173/173 across full slice-05 daemon suite (170 → +3: W9+W10
router-unit + integration dup-basename).

Seventh same-class catch on slice-05's tail. The d81456dc commit's
banked "PRE-handoff dogfood = grep live CONSUMER contract" discipline
was already applied to the scanner reachability proof. What it MISSED
was the basename-collision degeneracy + non-YAML-suffix degeneracy at
the router contract itself (the proof exercises one valid spec
end-to-end; doesn't probe degenerate inputs). Forward correction for
7.3f + 7.3g: PRE-handoff dogfood will explicitly include degenerate-
input probes (duplicate ids, non-target-shape entries, empty entries,
etc.) — not just the happy path through the live consumer.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-31 19:24:34 -07:00
1 parent d81456dc3d
commit 16ebb8af07
3 files changed
+188 -9

No files matched your search

@@ -67,10 +67,15 @@ export interface RouteWorkflowSpecsInput {
export interface RoutedWorkflowSpecRecord {
/** Declared path from manifest.workflow_specs[]. */
declaredPath: string;
/** "routed" = copied successfully; "missing" = source not in bundle;
* "unsafe" = source escapes bundleRoot OR target escapes
* targetWorkflowSpecsDir after leading-prefix strip. */
status: "routed" | "missing" | "unsafe";
/** "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). */
@@ -100,8 +105,26 @@ export function routeWorkflowSpecs(
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
@@ -139,11 +162,15 @@ export function routeWorkflowSpecs(
// the "target-side escape after prefix strip" hazard banked on the
// skills router (595e9550 B1 repair) is structurally impossible here.
//
// Basename collisions are an operator concern; two declared paths
// sharing the same filename will overwrite each other in declaration
// order. The scanner deduplicates by source_path + workflow.id; collision
// surfaces in the Library UI on next scan.
const targetAbs = nodePath.resolve(input.targetWorkflowSpecsDir, nodePath.basename(declared));
// 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)) {
@@ -154,8 +181,23 @@ export function routeWorkflowSpecs(
});
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",
@@ -1766,6 +1766,92 @@ describe("Bundle API routes", () => {
}
});
// Item 6 / Checkpoint 7.3e step 3 / guard B1 mirror at integration boundary:
// duplicate-basename declared paths must produce routedCount=1 + 1 conflict
// record, NOT 2 routed records claiming the same installedAt (the false-
// positive class guard caught on d81456dc).
it("POST /api/bundles/install duplicate-basename workflow_specs: routedCount=1, second flagged conflict (truthful routedCount)", async () => {
const origHome = process.env.OPENRIG_HOME;
const origSpecsRoot = process.env.OPENRIG_WORKSPACE_SPECS_ROOT;
const auditHome = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-wflow-dup-basename-"));
const specsRoot = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-wflow-dup-basename-target-"));
process.env.OPENRIG_HOME = auditHome;
process.env.OPENRIG_WORKSPACE_SPECS_ROOT = specsRoot;
const origBootstrap = setup.bootstrapOrchestrator.bootstrap.bind(setup.bootstrapOrchestrator);
try {
const { specPath } = seedPackage();
const bundlePath = path.join(tmpDir, "dup-basename.rigbundle");
const targetRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dup-basename-target-"));
try {
const wflowA = path.join(tmpDir, "test-pkg", "workflows-a");
const wflowB = path.join(tmpDir, "test-pkg", "workflows-b");
fs.mkdirSync(wflowA, { recursive: true });
fs.mkdirSync(wflowB, { recursive: true });
fs.writeFileSync(path.join(wflowA, "shared.yaml"), "content-A");
fs.writeFileSync(path.join(wflowB, "shared.yaml"), "content-B");
await app.request("/api/bundles/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ specPath, bundleName: "dup-basename", bundleVersion: "0.1.0", outputPath: bundlePath }),
});
const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), "dup-basename-stage-"));
try {
const tar = await import("tar");
await tar.extract({ file: bundlePath, cwd: stagingDir });
const bundleYamlPath = path.join(stagingDir, "bundle.yaml");
const original = fs.readFileSync(bundleYamlPath, "utf-8");
// Both declared paths share basename "shared.yaml"
const tampered = `${original}\nworkflow_specs:\n - packages/test-pkg/workflows-a/shared.yaml\n - packages/test-pkg/workflows-b/shared.yaml\n`;
fs.writeFileSync(bundleYamlPath, tampered);
const { pack } = await import("../src/domain/bundle-archive.js");
await pack(stagingDir, bundlePath);
} finally {
fs.rmSync(stagingDir, { recursive: true, force: true });
}
const stub = vi.fn().mockResolvedValue({
status: "completed",
runId: "test-run-dup-basename",
rigId: "01H000000000000000WDUPBN",
stages: [{ stage: "resolve_spec", status: "ok" }],
errors: [],
});
(setup.bootstrapOrchestrator as unknown as { bootstrap: typeof stub }).bootstrap = stub;
const installRes = await app.request("/api/bundles/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bundlePath, targetRoot, autoApprove: true }),
});
const body = await installRes.json();
expect(body.workflowSpecsRouting).toBeDefined();
// Truthful routedCount: only the first declared path was actually
// written. The second is flagged conflict, not silently overwritten.
expect(body.workflowSpecsRouting.routedCount).toBe(1);
expect(body.workflowSpecsRouting.rejectedCount).toBe(1);
expect(body.workflowSpecsRouting.records).toHaveLength(2);
expect(body.workflowSpecsRouting.records[0].status).toBe("routed");
expect(body.workflowSpecsRouting.records[1].status).toBe("conflict");
// Confirm the first content survived (no silent overwrite by 2nd)
const expectedTarget = path.join(specsRoot, "workflows", "shared.yaml");
expect(fs.existsSync(expectedTarget)).toBe(true);
expect(fs.readFileSync(expectedTarget, "utf-8")).toBe("content-A");
} finally {
fs.rmSync(targetRoot, { recursive: true, force: true });
}
} finally {
(setup.bootstrapOrchestrator as unknown as { bootstrap: typeof origBootstrap }).bootstrap = origBootstrap;
if (origHome === undefined) delete process.env.OPENRIG_HOME;
else process.env.OPENRIG_HOME = origHome;
if (origSpecsRoot === undefined) delete process.env.OPENRIG_WORKSPACE_SPECS_ROOT;
else process.env.OPENRIG_WORKSPACE_SPECS_ROOT = origSpecsRoot;
fs.rmSync(auditHome, { recursive: true, force: true });
fs.rmSync(specsRoot, { recursive: true, force: true });
}
});
it("POST /api/bundles/install does NOT include workflowSpecsRouting when bundle has no workflow_specs[]", async () => {
const origHome = process.env.OPENRIG_HOME;
const origSpecsRoot = process.env.OPENRIG_WORKSPACE_SPECS_ROOT;
@@ -178,4 +178,55 @@ describe("routeWorkflowSpecs", () => {
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");
});
});