Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
node_modules/
.env
.env.*
workspaces/
credentials/
dist/
repos/
.turbo/
.kiro/
.vscode/
8 changes: 8 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ RUN apk update && apk add --no-cache \
# Install pnpm
RUN npm install -g --ignore-scripts pnpm@10.33.0

# Install kiro-cli for headless mode executor backend
RUN curl -fsSL https://cli.kiro.dev/install | bash && \
cp -a /root/.local/bin/kiro-cli* /usr/local/bin/ 2>/dev/null; \
ls /usr/local/bin/kiro-cli*

# Build Node.js application in builder to avoid QEMU emulation failures in CI
WORKDIR /app

Expand Down Expand Up @@ -92,6 +97,9 @@ COPY --from=builder /app/apps/worker /app/apps/worker
COPY --from=builder /app/apps/cli/package.json /app/apps/cli/package.json

RUN npm install -g --ignore-scripts @anthropic-ai/claude-code@2.1.84 @playwright/cli@0.1.1

# Copy kiro-cli binaries from builder
COPY --from=builder /usr/local/bin/kiro-cli* /usr/local/bin/
RUN mkdir -p /tmp/.claude/skills && \
playwright-cli install --skills && \
cp -r .claude/skills/playwright-cli /tmp/.claude/skills/ && \
Expand Down
5 changes: 4 additions & 1 deletion apps/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export async function start(args: StartArgs): Promise<void> {
const workspacePath = path.join(workspacesDir, workspace);
fs.mkdirSync(workspacePath, { recursive: true });
fs.chmodSync(workspacePath, 0o777);
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli', '.playwright']) {
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli', '.playwright', 'kiro-agents']) {
const dirPath = path.join(workspacePath, dir);
fs.mkdirSync(dirPath, { recursive: true });
fs.chmodSync(dirPath, 0o777);
Expand All @@ -77,6 +77,8 @@ export async function start(args: StartArgs): Promise<void> {
fs.mkdirSync(path.join(shannonDir, dir), { recursive: true });
}
fs.mkdirSync(path.join(repo.hostPath, '.playwright'), { recursive: true });
// Pre-create .kiro/agents mount point for kiro-cli backend
fs.mkdirSync(path.join(repo.hostPath, '.kiro', 'agents'), { recursive: true });

const credentialsPath = getCredentialsPath();
const hasCredentials = fs.existsSync(credentialsPath);
Expand Down Expand Up @@ -171,6 +173,7 @@ export async function start(args: StartArgs): Promise<void> {

// Clear waiting line and show info
process.stdout.write('\r\x1b[K');

printInfo(args, workspace, workflowId, repo.hostPath, workspacesDir);
return;
}
Expand Down
3 changes: 3 additions & 0 deletions apps/cli/src/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,9 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
args.push('-v', `${path.join(workspacePath, '.playwright-cli')}:${opts.repo.containerPath}/.shannon/.playwright-cli`);
args.push('-v', `${path.join(workspacePath, '.playwright')}:${opts.repo.containerPath}/.playwright`);

// Writable overlay for kiro-cli agent definitions
args.push('-v', `${path.join(workspacePath, 'kiro-agents')}:${opts.repo.containerPath}/.kiro/agents`);

// Local mode: mount prompts for live editing
if (opts.promptsDir) {
args.push('-v', `${opts.promptsDir}:/app/apps/worker/prompts:ro`);
Expand Down
29 changes: 28 additions & 1 deletion apps/cli/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const FORWARD_VARS = [
'ANTHROPIC_LARGE_MODEL',
'CLAUDE_CODE_MAX_OUTPUT_TOKENS',
'CLAUDE_ADAPTIVE_THINKING',
'SHANNON_EXECUTOR_BACKEND',
'KIRO_API_KEY',
] as const;

/**
Expand All @@ -45,11 +47,24 @@ export function loadEnv(): void {

/**
* Build `-e KEY=VALUE` flags for docker run, only for set variables.
* When using kiro-cli backend, excludes conflicting provider vars that
* would trigger Bedrock/Vertex validation inside the worker container.
*/
export function buildEnvFlags(): string[] {
const flags: string[] = ['-e', 'TEMPORAL_ADDRESS=shannon-temporal:7233'];

const isKiroCli = process.env.SHANNON_EXECUTOR_BACKEND === 'kiro-cli';
const excludeForKiroCli = new Set([
'CLAUDE_CODE_USE_BEDROCK',
'AWS_BEARER_TOKEN_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'CLOUD_ML_REGION',
'ANTHROPIC_VERTEX_PROJECT_ID',
'GOOGLE_APPLICATION_CREDENTIALS',
]);

for (const key of FORWARD_VARS) {
if (isKiroCli && excludeForKiroCli.has(key)) continue;
const value = process.env[key];
if (value) {
flags.push('-e', `${key}=${value}`);
Expand All @@ -62,7 +77,7 @@ export function buildEnvFlags(): string[] {
interface CredentialValidation {
valid: boolean;
error?: string;
mode: 'api-key' | 'oauth' | 'custom-base-url' | 'bedrock' | 'vertex';
mode: 'api-key' | 'oauth' | 'custom-base-url' | 'bedrock' | 'vertex' | 'kiro-cli';
}

/** Check if a custom Anthropic-compatible base URL is configured. */
Expand All @@ -85,6 +100,18 @@ function detectProviders(): string[] {
* Validate that exactly one authentication method is configured.
*/
export function validateCredentials(): CredentialValidation {
// Kiro CLI backend bypasses traditional credential validation
if (process.env.SHANNON_EXECUTOR_BACKEND === 'kiro-cli') {
if (!process.env.KIRO_API_KEY) {
return {
valid: false,
mode: 'kiro-cli',
error: 'Kiro CLI backend requires KIRO_API_KEY',
};
}
return { valid: true, mode: 'kiro-cli' };
}

// Reject multiple providers
const providers = detectProviders();
if (providers.length > 1) {
Expand Down
7 changes: 5 additions & 2 deletions apps/worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"scripts": {
"build": "tsc",
"check": "tsc --noEmit",
"clean": "rm -rf dist"
"clean": "rm -rf dist",
"test": "vitest run"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "catalog:",
Expand All @@ -32,6 +33,8 @@
"zx": "^8.0.0"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9"
"@types/js-yaml": "^4.0.9",
"fast-check": "^4.6.0",
"vitest": "^4.1.4"
}
}
5 changes: 5 additions & 0 deletions apps/worker/src/ai/claude-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { ActivityLogger } from '../types/activity-logger.js';
import { isSpendingCapBehavior } from '../utils/billing-detection.js';
import { formatTimestamp } from '../utils/formatting.js';
import { Timer } from '../utils/metrics.js';
import type { ToolUsageSummary } from './kiro-cli-executor.js';
import { createAuditLogger } from './audit-logger.js';
import { dispatchMessage } from './message-handlers.js';
import { type ModelTier, resolveModel, supportsAdaptiveThinking } from './models.js';
Expand All @@ -40,6 +41,10 @@ export interface ClaudePromptResult {
prompt?: string | undefined;
retryable?: boolean | undefined;
structuredOutput?: unknown;
/** Tool usage summary from kiro-cli hooks (kiro-cli backend only). */
toolUsage?: ToolUsageSummary | undefined;
/** Per-invocation tool usage records (kiro-cli backend only). */
toolInvocations?: ReadonlyArray<{ tool: string; timestamp: number; success?: boolean; durationMs?: number }> | undefined;
}

function outputLines(lines: string[]): void {
Expand Down
146 changes: 146 additions & 0 deletions apps/worker/src/ai/claude-sdk-executor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ExecutorOptions } from '../interfaces/executor.js';
import type { ActivityLogger } from '../types/activity-logger.js';
import type { ClaudePromptResult } from './claude-executor.js';
import type { ModelTier } from './models.js';

vi.mock('./claude-executor.js', () => ({
runClaudePrompt: vi.fn(),
}));

import { runClaudePrompt } from './claude-executor.js';
import { ClaudeSdkExecutor } from './claude-sdk-executor.js';

const mockRunClaudePrompt = vi.mocked(runClaudePrompt);

function createMockLogger(): ActivityLogger {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
}

const MOCK_RESULT: ClaudePromptResult = {
result: 'test output',
success: true,
duration: 1234,
cost: 0.05,
model: 'claude-sonnet-4-6',
turns: 3,
};

describe('ClaudeSdkExecutor', () => {
let executor: ClaudeSdkExecutor;
let logger: ActivityLogger;

beforeEach(() => {
vi.clearAllMocks();
executor = new ClaudeSdkExecutor();
logger = createMockLogger();
mockRunClaudePrompt.mockResolvedValue(MOCK_RESULT);
});

it('delegates to runClaudePrompt with all options', async () => {
const options: ExecutorOptions = {
context: 'some context',
description: 'test description',
auditSession: null,
outputFormat: { type: 'json_schema', schema: {} },
apiKey: 'sk-test-key',
deliverablesSubdir: '.shannon/deliverables',
providerConfig: { providerType: 'anthropic_api', apiKey: 'pk-test' },
};

const result = await executor.execute('test prompt', '/repo', 'recon', 'medium', logger, options);

expect(result).toBe(MOCK_RESULT);
expect(mockRunClaudePrompt).toHaveBeenCalledOnce();
expect(mockRunClaudePrompt).toHaveBeenCalledWith(
'test prompt',
'/repo',
'some context',
'test description',
'recon',
null,
logger,
'medium',
{ type: 'json_schema', schema: {} },
'sk-test-key',
'.shannon/deliverables',
{ providerType: 'anthropic_api', apiKey: 'pk-test' },
);
});

it('uses default values when options are not provided', async () => {
const result = await executor.execute('prompt text', '/workspace', 'xss', 'large', logger);

expect(result).toBe(MOCK_RESULT);
expect(mockRunClaudePrompt).toHaveBeenCalledOnce();
expect(mockRunClaudePrompt).toHaveBeenCalledWith(
'prompt text',
'/workspace',
'', // context defaults to ''
'xss', // description defaults to agentName
'xss',
null, // auditSession defaults to null
logger,
'large',
undefined, // outputFormat
undefined, // apiKey
undefined, // deliverablesSubdir
undefined, // providerConfig
);
});

it('defaults context to empty string when options.context is undefined', async () => {
const options: ExecutorOptions = {
description: 'custom desc',
};

await executor.execute('p', '/dir', 'auth', 'small', logger, options);

const args = mockRunClaudePrompt.mock.calls[0];
expect(args?.[2]).toBe(''); // context
expect(args?.[3]).toBe('custom desc'); // description
});

it('defaults description to agentName when options.description is undefined', async () => {
const options: ExecutorOptions = {
context: 'ctx',
};

await executor.execute('p', '/dir', 'ssrf', 'medium', logger, options);

const args = mockRunClaudePrompt.mock.calls[0];
expect(args?.[2]).toBe('ctx'); // context
expect(args?.[3]).toBe('ssrf'); // description defaults to agentName
});

it('forwards each model tier correctly', async () => {
const tiers: ModelTier[] = ['small', 'medium', 'large'];

for (const tier of tiers) {
vi.clearAllMocks();
mockRunClaudePrompt.mockResolvedValue(MOCK_RESULT);
await executor.execute('p', '/dir', 'agent', tier, logger);
expect(mockRunClaudePrompt.mock.calls[0]?.[7]).toBe(tier);
}
});

it('returns the result from runClaudePrompt unchanged', async () => {
const customResult: ClaudePromptResult = {
result: null,
success: false,
duration: 0,
cost: 0,
error: 'something failed',
errorType: 'PentestError',
retryable: true,
};
mockRunClaudePrompt.mockResolvedValue(customResult);

const result = await executor.execute('p', '/dir', 'a', 'medium', logger);
expect(result).toBe(customResult);
});
});
41 changes: 41 additions & 0 deletions apps/worker/src/ai/claude-sdk-executor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* ClaudeSdkExecutor — thin adapter wrapping runClaudePrompt to satisfy the Executor interface.
*
* Maps the structured ExecutorOptions to runClaudePrompt's positional arguments.
* No new behavior — purely a delegation layer for DI compatibility.
*/

import type { JsonSchemaOutputFormat } from '@anthropic-ai/claude-agent-sdk';
import type { Executor, ExecutorOptions } from '../interfaces/executor.js';
import type { ActivityLogger } from '../types/activity-logger.js';
import type { ClaudePromptResult } from './claude-executor.js';
import { runClaudePrompt } from './claude-executor.js';
import type { ModelTier } from './models.js';

export class ClaudeSdkExecutor implements Executor {
/** Delegate to runClaudePrompt, mapping ExecutorOptions fields to positional args. */
async execute(
prompt: string,
sourceDir: string,
agentName: string,
modelTier: ModelTier,
logger: ActivityLogger,
options?: ExecutorOptions,
): Promise<ClaudePromptResult> {
return runClaudePrompt(
prompt,
sourceDir,
options?.context ?? '',
options?.description ?? agentName,
agentName,
options?.auditSession ?? null,
logger,
modelTier,
options?.outputFormat as JsonSchemaOutputFormat | undefined,
options?.apiKey,
options?.deliverablesSubdir,
options?.providerConfig,
options?.mcpServers,
);
}
}
Loading