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