feat(workspace): add root directory retrieval for workspaces

- Implemented a new endpoint to retrieve the absolute path of a workspace's root directory.
- Enhanced the workspace interface with a GetRoot method to facilitate this functionality.
- Updated the workspace manager to utilize the new method for improved path resolution.

Made-with: Cursor
This commit is contained in:
Max 2026-03-13 18:25:14 +08:00
parent 59004de3e8
commit 82bb44cbda
24 changed files with 834 additions and 211 deletions

View file

@ -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
// ================================================

View file

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

View file

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

View file

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

View file

@ -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",

View file

@ -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 != "" {

View file

@ -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 != "" {

View file

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

View file

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

View file

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

View file

@ -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())

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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()

View file

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

View file

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

View file

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

View file

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

View file

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