diff --git a/src/servers/sidecar/opencode/connect-credential.ts b/src/servers/sidecar/opencode/connect-credential.ts new file mode 100644 index 00000000..dbc2a3a3 --- /dev/null +++ b/src/servers/sidecar/opencode/connect-credential.ts @@ -0,0 +1,99 @@ +import { readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +// Hand the serve's NEW api surface the provider key it cannot find on its own. +// +// ── The problem this exists to prevent ── +// +// opencode keeps credentials in two unrelated places. The CLI, `opencode run` and the legacy +// `/session/*` surface read `~/.local/share/opencode/auth.json`. The newer `/api/session/*` surface — +// the one with `delivery: "steer" | "queue"`, `/interrupt` and a resumable per-session event stream — +// reads its own integration store instead (`/api/integration`, `/api/credential`), and knows nothing +// about that file. +// +// With no credential the new pipeline does not fail. It falls back to whatever needs none, which is the +// free tier, and a request for a paid model is simply never executed: prompt accepted, `prompt.admitted` +// and `prompted` emitted, no step, no error, no assistant message, forever. That silence cost most of an +// afternoon to diagnose (docs/opencode-fork-decision.md) and would cost it again on every new machine. +// +// So the sidecar connects it at start-up rather than relying on somebody having run a curl by hand. +// +// ── Deliberately best-effort ── +// +// Never throws and never blocks start-up. Turns run through `opencode run`, which uses `auth.json` and +// is unaffected by any of this; failing here costs the new pipeline only, and the sidecar is far more +// useful up than down. The connection persists in opencode's own store, so this is a no-op on every +// start after the first. + +const AUTH_PATH = join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'opencode', 'auth.json'); + +const ATTEMPTS = 6; +const RETRY_DELAY_MS = 1_500; + +/** The provider key opencode already holds for itself, or null. Never logged, never returned to callers. */ +function readProviderKey(providerId: string): string | null { + try { + const auth = JSON.parse(readFileSync(AUTH_PATH, 'utf8')) as Record; + const entry = auth[providerId]; + return entry?.type === 'api' && typeof entry.key === 'string' && entry.key ? entry.key : null; + } catch { + return null; // no auth file, unreadable, or not JSON — nothing to connect + } +} + +/** + * Connect `auth.json`'s key for one provider to the serve's integration store. + * + * `providerId` doubles as the integration id: opencode names them the same, so the Zen key stored under + * `opencode` connects to integration `opencode`. + */ +export async function connectProviderCredential(baseUrl: string, providerId = 'opencode'): Promise { + const key = readProviderKey(providerId); + if (!key) { + console.log(`[opencode] no ${providerId} key in auth.json; the new API surface will only reach free models`); + return; + } + + // Retried, because `/api/health` answers before the integration store is ready: connecting immediately + // after the health check returns 500, and the identical request succeeds seconds later. Measured, not + // assumed — the first version of this shipped without the retry and failed on its first real boot. + // + // Only 5xx is retried. A 4xx means the request itself is wrong (bad key, unknown integration) and + // repeating it just prints the same complaint five times. + for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { + try { + const res = await fetch(`${baseUrl}/api/integration/${providerId}/connect/key`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ key, label: 'officer-opencode sidecar' }), + signal: AbortSignal.timeout(10_000), + }); + + if (res.ok) { + console.log(`[opencode] connected the ${providerId} credential to the api surface`); + return; + } + // The body is deliberately not logged: a credential endpoint's error may quote what it was given. + if (res.status < 500) { + console.error(`[opencode] could not connect the ${providerId} credential: HTTP ${res.status}`); + return; + } + if (attempt === ATTEMPTS) { + console.error( + `[opencode] could not connect the ${providerId} credential after ${ATTEMPTS} attempts: HTTP ${res.status}`, + ); + return; + } + } catch (err) { + if (attempt === ATTEMPTS) { + console.error( + `[opencode] could not connect the ${providerId} credential:`, + err instanceof Error ? err.message : err, + ); + return; + } + } + await Bun.sleep(RETRY_DELAY_MS); + } +} diff --git a/src/servers/sidecar/opencode/index.ts b/src/servers/sidecar/opencode/index.ts index bb2aee96..93c12314 100644 --- a/src/servers/sidecar/opencode/index.ts +++ b/src/servers/sidecar/opencode/index.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { DATA_PATH } from '../../data-path'; import { createSidecarConnector } from '../connect'; import { sweepRecordedServe } from './serve-sweep'; +import { connectProviderCredential } from './connect-credential'; import { createSessionLogStore } from '../claude/session-log'; import type { SidecarCommand, SidecarEvent } from '../protocol'; import { runOpenCodeTurn, killOpenCodeTurn, listRunningOpenCodeTurns, stopAllOpenCodeTurns } from './runner'; @@ -132,6 +133,11 @@ if (!(await waitHealthy(baseUrl, HEALTH_TIMEOUT_MS))) { } console.log(`[opencode] serve healthy on port ${port}`); +// The new /api surface keeps credentials separately from auth.json and would otherwise reach free models +// only — silently. Best-effort and not awaited for correctness: turns go through `opencode run`, which +// reads auth.json directly and does not depend on this. +void connectProviderCredential(baseUrl); + // ── Command handlers ── type ReplyFn = (msg: SidecarEvent) => void;