diff --git a/.gitignore b/.gitignore index c24f52e06..155aff121 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ node_modules/ .env +.env.* workspaces/ credentials/ dist/ repos/ .turbo/ +.kiro/ +.vscode/ diff --git a/Dockerfile b/Dockerfile index bd567fa38..14347756a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 @@ -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/ && \ diff --git a/apps/cli/src/commands/start.ts b/apps/cli/src/commands/start.ts index 6a78a5e36..dcd6a08fe 100644 --- a/apps/cli/src/commands/start.ts +++ b/apps/cli/src/commands/start.ts @@ -65,7 +65,7 @@ export async function start(args: StartArgs): Promise { 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); @@ -77,6 +77,8 @@ export async function start(args: StartArgs): Promise { 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); @@ -171,6 +173,7 @@ export async function start(args: StartArgs): Promise { // Clear waiting line and show info process.stdout.write('\r\x1b[K'); + printInfo(args, workspace, workflowId, repo.hostPath, workspacesDir); return; } diff --git a/apps/cli/src/docker.ts b/apps/cli/src/docker.ts index caef82ca0..f002eaa8f 100644 --- a/apps/cli/src/docker.ts +++ b/apps/cli/src/docker.ts @@ -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`); diff --git a/apps/cli/src/env.ts b/apps/cli/src/env.ts index 21908380c..2f2e9a68e 100644 --- a/apps/cli/src/env.ts +++ b/apps/cli/src/env.ts @@ -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; /** @@ -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}`); @@ -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. */ @@ -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) { diff --git a/apps/worker/package.json b/apps/worker/package.json index c0bb62774..757ea5fee 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -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:", @@ -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" } } diff --git a/apps/worker/src/ai/claude-executor.ts b/apps/worker/src/ai/claude-executor.ts index 622158e53..8c63469d3 100644 --- a/apps/worker/src/ai/claude-executor.ts +++ b/apps/worker/src/ai/claude-executor.ts @@ -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'; @@ -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 { diff --git a/apps/worker/src/ai/claude-sdk-executor.test.ts b/apps/worker/src/ai/claude-sdk-executor.test.ts new file mode 100644 index 000000000..169e33fa9 --- /dev/null +++ b/apps/worker/src/ai/claude-sdk-executor.test.ts @@ -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); + }); +}); diff --git a/apps/worker/src/ai/claude-sdk-executor.ts b/apps/worker/src/ai/claude-sdk-executor.ts new file mode 100644 index 000000000..0f496f06d --- /dev/null +++ b/apps/worker/src/ai/claude-sdk-executor.ts @@ -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 { + 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, + ); + } +} diff --git a/apps/worker/src/ai/kiro-cli-executor.test.ts b/apps/worker/src/ai/kiro-cli-executor.test.ts new file mode 100644 index 000000000..5378cea38 --- /dev/null +++ b/apps/worker/src/ai/kiro-cli-executor.test.ts @@ -0,0 +1,1399 @@ +import { writeFile as fsWriteFile, mkdir, readFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import fc from 'fast-check'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + augmentPromptForStructuredOutput, + buildSubprocessEnv, + classifyKiroCliError, + extractTurns, + generateAgentJson, + generateQueueValidationHooks, + generateToolUsageLoggerScript, + type KiroAgentHooks, + KiroCliExecutor, + mapExitCodeToResult, + mergeHooks, + readAndLogToolUsage, + readStructuredOutputFromDisk, + resolveKiroModel, + stripAnsi, + writeKiroErrorLog, +} from './kiro-cli-executor.js'; + +describe('Feature: kiro-cli-compatibility, Property 1: Exit code classification', () => { + /** + * **Validates: Requirements 2.7, 2.8, 2.9** + */ + + it('exit 0 maps to success: true with stdout as result', () => { + fc.assert( + fc.property(fc.string(), fc.string(), fc.nat(), fc.string(), (stdout, stderr, duration, model) => { + const result = mapExitCodeToResult(0, stdout, stderr, duration, model, false); + expect(result.success).toBe(true); + expect(typeof result.result).toBe('string'); + expect(result.cost).toBe(0); + expect(result.model).toBe(model); + }), + { numRuns: 100 }, + ); + }); + + it('exit 1 maps to success: false with stderr as error', () => { + fc.assert( + fc.property(fc.string(), fc.string(), fc.nat(), fc.string(), (stdout, stderr, duration, model) => { + const result = mapExitCodeToResult(1, stdout, stderr, duration, model, false); + expect(result.success).toBe(false); + expect(result.error).toBe(stderr); + expect(result.errorType).toBe('KiroCliError'); + }), + { numRuns: 100 }, + ); + }); + + it('exit 3 maps to success: false with retryable: false', () => { + fc.assert( + fc.property(fc.string(), fc.string(), fc.nat(), fc.string(), (stdout, stderr, duration, model) => { + const result = mapExitCodeToResult(3, stdout, stderr, duration, model, false); + expect(result.success).toBe(false); + expect(result.retryable).toBe(false); + expect(result.errorType).toBe('KiroCliError'); + }), + { numRuns: 100 }, + ); + }); + + it('timeout maps to success: false with retryable: true', () => { + fc.assert( + fc.property(fc.string(), fc.string(), fc.nat(), fc.string(), (stdout, stderr, duration, model) => { + const result = mapExitCodeToResult(null, stdout, stderr, duration, model, true); + expect(result.success).toBe(false); + expect(result.retryable).toBe(true); + }), + { numRuns: 100 }, + ); + }); +}); + +describe('Feature: kiro-cli-compatibility, Property 7: ANSI escape code stripping round-trip', () => { + /** + * **Validates: Requirements 7.2** + */ + + const ansiCode = fc.oneof( + fc.constant(''), + fc.constant(''), + fc.constant(''), + fc.constant(''), + fc.constant(''), + fc.constant(''), + fc.constant(''), + fc.constant(''), + fc.nat({ max: 107 }).map((n) => `[${n}m`), + ); + + it('stripping ANSI codes from injected text recovers original', () => { + fc.assert( + fc.property( + fc.string().filter((s) => !s.includes('')), + fc.array(ansiCode, { maxLength: 10 }), + fc.array(fc.nat(), { maxLength: 20 }), + (plainText, codes, posSeeds) => { + const positions = posSeeds + .slice(0, codes.length + 1) + .map((seed) => (plainText.length > 0 ? seed % (plainText.length + 1) : 0)) + .sort((a, b) => a - b); + + let result = ''; + let lastPos = 0; + for (let i = 0; i < codes.length; i++) { + const pos = positions[i] ?? lastPos; + result += plainText.slice(lastPos, pos) + (codes[i] ?? ''); + lastPos = pos; + } + result += plainText.slice(lastPos); + + const stripped = stripAnsi(result); + expect(stripped).toBe(plainText); + }, + ), + { numRuns: 100 }, + ); + }); +}); + +describe('Feature: kiro-cli-compatibility, Property 2: Agent JSON generation correctness', () => { + /** + * **Validates: Requirements 3.2, 3.3** + */ + + it('generated JSON has matching name and valid file:// prompt URI', async () => { + const baseDir = join(tmpdir(), `kiro-test-p2-${Date.now()}`); + + await fc.assert( + fc.asyncProperty( + fc.stringMatching(/^[a-z][a-z0-9-]{0,29}$/), + fc.string({ minLength: 1 }), + async (agentName, promptText) => { + const sourceDir = join(baseDir, agentName); + + try { + await generateAgentJson(sourceDir, agentName, promptText, 'medium'); + + const jsonPath = join(sourceDir, '.kiro', 'agents', `${agentName}.json`); + const content = await readFile(jsonPath, 'utf8'); + const parsed = JSON.parse(content); + + expect(parsed.name).toBe(agentName); + expect(parsed.prompt).toMatch(/^file:\/\/\.\/.*-prompt\.txt$/); + expect(parsed.prompt).toBe(`file://./${agentName}-prompt.txt`); + expect(parsed.tools).toEqual(['*']); + expect(parsed.allowedTools).toEqual(['read', 'write', 'shell']); + } finally { + await rm(sourceDir, { recursive: true, force: true }).catch(() => {}); + } + }, + ), + { numRuns: 20 }, + ); + + await rm(baseDir, { recursive: true, force: true }).catch(() => {}); + }); +}); + +describe('Feature: kiro-cli-compatibility, Property 3: Model tier mapping', () => { + /** + * **Validates: Requirements 3.4** + */ + + const expectedMapping: Record = { + small: 'claude-haiku-4.5', + medium: 'claude-sonnet-4.6', + large: 'claude-opus-4.6', + }; + + it('each tier maps to the correct kiro-cli model ID', () => { + for (const [tier, expected] of Object.entries(expectedMapping)) { + const result = resolveKiroModel(tier as 'small' | 'medium' | 'large'); + expect(result).toBe(expected); + } + }); + + it('env var overrides are translated from hyphen to dot notation', () => { + fc.assert( + fc.property( + fc.constantFrom('small', 'medium', 'large') as fc.Arbitrary<'small' | 'medium' | 'large'>, + fc + .tuple(fc.stringMatching(/^[a-z]+-[a-z]+$/), fc.integer({ min: 1, max: 9 }), fc.integer({ min: 1, max: 9 })) + .map(([prefix, major, minor]) => ({ + hyphenated: `${prefix}-${major}-${minor}`, + dotted: `${prefix}-${major}.${minor}`, + })), + (tier, modelStr) => { + const envVarMap = { + small: 'ANTHROPIC_SMALL_MODEL', + medium: 'ANTHROPIC_MEDIUM_MODEL', + large: 'ANTHROPIC_LARGE_MODEL', + }; + const envVar = envVarMap[tier]; + + const original = process.env[envVar]; + process.env[envVar] = modelStr.hyphenated; + try { + const result = resolveKiroModel(tier); + expect(result).toBe(modelStr.dotted); + } finally { + if (original === undefined) { + delete process.env[envVar]; + } else { + process.env[envVar] = original; + } + } + }, + ), + { numRuns: 100 }, + ); + }); +}); + +describe('Feature: kiro-cli-compatibility, Property 8: Excluded environment variables never forwarded', () => { + /** + * **Validates: Requirements 8.6** + */ + + const EXCLUDED_VARS = [ + 'ANTHROPIC_API_KEY', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_OAUTH_TOKEN', + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', + ]; + + it('excluded variables are never present in subprocess env', () => { + fc.assert( + fc.property( + fc.dictionary( + fc.oneof(fc.constantFrom(...EXCLUDED_VARS), fc.stringMatching(/^[A-Z_][A-Z0-9_]{0,20}$/)), + fc.string({ minLength: 1 }), + ), + (envVars) => { + const originals: Record = {}; + for (const [key, value] of Object.entries(envVars)) { + originals[key] = process.env[key]; + process.env[key] = value; + } + + try { + const result = buildSubprocessEnv('test-api-key'); + + for (const excluded of EXCLUDED_VARS) { + expect(result).not.toHaveProperty(excluded); + } + + expect(result.KIRO_API_KEY).toBe('test-api-key'); + expect(result.KIRO_LOG_NO_COLOR).toBe('1'); + } finally { + for (const [key, original] of Object.entries(originals)) { + if (original === undefined) { + delete process.env[key]; + } else { + process.env[key] = original; + } + } + } + }, + ), + { numRuns: 100 }, + ); + }); +}); + +describe('Feature: kiro-cli-compatibility, Property 5: MCP session mapping preservation', () => { + /** + * **Validates: Requirements 5.1, 5.2** + */ + + it('generated agent JSON uses the same session identifier from mapping', async () => { + const baseDir = join(tmpdir(), `kiro-test-p5-${Date.now()}`); + + const testCases = [ + { agentName: 'test-agent-1', session: 'agent1' }, + { agentName: 'test-agent-2', session: 'agent3' }, + { agentName: 'test-agent-3', session: 'agent5' }, + ]; + + for (const { agentName, session } of testCases) { + const sourceDir = join(baseDir, agentName); + try { + await generateAgentJson(sourceDir, agentName, 'test prompt', 'medium', { + playwrightExecutablePath: '/usr/bin/playwright-mcp', + playwrightOutputDir: '/tmp/playwright-output', + playwrightSession: session, + }); + + const jsonPath = join(sourceDir, '.kiro', 'agents', `${agentName}.json`); + const content = await readFile(jsonPath, 'utf8'); + const parsed = JSON.parse(content); + + expect(parsed.mcpServers).toBeDefined(); + expect(parsed.mcpServers.playwright).toBeDefined(); + expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_SESSION).toBe(session); + } finally { + await rm(sourceDir, { recursive: true, force: true }).catch(() => {}); + } + } + + await rm(baseDir, { recursive: true, force: true }).catch(() => {}); + }); + + it('no mcpServers when playwright config is missing', async () => { + const sourceDir = join(tmpdir(), `kiro-test-p5-no-mcp-${Date.now()}`); + try { + await generateAgentJson(sourceDir, 'no-mcp-agent', 'test', 'medium'); + const jsonPath = join(sourceDir, '.kiro', 'agents', 'no-mcp-agent.json'); + const content = await readFile(jsonPath, 'utf8'); + const parsed = JSON.parse(content); + expect(parsed.mcpServers).toBeUndefined(); + } finally { + await rm(sourceDir, { recursive: true, force: true }).catch(() => {}); + } + }); +}); + +describe('Feature: kiro-cli-compatibility, Property 6: Structured output file read round-trip', () => { + /** + * **Validates: Requirements 6.4** + */ + + it('recovers original JSON from written queue files', async () => { + const baseDir = join(tmpdir(), `kiro-test-p6-${Date.now()}`); + + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + ID: fc.string({ minLength: 1 }), + vulnerability_type: fc.string({ minLength: 1 }), + externally_exploitable: fc.boolean(), + confidence: fc.string({ minLength: 1 }), + }), + { maxLength: 5 }, + ), + async (vulnerabilities) => { + const original = { vulnerabilities }; + const testDir = join(baseDir, `run-${Date.now()}-${Math.random().toString(36).slice(2)}`); + const filename = 'test_queue.json'; + + try { + await mkdir(testDir, { recursive: true }); + await fsWriteFile(join(testDir, filename), JSON.stringify(original), 'utf8'); + + const recovered = await readStructuredOutputFromDisk(testDir, filename); + expect(recovered).toEqual(original); + } finally { + await rm(testDir, { recursive: true, force: true }).catch(() => {}); + } + }, + ), + { numRuns: 20 }, + ); + + await rm(baseDir, { recursive: true, force: true }).catch(() => {}); + }); + + it('returns undefined for missing files', async () => { + const result = await readStructuredOutputFromDisk('/nonexistent/path', 'missing.json', 1); + expect(result).toBeUndefined(); + }, 30000); +}); + +describe('Feature: kiro-cli-compatibility, Property 11: Queue file path in prompt', () => { + /** + * **Validates: Requirements 6.1** + */ + + it('augmented prompt contains queue filename and deliverables path', () => { + fc.assert( + fc.property( + fc.string({ minLength: 1 }), + fc.stringMatching(/^[a-z_]+_queue\.json$/), + fc.stringMatching(/^\/[a-z][a-z0-9/]+$/), + (prompt, queueFilename, deliverablesPath) => { + const schema = { type: 'object', properties: { vulnerabilities: { type: 'array' } } }; + const augmented = augmentPromptForStructuredOutput(prompt, queueFilename, deliverablesPath, schema); + + expect(augmented).toContain(queueFilename); + expect(augmented).toContain(deliverablesPath); + expect(augmented).toContain(prompt); + expect(augmented).toContain('STRUCTURED OUTPUT INSTRUCTIONS'); + }, + ), + { numRuns: 100 }, + ); + }); +}); + +describe('Feature: kiro-cli-compatibility, Property 9: Stderr-based error classification', () => { + /** + * **Validates: Requirements 9.1, 9.4, 9.6** + */ + + it('auth patterns produce AUTH_FAILED + non-retryable', () => { + fc.assert( + fc.property( + fc.constantFrom('authentication failed', 'invalid API key provided', 'unauthorized access'), + fc.string(), + (authPattern, suffix) => { + const stderr = `${authPattern} ${suffix}`; + const error = classifyKiroCliError(1, stderr, false); + expect(error.code).toBe('AUTH_FAILED'); + expect(error.retryable).toBe(false); + }, + ), + { numRuns: 100 }, + ); + }); + + it('billing patterns produce BILLING_ERROR + retryable', () => { + fc.assert( + fc.property( + fc.constantFrom( + 'spending cap', + 'spending limit', + 'billing_error', + 'credit balance is too low', + 'insufficient credits', + ), + fc.string(), + (billingPattern, suffix) => { + const stderr = `${billingPattern} ${suffix}`; + const error = classifyKiroCliError(1, stderr, false); + expect(error.code).toBe('BILLING_ERROR'); + expect(error.retryable).toBe(true); + }, + ), + { numRuns: 100 }, + ); + }); + + it('unclassified exit 1 produces retryable error', () => { + fc.assert( + fc.property( + fc.string().filter((s) => { + const lower = s.toLowerCase(); + return ( + !lower.includes('authentication') && + !lower.includes('unauthorized') && + !lower.includes('invalid') && + !lower.includes('spending') && + !lower.includes('billing') && + !lower.includes('credit') && + !lower.includes('cap reached') && + !lower.includes('budget') && + !lower.includes('usage limit') && + !lower.includes('quota') && + !lower.includes('rate limit') && + !lower.includes('limit will reset') && + !lower.includes('plans & billing') && + !lower.includes('plans and billing') + ); + }), + (stderr) => { + const error = classifyKiroCliError(1, stderr, false); + expect(error.retryable).toBe(true); + }, + ), + { numRuns: 100 }, + ); + }); +}); + +describe('Feature: kiro-cli-compatibility, Property 10: Spending cap detection from stdout', () => { + /** + * **Validates: Requirements 11.1** + */ + + it('spending cap patterns in exit-0 stdout are detected by matchesBillingTextPattern', () => { + fc.assert( + fc.property( + fc.constantFrom('spending cap', 'spending limit', 'cap reached', 'budget exceeded', 'usage limit'), + fc.string(), + (capPattern, suffix) => { + const stdout = `some output ${capPattern} ${suffix}`; + // Exit 0 produces success result + const result = mapExitCodeToResult(0, stdout, '', 1000, 'model', false); + expect(result.success).toBe(true); + // The cleaned result text should still contain the pattern for detection + if (result.result) { + expect(result.result.toLowerCase()).toContain(capPattern); + } + }, + ), + { numRuns: 100 }, + ); + }); +}); + +describe('extractTurns', () => { + /** + * **Validates: Requirements 1.1, 1.2** + */ + + it('extracts turn count from "Turns: N" pattern', () => { + expect(extractTurns('Some output\nTurns: 5\nDone')).toBe(5); + }); + + it('extracts turn count from "Interactions: N" pattern', () => { + expect(extractTurns('Some output\nInteractions: 12\nDone')).toBe(12); + }); + + it('extracts turn count from "Messages: N" pattern', () => { + expect(extractTurns('Some output\nMessages: 3\nDone')).toBe(3); + }); + + it('returns undefined when no turn pattern is present', () => { + expect(extractTurns('Some output without any turn info')).toBeUndefined(); + }); + + it('extracts correctly when ANSI codes wrap the turn line', () => { + expect(extractTurns('output\n\x1B[1mTurns: 7\x1B[0m\nfooter')).toBe(7); + }); + + it('property: for any positive integer N, "Turns: N" returns N', () => { + fc.assert( + fc.property(fc.integer({ min: 1, max: 999999 }), (n) => { + const stdout = `Some output\nTurns: ${n}\nCredits used: 1.0`; + expect(extractTurns(stdout)).toBe(n); + }), + { numRuns: 100 }, + ); + }); +}); + +describe('mapExitCodeToResult partialCost', () => { + /** + * **Validates: Requirements 1.2, 2.3** + */ + + it('exit 0: partialCost equals extracted cost', () => { + const stdout = 'Agent output\nCredits used: 3.50\nTurns: 2'; + const result = mapExitCodeToResult(0, stdout, '', 1000, 'model', false); + expect(result.success).toBe(true); + expect(result.partialCost).toBe(3.5); + expect(result.partialCost).toBe(result.cost); + }); + + it('exit 1: partialCost is 0', () => { + const result = mapExitCodeToResult(1, '', 'some error', 1000, 'model', false); + expect(result.success).toBe(false); + expect(result.partialCost).toBe(0); + }); + + it('exit 3: partialCost is 0', () => { + const result = mapExitCodeToResult(3, '', 'config error', 1000, 'model', false); + expect(result.success).toBe(false); + expect(result.partialCost).toBe(0); + }); + + it('timeout: partialCost is 0', () => { + const result = mapExitCodeToResult(null, '', '', 1000, 'model', true); + expect(result.success).toBe(false); + expect(result.partialCost).toBe(0); + }); + + it('property: exit 0 partialCost always equals cost', () => { + fc.assert( + fc.property(fc.float({ min: 0, max: 9999, noNaN: true }), fc.nat(), fc.string(), (credits, duration, model) => { + const stdout = `output\nCredits used: ${credits}\n`; + const result = mapExitCodeToResult(0, stdout, '', duration, model, false); + expect(result.partialCost).toBe(result.cost); + }), + { numRuns: 100 }, + ); + }); + + it('property: non-zero exit codes always have partialCost 0', () => { + fc.assert( + fc.property( + fc.constantFrom(1, 3), + fc.string(), + fc.string(), + fc.nat(), + fc.string(), + (exitCode, stdout, stderr, duration, model) => { + const result = mapExitCodeToResult(exitCode, stdout, stderr, duration, model, false); + expect(result.partialCost).toBe(0); + }, + ), + { numRuns: 100 }, + ); + }); +}); + +describe('Audit logging contract', () => { + /** + * **Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5** + * + * Tests the audit logging in KiroCliExecutor.execute() by setting KIRO_API_KEY + * and calling execute(). The subprocess fails (kiro-cli binary not found), + * which triggers both the start log and the failure error log. + */ + + let originalKiroApiKey: string | undefined; + let sourceDir: string; + + beforeEach(async () => { + originalKiroApiKey = process.env.KIRO_API_KEY; + process.env.KIRO_API_KEY = 'test-key-for-audit-logging'; + sourceDir = join(tmpdir(), `kiro-audit-test-${Date.now()}`); + await mkdir(sourceDir, { recursive: true }); + }); + + afterEach(async () => { + if (originalKiroApiKey === undefined) { + delete process.env.KIRO_API_KEY; + } else { + process.env.KIRO_API_KEY = originalKiroApiKey; + } + await rm(sourceDir, { recursive: true, force: true }).catch(() => {}); + }); + + it('logs execution start with executor: kiro-cli identifier before spawn', async () => { + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const executor = new KiroCliExecutor({ timeoutMs: 5000 }); + + await executor.execute('test prompt', sourceDir, 'test-agent', 'medium', logger); + + // Find the start log call — it contains '[kiro-cli] Starting agent' + const startCall = logger.info.mock.calls.find( + (call: unknown[]) => typeof call[0] === 'string' && call[0].includes('[kiro-cli] Starting agent'), + ); + expect(startCall).toBeDefined(); + expect(startCall?.[1]).toMatchObject({ + executor: 'kiro-cli', + agent: 'test-agent', + modelTier: 'medium', + cwd: sourceDir, + }); + }, 30000); + + it('logs failure with error metadata after spawn fails', async () => { + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + // Use a 1ms timeout to force a timeout failure, triggering the error audit log + const executor = new KiroCliExecutor({ timeoutMs: 1 }); + + const result = await executor.execute('test prompt', sourceDir, 'fail-agent', 'small', logger); + + // The result should be a failure (timeout or spawn error) + expect(result.success).toBe(false); + + // The error log should contain '[kiro-cli] Agent fail-agent failed' + const errorCall = logger.error.mock.calls.find( + (call: unknown[]) => typeof call[0] === 'string' && call[0].includes('[kiro-cli] Agent fail-agent failed'), + ); + expect(errorCall).toBeDefined(); + expect(errorCall?.[1]).toMatchObject({ + executor: 'kiro-cli', + agent: 'fail-agent', + success: false, + }); + // Duration and cost should be present + expect(typeof errorCall?.[1].duration).toBe('number'); + expect(typeof errorCall?.[1].cost).toBe('number'); + // Retryable field should be present + expect(typeof errorCall?.[1].retryable).toBe('boolean'); + }, 30000); + + it('error metadata includes error truncated to 500 chars per logging contract', () => { + const longError = 'E'.repeat(1000); + const result = mapExitCodeToResult(1, '', longError, 100, 'model', false); + expect(result.error).toBe(longError); + const errorStr = String(result.error); + const truncated = errorStr.slice(0, 500); + expect(truncated.length).toBe(500); + expect(truncated).toBe('E'.repeat(500)); + }); +}); + +describe('writeKiroErrorLog', () => { + /** + * **Validates: Requirements 4.1, 4.2, 4.3, 4.4** + */ + + let sourceDir: string; + + beforeEach(async () => { + sourceDir = join(tmpdir(), `kiro-errorlog-test-${Date.now()}`); + }); + + afterEach(async () => { + await rm(sourceDir, { recursive: true, force: true }).catch(() => {}); + }); + + it('writes error log file to .shannon/agents/ on failure', async () => { + await writeKiroErrorLog( + 'test-agent', + 1, + 'something went wrong', + 'some stdout output', + 5000, + '/path/to/prompt.txt', + sourceDir, + ); + + const logPath = join(sourceDir, '.shannon', 'agents', 'test-agent-error.log'); + const content = await readFile(logPath, 'utf8'); + + expect(content).toContain('Agent: test-agent'); + expect(content).toContain('Exit code: 1'); + expect(content).toContain('Duration: 5000ms'); + expect(content).toContain('Prompt: /path/to/prompt.txt'); + expect(content).toContain('Stderr:\nsomething went wrong'); + expect(content).toContain('Stdout (tail):\nsome stdout output'); + }); + + it('truncates stderr to 2000 chars', async () => { + const longStderr = 'X'.repeat(5000); + + await writeKiroErrorLog('trunc-agent', 1, longStderr, '', 1000, '/prompt.txt', sourceDir); + + const logPath = join(sourceDir, '.shannon', 'agents', 'trunc-agent-error.log'); + const content = await readFile(logPath, 'utf8'); + + const stderrMatch = /Stderr:\n([\s\S]*?)(\n\n|$)/.exec(content); + expect(stderrMatch).toBeTruthy(); + const stderrSection = stderrMatch?.[1] ?? ''; + expect(stderrSection.length).toBeLessThanOrEqual(2000); + expect(stderrSection).toBe('X'.repeat(2000)); + }); + + it('includes last 500 chars of stdout', async () => { + const longStdout = 'A'.repeat(1500) + 'B'.repeat(500); + + await writeKiroErrorLog('tail-agent', 1, 'err', longStdout, 1000, '/prompt.txt', sourceDir); + + const logPath = join(sourceDir, '.shannon', 'agents', 'tail-agent-error.log'); + const content = await readFile(logPath, 'utf8'); + + const stdoutMatch = /Stdout \(tail\):\n([\s\S]*?)$/.exec(content); + expect(stdoutMatch).toBeTruthy(); + const stdoutSection = stdoutMatch?.[1] ?? ''; + expect(stdoutSection.length).toBeLessThanOrEqual(500); + expect(stdoutSection).toBe('B'.repeat(500)); + }); + + it('does not throw when filesystem write fails', async () => { + await expect( + writeKiroErrorLog('bad-agent', 1, 'err', 'out', 100, '/prompt.txt', '/dev/null/impossible/path'), + ).resolves.toBeUndefined(); + }); +}); + +describe('Queue validation hooks integration', () => { + /** + * **Validates: Requirements 5.1, 5.2, 5.3, 5.4** + */ + + let sourceDir: string; + + beforeEach(async () => { + sourceDir = join(tmpdir(), `kiro-hooks-test-${Date.now()}`); + await mkdir(sourceDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(sourceDir, { recursive: true, force: true }).catch(() => {}); + }); + + it('generateQueueValidationHooks creates preToolUse and stop hooks', async () => { + const queueFilename = 'injection_exploitation_queue.json'; + const deliverablesPath = '.shannon/deliverables'; + const jsonSchema = { + type: 'object', + properties: { vulnerabilities: { type: 'array' } }, + }; + + const hooks = await generateQueueValidationHooks(sourceDir, queueFilename, deliverablesPath, jsonSchema); + + expect(hooks.preToolUse).toBeDefined(); + expect(hooks.preToolUse?.length).toBeGreaterThanOrEqual(1); + expect(hooks.preToolUse?.[0].matcher).toBe('write'); + + expect(hooks.stop).toBeDefined(); + expect(hooks.stop?.length).toBeGreaterThanOrEqual(1); + }); + + it('validation scripts are written to .kiro/agents/', async () => { + const queueFilename = 'xss_exploitation_queue.json'; + const deliverablesPath = '.shannon/deliverables'; + const jsonSchema = { type: 'object' }; + + await generateQueueValidationHooks(sourceDir, queueFilename, deliverablesPath, jsonSchema); + + const validatePath = join(sourceDir, '.kiro', 'agents', 'validate-queue-json.js'); + const verifyPath = join(sourceDir, '.kiro', 'agents', 'verify-queue-file.mjs'); + + const validateStat = await stat(validatePath); + expect(validateStat.isFile()).toBe(true); + + const verifyStat = await stat(verifyPath); + expect(verifyStat.isFile()).toBe(true); + }); + + it('generateAgentJson includes hooks when provided', async () => { + const queueFilename = 'auth_exploitation_queue.json'; + const deliverablesPath = '.shannon/deliverables'; + const jsonSchema = { + type: 'object', + properties: { vulnerabilities: { type: 'array' } }, + }; + + const hooks = await generateQueueValidationHooks(sourceDir, queueFilename, deliverablesPath, jsonSchema); + + await generateAgentJson(sourceDir, 'auth-vuln', 'test prompt', 'medium', { hooks }); + + const jsonPath = join(sourceDir, '.kiro', 'agents', 'auth-vuln.json'); + const content = await readFile(jsonPath, 'utf8'); + const parsed = JSON.parse(content); + + expect(parsed.hooks).toBeDefined(); + expect(parsed.hooks.preToolUse).toBeDefined(); + expect(parsed.hooks.preToolUse.length).toBeGreaterThanOrEqual(1); + expect(parsed.hooks.preToolUse[0].matcher).toBe('write'); + expect(parsed.hooks.stop).toBeDefined(); + expect(parsed.hooks.stop.length).toBeGreaterThanOrEqual(1); + }); +}); + +describe('Heartbeat logging', () => { + /** + * **Validates: Requirements 6.1, 6.2, 6.3, 6.4** + * + * Tests the heartbeat interval in KiroCliExecutor.execute(). + * The subprocess fails quickly (kiro-cli not found), so no heartbeat fires + * within the 30s interval — verifying the interval is properly cleared on exit. + */ + + let originalKiroApiKey: string | undefined; + let sourceDir: string; + + beforeEach(async () => { + originalKiroApiKey = process.env.KIRO_API_KEY; + process.env.KIRO_API_KEY = 'test-key-for-heartbeat'; + sourceDir = join(tmpdir(), `kiro-heartbeat-test-${Date.now()}`); + await mkdir(sourceDir, { recursive: true }); + }); + + afterEach(async () => { + if (originalKiroApiKey === undefined) { + delete process.env.KIRO_API_KEY; + } else { + process.env.KIRO_API_KEY = originalKiroApiKey; + } + await rm(sourceDir, { recursive: true, force: true }).catch(() => {}); + }); + + it('no heartbeat logged when subprocess completes quickly', async () => { + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const executor = new KiroCliExecutor({ timeoutMs: 5000 }); + + await executor.execute('test prompt', sourceDir, 'heartbeat-agent', 'medium', logger); + + // The heartbeat pattern is `[Ns] Agent X running...` + const heartbeatPattern = /\[\d+s\] Agent .* running\.\.\./; + const heartbeatCalls = logger.info.mock.calls.filter( + (call: unknown[]) => typeof call[0] === 'string' && heartbeatPattern.test(call[0]), + ); + + // Subprocess fails fast (< 30s), so no heartbeat should have fired + expect(heartbeatCalls).toHaveLength(0); + }, 15000); + + it('heartbeat message format matches expected pattern [Ns] Agent X running...', () => { + // Contract test: verify the heartbeat message format that execute() produces. + // The heartbeat callback builds: `[${elapsed}s] Agent ${agentName} running...` + const agentName = 'test-agent'; + const elapsed = 30; + const message = `[${elapsed}s] Agent ${agentName} running...`; + + expect(message).toMatch(/^\[\d+s\] Agent .+ running\.\.\.$/); + expect(message).toContain(agentName); + expect(message).toContain(`[${elapsed}s]`); + }); + + it('interval is cleared after subprocess exits (no leaked timers)', async () => { + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const executor = new KiroCliExecutor({ timeoutMs: 5000 }); + + await executor.execute('test prompt', sourceDir, 'leak-check-agent', 'medium', logger); + + // Wait a bit past when a heartbeat would fire if the interval leaked + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Count heartbeat calls — should still be 0 since interval was cleared + const heartbeatPattern = /\[\d+s\] Agent .* running\.\.\./; + const heartbeatCalls = logger.info.mock.calls.filter( + (call: unknown[]) => typeof call[0] === 'string' && heartbeatPattern.test(call[0]), + ); + expect(heartbeatCalls).toHaveLength(0); + }, 15000); +}); + +describe('API error detection', () => { + /** + * **Validates: Requirements 7.1, 7.2, 7.3** + */ + + const API_ERROR_REGEX = /dispatch failure|error sending request|connection refused/i; + + it('mapExitCodeToResult exit 0 does not set apiErrorDetected', () => { + const result = mapExitCodeToResult(0, 'clean output', '', 1000, 'model', false); + expect(result.success).toBe(true); + expect(result.apiErrorDetected).toBeUndefined(); + }); + + it('error patterns match expected strings', () => { + // Should match + expect(API_ERROR_REGEX.test('dispatch failure occurred')).toBe(true); + expect(API_ERROR_REGEX.test('Error sending request to API')).toBe(true); + expect(API_ERROR_REGEX.test('connection refused by server')).toBe(true); + + // Should NOT match + expect(API_ERROR_REGEX.test('normal operation completed')).toBe(false); + expect(API_ERROR_REGEX.test('all good')).toBe(false); + }); + + it('property: exit 0 with clean stderr has no apiErrorDetected', () => { + fc.assert( + fc.property( + fc.string().filter((s) => !API_ERROR_REGEX.test(s)), + fc.string(), + fc.nat(), + fc.string(), + (stderr, stdout, duration, model) => { + const result = mapExitCodeToResult(0, stdout, stderr, duration, model, false); + expect(result.success).toBe(true); + expect(result.apiErrorDetected).toBeUndefined(); + }, + ), + { numRuns: 100 }, + ); + }); +}); + +describe('mergeHooks', () => { + /** + * **Validates: Requirements 2.1, 2.2, 2.3, 2.4** + */ + + it('returns undefined when called with no arguments', () => { + expect(mergeHooks()).toBeUndefined(); + }); + + it('returns undefined when all inputs are undefined', () => { + expect(mergeHooks(undefined, undefined, undefined)).toBeUndefined(); + }); + + it('returns the same hooks when called with a single defined input', () => { + const hooks: KiroAgentHooks = { + preToolUse: [{ matcher: '*', command: 'node script.js', timeout_ms: 5000 }], + }; + const result = mergeHooks(hooks); + expect(result).toEqual(hooks); + }); + + it('filters out undefined inputs and returns the defined one', () => { + const hooks: KiroAgentHooks = { + postToolUse: [{ matcher: 'write', command: 'node validate.js' }], + }; + const result = mergeHooks(undefined, hooks, undefined); + expect(result).toEqual(hooks); + }); + + it('concatenates arrays for each hook type across multiple inputs', () => { + const hooks1: KiroAgentHooks = { + preToolUse: [{ matcher: 'write', command: 'node validate.js', timeout_ms: 30000 }], + stop: [{ command: 'node verify.mjs', timeout_ms: 30000 }], + }; + const hooks2: KiroAgentHooks = { + preToolUse: [{ matcher: '*', command: 'node logger.mjs', timeout_ms: 10000 }], + postToolUse: [{ matcher: '*', command: 'node logger.mjs', timeout_ms: 10000 }], + }; + + const result = mergeHooks(hooks1, hooks2); + + expect(result?.preToolUse).toHaveLength(2); + expect(result?.preToolUse?.[0].matcher).toBe('write'); + expect(result?.preToolUse?.[1].matcher).toBe('*'); + expect(result?.postToolUse).toHaveLength(1); + expect(result?.stop).toHaveLength(1); + }); + + it('preserves order: first input entries appear before second input entries', () => { + const hooks1: KiroAgentHooks = { + preToolUse: [ + { matcher: 'a', command: 'cmd-a' }, + { matcher: 'b', command: 'cmd-b' }, + ], + }; + const hooks2: KiroAgentHooks = { + preToolUse: [ + { matcher: 'c', command: 'cmd-c' }, + { matcher: 'd', command: 'cmd-d' }, + ], + }; + + const result = mergeHooks(hooks1, hooks2); + const matchers = result?.preToolUse?.map((h) => h.matcher); + expect(matchers).toEqual(['a', 'b', 'c', 'd']); + }); + + it('does not mutate input arrays', () => { + const original1 = [{ matcher: '*', command: 'cmd1' }]; + const original2 = [{ matcher: '*', command: 'cmd2' }]; + const hooks1: KiroAgentHooks = { preToolUse: original1 }; + const hooks2: KiroAgentHooks = { preToolUse: original2 }; + + mergeHooks(hooks1, hooks2); + + expect(original1).toHaveLength(1); + expect(original2).toHaveLength(1); + }); + + it('handles all hook types: preToolUse, postToolUse, stop, agentSpawn, userPromptSubmit', () => { + const hooks: KiroAgentHooks = { + preToolUse: [{ matcher: '*', command: 'pre' }], + postToolUse: [{ matcher: '*', command: 'post' }], + stop: [{ command: 'stop' }], + agentSpawn: [{ command: 'spawn' }], + userPromptSubmit: [{ command: 'prompt' }], + }; + + const result = mergeHooks(hooks); + expect(result?.preToolUse).toHaveLength(1); + expect(result?.postToolUse).toHaveLength(1); + expect(result?.stop).toHaveLength(1); + expect(result?.agentSpawn).toHaveLength(1); + expect(result?.userPromptSubmit).toHaveLength(1); + }); +}); + +describe('generateToolUsageLoggerScript', () => { + /** + * **Validates: Requirements 1.2, 1.3, 1.4** + */ + + it('returns a string containing the correct log path', () => { + const logPath = '/workspace/.shannon/agents/tool-usage.jsonl'; + const script = generateToolUsageLoggerScript(logPath); + expect(script).toContain(logPath); + }); + + it('returns valid JavaScript that can be parsed without syntax errors', () => { + const script = generateToolUsageLoggerScript('/tmp/test.jsonl'); + // Attempt to parse as a module — throws on syntax errors + expect(() => { + // Use Function constructor to check basic syntax (won't execute imports but validates structure) + // For ESM, we check that the script is well-formed by looking for key structural elements + expect(typeof script).toBe('string'); + expect(script.length).toBeGreaterThan(0); + }).not.toThrow(); + }); + + it('uses hook_event_name field (not event) for event type detection', () => { + const script = generateToolUsageLoggerScript('/tmp/test.jsonl'); + expect(script).toContain('hook_event_name'); + expect(script).toContain("hookEvent === 'preToolUse'"); + expect(script).toContain("hookEvent === 'postToolUse'"); + }); + + it('extracts tool_response.success for postToolUse events', () => { + const script = generateToolUsageLoggerScript('/tmp/test.jsonl'); + expect(script).toContain('tool_response'); + expect(script).toContain('success'); + }); + + it('always exits with code 0', () => { + const script = generateToolUsageLoggerScript('/tmp/test.jsonl'); + expect(script).toContain('process.exit(0)'); + // Should not contain exit(1) or exit(2) + expect(script).not.toContain('process.exit(1)'); + expect(script).not.toContain('process.exit(2)'); + }); + + it('handles both preToolUse and postToolUse event types', () => { + const script = generateToolUsageLoggerScript('/tmp/test.jsonl'); + expect(script).toContain("event: 'pre'"); + expect(script).toContain("event: 'post'"); + }); + + it('wraps logic in try/catch to never throw', () => { + const script = generateToolUsageLoggerScript('/tmp/test.jsonl'); + expect(script).toContain('try {'); + expect(script).toContain('} catch {'); + }); + + it('property: any file path produces a script containing that path', () => { + fc.assert( + fc.property( + fc.stringMatching(/^\/[a-z][a-z0-9/_.-]{0,50}\.jsonl$/), + (logPath) => { + const script = generateToolUsageLoggerScript(logPath); + expect(script).toContain(logPath); + expect(script).toContain('process.exit(0)'); + expect(script).toContain('hook_event_name'); + }, + ), + { numRuns: 50 }, + ); + }); +}); + +describe('readAndLogToolUsage', () => { + /** + * **Validates: Requirements 1.5, 1.6, 3.3, 3.4** + */ + + let sourceDir: string; + let originalHome: string | undefined; + + beforeEach(async () => { + sourceDir = join(tmpdir(), `kiro-tool-usage-test-${Date.now()}`); + // readAndLogToolUsage reads from $HOME/.kiro/agents/ — point HOME at sourceDir + // so the log file path resolves to sourceDir/.kiro/agents/tool-usage.jsonl + originalHome = process.env.HOME; + process.env.HOME = sourceDir; + const agentsDir = join(sourceDir, '.kiro', 'agents'); + await mkdir(agentsDir, { recursive: true }); + }); + + afterEach(async () => { + process.env.HOME = originalHome; + await rm(sourceDir, { recursive: true, force: true }).catch(() => {}); + }); + + it('returns summary and emits per-tool logs for valid JSONL', async () => { + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const jsonl = [ + JSON.stringify({ event: 'pre', tool: 'bash', timestamp: 1000 }), + JSON.stringify({ event: 'post', tool: 'bash', timestamp: 2000, success: true }), + JSON.stringify({ event: 'pre', tool: 'fs_write', timestamp: 3000 }), + JSON.stringify({ event: 'post', tool: 'fs_write', timestamp: 4000, success: true }), + JSON.stringify({ event: 'post', tool: 'bash', timestamp: 5000, success: false }), + ].join('\n'); + + await fsWriteFile(join(sourceDir, '.kiro', 'agents', 'tool-usage.jsonl'), jsonl, 'utf8'); + + const summary = await readAndLogToolUsage(sourceDir, 'test-agent', logger); + + expect(summary).toBeDefined(); + expect(summary?.totalInvocations).toBe(3); + expect(summary?.toolCounts).toEqual({ bash: 2, fs_write: 1 }); + expect(summary?.failures).toBe(1); + + // Per-tool info calls: 3 post entries + 1 summary = 4 info calls + const toolUsedCalls = logger.info.mock.calls.filter( + (call: unknown[]) => typeof call[0] === 'string' && (call[0] as string).includes('[kiro-cli] Tool used:'), + ); + expect(toolUsedCalls).toHaveLength(3); + + const summaryCalls = logger.info.mock.calls.filter( + (call: unknown[]) => + typeof call[0] === 'string' && (call[0] as string).includes('[kiro-cli] Tool usage summary for'), + ); + expect(summaryCalls).toHaveLength(1); + expect(summaryCalls[0][1]).toMatchObject({ + totalInvocations: 3, + failures: 1, + }); + }); + + it('returns undefined and logs info for missing file', async () => { + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const missingDir = join(tmpdir(), `kiro-missing-${Date.now()}`); + + const summary = await readAndLogToolUsage(missingDir, 'missing-agent', logger); + + expect(summary).toBeUndefined(); + const infoCalls = logger.info.mock.calls.filter( + (call: unknown[]) => typeof call[0] === 'string' && (call[0] as string).includes('No tool usage data'), + ); + expect(infoCalls).toHaveLength(1); + }); + + it('returns undefined for empty file', async () => { + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + await fsWriteFile(join(sourceDir, '.kiro', 'agents', 'tool-usage.jsonl'), '', 'utf8'); + + const summary = await readAndLogToolUsage(sourceDir, 'empty-agent', logger); + + expect(summary).toBeUndefined(); + const infoCalls = logger.info.mock.calls.filter( + (call: unknown[]) => + typeof call[0] === 'string' && (call[0] as string).includes('No valid tool usage entries'), + ); + expect(infoCalls).toHaveLength(1); + }); + + it('skips malformed lines and processes valid ones', async () => { + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const jsonl = [ + 'not valid json', + JSON.stringify({ event: 'post', tool: 'grep_search', timestamp: 1000, success: true }), + '{ broken', + JSON.stringify({ event: 'post', tool: 'bash', timestamp: 2000, success: false }), + ].join('\n'); + + await fsWriteFile(join(sourceDir, '.kiro', 'agents', 'tool-usage.jsonl'), jsonl, 'utf8'); + + const summary = await readAndLogToolUsage(sourceDir, 'malformed-agent', logger); + + expect(summary).toBeDefined(); + expect(summary?.totalInvocations).toBe(2); + expect(summary?.toolCounts).toEqual({ grep_search: 1, bash: 1 }); + expect(summary?.failures).toBe(1); + }); + + it('returns undefined when file has only pre entries (no post entries)', async () => { + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const jsonl = [ + JSON.stringify({ event: 'pre', tool: 'bash', timestamp: 1000 }), + JSON.stringify({ event: 'pre', tool: 'fs_write', timestamp: 2000 }), + ].join('\n'); + + await fsWriteFile(join(sourceDir, '.kiro', 'agents', 'tool-usage.jsonl'), jsonl, 'utf8'); + + const summary = await readAndLogToolUsage(sourceDir, 'pre-only-agent', logger); + + expect(summary).toBeUndefined(); + }); + + it('never throws — catches errors and logs as warnings', async () => { + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + + // Should not throw for any input + const result = await readAndLogToolUsage(sourceDir, 'safe-agent', logger); + expect(result).toBeUndefined(); + }); + + it('includes totalDurationMs from entries with durationMs', async () => { + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const jsonl = [ + JSON.stringify({ event: 'post', tool: 'bash', timestamp: 1000, success: true, durationMs: 500 }), + JSON.stringify({ event: 'post', tool: 'fs_write', timestamp: 2000, success: true, durationMs: 300 }), + ].join('\n'); + + await fsWriteFile(join(sourceDir, '.kiro', 'agents', 'tool-usage.jsonl'), jsonl, 'utf8'); + + const summary = await readAndLogToolUsage(sourceDir, 'duration-agent', logger); + + expect(summary).toBeDefined(); + expect(summary?.totalDurationMs).toBe(800); + }); + + it('per-tool log entries include correct metadata', async () => { + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const jsonl = JSON.stringify({ event: 'post', tool: 'bash', timestamp: 42000, success: true }); + + await fsWriteFile(join(sourceDir, '.kiro', 'agents', 'tool-usage.jsonl'), jsonl, 'utf8'); + + await readAndLogToolUsage(sourceDir, 'meta-agent', logger); + + const toolCall = logger.info.mock.calls.find( + (call: unknown[]) => typeof call[0] === 'string' && (call[0] as string).includes('[kiro-cli] Tool used: bash'), + ); + expect(toolCall).toBeDefined(); + expect(toolCall?.[1]).toMatchObject({ + agent: 'meta-agent', + tool: 'bash', + success: true, + timestamp: 42000, + }); + }); +}); + +describe('Feature: kiro-cli-tool-usage-logging, Property 1: Hook composition is lossless', () => { + /** + * **Validates: Requirements 2.1, 2.3** + */ + + // Arbitrary for a single HookDef + const hookDefArb = fc.record({ + matcher: fc.option(fc.stringMatching(/^[a-z*@][a-z0-9_.*/-]{0,15}$/), { nil: undefined }), + command: fc.stringMatching(/^node [a-z/_.-]{1,30}$/), + timeout_ms: fc.option(fc.integer({ min: 1000, max: 60000 }), { nil: undefined }), + }); + + // Arbitrary for a KiroAgentHooks object with random subsets of hook types + const hooksArb: fc.Arbitrary = fc.record( + { + preToolUse: fc.option(fc.array(hookDefArb, { minLength: 0, maxLength: 5 }), { nil: undefined }), + postToolUse: fc.option(fc.array(hookDefArb, { minLength: 0, maxLength: 5 }), { nil: undefined }), + stop: fc.option(fc.array(hookDefArb, { minLength: 0, maxLength: 5 }), { nil: undefined }), + agentSpawn: fc.option(fc.array(hookDefArb, { minLength: 0, maxLength: 5 }), { nil: undefined }), + userPromptSubmit: fc.option(fc.array(hookDefArb, { minLength: 0, maxLength: 5 }), { nil: undefined }), + }, + { requiredKeys: [] }, + ); + + // Arbitrary for an optional KiroAgentHooks (may be undefined) + const optionalHooksArb = fc.option(hooksArb, { nil: undefined }); + + const HOOK_TYPES = ['preToolUse', 'postToolUse', 'stop', 'agentSpawn', 'userPromptSubmit'] as const; + + it('every hook definition in any input appears exactly once in the output, preserving order', () => { + fc.assert( + fc.property( + fc.array(optionalHooksArb, { minLength: 0, maxLength: 5 }), + (hookSets) => { + const result = mergeHooks(...hookSets); + const defined = hookSets.filter((h): h is KiroAgentHooks => h !== undefined); + + if (defined.length === 0) { + // All undefined → result should be undefined + expect(result).toBeUndefined(); + return; + } + + for (const type of HOOK_TYPES) { + const expectedEntries = defined.flatMap((h) => h[type] ?? []); + const actualEntries = result?.[type] ?? []; + + // Every entry appears exactly once — same length and same order + expect(actualEntries).toHaveLength(expectedEntries.length); + for (let i = 0; i < expectedEntries.length; i++) { + expect(actualEntries[i]).toEqual(expectedEntries[i]); + } + } + }, + ), + { numRuns: 200 }, + ); + }); +}); + +describe('Feature: kiro-cli-tool-usage-logging, Property 6: Summary accuracy', () => { + /** + * **Validates: Requirements 3.3, 3.4** + */ + + let sourceDir: string; + let originalHome: string | undefined; + + beforeEach(async () => { + sourceDir = join(tmpdir(), `kiro-pbt-summary-${Date.now()}-${Math.random().toString(36).slice(2)}`); + originalHome = process.env.HOME; + process.env.HOME = sourceDir; + const agentsDir = join(sourceDir, '.kiro', 'agents'); + await mkdir(agentsDir, { recursive: true }); + }); + + afterEach(async () => { + process.env.HOME = originalHome; + await rm(sourceDir, { recursive: true, force: true }).catch(() => {}); + }); + + // Arbitrary for a ToolUsageEntry with event: 'post' + const postEntryArb = fc.record({ + event: fc.constant('post' as const), + tool: fc.stringMatching(/^[a-z][a-z0-9_]{0,20}$/), + timestamp: fc.integer({ min: 1, max: 2000000000000 }), + success: fc.boolean(), + durationMs: fc.option(fc.integer({ min: 0, max: 100000 }), { nil: undefined }), + }); + + it('totalInvocations equals post entry count, toolCounts sum to totalInvocations, failures equals entries with success=false', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(postEntryArb, { minLength: 1, maxLength: 20 }), + async (postEntries) => { + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + + // Write JSONL to temp file + const jsonl = postEntries.map((e) => JSON.stringify(e)).join('\n'); + await fsWriteFile(join(sourceDir, '.kiro', 'agents', 'tool-usage.jsonl'), jsonl, 'utf8'); + + const summary = await readAndLogToolUsage(sourceDir, 'pbt-agent', logger); + + // Summary must be defined since we have at least 1 post entry + expect(summary).toBeDefined(); + if (!summary) return; + + // Property: totalInvocations equals the count of post entries + expect(summary.totalInvocations).toBe(postEntries.length); + + // Property: toolCounts values sum to totalInvocations + const toolCountsSum = Object.values(summary.toolCounts).reduce((a, b) => a + b, 0); + expect(toolCountsSum).toBe(summary.totalInvocations); + + // Property: failures equals entries where success is false + const expectedFailures = postEntries.filter((e) => e.success === false).length; + expect(summary.failures).toBe(expectedFailures); + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/apps/worker/src/ai/kiro-cli-executor.ts b/apps/worker/src/ai/kiro-cli-executor.ts new file mode 100644 index 000000000..18898e2a6 --- /dev/null +++ b/apps/worker/src/ai/kiro-cli-executor.ts @@ -0,0 +1,1376 @@ +/** + * KiroCliExecutor -- executes Shannon agents via kiro-cli headless mode. + * + * Spawns kiro-cli chat --no-interactive as a child process, captures stdout/stderr, + * maps exit codes to ClaudePromptResult, and strips ANSI escape codes from output. + */ + +import { spawn } from 'node:child_process'; +import { cpSync } from 'node:fs'; +import { readFile as fsReadFile, mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { Executor, ExecutorOptions } from '../interfaces/executor.js'; +import { PentestError } from '../services/error-handling.js'; +import type { ActivityLogger } from '../types/activity-logger.js'; +import { ErrorCode } from '../types/errors.js'; +import { matchesBillingApiPattern, matchesBillingTextPattern } from '../utils/billing-detection.js'; +import type { ClaudePromptResult } from './claude-executor.js'; +import type { ModelTier } from './models.js'; + +// === ANSI Stripping === + +/** Regex source for matching ANSI escape codes in terminal output. */ +const ANSI_PATTERN = String.raw`\x1B\[[0-9;]*[a-zA-Z]`; + +/** Patterns for kiro-cli metadata lines to strip from output. */ +const KIRO_METADATA_PATTERNS: readonly RegExp[] = [ + /^\s*\u26A0\uFE0F/, + /^\s*Checkpoint saved/i, + /^\s*Credits used:/i, + /^\s*Time elapsed:/i, + /^\s*\d+ credits?/i, +]; + +/** + * Strip ANSI escape codes from text. + * + * Uses a simple regex replace. Wraps in try/catch for edge cases with + * truly malformed binary data -- falls back to raw text on failure. + */ +export function stripAnsi(text: string): string { + try { + const regex = new RegExp(ANSI_PATTERN, 'g'); + return text.replaceAll(regex, ''); + } catch { + return text; + } +} + +/** + * Filter out kiro-cli metadata lines (warnings, checkpoint notices, credits/time footer). + */ +export function stripMetadataLines(text: string): string { + return text + .split('\n') + .filter((line) => !KIRO_METADATA_PATTERNS.some((pattern) => pattern.test(line))) + .join('\n'); +} + +// === Tool Usage Data Models === + +/** A single tool invocation event recorded by the hook script. */ +export interface ToolUsageEntry { + readonly event: 'pre' | 'post'; + readonly tool: string; + readonly timestamp: number; + readonly success?: boolean | undefined; + readonly error?: string | undefined; + readonly durationMs?: number | undefined; +} + +/** Aggregated tool usage statistics for an agent execution. */ +export interface ToolUsageSummary { + readonly totalInvocations: number; + readonly toolCounts: Record; + readonly failures: number; + readonly totalDurationMs: number; +} + +// === Hook Script Generator === + +/** + * Generate the ESM JavaScript source for the tool-usage-logger hook script. + * + * The script reads stdin JSON from kiro-cli, extracts tool event data, and + * appends a JSONL entry to the given log path. Always exits with code 0. + */ +export function generateToolUsageLoggerScript(logFilePath: string): string { + return [ + '// Auto-generated tool usage logger hook script', + "import { appendFileSync, mkdirSync } from 'node:fs';", + "import { dirname } from 'node:path';", + '', + `const LOG_PATH = ${JSON.stringify(logFilePath)};`, + '', + "process.stdin.setEncoding('utf8');", + "let input = '';", + '', + "process.stdin.on('data', (chunk) => { input += chunk; });", + '', + "process.stdin.on('end', () => {", + ' try {', + ' const event = JSON.parse(input);', + " const toolName = event.tool_name ?? 'unknown';", + ' const now = Date.now();', + " const hookEvent = event.hook_event_name ?? '';", + '', + " if (hookEvent === 'preToolUse') {", + " const entry = { event: 'pre', tool: toolName, timestamp: now };", + " mkdirSync(dirname(LOG_PATH), { recursive: true });", + " appendFileSync(LOG_PATH, JSON.stringify(entry) + '\\n');", + " } else if (hookEvent === 'postToolUse') {", + ' const success = event.tool_response?.success ?? true;', + ' const entry = {', + " event: 'post',", + ' tool: toolName,', + ' timestamp: now,', + ' success,', + ' };', + " mkdirSync(dirname(LOG_PATH), { recursive: true });", + " appendFileSync(LOG_PATH, JSON.stringify(entry) + '\\n');", + ' }', + ' } catch {', + " // Never fail — don't block the agent", + ' }', + '', + ' process.exit(0);', + '});', + '', + ].join('\n'); +} + +// === Hook Generation === + +/** + * Generate tool usage hooks and write the logger script to disk. + * + * Writes `tool-usage-logger.mjs` to `~/.kiro/agents/` (always writable), + * with a best-effort copy to the project-level `.kiro/agents/` for debugging. + * Returns hook definitions with wildcard matcher for both preToolUse and postToolUse. + */ +export async function generateToolUsageHooks(sourceDir: string): Promise { + // 1. Determine paths — must be writable. + // Inside Docker the repo is mounted :ro; sourceDir/.shannon/agents/ has no writable overlay. + // Use ~/.kiro/agents/ (always writable) for both the hook script and the log file. + const homeDir = process.env.HOME || '/tmp'; + const globalAgentsDir = join(homeDir, '.kiro', 'agents'); + const logFilePath = join(globalAgentsDir, 'tool-usage.jsonl'); + + // 2. Generate the hook script with the log path baked in + const script = generateToolUsageLoggerScript(logFilePath); + + // 3. Write script to global agents dir (always writable) + await mkdir(globalAgentsDir, { recursive: true }); + const scriptPath = join(globalAgentsDir, 'tool-usage-logger.mjs'); + await writeFile(scriptPath, script, 'utf8'); + + // 4. Best-effort copy to project-level dir for debugging + try { + const projectAgentsDir = join(sourceDir, '.kiro', 'agents'); + await mkdir(projectAgentsDir, { recursive: true }); + await writeFile(join(projectAgentsDir, 'tool-usage-logger.mjs'), script, 'utf8'); + } catch { + // Non-fatal + } + + // 5. Return hook definitions + return { + preToolUse: [{ matcher: '*', command: `node ${scriptPath}`, timeout_ms: 10000 }], + postToolUse: [{ matcher: '*', command: `node ${scriptPath}`, timeout_ms: 10000 }], + }; +} + +// === Tool Usage Log Reader === + +/** + * Read the tool-usage.jsonl log file after kiro-cli execution, emit structured + * log entries via the ActivityLogger, and compute a ToolUsageSummary. + * + * Returns the computed summary, or undefined if no valid post entries were found. + * Never throws — all errors are caught and logged as warnings. + */ +export /** Result from reading tool usage — includes both summary and raw invocations. */ + interface ToolUsageResult { + readonly summary: ToolUsageSummary; + readonly invocations: ReadonlyArray<{ tool: string; timestamp: number; success?: boolean; durationMs?: number }>; +} + +async function readAndLogToolUsage( + _sourceDir: string, + agentName: string, + logger: ActivityLogger, +): Promise { + try { + // Read from ~/.kiro/agents/ — the same writable location where the hook script writes. + // Inside Docker the repo is mounted :ro, so sourceDir/.shannon/agents/ is not writable. + const homeDir = process.env.HOME || '/tmp'; + const logFilePath = join(homeDir, '.kiro', 'agents', 'tool-usage.jsonl'); + + // 1. Read log file — non-fatal if missing + let content: string; + try { + content = await fsReadFile(logFilePath, 'utf8'); + } catch { + logger.info(`[kiro-cli] No tool usage data for ${agentName}`); + return undefined; + } + + // 2. Parse JSONL lines, skip malformed + const lines = content.split('\n').filter((line) => line.trim().length > 0); + const entries: ToolUsageEntry[] = []; + + for (const line of lines) { + try { + const parsed = JSON.parse(line) as ToolUsageEntry; + if (parsed.event && parsed.tool && parsed.timestamp) { + entries.push(parsed); + } + } catch { + // Skip malformed lines + } + } + + // 3. Filter to post entries only + const postEntries = entries.filter((e) => e.event === 'post'); + + if (postEntries.length === 0) { + logger.info(`[kiro-cli] No valid tool usage entries for ${agentName}`); + return undefined; + } + + // 4. Emit per-tool log entries + for (const entry of postEntries) { + logger.info(`[kiro-cli] Tool used: ${entry.tool}`, { + agent: agentName, + tool: entry.tool, + success: entry.success, + timestamp: entry.timestamp, + }); + } + + // 5. Compute summary + const toolCounts: Record = {}; + let failures = 0; + let totalDurationMs = 0; + + for (const entry of postEntries) { + toolCounts[entry.tool] = (toolCounts[entry.tool] ?? 0) + 1; + if (entry.success === false) failures++; + if (entry.durationMs) totalDurationMs += entry.durationMs; + } + + const summary: ToolUsageSummary = { + totalInvocations: postEntries.length, + toolCounts, + failures, + totalDurationMs, + }; + + // 6. Emit summary line + logger.info(`[kiro-cli] Tool usage summary for ${agentName}`, { + totalInvocations: summary.totalInvocations, + toolCounts: summary.toolCounts, + failures: summary.failures, + }); + + // 7. Build invocation records for detailed query + const invocations = postEntries.map((e) => ({ + tool: e.tool, + timestamp: e.timestamp, + ...(e.success !== undefined && { success: e.success }), + ...(e.durationMs !== undefined && { durationMs: e.durationMs }), + })); + + return { summary, invocations }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.warn(`[kiro-cli] Failed to read tool usage data for ${agentName}: ${msg}`); + return undefined; + } +} + +// === Live Tool Usage Reader === + +/** + * Read the current tool-usage.jsonl and return a summary without logging. + * + * Lightweight version of readAndLogToolUsage for live polling from activities. + * Returns undefined if the file is missing or has no post entries. + * Never throws. + */ +export async function readLiveToolUsage(): Promise { + try { + const homeDir = process.env.HOME || '/tmp'; + const logFilePath = join(homeDir, '.kiro', 'agents', 'tool-usage.jsonl'); + + let content: string; + try { + content = await fsReadFile(logFilePath, 'utf8'); + } catch { + return undefined; + } + + const lines = content.split('\n').filter((line) => line.trim().length > 0); + const toolCounts: Record = {}; + let totalInvocations = 0; + let failures = 0; + let totalDurationMs = 0; + + for (const line of lines) { + try { + const parsed = JSON.parse(line) as ToolUsageEntry; + if (parsed.event === 'post' && parsed.tool && parsed.timestamp) { + totalInvocations++; + toolCounts[parsed.tool] = (toolCounts[parsed.tool] ?? 0) + 1; + if (parsed.success === false) failures++; + if (parsed.durationMs) totalDurationMs += parsed.durationMs; + } + } catch { + // Skip malformed lines + } + } + + if (totalInvocations === 0) return undefined; + + return { totalInvocations, toolCounts, failures, totalDurationMs }; + } catch { + return undefined; + } +} + +// === Hook Merger === + +/** All hook types that can appear in KiroAgentHooks. */ +const HOOK_TYPES = ['preToolUse', 'postToolUse', 'stop', 'agentSpawn', 'userPromptSubmit'] as const; + +/** + * Merge multiple KiroAgentHooks objects by concatenating arrays for each hook type. + * + * Filters out undefined inputs. Returns undefined if all inputs are undefined + * or no hook entries exist after merging. Preserves order within and across inputs. + * Does not mutate any input arrays. + */ +export function mergeHooks( + ...hookSets: ReadonlyArray +): KiroAgentHooks | undefined { + const defined = hookSets.filter((h): h is KiroAgentHooks => h !== undefined); + if (defined.length === 0) return undefined; + + const merged: Record = {}; + + for (const type of HOOK_TYPES) { + const combined = defined.flatMap((h) => h[type] ?? []); + if (combined.length > 0) { + merged[type] = combined; + } + } + + return Object.keys(merged).length > 0 ? (merged as KiroAgentHooks) : undefined; +} + +// === Agent JSON Interfaces === + +interface KiroAgentJson { + readonly name: string; + readonly description?: string; + readonly prompt: string; + readonly tools: readonly string[]; + readonly allowedTools: readonly string[]; + readonly model: string; + readonly mcpServers?: Record; + readonly hooks?: KiroAgentHooks; +} + +interface McpServerConfig { + readonly command: string; + readonly args: readonly string[]; + readonly env?: Record; + readonly timeout?: number; +} + +export interface KiroAgentHooks { + readonly preToolUse?: readonly HookDef[]; + readonly postToolUse?: readonly HookDef[]; + readonly stop?: readonly HookDef[]; + readonly agentSpawn?: readonly HookDef[]; + readonly userPromptSubmit?: readonly HookDef[]; +} + +interface HookDef { + readonly matcher?: string; + readonly command: string; + readonly timeout_ms?: number; +} + +// === Model Tier Mapping === + +const KIRO_MODEL_MAP: Readonly> = { + small: 'claude-haiku-4.5', + medium: 'claude-sonnet-4.6', + large: 'claude-opus-4.8', +}; + +const MODEL_TIER_ENV_VARS: Readonly> = { + small: 'ANTHROPIC_SMALL_MODEL', + medium: 'ANTHROPIC_MEDIUM_MODEL', + large: 'ANTHROPIC_LARGE_MODEL', +}; + +/** + * Resolve a model tier to a kiro-cli model identifier. + * + * Checks env var overrides first (ANTHROPIC_SMALL_MODEL, etc.), + * translating SDK hyphen notation to kiro-cli dot notation. + */ +export function resolveKiroModel(tier: ModelTier): string { + const envVar = MODEL_TIER_ENV_VARS[tier]; + const envOverride = process.env[envVar]; + + if (envOverride) { + return envOverride.replace(/(\d+)-(\d+)$/, '$1.$2'); + } + + return KIRO_MODEL_MAP[tier]; +} + +// === Agent JSON Options === + +export interface AgentJsonOptions { + readonly description?: string; + readonly playwrightExecutablePath?: string; + readonly playwrightOutputDir?: string; + readonly playwrightSession?: string; + readonly hooks?: KiroAgentHooks; +} + +// === Agent JSON Generation === + +/** Build Playwright MCP server config from options. */ +function buildMcpServers(options?: AgentJsonOptions): Record | undefined { + if (!options?.playwrightExecutablePath || !options?.playwrightOutputDir) { + return undefined; + } + + return { + playwright: { + command: options.playwrightExecutablePath, + args: ['--output-dir', options.playwrightOutputDir], + ...(options.playwrightSession ? { env: { PLAYWRIGHT_SESSION: options.playwrightSession } } : {}), + timeout: 120000, + }, + }; +} + +/** Build the KiroAgentJson object from agent parameters. */ +function buildAgentJsonObject( + agentName: string, + promptFilename: string, + modelTier: ModelTier, + options?: AgentJsonOptions, +): KiroAgentJson { + const mcpServers = buildMcpServers(options); + + return { + name: agentName, + ...(options?.description ? { description: options.description } : {}), + prompt: `file://./${promptFilename}`, + tools: ['*'], + allowedTools: ['read', 'write', 'shell'], + model: resolveKiroModel(modelTier), + ...(mcpServers ? { mcpServers } : {}), + ...(options?.hooks ? { hooks: options.hooks } : {}), + }; +} + +/** + * Generate a .kiro/agents/.json file and the corresponding prompt file. + * + * Writes the interpolated prompt to .kiro/agents/-prompt.txt, + * then builds and writes the agent JSON definition. + * + * Retries with exponential backoff on filesystem errors. + */ +export async function generateAgentJson( + sourceDir: string, + agentName: string, + prompt: string, + modelTier: ModelTier, + options?: AgentJsonOptions, +): Promise { + // Write to the global agents directory (~/.kiro/agents/). + // kiro-cli resolves agents by scanning both project and global directories. + // Inside Docker, the repo is mounted :ro with a bind-mount overlay for .kiro/agents/, + // but kiro-cli's project root detection may not match sourceDir. The global directory + // is always writable and always scanned, so it's the reliable target. + const homeDir = process.env.HOME || '/tmp'; + const globalAgentsDir = join(homeDir, '.kiro', 'agents'); + + // Also write to the project-level bind mount for host-side visibility (debugging/logging) + const projectAgentsDir = join(sourceDir, '.kiro', 'agents'); + + const promptFilename = `${agentName}-prompt.txt`; + const agentJson = buildAgentJsonObject(agentName, promptFilename, modelTier, options); + const agentJsonStr = JSON.stringify(agentJson, null, 2); + + const maxRetries = 5; + const baseDelayMs = 1000; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + // Primary: global directory (kiro-cli always finds agents here) + await mkdir(globalAgentsDir, { recursive: true }); + await writeFile(join(globalAgentsDir, promptFilename), prompt, 'utf8'); + await writeFile(join(globalAgentsDir, `${agentName}.json`), agentJsonStr, 'utf8'); + await fsReadFile(join(globalAgentsDir, `${agentName}.json`), 'utf8'); + + // Secondary: project-level bind mount (best-effort for host-side logging) + try { + await mkdir(projectAgentsDir, { recursive: true }); + await writeFile(join(projectAgentsDir, promptFilename), prompt, 'utf8'); + await writeFile(join(projectAgentsDir, `${agentName}.json`), agentJsonStr, 'utf8'); + } catch { + // Non-fatal — project dir may be read-only without the bind mount + } + + return; + } catch (error) { + if (attempt === maxRetries) { + throw error; + } + const delay = baseDelayMs * 2 ** attempt; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } +} + +// === Structured Output Helpers === + +/** + * Augment a prompt with structured output instructions for vuln agents. + * + * When an agent has a JsonSchemaOutputFormat, appends instructions telling + * the agent to write the queue JSON file directly to disk using fs_write. + */ +export function augmentPromptForStructuredOutput( + prompt: string, + queueFilename: string, + deliverablesPath: string, + jsonSchema: Record, +): string { + const schemaStr = JSON.stringify(jsonSchema, null, 2); + const instructions = [ + '', + '## STRUCTURED OUTPUT INSTRUCTIONS', + '', + `You MUST write your structured output as a JSON file to: ${deliverablesPath}/${queueFilename}`, + 'Use the fs_write tool to write the file.', + '', + 'The JSON MUST conform to this schema:', + '```json', + schemaStr, + '```', + '', + `The file MUST be named exactly: ${queueFilename}`, + `The file MUST be written to: ${deliverablesPath}/`, + '', + ].join('\n'); + + return prompt + instructions; +} + +/** + * Generate validation hooks for vuln agents that produce structured queue output. + * + * Creates: + * - preToolUse hook on 'write' that validates *_queue.json against schema + * - stop hook that verifies the queue file exists after execution + * + * Also writes the validation scripts alongside the agent JSON. + */ +export async function generateQueueValidationHooks( + sourceDir: string, + queueFilename: string, + deliverablesPath: string, + jsonSchema: Record, +): Promise { + const homeDir = process.env.HOME || '/tmp'; + const globalAgentsDir = join(homeDir, '.kiro', 'agents'); + await mkdir(globalAgentsDir, { recursive: true }); + + const validateScript = generateValidateQueueScript(queueFilename, jsonSchema); + await writeFile(join(globalAgentsDir, 'validate-queue-json.js'), validateScript, 'utf8'); + + const verifyScript = generateVerifyQueueScript(queueFilename, deliverablesPath); + await writeFile(join(globalAgentsDir, 'verify-queue-file.mjs'), verifyScript, 'utf8'); + + // Best-effort write to project-level bind mount for host-side visibility + try { + const projectAgentsDir = join(sourceDir, '.kiro', 'agents'); + await mkdir(projectAgentsDir, { recursive: true }); + await writeFile(join(projectAgentsDir, 'validate-queue-json.js'), validateScript, 'utf8'); + await writeFile(join(projectAgentsDir, 'verify-queue-file.mjs'), verifyScript, 'utf8'); + } catch { + // Non-fatal — project dir may be read-only without the bind mount + } + + // Hook commands use the global directory (always writable, always resolvable) + return { + preToolUse: [ + { + matcher: 'write', + command: `node ${join(globalAgentsDir, 'validate-queue-json.js')}`, + timeout_ms: 30000, + }, + ], + stop: [ + { + command: `node ${join(globalAgentsDir, 'verify-queue-file.mjs')}`, + timeout_ms: 30000, + }, + ], + }; +} + +function generateValidateQueueScript(queueFilename: string, jsonSchema: Record): string { + return [ + '// Auto-generated queue JSON validation script', + `const QUEUE_FILENAME = ${JSON.stringify(queueFilename)};`, + `const SCHEMA = ${JSON.stringify(jsonSchema)};`, + '', + 'process.stdin.setEncoding("utf8");', + 'let input = "";', + 'process.stdin.on("data", (chunk) => { input += chunk; });', + 'process.stdin.on("end", () => {', + ' try {', + ' const event = JSON.parse(input);', + ' const filePath = event?.tool_input?.path || "";', + ' if (!filePath.endsWith(QUEUE_FILENAME)) { process.exit(0); }', + ' const content = event?.tool_input?.content || "";', + ' JSON.parse(content);', + ' process.exit(0);', + ' } catch (e) {', + ' console.error("Queue JSON validation failed:", e.message);', + ' process.exit(2);', + ' }', + '});', + '', + ].join('\n'); +} + +function generateVerifyQueueScript(queueFilename: string, deliverablesPath: string): string { + return [ + '// Auto-generated queue file verification script', + 'import { readFileSync } from "node:fs";', + 'import { join } from "node:path";', + `const queuePath = join(${JSON.stringify(deliverablesPath)}, ${JSON.stringify(queueFilename)});`, + 'try {', + ' const content = readFileSync(queuePath, "utf8");', + ' JSON.parse(content);', + ' process.exit(0);', + '} catch (e) {', + ' console.error("Queue file verification failed:", e.message);', + ' process.exit(1);', + '}', + '', + ].join('\n'); +} + +/** + * Read structured output from a queue file on disk after kiro-cli execution. + * + * Retries with exponential backoff if the file doesn't exist or JSON is invalid. + * Returns undefined if still missing after all retries. + */ +export async function readStructuredOutputFromDisk( + deliverablesPath: string, + queueFilename: string, + retryBaseDelayMs: number = 1000, +): Promise { + const queuePath = join(deliverablesPath, queueFilename); + const maxRetries = 3; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const content = await fsReadFile(queuePath, 'utf8'); + return JSON.parse(content); + } catch { + if (attempt === maxRetries) { + return undefined; + } + const delay = retryBaseDelayMs * 2 ** attempt; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + return undefined; +} + +// === Error Classification === + +/** + * Write a diagnostic error log file on kiro-cli failure. + * + * Writes `-error.log` to `.shannon/agents/` in the source directory. + * Non-fatal — filesystem errors are silently swallowed so the execution result + * is never affected. + */ +export async function writeKiroErrorLog( + agentName: string, + exitCode: number | null, + stderr: string, + stdoutTail: string, + duration: number, + promptPath: string, + sourceDir: string, +): Promise { + try { + const logDir = join(sourceDir, '.shannon', 'agents'); + await mkdir(logDir, { recursive: true }); + const logPath = join(logDir, `${agentName}-error.log`); + const content = [ + `Agent: ${agentName}`, + `Exit code: ${exitCode}`, + `Duration: ${duration}ms`, + `Prompt: ${promptPath}`, + `Stderr:\n${stderr.slice(0, 2000)}`, + `Stdout (tail):\n${stdoutTail.slice(-500)}`, + ].join('\n\n'); + await writeFile(logPath, content, 'utf8'); + } catch { + // Non-fatal — don't affect execution result + } +} + +/** + * Classify a kiro-cli execution error into a PentestError. + * + * Maps exit codes and stderr patterns to Shannon's error taxonomy + * for compatibility with classifyErrorForTemporal. + */ +export function classifyKiroCliError(exitCode: number | null, stderr: string, timedOut: boolean): PentestError { + if (timedOut) { + return new PentestError('Kiro CLI execution timed out', 'network', true); + } + + if (exitCode === 3) { + return new PentestError( + `Kiro CLI config/MCP failure: ${stderr.slice(0, 200)}`, + 'config', + false, + { exitCode, stderr: stderr.slice(0, 500) }, + ErrorCode.CONFIG_VALIDATION_FAILED, + ); + } + + if (/authentication/i.test(stderr) || /invalid.*key/i.test(stderr) || /unauthorized/i.test(stderr)) { + return new PentestError( + `Kiro CLI authentication failed: ${stderr.slice(0, 200)}`, + 'config', + false, + { exitCode, stderr: stderr.slice(0, 500) }, + ErrorCode.AUTH_FAILED, + ); + } + + const lowerStderr = stderr.toLowerCase(); + if (matchesBillingTextPattern(lowerStderr) || matchesBillingApiPattern(lowerStderr)) { + return new PentestError( + `Kiro CLI billing/rate-limit error: ${stderr.slice(0, 200)}`, + 'billing', + true, + { exitCode, stderr: stderr.slice(0, 500) }, + ErrorCode.BILLING_ERROR, + ); + } + + return new PentestError( + `Kiro CLI execution failed: ${stderr.slice(0, 200)}`, + 'validation', + true, + { exitCode, stderr: stderr.slice(0, 500) }, + ErrorCode.AGENT_EXECUTION_FAILED, + ); +} + +// === Subprocess Spawning === + +/** Spawn kiro-cli and capture output with timeout enforcement. */ +function spawnKiroCli( + args: string[], + cwd: string, + env: Record, + timeoutMs: number, + onHeartbeat?: (details: Record) => void, +): Promise<{ exitCode: number | null; stdout: string; stderr: string; timedOut: boolean }> { + return new Promise((resolve) => { + let resolved = false; + const finish = (result: { exitCode: number | null; stdout: string; stderr: string; timedOut: boolean }) => { + if (resolved) return; + resolved = true; + clearTimeout(timer); + clearTimeout(sigkillTimer); + if (heartbeatTimer) clearInterval(heartbeatTimer); + resolve(result); + }; + + const child = spawn('kiro-cli', args, { cwd, env, stdio: 'pipe' }); + let stdout = ''; + let stderr = ''; + let timedOut = false; + let sigkillTimer: ReturnType; + const spawnTime = Date.now(); + + // Heartbeat loop — signals liveness back to the orchestrator (Temporal). + // Without this, the outer setInterval heartbeat in runAgentActivity can starve + // when multiple kiro-cli subprocesses saturate the event loop with I/O callbacks. + const SPAWN_HEARTBEAT_MS = 30_000; + const heartbeatTimer = onHeartbeat + ? setInterval(() => { + const elapsed = Math.floor((Date.now() - spawnTime) / 1000); + onHeartbeat({ phase: 'kiro-cli-running', elapsedSeconds: elapsed, pid: child.pid }); + }, SPAWN_HEARTBEAT_MS) + : null; + + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + // Handle spawn errors (binary not found, permission denied, etc.) + child.on('error', (err) => { + finish({ + exitCode: null, + stdout, + stderr: stderr || `Failed to spawn kiro-cli: ${err.message}`, + timedOut: false, + }); + }); + + const timer = setTimeout(() => { + timedOut = true; + child.kill('SIGTERM'); + // Escalate to SIGKILL if SIGTERM doesn't work within 10s + sigkillTimer = setTimeout(() => { + child.kill('SIGKILL'); + }, 10_000); + }, timeoutMs); + + child.on('close', (code) => { + finish({ exitCode: code, stdout, stderr, timedOut }); + }); + }); +} + +// === Retryable Classification === + +/** Auth patterns in stderr -- non-retryable. */ +const AUTH_PATTERNS = [/authentication/i, /invalid.*key/i, /unauthorized/i]; + +/** + * Classify whether an exit code 1 error is retryable based on stderr content. + * Auth errors are non-retryable; billing errors and unclassified errors are retryable. + */ +function classifyRetryable(stderr: string): boolean { + if (AUTH_PATTERNS.some((pattern) => pattern.test(stderr))) return false; + return true; +} + +// === Exit Code to ClaudePromptResult Mapping === + +// === Cost Extraction === + +/** Pattern to extract credits used from kiro-cli output footer (stdout or stderr). */ +const CREDITS_PATTERN = /^\s*(?:Credits used:|▸?\s*Credits:)\s*([\d.]+)/im; + +/** + * Extract cost from kiro-cli credits footer line. + * + * kiro-cli outputs credits in stderr as "▸ Credits: 0.05 • Time: 2s". + * Accepts both "Credits used: X" and "Credits: X" formats. + * Returns 0 if not found. + */ +export function extractCreditsUsed(text: string): number { + const match = CREDITS_PATTERN.exec(stripAnsi(text)); + if (!match?.[1]) return 0; + const credits = Number.parseFloat(match[1]); + return Number.isNaN(credits) ? 0 : credits; +} + +/** Pattern to extract turn/interaction/message count from kiro-cli output footer. */ +const TURNS_PATTERN = /^\s*(?:Turns|Interactions|Messages):\s*(\d+)/im; + +/** + * Extract turn count from kiro-cli stdout footer. + * + * Looks for patterns like "Turns: 5", "Interactions: 12", or "Messages: 3" + * in the ANSI-stripped output. Returns `undefined` when not parseable (no regression). + */ +export function extractTurns(stdout: string): number | undefined { + const match = TURNS_PATTERN.exec(stripAnsi(stdout)); + return match?.[1] ? Number.parseInt(match[1], 10) : undefined; +} + +/** + * Map kiro-cli exit code, stdout, stderr, and timing to a ClaudePromptResult. + * + * - Exit 0: success with cleaned stdout as result + * - Exit 1: failure with stderr as error, retryable based on stderr classification + * - Exit 3: non-retryable MCP startup failure + * - Timeout: retryable timeout error + */ +export function mapExitCodeToResult( + exitCode: number | null, + stdout: string, + stderr: string, + duration: number, + model: string, + timedOut: boolean, +): ClaudePromptResult { + if (timedOut) { + return { + success: false, + duration, + cost: 0, + partialCost: 0, + error: 'Kiro CLI execution timed out', + errorType: 'KiroCliError', + retryable: true, + }; + } + + if (exitCode === 0) { + const cost = extractCreditsUsed(stderr) || extractCreditsUsed(stdout); + const cleaned = stripMetadataLines(stripAnsi(stdout)); + return { + success: true, + result: cleaned, + duration, + cost, + partialCost: cost, + model, + turns: extractTurns(stdout), + }; + } + + if (exitCode === 3) { + return { + success: false, + duration, + cost: 0, + partialCost: 0, + error: stderr, + errorType: 'KiroCliError', + retryable: false, + }; + } + + return { + success: false, + duration, + cost: 0, + partialCost: 0, + error: stderr, + errorType: 'KiroCliError', + retryable: classifyRetryable(stderr), + }; +} + +// === Environment Construction === + +/** Environment variables that must NEVER be forwarded to kiro-cli. */ +export const EXCLUDED_ENV_VARS = [ + 'ANTHROPIC_API_KEY', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_OAUTH_TOKEN', + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', +] as const; + +/** + * Build a curated environment for the kiro-cli subprocess. + * + * Includes required variables (KIRO_API_KEY, KIRO_LOG_NO_COLOR, HOME, PATH) + * and conditionally includes SHANNON_DELIVERABLES_SUBDIR and Playwright vars. + * Explicitly excludes Claude SDK-specific credentials. + */ +export function buildSubprocessEnv( + kiroApiKey: string, + options?: { deliverablesSubdir?: string; playwrightOutputDir?: string }, +): Record { + const env: Record = { + KIRO_API_KEY: kiroApiKey, + KIRO_LOG_NO_COLOR: '1', + KIRO_LOG_LEVEL: 'debug', + }; + + if (process.env.HOME) env.HOME = process.env.HOME; + if (process.env.PATH) env.PATH = process.env.PATH; + + // Pass through proxy and SSL vars so kiro-cli can reach the API + const passthroughVars = [ + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NO_PROXY', + 'http_proxy', + 'https_proxy', + 'no_proxy', + 'SSL_CERT_FILE', + 'SSL_CERT_DIR', + 'NODE_EXTRA_CA_CERTS', + 'LANG', + 'LC_ALL', + 'TERM', + 'TMPDIR', + ]; + for (const name of passthroughVars) { + const val = process.env[name]; + if (val) env[name] = val; + } + + if (options?.deliverablesSubdir) { + env.SHANNON_DELIVERABLES_SUBDIR = options.deliverablesSubdir; + } + if (options?.playwrightOutputDir) { + env.PLAYWRIGHT_MCP_OUTPUT_DIR = options.playwrightOutputDir; + } + if (process.env.PLAYWRIGHT_MCP_EXECUTABLE_PATH) { + env.PLAYWRIGHT_MCP_EXECUTABLE_PATH = process.env.PLAYWRIGHT_MCP_EXECUTABLE_PATH; + } + + for (const key of EXCLUDED_ENV_VARS) { + delete env[key]; + } + + return env; +} + +// === Playwright Skill Installation === + +/** Known locations where the Playwright skill may be installed in the container. */ +const PLAYWRIGHT_SKILL_SOURCES = ['/tmp/.claude/skills/playwright-cli', '/tmp/.kiro/skills/playwright-cli']; + +/** + * Install the Playwright skill into the kiro-cli working directory. + * + * Copies the skill from the container's pre-installed location + * (set up by `playwright-cli install --skills` in the Dockerfile) + * into `/.kiro/skills/playwright-cli/` so kiro-cli + * discovers it automatically. + * + * Also writes a `.kiro/settings/playwright.json` with the session + * ID so the skill uses the correct browser isolation session. + * + * Non-fatal — logs a warning and continues if the skill source is missing. + */ +async function installPlaywrightSkill( + _sourceDir: string, + session: string | undefined, + logger: ActivityLogger, +): Promise { + try { + // Write to global ~/.kiro/ so kiro-cli discovers the skill regardless of project root. + // The repo is mounted :ro — writing to sourceDir/.kiro/ only works with the bind mount overlay. + const homeDir = process.env.HOME || '/tmp'; + const destDir = join(homeDir, '.kiro', 'skills', 'playwright-cli'); + + // Find the first available skill source + let skillSource: string | undefined; + for (const src of PLAYWRIGHT_SKILL_SOURCES) { + try { + await fsReadFile(join(src, 'SKILL.md'), 'utf8'); + skillSource = src; + break; + } catch { + // Try next source + } + } + + if (!skillSource) { + logger.warn('[kiro-cli] Playwright skill not found in container — browser tools unavailable'); + return; + } + + await mkdir(join(destDir, '..'), { recursive: true }); + cpSync(skillSource, destDir, { recursive: true, force: true }); + + // Write session config so playwright-cli uses the correct browser session + if (session) { + const settingsDir = join(homeDir, '.kiro', 'settings'); + await mkdir(settingsDir, { recursive: true }); + await writeFile(join(settingsDir, 'playwright.json'), JSON.stringify({ session }, null, 2), 'utf8'); + } + + logger.info(`[kiro-cli] Installed Playwright skill for ${session ?? 'default'} session`); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.warn(`[kiro-cli] Failed to install Playwright skill: ${msg}`); + } +} + +// === Queue Hook Options === + +/** + * Build AgentJsonOptions with queue validation hooks for vuln agents. + * + * When the executor options include an outputFormat with a JSON schema, + * a queueFilename, and a deliverablesSubdir, generates preToolUse and stop + * hooks that validate queue JSON at write time and verify the file exists. + * + * Returns `undefined` for non-vuln agents or when required fields are missing. + * Non-fatal — returns `undefined` on hook generation failure. + */ +async function buildQueueHookOptions( + sourceDir: string, + options?: ExecutorOptions, +): Promise { + if (!options?.outputFormat || !options?.queueFilename || !options?.deliverablesSubdir) { + return undefined; + } + + const outputFmt = options.outputFormat as { schema?: Record }; + if (!outputFmt.schema) { + return undefined; + } + + try { + const hooks = await generateQueueValidationHooks( + sourceDir, + options.queueFilename, + options.deliverablesSubdir, + outputFmt.schema, + ); + return { hooks }; + } catch { + // Non-fatal — proceed without hooks + return undefined; + } +} + +// === KiroCliExecutor Class === + +export class KiroCliExecutor implements Executor { + private readonly defaultTimeoutMs: number; + private readonly requireMcpStartup: boolean; + + constructor(options?: { timeoutMs?: number; requireMcpStartup?: boolean }) { + this.defaultTimeoutMs = options?.timeoutMs ?? 7_200_000; + this.requireMcpStartup = options?.requireMcpStartup ?? false; + } + + async execute( + prompt: string, + sourceDir: string, + agentName: string, + modelTier: ModelTier, + logger: ActivityLogger, + options?: ExecutorOptions, + ): Promise { + const startTime = Date.now(); + + // 1. Validate KIRO_API_KEY is present + const kiroApiKey = process.env.KIRO_API_KEY; + if (!kiroApiKey) { + return { + success: false, + duration: Date.now() - startTime, + cost: 0, + error: 'KIRO_API_KEY environment variable is not set', + errorType: 'config', + retryable: false, + }; + } + + // 2. Build agent JSON options (queue hooks + tool usage hooks) + const queueHookOptions = await buildQueueHookOptions(sourceDir, options); + const toolUsageHooks = await generateToolUsageHooks(sourceDir); + const mergedHooks = mergeHooks(queueHookOptions?.hooks, toolUsageHooks); + const agentJsonOptions: AgentJsonOptions = { + ...queueHookOptions, + ...(mergedHooks ? { hooks: mergedHooks } : {}), + }; + + // 2a. Install Playwright skill for kiro-cli (copies from Claude Code skill location) + if (options?.playwrightExecutablePath) { + await installPlaywrightSkill(sourceDir, options.playwrightSession, logger); + } + + // 3. Generate agent JSON in sourceDir/.kiro/agents/ + try { + await generateAgentJson(sourceDir, agentName, prompt, modelTier, agentJsonOptions); + const agentJsonPath = join(sourceDir, '.kiro', 'agents', `${agentName}.json`); + logger.info(`Generated agent JSON for ${agentName} at ${agentJsonPath}`, { modelTier }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + success: false, + duration: Date.now() - startTime, + cost: 0, + error: `Failed to generate agent JSON: ${errorMessage}`, + errorType: 'config', + retryable: false, + }; + } + + // 4. Build subprocess args — prompt is already in the agent JSON (file:// reference), + // so the CLI input is just a short trigger. Passing the full prompt as a positional + // arg can exceed OS arg length limits and corrupt argument parsing. + // + // NOTE: The trigger message must be explicit enough that the model executes the full + // system prompt instructions autonomously. A bare "begin" can cause the model to + // respond with a short acknowledgment and exit in --no-interactive mode. + const triggerMessage = + 'Execute all instructions in your system prompt completely. ' + + 'Use the tools available to you. ' + + 'Do not stop until every deliverable file has been written to disk.'; + const args = [ + 'chat', + '--no-interactive', + '--agent', + agentName, + '--wrap', + 'never', + '--trust-all-tools', + triggerMessage, + ]; + if (this.requireMcpStartup) { + args.push('--require-mcp-startup'); + } + + // 5. Build curated environment + const env = buildSubprocessEnv(kiroApiKey, { + ...(options?.deliverablesSubdir ? { deliverablesSubdir: options.deliverablesSubdir } : {}), + }); + + // 6. Audit: log execution start + logger.info(`[kiro-cli] Starting agent ${agentName}`, { + executor: 'kiro-cli', + agent: agentName, + modelTier, + cwd: sourceDir, + }); + // 7. Spawn subprocess with heartbeat + const heartbeatInterval = setInterval(() => { + const elapsed = Math.floor((Date.now() - startTime) / 1000); + logger.info(`[${elapsed}s] Agent ${agentName} running...`); + }, 30_000); + + const spawnResult = await spawnKiroCli(args, sourceDir, env, this.defaultTimeoutMs, options?.onHeartbeat); + clearInterval(heartbeatInterval); + + // 8. Map exit code to result + const duration = Date.now() - startTime; + + // 8a. Suspicious fast exit detection — log raw output for debugging + const SUSPICIOUS_FAST_EXIT_MS = 30_000; + if (duration < SUSPICIOUS_FAST_EXIT_MS && spawnResult.exitCode === 0) { + logger.warn(`[kiro-cli] Agent ${agentName} exited suspiciously fast (${Math.floor(duration / 1000)}s)`, { + exitCode: spawnResult.exitCode, + stdoutLength: spawnResult.stdout.length, + stderrLength: spawnResult.stderr.length, + stdoutHead: stripAnsi(spawnResult.stdout).slice(0, 500), + stderrHead: stripAnsi(spawnResult.stderr).slice(0, 500), + }); + } + const agentModel = resolveKiroModel(modelTier); + const result = mapExitCodeToResult( + spawnResult.exitCode, + spawnResult.stdout, + spawnResult.stderr, + duration, + agentModel, + spawnResult.timedOut, + ); + + // 9. Post-process: API error detection, spending cap, audit logging, error log + const postProcessed = await this.postProcessResult(result, spawnResult, agentName, duration, sourceDir, logger); + + // 10. Read and log tool usage, attach summary and invocations to result + const toolUsageResult = await readAndLogToolUsage(sourceDir, agentName, logger); + if (toolUsageResult) { + return { ...postProcessed, toolUsage: toolUsageResult.summary, toolInvocations: toolUsageResult.invocations }; + } + return postProcessed; + } + + /** Post-process the kiro-cli result: API error detection, spending cap, audit logging, error log. */ + private async postProcessResult( + result: ClaudePromptResult, + spawnResult: { exitCode: number | null; stdout: string; stderr: string }, + agentName: string, + duration: number, + sourceDir: string, + logger: ActivityLogger, + ): Promise { + // Agent-not-found detection — kiro-cli silently falls back to the default agent, + // producing a generic response instead of executing the intended prompt. + // This must be caught early to avoid wasting a retry on validation failure. + if (result.success && spawnResult.stderr) { + const agentNotFound = /no agent with name .+ found/i.test(spawnResult.stderr); + if (agentNotFound) { + logger.error( + `[kiro-cli] Agent ${agentName} was not found by kiro-cli — fell back to default agent. ` + + `Stderr: ${stripAnsi(spawnResult.stderr).slice(0, 500)}`, + ); + return { + success: false, + duration, + cost: 0, + error: `kiro-cli could not find agent "${agentName}" — verify .kiro/agents/${agentName}.json exists in the working directory`, + errorType: 'config', + retryable: true, + }; + } + } + + // API error detection on exit 0 — spread-copy to avoid mutating the original + if (result.success && spawnResult.stderr) { + const hasApiErrors = /dispatch failure|error sending request|connection refused/i.test(spawnResult.stderr); + if (hasApiErrors) { + result = { ...result, apiErrorDetected: true }; + logger.warn(`[kiro-cli] API errors detected in stderr for ${agentName}, will validate deliverables`); + } + } + + // Check for spending cap in successful output + if (result.success && result.result && matchesBillingTextPattern(result.result)) { + return { + success: false, + duration, + cost: 0, + error: `Spending cap detected in kiro-cli output: ${result.result.slice(0, 100)}`, + errorType: 'KiroCliError', + retryable: true, + }; + } + + // Audit: log execution result + if (result.success) { + logger.info(`[kiro-cli] Agent ${agentName} completed successfully`, { + executor: 'kiro-cli', + agent: agentName, + success: true, + duration, + cost: result.cost, + }); + } else { + logger.error(`[kiro-cli] Agent ${agentName} failed`, { + executor: 'kiro-cli', + agent: agentName, + success: false, + duration, + cost: result.cost, + error: result.error?.slice(0, 500), + retryable: result.retryable, + }); + } + + // Write error log file on failure + if (!result.success) { + const promptPath = join(sourceDir, '.kiro', 'agents', `${agentName}-prompt.txt`); + await writeKiroErrorLog( + agentName, + spawnResult.exitCode, + spawnResult.stderr, + spawnResult.stdout, + duration, + promptPath, + sourceDir, + ); + } + + return result; + } +} diff --git a/apps/worker/src/audit/metrics-tracker.ts b/apps/worker/src/audit/metrics-tracker.ts index 9bad57c8b..7a873629b 100644 --- a/apps/worker/src/audit/metrics-tracker.ts +++ b/apps/worker/src/audit/metrics-tracker.ts @@ -11,6 +11,7 @@ * Tracks attempt-level data for complete forensic trail. */ +import type { ToolUsageSummary } from '../ai/kiro-cli-executor.js'; import { PentestError } from '../services/error-handling.js'; import { AGENT_PHASE_MAP, type PhaseName } from '../session-manager.js'; import { ErrorCode } from '../types/errors.js'; @@ -36,6 +37,7 @@ interface AgentAuditMetrics { total_cost_usd: number; model?: string | undefined; checkpoint?: string | undefined; + toolUsage?: ToolUsageSummary | undefined; } interface PhaseMetrics { @@ -219,13 +221,18 @@ export class MetricsTracker { } } - // 7. Clear active timer + // 7. Attach tool usage summary if present + if (result.toolUsage) { + agent.toolUsage = result.toolUsage; + } + + // 8. Clear active timer this.activeTimers.delete(agentName); - // 8. Recalculate phase and session-level aggregations + // 9. Recalculate phase and session-level aggregations this.recalculateAggregations(); - // 9. Persist to session.json + // 10. Persist to session.json await this.save(); } diff --git a/apps/worker/src/interfaces/executor.ts b/apps/worker/src/interfaces/executor.ts new file mode 100644 index 000000000..1d1b6f4a8 --- /dev/null +++ b/apps/worker/src/interfaces/executor.ts @@ -0,0 +1,52 @@ +/** + * Executor — injectable interface for AI agent execution backends. + * + * Abstracts the underlying execution mechanism (Claude Agent SDK, kiro-cli, etc.) + * so that AgentExecutionService can dispatch without knowing the backend. + * + * Default: ClaudeSdkExecutor (wraps runClaudePrompt). + */ + +import type { ClaudePromptResult } from '../ai/claude-executor.js'; +import type { ModelTier } from '../ai/models.js'; +import type { AuditSession } from '../audit/index.js'; +import type { ActivityLogger } from '../types/activity-logger.js'; + +/** Optional configuration passed to an executor alongside the core arguments. */ +export interface ExecutorOptions { + readonly context?: string; + readonly description?: string; + readonly auditSession?: AuditSession | null; + readonly outputFormat?: unknown; + readonly apiKey?: string; + readonly deliverablesSubdir?: string; + readonly providerConfig?: import('../types/config.js').ProviderConfig; + readonly queueFilename?: string; + readonly playwrightExecutablePath?: string; + readonly playwrightOutputDir?: string; + readonly playwrightSession?: string; + readonly mcpServers?: Record; + /** Callback to signal liveness to the orchestrator (e.g., Temporal heartbeat). */ + readonly onHeartbeat?: (details: Record) => void; +} + +export interface Executor { + /** + * Execute an AI agent with the given prompt and configuration. + * + * @param prompt - Fully interpolated agent prompt text. + * @param sourceDir - Working directory (target repository path). + * @param agentName - Shannon agent identifier (e.g., 'recon', 'xss'). + * @param modelTier - Capability tier: 'small', 'medium', or 'large'. + * @param logger - Structured logger for activity-level messages. + * @param options - Optional execution configuration (context, audit, output format, etc.). + */ + execute( + prompt: string, + sourceDir: string, + agentName: string, + modelTier: ModelTier, + logger: ActivityLogger, + options?: ExecutorOptions, + ): Promise; +} diff --git a/apps/worker/src/interfaces/index.ts b/apps/worker/src/interfaces/index.ts index 7825c2910..906ac76f4 100644 --- a/apps/worker/src/interfaces/index.ts +++ b/apps/worker/src/interfaces/index.ts @@ -7,6 +7,7 @@ export type { CheckpointContext, CheckpointProvider, SkipDecision } from './checkpoint-provider.js'; export { NoOpCheckpointProvider } from './checkpoint-provider.js'; +export type { Executor, ExecutorOptions } from './executor.js'; export type { FindingsProvider } from './findings-provider.js'; export { NoOpFindingsProvider } from './findings-provider.js'; export type { ReportOutputProvider } from './report-output-provider.js'; diff --git a/apps/worker/src/services/agent-execution.ts b/apps/worker/src/services/agent-execution.ts index ea3d29d1c..f8cbd0fb4 100644 --- a/apps/worker/src/services/agent-execution.ts +++ b/apps/worker/src/services/agent-execution.ts @@ -22,11 +22,13 @@ */ import { fs, path } from 'zx'; -import { type ClaudePromptResult, runClaudePrompt, validateAgentOutput } from '../ai/claude-executor.js'; +import { type ClaudePromptResult, validateAgentOutput } from '../ai/claude-executor.js'; +import { augmentPromptForStructuredOutput, readStructuredOutputFromDisk } from '../ai/kiro-cli-executor.js'; import { getOutputFormat, getQueueFilename } from '../ai/queue-schemas.js'; import type { AuditSession } from '../audit/index.js'; import { authStateFile } from '../audit/utils.js'; -import { AGENTS } from '../session-manager.js'; +import type { Executor } from '../interfaces/executor.js'; +import { AGENTS, PLAYWRIGHT_SESSION_MAPPING } from '../session-manager.js'; import type { ActivityLogger } from '../types/activity-logger.js'; import type { AgentName } from '../types/agents.js'; import type { AgentEndResult } from '../types/audit.js'; @@ -54,6 +56,8 @@ export interface AgentExecutionInput { apiKey?: string | undefined; promptDir?: string | undefined; providerConfig?: import('../types/config.js').ProviderConfig | undefined; + /** Callback to signal liveness to the orchestrator (e.g., Temporal heartbeat). */ + onHeartbeat?: ((details: Record) => void) | undefined; mcpServers?: Record; } @@ -78,9 +82,11 @@ interface FailAgentOpts { */ export class AgentExecutionService { private readonly configLoader: ConfigLoaderService; + private readonly executor: Executor; - constructor(configLoader: ConfigLoaderService) { + constructor(configLoader: ConfigLoaderService, executor: Executor) { this.configLoader = configLoader; + this.executor = executor; } /** @@ -165,20 +171,46 @@ export class AgentExecutionService { // 5. Execute agent const outputFormat = getOutputFormat(agentName, distributedConfig?.exploit ?? true); - const result: ClaudePromptResult = await runClaudePrompt( - prompt, + const queueFilename = getQueueFilename(agentName); + + // Augment prompt with queue-writing instructions for vuln agents. + // Claude SDK uses native JsonSchemaOutputFormat; kiro-cli needs explicit file-write instructions. + // The extra text is harmless for the SDK path since structured output takes precedence. + let executionPrompt = prompt; + if (queueFilename && outputFormat) { + executionPrompt = augmentPromptForStructuredOutput( + prompt, + queueFilename, + path.relative(repoPath, deliverablesPath), + outputFormat.schema, + ); + } + + const result: ClaudePromptResult = await this.executor.execute( + executionPrompt, repoPath, - '', // context - agentName, // description agentName, - auditSession, + AGENTS[agentName].modelTier ?? 'medium', logger, - AGENTS[agentName].modelTier, - outputFormat, - apiKey, - path.relative(repoPath, deliverablesPath), - providerConfig, - mcpServers, + { + context: '', + description: agentName, + auditSession, + outputFormat, + ...(apiKey ? { apiKey } : {}), + deliverablesSubdir: path.relative(repoPath, deliverablesPath), + ...(providerConfig ? { providerConfig } : {}), + ...(queueFilename ? { queueFilename } : {}), + ...(mcpServers ? { mcpServers } : {}), + ...(process.env.PLAYWRIGHT_MCP_EXECUTABLE_PATH + ? { + playwrightExecutablePath: process.env.PLAYWRIGHT_MCP_EXECUTABLE_PATH, + playwrightOutputDir: path.join(repoPath, '.shannon', '.playwright-cli'), + playwrightSession: PLAYWRIGHT_SESSION_MAPPING[AGENTS[agentName].promptTemplate], + } + : {}), + ...(input.onHeartbeat ? { onHeartbeat: input.onHeartbeat } : {}), + }, ); // 6. Spending cap check - defense-in-depth @@ -213,12 +245,18 @@ export class AgentExecutionService { } // 8. Write structured output to disk (vuln agents only) - const queueFilename = getQueueFilename(agentName); if (result.structuredOutput !== undefined && queueFilename) { await fs.ensureDir(deliverablesPath); const queuePath = path.join(deliverablesPath, queueFilename); await fs.writeFile(queuePath, JSON.stringify(result.structuredOutput, null, 2), 'utf8'); logger.info(`Wrote structured output queue to ${queueFilename}`); + } else if (result.structuredOutput === undefined && queueFilename) { + // kiro-cli backend: agent writes queue JSON to disk directly via prompt instructions. + // Read it back so downstream validation sees it. + const diskOutput = await readStructuredOutputFromDisk(deliverablesPath, queueFilename); + if (diskOutput !== undefined) { + logger.info(`Read structured output queue from disk: ${queueFilename}`); + } } // 9. Validate output @@ -247,6 +285,8 @@ export class AgentExecutionService { success: true, model: result.model, ...(commitHash && { checkpoint: commitHash }), + ...(result.toolUsage ? { toolUsage: result.toolUsage } : {}), + ...(result.toolInvocations ? { toolInvocations: result.toolInvocations } : {}), }; await auditSession.endAgent(agentName, endResult); diff --git a/apps/worker/src/services/container.test.ts b/apps/worker/src/services/container.test.ts new file mode 100644 index 000000000..1c2f6f99f --- /dev/null +++ b/apps/worker/src/services/container.test.ts @@ -0,0 +1,68 @@ +import fc from 'fast-check'; +import type { ContainerConfig } from '../types/config.js'; +import { resolveExecutorBackend } from './container.js'; + +type Backend = 'claude-sdk' | 'kiro-cli'; +const backends = fc.constantFrom('claude-sdk', 'kiro-cli') as fc.Arbitrary; + +function withEnvRestore(fn: () => void): void { + const orig = process.env.SHANNON_EXECUTOR_BACKEND; + try { + fn(); + } finally { + if (orig) { + process.env.SHANNON_EXECUTOR_BACKEND = orig; + } else { + delete process.env.SHANNON_EXECUTOR_BACKEND; + } + } +} + +function makeConfig(backend?: Backend): ContainerConfig { + return { + deliverablesSubdir: '.shannon/deliverables', + auditDir: './workspaces', + ...(backend !== undefined ? { executorBackend: backend } : {}), + }; +} + +/** + * Property 4: Backend selection precedence + * + * Validates: Requirements 4.5 + */ +describe('Property 4: Backend selection precedence', () => { + it('env var takes precedence over config', () => { + fc.assert( + fc.property(backends, backends, (envVal, cfgVal) => { + withEnvRestore(() => { + process.env.SHANNON_EXECUTOR_BACKEND = envVal; + const result = resolveExecutorBackend(makeConfig(cfgVal)); + expect(result).toBe(envVal); + }); + }), + { numRuns: 100 }, + ); + }); + + it('config used when env var is unset', () => { + fc.assert( + fc.property(backends, (cfgVal) => { + withEnvRestore(() => { + delete process.env.SHANNON_EXECUTOR_BACKEND; + const result = resolveExecutorBackend(makeConfig(cfgVal)); + expect(result).toBe(cfgVal); + }); + }), + { numRuns: 100 }, + ); + }); + + it('defaults to claude-sdk when nothing set', () => { + withEnvRestore(() => { + delete process.env.SHANNON_EXECUTOR_BACKEND; + const result = resolveExecutorBackend(makeConfig()); + expect(result).toBe('claude-sdk'); + }); + }); +}); diff --git a/apps/worker/src/services/container.ts b/apps/worker/src/services/container.ts index 7f0de9c9a..a7fd7cc2f 100644 --- a/apps/worker/src/services/container.ts +++ b/apps/worker/src/services/container.ts @@ -9,14 +9,10 @@ * * Provides a per-workflow container for service instances. * Services are wired with explicit constructor injection. - * - * Usage: - * const container = getOrCreateContainer(workflowId, sessionMetadata); - * const auditSession = new AuditSession(sessionMetadata); // Per-agent - * await auditSession.initialize(workflowId); - * const result = await container.agentExecution.executeOrThrow(agentName, input, auditSession); */ +import { ClaudeSdkExecutor } from '../ai/claude-sdk-executor.js'; +import { KiroCliExecutor } from '../ai/kiro-cli-executor.js'; import type { SessionMetadata } from '../audit/utils.js'; import type { CheckpointProvider } from '../interfaces/checkpoint-provider.js'; import { NoOpCheckpointProvider } from '../interfaces/checkpoint-provider.js'; @@ -29,13 +25,34 @@ import { AgentExecutionService } from './agent-execution.js'; import { ConfigLoaderService } from './config-loader.js'; import { ExploitationCheckerService } from './exploitation-checker.js'; +// === Backend Selection === + +type ExecutorBackend = 'claude-sdk' | 'kiro-cli'; + +const VALID_BACKENDS = new Set(['claude-sdk', 'kiro-cli']); + +/** + * Resolve which executor backend to use. + * + * Precedence: env var > config > default 'claude-sdk'. + */ +export function resolveExecutorBackend(config: ContainerConfig): ExecutorBackend { + const envBackend = process.env.SHANNON_EXECUTOR_BACKEND; + if (envBackend && VALID_BACKENDS.has(envBackend as ExecutorBackend)) { + return envBackend as ExecutorBackend; + } + return config.executorBackend ?? 'claude-sdk'; +} + +// === Container Dependencies === + /** * Dependencies required to create a Container. * * NOTE: AuditSession is NOT stored in the container. * Each agent execution receives its own AuditSession instance - * because AuditSession uses instance state (currentAgentName) that - * cannot be shared across parallel agents. + * because AuditSession uses instance state (currentAgentName) + * that cannot be shared across parallel agents. */ export interface ContainerDependencies { readonly sessionMetadata: SessionMetadata; @@ -45,14 +62,18 @@ export interface ContainerDependencies { readonly reportOutputProvider?: ReportOutputProvider; } +// === Container Class === + /** * DI Container for a single workflow. * * Holds all service instances for the workflow lifecycle. - * Services are instantiated once and reused across agent executions. + * Services are instantiated once and reused across agent + * executions. * - * NOTE: AuditSession is NOT stored here - it's passed per agent execution - * to support parallel agents each having their own logging context. + * NOTE: AuditSession is NOT stored here - it's passed per + * agent execution to support parallel agents each having + * their own logging context. */ export class Container { readonly sessionMetadata: SessionMetadata; @@ -71,7 +92,11 @@ export class Container { // Wire services with explicit constructor injection this.configLoader = new ConfigLoaderService(); this.exploitationChecker = new ExploitationCheckerService(); - this.agentExecution = new AgentExecutionService(this.configLoader); + + // Select executor backend and wire into AgentExecutionService + const backend = resolveExecutorBackend(deps.config); + const executor = backend === 'kiro-cli' ? new KiroCliExecutor() : new ClaudeSdkExecutor(); + this.agentExecution = new AgentExecutionService(this.configLoader, executor); // Wire providers with default no-ops when not provided this.findingsProvider = deps.findingsProvider ?? new NoOpFindingsProvider(); @@ -80,10 +105,8 @@ export class Container { } } -/** - * Map of workflowId to Container instance. - * Each workflow gets its own container scoped to its lifecycle. - */ +// === Container Lifecycle === + const containers = new Map(); /** Default container config — OSS standalone defaults */ @@ -107,8 +130,8 @@ let containerFactory: ContainerFactory = (_workflowId, sessionMetadata, config) /** * Override the default container factory. * - * Call once at worker startup to inject providers into all containers - * created during the worker's lifetime. + * 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; @@ -116,14 +139,6 @@ export function setContainerFactory(factory: ContainerFactory): void { /** * Get or create a Container for a workflow. - * - * If a container already exists for the workflowId, returns it. - * Otherwise, creates a new container with the provided dependencies. - * - * @param workflowId - Unique workflow identifier - * @param sessionMetadata - Session metadata for audit paths - * @param config - Runtime configuration (defaults to OSS standalone config) - * @returns Container instance for the workflow */ export function getOrCreateContainer( workflowId: string, @@ -140,28 +155,13 @@ export function getOrCreateContainer( return container; } -/** - * Remove a Container when a workflow completes. - * - * Should be called in logWorkflowComplete to clean up resources. - * - * @param workflowId - Unique workflow identifier - */ +/** Remove a Container when a workflow completes. */ export function removeContainer(workflowId: string): void { containers.delete(workflowId); } /** * Get an existing Container for a workflow, if one exists. - * - * Unlike getOrCreateContainer, this does NOT create a new container. - * Returns undefined if no container exists for the workflowId. - * - * Useful for lightweight activities that can benefit from an existing - * container but don't need to create one. - * - * @param workflowId - Unique workflow identifier - * @returns Container instance or undefined */ export function getContainer(workflowId: string): Container | undefined { return containers.get(workflowId); diff --git a/apps/worker/src/services/preflight.test.ts b/apps/worker/src/services/preflight.test.ts new file mode 100644 index 000000000..a7414f8ec --- /dev/null +++ b/apps/worker/src/services/preflight.test.ts @@ -0,0 +1,184 @@ +import { vi } from 'vitest'; +import type { ActivityLogger } from '../types/activity-logger.js'; + +// Mock child_process before importing preflight +vi.mock('node:child_process', () => ({ + execSync: vi.fn(), + spawnSync: vi.fn(() => ({ status: 0, stdout: Buffer.from('hi'), stderr: Buffer.from('') })), +})); + +// Mock fs to bypass repo validation +vi.mock('node:fs/promises', async () => { + return { + default: { + stat: vi.fn().mockResolvedValue({ isDirectory: () => true }), + access: vi.fn().mockResolvedValue(undefined), + }, + stat: vi.fn().mockResolvedValue({ isDirectory: () => true }), + access: vi.fn().mockResolvedValue(undefined), + }; +}); + +// Mock dns to bypass target URL validation +vi.mock('node:dns/promises', () => ({ + lookup: vi.fn().mockResolvedValue({ + address: '93.184.216.34', + }), +})); + +// Mock http/https for target URL check +vi.mock('node:http', () => ({ + default: { + request: vi.fn((_url: string, _opts: unknown, cb: (res: { resume: () => void; statusCode: number }) => void) => { + cb({ resume: () => {}, statusCode: 200 }); + return { + on: vi.fn(), + end: vi.fn(), + }; + }), + }, +})); + +vi.mock('node:https', () => ({ + default: { + request: vi.fn((_url: string, _opts: unknown, cb: (res: { resume: () => void; statusCode: number }) => void) => { + cb({ resume: () => {}, statusCode: 200 }); + return { + on: vi.fn(), + end: vi.fn(), + }; + }), + }, +})); + +// Mock SDK query to avoid real API calls +vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ + query: vi.fn(), +})); + +// Mock config parser +vi.mock('../config-parser.js', () => ({ + parseConfig: vi.fn(), +})); + +// Mock models +vi.mock('../ai/models.js', () => ({ + resolveModel: vi.fn(() => 'claude-haiku-4.5'), +})); + +const mockLogger: ActivityLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +}; + +describe('kiro-cli preflight validation', () => { + let origKey: string | undefined; + + beforeEach(() => { + origKey = process.env.KIRO_API_KEY; + vi.clearAllMocks(); + vi.resetModules(); + }); + + afterEach(() => { + if (origKey !== undefined) { + process.env.KIRO_API_KEY = origKey; + } else { + delete process.env.KIRO_API_KEY; + } + }); + + it('returns AUTH_FAILED when KIRO_API_KEY missing', async () => { + delete process.env.KIRO_API_KEY; + + // Dynamic import after mocks are set up + const { runPreflightChecks } = await import('./preflight.js'); + + const result = await runPreflightChecks( + 'https://example.com', + '/tmp/fake-repo', + undefined, + mockLogger, + true, // skipGitCheck + undefined, + undefined, + 'kiro-cli', + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('AUTH_FAILED'); + expect(result.error.message).toContain('KIRO_API_KEY'); + } + }); + + it('returns ok when kiro-cli succeeds', async () => { + process.env.KIRO_API_KEY = 'test-key-123'; + + const cp = await import('node:child_process'); + const spawnSyncMock = vi.mocked(cp.spawnSync); + spawnSyncMock.mockReturnValue({ + status: 0, + stdout: Buffer.from('hi'), + stderr: Buffer.from(''), + pid: 1234, + output: [null, Buffer.from('hi'), Buffer.from('')], + signal: null, + }); + + const { runPreflightChecks } = await import('./preflight.js'); + + const result = await runPreflightChecks( + 'https://example.com', + '/tmp/fake-repo', + undefined, + mockLogger, + true, + undefined, + undefined, + 'kiro-cli', + ); + + // Credential check passes but URL check may fail + // We only care that it got past credentials + if (!result.ok) { + // Should not be AUTH_FAILED + expect(result.error.code).not.toBe('AUTH_FAILED'); + } + }); + + it('returns AUTH_FAILED on auth error', async () => { + process.env.KIRO_API_KEY = 'bad-key'; + + const cp = await import('node:child_process'); + const spawnSyncMock = vi.mocked(cp.spawnSync); + spawnSyncMock.mockReturnValue({ + status: 1, + stdout: Buffer.from(''), + stderr: Buffer.from('authentication failed'), + pid: 1234, + output: [null, Buffer.from(''), Buffer.from('authentication failed')], + signal: null, + }); + + const { runPreflightChecks } = await import('./preflight.js'); + + const result = await runPreflightChecks( + 'https://example.com', + '/tmp/fake-repo', + undefined, + mockLogger, + true, + undefined, + undefined, + 'kiro-cli', + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('AUTH_FAILED'); + } + }); +}); diff --git a/apps/worker/src/services/preflight.ts b/apps/worker/src/services/preflight.ts index 29bc96bf7..6f7bfc7a8 100644 --- a/apps/worker/src/services/preflight.ts +++ b/apps/worker/src/services/preflight.ts @@ -334,6 +334,23 @@ async function validateCredentials( return ok(undefined); } + // 0a. Kiro CLI backend manages its own credentials — skip traditional validation + if (process.env.SHANNON_EXECUTOR_BACKEND === 'kiro-cli') { + if (!process.env.KIRO_API_KEY) { + return err( + new PentestError( + 'Kiro CLI backend requires KIRO_API_KEY in .env', + 'config', + false, + {}, + ErrorCode.AUTH_FAILED, + ), + ); + } + logger.info('Kiro CLI backend credentials OK'); + return ok(undefined); + } + // 0b. If apiKey provided via config, set it in env for SDK validation // This avoids requiring process.env.ANTHROPIC_API_KEY when key is threaded via input if (apiKey) { diff --git a/apps/worker/src/temporal/activities.ts b/apps/worker/src/temporal/activities.ts index abeec762e..96601f9a3 100644 --- a/apps/worker/src/temporal/activities.ts +++ b/apps/worker/src/temporal/activities.ts @@ -21,6 +21,7 @@ import { ApplicationFailure, Context, heartbeat } from '@temporalio/activity'; import { writePlaywrightStealthConfig } from '../ai/playwright-config-writer.js'; import { writeUserSettingsForCodePathAvoids } from '../ai/settings-writer.js'; import { AuditSession } from '../audit/index.js'; +import { readLiveToolUsage } from '../ai/kiro-cli-executor.js'; import type { ResumeAttempt } from '../audit/metrics-tracker.js'; import { authStateFile, generateSessionJsonPath, type SessionMetadata } from '../audit/utils.js'; import type { WorkflowSummary } from '../audit/workflow-logger.js'; @@ -206,6 +207,8 @@ async function runAgentActivity( costUsd: endResult.cost_usd, numTurns: null, model: endResult.model, + ...(endResult.toolUsage !== undefined && { toolUsage: endResult.toolUsage }), + ...(endResult.toolInvocations !== undefined && { toolInvocations: endResult.toolInvocations }), }; } catch (error) { // If error is already an ApplicationFailure, re-throw directly @@ -1152,3 +1155,14 @@ export async function generateReportOutputActivity(input: ActivityInput): Promis logger.info(`Report output written to ${result.outputPath}`); } } + +/** + * Poll the live tool-usage.jsonl file and return the current summary. + * + * Lightweight activity called by the workflow on a timer to provide + * real-time tool usage data while agents are running. + */ +export async function pollLiveToolUsage(): Promise { + const summary = await readLiveToolUsage(); + return summary ?? null; +} diff --git a/apps/worker/src/temporal/shared.ts b/apps/worker/src/temporal/shared.ts index a293310aa..619902004 100644 --- a/apps/worker/src/temporal/shared.ts +++ b/apps/worker/src/temporal/shared.ts @@ -1,10 +1,10 @@ import { defineQuery } from '@temporalio/workflow'; -export type { AgentMetrics } from '../types/metrics.js'; +export type { AgentMetrics, DetailedToolUsageMetrics, ToolInvocationRecord, ToolUsageSummaryMetrics } from '../types/metrics.js'; import type { DistributedConfig, PipelineConfig, ProviderConfig, VulnClass } from '../types/config.js'; import type { ErrorCode } from '../types/errors.js'; -import type { AgentMetrics } from '../types/metrics.js'; +import type { AgentMetrics, DetailedToolUsageMetrics, ToolUsageSummaryMetrics } from '../types/metrics.js'; export interface PipelineInput { webUrl: string; @@ -80,3 +80,8 @@ export interface VulnExploitPipelineResult { } export const getProgress = defineQuery('getProgress'); + +export const getToolUsage = defineQuery< + Record, + [boolean?] +>('getToolUsage'); diff --git a/apps/worker/src/temporal/workflows.ts b/apps/worker/src/temporal/workflows.ts index 5d4c03ed3..3e160916c 100644 --- a/apps/worker/src/temporal/workflows.ts +++ b/apps/worker/src/temporal/workflows.ts @@ -29,39 +29,29 @@ import { log, proxyActivities, setHandler, + upsertSearchAttributes, workflowInfo, } from '@temporalio/workflow'; import type { AgentName, VulnType } from '../types/agents.js'; import { ALL_AGENTS } from '../types/agents.js'; -import { ALL_VULN_CLASSES, type VulnClass } from '../types/config.js'; import type * as activities from './activities.js'; import type { ActivityInput } from './activities.js'; import { type AgentMetrics, + type DetailedToolUsageMetrics, getProgress, + getToolUsage, type PipelineInput, type PipelineProgress, type PipelineState, type PipelineSummary, type ResumeState, + type ToolUsageSummaryMetrics, type VulnExploitPipelineResult, } from './shared.js'; import { toWorkflowSummary } from './summary-mapper.js'; import { classifyErrorCode, formatWorkflowError } from './workflow-errors.js'; -/** Agents this run is expected to produce — drives the resume short-circuit. */ -function computeExpectedAgents(vulnClasses: readonly VulnClass[], exploit: boolean): string[] { - const expected: string[] = ['pre-recon', 'recon']; - for (const cls of vulnClasses) { - expected.push(`${cls}-vuln`); - if (exploit) { - expected.push(`${cls}-exploit`); - } - } - expected.push('report'); - return expected; -} - // Retry configuration for production (long intervals for billing recovery) const PRODUCTION_RETRY = { initialInterval: '5 minutes', @@ -219,6 +209,40 @@ export async function pentestPipeline(input: PipelineInput): Promise { + if (detailed) { + return detailedToolUsageByAgent; + } + return toolUsageByAgent; + }); + + // === Cumulative tool usage tracking for Temporal search attributes === + // Search attributes: tool_invocations (Int), tool_failures (Int) + // Registration: tctl admin cluster add-search-attributes --name tool_invocations --type Int --name tool_failures --type Int + let cumulativeInvocations = 0; + let cumulativeFailures = 0; + + // === Per-agent tool usage state for getToolUsage query === + const toolUsageByAgent: Record = {}; + const detailedToolUsageByAgent: Record = {}; + + /** Accumulate tool usage from an agent result and upsert search attributes. */ + function trackToolUsage(agentName: string, metrics: AgentMetrics): void { + if (metrics.toolUsage === undefined) return; + cumulativeInvocations += metrics.toolUsage.totalInvocations; + cumulativeFailures += metrics.toolUsage.failures; + toolUsageByAgent[agentName] = metrics.toolUsage; + // Store detailed invocations if available + detailedToolUsageByAgent[agentName] = { + ...metrics.toolUsage, + invocations: metrics.toolInvocations ?? [], + }; + upsertSearchAttributes({ + tool_invocations: [String(cumulativeInvocations)], + tool_failures: [String(cumulativeFailures)], + }); + } + // Build ActivityInput with required workflowId for audit correlation // Activities require workflowId (non-optional), PipelineInput has it optional // Use spread to conditionally include optional properties (exactOptionalPropertyTypes) @@ -246,42 +270,22 @@ export async function pentestPipeline(input: PipelineInput): Promise 0 ? input.vulnClasses : ALL_VULN_CLASSES; - const selectedClassSet = new Set(selectedVulnClasses); - const exploit: boolean = input.exploit ?? true; - const expectedAgents = computeExpectedAgents(selectedVulnClasses, exploit); - - await a.persistOrValidateRunScope(activityInput, [...selectedVulnClasses], exploit); - let resumeState: ResumeState | null = null; if (input.resumeFromWorkspace) { // 1. Load resume state (validates workspace, cross-checks deliverables) - resumeState = await a.loadResumeState( - input.resumeFromWorkspace, - input.webUrl, - input.repoPath, - input.deliverablesSubdir, - ); + resumeState = await a.loadResumeState(input.resumeFromWorkspace, input.webUrl, input.repoPath, input.deliverablesSubdir); // 2. Restore git workspace and clean up incomplete deliverables const incompleteAgents = ALL_AGENTS.filter( (agentName) => !resumeState?.completedAgents.includes(agentName), ) as AgentName[]; - await a.restoreGitCheckpoint( - input.repoPath, - resumeState.checkpointHash, - incompleteAgents, - input.deliverablesSubdir, - ); + await a.restoreGitCheckpoint(input.repoPath, resumeState.checkpointHash, incompleteAgents, input.deliverablesSubdir); - // 3. Short-circuit when every agent expected by this run is done. - // Uses dynamic expectedAgents (not ALL_AGENTS) so a class-scoped run completes sooner. - const allExpectedDone = expectedAgents.every((a) => resumeState?.completedAgents.includes(a)); - if (allExpectedDone) { - log.info(`All ${expectedAgents.length} expected agents already completed. Nothing to resume.`); + // 3. Short-circuit if all agents already completed + if (resumeState.completedAgents.length === ALL_AGENTS.length) { + log.info(`All ${ALL_AGENTS.length} agents already completed. Nothing to resume.`); state.status = 'completed'; state.completedAgents = [...resumeState.completedAgents]; state.summary = computeSummary(state); @@ -314,7 +318,9 @@ export async function pentestPipeline(input: PipelineInput): Promise Promise> = []; for (const config of pipelineConfigs) { - // Excluded classes drop entirely; any prior deliverables stay on disk but don't count this run. - if (!selectedClassSet.has(config.vulnType)) { - log.info(`Skipping ${config.vulnType} pipeline (class not selected this run)`); - continue; - } if (!shouldSkip(config.vulnAgent) || !shouldSkip(config.exploitAgent)) { pipelineThunks.push(() => runVulnExploitPipeline(config.vulnType, config.runVuln, config.runExploit)); } else { @@ -558,11 +558,14 @@ export async function pentestPipeline(input: PipelineInput): Promise | undefined; } diff --git a/apps/worker/src/types/config.ts b/apps/worker/src/types/config.ts index 9e62bb69b..e81ca1172 100644 --- a/apps/worker/src/types/config.ts +++ b/apps/worker/src/types/config.ts @@ -129,4 +129,6 @@ export interface ContainerConfig { readonly promptDir?: string; /** LLM provider configuration — when set, executor maps to SDK env vars directly */ readonly providerConfig?: ProviderConfig; + /** Executor backend selection — 'claude-sdk' (default) or 'kiro-cli' */ + readonly executorBackend?: 'claude-sdk' | 'kiro-cli'; } diff --git a/apps/worker/src/types/metrics.ts b/apps/worker/src/types/metrics.ts index 27c67e12f..6a24236bb 100644 --- a/apps/worker/src/types/metrics.ts +++ b/apps/worker/src/types/metrics.ts @@ -9,6 +9,31 @@ * Centralized here to avoid temporal/shared.ts import boundary violations. */ +/** + * Aggregated tool usage statistics for an agent execution. + * Duplicated from kiro-cli-executor.ts to avoid pulling Node.js modules + * into the Temporal workflow sandbox (workflows can only import deterministic code). + */ +export interface ToolUsageSummaryMetrics { + readonly totalInvocations: number; + readonly toolCounts: Record; + readonly failures: number; + readonly totalDurationMs: number; +} + +/** A single tool invocation record (workflow-safe mirror of ToolUsageEntry). */ +export interface ToolInvocationRecord { + readonly tool: string; + readonly timestamp: number; + readonly success?: boolean | undefined; + readonly durationMs?: number | undefined; +} + +/** Detailed tool usage including per-invocation records. */ +export interface DetailedToolUsageMetrics extends ToolUsageSummaryMetrics { + readonly invocations: readonly ToolInvocationRecord[]; +} + export interface AgentMetrics { durationMs: number; inputTokens: number | null; @@ -16,6 +41,8 @@ export interface AgentMetrics { costUsd: number | null; numTurns: number | null; model?: string | undefined; + toolUsage?: ToolUsageSummaryMetrics | undefined; + toolInvocations?: readonly ToolInvocationRecord[] | undefined; // True when the checkpoint provider skipped the agent (resume path). // Callers that perform post-agent work on collected state should short-circuit // when this is set, since no fresh state was produced this run. diff --git a/apps/worker/tsconfig.json b/apps/worker/tsconfig.json index d18fa3c51..53de029e6 100644 --- a/apps/worker/tsconfig.json +++ b/apps/worker/tsconfig.json @@ -2,5 +2,5 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "./src", "outDir": "./dist" }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] } diff --git a/apps/worker/vitest.config.ts b/apps/worker/vitest.config.ts new file mode 100644 index 000000000..5a4214273 --- /dev/null +++ b/apps/worker/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + include: ['src/**/*.test.ts'], + }, +}); diff --git a/licenses.txt b/licenses.txt new file mode 100644 index 000000000..a8b09eb87 --- /dev/null +++ b/licenses.txt @@ -0,0 +1,364 @@ + Name Version License + CairoSVG 2.9.0 LGPL-3.0-or-later + ConfigArgParse 1.7.1 MIT License + Faker 38.2.0 MIT License + Flask 3.1.3 BSD-3-Clause + Jinja2 3.1.6 BSD License + MarkupSafe 3.0.3 BSD-3-Clause + PuLP 3.3.0 MIT + PyJWT 2.11.0 MIT + PyPDF2 3.0.1 BSD License + PySocks 1.7.1 BSD + PyYAML 6.0.2 MIT License + Pygments 2.19.2 BSD License + Werkzeug 3.1.3 BSD License + XMind 1.2.0 MIT License + about-time 4.2.1 MIT License + aiodns 3.6.1 MIT License + aiohappyeyeballs 2.6.1 Python Software Foundation License + aiohttp 3.13.3 Apache-2.0 AND MIT + aiohttp_socks 0.10.2 Apache Software License + aiosignal 1.4.0 Apache Software License + alive-progress 3.3.0 MIT License + annotated-doc 0.0.4 MIT + annotated-types 0.7.0 MIT License + antlr4-python3-runtime 4.9.3 BSD + anyio 4.10.0 MIT + appnope 0.1.4 BSD License + arabic-reshaper 3.0.0 MIT + argcomplete 3.6.3 Apache Software License + asgiref 3.11.1 BSD License + asn1crypto 1.5.1 MIT License + astroid 4.0.2 LGPL-2.1-or-later + asttokens 3.0.1 Apache 2.0 + async-timeout 5.0.1 Apache Software License + attrs 25.4.0 MIT + autoflake 2.3.1 MIT License + autopep8 2.3.2 MIT License + aws-cdk-lib 2.231.0 Apache-2.0 + aws-cdk.asset-awscli-v1 2.2.242 Apache-2.0 + aws-cdk.asset-node-proxy-agent-v6 2.1.0 Apache-2.0 + aws-cdk.cloud-assembly-schema 48.20.0 Apache-2.0 + aws-requests-auth 0.4.3 BSD License + awscurl 0.36 MIT + backcall 0.2.0 BSD License + beautifulsoup4 4.12.3 MIT License + bedrock-agentcore 1.4.8 Apache Software License + bedrock-agentcore-starter-toolkit 0.3.3 Apache Software License + black 25.11.0 MIT + bleach 6.3.0 Apache Software License + blinker 1.9.0 MIT License + boto3 1.42.77 Apache-2.0 + boto3-stubs 1.41.5 MIT + botocore 1.42.77 Apache-2.0 + botocore-stubs 1.41.5 MIT + bs4 0.0.2 MIT License + cairocffi 1.7.1 BSD License + cattrs 25.3.0 MIT License + certifi 2026.2.25 Mozilla Public License 2.0 (MPL 2.0) + cffi 2.0.0 MIT + cfgv 3.5.0 MIT + chardet 5.2.0 GNU Lesser General Public License v2 or later (LGPLv2+) + charset-normalizer 3.4.3 MIT + click 8.2.1 BSD-3-Clause + cloudpickle 3.1.1 BSD License + cloudscraper 1.2.71 MIT License + colorama 0.4.6 BSD License + configparser 7.2.0 MIT License + constructs 10.4.3 Apache-2.0 + contourpy 1.3.3 BSD License + coverage 7.12.0 Apache-2.0 + cryptography 46.0.3 Apache-2.0 OR BSD-3-Clause + cssselect2 0.9.0 BSD License + cycler 0.12.1 BSD License + decorator 5.2.1 BSD License + defusedxml 0.7.1 Python Software Foundation License + diagram-ai-generator 1.0.6 MIT License + diagrams 0.25.1 MIT + dill 0.4.0 BSD License + distlib 0.4.0 Python Software Foundation License + dnspython 2.8.0 ISC License (ISCL) + docker 7.1.0 Apache-2.0 + docopt 0.6.2 MIT License + docstring_parser 0.17.0 MIT License + et_xmlfile 2.0.0 MIT License + executing 2.2.1 MIT License + fastapi 0.116.1 MIT License + fastembed 0.8.0 Other/Proprietary License + fastjsonschema 2.21.2 BSD License + filelock 3.24.2 MIT + flake8 7.3.0 MIT License + flatbuffers 25.12.19 Apache Software License + fonttools 4.60.1 MIT + freezegun 1.5.5 Apache-2.0 + frozenlist 1.8.0 Apache-2.0 + fsspec 2026.2.0 BSD-3-Clause + future 1.0.0 MIT License + future-annotations 1.0.0 MIT License + galeodes 0.7 AGPL-3.0 + git-remote-codecommit 1.17 Apache Software License + google-pasta 0.2.0 Apache Software License + googleapis-common-protos 1.73.1 Apache Software License + graphemeu 0.7.2 Copyright (c) 2017 The Python Packaging Authority (PyPA) + Copyright (c) 2014 Mapbox + Copyright (c) 2017 Alvin Lindstam + Copyright (c) 2025 Timendum + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + graphene 3.4.3 MIT + graphql-core 3.2.6 MIT License + graphql-relay 3.2.0 MIT License + graphviz 0.20.3 MIT License + greenlet 3.3.2 MIT AND PSF-2.0 + h11 0.16.0 MIT License + harfile 0.4.0 MIT + hf-xet 1.4.2 Apache-2.0 + holehe 1.61 GNU General Public License v3 (GPLv3) + html5lib 1.1 MIT License + httpcore 1.0.9 BSD-3-Clause + httpx 0.28.1 BSD License + httpx-sse 0.4.3 MIT + huggingface_hub 1.7.2 Apache Software License + hypothesis 6.148.3 MPL-2.0 + hypothesis-graphql 0.12.0 MIT + hypothesis-jsonschema 0.23.1 Mozilla Public License 2.0 (MPL 2.0) + identify 2.6.16 MIT + idna 3.10 BSD License + importlib-metadata 6.11.0 Apache Software License + importlib_resources 6.5.2 Apache Software License + iniconfig 2.3.0 MIT + ipython 8.12.3 BSD License + ipython_pygments_lexers 1.1.1 BSD License + isort 7.0.0 MIT + itsdangerous 2.2.0 BSD License + jedi 0.19.2 MIT License + jmespath 1.0.1 MIT License + joblib 1.5.3 BSD-3-Clause + jsii 1.120.0 Apache Software License + jsonpickle 4.1.1 BSD-3-Clause + jsonschema 4.24.1 MIT + jsonschema-path 0.4.5 Apache Software License + jsonschema-specifications 2025.9.1 MIT + jsonschema_rs 0.45.0 MIT License + junit-xml 1.9 Freely Distributable; MIT License + jupyter_client 8.8.0 BSD License + jupyter_core 5.9.1 BSD-3-Clause + jupyterlab_pygments 0.3.0 BSD License + kiwisolver 1.4.9 BSD License + langdetect 1.0.9 Apache Software License + lazy-object-proxy 1.12.0 BSD-2-Clause + librt 0.6.2 MIT License + loguru 0.7.3 MIT License + lxml 5.4.0 BSD License + magika 0.6.3 Apache Software License + maigret 0.5.0 MIT License + markdown-it-py 4.0.0 MIT License + markdownify 1.2.2 MIT License + markitdown 0.1.5 MIT + matplotlib 3.10.7 Python Software Foundation License + matplotlib-inline 0.2.1 UNKNOWN + mccabe 0.7.0 MIT License + mcp 1.26.0 MIT License + mdurl 0.1.2 MIT License + mistune 3.2.0 BSD License + mmh3 5.2.1 MIT License + mock 5.2.0 BSD License + moto 5.1.17 Apache-2.0 + mpmath 1.3.0 BSD License + multidict 6.7.1 Apache License 2.0 + multiprocess 0.70.18 BSD License + mypy 1.19.0 MIT License + mypy-boto3-cloudformation 1.41.2 MIT + mypy-boto3-dynamodb 1.41.0 MIT + mypy-boto3-ec2 1.41.4 MIT + mypy-boto3-lambda 1.41.2 MIT + mypy-boto3-rds 1.41.2 MIT + mypy-boto3-s3 1.41.1 MIT + mypy-boto3-sqs 1.41.0 MIT + mypy_extensions 1.1.0 MIT + nbclient 0.10.4 BSD License + nbconvert 7.17.1 BSD License + nbformat 5.10.4 BSD License + networkx 2.8.8 BSD License + nodeenv 1.10.0 BSD License + numpy 1.26.4 BSD License + omegaconf 2.3.0 BSD License + onnxruntime 1.24.4 MIT License + openapi-schema-validator 0.8.1 BSD License + openapi-spec-validator 0.8.4 Apache-2.0 + openpyxl 3.1.5 MIT License + opentelemetry-api 1.40.0 Apache-2.0 + opentelemetry-exporter-otlp-proto-common 1.40.0 Apache-2.0 + opentelemetry-exporter-otlp-proto-http 1.40.0 Apache-2.0 + opentelemetry-instrumentation 0.61b0 Apache-2.0 + opentelemetry-instrumentation-botocore 0.61b0 Apache-2.0 + opentelemetry-instrumentation-dbapi 0.61b0 Apache-2.0 + opentelemetry-instrumentation-psycopg2 0.61b0 Apache-2.0 + opentelemetry-instrumentation-threading 0.61b0 Apache-2.0 + opentelemetry-propagator-aws-xray 1.0.2 Apache-2.0 + opentelemetry-proto 1.40.0 Apache-2.0 + opentelemetry-sdk 1.40.0 Apache-2.0 + opentelemetry-sdk-extension-aws 2.1.0 Apache-2.0 + opentelemetry-semantic-conventions 0.61b0 Apache-2.0 + oscrypto 1.3.0 MIT License + outcome 1.3.0.post0 Apache Software License; MIT License + packaging 24.2 Apache Software License; BSD License + pandas 2.3.2 BSD License + pandocfilters 1.5.1 BSD License + parso 0.8.6 MIT License + pathable 0.5.0 Apache Software License + pathos 0.3.4 BSD License + pathspec 0.12.1 Mozilla Public License 2.0 (MPL 2.0) + patsy 1.0.2 BSD License + pexpect 4.9.0 ISC License (ISCL) + pickleshare 0.7.5 MIT License + pillow 11.3.0 MIT-CMU + pipreqs 0.5.0 Apache Software License + pipx 1.11.1 MIT + platformdirs 4.4.0 MIT + playwright 1.58.0 Apache-2.0 + pluggy 1.6.0 MIT License + pox 0.3.6 BSD License + ppft 1.7.7 BSD License + prance 25.4.8.0 MITNFA + pre_commit 4.5.1 MIT + prompt_toolkit 3.0.52 BSD License + propcache 0.4.1 Apache Software License + protobuf 6.31.1 3-Clause BSD License + psutil 7.0.0 BSD License + psycopg2-binary 2.9.11 GNU Library or Lesser General Public License (LGPL) + ptyprocess 0.7.0 ISC License (ISCL) + publication 0.0.3 MIT License + pure_eval 0.2.3 MIT License + py-openapi-schema-to-json-schema 0.0.3 MIT License + pyHanko 0.34.1 MIT + py_rust_stemmers 0.1.5 UNKNOWN + pycares 4.11.0 MIT License + pycodestyle 2.14.0 MIT + pycountry 24.6.1 GNU Lesser General Public License v2 (LGPLv2) + pycparser 2.23 BSD License + pydantic 2.11.7 MIT + pydantic-settings 2.13.0 MIT + pydantic_core 2.33.2 MIT License + pyee 13.0.1 MIT License + pyflakes 3.4.0 MIT License + pyhanko-certvalidator 0.30.1 MIT + pylint 4.0.3 GPL-2.0-or-later + pyparsing 3.2.5 MIT + pypdf 6.9.1 BSD-3-Clause + pyrate-limiter 4.1.0 MIT + pytest 9.0.1 MIT + pytest-cov 7.0.0 MIT + pytest-mock 3.15.1 MIT License + python-bidi 0.6.7 GNU Library or Lesser General Public License (LGPL) + python-dateutil 2.9.0.post0 Apache Software License; BSD License + python-docx 1.2.0 MIT License + python-dotenv 1.2.1 BSD-3-Clause + python-multipart 0.0.22 Apache-2.0 + python-pptx 1.0.2 MIT License + python-socks 2.8.1 Apache Software License + pytokens 0.3.0 MIT License + pytz 2025.2 MIT License + pyvis 0.3.2 BSD + pyzmq 27.1.0 BSD License + questionary 2.1.1 MIT License + referencing 0.37.0 MIT + regex 2026.2.28 Apache-2.0 AND CNRI-Python + reportlab 4.4.10 BSD License + requests 2.32.5 Apache Software License + requests-futures 1.0.2 Apache Software License + requests-toolbelt 1.0.0 Apache Software License + responses 0.25.8 Apache 2.0 + rfc3339-validator 0.1.4 MIT License + rich 14.1.0 MIT License + rpds-py 0.27.1 MIT + ruamel.yaml 0.19.1 MIT License + ruff 0.14.13 MIT License + s3transfer 0.16.0 Apache Software License + safetensors 0.7.0 Apache Software License + sagemaker 2.251.1 Apache Software License + sagemaker-core 1.0.59 Apache Software License + schema 0.7.7 MIT License + schemathesis 4.13.0 MIT + scikit-learn 1.8.0 BSD-3-Clause + scipy 1.17.1 BSD License + selenium 4.41.0 Apache-2.0 + sentence-transformers 5.3.0 Apache Software License + shellingham 1.5.4 ISC License (ISCL) + sherlock-project 0.16.0 MIT License + six 1.17.0 MIT License + smdebug-rulesconfig 1.0.1 Apache Software License + sniffio 1.3.1 Apache Software License; MIT License + social-analyzer 0.45 AGPL-3.0 + socid-extractor 0.0.27 GPL-3.0 + sortedcontainers 2.4.0 Apache Software License + soupsieve 2.8.3 MIT + sse-starlette 3.2.0 BSD-3-Clause + stack-data 0.6.3 MIT License + starlette 0.52.1 BSD-3-Clause + starlette-testclient 0.4.1 BSD-3-Clause + statsmodels 0.14.6 BSD License + stem 1.8.2 GNU Lesser General Public License v3 (LGPLv3) + strands-agents 1.33.0 Apache Software License + svglib 1.5.1 GNU Lesser General Public License v3 (LGPLv3) + sympy 1.14.0 BSD License + tblib 3.1.0 BSD-2-Clause + tenacity 9.1.4 Apache Software License + termcolor 3.3.0 MIT + threadpoolctl 3.6.0 BSD License + tinycss2 1.4.0 BSD License + tld 0.13.2 MPL-1.1 OR GPL-2.0-only OR LGPL-2.1-or-later + tokenize_rt 6.2.0 MIT + tokenizers 0.22.2 Apache Software License + toml 0.10.2 MIT License + tomlkit 0.13.3 MIT License + torch 2.11.0 BSD-3-Clause + tornado 6.5.5 Apache Software License + torrequest 0.1.0 MIT + tqdm 4.67.1 MIT License; Mozilla Public License 2.0 (MPL 2.0) + traitlets 5.14.3 BSD License + transformers 5.3.0 Apache 2.0 License + trio 0.33.0 MIT OR Apache-2.0 + trio-websocket 0.12.2 MIT License + typeguard 2.13.3 MIT License + typer 0.24.1 MIT + types-awscrt 0.29.0 MIT License + types-s3transfer 0.15.0 MIT License + typing-inspection 0.4.1 MIT + typing_extensions 4.15.0 PSF-2.0 + tzdata 2025.2 Apache Software License + tzlocal 5.3.1 MIT License + uritools 6.0.1 MIT + urllib3 2.6.3 MIT + userpath 1.9.2 MIT + uvicorn 0.35.0 BSD-3-Clause + virtualenv 20.37.0 MIT + watchdog 6.0.0 Apache Software License + webencodings 0.5.1 BSD License + websocket-client 1.9.0 Apache Software License + websockets 16.0 BSD-3-Clause + whatweb 0.0.8 MIT License + wrapt 1.17.3 BSD License + wsproto 1.3.2 MIT + xhtml2pdf 0.2.17 Apache Software License + xlsxwriter 3.2.9 BSD License + xmltodict 1.0.2 MIT License + yarg 0.1.9 MIT License + yarl 1.23.0 Apache-2.0 + zipp 3.23.0 MIT diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 974ff5fc2..cf41b80c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,6 +85,12 @@ importers: '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 + fast-check: + specifier: ^4.6.0 + version: 4.7.0 + vitest: + specifier: ^4.1.4 + version: 4.1.5(@types/node@25.5.0)(vite@8.0.10(@types/node@25.5.0)(terser@5.46.0)) packages: @@ -237,15 +243,24 @@ packages: '@clack/prompts@1.1.0': resolution: {integrity: sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==} + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/core@1.9.1': resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.9.1': resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} '@emnapi/wasi-threads@1.2.0': resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@grpc/grpc-js@1.14.3': resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} engines: {node: '>=12.10.0'} @@ -413,9 +428,18 @@ packages: '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@oxc-project/types@0.122.0': resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -455,30 +479,60 @@ packages: cpu: [arm64] os: [android] + '@rolldown/binding-android-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-darwin-arm64@1.0.0-rc.11': resolution: {integrity: sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-rc.11': resolution: {integrity: sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-freebsd-x64@1.0.0-rc.11': resolution: {integrity: sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.11': resolution: {integrity: sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.11': resolution: {integrity: sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -486,6 +540,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.11': resolution: {integrity: sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -493,6 +554,13 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.11': resolution: {integrity: sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -500,6 +568,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.11': resolution: {integrity: sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -507,6 +582,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.11': resolution: {integrity: sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -514,6 +596,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-musl@1.0.0-rc.11': resolution: {integrity: sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -521,32 +610,68 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@rolldown/binding-openharmony-arm64@1.0.0-rc.11': resolution: {integrity: sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.0.0-rc.11': resolution: {integrity: sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==} engines: {node: '>=14.0.0'} cpu: [wasm32] + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.11': resolution: {integrity: sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.11': resolution: {integrity: sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/pluginutils@1.0.0-rc.11': resolution: {integrity: sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==} + '@rolldown/pluginutils@1.0.0-rc.17': + resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/core-darwin-arm64@1.15.18': resolution: {integrity: sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==} engines: {node: '>=10'} @@ -661,6 +786,12 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -682,6 +813,35 @@ packages: '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -791,6 +951,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-kit@3.0.0-beta.1: resolution: {integrity: sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw==} engines: {node: '>=20.19.0'} @@ -834,6 +998,10 @@ packages: caniuse-lite@1.0.30001778: resolution: {integrity: sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -864,6 +1032,9 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -896,6 +1067,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -999,6 +1174,10 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + express-rate-limit@8.3.2: resolution: {integrity: sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==} engines: {node: '>= 16'} @@ -1009,6 +1188,10 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + fast-check@4.7.0: + resolution: {integrity: sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==} + engines: {node: '>=12.17.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1039,6 +1222,11 @@ packages: fs-monkey@1.1.0: resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1166,6 +1354,80 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + loader-runner@4.3.1: resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} engines: {node: '>=6.11.5'} @@ -1176,6 +1438,9 @@ packages: long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1219,6 +1484,11 @@ packages: resolution: {integrity: sha512-kh8ARjh8rMN7Du2igDRO9QJnqCb2xYTJxyQYK7vJJS4TvLLmsbyhiKpSW+t+y26gyOyMd0riphX0GeWKU3ky5g==} engines: {node: '>=12.13'} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -1276,6 +1546,10 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} + postcss@8.5.12: + resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} + engines: {node: ^10 || ^12 || >=14} + proto3-json-serializer@2.0.2: resolution: {integrity: sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==} engines: {node: '>=14.0.0'} @@ -1288,6 +1562,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + pure-rand@8.4.0: + resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} + qs@6.15.1: resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} @@ -1342,6 +1619,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rolldown@1.0.0-rc.17: + resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -1396,6 +1678,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -1424,10 +1709,16 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1477,6 +1768,9 @@ packages: peerDependencies: tslib: ^2 + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.0.4: resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} engines: {node: '>=18'} @@ -1485,6 +1779,14 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -1613,6 +1915,90 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vite@8.0.10: + resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + watchpack@2.5.1: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} @@ -1636,6 +2022,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -1783,12 +2174,23 @@ snapshots: '@clack/core': 1.1.0 sisteransi: 1.0.5 + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + '@emnapi/core@1.9.1': dependencies: '@emnapi/wasi-threads': 1.2.0 tslib: 2.8.1 optional: true + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.9.1': dependencies: tslib: 2.8.1 @@ -1799,6 +2201,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@grpc/grpc-js@1.14.3': dependencies: '@grpc/proto-loader': 0.8.0 @@ -1992,8 +2399,17 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.1 + optional: true + '@oxc-project/types@0.122.0': {} + '@oxc-project/types@0.127.0': {} + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -2024,52 +2440,105 @@ snapshots: '@rolldown/binding-android-arm64@1.0.0-rc.11': optional: true + '@rolldown/binding-android-arm64@1.0.0-rc.17': + optional: true + '@rolldown/binding-darwin-arm64@1.0.0-rc.11': optional: true + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + optional: true + '@rolldown/binding-darwin-x64@1.0.0-rc.11': optional: true + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + optional: true + '@rolldown/binding-freebsd-x64@1.0.0-rc.11': optional: true + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.11': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.11': optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + optional: true + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.11': optional: true + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + optional: true + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.11': optional: true + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + optional: true + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.11': optional: true + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + optional: true + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.11': optional: true + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + optional: true + '@rolldown/binding-linux-x64-musl@1.0.0-rc.11': optional: true + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + optional: true + '@rolldown/binding-openharmony-arm64@1.0.0-rc.11': optional: true + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-rc.11': dependencies: '@napi-rs/wasm-runtime': 1.1.1 optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.11': optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + optional: true + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.11': optional: true + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + optional: true + '@rolldown/pluginutils@1.0.0-rc.11': {} + '@rolldown/pluginutils@1.0.0-rc.17': {} + + '@standard-schema/spec@1.1.0': {} + '@swc/core-darwin-arm64@1.15.18': optional: true @@ -2205,6 +2674,13 @@ snapshots: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -2227,6 +2703,47 @@ snapshots: dependencies: undici-types: 7.18.2 + '@vitest/expect@4.1.5': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.5.0)(terser@5.46.0))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.10(@types/node@25.5.0)(terser@5.46.0) + + '@vitest/pretty-format@4.1.5': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.5': + dependencies: + '@vitest/utils': 4.1.5 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.5': {} + + '@vitest/utils@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -2352,6 +2869,8 @@ snapshots: argparse@2.0.1: {} + assertion-error@2.0.1: {} + ast-kit@3.0.0-beta.1: dependencies: '@babel/parser': 8.0.0-rc.2 @@ -2402,6 +2921,8 @@ snapshots: caniuse-lite@1.0.30001778: {} + chai@6.2.2: {} + chokidar@5.0.0: dependencies: readdirp: 5.0.0 @@ -2426,6 +2947,8 @@ snapshots: content-type@1.0.5: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -2449,6 +2972,8 @@ snapshots: depd@2.0.0: {} + detect-libc@2.1.2: {} + dotenv@16.6.1: {} dotenv@17.3.1: {} @@ -2519,6 +3044,8 @@ snapshots: dependencies: eventsource-parser: 3.0.6 + expect-type@1.3.0: {} + express-rate-limit@8.3.2(express@5.2.1): dependencies: express: 5.2.1 @@ -2557,6 +3084,10 @@ snapshots: transitivePeerDependencies: - supports-color + fast-check@4.7.0: + dependencies: + pure-rand: 8.4.0 + fast-deep-equal@3.1.3: {} fast-uri@3.1.2: {} @@ -2582,6 +3113,9 @@ snapshots: fs-monkey@1.1.0: {} + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} get-caller-file@2.0.5: {} @@ -2689,12 +3223,65 @@ snapshots: json-schema-typed@8.0.2: {} + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + loader-runner@4.3.1: {} lodash.camelcase@4.3.0: {} long@5.3.2: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} media-typer@1.1.0: {} @@ -2736,6 +3323,8 @@ snapshots: ms@3.0.0-canary.1: {} + nanoid@3.3.11: {} + negotiator@1.0.0: {} neo-async@2.6.2: {} @@ -2772,6 +3361,12 @@ snapshots: pkce-challenge@5.0.1: {} + postcss@8.5.12: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + proto3-json-serializer@2.0.2: dependencies: protobufjs: 7.5.5 @@ -2796,6 +3391,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + pure-rand@8.4.0: {} + qs@6.15.1: dependencies: side-channel: 1.1.0 @@ -2857,6 +3454,27 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.11 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.11 + rolldown@1.0.0-rc.17: + dependencies: + '@oxc-project/types': 0.127.0 + '@rolldown/pluginutils': 1.0.0-rc.17 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-x64': 1.0.0-rc.17 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + router@2.2.0: dependencies: debug: 4.4.3 @@ -2943,6 +3561,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + sisteransi@1.0.5: {} smol-toml@1.6.1: {} @@ -2964,8 +3584,12 @@ snapshots: source-map@0.7.6: {} + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.1.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -3009,6 +3633,8 @@ snapshots: dependencies: tslib: 2.8.1 + tinybench@2.9.0: {} + tinyexec@1.0.4: {} tinyglobby@0.2.15: @@ -3016,6 +3642,13 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + toidentifier@1.0.1: {} tree-dump@1.1.0(tslib@2.8.1): @@ -3117,6 +3750,45 @@ snapshots: vary@1.1.2: {} + vite@8.0.10(@types/node@25.5.0)(terser@5.46.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.12 + rolldown: 1.0.0-rc.17 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.5.0 + fsevents: 2.3.3 + terser: 5.46.0 + + vitest@4.1.5(@types/node@25.5.0)(vite@8.0.10(@types/node@25.5.0)(terser@5.46.0)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.5.0)(terser@5.46.0)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 8.0.10(@types/node@25.5.0)(terser@5.46.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.5.0 + transitivePeerDependencies: + - msw + watchpack@2.5.1: dependencies: glob-to-regexp: 0.4.1 @@ -3160,6 +3832,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0