From 94fa120d1f6ad7f8d67cbcbefae20d50e8e15507 Mon Sep 17 00:00:00 2001 From: Binguss Date: Wed, 15 Apr 2026 00:29:49 -0700 Subject: [PATCH 1/3] fix(windows): support CLAUDE_CODE_OAUTH_TOKEN env var and write auth.json to Roaming AppData Two Windows-specific bugs fixed: 1. When Claude Code is launched from Claude Desktop, it inherits the CLAUDE_CODE_OAUTH_TOKEN env var. With that var set, `claude auth login` considers the session already authenticated and never writes credentials to disk (~/.claude/.credentials.json). The plugin fell back to an empty account list, showing "credentials unavailable". Fix: readAllClaudeAccounts() now falls back to reading the env var directly (source: "env") when the credentials file is absent. The JWT expiry claim is decoded to populate expiresAt. writeBackCredentials and refreshAccount handle the "env" source as read-only. refreshIfNeeded skips the 60-second CLI fallback for env tokens, returning null immediately on expiry instead of hanging. 2. OpenCode stores its data directory in %APPDATA% (Roaming), but getAuthJsonPaths() only wrote auth.json to %LOCALAPPDATA% and the XDG path. OpenCode never saw the credentials the plugin synced. Fix: getAuthJsonPaths() now includes %APPDATA%\opencode\auth.json on Windows in addition to the existing paths. --- src/credentials.ts | 31 +++++++++++++++++++--- src/keychain.ts | 64 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 86 insertions(+), 9 deletions(-) diff --git a/src/credentials.ts b/src/credentials.ts index 31f8305..d53bfa6 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -98,10 +98,19 @@ export function saveAccountSource(source: string): void { function getAuthJsonPaths(): string[] { const xdgPath = join(homedir(), ".local", "share", "opencode", "auth.json") if (process.platform === "win32") { - const appData = + // Write to both Local and Roaming AppData on Windows. + // OpenCode stores its data directory in %APPDATA% (Roaming), but earlier + // versions of this plugin only wrote to %LOCALAPPDATA%. Writing to both + // ensures OpenCode's own auth picker also sees fresh credentials. + const localAppData = process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local") - const localAppDataPath = join(appData, "opencode", "auth.json") - return [xdgPath, localAppDataPath] + const roamingAppData = + process.env.APPDATA ?? join(homedir(), "AppData", "Roaming") + return [ + xdgPath, + join(localAppData, "opencode", "auth.json"), + join(roamingAppData, "opencode", "auth.json"), + ] } return [xdgPath] } @@ -292,6 +301,22 @@ export function refreshIfNeeded( } } + // Env-var credentials have no refresh token and no CLI path. + // Re-read the env var in case it was rotated by the parent process (e.g. + // Claude Desktop refreshed and updated its child environment). If it has + // genuinely expired, return null immediately rather than blocking for up to + // 60 s on a CLI timeout that cannot help. + if (target.source === "env") { + log("refresh_fallback_env", { source: target.source }) + const refreshed = refreshAccount(target.source) + if (refreshed && refreshed.expiresAt > Date.now() + 60_000) { + target.credentials = refreshed + return refreshed + } + log("refresh_exhausted", { source: target.source, reason: "env_token_expired" }) + return null + } + // Fall back to CLI-based refresh (consumes Haiku tokens) log("refresh_fallback_cli", { source: target.source }) refreshViaCli() diff --git a/src/keychain.ts b/src/keychain.ts index 9ca4bd9..ab40936 100644 --- a/src/keychain.ts +++ b/src/keychain.ts @@ -181,6 +181,43 @@ function readCredentialsFile(): ClaudeCredentials | null { } } +/** + * Read credentials from the CLAUDE_CODE_OAUTH_TOKEN environment variable. + * + * Claude Desktop sets this variable when it spawns Claude Code processes. When + * it is present, `claude auth login` considers the session already authenticated + * and never writes credentials to disk, so the env var is the only source + * available on those systems. + * + * The token is a JWT; we decode the `exp` claim to derive `expiresAt`. If + * decoding fails we default to 10 hours (observed Claude token lifetime). + * There is no refresh token in this path — when the token expires the caller + * must obtain a new one (e.g. by restarting Claude Desktop). + */ +function readEnvToken(): ClaudeCredentials | null { + const token = process.env.CLAUDE_CODE_OAUTH_TOKEN + if (!token) return null + + // Attempt to decode expiry from the JWT payload (second base64url segment). + let expiresAt = Date.now() + 36_000 * 1000 // default: 10 hours + try { + const payloadB64 = token.split(".")[1] + if (payloadB64) { + const payload = JSON.parse( + Buffer.from(payloadB64, "base64url").toString("utf-8"), + ) as { exp?: unknown } + if (typeof payload.exp === "number") { + expiresAt = payload.exp * 1000 + } + } + } catch { + // JWT decode failed — use default expiry + } + + log("env_token_read", { success: true, expiresAt }) + return { accessToken: token, refreshToken: "", expiresAt } +} + export function buildAccountLabels(credsList: ClaudeCredentials[]): string[] { const baseLabels = credsList.map((c) => { if (c.subscriptionType) { @@ -206,9 +243,22 @@ export function buildAccountLabels(credsList: ClaudeCredentials[]): string[] { export function readAllClaudeAccounts(): ClaudeAccount[] { if (process.platform !== "darwin") { const creds = readCredentialsFile() - if (!creds) return [] - const [label] = buildAccountLabels([creds]) - return [{ label, source: "file", credentials: creds }] + if (creds) { + const [label] = buildAccountLabels([creds]) + return [{ label, source: "file", credentials: creds }] + } + + // Fallback: use CLAUDE_CODE_OAUTH_TOKEN when set (e.g. launched from Claude + // Desktop). In that environment the env var is already a valid access token + // and `claude auth login` never writes credentials to disk because it + // considers the session already authenticated. + const envCreds = readEnvToken() + if (envCreds) { + log("env_token_fallback", { reason: "credentials_file_not_found" }) + return [{ label: "Claude (env)", source: "env", credentials: envCreds }] + } + + return [] } const services = listClaudeKeychainServices() @@ -282,6 +332,9 @@ export function writeBackCredentials( source: string, creds: ClaudeCredentials, ): boolean { + // Env-var credentials are read-only; there is nowhere to write them back. + if (source === "env") return false + const newCreds = { accessToken: creds.accessToken, refreshToken: creds.refreshToken, @@ -342,9 +395,8 @@ export function writeBackCredentials( } export function refreshAccount(source: string): ClaudeCredentials | null { - if (source === "file") { - return readCredentialsFile() - } + if (source === "file") return readCredentialsFile() + if (source === "env") return readEnvToken() const raw = readKeychainService(source) if (!raw) return null return parseCredentials(raw) From 7eaa62838f16c27adb1ffabc7b568ddd09e96eeb Mon Sep 17 00:00:00 2001 From: Griffin Martin Date: Wed, 29 Apr 2026 19:59:43 -0600 Subject: [PATCH 2/3] test(windows): cover env token, roaming AppData, and env refresh short-circuit Address review feedback from @bvironn on PR #200. Adds 14 tests for the three Windows-specific code paths the PR introduced; macOS/Linux CI can now exercise this behaviour via platform mocking and stubbed fixtures. Test additions: - src/keychain.test.ts: readEnvToken (via refreshAccount('env')) - decodes JWT exp claim into expiresAt - returns env value as accessToken with empty refreshToken - falls back to ~10h default for malformed JWTs (3 variants: not-base64, single-segment, missing-exp claim) - returns null when CLAUDE_CODE_OAUTH_TOKEN is unset - src/keychain.test.ts: writeBackCredentials('env') returns false (env credentials are read-only) - src/credentials.test.ts: getAuthJsonPaths - non-Windows returns only the XDG path - Windows returns XDG + Local + Roaming when env vars are set - Windows includes both Local and Roaming (regression guard for bug 2 in this PR) - Windows falls back to homedir-derived paths when env vars unset - src/credentials.test.ts: refreshIfNeeded(env source short-circuit) - returns null immediately when env token is expired - returns refreshed creds when env var rotated to a fresh token - returns null when env var is unset All three assert refreshAccount() is called exactly once, proving the env block does not fall through to the CLI fallback (which would call refreshAccount a second time). Production-code changes (additive, supporting the tests): - src/credentials.ts: export getAuthJsonPaths so it is unit-testable. No behaviour change. - src/keychain.ts: log JWT decode failures inside readEnvToken's catch block instead of swallowing silently. Matches the diagnostic style used elsewhere in this file (readKeychainEntry) and follows through on the spirit of the PR ("never silent"). --- src/credentials.test.ts | 302 +++++++++++++++++++++++++++++++++++++++- src/credentials.ts | 7 +- src/keychain.test.ts | 119 ++++++++++++++++ src/keychain.ts | 8 +- 4 files changed, 430 insertions(+), 6 deletions(-) diff --git a/src/credentials.test.ts b/src/credentials.test.ts index 14e4a59..b7ff7b8 100644 --- a/src/credentials.test.ts +++ b/src/credentials.test.ts @@ -1,9 +1,13 @@ import { describe, it } from "node:test" import assert from "node:assert/strict" -import { refreshViaOAuth, parseOAuthResponse } from "./credentials.ts" +import { + getAuthJsonPaths, + parseOAuthResponse, + refreshViaOAuth, +} from "./credentials.ts" import { chmodSync, mkdirSync, statSync, writeFileSync } from "node:fs" import { mkdtemp, readFile, writeFile } from "node:fs/promises" -import { tmpdir } from "node:os" +import { homedir, tmpdir } from "node:os" import { join } from "node:path" import { pathToFileURL } from "node:url" @@ -477,3 +481,297 @@ describe("parseOAuthResponse", () => { assert.equal(parseOAuthResponse("", currentRefresh, now), null) }) }) + +function withPlatform(value: NodeJS.Platform, fn: () => void): void { + const original = process.platform + Object.defineProperty(process, "platform", { + value, + configurable: true, + }) + try { + fn() + } finally { + Object.defineProperty(process, "platform", { + value: original, + configurable: true, + }) + } +} + +function withEnvVars( + vars: Record, + fn: () => void, +): void { + const originals: Record = {} + for (const key of Object.keys(vars)) { + originals[key] = process.env[key] + const v = vars[key] + if (v === undefined) delete process.env[key] + else process.env[key] = v + } + try { + fn() + } finally { + for (const key of Object.keys(originals)) { + const orig = originals[key] + if (typeof orig === "string") process.env[key] = orig + else delete process.env[key] + } + } +} + +describe("getAuthJsonPaths", () => { + const xdgPath = join(homedir(), ".local", "share", "opencode", "auth.json") + + it("returns only the XDG path on non-Windows platforms", () => { + withPlatform("darwin", () => { + assert.deepEqual(getAuthJsonPaths(), [xdgPath]) + }) + withPlatform("linux", () => { + assert.deepEqual(getAuthJsonPaths(), [xdgPath]) + }) + }) + + it("returns XDG + Local + Roaming AppData paths on Windows when both env vars are set", () => { + withPlatform("win32", () => { + withEnvVars( + { + LOCALAPPDATA: "C:\\Users\\test\\AppData\\Local", + APPDATA: "C:\\Users\\test\\AppData\\Roaming", + }, + () => { + const paths = getAuthJsonPaths() + assert.equal(paths.length, 3) + assert.equal(paths[0], xdgPath) + assert.equal( + paths[1], + join("C:\\Users\\test\\AppData\\Local", "opencode", "auth.json"), + ) + assert.equal( + paths[2], + join("C:\\Users\\test\\AppData\\Roaming", "opencode", "auth.json"), + ) + }, + ) + }) + }) + + it("includes both Local and Roaming Windows paths (regression: Roaming was previously missing)", () => { + // Bug 2 from PR #200: OpenCode reads from %APPDATA% (Roaming), but the + // plugin previously only wrote to %LOCALAPPDATA%. Both must be present. + withPlatform("win32", () => { + withEnvVars( + { + LOCALAPPDATA: "C:\\local", + APPDATA: "C:\\roaming", + }, + () => { + const paths = getAuthJsonPaths() + const hasLocal = paths.some((p) => p.startsWith("C:\\local")) + const hasRoaming = paths.some((p) => p.startsWith("C:\\roaming")) + assert.ok(hasLocal, "should include %LOCALAPPDATA% path") + assert.ok(hasRoaming, "should include %APPDATA% (Roaming) path") + }, + ) + }) + }) + + it("falls back to homedir-derived AppData paths when env vars are unset on Windows", () => { + withPlatform("win32", () => { + withEnvVars({ LOCALAPPDATA: undefined, APPDATA: undefined }, () => { + const paths = getAuthJsonPaths() + assert.equal(paths.length, 3) + assert.equal(paths[0], xdgPath) + assert.equal( + paths[1], + join(homedir(), "AppData", "Local", "opencode", "auth.json"), + ) + assert.equal( + paths[2], + join(homedir(), "AppData", "Roaming", "opencode", "auth.json"), + ) + }) + }) + }) +}) + +async function loadCredentialsWithCountingEnvKeychain( + envCreds: { + accessToken: string + refreshToken: string + expiresAt: number + } | null, +): Promise<{ + credentialsModule: { + refreshIfNeeded: (account: { + label: string + source: string + credentials: { + accessToken: string + refreshToken: string + expiresAt: number + } + }) => { + accessToken: string + refreshToken: string + expiresAt: number + } | null + } + keychainModule: { __getRefreshCount: () => number } +}> { + const tempDir = await mkdtemp(join(tmpdir(), "opencode-claude-auth-envref-")) + const tempKeychain = join(tempDir, "keychain.ts") + const tempBetas = join(tempDir, "betas.ts") + const tempLogger = join(tempDir, "logger.ts") + const tempCredentials = join(tempDir, "credentials.ts") + const sourceCredentials = await readFile( + new URL("./credentials.ts", import.meta.url), + "utf8", + ) + const rewritten = sourceCredentials.replace( + /from\s+["']\.\/(\w+)\.js["']/g, + 'from "./$1.ts"', + ) + + await writeFile( + tempLogger, + `export function log() {}\nexport function initLogger() {}\nexport function closeLogger() {}\n`, + "utf8", + ) + + // Stub keychain: refreshAccount returns the configured envCreds for "env", + // null otherwise. Counts every call so the test can assert the env + // short-circuit prevented a second call from the CLI fallback. + await writeFile( + tempKeychain, + `let refreshCount = 0 +const envCreds = ${JSON.stringify(envCreds)} +export function readAllClaudeAccounts() { return [] } +export function refreshAccount(source) { + refreshCount += 1 + if (source === "env") return envCreds + return null +} +export function writeBackCredentials() { return false } +export function buildAccountLabels(creds) { return creds.map((_, i) => \`A\${i+1}\`) } +export function __getRefreshCount() { return refreshCount } +`, + "utf8", + ) + + await writeFile( + tempBetas, + `export function resetExcludedBetas() {}\n`, + "utf8", + ) + await writeFile(tempCredentials, rewritten, "utf8") + + const [credentialsModule, keychainModule] = await Promise.all([ + import(pathToFileURL(tempCredentials).href), + import(pathToFileURL(tempKeychain).href), + ]) + + return { + credentialsModule: credentialsModule as { + refreshIfNeeded: (account: { + label: string + source: string + credentials: { + accessToken: string + refreshToken: string + expiresAt: number + } + }) => { + accessToken: string + refreshToken: string + expiresAt: number + } | null + }, + keychainModule: keychainModule as { __getRefreshCount: () => number }, + } +} + +describe("refreshIfNeeded (env source short-circuit)", () => { + it("returns null immediately when env token is also expired (skips CLI fallback)", async () => { + const now = Date.now() + // Stub refreshAccount("env") returns an already-expired credential. + const { credentialsModule, keychainModule } = + await loadCredentialsWithCountingEnvKeychain({ + accessToken: "expired-env-token", + refreshToken: "", + expiresAt: now - 60_000, // expired + }) + + const account = { + label: "Claude (env)", + source: "env", + credentials: { + accessToken: "old-env-token", + refreshToken: "", // empty: skips OAuth refresh path + expiresAt: now - 1_000, // expired -> triggers refreshIfNeeded body + }, + } + + const result = credentialsModule.refreshIfNeeded(account) + assert.equal(result, null) + + // Critical assertion: the env block calls refreshAccount exactly once. + // If the env short-circuit is removed, control falls through to the CLI + // fallback which calls refreshAccount AGAIN (line 323 in credentials.ts), + // making this count 2. So count===1 proves the short-circuit engaged + // and refreshViaCli() was skipped. + assert.equal( + keychainModule.__getRefreshCount(), + 1, + "env-source path should call refreshAccount exactly once and skip CLI fallback", + ) + }) + + it("returns refreshed credentials when env var has been rotated to a fresh token", async () => { + const now = Date.now() + const { credentialsModule, keychainModule } = + await loadCredentialsWithCountingEnvKeychain({ + accessToken: "fresh-env-token", + refreshToken: "", + expiresAt: now + 3_600_000, // 1h in the future + }) + + const account = { + label: "Claude (env)", + source: "env", + credentials: { + accessToken: "old-env-token", + refreshToken: "", + expiresAt: now - 1_000, // expired -> triggers refreshIfNeeded body + }, + } + + const result = credentialsModule.refreshIfNeeded(account) + assert.ok(result, "should return refreshed credentials") + assert.equal(result.accessToken, "fresh-env-token") + // Account credentials should have been updated in-place. + assert.equal(account.credentials.accessToken, "fresh-env-token") + assert.equal(keychainModule.__getRefreshCount(), 1) + }) + + it("returns null when CLAUDE_CODE_OAUTH_TOKEN has been unset entirely", async () => { + const now = Date.now() + const { credentialsModule, keychainModule } = + await loadCredentialsWithCountingEnvKeychain(null) + + const account = { + label: "Claude (env)", + source: "env", + credentials: { + accessToken: "old-env-token", + refreshToken: "", + expiresAt: now - 1_000, + }, + } + + assert.equal(credentialsModule.refreshIfNeeded(account), null) + // Still exactly one call — env block hits, gets null, returns null. + // Does NOT fall through to the CLI fallback that would call refreshAccount again. + assert.equal(keychainModule.__getRefreshCount(), 1) + }) +}) diff --git a/src/credentials.ts b/src/credentials.ts index d53bfa6..c5882f5 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -95,7 +95,7 @@ export function saveAccountSource(source: string): void { } } -function getAuthJsonPaths(): string[] { +export function getAuthJsonPaths(): string[] { const xdgPath = join(homedir(), ".local", "share", "opencode", "auth.json") if (process.platform === "win32") { // Write to both Local and Roaming AppData on Windows. @@ -313,7 +313,10 @@ export function refreshIfNeeded( target.credentials = refreshed return refreshed } - log("refresh_exhausted", { source: target.source, reason: "env_token_expired" }) + log("refresh_exhausted", { + source: target.source, + reason: "env_token_expired", + }) return null } diff --git a/src/keychain.test.ts b/src/keychain.test.ts index 5a05cf6..5f71bd7 100644 --- a/src/keychain.test.ts +++ b/src/keychain.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path" import { tmpdir } from "node:os" import { buildAccountLabels, + refreshAccount, updateCredentialBlob, writeBackCredentials, } from "./keychain.ts" @@ -498,6 +499,103 @@ describe("updateCredentialBlob", () => { }) }) +// Helper: build a JWT with the given payload claims. Signature is irrelevant +// — readEnvToken only decodes the payload, it does not verify. +function makeJwt(payload: Record): string { + const header = Buffer.from( + JSON.stringify({ alg: "none", typ: "JWT" }), + ).toString("base64url") + const body = Buffer.from(JSON.stringify(payload)).toString("base64url") + return `${header}.${body}.sig` +} + +function withEnvToken(value: string | undefined, fn: () => void): void { + const original = process.env.CLAUDE_CODE_OAUTH_TOKEN + if (value === undefined) { + delete process.env.CLAUDE_CODE_OAUTH_TOKEN + } else { + process.env.CLAUDE_CODE_OAUTH_TOKEN = value + } + try { + fn() + } finally { + if (typeof original === "string") { + process.env.CLAUDE_CODE_OAUTH_TOKEN = original + } else { + delete process.env.CLAUDE_CODE_OAUTH_TOKEN + } + } +} + +describe("readEnvToken (via refreshAccount('env'))", () => { + it("decodes the exp claim from a valid JWT into expiresAt (ms)", () => { + const expSeconds = Math.floor(Date.now() / 1000) + 3_600 + withEnvToken(makeJwt({ exp: expSeconds, sub: "user-1" }), () => { + const result = refreshAccount("env") + assert.ok(result, "should return credentials when env var is set") + assert.equal(result.expiresAt, expSeconds * 1000) + }) + }) + + it("returns the env var verbatim as accessToken with empty refreshToken", () => { + const token = makeJwt({ exp: Math.floor(Date.now() / 1000) + 3_600 }) + withEnvToken(token, () => { + const result = refreshAccount("env") + assert.ok(result) + assert.equal(result.accessToken, token) + assert.equal(result.refreshToken, "") + }) + }) + + it("falls back to ~10h default when JWT payload is malformed (not base64)", () => { + const before = Date.now() + withEnvToken("not.a.jwt", () => { + const result = refreshAccount("env") + assert.ok(result) + // 10h default = 36_000_000 ms; allow generous tolerance for test scheduling + const after = Date.now() + assert.ok( + result.expiresAt >= before + 36_000_000 - 5_000 && + result.expiresAt <= after + 36_000_000 + 5_000, + `expected ~10h default, got expiresAt=${result.expiresAt} relative to now`, + ) + }) + }) + + it("falls back to ~10h default when token is a single segment with no payload", () => { + const before = Date.now() + withEnvToken("opaque-token-no-dots", () => { + const result = refreshAccount("env") + assert.ok(result) + const after = Date.now() + assert.ok( + result.expiresAt >= before + 36_000_000 - 5_000 && + result.expiresAt <= after + 36_000_000 + 5_000, + ) + }) + }) + + it("falls back to ~10h default when payload JSON is missing the exp claim", () => { + const before = Date.now() + const tokenNoExp = makeJwt({ sub: "user-1" }) // no exp + withEnvToken(tokenNoExp, () => { + const result = refreshAccount("env") + assert.ok(result) + const after = Date.now() + assert.ok( + result.expiresAt >= before + 36_000_000 - 5_000 && + result.expiresAt <= after + 36_000_000 + 5_000, + ) + }) + }) + + it("returns null when CLAUDE_CODE_OAUTH_TOKEN is not set", () => { + withEnvToken(undefined, () => { + assert.equal(refreshAccount("env"), null) + }) + }) +}) + describe("writeBackCredentials (file source)", () => { it("reads, updates, and writes back credentials to file", async () => { const originalHome = process.env.HOME @@ -637,3 +735,24 @@ describe("writeBackCredentials (file source)", () => { } }) }) + +describe("writeBackCredentials (env source)", () => { + it("returns false because env-var credentials are read-only", () => { + const originalToken = process.env.CLAUDE_CODE_OAUTH_TOKEN + process.env.CLAUDE_CODE_OAUTH_TOKEN = "header.payload.sig" + try { + const result = writeBackCredentials("env", { + accessToken: "at", + refreshToken: "rt", + expiresAt: Date.now() + 600_000, + }) + assert.equal(result, false) + } finally { + if (typeof originalToken === "string") { + process.env.CLAUDE_CODE_OAUTH_TOKEN = originalToken + } else { + delete process.env.CLAUDE_CODE_OAUTH_TOKEN + } + } + }) +}) diff --git a/src/keychain.ts b/src/keychain.ts index ab40936..6024823 100644 --- a/src/keychain.ts +++ b/src/keychain.ts @@ -210,8 +210,12 @@ function readEnvToken(): ClaudeCredentials | null { expiresAt = payload.exp * 1000 } } - } catch { - // JWT decode failed — use default expiry + } catch (err) { + // JWT decode failed — use default expiry, but record why so failures + // aren't silent. + log("env_token_decode_failed", { + message: err instanceof Error ? err.message : String(err), + }) } log("env_token_read", { success: true, expiresAt }) From aefe0a8e74d0a47faf4cf0f2c5ba8acf6161ddbf Mon Sep 17 00:00:00 2001 From: Griffin Martin Date: Wed, 29 Apr 2026 20:04:08 -0600 Subject: [PATCH 3/3] docs: document CLAUDE_CODE_OAUTH_TOKEN env source and Roaming AppData sync Address README gap noted during PR review: PR #200 introduces a new consumed env var and changes the Windows auth.json sync target list, neither of which was reflected in README.md. - 'How it works': update Windows auth.json path list to include %APPDATA% (Roaming) alongside %LOCALAPPDATA%, and note that OpenCode itself reads from the Roaming location. - 'How it works': add a paragraph explaining the CLAUDE_CODE_OAUTH_TOKEN fallback on Windows + Claude Desktop. - 'Prerequisites': mention the env-var fallback path. - 'Environment variable overrides' table: add a row for CLAUDE_CODE_OAUTH_TOKEN, marked read-only and documented as set automatically by Claude Desktop (not intended for manual use). - 'How it works (technical)': replace the obsolete two-path Windows bullet with the new three-path version, and add a bullet describing the env-var fallback's read-only nature, JWT exp decoding, and the no-CLI-fallback short-circuit on expiry. --- README.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 33ca9fb..5b3d6ba 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,16 @@ Self-contained Anthropic auth provider for OpenCode using your Claude Code crede The plugin registers its own auth provider with a custom fetch handler that intercepts all Anthropic API requests. It reads OAuth tokens from the macOS Keychain (or `~/.claude/.credentials.json` on other platforms), caches them in memory with a 30-second TTL, and handles the full request lifecycle — no builtin Anthropic auth plugin required. On macOS, multiple Claude Code accounts are detected automatically and can be switched via `opencode auth login`. -It also syncs credentials to OpenCode's `auth.json` as a fallback (on Windows, it writes to both `%USERPROFILE%\.local\share\opencode\auth.json` and `%LOCALAPPDATA%\opencode\auth.json` to cover all installation methods). If a token is near expiry, it refreshes directly via Anthropic's OAuth endpoint (zero LLM tokens consumed), falling back to the Claude CLI if the direct refresh fails. Background re-sync runs every 5 minutes. +It also syncs credentials to OpenCode's `auth.json` as a fallback (on Windows, it writes to `%USERPROFILE%\.local\share\opencode\auth.json`, `%LOCALAPPDATA%\opencode\auth.json`, and `%APPDATA%\opencode\auth.json` to cover all installation methods — OpenCode itself reads from the Roaming `%APPDATA%` location). If a token is near expiry, it refreshes directly via Anthropic's OAuth endpoint (zero LLM tokens consumed), falling back to the Claude CLI if the direct refresh fails. Background re-sync runs every 5 minutes. + +On Windows, when Claude Code is launched from Claude Desktop, `claude auth login` never writes credentials to disk because Claude Desktop sets `CLAUDE_CODE_OAUTH_TOKEN` in the child environment and the CLI considers the session already authenticated. The plugin detects this case and reads the token directly from the environment as a last-resort credentials source. ## Prerequisites - Claude Code installed and authenticated (run `claude` at least once) - OpenCode installed -macOS is preferred (uses Keychain). Linux and Windows work via the credentials file fallback. +macOS is preferred (uses Keychain). Linux and Windows work via the credentials file fallback. On Windows, if Claude Code is launched from Claude Desktop, the plugin can also fall back to the `CLAUDE_CODE_OAUTH_TOKEN` env var that Claude Desktop sets for child processes. ## Installation @@ -180,13 +182,14 @@ This reads your stored credentials, calls Anthropic's OAuth token endpoint, and All configurable parameters can be overridden via environment variables. If Anthropic changes something before we publish an update, set an env var and keep working: -| Variable | Description | Default | -| ----------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `ANTHROPIC_CLI_VERSION` | Claude CLI version for user-agent and billing headers | `2.1.80` | -| `ANTHROPIC_USER_AGENT` | Full User-Agent string (overrides CLI version) | `claude-cli/{version} (external, cli)` | -| `ANTHROPIC_BETA_FLAGS` | Comma-separated beta feature flags | `claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,prompt-caching-scope-2026-01-05` | -| `ANTHROPIC_ENABLE_1M_CONTEXT` | Enable 1M token context window for 4.6+ models (requires Max subscription) | `false` | -| `CLAUDE_AUTH_DEBUG` | Enable diagnostic logging (`1` for default path, or a custom file path) | disabled | +| Variable | Description | Default | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `ANTHROPIC_CLI_VERSION` | Claude CLI version for user-agent and billing headers | `2.1.80` | +| `ANTHROPIC_USER_AGENT` | Full User-Agent string (overrides CLI version) | `claude-cli/{version} (external, cli)` | +| `ANTHROPIC_BETA_FLAGS` | Comma-separated beta feature flags | `claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,prompt-caching-scope-2026-01-05` | +| `ANTHROPIC_ENABLE_1M_CONTEXT` | Enable 1M token context window for 4.6+ models (requires Max subscription) | `false` | +| `CLAUDE_AUTH_DEBUG` | Enable diagnostic logging (`1` for default path, or a custom file path) | disabled | +| `CLAUDE_CODE_OAUTH_TOKEN` | Read-only fallback credential source on Windows when Claude Code is launched from Claude Desktop. The plugin reads this if `~/.claude/.credentials.json` is absent. Set automatically by Claude Desktop; not intended for manual use. | unset | Example: @@ -206,7 +209,8 @@ export ANTHROPIC_ENABLE_1M_CONTEXT=true # requires Claude Max - On macOS, enumerates all `Claude Code-credentials*` Keychain entries and labels them by subscription tier - Provides an account switcher via `opencode auth login` when multiple accounts are found; persists selection to `~/.local/share/opencode/claude-account-source.txt` - Syncs credentials to `auth.json` on startup and every 5 minutes as a fallback (sync never triggers refresh; refresh is lazy, only on API requests) -- On Windows, writes to both `%USERPROFILE%\.local\share\opencode\auth.json` and `%LOCALAPPDATA%\opencode\auth.json` +- On Windows, writes to `%USERPROFILE%\.local\share\opencode\auth.json`, `%LOCALAPPDATA%\opencode\auth.json`, and `%APPDATA%\opencode\auth.json` (OpenCode reads from the Roaming `%APPDATA%` path) +- On Windows, if Claude Code was launched from Claude Desktop and no on-disk credentials file exists, falls back to reading the OAuth token from `CLAUDE_CODE_OAUTH_TOKEN`. This source has no refresh token; expiry is decoded from the JWT `exp` claim (defaulting to ~10 hours if decode fails). When this token expires, the plugin returns null immediately rather than invoking the CLI fallback (which cannot help) - Retries API requests on 429 (rate limit) and 529 (overloaded) with exponential backoff, respecting `retry-after` headers - When a token is within 60 seconds of expiry, refreshes directly via `POST https://claude.ai/v1/oauth/token` (no LLM tokens consumed). Falls back to `claude` CLI if the direct refresh fails. New tokens are written back to Keychain (macOS) or credentials file (Linux/Windows) to keep stored credentials in sync with rotated refresh tokens - If credentials aren't OAuth-based, the auth loader returns `{}` and falls through to API key auth