Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
31 changes: 28 additions & 3 deletions src/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}
Expand Down Expand Up @@ -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.
Comment on lines +308 to +312

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The comment overstates what re-reading the env var can accomplish. Standard OS process semantics don't allow a parent process to update the environment of an already-running child process, so "Claude Desktop refreshed and updated its child environment" won't happen through normal means. The re-read is still worth doing (it's cheap and handles any in-process mutation of process.env), but the stated rationale will mislead future maintainers.

Suggested change
// 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.
// Env-var credentials have no refresh token and no CLI path.
// Re-read the env var in case process.env was mutated in-process since the
// last readAllClaudeAccounts() call. If the token has genuinely expired,
// return null immediately rather than blocking for up to 60 s on a CLI
// timeout that cannot help (the CLI would also see CLAUDE_CODE_OAUTH_TOKEN
// and skip writing credentials to disk).
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/credentials.ts
Line: 308-312

Comment:
The comment overstates what re-reading the env var can accomplish. Standard OS process semantics don't allow a parent process to update the environment of an already-running child process, so "Claude Desktop refreshed and updated its child environment" won't happen through normal means. The re-read is still worth doing (it's cheap and handles any in-process mutation of `process.env`), but the stated rationale will mislead future maintainers.

```suggestion
  // Env-var credentials have no refresh token and no CLI path.
  // Re-read the env var in case process.env was mutated in-process since the
  // last readAllClaudeAccounts() call. If the token has genuinely expired,
  // return null immediately rather than blocking for up to 60 s on a CLI
  // timeout that cannot help (the CLI would also see CLAUDE_CODE_OAUTH_TOKEN
  // and skip writing credentials to disk).
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Cursor Fix in Claude Code Fix in Codex

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()
Expand Down
64 changes: 58 additions & 6 deletions src/keychain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Loading