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.
This commit is contained in:
+78
-205
@@ -14,21 +14,17 @@
|
|||||||
* Checks run sequentially, cheapest first:
|
* Checks run sequentially, cheapest first:
|
||||||
* 1. Repository path exists and contains .git
|
* 1. Repository path exists and contains .git
|
||||||
* 2. Config file parses and validates (if provided)
|
* 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 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 { ErrorCode } from '../types/errors.js';
|
||||||
import { type Result, ok, err } from '../types/result.js';
|
import { type Result, ok, err } from '../types/result.js';
|
||||||
import { parseConfig } from '../config-parser.js';
|
import { parseConfig } from '../config-parser.js';
|
||||||
import type { ActivityLogger } from '../types/activity-logger.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 ===
|
// === Repository Validation ===
|
||||||
|
|
||||||
async function validateRepo(
|
async function validateRepo(
|
||||||
@@ -124,187 +120,41 @@ async function validateConfig(
|
|||||||
|
|
||||||
// === Credential Validation ===
|
// === Credential Validation ===
|
||||||
|
|
||||||
/**
|
/** Map SDK error type to a human-readable preflight PentestError. */
|
||||||
* Validate a direct Anthropic API key via minimal Messages API call.
|
function classifySdkError(
|
||||||
* Costs ~$0.000025 (1 input token + 1 output token on Haiku).
|
sdkError: SDKAssistantMessageError,
|
||||||
*/
|
authType: string
|
||||||
async function validateApiKey(
|
): Result<void, PentestError> {
|
||||||
apiKey: string,
|
switch (sdkError) {
|
||||||
logger: ActivityLogger
|
case 'authentication_failed':
|
||||||
): Promise<Result<void, PentestError>> {
|
return err(new PentestError(
|
||||||
logger.info('Validating Anthropic API key...');
|
`Invalid ${authType}. Check your credentials in .env and try again.`,
|
||||||
|
'config', false, { authType, sdkError }, ErrorCode.AUTH_FAILED
|
||||||
try {
|
));
|
||||||
const response = await fetch(ANTHROPIC_MESSAGES_URL, {
|
case 'billing_error':
|
||||||
method: 'POST',
|
return err(new PentestError(
|
||||||
headers: {
|
`Anthropic account has a billing issue. Add credits or check your billing dashboard.`,
|
||||||
'Content-Type': 'application/json',
|
'billing', true, { authType, sdkError }, ErrorCode.BILLING_ERROR
|
||||||
'x-api-key': apiKey,
|
));
|
||||||
'anthropic-version': '2023-06-01',
|
case 'rate_limit':
|
||||||
},
|
return err(new PentestError(
|
||||||
body: JSON.stringify({
|
`Anthropic rate limit or spending cap reached. Wait a few minutes and try again.`,
|
||||||
model: VALIDATION_MODEL,
|
'billing', true, { authType, sdkError }, ErrorCode.BILLING_ERROR
|
||||||
max_tokens: 1,
|
));
|
||||||
messages: [{ role: 'user', content: 'hi' }],
|
case 'server_error':
|
||||||
}),
|
return err(new PentestError(
|
||||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
`Anthropic API is temporarily unavailable. Try again shortly.`,
|
||||||
});
|
'network', true, { authType, sdkError }
|
||||||
|
));
|
||||||
if (response.ok) {
|
default:
|
||||||
logger.info('API key OK');
|
return err(new PentestError(
|
||||||
return ok(undefined);
|
`${authType} validation failed unexpectedly. Check your credentials in .env.`,
|
||||||
}
|
'config', false, { authType, sdkError }, ErrorCode.AUTH_FAILED
|
||||||
|
));
|
||||||
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 }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Validate credentials via a minimal Claude Agent SDK query. */
|
||||||
* 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<Result<void, PentestError>> {
|
|
||||||
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
|
|
||||||
*/
|
|
||||||
async function validateCredentials(
|
async function validateCredentials(
|
||||||
logger: ActivityLogger
|
logger: ActivityLogger
|
||||||
): Promise<Result<void, PentestError>> {
|
): Promise<Result<void, PentestError>> {
|
||||||
@@ -314,28 +164,51 @@ async function validateCredentials(
|
|||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. OAuth token
|
// 2. Check that at least one credential is present
|
||||||
const oauthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
if (!process.env.ANTHROPIC_API_KEY && !process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||||
if (oauthToken) {
|
return err(
|
||||||
return validateOAuthToken(oauthToken, logger);
|
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
|
// 3. Validate via SDK query
|
||||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
const authType = process.env.CLAUDE_CODE_OAUTH_TOKEN ? 'OAuth token' : 'API key';
|
||||||
if (apiKey) {
|
logger.info(`Validating ${authType} via SDK...`);
|
||||||
return validateApiKey(apiKey, logger);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. No credentials
|
try {
|
||||||
return err(
|
for await (const message of query({ prompt: 'hi', options: { model: 'claude-haiku-4-5-20251001', maxTurns: 1 } })) {
|
||||||
new PentestError(
|
if (message.type === 'assistant' && message.error) {
|
||||||
'No API credentials found. Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN in .env',
|
return classifySdkError(message.error, authType);
|
||||||
'config',
|
}
|
||||||
false,
|
if (message.type === 'result') {
|
||||||
{},
|
break;
|
||||||
ErrorCode.AUTH_FAILED
|
}
|
||||||
)
|
}
|
||||||
);
|
|
||||||
|
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 ===
|
// === 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);
|
const credResult = await validateCredentials(logger);
|
||||||
if (!credResult.ok) {
|
if (!credResult.ok) {
|
||||||
return credResult;
|
return credResult;
|
||||||
|
|||||||
Reference in New Issue
Block a user