mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 04:56:20 +02:00
* fix(agent/cursor): send prompt on stdin so CLI-like flags cannot be re-tokenised (MUL-4992) On Windows a Cursor task whose prompt contains CLI-like flags failed in ~2s with `error: unknown option '-X'` and no agent output (#5649). A pasted build log such as go build -ldflags "-X main.version=foo" -o bin/server ./cmd/server was enough to kill the run. buildCursorArgs put the whole prompt in argv as the positional after -p. The official Windows launcher chain ends in `& node.exe index.js $args` inside cursor-agent.ps1, where PowerShell re-serialises $args onto node's command line. Under Windows PowerShell 5.1 and pwsh <= 7.2 (Legacy native argument passing) an argument holding embedded double quotes is not re-escaped, so the quoted region closes early, node's argv parser re-splits at the interior spaces, and `-X` reaches commander.js as a standalone flag. #1709 removed the cmd.exe `%*` re-tokenisation but stopped at the Go -> PowerShell boundary, one hop before this. Its Windows tests only compare the argv slice Go builds and never execute a shim, which is why the gap stayed invisible. Fix: keep the prompt off every command line. cursor-agent's -p is a boolean print-mode switch and the prompt is positional; with no positional prompt and a non-TTY stdin the CLI reads stdin to EOF and uses that as the prompt. So drop the prompt from argv on all platforms and write it to stdin, leaving only fixed, content-free flags in argv. No shell or launcher on any platform can re-tokenise what is not on a command line. The write runs in its own goroutine: a prompt larger than the pipe buffer (~64 KiB) blocks mid-write until the child drains it, and the child cannot drain while nothing reads its stdout. Closing stdin signals end-of-prompt, so it is closed on both success and error paths, and on cancellation to release a blocked write. Write failures surface in the result diagnostic, ranked below explicit agent errors so an early child exit (bad auth, bad flag) is not masked by the resulting EPIPE. Tests: a prompt carrying the exact `-ldflags "-X ..."` shape must arrive byte-for-byte on stdin and appear nowhere in argv; a 512 KiB prompt must not deadlock; the prompt is written verbatim (the CLI trims it, we do not). Both new unix tests fail against the pre-fix code. A Windows-tagged test drives a real PowerShell host through the same .cmd -> -File rewrite. Closes #5649 Co-authored-by: multica-agent <github@multica.ai> * test(agent/cursor): prove the Windows launcher fix on both PowerShell hosts in CI The stdin fix has to hold on the host that actually exhibits the bug. powershell.exe (5.1) and pwsh <= 7.2 default to Legacy native argument passing; pwsh >= 7.3 defaults to Standard. A fix verified only on the newer host would not be a fix for the reporter. Run the shim probe against every PowerShell host on PATH rather than only the one defaultPowerShellLookup would select, and hook the windows-tagged launcher tests into the existing windows-execenv CI job. These tests are windows-tagged and the backend job runs on ubuntu, so until now they ran nowhere. Co-authored-by: multica-agent <github@multica.ai> * test(ci): run Windows launcher tests verbosely so skips are visible A skipped or unmatched test still reports "ok", which would make the Windows job look like coverage it is not providing. Co-authored-by: multica-agent <github@multica.ai> * test(agent/cursor): close both coverage gaps in the #5649 regression tests Two tests claimed guarantees they did not actually establish. Windows: the fake cursor-agent.ps1 called [Console]::In.ReadToEnd() and wrote the result itself, so it never launched a native child. The official shim ends in `& node.exe index.js $args`, and that last hop is precisely where the bug lives -- PowerShell re-serialises $args onto the child command line, and whether the child inherits stdin was left unproven. The fake ps1 now re-executes the test binary as a real native child (helper-process idiom), which records the argv it actually received and drains stdin. Both PowerShell hosts still run. Interlock: the large-prompt fake drained stdin before writing any stdout, so the child always unblocked the parent immediately and the test passed even against a synchronous write -- it could not fail for the reason it existed. The fake now floods stdout past pipe capacity *before* reading stdin, creating the real mutual block. Verified: with the writer made synchronous the test deadlocks to its 30s timeout, and passes only with the concurrent writer. Also adds the missing cancellation case: a child that never reads stdin leaves the writer blocked forever, so cancelling the context must close stdin, release the writer and settle the run as aborted. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
238 lines
7.9 KiB
Go
238 lines
7.9 KiB
Go
//go:build unix
|
|
|
|
package agent
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// cursorStdinProbe runs the cursor backend against a fake cursor-agent that
|
|
// records the argv it was given and drains its stdin to EOF, then emits a
|
|
// terminal stream-json result. It returns (argv, stdin, result).
|
|
//
|
|
// Draining stdin before answering mirrors the real CLI: with no positional
|
|
// prompt and a non-TTY stdin, cursor-agent reads stdin to EOF and uses it as
|
|
// the prompt.
|
|
func cursorStdinProbe(t *testing.T, prompt string) ([]string, string, Result) {
|
|
t.Helper()
|
|
|
|
dir := t.TempDir()
|
|
argvPath := filepath.Join(dir, "argv.txt")
|
|
stdinPath := filepath.Join(dir, "stdin.txt")
|
|
|
|
// Record argv one element per line, then drain stdin to EOF before
|
|
// answering. Quoting "$@" keeps each argv element intact.
|
|
script := fmt.Sprintf(`#!/bin/sh
|
|
: > %[1]q
|
|
for a in "$@"; do printf '%%s\n' "$a" >> %[1]q; done
|
|
cat > %[2]q
|
|
printf '%%s\n' '{"type":"result","subtype":"success","is_error":false,"result":"ok"}'
|
|
`, argvPath, stdinPath)
|
|
|
|
fakePath := filepath.Join(dir, "cursor-agent")
|
|
writeTestExecutable(t, fakePath, []byte(script))
|
|
|
|
backend, err := New("cursor", Config{ExecutablePath: fakePath, Logger: slog.Default()})
|
|
if err != nil {
|
|
t.Fatalf("New(cursor): %v", err)
|
|
}
|
|
session, err := backend.Execute(t.Context(), prompt, ExecOptions{Timeout: 30 * time.Second})
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
result := <-session.Result
|
|
|
|
argvRaw, err := os.ReadFile(argvPath)
|
|
if err != nil {
|
|
t.Fatalf("read recorded argv: %v", err)
|
|
}
|
|
stdinRaw, err := os.ReadFile(stdinPath)
|
|
if err != nil {
|
|
t.Fatalf("read recorded stdin: %v", err)
|
|
}
|
|
argv := strings.Split(strings.TrimSuffix(string(argvRaw), "\n"), "\n")
|
|
return argv, string(stdinRaw), result
|
|
}
|
|
|
|
// TestCursorExecuteSendsPromptOnStdinNotArgv is the regression test for #5649.
|
|
// A prompt carrying CLI-like flags inside embedded double quotes (the exact
|
|
// shape from the report) must reach the child intact on stdin, and must not
|
|
// appear in argv at all — argv is where Windows launchers re-tokenise it.
|
|
func TestCursorExecuteSendsPromptOnStdinNotArgv(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
prompt := "Please fix the build.\n" +
|
|
"Log follows:\n" +
|
|
`go build -ldflags "-X main.version=foo -X main.commit=bar" -o bin/server ./cmd/server` + "\n" +
|
|
"Thanks."
|
|
|
|
argv, stdinGot, result := cursorStdinProbe(t, prompt)
|
|
|
|
if stdinGot != prompt {
|
|
t.Errorf("prompt did not arrive on stdin intact:\n got %q\n want %q", stdinGot, prompt)
|
|
}
|
|
|
|
// The whole point of the fix: no fragment of the prompt is in argv, so no
|
|
// shell or launcher on any platform can re-tokenise it into flags.
|
|
for _, a := range argv {
|
|
for _, needle := range []string{"-X", "ldflags", "main.version", "Please fix"} {
|
|
if strings.Contains(a, needle) {
|
|
t.Errorf("prompt fragment %q leaked into argv element %q; argv=%v", needle, a, argv)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The fixed, content-free flags must still be present.
|
|
joined := strings.Join(argv, " ")
|
|
for _, want := range []string{"-p", "--output-format", "stream-json", "--yolo"} {
|
|
if !strings.Contains(joined, want) {
|
|
t.Errorf("expected %q in argv, got %v", want, argv)
|
|
}
|
|
}
|
|
|
|
if result.Status != "completed" {
|
|
t.Fatalf("status = %q, want completed; error=%q", result.Status, result.Error)
|
|
}
|
|
}
|
|
|
|
// TestCursorExecuteLargePromptDoesNotDeadlock is the interlock test: it forces
|
|
// the exact mutual block the concurrent writer exists to avoid.
|
|
//
|
|
// The child floods stdout PAST the pipe capacity *before* reading a byte of
|
|
// stdin. So the child is blocked writing stdout, and a parent that wrote the
|
|
// prompt before starting its stdout reader would be blocked writing stdin —
|
|
// neither side can advance and the run only ends at the task timeout. It passes
|
|
// only because the writer and the scanner run concurrently.
|
|
//
|
|
// Ordering matters: a fake that drains stdin first would pass even against a
|
|
// synchronous write, which is why this test flooded stdout first.
|
|
func TestCursorExecuteLargePromptDoesNotDeadlock(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
stdinPath := filepath.Join(dir, "stdin.txt")
|
|
|
|
// ~520 KiB of stdout before any read of stdin, several times any plausible
|
|
// pipe buffer on Linux/macOS.
|
|
script := fmt.Sprintf(`#!/bin/sh
|
|
yes '{"type":"noise","pad":"0123456789012345678901234567890123456789"}' | head -n 8000
|
|
cat > %[1]q
|
|
printf '%%s\n' '{"type":"result","subtype":"success","is_error":false,"result":"ok"}'
|
|
`, stdinPath)
|
|
|
|
fakePath := filepath.Join(dir, "cursor-agent")
|
|
writeTestExecutable(t, fakePath, []byte(script))
|
|
|
|
prompt := strings.Repeat("multica cursor stdin payload 0123456789\n", 13_108)
|
|
if len(prompt) < 512*1024 {
|
|
t.Fatalf("test prompt too small: %d bytes", len(prompt))
|
|
}
|
|
|
|
backend, err := New("cursor", Config{ExecutablePath: fakePath, Logger: slog.Default()})
|
|
if err != nil {
|
|
t.Fatalf("New(cursor): %v", err)
|
|
}
|
|
// A deadlock surfaces as this timeout firing instead of a completed run.
|
|
session, err := backend.Execute(t.Context(), prompt, ExecOptions{Timeout: 30 * time.Second})
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
result := <-session.Result
|
|
|
|
stdinRaw, err := os.ReadFile(stdinPath)
|
|
if err != nil {
|
|
t.Fatalf("read recorded stdin: %v", err)
|
|
}
|
|
if len(stdinRaw) != len(prompt) {
|
|
t.Errorf("stdin truncated: got %d bytes, want %d", len(stdinRaw), len(prompt))
|
|
}
|
|
if string(stdinRaw) != prompt {
|
|
t.Error("large prompt arrived corrupted on stdin")
|
|
}
|
|
if result.Status != "completed" {
|
|
t.Fatalf("status = %q, want completed (deadlock would show as timeout); error=%q", result.Status, result.Error)
|
|
}
|
|
}
|
|
|
|
// TestCursorExecuteCancelReleasesBlockedPromptWrite pins the other unlock
|
|
// point. The child never reads stdin at all, so a prompt past the pipe buffer
|
|
// leaves the writer blocked indefinitely; cancelling the parent context must
|
|
// close stdin, release that writer, and settle the run rather than hang.
|
|
func TestCursorExecuteCancelReleasesBlockedPromptWrite(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
// Never reads stdin, never exits on its own.
|
|
script := "#!/bin/sh\nsleep 120\n"
|
|
fakePath := filepath.Join(dir, "cursor-agent")
|
|
writeTestExecutable(t, fakePath, []byte(script))
|
|
|
|
prompt := strings.Repeat("blocked write payload 0123456789\n", 16_384) // ~512 KiB
|
|
|
|
backend, err := New("cursor", Config{ExecutablePath: fakePath, Logger: slog.Default()})
|
|
if err != nil {
|
|
t.Fatalf("New(cursor): %v", err)
|
|
}
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
session, err := backend.Execute(ctx, prompt, ExecOptions{Timeout: 120 * time.Second})
|
|
if err != nil {
|
|
cancel()
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
|
|
// Give the writer time to fill the pipe and block, then cancel.
|
|
time.Sleep(200 * time.Millisecond)
|
|
cancel()
|
|
|
|
select {
|
|
case result := <-session.Result:
|
|
if result.Status != "aborted" {
|
|
t.Fatalf("status = %q, want aborted; error=%q", result.Status, result.Error)
|
|
}
|
|
case <-time.After(30 * time.Second):
|
|
t.Fatal("cancel did not release the blocked prompt write; Result never arrived")
|
|
}
|
|
}
|
|
|
|
// TestCursorExecuteWritesPromptVerbatim pins our side of the whitespace
|
|
// contract: we write the prompt bytes exactly as given, adding no wrapper,
|
|
// framing or trailing newline (unlike Claude, which sends a JSON frame).
|
|
//
|
|
// Note the CLI itself trims the stdin prompt before use, so leading/trailing
|
|
// whitespace is not preserved end-to-end. That is cursor-agent's behaviour, not
|
|
// ours; task prompts carry no meaning in outer whitespace, so we do not
|
|
// compensate for it here.
|
|
func TestCursorExecuteWritesPromptVerbatim(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
prompt := "\n leading and trailing whitespace \n\n"
|
|
|
|
_, stdinGot, result := cursorStdinProbe(t, prompt)
|
|
|
|
if stdinGot != prompt {
|
|
t.Errorf("prompt was mutated before write:\n got %q\n want %q", stdinGot, prompt)
|
|
}
|
|
if result.Status != "completed" {
|
|
t.Fatalf("status = %q, want completed; error=%q", result.Status, result.Error)
|
|
}
|
|
}
|