mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
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:
1 parent
595e9550d1
commit
2bf9c6606f
2 files changed
+180
-1
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user