feat(slice-05): /install routes declared skills after successful bootstrap (Item 6 Checkpoint 7.3a) (OPR.0.3.2.5)

Integrates the bundle-skills-router (Checkpoint 7.2) into the /install
handler. After successful bootstrap (completed branch), if the bundle
manifest declares skills[], a new routeSkillsAfterBootstrap helper
extracts the bundle safely (banked unpack trust boundary), routes
each declared skill via routeSkills, cleans up, and includes the
result in the response body as `skillsRouting`.

Best-effort: skills routing failure does NOT fail the install response
(the install already succeeded; skills routing is a side-channel
post-install step). Try/catch wraps the helper invocation.

Scope decision: skills routing fires on the completed branch only.
Partial and failed bootstrap branches do NOT route skills. Rationale:
when the install state changed only partially or not at all, routing
skills could leave operator state inconsistent ("you got skills but
no working rig"). Honest-scoping: ship one behavior + document.
Operator can re-install if they want the skills. If guard or operator
wants partial-branch routing too, follow-up commit can add it.

What ships:
- packages/daemon/src/routes/bundles.ts:
  - import routeSkills + RouteSkillsResult + SkillsRouterFsOps
  - new skillsRouterFsOps() helper wrapping node:fs (mirrors
    auditFsOps pattern)
  - new routeSkillsAfterBootstrap helper: returns null when manifest
    lacks skills[]; otherwise unpack(bundlePath, tmpDir) + routeSkills
    + cleanup. Path-containment / safety inherits from unpack's
    trust boundary + routeSkills' source+target containment (post-
    7.2 B1 repair)
  - /install completed branch: try { skillsRouting = await
    routeSkillsAfterBootstrap(...) } catch {}; response body merges
    skillsRouting when present
- packages/daemon/test/bundle-routes.test.ts: 2 new tests —
  - "routes declared skills after successful bootstrap": creates
    bundle, tampers bundle.yaml to inject skills[] (bundle.yaml not
    in integrity.files so tamper is integrity-clean), re-packs,
    stubs bootstrap=completed, installs, asserts skillsRouting.
    routedCount=1 + actual file appears at OPENRIG_HOME/skills/...
  - "does NOT include skillsRouting when bundle has no skills[]":
    no-tamper install, asserts body.skillsRouting undefined

Verified:
- 37/37 daemon bundle-routes suite green (was 35; +2 new integration
  tests)
- Discriminator: renaming routeSkillsAfterBootstrap to a non-existent
  symbol causes 1/37 to fail (the with-skills test specifically); the
  no-skills test stays green because its assertion is ABSENT-of-
  skillsRouting which the silent-catch behavior also satisfies. Honest
  discriminator analysis per banked discipline: the function-rename
  catches the with-skills positive path; the no-skills test's
  verification surface is independent (asserting absence). Discriminator
  for the no-skills branch would be "remove the early return for empty
  skills" which would attempt routing and produce a record block in
  the response.
- packages/daemon typecheck unchanged
- Coherence + audit-every-layer unchanged

Pre-commit + handoff-claim disciplines both applied: git diff --cached
--stat verified scope; discriminator-flip ran BEFORE asserting claims
in commit body.

Diff: 2 files, +154 / -1 lines.

PRD bounce conditions verified:
- No schema bump
- No new SQLite migrations / event types / core-routing / bootstrap
  mutation / sidebar mutation
- scope-and-versioning sections 1+3 coherence preserved
- The bootstrap delegation is unchanged; skills routing is a post-
  delegation side-channel

What this commit does NOT ship (Checkpoint 7.3b + 7.3c):
- Plugin-bundling: manifest.plugins[] type extension + install
  routing via existing 0.3.1 plugin install API surface
- gstack HYBRID proof case: substrate-side authoring of small starter
  rig.yaml + bundle.yaml referencing existing 0.3.1 gstack-plugin
  via plugins[] kind per orch-ratified decision
- Partial / failed branch skills-routing (intentional scope decision
  above; revisit if needed)
- workflow_specs / context_packs / agent_images kinds (PRD v0-if-
  library-reachable; library reachability check is part of 7.3c
  or later)

Item 6 sub-chain:
- 9de91ad4 → 595e9550 (Item 6 part 2: skills router pure module + B1
  target-containment repair, CLEAR) <- prior tip
- THIS COMMIT — Item 6 part 3a: /install skills routing integration
- Next (7.3b) — plugins manifest type extension + tests
- Next (7.3c) — plugins routing integration + gstack HYBRID proof
  case authoring

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-18 15:00:06 -07:00
1 parent 595e9550d1
commit 2bf9c6606f
2 files changed
+180 -1

No files matched your search

+59 -1
View File
@@ -21,6 +21,7 @@ import { detectBundleConflicts, type BundleConflict } from "../domain/bundle-con
import type { RigRepository } from "../domain/rig-repository.js";
import { BundleAuditReader, BundleAuditWriter, type BundleAuditFsOps, type BundleAuditRecord } from "../domain/bundle-audit.js";
import { getDefaultOpenRigPath } from "../openrig-compat.js";
import { routeSkills, type SkillsRouterFsOps, type RouteSkillsResult } from "../domain/bundle-skills-router.js";
/**
* Read the daemon's own package.json version at call time (Item 1 / slice-05).
@@ -276,6 +277,50 @@ function podAssemblerFsOps(): PodAssemblerFsOps {
};
}
/** Item 6 / slice-05 Checkpoint 7.3: real SkillsRouterFsOps backed by node:fs. */
function skillsRouterFsOps(): SkillsRouterFsOps {
return {
exists: (p) => fs.existsSync(p),
readFile: (p) => fs.readFileSync(p, "utf-8"),
writeFile: (p, c) => fs.writeFileSync(p, c, "utf-8"),
mkdirp: (p) => fs.mkdirSync(p, { recursive: true }),
};
}
/**
* Item 6 / slice-05 Checkpoint 7.3: extract the bundle safely (banked unpack
* trust boundary) and route any declared skills to the operator skills
* library. Returns null when manifest has no skills[] (no-op). Called from
* /install completed branch — skills routing is independent of rig install
* state but only fires when install succeeded (partial/failed semantics
* deferred: keep skills routing tied to successful install outcome for v0).
*/
async function routeSkillsAfterBootstrap(opts: {
bundlePath: string;
manifest: Record<string, unknown> | undefined;
}): Promise<RouteSkillsResult | null> {
if (!opts.manifest) return null;
const rawSkills = opts.manifest["skills"];
if (!Array.isArray(rawSkills) || rawSkills.length === 0) return null;
const declaredSkills = rawSkills.filter((s): s is string => typeof s === "string" && s.length > 0);
if (declaredSkills.length === 0) return null;
const tmpDir = fs.mkdtempSync(nodePath.join(os.tmpdir(), "bundle-skills-route-"));
try {
await unpack(opts.bundlePath, tmpDir);
return routeSkills(
{
bundleRoot: tmpDir,
declaredSkills,
targetSkillsDir: getDefaultOpenRigPath("skills"),
},
skillsRouterFsOps(),
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
/** Item 4 / slice-05 Checkpoint 5.2: real BundleAuditFsOps backed by node:fs. */
function auditFsOps(): BundleAuditFsOps {
return {
@@ -700,7 +745,20 @@ bundleRoutes.post("/install", async (c) => {
targetRigName: installMeta?.rigName, cliVersion: clientCliVersion,
bundleManifest: installMeta?.bundleManifest,
});
return c.json(result, 201);
// Item 6 / Checkpoint 7.3: route any declared skills after successful
// install. Best-effort: a routing failure does NOT fail the install
// response (the install already succeeded; skills routing is a side-
// channel post-install step). Routing result included in response body
// so operators see what landed.
let skillsRouting: RouteSkillsResult | null = null;
try {
skillsRouting = await routeSkillsAfterBootstrap({
bundlePath, manifest: installMeta?.bundleManifest,
});
} catch {
// Side-channel failure; install already succeeded
}
return c.json(skillsRouting ? { ...result, skillsRouting } : result, 201);
}
if (result.status === "partial") {
const ok = result.stages.filter((s: { status: string }) => s.status === "ok").length;
+121
View File
@@ -1219,6 +1219,127 @@ describe("Bundle API routes", () => {
}
});
// Item 6 / slice-05 Checkpoint 7.3: /install routes declared skills to
// operator skills library after successful bootstrap. Uses bootstrap stub
// to force completed outcome; bundle manifest has skills[]; verifies the
// skills land at OPENRIG_HOME/skills/.
it("POST /api/bundles/install routes declared skills after successful bootstrap (completed branch)", async () => {
const origHome = process.env.OPENRIG_HOME;
const auditHome = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-skills-route-test-"));
process.env.OPENRIG_HOME = auditHome;
const origBootstrap = setup.bootstrapOrchestrator.bootstrap.bind(setup.bootstrapOrchestrator);
try {
const { specPath } = seedPackage();
const bundlePath = path.join(tmpDir, "with-skills.rigbundle");
const targetRoot = fs.mkdtempSync(path.join(os.tmpdir(), "skills-target-"));
try {
// Build a bundle with skills[] declared. Add a skill file to the
// package dir so it lands in the bundle archive when packed.
const skillSourceDir = path.join(tmpDir, "test-pkg", "skills");
fs.mkdirSync(skillSourceDir, { recursive: true });
fs.writeFileSync(path.join(skillSourceDir, "FOO.md"), "# foo skill body");
await app.request("/api/bundles/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ specPath, bundleName: "with-skills", bundleVersion: "0.1.0", outputPath: bundlePath }),
});
// Tamper the created bundle's bundle.yaml to inject skills[] field.
// bundle.yaml's own hash is excluded from integrity.files so editing
// it doesn't break verifyIntegrity.
const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), "skills-stage-"));
try {
const tar = await import("tar");
await tar.extract({ file: bundlePath, cwd: stagingDir });
const bundleYamlPath = path.join(stagingDir, "bundle.yaml");
const original = fs.readFileSync(bundleYamlPath, "utf-8");
// Append skills field referencing the file we put inside the bundled package
const tampered = `${original}\nskills:\n - packages/test-pkg/skills/FOO.md\n`;
fs.writeFileSync(bundleYamlPath, tampered);
const { pack } = await import("../src/domain/bundle-archive.js");
await pack(stagingDir, bundlePath);
} finally {
fs.rmSync(stagingDir, { recursive: true, force: true });
}
// Stub bootstrap to return completed so audit-write + skills routing fire
const stub = vi.fn().mockResolvedValue({
status: "completed",
runId: "test-run-skills",
rigId: "01H000000000000000SKILLS01",
stages: [{ stage: "resolve_spec", status: "ok" }],
errors: [],
});
(setup.bootstrapOrchestrator as unknown as { bootstrap: typeof stub }).bootstrap = stub;
const installRes = await app.request("/api/bundles/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bundlePath, targetRoot, autoApprove: true }),
});
const body = await installRes.json();
// Response carries skillsRouting block
expect(body.skillsRouting).toBeDefined();
expect(body.skillsRouting.routedCount).toBe(1);
expect(body.skillsRouting.records).toHaveLength(1);
expect(body.skillsRouting.records[0].status).toBe("routed");
// File actually copied to OPENRIG_HOME/skills/ tree
const expectedTarget = path.join(auditHome, "skills", "packages", "test-pkg", "skills", "FOO.md");
expect(fs.existsSync(expectedTarget)).toBe(true);
expect(fs.readFileSync(expectedTarget, "utf-8")).toBe("# foo skill body");
} finally {
fs.rmSync(targetRoot, { recursive: true, force: true });
}
} finally {
(setup.bootstrapOrchestrator as unknown as { bootstrap: typeof origBootstrap }).bootstrap = origBootstrap;
if (origHome === undefined) delete process.env.OPENRIG_HOME;
else process.env.OPENRIG_HOME = origHome;
fs.rmSync(auditHome, { recursive: true, force: true });
}
});
it("POST /api/bundles/install does NOT include skillsRouting when bundle has no skills[]", async () => {
const origHome = process.env.OPENRIG_HOME;
const auditHome = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-skills-no-test-"));
process.env.OPENRIG_HOME = auditHome;
const origBootstrap = setup.bootstrapOrchestrator.bootstrap.bind(setup.bootstrapOrchestrator);
try {
const { specPath } = seedPackage();
const bundlePath = path.join(tmpDir, "no-skills.rigbundle");
const targetRoot = fs.mkdtempSync(path.join(os.tmpdir(), "no-skills-target-"));
try {
await app.request("/api/bundles/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ specPath, bundleName: "no-skills", bundleVersion: "0.1.0", outputPath: bundlePath }),
});
const stub = vi.fn().mockResolvedValue({
status: "completed", runId: "test-run-no-skills",
rigId: "01H000000000000000NOSKIL01",
stages: [], errors: [],
});
(setup.bootstrapOrchestrator as unknown as { bootstrap: typeof stub }).bootstrap = stub;
const installRes = await app.request("/api/bundles/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bundlePath, targetRoot, autoApprove: true }),
});
const body = await installRes.json();
expect(body.skillsRouting).toBeUndefined();
} finally {
fs.rmSync(targetRoot, { recursive: true, force: true });
}
} finally {
(setup.bootstrapOrchestrator as unknown as { bootstrap: typeof origBootstrap }).bootstrap = origBootstrap;
if (origHome === undefined) delete process.env.OPENRIG_HOME;
else process.env.OPENRIG_HOME = origHome;
fs.rmSync(auditHome, { recursive: true, force: true });
}
});
// T11: Install concurrency lock
it("concurrent bundle install returns 409", async () => {
// Acquire lock manually