Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions internal/process/process.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,11 @@ func ParseSignal(sig string) (Signal, error) {

// Configuration for a Process
type Config struct {
PTY bool
Path string
Args []string
PTY bool
Path string
Args []string
// WindowsCmdLine bypasses os/exec argument quoting when non-empty.
WindowsCmdLine string
Env []string
Stdin io.Reader
Stdout io.Writer
Expand Down
1 change: 1 addition & 0 deletions internal/process/signal_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func (p *Process) setupProcessGroup() {
}
p.command.SysProcAttr = &windows.SysProcAttr{
CreationFlags: windows.CREATE_UNICODE_ENVIRONMENT | windows.CREATE_NEW_PROCESS_GROUP,
CmdLine: p.conf.WindowsCmdLine,
}
jobHandle, err := newJobObject()
if err != nil {
Expand Down
4 changes: 4 additions & 0 deletions internal/shell/lookpath.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,7 @@ func LookPath(file, path, fileExtensions string) (string, error) {
}
return "", &exec.Error{Name: file, Err: exec.ErrNotFound}
}

func systemCommandProcessor() (string, error) {
return "cmd.exe", nil
}
11 changes: 11 additions & 0 deletions internal/shell/lookpath_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@
package shell

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"

"golang.org/x/sys/windows"
)

func chkStat(file string) error {
Expand Down Expand Up @@ -89,3 +92,11 @@ func LookPath(file, path, fileExtensions string) (string, error) {
}
return "", &exec.Error{file, exec.ErrNotFound}
}

func systemCommandProcessor() (string, error) {
systemDirectory, err := windows.GetSystemDirectory()
if err != nil {
return "", fmt.Errorf("finding Windows system directory: %w", err)
}
return filepath.Join(systemDirectory, "cmd.exe"), nil
}
33 changes: 27 additions & 6 deletions internal/shell/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,9 +267,10 @@ retryLoop:

// Command represents a command that can be run in a shell.
type Command struct {
shell *Shell
command string
args []string
shell *Shell
command string
args []string
windowsCmdLine string
}

// Command returns a command that can be run in the shell.
Expand All @@ -295,6 +296,9 @@ func (s *Shell) Script(path, commandOverride string) (Command, error) {
isSh := filepath.Ext(path) == "" || filepath.Ext(path) == ".sh"
isWindows := runtime.GOOS == "windows"
isPwsh := filepath.Ext(path) == ".ps1"
ext := strings.ToLower(filepath.Ext(path))
isBatch := isWindows && (ext == ".bat" || ext == ".cmd")
var windowsCmdLine string

if commandOverride != "" {
// first element is the command, all others are args to which we append path
Expand All @@ -311,6 +315,21 @@ func (s *Shell) Script(path, commandOverride string) (Command, error) {
}

switch {
case isBatch:
// Batch files are interpreted by cmd.exe, not executed directly by
// CreateProcess. Use a raw command line because cmd.exe's quoting rules
// differ from the CommandLineToArgvW rules used by os/exec.
if strings.Contains(path, "%") {
return Command{}, fmt.Errorf("cannot run Windows batch script %q: path contains '%%', which cmd.exe expands", path)
}
cmdPath, err := systemCommandProcessor()
if err != nil {
return Command{}, fmt.Errorf("finding command processor for Windows batch script: %w", err)
}
command = cmdPath
args = []string{"/D", "/V:OFF", "/S", "/C", path}
windowsCmdLine = `"` + command + `" /D /V:OFF /S /C ""` + path + `""`

case isWindows && isSh:
if s.debug {
s.Commentf("Attempting to run %s with Bash for Windows", path)
Expand Down Expand Up @@ -386,9 +405,10 @@ func (s *Shell) Script(path, commandOverride string) (Command, error) {
}

return Command{
shell: s,
command: command,
args: args,
shell: s,
command: command,
args: args,
windowsCmdLine: windowsCmdLine,
}, nil
}

Expand Down Expand Up @@ -420,6 +440,7 @@ func (c Command) Run(ctx context.Context, opts ...RunCommandOpt) error {
c.shell.Errorf("Error building command: %v", err)
return err
}
cmdCfg.WindowsCmdLine = c.windowsCmdLine

// Merge in any extra env vars.
if cfg.extraEnv != nil {
Expand Down
70 changes: 70 additions & 0 deletions internal/shell/shell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,76 @@ func TestRun(t *testing.T) {
}
}

func TestRunWindowsBatchScriptWithPipedOutput(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("batch scripts require Windows")
}
t.Parallel()

for _, ext := range []string{".bat", ".CMD"} {
t.Run(ext, func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "hooks with spaces & parentheses (test) ! caret ^")
if err := os.Mkdir(dir, 0o755); err != nil {
t.Fatalf("os.Mkdir(%q) = %v", dir, err)
}
path := filepath.Join(dir, "agent-startup"+ext)
if err := os.WriteFile(path, []byte("@echo off\r\necho stdout=%BATCH_TEST_VALUE%\r\n>&2 echo stderr-line\r\n"), 0o755); err != nil {
t.Fatalf("os.WriteFile(%q) = %v", path, err)
}

out := new(bytes.Buffer)
sh := newShellForTest(t, shell.WithStdout(out), shell.WithPTY(false))
sh.Env.Set("BATCH_TEST_VALUE", "expanded")
script, err := sh.Script(path, "")
if err != nil {
t.Fatalf("sh.Script(%q, %q) = %v", path, "", err)
}
if err := script.Run(t.Context(), shell.ShowPrompt(false)); err != nil {
t.Fatalf("script.Run() = %v", err)
}

if diff := cmp.Diff(out.String(), "stdout=expanded\r\nstderr-line\r\n"); diff != "" {
t.Errorf("batch output diff (-got +want):\n%s", diff)
}
})
}
}

func TestRunWindowsBatchScriptPreservesExitCode(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("batch scripts require Windows")
}
t.Parallel()

path := filepath.Join(t.TempDir(), "nonzero.cmd")
if err := os.WriteFile(path, []byte("@exit /b 23\r\n"), 0o755); err != nil {
t.Fatalf("os.WriteFile(%q) = %v", path, err)
}

sh := newShellForTest(t, shell.WithPTY(false))
script, err := sh.Script(path, "")
if err != nil {
t.Fatalf("sh.Script(%q, %q) = %v", path, "", err)
}
if err := script.Run(t.Context(), shell.ShowPrompt(false)); shell.ExitCode(err) != 23 {
t.Fatalf("shell.ExitCode(script.Run()) = %d, want 23 (error: %v)", shell.ExitCode(err), err)
}
}

func TestWindowsBatchScriptRejectsPercentInPath(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("batch scripts require Windows")
}
t.Parallel()

sh := newShellForTest(t)
path := filepath.Join(t.TempDir(), "%TEMP%", "agent-startup.bat")
_, err := sh.Script(path, "")
if err == nil || !strings.Contains(err.Error(), "path contains '%'") {
t.Fatalf("sh.Script(%q, %q) error = %v, want percent-path error", path, "", err)
}
}

func TestRunWithStdin(t *testing.T) {
t.Parallel()

Expand Down