fix: remove CLAUDE_CODE_OAUTH_TOKEN injection that caused persistent 401 errors
The app was reading OAuth tokens from ~/.claude/.credentials.json and injecting them as CLAUDE_CODE_OAUTH_TOKEN into spawned CLI processes. These tokens are almost always stale because Claude Code refreshes tokens in-memory but rarely writes back to the credential store. When CLAUDE_CODE_OAUTH_TOKEN is set in the environment, the CLI uses it directly instead of going through its own auth/refresh flow, causing every API call to fail with 401 "OAuth token has expired". Remove all credential-file reading logic and let the CLI handle its own authentication. ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN (explicitly set by user) are still passed through. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2b3e0cfc2d
commit
1290c111c4
1 changed files with 12 additions and 114 deletions
|
|
@ -20,12 +20,11 @@ import {
|
|||
import { getMemberColor } from '@shared/constants/memberColors';
|
||||
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { execFile, spawn } from 'child_process';
|
||||
import { spawn } from 'child_process';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
import { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
|
||||
|
|
@ -63,7 +62,6 @@ const PROBE_CACHE_TTL_MS = 60_000;
|
|||
const PREFLIGHT_TIMEOUT_MS = 30000;
|
||||
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
||||
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
||||
const KEYCHAIN_TIMEOUT_MS = 5000;
|
||||
const FS_MONITOR_POLL_MS = 2000;
|
||||
const TASK_WAIT_FALLBACK_MS = 15_000;
|
||||
const TEAM_JSON_READ_TIMEOUT_MS = 5_000;
|
||||
|
|
@ -73,8 +71,6 @@ const PREFLIGHT_PING_PROMPT = 'Reply with the single word PONG and nothing else'
|
|||
const PREFLIGHT_PING_ARGS = ['-p', PREFLIGHT_PING_PROMPT, '--output-format', 'text'] as const;
|
||||
const PREFLIGHT_EXPECTED = 'PONG';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
type TeamsBaseLocation = 'configured' | 'default';
|
||||
|
||||
type ValidConfigProbeResult =
|
||||
|
|
@ -170,12 +166,7 @@ interface ProvisioningRun {
|
|||
|
||||
type LeadActivityState = 'active' | 'idle' | 'offline';
|
||||
|
||||
type ProvisioningAuthSource =
|
||||
| 'anthropic_api_key'
|
||||
| 'anthropic_auth_token'
|
||||
| 'claude_code_oauth_token_env'
|
||||
| 'claude_code_oauth_token_credentials'
|
||||
| 'none';
|
||||
type ProvisioningAuthSource = 'anthropic_api_key' | 'anthropic_auth_token' | 'none';
|
||||
|
||||
interface ProvisioningEnvResolution {
|
||||
env: NodeJS.ProcessEnv;
|
||||
|
|
@ -945,8 +936,7 @@ function buildCliExitError(code: number | null, stdoutText: string, stderrText:
|
|||
return (
|
||||
'Claude CLI reports it is not authenticated ("Please run /login"). ' +
|
||||
'Run `claude auth login` (or start `claude` and run `/login`) to authenticate, then retry. ' +
|
||||
'For automation/headless use, prefer `claude setup-token` and export `CLAUDE_CODE_OAUTH_TOKEN`, ' +
|
||||
'or set `ANTHROPIC_API_KEY` for `-p` mode.'
|
||||
'For automation/headless use, set `ANTHROPIC_API_KEY` for `-p` mode.'
|
||||
);
|
||||
}
|
||||
return trimmed.slice(-4000);
|
||||
|
|
@ -1101,7 +1091,7 @@ export class TeamProvisioningService {
|
|||
// authenticate through a mechanism we don't know about (e.g. a
|
||||
// managed apiKeyHelper, SSO, or a future auth flow).
|
||||
warnings.push(
|
||||
'No explicit auth env var found (ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN). ' +
|
||||
'No explicit auth env var found (ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN). ' +
|
||||
'Attempting preflight check to verify if CLI can authenticate on its own.'
|
||||
);
|
||||
}
|
||||
|
|
@ -1185,7 +1175,7 @@ export class TeamProvisioningService {
|
|||
const progress = updateProgress(run, 'failed', 'Authentication failed — CLI requires login', {
|
||||
error:
|
||||
'Claude CLI is not authenticated. Run `claude auth login` (or start `claude` and run `/login`) ' +
|
||||
'to authenticate, or set ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN and try again.',
|
||||
'to authenticate, or set ANTHROPIC_API_KEY and try again.',
|
||||
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
|
||||
});
|
||||
run.onProgress(progress);
|
||||
|
|
@ -1235,7 +1225,7 @@ export class TeamProvisioningService {
|
|||
return;
|
||||
}
|
||||
|
||||
// Respawn
|
||||
// Respawn with saved context — CLI handles its own auth refresh.
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawnCli(ctx.claudePath, ctx.args, {
|
||||
|
|
@ -3278,105 +3268,14 @@ export class TeamProvisioningService {
|
|||
return { env, authSource: 'anthropic_auth_token' };
|
||||
}
|
||||
|
||||
// 3. CLAUDE_CODE_OAUTH_TOKEN already in env (e.g. from `claude setup-token`)
|
||||
if (
|
||||
typeof env.CLAUDE_CODE_OAUTH_TOKEN === 'string' &&
|
||||
env.CLAUDE_CODE_OAUTH_TOKEN.trim().length > 0
|
||||
) {
|
||||
return { env, authSource: 'claude_code_oauth_token_env' };
|
||||
}
|
||||
|
||||
// 4. Try reading OAuth token from platform credential storage.
|
||||
// macOS: Keychain (service "Claude Code-credentials")
|
||||
// Linux: ~/.claude/.credentials.json
|
||||
// Note: keychain tokens may be stale — Claude Code refreshes in-memory
|
||||
// but does not always write back. We still try as best-effort.
|
||||
const oauthToken = await this.readOAuthTokenFromStorage(home);
|
||||
if (oauthToken) {
|
||||
env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken;
|
||||
return { env, authSource: 'claude_code_oauth_token_credentials' };
|
||||
}
|
||||
|
||||
// 3. No explicit API key — let the CLI handle its own OAuth auth.
|
||||
// Claude CLI reads credentials from its own storage and refreshes
|
||||
// tokens in-memory. Injecting CLAUDE_CODE_OAUTH_TOKEN from the
|
||||
// credentials file causes 401 errors because the stored token is
|
||||
// often stale (CLI refreshes in-memory but rarely writes back).
|
||||
return { env, authSource: 'none' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to read the OAuth access token from platform-specific storage.
|
||||
*
|
||||
* On macOS: reads from the encrypted Keychain (service "Claude Code-credentials").
|
||||
* On Linux: reads from ~/.claude/.credentials.json.
|
||||
*
|
||||
* Warning: the token retrieved here may be expired. Claude Code refreshes
|
||||
* tokens in-memory but does not always persist the refreshed value back to
|
||||
* the credential store. A subsequent preflight check (`claude -p "ping"`)
|
||||
* will detect if the token is actually usable.
|
||||
*/
|
||||
private async readOAuthTokenFromStorage(home: string): Promise<string | null> {
|
||||
const claudeBasePath = getClaudeBasePath();
|
||||
if (process.platform === 'darwin') {
|
||||
const keychainToken = await this.readOAuthTokenFromKeychain();
|
||||
if (keychainToken) {
|
||||
return keychainToken;
|
||||
}
|
||||
// Fallback: ~/.claude/.credentials.json (or overridden Claude root)
|
||||
return this.readOAuthTokenFromCredentialsFile(claudeBasePath, home);
|
||||
}
|
||||
return this.readOAuthTokenFromCredentialsFile(claudeBasePath, home);
|
||||
}
|
||||
|
||||
private async readOAuthTokenFromKeychain(): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'security',
|
||||
['find-generic-password', '-s', 'Claude Code-credentials', '-w'],
|
||||
{ timeout: KEYCHAIN_TIMEOUT_MS }
|
||||
);
|
||||
const parsed = JSON.parse(stdout.trim()) as unknown;
|
||||
return this.extractOAuthAccessToken(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async readOAuthTokenFromCredentialsFile(
|
||||
claudeBasePath: string,
|
||||
homeFallback: string
|
||||
): Promise<string | null> {
|
||||
// Preferred: current Claude root (supports claudeRootPath override)
|
||||
const primaryPath = path.join(claudeBasePath, '.credentials.json');
|
||||
// Back-compat: legacy location under HOME
|
||||
const legacyPath = path.join(homeFallback, '.claude', '.credentials.json');
|
||||
try {
|
||||
const raw = await fs.promises.readFile(primaryPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return this.extractOAuthAccessToken(parsed);
|
||||
} catch {
|
||||
try {
|
||||
const raw = await fs.promises.readFile(legacyPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return this.extractOAuthAccessToken(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractOAuthAccessToken(parsed: unknown): string | null {
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const root = parsed as { claudeAiOauth?: unknown };
|
||||
if (!root.claudeAiOauth || typeof root.claudeAiOauth !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const oauth = root.claudeAiOauth as { accessToken?: unknown };
|
||||
if (typeof oauth.accessToken !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const token = oauth.accessToken.trim();
|
||||
return token.length > 0 ? token : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately update projectPath in config.json at launch start, before CLI spawn.
|
||||
* Ensures TeamDetailView shows the correct project path even if provisioning
|
||||
|
|
@ -4076,8 +3975,7 @@ export class TeamProvisioningService {
|
|||
const hint = isAuthFailure
|
||||
? 'Claude CLI `-p` mode is not authenticated. ' +
|
||||
'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' +
|
||||
'For automation/headless use, set ANTHROPIC_API_KEY or run `claude setup-token` ' +
|
||||
'and export CLAUDE_CODE_OAUTH_TOKEN.' +
|
||||
'For automation/headless use, set ANTHROPIC_API_KEY.' +
|
||||
(attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
|
||||
: `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`;
|
||||
return { warning: hint };
|
||||
|
|
|
|||
Loading…
Reference in a new issue