diff --git a/server/pkg/agent/pi.go b/server/pkg/agent/pi.go index 66fd37110..da8b02d00 100644 --- a/server/pkg/agent/pi.go +++ b/server/pkg/agent/pi.go @@ -174,12 +174,13 @@ func isPiToolNameByte(b byte) bool { } func (b *piBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) { - execPath := b.cfg.ExecutablePath - if execPath == "" { - execPath = "pi" + execName := b.cfg.ExecutablePath + if execName == "" { + execName = "pi" } - if _, err := exec.LookPath(execPath); err != nil { - return nil, fmt.Errorf("pi executable not found at %q: %w", execPath, err) + lookedUp, err := exec.LookPath(execName) + if err != nil { + return nil, fmt.Errorf("pi executable not found at %q: %w", execName, err) } timeout := opts.Timeout @@ -205,10 +206,11 @@ func (b *piBackend) Execute(ctx context.Context, prompt string, opts ExecOptions runCtx, cancel := context.WithTimeout(ctx, timeout) args := buildPiArgs(prompt, sessionPath, opts, b.cfg.Logger) + argv0, cmdArgs := choosePiInvocation(execName, lookedUp, args, b.cfg.Logger) - cmd := exec.CommandContext(runCtx, execPath, args...) + cmd := exec.CommandContext(runCtx, argv0, cmdArgs...) hideAgentWindow(cmd) - b.cfg.Logger.Info("agent command", "exec", execPath, "args", args) + b.cfg.Logger.Info("agent command", "exec", argv0, "args", cmdArgs) cmd.WaitDelay = 10 * time.Second if opts.Cwd != "" { cmd.Dir = opts.Cwd diff --git a/server/pkg/agent/pi_invocation.go b/server/pkg/agent/pi_invocation.go new file mode 100644 index 000000000..c243f4c49 --- /dev/null +++ b/server/pkg/agent/pi_invocation.go @@ -0,0 +1,32 @@ +package agent + +import "log/slog" + +// choosePiInvocation selects the actual program (argv[0]) and the full +// argv to spawn a Pi run. +// +// Background: +// - On macOS/Linux, the npm-installed `pi` binstub is a shebang script +// that execs node directly with the JS entrypoint, so argv passes +// through unchanged. +// - On Windows, the npm installer ships `pi.cmd` whose body is +// "powershell ... -File pi.ps1 %*". CreateProcess for a .cmd file +// goes through cmd.exe, and %* in a .cmd batch file is expanded by +// re-tokenising the original command line, which mangles arguments +// containing newlines or other whitespace — for Pi, that's the +// multi-line positional prompt passed by buildPiArgs. Symptom: the +// Pi session JSONL records only the first line of the prompt +// (#3306). To stay on the official launch path while avoiding that +// re-tokenisation, we resolve pi.ps1 next to the .cmd and invoke +// PowerShell with `-File ` directly, letting Go pass each argv +// as a separate token. +// +// The Windows-specific behaviour is implemented in +// pi_invocation_windows.go; on other platforms we fall through to a +// passthrough. +func choosePiInvocation(execName, lookedUp string, args []string, logger *slog.Logger) (string, []string) { + if argv0, full, ok := platformPiInvocation(lookedUp, args, logger); ok { + return argv0, full + } + return execName, args +} diff --git a/server/pkg/agent/pi_invocation_other.go b/server/pkg/agent/pi_invocation_other.go new file mode 100644 index 000000000..bf635af24 --- /dev/null +++ b/server/pkg/agent/pi_invocation_other.go @@ -0,0 +1,12 @@ +//go:build !windows + +package agent + +import "log/slog" + +// platformPiInvocation is a no-op on non-Windows platforms: Pi's binstub +// invokes node directly via shebang and Go's os/exec can pass argv +// unchanged. +func platformPiInvocation(_ string, _ []string, _ *slog.Logger) (string, []string, bool) { + return "", nil, false +} diff --git a/server/pkg/agent/pi_invocation_test.go b/server/pkg/agent/pi_invocation_test.go new file mode 100644 index 000000000..3e123a0cf --- /dev/null +++ b/server/pkg/agent/pi_invocation_test.go @@ -0,0 +1,36 @@ +package agent + +import ( + "io" + "log/slog" + "path/filepath" + "reflect" + "testing" +) + +// TestChoosePiInvocation_PassthroughForNonLauncher verifies that when the +// resolved executable is not a Windows .cmd/.bat launcher, both argv[0] and +// the argv list are returned unchanged on every platform. This guards +// against accidental rewriting on macOS/Linux and for direct binary +// launches on Windows. +func TestChoosePiInvocation_PassthroughForNonLauncher(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + execName := "pi" + lookedUp := filepath.Join(t.TempDir(), "pi") // no .cmd / .bat + args := []string{ + "-p", + "--mode", "json", + "--session", "/tmp/pi-session.jsonl", + "You are running as a chat assistant for a Multica workspace.\n\nUser message:\n我需要创建一个issue\n", + } + + gotExec, gotArgs := choosePiInvocation(execName, lookedUp, args, logger) + + if gotExec != execName { + t.Errorf("argv0 changed unexpectedly: got %q want %q", gotExec, execName) + } + if !reflect.DeepEqual(gotArgs, args) { + t.Errorf("argv changed unexpectedly:\n got %#v\n want %#v", gotArgs, args) + } +} diff --git a/server/pkg/agent/pi_invocation_windows.go b/server/pkg/agent/pi_invocation_windows.go new file mode 100644 index 000000000..c5eeec363 --- /dev/null +++ b/server/pkg/agent/pi_invocation_windows.go @@ -0,0 +1,60 @@ +//go:build windows + +package agent + +import ( + "log/slog" + "os" + "path/filepath" + "strings" +) + +// platformPiInvocation rewrites the pi invocation on Windows when the +// resolved executable is the npm-installed pi.cmd launcher (or a .bat +// alias) that delegates to pi.ps1. +// +// We replace +// +// pi.cmd +// +// with +// +// powershell.exe -NoProfile -ExecutionPolicy Bypass -File pi.ps1 +// +// which is 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 the multi-line positional prompt the daemon builds in +// buildPiArgs — see #3306). +// +// powerShellLookup is shared with the cursor backend: both npm shims +// have the same launcher shape and need the same PowerShell host on the +// same Windows installation. +func platformPiInvocation(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, "pi.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("pi: routing through powershell -File to preserve argv tokens", + "powershell", psExe, + "ps1", ps1, + "original", lookedUp, + ) + } + return psExe, full, true +} diff --git a/server/pkg/agent/pi_invocation_windows_test.go b/server/pkg/agent/pi_invocation_windows_test.go new file mode 100644 index 000000000..2a6c324a7 --- /dev/null +++ b/server/pkg/agent/pi_invocation_windows_test.go @@ -0,0 +1,118 @@ +//go:build windows + +package agent + +import ( + "io" + "log/slog" + "path/filepath" + "reflect" + "testing" +) + +// TestPlatformPiInvocation_RewritesCmdLauncherToPowerShellFile is the core +// Windows test: when LookPath resolves pi to the npm-installed .cmd +// launcher and a sibling pi.ps1 exists, we should invoke PowerShell with +// -File and forward every original arg unchanged — including the +// multi-line positional prompt that would otherwise be mangled by the +// cmd.exe %* re-expansion inside pi.cmd. This is the regression test for +// #3306: daemon argv carried the full prompt, but Pi's session JSONL only +// recorded the first line. +func TestPlatformPiInvocation_RewritesCmdLauncherToPowerShellFile(t *testing.T) { + dir := t.TempDir() + cmdPath := filepath.Join(dir, "pi.cmd") + ps1Path := filepath.Join(dir, "pi.ps1") + writeFile(t, cmdPath, "@echo off\r\npowershell -NoProfile -ExecutionPolicy Bypass -File \"%~dp0pi.ps1\" %*\r\n") + writeFile(t, ps1Path, "# fake pi.ps1\r\n") + + fakePS := filepath.Join(dir, "powershell.exe") + writeFile(t, fakePS, "") + stubPowerShell(t, fakePS, true) + + multiLinePrompt := "You are running as a chat assistant for a Multica workspace.\n\nUser message:\n我需要创建一个issue\n" + args := []string{ + "-p", + "--mode", "json", + "--session", `C:\Users\X\.multica\pi-sessions\20260528T040000.jsonl`, + multiLinePrompt, + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + gotExec, gotArgs, ok := platformPiInvocation(cmdPath, args, logger) + if !ok { + t.Fatalf("expected platform rewrite to be applied, got ok=false") + } + if gotExec != fakePS { + t.Errorf("argv0: got %q want %q", gotExec, fakePS) + } + + wantArgs := append([]string{ + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", ps1Path, + }, args...) + if !reflect.DeepEqual(gotArgs, wantArgs) { + t.Errorf("argv mismatch:\n got %#v\n want %#v", gotArgs, wantArgs) + } + + // Explicit check: the last argv (the positional prompt) must still + // contain every line of the original multi-line prompt. This is the + // concrete property #3306 violates when cmd.exe re-tokenises %*. + if gotArgs[len(gotArgs)-1] != multiLinePrompt { + t.Errorf("multi-line prompt was mangled:\n got %q\n want %q", gotArgs[len(gotArgs)-1], multiLinePrompt) + } +} + +// TestPlatformPiInvocation_SkipsWhenNotCmdOrBat ensures we leave argv alone +// when the user explicitly resolved pi to something that isn't a batch +// launcher (e.g. a real binary or a node script via shebang shim). +func TestPlatformPiInvocation_SkipsWhenNotCmdOrBat(t *testing.T) { + dir := t.TempDir() + exePath := filepath.Join(dir, "pi.exe") + writeFile(t, exePath, "") + // A sibling .ps1 must not trick us into rewriting a non-launcher exec. + writeFile(t, filepath.Join(dir, "pi.ps1"), "") + + stubPowerShell(t, filepath.Join(dir, "powershell.exe"), true) + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, _, ok := platformPiInvocation(exePath, []string{"-p", "hello"}, logger); ok { + t.Fatalf("expected ok=false for non-.cmd/.bat launcher") + } +} + +// TestPlatformPiInvocation_SkipsWhenPS1Missing covers the rare case where a +// .cmd was found but its companion .ps1 is missing (e.g. a partial install +// or a third-party shim that wraps Pi differently). We must fall back to +// the original launcher rather than synthesising an invalid powershell +// -File invocation against a non-existent script. +func TestPlatformPiInvocation_SkipsWhenPS1Missing(t *testing.T) { + dir := t.TempDir() + cmdPath := filepath.Join(dir, "pi.cmd") + writeFile(t, cmdPath, "@echo off\r\n") + + stubPowerShell(t, filepath.Join(dir, "powershell.exe"), true) + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, _, ok := platformPiInvocation(cmdPath, []string{"-p", "hello"}, logger); ok { + t.Fatalf("expected ok=false when pi.ps1 is missing") + } +} + +// TestPlatformPiInvocation_SkipsWhenPowerShellMissing covers a stripped +// down environment in which neither pwsh.exe nor powershell.exe can be +// resolved. We must not fabricate an empty-string argv[0]. +func TestPlatformPiInvocation_SkipsWhenPowerShellMissing(t *testing.T) { + dir := t.TempDir() + cmdPath := filepath.Join(dir, "pi.cmd") + ps1Path := filepath.Join(dir, "pi.ps1") + writeFile(t, cmdPath, "@echo off\r\n") + writeFile(t, ps1Path, "# fake\r\n") + + stubPowerShell(t, "", false) + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, _, ok := platformPiInvocation(cmdPath, []string{"-p", "hello"}, logger); ok { + t.Fatalf("expected ok=false when no powershell host is available") + } +}