- 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>
287 lines
9.3 KiB
Go
287 lines
9.3 KiB
Go
package claude
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
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
|
|
// Uses stdin with --input-format stream-json for unlimited prompt length
|
|
func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map[string]string, error) {
|
|
// Build system prompt from conversation history
|
|
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)
|
|
inputJSONL, err := BuildInputJSONL(messages)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to build input JSONL: %w", err)
|
|
}
|
|
|
|
// Build Claude CLI arguments
|
|
var claudeArgs []string
|
|
|
|
// Add permission mode (required for MCP tools to work)
|
|
permMode := "bypassPermissions" // default for sandbox
|
|
if opts != nil && opts.Arguments != nil {
|
|
if mode, ok := opts.Arguments["permission_mode"].(string); ok && mode != "" {
|
|
permMode = mode
|
|
}
|
|
}
|
|
claudeArgs = append(claudeArgs, "--dangerously-skip-permissions")
|
|
claudeArgs = append(claudeArgs, "--permission-mode", permMode)
|
|
|
|
// Add streaming format flags (required for proper streaming output)
|
|
claudeArgs = append(claudeArgs, "--input-format", "stream-json")
|
|
claudeArgs = append(claudeArgs, "--output-format", "stream-json")
|
|
claudeArgs = append(claudeArgs, "--include-partial-messages") // Enable realtime streaming
|
|
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
|
|
if opts != nil && len(opts.MCPConfig) > 0 {
|
|
claudeArgs = append(claudeArgs, "--mcp-config", "/workspace/.mcp.json")
|
|
// Allow all tools from the "yao" MCP server
|
|
claudeArgs = append(claudeArgs, "--allowedTools", "mcp__yao__*")
|
|
}
|
|
|
|
// Build the full bash command
|
|
// Use heredoc for both system prompt and input JSONL to avoid shell escaping issues
|
|
// System prompt may contain quotes, newlines, special characters that break shell quoting
|
|
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 {
|
|
// Quote arguments that might contain special characters
|
|
bashCmd.WriteString(fmt.Sprintf(" %q", arg))
|
|
}
|
|
bashCmd.WriteString("\n")
|
|
bashCmd.WriteString(string(inputJSONL))
|
|
bashCmd.WriteString("\nINPUTEOF")
|
|
|
|
cmd := []string{"bash", "-c", bashCmd.String()}
|
|
|
|
// Build environment variables
|
|
env := buildEnvironment(opts, systemPrompt)
|
|
|
|
return cmd, env, nil
|
|
}
|
|
|
|
// BuildInputJSONL converts messages to Claude CLI stream-json input format
|
|
// Each message becomes a line in JSONL format
|
|
func BuildInputJSONL(messages []agentContext.Message) ([]byte, error) {
|
|
var lines []string
|
|
|
|
for _, msg := range messages {
|
|
// Skip system messages (handled via --system-prompt or env var)
|
|
if msg.Role == "system" {
|
|
continue
|
|
}
|
|
|
|
// Build the message content
|
|
var content interface{}
|
|
if msg.Content != nil {
|
|
content = msg.Content
|
|
} else {
|
|
content = ""
|
|
}
|
|
|
|
// Create stream-json message
|
|
streamMsg := map[string]interface{}{
|
|
"type": msg.Role, // "user" or "assistant"
|
|
"message": map[string]interface{}{
|
|
"role": msg.Role,
|
|
"content": content,
|
|
},
|
|
}
|
|
|
|
jsonBytes, err := json.Marshal(streamMsg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal message: %w", err)
|
|
}
|
|
lines = append(lines, string(jsonBytes))
|
|
}
|
|
|
|
return []byte(strings.Join(lines, "\n")), nil
|
|
}
|
|
|
|
// buildPrompts extracts system prompt and user prompt from messages
|
|
func buildPrompts(messages []agentContext.Message) (systemPrompt string, userPrompt string) {
|
|
var systemParts []string
|
|
var conversationParts []string
|
|
var lastUserMessage string
|
|
|
|
for _, msg := range messages {
|
|
switch msg.Role {
|
|
case "system":
|
|
systemParts = append(systemParts, getMessageContent(msg))
|
|
case "user":
|
|
lastUserMessage = getMessageContent(msg)
|
|
conversationParts = append(conversationParts, fmt.Sprintf("User: %s", lastUserMessage))
|
|
case "assistant":
|
|
conversationParts = append(conversationParts, fmt.Sprintf("Assistant: %s", getMessageContent(msg)))
|
|
}
|
|
}
|
|
|
|
// Build system prompt with conversation history
|
|
systemPrompt = strings.Join(systemParts, "\n\n")
|
|
|
|
// If there's conversation history, include it in the system prompt
|
|
if len(conversationParts) > 1 {
|
|
historySection := "\n\n## Conversation History\n\n" + strings.Join(conversationParts[:len(conversationParts)-1], "\n\n")
|
|
systemPrompt += historySection
|
|
}
|
|
|
|
// The user prompt is the last user message
|
|
userPrompt = lastUserMessage
|
|
|
|
return systemPrompt, userPrompt
|
|
}
|
|
|
|
// getMessageContent extracts text content from a message
|
|
func getMessageContent(msg agentContext.Message) string {
|
|
if msg.Content == nil {
|
|
return ""
|
|
}
|
|
|
|
// Handle string content
|
|
if str, ok := msg.Content.(string); ok {
|
|
return str
|
|
}
|
|
|
|
// Handle content array (multimodal messages)
|
|
if arr, ok := msg.Content.([]interface{}); ok {
|
|
var parts []string
|
|
for _, item := range arr {
|
|
if m, ok := item.(map[string]interface{}); ok {
|
|
if m["type"] == "text" {
|
|
if text, ok := m["text"].(string); ok {
|
|
parts = append(parts, text)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// buildEnvironment builds environment variables for Claude CLI
|
|
func buildEnvironment(opts *Options, systemPrompt string) map[string]string {
|
|
env := make(map[string]string)
|
|
|
|
if opts == nil {
|
|
return env
|
|
}
|
|
|
|
// claude-proxy runs on localhost:3456, Claude CLI connects to it
|
|
env["ANTHROPIC_BASE_URL"] = "http://127.0.0.1:3456"
|
|
env["ANTHROPIC_API_KEY"] = "dummy" // Proxy doesn't verify this
|
|
|
|
// Note: System prompt and max_turns are passed via CLI flags in BuildCommand
|
|
// CLAUDE_SYSTEM_PROMPT environment variable is NOT supported by Claude CLI
|
|
// --append-system-prompt or --system-prompt flags must be used instead
|
|
|
|
return env
|
|
}
|
|
|
|
// BuildProxyConfig builds the claude-proxy configuration JSON
|
|
// 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) {
|
|
if opts == nil {
|
|
return nil, fmt.Errorf("options is required")
|
|
}
|
|
|
|
// Build backend URL - ensure it ends with /chat/completions
|
|
backendURL := opts.ConnectorHost
|
|
if !strings.HasSuffix(backendURL, "/chat/completions") {
|
|
backendURL = strings.TrimSuffix(backendURL, "/") + "/chat/completions"
|
|
}
|
|
|
|
config := map[string]interface{}{
|
|
"backend": backendURL,
|
|
"api_key": opts.ConnectorKey,
|
|
"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, "", " ")
|
|
}
|
|
|
|
// BuildCCRConfig is deprecated, kept for backward compatibility
|
|
// Use BuildProxyConfig instead
|
|
func BuildCCRConfig(opts *Options) ([]byte, error) {
|
|
return BuildProxyConfig(opts)
|
|
}
|