diff --git a/packages/argue-cli/src/runtime/cli.ts b/packages/argue-cli/src/runtime/cli.ts index 208c5ec..6ce7c06 100644 --- a/packages/argue-cli/src/runtime/cli.ts +++ b/packages/argue-cli/src/runtime/cli.ts @@ -32,7 +32,6 @@ export function createCliRunner(raw: CliProviderConfig): ProviderTaskRunner { const { args: baseArgs, reasoningApplied } = buildBaseArgs( provider.cliType, agent.providerModel, - prompt, reasoning, hasSession ? sessionUUID : undefined, isResume @@ -62,7 +61,7 @@ export function createCliRunner(raw: CliProviderConfig): ProviderTaskRunner { } : {}) }, - stdin: usesStdinPrompt(provider.cliType) ? prompt : "", + stdin: prompt, abortSignal }); @@ -79,30 +78,9 @@ export function createCliRunner(raw: CliProviderConfig): ProviderTaskRunner { }; } -/** Returns true if the CLI tool reads the prompt from stdin, false if it goes in args. */ -function usesStdinPrompt(cliType: CliProviderConfig["cliType"]): boolean { - switch (cliType) { - case "claude": - case "codex": - case "gemini": - case "pi": - case "generic": - return true; - case "copilot": - case "amp": - return false; - case "opencode": - return true; - case "droid": - default: - return true; - } -} - function buildBaseArgs( cliType: CliProviderConfig["cliType"], providerModel: string, - prompt: string, reasoning?: string, sessionUUID?: string, isResume?: boolean @@ -141,7 +119,9 @@ function buildBaseArgs( }; } case "copilot": - return { args: ["-p", prompt, "--yolo", "--model", providerModel], reasoningApplied: false }; + // No -p on purpose: copilot resolves the prompt as `-p` first and stdin + // second, so adding the flag back would silently discard the piped one. + return { args: ["--yolo", "--model", providerModel], reasoningApplied: false }; case "gemini": return { args: ["--approval-mode", "yolo", "-m", providerModel], reasoningApplied: false }; case "pi": { @@ -153,7 +133,9 @@ function buildBaseArgs( case "droid": return { args: ["exec", "--auto", "high", "-m", providerModel], reasoningApplied: false }; case "amp": - return { args: ["-x", prompt, "--dangerously-allow-all"], reasoningApplied: false }; + // `-x` selects execute mode rather than carrying the prompt; left bare it + // reads stdin. Its value is optional, so nothing may follow it positionally. + return { args: ["-x", "--dangerously-allow-all"], reasoningApplied: false }; case "generic": return { args: [], reasoningApplied: !!reasoning }; default: @@ -261,6 +243,10 @@ async function runCommand(args: { ); }); + // Load-bearing: a CLI that exits before draining stdin (failing auth is the + // common case) fails this write with EPIPE, which is fatal when unhandled. + // The close handler above already reports why the child went away. + child.stdin.on("error", () => {}); child.stdin.end(args.stdin); }); } diff --git a/packages/argue-cli/test/runtime-cli.test.ts b/packages/argue-cli/test/runtime-cli.test.ts index 3e7a383..29b7740 100644 --- a/packages/argue-cli/test/runtime-cli.test.ts +++ b/packages/argue-cli/test/runtime-cli.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AgentTaskInput } from "@onevcat/argue"; import { describe, expect, it, vi } from "vitest"; +import { CliProviderSchema } from "../src/config.js"; import { createCliRunner } from "../src/runtime/cli.js"; function makeRoundTask(): AgentTaskInput { @@ -353,7 +354,7 @@ process.stdout.write(JSON.stringify(output)); } }); - it("builds copilot base args with prompt in args and --yolo", async () => { + it("builds copilot base args with stdin prompt and --yolo", async () => { const script = await createArgvAndStdinEchoScript("argue-cli-runner-copilot-"); const runner = createCliRunner({ @@ -367,16 +368,13 @@ process.stdout.write(JSON.stringify(output)); const result = await runner.runTask({ task: makeRoundTask(), agent }); const { argv, stdin } = getArgvAndStdin(result as { kind: string; output: { fullResponse: string } }); - expect(argv).toContain("-p"); - const pIdx = argv.indexOf("-p"); - const promptValue = argv[pIdx + 1]!; - expect(promptValue).toContain("argue CLI host"); + expect(argv).not.toContain("-p"); + expect(argv).not.toContain("--prompt"); + expect(stdin).toContain("argue CLI host"); expect(argv).toContain("--yolo"); expect(argv).toContain("--model"); expect(argv[argv.indexOf("--model") + 1]).toBe("fake"); - - expect(stdin).toBe(""); }); it("builds gemini base args with stdin prompt and --approval-mode yolo", async () => { @@ -520,7 +518,7 @@ process.stdout.write(JSON.stringify(output)); expect(stdin).toContain("argue CLI host"); }); - it("builds amp base args with -x prompt and no model flag", async () => { + it("builds amp base args with -x stdin prompt and no model flag", async () => { const script = await createArgvAndStdinEchoScript("argue-cli-runner-amp-"); const runner = createCliRunner({ @@ -535,15 +533,61 @@ process.stdout.write(JSON.stringify(output)); const { argv, stdin } = getArgvAndStdin(result as { kind: string; output: { fullResponse: string } }); expect(argv).toContain("-x"); - const xIdx = argv.indexOf("-x"); - const promptValue = argv[xIdx + 1]!; - expect(promptValue).toContain("argue CLI host"); + expect(argv[argv.indexOf("-x") + 1]).toBe("--dangerously-allow-all"); + expect(stdin).toContain("argue CLI host"); - expect(argv).toContain("--dangerously-allow-all"); expect(argv).not.toContain("--model"); expect(argv).not.toContain("-m"); + }); + + it("delivers the task payload over stdin for every cliType", async () => { + const cliTypes = CliProviderSchema.shape.cliType.options; + const leakedIntoArgv: string[] = []; + const missingFromStdin: string[] = []; + + for (const cliType of cliTypes) { + const script = await createArgvAndStdinEchoScript(`argue-cli-runner-stdin-${cliType}-`); + const runner = createCliRunner({ + type: "cli", + cliType, + command: script, + args: [], + models: { fake: {} } + }); + const result = await runner.runTask({ task: makeRoundTask(), agent }); + const { argv, stdin } = getArgvAndStdin(result as { kind: string; output: { fullResponse: string } }); + if (!stdin.includes("req-1")) missingFromStdin.push(cliType); + if (argv.some((entry) => entry.includes("req-1"))) leakedIntoArgv.push(cliType); + } + expect(leakedIntoArgv).toEqual([]); + expect(missingFromStdin).toEqual([]); + }); + + it("rejects cleanly when a CLI exits before draining a large prompt", async () => { + const root = await mkdtemp(join(tmpdir(), "argue-cli-runner-epipe-")); + const script = join(root, "runner.mjs"); + await writeFile( + script, + `#!/usr/bin/env node +process.stderr.write("No authentication information found."); +process.exit(1); +`, + { mode: 0o755 } + ); + + const runner = createCliRunner({ + type: "cli", + cliType: "copilot", + command: script, + args: [], + models: { fake: {} } + }); + + // Must stay larger than the pipe buffer, or the write lands before the + // child exits and the EPIPE window this guards never opens. + const task = { ...makeRoundTask(), prompt: "p".repeat(200_000) }; - expect(stdin).toBe(""); + await expect(runner.runTask({ task, agent })).rejects.toThrow(/exited with code=1/); }); it("parses fenced json output in codex mode", async () => {