feat(opencode): enhance platform-specific command handling and environment variable syntax

- Introduced `shellQuoteForPlatform` to build shell-safe command strings tailored for Windows and POSIX environments.
- Updated `buildSandboxEnvPrompt` to display the correct environment variable syntax based on the operating system.
- Refactored the command copying logic in the runner to use platform-aware commands for copying custom OpenCode tools.
- Removed outdated test scenarios related to the vision connector, streamlining the test suite.
This commit is contained in:
Max 2026-04-25 11:09:49 +08:00
parent 4b97a890fd
commit 04c3114344
6 changed files with 348 additions and 70 deletions

View file

@ -66,7 +66,7 @@ func (r *Runner) buildCommand(req *types.StreamRequest, p platform, attachmentPa
stdinMsg := buildStdinMessage(req.Messages, attachmentPaths) stdinMsg := buildStdinMessage(req.Messages, attachmentPaths)
script := shellQuote("opencode", args...) script := shellQuoteForPlatform(p, "opencode", args...)
return command{ return command{
shell: p.ShellCmd(script), shell: p.ShellCmd(script),
@ -255,12 +255,18 @@ func buildSandboxEnvPrompt(p platform, workDir string) string {
shell = "bash" shell = "bash"
} }
envVarSyntax := "$VAR_NAME"
if osName == "windows" {
envVarSyntax = "$env:VAR_NAME"
}
return fmt.Sprintf(`## Sandbox Environment return fmt.Sprintf(`## Sandbox Environment
- **Operating System**: %[2]s - **Operating System**: %[2]s
- **Shell**: %[3]s - **Shell**: %[3]s
- **Working Directory**: %[1]s - **Working Directory**: %[1]s
- **File Access**: You have full read/write access to %[1]s - **File Access**: You have full read/write access to %[1]s
- **Environment variable syntax**: `+"`%[4]s`"+`
## User Attachments ## User Attachments
@ -268,7 +274,7 @@ User-uploaded files are placed in %[1]s/.attachments/{chatID}/
Each chat session has its own subdirectory. Each chat session has its own subdirectory.
When the user attaches files, their paths are listed at the top of the message. 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. **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 { func getProviderPrefix(conn connector.Connector) string {
@ -302,7 +308,17 @@ func getRoleConnectors(req *types.StreamRequest) map[string]*types.RoleConnector
return req.Config.Runner.Connectors 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 { func shellQuote(program string, args ...string) string {
parts := make([]string, 0, 1+len(args)) parts := make([]string, 0, 1+len(args))
parts = append(parts, program) parts = append(parts, program)
@ -316,6 +332,21 @@ func shellQuote(program string, args ...string) string {
return strings.Join(parts, " ") 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 // connectorModelID returns the "provider/model" string matching the
// provider ID used in opencode.json (see buildProviderConfig). // provider ID used in opencode.json (see buildProviderConfig).
func connectorModelID(c connector.Connector) string { func connectorModelID(c connector.Connector) string {

View file

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

View file

@ -51,6 +51,10 @@ func resolvePlatform(computer infra.Computer) platform {
workDir := computer.GetWorkDir() workDir := computer.GetWorkDir()
shell := sys.Shell shell := sys.Shell
if osName == "windows" {
return newWindowsPlatform(workDir, shell)
}
base := posixBase{os: osName, workDir: workDir, shell: shell} base := posixBase{os: osName, workDir: workDir, shell: shell}
if base.shell == "" { if base.shell == "" {
base.shell = "bash" base.shell = "bash"

View file

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

View file

@ -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. // built-in read to route image files through the vision API.
if req.Config != nil && req.Config.Runner.Connectors != nil { if req.Config != nil && req.Config.Runner.Connectors != nil {
if vc, ok := req.Config.Runner.Connectors["vision"]; ok && vc != nil && vc.Connector != "" { if vc, ok := req.Config.Runner.Connectors["vision"]; ok && vc != nil && vc.Connector != "" {
p := resolvePlatform(req.Computer)
steps = append(steps, types.PrepareStep{ steps = append(steps, types.PrepareStep{
Action: "exec", 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, Once: true,
IgnoreError: true, IgnoreError: true,
}) })
@ -231,3 +232,15 @@ func (r *Runner) Cleanup(ctx context.Context, computer infra.Computer) error {
return nil 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`
}

View file

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