Merge branch 'main' into fix/windows-resize-and-esc
7 files · +111 -3
M packages/agent/systemprompt.go +25 -0
| @@ -2,6 +2,7 @@ package agent | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "runtime" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "time" | |
| 7 | 8 | ) | |
| @@ -26,6 +27,8 @@ type SystemPromptOpts struct { | |||
| 26 | 27 | Append []string // extra text appended at the end | |
| 27 | 28 | Now time.Time | |
| 28 | 29 | ZotDocsDir string | |
| 30 | + | GOOS string // defaults to runtime.GOOS; overridable for tests | |
| 31 | + | GOARCH string // defaults to runtime.GOARCH | |
| 29 | 32 | } | |
| 30 | 33 | ||
| 31 | 34 | // BuildSystemPrompt constructs the system prompt. | |
| @@ -79,9 +82,31 @@ func BuildSystemPrompt(o SystemPromptOpts) string { | |||
| 79 | 82 | } | |
| 80 | 83 | ||
| 81 | 84 | fmt.Fprintf(&sb, "\n\nCurrent date: %s\nCurrent working directory: %s\n", date, cwd) | |
| 85 | + | sb.WriteString(platformLine(o.GOOS, o.GOARCH)) | |
| 82 | 86 | return sb.String() | |
| 83 | 87 | } | |
| 84 | 88 | ||
| 89 | + | // platformLine tells the model which OS and shell dialect its commands | |
| 90 | + | // run under. Without it models default to POSIX syntax, and on Windows | |
| 91 | + | // every failed grep/ls/&&-chain round-trip wastes a tool call and its | |
| 92 | + | // tokens. Kept to one or two lines because it sits in the cached prefix. | |
| 93 | + | func platformLine(goos, goarch string) string { | |
| 94 | + | if goos == "" { | |
| 95 | + | goos = runtime.GOOS | |
| 96 | + | } | |
| 97 | + | if goarch == "" { | |
| 98 | + | goarch = runtime.GOARCH | |
| 99 | + | } | |
| 100 | + | line := fmt.Sprintf("Platform: %s/%s\n", goos, goarch) | |
| 101 | + | if goos == "windows" { | |
| 102 | + | line += windowsShellHint | |
| 103 | + | } | |
| 104 | + | return line | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | const windowsShellHint = `The bash tool runs cmd.exe /C, not a POSIX shell: use cmd syntax (dir, type, findstr, where, set, %VAR%, backslash paths); grep, ls, cat, sed, head, $VAR, and single quotes do not work. Keep commands simple: cmd's quoting breaks on nested double quotes, so prefer the read and glob tools for searching, git grep for code, and powershell -NoProfile -File <script.ps1> for anything complex. | |
| 108 | + | ` | |
| 109 | + | ||
| 85 | 110 | const defaultIdentity = `You are an expert coding assistant operating inside zot, a coding agent harness. The name "zot" stands for "zero-overhead-tool"; if the user asks what zot means, answer exactly that. | |
| 86 | 111 | ||
| 87 | 112 | Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did. | |
| @@ -2,6 +2,7 @@ package agent | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | + | "runtime" | |||
| 5 | "strings" | 6 | "strings" | ||
| 6 | "time" | 7 | "time" | ||
| 7 | ) | 8 | ) | ||
| @@ -26,6 +27,8 @@ type SystemPromptOpts struct { | |||||
| 26 | Append []string // extra text appended at the end | 27 | Append []string // extra text appended at the end | ||
| 27 | Now time.Time | 28 | Now time.Time | ||
| 28 | ZotDocsDir string | 29 | ZotDocsDir string | ||
| 30 | + | GOOS string // defaults to runtime.GOOS; overridable for tests | |||
| 31 | + | GOARCH string // defaults to runtime.GOARCH | |||
| 29 | } | 32 | } | ||
| 30 | 33 | ||||
| 31 | // BuildSystemPrompt constructs the system prompt. | 34 | // BuildSystemPrompt constructs the system prompt. | ||
| @@ -79,9 +82,31 @@ func BuildSystemPrompt(o SystemPromptOpts) string { | |||||
| 79 | } | 82 | } | ||
| 80 | 83 | ||||
| 81 | fmt.Fprintf(&sb, "\n\nCurrent date: %s\nCurrent working directory: %s\n", date, cwd) | 84 | fmt.Fprintf(&sb, "\n\nCurrent date: %s\nCurrent working directory: %s\n", date, cwd) | ||
| 85 | + | sb.WriteString(platformLine(o.GOOS, o.GOARCH)) | |||
| 82 | return sb.String() | 86 | return sb.String() | ||
| 83 | } | 87 | } | ||
| 84 | 88 | ||||
| 89 | + | // platformLine tells the model which OS and shell dialect its commands | |||
| 90 | + | // run under. Without it models default to POSIX syntax, and on Windows | |||
| 91 | + | // every failed grep/ls/&&-chain round-trip wastes a tool call and its | |||
| 92 | + | // tokens. Kept to one or two lines because it sits in the cached prefix. | |||
| 93 | + | func platformLine(goos, goarch string) string { | |||
| 94 | + | if goos == "" { | |||
| 95 | + | goos = runtime.GOOS | |||
| 96 | + | } | |||
| 97 | + | if goarch == "" { | |||
| 98 | + | goarch = runtime.GOARCH | |||
| 99 | + | } | |||
| 100 | + | line := fmt.Sprintf("Platform: %s/%s\n", goos, goarch) | |||
| 101 | + | if goos == "windows" { | |||
| 102 | + | line += windowsShellHint | |||
| 103 | + | } | |||
| 104 | + | return line | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | const windowsShellHint = `The bash tool runs cmd.exe /C, not a POSIX shell: use cmd syntax (dir, type, findstr, where, set, %VAR%, backslash paths); grep, ls, cat, sed, head, $VAR, and single quotes do not work. Keep commands simple: cmd's quoting breaks on nested double quotes, so prefer the read and glob tools for searching, git grep for code, and powershell -NoProfile -File <script.ps1> for anything complex. | |||
| 108 | + | ` | |||
| 109 | + | ||||
| 85 | const defaultIdentity = `You are an expert coding assistant operating inside zot, a coding agent harness. The name "zot" stands for "zero-overhead-tool"; if the user asks what zot means, answer exactly that. | 110 | const defaultIdentity = `You are an expert coding assistant operating inside zot, a coding agent harness. The name "zot" stands for "zero-overhead-tool"; if the user asks what zot means, answer exactly that. | ||
| 86 | 111 | ||||
| 87 | Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did. | 112 | Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did. | ||
M packages/agent/systemprompt_test.go +20 -0
| @@ -30,6 +30,26 @@ func TestBuildSystemPromptCustomOmitsBuiltInDocs(t *testing.T) { | |||
| 30 | 30 | } | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | + | func TestBuildSystemPromptPlatform(t *testing.T) { | |
| 34 | + | for _, tc := range []struct { | |
| 35 | + | goos string | |
| 36 | + | wantHint bool | |
| 37 | + | wantLabel string | |
| 38 | + | }{ | |
| 39 | + | {"windows", true, "Platform: windows/amd64"}, | |
| 40 | + | {"linux", false, "Platform: linux/amd64"}, | |
| 41 | + | {"darwin", false, "Platform: darwin/amd64"}, | |
| 42 | + | } { | |
| 43 | + | got := BuildSystemPrompt(SystemPromptOpts{CWD: "/w", GOOS: tc.goos, GOARCH: "amd64"}) | |
| 44 | + | if !strings.Contains(got, tc.wantLabel) { | |
| 45 | + | t.Errorf("%s: missing %q:\n%s", tc.goos, tc.wantLabel, got) | |
| 46 | + | } | |
| 47 | + | if strings.Contains(got, "cmd.exe /C") != tc.wantHint { | |
| 48 | + | t.Errorf("%s: windows shell hint present=%v, want %v", tc.goos, !tc.wantHint, tc.wantHint) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 33 | 53 | func TestBuildSystemPromptDefaultIncludesBuiltInDocs(t *testing.T) { | |
| 34 | 54 | got := BuildSystemPrompt(SystemPromptOpts{ | |
| 35 | 55 | CWD: "/workspace", | |
| @@ -30,6 +30,26 @@ func TestBuildSystemPromptCustomOmitsBuiltInDocs(t *testing.T) { | |||||
| 30 | } | 30 | } | ||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| 33 | + | func TestBuildSystemPromptPlatform(t *testing.T) { | |||
| 34 | + | for _, tc := range []struct { | |||
| 35 | + | goos string | |||
| 36 | + | wantHint bool | |||
| 37 | + | wantLabel string | |||
| 38 | + | }{ | |||
| 39 | + | {"windows", true, "Platform: windows/amd64"}, | |||
| 40 | + | {"linux", false, "Platform: linux/amd64"}, | |||
| 41 | + | {"darwin", false, "Platform: darwin/amd64"}, | |||
| 42 | + | } { | |||
| 43 | + | got := BuildSystemPrompt(SystemPromptOpts{CWD: "/w", GOOS: tc.goos, GOARCH: "amd64"}) | |||
| 44 | + | if !strings.Contains(got, tc.wantLabel) { | |||
| 45 | + | t.Errorf("%s: missing %q:\n%s", tc.goos, tc.wantLabel, got) | |||
| 46 | + | } | |||
| 47 | + | if strings.Contains(got, "cmd.exe /C") != tc.wantHint { | |||
| 48 | + | t.Errorf("%s: windows shell hint present=%v, want %v", tc.goos, !tc.wantHint, tc.wantHint) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 33 | func TestBuildSystemPromptDefaultIncludesBuiltInDocs(t *testing.T) { | 53 | func TestBuildSystemPromptDefaultIncludesBuiltInDocs(t *testing.T) { | ||
| 34 | got := BuildSystemPrompt(SystemPromptOpts{ | 54 | got := BuildSystemPrompt(SystemPromptOpts{ | ||
| 35 | CWD: "/workspace", | 55 | CWD: "/workspace", | ||
M packages/agent/tools/bash.go +4 -2
| @@ -286,7 +286,7 @@ func isExecutableFile(path string) bool { | |||
| 286 | 286 | ||
| 287 | 287 | func shellDescription(shell shellCommand) string { | |
| 288 | 288 | if shell.flag == "/C" { | |
| 289 | - | return "Run a Windows Command Prompt command via cmd /C. stdout+stderr merged." | |
| 289 | + | return "Run a Windows Command Prompt command via cmd /C (not bash: no grep/ls/cat/sed, no $VAR or single quotes; use findstr/dir/type/where and %VAR%). stdout+stderr merged." | |
| 290 | 290 | } | |
| 291 | 291 | if shell.isBash { | |
| 292 | 292 | return fmt.Sprintf("Run a Bash command via %s -c. stdout+stderr merged.", shell.path) | |
| @@ -296,5 +296,7 @@ func shellDescription(shell shellCommand) string { | |||
| 296 | 296 | ||
| 297 | 297 | func newShellCmd(ctx context.Context, command string) *exec.Cmd { | |
| 298 | 298 | shell := currentShell() | |
| 299 | - | return exec.CommandContext(ctx, shell.path, shell.flag, command) | |
| 299 | + | cmd := exec.CommandContext(ctx, shell.path, shell.flag, command) | |
| 300 | + | applyRawCmdLine(cmd, shell, command) | |
| 301 | + | return cmd | |
| 300 | 302 | } | |
| @@ -286,7 +286,7 @@ func isExecutableFile(path string) bool { | |||||
| 286 | 286 | ||||
| 287 | func shellDescription(shell shellCommand) string { | 287 | func shellDescription(shell shellCommand) string { | ||
| 288 | if shell.flag == "/C" { | 288 | if shell.flag == "/C" { | ||
| 289 | - | return "Run a Windows Command Prompt command via cmd /C. stdout+stderr merged." | 289 | + | return "Run a Windows Command Prompt command via cmd /C (not bash: no grep/ls/cat/sed, no $VAR or single quotes; use findstr/dir/type/where and %VAR%). stdout+stderr merged." |
| 290 | } | 290 | } | ||
| 291 | if shell.isBash { | 291 | if shell.isBash { | ||
| 292 | return fmt.Sprintf("Run a Bash command via %s -c. stdout+stderr merged.", shell.path) | 292 | return fmt.Sprintf("Run a Bash command via %s -c. stdout+stderr merged.", shell.path) | ||
| @@ -296,5 +296,7 @@ func shellDescription(shell shellCommand) string { | |||||
| 296 | 296 | ||||
| 297 | func newShellCmd(ctx context.Context, command string) *exec.Cmd { | 297 | func newShellCmd(ctx context.Context, command string) *exec.Cmd { | ||
| 298 | shell := currentShell() | 298 | shell := currentShell() | ||
| 299 | - | return exec.CommandContext(ctx, shell.path, shell.flag, command) | 299 | + | cmd := exec.CommandContext(ctx, shell.path, shell.flag, command) |
| 300 | + | applyRawCmdLine(cmd, shell, command) | |||
| 301 | + | return cmd | |||
| 300 | } | 302 | } | ||
M packages/agent/tools/bash_unix.go +3 -0
| @@ -8,6 +8,9 @@ import ( | |||
| 8 | 8 | "time" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | + | // applyRawCmdLine is only needed for cmd.exe quoting on Windows. | |
| 12 | + | func applyRawCmdLine(*exec.Cmd, shellCommand, string) {} | |
| 13 | + | ||
| 11 | 14 | // setProcessGroup puts the command in its own process group so | |
| 12 | 15 | // killProcessGroup can target the entire tree including background | |
| 13 | 16 | // children spawned with &. | |
| @@ -8,6 +8,9 @@ import ( | |||||
| 8 | "time" | 8 | "time" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | + | // applyRawCmdLine is only needed for cmd.exe quoting on Windows. | |||
| 12 | + | func applyRawCmdLine(*exec.Cmd, shellCommand, string) {} | |||
| 13 | + | ||||
| 11 | // setProcessGroup puts the command in its own process group so | 14 | // setProcessGroup puts the command in its own process group so | ||
| 12 | // killProcessGroup can target the entire tree including background | 15 | // killProcessGroup can target the entire tree including background | ||
| 13 | // children spawned with &. | 16 | // children spawned with &. | ||
M packages/agent/tools/bash_windows.go +17 -0
| @@ -4,9 +4,26 @@ package tools | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "os/exec" | |
| 7 | + | "syscall" | |
| 7 | 8 | "time" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 11 | + | // applyRawCmdLine hands the command to cmd.exe verbatim. Go's default | |
| 12 | + | // argument quoting escapes embedded double quotes as \", which cmd.exe | |
| 13 | + | // does not understand, so commands such as dir "C:\a b" or | |
| 14 | + | // findstr /c:"x y" reached cmd with literal backslashes and failed. | |
| 15 | + | // /S makes cmd strip exactly the outer quote pair and run the rest | |
| 16 | + | // unchanged. | |
| 17 | + | func applyRawCmdLine(cmd *exec.Cmd, shell shellCommand, command string) { | |
| 18 | + | if shell.flag != "/C" { | |
| 19 | + | return | |
| 20 | + | } | |
| 21 | + | if cmd.SysProcAttr == nil { | |
| 22 | + | cmd.SysProcAttr = &syscall.SysProcAttr{} | |
| 23 | + | } | |
| 24 | + | cmd.SysProcAttr.CmdLine = `cmd /S /C "` + command + `"` | |
| 25 | + | } | |
| 26 | + | ||
| 10 | 27 | func setProcessGroup(_ *exec.Cmd) {} | |
| 11 | 28 | ||
| 12 | 29 | func killProcessGroup(cmd *exec.Cmd) { | |
| @@ -4,9 +4,26 @@ package tools | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "os/exec" | 6 | "os/exec" | ||
| 7 | + | "syscall" | |||
| 7 | "time" | 8 | "time" | ||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| 11 | + | // applyRawCmdLine hands the command to cmd.exe verbatim. Go's default | |||
| 12 | + | // argument quoting escapes embedded double quotes as \", which cmd.exe | |||
| 13 | + | // does not understand, so commands such as dir "C:\a b" or | |||
| 14 | + | // findstr /c:"x y" reached cmd with literal backslashes and failed. | |||
| 15 | + | // /S makes cmd strip exactly the outer quote pair and run the rest | |||
| 16 | + | // unchanged. | |||
| 17 | + | func applyRawCmdLine(cmd *exec.Cmd, shell shellCommand, command string) { | |||
| 18 | + | if shell.flag != "/C" { | |||
| 19 | + | return | |||
| 20 | + | } | |||
| 21 | + | if cmd.SysProcAttr == nil { | |||
| 22 | + | cmd.SysProcAttr = &syscall.SysProcAttr{} | |||
| 23 | + | } | |||
| 24 | + | cmd.SysProcAttr.CmdLine = `cmd /S /C "` + command + `"` | |||
| 25 | + | } | |||
| 26 | + | ||||
| 10 | func setProcessGroup(_ *exec.Cmd) {} | 27 | func setProcessGroup(_ *exec.Cmd) {} | ||
| 11 | 28 | ||||
| 12 | func killProcessGroup(cmd *exec.Cmd) { | 29 | func killProcessGroup(cmd *exec.Cmd) { | ||
A packages/agent/tools/bash_windows_test.go +41 -0
| @@ -0,0 +1,41 @@ | |||
| 1 | + | //go:build windows | |
| 2 | + | ||
| 3 | + | package tools | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | ||
| 12 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Embedded double quotes must reach cmd.exe unchanged. Go's default | |
| 16 | + | // argument escaping turned them into \" which cmd does not understand. | |
| 17 | + | func TestBashWindowsQuotedArguments(t *testing.T) { | |
| 18 | + | dir := t.TempDir() | |
| 19 | + | sub := filepath.Join(dir, "a b") | |
| 20 | + | if err := os.MkdirAll(sub, 0o755); err != nil { | |
| 21 | + | t.Fatal(err) | |
| 22 | + | } | |
| 23 | + | if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte("hello world\n"), 0o644); err != nil { | |
| 24 | + | t.Fatal(err) | |
| 25 | + | } | |
| 26 | + | tool := &BashTool{CWD: dir} | |
| 27 | + | for _, tc := range []struct{ command, want string }{ | |
| 28 | + | {`dir /s /b "` + dir + `\SKILL.md"`, "SKILL.md"}, | |
| 29 | + | {`findstr /c:"hello world" "a b\SKILL.md"`, "hello world"}, | |
| 30 | + | {`echo "x y" & echo done`, "done"}, | |
| 31 | + | } { | |
| 32 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": tc.command}), nil) | |
| 33 | + | if err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | got := res.Content[0].(provider.TextBlock).Text | |
| 37 | + | if res.IsError || !strings.Contains(got, tc.want) { | |
| 38 | + | t.Errorf("%s: got\n%s", tc.command, got) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | } | |
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | //go:build windows | |||
| 2 | + | ||||
| 3 | + | package tools | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "strings" | |||
| 10 | + | "testing" | |||
| 11 | + | ||||
| 12 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // Embedded double quotes must reach cmd.exe unchanged. Go's default | |||
| 16 | + | // argument escaping turned them into \" which cmd does not understand. | |||
| 17 | + | func TestBashWindowsQuotedArguments(t *testing.T) { | |||
| 18 | + | dir := t.TempDir() | |||
| 19 | + | sub := filepath.Join(dir, "a b") | |||
| 20 | + | if err := os.MkdirAll(sub, 0o755); err != nil { | |||
| 21 | + | t.Fatal(err) | |||
| 22 | + | } | |||
| 23 | + | if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte("hello world\n"), 0o644); err != nil { | |||
| 24 | + | t.Fatal(err) | |||
| 25 | + | } | |||
| 26 | + | tool := &BashTool{CWD: dir} | |||
| 27 | + | for _, tc := range []struct{ command, want string }{ | |||
| 28 | + | {`dir /s /b "` + dir + `\SKILL.md"`, "SKILL.md"}, | |||
| 29 | + | {`findstr /c:"hello world" "a b\SKILL.md"`, "hello world"}, | |||
| 30 | + | {`echo "x y" & echo done`, "done"}, | |||
| 31 | + | } { | |||
| 32 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": tc.command}), nil) | |||
| 33 | + | if err != nil { | |||
| 34 | + | t.Fatal(err) | |||
| 35 | + | } | |||
| 36 | + | got := res.Content[0].(provider.TextBlock).Text | |||
| 37 | + | if res.IsError || !strings.Contains(got, tc.want) { | |||
| 38 | + | t.Errorf("%s: got\n%s", tc.command, got) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | } | |||
M packages/agent/tools/tools_test.go +1 -1
| @@ -325,7 +325,7 @@ func TestShellDescription(t *testing.T) { | |||
| 325 | 325 | { | |
| 326 | 326 | name: "Windows", | |
| 327 | 327 | shell: shellCommand{path: "cmd", flag: "/C"}, | |
| 328 | - | want: "Run a Windows Command Prompt command via cmd /C. stdout+stderr merged.", | |
| 328 | + | want: "Run a Windows Command Prompt command via cmd /C (not bash: no grep/ls/cat/sed, no $VAR or single quotes; use findstr/dir/type/where and %VAR%). stdout+stderr merged.", | |
| 329 | 329 | }, | |
| 330 | 330 | } | |
| 331 | 331 | for _, tt := range tests { | |
| @@ -325,7 +325,7 @@ func TestShellDescription(t *testing.T) { | |||||
| 325 | { | 325 | { | ||
| 326 | name: "Windows", | 326 | name: "Windows", | ||
| 327 | shell: shellCommand{path: "cmd", flag: "/C"}, | 327 | shell: shellCommand{path: "cmd", flag: "/C"}, | ||
| 328 | - | want: "Run a Windows Command Prompt command via cmd /C. stdout+stderr merged.", | 328 | + | want: "Run a Windows Command Prompt command via cmd /C (not bash: no grep/ls/cat/sed, no $VAR or single quotes; use findstr/dir/type/where and %VAR%). stdout+stderr merged.", |
| 329 | }, | 329 | }, | ||
| 330 | } | 330 | } | ||
| 331 | for _, tt := range tests { | 331 | for _, tt := range tests { | ||