diff --git a/opencode/index.ts b/opencode/index.ts index 4dad114..2e77f13 100644 --- a/opencode/index.ts +++ b/opencode/index.ts @@ -28,6 +28,16 @@ function splitArguments(raw: string): string[] { } let resolvedAkmCommand = "akm" + +// Test-only: reset the module-level resolved-CLI cache so each test resolves +// the akm command fresh under its own sandboxed env (HOME / AKM_OPENCODE_*). +// Without this, the first test to resolve pins `resolvedAkmCommand` for the +// rest of the process (resolveAkmCommand short-circuits on a still-valid +// cached command), making later resolution tests order-dependent. +export function __resetResolvedAkmForTests(): void { + resolvedAkmCommand = "akm" +} + const moduleDir = path.dirname(fileURLToPath(import.meta.url)) const SEMVER_PATTERN = /\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/ // Note: satisfiesAkmVersionRange() implements a custom matcher that accepts @@ -1753,7 +1763,10 @@ function getPathAkmCandidates(): string[] { const configNodeModules = getConfigNodeModulesAkmCommand() if (configNodeModules) candidates.push(configNodeModules) candidates.push("akm") - const home = os.homedir() + // Honor an explicit $HOME override (standard POSIX, and matches + // getConfigNodeModulesAkmCommand). os.homedir() snapshots HOME at process + // start and ignores later changes, which made this path non-sandboxable. + const home = process.env.HOME || os.homedir() if (home) { candidates.push(path.join(home, ".local", "bin", process.platform === "win32" ? "akm.cmd" : "akm")) } diff --git a/tests/claude-plugin.test.ts b/tests/claude-plugin.test.ts index c78c9d9..3634753 100644 --- a/tests/claude-plugin.test.ts +++ b/tests/claude-plugin.test.ts @@ -635,6 +635,19 @@ echo "[knowledge] should-not-appear" // Fake-akm helper: persist `akm config set ` calls // into $HOME/.config/akm/config.json so tests that assert against the file // still observe the plugin's writes after #463 moved them through the CLI. + // + // HERMETIC: the helper runs under the SAME interpreter that runs this test + // suite (`process.execPath`, i.e. the Bun that drives `bun test`), NOT a + // bare `node` resolved from the hook's PATH. The session-start hook builds + // its child-process PATH as `${binDir}:/usr/bin:/bin` — so a bare `node` + // only works on hosts that happen to ship `/usr/bin/node` (most dev boxes). + // Clean CI runs the suite under Bun with NO Node on PATH, so the bare-`node` + // form silently exited non-zero, `akm config set` failed, and the + // `config.defaults.agent` / `config.profiles.agent.claude` assertions below + // failed. Pinning to the suite's own interpreter makes the config write + // succeed regardless of whether Node is installed. (Bun runs `.cjs` CommonJS + // modules natively.) + const fakeAkmInterpreter = shellQuote(process.execPath) const fakeAkmConfigSet = path.join(binDir, "fake-akm-config-set.cjs") writeFileSync( fakeAkmConfigSet, @@ -675,7 +688,7 @@ case "$1" in *) break ;; esac done - node ${shellQuote(fakeAkmConfigSet)} "$HOME/.config/akm/config.json" "$1" "$2" + ${fakeAkmInterpreter} ${shellQuote(fakeAkmConfigSet)} "$HOME/.config/akm/config.json" "$1" "$2" exit $? fi exit 0 ;; diff --git a/tests/opencode-plugin.test.ts b/tests/opencode-plugin.test.ts index 2f2967a..9cc0e37 100644 --- a/tests/opencode-plugin.test.ts +++ b/tests/opencode-plugin.test.ts @@ -88,6 +88,23 @@ mock.module("node:child_process", () => ({ spawn: mockSpawn, })) +// Hermeticity: the plugin resolves its user-local akm fallback via +// `os.homedir()` (opencode/index.ts getPathAkmCandidates). Node/Bun's +// os.homedir() snapshots HOME at startup and does NOT reflect a later +// process.env.HOME change, so a withEnvVar("HOME", tempDir) sandbox alone can't +// redirect that candidate. We delegate every os function to the real module but +// make homedir() honor the *current* process.env.HOME, so HOME-sandboxed tests +// can place a fake akm under a temp dir and have the resolver actually probe it +// (instead of the developer's real ~/.local/bin/akm, which would never exist on +// clean CI). Data-safety: this only changes which directory is *read*; tests +// never write outside their mkdtemp sandbox. +const realOs = await import("node:os") +const osShim = { + ...realOs, + homedir: () => process.env.HOME ?? realOs.homedir(), +} +mock.module("node:os", () => ({ ...osShim, default: osShim })) + const pluginModule = await import("../opencode/index.ts") const { AkmPlugin, server, default: defaultPluginModule } = pluginModule @@ -175,6 +192,9 @@ describe("akm-opencode plugin", () => { mockFetch.mockClear() mockFetch.mockImplementation(async () => new Response(JSON.stringify({ version: "0.5.0" }), { status: 200 })) globalThis.fetch = mockFetch as typeof fetch + // Reset the module-level resolved-CLI cache so each test resolves akm fresh + // under its own sandboxed env (prevents order-dependent resolution tests). + ;(pluginModule as { __resetResolvedAkmForTests?: () => void }).__resetResolvedAkmForTests?.() }) describe("plugin loading", () => { @@ -3857,66 +3877,108 @@ describe("akm-opencode plugin", () => { }) it("falls back to ~/.local/bin/akm when PATH lookup fails", async () => { - const fallbackCommand = path.join(process.env.HOME ?? "/home/test", ".local", "bin", "akm") - mockExecFileSync.mockImplementation((cmd, args) => { - if (cmd === "akm" && args[0] === "--version") return "0.7.9" - if (cmd === fallbackCommand && args[0] === "--version") return "0.8.0-rc0" - if (cmd === fallbackCommand && args[0] === "search") return JSON.stringify({ hits: [] }) - return "mock output" - }) - - const hooks = await AkmPlugin(createPluginInput()) - const result = await hooks.tool!.akm_search.execute({ query: "anything" } as any, {} as any) - - expect(JSON.parse(result)).toEqual({ hits: [] }) - expect( - mockExecFileSync.mock.calls.some(([cmd, args]) => - cmd === fallbackCommand - && Array.isArray(args) - && args[0] === "search" - && args[1] === "anything", - ), - ).toBe(true) + // Hermetic: sandbox HOME so the resolver's user-local candidate + // (os.homedir()/.local/bin/akm, which honors $HOME on Linux) points at a + // temp dir, and create that file on disk so probeCommand's *real* + // existsSync() passes on a clean CI box where no akm is installed. Also + // opt out of bundled akm-cli resolution so the real bundled cli.js that + // ships on CI cannot preempt the candidate this test asserts on. + const sandboxHome = mkdtempSync(path.join(tmpdir(), "akm-opencode-home-")) + try { + await withEnvVar("AKM_OPENCODE_IGNORE_BUNDLED_CLI", "1", async () => { + await withEnvVar("HOME", sandboxHome, async () => { + const fallbackCommand = path.join(sandboxHome, ".local", "bin", "akm") + mkdirSync(path.dirname(fallbackCommand), { recursive: true }) + writeFileSync(fallbackCommand, "#!/bin/sh\n") + expect(existsSync(fallbackCommand)).toBe(true) + + mockExecFileSync.mockImplementation((cmd, args) => { + if (cmd === fallbackCommand && args[0] === "--version") return "0.8.0-rc0" + if (cmd === fallbackCommand && args[0] === "search") return JSON.stringify({ hits: [] }) + // Any OTHER akm candidate (the literal "akm" on PATH, or a command + // left cached in resolvedAkmCommand by a previous test) probes as + // out-of-range so the resolver is forced to fall through to the + // user-local fallback this test asserts on. Returning a real + // out-of-range semver also defeats the execFileSync shim's + // "synthesize 0.8.9" behavior for non-semver output. + if (Array.isArray(args) && args[0] === "--version") return "0.7.9" + return "mock output" + }) + + const hooks = await AkmPlugin(createPluginInput()) + const result = await hooks.tool!.akm_search.execute({ query: "anything" } as any, {} as any) + + expect(JSON.parse(result)).toEqual({ hits: [] }) + expect( + mockExecFileSync.mock.calls.some(([cmd, args]) => + cmd === fallbackCommand + && Array.isArray(args) + && args[0] === "search" + && args[1] === "anything", + ), + ).toBe(true) + }) + }) + } finally { + rmSync(sandboxHome, { recursive: true, force: true }) + } }) it("prefers ~/.config/opencode/node_modules/.bin/akm before user-local fallbacks", async () => { - await withEnvVar("HOME", mkdtempSync(path.join(tmpdir(), "akm-opencode-home-")), async () => { - const configCommand = path.join(process.env.HOME!, ".config", "opencode", "node_modules", ".bin", "akm") - const fallbackCommand = path.join(process.env.HOME!, ".local", "bin", "akm") - mkdirSync(path.dirname(configCommand), { recursive: true }) - writeFileSync(configCommand, "#!/bin/sh\n") - expect(existsSync(configCommand)).toBe(true) - - mockExecFileSync.mockImplementation((cmd, args) => { - if (Array.isArray(args) && args[0] === "search") { - return JSON.stringify({ hits: [], command: cmd }) - } - if (Array.isArray(args) && args[0] === "--version") { - return cmd === configCommand ? "0.8.0-rc0" : "0.7.9" - } - return "mock output" + // Opt out of bundled akm-cli resolution: on CI the real bundled + // opencode/node_modules/akm-cli/dist/cli.js exists and would be probed + // first, shadowing the config-node_modules candidate this test asserts on. + const sandboxHome = mkdtempSync(path.join(tmpdir(), "akm-opencode-home-")) + try { + await withEnvVar("AKM_OPENCODE_IGNORE_BUNDLED_CLI", "1", async () => { + await withEnvVar("HOME", sandboxHome, async () => { + // Pin XDG_CONFIG_HOME to the sandbox so getConfigNodeModulesAkmCommand + // (which reads XDG_CONFIG_HOME || $HOME/.config) probes exactly where + // we write the config bin. On CI XDG_CONFIG_HOME is set to something + // else, so without this the resolver looks in the wrong dir and the + // config candidate is never found. + await withEnvVar("XDG_CONFIG_HOME", path.join(sandboxHome, ".config"), async () => { + const configCommand = path.join(process.env.XDG_CONFIG_HOME!, "opencode", "node_modules", ".bin", "akm") + const fallbackCommand = path.join(process.env.HOME!, ".local", "bin", "akm") + mkdirSync(path.dirname(configCommand), { recursive: true }) + writeFileSync(configCommand, "#!/bin/sh\n") + expect(existsSync(configCommand)).toBe(true) + + mockExecFileSync.mockImplementation((cmd, args) => { + if (Array.isArray(args) && args[0] === "search") { + return JSON.stringify({ hits: [], command: cmd }) + } + if (Array.isArray(args) && args[0] === "--version") { + return cmd === configCommand ? "0.8.0-rc0" : "0.7.9" + } + return "mock output" + }) + + const hooks = await AkmPlugin(createPluginInput()) + const result = await hooks.tool!.akm_search.execute({ query: "anything" } as any, {} as any) + + expect(JSON.parse(result)).toEqual({ hits: [], command: configCommand }) + expect( + mockExecFileSync.mock.calls.some(([cmd, args]) => + cmd === configCommand + && Array.isArray(args) + && args[0] === "search" + && args[1] === "anything", + ), + ).toBe(true) + expect( + mockExecFileSync.mock.calls.some(([cmd, args]) => + cmd === fallbackCommand + && Array.isArray(args) + && args[0] === "search", + ), + ).toBe(false) + }) + }) }) - - const hooks = await AkmPlugin(createPluginInput()) - const result = await hooks.tool!.akm_search.execute({ query: "anything" } as any, {} as any) - - expect(JSON.parse(result)).toEqual({ hits: [], command: configCommand }) - expect( - mockExecFileSync.mock.calls.some(([cmd, args]) => - cmd === configCommand - && Array.isArray(args) - && args[0] === "search" - && args[1] === "anything", - ), - ).toBe(true) - expect( - mockExecFileSync.mock.calls.some(([cmd, args]) => - cmd === fallbackCommand - && Array.isArray(args) - && args[0] === "search", - ), - ).toBe(false) - }) + } finally { + rmSync(sandboxHome, { recursive: true, force: true }) + } }) it("uses AKM_LOCAL_BUILD_CLI via Bun before PATH fallbacks", async () => {