From c0d46cb6b9b1ed3cf4ed38b61683296e33b94671 Mon Sep 17 00:00:00 2001 From: ajmallesh Date: Thu, 19 Feb 2026 19:09:02 -0800 Subject: [PATCH 1/4] feat: add preflight validation phase with structured error reporting - Add preflight activity that validates repo path, config, and credentials before agent execution - Add formatWorkflowError() with pipe-delimited segments for multi-line log rendering - Add remediation hints for common failures (auth, billing, config errors) - Add REPO_NOT_FOUND, AUTH_FAILED, BILLING_ERROR codes with error classification - Add formatErrorBlock() in WorkflowLogger for indented error display --- src/audit/workflow-logger.ts | 20 +- src/services/error-handling.ts | 10 + src/services/preflight.ts | 379 +++++++++++++++++++++++++++++++++ src/temporal/activities.ts | 68 ++++++ src/temporal/workflows.ts | 110 +++++++++- src/types/errors.ts | 5 + 6 files changed, 590 insertions(+), 2 deletions(-) create mode 100644 src/services/preflight.ts diff --git a/src/audit/workflow-logger.ts b/src/audit/workflow-logger.ts index f01b80b..67a604c 100644 --- a/src/audit/workflow-logger.ts +++ b/src/audit/workflow-logger.ts @@ -311,6 +311,24 @@ export class WorkflowLogger { await this.logStream.write(line); } + /** + * Format a pipe-delimited error string into indented multi-line display. + * + * Input: "phase context|ErrorType|message|Hint: ..." + * Output: "Error: phase context\n ErrorType\n ..." + */ + private formatErrorBlock(errorString: string): string { + const segments = errorString.split('|'); + const label = 'Error: '; + const indent = ' '.repeat(label.length); + + const lines = segments.map((segment, i) => + i === 0 ? `${label}${segment.trim()}` : `${indent}${segment.trim()}` + ); + + return lines.join('\n') + '\n'; + } + /** * Log workflow completion with full summary */ @@ -330,7 +348,7 @@ export class WorkflowLogger { await this.logStream.write(`Agents: ${summary.completedAgents.length} completed\n`); if (summary.error) { - await this.logStream.write(`Error: ${summary.error}\n`); + await this.logStream.write(this.formatErrorBlock(summary.error)); } await this.logStream.write(`\n`); diff --git a/src/services/error-handling.ts b/src/services/error-handling.ts index 2f50d86..1c9c75c 100644 --- a/src/services/error-handling.ts +++ b/src/services/error-handling.ts @@ -148,6 +148,16 @@ function classifyByErrorCode( case ErrorCode.AGENT_EXECUTION_FAILED: return { type: 'AgentExecutionError', retryable: retryableFromError }; + // Preflight validation errors + case ErrorCode.REPO_NOT_FOUND: + return { type: 'ConfigurationError', retryable: false }; + + case ErrorCode.AUTH_FAILED: + return { type: 'AuthenticationError', retryable: false }; + + case ErrorCode.BILLING_ERROR: + return { type: 'BillingError', retryable: true }; + default: // Unknown code - fall through to string matching return { type: 'UnknownError', retryable: retryableFromError }; diff --git a/src/services/preflight.ts b/src/services/preflight.ts new file mode 100644 index 0000000..5108895 --- /dev/null +++ b/src/services/preflight.ts @@ -0,0 +1,379 @@ +// Copyright (C) 2025 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +/** + * Preflight Validation Service + * + * Runs cheap, fast checks before any agent execution begins. + * Catches configuration and credential problems early, saving + * time and API costs compared to failing mid-pipeline. + * + * 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) + */ + +import fs from 'fs/promises'; +import { PentestError } 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( + repoPath: string, + logger: ActivityLogger +): Promise> { + logger.info('Checking repository path...', { repoPath }); + + // 1. Check repo directory exists + try { + const stats = await fs.stat(repoPath); + if (!stats.isDirectory()) { + return err( + new PentestError( + `Repository path is not a directory: ${repoPath}`, + 'config', + false, + { repoPath }, + ErrorCode.REPO_NOT_FOUND + ) + ); + } + } catch { + return err( + new PentestError( + `Repository path does not exist: ${repoPath}`, + 'config', + false, + { repoPath }, + ErrorCode.REPO_NOT_FOUND + ) + ); + } + + // 2. Check .git directory exists + try { + const gitStats = await fs.stat(`${repoPath}/.git`); + if (!gitStats.isDirectory()) { + return err( + new PentestError( + `Not a git repository (no .git directory): ${repoPath}`, + 'config', + false, + { repoPath }, + ErrorCode.REPO_NOT_FOUND + ) + ); + } + } catch { + return err( + new PentestError( + `Not a git repository (no .git directory): ${repoPath}`, + 'config', + false, + { repoPath }, + ErrorCode.REPO_NOT_FOUND + ) + ); + } + + logger.info('Repository path OK'); + return ok(undefined); +} + +// === Config Validation === + +async function validateConfig( + configPath: string, + logger: ActivityLogger +): Promise> { + logger.info('Validating configuration file...', { configPath }); + + try { + await parseConfig(configPath); + logger.info('Configuration file OK'); + return ok(undefined); + } catch (error) { + if (error instanceof PentestError) { + return err(error); + } + const message = error instanceof Error ? error.message : String(error); + return err( + new PentestError( + `Configuration validation failed: ${message}`, + 'config', + false, + { configPath }, + ErrorCode.CONFIG_VALIDATION_FAILED + ) + ); + } +} + +// === 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 } + ) + ); + } +} + +/** + * 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 + */ +async function validateCredentials( + logger: ActivityLogger +): Promise> { + // 1. Router mode — can't validate provider keys, just warn + if (process.env.ANTHROPIC_BASE_URL) { + logger.warn('Router mode detected — skipping API credential validation'); + return ok(undefined); + } + + // 2. OAuth token + const oauthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN; + if (oauthToken) { + return validateOAuthToken(oauthToken, logger); + } + + // 3. Direct API key + const apiKey = process.env.ANTHROPIC_API_KEY; + if (apiKey) { + return validateApiKey(apiKey, logger); + } + + // 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 + ) + ); +} + +// === Preflight Orchestrator === + +/** + * Run all preflight checks sequentially (cheapest first). + * + * 1. Repository path exists and contains .git + * 2. Config file parses and validates (if configPath provided) + * 3. Credentials validate (API key, OAuth, or router mode) + * + * Returns on first failure. + */ +export async function runPreflightChecks( + repoPath: string, + configPath: string | undefined, + logger: ActivityLogger +): Promise> { + // 1. Repository check (free — filesystem only) + const repoResult = await validateRepo(repoPath, logger); + if (!repoResult.ok) { + return repoResult; + } + + // 2. Config check (free — filesystem + CPU) + if (configPath) { + const configResult = await validateConfig(configPath, logger); + if (!configResult.ok) { + return configResult; + } + } + + // 3. Credential check (cheap — 1 token or single GET) + const credResult = await validateCredentials(logger); + if (!credResult.ok) { + return credResult; + } + + logger.info('All preflight checks passed'); + return ok(undefined); +} diff --git a/src/temporal/activities.ts b/src/temporal/activities.ts index 9d8a03e..78780f0 100644 --- a/src/temporal/activities.ts +++ b/src/temporal/activities.ts @@ -36,6 +36,8 @@ import { AGENTS } from '../session-manager.js'; import { executeGitCommandWithRetry } from '../services/git-manager.js'; import type { ResumeAttempt } from '../audit/metrics-tracker.js'; import { createActivityLogger } from './activity-logger.js'; +import { runPreflightChecks } from '../services/preflight.js'; +import { isErr } from '../types/result.js'; // Max lengths to prevent Temporal protobuf buffer overflow const MAX_ERROR_MESSAGE_LENGTH = 2000; @@ -246,6 +248,72 @@ export async function runReportAgent(input: ActivityInput): Promise { + const startTime = Date.now(); + const attemptNumber = Context.current().info.attempt; + + const heartbeatInterval = setInterval(() => { + const elapsed = Math.floor((Date.now() - startTime) / 1000); + heartbeat({ phase: 'preflight', elapsedSeconds: elapsed, attempt: attemptNumber }); + }, HEARTBEAT_INTERVAL_MS); + + try { + const logger = createActivityLogger(); + logger.info('Running preflight validation...', { attempt: attemptNumber }); + + const result = await runPreflightChecks(input.repoPath, input.configPath, logger); + + if (isErr(result)) { + const classified = classifyErrorForTemporal(result.error); + const message = truncateErrorMessage(result.error.message); + + if (classified.retryable) { + const failure = ApplicationFailure.create({ + message, + type: classified.type, + details: [{ phase: 'preflight', attemptNumber, elapsed: Date.now() - startTime }], + }); + truncateStackTrace(failure); + throw failure; + } else { + const failure = ApplicationFailure.nonRetryable(message, classified.type, [ + { phase: 'preflight', attemptNumber, elapsed: Date.now() - startTime }, + ]); + truncateStackTrace(failure); + throw failure; + } + } + + logger.info('Preflight validation passed'); + } catch (error) { + if (error instanceof ApplicationFailure) { + throw error; + } + + const classified = classifyErrorForTemporal(error); + const rawMessage = error instanceof Error ? error.message : String(error); + const message = truncateErrorMessage(rawMessage); + + const failure = ApplicationFailure.nonRetryable(message, classified.type, [ + { phase: 'preflight', attemptNumber, elapsed: Date.now() - startTime }, + ]); + truncateStackTrace(failure); + throw failure; + } finally { + clearInterval(heartbeatInterval); + } +} + /** * Assemble the final report by concatenating exploitation evidence files. */ diff --git a/src/temporal/workflows.ts b/src/temporal/workflows.ts index 45344d5..6fbadeb 100644 --- a/src/temporal/workflows.ts +++ b/src/temporal/workflows.ts @@ -86,6 +86,106 @@ const testActs = proxyActivities({ retry: TESTING_RETRY, }); +// Retry configuration for preflight validation (short timeout, few retries) +const PREFLIGHT_RETRY = { + initialInterval: '10 seconds', + maximumInterval: '1 minute', + backoffCoefficient: 2, + maximumAttempts: 3, + nonRetryableErrorTypes: PRODUCTION_RETRY.nonRetryableErrorTypes, +}; + +// Activity proxy for preflight validation (short timeout) +const preflightActs = proxyActivities({ + startToCloseTimeout: '2 minutes', + heartbeatTimeout: '2 minutes', + retry: PREFLIGHT_RETRY, +}); + +/** Maps Temporal error type strings to actionable remediation hints. */ +const REMEDIATION_HINTS: Record = { + AuthenticationError: + 'Verify ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN in .env is valid and not expired.', + ConfigurationError: 'Check your CONFIG file path and contents.', + BillingError: + 'Check your Anthropic billing dashboard. Add credits or wait for spending cap reset.', + GitError: 'Check repository path and git state.', + InvalidTargetError: 'Verify the target URL is correct and accessible.', + PermissionError: 'Check file and network permissions.', + ExecutionLimitError: 'Agent exceeded maximum turns or budget. Review prompt complexity.', +}; + +/** + * Walk the .cause chain to find the innermost error with a .type property. + * Temporal wraps ApplicationFailure in ActivityFailure — the useful info is inside. + * + * Uses duck-typing because workflow code cannot import @temporalio/activity types. + */ +function unwrapActivityError(error: unknown): { + message: string; + type: string | null; +} { + let current: unknown = error; + let typed: { message: string; type: string } | null = null; + + while (current instanceof Error) { + if ('type' in current && typeof (current as { type: unknown }).type === 'string') { + typed = { + message: current.message, + type: (current as { type: string }).type, + }; + } + current = (current as { cause?: unknown }).cause; + } + + if (typed) { + return typed; + } + + return { + message: error instanceof Error ? error.message : String(error), + type: null, + }; +} + +/** + * Format a structured error string from workflow catch context. + * Segments are delimited by | for multi-line rendering by WorkflowLogger. + */ +function formatWorkflowError( + error: unknown, + currentPhase: string | null, + currentAgent: string | null +): string { + const unwrapped = unwrapActivityError(error); + + // Phase context (first segment) + let phaseContext = 'Pipeline failed'; + if (currentPhase && currentAgent && currentPhase !== currentAgent) { + phaseContext = `${currentPhase} failed (agent: ${currentAgent})`; + } else if (currentPhase) { + phaseContext = `${currentPhase} failed`; + } + + const segments: string[] = [phaseContext]; + + if (unwrapped.type) { + segments.push(unwrapped.type); + } + + // Sanitize pipe characters from message to preserve delimiter format + segments.push(unwrapped.message.replaceAll('|', '/')); + + if (unwrapped.type) { + const hint = REMEDIATION_HINTS[unwrapped.type]; + if (hint) { + segments.push(`Hint: ${hint}`); + } + } + + return segments.join('|'); +} + /** * Compute aggregated metrics from the current pipeline state. * Called on both success and failure to provide partial metrics. @@ -298,6 +398,14 @@ export async function pentestPipelineWorkflow( } try { + // === Preflight Validation === + // Quick sanity checks before committing to expensive agent runs. + // NOT using runSequentialPhase — preflight doesn't produce AgentMetrics. + state.currentPhase = 'preflight'; + state.currentAgent = null; + await preflightActs.runPreflightValidation(activityInput); + log.info('Preflight validation passed'); + // === Phase 1: Pre-Reconnaissance === await runSequentialPhase('pre-recon', 'pre-recon', a.runPreReconAgent); @@ -409,7 +517,7 @@ export async function pentestPipelineWorkflow( } catch (error) { state.status = 'failed'; state.failedAgent = state.currentAgent; - state.error = error instanceof Error ? error.message : String(error); + state.error = formatWorkflowError(error, state.currentPhase, state.currentAgent); state.summary = computeSummary(state); // Log workflow failure summary diff --git a/src/types/errors.ts b/src/types/errors.ts index f67594f..568fa6a 100644 --- a/src/types/errors.ts +++ b/src/types/errors.ts @@ -39,6 +39,11 @@ export enum ErrorCode { // Validation errors (PentestErrorType: 'validation') DELIVERABLE_NOT_FOUND = 'DELIVERABLE_NOT_FOUND', + + // Preflight validation errors + REPO_NOT_FOUND = 'REPO_NOT_FOUND', + AUTH_FAILED = 'AUTH_FAILED', + BILLING_ERROR = 'BILLING_ERROR', } export type PentestErrorType = From 7ecf5abb3535be78c6decba603742fdea8e8e750 Mon Sep 17 00:00:00 2001 From: ajmallesh Date: Thu, 19 Feb 2026 22:20:20 -0800 Subject: [PATCH 2/4] refactor: extract error formatting utilities from workflows.ts into workflow-errors.ts --- src/temporal/workflow-errors.ts | 94 +++++++++++++++++++++++++++++++++ src/temporal/workflows.ts | 88 +----------------------------- 2 files changed, 96 insertions(+), 86 deletions(-) create mode 100644 src/temporal/workflow-errors.ts diff --git a/src/temporal/workflow-errors.ts b/src/temporal/workflow-errors.ts new file mode 100644 index 0000000..3062080 --- /dev/null +++ b/src/temporal/workflow-errors.ts @@ -0,0 +1,94 @@ +// Copyright (C) 2025 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +/** + * Workflow error formatting utilities. + * Pure functions with no side effects — safe for Temporal workflow sandbox. + */ + +/** Maps Temporal error type strings to actionable remediation hints. */ +const REMEDIATION_HINTS: Record = { + AuthenticationError: + 'Verify ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN in .env is valid and not expired.', + ConfigurationError: 'Check your CONFIG file path and contents.', + BillingError: + 'Check your Anthropic billing dashboard. Add credits or wait for spending cap reset.', + GitError: 'Check repository path and git state.', + InvalidTargetError: 'Verify the target URL is correct and accessible.', + PermissionError: 'Check file and network permissions.', + ExecutionLimitError: 'Agent exceeded maximum turns or budget. Review prompt complexity.', +}; + +/** + * Walk the .cause chain to find the innermost error with a .type property. + * Temporal wraps ApplicationFailure in ActivityFailure — the useful info is inside. + * + * Uses duck-typing because workflow code cannot import @temporalio/activity types. + */ +function unwrapActivityError(error: unknown): { + message: string; + type: string | null; +} { + let current: unknown = error; + let typed: { message: string; type: string } | null = null; + + while (current instanceof Error) { + if ('type' in current && typeof (current as { type: unknown }).type === 'string') { + typed = { + message: current.message, + type: (current as { type: string }).type, + }; + } + current = (current as { cause?: unknown }).cause; + } + + if (typed) { + return typed; + } + + return { + message: error instanceof Error ? error.message : String(error), + type: null, + }; +} + +/** + * Format a structured error string from workflow catch context. + * Segments are delimited by | for multi-line rendering by WorkflowLogger. + */ +export function formatWorkflowError( + error: unknown, + currentPhase: string | null, + currentAgent: string | null +): string { + const unwrapped = unwrapActivityError(error); + + // Phase context (first segment) + let phaseContext = 'Pipeline failed'; + if (currentPhase && currentAgent && currentPhase !== currentAgent) { + phaseContext = `${currentPhase} failed (agent: ${currentAgent})`; + } else if (currentPhase) { + phaseContext = `${currentPhase} failed`; + } + + const segments: string[] = [phaseContext]; + + if (unwrapped.type) { + segments.push(unwrapped.type); + } + + // Sanitize pipe characters from message to preserve delimiter format + segments.push(unwrapped.message.replaceAll('|', '/')); + + if (unwrapped.type) { + const hint = REMEDIATION_HINTS[unwrapped.type]; + if (hint) { + segments.push(`Hint: ${hint}`); + } + } + + return segments.join('|'); +} diff --git a/src/temporal/workflows.ts b/src/temporal/workflows.ts index 6fbadeb..4950f6c 100644 --- a/src/temporal/workflows.ts +++ b/src/temporal/workflows.ts @@ -41,10 +41,10 @@ import { type AgentMetrics, type ResumeState, } from './shared.js'; -import type { VulnType } from '../services/queue-validation.js'; -import type { AgentName } from '../types/agents.js'; +import type { AgentName, VulnType } from '../types/agents.js'; import { ALL_AGENTS } from '../types/agents.js'; import { toWorkflowSummary } from './summary-mapper.js'; +import { formatWorkflowError } from './workflow-errors.js'; // Retry configuration for production (long intervals for billing recovery) const PRODUCTION_RETRY = { @@ -102,90 +102,6 @@ const preflightActs = proxyActivities({ retry: PREFLIGHT_RETRY, }); -/** Maps Temporal error type strings to actionable remediation hints. */ -const REMEDIATION_HINTS: Record = { - AuthenticationError: - 'Verify ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN in .env is valid and not expired.', - ConfigurationError: 'Check your CONFIG file path and contents.', - BillingError: - 'Check your Anthropic billing dashboard. Add credits or wait for spending cap reset.', - GitError: 'Check repository path and git state.', - InvalidTargetError: 'Verify the target URL is correct and accessible.', - PermissionError: 'Check file and network permissions.', - ExecutionLimitError: 'Agent exceeded maximum turns or budget. Review prompt complexity.', -}; - -/** - * Walk the .cause chain to find the innermost error with a .type property. - * Temporal wraps ApplicationFailure in ActivityFailure — the useful info is inside. - * - * Uses duck-typing because workflow code cannot import @temporalio/activity types. - */ -function unwrapActivityError(error: unknown): { - message: string; - type: string | null; -} { - let current: unknown = error; - let typed: { message: string; type: string } | null = null; - - while (current instanceof Error) { - if ('type' in current && typeof (current as { type: unknown }).type === 'string') { - typed = { - message: current.message, - type: (current as { type: string }).type, - }; - } - current = (current as { cause?: unknown }).cause; - } - - if (typed) { - return typed; - } - - return { - message: error instanceof Error ? error.message : String(error), - type: null, - }; -} - -/** - * Format a structured error string from workflow catch context. - * Segments are delimited by | for multi-line rendering by WorkflowLogger. - */ -function formatWorkflowError( - error: unknown, - currentPhase: string | null, - currentAgent: string | null -): string { - const unwrapped = unwrapActivityError(error); - - // Phase context (first segment) - let phaseContext = 'Pipeline failed'; - if (currentPhase && currentAgent && currentPhase !== currentAgent) { - phaseContext = `${currentPhase} failed (agent: ${currentAgent})`; - } else if (currentPhase) { - phaseContext = `${currentPhase} failed`; - } - - const segments: string[] = [phaseContext]; - - if (unwrapped.type) { - segments.push(unwrapped.type); - } - - // Sanitize pipe characters from message to preserve delimiter format - segments.push(unwrapped.message.replaceAll('|', '/')); - - if (unwrapped.type) { - const hint = REMEDIATION_HINTS[unwrapped.type]; - if (hint) { - segments.push(`Hint: ${hint}`); - } - } - - return segments.join('|'); -} - /** * Compute aggregated metrics from the current pipeline state. * Called on both success and failure to provide partial metrics. From e8e830c9f88a4da80acfa20ec792c569d2c6b1ad Mon Sep 17 00:00:00 2001 From: ezl-keygraph Date: Fri, 20 Feb 2026 17:00:31 +0530 Subject: [PATCH 3/4] 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; From 839686c23cc9e60dba9336d34137ae784fda189b Mon Sep 17 00:00:00 2001 From: ajmallesh Date: Fri, 20 Feb 2026 07:49:53 -0800 Subject: [PATCH 4/4] refactor: use SDK-exported SDKAssistantMessageError instead of local type definition --- src/ai/message-handlers.ts | 2 +- src/ai/types.ts | 10 ++-------- src/services/preflight.ts | 3 ++- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/ai/message-handlers.ts b/src/ai/message-handlers.ts index e60ddb5..78068ad 100644 --- a/src/ai/message-handlers.ts +++ b/src/ai/message-handlers.ts @@ -19,9 +19,9 @@ import { } from './output-formatters.js'; import type { AuditLogger } from './audit-logger.js'; import type { ProgressManager } from './progress-manager.js'; +import type { SDKAssistantMessageError } from '@anthropic-ai/claude-agent-sdk'; import type { AssistantMessage, - SDKAssistantMessageError, ResultMessage, ToolUseMessage, ToolResultMessage, diff --git a/src/ai/types.ts b/src/ai/types.ts index af1a6f6..ee98bdf 100644 --- a/src/ai/types.ts +++ b/src/ai/types.ts @@ -6,6 +6,8 @@ // Type definitions for Claude executor message processing pipeline +import type { SDKAssistantMessageError } from '@anthropic-ai/claude-agent-sdk'; + export interface ExecutionContext { isParallelExecution: boolean; useCleanOutput: boolean; @@ -51,14 +53,6 @@ export interface ContentBlock { text?: string; } -export type SDKAssistantMessageError = - | 'authentication_failed' - | 'billing_error' - | 'rate_limit' - | 'invalid_request' - | 'server_error' - | 'max_output_tokens' - | 'unknown'; export interface AssistantMessage { type: 'assistant'; diff --git a/src/services/preflight.ts b/src/services/preflight.ts index b5f45a6..95d6a08 100644 --- a/src/services/preflight.ts +++ b/src/services/preflight.ts @@ -18,7 +18,8 @@ */ import fs from 'fs/promises'; -import { query, type SDKAssistantMessageError } from '@anthropic-ai/claude-agent-sdk'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import 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';