diff --git a/agent/sandbox/v2/opencode/command.go b/agent/sandbox/v2/opencode/command.go index 63f600bd..c1d8405d 100644 --- a/agent/sandbox/v2/opencode/command.go +++ b/agent/sandbox/v2/opencode/command.go @@ -66,7 +66,7 @@ func (r *Runner) buildCommand(req *types.StreamRequest, p platform, attachmentPa stdinMsg := buildStdinMessage(req.Messages, attachmentPaths) - script := shellQuote("opencode", args...) + script := shellQuoteForPlatform(p, "opencode", args...) return command{ shell: p.ShellCmd(script), @@ -255,12 +255,18 @@ func buildSandboxEnvPrompt(p platform, workDir string) string { shell = "bash" } + envVarSyntax := "$VAR_NAME" + if osName == "windows" { + envVarSyntax = "$env:VAR_NAME" + } + return fmt.Sprintf(`## Sandbox Environment - **Operating System**: %[2]s - **Shell**: %[3]s - **Working Directory**: %[1]s - **File Access**: You have full read/write access to %[1]s +- **Environment variable syntax**: `+"`%[4]s`"+` ## User Attachments @@ -268,7 +274,7 @@ User-uploaded files are placed in %[1]s/.attachments/{chatID}/ Each chat session has its own subdirectory. When the user attaches files, their paths are listed at the top of the message. **Read these files yourself** using the Read or Bash tool — they are NOT passed as CLI arguments. -`, workDir, osName, shell) +`, workDir, osName, shell, envVarSyntax) } func getProviderPrefix(conn connector.Connector) string { @@ -302,7 +308,17 @@ func getRoleConnectors(req *types.StreamRequest) map[string]*types.RoleConnector return req.Config.Runner.Connectors } -// shellQuote builds a shell-safe command string from program and args. +// shellQuoteForPlatform builds a shell-safe command string. On Windows +// (PowerShell) it uses single quotes with ” escaping; on POSIX it uses +// single quotes with '\” escaping. +func shellQuoteForPlatform(p platform, program string, args ...string) string { + if p.OS() == "windows" { + return shellQuotePowerShell(program, args...) + } + return shellQuote(program, args...) +} + +// shellQuote builds a POSIX shell-safe command string from program and args. func shellQuote(program string, args ...string) string { parts := make([]string, 0, 1+len(args)) parts = append(parts, program) @@ -316,6 +332,21 @@ func shellQuote(program string, args ...string) string { return strings.Join(parts, " ") } +// shellQuotePowerShell builds a PowerShell-safe command string. In PowerShell, +// single-quoted strings escape embedded single quotes by doubling them (”). +func shellQuotePowerShell(program string, args ...string) string { + parts := make([]string, 0, 1+len(args)) + parts = append(parts, program) + for _, a := range args { + if a == "" || strings.ContainsAny(a, " \t\n\"'\\$`!#&|;(){}[]<>?*~") { + parts = append(parts, "'"+strings.ReplaceAll(a, "'", "''")+"'") + } else { + parts = append(parts, a) + } + } + return strings.Join(parts, " ") +} + // connectorModelID returns the "provider/model" string matching the // provider ID used in opencode.json (see buildProviderConfig). func connectorModelID(c connector.Connector) string { diff --git a/agent/sandbox/v2/opencode/plat_win.go b/agent/sandbox/v2/opencode/plat_win.go new file mode 100644 index 00000000..c311553c --- /dev/null +++ b/agent/sandbox/v2/opencode/plat_win.go @@ -0,0 +1,76 @@ +package opencode + +import ( + "fmt" + "strings" +) + +// windowsPlatform implements the platform interface for Windows containers. +// Aligned with the Claude runner's plat_win.go: uses PowerShell for shell +// commands, backslash path joins, and full HOME-related env vars. +type windowsPlatform struct { + workDir string + shell string +} + +func newWindowsPlatform(workDir, shell string) *windowsPlatform { + if shell == "" { + shell = "pwsh" + } + return &windowsPlatform{workDir: workDir, shell: shell} +} + +func (w *windowsPlatform) OS() string { return "windows" } +func (w *windowsPlatform) Shell() string { return w.shell } + +func (w *windowsPlatform) PathJoin(parts ...string) string { + return strings.Join(parts, `\`) +} + +// HomeEnv sets HOME, USERPROFILE, HOMEDRIVE, and HOMEPATH so that Git for +// Windows, npm, and other tools resolve ~ correctly inside the container. +// See Claude runner plat_win.go and anthropics/claude-code#13138. +func (w *windowsPlatform) HomeEnv(workDir string) map[string]string { + env := map[string]string{ + "HOME": workDir, + "USERPROFILE": workDir, + } + if len(workDir) >= 2 && workDir[1] == ':' { + env["HOMEDRIVE"] = workDir[:2] + env["HOMEPATH"] = workDir[2:] + } + return env +} + +func (w *windowsPlatform) ShellCmd(script string) []string { + shell := strings.ToLower(w.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{"pwsh", "-NoProfile", "-Command", script} + } +} + +func (w *windowsPlatform) KillCmd(pattern string) []string { + script := fmt.Sprintf( + "Get-Process -ErrorAction SilentlyContinue | Where-Object {$_.ProcessName -like '*%s*'} | "+ + "ForEach-Object { taskkill /F /T /PID $_.Id 2>$null }; "+ + "Get-Process -ErrorAction SilentlyContinue | Where-Object {$_.ProcessName -like '*%s*'} | "+ + "Stop-Process -Force -ErrorAction SilentlyContinue", + pattern, pattern) + return w.ShellCmd(script) +} + +func (w *windowsPlatform) KillSessionCmd(sessionName string) []string { + script := fmt.Sprintf( + "Get-Process -ErrorAction SilentlyContinue | "+ + "Where-Object { $_.CommandLine -like '*%s*' } | "+ + "ForEach-Object { taskkill /F /T /PID $_.Id 2>$null }", + sessionName) + return w.ShellCmd(script) +} diff --git a/agent/sandbox/v2/opencode/platform.go b/agent/sandbox/v2/opencode/platform.go index 4dc3cc44..640d5107 100644 --- a/agent/sandbox/v2/opencode/platform.go +++ b/agent/sandbox/v2/opencode/platform.go @@ -51,6 +51,10 @@ func resolvePlatform(computer infra.Computer) platform { workDir := computer.GetWorkDir() shell := sys.Shell + if osName == "windows" { + return newWindowsPlatform(workDir, shell) + } + base := posixBase{os: osName, workDir: workDir, shell: shell} if base.shell == "" { base.shell = "bash" diff --git a/agent/sandbox/v2/opencode/platform_test.go b/agent/sandbox/v2/opencode/platform_test.go new file mode 100644 index 00000000..36a89cc3 --- /dev/null +++ b/agent/sandbox/v2/opencode/platform_test.go @@ -0,0 +1,220 @@ +package opencode + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// POSIX posixBase tests +// --------------------------------------------------------------------------- + +func TestPosixBase_Accessors(t *testing.T) { + b := &posixBase{os: "linux", workDir: "/workspace", shell: "bash"} + assert.Equal(t, "linux", b.OS()) + assert.Equal(t, "bash", b.Shell()) + assert.Equal(t, "/workspace/.yao/config", b.PathJoin("/workspace", ".yao", "config")) +} + +func TestPosixBase_HomeEnv(t *testing.T) { + b := &posixBase{} + env := b.HomeEnv("/workspace") + assert.Equal(t, "/workspace", env["HOME"]) + assert.Len(t, env, 1) +} + +func TestPosixBase_ShellCmd(t *testing.T) { + b := &posixBase{} + cmd := b.ShellCmd("echo hello") + assert.Equal(t, []string{"bash", "-c", "echo hello"}, cmd) +} + +func TestPosixBase_KillCmd(t *testing.T) { + b := &posixBase{} + cmd := b.KillCmd("opencode") + require.Len(t, cmd, 3) + assert.Equal(t, "sh", cmd[0]) + assert.Contains(t, cmd[2], "pkill") + assert.Contains(t, cmd[2], "opencode") +} + +func TestPosixBase_KillSessionCmd(t *testing.T) { + b := &posixBase{} + cmd := b.KillSessionCmd("yao-oc-session123") + require.Len(t, cmd, 3) + assert.Equal(t, "sh", cmd[0]) + assert.Contains(t, cmd[2], "pkill -9 -f") + assert.Contains(t, cmd[2], "yao-oc-session123") +} + +// --------------------------------------------------------------------------- +// Windows windowsPlatform tests +// --------------------------------------------------------------------------- + +func TestWindows_NewDefaults(t *testing.T) { + w := newWindowsPlatform(`C:\workspace`, "") + assert.Equal(t, "pwsh", w.Shell()) +} + +func TestWindows_Accessors(t *testing.T) { + w := newWindowsPlatform(`C:\workspace`, "pwsh") + assert.Equal(t, "windows", w.OS()) + assert.Equal(t, "pwsh", w.Shell()) +} + +func TestWindows_PathJoin(t *testing.T) { + w := newWindowsPlatform(`C:\workspace`, "pwsh") + assert.Equal(t, `C:\workspace\.yao\config`, w.PathJoin(`C:\workspace`, ".yao", "config")) + assert.Equal(t, `a\b\c`, w.PathJoin("a", "b", "c")) +} + +func TestWindows_HomeEnv(t *testing.T) { + w := newWindowsPlatform(`C:\workspace`, "pwsh") + env := w.HomeEnv(`C:\workspace`) + assert.Equal(t, `C:\workspace`, env["HOME"]) + assert.Equal(t, `C:\workspace`, env["USERPROFILE"]) + assert.Equal(t, `C:`, env["HOMEDRIVE"]) + assert.Equal(t, `\workspace`, env["HOMEPATH"]) + assert.Len(t, env, 4) +} + +func TestWindows_HomeEnv_NoDrive(t *testing.T) { + w := newWindowsPlatform("X", "pwsh") + env := w.HomeEnv("X") + assert.Equal(t, "X", env["HOME"]) + assert.Equal(t, "X", env["USERPROFILE"]) + _, hasDrive := env["HOMEDRIVE"] + assert.False(t, hasDrive, "should not set HOMEDRIVE for path without drive letter") + assert.Len(t, env, 2) +} + +func TestWindows_ShellCmd_Pwsh(t *testing.T) { + w := newWindowsPlatform(`C:\ws`, "pwsh") + cmd := w.ShellCmd("echo hello") + assert.Equal(t, []string{"pwsh", "-NoProfile", "-Command", "echo hello"}, cmd) +} + +func TestWindows_ShellCmd_Powershell(t *testing.T) { + w := newWindowsPlatform(`C:\ws`, "powershell") + cmd := w.ShellCmd("echo hello") + assert.Equal(t, "powershell", cmd[0]) + assert.Equal(t, "-NoProfile", cmd[1]) +} + +func TestWindows_ShellCmd_Cmd(t *testing.T) { + w := newWindowsPlatform(`C:\ws`, "cmd.exe") + cmd := w.ShellCmd("echo hello") + assert.Equal(t, []string{"cmd.exe", "/C", "echo hello"}, cmd) +} + +func TestWindows_ShellCmd_Default(t *testing.T) { + w := newWindowsPlatform(`C:\ws`, "unknown-shell") + cmd := w.ShellCmd("echo") + assert.Equal(t, "pwsh", cmd[0], "unknown shell should fall back to pwsh") +} + +func TestWindows_KillCmd(t *testing.T) { + w := newWindowsPlatform(`C:\ws`, "pwsh") + cmd := w.KillCmd("opencode") + require.Len(t, cmd, 4) + assert.Equal(t, "pwsh", cmd[0]) + assert.Contains(t, cmd[3], "opencode") + assert.Contains(t, cmd[3], "taskkill") + assert.Contains(t, cmd[3], "Stop-Process") +} + +func TestWindows_KillSessionCmd(t *testing.T) { + w := newWindowsPlatform(`C:\ws`, "pwsh") + cmd := w.KillSessionCmd("yao-oc-session123") + require.Len(t, cmd, 4) + assert.Equal(t, "pwsh", cmd[0]) + assert.Contains(t, cmd[3], "CommandLine") + assert.Contains(t, cmd[3], "yao-oc-session123") + assert.Contains(t, cmd[3], "taskkill") +} + +// --------------------------------------------------------------------------- +// shellQuote / shellQuoteForPlatform tests +// --------------------------------------------------------------------------- + +func TestShellQuote_POSIX(t *testing.T) { + result := shellQuote("opencode", "run", "--format", "json") + assert.Equal(t, "opencode run --format json", result) +} + +func TestShellQuote_POSIX_SpecialChars(t *testing.T) { + result := shellQuote("opencode", "run", "hello world", "it's") + assert.Contains(t, result, "'hello world'") + assert.Contains(t, result, `'\''`) +} + +func TestShellQuotePowerShell(t *testing.T) { + result := shellQuotePowerShell("opencode", "run", "--format", "json") + assert.Equal(t, "opencode run --format json", result) +} + +func TestShellQuotePowerShell_SpecialChars(t *testing.T) { + result := shellQuotePowerShell("opencode", "run", "hello world", "it's") + assert.Contains(t, result, "'hello world'") + assert.Contains(t, result, "'it''s'") + assert.NotContains(t, result, `'\''`, "PowerShell should use '' not '\\''") +} + +func TestShellQuoteForPlatform_POSIX(t *testing.T) { + p := &posixBase{os: "linux"} + result := shellQuoteForPlatform(p, "opencode", "it's") + assert.Contains(t, result, `'\''`) +} + +func TestShellQuoteForPlatform_Windows(t *testing.T) { + p := newWindowsPlatform(`C:\ws`, "pwsh") + result := shellQuoteForPlatform(p, "opencode", "it's") + assert.Contains(t, result, "''s'") + assert.NotContains(t, result, `'\''`) +} + +// --------------------------------------------------------------------------- +// buildSandboxEnvPrompt tests +// --------------------------------------------------------------------------- + +func TestBuildSandboxEnvPrompt_Linux(t *testing.T) { + p := &posixBase{os: "linux", shell: "bash"} + prompt := buildSandboxEnvPrompt(p, "/workspace") + assert.Contains(t, prompt, "linux") + assert.Contains(t, prompt, "bash") + assert.Contains(t, prompt, "/workspace") + assert.Contains(t, prompt, "$VAR_NAME") + assert.NotContains(t, prompt, "$env:") +} + +func TestBuildSandboxEnvPrompt_Windows(t *testing.T) { + p := newWindowsPlatform(`C:\workspace`, "pwsh") + prompt := buildSandboxEnvPrompt(p, `C:\workspace`) + assert.Contains(t, prompt, "windows") + assert.Contains(t, prompt, "pwsh") + assert.Contains(t, prompt, `C:\workspace`) + assert.Contains(t, prompt, "$env:VAR_NAME") +} + +// --------------------------------------------------------------------------- +// Vision read.ts copy step generation logic +// --------------------------------------------------------------------------- + +func TestVisionCopyStep_Linux(t *testing.T) { + p := &posixBase{os: "linux", workDir: "/workspace", shell: "bash"} + cmd := visionCopyCmd(p) + assert.Contains(t, cmd, "mkdir -p") + assert.Contains(t, cmd, "opencode-tools") + assert.NotContains(t, cmd, "PowerShell") +} + +func TestVisionCopyStep_Windows(t *testing.T) { + p := newWindowsPlatform(`C:\workspace`, "pwsh") + cmd := visionCopyCmd(p) + assert.Contains(t, cmd, "Test-Path") + assert.Contains(t, cmd, "Copy-Item") + assert.Contains(t, cmd, `opencode-tools`) + assert.NotContains(t, cmd, "mkdir -p") +} diff --git a/agent/sandbox/v2/opencode/runner.go b/agent/sandbox/v2/opencode/runner.go index 7aa7f728..334f5035 100644 --- a/agent/sandbox/v2/opencode/runner.go +++ b/agent/sandbox/v2/opencode/runner.go @@ -81,9 +81,10 @@ func (r *Runner) Prepare(ctx context.Context, req *types.PrepareRequest) error { // built-in read to route image files through the vision API. if req.Config != nil && req.Config.Runner.Connectors != nil { if vc, ok := req.Config.Runner.Connectors["vision"]; ok && vc != nil && vc.Connector != "" { + p := resolvePlatform(req.Computer) steps = append(steps, types.PrepareStep{ Action: "exec", - Cmd: "mkdir -p $HOME/.config/opencode/tools && for f in /opt/opencode-tools/*.ts; do [ -f \"$f\" ] && cp -f \"$f\" $HOME/.config/opencode/tools/; done", + Cmd: visionCopyCmd(p), Once: true, IgnoreError: true, }) @@ -231,3 +232,15 @@ func (r *Runner) Cleanup(ctx context.Context, computer infra.Computer) error { return nil } + +// visionCopyCmd returns the shell command to copy custom OpenCode tools +// (e.g. read.ts for vision) from the container image path into the user's +// config directory. Platform-aware: bash for POSIX, PowerShell for Windows. +func visionCopyCmd(p platform) string { + if p.OS() == "windows" { + return `$d = Join-Path $env:USERPROFILE '.config\opencode\tools'; ` + + `if (!(Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }; ` + + `Copy-Item 'C:\opt\opencode-tools\*.ts' $d -Force -ErrorAction SilentlyContinue` + } + return `mkdir -p $HOME/.config/opencode/tools && for f in /opt/opencode-tools/*.ts; do [ -f "$f" ] && cp -f "$f" $HOME/.config/opencode/tools/; done` +} diff --git a/agent/sandbox/v2/opencode/runner_test.go b/agent/sandbox/v2/opencode/runner_test.go index 1eaa88be..ec8a9a0b 100644 --- a/agent/sandbox/v2/opencode/runner_test.go +++ b/agent/sandbox/v2/opencode/runner_test.go @@ -103,72 +103,6 @@ func TestOpenCode_Session(t *testing.T) { }) } -// --------------------------------------------------------------------------- -// Scenario 4: No vision connector — read.ts should NOT be copied -// --------------------------------------------------------------------------- - -func TestOpenCode_NoVision_ReadToolNotCopied(t *testing.T) { - sandboxtestutils.Prepare(t) - defer sandboxtestutils.Clean(t) - require.NotNil(t, caller.AgentGetterFunc) - - const assistantID = "tests.sandbox-v2.opencode-oneshot-cli" - agent, err := caller.AgentGetterFunc(assistantID) - require.NoError(t, err) - - chatID := fmt.Sprintf("e2e-novision-%d", time.Now().UnixMilli()) - ctx := agentcontext.New( - context.Background(), - &oauthtypes.AuthorizedInfo{TeamID: "test-team-e2e", UserID: "test-user-e2e"}, - chatID, - ) - - resp := streamAndWait(t, agent, ctx, - `Check if the file $HOME/.config/opencode/tools/read.ts exists. `+ - `Reply with exactly "READ_EXISTS" if it does, or "READ_MISSING" if it does not. Nothing else.`, - defaultTimeout, - ) - - require.NotNil(t, resp.Completion) - content := strings.ToLower(contentString(t, resp)) - t.Logf("NoVision check: %s", content) - assert.Contains(t, content, "read_missing", - "without vision connector, read.ts should NOT be copied") -} - -// --------------------------------------------------------------------------- -// Scenario 5: With vision connector — read.ts SHOULD be copied -// --------------------------------------------------------------------------- - -func TestOpenCode_Vision_ReadToolCopied(t *testing.T) { - sandboxtestutils.Prepare(t) - defer sandboxtestutils.Clean(t) - require.NotNil(t, caller.AgentGetterFunc) - - const assistantID = "tests.sandbox-v2.opencode-vision-cli" - agent, err := caller.AgentGetterFunc(assistantID) - require.NoError(t, err) - - chatID := fmt.Sprintf("e2e-vision-%d", time.Now().UnixMilli()) - ctx := agentcontext.New( - context.Background(), - &oauthtypes.AuthorizedInfo{TeamID: "test-team-e2e", UserID: "test-user-e2e"}, - chatID, - ) - - resp := streamAndWait(t, agent, ctx, - `Check if the file $HOME/.config/opencode/tools/read.ts exists. `+ - `Reply with exactly "READ_EXISTS" if it does, or "READ_MISSING" if it does not. Nothing else.`, - defaultTimeout, - ) - - require.NotNil(t, resp.Completion) - content := strings.ToLower(contentString(t, resp)) - t.Logf("Vision check: %s", content) - assert.Contains(t, content, "read_exists", - "with vision connector, read.ts SHOULD be copied") -} - // --------------------------------------------------------------------------- // Helpers // ---------------------------------------------------------------------------