feat(slice-05): bundle-skills-router module — Item 6 cross-primitive bundling part 2 (Checkpoint 7.2) (OPR.0.3.2.5)

Pure-function module that copies skill files declared in a bundle
manifest's skills[] block from the bundle's extracted tree to the
operator skills library. No daemon dependencies — fully unit-testable
via FsOps injection mirroring the codebase pattern.

Safety: defense-in-depth path-containment check on each declared
skill path (mirrors bundle-source-resolver / install-time helper
B1-repair pattern). Skills outside the bundle workspace are rejected
with status=unsafe. Missing source files are reported with
status=missing (NOT thrown errors) so the install lifecycle can
continue — caller decides whether to abort.

What ships:
- New packages/daemon/src/domain/bundle-skills-router.ts (~115
  lines):
  - SkillsRouterFsOps DI (exists / readFile / writeFile / mkdirp)
  - RouteSkillsInput (bundleRoot + declaredSkills[] +
    targetSkillsDir)
  - RoutedSkillRecord (declaredPath + status: routed|missing|unsafe +
    optional installedAt + detail)
  - RouteSkillsResult (records[] + routedCount + rejectedCount)
  - routeSkills function with path-containment defense-in-depth,
    leading "skills/" prefix strip for cleaner operator-side layout,
    per-skill mkdirp + readFile + writeFile flow
- New packages/daemon/test/bundle-skills-router.test.ts: 7 unit tests
  covering empty input, single-skill, multi-skill layout, missing
  source skipped, unsafe path rejected, mixed-list aggregation,
  non-prefixed path verbatim

Diff: 2 new files. No existing files modified.

Verified:
- 7/7 new skills-router tests green
- Discriminator confirmed: disabling fs.writeFile in routeSkills
  causes 2/7 tests to fail (R2 single-skill + R3 multi-skill — the
  two tests that assert content via fs._written.get); R4/R5 status-
  only tests stay green for the same not-asserting-content reason;
  restored returns 7/7
- scope-and-versioning coherence grep clean
- audit-every-layer: pure module with FsOps DI; no module-level
  env/host/version reads

Pre-commit discipline applied: git diff --cached --stat verified
the 2 new files staged BEFORE commit.

Handoff-claim discipline applied (banked, post-incidents): ran the
discriminator-flip BEFORE writing claims about test coverage in this
commit body. The "tests cover happy path + missing + unsafe + mixed"
claim is now backed by verified test counts (7 tests with stated
assertions; discriminator-verified that content-write is exercised
by R2 + R3 specifically).

PRD bounce conditions verified:
- No schema bump (consumes optional manifest field from 0c13c7c6)
- No new SQLite migrations
- No event types
- No core-routing or bootstrap mutation
- No sidebar entries touched

What this commit does NOT ship (Checkpoint 7.3 — closes Item 6):
- /install handler invokes routeSkills after bootstrap completion
- Audit record extended with skills-routing records
- Plugin-bundling: manifest.plugins[] type extension + install routing
  via the existing 0.3.1 plugin install API
- gstack HYBRID proof case: small starter rig.yaml + bundle.yaml
  authoring (substrate-side) referencing existing 0.3.1 gstack-plugin
  via plugins[] kind per orch-ratified decision
- workflow_specs / context_packs / agent_images kinds (PRD says v0-
  if-library-reachable; library reachability check is part of 7.3)

Item 6 sub-chain:
- 0c13c7c6 (Item 6 part 1: skills[] manifest type layer, CLEAR) <- prior tip
- THIS COMMIT — Item 6 part 2: skills router pure function + tests
- Next (7.3) — /install integration + plugin-bundling + gstack proof

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-18 14:47:24 -07:00
1 parent 0c13c7c661
commit 9de91ad43a
2 files changed
+245

No files matched your search

@@ -0,0 +1,114 @@
/**
* Bundle skills router (Item 6 / slice-05 Checkpoint 7.2).
*
* Pure function. Copies skill files declared in a bundle manifest's skills[]
* block from the bundle's extracted tree to the operator skills library.
* No daemon dependencies — fully unit-testable via FsOps injection.
*
* Safety: each declared skill path is treated as untrusted manifest content;
* the helper rejects paths that escape the bundle tree (path containment
* check mirrors the bundle-source-resolver pattern). The output target
* directory is created via mkdirp.
*
* 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 for absent skills library: caller decides to skip-or-fail.
*
* /install handler integration lands at Checkpoint 7.3.
*/
import nodePath from "node:path";
/** Filesystem injection point — real impl wraps node:fs. Tests substitute in-memory. */
export interface SkillsRouterFsOps {
exists: (path: string) => boolean;
readFile: (path: string) => string;
writeFile: (path: string, content: string) => void;
mkdirp: (path: string) => void;
}
/** Inputs to routeSkills. */
export interface RouteSkillsInput {
/** Absolute path to the bundle's extracted root (tmp dir from unpack). */
bundleRoot: string;
/** Relative skill paths declared in the manifest's skills[] block. */
declaredSkills: string[];
/** Absolute path to the operator skills library (default ~/.openrig/skills). */
targetSkillsDir: string;
}
/** One routed skill (or one rejection). */
export interface RoutedSkillRecord {
/** Declared path from manifest.skills[]. */
declaredPath: string;
/** "routed" = copied successfully; "missing" = source not in bundle;
* "unsafe" = escapes bundle workspace; "no_library" = target dir not
* present and caller did not request creation (reserved for future
* library-reachability mode). */
status: "routed" | "missing" | "unsafe";
/** Where the skill 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 RouteSkillsResult {
records: RoutedSkillRecord[];
routedCount: number;
rejectedCount: number;
}
/**
* Route each declared skill from the bundle tree to the operator skills
* library. Per-skill safety: resolved source path must stay inside bundleRoot;
* skipped if missing from bundle. Caller writes the install audit record
* (Item 4 chain) using the records returned here.
*/
export function routeSkills(input: RouteSkillsInput, fs: SkillsRouterFsOps): RouteSkillsResult {
const records: RoutedSkillRecord[] = [];
const bundleRootResolved = nodePath.resolve(input.bundleRoot);
fs.mkdirp(input.targetSkillsDir);
for (const declared of input.declaredSkills) {
const sourceAbs = nodePath.resolve(input.bundleRoot, declared);
// Defense-in-depth path-containment (mirrors bundle-source-resolver 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: `skill path '${declared}' escapes bundle workspace; rejected`,
});
continue;
}
if (!fs.exists(sourceAbs)) {
records.push({
declaredPath: declared,
status: "missing",
detail: `skill source '${declared}' not present in bundle; skipped`,
});
continue;
}
// Target path mirrors the declared path under the target skills directory.
// Operator's skill library inherits the bundle's directory layout for the
// declared skills (e.g., skills/foo/SKILL.md → <target>/foo/SKILL.md);
// strip the leading "skills/" prefix if present so the target dir is the
// root of the operator's skill tree.
const declaredTrimmed = declared.startsWith("skills/") ? declared.slice("skills/".length) : declared;
const targetAbs = nodePath.resolve(input.targetSkillsDir, declaredTrimmed);
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,131 @@
import { describe, it, expect } from "vitest";
import { routeSkills, type SkillsRouterFsOps, type RouteSkillsInput } from "../src/domain/bundle-skills-router.js";
// Item 6 / slice-05 Checkpoint 7.2: bundle-skills-router pure-function tests.
function mockFs(initialFiles: Record<string, string> = {}): SkillsRouterFsOps & { _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/skills";
function makeInput(overrides?: Partial<RouteSkillsInput>): RouteSkillsInput {
return {
bundleRoot: BUNDLE_ROOT,
declaredSkills: [],
targetSkillsDir: TARGET,
...overrides,
};
}
describe("routeSkills", () => {
// R1: empty skills list → empty records + target dir mkdirp'd
it("empty declaredSkills produces empty records but still mkdirp's target", () => {
const fs = mockFs();
const result = routeSkills(makeInput(), fs);
expect(result.records).toEqual([]);
expect(result.routedCount).toBe(0);
expect(result.rejectedCount).toBe(0);
expect(fs._mkdirpCalls).toContain(TARGET);
});
// R2: routes one skill end-to-end
it("routes one skill: source file copied to target with installedAt populated", () => {
const fs = mockFs({
[`${BUNDLE_ROOT}/skills/foo/SKILL.md`]: "# foo skill body",
});
const result = routeSkills(makeInput({ declaredSkills: ["skills/foo/SKILL.md"] }), 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}/foo/SKILL.md`);
expect(fs._written.get(`${TARGET}/foo/SKILL.md`)).toBe("# foo skill body");
});
// R3: routes multiple skills; preserves directory layout
it("routes multiple skills preserving the per-skill directory layout under target", () => {
const fs = mockFs({
[`${BUNDLE_ROOT}/skills/foo/SKILL.md`]: "foo",
[`${BUNDLE_ROOT}/skills/bar/SKILL.md`]: "bar",
[`${BUNDLE_ROOT}/skills/bar/helper.md`]: "bar-helper",
});
const result = routeSkills(
makeInput({ declaredSkills: ["skills/foo/SKILL.md", "skills/bar/SKILL.md", "skills/bar/helper.md"] }),
fs,
);
expect(result.routedCount).toBe(3);
expect(fs._written.get(`${TARGET}/foo/SKILL.md`)).toBe("foo");
expect(fs._written.get(`${TARGET}/bar/SKILL.md`)).toBe("bar");
expect(fs._written.get(`${TARGET}/bar/helper.md`)).toBe("bar-helper");
});
// R4: missing source file produces "missing" record (skipped, not error)
it("missing source file is skipped with status=missing (honest-scoping)", () => {
const fs = mockFs(); // empty — no skill files
const result = routeSkills(
makeInput({ declaredSkills: ["skills/absent/SKILL.md"] }),
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");
});
// R5: unsafe path escaping bundle workspace is rejected
it("unsafe declared path (../traversal) escapes bundle workspace and is rejected", () => {
const fs = mockFs();
const result = routeSkills(
makeInput({ declaredSkills: ["../escape/SKILL.md"] }),
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");
});
// R6: mixed list — routed + missing + unsafe in one call
it("mixed declared list aggregates correctly across routed/missing/unsafe", () => {
const fs = mockFs({
[`${BUNDLE_ROOT}/skills/ok/SKILL.md`]: "ok",
});
const result = routeSkills(
makeInput({ declaredSkills: ["skills/ok/SKILL.md", "skills/absent/SKILL.md", "../escape/SKILL.md"] }),
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");
});
// R7: non-"skills/" prefixed declared path is honored as-is (no leading strip)
it("declared path without leading skills/ prefix routes verbatim", () => {
const fs = mockFs({
[`${BUNDLE_ROOT}/custom/path/X.md`]: "x",
});
const result = routeSkills(
makeInput({ declaredSkills: ["custom/path/X.md"] }),
fs,
);
expect(result.routedCount).toBe(1);
expect(result.records[0]!.installedAt).toBe(`${TARGET}/custom/path/X.md`);
});
});