diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bd87f4..0a8d10c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## Unreleased + +### Fixed: setup could not store the API key for OpenCode 2 on Windows + +With OpenCode 2 installed through npm, `opencode` on Windows is a `.cmd` shim. Node refuses to pass arguments containing JSON quotes to a batch file (CVE-2024-27980 hardening), so `credential.create` failed with "contains a cmd.exe special character", and the error message echoed the request body including the API key. Setup now resolves the real `opencode.exe` from PATH, looking through the npm shim, and synchronous spawn errors no longer leak argv. Replacing an existing credential also failed: `credential.remove` takes `credentialID` as a path parameter (`--param`), not as a request body. + +A failed run left OpenCode 2 with whatever key was imported from `auth.json` earlier, so the provider showed up without models (discovery answered 401). Rerun setup after updating. + +### Fixed: setup ignored OpenCode Desktop 2 when no `opencode` CLI was on PATH + +OpenCode Desktop 2 on Windows ships its own CLI under `%APPDATA%\ai.opencode.desktop\cli\-\opencode-cli.exe` and does not add it to PATH. Without a separately installed CLI, setup found no binary and wrote the key to `auth.json`, which OpenCode 2 imports only on its first start, so an existing installation never saw the key. Setup now falls back to the newest bundled Desktop CLI. + +### Added: OpenCode 2 flags a rejected API key on the connection + +When the proxy answers discovery with 401 or 403, the plugin marks the profile's connection as `needs_auth` with a message naming the proxy, so OpenCode shows that the key needs replacing instead of an empty model list. The mark is cleared once discovery succeeds again. + ## 0.6.1 ### Fixed: the baseline's blanket allow overrode a user's own specific rules diff --git a/MITARBEITER-SETUP.md b/MITARBEITER-SETUP.md index 1c72c81..dc9fd7b 100644 --- a/MITARBEITER-SETUP.md +++ b/MITARBEITER-SETUP.md @@ -12,7 +12,7 @@ npx @noser-engineering/opencode-plugin-neuron setup --global ``` -**OpenCode 2.x:** Das Setup erkennt die installierte Version automatisch (`--opencode-version <1|2>` erzwingt sie). Damit der API-Key gespeichert wird, muss `opencode` im `PATH` liegen; fehlt es, landet der Key mit einer Warnung in `auth.json`, das OpenCode 2 nur beim allerersten Start übernimmt. Alternativ in OpenCode `/connect` ausführen und das Profil wählen, der Key wird ohne Neustart übernommen. Updates des Plugins holt `opencode plugin update`. +**OpenCode 2.x:** Das Setup erkennt die installierte Version automatisch (`--opencode-version <1|2>` erzwingt sie). Den API-Key speichert das Setup über die `opencode`-CLI. Unter Windows findet es auch die per npm installierte CLI und die CLI, die OpenCode Desktop mitbringt. Findet es gar keine, landet der Key mit einer Warnung in `auth.json`, das OpenCode 2 nur beim allerersten Start übernimmt. Alternativ in OpenCode `/connect` ausführen und das Profil wählen, der Key wird ohne Neustart übernommen. Updates des Plugins holt `opencode plugin update`. ### Ohne Node.js installieren @@ -112,7 +112,8 @@ Der leere Block `{}` genügt, weil damit nur die Freigabe ausgesprochen wird; al ## Probleme -- `401 Unauthorized`: API-Key prüfen und das Setup erneut ausführen. +- `401 Unauthorized`: API-Key prüfen und das Setup erneut ausführen. In OpenCode 2 ist die Verbindung des Profils dann als „neu anmelden" markiert; alternativ `/connect` ausführen und das Profil wählen. +- OpenCode 2 zeigt das Profil, aber keine Modelle: Meist ist ein alter Key gespeichert. Setup mit dem richtigen Key erneut ausführen. - Plugin wird nicht gefunden: Node.js/npm-Version prüfen und den Befehl erneut ausführen. - Modelle fehlen: OpenCode neu starten; die Modellliste wird beim Start geladen. - Ein Provider, den du bisher genutzt hast, ist verschwunden: Er wurde nie in `opencode.json` eingetragen, sondern lief über automatisch geladene Zugangsdaten. Ist er für dieses Projekt freigegeben, trag ihn ein wie oben unter „Kundenaccount verwenden". Ist er es nicht, hat die Sperre ihre Arbeit getan. diff --git a/README.md b/README.md index cef9a88..e7768a6 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,8 @@ The package ships two entrypoints. OpenCode 1.x loads `.` (the `plugin` config k What changes on OpenCode 2: -- API keys live in OpenCode's credential store, not in `auth.json`. Setup writes them through the `opencode` CLI, so it has to be on `PATH`; otherwise setup falls back to `auth.json` with a warning, which OpenCode 2 imports only on its very first start. On Windows the npm shim (`opencode.cmd`) cannot be launched this way, so npm-installed OpenCode falls back to `auth.json`; the Scoop/standalone `opencode.exe` works. `opencode api` takes the body only through `-d` (no stdin in 2.0.22), so the key is briefly visible in the process list while setup stores it. `opencode api credential.list` shows the stored credential. You can also connect a profile from inside OpenCode with `/connect`, where the profile is offered; the plugin picks the key up without a restart. +- API keys live in OpenCode's credential store, not in `auth.json`. Setup writes them through the `opencode` CLI. On Windows it runs the real `opencode.exe`, also behind the npm shim (`opencode.cmd`), and without a CLI on `PATH` it uses the one bundled with OpenCode Desktop (`%APPDATA%\ai.opencode.desktop\cli\-\opencode-cli.exe`). If none is found, setup falls back to `auth.json` with a warning, which OpenCode 2 imports only on its very first start. `opencode api` takes the body only through `-d` (no stdin in 2.0.22), so the key is briefly visible in the process list while setup stores it. `opencode api credential.list` shows the stored credential. You can also connect a profile from inside OpenCode with `/connect`, where the profile is offered; the plugin picks the key up without a restart. +- When the proxy rejects the stored key (HTTP 401 or 403), the profile's connection is marked as needing a new key, and the provider stays without models until a valid key is stored. The mark clears on the next successful discovery. - A key stored for a profile whose URL changed is ignored, with a warning in the log, exactly as before. - The compliance layer removes OpenCode Zen (`opencode`, `opencode-go`) and any `denyProviders` entry unless the provider is declared under `providers` in `opencode.json`, and appends the permission baseline to every agent (`bash` is `shell` in v2). A deny of the baseline overrides a conflicting rule of yours for the same resource; an ask or allow yields to it. See [Permission baseline](#permission-baseline). - A v2 plugin cannot set `share` or `update`. Setup writes `"share": "disabled"` and `"update": "notify"` into the config it edits, only when they are absent. Removing them is your call, and the plugin will not put them back. diff --git a/src/credential-store.ts b/src/credential-store.ts index 3334fc5..fd84141 100644 --- a/src/credential-store.ts +++ b/src/credential-store.ts @@ -1,4 +1,6 @@ import { execFile } from "node:child_process" +import { readdir, readFile, stat } from "node:fs/promises" +import { join } from "node:path" import { extractApiCredentials, readAuthStore, updateApiCredentials, type StoredApiCredential } from "./auth.js" /** @@ -22,28 +24,118 @@ export class OpenCodeNotFoundError extends Error { super( code && code !== "ENOENT" ? `The opencode binary could not be run (${code})` - : "The opencode binary was not found on PATH", + : "The opencode binary was not found on PATH or in OpenCode Desktop", ) this.name = "OpenCodeNotFoundError" } } +const NPM_SHIM_TARGET = /"%~?dp0%?\\?([^"%]*\\opencode\.exe)"/i + +async function isFile(path: string): Promise { + try { + return (await stat(path)).isFile() + } catch { + return false + } +} + +function compareVersions(a: string, b: string): number { + const pa = a.split(".").map(Number) + const pb = b.split(".").map(Number) + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const diff = (pa[i] ?? 0) - (pb[i] ?? 0) + if (diff) return diff + } + return 0 +} + +/** + * OpenCode Desktop 2 on Windows ships its own CLI under + * `%APPDATA%\ai.opencode.desktop\cli\-\opencode-cli.exe` and + * does not put it on PATH. Picks the newest version, preferring the current + * architecture. + */ +async function findDesktopCli(appData: string | undefined, arch: string): Promise { + if (!appData) return undefined + const root = join(appData, "ai.opencode.desktop", "cli") + const entries = await readdir(root).catch(() => [] as string[]) + const candidates = entries + .map((name) => ({ name, match: /^(\d+(?:\.\d+)*)-(.+)$/.exec(name) })) + .filter((entry): entry is { name: string; match: RegExpExecArray } => entry.match !== null) + .sort((a, b) => { + const archDiff = Number(b.match[2] === arch) - Number(a.match[2] === arch) + return archDiff || compareVersions(b.match[1]!, a.match[1]!) + }) + for (const { name } of candidates) { + const exe = join(root, name, "opencode-cli.exe") + if (await isFile(exe)) return exe + } + return undefined +} + +export interface ExecutableLookup { + platform?: NodeJS.Platform + path?: string + appData?: string + arch?: string +} + +/** + * On Windows `opencode` is often an npm `.cmd` shim. Neither Node nor Bun can + * pass a JSON body to a batch file safely, so resolve the real `opencode.exe` + * from PATH, looking through npm shims to the executable they forward to. + * Without one on PATH, falls back to the CLI bundled with OpenCode Desktop. + */ +export async function resolveOpenCodeExecutable(lookup: ExecutableLookup = {}): Promise { + const platform = lookup.platform ?? process.platform + if (platform !== "win32") return "opencode" + const pathValue = "path" in lookup ? lookup.path : (process.env.PATH ?? process.env.Path) + for (const dir of (pathValue ?? "").split(";").filter(Boolean)) { + const exe = join(dir, "opencode.exe") + if (await isFile(exe)) return exe + for (const shim of ["opencode.cmd", "opencode.bat"]) { + const text = await readFile(join(dir, shim), "utf8").catch(() => undefined) + const target = text?.match(NPM_SHIM_TARGET)?.[1] + if (!target) continue + const resolved = join(dir, ...target.split(/[\\/]/).filter(Boolean)) + if (await isFile(resolved)) return resolved + } + } + const appData = "appData" in lookup ? lookup.appData : process.env.APPDATA + return (await findDesktopCli(appData, lookup.arch ?? process.arch)) ?? "opencode" +} + +let executable: Promise | undefined + export function runOpenCodeBinary(args: string[], stdin?: string): Promise { + executable ??= resolveOpenCodeExecutable() + return executable.then((command) => spawnOpenCode(command, args, stdin)) +} + +function spawnOpenCode(command: string, args: string[], stdin?: string): Promise { return new Promise((resolve, reject) => { - const child = execFile("opencode", args, { maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => { - if (error) { - const code = (error as NodeJS.ErrnoException).code - // A string code is a spawn-level failure (ENOENT, EINVAL for the Windows npm shim, EACCES); - // a numeric code is the process exit status. - if (typeof code === "string") return reject(new OpenCodeNotFoundError(code)) - // Never echo error.message or the full argv: both carry the JSON body, including the API key. - const detail = stderr.trim() || stdout.trim().slice(-200) - return reject( - new Error(`opencode ${args[0]} ${args[1]} failed (exit ${code ?? "unknown"})${detail ? `: ${detail}` : ""}`), - ) - } - resolve(stdout) - }) + let child: ReturnType + try { + child = execFile(command, args, { maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => { + if (error) { + const code = (error as NodeJS.ErrnoException).code + // A string code is a spawn-level failure (ENOENT, EINVAL for the Windows npm shim, EACCES); + // a numeric code is the process exit status. + if (typeof code === "string") return reject(new OpenCodeNotFoundError(code)) + // Never echo error.message or the full argv: both carry the JSON body, including the API key. + const detail = stderr.trim() || stdout.trim().slice(-200) + return reject( + new Error(`opencode ${args[0]} ${args[1]} failed (exit ${code ?? "unknown"})${detail ? `: ${detail}` : ""}`), + ) + } + resolve(stdout) + }) + } catch (error) { + // Synchronous spawn failures (e.g. argument validation for batch files) echo the argv; never forward them. + const code = (error as NodeJS.ErrnoException).code + return reject(new OpenCodeNotFoundError(typeof code === "string" ? code : "spawn failed")) + } if (stdin !== undefined) child.stdin?.end(stdin) else child.stdin?.end() }) @@ -123,7 +215,7 @@ export function opencodeCredentialStore( const removeOld = async (providerID: string) => { for (const entry of existing) { if (entry.integrationID !== providerID) continue - await run(["api", "credential.remove", STANDALONE, "-d", JSON.stringify({ credentialID: entry.id })]) + await run(["api", "credential.remove", STANDALONE, "--param", `credentialID=${entry.id}`]) } } for (const [providerID, credential] of Object.entries(updates)) { diff --git a/src/discovery.ts b/src/discovery.ts index b015b68..4d4cc46 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -215,6 +215,21 @@ export function applyModelInfo(models: LiteLLMModel[], info: ModelInfo): LiteLLM }) } +export class DiscoveryHttpError extends Error { + constructor( + readonly status: number, + statusText: string, + ) { + super(`model discovery returned HTTP ${status} ${statusText}`.trim()) + this.name = "DiscoveryHttpError" + } +} + +/** True when the proxy rejected the key itself, as opposed to being unreachable. */ +export function isAuthRejection(error: unknown): boolean { + return error instanceof DiscoveryHttpError && (error.status === 401 || error.status === 403) +} + async function fetchModelList( url: string, apiKey: string | undefined, @@ -230,9 +245,7 @@ async function fetchModelList( redirect: "error", signal: AbortSignal.timeout(options.timeoutMs), }) - if (!response.ok) { - throw new Error(`model discovery returned HTTP ${response.status} ${response.statusText}`.trim()) - } + if (!response.ok) throw new DiscoveryHttpError(response.status, response.statusText) const payload: unknown = await response.json() if (!asRecord(payload)) throw new Error("model discovery returned an invalid JSON object") diff --git a/src/v2/context.ts b/src/v2/context.ts index 17c1ece..38e4901 100644 --- a/src/v2/context.ts +++ b/src/v2/context.ts @@ -78,8 +78,16 @@ export interface Registration { export type Transform = (callback: (editor: Editor) => void) => Promise +/** Shown by OpenCode on the integration; `needs_auth` asks the user to reconnect. */ +export interface ConnectionStatus { + status: "needs_auth" + message: string +} + export interface Connection { type: "credential" | "env" + /** The problem last reported for this connection, if any. */ + status?: ConnectionStatus } export interface KeyCredential { @@ -103,6 +111,8 @@ export interface NeuronContext { readonly connection: { active(integrationID: string): Promise resolve(connection: Connection): Promise + /** Optional so a host without it still loads the plugin. */ + status?(input: { integrationID: string; connection: Connection; status: ConnectionStatus | undefined }): Promise } } readonly event: { diff --git a/src/v2/plugin.ts b/src/v2/plugin.ts index 2cde69d..fe7f8ad 100644 --- a/src/v2/plugin.ts +++ b/src/v2/plugin.ts @@ -1,11 +1,11 @@ import { DEFAULT_PERMISSION_POLICY } from "../compliance.js" import { PROVIDER_PACKAGE_V2 } from "../constants.js" -import { applyModelInfo, emptyModelInfo } from "../discovery.js" +import { applyModelInfo, emptyModelInfo, isAuthRejection } from "../discovery.js" import { parsePluginOptions } from "../options.js" import { createDiscoveryCache, type DiscoveryCache } from "../plugin.js" import type { LiteLLMModel, ModelInfo, NeuronProfile, ParsedPluginOptions } from "../types.js" import { applyDenyList, applyPermissionRules, toPermissionRules, V2_EXTRA_PERMISSIONS } from "./compliance.js" -import type { NeuronContext, Registration, V2ModelInfo, V2ProviderInfo } from "./context.js" +import type { Connection, ConnectionStatus, NeuronContext, Registration, V2ModelInfo, V2ProviderInfo } from "./context.js" import { toModelInfo } from "./model.js" export type V2LogLevel = "info" | "warn" | "error" @@ -47,14 +47,40 @@ function describe(error: unknown): string { async function resolveKey( ctx: NeuronContext, profile: NeuronProfile, -): Promise<{ apiKey?: string; conflict: boolean }> { +): Promise<{ apiKey?: string; connection?: Connection; conflict: boolean }> { const connection = await ctx.integration.connection.active(profile.id) if (!connection) return { conflict: false } const credential = await ctx.integration.connection.resolve(connection) if (!credential || credential.type !== "key") return { conflict: false } const storedURL = credential.metadata?.baseURL if (typeof storedURL === "string" && storedURL !== profile.baseURL) return { conflict: true } - return { apiKey: credential.key, conflict: false } + return { apiKey: credential.key, connection, conflict: false } +} + +/** + * Marks the profile's connection as needing a new key when the proxy rejects + * it, so OpenCode shows the problem instead of an empty model list, and + * clears the mark once the key works again. Best effort: a host without + * `connection.status` or a failing call only costs the hint. + */ +async function reportConnectionStatus( + ctx: NeuronContext, + profile: NeuronProfile, + connection: Connection | undefined, + status: ConnectionStatus | undefined, + dependencies: V2Dependencies, +): Promise { + const report = ctx.integration.connection.status + if (!connection || !report) return + if (!status && !connection.status) return + try { + await report({ integrationID: profile.id, connection, status }) + } catch (error) { + dependencies.log("warn", `Could not report the connection status for ${profile.name}`, { + providerID: profile.id, + error: describe(error), + }) + } } async function discover( @@ -99,6 +125,7 @@ async function prepareProfiles( const results = await Promise.all( options.profiles.map(async (profile): Promise => { let apiKey: string | undefined + let connection: Connection | undefined try { const resolved = await resolveKey(ctx, profile) if (resolved.conflict) { @@ -109,6 +136,7 @@ async function prepareProfiles( return undefined } apiKey = resolved.apiKey + connection = resolved.connection } catch (error) { dependencies.log("warn", `Could not read the credential for ${profile.name}`, { providerID: profile.id, @@ -129,6 +157,7 @@ async function prepareProfiles( providerID: profile.id, baseURL: profile.baseURL, }) + await reportConnectionStatus(ctx, profile, connection, undefined, dependencies) return { info, models: models.map((model) => toModelInfo(model, profile.id)) } } catch (error) { dependencies.log("warn", `Model discovery failed for ${profile.name}`, { @@ -136,6 +165,18 @@ async function prepareProfiles( baseURL: profile.baseURL, error: describe(error), }) + if (isAuthRejection(error)) { + await reportConnectionStatus( + ctx, + profile, + connection, + { + status: "needs_auth", + message: `${profile.baseURL} rejected the stored API key. Enter a valid key for ${profile.name}.`, + }, + dependencies, + ) + } // Registered anyway, with no models: /connect still has to offer the // profile so the user can fix the key. return { info, models: [] } diff --git a/test/credential-store.test.ts b/test/credential-store.test.ts index d5e85f0..cba53b6 100644 --- a/test/credential-store.test.ts +++ b/test/credential-store.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile } from "node:fs/promises" +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { beforeEach, describe, expect, it, vi } from "vitest" @@ -10,6 +10,7 @@ import { fileCredentialStore, OpenCodeNotFoundError, opencodeCredentialStore, + resolveOpenCodeExecutable, runOpenCodeBinary, type RunOpenCode, } from "../src/credential-store.js" @@ -79,7 +80,7 @@ describe("opencodeCredentialStore", () => { }), ], }, - { args: ["api", "credential.remove", "--standalone", "-d", JSON.stringify({ credentialID: "cred_1" })] }, + { args: ["api", "credential.remove", "--standalone", "--param", "credentialID=cred_1"] }, ]) }) @@ -87,7 +88,7 @@ describe("opencodeCredentialStore", () => { const run = fakeRun() await opencodeCredentialStore(run).write({}, ["work", "unknown"]) expect(run.calls.slice(1)).toEqual([ - { args: ["api", "credential.remove", "--standalone", "-d", JSON.stringify({ credentialID: "cred_1" })] }, + { args: ["api", "credential.remove", "--standalone", "--param", "credentialID=cred_1"] }, ]) }) @@ -152,4 +153,71 @@ describe("runOpenCodeBinary", () => { stubExec(null, "out", "") await expect(runOpenCodeBinary(["api", "credential.list"])).resolves.toBe("out") }) + + it("maps synchronous spawn errors without leaking argv", async () => { + execFileMock.mockImplementation(() => { + throw Object.assign(new Error('args[5] contains a cmd.exe special character: {"key":"sk-secret"}'), { + code: "ERR_INVALID_ARG_VALUE", + }) + }) + const error = await runOpenCodeBinary(["api", "credential.create", "-d", '{"key":"sk-secret"}']).catch((e: Error) => e) + expect(error).toBeInstanceOf(OpenCodeNotFoundError) + expect((error as Error).message).not.toContain("sk-secret") + }) +}) + +describe("resolveOpenCodeExecutable", () => { + const win = (path: string | undefined, appData?: string) => + resolveOpenCodeExecutable({ platform: "win32", path, appData, arch: "x64" }) + + async function desktopCli(versions: string[]): Promise<{ appData: string; exe: (name: string) => string }> { + const appData = await mkdtemp(join(tmpdir(), "neuron-appdata-")) + const exe = (name: string) => join(appData, "ai.opencode.desktop", "cli", name, "opencode-cli.exe") + for (const name of versions) { + await mkdir(join(appData, "ai.opencode.desktop", "cli", name), { recursive: true }) + await writeFile(exe(name), "") + } + return { appData, exe } + } + + it("uses the bare name outside Windows", async () => { + expect(await resolveOpenCodeExecutable({ platform: "linux", path: "/usr/bin" })).toBe("opencode") + }) + + it("prefers opencode.exe on PATH", async () => { + const dir = await mkdtemp(join(tmpdir(), "neuron-path-")) + await writeFile(join(dir, "opencode.exe"), "") + const { appData } = await desktopCli(["2.0.29-x64"]) + expect(await win(`C:\\nowhere;${dir}`, appData)).toBe(join(dir, "opencode.exe")) + }) + + it("falls back to the newest CLI bundled with OpenCode Desktop for this architecture", async () => { + const { appData, exe } = await desktopCli(["2.0.9-x64", "2.0.29-x64", "2.1.0-arm64"]) + expect(await win("", appData)).toBe(exe("2.0.29-x64")) + }) + + it("skips a Desktop CLI directory without the executable", async () => { + const { appData, exe } = await desktopCli(["2.0.20-x64"]) + await mkdir(join(appData, "ai.opencode.desktop", "cli", "2.0.30-x64"), { recursive: true }) + expect(await win("", appData)).toBe(exe("2.0.20-x64")) + }) + + it("looks through the npm .cmd shim to the real executable", async () => { + const dir = await mkdtemp(join(tmpdir(), "neuron-path-")) + const exe = join(dir, "node_modules", "@opencode", "cli", "bin", "opencode.exe") + await mkdir(join(dir, "node_modules", "@opencode", "cli", "bin"), { recursive: true }) + await writeFile(exe, "") + await writeFile( + join(dir, "opencode.cmd"), + '@ECHO off\r\nCALL :find_dp0\r\n"%dp0%\\node_modules\\@opencode\\cli\\bin\\opencode.exe" %*\r\n', + ) + expect(await win(dir, undefined)).toBe(exe) + }) + + it("falls back to the bare name when nothing usable is found", async () => { + const dir = await mkdtemp(join(tmpdir(), "neuron-path-")) + await writeFile(join(dir, "opencode.cmd"), "@ECHO off\r\nnode something.js %*\r\n") + const appData = await mkdtemp(join(tmpdir(), "neuron-appdata-")) + expect(await win(dir, appData)).toBe("opencode") + }) }) diff --git a/test/v2/declared.test.ts b/test/v2/declared.test.ts index c8820b3..acfd1c9 100644 --- a/test/v2/declared.test.ts +++ b/test/v2/declared.test.ts @@ -17,18 +17,18 @@ describe("configFileCandidates", () => { it("lists the global and project files OpenCode 2 reads, OPENCODE_CONFIG first", () => { expect(configFileCandidates("/proj", { OPENCODE_CONFIG: "/etc/oc.json" }, "/home/u")).toEqual([ "/etc/oc.json", - "/home/u/.config/opencode/opencode.json", - "/home/u/.config/opencode/opencode.jsonc", - "/proj/opencode.json", - "/proj/opencode.jsonc", - "/proj/.opencode/opencode.json", - "/proj/.opencode/opencode.jsonc", + join("/home/u", ".config", "opencode", "opencode.json"), + join("/home/u", ".config", "opencode", "opencode.jsonc"), + join("/proj", "opencode.json"), + join("/proj", "opencode.jsonc"), + join("/proj", ".opencode", "opencode.json"), + join("/proj", ".opencode", "opencode.jsonc"), ]) }) it("honours OPENCODE_CONFIG_DIR and XDG_CONFIG_HOME for the global directory", () => { - expect(configFileCandidates("/proj", { OPENCODE_CONFIG_DIR: "/cfg" }, "/home/u")[0]).toBe("/cfg/opencode.json") - expect(configFileCandidates("/proj", { XDG_CONFIG_HOME: "/xdg" }, "/home/u")[0]).toBe("/xdg/opencode/opencode.json") + expect(configFileCandidates("/proj", { OPENCODE_CONFIG_DIR: "/cfg" }, "/home/u")[0]).toBe(join("/cfg", "opencode.json")) + expect(configFileCandidates("/proj", { XDG_CONFIG_HOME: "/xdg" }, "/home/u")[0]).toBe(join("/xdg", "opencode", "opencode.json")) }) }) diff --git a/test/v2/plugin.test.ts b/test/v2/plugin.test.ts index b038760..15727c0 100644 --- a/test/v2/plugin.test.ts +++ b/test/v2/plugin.test.ts @@ -13,6 +13,7 @@ import type { V2ModelInfo, V2ProviderInfo, } from "../../src/v2/context.js" +import { DiscoveryHttpError } from "../../src/discovery.js" import { setupNeuron, type V2Dependencies } from "../../src/v2/plugin.js" type Credentials = Record @@ -76,7 +77,7 @@ class FakeContext implements NeuronContext { active: async (id: string): Promise => this.credentials[id] ? { type: "credential" } : undefined, resolve: async (_c: Connection) => undefined as KeyCredential | undefined, - }, + } as NeuronContext["integration"]["connection"], } readonly event = { @@ -228,6 +229,64 @@ describe("setupNeuron", () => { expect(d.log).toHaveBeenCalledWith("warn", expect.stringContaining("Model discovery failed"), expect.anything()) }) + describe("connection status", () => { + const rejected = () => new DiscoveryHttpError(401, "Unauthorized") + + function withKey(connection: Connection = { type: "credential" }) { + const ctx = new FakeContext({ profiles: [PROFILE] }, { work: { type: "key", key: "sk-bad" } }) + ctx.integration.connection.active = async () => connection + ctx.integration.connection.resolve = async () => ({ type: "key", key: "sk-bad", metadata: { baseURL: PROFILE.baseURL } }) + const status = vi.fn(async () => undefined) + ctx.integration.connection.status = status + return { ctx, status } + } + + it("marks the connection as needing a new key when the proxy rejects it", async () => { + const { ctx, status } = withKey() + await setupNeuron(ctx, deps({ discoverRawModels: async () => Promise.reject(rejected()) })) + + expect(ctx.rebuild().providers.get("work")?.models).toEqual([]) + expect(status).toHaveBeenCalledWith({ + integrationID: "work", + connection: { type: "credential" }, + status: { status: "needs_auth", message: expect.stringContaining("rejected the stored API key") }, + }) + }) + + it("does not mark the connection for errors other than a rejected key", async () => { + const { ctx, status } = withKey() + await setupNeuron(ctx, deps({ discoverRawModels: async () => Promise.reject(new DiscoveryHttpError(502, "Bad Gateway")) })) + expect(status).not.toHaveBeenCalled() + }) + + it("clears an earlier mark once discovery succeeds", async () => { + const connection: Connection = { type: "credential", status: { status: "needs_auth", message: "old" } } + const { ctx, status } = withKey(connection) + await setupNeuron(ctx, deps()) + expect(status).toHaveBeenCalledWith({ integrationID: "work", connection, status: undefined }) + }) + + it("leaves a healthy connection alone", async () => { + const { ctx, status } = withKey() + await setupNeuron(ctx, deps()) + expect(status).not.toHaveBeenCalled() + }) + + it("keeps the provider when reporting the status fails or is not supported", async () => { + const { ctx, status } = withKey() + status.mockRejectedValue(new Error("unsupported")) + const d = deps({ discoverRawModels: async () => Promise.reject(rejected()) }) + await setupNeuron(ctx, d) + expect(ctx.rebuild().providers.has("work")).toBe(true) + expect(d.log).toHaveBeenCalledWith("warn", expect.stringContaining("connection status"), expect.anything()) + + const bare = withKey() + delete bare.ctx.integration.connection.status + await setupNeuron(bare.ctx, deps({ discoverRawModels: async () => Promise.reject(rejected()) })) + expect(bare.ctx.rebuild().providers.has("work")).toBe(true) + }) + }) + it("removes blocked providers unless declared in a config file and appends the permission baseline", async () => { const ctx = new FakeContext({ profiles: [PROFILE] }, {}, [catalog("opencode", "enabled"), catalog("opencode-go", "enabled"), catalog("anthropic", "auto")]) const readDeclaredProviders = vi.fn(async (directory: string) => (directory === "/fake/project" ? new Set(["opencode-go"]) : new Set()))