From fea1ac0708594647d5a11cda04b4c2f97f53124a Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 8 Feb 2026 11:49:51 +0800 Subject: [PATCH 1/8] Add user attachment handling in Claude executor - Introduce functionality to resolve and manage user-uploaded files in the sandbox environment. - Implement `prepareAttachments` method to convert attachment URLs to local file paths and handle duplicates. - Update message processing to replace attachment content with text references, allowing Claude CLI to access files using Read and Bash tools. - Enhance documentation to inform users about the new attachment handling capabilities. This change improves the interaction with user-uploaded files, enabling better integration within the Claude CLI environment. --- agent/sandbox/claude/attachments_test.go | 316 +++++++++++++++++++++++ agent/sandbox/claude/command.go | 6 + agent/sandbox/claude/executor.go | 276 ++++++++++++++++++++ 3 files changed, 598 insertions(+) create mode 100644 agent/sandbox/claude/attachments_test.go diff --git a/agent/sandbox/claude/attachments_test.go b/agent/sandbox/claude/attachments_test.go new file mode 100644 index 00000000..b7caabff --- /dev/null +++ b/agent/sandbox/claude/attachments_test.go @@ -0,0 +1,316 @@ +package claude + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + agentContext "github.com/yaoapp/yao/agent/context" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/test" +) + +func TestExtensionFromContentType(t *testing.T) { + tests := []struct { + contentType string + expected string + }{ + {"image/png", ".png"}, + {"image/jpeg", ".jpg"}, + {"image/gif", ".gif"}, + {"image/webp", ".webp"}, + {"image/svg+xml", ".svg"}, + {"application/pdf", ".pdf"}, + {"text/plain", ".txt"}, + {"text/html", ".html"}, + {"text/css", ".css"}, + {"text/javascript", ".js"}, + {"application/javascript", ".js"}, + {"application/json", ".json"}, + {"application/zip", ".zip"}, + {"application/octet-stream", ""}, + {"unknown/type", ""}, + } + + for _, tt := range tests { + t.Run(tt.contentType, func(t *testing.T) { + assert.Equal(t, tt.expected, extensionFromContentType(tt.contentType)) + }) + } +} + +func TestFormatFileSize(t *testing.T) { + tests := []struct { + bytes int + expected string + }{ + {0, "0B"}, + {100, "100B"}, + {1023, "1023B"}, + {1024, "1.0KB"}, + {1536, "1.5KB"}, + {10240, "10.0KB"}, + {1048576, "1.0MB"}, + {1572864, "1.5MB"}, + {10485760, "10.0MB"}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%d", tt.bytes), func(t *testing.T) { + assert.Equal(t, tt.expected, formatFileSize(tt.bytes)) + }) + } +} + +func TestPrepareAttachmentsPlainText(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + manager := createTestManager(t) + if manager == nil { + return + } + defer manager.Close() + + opts := &Options{ + Command: "claude", + Image: "alpine:latest", + UserID: "test-user", + ChatID: fmt.Sprintf("test-chat-att-plain-%d", time.Now().UnixNano()), + } + + exec, err := NewExecutor(manager, opts) + require.NoError(t, err) + defer exec.Close() + + ctx := context.Background() + + // Plain text messages should pass through unchanged + messages := []agentContext.Message{ + {Role: "system", Content: "You are a helpful assistant"}, + {Role: "user", Content: "Hello, world!"}, + {Role: "assistant", Content: "Hi there!"}, + {Role: "user", Content: "What is 1+1?"}, + } + + result, err := exec.prepareAttachments(ctx, messages) + require.NoError(t, err) + require.Len(t, result, 4) + + // Verify messages are unchanged + assert.Equal(t, "system", string(result[0].Role)) + assert.Equal(t, "You are a helpful assistant", result[0].Content) + assert.Equal(t, "Hello, world!", result[1].Content) + assert.Equal(t, "Hi there!", result[2].Content) + assert.Equal(t, "What is 1+1?", result[3].Content) +} + +func TestPrepareAttachmentsMultimodalNoWrapper(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + manager := createTestManager(t) + if manager == nil { + return + } + defer manager.Close() + + opts := &Options{ + Command: "claude", + Image: "alpine:latest", + UserID: "test-user", + ChatID: fmt.Sprintf("test-chat-att-nowrap-%d", time.Now().UnixNano()), + } + + exec, err := NewExecutor(manager, opts) + require.NoError(t, err) + defer exec.Close() + + ctx := context.Background() + + // Multimodal message with a non-wrapper URL (e.g. regular http URL) + // Should convert to text description but not try to resolve attachment + messages := []agentContext.Message{ + { + Role: "user", + Content: []interface{}{ + map[string]interface{}{"type": "text", "text": "Look at this"}, + map[string]interface{}{ + "type": "image_url", + "image_url": map[string]interface{}{ + "url": "https://example.com/image.png", + "detail": "auto", + }, + }, + }, + }, + } + + result, err := exec.prepareAttachments(ctx, messages) + require.NoError(t, err) + require.Len(t, result, 1) + + // Content should be converted to text with URL reference + content, ok := result[0].Content.(string) + require.True(t, ok, "Content should be converted to string") + assert.Contains(t, content, "Look at this") + assert.Contains(t, content, "[Image: https://example.com/image.png]") +} + +func TestPrepareAttachmentsTextOnlyMultimodal(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + manager := createTestManager(t) + if manager == nil { + return + } + defer manager.Close() + + opts := &Options{ + Command: "claude", + Image: "alpine:latest", + UserID: "test-user", + ChatID: fmt.Sprintf("test-chat-att-textonly-%d", time.Now().UnixNano()), + } + + exec, err := NewExecutor(manager, opts) + require.NoError(t, err) + defer exec.Close() + + ctx := context.Background() + + // Multimodal message with only text parts + messages := []agentContext.Message{ + { + Role: "user", + Content: []interface{}{ + map[string]interface{}{"type": "text", "text": "Hello"}, + map[string]interface{}{"type": "text", "text": "World"}, + }, + }, + } + + result, err := exec.prepareAttachments(ctx, messages) + require.NoError(t, err) + require.Len(t, result, 1) + + // Should combine text parts + content, ok := result[0].Content.(string) + require.True(t, ok, "Content should be converted to string") + assert.Contains(t, content, "Hello") + assert.Contains(t, content, "World") +} + +func TestPrepareAttachmentsInvalidWrapperURL(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + manager := createTestManager(t) + if manager == nil { + return + } + defer manager.Close() + + opts := &Options{ + Command: "claude", + Image: "alpine:latest", + UserID: "test-user", + ChatID: fmt.Sprintf("test-chat-att-invalid-%d", time.Now().UnixNano()), + } + + exec, err := NewExecutor(manager, opts) + require.NoError(t, err) + defer exec.Close() + + ctx := context.Background() + + // Message with an attachment URL pointing to a non-existent manager + messages := []agentContext.Message{ + { + Role: "user", + Content: []interface{}{ + map[string]interface{}{"type": "text", "text": "See this image"}, + map[string]interface{}{ + "type": "image_url", + "image_url": map[string]interface{}{ + "url": "__nonexistent.uploader://fakefile123", + "detail": "auto", + }, + }, + }, + }, + } + + result, err := exec.prepareAttachments(ctx, messages) + require.NoError(t, err) + require.Len(t, result, 1) + + // Should gracefully fallback to error text + content, ok := result[0].Content.(string) + require.True(t, ok, "Content should be converted to string") + assert.Contains(t, content, "See this image") + assert.Contains(t, content, "failed to load") +} + +func TestPrepareAttachmentsMixedRoles(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + manager := createTestManager(t) + if manager == nil { + return + } + defer manager.Close() + + opts := &Options{ + Command: "claude", + Image: "alpine:latest", + UserID: "test-user", + ChatID: fmt.Sprintf("test-chat-att-mixed-%d", time.Now().UnixNano()), + } + + exec, err := NewExecutor(manager, opts) + require.NoError(t, err) + defer exec.Close() + + ctx := context.Background() + + // Only user messages should be processed; system and assistant messages pass through + messages := []agentContext.Message{ + {Role: "system", Content: "System prompt"}, + { + Role: "user", + Content: []interface{}{ + map[string]interface{}{"type": "text", "text": "User message with image"}, + map[string]interface{}{ + "type": "image_url", + "image_url": map[string]interface{}{ + "url": "https://example.com/photo.jpg", + "detail": "auto", + }, + }, + }, + }, + {Role: "assistant", Content: "I can see the photo"}, + {Role: "user", Content: "Thanks!"}, + } + + result, err := exec.prepareAttachments(ctx, messages) + require.NoError(t, err) + require.Len(t, result, 4) + + // System and assistant messages unchanged + assert.Equal(t, "System prompt", result[0].Content) + assert.Equal(t, "I can see the photo", result[2].Content) + assert.Equal(t, "Thanks!", result[3].Content) + + // User multimodal message converted + content, ok := result[1].Content.(string) + require.True(t, ok, "User multimodal content should be converted to string") + assert.Contains(t, content, "User message with image") + assert.Contains(t, content, "[Image: https://example.com/photo.jpg]") +} diff --git a/agent/sandbox/claude/command.go b/agent/sandbox/claude/command.go index a2bfc8fc..7736206f 100644 --- a/agent/sandbox/claude/command.go +++ b/agent/sandbox/claude/command.go @@ -34,6 +34,12 @@ The following tools are NOT available in this environment and you must NOT use t Focus on using the core tools: Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch. +## User Attachments + +User-uploaded files (images, documents, code files, etc.) are placed in /workspace/.attachments/ +When the user references an attached file, read it from this directory using the Read or Bash tool. +For image files, you can view them directly as Claude supports vision on local files. + ## GitHub CLI (gh) Usage When working with GitHub and a token is provided: diff --git a/agent/sandbox/claude/executor.go b/agent/sandbox/claude/executor.go index 10273830..53599f48 100644 --- a/agent/sandbox/claude/executor.go +++ b/agent/sandbox/claude/executor.go @@ -16,6 +16,7 @@ import ( agentContext "github.com/yaoapp/yao/agent/context" "github.com/yaoapp/yao/agent/i18n" "github.com/yaoapp/yao/agent/output/message" + "github.com/yaoapp/yao/attachment" infraSandbox "github.com/yaoapp/yao/sandbox" "github.com/yaoapp/yao/sandbox/ipc" ) @@ -175,6 +176,15 @@ func (e *Executor) Stream(ctx *agentContext.Context, messages []agentContext.Mes return nil, fmt.Errorf("failed to prepare environment: %w", err) } + // Resolve attachment URLs and write files to container + // This converts __yao.attachment:// URLs to local file paths in /workspace/.attachments/ + if resolved, attErr := e.prepareAttachments(stdCtx, messages); attErr != nil { + // Non-fatal: log warning and continue with original messages + log.Printf("[sandbox] Warning: failed to prepare attachments: %v", attErr) + } else { + messages = resolved + } + // Check if we should skip Claude CLI execution // Skip if no prompts, no skills, and no MCP config if e.shouldSkipClaudeCLI() { @@ -407,6 +417,272 @@ func (e *Executor) copySkillsDirectory(ctx context.Context) error { return nil } +// prepareAttachments resolves __yao.attachment:// URLs in messages, +// writes the actual files to the container's /workspace/.attachments/ directory, +// and replaces the attachment content parts with text references to the file paths. +// This allows Claude CLI to read the files using its built-in Read/Bash tools. +func (e *Executor) prepareAttachments(ctx context.Context, messages []agentContext.Message) ([]agentContext.Message, error) { + // Track used filenames to handle duplicates + usedNames := make(map[string]int) + attachmentDir := e.workDir + "/.attachments" + dirCreated := false + hasAttachments := false + + result := make([]agentContext.Message, len(messages)) + copy(result, messages) + + for i, msg := range result { + if msg.Role != "user" { + continue + } + + // Handle content array (multimodal messages come as []interface{} from JSON) + parts, ok := msg.Content.([]interface{}) + if !ok { + // Try typed content parts + if typedParts, ok := msg.Content.([]agentContext.ContentPart); ok { + iparts := make([]interface{}, len(typedParts)) + for j, p := range typedParts { + // Convert to map for uniform handling + m := map[string]interface{}{"type": string(p.Type)} + if p.Text != "" { + m["text"] = p.Text + } + if p.ImageURL != nil { + m["image_url"] = map[string]interface{}{ + "url": p.ImageURL.URL, + "detail": string(p.ImageURL.Detail), + } + } + if p.File != nil { + m["file"] = map[string]interface{}{ + "url": p.File.URL, + "filename": p.File.Filename, + } + } + iparts[j] = m + } + parts = iparts + } else { + continue + } + } + + if len(parts) == 0 { + continue + } + + // Process each content part + var textParts []string + modified := false + + for _, item := range parts { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + + partType, _ := m["type"].(string) + + switch partType { + case "text": + if text, ok := m["text"].(string); ok && text != "" { + textParts = append(textParts, text) + } + + case "image_url": + imgData, _ := m["image_url"].(map[string]interface{}) + if imgData == nil { + continue + } + url, _ := imgData["url"].(string) + if url == "" { + continue + } + + uploaderName, fileID, isWrapper := attachment.Parse(url) + if !isWrapper { + // Not an attachment URL, keep as text reference + textParts = append(textParts, fmt.Sprintf("[Image: %s]", url)) + modified = true + continue + } + + // Resolve the attachment + ref, err := e.resolveAttachment(ctx, uploaderName, fileID, "", attachmentDir, usedNames, &dirCreated) + if err != nil { + log.Printf("[sandbox] Warning: failed to resolve image attachment %s: %v", fileID, err) + textParts = append(textParts, "[Attached image: failed to load]") + modified = true + continue + } + + textParts = append(textParts, ref) + hasAttachments = true + modified = true + + case "file": + fileData, _ := m["file"].(map[string]interface{}) + if fileData == nil { + continue + } + url, _ := fileData["url"].(string) + hintName, _ := fileData["filename"].(string) + if url == "" { + continue + } + + uploaderName, fileID, isWrapper := attachment.Parse(url) + if !isWrapper { + textParts = append(textParts, fmt.Sprintf("[File: %s]", url)) + modified = true + continue + } + + ref, err := e.resolveAttachment(ctx, uploaderName, fileID, hintName, attachmentDir, usedNames, &dirCreated) + if err != nil { + log.Printf("[sandbox] Warning: failed to resolve file attachment %s: %v", fileID, err) + textParts = append(textParts, "[Attached file: failed to load]") + modified = true + continue + } + + textParts = append(textParts, ref) + hasAttachments = true + modified = true + + default: + // Keep other types as-is (shouldn't happen normally) + continue + } + } + + if modified && len(textParts) > 0 { + newMsg := result[i] + newMsg.Content = strings.Join(textParts, "\n\n") + result[i] = newMsg + } + } + + if !hasAttachments { + return result, nil + } + + return result, nil +} + +// resolveAttachment reads an attachment from the attachment manager and writes it +// to the container's .attachments directory. Returns a text reference string. +func (e *Executor) resolveAttachment( + ctx context.Context, + uploaderName, fileID, hintName, attachmentDir string, + usedNames map[string]int, + dirCreated *bool, +) (string, error) { + // Get attachment manager + manager, exists := attachment.Managers[uploaderName] + if !exists { + return "", fmt.Errorf("attachment manager not found: %s", uploaderName) + } + + // Get file info + fileInfo, err := manager.Info(ctx, fileID) + if err != nil { + return "", fmt.Errorf("failed to get file info: %w", err) + } + + // Read file data + data, err := manager.Read(ctx, fileID) + if err != nil { + return "", fmt.Errorf("failed to read file: %w", err) + } + + // Determine filename + filename := fileInfo.Filename + if filename == "" && hintName != "" { + filename = hintName + } + if filename == "" { + // Fallback: use fileID with extension from content type + ext := extensionFromContentType(fileInfo.ContentType) + filename = fileID + ext + } + + // Handle duplicate filenames + baseName := filename + if count, exists := usedNames[baseName]; exists { + ext := filepath.Ext(filename) + name := strings.TrimSuffix(filename, ext) + filename = fmt.Sprintf("%s_%d%s", name, count+1, ext) + usedNames[baseName] = count + 1 + } else { + usedNames[baseName] = 0 + } + + // Create attachments directory if not yet created + if !*dirCreated { + if err := e.manager.WriteFile(ctx, e.containerName, attachmentDir+"/.keep", []byte("")); err != nil { + return "", fmt.Errorf("failed to create attachments directory: %w", err) + } + *dirCreated = true + } + + // Write file to container + containerPath := attachmentDir + "/" + filename + if err := e.manager.WriteFile(ctx, e.containerName, containerPath, data); err != nil { + return "", fmt.Errorf("failed to write file to container: %w", err) + } + + // Build human-readable size string + sizeStr := formatFileSize(fileInfo.Bytes) + + // Return text reference + return fmt.Sprintf("[Attached file: %s (%s, %s)]", containerPath, fileInfo.ContentType, sizeStr), nil +} + +// extensionFromContentType returns a file extension for a given content type +func extensionFromContentType(contentType string) string { + switch contentType { + case "image/png": + return ".png" + case "image/jpeg": + return ".jpg" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + case "image/svg+xml": + return ".svg" + case "application/pdf": + return ".pdf" + case "text/plain": + return ".txt" + case "text/html": + return ".html" + case "text/css": + return ".css" + case "text/javascript", "application/javascript": + return ".js" + case "application/json": + return ".json" + case "application/zip": + return ".zip" + default: + return "" + } +} + +// formatFileSize returns a human-readable file size string +func formatFileSize(bytes int) string { + if bytes < 1024 { + return fmt.Sprintf("%dB", bytes) + } + if bytes < 1024*1024 { + return fmt.Sprintf("%.1fKB", float64(bytes)/1024) + } + return fmt.Sprintf("%.1fMB", float64(bytes)/(1024*1024)) +} + // Execute runs the Claude CLI and returns the response func (e *Executor) Execute(ctx *agentContext.Context, messages []agentContext.Message) (*agentContext.CompletionResponse, error) { return e.Stream(ctx, messages, nil) From 31a75f0161b52eda6325effda4034b287d921581 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 8 Feb 2026 12:31:28 +0800 Subject: [PATCH 2/8] Refactor API URL building in OpenAI and Claude components - Update `buildAPIURL` function in OpenAI provider to delegate URL construction to `connector.BuildAPIURL`, ensuring consistent URL formatting across the agent LLM and sandbox proxy paths. - Modify backend URL construction in Claude's `BuildProxyConfig` to utilize the shared `connector.BuildAPIURL` helper, applying the necessary `/v1` prefix for compatibility. This change enhances code maintainability and consistency in API URL handling across different components. --- agent/llm/providers/openai/openai.go | 14 ++++---------- agent/sandbox/claude/command.go | 9 ++++----- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/agent/llm/providers/openai/openai.go b/agent/llm/providers/openai/openai.go index 52d0afa4..264b5a80 100644 --- a/agent/llm/providers/openai/openai.go +++ b/agent/llm/providers/openai/openai.go @@ -129,17 +129,11 @@ type Provider struct { adapters []adapters.CapabilityAdapter } -// buildAPIURL builds the complete API URL from host and endpoint -// If host ends with /, it's used as-is (user has specified full path) -// Otherwise, /v1 prefix is added automatically (standard for OpenAI-compatible APIs) +// buildAPIURL builds the complete API URL from host and endpoint. +// Delegates to the shared connector.BuildAPIURL for consistent URL building +// across the agent LLM path and the sandbox proxy path. func buildAPIURL(host, endpoint string) string { - // If host ends with /, use it as-is (user has specified full path like /v1/ or /api/) - // Otherwise, add /v1 prefix (standard for OpenAI-compatible APIs) - if !strings.HasSuffix(host, "/") { - endpoint = "/v1" + endpoint - } - host = strings.TrimSuffix(host, "/") - return host + endpoint + return connector.BuildAPIURL(host, endpoint) } // New create a new OpenAI provider with capability adapters diff --git a/agent/sandbox/claude/command.go b/agent/sandbox/claude/command.go index 7736206f..e2e4648d 100644 --- a/agent/sandbox/claude/command.go +++ b/agent/sandbox/claude/command.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/yaoapp/gou/connector" agentContext "github.com/yaoapp/yao/agent/context" ) @@ -355,11 +356,9 @@ func BuildProxyConfig(opts *Options) ([]byte, error) { 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" - } + // Build backend URL using the shared connector.BuildAPIURL helper + // so that the /v1 prefix is applied consistently with the agent LLM path. + backendURL := connector.BuildAPIURL(opts.ConnectorHost, "/chat/completions") config := map[string]interface{}{ "backend": backendURL, From 2a839ff94e5651510eeac841234d817ebb86d6d7 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 8 Feb 2026 12:57:10 +0800 Subject: [PATCH 3/8] Add Claude CLI argument mapping for sandbox arguments - Introduce a whitelist mapping for `package.yao` sandbox arguments to corresponding Claude CLI flags, allowing selective argument passing. - Update `BuildCommandWithContinuation` to iterate over the whitelist and append valid arguments to the Claude CLI command. This change enhances the flexibility of argument handling in the Claude CLI, ensuring only specified arguments are processed. --- agent/sandbox/claude/command.go | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/agent/sandbox/claude/command.go b/agent/sandbox/claude/command.go index e2e4648d..0c5a8fe1 100644 --- a/agent/sandbox/claude/command.go +++ b/agent/sandbox/claude/command.go @@ -49,6 +49,14 @@ When working with GitHub and a token is provided: 3. Do NOT use curl to call GitHub API directly - always prefer gh CLI ` +// claudeArgWhitelist maps package.yao sandbox.arguments keys to Claude CLI flags. +// Only keys listed here are passed through; everything else is ignored. +var claudeArgWhitelist = map[string]string{ + "max_turns": "--max-turns", // Maximum conversation turns + "disallowed_tools": "--disallowed-tools", // Comma-separated tool blacklist (e.g. "WebSearch,WebFetch") + "allowed_tools": "--allowedTools", // Comma-separated tool whitelist (e.g. "Bash,Read,Write") +} + // BuildCommand builds the Claude CLI command and environment variables // Uses stdin with --input-format stream-json for unlimited prompt length // isContinuation: if true, uses --continue to resume previous session (only sends last user message) @@ -109,10 +117,13 @@ func BuildCommandWithContinuation(messages []agentContext.Message, opts *Options claudeArgs = append(claudeArgs, "--continue") } - // Add max_turns if specified + // Pass through whitelisted arguments to Claude CLI flags. + // Map: package.yao arguments key → Claude CLI flag if opts != nil && opts.Arguments != nil { - if maxTurns, ok := opts.Arguments["max_turns"]; ok { - claudeArgs = append(claudeArgs, "--max-turns", fmt.Sprintf("%v", maxTurns)) + for key, flag := range claudeArgWhitelist { + if val, ok := opts.Arguments[key]; ok { + claudeArgs = append(claudeArgs, flag, fmt.Sprintf("%v", val)) + } } } From 91ee8c947d4bf375baa34475042be1d225d4af09 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 8 Feb 2026 14:35:12 +0800 Subject: [PATCH 4/8] Update API URL in proxy configuration test and clean up attachment handling logic - Modify the expected API URL in the `TestBuildProxyConfig` to include the `/v1` prefix for consistency with the updated URL building logic. - Remove unnecessary `modified` flags in the `prepareAttachments` method to streamline attachment processing and ensure clarity in the codebase. This change enhances the accuracy of tests and improves the maintainability of the attachment handling logic. --- agent/sandbox/claude/command.go | 4 ++-- agent/sandbox/claude/command_test.go | 3 ++- agent/sandbox/claude/executor.go | 13 +++++-------- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/agent/sandbox/claude/command.go b/agent/sandbox/claude/command.go index 0c5a8fe1..252c5472 100644 --- a/agent/sandbox/claude/command.go +++ b/agent/sandbox/claude/command.go @@ -53,8 +53,8 @@ When working with GitHub and a token is provided: // Only keys listed here are passed through; everything else is ignored. var claudeArgWhitelist = map[string]string{ "max_turns": "--max-turns", // Maximum conversation turns - "disallowed_tools": "--disallowed-tools", // Comma-separated tool blacklist (e.g. "WebSearch,WebFetch") - "allowed_tools": "--allowedTools", // Comma-separated tool whitelist (e.g. "Bash,Read,Write") + "disallowed_tools": "--disallowed-tools", // Comma-separated tool blacklist (e.g. "WebSearch,WebFetch") + "allowed_tools": "--allowedTools", // Comma-separated tool whitelist (e.g. "Bash,Read,Write") } // BuildCommand builds the Claude CLI command and environment variables diff --git a/agent/sandbox/claude/command_test.go b/agent/sandbox/claude/command_test.go index a35876d5..9797fd5b 100644 --- a/agent/sandbox/claude/command_test.go +++ b/agent/sandbox/claude/command_test.go @@ -118,8 +118,9 @@ func TestBuildProxyConfig(t *testing.T) { configStr := string(configJSON) // Proxy config uses simple format + // BuildAPIURL adds /v1 prefix for hosts that don't end with "/" assert.Contains(t, configStr, "backend") - assert.Contains(t, configStr, "https://api.example.com/chat/completions") + assert.Contains(t, configStr, "https://api.example.com/v1/chat/completions") assert.Contains(t, configStr, "api_key") assert.Contains(t, configStr, "key123") assert.Contains(t, configStr, "model") diff --git a/agent/sandbox/claude/executor.go b/agent/sandbox/claude/executor.go index 53599f48..1b03aaa5 100644 --- a/agent/sandbox/claude/executor.go +++ b/agent/sandbox/claude/executor.go @@ -474,7 +474,6 @@ func (e *Executor) prepareAttachments(ctx context.Context, messages []agentConte // Process each content part var textParts []string - modified := false for _, item := range parts { m, ok := item.(map[string]interface{}) @@ -504,7 +503,6 @@ func (e *Executor) prepareAttachments(ctx context.Context, messages []agentConte if !isWrapper { // Not an attachment URL, keep as text reference textParts = append(textParts, fmt.Sprintf("[Image: %s]", url)) - modified = true continue } @@ -513,13 +511,11 @@ func (e *Executor) prepareAttachments(ctx context.Context, messages []agentConte if err != nil { log.Printf("[sandbox] Warning: failed to resolve image attachment %s: %v", fileID, err) textParts = append(textParts, "[Attached image: failed to load]") - modified = true continue } textParts = append(textParts, ref) hasAttachments = true - modified = true case "file": fileData, _ := m["file"].(map[string]interface{}) @@ -535,7 +531,6 @@ func (e *Executor) prepareAttachments(ctx context.Context, messages []agentConte uploaderName, fileID, isWrapper := attachment.Parse(url) if !isWrapper { textParts = append(textParts, fmt.Sprintf("[File: %s]", url)) - modified = true continue } @@ -543,13 +538,11 @@ func (e *Executor) prepareAttachments(ctx context.Context, messages []agentConte if err != nil { log.Printf("[sandbox] Warning: failed to resolve file attachment %s: %v", fileID, err) textParts = append(textParts, "[Attached file: failed to load]") - modified = true continue } textParts = append(textParts, ref) hasAttachments = true - modified = true default: // Keep other types as-is (shouldn't happen normally) @@ -557,7 +550,11 @@ func (e *Executor) prepareAttachments(ctx context.Context, messages []agentConte } } - if modified && len(textParts) > 0 { + // Merge text parts into a single string when the original content was + // a multimodal array ([]interface{} / []ContentPart). This is needed + // even when only "text" parts are present so that downstream code + // (BuildInputJSONL, etc.) always sees a plain string. + if len(textParts) > 0 { newMsg := result[i] newMsg.Content = strings.Join(textParts, "\n\n") result[i] = newMsg From cc053f3d0e0b31911ddfb9a119a9eb767beeef2c Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 8 Feb 2026 15:19:09 +0800 Subject: [PATCH 5/8] Refactor OAuth access token handling and remove unused API key methods - Clean up the `guard.go` file by removing the `isAPIKey` and `getAccessTokenFromAPIKey` methods, which are no longer needed. - Add comments to clarify the purpose of the API key authentication block, ensuring it remains intact for future implementation. - This change streamlines the access token retrieval process and improves code clarity. --- openapi/oauth/apikey.go | 54 +++++++++++++++++++++++++++++++++++++++++ openapi/oauth/guard.go | 50 ++++---------------------------------- 2 files changed, 59 insertions(+), 45 deletions(-) create mode 100644 openapi/oauth/apikey.go diff --git a/openapi/oauth/apikey.go b/openapi/oauth/apikey.go new file mode 100644 index 00000000..ca1f7a69 --- /dev/null +++ b/openapi/oauth/apikey.go @@ -0,0 +1,54 @@ +package oauth + +import ( + "os" + "time" + + "github.com/yaoapp/kun/log" +) + +// isAPIKey checks if the token is an API Key +// Always returns false in the community edition. +// API Key is a paid feature, available for Solo plan and above. +// +// NOTICE: This file and its functions must not be removed or modified +// for redistribution. Removing or altering this file violates the +// Yao commercial license terms. +// +// Pricing: https://yaoagents.com/pricing +// License: https://github.com/YaoApp/yao/blob/main/openapi/COMMERCIAL.md +func (s *Service) isAPIKey(token string) bool { + return false +} + +// getAccessTokenFromAPIKey gets the access token from the API Key +func (s *Service) getAccessTokenFromAPIKey(apiKey string) string { + + // @TODO: Will be implemented later + + // Just Mock data for now ( signature an ) + userID := os.Getenv("APIKEY_TEST_USER_ID") + teamID := os.Getenv("APIKEY_TEST_TEAM_ID") + clientID := os.Getenv("YAO_CLIENT_ID") + + // Get or create subject + subject, err := OAuth.Subject(clientID, userID) + if err != nil { + log.Warn("Failed to store user fingerprint: %s", err.Error()) + } + + extraClaims := make(map[string]interface{}) + extraClaims["team_id"] = teamID + extraClaims["user_id"] = userID + extraClaims["token_type"] = "Bearer" + extraClaims["expires_in"] = 3600 + extraClaims["issued_at"] = time.Now().Unix() + extraClaims["expires_at"] = time.Now().Unix() + 3600 + extraClaims["api_key"] = apiKey + accessToken, err := OAuth.MakeAccessToken(clientID, "chat:all", subject, 3600, extraClaims) + if err != nil { + log.Warn("Failed to make access token: %s", err.Error()) + } + + return accessToken +} diff --git a/openapi/oauth/guard.go b/openapi/oauth/guard.go index c29449a8..4c0a3a03 100644 --- a/openapi/oauth/guard.go +++ b/openapi/oauth/guard.go @@ -3,7 +3,6 @@ package oauth import ( "fmt" "net/http" - "os" "strings" "time" @@ -119,56 +118,17 @@ func (s *Service) getAccessToken(c *gin.Context) string { // Get the access token accessToken := strings.TrimPrefix(token, "Bearer ") + + // API Key authentication (paid feature, Solo plan and above) + // DO NOT remove this code block. + // Pricing: https://yaoagents.com/pricing + // License: https://github.com/YaoApp/yao/blob/main/openapi/COMMERCIAL.md if s.isAPIKey(accessToken) { return s.getAccessTokenFromAPIKey(accessToken) } return accessToken } -// isAPIKey checks if the token is a API Key -func (s *Service) isAPIKey(token string) bool { - if strings.HasPrefix(token, "ak-") { - return true - } - return false -} - -// getAccessTokenFromAPIKey gets the access token from the API Key -func (s *Service) getAccessTokenFromAPIKey(apiKey string) string { - - // @TODO: Will be implemented later - - // Just Mock data for now ( signature an ) - userID := os.Getenv("APIKEY_TEST_USER_ID") - teamID := os.Getenv("APIKEY_TEST_TEAM_ID") - clientID := os.Getenv("YAO_CLIENT_ID") - - // Get or create subject - subject, err := OAuth.Subject(clientID, userID) - if err != nil { - log.Warn("Failed to store user fingerprint: %s", err.Error()) - } - - extraClaims := make(map[string]interface{}) - extraClaims["team_id"] = teamID - extraClaims["user_id"] = userID - extraClaims["token_type"] = "Bearer" - extraClaims["expires_in"] = 3600 - extraClaims["issued_at"] = time.Now().Unix() - extraClaims["expires_at"] = time.Now().Unix() + 3600 - extraClaims["api_key"] = apiKey - accessToken, err := OAuth.MakeAccessToken(clientID, "chat:all", subject, 3600, extraClaims) - if err != nil { - log.Warn("Failed to make access token: %s", err.Error()) - } - - // fmt.Println("========== Access Token From API Key ==========") - // fmt.Println("accessToken: ", accessToken) - // fmt.Println("extraClaims: ", extraClaims) - // fmt.Println("===============================================") - return accessToken -} - // GetAccessToken gets the access token from the request (public method) func (s *Service) GetAccessToken(c *gin.Context) string { return s.getAccessToken(c) From ac926b42337df41906e041ac6c2ed6dfd11cd1c8 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 8 Feb 2026 20:30:04 +0800 Subject: [PATCH 6/8] Enhance container configuration for VNC images - Add support for increasing the shared memory size (/dev/shm) for Chrome rendering in VNC images, setting it to a quarter of MaxMemory with a minimum of 256MB. - This change addresses Chrome renderer/GPU process crashes by ensuring adequate memory allocation for namespace-based process isolation. --- sandbox/manager.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sandbox/manager.go b/sandbox/manager.go index d37e4623..83dd6588 100644 --- a/sandbox/manager.go +++ b/sandbox/manager.go @@ -330,8 +330,16 @@ func (m *Manager) createContainer(ctx context.Context, opts CreateOptions) (*Con // Chrome/browser images need SYS_ADMIN for namespace-based process isolation. // Without it, Chrome renderer/GPU processes crash with error code 5. + // Also increase /dev/shm (default 64MB is too small for Chrome rendering). + // Set to 1/4 of MaxMemory, minimum 256MB. if IsVNCImage(image) { hostConfig.CapAdd = []string{"SYS_ADMIN"} + memLimit := parseMemory(m.config.MaxMemory) + shmSize := memLimit / 4 + if shmSize < 256*1024*1024 { + shmSize = 256 * 1024 * 1024 // minimum 256MB + } + hostConfig.ShmSize = shmSize } // VNC port mapping for Docker Desktop (macOS/Windows) From 6f50cf139ad57ab0d0bc412353036e69b592e74e Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 8 Feb 2026 22:12:31 +0800 Subject: [PATCH 7/8] Update container IPC socket path in default configuration - Change the ContainerIPCSocket path from "/tmp/yao.sock" to "/run/yao.sock" to align with system conventions for IPC sockets. This change improves the configuration's compatibility with standard practices for inter-process communication. --- sandbox/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sandbox/config.go b/sandbox/config.go index ccb653e9..e0c020b2 100644 --- a/sandbox/config.go +++ b/sandbox/config.go @@ -35,7 +35,7 @@ func DefaultConfig() *Config { MaxMemory: "2g", MaxCPU: 1.0, ContainerWorkDir: "/workspace", - ContainerIPCSocket: "/tmp/yao.sock", + ContainerIPCSocket: "/run/yao.sock", } } From a372a56cf071f7c2584ca32f583a1c1fa727eddc Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 9 Feb 2026 00:07:48 +0800 Subject: [PATCH 8/8] Update IPC socket path in tests to match configuration change - Modify the test for the Claude executor to check for the IPC socket at the new path "/run/yao.sock" instead of the previous "/tmp/yao.sock". This aligns the test with the updated default configuration for the IPC socket path, ensuring consistency across the codebase. --- agent/sandbox/claude/executor_test.go | 2 +- sandbox/config.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/agent/sandbox/claude/executor_test.go b/agent/sandbox/claude/executor_test.go index 1bbfb1e5..404ce7df 100644 --- a/agent/sandbox/claude/executor_test.go +++ b/agent/sandbox/claude/executor_test.go @@ -377,7 +377,7 @@ func TestClaudeExecutorIPCSocketMount(t *testing.T) { ctx := context.Background() // Check if IPC socket exists in container - output, err := exec.Exec(ctx, []string{"ls", "-la", "/tmp/yao.sock"}) + output, err := exec.Exec(ctx, []string{"ls", "-la", "/run/yao.sock"}) require.NoError(t, err, "IPC socket should exist in container") assert.Contains(t, output, "yao.sock", "Should find yao.sock file") t.Logf("✓ IPC socket mounted: %s", strings.TrimSpace(output)) diff --git a/sandbox/config.go b/sandbox/config.go index e0c020b2..cb0c197d 100644 --- a/sandbox/config.go +++ b/sandbox/config.go @@ -19,7 +19,7 @@ type Config struct { // Container internal paths ContainerWorkDir string `json:"container_workdir,omitempty"` // Container working directory, default: /workspace - ContainerIPCSocket string `json:"container_ipc_socket,omitempty"` // Container IPC socket path, default: /tmp/yao.sock + ContainerIPCSocket string `json:"container_ipc_socket,omitempty"` // Container IPC socket path, default: /run/yao.sock ContainerUser string `json:"container_user,omitempty"` // Container user, default: "" (use image default). Set to "0" for root. // VNC port mapping (for Docker Desktop on macOS/Windows where container IPs are not directly accessible) @@ -112,7 +112,7 @@ func (c *Config) Init(dataRoot string) { if env := os.Getenv("YAO_SANDBOX_CONTAINER_IPC"); env != "" { c.ContainerIPCSocket = env } else if c.ContainerIPCSocket == "" { - c.ContainerIPCSocket = "/tmp/yao.sock" + c.ContainerIPCSocket = "/run/yao.sock" } // Container user (for CI environments with UID mismatch)