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

Mirrors the Checkpoint 7.3 skills-routing integration for the plugins block.
The /install completed branch now extracts the bundle once via the canonical
unpack trust boundary (bundleRoot tmp dir), parses bundle.yaml, filters
plugin references to {kind:local, id, path}, and copies each plugin tree to
<OPENRIG_HOME>/plugins/<id>/ via routePlugins. Skills and plugins routing
are independent best-effort post-install side channels — each in its own
try/catch, each result merged into the response only when non-null.

routePluginsAfterBootstrap takes bundlePath only (NOT installMeta) per the
5f410eee B1 lesson: routing must fire on the dual-override path
(--skip-version-check + --force together) where installMeta is null.

PluginsRouterFsOps real-fs wrapper: fs.cpSync(recursive) for copyDir,
fs.statSync().isDirectory() for isDirectory, fs.mkdirSync(recursive) for
mkdirp, fs.existsSync for exists.

Discriminator-flip on routePluginsAfterBootstrap (inserted `return null;`
at function entry) BEFORE this commit confirmed the integration path: 1/49
failed — the with-plugins test (`expect(body.pluginsRouting).toBeDefined()`
at bundle-routes.test.ts:1434). Restored, 49/49 green.

Honest test-coverage disclosure:
- The with-plugins test (bundle-routes.test.ts:1382) IS the discriminator
  for the integration path. It tampers a bundle with plugins[] inline, runs
  the full /install completed branch, and asserts pluginsRouting.routedCount,
  records[0].id, records[0].status, AND that the copied plugin files land at
  OPENRIG_HOME/plugins/myplugin/.
- The without-plugins null-branch test (bundle-routes.test.ts:1454) does NOT
  independently fail under the discriminator-flip (the extras key is absent
  in both "function returns null" and "no plugins[] declared" cases). Its
  coverage is the inverse-precondition: a bundle without plugins[] must not
  inject a pluginsRouting field into the response body — the extras-merge
  conditional must respect the null. This is structural-correctness coverage,
  not function-fires coverage.
- The pure-function bundle-plugins-router.test.ts (9 P-tests P1-P9: empty /
  single / multi / missing / not_directory / source-escape / target-escape /
  non-local-kind / mixed-aggregate) is unaffected by the wrapper flip; it
  tests routePlugins directly with mocked FsOps and remains green
  throughout.

Per orch-ratified HYBRID decision (cont.18): plugin-routing is the v0 cross-
primitive bundling proof case. v0 source.kind is local-only (under the
bundle's extracted tree); other kinds reserved for future. Both-sides path
containment is enforced inside routePlugins (source under bundleRoot AND
target under targetPluginsDir — banked both-sides-trust-boundary lesson
addendum). bundlePath-only wrapper signature is the decoupling
documented in cont.18.

Tests: 49/49 across packages/daemon/test/bundle-routes.test.ts (40 — 2
new for plugins routing) + packages/daemon/test/bundle-plugins-router.test.ts (9).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-31 18:28:20 -07:00
1 parent 76c5f0dbff
commit f6b2184940
2 files changed
+181 -1

No files matched your search

+64 -1
View File
@@ -22,6 +22,7 @@ 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";
import { routePlugins, type PluginsRouterFsOps, type RoutePluginsResult, type PluginRoutingInput } from "../domain/bundle-plugins-router.js";
/**
* Read the daemon's own package.json version at call time (Item 1 / slice-05).
@@ -277,6 +278,59 @@ function podAssemblerFsOps(): PodAssemblerFsOps {
};
}
/** Item 6 / slice-05 Checkpoint 7.3d: real PluginsRouterFsOps backed by node:fs. */
function pluginsRouterFsOps(): PluginsRouterFsOps {
return {
exists: (p) => fs.existsSync(p),
isDirectory: (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } },
mkdirp: (p) => fs.mkdirSync(p, { recursive: true }),
copyDir: (s, d) => fs.cpSync(s, d, { recursive: true }),
};
}
/**
* Item 6 / slice-05 Checkpoint 7.3d: extract the bundle safely (banked unpack
* trust boundary) and route any declared plugin references to the operator
* plugins library (<OPENRIG_HOME>/plugins/<id>/). Returns null when bundle
* has no plugins[] (no-op). Mirror of routeSkillsAfterBootstrap pattern —
* takes bundlePath only (decoupled from installMeta per the 5f410eee B1
* lesson; routing must fire on the dual-override path too).
*/
async function routePluginsAfterBootstrap(bundlePath: string): Promise<RoutePluginsResult | null> {
const tmpDir = fs.mkdtempSync(nodePath.join(os.tmpdir(), "bundle-plugins-route-"));
try {
await unpack(bundlePath, tmpDir);
const manifestPath = nodePath.join(tmpDir, "bundle.yaml");
if (!fs.existsSync(manifestPath)) return null;
const manifestYaml = fs.readFileSync(manifestPath, "utf-8");
const manifest = parsePodBundleManifest(manifestYaml) as Record<string, unknown>;
const rawPlugins = manifest["plugins"];
if (!Array.isArray(rawPlugins) || rawPlugins.length === 0) return null;
const declaredPlugins: PluginRoutingInput[] = [];
for (const entry of rawPlugins) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
const p = entry as Record<string, unknown>;
const s = p["source"];
if (typeof p["id"] !== "string" || !p["id"]) continue;
if (!s || typeof s !== "object" || Array.isArray(s)) continue;
const src = s as Record<string, unknown>;
if (src["kind"] !== "local" || typeof src["path"] !== "string" || !src["path"]) continue;
declaredPlugins.push({ id: p["id"], source: { kind: "local", path: src["path"] } });
}
if (declaredPlugins.length === 0) return null;
return routePlugins(
{
bundleRoot: tmpDir,
declaredPlugins,
targetPluginsDir: getDefaultOpenRigPath("plugins"),
},
pluginsRouterFsOps(),
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
/** Item 6 / slice-05 Checkpoint 7.3: real SkillsRouterFsOps backed by node:fs. */
function skillsRouterFsOps(): SkillsRouterFsOps {
return {
@@ -760,12 +814,21 @@ bundleRoutes.post("/install", async (c) => {
// channel post-install step). Routing result included in response body
// so operators see what landed.
let skillsRouting: RouteSkillsResult | null = null;
let pluginsRouting: RoutePluginsResult | null = null;
try {
skillsRouting = await routeSkillsAfterBootstrap(bundlePath);
} catch {
// Side-channel failure; install already succeeded
}
return c.json(skillsRouting ? { ...result, skillsRouting } : result, 201);
try {
pluginsRouting = await routePluginsAfterBootstrap(bundlePath);
} catch {
// Side-channel failure; install already succeeded
}
const extras: Record<string, unknown> = {};
if (skillsRouting) extras.skillsRouting = skillsRouting;
if (pluginsRouting) extras.pluginsRouting = pluginsRouting;
return c.json(Object.keys(extras).length > 0 ? { ...result, ...extras } : result, 201);
}
if (result.status === "partial") {
const ok = result.stages.filter((s: { status: string }) => s.status === "ok").length;
+117
View File
@@ -1375,6 +1375,123 @@ describe("Bundle API routes", () => {
}
});
// Item 6 / Checkpoint 7.3d: /install routes declared plugins after
// successful bootstrap. Mirrors skills-routing test; bundle has plugins[]
// with source pointing to a directory in the bundle tree. Verifies the
// plugin dir lands at OPENRIG_HOME/plugins/<id>/.
it("POST /api/bundles/install routes declared plugins after successful bootstrap (completed branch)", async () => {
const origHome = process.env.OPENRIG_HOME;
const auditHome = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-plugins-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-plugins.rigbundle");
const targetRoot = fs.mkdtempSync(path.join(os.tmpdir(), "plugins-target-"));
try {
// Put a plugin tree inside the bundle's package source so it lands in the archive
const pluginSrc = path.join(tmpDir, "test-pkg", "plugins", "myplugin");
fs.mkdirSync(pluginSrc, { recursive: true });
fs.writeFileSync(path.join(pluginSrc, "plugin.json"), '{"name":"myplugin","version":"1.0"}');
fs.writeFileSync(path.join(pluginSrc, "README.md"), "# myplugin");
await app.request("/api/bundles/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ specPath, bundleName: "with-plugins", bundleVersion: "0.1.0", outputPath: bundlePath }),
});
// Tamper bundle.yaml to inject plugins[] referencing the path in the bundle
const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), "plugins-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");
const tampered = `${original}\nplugins:\n - id: myplugin\n source:\n kind: local\n path: packages/test-pkg/plugins/myplugin\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 });
}
const stub = vi.fn().mockResolvedValue({
status: "completed",
runId: "test-run-plugins",
rigId: "01H000000000000000PLUG0001",
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();
expect(body.pluginsRouting).toBeDefined();
expect(body.pluginsRouting.routedCount).toBe(1);
expect(body.pluginsRouting.records[0].id).toBe("myplugin");
expect(body.pluginsRouting.records[0].status).toBe("routed");
// Plugin directory landed at OPENRIG_HOME/plugins/myplugin
const expectedPluginDir = path.join(auditHome, "plugins", "myplugin");
expect(fs.existsSync(expectedPluginDir)).toBe(true);
expect(fs.existsSync(path.join(expectedPluginDir, "plugin.json"))).toBe(true);
expect(fs.existsSync(path.join(expectedPluginDir, "README.md"))).toBe(true);
} 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 pluginsRouting when bundle has no plugins[]", async () => {
const origHome = process.env.OPENRIG_HOME;
const auditHome = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-no-plugins-test-"));
process.env.OPENRIG_HOME = auditHome;
const origBootstrap = setup.bootstrapOrchestrator.bootstrap.bind(setup.bootstrapOrchestrator);
try {
const { specPath } = seedPackage();
const bundlePath = path.join(tmpDir, "no-plugins.rigbundle");
const targetRoot = fs.mkdtempSync(path.join(os.tmpdir(), "no-plugins-target-"));
try {
await app.request("/api/bundles/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ specPath, bundleName: "no-plugins-installer", bundleVersion: "0.1.0", outputPath: bundlePath }),
});
const stub = vi.fn().mockResolvedValue({
status: "completed", runId: "test-run-no-plugins",
rigId: "01H000000000000000NOPLG01",
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.pluginsRouting).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 });
}
});
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-"));