feat(slice-05): bundle-workflow-specs-router module — Item 6 cross-primitive bundling part 4b (Checkpoint 7.3e step 2) (OPR.0.3.2.5)

Pure-function router module for the workflow_specs cross-primitive kind.
Mirrors the bundle-skills-router pattern (file-paths, single-file copy
preserving directory layout, leading-prefix strip "workflows/" instead of
"skills/") since workflow_specs is also a path-array kind per the PRD
schema. Both-sides path containment per banked
feedback_pre_existing_trust_boundary_reuse_canonical_helper addendum
(source under bundleRoot AND target under targetWorkflowSpecsDir, the
latter checked post leading-prefix strip).

bundle-workflow-specs-router.ts (+144):
- WorkflowSpecsRouterFsOps interface (exists/readFile/writeFile/mkdirp)
- RouteWorkflowSpecsInput + RoutedWorkflowSpecRecord + RouteWorkflowSpecsResult
  types
- routeWorkflowSpecs pure function with both-sides containment + status
  enum (routed/missing/unsafe)
- "workflows/" prefix strip for target-path mapping (analogue of "skills/"
  strip in bundle-skills-router); paths without the prefix route verbatim

bundle-workflow-specs-router.test.ts (+162): 8 tests W1-W8 mirroring the
skills router test pattern exactly:
- W1 empty list → mkdirp target + empty records
- W2 single spec routes end-to-end
- W3 multiple specs preserve directory layout
- W4 missing source → status=missing (honest-scoping)
- W5 source path escaping bundle workspace → status=unsafe
- W6 mixed routed/missing/unsafe aggregate
- W7 target-side containment: "workflows/../outside/spec.yaml" passes
  source check (resolves under bundleRoot) but escapes target after
  "workflows/" strip → status=unsafe (banked both-sides lesson exercised)
- W8 non-"workflows/" prefixed path routes verbatim

Discriminator-flip evidence (PROACTIVELY this time per committed
discriminator-first discipline): inserted an early
`return { records: [], routedCount: 0, rejectedCount: 0 };` after the
target mkdirp call but BEFORE the per-spec loop. Result: 7/8 fail —
exactly W2-W8 (all loop-dependent tests). W1 stays green because its
contract is empty-list-still-mkdirps-target which the no-op return
honors. This proves the loop body's containment + routing + missing-
handling contracts are EACH load-bearing for at least one test.
Restored, 25/25 green across all 3 router modules (skills 8 + plugins 9
+ workflow_specs 8).

No daemon dependencies in the router module; the /install integration
+ real-fs wrapper land at Checkpoint 7.3e step 3 with the B1-mirror
dual-override test PROACTIVELY (per the 5f410eee decoupling lesson the
skills + plugins integrations both required).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-31 18:54:34 -07:00
1 parent 1e4b229da0
commit 08a74ceacb
2 files changed
+306

No files matched your search

@@ -0,0 +1,144 @@
/**
* Bundle workflow_specs router (Item 6 / slice-05 Checkpoint 7.3e step 2).
*
* Pure function. Copies workflow spec YAML files declared in a bundle
* manifest's workflow_specs[] block from the bundle's extracted tree to the
* operator workflow-specs library. No daemon dependencies — fully
* unit-testable via FsOps injection.
*
* Mirrors the bundle-skills-router pattern (file-paths, single-file copy
* preserving directory layout, leading-prefix strip). Per orch-ratified
* Candidate A on Item-6 completeness: workflow_specs is the 3rd v0
* cross-primitive kind (after skills + plugins). Source primitive
* reachable on main (WorkflowSpecCache + workflow-runtime + scanner).
*
* Safety (banked feedback_pre_existing_trust_boundary_reuse_canonical_helper
* addendum): both-sides containment — declared source path must stay
* inside bundleRoot; resolved target path (after leading "workflows/"
* strip) must stay inside targetWorkflowSpecsDir. The leading-prefix
* strip can promote intermediate ../ segments that bypass source check
* but escape target.
*
* Honest-scoping: missing source files surface as warnings in the
* result (NOT thrown errors) so the install lifecycle can continue
* with what's available. Same as bundle-skills-router.
*
* /install handler integration lands at Checkpoint 7.3e step 3.
*/
import nodePath from "node:path";
/** Filesystem injection point — real impl wraps node:fs. Tests substitute in-memory. */
export interface WorkflowSpecsRouterFsOps {
exists: (path: string) => boolean;
readFile: (path: string) => string;
writeFile: (path: string, content: string) => void;
mkdirp: (path: string) => void;
}
/** Inputs to routeWorkflowSpecs. */
export interface RouteWorkflowSpecsInput {
/** Absolute path to the bundle's extracted root (tmp dir from unpack). */
bundleRoot: string;
/** Relative workflow_spec paths declared in the manifest's workflow_specs[] block. */
declaredWorkflowSpecs: string[];
/** Absolute path to the operator workflow-specs library (default ~/.openrig/workflow-specs). */
targetWorkflowSpecsDir: string;
}
/** One routed workflow_spec (or one rejection). */
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";
/** 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). */
detail?: string;
}
/** Aggregate routing result. */
export interface RouteWorkflowSpecsResult {
records: RoutedWorkflowSpecRecord[];
routedCount: number;
rejectedCount: number;
}
/**
* Route each declared workflow_spec from the bundle tree to the operator
* workflow-specs library. Per-entry safety: BOTH source path (under
* bundleRoot) AND target path (under targetWorkflowSpecsDir, post leading-
* prefix strip) are containment-checked. Banked both-sides-trust-boundary
* lesson applied. Caller writes the install audit record using the records
* returned here.
*/
export function routeWorkflowSpecs(
input: RouteWorkflowSpecsInput,
fs: WorkflowSpecsRouterFsOps,
): RouteWorkflowSpecsResult {
const records: RoutedWorkflowSpecRecord[] = [];
const bundleRootResolved = nodePath.resolve(input.bundleRoot);
const targetRootResolved = nodePath.resolve(input.targetWorkflowSpecsDir);
fs.mkdirp(input.targetWorkflowSpecsDir);
for (const declared of input.declaredWorkflowSpecs) {
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
// isRelativeSafePath but we re-check here in case the input bypassed
// validation upstream).
if (sourceAbs !== bundleRootResolved && !sourceAbs.startsWith(bundleRootResolved + nodePath.sep)) {
records.push({
declaredPath: declared,
status: "unsafe",
detail: `workflow_spec path '${declared}' escapes bundle workspace; rejected`,
});
continue;
}
if (!fs.exists(sourceAbs)) {
records.push({
declaredPath: declared,
status: "missing",
detail: `workflow_spec source '${declared}' not present in bundle; skipped`,
});
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.
if (targetAbs !== targetRootResolved && !targetAbs.startsWith(targetRootResolved + nodePath.sep)) {
records.push({
declaredPath: declared,
status: "unsafe",
detail: `workflow_spec target path for '${declared}' escapes target workflow-specs library; rejected`,
});
continue;
}
fs.mkdirp(nodePath.dirname(targetAbs));
const content = fs.readFile(sourceAbs);
fs.writeFile(targetAbs, content);
records.push({
declaredPath: declared,
status: "routed",
installedAt: targetAbs,
});
}
const routedCount = records.filter((r) => r.status === "routed").length;
const rejectedCount = records.length - routedCount;
return { records, routedCount, rejectedCount };
}
@@ -0,0 +1,162 @@
import { describe, it, expect } from "vitest";
import nodePath from "node:path";
import { routeWorkflowSpecs, type WorkflowSpecsRouterFsOps, type RouteWorkflowSpecsInput } from "../src/domain/bundle-workflow-specs-router.js";
// Item 6 / slice-05 Checkpoint 7.3e step 2: bundle-workflow-specs-router
// pure-function tests. Mirrors the bundle-skills-router test pattern with
// "workflows/" prefix and YAML file content shapes.
function mockFs(initialFiles: Record<string, string> = {}): WorkflowSpecsRouterFsOps & { _written: Map<string, string>; _mkdirpCalls: string[] } {
const written = new Map<string, string>(Object.entries(initialFiles));
const mkdirpCalls: string[] = [];
return {
_written: written,
_mkdirpCalls: mkdirpCalls,
exists: (p: string) => written.has(p),
readFile: (p: string) => {
const v = written.get(p);
if (v === undefined) throw new Error(`File not found in mock: ${p}`);
return v;
},
writeFile: (p: string, c: string) => { written.set(p, c); },
mkdirp: (p: string) => { mkdirpCalls.push(p); },
};
}
const BUNDLE_ROOT = "/bundle/root";
const TARGET = "/operator/.openrig/workflow-specs";
function makeInput(overrides?: Partial<RouteWorkflowSpecsInput>): RouteWorkflowSpecsInput {
return {
bundleRoot: BUNDLE_ROOT,
declaredWorkflowSpecs: [],
targetWorkflowSpecsDir: TARGET,
...overrides,
};
}
describe("routeWorkflowSpecs", () => {
// W1: empty list → empty records + target dir mkdirp'd
it("empty declaredWorkflowSpecs produces empty records but still mkdirp's target", () => {
const fs = mockFs();
const result = routeWorkflowSpecs(makeInput(), fs);
expect(result.records).toEqual([]);
expect(result.routedCount).toBe(0);
expect(result.rejectedCount).toBe(0);
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", () => {
const fs = mockFs({
[`${BUNDLE_ROOT}/workflows/onboarding.yaml`]: "name: onboarding\nversion: 1.0",
});
const result = routeWorkflowSpecs(makeInput({ declaredWorkflowSpecs: ["workflows/onboarding.yaml"] }), fs);
expect(result.routedCount).toBe(1);
expect(result.rejectedCount).toBe(0);
expect(result.records[0]!.status).toBe("routed");
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", () => {
const fs = mockFs({
[`${BUNDLE_ROOT}/workflows/onboarding.yaml`]: "yaml-1",
[`${BUNDLE_ROOT}/workflows/release.yaml`]: "yaml-2",
[`${BUNDLE_ROOT}/workflows/sub/maintenance.yaml`]: "yaml-3",
});
const result = routeWorkflowSpecs(
makeInput({
declaredWorkflowSpecs: ["workflows/onboarding.yaml", "workflows/release.yaml", "workflows/sub/maintenance.yaml"],
}),
fs,
);
expect(result.routedCount).toBe(3);
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");
});
// W4: missing source file → "missing" record (skipped, not error)
it("missing source file is skipped with status=missing (honest-scoping)", () => {
const fs = mockFs();
const result = routeWorkflowSpecs(
makeInput({ declaredWorkflowSpecs: ["workflows/absent.yaml"] }),
fs,
);
expect(result.routedCount).toBe(0);
expect(result.rejectedCount).toBe(1);
expect(result.records[0]!.status).toBe("missing");
expect(result.records[0]!.detail).toContain("not present");
});
// W5: unsafe source path escaping bundle workspace rejected
it("unsafe declared path (../traversal) escapes bundle workspace and is rejected", () => {
const fs = mockFs();
const result = routeWorkflowSpecs(
makeInput({ declaredWorkflowSpecs: ["../escape/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 bundle workspace");
});
// W6: mixed list — routed + missing + unsafe in one call
it("mixed declared list aggregates correctly across routed/missing/unsafe", () => {
const fs = mockFs({
[`${BUNDLE_ROOT}/workflows/ok.yaml`]: "ok",
});
const result = routeWorkflowSpecs(
makeInput({
declaredWorkflowSpecs: ["workflows/ok.yaml", "workflows/absent.yaml", "../escape/spec.yaml"],
}),
fs,
);
expect(result.records).toHaveLength(3);
expect(result.routedCount).toBe(1);
expect(result.rejectedCount).toBe(2);
expect(result.records[0]!.status).toBe("routed");
expect(result.records[1]!.status).toBe("missing");
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", () => {
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",
});
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(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", () => {
const fs = mockFs({
[`${BUNDLE_ROOT}/custom/path/spec.yaml`]: "custom",
});
const result = routeWorkflowSpecs(
makeInput({ declaredWorkflowSpecs: ["custom/path/spec.yaml"] }),
fs,
);
expect(result.routedCount).toBe(1);
expect(result.records[0]!.installedAt).toBe(`${TARGET}/custom/path/spec.yaml`);
});
});