Files
multica/server/pkg/agent/cursor_invocation_windows.go
carmake 805071b5b1 fix(agent/cursor): route Windows launcher through PowerShell -File to preserve multi-line prompts (#1709)
On Windows the official cursor-agent installer ships cursor-agent.cmd whose
body is `powershell ... -File cursor-agent.ps1 %*`. CreateProcess for a .cmd
file goes through cmd.exe, and `%*` in a batch file is expanded by
re-tokenising the original command line, which mangles arguments containing
newlines or other whitespace - most notably a long, multi-line `-p <prompt>`.
The agent then only sees a truncated prompt and fails with "Workspace Trust
Required" or exits 1 immediately.

When LookPath resolves cursor-agent to a .cmd/.bat launcher and a sibling
cursor-agent.ps1 exists, invoke PowerShell directly with `-File <ps1>` so
Go's os/exec passes each argv as a discrete token. This is exactly what the
.cmd does internally; we just skip the cmd.exe re-tokenisation step.
PowerShell host resolution prefers pwsh.exe (PS 7) on PATH, then
powershell.exe on PATH, and finally falls back to
%SystemRoot%\System32\WindowsPowerShell\v1.0.

Platform-specific code is split via build tags
(cursor_invocation_windows.go / cursor_invocation_other.go) so non-Windows
builds carry no Windows-only dependencies. The lookup is exposed as a
package variable to make the Windows path fully unit-testable without
spawning real PowerShell. Five unit tests cover: passthrough on non-launcher
targets, successful rewrite with a multi-line prompt, .exe direct launch
(skip), missing .ps1 (skip), and missing PowerShell host (skip).

The change leaves macOS / Linux behaviour entirely untouched and stays on
the official cursor-agent launch chain - no node.exe direct invocation, no
prompt mutation, no extra flags.

Closes #1297

Made-with: Cursor
2026-04-29 14:00:15 +08:00

81 lines
2.3 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
// platformCursorInvocation rewrites the cursor-agent invocation on Windows
// when the resolved executable is the official cursor-agent.cmd launcher
// (or a .bat alias) that delegates to cursor-agent.ps1.
//
// We replace
//
// cursor-agent.cmd <args...>
//
// with
//
// powershell.exe -NoProfile -ExecutionPolicy Bypass -File cursor-agent.ps1 <args...>
//
// which is exactly what the .cmd does internally, but lets Go pass each arg
// as a discrete token instead of routing through cmd.exe's %* re-expansion
// (which mangles multi-line / whitespace-heavy prompts such as a long -p).
func platformCursorInvocation(lookedUp string, args []string, logger *slog.Logger) (string, []string, bool) {
ext := strings.ToLower(filepath.Ext(lookedUp))
if ext != ".cmd" && ext != ".bat" {
return "", nil, false
}
dir := filepath.Dir(lookedUp)
ps1 := filepath.Join(dir, "cursor-agent.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("cursor-agent: routing through powershell -File to preserve argv tokens",
"powershell", psExe,
"ps1", ps1,
"original", lookedUp,
)
}
return psExe, full, true
}
// 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
}