mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-08-14 15:30:25 +02:00
feat: provider extensions and drop claude-code-router mode (#295)
* feat: add ReportOutputProvider for consumer-extended report artifacts * fix: thread deliverablesSubdir through report assembly * fix: produce structured report JSON on resume path * fix: fail loud on structured report output provider errors * feat: extend checkpoint provider and container DI for consumer-specific backends * fix: pre-create .shannon overlay mount points on all platforms * chore: drop claude-code-router mode * fix: drop 'resets' keyword from spending-cap text patterns
This commit is contained in:
@@ -19,32 +19,5 @@ services:
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
router:
|
||||
image: node:20-slim
|
||||
container_name: shannon-router
|
||||
profiles: ["router"]
|
||||
command: >
|
||||
sh -c "apt-get update && apt-get install -y gettext-base &&
|
||||
npm install -g @musistudio/claude-code-router &&
|
||||
mkdir -p /root/.claude-code-router &&
|
||||
envsubst < /config/router-config.json > /root/.claude-code-router/config.json &&
|
||||
ccr start"
|
||||
ports:
|
||||
- "127.0.0.1:3456:3456"
|
||||
volumes:
|
||||
- ./router-config.json:/config/router-config.json:ro
|
||||
environment:
|
||||
- HOST=0.0.0.0
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
|
||||
- ROUTER_DEFAULT=${ROUTER_DEFAULT:-openai,gpt-4o}
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3456/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
volumes:
|
||||
temporal-data:
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"HOST": "0.0.0.0",
|
||||
"APIKEY": "shannon-router-key",
|
||||
"LOG": true,
|
||||
"LOG_LEVEL": "info",
|
||||
"NON_INTERACTIVE_MODE": true,
|
||||
"API_TIMEOUT_MS": 600000,
|
||||
"Providers": [
|
||||
{
|
||||
"name": "openai",
|
||||
"api_base_url": "https://api.openai.com/v1/chat/completions",
|
||||
"api_key": "$OPENAI_API_KEY",
|
||||
"models": ["gpt-5.2", "gpt-5-mini"],
|
||||
"transformer": {
|
||||
"use": [["maxcompletiontokens", { "max_completion_tokens": 16384 }]]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "openrouter",
|
||||
"api_base_url": "https://openrouter.ai/api/v1/chat/completions",
|
||||
"api_key": "$OPENROUTER_API_KEY",
|
||||
"models": ["google/gemini-3-flash-preview"],
|
||||
"transformer": {
|
||||
"use": ["openrouter"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"Router": {
|
||||
"default": "$ROUTER_DEFAULT"
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { type ShannonConfig, saveConfig } from '../config/writer.js';
|
||||
|
||||
const SHANNON_HOME = path.join(os.homedir(), '.shannon');
|
||||
|
||||
type Provider = 'anthropic' | 'custom_base_url' | 'bedrock' | 'vertex' | 'router';
|
||||
type Provider = 'anthropic' | 'custom_base_url' | 'bedrock' | 'vertex';
|
||||
|
||||
export async function setup(): Promise<void> {
|
||||
p.intro('Shannon Setup');
|
||||
@@ -26,7 +26,6 @@ export async function setup(): Promise<void> {
|
||||
{ value: 'custom_base_url' as const, label: 'Custom Base URL', hint: 'proxies, gateways' },
|
||||
{ value: 'bedrock' as const, label: 'Claude via AWS Bedrock' },
|
||||
{ value: 'vertex' as const, label: 'Claude via Google Vertex AI' },
|
||||
{ value: 'router' as const, label: 'Router', hint: 'experimental' },
|
||||
],
|
||||
});
|
||||
if (p.isCancel(provider)) return cancelAndExit();
|
||||
@@ -51,8 +50,6 @@ async function setupProvider(provider: Provider): Promise<ShannonConfig> {
|
||||
return setupBedrock();
|
||||
case 'vertex':
|
||||
return setupVertex();
|
||||
case 'router':
|
||||
return setupRouter();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,50 +279,6 @@ async function setupVertex(): Promise<ShannonConfig> {
|
||||
};
|
||||
}
|
||||
|
||||
async function setupRouter(): Promise<ShannonConfig> {
|
||||
const routerProvider = await p.select({
|
||||
message: 'Router provider',
|
||||
options: [
|
||||
{ value: 'openai' as const, label: 'OpenAI' },
|
||||
{ value: 'openrouter' as const, label: 'OpenRouter' },
|
||||
],
|
||||
});
|
||||
if (p.isCancel(routerProvider)) return cancelAndExit();
|
||||
|
||||
const apiKey = await promptSecret(
|
||||
routerProvider === 'openai' ? 'Enter your OpenAI API key' : 'Enter your OpenRouter API key',
|
||||
);
|
||||
|
||||
let defaultModel: string;
|
||||
if (routerProvider === 'openai') {
|
||||
const model = await p.select({
|
||||
message: 'Default model',
|
||||
options: [
|
||||
{ value: 'gpt-5.2' as const, label: 'GPT-5.2' },
|
||||
{ value: 'gpt-5-mini' as const, label: 'GPT-5 Mini' },
|
||||
],
|
||||
});
|
||||
if (p.isCancel(model)) return cancelAndExit();
|
||||
defaultModel = `openai,${model}`;
|
||||
} else {
|
||||
const model = await p.select({
|
||||
message: 'Default model',
|
||||
options: [{ value: 'google/gemini-3-flash-preview' as const, label: 'Google Gemini 3 Flash Preview' }],
|
||||
});
|
||||
if (p.isCancel(model)) return cancelAndExit();
|
||||
defaultModel = `openrouter,${model}`;
|
||||
}
|
||||
|
||||
const router: ShannonConfig['router'] = { default: defaultModel };
|
||||
if (routerProvider === 'openai') {
|
||||
router.openai_key = apiKey;
|
||||
} else {
|
||||
router.openrouter_key = apiKey;
|
||||
}
|
||||
|
||||
return { router };
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
async function promptSecret(message: string): Promise<string> {
|
||||
|
||||
@@ -7,10 +7,9 @@
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { ensureImage, ensureInfra, randomSuffix, spawnWorker } from '../docker.js';
|
||||
import { buildEnvFlags, isRouterConfigured, loadEnv, validateCredentials } from '../env.js';
|
||||
import { buildEnvFlags, loadEnv, validateCredentials } from '../env.js';
|
||||
import { getCredentialsPath, getWorkspacesDir, initHome } from '../home.js';
|
||||
import { isLocal } from '../mode.js';
|
||||
import { resolveConfig, resolveRepo } from '../paths.js';
|
||||
@@ -23,7 +22,6 @@ export interface StartArgs {
|
||||
workspace?: string;
|
||||
output?: string;
|
||||
pipelineTesting: boolean;
|
||||
router: boolean;
|
||||
version: string;
|
||||
}
|
||||
|
||||
@@ -32,13 +30,12 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
initHome();
|
||||
loadEnv();
|
||||
|
||||
// 2. Validate credentials and auto-detect router mode
|
||||
// 2. Validate credentials
|
||||
const creds = validateCredentials();
|
||||
if (!creds.valid) {
|
||||
console.error(`ERROR: ${creds.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const useRouter = args.router || isRouterConfigured();
|
||||
|
||||
// 3. Resolve paths
|
||||
const repo = resolveRepo(args.repo);
|
||||
@@ -49,26 +46,20 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
fs.mkdirSync(workspacesDir, { recursive: true });
|
||||
fs.chmodSync(workspacesDir, 0o777);
|
||||
|
||||
// 5. Handle router env
|
||||
if (useRouter) {
|
||||
process.env.ANTHROPIC_BASE_URL = 'http://shannon-router:3456';
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'shannon-router-key';
|
||||
}
|
||||
|
||||
// 6. Ensure image (auto-build in dev, pull in npx) and start infra
|
||||
// 5. Ensure image (auto-build in dev, pull in npx) and start infra
|
||||
ensureImage(args.version);
|
||||
await ensureInfra(useRouter);
|
||||
await ensureInfra();
|
||||
|
||||
// 7. Generate unique task queue and container name
|
||||
// 6. Generate unique task queue and container name
|
||||
const suffix = randomSuffix();
|
||||
const taskQueue = `shannon-${suffix}`;
|
||||
const containerName = `shannon-worker-${suffix}`;
|
||||
|
||||
// 8. Generate workspace name if not provided
|
||||
// 7. Generate workspace name if not provided
|
||||
const workspace =
|
||||
args.workspace ?? `${new URL(args.url).hostname.replace(/[^a-zA-Z0-9-]/g, '-')}_shannon-${Date.now()}`;
|
||||
|
||||
// 9. Create writable overlay directories (mounted over :ro repo paths inside container)
|
||||
// 8. Create writable overlay directories (mounted over :ro repo paths inside container)
|
||||
// Workspace dir must be 0o777 so the container user (UID 1001) can create audit subdirs
|
||||
const workspacePath = path.join(workspacesDir, workspace);
|
||||
fs.mkdirSync(workspacePath, { recursive: true });
|
||||
@@ -79,12 +70,10 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
fs.chmodSync(dirPath, 0o777);
|
||||
}
|
||||
|
||||
// 10. Pre-create overlay mount points (Linux :ro mounts can't auto-create them)
|
||||
if (os.platform() === 'linux') {
|
||||
const shannonDir = path.join(repo.hostPath, '.shannon');
|
||||
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli']) {
|
||||
fs.mkdirSync(path.join(shannonDir, dir), { recursive: true });
|
||||
}
|
||||
// 9. Pre-create overlay mount points (:ro mounts can't auto-create them)
|
||||
const shannonDir = path.join(repo.hostPath, '.shannon');
|
||||
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli']) {
|
||||
fs.mkdirSync(path.join(shannonDir, dir), { recursive: true });
|
||||
}
|
||||
|
||||
const credentialsPath = getCredentialsPath();
|
||||
@@ -172,7 +161,7 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
|
||||
// Clear waiting line and show info
|
||||
process.stdout.write('\r\x1b[K');
|
||||
printInfo(args, useRouter, workspace, workflowId, repo.hostPath, workspacesDir);
|
||||
printInfo(args, workspace, workflowId, repo.hostPath, workspacesDir);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
@@ -208,7 +197,6 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
|
||||
function printInfo(
|
||||
args: StartArgs,
|
||||
routerActive: boolean,
|
||||
workspace: string,
|
||||
workflowId: string,
|
||||
repoPath: string,
|
||||
@@ -226,9 +214,6 @@ function printInfo(
|
||||
if (args.pipelineTesting) {
|
||||
console.log(' Mode: Pipeline Testing');
|
||||
}
|
||||
if (routerActive) {
|
||||
console.log(' Router: Enabled');
|
||||
}
|
||||
console.log('');
|
||||
console.log(' Monitor:');
|
||||
if (workflowId) {
|
||||
|
||||
@@ -44,11 +44,6 @@ const CONFIG_MAP: readonly ConfigMapping[] = [
|
||||
{ env: 'ANTHROPIC_BASE_URL', toml: 'custom_base_url.base_url', type: 'string' },
|
||||
{ env: 'ANTHROPIC_AUTH_TOKEN', toml: 'custom_base_url.auth_token', type: 'string' },
|
||||
|
||||
// Router
|
||||
{ env: 'ROUTER_DEFAULT', toml: 'router.default', type: 'string' },
|
||||
{ env: 'OPENAI_API_KEY', toml: 'router.openai_key', type: 'string' },
|
||||
{ env: 'OPENROUTER_API_KEY', toml: 'router.openrouter_key', type: 'string' },
|
||||
|
||||
// Model tiers
|
||||
{ env: 'ANTHROPIC_SMALL_MODEL', toml: 'models.small', type: 'string' },
|
||||
{ env: 'ANTHROPIC_MEDIUM_MODEL', toml: 'models.medium', type: 'string' },
|
||||
@@ -165,20 +160,6 @@ function validateProviderFields(config: TOMLConfig, provider: string, errors: st
|
||||
validateModelTiers(config, 'vertex', errors);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'router': {
|
||||
if (!keys.includes('default')) {
|
||||
errors.push('[router] missing required key: default');
|
||||
}
|
||||
if (!keys.includes('openai_key') && !keys.includes('openrouter_key')) {
|
||||
errors.push('[router] requires either openai_key or openrouter_key');
|
||||
}
|
||||
const models = config.models as Record<string, unknown> | undefined;
|
||||
if (models && typeof models === 'object' && Object.keys(models).length > 0) {
|
||||
errors.push('[models] is not supported with [router]');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +223,7 @@ function validateConfig(config: TOMLConfig): string[] {
|
||||
}
|
||||
|
||||
// 4. Only one provider section allowed (ignore empty sections)
|
||||
const PROVIDER_SECTIONS = ['anthropic', 'custom_base_url', 'bedrock', 'vertex', 'router'] as const;
|
||||
const PROVIDER_SECTIONS = ['anthropic', 'custom_base_url', 'bedrock', 'vertex'] as const;
|
||||
const present = PROVIDER_SECTIONS.filter((s) => {
|
||||
const section = config[s];
|
||||
return section && typeof section === 'object' && Object.keys(section).length > 0;
|
||||
|
||||
@@ -13,7 +13,6 @@ export interface ShannonConfig {
|
||||
custom_base_url?: { base_url?: string; auth_token?: string };
|
||||
bedrock?: { use?: boolean; region?: string; token?: string };
|
||||
vertex?: { use?: boolean; region?: string; project_id?: string; key_path?: string };
|
||||
router?: { default?: string; openai_key?: string; openrouter_key?: string };
|
||||
models?: { small?: string; medium?: string; large?: string };
|
||||
}
|
||||
|
||||
|
||||
+14
-51
@@ -69,65 +69,28 @@ export function isTemporalReady(): boolean {
|
||||
return output.includes('SERVING');
|
||||
}
|
||||
|
||||
/** Check if the router container is running and healthy. */
|
||||
function isRouterReady(): boolean {
|
||||
const status = runOutput('docker', ['inspect', '--format', '{{.State.Health.Status}}', 'shannon-router']);
|
||||
return status === 'healthy';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure Temporal (and optionally router) are running via compose.
|
||||
* If Temporal is already up but router is needed and missing, starts router only.
|
||||
* Ensure Temporal is running via compose.
|
||||
*/
|
||||
export async function ensureInfra(useRouter: boolean): Promise<void> {
|
||||
const temporalReady = isTemporalReady();
|
||||
const routerNeeded = useRouter && !isRouterReady();
|
||||
|
||||
if (temporalReady && !routerNeeded) {
|
||||
export async function ensureInfra(): Promise<void> {
|
||||
if (isTemporalReady()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const composeFile = getComposeFile();
|
||||
const composeArgs = ['compose', '-f', composeFile];
|
||||
if (useRouter) composeArgs.push('--profile', 'router');
|
||||
composeArgs.push('up', '-d');
|
||||
console.log('Starting Shannon infrastructure...');
|
||||
execFileSync('docker', ['compose', '-f', composeFile, 'up', '-d'], { stdio: 'inherit' });
|
||||
|
||||
if (temporalReady && routerNeeded) {
|
||||
console.log('Starting router...');
|
||||
} else {
|
||||
console.log('Starting Shannon infrastructure...');
|
||||
}
|
||||
execFileSync('docker', composeArgs, { stdio: 'inherit' });
|
||||
|
||||
// Wait for Temporal if it wasn't already running
|
||||
if (!temporalReady) {
|
||||
console.log('Waiting for Temporal to be ready...');
|
||||
for (let i = 0; i < 30; i++) {
|
||||
if (isTemporalReady()) {
|
||||
console.log('Temporal is ready!');
|
||||
break;
|
||||
}
|
||||
if (i === 29) {
|
||||
console.error('Timeout waiting for Temporal');
|
||||
process.exit(1);
|
||||
}
|
||||
await sleep(2000);
|
||||
console.log('Waiting for Temporal to be ready...');
|
||||
for (let i = 0; i < 30; i++) {
|
||||
if (isTemporalReady()) {
|
||||
console.log('Temporal is ready!');
|
||||
return;
|
||||
}
|
||||
await sleep(2000);
|
||||
}
|
||||
|
||||
// Wait for router if needed
|
||||
if (routerNeeded) {
|
||||
console.log('Waiting for router to be ready...');
|
||||
for (let i = 0; i < 15; i++) {
|
||||
if (isRouterReady()) {
|
||||
console.log('Router is ready!');
|
||||
return;
|
||||
}
|
||||
await sleep(2000);
|
||||
}
|
||||
console.error('Timeout waiting for router');
|
||||
process.exit(1);
|
||||
}
|
||||
console.error('Timeout waiting for Temporal');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,7 +251,7 @@ export function stopWorkers(): void {
|
||||
*/
|
||||
export function stopInfra(clean: boolean): void {
|
||||
const composeFile = getComposeFile();
|
||||
const args = ['compose', '-f', composeFile, '--profile', 'router', 'down'];
|
||||
const args = ['compose', '-f', composeFile, 'down'];
|
||||
if (clean) args.push('-v');
|
||||
execFileSync('docker', args, { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
+1
-13
@@ -14,7 +14,6 @@ const FORWARD_VARS = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ROUTER_DEFAULT',
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'CLAUDE_CODE_USE_BEDROCK',
|
||||
'AWS_REGION',
|
||||
@@ -27,8 +26,6 @@ const FORWARD_VARS = [
|
||||
'ANTHROPIC_MEDIUM_MODEL',
|
||||
'ANTHROPIC_LARGE_MODEL',
|
||||
'CLAUDE_CODE_MAX_OUTPUT_TOKENS',
|
||||
'OPENAI_API_KEY',
|
||||
'OPENROUTER_API_KEY',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -64,12 +61,7 @@ export function buildEnvFlags(): string[] {
|
||||
interface CredentialValidation {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
mode: 'api-key' | 'oauth' | 'custom-base-url' | 'bedrock' | 'vertex' | 'router';
|
||||
}
|
||||
|
||||
/** Check if router credentials are present in the environment. */
|
||||
export function isRouterConfigured(): boolean {
|
||||
return !!(process.env.ROUTER_DEFAULT && (process.env.OPENAI_API_KEY || process.env.OPENROUTER_API_KEY));
|
||||
mode: 'api-key' | 'oauth' | 'custom-base-url' | 'bedrock' | 'vertex';
|
||||
}
|
||||
|
||||
/** Check if a custom Anthropic-compatible base URL is configured. */
|
||||
@@ -85,7 +77,6 @@ function detectProviders(): string[] {
|
||||
if (isCustomBaseUrlConfigured()) providers.push('Custom Base URL');
|
||||
if (process.env.CLAUDE_CODE_USE_BEDROCK === '1') providers.push('AWS Bedrock');
|
||||
if (process.env.CLAUDE_CODE_USE_VERTEX === '1') providers.push('Google Vertex');
|
||||
if (isRouterConfigured()) providers.push('Router');
|
||||
return providers;
|
||||
}
|
||||
|
||||
@@ -151,9 +142,6 @@ export function validateCredentials(): CredentialValidation {
|
||||
}
|
||||
return { valid: true, mode: 'vertex' };
|
||||
}
|
||||
if (isRouterConfigured()) {
|
||||
return { valid: true, mode: 'router' };
|
||||
}
|
||||
|
||||
const hint =
|
||||
getMode() === 'local'
|
||||
|
||||
@@ -69,7 +69,6 @@ Options for 'start':
|
||||
-o, --output <path> Copy deliverables to this directory after run
|
||||
-w, --workspace <name> Named workspace (auto-resumes if exists)
|
||||
--pipeline-testing Use minimal prompts for fast testing
|
||||
--router Route requests through claude-code-router
|
||||
|
||||
Examples:
|
||||
${prefix} start -u https://example.com -r ${mode === 'local' ? 'my-repo' : './my-repo'}
|
||||
@@ -94,7 +93,6 @@ interface ParsedStartArgs {
|
||||
workspace?: string;
|
||||
output?: string;
|
||||
pipelineTesting: boolean;
|
||||
router: boolean;
|
||||
}
|
||||
|
||||
function parseStartArgs(argv: string[]): ParsedStartArgs {
|
||||
@@ -104,7 +102,6 @@ function parseStartArgs(argv: string[]): ParsedStartArgs {
|
||||
let workspace: string | undefined;
|
||||
let output: string | undefined;
|
||||
let pipelineTesting = false;
|
||||
let router = false;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
@@ -149,9 +146,6 @@ function parseStartArgs(argv: string[]): ParsedStartArgs {
|
||||
case '--pipeline-testing':
|
||||
pipelineTesting = true;
|
||||
break;
|
||||
case '--router':
|
||||
router = true;
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown option: ${arg}`);
|
||||
console.error(`Run "${getMode() === 'local' ? './shannon' : 'npx @keygraph/shannon'} help" for usage`);
|
||||
@@ -169,7 +163,6 @@ function parseStartArgs(argv: string[]): ParsedStartArgs {
|
||||
url,
|
||||
repo,
|
||||
pipelineTesting,
|
||||
router,
|
||||
...(config && { config }),
|
||||
...(workspace && { workspace }),
|
||||
...(output && { output }),
|
||||
|
||||
@@ -21,7 +21,6 @@ import { dispatchMessage } from './message-handlers.js';
|
||||
import { type ModelTier, resolveModel } from './models.js';
|
||||
import { detectExecutionContext, formatCompletionMessage, formatErrorOutput } from './output-formatters.js';
|
||||
import { createProgressManager } from './progress-manager.js';
|
||||
import { getActualModelName } from './router-utils.js';
|
||||
|
||||
declare global {
|
||||
var SHANNON_DISABLE_LOADER: boolean | undefined;
|
||||
@@ -183,7 +182,6 @@ export async function runClaudePrompt(
|
||||
case 'litellm_router':
|
||||
if (providerConfig.baseUrl) sdkEnv.ANTHROPIC_BASE_URL = providerConfig.baseUrl;
|
||||
if (providerConfig.authToken) sdkEnv.ANTHROPIC_AUTH_TOKEN = providerConfig.authToken;
|
||||
if (providerConfig.routerDefault) sdkEnv.ROUTER_DEFAULT = providerConfig.routerDefault;
|
||||
break;
|
||||
default:
|
||||
// 'anthropic_api' or unset — apiKey already handled above
|
||||
@@ -384,9 +382,8 @@ async function processMessageStream(
|
||||
if (dispatchResult.apiErrorDetected) {
|
||||
apiErrorDetected = true;
|
||||
}
|
||||
// Capture model from SystemInitMessage, but override with router model if applicable
|
||||
if (dispatchResult.model) {
|
||||
model = getActualModelName(dispatchResult.model);
|
||||
model = dispatchResult.model;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
formatToolUseOutput,
|
||||
} from './output-formatters.js';
|
||||
import type { ProgressManager } from './progress-manager.js';
|
||||
import { getActualModelName } from './router-utils.js';
|
||||
import type {
|
||||
ApiErrorDetection,
|
||||
AssistantMessage,
|
||||
@@ -309,12 +308,10 @@ export async function dispatchMessage(
|
||||
case 'system': {
|
||||
if (message.subtype === 'init') {
|
||||
const initMsg = message as SystemInitMessage;
|
||||
const actualModel = getActualModelName(initMsg.model);
|
||||
if (!execContext.useCleanOutput) {
|
||||
logger.info(`Model: ${actualModel}, Permission: ${initMsg.permissionMode}`);
|
||||
logger.info(`Model: ${initMsg.model}, Permission: ${initMsg.permissionMode}`);
|
||||
}
|
||||
// Return actual model for tracking in audit logs
|
||||
return { type: 'continue', model: actualModel };
|
||||
return { type: 'continue', model: initMsg.model };
|
||||
}
|
||||
return { type: 'continue' };
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Get the actual model name being used.
|
||||
* When using claude-code-router, the SDK reports its configured model (claude-sonnet)
|
||||
* but the actual model is determined by ROUTER_DEFAULT env var.
|
||||
*/
|
||||
export function getActualModelName(sdkReportedModel?: string): string | undefined {
|
||||
const routerBaseUrl = process.env.ANTHROPIC_BASE_URL;
|
||||
const routerDefault = process.env.ROUTER_DEFAULT;
|
||||
|
||||
// If router mode is active and ROUTER_DEFAULT is set, use that
|
||||
if (routerBaseUrl && routerDefault) {
|
||||
// ROUTER_DEFAULT format: "provider,model" (e.g., "gemini,gemini-2.5-pro")
|
||||
const parts = routerDefault.split(',');
|
||||
if (parts.length >= 2) {
|
||||
return parts.slice(1).join(','); // Handle model names with commas
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to SDK-reported model
|
||||
return sdkReportedModel;
|
||||
}
|
||||
@@ -1,25 +1,59 @@
|
||||
/**
|
||||
* CheckpointProvider — injectable interface for external state persistence.
|
||||
*
|
||||
* Called after each agent completes to allow external progress tracking.
|
||||
* During the concurrent vulnerability-exploitation phase, 5 pipelines run
|
||||
* in parallel — onAgentComplete fires per-agent for granular progress.
|
||||
* Called before and after each agent to support skip-guard (resume) and
|
||||
* post-agent artifact persistence. During the concurrent vulnerability-exploitation
|
||||
* phase, 5 pipelines run in parallel — methods fire per-agent for granular control.
|
||||
*
|
||||
* Default: no-op.
|
||||
* Default: no-op (skip nothing, persist nothing).
|
||||
*/
|
||||
|
||||
import type { PipelineState } from '../temporal/shared.js';
|
||||
import type { AgentMetrics, PipelineState } from '../temporal/shared.js';
|
||||
|
||||
/** Result of a pre-agent skip check. */
|
||||
export interface SkipDecision {
|
||||
readonly skip: boolean;
|
||||
readonly metrics?: AgentMetrics; // Required when skip=true
|
||||
}
|
||||
|
||||
/** File-system context passed after agent completion for artifact persistence. */
|
||||
export interface CheckpointContext {
|
||||
readonly repoPath: string;
|
||||
readonly sessionId: string;
|
||||
readonly deliverablesSubdir: string;
|
||||
readonly outputPath?: string;
|
||||
}
|
||||
|
||||
export interface CheckpointProvider {
|
||||
/**
|
||||
* Called before an agent activity executes.
|
||||
* Return { skip: true, metrics } to skip the agent (e.g., output files already exist).
|
||||
* Return { skip: false } to run normally.
|
||||
*/
|
||||
shouldSkipAgent(
|
||||
agentName: string,
|
||||
repoPath: string,
|
||||
deliverablesSubdir: string,
|
||||
): Promise<SkipDecision>;
|
||||
|
||||
/**
|
||||
* Called after an agent activity succeeds.
|
||||
* Receives pipeline state and optional file context for artifact persistence.
|
||||
*/
|
||||
onAgentComplete(
|
||||
agentName: string,
|
||||
phase: string,
|
||||
state: PipelineState,
|
||||
context?: CheckpointContext,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
/** Default no-op implementation — no external checkpointing. */
|
||||
export class NoOpCheckpointProvider implements CheckpointProvider {
|
||||
async shouldSkipAgent(): Promise<SkipDecision> {
|
||||
return { skip: false };
|
||||
}
|
||||
|
||||
async onAgentComplete(): Promise<void> {
|
||||
// No-op
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* FindingsProvider — injectable interface for external findings integration.
|
||||
*
|
||||
* Allows external security data (SAST, SCA, secrets, etc.) to be merged
|
||||
* Allows external security data from consumer-supplied sources to be merged
|
||||
* into the exploitation pipeline between vulnerability analysis and exploitation.
|
||||
*
|
||||
* Default: no-op returning { mergedCount: 0 }.
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
* Consumers can provide alternate implementations via the DI container.
|
||||
*/
|
||||
|
||||
export type { CheckpointProvider } from './checkpoint-provider.js';
|
||||
export type { CheckpointProvider, CheckpointContext, SkipDecision } from './checkpoint-provider.js';
|
||||
export { NoOpCheckpointProvider } from './checkpoint-provider.js';
|
||||
export type { FindingsProvider } from './findings-provider.js';
|
||||
export { NoOpFindingsProvider } from './findings-provider.js';
|
||||
export type { ReportOutputProvider } from './report-output-provider.js';
|
||||
export { NoOpReportOutputProvider } from './report-output-provider.js';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* ReportOutputProvider — injectable interface for emitting an optional
|
||||
* additional artifact alongside the assembled markdown report.
|
||||
*
|
||||
* Runs after the report agent has finalized
|
||||
* `comprehensive_security_assessment_report.md`. Consumers can override to
|
||||
* produce derived outputs; the default no-op produces nothing.
|
||||
*/
|
||||
|
||||
import type { ActivityInput } from '../temporal/activities.js';
|
||||
import type { ActivityLogger } from '../types/activity-logger.js';
|
||||
|
||||
export interface ReportOutputProvider {
|
||||
generate(input: ActivityInput, logger: ActivityLogger): Promise<{ outputPath?: string }>;
|
||||
}
|
||||
|
||||
/** Default no-op implementation — no additional output produced. */
|
||||
export class NoOpReportOutputProvider implements ReportOutputProvider {
|
||||
async generate(): Promise<{ outputPath?: string }> {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import type { CheckpointProvider } from '../interfaces/checkpoint-provider.js';
|
||||
import { NoOpCheckpointProvider } from '../interfaces/checkpoint-provider.js';
|
||||
import type { FindingsProvider } from '../interfaces/findings-provider.js';
|
||||
import { NoOpFindingsProvider } from '../interfaces/findings-provider.js';
|
||||
import type { ReportOutputProvider } from '../interfaces/report-output-provider.js';
|
||||
import { NoOpReportOutputProvider } from '../interfaces/report-output-provider.js';
|
||||
import type { ContainerConfig } from '../types/config.js';
|
||||
import { AgentExecutionService } from './agent-execution.js';
|
||||
import { ConfigLoaderService } from './config-loader.js';
|
||||
@@ -40,6 +42,7 @@ export interface ContainerDependencies {
|
||||
readonly config: ContainerConfig;
|
||||
readonly findingsProvider?: FindingsProvider;
|
||||
readonly checkpointProvider?: CheckpointProvider;
|
||||
readonly reportOutputProvider?: ReportOutputProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,6 +62,7 @@ export class Container {
|
||||
readonly exploitationChecker: ExploitationCheckerService;
|
||||
readonly findingsProvider: FindingsProvider;
|
||||
readonly checkpointProvider: CheckpointProvider;
|
||||
readonly reportOutputProvider: ReportOutputProvider;
|
||||
|
||||
constructor(deps: ContainerDependencies) {
|
||||
this.sessionMetadata = deps.sessionMetadata;
|
||||
@@ -72,6 +76,7 @@ export class Container {
|
||||
// Wire providers with default no-ops when not provided
|
||||
this.findingsProvider = deps.findingsProvider ?? new NoOpFindingsProvider();
|
||||
this.checkpointProvider = deps.checkpointProvider ?? new NoOpCheckpointProvider();
|
||||
this.reportOutputProvider = deps.reportOutputProvider ?? new NoOpReportOutputProvider();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +92,32 @@ const DEFAULT_CONFIG: ContainerConfig = {
|
||||
auditDir: './workspaces',
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory function for creating containers.
|
||||
*
|
||||
* Default: creates a plain Container with NoOp providers. Consumers can call
|
||||
* setContainerFactory() at worker startup to inject custom provider
|
||||
* implementations into every container.
|
||||
*/
|
||||
type ContainerFactory = (
|
||||
workflowId: string,
|
||||
sessionMetadata: SessionMetadata,
|
||||
config: ContainerConfig,
|
||||
) => Container;
|
||||
|
||||
let containerFactory: ContainerFactory = (_workflowId, sessionMetadata, config) =>
|
||||
new Container({ sessionMetadata, config });
|
||||
|
||||
/**
|
||||
* Override the default container factory.
|
||||
*
|
||||
* Call once at worker startup to inject providers into all containers
|
||||
* created during the worker's lifetime.
|
||||
*/
|
||||
export function setContainerFactory(factory: ContainerFactory): void {
|
||||
containerFactory = factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a Container for a workflow.
|
||||
*
|
||||
@@ -106,7 +137,7 @@ export function getOrCreateContainer(
|
||||
let container = containers.get(workflowId);
|
||||
|
||||
if (!container) {
|
||||
container = new Container({ sessionMetadata, config });
|
||||
container = containerFactory(workflowId, sessionMetadata, config);
|
||||
containers.set(workflowId, container);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,9 @@ export { AgentExecutionService } from './agent-execution.js';
|
||||
|
||||
export { ConfigLoaderService } from './config-loader.js';
|
||||
export type { ContainerDependencies } from './container.js';
|
||||
export { Container, getContainer, getOrCreateContainer, removeContainer } from './container.js';
|
||||
export { Container, getContainer, getOrCreateContainer, removeContainer, setContainerFactory } from './container.js';
|
||||
export { ExploitationCheckerService } from './exploitation-checker.js';
|
||||
export { loadPrompt } from './prompt-manager.js';
|
||||
export { assembleFinalReport, injectModelIntoReport } from './reporting.js';
|
||||
export type { ClaudePromptResult } from '../ai/claude-executor.js';
|
||||
export { runClaudePrompt } from '../ai/claude-executor.js';
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* Checks run sequentially, cheapest first:
|
||||
* 1. Repository path exists and contains .git
|
||||
* 2. Config file parses and validates (if provided)
|
||||
* 3. Credentials validate via Claude Agent SDK query (API key, OAuth, Bedrock, Vertex AI, or router mode)
|
||||
* 3. Credentials validate via Claude Agent SDK query (API key, OAuth, Bedrock, or Vertex AI)
|
||||
* 4. Target URL is reachable from the container (DNS + HTTP)
|
||||
*/
|
||||
|
||||
@@ -463,7 +463,7 @@ async function validateTargetUrl(targetUrl: string, logger: ActivityLogger): Pro
|
||||
*
|
||||
* 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)
|
||||
* 3. Credentials validate (API key, OAuth, Bedrock, or Vertex AI)
|
||||
* 4. Target URL is reachable from the container
|
||||
*
|
||||
* Returns on first failure.
|
||||
|
||||
@@ -17,7 +17,11 @@ interface DeliverableFile {
|
||||
}
|
||||
|
||||
// Pure function: Assemble final report from specialist deliverables
|
||||
export async function assembleFinalReport(sourceDir: string, logger: ActivityLogger): Promise<string> {
|
||||
export async function assembleFinalReport(
|
||||
sourceDir: string,
|
||||
deliverablesSubdir: string | undefined,
|
||||
logger: ActivityLogger,
|
||||
): Promise<string> {
|
||||
const deliverableFiles: DeliverableFile[] = [
|
||||
{ name: 'Injection', path: 'injection_exploitation_evidence.md', required: false },
|
||||
{ name: 'XSS', path: 'xss_exploitation_evidence.md', required: false },
|
||||
@@ -29,7 +33,7 @@ export async function assembleFinalReport(sourceDir: string, logger: ActivityLog
|
||||
const sections: string[] = [];
|
||||
|
||||
for (const file of deliverableFiles) {
|
||||
const filePath = path.join(deliverablesDir(sourceDir), file.path);
|
||||
const filePath = path.join(deliverablesDir(sourceDir, deliverablesSubdir), file.path);
|
||||
try {
|
||||
if (await fs.pathExists(filePath)) {
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
@@ -56,7 +60,7 @@ export async function assembleFinalReport(sourceDir: string, logger: ActivityLog
|
||||
}
|
||||
|
||||
const finalContent = sections.join('\n\n');
|
||||
const outputDir = deliverablesDir(sourceDir);
|
||||
const outputDir = deliverablesDir(sourceDir, deliverablesSubdir);
|
||||
const finalReportPath = path.join(outputDir, 'comprehensive_security_assessment_report.md');
|
||||
|
||||
try {
|
||||
@@ -82,6 +86,7 @@ export async function assembleFinalReport(sourceDir: string, logger: ActivityLog
|
||||
*/
|
||||
export async function injectModelIntoReport(
|
||||
repoPath: string,
|
||||
deliverablesSubdir: string | undefined,
|
||||
outputPath: string,
|
||||
logger: ActivityLogger,
|
||||
): Promise<void> {
|
||||
@@ -118,7 +123,7 @@ export async function injectModelIntoReport(
|
||||
logger.info(`Injecting model info into report: ${modelStr}`);
|
||||
|
||||
// 3. Read the final report
|
||||
const reportPath = path.join(deliverablesDir(repoPath), 'comprehensive_security_assessment_report.md');
|
||||
const reportPath = path.join(deliverablesDir(repoPath, deliverablesSubdir), 'comprehensive_security_assessment_report.md');
|
||||
|
||||
if (!(await fs.pathExists(reportPath))) {
|
||||
logger.warn('Final report not found, skipping model injection');
|
||||
|
||||
@@ -103,7 +103,6 @@ export const AGENTS: Readonly<Record<AgentName, AgentDefinition>> = Object.freez
|
||||
prerequisites: ['injection-exploit', 'xss-exploit', 'auth-exploit', 'ssrf-exploit', 'authz-exploit'],
|
||||
promptTemplate: 'report-executive',
|
||||
deliverableFilename: 'comprehensive_security_assessment_report.md',
|
||||
modelTier: 'small',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { ResumeAttempt } from '../audit/metrics-tracker.js';
|
||||
import type { SessionMetadata } from '../audit/utils.js';
|
||||
import type { WorkflowSummary } from '../audit/workflow-logger.js';
|
||||
import type { ContainerConfig, ProviderConfig } from '../types/config.js';
|
||||
import type { CheckpointContext } from '../interfaces/checkpoint-provider.js';
|
||||
import { getContainer, getOrCreateContainer, removeContainer } from '../services/container.js';
|
||||
import { classifyErrorForTemporal, PentestError } from '../services/error-handling.js';
|
||||
import { ExploitationCheckerService } from '../services/exploitation-checker.js';
|
||||
@@ -131,6 +132,20 @@ function buildContainerConfig(input: ActivityInput): ContainerConfig {
|
||||
*/
|
||||
async function runAgentActivity(agentName: AgentName, input: ActivityInput): Promise<AgentMetrics> {
|
||||
const { repoPath, configPath, pipelineTestingMode = false, workflowId, webUrl } = input;
|
||||
|
||||
// Skip guard: the checkpoint provider decides whether to run the agent.
|
||||
// The default NoOp provider always returns { skip: false }.
|
||||
const skipContainer = getContainer(workflowId) ??
|
||||
getOrCreateContainer(workflowId, buildSessionMetadata(input), buildContainerConfig(input));
|
||||
const decision = await skipContainer.checkpointProvider.shouldSkipAgent(
|
||||
agentName,
|
||||
repoPath,
|
||||
input.deliverablesSubdir ?? DEFAULT_DELIVERABLES_SUBDIR,
|
||||
);
|
||||
if (decision.skip && decision.metrics) {
|
||||
return decision.metrics;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const attemptNumber = Context.current().info.attempt;
|
||||
|
||||
@@ -288,7 +303,7 @@ export async function runReportAgent(input: ActivityInput): Promise<AgentMetrics
|
||||
* Runs cheap checks before any agent execution:
|
||||
* 1. Repository path exists with .git
|
||||
* 2. Config file validates (if provided)
|
||||
* 3. Credential validation (API key, OAuth, or router mode)
|
||||
* 3. Credential validation (API key, OAuth, Bedrock, or Vertex AI)
|
||||
* 4. Target URL reachable from the container
|
||||
*
|
||||
* NOT using runAgentActivity — preflight doesn't run an agent via the SDK.
|
||||
@@ -378,11 +393,11 @@ export async function initDeliverableGit(input: ActivityInput): Promise<void> {
|
||||
* Assemble the final report by concatenating exploitation evidence files.
|
||||
*/
|
||||
export async function assembleReportActivity(input: ActivityInput): Promise<void> {
|
||||
const { repoPath } = input;
|
||||
const { repoPath, deliverablesSubdir } = input;
|
||||
const logger = createActivityLogger();
|
||||
logger.info('Assembling deliverables from specialist agents...');
|
||||
try {
|
||||
await assembleFinalReport(repoPath, logger);
|
||||
await assembleFinalReport(repoPath, deliverablesSubdir, logger);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
logger.warn(`Error assembling final report: ${err.message}`);
|
||||
@@ -393,11 +408,11 @@ export async function assembleReportActivity(input: ActivityInput): Promise<void
|
||||
* Inject model metadata into the final report.
|
||||
*/
|
||||
export async function injectReportMetadataActivity(input: ActivityInput): Promise<void> {
|
||||
const { repoPath, sessionId, outputPath } = input;
|
||||
const { repoPath, sessionId, outputPath, deliverablesSubdir } = input;
|
||||
const logger = createActivityLogger();
|
||||
const effectiveOutputPath = outputPath ? path.join(outputPath, sessionId) : path.join('./workspaces', sessionId);
|
||||
try {
|
||||
await injectModelIntoReport(repoPath, effectiveOutputPath, logger);
|
||||
await injectModelIntoReport(repoPath, deliverablesSubdir, effectiveOutputPath, logger);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
logger.warn(`Error injecting model into report: ${err.message}`);
|
||||
@@ -585,6 +600,18 @@ export async function restoreGitCheckpoint(
|
||||
const logger = createActivityLogger();
|
||||
logger.info(`Restoring deliverables to ${checkpointHash}...`);
|
||||
|
||||
// Validate hash exists in this clone before attempting reset
|
||||
try {
|
||||
await executeGitCommandWithRetry(
|
||||
['git', 'rev-parse', '--verify', checkpointHash],
|
||||
repoPath,
|
||||
'verify checkpoint hash exists'
|
||||
);
|
||||
} catch {
|
||||
logger.info(`Checkpoint hash not found in clone, skipping git reset: ${checkpointHash}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await executeGitCommandWithRetry(
|
||||
['git', 'reset', '--hard', checkpointHash],
|
||||
deliverablesPath,
|
||||
@@ -736,5 +763,42 @@ export async function saveCheckpoint(
|
||||
): Promise<void> {
|
||||
const container = getContainer(input.workflowId);
|
||||
if (!container?.checkpointProvider) return;
|
||||
return container.checkpointProvider.onAgentComplete(agentName, phase, state);
|
||||
|
||||
const context: CheckpointContext = {
|
||||
repoPath: input.repoPath,
|
||||
sessionId: input.sessionId,
|
||||
deliverablesSubdir: input.deliverablesSubdir ?? DEFAULT_DELIVERABLES_SUBDIR,
|
||||
...(input.outputPath !== undefined && { outputPath: input.outputPath }),
|
||||
};
|
||||
|
||||
return container.checkpointProvider.onAgentComplete(agentName, phase, state, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an optional additional output alongside the assembled markdown report.
|
||||
*
|
||||
* Delegates to the ReportOutputProvider registered in the DI container.
|
||||
* Default: no-op. Consumers can override this activity at the worker level
|
||||
* to emit derived outputs from the final report.
|
||||
*/
|
||||
export async function generateReportOutputActivity(input: ActivityInput): Promise<void> {
|
||||
const container = getContainer(input.workflowId);
|
||||
if (!container?.reportOutputProvider) return;
|
||||
|
||||
const logger = createActivityLogger();
|
||||
|
||||
// Resolve promptDir against the worker root so providers are cwd-independent.
|
||||
const resolvedInput: ActivityInput = {
|
||||
...input,
|
||||
...(input.promptDir !== undefined && {
|
||||
promptDir: path.isAbsolute(input.promptDir)
|
||||
? input.promptDir
|
||||
: path.resolve(process.env.SHANNON_WORKER_ROOT ?? process.cwd(), input.promptDir),
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await container.reportOutputProvider.generate(resolvedInput, logger);
|
||||
if (result.outputPath) {
|
||||
logger.info(`Report output written to ${result.outputPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,10 +25,10 @@ export interface PipelineInput {
|
||||
deliverablesSubdir?: string; // Override deliverables path (default: '.shannon/deliverables')
|
||||
auditDir?: string; // Override audit log directory (default: './workspaces')
|
||||
promptDir?: string; // Override prompt template directory
|
||||
sastSarifPath?: string; // Path to SARIF file (gates SAST-enhanced mode)
|
||||
sastSarifPath?: string; // Optional path for consumer-supplied findings input
|
||||
checkpointsEnabled?: boolean; // Enable checkpoint activities (default: false)
|
||||
skipGitCheck?: boolean; // Skip .git directory validation in preflight (e.g. when .git is removed after clone)
|
||||
providerConfig?: ProviderConfig; // LLM provider configuration (Bedrock, Vertex, LiteLLM, etc.)
|
||||
providerConfig?: ProviderConfig; // LLM provider configuration (Bedrock, Vertex, etc.)
|
||||
}
|
||||
|
||||
export interface ResumeState {
|
||||
|
||||
@@ -322,30 +322,14 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
|
||||
];
|
||||
}
|
||||
|
||||
// Aggregate results from settled pipeline promises into workflow state
|
||||
// Aggregate errors from settled pipeline promises.
|
||||
// Metrics and completedAgents are updated incrementally inside runVulnExploitPipeline
|
||||
// so that getProgress queries reflect real-time status during execution.
|
||||
function aggregatePipelineResults(results: PromiseSettledResult<VulnExploitPipelineResult>[]): void {
|
||||
const failedPipelines: string[] = [];
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
const { vulnType, vulnMetrics, exploitMetrics } = result.value;
|
||||
|
||||
const vulnAgentName = `${vulnType}-vuln`;
|
||||
if (vulnMetrics) {
|
||||
state.agentMetrics[vulnAgentName] = vulnMetrics;
|
||||
state.completedAgents.push(vulnAgentName);
|
||||
} else if (shouldSkip(vulnAgentName)) {
|
||||
state.completedAgents.push(vulnAgentName);
|
||||
}
|
||||
|
||||
const exploitAgentName = `${vulnType}-exploit`;
|
||||
if (exploitMetrics) {
|
||||
state.agentMetrics[exploitAgentName] = exploitMetrics;
|
||||
state.completedAgents.push(exploitAgentName);
|
||||
} else if (shouldSkip(exploitAgentName)) {
|
||||
state.completedAgents.push(exploitAgentName);
|
||||
}
|
||||
} else {
|
||||
if (result.status === 'rejected') {
|
||||
const errorMsg = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
||||
failedPipelines.push(errorMsg);
|
||||
}
|
||||
@@ -432,14 +416,17 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
|
||||
let vulnMetrics: AgentMetrics | null = null;
|
||||
if (!shouldSkip(vulnAgentName)) {
|
||||
vulnMetrics = await runVulnAgent();
|
||||
state.agentMetrics[vulnAgentName] = vulnMetrics;
|
||||
state.completedAgents.push(vulnAgentName);
|
||||
if (input.checkpointsEnabled) {
|
||||
await a.saveCheckpoint(activityInput, vulnAgentName, 'vulnerability-analysis', state);
|
||||
}
|
||||
} else {
|
||||
log.info(`Skipping ${vulnAgentName} (already complete)`);
|
||||
state.completedAgents.push(vulnAgentName);
|
||||
}
|
||||
|
||||
// 1.5. Merge external findings (SAST, SCA, etc.) into exploitation queue
|
||||
// 1.5. Merge external findings from consumer provider into exploitation queue
|
||||
await a.mergeFindingsIntoQueue(activityInput, vulnType);
|
||||
|
||||
// 2. Check exploitation queue for actionable findings
|
||||
@@ -450,11 +437,14 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
|
||||
if (decision.shouldExploit) {
|
||||
if (!shouldSkip(exploitAgentName)) {
|
||||
exploitMetrics = await runExploitAgent();
|
||||
state.agentMetrics[exploitAgentName] = exploitMetrics;
|
||||
state.completedAgents.push(exploitAgentName);
|
||||
if (input.checkpointsEnabled) {
|
||||
await a.saveCheckpoint(activityInput, exploitAgentName, 'exploitation', state);
|
||||
}
|
||||
} else {
|
||||
log.info(`Skipping ${exploitAgentName} (already complete)`);
|
||||
state.completedAgents.push(exploitAgentName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,6 +506,13 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
|
||||
state.completedAgents.push('report');
|
||||
}
|
||||
|
||||
// Runs after the skip gate so consumer providers still execute on resume.
|
||||
await a.generateReportOutputActivity(activityInput);
|
||||
|
||||
if (input.checkpointsEnabled) {
|
||||
await a.saveCheckpoint(activityInput, 'report-output', 'reporting', state);
|
||||
}
|
||||
|
||||
state.status = 'completed';
|
||||
state.currentPhase = null;
|
||||
state.currentAgent = null;
|
||||
|
||||
@@ -80,7 +80,6 @@ export interface ProviderConfig {
|
||||
readonly gcpCredentialsPath?: string;
|
||||
readonly baseUrl?: string;
|
||||
readonly authToken?: string;
|
||||
readonly routerDefault?: string;
|
||||
readonly modelOverrides?: Record<string, string>;
|
||||
readonly supportsStructuredOutput?: boolean;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ export const BILLING_TEXT_PATTERNS = [
|
||||
'cap reached',
|
||||
'budget exceeded',
|
||||
'usage limit',
|
||||
'resets',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user