From e8e830c9f88a4da80acfa20ec792c569d2c6b1ad Mon Sep 17 00:00:00 2001 From: ezl-keygraph Date: Fri, 20 Feb 2026 17:00:31 +0530 Subject: [PATCH] refactor: replace HTTP credential checks with Claude Agent SDK query Replaces validateApiKey and validateOAuthToken (direct fetch calls) with a single SDK-based query using claude-haiku-4-5-20251001. Uses SDKAssistantMessageError types for structured error classification and returns human-readable error messages for each failure case. --- src/services/preflight.ts | 283 +++++++++++--------------------------- 1 file changed, 78 insertions(+), 205 deletions(-) diff --git a/src/services/preflight.ts b/src/services/preflight.ts index 5108895..b5f45a6 100644 --- a/src/services/preflight.ts +++ b/src/services/preflight.ts @@ -14,21 +14,17 @@ * Checks run sequentially, cheapest first: * 1. Repository path exists and contains .git * 2. Config file parses and validates (if provided) - * 3. Credentials validate (API key, OAuth token, or router mode) + * 3. Credentials validate via Claude Agent SDK query (API key, OAuth, or router mode) */ import fs from 'fs/promises'; -import { PentestError } from './error-handling.js'; +import { query, type SDKAssistantMessageError } from '@anthropic-ai/claude-agent-sdk'; +import { PentestError, isRetryableError } from './error-handling.js'; import { ErrorCode } from '../types/errors.js'; import { type Result, ok, err } from '../types/result.js'; import { parseConfig } from '../config-parser.js'; import type { ActivityLogger } from '../types/activity-logger.js'; -const VALIDATION_MODEL = 'claude-haiku-3-5-20241022'; -const ANTHROPIC_MESSAGES_URL = 'https://api.anthropic.com/v1/messages'; -const ANTHROPIC_OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'; -const FETCH_TIMEOUT_MS = 30_000; - // === Repository Validation === async function validateRepo( @@ -124,187 +120,41 @@ async function validateConfig( // === Credential Validation === -/** - * Validate a direct Anthropic API key via minimal Messages API call. - * Costs ~$0.000025 (1 input token + 1 output token on Haiku). - */ -async function validateApiKey( - apiKey: string, - logger: ActivityLogger -): Promise> { - logger.info('Validating Anthropic API key...'); - - try { - const response = await fetch(ANTHROPIC_MESSAGES_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify({ - model: VALIDATION_MODEL, - max_tokens: 1, - messages: [{ role: 'user', content: 'hi' }], - }), - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), - }); - - if (response.ok) { - logger.info('API key OK'); - return ok(undefined); - } - - let errorBody: string; - try { - errorBody = await response.text(); - } catch { - errorBody = ''; - } - - if (response.status === 401) { - return err( - new PentestError( - `API authentication failed: invalid x-api-key`, - 'config', - false, - { status: response.status }, - ErrorCode.AUTH_FAILED - ) - ); - } - - if (response.status === 402 || response.status === 403) { - return err( - new PentestError( - `Anthropic billing error (HTTP ${response.status}): ${errorBody.slice(0, 200)}`, - 'billing', - true, - { status: response.status }, - ErrorCode.BILLING_ERROR - ) - ); - } - - if (response.status === 429) { - return err( - new PentestError( - `Spending cap or rate limit reached (HTTP 429)`, - 'billing', - true, - { status: response.status }, - ErrorCode.BILLING_ERROR - ) - ); - } - - // Other status codes (5xx, etc) - transient - return err( - new PentestError( - `Anthropic API error (HTTP ${response.status}): ${errorBody.slice(0, 200)}`, - 'network', - true, - { status: response.status } - ) - ); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return err( - new PentestError( - `Failed to reach Anthropic API: ${message}`, - 'network', - true, - { originalError: message } - ) - ); +/** Map SDK error type to a human-readable preflight PentestError. */ +function classifySdkError( + sdkError: SDKAssistantMessageError, + authType: string +): Result { + switch (sdkError) { + case 'authentication_failed': + return err(new PentestError( + `Invalid ${authType}. Check your credentials in .env and try again.`, + 'config', false, { authType, sdkError }, ErrorCode.AUTH_FAILED + )); + case 'billing_error': + return err(new PentestError( + `Anthropic account has a billing issue. Add credits or check your billing dashboard.`, + 'billing', true, { authType, sdkError }, ErrorCode.BILLING_ERROR + )); + case 'rate_limit': + return err(new PentestError( + `Anthropic rate limit or spending cap reached. Wait a few minutes and try again.`, + 'billing', true, { authType, sdkError }, ErrorCode.BILLING_ERROR + )); + case 'server_error': + return err(new PentestError( + `Anthropic API is temporarily unavailable. Try again shortly.`, + 'network', true, { authType, sdkError } + )); + default: + return err(new PentestError( + `${authType} validation failed unexpectedly. Check your credentials in .env.`, + 'config', false, { authType, sdkError }, ErrorCode.AUTH_FAILED + )); } } -/** - * Validate an OAuth token via the Anthropic usage endpoint. - * Confirms the token is valid and checks quota availability. - */ -async function validateOAuthToken( - token: string, - logger: ActivityLogger -): Promise> { - logger.info('Validating OAuth token...'); - - try { - const response = await fetch(ANTHROPIC_OAUTH_USAGE_URL, { - method: 'GET', - headers: { - Authorization: `Bearer ${token}`, - }, - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), - }); - - if (response.ok) { - logger.info('OAuth token OK'); - return ok(undefined); - } - - let errorBody: string; - try { - errorBody = await response.text(); - } catch { - errorBody = ''; - } - - if (response.status === 401) { - return err( - new PentestError( - `OAuth token is invalid or expired`, - 'config', - false, - { status: response.status }, - ErrorCode.AUTH_FAILED - ) - ); - } - - if (response.status === 403 || response.status === 429) { - return err( - new PentestError( - `OAuth billing/quota error (HTTP ${response.status}): ${errorBody.slice(0, 200)}`, - 'billing', - true, - { status: response.status }, - ErrorCode.BILLING_ERROR - ) - ); - } - - return err( - new PentestError( - `OAuth validation error (HTTP ${response.status}): ${errorBody.slice(0, 200)}`, - 'network', - true, - { status: response.status } - ) - ); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return err( - new PentestError( - `Failed to reach Anthropic OAuth endpoint: ${message}`, - 'network', - true, - { originalError: message } - ) - ); - } -} - -/** - * Validate credentials based on detected auth mode. - * - * Auth modes (mutually exclusive): - * - Router mode (ANTHROPIC_BASE_URL set): skip validation, log warning - * - OAuth (CLAUDE_CODE_OAUTH_TOKEN set): validate via /api/oauth/usage - * - API key (ANTHROPIC_API_KEY set): validate via Messages API - * - None: error - */ +/** Validate credentials via a minimal Claude Agent SDK query. */ async function validateCredentials( logger: ActivityLogger ): Promise> { @@ -314,28 +164,51 @@ async function validateCredentials( return ok(undefined); } - // 2. OAuth token - const oauthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN; - if (oauthToken) { - return validateOAuthToken(oauthToken, logger); + // 2. Check that at least one credential is present + if (!process.env.ANTHROPIC_API_KEY && !process.env.CLAUDE_CODE_OAUTH_TOKEN) { + return err( + new PentestError( + 'No API credentials found. Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN in .env', + 'config', + false, + {}, + ErrorCode.AUTH_FAILED + ) + ); } - // 3. Direct API key - const apiKey = process.env.ANTHROPIC_API_KEY; - if (apiKey) { - return validateApiKey(apiKey, logger); - } + // 3. Validate via SDK query + const authType = process.env.CLAUDE_CODE_OAUTH_TOKEN ? 'OAuth token' : 'API key'; + logger.info(`Validating ${authType} via SDK...`); - // 4. No credentials - return err( - new PentestError( - 'No API credentials found. Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN in .env', - 'config', - false, - {}, - ErrorCode.AUTH_FAILED - ) - ); + try { + for await (const message of query({ prompt: 'hi', options: { model: 'claude-haiku-4-5-20251001', maxTurns: 1 } })) { + if (message.type === 'assistant' && message.error) { + return classifySdkError(message.error, authType); + } + if (message.type === 'result') { + break; + } + } + + logger.info(`${authType} OK`); + return ok(undefined); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const retryable = isRetryableError(error instanceof Error ? error : new Error(message)); + + return err( + new PentestError( + retryable + ? `Failed to reach Anthropic API. Check your network connection.` + : `${authType} validation failed: ${message}`, + retryable ? 'network' : 'config', + retryable, + { authType }, + retryable ? undefined : ErrorCode.AUTH_FAILED + ) + ); + } } // === Preflight Orchestrator === @@ -368,7 +241,7 @@ export async function runPreflightChecks( } } - // 3. Credential check (cheap — 1 token or single GET) + // 3. Credential check (cheap — 1 SDK round-trip) const credResult = await validateCredentials(logger); if (!credResult.ok) { return credResult;