Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 11 additions & 25 deletions packages/argue-cli/src/runtime/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -62,7 +61,7 @@ export function createCliRunner(raw: CliProviderConfig): ProviderTaskRunner {
}
: {})
},
stdin: usesStdinPrompt(provider.cliType) ? prompt : "",
stdin: prompt,
abortSignal
});

Expand All @@ -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
Expand Down Expand Up @@ -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": {
Expand All @@ -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:
Expand Down Expand Up @@ -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);
});
}
70 changes: 57 additions & 13 deletions packages/argue-cli/test/runtime-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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({
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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({
Expand All @@ -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 () => {
Expand Down