mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
fix(slice-05): basename-flatten + valid YAML fixture + scanner-reachability proof (guard B1 repair on 9f9ebe0a) (OPR.0.3.2.5)
Per velocity-guard BLOCKING-CONCERN on9f9ebe0a(qitem-…04f93bfb): the scanner-reachability claim in my 7.3e step 3 handoff was false in two load-bearing ways: 1. SCANNER IS FLAT-FOLDER-ONLY. The router landed files at <specsRoot>/workflows/packages/test-pkg/workflows/onboarding.yaml (preserving bundle directory layout). The live spec-library-workflow-scanner reads ONLY top-level YAML via readdirSync(folder) + isFile() check — no recursion (verified at spec-library-workflow-scanner.ts:382-397). Nested specs invisible to the scanner and the Library UI → "routed" but operator dead-end. 2. FIXTURE YAML WAS NOT A VALID WORKFLOW SPEC. parseWorkflowSpec requires top-level workflow: key + workflow.id + workflow.version + non-empty workflow.steps + workflow.roles mapping (workflow-spec- cache.ts:58-112). My fixture wrote name/version/steps at root with empty steps[] — even if placed at top-level it would have become an error diagnostic row, NOT a valid cached workflow. Both problems mean "workflowSpecsRouting reports routedCount=1" was compatible with "the spec is invisible to the operator". Exactly the class of falseness thed43b7729contract-realignment was trying to prevent, one layer deeper. Fix has three pieces: (A) ROUTER: basename-flatten target computation. routeWorkflowSpecs now computes target as `nodePath.resolve(targetWorkflowSpecsDir, nodePath.basename(declared))` — every declared path lands at the top level of the scanner-reachable folder, regardless of its directory position in the bundle. SCANNER-REACHABILITY CONTRACT documented at the basename-resolve site explaining: scanner is top-level-only, nested would be invisible, basename enforces visibility. Side benefit: basename is structurally incapable of escaping the target dir (no separators in a leaf name), so the target-side prefix-strip escape hazard banked on bundle-skills-router (595e9550B1) is structurally impossible here — the previous target-containment check kept as a defensive sanity check but is now expected-always-true. (B) ROUTER UNIT TESTS: 4 tests updated for the new contract: - W2: declared "workflows/onboarding.yaml" → target/onboarding.yaml (basename, no longer preserves "workflows/" stripped form) - W3: 3 specs incl. "workflows/sub/maintenance.yaml" all land FLAT at target/<basename> — explicit assertion that the "sub/" path is NOT created - W7: target-escape attempt "workflows/../outside/spec.yaml" now lands SAFELY at target/spec.yaml (basename structurally prevents escape); test renamed + redocumented. Replaces the prior banked both-sides-trust-boundary rejection (the prior contract had the escape hazard via leading-prefix strip; basename obsoletes both the strip AND the hazard). - W8: non-prefixed "custom/path/spec.yaml" lands at target/spec.yaml (basename; prefix-strip is now uniform/vestigial) (C) INTEGRATION TEST: basic with-workflow_specs test now uses a VALID workflow YAML fixture (workflow.id="bundle-routed-test", workflow.version= "1", workflow.roles mapping, non-empty workflow.steps) mirroring workflow-spec-folder-scanner.test.ts:22-43, and includes a real SCANNER-REACHABILITY PROOF: after /install completes, instantiates a fresh WorkflowSpecCache + invokes scanWorkflowSpecFolder against the target dir; asserts scanResult.valid===1 + scanResult.errors===0 + the cached row matches the fixture's workflow.id + version. This is the end-to-end proof guard's fix-recipe required — no longer just "file copied" but "scanner found+cached as valid workflow". The B1-mirror dual-override test target updated to match basename behavior (dualflow.yaml lands at <specsRoot>/workflows/dualflow.yaml, not the prior nested path). The null-branch test unchanged (extras- key absence proof is unaffected by routing semantics). Discriminator-flips kept proving the same contracts as before — neither was invalidated by the basename change. Both A (helper returns null → 2/44 fail) and B (gate on installMeta → 1/44 fail) still behave as predicted in the9f9ebe0ahandoff. Final: 170/170 across full slice-05 daemon suite. Sixth same-class contract-claim incident on slice-05's tail; the discriminator-first + PRE-handoff-dogfood discipline I committed to needs ALSO to grep the live consumer contract (not just the resolved target path). Banked discipline extension for 7.3f + 7.3g + 7.4. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
9f9ebe0a51
commit
d81456dc3d
3 files changed
+132
-48
No files matched your search
@@ -123,20 +123,29 @@ export function routeWorkflowSpecs(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Target path mirrors the declared path under the target workflow-specs
|
||||
// directory. Strip the leading "workflows/" prefix if present so the
|
||||
// target dir is the root of the operator's workflow-specs tree (analogue
|
||||
// of the skills router "skills/" strip).
|
||||
const declaredTrimmed = declared.startsWith("workflows/") ? declared.slice("workflows/".length) : declared;
|
||||
const targetAbs = nodePath.resolve(input.targetWorkflowSpecsDir, declaredTrimmed);
|
||||
// Defense-in-depth path-containment on TARGET (banked both-sides-of-
|
||||
// trust-boundary lesson; mirror of bundle-skills-router B1 repair
|
||||
// 595e9550). Leading prefix strip can promote a relative segment that
|
||||
// looks safe under bundleRoot (e.g. "workflows/../outside/spec.yaml"
|
||||
// passes source-containment because bundleRoot may contain
|
||||
// "outside/spec.yaml", but stripping yields "../outside/spec.yaml" which
|
||||
// would escape targetWorkflowSpecsDir). Reject if the target resolves
|
||||
// outside the target library.
|
||||
// Target = top-level basename under targetWorkflowSpecsDir.
|
||||
//
|
||||
// SCANNER-REACHABILITY CONTRACT: spec-library-workflow-scanner reads
|
||||
// ONLY top-level YAML files via readdirSync(folder) + isFile() (see
|
||||
// spec-library-workflow-scanner.ts:382-397) — no recursion. Nested
|
||||
// YAML inside subdirs is INVISIBLE to the scanner and Library UI.
|
||||
// Workflow specs MUST land at the top of <workspace.specs_root>/workflows
|
||||
// to become operator-visible. Basename-only target computation enforces
|
||||
// this; preserving the bundle's directory layout would silently route
|
||||
// specs to an unscanned location.
|
||||
//
|
||||
// Side benefit: basename can never escape targetWorkflowSpecsDir
|
||||
// (nodePath.basename(any-path) is a leaf name with no separators), so
|
||||
// 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));
|
||||
// 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)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
@@ -145,7 +154,6 @@ export function routeWorkflowSpecs(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
fs.mkdirp(nodePath.dirname(targetAbs));
|
||||
const content = fs.readFile(sourceAbs);
|
||||
fs.writeFile(targetAbs, content);
|
||||
records.push({
|
||||
|
||||
@@ -1536,9 +1536,11 @@ describe("Bundle API routes", () => {
|
||||
// Item 6 / Checkpoint 7.3e step 3: /install routes declared workflow_specs
|
||||
// after successful bootstrap. Target = SettingsStore-resolved
|
||||
// <workspaceSpecsRoot>/workflows. Bundle has workflow_specs[] with a path
|
||||
// pointing to a workflow YAML file in the bundle tree. Verifies the file
|
||||
// lands at <env-overridden specs root>/workflows/<basename>.
|
||||
it("POST /api/bundles/install routes declared workflow_specs after successful bootstrap (completed branch)", async () => {
|
||||
// pointing to a workflow YAML file in the bundle tree. Router lands the
|
||||
// file at top-level basename under <specsRoot>/workflows. Scanner-
|
||||
// reachability proven via a real scanWorkflowSpecFolder call against the
|
||||
// target dir (guard d43b7729 + 9f9ebe0a scanner-reachability lesson).
|
||||
it("POST /api/bundles/install routes declared workflow_specs after successful bootstrap (completed branch) — proves scanner-reachability", async () => {
|
||||
const origHome = process.env.OPENRIG_HOME;
|
||||
const origSpecsRoot = process.env.OPENRIG_WORKSPACE_SPECS_ROOT;
|
||||
const auditHome = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-workflow-specs-route-test-"));
|
||||
@@ -1551,10 +1553,36 @@ describe("Bundle API routes", () => {
|
||||
const bundlePath = path.join(tmpDir, "with-workflow-specs.rigbundle");
|
||||
const targetRoot = fs.mkdtempSync(path.join(os.tmpdir(), "wflow-target-"));
|
||||
try {
|
||||
// Put a workflow_spec YAML inside the bundle's package source
|
||||
// Valid workflow YAML fixture mirroring workflow-spec-folder-scanner.test.ts:22-43
|
||||
// (workflow.id + workflow.version + workflow.roles + workflow.steps non-empty).
|
||||
// Without these the spec-library-workflow-scanner records it as an error
|
||||
// diagnostic row, not a valid workflow — defeating the scanner-reachability proof.
|
||||
const validWorkflowYaml = `workflow:
|
||||
id: bundle-routed-test
|
||||
version: '1'
|
||||
objective: A bundle-routed workflow fixture
|
||||
target:
|
||||
rig: test-fixture
|
||||
entry:
|
||||
role: producer
|
||||
roles:
|
||||
producer:
|
||||
preferred_targets:
|
||||
- producer@test-fixture
|
||||
steps:
|
||||
- id: produce
|
||||
actor_role: producer
|
||||
objective: Draft.
|
||||
allowed_exits:
|
||||
- done
|
||||
invariants:
|
||||
allowed_exits:
|
||||
- done
|
||||
`;
|
||||
// Put the valid YAML inside the bundle's package source
|
||||
const workflowSrcDir = path.join(tmpDir, "test-pkg", "workflows");
|
||||
fs.mkdirSync(workflowSrcDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(workflowSrcDir, "onboarding.yaml"), "name: onboarding\nversion: '1.0'\nsteps: []\n");
|
||||
fs.writeFileSync(path.join(workflowSrcDir, "onboarding.yaml"), validWorkflowYaml);
|
||||
|
||||
await app.request("/api/bundles/create", {
|
||||
method: "POST",
|
||||
@@ -1568,9 +1596,8 @@ describe("Bundle API routes", () => {
|
||||
await tar.extract({ file: bundlePath, cwd: stagingDir });
|
||||
const bundleYamlPath = path.join(stagingDir, "bundle.yaml");
|
||||
const original = fs.readFileSync(bundleYamlPath, "utf-8");
|
||||
// Declare the verbatim package path (no leading "workflows/"), mirror of
|
||||
// the skills-test pattern at line 1258. File is already in the bundle via
|
||||
// the test-pkg source; integrity manifest unchanged.
|
||||
// Declared path can be the verbatim package path; the router uses
|
||||
// basename() to land it at top-level (scanner-reachability contract).
|
||||
const tampered = `${original}\nworkflow_specs:\n - packages/test-pkg/workflows/onboarding.yaml\n`;
|
||||
fs.writeFileSync(bundleYamlPath, tampered);
|
||||
const { pack } = await import("../src/domain/bundle-archive.js");
|
||||
@@ -1597,11 +1624,49 @@ describe("Bundle API routes", () => {
|
||||
expect(body.workflowSpecsRouting).toBeDefined();
|
||||
expect(body.workflowSpecsRouting.routedCount).toBe(1);
|
||||
expect(body.workflowSpecsRouting.records[0].status).toBe("routed");
|
||||
// Declared path has no leading "workflows/" so routes verbatim;
|
||||
// file lands at <specsRoot>/workflows/<verbatim-path>
|
||||
const expectedTarget = path.join(specsRoot, "workflows", "packages", "test-pkg", "workflows", "onboarding.yaml");
|
||||
// Router uses basename(): declared "packages/test-pkg/workflows/onboarding.yaml"
|
||||
// → lands at <specsRoot>/workflows/onboarding.yaml (top-level only).
|
||||
const expectedTarget = path.join(specsRoot, "workflows", "onboarding.yaml");
|
||||
expect(fs.existsSync(expectedTarget)).toBe(true);
|
||||
expect(fs.readFileSync(expectedTarget, "utf-8")).toBe("name: onboarding\nversion: '1.0'\nsteps: []\n");
|
||||
expect(fs.readFileSync(expectedTarget, "utf-8")).toBe(validWorkflowYaml);
|
||||
|
||||
// Scanner-reachability PROOF: invoke the real scanWorkflowSpecFolder
|
||||
// against the target dir with a fresh WorkflowSpecCache; assert at
|
||||
// least one valid (non-diagnostic) cached spec matching the fixture
|
||||
// id+version. This proves the routed file is operator-visible via
|
||||
// the live Library scan path (the d43b7729 / 9f9ebe0a contract).
|
||||
const { createDb } = await import("../src/db/connection.js");
|
||||
const { migrate } = await import("../src/db/migrate.js");
|
||||
const { coreSchema } = await import("../src/db/migrations/001_core_schema.js");
|
||||
const { eventsSchema } = await import("../src/db/migrations/003_events.js");
|
||||
const { workflowSpecsSchema } = await import("../src/db/migrations/033_workflow_specs.js");
|
||||
const { workflowSpecsDiagnosticSchema } = await import("../src/db/migrations/040_workflow_specs_diagnostic.js");
|
||||
const { WorkflowSpecCache } = await import("../src/domain/workflow-spec-cache.js");
|
||||
const { scanWorkflowSpecFolder } = await import("../src/domain/spec-library-workflow-scanner.js");
|
||||
const scanDb = createDb();
|
||||
migrate(scanDb, [coreSchema, eventsSchema, workflowSpecsSchema, workflowSpecsDiagnosticSchema]);
|
||||
const scanCache = new WorkflowSpecCache(scanDb);
|
||||
try {
|
||||
const scanResult = scanWorkflowSpecFolder({
|
||||
db: scanDb,
|
||||
cache: scanCache,
|
||||
folder: path.join(specsRoot, "workflows"),
|
||||
builtinDir: null,
|
||||
});
|
||||
// The fixture is a VALID workflow spec (workflow.id + version + roles +
|
||||
// steps); scanner caches it as `valid`, not `errors`.
|
||||
expect(scanResult.scanned).toBe(1);
|
||||
expect(scanResult.valid).toBe(1);
|
||||
expect(scanResult.errors).toBe(0);
|
||||
// Confirm the cached row matches the fixture's workflow.id + version.
|
||||
const cached = scanDb.prepare(`SELECT name, version FROM workflow_specs ORDER BY name, version`).all() as Array<{ name: string; version: string }>;
|
||||
expect(cached.length).toBeGreaterThanOrEqual(1);
|
||||
const found = cached.find((r) => r.name === "bundle-routed-test");
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.version).toBe("1");
|
||||
} finally {
|
||||
scanDb.close();
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(targetRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1683,7 +1748,8 @@ describe("Bundle API routes", () => {
|
||||
expect(body.workflowSpecsRouting).toBeDefined();
|
||||
expect(body.workflowSpecsRouting.routedCount).toBe(1);
|
||||
expect(body.workflowSpecsRouting.records[0].status).toBe("routed");
|
||||
const expectedTarget = path.join(specsRoot, "workflows", "packages", "test-pkg", "workflows", "dualflow.yaml");
|
||||
// Router uses basename() → top-level under <specsRoot>/workflows
|
||||
const expectedTarget = path.join(specsRoot, "workflows", "dualflow.yaml");
|
||||
expect(fs.existsSync(expectedTarget)).toBe(true);
|
||||
expect(fs.readFileSync(expectedTarget, "utf-8")).toBe("name: dualflow\nversion: '2.0'\nsteps: []\n");
|
||||
} finally {
|
||||
|
||||
@@ -55,8 +55,8 @@ describe("routeWorkflowSpecs", () => {
|
||||
expect(fs._mkdirpCalls).toContain(TARGET);
|
||||
});
|
||||
|
||||
// W2: routes one spec end-to-end
|
||||
it("routes one workflow_spec: source YAML copied to target with installedAt populated", () => {
|
||||
// W2: routes one spec end-to-end at top-level basename
|
||||
it("routes one workflow_spec: source YAML copied to target/<basename> with installedAt populated", () => {
|
||||
const fs = mockFs({
|
||||
[`${BUNDLE_ROOT}/workflows/onboarding.yaml`]: "name: onboarding\nversion: 1.0",
|
||||
});
|
||||
@@ -64,12 +64,15 @@ describe("routeWorkflowSpecs", () => {
|
||||
expect(result.routedCount).toBe(1);
|
||||
expect(result.rejectedCount).toBe(0);
|
||||
expect(result.records[0]!.status).toBe("routed");
|
||||
// Basename-only: declared "workflows/onboarding.yaml" → target/onboarding.yaml
|
||||
// (scanner-reachability contract: spec-library-workflow-scanner reads only
|
||||
// top-level YAML; nested paths would be invisible).
|
||||
expect(result.records[0]!.installedAt).toBe(`${TARGET}/onboarding.yaml`);
|
||||
expect(fs._written.get(`${TARGET}/onboarding.yaml`)).toBe("name: onboarding\nversion: 1.0");
|
||||
});
|
||||
|
||||
// W3: routes multiple specs; preserves directory layout
|
||||
it("routes multiple workflow_specs preserving the directory layout under target", () => {
|
||||
// W3: routes multiple specs ALL FLAT at top level (basename collapses layout)
|
||||
it("routes multiple workflow_specs all flat at top level (basename collapses directory layout)", () => {
|
||||
const fs = mockFs({
|
||||
[`${BUNDLE_ROOT}/workflows/onboarding.yaml`]: "yaml-1",
|
||||
[`${BUNDLE_ROOT}/workflows/release.yaml`]: "yaml-2",
|
||||
@@ -82,9 +85,13 @@ describe("routeWorkflowSpecs", () => {
|
||||
fs,
|
||||
);
|
||||
expect(result.routedCount).toBe(3);
|
||||
// All land at top-level basename — the "sub/" prefix on entry 3 is stripped
|
||||
// by basename(). This matches scanner-reachability (readdirSync + isFile only).
|
||||
expect(fs._written.get(`${TARGET}/onboarding.yaml`)).toBe("yaml-1");
|
||||
expect(fs._written.get(`${TARGET}/release.yaml`)).toBe("yaml-2");
|
||||
expect(fs._written.get(`${TARGET}/sub/maintenance.yaml`)).toBe("yaml-3");
|
||||
expect(fs._written.get(`${TARGET}/maintenance.yaml`)).toBe("yaml-3");
|
||||
// Confirm the would-be-nested path is NOT created (basename flattened it).
|
||||
expect(fs._written.has(`${TARGET}/sub/maintenance.yaml`)).toBe(false);
|
||||
});
|
||||
|
||||
// W4: missing source file → "missing" record (skipped, not error)
|
||||
@@ -132,32 +139,33 @@ describe("routeWorkflowSpecs", () => {
|
||||
expect(result.records[2]!.status).toBe("unsafe");
|
||||
});
|
||||
|
||||
// W7-B1: target-side path containment (banked both-sides-trust-boundary).
|
||||
// Declared path "workflows/../outside/spec.yaml" passes SOURCE containment
|
||||
// (resolves under bundleRoot since the leading "workflows/" segment is
|
||||
// consumed before ../) but after the leading "workflows/" strip becomes
|
||||
// "../outside/spec.yaml" which would escape targetWorkflowSpecsDir.
|
||||
it("declared path that would escape target workflow-specs dir after prefix strip is rejected", () => {
|
||||
// W7: target-side escape attempt lands SAFELY at basename (structural
|
||||
// containment via basename — replaces the prior prefix-strip target-escape
|
||||
// hazard from the skills router; basename guarantees no traversal).
|
||||
it("target-escape attempt via traversal still lands safely at basename (structural containment)", () => {
|
||||
const fs = mockFs({
|
||||
// Source is reachable from bundleRoot via "workflows/../outside/spec.yaml"
|
||||
// which resolves to "<bundleRoot>/outside/spec.yaml" — passes source check.
|
||||
[`${BUNDLE_ROOT}/outside/spec.yaml`]: "would-escape-target",
|
||||
[`${BUNDLE_ROOT}/outside/spec.yaml`]: "would-have-escaped-target",
|
||||
});
|
||||
const result = routeWorkflowSpecs(
|
||||
makeInput({ declaredWorkflowSpecs: ["workflows/../outside/spec.yaml"] }),
|
||||
fs,
|
||||
);
|
||||
expect(result.routedCount).toBe(0);
|
||||
expect(result.rejectedCount).toBe(1);
|
||||
expect(result.records[0]!.status).toBe("unsafe");
|
||||
expect(result.records[0]!.detail).toContain("escapes target workflow-specs library");
|
||||
// Crucially: no write happened outside target.
|
||||
expect(result.routedCount).toBe(1);
|
||||
expect(result.rejectedCount).toBe(0);
|
||||
expect(result.records[0]!.status).toBe("routed");
|
||||
// basename("workflows/../outside/spec.yaml") = "spec.yaml" → lands safely.
|
||||
expect(result.records[0]!.installedAt).toBe(`${TARGET}/spec.yaml`);
|
||||
expect(fs._written.get(`${TARGET}/spec.yaml`)).toBe("would-have-escaped-target");
|
||||
// Confirm: no write outside target dir.
|
||||
expect(fs._written.has(`${TARGET}/../outside/spec.yaml`)).toBe(false);
|
||||
expect(fs._written.has(nodePath.resolve(`${TARGET}/../outside/spec.yaml`))).toBe(false);
|
||||
});
|
||||
|
||||
// W8: non-"workflows/" prefixed declared path is honored as-is (no leading strip)
|
||||
it("declared path without leading workflows/ prefix routes verbatim", () => {
|
||||
// W8: non-prefixed declared path lands at its basename (prefix-strip is
|
||||
// obsoleted by basename — the rule is uniform).
|
||||
it("declared path without any leading prefix routes to target/<basename>", () => {
|
||||
const fs = mockFs({
|
||||
[`${BUNDLE_ROOT}/custom/path/spec.yaml`]: "custom",
|
||||
});
|
||||
@@ -166,6 +174,8 @@ describe("routeWorkflowSpecs", () => {
|
||||
fs,
|
||||
);
|
||||
expect(result.routedCount).toBe(1);
|
||||
expect(result.records[0]!.installedAt).toBe(`${TARGET}/custom/path/spec.yaml`);
|
||||
// basename("custom/path/spec.yaml") = "spec.yaml" → top-level under target.
|
||||
expect(result.records[0]!.installedAt).toBe(`${TARGET}/spec.yaml`);
|
||||
expect(fs._written.has(`${TARGET}/custom/path/spec.yaml`)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user