Merge pull request #14 from AlexeyZelenko/fix/remove-stale-oauth-token-injection

fix: remove stale OAuth token injection causing 401 errors
This commit is contained in:
Илия 2026-03-04 22:26:40 +02:00 committed by GitHub
commit ea4cf85e2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 231 additions and 183 deletions

View file

@ -1,38 +1,16 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path // empty' | { read f; for p in pnpm-lock.yaml .env dist/ dist-electron/ node_modules/; do if [ -n \"$f\" ] && echo \"$f\" | grep -q \"$p\"; then echo \"Blocked: $f matches protected pattern '$p'\" >&2; exit 2; fi; done; exit 0; }"
}
]
}
"alwaysThinkingEnabled": true,
"permissions": {
"allow": [
"Edit",
"Bash",
"ReadFile(*)",
"Web Search",
"WebSearch",
"WebFetch",
"Fetch"
],
"PostToolUse": [
{
"matcher": "Edit|Write|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path // .tool_input.notebook_path // empty' | { read file_path; if [ -n \"$file_path\" ] && echo \"$file_path\" | grep -qE '\\.(ts|tsx|js|jsx)$'; then pnpm eslint --fix \"$file_path\" 2>/dev/null || true; fi; if [ -n \"$file_path\" ] && echo \"$file_path\" | grep -qE '\\.(ts|tsx|js|jsx|json|css)$'; then pnpm prettier --write \"$file_path\" 2>/dev/null || true; fi; }",
"timeout": 30
}
]
}
],
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "echo '[Post-compaction context reminder]\n- Package manager: pnpm only (not npm/yarn)\n- Path aliases: @main/*, @renderer/*, @shared/*, @preload/*\n- Electron 3-process: main/ (Node), preload/ (bridge), renderer/ (React), shared/ (cross-process)\n- isMeta: false = real user message, true = internal/system message\n- Chunk types: UserChunk, AIChunk, SystemChunk, CompactChunk\n- State: Zustand slices pattern (data, selectedId, loading, error)\n- Styling: Tailwind with CSS variables (bg-surface, text-text, border-border)\n- Naming: PascalCase services/components, camelCase utils, UPPER_SNAKE constants\n- Barrel exports: import from domain index.ts\n- New IPC: channel in preload/constants → handler in main/ipc → method in preload/index.ts\n- PostToolUse hook auto-runs eslint+prettier on edited files\n- PreToolUse hook blocks edits to pnpm-lock.yaml, .env, dist/, node_modules/'"
}
]
}
]
"deny": [],
"ask": []
}
}
}

View file

@ -97,6 +97,8 @@ export class FileWatcher extends EventEmitter {
private pendingReprocess = new Set<string>();
/** Flag to prevent reuse after disposal */
private disposed = false;
/** Timestamp when this FileWatcher instance was created (used to distinguish old vs new files) */
private readonly instanceCreatedAt = Date.now();
constructor(
dataCache: DataCache,
@ -751,6 +753,7 @@ export class FileWatcher extends EventEmitter {
return;
}
const isFirstRead = lastLineCount === 0 && lastSize === 0;
const canUseIncrementalAppend = lastLineCount > 0 && currentSize > lastSize;
let newMessages: ParsedMessage[] = [];
let currentLineCount: number;
@ -777,6 +780,22 @@ export class FileWatcher extends EventEmitter {
return;
}
// On first read (after app restart), establish baseline without detecting errors
// for files that existed BEFORE this FileWatcher started. This prevents flooding
// notifications with historical errors from old sessions.
// Files created AFTER startup are new sessions — detect errors normally.
if (isFirstRead) {
const isPreExistingFile = fileStats.birthtimeMs < this.instanceCreatedAt;
if (isPreExistingFile) {
this.lastProcessedLineCount.set(filePath, currentLineCount);
this.lastProcessedSize.set(filePath, processedSize);
logger.info(
`FileWatcher: Baseline established for ${filePath} (${currentLineCount} lines, ${processedSize} bytes)`
);
return;
}
}
// Detect errors in new messages
// Note: We pass the offset-adjusted line numbers to errorDetector
const errors = await errorDetector.detectErrors(newMessages, sessionId, projectId, filePath);

View file

@ -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);
@ -1096,20 +1086,10 @@ export class TeamProvisioningService {
const warnings: string[] = [];
if (authSource === 'none') {
// No explicit auth found. Still attempt preflight — the CLI may
// 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). ' +
'Attempting preflight check to verify if CLI can authenticate on its own.'
);
}
if (authSource === 'anthropic_auth_token') {
warnings.push(
'Using ANTHROPIC_AUTH_TOKEN (proxy) mapped to ANTHROPIC_API_KEY for `-p` mode.'
);
if (authSource === 'anthropic_api_key') {
logger.info('Auth: using explicit ANTHROPIC_API_KEY');
} else if (authSource === 'anthropic_auth_token') {
logger.info('Auth: using ANTHROPIC_AUTH_TOKEN mapped to ANTHROPIC_API_KEY');
}
const probe = await this.probeClaudeRuntime(claudePath, targetCwd, executionEnv);
@ -1185,7 +1165,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 +1215,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, {
@ -1473,13 +1453,7 @@ export class TeamProvisioningService {
const prompt = buildProvisioningPrompt(request);
let child: ReturnType<typeof spawn>;
const { env: shellEnv, authSource } = await this.buildProvisioningEnv();
if (authSource === 'none') {
logger.warn(
'No explicit auth env var found for `-p` mode. ' +
'Attempting spawn anyway — CLI may authenticate via apiKeyHelper, SSO, or other mechanism.'
);
}
const { env: shellEnv } = await this.buildProvisioningEnv();
const spawnArgs = [
'--input-format',
'stream-json',
@ -1779,13 +1753,7 @@ export class TeamProvisioningService {
const prompt = buildLaunchPrompt(request, expectedMemberSpecs, existingTasks);
let child: ReturnType<typeof spawn>;
const { env: shellEnv, authSource } = await this.buildProvisioningEnv();
if (authSource === 'none') {
logger.warn(
'No explicit auth env var found for `-p` mode (launch). ' +
'Attempting spawn anyway — CLI may authenticate via apiKeyHelper, SSO, or other mechanism.'
);
}
const { env: shellEnv } = await this.buildProvisioningEnv();
const launchArgs = [
'--input-format',
'stream-json',
@ -3278,105 +3246,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 +3953,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 };

View file

@ -429,12 +429,6 @@ describe('FileWatcher', () => {
const detectPromise = new Promise<void>((resolve) => {
detectResolve = resolve;
});
vi.mocked(errorDetector.detectErrors).mockImplementation(
() =>
new Promise((resolve) => {
detectPromise.then(() => resolve([]));
})
);
const watcherAny = watcher as unknown as {
detectErrorsInSessionFile: (
@ -444,9 +438,27 @@ describe('FileWatcher', () => {
) => Promise<void>;
processingInProgress: Set<string>;
pendingReprocess: Set<string>;
instanceCreatedAt: number;
};
// Ensure watcher treats the file as pre-existing so first call baselines
watcherAny.instanceCreatedAt = Date.now() + 60_000;
// Start first call (will block on detectErrors)
// First call establishes baseline (skips error detection on first read)
vi.mocked(errorDetector.detectErrors).mockResolvedValue([]);
await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath);
// Append new data so subsequent calls have new lines to process
fs.appendFileSync(filePath, jsonlLine('u2', 'world'));
// Now make detectErrors slow to simulate long processing
vi.mocked(errorDetector.detectErrors).mockImplementation(
() =>
new Promise((resolve) => {
detectPromise.then(() => resolve([]));
})
);
// Start call that will block on detectErrors (not first read anymore)
const first = watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath);
// Wait a tick so the first call enters the processing block and reaches detectErrors
@ -512,7 +524,10 @@ describe('FileWatcher', () => {
) => Promise<void>;
lastProcessedSize: Map<string, number>;
lastProcessedLineCount: Map<string, number>;
instanceCreatedAt: number;
};
// Treat file as new (created after watcher) so it goes through the full parse path
watcherAny.instanceCreatedAt = 0;
// First call - fallback path (no lastProcessedLineCount)
await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath);
@ -527,6 +542,166 @@ describe('FileWatcher', () => {
});
});
// ===========================================================================
// First-Read Baseline Tests (prevents old session error flooding)
// ===========================================================================
describe('first-read baseline behavior', () => {
it('establishes baseline without detecting errors for pre-existing files', async () => {
vi.useRealTimers();
useRealExistsSync();
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-baseline-'));
const projectsDir = path.join(tempDir, 'projects');
const projectDir = path.join(projectsDir, 'test-project');
fs.mkdirSync(projectDir, { recursive: true });
const filePath = path.join(projectDir, 'session-1.jsonl');
// Write a file with multiple lines (simulating an existing session with errors)
fs.writeFileSync(
filePath,
jsonlLine('u1', 'hello') + jsonlLine('u2', 'world') + jsonlLine('u3', 'error line'),
'utf8'
);
const dataCache = new DataCache(50, 10, false);
const notificationManager = createMockNotificationManager();
const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos'));
watcher.setNotificationManager(notificationManager);
// Simulate watcher starting well after the file was created
const watcherAny = watcher as unknown as {
detectErrorsInSessionFile: (
projectId: string,
sessionId: string,
filePath: string
) => Promise<void>;
lastProcessedLineCount: Map<string, number>;
lastProcessedSize: Map<string, number>;
instanceCreatedAt: number;
};
watcherAny.instanceCreatedAt = Date.now() + 60_000; // watcher "started" in the future
vi.mocked(errorDetector.detectErrors).mockClear();
// First read should establish baseline, NOT detect errors
await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath);
// errorDetector.detectErrors should NOT have been called
expect(errorDetector.detectErrors).not.toHaveBeenCalled();
// Baseline tracking should be established
expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(3);
expect(watcherAny.lastProcessedSize.get(filePath)).toBe(fs.statSync(filePath).size);
// notificationManager.addError should NOT have been called
expect(notificationManager.addError).not.toHaveBeenCalled();
watcher.stop();
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('detects errors only in new data after baseline is established', async () => {
vi.useRealTimers();
useRealExistsSync();
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-post-baseline-'));
const projectsDir = path.join(tempDir, 'projects');
const projectDir = path.join(projectsDir, 'test-project');
fs.mkdirSync(projectDir, { recursive: true });
const filePath = path.join(projectDir, 'session-1.jsonl');
// Initial content (old session data)
fs.writeFileSync(filePath, jsonlLine('u1', 'hello') + jsonlLine('u2', 'world'), 'utf8');
const dataCache = new DataCache(50, 10, false);
const notificationManager = createMockNotificationManager();
const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos'));
watcher.setNotificationManager(notificationManager);
// Simulate watcher starting well after the file was created
const watcherAny = watcher as unknown as {
detectErrorsInSessionFile: (
projectId: string,
sessionId: string,
filePath: string
) => Promise<void>;
lastProcessedLineCount: Map<string, number>;
instanceCreatedAt: number;
};
watcherAny.instanceCreatedAt = Date.now() + 60_000;
vi.mocked(errorDetector.detectErrors).mockClear();
vi.mocked(errorDetector.detectErrors).mockResolvedValue([]);
// First read: baseline only
await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath);
expect(errorDetector.detectErrors).not.toHaveBeenCalled();
expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(2);
// Append new data
fs.appendFileSync(filePath, jsonlLine('u3', 'new error'));
// Second read: should detect errors in new data only
await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath);
expect(errorDetector.detectErrors).toHaveBeenCalledTimes(1);
// Verify only the new message was passed to detectErrors
const callArgs = vi.mocked(errorDetector.detectErrors).mock.calls[0];
expect(callArgs[0]).toHaveLength(1); // only 1 new message
// Tracking should now reflect all 3 lines
expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(3);
watcher.stop();
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('detects errors immediately for files created after watcher startup', async () => {
vi.useRealTimers();
useRealExistsSync();
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-newfile-'));
const projectsDir = path.join(tempDir, 'projects');
const projectDir = path.join(projectsDir, 'test-project');
fs.mkdirSync(projectDir, { recursive: true });
const dataCache = new DataCache(50, 10, false);
const notificationManager = createMockNotificationManager();
const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos'));
watcher.setNotificationManager(notificationManager);
vi.mocked(errorDetector.detectErrors).mockClear();
vi.mocked(errorDetector.detectErrors).mockResolvedValue([]);
// instanceCreatedAt is already set to "now" by the constructor,
// and the file created below will have birthtimeMs >= instanceCreatedAt,
// so it will be treated as a new file (no baseline skip)
const filePath = path.join(projectDir, 'session-new.jsonl');
fs.writeFileSync(filePath, jsonlLine('u1', 'hello') + jsonlLine('u2', 'error'), 'utf8');
const watcherAny = watcher as unknown as {
detectErrorsInSessionFile: (
projectId: string,
sessionId: string,
filePath: string
) => Promise<void>;
lastProcessedLineCount: Map<string, number>;
};
// First read of a NEW file should detect errors (not baseline-skip)
await watcherAny.detectErrorsInSessionFile('test-project', 'session-new', filePath);
expect(errorDetector.detectErrors).toHaveBeenCalledTimes(1);
const callArgs = vi.mocked(errorDetector.detectErrors).mock.calls[0];
expect(callArgs[0]).toHaveLength(2); // all messages scanned
expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(2);
watcher.stop();
fs.rmSync(tempDir, { recursive: true, force: true });
});
});
// ===========================================================================
// Timer Lifecycle Tests
// ===========================================================================