Add secure proxy configuration with options and secrets support

- Implement secure config file location (/tmp/.yao/proxy.json) instead of user-visible /workspace/
- Add generic options map support for backend-specific parameters (e.g., thinking for Volcengine GLM-4.7)
- Add secrets support for passing sensitive env vars (e.g., GITHUB_TOKEN) to sandbox container
- Remove excessive debug logs, keep critical ones with log.Printf("[Sandbox]...")
- Fix test assertions for system prompt passing via CLI args instead of env var
- Update i18n messages for sandbox loading states

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Max 2026-01-31 22:46:48 +08:00
parent 323b322cf9
commit ea8be6c287
18 changed files with 1209 additions and 159 deletions

2
.gitignore vendored
View file

@ -58,3 +58,5 @@ introduction/*
!sandbox/docker/build.sh !sandbox/docker/build.sh
sandbox/docker/yao-bridge-* sandbox/docker/yao-bridge-*
sandbox/docker/claude-proxy-* sandbox/docker/claude-proxy-*
sandbox/docker/claude/claude-proxy-*
sandbox/proxy/claude-proxy-linux-*

View file

@ -157,10 +157,11 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
// Sandbox must be created BEFORE hooks so that hooks can access ctx.sandbox // Sandbox must be created BEFORE hooks so that hooks can access ctx.sandbox
var sandboxExecutor agentsandbox.Executor var sandboxExecutor agentsandbox.Executor
var sandboxCleanup func() var sandboxCleanup func()
var sandboxLoadingMsgID string
if ast.HasSandbox() { if ast.HasSandbox() {
ctx.Logger.Phase("Sandbox") ctx.Logger.Phase("Sandbox")
var err error var err error
sandboxExecutor, sandboxCleanup, err = ast.initSandbox(ctx, opts) sandboxExecutor, sandboxCleanup, sandboxLoadingMsgID, err = ast.initSandbox(ctx, opts)
if err != nil { if err != nil {
ast.traceAgentFail(agentNode, err) ast.traceAgentFail(agentNode, err)
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err) ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
@ -285,7 +286,7 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
// Choose between sandbox execution or direct LLM execution // Choose between sandbox execution or direct LLM execution
if ast.HasSandbox() { if ast.HasSandbox() {
// Sandbox execution path (Claude CLI, Cursor CLI, etc.) // Sandbox execution path (Claude CLI, Cursor CLI, etc.)
completionResponse, err = ast.executeSandboxStream(ctx, completionMessages, agentNode, streamHandler, sandboxExecutor) completionResponse, err = ast.executeSandboxStream(ctx, completionMessages, agentNode, streamHandler, sandboxExecutor, sandboxLoadingMsgID)
} else { } else {
// Direct LLM execution path // Direct LLM execution path
completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts) completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts)

View file

@ -12,6 +12,7 @@ import (
gouMCP "github.com/yaoapp/gou/mcp" gouMCP "github.com/yaoapp/gou/mcp"
mcpProcess "github.com/yaoapp/gou/mcp/process" mcpProcess "github.com/yaoapp/gou/mcp/process"
"github.com/yaoapp/yao/agent/context" "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n"
"github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/output/message"
agentsandbox "github.com/yaoapp/yao/agent/sandbox" agentsandbox "github.com/yaoapp/yao/agent/sandbox"
"github.com/yaoapp/yao/config" "github.com/yaoapp/yao/config"
@ -55,22 +56,22 @@ func (ast *Assistant) HasSandbox() bool {
// Returns the full Executor (for LLM calls), cleanup function, and any error // Returns the full Executor (for LLM calls), cleanup function, and any error
// This is called BEFORE hooks so that hooks can access ctx.sandbox // This is called BEFORE hooks so that hooks can access ctx.sandbox
// The executor implements both agentsandbox.Executor and context.SandboxExecutor interfaces // The executor implements both agentsandbox.Executor and context.SandboxExecutor interfaces
func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) (agentsandbox.Executor, func(), error) { func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) (agentsandbox.Executor, func(), string, error) {
// Get sandbox manager (singleton) // Get sandbox manager (singleton)
manager, err := GetSandboxManager() manager, err := GetSandboxManager()
if err != nil { if err != nil {
ctx.Logger.Error("Sandbox manager initialization failed: %v", err) ctx.Logger.Error("Sandbox manager initialization failed: %v", err)
return nil, nil, fmt.Errorf("sandbox manager not available: %w", err) return nil, nil, "", fmt.Errorf("sandbox manager not available: %w", err)
} }
if manager == nil { if manager == nil {
return nil, nil, fmt.Errorf("sandbox manager not initialized") return nil, nil, "", fmt.Errorf("sandbox manager not initialized")
} }
// Build executor options from assistant config // Build executor options from assistant config
execOpts, err := ast.buildSandboxOptions(ctx, opts) execOpts, err := ast.buildSandboxOptions(ctx, opts)
if err != nil { if err != nil {
ctx.Logger.Error("Failed to build sandbox options: %v", err) ctx.Logger.Error("Failed to build sandbox options: %v", err)
return nil, nil, fmt.Errorf("failed to build sandbox options: %w", err) return nil, nil, "", fmt.Errorf("failed to build sandbox options: %w", err)
} }
// Log sandbox creation // Log sandbox creation
@ -86,7 +87,7 @@ func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) (
loadingMsg := &message.Message{ loadingMsg := &message.Message{
Type: message.TypeLoading, Type: message.TypeLoading,
Props: map[string]interface{}{ Props: map[string]interface{}{
"message": "Preparing sandbox environment", "message": i18n.T(ctx.Locale, "sandbox.preparing"),
}, },
} }
loadingMsgID, _ := ctx.SendStream(loadingMsg) loadingMsgID, _ := ctx.SendStream(loadingMsg)
@ -98,11 +99,21 @@ func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) (
if traceErr == nil && trace != nil { if traceErr == nil && trace != nil {
trace.Error("Sandbox creation failed: %v", err) trace.Error("Sandbox creation failed: %v", err)
} }
// End loading message // End loading message with done:true
if loadingMsgID != "" { if loadingMsgID != "" {
ctx.End(loadingMsgID) doneMsg := &message.Message{
MessageID: loadingMsgID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: message.TypeLoading,
Props: map[string]interface{}{
"message": i18n.T(ctx.Locale, "sandbox.failed"),
"done": true,
},
} }
return nil, nil, fmt.Errorf("failed to create sandbox executor: %w", err) ctx.Send(doneMsg)
}
return nil, nil, "", fmt.Errorf("failed to create sandbox executor: %w", err)
} }
// Log sandbox ready // Log sandbox ready
@ -111,11 +122,6 @@ func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) (
trace.Info("Sandbox container ready") trace.Info("Sandbox container ready")
} }
// End loading message
if loadingMsgID != "" {
ctx.End(loadingMsgID)
}
// Return cleanup function // Return cleanup function
cleanup := func() { cleanup := func() {
if err := executor.Close(); err != nil { if err := executor.Close(); err != nil {
@ -123,7 +129,9 @@ func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) (
} }
} }
return executor, cleanup, nil // Keep loadingMsgID open - it will be closed when first output is received
// This provides better UX: user sees "Preparing..." until actual content appears
return executor, cleanup, loadingMsgID, nil
} }
// executeSandboxStream executes the request using sandbox (Claude CLI, etc.) // executeSandboxStream executes the request using sandbox (Claude CLI, etc.)
@ -135,6 +143,7 @@ func (ast *Assistant) executeSandboxStream(
agentNode traceTypes.Node, agentNode traceTypes.Node,
streamHandler message.StreamFunc, streamHandler message.StreamFunc,
executor agentsandbox.Executor, executor agentsandbox.Executor,
loadingMsgID string,
) (*context.CompletionResponse, error) { ) (*context.CompletionResponse, error) {
// Mark the agentNode as used to avoid unused variable error // Mark the agentNode as used to avoid unused variable error
@ -147,9 +156,41 @@ func (ast *Assistant) executeSandboxStream(
// Log sandbox execution // Log sandbox execution
ctx.Logger.Info("Executing via sandbox (command: %s)", ast.Sandbox.Command) ctx.Logger.Info("Executing via sandbox (command: %s)", ast.Sandbox.Command)
// Pass the "preparing sandbox" loading message ID to executor
// It will be closed when first output (text or tool) is received
if loadingMsgID != "" {
executor.SetLoadingMsgID(loadingMsgID)
}
// Execute LLM call via sandbox // Execute LLM call via sandbox
// The loadingMsgID will be closed when first output is received
// Tool calls will create their own loading messages below the text
resp, err := executor.Stream(ctx, completionMessages, streamHandler) resp, err := executor.Stream(ctx, completionMessages, streamHandler)
if err != nil { if err != nil {
// Close loading message on error
if loadingMsgID != "" {
doneMsg := &message.Message{
MessageID: loadingMsgID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: message.TypeLoading,
Props: map[string]interface{}{
"message": i18n.T(ctx.Locale, "sandbox.failed"),
"done": true,
},
}
ctx.Send(doneMsg)
}
// Send error message to client
errMsg := &message.Message{
Type: message.TypeError,
Props: map[string]interface{}{
"message": err.Error(),
},
}
ctx.Send(errMsg)
return nil, fmt.Errorf("sandbox execution failed: %w", err) return nil, fmt.Errorf("sandbox execution failed: %w", err)
} }
@ -227,6 +268,40 @@ func (ast *Assistant) buildSandboxOptions(ctx *context.Context, opts *context.Op
execOpts.Model = model execOpts.Model = model
} }
// Extract extra connector options (thinking, max_tokens, temperature, etc.)
// These are backend-specific parameters that need to be passed through to the proxy
connectorOptions := make(map[string]interface{})
for k, v := range setting {
// Skip standard fields that are already handled
switch k {
case "host", "key", "model", "azure", "capabilities":
continue
default:
// Include all other fields as extra options
connectorOptions[k] = v
}
}
if len(connectorOptions) > 0 {
execOpts.ConnectorOptions = connectorOptions
ctx.Logger.Debug("Connector options extracted: %v", connectorOptions)
}
// Extract secrets from sandbox config (e.g., GITHUB_TOKEN: "$ENV.GITHUB_TOKEN")
if ast.Sandbox != nil && len(ast.Sandbox.Secrets) > 0 {
secrets := make(map[string]string)
for k, v := range ast.Sandbox.Secrets {
// Resolve $ENV.XXX references
resolved := resolveEnvValue(v)
if resolved != "" {
secrets[k] = resolved
}
}
if len(secrets) > 0 {
execOpts.Secrets = secrets
ctx.Logger.Debug("Secrets extracted: %d items", len(secrets))
}
}
// Build MCP config and load tools if the assistant has MCP servers configured // Build MCP config and load tools if the assistant has MCP servers configured
if ast.MCP != nil && len(ast.MCP.Servers) > 0 { if ast.MCP != nil && len(ast.MCP.Servers) > 0 {
// Build MCP config for Claude CLI // Build MCP config for Claude CLI
@ -366,3 +441,21 @@ func (ast *Assistant) BuildMCPConfigForSandbox(ctx *context.Context) ([]byte, er
return json.Marshal(config) return json.Marshal(config)
} }
// resolveEnvValue resolves environment variable references in a string
// Supports format: $ENV.VAR_NAME or plain value
// Returns empty string if the variable is not set
func resolveEnvValue(value string) string {
if value == "" {
return ""
}
// Check for $ENV.XXX format
if len(value) > 5 && value[:5] == "$ENV." {
envName := value[5:]
return os.Getenv(envName)
}
// Return as-is if not an env reference
return value
}

View file

@ -95,8 +95,13 @@ func TestClaudeCommandBuilding(t *testing.T) {
assert.NotEmpty(t, env) assert.NotEmpty(t, env)
assert.Equal(t, "http://127.0.0.1:3456", env["ANTHROPIC_BASE_URL"], "Should set proxy base URL") assert.Equal(t, "http://127.0.0.1:3456", env["ANTHROPIC_BASE_URL"], "Should set proxy base URL")
assert.Equal(t, "dummy", env["ANTHROPIC_API_KEY"], "Should set dummy API key for proxy") assert.Equal(t, "dummy", env["ANTHROPIC_API_KEY"], "Should set dummy API key for proxy")
assert.Equal(t, "10", env["CLAUDE_MAX_TURNS"])
assert.Contains(t, env["CLAUDE_SYSTEM_PROMPT"], "You are a helpful coding assistant") // max_turns is passed via CLI flag
// system prompt is written to file via heredoc, then referenced via --append-system-prompt-file
assert.Contains(t, cmd[2], "--max-turns", "Should include max-turns flag")
assert.Contains(t, cmd[2], "cat << 'PROMPTEOF' > /tmp/.system-prompt.txt", "Should use heredoc for system prompt")
assert.Contains(t, cmd[2], "--append-system-prompt-file", "Should include append-system-prompt-file flag")
assert.Contains(t, cmd[2], "You are a helpful coding assistant", "Command should contain system prompt")
t.Logf("Built environment: %v", env) t.Logf("Built environment: %v", env)
} }

View file

@ -99,14 +99,39 @@ func init() {
"kb.chat.name": "Chat Knowledge Base", "kb.chat.name": "Chat Knowledge Base",
"kb.chat.description": "Auto-created knowledge base collection for chat sessions", "kb.chat.description": "Auto-created knowledge base collection for chat sessions",
// Sandbox: assistant/sandbox.go - Sandbox status messages
"sandbox.preparing": "Preparing sandbox environment",
"sandbox.ready": "Sandbox ready",
"sandbox.working": "Working on your request",
"sandbox.completed": "Completed",
"sandbox.failed": "Execution failed",
// Sandbox: claude/executor.go - Tool execution messages
"sandbox.tool.read": "Reading file",
"sandbox.tool.write": "Writing file",
"sandbox.tool.edit": "Editing file",
"sandbox.tool.bash": "Running command",
"sandbox.tool.glob": "Finding files",
"sandbox.tool.grep": "Searching code",
"sandbox.tool.ls": "Listing directory",
"sandbox.tool.task": "Running subtask",
"sandbox.tool.web_search": "Searching web",
"sandbox.tool.web_fetch": "Fetching URL",
"sandbox.tool.todo_write": "Managing tasks",
"sandbox.tool.ask_question": "Asking question",
"sandbox.tool.switch_mode": "Switching mode",
"sandbox.tool.read_lints": "Checking lints",
"sandbox.tool.edit_notebook": "Editing notebook",
"sandbox.tool.unknown": "Executing {{name}}",
// Content: content/image/image.go - Image processing messages // Content: content/image/image.go - Image processing messages
"content.image.analyzing": "Analyzing image...", "content.image.analyzing": "Analyzing image",
// Content: content/pdf/pdf.go - PDF processing messages // Content: content/pdf/pdf.go - PDF processing messages
"content.pdf.analyzing_page": "Analyzing PDF page %d/%d...", "content.pdf.analyzing_page": "Analyzing PDF page %d/%d",
// Search: assistant/search.go - Output messages // Search: assistant/search.go - Output messages
"search.loading": "Searching...", "search.loading": "Searching",
"search.success": "Found %d references", "search.success": "Found %d references",
"search.success.one": "Found 1 reference", "search.success.one": "Found 1 reference",
"search.partial": "Found %d references (some sources failed)", "search.partial": "Found %d references (some sources failed)",
@ -114,12 +139,12 @@ func init() {
"search.no_results": "No references found", "search.no_results": "No references found",
// Search Intent: assistant/search.go - Intent detection messages // Search Intent: assistant/search.go - Intent detection messages
"search.intent.loading": "Checking if references are needed...", "search.intent.loading": "Checking if references are needed",
"search.intent.need_search": "Searching for references...", "search.intent.need_search": "Searching for references",
"search.intent.no_search": "No references needed", "search.intent.no_search": "No references needed",
// Keyword Extraction: assistant/search.go - Keyword extraction messages // Keyword Extraction: assistant/search.go - Keyword extraction messages
"search.keyword.loading": "Analyzing conversation...", "search.keyword.loading": "Analyzing conversation",
"search.keyword.done": "Analysis complete", "search.keyword.done": "Analysis complete",
// Search: assistant/search.go - Trace labels // Search: assistant/search.go - Trace labels
@ -198,14 +223,39 @@ func init() {
"kb.chat.name": "聊天知识库", "kb.chat.name": "聊天知识库",
"kb.chat.description": "自动为聊天会话创建的知识库集合", "kb.chat.description": "自动为聊天会话创建的知识库集合",
// Sandbox: assistant/sandbox.go - Sandbox status messages
"sandbox.preparing": "正在准备沙箱环境",
"sandbox.ready": "沙箱环境就绪",
"sandbox.working": "正在处理您的请求",
"sandbox.completed": "处理完成",
"sandbox.failed": "执行失败",
// Sandbox: claude/executor.go - Tool execution messages
"sandbox.tool.read": "正在读取文件",
"sandbox.tool.write": "正在写入文件",
"sandbox.tool.edit": "正在编辑文件",
"sandbox.tool.bash": "正在执行命令",
"sandbox.tool.glob": "正在查找文件",
"sandbox.tool.grep": "正在搜索代码",
"sandbox.tool.ls": "正在列出目录",
"sandbox.tool.task": "正在执行子任务",
"sandbox.tool.web_search": "正在搜索网页",
"sandbox.tool.web_fetch": "正在获取网页",
"sandbox.tool.todo_write": "正在管理任务",
"sandbox.tool.ask_question": "正在询问问题",
"sandbox.tool.switch_mode": "正在切换模式",
"sandbox.tool.read_lints": "正在检查代码",
"sandbox.tool.edit_notebook": "正在编辑笔记本",
"sandbox.tool.unknown": "正在执行 {{name}}",
// Content: content/image/image.go - Image processing messages // Content: content/image/image.go - Image processing messages
"content.image.analyzing": "正在分析图片...", "content.image.analyzing": "正在分析图片",
// Content: content/pdf/pdf.go - PDF processing messages // Content: content/pdf/pdf.go - PDF processing messages
"content.pdf.analyzing_page": "正在分析 PDF 第 %d/%d 页...", "content.pdf.analyzing_page": "正在分析 PDF 第 %d/%d 页",
// Search: assistant/search.go - Output messages // Search: assistant/search.go - Output messages
"search.loading": "正在搜索...", "search.loading": "正在搜索",
"search.success": "找到 %d 条参考资料", "search.success": "找到 %d 条参考资料",
"search.success.one": "找到 1 条参考资料", "search.success.one": "找到 1 条参考资料",
"search.partial": "找到 %d 条参考资料(部分来源失败)", "search.partial": "找到 %d 条参考资料(部分来源失败)",
@ -213,12 +263,12 @@ func init() {
"search.no_results": "未找到相关资料", "search.no_results": "未找到相关资料",
// Search Intent: assistant/search.go - Intent detection messages // Search Intent: assistant/search.go - Intent detection messages
"search.intent.loading": "检查是否需要查询资料...", "search.intent.loading": "检查是否需要查询资料",
"search.intent.need_search": "正在查询相关资料...", "search.intent.need_search": "正在查询相关资料",
"search.intent.no_search": "无需查询资料", "search.intent.no_search": "无需查询资料",
// Keyword Extraction: assistant/search.go - Keyword extraction messages // Keyword Extraction: assistant/search.go - Keyword extraction messages
"search.keyword.loading": "正在分析对话内容...", "search.keyword.loading": "正在分析对话内容",
"search.keyword.done": "分析完成", "search.keyword.done": "分析完成",
// Search: assistant/search.go - Trace labels // Search: assistant/search.go - Trace labels
@ -325,14 +375,39 @@ func init() {
"kb.chat.name": "聊天知识库", "kb.chat.name": "聊天知识库",
"kb.chat.description": "自动为聊天会话创建的知识库集合", "kb.chat.description": "自动为聊天会话创建的知识库集合",
// Sandbox: assistant/sandbox.go - Sandbox status messages
"sandbox.preparing": "正在准备沙箱环境",
"sandbox.ready": "沙箱环境就绪",
"sandbox.working": "正在处理您的请求",
"sandbox.completed": "处理完成",
"sandbox.failed": "执行失败",
// Sandbox: claude/executor.go - Tool execution messages
"sandbox.tool.read": "正在读取文件",
"sandbox.tool.write": "正在写入文件",
"sandbox.tool.edit": "正在编辑文件",
"sandbox.tool.bash": "正在执行命令",
"sandbox.tool.glob": "正在查找文件",
"sandbox.tool.grep": "正在搜索代码",
"sandbox.tool.ls": "正在列出目录",
"sandbox.tool.task": "正在执行子任务",
"sandbox.tool.web_search": "正在搜索网页",
"sandbox.tool.web_fetch": "正在获取网页",
"sandbox.tool.todo_write": "正在管理任务",
"sandbox.tool.ask_question": "正在询问问题",
"sandbox.tool.switch_mode": "正在切换模式",
"sandbox.tool.read_lints": "正在检查代码",
"sandbox.tool.edit_notebook": "正在编辑笔记本",
"sandbox.tool.unknown": "正在执行 {{name}}",
// Content: content/image/image.go - Image processing messages // Content: content/image/image.go - Image processing messages
"content.image.analyzing": "正在分析图片...", "content.image.analyzing": "正在分析图片",
// Content: content/pdf/pdf.go - PDF processing messages // Content: content/pdf/pdf.go - PDF processing messages
"content.pdf.analyzing_page": "正在分析 PDF 第 %d/%d 页...", "content.pdf.analyzing_page": "正在分析 PDF 第 %d/%d 页",
// Search: assistant/search.go - Output messages // Search: assistant/search.go - Output messages
"search.loading": "正在搜索...", "search.loading": "正在搜索",
"search.success": "找到 %d 条参考资料", "search.success": "找到 %d 条参考资料",
"search.success.one": "找到 1 条参考资料", "search.success.one": "找到 1 条参考资料",
"search.partial": "找到 %d 条参考资料(部分来源失败)", "search.partial": "找到 %d 条参考资料(部分来源失败)",
@ -340,12 +415,12 @@ func init() {
"search.no_results": "未找到相关资料", "search.no_results": "未找到相关资料",
// Search Intent: assistant/search.go - Intent detection messages // Search Intent: assistant/search.go - Intent detection messages
"search.intent.loading": "检查是否需要查询资料...", "search.intent.loading": "检查是否需要查询资料",
"search.intent.need_search": "正在查询相关资料...", "search.intent.need_search": "正在查询相关资料",
"search.intent.no_search": "无需查询资料", "search.intent.no_search": "无需查询资料",
// Keyword Extraction: assistant/search.go - Keyword extraction messages // Keyword Extraction: assistant/search.go - Keyword extraction messages
"search.keyword.loading": "正在分析对话内容...", "search.keyword.loading": "正在分析对话内容",
"search.keyword.done": "分析完成", "search.keyword.done": "分析完成",
// Search: assistant/search.go - Trace labels // Search: assistant/search.go - Trace labels

View file

@ -8,12 +8,53 @@ import (
agentContext "github.com/yaoapp/yao/agent/context" agentContext "github.com/yaoapp/yao/agent/context"
) )
// sandboxEnvPrompt is the system prompt injected for sandbox environment
// This tells Claude CLI about the workspace and project structure
const sandboxEnvPrompt = `## Sandbox Environment
You are running in a sandboxed environment with the following setup:
- **Working Directory**: /workspace
- **Project Structure**: If this is a new project, create a dedicated project folder (e.g., /workspace/my-project/) and work inside it
- **File Access**: You have full read/write access to /workspace
- **Output Files**: Save all output files to the working directory
When creating new projects:
1. Create a project directory with a descriptive name
2. Initialize the project structure inside that directory
3. Keep all related files organized within the project folder
## IMPORTANT: Restricted Tools
The following tools are NOT available in this environment and you must NOT use them:
- EnterPlanMode, ExitPlanMode (use regular text to explain plans instead)
- Task, TaskOutput, TaskStop (complete tasks directly without delegation)
- AskUserQuestion (make reasonable assumptions instead of asking)
- Skill, ToolSearch (not supported)
Focus on using the core tools: Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch.
## GitHub CLI (gh) Usage
When working with GitHub and a token is provided:
1. First authenticate gh CLI using the token: echo "TOKEN" | gh auth login --with-token
2. Then use gh commands normally (gh repo create, gh pr create, etc.)
3. Do NOT use curl to call GitHub API directly - always prefer gh CLI
`
// BuildCommand builds the Claude CLI command and environment variables // BuildCommand builds the Claude CLI command and environment variables
// Uses stdin with --input-format stream-json for unlimited prompt length // Uses stdin with --input-format stream-json for unlimited prompt length
func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map[string]string, error) { func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map[string]string, error) {
// Build system prompt from conversation history // Build system prompt from conversation history
systemPrompt, _ := buildPrompts(messages) systemPrompt, _ := buildPrompts(messages)
// Inject sandbox environment prompt
if systemPrompt != "" {
systemPrompt = systemPrompt + "\n\n" + sandboxEnvPrompt
} else {
systemPrompt = sandboxEnvPrompt
}
// Build input JSONL for Claude CLI (stream-json format) // Build input JSONL for Claude CLI (stream-json format)
inputJSONL, err := BuildInputJSONL(messages) inputJSONL, err := BuildInputJSONL(messages)
if err != nil { if err != nil {
@ -36,8 +77,16 @@ func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map
// Add streaming format flags (required for proper streaming output) // Add streaming format flags (required for proper streaming output)
claudeArgs = append(claudeArgs, "--input-format", "stream-json") claudeArgs = append(claudeArgs, "--input-format", "stream-json")
claudeArgs = append(claudeArgs, "--output-format", "stream-json") claudeArgs = append(claudeArgs, "--output-format", "stream-json")
claudeArgs = append(claudeArgs, "--include-partial-messages") // Enable realtime streaming
claudeArgs = append(claudeArgs, "--verbose") claudeArgs = append(claudeArgs, "--verbose")
// Add max_turns if specified
if opts != nil && opts.Arguments != nil {
if maxTurns, ok := opts.Arguments["max_turns"]; ok {
claudeArgs = append(claudeArgs, "--max-turns", fmt.Sprintf("%v", maxTurns))
}
}
// Add MCP config if available // Add MCP config if available
if opts != nil && len(opts.MCPConfig) > 0 { if opts != nil && len(opts.MCPConfig) > 0 {
claudeArgs = append(claudeArgs, "--mcp-config", "/workspace/.mcp.json") claudeArgs = append(claudeArgs, "--mcp-config", "/workspace/.mcp.json")
@ -46,16 +95,30 @@ func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map
} }
// Build the full bash command // Build the full bash command
// Use heredoc to pass input JSONL via stdin (no length limit) // Use heredoc for both system prompt and input JSONL to avoid shell escaping issues
// claude-proxy is already started by prepareEnvironment // System prompt may contain quotes, newlines, special characters that break shell quoting
bashCmd := "cat << 'INPUTEOF' | claude -p" var bashCmd strings.Builder
// If we have a system prompt, write it to a temp file via heredoc first
// then use --append-system-prompt-file
if systemPrompt != "" {
bashCmd.WriteString("cat << 'PROMPTEOF' > /tmp/.system-prompt.txt\n")
bashCmd.WriteString(systemPrompt)
bashCmd.WriteString("\nPROMPTEOF\n")
claudeArgs = append(claudeArgs, "--append-system-prompt-file", "/tmp/.system-prompt.txt")
}
// Build claude command with all arguments
bashCmd.WriteString("cat << 'INPUTEOF' | claude -p")
for _, arg := range claudeArgs { for _, arg := range claudeArgs {
// Quote arguments that might contain special characters // Quote arguments that might contain special characters
bashCmd += fmt.Sprintf(" %q", arg) bashCmd.WriteString(fmt.Sprintf(" %q", arg))
} }
bashCmd += "\n" + string(inputJSONL) + "\nINPUTEOF" bashCmd.WriteString("\n")
bashCmd.WriteString(string(inputJSONL))
bashCmd.WriteString("\nINPUTEOF")
cmd := []string{"bash", "-c", bashCmd} cmd := []string{"bash", "-c", bashCmd.String()}
// Build environment variables // Build environment variables
env := buildEnvironment(opts, systemPrompt) env := buildEnvironment(opts, systemPrompt)
@ -175,24 +238,16 @@ func buildEnvironment(opts *Options, systemPrompt string) map[string]string {
env["ANTHROPIC_BASE_URL"] = "http://127.0.0.1:3456" env["ANTHROPIC_BASE_URL"] = "http://127.0.0.1:3456"
env["ANTHROPIC_API_KEY"] = "dummy" // Proxy doesn't verify this env["ANTHROPIC_API_KEY"] = "dummy" // Proxy doesn't verify this
// Set system prompt via environment (Claude CLI supports this) // Note: System prompt and max_turns are passed via CLI flags in BuildCommand
if systemPrompt != "" { // CLAUDE_SYSTEM_PROMPT environment variable is NOT supported by Claude CLI
env["CLAUDE_SYSTEM_PROMPT"] = systemPrompt // --append-system-prompt or --system-prompt flags must be used instead
}
// Additional Claude CLI options from Arguments
if opts.Arguments != nil {
// max_turns
if maxTurns, ok := opts.Arguments["max_turns"]; ok {
env["CLAUDE_MAX_TURNS"] = fmt.Sprintf("%v", maxTurns)
}
}
return env return env
} }
// BuildProxyConfig builds the claude-proxy configuration JSON // BuildProxyConfig builds the claude-proxy configuration JSON
// This config file is read by start-claude-proxy script in the container // This config file is read by start-claude-proxy script in the container
// Config is written to /tmp/.yao/proxy.json (not /workspace/) for security
func BuildProxyConfig(opts *Options) ([]byte, error) { func BuildProxyConfig(opts *Options) ([]byte, error) {
if opts == nil { if opts == nil {
return nil, fmt.Errorf("options is required") return nil, fmt.Errorf("options is required")
@ -210,6 +265,18 @@ func BuildProxyConfig(opts *Options) ([]byte, error) {
"model": opts.Model, "model": opts.Model,
} }
// Add extra connector options if present (e.g., thinking, max_tokens, temperature)
// These will be passed to the proxy via CLAUDE_PROXY_OPTIONS environment variable
if len(opts.ConnectorOptions) > 0 {
config["options"] = opts.ConnectorOptions
}
// Add secrets if present (e.g., GITHUB_TOKEN, AWS_ACCESS_KEY)
// These will be exported as environment variables for Claude CLI to use
if len(opts.Secrets) > 0 {
config["secrets"] = opts.Secrets
}
return json.MarshalIndent(config, "", " ") return json.MarshalIndent(config, "", " ")
} }

View file

@ -33,6 +33,7 @@ func TestBuildCommand(t *testing.T) {
// Should have stream-json flags // Should have stream-json flags
assert.Contains(t, cmd[2], "--input-format") assert.Contains(t, cmd[2], "--input-format")
assert.Contains(t, cmd[2], "--output-format") assert.Contains(t, cmd[2], "--output-format")
assert.Contains(t, cmd[2], "--include-partial-messages")
assert.Contains(t, cmd[2], "--verbose") assert.Contains(t, cmd[2], "--verbose")
assert.Contains(t, cmd[2], "stream-json") assert.Contains(t, cmd[2], "stream-json")
@ -51,12 +52,35 @@ func TestBuildCommandWithSystemPrompt(t *testing.T) {
opts := &Options{} opts := &Options{}
_, env, err := BuildCommand(messages, opts) cmd, _, err := BuildCommand(messages, opts)
require.NoError(t, err) require.NoError(t, err)
// System prompt should include conversation history // System prompt should be written to file via heredoc, then passed via --append-system-prompt-file
assert.Contains(t, env["CLAUDE_SYSTEM_PROMPT"], "You are a code reviewer") bashCmd := cmd[2] // The bash -c command string
assert.Contains(t, env["CLAUDE_SYSTEM_PROMPT"], "Conversation History") assert.Contains(t, bashCmd, "cat << 'PROMPTEOF' > /tmp/.system-prompt.txt")
assert.Contains(t, bashCmd, "You are a code reviewer")
assert.Contains(t, bashCmd, "PROMPTEOF")
assert.Contains(t, bashCmd, "--append-system-prompt-file")
assert.Contains(t, bashCmd, "/tmp/.system-prompt.txt")
}
func TestBuildCommandWithSpecialCharsInPrompt(t *testing.T) {
// Test that special characters in prompts are handled correctly
messages := []agentContext.Message{
{Role: "system", Content: "You are a helper.\n\n## Rules\n- Rule 1: Don't use \"quotes\" wrongly\n- Rule 2: Handle 'single quotes' too\n- Rule 3: Special chars like $VAR and `backticks`"},
{Role: "user", Content: "Hello"},
}
opts := &Options{}
cmd, _, err := BuildCommand(messages, opts)
require.NoError(t, err)
bashCmd := cmd[2]
// The heredoc approach should preserve all special characters
assert.Contains(t, bashCmd, "## Rules")
assert.Contains(t, bashCmd, `Don't use "quotes" wrongly`)
assert.Contains(t, bashCmd, "'single quotes'")
} }
func TestBuildCommandWithArguments(t *testing.T) { func TestBuildCommandWithArguments(t *testing.T) {
@ -71,12 +95,15 @@ func TestBuildCommandWithArguments(t *testing.T) {
}, },
} }
cmd, env, err := BuildCommand(messages, opts) cmd, _, err := BuildCommand(messages, opts)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "20", env["CLAUDE_MAX_TURNS"]) bashCmd := cmd[2] // The bash -c command string
// permission_mode should be in command args, not env // max_turns should be in command args via --max-turns
assert.Contains(t, cmd[2], "acceptEdits") assert.Contains(t, bashCmd, "--max-turns")
assert.Contains(t, bashCmd, "20")
// permission_mode should be in command args
assert.Contains(t, bashCmd, "acceptEdits")
} }
func TestBuildProxyConfig(t *testing.T) { func TestBuildProxyConfig(t *testing.T) {

View file

@ -258,8 +258,10 @@ func TestE2EBuildCommand(t *testing.T) {
assert.Equal(t, "http://127.0.0.1:3456", env["ANTHROPIC_BASE_URL"]) assert.Equal(t, "http://127.0.0.1:3456", env["ANTHROPIC_BASE_URL"])
assert.Equal(t, "dummy", env["ANTHROPIC_API_KEY"]) assert.Equal(t, "dummy", env["ANTHROPIC_API_KEY"])
// System prompt should be in environment // System prompt should be passed via CLI argument, not environment variable
assert.Contains(t, env["CLAUDE_SYSTEM_PROMPT"], "You are helpful", "System prompt should be in env") // CLAUDE_SYSTEM_PROMPT env var is NOT supported by Claude CLI
assert.Contains(t, bashCmd, "--append-system-prompt", "Should have append-system-prompt flag")
assert.Contains(t, bashCmd, "You are helpful", "System prompt should be in CLI args")
t.Log("✓ Command building verified") t.Log("✓ Command building verified")
} }

View file

@ -6,10 +6,15 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"os"
"path/filepath"
"strings" "strings"
"time" "time"
goujson "github.com/yaoapp/gou/json"
agentContext "github.com/yaoapp/yao/agent/context" agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n"
"github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/output/message"
infraSandbox "github.com/yaoapp/yao/sandbox" infraSandbox "github.com/yaoapp/yao/sandbox"
"github.com/yaoapp/yao/sandbox/ipc" "github.com/yaoapp/yao/sandbox/ipc"
@ -32,6 +37,8 @@ type Options struct {
ConnectorHost string ConnectorHost string
ConnectorKey string ConnectorKey string
Model string Model string
ConnectorOptions map[string]interface{} // Extra connector options (e.g., thinking, max_tokens)
Secrets map[string]string // Secrets to pass to container (e.g., GITHUB_TOKEN)
} }
// Executor implements the sandbox.Executor interface for Claude CLI // Executor implements the sandbox.Executor interface for Claude CLI
@ -40,6 +47,7 @@ type Executor struct {
containerName string containerName string
opts *Options opts *Options
workDir string workDir string
loadingMsgID string // Loading message ID for tool execution updates
} }
// NewExecutor creates a new Claude executor // NewExecutor creates a new Claude executor
@ -91,12 +99,61 @@ func NewExecutor(manager *infraSandbox.Manager, opts interface{}) (*Executor, er
}, nil }, nil
} }
// SetLoadingMsgID sets the loading message ID for tool execution updates
func (e *Executor) SetLoadingMsgID(id string) {
e.loadingMsgID = id
}
// Stream runs the Claude CLI with streaming output // Stream runs the Claude CLI with streaming output
func (e *Executor) Stream(ctx *agentContext.Context, messages []agentContext.Message, handler message.StreamFunc) (*agentContext.CompletionResponse, error) { func (e *Executor) Stream(ctx *agentContext.Context, messages []agentContext.Message, handler message.StreamFunc) (*agentContext.CompletionResponse, error) {
stdCtx := context.Background() // Create a cancellable context for this stream operation
if ctx != nil && ctx.Context != nil { // We need to handle both:
stdCtx = ctx.Context // 1. HTTP context cancellation (client disconnect)
// 2. InterruptController cancellation (user clicks "stop" button)
//
// Note on InterruptController:
// - ctx.Interrupt.Context() is only cancelled when InterruptForce && len(Messages) == 0
// - When user sends messages with the interrupt, the context is NOT cancelled
// - We use ctx.Interrupt.IsInterrupted() to check for any interrupt signal
stdCtx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
// Start a goroutine to monitor for interrupts and HTTP context cancellation
go func() {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-stdCtx.Done():
// Already cancelled, exit
return
case <-ticker.C:
// Check if there's a pending interrupt signal using Peek()
// This works even when Messages are included (which doesn't cancel the context)
if ctx != nil && ctx.Interrupt != nil {
if signal := ctx.Interrupt.Peek(); signal != nil {
cancelFunc()
return
} }
}
// Check InterruptController.IsInterrupted() (for context-cancelled interrupts)
if ctx != nil && ctx.Interrupt != nil && ctx.Interrupt.IsInterrupted() {
cancelFunc()
return
}
// Check HTTP context
if ctx != nil && ctx.Context != nil {
select {
case <-ctx.Context.Done():
cancelFunc()
return
default:
}
}
}
}
}()
// Set MCP tools for this request (dynamic, runtime configuration) // Set MCP tools for this request (dynamic, runtime configuration)
if len(e.opts.MCPTools) > 0 { if len(e.opts.MCPTools) > 0 {
@ -147,10 +204,51 @@ func (e *Executor) Stream(ctx *agentContext.Context, messages []agentContext.Mes
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to execute command: %w", err) return nil, fmt.Errorf("failed to execute command: %w", err)
} }
defer reader.Close()
// Parse streaming output // Ensure reader is closed when context is cancelled or function returns
return e.parseStream(reader, handler) // This is important for cleanup when user clicks "stop"
done := make(chan struct{})
defer func() {
close(done)
reader.Close()
}()
// Monitor for context cancellation and forcefully kill Claude CLI process
go func() {
// Also start a ticker to periodically check context status for debugging
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-stdCtx.Done():
// First, kill the Claude CLI process inside the container
// This is important because closing the reader/connection alone may not stop the process
// Use a background context since stdCtx is already cancelled
killCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Kill claude process (the Claude CLI binary)
e.manager.KillProcess(killCtx, e.containerName, "claude")
// Also close the reader to unblock any pending reads
reader.Close()
return
case <-done:
// Normal completion, nothing to do
return
case <-ticker.C:
// Periodic check - no action needed
}
}
}()
// DEBUG: Tee the reader to write raw output to a log file for debugging
debugLogPath := e.workDir + "/claude-cli-raw.log"
debugReader := e.createDebugReader(stdCtx, reader, debugLogPath)
// Parse streaming output (uses e.loadingMsgID set via SetLoadingMsgID)
return e.parseStream(ctx, debugReader, handler)
} }
// shouldSkipClaudeCLI checks if Claude CLI execution should be skipped // shouldSkipClaudeCLI checks if Claude CLI execution should be skipped
@ -204,8 +302,15 @@ func (e *Executor) startClaudeProxy(ctx context.Context) error {
return fmt.Errorf("failed to build proxy config: %w", err) return fmt.Errorf("failed to build proxy config: %w", err)
} }
// Write config to workspace // Create config directory (outside workspace for security - user can't see api_key/secrets)
configPath := e.workDir + "/.claude-proxy.json" // /tmp/.yao/ is not visible to user's file manager
configDir := "/tmp/.yao"
if _, err := e.manager.Exec(ctx, e.containerName, []string{"mkdir", "-p", configDir}, nil); err != nil {
return fmt.Errorf("failed to create config directory %s: %w", configDir, err)
}
// Write config to secure location (not in /workspace/)
configPath := configDir + "/proxy.json"
if err := e.manager.WriteFile(ctx, e.containerName, configPath, configJSON); err != nil { if err := e.manager.WriteFile(ctx, e.containerName, configPath, configJSON); err != nil {
return fmt.Errorf("failed to write config to %s: %w", configPath, err) return fmt.Errorf("failed to write config to %s: %w", configPath, err)
} }
@ -283,12 +388,62 @@ func (e *Executor) Execute(ctx *agentContext.Context, messages []agentContext.Me
return e.Stream(ctx, messages, nil) return e.Stream(ctx, messages, nil)
} }
// debugWriter wraps an io.Reader to write all data to a debug log file
type debugWriter struct {
reader io.Reader
logFile *os.File
buffer []byte
}
func (d *debugWriter) Read(p []byte) (n int, err error) {
n, err = d.reader.Read(p)
if n > 0 && d.logFile != nil {
// Write raw bytes to log file
d.logFile.Write(p[:n])
d.logFile.Sync()
}
return n, err
}
func (d *debugWriter) Close() error {
if d.logFile != nil {
d.logFile.Close()
}
return nil
}
// createDebugReader creates a tee reader that writes to a debug log file
// The log file is written to the container's workspace for inspection
func (e *Executor) createDebugReader(ctx context.Context, reader io.ReadCloser, logPath string) io.Reader {
// Create a local temp file for debug logging
// We write to a local file first, then copy to container when done
localLogPath := "/tmp/claude-cli-debug-" + e.containerName + ".log"
logFile, err := os.Create(localLogPath)
if err != nil {
return reader
}
// Write header
logFile.WriteString("=== Claude CLI Raw Output Debug Log ===\n")
logFile.WriteString(fmt.Sprintf("Container: %s\n", e.containerName))
logFile.WriteString(fmt.Sprintf("Time: %s\n", time.Now().Format(time.RFC3339)))
logFile.WriteString(fmt.Sprintf("WorkDir: %s\n", e.workDir))
logFile.WriteString("=== BEGIN OUTPUT ===\n")
logFile.Sync()
return &debugWriter{
reader: reader,
logFile: logFile,
}
}
// parseStream parses Claude CLI streaming output (stream-json format) // parseStream parses Claude CLI streaming output (stream-json format)
// Claude CLI output format: // Claude CLI output format with --include-partial-messages:
// - {"type":"system","subtype":"init",...} - initialization // - {"type":"system","subtype":"init",...} - initialization
// - {"type":"assistant","message":{...,"content":[{"type":"text","text":"..."}],...}} - assistant messages // - {"type":"stream_event","event":{"delta":{"type":"text_delta","text":"..."}}} - real-time text deltas
// - {"type":"assistant","message":{...,"content":[{"type":"text","text":"..."}],...}} - complete messages
// - {"type":"result","subtype":"success",...,"result":"..."} - final result // - {"type":"result","subtype":"success",...,"result":"..."} - final result
func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*agentContext.CompletionResponse, error) { func (e *Executor) parseStream(ctx *agentContext.Context, reader io.Reader, handler message.StreamFunc) (*agentContext.CompletionResponse, error) {
scanner := bufio.NewScanner(reader) scanner := bufio.NewScanner(reader)
// Increase buffer size for potentially large outputs // Increase buffer size for potentially large outputs
buf := make([]byte, 0, 64*1024) buf := make([]byte, 0, 64*1024)
@ -300,9 +455,57 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a
var usage *message.UsageInfo var usage *message.UsageInfo
var finalResult string var finalResult string
messageStarted := false // Track if we've sent ChunkMessageStart messageStarted := false // Track if we've sent ChunkMessageStart
prepLoadingClosed := false // Track if "preparing sandbox" loading has been closed
// Tool input accumulation state
type toolState struct {
name string
index int
inputJSON strings.Builder
loadingID string // Each tool has its own loading message
}
var currentTool *toolState
var lastToolLoadingID string // Track the last tool loading ID to close it
// Helper function to close "preparing sandbox" loading on first output
closePrepLoading := func() {
if !prepLoadingClosed && e.loadingMsgID != "" && ctx != nil {
doneMsg := &message.Message{
MessageID: e.loadingMsgID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: message.TypeLoading,
Props: map[string]interface{}{
"message": "",
"done": true,
},
}
ctx.Send(doneMsg)
prepLoadingClosed = true
}
}
lineCount := 0
// Get the underlying context for cancellation checks
var stdCtx context.Context
if ctx != nil && ctx.Context != nil {
stdCtx = ctx.Context
} else {
stdCtx = context.Background()
}
for scanner.Scan() { for scanner.Scan() {
// Check for context cancellation on each iteration
select {
case <-stdCtx.Done():
return nil, stdCtx.Err()
default:
// Continue processing
}
line := scanner.Text() line := scanner.Text()
lineCount++
if line == "" { if line == "" {
continue continue
} }
@ -326,10 +529,137 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a
model = m model = m
} }
case "stream_event":
// Real-time streaming event (from --include-partial-messages)
// Format: {"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"..."}}}
if event, ok := msg["event"].(map[string]interface{}); ok {
eventType, _ := event["type"].(string)
switch eventType {
case "content_block_start":
// Check if this is a tool_use block starting
// Format: {"event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"...","name":"Write","input":{}}}}
if contentBlock, ok := event["content_block"].(map[string]interface{}); ok {
blockType, _ := contentBlock["type"].(string)
if blockType == "tool_use" {
toolName, _ := contentBlock["name"].(string)
blockIndex := 0
if idx, ok := event["index"].(float64); ok {
blockIndex = int(idx)
}
if toolName != "" && ctx != nil {
// Close "preparing sandbox" loading on first tool
closePrepLoading()
// Close previous tool loading if exists
if lastToolLoadingID != "" {
doneMsg := &message.Message{
MessageID: lastToolLoadingID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: message.TypeLoading,
Props: map[string]interface{}{
"message": "",
"done": true,
},
}
ctx.Send(doneMsg)
}
// Create new loading message for this tool
locale := ctx.Locale
toolLoadingMsg := &message.Message{
Type: message.TypeLoading,
Props: map[string]interface{}{
"message": getToolDescription(toolName, locale),
},
}
newLoadingID, _ := ctx.SendStream(toolLoadingMsg)
// Initialize tool state for input accumulation
currentTool = &toolState{
name: toolName,
index: blockIndex,
loadingID: newLoadingID,
}
lastToolLoadingID = newLoadingID
log.Printf("[Sandbox] Tool started: %s", toolName)
}
}
}
case "content_block_delta":
if delta, ok := event["delta"].(map[string]interface{}); ok {
deltaType, _ := delta["type"].(string)
switch deltaType {
case "text_delta":
if text, ok := delta["text"].(string); ok && text != "" {
// Close "preparing sandbox" loading on first text output
closePrepLoading()
// Send to stream handler for real-time output
if handler != nil {
// Send ChunkMessageStart first if not already started
if !messageStarted {
startData := message.EventMessageStartData{
MessageID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()),
Type: "text",
Timestamp: time.Now().UnixMilli(),
}
startDataJSON, _ := json.Marshal(startData)
handler(message.ChunkMessageStart, startDataJSON)
messageStarted = true
}
handler(message.ChunkText, []byte(text))
}
// Also accumulate for final response
textContent.WriteString(text)
}
case "input_json_delta":
// Accumulate tool input JSON fragments
if currentTool != nil {
if partialJSON, ok := delta["partial_json"].(string); ok {
currentTool.inputJSON.WriteString(partialJSON)
}
}
}
}
case "content_block_stop":
// Tool input complete - parse and update loading with detailed info
if currentTool != nil && currentTool.loadingID != "" && ctx != nil {
inputStr := currentTool.inputJSON.String()
if inputStr != "" {
// Use gou/json.Parse for fault-tolerant parsing
locale := ctx.Locale
detailedMsg := getToolDetailedDescription(currentTool.name, inputStr, locale)
if detailedMsg != "" {
toolMsg := &message.Message{
MessageID: currentTool.loadingID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: message.TypeLoading,
Props: map[string]interface{}{
"message": detailedMsg,
},
}
ctx.Send(toolMsg)
log.Printf("[Sandbox] Tool: %s -> %s", currentTool.name, detailedMsg)
}
}
// Note: Don't close loading here - it will be closed when next tool starts or at end
// Reset tool state but keep lastToolLoadingID to close it later
currentTool = nil
}
}
}
case "assistant": case "assistant":
// Assistant message - extract content // Assistant message - extract content
// Note: Claude CLI sends multiple assistant messages, each with cumulative content. // With --include-partial-messages, we receive real-time text via stream_event
// We only process the final one (with stop_reason) to avoid duplicates. // The assistant message contains the full accumulated content
if msgData, ok := msg["message"].(map[string]interface{}); ok { if msgData, ok := msg["message"].(map[string]interface{}); ok {
// Get model from message // Get model from message
if m, ok := msgData["model"].(string); ok && model == "" { if m, ok := msgData["model"].(string); ok && model == "" {
@ -340,7 +670,8 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a
stopReason, hasStopReason := msgData["stop_reason"].(string) stopReason, hasStopReason := msgData["stop_reason"].(string)
isFinalMessage := hasStopReason && stopReason != "" isFinalMessage := hasStopReason && stopReason != ""
// Extract content array - only from final message to avoid duplicates // Extract content from final message
// This serves as a fallback if stream_event wasn't received
if isFinalMessage { if isFinalMessage {
if contentArr, ok := msgData["content"].([]interface{}); ok { if contentArr, ok := msgData["content"].([]interface{}); ok {
for _, item := range contentArr { for _, item := range contentArr {
@ -349,12 +680,12 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a
switch itemType { switch itemType {
case "text": case "text":
if text, ok := contentItem["text"].(string); ok { // Only use this if we haven't already accumulated text from stream_event
if textContent.Len() == 0 {
if text, ok := contentItem["text"].(string); ok && text != "" {
textContent.WriteString(text) textContent.WriteString(text)
// Send to stream handler if available // Send to stream handler if available
if handler != nil { if handler != nil {
// Send ChunkMessageStart first if not already started
// This initializes the stream state (inGroup=true) required for Buffer.AddAssistantMessage
if !messageStarted { if !messageStarted {
startData := message.EventMessageStartData{ startData := message.EventMessageStartData{
MessageID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()), MessageID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()),
@ -368,22 +699,60 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a
handler(message.ChunkText, []byte(text)) handler(message.ChunkText, []byte(text))
} }
} }
}
case "tool_use": case "tool_use":
toolName := getString(contentItem, "name")
toolCall := agentContext.ToolCall{ toolCall := agentContext.ToolCall{
ID: getString(contentItem, "id"), ID: getString(contentItem, "id"),
Type: agentContext.ToolTypeFunction, Type: agentContext.ToolTypeFunction,
Function: agentContext.Function{ Function: agentContext.Function{
Name: getString(contentItem, "name"), Name: toolName,
}, },
} }
// Get input as JSON string // Get input as JSON string
var inputJSONStr string
if input, ok := contentItem["input"]; ok { if input, ok := contentItem["input"]; ok {
if inputJSON, err := json.Marshal(input); err == nil { if inputJSON, err := json.Marshal(input); err == nil {
toolCall.Function.Arguments = string(inputJSON) inputJSONStr = string(inputJSON)
toolCall.Function.Arguments = inputJSONStr
} }
} }
toolCalls = append(toolCalls, toolCall) toolCalls = append(toolCalls, toolCall)
// Create tool loading message (from complete assistant message)
// This is a fallback for when stream_event wasn't received
if toolName != "" && ctx != nil {
// Close previous tool loading if exists
if lastToolLoadingID != "" {
doneMsg := &message.Message{
MessageID: lastToolLoadingID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: message.TypeLoading,
Props: map[string]interface{}{
"message": "",
"done": true,
},
}
ctx.Send(doneMsg)
}
// Create new loading for this tool
locale := ctx.Locale
detailedMsg := getToolDetailedDescription(toolName, inputJSONStr, locale)
if detailedMsg != "" {
toolLoadingMsg := &message.Message{
Type: message.TypeLoading,
Props: map[string]interface{}{
"message": detailedMsg,
},
}
newLoadingID, _ := ctx.SendStream(toolLoadingMsg)
lastToolLoadingID = newLoadingID
log.Printf("[Sandbox] Tool: %s -> %s", toolName, detailedMsg)
}
}
} }
} }
} }
@ -405,11 +774,17 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a
case "result": case "result":
// Final result message // Final result message
// Check if this is an error result (is_error: true)
isError, _ := msg["is_error"].(bool)
if result, ok := msg["result"].(string); ok { if result, ok := msg["result"].(string); ok {
if isError {
// This is an error - return it as an error
return nil, fmt.Errorf("Claude CLI error: %s", result)
}
finalResult = result finalResult = result
} }
// Send done signal to handler (only if message was started) // Send done signal to handler (only if message was started and not an error)
if handler != nil && messageStarted { if handler != nil && messageStarted && !isError {
handler(message.ChunkMessageEnd, nil) handler(message.ChunkMessageEnd, nil)
} }
@ -430,6 +805,21 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a
return nil, fmt.Errorf("error reading stream: %w", err) return nil, fmt.Errorf("error reading stream: %w", err)
} }
// Close the last tool loading message if exists
if lastToolLoadingID != "" && ctx != nil {
doneMsg := &message.Message{
MessageID: lastToolLoadingID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: message.TypeLoading,
Props: map[string]interface{}{
"message": "",
"done": true,
},
}
ctx.Send(doneMsg)
}
// Use final result if available, otherwise use accumulated text content // Use final result if available, otherwise use accumulated text content
content := textContent.String() content := textContent.String()
if finalResult != "" && content == "" { if finalResult != "" && content == "" {
@ -460,6 +850,161 @@ func (e *Executor) parseStream(reader io.Reader, handler message.StreamFunc) (*a
return response, nil return response, nil
} }
// truncateStr truncates a string to maxLen characters
func truncateStr(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
// getToolDescription returns a human-readable, localized description for a Claude CLI tool
func getToolDescription(toolName string, locale string) string {
// Map tool names to i18n keys
toolKeys := map[string]string{
"Read": "sandbox.tool.read",
"Write": "sandbox.tool.write",
"Edit": "sandbox.tool.edit",
"StrReplace": "sandbox.tool.edit",
"Bash": "sandbox.tool.bash",
"Shell": "sandbox.tool.bash",
"Glob": "sandbox.tool.glob",
"Grep": "sandbox.tool.grep",
"LS": "sandbox.tool.ls",
"Task": "sandbox.tool.task",
"WebSearch": "sandbox.tool.web_search",
"WebFetch": "sandbox.tool.web_fetch",
"TodoWrite": "sandbox.tool.todo_write",
"AskQuestion": "sandbox.tool.ask_question",
"SwitchMode": "sandbox.tool.switch_mode",
"ReadLints": "sandbox.tool.read_lints",
"EditNotebook": "sandbox.tool.edit_notebook",
}
if key, ok := toolKeys[toolName]; ok {
return i18n.T(locale, key)
}
// For unknown tools, use the unknown key and replace {{name}} manually
template := i18n.T(locale, "sandbox.tool.unknown")
return strings.Replace(template, "{{name}}", toolName, 1)
}
// getToolDetailedDescription returns a detailed description with specific parameters
// It parses the tool input JSON and extracts key information to show users
func getToolDetailedDescription(toolName string, inputJSON string, locale string) string {
// Parse the input JSON using fault-tolerant parser
parsed, err := goujson.Parse(inputJSON)
if err != nil {
// Fall back to basic description if parsing fails
return getToolDescription(toolName, locale)
}
input, ok := parsed.(map[string]interface{})
if !ok {
return getToolDescription(toolName, locale)
}
// Extract key information based on tool type
var detail string
switch toolName {
case "Bash", "Shell":
// Show the command being executed
if cmd, ok := input["command"].(string); ok && cmd != "" {
// Truncate long commands
if len(cmd) > 50 {
cmd = cmd[:47] + "..."
}
detail = cmd
}
case "Read":
// Show the file being read
if path, ok := input["path"].(string); ok && path != "" {
detail = filepath.Base(path)
}
case "Write":
// Show the file being written
// Note: Claude CLI uses "file_path" for Write tool, not "path"
if path, ok := input["file_path"].(string); ok && path != "" {
detail = filepath.Base(path)
} else if path, ok := input["path"].(string); ok && path != "" {
detail = filepath.Base(path)
}
case "Edit", "StrReplace":
// Show the file being edited
if path, ok := input["path"].(string); ok && path != "" {
detail = filepath.Base(path)
}
case "Glob":
// Show the glob pattern
if pattern, ok := input["glob_pattern"].(string); ok && pattern != "" {
detail = pattern
} else if pattern, ok := input["pattern"].(string); ok && pattern != "" {
detail = pattern
}
case "Grep":
// Show the search pattern
if pattern, ok := input["pattern"].(string); ok && pattern != "" {
if len(pattern) > 30 {
pattern = pattern[:27] + "..."
}
detail = pattern
}
case "LS":
// Show the directory
if path, ok := input["target_directory"].(string); ok && path != "" {
detail = filepath.Base(path)
} else if path, ok := input["path"].(string); ok && path != "" {
detail = filepath.Base(path)
}
case "WebSearch":
// Show the search query
if query, ok := input["search_term"].(string); ok && query != "" {
if len(query) > 40 {
query = query[:37] + "..."
}
detail = query
} else if query, ok := input["query"].(string); ok && query != "" {
if len(query) > 40 {
query = query[:37] + "..."
}
detail = query
}
case "WebFetch":
// Show the URL
if url, ok := input["url"].(string); ok && url != "" {
// Extract domain from URL
if len(url) > 50 {
url = url[:47] + "..."
}
detail = url
}
case "Task":
// Show the task description
if desc, ok := input["description"].(string); ok && desc != "" {
if len(desc) > 40 {
desc = desc[:37] + "..."
}
detail = desc
}
}
// Build the message with detail
baseMsg := getToolDescription(toolName, locale)
if detail != "" {
return baseMsg + ": " + detail
}
return baseMsg
}
// ReadFile reads a file from the container // ReadFile reads a file from the container
func (e *Executor) ReadFile(ctx context.Context, path string) ([]byte, error) { func (e *Executor) ReadFile(ctx context.Context, path string) ([]byte, error) {
// Make path absolute if not // Make path absolute if not

View file

@ -41,6 +41,8 @@ func New(manager *infraSandbox.Manager, opts *Options) (Executor, error) {
ConnectorHost: opts.ConnectorHost, ConnectorHost: opts.ConnectorHost,
ConnectorKey: opts.ConnectorKey, ConnectorKey: opts.ConnectorKey,
Model: opts.Model, Model: opts.Model,
ConnectorOptions: opts.ConnectorOptions, // Extra options like thinking, max_tokens
Secrets: opts.Secrets, // Secrets for container env vars
} }
return claude.NewExecutor(manager, claudeOpts) return claude.NewExecutor(manager, claudeOpts)
case "cursor": case "cursor":

View file

@ -18,6 +18,9 @@ type Executor interface {
// Stream runs the request with streaming output (uses options set at creation time) // Stream runs the request with streaming output (uses options set at creation time)
Stream(ctx *agentContext.Context, messages []agentContext.Message, handler message.StreamFunc) (*agentContext.CompletionResponse, error) Stream(ctx *agentContext.Context, messages []agentContext.Message, handler message.StreamFunc) (*agentContext.CompletionResponse, error)
// SetLoadingMsgID sets the loading message ID for tool execution status updates
SetLoadingMsgID(id string)
// Filesystem operations (for Hooks) // Filesystem operations (for Hooks)
ReadFile(ctx context.Context, path string) ([]byte, error) ReadFile(ctx context.Context, path string) ([]byte, error)
WriteFile(ctx context.Context, path string, content []byte) error WriteFile(ctx context.Context, path string, content []byte) error
@ -83,6 +86,14 @@ type Options struct {
ConnectorHost string `json:"-"` ConnectorHost string `json:"-"`
ConnectorKey string `json:"-"` ConnectorKey string `json:"-"`
Model string `json:"-"` Model string `json:"-"`
// ConnectorOptions - extra options from connector config (e.g., thinking, max_tokens, temperature)
// These are backend-specific parameters passed to the proxy
ConnectorOptions map[string]interface{} `json:"-"`
// Secrets - sensitive values from sandbox.secrets config (e.g., GITHUB_TOKEN)
// Resolved from $ENV.XXX references, exported as env vars in container
Secrets map[string]string `json:"-"`
} }
// SandboxConfig represents the sandbox configuration in assistant package.yao // SandboxConfig represents the sandbox configuration in assistant package.yao

View file

@ -370,6 +370,7 @@ type Sandbox struct {
MaxCPU float64 `json:"max_cpu,omitempty"` // CPU limit (e.g., 2.0) MaxCPU float64 `json:"max_cpu,omitempty"` // CPU limit (e.g., 2.0)
Timeout string `json:"timeout,omitempty"` // Execution timeout (e.g., "10m") Timeout string `json:"timeout,omitempty"` // Execution timeout (e.g., "10m")
Arguments map[string]interface{} `json:"arguments,omitempty"` // Command-specific arguments Arguments map[string]interface{} `json:"arguments,omitempty"` // Command-specific arguments
Secrets map[string]string `json:"secrets,omitempty"` // Secrets to pass to container (e.g., GITHUB_TOKEN: "$ENV.GITHUB_TOKEN")
} }
// Tool represents a tool configuration for storage // Tool represents a tool configuration for storage

View file

@ -25,6 +25,14 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \ && apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# GitHub CLI (gh) for repository operations
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& apt-get update \
&& apt-get install -y gh \
&& rm -rf /var/lib/apt/lists/*
# npm global packages directory for sandbox user # npm global packages directory for sandbox user
RUN mkdir -p /home/sandbox/.npm-global && \ RUN mkdir -p /home/sandbox/.npm-global && \
chown -R sandbox:sandbox /home/sandbox/.npm-global chown -R sandbox:sandbox /home/sandbox/.npm-global
@ -39,6 +47,21 @@ ENV PATH="/home/sandbox/.npm-global/bin:${PATH}"
RUN npm install -g @anthropic-ai/claude-code || \ RUN npm install -g @anthropic-ai/claude-code || \
echo "Claude CLI installation skipped (may not be available yet)" echo "Claude CLI installation skipped (may not be available yet)"
# Create Claude CLI configuration for auto-approve all operations
# This is CRITICAL for non-interactive sandbox usage
# The --dangerously-skip-permissions flag alone is not enough;
# we also need the settings.json to fully bypass permission prompts
RUN mkdir -p /home/sandbox/.claude && \
cat > /home/sandbox/.claude/settings.json << 'EOF'
{
"permissions": {
"defaultMode": "bypassPermissions",
"allow": ["*"],
"deny": []
}
}
EOF
USER root USER root
# Install claude-proxy (architecture-specific binary) # Install claude-proxy (architecture-specific binary)
@ -140,14 +163,14 @@ SCRIPT
RUN chmod +x /usr/local/bin/claude-run RUN chmod +x /usr/local/bin/claude-run
# Create start-claude-proxy script for programmatic use (called by Yao) # Create start-claude-proxy script for programmatic use (called by Yao)
# This reads proxy config from /workspace/.claude-proxy.json if exists # Config is read from /tmp/.yao/proxy.json (NOT /workspace/ for security - api_key/secrets hidden from user)
RUN cat > /usr/local/bin/start-claude-proxy << 'SCRIPT' RUN cat > /usr/local/bin/start-claude-proxy << 'SCRIPT'
#!/bin/bash #!/bin/bash
# Start claude-proxy from config file or environment variables # Start claude-proxy from config file or environment variables
# Config file: /workspace/.claude-proxy.json # Config file: /tmp/.yao/proxy.json (secure location, not visible to user file manager)
# Format: {"backend": "...", "api_key": "...", "model": "..."} # Format: {"backend": "...", "api_key": "...", "model": "...", "options": {...}, "secrets": {...}}
CONFIG_FILE="${WORKSPACE:-/workspace}/.claude-proxy.json" CONFIG_FILE="/tmp/.yao/proxy.json"
LOG_FILE="${WORKSPACE:-/workspace}/proxy.log" LOG_FILE="${WORKSPACE:-/workspace}/proxy.log"
PORT="${CLAUDE_PROXY_PORT:-3456}" PORT="${CLAUDE_PROXY_PORT:-3456}"
@ -156,6 +179,8 @@ if [ -f "$CONFIG_FILE" ]; then
BACKEND=$(jq -r '.backend // empty' "$CONFIG_FILE" 2>/dev/null) BACKEND=$(jq -r '.backend // empty' "$CONFIG_FILE" 2>/dev/null)
API_KEY=$(jq -r '.api_key // empty' "$CONFIG_FILE" 2>/dev/null) API_KEY=$(jq -r '.api_key // empty' "$CONFIG_FILE" 2>/dev/null)
MODEL=$(jq -r '.model // empty' "$CONFIG_FILE" 2>/dev/null) MODEL=$(jq -r '.model // empty' "$CONFIG_FILE" 2>/dev/null)
# Read extra options as JSON string (e.g., {"thinking":{"type":"enabled"}})
OPTIONS=$(jq -c '.options // empty' "$CONFIG_FILE" 2>/dev/null)
if [ -n "$BACKEND" ]; then if [ -n "$BACKEND" ]; then
export CLAUDE_PROXY_BACKEND="$BACKEND" export CLAUDE_PROXY_BACKEND="$BACKEND"
@ -166,6 +191,23 @@ if [ -f "$CONFIG_FILE" ]; then
if [ -n "$MODEL" ]; then if [ -n "$MODEL" ]; then
export CLAUDE_PROXY_MODEL="$MODEL" export CLAUDE_PROXY_MODEL="$MODEL"
fi fi
# Only set options if it's a valid non-empty JSON object
if [ -n "$OPTIONS" ] && [ "$OPTIONS" != "null" ] && [ "$OPTIONS" != "" ]; then
export CLAUDE_PROXY_OPTIONS="$OPTIONS"
fi
# Export secrets as environment variables for Claude CLI to use
# e.g., {"GITHUB_TOKEN": "ghp_xxx"} -> export GITHUB_TOKEN=ghp_xxx
SECRETS=$(jq -c '.secrets // empty' "$CONFIG_FILE" 2>/dev/null)
if [ -n "$SECRETS" ] && [ "$SECRETS" != "null" ] && [ "$SECRETS" != "" ] && [ "$SECRETS" != "{}" ]; then
# Parse each key-value pair and export
for key in $(echo "$SECRETS" | jq -r 'keys[]' 2>/dev/null); do
value=$(echo "$SECRETS" | jq -r --arg k "$key" '.[$k]' 2>/dev/null)
if [ -n "$value" ] && [ "$value" != "null" ]; then
export "$key"="$value"
fi
done
fi
fi fi
# Check if we have the required config # Check if we have the required config
@ -181,9 +223,14 @@ if curl -s "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then
exit 0 exit 0
fi fi
# Start proxy # Start proxy with environment variables explicitly passed
mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true
nohup /usr/local/bin/claude-proxy -v -l "$LOG_FILE" > /dev/null 2>&1 & nohup env \
CLAUDE_PROXY_BACKEND="$CLAUDE_PROXY_BACKEND" \
CLAUDE_PROXY_API_KEY="$CLAUDE_PROXY_API_KEY" \
CLAUDE_PROXY_MODEL="$CLAUDE_PROXY_MODEL" \
CLAUDE_PROXY_OPTIONS="$CLAUDE_PROXY_OPTIONS" \
/usr/local/bin/claude-proxy -v -l "$LOG_FILE" > /dev/null 2>&1 &
# Wait for startup # Wait for startup
for i in {1..20}; do for i in {1..20}; do

View file

@ -42,6 +42,8 @@ type demuxReadCloser struct {
closer io.Closer closer io.Closer
done chan struct{} done chan struct{}
err error err error
closed bool
mu sync.Mutex
} }
// newDemuxReadCloser creates a new demuxed reader from Docker multiplexed stream // newDemuxReadCloser creates a new demuxed reader from Docker multiplexed stream
@ -60,11 +62,20 @@ func newDemuxReadCloser(src io.Reader, closer io.Closer) *demuxReadCloser {
defer close(d.done) defer close(d.done)
defer pw.Close() defer pw.Close()
fmt.Printf("[DEBUG demux] Starting stdcopy.StdCopy\n")
startTime := time.Now()
// Use stdcopy to demux stdout and stderr // Use stdcopy to demux stdout and stderr
// We only care about stdout here, stderr goes to a discard writer // We only care about stdout here, stderr goes to a discard writer
_, err := stdcopy.StdCopy(pw, io.Discard, src) n, err := stdcopy.StdCopy(pw, io.Discard, src)
elapsed := time.Since(startTime)
fmt.Printf("[DEBUG demux] stdcopy.StdCopy returned: bytes=%d, err=%v, elapsed=%v\n", n, err, elapsed)
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
d.mu.Lock()
d.err = err d.err = err
d.mu.Unlock()
} }
}() }()
@ -76,15 +87,39 @@ func (d *demuxReadCloser) Read(p []byte) (int, error) {
} }
func (d *demuxReadCloser) Close() error { func (d *demuxReadCloser) Close() error {
// Close the source to stop the demux goroutine d.mu.Lock()
if d.closed {
d.mu.Unlock()
return nil
}
d.closed = true
d.mu.Unlock()
// Close the pipe writer first to signal EOF to any readers
// This will cause pipeReader.Read() to return io.EOF
d.pipeWriter.CloseWithError(io.EOF)
// Close the source connection to interrupt stdcopy.StdCopy
if d.closer != nil { if d.closer != nil {
d.closer.Close() d.closer.Close()
} }
// Close the pipe reader to unblock any pending reads // Close the pipe reader to unblock any pending reads
d.pipeReader.Close() d.pipeReader.Close()
// Wait for demux goroutine to finish
<-d.done // Wait for demux goroutine to finish with a timeout
return d.err // Don't block forever if stdcopy.StdCopy is stuck
select {
case <-d.done:
// Normal completion
case <-time.After(5 * time.Second):
fmt.Printf("[DEBUG demux] Timeout waiting for demux goroutine to finish\n")
}
d.mu.Lock()
err := d.err
d.mu.Unlock()
return err
} }
// Manager manages sandbox containers // Manager manages sandbox containers
@ -562,6 +597,39 @@ func (m *Manager) Remove(ctx context.Context, name string) error {
return nil return nil
} }
// KillProcess kills a process inside the container by name pattern
// This is used to forcefully stop long-running processes like Claude CLI
func (m *Manager) KillProcess(ctx context.Context, name string, processPattern string) error {
c, ok := m.containers.Load(name)
if !ok {
return ErrContainerNotFound
}
cont := c.(*Container)
// Use pkill to kill processes matching the pattern
// -f matches against the full command line
// Use SIGKILL (-9) to ensure the process is killed immediately
cmd := []string{"pkill", "-9", "-f", processPattern}
execConfig := container.ExecOptions{
Cmd: cmd,
AttachStdout: true,
AttachStderr: true,
}
execResp, err := m.dockerClient.ContainerExecCreate(ctx, cont.ID, execConfig)
if err != nil {
return fmt.Errorf("failed to create exec for kill: %w", err)
}
// Start the exec
if err := m.dockerClient.ContainerExecStart(ctx, execResp.ID, container.ExecStartOptions{}); err != nil {
return fmt.Errorf("failed to start exec for kill: %w", err)
}
return nil
}
// List returns all containers for a user // List returns all containers for a user
func (m *Manager) List(ctx context.Context, userID string) ([]*Container, error) { func (m *Manager) List(ctx context.Context, userID string) ([]*Container, error) {
var result []*Container var result []*Container

View file

@ -154,7 +154,7 @@ claude --dangerously-skip-permissions "your task"
### Option Reference ### Option Reference
| Option | Description | | Option | Description |
|--------|-------------| | ------------------------------------- | ----------------------------------------- |
| `-p, --print` | Print mode, exit after output | | `-p, --print` | Print mode, exit after output |
| `--dangerously-skip-permissions` | Skip all permission checks | | `--dangerously-skip-permissions` | Skip all permission checks |
| `--permission-mode bypassPermissions` | Bypass permission mode | | `--permission-mode bypassPermissions` | Bypass permission mode |
@ -174,7 +174,7 @@ curl http://127.0.0.1:3456/health
## Supported Backends ## Supported Backends
| Backend | API URL | | Backend | API URL |
|---------|---------| | ---------------------------- | ----------------------------------------------------------- |
| Volcengine GLM | `https://ark.cn-beijing.volces.com/api/v3/chat/completions` | | Volcengine GLM | `https://ark.cn-beijing.volces.com/api/v3/chat/completions` |
| Volcengine DeepSeek | `https://ark.cn-beijing.volces.com/api/v3/chat/completions` | | Volcengine DeepSeek | `https://ark.cn-beijing.volces.com/api/v3/chat/completions` |
| DeepSeek Official | `https://api.deepseek.com/chat/completions` | | DeepSeek Official | `https://api.deepseek.com/chat/completions` |

View file

@ -3,25 +3,58 @@ package proxy
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
) )
// convertRequest converts an Anthropic request to OpenAI format // convertRequest converts an Anthropic request to OpenAI format
func (s *Server) convertRequest(req *AnthropicRequest) *OpenAIRequest { func (s *Server) convertRequest(req *AnthropicRequest) *OpenAIRequest {
// Limit max_tokens to backend's maximum (most models support 16384) // Get max_tokens from options if specified, otherwise use request value
maxTokens := req.MaxTokens maxTokens := req.MaxTokens
if maxTokens > 16384 { if s.config.Options != nil {
maxTokens = 16384 if mt, ok := s.config.Options["max_tokens"]; ok {
switch v := mt.(type) {
case float64:
maxTokens = int(v)
case int:
maxTokens = v
}
}
}
// Get temperature from options if specified
temperature := req.Temperature
if s.config.Options != nil {
if temp, ok := s.config.Options["temperature"]; ok {
if v, ok := temp.(float64); ok {
temperature = &v
}
}
} }
openaiReq := &OpenAIRequest{ openaiReq := &OpenAIRequest{
Model: s.config.Model, Model: s.config.Model,
MaxTokens: maxTokens, MaxTokens: maxTokens,
Stream: req.Stream, Stream: req.Stream,
Temperature: req.Temperature, Temperature: temperature,
TopP: req.TopP, TopP: req.TopP,
Stop: req.StopSequences, Stop: req.StopSequences,
} }
// Pass through extra options (e.g., thinking, reasoning_effort, etc.)
// These are backend-specific parameters that will be merged into the request
if s.config.Options != nil {
openaiReq.ExtraOptions = make(map[string]interface{})
for k, v := range s.config.Options {
// Skip standard fields that are already handled
switch k {
case "max_tokens", "temperature", "model", "key", "proxy":
continue
default:
openaiReq.ExtraOptions[k] = v
}
}
}
// Convert messages // Convert messages
openaiReq.Messages = s.convertMessages(req.Messages, req.System) openaiReq.Messages = s.convertMessages(req.Messages, req.System)
@ -302,12 +335,17 @@ func extractSystemText(system interface{}) string {
for _, item := range s { for _, item := range s {
if block, ok := item.(map[string]interface{}); ok { if block, ok := item.(map[string]interface{}); ok {
if text, ok := block["text"].(string); ok { if text, ok := block["text"].(string); ok {
// Skip billing headers and other metadata
if strings.HasPrefix(text, "x-anthropic-") {
continue
}
texts = append(texts, text) texts = append(texts, text)
} }
} }
} }
// Concatenate all system texts with newlines
if len(texts) > 0 { if len(texts) > 0 {
return texts[0] // Return first system text return strings.Join(texts, "\n\n")
} }
} }
return "" return ""

View file

@ -26,6 +26,7 @@ type Config struct {
Timeout int Timeout int
Verbose bool Verbose bool
LogFile string LogFile string
Options map[string]interface{} // Extra options to pass to backend (e.g., thinking, max_tokens)
} }
// Server is the API proxy server // Server is the API proxy server
@ -58,6 +59,10 @@ func Main() {
log.Printf("Claude API Proxy starting on %s", addr) log.Printf("Claude API Proxy starting on %s", addr)
log.Printf("Backend: %s", config.Backend) log.Printf("Backend: %s", config.Backend)
log.Printf("Model: %s", config.Model) log.Printf("Model: %s", config.Model)
if len(config.Options) > 0 {
optBytes, _ := json.Marshal(config.Options)
log.Printf("Options: %s", string(optBytes))
}
http.HandleFunc("/v1/messages", server.handleMessages) http.HandleFunc("/v1/messages", server.handleMessages)
http.HandleFunc("/health", server.handleHealth) http.HandleFunc("/health", server.handleHealth)
@ -118,6 +123,17 @@ func parseFlags() *Config {
config.Timeout = 300 config.Timeout = 300
} }
// Parse extra options from environment variable (JSON format)
// Example: CLAUDE_PROXY_OPTIONS='{"thinking":{"type":"enabled"},"max_tokens":65536}'
if optionsStr := os.Getenv("CLAUDE_PROXY_OPTIONS"); optionsStr != "" {
var options map[string]interface{}
if err := json.Unmarshal([]byte(optionsStr), &options); err != nil {
log.Printf("Warning: failed to parse CLAUDE_PROXY_OPTIONS: %v", err)
} else {
config.Options = options
}
}
return config return config
} }

View file

@ -1,5 +1,7 @@
package proxy package proxy
import "encoding/json"
// ============================================ // ============================================
// Anthropic API Types // Anthropic API Types
// ============================================ // ============================================
@ -126,6 +128,54 @@ type OpenAIRequest struct {
Stop []string `json:"stop,omitempty"` Stop []string `json:"stop,omitempty"`
Tools []OpenAITool `json:"tools,omitempty"` Tools []OpenAITool `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"` // "auto", "none", "required", or object ToolChoice interface{} `json:"tool_choice,omitempty"` // "auto", "none", "required", or object
// Extra options for backend-specific parameters (e.g., thinking for GLM-4)
// These are merged into the final JSON request
ExtraOptions map[string]interface{} `json:"-"`
}
// MarshalJSON custom marshaler to merge ExtraOptions into the request
func (r OpenAIRequest) MarshalJSON() ([]byte, error) {
// Create a map with standard fields
m := map[string]interface{}{
"model": r.Model,
"messages": r.Messages,
}
if r.MaxTokens > 0 {
m["max_tokens"] = r.MaxTokens
}
if r.Stream {
m["stream"] = r.Stream
}
if r.StreamOptions != nil {
m["stream_options"] = r.StreamOptions
}
if r.Temperature != nil {
m["temperature"] = *r.Temperature
}
if r.TopP != nil {
m["top_p"] = *r.TopP
}
if len(r.Stop) > 0 {
m["stop"] = r.Stop
}
if len(r.Tools) > 0 {
m["tools"] = r.Tools
}
if r.ToolChoice != nil {
m["tool_choice"] = r.ToolChoice
}
// Merge extra options (backend-specific parameters like thinking, etc.)
for k, v := range r.ExtraOptions {
// Don't override standard fields
if _, exists := m[k]; !exists {
m[k] = v
}
}
return json.Marshal(m)
} }
// StreamOptions represents stream options in OpenAI format // StreamOptions represents stream options in OpenAI format