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

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()
shell := sys.Shell
if osName == "windows" {
return newWindowsPlatform(workDir, shell)
}
base := posixBase{os: osName, workDir: workDir, shell: shell}
if base.shell == "" {
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.
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`
}

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