mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
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 ond81456dc(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. Thed81456dccommit'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:
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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user