Merge pull request #1455 from trheyi/main
Add user attachment handling, sandbox integration, and configuration refinements
This commit is contained in:
commit
0562ed4a8b
10 changed files with 690 additions and 68 deletions
|
|
@ -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
|
||||
|
|
|
|||
316
agent/sandbox/claude/attachments_test.go
Normal file
316
agent/sandbox/claude/attachments_test.go
Normal 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]")
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
54
openapi/oauth/apikey.go
Normal file
54
openapi/oauth/apikey.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue