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