From c7110df3c6fa87ab381515ca2d682e2f7e5c56f8 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Tue, 5 May 2026 00:40:14 -0700 Subject: [PATCH 01/21] =?UTF-8?q?feat:=20Pi=20coding=20tools=20integration?= =?UTF-8?q?=20=E2=80=94=20adapter=20+=20DirectApiRunner=20(by=20Wren)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Pi coding tools (read, write, edit, bash, grep, find, ls) from @mariozechner/pi-coding-agent as composable tools for Ink's direct-api backend. This gives our native backend battle-tested filesystem access while keeping full control of context, memory, and eviction. - pi-coding-tools.ts: adapter bridging Pi tool factories to Anthropic.Tool schemas + executor functions, with workspace root enforcement - direct-api-runner.ts: IRunner implementation with proper agentic tool execution loop (tool results fed back until end_turn) - Session service wired to select DirectApiRunner for 'direct-api' backend - Pi packages added as dependencies with ESM dynamic import Co-Authored-By: Wren --- package.json | 2 +- packages/api/package.json | 2 + packages/api/src/agent/index.ts | 8 + .../api/src/agent/tools/pi-coding-tools.ts | 200 ++ .../services/sessions/direct-api-runner.ts | 267 ++ packages/api/src/services/sessions/index.ts | 1 + .../src/services/sessions/session-service.ts | 26 +- yarn.lock | 2350 +++++++++++++++-- 8 files changed, 2648 insertions(+), 208 deletions(-) create mode 100644 packages/api/src/agent/tools/pi-coding-tools.ts create mode 100644 packages/api/src/services/sessions/direct-api-runner.ts diff --git a/package.json b/package.json index e7db8fa4..ebf0696f 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "minimatch@9.0.3": "9.0.7", "undici": "6.24.0", "lodash": "4.18.0", - "brace-expansion": "1.1.13", + "brace-expansion@^1": "1.1.13", "@slack/bolt/path-to-regexp": "8.4.0", "handlebars": "4.7.9" } diff --git a/packages/api/package.json b/packages/api/package.json index 112fdc1c..19fd905f 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -35,6 +35,8 @@ "dependencies": { "@anthropic-ai/sdk": "^0.71.2", "@inklabs/shared": "workspace:*", + "@mariozechner/pi-agent-core": "0.71.1", + "@mariozechner/pi-coding-agent": "0.71.1", "@modelcontextprotocol/sdk": "^1.26.0", "@slack/bolt": "^4.6.0", "@supabase/supabase-js": "^2.39.3", diff --git a/packages/api/src/agent/index.ts b/packages/api/src/agent/index.ts index 1099c1cc..ce1d7911 100644 --- a/packages/api/src/agent/index.ts +++ b/packages/api/src/agent/index.ts @@ -14,6 +14,14 @@ export type { ClaudeCodeConfig } from './backends/claude-code.backend'; export { DirectApiBackend, createDirectApiBackend } from './backends/direct-api.backend'; export type { DirectApiConfig } from './backends/direct-api.backend'; +// Tools +export { + createInkCodingTools, + getPiToolSchemas, + createPiToolExecutor, +} from './tools/pi-coding-tools'; +export type { InkToolDefinition, PiCodingToolsConfig } from './tools/pi-coding-tools'; + // Manager export { BackendManager, createBackendManager } from './backend-manager'; export type { BackendManagerConfig } from './backend-manager'; diff --git a/packages/api/src/agent/tools/pi-coding-tools.ts b/packages/api/src/agent/tools/pi-coding-tools.ts new file mode 100644 index 00000000..444353ce --- /dev/null +++ b/packages/api/src/agent/tools/pi-coding-tools.ts @@ -0,0 +1,200 @@ +/** + * Pi Coding Tools Adapter + * + * Bridges @mariozechner/pi-coding-agent's tool factories into Ink's + * direct-api backend tool format (Anthropic.Tool + execution). + * + * Pi packages are ESM-only, so we use dynamic import(). + */ + +import path from 'path'; +import type Anthropic from '@anthropic-ai/sdk'; +import { logger } from '../../utils/logger'; + +// Pi tool types — widened to accept TypeBox TObject schemas +interface PiAgentTool { + name: string; + label?: string; + description?: string; + parameters?: unknown; + execute: ( + callId: string, + params: unknown, + signal?: AbortSignal, + onUpdate?: unknown + ) => Promise; +} + +interface PiToolResult { + content?: Array<{ type: string; text?: string }>; +} + +export interface InkToolDefinition { + /** Anthropic API tool schema (for sending to the LLM) */ + schema: Anthropic.Tool; + /** Execute the tool and return a string result */ + execute: (params: Record, signal?: AbortSignal) => Promise; +} + +export interface PiCodingToolsConfig { + /** Working directory — scopes all filesystem tools to this path */ + cwd: string; + /** Tools to include (default: all) */ + include?: Array<'read' | 'write' | 'edit' | 'bash' | 'grep' | 'find' | 'ls'>; + /** Tools to exclude */ + exclude?: Array<'read' | 'write' | 'edit' | 'bash' | 'grep' | 'find' | 'ls'>; + /** Enforce workspace root boundary — blocks access outside cwd (default: true) */ + enforceWorkspaceRoot?: boolean; +} + +const TOOLS_WITH_PATH_PARAM = new Set(['read', 'write', 'edit', 'grep', 'find', 'ls']); + +function isPathWithinWorkspace(filePath: string, cwd: string): boolean { + const resolved = path.resolve(cwd, filePath); + const normalizedCwd = path.resolve(cwd); + return resolved.startsWith(normalizedCwd + path.sep) || resolved === normalizedCwd; +} + +interface PiModuleExports { + createCodingTools: (cwd: string) => PiAgentTool[]; + createGrepTool: (cwd: string, ...args: unknown[]) => PiAgentTool; + createFindTool: (cwd: string, ...args: unknown[]) => PiAgentTool; + createLsTool: (cwd: string, ...args: unknown[]) => PiAgentTool; +} + +let piModule: PiModuleExports | null = null; + +async function loadPiModule(): Promise { + if (piModule) return piModule; + const mod = (await import('@mariozechner/pi-coding-agent')) as Record; + piModule = { + createCodingTools: mod.createCodingTools as PiModuleExports['createCodingTools'], + createGrepTool: mod.createGrepTool as PiModuleExports['createGrepTool'], + createFindTool: mod.createFindTool as PiModuleExports['createFindTool'], + createLsTool: mod.createLsTool as PiModuleExports['createLsTool'], + }; + return piModule; +} + +function piParametersToJsonSchema(params: unknown): Record { + if (!params) return { type: 'object', properties: {} }; + if (typeof params !== 'object') return { type: 'object', properties: {} }; + + const p = params as Record; + // Pi uses TypeBox schemas — they compile to standard JSON Schema + // The 'properties' and 'type' fields should already be present + if (p.type === 'object' && p.properties) { + return p; + } + + // Fallback: wrap in an object schema + return { type: 'object', properties: p }; +} + +function formatToolResult(result: unknown): string { + if (result === null || result === undefined) { + return '(no output)'; + } + + // Pi tools return { content: [{ type: 'text', text: '...' }] } + if (typeof result === 'object' && result !== null) { + const r = result as PiToolResult; + if (Array.isArray(r.content)) { + return r.content + .filter((c) => c.type === 'text' && c.text) + .map((c) => c.text) + .join('\n'); + } + } + + if (typeof result === 'string') return result; + return JSON.stringify(result); +} + +/** + * Create Pi coding tools adapted for Ink's direct-api backend. + * + * Returns both the Anthropic.Tool schemas (for the API call) and + * execute functions (for handling tool_use responses). + */ +export async function createInkCodingTools( + config: PiCodingToolsConfig +): Promise { + const pi = await loadPiModule(); + + // createCodingTools gives us: read, bash, edit, write + // Add grep, find, ls individually for the full coding toolset + const rawTools: PiAgentTool[] = [ + ...pi.createCodingTools(config.cwd), + pi.createGrepTool(config.cwd), + pi.createFindTool(config.cwd), + pi.createLsTool(config.cwd), + ]; + + // Filter tools based on include/exclude + let tools = rawTools; + if (config.include) { + const includeSet = new Set(config.include); + tools = tools.filter((t) => includeSet.has(t.name as any)); + } + if (config.exclude) { + const excludeSet = new Set(config.exclude); + tools = tools.filter((t) => !excludeSet.has(t.name as any)); + } + + logger.info('Pi coding tools loaded', { + cwd: config.cwd, + tools: tools.map((t) => t.name), + }); + + const enforceRoot = config.enforceWorkspaceRoot !== false; + + return tools.map((tool) => ({ + schema: { + name: tool.name, + description: tool.description || `${tool.name} tool`, + input_schema: piParametersToJsonSchema(tool.parameters) as Anthropic.Tool.InputSchema, + }, + execute: async (params: Record, signal?: AbortSignal): Promise => { + // Workspace root enforcement for file-based tools + if (enforceRoot && TOOLS_WITH_PATH_PARAM.has(tool.name)) { + const filePath = (params.path as string) || ''; + if (filePath && !isPathWithinWorkspace(filePath, config.cwd)) { + return `Error: Access denied — path "${filePath}" is outside workspace root "${config.cwd}"`; + } + } + + const callId = `ink-${tool.name}-${Date.now()}`; + try { + const result = await tool.execute(callId, params, signal); + return formatToolResult(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error(`Pi tool ${tool.name} failed`, { error: message, params }); + return `Error: ${message}`; + } + }, + })); +} + +/** + * Convenience: get just the Anthropic.Tool schemas (for setTools). + */ +export async function getPiToolSchemas(config: PiCodingToolsConfig): Promise { + const tools = await createInkCodingTools(config); + return tools.map((t) => t.schema); +} + +/** + * Create a tool executor map for handling tool_use responses. + */ +export async function createPiToolExecutor( + config: PiCodingToolsConfig +): Promise> { + const tools = await createInkCodingTools(config); + const map = new Map(); + for (const tool of tools) { + map.set(tool.schema.name, tool.execute); + } + return map; +} diff --git a/packages/api/src/services/sessions/direct-api-runner.ts b/packages/api/src/services/sessions/direct-api-runner.ts new file mode 100644 index 00000000..60cb2d65 --- /dev/null +++ b/packages/api/src/services/sessions/direct-api-runner.ts @@ -0,0 +1,267 @@ +/** + * Direct API Runner + * + * Implements IRunner using the Anthropic API directly with Pi coding tools. + * Unlike CLI runners (Claude/Codex/Gemini), this calls the API in-process + * with a proper tool execution loop — tool results are fed back to continue + * the conversation until the model emits end_turn. + */ + +import Anthropic from '@anthropic-ai/sdk'; +import type { + InjectedContext, + ClaudeRunnerConfig, + RunnerResult, + ChannelResponse, + ChannelType, + IRunner, + ToolCall, +} from './types.js'; +import { formatInjectedContext } from './context-builder.js'; +import { buildIdentityPrompt } from './claude-runner.js'; +import { logger } from '../../utils/logger.js'; +import { + createInkCodingTools, + type InkToolDefinition, + type PiCodingToolsConfig, +} from '../../agent/tools/pi-coding-tools.js'; + +const MAX_TOOL_ITERATIONS = 50; +const DEFAULT_MODEL = 'claude-sonnet-4-20250514'; +const DEFAULT_MAX_TOKENS = 16384; + +export interface DirectApiRunnerConfig { + apiKey?: string; + model?: string; + maxTokens?: number; + /** Pi coding tools config — if omitted, tools are loaded with cwd from runner config */ + piToolsConfig?: Partial; + /** Additional tools to register alongside Pi coding tools */ + extraTools?: Anthropic.Tool[]; +} + +export class DirectApiRunner implements IRunner { + private client: Anthropic | null = null; + private runnerConfig: DirectApiRunnerConfig; + private toolsCache: Map = new Map(); + + constructor(config: DirectApiRunnerConfig = {}) { + this.runnerConfig = config; + } + + async run( + message: string, + options: { + backendSessionId?: string; + injectedContext?: InjectedContext; + config: ClaudeRunnerConfig; + } + ): Promise { + const { injectedContext, config } = options; + + this.ensureClient(); + + // Build system prompt + const systemPrompt = this.buildSystemPrompt(config, injectedContext); + + // Build user message with context injection (first turn only) + let fullMessage = message; + if (injectedContext && !options.backendSessionId) { + const contextBlock = formatInjectedContext(injectedContext); + fullMessage = `${contextBlock}\n\n---\n\n${message}`; + } + + // Load Pi coding tools scoped to the working directory + const tools = await this.getTools(config.workingDirectory); + const toolSchemas: Anthropic.Tool[] = tools.map((t) => t.schema); + if (this.runnerConfig.extraTools) { + toolSchemas.push(...this.runnerConfig.extraTools); + } + + // Build executor map for fast lookup + const executorMap = new Map(); + for (const tool of tools) { + executorMap.set(tool.schema.name, tool.execute); + } + + // Run the agentic loop + const messages: Anthropic.MessageParam[] = [{ role: 'user', content: fullMessage }]; + const responses: ChannelResponse[] = []; + const toolCalls: ToolCall[] = []; + let totalInputTokens = 0; + let totalOutputTokens = 0; + let finalTextResponse = ''; + let backendSessionId = options.backendSessionId || `direct-api-${Date.now()}`; + + for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) { + const response = await this.client!.messages.create({ + model: this.runnerConfig.model || config.model || DEFAULT_MODEL, + max_tokens: this.runnerConfig.maxTokens || DEFAULT_MAX_TOKENS, + system: systemPrompt, + messages, + tools: toolSchemas.length > 0 ? toolSchemas : undefined, + }); + + totalInputTokens += response.usage.input_tokens; + totalOutputTokens += response.usage.output_tokens; + + // Collect text and tool_use blocks + const textParts: string[] = []; + const toolUseBlocks: Anthropic.ToolUseBlock[] = []; + + for (const block of response.content) { + if (block.type === 'text') { + textParts.push(block.text); + } else if (block.type === 'tool_use') { + toolUseBlocks.push(block); + } + } + + if (textParts.length > 0) { + finalTextResponse = textParts.join(''); + } + + // Check for send_response in tool calls + for (const toolUse of toolUseBlocks) { + const input = toolUse.input as Record; + toolCalls.push({ + toolUseId: toolUse.id, + toolName: toolUse.name, + input, + }); + + if (toolUse.name === 'send_response' || toolUse.name === 'mcp__inkwell__send_response') { + const channel = (input.channel as ChannelType) || 'api'; + const conversationId = input.conversationId as string | undefined; + const content = input.content as string | undefined; + if (content) { + responses.push({ + channel, + conversationId: conversationId || '', + content, + format: input.format as ChannelResponse['format'], + }); + } + } + } + + // If no tool use, we're done + if (toolUseBlocks.length === 0 || response.stop_reason === 'end_turn') { + break; + } + + // Execute tools and build tool_result messages + // Add assistant turn with the full content (text + tool_use) + messages.push({ role: 'assistant', content: response.content }); + + const toolResults: Anthropic.ToolResultBlockParam[] = []; + for (const toolUse of toolUseBlocks) { + const executor = executorMap.get(toolUse.name); + let resultText: string; + + if (executor) { + try { + resultText = await executor(toolUse.input as Record); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + logger.error(`Direct API runner: tool ${toolUse.name} threw`, { error: errMsg }); + resultText = `Error: ${errMsg}`; + } + } else { + resultText = `Error: Tool "${toolUse.name}" not available in this runtime. Available tools: ${Array.from(executorMap.keys()).join(', ')}`; + logger.warn(`Direct API runner: unknown tool "${toolUse.name}" requested`); + } + + toolResults.push({ + type: 'tool_result', + tool_use_id: toolUse.id, + content: resultText, + }); + } + + // Add tool results as user turn + messages.push({ role: 'user', content: toolResults }); + + logger.debug('Direct API runner: tool iteration complete', { + iteration, + toolsExecuted: toolUseBlocks.map((t) => t.name), + }); + } + + return { + success: true, + backendSessionId, + responses, + usage: { + contextTokens: 0, + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + }, + finalTextResponse: finalTextResponse || undefined, + toolCalls, + }; + } + + private ensureClient(): void { + if (this.client) return; + const apiKey = this.runnerConfig.apiKey || process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + throw new Error('ANTHROPIC_API_KEY is required for Direct API runner'); + } + this.client = new Anthropic({ apiKey }); + } + + private async getTools(cwd: string): Promise { + if (this.toolsCache.has(cwd)) { + return this.toolsCache.get(cwd)!; + } + + const piConfig: PiCodingToolsConfig = { + cwd, + ...this.runnerConfig.piToolsConfig, + }; + + const tools = await createInkCodingTools(piConfig); + this.toolsCache.set(cwd, tools); + return tools; + } + + private buildSystemPrompt(config: ClaudeRunnerConfig, context?: InjectedContext): string { + const parts: string[] = []; + + // Identity prompt (same as CLI runners) + if (config.agentId && context?.agent) { + parts.push( + buildIdentityPrompt( + config.agentId, + context.agent.name, + context.agent.soul, + context.user?.timezone, + context.agent.heartbeat, + { + pcpSessionId: config.pcpSessionId, + studioId: config.studioId, + } + ) + ); + } + + // System prompt from config + if (config.systemPrompt) { + parts.push(config.systemPrompt); + } + if (config.appendSystemPrompt) { + parts.push(config.appendSystemPrompt); + } + + // Coding tools context + parts.push(`## Coding Tools + +You have filesystem access scoped to: ${config.workingDirectory} + +Available tools: read, write, edit, bash, grep, find, ls +All file paths are resolved relative to the working directory. Access outside this directory is blocked.`); + + return parts.join('\n\n'); + } +} diff --git a/packages/api/src/services/sessions/index.ts b/packages/api/src/services/sessions/index.ts index 15ef59c7..00916d45 100644 --- a/packages/api/src/services/sessions/index.ts +++ b/packages/api/src/services/sessions/index.ts @@ -25,6 +25,7 @@ export { ContextBuilder, formatInjectedContext } from './context-builder.js'; export { ClaudeRunner, buildIdentityPrompt } from './claude-runner.js'; export { CodexRunner } from './codex-runner.js'; export { GeminiRunner } from './gemini-runner.js'; +export { DirectApiRunner } from './direct-api-runner.js'; // Types export type { diff --git a/packages/api/src/services/sessions/session-service.ts b/packages/api/src/services/sessions/session-service.ts index a1d832b6..2a601702 100644 --- a/packages/api/src/services/sessions/session-service.ts +++ b/packages/api/src/services/sessions/session-service.ts @@ -33,6 +33,7 @@ import { ContextBuilder } from './context-builder.js'; import { ClaudeRunner, buildIdentityPrompt } from './claude-runner.js'; import { CodexRunner } from './codex-runner.js'; import { GeminiRunner } from './gemini-runner.js'; +import { DirectApiRunner } from './direct-api-runner.js'; import { ActivityStreamRepository } from '../../data/repositories/activity-stream.repository.js'; import { resolveIdentityId } from '../../auth/resolve-identity.js'; import { classifyError } from '@inklabs/shared'; @@ -134,6 +135,7 @@ export class SessionService implements ISessionService { private claudeRunner: IRunner; private codexRunner: IRunner; private geminiRunner: IRunner; + private directApiRunner: IRunner; private activityStream: IActivityStream; private config: SessionServiceConfig; private supabase: SupabaseClient | null; @@ -166,13 +168,15 @@ export class SessionService implements ISessionService { config: Partial = {}, codexRunner?: IRunner, supabase?: SupabaseClient, - geminiRunner?: IRunner + geminiRunner?: IRunner, + directApiRunner?: IRunner ) { this.repository = repository; this.contextBuilder = contextBuilder; this.claudeRunner = claudeRunner; this.codexRunner = codexRunner || claudeRunner; this.geminiRunner = geminiRunner || claudeRunner; + this.directApiRunner = directApiRunner || new DirectApiRunner(); this.activityStream = activityStream; this.config = { ...DEFAULT_CONFIG, ...config }; this.supabase = supabase || null; @@ -475,7 +479,9 @@ export class SessionService implements ISessionService { ? this.codexRunner : resolvedBackend === 'gemini' ? this.geminiRunner - : this.claudeRunner; + : resolvedBackend === 'direct-api' + ? this.directApiRunner + : this.claudeRunner; // 5a. Log backend spawn to activity stream (fire-and-forget) const triggerSource = metadata?.triggerType as string | undefined; @@ -1251,7 +1257,9 @@ This session will continue with a fresh context after compaction. Your identity, ? this.codexRunner : runtimeBackend === 'gemini' ? this.geminiRunner - : this.claudeRunner; + : runtimeBackend === 'direct-api' + ? this.directApiRunner + : this.claudeRunner; // Phase 1: Send compaction prompt — agent saves context, notifies users, ends session const result = await runner.run(compactionPrompt, { @@ -1290,10 +1298,13 @@ This session will continue with a fresh context after compaction. Your identity, /** * Normalize backend value to runtime backend IDs used by sessions. */ - private normalizeBackend(raw: string | null | undefined): 'claude-code' | 'codex-cli' | 'gemini' { + private normalizeBackend( + raw: string | null | undefined + ): 'claude-code' | 'codex-cli' | 'gemini' | 'direct-api' { const value = (raw || '').toLowerCase().trim(); if (value === 'codex' || value === 'codex-cli') return 'codex-cli'; if (value === 'gemini' || value === 'gemini-cli') return 'gemini'; + if (value === 'direct-api' || value === 'direct' || value === 'api') return 'direct-api'; if (value === 'claude' || value === 'claude-code' || value === '') return 'claude-code'; logger.warn('Unknown backend configured, falling back to claude-code', { raw }); return 'claude-code'; @@ -1305,7 +1316,7 @@ This session will continue with a fresh context after compaction. Your identity, private async resolveAgentBackend( userId: string, agentId: string - ): Promise<'claude-code' | 'codex-cli' | 'gemini'> { + ): Promise<'claude-code' | 'codex-cli' | 'gemini' | 'direct-api'> { try { const identityBackend = await this.contextBuilder.getAgentBackend(userId, agentId); return this.normalizeBackend(identityBackend); @@ -1325,7 +1336,7 @@ This session will continue with a fresh context after compaction. Your identity, private resolveRuntimeBackend( sessionBackend: string | null | undefined, identityBackend: string | null | undefined - ): 'claude-code' | 'codex-cli' | 'gemini' { + ): 'claude-code' | 'codex-cli' | 'gemini' | 'direct-api' { if (sessionBackend) return this.normalizeBackend(sessionBackend); return this.normalizeBackend(identityBackend); } @@ -1596,6 +1607,7 @@ export function createSessionService( config, new CodexRunner(), supabase, - new GeminiRunner() + new GeminiRunner(), + new DirectApiRunner() ); } diff --git a/yarn.lock b/yarn.lock index 890fb95a..8eaf0c47 100644 --- a/yarn.lock +++ b/yarn.lock @@ -38,6 +38,615 @@ __metadata: languageName: node linkType: hard +"@anthropic-ai/sdk@npm:^0.91.1": + version: 0.91.1 + resolution: "@anthropic-ai/sdk@npm:0.91.1" + dependencies: + json-schema-to-ts: "npm:^3.1.1" + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + bin: + anthropic-ai-sdk: bin/cli + checksum: 10/3ac357233f237e038af32a0a2d820e3531e35dc2e3b39864639fc42570bad6d1036630b49b6a75e9961ef31a9bcd338a3f262fbde60e4d13014683c40265756a + languageName: node + linkType: hard + +"@aws-crypto/crc32@npm:5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/crc32@npm:5.2.0" + dependencies: + "@aws-crypto/util": "npm:^5.2.0" + "@aws-sdk/types": "npm:^3.222.0" + tslib: "npm:^2.6.2" + checksum: 10/1b0a56ad4cb44c9512d8b1668dcf9306ab541d3a73829f435ca97abaec8d56f3db953db03ad0d0698754fea16fcd803d11fa42e0889bc7b803c6a030b04c63de + languageName: node + linkType: hard + +"@aws-crypto/sha256-browser@npm:5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/sha256-browser@npm:5.2.0" + dependencies: + "@aws-crypto/sha256-js": "npm:^5.2.0" + "@aws-crypto/supports-web-crypto": "npm:^5.2.0" + "@aws-crypto/util": "npm:^5.2.0" + "@aws-sdk/types": "npm:^3.222.0" + "@aws-sdk/util-locate-window": "npm:^3.0.0" + "@smithy/util-utf8": "npm:^2.0.0" + tslib: "npm:^2.6.2" + checksum: 10/2b1b701ca6caa876333b4eb2b96e5187d71ebb51ebf8e2d632690dbcdedeff038202d23adcc97e023437ed42bb1963b7b463e343687edf0635fd4b98b2edad1a + languageName: node + linkType: hard + +"@aws-crypto/sha256-js@npm:5.2.0, @aws-crypto/sha256-js@npm:^5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/sha256-js@npm:5.2.0" + dependencies: + "@aws-crypto/util": "npm:^5.2.0" + "@aws-sdk/types": "npm:^3.222.0" + tslib: "npm:^2.6.2" + checksum: 10/f46aace7b873c615be4e787ab0efd0148ef7de48f9f12c7d043e05c52e52b75bb0bf6dbcb9b2852d940d7724fab7b6d5ff1469160a3dd024efe7a68b5f70df8c + languageName: node + linkType: hard + +"@aws-crypto/supports-web-crypto@npm:^5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/supports-web-crypto@npm:5.2.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/6ed0c7e17f4f6663d057630805c45edb35d5693380c24ab52d4c453ece303c6c8a6ade9ee93c97dda77d9f6cae376ffbb44467057161c513dffa3422250edaf5 + languageName: node + linkType: hard + +"@aws-crypto/util@npm:^5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/util@npm:5.2.0" + dependencies: + "@aws-sdk/types": "npm:^3.222.0" + "@smithy/util-utf8": "npm:^2.0.0" + tslib: "npm:^2.6.2" + checksum: 10/f80a174c404e1ad4364741c942f440e75f834c08278fa754349fe23a6edc679d480ea9ced5820774aee58091ed270067022d8059ecf1a7ef452d58134ac7e9e1 + languageName: node + linkType: hard + +"@aws-sdk/client-bedrock-runtime@npm:^3.1030.0": + version: 3.1042.0 + resolution: "@aws-sdk/client-bedrock-runtime@npm:3.1042.0" + dependencies: + "@aws-crypto/sha256-browser": "npm:5.2.0" + "@aws-crypto/sha256-js": "npm:5.2.0" + "@aws-sdk/core": "npm:^3.974.8" + "@aws-sdk/credential-provider-node": "npm:^3.972.39" + "@aws-sdk/eventstream-handler-node": "npm:^3.972.14" + "@aws-sdk/middleware-eventstream": "npm:^3.972.10" + "@aws-sdk/middleware-host-header": "npm:^3.972.10" + "@aws-sdk/middleware-logger": "npm:^3.972.10" + "@aws-sdk/middleware-recursion-detection": "npm:^3.972.11" + "@aws-sdk/middleware-user-agent": "npm:^3.972.38" + "@aws-sdk/middleware-websocket": "npm:^3.972.16" + "@aws-sdk/region-config-resolver": "npm:^3.972.13" + "@aws-sdk/token-providers": "npm:3.1042.0" + "@aws-sdk/types": "npm:^3.973.8" + "@aws-sdk/util-endpoints": "npm:^3.996.8" + "@aws-sdk/util-user-agent-browser": "npm:^3.972.10" + "@aws-sdk/util-user-agent-node": "npm:^3.973.24" + "@smithy/config-resolver": "npm:^4.4.17" + "@smithy/core": "npm:^3.23.17" + "@smithy/eventstream-serde-browser": "npm:^4.2.14" + "@smithy/eventstream-serde-config-resolver": "npm:^4.3.14" + "@smithy/eventstream-serde-node": "npm:^4.2.14" + "@smithy/fetch-http-handler": "npm:^5.3.17" + "@smithy/hash-node": "npm:^4.2.14" + "@smithy/invalid-dependency": "npm:^4.2.14" + "@smithy/middleware-content-length": "npm:^4.2.14" + "@smithy/middleware-endpoint": "npm:^4.4.32" + "@smithy/middleware-retry": "npm:^4.5.7" + "@smithy/middleware-serde": "npm:^4.2.20" + "@smithy/middleware-stack": "npm:^4.2.14" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/node-http-handler": "npm:^4.6.1" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/smithy-client": "npm:^4.12.13" + "@smithy/types": "npm:^4.14.1" + "@smithy/url-parser": "npm:^4.2.14" + "@smithy/util-base64": "npm:^4.3.2" + "@smithy/util-body-length-browser": "npm:^4.2.2" + "@smithy/util-body-length-node": "npm:^4.2.3" + "@smithy/util-defaults-mode-browser": "npm:^4.3.49" + "@smithy/util-defaults-mode-node": "npm:^4.2.54" + "@smithy/util-endpoints": "npm:^3.4.2" + "@smithy/util-middleware": "npm:^4.2.14" + "@smithy/util-retry": "npm:^4.3.6" + "@smithy/util-stream": "npm:^4.5.25" + "@smithy/util-utf8": "npm:^4.2.2" + tslib: "npm:^2.6.2" + checksum: 10/714e225f988fa437a5622371bd9f0587160e3d4d632fb4921a2fd58bc71e33622a2f088c25a773565ae10f527393efff4b33391afe9d74e2a35add6a1c499667 + languageName: node + linkType: hard + +"@aws-sdk/core@npm:^3.974.8": + version: 3.974.8 + resolution: "@aws-sdk/core@npm:3.974.8" + dependencies: + "@aws-sdk/types": "npm:^3.973.8" + "@aws-sdk/xml-builder": "npm:^3.972.22" + "@smithy/core": "npm:^3.23.17" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/signature-v4": "npm:^5.3.14" + "@smithy/smithy-client": "npm:^4.12.13" + "@smithy/types": "npm:^4.14.1" + "@smithy/util-base64": "npm:^4.3.2" + "@smithy/util-middleware": "npm:^4.2.14" + "@smithy/util-retry": "npm:^4.3.6" + "@smithy/util-utf8": "npm:^4.2.2" + tslib: "npm:^2.6.2" + checksum: 10/7371738ba92353ebb9cdc753bf53240d7fec2486b8a87c191d11ff15386a19a4335ff7c28444f87825ad2643b1c41a491dfcb8dd9cd6e556eb352da5b7e05a9b + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-env@npm:^3.972.34": + version: 3.972.34 + resolution: "@aws-sdk/credential-provider-env@npm:3.972.34" + dependencies: + "@aws-sdk/core": "npm:^3.974.8" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/764a8accd62fa11dffc02354c98c7b3f4ce91b31b1c0baaecde5e10101fb60fb0ab25125e9ef1dd52cf681928e57f7da6afd4e9c63c8ec13632d38406e8ae2cb + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-http@npm:^3.972.36": + version: 3.972.36 + resolution: "@aws-sdk/credential-provider-http@npm:3.972.36" + dependencies: + "@aws-sdk/core": "npm:^3.974.8" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/fetch-http-handler": "npm:^5.3.17" + "@smithy/node-http-handler": "npm:^4.6.1" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/smithy-client": "npm:^4.12.13" + "@smithy/types": "npm:^4.14.1" + "@smithy/util-stream": "npm:^4.5.25" + tslib: "npm:^2.6.2" + checksum: 10/832699bb7075baca36530abe4fbcbde84e340ce3e24cfb4615be2bead459824944181644f625da576e92f25dc341cfabd713d8f3455cab55bc47be16eee2c1fe + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-ini@npm:^3.972.38": + version: 3.972.38 + resolution: "@aws-sdk/credential-provider-ini@npm:3.972.38" + dependencies: + "@aws-sdk/core": "npm:^3.974.8" + "@aws-sdk/credential-provider-env": "npm:^3.972.34" + "@aws-sdk/credential-provider-http": "npm:^3.972.36" + "@aws-sdk/credential-provider-login": "npm:^3.972.38" + "@aws-sdk/credential-provider-process": "npm:^3.972.34" + "@aws-sdk/credential-provider-sso": "npm:^3.972.38" + "@aws-sdk/credential-provider-web-identity": "npm:^3.972.38" + "@aws-sdk/nested-clients": "npm:^3.997.6" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/credential-provider-imds": "npm:^4.2.14" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/shared-ini-file-loader": "npm:^4.4.9" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/49c7faa65d961450bc62e883553b278d3b525fd9a31c1e60e9ed927034c78603d1f2456b1bccce10ac478672eea8a0fb31b76ab0adf7b68dd0cb362e841b48aa + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-login@npm:^3.972.38": + version: 3.972.38 + resolution: "@aws-sdk/credential-provider-login@npm:3.972.38" + dependencies: + "@aws-sdk/core": "npm:^3.974.8" + "@aws-sdk/nested-clients": "npm:^3.997.6" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/shared-ini-file-loader": "npm:^4.4.9" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/b03c35546b1ebffb2ba01d54131fb8bc5438a59f8a484ad3af3ebb42761cb9912d2771764dd60ab1a1a65fc64d61446fd3a519766f21354d2b0fda3d874a492e + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-node@npm:^3.972.39": + version: 3.972.39 + resolution: "@aws-sdk/credential-provider-node@npm:3.972.39" + dependencies: + "@aws-sdk/credential-provider-env": "npm:^3.972.34" + "@aws-sdk/credential-provider-http": "npm:^3.972.36" + "@aws-sdk/credential-provider-ini": "npm:^3.972.38" + "@aws-sdk/credential-provider-process": "npm:^3.972.34" + "@aws-sdk/credential-provider-sso": "npm:^3.972.38" + "@aws-sdk/credential-provider-web-identity": "npm:^3.972.38" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/credential-provider-imds": "npm:^4.2.14" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/shared-ini-file-loader": "npm:^4.4.9" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/b577b77075d4957d46f5bbc190af6f70dfc0a4acff8c8db73425b94aa2e84133393cf305febcc7cacdc7c7896e67697d2f53cbc2a454bf7ba011a830e7dd067f + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-process@npm:^3.972.34": + version: 3.972.34 + resolution: "@aws-sdk/credential-provider-process@npm:3.972.34" + dependencies: + "@aws-sdk/core": "npm:^3.974.8" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/shared-ini-file-loader": "npm:^4.4.9" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/184a83004066fe745e0aa36ec48480c9eadd13300f854100b6e71d7b14e3cc7254b16e28d5d7bcbc9c1a593ef3275d10b0accfba0b4c5f333f3e7be8850a5ccb + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-sso@npm:^3.972.38": + version: 3.972.38 + resolution: "@aws-sdk/credential-provider-sso@npm:3.972.38" + dependencies: + "@aws-sdk/core": "npm:^3.974.8" + "@aws-sdk/nested-clients": "npm:^3.997.6" + "@aws-sdk/token-providers": "npm:3.1041.0" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/shared-ini-file-loader": "npm:^4.4.9" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/2f1bd86399d46487901e6eee881afaf607e0aac459c6db1fceb342c225f96f726d8d73349f9cf9d5b2c5a1be6a9a373902aaae6a3e448260d0ebc80d1fe8f9a1 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-web-identity@npm:^3.972.38": + version: 3.972.38 + resolution: "@aws-sdk/credential-provider-web-identity@npm:3.972.38" + dependencies: + "@aws-sdk/core": "npm:^3.974.8" + "@aws-sdk/nested-clients": "npm:^3.997.6" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/shared-ini-file-loader": "npm:^4.4.9" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/9975bc20ab171365b73e2e4c9567bb07ffbace0bf5e0e2c6c052e5b99f06d5635c1ce6e0e5260bd62150eca933878ac5cbccb58b3f3058714ce93e3f93aa9649 + languageName: node + linkType: hard + +"@aws-sdk/eventstream-handler-node@npm:^3.972.14": + version: 3.972.14 + resolution: "@aws-sdk/eventstream-handler-node@npm:3.972.14" + dependencies: + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/eventstream-codec": "npm:^4.2.14" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/bf5038f4eccb933b6a9ae5230288278af337bc8988261cc68d261d0fd12994d3d81280b3d2a679c40eb1254457dd2d0eea771503ffbb6f0ffd5f4b89687c292b + languageName: node + linkType: hard + +"@aws-sdk/middleware-eventstream@npm:^3.972.10": + version: 3.972.10 + resolution: "@aws-sdk/middleware-eventstream@npm:3.972.10" + dependencies: + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/dc8f8f6331ac1e5599035cab9198441768f7edbd46b03189390775ee0e0eb09a66b26f4e7b9959c852b8609f3238f68fb68e2a749c5bcb7303ad5fe7c74c98e2 + languageName: node + linkType: hard + +"@aws-sdk/middleware-host-header@npm:^3.972.10": + version: 3.972.10 + resolution: "@aws-sdk/middleware-host-header@npm:3.972.10" + dependencies: + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/4098fa5f6d519adcc62e39bcc59ac46cf1478b7608b5137ca9e1e4d64acd8123ecc699627edccb06d755db84aea5077e5e4b1d34501efb6043bd0fe51e3c4695 + languageName: node + linkType: hard + +"@aws-sdk/middleware-logger@npm:^3.972.10": + version: 3.972.10 + resolution: "@aws-sdk/middleware-logger@npm:3.972.10" + dependencies: + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/a5ccf69d05be4288448d6285640b693c7c2182c63a8a8c4474c83b5c020011ae538ed6bf34928eea2ee9edae3a73b49f45b5db828d65f346e660609c8add739b + languageName: node + linkType: hard + +"@aws-sdk/middleware-recursion-detection@npm:^3.972.11": + version: 3.972.11 + resolution: "@aws-sdk/middleware-recursion-detection@npm:3.972.11" + dependencies: + "@aws-sdk/types": "npm:^3.973.8" + "@aws/lambda-invoke-store": "npm:^0.2.2" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/3ce52895d9414d6d791114cea5a38bcc83659ec0bfa79234a032d8337df6248715bdcfcc5a0c601e71ce21f31967bc05eb84c22af455309bf9d995eb8a6ad309 + languageName: node + linkType: hard + +"@aws-sdk/middleware-sdk-s3@npm:^3.972.37": + version: 3.972.37 + resolution: "@aws-sdk/middleware-sdk-s3@npm:3.972.37" + dependencies: + "@aws-sdk/core": "npm:^3.974.8" + "@aws-sdk/types": "npm:^3.973.8" + "@aws-sdk/util-arn-parser": "npm:^3.972.3" + "@smithy/core": "npm:^3.23.17" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/signature-v4": "npm:^5.3.14" + "@smithy/smithy-client": "npm:^4.12.13" + "@smithy/types": "npm:^4.14.1" + "@smithy/util-config-provider": "npm:^4.2.2" + "@smithy/util-middleware": "npm:^4.2.14" + "@smithy/util-stream": "npm:^4.5.25" + "@smithy/util-utf8": "npm:^4.2.2" + tslib: "npm:^2.6.2" + checksum: 10/8b5f71c1a62fb02f0acb70eb55592db2ffa8c5c0822fc03babe3d4321672cdcf13ad165d6b921f8312683014c3f1efcede0254790c0ec8bca3a3956fea4f67d8 + languageName: node + linkType: hard + +"@aws-sdk/middleware-user-agent@npm:^3.972.38": + version: 3.972.38 + resolution: "@aws-sdk/middleware-user-agent@npm:3.972.38" + dependencies: + "@aws-sdk/core": "npm:^3.974.8" + "@aws-sdk/types": "npm:^3.973.8" + "@aws-sdk/util-endpoints": "npm:^3.996.8" + "@smithy/core": "npm:^3.23.17" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/types": "npm:^4.14.1" + "@smithy/util-retry": "npm:^4.3.6" + tslib: "npm:^2.6.2" + checksum: 10/f1bfd65d5a49ac80d09c3309bf32e0c6c3362566232aaf5ab830044622efa89abeef0231ce6a03ab5f61c15a6ed22e62c6bf8a290dd4480e2330033eec6d9fef + languageName: node + linkType: hard + +"@aws-sdk/middleware-websocket@npm:^3.972.16": + version: 3.972.16 + resolution: "@aws-sdk/middleware-websocket@npm:3.972.16" + dependencies: + "@aws-sdk/types": "npm:^3.973.8" + "@aws-sdk/util-format-url": "npm:^3.972.10" + "@smithy/eventstream-codec": "npm:^4.2.14" + "@smithy/eventstream-serde-browser": "npm:^4.2.14" + "@smithy/fetch-http-handler": "npm:^5.3.17" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/signature-v4": "npm:^5.3.14" + "@smithy/types": "npm:^4.14.1" + "@smithy/util-base64": "npm:^4.3.2" + "@smithy/util-hex-encoding": "npm:^4.2.2" + "@smithy/util-utf8": "npm:^4.2.2" + tslib: "npm:^2.6.2" + checksum: 10/98803772fe93edab7cb26f6816e1f435148e74ac1a6f03eaa5f5ddbc7231b3ff7f6776f22a85948ce29093b809aa9d8f4483e9e2e93a4169251116f1f53000ab + languageName: node + linkType: hard + +"@aws-sdk/nested-clients@npm:^3.997.6": + version: 3.997.6 + resolution: "@aws-sdk/nested-clients@npm:3.997.6" + dependencies: + "@aws-crypto/sha256-browser": "npm:5.2.0" + "@aws-crypto/sha256-js": "npm:5.2.0" + "@aws-sdk/core": "npm:^3.974.8" + "@aws-sdk/middleware-host-header": "npm:^3.972.10" + "@aws-sdk/middleware-logger": "npm:^3.972.10" + "@aws-sdk/middleware-recursion-detection": "npm:^3.972.11" + "@aws-sdk/middleware-user-agent": "npm:^3.972.38" + "@aws-sdk/region-config-resolver": "npm:^3.972.13" + "@aws-sdk/signature-v4-multi-region": "npm:^3.996.25" + "@aws-sdk/types": "npm:^3.973.8" + "@aws-sdk/util-endpoints": "npm:^3.996.8" + "@aws-sdk/util-user-agent-browser": "npm:^3.972.10" + "@aws-sdk/util-user-agent-node": "npm:^3.973.24" + "@smithy/config-resolver": "npm:^4.4.17" + "@smithy/core": "npm:^3.23.17" + "@smithy/fetch-http-handler": "npm:^5.3.17" + "@smithy/hash-node": "npm:^4.2.14" + "@smithy/invalid-dependency": "npm:^4.2.14" + "@smithy/middleware-content-length": "npm:^4.2.14" + "@smithy/middleware-endpoint": "npm:^4.4.32" + "@smithy/middleware-retry": "npm:^4.5.7" + "@smithy/middleware-serde": "npm:^4.2.20" + "@smithy/middleware-stack": "npm:^4.2.14" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/node-http-handler": "npm:^4.6.1" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/smithy-client": "npm:^4.12.13" + "@smithy/types": "npm:^4.14.1" + "@smithy/url-parser": "npm:^4.2.14" + "@smithy/util-base64": "npm:^4.3.2" + "@smithy/util-body-length-browser": "npm:^4.2.2" + "@smithy/util-body-length-node": "npm:^4.2.3" + "@smithy/util-defaults-mode-browser": "npm:^4.3.49" + "@smithy/util-defaults-mode-node": "npm:^4.2.54" + "@smithy/util-endpoints": "npm:^3.4.2" + "@smithy/util-middleware": "npm:^4.2.14" + "@smithy/util-retry": "npm:^4.3.6" + "@smithy/util-utf8": "npm:^4.2.2" + tslib: "npm:^2.6.2" + checksum: 10/8467df064e288a3cd2f39d33ef3a63193bb40ac5ef54ef2265dc1b1d8801dbc29c442b75c85a29e6d02e2e43e8f84924dbb7933db8642bb37788c97634c2e814 + languageName: node + linkType: hard + +"@aws-sdk/region-config-resolver@npm:^3.972.13": + version: 3.972.13 + resolution: "@aws-sdk/region-config-resolver@npm:3.972.13" + dependencies: + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/config-resolver": "npm:^4.4.17" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/f80c26ecd5d93c1f2c8e05c15ac8e6cff2411f6739f19995d8262331ebf75247b20ee932c4e48b13a19976b56706559b8ba814e2a5f245949987ac3cd1b97ab0 + languageName: node + linkType: hard + +"@aws-sdk/signature-v4-multi-region@npm:^3.996.25": + version: 3.996.25 + resolution: "@aws-sdk/signature-v4-multi-region@npm:3.996.25" + dependencies: + "@aws-sdk/middleware-sdk-s3": "npm:^3.972.37" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/signature-v4": "npm:^5.3.14" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/1139b78872c18b65c3f4a3e6d38ba78bd4196129fb969bf0246034b41ac3387b9516f45734ed62e38eb1d564dcd1861ae633a2a50945ff279d67e4aeac503fa6 + languageName: node + linkType: hard + +"@aws-sdk/token-providers@npm:3.1041.0": + version: 3.1041.0 + resolution: "@aws-sdk/token-providers@npm:3.1041.0" + dependencies: + "@aws-sdk/core": "npm:^3.974.8" + "@aws-sdk/nested-clients": "npm:^3.997.6" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/shared-ini-file-loader": "npm:^4.4.9" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/bf101933d1daf6056fe92b40f88a65306fc3be17df834acbec4181f5eb781b8968f2aaf3e6f9be31ebf1302da001ae2ba44954b9c8071318ef8bed5d12d36cc9 + languageName: node + linkType: hard + +"@aws-sdk/token-providers@npm:3.1042.0": + version: 3.1042.0 + resolution: "@aws-sdk/token-providers@npm:3.1042.0" + dependencies: + "@aws-sdk/core": "npm:^3.974.8" + "@aws-sdk/nested-clients": "npm:^3.997.6" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/shared-ini-file-loader": "npm:^4.4.9" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/1a313a54a66ec87a759065299e8f4a3fb1fc9f059c0c999f537abc0feba5e2070025552034c9f128788ee815eaa821d092dd178d9794c88f1fc8d08131deac6c + languageName: node + linkType: hard + +"@aws-sdk/types@npm:^3.222.0, @aws-sdk/types@npm:^3.973.8": + version: 3.973.8 + resolution: "@aws-sdk/types@npm:3.973.8" + dependencies: + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/76f613d0dfcb9fd3f66aa39aaba81d9d0a0e20d09ad1c8dff9c0c990c69f5d5ee758dfbbb4cf7445ed0d30f96454369282ede7a4a9e64b7e7be95c7b5e34ebc3 + languageName: node + linkType: hard + +"@aws-sdk/util-arn-parser@npm:^3.972.3": + version: 3.972.3 + resolution: "@aws-sdk/util-arn-parser@npm:3.972.3" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/140a30615c914bcb37a5bb6ff825e8b6d2bedea757c2b03a4f5abb986003683ceadc322c0ee9f9a3ba4d5925357515ed7be01ef13c56c3b0126d4e1bd7292a33 + languageName: node + linkType: hard + +"@aws-sdk/util-endpoints@npm:^3.996.8": + version: 3.996.8 + resolution: "@aws-sdk/util-endpoints@npm:3.996.8" + dependencies: + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/types": "npm:^4.14.1" + "@smithy/url-parser": "npm:^4.2.14" + "@smithy/util-endpoints": "npm:^3.4.2" + tslib: "npm:^2.6.2" + checksum: 10/9b95fbe21751616f3b1453060dbf3db6350acc9dcb8490acfa53a161d3c7a96acc0297531613b4d2067c602fbb097b8f37c72bd7aafd803ab5589ab35c61a6be + languageName: node + linkType: hard + +"@aws-sdk/util-format-url@npm:^3.972.10": + version: 3.972.10 + resolution: "@aws-sdk/util-format-url@npm:3.972.10" + dependencies: + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/querystring-builder": "npm:^4.2.14" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/57698e7df25da153fee7b2b98ece6009d139faa4fb81bb4fa51455a462c1bd2696ba9b451a7d7f14e9a34b28b6c01e3f191458dcd3d7ca841e5ff91e59e799b1 + languageName: node + linkType: hard + +"@aws-sdk/util-locate-window@npm:^3.0.0": + version: 3.965.5 + resolution: "@aws-sdk/util-locate-window@npm:3.965.5" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/66391a7f6d0c383d6bc3ea67e35b0b0164798d9acbe47271fbc676cf74e7a56690f48425a91cce70e764c53ca46619f4abc076b569d8f991c19dd7c1ac4b0a79 + languageName: node + linkType: hard + +"@aws-sdk/util-user-agent-browser@npm:^3.972.10": + version: 3.972.10 + resolution: "@aws-sdk/util-user-agent-browser@npm:3.972.10" + dependencies: + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/types": "npm:^4.14.1" + bowser: "npm:^2.11.0" + tslib: "npm:^2.6.2" + checksum: 10/dc76c0ede53607e7d98aff0f25a766dfa8f8a73ef974cd6976bf8d07d908effb88f1a374af44ce28c4babb0564e63207f4650e2a8b755dd9188c3a485b69f1ec + languageName: node + linkType: hard + +"@aws-sdk/util-user-agent-node@npm:^3.973.24": + version: 3.973.24 + resolution: "@aws-sdk/util-user-agent-node@npm:3.973.24" + dependencies: + "@aws-sdk/middleware-user-agent": "npm:^3.972.38" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/types": "npm:^4.14.1" + "@smithy/util-config-provider": "npm:^4.2.2" + tslib: "npm:^2.6.2" + peerDependencies: + aws-crt: ">=1.0.0" + peerDependenciesMeta: + aws-crt: + optional: true + checksum: 10/e94edde07b98f4ebae95a30a5f2b17514faba5643d596344d142457d7d827c34d7e9217308a479dab89cb95eed9aaadd1fa9479bb087b343d73541eb0b8abba2 + languageName: node + linkType: hard + +"@aws-sdk/xml-builder@npm:^3.972.22": + version: 3.972.22 + resolution: "@aws-sdk/xml-builder@npm:3.972.22" + dependencies: + "@nodable/entities": "npm:2.1.0" + "@smithy/types": "npm:^4.14.1" + fast-xml-parser: "npm:5.7.2" + tslib: "npm:^2.6.2" + checksum: 10/54032fdf33434cdefcecd374747c0cb16f1e13ee0237a089fdb49c2f01758fbb55c1ba22457cae86223af9abfdf54727099cbe67416af1664bc6e290e10f0b0d + languageName: node + linkType: hard + +"@aws/lambda-invoke-store@npm:^0.2.2": + version: 0.2.4 + resolution: "@aws/lambda-invoke-store@npm:0.2.4" + checksum: 10/47e73cf73141be73854c69722502e928a435b3d908ffa693a9545c1099dd7b2dd3f67c43c523d786a75911100e77ed52dce1f88d09363a67526448c5b7c804d5 + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": version: 7.29.0 resolution: "@babel/code-frame@npm:7.29.0" @@ -930,6 +1539,23 @@ __metadata: languageName: node linkType: hard +"@google/genai@npm:^1.40.0": + version: 1.52.0 + resolution: "@google/genai@npm:1.52.0" + dependencies: + google-auth-library: "npm:^10.3.0" + p-retry: "npm:^4.6.2" + protobufjs: "npm:^7.5.4" + ws: "npm:^8.18.0" + peerDependencies: + "@modelcontextprotocol/sdk": ^1.25.2 + peerDependenciesMeta: + "@modelcontextprotocol/sdk": + optional: true + checksum: 10/4815bb7198910e20bc1976cb9c649d31c0d90b9352c5a433fc60a8906c74cd4022768daf75a7bdf86daa73beb9d0a6e426efbc2e5d6ed02df5c21ea63d335eae + languageName: node + linkType: hard + "@hapi/boom@npm:^9.1.3": version: 9.1.4 resolution: "@hapi/boom@npm:9.1.4" @@ -947,11 +1573,11 @@ __metadata: linkType: hard "@hono/node-server@npm:^1.19.9": - version: 1.19.13 - resolution: "@hono/node-server@npm:1.19.13" + version: 1.19.11 + resolution: "@hono/node-server@npm:1.19.11" peerDependencies: hono: ^4 - checksum: 10/67e453b0f6c0854244f3985eecbcf657b7d0247eb9404f2f507fec9242d8f8077ab8b0a212de1ac147290e76167e3598ca0df38940f6b63bde67f58526aba253 + checksum: 10/1718910924944bfa2f2649ae102fbdaee42e388ed373f1e36bb2674447b9f1a64a9344908c046c6100e6c387fe2d1dab5c95684cb3a415ba0a0b719fbef0a6cb languageName: node linkType: hard @@ -1213,6 +1839,8 @@ __metadata: dependencies: "@anthropic-ai/sdk": "npm:^0.71.2" "@inklabs/shared": "workspace:*" + "@mariozechner/pi-agent-core": "npm:0.71.1" + "@mariozechner/pi-coding-agent": "npm:0.71.1" "@modelcontextprotocol/sdk": "npm:^1.26.0" "@slack/bolt": "npm:^4.6.0" "@supabase/supabase-js": "npm:^2.39.3" @@ -2004,125 +2632,347 @@ __metadata: languageName: node linkType: hard -"@modelcontextprotocol/sdk@npm:^1.12.1, @modelcontextprotocol/sdk@npm:^1.26.0": - version: 1.27.1 - resolution: "@modelcontextprotocol/sdk@npm:1.27.1" - dependencies: - "@hono/node-server": "npm:^1.19.9" - ajv: "npm:^8.17.1" - ajv-formats: "npm:^3.0.1" - content-type: "npm:^1.0.5" - cors: "npm:^2.8.5" - cross-spawn: "npm:^7.0.5" - eventsource: "npm:^3.0.2" - eventsource-parser: "npm:^3.0.0" - express: "npm:^5.2.1" - express-rate-limit: "npm:^8.2.1" - hono: "npm:^4.11.4" - jose: "npm:^6.1.3" - json-schema-typed: "npm:^8.0.2" - pkce-challenge: "npm:^5.0.0" - raw-body: "npm:^3.0.0" - zod: "npm:^3.25 || ^4.0" - zod-to-json-schema: "npm:^3.25.1" - peerDependencies: - "@cfworker/json-schema": ^4.1.1 - zod: ^3.25 || ^4.0 - peerDependenciesMeta: - "@cfworker/json-schema": - optional: true - zod: - optional: false - checksum: 10/3cb0d61cfb916e555c85b4a527e772f88fcf9c6abacbe5eb5e965aac7c898190c416341ab3b3cba8c2d5f5ce4d513279fba3ad7784a0903d7ccd335decc55395 +"@mariozechner/clipboard-darwin-arm64@npm:0.3.2": + version: 0.3.2 + resolution: "@mariozechner/clipboard-darwin-arm64@npm:0.3.2" + conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@napi-rs/wasm-runtime@npm:^1.1.1": - version: 1.1.1 - resolution: "@napi-rs/wasm-runtime@npm:1.1.1" - dependencies: - "@emnapi/core": "npm:^1.7.1" - "@emnapi/runtime": "npm:^1.7.1" - "@tybys/wasm-util": "npm:^0.10.1" - checksum: 10/080e7f2aefb84e09884d21c650a2cbafdf25bfd2634693791b27e36eec0ddaa3c1656a943f8c913ac75879a0b04e68f8a827897ee655ab54a93169accf05b194 +"@mariozechner/clipboard-darwin-universal@npm:0.3.2": + version: 0.3.2 + resolution: "@mariozechner/clipboard-darwin-universal@npm:0.3.2" + conditions: os=darwin languageName: node linkType: hard -"@napi-rs/wasm-runtime@npm:^1.1.4": - version: 1.1.4 - resolution: "@napi-rs/wasm-runtime@npm:1.1.4" - dependencies: - "@tybys/wasm-util": "npm:^0.10.1" - peerDependencies: - "@emnapi/core": ^1.7.1 - "@emnapi/runtime": ^1.7.1 - checksum: 10/1db3dc7eeb981306b09360487bd8ce4dfa5588d273bd8ea9f07dccca1b4ade57b675414180fc9bb66966c6c50b17208b0263194993e2f7f92cc7af28bda4d1af +"@mariozechner/clipboard-darwin-x64@npm:0.3.2": + version: 0.3.2 + resolution: "@mariozechner/clipboard-darwin-x64@npm:0.3.2" + conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@next/env@npm:16.2.3": - version: 16.2.3 - resolution: "@next/env@npm:16.2.3" - checksum: 10/30ed128d8ffae47e58732ee134b78da36e2d6942da7479ec5e640d205b7822224daf2f07d7a69352dc362908eb260fc9fa7eaba1ce5e6311abeacc6ffb0fe90a +"@mariozechner/clipboard-linux-arm64-gnu@npm:0.3.2": + version: 0.3.2 + resolution: "@mariozechner/clipboard-linux-arm64-gnu@npm:0.3.2" + conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@next/swc-darwin-arm64@npm:16.2.3": - version: 16.2.3 - resolution: "@next/swc-darwin-arm64@npm:16.2.3" - conditions: os=darwin & cpu=arm64 +"@mariozechner/clipboard-linux-arm64-musl@npm:0.3.2": + version: 0.3.2 + resolution: "@mariozechner/clipboard-linux-arm64-musl@npm:0.3.2" + conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@next/swc-darwin-x64@npm:16.2.3": - version: 16.2.3 - resolution: "@next/swc-darwin-x64@npm:16.2.3" - conditions: os=darwin & cpu=x64 +"@mariozechner/clipboard-linux-riscv64-gnu@npm:0.3.2": + version: 0.3.2 + resolution: "@mariozechner/clipboard-linux-riscv64-gnu@npm:0.3.2" + conditions: os=linux & cpu=riscv64 & libc=glibc languageName: node linkType: hard -"@next/swc-linux-arm64-gnu@npm:16.2.3": - version: 16.2.3 - resolution: "@next/swc-linux-arm64-gnu@npm:16.2.3" - conditions: os=linux & cpu=arm64 & libc=glibc +"@mariozechner/clipboard-linux-x64-gnu@npm:0.3.2": + version: 0.3.2 + resolution: "@mariozechner/clipboard-linux-x64-gnu@npm:0.3.2" + conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@next/swc-linux-arm64-musl@npm:16.2.3": - version: 16.2.3 - resolution: "@next/swc-linux-arm64-musl@npm:16.2.3" - conditions: os=linux & cpu=arm64 & libc=musl +"@mariozechner/clipboard-linux-x64-musl@npm:0.3.2": + version: 0.3.2 + resolution: "@mariozechner/clipboard-linux-x64-musl@npm:0.3.2" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@mariozechner/clipboard-win32-arm64-msvc@npm:0.3.2": + version: 0.3.2 + resolution: "@mariozechner/clipboard-win32-arm64-msvc@npm:0.3.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@mariozechner/clipboard-win32-x64-msvc@npm:0.3.2": + version: 0.3.2 + resolution: "@mariozechner/clipboard-win32-x64-msvc@npm:0.3.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@mariozechner/clipboard@npm:^0.3.5": + version: 0.3.5 + resolution: "@mariozechner/clipboard@npm:0.3.5" + dependencies: + "@mariozechner/clipboard-darwin-arm64": "npm:0.3.2" + "@mariozechner/clipboard-darwin-universal": "npm:0.3.2" + "@mariozechner/clipboard-darwin-x64": "npm:0.3.2" + "@mariozechner/clipboard-linux-arm64-gnu": "npm:0.3.2" + "@mariozechner/clipboard-linux-arm64-musl": "npm:0.3.2" + "@mariozechner/clipboard-linux-riscv64-gnu": "npm:0.3.2" + "@mariozechner/clipboard-linux-x64-gnu": "npm:0.3.2" + "@mariozechner/clipboard-linux-x64-musl": "npm:0.3.2" + "@mariozechner/clipboard-win32-arm64-msvc": "npm:0.3.2" + "@mariozechner/clipboard-win32-x64-msvc": "npm:0.3.2" + dependenciesMeta: + "@mariozechner/clipboard-darwin-arm64": + optional: true + "@mariozechner/clipboard-darwin-universal": + optional: true + "@mariozechner/clipboard-darwin-x64": + optional: true + "@mariozechner/clipboard-linux-arm64-gnu": + optional: true + "@mariozechner/clipboard-linux-arm64-musl": + optional: true + "@mariozechner/clipboard-linux-riscv64-gnu": + optional: true + "@mariozechner/clipboard-linux-x64-gnu": + optional: true + "@mariozechner/clipboard-linux-x64-musl": + optional: true + "@mariozechner/clipboard-win32-arm64-msvc": + optional: true + "@mariozechner/clipboard-win32-x64-msvc": + optional: true + checksum: 10/fc69ef49308b476cd05e02b6a1d92d97a9a4dffba3a4976c8eea69b64234e73db09ec251083fda04c72336e0760c20bef3ae79fe07e9c13b6fe34586e80ec24a languageName: node linkType: hard -"@next/swc-linux-x64-gnu@npm:16.2.3": - version: 16.2.3 - resolution: "@next/swc-linux-x64-gnu@npm:16.2.3" +"@mariozechner/jiti@npm:^2.6.2": + version: 2.6.5 + resolution: "@mariozechner/jiti@npm:2.6.5" + dependencies: + std-env: "npm:^3.10.0" + yoctocolors: "npm:^2.1.2" + bin: + jiti: lib/jiti-cli.mjs + checksum: 10/53e94727a025d722a1d624c8f0ebde0fff4dcc768e9ea79f0a17e42a1056864da7568d997022c60832b083ebd69914af5244577bf5873329be98134d5977a489 + languageName: node + linkType: hard + +"@mariozechner/pi-agent-core@npm:0.71.1, @mariozechner/pi-agent-core@npm:^0.71.1": + version: 0.71.1 + resolution: "@mariozechner/pi-agent-core@npm:0.71.1" + dependencies: + "@mariozechner/pi-ai": "npm:^0.71.1" + typebox: "npm:^1.1.24" + checksum: 10/f253ae61a39becb5b32956263fa58a236c153a196828527ea1d157cb86c6e626be33e88596e52713963e8e5e4cf261dda1c56746991aab09135ffd60d7978c1d + languageName: node + linkType: hard + +"@mariozechner/pi-ai@npm:^0.71.1": + version: 0.71.1 + resolution: "@mariozechner/pi-ai@npm:0.71.1" + dependencies: + "@anthropic-ai/sdk": "npm:^0.91.1" + "@aws-sdk/client-bedrock-runtime": "npm:^3.1030.0" + "@google/genai": "npm:^1.40.0" + "@mistralai/mistralai": "npm:^2.2.0" + chalk: "npm:^5.6.2" + openai: "npm:6.26.0" + partial-json: "npm:^0.1.7" + proxy-agent: "npm:^6.5.0" + typebox: "npm:^1.1.24" + undici: "npm:^7.19.1" + zod-to-json-schema: "npm:^3.24.6" + bin: + pi-ai: dist/cli.js + checksum: 10/121448e263bf5f9949c2265335f823d0161ba58cd938e248ed475eae2ce2f930510b3e3edd5a903c69f93a07f353bffc343b6cd2c89fbc5da1c0b5774d16260e + languageName: node + linkType: hard + +"@mariozechner/pi-coding-agent@npm:0.71.1": + version: 0.71.1 + resolution: "@mariozechner/pi-coding-agent@npm:0.71.1" + dependencies: + "@mariozechner/clipboard": "npm:^0.3.5" + "@mariozechner/jiti": "npm:^2.6.2" + "@mariozechner/pi-agent-core": "npm:^0.71.1" + "@mariozechner/pi-ai": "npm:^0.71.1" + "@mariozechner/pi-tui": "npm:^0.71.1" + "@silvia-odwyer/photon-node": "npm:^0.3.4" + chalk: "npm:^5.5.0" + cli-highlight: "npm:^2.1.11" + diff: "npm:^8.0.2" + extract-zip: "npm:^2.0.1" + file-type: "npm:^21.1.1" + glob: "npm:^13.0.1" + hosted-git-info: "npm:^9.0.2" + ignore: "npm:^7.0.5" + marked: "npm:^15.0.12" + minimatch: "npm:^10.2.3" + proper-lockfile: "npm:^4.1.2" + strip-ansi: "npm:^7.1.0" + typebox: "npm:^1.1.24" + undici: "npm:^7.19.1" + uuid: "npm:^14.0.0" + yaml: "npm:^2.8.2" + dependenciesMeta: + "@mariozechner/clipboard": + optional: true + bin: + pi: dist/cli.js + checksum: 10/ac868029019b9b4baf7e555a8ab29e8210b9163cd8bc203cfb70cdd8dffbb003c2c81c3b8c11ea6fabd1c80a33a4691efdf4bc33814fd171854724a423fa1cdd + languageName: node + linkType: hard + +"@mariozechner/pi-tui@npm:^0.71.1": + version: 0.71.1 + resolution: "@mariozechner/pi-tui@npm:0.71.1" + dependencies: + "@types/mime-types": "npm:^2.1.4" + chalk: "npm:^5.5.0" + get-east-asian-width: "npm:^1.3.0" + koffi: "npm:^2.9.0" + marked: "npm:^15.0.12" + mime-types: "npm:^3.0.1" + dependenciesMeta: + koffi: + optional: true + checksum: 10/ab29d89f6a6b678bb46d86adf91fc9947296c4054cef1790234bcb18b4997a46e5d9d78cc0cd29f7b2d9b2bd2796ee1d5d2d9f10fcf63d7cd9dad7514335a23d + languageName: node + linkType: hard + +"@mistralai/mistralai@npm:^2.2.0": + version: 2.2.1 + resolution: "@mistralai/mistralai@npm:2.2.1" + dependencies: + ws: "npm:^8.18.0" + zod: "npm:^3.25.0 || ^4.0.0" + zod-to-json-schema: "npm:^3.25.0" + checksum: 10/0a62da698b116b4d4ddfeb9d299774a1c9f113949e95d64b11a85b9e76fafd5f4713cf4c3ed70a75f88e34de5b7aaa14988e969290e554c089c9470c05c583cb + languageName: node + linkType: hard + +"@modelcontextprotocol/sdk@npm:^1.12.1, @modelcontextprotocol/sdk@npm:^1.26.0": + version: 1.27.1 + resolution: "@modelcontextprotocol/sdk@npm:1.27.1" + dependencies: + "@hono/node-server": "npm:^1.19.9" + ajv: "npm:^8.17.1" + ajv-formats: "npm:^3.0.1" + content-type: "npm:^1.0.5" + cors: "npm:^2.8.5" + cross-spawn: "npm:^7.0.5" + eventsource: "npm:^3.0.2" + eventsource-parser: "npm:^3.0.0" + express: "npm:^5.2.1" + express-rate-limit: "npm:^8.2.1" + hono: "npm:^4.11.4" + jose: "npm:^6.1.3" + json-schema-typed: "npm:^8.0.2" + pkce-challenge: "npm:^5.0.0" + raw-body: "npm:^3.0.0" + zod: "npm:^3.25 || ^4.0" + zod-to-json-schema: "npm:^3.25.1" + peerDependencies: + "@cfworker/json-schema": ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + "@cfworker/json-schema": + optional: true + zod: + optional: false + checksum: 10/3cb0d61cfb916e555c85b4a527e772f88fcf9c6abacbe5eb5e965aac7c898190c416341ab3b3cba8c2d5f5ce4d513279fba3ad7784a0903d7ccd335decc55395 + languageName: node + linkType: hard + +"@napi-rs/wasm-runtime@npm:^1.1.1": + version: 1.1.1 + resolution: "@napi-rs/wasm-runtime@npm:1.1.1" + dependencies: + "@emnapi/core": "npm:^1.7.1" + "@emnapi/runtime": "npm:^1.7.1" + "@tybys/wasm-util": "npm:^0.10.1" + checksum: 10/080e7f2aefb84e09884d21c650a2cbafdf25bfd2634693791b27e36eec0ddaa3c1656a943f8c913ac75879a0b04e68f8a827897ee655ab54a93169accf05b194 + languageName: node + linkType: hard + +"@napi-rs/wasm-runtime@npm:^1.1.4": + version: 1.1.4 + resolution: "@napi-rs/wasm-runtime@npm:1.1.4" + dependencies: + "@tybys/wasm-util": "npm:^0.10.1" + peerDependencies: + "@emnapi/core": ^1.7.1 + "@emnapi/runtime": ^1.7.1 + checksum: 10/1db3dc7eeb981306b09360487bd8ce4dfa5588d273bd8ea9f07dccca1b4ade57b675414180fc9bb66966c6c50b17208b0263194993e2f7f92cc7af28bda4d1af + languageName: node + linkType: hard + +"@next/env@npm:16.2.5": + version: 16.2.5 + resolution: "@next/env@npm:16.2.5" + checksum: 10/54e3b113f9f758e58b47bc4c4d927a6dac9561696c667a2e39e2f39df567925798b88ce6f53f0fe76e2ccc41446a567307bafcf533827e4d20d1d419f86e0d77 + languageName: node + linkType: hard + +"@next/swc-darwin-arm64@npm:16.2.5": + version: 16.2.5 + resolution: "@next/swc-darwin-arm64@npm:16.2.5" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@next/swc-darwin-x64@npm:16.2.5": + version: 16.2.5 + resolution: "@next/swc-darwin-x64@npm:16.2.5" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@next/swc-linux-arm64-gnu@npm:16.2.5": + version: 16.2.5 + resolution: "@next/swc-linux-arm64-gnu@npm:16.2.5" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@next/swc-linux-arm64-musl@npm:16.2.5": + version: 16.2.5 + resolution: "@next/swc-linux-arm64-musl@npm:16.2.5" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@next/swc-linux-x64-gnu@npm:16.2.5": + version: 16.2.5 + resolution: "@next/swc-linux-x64-gnu@npm:16.2.5" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@next/swc-linux-x64-musl@npm:16.2.3": - version: 16.2.3 - resolution: "@next/swc-linux-x64-musl@npm:16.2.3" +"@next/swc-linux-x64-musl@npm:16.2.5": + version: 16.2.5 + resolution: "@next/swc-linux-x64-musl@npm:16.2.5" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@next/swc-win32-arm64-msvc@npm:16.2.3": - version: 16.2.3 - resolution: "@next/swc-win32-arm64-msvc@npm:16.2.3" +"@next/swc-win32-arm64-msvc@npm:16.2.5": + version: 16.2.5 + resolution: "@next/swc-win32-arm64-msvc@npm:16.2.5" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@next/swc-win32-x64-msvc@npm:16.2.3": - version: 16.2.3 - resolution: "@next/swc-win32-x64-msvc@npm:16.2.3" +"@next/swc-win32-x64-msvc@npm:16.2.5": + version: 16.2.5 + resolution: "@next/swc-win32-x64-msvc@npm:16.2.5" conditions: os=win32 & cpu=x64 languageName: node linkType: hard +"@nodable/entities@npm:2.1.0, @nodable/entities@npm:^2.1.0": + version: 2.1.0 + resolution: "@nodable/entities@npm:2.1.0" + checksum: 10/355c55e82aebe45d4b962d16530951df51e19e3e63a27ea61ad3260c0807064619b270b9c83db10e8394f42760abd5b7f7c5b5117678c4246ce8364a4aafc637 + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -2193,10 +3043,10 @@ __metadata: languageName: node linkType: hard -"@oxc-project/types@npm:=0.127.0": - version: 0.127.0 - resolution: "@oxc-project/types@npm:0.127.0" - checksum: 10/f154f4720367186aed63a16fb1395f9039d4e6872265fe9e6b5eacc02fb2b948f9ea6c5f85efd3a015ea28aa8c31232b7a8301218ae28651659e46dd0c4f2031 +"@oxc-project/types@npm:=0.128.0": + version: 0.128.0 + resolution: "@oxc-project/types@npm:0.128.0" + checksum: 10/cf1479772d22902ee5a0a6fda74f71cce2a569d084f9e26b524972f3145439b2e514ffc92de518757c0502bf5d2d26cb1de99e4c56bd2c57f54fcd4179e9c730 languageName: node linkType: hard @@ -2228,6 +3078,13 @@ __metadata: languageName: node linkType: hard +"@protobufjs/codegen@npm:^2.0.5": + version: 2.0.5 + resolution: "@protobufjs/codegen@npm:2.0.5" + checksum: 10/290335fa114f26202abc0695f279d53e2fd516b01cfd8298923591e0bda011295ff40e3582a1cda0a0f27cbc5039a0292082d5ad08872bb5d6243a614ac15c88 + languageName: node + linkType: hard + "@protobufjs/eventemitter@npm:^1.1.0": version: 1.1.0 resolution: "@protobufjs/eventemitter@npm:1.1.0" @@ -2259,6 +3116,13 @@ __metadata: languageName: node linkType: hard +"@protobufjs/inquire@npm:^1.1.1": + version: 1.1.1 + resolution: "@protobufjs/inquire@npm:1.1.1" + checksum: 10/504740e8ac348f70b33bcf6a20c83d5b9679901654c1a96b18c0491ec2f2f7ac580e74019b6d1bce16113bfb9746bc6e7dfd4e12a717deed699675b7f230ce9e + languageName: node + linkType: hard + "@protobufjs/path@npm:^1.1.2": version: 1.1.2 resolution: "@protobufjs/path@npm:1.1.2" @@ -2280,6 +3144,13 @@ __metadata: languageName: node linkType: hard +"@protobufjs/utf8@npm:^1.1.1": + version: 1.1.1 + resolution: "@protobufjs/utf8@npm:1.1.1" + checksum: 10/ed0c3f9ff1afd602a0aed54c4c03a0b8f641686a5587d8949e088dcac653fb2019d15691ed92eef23dfdf9f4293249532d0508ecd15cef810acf026917719a19 + languageName: node + linkType: hard + "@radix-ui/primitive@npm:1.1.3": version: 1.1.3 resolution: "@radix-ui/primitive@npm:1.1.3" @@ -2673,9 +3544,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-android-arm64@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-android-arm64@npm:1.0.0-rc.17" +"@rolldown/binding-android-arm64@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-android-arm64@npm:1.0.0-rc.18" conditions: os=android & cpu=arm64 languageName: node linkType: hard @@ -2687,9 +3558,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-darwin-arm64@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-darwin-arm64@npm:1.0.0-rc.17" +"@rolldown/binding-darwin-arm64@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-darwin-arm64@npm:1.0.0-rc.18" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard @@ -2701,9 +3572,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-darwin-x64@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-darwin-x64@npm:1.0.0-rc.17" +"@rolldown/binding-darwin-x64@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-darwin-x64@npm:1.0.0-rc.18" conditions: os=darwin & cpu=x64 languageName: node linkType: hard @@ -2715,9 +3586,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-freebsd-x64@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-freebsd-x64@npm:1.0.0-rc.17" +"@rolldown/binding-freebsd-x64@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-freebsd-x64@npm:1.0.0-rc.18" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard @@ -2729,9 +3600,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.17" +"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.18" conditions: os=linux & cpu=arm languageName: node linkType: hard @@ -2743,9 +3614,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.17" +"@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.18" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard @@ -2757,9 +3628,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.17" +"@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.18" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard @@ -2771,9 +3642,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.17" +"@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.18" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard @@ -2785,9 +3656,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.17" +"@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.18" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard @@ -2799,9 +3670,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.17" +"@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.18" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard @@ -2813,9 +3684,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.17" +"@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.18" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard @@ -2827,9 +3698,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.17" +"@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.18" conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard @@ -2841,9 +3712,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.17" +"@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.18" dependencies: "@emnapi/core": "npm:1.10.0" "@emnapi/runtime": "npm:1.10.0" @@ -2861,9 +3732,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.17" +"@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.18" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard @@ -2875,9 +3746,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.17" +"@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.18" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -2889,10 +3760,10 @@ __metadata: languageName: node linkType: hard -"@rolldown/pluginutils@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "@rolldown/pluginutils@npm:1.0.0-rc.17" - checksum: 10/d659ea756ee6d360a015708d1035c07047e08db99a4160c74c7f22a7ece5611efcc18ad56db4a63b69edb506ded47596d9c0d301919242470d8c412d916b9750 +"@rolldown/pluginutils@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "@rolldown/pluginutils@npm:1.0.0-rc.18" + checksum: 10/9dc298defd9615e55c7b57caca78e5f160d6a8dffbf823c990082421a59b7e6413daf113b81d93d9ef8db3d7608816a3117748e9b2f8d16e0964fe2c520d342e languageName: node linkType: hard @@ -2941,6 +3812,13 @@ __metadata: languageName: node linkType: hard +"@silvia-odwyer/photon-node@npm:^0.3.4": + version: 0.3.4 + resolution: "@silvia-odwyer/photon-node@npm:0.3.4" + checksum: 10/9353e3b2ed8e89f3410bdfe390c12e269867440ceb9a6f5c8c765732efd86bf299fbb7fae68c8959241878705b0f8516016c98cd83e69770bfc05f531dc6edb7 + languageName: node + linkType: hard + "@sinclair/typebox@npm:^0.27.8": version: 0.27.10 resolution: "@sinclair/typebox@npm:0.27.10" @@ -3049,6 +3927,541 @@ __metadata: languageName: node linkType: hard +"@smithy/config-resolver@npm:^4.4.17": + version: 4.4.17 + resolution: "@smithy/config-resolver@npm:4.4.17" + dependencies: + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/types": "npm:^4.14.1" + "@smithy/util-config-provider": "npm:^4.2.2" + "@smithy/util-endpoints": "npm:^3.4.2" + "@smithy/util-middleware": "npm:^4.2.14" + tslib: "npm:^2.6.2" + checksum: 10/23590df61ac91cee66a7540d1a988244a1b299d6035375ea8753df2018c4f0aa5fc9317f9c487bef9055c8a89e0ae25be08268b7d22cb4410189803732202de8 + languageName: node + linkType: hard + +"@smithy/core@npm:^3.23.17": + version: 3.23.17 + resolution: "@smithy/core@npm:3.23.17" + dependencies: + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/types": "npm:^4.14.1" + "@smithy/url-parser": "npm:^4.2.14" + "@smithy/util-base64": "npm:^4.3.2" + "@smithy/util-body-length-browser": "npm:^4.2.2" + "@smithy/util-middleware": "npm:^4.2.14" + "@smithy/util-stream": "npm:^4.5.25" + "@smithy/util-utf8": "npm:^4.2.2" + "@smithy/uuid": "npm:^1.1.2" + tslib: "npm:^2.6.2" + checksum: 10/fe609d8b83792eb4922b884ffd53b48a8a79c1c33c8d361b2538d3904ca34d1452992999735cc6b7dcf7eb4663f6cb542dd42495d9af561349b3a36157308e8f + languageName: node + linkType: hard + +"@smithy/credential-provider-imds@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/credential-provider-imds@npm:4.2.14" + dependencies: + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/types": "npm:^4.14.1" + "@smithy/url-parser": "npm:^4.2.14" + tslib: "npm:^2.6.2" + checksum: 10/40b82b3e6a5ec6f52c9344642eb10a572f1a18483edf1948d14537adeddadb575757101bea240e8b33570f8d03622ba2fd4a81fd52da1113456076a3f2fa0a08 + languageName: node + linkType: hard + +"@smithy/eventstream-codec@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/eventstream-codec@npm:4.2.14" + dependencies: + "@aws-crypto/crc32": "npm:5.2.0" + "@smithy/types": "npm:^4.14.1" + "@smithy/util-hex-encoding": "npm:^4.2.2" + tslib: "npm:^2.6.2" + checksum: 10/e6a2ec5f0dccf3f22094b11f008fd4e0c389cf6ac43abcf3971eddf9fcbb1eb3e7f4ad87c867df73d2340e0c713660d0d3fcdf71222a455d6b3771d8564d2a9c + languageName: node + linkType: hard + +"@smithy/eventstream-serde-browser@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/eventstream-serde-browser@npm:4.2.14" + dependencies: + "@smithy/eventstream-serde-universal": "npm:^4.2.14" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/c3cd659638dd3fd70743c1305d6baef9ecafd3d98d5a1d06afc174e43ced92532c606ca1a1588999f8cbdf732c728b48d14d76ccb8b4c687ab3af43339f5e41a + languageName: node + linkType: hard + +"@smithy/eventstream-serde-config-resolver@npm:^4.3.14": + version: 4.3.14 + resolution: "@smithy/eventstream-serde-config-resolver@npm:4.3.14" + dependencies: + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/1fe57d331395593e088622667e61fa2f322b14d7c7e46ea32b119f1462ba73c870c59a9d4910361cee5f3b1022f01e95f325f3a418eb07416af8dd5f65b8f868 + languageName: node + linkType: hard + +"@smithy/eventstream-serde-node@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/eventstream-serde-node@npm:4.2.14" + dependencies: + "@smithy/eventstream-serde-universal": "npm:^4.2.14" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/81eff86735ae5a0a93863c5f0d86576bc91d590dd1606774e66d508199e7441919662bcfd01ce193f4198e86643d2cf74cbaed29e578d3259c0eaf61796b448e + languageName: node + linkType: hard + +"@smithy/eventstream-serde-universal@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/eventstream-serde-universal@npm:4.2.14" + dependencies: + "@smithy/eventstream-codec": "npm:^4.2.14" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/47be7249a64ec44a7275a26e311b66769b055b082885504a2d50613a0b6c93b213db714f2668dd58ed852703b41665bc9b67c914dc229be4615244b3702cb006 + languageName: node + linkType: hard + +"@smithy/fetch-http-handler@npm:^5.3.17": + version: 5.3.17 + resolution: "@smithy/fetch-http-handler@npm:5.3.17" + dependencies: + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/querystring-builder": "npm:^4.2.14" + "@smithy/types": "npm:^4.14.1" + "@smithy/util-base64": "npm:^4.3.2" + tslib: "npm:^2.6.2" + checksum: 10/1a5e737fbea32fa58b83e120d7d48e19ebfe0c64f03d7a1b09d97f3bd46bdf95beb54047a08d7e8712441ace12137e14f3a63a2ef6d699f4b5621ab200033d84 + languageName: node + linkType: hard + +"@smithy/hash-node@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/hash-node@npm:4.2.14" + dependencies: + "@smithy/types": "npm:^4.14.1" + "@smithy/util-buffer-from": "npm:^4.2.2" + "@smithy/util-utf8": "npm:^4.2.2" + tslib: "npm:^2.6.2" + checksum: 10/d5ef7312b9d1b3f85b3dfdabc90355fcf1c490b7c0c66031ba64f48fd2406cbae6408afd614dcb782b06a270da938339e7c3f9d641cc9e563c6ee5337b822431 + languageName: node + linkType: hard + +"@smithy/invalid-dependency@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/invalid-dependency@npm:4.2.14" + dependencies: + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/3c2a86a3392822015a47e86afeaca11efa0413e57eec5400d8eb6d8f93d86b4bae2365e7d020aec340282e2378d03744aecb57f1218a5df125da9f97142631cd + languageName: node + linkType: hard + +"@smithy/is-array-buffer@npm:^2.2.0": + version: 2.2.0 + resolution: "@smithy/is-array-buffer@npm:2.2.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/d366743ecc7a9fc3bad21dbb3950d213c12bdd4aeb62b1265bf6cbe38309df547664ef3e51ab732e704485194f15e89d361943b0bfbe3fe1a4b3178b942913cc + languageName: node + linkType: hard + +"@smithy/is-array-buffer@npm:^4.2.2": + version: 4.2.2 + resolution: "@smithy/is-array-buffer@npm:4.2.2" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/ebf9bac3daad0e1c3b201d41c4d2ab4be0c08c4c34604f87965b73cb052b1fd99133088f3b9837527f8fd6ed071b8684bb554ff381e5fdeacfc5907a66e4688b + languageName: node + linkType: hard + +"@smithy/middleware-content-length@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/middleware-content-length@npm:4.2.14" + dependencies: + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/8da4f2b00323fcd3133b0d5d2b705cbbcc33c0f91aa1d4c43dfde8e0414b6756d98ee99764da5a0ee86e9c44555d646429c3e9e74e0360918efc6faf65d60c59 + languageName: node + linkType: hard + +"@smithy/middleware-endpoint@npm:^4.4.32": + version: 4.4.32 + resolution: "@smithy/middleware-endpoint@npm:4.4.32" + dependencies: + "@smithy/core": "npm:^3.23.17" + "@smithy/middleware-serde": "npm:^4.2.20" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/shared-ini-file-loader": "npm:^4.4.9" + "@smithy/types": "npm:^4.14.1" + "@smithy/url-parser": "npm:^4.2.14" + "@smithy/util-middleware": "npm:^4.2.14" + tslib: "npm:^2.6.2" + checksum: 10/84b58d1856c245df8a3d0d8cb19f9577e580e0788bc7f0c2ae5b6d0d02e27a9c4fec89c640cb971ddec9635c9c62fd6482db1a82851e3c7d47e5d1fc08008324 + languageName: node + linkType: hard + +"@smithy/middleware-retry@npm:^4.5.7": + version: 4.5.7 + resolution: "@smithy/middleware-retry@npm:4.5.7" + dependencies: + "@smithy/core": "npm:^3.23.17" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/service-error-classification": "npm:^4.3.1" + "@smithy/smithy-client": "npm:^4.12.13" + "@smithy/types": "npm:^4.14.1" + "@smithy/util-middleware": "npm:^4.2.14" + "@smithy/util-retry": "npm:^4.3.6" + "@smithy/uuid": "npm:^1.1.2" + tslib: "npm:^2.6.2" + checksum: 10/accdd12c43fbc30703656a1849c80f3ef0e28835664078700240d83ca2f8636ca708000b08f5e7828f88fcc79ed9f06cb4f6c6c3dfc38f1cb03d339fdea8b21c + languageName: node + linkType: hard + +"@smithy/middleware-serde@npm:^4.2.20": + version: 4.2.20 + resolution: "@smithy/middleware-serde@npm:4.2.20" + dependencies: + "@smithy/core": "npm:^3.23.17" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/84c6072347a66ec13fed32e53cc8d2027ddbb0e2d75cff1a66d1676ec2cfc8a4ee5e082a5f61d1d0904a0fbafe247e4dd9b8d78935837d34fb0de67a3e8379a2 + languageName: node + linkType: hard + +"@smithy/middleware-stack@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/middleware-stack@npm:4.2.14" + dependencies: + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/353fdcbcd92233b93d303ecb9e827caccdd10136c6f0fec4a21bfb418483bb34dfb6fccfe3d25bfa9a78e0bb1a67a04ecc46ba7f206352a8c7e1f06b1afe6d00 + languageName: node + linkType: hard + +"@smithy/node-config-provider@npm:^4.3.14": + version: 4.3.14 + resolution: "@smithy/node-config-provider@npm:4.3.14" + dependencies: + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/shared-ini-file-loader": "npm:^4.4.9" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/f184bf150f36c4ae6ba32a694c3e47bea6b406d0859b07d14d59864e7bb8868aa98f565ba3ea5a7cc1c107bf20b29d55318fe82ef95a1e156b2b5511af58c817 + languageName: node + linkType: hard + +"@smithy/node-http-handler@npm:^4.6.1": + version: 4.6.1 + resolution: "@smithy/node-http-handler@npm:4.6.1" + dependencies: + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/querystring-builder": "npm:^4.2.14" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/0dd0766f85a963a5a9b0ba0d6ead445819526b7abefadf84631812f2db114e7ad7115b188af603b2283568cbf0c0d47f064e1b6c04094042d8a06887e6c3cef7 + languageName: node + linkType: hard + +"@smithy/property-provider@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/property-provider@npm:4.2.14" + dependencies: + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/63b496cb880bab841c12c53a64532fad4729c3713194e02027d721f8ed5e06b93084245abd7927a43f95626c71a404b11ce030f6e50b309786e5d3cf90181707 + languageName: node + linkType: hard + +"@smithy/protocol-http@npm:^5.3.14": + version: 5.3.14 + resolution: "@smithy/protocol-http@npm:5.3.14" + dependencies: + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/4fdf35ce266dd86328be3b46e821ba30fdb7116e0af05956a9e7ad16ed03700a901859f6c34c46c40118b751625ae35c56d20e0476806ffa8cdfea466b604df7 + languageName: node + linkType: hard + +"@smithy/querystring-builder@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/querystring-builder@npm:4.2.14" + dependencies: + "@smithy/types": "npm:^4.14.1" + "@smithy/util-uri-escape": "npm:^4.2.2" + tslib: "npm:^2.6.2" + checksum: 10/cb9357512429e084c3acddd95bf888ccdc5ca981f62f885d1d91f989d6096d57fe2116ac58810f48483fbc7a58f6c4515848a8979ff16ea19ceba4224f710ff0 + languageName: node + linkType: hard + +"@smithy/querystring-parser@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/querystring-parser@npm:4.2.14" + dependencies: + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/efbebff52a92859fa378ac16935c4fc2b70b738032a23daab9ff6bd6c0cc0fcd98838e4f51c3be98f3c20d9c4f9258b9a26dba56ecf51a226bfaaa066fa95bbf + languageName: node + linkType: hard + +"@smithy/service-error-classification@npm:^4.3.1": + version: 4.3.1 + resolution: "@smithy/service-error-classification@npm:4.3.1" + dependencies: + "@smithy/types": "npm:^4.14.1" + checksum: 10/611fa6a143e48430d1cb9b3cefa9c5683a9bbb2e6a0215e36605bd35a679ab6347c6070bfee3c2376c25df24e497b87407d284d0b212b758017a4b179deaf424 + languageName: node + linkType: hard + +"@smithy/shared-ini-file-loader@npm:^4.4.9": + version: 4.4.9 + resolution: "@smithy/shared-ini-file-loader@npm:4.4.9" + dependencies: + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/8b094b76ce1af93e3acce92d67f3518a40e8527c160928531a793a976443b7f71b98a91b45f5b1e39ef716785cc27371a25d8c6b4796ba481ea077ace752991f + languageName: node + linkType: hard + +"@smithy/signature-v4@npm:^5.3.14": + version: 5.3.14 + resolution: "@smithy/signature-v4@npm:5.3.14" + dependencies: + "@smithy/is-array-buffer": "npm:^4.2.2" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/types": "npm:^4.14.1" + "@smithy/util-hex-encoding": "npm:^4.2.2" + "@smithy/util-middleware": "npm:^4.2.14" + "@smithy/util-uri-escape": "npm:^4.2.2" + "@smithy/util-utf8": "npm:^4.2.2" + tslib: "npm:^2.6.2" + checksum: 10/4f8110d30207eb1235e96795b284841c068c71d734170d54ada7aac87ef85be4fadf45488db115fb80dc111a12763bbeacf8f68d94302c03bfd521754dbd790b + languageName: node + linkType: hard + +"@smithy/smithy-client@npm:^4.12.13": + version: 4.12.13 + resolution: "@smithy/smithy-client@npm:4.12.13" + dependencies: + "@smithy/core": "npm:^3.23.17" + "@smithy/middleware-endpoint": "npm:^4.4.32" + "@smithy/middleware-stack": "npm:^4.2.14" + "@smithy/protocol-http": "npm:^5.3.14" + "@smithy/types": "npm:^4.14.1" + "@smithy/util-stream": "npm:^4.5.25" + tslib: "npm:^2.6.2" + checksum: 10/078cb152d478559cc77aca0b9e0d1307220e6a5dcb989667e44d9ae691eef8b3827acfdbadebc14a0cc5dee95f6d8acf58e3e238e31c32986756f6c8d85ce7dc + languageName: node + linkType: hard + +"@smithy/types@npm:^4.14.1": + version: 4.14.1 + resolution: "@smithy/types@npm:4.14.1" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/45ee555075cb41dc50ce983fd58c504b4c64ef5fa50e73fa2ff14c5fa014be4af112823b07975cb1aa683d77a8c8c520c95224227c9108a904561a8d175984d4 + languageName: node + linkType: hard + +"@smithy/url-parser@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/url-parser@npm:4.2.14" + dependencies: + "@smithy/querystring-parser": "npm:^4.2.14" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/cd7556388084b5d347192632c7c1b9560f4fc7eb85c7ead0b3520a64948434fd622609172a1da730a83bfa145e52ec63f80984906732bf910359d37c3d7102b1 + languageName: node + linkType: hard + +"@smithy/util-base64@npm:^4.3.2": + version: 4.3.2 + resolution: "@smithy/util-base64@npm:4.3.2" + dependencies: + "@smithy/util-buffer-from": "npm:^4.2.2" + "@smithy/util-utf8": "npm:^4.2.2" + tslib: "npm:^2.6.2" + checksum: 10/9246e1874c2e96c2059e97c88e039bf40abb8b0d6fb229e1aca9df896371c44a9dc951c99d2febbd8267033920cef4180852e6655f3de8c4743d9a9bbf6d96b4 + languageName: node + linkType: hard + +"@smithy/util-body-length-browser@npm:^4.2.2": + version: 4.2.2 + resolution: "@smithy/util-body-length-browser@npm:4.2.2" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/c3058710fddecee22d4bc03839310c13783a7593a0a1808998998c465f6408012b5ce34bbec379fff84fb56f4017747d7817c4a7f050b45834b2aada27a2e7a7 + languageName: node + linkType: hard + +"@smithy/util-body-length-node@npm:^4.2.3": + version: 4.2.3 + resolution: "@smithy/util-body-length-node@npm:4.2.3" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/ad271366f72505f66ac82b87f0964211c79f40cb5bebeceeb23d74613ce33f1e77a9e81b03b48aced35c76ddd7184103886ce13dde96c8ee6e05dec59239798b + languageName: node + linkType: hard + +"@smithy/util-buffer-from@npm:^2.2.0": + version: 2.2.0 + resolution: "@smithy/util-buffer-from@npm:2.2.0" + dependencies: + "@smithy/is-array-buffer": "npm:^2.2.0" + tslib: "npm:^2.6.2" + checksum: 10/53253e4e351df3c4b7907dca48a0a6ceae783e98a8e73526820b122b3047a53fd127c19f4d8301f68d852011d821da519da783de57e0b22eed57c4df5b90d089 + languageName: node + linkType: hard + +"@smithy/util-buffer-from@npm:^4.2.2": + version: 4.2.2 + resolution: "@smithy/util-buffer-from@npm:4.2.2" + dependencies: + "@smithy/is-array-buffer": "npm:^4.2.2" + tslib: "npm:^2.6.2" + checksum: 10/111148eb7fb8c2913c0f09ca9991a409c69b2df643aa73378e64e14404ce040f67c716f7b4f55b76c0640f4357b649b9eb6a7f1539d7b37a2f0a7e0c3ba7062d + languageName: node + linkType: hard + +"@smithy/util-config-provider@npm:^4.2.2": + version: 4.2.2 + resolution: "@smithy/util-config-provider@npm:4.2.2" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/ff3989fc07b5162674ccce6aacf7c74dbaea9e7a731c05399a80ed758a67adf83e87d47431a69aa2b1c325497670ff7d1390d9441e3c0c2cea66e830f61f965a + languageName: node + linkType: hard + +"@smithy/util-defaults-mode-browser@npm:^4.3.49": + version: 4.3.49 + resolution: "@smithy/util-defaults-mode-browser@npm:4.3.49" + dependencies: + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/smithy-client": "npm:^4.12.13" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/e51b381e4c62131e594e78bae53aa7ca5aea7e063f85d70e17d82c0034a14e7e77491ab4d1e5c781066c56706c225faf2116a9cda250e7c05a8b938d7d1f5365 + languageName: node + linkType: hard + +"@smithy/util-defaults-mode-node@npm:^4.2.54": + version: 4.2.54 + resolution: "@smithy/util-defaults-mode-node@npm:4.2.54" + dependencies: + "@smithy/config-resolver": "npm:^4.4.17" + "@smithy/credential-provider-imds": "npm:^4.2.14" + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/property-provider": "npm:^4.2.14" + "@smithy/smithy-client": "npm:^4.12.13" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/e2ce34e7297275f3b71ede565c5bce7c605ff31824d775d98625b101851700d51b242cd270fc8d7cffce132eb3d908d99b9f333b4a90c8fc8e3984d0c7352a86 + languageName: node + linkType: hard + +"@smithy/util-endpoints@npm:^3.4.2": + version: 3.4.2 + resolution: "@smithy/util-endpoints@npm:3.4.2" + dependencies: + "@smithy/node-config-provider": "npm:^4.3.14" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/b292b8e1c5eaef76989e205eba9eb4c39f3cc7e685a7258dd2a3b6cefd459b6208143724041241470eca52c4612b79a9a809a5a70cd281d86f2acaed7a232c0d + languageName: node + linkType: hard + +"@smithy/util-hex-encoding@npm:^4.2.2": + version: 4.2.2 + resolution: "@smithy/util-hex-encoding@npm:4.2.2" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/2b1dfed0fcbe13c9a449b06adf805814fb3ec5d0d614704bfb250875cec7cf19f5a77a81013c91b81f45b7193038268f92d59de339192d578c9ef77a1b51c4d9 + languageName: node + linkType: hard + +"@smithy/util-middleware@npm:^4.2.14": + version: 4.2.14 + resolution: "@smithy/util-middleware@npm:4.2.14" + dependencies: + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/b468d35214cfe1809db56a35d6cffb3dbc6d84ca911a88890d43040e726de68c98b2157d467630df2ad0625ee3e4bd208ae0b14c9f3140b2a56318b2c78700f2 + languageName: node + linkType: hard + +"@smithy/util-retry@npm:^4.3.6": + version: 4.3.8 + resolution: "@smithy/util-retry@npm:4.3.8" + dependencies: + "@smithy/service-error-classification": "npm:^4.3.1" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/1e093b8d1e8b00ba5a381cfc65cbeff80d5930823d73c253ab73d06449d397a8c2e045e4a201753557551f6518a215432562e326289a8209ac8ad57bdc11a0c9 + languageName: node + linkType: hard + +"@smithy/util-stream@npm:^4.5.25": + version: 4.5.25 + resolution: "@smithy/util-stream@npm:4.5.25" + dependencies: + "@smithy/fetch-http-handler": "npm:^5.3.17" + "@smithy/node-http-handler": "npm:^4.6.1" + "@smithy/types": "npm:^4.14.1" + "@smithy/util-base64": "npm:^4.3.2" + "@smithy/util-buffer-from": "npm:^4.2.2" + "@smithy/util-hex-encoding": "npm:^4.2.2" + "@smithy/util-utf8": "npm:^4.2.2" + tslib: "npm:^2.6.2" + checksum: 10/0767af65bab121afe8bc44be9baed7e647b0e55e773ab61cebc812df24b3699f0daa4c09206b21cfb5993c9970a59c09359e3a7a1abb8b9ff2556cfb5b9016a0 + languageName: node + linkType: hard + +"@smithy/util-uri-escape@npm:^4.2.2": + version: 4.2.2 + resolution: "@smithy/util-uri-escape@npm:4.2.2" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/2b649e431a92c89e4fb7cc817e7bfcbe547d07f725c401445ea81aaea37e4ede383e1e2b9c3f0dfb228dee597166e95805a2f3e57fa6ae1b5341abc48a397935 + languageName: node + linkType: hard + +"@smithy/util-utf8@npm:^2.0.0": + version: 2.3.0 + resolution: "@smithy/util-utf8@npm:2.3.0" + dependencies: + "@smithy/util-buffer-from": "npm:^2.2.0" + tslib: "npm:^2.6.2" + checksum: 10/c766ead8dac6bc6169f4cac1cc47ef7bd86928d06255148f9528228002f669c8cc49f78dc2b9ba5d7e214d40315024a9e32c5c9130b33e20f0fe4532acd0dff5 + languageName: node + linkType: hard + +"@smithy/util-utf8@npm:^4.2.2": + version: 4.2.2 + resolution: "@smithy/util-utf8@npm:4.2.2" + dependencies: + "@smithy/util-buffer-from": "npm:^4.2.2" + tslib: "npm:^2.6.2" + checksum: 10/4dd23ac07cab78279a9f4f250b43df69d3303458b741e38c447ba7e92f7c6b1651076479023687b9eb3996aa3269ee1a0d363a1c320d15e78247ca9ea74aca58 + languageName: node + linkType: hard + +"@smithy/uuid@npm:^1.1.2": + version: 1.1.2 + resolution: "@smithy/uuid@npm:1.1.2" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/35b77a2483a37755c2be1faf66036f5e0b7939a7c608b93982fce9d4f137f1778784f101a2874a6756d9fd25092c6a95dd07314df12dcb9a0a03244b4cc4d8c4 + languageName: node + linkType: hard + "@so-ric/colorspace@npm:^1.1.6": version: 1.1.6 resolution: "@so-ric/colorspace@npm:1.1.6" @@ -3540,6 +4953,13 @@ __metadata: languageName: node linkType: hard +"@tootallnate/quickjs-emscripten@npm:^0.23.0": + version: 0.23.0 + resolution: "@tootallnate/quickjs-emscripten@npm:0.23.0" + checksum: 10/95cbad451d195b9d8f312103abafcc010741eb9256e98d7953e7c026d4c1ed4abb2248a14018bf49e3201c350104fc643137b23aa0bbed2744c795c39dc48a28 + languageName: node + linkType: hard + "@tybys/wasm-util@npm:^0.10.1": version: 0.10.1 resolution: "@tybys/wasm-util@npm:0.10.1" @@ -3899,6 +5319,13 @@ __metadata: languageName: node linkType: hard +"@types/mime-types@npm:^2.1.4": + version: 2.1.4 + resolution: "@types/mime-types@npm:2.1.4" + checksum: 10/f8c521c54ee0c0b9f90a65356a80b1413ed27ccdc94f5c7ebb3de5d63cedb559cd2610ea55b4100805c7349606a920d96e54f2d16b2f0afa6b7cd5253967ccc9 + languageName: node + linkType: hard + "@types/mime@npm:^1": version: 1.3.5 resolution: "@types/mime@npm:1.3.5" @@ -4107,6 +5534,15 @@ __metadata: languageName: node linkType: hard +"@types/yauzl@npm:^2.9.1": + version: 2.10.3 + resolution: "@types/yauzl@npm:2.10.3" + dependencies: + "@types/node": "npm:*" + checksum: 10/5ee966ea7bd6b2802f31ad4281c92c4c0b6dfa593c378a2582c58541fa113bec3d70eb0696b34ad95e8e6861a884cba6c3e351285816693ed176222f840a8c08 + languageName: node + linkType: hard + "@typescript-eslint/eslint-plugin@npm:^6.17.0": version: 6.21.0 resolution: "@typescript-eslint/eslint-plugin@npm:6.21.0" @@ -4620,6 +6056,15 @@ __metadata: languageName: node linkType: hard +"ast-types@npm:^0.13.4": + version: 0.13.4 + resolution: "ast-types@npm:0.13.4" + dependencies: + tslib: "npm:^2.0.1" + checksum: 10/c55b375b9aaf44713d8c0f77a08215ab6d44f368b13e44f2141c421022af3c62b615a30c8ea629457f0cbaec409c713401c0188a124552c8fe4a5ad6b17ff3c3 + languageName: node + linkType: hard + "ast-v8-to-istanbul@npm:^1.0.0": version: 1.0.0 resolution: "ast-v8-to-istanbul@npm:1.0.0" @@ -4821,6 +6266,13 @@ __metadata: languageName: node linkType: hard +"balanced-match@npm:^4.0.2": + version: 4.0.4 + resolution: "balanced-match@npm:4.0.4" + checksum: 10/fb07bb66a0959c2843fc055838047e2a95ccebb837c519614afb067ebfdf2fa967ca8d712c35ced07f2cd26fc6f07964230b094891315ad74f11eba3d53178a0 + languageName: node + linkType: hard + "base64-js@npm:^1.3.0": version: 1.5.1 resolution: "base64-js@npm:1.5.1" @@ -4837,6 +6289,13 @@ __metadata: languageName: node linkType: hard +"basic-ftp@npm:^5.0.2": + version: 5.3.1 + resolution: "basic-ftp@npm:5.3.1" + checksum: 10/9232ee155114efafadf5adee86a6750208653a9071e53e9803dceac61a66b3ba3974771ff2490ff28f1a118fdfb806ffcc488f64609e61a9cedb52480b312843 + languageName: node + linkType: hard + "bcrypt@npm:^6.0.0": version: 6.0.0 resolution: "bcrypt@npm:6.0.0" @@ -4906,13 +6365,29 @@ __metadata: languageName: node linkType: hard -"brace-expansion@npm:1.1.13": - version: 1.1.13 - resolution: "brace-expansion@npm:1.1.13" +"bowser@npm:^2.11.0": + version: 2.14.1 + resolution: "bowser@npm:2.14.1" + checksum: 10/a002f0795ef360314c75552b94daa42f74473f38b34255cfa959779e875806ef8e41b24ec63a533717798c8ef70bb991aef3037a2bb5dd32e8f507b39a509163 + languageName: node + linkType: hard + +"brace-expansion@npm:^1.1.7": + version: 1.1.14 + resolution: "brace-expansion@npm:1.1.14" dependencies: balanced-match: "npm:^1.0.0" concat-map: "npm:0.0.1" - checksum: 10/b5f4329fdbe9d2e25fa250c8f866ebd054ba946179426e99b86dcccddabdb1d481f0e40ee5430032e62a7d0a6c2837605ace6783d015aa1d65d85ca72154d936 + checksum: 10/2de747a5891ea0d3a1946ea1ae26e056a47f7ea8d42a3009e1736ec3a31a5aa69a3c5da59d998426773553afe4c258e5b12d7953b534fa7f2cf12ce92eed4931 + languageName: node + linkType: hard + +"brace-expansion@npm:^5.0.2, brace-expansion@npm:^5.0.5": + version: 5.0.5 + resolution: "brace-expansion@npm:5.0.5" + dependencies: + balanced-match: "npm:^4.0.2" + checksum: 10/f259b2ddf04489da9512ad637ba6b4ef2d77abd4445d20f7f1714585f153435200a53fa6a2e4a5ee974df14ddad4cd16421f6f803e96e8b452bd48598878d0ee languageName: node linkType: hard @@ -4975,6 +6450,13 @@ __metadata: languageName: node linkType: hard +"buffer-crc32@npm:~0.2.3": + version: 0.2.13 + resolution: "buffer-crc32@npm:0.2.13" + checksum: 10/06252347ae6daca3453b94e4b2f1d3754a3b146a111d81c68924c22d91889a40623264e95e67955b1cb4a68cbedf317abeabb5140a9766ed248973096db5ce1c + languageName: node + linkType: hard + "buffer-equal-constant-time@npm:^1.0.1": version: 1.0.1 resolution: "buffer-equal-constant-time@npm:1.0.1" @@ -5120,7 +6602,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^5.3.0, chalk@npm:^5.6.0": +"chalk@npm:^5.3.0, chalk@npm:^5.5.0, chalk@npm:^5.6.0, chalk@npm:^5.6.2": version: 5.6.2 resolution: "chalk@npm:5.6.2" checksum: 10/1b2f48f6fba1370670d5610f9cd54c391d6ede28f4b7062dd38244ea5768777af72e5be6b74fb6c6d54cb84c4a2dff3f3afa9b7cb5948f7f022cfd3d087989e0 @@ -5294,6 +6776,22 @@ __metadata: languageName: node linkType: hard +"cli-highlight@npm:^2.1.11": + version: 2.1.11 + resolution: "cli-highlight@npm:2.1.11" + dependencies: + chalk: "npm:^4.0.0" + highlight.js: "npm:^10.7.1" + mz: "npm:^2.4.0" + parse5: "npm:^5.1.1" + parse5-htmlparser2-tree-adapter: "npm:^6.0.0" + yargs: "npm:^16.0.0" + bin: + highlight: bin/highlight + checksum: 10/05d2b5beb8a4d3259f693517d013bf53d04ad20f470b77c3d02e051963092fae388388e3127f67d3679884a0c32cb855bf590292017c5e68c0f8d86f4b8e146e + languageName: node + linkType: hard + "cli-spinners@npm:^2.9.2": version: 2.9.2 resolution: "cli-spinners@npm:2.9.2" @@ -5346,6 +6844,17 @@ __metadata: languageName: node linkType: hard +"cliui@npm:^7.0.2": + version: 7.0.4 + resolution: "cliui@npm:7.0.4" + dependencies: + string-width: "npm:^4.2.0" + strip-ansi: "npm:^6.0.0" + wrap-ansi: "npm:^7.0.0" + checksum: 10/db858c49af9d59a32d603987e6fddaca2ce716cd4602ba5a2bb3a5af1351eebe82aba8dff3ef3e1b331f7fa9d40ca66e67bdf8e7c327ce0ea959747ead65c0ef + languageName: node + linkType: hard + "cliui@npm:^8.0.1": version: 8.0.1 resolution: "cliui@npm:8.0.1" @@ -5702,6 +7211,13 @@ __metadata: languageName: node linkType: hard +"data-uri-to-buffer@npm:^6.0.2": + version: 6.0.2 + resolution: "data-uri-to-buffer@npm:6.0.2" + checksum: 10/8b6927c33f9b54037f442856be0aa20e5fd49fa6c9c8ceece408dc306445d593ad72d207d57037c529ce65f413b421da800c6827b1dbefb607b8056f17123a61 + languageName: node + linkType: hard + "debug@npm:2.6.9": version: 2.6.9 resolution: "debug@npm:2.6.9" @@ -5765,6 +7281,17 @@ __metadata: languageName: node linkType: hard +"degenerator@npm:^5.0.0": + version: 5.0.1 + resolution: "degenerator@npm:5.0.1" + dependencies: + ast-types: "npm:^0.13.4" + escodegen: "npm:^2.1.0" + esprima: "npm:^4.0.1" + checksum: 10/a64fa39cdf6c2edd75188157d32338ee9de7193d7dbb2aeb4acb1eb30fa4a15ed80ba8dae9bd4d7b085472cf174a5baf81adb761aaa8e326771392c922084152 + languageName: node + linkType: hard + "delayed-stream@npm:~1.0.0": version: 1.0.0 resolution: "delayed-stream@npm:1.0.0" @@ -5844,6 +7371,13 @@ __metadata: languageName: node linkType: hard +"diff@npm:^8.0.2": + version: 8.0.4 + resolution: "diff@npm:8.0.4" + checksum: 10/b4036ceda0d1e10683a2313079ed52c5e6b09553ae29da87bce81d98714d9725dbf3c0f6f7c3b1f16eec049fe17087e38ee329e732580fa62f6ec1c2487b2435 + languageName: node + linkType: hard + "dijkstrajs@npm:^1.0.1": version: 1.0.3 resolution: "dijkstrajs@npm:1.0.3" @@ -6025,6 +7559,15 @@ __metadata: languageName: node linkType: hard +"end-of-stream@npm:^1.1.0": + version: 1.4.5 + resolution: "end-of-stream@npm:1.4.5" + dependencies: + once: "npm:^1.4.0" + checksum: 10/1e0cfa6e7f49887544e03314f9dfc56a8cb6dde910cbb445983ecc2ff426fc05946df9d75d8a21a3a64f2cecfe1bf88f773952029f46756b2ed64a24e95b1fb8 + languageName: node + linkType: hard + "entities@npm:^4.2.0, entities@npm:^4.4.0": version: 4.5.0 resolution: "entities@npm:4.5.0" @@ -6240,6 +7783,24 @@ __metadata: languageName: node linkType: hard +"escodegen@npm:^2.1.0": + version: 2.1.0 + resolution: "escodegen@npm:2.1.0" + dependencies: + esprima: "npm:^4.0.1" + estraverse: "npm:^5.2.0" + esutils: "npm:^2.0.2" + source-map: "npm:~0.6.1" + dependenciesMeta: + source-map: + optional: true + bin: + escodegen: bin/escodegen.js + esgenerate: bin/esgenerate.js + checksum: 10/47719a65b2888b4586e3fa93769068b275961c13089e90d5d01a96a6e8e95871b1c3893576814c8fbf08a4a31a496f37e7b2c937cf231270f4d81de012832c7c + languageName: node + linkType: hard + "eslint-scope@npm:^7.2.2": version: 7.2.2 resolution: "eslint-scope@npm:7.2.2" @@ -6316,7 +7877,7 @@ __metadata: languageName: node linkType: hard -"esprima@npm:^4.0.0": +"esprima@npm:^4.0.0, esprima@npm:^4.0.1": version: 4.0.1 resolution: "esprima@npm:4.0.1" bin: @@ -6571,6 +8132,23 @@ __metadata: languageName: node linkType: hard +"extract-zip@npm:^2.0.1": + version: 2.0.1 + resolution: "extract-zip@npm:2.0.1" + dependencies: + "@types/yauzl": "npm:^2.9.1" + debug: "npm:^4.1.1" + get-stream: "npm:^5.1.0" + yauzl: "npm:^2.10.0" + dependenciesMeta: + "@types/yauzl": + optional: true + bin: + extract-zip: cli.js + checksum: 10/8cbda9debdd6d6980819cc69734d874ddd71051c9fe5bde1ef307ebcedfe949ba57b004894b585f758b7c9eeeea0e3d87f2dda89b7d25320459c2c9643ebb635 + languageName: node + linkType: hard + "fast-deep-equal@npm:3.1.3, fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -6644,6 +8222,29 @@ __metadata: languageName: node linkType: hard +"fast-xml-builder@npm:^1.1.5": + version: 1.1.8 + resolution: "fast-xml-builder@npm:1.1.8" + dependencies: + path-expression-matcher: "npm:^1.1.3" + checksum: 10/9ed7f690d0718392aececad0344cb43f59ca99b25fbeee25ea365a3a564629e444bb685677c6b5222bfdb61f514d0ee4d6fd03d6872b79000f4b7354f9c35084 + languageName: node + linkType: hard + +"fast-xml-parser@npm:5.7.2": + version: 5.7.2 + resolution: "fast-xml-parser@npm:5.7.2" + dependencies: + "@nodable/entities": "npm:^2.1.0" + fast-xml-builder: "npm:^1.1.5" + path-expression-matcher: "npm:^1.5.0" + strnum: "npm:^2.2.3" + bin: + fxparser: src/cli/cli.js + checksum: 10/7f32d77127dbd5eb1b4c9f7f6ad81972527049905cebe8926c88d102b84c2a56468180dd7541384c93bfc8dad2cef0b1b82b097a931893d3cab3989bd1cf83e1 + languageName: node + linkType: hard + "fastq@npm:^1.6.0": version: 1.20.1 resolution: "fastq@npm:1.20.1" @@ -6662,6 +8263,15 @@ __metadata: languageName: node linkType: hard +"fd-slicer@npm:~1.1.0": + version: 1.1.0 + resolution: "fd-slicer@npm:1.1.0" + dependencies: + pend: "npm:~1.2.0" + checksum: 10/db3e34fa483b5873b73f248e818f8a8b59a6427fd8b1436cd439c195fdf11e8659419404826059a642b57d18075c856d06d6a50a1413b714f12f833a9341ead3 + languageName: node + linkType: hard + "fdir@npm:^6.5.0": version: 6.5.0 resolution: "fdir@npm:6.5.0" @@ -6700,6 +8310,18 @@ __metadata: languageName: node linkType: hard +"file-type@npm:^21.1.1": + version: 21.3.4 + resolution: "file-type@npm:21.3.4" + dependencies: + "@tokenizer/inflate": "npm:^0.4.1" + strtok3: "npm:^10.3.4" + token-types: "npm:^6.1.1" + uint8array-extras: "npm:^1.4.0" + checksum: 10/42d5cf6aafb998fb2f0357e96ea7c48bcce5249f899523a3a5a4c297f4fe2346cc0aff9cf04243ada5c54b6457183550b2a04902b6533cd5e64aaa344d78e0eb + languageName: node + linkType: hard + "file-type@npm:^21.3.1": version: 21.3.3 resolution: "file-type@npm:21.3.3" @@ -6795,7 +8417,17 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.15.11, follow-redirects@npm:^1.16.0": +"follow-redirects@npm:^1.15.11": + version: 1.15.11 + resolution: "follow-redirects@npm:1.15.11" + peerDependenciesMeta: + debug: + optional: true + checksum: 10/07372fd74b98c78cf4d417d68d41fdaa0be4dcacafffb9e67b1e3cf090bc4771515e65020651528faab238f10f9b9c0d9707d6c1574a6c0387c5de1042cde9ba + languageName: node + linkType: hard + +"follow-redirects@npm:^1.16.0": version: 1.16.0 resolution: "follow-redirects@npm:1.16.0" peerDependenciesMeta: @@ -6940,7 +8572,7 @@ __metadata: languageName: node linkType: hard -"get-east-asian-width@npm:^1.0.0, get-east-asian-width@npm:^1.3.1, get-east-asian-width@npm:^1.5.0": +"get-east-asian-width@npm:^1.0.0, get-east-asian-width@npm:^1.3.0, get-east-asian-width@npm:^1.3.1, get-east-asian-width@npm:^1.5.0": version: 1.5.0 resolution: "get-east-asian-width@npm:1.5.0" checksum: 10/60bc34cd1e975055ab99f0f177e31bed3e516ff7cee9c536474383954a976abaa6b94a51d99ad158ef1e372790fa096cab7d07f166bb0778f6587954c0fbe946 @@ -6992,6 +8624,15 @@ __metadata: languageName: node linkType: hard +"get-stream@npm:^5.1.0": + version: 5.2.0 + resolution: "get-stream@npm:5.2.0" + dependencies: + pump: "npm:^3.0.0" + checksum: 10/13a73148dca795e41421013da6e3ebff8ccb7fba4d2f023fd0c6da2c166ec4e789bec9774a73a7b49c08daf2cae552f8a3e914042ac23b5f59dd278cc8f9cbfb + languageName: node + linkType: hard + "get-stream@npm:^6.0.0": version: 6.0.1 resolution: "get-stream@npm:6.0.1" @@ -7008,6 +8649,17 @@ __metadata: languageName: node linkType: hard +"get-uri@npm:^6.0.1": + version: 6.0.5 + resolution: "get-uri@npm:6.0.5" + dependencies: + basic-ftp: "npm:^5.0.2" + data-uri-to-buffer: "npm:^6.0.2" + debug: "npm:^4.3.4" + checksum: 10/6daa56eb367dc030ae7bf6db4b5d36f200c9bb47ab00593c142176e4f33f22e129a294ac94329c6bcaebda19b7506080267a336742d20a915fb2bef9c400347f + languageName: node + linkType: hard + "glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2" @@ -7026,7 +8678,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^13.0.0": +"glob@npm:^13.0.0, glob@npm:^13.0.1": version: 13.0.6 resolution: "glob@npm:13.0.6" dependencies: @@ -7074,7 +8726,7 @@ __metadata: languageName: node linkType: hard -"google-auth-library@npm:^10.1.0, google-auth-library@npm:^10.2.0": +"google-auth-library@npm:^10.1.0, google-auth-library@npm:^10.2.0, google-auth-library@npm:^10.3.0": version: 10.6.2 resolution: "google-auth-library@npm:10.6.2" dependencies: @@ -7125,7 +8777,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": +"graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10/bf152d0ed1dc159239db1ba1f74fdbc40cb02f626770dcd5815c427ce0688c2635a06ed69af364396da4636d0408fcf7d4afdf7881724c3307e46aff30ca49e2 @@ -7237,10 +8889,17 @@ __metadata: languageName: node linkType: hard +"highlight.js@npm:^10.7.1": + version: 10.7.3 + resolution: "highlight.js@npm:10.7.3" + checksum: 10/db8d10a541936b058e221dbde77869664b2b45bca75d660aa98065be2cd29f3924755fbc7348213f17fd931aefb6e6597448ba6fe82afba6d8313747a91983ee + languageName: node + linkType: hard + "hono@npm:^4.11.4": - version: 4.12.18 - resolution: "hono@npm:4.12.18" - checksum: 10/0adda91eeb68c921e073bcbedd8758f026cc47dbbe7824147cf0ee8ef03004b1891f4d4edc80f6ab4c6ab33ec1f2fac52c4f7098752f8acf6833bcb76efb39e4 + version: 4.12.8 + resolution: "hono@npm:4.12.8" + checksum: 10/f6cbb5cd6f24c1a8eac3bcda82481f2ef77d6f56b5754c9004a1be8f2672433a401cd670d62e7852f3065b6daf49e3f16ad9525c09f5cf40858a903b95404d74 languageName: node linkType: hard @@ -7258,6 +8917,15 @@ __metadata: languageName: node linkType: hard +"hosted-git-info@npm:^9.0.2": + version: 9.0.3 + resolution: "hosted-git-info@npm:9.0.3" + dependencies: + lru-cache: "npm:^11.1.0" + checksum: 10/c5683d03a57a691c60973eb7f6e6b83e0d70903df7e8e032c9a4673fb9c70c6df70cdcd0f3bf796426c75973a3251c39b0755510ff1ec2a704239668ef1881cc + languageName: node + linkType: hard + "html-comment-regex@npm:^1.1.2": version: 1.1.2 resolution: "html-comment-regex@npm:1.1.2" @@ -7311,7 +8979,7 @@ __metadata: languageName: node linkType: hard -"http-proxy-agent@npm:^7.0.0": +"http-proxy-agent@npm:^7.0.0, http-proxy-agent@npm:^7.0.1": version: 7.0.2 resolution: "http-proxy-agent@npm:7.0.2" dependencies: @@ -7321,7 +8989,7 @@ __metadata: languageName: node linkType: hard -"https-proxy-agent@npm:^7.0.1": +"https-proxy-agent@npm:^7.0.1, https-proxy-agent@npm:^7.0.6": version: 7.0.6 resolution: "https-proxy-agent@npm:7.0.6" dependencies: @@ -7386,6 +9054,13 @@ __metadata: languageName: node linkType: hard +"ignore@npm:^7.0.5": + version: 7.0.5 + resolution: "ignore@npm:7.0.5" + checksum: 10/f134b96a4de0af419196f52c529d5c6120c4456ff8a6b5a14ceaaa399f883e15d58d2ce651c9b69b9388491d4669dda47285d307e827de9304a53a1824801bc6 + languageName: node + linkType: hard + "import-fresh@npm:^3.2.1": version: 3.3.1 resolution: "import-fresh@npm:3.3.1" @@ -8433,6 +10108,13 @@ __metadata: languageName: node linkType: hard +"koffi@npm:^2.9.0": + version: 2.16.1 + resolution: "koffi@npm:2.16.1" + checksum: 10/6675c723e0c120246a469c4e93895b7bf0837b0a862ef8a160b846f1684d23096da9a65f3b77cd8a85b775dcba668d478634e0b712c76d85e8a9a1eca1faadf6 + languageName: node + linkType: hard + "kuler@npm:^2.0.0": version: 2.0.0 resolution: "kuler@npm:2.0.0" @@ -8831,6 +10513,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^7.14.1": + version: 7.18.3 + resolution: "lru-cache@npm:7.18.3" + checksum: 10/6029ca5aba3aacb554e919d7ef804fffd4adfc4c83db00fac8248c7c78811fb6d4b6f70f7fd9d55032b3823446546a007edaa66ad1f2377ae833bd983fac5d98 + languageName: node + linkType: hard + "lucide-react@npm:^0.469.0": version: 0.469.0 resolution: "lucide-react@npm:0.469.0" @@ -8958,6 +10647,15 @@ __metadata: languageName: node linkType: hard +"marked@npm:^15.0.12": + version: 15.0.12 + resolution: "marked@npm:15.0.12" + bin: + marked: bin/marked.js + checksum: 10/deeb619405c0c46af00c99b18b3365450abeb309104b24e3658f46142344f6b7c4117608c3b5834084d8738e92f81240c19f596e6ee369260f96e52b3457eaee + languageName: node + linkType: hard + "math-intrinsics@npm:^1.1.0": version: 1.1.0 resolution: "math-intrinsics@npm:1.1.0" @@ -9757,7 +11455,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^3.0.0, mime-types@npm:^3.0.2": +"mime-types@npm:^3.0.0, mime-types@npm:^3.0.1, mime-types@npm:^3.0.2": version: 3.0.2 resolution: "mime-types@npm:3.0.2" dependencies: @@ -9807,6 +11505,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^10.2.3": + version: 10.2.5 + resolution: "minimatch@npm:10.2.5" + dependencies: + brace-expansion: "npm:^5.0.5" + checksum: 10/19e87a931aff60ee7b9d80f39f817b8bfc54f61f8356ee3549fbf636dbccacacfec8d803eac73293955c4527cd085247dfc064bce4a5e349f8f3b85e2bf5da0f + languageName: node + linkType: hard + "minimatch@npm:^3.0.4, minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": version: 3.1.5 resolution: "minimatch@npm:3.1.5" @@ -9954,7 +11661,7 @@ __metadata: languageName: node linkType: hard -"mz@npm:^2.7.0": +"mz@npm:^2.4.0, mz@npm:^2.7.0": version: 2.7.0 resolution: "mz@npm:2.7.0" dependencies: @@ -10009,19 +11716,26 @@ __metadata: languageName: node linkType: hard +"netmask@npm:^2.0.2": + version: 2.1.1 + resolution: "netmask@npm:2.1.1" + checksum: 10/473430b03b09787ad80041d5df4f43d93e7d9484c2646156eee0b31b95d627983aa5ccaf2bb9fc3fb3926bcf2dab82aa41005ad90d0095eb802f9a2354b2de27 + languageName: node + linkType: hard + "next@npm:^16.2.3": - version: 16.2.3 - resolution: "next@npm:16.2.3" - dependencies: - "@next/env": "npm:16.2.3" - "@next/swc-darwin-arm64": "npm:16.2.3" - "@next/swc-darwin-x64": "npm:16.2.3" - "@next/swc-linux-arm64-gnu": "npm:16.2.3" - "@next/swc-linux-arm64-musl": "npm:16.2.3" - "@next/swc-linux-x64-gnu": "npm:16.2.3" - "@next/swc-linux-x64-musl": "npm:16.2.3" - "@next/swc-win32-arm64-msvc": "npm:16.2.3" - "@next/swc-win32-x64-msvc": "npm:16.2.3" + version: 16.2.5 + resolution: "next@npm:16.2.5" + dependencies: + "@next/env": "npm:16.2.5" + "@next/swc-darwin-arm64": "npm:16.2.5" + "@next/swc-darwin-x64": "npm:16.2.5" + "@next/swc-linux-arm64-gnu": "npm:16.2.5" + "@next/swc-linux-arm64-musl": "npm:16.2.5" + "@next/swc-linux-x64-gnu": "npm:16.2.5" + "@next/swc-linux-x64-musl": "npm:16.2.5" + "@next/swc-win32-arm64-msvc": "npm:16.2.5" + "@next/swc-win32-x64-msvc": "npm:16.2.5" "@swc/helpers": "npm:0.5.15" baseline-browser-mapping: "npm:^2.9.19" caniuse-lite: "npm:^1.0.30001579" @@ -10065,7 +11779,7 @@ __metadata: optional: true bin: next: dist/bin/next - checksum: 10/5164885daacbb36a771380e1b5efba524863e1bdf2b5a6c80413cbf1e3ab4e8ddab5716cd91ff94ca5c5c5deb2a12d1312d6d6ae994e16ebfa985fdda6134bc6 + checksum: 10/65a095a5c02d6c80cc5520f8816e9f1c18ee146ca57efc2da9fb2e2b48508e3655b13c8af12d8d02eaa9b40d4e4b946f2a87e82addc0d3564c7f984158880957 languageName: node linkType: hard @@ -10263,7 +11977,7 @@ __metadata: languageName: node linkType: hard -"once@npm:^1.3.0, once@npm:^1.4.0": +"once@npm:^1.3.0, once@npm:^1.3.1, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" dependencies: @@ -10299,6 +12013,23 @@ __metadata: languageName: node linkType: hard +"openai@npm:6.26.0": + version: 6.26.0 + resolution: "openai@npm:6.26.0" + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + bin: + openai: bin/cli + checksum: 10/33dc7beb65916331985ba811b7a30fab974f47ceed6b4ef1bf537d35a977c9cc1d8471154456fd0ab5efd1656e8881f447756cebf88def568973fbf5d08ca392 + languageName: node + linkType: hard + "optionator@npm:^0.9.3": version: 0.9.4 resolution: "optionator@npm:0.9.4" @@ -10407,7 +12138,7 @@ __metadata: languageName: node linkType: hard -"p-retry@npm:^4": +"p-retry@npm:^4, p-retry@npm:^4.6.2": version: 4.6.2 resolution: "p-retry@npm:4.6.2" dependencies: @@ -10447,6 +12178,32 @@ __metadata: languageName: node linkType: hard +"pac-proxy-agent@npm:^7.1.0": + version: 7.2.0 + resolution: "pac-proxy-agent@npm:7.2.0" + dependencies: + "@tootallnate/quickjs-emscripten": "npm:^0.23.0" + agent-base: "npm:^7.1.2" + debug: "npm:^4.3.4" + get-uri: "npm:^6.0.1" + http-proxy-agent: "npm:^7.0.0" + https-proxy-agent: "npm:^7.0.6" + pac-resolver: "npm:^7.0.1" + socks-proxy-agent: "npm:^8.0.5" + checksum: 10/187656be62d5a6b983d90a86d64106a38b1a9ee78f591fabb27b3cf0d51e5d528456a9faaaf981c93dd54dc9c9ee8d33e35a51072b73a19ec1a8e0d0c36a2b99 + languageName: node + linkType: hard + +"pac-resolver@npm:^7.0.1": + version: 7.0.1 + resolution: "pac-resolver@npm:7.0.1" + dependencies: + degenerator: "npm:^5.0.0" + netmask: "npm:^2.0.2" + checksum: 10/839134328781b80d49f9684eae1f5c74f50a1d4482076d44c84fc2f3ca93da66fa11245a4725a057231e06b311c20c989fd0681e662a0792d17f644d8fe62a5e + languageName: node + linkType: hard + "parent-module@npm:^1.0.0": version: 1.0.1 resolution: "parent-module@npm:1.0.1" @@ -10497,6 +12254,15 @@ __metadata: languageName: node linkType: hard +"parse5-htmlparser2-tree-adapter@npm:^6.0.0": + version: 6.0.1 + resolution: "parse5-htmlparser2-tree-adapter@npm:6.0.1" + dependencies: + parse5: "npm:^6.0.1" + checksum: 10/3400a2cd1ad450b2fe148544154f86ea53d3ed6b6eab56c78bb43b9629d3dfe9f580dffd75bbf32be134ffef645b68081fc764bf75c210f236ab9c5c8c38c252 + languageName: node + linkType: hard + "parse5-htmlparser2-tree-adapter@npm:^7.0.0": version: 7.1.0 resolution: "parse5-htmlparser2-tree-adapter@npm:7.1.0" @@ -10507,6 +12273,20 @@ __metadata: languageName: node linkType: hard +"parse5@npm:^5.1.1": + version: 5.1.1 + resolution: "parse5@npm:5.1.1" + checksum: 10/5b509744cfe81488a33be05578df490c460690e64519fa67f0a0acb9c1bca05914e8acad17a977e2cf5964a000e43959b40024f0c243dd6595dd0cca8a32f71b + languageName: node + linkType: hard + +"parse5@npm:^6.0.1": + version: 6.0.1 + resolution: "parse5@npm:6.0.1" + checksum: 10/dfb110581f62bd1425725a7c784ae022a24669bd0efc24b58c71fc731c4d868193e2ebd85b74cde2dbb965e4dcf07059b1e651adbec1b3b5267531bd132fdb75 + languageName: node + linkType: hard + "parse5@npm:^7.0.0": version: 7.3.0 resolution: "parse5@npm:7.3.0" @@ -10523,6 +12303,13 @@ __metadata: languageName: node linkType: hard +"partial-json@npm:^0.1.7": + version: 0.1.7 + resolution: "partial-json@npm:0.1.7" + checksum: 10/6fb8d305638e4747fccfc0649b8a8de99314bfafcc4e3136a3a27c0632857c40b62e6e6bb5f17af00472387393f1d8a58a7865242ca20382af31b593a7bb1f89 + languageName: node + linkType: hard + "patch-console@npm:^2.0.0": version: 2.0.0 resolution: "patch-console@npm:2.0.0" @@ -10537,6 +12324,13 @@ __metadata: languageName: node linkType: hard +"path-expression-matcher@npm:^1.1.3, path-expression-matcher@npm:^1.5.0": + version: 1.5.0 + resolution: "path-expression-matcher@npm:1.5.0" + checksum: 10/28303bb9ee6831e6df14c10cd3f3f7b2d7c8d7f788d8bdb7440136fd696064c82a3e264999a0764d28e39f698275fc03a5493bec93c57ef4a22566280367dd64 + languageName: node + linkType: hard + "path-is-absolute@npm:^1.0.0": version: 1.0.1 resolution: "path-is-absolute@npm:1.0.1" @@ -10603,6 +12397,13 @@ __metadata: languageName: node linkType: hard +"pend@npm:~1.2.0": + version: 1.2.0 + resolution: "pend@npm:1.2.0" + checksum: 10/6c72f5243303d9c60bd98e6446ba7d30ae29e3d56fdb6fae8767e8ba6386f33ee284c97efe3230a0d0217e2b1723b8ab490b1bbf34fcbb2180dbc8a9de47850d + languageName: node + linkType: hard + "picocolors@npm:^1.0.0, picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" @@ -10812,7 +12613,7 @@ __metadata: languageName: node linkType: hard -"postcss@npm:^8.5.10": +"postcss@npm:^8.5.14": version: 8.5.14 resolution: "postcss@npm:8.5.14" dependencies: @@ -10874,6 +12675,17 @@ __metadata: languageName: node linkType: hard +"proper-lockfile@npm:^4.1.2": + version: 4.1.2 + resolution: "proper-lockfile@npm:4.1.2" + dependencies: + graceful-fs: "npm:^4.2.4" + retry: "npm:^0.12.0" + signal-exit: "npm:^3.0.2" + checksum: 10/000a4875f543f591872b36ca94531af8a6463ddb0174f41c0b004d19e231d7445268b422ff1ea595e43d238655c702250cd3d27f408e7b9d97b56f1533ba26bf + languageName: node + linkType: hard + "property-information@npm:^7.0.0": version: 7.1.0 resolution: "property-information@npm:7.1.0" @@ -11119,6 +12931,26 @@ __metadata: languageName: node linkType: hard +"protobufjs@npm:^7.5.4": + version: 7.5.6 + resolution: "protobufjs@npm:7.5.6" + dependencies: + "@protobufjs/aspromise": "npm:^1.1.2" + "@protobufjs/base64": "npm:^1.1.2" + "@protobufjs/codegen": "npm:^2.0.5" + "@protobufjs/eventemitter": "npm:^1.1.0" + "@protobufjs/fetch": "npm:^1.1.0" + "@protobufjs/float": "npm:^1.0.2" + "@protobufjs/inquire": "npm:^1.1.1" + "@protobufjs/path": "npm:^1.1.2" + "@protobufjs/pool": "npm:^1.1.0" + "@protobufjs/utf8": "npm:^1.1.1" + "@types/node": "npm:>=13.7.0" + long: "npm:^5.0.0" + checksum: 10/13d7ab5b5d588b704476a600506cda9f647e2f657faf5a18e8866ce5b04800026a508a4958673e4d5674090bbc528717b01128f8e226055d56e90745e3ee6ada + languageName: node + linkType: hard + "proxy-addr@npm:^2.0.7, proxy-addr@npm:~2.0.7": version: 2.0.7 resolution: "proxy-addr@npm:2.0.7" @@ -11129,6 +12961,22 @@ __metadata: languageName: node linkType: hard +"proxy-agent@npm:^6.5.0": + version: 6.5.0 + resolution: "proxy-agent@npm:6.5.0" + dependencies: + agent-base: "npm:^7.1.2" + debug: "npm:^4.3.4" + http-proxy-agent: "npm:^7.0.1" + https-proxy-agent: "npm:^7.0.6" + lru-cache: "npm:^7.14.1" + pac-proxy-agent: "npm:^7.1.0" + proxy-from-env: "npm:^1.1.0" + socks-proxy-agent: "npm:^8.0.5" + checksum: 10/56d5a494d96dafad94868870af776939e7b9aaca172465a5c251d2523496a8353b029c32d2a72a012bd62622cdc9a43ba3df59b5738ab7b740bc6b362e9f9477 + languageName: node + linkType: hard + "proxy-from-env@npm:^1.1.0": version: 1.1.0 resolution: "proxy-from-env@npm:1.1.0" @@ -11143,6 +12991,16 @@ __metadata: languageName: node linkType: hard +"pump@npm:^3.0.0": + version: 3.0.4 + resolution: "pump@npm:3.0.4" + dependencies: + end-of-stream: "npm:^1.1.0" + once: "npm:^1.3.1" + checksum: 10/d043c3e710c56ffd280711e98a94e863ab334f79ea43cee0fb70e1349b2355ffd2ff287c7522e4c960a247699d5b7825f00fa090b85d6179c973be13f78a6c49 + languageName: node + linkType: hard + "punycode.js@npm:^2.3.1": version: 2.3.1 resolution: "punycode.js@npm:2.3.1" @@ -11632,6 +13490,13 @@ __metadata: languageName: node linkType: hard +"retry@npm:^0.12.0": + version: 0.12.0 + resolution: "retry@npm:0.12.0" + checksum: 10/1f914879f97e7ee931ad05fe3afa629bd55270fc6cf1c1e589b6a99fab96d15daad0fa1a52a00c729ec0078045fe3e399bd4fd0c93bcc906957bdc17f89cb8e6 + languageName: node + linkType: hard + "retry@npm:^0.13.1": version: 0.13.1 resolution: "retry@npm:0.13.1" @@ -11664,27 +13529,27 @@ __metadata: languageName: node linkType: hard -"rolldown@npm:1.0.0-rc.17": - version: 1.0.0-rc.17 - resolution: "rolldown@npm:1.0.0-rc.17" - dependencies: - "@oxc-project/types": "npm:=0.127.0" - "@rolldown/binding-android-arm64": "npm:1.0.0-rc.17" - "@rolldown/binding-darwin-arm64": "npm:1.0.0-rc.17" - "@rolldown/binding-darwin-x64": "npm:1.0.0-rc.17" - "@rolldown/binding-freebsd-x64": "npm:1.0.0-rc.17" - "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.0-rc.17" - "@rolldown/binding-linux-arm64-gnu": "npm:1.0.0-rc.17" - "@rolldown/binding-linux-arm64-musl": "npm:1.0.0-rc.17" - "@rolldown/binding-linux-ppc64-gnu": "npm:1.0.0-rc.17" - "@rolldown/binding-linux-s390x-gnu": "npm:1.0.0-rc.17" - "@rolldown/binding-linux-x64-gnu": "npm:1.0.0-rc.17" - "@rolldown/binding-linux-x64-musl": "npm:1.0.0-rc.17" - "@rolldown/binding-openharmony-arm64": "npm:1.0.0-rc.17" - "@rolldown/binding-wasm32-wasi": "npm:1.0.0-rc.17" - "@rolldown/binding-win32-arm64-msvc": "npm:1.0.0-rc.17" - "@rolldown/binding-win32-x64-msvc": "npm:1.0.0-rc.17" - "@rolldown/pluginutils": "npm:1.0.0-rc.17" +"rolldown@npm:1.0.0-rc.18": + version: 1.0.0-rc.18 + resolution: "rolldown@npm:1.0.0-rc.18" + dependencies: + "@oxc-project/types": "npm:=0.128.0" + "@rolldown/binding-android-arm64": "npm:1.0.0-rc.18" + "@rolldown/binding-darwin-arm64": "npm:1.0.0-rc.18" + "@rolldown/binding-darwin-x64": "npm:1.0.0-rc.18" + "@rolldown/binding-freebsd-x64": "npm:1.0.0-rc.18" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.0-rc.18" + "@rolldown/binding-linux-arm64-gnu": "npm:1.0.0-rc.18" + "@rolldown/binding-linux-arm64-musl": "npm:1.0.0-rc.18" + "@rolldown/binding-linux-ppc64-gnu": "npm:1.0.0-rc.18" + "@rolldown/binding-linux-s390x-gnu": "npm:1.0.0-rc.18" + "@rolldown/binding-linux-x64-gnu": "npm:1.0.0-rc.18" + "@rolldown/binding-linux-x64-musl": "npm:1.0.0-rc.18" + "@rolldown/binding-openharmony-arm64": "npm:1.0.0-rc.18" + "@rolldown/binding-wasm32-wasi": "npm:1.0.0-rc.18" + "@rolldown/binding-win32-arm64-msvc": "npm:1.0.0-rc.18" + "@rolldown/binding-win32-x64-msvc": "npm:1.0.0-rc.18" + "@rolldown/pluginutils": "npm:1.0.0-rc.18" dependenciesMeta: "@rolldown/binding-android-arm64": optional: true @@ -11718,7 +13583,7 @@ __metadata: optional: true bin: rolldown: bin/cli.mjs - checksum: 10/5e7415a7cb732c4f7168ab6dcc841ed9ec4ad614058294a53d94821a762c274a69b009e41e9c8e4983a059907f02d462030a36b42543c0f41ce702fcd68d10d5 + checksum: 10/43a3cea928039291de3b2ba8c64424a22c17bd403b8f260cf1a4b3f3e6bb2ccdcf895f133e768479753ab37544a28a7f062a93f2eebb90217f0cdf0054a5bdc3 languageName: node linkType: hard @@ -12192,7 +14057,7 @@ __metadata: languageName: node linkType: hard -"socks-proxy-agent@npm:^8.0.3": +"socks-proxy-agent@npm:^8.0.3, socks-proxy-agent@npm:^8.0.5": version: 8.0.5 resolution: "socks-proxy-agent@npm:8.0.5" dependencies: @@ -12239,7 +14104,7 @@ __metadata: languageName: node linkType: hard -"source-map@npm:^0.6.0, source-map@npm:^0.6.1": +"source-map@npm:^0.6.0, source-map@npm:^0.6.1, source-map@npm:~0.6.1": version: 0.6.1 resolution: "source-map@npm:0.6.1" checksum: 10/59ef7462f1c29d502b3057e822cdbdae0b0e565302c4dd1a95e11e793d8d9d62006cdc10e0fd99163ca33ff2071360cf50ee13f90440806e7ed57d81cba2f7ff @@ -12306,6 +14171,13 @@ __metadata: languageName: node linkType: hard +"std-env@npm:^3.10.0": + version: 3.10.0 + resolution: "std-env@npm:3.10.0" + checksum: 10/19c9cda4f370b1ffae2b8b08c72167d8c3e5cfa972aaf5c6873f85d0ed2faa729407f5abb194dc33380708c00315002febb6f1e1b484736bfcf9361ad366013a + languageName: node + linkType: hard + "std-env@npm:^4.0.0-rc.1": version: 4.0.0 resolution: "std-env@npm:4.0.0" @@ -12427,6 +14299,13 @@ __metadata: languageName: node linkType: hard +"strnum@npm:^2.2.3": + version: 2.2.3 + resolution: "strnum@npm:2.2.3" + checksum: 10/fb70206301858c319f59ed34fecedf90ac3b821692c2accd403d9d4a3384223a09df8fd92b130bbd4e885b67b7790715c003405ce5f959d9cabbf07d41d62aa8 + languageName: node + linkType: hard + "strtok3@npm:^10.3.4": version: 10.3.4 resolution: "strtok3@npm:10.3.4" @@ -12863,7 +14742,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:2.8.1, tslib@npm:^2.0.0, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.6.3, tslib@npm:^2.8.0": +"tslib@npm:2.8.1, tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.6.3, tslib@npm:^2.8.0": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10/3e2e043d5c2316461cb54e5c7fe02c30ef6dccb3384717ca22ae5c6b5bc95232a6241df19c622d9c73b809bea33b187f6dbc73030963e29950c2141bc32a79f7 @@ -12951,6 +14830,13 @@ __metadata: languageName: node linkType: hard +"typebox@npm:^1.1.24": + version: 1.1.37 + resolution: "typebox@npm:1.1.37" + checksum: 10/022f7e4dac815ebd612ec72e962964c46985effc09bc9cef0d7893020b0c4c98d85aaf5e908231245c677b8df6fb7443d2b413bf24dd306b150ab2d47e1d50bb + languageName: node + linkType: hard + "typescript@npm:^5.3.0, typescript@npm:^5.3.3": version: 5.9.3 resolution: "typescript@npm:5.9.3" @@ -13267,6 +15153,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^14.0.0": + version: 14.0.0 + resolution: "uuid@npm:14.0.0" + bin: + uuid: dist-node/bin/uuid + checksum: 10/8ee9b98f9650e25555515f7a28d3c3ae9364e72f7bb19b9e08b681bc135338beba5509b2830f6ae1cfaba4d45401da0d16d4d109b977097bc3d6ba0c5583341b + languageName: node + linkType: hard + "v8-to-istanbul@npm:^9.0.1": version: 9.3.0 resolution: "v8-to-istanbul@npm:9.3.0" @@ -13386,18 +15281,18 @@ __metadata: linkType: hard "vite@npm:^8.0.9": - version: 8.0.10 - resolution: "vite@npm:8.0.10" + version: 8.0.11 + resolution: "vite@npm:8.0.11" dependencies: fsevents: "npm:~2.3.3" lightningcss: "npm:^1.32.0" picomatch: "npm:^4.0.4" - postcss: "npm:^8.5.10" - rolldown: "npm:1.0.0-rc.17" + postcss: "npm:^8.5.14" + rolldown: "npm:1.0.0-rc.18" tinyglobby: "npm:^0.2.16" peerDependencies: "@types/node": ^20.19.0 || >=22.12.0 - "@vitejs/devtools": ^0.1.0 + "@vitejs/devtools": ^0.1.18 esbuild: ^0.27.0 || ^0.28.0 jiti: ">=1.21.0" less: ^4.0.0 @@ -13438,7 +15333,7 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: 10/64c6fa4efa1a9ca3e1cacbcca16487b75ea25d62efbfb99c4e571b5f716296dc4f8af825eb624e273b11c3bee4e87daec35815fb6a56e01c843659c003ed2bcd + checksum: 10/355a0ec8dd206330f5f8b79623dc06815ded830ce0e0e7594d8de8bce1a33c6a18950c8d69e819731163244ce0a1cf06aee949ac11c814ff3080370ebb2ca146 languageName: node linkType: hard @@ -13755,6 +15650,13 @@ __metadata: languageName: node linkType: hard +"yargs-parser@npm:^20.2.2": + version: 20.2.9 + resolution: "yargs-parser@npm:20.2.9" + checksum: 10/0188f430a0f496551d09df6719a9132a3469e47fe2747208b1dd0ab2bb0c512a95d0b081628bbca5400fb20dbf2fabe63d22badb346cecadffdd948b049f3fcc + languageName: node + linkType: hard + "yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" @@ -13796,6 +15698,31 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^16.0.0": + version: 16.2.0 + resolution: "yargs@npm:16.2.0" + dependencies: + cliui: "npm:^7.0.2" + escalade: "npm:^3.1.1" + get-caller-file: "npm:^2.0.5" + require-directory: "npm:^2.1.1" + string-width: "npm:^4.2.0" + y18n: "npm:^5.0.5" + yargs-parser: "npm:^20.2.2" + checksum: 10/807fa21211d2117135d557f95fcd3c3d390530cda2eca0c840f1d95f0f40209dcfeb5ec18c785a1f3425896e623e3b2681e8bb7b6600060eda1c3f4804e7957e + languageName: node + linkType: hard + +"yauzl@npm:^2.10.0": + version: 2.10.0 + resolution: "yauzl@npm:2.10.0" + dependencies: + buffer-crc32: "npm:~0.2.3" + fd-slicer: "npm:~1.1.0" + checksum: 10/1e4c311050dc0cf2ee3dbe8854fe0a6cde50e420b3e561a8d97042526b4cf7a0718d6c8d89e9e526a152f4a9cec55bcea9c3617264115f48bd6704cf12a04445 + languageName: node + linkType: hard + "yocto-queue@npm:^0.1.0": version: 0.1.0 resolution: "yocto-queue@npm:0.1.0" @@ -13803,6 +15730,13 @@ __metadata: languageName: node linkType: hard +"yoctocolors@npm:^2.1.2": + version: 2.1.2 + resolution: "yoctocolors@npm:2.1.2" + checksum: 10/6ee42d665a4cc161c7de3f015b2a65d6c65d2808bfe3b99e228bd2b1b784ef1e54d1907415c025fc12b400f26f372bfc1b71966c6c738d998325ca422eb39363 + languageName: node + linkType: hard + "yoga-layout@npm:~3.2.1": version: 3.2.1 resolution: "yoga-layout@npm:3.2.1" @@ -13810,6 +15744,15 @@ __metadata: languageName: node linkType: hard +"zod-to-json-schema@npm:^3.24.6, zod-to-json-schema@npm:^3.25.0": + version: 3.25.2 + resolution: "zod-to-json-schema@npm:3.25.2" + peerDependencies: + zod: ^3.25.28 || ^4 + checksum: 10/7035328654113f1a0b8e4c2d34a06f918c93650ef8a50d4fb30ad8f22e47d5762c163af9c82494756b34776bae3c41c26cfc6945105b0eee7dceb528cc07e665 + languageName: node + linkType: hard + "zod-to-json-schema@npm:^3.25.1": version: 3.25.1 resolution: "zod-to-json-schema@npm:3.25.1" @@ -13833,6 +15776,13 @@ __metadata: languageName: node linkType: hard +"zod@npm:^3.25.0 || ^4.0.0": + version: 4.4.3 + resolution: "zod@npm:4.4.3" + checksum: 10/804b9a42aa8f35f2b3c5a8dff906291cb749115f83ee2afe3576d70b5b5c53c965365c7f4967690647a9c54af9838ff232a85ff9577a0a36c44b68bc6cdefe36 + languageName: node + linkType: hard + "zwitch@npm:^1.0.0": version: 1.0.5 resolution: "zwitch@npm:1.0.5" From c9382ee17bab85bc36fb13d437fbd60089bfdb97 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Tue, 5 May 2026 00:42:44 -0700 Subject: [PATCH 02/21] =?UTF-8?q?feat:=20bash=20security=20layer=20?= =?UTF-8?q?=E2=80=94=20default=20timeout=20+=20dangerous=20command=20block?= =?UTF-8?q?ing=20(by=20Wren)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pi's bash tool already provides cwd scoping, process tree killing, and output truncation. This adds Ink-level security on top: - Default 120s timeout (injected if model doesn't specify one) - Pattern-based blocking for destructive commands (rm -rf /, mkfs, fork bombs, pipe-to-shell, etc.) - Bypass flag for sandbox_bypass studios (maps from ClaudeRunnerConfig) Co-Authored-By: Wren --- .../api/src/agent/tools/pi-coding-tools.ts | 51 +++++++++++++++++++ .../services/sessions/direct-api-runner.ts | 12 +++-- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/packages/api/src/agent/tools/pi-coding-tools.ts b/packages/api/src/agent/tools/pi-coding-tools.ts index 444353ce..9eca63ca 100644 --- a/packages/api/src/agent/tools/pi-coding-tools.ts +++ b/packages/api/src/agent/tools/pi-coding-tools.ts @@ -45,6 +45,10 @@ export interface PiCodingToolsConfig { exclude?: Array<'read' | 'write' | 'edit' | 'bash' | 'grep' | 'find' | 'ls'>; /** Enforce workspace root boundary — blocks access outside cwd (default: true) */ enforceWorkspaceRoot?: boolean; + /** Default bash timeout in seconds (default: 120) */ + bashTimeoutSeconds?: number; + /** Bypass bash security checks (for sandbox_bypass studios) */ + bashSandboxBypass?: boolean; } const TOOLS_WITH_PATH_PARAM = new Set(['read', 'write', 'edit', 'grep', 'find', 'ls']); @@ -111,6 +115,31 @@ function formatToolResult(result: unknown): string { return JSON.stringify(result); } +// ─── Bash Security Layer ─── + +const DEFAULT_BASH_TIMEOUT_SECONDS = 120; + +const BLOCKED_COMMAND_PATTERNS = [ + /\brm\s+-[a-zA-Z]*r[a-zA-Z]*f?\s+\/(?!\S*\/\S)/, + /\bshutdown\b/, + /\breboot\b/, + /\bmkfs\b/, + /\bdd\s+.*of=\/dev\//, + /\b:(){ :\|:& };:/, + />\s*\/dev\/sd[a-z]/, + /\bcurl\b.*\|\s*(?:sudo\s+)?(?:bash|sh|zsh)\b/, + /\bwget\b.*\|\s*(?:sudo\s+)?(?:bash|sh|zsh)\b/, +]; + +function isBashCommandBlocked(command: string): string | null { + for (const pattern of BLOCKED_COMMAND_PATTERNS) { + if (pattern.test(command)) { + return `Blocked: command matches dangerous pattern (${pattern.source})`; + } + } + return null; +} + /** * Create Pi coding tools adapted for Ink's direct-api backend. * @@ -148,6 +177,8 @@ export async function createInkCodingTools( }); const enforceRoot = config.enforceWorkspaceRoot !== false; + const bashTimeout = config.bashTimeoutSeconds ?? DEFAULT_BASH_TIMEOUT_SECONDS; + const bashBypass = config.bashSandboxBypass === true; return tools.map((tool) => ({ schema: { @@ -164,6 +195,26 @@ export async function createInkCodingTools( } } + // Bash security layer + if (tool.name === 'bash') { + const command = (params.command as string) || ''; + + if (!bashBypass) { + const blocked = isBashCommandBlocked(command); + if (blocked) { + logger.warn('Bash command blocked by security policy', { + command: command.slice(0, 200), + }); + return `Error: ${blocked}`; + } + } + + // Inject default timeout if not specified by the model + if (!params.timeout) { + params = { ...params, timeout: bashTimeout }; + } + } + const callId = `ink-${tool.name}-${Date.now()}`; try { const result = await tool.execute(callId, params, signal); diff --git a/packages/api/src/services/sessions/direct-api-runner.ts b/packages/api/src/services/sessions/direct-api-runner.ts index 60cb2d65..641cbb3a 100644 --- a/packages/api/src/services/sessions/direct-api-runner.ts +++ b/packages/api/src/services/sessions/direct-api-runner.ts @@ -72,7 +72,7 @@ export class DirectApiRunner implements IRunner { } // Load Pi coding tools scoped to the working directory - const tools = await this.getTools(config.workingDirectory); + const tools = await this.getTools(config.workingDirectory, config.sandboxBypass); const toolSchemas: Anthropic.Tool[] = tools.map((t) => t.schema); if (this.runnerConfig.extraTools) { toolSchemas.push(...this.runnerConfig.extraTools); @@ -211,18 +211,20 @@ export class DirectApiRunner implements IRunner { this.client = new Anthropic({ apiKey }); } - private async getTools(cwd: string): Promise { - if (this.toolsCache.has(cwd)) { - return this.toolsCache.get(cwd)!; + private async getTools(cwd: string, sandboxBypass?: boolean): Promise { + const cacheKey = `${cwd}:${sandboxBypass ? 'bypass' : 'sandbox'}`; + if (this.toolsCache.has(cacheKey)) { + return this.toolsCache.get(cacheKey)!; } const piConfig: PiCodingToolsConfig = { cwd, + bashSandboxBypass: sandboxBypass, ...this.runnerConfig.piToolsConfig, }; const tools = await createInkCodingTools(piConfig); - this.toolsCache.set(cwd, tools); + this.toolsCache.set(cacheKey, tools); return tools; } From 2aa0aa6a7706362184e4ff4ba42013bef730fdd6 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Tue, 5 May 2026 14:51:33 -0700 Subject: [PATCH 03/21] =?UTF-8?q?refactor:=20strip=20hand-rolled=20bash=20?= =?UTF-8?q?blocker=20=E2=80=94=20rely=20on=20Pi's=20native=20guards=20(by?= =?UTF-8?q?=20Wren)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove regex command blocking (BLOCKED_COMMAND_PATTERNS, isBashCommandBlocked). Pi's bash already has timeouts, output truncation, and process cleanup. Keep workspace root enforcement + default timeout injection (Pi doesn't provide these). Rewrite tests to use only safe commands (echo, ls, cat, pwd) — no destructive patterns. Co-Authored-By: Wren --- .../src/agent/tools/pi-coding-tools.test.ts | 172 ++++++++++++++++++ .../api/src/agent/tools/pi-coding-tools.ts | 49 +---- .../services/sessions/direct-api-runner.ts | 12 +- 3 files changed, 181 insertions(+), 52 deletions(-) create mode 100644 packages/api/src/agent/tools/pi-coding-tools.test.ts diff --git a/packages/api/src/agent/tools/pi-coding-tools.test.ts b/packages/api/src/agent/tools/pi-coding-tools.test.ts new file mode 100644 index 00000000..ba8442f1 --- /dev/null +++ b/packages/api/src/agent/tools/pi-coding-tools.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import path from 'path'; +import { mkdtempSync, writeFileSync, mkdirSync, readFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { createInkCodingTools, type InkToolDefinition } from './pi-coding-tools'; + +vi.mock('../../utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +describe('Pi Coding Tools Adapter', () => { + let testDir: string; + let tools: InkToolDefinition[]; + + beforeAll(async () => { + testDir = mkdtempSync(path.join(tmpdir(), 'pi-tools-test-')); + writeFileSync(path.join(testDir, 'hello.txt'), 'Hello, world!\n'); + mkdirSync(path.join(testDir, 'subdir')); + writeFileSync(path.join(testDir, 'subdir', 'nested.txt'), 'nested content\n'); + + tools = await createInkCodingTools({ cwd: testDir }); + }); + + it('loads all 7 coding tools', () => { + const names = tools.map((t) => t.schema.name); + expect(names).toContain('read'); + expect(names).toContain('write'); + expect(names).toContain('edit'); + expect(names).toContain('bash'); + expect(names).toContain('grep'); + expect(names).toContain('find'); + expect(names).toContain('ls'); + expect(names.length).toBe(7); + }); + + it('generates valid Anthropic tool schemas', () => { + for (const tool of tools) { + expect(tool.schema.name).toBeTruthy(); + expect(tool.schema.input_schema).toBeDefined(); + expect(tool.schema.input_schema.type).toBe('object'); + } + }); + + describe('read tool', () => { + it('reads files within workspace', async () => { + const readTool = tools.find((t) => t.schema.name === 'read')!; + const result = await readTool.execute({ path: 'hello.txt' }); + expect(result).toContain('Hello, world!'); + }); + + it('reads nested files', async () => { + const readTool = tools.find((t) => t.schema.name === 'read')!; + const result = await readTool.execute({ path: 'subdir/nested.txt' }); + expect(result).toContain('nested content'); + }); + }); + + describe('write tool', () => { + it('writes a file within workspace', async () => { + const writeTool = tools.find((t) => t.schema.name === 'write')!; + await writeTool.execute({ path: 'output.txt', content: 'written by test\n' }); + const content = readFileSync(path.join(testDir, 'output.txt'), 'utf-8'); + expect(content).toBe('written by test\n'); + }); + }); + + describe('bash tool', () => { + it('runs safe commands', async () => { + const bashTool = tools.find((t) => t.schema.name === 'bash')!; + const result = await bashTool.execute({ command: 'echo hello' }); + expect(result).toContain('hello'); + }); + + it('can list files', async () => { + const bashTool = tools.find((t) => t.schema.name === 'bash')!; + const result = await bashTool.execute({ command: 'ls' }); + expect(result).toContain('hello.txt'); + expect(result).toContain('subdir'); + }); + + it('can read files with cat', async () => { + const bashTool = tools.find((t) => t.schema.name === 'bash')!; + const result = await bashTool.execute({ command: 'cat hello.txt' }); + expect(result).toContain('Hello, world!'); + }); + + it('reports pwd as the workspace directory', async () => { + const bashTool = tools.find((t) => t.schema.name === 'bash')!; + const result = await bashTool.execute({ command: 'pwd' }); + expect(result).toContain(testDir); + }); + }); + + describe('grep tool', () => { + it('finds text in files', async () => { + const grepTool = tools.find((t) => t.schema.name === 'grep')!; + const result = await grepTool.execute({ pattern: 'Hello', path: '.' }); + expect(result).toContain('hello.txt'); + }); + }); + + describe('find tool', () => { + it('finds files by name', async () => { + const findTool = tools.find((t) => t.schema.name === 'find')!; + const result = await findTool.execute({ pattern: '*.txt', path: '.' }); + expect(result).toContain('hello.txt'); + expect(result).toContain('nested.txt'); + }); + }); + + describe('ls tool', () => { + it('lists directory contents', async () => { + const lsTool = tools.find((t) => t.schema.name === 'ls')!; + const result = await lsTool.execute({ path: '.' }); + expect(result).toContain('hello.txt'); + expect(result).toContain('subdir'); + }); + }); + + describe('workspace root enforcement', () => { + it('blocks absolute path escape', async () => { + const readTool = tools.find((t) => t.schema.name === 'read')!; + const result = await readTool.execute({ path: '/etc/hostname' }); + expect(result).toContain('Access denied'); + expect(result).toContain('outside workspace root'); + }); + + it('blocks relative path traversal', async () => { + const readTool = tools.find((t) => t.schema.name === 'read')!; + const result = await readTool.execute({ path: '../../etc/hostname' }); + expect(result).toContain('Access denied'); + }); + + it('can be disabled', async () => { + const unenforced = await createInkCodingTools({ + cwd: testDir, + enforceWorkspaceRoot: false, + include: ['read'], + }); + const readTool = unenforced[0]; + const result = await readTool.execute({ path: '/tmp' }); + // Won't get "Access denied" — might get a different error (reading a dir), but not workspace enforcement + expect(result).not.toContain('Access denied'); + }); + }); + + describe('include/exclude filtering', () => { + it('respects include filter', async () => { + const filtered = await createInkCodingTools({ + cwd: testDir, + include: ['read', 'ls'], + }); + const names = filtered.map((t) => t.schema.name); + expect(names).toEqual(['read', 'ls']); + }); + + it('respects exclude filter', async () => { + const filtered = await createInkCodingTools({ + cwd: testDir, + exclude: ['bash'], + }); + const names = filtered.map((t) => t.schema.name); + expect(names).not.toContain('bash'); + expect(names.length).toBe(6); + }); + }); +}); diff --git a/packages/api/src/agent/tools/pi-coding-tools.ts b/packages/api/src/agent/tools/pi-coding-tools.ts index 9eca63ca..44813e98 100644 --- a/packages/api/src/agent/tools/pi-coding-tools.ts +++ b/packages/api/src/agent/tools/pi-coding-tools.ts @@ -45,10 +45,8 @@ export interface PiCodingToolsConfig { exclude?: Array<'read' | 'write' | 'edit' | 'bash' | 'grep' | 'find' | 'ls'>; /** Enforce workspace root boundary — blocks access outside cwd (default: true) */ enforceWorkspaceRoot?: boolean; - /** Default bash timeout in seconds (default: 120) */ + /** Default bash timeout in seconds when model doesn't specify one (default: 120) */ bashTimeoutSeconds?: number; - /** Bypass bash security checks (for sandbox_bypass studios) */ - bashSandboxBypass?: boolean; } const TOOLS_WITH_PATH_PARAM = new Set(['read', 'write', 'edit', 'grep', 'find', 'ls']); @@ -115,31 +113,8 @@ function formatToolResult(result: unknown): string { return JSON.stringify(result); } -// ─── Bash Security Layer ─── - const DEFAULT_BASH_TIMEOUT_SECONDS = 120; -const BLOCKED_COMMAND_PATTERNS = [ - /\brm\s+-[a-zA-Z]*r[a-zA-Z]*f?\s+\/(?!\S*\/\S)/, - /\bshutdown\b/, - /\breboot\b/, - /\bmkfs\b/, - /\bdd\s+.*of=\/dev\//, - /\b:(){ :\|:& };:/, - />\s*\/dev\/sd[a-z]/, - /\bcurl\b.*\|\s*(?:sudo\s+)?(?:bash|sh|zsh)\b/, - /\bwget\b.*\|\s*(?:sudo\s+)?(?:bash|sh|zsh)\b/, -]; - -function isBashCommandBlocked(command: string): string | null { - for (const pattern of BLOCKED_COMMAND_PATTERNS) { - if (pattern.test(command)) { - return `Blocked: command matches dangerous pattern (${pattern.source})`; - } - } - return null; -} - /** * Create Pi coding tools adapted for Ink's direct-api backend. * @@ -178,7 +153,6 @@ export async function createInkCodingTools( const enforceRoot = config.enforceWorkspaceRoot !== false; const bashTimeout = config.bashTimeoutSeconds ?? DEFAULT_BASH_TIMEOUT_SECONDS; - const bashBypass = config.bashSandboxBypass === true; return tools.map((tool) => ({ schema: { @@ -195,24 +169,9 @@ export async function createInkCodingTools( } } - // Bash security layer - if (tool.name === 'bash') { - const command = (params.command as string) || ''; - - if (!bashBypass) { - const blocked = isBashCommandBlocked(command); - if (blocked) { - logger.warn('Bash command blocked by security policy', { - command: command.slice(0, 200), - }); - return `Error: ${blocked}`; - } - } - - // Inject default timeout if not specified by the model - if (!params.timeout) { - params = { ...params, timeout: bashTimeout }; - } + // Inject default bash timeout if the model doesn't specify one + if (tool.name === 'bash' && !params.timeout) { + params = { ...params, timeout: bashTimeout }; } const callId = `ink-${tool.name}-${Date.now()}`; diff --git a/packages/api/src/services/sessions/direct-api-runner.ts b/packages/api/src/services/sessions/direct-api-runner.ts index 641cbb3a..60cb2d65 100644 --- a/packages/api/src/services/sessions/direct-api-runner.ts +++ b/packages/api/src/services/sessions/direct-api-runner.ts @@ -72,7 +72,7 @@ export class DirectApiRunner implements IRunner { } // Load Pi coding tools scoped to the working directory - const tools = await this.getTools(config.workingDirectory, config.sandboxBypass); + const tools = await this.getTools(config.workingDirectory); const toolSchemas: Anthropic.Tool[] = tools.map((t) => t.schema); if (this.runnerConfig.extraTools) { toolSchemas.push(...this.runnerConfig.extraTools); @@ -211,20 +211,18 @@ export class DirectApiRunner implements IRunner { this.client = new Anthropic({ apiKey }); } - private async getTools(cwd: string, sandboxBypass?: boolean): Promise { - const cacheKey = `${cwd}:${sandboxBypass ? 'bypass' : 'sandbox'}`; - if (this.toolsCache.has(cacheKey)) { - return this.toolsCache.get(cacheKey)!; + private async getTools(cwd: string): Promise { + if (this.toolsCache.has(cwd)) { + return this.toolsCache.get(cwd)!; } const piConfig: PiCodingToolsConfig = { cwd, - bashSandboxBypass: sandboxBypass, ...this.runnerConfig.piToolsConfig, }; const tools = await createInkCodingTools(piConfig); - this.toolsCache.set(cacheKey, tools); + this.toolsCache.set(cwd, tools); return tools; } From 858adfecfa295d298306da4b4a314216cd2bd390 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Wed, 6 May 2026 18:02:22 -0700 Subject: [PATCH 04/21] =?UTF-8?q?feat:=20sandbox=20orchestrator=20?= =?UTF-8?q?=E2=80=94=20Docker=20container=20lifecycle=20for=20strategy-dri?= =?UTF-8?q?ven=20work=20(by=20Wren)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SandboxOrchestrator manages spinning up, stopping, and querying Docker containers for autonomous task group execution. Pure functions for container naming, env var building, and docker arg construction are tested separately (24 unit tests). Integration tests verify real Docker containers with studio mounts, env propagation, and CLI availability. Container naming: ink-sandbox--- Co-Authored-By: Wren --- packages/api/src/services/sandbox/index.ts | 12 + .../sandbox/orchestrator.integration.test.ts | 208 ++++++++++ .../src/services/sandbox/orchestrator.test.ts | 209 ++++++++++ .../api/src/services/sandbox/orchestrator.ts | 379 ++++++++++++++++++ 4 files changed, 808 insertions(+) create mode 100644 packages/api/src/services/sandbox/index.ts create mode 100644 packages/api/src/services/sandbox/orchestrator.integration.test.ts create mode 100644 packages/api/src/services/sandbox/orchestrator.test.ts create mode 100644 packages/api/src/services/sandbox/orchestrator.ts diff --git a/packages/api/src/services/sandbox/index.ts b/packages/api/src/services/sandbox/index.ts new file mode 100644 index 00000000..1c84e3e2 --- /dev/null +++ b/packages/api/src/services/sandbox/index.ts @@ -0,0 +1,12 @@ +export { + SandboxOrchestrator, + buildContainerName, + buildEnvVars, + buildDockerRunArgs, + buildMounts, + type SandboxSpinUpRequest, + type SandboxSpinUpResult, + type SandboxStatusResult, + type BackendAuthName, + type SandboxMount, +} from './orchestrator.js'; diff --git a/packages/api/src/services/sandbox/orchestrator.integration.test.ts b/packages/api/src/services/sandbox/orchestrator.integration.test.ts new file mode 100644 index 00000000..33fa39a6 --- /dev/null +++ b/packages/api/src/services/sandbox/orchestrator.integration.test.ts @@ -0,0 +1,208 @@ +/** + * Integration tests for SandboxOrchestrator. + * + * These tests spin up real Docker containers. They require: + * - Docker daemon running + * - The inkwell:studio-sandbox image built (`ink studio sandbox build`) + * + * Run with: npx vitest run --config vitest.integration.config.ts src/services/sandbox/orchestrator.integration.test.ts + */ + +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import { execFileSync, spawnSync } from 'child_process'; +import { mkdtempSync, writeFileSync, mkdirSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { SandboxOrchestrator, buildContainerName, type SandboxSpinUpRequest } from './orchestrator'; + +function dockerAvailable(): boolean { + try { + execFileSync('docker', ['info'], { stdio: 'ignore', timeout: 5_000 }); + return true; + } catch { + return false; + } +} + +function imageExists(image: string): boolean { + const result = spawnSync('docker', ['image', 'inspect', image], { stdio: 'ignore' }); + return result.status === 0; +} + +const SKIP = !dockerAvailable() || !imageExists('inkwell:studio-sandbox'); + +describe.skipIf(SKIP)('SandboxOrchestrator (integration)', () => { + let orchestrator: SandboxOrchestrator; + let testDir: string; + const containersToCleanup: string[] = []; + + beforeAll(() => { + orchestrator = new SandboxOrchestrator(); + testDir = mkdtempSync(join(tmpdir(), 'sandbox-integ-')); + writeFileSync(join(testDir, 'hello.txt'), 'Integration test file\n'); + mkdirSync(join(testDir, 'src'), { recursive: true }); + writeFileSync(join(testDir, 'src', 'index.ts'), 'console.log("hello");\n'); + }); + + afterAll(async () => { + for (const name of containersToCleanup) { + await orchestrator.stop(name).catch(() => {}); + } + }); + + function makeRequest(overrides: Partial = {}): SandboxSpinUpRequest { + return { + userId: 'test-user', + agentId: 'test-agent', + studioId: `studio-${Date.now()}`, + studioSlug: 'test', + worktreePath: testDir, + repoRoot: testDir, + ...overrides, + }; + } + + it('spins up a container and verifies it is running', async () => { + const request = makeRequest({ studioSlug: 'integ-spinup' }); + const containerName = buildContainerName(request); + containersToCleanup.push(containerName); + + const result = await orchestrator.spinUp(request); + expect(result.success).toBe(true); + expect(result.containerName).toBe(containerName); + + const running = await orchestrator.isRunning(containerName); + expect(running).toBe(true); + }, 30_000); + + it('returns alreadyRunning when container exists', async () => { + const request = makeRequest({ studioSlug: 'integ-already' }); + const containerName = buildContainerName(request); + containersToCleanup.push(containerName); + + const first = await orchestrator.spinUp(request); + expect(first.success).toBe(true); + + const second = await orchestrator.spinUp(request); + expect(second.success).toBe(true); + expect(second.alreadyRunning).toBe(true); + }, 30_000); + + it('mounts studio at /studio and can read files', async () => { + const request = makeRequest({ studioSlug: 'integ-mount' }); + const containerName = buildContainerName(request); + containersToCleanup.push(containerName); + + await orchestrator.spinUp(request); + + const { stdout } = await orchestrator.exec(containerName, ['cat', '/studio/hello.txt']); + expect(stdout.trim()).toBe('Integration test file'); + }, 30_000); + + it('passes env vars into the container', async () => { + const request = makeRequest({ + studioSlug: 'integ-env', + taskGroupId: 'tg-test-123', + taskGroupTitle: 'Test Group', + }); + const containerName = buildContainerName(request); + containersToCleanup.push(containerName); + + await orchestrator.spinUp(request); + + const { stdout: agentId } = await orchestrator.exec(containerName, [ + 'bash', + '-c', + 'echo $AGENT_ID', + ]); + expect(agentId.trim()).toBe('test-agent'); + + const { stdout: tgId } = await orchestrator.exec(containerName, [ + 'bash', + '-c', + 'echo $INK_TASK_GROUP_ID', + ]); + expect(tgId.trim()).toBe('tg-test-123'); + + const { stdout: sandbox } = await orchestrator.exec(containerName, [ + 'bash', + '-c', + 'echo $INK_SANDBOX', + ]); + expect(sandbox.trim()).toBe('docker'); + }, 30_000); + + it('stops a running container', async () => { + const request = makeRequest({ studioSlug: 'integ-stop' }); + const containerName = buildContainerName(request); + // Don't add to cleanup — we're stopping it ourselves + + await orchestrator.spinUp(request); + expect(await orchestrator.isRunning(containerName)).toBe(true); + + const stopped = await orchestrator.stop(containerName); + expect(stopped).toBe(true); + expect(await orchestrator.isRunning(containerName)).toBe(false); + }, 30_000); + + it('gets container status with labels', async () => { + const request = makeRequest({ + studioSlug: 'integ-status', + taskGroupId: 'tg-status-test', + }); + const containerName = buildContainerName(request); + containersToCleanup.push(containerName); + + await orchestrator.spinUp(request); + + const status = await orchestrator.getStatus(containerName); + expect(status.running).toBe(true); + expect(status.labels?.['ink.sandbox']).toBe('true'); + expect(status.labels?.['ink.agent-id']).toBe('test-agent'); + expect(status.labels?.['ink.task-group-id']).toBe('tg-status-test'); + }, 30_000); + + it('lists active sandboxes', async () => { + const request = makeRequest({ studioSlug: 'integ-list' }); + const containerName = buildContainerName(request); + containersToCleanup.push(containerName); + + await orchestrator.spinUp(request); + + const sandboxes = await orchestrator.listSandboxes(); + const found = sandboxes.find((s) => s.containerName === containerName); + expect(found).toBeDefined(); + expect(found?.running).toBe(true); + }, 30_000); + + it('container has node and claude cli available', async () => { + const request = makeRequest({ studioSlug: 'integ-tools' }); + const containerName = buildContainerName(request); + containersToCleanup.push(containerName); + + await orchestrator.spinUp(request); + + const { stdout: nodeVersion } = await orchestrator.exec(containerName, ['node', '--version']); + expect(nodeVersion.trim()).toMatch(/^v22\./); + + const { stdout: claudePath } = await orchestrator.exec(containerName, ['which', 'claude']); + expect(claudePath.trim()).toBeTruthy(); + }, 30_000); + + it('container name includes task group context', async () => { + const request = makeRequest({ + studioSlug: 'integ-naming', + taskGroupId: 'tg-naming-test', + taskGroupTitle: 'Auth Migration', + }); + const containerName = buildContainerName(request); + containersToCleanup.push(containerName); + + expect(containerName).toContain('integ-naming'); + expect(containerName).toContain('auth-migration'); + + await orchestrator.spinUp(request); + const running = await orchestrator.isRunning(containerName); + expect(running).toBe(true); + }, 30_000); +}); diff --git a/packages/api/src/services/sandbox/orchestrator.test.ts b/packages/api/src/services/sandbox/orchestrator.test.ts new file mode 100644 index 00000000..7cb92bae --- /dev/null +++ b/packages/api/src/services/sandbox/orchestrator.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + buildContainerName, + buildEnvVars, + buildDockerRunArgs, + buildMounts, + SandboxOrchestrator, + type SandboxSpinUpRequest, +} from './orchestrator'; + +vi.mock('../../utils/logger', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +const baseRequest: SandboxSpinUpRequest = { + userId: 'user-123', + agentId: 'wren', + studioId: 'studio-abc', + studioSlug: 'wren', + worktreePath: '/tmp/test-studio', + repoRoot: '/tmp/test-repo', + branch: 'wren/feat/sandbox', +}; + +describe('buildContainerName', () => { + it('includes studio slug and digest', () => { + const name = buildContainerName(baseRequest); + expect(name).toMatch(/^ink-sandbox-wren-[a-f0-9]{8}$/); + }); + + it('includes task group slug when provided', () => { + const name = buildContainerName({ + ...baseRequest, + taskGroupId: 'tg-456', + taskGroupTitle: 'Auth Refactor', + }); + expect(name).toMatch(/^ink-sandbox-wren-auth-refactor-[a-f0-9]{8}$/); + }); + + it('falls back to agentId when no studioSlug', () => { + const name = buildContainerName({ ...baseRequest, studioSlug: undefined }); + expect(name).toMatch(/^ink-sandbox-wren-[a-f0-9]{8}$/); + }); + + it('sanitizes special characters', () => { + const name = buildContainerName({ + ...baseRequest, + studioSlug: 'My Studio!!!', + taskGroupId: 'tg-1', + taskGroupTitle: 'Fix: all the BUGS (urgent)', + }); + expect(name).not.toMatch(/[^a-z0-9-]/); + }); + + it('produces different names for different task groups on the same studio', () => { + const name1 = buildContainerName({ ...baseRequest, taskGroupId: 'tg-1', taskGroupTitle: 'A' }); + const name2 = buildContainerName({ ...baseRequest, taskGroupId: 'tg-2', taskGroupTitle: 'B' }); + expect(name1).not.toBe(name2); + }); + + it('truncates long slugs', () => { + const name = buildContainerName({ + ...baseRequest, + studioSlug: 'a-very-long-studio-name-that-goes-on-forever', + }); + // "ink-sandbox-" (12) + slug (max 24) + "-" (1) + digest (8) = max 45 chars + expect(name.length).toBeLessThanOrEqual(50); + }); +}); + +describe('buildEnvVars', () => { + it('includes core env vars', () => { + const env = buildEnvVars(baseRequest); + expect(env.AGENT_ID).toBe('wren'); + expect(env.INK_STUDIO_ID).toBe('studio-abc'); + expect(env.INK_SANDBOX).toBe('docker'); + expect(env.INK_STUDIO_PATH).toBe('/studio'); + }); + + it('rewrites localhost to host.docker.internal', () => { + const env = buildEnvVars({ ...baseRequest, serverUrl: 'http://localhost:3001' }); + expect(env.INK_SERVER_URL).toBe('http://host.docker.internal:3001'); + }); + + it('preserves non-localhost URLs', () => { + const env = buildEnvVars({ ...baseRequest, serverUrl: 'https://api.example.com' }); + expect(env.INK_SERVER_URL).toBe('https://api.example.com'); + }); + + it('includes task group vars when provided', () => { + const env = buildEnvVars({ + ...baseRequest, + taskGroupId: 'tg-456', + taskGroupTitle: 'Auth Refactor', + taskGroupContext: 'Migrating session tokens', + taskGroupThreadKey: 'strategy:tg-456', + }); + expect(env.INK_TASK_GROUP_ID).toBe('tg-456'); + expect(env.INK_TASK_GROUP_TITLE).toBe('Auth Refactor'); + expect(env.INK_TASK_GROUP_CONTEXT).toBe('Migrating session tokens'); + expect(env.INK_TASK_GROUP_THREAD_KEY).toBe('strategy:tg-456'); + }); + + it('omits task group vars when not provided', () => { + const env = buildEnvVars(baseRequest); + expect(env.INK_TASK_GROUP_ID).toBeUndefined(); + expect(env.INK_TASK_GROUP_TITLE).toBeUndefined(); + }); + + it('merges extraEnv', () => { + const env = buildEnvVars({ ...baseRequest, extraEnv: { CUSTOM_VAR: 'value' } }); + expect(env.CUSTOM_VAR).toBe('value'); + }); + + it('includes branch when provided', () => { + const env = buildEnvVars(baseRequest); + expect(env.INK_BRANCH).toBe('wren/feat/sandbox'); + }); +}); + +describe('buildDockerRunArgs', () => { + it('includes required docker run flags', () => { + const args = buildDockerRunArgs(baseRequest); + expect(args[0]).toBe('run'); + expect(args).toContain('--rm'); + expect(args).toContain('-d'); + expect(args).toContain(DEFAULT_IMAGE_NAME()); + }); + + it('sets container name', () => { + const args = buildDockerRunArgs(baseRequest); + const nameIdx = args.indexOf('--name'); + expect(nameIdx).toBeGreaterThan(-1); + expect(args[nameIdx + 1]).toMatch(/^ink-sandbox-/); + }); + + it('sets workdir to /studio', () => { + const args = buildDockerRunArgs(baseRequest); + const idx = args.indexOf('--workdir'); + expect(args[idx + 1]).toBe('/studio'); + }); + + it('adds host.docker.internal mapping', () => { + const args = buildDockerRunArgs(baseRequest); + expect(args).toContain('--add-host'); + const idx = args.indexOf('--add-host'); + expect(args[idx + 1]).toBe('host.docker.internal:host-gateway'); + }); + + it('adds discovery labels', () => { + const args = buildDockerRunArgs(baseRequest); + expect(args).toContain('ink.sandbox=true'); + expect(args).toContain(`ink.agent-id=wren`); + expect(args).toContain(`ink.studio-id=studio-abc`); + }); + + it('adds task group label when provided', () => { + const args = buildDockerRunArgs({ ...baseRequest, taskGroupId: 'tg-456' }); + expect(args).toContain('ink.task-group-id=tg-456'); + }); + + it('sets network none when requested', () => { + const args = buildDockerRunArgs({ ...baseRequest, networkMode: 'none' }); + const idx = args.indexOf('--network'); + expect(idx).toBeGreaterThan(-1); + expect(args[idx + 1]).toBe('none'); + }); + + it('passes env vars as -e flags', () => { + const args = buildDockerRunArgs(baseRequest); + const envPairs = args.filter((_, i) => i > 0 && args[i - 1] === '-e'); + expect(envPairs.some((p) => p.startsWith('AGENT_ID=wren'))).toBe(true); + expect(envPairs.some((p) => p.startsWith('INK_SANDBOX=docker'))).toBe(true); + }); +}); + +describe('buildMounts', () => { + it('returns empty array when worktree path does not exist', () => { + const mounts = buildMounts({ ...baseRequest, worktreePath: '/nonexistent/path' }); + expect(mounts.filter((m) => m.target === '/studio')).toHaveLength(0); + }); +}); + +describe('SandboxOrchestrator', () => { + let mockExecFile: ReturnType; + + beforeEach(() => { + mockExecFile = vi.fn(); + }); + + describe('isRunning', () => { + it('returns true when docker inspect succeeds', async () => { + const orch = new SandboxOrchestrator({ dockerCommand: '/usr/bin/true' }); + // /usr/bin/true always exits 0 — simulates a found container + const result = await orch.isRunning('test-container'); + expect(result).toBe(true); + }); + + it('returns false when docker inspect fails', async () => { + const orch = new SandboxOrchestrator({ dockerCommand: '/usr/bin/false' }); + const result = await orch.isRunning('test-container'); + expect(result).toBe(false); + }); + }); +}); + +function DEFAULT_IMAGE_NAME(): string { + return 'inkwell:studio-sandbox'; +} diff --git a/packages/api/src/services/sandbox/orchestrator.ts b/packages/api/src/services/sandbox/orchestrator.ts new file mode 100644 index 00000000..90d02a19 --- /dev/null +++ b/packages/api/src/services/sandbox/orchestrator.ts @@ -0,0 +1,379 @@ +/** + * Sandbox Orchestrator + * + * Manages Docker container lifecycle for sandboxed agent work. + * Called by the strategy service when an agent needs to be spun up + * in an isolated environment for autonomous task execution. + * + * Design: builds docker run args from DB-sourced studio data (no filesystem + * dependency for planning). Shells out to Docker via child_process. + */ + +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { createHash } from 'crypto'; +import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs'; +import { basename, join, resolve as resolvePath } from 'path'; +import { homedir } from 'os'; +import { logger } from '../../utils/logger.js'; + +const execFileAsync = promisify(execFile); + +const DEFAULT_IMAGE = 'inkwell:studio-sandbox'; +const CONTAINER_HOME = '/home/sb'; +const CONTAINER_LABEL = 'ink.sandbox=true'; + +export type BackendAuthName = 'claude' | 'codex' | 'gemini'; + +export interface SandboxMount { + source: string; + target: string; + readOnly: boolean; +} + +export interface SandboxSpinUpRequest { + userId: string; + agentId: string; + studioId: string; + studioSlug?: string; + worktreePath: string; + repoRoot: string; + branch?: string; + taskGroupId?: string; + taskGroupTitle?: string; + taskGroupContext?: string; + taskGroupThreadKey?: string; + serverUrl?: string; + image?: string; + backendAuth?: BackendAuthName[]; + networkMode?: 'default' | 'none'; + extraEnv?: Record; +} + +export interface SandboxSpinUpResult { + containerName: string; + success: boolean; + alreadyRunning?: boolean; + error?: string; +} + +export interface SandboxStatusResult { + containerName: string; + running: boolean; + image?: string; + startedAt?: string; + labels?: Record; +} + +export function buildContainerName(request: SandboxSpinUpRequest): string { + const label = sanitizeSlug(request.studioSlug || request.agentId || 'studio'); + const parts = [request.worktreePath]; + if (request.taskGroupId) parts.push(request.taskGroupId); + const digest = createHash('sha256').update(parts.join(':')).digest('hex').slice(0, 8); + + if (request.taskGroupId && request.taskGroupTitle) { + const taskSlug = sanitizeSlug(request.taskGroupTitle); + return `ink-sandbox-${label}-${taskSlug}-${digest}`; + } + + return `ink-sandbox-${label}-${digest}`; +} + +function sanitizeSlug(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 24); +} + +export function buildEnvVars(request: SandboxSpinUpRequest): Record { + const serverUrl = request.serverUrl || process.env.INK_SERVER_URL || 'http://localhost:3001'; + + const env: Record = { + HOME: CONTAINER_HOME, + AGENT_ID: request.agentId, + INK_SERVER_URL: rewriteLoopbackUrl(serverUrl), + INK_STUDIO_ID: request.studioId, + INK_SANDBOX: 'docker', + INK_STUDIO_PATH: '/studio', + INK_STUDIOS_PATH: '/studios', + }; + + if (request.taskGroupId) { + env.INK_TASK_GROUP_ID = request.taskGroupId; + } + if (request.taskGroupTitle) { + env.INK_TASK_GROUP_TITLE = request.taskGroupTitle; + } + if (request.taskGroupContext) { + env.INK_TASK_GROUP_CONTEXT = request.taskGroupContext; + } + if (request.taskGroupThreadKey) { + env.INK_TASK_GROUP_THREAD_KEY = request.taskGroupThreadKey; + } + if (request.branch) { + env.INK_BRANCH = request.branch; + } + + if (request.extraEnv) { + Object.assign(env, request.extraEnv); + } + + return env; +} + +function rewriteLoopbackUrl(rawUrl: string): string { + try { + const parsed = new URL(rawUrl); + if (['localhost', '127.0.0.1', '::1', '[::1]'].includes(parsed.hostname)) { + parsed.hostname = 'host.docker.internal'; + return parsed.toString().replace(/\/+$/, ''); + } + } catch { + // Not a URL — leave it + } + return rawUrl; +} + +export function buildMounts(request: SandboxSpinUpRequest): SandboxMount[] { + const mounts: SandboxMount[] = []; + + if (existsSync(request.worktreePath)) { + mounts.push({ source: request.worktreePath, target: '/studio', readOnly: false }); + } + + // Mount patched MCP config if it exists + const patchedMcpPath = patchMcpConfig(request.worktreePath); + if (patchedMcpPath) { + mounts.push({ source: patchedMcpPath, target: '/studio/.mcp.json', readOnly: true }); + } + + // Backend auth dirs (read-only) + const home = homedir(); + const authDirs: Record = { + claude: join(home, '.claude'), + codex: join(home, '.codex'), + gemini: join(home, '.gemini'), + }; + + for (const backend of request.backendAuth || []) { + const sourceDir = authDirs[backend]; + if (existsSync(sourceDir)) { + mounts.push({ + source: sourceDir, + target: `${CONTAINER_HOME}/.${backend}`, + readOnly: true, + }); + } + } + + return mounts; +} + +function patchMcpConfig(studioPath: string): string | undefined { + const sourcePath = join(studioPath, '.mcp.json'); + if (!existsSync(sourcePath)) return undefined; + + try { + const parsed = JSON.parse(readFileSync(sourcePath, 'utf-8')) as { + mcpServers?: Record; + }; + const servers = parsed.mcpServers; + if (!servers) return undefined; + + let modified = false; + for (const server of Object.values(servers)) { + if (server?.type === 'http' && typeof server.url === 'string') { + const rewritten = rewriteLoopbackUrl(server.url); + if (rewritten !== server.url) { + server.url = rewritten; + modified = true; + } + } + } + + if (!modified) return undefined; + + const runtimeDir = join(studioPath, '.ink', 'runtime', 'sandbox'); + mkdirSync(runtimeDir, { recursive: true }); + const targetPath = join(runtimeDir, 'mcp.docker.json'); + writeFileSync(targetPath, JSON.stringify(parsed, null, 2) + '\n', 'utf-8'); + return targetPath; + } catch { + return undefined; + } +} + +export function buildDockerRunArgs(request: SandboxSpinUpRequest): string[] { + const containerName = buildContainerName(request); + const image = request.image || DEFAULT_IMAGE; + const env = buildEnvVars(request); + const mounts = buildMounts(request); + + const args = ['run', '--rm', '-d', '--name', containerName]; + args.push('--workdir', '/studio'); + args.push('--add-host', 'host.docker.internal:host-gateway'); + args.push('--hostname', containerName); + + // Labels for discovery and lifecycle management + args.push('--label', CONTAINER_LABEL); + args.push('--label', `ink.agent-id=${request.agentId}`); + args.push('--label', `ink.studio-id=${request.studioId}`); + if (request.taskGroupId) { + args.push('--label', `ink.task-group-id=${request.taskGroupId}`); + } + + // Preserve host user for file ownership + const uid = typeof process.getuid === 'function' ? process.getuid() : undefined; + const gid = typeof process.getgid === 'function' ? process.getgid() : undefined; + if (uid !== undefined && gid !== undefined) { + args.push('--user', `${uid}:${gid}`); + } + + if (request.networkMode === 'none') { + args.push('--network', 'none'); + } + + for (const [key, value] of Object.entries(env)) { + args.push('-e', `${key}=${value}`); + } + + for (const mount of mounts) { + const ro = mount.readOnly ? ',readonly' : ''; + args.push('--mount', `type=bind,src=${mount.source},dst=${mount.target}${ro}`); + } + + args.push(image); + return args; +} + +// ============================================================================ +// Orchestrator Class +// ============================================================================ + +export class SandboxOrchestrator { + private dockerCommand: string; + + constructor(options: { dockerCommand?: string } = {}) { + this.dockerCommand = options.dockerCommand || 'docker'; + } + + async spinUp(request: SandboxSpinUpRequest): Promise { + const containerName = buildContainerName(request); + + // Check if already running + const alreadyRunning = await this.isRunning(containerName); + if (alreadyRunning) { + logger.info(`Sandbox already running: ${containerName}`); + return { containerName, success: true, alreadyRunning: true }; + } + + const args = buildDockerRunArgs(request); + + try { + await execFileAsync(this.dockerCommand, args, { timeout: 30_000 }); + logger.info('Sandbox container started', { + containerName, + agentId: request.agentId, + studioId: request.studioId, + taskGroupId: request.taskGroupId, + }); + return { containerName, success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error('Sandbox spin-up failed', { containerName, error: message }); + return { containerName, success: false, error: message }; + } + } + + async stop(containerName: string): Promise { + try { + await execFileAsync(this.dockerCommand, ['rm', '-f', containerName], { timeout: 15_000 }); + logger.info(`Sandbox stopped: ${containerName}`); + return true; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.warn(`Sandbox stop failed: ${containerName}`, { error: message }); + return false; + } + } + + async isRunning(containerName: string): Promise { + try { + await execFileAsync(this.dockerCommand, ['container', 'inspect', containerName], { + timeout: 5_000, + }); + return true; + } catch { + return false; + } + } + + async getStatus(containerName: string): Promise { + try { + const { stdout } = await execFileAsync( + this.dockerCommand, + ['container', 'inspect', '--format', '{{json .}}', containerName], + { timeout: 5_000 } + ); + const info = JSON.parse(stdout.trim()); + return { + containerName, + running: info.State?.Running === true, + image: info.Config?.Image, + startedAt: info.State?.StartedAt, + labels: info.Config?.Labels, + }; + } catch { + return { containerName, running: false }; + } + } + + async listSandboxes(): Promise { + try { + const { stdout } = await execFileAsync( + this.dockerCommand, + ['ps', '--filter', 'label=ink.sandbox=true', '--format', '{{json .}}'], + { timeout: 10_000 } + ); + + if (!stdout.trim()) return []; + + return stdout + .trim() + .split('\n') + .map((line) => { + const info = JSON.parse(line); + return { + containerName: info.Names, + running: info.State === 'running', + image: info.Image, + labels: info.Labels ? parseLabelString(info.Labels) : undefined, + }; + }); + } catch { + return []; + } + } + + async exec( + containerName: string, + command: string[] + ): Promise<{ stdout: string; stderr: string }> { + return execFileAsync(this.dockerCommand, ['exec', containerName, ...command], { + timeout: 60_000, + }); + } +} + +function parseLabelString(labels: string): Record { + const result: Record = {}; + for (const pair of labels.split(',')) { + const eqIdx = pair.indexOf('='); + if (eqIdx > 0) { + result[pair.slice(0, eqIdx)] = pair.slice(eqIdx + 1); + } + } + return result; +} From 3f398803c2a8b7ec5eca0d5053426f83bbc85917 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Wed, 6 May 2026 18:05:16 -0700 Subject: [PATCH 05/21] =?UTF-8?q?feat:=20wire=20sandbox=20orchestrator=20i?= =?UTF-8?q?nto=20strategy=20service=20=E2=80=94=20start=5Fstrategy=20can?= =?UTF-8?q?=20spin=20up=20containers=20(by=20Wren)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When strategy_config.sandbox is true, startStrategy resolves the target studio from DB metadata and calls SandboxOrchestrator.spinUp(). Sandbox failure is non-fatal — strategy still activates, the error is logged to the activity stream. Added sandbox/sandboxBackendAuth fields to StrategyConfig. 4 new unit tests covering: sandbox disabled, sandbox enabled with studio, sandbox failure graceful degradation, missing studioId skip. Co-Authored-By: Wren --- .../repositories/task-groups.repository.ts | 4 + .../api/src/services/strategy.service.test.ts | 235 ++++++++++++++++++ packages/api/src/services/strategy.service.ts | 87 ++++++- 3 files changed, 325 insertions(+), 1 deletion(-) diff --git a/packages/api/src/data/repositories/task-groups.repository.ts b/packages/api/src/data/repositories/task-groups.repository.ts index 880fb4c4..fcbe184b 100644 --- a/packages/api/src/data/repositories/task-groups.repository.ts +++ b/packages/api/src/data/repositories/task-groups.repository.ts @@ -68,6 +68,10 @@ export interface StrategyConfig { watchdogIntervalMinutes?: number; /** Supervisor agent identity ID — gets check-in notifications and a final audit on completion */ supervisorId?: string; + /** Run the strategy in a sandboxed Docker container */ + sandbox?: boolean; + /** Backend auth dirs to mount in the sandbox (default: ['claude']) */ + sandboxBackendAuth?: Array<'claude' | 'codex' | 'gemini'>; } export interface CreateTaskGroupInput { diff --git a/packages/api/src/services/strategy.service.test.ts b/packages/api/src/services/strategy.service.test.ts index 418d115d..feeedb52 100644 --- a/packages/api/src/services/strategy.service.test.ts +++ b/packages/api/src/services/strategy.service.test.ts @@ -126,6 +126,9 @@ function createMockDataComposer() { activityStream: { logActivity: vi.fn().mockResolvedValue({ id: 'activity-1' }), }, + studios: { + findById: vi.fn().mockResolvedValue(null), + }, }, }; } @@ -1501,4 +1504,236 @@ describe('StrategyService', () => { expect(payload.content).toContain(pendingTask.title); }); }); + + describe('sandbox integration', () => { + it('does not spin up sandbox when config.sandbox is not set', async () => { + const group = createMockGroup({ strategy: null, status: 'active' }); + const task = createMockTask(); + const mockOrchestrator = { spinUp: vi.fn(), isRunning: vi.fn() }; + + dc.repositories.taskGroups.findById.mockResolvedValue(group); + dc.repositories.taskGroups.update.mockResolvedValue({ + ...group, + strategy: 'persistence', + status: 'active', + strategy_config: {}, + }); + + const mockClient = dc.getClient(); + mockClient.from.mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + in: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ data: task, error: null }), + }), + }), + }), + }), + }), + insert: vi.fn().mockResolvedValue({ data: null, error: null }), + update: vi.fn().mockReturnValue({ + contains: vi.fn().mockResolvedValue({ data: null, error: null }), + }), + }); + + const serviceWithSandbox = new StrategyService(dc as any, mockOrchestrator as any); + const result = await serviceWithSandbox.startStrategy({ + groupId: 'group-1', + userId: 'user-123', + strategy: 'persistence', + ownerAgentId: 'wren', + }); + + expect(mockOrchestrator.spinUp).not.toHaveBeenCalled(); + expect(result.sandbox).toBeUndefined(); + }); + + it('spins up sandbox when config.sandbox is true and studio exists', async () => { + const group = createMockGroup({ + strategy: null, + status: 'active', + metadata: { studioId: 'studio-abc' }, + }); + const task = createMockTask(); + const mockOrchestrator = { + spinUp: vi.fn().mockResolvedValue({ + containerName: 'ink-sandbox-wren-test-12345678', + success: true, + }), + isRunning: vi.fn(), + }; + + dc.repositories.taskGroups.findById.mockResolvedValue(group); + dc.repositories.taskGroups.update.mockResolvedValue({ + ...group, + strategy: 'persistence', + status: 'active', + strategy_config: { sandbox: true }, + }); + dc.repositories.studios.findById.mockResolvedValue({ + id: 'studio-abc', + userId: 'user-123', + agentId: 'wren', + worktreePath: '/tmp/test-studio', + repoRoot: '/tmp/test-repo', + branch: 'wren/feat/test', + slug: 'wren', + }); + + const mockClient = dc.getClient(); + mockClient.from.mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + in: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ data: task, error: null }), + }), + }), + }), + }), + }), + insert: vi.fn().mockResolvedValue({ data: null, error: null }), + update: vi.fn().mockReturnValue({ + contains: vi.fn().mockResolvedValue({ data: null, error: null }), + }), + }); + + const serviceWithSandbox = new StrategyService(dc as any, mockOrchestrator as any); + const result = await serviceWithSandbox.startStrategy({ + groupId: 'group-1', + userId: 'user-123', + strategy: 'persistence', + ownerAgentId: 'wren', + }); + + expect(mockOrchestrator.spinUp).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: 'wren', + studioId: 'studio-abc', + worktreePath: '/tmp/test-studio', + taskGroupId: 'group-1', + taskGroupTitle: 'Test Strategy Group', + }) + ); + expect(result.sandbox).toBeDefined(); + expect(result.sandbox?.success).toBe(true); + }); + + it('handles sandbox spin-up failure gracefully', async () => { + const group = createMockGroup({ + strategy: null, + status: 'active', + metadata: { studioId: 'studio-abc' }, + }); + const task = createMockTask(); + const mockOrchestrator = { + spinUp: vi.fn().mockResolvedValue({ + containerName: 'ink-sandbox-wren-test-12345678', + success: false, + error: 'Docker daemon not running', + }), + isRunning: vi.fn(), + }; + + dc.repositories.taskGroups.findById.mockResolvedValue(group); + dc.repositories.taskGroups.update.mockResolvedValue({ + ...group, + strategy: 'persistence', + status: 'active', + strategy_config: { sandbox: true }, + }); + dc.repositories.studios.findById.mockResolvedValue({ + id: 'studio-abc', + userId: 'user-123', + agentId: 'wren', + worktreePath: '/tmp/test-studio', + repoRoot: '/tmp/test-repo', + branch: 'wren/feat/test', + slug: 'wren', + }); + + const mockClient = dc.getClient(); + mockClient.from.mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + in: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ data: task, error: null }), + }), + }), + }), + }), + }), + insert: vi.fn().mockResolvedValue({ data: null, error: null }), + update: vi.fn().mockReturnValue({ + contains: vi.fn().mockResolvedValue({ data: null, error: null }), + }), + }); + + const serviceWithSandbox = new StrategyService(dc as any, mockOrchestrator as any); + const result = await serviceWithSandbox.startStrategy({ + groupId: 'group-1', + userId: 'user-123', + strategy: 'persistence', + ownerAgentId: 'wren', + }); + + // Strategy still starts — sandbox failure is non-fatal + expect(result.action).toBe('next_task'); + expect(result.sandbox?.success).toBe(false); + expect(result.sandbox?.error).toContain('Docker daemon'); + }); + + it('skips sandbox when no studioId in metadata', async () => { + const group = createMockGroup({ + strategy: null, + status: 'active', + metadata: {}, + }); + const task = createMockTask(); + const mockOrchestrator = { spinUp: vi.fn(), isRunning: vi.fn() }; + + dc.repositories.taskGroups.findById.mockResolvedValue(group); + dc.repositories.taskGroups.update.mockResolvedValue({ + ...group, + strategy: 'persistence', + status: 'active', + strategy_config: { sandbox: true }, + }); + + const mockClient = dc.getClient(); + mockClient.from.mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + in: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ data: task, error: null }), + }), + }), + }), + }), + }), + insert: vi.fn().mockResolvedValue({ data: null, error: null }), + update: vi.fn().mockReturnValue({ + contains: vi.fn().mockResolvedValue({ data: null, error: null }), + }), + }); + + const serviceWithSandbox = new StrategyService(dc as any, mockOrchestrator as any); + const result = await serviceWithSandbox.startStrategy({ + groupId: 'group-1', + userId: 'user-123', + strategy: 'persistence', + ownerAgentId: 'wren', + }); + + expect(mockOrchestrator.spinUp).not.toHaveBeenCalled(); + expect(result.sandbox).toBeUndefined(); + }); + }); }); diff --git a/packages/api/src/services/strategy.service.ts b/packages/api/src/services/strategy.service.ts index 66ef0594..d9cbf0c7 100644 --- a/packages/api/src/services/strategy.service.ts +++ b/packages/api/src/services/strategy.service.ts @@ -23,6 +23,7 @@ import type { import type { ProjectTask, TaskAssignment } from '../data/repositories/project-tasks.repository'; import { handleSendToInbox } from '../mcp/tools/inbox-handlers'; import { logger } from '../utils/logger'; +import type { SandboxOrchestrator, SandboxSpinUpResult } from './sandbox/orchestrator'; // ============================================================================ // Types @@ -51,6 +52,8 @@ export interface StrategyAdvanceResult { notified?: boolean; /** Completion stats when group is done */ stats?: { total: number; completed: number }; + /** Sandbox container info (when sandbox mode is active) */ + sandbox?: SandboxSpinUpResult; } export interface StrategyStatus { @@ -158,7 +161,15 @@ const STRATEGY_PROMPTS: Record | null; @@ -228,6 +239,9 @@ export class StrategyService { // actually begins, matching how heartbeats/reminders already deliver. const triggered = await this.triggerOwnerAgent(updated, nextTask, 'strategy_kickoff'); + // Spin up sandbox container if configured + const sandboxResult = await this.maybeSpinUpSandbox(updated); + // Log strategy start await this.logStrategyEvent( updated, @@ -237,6 +251,9 @@ export class StrategyService { firstTaskId: nextTask.id, firstTaskTitle: nextTask.title, ownerTriggered: triggered, + sandbox: sandboxResult + ? { containerName: sandboxResult.containerName, success: sandboxResult.success } + : undefined, } ); @@ -247,6 +264,7 @@ export class StrategyService { nextTask, prompt, notified: triggered, + sandbox: sandboxResult || undefined, }; } @@ -864,6 +882,73 @@ export class StrategyService { } } + /** + * Spin up a sandbox Docker container for the strategy's owner agent. + * Resolves the studio from DB metadata, builds a SandboxSpinUpRequest, + * and delegates to the orchestrator. Returns null if sandbox mode is + * not enabled or no orchestrator is configured. + */ + private async maybeSpinUpSandbox(group: TaskGroup): Promise { + const config = group.strategy_config as StrategyConfig; + if (!config.sandbox) return null; + if (!this.sandboxOrchestrator) { + logger.warn( + `Strategy group ${group.id} has sandbox enabled but no SandboxOrchestrator configured` + ); + return null; + } + + const metadata = (group.metadata || {}) as Record; + const studioId = typeof metadata.studioId === 'string' ? metadata.studioId : undefined; + if (!studioId) { + logger.warn(`Strategy group ${group.id}: sandbox requested but no studioId in metadata`); + return null; + } + + const studio = await this.dataComposer.repositories.studios.findById(studioId); + if (!studio) { + logger.warn(`Strategy group ${group.id}: studio ${studioId} not found`); + return null; + } + + const result = await this.sandboxOrchestrator.spinUp({ + userId: group.user_id, + agentId: group.owner_agent_id || studio.agentId || 'unknown', + studioId: studio.id, + studioSlug: studio.slug || undefined, + worktreePath: studio.worktreePath, + repoRoot: studio.repoRoot, + branch: studio.branch, + taskGroupId: group.id, + taskGroupTitle: group.title, + taskGroupContext: group.context_summary || undefined, + taskGroupThreadKey: group.thread_key || `strategy:${group.id}`, + backendAuth: (config.sandboxBackendAuth as any) || ['claude'], + }); + + if (result.success) { + await this.logStrategyEvent( + group, + 'sandbox_started', + `Sandbox container started: ${result.containerName}`, + { + containerName: result.containerName, + studioId: studio.id, + alreadyRunning: result.alreadyRunning, + } + ); + } else { + await this.logStrategyEvent( + group, + 'sandbox_failed', + `Sandbox spin-up failed: ${result.error}`, + { containerName: result.containerName, error: result.error } + ); + } + + return result; + } + /** * Public entry point for watchdog-driven triggers. Called from the heartbeat * reminder-delivery path when a scheduled_reminder has From d74966daf3cf50cfd4efb8dc347ba708cfe033da Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Thu, 7 May 2026 01:21:26 -0700 Subject: [PATCH 06/21] feat: MCP config patching + live tests for sandbox containers (by Wren) patchMcpConfig now strips stdio/command MCP servers (can't spawn inside containers) and keeps only HTTP servers with loopback URL rewriting. Mount ~/.claude.json alongside ~/.claude/ for Claude Code config. Live tests verify: worktree read/write from container, source file modification visible on host, patched MCP config correctness. LLM response tests gated behind ANTHROPIC_API_KEY + INK_LIVE_TESTS=1. 5 new MCP patching unit tests. 3 live integration tests (6 total, 3 skipped without API key). Co-Authored-By: Wren --- .../orchestrator.live.integration.test.ts | 254 ++++++++++++++++++ .../src/services/sandbox/orchestrator.test.ts | 89 ++++++ .../api/src/services/sandbox/orchestrator.ts | 35 ++- 3 files changed, 367 insertions(+), 11 deletions(-) create mode 100644 packages/api/src/services/sandbox/orchestrator.live.integration.test.ts diff --git a/packages/api/src/services/sandbox/orchestrator.live.integration.test.ts b/packages/api/src/services/sandbox/orchestrator.live.integration.test.ts new file mode 100644 index 00000000..d7e2124d --- /dev/null +++ b/packages/api/src/services/sandbox/orchestrator.live.integration.test.ts @@ -0,0 +1,254 @@ +/** + * Live tests for SandboxOrchestrator. + * + * These tests spin up real Docker containers and make real LLM API calls. + * They require: + * - Docker daemon running + * - The inkwell:studio-sandbox image built + * - ANTHROPIC_API_KEY set (for Claude calls) + * - Inkwell server running on localhost:3001 (for MCP tests) + * + * Run with: INK_LIVE_TESTS=1 npx vitest run --config vitest.integration.config.ts src/services/sandbox/orchestrator.live.test.ts + */ + +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import { execFileSync, spawnSync } from 'child_process'; +import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, existsSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { SandboxOrchestrator, buildContainerName, type SandboxSpinUpRequest } from './orchestrator'; + +function dockerAvailable(): boolean { + try { + execFileSync('docker', ['info'], { stdio: 'ignore', timeout: 5_000 }); + return true; + } catch { + return false; + } +} + +function imageExists(image: string): boolean { + const result = spawnSync('docker', ['image', 'inspect', image], { stdio: 'ignore' }); + return result.status === 0; +} + +function inkwellReachable(): boolean { + try { + execFileSync('curl', ['-sf', '-o', '/dev/null', 'http://localhost:3001/health'], { + timeout: 3_000, + }); + return true; + } catch { + return false; + } +} + +const SKIP = + process.env.INK_LIVE_TESTS !== '1' || + !dockerAvailable() || + !imageExists('inkwell:studio-sandbox'); + +describe.skipIf(SKIP)('SandboxOrchestrator (live)', () => { + let orchestrator: SandboxOrchestrator; + let testDir: string; + const containersToCleanup: string[] = []; + + beforeAll(() => { + orchestrator = new SandboxOrchestrator(); + testDir = mkdtempSync(join(tmpdir(), 'sandbox-live-')); + + // Create a minimal studio with files to manipulate + writeFileSync(join(testDir, 'README.md'), '# Test Project\n\nThis is a sandbox live test.\n'); + mkdirSync(join(testDir, 'src'), { recursive: true }); + writeFileSync( + join(testDir, 'src', 'hello.ts'), + 'export function greet() { return "hello"; }\n' + ); + + // Create .mcp.json with inkwell (HTTP) + a stdio server to verify stripping + writeFileSync( + join(testDir, '.mcp.json'), + JSON.stringify( + { + mcpServers: { + inkwell: { type: 'http', url: 'http://localhost:3001/mcp' }, + playwright: { type: 'stdio', command: 'npx', args: ['@playwright/mcp'] }, + }, + }, + null, + 2 + ) + ); + }); + + afterAll(async () => { + for (const name of containersToCleanup) { + await orchestrator.stop(name).catch(() => {}); + } + }); + + function makeRequest(overrides: Partial = {}): SandboxSpinUpRequest { + return { + userId: 'live-test-user', + agentId: 'live-test-agent', + studioId: `studio-live-${Date.now()}`, + studioSlug: 'live', + worktreePath: testDir, + repoRoot: testDir, + backendAuth: ['claude'], + extraEnv: { + // Pass API key into the container for Claude calls + ...(process.env.ANTHROPIC_API_KEY + ? { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY } + : {}), + }, + ...overrides, + }; + } + + describe('worktree manipulation', () => { + it('agent can read and write files in the mounted studio', async () => { + const request = makeRequest({ studioSlug: 'live-worktree' }); + const containerName = buildContainerName(request); + containersToCleanup.push(containerName); + + await orchestrator.spinUp(request); + + // Read existing file + const { stdout: readResult } = await orchestrator.exec(containerName, [ + 'cat', + '/studio/README.md', + ]); + expect(readResult).toContain('Test Project'); + + // Write a new file + await orchestrator.exec(containerName, [ + 'bash', + '-c', + 'echo "created by sandbox" > /studio/sandbox-output.txt', + ]); + + // Verify file exists on the host (bind mount = shared filesystem) + const hostPath = join(testDir, 'sandbox-output.txt'); + expect(existsSync(hostPath)).toBe(true); + expect(readFileSync(hostPath, 'utf-8').trim()).toBe('created by sandbox'); + }, 30_000); + + it('agent can modify existing source files', async () => { + const request = makeRequest({ studioSlug: 'live-modify' }); + const containerName = buildContainerName(request); + containersToCleanup.push(containerName); + + await orchestrator.spinUp(request); + + // Append to an existing file + await orchestrator.exec(containerName, [ + 'bash', + '-c', + 'echo \'export function farewell() { return "goodbye"; }\' >> /studio/src/hello.ts', + ]); + + const content = readFileSync(join(testDir, 'src', 'hello.ts'), 'utf-8'); + expect(content).toContain('farewell'); + expect(content).toContain('greet'); + }, 30_000); + }); + + describe('MCP config patching', () => { + it('patched config contains only HTTP servers with rewritten URLs', async () => { + const request = makeRequest({ studioSlug: 'live-mcp' }); + const containerName = buildContainerName(request); + containersToCleanup.push(containerName); + + await orchestrator.spinUp(request); + + const { stdout } = await orchestrator.exec(containerName, ['cat', '/studio/.mcp.json']); + const config = JSON.parse(stdout); + + // inkwell should be present with rewritten URL + expect(config.mcpServers.inkwell).toBeDefined(); + expect(config.mcpServers.inkwell.url).toContain('host.docker.internal'); + + // stdio server should be stripped + expect(config.mcpServers.playwright).toBeUndefined(); + }, 30_000); + }); + + describe('LLM response', () => { + const SKIP_LLM = !process.env.ANTHROPIC_API_KEY; + + it.skipIf(SKIP_LLM)( + 'gets a live Claude response inside the container', + async () => { + const request = makeRequest({ studioSlug: 'live-llm' }); + const containerName = buildContainerName(request); + containersToCleanup.push(containerName); + + await orchestrator.spinUp(request); + + // Use claude CLI with --print (non-interactive, single-shot) + // Ask a trivially answerable question to minimize cost + const { stdout } = await orchestrator.exec(containerName, [ + 'claude', + '--print', + '--model', + 'claude-haiku-4-5-20251001', + 'Reply with exactly the word SANDBOX and nothing else.', + ]); + + expect(stdout.toUpperCase()).toContain('SANDBOX'); + }, + 120_000 + ); + + it.skipIf(SKIP_LLM)( + 'Claude can read workspace files via coding tools', + async () => { + const request = makeRequest({ studioSlug: 'live-read' }); + const containerName = buildContainerName(request); + containersToCleanup.push(containerName); + + await orchestrator.spinUp(request); + + const { stdout } = await orchestrator.exec(containerName, [ + 'claude', + '--print', + '--model', + 'claude-haiku-4-5-20251001', + '--allowedTools', + 'Read', + 'Read the file src/hello.ts and tell me what function it exports. Reply with just the function name.', + ]); + + expect(stdout.toLowerCase()).toContain('greet'); + }, + 120_000 + ); + }); + + describe('Inkwell MCP access', () => { + const SKIP_INKWELL = !inkwellReachable() || !process.env.ANTHROPIC_API_KEY; + + it.skipIf(SKIP_INKWELL)( + 'container can reach Inkwell server via host.docker.internal', + async () => { + const request = makeRequest({ studioSlug: 'live-inkwell-reach' }); + const containerName = buildContainerName(request); + containersToCleanup.push(containerName); + + await orchestrator.spinUp(request); + + // Verify HTTP connectivity to the Inkwell server + const { stdout } = await orchestrator.exec(containerName, [ + 'curl', + '-sf', + 'http://host.docker.internal:3001/health', + ]); + + // Health endpoint should return something (OK, JSON, etc.) + expect(stdout.length).toBeGreaterThan(0); + }, + 30_000 + ); + }); +}); diff --git a/packages/api/src/services/sandbox/orchestrator.test.ts b/packages/api/src/services/sandbox/orchestrator.test.ts index 7cb92bae..33512d33 100644 --- a/packages/api/src/services/sandbox/orchestrator.test.ts +++ b/packages/api/src/services/sandbox/orchestrator.test.ts @@ -1,9 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, readFileSync, mkdirSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { buildContainerName, buildEnvVars, buildDockerRunArgs, buildMounts, + patchMcpConfig, SandboxOrchestrator, type SandboxSpinUpRequest, } from './orchestrator'; @@ -181,6 +185,91 @@ describe('buildMounts', () => { }); }); +describe('patchMcpConfig', () => { + it('rewrites localhost URLs to host.docker.internal', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'mcp-patch-')); + writeFileSync( + join(tmpDir, '.mcp.json'), + JSON.stringify({ + mcpServers: { + inkwell: { type: 'http', url: 'http://localhost:3001/mcp' }, + }, + }) + ); + + const result = patchMcpConfig(tmpDir); + expect(result).toBeTruthy(); + const patched = JSON.parse(readFileSync(result!, 'utf-8')); + expect(patched.mcpServers.inkwell.url).toBe('http://host.docker.internal:3001/mcp'); + }); + + it('strips stdio/command-based servers', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'mcp-patch-')); + writeFileSync( + join(tmpDir, '.mcp.json'), + JSON.stringify({ + mcpServers: { + inkwell: { type: 'http', url: 'http://localhost:3001/mcp' }, + inkmail: { command: 'npx', args: ['tsx', 'packages/channel-plugin/index.ts'] }, + playwright: { type: 'stdio', command: 'npx', args: ['@playwright/mcp'] }, + }, + }) + ); + + const result = patchMcpConfig(tmpDir); + expect(result).toBeTruthy(); + const patched = JSON.parse(readFileSync(result!, 'utf-8')); + expect(Object.keys(patched.mcpServers)).toEqual(['inkwell']); + expect(patched.mcpServers.inkmail).toBeUndefined(); + expect(patched.mcpServers.playwright).toBeUndefined(); + }); + + it('preserves remote HTTP servers without rewriting', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'mcp-patch-')); + writeFileSync( + join(tmpDir, '.mcp.json'), + JSON.stringify({ + mcpServers: { + github: { + type: 'http', + url: 'https://api.githubcopilot.com/mcp/', + headers: { Authorization: 'Bearer token' }, + }, + inkwell: { type: 'http', url: 'http://localhost:3001/mcp' }, + }, + }) + ); + + const result = patchMcpConfig(tmpDir); + expect(result).toBeTruthy(); + const patched = JSON.parse(readFileSync(result!, 'utf-8')); + expect(patched.mcpServers.github.url).toBe('https://api.githubcopilot.com/mcp/'); + expect(patched.mcpServers.github.headers).toEqual({ Authorization: 'Bearer token' }); + expect(patched.mcpServers.inkwell.url).toBe('http://host.docker.internal:3001/mcp'); + }); + + it('returns undefined when no HTTP servers exist', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'mcp-patch-')); + writeFileSync( + join(tmpDir, '.mcp.json'), + JSON.stringify({ + mcpServers: { + playwright: { type: 'stdio', command: 'npx', args: ['@playwright/mcp'] }, + }, + }) + ); + + const result = patchMcpConfig(tmpDir); + expect(result).toBeUndefined(); + }); + + it('returns undefined when .mcp.json does not exist', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'mcp-patch-')); + const result = patchMcpConfig(tmpDir); + expect(result).toBeUndefined(); + }); +}); + describe('SandboxOrchestrator', () => { let mockExecFile: ReturnType; diff --git a/packages/api/src/services/sandbox/orchestrator.ts b/packages/api/src/services/sandbox/orchestrator.ts index 90d02a19..98f76734 100644 --- a/packages/api/src/services/sandbox/orchestrator.ts +++ b/packages/api/src/services/sandbox/orchestrator.ts @@ -166,39 +166,52 @@ export function buildMounts(request: SandboxSpinUpRequest): SandboxMount[] { readOnly: true, }); } + + // Claude Code also needs ~/.claude.json (config separate from ~/.claude/ dir) + if (backend === 'claude') { + const claudeJson = join(home, '.claude.json'); + if (existsSync(claudeJson)) { + mounts.push({ + source: claudeJson, + target: `${CONTAINER_HOME}/.claude.json`, + readOnly: true, + }); + } + } } return mounts; } -function patchMcpConfig(studioPath: string): string | undefined { +/** + * Patch .mcp.json for Docker: rewrite loopback URLs to host.docker.internal + * and strip stdio/command-based servers (they can't spawn inside the container). + */ +export function patchMcpConfig(studioPath: string): string | undefined { const sourcePath = join(studioPath, '.mcp.json'); if (!existsSync(sourcePath)) return undefined; try { const parsed = JSON.parse(readFileSync(sourcePath, 'utf-8')) as { - mcpServers?: Record; + mcpServers?: Record>; }; const servers = parsed.mcpServers; if (!servers) return undefined; - let modified = false; - for (const server of Object.values(servers)) { + const patched: Record> = {}; + for (const [name, server] of Object.entries(servers)) { + // Only keep HTTP transport servers — stdio/command servers can't run in the container if (server?.type === 'http' && typeof server.url === 'string') { - const rewritten = rewriteLoopbackUrl(server.url); - if (rewritten !== server.url) { - server.url = rewritten; - modified = true; - } + patched[name] = { ...server, url: rewriteLoopbackUrl(server.url) }; } } - if (!modified) return undefined; + if (Object.keys(patched).length === 0) return undefined; const runtimeDir = join(studioPath, '.ink', 'runtime', 'sandbox'); mkdirSync(runtimeDir, { recursive: true }); const targetPath = join(runtimeDir, 'mcp.docker.json'); - writeFileSync(targetPath, JSON.stringify(parsed, null, 2) + '\n', 'utf-8'); + writeFileSync(targetPath, JSON.stringify({ mcpServers: patched }, null, 2) + '\n', 'utf-8'); return targetPath; } catch { return undefined; From b5d834f889a1a86e8fc4c8162b928a56cd5e45a6 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Thu, 7 May 2026 01:25:58 -0700 Subject: [PATCH 07/21] feat: wire SandboxOrchestrator singleton into all StrategyService instantiation sites All 7 call sites (strategy-handlers, task-handlers, server watchdog) now pass the shared orchestrator so start_strategy actually spins up Docker containers when sandbox mode is enabled. Also forwards ANTHROPIC_API_KEY to containers when present so Claude CLI can authenticate. Co-Authored-By: Wren --- packages/api/src/mcp/tools/strategy-handlers.ts | 11 ++++++----- packages/api/src/mcp/tools/task-handlers.ts | 3 ++- packages/api/src/server.ts | 3 ++- packages/api/src/services/sandbox/index.ts | 11 +++++++++++ packages/api/src/services/sandbox/orchestrator.ts | 2 +- packages/api/src/services/strategy.service.ts | 6 ++++++ 6 files changed, 28 insertions(+), 8 deletions(-) diff --git a/packages/api/src/mcp/tools/strategy-handlers.ts b/packages/api/src/mcp/tools/strategy-handlers.ts index 2234dbe9..214a4fa5 100644 --- a/packages/api/src/mcp/tools/strategy-handlers.ts +++ b/packages/api/src/mcp/tools/strategy-handlers.ts @@ -12,6 +12,7 @@ import type { VerificationMode, } from '../../data/repositories/task-groups.repository'; import { StrategyService } from '../../services/strategy.service'; +import { getOrchestrator } from '../../services/sandbox/index.js'; import { resolveUser, type UserIdentifier } from '../../services/user-resolver'; import { getEffectiveAgentId } from '../../auth/enforce-identity'; @@ -116,7 +117,7 @@ export async function handleStartStrategy( const agentId = getEffectiveAgentId(args.ownerAgentId); - const service = new StrategyService(dataComposer); + const service = new StrategyService(dataComposer, getOrchestrator()); const result = await service.startStrategy({ groupId: args.groupId, userId: resolved.user.id, @@ -179,7 +180,7 @@ export async function handlePauseStrategy( return mcpResponse({ success: false, error: 'User not found' }, true); } - const service = new StrategyService(dataComposer); + const service = new StrategyService(dataComposer, getOrchestrator()); const group = await service.pauseStrategy(args.groupId, resolved.user.id); return mcpResponse({ @@ -220,7 +221,7 @@ export async function handleResumeStrategy( return mcpResponse({ success: false, error: 'User not found' }, true); } - const service = new StrategyService(dataComposer); + const service = new StrategyService(dataComposer, getOrchestrator()); const result = await service.resumeStrategy(args.groupId, resolved.user.id); return mcpResponse({ @@ -271,7 +272,7 @@ export async function handleCancelStrategy( return mcpResponse({ success: false, error: 'User not found' }, true); } - const service = new StrategyService(dataComposer); + const service = new StrategyService(dataComposer, getOrchestrator()); const group = await service.cancelStrategy(args.groupId, resolved.user.id, args.reason); return mcpResponse({ @@ -312,7 +313,7 @@ export async function handleGetStrategyStatus( return mcpResponse({ success: false, error: 'User not found' }, true); } - const service = new StrategyService(dataComposer); + const service = new StrategyService(dataComposer, getOrchestrator()); const status = await service.getStrategyStatus(args.groupId, resolved.user.id); return mcpResponse({ success: true, ...status }); diff --git a/packages/api/src/mcp/tools/task-handlers.ts b/packages/api/src/mcp/tools/task-handlers.ts index d4b7f9fd..0180ccc0 100644 --- a/packages/api/src/mcp/tools/task-handlers.ts +++ b/packages/api/src/mcp/tools/task-handlers.ts @@ -9,6 +9,7 @@ import { z } from 'zod'; import type { DataComposer } from '../../data/composer'; import type { TaskStatus, TaskPriority } from '../../data/repositories/project-tasks.repository'; import { StrategyService } from '../../services/strategy.service'; +import { getOrchestrator } from '../../services/sandbox/index.js'; import { resolveUser, type UserIdentifier } from '../../services/user-resolver'; import { getEffectiveAgentId } from '../../auth/enforce-identity'; import { getRequestContext } from '../../utils/request-context'; @@ -412,7 +413,7 @@ export async function handleCompleteTask( try { const group = await dataComposer.repositories.taskGroups.findById(task.task_group_id); if (group && group.strategy && group.status === 'active') { - const strategyService = new StrategyService(dataComposer); + const strategyService = new StrategyService(dataComposer, getOrchestrator()); strategyResult = await strategyService.advanceStrategy( task.task_group_id, task.id, diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index 9cff9e12..d0b221e9 100644 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -40,6 +40,7 @@ import { type DueReminder, } from './services/heartbeat'; import { StrategyService } from './services/strategy.service'; +import { getOrchestrator } from './services/sandbox/index.js'; import { setResponseCallback, hasExplicitResponse } from './mcp/tools/response-handlers'; import { getAgentGateway, type AgentTriggerPayload } from './channels/agent-gateway'; import { resolveRouteAgentId } from './services/routing/resolve-route'; @@ -466,7 +467,7 @@ async function startServer(config: ServerConfig = {}): Promise { return false; } try { - const strategyService = new StrategyService(dataComposer); + const strategyService = new StrategyService(dataComposer, getOrchestrator()); const fired = await strategyService.triggerWatchdog(groupId); if (fired) { logger.info( diff --git a/packages/api/src/services/sandbox/index.ts b/packages/api/src/services/sandbox/index.ts index 1c84e3e2..51967207 100644 --- a/packages/api/src/services/sandbox/index.ts +++ b/packages/api/src/services/sandbox/index.ts @@ -10,3 +10,14 @@ export { type BackendAuthName, type SandboxMount, } from './orchestrator.js'; + +import { SandboxOrchestrator } from './orchestrator.js'; + +let _instance: SandboxOrchestrator | undefined; + +export function getOrchestrator(): SandboxOrchestrator { + if (!_instance) { + _instance = new SandboxOrchestrator(); + } + return _instance; +} diff --git a/packages/api/src/services/sandbox/orchestrator.ts b/packages/api/src/services/sandbox/orchestrator.ts index 98f76734..9df39701 100644 --- a/packages/api/src/services/sandbox/orchestrator.ts +++ b/packages/api/src/services/sandbox/orchestrator.ts @@ -13,7 +13,7 @@ import { execFile } from 'child_process'; import { promisify } from 'util'; import { createHash } from 'crypto'; import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs'; -import { basename, join, resolve as resolvePath } from 'path'; +import { join } from 'path'; import { homedir } from 'os'; import { logger } from '../../utils/logger.js'; diff --git a/packages/api/src/services/strategy.service.ts b/packages/api/src/services/strategy.service.ts index d9cbf0c7..0a9af6f0 100644 --- a/packages/api/src/services/strategy.service.ts +++ b/packages/api/src/services/strategy.service.ts @@ -911,6 +911,11 @@ export class StrategyService { return null; } + const extraEnv: Record = {}; + if (process.env.ANTHROPIC_API_KEY) { + extraEnv.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; + } + const result = await this.sandboxOrchestrator.spinUp({ userId: group.user_id, agentId: group.owner_agent_id || studio.agentId || 'unknown', @@ -924,6 +929,7 @@ export class StrategyService { taskGroupContext: group.context_summary || undefined, taskGroupThreadKey: group.thread_key || `strategy:${group.id}`, backendAuth: (config.sandboxBackendAuth as any) || ['claude'], + extraEnv, }); if (result.success) { From 71760f6ea85f5e0155c1047aba64e8ed219b50e7 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Thu, 7 May 2026 12:20:51 -0700 Subject: [PATCH 08/21] feat: stage Claude OAuth credentials from macOS keychain for Docker containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Containers can't access the host's macOS keychain, so we extract OAuth tokens at spin-up time and stage them into a mounted directory. This lets the Claude CLI inside containers authenticate using the user's existing Max plan subscription — no separate ANTHROPIC_API_KEY needed. stageClaudeDir() builds a minimal .claude/ dir with credentials + settings, mounted read-only into the container. Credentials are extracted from the keychain via `security find-generic-password`, matching the approach used by OpenClaw's live Docker test harness. All 6 live tests now pass: worktree manipulation, MCP config patching, live LLM responses, file reading via coding tools, and Inkwell connectivity. Co-Authored-By: Wren --- packages/api/src/services/sandbox/index.ts | 1 + .../orchestrator.live.integration.test.ts | 41 ++++--- .../src/services/sandbox/orchestrator.test.ts | 32 ++++- .../api/src/services/sandbox/orchestrator.ts | 112 ++++++++++++++++-- packages/api/src/services/strategy.service.ts | 6 - 5 files changed, 158 insertions(+), 34 deletions(-) diff --git a/packages/api/src/services/sandbox/index.ts b/packages/api/src/services/sandbox/index.ts index 51967207..6972028c 100644 --- a/packages/api/src/services/sandbox/index.ts +++ b/packages/api/src/services/sandbox/index.ts @@ -4,6 +4,7 @@ export { buildEnvVars, buildDockerRunArgs, buildMounts, + stageClaudeDir, type SandboxSpinUpRequest, type SandboxSpinUpResult, type SandboxStatusResult, diff --git a/packages/api/src/services/sandbox/orchestrator.live.integration.test.ts b/packages/api/src/services/sandbox/orchestrator.live.integration.test.ts index d7e2124d..8e79ceb6 100644 --- a/packages/api/src/services/sandbox/orchestrator.live.integration.test.ts +++ b/packages/api/src/services/sandbox/orchestrator.live.integration.test.ts @@ -5,8 +5,8 @@ * They require: * - Docker daemon running * - The inkwell:studio-sandbox image built - * - ANTHROPIC_API_KEY set (for Claude calls) - * - Inkwell server running on localhost:3001 (for MCP tests) + * - Active Claude Code session (OAuth tokens staged from macOS keychain) + * - Inkwell server running on localhost:3001 (for MCP connectivity test) * * Run with: INK_LIVE_TESTS=1 npx vitest run --config vitest.integration.config.ts src/services/sandbox/orchestrator.live.test.ts */ @@ -16,7 +16,12 @@ import { execFileSync, spawnSync } from 'child_process'; import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, existsSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; -import { SandboxOrchestrator, buildContainerName, type SandboxSpinUpRequest } from './orchestrator'; +import { + SandboxOrchestrator, + buildContainerName, + stageClaudeDir, + type SandboxSpinUpRequest, +} from './orchestrator'; function dockerAvailable(): boolean { try { @@ -32,6 +37,17 @@ function imageExists(image: string): boolean { return result.status === 0; } +function claudeCredentialsAvailable(): boolean { + try { + const tmpDir = mkdtempSync(join(tmpdir(), 'cred-check-')); + const result = stageClaudeDir(tmpDir); + if (!result) return false; + return existsSync(join(result, '.credentials.json')); + } catch { + return false; + } +} + function inkwellReachable(): boolean { try { execFileSync('curl', ['-sf', '-o', '/dev/null', 'http://localhost:3001/health'], { @@ -96,12 +112,6 @@ describe.skipIf(SKIP)('SandboxOrchestrator (live)', () => { worktreePath: testDir, repoRoot: testDir, backendAuth: ['claude'], - extraEnv: { - // Pass API key into the container for Claude calls - ...(process.env.ANTHROPIC_API_KEY - ? { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY } - : {}), - }, ...overrides, }; } @@ -175,7 +185,7 @@ describe.skipIf(SKIP)('SandboxOrchestrator (live)', () => { }); describe('LLM response', () => { - const SKIP_LLM = !process.env.ANTHROPIC_API_KEY; + const SKIP_LLM = !claudeCredentialsAvailable(); it.skipIf(SKIP_LLM)( 'gets a live Claude response inside the container', @@ -187,16 +197,17 @@ describe.skipIf(SKIP)('SandboxOrchestrator (live)', () => { await orchestrator.spinUp(request); // Use claude CLI with --print (non-interactive, single-shot) - // Ask a trivially answerable question to minimize cost + // Just verify we get a non-empty response (the model is authenticated) const { stdout } = await orchestrator.exec(containerName, [ 'claude', '--print', '--model', 'claude-haiku-4-5-20251001', - 'Reply with exactly the word SANDBOX and nothing else.', + 'What is 2+2? Reply with just the number.', ]); - expect(stdout.toUpperCase()).toContain('SANDBOX'); + expect(stdout.trim().length).toBeGreaterThan(0); + expect(stdout).toContain('4'); }, 120_000 ); @@ -210,6 +221,7 @@ describe.skipIf(SKIP)('SandboxOrchestrator (live)', () => { await orchestrator.spinUp(request); + // --allowedTools requires -p for the prompt const { stdout } = await orchestrator.exec(containerName, [ 'claude', '--print', @@ -217,6 +229,7 @@ describe.skipIf(SKIP)('SandboxOrchestrator (live)', () => { 'claude-haiku-4-5-20251001', '--allowedTools', 'Read', + '-p', 'Read the file src/hello.ts and tell me what function it exports. Reply with just the function name.', ]); @@ -227,7 +240,7 @@ describe.skipIf(SKIP)('SandboxOrchestrator (live)', () => { }); describe('Inkwell MCP access', () => { - const SKIP_INKWELL = !inkwellReachable() || !process.env.ANTHROPIC_API_KEY; + const SKIP_INKWELL = !inkwellReachable(); it.skipIf(SKIP_INKWELL)( 'container can reach Inkwell server via host.docker.internal', diff --git a/packages/api/src/services/sandbox/orchestrator.test.ts b/packages/api/src/services/sandbox/orchestrator.test.ts index 33512d33..89727e37 100644 --- a/packages/api/src/services/sandbox/orchestrator.test.ts +++ b/packages/api/src/services/sandbox/orchestrator.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, writeFileSync, readFileSync, mkdirSync } from 'fs'; -import { tmpdir } from 'os'; +import { existsSync, mkdtempSync, writeFileSync, readFileSync, mkdirSync } from 'fs'; +import { homedir, tmpdir } from 'os'; import { join } from 'path'; import { buildContainerName, @@ -8,6 +8,7 @@ import { buildDockerRunArgs, buildMounts, patchMcpConfig, + stageClaudeDir, SandboxOrchestrator, type SandboxSpinUpRequest, } from './orchestrator'; @@ -270,6 +271,33 @@ describe('patchMcpConfig', () => { }); }); +describe('stageClaudeDir', () => { + it('stages credentials from file when .credentials.json exists', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'cred-stage-')); + // stageClaudeDir reads from the real homedir, so we test what it returns + const result = stageClaudeDir(join(tmpDir, 'staging')); + // On this machine (macOS with active Claude session), should extract from keychain + // In CI or without credentials, returns undefined — both are valid + if (result) { + expect(result).toContain('claude-home'); + expect(existsSync(join(result, '.credentials.json'))).toBe(true); + const creds = JSON.parse(readFileSync(join(result, '.credentials.json'), 'utf-8')); + expect(creds.claudeAiOauth).toBeDefined(); + } + }); + + it('copies settings files when they exist', () => { + const result = stageClaudeDir(mkdtempSync(join(tmpdir(), 'cred-stage-'))); + if (result) { + // settings.json should be copied if it exists on the host + const hostSettings = join(homedir(), '.claude', 'settings.json'); + if (existsSync(hostSettings)) { + expect(existsSync(join(result, 'settings.json'))).toBe(true); + } + } + }); +}); + describe('SandboxOrchestrator', () => { let mockExecFile: ReturnType; diff --git a/packages/api/src/services/sandbox/orchestrator.ts b/packages/api/src/services/sandbox/orchestrator.ts index 9df39701..2b3131d2 100644 --- a/packages/api/src/services/sandbox/orchestrator.ts +++ b/packages/api/src/services/sandbox/orchestrator.ts @@ -9,12 +9,12 @@ * dependency for planning). Shells out to Docker via child_process. */ -import { execFile } from 'child_process'; +import { execFile, execFileSync } from 'child_process'; import { promisify } from 'util'; import { createHash } from 'crypto'; import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs'; import { join } from 'path'; -import { homedir } from 'os'; +import { homedir, platform } from 'os'; import { logger } from '../../utils/logger.js'; const execFileAsync = promisify(execFile); @@ -136,6 +136,71 @@ function rewriteLoopbackUrl(rawUrl: string): string { return rawUrl; } +const CLAUDE_KEYCHAIN_SERVICE = 'Claude Code-credentials'; +const CLAUDE_CREDENTIALS_RELATIVE = '.claude/.credentials.json'; + +/** + * Stage a Claude config directory for Docker mounting. + * + * Claude Code stores OAuth tokens in the macOS keychain (primary) with a + * file fallback at ~/.claude/.credentials.json. Docker containers can't + * access the keychain, so we build a staging directory with extracted + * credentials plus the config files Claude Code needs (settings, etc.). + * + * This replaces mounting ~/.claude directly — Docker can't overlay a file + * inside a read-only directory mount, so we stage everything into one dir. + * + * Returns the path to the staged directory, or undefined if credentials + * aren't available. + */ +export function stageClaudeDir(stagingDir: string): string | undefined { + const home = homedir(); + const claudeHome = join(home, '.claude'); + const stagedDir = join(stagingDir, 'claude-home'); + mkdirSync(stagedDir, { recursive: true }); + + // Copy config files Claude Code needs + const filesToCopy = ['settings.json', 'settings.local.json']; + for (const file of filesToCopy) { + const src = join(claudeHome, file); + if (existsSync(src)) { + writeFileSync(join(stagedDir, file), readFileSync(src, 'utf-8'), { mode: 0o600 }); + } + } + + // Stage credentials: file fallback first, then keychain extraction + const credFile = join(claudeHome, '.credentials.json'); + if (existsSync(credFile)) { + writeFileSync(join(stagedDir, '.credentials.json'), readFileSync(credFile, 'utf-8'), { + mode: 0o600, + }); + return stagedDir; + } + + if (platform() === 'darwin') { + try { + const raw = execFileSync( + 'security', + ['find-generic-password', '-s', CLAUDE_KEYCHAIN_SERVICE, '-w'], + { encoding: 'utf-8', timeout: 5_000, stdio: ['pipe', 'pipe', 'pipe'] } + ).trim(); + + const data = JSON.parse(raw); + if (!data?.claudeAiOauth?.accessToken) return undefined; + + writeFileSync(join(stagedDir, '.credentials.json'), JSON.stringify(data, null, 2) + '\n', { + mode: 0o600, + }); + return stagedDir; + } catch { + logger.debug('Could not extract Claude credentials from keychain'); + return undefined; + } + } + + return undefined; +} + export function buildMounts(request: SandboxSpinUpRequest): SandboxMount[] { const mounts: SandboxMount[] = []; @@ -158,17 +223,31 @@ export function buildMounts(request: SandboxSpinUpRequest): SandboxMount[] { }; for (const backend of request.backendAuth || []) { - const sourceDir = authDirs[backend]; - if (existsSync(sourceDir)) { - mounts.push({ - source: sourceDir, - target: `${CONTAINER_HOME}/.${backend}`, - readOnly: true, - }); - } - - // Claude Code also needs ~/.claude.json (config separate from ~/.claude/ dir) if (backend === 'claude') { + // Stage ~/.claude with credentials extracted from keychain. + // We can't mount ~/.claude read-only and then overlay a file inside it, + // so we stage everything into one directory. + const runtimeDir = join(request.worktreePath, '.ink', 'runtime', 'sandbox'); + const stagedClaudeHome = stageClaudeDir(runtimeDir); + if (stagedClaudeHome) { + mounts.push({ + source: stagedClaudeHome, + target: `${CONTAINER_HOME}/.claude`, + readOnly: true, + }); + } else { + // Fallback: mount ~/.claude directly (no credentials, but settings work) + const sourceDir = authDirs[backend]; + if (existsSync(sourceDir)) { + mounts.push({ + source: sourceDir, + target: `${CONTAINER_HOME}/.claude`, + readOnly: true, + }); + } + } + + // Claude Code also needs ~/.claude.json (config separate from ~/.claude/ dir) const claudeJson = join(home, '.claude.json'); if (existsSync(claudeJson)) { mounts.push({ @@ -177,6 +256,15 @@ export function buildMounts(request: SandboxSpinUpRequest): SandboxMount[] { readOnly: true, }); } + } else { + const sourceDir = authDirs[backend]; + if (existsSync(sourceDir)) { + mounts.push({ + source: sourceDir, + target: `${CONTAINER_HOME}/.${backend}`, + readOnly: true, + }); + } } } diff --git a/packages/api/src/services/strategy.service.ts b/packages/api/src/services/strategy.service.ts index 0a9af6f0..d9cbf0c7 100644 --- a/packages/api/src/services/strategy.service.ts +++ b/packages/api/src/services/strategy.service.ts @@ -911,11 +911,6 @@ export class StrategyService { return null; } - const extraEnv: Record = {}; - if (process.env.ANTHROPIC_API_KEY) { - extraEnv.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; - } - const result = await this.sandboxOrchestrator.spinUp({ userId: group.user_id, agentId: group.owner_agent_id || studio.agentId || 'unknown', @@ -929,7 +924,6 @@ export class StrategyService { taskGroupContext: group.context_summary || undefined, taskGroupThreadKey: group.thread_key || `strategy:${group.id}`, backendAuth: (config.sandboxBackendAuth as any) || ['claude'], - extraEnv, }); if (result.success) { From c8b3324419d83aca2295de73f497e945cab38cbe Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Thu, 7 May 2026 14:58:22 -0700 Subject: [PATCH 09/21] feat: Codex credential staging for Docker containers stageCodexDir() copies auth.json and patches config.toml for container use: rewrites loopback MCP URLs to host.docker.internal, strips host-specific project paths, adds /studio as trusted. Co-Authored-By: Wren --- packages/api/src/services/sandbox/index.ts | 1 + .../src/services/sandbox/orchestrator.test.ts | 58 +++++++++++++++ .../api/src/services/sandbox/orchestrator.ts | 74 +++++++++++++++++++ 3 files changed, 133 insertions(+) diff --git a/packages/api/src/services/sandbox/index.ts b/packages/api/src/services/sandbox/index.ts index 6972028c..9b5395c9 100644 --- a/packages/api/src/services/sandbox/index.ts +++ b/packages/api/src/services/sandbox/index.ts @@ -5,6 +5,7 @@ export { buildDockerRunArgs, buildMounts, stageClaudeDir, + stageCodexDir, type SandboxSpinUpRequest, type SandboxSpinUpResult, type SandboxStatusResult, diff --git a/packages/api/src/services/sandbox/orchestrator.test.ts b/packages/api/src/services/sandbox/orchestrator.test.ts index 89727e37..ab1eee8e 100644 --- a/packages/api/src/services/sandbox/orchestrator.test.ts +++ b/packages/api/src/services/sandbox/orchestrator.test.ts @@ -9,6 +9,7 @@ import { buildMounts, patchMcpConfig, stageClaudeDir, + stageCodexDir, SandboxOrchestrator, type SandboxSpinUpRequest, } from './orchestrator'; @@ -298,6 +299,63 @@ describe('stageClaudeDir', () => { }); }); +describe('stageCodexDir', () => { + it('stages auth.json and patched config.toml when codex home exists', () => { + const codexHome = join(homedir(), '.codex'); + if (!existsSync(join(codexHome, 'auth.json'))) return; // skip if no Codex auth + + const tmpDir = mkdtempSync(join(tmpdir(), 'codex-stage-')); + const result = stageCodexDir(tmpDir); + expect(result).toBeDefined(); + expect(existsSync(join(result!, 'auth.json'))).toBe(true); + + const auth = JSON.parse(readFileSync(join(result!, 'auth.json'), 'utf-8')); + expect(auth.tokens).toBeDefined(); + }); + + it('rewrites loopback URLs in config.toml', () => { + const codexHome = join(homedir(), '.codex'); + if (!existsSync(join(codexHome, 'config.toml'))) return; + + const tmpDir = mkdtempSync(join(tmpdir(), 'codex-stage-')); + const result = stageCodexDir(tmpDir); + if (!result) return; + + const config = readFileSync(join(result, 'config.toml'), 'utf-8'); + // Loopback URLs should be rewritten + expect(config).not.toMatch(/url\s*=\s*"https?:\/\/localhost/); + if (config.includes('host.docker.internal')) { + expect(config).toContain('host.docker.internal'); + } + }); + + it('strips host-specific project paths and adds /studio', () => { + const codexHome = join(homedir(), '.codex'); + if (!existsSync(join(codexHome, 'config.toml'))) return; + + const tmpDir = mkdtempSync(join(tmpdir(), 'codex-stage-')); + const result = stageCodexDir(tmpDir); + if (!result) return; + + const config = readFileSync(join(result, 'config.toml'), 'utf-8'); + // Host paths stripped + expect(config).not.toContain('/Users/'); + // Container project added + expect(config).toContain('[projects."/studio"]'); + expect(config).toContain('trust_level = "trusted"'); + }); + + it('returns undefined when auth.json does not exist', () => { + // stageCodexDir checks for ~/.codex/auth.json before creating staging dir + // On a machine without Codex auth, this returns undefined + const tmpDir = mkdtempSync(join(tmpdir(), 'codex-no-auth-')); + // We can't mock homedir easily, but verify the function doesn't throw + const result = stageCodexDir(tmpDir); + // On this machine with Codex installed, it will succeed + expect(result === undefined || typeof result === 'string').toBe(true); + }); +}); + describe('SandboxOrchestrator', () => { let mockExecFile: ReturnType; diff --git a/packages/api/src/services/sandbox/orchestrator.ts b/packages/api/src/services/sandbox/orchestrator.ts index 2b3131d2..ca472945 100644 --- a/packages/api/src/services/sandbox/orchestrator.ts +++ b/packages/api/src/services/sandbox/orchestrator.ts @@ -201,6 +201,60 @@ export function stageClaudeDir(stagingDir: string): string | undefined { return undefined; } +/** + * Stage a Codex config directory for Docker mounting. + * + * Codex stores credentials in ~/.codex/auth.json (file-based, no keychain + * on macOS by default). The config.toml contains host-specific project + * paths and MCP server URLs that need rewriting for container use. + * + * Returns the path to the staged directory, or undefined if no auth exists. + */ +export function stageCodexDir(stagingDir: string): string | undefined { + const home = homedir(); + const codexHome = join(home, '.codex'); + if (!existsSync(codexHome)) return undefined; + + const authFile = join(codexHome, 'auth.json'); + if (!existsSync(authFile)) return undefined; + + const stagedDir = join(stagingDir, 'codex-home'); + mkdirSync(stagedDir, { recursive: true }); + + // Copy auth.json as-is + writeFileSync(join(stagedDir, 'auth.json'), readFileSync(authFile, 'utf-8'), { mode: 0o600 }); + + // Patch config.toml: rewrite loopback MCP URLs, strip host-specific project paths + const configFile = join(codexHome, 'config.toml'); + if (existsSync(configFile)) { + let config = readFileSync(configFile, 'utf-8'); + + // Rewrite localhost/127.0.0.1 URLs to host.docker.internal + config = config.replace( + /url\s*=\s*"(https?:\/\/(?:localhost|127\.0\.0\.1|::1|\[::1\])(:\d+)?[^"]*)"/g, + (_match, url: string) => `url = "${rewriteLoopbackUrl(url)}"` + ); + + // Strip host-specific [projects.*] sections — they reference host paths + config = config.replace(/\[projects\."[^"]*"\]\s*\n(?:[^\[]*\n)*/g, ''); + + // Add container project as trusted + config += '\n[projects."/studio"]\ntrust_level = "trusted"\n'; + + writeFileSync(join(stagedDir, 'config.toml'), config, { mode: 0o600 }); + } + + // Copy installation_id if present + const installId = join(codexHome, 'installation_id'); + if (existsSync(installId)) { + writeFileSync(join(stagedDir, 'installation_id'), readFileSync(installId, 'utf-8'), { + mode: 0o600, + }); + } + + return stagedDir; +} + export function buildMounts(request: SandboxSpinUpRequest): SandboxMount[] { const mounts: SandboxMount[] = []; @@ -256,6 +310,26 @@ export function buildMounts(request: SandboxSpinUpRequest): SandboxMount[] { readOnly: true, }); } + } else if (backend === 'codex') { + // Stage ~/.codex with patched config.toml (rewrite loopback URLs, strip host paths) + const runtimeDir = join(request.worktreePath, '.ink', 'runtime', 'sandbox'); + const stagedCodexHome = stageCodexDir(runtimeDir); + if (stagedCodexHome) { + mounts.push({ + source: stagedCodexHome, + target: `${CONTAINER_HOME}/.codex`, + readOnly: true, + }); + } else { + const sourceDir = authDirs[backend]; + if (existsSync(sourceDir)) { + mounts.push({ + source: sourceDir, + target: `${CONTAINER_HOME}/.codex`, + readOnly: true, + }); + } + } } else { const sourceDir = authDirs[backend]; if (existsSync(sourceDir)) { From 81a19d6af59a48c81137196eaac65859ac767008 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Thu, 7 May 2026 15:29:46 -0700 Subject: [PATCH 10/21] =?UTF-8?q?fix:=20address=20review=20blockers=20?= =?UTF-8?q?=E2=80=94=20async=20staging,=20git=20worktree=20mounts,=20crede?= =?UTF-8?q?ntial=20isolation=20(by=20Wren)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes 3 of 4 blockers from Lumen's PR #346 review: 1. Async conversion: stageClaudeDir, stageCodexDir, patchMcpConfig, buildMounts, and buildDockerRunArgs are now fully async (fs/promises + execFileAsync). No sync fs/keychain calls in request handlers. 2. Git worktree mounts: resolveGitMounts() detects .git files (worktree markers), mounts the canonical .git dir at /repo/.git, and overlays a patched .git file with container-relative paths. git operations now work inside containers. 3. Credential staging isolation: staging dir moved from /.ink/runtime/ to ~/.ink/runtime/sandbox//. Credentials no longer visible through /studio mount. Removed unused CLAUDE_CREDENTIALS_RELATIVE constant. 4. Phase 1 labeling: header comment now explicitly documents this as prepare-only (container alongside host session, not strategy execution inside container). Tests: 39 unit (4 new for git worktrees + staging isolation), 2107 suite total. Co-Authored-By: Wren --- .../orchestrator.live.integration.test.ts | 31 ++- .../src/services/sandbox/orchestrator.test.ts | 156 ++++++++--- .../api/src/services/sandbox/orchestrator.ts | 263 ++++++++++++------ 3 files changed, 313 insertions(+), 137 deletions(-) diff --git a/packages/api/src/services/sandbox/orchestrator.live.integration.test.ts b/packages/api/src/services/sandbox/orchestrator.live.integration.test.ts index 8e79ceb6..8aba9f4e 100644 --- a/packages/api/src/services/sandbox/orchestrator.live.integration.test.ts +++ b/packages/api/src/services/sandbox/orchestrator.live.integration.test.ts @@ -14,14 +14,9 @@ import { describe, it, expect, afterAll, beforeAll } from 'vitest'; import { execFileSync, spawnSync } from 'child_process'; import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, existsSync } from 'fs'; -import { tmpdir } from 'os'; +import { homedir, tmpdir } from 'os'; import { join } from 'path'; -import { - SandboxOrchestrator, - buildContainerName, - stageClaudeDir, - type SandboxSpinUpRequest, -} from './orchestrator'; +import { SandboxOrchestrator, buildContainerName, type SandboxSpinUpRequest } from './orchestrator'; function dockerAvailable(): boolean { try { @@ -38,14 +33,22 @@ function imageExists(image: string): boolean { } function claudeCredentialsAvailable(): boolean { - try { - const tmpDir = mkdtempSync(join(tmpdir(), 'cred-check-')); - const result = stageClaudeDir(tmpDir); - if (!result) return false; - return existsSync(join(result, '.credentials.json')); - } catch { - return false; + // Sync check for skip conditions (can't use async stageClaudeDir with it.skipIf) + const credFile = join(homedir(), '.claude', '.credentials.json'); + if (existsSync(credFile)) return true; + if (process.platform === 'darwin') { + try { + execFileSync('security', ['find-generic-password', '-s', 'Claude Code-credentials', '-w'], { + encoding: 'utf-8', + timeout: 5_000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + return true; + } catch { + return false; + } } + return false; } function inkwellReachable(): boolean { diff --git a/packages/api/src/services/sandbox/orchestrator.test.ts b/packages/api/src/services/sandbox/orchestrator.test.ts index ab1eee8e..37e261bf 100644 --- a/packages/api/src/services/sandbox/orchestrator.test.ts +++ b/packages/api/src/services/sandbox/orchestrator.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { existsSync, mkdtempSync, writeFileSync, readFileSync, mkdirSync } from 'fs'; import { homedir, tmpdir } from 'os'; import { join } from 'path'; @@ -125,55 +125,55 @@ describe('buildEnvVars', () => { }); describe('buildDockerRunArgs', () => { - it('includes required docker run flags', () => { - const args = buildDockerRunArgs(baseRequest); + it('includes required docker run flags', async () => { + const args = await buildDockerRunArgs(baseRequest); expect(args[0]).toBe('run'); expect(args).toContain('--rm'); expect(args).toContain('-d'); expect(args).toContain(DEFAULT_IMAGE_NAME()); }); - it('sets container name', () => { - const args = buildDockerRunArgs(baseRequest); + it('sets container name', async () => { + const args = await buildDockerRunArgs(baseRequest); const nameIdx = args.indexOf('--name'); expect(nameIdx).toBeGreaterThan(-1); expect(args[nameIdx + 1]).toMatch(/^ink-sandbox-/); }); - it('sets workdir to /studio', () => { - const args = buildDockerRunArgs(baseRequest); + it('sets workdir to /studio', async () => { + const args = await buildDockerRunArgs(baseRequest); const idx = args.indexOf('--workdir'); expect(args[idx + 1]).toBe('/studio'); }); - it('adds host.docker.internal mapping', () => { - const args = buildDockerRunArgs(baseRequest); + it('adds host.docker.internal mapping', async () => { + const args = await buildDockerRunArgs(baseRequest); expect(args).toContain('--add-host'); const idx = args.indexOf('--add-host'); expect(args[idx + 1]).toBe('host.docker.internal:host-gateway'); }); - it('adds discovery labels', () => { - const args = buildDockerRunArgs(baseRequest); + it('adds discovery labels', async () => { + const args = await buildDockerRunArgs(baseRequest); expect(args).toContain('ink.sandbox=true'); expect(args).toContain(`ink.agent-id=wren`); expect(args).toContain(`ink.studio-id=studio-abc`); }); - it('adds task group label when provided', () => { - const args = buildDockerRunArgs({ ...baseRequest, taskGroupId: 'tg-456' }); + it('adds task group label when provided', async () => { + const args = await buildDockerRunArgs({ ...baseRequest, taskGroupId: 'tg-456' }); expect(args).toContain('ink.task-group-id=tg-456'); }); - it('sets network none when requested', () => { - const args = buildDockerRunArgs({ ...baseRequest, networkMode: 'none' }); + it('sets network none when requested', async () => { + const args = await buildDockerRunArgs({ ...baseRequest, networkMode: 'none' }); const idx = args.indexOf('--network'); expect(idx).toBeGreaterThan(-1); expect(args[idx + 1]).toBe('none'); }); - it('passes env vars as -e flags', () => { - const args = buildDockerRunArgs(baseRequest); + it('passes env vars as -e flags', async () => { + const args = await buildDockerRunArgs(baseRequest); const envPairs = args.filter((_, i) => i > 0 && args[i - 1] === '-e'); expect(envPairs.some((p) => p.startsWith('AGENT_ID=wren'))).toBe(true); expect(envPairs.some((p) => p.startsWith('INK_SANDBOX=docker'))).toBe(true); @@ -181,14 +181,65 @@ describe('buildDockerRunArgs', () => { }); describe('buildMounts', () => { - it('returns empty array when worktree path does not exist', () => { - const mounts = buildMounts({ ...baseRequest, worktreePath: '/nonexistent/path' }); + it('returns empty array when worktree path does not exist', async () => { + const mounts = await buildMounts({ ...baseRequest, worktreePath: '/nonexistent/path' }); expect(mounts.filter((m) => m.target === '/studio')).toHaveLength(0); }); + + it('mounts worktree at /studio when path exists', async () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'mount-test-')); + const mounts = await buildMounts({ ...baseRequest, worktreePath: tmpDir, repoRoot: tmpDir }); + expect(mounts.some((m) => m.target === '/studio' && m.source === tmpDir)).toBe(true); + }); + + it('resolves git worktree mounts when .git is a file', async () => { + const repoDir = mkdtempSync(join(tmpdir(), 'repo-')); + const worktreeDir = mkdtempSync(join(tmpdir(), 'worktree-')); + + // Create canonical .git structure + mkdirSync(join(repoDir, '.git', 'worktrees', 'my-branch'), { recursive: true }); + + // Create .git file (worktree marker) + writeFileSync(join(worktreeDir, '.git'), `gitdir: ${repoDir}/.git/worktrees/my-branch\n`); + + const mounts = await buildMounts({ + ...baseRequest, + worktreePath: worktreeDir, + repoRoot: repoDir, + }); + + // Should mount canonical .git dir + const gitDirMount = mounts.find((m) => m.target === '/repo/.git'); + expect(gitDirMount).toBeDefined(); + expect(gitDirMount!.source).toBe(join(repoDir, '.git')); + + // Should mount patched .git file + const gitFileMount = mounts.find((m) => m.target === '/studio/.git'); + expect(gitFileMount).toBeDefined(); + expect(gitFileMount!.readOnly).toBe(true); + + // Patched .git file should point to container path + const patchedContent = readFileSync(gitFileMount!.source, 'utf-8'); + expect(patchedContent).toBe('gitdir: /repo/.git/worktrees/my-branch\n'); + }); + + it('skips git worktree mounts when .git is a directory', async () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'repo-git-dir-')); + mkdirSync(join(tmpDir, '.git'), { recursive: true }); + + const mounts = await buildMounts({ + ...baseRequest, + worktreePath: tmpDir, + repoRoot: tmpDir, + }); + + // No /repo/.git mount needed — .git dir is inside the bind mount + expect(mounts.find((m) => m.target === '/repo/.git')).toBeUndefined(); + }); }); describe('patchMcpConfig', () => { - it('rewrites localhost URLs to host.docker.internal', () => { + it('rewrites localhost URLs to host.docker.internal', async () => { const tmpDir = mkdtempSync(join(tmpdir(), 'mcp-patch-')); writeFileSync( join(tmpDir, '.mcp.json'), @@ -199,13 +250,13 @@ describe('patchMcpConfig', () => { }) ); - const result = patchMcpConfig(tmpDir); + const result = await patchMcpConfig(tmpDir); expect(result).toBeTruthy(); const patched = JSON.parse(readFileSync(result!, 'utf-8')); expect(patched.mcpServers.inkwell.url).toBe('http://host.docker.internal:3001/mcp'); }); - it('strips stdio/command-based servers', () => { + it('strips stdio/command-based servers', async () => { const tmpDir = mkdtempSync(join(tmpdir(), 'mcp-patch-')); writeFileSync( join(tmpDir, '.mcp.json'), @@ -218,7 +269,7 @@ describe('patchMcpConfig', () => { }) ); - const result = patchMcpConfig(tmpDir); + const result = await patchMcpConfig(tmpDir); expect(result).toBeTruthy(); const patched = JSON.parse(readFileSync(result!, 'utf-8')); expect(Object.keys(patched.mcpServers)).toEqual(['inkwell']); @@ -226,7 +277,7 @@ describe('patchMcpConfig', () => { expect(patched.mcpServers.playwright).toBeUndefined(); }); - it('preserves remote HTTP servers without rewriting', () => { + it('preserves remote HTTP servers without rewriting', async () => { const tmpDir = mkdtempSync(join(tmpdir(), 'mcp-patch-')); writeFileSync( join(tmpDir, '.mcp.json'), @@ -242,7 +293,7 @@ describe('patchMcpConfig', () => { }) ); - const result = patchMcpConfig(tmpDir); + const result = await patchMcpConfig(tmpDir); expect(result).toBeTruthy(); const patched = JSON.parse(readFileSync(result!, 'utf-8')); expect(patched.mcpServers.github.url).toBe('https://api.githubcopilot.com/mcp/'); @@ -250,7 +301,7 @@ describe('patchMcpConfig', () => { expect(patched.mcpServers.inkwell.url).toBe('http://host.docker.internal:3001/mcp'); }); - it('returns undefined when no HTTP servers exist', () => { + it('returns undefined when no HTTP servers exist', async () => { const tmpDir = mkdtempSync(join(tmpdir(), 'mcp-patch-')); writeFileSync( join(tmpDir, '.mcp.json'), @@ -261,22 +312,41 @@ describe('patchMcpConfig', () => { }) ); - const result = patchMcpConfig(tmpDir); + const result = await patchMcpConfig(tmpDir); expect(result).toBeUndefined(); }); - it('returns undefined when .mcp.json does not exist', () => { + it('returns undefined when .mcp.json does not exist', async () => { const tmpDir = mkdtempSync(join(tmpdir(), 'mcp-patch-')); - const result = patchMcpConfig(tmpDir); + const result = await patchMcpConfig(tmpDir); expect(result).toBeUndefined(); }); + + it('writes to provided staging dir instead of worktree', async () => { + const studioDir = mkdtempSync(join(tmpdir(), 'studio-')); + const stagingDir = mkdtempSync(join(tmpdir(), 'staging-')); + writeFileSync( + join(studioDir, '.mcp.json'), + JSON.stringify({ + mcpServers: { + inkwell: { type: 'http', url: 'http://localhost:3001/mcp' }, + }, + }) + ); + + const result = await patchMcpConfig(studioDir, stagingDir); + expect(result).toBeTruthy(); + // Patched file should be in staging dir, not studio dir + expect(result!.startsWith(stagingDir)).toBe(true); + expect(existsSync(join(studioDir, '.ink'))).toBe(false); + }); }); describe('stageClaudeDir', () => { - it('stages credentials from file when .credentials.json exists', () => { + it('stages credentials from file when .credentials.json exists', async () => { const tmpDir = mkdtempSync(join(tmpdir(), 'cred-stage-')); // stageClaudeDir reads from the real homedir, so we test what it returns - const result = stageClaudeDir(join(tmpDir, 'staging')); + const result = await stageClaudeDir(join(tmpDir, 'staging')); // On this machine (macOS with active Claude session), should extract from keychain // In CI or without credentials, returns undefined — both are valid if (result) { @@ -287,8 +357,8 @@ describe('stageClaudeDir', () => { } }); - it('copies settings files when they exist', () => { - const result = stageClaudeDir(mkdtempSync(join(tmpdir(), 'cred-stage-'))); + it('copies settings files when they exist', async () => { + const result = await stageClaudeDir(mkdtempSync(join(tmpdir(), 'cred-stage-'))); if (result) { // settings.json should be copied if it exists on the host const hostSettings = join(homedir(), '.claude', 'settings.json'); @@ -300,12 +370,12 @@ describe('stageClaudeDir', () => { }); describe('stageCodexDir', () => { - it('stages auth.json and patched config.toml when codex home exists', () => { + it('stages auth.json and patched config.toml when codex home exists', async () => { const codexHome = join(homedir(), '.codex'); if (!existsSync(join(codexHome, 'auth.json'))) return; // skip if no Codex auth const tmpDir = mkdtempSync(join(tmpdir(), 'codex-stage-')); - const result = stageCodexDir(tmpDir); + const result = await stageCodexDir(tmpDir); expect(result).toBeDefined(); expect(existsSync(join(result!, 'auth.json'))).toBe(true); @@ -313,12 +383,12 @@ describe('stageCodexDir', () => { expect(auth.tokens).toBeDefined(); }); - it('rewrites loopback URLs in config.toml', () => { + it('rewrites loopback URLs in config.toml', async () => { const codexHome = join(homedir(), '.codex'); if (!existsSync(join(codexHome, 'config.toml'))) return; const tmpDir = mkdtempSync(join(tmpdir(), 'codex-stage-')); - const result = stageCodexDir(tmpDir); + const result = await stageCodexDir(tmpDir); if (!result) return; const config = readFileSync(join(result, 'config.toml'), 'utf-8'); @@ -329,12 +399,12 @@ describe('stageCodexDir', () => { } }); - it('strips host-specific project paths and adds /studio', () => { + it('strips host-specific project paths and adds /studio', async () => { const codexHome = join(homedir(), '.codex'); if (!existsSync(join(codexHome, 'config.toml'))) return; const tmpDir = mkdtempSync(join(tmpdir(), 'codex-stage-')); - const result = stageCodexDir(tmpDir); + const result = await stageCodexDir(tmpDir); if (!result) return; const config = readFileSync(join(result, 'config.toml'), 'utf-8'); @@ -345,22 +415,20 @@ describe('stageCodexDir', () => { expect(config).toContain('trust_level = "trusted"'); }); - it('returns undefined when auth.json does not exist', () => { + it('returns undefined when auth.json does not exist', async () => { // stageCodexDir checks for ~/.codex/auth.json before creating staging dir // On a machine without Codex auth, this returns undefined const tmpDir = mkdtempSync(join(tmpdir(), 'codex-no-auth-')); // We can't mock homedir easily, but verify the function doesn't throw - const result = stageCodexDir(tmpDir); + const result = await stageCodexDir(tmpDir); // On this machine with Codex installed, it will succeed expect(result === undefined || typeof result === 'string').toBe(true); }); }); describe('SandboxOrchestrator', () => { - let mockExecFile: ReturnType; - beforeEach(() => { - mockExecFile = vi.fn(); + vi.fn(); }); describe('isRunning', () => { diff --git a/packages/api/src/services/sandbox/orchestrator.ts b/packages/api/src/services/sandbox/orchestrator.ts index ca472945..613bea36 100644 --- a/packages/api/src/services/sandbox/orchestrator.ts +++ b/packages/api/src/services/sandbox/orchestrator.ts @@ -5,16 +5,21 @@ * Called by the strategy service when an agent needs to be spun up * in an isolated environment for autonomous task execution. * + * Phase 1 (current): Prepares containers alongside the host-side agent + * session. The container is spun up with credentials and worktree mounted + * but strategy execution still runs on the host. Phase 2 will route + * strategy execution into the container itself. + * * Design: builds docker run args from DB-sourced studio data (no filesystem * dependency for planning). Shells out to Docker via child_process. */ -import { execFile, execFileSync } from 'child_process'; +import { execFile } from 'child_process'; import { promisify } from 'util'; import { createHash } from 'crypto'; -import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs'; +import { readFile, writeFile, mkdir, mkdtemp, access, constants as fsConstants } from 'fs/promises'; import { join } from 'path'; -import { homedir, platform } from 'os'; +import { homedir, platform, tmpdir } from 'os'; import { logger } from '../../utils/logger.js'; const execFileAsync = promisify(execFile); @@ -22,6 +27,7 @@ const execFileAsync = promisify(execFile); const DEFAULT_IMAGE = 'inkwell:studio-sandbox'; const CONTAINER_HOME = '/home/sb'; const CONTAINER_LABEL = 'ink.sandbox=true'; +const CLAUDE_KEYCHAIN_SERVICE = 'Claude Code-credentials'; export type BackendAuthName = 'claude' | 'codex' | 'gemini'; @@ -136,8 +142,22 @@ function rewriteLoopbackUrl(rawUrl: string): string { return rawUrl; } -const CLAUDE_KEYCHAIN_SERVICE = 'Claude Code-credentials'; -const CLAUDE_CREDENTIALS_RELATIVE = '.claude/.credentials.json'; +// ============================================================================ +// Async helpers +// ============================================================================ + +async function fileExists(p: string): Promise { + try { + await access(p, fsConstants.F_OK); + return true; + } catch { + return false; + } +} + +// ============================================================================ +// Credential staging +// ============================================================================ /** * Stage a Claude config directory for Docker mounting. @@ -153,42 +173,42 @@ const CLAUDE_CREDENTIALS_RELATIVE = '.claude/.credentials.json'; * Returns the path to the staged directory, or undefined if credentials * aren't available. */ -export function stageClaudeDir(stagingDir: string): string | undefined { +export async function stageClaudeDir(stagingDir: string): Promise { const home = homedir(); const claudeHome = join(home, '.claude'); const stagedDir = join(stagingDir, 'claude-home'); - mkdirSync(stagedDir, { recursive: true }); + await mkdir(stagedDir, { recursive: true }); // Copy config files Claude Code needs const filesToCopy = ['settings.json', 'settings.local.json']; for (const file of filesToCopy) { const src = join(claudeHome, file); - if (existsSync(src)) { - writeFileSync(join(stagedDir, file), readFileSync(src, 'utf-8'), { mode: 0o600 }); + if (await fileExists(src)) { + const content = await readFile(src, 'utf-8'); + await writeFile(join(stagedDir, file), content, { mode: 0o600 }); } } // Stage credentials: file fallback first, then keychain extraction const credFile = join(claudeHome, '.credentials.json'); - if (existsSync(credFile)) { - writeFileSync(join(stagedDir, '.credentials.json'), readFileSync(credFile, 'utf-8'), { - mode: 0o600, - }); + if (await fileExists(credFile)) { + const content = await readFile(credFile, 'utf-8'); + await writeFile(join(stagedDir, '.credentials.json'), content, { mode: 0o600 }); return stagedDir; } if (platform() === 'darwin') { try { - const raw = execFileSync( + const { stdout: raw } = await execFileAsync( 'security', ['find-generic-password', '-s', CLAUDE_KEYCHAIN_SERVICE, '-w'], - { encoding: 'utf-8', timeout: 5_000, stdio: ['pipe', 'pipe', 'pipe'] } - ).trim(); + { encoding: 'utf-8', timeout: 5_000 } + ); - const data = JSON.parse(raw); + const data = JSON.parse(raw.trim()); if (!data?.claudeAiOauth?.accessToken) return undefined; - writeFileSync(join(stagedDir, '.credentials.json'), JSON.stringify(data, null, 2) + '\n', { + await writeFile(join(stagedDir, '.credentials.json'), JSON.stringify(data, null, 2) + '\n', { mode: 0o600, }); return stagedDir; @@ -210,24 +230,25 @@ export function stageClaudeDir(stagingDir: string): string | undefined { * * Returns the path to the staged directory, or undefined if no auth exists. */ -export function stageCodexDir(stagingDir: string): string | undefined { +export async function stageCodexDir(stagingDir: string): Promise { const home = homedir(); const codexHome = join(home, '.codex'); - if (!existsSync(codexHome)) return undefined; + if (!(await fileExists(codexHome))) return undefined; const authFile = join(codexHome, 'auth.json'); - if (!existsSync(authFile)) return undefined; + if (!(await fileExists(authFile))) return undefined; const stagedDir = join(stagingDir, 'codex-home'); - mkdirSync(stagedDir, { recursive: true }); + await mkdir(stagedDir, { recursive: true }); // Copy auth.json as-is - writeFileSync(join(stagedDir, 'auth.json'), readFileSync(authFile, 'utf-8'), { mode: 0o600 }); + const authContent = await readFile(authFile, 'utf-8'); + await writeFile(join(stagedDir, 'auth.json'), authContent, { mode: 0o600 }); // Patch config.toml: rewrite loopback MCP URLs, strip host-specific project paths const configFile = join(codexHome, 'config.toml'); - if (existsSync(configFile)) { - let config = readFileSync(configFile, 'utf-8'); + if (await fileExists(configFile)) { + let config = await readFile(configFile, 'utf-8'); // Rewrite localhost/127.0.0.1 URLs to host.docker.internal config = config.replace( @@ -241,33 +262,149 @@ export function stageCodexDir(stagingDir: string): string | undefined { // Add container project as trusted config += '\n[projects."/studio"]\ntrust_level = "trusted"\n'; - writeFileSync(join(stagedDir, 'config.toml'), config, { mode: 0o600 }); + await writeFile(join(stagedDir, 'config.toml'), config, { mode: 0o600 }); } // Copy installation_id if present const installId = join(codexHome, 'installation_id'); - if (existsSync(installId)) { - writeFileSync(join(stagedDir, 'installation_id'), readFileSync(installId, 'utf-8'), { - mode: 0o600, - }); + if (await fileExists(installId)) { + const content = await readFile(installId, 'utf-8'); + await writeFile(join(stagedDir, 'installation_id'), content, { mode: 0o600 }); } return stagedDir; } -export function buildMounts(request: SandboxSpinUpRequest): SandboxMount[] { +// ============================================================================ +// MCP config patching +// ============================================================================ + +/** + * Patch .mcp.json for Docker: rewrite loopback URLs to host.docker.internal + * and strip stdio/command-based servers (they can't spawn inside the container). + * + * Writes the patched config to stagingDir (outside the worktree) to avoid + * exposing it inside the /studio mount. + */ +export async function patchMcpConfig( + studioPath: string, + stagingDir?: string +): Promise { + const sourcePath = join(studioPath, '.mcp.json'); + if (!(await fileExists(sourcePath))) return undefined; + + try { + const raw = await readFile(sourcePath, 'utf-8'); + const parsed = JSON.parse(raw) as { + mcpServers?: Record>; + }; + const servers = parsed.mcpServers; + if (!servers) return undefined; + + const patched: Record> = {}; + for (const [name, server] of Object.entries(servers)) { + // Only keep HTTP transport servers — stdio/command servers can't run in the container + if (server?.type === 'http' && typeof server.url === 'string') { + patched[name] = { ...server, url: rewriteLoopbackUrl(server.url) }; + } + } + + if (Object.keys(patched).length === 0) return undefined; + + const outDir = stagingDir || (await mkdtemp(join(tmpdir(), 'ink-mcp-'))); + await mkdir(outDir, { recursive: true }); + const targetPath = join(outDir, 'mcp.docker.json'); + await writeFile(targetPath, JSON.stringify({ mcpServers: patched }, null, 2) + '\n', 'utf-8'); + return targetPath; + } catch { + return undefined; + } +} + +// ============================================================================ +// Git worktree resolution +// ============================================================================ + +/** + * Resolve additional mounts needed for git worktrees. + * + * In a worktree, .git is a file containing "gitdir: " rather than + * a directory. The referenced path points to repoRoot/.git/worktrees/, + * which in turn references the shared objects/refs. Docker bind-mounting + * the worktree alone breaks git because the canonical .git dir isn't visible. + * + * Fix: mount the canonical .git dir and overlay a patched .git file that + * uses container-relative paths. + */ +async function resolveGitMounts( + worktreePath: string, + repoRoot: string, + stagingDir: string +): Promise { + const gitPath = join(worktreePath, '.git'); + + try { + await access(gitPath, fsConstants.F_OK); + } catch { + return []; + } + + try { + const content = await readFile(gitPath, 'utf-8'); + + // .git file format: "gitdir: \n" + const match = content.match(/^gitdir:\s*(.+)$/m); + if (!match) return []; + + const hostGitDir = match[1].trim(); + const worktreeMatch = hostGitDir.match(/\.git\/worktrees\/(.+)$/); + if (!worktreeMatch) return []; + + const worktreeName = worktreeMatch[1]; + const canonicalGitDir = join(repoRoot, '.git'); + + if (!(await fileExists(canonicalGitDir))) return []; + + const mounts: SandboxMount[] = [ + { source: canonicalGitDir, target: '/repo/.git', readOnly: false }, + ]; + + // Create a patched .git file pointing to the container-mapped path + const patchedGitFile = join(stagingDir, 'dotgit'); + await writeFile(patchedGitFile, `gitdir: /repo/.git/worktrees/${worktreeName}\n`); + mounts.push({ source: patchedGitFile, target: '/studio/.git', readOnly: true }); + + return mounts; + } catch { + return []; + } +} + +// ============================================================================ +// Mount + arg builders +// ============================================================================ + +export async function buildMounts( + request: SandboxSpinUpRequest, + stagingDir?: string +): Promise { + const effectiveDir = stagingDir || (await mkdtemp(join(tmpdir(), 'ink-sandbox-'))); const mounts: SandboxMount[] = []; - if (existsSync(request.worktreePath)) { + if (await fileExists(request.worktreePath)) { mounts.push({ source: request.worktreePath, target: '/studio', readOnly: false }); } // Mount patched MCP config if it exists - const patchedMcpPath = patchMcpConfig(request.worktreePath); + const patchedMcpPath = await patchMcpConfig(request.worktreePath, effectiveDir); if (patchedMcpPath) { mounts.push({ source: patchedMcpPath, target: '/studio/.mcp.json', readOnly: true }); } + // Resolve git worktree mounts (canonical .git dir + patched .git file) + const gitMounts = await resolveGitMounts(request.worktreePath, request.repoRoot, effectiveDir); + mounts.push(...gitMounts); + // Backend auth dirs (read-only) const home = homedir(); const authDirs: Record = { @@ -281,8 +418,7 @@ export function buildMounts(request: SandboxSpinUpRequest): SandboxMount[] { // Stage ~/.claude with credentials extracted from keychain. // We can't mount ~/.claude read-only and then overlay a file inside it, // so we stage everything into one directory. - const runtimeDir = join(request.worktreePath, '.ink', 'runtime', 'sandbox'); - const stagedClaudeHome = stageClaudeDir(runtimeDir); + const stagedClaudeHome = await stageClaudeDir(effectiveDir); if (stagedClaudeHome) { mounts.push({ source: stagedClaudeHome, @@ -292,7 +428,7 @@ export function buildMounts(request: SandboxSpinUpRequest): SandboxMount[] { } else { // Fallback: mount ~/.claude directly (no credentials, but settings work) const sourceDir = authDirs[backend]; - if (existsSync(sourceDir)) { + if (await fileExists(sourceDir)) { mounts.push({ source: sourceDir, target: `${CONTAINER_HOME}/.claude`, @@ -303,7 +439,7 @@ export function buildMounts(request: SandboxSpinUpRequest): SandboxMount[] { // Claude Code also needs ~/.claude.json (config separate from ~/.claude/ dir) const claudeJson = join(home, '.claude.json'); - if (existsSync(claudeJson)) { + if (await fileExists(claudeJson)) { mounts.push({ source: claudeJson, target: `${CONTAINER_HOME}/.claude.json`, @@ -312,8 +448,7 @@ export function buildMounts(request: SandboxSpinUpRequest): SandboxMount[] { } } else if (backend === 'codex') { // Stage ~/.codex with patched config.toml (rewrite loopback URLs, strip host paths) - const runtimeDir = join(request.worktreePath, '.ink', 'runtime', 'sandbox'); - const stagedCodexHome = stageCodexDir(runtimeDir); + const stagedCodexHome = await stageCodexDir(effectiveDir); if (stagedCodexHome) { mounts.push({ source: stagedCodexHome, @@ -322,7 +457,7 @@ export function buildMounts(request: SandboxSpinUpRequest): SandboxMount[] { }); } else { const sourceDir = authDirs[backend]; - if (existsSync(sourceDir)) { + if (await fileExists(sourceDir)) { mounts.push({ source: sourceDir, target: `${CONTAINER_HOME}/.codex`, @@ -332,7 +467,7 @@ export function buildMounts(request: SandboxSpinUpRequest): SandboxMount[] { } } else { const sourceDir = authDirs[backend]; - if (existsSync(sourceDir)) { + if (await fileExists(sourceDir)) { mounts.push({ source: sourceDir, target: `${CONTAINER_HOME}/.${backend}`, @@ -345,46 +480,16 @@ export function buildMounts(request: SandboxSpinUpRequest): SandboxMount[] { return mounts; } -/** - * Patch .mcp.json for Docker: rewrite loopback URLs to host.docker.internal - * and strip stdio/command-based servers (they can't spawn inside the container). - */ -export function patchMcpConfig(studioPath: string): string | undefined { - const sourcePath = join(studioPath, '.mcp.json'); - if (!existsSync(sourcePath)) return undefined; - - try { - const parsed = JSON.parse(readFileSync(sourcePath, 'utf-8')) as { - mcpServers?: Record>; - }; - const servers = parsed.mcpServers; - if (!servers) return undefined; - - const patched: Record> = {}; - for (const [name, server] of Object.entries(servers)) { - // Only keep HTTP transport servers — stdio/command servers can't run in the container - if (server?.type === 'http' && typeof server.url === 'string') { - patched[name] = { ...server, url: rewriteLoopbackUrl(server.url) }; - } - } - - if (Object.keys(patched).length === 0) return undefined; - - const runtimeDir = join(studioPath, '.ink', 'runtime', 'sandbox'); - mkdirSync(runtimeDir, { recursive: true }); - const targetPath = join(runtimeDir, 'mcp.docker.json'); - writeFileSync(targetPath, JSON.stringify({ mcpServers: patched }, null, 2) + '\n', 'utf-8'); - return targetPath; - } catch { - return undefined; - } -} - -export function buildDockerRunArgs(request: SandboxSpinUpRequest): string[] { +export async function buildDockerRunArgs(request: SandboxSpinUpRequest): Promise { const containerName = buildContainerName(request); const image = request.image || DEFAULT_IMAGE; const env = buildEnvVars(request); - const mounts = buildMounts(request); + + // Staging dir outside the worktree — credentials and config land here + const stagingDir = join(homedir(), '.ink', 'runtime', 'sandbox', containerName); + await mkdir(stagingDir, { recursive: true }); + + const mounts = await buildMounts(request, stagingDir); const args = ['run', '--rm', '-d', '--name', containerName]; args.push('--workdir', '/studio'); @@ -444,7 +549,7 @@ export class SandboxOrchestrator { return { containerName, success: true, alreadyRunning: true }; } - const args = buildDockerRunArgs(request); + const args = await buildDockerRunArgs(request); try { await execFileAsync(this.dockerCommand, args, { timeout: 30_000 }); From 959219f421eabca14175a6171c173ef091c0427b Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Thu, 7 May 2026 15:43:47 -0700 Subject: [PATCH 11/21] feat: persistent test fixtures + readiness probe (by Wren) Two improvements for dev velocity and stability: 1. Persistent test containers: INK_PERSIST_TEST_CONTAINER=1 keeps fixture containers (ink-test-sandbox-integ, ink-test-sandbox-live) alive across runs. Stable dirs (~/.ink/test-fixtures/) ensure mounts match. Tear down manually with: docker rm -f ink-test-sandbox-integ ink-test-sandbox-live 2. Readiness probe: waitReady() polls 'docker exec true' after spin-up (5s timeout, 100ms interval). Prevents flaky exec failures when Docker is slow to create the exec environment. Also adds containerName override to SandboxSpinUpRequest for named containers (test fixtures, explicit naming use cases). Co-Authored-By: Wren --- .../sandbox/orchestrator.integration.test.ts | 359 ++++++++++-------- .../orchestrator.live.integration.test.ts | 137 +++---- .../src/services/sandbox/orchestrator.test.ts | 5 + .../api/src/services/sandbox/orchestrator.ts | 28 ++ 4 files changed, 297 insertions(+), 232 deletions(-) diff --git a/packages/api/src/services/sandbox/orchestrator.integration.test.ts b/packages/api/src/services/sandbox/orchestrator.integration.test.ts index 33fa39a6..b58b29ae 100644 --- a/packages/api/src/services/sandbox/orchestrator.integration.test.ts +++ b/packages/api/src/services/sandbox/orchestrator.integration.test.ts @@ -5,13 +5,20 @@ * - Docker daemon running * - The inkwell:studio-sandbox image built (`ink studio sandbox build`) * + * Container reuse: + * A shared fixture container (ink-test-sandbox-integ) is spun up once + * and reused across capability tests. Set INK_PERSIST_TEST_CONTAINER=1 + * to keep it alive after the run — saves ~20s on re-runs. + * + * To tear it down manually: docker rm -f ink-test-sandbox-integ + * * Run with: npx vitest run --config vitest.integration.config.ts src/services/sandbox/orchestrator.integration.test.ts */ import { describe, it, expect, afterAll, beforeAll } from 'vitest'; import { execFileSync, spawnSync } from 'child_process'; -import { mkdtempSync, writeFileSync, mkdirSync } from 'fs'; -import { tmpdir } from 'os'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs'; +import { homedir, tmpdir } from 'os'; import { join } from 'path'; import { SandboxOrchestrator, buildContainerName, type SandboxSpinUpRequest } from './orchestrator'; @@ -30,24 +37,57 @@ function imageExists(image: string): boolean { } const SKIP = !dockerAvailable() || !imageExists('inkwell:studio-sandbox'); +const PERSIST = process.env.INK_PERSIST_TEST_CONTAINER === '1'; +const FIXTURE_NAME = 'ink-test-sandbox-integ'; +// Stable path so persisted containers' mounts match across runs +const FIXTURE_DIR = join(homedir(), '.ink', 'test-fixtures', 'sandbox-integ'); describe.skipIf(SKIP)('SandboxOrchestrator (integration)', () => { let orchestrator: SandboxOrchestrator; let testDir: string; - const containersToCleanup: string[] = []; + const ephemeralContainers: string[] = []; + let fixtureWasPreExisting = false; - beforeAll(() => { + beforeAll(async () => { orchestrator = new SandboxOrchestrator(); - testDir = mkdtempSync(join(tmpdir(), 'sandbox-integ-')); + + // Use a stable dir for fixture containers, temp dir for ephemeral + testDir = FIXTURE_DIR; + mkdirSync(testDir, { recursive: true }); + // Refresh test files each run (idempotent) writeFileSync(join(testDir, 'hello.txt'), 'Integration test file\n'); mkdirSync(join(testDir, 'src'), { recursive: true }); writeFileSync(join(testDir, 'src', 'index.ts'), 'console.log("hello");\n'); + // Clean up any output files from prior runs + try { + rmSync(join(testDir, 'fixture-output.txt')); + } catch {} + + // Spin up or reuse the shared fixture container + fixtureWasPreExisting = await orchestrator.isRunning(FIXTURE_NAME); + if (!fixtureWasPreExisting) { + const result = await orchestrator.spinUp({ + userId: 'test-user', + agentId: 'test-agent', + studioId: 'studio-integ-fixture', + studioSlug: 'integ', + worktreePath: testDir, + repoRoot: testDir, + containerName: FIXTURE_NAME, + }); + expect(result.success).toBe(true); + } }); afterAll(async () => { - for (const name of containersToCleanup) { + // Always clean up ephemeral containers + for (const name of ephemeralContainers) { await orchestrator.stop(name).catch(() => {}); } + // Only tear down fixture if not persisting and we created it this run + if (!PERSIST && !fixtureWasPreExisting) { + await orchestrator.stop(FIXTURE_NAME).catch(() => {}); + } }); function makeRequest(overrides: Partial = {}): SandboxSpinUpRequest { @@ -62,147 +102,168 @@ describe.skipIf(SKIP)('SandboxOrchestrator (integration)', () => { }; } - it('spins up a container and verifies it is running', async () => { - const request = makeRequest({ studioSlug: 'integ-spinup' }); - const containerName = buildContainerName(request); - containersToCleanup.push(containerName); - - const result = await orchestrator.spinUp(request); - expect(result.success).toBe(true); - expect(result.containerName).toBe(containerName); - - const running = await orchestrator.isRunning(containerName); - expect(running).toBe(true); - }, 30_000); - - it('returns alreadyRunning when container exists', async () => { - const request = makeRequest({ studioSlug: 'integ-already' }); - const containerName = buildContainerName(request); - containersToCleanup.push(containerName); - - const first = await orchestrator.spinUp(request); - expect(first.success).toBe(true); - - const second = await orchestrator.spinUp(request); - expect(second.success).toBe(true); - expect(second.alreadyRunning).toBe(true); - }, 30_000); - - it('mounts studio at /studio and can read files', async () => { - const request = makeRequest({ studioSlug: 'integ-mount' }); - const containerName = buildContainerName(request); - containersToCleanup.push(containerName); - - await orchestrator.spinUp(request); - - const { stdout } = await orchestrator.exec(containerName, ['cat', '/studio/hello.txt']); - expect(stdout.trim()).toBe('Integration test file'); - }, 30_000); - - it('passes env vars into the container', async () => { - const request = makeRequest({ - studioSlug: 'integ-env', - taskGroupId: 'tg-test-123', - taskGroupTitle: 'Test Group', - }); - const containerName = buildContainerName(request); - containersToCleanup.push(containerName); - - await orchestrator.spinUp(request); - - const { stdout: agentId } = await orchestrator.exec(containerName, [ - 'bash', - '-c', - 'echo $AGENT_ID', - ]); - expect(agentId.trim()).toBe('test-agent'); - - const { stdout: tgId } = await orchestrator.exec(containerName, [ - 'bash', - '-c', - 'echo $INK_TASK_GROUP_ID', - ]); - expect(tgId.trim()).toBe('tg-test-123'); - - const { stdout: sandbox } = await orchestrator.exec(containerName, [ - 'bash', - '-c', - 'echo $INK_SANDBOX', - ]); - expect(sandbox.trim()).toBe('docker'); - }, 30_000); - - it('stops a running container', async () => { - const request = makeRequest({ studioSlug: 'integ-stop' }); - const containerName = buildContainerName(request); - // Don't add to cleanup — we're stopping it ourselves - - await orchestrator.spinUp(request); - expect(await orchestrator.isRunning(containerName)).toBe(true); - - const stopped = await orchestrator.stop(containerName); - expect(stopped).toBe(true); - expect(await orchestrator.isRunning(containerName)).toBe(false); - }, 30_000); - - it('gets container status with labels', async () => { - const request = makeRequest({ - studioSlug: 'integ-status', - taskGroupId: 'tg-status-test', - }); - const containerName = buildContainerName(request); - containersToCleanup.push(containerName); - - await orchestrator.spinUp(request); - - const status = await orchestrator.getStatus(containerName); - expect(status.running).toBe(true); - expect(status.labels?.['ink.sandbox']).toBe('true'); - expect(status.labels?.['ink.agent-id']).toBe('test-agent'); - expect(status.labels?.['ink.task-group-id']).toBe('tg-status-test'); - }, 30_000); - - it('lists active sandboxes', async () => { - const request = makeRequest({ studioSlug: 'integ-list' }); - const containerName = buildContainerName(request); - containersToCleanup.push(containerName); - - await orchestrator.spinUp(request); - - const sandboxes = await orchestrator.listSandboxes(); - const found = sandboxes.find((s) => s.containerName === containerName); - expect(found).toBeDefined(); - expect(found?.running).toBe(true); - }, 30_000); - - it('container has node and claude cli available', async () => { - const request = makeRequest({ studioSlug: 'integ-tools' }); - const containerName = buildContainerName(request); - containersToCleanup.push(containerName); - - await orchestrator.spinUp(request); - - const { stdout: nodeVersion } = await orchestrator.exec(containerName, ['node', '--version']); - expect(nodeVersion.trim()).toMatch(/^v22\./); - - const { stdout: claudePath } = await orchestrator.exec(containerName, ['which', 'claude']); - expect(claudePath.trim()).toBeTruthy(); - }, 30_000); - - it('container name includes task group context', async () => { - const request = makeRequest({ - studioSlug: 'integ-naming', - taskGroupId: 'tg-naming-test', - taskGroupTitle: 'Auth Migration', - }); - const containerName = buildContainerName(request); - containersToCleanup.push(containerName); - - expect(containerName).toContain('integ-naming'); - expect(containerName).toContain('auth-migration'); - - await orchestrator.spinUp(request); - const running = await orchestrator.isRunning(containerName); - expect(running).toBe(true); - }, 30_000); + // ── Shared fixture tests (fast — reuse one container) ─────────────── + + describe('shared fixture', () => { + it('can read files at /studio', async () => { + const { stdout } = await orchestrator.exec(FIXTURE_NAME, ['cat', '/studio/hello.txt']); + expect(stdout.trim()).toBe('Integration test file'); + }, 10_000); + + it('has node and claude cli available', async () => { + const { stdout: nodeVersion } = await orchestrator.exec(FIXTURE_NAME, ['node', '--version']); + expect(nodeVersion.trim()).toMatch(/^v22\./); + + const { stdout: claudePath } = await orchestrator.exec(FIXTURE_NAME, ['which', 'claude']); + expect(claudePath.trim()).toBeTruthy(); + }, 10_000); + + it('has correct workdir', async () => { + const { stdout } = await orchestrator.exec(FIXTURE_NAME, ['pwd']); + expect(stdout.trim()).toBe('/studio'); + }, 10_000); + + it('can write files visible on the host', async () => { + await orchestrator.exec(FIXTURE_NAME, [ + 'bash', + '-c', + 'echo "from-fixture" > /studio/fixture-output.txt', + ]); + const { readFileSync } = await import('fs'); + const content = readFileSync(join(testDir, 'fixture-output.txt'), 'utf-8'); + expect(content.trim()).toBe('from-fixture'); + }, 10_000); + }); + + // ── Per-container lifecycle tests (each spins up its own) ─────────── + + describe('container lifecycle', () => { + it('spins up a container and verifies it is running', async () => { + const request = makeRequest({ studioSlug: 'integ-spinup' }); + const containerName = buildContainerName(request); + ephemeralContainers.push(containerName); + + const result = await orchestrator.spinUp(request); + expect(result.success).toBe(true); + expect(result.containerName).toBe(containerName); + + const running = await orchestrator.isRunning(containerName); + expect(running).toBe(true); + }, 30_000); + + it('returns alreadyRunning when container exists', async () => { + const request = makeRequest({ studioSlug: 'integ-already' }); + const containerName = buildContainerName(request); + ephemeralContainers.push(containerName); + + const first = await orchestrator.spinUp(request); + expect(first.success).toBe(true); + + const second = await orchestrator.spinUp(request); + expect(second.success).toBe(true); + expect(second.alreadyRunning).toBe(true); + }, 30_000); + + it('passes env vars into the container', async () => { + const request = makeRequest({ + studioSlug: 'integ-env', + taskGroupId: 'tg-test-123', + taskGroupTitle: 'Test Group', + }); + const containerName = buildContainerName(request); + ephemeralContainers.push(containerName); + + await orchestrator.spinUp(request); + + const { stdout: agentId } = await orchestrator.exec(containerName, [ + 'bash', + '-c', + 'echo $AGENT_ID', + ]); + expect(agentId.trim()).toBe('test-agent'); + + const { stdout: tgId } = await orchestrator.exec(containerName, [ + 'bash', + '-c', + 'echo $INK_TASK_GROUP_ID', + ]); + expect(tgId.trim()).toBe('tg-test-123'); + + const { stdout: sandbox } = await orchestrator.exec(containerName, [ + 'bash', + '-c', + 'echo $INK_SANDBOX', + ]); + expect(sandbox.trim()).toBe('docker'); + }, 30_000); + + it('stops a running container', async () => { + const request = makeRequest({ studioSlug: 'integ-stop' }); + const containerName = buildContainerName(request); + + await orchestrator.spinUp(request); + expect(await orchestrator.isRunning(containerName)).toBe(true); + + const stopped = await orchestrator.stop(containerName); + expect(stopped).toBe(true); + expect(await orchestrator.isRunning(containerName)).toBe(false); + }, 30_000); + + it('gets container status with labels', async () => { + const request = makeRequest({ + studioSlug: 'integ-status', + taskGroupId: 'tg-status-test', + }); + const containerName = buildContainerName(request); + ephemeralContainers.push(containerName); + + await orchestrator.spinUp(request); + + const status = await orchestrator.getStatus(containerName); + expect(status.running).toBe(true); + expect(status.labels?.['ink.sandbox']).toBe('true'); + expect(status.labels?.['ink.agent-id']).toBe('test-agent'); + expect(status.labels?.['ink.task-group-id']).toBe('tg-status-test'); + }, 30_000); + + it('lists active sandboxes', async () => { + const request = makeRequest({ studioSlug: 'integ-list' }); + const containerName = buildContainerName(request); + ephemeralContainers.push(containerName); + + await orchestrator.spinUp(request); + + const sandboxes = await orchestrator.listSandboxes(); + const found = sandboxes.find((s) => s.containerName === containerName); + expect(found).toBeDefined(); + expect(found?.running).toBe(true); + }, 30_000); + + it('container name includes task group context', async () => { + const request = makeRequest({ + studioSlug: 'integ-naming', + taskGroupId: 'tg-naming-test', + taskGroupTitle: 'Auth Migration', + }); + const containerName = buildContainerName(request); + ephemeralContainers.push(containerName); + + expect(containerName).toContain('integ-naming'); + expect(containerName).toContain('auth-migration'); + + await orchestrator.spinUp(request); + const running = await orchestrator.isRunning(containerName); + expect(running).toBe(true); + }, 30_000); + + it('respects containerName override', async () => { + const customName = 'ink-test-custom-name'; + const request = makeRequest({ containerName: customName }); + ephemeralContainers.push(customName); + + const result = await orchestrator.spinUp(request); + expect(result.containerName).toBe(customName); + expect(await orchestrator.isRunning(customName)).toBe(true); + }, 30_000); + }); }); diff --git a/packages/api/src/services/sandbox/orchestrator.live.integration.test.ts b/packages/api/src/services/sandbox/orchestrator.live.integration.test.ts index 8aba9f4e..c03c1736 100644 --- a/packages/api/src/services/sandbox/orchestrator.live.integration.test.ts +++ b/packages/api/src/services/sandbox/orchestrator.live.integration.test.ts @@ -8,15 +8,22 @@ * - Active Claude Code session (OAuth tokens staged from macOS keychain) * - Inkwell server running on localhost:3001 (for MCP connectivity test) * - * Run with: INK_LIVE_TESTS=1 npx vitest run --config vitest.integration.config.ts src/services/sandbox/orchestrator.live.test.ts + * Container reuse: + * A shared fixture container (ink-test-sandbox-live) is spun up once + * with credentials mounted and reused across all tests. Set + * INK_PERSIST_TEST_CONTAINER=1 to keep it alive after the run. + * + * To tear it down manually: docker rm -f ink-test-sandbox-live + * + * Run with: INK_LIVE_TESTS=1 npx vitest run --config vitest.integration.config.ts src/services/sandbox/orchestrator.live.integration.test.ts */ import { describe, it, expect, afterAll, beforeAll } from 'vitest'; import { execFileSync, spawnSync } from 'child_process'; -import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, existsSync } from 'fs'; -import { homedir, tmpdir } from 'os'; +import { writeFileSync, mkdirSync, readFileSync, existsSync, rmSync } from 'fs'; +import { homedir } from 'os'; import { join } from 'path'; -import { SandboxOrchestrator, buildContainerName, type SandboxSpinUpRequest } from './orchestrator'; +import { SandboxOrchestrator, type SandboxSpinUpRequest } from './orchestrator'; function dockerAvailable(): boolean { try { @@ -33,7 +40,6 @@ function imageExists(image: string): boolean { } function claudeCredentialsAvailable(): boolean { - // Sync check for skip conditions (can't use async stageClaudeDir with it.skipIf) const credFile = join(homedir(), '.claude', '.credentials.json'); if (existsSync(credFile)) return true; if (process.platform === 'darwin') { @@ -67,22 +73,32 @@ const SKIP = !dockerAvailable() || !imageExists('inkwell:studio-sandbox'); +const PERSIST = process.env.INK_PERSIST_TEST_CONTAINER === '1'; +const FIXTURE_NAME = 'ink-test-sandbox-live'; +// Stable path so persisted containers' mounts match across runs +const FIXTURE_DIR = join(homedir(), '.ink', 'test-fixtures', 'sandbox-live'); + describe.skipIf(SKIP)('SandboxOrchestrator (live)', () => { let orchestrator: SandboxOrchestrator; let testDir: string; - const containersToCleanup: string[] = []; + let fixtureWasPreExisting = false; - beforeAll(() => { + beforeAll(async () => { orchestrator = new SandboxOrchestrator(); - testDir = mkdtempSync(join(tmpdir(), 'sandbox-live-')); + testDir = FIXTURE_DIR; + mkdirSync(testDir, { recursive: true }); - // Create a minimal studio with files to manipulate + // Refresh test files each run (idempotent) writeFileSync(join(testDir, 'README.md'), '# Test Project\n\nThis is a sandbox live test.\n'); mkdirSync(join(testDir, 'src'), { recursive: true }); writeFileSync( join(testDir, 'src', 'hello.ts'), 'export function greet() { return "hello"; }\n' ); + // Clean up output files from prior runs + try { + rmSync(join(testDir, 'sandbox-output.txt')); + } catch {} // Create .mcp.json with inkwell (HTTP) + a stdio server to verify stripping writeFileSync( @@ -98,64 +114,51 @@ describe.skipIf(SKIP)('SandboxOrchestrator (live)', () => { 2 ) ); + + // Spin up or reuse the shared fixture container (with credentials) + fixtureWasPreExisting = await orchestrator.isRunning(FIXTURE_NAME); + if (!fixtureWasPreExisting) { + const result = await orchestrator.spinUp({ + userId: 'live-test-user', + agentId: 'live-test-agent', + studioId: 'studio-live-fixture', + studioSlug: 'live', + worktreePath: testDir, + repoRoot: testDir, + backendAuth: ['claude'], + containerName: FIXTURE_NAME, + }); + expect(result.success).toBe(true); + } }); afterAll(async () => { - for (const name of containersToCleanup) { - await orchestrator.stop(name).catch(() => {}); + if (!PERSIST && !fixtureWasPreExisting) { + await orchestrator.stop(FIXTURE_NAME).catch(() => {}); } }); - function makeRequest(overrides: Partial = {}): SandboxSpinUpRequest { - return { - userId: 'live-test-user', - agentId: 'live-test-agent', - studioId: `studio-live-${Date.now()}`, - studioSlug: 'live', - worktreePath: testDir, - repoRoot: testDir, - backendAuth: ['claude'], - ...overrides, - }; - } - describe('worktree manipulation', () => { it('agent can read and write files in the mounted studio', async () => { - const request = makeRequest({ studioSlug: 'live-worktree' }); - const containerName = buildContainerName(request); - containersToCleanup.push(containerName); - - await orchestrator.spinUp(request); - - // Read existing file - const { stdout: readResult } = await orchestrator.exec(containerName, [ + const { stdout: readResult } = await orchestrator.exec(FIXTURE_NAME, [ 'cat', '/studio/README.md', ]); expect(readResult).toContain('Test Project'); - // Write a new file - await orchestrator.exec(containerName, [ + await orchestrator.exec(FIXTURE_NAME, [ 'bash', '-c', 'echo "created by sandbox" > /studio/sandbox-output.txt', ]); - // Verify file exists on the host (bind mount = shared filesystem) const hostPath = join(testDir, 'sandbox-output.txt'); expect(existsSync(hostPath)).toBe(true); expect(readFileSync(hostPath, 'utf-8').trim()).toBe('created by sandbox'); - }, 30_000); + }, 10_000); it('agent can modify existing source files', async () => { - const request = makeRequest({ studioSlug: 'live-modify' }); - const containerName = buildContainerName(request); - containersToCleanup.push(containerName); - - await orchestrator.spinUp(request); - - // Append to an existing file - await orchestrator.exec(containerName, [ + await orchestrator.exec(FIXTURE_NAME, [ 'bash', '-c', 'echo \'export function farewell() { return "goodbye"; }\' >> /studio/src/hello.ts', @@ -164,27 +167,18 @@ describe.skipIf(SKIP)('SandboxOrchestrator (live)', () => { const content = readFileSync(join(testDir, 'src', 'hello.ts'), 'utf-8'); expect(content).toContain('farewell'); expect(content).toContain('greet'); - }, 30_000); + }, 10_000); }); describe('MCP config patching', () => { it('patched config contains only HTTP servers with rewritten URLs', async () => { - const request = makeRequest({ studioSlug: 'live-mcp' }); - const containerName = buildContainerName(request); - containersToCleanup.push(containerName); - - await orchestrator.spinUp(request); - - const { stdout } = await orchestrator.exec(containerName, ['cat', '/studio/.mcp.json']); + const { stdout } = await orchestrator.exec(FIXTURE_NAME, ['cat', '/studio/.mcp.json']); const config = JSON.parse(stdout); - // inkwell should be present with rewritten URL expect(config.mcpServers.inkwell).toBeDefined(); expect(config.mcpServers.inkwell.url).toContain('host.docker.internal'); - - // stdio server should be stripped expect(config.mcpServers.playwright).toBeUndefined(); - }, 30_000); + }, 10_000); }); describe('LLM response', () => { @@ -193,15 +187,7 @@ describe.skipIf(SKIP)('SandboxOrchestrator (live)', () => { it.skipIf(SKIP_LLM)( 'gets a live Claude response inside the container', async () => { - const request = makeRequest({ studioSlug: 'live-llm' }); - const containerName = buildContainerName(request); - containersToCleanup.push(containerName); - - await orchestrator.spinUp(request); - - // Use claude CLI with --print (non-interactive, single-shot) - // Just verify we get a non-empty response (the model is authenticated) - const { stdout } = await orchestrator.exec(containerName, [ + const { stdout } = await orchestrator.exec(FIXTURE_NAME, [ 'claude', '--print', '--model', @@ -218,14 +204,7 @@ describe.skipIf(SKIP)('SandboxOrchestrator (live)', () => { it.skipIf(SKIP_LLM)( 'Claude can read workspace files via coding tools', async () => { - const request = makeRequest({ studioSlug: 'live-read' }); - const containerName = buildContainerName(request); - containersToCleanup.push(containerName); - - await orchestrator.spinUp(request); - - // --allowedTools requires -p for the prompt - const { stdout } = await orchestrator.exec(containerName, [ + const { stdout } = await orchestrator.exec(FIXTURE_NAME, [ 'claude', '--print', '--model', @@ -248,23 +227,15 @@ describe.skipIf(SKIP)('SandboxOrchestrator (live)', () => { it.skipIf(SKIP_INKWELL)( 'container can reach Inkwell server via host.docker.internal', async () => { - const request = makeRequest({ studioSlug: 'live-inkwell-reach' }); - const containerName = buildContainerName(request); - containersToCleanup.push(containerName); - - await orchestrator.spinUp(request); - - // Verify HTTP connectivity to the Inkwell server - const { stdout } = await orchestrator.exec(containerName, [ + const { stdout } = await orchestrator.exec(FIXTURE_NAME, [ 'curl', '-sf', 'http://host.docker.internal:3001/health', ]); - // Health endpoint should return something (OK, JSON, etc.) expect(stdout.length).toBeGreaterThan(0); }, - 30_000 + 10_000 ); }); }); diff --git a/packages/api/src/services/sandbox/orchestrator.test.ts b/packages/api/src/services/sandbox/orchestrator.test.ts index 37e261bf..fa5a355f 100644 --- a/packages/api/src/services/sandbox/orchestrator.test.ts +++ b/packages/api/src/services/sandbox/orchestrator.test.ts @@ -72,6 +72,11 @@ describe('buildContainerName', () => { // "ink-sandbox-" (12) + slug (max 24) + "-" (1) + digest (8) = max 45 chars expect(name.length).toBeLessThanOrEqual(50); }); + + it('uses containerName override when provided', () => { + const name = buildContainerName({ ...baseRequest, containerName: 'ink-test-my-fixture' }); + expect(name).toBe('ink-test-my-fixture'); + }); }); describe('buildEnvVars', () => { diff --git a/packages/api/src/services/sandbox/orchestrator.ts b/packages/api/src/services/sandbox/orchestrator.ts index 613bea36..952d21ad 100644 --- a/packages/api/src/services/sandbox/orchestrator.ts +++ b/packages/api/src/services/sandbox/orchestrator.ts @@ -54,6 +54,8 @@ export interface SandboxSpinUpRequest { backendAuth?: BackendAuthName[]; networkMode?: 'default' | 'none'; extraEnv?: Record; + /** Override the computed container name (useful for test fixtures, named containers) */ + containerName?: string; } export interface SandboxSpinUpResult { @@ -72,6 +74,7 @@ export interface SandboxStatusResult { } export function buildContainerName(request: SandboxSpinUpRequest): string { + if (request.containerName) return request.containerName; const label = sanitizeSlug(request.studioSlug || request.agentId || 'studio'); const parts = [request.worktreePath]; if (request.taskGroupId) parts.push(request.taskGroupId); @@ -553,6 +556,13 @@ export class SandboxOrchestrator { try { await execFileAsync(this.dockerCommand, args, { timeout: 30_000 }); + + // Wait for the container to be ready to accept exec calls + const ready = await this.waitReady(containerName); + if (!ready) { + logger.warn('Sandbox started but readiness check timed out', { containerName }); + } + logger.info('Sandbox container started', { containerName, agentId: request.agentId, @@ -567,6 +577,24 @@ export class SandboxOrchestrator { } } + /** + * Poll until the container can accept exec calls. Returns false on timeout. + */ + async waitReady(containerName: string, timeoutMs = 5_000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + await execFileAsync(this.dockerCommand, ['exec', containerName, 'true'], { + timeout: 2_000, + }); + return true; + } catch { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + return false; + } + async stop(containerName: string): Promise { try { await execFileAsync(this.dockerCommand, ['rm', '-f', containerName], { timeout: 15_000 }); From 2a6a426c4c72304f3fcf3ca6382b95f700ebbe87 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Thu, 7 May 2026 16:03:22 -0700 Subject: [PATCH 12/21] =?UTF-8?q?feat:=20bash=20process=20guard=20?= =?UTF-8?q?=E2=80=94=20dangerous=20command=20blocking=20+=20agent-scoped?= =?UTF-8?q?=20kill=20enforcement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a defense-in-depth layer for bash command execution: - Static analysis blocks catastrophic commands (fork bombs, rm -rf /, mkfs, dd to block devices, shutdown/reboot, nsenter) - Kill scope enforcement prevents agents from signaling PIDs they don't own, enabling multi-SB-per-container isolation - ProcessRegistry tracks background PIDs per agent with TTL cleanup - Integrated into Pi coding tools adapter (activated by agentId config) Co-Authored-By: Wren --- .../api/src/agent/tools/bash-guard.test.ts | 406 ++++++++++++++++++ packages/api/src/agent/tools/bash-guard.ts | 282 ++++++++++++ .../src/agent/tools/pi-coding-tools.test.ts | 76 +++- .../api/src/agent/tools/pi-coding-tools.ts | 41 +- 4 files changed, 803 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/agent/tools/bash-guard.test.ts create mode 100644 packages/api/src/agent/tools/bash-guard.ts diff --git a/packages/api/src/agent/tools/bash-guard.test.ts b/packages/api/src/agent/tools/bash-guard.test.ts new file mode 100644 index 00000000..9c762f94 --- /dev/null +++ b/packages/api/src/agent/tools/bash-guard.test.ts @@ -0,0 +1,406 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + analyzeCommand, + ProcessRegistry, + extractBackgroundPids, + guardBashCommand, + getProcessRegistry, + resetProcessRegistry, +} from './bash-guard'; + +vi.mock('../../utils/logger', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +describe('Bash Guard', () => { + describe('analyzeCommand', () => { + describe('fork bombs', () => { + it('blocks classic fork bomb', () => { + const result = analyzeCommand(':(){ :|:& };:'); + expect(result.blocked).toBe(true); + expect(result.reason).toContain('fork bomb'); + }); + + it('blocks named fork bomb', () => { + const result = analyzeCommand('bomb(){ bomb|bomb& };bomb'); + expect(result.blocked).toBe(true); + expect(result.reason).toContain('fork bomb'); + }); + + it('blocks fork bomb with spaces', () => { + const result = analyzeCommand('x(){ x | x & };x'); + expect(result.blocked).toBe(true); + }); + + it('allows function definitions that are not fork bombs', () => { + const result = analyzeCommand('greet(){ echo "hello"; }'); + expect(result.blocked).toBe(false); + }); + }); + + describe('recursive root delete', () => { + it('blocks rm -rf /', () => { + expect(analyzeCommand('rm -rf /').blocked).toBe(true); + expect(analyzeCommand('rm -rf /').reason).toContain('recursive delete'); + }); + + it('blocks rm -r -f /', () => { + expect(analyzeCommand('rm -r -f /').blocked).toBe(true); + }); + + it('blocks rm --recursive /', () => { + expect(analyzeCommand('rm --recursive /').blocked).toBe(true); + }); + + it('blocks rm -rf /*', () => { + expect(analyzeCommand('rm -rf /*').blocked).toBe(true); + }); + + it('blocks sudo rm -rf /', () => { + expect(analyzeCommand('sudo rm -rf /').blocked).toBe(true); + }); + + it('blocks rm -rf / after semicolon', () => { + expect(analyzeCommand('echo hi; rm -rf /').blocked).toBe(true); + }); + + it('allows rm -rf /tmp/foo', () => { + expect(analyzeCommand('rm -rf /tmp/foo').blocked).toBe(false); + }); + + it('allows rm without recursive flag', () => { + expect(analyzeCommand('rm /tmp/file.txt').blocked).toBe(false); + }); + + it('allows rm -r on non-root paths', () => { + expect(analyzeCommand('rm -r ./build').blocked).toBe(false); + }); + }); + + describe('dangerous patterns', () => { + it('blocks mkfs', () => { + expect(analyzeCommand('mkfs.ext4 /dev/sda1').blocked).toBe(true); + expect(analyzeCommand('mkfs /dev/sda').blocked).toBe(true); + }); + + it('blocks dd to block devices', () => { + expect(analyzeCommand('dd if=/dev/zero of=/dev/sda').blocked).toBe(true); + expect(analyzeCommand('dd if=/dev/zero of=/dev/nvme0n1').blocked).toBe(true); + }); + + it('allows dd to regular files', () => { + expect(analyzeCommand('dd if=/dev/zero of=/tmp/image.iso bs=1M count=100').blocked).toBe( + false + ); + }); + + it('blocks shutdown', () => { + expect(analyzeCommand('shutdown -h now').blocked).toBe(true); + expect(analyzeCommand('shutdown').blocked).toBe(true); + }); + + it('blocks reboot', () => { + expect(analyzeCommand('reboot').blocked).toBe(true); + }); + + it('blocks poweroff', () => { + expect(analyzeCommand('poweroff').blocked).toBe(true); + }); + + it('blocks init 0 and init 6', () => { + expect(analyzeCommand('init 0').blocked).toBe(true); + expect(analyzeCommand('init 6').blocked).toBe(true); + }); + + it('allows init with other arguments', () => { + expect(analyzeCommand('init 3').blocked).toBe(false); + }); + + it('blocks nsenter', () => { + expect(analyzeCommand('nsenter --target 1 --mount').blocked).toBe(true); + }); + }); + + describe('kill commands', () => { + it('detects kill as a kill command', () => { + const result = analyzeCommand('kill 1234'); + expect(result.isKillCommand).toBe(true); + expect(result.killPidTargets).toEqual([1234]); + }); + + it('extracts multiple PID targets', () => { + const result = analyzeCommand('kill -9 1234 5678'); + expect(result.killPidTargets).toEqual([1234, 5678]); + }); + + it('blocks kill -1 (all processes)', () => { + const result = analyzeCommand('kill -1'); + expect(result.blocked).toBe(true); + expect(result.reason).toContain('all processes'); + }); + + it('blocks kill targeting PID 1', () => { + const result = analyzeCommand('kill 1'); + expect(result.blocked).toBe(true); + expect(result.reason).toContain('PID 1'); + }); + + it('blocks kill -9 1', () => { + const result = analyzeCommand('kill -9 1'); + expect(result.blocked).toBe(true); + expect(result.reason).toContain('PID 1'); + }); + + it('does not block kill of high PIDs', () => { + const result = analyzeCommand('kill -9 12345'); + expect(result.blocked).toBe(false); + expect(result.isKillCommand).toBe(true); + expect(result.killPidTargets).toEqual([12345]); + }); + + it('handles kill after -- separator', () => { + const result = analyzeCommand('kill -- 4567'); + expect(result.killPidTargets).toEqual([4567]); + }); + + it('detects pkill as a kill command', () => { + const result = analyzeCommand('pkill -f node'); + expect(result.isKillCommand).toBe(true); + }); + + it('detects killall as a kill command', () => { + const result = analyzeCommand('killall node'); + expect(result.isKillCommand).toBe(true); + }); + + it('handles multiple kill commands in one line', () => { + const result = analyzeCommand('kill 100; kill 200'); + expect(result.killPidTargets).toContain(100); + expect(result.killPidTargets).toContain(200); + }); + }); + + describe('safe commands', () => { + it('allows echo', () => { + expect(analyzeCommand('echo hello').blocked).toBe(false); + expect(analyzeCommand('echo hello').isKillCommand).toBe(false); + }); + + it('allows ls', () => { + expect(analyzeCommand('ls -la').blocked).toBe(false); + }); + + it('allows cat', () => { + expect(analyzeCommand('cat /etc/hostname').blocked).toBe(false); + }); + + it('allows git commands', () => { + expect(analyzeCommand('git status').blocked).toBe(false); + expect(analyzeCommand('git commit -m "fix"').blocked).toBe(false); + }); + + it('allows npm/yarn', () => { + expect(analyzeCommand('npm install').blocked).toBe(false); + expect(analyzeCommand('yarn build').blocked).toBe(false); + }); + + it('allows piped commands', () => { + expect(analyzeCommand('ps aux | grep node').blocked).toBe(false); + }); + + it('allows compound commands', () => { + expect(analyzeCommand('cd /tmp && ls -la').blocked).toBe(false); + }); + }); + }); + + describe('ProcessRegistry', () => { + let registry: ProcessRegistry; + + beforeEach(() => { + registry = new ProcessRegistry(); + }); + + it('registers and retrieves PIDs', () => { + registry.register('wren', 1234, 'sleep 100'); + expect(registry.has(1234)).toBe(true); + expect(registry.isOwned('wren', 1234)).toBe(true); + expect(registry.getOwner(1234)).toBe('wren'); + }); + + it('tracks PIDs per agent', () => { + registry.register('wren', 100, 'sleep 100'); + registry.register('wren', 200, 'node server.js'); + registry.register('lumen', 300, 'python main.py'); + + expect(registry.getAgentPids('wren')).toEqual([100, 200]); + expect(registry.getAgentPids('lumen')).toEqual([300]); + }); + + it('rejects ownership checks for wrong agent', () => { + registry.register('wren', 1234, 'sleep 100'); + expect(registry.isOwned('lumen', 1234)).toBe(false); + }); + + it('removes entries', () => { + registry.register('wren', 1234, 'sleep 100'); + registry.remove(1234); + expect(registry.has(1234)).toBe(false); + expect(registry.size).toBe(0); + }); + + it('clears all entries', () => { + registry.register('wren', 100, 'a'); + registry.register('lumen', 200, 'b'); + registry.clear(); + expect(registry.size).toBe(0); + }); + + it('removes dead processes on cleanup', () => { + // PID 99999999 almost certainly doesn't exist + registry.register('wren', 99999999, 'ghost'); + expect(registry.size).toBe(1); + registry.cleanup(); + expect(registry.size).toBe(0); + }); + + it('keeps alive processes on cleanup', () => { + // Current process is alive + registry.register('wren', process.pid, 'self'); + registry.cleanup(); + expect(registry.has(process.pid)).toBe(true); + }); + + it('removes expired entries on cleanup', () => { + const shortTtl = new ProcessRegistry(1); // 1ms TTL + shortTtl.register('wren', process.pid, 'self'); + + // Wait for TTL to expire + const start = Date.now(); + while (Date.now() - start < 5) { + /* spin */ + } + + shortTtl.cleanup(); + expect(shortTtl.size).toBe(0); + }); + }); + + describe('extractBackgroundPids', () => { + it('extracts PID from bash background output', () => { + expect(extractBackgroundPids('[1] 12345')).toEqual([12345]); + }); + + it('extracts multiple PIDs', () => { + const output = '[1] 12345\n[2] 67890'; + expect(extractBackgroundPids(output)).toEqual([12345, 67890]); + }); + + it('handles PIDs mixed with other output', () => { + const output = 'Starting server...\n[1] 54321\nListening on port 3000'; + expect(extractBackgroundPids(output)).toEqual([54321]); + }); + + it('returns empty for no background PIDs', () => { + expect(extractBackgroundPids('hello world')).toEqual([]); + expect(extractBackgroundPids('')).toEqual([]); + }); + + it('handles job completion messages', () => { + const output = '[1] 12345\n[1]+ Done sleep 1'; + expect(extractBackgroundPids(output)).toEqual([12345]); + }); + }); + + describe('guardBashCommand', () => { + beforeEach(() => { + resetProcessRegistry(); + }); + + afterEach(() => { + resetProcessRegistry(); + }); + + describe('dangerous command blocking', () => { + it('blocks fork bombs', () => { + const result = guardBashCommand(':(){ :|:& };:', { agentId: 'wren' }); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('fork bomb'); + }); + + it('blocks rm -rf /', () => { + const result = guardBashCommand('rm -rf /', { agentId: 'wren' }); + expect(result.allowed).toBe(false); + }); + + it('allows safe commands', () => { + const result = guardBashCommand('echo hello', { agentId: 'wren' }); + expect(result.allowed).toBe(true); + }); + + it('can be disabled', () => { + const result = guardBashCommand(':(){ :|:& };:', { + agentId: 'wren', + blockDangerousCommands: false, + }); + expect(result.allowed).toBe(true); + }); + }); + + describe('kill scope enforcement', () => { + it('blocks kill targeting unregistered PIDs', () => { + const result = guardBashCommand('kill 1234', { agentId: 'wren' }); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('not owned by this agent'); + }); + + it('allows kill targeting own PIDs', () => { + const registry = getProcessRegistry(); + registry.register('wren', 1234, 'sleep 100'); + + const result = guardBashCommand('kill 1234', { agentId: 'wren' }); + expect(result.allowed).toBe(true); + }); + + it('blocks kill targeting another agent PIDs', () => { + const registry = getProcessRegistry(); + registry.register('lumen', 1234, 'sleep 100'); + + const result = guardBashCommand('kill 1234', { agentId: 'wren' }); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('1234'); + }); + + it('allows kill with mixed PIDs when all are owned', () => { + const registry = getProcessRegistry(); + registry.register('wren', 100, 'a'); + registry.register('wren', 200, 'b'); + + const result = guardBashCommand('kill 100 200', { agentId: 'wren' }); + expect(result.allowed).toBe(true); + }); + + it('blocks when any target PID is not owned', () => { + const registry = getProcessRegistry(); + registry.register('wren', 100, 'a'); + + const result = guardBashCommand('kill 100 200', { agentId: 'wren' }); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('200'); + }); + + it('allows pkill/killall (no PID targets to check)', () => { + const result = guardBashCommand('pkill -f "old-server"', { agentId: 'wren' }); + expect(result.allowed).toBe(true); + }); + + it('can be disabled', () => { + const result = guardBashCommand('kill 1234', { + agentId: 'wren', + enforceKillScope: false, + }); + expect(result.allowed).toBe(true); + }); + }); + }); +}); diff --git a/packages/api/src/agent/tools/bash-guard.ts b/packages/api/src/agent/tools/bash-guard.ts new file mode 100644 index 00000000..24714ce5 --- /dev/null +++ b/packages/api/src/agent/tools/bash-guard.ts @@ -0,0 +1,282 @@ +/** + * Bash Process Guard + * + * Defense-in-depth for bash command execution. NOT a substitute for + * container isolation — catches common dangerous patterns and provides + * multi-agent process boundaries. + * + * Three layers: + * 1. Static analysis — block catastrophic commands pre-execution + * 2. Kill scope enforcement — prevent cross-agent process termination + * 3. Process registry — track background PIDs per agent + */ + +import { logger } from '../../utils/logger'; + +// --- Types --- + +export interface CommandAnalysis { + blocked: boolean; + reason?: string; + isKillCommand: boolean; + killPidTargets: number[]; +} + +export interface BashGuardConfig { + agentId: string; + /** Block catastrophic commands before execution (default: true) */ + blockDangerousCommands?: boolean; + /** Enforce kill targeting only agent-owned PIDs (default: true) */ + enforceKillScope?: boolean; +} + +export interface GuardResult { + allowed: boolean; + reason?: string; +} + +// --- Dangerous command detection --- + +// Fork bomb: function that pipes to itself with backgrounding +// Catches :(){ :|:& };: and named variants like bomb(){ bomb|bomb& };bomb +const FORK_BOMB_RE = /([\w:.]+)\(\)\s*\{\s*\1\s*\|\s*\1\s*&/; + +interface DangerousPattern { + pattern: RegExp; + reason: string; +} + +const DANGEROUS_PATTERNS: DangerousPattern[] = [ + { pattern: FORK_BOMB_RE, reason: 'fork bomb detected' }, + { pattern: /\bmkfs(?:\.\w+)?\s/, reason: 'filesystem format' }, + { pattern: /\bdd\b[^;|&]*\bof=\/dev\/(?:sd|hd|nvme|vd)/, reason: 'raw disk write' }, + { pattern: /\bshutdown\b/, reason: 'system shutdown' }, + { pattern: /\breboot\b/, reason: 'system reboot' }, + { pattern: /\bpoweroff\b/, reason: 'system poweroff' }, + { pattern: /\binit\s+[06]\b/, reason: 'init level change' }, + { pattern: /\bnsenter\s/, reason: 'namespace entry' }, +]; + +function isRecursiveRootDelete(command: string): boolean { + const segments = command.split(/[;|&]+/); + for (const segment of segments) { + if (!/\brm\b/.test(segment)) continue; + if (!/-[a-zA-Z]*r/.test(segment) && !/--recursive/.test(segment)) continue; + const tokens = segment.trim().split(/\s+/); + const rmIdx = tokens.findIndex((t) => t === 'rm'); + if (rmIdx === -1) continue; + const pathArgs = tokens.slice(rmIdx + 1).filter((t) => !t.startsWith('-')); + if (pathArgs.some((t) => t === '/' || t === '/*')) return true; + } + return false; +} + +// --- Kill command analysis --- + +function analyzeKillCommands(command: string): { + found: boolean; + allProcesses: boolean; + initProcess: boolean; + pids: number[]; +} { + const result = { found: false, allProcesses: false, initProcess: false, pids: [] as number[] }; + + const matches = [...command.matchAll(/\bkill\s+([^;|&\n]+)/g)]; + if (matches.length === 0) return result; + result.found = true; + + for (const m of matches) { + const args = m[1].trim(); + + // -1 as a PID argument targets all processes + if (/(?:^|\s)-1(?:\s|$)/.test(args)) { + result.allProcesses = true; + } + + // Extract numeric PID targets (non-flag tokens after --) + // or non-flag tokens that are pure numbers + const tokens = args.split(/\s+/); + const dashDashIdx = tokens.indexOf('--'); + const candidates = + dashDashIdx >= 0 ? tokens.slice(dashDashIdx + 1) : tokens.filter((t) => !t.startsWith('-')); + + for (const t of candidates) { + const n = parseInt(t, 10); + if (!isNaN(n) && n > 0) result.pids.push(n); + } + } + + result.initProcess = result.pids.includes(1); + return result; +} + +// --- Public API --- + +export function analyzeCommand(command: string): CommandAnalysis { + const base: CommandAnalysis = { blocked: false, isKillCommand: false, killPidTargets: [] }; + + if (isRecursiveRootDelete(command)) { + return { ...base, blocked: true, reason: 'Blocked: recursive delete at filesystem root' }; + } + + for (const { pattern, reason } of DANGEROUS_PATTERNS) { + if (pattern.test(command)) { + return { ...base, blocked: true, reason: `Blocked: ${reason}` }; + } + } + + const kill = analyzeKillCommands(command); + const hasPkillKillall = /\b(pkill|killall)\b/.test(command); + + if (kill.allProcesses) { + return { + blocked: true, + reason: 'Blocked: kill -1 targets all processes', + isKillCommand: true, + killPidTargets: kill.pids, + }; + } + + if (kill.initProcess) { + return { + blocked: true, + reason: 'Blocked: cannot signal PID 1 (init process)', + isKillCommand: true, + killPidTargets: kill.pids, + }; + } + + return { + blocked: false, + isKillCommand: kill.found || hasPkillKillall, + killPidTargets: kill.pids, + }; +} + +// --- Process Registry --- + +interface ProcessEntry { + pid: number; + agentId: string; + command: string; + registeredAt: number; +} + +const DEFAULT_TTL_MS = 30 * 60 * 1000; + +export class ProcessRegistry { + private entries = new Map(); + private ttlMs: number; + + constructor(ttlMs = DEFAULT_TTL_MS) { + this.ttlMs = ttlMs; + } + + register(agentId: string, pid: number, command: string): void { + this.entries.set(pid, { pid, agentId, command, registeredAt: Date.now() }); + } + + isOwned(agentId: string, pid: number): boolean { + const entry = this.entries.get(pid); + return !!entry && entry.agentId === agentId; + } + + getOwner(pid: number): string | undefined { + return this.entries.get(pid)?.agentId; + } + + getAgentPids(agentId: string): number[] { + return [...this.entries.values()].filter((e) => e.agentId === agentId).map((e) => e.pid); + } + + has(pid: number): boolean { + return this.entries.has(pid); + } + + remove(pid: number): void { + this.entries.delete(pid); + } + + cleanup(): void { + const now = Date.now(); + for (const [pid, entry] of this.entries) { + if (now - entry.registeredAt > this.ttlMs) { + this.entries.delete(pid); + continue; + } + try { + process.kill(pid, 0); + } catch { + this.entries.delete(pid); + } + } + } + + get size(): number { + return this.entries.size; + } + + clear(): void { + this.entries.clear(); + } +} + +let _registry: ProcessRegistry | undefined; + +export function getProcessRegistry(): ProcessRegistry { + if (!_registry) _registry = new ProcessRegistry(); + return _registry; +} + +export function resetProcessRegistry(): void { + _registry = undefined; +} + +// --- Background PID extraction --- + +export function extractBackgroundPids(output: string): number[] { + const re = /\[\d+\]\s+(\d+)/g; + const pids: number[] = []; + let match; + while ((match = re.exec(output)) !== null) { + const pid = parseInt(match[1], 10); + if (pid > 0) pids.push(pid); + } + return pids; +} + +// --- Guard --- + +export function guardBashCommand(command: string, config: BashGuardConfig): GuardResult { + const analysis = analyzeCommand(command); + + if (config.blockDangerousCommands !== false && analysis.blocked) { + logger.warn('Bash guard blocked command', { + agentId: config.agentId, + command: command.substring(0, 200), + reason: analysis.reason, + }); + return { allowed: false, reason: analysis.reason }; + } + + if ( + config.enforceKillScope !== false && + analysis.isKillCommand && + analysis.killPidTargets.length > 0 + ) { + const registry = getProcessRegistry(); + const unauthorized = analysis.killPidTargets.filter( + (pid) => !registry.isOwned(config.agentId, pid) + ); + if (unauthorized.length > 0) { + const reason = `Blocked: cannot signal PIDs not owned by this agent: [${unauthorized.join(', ')}]`; + logger.warn('Bash guard blocked kill', { + agentId: config.agentId, + unauthorizedPids: unauthorized, + }); + return { allowed: false, reason }; + } + } + + return { allowed: true }; +} diff --git a/packages/api/src/agent/tools/pi-coding-tools.test.ts b/packages/api/src/agent/tools/pi-coding-tools.test.ts index ba8442f1..00971cf6 100644 --- a/packages/api/src/agent/tools/pi-coding-tools.test.ts +++ b/packages/api/src/agent/tools/pi-coding-tools.test.ts @@ -1,8 +1,9 @@ -import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; import path from 'path'; import { mkdtempSync, writeFileSync, mkdirSync, readFileSync } from 'fs'; import { tmpdir } from 'os'; import { createInkCodingTools, type InkToolDefinition } from './pi-coding-tools'; +import { resetProcessRegistry, getProcessRegistry } from './bash-guard'; vi.mock('../../utils/logger', () => ({ logger: { @@ -169,4 +170,77 @@ describe('Pi Coding Tools Adapter', () => { expect(names.length).toBe(6); }); }); + + describe('bash guard integration', () => { + let guardedTools: InkToolDefinition[]; + + beforeAll(async () => { + guardedTools = await createInkCodingTools({ + cwd: testDir, + agentId: 'test-agent', + }); + }); + + afterEach(() => { + resetProcessRegistry(); + }); + + it('blocks fork bombs before execution', async () => { + const bash = guardedTools.find((t) => t.schema.name === 'bash')!; + const result = await bash.execute({ command: ':(){ :|:& };:' }); + expect(result).toContain('Error'); + expect(result).toContain('fork bomb'); + }); + + it('blocks rm -rf / before execution', async () => { + const bash = guardedTools.find((t) => t.schema.name === 'bash')!; + const result = await bash.execute({ command: 'rm -rf /' }); + expect(result).toContain('Error'); + expect(result).toContain('recursive delete'); + }); + + it('blocks shutdown before execution', async () => { + const bash = guardedTools.find((t) => t.schema.name === 'bash')!; + const result = await bash.execute({ command: 'shutdown -h now' }); + expect(result).toContain('Error'); + expect(result).toContain('shutdown'); + }); + + it('blocks kill targeting unregistered PIDs', async () => { + const bash = guardedTools.find((t) => t.schema.name === 'bash')!; + const result = await bash.execute({ command: 'kill 99999' }); + expect(result).toContain('Error'); + expect(result).toContain('not owned by this agent'); + }); + + it('allows kill targeting own registered PIDs', async () => { + const registry = getProcessRegistry(); + registry.register('test-agent', 99999999, 'sleep 100'); + + const bash = guardedTools.find((t) => t.schema.name === 'bash')!; + // The kill will execute but fail (PID doesn't exist) — that's fine, + // the point is the guard lets it through + const result = await bash.execute({ command: 'kill 99999999' }); + expect(result).not.toContain('not owned by this agent'); + }); + + it('allows safe commands with guard enabled', async () => { + const bash = guardedTools.find((t) => t.schema.name === 'bash')!; + const result = await bash.execute({ command: 'echo guarded-hello' }); + expect(result).toContain('guarded-hello'); + }); + + it('does not guard when agentId is not set', async () => { + // The original tools (no agentId) should work normally + const bash = tools.find((t) => t.schema.name === 'bash')!; + const result = await bash.execute({ command: 'echo unguarded' }); + expect(result).toContain('unguarded'); + }); + + it('guard does not interfere with non-bash tools', async () => { + const readTool = guardedTools.find((t) => t.schema.name === 'read')!; + const result = await readTool.execute({ path: 'hello.txt' }); + expect(result).toContain('Hello, world!'); + }); + }); }); diff --git a/packages/api/src/agent/tools/pi-coding-tools.ts b/packages/api/src/agent/tools/pi-coding-tools.ts index 44813e98..b4376768 100644 --- a/packages/api/src/agent/tools/pi-coding-tools.ts +++ b/packages/api/src/agent/tools/pi-coding-tools.ts @@ -10,6 +10,7 @@ import path from 'path'; import type Anthropic from '@anthropic-ai/sdk'; import { logger } from '../../utils/logger'; +import { guardBashCommand, extractBackgroundPids, getProcessRegistry } from './bash-guard'; // Pi tool types — widened to accept TypeBox TObject schemas interface PiAgentTool { @@ -47,6 +48,15 @@ export interface PiCodingToolsConfig { enforceWorkspaceRoot?: boolean; /** Default bash timeout in seconds when model doesn't specify one (default: 120) */ bashTimeoutSeconds?: number; + /** Agent identity — enables bash guard (dangerous command blocking + kill scope) */ + agentId?: string; + /** Bash guard options (requires agentId to be set) */ + bashGuard?: { + /** Block catastrophic commands before execution (default: true) */ + blockDangerousCommands?: boolean; + /** Enforce kill targeting only agent-owned PIDs (default: true) */ + enforceKillScope?: boolean; + }; } const TOOLS_WITH_PATH_PARAM = new Set(['read', 'write', 'edit', 'grep', 'find', 'ls']); @@ -153,6 +163,7 @@ export async function createInkCodingTools( const enforceRoot = config.enforceWorkspaceRoot !== false; const bashTimeout = config.bashTimeoutSeconds ?? DEFAULT_BASH_TIMEOUT_SECONDS; + const agentId = config.agentId; return tools.map((tool) => ({ schema: { @@ -169,6 +180,20 @@ export async function createInkCodingTools( } } + // Bash guard: block dangerous commands and enforce kill scope + if (tool.name === 'bash' && agentId) { + const command = params.command as string; + if (command) { + const guard = guardBashCommand(command, { + agentId, + ...config.bashGuard, + }); + if (!guard.allowed) { + return `Error: ${guard.reason}`; + } + } + } + // Inject default bash timeout if the model doesn't specify one if (tool.name === 'bash' && !params.timeout) { params = { ...params, timeout: bashTimeout }; @@ -177,7 +202,21 @@ export async function createInkCodingTools( const callId = `ink-${tool.name}-${Date.now()}`; try { const result = await tool.execute(callId, params, signal); - return formatToolResult(result); + const resultText = formatToolResult(result); + + // Track background PIDs from bash output + if (tool.name === 'bash' && agentId) { + const bgPids = extractBackgroundPids(resultText); + if (bgPids.length > 0) { + const registry = getProcessRegistry(); + for (const pid of bgPids) { + registry.register(agentId, pid, (params.command as string) || ''); + } + logger.debug('Tracked background PIDs', { agentId, pids: bgPids }); + } + } + + return resultText; } catch (err) { const message = err instanceof Error ? err.message : String(err); logger.error(`Pi tool ${tool.name} failed`, { error: message, params }); From 35b3b0a98b159b20c0eedcf06a33086e9b3c8155 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Thu, 7 May 2026 16:18:21 -0700 Subject: [PATCH 13/21] =?UTF-8?q?test:=20bash=20guard=20live=20container?= =?UTF-8?q?=20tests=20=E2=80=94=20verify=20guard=20+=20container=20integra?= =?UTF-8?q?tion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guard blocks dangerous commands pre-execution; container stays healthy. Safe commands pass through and results are visible inside the container. Cross-agent kill enforcement validated with real ProcessRegistry. Co-Authored-By: Wren --- .../sandbox/orchestrator.integration.test.ts | 98 ++++++++++++++++++- 1 file changed, 96 insertions(+), 2 deletions(-) diff --git a/packages/api/src/services/sandbox/orchestrator.integration.test.ts b/packages/api/src/services/sandbox/orchestrator.integration.test.ts index b58b29ae..5a86f765 100644 --- a/packages/api/src/services/sandbox/orchestrator.integration.test.ts +++ b/packages/api/src/services/sandbox/orchestrator.integration.test.ts @@ -15,12 +15,14 @@ * Run with: npx vitest run --config vitest.integration.config.ts src/services/sandbox/orchestrator.integration.test.ts */ -import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import { describe, it, expect, afterAll, afterEach, beforeAll } from 'vitest'; import { execFileSync, spawnSync } from 'child_process'; -import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync } from 'fs'; import { homedir, tmpdir } from 'os'; import { join } from 'path'; import { SandboxOrchestrator, buildContainerName, type SandboxSpinUpRequest } from './orchestrator'; +import { createInkCodingTools, type InkToolDefinition } from '../../agent/tools/pi-coding-tools'; +import { getProcessRegistry, resetProcessRegistry } from '../../agent/tools/bash-guard'; function dockerAvailable(): boolean { try { @@ -266,4 +268,96 @@ describe.skipIf(SKIP)('SandboxOrchestrator (integration)', () => { expect(await orchestrator.isRunning(customName)).toBe(true); }, 30_000); }); + + // ── Bash guard in container context ──────────────────────────────── + + describe('bash guard (container context)', () => { + let guardedTools: InkToolDefinition[]; + + beforeAll(async () => { + guardedTools = await createInkCodingTools({ + cwd: testDir, + agentId: 'integ-guard-agent', + }); + }); + + afterEach(() => { + resetProcessRegistry(); + }); + + it('blocks fork bomb — container stays healthy', async () => { + const bash = guardedTools.find((t) => t.schema.name === 'bash')!; + const result = await bash.execute({ command: ':(){ :|:& };:' }); + expect(result).toContain('Blocked'); + expect(result).toContain('fork bomb'); + + // Container is still alive — the fork bomb never executed + const { stdout } = await orchestrator.exec(FIXTURE_NAME, ['echo', 'still-alive']); + expect(stdout.trim()).toBe('still-alive'); + }, 10_000); + + it('blocks rm -rf / — workspace files intact', async () => { + const bash = guardedTools.find((t) => t.schema.name === 'bash')!; + const result = await bash.execute({ command: 'rm -rf /' }); + expect(result).toContain('Blocked'); + expect(result).toContain('recursive delete'); + + // Workspace files still exist inside the container + const { stdout } = await orchestrator.exec(FIXTURE_NAME, ['cat', '/studio/hello.txt']); + expect(stdout).toContain('Integration test file'); + }, 10_000); + + it('blocks shutdown — container unaffected', async () => { + const bash = guardedTools.find((t) => t.schema.name === 'bash')!; + const result = await bash.execute({ command: 'shutdown -h now' }); + expect(result).toContain('Blocked'); + expect(result).toContain('shutdown'); + + const { stdout } = await orchestrator.exec(FIXTURE_NAME, ['echo', 'not-shut-down']); + expect(stdout.trim()).toBe('not-shut-down'); + }, 10_000); + + it('blocks kill of unregistered PIDs', async () => { + const bash = guardedTools.find((t) => t.schema.name === 'bash')!; + const result = await bash.execute({ command: 'kill 12345' }); + expect(result).toContain('Blocked'); + expect(result).toContain('not owned by this agent'); + }, 10_000); + + it('allows safe commands — result visible in container', async () => { + const bash = guardedTools.find((t) => t.schema.name === 'bash')!; + const result = await bash.execute({ + command: 'echo "guard-allowed" > guard-test-output.txt', + }); + // No error from guard + expect(result).not.toContain('Blocked'); + + // File created on host is visible inside the container + const { stdout } = await orchestrator.exec(FIXTURE_NAME, [ + 'cat', + '/studio/guard-test-output.txt', + ]); + expect(stdout.trim()).toBe('guard-allowed'); + }, 10_000); + + it('allows kill of agent-owned PIDs', async () => { + const registry = getProcessRegistry(); + registry.register('integ-guard-agent', 99999999, 'test-process'); + + const bash = guardedTools.find((t) => t.schema.name === 'bash')!; + // Guard lets it through — actual kill fails (PID doesn't exist) but that's expected + const result = await bash.execute({ command: 'kill 99999999' }); + expect(result).not.toContain('not owned by this agent'); + }, 10_000); + + it('cross-agent kill blocked — other agent PID protected', async () => { + const registry = getProcessRegistry(); + registry.register('other-agent', 88888, 'other-process'); + + const bash = guardedTools.find((t) => t.schema.name === 'bash')!; + const result = await bash.execute({ command: 'kill 88888' }); + expect(result).toContain('Blocked'); + expect(result).toContain('88888'); + }, 10_000); + }); }); From 09379de57009db35a75dbd6431feb52a698e287a Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Thu, 7 May 2026 16:29:43 -0700 Subject: [PATCH 14/21] =?UTF-8?q?fix:=20address=20review=20blockers=20?= =?UTF-8?q?=E2=80=94=20fail-closed=20kill=20guard,=20remove=20stdout=20PID?= =?UTF-8?q?=20registration,=20propagate=20agentId?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Kill scope now fail-closed: blocks pkill/killall (name-based), kill $PPID/$(cmd) (unresolvable), kill 0 (process group), kill -- -N (negative PID). Only numeric, registry-verified PIDs pass through. 2. Removed auto-registration of PIDs from bash stdout — poisonable via echo "[1] ". Registry now explicit-only. 3. DirectApiRunner propagates config.agentId into Pi tools so production bash tools are guarded. Cache key includes agentId. Co-Authored-By: Wren --- .../api/src/agent/tools/bash-guard.test.ts | 57 +++++++++++++++-- packages/api/src/agent/tools/bash-guard.ts | 63 ++++++++++++++++++- .../src/agent/tools/pi-coding-tools.test.ts | 14 +++++ .../api/src/agent/tools/pi-coding-tools.ts | 18 +----- .../sessions/direct-api-runner.test.ts | 48 ++++++++++++++ .../services/sessions/direct-api-runner.ts | 14 +++-- 6 files changed, 183 insertions(+), 31 deletions(-) create mode 100644 packages/api/src/services/sessions/direct-api-runner.test.ts diff --git a/packages/api/src/agent/tools/bash-guard.test.ts b/packages/api/src/agent/tools/bash-guard.test.ts index 9c762f94..dc5080de 100644 --- a/packages/api/src/agent/tools/bash-guard.test.ts +++ b/packages/api/src/agent/tools/bash-guard.test.ts @@ -163,14 +163,16 @@ describe('Bash Guard', () => { expect(result.killPidTargets).toEqual([4567]); }); - it('detects pkill as a kill command', () => { + it('blocks pkill (name-based, cannot verify ownership)', () => { const result = analyzeCommand('pkill -f node'); - expect(result.isKillCommand).toBe(true); + expect(result.blocked).toBe(true); + expect(result.reason).toContain('pkill/killall'); }); - it('detects killall as a kill command', () => { + it('blocks killall (name-based, cannot verify ownership)', () => { const result = analyzeCommand('killall node'); - expect(result.isKillCommand).toBe(true); + expect(result.blocked).toBe(true); + expect(result.reason).toContain('pkill/killall'); }); it('handles multiple kill commands in one line', () => { @@ -178,6 +180,36 @@ describe('Bash Guard', () => { expect(result.killPidTargets).toContain(100); expect(result.killPidTargets).toContain(200); }); + + it('blocks kill 0 (current process group)', () => { + const result = analyzeCommand('kill 0'); + expect(result.blocked).toBe(true); + expect(result.reason).toContain('process group'); + }); + + it('blocks kill with negative PID (process group target)', () => { + const result = analyzeCommand('kill -- -1234'); + expect(result.blocked).toBe(true); + expect(result.reason).toContain('process group'); + }); + + it('blocks kill $PPID (unresolvable variable target)', () => { + const result = analyzeCommand('kill $PPID'); + expect(result.blocked).toBe(true); + expect(result.reason).toContain('variable/dynamic'); + }); + + it('blocks kill $(cat /tmp/pid) (unresolvable command substitution)', () => { + const result = analyzeCommand('kill $(cat /tmp/pid)'); + expect(result.blocked).toBe(true); + expect(result.reason).toContain('variable/dynamic'); + }); + + it('blocks kill with backtick substitution', () => { + const result = analyzeCommand('kill `pgrep node`'); + expect(result.blocked).toBe(true); + expect(result.reason).toContain('variable/dynamic'); + }); }); describe('safe commands', () => { @@ -389,9 +421,22 @@ describe('Bash Guard', () => { expect(result.reason).toContain('200'); }); - it('allows pkill/killall (no PID targets to check)', () => { + it('blocks pkill (name-based, fail-closed)', () => { const result = guardBashCommand('pkill -f "old-server"', { agentId: 'wren' }); - expect(result.allowed).toBe(true); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('pkill/killall'); + }); + + it('blocks kill with variable expansion', () => { + const result = guardBashCommand('kill $PPID', { agentId: 'wren' }); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('variable/dynamic'); + }); + + it('blocks kill 0 (process group)', () => { + const result = guardBashCommand('kill 0', { agentId: 'wren' }); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('process group'); }); it('can be disabled', () => { diff --git a/packages/api/src/agent/tools/bash-guard.ts b/packages/api/src/agent/tools/bash-guard.ts index 24714ce5..f12e3eda 100644 --- a/packages/api/src/agent/tools/bash-guard.ts +++ b/packages/api/src/agent/tools/bash-guard.ts @@ -77,9 +77,18 @@ function analyzeKillCommands(command: string): { found: boolean; allProcesses: boolean; initProcess: boolean; + processGroup: boolean; + hasUnresolvableTargets: boolean; pids: number[]; } { - const result = { found: false, allProcesses: false, initProcess: false, pids: [] as number[] }; + const result = { + found: false, + allProcesses: false, + initProcess: false, + processGroup: false, + hasUnresolvableTargets: false, + pids: [] as number[], + }; const matches = [...command.matchAll(/\bkill\s+([^;|&\n]+)/g)]; if (matches.length === 0) return result; @@ -101,8 +110,29 @@ function analyzeKillCommands(command: string): { dashDashIdx >= 0 ? tokens.slice(dashDashIdx + 1) : tokens.filter((t) => !t.startsWith('-')); for (const t of candidates) { + // Variable expansion or command substitution — can't resolve statically + if (/\$/.test(t) || /`/.test(t)) { + result.hasUnresolvableTargets = true; + continue; + } const n = parseInt(t, 10); - if (!isNaN(n) && n > 0) result.pids.push(n); + if (isNaN(n)) continue; + if (n === 0) { + result.processGroup = true; + } else if (n < 0) { + result.processGroup = true; + } else { + result.pids.push(n); + } + } + + // Also check for negative PIDs after -- (process group targets) + if (dashDashIdx >= 0) { + for (const t of tokens.slice(dashDashIdx + 1)) { + if (/^-\d+$/.test(t) && t !== '-1') { + result.processGroup = true; + } + } } } @@ -146,9 +176,36 @@ export function analyzeCommand(command: string): CommandAnalysis { }; } + if (kill.processGroup) { + return { + blocked: true, + reason: 'Blocked: kill targeting process group (PID 0 or negative PID)', + isKillCommand: true, + killPidTargets: kill.pids, + }; + } + + if (kill.hasUnresolvableTargets) { + return { + blocked: true, + reason: 'Blocked: kill with variable/dynamic PID target — cannot verify ownership', + isKillCommand: true, + killPidTargets: kill.pids, + }; + } + + if (hasPkillKillall) { + return { + blocked: true, + reason: 'Blocked: pkill/killall target by name — cannot verify process ownership', + isKillCommand: true, + killPidTargets: [], + }; + } + return { blocked: false, - isKillCommand: kill.found || hasPkillKillall, + isKillCommand: kill.found, killPidTargets: kill.pids, }; } diff --git a/packages/api/src/agent/tools/pi-coding-tools.test.ts b/packages/api/src/agent/tools/pi-coding-tools.test.ts index 00971cf6..fbd30026 100644 --- a/packages/api/src/agent/tools/pi-coding-tools.test.ts +++ b/packages/api/src/agent/tools/pi-coding-tools.test.ts @@ -237,6 +237,20 @@ describe('Pi Coding Tools Adapter', () => { expect(result).toContain('unguarded'); }); + it('blocks pkill (fail-closed, name-based)', async () => { + const bash = guardedTools.find((t) => t.schema.name === 'bash')!; + const result = await bash.execute({ command: 'pkill -f node' }); + expect(result).toContain('Error'); + expect(result).toContain('pkill/killall'); + }); + + it('blocks kill with variable target (fail-closed)', async () => { + const bash = guardedTools.find((t) => t.schema.name === 'bash')!; + const result = await bash.execute({ command: 'kill $PPID' }); + expect(result).toContain('Error'); + expect(result).toContain('variable/dynamic'); + }); + it('guard does not interfere with non-bash tools', async () => { const readTool = guardedTools.find((t) => t.schema.name === 'read')!; const result = await readTool.execute({ path: 'hello.txt' }); diff --git a/packages/api/src/agent/tools/pi-coding-tools.ts b/packages/api/src/agent/tools/pi-coding-tools.ts index b4376768..b3aee19d 100644 --- a/packages/api/src/agent/tools/pi-coding-tools.ts +++ b/packages/api/src/agent/tools/pi-coding-tools.ts @@ -10,7 +10,7 @@ import path from 'path'; import type Anthropic from '@anthropic-ai/sdk'; import { logger } from '../../utils/logger'; -import { guardBashCommand, extractBackgroundPids, getProcessRegistry } from './bash-guard'; +import { guardBashCommand } from './bash-guard'; // Pi tool types — widened to accept TypeBox TObject schemas interface PiAgentTool { @@ -202,21 +202,7 @@ export async function createInkCodingTools( const callId = `ink-${tool.name}-${Date.now()}`; try { const result = await tool.execute(callId, params, signal); - const resultText = formatToolResult(result); - - // Track background PIDs from bash output - if (tool.name === 'bash' && agentId) { - const bgPids = extractBackgroundPids(resultText); - if (bgPids.length > 0) { - const registry = getProcessRegistry(); - for (const pid of bgPids) { - registry.register(agentId, pid, (params.command as string) || ''); - } - logger.debug('Tracked background PIDs', { agentId, pids: bgPids }); - } - } - - return resultText; + return formatToolResult(result); } catch (err) { const message = err instanceof Error ? err.message : String(err); logger.error(`Pi tool ${tool.name} failed`, { error: message, params }); diff --git a/packages/api/src/services/sessions/direct-api-runner.test.ts b/packages/api/src/services/sessions/direct-api-runner.test.ts new file mode 100644 index 00000000..a7571cb6 --- /dev/null +++ b/packages/api/src/services/sessions/direct-api-runner.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from 'vitest'; +import { DirectApiRunner } from './direct-api-runner'; +import type { InkToolDefinition } from '../../agent/tools/pi-coding-tools'; + +vi.mock('../../utils/logger', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +describe('DirectApiRunner', () => { + describe('agentId propagation', () => { + it('creates guarded bash tools when agentId is provided', async () => { + const runner = new DirectApiRunner({ apiKey: 'test-key' }); + + // Access private getTools to verify agentId propagation + const tools: InkToolDefinition[] = await (runner as any).getTools('/tmp', 'wren'); + const bash = tools.find((t) => t.schema.name === 'bash')!; + + // If agentId propagated, the guard blocks dangerous commands + const result = await bash.execute({ command: ':(){ :|:& };:' }); + expect(result).toContain('fork bomb'); + }); + + it('creates unguarded bash tools when agentId is absent', async () => { + const runner = new DirectApiRunner({ apiKey: 'test-key' }); + + const tools: InkToolDefinition[] = await (runner as any).getTools('/tmp'); + const bash = tools.find((t) => t.schema.name === 'bash')!; + + // Without agentId, guard is bypassed — command would execute + // (we test with a safe command to avoid actual execution of dangerous ones) + const result = await bash.execute({ command: 'echo hello' }); + expect(result).toContain('hello'); + }); + + it('caches tools by cwd+agentId combination', async () => { + const runner = new DirectApiRunner({ apiKey: 'test-key' }); + + const tools1 = await (runner as any).getTools('/tmp', 'wren'); + const tools2 = await (runner as any).getTools('/tmp', 'wren'); + const tools3 = await (runner as any).getTools('/tmp', 'lumen'); + + // Same cwd+agentId returns cached instance + expect(tools1).toBe(tools2); + // Different agentId returns different instance + expect(tools1).not.toBe(tools3); + }); + }); +}); diff --git a/packages/api/src/services/sessions/direct-api-runner.ts b/packages/api/src/services/sessions/direct-api-runner.ts index 60cb2d65..03ca2251 100644 --- a/packages/api/src/services/sessions/direct-api-runner.ts +++ b/packages/api/src/services/sessions/direct-api-runner.ts @@ -43,7 +43,7 @@ export interface DirectApiRunnerConfig { export class DirectApiRunner implements IRunner { private client: Anthropic | null = null; private runnerConfig: DirectApiRunnerConfig; - private toolsCache: Map = new Map(); + private toolsCache = new Map(); constructor(config: DirectApiRunnerConfig = {}) { this.runnerConfig = config; @@ -72,7 +72,7 @@ export class DirectApiRunner implements IRunner { } // Load Pi coding tools scoped to the working directory - const tools = await this.getTools(config.workingDirectory); + const tools = await this.getTools(config.workingDirectory, config.agentId); const toolSchemas: Anthropic.Tool[] = tools.map((t) => t.schema); if (this.runnerConfig.extraTools) { toolSchemas.push(...this.runnerConfig.extraTools); @@ -211,18 +211,20 @@ export class DirectApiRunner implements IRunner { this.client = new Anthropic({ apiKey }); } - private async getTools(cwd: string): Promise { - if (this.toolsCache.has(cwd)) { - return this.toolsCache.get(cwd)!; + private async getTools(cwd: string, agentId?: string): Promise { + const cacheKey = `${cwd}:${agentId ?? ''}`; + if (this.toolsCache.has(cacheKey)) { + return this.toolsCache.get(cacheKey)!; } const piConfig: PiCodingToolsConfig = { cwd, ...this.runnerConfig.piToolsConfig, + ...(agentId && { agentId }), }; const tools = await createInkCodingTools(piConfig); - this.toolsCache.set(cwd, tools); + this.toolsCache.set(cacheKey, tools); return tools; } From 1c3c184f74d915b3ff57d7a70918cf158866fe58 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Thu, 7 May 2026 18:28:39 -0700 Subject: [PATCH 15/21] =?UTF-8?q?feat:=20sandbox=20fail-closed=20policy=20?= =?UTF-8?q?=E2=80=94=20abort=20strategy=20when=20required=20sandbox=20can'?= =?UTF-8?q?t=20start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sandboxPolicy: 'required' (default) aborts the strategy and pauses the group when sandbox spin-up fails. 'preferred' falls back to host execution. Reorders startStrategy: sandbox before trigger, so the agent isn't kicked off on the host when the sandbox was supposed to provide isolation. maybeSpinUpSandbox now returns failure results (not null) for configuration errors (no orchestrator, no studioId, studio not found) so the fail-closed path catches them. Co-Authored-By: Wren --- .../repositories/task-groups.repository.ts | 2 + .../api/src/services/strategy.service.test.ts | 78 ++++++++++++++++++- packages/api/src/services/strategy.service.ts | 59 ++++++++++---- 3 files changed, 119 insertions(+), 20 deletions(-) diff --git a/packages/api/src/data/repositories/task-groups.repository.ts b/packages/api/src/data/repositories/task-groups.repository.ts index fcbe184b..be126276 100644 --- a/packages/api/src/data/repositories/task-groups.repository.ts +++ b/packages/api/src/data/repositories/task-groups.repository.ts @@ -70,6 +70,8 @@ export interface StrategyConfig { supervisorId?: string; /** Run the strategy in a sandboxed Docker container */ sandbox?: boolean; + /** Sandbox failure policy: 'required' fails the strategy if sandbox can't start, 'preferred' falls back to host (default: 'required') */ + sandboxPolicy?: 'required' | 'preferred'; /** Backend auth dirs to mount in the sandbox (default: ['claude']) */ sandboxBackendAuth?: Array<'claude' | 'codex' | 'gemini'>; } diff --git a/packages/api/src/services/strategy.service.test.ts b/packages/api/src/services/strategy.service.test.ts index feeedb52..fd856380 100644 --- a/packages/api/src/services/strategy.service.test.ts +++ b/packages/api/src/services/strategy.service.test.ts @@ -1622,7 +1622,7 @@ describe('StrategyService', () => { expect(result.sandbox?.success).toBe(true); }); - it('handles sandbox spin-up failure gracefully', async () => { + it('aborts strategy when sandbox fails and policy is required (default)', async () => { const group = createMockGroup({ strategy: null, status: 'active', @@ -1682,13 +1682,80 @@ describe('StrategyService', () => { ownerAgentId: 'wren', }); - // Strategy still starts — sandbox failure is non-fatal + // Fail-closed: strategy aborts, group is paused + expect(result.action).toBe('group_complete'); + expect(result.sandbox?.success).toBe(false); + expect(result.sandbox?.error).toContain('Docker daemon'); + expect(result.prompt).toContain('Sandbox spin-up failed'); + }); + + it('continues strategy when sandbox fails and policy is preferred', async () => { + const group = createMockGroup({ + strategy: null, + status: 'active', + metadata: { studioId: 'studio-abc' }, + }); + const task = createMockTask(); + const mockOrchestrator = { + spinUp: vi.fn().mockResolvedValue({ + containerName: 'ink-sandbox-wren-test-12345678', + success: false, + error: 'Docker daemon not running', + }), + isRunning: vi.fn(), + }; + + dc.repositories.taskGroups.findById.mockResolvedValue(group); + dc.repositories.taskGroups.update.mockResolvedValue({ + ...group, + strategy: 'persistence', + status: 'active', + strategy_config: { sandbox: true, sandboxPolicy: 'preferred' }, + }); + dc.repositories.studios.findById.mockResolvedValue({ + id: 'studio-abc', + userId: 'user-123', + agentId: 'wren', + worktreePath: '/tmp/test-studio', + repoRoot: '/tmp/test-repo', + branch: 'wren/feat/test', + slug: 'wren', + }); + + const mockClient = dc.getClient(); + mockClient.from.mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + in: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ data: task, error: null }), + }), + }), + }), + }), + }), + insert: vi.fn().mockResolvedValue({ data: null, error: null }), + update: vi.fn().mockReturnValue({ + contains: vi.fn().mockResolvedValue({ data: null, error: null }), + }), + }); + + const serviceWithSandbox = new StrategyService(dc as any, mockOrchestrator as any); + const result = await serviceWithSandbox.startStrategy({ + groupId: 'group-1', + userId: 'user-123', + strategy: 'persistence', + ownerAgentId: 'wren', + }); + + // Preferred: strategy continues on host despite sandbox failure expect(result.action).toBe('next_task'); expect(result.sandbox?.success).toBe(false); expect(result.sandbox?.error).toContain('Docker daemon'); }); - it('skips sandbox when no studioId in metadata', async () => { + it('aborts when sandbox requested but no studioId in metadata', async () => { const group = createMockGroup({ strategy: null, status: 'active', @@ -1732,8 +1799,11 @@ describe('StrategyService', () => { ownerAgentId: 'wren', }); + // Fail-closed: no studioId means sandbox can't start expect(mockOrchestrator.spinUp).not.toHaveBeenCalled(); - expect(result.sandbox).toBeUndefined(); + expect(result.action).toBe('group_complete'); + expect(result.sandbox?.success).toBe(false); + expect(result.prompt).toContain('Sandbox spin-up failed'); }); }); }); diff --git a/packages/api/src/services/strategy.service.ts b/packages/api/src/services/strategy.service.ts index d9cbf0c7..bac27ec3 100644 --- a/packages/api/src/services/strategy.service.ts +++ b/packages/api/src/services/strategy.service.ts @@ -232,15 +232,40 @@ export class StrategyService { // Create a watchdog reminder so the heartbeat checks progress periodically await this.createWatchdogReminder(updated, input.userId); - // Kick off the owner agent in the assigned studio. Without this trigger, - // start_strategy only returns a prompt to the caller's session — which - // is useless when the caller is delegating to a different studio/agent. - // The trigger spawns (or resumes) a session in the target studio so work - // actually begins, matching how heartbeats/reminders already deliver. - const triggered = await this.triggerOwnerAgent(updated, nextTask, 'strategy_kickoff'); - - // Spin up sandbox container if configured + // Spin up sandbox BEFORE triggering the agent — if sandboxPolicy is + // 'required' (default), a failed sandbox aborts the strategy instead + // of silently degrading to host execution. + const config = updated.strategy_config as StrategyConfig; const sandboxResult = await this.maybeSpinUpSandbox(updated); + const sandboxPolicy = config.sandboxPolicy || 'required'; + + if (config.sandbox && sandboxResult && !sandboxResult.success && sandboxPolicy === 'required') { + // Fail-closed: revert the strategy to paused and report the failure + await this.dataComposer.repositories.taskGroups.update(input.groupId, { + status: 'paused', + strategy_paused_at: new Date().toISOString(), + }); + await this.logStrategyEvent( + updated, + 'sandbox_failed', + `Strategy aborted: sandbox required but spin-up failed — ${sandboxResult.error}`, + { + containerName: sandboxResult.containerName, + error: sandboxResult.error, + policy: 'required', + } + ); + return { + action: 'group_complete', + stats: { total: 0, completed: 0 }, + prompt: `Sandbox spin-up failed (policy: required). Error: ${sandboxResult.error}. Strategy has been paused — fix the sandbox configuration and retry.`, + sandbox: sandboxResult, + }; + } + + // Trigger the owner agent in the assigned studio. The trigger spawns + // (or resumes) a session in the target studio so work actually begins. + const triggered = await this.triggerOwnerAgent(updated, nextTask, 'strategy_kickoff'); // Log strategy start await this.logStrategyEvent( @@ -891,24 +916,26 @@ export class StrategyService { private async maybeSpinUpSandbox(group: TaskGroup): Promise { const config = group.strategy_config as StrategyConfig; if (!config.sandbox) return null; + if (!this.sandboxOrchestrator) { - logger.warn( - `Strategy group ${group.id} has sandbox enabled but no SandboxOrchestrator configured` - ); - return null; + const msg = `Sandbox enabled but no SandboxOrchestrator configured`; + logger.warn(`Strategy group ${group.id}: ${msg}`); + return { containerName: '', success: false, error: msg }; } const metadata = (group.metadata || {}) as Record; const studioId = typeof metadata.studioId === 'string' ? metadata.studioId : undefined; if (!studioId) { - logger.warn(`Strategy group ${group.id}: sandbox requested but no studioId in metadata`); - return null; + const msg = `Sandbox requested but no studioId in metadata`; + logger.warn(`Strategy group ${group.id}: ${msg}`); + return { containerName: '', success: false, error: msg }; } const studio = await this.dataComposer.repositories.studios.findById(studioId); if (!studio) { - logger.warn(`Strategy group ${group.id}: studio ${studioId} not found`); - return null; + const msg = `Studio ${studioId} not found`; + logger.warn(`Strategy group ${group.id}: ${msg}`); + return { containerName: '', success: false, error: msg }; } const result = await this.sandboxOrchestrator.spinUp({ From 8d0df22ab61efd21cf2c2eddc4a137fec826d7c8 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Thu, 7 May 2026 18:34:10 -0700 Subject: [PATCH 16/21] =?UTF-8?q?feat:=20container-aware=20spawn=20?= =?UTF-8?q?=E2=80=94=20route=20CLI=20runners=20through=20Docker=20exec=20t?= =?UTF-8?q?ransparently?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ContainerTarget to SpawnBackendOptions and resolveSpawnTarget() which wraps binary invocation in 'docker exec' when a container target is set. Env vars become -e flags, cwd becomes --workdir, stdin piping adds -i. All three CLI runners (Claude, Codex, Gemini) now accept config.container and route through resolveSpawnTarget. The runner doesn't know or care whether it's executing on the host or inside a container — the spawn abstraction handles it. ClaudeRunnerConfig gains container?: { containerName, dockerBinary? } for the session service to set when sandbox execution is active. Co-Authored-By: Wren --- .../src/services/sessions/claude-runner.ts | 61 ++++++---- .../api/src/services/sessions/codex-runner.ts | 43 ++++--- .../src/services/sessions/gemini-runner.ts | 43 ++++--- packages/api/src/services/sessions/types.ts | 5 + packages/shared/src/runner/index.ts | 2 + .../shared/src/runner/spawn-backend.test.ts | 108 +++++++++++++++++- packages/shared/src/runner/spawn-backend.ts | 72 +++++++++++- 7 files changed, 272 insertions(+), 62 deletions(-) diff --git a/packages/api/src/services/sessions/claude-runner.ts b/packages/api/src/services/sessions/claude-runner.ts index f898b976..f7140281 100644 --- a/packages/api/src/services/sessions/claude-runner.ts +++ b/packages/api/src/services/sessions/claude-runner.ts @@ -19,7 +19,12 @@ import type { import { formatInjectedContext } from './context-builder.js'; import { logger } from '../../utils/logger.js'; import { resolveBinaryPath, buildSpawnPath } from './resolve-binary.js'; -import { injectSessionHeaders, buildSessionEnv, writeRuntimeSessionHint } from '@inklabs/shared'; +import { + injectSessionHeaders, + buildSessionEnv, + writeRuntimeSessionHint, + resolveSpawnTarget, +} from '@inklabs/shared'; import { ensureStudioSettings, applyPermissionOverlay } from '../studio-settings.js'; /** Maximum time (ms) to wait for a Claude Code subprocess before killing it. @@ -238,30 +243,38 @@ export class ClaudeRunner implements IRunner { // Strip CLAUDECODE to prevent "nested session" detection when PCP is // launched from inside a Claude Code session (e.g., via PM2). const { CLAUDECODE, ...cleanEnv } = process.env; - const proc = spawn(claudeBin, args, { + const spawnEnv: Record = { + // Ensure Claude Code uses correct paths + HOME: process.env.HOME || '', + PATH: buildSpawnPath(claudeBin), + // Agent identity — hooks resolve identity from $AGENT_ID. + ...(config.agentId ? { AGENT_ID: config.agentId } : {}), + // Session env vars + ...buildSessionEnv({ + pcpSessionId: config.pcpSessionId, + runtimeLinkId: config.pcpSessionId ? runtimeLinkId : undefined, + studioId: config.studioId, + accessToken: config.pcpAccessToken, + agentId: config.agentId, + runtime: 'claude', + repoRoot: config.repoRoot, + }), + }; + + // Route through container or host — resolveSpawnTarget handles the + // docker exec wrapping transparently. + const target = resolveSpawnTarget({ + binary: claudeBin, + args, cwd: config.workingDirectory, - env: { - ...cleanEnv, - // Ensure Claude Code uses correct paths - HOME: process.env.HOME, - PATH: buildSpawnPath(claudeBin), - // Agent identity — hooks resolve identity from $AGENT_ID. - // Without this, hooks in cross-agent studios (e.g., Myra triggered - // in Wren's worktree) fall back to .ink/identity.json and get the - // wrong agent ID. - ...(config.agentId ? { AGENT_ID: config.agentId } : {}), - // Session env vars: INK_SESSION_ID for ${VAR} interpolation in - // .mcp.json headers, INK_RUNTIME_LINK_ID for hook hint matching. - ...buildSessionEnv({ - pcpSessionId: config.pcpSessionId, - runtimeLinkId: config.pcpSessionId ? runtimeLinkId : undefined, - studioId: config.studioId, - accessToken: config.pcpAccessToken, - agentId: config.agentId, - runtime: 'claude', - repoRoot: config.repoRoot, - }), - }, + env: spawnEnv, + pipeStdin: true, + container: config.container, + }); + + const proc = spawn(target.binary, target.args, { + cwd: target.cwd, + env: config.container ? target.env : { ...cleanEnv, ...spawnEnv }, stdio: ['pipe', 'pipe', 'pipe'], }); diff --git a/packages/api/src/services/sessions/codex-runner.ts b/packages/api/src/services/sessions/codex-runner.ts index 82eb2641..bee580a1 100644 --- a/packages/api/src/services/sessions/codex-runner.ts +++ b/packages/api/src/services/sessions/codex-runner.ts @@ -22,7 +22,7 @@ import type { import { formatInjectedContext } from './context-builder.js'; import { logger } from '../../utils/logger.js'; import { resolveBinaryPath, buildSpawnPath } from './resolve-binary.js'; -import { buildSessionEnv, writeRuntimeSessionHint } from '@inklabs/shared'; +import { buildSessionEnv, writeRuntimeSessionHint, resolveSpawnTarget } from '@inklabs/shared'; /** Maximum time (ms) to wait for a Codex CLI subprocess before killing it. * Override with CODEX_PROCESS_TIMEOUT_MS env var. */ @@ -184,23 +184,32 @@ export class CodexRunner implements IRunner { return new Promise((resolve, reject) => { // Strip CLAUDECODE to prevent env leaking into subprocess const { CLAUDECODE, ...cleanEnv } = process.env; - const proc = spawn(codexBin, args, { + const spawnEnv: Record = { + HOME: process.env.HOME || '', + PATH: buildSpawnPath(codexBin), + ...(config.agentId ? { AGENT_ID: config.agentId } : {}), + ...buildSessionEnv({ + pcpSessionId: config.pcpSessionId, + runtimeLinkId: config.pcpSessionId ? runtimeLinkId : undefined, + studioId: config.studioId, + accessToken: config.pcpAccessToken, + agentId: config.agentId, + runtime: 'codex', + repoRoot: config.repoRoot, + }), + }; + + const target = resolveSpawnTarget({ + binary: codexBin, + args, cwd: config.workingDirectory, - env: { - ...cleanEnv, - HOME: process.env.HOME, - PATH: buildSpawnPath(codexBin), - ...(config.agentId ? { AGENT_ID: config.agentId } : {}), - ...buildSessionEnv({ - pcpSessionId: config.pcpSessionId, - runtimeLinkId: config.pcpSessionId ? runtimeLinkId : undefined, - studioId: config.studioId, - accessToken: config.pcpAccessToken, - agentId: config.agentId, - runtime: 'codex', - repoRoot: config.repoRoot, - }), - }, + env: spawnEnv, + container: config.container, + }); + + const proc = spawn(target.binary, target.args, { + cwd: target.cwd, + env: config.container ? target.env : { ...cleanEnv, ...spawnEnv }, stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/packages/api/src/services/sessions/gemini-runner.ts b/packages/api/src/services/sessions/gemini-runner.ts index d4c0aa38..c58aeb8c 100644 --- a/packages/api/src/services/sessions/gemini-runner.ts +++ b/packages/api/src/services/sessions/gemini-runner.ts @@ -26,7 +26,7 @@ import type { import { formatInjectedContext } from './context-builder.js'; import { logger } from '../../utils/logger.js'; import { resolveBinaryPath, buildSpawnPath } from './resolve-binary.js'; -import { buildSessionEnv } from '@inklabs/shared'; +import { buildSessionEnv, resolveSpawnTarget } from '@inklabs/shared'; /** Maximum time (ms) to wait for a Gemini CLI subprocess before killing it. * Override with GEMINI_PROCESS_TIMEOUT_MS env var. */ @@ -204,23 +204,32 @@ export class GeminiRunner implements IRunner { return new Promise((resolve, reject) => { // Strip CLAUDECODE to prevent env leaking into subprocess const { CLAUDECODE, ...cleanEnv } = process.env; - const proc = spawn(geminiBin, args, { + const spawnEnv: Record = { + HOME: process.env.HOME || '', + PATH: buildSpawnPath(geminiBin), + ...(config.agentId ? { AGENT_ID: config.agentId } : {}), + ...(extraEnv || {}), + ...buildSessionEnv({ + pcpSessionId: config.pcpSessionId, + studioId: config.studioId, + accessToken: config.pcpAccessToken, + agentId: config.agentId, + runtime: 'gemini', + repoRoot: config.repoRoot, + }), + }; + + const target = resolveSpawnTarget({ + binary: geminiBin, + args, cwd: config.workingDirectory, - env: { - ...cleanEnv, - HOME: process.env.HOME, - PATH: buildSpawnPath(geminiBin), - ...(config.agentId ? { AGENT_ID: config.agentId } : {}), - ...(extraEnv || {}), - ...buildSessionEnv({ - pcpSessionId: config.pcpSessionId, - studioId: config.studioId, - accessToken: config.pcpAccessToken, - agentId: config.agentId, - runtime: 'gemini', - repoRoot: config.repoRoot, - }), - }, + env: spawnEnv, + container: config.container, + }); + + const proc = spawn(target.binary, target.args, { + cwd: target.cwd, + env: config.container ? target.env : { ...cleanEnv, ...spawnEnv }, stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/packages/api/src/services/sessions/types.ts b/packages/api/src/services/sessions/types.ts index 91f5c099..213508c7 100644 --- a/packages/api/src/services/sessions/types.ts +++ b/packages/api/src/services/sessions/types.ts @@ -432,6 +432,11 @@ export interface ClaudeRunnerConfig { allow?: string[]; deny?: string[]; }; + /** Run the backend CLI inside a Docker container instead of on the host */ + container?: { + containerName: string; + dockerBinary?: string; + }; } export interface RunnerResult { diff --git a/packages/shared/src/runner/index.ts b/packages/shared/src/runner/index.ts index c4d816d3..46867934 100644 --- a/packages/shared/src/runner/index.ts +++ b/packages/shared/src/runner/index.ts @@ -1,7 +1,9 @@ export { buildCleanEnv, + resolveSpawnTarget, spawnBackend, LineBuffer, + type ContainerTarget, type SpawnBackendOptions, type SpawnBackendResult, } from './spawn-backend.js'; diff --git a/packages/shared/src/runner/spawn-backend.test.ts b/packages/shared/src/runner/spawn-backend.test.ts index cb5c934c..2ca5503e 100644 --- a/packages/shared/src/runner/spawn-backend.test.ts +++ b/packages/shared/src/runner/spawn-backend.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildCleanEnv, spawnBackend, LineBuffer } from './spawn-backend.js'; +import { buildCleanEnv, spawnBackend, resolveSpawnTarget, LineBuffer } from './spawn-backend.js'; describe('buildCleanEnv', () => { it('strips CLAUDECODE from process.env', () => { @@ -107,6 +107,112 @@ describe('spawnBackend', () => { }); }); +describe('resolveSpawnTarget', () => { + it('passes through binary and args for host execution', () => { + const target = resolveSpawnTarget({ + binary: 'claude', + args: ['--print', '--verbose'], + cwd: '/tmp/studio', + env: { AGENT_ID: 'wren' }, + }); + expect(target.binary).toBe('claude'); + expect(target.args).toEqual(['--print', '--verbose']); + expect(target.cwd).toBe('/tmp/studio'); + expect(target.env.AGENT_ID).toBe('wren'); + }); + + it('wraps binary in docker exec for container execution', () => { + const target = resolveSpawnTarget({ + binary: 'claude', + args: ['--print', '--verbose'], + container: { containerName: 'ink-sandbox-wren-abc123' }, + }); + expect(target.binary).toBe('docker'); + expect(target.args[0]).toBe('exec'); + expect(target.args).toContain('ink-sandbox-wren-abc123'); + expect(target.args).toContain('claude'); + expect(target.args).toContain('--print'); + expect(target.args).toContain('--verbose'); + }); + + it('passes cwd as --workdir to docker exec', () => { + const target = resolveSpawnTarget({ + binary: 'claude', + args: [], + cwd: '/studio', + container: { containerName: 'test-container' }, + }); + const workdirIdx = target.args.indexOf('--workdir'); + expect(workdirIdx).toBeGreaterThan(-1); + expect(target.args[workdirIdx + 1]).toBe('/studio'); + // Host cwd should be undefined (cwd is inside the container) + expect(target.cwd).toBeUndefined(); + }); + + it('passes env vars as -e flags to docker exec', () => { + const target = resolveSpawnTarget({ + binary: 'claude', + args: [], + env: { AGENT_ID: 'wren', INK_SANDBOX: 'docker' }, + container: { containerName: 'test-container' }, + }); + expect(target.args).toContain('-e'); + expect(target.args).toContain('AGENT_ID=wren'); + expect(target.args).toContain('INK_SANDBOX=docker'); + }); + + it('adds -i flag when pipeStdin is true for container', () => { + const target = resolveSpawnTarget({ + binary: 'claude', + args: [], + pipeStdin: true, + container: { containerName: 'test-container' }, + }); + expect(target.args).toContain('-i'); + }); + + it('does not add -i flag when pipeStdin is false for container', () => { + const target = resolveSpawnTarget({ + binary: 'claude', + args: [], + pipeStdin: false, + container: { containerName: 'test-container' }, + }); + const execIdx = target.args.indexOf('exec'); + const containerIdx = target.args.indexOf('test-container'); + // No -i between exec and container name + const sliceBetween = target.args.slice(execIdx + 1, containerIdx); + expect(sliceBetween).not.toContain('-i'); + }); + + it('uses custom docker binary when specified', () => { + const target = resolveSpawnTarget({ + binary: 'claude', + args: [], + container: { containerName: 'test', dockerBinary: 'podman' }, + }); + expect(target.binary).toBe('podman'); + }); + + it('preserves argument order: docker exec [flags] container binary args', () => { + const target = resolveSpawnTarget({ + binary: 'claude', + args: ['--print', '-m', 'sonnet'], + cwd: '/studio', + env: { KEY: 'val' }, + pipeStdin: true, + container: { containerName: 'my-sandbox' }, + }); + // Structure: docker exec -i --workdir /studio -e KEY=val my-sandbox claude --print -m sonnet + const containerIdx = target.args.indexOf('my-sandbox'); + expect(containerIdx).toBeGreaterThan(0); + expect(target.args[containerIdx + 1]).toBe('claude'); + expect(target.args[containerIdx + 2]).toBe('--print'); + expect(target.args[containerIdx + 3]).toBe('-m'); + expect(target.args[containerIdx + 4]).toBe('sonnet'); + }); +}); + describe('LineBuffer', () => { it('splits complete lines', () => { const buf = new LineBuffer(); diff --git a/packages/shared/src/runner/spawn-backend.ts b/packages/shared/src/runner/spawn-backend.ts index dd2d4ee8..4715e99b 100644 --- a/packages/shared/src/runner/spawn-backend.ts +++ b/packages/shared/src/runner/spawn-backend.ts @@ -16,6 +16,13 @@ import { spawn, type ChildProcess } from 'child_process'; // ─── Types ────────────────────────────────────────────────────── +export interface ContainerTarget { + /** Docker container name or ID to exec into */ + containerName: string; + /** Docker binary (default: 'docker') */ + dockerBinary?: string; +} + export interface SpawnBackendOptions { /** Absolute or PATH-relative binary name */ binary: string; @@ -35,6 +42,8 @@ export interface SpawnBackendOptions { onStdout?: (chunk: string) => void; /** Called on each stderr chunk */ onStderr?: (chunk: string) => void; + /** Run the binary inside a Docker container instead of on the host */ + container?: ContainerTarget; } export interface SpawnBackendResult { @@ -67,23 +76,80 @@ export function buildCleanEnv( return { ...cleanEnv, ...extraEnv }; } +/** + * Build the actual binary + args for spawning, handling container routing. + * + * When `container` is set, wraps the command in `docker exec` so the + * binary runs inside the container. Env vars are passed via `-e` flags + * and cwd via `--workdir`. The caller sees the same interface regardless + * of execution target. + */ +export function resolveSpawnTarget(options: SpawnBackendOptions): { + binary: string; + args: string[]; + cwd?: string; + env: Record; +} { + if (!options.container) { + return { + binary: options.binary, + args: options.args, + cwd: options.cwd, + env: buildCleanEnv(options.env), + }; + } + + const docker = options.container.dockerBinary || 'docker'; + const execArgs = ['exec']; + + if (options.pipeStdin) { + execArgs.push('-i'); + } + + if (options.cwd) { + execArgs.push('--workdir', options.cwd); + } + + if (options.env) { + for (const [key, value] of Object.entries(options.env)) { + execArgs.push('-e', `${key}=${value}`); + } + } + + execArgs.push(options.container.containerName, options.binary, ...options.args); + + return { + binary: docker, + args: execArgs, + // cwd is inside the container (passed via --workdir), not on the host + cwd: undefined, + // Host env is clean but doesn't need the extra vars (they're inside the container) + env: buildCleanEnv(), + }; +} + /** * Spawn a backend process with timeout management, output accumulation, * and CLAUDECODE env stripping. * * This is the canonical spawn function for all backend process invocations. * Both API server runners and CLI backend-runner should use this. + * + * When `options.container` is set, the binary runs inside the specified + * Docker container via `docker exec`. The interface is identical — callers + * don't need to know whether they're targeting host or container. */ export function spawnBackend(options: SpawnBackendOptions): { child: ChildProcess; result: Promise; } { const started = Date.now(); + const target = resolveSpawnTarget(options); - const child = spawn(options.binary, options.args, { + const child = spawn(target.binary, target.args, { stdio: [options.pipeStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'], - cwd: options.cwd, - env: buildCleanEnv(options.env), + cwd: target.cwd, + env: target.env, }); let stdout = ''; From 1a4d2a19b9be55ccf57301136ebfff3d58e1fb69 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Thu, 7 May 2026 18:37:11 -0700 Subject: [PATCH 17/21] =?UTF-8?q?feat:=20wire=20sandbox=20container=20thro?= =?UTF-8?q?ugh=20trigger=20=E2=86=92=20session=20=E2=86=92=20runner=20pipe?= =?UTF-8?q?line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strategy passes sandboxContainerName through trigger metadata when sandbox spins up successfully. Gateway default handler forwards it to SessionRequest metadata. Session service reads it and sets config.container on the runner. End-to-end flow: startStrategy → maybeSpinUpSandbox → triggerOwnerAgent (metadata.sandboxContainerName) → gateway → session service → runner config → resolveSpawnTarget → docker exec claude Co-Authored-By: Wren --- packages/api/src/server.ts | 4 ++++ .../api/src/services/sessions/session-service.ts | 4 ++++ packages/api/src/services/strategy.service.ts | 14 ++++++++++++-- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index d0b221e9..748038b5 100644 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -899,6 +899,10 @@ When you complete a task_request, mark it as completed using update_inbox_messag payload.metadata && typeof payload.metadata.groupId === 'string' ? payload.metadata.groupId : undefined, + // Forward sandbox container name so the session service routes CLI execution into it + ...(payload.metadata?.sandboxContainerName + ? { sandboxContainerName: payload.metadata.sandboxContainerName } + : {}), }, }; diff --git a/packages/api/src/services/sessions/session-service.ts b/packages/api/src/services/sessions/session-service.ts index 2a601702..348911c2 100644 --- a/packages/api/src/services/sessions/session-service.ts +++ b/packages/api/src/services/sessions/session-service.ts @@ -471,6 +471,10 @@ export class SessionService implements ISessionService { ...(permissionOverlay ? { permissionOverlay } : {}), // Propagate repo root so spawned backend's context token carries it repoRoot: resolvedWorkingDirectory.replace(/--[^/]+$/, ''), + // Route CLI execution into sandbox container when triggered by a sandboxed strategy + ...(metadata?.sandboxContainerName + ? { container: { containerName: metadata.sandboxContainerName as string } } + : {}), }; // 5. Run with selected backend diff --git a/packages/api/src/services/strategy.service.ts b/packages/api/src/services/strategy.service.ts index bac27ec3..58692a93 100644 --- a/packages/api/src/services/strategy.service.ts +++ b/packages/api/src/services/strategy.service.ts @@ -265,7 +265,15 @@ export class StrategyService { // Trigger the owner agent in the assigned studio. The trigger spawns // (or resumes) a session in the target studio so work actually begins. - const triggered = await this.triggerOwnerAgent(updated, nextTask, 'strategy_kickoff'); + // Pass the sandbox container name so the triggered session routes + // CLI execution into the container. + const sandboxContainer = sandboxResult?.success ? sandboxResult.containerName : undefined; + const triggered = await this.triggerOwnerAgent( + updated, + nextTask, + 'strategy_kickoff', + sandboxContainer + ); // Log strategy start await this.logStrategyEvent( @@ -816,7 +824,8 @@ export class StrategyService { private async triggerOwnerAgent( group: TaskGroup, task: ProjectTask, - reason: 'strategy_kickoff' | 'watchdog' | 'manual_resume' + reason: 'strategy_kickoff' | 'watchdog' | 'manual_resume', + sandboxContainerName?: string ): Promise { if (!group.owner_agent_id) { logger.warn( @@ -862,6 +871,7 @@ export class StrategyService { groupId: group.id, taskId: task.id, strategy: group.strategy, + ...(sandboxContainerName ? { sandboxContainerName } : {}), }, }, this.dataComposer From cd6dc630c7cf18c21ea3ba22b0bb1cd0f083468a Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Thu, 7 May 2026 18:59:13 -0700 Subject: [PATCH 18/21] fix: close sandbox routing gaps from PR review (by Wren) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add sandbox/sandboxPolicy/sandboxBackendAuth to startStrategySchema so the MCP tool can actually enable sandbox mode - resolveSpawnTarget: use path.basename for container binary (container has CLIs on PATH, not at host absolute paths) and map host cwd to container workDir (default /studio) - Watchdog re-triggers now spin up/reuse the sandbox container and apply the fail-closed policy before calling triggerOwnerAgent - Guard direct-api backend against container config — throws if a sandboxed strategy routes to a runner that can't containerize Co-Authored-By: Wren --- .../api/src/mcp/tools/strategy-handlers.ts | 14 ++++++++ .../src/services/sessions/session-service.ts | 10 ++++++ packages/api/src/services/strategy.service.ts | 30 +++++++++++++++- .../shared/src/runner/spawn-backend.test.ts | 34 +++++++++++++++++++ packages/shared/src/runner/spawn-backend.ts | 10 ++++-- 5 files changed, 95 insertions(+), 3 deletions(-) diff --git a/packages/api/src/mcp/tools/strategy-handlers.ts b/packages/api/src/mcp/tools/strategy-handlers.ts index 214a4fa5..07101bc5 100644 --- a/packages/api/src/mcp/tools/strategy-handlers.ts +++ b/packages/api/src/mcp/tools/strategy-handlers.ts @@ -103,6 +103,17 @@ export const startStrategySchema = z.object({ .describe( 'Supervisor agent identity ID (UUID). Gets check-in notifications and a final audit on completion.' ), + sandbox: z.boolean().optional().describe('Run the strategy in a sandboxed Docker container'), + sandboxPolicy: z + .enum(['required', 'preferred']) + .optional() + .describe( + "Sandbox failure policy: 'required' aborts if sandbox can't start (default), 'preferred' falls back to host" + ), + sandboxBackendAuth: z + .array(z.enum(['claude', 'codex', 'gemini'])) + .optional() + .describe("Backend auth dirs to mount in the sandbox (default: ['claude'])"), }); export async function handleStartStrategy( @@ -134,6 +145,9 @@ export async function handleStartStrategy( contextSummaryInterval: args.contextSummaryInterval, verificationGates: args.verificationGates, supervisorId: args.supervisorId, + sandbox: args.sandbox, + sandboxPolicy: args.sandboxPolicy, + sandboxBackendAuth: args.sandboxBackendAuth, }, }); diff --git a/packages/api/src/services/sessions/session-service.ts b/packages/api/src/services/sessions/session-service.ts index 348911c2..8d1e4c6e 100644 --- a/packages/api/src/services/sessions/session-service.ts +++ b/packages/api/src/services/sessions/session-service.ts @@ -478,6 +478,16 @@ export class SessionService implements ISessionService { }; // 5. Run with selected backend + // Direct-api runners execute tools in-process against the host filesystem — + // they cannot route to a Docker container. Reject the combination so a + // sandboxed strategy doesn't silently bypass containment. + if (resolvedBackend === 'direct-api' && runnerConfig.container) { + throw new Error( + 'direct-api backend cannot run inside a sandbox container. ' + + 'Use a CLI backend (claude-code, codex-cli, gemini) for sandboxed strategies.' + ); + } + const runner = resolvedBackend === 'codex-cli' ? this.codexRunner diff --git a/packages/api/src/services/strategy.service.ts b/packages/api/src/services/strategy.service.ts index 58692a93..2b560412 100644 --- a/packages/api/src/services/strategy.service.ts +++ b/packages/api/src/services/strategy.service.ts @@ -1053,7 +1053,35 @@ export class StrategyService { return false; } - return this.triggerOwnerAgent(group, currentTask, 'watchdog'); + // If the strategy uses a sandbox, spin up (or reuse) the container before + // triggering. The orchestrator short-circuits if the container is already + // running, so this is safe to call on every watchdog tick. + const config = group.strategy_config as StrategyConfig; + let sandboxContainerName: string | undefined; + if (config.sandbox) { + const sandboxResult = await this.maybeSpinUpSandbox(group); + const sandboxPolicy = config.sandboxPolicy || 'required'; + + if (sandboxResult && !sandboxResult.success && sandboxPolicy === 'required') { + await this.logStrategyEvent( + group, + 'sandbox_failed', + `Watchdog aborted: sandbox required but spin-up failed — ${sandboxResult.error}`, + { error: sandboxResult.error, policy: 'required', trigger: 'watchdog' } + ); + await this.dataComposer.repositories.taskGroups.update(groupId, { + status: 'paused', + strategy_paused_at: new Date().toISOString(), + }); + return false; + } + + if (sandboxResult?.success) { + sandboxContainerName = sandboxResult.containerName; + } + } + + return this.triggerOwnerAgent(group, currentTask, 'watchdog', sandboxContainerName); } /** diff --git a/packages/shared/src/runner/spawn-backend.test.ts b/packages/shared/src/runner/spawn-backend.test.ts index 2ca5503e..1392e9ec 100644 --- a/packages/shared/src/runner/spawn-backend.test.ts +++ b/packages/shared/src/runner/spawn-backend.test.ts @@ -194,6 +194,40 @@ describe('resolveSpawnTarget', () => { expect(target.binary).toBe('podman'); }); + it('converts host binary path to basename for container execution', () => { + const target = resolveSpawnTarget({ + binary: '/Users/conor/.local/bin/claude', + args: ['--print'], + container: { containerName: 'test-container' }, + }); + expect(target.binary).toBe('docker'); + const containerIdx = target.args.indexOf('test-container'); + expect(target.args[containerIdx + 1]).toBe('claude'); + }); + + it('maps host cwd to container workDir (default /studio)', () => { + const target = resolveSpawnTarget({ + binary: 'claude', + args: [], + cwd: '/Users/conor/ws/pcp/personal-context-protocol--wren', + container: { containerName: 'test-container' }, + }); + const workdirIdx = target.args.indexOf('--workdir'); + expect(target.args[workdirIdx + 1]).toBe('/studio'); + expect(target.cwd).toBeUndefined(); + }); + + it('respects custom container workDir', () => { + const target = resolveSpawnTarget({ + binary: 'claude', + args: [], + cwd: '/Users/conor/ws/project', + container: { containerName: 'test-container', workDir: '/workspace' }, + }); + const workdirIdx = target.args.indexOf('--workdir'); + expect(target.args[workdirIdx + 1]).toBe('/workspace'); + }); + it('preserves argument order: docker exec [flags] container binary args', () => { const target = resolveSpawnTarget({ binary: 'claude', diff --git a/packages/shared/src/runner/spawn-backend.ts b/packages/shared/src/runner/spawn-backend.ts index 4715e99b..ab5966ae 100644 --- a/packages/shared/src/runner/spawn-backend.ts +++ b/packages/shared/src/runner/spawn-backend.ts @@ -13,6 +13,7 @@ */ import { spawn, type ChildProcess } from 'child_process'; +import path from 'path'; // ─── Types ────────────────────────────────────────────────────── @@ -21,6 +22,8 @@ export interface ContainerTarget { containerName: string; /** Docker binary (default: 'docker') */ dockerBinary?: string; + /** Working directory inside the container (default: '/studio') */ + workDir?: string; } export interface SpawnBackendOptions { @@ -106,8 +109,9 @@ export function resolveSpawnTarget(options: SpawnBackendOptions): { execArgs.push('-i'); } + const containerWorkDir = options.container.workDir || '/studio'; if (options.cwd) { - execArgs.push('--workdir', options.cwd); + execArgs.push('--workdir', containerWorkDir); } if (options.env) { @@ -116,7 +120,9 @@ export function resolveSpawnTarget(options: SpawnBackendOptions): { } } - execArgs.push(options.container.containerName, options.binary, ...options.args); + // Use basename — the container has CLI tools on its PATH, not at host-resolved absolute paths + const containerBinary = path.basename(options.binary); + execArgs.push(options.container.containerName, containerBinary, ...options.args); return { binary: docker, From 2f7abaac0fff7c4df435b467f88173351676b7b6 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Fri, 8 May 2026 01:06:35 -0700 Subject: [PATCH 19/21] =?UTF-8?q?refactor:=20rename=20DirectApiRunner=20?= =?UTF-8?q?=E2=86=92=20InkRunner,=20DirectApiBackend=20=E2=86=92=20InkBack?= =?UTF-8?q?end=20(by=20Wren)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the in-process API runner and backend from 'direct-api' to 'ink' to align with the project's naming conventions. The normalizeBackend() method still accepts 'direct-api' as input for backward compatibility with existing database rows. Co-Authored-By: Wren --- packages/api/src/agent/adapters/index.ts | 2 +- packages/api/src/agent/adapters/types.ts | 2 +- packages/api/src/agent/backend-manager.ts | 21 +++++---- .../{direct-api.backend.ts => ink.backend.ts} | 44 +++++++++---------- packages/api/src/agent/index.ts | 4 +- .../api/src/agent/tools/pi-coding-tools.ts | 4 +- packages/api/src/agent/types.ts | 2 +- packages/api/src/services/sessions/index.ts | 2 +- ...-api-runner.test.ts => ink-runner.test.ts} | 10 ++--- .../{direct-api-runner.ts => ink-runner.ts} | 22 +++++----- .../src/services/sessions/session-service.ts | 35 ++++++++------- 11 files changed, 74 insertions(+), 74 deletions(-) rename packages/api/src/agent/backends/{direct-api.backend.ts => ink.backend.ts} (84%) rename packages/api/src/services/sessions/{direct-api-runner.test.ts => ink-runner.test.ts} (85%) rename packages/api/src/services/sessions/{direct-api-runner.ts => ink-runner.ts} (91%) diff --git a/packages/api/src/agent/adapters/index.ts b/packages/api/src/agent/adapters/index.ts index d9dc09a7..fd65d413 100644 --- a/packages/api/src/agent/adapters/index.ts +++ b/packages/api/src/agent/adapters/index.ts @@ -45,7 +45,7 @@ export function getAdapterRegistry(): AdapterRegistry { registryInstance.register(getClaudeCodeAdapter()); // Future adapters would be registered here: - // registryInstance.register(getDirectApiAdapter()); + // registryInstance.register(getInkAdapter()); // registryInstance.register(getOpenAiAdapter()); } return registryInstance; diff --git a/packages/api/src/agent/adapters/types.ts b/packages/api/src/agent/adapters/types.ts index 9c7ce284..6ed219f2 100644 --- a/packages/api/src/agent/adapters/types.ts +++ b/packages/api/src/agent/adapters/types.ts @@ -30,7 +30,7 @@ export interface BackendPermissionConfig { * Implement this for each backend to translate PCP permissions */ export interface PermissionAdapter { - /** Backend identifier (e.g., 'claude-code', 'direct-api') */ + /** Backend identifier (e.g., 'claude-code', 'ink') */ readonly backendId: string; /** diff --git a/packages/api/src/agent/backend-manager.ts b/packages/api/src/agent/backend-manager.ts index 0abab7a4..3c77cedc 100644 --- a/packages/api/src/agent/backend-manager.ts +++ b/packages/api/src/agent/backend-manager.ts @@ -16,9 +16,9 @@ import type { ResponseHandler, } from './types'; import type { ClaudeCodeConfig } from './backends/claude-code.backend'; -import type { DirectApiConfig } from './backends/direct-api.backend'; +import type { InkConfig } from './backends/ink.backend'; import { ClaudeCodeBackend, createClaudeCodeBackend } from './backends/claude-code.backend'; -import { DirectApiBackend, createDirectApiBackend } from './backends/direct-api.backend'; +import { InkBackend, createInkBackend } from './backends/ink.backend'; export interface BackendManagerConfig { /** Primary backend to use */ @@ -28,7 +28,7 @@ export interface BackendManagerConfig { /** Backend-specific configurations */ backends: { 'claude-code'?: Partial; - 'direct-api'?: Partial; + ink?: Partial; }; /** Enable automatic failover */ enableFailover?: boolean; @@ -138,10 +138,9 @@ export class BackendManager extends EventEmitter { setResponseHandler(handler: ResponseHandler): void { this.responseHandler = handler; - // Pass to direct API backend if it exists - const directApi = this.backends.get('direct-api') as DirectApiBackend | undefined; - if (directApi) { - directApi.setResponseHandler(handler); + const inkBackend = this.backends.get('ink') as InkBackend | undefined; + if (inkBackend) { + inkBackend.setResponseHandler(handler); } } @@ -228,14 +227,14 @@ export class BackendManager extends EventEmitter { this.backends.set('claude-code', backend); } - // Create Direct API backend - if (this.config.backends['direct-api'] || this.config.primaryBackend === 'direct-api') { - const backend = createDirectApiBackend(this.config.backends['direct-api']); + // Create Ink backend + if (this.config.backends.ink || this.config.primaryBackend === 'ink') { + const backend = createInkBackend(this.config.backends.ink); this.setupBackendEvents(backend); if (this.responseHandler) { backend.setResponseHandler(this.responseHandler); } - this.backends.set('direct-api', backend); + this.backends.set('ink', backend); } } diff --git a/packages/api/src/agent/backends/direct-api.backend.ts b/packages/api/src/agent/backends/ink.backend.ts similarity index 84% rename from packages/api/src/agent/backends/direct-api.backend.ts rename to packages/api/src/agent/backends/ink.backend.ts index 72abe0f9..3938af74 100644 --- a/packages/api/src/agent/backends/direct-api.backend.ts +++ b/packages/api/src/agent/backends/ink.backend.ts @@ -1,5 +1,5 @@ /** - * Direct API Backend + * Ink Backend * * Uses the Anthropic API directly instead of Claude Code CLI. * Useful for cloud deployments where CLI isn't available. @@ -17,8 +17,8 @@ import type { ResponseHandler, } from '../types'; -export interface DirectApiConfig extends BackendConfig { - type: 'direct-api'; +export interface InkConfig extends BackendConfig { + type: 'ink'; apiKey?: string; model?: string; maxTokens?: number; @@ -30,15 +30,15 @@ interface ConversationMessage { content: string; } -const DEFAULT_CONFIG: Partial = { +const DEFAULT_CONFIG: Partial = { model: 'claude-sonnet-4-20250514', maxTokens: 4096, }; -export class DirectApiBackend extends EventEmitter implements AgentBackend { - readonly type: BackendType = 'direct-api'; +export class InkBackend extends EventEmitter implements AgentBackend { + readonly type: BackendType = 'ink'; - private config: DirectApiConfig; + private config: InkConfig; private client: Anthropic | null = null; private ready = false; private messageCount = 0; @@ -55,9 +55,9 @@ export class DirectApiBackend extends EventEmitter implements AgentBackend { // MCP tools definition (simplified for API calls) private tools: Anthropic.Tool[] = []; - constructor(config: Partial = {}) { + constructor(config: Partial = {}) { super(); - this.config = { ...DEFAULT_CONFIG, ...config, type: 'direct-api' } as DirectApiConfig; + this.config = { ...DEFAULT_CONFIG, ...config, type: 'ink' } as InkConfig; } /** @@ -76,28 +76,28 @@ export class DirectApiBackend extends EventEmitter implements AgentBackend { async initialize(): Promise { if (this.client) { - logger.warn('Direct API backend already initialized'); + logger.warn('Ink backend already initialized'); return; } - logger.info('Initializing Direct API backend...'); + logger.info('Initializing Ink backend...'); const apiKey = this.config.apiKey || process.env.ANTHROPIC_API_KEY; if (!apiKey) { - throw new Error('ANTHROPIC_API_KEY is required for Direct API backend'); + throw new Error('ANTHROPIC_API_KEY is required for Ink backend'); } this.client = new Anthropic({ apiKey }); - this.sessionId = `direct-api-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + this.sessionId = `ink-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; this.startTime = new Date(); this.ready = true; this.emit('ready'); - logger.info('Direct API backend ready', { sessionId: this.sessionId }); + logger.info('Ink backend ready', { sessionId: this.sessionId }); } async shutdown(): Promise { - logger.info('Shutting down Direct API backend...'); + logger.info('Shutting down Ink backend...'); this.client = null; this.ready = false; this.conversationHistory.clear(); @@ -106,7 +106,7 @@ export class DirectApiBackend extends EventEmitter implements AgentBackend { async sendMessage(message: AgentMessage): Promise { if (!this.client || !this.ready) { - throw new Error('Direct API backend not ready'); + throw new Error('Ink backend not ready'); } this.messageCount++; @@ -121,7 +121,7 @@ export class DirectApiBackend extends EventEmitter implements AgentBackend { }); logger.info( - `Sending message via Direct API [${message.channel}]: ${message.content.substring(0, 100)}...` + `Sending message via Ink [${message.channel}]: ${message.content.substring(0, 100)}...` ); try { @@ -138,7 +138,7 @@ export class DirectApiBackend extends EventEmitter implements AgentBackend { await this.processResponse(response, message); } catch (error) { this.lastError = error instanceof Error ? error.message : 'Unknown error'; - logger.error('Direct API error:', error); + logger.error('Ink error:', error); this.emit('error', error); throw error; } @@ -164,7 +164,7 @@ export class DirectApiBackend extends EventEmitter implements AgentBackend { } async resumeSession(sessionId: string): Promise { - // Direct API doesn't have true session resumption + // Ink doesn't have true session resumption // but we can set the session ID for tracking this.sessionId = sessionId; return true; @@ -293,8 +293,8 @@ export class DirectApiBackend extends EventEmitter implements AgentBackend { } /** - * Create a Direct API backend instance + * Create a Ink backend instance */ -export function createDirectApiBackend(config?: Partial): DirectApiBackend { - return new DirectApiBackend(config); +export function createInkBackend(config?: Partial): InkBackend { + return new InkBackend(config); } diff --git a/packages/api/src/agent/index.ts b/packages/api/src/agent/index.ts index ce1d7911..e08fc47d 100644 --- a/packages/api/src/agent/index.ts +++ b/packages/api/src/agent/index.ts @@ -11,8 +11,8 @@ export * from './types'; export { ClaudeCodeBackend, createClaudeCodeBackend } from './backends/claude-code.backend'; export type { ClaudeCodeConfig } from './backends/claude-code.backend'; -export { DirectApiBackend, createDirectApiBackend } from './backends/direct-api.backend'; -export type { DirectApiConfig } from './backends/direct-api.backend'; +export { InkBackend, createInkBackend } from './backends/ink.backend'; +export type { InkConfig } from './backends/ink.backend'; // Tools export { diff --git a/packages/api/src/agent/tools/pi-coding-tools.ts b/packages/api/src/agent/tools/pi-coding-tools.ts index b3aee19d..d0870652 100644 --- a/packages/api/src/agent/tools/pi-coding-tools.ts +++ b/packages/api/src/agent/tools/pi-coding-tools.ts @@ -2,7 +2,7 @@ * Pi Coding Tools Adapter * * Bridges @mariozechner/pi-coding-agent's tool factories into Ink's - * direct-api backend tool format (Anthropic.Tool + execution). + * Ink backend tool format (Anthropic.Tool + execution). * * Pi packages are ESM-only, so we use dynamic import(). */ @@ -126,7 +126,7 @@ function formatToolResult(result: unknown): string { const DEFAULT_BASH_TIMEOUT_SECONDS = 120; /** - * Create Pi coding tools adapted for Ink's direct-api backend. + * Create Pi coding tools adapted for the Ink backend. * * Returns both the Anthropic.Tool schemas (for the API call) and * execute functions (for handling tool_use responses). diff --git a/packages/api/src/agent/types.ts b/packages/api/src/agent/types.ts index fe228a9f..774cdb34 100644 --- a/packages/api/src/agent/types.ts +++ b/packages/api/src/agent/types.ts @@ -18,7 +18,7 @@ export type ChannelType = | 'agent' | 'heartbeat' | 'web'; -export type BackendType = 'claude-code' | 'direct-api'; +export type BackendType = 'claude-code' | 'ink'; export type ResponseFormat = 'text' | 'markdown' | 'code' | 'json'; /** diff --git a/packages/api/src/services/sessions/index.ts b/packages/api/src/services/sessions/index.ts index 00916d45..71dfcef1 100644 --- a/packages/api/src/services/sessions/index.ts +++ b/packages/api/src/services/sessions/index.ts @@ -25,7 +25,7 @@ export { ContextBuilder, formatInjectedContext } from './context-builder.js'; export { ClaudeRunner, buildIdentityPrompt } from './claude-runner.js'; export { CodexRunner } from './codex-runner.js'; export { GeminiRunner } from './gemini-runner.js'; -export { DirectApiRunner } from './direct-api-runner.js'; +export { InkRunner } from './ink-runner.js'; // Types export type { diff --git a/packages/api/src/services/sessions/direct-api-runner.test.ts b/packages/api/src/services/sessions/ink-runner.test.ts similarity index 85% rename from packages/api/src/services/sessions/direct-api-runner.test.ts rename to packages/api/src/services/sessions/ink-runner.test.ts index a7571cb6..273fa26e 100644 --- a/packages/api/src/services/sessions/direct-api-runner.test.ts +++ b/packages/api/src/services/sessions/ink-runner.test.ts @@ -1,15 +1,15 @@ import { describe, it, expect, vi } from 'vitest'; -import { DirectApiRunner } from './direct-api-runner'; +import { InkRunner } from './ink-runner'; import type { InkToolDefinition } from '../../agent/tools/pi-coding-tools'; vi.mock('../../utils/logger', () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); -describe('DirectApiRunner', () => { +describe('InkRunner', () => { describe('agentId propagation', () => { it('creates guarded bash tools when agentId is provided', async () => { - const runner = new DirectApiRunner({ apiKey: 'test-key' }); + const runner = new InkRunner({ apiKey: 'test-key' }); // Access private getTools to verify agentId propagation const tools: InkToolDefinition[] = await (runner as any).getTools('/tmp', 'wren'); @@ -21,7 +21,7 @@ describe('DirectApiRunner', () => { }); it('creates unguarded bash tools when agentId is absent', async () => { - const runner = new DirectApiRunner({ apiKey: 'test-key' }); + const runner = new InkRunner({ apiKey: 'test-key' }); const tools: InkToolDefinition[] = await (runner as any).getTools('/tmp'); const bash = tools.find((t) => t.schema.name === 'bash')!; @@ -33,7 +33,7 @@ describe('DirectApiRunner', () => { }); it('caches tools by cwd+agentId combination', async () => { - const runner = new DirectApiRunner({ apiKey: 'test-key' }); + const runner = new InkRunner({ apiKey: 'test-key' }); const tools1 = await (runner as any).getTools('/tmp', 'wren'); const tools2 = await (runner as any).getTools('/tmp', 'wren'); diff --git a/packages/api/src/services/sessions/direct-api-runner.ts b/packages/api/src/services/sessions/ink-runner.ts similarity index 91% rename from packages/api/src/services/sessions/direct-api-runner.ts rename to packages/api/src/services/sessions/ink-runner.ts index 03ca2251..68eb0f1b 100644 --- a/packages/api/src/services/sessions/direct-api-runner.ts +++ b/packages/api/src/services/sessions/ink-runner.ts @@ -1,7 +1,7 @@ /** - * Direct API Runner + * Ink Runner * - * Implements IRunner using the Anthropic API directly with Pi coding tools. + * Implements IRunner using the Anthropic API directly with Ink coding tools. * Unlike CLI runners (Claude/Codex/Gemini), this calls the API in-process * with a proper tool execution loop — tool results are fed back to continue * the conversation until the model emits end_turn. @@ -30,7 +30,7 @@ const MAX_TOOL_ITERATIONS = 50; const DEFAULT_MODEL = 'claude-sonnet-4-20250514'; const DEFAULT_MAX_TOKENS = 16384; -export interface DirectApiRunnerConfig { +export interface InkRunnerConfig { apiKey?: string; model?: string; maxTokens?: number; @@ -40,12 +40,12 @@ export interface DirectApiRunnerConfig { extraTools?: Anthropic.Tool[]; } -export class DirectApiRunner implements IRunner { +export class InkRunner implements IRunner { private client: Anthropic | null = null; - private runnerConfig: DirectApiRunnerConfig; + private runnerConfig: InkRunnerConfig; private toolsCache = new Map(); - constructor(config: DirectApiRunnerConfig = {}) { + constructor(config: InkRunnerConfig = {}) { this.runnerConfig = config; } @@ -91,7 +91,7 @@ export class DirectApiRunner implements IRunner { let totalInputTokens = 0; let totalOutputTokens = 0; let finalTextResponse = ''; - let backendSessionId = options.backendSessionId || `direct-api-${Date.now()}`; + let backendSessionId = options.backendSessionId || `ink-${Date.now()}`; for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) { const response = await this.client!.messages.create({ @@ -164,12 +164,12 @@ export class DirectApiRunner implements IRunner { resultText = await executor(toolUse.input as Record); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); - logger.error(`Direct API runner: tool ${toolUse.name} threw`, { error: errMsg }); + logger.error(`Ink runner: tool ${toolUse.name} threw`, { error: errMsg }); resultText = `Error: ${errMsg}`; } } else { resultText = `Error: Tool "${toolUse.name}" not available in this runtime. Available tools: ${Array.from(executorMap.keys()).join(', ')}`; - logger.warn(`Direct API runner: unknown tool "${toolUse.name}" requested`); + logger.warn(`Ink runner: unknown tool "${toolUse.name}" requested`); } toolResults.push({ @@ -182,7 +182,7 @@ export class DirectApiRunner implements IRunner { // Add tool results as user turn messages.push({ role: 'user', content: toolResults }); - logger.debug('Direct API runner: tool iteration complete', { + logger.debug('Ink runner: tool iteration complete', { iteration, toolsExecuted: toolUseBlocks.map((t) => t.name), }); @@ -206,7 +206,7 @@ export class DirectApiRunner implements IRunner { if (this.client) return; const apiKey = this.runnerConfig.apiKey || process.env.ANTHROPIC_API_KEY; if (!apiKey) { - throw new Error('ANTHROPIC_API_KEY is required for Direct API runner'); + throw new Error('ANTHROPIC_API_KEY is required for Ink runner'); } this.client = new Anthropic({ apiKey }); } diff --git a/packages/api/src/services/sessions/session-service.ts b/packages/api/src/services/sessions/session-service.ts index 8d1e4c6e..507af36a 100644 --- a/packages/api/src/services/sessions/session-service.ts +++ b/packages/api/src/services/sessions/session-service.ts @@ -33,7 +33,7 @@ import { ContextBuilder } from './context-builder.js'; import { ClaudeRunner, buildIdentityPrompt } from './claude-runner.js'; import { CodexRunner } from './codex-runner.js'; import { GeminiRunner } from './gemini-runner.js'; -import { DirectApiRunner } from './direct-api-runner.js'; +import { InkRunner } from './ink-runner.js'; import { ActivityStreamRepository } from '../../data/repositories/activity-stream.repository.js'; import { resolveIdentityId } from '../../auth/resolve-identity.js'; import { classifyError } from '@inklabs/shared'; @@ -135,7 +135,7 @@ export class SessionService implements ISessionService { private claudeRunner: IRunner; private codexRunner: IRunner; private geminiRunner: IRunner; - private directApiRunner: IRunner; + private inkRunner: IRunner; private activityStream: IActivityStream; private config: SessionServiceConfig; private supabase: SupabaseClient | null; @@ -169,14 +169,14 @@ export class SessionService implements ISessionService { codexRunner?: IRunner, supabase?: SupabaseClient, geminiRunner?: IRunner, - directApiRunner?: IRunner + inkRunner?: IRunner ) { this.repository = repository; this.contextBuilder = contextBuilder; this.claudeRunner = claudeRunner; this.codexRunner = codexRunner || claudeRunner; this.geminiRunner = geminiRunner || claudeRunner; - this.directApiRunner = directApiRunner || new DirectApiRunner(); + this.inkRunner = inkRunner || new InkRunner(); this.activityStream = activityStream; this.config = { ...DEFAULT_CONFIG, ...config }; this.supabase = supabase || null; @@ -478,12 +478,12 @@ export class SessionService implements ISessionService { }; // 5. Run with selected backend - // Direct-api runners execute tools in-process against the host filesystem — - // they cannot route to a Docker container. Reject the combination so a + // Ink runner executes tools in-process against the host filesystem — + // it cannot route to a Docker container. Reject the combination so a // sandboxed strategy doesn't silently bypass containment. - if (resolvedBackend === 'direct-api' && runnerConfig.container) { + if (resolvedBackend === 'ink' && runnerConfig.container) { throw new Error( - 'direct-api backend cannot run inside a sandbox container. ' + + 'ink backend cannot run inside a sandbox container. ' + 'Use a CLI backend (claude-code, codex-cli, gemini) for sandboxed strategies.' ); } @@ -493,8 +493,8 @@ export class SessionService implements ISessionService { ? this.codexRunner : resolvedBackend === 'gemini' ? this.geminiRunner - : resolvedBackend === 'direct-api' - ? this.directApiRunner + : resolvedBackend === 'ink' + ? this.inkRunner : this.claudeRunner; // 5a. Log backend spawn to activity stream (fire-and-forget) @@ -1271,8 +1271,8 @@ This session will continue with a fresh context after compaction. Your identity, ? this.codexRunner : runtimeBackend === 'gemini' ? this.geminiRunner - : runtimeBackend === 'direct-api' - ? this.directApiRunner + : runtimeBackend === 'ink' + ? this.inkRunner : this.claudeRunner; // Phase 1: Send compaction prompt — agent saves context, notifies users, ends session @@ -1314,11 +1314,12 @@ This session will continue with a fresh context after compaction. Your identity, */ private normalizeBackend( raw: string | null | undefined - ): 'claude-code' | 'codex-cli' | 'gemini' | 'direct-api' { + ): 'claude-code' | 'codex-cli' | 'gemini' | 'ink' { const value = (raw || '').toLowerCase().trim(); if (value === 'codex' || value === 'codex-cli') return 'codex-cli'; if (value === 'gemini' || value === 'gemini-cli') return 'gemini'; - if (value === 'direct-api' || value === 'direct' || value === 'api') return 'direct-api'; + if (value === 'ink' || value === 'direct-api' || value === 'direct' || value === 'api') + return 'ink'; if (value === 'claude' || value === 'claude-code' || value === '') return 'claude-code'; logger.warn('Unknown backend configured, falling back to claude-code', { raw }); return 'claude-code'; @@ -1330,7 +1331,7 @@ This session will continue with a fresh context after compaction. Your identity, private async resolveAgentBackend( userId: string, agentId: string - ): Promise<'claude-code' | 'codex-cli' | 'gemini' | 'direct-api'> { + ): Promise<'claude-code' | 'codex-cli' | 'gemini' | 'ink'> { try { const identityBackend = await this.contextBuilder.getAgentBackend(userId, agentId); return this.normalizeBackend(identityBackend); @@ -1350,7 +1351,7 @@ This session will continue with a fresh context after compaction. Your identity, private resolveRuntimeBackend( sessionBackend: string | null | undefined, identityBackend: string | null | undefined - ): 'claude-code' | 'codex-cli' | 'gemini' | 'direct-api' { + ): 'claude-code' | 'codex-cli' | 'gemini' | 'ink' { if (sessionBackend) return this.normalizeBackend(sessionBackend); return this.normalizeBackend(identityBackend); } @@ -1622,6 +1623,6 @@ export function createSessionService( new CodexRunner(), supabase, new GeminiRunner(), - new DirectApiRunner() + new InkRunner() ); } From edebc27e65ddb841b12c8783359419b9bcf8ab9b Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Fri, 8 May 2026 12:01:28 -0700 Subject: [PATCH 20/21] =?UTF-8?q?fix:=20container=20path=20translation=20?= =?UTF-8?q?=E2=80=94=20write=20runner=20temp=20files=20to=20bind-mounted?= =?UTF-8?q?=20dir=20(by=20Wren)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When CLI runners (Claude/Codex/Gemini) execute inside a Docker container, temp files (MCP config, identity prompts, policy files) must be written to a bind-mounted directory so they're visible at container-side paths. - Add CONTAINER_RUNNER_FILES constant (/run/ink) and getRunnerFilesDir() - Add runner-files bind mount to sandbox orchestrator - Add outputDir option to injectSessionHeaders for container-aware writes - Claude runner: MCP config written to runtimeDir, arg uses /run/ink path - Codex runner: identity prompt written to runtimeDir with container path - Gemini runner: policy + settings files written to runtimeDir - Fix sandboxContainerName type in SessionRequest metadata - Fix server.ts type narrowing for sandboxContainerName from payload Co-Authored-By: Wren --- packages/api/src/server.ts | 3 +- .../api/src/services/sandbox/orchestrator.ts | 14 +++++ .../src/services/sessions/claude-runner.ts | 12 +++- .../api/src/services/sessions/codex-runner.ts | 42 +++++++++---- .../src/services/sessions/gemini-runner.ts | 61 +++++++++++++------ .../src/services/sessions/session-service.ts | 8 ++- packages/api/src/services/sessions/types.ts | 4 ++ packages/shared/src/runner/index.ts | 1 + packages/shared/src/runner/mcp-config.ts | 6 +- packages/shared/src/runner/spawn-backend.ts | 3 + 10 files changed, 117 insertions(+), 37 deletions(-) diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index 748038b5..0dd1ddda 100644 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -900,7 +900,8 @@ When you complete a task_request, mark it as completed using update_inbox_messag ? payload.metadata.groupId : undefined, // Forward sandbox container name so the session service routes CLI execution into it - ...(payload.metadata?.sandboxContainerName + ...(payload.metadata?.sandboxContainerName && + typeof payload.metadata.sandboxContainerName === 'string' ? { sandboxContainerName: payload.metadata.sandboxContainerName } : {}), }, diff --git a/packages/api/src/services/sandbox/orchestrator.ts b/packages/api/src/services/sandbox/orchestrator.ts index 952d21ad..43098d5b 100644 --- a/packages/api/src/services/sandbox/orchestrator.ts +++ b/packages/api/src/services/sandbox/orchestrator.ts @@ -26,6 +26,7 @@ const execFileAsync = promisify(execFile); const DEFAULT_IMAGE = 'inkwell:studio-sandbox'; const CONTAINER_HOME = '/home/sb'; +const CONTAINER_RUNNER_FILES = '/run/ink'; const CONTAINER_LABEL = 'ink.sandbox=true'; const CLAUDE_KEYCHAIN_SERVICE = 'Claude Code-credentials'; @@ -73,6 +74,14 @@ export interface SandboxStatusResult { labels?: Record; } +/** + * Derive the host-side runner-files directory from a container name. + * Runners write temp files here; the orchestrator bind-mounts it as /run/ink. + */ +export function getRunnerFilesDir(containerName: string): string { + return join(homedir(), '.ink', 'runtime', 'sandbox', containerName, 'runner-files'); +} + export function buildContainerName(request: SandboxSpinUpRequest): string { if (request.containerName) return request.containerName; const label = sanitizeSlug(request.studioSlug || request.agentId || 'studio'); @@ -398,6 +407,11 @@ export async function buildMounts( mounts.push({ source: request.worktreePath, target: '/studio', readOnly: false }); } + // Runner temp files — runners write here on the host, visible at /run/ink inside the container + const runnerFilesDir = join(effectiveDir, 'runner-files'); + await mkdir(runnerFilesDir, { recursive: true }); + mounts.push({ source: runnerFilesDir, target: CONTAINER_RUNNER_FILES, readOnly: false }); + // Mount patched MCP config if it exists const patchedMcpPath = await patchMcpConfig(request.worktreePath, effectiveDir); if (patchedMcpPath) { diff --git a/packages/api/src/services/sessions/claude-runner.ts b/packages/api/src/services/sessions/claude-runner.ts index f7140281..48992d9a 100644 --- a/packages/api/src/services/sessions/claude-runner.ts +++ b/packages/api/src/services/sessions/claude-runner.ts @@ -24,6 +24,7 @@ import { buildSessionEnv, writeRuntimeSessionHint, resolveSpawnTarget, + CONTAINER_RUNNER_FILES, } from '@inklabs/shared'; import { ensureStudioSettings, applyPermissionOverlay } from '../studio-settings.js'; @@ -198,6 +199,7 @@ export class ClaudeRunner implements IRunner { pcpSessionId: config.pcpSessionId, studioId: config.studioId, accessToken: config.pcpAccessToken, + outputDir: config.container?.runtimeDir, }) : null; @@ -231,11 +233,17 @@ export class ClaudeRunner implements IRunner { } } - // If headers were injected, patch the --mcp-config arg to point to the temp file + // If headers were injected, patch the --mcp-config arg to point to the temp file. + // When containerized, translate host path to the container-side mount point. if (mcpInjection?.modified) { const mcpIdx = args.indexOf('--mcp-config'); if (mcpIdx !== -1 && args[mcpIdx + 1]) { - args[mcpIdx + 1] = mcpInjection.mcpConfigPath; + if (config.container?.runtimeDir) { + const filename = mcpInjection.mcpConfigPath.split('/').pop()!; + args[mcpIdx + 1] = `${CONTAINER_RUNNER_FILES}/${filename}`; + } else { + args[mcpIdx + 1] = mcpInjection.mcpConfigPath; + } } } diff --git a/packages/api/src/services/sessions/codex-runner.ts b/packages/api/src/services/sessions/codex-runner.ts index bee580a1..2b70ef15 100644 --- a/packages/api/src/services/sessions/codex-runner.ts +++ b/packages/api/src/services/sessions/codex-runner.ts @@ -22,7 +22,12 @@ import type { import { formatInjectedContext } from './context-builder.js'; import { logger } from '../../utils/logger.js'; import { resolveBinaryPath, buildSpawnPath } from './resolve-binary.js'; -import { buildSessionEnv, writeRuntimeSessionHint, resolveSpawnTarget } from '@inklabs/shared'; +import { + buildSessionEnv, + writeRuntimeSessionHint, + resolveSpawnTarget, + CONTAINER_RUNNER_FILES, +} from '@inklabs/shared'; /** Maximum time (ms) to wait for a Codex CLI subprocess before killing it. * Override with CODEX_PROCESS_TIMEOUT_MS env var. */ @@ -55,15 +60,22 @@ export class CodexRunner implements IRunner { fullMessage = `${contextBlock}\n\n---\n\n${message}`; } - const { promptPath, cleanup } = this.createIdentityPromptTempFile( - config.appendSystemPrompt || config.systemPrompt || '' + const { promptPath, containerPath, cleanup } = this.createIdentityPromptTempFile( + config.appendSystemPrompt || config.systemPrompt || '', + config.container?.runtimeDir ); try { // Only pass a session ID to buildArgs when resuming a known backend session. // For fresh runs, Codex assigns its own session UUID — we extract it from stdout. const argsSessionId = isResume ? backendSessionId! : undefined; - const args = this.buildArgs(argsSessionId, isResume, fullMessage, config, promptPath); + const args = this.buildArgs( + argsSessionId, + isResume, + fullMessage, + config, + containerPath || promptPath + ); logger.info('Spawning Codex CLI', { resumeSessionId: argsSessionId || null, isResume, @@ -439,20 +451,28 @@ export class CodexRunner implements IRunner { } } - private createIdentityPromptTempFile(content: string): { + private createIdentityPromptTempFile( + content: string, + runtimeDir?: string + ): { promptPath: string; + containerPath?: string; cleanup: () => void; } { - const dir = mkdtempSync(join(tmpdir(), 'pcp-codex-')); - const promptPath = join(dir, 'identity.md'); + const dir = runtimeDir || mkdtempSync(join(tmpdir(), 'pcp-codex-')); + const filename = `identity-${process.pid}-${Date.now()}.md`; + const promptPath = join(dir, filename); writeFileSync(promptPath, content || 'Follow system identity instructions.'); return { promptPath, + containerPath: runtimeDir ? `${CONTAINER_RUNNER_FILES}/${filename}` : undefined, cleanup: () => { - try { - rmSync(dir, { recursive: true, force: true }); - } catch { - // ignore cleanup errors + if (!runtimeDir) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } } }, }; diff --git a/packages/api/src/services/sessions/gemini-runner.ts b/packages/api/src/services/sessions/gemini-runner.ts index c58aeb8c..b2b9b24e 100644 --- a/packages/api/src/services/sessions/gemini-runner.ts +++ b/packages/api/src/services/sessions/gemini-runner.ts @@ -26,7 +26,7 @@ import type { import { formatInjectedContext } from './context-builder.js'; import { logger } from '../../utils/logger.js'; import { resolveBinaryPath, buildSpawnPath } from './resolve-binary.js'; -import { buildSessionEnv, resolveSpawnTarget } from '@inklabs/shared'; +import { buildSessionEnv, resolveSpawnTarget, CONTAINER_RUNNER_FILES } from '@inklabs/shared'; /** Maximum time (ms) to wait for a Gemini CLI subprocess before killing it. * Override with GEMINI_PROCESS_TIMEOUT_MS env var. */ @@ -62,8 +62,13 @@ export class GeminiRunner implements IRunner { } // Optionally write system prompt to a temp policy file - const { policyPath, cleanup } = this.createPolicyTempFile( - config.appendSystemPrompt || config.systemPrompt || '' + const { + policyPath, + containerPath: containerPolicyPath, + cleanup, + } = this.createPolicyTempFile( + config.appendSystemPrompt || config.systemPrompt || '', + config.container?.runtimeDir ); // Build Gemini system settings with PCP MCP server config (including auth). @@ -71,7 +76,8 @@ export class GeminiRunner implements IRunner { // We use GEMINI_CLI_SYSTEM_SETTINGS_PATH to point to a temp settings file // that overrides the mcpServers section. Other user settings (model, auth, // etc.) are preserved since system settings only override matching keys. - let geminiSettingsPath: string | undefined; + let geminiSettingsEnvPath: string | undefined; + let geminiSettingsHostPath: string | undefined; if (config.pcpAccessToken) { const mcpJsonPath = join(config.workingDirectory, '.mcp.json'); // Start from workspace .mcp.json servers (includes supabase, github, etc.) @@ -101,12 +107,16 @@ export class GeminiRunner implements IRunner { }, }; - const settingsDir = join(tmpdir(), 'sb-gemini'); + const settingsDir = config.container?.runtimeDir || join(tmpdir(), 'sb-gemini'); mkdirSync(settingsDir, { recursive: true }); - const settingsFile = join(settingsDir, `settings-${process.pid}-${Date.now()}.json`); + const settingsFilename = `settings-${process.pid}-${Date.now()}.json`; + const settingsFile = join(settingsDir, settingsFilename); try { writeFileSync(settingsFile, JSON.stringify({ mcpServers }, null, 2)); - geminiSettingsPath = settingsFile; + geminiSettingsHostPath = settingsFile; + geminiSettingsEnvPath = config.container?.runtimeDir + ? `${CONTAINER_RUNNER_FILES}/${settingsFilename}` + : settingsFile; } catch (err) { logger.warn('Failed to write Gemini system settings', { error: err instanceof Error ? err.message : String(err), @@ -115,20 +125,23 @@ export class GeminiRunner implements IRunner { } try { - const args = this.buildArgs(fullMessage, config, policyPath, backendSessionId); + const effectivePolicyPath = containerPolicyPath || policyPath; + const args = this.buildArgs(fullMessage, config, effectivePolicyPath, backendSessionId); logger.info('Spawning Gemini CLI', { isResume, backendSessionId: backendSessionId || '(new)', workingDirectory: config.workingDirectory, messageLength: fullMessage.length, hasPcpAccessToken: !!config.pcpAccessToken, - geminiSettingsOverride: !!geminiSettingsPath, + geminiSettingsOverride: !!geminiSettingsEnvPath, }); const result = await this.spawnProcess( args, config, - geminiSettingsPath ? { GEMINI_CLI_SYSTEM_SETTINGS_PATH: geminiSettingsPath } : undefined + geminiSettingsEnvPath + ? { GEMINI_CLI_SYSTEM_SETTINGS_PATH: geminiSettingsEnvPath } + : undefined ); // Use session ID from Gemini's init event, fall back to the one we passed in @@ -154,10 +167,10 @@ export class GeminiRunner implements IRunner { error: error instanceof Error ? error.message : 'Unknown error', }; } finally { - // Clean up temp Gemini settings file - if (geminiSettingsPath) { + // Clean up temp Gemini settings file (host path, not container path) + if (geminiSettingsHostPath && !config.container?.runtimeDir) { try { - rmSync(geminiSettingsPath, { force: true }); + rmSync(geminiSettingsHostPath, { force: true }); } catch { // best-effort cleanup } @@ -544,25 +557,33 @@ export class GeminiRunner implements IRunner { /** * Write system prompt / identity to a temp policy file for Gemini CLI. */ - private createPolicyTempFile(content: string): { + private createPolicyTempFile( + content: string, + runtimeDir?: string + ): { policyPath: string | undefined; + containerPath?: string; cleanup: () => void; } { if (!content) { return { policyPath: undefined, cleanup: () => {} }; } - const dir = mkdtempSync(join(tmpdir(), 'gemini-policy-')); - const policyPath = join(dir, 'identity-policy.md'); + const dir = runtimeDir || mkdtempSync(join(tmpdir(), 'gemini-policy-')); + const filename = `identity-policy-${process.pid}-${Date.now()}.md`; + const policyPath = join(dir, filename); writeFileSync(policyPath, content, 'utf-8'); return { policyPath, + containerPath: runtimeDir ? `${CONTAINER_RUNNER_FILES}/${filename}` : undefined, cleanup: () => { - try { - rmSync(dir, { recursive: true, force: true }); - } catch { - // Best effort cleanup + if (!runtimeDir) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // Best effort cleanup + } } }, }; diff --git a/packages/api/src/services/sessions/session-service.ts b/packages/api/src/services/sessions/session-service.ts index 507af36a..331d4f26 100644 --- a/packages/api/src/services/sessions/session-service.ts +++ b/packages/api/src/services/sessions/session-service.ts @@ -37,6 +37,7 @@ import { InkRunner } from './ink-runner.js'; import { ActivityStreamRepository } from '../../data/repositories/activity-stream.repository.js'; import { resolveIdentityId } from '../../auth/resolve-identity.js'; import { classifyError } from '@inklabs/shared'; +import { getRunnerFilesDir } from '../sandbox/orchestrator.js'; import { logger } from '../../utils/logger.js'; /** @@ -473,7 +474,12 @@ export class SessionService implements ISessionService { repoRoot: resolvedWorkingDirectory.replace(/--[^/]+$/, ''), // Route CLI execution into sandbox container when triggered by a sandboxed strategy ...(metadata?.sandboxContainerName - ? { container: { containerName: metadata.sandboxContainerName as string } } + ? { + container: { + containerName: metadata.sandboxContainerName, + runtimeDir: getRunnerFilesDir(metadata.sandboxContainerName), + }, + } : {}), }; diff --git a/packages/api/src/services/sessions/types.ts b/packages/api/src/services/sessions/types.ts index 213508c7..11f0a7d8 100644 --- a/packages/api/src/services/sessions/types.ts +++ b/packages/api/src/services/sessions/types.ts @@ -138,6 +138,8 @@ export interface SessionRequest { repoRoot?: string; // Task group ID for strategy lifecycle correlation taskGroupId?: string; + // Docker container name for sandboxed strategy execution + sandboxContainerName?: string; }; } @@ -436,6 +438,8 @@ export interface ClaudeRunnerConfig { container?: { containerName: string; dockerBinary?: string; + /** Host-side directory for runner temp files; bind-mounted as /run/ink inside the container */ + runtimeDir?: string; }; } diff --git a/packages/shared/src/runner/index.ts b/packages/shared/src/runner/index.ts index 46867934..b0ecc6ed 100644 --- a/packages/shared/src/runner/index.ts +++ b/packages/shared/src/runner/index.ts @@ -3,6 +3,7 @@ export { resolveSpawnTarget, spawnBackend, LineBuffer, + CONTAINER_RUNNER_FILES, type ContainerTarget, type SpawnBackendOptions, type SpawnBackendResult, diff --git a/packages/shared/src/runner/mcp-config.ts b/packages/shared/src/runner/mcp-config.ts index 072042ee..3b4d0be1 100644 --- a/packages/shared/src/runner/mcp-config.ts +++ b/packages/shared/src/runner/mcp-config.ts @@ -38,6 +38,8 @@ export interface InjectSessionHeadersOptions { studioId?: string; /** Optional access token — injected as Authorization header for triggered sessions */ accessToken?: string; + /** Directory to write the modified config to (default: system tmpdir/sb-mcp). Use this for container execution where temp files must be in a mounted directory. */ + outputDir?: string; } export interface InjectSessionHeadersResult { @@ -137,8 +139,8 @@ export function injectSessionHeaders( return { mcpConfigPath, cleanup: () => {}, modified: false }; } - // Write modified config to temp file - const tmpDir = join(tmpdir(), 'sb-mcp'); + // Write modified config to temp file (or outputDir for container execution) + const tmpDir = options.outputDir || join(tmpdir(), 'sb-mcp'); mkdirSync(tmpDir, { recursive: true }); const tmpPath = join(tmpDir, `mcp-server-${process.pid}-${Date.now()}.json`); writeFileSync(tmpPath, JSON.stringify(config, null, 2)); diff --git a/packages/shared/src/runner/spawn-backend.ts b/packages/shared/src/runner/spawn-backend.ts index ab5966ae..d494d801 100644 --- a/packages/shared/src/runner/spawn-backend.ts +++ b/packages/shared/src/runner/spawn-backend.ts @@ -15,6 +15,9 @@ import { spawn, type ChildProcess } from 'child_process'; import path from 'path'; +/** Container-side path for runner temp files, bind-mounted from the host staging dir */ +export const CONTAINER_RUNNER_FILES = '/run/ink'; + // ─── Types ────────────────────────────────────────────────────── export interface ContainerTarget { From ab7e63359d28bbe6f9c720df2772a73ab4a34926 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Fri, 8 May 2026 12:03:30 -0700 Subject: [PATCH 21/21] test: container path translation regression tests (by Wren) Verify that when runners execute inside Docker containers, temp file paths use /run/ink (container-side mount) instead of host /tmp paths. - orchestrator: getRunnerFilesDir path pattern, buildMounts /run/ink mount - mcp-config: injectSessionHeaders outputDir redirects config writes - codex-runner: model_instructions_file uses container path when runtimeDir set Co-Authored-By: Wren --- .../src/services/sandbox/orchestrator.test.ts | 26 ++++ .../services/sessions/codex-runner.test.ts | 123 +++++++++++++++++- packages/shared/src/runner/mcp-config.test.ts | 42 ++++++ 3 files changed, 190 insertions(+), 1 deletion(-) diff --git a/packages/api/src/services/sandbox/orchestrator.test.ts b/packages/api/src/services/sandbox/orchestrator.test.ts index fa5a355f..274a3f69 100644 --- a/packages/api/src/services/sandbox/orchestrator.test.ts +++ b/packages/api/src/services/sandbox/orchestrator.test.ts @@ -7,6 +7,7 @@ import { buildEnvVars, buildDockerRunArgs, buildMounts, + getRunnerFilesDir, patchMcpConfig, stageClaudeDir, stageCodexDir, @@ -185,7 +186,32 @@ describe('buildDockerRunArgs', () => { }); }); +describe('getRunnerFilesDir', () => { + it('returns expected path pattern under ~/.ink/runtime/sandbox//runner-files', () => { + const dir = getRunnerFilesDir('ink-sandbox-wren-abc12345'); + expect(dir).toBe( + join(homedir(), '.ink', 'runtime', 'sandbox', 'ink-sandbox-wren-abc12345', 'runner-files') + ); + }); + + it('returns distinct paths for different container names', () => { + const dir1 = getRunnerFilesDir('ink-sandbox-wren-aaaa1111'); + const dir2 = getRunnerFilesDir('ink-sandbox-lumen-bbbb2222'); + expect(dir1).not.toBe(dir2); + }); +}); + describe('buildMounts', () => { + it('includes a mount with target /run/ink for runner temp files', async () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'mount-ink-')); + const mounts = await buildMounts({ ...baseRequest, worktreePath: tmpDir, repoRoot: tmpDir }); + const runInkMount = mounts.find((m) => m.target === '/run/ink'); + expect(runInkMount).toBeDefined(); + expect(runInkMount!.readOnly).toBe(false); + // Source should end with 'runner-files' + expect(runInkMount!.source).toMatch(/runner-files$/); + }); + it('returns empty array when worktree path does not exist', async () => { const mounts = await buildMounts({ ...baseRequest, worktreePath: '/nonexistent/path' }); expect(mounts.filter((m) => m.target === '/studio')).toHaveLength(0); diff --git a/packages/api/src/services/sessions/codex-runner.test.ts b/packages/api/src/services/sessions/codex-runner.test.ts index 131ecb29..873d47e4 100644 --- a/packages/api/src/services/sessions/codex-runner.test.ts +++ b/packages/api/src/services/sessions/codex-runner.test.ts @@ -1,5 +1,8 @@ -import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; import { EventEmitter } from 'events'; +import { mkdtempSync, existsSync, readdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; vi.mock('child_process', async (importOriginal) => { const actual = await importOriginal(); @@ -406,6 +409,124 @@ describe('CodexRunner', () => { expect(result.backendSessionId).toBe(codexSessionId); }); + describe('container path translation', () => { + let runtimeDir: string; + + beforeEach(() => { + runtimeDir = mkdtempSync(join(tmpdir(), 'codex-container-test-')); + }); + + afterEach(() => { + try { + rmSync(runtimeDir, { recursive: true, force: true }); + } catch { + // best effort + } + }); + + it('uses /run/ink/ container path when config.container.runtimeDir is set', async () => { + const mockProc = createMockProcess(); + (spawn as Mock).mockReturnValue(mockProc); + + const runner = new CodexRunner(); + const runPromise = runner.run('hello', { + config: { + workingDirectory: process.cwd(), + mcpConfigPath: '', + model: 'gpt-5-codex', + appendSystemPrompt: 'test identity prompt', + container: { + containerName: 'ink-sandbox-test-abc', + runtimeDir, + }, + }, + }); + + setTimeout(() => { + mockProc.stdout.emit('data', Buffer.from(`${JSON.stringify({ result: 'ok' })}\n`)); + mockProc.emit('close', 0); + }, 5); + + await runPromise; + + expect(spawn).toHaveBeenCalledTimes(1); + const [, args] = (spawn as Mock).mock.calls[0] as [string, string[]]; + + // The model_instructions_file arg should point to /run/ink/ (container path) + const configArg = args.find( + (a: string) => typeof a === 'string' && a.startsWith('model_instructions_file=') + ); + expect(configArg).toBeDefined(); + expect(configArg).toMatch(/^model_instructions_file=\/run\/ink\//); + }); + + it('writes the identity prompt file to runtimeDir on the host', async () => { + const mockProc = createMockProcess(); + (spawn as Mock).mockReturnValue(mockProc); + + const runner = new CodexRunner(); + const runPromise = runner.run('hello', { + config: { + workingDirectory: process.cwd(), + mcpConfigPath: '', + model: 'gpt-5-codex', + appendSystemPrompt: 'host-side identity content', + container: { + containerName: 'ink-sandbox-test-abc', + runtimeDir, + }, + }, + }); + + setTimeout(() => { + mockProc.stdout.emit('data', Buffer.from(`${JSON.stringify({ result: 'ok' })}\n`)); + mockProc.emit('close', 0); + }, 5); + + await runPromise; + + // Verify the file was written to the runtimeDir (host-side) + const files = readdirSync(runtimeDir); + const identityFile = files.find((f) => f.startsWith('identity-')); + expect(identityFile).toBeDefined(); + }); + + it('uses /tmp/ path when runtimeDir is NOT set', async () => { + const mockProc = createMockProcess(); + (spawn as Mock).mockReturnValue(mockProc); + + const runner = new CodexRunner(); + const runPromise = runner.run('hello', { + config: { + workingDirectory: process.cwd(), + mcpConfigPath: '', + model: 'gpt-5-codex', + appendSystemPrompt: 'test identity prompt', + // No container config + }, + }); + + setTimeout(() => { + mockProc.stdout.emit('data', Buffer.from(`${JSON.stringify({ result: 'ok' })}\n`)); + mockProc.emit('close', 0); + }, 5); + + await runPromise; + + expect(spawn).toHaveBeenCalledTimes(1); + const [, args] = (spawn as Mock).mock.calls[0] as [string, string[]]; + + // Without container, the model_instructions_file should NOT use /run/ink/ + const configArg = args.find( + (a: string) => typeof a === 'string' && a.startsWith('model_instructions_file=') + ); + expect(configArg).toBeDefined(); + expect(configArg).not.toMatch(/\/run\/ink\//); + // Should be a host-side tmp path + expect(configArg).toMatch(/model_instructions_file=\//); + }); + }); + it('includes parsed startup events in diagnostics when codex exits non-zero without stderr', async () => { const mockProc = createMockProcess(); (spawn as Mock).mockReturnValue(mockProc); diff --git a/packages/shared/src/runner/mcp-config.test.ts b/packages/shared/src/runner/mcp-config.test.ts index 01b8b278..a90f3ab3 100644 --- a/packages/shared/src/runner/mcp-config.test.ts +++ b/packages/shared/src/runner/mcp-config.test.ts @@ -104,6 +104,48 @@ describe('injectSessionHeaders', () => { unlinkSync(configPath); }); + it('writes modified config to outputDir when set', () => { + const configPath = writeTempConfig({ + mcpServers: { inkwell: { type: 'http', url: 'http://localhost:3001/mcp' } }, + }); + + const outputDir = join(testDir, `output-${Date.now()}`); + mkdirSync(outputDir, { recursive: true }); + + const result = injectSessionHeaders({ + mcpConfigPath: configPath, + pcpSessionId: 'test-session-id', + studioId: 'test-studio-id', + outputDir, + }); + + expect(result.modified).toBe(true); + // The returned mcpConfigPath should be inside the outputDir, not the default /tmp/sb-mcp + expect(result.mcpConfigPath.startsWith(outputDir)).toBe(true); + expect(existsSync(result.mcpConfigPath)).toBe(true); + + // Verify the config was written correctly + const config = JSON.parse(readFileSync(result.mcpConfigPath, 'utf-8')); + expect(config.mcpServers.inkwell.headers['x-ink-session-id']).toBe('${INK_SESSION_ID}'); + result.cleanup(); + }); + + it('uses default sb-mcp dir when outputDir is not set', () => { + const configPath = writeTempConfig({ + mcpServers: { inkwell: { type: 'http', url: 'http://localhost:3001/mcp' } }, + }); + + const result = injectSessionHeaders({ + mcpConfigPath: configPath, + pcpSessionId: 'test-session-id', + }); + + expect(result.modified).toBe(true); + // Without outputDir, should use the default tmpdir/sb-mcp path + expect(result.mcpConfigPath).toContain('sb-mcp'); + result.cleanup(); + }); + it('returns original path when headers already present', () => { const configPath = writeTempConfig({ mcpServers: {