Files
multica/server/pkg/agent/cursor_invocation_windows.go
Jan De Dobbeleer 1e1a4f7845 fix(daemon): fix Copilot CLI invocation on Windows and strip shell quotes from custom args (MUL-2876)
Bug 1: detect copilot.cmd/.bat on Windows and invoke the sibling .ps1 directly via powershell -File, bypassing cmd.exe %* re-tokenisation that mangled the multi-line -p prompt. Shared rewriteCmdToPS1() now serves cursor, pi, and copilot.

Bug 2: filterCustomArgs (shared by all agent backends) strips one outer layer of shell quotes via unshellQuoteArg() before processing, so shell-style custom args like --deny-tool='write' no longer reach the CLI with literal quotes.
2026-06-01 23:28:51 +08:00

86 lines
2.8 KiB
Go

//go:build windows
package agent
import (
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
)
// powerShellLookup resolves the PowerShell host to use. It is overridable in
// tests; production callers should leave it at its default.
var powerShellLookup = defaultPowerShellLookup
// rewriteCmdToPS1 handles the .cmd → PowerShell rewrite for npm-installed
// agent CLIs on Windows. Each CLI ships a <name>.cmd launcher whose body is
// effectively
//
// powershell -NoProfile -ExecutionPolicy Bypass -File <name>.ps1 %*
//
// Going through cmd.exe causes %* re-expansion to re-tokenise the raw
// command-line string, which mangles multi-line arguments (most critically
// the -p prompt). We instead invoke PowerShell with -File <name>.ps1
// directly so Go passes each argv element as a discrete token.
//
// toolName is used both for the log message and to derive the canonical ps1
// filename (toolName + ".ps1" in the same directory as lookedUp). Using the
// canonical name rather than the launcher basename ensures we find the right
// script even when the user has a custom .bat wrapper with a different name.
func rewriteCmdToPS1(toolName, lookedUp string, args []string, logger *slog.Logger) (string, []string, bool) {
ext := strings.ToLower(filepath.Ext(lookedUp))
if ext != ".cmd" && ext != ".bat" {
return "", nil, false
}
ps1 := filepath.Join(filepath.Dir(lookedUp), toolName+".ps1")
if st, err := os.Stat(ps1); err != nil || st.IsDir() {
return "", nil, false
}
psExe, ok := powerShellLookup()
if !ok {
return "", nil, false
}
full := make([]string, 0, 5+len(args))
full = append(full, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", ps1)
full = append(full, args...)
if logger != nil {
logger.Info(toolName+": routing through powershell -File to preserve argv tokens",
"powershell", psExe,
"ps1", ps1,
"original", lookedUp,
)
}
return psExe, full, true
}
// platformCursorInvocation rewrites cursor-agent.cmd → PowerShell -File
// cursor-agent.ps1 on Windows to avoid cmd.exe %* re-tokenisation.
func platformCursorInvocation(lookedUp string, args []string, logger *slog.Logger) (string, []string, bool) {
return rewriteCmdToPS1("cursor-agent", lookedUp, args, logger)
}
// defaultPowerShellLookup prefers PowerShell on PATH (PowerShell 7's pwsh.exe
// or any user-overridden powershell.exe) and falls back to the system path
// shipped with Windows.
func defaultPowerShellLookup() (string, bool) {
for _, name := range []string{"pwsh.exe", "powershell.exe"} {
if p, err := exec.LookPath(name); err == nil {
return p, true
}
}
root := os.Getenv("SystemRoot")
if root == "" {
root = `C:\Windows`
}
candidate := filepath.Join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
if st, err := os.Stat(candidate); err == nil && !st.IsDir() {
return candidate, true
}
return "", false
}