diff --git a/CHANGELOG.md b/CHANGELOG.md index 60673338e9..6bffaba9f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -253,6 +253,19 @@ already in use` and no way forward short of finding the file in a temporary older CoDev Hub are re-migrated the same way whenever the hub re-inlines a key. ([#285](https://github.com/quickbeard/codev-code/pull/285)) +- **An expired gateway key heals itself mid-session** — the gateway now mints + short-lived API keys, and CoDev Code treats a key expiring as routine + rather than an error: when the gateway answers 401/403, the request is + silently retried once with a fresh key exchanged from your SSO session — no + prompt, no restart, no visible hiccup — and the fresh key is saved so the + next launch starts on it. A burst of parallel requests hitting the same + expired key triggers exactly one exchange. Where no SSO session exists + (inside a sandbox, or a custom provider you configured by hand) nothing + changes. The desktop app additionally checks the stored key when starting a + sandbox and swaps in a fresh one before handing the sandbox its connection, + since a sandbox cannot re-exchange on its own; a key that expires while a + sandbox is running still needs that sandbox restarted. + ([#286](https://github.com/quickbeard/codev-code/pull/286)) ### Desktop / Web UI diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 5b6d6f6680..192840bae4 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -158,6 +158,7 @@ from the squash message first, or use a plain merge. | `packages/opencode/src/installation/index.ts` | Updater detects/queries/installs `codev-code` (npm-family); GitHub fallback → quickbeard/codev-code. `method()` drops upstream's `~/.local/bin` → `curl` check, keeping only `~/.opencode/bin`. The `curl` upgrade branch refuses instead of running upstream's install script: `upgradeCurl` + `upgradeScriptShell` + the `Stream` import are removed, and `case "curl"` returns `UpgradeFailedError` pointing at `npm i -g codev-code` | `codev-code upgrade` and the update notification target OUR npm package, never stock opencode. brew/choco/scoop probes never match (no such packages). A `curl`-detected binary (in `~/.opencode/bin`) is a stock opencode install; piping upstream's `opencode.ai/install` script would silently replace the fork with stock opencode. `~/.local/bin` is dropped because it's also a common npm/bun global-bin prefix, so a legitimate package-manager install there was misdetected as curl (now falls through to the manager probes). | Keep our package names, the `~/.local/bin` removal, and the curl refusal; keep `upgradeCurl`/`upgradeScriptShell`/`Stream` deleted (take upstream's changes elsewhere). | | `packages/opencode/src/cli/cmd/uninstall.ts` | npm-family uninstall commands (display + exec maps) `opencode-ai` → `codev-code` | `codev uninstall` must remove the fork's actual npm package; the old name was a silent no-op for every npm-installed fork user. NOT renamed: brew/choco/scoop package names (`opencode` — the fork publishes no such formula/package, and `method()` never resolves to those for a fork install) and the curl `.opencode/bin` + `# opencode` shell markers (they match upstream install-script artifacts, not the fork's). | Keep our `codev-code` names; take upstream's surrounding changes. | | `packages/opencode/src/config/config.ts` | `share` defaults to `"disabled"` when unset | Sharing publishes sessions to upstream's share service; internal users must opt in via config. | Keep our default block. | +| `packages/opencode/src/provider/provider.ts` | Added a `netgate` entry to the `custom()` loader map (wires `@codev/gateway` `createGatewayFetch` — dynamic import — as `options.fetch`, `autoload: false`, `onRotate` persisting via `dep.authSet`); `CustomDep` gained `authSet: (id, info) => Promise`, wired as `bridge.promise(auth.set(...).pipe(Effect.orDie))`. Covered by the two fork-appended `netgate` tests at the end of `test/provider/provider.test.ts` (append-only, plus their `itWithAuth = testEffect(providerLayer())` harness line). | The CoDev gateway mints short-lived keys; the wrapped fetch heals a 401/403 in place — silent single-flight SSO re-exchange, one retry with the fresh Authorization, rotated key persisted to the auth store and the shared auth file. `autoload: false` keeps it a decoration on an already-enabled provider: without an SSO session (sandbox guests, custom-provider installs) the wrapper is a passthrough, and a config-only netgate (guest placeholder) never gets it. | Keep the map entry, the `authSet` field + wiring, and the appended tests; take upstream's surrounding changes to the loader map and dep wiring. | | `packages/opencode/src/config/config.ts` | Config layer init calls `ConfigGatewayKeyMigration.migrateInlineGatewayKeys(authSvc, fs)` (fork-only `src/config/gateway-key-migration.ts`, covered by fork-only `test/config/gateway-key-migration.test.ts`) before the first global-config load, wrapped in `Effect.catch` → log-warning so a failure never blocks startup | One-time migration for configs written before the keyless gateway block: older writers (the codev hub, this fork's configure flows) inlined the gateway API key as `provider..options.apiKey` in the global codev.json(c); the credential now lives in opencode's auth.json (registry merge by provider id). Beyond hygiene, an inline key outranks the auth-store key at SDK construction, so a stale leftover would pin requests to a rotated-out credential; an old hub that re-inlines the key on refresh is simply re-migrated next launch. Must run before `loadGlobal` first caches the file. | Keep the import + call (and its placement before the cached global load); take upstream's surrounding layer changes. | | Config-file rename: `packages/opencode/src/config/{config,managed,tui-migrate}.ts`, `packages/core/src/config.ts`, `packages/opencode/src/plugin/install.ts`, `packages/opencode/src/cli/cmd/{mcp,plug,providers,error}.ts`, user-facing strings (`packages/{tui,app}` dialogs/tips/i18n, skill descriptions in `packages/core/src/plugin/skill.ts` + `packages/opencode/src/skill/index.ts`), bundled prompts (`customize-opencode.md`, `initialize.txt`), tests seeding config fixtures, docs (`packages/web/src/content/docs`), stragglers (`.gitignore` root ignore entry, `packages/tui/src/component/dialog-provider.tsx` credential-dialog strings, `packages/tui/src/util/error.ts` model-not-found hint, `packages/opencode/script/run-workspace-server` header comment, `specs/v2/config.md` discovery prose) | Config file `opencode.json(c)` → `codev.json(c)` everywhere (global, project walk-up, config dirs, managed); global `config.json` support and the legacy TOML `config` migration removed; managed config dir `/Library/Application Support/opencode`→`…/codev`, `/etc/opencode`→`/etc/codev`, `%ProgramData%\opencode`→`…\codev` | The fork's config surface uses the fork's name; hard rename, no legacy fallback. NOT renamed: the `$schema` URL `https://opencode.ai/config.json`, `@opencode-ai/*` packages, theme asset files named `opencode.json`, brew/scoop manifest URLs. | Keep our file names; take upstream's surrounding logic. Rename any new upstream `opencode.json`/global-`config.json` reference to `codev.json` (watch the not-renamed list). | | `.opencode/` (repo root, whole directory) | Renamed to `.codev/`; inner `opencode.jsonc` → `codev.jsonc`; internal `.opencode/` path mentions in `glossary/README.md`, `skills/effect/SKILL.md`, `command/translate.md` updated; the `opencode-local` reference → `codev-local` pointing at `~/.local/share/codev` | This is the repo's own dogfooding config dir; after the config-dir rename row below, the agent only discovers `.codev/`. | Keep our directory; re-apply upstream's `.opencode/**` changes into `.codev/` (sync surfaces them as modify/delete or add conflicts). | diff --git a/packages/codev-gateway/src/index.ts b/packages/codev-gateway/src/index.ts index 37b0309fea..18dacd8565 100644 --- a/packages/codev-gateway/src/index.ts +++ b/packages/codev-gateway/src/index.ts @@ -18,4 +18,5 @@ export { slugifyProviderName, type ProviderIdentity, } from "./provider" +export { createGatewayFetch, reexchangeGatewayKey, type GatewayFetchOptions } from "./rotate" export { login, parseCallbackParams, silentSso, type LoginOptions, type OnLoginReady } from "./sso" diff --git a/packages/codev-gateway/src/rotate.ts b/packages/codev-gateway/src/rotate.ts new file mode 100644 index 0000000000..ce7c0e67b4 --- /dev/null +++ b/packages/codev-gateway/src/rotate.ts @@ -0,0 +1,111 @@ +import { loadApiKey, saveApiKey } from "./auth-file" +import { fetchApiKey } from "./backend" +import { silentSso } from "./sso" + +// Call-signature-only fetch: Bun's `typeof fetch` also demands `preconnect`, +// which mocks and wrappers have no business implementing. +export type FetchLike = (input: Parameters[0], init?: Parameters[1]) => Promise + +// Silent gateway-key re-exchange: the gateway now mints short-lived keys, so a +// 401/403 mid-session is expected life-cycle, not an error. The SSO session +// (refresh token) outlives the key; exchanging it via the backend yields a +// fresh key without any user interaction. Single-flight: a burst of parallel +// requests all failing on the same expired key funnels into one exchange. +// +// Returns the fresh key, or null when re-exchange is impossible (no SSO +// session on this machine — e.g. a sandbox guest — or the account has no +// provisioned key) or the backend/IdP errored. Never throws. +let inflight: Promise | null = null + +export function reexchangeGatewayKey(opts?: { fetchImpl?: FetchLike }): Promise { + if (inflight) return inflight + const run = (async () => { + try { + const session = await silentSso() + if (!session) return null + const apiKey = await fetchApiKey(session.access_token, { fetchImpl: opts?.fetchImpl as typeof fetch }) + if (!apiKey) return null + // Preserve the recorded default model; SSO-minted keys store no base_url + // and clear any manual provider identity (hub saveApiKey semantics), so + // the rest of the block is deliberately omitted. + saveApiKey({ apiKey, model: loadApiKey()?.model }) + return apiKey + } catch { + return null + } finally { + inflight = null + } + })() + inflight = run + return run +} + +export interface GatewayFetchOptions { + fetchImpl?: FetchLike + // Persist hook for the caller's own credential store (opencode's auth.json, + // via the provider registry). The shared ~/.codev(-hub)/auth.json is already + // updated inside reexchangeGatewayKey. A persist failure is swallowed: the + // in-memory rotated key still serves this session, and the next launch's + // first 401 simply rotates again. + onRotate?: (apiKey: string) => Promise | void + // Test seam; defaults to reexchangeGatewayKey with the same fetchImpl. + reexchange?: () => Promise +} + +// A fetch for the gateway provider's SDK that heals expired keys in place: on +// a 401/403 it silently re-exchanges the SSO session for a fresh key, retries +// the request once with the new Authorization, and keeps overriding the header +// from then on (the SDK baked the old key into its own headers at +// construction). Until a rotation happens, requests pass through untouched. +export function createGatewayFetch(options?: GatewayFetchOptions): FetchLike { + const fetchImpl = options?.fetchImpl ?? fetch + const reexchange = options?.reexchange ?? (() => reexchangeGatewayKey({ fetchImpl: options?.fetchImpl })) + let rotatedKey: string | undefined + + return async (input, init) => { + const attempt = (key: string | undefined) => + key === undefined ? fetchImpl(input, init) : fetchImpl(input, withAuthorization(init, key)) + const res = await attempt(rotatedKey) + if (res.status !== 401 && res.status !== 403) return res + // A Request-object input can carry state (streams) the retry can't + // reconstruct; the SDK calls fetch(url, init), so only that shape rotates. + if (input instanceof Request || !isReplayableBody(init?.body)) return res + const fresh = await reexchange() + // Same key back means the key isn't the problem (e.g. a 403 for model + // access) — surface the original response rather than retrying blindly. + if (!fresh || fresh === (rotatedKey ?? bearerFrom(init))) return res + rotatedKey = fresh + try { + await options?.onRotate?.(fresh) + } catch { + // Persisting is best-effort; the retry below must still happen. + } + try { + await res.body?.cancel() + } catch { + // Discarded response; a cancel failure changes nothing. + } + return attempt(fresh) + } +} + +// The request body must be re-sendable for the retry: chat requests are JSON +// strings, so anything stream-like is treated as one-shot and left alone. +function isReplayableBody(body: RequestInit["body"] | undefined): boolean { + if (body === undefined || body === null) return true + if (typeof body === "string") return true + if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) return true + return body instanceof URLSearchParams +} + +function withAuthorization(init: RequestInit | undefined, key: string): RequestInit { + const headers = new Headers(init?.headers) + headers.set("authorization", `Bearer ${key}`) + return { ...init, headers } +} + +function bearerFrom(init: RequestInit | undefined): string | undefined { + const value = new Headers(init?.headers).get("authorization") + const match = value?.match(/^Bearer\s+(.+)$/i) + return match?.[1] +} diff --git a/packages/codev-gateway/test/rotate.test.ts b/packages/codev-gateway/test/rotate.test.ts new file mode 100644 index 0000000000..81aa79c80e --- /dev/null +++ b/packages/codev-gateway/test/rotate.test.ts @@ -0,0 +1,203 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { BACKEND_URL } from "../src/const" +import { createGatewayFetch, type FetchLike, reexchangeGatewayKey } from "../src/rotate" + +// CODEV_TEST_HOME rather than HOME: Bun's homedir() ignores runtime HOME +// changes; the auth-file module resolves the home per call via this override. +let tempDir: string +let originalTestHome: string | undefined + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "codev-rotate-test-")) + originalTestHome = process.env.CODEV_TEST_HOME + process.env.CODEV_TEST_HOME = tempDir +}) + +afterEach(() => { + if (originalTestHome === undefined) delete process.env.CODEV_TEST_HOME + if (originalTestHome !== undefined) process.env.CODEV_TEST_HOME = originalTestHome + rmSync(tempDir, { recursive: true, force: true }) +}) + +const authPath = () => join(tempDir, ".codev", "auth.json") + +// A cached, non-expired SSO session: silentSso() returns it without touching +// the network, so the only fetch the re-exchange makes is the backend call. +function seedSession(extra: Record = {}) { + mkdirSync(join(tempDir, ".codev"), { recursive: true }) + writeFileSync( + authPath(), + JSON.stringify({ + access_token: "sso-token", + id_token: "id", + expires_at: Date.now() + 3_600_000, + user: { sub: "u", email: "u@example.test", displayName: "U" }, + ...extra, + }), + ) +} + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) + +describe("reexchangeGatewayKey", () => { + test("exchanges the cached session for a fresh key and persists it", async () => { + seedSession({ api_key: "sk-old", model: "m1" }) + const calls: string[] = [] + const fetchImpl: FetchLike = async (input, init) => { + calls.push(`${init?.method} ${input}`) + return jsonResponse({ api_key: "sk-fresh" }) + } + + expect(await reexchangeGatewayKey({ fetchImpl })).toBe("sk-fresh") + expect(calls).toEqual([`POST ${BACKEND_URL}/auth/exchange`]) + + const file = JSON.parse(readFileSync(authPath(), "utf-8")) + expect(file.api_key).toBe("sk-fresh") + // The recorded default model survives the rotation. + expect(file.model).toBe("m1") + }) + + test("funnels concurrent calls into one exchange", async () => { + seedSession() + let exchanges = 0 + let release!: (value: Response) => void + const gate = new Promise((resolve) => { + release = resolve + }) + const fetchImpl: FetchLike = async () => { + exchanges++ + return gate + } + + const first = reexchangeGatewayKey({ fetchImpl }) + const second = reexchangeGatewayKey({ fetchImpl }) + release(jsonResponse({ api_key: "sk-fresh" })) + expect(await Promise.all([first, second])).toEqual(["sk-fresh", "sk-fresh"]) + expect(exchanges).toBe(1) + }) + + test("returns null without an SSO session (e.g. sandbox guests)", async () => { + let touched = false + const fetchImpl: FetchLike = async () => { + touched = true + return jsonResponse({}) + } + expect(await reexchangeGatewayKey({ fetchImpl })).toBeNull() + expect(touched).toBe(false) + }) + + test("returns null instead of throwing when the backend errors", async () => { + seedSession() + const fetchImpl: FetchLike = async () => jsonResponse({ error: "down" }, 502) + expect(await reexchangeGatewayKey({ fetchImpl })).toBeNull() + }) +}) + +describe("createGatewayFetch", () => { + const chat = (headers: Record = { authorization: "Bearer sk-old" }) => + ["https://gw.test/v1/chat/completions", { method: "POST", headers, body: JSON.stringify({ model: "m" }) }] as const + + test("passes successful responses through untouched", async () => { + let reexchanged = false + const wrapped = createGatewayFetch({ + fetchImpl: async () => jsonResponse({ ok: true }), + reexchange: async () => { + reexchanged = true + return "sk-fresh" + }, + }) + const res = await wrapped(...chat()) + expect(res.status).toBe(200) + expect(reexchanged).toBe(false) + }) + + test("rotates on 401 and retries once with the fresh Authorization", async () => { + const seen: (string | null)[] = [] + const rotated: string[] = [] + const wrapped = createGatewayFetch({ + fetchImpl: async (_input, init) => { + const auth = new Headers(init?.headers).get("authorization") + seen.push(auth) + return auth === "Bearer sk-fresh" ? jsonResponse({ ok: true }) : jsonResponse({ error: "expired" }, 401) + }, + reexchange: async () => "sk-fresh", + onRotate: async (key) => { + rotated.push(key) + }, + }) + + const res = await wrapped(...chat()) + expect(res.status).toBe(200) + expect(seen).toEqual(["Bearer sk-old", "Bearer sk-fresh"]) + expect(rotated).toEqual(["sk-fresh"]) + + // Later requests keep the rotated key without another exchange: the SDK + // still bakes the old key into its headers. + const next = await wrapped(...chat()) + expect(next.status).toBe(200) + expect(seen[2]).toBe("Bearer sk-fresh") + }) + + test("surfaces the original 401 when re-exchange fails", async () => { + const wrapped = createGatewayFetch({ + fetchImpl: async () => jsonResponse({ error: "expired" }, 401), + reexchange: async () => null, + }) + expect((await wrapped(...chat())).status).toBe(401) + }) + + test("does not retry when the exchange returns the key already in use", async () => { + let attempts = 0 + const wrapped = createGatewayFetch({ + fetchImpl: async () => { + attempts++ + return jsonResponse({ error: "no access to model" }, 403) + }, + reexchange: async () => "sk-old", + }) + expect((await wrapped(...chat())).status).toBe(403) + expect(attempts).toBe(1) + }) + + test("leaves one-shot stream bodies alone", async () => { + let reexchanged = false + const wrapped = createGatewayFetch({ + fetchImpl: async () => jsonResponse({ error: "expired" }, 401), + reexchange: async () => { + reexchanged = true + return "sk-fresh" + }, + }) + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("{}")) + controller.close() + }, + }) + const res = await wrapped("https://gw.test/v1/chat/completions", { + method: "POST", + headers: { authorization: "Bearer sk-old" }, + body: stream, + }) + expect(res.status).toBe(401) + expect(reexchanged).toBe(false) + }) + + test("rotation persists even when the onRotate hook throws", async () => { + const wrapped = createGatewayFetch({ + fetchImpl: async (_input, init) => + new Headers(init?.headers).get("authorization") === "Bearer sk-fresh" + ? jsonResponse({ ok: true }) + : jsonResponse({ error: "expired" }, 401), + reexchange: async () => "sk-fresh", + onRotate: () => { + throw new Error("disk full") + }, + }) + expect((await wrapped(...chat())).status).toBe(200) + }) +}) diff --git a/packages/desktop/src/main/sandbox/egress.ts b/packages/desktop/src/main/sandbox/egress.ts index 717753cd4c..63c5b34115 100644 --- a/packages/desktop/src/main/sandbox/egress.ts +++ b/packages/desktop/src/main/sandbox/egress.ts @@ -2,7 +2,15 @@ import { randomUUID } from "node:crypto" import { readFile } from "node:fs/promises" import { join } from "node:path" import { app, net } from "electron" -import { buildGatewayConfig, fetchModels, loadApiKey, loadGatewayUrl, normalizeGatewayBaseUrl } from "@codev/gateway" +import { + buildGatewayConfig, + fetchModels, + loadApiKey, + loadGatewayUrl, + normalizeGatewayBaseUrl, + reexchangeGatewayKey, + validateApiKey, +} from "@codev/gateway" import { parseBrokerRules } from "@codev/sandbox" import type { SandboxEgress } from "@codev/sandbox" import { write } from "../logging" @@ -52,7 +60,12 @@ export async function resolveSandboxEgress(opts: { guestBrokerPort: number }): P // backend can only reach the host over TCP, so without this the broker would // be an open proxy to the gateway for anything that can reach the port. const auth = randomUUID() - const gateway = creds?.apiKey && baseUrl && creds.model ? { apiKey: creds.apiKey, baseUrl, model: creds.model } : null + const stored = creds?.apiKey && baseUrl && creds.model ? { apiKey: creds.apiKey, baseUrl, model: creds.model } : null + // The gateway mints short-lived keys, and a guest cannot re-exchange on its + // own (no SSO session inside the VM) — heal a dead key host-side before it + // is baked into the broker route, the sandbox twin of the hub's pre-launch + // check. Mid-run expiry still needs a sandbox restart to pick up a new key. + const gateway = stored ? { ...stored, apiKey: await freshGatewayKey(stored) } : null // The whole catalogue, not just the model the login recorded, so the guest's // model picker matches the local server's instead of offering one entry. // Cached across starts; the first one after a login pays the lookup, and an @@ -107,6 +120,24 @@ export async function resolveSandboxEgress(opts: { guestBrokerPort: number }): P } } +// validateApiKey throws when the gateway is unreachable — treated as alive, +// since a dead-key verdict can't be proven (hub preflight semantics); a failed +// re-exchange falls back to the stored key so the guest at least surfaces the +// gateway's own error instead of silently losing egress. +async function freshGatewayKey(gateway: { apiKey: string; baseUrl: string }): Promise { + const alive = await validateApiKey(gateway.apiKey, gateway.baseUrl, { fetchImpl: hostFetch }).catch(() => true) + if (alive) return gateway.apiKey + write("sandbox", "stored gateway key rejected; re-exchanging the SSO session", {}, "warn") + const fresh = await reexchangeGatewayKey({ fetchImpl: hostFetch }) + write( + "sandbox", + fresh ? "gateway key re-exchanged" : "gateway key re-exchange failed; using the stored key", + {}, + fresh ? "info" : "warn", + ) + return fresh ?? gateway.apiKey +} + // Optional admin-editable allowlist of extra egress routes (a mirror registry, // an internal API): userData/sandbox-egress.json, an object of name → // upstream URL. Reachable in the guest at the broker stub's //... path diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 6524bcf00d..36d30959eb 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -146,6 +146,10 @@ type CustomLoader = (provider: Info) => Effect.Effect<{ type CustomDep = { auth: (id: string) => Effect.Effect + // Fork: promise-based (via the bridge) because the one consumer — the CoDev + // gateway loader's key-rotation hook — runs at request time, outside any + // Effect context. + authSet: (id: string, info: Auth.Info) => Promise config: () => Effect.Effect env: () => Effect.Effect> get: (key: string) => Effect.Effect @@ -199,6 +203,25 @@ function custom(dep: CustomDep): Record { options: ok ? {} : { apiKey: "public" }, } }), + // Fork: the CoDev gateway (id from @codev/gateway GATEWAY_PROVIDER). The + // gateway mints short-lived keys, so the SDK's fetch is wrapped to heal a + // 401/403 in place: silent SSO re-exchange (single-flight), one retry with + // the fresh Authorization, and the rotated key persisted to the auth store + // so the next launch starts on it. autoload false — this only decorates a + // provider the config/auth store already enabled; without an SSO session + // (sandbox guests, manual custom-provider installs) the wrapper is a pure + // passthrough. + netgate: Effect.fnUntraced(function* (input: Info) { + const { createGatewayFetch } = yield* Effect.promise(() => import("@codev/gateway")) + return { + autoload: false, + options: { + fetch: createGatewayFetch({ + onRotate: (key) => dep.authSet(input.id, { type: "api", key }), + }), + }, + } + }), openai: () => Effect.succeed({ autoload: false, @@ -1357,6 +1380,7 @@ const layer = Layer.effect( } = {} const dep = { auth: (id: string) => auth.get(id).pipe(Effect.orDie), + authSet: (id: string, info: Auth.Info) => bridge.promise(auth.set(id, info).pipe(Effect.orDie)), config: () => config.get(), env: () => env.all(), get: (key: string) => env.get(key), diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index d290982ada..93b079d94a 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -2098,3 +2098,46 @@ it.effect("opencode loader keeps paid models when auth exists", () => expect(keyedCount).toBeGreaterThan(0) }).pipe(provideMultiInstance), ) + +// Fork-only: the netgate custom loader wires the key-rotation fetch +// (@codev/gateway createGatewayFetch) — but only onto a provider the auth +// store already enabled. A config-only netgate (the sandbox guest's inline +// broker placeholder) must stay on the SDK's plain fetch: a guest has no SSO +// session, so rotation there is meaningless. +const netgateFixture = (options: Record) => ({ + netgate: { + name: "netGate", + npm: "@ai-sdk/openai-compatible", + env: [], + models: { m: { name: "m", limit: { context: 196608, output: 65536 } } }, + options, + }, +}) + +// providerLayer exposes Auth.Service to the test effect; the default `it` +// layer does not. +const itWithAuth = testEffect(providerLayer()) + +itWithAuth.instance( + "netgate wires the key-rotation fetch when the auth store enables it", + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set("netgate", { type: "api", key: "sk-store" }) + yield* Effect.gen(function* () { + const providers = yield* list + expect(typeof providers[ProviderV2.ID.make("netgate")].options.fetch).toBe("function") + }).pipe(Effect.ensuring(auth.remove("netgate").pipe(Effect.orDie))) + }), + { config: { provider: netgateFixture({ baseURL: "https://gw.test/v1" }) } }, +) + +it.instance( + "netgate stays on the plain fetch for a config-only inline key", + Effect.gen(function* () { + const providers = yield* list + const options = providers[ProviderV2.ID.make("netgate")].options + expect(options.fetch).toBeUndefined() + expect(options.apiKey).toBe("sk-guest-placeholder") + }), + { config: { provider: netgateFixture({ baseURL: "https://gw.test/v1", apiKey: "sk-guest-placeholder" }) } }, +)