Skip to content
Closed
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>`, 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.<id>.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). |
Expand Down
1 change: 1 addition & 0 deletions packages/codev-gateway/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
111 changes: 111 additions & 0 deletions packages/codev-gateway/src/rotate.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => Promise<Response>

// 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<string | null> | null = null

export function reexchangeGatewayKey(opts?: { fetchImpl?: FetchLike }): Promise<string | null> {
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> | void
// Test seam; defaults to reexchangeGatewayKey with the same fetchImpl.
reexchange?: () => Promise<string | null>
}

// 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]
}
Loading
Loading