From e3ecc9913f47e8d55c6e1572388fb16d9a44cd87 Mon Sep 17 00:00:00 2001 From: itlackey Date: Wed, 3 Jun 2026 22:18:08 -0500 Subject: [PATCH 1/4] test(opencode): make akm CLI-availability fallback tests hermetic for clean CI The two "akm CLI availability" fallback tests passed on dev machines but failed on clean CI because they depended on machine state: 1. Bundled-CLI preemption: akm 0.8.0 added bundled akm-cli resolution (getResolvedAkmDetails/getBundledAkmCommand) tried before PATH/fallbacks. The real bundled opencode/node_modules/akm-cli/dist/cli.js exists on CI and was probed/selected, shadowing the candidate each test asserts on. Both tests now set AKM_OPENCODE_IGNORE_BUNDLED_CLI=1 (the existing default-off opt-out) so the bundled candidate is skipped. 2. Hidden filesystem dependency: probeCommand() does a real existsSync() on absolute candidate paths even when execFileSync is mocked. The fallback test never created ~/.local/bin/akm and never sandboxed HOME, so on clean CI the candidate was skipped. It now sandboxes HOME to a mkdtemp dir and creates $HOME/.local/bin/akm with a shebang so existsSync passes deterministically. 3. os.homedir() snapshot: getPathAkmCandidates() builds the user-local fallback from os.homedir(), which snapshots HOME at startup and ignores a later process.env.HOME change. A node:os module mock now delegates to the real os but makes homedir() honor the current process.env.HOME, so the HOME sandbox actually redirects the probed path into the temp dir (never the real ~/.local/bin/akm). All writes stay inside the mkdtemp sandbox. The fallback test's mock also returns an out-of-range semver for any non-target --version probe, defeating both the execFileSync shim's "synthesize 0.8.9" behavior and a stale resolvedAkmCommand left cached by a prior test. Assertions unchanged; full file 158/158 passes, including under a simulated clean-CI env (empty HOME, no akm on PATH, bundled cli.js present). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/opencode-plugin.test.ts | 164 ++++++++++++++++++++++------------ 1 file changed, 108 insertions(+), 56 deletions(-) diff --git a/tests/opencode-plugin.test.ts b/tests/opencode-plugin.test.ts index 2f2967a..74c13d7 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 @@ -3857,66 +3874,101 @@ 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 () => { + 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" + }) + + 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 () => { From fadad21d09dac49e1a5e03d99302948c9c326f44 Mon Sep 17 00:00:00 2001 From: itlackey Date: Wed, 3 Jun 2026 22:19:07 -0500 Subject: [PATCH 2/4] test(claude): make session-start test hermetic vs missing Node on PATH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "session-start injects hints and curated context" test wrote its fake `akm config set` helper to be run by a bare `node` resolved from the hook's PATH (`${binDir}:/usr/bin:/bin`). That only works on hosts that ship `/usr/bin/node` (most dev boxes). On clean CI the suite runs under Bun with no Node on PATH, so the helper exited non-zero, `akm config set` failed, the config file stayed `{}`, and the `config.defaults.agent` / `config.profiles.agent.claude` assertions failed. Pin the fake helper to the test suite's own interpreter (process.execPath, i.e. the Bun running `bun test`) instead of a bare `node`. Bun runs `.cjs` CommonJS modules natively, so the config write now succeeds regardless of whether Node is installed. Note: the Claude SessionStart hook (claude/hooks/akm-hook.ts) resolves akm via AKM_LOCAL_BUILD_CLI then PATH `akm` only — it has NO bundled-akm-cli node_modules resolution, so the bundled-CLI hypothesis did not apply here. The real environment dependency was Node availability for the test's fake. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/claude-plugin.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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 ;; From df67b2b808f7ac2321e23932ef25397a5d664213 Mon Sep 17 00:00:00 2001 From: itlackey Date: Wed, 3 Jun 2026 22:28:43 -0500 Subject: [PATCH 3/4] fix(resolve): honor $HOME in PATH candidates + reset resolved-CLI cache between tests The 'prefers ~/.config/opencode/.bin/akm' test still failed on clean CI: - getPathAkmCandidates used bare os.homedir() (snapshots HOME at process start, ignores later changes) for the ~/.local/bin fallback, so it probed the real CI home instead of the test's sandbox HOME. Now uses process.env.HOME || os.homedir(), matching getConfigNodeModulesAkmCommand (standard POSIX, sandboxable). - resolveAkmCommand caches the resolved command at module level and short-circuits on a still-valid cached command, making resolution tests order-dependent. Add __resetResolvedAkmForTests() and call it in the suite beforeEach so each test resolves fresh under its own sandboxed env. Local gate green: integration 324/0, evals exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- opencode/index.ts | 15 ++++++++++++++- tests/opencode-plugin.test.ts | 3 +++ 2 files changed, 17 insertions(+), 1 deletion(-) 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/opencode-plugin.test.ts b/tests/opencode-plugin.test.ts index 74c13d7..cd49333 100644 --- a/tests/opencode-plugin.test.ts +++ b/tests/opencode-plugin.test.ts @@ -192,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", () => { From d173c98d00a66e09b8bf47eb0a2c692665768a63 Mon Sep 17 00:00:00 2001 From: itlackey Date: Wed, 3 Jun 2026 22:32:27 -0500 Subject: [PATCH 4/4] test(opencode): pin XDG_CONFIG_HOME in prefers-config test getConfigNodeModulesAkmCommand reads XDG_CONFIG_HOME || $HOME/.config, but the test wrote its config bin under $HOME/.config directly. On CI XDG_CONFIG_HOME is set elsewhere, so the resolver probed the wrong dir and never found the config candidate (passed locally only because XDG_CONFIG_HOME was unset). Pin XDG_CONFIG_HOME to the sandbox and write the bin there. Verified robust by running with a bogus preset XDG_CONFIG_HOME (reproduces the CI condition). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/opencode-plugin.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/opencode-plugin.test.ts b/tests/opencode-plugin.test.ts index cd49333..9cc0e37 100644 --- a/tests/opencode-plugin.test.ts +++ b/tests/opencode-plugin.test.ts @@ -3932,7 +3932,13 @@ describe("akm-opencode plugin", () => { try { await withEnvVar("AKM_OPENCODE_IGNORE_BUNDLED_CLI", "1", async () => { await withEnvVar("HOME", sandboxHome, async () => { - const configCommand = path.join(process.env.HOME!, ".config", "opencode", "node_modules", ".bin", "akm") + // 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") @@ -3968,6 +3974,7 @@ describe("akm-opencode plugin", () => { ), ).toBe(false) }) + }) }) } finally { rmSync(sandboxHome, { recursive: true, force: true })