Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion opencode/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"))
}
Expand Down
15 changes: 14 additions & 1 deletion tests/claude-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,19 @@ echo "[knowledge] should-not-appear"
// Fake-akm helper: persist `akm config set <dottedKey> <jsonOrString>` 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,
Expand Down Expand Up @@ -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 ;;
Expand Down
174 changes: 118 additions & 56 deletions tests/opencode-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading