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
134 changes: 134 additions & 0 deletions apps/server/src/lib/claude-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* Shared utilities for reading and parsing ~/.claude/settings.json
*
* This file centralizes all logic related to the Claude settings file
* to avoid code duplication across multiple files.
*/

import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { createLogger } from '@automaker/utils';

const logger = createLogger('ClaudeSettings');

export interface ClaudeSettings {
env?: Record<string, string>;
apiKey?: string;
api_key?: string;
oauthToken?: string;
oauth_token?: string;
primaryApiKey?: string;
}

/**
* Get the path to ~/.claude/settings.json
*/
export function getSettingsPath(): string {
return path.join(os.homedir(), '.claude', 'settings.json');
}

/**
* Load and parse ~/.claude/settings.json
* Returns null if file doesn't exist or is invalid
*/
export async function loadClaudeSettings(): Promise<ClaudeSettings | null> {
try {
const content = await fs.readFile(getSettingsPath(), 'utf-8');
return JSON.parse(content);
} catch (error: unknown) {
// It's fine if the file doesn't exist, but other errors should be logged
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code !== 'ENOENT') {
logger.error('Error reading or parsing ~/.claude/settings.json:', error);
}
return null;
}
}

/**
* Extract authentication token from settings
* Checks multiple locations in order of priority:
* 1. env.ANTHROPIC_AUTH_TOKEN - Claude Code format
* 2. oauthToken / oauth_token - root-level OAuth tokens
* 3. apiKey / api_key / primaryApiKey - root-level API keys
*/
export function extractAuthToken(settings: ClaudeSettings): string | null {
// Check env section (Claude Code format)
if (settings.env?.ANTHROPIC_AUTH_TOKEN) {
return settings.env.ANTHROPIC_AUTH_TOKEN;
}

// Check for OAuth tokens at root level
if (settings.oauthToken) {
return settings.oauthToken;
}
if (settings.oauth_token) {
return settings.oauth_token;
}

// Check for API keys at root level
if (settings.apiKey) {
return settings.apiKey;
}
if (settings.api_key) {
return settings.api_key;
}
if (settings.primaryApiKey) {
return settings.primaryApiKey;
}

return null;
}

/**
* Check if settings file exists and contains authentication
*/
export async function hasSettingsFileAuth(): Promise<boolean> {
const settings = await loadClaudeSettings();
return settings ? extractAuthToken(settings) !== null : false;
}

/**
* Get auth token from settings file
*/
export async function getSettingsFileToken(): Promise<string | null> {
const settings = await loadClaudeSettings();
return settings ? extractAuthToken(settings) : null;
}

/**
* Get the env section from settings file
* Returns null if file doesn't exist or has no env section
*/
export async function getSettingsEnv(): Promise<Record<string, string> | null> {
const settings = await loadClaudeSettings();
if (!settings) {
return null;
}

// Return the env section if it exists and has content
if (settings.env && typeof settings.env === 'object' && Object.keys(settings.env).length > 0) {
return settings.env;
}

// Build env from root-level tokens if no env section
const env: Record<string, string> = {};

// Check for OAuth tokens at root level
if (settings.oauthToken) {
env.ANTHROPIC_API_KEY = settings.oauthToken;
} else if (settings.oauth_token) {
env.ANTHROPIC_API_KEY = settings.oauth_token;
}
// Check for API keys at root level
else if (settings.apiKey) {
env.ANTHROPIC_API_KEY = settings.apiKey;
} else if (settings.api_key) {
env.ANTHROPIC_API_KEY = settings.api_key;
} else if (settings.primaryApiKey) {
env.ANTHROPIC_API_KEY = settings.primaryApiKey;
}

return Object.keys(env).length > 0 ? env : null;
}
96 changes: 93 additions & 3 deletions apps/server/src/providers/claude-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
*
* Wraps the @anthropic-ai/claude-agent-sdk for seamless integration
* with the provider architecture.
*
* Supports authentication via:
* 1. ~/.claude/settings.json - Loads env variables from settings file
* 2. Claude CLI (via claude login) - uses OAuth tokens from ~/.claude/
* 3. Anthropic API Key - via ANTHROPIC_API_KEY env var or in-memory storage
*/

import { query, type Options } from '@anthropic-ai/claude-agent-sdk';
Expand All @@ -13,6 +18,27 @@ import type {
InstallationStatus,
ModelDefinition,
} from './types.js';
import { getSettingsEnv, hasSettingsFileAuth } from '../lib/claude-settings.js';

/**
* Helper to get Anthropic API key from in-memory storage.
* Used as fallback when no env var or settings file is available.
*/
function getInMemoryApiKey(): string | undefined {
// Try dynamic import of in-memory storage
try {
// Dynamic import to avoid circular dependency
const setupCommon = require('../routes/setup/common.js');
const apiKey = setupCommon.getApiKey?.('anthropic');
if (apiKey) {
return apiKey;
}
} catch {
// Setup routes not available
}

return undefined;
}

export class ClaudeProvider extends BaseProvider {
getName(): string {
Expand All @@ -21,6 +47,9 @@ export class ClaudeProvider extends BaseProvider {

/**
* Execute a query using Claude Agent SDK
*
* Loads environment variables from ~/.claude/settings.json if available.
* This allows the SDK to use authentication tokens and other settings.
*/
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
const {
Expand All @@ -35,6 +64,46 @@ export class ClaudeProvider extends BaseProvider {
sdkSessionId,
} = options;

// Track original environment to restore later
const originalEnv: Record<string, string | undefined> = {};
const envKeysToRestore: string[] = [];

// Load settings from ~/.claude/settings.json
const settingsEnv = await getSettingsEnv();

// Set environment variables from settings file if available
if (settingsEnv) {
for (const [key, value] of Object.entries(settingsEnv)) {
// Save original value if it exists
if (key in process.env) {
originalEnv[key] = process.env[key];
}
envKeysToRestore.push(key);
// Set the environment variable
process.env[key] = value;
}
// Map ANTHROPIC_AUTH_TOKEN to ANTHROPIC_API_KEY if present
if (settingsEnv.ANTHROPIC_AUTH_TOKEN && !settingsEnv.ANTHROPIC_API_KEY) {
if ('ANTHROPIC_API_KEY' in process.env) {
originalEnv.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
}
if (!envKeysToRestore.includes('ANTHROPIC_API_KEY')) {
envKeysToRestore.push('ANTHROPIC_API_KEY');
}
process.env.ANTHROPIC_API_KEY = settingsEnv.ANTHROPIC_AUTH_TOKEN;
}
}

// If no API key env var is set yet (from settings or original env), try in-memory storage
if (!process.env.ANTHROPIC_API_KEY) {
const inMemoryApiKey = getInMemoryApiKey();
if (inMemoryApiKey) {
originalEnv.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
envKeysToRestore.push('ANTHROPIC_API_KEY');
process.env.ANTHROPIC_API_KEY = inMemoryApiKey;
}
}
Comment on lines 67 to 105
Copy link
Contributor

Choose a reason for hiding this comment

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

critical

Modifying the global process.env object is not safe for concurrent execution. If two requests call executeQuery at the same time, one request might use the environment variables set by the other, leading to incorrect authentication or data leakage. This is a critical issue for a server application.

Ideally, the credentials should be passed directly to the SDK's query function if its API supports it. If the SDK strictly relies on environment variables, you might need to consider a different approach, such as using child processes for execution or implementing a mutex/lock to ensure only one executeQuery runs at a time (which would hurt performance).

Comment on lines 71 to 105
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the actual file to understand the full context
cat -n apps/server/src/providers/claude-provider.ts | head -250

Repository: AutoMaker-Org/automaker

Length of output: 9075


🏁 Script executed:

# Also check if the file exists and get its size
wc -l apps/server/src/providers/claude-provider.ts

Repository: AutoMaker-Org/automaker

Length of output: 115


🏁 Script executed:

# Search for the Options type/interface definition in the codebase
rg -t ts -A 10 "interface\s+Options|type\s+Options" --max-count=20

Repository: AutoMaker-Org/automaker

Length of output: 886


🏁 Script executed:

# Check if there are any existing mutex or synchronization patterns in the codebase
rg -t ts "Mutex|Lock|acquire|release" --max-count=30

Repository: AutoMaker-Org/automaker

Length of output: 3823


🏁 Script executed:

# Look for how executeQuery is called to understand if it's actually concurrent
rg -t ts "executeQuery|executeQuery\(" apps/server/src/providers/ -B 2 -A 2

Repository: AutoMaker-Org/automaker

Length of output: 1228


🏁 Script executed:

# Check if Claude SDK is available and search for Options type definition
find node_modules/@anthropic-ai -name "*.d.ts" -o -name "*.ts" 2>/dev/null | head -20

Repository: AutoMaker-Org/automaker

Length of output: 49


🏁 Script executed:

# Try to find the Options interface definition in node_modules
find node_modules/@anthropic-ai -type f -name "*.d.ts" -exec grep -l "Options" {} \; 2>/dev/null | head -5

Repository: AutoMaker-Org/automaker

Length of output: 49


🏁 Script executed:

# Search for where executeQuery is called/invoked
rg -t ts "executeQuery\(" --max-count=20 -B 2 -A 2

Repository: AutoMaker-Org/automaker

Length of output: 8680


🏁 Script executed:

# Check for concurrent request handling patterns or queuing mechanisms
rg -t ts "Promise\.all|Promise\.race|Promise\.allSettled" apps/server/src/ -B 2 -A 2 --max-count=15

Repository: AutoMaker-Org/automaker

Length of output: 1511


🏁 Script executed:

# Look for any concurrency-related comments or TODOs
rg -t ts "(concurrent|parallel|race|queue|mutex|lock|serialize)" apps/server/src/providers/ -i

Repository: AutoMaker-Org/automaker

Length of output: 299


🏁 Script executed:

# Check package.json for Claude SDK version and see if it's listed as a dependency
cat package.json | grep -A 5 -B 5 "anthropic\|claude" | head -40

Repository: AutoMaker-Org/automaker

Length of output: 49


🏁 Script executed:

# Check if there's a package-lock.json or yarn.lock that might reference the SDK
find . -name "package.json" -type f | xargs grep -l "anthropic" 2>/dev/null | head -5

Repository: AutoMaker-Org/automaker

Length of output: 131


🏁 Script executed:

# Look at agent-service to understand how executeQuery is called
sed -n '1,100p' apps/server/src/services/agent-service.ts

Repository: AutoMaker-Org/automaker

Length of output: 2949


🏁 Script executed:

# Check if there are simultaneous calls to executeQuery in auto-mode-service
grep -n "executeQuery" apps/server/src/services/auto-mode-service.ts

Repository: AutoMaker-Org/automaker

Length of output: 399


🏁 Script executed:

# Search for test files related to concurrency or parallel execution
find apps/server/tests -name "*.test.ts" | xargs grep -l "concurrent\|parallel" 2>/dev/null

Repository: AutoMaker-Org/automaker

Length of output: 143


🏁 Script executed:

# Look at the auto-mode-service to see the context around executeQuery calls
sed -n '1100,1120p' apps/server/src/services/auto-mode-service.ts

Repository: AutoMaker-Org/automaker

Length of output: 787


🏁 Script executed:

# Check the context around line 1830
sed -n '1820,1850p' apps/server/src/services/auto-mode-service.ts

Repository: AutoMaker-Org/automaker

Length of output: 1297


🏁 Script executed:

# Look for the context around line 2047
sed -n '2035,2060p' apps/server/src/services/auto-mode-service.ts

Repository: AutoMaker-Org/automaker

Length of output: 1232


🏁 Script executed:

# Check if any of these executeQuery calls happen within Promise.all or similar concurrent patterns
grep -n "Promise.all\|Promise.race\|Promise.allSettled" apps/server/src/services/auto-mode-service.ts

Repository: AutoMaker-Org/automaker

Length of output: 49


🏁 Script executed:

# Check the structure - are executeQuery calls awaited sequentially?
sed -n '2040,2080p' apps/server/src/services/auto-mode-service.ts

Repository: AutoMaker-Org/automaker

Length of output: 2091


🏁 Script executed:

# Check if there are websocket or HTTP handlers that might call executeQuery from different concurrent requests
grep -n "executeQuery\|agentService\|provider.executeQuery" apps/server/src/routes/*.ts apps/server/src/routes/**/*.ts 2>/dev/null | head -40

Repository: AutoMaker-Org/automaker

Length of output: 1596


🏁 Script executed:

# Look at how agent-service methods are called from routes (potential concurrency point)
grep -rn "startConversation\|handleUserMessage" apps/server/src/routes --include="*.ts" | head -20

Repository: AutoMaker-Org/automaker

Length of output: 174


🏁 Script executed:

# Check if there's any concurrency protection in agent-service when handling requests
sed -n '150,300p' apps/server/src/services/agent-service.ts

Repository: AutoMaker-Org/automaker

Length of output: 5481


🏁 Script executed:

# Check for WebSocket or HTTP request handlers that might handle multiple concurrent requests
find apps/server/src/routes -name "*.ts" -type f | xargs grep -l "WebSocket\|ws\|concurrent" 2>/dev/null

Repository: AutoMaker-Org/automaker

Length of output: 946


🏁 Script executed:

# Look for rate limiting, queueing, or concurrency control mechanisms
rg -t ts "queue|Queue|limit|Limit|serial|Serial|concurrency" apps/server/src/ --max-count=20

Repository: AutoMaker-Org/automaker

Length of output: 6170


🏁 Script executed:

# Look at the send.ts route to see how executeQuery is called from HTTP handlers
cat -n apps/server/src/routes/agent/routes/send.ts | head -100

Repository: AutoMaker-Org/automaker

Length of output: 1914


🏁 Script executed:

# Check if there's a single provider instance or multiple
grep -n "ProviderFactory\|getProviderForModel\|new ClaudeProvider" apps/server/src -r --include="*.ts"

Repository: AutoMaker-Org/automaker

Length of output: 1578


🏁 Script executed:

# Look at how multiple concurrent requests could hit executeQuery
sed -n '1,50p' apps/server/src/routes/agent/routes/send.ts

Repository: AutoMaker-Org/automaker

Length of output: 1564


🏁 Script executed:

# Search for the Options type from Claude SDK - check what properties it supports
rg -t ts -A 30 "export.*type.*Options|export.*interface.*Options" apps/server/src --max-count=5

Repository: AutoMaker-Org/automaker

Length of output: 6323


🏁 Script executed:

# Check the actual import and see if we can find SDK type definitions
grep -A 20 "from '@anthropic-ai/claude-agent-sdk'" apps/server/src/providers/claude-provider.ts

Repository: AutoMaker-Org/automaker

Length of output: 759


🏁 Script executed:

# Try to find Claude SDK in package.json to see the version
grep -A 2 -B 2 "claude-agent-sdk\|@anthropic-ai" apps/server/package.json

Repository: AutoMaker-Org/automaker

Length of output: 226


🏁 Script executed:

# Look for any documentation or comments about the Claude SDK Options
rg -t ts "Claude.*Option|SDK.*Option" apps/server/src --max-count=10

Repository: AutoMaker-Org/automaker

Length of output: 598


🏁 Script executed:

# Check if there are concurrent sendMessage calls that could overlap
grep -n "sendMessage\|executeQuery" apps/server/src/services/agent-service.ts | head -20

Repository: AutoMaker-Org/automaker

Length of output: 149


🏁 Script executed:

# Look at the actual session handling to see if there's any serialization
sed -n '290,330p' apps/server/src/services/agent-service.ts

Repository: AutoMaker-Org/automaker

Length of output: 1194


Risk: Global state mutation causes concurrency issues in concurrent request scenarios.

The code modifies process.env directly (lines 122-142) with proper restoration in the finally block (lines 199-207), but this approach has a race condition window when multiple requests execute concurrently:

  • The HTTP /send handler (send.ts) launches agentService.sendMessage() without awaiting it, enabling concurrent background execution
  • Multiple concurrent requests can trigger overlapping executeQuery() calls with process.env mutations
  • Although each request restores its environment, the window between setting env vars and restoring them can cause different concurrent executions to interfere with each other's configurations

Suggested improvements:

  1. Pass environment to SDK directly (requires verification): Check if the Claude Agent SDK (@anthropic-ai/claude-agent-sdk v0.1.72+) accepts environment configuration as an Options parameter instead of mutating process.env.

  2. Add mutex/lock for env mutations: If the SDK doesn't support env config, wrap the environment manipulation in a mutex to serialize access:

import { Mutex } from 'async-mutex';
const envMutex = new Mutex();

async *executeQuery(options: ExecuteOptions) {
  const release = await envMutex.acquire();
  try {
    // ... environment manipulation
  } finally {
    release();
    // ... restore environment
  }
}
  1. Spawn child process for isolation if performance permits.
🤖 Prompt for AI Agents
In apps/server/src/providers/claude-provider.ts around lines 118 to 142, the
code mutates process.env to inject Anthropic settings which creates a race
condition when multiple requests call executeQuery concurrently; replace this
mutation with one of the following fixes: (1) prefer passing the settings
directly to the Claude SDK/agent when creating the client or invoking methods
(check the SDK docs for an options/config parameter) so no global state is
changed; (2) if the SDK has no config parameter, protect the environment
mutation with a mutex/lock so only one execution mutates process.env at a time
(acquire before setting, release after restoring) to serialize access; or (3) if
low contention/strong isolation is required, run the SDK call in a short-lived
child process with the env injected for that process only. Implement the chosen
approach and remove or keep the current process.env mutation only when it is
guarded by the mutex or scoped to a child process to eliminate concurrent
interference.


// Build Claude SDK options
const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
const toolsToUse = allowedTools || defaultTools;
Expand Down Expand Up @@ -92,6 +161,15 @@ export class ClaudeProvider extends BaseProvider {
} catch (error) {
console.error('[ClaudeProvider] executeQuery() error during execution:', error);
throw error;
} finally {
// Restore original environment
for (const key of envKeysToRestore) {
if (originalEnv[key] !== undefined) {
process.env[key] = originalEnv[key];
} else {
delete process.env[key];
}
}
}
}

Expand All @@ -100,13 +178,25 @@ export class ClaudeProvider extends BaseProvider {
*/
async detectInstallation(): Promise<InstallationStatus> {
// Claude SDK is always available since it's a dependency
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
// Check for authentication from multiple sources
const hasEnvApiKey = !!process.env.ANTHROPIC_API_KEY;
const hasInMemoryApiKey = !!getInMemoryApiKey();
const hasSettingsAuth = await hasSettingsFileAuth();

// Authenticated if we have any auth source
const authenticated = hasEnvApiKey || hasInMemoryApiKey || hasSettingsAuth;

const status: InstallationStatus = {
installed: true,
method: 'sdk',
hasApiKey,
authenticated: hasApiKey,
hasApiKey: hasEnvApiKey || hasInMemoryApiKey,
authenticated,
// Additional info about auth method
authMethod: hasSettingsAuth
? 'settings_file'
: hasEnvApiKey || hasInMemoryApiKey
? 'api_key'
: 'none',
};

return status;
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export interface InstallationStatus {
method?: 'cli' | 'npm' | 'brew' | 'sdk';
hasApiKey?: boolean;
authenticated?: boolean;
authMethod?: 'settings_file' | 'api_key' | 'cli_authenticated' | 'oauth_token' | 'none';
error?: string;
}

Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/routes/setup/get-claude-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import os from 'os';
import path from 'path';
import fs from 'fs/promises';
import { getApiKey } from './common.js';
import { hasSettingsFileAuth } from '../../lib/claude-settings.js';

const execAsync = promisify(exec);

Expand Down Expand Up @@ -90,13 +91,21 @@ export async function getClaudeStatus() {
hasStoredOAuthToken: !!getApiKey('anthropic_oauth_token'),
hasStoredApiKey: !!getApiKey('anthropic'),
hasEnvApiKey: !!process.env.ANTHROPIC_API_KEY,
hasSettingsFileAuth: false, // NEW: Check for ~/.claude/settings.json with auth
// Additional fields for detailed status
oauthTokenValid: false,
apiKeyValid: false,
hasCliAuth: false,
hasRecentActivity: false,
};

// Check for ~/.claude/settings.json with auth token (highest priority for SDK usage)
auth.hasSettingsFileAuth = await hasSettingsFileAuth();
if (auth.hasSettingsFileAuth && !auth.authenticated) {
auth.authenticated = true;
auth.method = 'settings_file';
}

const claudeDir = path.join(os.homedir(), '.claude');

// Check for recent Claude CLI activity - indicates working authentication
Expand Down
Loading