diff --git a/agent/assistant/agent.go b/agent/assistant/agent.go index 9e49226b..5e4583a8 100644 --- a/agent/assistant/agent.go +++ b/agent/assistant/agent.go @@ -204,6 +204,15 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa } }() + // ================================================ + // Standalone Workspace Loading (no sandbox required) + // ================================================ + // When no sandbox is configured but the user selected a workspace, + // load the workspace FS into context so hooks can access ctx.workspace. + if !ctx.HasWorkspace() { + ast.initStandaloneWorkspace(ctx) + } + // ================================================ // Execute Create Hook // ================================================ diff --git a/agent/assistant/sandbox_v2.go b/agent/assistant/sandbox_v2.go index 4a58b4cc..a63ba159 100644 --- a/agent/assistant/sandbox_v2.go +++ b/agent/assistant/sandbox_v2.go @@ -2,6 +2,7 @@ package assistant import ( "fmt" + "log" "os" "path/filepath" @@ -13,6 +14,7 @@ import ( "github.com/yaoapp/yao/config" infraV2 "github.com/yaoapp/yao/sandbox/v2" traceTypes "github.com/yaoapp/yao/trace/types" + "github.com/yaoapp/yao/workspace" ) // HasSandboxV2 returns true if the assistant has a V2 sandbox configuration. @@ -167,6 +169,26 @@ func (ast *Assistant) executeSandboxV2Stream( return sandboxv2.ExecuteSandboxStream(ctx, execReq, streamHandler) } +// initStandaloneWorkspace loads the workspace FS into context when no sandbox +// is configured but the user selected a workspace (metadata["workspace_id"]). +func (ast *Assistant) initStandaloneWorkspace(ctx *context.Context) { + if ctx.Metadata == nil { + return + } + wsID, _ := ctx.Metadata["workspace_id"].(string) + if wsID == "" { + return + } + + stdCtx := ctx.Context + wsFS, err := workspace.M().FS(stdCtx, wsID) + if err != nil { + log.Printf("[assistant] initStandaloneWorkspace: failed to load workspace %s: %v", wsID, err) + return + } + ctx.SetWorkspace(wsFS) +} + func closeLoadingV2(ctx *context.Context, loadingMsgID, msgKey string) { if loadingMsgID == "" || ctx == nil { return diff --git a/agent/context/jsapi_computer.go b/agent/context/jsapi_computer.go index ae3c57c1..795fd2db 100644 --- a/agent/context/jsapi_computer.go +++ b/agent/context/jsapi_computer.go @@ -20,6 +20,12 @@ func (ctx *Context) SetComputer(computer infraV2.Computer) { } } +// SetWorkspace sets the workspace FS directly without requiring a Computer. +// Use this when the user selected a workspace but no sandbox is configured. +func (ctx *Context) SetWorkspace(ws workspace.FS) { + ctx.workspace = ws +} + // GetComputer returns the V2 computer if available. func (ctx *Context) GetComputer() infraV2.Computer { return ctx.computer @@ -35,6 +41,11 @@ func (ctx *Context) HasComputer() bool { return ctx.computer != nil } +// HasWorkspace returns true if workspace FS is available. +func (ctx *Context) HasWorkspace() bool { + return ctx.workspace != nil +} + // createComputerInstance creates the ctx.computer JavaScript object. func (ctx *Context) createComputerInstance(v8ctx *v8go.Context) *v8go.Value { if ctx.computer == nil { diff --git a/agent/sandbox/v2/claude/oscompat.go b/agent/sandbox/v2/claude/oscompat.go new file mode 100644 index 00000000..ba0f24d7 --- /dev/null +++ b/agent/sandbox/v2/claude/oscompat.go @@ -0,0 +1,164 @@ +package claude + +import ( + "fmt" + "path" + "strings" + + "github.com/yaoapp/yao/agent/sandbox/v2/types" + infra "github.com/yaoapp/yao/sandbox/v2" +) + +// osEnv captures OS-dependent paths and shell settings derived from the +// Computer's SystemInfo. All runner code should use osEnv instead of +// hardcoded Linux constants. +type osEnv struct { + OS string // "windows", "linux", "darwin", ... + Shell string // preferred shell binary: "bash", "pwsh", "cmd.exe", ... + WorkDir string // working directory on the target machine + UserHome string // user home directory (empty if irrelevant) + TempDir string // system temp directory +} + +func (e *osEnv) isWindows() bool { + return strings.EqualFold(e.OS, "windows") +} + +// resolveOSEnv builds an osEnv from the Computer's reported SystemInfo, +// falling back to SandboxConfig values where available, then to per-OS defaults. +func resolveOSEnv(computer infra.Computer, _ *types.SandboxConfig) *osEnv { + sys := computer.ComputerInfo().System + + env := &osEnv{ + OS: strings.ToLower(sys.OS), + Shell: sys.Shell, + TempDir: sys.TempDir, + WorkDir: computer.GetWorkDir(), + } + + if env.TempDir == "" { + env.TempDir = env.pathJoin(env.WorkDir, ".tmp") + } + + return env +} + +// shellCmd returns the command slice to run a script through the appropriate shell. +func (e *osEnv) shellCmd(script string) []string { + shell := strings.ToLower(e.Shell) + switch shell { + case "pwsh": + return []string{"pwsh", "-NoProfile", "-Command", script} + case "powershell": + return []string{"powershell", "-NoProfile", "-Command", script} + case "cmd.exe", "cmd": + return []string{"cmd.exe", "/C", script} + default: + return []string{"bash", "-c", script} + } +} + +// mkdirCmd returns a shell command string to create a directory (with parents). +func (e *osEnv) mkdirCmd(dir string) string { + if e.isWindows() { + return fmt.Sprintf(`if (!(Test-Path '%s')) { New-Item -ItemType Directory -Path '%s' -Force | Out-Null }`, dir, dir) + } + return fmt.Sprintf("mkdir -p %s", dir) +} + +// listDirCmd returns a command slice to list directory contents. +func (e *osEnv) listDirCmd(dir string) []string { + if e.isWindows() { + return e.shellCmd(fmt.Sprintf("Get-ChildItem -Name '%s'", dir)) + } + return []string{"ls", dir} +} + +// killProcessCmd returns a command slice to kill processes matching a pattern. +func (e *osEnv) killProcessCmd(pattern string) []string { + if e.isWindows() { + script := fmt.Sprintf("Get-Process | Where-Object {$_.ProcessName -like '*%s*'} | Stop-Process -Force -ErrorAction SilentlyContinue", pattern) + return e.shellCmd(script) + } + return []string{"sh", "-c", fmt.Sprintf("pkill -f '%s' || true", pattern)} +} + +// rootDir returns the filesystem root for the target OS. +func (e *osEnv) rootDir() string { + if e.isWindows() { + return `C:\` + } + return "/" +} + +// pathJoin joins path segments using the appropriate separator. +func (e *osEnv) pathJoin(parts ...string) string { + if e.isWindows() { + return strings.Join(parts, `\`) + } + return path.Join(parts...) +} + +// buildCLIScript builds the complete CLI invocation script for the target OS. +// Returns (script, stdin) — on Linux stdin is nil (heredoc handles it), +// on Windows stdin contains inputJSONL bytes to pass via gRPC Stdin. +func (e *osEnv) buildCLIScript(args []string, systemPrompt, inputJSONL string) (string, []byte) { + workDir := e.WorkDir + promptFile := e.pathJoin(workDir, ".yao", ".system-prompt.txt") + + if e.isWindows() { + return e.buildPowerShellScript(args, systemPrompt, inputJSONL, workDir, promptFile) + } + return e.buildBashScript(args, systemPrompt, inputJSONL, workDir, promptFile), nil +} + +func (e *osEnv) buildBashScript(args []string, systemPrompt, inputJSONL, workDir, promptFile string) string { + var b strings.Builder + + if e.UserHome != "" { + b.WriteString(fmt.Sprintf("touch %s/.Xauthority 2>/dev/null; ", e.UserHome)) + } + b.WriteString("touch \"$HOME/.Xauthority\" 2>/dev/null\n") + + if systemPrompt != "" { + b.WriteString(fmt.Sprintf("mkdir -p %s/.yao\n", workDir)) + b.WriteString(fmt.Sprintf("cat << 'PROMPTEOF' > %s\n", promptFile)) + b.WriteString(systemPrompt) + b.WriteString("\nPROMPTEOF\n") + args = append(args, "--append-system-prompt-file", promptFile) + } + + b.WriteString("cat << 'INPUTEOF' | claude -p") + for _, arg := range args { + b.WriteString(fmt.Sprintf(" %q", arg)) + } + b.WriteString(" 2>&1\n") + b.WriteString(inputJSONL) + b.WriteString("\nINPUTEOF") + + return b.String() +} + +// buildPowerShellScript builds a script that writes the system prompt file, +// then launches claude -p. inputJSONL is returned as stdin bytes to be passed +// directly via gRPC, bypassing PowerShell's encoding entirely. +func (e *osEnv) buildPowerShellScript(args []string, systemPrompt, inputJSONL, workDir, promptFile string) (string, []byte) { + var b strings.Builder + noBOM := "(New-Object System.Text.UTF8Encoding $false)" + + yaoDir := e.pathJoin(workDir, ".yao") + b.WriteString(fmt.Sprintf("if (!(Test-Path '%s')) { New-Item -ItemType Directory -Path '%s' -Force | Out-Null }\n", yaoDir, yaoDir)) + + if systemPrompt != "" { + escaped := strings.ReplaceAll(systemPrompt, "'", "''") + b.WriteString(fmt.Sprintf("[IO.File]::WriteAllText('%s', @'\n%s\n'@, %s)\n", promptFile, escaped, noBOM)) + args = append(args, "--append-system-prompt-file", promptFile) + } + + b.WriteString("claude -p") + for _, arg := range args { + b.WriteString(fmt.Sprintf(" '%s'", strings.ReplaceAll(arg, "'", "''"))) + } + + return b.String(), []byte(inputJSONL + "\n") +} diff --git a/agent/sandbox/v2/claude/runner.go b/agent/sandbox/v2/claude/runner.go index fac6c522..886fde38 100644 --- a/agent/sandbox/v2/claude/runner.go +++ b/agent/sandbox/v2/claude/runner.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "path" "strings" "time" @@ -14,12 +15,7 @@ import ( infra "github.com/yaoapp/yao/sandbox/v2" ) -const ( - defaultWorkDir = "/workspace" - defaultUser = "sandbox" - defaultUserHome = "/home/sandbox" - defaultProxyPort = 3456 -) +const defaultProxyPort = 3456 // ClaudeRunner implements the Runner interface for Claude CLI (mode=cli). type ClaudeRunner struct { @@ -45,33 +41,30 @@ func (r *ClaudeRunner) Prepare(ctx context.Context, req *types.PrepareRequest) e r.mode = "cli" } - workDir := resolveWorkDir(req.Config) + env := resolveOSEnv(req.Computer, 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 != "" { + claudeDir := env.pathJoin(env.WorkDir, ".claude") steps = append(steps, types.PrepareStep{ Action: "exec", - Cmd: fmt.Sprintf("mkdir -p %s/.claude", workDir), + Cmd: env.mkdirCmd(claudeDir), Once: 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"), + Path: env.pathJoin(env.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) @@ -88,9 +81,8 @@ func (r *ClaudeRunner) Stream(ctx context.Context, req *types.StreamRequest, han return fmt.Errorf("computer is nil") } - workDir := resolveWorkDir(req.Config) + oe := resolveOSEnv(computer, req.Config) - // Prepare attachments: resolve __yao.attachment:// URLs, copy files to workspace. if req.ChatID != "" { ws := computer.Workplace() if ws != nil { @@ -102,90 +94,118 @@ func (r *ClaudeRunner) Stream(ctx context.Context, req *types.StreamRequest, han } } - // Detect continuation (existing .claude/projects/ directory). - isContinuation := hasExistingSession(ctx, computer, workDir) + isContinuation := hasExistingSession(ctx, computer, oe) - // Build CLI command and env. - cmd, env := r.buildCLICommand(req, isContinuation) + cmd, env, stdin := r.buildCLICommand(req, oe, isContinuation) - // Create stream. - execStream, err := computer.Stream(ctx, cmd, infra.WithWorkDir(workDir), infra.WithEnv(env)) + streamOpts := []infra.ExecOption{infra.WithWorkDir(oe.WorkDir), infra.WithEnv(env)} + if len(stdin) > 0 { + streamOpts = append(streamOpts, infra.WithStdin(stdin)) + } + + execStream, err := computer.Stream(ctx, cmd, streamOpts...) 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) - }() + streamCtx, streamCancel := context.WithCancel(ctx) + defer streamCancel() 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: + <-streamCtx.Done() + killCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + computer.Exec(killCtx, oe.killProcessCmd("claude")) + execStream.Cancel() + }() + + var stderrBuf strings.Builder + go func() { + buf := make([]byte, 4096) + for { + n, err := execStream.Stderr.Read(buf) + if n > 0 { + stderrBuf.Write(buf[:n]) + chunk := string(buf[:n]) + if strings.Contains(strings.ToLower(chunk), "error") { + streamCancel() + io.Copy(&stderrBuf, execStream.Stderr) + return + } + } + if err != nil { + return + } } }() - // Parse streaming output. - parseErr := parseStreamJSON(ctx, execStream.Stdout, handler) + parseErr := parseStreamJSON(streamCtx, execStream.Stdout, handler) - // Wait for process exit. exitCode, waitErr := execStream.Wait() + stderrStr := strings.TrimSpace(stderrBuf.String()) + if parseErr != nil { + if stderrStr != "" { + return fmt.Errorf("%w (stderr: %s)", parseErr, stderrStr) + } return parseErr } if waitErr != nil { + if stderrStr != "" { + return fmt.Errorf("%w (stderr: %s)", waitErr, stderrStr) + } return waitErr } if exitCode != 0 { + if stderrStr != "" { + return fmt.Errorf("claude CLI exited with code %d: %s", exitCode, stderrStr) + } return fmt.Errorf("claude CLI exited with code %d", exitCode) } return nil } // Cleanup kills any remaining claude processes. -// 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"}) + oe := resolveOSEnv(computer, nil) + computer.Exec(ctx, oe.killProcessCmd("claude")) } 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}) +func hasExistingSession(ctx context.Context, computer infra.Computer, oe *osEnv) bool { + sessionDir := oe.pathJoin(oe.WorkDir, ".claude", "projects") + result, err := computer.Exec(ctx, oe.listDirCmd(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) - +// buildCLICommand constructs the Claude CLI command, environment variables, and optional stdin bytes. +func (r *ClaudeRunner) buildCLICommand(req *types.StreamRequest, oe *osEnv, isContinuation bool) ([]string, map[string]string, []byte) { 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") + if oe.isWindows() { + env["USERPROFILE"] = oe.WorkDir + if len(oe.WorkDir) >= 2 && oe.WorkDir[1] == ':' { + env["HOMEDRIVE"] = oe.WorkDir[:2] + env["HOMEPATH"] = oe.WorkDir[2:] + } + } else { + env["HOME"] = oe.WorkDir + if oe.UserHome != "" { + env["XAUTHORITY"] = path.Join(oe.UserHome, ".Xauthority") + } } - // Connector environment. if req.Connector != nil { setting := req.Connector.Setting() host, _ := setting["host"].(string) @@ -209,23 +229,20 @@ func (r *ClaudeRunner) buildCLICommand(req *types.StreamRequest, isContinuation } } - // 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) + envPrompt := buildSandboxEnvPrompt(oe.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) @@ -233,10 +250,19 @@ func (r *ClaudeRunner) buildCLICommand(req *types.StreamRequest, isContinuation inputJSONL = buildFirstRequestJSONL(req.Messages) } - // CLI args. var args []string - args = append(args, "--dangerously-skip-permissions") - args = append(args, "--permission-mode", "bypassPermissions") + + permMode := "" + if req.Config != nil && req.Config.Runner.Options != nil { + if v, ok := req.Config.Runner.Options["permission_mode"]; ok { + permMode = fmt.Sprintf("%v", v) + } + } + if permMode == "bypassPermissions" { + args = append(args, "--dangerously-skip-permissions") + args = append(args, "--permission-mode", permMode) + } + args = append(args, "--input-format", "stream-json") args = append(args, "--output-format", "stream-json") args = append(args, "--include-partial-messages") @@ -246,7 +272,6 @@ func (r *ClaudeRunner) buildCLICommand(req *types.StreamRequest, 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 { @@ -255,39 +280,16 @@ func (r *ClaudeRunner) buildCLICommand(req *types.StreamRequest, isContinuation } } - // MCP config (set by Prepare if MCPServers were present). if r.hasMCP { - args = append(args, "--mcp-config", path.Join(workDir, ".mcp.json")) + mcpPath := oe.pathJoin(oe.WorkDir, ".mcp.json") + args = append(args, "--mcp-config", mcpPath) 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 + script, stdin := oe.buildCLIScript(args, systemPrompt, inputJSONL) + return oe.shellCmd(script), env, stdin } // buildMCPConfig creates the .mcp.json for Claude CLI based on declared servers. @@ -374,30 +376,6 @@ When working with GitHub and a token is provided: `, 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", diff --git a/agent/sandbox/v2/lifecycle.go b/agent/sandbox/v2/lifecycle.go index afe8604e..d2f28800 100644 --- a/agent/sandbox/v2/lifecycle.go +++ b/agent/sandbox/v2/lifecycle.go @@ -11,6 +11,8 @@ import ( agentContext "github.com/yaoapp/yao/agent/context" "github.com/yaoapp/yao/agent/sandbox/v2/types" infra "github.com/yaoapp/yao/sandbox/v2" + "github.com/yaoapp/yao/tai" + "github.com/yaoapp/yao/workspace" ) // BuildIdentifier determines the Computer identifier based on lifecycle policy @@ -59,6 +61,98 @@ func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *i } cfg.WorkspaceID = workspaceID + // Resolve computer_id from metadata to determine kind and nodeID. + computerID := "" + if ctx.Metadata != nil { + if cid, ok := ctx.Metadata["computer_id"].(string); ok && cid != "" { + computerID = cid + } + } + + // Workspace-wins rule: when both workspace_id and computer_id are present, + // the workspace's bound node takes precedence over computer_id. + if workspaceID != "" && workspaceID != ownerID { + wsNode, err := workspace.M().NodeForWorkspace(context.Background(), workspaceID) + if err == nil && wsNode != "" { + if computerID != "" && computerID != wsNode { + log.Printf("[sandbox/v2] workspace %s bound to node %s overrides computer_id %s", workspaceID, wsNode, computerID) + } + computerID = wsNode + } + } + + if computerID != "" { + return resolveComputerByID(cfg, manager, computerID, ownerID, identifier, workspaceID, conn...) + } + + // No computer_id: fall back to DSL-based dispatch (original logic). + return resolveComputerByDSL(cfg, manager, ownerID, identifier, workspaceID, conn...) +} + +// resolveComputerByID dispatches based on the runtime computer_id from metadata. +// It queries the registry and sandbox manager to determine the computer kind. +func resolveComputerByID( + cfg *types.SandboxConfig, manager *infra.Manager, + computerID, ownerID, identifier, workspaceID string, + conn ...connector.Connector, +) (infra.Computer, string, error) { + + // 1) Check if computer_id is a known Tai node (host or node kind). + if node, ok := tai.GetNodeMeta(computerID); ok { + cfg.NodeID = computerID + hasContainerRuntime := node.Capabilities.Docker || node.Capabilities.K8s + + if node.Capabilities.HostExec && !hasContainerRuntime { + // Host-only node: must use host mode regardless of DSL image config. + cfg.Kind = "host" + host, err := manager.Host(context.Background(), computerID) + if err != nil { + return nil, identifier, fmt.Errorf("get host computer: %w", err) + } + host.BindWorkplace(workspaceID) + return host, identifier, nil + } + + if node.Capabilities.HostExec && hasContainerRuntime && cfg.Computer.Image == "" { + // Dual-capable node with no image in DSL: prefer host mode. + cfg.Kind = "host" + host, err := manager.Host(context.Background(), computerID) + if err != nil { + return nil, identifier, fmt.Errorf("get host computer: %w", err) + } + host.BindWorkplace(workspaceID) + return host, identifier, nil + } + + if !hasContainerRuntime { + return nil, identifier, fmt.Errorf("node %q has no container runtime and no host_exec capability", computerID) + } + + // Node with container runtime and DSL has image: create/reuse a box. + cfg.Kind = "box" + return resolveBox(cfg, manager, ownerID, identifier, workspaceID, conn...) + } + + // 2) Check if computer_id is an existing box ID. + if manager != nil { + box, err := manager.Get(context.Background(), computerID) + if err == nil && box != nil { + cfg.Kind = "box" + box.BindWorkplace(workspaceID) + return box, computerID, nil + } + } + + return nil, identifier, fmt.Errorf("computer %q not found in registry or sandbox manager", computerID) +} + +// resolveComputerByDSL dispatches based on DSL static configuration (cfg.Computer.Image). +func resolveComputerByDSL( + cfg *types.SandboxConfig, manager *infra.Manager, + ownerID, identifier, workspaceID string, + conn ...connector.Connector, +) (infra.Computer, string, error) { + // Host mode: no image → host computer. if cfg.Computer.Image == "" { cfg.Kind = "host" @@ -75,6 +169,15 @@ func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *i } cfg.Kind = "box" + return resolveBox(cfg, manager, ownerID, identifier, workspaceID, conn...) +} + +// resolveBox reuses or creates a box container. +func resolveBox( + cfg *types.SandboxConfig, manager *infra.Manager, + ownerID, identifier, workspaceID string, + conn ...connector.Connector, +) (infra.Computer, string, error) { // Reuse: non-empty identifier → try Get first. if identifier != "" { diff --git a/agent/sandbox/v2/prepare.go b/agent/sandbox/v2/prepare.go index 26e50843..ae8951b1 100644 --- a/agent/sandbox/v2/prepare.go +++ b/agent/sandbox/v2/prepare.go @@ -144,7 +144,12 @@ func runExecStep(ctx context.Context, computer infra.Computer, step types.Prepar } } - result, err := computer.Exec(ctx, shellWrap(kind, script), infra.WithWorkDir("/")) + rootDir := "/" + if isWindowsComputer(computer) { + rootDir = `C:\` + } + + result, err := computer.Exec(ctx, shellWrap(kind, script), infra.WithWorkDir(rootDir)) if err != nil { return err } @@ -155,6 +160,10 @@ func runExecStep(ctx context.Context, computer infra.Computer, step types.Prepar return checkResult(result, label) } +func isWindowsComputer(computer infra.Computer) bool { + return strings.EqualFold(computer.ComputerInfo().System.OS, "windows") +} + // checkResult inspects ExecResult for errors. func checkResult(result *infra.ExecResult, label string) error { if result.Error != "" { diff --git a/openapi/workspace/workspace.go b/openapi/workspace/workspace.go index 634fa7a7..f3eade52 100644 --- a/openapi/workspace/workspace.go +++ b/openapi/workspace/workspace.go @@ -29,6 +29,7 @@ import ( // - DELETE /:id/files/*path — delete file // - POST /:id/mkdir — create directory // - POST /:id/rename — rename file/directory +// - GET /:id/rootdir — get workspace root directory absolute path func Attach(group *gin.RouterGroup, oauth types.OAuth) { group.Use(oauth.Guard) @@ -39,6 +40,7 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) { group.PUT("/:id", handleUpdate) group.DELETE("/:id", handleDelete) + group.GET("/:id/rootdir", handleRootDir) group.GET("/:id/files", handleListFiles) group.GET("/:id/files/*path", handleReadFile) group.PUT("/:id/files/*path", handleWriteFile) @@ -285,6 +287,21 @@ func handleDelete(c *gin.Context) { c.Status(http.StatusNoContent) } +func handleRootDir(c *gin.Context) { + _, ok := resolveAndCheckWS(c) + if !ok { + return + } + + rootDir, err := mgr().MountPath(context.Background(), c.Param("id")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + response.RespondWithSuccess(c, http.StatusOK, gin.H{"root_dir": rootDir}) +} + func handleListFiles(c *gin.Context) { _, ok := resolveAndCheckWS(c) if !ok { diff --git a/sandbox/v2/box.go b/sandbox/v2/box.go index 19ba2fe2..aed45d11 100644 --- a/sandbox/v2/box.go +++ b/sandbox/v2/box.go @@ -30,6 +30,7 @@ type Box struct { image string workspaceID string system SystemInfo + workDir string ws taiworkspace.FS manager *Manager } @@ -195,6 +196,14 @@ func (b *Box) Workspace() taiworkspace.FS { return b.ws } +// GetWorkDir returns the container-internal working directory for command execution. +func (b *Box) GetWorkDir() string { + if b.workDir != "" { + return b.workDir + } + return "/workspace" +} + // WorkspaceID returns the workspace ID mounted to this sandbox, or empty string. func (b *Box) WorkspaceID() string { return b.workspaceID } diff --git a/sandbox/v2/host.go b/sandbox/v2/host.go index 8023a308..1fd77921 100644 --- a/sandbox/v2/host.go +++ b/sandbox/v2/host.go @@ -233,6 +233,21 @@ func (h *Host) Workplace() taiworkspace.FS { return taiworkspace.New(res.Volume, h.workplaceID) } +// GetWorkDir returns the host working directory for command execution. +// Resolves from the bound workspace's root path on disk, falling back to +// the system temp directory if no workspace is bound or root resolution fails. +func (h *Host) GetWorkDir() string { + if ws := h.Workplace(); ws != nil { + if root, err := ws.GetRoot(); err == nil && root != "" { + return root + } + } + if h.system.TempDir != "" { + return h.system.TempDir + } + return "/tmp" +} + // NodeID returns the node ID this Host belongs to. func (h *Host) NodeID() string { return h.nodeID } diff --git a/sandbox/v2/manager.go b/sandbox/v2/manager.go index 11d83c99..7a271257 100644 --- a/sandbox/v2/manager.go +++ b/sandbox/v2/manager.go @@ -199,6 +199,11 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error) TempDir: res.System.TempDir, } + boxWorkDir := opts.WorkDir + if boxWorkDir == "" { + boxWorkDir = "/workspace" + } + box := &Box{ id: id, containerID: containerID, @@ -214,6 +219,7 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error) vnc: opts.VNC, image: opts.Image, workspaceID: opts.WorkspaceID, + workDir: boxWorkDir, system: sys, } box.lastCall.Store(time.Now().UnixMilli()) @@ -459,6 +465,7 @@ func (m *Manager) recoverBoxes(ctx context.Context, nodeID string, res *tai.Conn createdAt: time.Now(), image: c.Image, workspaceID: c.Labels["workspace-id"], + workDir: "/workspace", manager: m, } box.lastCall.Store(time.Now().UnixMilli()) diff --git a/sandbox/v2/types.go b/sandbox/v2/types.go index 1d4a26d4..bc78c85a 100644 --- a/sandbox/v2/types.go +++ b/sandbox/v2/types.go @@ -22,6 +22,7 @@ type Computer interface { Proxy(ctx context.Context, port int, path string) (string, error) BindWorkplace(workspaceID string) Workplace() workspace.FS + GetWorkDir() string } // ComputerInfo holds identity and registry information for a Computer. diff --git a/tai/tunnel/grpc_handler.go b/tai/tunnel/grpc_handler.go index cf8445e5..57d8b93f 100644 --- a/tai/tunnel/grpc_handler.go +++ b/tai/tunnel/grpc_handler.go @@ -30,6 +30,8 @@ type TunnelHandler struct { reg *registry.Registry pending sync.Map // channel_id → chan taipb.TaiTunnel_ForwardServer logger *slog.Logger + + sendMu sync.Map // taiID → *sync.Mutex – serializes Send on each Register stream } // NewTunnelHandler creates a TunnelHandler backed by the given registry. @@ -84,17 +86,24 @@ func (h *TunnelHandler) Register(stream taipb.TaiTunnel_RegisterServer) error { Capabilities: capsFromProto(msg.Caps), } + var mu sync.Mutex + h.sendMu.Store(resolvedTaiID, &mu) + h.reg.Register(node) h.reg.SetRegisterStream(resolvedTaiID, stream) defer func() { + h.sendMu.Delete(resolvedTaiID) h.reg.Unregister(resolvedTaiID) h.logger.Info("tai gRPC tunnel disconnected", "tai_id", resolvedTaiID) }() - if err := stream.Send(&taipb.TunnelControl{ + mu.Lock() + err = stream.Send(&taipb.TunnelControl{ Type: "registered", TaiId: resolvedTaiID, - }); err != nil { + }) + mu.Unlock() + if err != nil { return fmt.Errorf("send registered: %w", err) } @@ -133,8 +142,11 @@ func (h *TunnelHandler) Register(stream taipb.TaiTunnel_RegisterServer) error { switch ctrl.Type { case "ping": h.reg.UpdatePing(resolvedTaiID) - if err := stream.Send(&taipb.TunnelControl{Type: "pong"}); err != nil { - return err + mu.Lock() + sendErr := stream.Send(&taipb.TunnelControl{Type: "pong"}) + mu.Unlock() + if sendErr != nil { + return sendErr } } @@ -163,9 +175,12 @@ func (h *TunnelHandler) Forward(stream taipb.TaiTunnel_ForwardServer) error { } channelID := vals[0] + h.logger.Debug("[forward] Forward stream arrived", "channel_id", channelID[:16]) + if ch, ok := h.pending.LoadAndDelete(channelID); ok { ch.(chan taipb.TaiTunnel_ForwardServer) <- stream } else { + h.logger.Warn("[forward] no pending channel (expired?)", "channel_id", channelID[:16]) return fmt.Errorf("no pending channel for %s", channelID) } @@ -181,6 +196,12 @@ func (h *TunnelHandler) RequestForward(taiID string, targetPort int) (taipb.TaiT return nil, fmt.Errorf("tai %s: no active register stream", taiID) } + muVal, ok := h.sendMu.Load(taiID) + if !ok { + return nil, fmt.Errorf("tai %s: no send mutex (stream closing?)", taiID) + } + mu := muVal.(*sync.Mutex) + channelID, err := registry.GenerateChannelID() if err != nil { return nil, fmt.Errorf("generate channel_id: %w", err) @@ -194,19 +215,31 @@ func (h *TunnelHandler) RequestForward(taiID string, targetPort int) (taipb.TaiT if !ok { return nil, fmt.Errorf("tai %s: register stream type mismatch", taiID) } - if err := regStream.Send(&taipb.TunnelControl{ + + h.logger.Debug("[forward] sending open command", + "tai_id", taiID, "port", targetPort, "channel_id", channelID[:16]) + + mu.Lock() + sendErr := regStream.Send(&taipb.TunnelControl{ Type: "open", ChannelId: channelID, TargetPort: int32(targetPort), - }); err != nil { - return nil, fmt.Errorf("send open: %w", err) + }) + mu.Unlock() + if sendErr != nil { + return nil, fmt.Errorf("send open: %w", sendErr) } + h.logger.Debug("[forward] open sent, waiting for callback", + "tai_id", taiID, "port", targetPort, "channel_id", channelID[:16]) + select { case fwd := <-waitCh: + h.logger.Debug("[forward] callback received", + "tai_id", taiID, "channel_id", channelID[:16]) return fwd, nil case <-time.After(10 * time.Second): - return nil, fmt.Errorf("tai %s: forward timeout (10s)", taiID) + return nil, fmt.Errorf("tai %s: forward timeout (10s) channel=%s", taiID, channelID[:16]) case <-regStream.Context().Done(): return nil, fmt.Errorf("tai %s: register stream closed while waiting for forward", taiID) } diff --git a/tai/volume/local.go b/tai/volume/local.go index 11ca1e11..1494790c 100644 --- a/tai/volume/local.go +++ b/tai/volume/local.go @@ -138,6 +138,10 @@ func (l *localStorage) MkdirAll(_ context.Context, sessionID, path string) error return os.MkdirAll(abs, 0o755) } +func (l *localStorage) Abs(_ context.Context, sessionID, path string) (string, error) { + return l.abs(sessionID, path) +} + // 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. diff --git a/tai/volume/mock_test.go b/tai/volume/mock_test.go index bdee3352..5c8875b8 100644 --- a/tai/volume/mock_test.go +++ b/tai/volume/mock_test.go @@ -190,6 +190,10 @@ func (m *mockVolumeServer) SyncPull(req *pb.SyncManifest, stream grpc.ServerStre return nil } +func (m *mockVolumeServer) Abs(_ context.Context, req *pb.FSRequest) (*pb.FSAbsResponse, error) { + return &pb.FSAbsResponse{Path: "/data/" + req.SessionId + "/" + req.Path}, nil +} + func (m *mockVolumeServer) ListDir(_ context.Context, req *pb.FSRequest) (*pb.FSListResponse, error) { return &pb.FSListResponse{Entries: []*pb.FileInfo{ {Path: "a.txt", Size: 10}, @@ -560,6 +564,10 @@ func (m *errMockVolumeServer) Copy(_ context.Context, _ *pb.FSCopyRequest) (*pb. return nil, fmt.Errorf("injected copy error") } +func (m *errMockVolumeServer) Abs(_ context.Context, _ *pb.FSRequest) (*pb.FSAbsResponse, error) { + return nil, fmt.Errorf("injected abs error") +} + func startErrMockServer(t *testing.T) (*grpc.ClientConn, func()) { t.Helper() lis, err := net.Listen("tcp", "127.0.0.1:0") @@ -748,3 +756,42 @@ func TestErrRemoteCopy(t *testing.T) { t.Error("expected error") } } + +func TestMockRemoteAbs(t *testing.T) { + conn, cleanup := startMockServer(t, &mockVolumeServer{}) + defer cleanup() + + vol := NewRemote(conn) + got, err := vol.Abs(context.Background(), "s1", ".") + if err != nil { + t.Fatalf("Abs: %v", err) + } + if got != "/data/s1/." { + t.Errorf("Abs = %q, want %q", got, "/data/s1/.") + } +} + +func TestMockRemoteAbsRelative(t *testing.T) { + conn, cleanup := startMockServer(t, &mockVolumeServer{}) + defer cleanup() + + vol := NewRemote(conn) + got, err := vol.Abs(context.Background(), "s1", "sub/file.txt") + if err != nil { + t.Fatalf("Abs: %v", err) + } + if got != "/data/s1/sub/file.txt" { + t.Errorf("Abs = %q, want %q", got, "/data/s1/sub/file.txt") + } +} + +func TestErrRemoteAbs(t *testing.T) { + conn, cleanup := startErrMockServer(t) + defer cleanup() + + vol := NewRemote(conn) + _, err := vol.Abs(context.Background(), "s1", ".") + if err == nil { + t.Error("expected error") + } +} diff --git a/tai/volume/pb/volume.pb.go b/tai/volume/pb/volume.pb.go index 96e97bd8..e7bac607 100644 --- a/tai/volume/pb/volume.pb.go +++ b/tai/volume/pb/volume.pb.go @@ -631,6 +631,50 @@ func (x *FSOpResponse) GetError() string { return "" } +type FSAbsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` // absolute path on the host filesystem + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FSAbsResponse) Reset() { + *x = FSAbsResponse{} + mi := &file_tai_volume_pb_volume_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FSAbsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FSAbsResponse) ProtoMessage() {} + +func (x *FSAbsResponse) ProtoReflect() protoreflect.Message { + mi := &file_tai_volume_pb_volume_proto_msgTypes[8] + 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 FSAbsResponse.ProtoReflect.Descriptor instead. +func (*FSAbsResponse) Descriptor() ([]byte, []int) { + return file_tai_volume_pb_volume_proto_rawDescGZIP(), []int{8} +} + +func (x *FSAbsResponse) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + type FSReadRequest struct { state protoimpl.MessageState `protogen:"open.v1"` SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` @@ -641,7 +685,7 @@ type FSReadRequest struct { func (x *FSReadRequest) Reset() { *x = FSReadRequest{} - mi := &file_tai_volume_pb_volume_proto_msgTypes[8] + mi := &file_tai_volume_pb_volume_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -653,7 +697,7 @@ func (x *FSReadRequest) String() string { func (*FSReadRequest) ProtoMessage() {} func (x *FSReadRequest) ProtoReflect() protoreflect.Message { - mi := &file_tai_volume_pb_volume_proto_msgTypes[8] + mi := &file_tai_volume_pb_volume_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -666,7 +710,7 @@ func (x *FSReadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FSReadRequest.ProtoReflect.Descriptor instead. func (*FSReadRequest) Descriptor() ([]byte, []int) { - return file_tai_volume_pb_volume_proto_rawDescGZIP(), []int{8} + return file_tai_volume_pb_volume_proto_rawDescGZIP(), []int{9} } func (x *FSReadRequest) GetSessionId() string { @@ -695,7 +739,7 @@ type FSDataChunk struct { func (x *FSDataChunk) Reset() { *x = FSDataChunk{} - mi := &file_tai_volume_pb_volume_proto_msgTypes[9] + mi := &file_tai_volume_pb_volume_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -707,7 +751,7 @@ func (x *FSDataChunk) String() string { func (*FSDataChunk) ProtoMessage() {} func (x *FSDataChunk) ProtoReflect() protoreflect.Message { - mi := &file_tai_volume_pb_volume_proto_msgTypes[9] + mi := &file_tai_volume_pb_volume_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -720,7 +764,7 @@ func (x *FSDataChunk) ProtoReflect() protoreflect.Message { // Deprecated: Use FSDataChunk.ProtoReflect.Descriptor instead. func (*FSDataChunk) Descriptor() ([]byte, []int) { - return file_tai_volume_pb_volume_proto_rawDescGZIP(), []int{9} + return file_tai_volume_pb_volume_proto_rawDescGZIP(), []int{10} } func (x *FSDataChunk) GetData() []byte { @@ -764,7 +808,7 @@ type FSWriteChunk struct { func (x *FSWriteChunk) Reset() { *x = FSWriteChunk{} - mi := &file_tai_volume_pb_volume_proto_msgTypes[10] + mi := &file_tai_volume_pb_volume_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -776,7 +820,7 @@ func (x *FSWriteChunk) String() string { func (*FSWriteChunk) ProtoMessage() {} func (x *FSWriteChunk) ProtoReflect() protoreflect.Message { - mi := &file_tai_volume_pb_volume_proto_msgTypes[10] + mi := &file_tai_volume_pb_volume_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -789,7 +833,7 @@ func (x *FSWriteChunk) ProtoReflect() protoreflect.Message { // Deprecated: Use FSWriteChunk.ProtoReflect.Descriptor instead. func (*FSWriteChunk) Descriptor() ([]byte, []int) { - return file_tai_volume_pb_volume_proto_rawDescGZIP(), []int{10} + return file_tai_volume_pb_volume_proto_rawDescGZIP(), []int{11} } func (x *FSWriteChunk) GetSessionId() string { @@ -836,7 +880,7 @@ type FSWriteResponse struct { func (x *FSWriteResponse) Reset() { *x = FSWriteResponse{} - mi := &file_tai_volume_pb_volume_proto_msgTypes[11] + mi := &file_tai_volume_pb_volume_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -848,7 +892,7 @@ func (x *FSWriteResponse) String() string { func (*FSWriteResponse) ProtoMessage() {} func (x *FSWriteResponse) ProtoReflect() protoreflect.Message { - mi := &file_tai_volume_pb_volume_proto_msgTypes[11] + mi := &file_tai_volume_pb_volume_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -861,7 +905,7 @@ func (x *FSWriteResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FSWriteResponse.ProtoReflect.Descriptor instead. func (*FSWriteResponse) Descriptor() ([]byte, []int) { - return file_tai_volume_pb_volume_proto_rawDescGZIP(), []int{11} + return file_tai_volume_pb_volume_proto_rawDescGZIP(), []int{12} } func (x *FSWriteResponse) GetSize() int64 { @@ -880,7 +924,7 @@ type FSListResponse struct { func (x *FSListResponse) Reset() { *x = FSListResponse{} - mi := &file_tai_volume_pb_volume_proto_msgTypes[12] + mi := &file_tai_volume_pb_volume_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -892,7 +936,7 @@ func (x *FSListResponse) String() string { func (*FSListResponse) ProtoMessage() {} func (x *FSListResponse) ProtoReflect() protoreflect.Message { - mi := &file_tai_volume_pb_volume_proto_msgTypes[12] + mi := &file_tai_volume_pb_volume_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -905,7 +949,7 @@ func (x *FSListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FSListResponse.ProtoReflect.Descriptor instead. func (*FSListResponse) Descriptor() ([]byte, []int) { - return file_tai_volume_pb_volume_proto_rawDescGZIP(), []int{12} + return file_tai_volume_pb_volume_proto_rawDescGZIP(), []int{13} } func (x *FSListResponse) GetEntries() []*FileInfo { @@ -926,7 +970,7 @@ type FSRemoveRequest struct { func (x *FSRemoveRequest) Reset() { *x = FSRemoveRequest{} - mi := &file_tai_volume_pb_volume_proto_msgTypes[13] + mi := &file_tai_volume_pb_volume_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -938,7 +982,7 @@ func (x *FSRemoveRequest) String() string { func (*FSRemoveRequest) ProtoMessage() {} func (x *FSRemoveRequest) ProtoReflect() protoreflect.Message { - mi := &file_tai_volume_pb_volume_proto_msgTypes[13] + mi := &file_tai_volume_pb_volume_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -951,7 +995,7 @@ func (x *FSRemoveRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FSRemoveRequest.ProtoReflect.Descriptor instead. func (*FSRemoveRequest) Descriptor() ([]byte, []int) { - return file_tai_volume_pb_volume_proto_rawDescGZIP(), []int{13} + return file_tai_volume_pb_volume_proto_rawDescGZIP(), []int{14} } func (x *FSRemoveRequest) GetSessionId() string { @@ -986,7 +1030,7 @@ type FSRenameRequest struct { func (x *FSRenameRequest) Reset() { *x = FSRenameRequest{} - mi := &file_tai_volume_pb_volume_proto_msgTypes[14] + mi := &file_tai_volume_pb_volume_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -998,7 +1042,7 @@ func (x *FSRenameRequest) String() string { func (*FSRenameRequest) ProtoMessage() {} func (x *FSRenameRequest) ProtoReflect() protoreflect.Message { - mi := &file_tai_volume_pb_volume_proto_msgTypes[14] + mi := &file_tai_volume_pb_volume_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1011,7 +1055,7 @@ func (x *FSRenameRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FSRenameRequest.ProtoReflect.Descriptor instead. func (*FSRenameRequest) Descriptor() ([]byte, []int) { - return file_tai_volume_pb_volume_proto_rawDescGZIP(), []int{14} + return file_tai_volume_pb_volume_proto_rawDescGZIP(), []int{15} } func (x *FSRenameRequest) GetSessionId() string { @@ -1048,7 +1092,7 @@ type FSCopyRequest struct { func (x *FSCopyRequest) Reset() { *x = FSCopyRequest{} - 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) } @@ -1060,7 +1104,7 @@ func (x *FSCopyRequest) String() string { func (*FSCopyRequest) ProtoMessage() {} func (x *FSCopyRequest) 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 { @@ -1073,7 +1117,7 @@ func (x *FSCopyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FSCopyRequest.ProtoReflect.Descriptor instead. func (*FSCopyRequest) 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 *FSCopyRequest) GetSessionId() string { @@ -1123,7 +1167,7 @@ type ArchiveRequest struct { func (x *ArchiveRequest) Reset() { *x = ArchiveRequest{} - 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) } @@ -1135,7 +1179,7 @@ func (x *ArchiveRequest) String() string { func (*ArchiveRequest) ProtoMessage() {} func (x *ArchiveRequest) 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 { @@ -1148,7 +1192,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{16} + return file_tai_volume_pb_volume_proto_rawDescGZIP(), []int{17} } func (x *ArchiveRequest) GetSessionId() string { @@ -1189,7 +1233,7 @@ type ArchiveResponse struct { func (x *ArchiveResponse) Reset() { *x = ArchiveResponse{} - mi := &file_tai_volume_pb_volume_proto_msgTypes[17] + mi := &file_tai_volume_pb_volume_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1201,7 +1245,7 @@ func (x *ArchiveResponse) String() string { func (*ArchiveResponse) ProtoMessage() {} func (x *ArchiveResponse) ProtoReflect() protoreflect.Message { - mi := &file_tai_volume_pb_volume_proto_msgTypes[17] + mi := &file_tai_volume_pb_volume_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1214,7 +1258,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{17} + return file_tai_volume_pb_volume_proto_rawDescGZIP(), []int{18} } func (x *ArchiveResponse) GetSizeBytes() int64 { @@ -1285,7 +1329,9 @@ const file_tai_volume_pb_volume_proto_rawDesc = "" + "\x04path\x18\x02 \x01(\tR\x04path\"4\n" + "\fFSOpResponse\x12\x0e\n" + "\x02ok\x18\x01 \x01(\bR\x02ok\x12\x14\n" + - "\x05error\x18\x02 \x01(\tR\x05error\"B\n" + + "\x05error\x18\x02 \x01(\tR\x05error\"#\n" + + "\rFSAbsResponse\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\"B\n" + "\rFSReadRequest\x12\x1d\n" + "\n" + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x12\n" + @@ -1334,7 +1380,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\xfa\a\n" + + "filesCount2\xab\b\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" + @@ -1344,7 +1390,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\x121\n" + + "\bMkdirAll\x12\x11.volume.FSRequest\x1a\x14.volume.FSOpResponse\x12/\n" + + "\x03Abs\x12\x11.volume.FSRequest\x1a\x15.volume.FSAbsResponse\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" + @@ -1368,7 +1415,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, 18) +var file_tai_volume_pb_volume_proto_msgTypes = make([]protoimpl.MessageInfo, 19) var file_tai_volume_pb_volume_proto_goTypes = []any{ (FileChunk_ChunkType)(0), // 0: volume.FileChunk.ChunkType (*FileInfo)(nil), // 1: volume.FileInfo @@ -1379,16 +1426,17 @@ var file_tai_volume_pb_volume_proto_goTypes = []any{ (*SyncResult)(nil), // 6: volume.SyncResult (*FSRequest)(nil), // 7: volume.FSRequest (*FSOpResponse)(nil), // 8: volume.FSOpResponse - (*FSReadRequest)(nil), // 9: volume.FSReadRequest - (*FSDataChunk)(nil), // 10: volume.FSDataChunk - (*FSWriteChunk)(nil), // 11: volume.FSWriteChunk - (*FSWriteResponse)(nil), // 12: volume.FSWriteResponse - (*FSListResponse)(nil), // 13: volume.FSListResponse - (*FSRemoveRequest)(nil), // 14: volume.FSRemoveRequest - (*FSRenameRequest)(nil), // 15: volume.FSRenameRequest - (*FSCopyRequest)(nil), // 16: volume.FSCopyRequest - (*ArchiveRequest)(nil), // 17: volume.ArchiveRequest - (*ArchiveResponse)(nil), // 18: volume.ArchiveResponse + (*FSAbsResponse)(nil), // 9: volume.FSAbsResponse + (*FSReadRequest)(nil), // 10: volume.FSReadRequest + (*FSDataChunk)(nil), // 11: volume.FSDataChunk + (*FSWriteChunk)(nil), // 12: volume.FSWriteChunk + (*FSWriteResponse)(nil), // 13: volume.FSWriteResponse + (*FSListResponse)(nil), // 14: volume.FSListResponse + (*FSRemoveRequest)(nil), // 15: volume.FSRemoveRequest + (*FSRenameRequest)(nil), // 16: volume.FSRenameRequest + (*FSCopyRequest)(nil), // 17: volume.FSCopyRequest + (*ArchiveRequest)(nil), // 18: volume.ArchiveRequest + (*ArchiveResponse)(nil), // 19: volume.ArchiveResponse } var file_tai_volume_pb_volume_proto_depIdxs = []int32{ 1, // 0: volume.SyncManifest.files:type_name -> volume.FileInfo @@ -1400,42 +1448,44 @@ var file_tai_volume_pb_volume_proto_depIdxs = []int32{ 1, // 6: volume.FSListResponse.entries:type_name -> volume.FileInfo 3, // 7: volume.Volume.SyncPush:input_type -> volume.SyncMessage 2, // 8: volume.Volume.SyncPull:input_type -> volume.SyncManifest - 9, // 9: volume.Volume.ReadFile:input_type -> volume.FSReadRequest - 11, // 10: volume.Volume.WriteFile:input_type -> volume.FSWriteChunk + 10, // 9: volume.Volume.ReadFile:input_type -> volume.FSReadRequest + 12, // 10: volume.Volume.WriteFile:input_type -> volume.FSWriteChunk 7, // 11: volume.Volume.Stat:input_type -> volume.FSRequest 7, // 12: volume.Volume.ListDir:input_type -> volume.FSRequest - 14, // 13: volume.Volume.Remove:input_type -> volume.FSRemoveRequest - 15, // 14: volume.Volume.Rename:input_type -> volume.FSRenameRequest + 15, // 13: volume.Volume.Remove:input_type -> volume.FSRemoveRequest + 16, // 14: volume.Volume.Rename:input_type -> volume.FSRenameRequest 7, // 15: volume.Volume.MkdirAll:input_type -> volume.FSRequest - 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, // 16: volume.Volume.Abs:input_type -> volume.FSRequest + 17, // 17: volume.Volume.Copy:input_type -> volume.FSCopyRequest + 18, // 18: volume.Volume.Zip:input_type -> volume.ArchiveRequest + 18, // 19: volume.Volume.Unzip:input_type -> volume.ArchiveRequest + 18, // 20: volume.Volume.Gzip:input_type -> volume.ArchiveRequest + 18, // 21: volume.Volume.Gunzip:input_type -> volume.ArchiveRequest + 18, // 22: volume.Volume.Tar:input_type -> volume.ArchiveRequest + 18, // 23: volume.Volume.Untar:input_type -> volume.ArchiveRequest + 18, // 24: volume.Volume.Tgz:input_type -> volume.ArchiveRequest + 18, // 25: volume.Volume.Untgz:input_type -> volume.ArchiveRequest + 3, // 26: volume.Volume.SyncPush:output_type -> volume.SyncMessage + 3, // 27: volume.Volume.SyncPull:output_type -> volume.SyncMessage + 11, // 28: volume.Volume.ReadFile:output_type -> volume.FSDataChunk + 13, // 29: volume.Volume.WriteFile:output_type -> volume.FSWriteResponse + 1, // 30: volume.Volume.Stat:output_type -> volume.FileInfo + 14, // 31: volume.Volume.ListDir:output_type -> volume.FSListResponse + 8, // 32: volume.Volume.Remove:output_type -> volume.FSOpResponse + 8, // 33: volume.Volume.Rename:output_type -> volume.FSOpResponse + 8, // 34: volume.Volume.MkdirAll:output_type -> volume.FSOpResponse + 9, // 35: volume.Volume.Abs:output_type -> volume.FSAbsResponse + 6, // 36: volume.Volume.Copy:output_type -> volume.SyncResult + 19, // 37: volume.Volume.Zip:output_type -> volume.ArchiveResponse + 19, // 38: volume.Volume.Unzip:output_type -> volume.ArchiveResponse + 19, // 39: volume.Volume.Gzip:output_type -> volume.ArchiveResponse + 19, // 40: volume.Volume.Gunzip:output_type -> volume.ArchiveResponse + 19, // 41: volume.Volume.Tar:output_type -> volume.ArchiveResponse + 19, // 42: volume.Volume.Untar:output_type -> volume.ArchiveResponse + 19, // 43: volume.Volume.Tgz:output_type -> volume.ArchiveResponse + 19, // 44: volume.Volume.Untgz:output_type -> volume.ArchiveResponse + 26, // [26:45] is the sub-list for method output_type + 7, // [7:26] 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 @@ -1458,7 +1508,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: 18, + NumMessages: 19, NumExtensions: 0, NumServices: 1, }, diff --git a/tai/volume/pb/volume.proto b/tai/volume/pb/volume.proto index 5fa38279..3ecde25a 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); + // Abs: resolve a session-relative path to its absolute path on the host. + rpc Abs(FSRequest) returns (FSAbsResponse); // Copy: copy src to dst within the same workspace (server-side when remote). rpc Copy(FSCopyRequest) returns (SyncResult); @@ -112,6 +114,10 @@ message FSOpResponse { string error = 2; } +message FSAbsResponse { + string path = 1; // absolute path on the host filesystem +} + message FSReadRequest { string session_id = 1; string path = 2; diff --git a/tai/volume/pb/volume_grpc.pb.go b/tai/volume/pb/volume_grpc.pb.go index a5370fe3..a35792e3 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_Abs_FullMethodName = "/volume.Volume/Abs" Volume_Copy_FullMethodName = "/volume.Volume/Copy" Volume_Zip_FullMethodName = "/volume.Volume/Zip" Volume_Unzip_FullMethodName = "/volume.Volume/Unzip" @@ -64,6 +65,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) + // Abs: resolve a session-relative path to its absolute path on the host. + Abs(ctx context.Context, in *FSRequest, opts ...grpc.CallOption) (*FSAbsResponse, 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) @@ -198,6 +201,16 @@ func (c *volumeClient) MkdirAll(ctx context.Context, in *FSRequest, opts ...grpc return out, nil } +func (c *volumeClient) Abs(ctx context.Context, in *FSRequest, opts ...grpc.CallOption) (*FSAbsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(FSAbsResponse) + err := c.cc.Invoke(ctx, Volume_Abs_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + 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) @@ -313,6 +326,8 @@ type VolumeServer interface { Remove(context.Context, *FSRemoveRequest) (*FSOpResponse, error) Rename(context.Context, *FSRenameRequest) (*FSOpResponse, error) MkdirAll(context.Context, *FSRequest) (*FSOpResponse, error) + // Abs: resolve a session-relative path to its absolute path on the host. + Abs(context.Context, *FSRequest) (*FSAbsResponse, 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) @@ -360,6 +375,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) Abs(context.Context, *FSRequest) (*FSAbsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Abs not implemented") +} func (UnimplementedVolumeServer) Copy(context.Context, *FSCopyRequest) (*SyncResult, error) { return nil, status.Error(codes.Unimplemented, "method Copy not implemented") } @@ -534,6 +552,24 @@ func _Volume_MkdirAll_Handler(srv interface{}, ctx context.Context, dec func(int return interceptor(ctx, in, info, handler) } +func _Volume_Abs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FSRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VolumeServer).Abs(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Volume_Abs_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VolumeServer).Abs(ctx, req.(*FSRequest)) + } + 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 { @@ -723,6 +759,10 @@ var Volume_ServiceDesc = grpc.ServiceDesc{ MethodName: "MkdirAll", Handler: _Volume_MkdirAll_Handler, }, + { + MethodName: "Abs", + Handler: _Volume_Abs_Handler, + }, { MethodName: "Copy", Handler: _Volume_Copy_Handler, diff --git a/tai/volume/remote.go b/tai/volume/remote.go index e7019e66..59b2aabf 100644 --- a/tai/volume/remote.go +++ b/tai/volume/remote.go @@ -167,6 +167,17 @@ func (r *remoteStorage) MkdirAll(ctx context.Context, sessionID, path string) er return nil } +func (r *remoteStorage) Abs(ctx context.Context, sessionID, path string) (string, error) { + resp, err := r.client.Abs(ctx, &pb.FSRequest{ + SessionId: sessionID, + Path: path, + }) + if err != nil { + return "", err + } + return resp.Path, nil +} + // 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() diff --git a/tai/volume/volume.go b/tai/volume/volume.go index e8498c33..ebf264e9 100644 --- a/tai/volume/volume.go +++ b/tai/volume/volume.go @@ -17,6 +17,7 @@ type Volume interface { Remove(ctx context.Context, sessionID, path string, recursive bool) error Rename(ctx context.Context, sessionID, oldPath, newPath string) error MkdirAll(ctx context.Context, sessionID, path string) error + Abs(ctx context.Context, sessionID, path string) (string, error) SyncPush(ctx context.Context, sessionID, localDir string, opts ...SyncOption) (*SyncResult, error) SyncPull(ctx context.Context, sessionID, localDir string, opts ...SyncOption) (*SyncResult, error) diff --git a/tai/volume/volume_test.go b/tai/volume/volume_test.go index eae954a8..8a30619e 100644 --- a/tai/volume/volume_test.go +++ b/tai/volume/volume_test.go @@ -560,10 +560,83 @@ func TestRemoteVolume(t *testing.T) { _ = vol.Remove(ctx, arcSid, ".", true) }) + t.Run("Abs dot", func(t *testing.T) { + absSid := "abs-remote-test" + _ = vol.MkdirAll(ctx, absSid, ".") + got, err := vol.Abs(ctx, absSid, ".") + if err != nil { + t.Fatalf("Abs: %v", err) + } + if got == "" { + t.Error("Abs returned empty") + } + _ = vol.Remove(ctx, absSid, ".", true) + }) + + t.Run("Abs relative", func(t *testing.T) { + got, err := vol.Abs(ctx, sid, "sub/file.txt") + if err != nil { + t.Fatalf("Abs: %v", err) + } + if got == "" { + t.Error("Abs returned empty") + } + }) + + t.Run("Abs path traversal", func(t *testing.T) { + _, err := vol.Abs(ctx, sid, "../../etc/passwd") + if err == nil { + t.Error("expected error for Abs path traversal") + } + }) + // Cleanup _ = vol.Remove(ctx, sid, ".", true) } +func TestLocalAbs_Dot(t *testing.T) { + dir := t.TempDir() + vol := NewLocal(dir) + ctx := context.Background() + sid := "abs-test" + + got, err := vol.Abs(ctx, sid, ".") + if err != nil { + t.Fatalf("Abs: %v", err) + } + want := dir + "/" + sid + if got != want { + t.Errorf("Abs(\".\") = %q, want %q", got, want) + } +} + +func TestLocalAbs_RelativePath(t *testing.T) { + dir := t.TempDir() + vol := NewLocal(dir) + ctx := context.Background() + sid := "abs-rel" + + got, err := vol.Abs(ctx, sid, "sub/file.txt") + if err != nil { + t.Fatalf("Abs: %v", err) + } + want := dir + "/" + sid + "/sub/file.txt" + if got != want { + t.Errorf("Abs = %q, want %q", got, want) + } +} + +func TestLocalAbs_PathTraversal(t *testing.T) { + dir := t.TempDir() + vol := NewLocal(dir) + ctx := context.Background() + + _, err := vol.Abs(ctx, "test", "../../etc/passwd") + if err == nil { + t.Error("expected error for path traversal in Abs") + } +} + func TestLocalPathTraversal(t *testing.T) { dir := t.TempDir() vol := NewLocal(dir) diff --git a/tai/workspace/workspace.go b/tai/workspace/workspace.go index 5ff5b518..08c518b1 100644 --- a/tai/workspace/workspace.go +++ b/tai/workspace/workspace.go @@ -31,6 +31,9 @@ type FS interface { // 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) + + // GetRoot returns the absolute path of this workspace's root directory on the host filesystem. + GetRoot() (string, error) } // New creates an FS backed by the given Volume for the specified session. @@ -119,6 +122,10 @@ func (w *workspaceFS) MkdirAll(name string, _ os.FileMode) error { return w.vol.MkdirAll(context.Background(), w.session, name) } +func (w *workspaceFS) GetRoot() (string, error) { + return w.vol.Abs(context.Background(), w.session, ".") +} + func (w *workspaceFS) Close() error { return nil } // --- fs.FileInfo adapter --- diff --git a/tai/workspace/workspace_test.go b/tai/workspace/workspace_test.go index 97ba9f1a..2a03770f 100644 --- a/tai/workspace/workspace_test.go +++ b/tai/workspace/workspace_test.go @@ -210,6 +210,25 @@ func TestWorkspaceFS(t *testing.T) { }) } +func TestGetRoot(t *testing.T) { + dir := t.TempDir() + vol := volume.NewLocal(dir) + defer vol.Close() + + sid := "getroot-test" + wfs := New(vol, sid) + defer wfs.Close() + + root, err := wfs.GetRoot() + if err != nil { + t.Fatalf("GetRoot: %v", err) + } + want := dir + "/" + sid + if root != want { + t.Errorf("GetRoot() = %q, want %q", root, want) + } +} + // Compile-time interface checks. var ( _ fs.FS = (*workspaceFS)(nil) diff --git a/workspace/manager.go b/workspace/manager.go index e9be938d..3864dbbd 100644 --- a/workspace/manager.go +++ b/workspace/manager.go @@ -275,20 +275,7 @@ func (m *Manager) MountPath(ctx context.Context, id string) (string, error) { if err != nil { return "", err } - _ = vol - for _, snap := range listNodes() { - res, ok := tai.GetResources(snap.TaiID) - if !ok { - continue - } - if res.Volume == vol { - if res.DataDir == "" { - return "", nil - } - return res.DataDir + "/" + id, nil - } - } - return "", nil + return vol.Abs(ctx, id, ".") } // --- internal ---