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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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\<version>-<arch>\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
Expand Down
5 changes: 3 additions & 2 deletions MITARBEITER-SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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\<version>-<arch>\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.
Expand Down
124 changes: 108 additions & 16 deletions src/credential-store.ts
Original file line number Diff line number Diff line change
@@ -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"

/**
Expand All @@ -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<boolean> {
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\<version>-<arch>\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<string | undefined> {
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<string> {
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<string> | undefined

export function runOpenCodeBinary(args: string[], stdin?: string): Promise<string> {
executable ??= resolveOpenCodeExecutable()
return executable.then((command) => spawnOpenCode(command, args, stdin))
}

function spawnOpenCode(command: string, args: string[], stdin?: string): Promise<string> {
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<typeof execFile>
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()
})
Expand Down Expand Up @@ -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)) {
Expand Down
19 changes: 16 additions & 3 deletions src/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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")
Expand Down
10 changes: 10 additions & 0 deletions src/v2/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,16 @@ export interface Registration {

export type Transform<Editor> = (callback: (editor: Editor) => void) => Promise<Registration>

/** 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 {
Expand All @@ -103,6 +111,8 @@ export interface NeuronContext {
readonly connection: {
active(integrationID: string): Promise<Connection | undefined>
resolve(connection: Connection): Promise<KeyCredential | OtherCredential | undefined>
/** Optional so a host without it still loads the plugin. */
status?(input: { integrationID: string; connection: Connection; status: ConnectionStatus | undefined }): Promise<void>
}
}
readonly event: {
Expand Down
49 changes: 45 additions & 4 deletions src/v2/plugin.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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<void> {
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(
Expand Down Expand Up @@ -99,6 +125,7 @@ async function prepareProfiles(
const results = await Promise.all(
options.profiles.map(async (profile): Promise<ProfileRegistration | undefined> => {
let apiKey: string | undefined
let connection: Connection | undefined
try {
const resolved = await resolveKey(ctx, profile)
if (resolved.conflict) {
Expand All @@ -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,
Expand All @@ -129,13 +157,26 @@ 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}`, {
providerID: profile.id,
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: [] }
Expand Down
Loading
Loading