Files
multica/server/pkg/agent/copilot_invocation.go
Multica Eveandmultica-agent 74281e313d fix(agent): deliver the copilot prompt on stdin (MUL-8105) (#9380)
* fix(agent): deliver the copilot prompt on stdin; name E2BIG start failures (MUL-8105)

Copilot inlined the whole task prompt as -p <prompt>, so a thread above Linux's 131,072-byte MAX_ARG_STRLEN died at fork/exec with E2BIG (#9362). The prompt now rides stdin, mirroring the OpenCode move in #6552. startOwnedProcessTree also wraps oversized-command-line failures in ErrArgvLimitExceeded with an actionable remedy.

Co-authored-by: multica-agent <[email protected]>

* docs(agent): update copilotBackend comment for stdin prompt delivery (MUL-8105)

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-10-21 12:26:50 +08:00

107 lines
4.8 KiB
Go

package agent
import (
"log/slog"
"os"
"path/filepath"
"runtime"
"strings"
)
// chooseCopilotInvocation selects the actual program (argv[0]) and the full
// argv to spawn a Copilot CLI run.
//
// On macOS/Linux the npm binstub is a symlink to npm-loader.js with a
// `#!/usr/bin/env node` shebang, so execve hands argv to node unchanged and
// node's spawnSync forwards it to the bundled native binary unchanged.
//
// On Windows there is no shebang, so npm ships copilot.cmd/copilot.ps1
// launchers and every layer in between re-serialises argv onto a new command
// line. Both launchers are lossy for the multi-line, quote-bearing -p prompt
// built by buildCopilotArgs; the symptom is Copilot's own
// "It looks like your prompt was not quoted, so the extra words were treated
// as separate arguments" error. The Windows rewrite in
// copilot_invocation_windows.go therefore skips the launchers and spawns the
// bundled copilot.exe directly.
func chooseCopilotInvocation(execName, lookedUp string, args []string, logger *slog.Logger) (string, []string) {
if argv0, full, ok := platformCopilotInvocation(lookedUp, args, logger); ok {
return argv0, full
}
return execName, args
}
// resolveCopilotNativeFromShim returns the path to the native Copilot
// executable bundled inside the npm package, given the path to the npm
// `copilot.cmd` shim that PATH lookup found on Windows. Returns "" if the
// shim doesn't end in `.cmd` or no candidate platform package ships a binary
// at either known location, in which case the caller falls back to the
// PowerShell launcher.
//
// Why bypass the launchers instead of quoting harder: nothing on the Go side
// can make them lossless. Both hops re-parse a command line with rules that
// differ from the CRT rules Go's os/exec escapes for.
//
// - copilot.cmd forwards with `%*`, which cmd.exe expands by re-tokenising
// the raw command line — newlines and quotes in the -p prompt do not
// survive.
// - 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 a prompt containing them
// is re-tokenised — the same defect already documented for cursor-agent
// in buildCursorArgs (#5649).
//
// The task prompt itself now rides stdin (see buildCopilotArgs, #9362), but
// model ids, session ids and custom_args still travel on argv, so the native
// binary stays preferred. Spawning copilot.exe directly leaves exactly one
// hop, Go -> native binary, and Go's syscall.EscapeArg is the exact inverse of
// the CRT parsing the binary uses.
//
// Layout when installed via `npm install -g @github/copilot` (verified
// against @github/copilot 1.0.77):
//
// <prefix>\copilot.cmd (shim)
// <prefix>\node_modules\@github\copilot\npm-loader.js (JS entry)
// <prefix>\node_modules\@github\copilot\node_modules\@github\copilot-win32-x64\copilot.exe (native)
//
// npm keeps the platform package nested under the parent package; older npm
// versions and other package managers hoist it to the top-level
// node_modules instead, so both locations are probed.
//
// statFn is injected so this is testable on non-Windows hosts.
func resolveCopilotNativeFromShim(shimPath string, statFn func(string) (os.FileInfo, error)) string {
if !strings.EqualFold(filepath.Ext(shimPath), ".cmd") {
return ""
}
prefix := filepath.Dir(shimPath)
for _, pkg := range copilotWindowsPackageCandidates(runtime.GOARCH) {
candidates := []string{
// npm's current layout: optional platform dep nested under the parent.
filepath.Join(prefix, "node_modules", "@github", "copilot", "node_modules", "@github", pkg, "copilot.exe"),
// Hoisted layout used by older npm versions and other installers.
filepath.Join(prefix, "node_modules", "@github", pkg, "copilot.exe"),
}
for _, candidate := range candidates {
if _, err := statFn(candidate); err == nil {
return candidate
}
}
}
return ""
}
// copilotWindowsPackageCandidates returns the npm platform package names that
// may host the bundled copilot.exe, ordered so the most likely match for the
// given GOARCH comes first. ARM64 hosts try the arm64 build first; everything
// else tries x64 first, because an x64 Node running under emulation on an
// ARM64 host installs the x64 platform package. Cost is one extra statFn call
// per miss when the GOARCH-preferred package isn't installed.
func copilotWindowsPackageCandidates(goarch string) []string {
switch goarch {
case "arm64":
return []string{"copilot-win32-arm64", "copilot-win32-x64"}
default:
return []string{"copilot-win32-x64", "copilot-win32-arm64"}
}
}