Files
multica/server/pkg/agent/copilot_invocation_test.go
Bohan Jiang cd9b956269 fix(agent): spawn Copilot's native binary on Windows so the prompt survives (MUL-5586) (#6236)
On Windows the daemon passes the full multi-line prompt as `-p <prompt>` but
spawns npm's `copilot.cmd`, which we already rewrite to
`powershell -File copilot.ps1`. Neither launcher can carry that argument:

- `copilot.cmd` forwards with `%*`, which cmd.exe expands by re-tokenising
  the raw command line.
- `copilot.ps1` ends in `& node.exe npm-loader.js $args`, and PowerShell
  re-serialises `$args` onto node's command line. Under Windows PowerShell
  5.1 (and pwsh <= 7.2, which default to Legacy native argument passing)
  embedded double quotes are not re-escaped, so the prompt is re-tokenised.

Copilot then sees several argv tokens where one was intended and refuses the
run with "It looks like your prompt was not quoted, so the extra words were
treated as separate arguments" — the same defect class already fixed for
cursor-agent in #5649, except Copilot has no stdin prompt channel to escape
through, so the prompt must stay on the command line and the launchers have
to go.

Copilot CLI ships a native per-platform binary and `npm-loader.js` does
nothing but `spawnSync` it with argv untouched, so resolve
`copilot-win32-{x64,arm64}\copilot.exe` out of the npm layout and spawn it
directly. That leaves exactly one hop, Go -> native binary, and Go's
syscall.EscapeArg is the exact inverse of the CRT parsing that binary uses.
This mirrors resolveOpenCodeNativeFromShim / resolveDevecoNativeFromShim.

Both the nested (current npm) and hoisted (older npm) platform-package
locations are probed; when neither resolves, we keep falling back to the
PowerShell launcher, which is still better than cmd.exe.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-31 16:09:36 +08:00

155 lines
5.8 KiB
Go

package agent
import (
"io"
"log/slog"
"path/filepath"
"reflect"
"testing"
)
// TestChooseCopilotInvocation_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 TestChooseCopilotInvocation_PassthroughForNonLauncher(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
execName := "copilot"
lookedUp := filepath.Join(t.TempDir(), "copilot") // no .cmd / .bat
args := []string{
"-p", "You are running as a local coding agent.\n\nDo something.",
"--output-format", "json",
"--allow-all",
"--no-ask-user",
}
gotExec, gotArgs := chooseCopilotInvocation(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)
}
}
// ── Windows native-binary resolution tests ──
//
// These run on every platform: resolveCopilotNativeFromShim takes statFn as a
// parameter precisely so the Windows layout can be asserted from macOS/Linux CI.
// TestResolveCopilotNativeFromShim_NestedNpmLayout covers the layout npm
// actually produces today (verified against @github/copilot 1.0.77): the
// platform package stays nested under the parent package rather than being
// hoisted.
func TestResolveCopilotNativeFromShim_NestedNpmLayout(t *testing.T) {
t.Parallel()
prefix := filepath.Join("C:\\Users", "dev", "AppData", "Roaming", "npm")
shim := filepath.Join(prefix, "copilot.cmd")
native := filepath.Join(prefix, "node_modules", "@github", "copilot", "node_modules", "@github", "copilot-win32-x64", "copilot.exe")
if got := resolveCopilotNativeFromShim(shim, fakeStat(native)); got != native {
t.Errorf("got %q, want %q", got, native)
}
}
// TestResolveCopilotNativeFromShim_HoistedNpmLayout covers older npm versions
// and other installers that hoist the optional platform dep to the top-level
// node_modules.
func TestResolveCopilotNativeFromShim_HoistedNpmLayout(t *testing.T) {
t.Parallel()
prefix := filepath.Join("C:\\Users", "dev", "AppData", "Roaming", "npm")
shim := filepath.Join(prefix, "copilot.cmd")
native := filepath.Join(prefix, "node_modules", "@github", "copilot-win32-x64", "copilot.exe")
if got := resolveCopilotNativeFromShim(shim, fakeStat(native)); got != native {
t.Errorf("got %q, want %q", got, native)
}
}
// TestResolveCopilotNativeFromShim_FindsArm64Package covers Windows-on-ARM
// hosts, where npm installs @github/copilot-win32-arm64 instead. The resolver
// must find it regardless of which arch this test binary was built for, since
// the arch list only controls probe order, not membership.
func TestResolveCopilotNativeFromShim_FindsArm64Package(t *testing.T) {
t.Parallel()
prefix := filepath.Join("C:\\Users", "dev", "AppData", "Roaming", "npm")
shim := filepath.Join(prefix, "copilot.cmd")
native := filepath.Join(prefix, "node_modules", "@github", "copilot", "node_modules", "@github", "copilot-win32-arm64", "copilot.exe")
if got := resolveCopilotNativeFromShim(shim, fakeStat(native)); got != native {
t.Errorf("got %q, want %q", got, native)
}
}
// TestResolveCopilotNativeFromShim_ReturnsEmptyWhenNativeMissing covers a
// partial install or a Copilot build predating the platform packages. The
// caller must fall back to the PowerShell launcher rather than spawn a path
// that doesn't exist.
func TestResolveCopilotNativeFromShim_ReturnsEmptyWhenNativeMissing(t *testing.T) {
t.Parallel()
shim := filepath.Join("C:\\Users", "dev", "AppData", "Roaming", "npm", "copilot.cmd")
if got := resolveCopilotNativeFromShim(shim, fakeStat()); got != "" {
t.Errorf("got %q, want empty (missing native binary)", got)
}
}
// TestResolveCopilotNativeFromShim_SkipsNonCmdPath keeps macOS/Linux and
// direct-binary Windows launches on the untouched passthrough path.
func TestResolveCopilotNativeFromShim_SkipsNonCmdPath(t *testing.T) {
t.Parallel()
for _, p := range []string{
"/usr/local/bin/copilot",
"C:\\Users\\dev\\AppData\\Roaming\\npm\\copilot.exe",
"C:\\Users\\dev\\AppData\\Roaming\\npm\\copilot.ps1",
"",
} {
if got := resolveCopilotNativeFromShim(p, fakeStat("anything")); got != "" {
t.Errorf("path %q: got %q, want empty", p, got)
}
}
}
// TestResolveCopilotNativeFromShim_AcceptsUppercaseExtension guards the
// PATHEXT case: Windows filesystems are case-insensitive and exec.LookPath
// can return either case.
func TestResolveCopilotNativeFromShim_AcceptsUppercaseExtension(t *testing.T) {
t.Parallel()
prefix := filepath.Join("C:\\Users", "dev", "AppData", "Roaming", "npm")
shim := filepath.Join(prefix, "copilot.CMD")
native := filepath.Join(prefix, "node_modules", "@github", "copilot", "node_modules", "@github", "copilot-win32-x64", "copilot.exe")
if got := resolveCopilotNativeFromShim(shim, fakeStat(native)); got != native {
t.Errorf("got %q, want %q", got, native)
}
}
// TestCopilotWindowsPackageCandidates_ArchOrdering pins the probe order: the
// host's own arch first, but never at the cost of dropping the other one — an
// x64 Node running under emulation on an ARM64 host installs the x64 package.
func TestCopilotWindowsPackageCandidates_ArchOrdering(t *testing.T) {
t.Parallel()
cases := []struct {
goarch string
want []string
}{
{"arm64", []string{"copilot-win32-arm64", "copilot-win32-x64"}},
{"amd64", []string{"copilot-win32-x64", "copilot-win32-arm64"}},
}
for _, tc := range cases {
if got := copilotWindowsPackageCandidates(tc.goarch); !reflect.DeepEqual(got, tc.want) {
t.Errorf("goarch %q: got %#v, want %#v", tc.goarch, got, tc.want)
}
}
}