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/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..252c5472 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" ) @@ -34,6 +35,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: @@ -42,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) @@ -102,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)) + } } } @@ -349,11 +367,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, 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 10273830..1b03aaa5 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,269 @@ 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 + + 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)) + 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]") + continue + } + + textParts = append(textParts, ref) + hasAttachments = 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)) + 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]") + continue + } + + textParts = append(textParts, ref) + hasAttachments = true + + default: + // Keep other types as-is (shouldn't happen normally) + continue + } + } + + // 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 + } + } + + 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) 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/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) diff --git a/sandbox/config.go b/sandbox/config.go index ccb653e9..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) @@ -35,7 +35,7 @@ func DefaultConfig() *Config { MaxMemory: "2g", MaxCPU: 1.0, ContainerWorkDir: "/workspace", - ContainerIPCSocket: "/tmp/yao.sock", + ContainerIPCSocket: "/run/yao.sock", } } @@ -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) 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)