From 2b994040b88c13aba36c20a5c6beb86917c54de0 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Fri, 21 Aug 2026 02:08:42 +0700 Subject: [PATCH] feat(gateway): silently re-exchange expired gateway keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway now mints short-lived API keys (codev-backend KEY_DURATION), so a 401/403 mid-session is expected life-cycle. The netgate provider's SDK fetch is wrapped (@codev/gateway createGatewayFetch): on 401/403 it re-exchanges the SSO session for a fresh key via the backend (single-flight across parallel failures), retries the request once with the fresh Authorization, and keeps overriding the header from then on. The rotated key is persisted to the shared auth file and, via the new CustomDep.authSet bridge, to opencode's auth store so the next launch starts on it. The wrapper degrades to a passthrough wherever re-exchange is impossible (no SSO session — sandbox guests, hand-configured custom providers), and a config-only netgate (the guest's inline broker placeholder) never gets the wrapper at all (autoload: false). Because guests cannot re-exchange, the desktop egress resolver now validates the stored key at sandbox start and heals a dead one host-side before baking it into the broker route; mid-run expiry still needs a sandbox restart. Bodies that cannot be replayed (streams) are never retried, and an exchange that returns the key already in use surfaces the original response instead of retrying blindly. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 13 ++ DIVERGENCES.md | 1 + packages/codev-gateway/src/index.ts | 1 + packages/codev-gateway/src/rotate.ts | 111 ++++++++++ packages/codev-gateway/test/rotate.test.ts | 203 ++++++++++++++++++ packages/desktop/src/main/sandbox/egress.ts | 35 ++- packages/opencode/src/provider/provider.ts | 24 +++ .../opencode/test/provider/provider.test.ts | 43 ++++ 8 files changed, 429 insertions(+), 2 deletions(-) create mode 100644 packages/codev-gateway/src/rotate.ts create mode 100644 packages/codev-gateway/test/rotate.test.ts 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" }) } }, +)