Merge pull request #1455 from trheyi/main

Add user attachment handling, sandbox integration, and configuration refinements
This commit is contained in:
Max 2026-02-09 09:55:41 +08:00 committed by GitHub
commit 0562ed4a8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 690 additions and 68 deletions

View file

@ -129,17 +129,11 @@ type Provider struct {
adapters []adapters.CapabilityAdapter adapters []adapters.CapabilityAdapter
} }
// buildAPIURL builds the complete API URL from host and endpoint // buildAPIURL builds the complete API URL from host and endpoint.
// If host ends with /, it's used as-is (user has specified full path) // Delegates to the shared connector.BuildAPIURL for consistent URL building
// Otherwise, /v1 prefix is added automatically (standard for OpenAI-compatible APIs) // across the agent LLM path and the sandbox proxy path.
func buildAPIURL(host, endpoint string) string { func buildAPIURL(host, endpoint string) string {
// If host ends with /, use it as-is (user has specified full path like /v1/ or /api/) return connector.BuildAPIURL(host, endpoint)
// Otherwise, add /v1 prefix (standard for OpenAI-compatible APIs)
if !strings.HasSuffix(host, "/") {
endpoint = "/v1" + endpoint
}
host = strings.TrimSuffix(host, "/")
return host + endpoint
} }
// New create a new OpenAI provider with capability adapters // New create a new OpenAI provider with capability adapters

View file

@ -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]")
}

View file

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/yaoapp/gou/connector"
agentContext "github.com/yaoapp/yao/agent/context" 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. 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 ## GitHub CLI (gh) Usage
When working with GitHub and a token is provided: 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 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 // 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
// isContinuation: if true, uses --continue to resume previous session (only sends last user message) // 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") 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 opts != nil && opts.Arguments != nil {
if maxTurns, ok := opts.Arguments["max_turns"]; ok { for key, flag := range claudeArgWhitelist {
claudeArgs = append(claudeArgs, "--max-turns", fmt.Sprintf("%v", maxTurns)) 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") return nil, fmt.Errorf("options is required")
} }
// Build backend URL - ensure it ends with /chat/completions // Build backend URL using the shared connector.BuildAPIURL helper
backendURL := opts.ConnectorHost // so that the /v1 prefix is applied consistently with the agent LLM path.
if !strings.HasSuffix(backendURL, "/chat/completions") { backendURL := connector.BuildAPIURL(opts.ConnectorHost, "/chat/completions")
backendURL = strings.TrimSuffix(backendURL, "/") + "/chat/completions"
}
config := map[string]interface{}{ config := map[string]interface{}{
"backend": backendURL, "backend": backendURL,

View file

@ -118,8 +118,9 @@ func TestBuildProxyConfig(t *testing.T) {
configStr := string(configJSON) configStr := string(configJSON)
// Proxy config uses simple format // 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, "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, "api_key")
assert.Contains(t, configStr, "key123") assert.Contains(t, configStr, "key123")
assert.Contains(t, configStr, "model") assert.Contains(t, configStr, "model")

View file

@ -16,6 +16,7 @@ import (
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/i18n"
"github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/attachment"
infraSandbox "github.com/yaoapp/yao/sandbox" infraSandbox "github.com/yaoapp/yao/sandbox"
"github.com/yaoapp/yao/sandbox/ipc" "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) 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 // Check if we should skip Claude CLI execution
// Skip if no prompts, no skills, and no MCP config // Skip if no prompts, no skills, and no MCP config
if e.shouldSkipClaudeCLI() { if e.shouldSkipClaudeCLI() {
@ -407,6 +417,269 @@ func (e *Executor) copySkillsDirectory(ctx context.Context) error {
return nil 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 // Execute runs the Claude CLI and returns the response
func (e *Executor) Execute(ctx *agentContext.Context, messages []agentContext.Message) (*agentContext.CompletionResponse, error) { func (e *Executor) Execute(ctx *agentContext.Context, messages []agentContext.Message) (*agentContext.CompletionResponse, error) {
return e.Stream(ctx, messages, nil) return e.Stream(ctx, messages, nil)

View file

@ -377,7 +377,7 @@ func TestClaudeExecutorIPCSocketMount(t *testing.T) {
ctx := context.Background() ctx := context.Background()
// Check if IPC socket exists in container // 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") require.NoError(t, err, "IPC socket should exist in container")
assert.Contains(t, output, "yao.sock", "Should find yao.sock file") assert.Contains(t, output, "yao.sock", "Should find yao.sock file")
t.Logf("✓ IPC socket mounted: %s", strings.TrimSpace(output)) t.Logf("✓ IPC socket mounted: %s", strings.TrimSpace(output))

54
openapi/oauth/apikey.go Normal file
View file

@ -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
}

View file

@ -3,7 +3,6 @@ package oauth
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"os"
"strings" "strings"
"time" "time"
@ -119,56 +118,17 @@ func (s *Service) getAccessToken(c *gin.Context) string {
// Get the access token // Get the access token
accessToken := strings.TrimPrefix(token, "Bearer ") 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) { if s.isAPIKey(accessToken) {
return s.getAccessTokenFromAPIKey(accessToken) return s.getAccessTokenFromAPIKey(accessToken)
} }
return 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) // GetAccessToken gets the access token from the request (public method)
func (s *Service) GetAccessToken(c *gin.Context) string { func (s *Service) GetAccessToken(c *gin.Context) string {
return s.getAccessToken(c) return s.getAccessToken(c)

View file

@ -19,7 +19,7 @@ type Config struct {
// Container internal paths // Container internal paths
ContainerWorkDir string `json:"container_workdir,omitempty"` // Container working directory, default: /workspace 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. 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) // 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", MaxMemory: "2g",
MaxCPU: 1.0, MaxCPU: 1.0,
ContainerWorkDir: "/workspace", 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 != "" { if env := os.Getenv("YAO_SANDBOX_CONTAINER_IPC"); env != "" {
c.ContainerIPCSocket = env c.ContainerIPCSocket = env
} else if c.ContainerIPCSocket == "" { } else if c.ContainerIPCSocket == "" {
c.ContainerIPCSocket = "/tmp/yao.sock" c.ContainerIPCSocket = "/run/yao.sock"
} }
// Container user (for CI environments with UID mismatch) // Container user (for CI environments with UID mismatch)

View file

@ -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. // Chrome/browser images need SYS_ADMIN for namespace-based process isolation.
// Without it, Chrome renderer/GPU processes crash with error code 5. // 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) { if IsVNCImage(image) {
hostConfig.CapAdd = []string{"SYS_ADMIN"} 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) // VNC port mapping for Docker Desktop (macOS/Windows)