From 3f390c223c3eea84c7d624e02d202fd2d0358ba2 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 10 Mar 2026 01:13:17 +0800 Subject: [PATCH] feat(sandbox/v2): enhance V2 sandbox integration and testing - Implemented V2 sandbox initialization in the assistant loading process, allowing for standalone sandbox.yao configuration. - Added support for V2 sandbox execution paths in the Assistant's Stream method, differentiating between V1 and V2 sandboxes. - Introduced comprehensive tests for V2 sandbox configurations, ensuring correct loading and execution behavior. - Updated the context and types to accommodate V2 sandbox features, including system information and workspace management. Made-with: Cursor --- .gitignore | 3 +- Makefile | 6 +- agent/assistant/agent.go | 35 +- agent/assistant/load.go | 75 +- agent/assistant/load_test.go | 156 +++ agent/assistant/sandbox_v2.go | 189 ++++ agent/context/jsapi.go | 20 +- agent/context/jsapi_computer.go | 228 +++++ agent/context/jsapi_workspace.go | 291 ++++++ agent/context/types.go | 4 + agent/sandbox/v2/claude/attachments.go | 225 +++++ agent/sandbox/v2/claude/parse.go | 239 +++++ agent/sandbox/v2/claude/runner.go | 458 +++++++++ agent/sandbox/v2/claude/runner_test.go | 279 ++++++ agent/sandbox/v2/claude/testdata/code.ts | 904 ++++++++++++++++++ .../sandbox/v2/claude/testdata/test-image.png | Bin 0 -> 75176 bytes agent/sandbox/v2/init.go | 13 + agent/sandbox/v2/lifecycle.go | 150 +++ agent/sandbox/v2/lifecycle_test.go | 547 +++++++++++ agent/sandbox/v2/options.go | 168 ++++ agent/sandbox/v2/prepare.go | 171 ++++ agent/sandbox/v2/prepare_test.go | 549 +++++++++++ agent/sandbox/v2/runner.go | 32 + agent/sandbox/v2/shell.go | 47 + agent/sandbox/v2/stream.go | 138 +++ agent/sandbox/v2/testutils/testutils.go | 47 + agent/sandbox/v2/testutils_test.go | 228 +++++ agent/sandbox/v2/types/config.go | 137 +++ agent/sandbox/v2/types/runner.go | 53 + agent/sandbox/v2/types/token.go | 9 + agent/sandbox/v2/yao/runner.go | 38 + agent/sandbox/v2/yao/runner_test.go | 137 +++ agent/store/types/sandbox_v2.go | 99 ++ agent/store/types/types.go | 75 +- sandbox/v2/box.go | 6 +- sandbox/v2/host.go | 2 + sandbox/v2/manager.go | 57 +- sandbox/v2/types.go | 4 +- tai/registry/registry.go | 2 + tai/serverinfo/pb/serverinfo.pb.go | 200 +++- tai/serverinfo/pb/serverinfo.proto | 13 +- tai/serverinfo/pb/serverinfo_grpc.pb.go | 4 +- tai/tai.go | 68 +- tai/volume/local.go | 131 ++- tai/volume/mock_test.go | 54 ++ tai/volume/pb/volume.pb.go | 167 +++- tai/volume/pb/volume.proto | 10 + tai/volume/pb/volume_grpc.pb.go | 40 + tai/volume/remote.go | 37 +- tai/volume/volume.go | 23 +- tai/volume/volume_test.go | 241 +++++ tai/workspace/copy.go | 158 +++ tai/workspace/uri.go | 49 + tai/workspace/workspace.go | 6 + workspace/jsapi/fs.go | 200 +--- 55 files changed, 6872 insertions(+), 350 deletions(-) create mode 100644 agent/assistant/sandbox_v2.go create mode 100644 agent/context/jsapi_computer.go create mode 100644 agent/context/jsapi_workspace.go create mode 100644 agent/sandbox/v2/claude/attachments.go create mode 100644 agent/sandbox/v2/claude/parse.go create mode 100644 agent/sandbox/v2/claude/runner.go create mode 100644 agent/sandbox/v2/claude/runner_test.go create mode 100644 agent/sandbox/v2/claude/testdata/code.ts create mode 100644 agent/sandbox/v2/claude/testdata/test-image.png create mode 100644 agent/sandbox/v2/init.go create mode 100644 agent/sandbox/v2/lifecycle.go create mode 100644 agent/sandbox/v2/lifecycle_test.go create mode 100644 agent/sandbox/v2/options.go create mode 100644 agent/sandbox/v2/prepare.go create mode 100644 agent/sandbox/v2/prepare_test.go create mode 100644 agent/sandbox/v2/runner.go create mode 100644 agent/sandbox/v2/shell.go create mode 100644 agent/sandbox/v2/stream.go create mode 100644 agent/sandbox/v2/testutils/testutils.go create mode 100644 agent/sandbox/v2/testutils_test.go create mode 100644 agent/sandbox/v2/types/config.go create mode 100644 agent/sandbox/v2/types/runner.go create mode 100644 agent/sandbox/v2/types/token.go create mode 100644 agent/sandbox/v2/yao/runner.go create mode 100644 agent/sandbox/v2/yao/runner_test.go create mode 100644 agent/store/types/sandbox_v2.go create mode 100644 tai/workspace/copy.go create mode 100644 tai/workspace/uri.go diff --git a/.gitignore b/.gitignore index 24fe2d1c..6f3a811a 100644 --- a/.gitignore +++ b/.gitignore @@ -74,4 +74,5 @@ tg-login tg-send registry/data/ registry/manager/DESIGN*.md -tai/testdata/ \ No newline at end of file +tai/testdata/ +agent/sandbox/docs/*.md \ No newline at end of file diff --git a/Makefile b/Makefile index 7844d513..88a19449 100644 --- a/Makefile +++ b/Makefile @@ -10,11 +10,11 @@ NOW := $(shell date +"%FT%T%z") OS := $(shell uname) # ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) -TESTFOLDER := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*|registry' | awk '!/\/tests\// || /openapi\/tests/') +TESTFOLDER := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*|registry|agent/sandbox/v2' | awk '!/\/tests\// || /openapi\/tests/') # Core tests (exclude AI-related: agent, aigc, openai, KB, sandbox, registry, grpc, and integrations which require external services) TESTFOLDER_CORE := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*|agent|kb|sandbox|integrations|registry|tai|grpc' | awk '!/\/tests\// || /openapi\/tests/') -# Agent tests (agent, aigc) - exclude agent/search/handlers/web (requires external API keys) and robot packages (tested in robot job) -TESTFOLDER_AGENT := $(shell $(GO) list ./agent/... ./aigc/... | grep -vE 'agent/search/handlers/web|agent/robot/') +# Agent tests (agent, aigc) - exclude agent/search/handlers/web (requires external API keys), robot packages (tested in robot job), and agent/sandbox/v2 (WIP, has its own job) +TESTFOLDER_AGENT := $(shell $(GO) list ./agent/... ./aigc/... | grep -vE 'agent/search/handlers/web|agent/robot/|agent/sandbox/v2') # KB tests (kb) TESTFOLDER_KB := $(shell $(GO) list ./kb/...) # Robot tests (agent/robot/... packages, excluding events/integrations which require Telegram etc.) diff --git a/agent/assistant/agent.go b/agent/assistant/agent.go index fca12ab8..9e49226b 100644 --- a/agent/assistant/agent.go +++ b/agent/assistant/agent.go @@ -14,6 +14,8 @@ import ( "github.com/yaoapp/yao/agent/llm" "github.com/yaoapp/yao/agent/output/message" agentsandbox "github.com/yaoapp/yao/agent/sandbox" + sandboxTypes "github.com/yaoapp/yao/agent/sandbox/v2/types" + infraV2 "github.com/yaoapp/yao/sandbox/v2" ) // Stream stream the agent @@ -163,7 +165,25 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa var sandboxExecutor agentsandbox.Executor var sandboxCleanup func() var sandboxLoadingMsgID string - if ast.HasSandbox() { + + // V2 sandbox state + var v2Runner sandboxTypes.Runner + var v2Computer infraV2.Computer + var v2LoadingMsgID string + + if ast.HasSandboxV2() { + ctx.Logger.Phase("Sandbox V2") + var err error + var v2Cleanup func() + v2Runner, v2Computer, v2Cleanup, v2LoadingMsgID, err = ast.initSandboxV2(ctx, opts) + if err != nil { + ast.traceAgentFail(agentNode, err) + ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err) + return nil, err + } + sandboxCleanup = v2Cleanup + ctx.Logger.PhaseComplete("Sandbox V2") + } else if ast.HasSandbox() { ctx.Logger.Phase("Sandbox") var err error sandboxExecutor, sandboxCleanup, sandboxLoadingMsgID, err = ast.initSandbox(ctx, opts) @@ -289,8 +309,17 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa // Execute the LLM streaming call // Choose between sandbox execution or direct LLM execution - if ast.HasSandbox() { - // Sandbox execution path (Claude CLI, Cursor CLI, etc.) + if ast.HasSandboxV2() && v2Runner != nil && v2Computer != nil && v2Runner.Name() != "yao" { + // V2 Sandbox execution path (non-yao runners replace LLM.Stream) + completionResponse, err = ast.executeSandboxV2Stream(ctx, completionMessages, agentNode, streamHandler, v2Runner, v2Computer, v2LoadingMsgID) + } else if ast.HasSandboxV2() && v2Runner != nil && v2Runner.Name() == "yao" { + // V2 yao runner: Prepare is done, close loading, fall through to LLM + if v2LoadingMsgID != "" { + closeLoadingV2(ctx, v2LoadingMsgID, "") + } + completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts) + } else if ast.HasSandbox() { + // V1 Sandbox execution path (Claude CLI, Cursor CLI, etc.) completionResponse, err = ast.executeSandboxStream(ctx, completionMessages, agentNode, streamHandler, sandboxExecutor, sandboxLoadingMsgID) } else { // Direct LLM execution path diff --git a/agent/assistant/load.go b/agent/assistant/load.go index 0333ac58..75092b47 100644 --- a/agent/assistant/load.go +++ b/agent/assistant/load.go @@ -12,8 +12,10 @@ import ( "github.com/yaoapp/gou/fs" "github.com/yaoapp/yao/agent/context" "github.com/yaoapp/yao/agent/i18n" + sandboxTypes "github.com/yaoapp/yao/agent/sandbox/v2/types" searchTypes "github.com/yaoapp/yao/agent/search/types" store "github.com/yaoapp/yao/agent/store/types" + "github.com/yaoapp/yao/config" "gopkg.in/yaml.v3" ) @@ -378,7 +380,45 @@ func LoadPath(path string) (*Assistant, error) { return nil, err } data["locales"] = locales - return loadMap(data) + + // V2 sandbox: load standalone sandbox.yao if present (Path A). + sandboxFile := filepath.Join(path, "sandbox.yao") + if has, _ := app.Exists(sandboxFile); has { + absFile := filepath.Join(config.Conf.AppSource, sandboxFile) + sbCfg, sbErr := store.LoadSandboxConfig(absFile) + if sbErr != nil { + return nil, fmt.Errorf("load sandbox.yao: %w", sbErr) + } + data["__sandbox_v2"] = sbCfg + } + + ast, err := loadMap(data) + if err != nil { + return nil, err + } + + // If V2 sandbox was loaded via Path A, assign it now. + if sbCfg, ok := data["__sandbox_v2"].(*sandboxTypes.SandboxConfig); ok && sbCfg != nil { + ast.SandboxV2 = sbCfg + } + + // Compute config hash for V2 sandbox. + if ast.SandboxV2 != nil { + var mcpServers []store.MCPServerConfig + if ast.MCP != nil { + mcpServers = ast.MCP.Servers + } + skillsDir := "" + if ast.Path != "" { + dir := filepath.Join(config.Conf.AppSource, ast.Path, "skills") + if info, e := os.Stat(dir); e == nil && info.IsDir() { + skillsDir = dir + } + } + ast.ConfigHash = store.ComputeConfigHash(ast.SandboxV2, mcpServers, skillsDir) + } + + return ast, nil } func loadMap(data map[string]interface{}) (*Assistant, error) { @@ -721,12 +761,25 @@ func loadMap(data map[string]interface{}) (*Assistant, error) { } // sandbox (for coding agents like Claude CLI, Cursor CLI) - if sandbox, has := data["sandbox"]; has { - sb, err := store.ToSandbox(sandbox) - if err != nil { - return nil, err + // V2 sandbox via independent sandbox.yao is loaded in LoadPath (below). + // This block handles the package.yao embedded "sandbox" field with version dispatch. + if assistant.SandboxV2 == nil { + if sandbox, has := data["sandbox"]; has { + version := extractSandboxVersion(sandbox) + if version == sandboxTypes.SandboxVersionV2 { + sb, err := store.ToSandboxV2(sandbox) + if err != nil { + return nil, err + } + assistant.SandboxV2 = sb + } else { + sb, err := store.ToSandbox(sandbox) + if err != nil { + return nil, err + } + assistant.Sandbox = sb + } } - assistant.Sandbox = sb } // dependencies (name -> version constraint, like npm dependencies) @@ -1036,3 +1089,13 @@ func mergeSearchConfig(base, override *searchTypes.Config) *searchTypes.Config { return &result } + +// extractSandboxVersion tries to read the "version" field from a sandbox config value. +func extractSandboxVersion(v any) string { + if m, ok := v.(map[string]any); ok { + if ver, ok := m["version"].(string); ok { + return ver + } + } + return "" +} diff --git a/agent/assistant/load_test.go b/agent/assistant/load_test.go index a15406dd..818ee334 100644 --- a/agent/assistant/load_test.go +++ b/agent/assistant/load_test.go @@ -594,6 +594,162 @@ func TestLoadSystemAgents(t *testing.T) { }) } +// TestLoadPathSandboxV2 tests loading assistants with V2 sandbox configuration (standalone sandbox.yao) +func TestLoadPathSandboxV2(t *testing.T) { + prepare(t) + defer test.Clean() + + t.Run("OneshotCLI", func(t *testing.T) { + ast, err := assistant.LoadPath("/assistants/tests/sandbox-v2/oneshot-cli") + require.NoError(t, err) + require.NotNil(t, ast) + + assert.Equal(t, "Sandbox V2 Oneshot CLI", ast.Name) + assert.Contains(t, ast.Tags, "SandboxV2") + + // V2 sandbox should be loaded from sandbox.yao + require.NotNil(t, ast.SandboxV2, "SandboxV2 should be loaded") + assert.Equal(t, "2.0", ast.SandboxV2.Version) + assert.Equal(t, "yaoapp/tai-sandbox-claude:latest", ast.SandboxV2.Computer.Image) + assert.Equal(t, "2GB", ast.SandboxV2.Computer.Memory) + assert.Equal(t, float64(2), ast.SandboxV2.Computer.CPUs) + assert.Equal(t, "/workspace", ast.SandboxV2.Computer.WorkDir) + assert.Equal(t, "claude", ast.SandboxV2.Runner.Name) + assert.Equal(t, "cli", ast.SandboxV2.Runner.Mode) + assert.Equal(t, "oneshot", ast.SandboxV2.Lifecycle) + + // Runner options + assert.NotNil(t, ast.SandboxV2.Runner.Options) + assert.Equal(t, float64(5), ast.SandboxV2.Runner.Options["max_turns"]) + + // V1 Sandbox should be nil + assert.Nil(t, ast.Sandbox, "V1 Sandbox should be nil when V2 is present") + + // ConfigHash should be computed + assert.NotEmpty(t, ast.ConfigHash, "ConfigHash should be computed for V2 sandbox") + + // HasSandboxV2 helper + assert.True(t, ast.HasSandboxV2()) + }) + + t.Run("SessionCLI", func(t *testing.T) { + ast, err := assistant.LoadPath("/assistants/tests/sandbox-v2/session-cli") + require.NoError(t, err) + require.NotNil(t, ast) + + require.NotNil(t, ast.SandboxV2) + assert.Equal(t, "session", ast.SandboxV2.Lifecycle) + assert.Equal(t, "10m", ast.SandboxV2.IdleTimeout) + + // Prepare steps + require.Len(t, ast.SandboxV2.Prepare, 1) + assert.Equal(t, "exec", ast.SandboxV2.Prepare[0].Action) + assert.True(t, ast.SandboxV2.Prepare[0].Once) + }) + + t.Run("LongrunningCLI", func(t *testing.T) { + ast, err := assistant.LoadPath("/assistants/tests/sandbox-v2/longrunning-cli") + require.NoError(t, err) + require.NotNil(t, ast) + + require.NotNil(t, ast.SandboxV2) + assert.Equal(t, "longrunning", ast.SandboxV2.Lifecycle) + assert.Equal(t, "15m", ast.SandboxV2.IdleTimeout) + assert.Equal(t, "2h", ast.SandboxV2.MaxLifetime) + assert.Equal(t, "5s", ast.SandboxV2.StopTimeout) + assert.Equal(t, "4GB", ast.SandboxV2.Computer.Memory) + assert.Equal(t, "rw", ast.SandboxV2.Computer.MountMode) + + // Environment + assert.Equal(t, "test", ast.SandboxV2.Environment["NODE_ENV"]) + assert.Equal(t, "longrunning", ast.SandboxV2.Environment["V2_TEST_MODE"]) + + // Secrets + assert.Equal(t, "sandbox-v2-longrunning-secret", ast.SandboxV2.Secrets["TEST_SECRET"]) + + // Prepare steps + require.Len(t, ast.SandboxV2.Prepare, 3) + assert.True(t, ast.SandboxV2.Prepare[2].IgnoreError) + + // MCP (from package.yao) + require.NotNil(t, ast.MCP) + require.Len(t, ast.MCP.Servers, 1) + assert.Equal(t, "echo", ast.MCP.Servers[0].ServerID) + + // ConfigHash should include MCP servers + hashWithMCP := ast.ConfigHash + assert.NotEmpty(t, hashWithMCP) + }) + + t.Run("HooksOnly_YaoRunner", func(t *testing.T) { + ast, err := assistant.LoadPath("/assistants/tests/sandbox-v2/hooks-only") + require.NoError(t, err) + require.NotNil(t, ast) + + require.NotNil(t, ast.SandboxV2) + assert.Equal(t, "yao", ast.SandboxV2.Runner.Name) + assert.Equal(t, "oneshot", ast.SandboxV2.Lifecycle) + assert.Equal(t, float64(1), ast.SandboxV2.Computer.CPUs) + + // Runner mode should be empty (yao runner ignores mode) + assert.Empty(t, ast.SandboxV2.Runner.Mode) + }) + + t.Run("FullPrepare", func(t *testing.T) { + ast, err := assistant.LoadPath("/assistants/tests/sandbox-v2/full-prepare") + require.NoError(t, err) + require.NotNil(t, ast) + + require.NotNil(t, ast.SandboxV2) + assert.Equal(t, "session", ast.SandboxV2.Lifecycle) + assert.Equal(t, "15m", ast.SandboxV2.IdleTimeout) + + // Prepare: 5 steps with mixed actions + require.Len(t, ast.SandboxV2.Prepare, 5) + assert.Equal(t, "copy", ast.SandboxV2.Prepare[0].Action) + assert.Equal(t, "skills", ast.SandboxV2.Prepare[0].Src) + assert.Equal(t, "~/.claude/skills", ast.SandboxV2.Prepare[0].Dst) + assert.Equal(t, "exec", ast.SandboxV2.Prepare[1].Action) + assert.True(t, ast.SandboxV2.Prepare[1].Once) + assert.True(t, ast.SandboxV2.Prepare[3].IgnoreError) + + // Environment + Secrets + assert.Equal(t, "full", ast.SandboxV2.Environment["V2_PREPARE_TEST"]) + assert.Equal(t, "v2-full-prepare-key", ast.SandboxV2.Secrets["TEST_API_KEY"]) + + // Runner options + assert.Equal(t, "acceptEdits", ast.SandboxV2.Runner.Options["permission_mode"]) + }) + + t.Run("HostMode", func(t *testing.T) { + ast, err := assistant.LoadPath("/assistants/tests/sandbox-v2/host-mode") + require.NoError(t, err) + require.NotNil(t, ast) + + require.NotNil(t, ast.SandboxV2) + // Host mode: no image + assert.Empty(t, ast.SandboxV2.Computer.Image) + assert.Equal(t, "/tmp/yao-sandbox-v2-host-test", ast.SandboxV2.Computer.WorkDir) + assert.Equal(t, "session", ast.SandboxV2.Lifecycle) + }) + + t.Run("ConfigHashDeterministic", func(t *testing.T) { + ast1, err := assistant.LoadPath("/assistants/tests/sandbox-v2/oneshot-cli") + require.NoError(t, err) + ast2, err := assistant.LoadPath("/assistants/tests/sandbox-v2/oneshot-cli") + require.NoError(t, err) + assert.Equal(t, ast1.ConfigHash, ast2.ConfigHash, "same config should produce same hash") + }) + + t.Run("ConfigHashDiffers", func(t *testing.T) { + ast1, err := assistant.LoadPath("/assistants/tests/sandbox-v2/oneshot-cli") + require.NoError(t, err) + ast2, err := assistant.LoadPath("/assistants/tests/sandbox-v2/longrunning-cli") + require.NoError(t, err) + assert.NotEqual(t, ast1.ConfigHash, ast2.ConfigHash, "different configs should produce different hashes") + }) +} + // TestValidate tests the assistant Validate method func TestValidate(t *testing.T) { tests := []struct { diff --git a/agent/assistant/sandbox_v2.go b/agent/assistant/sandbox_v2.go new file mode 100644 index 00000000..a6e2b97e --- /dev/null +++ b/agent/assistant/sandbox_v2.go @@ -0,0 +1,189 @@ +package assistant + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/yaoapp/yao/agent/context" + "github.com/yaoapp/yao/agent/i18n" + "github.com/yaoapp/yao/agent/output/message" + sandboxv2 "github.com/yaoapp/yao/agent/sandbox/v2" + sandboxTypes "github.com/yaoapp/yao/agent/sandbox/v2/types" + "github.com/yaoapp/yao/config" + infraV2 "github.com/yaoapp/yao/sandbox/v2" + traceTypes "github.com/yaoapp/yao/trace/types" +) + +// HasSandboxV2 returns true if the assistant has a V2 sandbox configuration. +func (ast *Assistant) HasSandboxV2() bool { + return ast.SandboxV2 != nil +} + +// initSandboxV2 initializes the V2 sandbox: obtains a Computer, gets a Runner, +// runs Prepare, and returns the runner, computer, cleanup closure, loading +// message ID, and any error. +func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options) ( + sandboxTypes.Runner, infraV2.Computer, func(), string, error, +) { + cfg := ast.SandboxV2 + manager := infraV2.M() + + loadingMsg := &message.Message{ + Type: message.TypeLoading, + Props: map[string]any{ + "message": i18n.T(ctx.Locale, "sandbox.preparing"), + }, + } + loadingMsgID, _ := ctx.SendStream(loadingMsg) + + stdCtx := ctx.Context + + // 1. Obtain Computer. + computer, identifier, err := sandboxv2.GetComputer(ctx, cfg, manager) + if err != nil { + closeLoadingV2(ctx, loadingMsgID, "sandbox.failed") + return nil, nil, nil, "", fmt.Errorf("getComputer failed: %w", err) + } + _ = identifier + + // 2. Get Runner. + runner, err := sandboxv2.Get(cfg.Runner.Name) + if err != nil { + sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager) + closeLoadingV2(ctx, loadingMsgID, "sandbox.failed") + return nil, nil, nil, "", fmt.Errorf("get runner %q: %w", cfg.Runner.Name, err) + } + + // 3. Resolve connector. + conn, _, err := ast.GetConnector(ctx, opts) + if err != nil && cfg.Runner.Name != "yao" { + sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager) + closeLoadingV2(ctx, loadingMsgID, "sandbox.failed") + return nil, nil, nil, "", fmt.Errorf("get connector: %w", err) + } + + // 4. Resolve skills directory. + skillsDir := "" + if ast.Path != "" { + dir := filepath.Join(config.Conf.AppSource, ast.Path, "skills") + if info, e := os.Stat(dir); e == nil && info.IsDir() { + skillsDir = dir + } + } + + // 5. Convert MCP servers. + var mcpServers []sandboxTypes.MCPServer + if ast.MCP != nil { + for _, s := range ast.MCP.Servers { + mcpServers = append(mcpServers, sandboxTypes.MCPServer{ + ServerID: s.ServerID, + Resources: s.Resources, + Tools: s.Tools, + }) + } + } + + // 6. Runner.Prepare (standard context). + err = runner.Prepare(stdCtx, &sandboxTypes.PrepareRequest{ + Computer: computer, + Config: cfg, + Connector: conn, + SkillsDir: skillsDir, + MCPServers: mcpServers, + ConfigHash: ast.ConfigHash, + RunSteps: sandboxv2.RunPrepareSteps, + }) + if err != nil { + runner.Cleanup(stdCtx, computer) + sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager) + closeLoadingV2(ctx, loadingMsgID, "sandbox.failed") + return nil, nil, nil, "", fmt.Errorf("runner.Prepare: %w", err) + } + + // Inject computer + workspace into context so Create/Next hooks + // can access ctx.computer and ctx.workspace. + ctx.SetComputer(computer) + + cleanup := func() { + // Defensive fallback — executeSandboxV2Stream defer handles the + // normal case; this covers paths that never reach execution. + } + + return runner, computer, cleanup, loadingMsgID, nil +} + +// executeSandboxV2Stream calls the V2 Runner.Stream and wraps it in the +// standard completion response. +func (ast *Assistant) executeSandboxV2Stream( + ctx *context.Context, + completionMessages []context.Message, + agentNode traceTypes.Node, + streamHandler message.StreamFunc, + runner sandboxTypes.Runner, + computer infraV2.Computer, + loadingMsgID string, +) (*context.CompletionResponse, error) { + _ = agentNode + + cfg := ast.SandboxV2 + manager := infraV2.M() + + // Close the "preparing" loading on first output. + if loadingMsgID != "" { + closeLoadingV2(ctx, loadingMsgID, "") + } + + // Build system prompt. + var systemPrompt string + if len(ast.Prompts) > 0 { + for _, p := range ast.Prompts { + if p.Role == "system" && p.Content != "" { + systemPrompt = p.Content + break + } + } + } + + // Resolve connector for Stream. + conn, _, _ := ast.GetConnector(ctx) + + streamReq := &sandboxTypes.StreamRequest{ + Computer: computer, + Config: cfg, + Connector: conn, + Messages: completionMessages, + SystemPrompt: systemPrompt, + ChatID: ctx.ChatID, + } + + execReq := &sandboxv2.ExecuteRequest{ + Computer: computer, + Runner: runner, + Config: cfg, + StreamReq: streamReq, + Manager: manager, + } + + return sandboxv2.ExecuteSandboxStream(ctx, execReq, streamHandler) +} + +func closeLoadingV2(ctx *context.Context, loadingMsgID, msgKey string) { + if loadingMsgID == "" || ctx == nil { + return + } + props := map[string]any{"done": true} + if msgKey != "" { + props["message"] = i18n.T(ctx.Locale, msgKey) + } else { + props["message"] = "" + } + doneMsg := &message.Message{ + MessageID: loadingMsgID, + Delta: true, + DeltaAction: message.DeltaReplace, + Type: message.TypeLoading, + Props: props, + } + ctx.Send(doneMsg) +} diff --git a/agent/context/jsapi.go b/agent/context/jsapi.go index 1081ba7d..9aa767fc 100644 --- a/agent/context/jsapi.go +++ b/agent/context/jsapi.go @@ -152,7 +152,7 @@ func (ctx *Context) NewObject(v8ctx *v8go.Context) (*v8go.Value, error) { memoryObj.Release() } - // Sandbox object - only set if sandbox executor is available + // Sandbox object - only set if sandbox executor is available (V1) if ctx.sandboxExecutor != nil { sandboxObj := ctx.createSandboxInstance(v8ctx) if sandboxObj != nil { @@ -161,6 +161,24 @@ func (ctx *Context) NewObject(v8ctx *v8go.Context) (*v8go.Value, error) { } } + // Computer object - only set if V2 computer is available + if ctx.computer != nil { + computerObj := ctx.createComputerInstance(v8ctx) + if computerObj != nil { + obj.Set("computer", computerObj) + computerObj.Release() + } + } + + // Workspace object - only set if V2 workspace is available + if ctx.workspace != nil { + wsObj := ctx.createWorkspaceInstance(v8ctx) + if wsObj != nil { + obj.Set("workspace", wsObj) + wsObj.Release() + } + } + return instance.Value, nil } diff --git a/agent/context/jsapi_computer.go b/agent/context/jsapi_computer.go new file mode 100644 index 00000000..ae3c57c1 --- /dev/null +++ b/agent/context/jsapi_computer.go @@ -0,0 +1,228 @@ +package context + +import ( + "context" + "fmt" + "strings" + + "github.com/yaoapp/gou/runtime/v8/bridge" + infraV2 "github.com/yaoapp/yao/sandbox/v2" + "github.com/yaoapp/yao/tai/workspace" + "rogchap.com/v8go" +) + +// SetComputer sets the V2 computer and its workspace for this context. +// Should be called after Runner.Prepare succeeds in initSandboxV2. +func (ctx *Context) SetComputer(computer infraV2.Computer) { + ctx.computer = computer + if computer != nil { + ctx.workspace = computer.Workplace() + } +} + +// GetComputer returns the V2 computer if available. +func (ctx *Context) GetComputer() infraV2.Computer { + return ctx.computer +} + +// GetWorkspace returns the V2 workspace FS if available. +func (ctx *Context) GetWorkspace() workspace.FS { + return ctx.workspace +} + +// HasComputer returns true if V2 computer is available. +func (ctx *Context) HasComputer() bool { + return ctx.computer != nil +} + +// createComputerInstance creates the ctx.computer JavaScript object. +func (ctx *Context) createComputerInstance(v8ctx *v8go.Context) *v8go.Value { + if ctx.computer == nil { + return nil + } + + iso := v8ctx.Isolate() + objTpl := v8go.NewObjectTemplate(iso) + + info := ctx.computer.ComputerInfo() + id := info.BoxID + if id == "" { + id = info.NodeID + } + objTpl.Set("id", id) + + objTpl.Set("Exec", ctx.computerExecMethod(iso)) + objTpl.Set("VNC", ctx.computerVNCMethod(iso)) + objTpl.Set("Proxy", ctx.computerProxyMethod(iso)) + objTpl.Set("Info", ctx.computerInfoMethod(iso)) + + instance, err := objTpl.NewInstance(v8ctx) + if err != nil { + return nil + } + return instance.Value +} + +// computerExecMethod implements ctx.computer.Exec(cmd) +// cmd can be a string or an array of strings. +// Returns: { stdout, stderr, exit_code } +func (ctx *Context) computerExecMethod(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + v8ctx := info.Context() + args := info.Args() + + if ctx.computer == nil { + return bridge.JsException(v8ctx, "computer not available") + } + if len(args) < 1 { + return bridge.JsException(v8ctx, "Exec requires a command argument") + } + + cmd, err := parseCommandArg(v8ctx, args[0]) + if err != nil { + return bridge.JsException(v8ctx, err.Error()) + } + + result, err := ctx.computer.Exec(context.Background(), cmd) + if err != nil { + return bridge.JsException(v8ctx, "Exec failed: "+err.Error()) + } + + res := map[string]interface{}{ + "stdout": result.Stdout, + "stderr": result.Stderr, + "exit_code": int32(result.ExitCode), + } + jsVal, err := bridge.JsValue(v8ctx, res) + if err != nil { + return bridge.JsException(v8ctx, err.Error()) + } + return jsVal + }) +} + +// computerVNCMethod implements ctx.computer.VNC() +// Returns the VNC URL string. +func (ctx *Context) computerVNCMethod(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + v8ctx := info.Context() + + if ctx.computer == nil { + return bridge.JsException(v8ctx, "computer not available") + } + + url, err := ctx.computer.VNC(context.Background()) + if err != nil { + return bridge.JsException(v8ctx, "VNC failed: "+err.Error()) + } + + jsVal, err := v8go.NewValue(iso, url) + if err != nil { + return bridge.JsException(v8ctx, err.Error()) + } + return jsVal + }) +} + +// computerProxyMethod implements ctx.computer.Proxy(port, path?) +// Returns the proxy URL string. +func (ctx *Context) computerProxyMethod(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + v8ctx := info.Context() + args := info.Args() + + if ctx.computer == nil { + return bridge.JsException(v8ctx, "computer not available") + } + if len(args) < 1 || !args[0].IsNumber() { + return bridge.JsException(v8ctx, "Proxy requires a port number") + } + + port := int(args[0].Integer()) + path := "" + if len(args) >= 2 && args[1].IsString() { + path = args[1].String() + } + + url, err := ctx.computer.Proxy(context.Background(), port, path) + if err != nil { + return bridge.JsException(v8ctx, "Proxy failed: "+err.Error()) + } + + jsVal, err := v8go.NewValue(iso, url) + if err != nil { + return bridge.JsException(v8ctx, err.Error()) + } + return jsVal + }) +} + +// computerInfoMethod implements ctx.computer.Info() +// Returns a JS object with computer identity and system information. +func (ctx *Context) computerInfoMethod(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + v8ctx := info.Context() + + if ctx.computer == nil { + return bridge.JsException(v8ctx, "computer not available") + } + + ci := ctx.computer.ComputerInfo() + result := map[string]interface{}{ + "kind": ci.Kind, + "node_id": ci.NodeID, + "tai_id": ci.TaiID, + "status": ci.Status, + "system": map[string]interface{}{ + "os": ci.System.OS, + "arch": ci.System.Arch, + "hostname": ci.System.Hostname, + "num_cpu": int32(ci.System.NumCPU), + "shell": ci.System.Shell, + }, + } + if ci.BoxID != "" { + result["box_id"] = ci.BoxID + result["container_id"] = ci.ContainerID + result["image"] = ci.Image + result["policy"] = string(ci.Policy) + } + + jsVal, err := bridge.JsValue(v8ctx, result) + if err != nil { + return bridge.JsException(v8ctx, err.Error()) + } + return jsVal + }) +} + +// parseCommandArg converts a JS value (string or string array) to []string. +func parseCommandArg(v8ctx *v8go.Context, val *v8go.Value) ([]string, error) { + if val.IsString() { + raw := val.String() + return strings.Fields(raw), nil + } + + if val.IsArray() { + obj, err := val.AsObject() + if err != nil { + return nil, err + } + lengthVal, err := obj.Get("length") + if err != nil { + return nil, err + } + length := int(lengthVal.Integer()) + cmd := make([]string, length) + for i := 0; i < length; i++ { + item, err := obj.GetIdx(uint32(i)) + if err != nil { + return nil, err + } + cmd[i] = item.String() + } + return cmd, nil + } + + return nil, fmt.Errorf("command must be a string or array of strings") +} diff --git a/agent/context/jsapi_workspace.go b/agent/context/jsapi_workspace.go new file mode 100644 index 00000000..d02c3168 --- /dev/null +++ b/agent/context/jsapi_workspace.go @@ -0,0 +1,291 @@ +package context + +import ( + "io/fs" + "os" + + "github.com/yaoapp/gou/runtime/v8/bridge" + "rogchap.com/v8go" +) + +// createWorkspaceInstance creates the ctx.workspace JavaScript object. +func (ctx *Context) createWorkspaceInstance(v8ctx *v8go.Context) *v8go.Value { + if ctx.workspace == nil { + return nil + } + + iso := v8ctx.Isolate() + objTpl := v8go.NewObjectTemplate(iso) + + objTpl.Set("ReadFile", ctx.wsReadFileMethod(iso)) + objTpl.Set("WriteFile", ctx.wsWriteFileMethod(iso)) + objTpl.Set("ReadDir", ctx.wsReadDirMethod(iso)) + objTpl.Set("MkdirAll", ctx.wsMkdirAllMethod(iso)) + objTpl.Set("Remove", ctx.wsRemoveMethod(iso)) + objTpl.Set("RemoveAll", ctx.wsRemoveAllMethod(iso)) + objTpl.Set("Rename", ctx.wsRenameMethod(iso)) + objTpl.Set("Copy", ctx.wsCopyMethod(iso)) + objTpl.Set("Stat", ctx.wsStatMethod(iso)) + objTpl.Set("Exists", ctx.wsExistsMethod(iso)) + + instance, err := objTpl.NewInstance(v8ctx) + if err != nil { + return nil + } + return instance.Value +} + +// wsReadFileMethod implements ctx.workspace.ReadFile(path) +func (ctx *Context) wsReadFileMethod(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + v8ctx := info.Context() + args := info.Args() + + if ctx.workspace == nil { + return bridge.JsException(v8ctx, "workspace not available") + } + if len(args) < 1 { + return bridge.JsException(v8ctx, "ReadFile requires a path argument") + } + + data, err := ctx.workspace.ReadFile(args[0].String()) + if err != nil { + return bridge.JsException(v8ctx, "ReadFile failed: "+err.Error()) + } + + jsVal, err := v8go.NewValue(iso, string(data)) + if err != nil { + return bridge.JsException(v8ctx, err.Error()) + } + return jsVal + }) +} + +// wsWriteFileMethod implements ctx.workspace.WriteFile(path, content) +func (ctx *Context) wsWriteFileMethod(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + v8ctx := info.Context() + args := info.Args() + + if ctx.workspace == nil { + return bridge.JsException(v8ctx, "workspace not available") + } + if len(args) < 2 { + return bridge.JsException(v8ctx, "WriteFile requires path and content arguments") + } + + path := args[0].String() + content := args[1].String() + + if err := ctx.workspace.WriteFile(path, []byte(content), 0o644); err != nil { + return bridge.JsException(v8ctx, "WriteFile failed: "+err.Error()) + } + return v8go.Undefined(iso) + }) +} + +// wsReadDirMethod implements ctx.workspace.ReadDir(path) +func (ctx *Context) wsReadDirMethod(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + v8ctx := info.Context() + args := info.Args() + + if ctx.workspace == nil { + return bridge.JsException(v8ctx, "workspace not available") + } + + path := "." + if len(args) >= 1 && args[0].IsString() { + path = args[0].String() + } + + entries, err := ctx.workspace.ReadDir(path) + if err != nil { + return bridge.JsException(v8ctx, "ReadDir failed: "+err.Error()) + } + + result := make([]map[string]interface{}, 0, len(entries)) + for _, e := range entries { + fi, _ := e.Info() + item := map[string]interface{}{ + "name": e.Name(), + "is_dir": e.IsDir(), + } + if fi != nil { + item["size"] = int32(fi.Size()) + } + result = append(result, item) + } + + jsVal, err := bridge.JsValue(v8ctx, result) + if err != nil { + return bridge.JsException(v8ctx, err.Error()) + } + return jsVal + }) +} + +// wsMkdirAllMethod implements ctx.workspace.MkdirAll(path) +func (ctx *Context) wsMkdirAllMethod(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + v8ctx := info.Context() + args := info.Args() + + if ctx.workspace == nil { + return bridge.JsException(v8ctx, "workspace not available") + } + if len(args) < 1 { + return bridge.JsException(v8ctx, "MkdirAll requires a path argument") + } + + if err := ctx.workspace.MkdirAll(args[0].String(), 0o755); err != nil { + return bridge.JsException(v8ctx, "MkdirAll failed: "+err.Error()) + } + return v8go.Undefined(iso) + }) +} + +// wsRemoveMethod implements ctx.workspace.Remove(path) +func (ctx *Context) wsRemoveMethod(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + v8ctx := info.Context() + args := info.Args() + + if ctx.workspace == nil { + return bridge.JsException(v8ctx, "workspace not available") + } + if len(args) < 1 { + return bridge.JsException(v8ctx, "Remove requires a path argument") + } + + if err := ctx.workspace.Remove(args[0].String()); err != nil { + return bridge.JsException(v8ctx, "Remove failed: "+err.Error()) + } + return v8go.Undefined(iso) + }) +} + +// wsRemoveAllMethod implements ctx.workspace.RemoveAll(path) +func (ctx *Context) wsRemoveAllMethod(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + v8ctx := info.Context() + args := info.Args() + + if ctx.workspace == nil { + return bridge.JsException(v8ctx, "workspace not available") + } + if len(args) < 1 { + return bridge.JsException(v8ctx, "RemoveAll requires a path argument") + } + + if err := ctx.workspace.RemoveAll(args[0].String()); err != nil { + return bridge.JsException(v8ctx, "RemoveAll failed: "+err.Error()) + } + return v8go.Undefined(iso) + }) +} + +// wsRenameMethod implements ctx.workspace.Rename(oldName, newName) +func (ctx *Context) wsRenameMethod(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + v8ctx := info.Context() + args := info.Args() + + if ctx.workspace == nil { + return bridge.JsException(v8ctx, "workspace not available") + } + if len(args) < 2 { + return bridge.JsException(v8ctx, "Rename requires oldName and newName arguments") + } + + if err := ctx.workspace.Rename(args[0].String(), args[1].String()); err != nil { + return bridge.JsException(v8ctx, "Rename failed: "+err.Error()) + } + return v8go.Undefined(iso) + }) +} + +// wsCopyMethod implements ctx.workspace.Copy(src, dst) +func (ctx *Context) wsCopyMethod(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + v8ctx := info.Context() + args := info.Args() + + if ctx.workspace == nil { + return bridge.JsException(v8ctx, "workspace not available") + } + if len(args) < 2 { + return bridge.JsException(v8ctx, "Copy requires src and dst arguments") + } + + if _, err := ctx.workspace.Copy(args[0].String(), args[1].String()); err != nil { + return bridge.JsException(v8ctx, "Copy failed: "+err.Error()) + } + return v8go.Undefined(iso) + }) +} + +// wsStatMethod implements ctx.workspace.Stat(path) +func (ctx *Context) wsStatMethod(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + v8ctx := info.Context() + args := info.Args() + + if ctx.workspace == nil { + return bridge.JsException(v8ctx, "workspace not available") + } + if len(args) < 1 { + return bridge.JsException(v8ctx, "Stat requires a path argument") + } + + fi, err := ctx.workspace.Stat(args[0].String()) + if err != nil { + return bridge.JsException(v8ctx, "Stat failed: "+err.Error()) + } + + result := map[string]interface{}{ + "name": fi.Name(), + "size": int32(fi.Size()), + "is_dir": fi.IsDir(), + "mode": int32(fi.Mode()), + "mtime": fi.ModTime().UnixMilli(), + } + jsVal, err := bridge.JsValue(v8ctx, result) + if err != nil { + return bridge.JsException(v8ctx, err.Error()) + } + return jsVal + }) +} + +// wsExistsMethod implements ctx.workspace.Exists(path) +func (ctx *Context) wsExistsMethod(iso *v8go.Isolate) *v8go.FunctionTemplate { + return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value { + v8ctx := info.Context() + args := info.Args() + + if ctx.workspace == nil { + return bridge.JsException(v8ctx, "workspace not available") + } + if len(args) < 1 { + return bridge.JsException(v8ctx, "Exists requires a path argument") + } + + _, err := ctx.workspace.Stat(args[0].String()) + exists := err == nil || !isNotExist(err) + + jsVal, _ := v8go.NewValue(iso, exists) + return jsVal + }) +} + +func isNotExist(err error) bool { + if os.IsNotExist(err) { + return true + } + pathErr, ok := err.(*fs.PathError) + if ok && os.IsNotExist(pathErr.Err) { + return true + } + return false +} diff --git a/agent/context/types.go b/agent/context/types.go index 5f5f39ac..10f3aa40 100644 --- a/agent/context/types.go +++ b/agent/context/types.go @@ -11,6 +11,8 @@ import ( "github.com/yaoapp/yao/agent/output" "github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/openapi/oauth/types" + infraV2 "github.com/yaoapp/yao/sandbox/v2" + "github.com/yaoapp/yao/tai/workspace" traceTypes "github.com/yaoapp/yao/trace/types" ) @@ -251,6 +253,8 @@ type Context struct { trace traceTypes.Manager `json:"-"` // Trace manager, lazy initialized on first access messageMetadata *messageMetadataStore `json:"-"` // Thread-safe message metadata store for delta operations sandboxExecutor SandboxExecutor `json:"-"` // Sandbox executor for hooks (set by assistant when sandbox is configured) + computer infraV2.Computer `json:"-"` // V2 sandbox computer (set by assistant when V2 sandbox is configured) + workspace workspace.FS `json:"-"` // V2 workspace FS (derived from computer.Workplace()) // Model capabilities (set by assistant, used by output adapters) Capabilities *llm.Capabilities `json:"-"` // Model capabilities for the current connector diff --git a/agent/sandbox/v2/claude/attachments.go b/agent/sandbox/v2/claude/attachments.go new file mode 100644 index 00000000..5e38438e --- /dev/null +++ b/agent/sandbox/v2/claude/attachments.go @@ -0,0 +1,225 @@ +package claude + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + agentContext "github.com/yaoapp/yao/agent/context" + "github.com/yaoapp/yao/attachment" + workspace "github.com/yaoapp/yao/tai/workspace" +) + +// prepareAttachments resolves __yao.attachment:// URLs in messages, +// copies actual files into the workspace .attachments/{chatID}/ directory via ws.Copy, +// and replaces multimodal content parts with text references. +func prepareAttachments(ctx context.Context, messages []agentContext.Message, chatID string, ws workspace.FS) ([]agentContext.Message, error) { + usedNames := make(map[string]int) + attachDir := ".attachments/" + chatID + + result := make([]agentContext.Message, len(messages)) + copy(result, messages) + + for i, msg := range result { + if msg.Role != "user" { + continue + } + + parts, ok := msg.Content.([]interface{}) + if !ok { + if typedParts, ok := msg.Content.([]agentContext.ContentPart); ok { + iparts := make([]interface{}, len(typedParts)) + for j, p := range typedParts { + 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 + } + + 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 { + textParts = append(textParts, fmt.Sprintf("[Image: %s]", url)) + continue + } + ref, err := resolveAttachment(ctx, uploaderName, fileID, "", attachDir, usedNames, ws) + if err != nil { + textParts = append(textParts, "[Attached image: failed to load]") + continue + } + textParts = append(textParts, ref) + + 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 := resolveAttachment(ctx, uploaderName, fileID, hintName, attachDir, usedNames, ws) + if err != nil { + textParts = append(textParts, "[Attached file: failed to load]") + continue + } + textParts = append(textParts, ref) + } + } + + if len(textParts) > 0 { + newMsg := result[i] + newMsg.Content = strings.Join(textParts, "\n\n") + result[i] = newMsg + } + } + + return result, nil +} + +// resolveAttachment gets the local path of an attachment and copies it into +// the workspace via ws.Copy("local:///abs/path", ".attachments/{chatID}/filename"). +func resolveAttachment( + ctx context.Context, + uploaderName, fileID, hintName, attachDir string, + usedNames map[string]int, + ws workspace.FS, +) (string, error) { + manager, exists := attachment.Managers[uploaderName] + if !exists { + return "", fmt.Errorf("attachment manager not found: %s", uploaderName) + } + + fileInfo, err := manager.Info(ctx, fileID) + if err != nil { + return "", fmt.Errorf("failed to get file info: %w", err) + } + + absPath, _, err := manager.LocalPath(ctx, fileID) + if err != nil { + return "", fmt.Errorf("failed to get local path: %w", err) + } + + filename := fileInfo.Filename + if filename == "" && hintName != "" { + filename = hintName + } + if filename == "" { + ext := extensionFromContentType(fileInfo.ContentType) + filename = fileID + ext + } + + 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 + } + + dstPath := attachDir + "/" + filename + src := "local:///" + absPath + + if _, err := ws.Copy(src, dstPath); err != nil { + return "", fmt.Errorf("failed to copy attachment to workspace: %w", err) + } + + sizeStr := formatFileSize(fileInfo.Bytes) + return fmt.Sprintf("[Attached file: %s (%s, %s)]", dstPath, fileInfo.ContentType, sizeStr), nil +} + +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 "" + } +} + +func formatFileSize(bytes int) string { + switch { + case bytes >= 1024*1024: + return fmt.Sprintf("%.1fMB", float64(bytes)/(1024*1024)) + case bytes >= 1024: + return fmt.Sprintf("%.1fKB", float64(bytes)/1024) + default: + return fmt.Sprintf("%dB", bytes) + } +} diff --git a/agent/sandbox/v2/claude/parse.go b/agent/sandbox/v2/claude/parse.go new file mode 100644 index 00000000..a960a224 --- /dev/null +++ b/agent/sandbox/v2/claude/parse.go @@ -0,0 +1,239 @@ +package claude + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "log" + "strings" + "time" + + goujson "github.com/yaoapp/gou/json" + agentContext "github.com/yaoapp/yao/agent/context" + "github.com/yaoapp/yao/agent/output/message" +) + +// parseStreamJSON reads stream-json lines from Claude CLI stdout and +// pushes them through handler as standard StreamChunkType events. +func parseStreamJSON(_ context.Context, stdout io.ReadCloser, handler message.StreamFunc) error { + scanner := bufio.NewScanner(stdout) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + messageStarted := false + + type toolState struct { + name string + inputJSON strings.Builder + } + var currentTool *toolState + + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + var msg map[string]any + if err := json.Unmarshal([]byte(line), &msg); err != nil { + continue + } + + msgType, _ := msg["type"].(string) + stopped := false + + switch msgType { + case "system": + if handler != nil { + data, _ := json.Marshal(msg) + if handler(message.ChunkMetadata, data) != 0 { + stopped = true + } + } + + case "stream_event": + event, _ := msg["event"].(map[string]any) + if event == nil { + continue + } + eventType, _ := event["type"].(string) + + switch eventType { + case "content_block_start": + if cb, ok := event["content_block"].(map[string]any); ok { + blockType, _ := cb["type"].(string) + if blockType == "tool_use" { + toolName, _ := cb["name"].(string) + currentTool = &toolState{name: toolName} + if handler != nil { + data, _ := json.Marshal(map[string]any{"tool": toolName}) + if handler(message.ChunkToolCall, data) != 0 { + stopped = true + } + } + } + } + + case "content_block_delta": + if delta, ok := event["delta"].(map[string]any); ok { + deltaType, _ := delta["type"].(string) + switch deltaType { + case "text_delta": + if text, ok := delta["text"].(string); ok && text != "" { + if handler != nil { + if !messageStarted { + startData := message.EventMessageStartData{ + MessageID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()), + Type: "text", + Timestamp: time.Now().UnixMilli(), + } + sd, _ := json.Marshal(startData) + if handler(message.ChunkMessageStart, sd) != 0 { + stopped = true + break + } + messageStarted = true + } + if handler(message.ChunkText, []byte(text)) != 0 { + stopped = true + } + } + } + case "input_json_delta": + if currentTool != nil { + if partial, ok := delta["partial_json"].(string); ok { + currentTool.inputJSON.WriteString(partial) + } + } + } + } + + case "content_block_stop": + currentTool = nil + } + + case "assistant": + if msgData, ok := msg["message"].(map[string]any); ok { + stopReason, _ := msgData["stop_reason"].(string) + if stopReason != "" { + if contentArr, ok := msgData["content"].([]any); ok { + for _, item := range contentArr { + ci, ok := item.(map[string]any) + if !ok { + continue + } + itemType, _ := ci["type"].(string) + if itemType == "text" { + if text, ok := ci["text"].(string); ok && text != "" && handler != nil && !messageStarted { + startData := message.EventMessageStartData{ + MessageID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()), + Type: "text", + Timestamp: time.Now().UnixMilli(), + } + sd, _ := json.Marshal(startData) + if handler(message.ChunkMessageStart, sd) != 0 { + stopped = true + break + } + if handler(message.ChunkText, []byte(text)) != 0 { + stopped = true + break + } + messageStarted = true + } + } + } + } + } + } + + case "result": + isError, _ := msg["is_error"].(bool) + if isError { + if result, ok := msg["result"].(string); ok { + if handler != nil { + handler(message.ChunkError, []byte(result)) + } + return fmt.Errorf("Claude CLI error: %s", result) + } + } + if handler != nil && messageStarted { + handler(message.ChunkMessageEnd, nil) + } + + case "error": + var errMsg string + switch e := msg["error"].(type) { + case string: + errMsg = e + case map[string]any: + errMsg, _ = e["message"].(string) + } + if errMsg != "" { + if handler != nil { + handler(message.ChunkError, []byte(errMsg)) + } + return fmt.Errorf("Claude CLI error: %s", errMsg) + } + } + + if stopped { + break + } + } + + return scanner.Err() +} + +// buildFirstRequestJSONL builds JSONL with all messages for the first request. +func buildFirstRequestJSONL(messages []agentContext.Message) string { + var lines []string + for _, msg := range messages { + if msg.Role == "system" { + continue + } + content := msg.Content + if content == nil { + content = "" + } + streamMsg := map[string]any{ + "type": string(msg.Role), + "message": map[string]any{ + "role": string(msg.Role), + "content": content, + }, + } + data, _ := json.Marshal(streamMsg) + lines = append(lines, string(data)) + } + return strings.Join(lines, "\n") +} + +// buildLastUserMessageJSONL builds JSONL with only the last user message. +func buildLastUserMessageJSONL(messages []agentContext.Message) string { + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role == "user" { + content := messages[i].Content + if content == nil { + content = "" + } + msg := map[string]any{ + "type": "user", + "message": map[string]any{ + "role": "user", + "content": content, + }, + } + data, _ := json.Marshal(msg) + return string(data) + } + } + return "" +} + +// Suppress unused import warnings — goujson.Parse is used for tool description +// parsing in V1 and will be used for detailed tool descriptions in future. +var _ = goujson.Parse +var _ = log.Printf diff --git a/agent/sandbox/v2/claude/runner.go b/agent/sandbox/v2/claude/runner.go new file mode 100644 index 00000000..ea9516ab --- /dev/null +++ b/agent/sandbox/v2/claude/runner.go @@ -0,0 +1,458 @@ +package claude + +import ( + "context" + "encoding/json" + "fmt" + "path" + "strings" + "time" + + "github.com/yaoapp/gou/connector" + "github.com/yaoapp/yao/agent/output/message" + "github.com/yaoapp/yao/agent/sandbox/v2/types" + infra "github.com/yaoapp/yao/sandbox/v2" +) + +const ( + defaultWorkDir = "/workspace" + defaultUser = "sandbox" + defaultUserHome = "/home/sandbox" + defaultProxyPort = 3456 +) + +// ClaudeRunner implements the Runner interface for Claude CLI (mode=cli). +type ClaudeRunner struct { + mode string + proxyReady bool + hasMCP bool + mcpToolPattern string // e.g. "mcp__yao__*,mcp__github__*" + servicePort int + servicePath string + serviceProtocol string +} + +// New creates a new ClaudeRunner. +func New() *ClaudeRunner { + return &ClaudeRunner{mode: "cli"} +} + +func (r *ClaudeRunner) Name() string { return "claude" } + +// Prepare executes user-defined and runner-specific prepare steps. +func (r *ClaudeRunner) Prepare(ctx context.Context, req *types.PrepareRequest) error { + r.mode = req.Config.Runner.Mode + if r.mode == "" { + r.mode = "cli" + } + + workDir := resolveWorkDir(req.Config) + + // Merge user-defined steps with runner-specific steps. + steps := append([]types.PrepareStep{}, req.Config.Prepare...) + + // Runner-specific: ensure .claude directory in workDir. + if req.SkillsDir != "" { + steps = append(steps, types.PrepareStep{ + Action: "exec", + Cmd: fmt.Sprintf("mkdir -p %s/.claude", workDir), + Once: true, + }) + } + + // Runner-specific: write proxy config and start proxy (for non-anthropic connectors). + if req.Connector != nil && !req.Connector.Is(connector.ANTHROPIC) { + setting := req.Connector.Setting() + host, _ := setting["host"].(string) + key, _ := setting["key"].(string) + model, _ := setting["model"].(string) + if host != "" && key != "" { + proxyJSON := buildProxyConfig(host, key, model, setting) + steps = append(steps, types.PrepareStep{ + Action: "file", + Path: ".yao/proxy.json", + Content: proxyJSON, + Once: true, + }) + steps = append(steps, types.PrepareStep{ + Action: "exec", + Cmd: "which start-claude-proxy && start-claude-proxy || true", + Once: true, + IgnoreError: true, + }) + r.proxyReady = true + } + } + + // Runner-specific: write MCP config. + if len(req.MCPServers) > 0 { + r.hasMCP = true + r.mcpToolPattern = buildMCPAllowedTools(req.MCPServers) + mcpJSON := buildMCPConfig(req.MCPServers) + steps = append(steps, types.PrepareStep{ + Action: "file", + Path: path.Join(workDir, ".mcp.json"), + Content: mcpJSON, + }) + } + + // Execute all steps via the injected callback. + if req.RunSteps != nil && len(steps) > 0 { + if err := req.RunSteps(ctx, steps, req.Computer, req.Config.ID, req.ConfigHash); err != nil { + return fmt.Errorf("claude prepare steps: %w", err) + } + } + + return nil +} + +// Stream executes the Claude CLI and streams output to handler. +func (r *ClaudeRunner) Stream(ctx context.Context, req *types.StreamRequest, handler message.StreamFunc) error { + computer := req.Computer + if computer == nil { + return fmt.Errorf("computer is nil") + } + + workDir := resolveWorkDir(req.Config) + + // Prepare attachments: resolve __yao.attachment:// URLs, copy files to workspace. + if req.ChatID != "" { + ws := computer.Workplace() + if ws != nil { + processed, err := prepareAttachments(ctx, req.Messages, req.ChatID, ws) + if err != nil { + return fmt.Errorf("prepareAttachments: %w", err) + } + req.Messages = processed + } + } + + // Detect continuation (existing .claude/projects/ directory). + isContinuation := hasExistingSession(ctx, computer, workDir) + + // Build CLI command and env. + cmd, env := r.buildCLICommand(req, isContinuation) + + // Create stream. + execStream, err := computer.Stream(ctx, cmd, infra.WithWorkDir(workDir), infra.WithEnv(env)) + if err != nil { + return fmt.Errorf("computer.Stream: %w", err) + } + + // Monitor for context cancellation — kill the process. + done := make(chan struct{}) + defer func() { + close(done) + }() + + go func() { + select { + case <-ctx.Done(): + killCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + computer.Exec(killCtx, []string{"pkill", "-f", "claude"}) + execStream.Cancel() + case <-done: + } + }() + + // Parse streaming output. + parseErr := parseStreamJSON(ctx, execStream.Stdout, handler) + + // Wait for process exit. + exitCode, waitErr := execStream.Wait() + if parseErr != nil { + return parseErr + } + if waitErr != nil { + return waitErr + } + if exitCode != 0 { + return fmt.Errorf("claude CLI exited with code %d", exitCode) + } + return nil +} + +// Cleanup kills any remaining claude processes. +// mode=service: don't kill the service daemon (lifecycle manages it), only clean proxy. +// mode=cli: kill all claude CLI processes. +func (r *ClaudeRunner) Cleanup(ctx context.Context, computer infra.Computer) error { + if computer == nil { + return nil + } + + if r.mode != "service" { + computer.Exec(ctx, []string{"sh", "-c", "pkill -f 'claude' || true"}) + } + + if r.proxyReady { + computer.Exec(ctx, []string{"sh", "-c", "pkill -f 'claude-proxy' || true"}) + } + + return nil +} + +// hasExistingSession checks if a Claude CLI session exists in the workspace. +func hasExistingSession(ctx context.Context, computer infra.Computer, workDir string) bool { + sessionDir := path.Join(workDir, ".claude/projects") + result, err := computer.Exec(ctx, []string{"ls", sessionDir}) + if err != nil || result.ExitCode != 0 { + return false + } + return strings.TrimSpace(result.Stdout) != "" +} + +// buildCLICommand constructs the Claude CLI command and environment variables. +func (r *ClaudeRunner) buildCLICommand(req *types.StreamRequest, isContinuation bool) ([]string, map[string]string) { + workDir := resolveWorkDir(req.Config) + userHome := resolveUserHome(req.Config) + + env := make(map[string]string) + env["HOME"] = workDir + + // User-specific paths (only set when running as non-root user inside container). + if userHome != "" { + env["XAUTHORITY"] = path.Join(userHome, ".Xauthority") + } + + // Connector environment. + if req.Connector != nil { + setting := req.Connector.Setting() + host, _ := setting["host"].(string) + key, _ := setting["key"].(string) + model, _ := setting["model"].(string) + + if req.Connector.Is(connector.ANTHROPIC) { + env["ANTHROPIC_BASE_URL"] = host + env["ANTHROPIC_API_KEY"] = key + } else { + env["ANTHROPIC_BASE_URL"] = fmt.Sprintf("http://127.0.0.1:%d", defaultProxyPort) + env["ANTHROPIC_API_KEY"] = "dummy" + } + + if model != "" { + env["ANTHROPIC_MODEL"] = model + env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = model + env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = model + env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = model + env["CLAUDE_CODE_SUBAGENT_MODEL"] = model + } + } + + // Secrets from config. + if req.Config != nil && len(req.Config.Secrets) > 0 { + for k, v := range req.Config.Secrets { + env[k] = v + } + } + + // Build system prompt. + var systemPrompt string + envPrompt := buildSandboxEnvPrompt(workDir) + if !isContinuation && req.SystemPrompt != "" { + systemPrompt = req.SystemPrompt + "\n\n" + envPrompt + } else if !isContinuation { + systemPrompt = envPrompt + } + + // Build input JSONL. + var inputJSONL string + if isContinuation { + inputJSONL = buildLastUserMessageJSONL(req.Messages) + } else { + inputJSONL = buildFirstRequestJSONL(req.Messages) + } + + // CLI args. + var args []string + args = append(args, "--dangerously-skip-permissions") + args = append(args, "--permission-mode", "bypassPermissions") + args = append(args, "--input-format", "stream-json") + args = append(args, "--output-format", "stream-json") + args = append(args, "--include-partial-messages") + args = append(args, "--verbose") + + if isContinuation { + args = append(args, "--continue") + } + + // Runner options pass-through. + if req.Config != nil && req.Config.Runner.Options != nil { + for key, val := range req.Config.Runner.Options { + if flag, ok := claudeArgWhitelist[key]; ok { + args = append(args, flag, fmt.Sprintf("%v", val)) + } + } + } + + // MCP config (set by Prepare if MCPServers were present). + if r.hasMCP { + args = append(args, "--mcp-config", path.Join(workDir, ".mcp.json")) + if r.mcpToolPattern != "" { + args = append(args, "--allowedTools", r.mcpToolPattern) + } + } + + // Build bash command with heredoc. + var bash strings.Builder + if userHome != "" { + bash.WriteString(fmt.Sprintf("touch %s/.Xauthority 2>/dev/null; ", userHome)) + } + bash.WriteString("touch \"$HOME/.Xauthority\" 2>/dev/null\n") + + if systemPrompt != "" { + promptFile := path.Join(workDir, ".yao/.system-prompt.txt") + bash.WriteString(fmt.Sprintf("mkdir -p %s/.yao\n", workDir)) + bash.WriteString(fmt.Sprintf("cat << 'PROMPTEOF' > %s\n", promptFile)) + bash.WriteString(systemPrompt) + bash.WriteString("\nPROMPTEOF\n") + args = append(args, "--append-system-prompt-file", promptFile) + } + + bash.WriteString("cat << 'INPUTEOF' | claude -p") + for _, arg := range args { + bash.WriteString(fmt.Sprintf(" %q", arg)) + } + bash.WriteString(" 2>&1\n") + bash.WriteString(inputJSONL) + bash.WriteString("\nINPUTEOF") + + return []string{"bash", "-c", bash.String()}, env +} + +// buildProxyConfig creates the claude-proxy configuration JSON. +func buildProxyConfig(host, key, model string, setting map[string]any) []byte { + backendURL := connector.BuildAPIURL(host, "/chat/completions") + config := map[string]any{ + "backend": backendURL, + "api_key": key, + "model": model, + } + opts := make(map[string]any) + for k, v := range setting { + switch k { + case "host", "key", "model", "azure", "capabilities": + continue + default: + opts[k] = v + } + } + if len(opts) > 0 { + config["options"] = opts + } + data, _ := json.MarshalIndent(config, "", " ") + return data +} + +// buildMCPConfig creates the .mcp.json for Claude CLI based on declared servers. +// Each server delegates to "tai call" which bridges stdio JSON-RPC to Yao gRPC. +// Connection is configured via env vars (YAO_GRPC_ADDR, YAO_TOKEN, etc.) +// injected by the sandbox infrastructure at container start. +func buildMCPConfig(servers []types.MCPServer) []byte { + mcpServers := make(map[string]any, len(servers)) + for _, s := range servers { + name := s.ServerID + if name == "" { + continue + } + mcpServers[name] = map[string]any{ + "command": "tai", + "args": []string{"call"}, + } + } + if len(mcpServers) == 0 { + mcpServers["yao"] = map[string]any{ + "command": "tai", + "args": []string{"call"}, + } + } + config := map[string]any{"mcpServers": mcpServers} + data, _ := json.Marshal(config) + return data +} + +// buildMCPAllowedTools generates the --allowedTools pattern from server IDs. +func buildMCPAllowedTools(servers []types.MCPServer) string { + patterns := make([]string, 0, len(servers)) + for _, s := range servers { + if s.ServerID != "" { + patterns = append(patterns, fmt.Sprintf("mcp__%s__*", s.ServerID)) + } + } + if len(patterns) == 0 { + return "mcp__yao__*" + } + return strings.Join(patterns, ",") +} + +// buildSandboxEnvPrompt generates the sandbox environment prompt with the actual working directory. +func buildSandboxEnvPrompt(workDir string) string { + return fmt.Sprintf(`## Sandbox Environment + +You are running in a sandboxed environment with the following setup: + +- **Working Directory**: %[1]s +- **Project Structure**: If this is a new project, create a dedicated project folder (e.g., %[1]s/my-project/) and work inside it +- **File Access**: You have full read/write access to %[1]s +- **Output Files**: Save all output files to the working directory + +When creating new projects: +1. Create a project directory with a descriptive name +2. Initialize the project structure inside that directory +3. Keep all related files organized within the project folder + +## IMPORTANT: Restricted Tools + +The following tools are NOT available in this environment and you must NOT use them: +- EnterPlanMode, ExitPlanMode (use regular text to explain plans instead) +- Task, TaskOutput, TaskStop (complete tasks directly without delegation) +- AskUserQuestion (make reasonable assumptions instead of asking) +- Skill, ToolSearch (not supported) + +Focus on using the core tools: Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch. + +## User Attachments + +User-uploaded files (images, documents, code files, etc.) are placed in %[1]s/.attachments/{chatID}/ +Each chat session has its own subdirectory to avoid conflicts. +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: +1. First authenticate gh CLI using the token: echo "TOKEN" | gh auth login --with-token +2. Then use gh commands normally (gh repo create, gh pr create, etc.) +3. Do NOT use curl to call GitHub API directly - always prefer gh CLI +`, workDir) +} + +// resolveWorkDir returns the configured working directory, falling back to default. +func resolveWorkDir(cfg *types.SandboxConfig) string { + if cfg != nil && cfg.Computer.WorkDir != "" { + return cfg.Computer.WorkDir + } + return defaultWorkDir +} + +// resolveUserHome returns the home directory for the container user. +// Returns empty string if no user is configured (root or unspecified). +func resolveUserHome(cfg *types.SandboxConfig) string { + if cfg == nil { + return defaultUserHome + } + user := cfg.Computer.User + if user == "" { + user = defaultUser + } + if user == "root" { + return "/root" + } + return fmt.Sprintf("/home/%s", user) +} + +var claudeArgWhitelist = map[string]string{ + "max_turns": "--max-turns", + "disallowed_tools": "--disallowed-tools", + "allowed_tools": "--allowedTools", +} diff --git a/agent/sandbox/v2/claude/runner_test.go b/agent/sandbox/v2/claude/runner_test.go new file mode 100644 index 00000000..97afa979 --- /dev/null +++ b/agent/sandbox/v2/claude/runner_test.go @@ -0,0 +1,279 @@ +package claude_test + +import ( + "bytes" + "context" + "fmt" + "mime/multipart" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yaoapp/yao/agent/caller" + agentcontext "github.com/yaoapp/yao/agent/context" + sandboxtestutils "github.com/yaoapp/yao/agent/sandbox/v2/testutils" + "github.com/yaoapp/yao/attachment" + oauthtypes "github.com/yaoapp/yao/openapi/oauth/types" +) + +type e2eCase struct { + ID string + Prompt string + Timeout time.Duration +} + +var cases = []e2eCase{ + { + ID: "tests.sandbox-v2.oneshot-cli", + Prompt: "Reply exactly with: hello sandbox v2", + Timeout: 3 * time.Minute, + }, +} + +func TestSandboxV2_Claude_E2E(t *testing.T) { + sandboxtestutils.Prepare(t) + defer sandboxtestutils.Clean(t) + + require.NotNil(t, caller.AgentGetterFunc, "AgentGetterFunc should be registered after Prepare") + + for _, tc := range cases { + tc := tc + t.Run(tc.ID, func(t *testing.T) { + agent, err := caller.AgentGetterFunc(tc.ID) + require.NoError(t, err, "should load assistant %s", tc.ID) + + timeout := tc.Timeout + if timeout == 0 { + timeout = 3 * time.Minute + } + + chatID := fmt.Sprintf("e2e-%s-%d", tc.ID, time.Now().UnixMilli()) + ctx := agentcontext.New( + context.Background(), + &oauthtypes.AuthorizedInfo{ + TeamID: "test-team-e2e", + UserID: "test-user-e2e", + }, + chatID, + ) + + messages := []agentcontext.Message{ + {Role: "user", Content: tc.Prompt}, + } + + done := make(chan struct{}) + var resp *agentcontext.Response + var streamErr error + + go func() { + defer close(done) + resp, streamErr = agent.Stream(ctx, messages) + }() + + select { + case <-done: + case <-time.After(timeout): + t.Fatalf("timeout after %v", timeout) + } + + require.NoError(t, streamErr, "Stream should not return error") + require.NotNil(t, resp, "response should not be nil") + + // ── 1. CompletionResponse should behave like the LLM path ── + require.NotNil(t, resp.Completion, "completion should not be nil") + assert.Equal(t, "assistant", resp.Completion.Role, "role should be assistant") + assert.Equal(t, agentcontext.FinishReasonStop, resp.Completion.FinishReason, "finish_reason should be stop") + assert.NotNil(t, resp.Completion.Content, "Content should be populated (same as LLM path)") + + contentStr, ok := resp.Completion.Content.(string) + require.True(t, ok, "Content should be a string, got %T", resp.Completion.Content) + t.Logf("CompletionResponse.Content (%d chars): %s", len(contentStr), contentStr) + assert.Contains(t, contentStr, "hello sandbox v2", "Content should contain expected text") + + // ── 2. Buffer: frame sequence handled correctly ── + require.NotNil(t, ctx.Buffer, "ctx.Buffer should not be nil") + + msgs := ctx.Buffer.GetMessages() + t.Logf("buffer message count: %d", len(msgs)) + for _, m := range msgs { + t.Logf(" seq=%d role=%s type=%s streaming=%v props_keys=%v", + m.Sequence, m.Role, m.Type, m.IsStreaming, mapKeys(m.Props)) + } + + var userInputCount, assistantTextCount, loadingCount int + var bufferTextContent string + for _, m := range msgs { + switch { + case m.Role == "user" && m.Type == "user_input": + userInputCount++ + case m.Role == "assistant" && m.Type == "loading": + loadingCount++ + case m.Role == "assistant" && m.Type == "text": + assistantTextCount++ + assert.False(t, m.IsStreaming, "text message should not be streaming (handleMessageEnd should have finalized it)") + require.NotNil(t, m.Props, "text message props should not be nil") + if c, ok := m.Props["content"].(string); ok { + bufferTextContent += c + } + } + } + + assert.Equal(t, 1, userInputCount, "should have exactly 1 user_input message") + assert.GreaterOrEqual(t, loadingCount, 1, "should have at least 1 loading message") + assert.Equal(t, 1, assistantTextCount, "should have exactly 1 assistant text message (from handleMessageEnd)") + assert.Contains(t, bufferTextContent, "hello sandbox v2", "buffer text should contain expected content") + + // ── 3. Buffer content matches CompletionResponse.Content ── + assert.Equal(t, contentStr, bufferTextContent, + "CompletionResponse.Content and Buffer text should match") + }) + } +} + +func TestSandboxV2_Claude_Attachments(t *testing.T) { + sandboxtestutils.Prepare(t) + defer sandboxtestutils.Clean(t) + + require.NotNil(t, caller.AgentGetterFunc, "AgentGetterFunc should be registered after Prepare") + + agent, err := caller.AgentGetterFunc("tests.sandbox-v2.oneshot-cli") + require.NoError(t, err) + + // ── 1. Locate testdata via runtime.Caller ── + _, thisFile, _, ok := runtime.Caller(0) + require.True(t, ok) + testdataDir := filepath.Join(filepath.Dir(thisFile), "testdata") + + // ── 2. Create attachment manager and upload test files ── + const uploaderName = "__yao.attachment" + manager, err := attachment.New(attachment.ManagerOption{ + Driver: "local", + MaxSize: "50M", + AllowedTypes: []string{"image/*", "text/*", "application/*", "video/*", ".ts", ".js", ".tsx", ".jsx"}, + Options: map[string]interface{}{"path": filepath.Join(os.TempDir(), "test_sandbox_v2_attach")}, + }) + require.NoError(t, err) + manager.Name = uploaderName + attachment.Managers[uploaderName] = manager + t.Cleanup(func() { delete(attachment.Managers, uploaderName) }) + + imgFile := uploadTestFile(t, manager, testdataDir, "test-image.png", "image/png") + codeFile := uploadTestFile(t, manager, testdataDir, "code.ts", "text/plain") + + imgWrapper := fmt.Sprintf("%s://%s", uploaderName, imgFile.ID) + codeWrapper := fmt.Sprintf("%s://%s", uploaderName, codeFile.ID) + t.Logf("image wrapper: %s", imgWrapper) + t.Logf("code wrapper: %s", codeWrapper) + + // ── 3. Build multimodal messages (same as CUI InputArea) ── + chatID := fmt.Sprintf("e2e-attach-%d", time.Now().UnixMilli()) + ctx := agentcontext.New( + context.Background(), + &oauthtypes.AuthorizedInfo{TeamID: "test-team-e2e", UserID: "test-user-e2e"}, + chatID, + ) + + messages := []agentcontext.Message{ + { + Role: "user", + Content: []interface{}{ + map[string]interface{}{"type": "text", "text": "Describe the attached image and summarize the attached code file. Reply in English."}, + map[string]interface{}{ + "type": "image_url", + "image_url": map[string]interface{}{"url": imgWrapper, "detail": "auto"}, + }, + map[string]interface{}{ + "type": "file", + "file": map[string]interface{}{"url": codeWrapper, "filename": "code.ts"}, + }, + }, + }, + } + + // ── 4. Run E2E stream ── + done := make(chan struct{}) + var resp *agentcontext.Response + var streamErr error + + go func() { + defer close(done) + resp, streamErr = agent.Stream(ctx, messages) + }() + + select { + case <-done: + case <-time.After(5 * time.Minute): + t.Fatalf("timeout after 5m") + } + + require.NoError(t, streamErr, "Stream should not return error") + require.NotNil(t, resp) + require.NotNil(t, resp.Completion) + + contentStr, ok := resp.Completion.Content.(string) + require.True(t, ok, "Content should be a string, got %T", resp.Completion.Content) + t.Logf("Response (%d chars): %s", len(contentStr), contentStr) + + lower := strings.ToLower(contentStr) + + // ── 5. Verify Claude actually read the image ── + imageKeywords := []string{"hello", "utf", "chinese", "text", "emoji"} + imgHit := false + for _, kw := range imageKeywords { + if strings.Contains(lower, kw) { + imgHit = true + break + } + } + assert.True(t, imgHit, "response should mention image content (tried: %v)", imageKeywords) + + // ── 6. Verify Claude actually read the code ── + codeKeywords := []string{"excel", "typescript", "class", "volcengine"} + codeHit := false + for _, kw := range codeKeywords { + if strings.Contains(lower, kw) { + codeHit = true + break + } + } + assert.True(t, codeHit, "response should mention code content (tried: %v)", codeKeywords) +} + +func uploadTestFile(t *testing.T, manager *attachment.Manager, testdataDir, filename, contentType string) *attachment.File { + t.Helper() + path := filepath.Join(testdataDir, filename) + data, err := os.ReadFile(path) + require.NoError(t, err, "read testdata/%s", filename) + + fh := &attachment.FileHeader{ + FileHeader: &multipart.FileHeader{ + Filename: filename, + Size: int64(len(data)), + Header: make(map[string][]string), + }, + } + fh.Header.Set("Content-Type", contentType) + + file, err := manager.Upload(context.Background(), fh, bytes.NewReader(data), attachment.UploadOption{ + Groups: []string{"e2e-sandbox-v2"}, + }) + require.NoError(t, err, "upload testdata/%s", filename) + t.Logf("uploaded %s => ID=%s, Path=%s", filename, file.ID, file.Path) + return file +} + +func mapKeys(m map[string]interface{}) []string { + if m == nil { + return nil + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} diff --git a/agent/sandbox/v2/claude/testdata/code.ts b/agent/sandbox/v2/claude/testdata/code.ts new file mode 100644 index 00000000..458e8de1 --- /dev/null +++ b/agent/sandbox/v2/claude/testdata/code.ts @@ -0,0 +1,904 @@ +import { Process } from "@yao/runtime"; + +/** + * Excel class for manipulating Excel files via Yao's Excel Module + */ +export class Excel { + private handle: string | null = null; + + /** + * Creates a new Excel instance + * @param file Path to the Excel file + */ + constructor(private file: string, writable: boolean = false) { + this.file = file; + this.Open(writable); + } + + /** + * Read each sheet top n rows + * @param file Path to the Excel file + * @param n number of rows to read + * @returns Object with sheet names as keys and arrays of row values as values + */ + static Heads( + file: string, + n: number = 5, + filters?: string[] + ): Record { + const excel = new Excel(file); + const heads = excel.Heads(n, filters); + excel.Close(); + return heads; + } + + /** + * Read each sheet top n rows + * @param n number of rows to read + * @returns Object with sheet names as keys and arrays of row values as values + * @throws Error if file not opened + */ + Heads(n: number = 5, filters: string[] = []): Record { + if (!this.handle) throw new Error("Excel file not opened"); + + const sheets = this.Sheets(); + const result: Record = {}; + + for (const sheet of sheets) { + if (filters.length > 0 && !filters.includes(sheet)) { + continue; + } + + // Open row iterator for the sheet + const iterator = this.each.OpenRow(sheet); + const rows: any[][] = []; + + // Read n rows + let row; + let count = 0; + while ( + count < n && + (row = Process(`excel.each.NextRow`, iterator)) !== null + ) { + // Add column headers (A, B, C, ...) for the first row + if (count === 0) { + const headerRow = []; + for (let i = 0; i < row.length; i++) { + headerRow.push(this.convert.ColumnNumberToName(i + 1)); + } + rows.push(headerRow); + } + + // Trim Each cell's value + row = row.map((cell) => cell?.trim?.()); + rows.push(row); + count++; + } + + // Close the row iterator + this.each.CloseRow(iterator); + + // Find the max length of each row, and pad the column headers(A, B, C, ...) to the same length + const maxLength = Math.max(...rows.map((row) => row.length)); + const start = rows[0].length; + const neededLength = maxLength - rows[0].length; + for (let i = 0; i < neededLength; i++) { + rows[0].push(this.convert.ColumnNumberToName(start + i + 1)); + } + + // Add the sheet's rows to the result + result[sheet] = rows; + } + + return result; + } + + /** + * Check if a sheet exists in the Excel file + * @param file Path to the Excel file + * @param sheet Sheet name to check + * @returns boolean - true if sheet exists, false otherwise + */ + static Exists(file: string, sheet: string) { + const excel = new Excel(file); + const exists = excel.sheet.Exists(sheet); + excel.Close(); + return exists; + } + + /** + * Opens an Excel file for reading or writing + * @param writable Whether to open in writable mode (true) or read-only mode (false) + * @returns Handle ID used for subsequent operations + */ + Open(writable: boolean = false) { + this.handle = Process(`excel.Open`, this.file, writable); + return this.handle; + } + + /** + * Closes the Excel file and releases resources + * IMPORTANT: Always call this method when done to prevent memory leaks + */ + Close() { + if (this.handle) { + Process(`excel.Close`, this.handle); + this.handle = null; + } + } + + /** + * Saves changes to the Excel file + * @throws Error if file not opened + */ + Save() { + if (!this.handle) throw new Error("Excel file not opened"); + return Process(`excel.Save`, this.handle); + } + + /** + * Gets all sheet names in the workbook + * @returns Array of sheet names + * @throws Error if file not opened + */ + Sheets() { + if (!this.handle) throw new Error("Excel file not opened"); + return Process(`excel.Sheets`, this.handle); + } + + // Sheet operations + sheet = { + /** + * Creates a new sheet in the workbook + * @param name Name for the new sheet + * @returns number Index of the new sheet + * @throws Error if file not opened + */ + Create: (name: string) => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process(`excel.sheet.create`, this.handle, name); + }, + + /** + * Lists all sheets in the workbook + * @returns string[] Array of sheet names + * @throws Error if file not opened + */ + List: () => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process(`excel.sheet.list`, this.handle); + }, + + /** + * Checks if a sheet exists in the workbook + * @param name Sheet name to check + * @returns boolean - true if sheet exists, false otherwise + * @throws Error if file not opened + */ + Exists: (name: string) => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process(`excel.sheet.exists`, this.handle, name); + }, + + /** + * Reads all data from a sheet + * @param name Sheet name + * @returns any[][] Two-dimensional array of cell values + * @throws Error if file not opened + */ + Read: (name: string) => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process(`excel.sheet.read`, this.handle, name); + }, + + /** + * Reads all data from a sheet with pagination support + * @param name Sheet name + * @param from Starting row index (0-based) + * @param chunk_size Number of rows to read + * @returns any[][] Two-dimensional array of cell values + * @throws Error if file not opened + */ + Rows: (name: string, from: number, chunk_size: number) => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process(`excel.sheet.rows`, this.handle, name, from, chunk_size); + }, + + /** + * Updates data in a sheet. Creates the sheet if it doesn't exist. + * @param name Sheet name + * @param data Two-dimensional array of values to write + * @throws Error if file not opened + */ + Update: (name: string, data: any[][]) => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process(`excel.sheet.update`, this.handle, name, data); + }, + + /** + * Copies a sheet with all its content and formatting + * @param source Source sheet name + * @param target Target sheet name (must not exist) + * @throws Error if file not opened + */ + Copy: (source: string, target: string) => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process(`excel.sheet.copy`, this.handle, source, target); + }, + + /** + * Deletes a sheet from the workbook + * @param name Sheet name to delete + * @throws Error if file not opened + */ + Delete: (name: string) => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process(`excel.sheet.delete`, this.handle, name); + }, + + /** + * Gets the dimensions (number of rows and columns) of a sheet + * @param name Sheet name + * @returns {rows: number, cols: number} - Object containing row and column counts + * @throws Error if file not opened + */ + Dimension: (name: string) => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process(`excel.sheet.dimension`, this.handle, name); + }, + }; + + // Reading operations + read = { + /** + * Reads a cell's value + * @param sheet Sheet name + * @param cell Cell reference (e.g. "A1") + * @returns Cell value + * @throws Error if file not opened + */ + Cell: (sheet: string, cell: string) => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process(`excel.read.Cell`, this.handle, sheet, cell); + }, + + /** + * Reads all rows in a sheet + * @param sheet Sheet name + * @returns Two-dimensional array of cell values + * @throws Error if file not opened + */ + Row: (sheet: string) => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process(`excel.read.Row`, this.handle, sheet); + }, + + /** + * Reads all columns in a sheet + * @param sheet Sheet name + * @returns Two-dimensional array of cell values + * @throws Error if file not opened + */ + Column: (sheet: string) => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process(`excel.read.Column`, this.handle, sheet); + }, + }; + + // Writing operations + write = { + /** + * Writes a value to a cell + * @param sheet Sheet name + * @param cell Cell reference (e.g. "A1") + * @param value Value to write (string, number, boolean, etc.) + * @throws Error if file not opened + */ + Cell: (sheet: string, cell: string, value: any) => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process(`excel.write.Cell`, this.handle, sheet, cell, value); + }, + + /** + * Writes values to a row starting at the specified cell + * @param sheet Sheet name + * @param startCell Starting cell reference (e.g. "A1") + * @param values Array of values to write + * @throws Error if file not opened + */ + Row: (sheet: string, startCell: string, values: any[]) => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process(`excel.write.Row`, this.handle, sheet, startCell, values); + }, + + /** + * Writes values to a column starting at the specified cell + * @param sheet Sheet name + * @param startCell Starting cell reference (e.g. "A1") + * @param values Array of values to write + * @throws Error if file not opened + */ + Column: (sheet: string, startCell: string, values: any[]) => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process( + `excel.write.Column`, + this.handle, + sheet, + startCell, + values + ); + }, + + /** + * Writes a two-dimensional array of values starting at the specified cell + * @param sheet Sheet name + * @param startCell Starting cell reference (e.g. "A1") + * @param values Two-dimensional array of values to write + * @throws Error if file not opened + */ + All: (sheet: string, startCell: string, values: any[][]) => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process(`excel.write.All`, this.handle, sheet, startCell, values); + }, + }; + + // Setting properties + set = { + /** + * Applies a style to a cell + * @param sheet Sheet name + * @param cell Cell reference (e.g. "A1") + * @param styleID Style ID to apply + * @throws Error if file not opened + */ + Style: (sheet: string, cell: string, styleID: number) => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process(`excel.set.Style`, this.handle, sheet, cell, styleID); + }, + + /** + * Sets a row's height + * @param sheet Sheet name + * @param row Row number + * @param height Height in points + * @throws Error if file not opened + */ + RowHeight: (sheet: string, row: number, height: number) => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process(`excel.set.RowHeight`, this.handle, sheet, row, height); + }, + + /** + * Sets column width for a range of columns + * @param sheet Sheet name + * @param startCol Starting column letter + * @param endCol Ending column letter + * @param width Width in points + * @throws Error if file not opened + */ + ColumnWidth: ( + sheet: string, + startCol: string, + endCol: string, + width: number + ) => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process( + `excel.set.ColumnWidth`, + this.handle, + sheet, + startCol, + endCol, + width + ); + }, + + /** + * Merges cells in a range + * @param sheet Sheet name + * @param startCell Starting cell reference (e.g. "A1") + * @param endCell Ending cell reference (e.g. "B2") + * @throws Error if file not opened + */ + MergeCell: (sheet: string, startCell: string, endCell: string) => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process( + `excel.set.MergeCell`, + this.handle, + sheet, + startCell, + endCell + ); + }, + + /** + * Unmerges previously merged cells + * @param sheet Sheet name + * @param startCell Starting cell reference (e.g. "A1") + * @param endCell Ending cell reference (e.g. "B2") + * @throws Error if file not opened + */ + UnmergeCell: (sheet: string, startCell: string, endCell: string) => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process( + `excel.set.UnmergeCell`, + this.handle, + sheet, + startCell, + endCell + ); + }, + + /** + * Sets a formula in a cell + * @param sheet Sheet name + * @param cell Cell reference (e.g. "C1") + * @param formula Excel formula without the leading equals sign + * @throws Error if file not opened + */ + Formula: (sheet: string, cell: string, formula: string) => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process(`excel.set.Formula`, this.handle, sheet, cell, formula); + }, + + /** + * Adds a hyperlink to a cell + * @param sheet Sheet name + * @param cell Cell reference (e.g. "A1") + * @param url URL for the hyperlink + * @param text Display text for the hyperlink + * @throws Error if file not opened + */ + Link: (sheet: string, cell: string, url: string, text: string) => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process(`excel.set.Link`, this.handle, sheet, cell, url, text); + }, + }; + + // Iteration methods + each = { + /** + * Opens a row iterator + * @param sheet Sheet name + * @returns Row iterator ID + * @throws Error if file not opened + */ + OpenRow: (sheet: string) => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process(`excel.each.OpenRow`, this.handle, sheet); + }, + + /** + * Gets the next row from the iterator + * @param rowID Row iterator ID from excel.each.OpenRow + * @returns Array of cell values or null if no more rows + */ + NextRow: (rowID: string) => { + return Process(`excel.each.NextRow`, rowID); + }, + + /** + * Closes the row iterator + * @param rowID Row iterator ID from excel.each.OpenRow + */ + CloseRow: (rowID: string) => { + return Process(`excel.each.CloseRow`, rowID); + }, + + /** + * Opens a column iterator + * @param sheet Sheet name + * @returns Column iterator ID + * @throws Error if file not opened + */ + OpenColumn: (sheet: string) => { + if (!this.handle) throw new Error("Excel file not opened"); + return Process(`excel.each.OpenColumn`, this.handle, sheet); + }, + + /** + * Gets the next column from the iterator + * @param colID Column iterator ID from excel.each.OpenColumn + * @returns Array of cell values or null if no more columns + */ + NextColumn: (colID: string) => { + return Process(`excel.each.NextColumn`, colID); + }, + + /** + * Closes the column iterator + * @param colID Column iterator ID from excel.each.OpenColumn + */ + CloseColumn: (colID: string) => { + return Process(`excel.each.CloseColumn`, colID); + }, + }; + + // Conversion utilities + convert = { + /** + * Converts a column name to a column number + * @param colName Column name (e.g. "A", "AB") + * @returns Column number (1-based) + */ + ColumnNameToNumber: (colName: string) => { + return Process(`excel.convert.ColumnNameToNumber`, colName); + }, + + /** + * Converts a column number to a column name + * @param colNum Column number (1-based) + * @returns Column name + */ + ColumnNumberToName: (colNum: number) => { + return Process(`excel.convert.ColumnNumberToName`, colNum); + }, + + /** + * Converts a cell reference to coordinates + * @param cell Cell reference (e.g. "A1") + * @returns Array with [columnNumber, rowNumber] (1-based) + */ + CellNameToCoordinates: (cell: string) => { + return Process(`excel.convert.CellNameToCoordinates`, cell); + }, + + /** + * Converts coordinates to a cell reference + * @param col Column number (1-based) + * @param row Row number (1-based) + * @returns Cell reference + */ + CoordinatesToCellName: (col: number, row: number) => { + return Process(`excel.convert.CoordinatesToCellName`, col, row); + }, + }; +} +/** + * Volcengine OpenAPI SDK + */ +import { Exception, http, Process } from "@yao/runtime"; + +export class Volcengine { + private AccessKeyId: string; + private SecretAccessKey: string; + private Region: string; + private Service: string; + private Endpoint: string; + constructor(option: Option) { + this.AccessKeyId = option.AccessKeyId; + this.SecretAccessKey = option.SecretAccessKey; + this.Region = option.Region; + this.Service = option.Service; + this.Endpoint = option.Endpoint + ? `https://${option.Endpoint}` + : `https://${this.Service}.${this.Region}.volcengineapi.com`; + } + + public Get(query: Record) { + const url = this.Endpoint; + const host = url.split("://")[1].split("/")[0]; + const headers = { host: host }; + const request: Request = { + Method: "GET", + URI: "/", + Query: query, + Headers: headers, + Payload: null, + }; + + const auth = this.getAuthorization(request); + + // Add authorization header + headers["Authorization"] = auth; + headers["Content-Type"] = "application/json"; + + const resp = http.Get(url, query, headers); + if (resp.code > 299 || resp.code < 200) { + const { ResponseMetadata } = resp.data || {}; + const { Error } = ResponseMetadata || {}; + const message = + Error?.Message || (resp.code === 0 ? resp.message : "Unknown error"); + throw new Exception(message, resp.code); + } + + return resp.data; + } + + /** + * Post request + * @param query Query parameters + * @param payload Payload + * @returns Response + */ + public Post(query: Record, payload: Record) { + const url = this.Endpoint; + const host = url.split("://")[1].split("/")[0]; + const headers = { host: host }; + const body = JSON.stringify(payload); + const request: Request = { + Method: "POST", + URI: "/", + Query: query, + Headers: headers, + Payload: body, + }; + + const auth = this.getAuthorization(request); + headers["Authorization"] = auth; + headers["Content-Type"] = "application/json"; + + const resp = http.Post(url, body, null, query, headers); + if (resp.code > 299 || resp.code < 200) { + const { ResponseMetadata } = resp.data || {}; + const { Error } = ResponseMetadata || {}; + const message = + Error?.Message || (resp.code === 0 ? resp.message : "Unknown error"); + throw new Exception(message, resp.code); + } + return resp.data; + } + + /** + * Create a canonical request + * @param request Request object + * @returns Canonical request string + */ + private canonicalRequest(request: Request): string { + const xDate = this.formatDate(new Date()); + + // 1. HTTP Method + const method = request.Method; + + // 2. URI (default to '/' if null) + const uri = request.URI || "/"; + + // 3. Query String + let queryString = ""; + if (request.Query) { + if (Array.isArray(request.Query)) { + // Handle array of query parameters + const queryParams = request.Query.reduce((acc: string[], curr) => { + Object.entries(curr).forEach(([key, value]) => { + if (value !== null && value !== undefined && value !== "") { + acc.push( + `${encodeURIComponent(key)}=${encodeURIComponent(value)}` + ); + } + }); + return acc; + }, []); + queryString = queryParams.sort().join("&"); + } else { + // Handle single query object + const queryParams = Object.entries(request.Query) + .filter( + ([_, value]) => + value !== null && value !== undefined && value !== "" + ) + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent(value)}` + ) + .sort(); + queryString = queryParams.join("&"); + } + } + + // 4. Headers + // First, collect all headers in a normalized format + const headers: Record = { "x-date": xDate }; + if (request.Headers) { + if (Array.isArray(request.Headers)) { + request.Headers.forEach((headerObj) => { + Object.entries(headerObj).forEach(([key, value]) => { + if (value !== null && value !== undefined && value.trim() !== "") { + headers[key.toLowerCase()] = value.trim(); + } + }); + }); + } else { + Object.entries(request.Headers).forEach(([key, value]) => { + if (value !== null && value !== undefined && value.trim() !== "") { + headers[key.toLowerCase()] = value.trim(); + } + }); + } + } + + // Get required headers if they exist + const signedHeaderKeys: string[] = []; + const requiredHeaders = ["host", "x-date"]; + + // Add required headers first if they exist + requiredHeaders.forEach((key) => { + if (headers[key]) { + signedHeaderKeys.push(key); + } + }); + + // Add any additional headers + // const additionalHeaders = Object.keys(headers) + // .filter((key) => !requiredHeaders.includes(key)) + // .sort(); + // signedHeaderKeys.push(...additionalHeaders); + + // Build canonical headers string + const canonicalHeaders = signedHeaderKeys + .map((key) => `${key}:${headers[key]}`) + .join("\n"); + + // Build signed headers string + const signedHeaders = signedHeaderKeys.join(";"); + + // 5. Payload/Body + let hashedPayload = Process("crypto.Hash", "SHA256", ""); + if (request.Payload !== null && request.Payload !== undefined) { + if (typeof request.Payload === "string") { + if (request.Payload !== "") { + hashedPayload = Process("crypto.Hash", "SHA256", request.Payload); + } + } else { + const payload = JSON.stringify(request.Payload); + if (payload !== "{}" && payload !== "[]") { + hashedPayload = Process("crypto.Hash", "SHA256", payload); + } + } + } + + // Combine all components + const parts = [ + method, + uri, + queryString, + canonicalHeaders, + "", // Empty line after headers + signedHeaders, + hashedPayload, + ]; + + return parts.join("\n"); + } + + /** + * Format date to YYYYMMDDTHHMMSSZ + * @param date Date object + * @returns Formatted date string + */ + private formatDate(date: Date): string { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + const day = String(date.getUTCDate()).padStart(2, "0"); + const hours = String(date.getUTCHours()).padStart(2, "0"); + const minutes = String(date.getUTCMinutes()).padStart(2, "0"); + const seconds = String(date.getUTCSeconds()).padStart(2, "0"); + return `${year}${month}${day}T${hours}${minutes}${seconds}Z`; + } + + /** + * Create string to sign + * @param canonicalRequest Canonical request string + * @returns String to sign + */ + private stringToSign(canonicalRequest: string): string { + const algorithm = "HMAC-SHA256"; + const requestDateTime = this.formatDate(new Date()); + const requestDate = requestDateTime.slice(0, 8); + const credentialScope = `${requestDate}/${this.Region}/${this.Service}/request`; // YYYYMMDD + + const hashedCanonicalRequest = Process( + "crypto.Hash", + "SHA256", + canonicalRequest + ); + + return `${algorithm}\n${requestDateTime}\n${credentialScope}\n${hashedCanonicalRequest}`; + } + + /** + * Derive signing key + * @param date Date in YYYY/MM/DD format + * @returns Signing key + */ + private getSigningKey(date: string): string { + const kDate = Process("crypto.HMAC", "SHA256", date, this.SecretAccessKey); + const kRegion = Process( + "crypto.HMACWith", + { key: "hex" }, + this.Region, + kDate + ); + const kService = Process( + "crypto.HMACWith", + { key: "hex" }, + this.Service, + kRegion + ); + + const kSigning = Process( + "crypto.HMACWith", + { key: "hex" }, + "request", + kService + ); + return kSigning; + } + + /** + * Calculate signature + * @param stringToSign String to sign + * @param signingKey Signing key + * @returns Signature + */ + private signature(stringToSign: string, signingKey: string): string { + return Process("crypto.HMACWith", { key: "hex" }, stringToSign, signingKey); + } + + /** + * Build authorization header + * @param request Request object + * @returns Authorization header value + */ + public getAuthorization(request: Request): string { + const xDate = this.formatDate(new Date()); + if (request.Headers) { + if (typeof request.Headers === "object") { + request.Headers["x-date"] = request.Headers["x-date"] + ? request.Headers["x-date"] + : xDate; + } + } + + // 1. Create canonical request + const canonicalReq = this.canonicalRequest(request); + + // 2. Create string to sign + const stringToSign = this.stringToSign(canonicalReq); + + // 3. Get date from string to sign + const [algorithm, requestDateTime, credentialScope] = + stringToSign.split("\n"); + const date = requestDateTime.slice(0, 8); + + // 4. Derive signing key + const signingKey = this.getSigningKey(date); + // 5. Calculate signature + const signature = this.signature(stringToSign, signingKey); + + // 6. Build authorization header + let signedHeaders = ""; + if (request.Headers) { + const headers: Record = {}; + if (Array.isArray(request.Headers)) { + request.Headers.forEach((headerObj) => { + Object.entries(headerObj).forEach(([key, value]) => { + headers[key.toLowerCase()] = value.trim(); + }); + }); + } else { + Object.entries(request.Headers).forEach(([key, value]) => { + headers[key.toLowerCase()] = value.trim(); + }); + } + signedHeaders = Object.keys(headers).sort().join(";"); + } + + return `${algorithm} Credential=${this.AccessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; + } +} + +export interface Option { + AccessKeyId: string; + SecretAccessKey: string; + Endpoint?: string; + Region: string; + Service: string; +} + +export interface Request { + Method: "GET" | "POST"; + URI: string | null; // Default / + Query: Record | Record[] | null; + Headers: Record | Record[] | null; + Payload: string | Record | any[] | null; +} diff --git a/agent/sandbox/v2/claude/testdata/test-image.png b/agent/sandbox/v2/claude/testdata/test-image.png new file mode 100644 index 0000000000000000000000000000000000000000..7c354fd1b3d6e66afe3ba8ee92c976c86d4abda8 GIT binary patch literal 75176 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 bmYcsit5!u$k1{chcl`evMgOyi+aLKiI>`h+ literal 0 HcmV?d00001 diff --git a/agent/sandbox/v2/init.go b/agent/sandbox/v2/init.go new file mode 100644 index 00000000..cd13d530 --- /dev/null +++ b/agent/sandbox/v2/init.go @@ -0,0 +1,13 @@ +package sandboxv2 + +import ( + "github.com/yaoapp/yao/agent/sandbox/v2/claude" + "github.com/yaoapp/yao/agent/sandbox/v2/types" + yaorunner "github.com/yaoapp/yao/agent/sandbox/v2/yao" +) + +func init() { + Register("claude", func() types.Runner { return claude.New() }) + Register("claude/cli", func() types.Runner { return claude.New() }) + Register("yao", func() types.Runner { return yaorunner.New() }) +} diff --git a/agent/sandbox/v2/lifecycle.go b/agent/sandbox/v2/lifecycle.go new file mode 100644 index 00000000..84680a1c --- /dev/null +++ b/agent/sandbox/v2/lifecycle.go @@ -0,0 +1,150 @@ +package sandboxv2 + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "log" + + agentContext "github.com/yaoapp/yao/agent/context" + "github.com/yaoapp/yao/agent/sandbox/v2/types" + infra "github.com/yaoapp/yao/sandbox/v2" +) + +// BuildIdentifier determines the Computer identifier based on lifecycle policy +// and optional metadata override. Returns "" for oneshot (always new). +func BuildIdentifier(cfg *types.SandboxConfig, ownerID, chatID, assistantID string, metadata map[string]any) string { + if cfg.Lifecycle == "oneshot" { + return "" + } + + // Custom identifier from metadata takes precedence. + if metadata != nil { + if cid, ok := metadata["computer_id"].(string); ok && cid != "" { + return fmt.Sprintf("%s-%s", ownerID, cid) + } + } + + switch cfg.Lifecycle { + case "session": + return fmt.Sprintf("%s-%s", ownerID, chatID) + case "longrunning", "persistent": + return fmt.Sprintf("%s-%s", ownerID, assistantID) + default: + return "" + } +} + +// GetComputer obtains or creates a Computer for the current request. +// Returns the Computer, the resolved identifier, and any error. +func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *infra.Manager) (infra.Computer, string, error) { + ownerID := resolveOwnerID(ctx) + identifier := BuildIdentifier(cfg, ownerID, ctx.ChatID, ctx.AssistantID, ctx.Metadata) + + // Fill runtime fields. + cfg.Owner = ownerID + cfg.ID = identifier + + workspaceID := "" + if ctx.Metadata != nil { + if ws, ok := ctx.Metadata["workspace_id"].(string); ok && ws != "" { + workspaceID = ws + } + } + if workspaceID == "" { + workspaceID = ownerID + } + cfg.WorkspaceID = workspaceID + + // Host mode: no image → host computer. + if cfg.Computer.Image == "" { + cfg.Kind = "host" + nodeID := cfg.NodeID + if nodeID == "" { + return nil, identifier, fmt.Errorf("host mode requires a nodeID (set in sandbox.yao or workspace)") + } + host, err := manager.Host(context.Background(), nodeID) + if err != nil { + return nil, identifier, fmt.Errorf("get host computer: %w", err) + } + host.BindWorkplace(workspaceID) + return host, identifier, nil + } + + cfg.Kind = "box" + + // Reuse: non-empty identifier → try Get first. + if identifier != "" { + box, err := manager.Get(context.Background(), identifier) + if err == nil && box != nil { + box.BindWorkplace(workspaceID) + return box, identifier, nil + } + } + + // Create new box. + createOpts, err := BuildCreateOptions(cfg, identifier, ownerID, workspaceID) + if err != nil { + return nil, identifier, fmt.Errorf("build create options: %w", err) + } + + // Oneshot with empty identifier: generate a random one. + if createOpts.ID == "" { + createOpts.ID = randomID() + identifier = createOpts.ID + cfg.ID = identifier + } + + box, err := manager.Create(context.Background(), createOpts) + if err != nil { + return nil, identifier, fmt.Errorf("create computer: %w", err) + } + return box, identifier, nil +} + +// LifecycleAction performs the post-request lifecycle operation based on policy. +// Called in defer after executeSandboxStream completes. +func LifecycleAction(ctx context.Context, cfg *types.SandboxConfig, computer infra.Computer, manager *infra.Manager) { + if computer == nil || cfg == nil { + return + } + + info := computer.ComputerInfo() + + switch cfg.Lifecycle { + case "oneshot": + if info.Kind == "box" && manager != nil { + if err := manager.Remove(ctx, cfg.ID); err != nil { + log.Printf("[sandbox/v2] oneshot remove %s: %v", cfg.ID, err) + } + } + + case "session", "longrunning": + if info.Kind == "box" && manager != nil { + manager.Heartbeat(cfg.ID, false, 0) // active=false: request finished, start idle timer + } + + case "persistent": + // No action — persistent boxes survive indefinitely. + } +} + +// resolveOwnerID returns teamID if available, otherwise userID. +func resolveOwnerID(ctx *agentContext.Context) string { + if ctx.Authorized != nil { + if ctx.Authorized.TeamID != "" { + return ctx.Authorized.TeamID + } + if ctx.Authorized.UserID != "" { + return ctx.Authorized.UserID + } + } + return "anonymous" +} + +func randomID() string { + b := make([]byte, 8) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/agent/sandbox/v2/lifecycle_test.go b/agent/sandbox/v2/lifecycle_test.go new file mode 100644 index 00000000..64cf2a81 --- /dev/null +++ b/agent/sandbox/v2/lifecycle_test.go @@ -0,0 +1,547 @@ +package sandboxv2_test + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + agentContext "github.com/yaoapp/yao/agent/context" + sandboxv2 "github.com/yaoapp/yao/agent/sandbox/v2" + "github.com/yaoapp/yao/agent/sandbox/v2/types" + oauthTypes "github.com/yaoapp/yao/openapi/oauth/types" + infra "github.com/yaoapp/yao/sandbox/v2" +) + +// =========================================================================== +// BuildIdentifier — pure-function tests (no infra needed) +// =========================================================================== + +func TestBuildIdentifier_Oneshot(t *testing.T) { + cfg := &types.SandboxConfig{Lifecycle: "oneshot"} + id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast1", nil) + if id != "" { + t.Errorf("oneshot should return empty, got %q", id) + } +} + +func TestBuildIdentifier_Session(t *testing.T) { + cfg := &types.SandboxConfig{Lifecycle: "session"} + id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat42", "ast1", nil) + if id != "owner1-chat42" { + t.Errorf("session: got %q, want %q", id, "owner1-chat42") + } +} + +func TestBuildIdentifier_Longrunning(t *testing.T) { + cfg := &types.SandboxConfig{Lifecycle: "longrunning"} + id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast99", nil) + if id != "owner1-ast99" { + t.Errorf("longrunning: got %q, want %q", id, "owner1-ast99") + } +} + +func TestBuildIdentifier_Persistent(t *testing.T) { + cfg := &types.SandboxConfig{Lifecycle: "persistent"} + id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast99", nil) + if id != "owner1-ast99" { + t.Errorf("persistent: got %q, want %q", id, "owner1-ast99") + } +} + +func TestBuildIdentifier_MetadataOverride(t *testing.T) { + cfg := &types.SandboxConfig{Lifecycle: "session"} + meta := map[string]any{"computer_id": "custom-box"} + id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast1", meta) + if id != "owner1-custom-box" { + t.Errorf("metadata override: got %q, want %q", id, "owner1-custom-box") + } +} + +func TestBuildIdentifier_MetadataEmptyIgnored(t *testing.T) { + cfg := &types.SandboxConfig{Lifecycle: "session"} + meta := map[string]any{"computer_id": ""} + id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat42", "ast1", meta) + if id != "owner1-chat42" { + t.Errorf("empty metadata should fall through to session, got %q", id) + } +} + +func TestBuildIdentifier_UnknownLifecycle(t *testing.T) { + cfg := &types.SandboxConfig{Lifecycle: "unknown"} + id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast1", nil) + if id != "" { + t.Errorf("unknown lifecycle should return empty, got %q", id) + } +} + +// =========================================================================== +// GetComputer — real container tests +// =========================================================================== + +func makeAgentCtx(teamID, userID, chatID, assistantID string, metadata map[string]any) *agentContext.Context { + var auth *oauthTypes.AuthorizedInfo + if teamID != "" || userID != "" { + auth = &oauthTypes.AuthorizedInfo{TeamID: teamID, UserID: userID} + } + return &agentContext.Context{ + Context: context.Background(), + Authorized: auth, + ChatID: chatID, + AssistantID: assistantID, + Metadata: metadata, + } +} + +func TestGetComputer_BoxCreate(t *testing.T) { + skipIfNoDocker(t) + + for _, nc := range boxNodes() { + nc := nc + t.Run(nc.Name, func(t *testing.T) { + m := setupManager(t, &nc) + ensureImage(t, m, nc) + + wsID := fmt.Sprintf("lc-create-%d", time.Now().UnixNano()) + createTestWorkspace(t, nc.TaiID, wsID) + + cfg := &types.SandboxConfig{ + Version: "2.0", + Lifecycle: "oneshot", + Computer: types.ComputerConfig{Image: testImage()}, + NodeID: nc.TaiID, + } + meta := map[string]any{"workspace_id": wsID} + ctx := makeAgentCtx("team-t1", "", "chat-1", "ast-1", meta) + + computer, identifier, err := sandboxv2.GetComputer(ctx, cfg, m) + if err != nil { + t.Fatalf("GetComputer: %v", err) + } + defer cleanupComputer(t, m, cfg) + + if identifier == "" { + t.Fatal("oneshot should get a random identifier, got empty") + } + + info := computer.ComputerInfo() + if info.Kind != "box" { + t.Errorf("kind = %q, want %q", info.Kind, "box") + } + if cfg.Owner != "team-t1" { + t.Errorf("cfg.Owner = %q, want %q", cfg.Owner, "team-t1") + } + if cfg.Kind != "box" { + t.Errorf("cfg.Kind = %q, want %q", cfg.Kind, "box") + } + }) + } +} + +func TestGetComputer_BoxReuse(t *testing.T) { + skipIfNoDocker(t) + + for _, nc := range boxNodes() { + nc := nc + t.Run(nc.Name, func(t *testing.T) { + m := setupManager(t, &nc) + ensureImage(t, m, nc) + + wsID := fmt.Sprintf("lc-reuse-%d", time.Now().UnixNano()) + createTestWorkspace(t, nc.TaiID, wsID) + + cfg := &types.SandboxConfig{ + Version: "2.0", + Lifecycle: "session", + Computer: types.ComputerConfig{Image: testImage()}, + NodeID: nc.TaiID, + } + meta := map[string]any{"workspace_id": wsID} + ctx := makeAgentCtx("team-reuse", "", "chat-reuse", "ast-1", meta) + + computer1, id1, err := sandboxv2.GetComputer(ctx, cfg, m) + if err != nil { + t.Fatalf("first GetComputer: %v", err) + } + defer cleanupComputer(t, m, cfg) + + cfg2 := &types.SandboxConfig{ + Version: "2.0", + Lifecycle: "session", + Computer: types.ComputerConfig{Image: testImage()}, + NodeID: nc.TaiID, + } + computer2, id2, err := sandboxv2.GetComputer(ctx, cfg2, m) + if err != nil { + t.Fatalf("second GetComputer: %v", err) + } + + if id1 != id2 { + t.Errorf("identifiers differ: %q vs %q", id1, id2) + } + + info1 := computer1.ComputerInfo() + info2 := computer2.ComputerInfo() + if info1.ContainerID != info2.ContainerID { + t.Errorf("container IDs differ: %q vs %q (should reuse)", info1.ContainerID, info2.ContainerID) + } + }) + } +} + +func TestGetComputer_WorkspaceBindAlways(t *testing.T) { + skipIfNoDocker(t) + + for _, nc := range boxNodes() { + nc := nc + t.Run(nc.Name, func(t *testing.T) { + m := setupManager(t, &nc) + ensureImage(t, m, nc) + + wsID := fmt.Sprintf("lc-ws-%d", time.Now().UnixNano()) + createTestWorkspace(t, nc.TaiID, wsID) + + cfg := &types.SandboxConfig{ + Version: "2.0", + Lifecycle: "oneshot", + Computer: types.ComputerConfig{Image: testImage()}, + NodeID: nc.TaiID, + } + meta := map[string]any{"workspace_id": wsID} + ctx := makeAgentCtx("team-ws", "", "chat-ws", "ast-ws", meta) + + computer, _, err := sandboxv2.GetComputer(ctx, cfg, m) + if err != nil { + t.Fatalf("GetComputer: %v", err) + } + defer cleanupComputer(t, m, cfg) + + if cfg.WorkspaceID != wsID { + t.Errorf("WorkspaceID = %q, want %q", cfg.WorkspaceID, wsID) + } + + ws := computer.Workplace() + if ws == nil { + t.Fatal("Workplace() returned nil, workspace should always be bound") + } + }) + } +} + +func TestGetComputer_WorkspaceFallbackOwner(t *testing.T) { + skipIfNoDocker(t) + + for _, nc := range boxNodes() { + nc := nc + t.Run(nc.Name, func(t *testing.T) { + m := setupManager(t, &nc) + ensureImage(t, m, nc) + + ownerID := fmt.Sprintf("lc-owner-%d", time.Now().UnixNano()) + createTestWorkspace(t, nc.TaiID, ownerID) + + cfg := &types.SandboxConfig{ + Version: "2.0", + Lifecycle: "oneshot", + Computer: types.ComputerConfig{Image: testImage()}, + NodeID: nc.TaiID, + } + ctx := makeAgentCtx(ownerID, "", "chat-fb", "ast-fb", nil) + + computer, _, err := sandboxv2.GetComputer(ctx, cfg, m) + if err != nil { + t.Fatalf("GetComputer: %v", err) + } + defer cleanupComputer(t, m, cfg) + + if cfg.WorkspaceID != ownerID { + t.Errorf("WorkspaceID = %q, want %q (should fallback to ownerID)", cfg.WorkspaceID, ownerID) + } + + ws := computer.Workplace() + if ws == nil { + t.Fatal("Workplace() returned nil") + } + }) + } +} + +func TestGetComputer_OwnerPriority(t *testing.T) { + skipIfNoDocker(t) + + nc := boxNodes()[0] + m := setupManager(t, &nc) + ensureImage(t, m, nc) + + t.Run("teamID", func(t *testing.T) { + wsID := fmt.Sprintf("lc-ownp-team-%d", time.Now().UnixNano()) + createTestWorkspace(t, nc.TaiID, wsID) + + cfg := &types.SandboxConfig{ + Version: "2.0", Lifecycle: "oneshot", + Computer: types.ComputerConfig{Image: testImage()}, + NodeID: nc.TaiID, + } + ctx := makeAgentCtx("my-team", "my-user", "c", "a", map[string]any{"workspace_id": wsID}) + _, _, err := sandboxv2.GetComputer(ctx, cfg, m) + if err != nil { + t.Fatalf("GetComputer: %v", err) + } + defer cleanupComputer(t, m, cfg) + if cfg.Owner != "my-team" { + t.Errorf("Owner = %q, want %q (teamID takes precedence)", cfg.Owner, "my-team") + } + }) + + t.Run("userID", func(t *testing.T) { + wsID := fmt.Sprintf("lc-ownp-user-%d", time.Now().UnixNano()) + createTestWorkspace(t, nc.TaiID, wsID) + + cfg := &types.SandboxConfig{ + Version: "2.0", Lifecycle: "oneshot", + Computer: types.ComputerConfig{Image: testImage()}, + NodeID: nc.TaiID, + } + ctx := makeAgentCtx("", "my-user", "c", "a", map[string]any{"workspace_id": wsID}) + _, _, err := sandboxv2.GetComputer(ctx, cfg, m) + if err != nil { + t.Fatalf("GetComputer: %v", err) + } + defer cleanupComputer(t, m, cfg) + if cfg.Owner != "my-user" { + t.Errorf("Owner = %q, want %q", cfg.Owner, "my-user") + } + }) + + t.Run("anonymous", func(t *testing.T) { + wsID := fmt.Sprintf("lc-ownp-anon-%d", time.Now().UnixNano()) + createTestWorkspace(t, nc.TaiID, wsID) + + cfg := &types.SandboxConfig{ + Version: "2.0", Lifecycle: "oneshot", + Computer: types.ComputerConfig{Image: testImage()}, + NodeID: nc.TaiID, + } + ctx := makeAgentCtx("", "", "c", "a", map[string]any{"workspace_id": wsID}) + _, _, err := sandboxv2.GetComputer(ctx, cfg, m) + if err != nil { + t.Fatalf("GetComputer: %v", err) + } + defer cleanupComputer(t, m, cfg) + if cfg.Owner != "anonymous" { + t.Errorf("Owner = %q, want %q", cfg.Owner, "anonymous") + } + }) +} + +func TestGetComputer_HostMode(t *testing.T) { + skipIfNoHostExec(t) + + for _, tgt := range hostTargets() { + tgt := tgt + t.Run(tgt.Name, func(t *testing.T) { + m := setupHostManager(t, &tgt) + + cfg := &types.SandboxConfig{ + Version: "2.0", + Lifecycle: "session", + Computer: types.ComputerConfig{}, + NodeID: tgt.TaiID, + } + ctx := makeAgentCtx("team-host", "", "chat-host", "ast-host", nil) + + computer, _, err := sandboxv2.GetComputer(ctx, cfg, m) + if err != nil { + t.Fatalf("GetComputer host: %v", err) + } + + if cfg.Kind != "host" { + t.Errorf("Kind = %q, want %q", cfg.Kind, "host") + } + + info := computer.ComputerInfo() + if info.Kind != "host" { + t.Errorf("ComputerInfo.Kind = %q, want %q", info.Kind, "host") + } + + ws := computer.Workplace() + if ws == nil { + t.Fatal("Workplace() returned nil on host mode") + } + }) + } +} + +func TestGetComputer_HostMissingNodeID(t *testing.T) { + skipIfNoDocker(t) + + nc := boxNodes()[0] + m := setupManager(t, &nc) + + cfg := &types.SandboxConfig{ + Version: "2.0", + Lifecycle: "session", + Computer: types.ComputerConfig{}, + NodeID: "", + } + ctx := makeAgentCtx("team-err", "", "c", "a", nil) + + _, _, err := sandboxv2.GetComputer(ctx, cfg, m) + if err == nil { + t.Fatal("expected error for host mode without nodeID") + } + if !strings.Contains(err.Error(), "nodeID") { + t.Errorf("error should mention nodeID, got: %v", err) + } +} + +// =========================================================================== +// LifecycleAction — behavior tests +// =========================================================================== + +func TestLifecycleAction_Oneshot(t *testing.T) { + skipIfNoDocker(t) + + for _, nc := range boxNodes() { + nc := nc + t.Run(nc.Name, func(t *testing.T) { + m := setupManager(t, &nc) + ensureImage(t, m, nc) + + wsID := fmt.Sprintf("lc-oneshot-%d", time.Now().UnixNano()) + createTestWorkspace(t, nc.TaiID, wsID) + + cfg := &types.SandboxConfig{ + Version: "2.0", + Lifecycle: "oneshot", + Computer: types.ComputerConfig{Image: testImage()}, + NodeID: nc.TaiID, + } + ctx := makeAgentCtx("team-oneshot", "", "c", "a", map[string]any{"workspace_id": wsID}) + + computer, _, err := sandboxv2.GetComputer(ctx, cfg, m) + if err != nil { + t.Fatalf("GetComputer: %v", err) + } + + boxID := cfg.ID + + sandboxv2.LifecycleAction(context.Background(), cfg, computer, m) + + _, getErr := m.Get(context.Background(), boxID) + if getErr == nil { + t.Error("box should be removed after oneshot LifecycleAction") + } + }) + } +} + +func TestLifecycleAction_Session(t *testing.T) { + skipIfNoDocker(t) + + for _, nc := range boxNodes() { + nc := nc + t.Run(nc.Name, func(t *testing.T) { + m := setupManager(t, &nc) + ensureImage(t, m, nc) + + wsID := fmt.Sprintf("lc-sess-%d", time.Now().UnixNano()) + createTestWorkspace(t, nc.TaiID, wsID) + + cfg := &types.SandboxConfig{ + Version: "2.0", + Lifecycle: "session", + Computer: types.ComputerConfig{Image: testImage()}, + NodeID: nc.TaiID, + } + ctx := makeAgentCtx("team-sess", "", "chat-sess", "ast-sess", map[string]any{"workspace_id": wsID}) + + computer, _, err := sandboxv2.GetComputer(ctx, cfg, m) + if err != nil { + t.Fatalf("GetComputer: %v", err) + } + defer cleanupComputer(t, m, cfg) + + sandboxv2.LifecycleAction(context.Background(), cfg, computer, m) + + box, err := m.Get(context.Background(), cfg.ID) + if err != nil { + t.Fatalf("box should still exist after session LifecycleAction: %v", err) + } + if box == nil { + t.Fatal("box is nil after session LifecycleAction") + } + }) + } +} + +func TestLifecycleAction_Persistent(t *testing.T) { + skipIfNoDocker(t) + + for _, nc := range boxNodes() { + nc := nc + t.Run(nc.Name, func(t *testing.T) { + m := setupManager(t, &nc) + ensureImage(t, m, nc) + + wsID := fmt.Sprintf("lc-pers-%d", time.Now().UnixNano()) + createTestWorkspace(t, nc.TaiID, wsID) + + cfg := &types.SandboxConfig{ + Version: "2.0", + Lifecycle: "persistent", + Computer: types.ComputerConfig{Image: testImage()}, + NodeID: nc.TaiID, + } + ctx := makeAgentCtx("team-pers", "", "chat-pers", "ast-pers", map[string]any{"workspace_id": wsID}) + + computer, _, err := sandboxv2.GetComputer(ctx, cfg, m) + if err != nil { + t.Fatalf("GetComputer: %v", err) + } + defer cleanupComputer(t, m, cfg) + + sandboxv2.LifecycleAction(context.Background(), cfg, computer, m) + + box, err := m.Get(context.Background(), cfg.ID) + if err != nil { + t.Fatalf("box should still exist after persistent LifecycleAction: %v", err) + } + if box == nil { + t.Fatal("box is nil after persistent LifecycleAction") + } + }) + } +} + +func TestLifecycleAction_NilSafe(t *testing.T) { + cfg := &types.SandboxConfig{Lifecycle: "oneshot"} + sandboxv2.LifecycleAction(context.Background(), cfg, nil, nil) + sandboxv2.LifecycleAction(context.Background(), nil, nil, nil) +} + +// =========================================================================== +// helpers +// =========================================================================== + +func ensureImage(t *testing.T, m *infra.Manager, nc nodeConfig) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + if err := m.EnsureImage(ctx, nc.TaiID, testImage(), infra.ImagePullOptions{}); err != nil { + t.Fatalf("EnsureImage: %v", err) + } +} + +func cleanupComputer(t *testing.T, m *infra.Manager, cfg *types.SandboxConfig) { + t.Helper() + if cfg.ID == "" { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := m.Remove(ctx, cfg.ID); err != nil { + t.Logf("cleanup Remove(%s): %v", cfg.ID, err) + } +} diff --git a/agent/sandbox/v2/options.go b/agent/sandbox/v2/options.go new file mode 100644 index 00000000..df313a24 --- /dev/null +++ b/agent/sandbox/v2/options.go @@ -0,0 +1,168 @@ +package sandboxv2 + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/yaoapp/yao/agent/sandbox/v2/types" + infra "github.com/yaoapp/yao/sandbox/v2" +) + +// resolveEnvRef resolves $ENV.XXX references to os.Getenv("XXX"). +func resolveEnvRef(value string) string { + if strings.HasPrefix(value, "$ENV.") { + return os.Getenv(value[5:]) + } + return value +} + +// BuildCreateOptions converts a SandboxConfig into the V2 infrastructure +// CreateOptions. Pure runtime mapping — no file-system or DSL access. +func BuildCreateOptions(cfg *types.SandboxConfig, identifier, ownerID, workspaceID string) (infra.CreateOptions, error) { + opts := infra.CreateOptions{ + ID: identifier, + Owner: ownerID, + Image: cfg.Computer.Image, + WorkDir: cfg.Computer.WorkDir, + User: cfg.Computer.User, + MountPath: cfg.Computer.MountPath, + MountMode: cfg.Computer.MountMode, + WorkspaceID: workspaceID, + Labels: cfg.Labels, + } + + if opts.Labels == nil { + opts.Labels = make(map[string]string) + } + + // Lifecycle policy + switch cfg.Lifecycle { + case "oneshot": + opts.Policy = infra.OneShot + case "session": + opts.Policy = infra.Session + case "longrunning": + opts.Policy = infra.LongRunning + case "persistent": + opts.Policy = infra.Persistent + default: + opts.Policy = infra.OneShot + } + + // Timeouts + if cfg.IdleTimeout != "" { + d, err := time.ParseDuration(cfg.IdleTimeout) + if err != nil { + return opts, fmt.Errorf("idle_timeout: %w", err) + } + opts.IdleTimeout = d + } + if cfg.MaxLifetime != "" { + d, err := time.ParseDuration(cfg.MaxLifetime) + if err != nil { + return opts, fmt.Errorf("max_lifetime: %w", err) + } + opts.MaxLifetime = d + } + if cfg.StopTimeout != "" { + d, err := time.ParseDuration(cfg.StopTimeout) + if err != nil { + return opts, fmt.Errorf("stop_timeout: %w", err) + } + opts.StopTimeout = d + } + + // Memory (string like "4g" → bytes) + if cfg.Computer.Memory != "" { + mem, err := parseMemory(cfg.Computer.Memory) + if err != nil { + return opts, fmt.Errorf("memory: %w", err) + } + opts.Memory = mem + } + + opts.CPUs = cfg.Computer.CPUs + + // VNC + opts.VNC = cfg.Computer.VNC.Enabled + + // Ports + for _, p := range cfg.Computer.Ports { + opts.Ports = append(opts.Ports, infra.PortMapping{ + ContainerPort: p.Port, + HostPort: p.HostPort, + Protocol: p.Protocol, + }) + } + + // NodeID (host mode pre-selection) + if cfg.NodeID != "" { + opts.NodeID = cfg.NodeID + } + + // Merge environment + secrets into CreateOptions.Env. + // Secrets override environment for same-name keys. + // $ENV.XXX references are resolved at runtime. + envSize := len(cfg.Environment) + len(cfg.Secrets) + if envSize > 0 { + opts.Env = make(map[string]string, envSize) + for k, v := range cfg.Environment { + opts.Env[k] = resolveEnvRef(v) + } + for k, v := range cfg.Secrets { + opts.Env[k] = resolveEnvRef(v) + } + } + + return opts, nil +} + +// parseMemory converts a human-readable memory string to bytes. +// Supported formats: "4GB", "4G", "4g", "512MB", "512M", "512m", "1024KB", "1024K", "1024". +func parseMemory(s string) (int64, error) { + if len(s) == 0 { + return 0, nil + } + + upper := strings.ToUpper(s) + var num string + var multiplier int64 + + switch { + case strings.HasSuffix(upper, "GB"): + num = s[:len(s)-2] + multiplier = 1 << 30 + case strings.HasSuffix(upper, "MB"): + num = s[:len(s)-2] + multiplier = 1 << 20 + case strings.HasSuffix(upper, "KB"): + num = s[:len(s)-2] + multiplier = 1 << 10 + case strings.HasSuffix(upper, "TB"): + num = s[:len(s)-2] + multiplier = 1 << 40 + case strings.HasSuffix(upper, "G"): + num = s[:len(s)-1] + multiplier = 1 << 30 + case strings.HasSuffix(upper, "M"): + num = s[:len(s)-1] + multiplier = 1 << 20 + case strings.HasSuffix(upper, "K"): + num = s[:len(s)-1] + multiplier = 1 << 10 + case strings.HasSuffix(upper, "T"): + num = s[:len(s)-1] + multiplier = 1 << 40 + default: + num = s + multiplier = 1 + } + + var val float64 + if _, err := fmt.Sscanf(num, "%f", &val); err != nil { + return 0, fmt.Errorf("invalid memory value %q", s) + } + return int64(val * float64(multiplier)), nil +} diff --git a/agent/sandbox/v2/prepare.go b/agent/sandbox/v2/prepare.go new file mode 100644 index 00000000..26e50843 --- /dev/null +++ b/agent/sandbox/v2/prepare.go @@ -0,0 +1,171 @@ +package sandboxv2 + +import ( + "context" + "fmt" + "log" + "path" + "strings" + + "github.com/yaoapp/yao/agent/sandbox/v2/types" + infra "github.com/yaoapp/yao/sandbox/v2" + "github.com/yaoapp/yao/tai/workspace" +) + +const onceMarkerDir = ".yao/prepare" + +// RunPrepareSteps executes a list of PrepareStep actions on the given Computer. +// file/copy/marker operations use computer.Workplace() (gRPC volume, cross-platform). +// exec operations use shell via Computer.Exec. +func RunPrepareSteps(ctx context.Context, steps []types.PrepareStep, computer infra.Computer, assistantID, configHash string) error { + if len(steps) == 0 { + return nil + } + + var ws workspace.FS + if computer != nil { + ws = computer.Workplace() + } + + markerDir := onceMarkerDir + if assistantID != "" { + markerDir = onceMarkerDir + "/" + assistantID + } + markerPath := markerDir + "/done" + + skipOnce := false + if configHash != "" && ws != nil { + if data, err := ws.ReadFile(markerPath); err == nil { + if strings.TrimSpace(string(data)) == configHash { + skipOnce = true + } + } + } + + for i, step := range steps { + if step.Once && skipOnce { + continue + } + + var err error + switch step.Action { + case "file": + err = runFileStep(ws, step) + case "copy": + err = runCopyStep(ws, step) + case "exec": + err = runExecStep(ctx, computer, step) + case "process": + log.Printf("[sandbox/v2] prepare step %d: action=process (reserved, skipping)", i) + default: + err = fmt.Errorf("unknown prepare action %q", step.Action) + } + + if err != nil { + if step.IgnoreError { + log.Printf("[sandbox/v2] prepare step %d (%s): ignored error: %v", i, step.Action, err) + continue + } + return fmt.Errorf("prepare step %d (%s): %w", i, step.Action, err) + } + } + + if configHash != "" && ws != nil { + ws.MkdirAll(markerDir, 0755) + ws.WriteFile(markerPath, []byte(configHash), 0644) + } + + return nil +} + +// --------------------------------------------------------------------------- +// Step runners +// --------------------------------------------------------------------------- + +func runFileStep(ws workspace.FS, step types.PrepareStep) error { + if step.Path == "" { + return fmt.Errorf("file step requires path") + } + if ws == nil { + return fmt.Errorf("file step requires workspace") + } + + dir := path.Dir(step.Path) + if dir != "." && dir != "/" { + if err := ws.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("mkdir %s: %w", dir, err) + } + } + + if err := ws.WriteFile(step.Path, step.Content, 0644); err != nil { + return fmt.Errorf("write file %s: %w", step.Path, err) + } + return nil +} + +func runCopyStep(ws workspace.FS, step types.PrepareStep) error { + if step.Src == "" || step.Dst == "" { + return fmt.Errorf("copy step requires src and dst") + } + if ws == nil { + return fmt.Errorf("copy step requires workspace") + } + + data, err := ws.ReadFile(step.Src) + if err != nil { + return fmt.Errorf("read src %s: %w", step.Src, err) + } + + dir := path.Dir(step.Dst) + if dir != "." && dir != "/" { + if err := ws.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("mkdir %s: %w", dir, err) + } + } + + if err := ws.WriteFile(step.Dst, data, 0644); err != nil { + return fmt.Errorf("write dst %s: %w", step.Dst, err) + } + return nil +} + +func runExecStep(ctx context.Context, computer infra.Computer, step types.PrepareStep) error { + if step.Cmd == "" { + return fmt.Errorf("exec step requires cmd") + } + + kind := shellFromSystem(computer) + script := step.Cmd + if step.Background { + if kind == shellSh { + script = fmt.Sprintf("nohup %s > /dev/null 2>&1 &", step.Cmd) + } else { + script = fmt.Sprintf("Start-Process -NoNewWindow -FilePath 'cmd.exe' -ArgumentList '/C %s'", step.Cmd) + } + } + + result, err := computer.Exec(ctx, shellWrap(kind, script), infra.WithWorkDir("/")) + if err != nil { + return err + } + label := "exec" + if step.Background { + label = "exec(background)" + } + return checkResult(result, label) +} + +// checkResult inspects ExecResult for errors. +func checkResult(result *infra.ExecResult, label string) error { + if result.Error != "" { + return fmt.Errorf("%s: %s", label, result.Error) + } + if result.ExitCode != 0 { + stderr := result.Stderr + if len(stderr) > 200 { + stderr = stderr[:200] + "..." + } + return fmt.Errorf("%s: exit %d: %s", label, result.ExitCode, stderr) + } + return nil +} diff --git a/agent/sandbox/v2/prepare_test.go b/agent/sandbox/v2/prepare_test.go new file mode 100644 index 00000000..b607a83e --- /dev/null +++ b/agent/sandbox/v2/prepare_test.go @@ -0,0 +1,549 @@ +package sandboxv2_test + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + sandboxv2 "github.com/yaoapp/yao/agent/sandbox/v2" + "github.com/yaoapp/yao/agent/sandbox/v2/types" +) + +// --------------------------------------------------------------------------- +// Box tests (local + remote) +// --------------------------------------------------------------------------- + +func TestRunPrepareSteps_Exec(t *testing.T) { + skipIfNoDocker(t) + + for _, nc := range boxNodes() { + nc := nc + t.Run(nc.Name, func(t *testing.T) { + m := setupManager(t, &nc) + box := createBox(t, m, nc) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + steps := []types.PrepareStep{ + {Action: "exec", Cmd: "echo hello > /tmp/prep-test"}, + {Action: "exec", Cmd: "echo world >> /tmp/prep-test"}, + } + + err := sandboxv2.RunPrepareSteps(ctx, steps, box, "test-assistant", "") + if err != nil { + t.Fatalf("RunPrepareSteps: %v", err) + } + + result, err := box.Exec(ctx, []string{"cat", "/tmp/prep-test"}) + if err != nil { + t.Fatalf("cat: %v", err) + } + got := strings.TrimSpace(result.Stdout) + if got != "hello\nworld" { + t.Errorf("content = %q, want %q", got, "hello\nworld") + } + }) + } +} + +func TestRunPrepareSteps_File(t *testing.T) { + skipIfNoDocker(t) + + for _, nc := range boxNodes() { + nc := nc + t.Run(nc.Name, func(t *testing.T) { + m := setupManager(t, &nc) + box := createBox(t, m, nc) + wsID := fmt.Sprintf("test-file-%d", time.Now().UnixNano()) + box.BindWorkplace(wsID) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + steps := []types.PrepareStep{ + {Action: "file", Path: "config/test.txt", Content: []byte("file-content-v2")}, + } + + err := sandboxv2.RunPrepareSteps(ctx, steps, box, "test-assistant", "") + if err != nil { + t.Fatalf("RunPrepareSteps: %v", err) + } + + ws := box.Workplace() + data, err := ws.ReadFile("config/test.txt") + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(data) != "file-content-v2" { + t.Errorf("content = %q, want %q", string(data), "file-content-v2") + } + }) + } +} + +func TestRunPrepareSteps_Copy(t *testing.T) { + skipIfNoDocker(t) + + for _, nc := range boxNodes() { + nc := nc + t.Run(nc.Name, func(t *testing.T) { + m := setupManager(t, &nc) + box := createBox(t, m, nc) + wsID := fmt.Sprintf("test-copy-%d", time.Now().UnixNano()) + box.BindWorkplace(wsID) + + ws := box.Workplace() + ws.WriteFile("src.txt", []byte("copy-src"), 0644) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + steps := []types.PrepareStep{ + {Action: "copy", Src: "src.txt", Dst: "dst.txt"}, + } + + err := sandboxv2.RunPrepareSteps(ctx, steps, box, "test-assistant", "") + if err != nil { + t.Fatalf("RunPrepareSteps: %v", err) + } + + data, err := ws.ReadFile("dst.txt") + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(data) != "copy-src" { + t.Errorf("content = %q, want %q", string(data), "copy-src") + } + }) + } +} + +func TestRunPrepareSteps_OnceMarker(t *testing.T) { + skipIfNoDocker(t) + + for _, nc := range boxNodes() { + nc := nc + t.Run(nc.Name, func(t *testing.T) { + m := setupManager(t, &nc) + box := createBox(t, m, nc) + wsID := fmt.Sprintf("test-once-%d", time.Now().UnixNano()) + box.BindWorkplace(wsID) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + counter := "/tmp/once-counter" + steps := []types.PrepareStep{ + {Action: "exec", Cmd: "echo -n x >> " + counter, Once: true}, + } + + hash := "abc123" + assistantID := "test-once" + + if err := sandboxv2.RunPrepareSteps(ctx, steps, box, assistantID, hash); err != nil { + t.Fatalf("first run: %v", err) + } + r1, _ := box.Exec(ctx, []string{"cat", counter}) + if r1.Stdout != "x" { + t.Fatalf("first run: got %q, want %q", r1.Stdout, "x") + } + + if err := sandboxv2.RunPrepareSteps(ctx, steps, box, assistantID, hash); err != nil { + t.Fatalf("second run: %v", err) + } + r2, _ := box.Exec(ctx, []string{"cat", counter}) + if r2.Stdout != "x" { + t.Errorf("second run: got %q, want %q (once step should be skipped)", r2.Stdout, "x") + } + + if err := sandboxv2.RunPrepareSteps(ctx, steps, box, assistantID, "new-hash"); err != nil { + t.Fatalf("third run: %v", err) + } + r3, _ := box.Exec(ctx, []string{"cat", counter}) + if r3.Stdout != "xx" { + t.Errorf("third run: got %q, want %q (hash changed, should re-execute)", r3.Stdout, "xx") + } + }) + } +} + +func TestRunPrepareSteps_OnceIsolation(t *testing.T) { + skipIfNoDocker(t) + + for _, nc := range boxNodes() { + nc := nc + t.Run(nc.Name, func(t *testing.T) { + m := setupManager(t, &nc) + box := createBox(t, m, nc) + wsID := fmt.Sprintf("test-iso-%d", time.Now().UnixNano()) + box.BindWorkplace(wsID) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + stepsA := []types.PrepareStep{ + {Action: "exec", Cmd: "echo -n A >> /tmp/iso-a", Once: true}, + } + stepsB := []types.PrepareStep{ + {Action: "exec", Cmd: "echo -n B >> /tmp/iso-b", Once: true}, + } + + hash := "same-hash" + + if err := sandboxv2.RunPrepareSteps(ctx, stepsA, box, "assistant-a", hash); err != nil { + t.Fatalf("assistant-a: %v", err) + } + if err := sandboxv2.RunPrepareSteps(ctx, stepsB, box, "assistant-b", hash); err != nil { + t.Fatalf("assistant-b: %v", err) + } + + rA, _ := box.Exec(ctx, []string{"cat", "/tmp/iso-a"}) + rB, _ := box.Exec(ctx, []string{"cat", "/tmp/iso-b"}) + if rA.Stdout != "A" { + t.Errorf("assistant-a: got %q, want %q", rA.Stdout, "A") + } + if rB.Stdout != "B" { + t.Errorf("assistant-b: got %q, want %q", rB.Stdout, "B") + } + + if err := sandboxv2.RunPrepareSteps(ctx, stepsA, box, "assistant-a", hash); err != nil { + t.Fatalf("assistant-a re-run: %v", err) + } + if err := sandboxv2.RunPrepareSteps(ctx, stepsB, box, "assistant-b", hash); err != nil { + t.Fatalf("assistant-b re-run: %v", err) + } + rA2, _ := box.Exec(ctx, []string{"cat", "/tmp/iso-a"}) + rB2, _ := box.Exec(ctx, []string{"cat", "/tmp/iso-b"}) + if rA2.Stdout != "A" { + t.Errorf("assistant-a re-run: got %q, want %q (should be skipped)", rA2.Stdout, "A") + } + if rB2.Stdout != "B" { + t.Errorf("assistant-b re-run: got %q, want %q (should be skipped)", rB2.Stdout, "B") + } + }) + } +} + +func TestRunPrepareSteps_IgnoreError(t *testing.T) { + skipIfNoDocker(t) + + for _, nc := range boxNodes() { + nc := nc + t.Run(nc.Name, func(t *testing.T) { + m := setupManager(t, &nc) + box := createBox(t, m, nc) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + steps := []types.PrepareStep{ + {Action: "exec", Cmd: "false", IgnoreError: true}, + {Action: "exec", Cmd: "echo survived > /tmp/survived"}, + } + + err := sandboxv2.RunPrepareSteps(ctx, steps, box, "test-assistant", "") + if err != nil { + t.Fatalf("RunPrepareSteps: %v (ignore_error should have prevented failure)", err) + } + + result, _ := box.Exec(ctx, []string{"cat", "/tmp/survived"}) + if strings.TrimSpace(result.Stdout) != "survived" { + t.Errorf("second step should have executed, got %q", result.Stdout) + } + }) + } +} + +func TestRunPrepareSteps_FailOnError(t *testing.T) { + skipIfNoDocker(t) + + for _, nc := range boxNodes() { + nc := nc + t.Run(nc.Name, func(t *testing.T) { + m := setupManager(t, &nc) + box := createBox(t, m, nc) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + steps := []types.PrepareStep{ + {Action: "exec", Cmd: "false"}, + {Action: "exec", Cmd: "echo should-not-reach > /tmp/unreachable"}, + } + + err := sandboxv2.RunPrepareSteps(ctx, steps, box, "test-assistant", "") + if err == nil { + t.Fatal("expected error from failing step without ignore_error") + } + + result, _ := box.Exec(ctx, []string{"cat", "/tmp/unreachable"}) + if result.ExitCode == 0 { + t.Error("second step should not have executed") + } + }) + } +} + +func TestRunPrepareSteps_UnknownAction(t *testing.T) { + skipIfNoDocker(t) + + for _, nc := range boxNodes() { + nc := nc + t.Run(nc.Name, func(t *testing.T) { + m := setupManager(t, &nc) + _ = createBox(t, m, nc) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + steps := []types.PrepareStep{ + {Action: "unknown_action"}, + } + + err := sandboxv2.RunPrepareSteps(ctx, steps, nil, "test-assistant", "") + if err == nil { + t.Fatal("expected error for unknown action") + } + if !strings.Contains(err.Error(), "unknown_action") { + t.Errorf("error should mention action name, got: %v", err) + } + }) + } +} + +func TestRunPrepareSteps_EmptySteps(t *testing.T) { + err := sandboxv2.RunPrepareSteps(context.Background(), nil, nil, "test-assistant", "hash") + if err != nil { + t.Fatalf("empty steps should succeed: %v", err) + } +} + +func TestRunPrepareSteps_Background(t *testing.T) { + skipIfNoDocker(t) + + for _, nc := range boxNodes() { + nc := nc + t.Run(nc.Name, func(t *testing.T) { + m := setupManager(t, &nc) + box := createBox(t, m, nc) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + steps := []types.PrepareStep{ + {Action: "exec", Cmd: "sleep 30", Background: true}, + {Action: "exec", Cmd: "echo after-bg > /tmp/after-bg"}, + } + + err := sandboxv2.RunPrepareSteps(ctx, steps, box, "test-assistant", "") + if err != nil { + t.Fatalf("RunPrepareSteps: %v", err) + } + + result, _ := box.Exec(ctx, []string{"cat", "/tmp/after-bg"}) + if strings.TrimSpace(result.Stdout) != "after-bg" { + t.Errorf("background step blocked execution, got %q", result.Stdout) + } + }) + } +} + +func TestRunPrepareSteps_MixedActions(t *testing.T) { + skipIfNoDocker(t) + + for _, nc := range boxNodes() { + nc := nc + t.Run(nc.Name, func(t *testing.T) { + m := setupManager(t, &nc) + box := createBox(t, m, nc) + wsID := fmt.Sprintf("test-mixed-%d", time.Now().UnixNano()) + box.BindWorkplace(wsID) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + steps := []types.PrepareStep{ + {Action: "file", Path: "mixed.conf", Content: []byte("key=value")}, + {Action: "exec", Cmd: "echo exec-ok > /tmp/mixed-exec"}, + {Action: "copy", Src: "mixed.conf", Dst: "mixed-copy.conf"}, + } + + err := sandboxv2.RunPrepareSteps(ctx, steps, box, "test-assistant", "") + if err != nil { + t.Fatalf("RunPrepareSteps: %v", err) + } + + ws := box.Workplace() + data, err := ws.ReadFile("mixed-copy.conf") + if err != nil { + t.Fatalf("ReadFile mixed-copy.conf: %v", err) + } + if string(data) != "key=value" { + t.Errorf("copy result: got %q, want %q", string(data), "key=value") + } + + result, _ := box.Exec(ctx, []string{"cat", "/tmp/mixed-exec"}) + if strings.TrimSpace(result.Stdout) != "exec-ok" { + t.Errorf("exec result: got %q, want %q", result.Stdout, "exec-ok") + } + }) + } +} + +// --------------------------------------------------------------------------- +// HostExec tests +// --------------------------------------------------------------------------- + +func TestRunPrepareSteps_HostExec(t *testing.T) { + skipIfNoHostExec(t) + + for _, tgt := range hostTargets() { + tgt := tgt + t.Run(tgt.Name, func(t *testing.T) { + m := setupHostManager(t, &tgt) + host := createHost(t, m, tgt) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + t.Logf("SystemInfo: OS=%q Shell=%q TempDir=%q", + host.ComputerInfo().System.OS, + host.ComputerInfo().System.Shell, + host.ComputerInfo().System.TempDir) + + isWin := tgt.Name == "win-native" + var cmd string + if isWin { + cmd = `Write-Output 'host-ok'` + } else { + cmd = "echo host-ok" + } + steps := []types.PrepareStep{ + {Action: "exec", Cmd: cmd}, + } + + err := sandboxv2.RunPrepareSteps(ctx, steps, host, "test-host", "") + if err != nil { + t.Fatalf("RunPrepareSteps on host: %v", err) + } + }) + } +} + +func TestRunPrepareSteps_HostExecFile(t *testing.T) { + skipIfNoHostExec(t) + + for _, tgt := range hostTargets() { + tgt := tgt + t.Run(tgt.Name, func(t *testing.T) { + m := setupHostManager(t, &tgt) + host := createHost(t, m, tgt) + wsID := fmt.Sprintf("test-hostfile-%d", time.Now().UnixNano()) + host.BindWorkplace(wsID) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + steps := []types.PrepareStep{ + {Action: "file", Path: "host-test.txt", Content: []byte("host-file-data")}, + } + + err := sandboxv2.RunPrepareSteps(ctx, steps, host, "test-host", "") + if err != nil { + t.Fatalf("RunPrepareSteps file: %v", err) + } + + ws := host.Workplace() + data, err := ws.ReadFile("host-test.txt") + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(data) != "host-file-data" { + t.Errorf("content = %q, want %q", string(data), "host-file-data") + } + }) + } +} + +func TestRunPrepareSteps_HostExecCopy(t *testing.T) { + skipIfNoHostExec(t) + + for _, tgt := range hostTargets() { + tgt := tgt + t.Run(tgt.Name, func(t *testing.T) { + m := setupHostManager(t, &tgt) + host := createHost(t, m, tgt) + wsID := fmt.Sprintf("test-hostcopy-%d", time.Now().UnixNano()) + host.BindWorkplace(wsID) + + ws := host.Workplace() + ws.WriteFile("copy-src.txt", []byte("copy-data"), 0644) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + steps := []types.PrepareStep{ + {Action: "copy", Src: "copy-src.txt", Dst: "copy-dst.txt"}, + } + + err := sandboxv2.RunPrepareSteps(ctx, steps, host, "test-host", "") + if err != nil { + t.Fatalf("RunPrepareSteps copy: %v", err) + } + + data, err := ws.ReadFile("copy-dst.txt") + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(data) != "copy-data" { + t.Errorf("content = %q, want %q", string(data), "copy-data") + } + }) + } +} + +func TestRunPrepareSteps_HostExecOnce(t *testing.T) { + skipIfNoHostExec(t) + + for _, tgt := range hostTargets() { + tgt := tgt + t.Run(tgt.Name, func(t *testing.T) { + m := setupHostManager(t, &tgt) + host := createHost(t, m, tgt) + wsID := fmt.Sprintf("test-hostonce-%d", time.Now().UnixNano()) + host.BindWorkplace(wsID) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + isWin := tgt.Name == "win-native" + var cmd string + if isWin { + cmd = `Write-Output 'once-ok'` + } else { + cmd = "echo once-ok" + } + steps := []types.PrepareStep{ + {Action: "exec", Cmd: cmd, Once: true}, + } + hash := "host-once-hash" + aid := "host-once-aid" + + if err := sandboxv2.RunPrepareSteps(ctx, steps, host, aid, hash); err != nil { + t.Fatalf("first run: %v", err) + } + + ws := host.Workplace() + markerData, err := ws.ReadFile(".yao/prepare/" + aid + "/done") + if err != nil { + t.Fatalf("marker not written: %v", err) + } + if string(markerData) != hash { + t.Errorf("marker = %q, want %q", string(markerData), hash) + } + }) + } +} diff --git a/agent/sandbox/v2/runner.go b/agent/sandbox/v2/runner.go new file mode 100644 index 00000000..b2132607 --- /dev/null +++ b/agent/sandbox/v2/runner.go @@ -0,0 +1,32 @@ +package sandboxv2 + +import ( + "fmt" + "sync" + + "github.com/yaoapp/yao/agent/sandbox/v2/types" +) + +var ( + mu sync.RWMutex + runners = map[string]func() types.Runner{} +) + +// Register adds a runner factory to the global registry. +// Typically called from init() in the runner's package. +func Register(name string, factory func() types.Runner) { + mu.Lock() + defer mu.Unlock() + runners[name] = factory +} + +// Get creates a new Runner instance from the registry. +func Get(name string) (types.Runner, error) { + mu.RLock() + defer mu.RUnlock() + factory, ok := runners[name] + if !ok { + return nil, fmt.Errorf("sandbox runner %q not registered", name) + } + return factory(), nil +} diff --git a/agent/sandbox/v2/shell.go b/agent/sandbox/v2/shell.go new file mode 100644 index 00000000..565ca09b --- /dev/null +++ b/agent/sandbox/v2/shell.go @@ -0,0 +1,47 @@ +package sandboxv2 + +import ( + "strings" + + infra "github.com/yaoapp/yao/sandbox/v2" +) + +// shellKind identifies which shell to use for command execution. +type shellKind int + +const ( + shellSh shellKind = iota // Unix: sh -c + shellPwsh // Windows: pwsh -NoProfile -Command + shellPS // Windows: powershell -NoProfile -Command + shellCmd // Windows: cmd.exe /C (last-resort fallback) +) + +// shellWrap returns the Exec command slice to run a script string. +func shellWrap(kind shellKind, script string) []string { + switch kind { + case shellPwsh: + return []string{"pwsh", "-NoProfile", "-Command", script} + case shellPS: + return []string{"powershell", "-NoProfile", "-Command", script} + case shellCmd: + return []string{"cmd.exe", "/C", script} + default: + return []string{"sh", "-c", script} + } +} + +// shellFromSystem resolves shellKind from ComputerInfo().System.Shell +// reported by the Tai node at registration time. +func shellFromSystem(computer infra.Computer) shellKind { + shell := strings.ToLower(computer.ComputerInfo().System.Shell) + switch shell { + case "pwsh": + return shellPwsh + case "powershell": + return shellPS + case "cmd.exe", "cmd": + return shellCmd + default: + return shellSh + } +} diff --git a/agent/sandbox/v2/stream.go b/agent/sandbox/v2/stream.go new file mode 100644 index 00000000..2066cac5 --- /dev/null +++ b/agent/sandbox/v2/stream.go @@ -0,0 +1,138 @@ +package sandboxv2 + +import ( + "context" + "errors" + "fmt" + "log" + "time" + + agentContext "github.com/yaoapp/yao/agent/context" + "github.com/yaoapp/yao/agent/output/message" + "github.com/yaoapp/yao/agent/sandbox/v2/types" + infra "github.com/yaoapp/yao/sandbox/v2" +) + +// ExecuteRequest consolidates all parameters for ExecuteSandboxStream. +type ExecuteRequest struct { + Computer infra.Computer + Runner types.Runner + Config *types.SandboxConfig + StreamReq *types.StreamRequest + Manager *infra.Manager +} + +// ExecuteSandboxStream is the V2 replacement for executeSandboxStream. +// It calls runner.Stream, handles interrupts, and performs cleanup/lifecycle +// in defer. +func ExecuteSandboxStream( + ctx *agentContext.Context, + req *ExecuteRequest, + handler message.StreamFunc, +) (*agentContext.CompletionResponse, error) { + + if req.Runner == nil || req.Computer == nil { + return nil, fmt.Errorf("runner and computer are required") + } + + stdCtx := ctx.Context + panicked := true // Assume panic; set false on normal exit. + + // Resolve stop timeout from config (default 2s). + stopTimeout := 2 * time.Second + if req.Config != nil && req.Config.StopTimeout != "" { + if d, err := time.ParseDuration(req.Config.StopTimeout); err == nil { + stopTimeout = d + } + } + + // Panic recovery (registered first, executes last in LIFO order). + defer func() { + if r := recover(); r != nil { + log.Printf("[sandbox/v2] panic in stream: %v", r) + cleanCtx, cancel := context.WithTimeout(context.Background(), stopTimeout) + defer cancel() + req.Runner.Cleanup(cleanCtx, req.Computer) + LifecycleAction(cleanCtx, req.Config, req.Computer, req.Manager) + } + }() + + // Lifecycle action (registered second, executes second-to-last). + defer func() { + if !panicked { + LifecycleAction(stdCtx, req.Config, req.Computer, req.Manager) + } + }() + + // Runner cleanup (registered last, executes first). + defer func() { + if !panicked { + cleanCtx, cancel := context.WithTimeout(context.Background(), stopTimeout) + defer cancel() + req.Runner.Cleanup(cleanCtx, req.Computer) + } + }() + + // Build a cancellable runnerCtx that bridges agentContext interrupts. + runnerCtx, cancelRunner := context.WithCancel(stdCtx) + defer cancelRunner() // Prevent goroutine leak. + + done := make(chan struct{}) + defer close(done) + + go func() { + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-ticker.C: + if ctx.Interrupt != nil { + if sig := ctx.Interrupt.Peek(); sig != nil { + cancelRunner() + return + } + if ctx.Interrupt.IsInterrupted() { + cancelRunner() + return + } + } + case <-stdCtx.Done(): + cancelRunner() + return + } + } + }() + + var textContent []byte + wrappedHandler := func(chunkType message.StreamChunkType, data []byte) int { + if chunkType == message.ChunkText { + textContent = append(textContent, data...) + } + if handler != nil { + return handler(chunkType, data) + } + return 0 + } + + err := req.Runner.Stream(runnerCtx, req.StreamReq, wrappedHandler) + + panicked = false // Normal exit reached. + + if err != nil { + if errors.Is(err, context.Canceled) { + return nil, err + } + return nil, fmt.Errorf("runner.Stream: %w", err) + } + + resp := &agentContext.CompletionResponse{ + Role: "assistant", + FinishReason: agentContext.FinishReasonStop, + } + if len(textContent) > 0 { + resp.Content = string(textContent) + } + return resp, nil +} diff --git a/agent/sandbox/v2/testutils/testutils.go b/agent/sandbox/v2/testutils/testutils.go new file mode 100644 index 00000000..7e51a105 --- /dev/null +++ b/agent/sandbox/v2/testutils/testutils.go @@ -0,0 +1,47 @@ +package testutils + +import ( + "context" + "os" + "path/filepath" + "testing" + + agenttestutils "github.com/yaoapp/yao/agent/testutils" + "github.com/yaoapp/yao/config" + sandboxv2 "github.com/yaoapp/yao/sandbox/v2" + "github.com/yaoapp/yao/tai" + "github.com/yaoapp/yao/tai/registry" +) + +// Prepare initializes the full environment required for sandbox V2 E2E tests: +// - agent layer (assistants, LLM, caller) +// - tai registry + local node +// - sandbox V2 manager +func Prepare(t *testing.T) { + t.Helper() + + agenttestutils.Prepare(t) + + if registry.Global() == nil { + registry.Init(nil) + } + + dataDir := filepath.Join(config.Conf.DataRoot, "workspaces") + os.MkdirAll(dataDir, 0755) + tai.RegisterLocal(tai.WithDataDir(dataDir)) + + sandboxv2.Init() + if err := sandboxv2.M().Start(context.Background()); err != nil { + t.Fatalf("sandbox v2 manager start: %v", err) + } + + t.Cleanup(func() { + sandboxv2.M().Close() + }) +} + +// Clean tears down the test environment. +func Clean(t *testing.T) { + t.Helper() + agenttestutils.Clean(t) +} diff --git a/agent/sandbox/v2/testutils_test.go b/agent/sandbox/v2/testutils_test.go new file mode 100644 index 00000000..1ee2c02b --- /dev/null +++ b/agent/sandbox/v2/testutils_test.go @@ -0,0 +1,228 @@ +package sandboxv2_test + +import ( + "context" + "fmt" + "log" + "os" + "strconv" + "strings" + "testing" + "time" + + sandbox "github.com/yaoapp/yao/sandbox/v2" + "github.com/yaoapp/yao/tai" + "github.com/yaoapp/yao/tai/registry" + taisandbox "github.com/yaoapp/yao/tai/sandbox" + "github.com/yaoapp/yao/workspace" +) + +// --------------------------------------------------------------------------- +// node configuration — mirrors sandbox/v2 testutils but scoped to prepare tests +// --------------------------------------------------------------------------- + +type nodeConfig struct { + Name string + Addr string + TaiID string + Options []tai.Option +} + +type hostTarget struct { + Name string + Addr string + TaiID string +} + +// --------------------------------------------------------------------------- +// environment helpers (same conventions as sandbox/v2 + env.local.sh) +// --------------------------------------------------------------------------- + +func testLocalAddr() string { + if addr := os.Getenv("SANDBOX_TEST_LOCAL_ADDR"); addr != "" { + return addr + } + return "local" +} + +func testImage() string { + if img := os.Getenv("SANDBOX_TEST_IMAGE"); img != "" { + return img + } + return "alpine:latest" +} + +func envPort(key string, fallback int) int { + if v := os.Getenv(key); v != "" { + if p, err := strconv.Atoi(v); err == nil { + return p + } + } + return fallback +} + +// --------------------------------------------------------------------------- +// node discovery +// --------------------------------------------------------------------------- + +func boxNodes() []nodeConfig { + nodes := []nodeConfig{ + {Name: "local", Addr: testLocalAddr()}, + } + if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" { + nodes = append(nodes, nodeConfig{Name: "remote", Addr: addr}) + } + return nodes +} + +func hostTargets() []hostTarget { + var targets []hostTarget + if addr := os.Getenv("TAI_TEST_WIN_HOSTEXEC_LINUX"); addr != "" { + targets = append(targets, hostTarget{Name: "win-linux", Addr: addr}) + } + if addr := os.Getenv("TAI_TEST_WIN_HOSTEXEC_NATIVE"); addr != "" { + targets = append(targets, hostTarget{Name: "win-native", Addr: addr}) + } + return targets +} + +// --------------------------------------------------------------------------- +// TestMain — purge stale containers from previous runs +// --------------------------------------------------------------------------- + +func TestMain(m *testing.M) { + purgeStale() + os.Exit(m.Run()) +} + +func purgeStale() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + for _, nc := range boxNodes() { + client, err := tai.New(nc.Addr, nc.Options...) + if err != nil { + continue + } + sb := client.Sandbox() + if sb == nil { + client.Close() + continue + } + containers, _ := sb.List(ctx, taisandbox.ListOptions{All: true}) + for _, c := range containers { + id := c.Name + if id == "" { + id = c.ID + } + if strings.HasPrefix(id, "sb-prep-") || strings.HasPrefix(id, "sb-lc-") { + sb.Remove(ctx, id, true) + log.Printf("[purge] %s: removed %s", nc.Name, id) + } + } + client.Close() + } +} + +// --------------------------------------------------------------------------- +// Manager + Box helpers +// --------------------------------------------------------------------------- + +func setupManager(t *testing.T, nc *nodeConfig) *sandbox.Manager { + t.Helper() + if registry.Global() == nil { + registry.Init(nil) + } + client, err := tai.New(nc.Addr, nc.Options...) + if err != nil { + t.Fatalf("tai.New(%s): %v", nc.Addr, err) + } + nc.TaiID = client.TaiID() + + sandbox.Init() + m := sandbox.M() + t.Cleanup(func() { m.Close() }) + return m +} + +func createBox(t *testing.T, m *sandbox.Manager, nc nodeConfig) *sandbox.Box { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + if err := m.EnsureImage(ctx, nc.TaiID, testImage(), sandbox.ImagePullOptions{}); err != nil { + t.Fatalf("EnsureImage: %v", err) + } + + box, err := m.Create(ctx, sandbox.CreateOptions{ + ID: fmt.Sprintf("sb-prep-%d", time.Now().UnixNano()), + Image: testImage(), + Owner: "test-prepare", + NodeID: nc.TaiID, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + t.Cleanup(func() { + cCtx, cCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cCancel() + if err := m.Remove(cCtx, box.ID()); err != nil { + t.Logf("cleanup Remove(%s): %v", box.ID(), err) + } + }) + return box +} + +func createHost(t *testing.T, m *sandbox.Manager, tgt hostTarget) *sandbox.Host { + t.Helper() + host, err := m.Host(context.Background(), tgt.TaiID) + if err != nil { + t.Skipf("Host(%s): %v", tgt.Name, err) + } + return host +} + +func setupHostManager(t *testing.T, tgt *hostTarget) *sandbox.Manager { + t.Helper() + nc := nodeConfig{Name: tgt.Name, Addr: fmt.Sprintf("tai://%s", tgt.Addr)} + m := setupManager(t, &nc) + tgt.TaiID = nc.TaiID + return m +} + +// --------------------------------------------------------------------------- +// skip helpers +// --------------------------------------------------------------------------- + +func skipIfNoDocker(t *testing.T) { + t.Helper() + if testLocalAddr() == "" { + t.Skip("SANDBOX_TEST_LOCAL_ADDR not set") + } +} + +func skipIfNoHostExec(t *testing.T) { + t.Helper() + if len(hostTargets()) == 0 { + t.Skip("no HostExec targets configured") + } +} + +func createTestWorkspace(t *testing.T, taiID, wsID string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + _, err := workspace.M().Create(ctx, workspace.CreateOptions{ + ID: wsID, + Owner: "test", + Node: taiID, + }) + if err != nil && !strings.Contains(err.Error(), "exists") { + t.Fatalf("create workspace %q: %v", wsID, err) + } + t.Cleanup(func() { + cCtx, cCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cCancel() + workspace.M().Delete(cCtx, wsID, true) + }) +} diff --git a/agent/sandbox/v2/types/config.go b/agent/sandbox/v2/types/config.go new file mode 100644 index 00000000..2f5a57af --- /dev/null +++ b/agent/sandbox/v2/types/config.go @@ -0,0 +1,137 @@ +package types + +import ( + "encoding/json" + "fmt" +) + +const ( + SandboxVersionV1 = "1.0" + SandboxVersionV2 = "2.0" +) + +// SandboxConfig is the V2 sandbox configuration loaded from sandbox.yao or +// the package.yao "sandbox" block when version == "2.0". +type SandboxConfig struct { + Version string `json:"version" yaml:"version"` + Computer ComputerConfig `json:"computer" yaml:"computer"` + Runner RunnerConfig `json:"runner" yaml:"runner"` + Lifecycle string `json:"lifecycle,omitempty" yaml:"lifecycle,omitempty"` + IdleTimeout string `json:"idle_timeout,omitempty" yaml:"idle_timeout,omitempty"` + MaxLifetime string `json:"max_lifetime,omitempty" yaml:"max_lifetime,omitempty"` + StopTimeout string `json:"stop_timeout,omitempty" yaml:"stop_timeout,omitempty"` + Prepare []PrepareStep `json:"prepare,omitempty" yaml:"prepare,omitempty"` + Environment map[string]string `json:"environment,omitempty" yaml:"environment,omitempty"` + Secrets map[string]string `json:"secrets,omitempty" yaml:"secrets,omitempty"` + + // Populated by the framework at runtime (never serialized). + Owner string `json:"-" yaml:"-"` + ID string `json:"-" yaml:"-"` + Labels map[string]string `json:"-" yaml:"-"` + NodeID string `json:"-" yaml:"-"` + Kind string `json:"-" yaml:"-"` + WorkspaceID string `json:"-" yaml:"-"` +} + +// ComputerConfig describes the execution environment (container or host). +type ComputerConfig struct { + Image string `json:"image,omitempty" yaml:"image,omitempty"` + VNC VNCConfig `json:"vnc,omitempty" yaml:"vnc,omitempty"` + Memory string `json:"memory,omitempty" yaml:"memory,omitempty"` + CPUs float64 `json:"cpus,omitempty" yaml:"cpus,omitempty"` + Ports PortList `json:"ports,omitempty" yaml:"ports,omitempty"` + User string `json:"user,omitempty" yaml:"user,omitempty"` + WorkDir string `json:"work_dir,omitempty" yaml:"work_dir,omitempty"` + MountPath string `json:"mount_path,omitempty" yaml:"mount_path,omitempty"` + MountMode string `json:"mount_mode,omitempty" yaml:"mount_mode,omitempty"` +} + +// RunnerConfig identifies which Runner to use and how. +type RunnerConfig struct { + Name string `json:"name" yaml:"name"` + Mode string `json:"mode,omitempty" yaml:"mode,omitempty"` + Options map[string]any `json:"options,omitempty" yaml:"options,omitempty"` +} + +// PrepareStep is a single action executed during Runner.Prepare. +type PrepareStep struct { + Action string `json:"action" yaml:"action"` + Once bool `json:"once,omitempty" yaml:"once,omitempty"` + IgnoreError bool `json:"ignore_error,omitempty" yaml:"ignore_error,omitempty"` + + // action=copy + Src string `json:"src,omitempty" yaml:"src,omitempty"` + Dst string `json:"dst,omitempty" yaml:"dst,omitempty"` + + // action=exec + Cmd string `json:"cmd,omitempty" yaml:"cmd,omitempty"` + Background bool `json:"background,omitempty" yaml:"background,omitempty"` + + // action=file (internal use by Runner.Prepare) + Path string `json:"path,omitempty" yaml:"path,omitempty"` + Content []byte `json:"-" yaml:"-"` + + // action=process (reserved) + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Args []any `json:"args,omitempty" yaml:"args,omitempty"` +} + +// --------------------------------------------------------------------------- +// VNCConfig — supports both bool and object in JSON/YAML: +// true → VNCConfig{Enabled: true} +// {"enabled": true, "password": "xxx"} → full struct +// --------------------------------------------------------------------------- + +type VNCConfig struct { + Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + ViewOnly bool `json:"view_only,omitempty" yaml:"view_only,omitempty"` + Password string `json:"password,omitempty" yaml:"password,omitempty"` + Resolution string `json:"resolution,omitempty" yaml:"resolution,omitempty"` +} + +func (v *VNCConfig) UnmarshalJSON(data []byte) error { + var b bool + if err := json.Unmarshal(data, &b); err == nil { + v.Enabled = b + return nil + } + type alias VNCConfig + var a alias + if err := json.Unmarshal(data, &a); err != nil { + return err + } + *v = VNCConfig(a) + return nil +} + +// --------------------------------------------------------------------------- +// PortList — supports both int array and object array in JSON: +// [3000, 8080] → []PortMapping{{Port: 3000}, {Port: 8080}} +// [{"port": 3000, "host_port": 9000}] → full structs +// --------------------------------------------------------------------------- + +type PortList []PortMapping + +type PortMapping struct { + Port int `json:"port" yaml:"port"` + HostPort int `json:"host_port,omitempty" yaml:"host_port,omitempty"` + Protocol string `json:"protocol,omitempty" yaml:"protocol,omitempty"` +} + +func (p *PortList) UnmarshalJSON(data []byte) error { + var ints []int + if err := json.Unmarshal(data, &ints); err == nil { + out := make(PortList, len(ints)) + for i, port := range ints { + out[i] = PortMapping{Port: port} + } + *p = out + return nil + } + var objs []PortMapping + if err := json.Unmarshal(data, &objs); err != nil { + return fmt.Errorf("ports: expected int array or object array: %w", err) + } + *p = objs + return nil +} diff --git a/agent/sandbox/v2/types/runner.go b/agent/sandbox/v2/types/runner.go new file mode 100644 index 00000000..5a90f80f --- /dev/null +++ b/agent/sandbox/v2/types/runner.go @@ -0,0 +1,53 @@ +package types + +import ( + "context" + + "github.com/yaoapp/gou/connector" + agentContext "github.com/yaoapp/yao/agent/context" + "github.com/yaoapp/yao/agent/output/message" + infra "github.com/yaoapp/yao/sandbox/v2" +) + +// Runner is the interface that all sandbox runners must implement. +// A Runner replaces the LLM invocation layer (executeLLMStream) when a +// sandbox is configured. +type Runner interface { + Name() string + Prepare(ctx context.Context, req *PrepareRequest) error + Stream(ctx context.Context, req *StreamRequest, handler message.StreamFunc) error + Cleanup(ctx context.Context, computer infra.Computer) error +} + +// MCPServer mirrors store/types.MCPServerConfig to avoid a cyclic import +// between this leaf package and agent/store/types. +type MCPServer struct { + ServerID string `json:"server_id,omitempty"` + Resources []string `json:"resources,omitempty"` + Tools []string `json:"tools,omitempty"` +} + +// RunStepsFunc is the signature of RunPrepareSteps. Workspace is obtained +// internally via computer.Workplace(). +type RunStepsFunc func(ctx context.Context, steps []PrepareStep, computer infra.Computer, assistantID, configHash string) error + +// PrepareRequest carries everything needed by Runner.Prepare. +type PrepareRequest struct { + Computer infra.Computer + Config *SandboxConfig + Connector connector.Connector + SkillsDir string + MCPServers []MCPServer + ConfigHash string + RunSteps RunStepsFunc +} + +// StreamRequest carries everything needed by Runner.Stream. +type StreamRequest struct { + Computer infra.Computer + Config *SandboxConfig + Connector connector.Connector + Messages []agentContext.Message + SystemPrompt string + ChatID string +} diff --git a/agent/sandbox/v2/types/token.go b/agent/sandbox/v2/types/token.go new file mode 100644 index 00000000..7f1cf525 --- /dev/null +++ b/agent/sandbox/v2/types/token.go @@ -0,0 +1,9 @@ +package types + +import "time" + +// SandboxToken is a short-lived JWT issued for a sandbox computer. +type SandboxToken struct { + Token string + ExpiresAt time.Time +} diff --git a/agent/sandbox/v2/yao/runner.go b/agent/sandbox/v2/yao/runner.go new file mode 100644 index 00000000..7bbd7109 --- /dev/null +++ b/agent/sandbox/v2/yao/runner.go @@ -0,0 +1,38 @@ +package yao + +import ( + "context" + + "github.com/yaoapp/yao/agent/output/message" + "github.com/yaoapp/yao/agent/sandbox/v2/types" + infra "github.com/yaoapp/yao/sandbox/v2" +) + +// YaoRunner is a no-op Runner for pure Hook-driven sandbox interactions. +// When runner.name == "yao", the assistant relies entirely on Create/Next +// hooks for logic; no external CLI is invoked. +type YaoRunner struct{} + +func New() *YaoRunner { return &YaoRunner{} } + +func (r *YaoRunner) Name() string { return "yao" } + +// Prepare runs user-defined prepare steps (copy, exec, file) but adds +// no runner-specific steps. Connector is not required. +func (r *YaoRunner) Prepare(ctx context.Context, req *types.PrepareRequest) error { + if req.RunSteps != nil && len(req.Config.Prepare) > 0 { + return req.RunSteps(ctx, req.Config.Prepare, req.Computer, req.Config.ID, req.ConfigHash) + } + return nil +} + +// Stream is a no-op — hooks handle all interaction. Returns immediately +// so the assistant framework proceeds to the Next hook. +func (r *YaoRunner) Stream(_ context.Context, _ *types.StreamRequest, _ message.StreamFunc) error { + return nil +} + +// Cleanup is a no-op for the yao runner. +func (r *YaoRunner) Cleanup(_ context.Context, _ infra.Computer) error { + return nil +} diff --git a/agent/sandbox/v2/yao/runner_test.go b/agent/sandbox/v2/yao/runner_test.go new file mode 100644 index 00000000..2f7e4201 --- /dev/null +++ b/agent/sandbox/v2/yao/runner_test.go @@ -0,0 +1,137 @@ +package yao_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yaoapp/yao/agent/caller" + agentcontext "github.com/yaoapp/yao/agent/context" + sandboxtestutils "github.com/yaoapp/yao/agent/sandbox/v2/testutils" + oauthtypes "github.com/yaoapp/yao/openapi/oauth/types" +) + +func TestSandboxV2_Yao_JSAPI(t *testing.T) { + sandboxtestutils.Prepare(t) + defer sandboxtestutils.Clean(t) + + require.NotNil(t, caller.AgentGetterFunc, "AgentGetterFunc should be registered after Prepare") + + agent, err := caller.AgentGetterFunc("tests.sandbox-v2.jsapi-v2") + require.NoError(t, err, "should load assistant tests.sandbox-v2.jsapi-v2") + + chatID := fmt.Sprintf("e2e-jsapi-%d", time.Now().UnixMilli()) + ctx := agentcontext.New( + context.Background(), + &oauthtypes.AuthorizedInfo{ + TeamID: "test-team-jsapi", + UserID: "test-user-jsapi", + }, + chatID, + ) + + messages := []agentcontext.Message{ + {Role: "user", Content: "test jsapi"}, + } + + done := make(chan struct{}) + var resp *agentcontext.Response + var streamErr error + + go func() { + defer close(done) + resp, streamErr = agent.Stream(ctx, messages) + }() + + select { + case <-done: + case <-time.After(3 * time.Minute): + t.Fatalf("timeout after 3m") + } + + require.NoError(t, streamErr, "Stream should not return error") + require.NotNil(t, resp, "response should not be nil") + + // runner=yao goes through executeLLMStream, then Next hook returns { data: results } + // The Next hook result should appear in resp.Next + require.NotNil(t, resp.Next, "resp.Next should not be nil (Next hook returned data)") + t.Logf("resp.Next: %+v", resp.Next) + + nextData, ok := resp.Next.(map[string]interface{}) + if !ok { + t.Fatalf("resp.Next should be a map, got %T: %+v", resp.Next, resp.Next) + } + + // The Next hook returns { data: results }, the framework unwraps .data + data, hasData := nextData["data"] + if hasData { + nextData, ok = data.(map[string]interface{}) + require.True(t, ok, "data should be a map") + } + + t.Logf("JSAPI test results: %+v", nextData) + + // ── Verify ctx.computer was available ── + assert.Equal(t, true, nextData["has_computer"], "ctx.computer should be available") + assert.Equal(t, true, nextData["has_workspace"], "ctx.workspace should be available") + + // ── Verify ctx.computer.Info() ── + if infoRaw, ok := nextData["computer_info"]; ok { + info, ok := infoRaw.(map[string]interface{}) + require.True(t, ok, "computer_info should be a map") + assert.NotEmpty(t, info["kind"], "computer_info.kind should not be empty") + t.Logf("computer info: kind=%v os=%v", info["kind"], info["os"]) + } else { + assert.Nil(t, nextData["computer_info_error"], "computer.Info() should not error") + } + + // ── Verify ctx.computer.Exec() ── + assert.Equal(t, "jsapi-v2-test", nextData["exec_stdout"], "Exec should return expected stdout") + assert.Nil(t, nextData["exec_error"], "Exec should not error") + if exitCode, ok := nextData["exec_exit_code"]; ok { + // JS numbers come back as float64 through JSON + switch v := exitCode.(type) { + case float64: + assert.Equal(t, float64(0), v, "exit_code should be 0") + case int: + assert.Equal(t, 0, v, "exit_code should be 0") + } + } + + // ── Verify ctx.workspace write/read ── + assert.Equal(t, true, nextData["write_read_ok"], "workspace WriteFile+ReadFile round-trip should work") + assert.Equal(t, "hello from jsapi v2", nextData["read_content"], "read content should match") + assert.Nil(t, nextData["write_read_error"], "write/read should not error") + + // ── Verify ctx.workspace MkdirAll + Exists ── + assert.Equal(t, true, nextData["mkdir_exists_ok"], "MkdirAll + Exists should work") + assert.Nil(t, nextData["mkdir_exists_error"], "mkdir/exists should not error") + + // ── Verify ctx.workspace ReadDir ── + assert.Nil(t, nextData["readdir_error"], "ReadDir should not error") + if count, ok := nextData["readdir_count"]; ok { + switch v := count.(type) { + case float64: + assert.Greater(t, v, float64(0), "ReadDir should return entries") + } + } + + // ── Verify ctx.workspace Stat ── + assert.Equal(t, true, nextData["stat_ok"], "Stat should return correct info") + assert.Nil(t, nextData["stat_error"], "Stat should not error") + + // ── Verify ctx.workspace Copy ── + assert.Equal(t, true, nextData["copy_ok"], "Copy should work") + assert.Nil(t, nextData["copy_error"], "Copy should not error") + + // ── Verify ctx.workspace Rename ── + assert.Equal(t, true, nextData["rename_ok"], "Rename should work") + assert.Nil(t, nextData["rename_error"], "Rename should not error") + + // ── Verify ctx.workspace Remove ── + assert.Equal(t, true, nextData["remove_ok"], "Remove should work") + assert.Nil(t, nextData["remove_error"], "Remove should not error") +} diff --git a/agent/store/types/sandbox_v2.go b/agent/store/types/sandbox_v2.go new file mode 100644 index 00000000..88485574 --- /dev/null +++ b/agent/store/types/sandbox_v2.go @@ -0,0 +1,99 @@ +package types + +import ( + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + jsoniter "github.com/json-iterator/go" + sandboxTypes "github.com/yaoapp/yao/agent/sandbox/v2/types" +) + +// LoadSandboxConfig reads a sandbox.yao file (JSON or YAML) and returns +// the V2 SandboxConfig. Called during Assistant.Load(). +func LoadSandboxConfig(filePath string) (*sandboxTypes.SandboxConfig, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("read sandbox config %s: %w", filePath, err) + } + + ext := strings.ToLower(filepath.Ext(filePath)) + var cfg sandboxTypes.SandboxConfig + + switch ext { + case ".json", ".yao": + if err := jsoniter.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse sandbox config (json): %w", err) + } + default: + if err := jsoniter.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse sandbox config: %w", err) + } + } + + if cfg.Version != sandboxTypes.SandboxVersionV2 { + return nil, fmt.Errorf("sandbox.yao version must be %q, got %q", sandboxTypes.SandboxVersionV2, cfg.Version) + } + + return &cfg, nil +} + +// ToSandboxV2 converts a generic value (typically map[string]any from DSL +// parsing) into a V2 SandboxConfig. +func ToSandboxV2(v any) (*sandboxTypes.SandboxConfig, error) { + if v == nil { + return nil, nil + } + + switch sb := v.(type) { + case *sandboxTypes.SandboxConfig: + return sb, nil + case sandboxTypes.SandboxConfig: + return &sb, nil + default: + raw, err := jsoniter.Marshal(v) + if err != nil { + return nil, fmt.Errorf("sandbox v2 format error: %w", err) + } + var cfg sandboxTypes.SandboxConfig + if err := jsoniter.Unmarshal(raw, &cfg); err != nil { + return nil, fmt.Errorf("sandbox v2 format error: %w", err) + } + return &cfg, nil + } +} + +// ComputeConfigHash computes a SHA-256 fingerprint of the sandbox configuration, +// MCP servers, and skills directory. Used for hot-reload detection in prepare +// step "once" logic. +func ComputeConfigHash(cfg *sandboxTypes.SandboxConfig, mcpServers []MCPServerConfig, skillsDir string) string { + h := sha256.New() + + raw, _ := jsoniter.Marshal(cfg) + h.Write(raw) + + if len(mcpServers) > 0 { + mcpRaw, _ := jsoniter.Marshal(mcpServers) + h.Write(mcpRaw) + } + + if skillsDir != "" { + h.Write([]byte(skillsDir)) + entries, err := os.ReadDir(skillsDir) + if err == nil { + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + sort.Strings(names) + for _, n := range names { + h.Write([]byte(n)) + } + } + } + + return fmt.Sprintf("%x", h.Sum(nil)) +} diff --git a/agent/store/types/types.go b/agent/store/types/types.go index ea4ae8c1..4063b285 100644 --- a/agent/store/types/types.go +++ b/agent/store/types/types.go @@ -9,6 +9,7 @@ import ( "github.com/yaoapp/xun/dbal/query" "github.com/yaoapp/yao/agent/context" "github.com/yaoapp/yao/agent/i18n" + sandboxTypes "github.com/yaoapp/yao/agent/sandbox/v2/types" searchTypes "github.com/yaoapp/yao/agent/search/types" ) @@ -421,42 +422,44 @@ type ConnectorOptions struct { // AssistantModel the assistant database model type AssistantModel struct { - ID string `json:"assistant_id"` // Assistant ID - Type string `json:"type,omitempty"` // Assistant Type, default is assistant - Name string `json:"name,omitempty"` // Assistant Name - Avatar string `json:"avatar,omitempty"` // Assistant Avatar - Connector string `json:"connector"` // AI Connector (default connector) - ConnectorOptions *ConnectorOptions `json:"connector_options,omitempty"` // Connector selection options for user to choose from - Path string `json:"path,omitempty"` // Assistant Path - BuiltIn bool `json:"built_in,omitempty"` // Whether this is a built-in assistant - Sort int `json:"sort,omitempty"` // Assistant Sort - Description string `json:"description,omitempty"` // Assistant Description - Capabilities string `json:"capabilities,omitempty"` // Assistant capabilities description (useful for Robot orchestration) - Tags []string `json:"tags,omitempty"` // Assistant Tags - Modes []string `json:"modes,omitempty"` // Supported modes (e.g., ["task", "chat"]), null means all modes are supported - DefaultMode string `json:"default_mode,omitempty"` // Default mode, can be empty - Readonly bool `json:"readonly,omitempty"` // Whether this assistant is readonly - Public bool `json:"public,omitempty"` // Whether this assistant is shared across all teams in the platform - Share string `json:"share,omitempty"` // Assistant sharing scope (private/team) - Mentionable bool `json:"mentionable,omitempty"` // Whether this assistant is mentionable - Automated bool `json:"automated,omitempty"` // Whether this assistant is automated - Options map[string]interface{} `json:"options,omitempty"` // AI Options - Prompts []Prompt `json:"prompts,omitempty"` // AI Prompts (default prompts) - PromptPresets map[string][]Prompt `json:"prompt_presets,omitempty"` // Prompt presets organized by mode (e.g., "chat", "task", etc.) - DisableGlobalPrompts bool `json:"disable_global_prompts,omitempty"` // Whether to disable global prompts, default is false - KB *KnowledgeBase `json:"kb,omitempty"` // Knowledge base configuration - DB *Database `json:"db,omitempty"` // Database configuration - MCP *MCPServers `json:"mcp,omitempty"` // MCP servers configuration - Workflow *Workflow `json:"workflow,omitempty"` // Workflow configuration - Sandbox *Sandbox `json:"sandbox,omitempty"` // Sandbox configuration for coding agents - Placeholder *Placeholder `json:"placeholder,omitempty"` // Assistant Placeholder - Source string `json:"source,omitempty"` // Hook script source code - Locales i18n.Map `json:"locales,omitempty"` // Assistant Locales - Uses *context.Uses `json:"uses,omitempty"` // Assistant-specific wrapper configurations for vision, audio, etc. If not set, use global settings - Search *searchTypes.Config `json:"search,omitempty"` // Search configuration (web, kb, db, citation, weights, etc.) - Dependencies map[string]string `json:"dependencies,omitempty"` // Dependencies on other MCP Clients (name -> version constraint) - CreatedAt int64 `json:"created_at"` // Creation timestamp - UpdatedAt int64 `json:"updated_at"` // Last update timestamp + ID string `json:"assistant_id"` // Assistant ID + Type string `json:"type,omitempty"` // Assistant Type, default is assistant + Name string `json:"name,omitempty"` // Assistant Name + Avatar string `json:"avatar,omitempty"` // Assistant Avatar + Connector string `json:"connector"` // AI Connector (default connector) + ConnectorOptions *ConnectorOptions `json:"connector_options,omitempty"` // Connector selection options for user to choose from + Path string `json:"path,omitempty"` // Assistant Path + BuiltIn bool `json:"built_in,omitempty"` // Whether this is a built-in assistant + Sort int `json:"sort,omitempty"` // Assistant Sort + Description string `json:"description,omitempty"` // Assistant Description + Capabilities string `json:"capabilities,omitempty"` // Assistant capabilities description (useful for Robot orchestration) + Tags []string `json:"tags,omitempty"` // Assistant Tags + Modes []string `json:"modes,omitempty"` // Supported modes (e.g., ["task", "chat"]), null means all modes are supported + DefaultMode string `json:"default_mode,omitempty"` // Default mode, can be empty + Readonly bool `json:"readonly,omitempty"` // Whether this assistant is readonly + Public bool `json:"public,omitempty"` // Whether this assistant is shared across all teams in the platform + Share string `json:"share,omitempty"` // Assistant sharing scope (private/team) + Mentionable bool `json:"mentionable,omitempty"` // Whether this assistant is mentionable + Automated bool `json:"automated,omitempty"` // Whether this assistant is automated + Options map[string]interface{} `json:"options,omitempty"` // AI Options + Prompts []Prompt `json:"prompts,omitempty"` // AI Prompts (default prompts) + PromptPresets map[string][]Prompt `json:"prompt_presets,omitempty"` // Prompt presets organized by mode (e.g., "chat", "task", etc.) + DisableGlobalPrompts bool `json:"disable_global_prompts,omitempty"` // Whether to disable global prompts, default is false + KB *KnowledgeBase `json:"kb,omitempty"` // Knowledge base configuration + DB *Database `json:"db,omitempty"` // Database configuration + MCP *MCPServers `json:"mcp,omitempty"` // MCP servers configuration + Workflow *Workflow `json:"workflow,omitempty"` // Workflow configuration + Sandbox *Sandbox `json:"sandbox,omitempty"` // Sandbox configuration for coding agents (V1) + SandboxV2 *sandboxTypes.SandboxConfig `json:"-"` // V2 sandbox configuration (runtime only, not persisted in DB) + ConfigHash string `json:"-"` // V2 sandbox config fingerprint for hot-reload + Placeholder *Placeholder `json:"placeholder,omitempty"` // Assistant Placeholder + Source string `json:"source,omitempty"` // Hook script source code + Locales i18n.Map `json:"locales,omitempty"` // Assistant Locales + Uses *context.Uses `json:"uses,omitempty"` // Assistant-specific wrapper configurations for vision, audio, etc. If not set, use global settings + Search *searchTypes.Config `json:"search,omitempty"` // Search configuration (web, kb, db, citation, weights, etc.) + Dependencies map[string]string `json:"dependencies,omitempty"` // Dependencies on other MCP Clients (name -> version constraint) + CreatedAt int64 `json:"created_at"` // Creation timestamp + UpdatedAt int64 `json:"updated_at"` // Last update timestamp // Permission management fields (not exposed in JSON API responses) YaoCreatedBy string `json:"-"` // User who created the assistant (not exposed in JSON) diff --git a/sandbox/v2/box.go b/sandbox/v2/box.go index 67d9999d..5cf4223e 100644 --- a/sandbox/v2/box.go +++ b/sandbox/v2/box.go @@ -29,6 +29,7 @@ type Box struct { vnc bool image string workspaceID string + system SystemInfo ws workspace.FS manager *Manager } @@ -46,6 +47,7 @@ func (b *Box) ComputerInfo() ComputerInfo { return ComputerInfo{ Kind: "box", NodeID: b.nodeID, + System: b.system, Status: "online", BoxID: b.id, ContainerID: b.containerID, @@ -98,10 +100,6 @@ func (b *Box) Exec(ctx context.Context, cmd []string, opts ...ExecOption) (*Exec Stderr: result.Stderr, } - if b.policy == OneShot { - b.manager.Remove(ctx, b.id) - } - return r, nil } diff --git a/sandbox/v2/host.go b/sandbox/v2/host.go index 7a8a6df3..125388c3 100644 --- a/sandbox/v2/host.go +++ b/sandbox/v2/host.go @@ -18,6 +18,7 @@ import ( type Host struct { nodeID string workplaceID string + system SystemInfo manager *Manager } @@ -31,6 +32,7 @@ func (h *Host) ComputerInfo() ComputerInfo { return ComputerInfo{ Kind: "host", NodeID: h.nodeID, + System: h.system, Status: "online", } } diff --git a/sandbox/v2/manager.go b/sandbox/v2/manager.go index 1cdfc2cc..1c9659eb 100644 --- a/sandbox/v2/manager.go +++ b/sandbox/v2/manager.go @@ -98,7 +98,20 @@ func (m *Manager) Host(_ context.Context, nodeID string) (*Host, error) { return nil, fmt.Errorf("sandbox: node %q has no host_exec capability", nodeID) } - return &Host{nodeID: nodeID, manager: m}, nil + var sys SystemInfo + if snap, ok := tai.GetNodeSnapshot(nodeID); ok { + sys = SystemInfo{ + OS: snap.System.OS, + Arch: snap.System.Arch, + Hostname: snap.System.Hostname, + NumCPU: snap.System.NumCPU, + TotalMem: snap.System.TotalMem, + Shell: snap.System.Shell, + TempDir: snap.System.TempDir, + } + } + + return &Host{nodeID: nodeID, system: sys, manager: m}, nil } // Create creates and starts a new sandbox. @@ -113,9 +126,33 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error) if wsm := workspace.M(); wsm != nil { node, err := wsm.NodeForWorkspace(ctx, opts.WorkspaceID) if err != nil { - return nil, fmt.Errorf("sandbox: resolve workspace %q: %w", opts.WorkspaceID, err) + targetNode := nodeID + if targetNode == "" { + if nodes := wsm.Nodes(); len(nodes) > 0 { + for _, n := range nodes { + if n.Online { + targetNode = n.Name + break + } + } + } + } + if targetNode == "" { + return nil, fmt.Errorf("sandbox: resolve workspace %q: no available node", opts.WorkspaceID) + } + _, err = wsm.Create(ctx, workspace.CreateOptions{ + ID: opts.WorkspaceID, + Name: opts.WorkspaceID, + Owner: opts.Owner, + Node: targetNode, + }) + if err != nil { + return nil, fmt.Errorf("sandbox: auto-create workspace %q: %w", opts.WorkspaceID, err) + } + nodeID = targetNode + } else { + nodeID = node } - nodeID = node } } @@ -154,6 +191,19 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error) policy = Session } + var sys SystemInfo + if snap, ok := tai.GetNodeSnapshot(nodeID); ok { + sys = SystemInfo{ + OS: snap.System.OS, + Arch: snap.System.Arch, + Hostname: snap.System.Hostname, + NumCPU: snap.System.NumCPU, + TotalMem: snap.System.TotalMem, + Shell: snap.System.Shell, + TempDir: snap.System.TempDir, + } + } + box := &Box{ id: id, containerID: containerID, @@ -169,6 +219,7 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error) vnc: opts.VNC, image: opts.Image, workspaceID: opts.WorkspaceID, + system: sys, } box.lastCall.Store(time.Now().UnixMilli()) diff --git a/sandbox/v2/types.go b/sandbox/v2/types.go index e5c5bca1..1d4a26d4 100644 --- a/sandbox/v2/types.go +++ b/sandbox/v2/types.go @@ -45,13 +45,15 @@ type ComputerInfo struct { Labels map[string]string } -// SystemInfo describes the hardware of a Tai node. +// SystemInfo describes the hardware and environment of a Tai node. type SystemInfo struct { OS string Arch string Hostname string NumCPU int TotalMem int64 + Shell string // preferred shell: "sh", "pwsh", "powershell", "cmd.exe" + TempDir string // system temp directory } // --------------------------------------------------------------------------- diff --git a/tai/registry/registry.go b/tai/registry/registry.go index 857db710..52115787 100644 --- a/tai/registry/registry.go +++ b/tai/registry/registry.go @@ -22,6 +22,8 @@ type SystemInfo struct { Hostname string `json:"hostname"` NumCPU int `json:"num_cpu"` TotalMem int64 `json:"total_mem,omitempty"` + Shell string `json:"shell,omitempty"` + TempDir string `json:"temp_dir,omitempty"` } // TaiNode represents a registered Tai instance (direct or tunnel). diff --git a/tai/serverinfo/pb/serverinfo.pb.go b/tai/serverinfo/pb/serverinfo.pb.go index 5d85f88a..ac804da4 100644 --- a/tai/serverinfo/pb/serverinfo.pb.go +++ b/tai/serverinfo/pb/serverinfo.pb.go @@ -2,7 +2,7 @@ // versions: // protoc-gen-go v1.36.11 // protoc v4.25.0 -// source: tai/serverinfo/pb/serverinfo.proto +// source: serverinfo.proto package pb @@ -29,7 +29,7 @@ type GetInfoRequest struct { func (x *GetInfoRequest) Reset() { *x = GetInfoRequest{} - mi := &file_tai_serverinfo_pb_serverinfo_proto_msgTypes[0] + mi := &file_serverinfo_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -41,7 +41,7 @@ func (x *GetInfoRequest) String() string { func (*GetInfoRequest) ProtoMessage() {} func (x *GetInfoRequest) ProtoReflect() protoreflect.Message { - mi := &file_tai_serverinfo_pb_serverinfo_proto_msgTypes[0] + mi := &file_serverinfo_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -54,7 +54,99 @@ func (x *GetInfoRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetInfoRequest.ProtoReflect.Descriptor instead. func (*GetInfoRequest) Descriptor() ([]byte, []int) { - return file_tai_serverinfo_pb_serverinfo_proto_rawDescGZIP(), []int{0} + return file_serverinfo_proto_rawDescGZIP(), []int{0} +} + +type SystemInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Os string `protobuf:"bytes,1,opt,name=os,proto3" json:"os,omitempty"` + Arch string `protobuf:"bytes,2,opt,name=arch,proto3" json:"arch,omitempty"` + Hostname string `protobuf:"bytes,3,opt,name=hostname,proto3" json:"hostname,omitempty"` + NumCpu int32 `protobuf:"varint,4,opt,name=num_cpu,json=numCpu,proto3" json:"num_cpu,omitempty"` + TotalMem int64 `protobuf:"varint,5,opt,name=total_mem,json=totalMem,proto3" json:"total_mem,omitempty"` + Shell string `protobuf:"bytes,6,opt,name=shell,proto3" json:"shell,omitempty"` // preferred shell: "sh", "pwsh", "powershell", "cmd.exe" + TempDir string `protobuf:"bytes,7,opt,name=temp_dir,json=tempDir,proto3" json:"temp_dir,omitempty"` // system temp directory + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SystemInfo) Reset() { + *x = SystemInfo{} + mi := &file_serverinfo_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SystemInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SystemInfo) ProtoMessage() {} + +func (x *SystemInfo) ProtoReflect() protoreflect.Message { + mi := &file_serverinfo_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SystemInfo.ProtoReflect.Descriptor instead. +func (*SystemInfo) Descriptor() ([]byte, []int) { + return file_serverinfo_proto_rawDescGZIP(), []int{1} +} + +func (x *SystemInfo) GetOs() string { + if x != nil { + return x.Os + } + return "" +} + +func (x *SystemInfo) GetArch() string { + if x != nil { + return x.Arch + } + return "" +} + +func (x *SystemInfo) GetHostname() string { + if x != nil { + return x.Hostname + } + return "" +} + +func (x *SystemInfo) GetNumCpu() int32 { + if x != nil { + return x.NumCpu + } + return 0 +} + +func (x *SystemInfo) GetTotalMem() int64 { + if x != nil { + return x.TotalMem + } + return 0 +} + +func (x *SystemInfo) GetShell() string { + if x != nil { + return x.Shell + } + return "" +} + +func (x *SystemInfo) GetTempDir() string { + if x != nil { + return x.TempDir + } + return "" } type GetInfoResponse struct { @@ -62,13 +154,14 @@ type GetInfoResponse struct { Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` Ports map[string]int32 `protobuf:"bytes,2,rep,name=ports,proto3" json:"ports,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` // "grpc", "http", "vnc", "docker", "k8s" Capabilities map[string]bool `protobuf:"bytes,3,rep,name=capabilities,proto3" json:"capabilities,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` // "docker", "k8s" + System *SystemInfo `protobuf:"bytes,4,opt,name=system,proto3" json:"system,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetInfoResponse) Reset() { *x = GetInfoResponse{} - mi := &file_tai_serverinfo_pb_serverinfo_proto_msgTypes[1] + mi := &file_serverinfo_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -80,7 +173,7 @@ func (x *GetInfoResponse) String() string { func (*GetInfoResponse) ProtoMessage() {} func (x *GetInfoResponse) ProtoReflect() protoreflect.Message { - mi := &file_tai_serverinfo_pb_serverinfo_proto_msgTypes[1] + mi := &file_serverinfo_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -93,7 +186,7 @@ func (x *GetInfoResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetInfoResponse.ProtoReflect.Descriptor instead. func (*GetInfoResponse) Descriptor() ([]byte, []int) { - return file_tai_serverinfo_pb_serverinfo_proto_rawDescGZIP(), []int{1} + return file_serverinfo_proto_rawDescGZIP(), []int{2} } func (x *GetInfoResponse) GetVersion() string { @@ -117,17 +210,34 @@ func (x *GetInfoResponse) GetCapabilities() map[string]bool { return nil } -var File_tai_serverinfo_pb_serverinfo_proto protoreflect.FileDescriptor +func (x *GetInfoResponse) GetSystem() *SystemInfo { + if x != nil { + return x.System + } + return nil +} -const file_tai_serverinfo_pb_serverinfo_proto_rawDesc = "" + +var File_serverinfo_proto protoreflect.FileDescriptor + +const file_serverinfo_proto_rawDesc = "" + "\n" + - "\"tai/serverinfo/pb/serverinfo.proto\x12\n" + + "\x10serverinfo.proto\x12\n" + "serverinfo\"\x10\n" + - "\x0eGetInfoRequest\"\xb7\x02\n" + + "\x0eGetInfoRequest\"\xb3\x01\n" + + "\n" + + "SystemInfo\x12\x0e\n" + + "\x02os\x18\x01 \x01(\tR\x02os\x12\x12\n" + + "\x04arch\x18\x02 \x01(\tR\x04arch\x12\x1a\n" + + "\bhostname\x18\x03 \x01(\tR\bhostname\x12\x17\n" + + "\anum_cpu\x18\x04 \x01(\x05R\x06numCpu\x12\x1b\n" + + "\ttotal_mem\x18\x05 \x01(\x03R\btotalMem\x12\x14\n" + + "\x05shell\x18\x06 \x01(\tR\x05shell\x12\x19\n" + + "\btemp_dir\x18\a \x01(\tR\atempDir\"\xe7\x02\n" + "\x0fGetInfoResponse\x12\x18\n" + "\aversion\x18\x01 \x01(\tR\aversion\x12<\n" + "\x05ports\x18\x02 \x03(\v2&.serverinfo.GetInfoResponse.PortsEntryR\x05ports\x12Q\n" + - "\fcapabilities\x18\x03 \x03(\v2-.serverinfo.GetInfoResponse.CapabilitiesEntryR\fcapabilities\x1a8\n" + + "\fcapabilities\x18\x03 \x03(\v2-.serverinfo.GetInfoResponse.CapabilitiesEntryR\fcapabilities\x12.\n" + + "\x06system\x18\x04 \x01(\v2\x16.serverinfo.SystemInfoR\x06system\x1a8\n" + "\n" + "PortsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + @@ -140,56 +250,58 @@ const file_tai_serverinfo_pb_serverinfo_proto_rawDesc = "" + "\aGetInfo\x12\x1a.serverinfo.GetInfoRequest\x1a\x1b.serverinfo.GetInfoResponseB%Z#github.com/yaoapp/tai/serverinfo/pbb\x06proto3" var ( - file_tai_serverinfo_pb_serverinfo_proto_rawDescOnce sync.Once - file_tai_serverinfo_pb_serverinfo_proto_rawDescData []byte + file_serverinfo_proto_rawDescOnce sync.Once + file_serverinfo_proto_rawDescData []byte ) -func file_tai_serverinfo_pb_serverinfo_proto_rawDescGZIP() []byte { - file_tai_serverinfo_pb_serverinfo_proto_rawDescOnce.Do(func() { - file_tai_serverinfo_pb_serverinfo_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_tai_serverinfo_pb_serverinfo_proto_rawDesc), len(file_tai_serverinfo_pb_serverinfo_proto_rawDesc))) +func file_serverinfo_proto_rawDescGZIP() []byte { + file_serverinfo_proto_rawDescOnce.Do(func() { + file_serverinfo_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_serverinfo_proto_rawDesc), len(file_serverinfo_proto_rawDesc))) }) - return file_tai_serverinfo_pb_serverinfo_proto_rawDescData + return file_serverinfo_proto_rawDescData } -var file_tai_serverinfo_pb_serverinfo_proto_msgTypes = make([]protoimpl.MessageInfo, 4) -var file_tai_serverinfo_pb_serverinfo_proto_goTypes = []any{ +var file_serverinfo_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_serverinfo_proto_goTypes = []any{ (*GetInfoRequest)(nil), // 0: serverinfo.GetInfoRequest - (*GetInfoResponse)(nil), // 1: serverinfo.GetInfoResponse - nil, // 2: serverinfo.GetInfoResponse.PortsEntry - nil, // 3: serverinfo.GetInfoResponse.CapabilitiesEntry + (*SystemInfo)(nil), // 1: serverinfo.SystemInfo + (*GetInfoResponse)(nil), // 2: serverinfo.GetInfoResponse + nil, // 3: serverinfo.GetInfoResponse.PortsEntry + nil, // 4: serverinfo.GetInfoResponse.CapabilitiesEntry } -var file_tai_serverinfo_pb_serverinfo_proto_depIdxs = []int32{ - 2, // 0: serverinfo.GetInfoResponse.ports:type_name -> serverinfo.GetInfoResponse.PortsEntry - 3, // 1: serverinfo.GetInfoResponse.capabilities:type_name -> serverinfo.GetInfoResponse.CapabilitiesEntry - 0, // 2: serverinfo.ServerInfo.GetInfo:input_type -> serverinfo.GetInfoRequest - 1, // 3: serverinfo.ServerInfo.GetInfo:output_type -> serverinfo.GetInfoResponse - 3, // [3:4] is the sub-list for method output_type - 2, // [2:3] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name +var file_serverinfo_proto_depIdxs = []int32{ + 3, // 0: serverinfo.GetInfoResponse.ports:type_name -> serverinfo.GetInfoResponse.PortsEntry + 4, // 1: serverinfo.GetInfoResponse.capabilities:type_name -> serverinfo.GetInfoResponse.CapabilitiesEntry + 1, // 2: serverinfo.GetInfoResponse.system:type_name -> serverinfo.SystemInfo + 0, // 3: serverinfo.ServerInfo.GetInfo:input_type -> serverinfo.GetInfoRequest + 2, // 4: serverinfo.ServerInfo.GetInfo:output_type -> serverinfo.GetInfoResponse + 4, // [4:5] is the sub-list for method output_type + 3, // [3:4] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name } -func init() { file_tai_serverinfo_pb_serverinfo_proto_init() } -func file_tai_serverinfo_pb_serverinfo_proto_init() { - if File_tai_serverinfo_pb_serverinfo_proto != nil { +func init() { file_serverinfo_proto_init() } +func file_serverinfo_proto_init() { + if File_serverinfo_proto != nil { return } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_tai_serverinfo_pb_serverinfo_proto_rawDesc), len(file_tai_serverinfo_pb_serverinfo_proto_rawDesc)), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_serverinfo_proto_rawDesc), len(file_serverinfo_proto_rawDesc)), NumEnums: 0, - NumMessages: 4, + NumMessages: 5, NumExtensions: 0, NumServices: 1, }, - GoTypes: file_tai_serverinfo_pb_serverinfo_proto_goTypes, - DependencyIndexes: file_tai_serverinfo_pb_serverinfo_proto_depIdxs, - MessageInfos: file_tai_serverinfo_pb_serverinfo_proto_msgTypes, + GoTypes: file_serverinfo_proto_goTypes, + DependencyIndexes: file_serverinfo_proto_depIdxs, + MessageInfos: file_serverinfo_proto_msgTypes, }.Build() - File_tai_serverinfo_pb_serverinfo_proto = out.File - file_tai_serverinfo_pb_serverinfo_proto_goTypes = nil - file_tai_serverinfo_pb_serverinfo_proto_depIdxs = nil + File_serverinfo_proto = out.File + file_serverinfo_proto_goTypes = nil + file_serverinfo_proto_depIdxs = nil } diff --git a/tai/serverinfo/pb/serverinfo.proto b/tai/serverinfo/pb/serverinfo.proto index 4eece2ee..844f1881 100644 --- a/tai/serverinfo/pb/serverinfo.proto +++ b/tai/serverinfo/pb/serverinfo.proto @@ -8,8 +8,19 @@ service ServerInfo { message GetInfoRequest {} +message SystemInfo { + string os = 1; + string arch = 2; + string hostname = 3; + int32 num_cpu = 4; + int64 total_mem = 5; + string shell = 6; // preferred shell: "sh", "pwsh", "powershell", "cmd.exe" + string temp_dir = 7; // system temp directory +} + message GetInfoResponse { string version = 1; - map ports = 2; // "grpc", "http", "vnc", "docker", "k8s" + map ports = 2; // "grpc", "http", "vnc", "docker", "k8s" map capabilities = 3; // "docker", "k8s" + SystemInfo system = 4; } diff --git a/tai/serverinfo/pb/serverinfo_grpc.pb.go b/tai/serverinfo/pb/serverinfo_grpc.pb.go index 07e690ec..173e1fa0 100644 --- a/tai/serverinfo/pb/serverinfo_grpc.pb.go +++ b/tai/serverinfo/pb/serverinfo_grpc.pb.go @@ -2,7 +2,7 @@ // versions: // - protoc-gen-go-grpc v1.6.1 // - protoc v4.25.0 -// source: tai/serverinfo/pb/serverinfo.proto +// source: serverinfo.proto package pb @@ -117,5 +117,5 @@ var ServerInfo_ServiceDesc = grpc.ServiceDesc{ }, }, Streams: []grpc.StreamDesc{}, - Metadata: "tai/serverinfo/pb/serverinfo.proto", + Metadata: "serverinfo.proto", } diff --git a/tai/tai.go b/tai/tai.go index 7a1d3bd9..a2663a13 100644 --- a/tai/tai.go +++ b/tai/tai.go @@ -239,15 +239,14 @@ func (c *Client) initRemote(cfg *config) (*Client, error) { c.grpcConn = conn c.he = hepb.NewHostExecClient(conn) - caps, err := c.discoverServerInfo(conn, cfg) + info, err := c.discoverServerInfo(conn, cfg) if err != nil { - // Old Tai without ServerInfo — fall back to legacy behaviour (try Docker). - caps = map[string]bool{"docker": true} + info = &discoveredInfo{Capabilities: map[string]bool{"docker": true}} } - hasDocker := caps["docker"] - hasK8s := caps["k8s"] - hasHostExec := caps["host_exec"] + hasDocker := info.Capabilities["docker"] + hasK8s := info.Capabilities["k8s"] + hasHostExec := info.Capabilities["host_exec"] if !hasDocker && !hasK8s && !hasHostExec { conn.Close() @@ -297,9 +296,12 @@ func (c *Client) initRemote(cfg *config) (*Client, error) { id := fmt.Sprintf("%s-%d", c.host, c.ports.GRPC) c.taiID = id reg.Register(®istry.TaiNode{ - TaiID: id, - Mode: "direct", - Addr: fmt.Sprintf("tai://%s:%d", c.host, c.ports.GRPC), + TaiID: id, + Mode: "direct", + Version: info.Version, + System: info.System, + Capabilities: info.Capabilities, + Addr: fmt.Sprintf("tai://%s:%d", c.host, c.ports.GRPC), Ports: map[string]int{ "grpc": c.ports.GRPC, "http": c.ports.HTTP, @@ -351,13 +353,13 @@ func (c *Client) initTunnel(cfg *config) (*Client, error) { c.he = hepb.NewHostExecClient(conn) c.vol = volume.NewRemote(conn) - caps, err := c.discoverServerInfo(conn, cfg) + info, err := c.discoverServerInfo(conn, cfg) if err != nil { - caps = map[string]bool{"docker": true} + info = &discoveredInfo{Capabilities: map[string]bool{"docker": true}} } - hasDocker := caps["docker"] - hasHostExec := caps["host_exec"] + hasDocker := info.Capabilities["docker"] + hasHostExec := info.Capabilities["host_exec"] if !hasDocker && !hasHostExec { c.closeTunnelListeners() @@ -544,10 +546,16 @@ func isLocalHost(h string) bool { return h == "127.0.0.1" || h == "localhost" || h == "::1" } +type discoveredInfo struct { + Capabilities map[string]bool + System registry.SystemInfo + Version string +} + // discoverServerInfo calls ServerInfo.GetInfo on the remote Tai server, merges -// discovered ports into c.ports, and returns the server's capabilities map. +// discovered ports into c.ports, and returns capabilities + system info. // Ports explicitly set via WithPorts take precedence over server-reported values. -func (c *Client) discoverServerInfo(conn *grpc.ClientConn, cfg *config) (map[string]bool, error) { +func (c *Client) discoverServerInfo(conn *grpc.ClientConn, cfg *config) (*discoveredInfo, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -576,7 +584,25 @@ func (c *Client) discoverServerInfo(conn *grpc.ClientConn, cfg *config) (map[str if caps == nil { caps = make(map[string]bool) } - return caps, nil + + var sys registry.SystemInfo + if s := resp.System; s != nil { + sys = registry.SystemInfo{ + OS: s.Os, + Arch: s.Arch, + Hostname: s.Hostname, + NumCPU: int(s.NumCpu), + TotalMem: s.TotalMem, + Shell: s.Shell, + TempDir: s.TempDir, + } + } + + return &discoveredInfo{ + Capabilities: caps, + System: sys, + Version: resp.Version, + }, nil } // RegisterLocal probes the local Docker environment and, if reachable, @@ -616,3 +642,13 @@ func GetClient(taiID string) (*Client, bool) { } return c, true } + +// GetNodeSnapshot returns the registry snapshot for a Tai node by ID. +// Callers can inspect System, Capabilities, Mode and other registry-level fields. +func GetNodeSnapshot(taiID string) (*registry.NodeSnapshot, bool) { + reg := registry.Global() + if reg == nil { + return nil, false + } + return reg.Get(taiID) +} diff --git a/tai/volume/local.go b/tai/volume/local.go index 53410dec..11ca1e11 100644 --- a/tai/volume/local.go +++ b/tai/volume/local.go @@ -138,14 +138,125 @@ func (l *localStorage) MkdirAll(_ context.Context, sessionID, path string) error return os.MkdirAll(abs, 0o755) } +// Copy duplicates src to dst within the same workspace session. +// Supports single files and directories (recursive). Uses excludes from SyncOption +// and forceFull to overwrite even when mtime+size match. +func (l *localStorage) Copy(_ context.Context, sessionID, src, dst string, opts ...SyncOption) (*SyncResult, error) { + start := time.Now() + cfg := ApplySyncOpts(opts) + + srcAbs, err := l.abs(sessionID, src) + if err != nil { + return nil, err + } + dstAbs, err := l.abs(sessionID, dst) + if err != nil { + return nil, err + } + + srcInfo, err := os.Stat(srcAbs) + if err != nil { + return nil, err + } + + if !srcInfo.IsDir() { + n, err := l.copyFile(srcAbs, dstAbs, srcInfo, cfg.ForceFull) + if err != nil { + return nil, err + } + synced := 0 + if n > 0 { + synced = 1 + } + return &SyncResult{ + FilesSynced: synced, + BytesTransferred: n, + Duration: time.Since(start), + }, nil + } + + var synced int + var transferred int64 + err = filepath.WalkDir(srcAbs, func(abs string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + if os.IsNotExist(walkErr) { + return nil + } + return walkErr + } + rel, _ := filepath.Rel(srcAbs, abs) + if rel == "." { + return os.MkdirAll(dstAbs, 0o755) + } + + if isExcluded(rel, d.IsDir(), cfg.Excludes) { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + + target := filepath.Join(dstAbs, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + + info, err := d.Info() + if err != nil { + return nil + } + n, err := l.copyFile(abs, target, info, cfg.ForceFull) + if err != nil { + return err + } + if n > 0 { + synced++ + transferred += n + } + return nil + }) + + return &SyncResult{ + FilesSynced: synced, + BytesTransferred: transferred, + Duration: time.Since(start), + }, err +} + +func (l *localStorage) copyFile(srcAbs, dstAbs string, srcInfo os.FileInfo, force bool) (int64, error) { + if !force { + if dstInfo, e := os.Stat(dstAbs); e == nil { + if dstInfo.Size() == srcInfo.Size() && dstInfo.ModTime().Equal(srcInfo.ModTime()) { + return 0, nil + } + } + } + + data, err := os.ReadFile(srcAbs) + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, err + } + if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil { + return 0, err + } + if err := os.WriteFile(dstAbs, data, srcInfo.Mode()); err != nil { + return 0, err + } + _ = os.Chtimes(dstAbs, srcInfo.ModTime(), srcInfo.ModTime()) + return int64(len(data)), nil +} + // SyncPush copies changed files from localDir to dataDir/{sessionID}/. // Uses mtime+size to detect changes. Files that vanish during sync are skipped. func (l *localStorage) SyncPush(_ context.Context, sessionID, localDir string, opts ...SyncOption) (*SyncResult, error) { start := time.Now() - cfg := applySyncOpts(opts) + cfg := ApplySyncOpts(opts) dst := l.root(sessionID) - if cfg.remotePath != "" { - dst = filepath.Join(dst, filepath.Clean(cfg.remotePath)) + if cfg.RemotePath != "" { + dst = filepath.Join(dst, filepath.Clean(cfg.RemotePath)) } if err := os.MkdirAll(dst, 0o755); err != nil { return nil, err @@ -167,7 +278,7 @@ func (l *localStorage) SyncPush(_ context.Context, sessionID, localDir string, o } rel = filepath.ToSlash(rel) - if isExcluded(rel, d.IsDir(), cfg.excludes) { + if isExcluded(rel, d.IsDir(), cfg.Excludes) { if d.IsDir() { return filepath.SkipDir } @@ -184,7 +295,7 @@ func (l *localStorage) SyncPush(_ context.Context, sessionID, localDir string, o return nil // file vanished between readdir and stat; skip } - if !cfg.forceFull { + if !cfg.ForceFull { if dstInfo, e := os.Stat(target); e == nil { if dstInfo.Size() == srcInfo.Size() && dstInfo.ModTime().Equal(srcInfo.ModTime()) { return nil @@ -222,10 +333,10 @@ func (l *localStorage) SyncPush(_ context.Context, sessionID, localDir string, o // Files that vanish during sync are skipped. func (l *localStorage) SyncPull(_ context.Context, sessionID, localDir string, opts ...SyncOption) (*SyncResult, error) { start := time.Now() - cfg := applySyncOpts(opts) + cfg := ApplySyncOpts(opts) src := l.root(sessionID) - if cfg.remotePath != "" { - src = filepath.Join(src, filepath.Clean(cfg.remotePath)) + if cfg.RemotePath != "" { + src = filepath.Join(src, filepath.Clean(cfg.RemotePath)) } if err := os.MkdirAll(localDir, 0o755); err != nil { return nil, err @@ -247,7 +358,7 @@ func (l *localStorage) SyncPull(_ context.Context, sessionID, localDir string, o } rel = filepath.ToSlash(rel) - if isExcluded(rel, d.IsDir(), cfg.excludes) { + if isExcluded(rel, d.IsDir(), cfg.Excludes) { if d.IsDir() { return filepath.SkipDir } @@ -264,7 +375,7 @@ func (l *localStorage) SyncPull(_ context.Context, sessionID, localDir string, o return nil // file vanished between readdir and stat; skip } - if !cfg.forceFull { + if !cfg.ForceFull { if dstInfo, e := os.Stat(target); e == nil { if dstInfo.Size() == srcInfo.Size() && dstInfo.ModTime().Equal(srcInfo.ModTime()) { return nil diff --git a/tai/volume/mock_test.go b/tai/volume/mock_test.go index 59f0ce06..bdee3352 100644 --- a/tai/volume/mock_test.go +++ b/tai/volume/mock_test.go @@ -197,6 +197,13 @@ func (m *mockVolumeServer) ListDir(_ context.Context, req *pb.FSRequest) (*pb.FS }}, nil } +func (m *mockVolumeServer) Copy(_ context.Context, req *pb.FSCopyRequest) (*pb.SyncResult, error) { + return &pb.SyncResult{ + FilesSynced: 1, + BytesTransferred: 42, + }, nil +} + func startMockServer(t *testing.T, mock *mockVolumeServer) (*grpc.ClientConn, func()) { t.Helper() lis, err := net.Listen("tcp", "127.0.0.1:0") @@ -549,6 +556,10 @@ func (m *errMockVolumeServer) MkdirAll(_ context.Context, _ *pb.FSRequest) (*pb. return nil, fmt.Errorf("injected mkdir error") } +func (m *errMockVolumeServer) Copy(_ context.Context, _ *pb.FSCopyRequest) (*pb.SyncResult, error) { + return nil, fmt.Errorf("injected copy error") +} + func startErrMockServer(t *testing.T) (*grpc.ClientConn, func()) { t.Helper() lis, err := net.Listen("tcp", "127.0.0.1:0") @@ -694,3 +705,46 @@ func TestPbToFileInfo(t *testing.T) { t.Errorf("mode = %v", fi.Mode) } } + +func TestMockRemoteCopy(t *testing.T) { + conn, cleanup := startMockServer(t, &mockVolumeServer{}) + defer cleanup() + + vol := NewRemote(conn) + result, err := vol.Copy(context.Background(), "s1", "src.txt", "dst.txt") + if err != nil { + t.Fatalf("Copy: %v", err) + } + if result.FilesSynced != 1 { + t.Errorf("synced = %d, want 1", result.FilesSynced) + } + if result.BytesTransferred != 42 { + t.Errorf("bytes = %d, want 42", result.BytesTransferred) + } +} + +func TestMockRemoteCopyWithOpts(t *testing.T) { + conn, cleanup := startMockServer(t, &mockVolumeServer{}) + defer cleanup() + + vol := NewRemote(conn) + result, err := vol.Copy(context.Background(), "s1", "src", "dst", + WithExcludes("*.log"), WithForceFull()) + if err != nil { + t.Fatalf("Copy: %v", err) + } + if result.FilesSynced != 1 { + t.Errorf("synced = %d", result.FilesSynced) + } +} + +func TestErrRemoteCopy(t *testing.T) { + conn, cleanup := startErrMockServer(t) + defer cleanup() + + vol := NewRemote(conn) + _, err := vol.Copy(context.Background(), "s1", "a", "b") + if err == nil { + t.Error("expected error") + } +} diff --git a/tai/volume/pb/volume.pb.go b/tai/volume/pb/volume.pb.go index 1437faf6..96e97bd8 100644 --- a/tai/volume/pb/volume.pb.go +++ b/tai/volume/pb/volume.pb.go @@ -1035,6 +1035,82 @@ func (x *FSRenameRequest) GetNewPath() string { return "" } +type FSCopyRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + SrcPath string `protobuf:"bytes,2,opt,name=src_path,json=srcPath,proto3" json:"src_path,omitempty"` + DstPath string `protobuf:"bytes,3,opt,name=dst_path,json=dstPath,proto3" json:"dst_path,omitempty"` + Excludes []string `protobuf:"bytes,4,rep,name=excludes,proto3" json:"excludes,omitempty"` // glob patterns + Force bool `protobuf:"varint,5,opt,name=force,proto3" json:"force,omitempty"` // overwrite even if mtime/size match + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FSCopyRequest) Reset() { + *x = FSCopyRequest{} + mi := &file_tai_volume_pb_volume_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FSCopyRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FSCopyRequest) ProtoMessage() {} + +func (x *FSCopyRequest) ProtoReflect() protoreflect.Message { + mi := &file_tai_volume_pb_volume_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FSCopyRequest.ProtoReflect.Descriptor instead. +func (*FSCopyRequest) Descriptor() ([]byte, []int) { + return file_tai_volume_pb_volume_proto_rawDescGZIP(), []int{15} +} + +func (x *FSCopyRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *FSCopyRequest) GetSrcPath() string { + if x != nil { + return x.SrcPath + } + return "" +} + +func (x *FSCopyRequest) GetDstPath() string { + if x != nil { + return x.DstPath + } + return "" +} + +func (x *FSCopyRequest) GetExcludes() []string { + if x != nil { + return x.Excludes + } + return nil +} + +func (x *FSCopyRequest) GetForce() bool { + if x != nil { + return x.Force + } + return false +} + type ArchiveRequest struct { state protoimpl.MessageState `protogen:"open.v1"` SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` @@ -1047,7 +1123,7 @@ type ArchiveRequest struct { func (x *ArchiveRequest) Reset() { *x = ArchiveRequest{} - mi := &file_tai_volume_pb_volume_proto_msgTypes[15] + mi := &file_tai_volume_pb_volume_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1059,7 +1135,7 @@ func (x *ArchiveRequest) String() string { func (*ArchiveRequest) ProtoMessage() {} func (x *ArchiveRequest) ProtoReflect() protoreflect.Message { - mi := &file_tai_volume_pb_volume_proto_msgTypes[15] + mi := &file_tai_volume_pb_volume_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1072,7 +1148,7 @@ func (x *ArchiveRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ArchiveRequest.ProtoReflect.Descriptor instead. func (*ArchiveRequest) Descriptor() ([]byte, []int) { - return file_tai_volume_pb_volume_proto_rawDescGZIP(), []int{15} + return file_tai_volume_pb_volume_proto_rawDescGZIP(), []int{16} } func (x *ArchiveRequest) GetSessionId() string { @@ -1113,7 +1189,7 @@ type ArchiveResponse struct { func (x *ArchiveResponse) Reset() { *x = ArchiveResponse{} - mi := &file_tai_volume_pb_volume_proto_msgTypes[16] + mi := &file_tai_volume_pb_volume_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1125,7 +1201,7 @@ func (x *ArchiveResponse) String() string { func (*ArchiveResponse) ProtoMessage() {} func (x *ArchiveResponse) ProtoReflect() protoreflect.Message { - mi := &file_tai_volume_pb_volume_proto_msgTypes[16] + mi := &file_tai_volume_pb_volume_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1138,7 +1214,7 @@ func (x *ArchiveResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ArchiveResponse.ProtoReflect.Descriptor instead. func (*ArchiveResponse) Descriptor() ([]byte, []int) { - return file_tai_volume_pb_volume_proto_rawDescGZIP(), []int{16} + return file_tai_volume_pb_volume_proto_rawDescGZIP(), []int{17} } func (x *ArchiveResponse) GetSizeBytes() int64 { @@ -1240,7 +1316,14 @@ const file_tai_volume_pb_volume_proto_rawDesc = "" + "\n" + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x19\n" + "\bold_path\x18\x02 \x01(\tR\aoldPath\x12\x19\n" + - "\bnew_path\x18\x03 \x01(\tR\anewPath\"\x81\x01\n" + + "\bnew_path\x18\x03 \x01(\tR\anewPath\"\x96\x01\n" + + "\rFSCopyRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x19\n" + + "\bsrc_path\x18\x02 \x01(\tR\asrcPath\x12\x19\n" + + "\bdst_path\x18\x03 \x01(\tR\adstPath\x12\x1a\n" + + "\bexcludes\x18\x04 \x03(\tR\bexcludes\x12\x14\n" + + "\x05force\x18\x05 \x01(\bR\x05force\"\x81\x01\n" + "\x0eArchiveRequest\x12\x1d\n" + "\n" + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x19\n" + @@ -1251,7 +1334,7 @@ const file_tai_volume_pb_volume_proto_rawDesc = "" + "\n" + "size_bytes\x18\x01 \x01(\x03R\tsizeBytes\x12\x1f\n" + "\vfiles_count\x18\x02 \x01(\x05R\n" + - "filesCount2\xc7\a\n" + + "filesCount2\xfa\a\n" + "\x06Volume\x128\n" + "\bSyncPush\x12\x13.volume.SyncMessage\x1a\x13.volume.SyncMessage(\x010\x01\x127\n" + "\bSyncPull\x12\x14.volume.SyncManifest\x1a\x13.volume.SyncMessage0\x01\x128\n" + @@ -1261,7 +1344,8 @@ const file_tai_volume_pb_volume_proto_rawDesc = "" + "\aListDir\x12\x11.volume.FSRequest\x1a\x16.volume.FSListResponse\x127\n" + "\x06Remove\x12\x17.volume.FSRemoveRequest\x1a\x14.volume.FSOpResponse\x127\n" + "\x06Rename\x12\x17.volume.FSRenameRequest\x1a\x14.volume.FSOpResponse\x123\n" + - "\bMkdirAll\x12\x11.volume.FSRequest\x1a\x14.volume.FSOpResponse\x126\n" + + "\bMkdirAll\x12\x11.volume.FSRequest\x1a\x14.volume.FSOpResponse\x121\n" + + "\x04Copy\x12\x15.volume.FSCopyRequest\x1a\x12.volume.SyncResult\x126\n" + "\x03Zip\x12\x16.volume.ArchiveRequest\x1a\x17.volume.ArchiveResponse\x128\n" + "\x05Unzip\x12\x16.volume.ArchiveRequest\x1a\x17.volume.ArchiveResponse\x127\n" + "\x04Gzip\x12\x16.volume.ArchiveRequest\x1a\x17.volume.ArchiveResponse\x129\n" + @@ -1284,7 +1368,7 @@ func file_tai_volume_pb_volume_proto_rawDescGZIP() []byte { } var file_tai_volume_pb_volume_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_tai_volume_pb_volume_proto_msgTypes = make([]protoimpl.MessageInfo, 17) +var file_tai_volume_pb_volume_proto_msgTypes = make([]protoimpl.MessageInfo, 18) var file_tai_volume_pb_volume_proto_goTypes = []any{ (FileChunk_ChunkType)(0), // 0: volume.FileChunk.ChunkType (*FileInfo)(nil), // 1: volume.FileInfo @@ -1302,8 +1386,9 @@ var file_tai_volume_pb_volume_proto_goTypes = []any{ (*FSListResponse)(nil), // 13: volume.FSListResponse (*FSRemoveRequest)(nil), // 14: volume.FSRemoveRequest (*FSRenameRequest)(nil), // 15: volume.FSRenameRequest - (*ArchiveRequest)(nil), // 16: volume.ArchiveRequest - (*ArchiveResponse)(nil), // 17: volume.ArchiveResponse + (*FSCopyRequest)(nil), // 16: volume.FSCopyRequest + (*ArchiveRequest)(nil), // 17: volume.ArchiveRequest + (*ArchiveResponse)(nil), // 18: volume.ArchiveResponse } var file_tai_volume_pb_volume_proto_depIdxs = []int32{ 1, // 0: volume.SyncManifest.files:type_name -> volume.FileInfo @@ -1322,33 +1407,35 @@ var file_tai_volume_pb_volume_proto_depIdxs = []int32{ 14, // 13: volume.Volume.Remove:input_type -> volume.FSRemoveRequest 15, // 14: volume.Volume.Rename:input_type -> volume.FSRenameRequest 7, // 15: volume.Volume.MkdirAll:input_type -> volume.FSRequest - 16, // 16: volume.Volume.Zip:input_type -> volume.ArchiveRequest - 16, // 17: volume.Volume.Unzip:input_type -> volume.ArchiveRequest - 16, // 18: volume.Volume.Gzip:input_type -> volume.ArchiveRequest - 16, // 19: volume.Volume.Gunzip:input_type -> volume.ArchiveRequest - 16, // 20: volume.Volume.Tar:input_type -> volume.ArchiveRequest - 16, // 21: volume.Volume.Untar:input_type -> volume.ArchiveRequest - 16, // 22: volume.Volume.Tgz:input_type -> volume.ArchiveRequest - 16, // 23: volume.Volume.Untgz:input_type -> volume.ArchiveRequest - 3, // 24: volume.Volume.SyncPush:output_type -> volume.SyncMessage - 3, // 25: volume.Volume.SyncPull:output_type -> volume.SyncMessage - 10, // 26: volume.Volume.ReadFile:output_type -> volume.FSDataChunk - 12, // 27: volume.Volume.WriteFile:output_type -> volume.FSWriteResponse - 1, // 28: volume.Volume.Stat:output_type -> volume.FileInfo - 13, // 29: volume.Volume.ListDir:output_type -> volume.FSListResponse - 8, // 30: volume.Volume.Remove:output_type -> volume.FSOpResponse - 8, // 31: volume.Volume.Rename:output_type -> volume.FSOpResponse - 8, // 32: volume.Volume.MkdirAll:output_type -> volume.FSOpResponse - 17, // 33: volume.Volume.Zip:output_type -> volume.ArchiveResponse - 17, // 34: volume.Volume.Unzip:output_type -> volume.ArchiveResponse - 17, // 35: volume.Volume.Gzip:output_type -> volume.ArchiveResponse - 17, // 36: volume.Volume.Gunzip:output_type -> volume.ArchiveResponse - 17, // 37: volume.Volume.Tar:output_type -> volume.ArchiveResponse - 17, // 38: volume.Volume.Untar:output_type -> volume.ArchiveResponse - 17, // 39: volume.Volume.Tgz:output_type -> volume.ArchiveResponse - 17, // 40: volume.Volume.Untgz:output_type -> volume.ArchiveResponse - 24, // [24:41] is the sub-list for method output_type - 7, // [7:24] is the sub-list for method input_type + 16, // 16: volume.Volume.Copy:input_type -> volume.FSCopyRequest + 17, // 17: volume.Volume.Zip:input_type -> volume.ArchiveRequest + 17, // 18: volume.Volume.Unzip:input_type -> volume.ArchiveRequest + 17, // 19: volume.Volume.Gzip:input_type -> volume.ArchiveRequest + 17, // 20: volume.Volume.Gunzip:input_type -> volume.ArchiveRequest + 17, // 21: volume.Volume.Tar:input_type -> volume.ArchiveRequest + 17, // 22: volume.Volume.Untar:input_type -> volume.ArchiveRequest + 17, // 23: volume.Volume.Tgz:input_type -> volume.ArchiveRequest + 17, // 24: volume.Volume.Untgz:input_type -> volume.ArchiveRequest + 3, // 25: volume.Volume.SyncPush:output_type -> volume.SyncMessage + 3, // 26: volume.Volume.SyncPull:output_type -> volume.SyncMessage + 10, // 27: volume.Volume.ReadFile:output_type -> volume.FSDataChunk + 12, // 28: volume.Volume.WriteFile:output_type -> volume.FSWriteResponse + 1, // 29: volume.Volume.Stat:output_type -> volume.FileInfo + 13, // 30: volume.Volume.ListDir:output_type -> volume.FSListResponse + 8, // 31: volume.Volume.Remove:output_type -> volume.FSOpResponse + 8, // 32: volume.Volume.Rename:output_type -> volume.FSOpResponse + 8, // 33: volume.Volume.MkdirAll:output_type -> volume.FSOpResponse + 6, // 34: volume.Volume.Copy:output_type -> volume.SyncResult + 18, // 35: volume.Volume.Zip:output_type -> volume.ArchiveResponse + 18, // 36: volume.Volume.Unzip:output_type -> volume.ArchiveResponse + 18, // 37: volume.Volume.Gzip:output_type -> volume.ArchiveResponse + 18, // 38: volume.Volume.Gunzip:output_type -> volume.ArchiveResponse + 18, // 39: volume.Volume.Tar:output_type -> volume.ArchiveResponse + 18, // 40: volume.Volume.Untar:output_type -> volume.ArchiveResponse + 18, // 41: volume.Volume.Tgz:output_type -> volume.ArchiveResponse + 18, // 42: volume.Volume.Untgz:output_type -> volume.ArchiveResponse + 25, // [25:43] is the sub-list for method output_type + 7, // [7:25] is the sub-list for method input_type 7, // [7:7] is the sub-list for extension type_name 7, // [7:7] is the sub-list for extension extendee 0, // [0:7] is the sub-list for field type_name @@ -1371,7 +1458,7 @@ func file_tai_volume_pb_volume_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_tai_volume_pb_volume_proto_rawDesc), len(file_tai_volume_pb_volume_proto_rawDesc)), NumEnums: 1, - NumMessages: 17, + NumMessages: 18, NumExtensions: 0, NumServices: 1, }, diff --git a/tai/volume/pb/volume.proto b/tai/volume/pb/volume.proto index 464b4d1b..5fa38279 100644 --- a/tai/volume/pb/volume.proto +++ b/tai/volume/pb/volume.proto @@ -30,6 +30,8 @@ service Volume { rpc Remove(FSRemoveRequest) returns (FSOpResponse); rpc Rename(FSRenameRequest) returns (FSOpResponse); rpc MkdirAll(FSRequest) returns (FSOpResponse); + // Copy: copy src to dst within the same workspace (server-side when remote). + rpc Copy(FSCopyRequest) returns (SyncResult); // --- Archive / Compression --- @@ -150,6 +152,14 @@ message FSRenameRequest { string new_path = 3; } +message FSCopyRequest { + string session_id = 1; + string src_path = 2; + string dst_path = 3; + repeated string excludes = 4; // glob patterns + bool force = 5; // overwrite even if mtime/size match +} + // --- Archive / Compression Messages --- message ArchiveRequest { diff --git a/tai/volume/pb/volume_grpc.pb.go b/tai/volume/pb/volume_grpc.pb.go index b2706be5..a5370fe3 100644 --- a/tai/volume/pb/volume_grpc.pb.go +++ b/tai/volume/pb/volume_grpc.pb.go @@ -28,6 +28,7 @@ const ( Volume_Remove_FullMethodName = "/volume.Volume/Remove" Volume_Rename_FullMethodName = "/volume.Volume/Rename" Volume_MkdirAll_FullMethodName = "/volume.Volume/MkdirAll" + Volume_Copy_FullMethodName = "/volume.Volume/Copy" Volume_Zip_FullMethodName = "/volume.Volume/Zip" Volume_Unzip_FullMethodName = "/volume.Volume/Unzip" Volume_Gzip_FullMethodName = "/volume.Volume/Gzip" @@ -63,6 +64,8 @@ type VolumeClient interface { Remove(ctx context.Context, in *FSRemoveRequest, opts ...grpc.CallOption) (*FSOpResponse, error) Rename(ctx context.Context, in *FSRenameRequest, opts ...grpc.CallOption) (*FSOpResponse, error) MkdirAll(ctx context.Context, in *FSRequest, opts ...grpc.CallOption) (*FSOpResponse, error) + // Copy: copy src to dst within the same workspace (server-side when remote). + Copy(ctx context.Context, in *FSCopyRequest, opts ...grpc.CallOption) (*SyncResult, error) Zip(ctx context.Context, in *ArchiveRequest, opts ...grpc.CallOption) (*ArchiveResponse, error) Unzip(ctx context.Context, in *ArchiveRequest, opts ...grpc.CallOption) (*ArchiveResponse, error) Gzip(ctx context.Context, in *ArchiveRequest, opts ...grpc.CallOption) (*ArchiveResponse, error) @@ -195,6 +198,16 @@ func (c *volumeClient) MkdirAll(ctx context.Context, in *FSRequest, opts ...grpc return out, nil } +func (c *volumeClient) Copy(ctx context.Context, in *FSCopyRequest, opts ...grpc.CallOption) (*SyncResult, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SyncResult) + err := c.cc.Invoke(ctx, Volume_Copy_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *volumeClient) Zip(ctx context.Context, in *ArchiveRequest, opts ...grpc.CallOption) (*ArchiveResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ArchiveResponse) @@ -300,6 +313,8 @@ type VolumeServer interface { Remove(context.Context, *FSRemoveRequest) (*FSOpResponse, error) Rename(context.Context, *FSRenameRequest) (*FSOpResponse, error) MkdirAll(context.Context, *FSRequest) (*FSOpResponse, error) + // Copy: copy src to dst within the same workspace (server-side when remote). + Copy(context.Context, *FSCopyRequest) (*SyncResult, error) Zip(context.Context, *ArchiveRequest) (*ArchiveResponse, error) Unzip(context.Context, *ArchiveRequest) (*ArchiveResponse, error) Gzip(context.Context, *ArchiveRequest) (*ArchiveResponse, error) @@ -345,6 +360,9 @@ func (UnimplementedVolumeServer) Rename(context.Context, *FSRenameRequest) (*FSO func (UnimplementedVolumeServer) MkdirAll(context.Context, *FSRequest) (*FSOpResponse, error) { return nil, status.Error(codes.Unimplemented, "method MkdirAll not implemented") } +func (UnimplementedVolumeServer) Copy(context.Context, *FSCopyRequest) (*SyncResult, error) { + return nil, status.Error(codes.Unimplemented, "method Copy not implemented") +} func (UnimplementedVolumeServer) Zip(context.Context, *ArchiveRequest) (*ArchiveResponse, error) { return nil, status.Error(codes.Unimplemented, "method Zip not implemented") } @@ -516,6 +534,24 @@ func _Volume_MkdirAll_Handler(srv interface{}, ctx context.Context, dec func(int return interceptor(ctx, in, info, handler) } +func _Volume_Copy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FSCopyRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VolumeServer).Copy(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Volume_Copy_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VolumeServer).Copy(ctx, req.(*FSCopyRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _Volume_Zip_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ArchiveRequest) if err := dec(in); err != nil { @@ -687,6 +723,10 @@ var Volume_ServiceDesc = grpc.ServiceDesc{ MethodName: "MkdirAll", Handler: _Volume_MkdirAll_Handler, }, + { + MethodName: "Copy", + Handler: _Volume_Copy_Handler, + }, { MethodName: "Zip", Handler: _Volume_Zip_Handler, diff --git a/tai/volume/remote.go b/tai/volume/remote.go index cc41f805..e7019e66 100644 --- a/tai/volume/remote.go +++ b/tai/volume/remote.go @@ -170,7 +170,7 @@ func (r *remoteStorage) MkdirAll(ctx context.Context, sessionID, path string) er // SyncPush sends local files to Tai using the manifest-first bidi streaming protocol. func (r *remoteStorage) SyncPush(ctx context.Context, sessionID, localDir string, opts ...SyncOption) (*SyncResult, error) { start := time.Now() - cfg := applySyncOpts(opts) + cfg := ApplySyncOpts(opts) // Scan local directory var manifest []*pb.FileInfo @@ -183,7 +183,7 @@ func (r *remoteStorage) SyncPush(ctx context.Context, sessionID, localDir string return nil } rel = filepath.ToSlash(rel) - if isExcluded(rel, d.IsDir(), cfg.excludes) { + if isExcluded(rel, d.IsDir(), cfg.Excludes) { if d.IsDir() { return filepath.SkipDir } @@ -217,8 +217,8 @@ func (r *remoteStorage) SyncPush(ctx context.Context, sessionID, localDir string Manifest: &pb.SyncManifest{ SessionId: sessionID, Files: manifest, - ForceFull: cfg.forceFull, - RemotePath: cfg.remotePath, + ForceFull: cfg.ForceFull, + RemotePath: cfg.RemotePath, }, }, }); err != nil { @@ -310,7 +310,7 @@ func (r *remoteStorage) SyncPush(ctx context.Context, sessionID, localDir string // SyncPull receives changed files from Tai. func (r *remoteStorage) SyncPull(ctx context.Context, sessionID, localDir string, opts ...SyncOption) (*SyncResult, error) { start := time.Now() - cfg := applySyncOpts(opts) + cfg := ApplySyncOpts(opts) // Build local manifest var manifest []*pb.FileInfo @@ -323,7 +323,7 @@ func (r *remoteStorage) SyncPull(ctx context.Context, sessionID, localDir string return nil } rel = filepath.ToSlash(rel) - if isExcluded(rel, d.IsDir(), cfg.excludes) { + if isExcluded(rel, d.IsDir(), cfg.Excludes) { if d.IsDir() { return filepath.SkipDir } @@ -346,8 +346,8 @@ func (r *remoteStorage) SyncPull(ctx context.Context, sessionID, localDir string stream, err := r.client.SyncPull(ctx, &pb.SyncManifest{ SessionId: sessionID, Files: manifest, - ForceFull: cfg.forceFull, - RemotePath: cfg.remotePath, + ForceFull: cfg.ForceFull, + RemotePath: cfg.RemotePath, }) if err != nil { return nil, err @@ -514,6 +514,27 @@ func (r *remoteStorage) Untgz(ctx context.Context, sessionID, src, dst string) ( return &ArchiveResult{SizeBytes: resp.SizeBytes, FilesCount: int(resp.FilesCount)}, nil } +func (r *remoteStorage) Copy(ctx context.Context, sessionID, src, dst string, opts ...SyncOption) (*SyncResult, error) { + start := time.Now() + cfg := ApplySyncOpts(opts) + + resp, err := r.client.Copy(ctx, &pb.FSCopyRequest{ + SessionId: sessionID, + SrcPath: src, + DstPath: dst, + Excludes: cfg.Excludes, + Force: cfg.ForceFull, + }) + if err != nil { + return nil, err + } + return &SyncResult{ + FilesSynced: int(resp.FilesSynced), + BytesTransferred: resp.BytesTransferred, + Duration: time.Since(start), + }, nil +} + func (r *remoteStorage) Close() error { return nil } diff --git a/tai/volume/volume.go b/tai/volume/volume.go index be74a0c0..e8498c33 100644 --- a/tai/volume/volume.go +++ b/tai/volume/volume.go @@ -20,6 +20,7 @@ type Volume interface { SyncPush(ctx context.Context, sessionID, localDir string, opts ...SyncOption) (*SyncResult, error) SyncPull(ctx context.Context, sessionID, localDir string, opts ...SyncOption) (*SyncResult, error) + Copy(ctx context.Context, sessionID, src, dst string, opts ...SyncOption) (*SyncResult, error) Zip(ctx context.Context, sessionID, src, dst string, excludes []string) (*ArchiveResult, error) Unzip(ctx context.Context, sessionID, src, dst string) (*ArchiveResult, error) @@ -56,31 +57,33 @@ type ArchiveResult struct { } // SyncOption configures sync behavior. -type SyncOption func(*syncConfig) +type SyncOption func(*SyncConfig) -type syncConfig struct { - forceFull bool - excludes []string - remotePath string +// SyncConfig holds resolved sync options. +type SyncConfig struct { + ForceFull bool + Excludes []string + RemotePath string } // WithForceFull skips snapshot caches and diffs against actual disk. func WithForceFull() SyncOption { - return func(c *syncConfig) { c.forceFull = true } + return func(c *SyncConfig) { c.ForceFull = true } } // WithExcludes adds glob patterns to exclude from sync. func WithExcludes(patterns ...string) SyncOption { - return func(c *syncConfig) { c.excludes = append(c.excludes, patterns...) } + return func(c *SyncConfig) { c.Excludes = append(c.Excludes, patterns...) } } // WithRemotePath sets a sub-path within the workspace root for sync operations. func WithRemotePath(path string) SyncOption { - return func(c *syncConfig) { c.remotePath = path } + return func(c *SyncConfig) { c.RemotePath = path } } -func applySyncOpts(opts []SyncOption) syncConfig { - var cfg syncConfig +// ApplySyncOpts resolves a slice of SyncOption into a SyncConfig. +func ApplySyncOpts(opts []SyncOption) SyncConfig { + var cfg SyncConfig for _, o := range opts { o(&cfg) } diff --git a/tai/volume/volume_test.go b/tai/volume/volume_test.go index 79ef4b9b..eae954a8 100644 --- a/tai/volume/volume_test.go +++ b/tai/volume/volume_test.go @@ -1286,3 +1286,244 @@ func TestLocalSyncExcludes(t *testing.T) { t.Error("excluded file should not exist") } } + +func TestLocalCopyFile(t *testing.T) { + dir := t.TempDir() + vol := NewLocal(dir) + defer vol.Close() + ctx := context.Background() + sid := "copy-file" + + _ = vol.WriteFile(ctx, sid, "src.txt", []byte("hello copy"), 0o644) + + result, err := vol.Copy(ctx, sid, "src.txt", "dst.txt") + if err != nil { + t.Fatalf("Copy file: %v", err) + } + if result.FilesSynced != 1 { + t.Errorf("synced = %d, want 1", result.FilesSynced) + } + if result.BytesTransferred != 10 { + t.Errorf("bytes = %d, want 10", result.BytesTransferred) + } + + data, _, err := vol.ReadFile(ctx, sid, "dst.txt") + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(data) != "hello copy" { + t.Errorf("content = %q", data) + } +} + +func TestLocalCopyDir(t *testing.T) { + dir := t.TempDir() + vol := NewLocal(dir) + defer vol.Close() + ctx := context.Background() + sid := "copy-dir" + + _ = vol.MkdirAll(ctx, sid, "src/sub") + _ = vol.WriteFile(ctx, sid, "src/a.txt", []byte("aaa"), 0o644) + _ = vol.WriteFile(ctx, sid, "src/sub/b.txt", []byte("bbb"), 0o644) + + result, err := vol.Copy(ctx, sid, "src", "dst") + if err != nil { + t.Fatalf("Copy dir: %v", err) + } + if result.FilesSynced != 2 { + t.Errorf("synced = %d, want 2", result.FilesSynced) + } + + data, _, err := vol.ReadFile(ctx, sid, "dst/a.txt") + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(data) != "aaa" { + t.Errorf("content = %q", data) + } + + data, _, err = vol.ReadFile(ctx, sid, "dst/sub/b.txt") + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(data) != "bbb" { + t.Errorf("content = %q", data) + } +} + +func TestLocalCopyExcludes(t *testing.T) { + dir := t.TempDir() + vol := NewLocal(dir) + defer vol.Close() + ctx := context.Background() + sid := "copy-excl" + + _ = vol.MkdirAll(ctx, sid, "src") + _ = vol.WriteFile(ctx, sid, "src/keep.txt", []byte("keep"), 0o644) + _ = vol.WriteFile(ctx, sid, "src/skip.log", []byte("skip"), 0o644) + + result, err := vol.Copy(ctx, sid, "src", "dst", WithExcludes("*.log")) + if err != nil { + t.Fatalf("Copy: %v", err) + } + if result.FilesSynced != 1 { + t.Errorf("synced = %d, want 1", result.FilesSynced) + } + + _, err = vol.Stat(ctx, sid, "dst/keep.txt") + if err != nil { + t.Error("keep.txt should exist") + } + _, err = vol.Stat(ctx, sid, "dst/skip.log") + if !os.IsNotExist(err) { + t.Error("skip.log should not exist") + } +} + +func TestLocalCopySkipsUnchanged(t *testing.T) { + dir := t.TempDir() + vol := NewLocal(dir) + defer vol.Close() + ctx := context.Background() + sid := "copy-skip" + + _ = vol.WriteFile(ctx, sid, "src.txt", []byte("data"), 0o644) + + result1, err := vol.Copy(ctx, sid, "src.txt", "dst.txt", WithForceFull()) + if err != nil { + t.Fatalf("Copy 1: %v", err) + } + if result1.FilesSynced != 1 { + t.Errorf("first copy synced = %d, want 1", result1.FilesSynced) + } + + result2, err := vol.Copy(ctx, sid, "src.txt", "dst.txt") + if err != nil { + t.Fatalf("Copy 2: %v", err) + } + if result2.FilesSynced != 0 { + t.Errorf("second copy synced = %d, want 0 (unchanged)", result2.FilesSynced) + } +} + +func TestLocalCopyForceFull(t *testing.T) { + dir := t.TempDir() + vol := NewLocal(dir) + defer vol.Close() + ctx := context.Background() + sid := "copy-force" + + _ = vol.WriteFile(ctx, sid, "src.txt", []byte("data"), 0o644) + + _, _ = vol.Copy(ctx, sid, "src.txt", "dst.txt", WithForceFull()) + + result, err := vol.Copy(ctx, sid, "src.txt", "dst.txt", WithForceFull()) + if err != nil { + t.Fatalf("Copy: %v", err) + } + if result.FilesSynced != 1 { + t.Errorf("force copy synced = %d, want 1", result.FilesSynced) + } +} + +func TestLocalCopyNotExist(t *testing.T) { + dir := t.TempDir() + vol := NewLocal(dir) + ctx := context.Background() + + _, err := vol.Copy(ctx, "test", "nonexistent", "dst") + if err == nil { + t.Error("expected error for copy nonexistent source") + } +} + +func TestLocalCopyPathTraversal(t *testing.T) { + dir := t.TempDir() + vol := NewLocal(dir) + ctx := context.Background() + + _, err := vol.Copy(ctx, "test", "../../etc/passwd", "dst") + if err == nil { + t.Error("expected error for path traversal in src") + } + + _, err = vol.Copy(ctx, "test", "src", "../../etc/evil") + if err == nil { + t.Error("expected error for path traversal in dst") + } +} + +func TestRemoteCopy(t *testing.T) { + addr := taiTestGRPC() + conn, err := grpc.NewClient(addr, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Skipf("gRPC dial %s: %v", addr, err) + } + defer conn.Close() + + vol := NewRemote(conn) + defer vol.Close() + ctx := context.Background() + sid := "copy-remote-test" + + _ = vol.WriteFile(ctx, sid, "src.txt", []byte("remote copy"), 0o644) + + result, err := vol.Copy(ctx, sid, "src.txt", "dst.txt", WithForceFull()) + if err != nil { + t.Fatalf("Copy: %v", err) + } + if result.FilesSynced < 1 { + t.Errorf("synced = %d", result.FilesSynced) + } + + data, _, err := vol.ReadFile(ctx, sid, "dst.txt") + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(data) != "remote copy" { + t.Errorf("content = %q", data) + } + + _ = vol.Remove(ctx, sid, ".", true) +} + +func TestRemoteCopyDir(t *testing.T) { + addr := taiTestGRPC() + conn, err := grpc.NewClient(addr, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Skipf("gRPC dial %s: %v", addr, err) + } + defer conn.Close() + + vol := NewRemote(conn) + defer vol.Close() + ctx := context.Background() + sid := "copy-remote-dir" + + _ = vol.MkdirAll(ctx, sid, "src/sub") + _ = vol.WriteFile(ctx, sid, "src/a.txt", []byte("aaa"), 0o644) + _ = vol.WriteFile(ctx, sid, "src/sub/b.txt", []byte("bbb"), 0o644) + + result, err := vol.Copy(ctx, sid, "src", "dst", WithForceFull()) + if err != nil { + t.Fatalf("Copy: %v", err) + } + if result.FilesSynced < 2 { + t.Errorf("synced = %d", result.FilesSynced) + } + + data, _, err := vol.ReadFile(ctx, sid, "dst/sub/b.txt") + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(data) != "bbb" { + t.Errorf("content = %q", data) + } + + _ = vol.Remove(ctx, sid, ".", true) +} diff --git a/tai/workspace/copy.go b/tai/workspace/copy.go new file mode 100644 index 00000000..516e2cbc --- /dev/null +++ b/tai/workspace/copy.go @@ -0,0 +1,158 @@ +package workspace + +import ( + "context" + "io/fs" + "os" + "path/filepath" + + "github.com/yaoapp/yao/tai/volume" +) + +// Copy implements the FS.Copy method with 4-way dispatch: +// +// ws -> ws : Volume.Copy (server-side for remote, local copy for local) +// host -> ws : Volume.SyncPush +// ws -> host : Volume.SyncPull +// host -> host : os-level recursive copy +func (w *workspaceFS) Copy(src, dst string, opts ...volume.SyncOption) (*volume.SyncResult, error) { + srcURI := parseHostURI(src) + dstURI := parseHostURI(dst) + ctx := context.Background() + + switch { + case !srcURI.IsHost && !dstURI.IsHost: + return w.vol.Copy(ctx, w.session, srcURI.Path, dstURI.Path, opts...) + + case srcURI.IsHost && !dstURI.IsHost: + hostPath, err := resolveAbsHostPath(srcURI) + if err != nil { + return nil, err + } + info, err := os.Stat(hostPath) + if err != nil { + return nil, err + } + if !info.IsDir() { + return nil, w.pushSingleFile(ctx, hostPath, dstURI.Path, info) + } + pushOpts := append(sliceClone(opts), volume.WithRemotePath(dstURI.Path)) + return w.vol.SyncPush(ctx, w.session, hostPath, pushOpts...) + + case !srcURI.IsHost && dstURI.IsHost: + hostPath, err := resolveAbsHostPath(dstURI) + if err != nil { + return nil, err + } + srcInfo, statErr := w.vol.Stat(ctx, w.session, srcURI.Path) + if statErr != nil { + return nil, statErr + } + if !srcInfo.IsDir { + return nil, w.pullSingleFile(ctx, srcURI.Path, hostPath) + } + pullOpts := append(sliceClone(opts), volume.WithRemotePath(srcURI.Path)) + return w.vol.SyncPull(ctx, w.session, hostPath, pullOpts...) + + default: + srcPath, err := resolveAbsHostPath(srcURI) + if err != nil { + return nil, err + } + dstPath, err := resolveAbsHostPath(dstURI) + if err != nil { + return nil, err + } + cfg := volume.ApplySyncOpts(opts) + return nil, copyLocalToLocal(srcPath, dstPath, cfg.Excludes) + } +} + +// pushSingleFile reads a host file and writes it into the workspace at dstPath. +func (w *workspaceFS) pushSingleFile(ctx context.Context, hostPath, dstPath string, info os.FileInfo) error { + data, err := os.ReadFile(hostPath) + if err != nil { + return err + } + dir := filepath.Dir(dstPath) + if dir != "" && dir != "." { + if err := w.vol.MkdirAll(ctx, w.session, dir); err != nil { + return err + } + } + perm := info.Mode() + if perm == 0 { + perm = 0o644 + } + return w.vol.WriteFile(ctx, w.session, dstPath, data, perm) +} + +// pullSingleFile reads a workspace file and writes it to hostPath. +func (w *workspaceFS) pullSingleFile(ctx context.Context, srcPath, hostPath string) error { + data, perm, err := w.vol.ReadFile(ctx, w.session, srcPath) + if err != nil { + return err + } + if perm == 0 { + perm = 0o644 + } + if err := os.MkdirAll(filepath.Dir(hostPath), 0o755); err != nil { + return err + } + return os.WriteFile(hostPath, data, perm) +} + +func copyLocalToLocal(src, dst string, excludes []string) error { + info, err := os.Stat(src) + if err != nil { + return err + } + if !info.IsDir() { + data, err := os.ReadFile(src) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + return os.WriteFile(dst, data, info.Mode()) + } + + return filepath.WalkDir(src, func(abs string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(src, abs) + if rel == "." { + return os.MkdirAll(dst, 0o755) + } + for _, p := range excludes { + if matched, _ := filepath.Match(p, filepath.Base(rel)); matched { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + } + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + data, err := os.ReadFile(abs) + if err != nil { + return err + } + fi, _ := d.Info() + perm := os.FileMode(0o644) + if fi != nil { + perm = fi.Mode() + } + return os.WriteFile(target, data, perm) + }) +} + +func sliceClone(opts []volume.SyncOption) []volume.SyncOption { + cp := make([]volume.SyncOption, len(opts)) + copy(cp, opts) + return cp +} diff --git a/tai/workspace/uri.go b/tai/workspace/uri.go new file mode 100644 index 00000000..e4ed9056 --- /dev/null +++ b/tai/workspace/uri.go @@ -0,0 +1,49 @@ +package workspace + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// hostURI holds the parsed result of a host URI. +type hostURI struct { + Scheme string // "local" or "tmp"; empty for workspace paths + Path string // resolved absolute path (host) or relative path (workspace) + IsHost bool +} + +// parseHostURI extracts scheme and path from a host URI string. +// +// "local:///abs/path" -> {Scheme:"local", Path:"/abs/path", IsHost:true} +// "tmp:///rel/path" -> {Scheme:"tmp", Path:"rel/path", IsHost:true} +// "some/workspace/path" -> {Scheme:"", Path:"some/workspace/path", IsHost:false} +func parseHostURI(raw string) hostURI { + switch { + case strings.HasPrefix(raw, "local:///"): + return hostURI{Scheme: "local", Path: strings.TrimPrefix(raw, "local:///"), IsHost: true} + case strings.HasPrefix(raw, "tmp:///"): + return hostURI{Scheme: "tmp", Path: strings.TrimPrefix(raw, "tmp:///"), IsHost: true} + default: + return hostURI{Path: raw} + } +} + +// resolveAbsHostPath converts a parsed hostURI into an absolute filesystem path. +// For "local" scheme, Path is already absolute (rooted at /). +// For "tmp" scheme, Path is relative to os.TempDir(). +func resolveAbsHostPath(u hostURI) (string, error) { + switch u.Scheme { + case "local": + abs := filepath.Clean("/" + u.Path) + return abs, nil + case "tmp": + if strings.Contains(u.Path, "..") { + return "", fmt.Errorf("path traversal not allowed in tmp:// URI") + } + return filepath.Join(os.TempDir(), u.Path), nil + default: + return "", fmt.Errorf("not a host URI: %q", u.Path) + } +} diff --git a/tai/workspace/workspace.go b/tai/workspace/workspace.go index 6af4e69d..5ff5b518 100644 --- a/tai/workspace/workspace.go +++ b/tai/workspace/workspace.go @@ -25,6 +25,12 @@ type FS interface { RemoveAll(name string) error Rename(oldname, newname string) error MkdirAll(name string, perm os.FileMode) error + + // Copy copies files between workspace paths and/or host paths. + // Host paths use "local:///" (absolute system path) or "tmp:///" (os.TempDir-relative). + // ws↔ws uses Volume.Copy (server-side for remote volumes, avoiding 2N network round-trips). + // Returns non-nil *SyncResult for host↔workspace and ws↔ws transfers; nil for host↔host. + Copy(src, dst string, opts ...volume.SyncOption) (*volume.SyncResult, error) } // New creates an FS backed by the given Volume for the specified session. diff --git a/workspace/jsapi/fs.go b/workspace/jsapi/fs.go index 227f21fe..c82444b9 100644 --- a/workspace/jsapi/fs.go +++ b/workspace/jsapi/fs.go @@ -402,8 +402,6 @@ func copyHandler(info *v8go.FunctionCallbackInfo, wsID string) *v8go.Value { src := args[0].String() dst := args[1].String() - srcIsHost := isHostURI(src) - dstIsHost := isHostURI(dst) var excludes []string force := false @@ -419,12 +417,7 @@ func copyHandler(info *v8go.FunctionCallbackInfo, wsID string) *v8go.Value { } } - vol, sid, err := workspace.M().Volume(ctx, wsID) - if err != nil { - return throwError(info, err.Error()) - } - - opts := []volume.SyncOption{} + var opts []volume.SyncOption if len(excludes) > 0 { opts = append(opts, volume.WithExcludes(excludes...)) } @@ -432,52 +425,22 @@ func copyHandler(info *v8go.FunctionCallbackInfo, wsID string) *v8go.Value { opts = append(opts, volume.WithForceFull()) } - switch { - case !srcIsHost && !dstIsHost: - if err := copyWithinWorkspace(ctx, wsID, src, dst); err != nil { - return throwError(info, err.Error()) - } - return v8go.Undefined(iso) + // Map JSAPI local:// (AppRoot-relative) to Go-layer local:/// (absolute) + src = mapHostURI(src) + dst = mapHostURI(dst) - case srcIsHost && !dstIsHost: - hostPath, err := resolveHostPath(src) - if err != nil { - return throwError(info, err.Error()) - } - opts = append(opts, volume.WithRemotePath(dst)) - result, err := vol.SyncPush(ctx, sid, hostPath, opts...) - if err != nil { - return throwError(info, err.Error()) - } - return syncResultToJS(info, result) - - case !srcIsHost && dstIsHost: - hostPath, err := resolveHostPath(dst) - if err != nil { - return throwError(info, err.Error()) - } - opts = append(opts, volume.WithRemotePath(src)) - result, err := vol.SyncPull(ctx, sid, hostPath, opts...) - if err != nil { - return throwError(info, err.Error()) - } - return syncResultToJS(info, result) - - case srcIsHost && dstIsHost: - srcPath, err := resolveHostPath(src) - if err != nil { - return throwError(info, err.Error()) - } - dstPath, err := resolveHostPath(dst) - if err != nil { - return throwError(info, err.Error()) - } - if err := copyLocalToLocal(srcPath, dstPath, excludes); err != nil { - return throwError(info, err.Error()) - } - return v8go.Undefined(iso) + wsFS, err := workspace.M().FS(ctx, wsID) + if err != nil { + return throwError(info, err.Error()) } + result, err := wsFS.Copy(src, dst, opts...) + if err != nil { + return throwError(info, err.Error()) + } + if result != nil { + return syncResultToJS(info, result) + } return v8go.Undefined(iso) } @@ -491,123 +454,38 @@ func syncResultToJS(info *v8go.FunctionCallbackInfo, r *volume.SyncResult) *v8go return val } -func copyWithinWorkspace(ctx context.Context, wsID, src, dst string) error { - fsys, err := workspace.M().FS(ctx, wsID) - if err != nil { - return err - } - - fi, err := fsys.Stat(src) - if err != nil { - return err - } - - if !fi.IsDir() { - data, err := fsys.ReadFile(src) - if err != nil { - return err - } - return fsys.WriteFile(dst, data, fi.Mode()) - } - - return fs.WalkDir(fsys, src, func(p string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - rel, _ := filepath.Rel(src, p) - target := filepath.Join(dst, rel) - if d.IsDir() { - return fsys.MkdirAll(target, 0o755) - } - data, err := fsys.ReadFile(p) - if err != nil { - return err - } - info, _ := d.Info() - perm := os.FileMode(0o644) - if info != nil { - perm = info.Mode() - } - return fsys.WriteFile(target, data, perm) - }) -} - -func isHostURI(path string) bool { - return strings.HasPrefix(path, "local://") || strings.HasPrefix(path, "tmp://") -} - -func resolveHostPath(rawPath string) (string, error) { - if strings.HasPrefix(rawPath, "tmp://") { - rel := strings.TrimPrefix(rawPath, "tmp://") +// mapHostURI converts JSAPI host URIs to Go-layer absolute URIs. +// local://relative -> local:///{AppSource}/relative (with security checks) +// tmp://relative -> tmp:///relative (Go layer resolves os.TempDir) +// other -> unchanged (workspace-relative path) +func mapHostURI(raw string) string { + switch { + case strings.HasPrefix(raw, "local://"): + rel := strings.TrimPrefix(raw, "local://") if strings.Contains(rel, "..") { - return "", fmt.Errorf("path traversal not allowed") + return raw } - return filepath.Join(os.TempDir(), rel), nil - } - - appRoot := config.Conf.AppSource - rel := strings.TrimPrefix(rawPath, "local://") - if strings.Contains(rel, "..") { - return "", fmt.Errorf("path traversal not allowed") - } - abs := filepath.Join(appRoot, rel) - resolved, err := filepath.EvalSymlinks(abs) - if err != nil { - resolved = abs - } - if !strings.HasPrefix(resolved, appRoot) { - return "", fmt.Errorf("path escapes app root") - } - return resolved, nil -} - -func copyLocalToLocal(src, dst string, excludes []string) error { - info, err := os.Stat(src) - if err != nil { - return err - } - if !info.IsDir() { - data, err := os.ReadFile(src) + appRoot := config.Conf.AppSource + abs := filepath.Join(appRoot, rel) + resolved, err := filepath.EvalSymlinks(abs) if err != nil { - return err + resolved = abs } - if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { - return err + if !strings.HasPrefix(resolved, appRoot) { + return raw } - return os.WriteFile(dst, data, info.Mode()) - } + return "local:///" + resolved - return filepath.WalkDir(src, func(abs string, d fs.DirEntry, err error) error { - if err != nil { - return err + case strings.HasPrefix(raw, "tmp://"): + rel := strings.TrimPrefix(raw, "tmp://") + if strings.Contains(rel, "..") { + return raw } - rel, _ := filepath.Rel(src, abs) - if rel == "." { - return os.MkdirAll(dst, 0o755) - } - for _, p := range excludes { - if matched, _ := filepath.Match(p, filepath.Base(rel)); matched { - if d.IsDir() { - return filepath.SkipDir - } - return nil - } - } - target := filepath.Join(dst, rel) - if d.IsDir() { - return os.MkdirAll(target, 0o755) - } - data, err := os.ReadFile(abs) - if err != nil { - return err - } - fi, _ := d.Info() - perm := os.FileMode(0o644) - if fi != nil { - perm = fi.Mode() - } - return os.WriteFile(target, data, perm) - }) + return "tmp:///" + rel + + default: + return raw + } } func parseStringArray(val *v8go.Value) []string {