fix(daemon): plugin discovery resolves openrigPluginsDir via OPENRIG_HOME helper

Bug-fix slice plugin-discovery-respects-openrig-home. Pre-fix the
PluginDiscoveryService was constructed in startup.ts with a hardcoded
`path.join(os.homedir(), '.openrig', 'plugins')` while the sibling
PluginVendorService used `getDefaultOpenRigPath('plugins')` (the
OPENRIG_HOME-aware helper). Operators setting a custom OPENRIG_HOME
found vendor placed assets under the override path but discovery
scanned the default — the asymmetry surfaced on velocity-qa VM
dogfood and required a manual mirror workaround.

Fix is a one-line call-site change: route openrigPluginsDir through
the same `getDefaultOpenRigPath` helper that vendor already uses.
claudeCacheDir / codexCacheDir stay homedir-anchored because those
locations belong to the runtime tools, not to the OpenRig state root.

Audit per IMPL-PRD §3.4 + banked feedback_lifecycle_exporter_audit_all_callers:

  git grep "\\.openrig['\"]/plugins" packages/daemon/src
  git grep -E 'homedir\\(\\).*\\.openrig.*plugins' packages/daemon/src

Every other hit is a comment / docstring (documentation mentioning
the legacy default path), not a runtime literal. The only runtime
construction was startup.ts:777.

New tests at packages/daemon/test/plugin-discovery-openrig-home-wiring.test.ts:

- HG-1: with OPENRIG_HOME=<tmp>, vendor a synthetic plugin at
  <tmp>/plugins/example/; discovery surfaces `example` via
  listPlugins. (Pre-fix: FAILS; vendored entries don't appear.)
- HG-3 + HG-4: vendor + discovery share the resolver. After
  createDaemon under custom OPENRIG_HOME, openrig-core is vendored
  into <tmp>/plugins/openrig-core/ AND surfaced via discovery — same
  root, no cross-leak between services.
- HG-2 + HG-5: static-grep audit walks daemon/src/**/*.ts (line +
  block comments stripped) and FAILS if any runtime homedir() literal
  for the plugins path remains. Pre-fix: FAILS on startup.ts.

Targeted 3/3 PASS. Discriminates: 2 of 3 tests fail on pre-fix tip
91d81d2 (sanity check). Full daemon suite: 3353 passed / 1 skipped /
0 failed. git diff --check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-11 04:59:14 -07:00
1 parent 91d81d2272
commit eeb583e654
2 files changed
+154 -1

No files matched your search

+9 -1
View File
@@ -773,8 +773,16 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// for used-by uses the same default user spec root as SpecLibraryService
// above; one root at v0 (multi-root expansion deferred to a later slice
// when spec library hooks its full root list through to discovery).
// bug-fix slice plugin-discovery-respects-openrig-home: route the
// openrigPluginsDir through the OPENRIG_HOME-aware resolver so
// discovery + vendor (which already uses the helper at line 428)
// resolve to the same root. Operator-level test isolation + the
// slice 22 populated-VM-env story both depend on this symmetry.
// claudeCacheDir / codexCacheDir remain homedir-anchored because
// those cache locations belong to the runtime tools, not to the
// OpenRig state root.
pluginDiscoveryService: new PluginDiscoveryService({
openrigPluginsDir: nodePath.join(os.homedir(), ".openrig", "plugins"),
openrigPluginsDir: getDefaultOpenRigPath("plugins"),
claudeCacheDir: nodePath.join(os.homedir(), ".claude", "plugins", "cache"),
codexCacheDir: nodePath.join(os.homedir(), ".codex", "plugins", "cache"),
specLibraryDir: getDefaultOpenRigPath("specs"),
@@ -0,0 +1,145 @@
// bug-fix slice plugin-discovery-respects-openrig-home — wiring test.
//
// Verifies the startup-time wiring of PluginDiscoveryService honors
// `OPENRIG_HOME` env override (HG-1, HG-3, HG-4 per IMPL-PRD §5). The
// service-level tests in plugin-discovery-service.test.ts cover the
// scan logic against injected paths; THIS test covers the call-site
// resolution in startup.ts that pre-fix hardcoded
// `~/.openrig/plugins`. Without this gate, the next regression at the
// call site would only surface via end-to-end VM exercise (which is
// exactly how velocity-qa caught it on founder-walk VM refresh).
//
// HG-5 (audit-grep) is asserted as a static check in the second
// describe: no `homedir().*\.openrig.*plugins` literal remains in
// daemon src.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync, readdirSync, statSync } from "node:fs";
import { join, resolve, dirname } from "node:path";
import { tmpdir } from "node:os";
import { createDaemon } from "../src/startup.js";
let tmpHome: string;
let savedHome: string | undefined;
let savedNoKernel: string | undefined;
beforeEach(() => {
tmpHome = mkdtempSync(join(tmpdir(), "openrig-home-wiring-"));
savedHome = process.env.OPENRIG_HOME;
savedNoKernel = process.env.OPENRIG_NO_KERNEL;
process.env.OPENRIG_HOME = tmpHome;
process.env.OPENRIG_NO_KERNEL = "1"; // belt-and-suspenders; vitest already auto-skips
});
afterEach(() => {
if (savedHome === undefined) delete process.env.OPENRIG_HOME;
else process.env.OPENRIG_HOME = savedHome;
if (savedNoKernel === undefined) delete process.env.OPENRIG_NO_KERNEL;
else process.env.OPENRIG_NO_KERNEL = savedNoKernel;
rmSync(tmpHome, { recursive: true, force: true });
});
function writeClaudePluginManifest(pluginDir: string, manifest: Record<string, unknown>) {
const pluginDotDir = join(pluginDir, ".claude-plugin");
mkdirSync(pluginDotDir, { recursive: true });
writeFileSync(join(pluginDotDir, "plugin.json"), JSON.stringify(manifest, null, 2));
}
describe("plugin discovery honors OPENRIG_HOME (HG-1, HG-3, HG-4)", () => {
it("scans <OPENRIG_HOME>/plugins and finds a plugin placed there", async () => {
// Vendor a synthetic plugin at <OPENRIG_HOME>/plugins/example/
const pluginDir = join(tmpHome, "plugins", "example");
writeClaudePluginManifest(pluginDir, {
name: "example",
version: "0.1.0",
description: "test plugin",
});
const { deps, db } = await createDaemon({ dbPath: ":memory:" });
try {
const service = deps.pluginDiscoveryService;
expect(service).toBeDefined();
const plugins = await service!.listPlugins({});
const ids = plugins.map((p) => p.id);
expect(ids).toContain("example");
} finally {
db.close();
}
});
it("vendor + discovery resolve to the SAME OPENRIG_HOME-rooted path (no cross-leak between services)", async () => {
// Symmetric resolution check: after createDaemon runs, the
// bundled openrig-core asset MUST have been vendored under
// <OPENRIG_HOME>/plugins/openrig-core/. If discovery resolved a
// different root, the auto-vendored asset wouldn't be visible
// through listPlugins (which is exactly the founder-walk VM
// refresh bug — vendor wrote one place, discovery scanned
// another). Same-root verification proves both services share
// the resolver.
const { deps, db } = await createDaemon({ dbPath: ":memory:" });
try {
// Vendor should have placed openrig-core at <tmpHome>/plugins/openrig-core/
const expectedVendoredPath = join(tmpHome, "plugins", "openrig-core");
const stats = (() => {
try { return statSync(expectedVendoredPath); } catch { return null; }
})();
expect(stats, `vendor service should have placed openrig-core under ${expectedVendoredPath}`).not.toBeNull();
expect(stats!.isDirectory()).toBe(true);
// And discovery should surface it via the listPlugins envelope.
const plugins = await deps.pluginDiscoveryService!.listPlugins({});
const vendoredIds = plugins.filter((p) => p.source === "vendored").map((p) => p.id).sort();
expect(vendoredIds).toContain("openrig-core");
} finally {
db.close();
}
});
});
describe("plugin-discovery-respects-openrig-home audit (HG-2, HG-5)", () => {
// T5: static-grep audit — no hardcoded `homedir() ... .openrig ...
// plugins` literal should remain in daemon src. Comments OK; runtime
// path construction NOT OK. This test will fail if a future change
// reintroduces the hardcoded path that founder-walk VM refresh
// exposed.
it("no daemon src file constructs the plugins path via homedir() literal", () => {
const daemonSrcDir = resolve(__dirname, "..", "src");
const offenders: string[] = [];
const NEEDLE = /homedir\(\)\s*,\s*["']\.openrig["']\s*,\s*["']plugins["']/;
const NEEDLE_PATH_JOIN = /path\.join\([^)]*homedir\(\)[^)]*\.openrig[^)]*plugins/;
function walk(dir: string) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
continue;
}
if (!entry.isFile() || !entry.name.endsWith(".ts")) continue;
const text = readFileSync(full, "utf-8");
// Strip line comments + block comments to avoid false positives
// on documentation that mentions the legacy path.
const stripped = text
.split("\n")
.map((line) => {
const idx = line.indexOf("//");
return idx === -1 ? line : line.slice(0, idx);
})
.join("\n")
.replace(/\/\*[\s\S]*?\*\//g, "");
if (NEEDLE.test(stripped) || NEEDLE_PATH_JOIN.test(stripped)) {
offenders.push(full.replace(daemonSrcDir + "/", ""));
}
}
}
walk(daemonSrcDir);
if (offenders.length > 0) {
throw new Error(
`Daemon src files still construct plugins path via homedir() literal — must use getDefaultOpenRigPath('plugins') instead:\n - ${offenders.join("\n - ")}`,
);
}
// Reference the unused import deliberately to silence lint
void statSync;
void dirname;
});
});