-
Notifications
You must be signed in to change notification settings - Fork 4
fix(adapters): deliver the prompt over stdin so a null byte can't crash the judge #63
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f59e492
2779279
d99d231
bec888f
992ec3d
52b5552
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,7 +31,7 @@ AXIS (Agent Experience Index Score) is a synthetic testing framework for AI agen | |
|
|
||
| Built-in adapters split into two factories. NDJSON-style adapters (`claude-code`, `codex`) are created via `createAgentAdapter(spec)` from `src/adapters/base/agent-adapter.ts`. ACP-based adapters (`claude-sdk`, `codex-sdk`, `gemini`, `goose`, `opencode`, `qwen-code`, `stakpak`, `blackbox`, `fast-agent`, `mistral-vibe`, `factory-droid`, `poolside`, `vtcode`, `cursor-agent`, `auggie`, `kimi`, `openhands`, `cline`, `kiro-cli`, `kilo`, `qoder`) are created via `createAcpBasedAdapter(spec)` from `src/adapters/base/acp-adapter.ts`. Each adapter is a plain factory function (e.g. `createGeminiAdapter()`) that returns an `AgentAdapter` -no classes, no inheritance. The factory owns the shared plumbing: | ||
|
|
||
| - Spawn + stdin.end + cleanup registration (SIGTERM on Ctrl-C) | ||
| - Spawn + cleanup registration (SIGTERM on Ctrl-C); stdin is closed immediately by default, or written with the prompt then closed when `promptVia: "stdin"` | ||
| - 10-minute timeout → SIGTERM → SIGKILL after 5s grace (timer cleared on clean exit) | ||
| - stderr capped at 100 KB | ||
| - `close` event listener registered BEFORE stdout stream to avoid missing it | ||
|
|
@@ -46,20 +46,21 @@ The NDJSON-style adapters (`claude-code`, `codex`) use `lines` mode for NDJSON p | |
|
|
||
| Call `createAgentAdapter(spec)` with an `AgentAdapterSpec<State>`. The spec is a single typed object -no class inheritance, no protected hooks: | ||
|
|
||
| | Spec field | Purpose | | ||
| | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| | `name` | Adapter name (registered in `src/adapters/registry.ts`) | | ||
| | `cliCommand?` | CLI binary for `resolveCommand`; omit if user-supplied | | ||
| | `timeoutMs?` | Execution timeout (default 10 min) | | ||
| | `requiredEnv?` | Env vars validated by the runner pre-flight (e.g. `ANTHROPIC_API_KEY`) | | ||
| | `hasLocalSession?` | Detect a usable local CLI login (e.g. `claude login`, `codex login`). Runner calls this only when `requiredEnv` is missing — explicit API keys always win | | ||
| | `isolationEnv?` | Isolation vars (e.g. `CLAUDE_CONFIG_DIR`, `CODEX_HOME`). Signature: `({ workspace, home }) => Record<string, string>`. Point `*_HOME`-style paths under `home`, never `workspace` | | ||
| | `prepare?` | Side effects (mkdir, MCP / skills writers) before spawn | | ||
| | `resolveCommand?` | Override how the CLI command is resolved | | ||
| | `buildArgs` | Build CLI arguments (prefix args from command resolution prepended automatically) | | ||
| | `initialState` | Per-run mutable state used by `streamConfig` handlers and `getResult` | | ||
| | `streamConfig` | How to process agent stdout. Discriminated union: `{ mode: "lines", onLine, onEnd? }` or `{ mode: "aggregate", onChunk, onEnd? }` | | ||
| | `getResult` | Build final `{ result, metadata? }` from accumulated state after exit | | ||
| | Spec field | Purpose | | ||
| | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ||
| | `name` | Adapter name (registered in `src/adapters/registry.ts`) | | ||
| | `cliCommand?` | CLI binary for `resolveCommand`; omit if user-supplied | | ||
| | `timeoutMs?` | Execution timeout (default 10 min) | | ||
| | `requiredEnv?` | Env vars validated by the runner pre-flight (e.g. `ANTHROPIC_API_KEY`) | | ||
| | `hasLocalSession?` | Detect a usable local CLI login (e.g. `claude login`, `codex login`). Runner calls this only when `requiredEnv` is missing — explicit API keys always win | | ||
| | `isolationEnv?` | Isolation vars (e.g. `CLAUDE_CONFIG_DIR`, `CODEX_HOME`). Signature: `({ workspace, home }) => Record<string, string>`. Point `*_HOME`-style paths under `home`, never `workspace` | | ||
| | `prepare?` | Side effects (mkdir, MCP / skills writers) before spawn | | ||
| | `resolveCommand?` | Override how the CLI command is resolved | | ||
| | `buildArgs` | Build CLI arguments (prefix args from command resolution prepended automatically) | | ||
| | `promptVia?` | How the prompt reaches the CLI: `"argv"` (default) -`buildArgs` places `input.prompt` on the command line; `"stdin"` -the base writes `input.prompt` to the child's stdin and closes it, and `buildArgs` must omit the prompt. `claude-code` and `codex` use `"stdin"`: argv rejects null bytes and caps argument length, and agent transcripts can contain either | | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Complete the prompt transport rationale in both documents. The sentence ends with
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| | `initialState` | Per-run mutable state used by `streamConfig` handlers and `getResult` | | ||
| | `streamConfig` | How to process agent stdout. Discriminated union: `{ mode: "lines", onLine, onEnd? }` or `{ mode: "aggregate", onChunk, onEnd? }` | | ||
| | `getResult` | Build final `{ result, metadata? }` from accumulated state after exit | | ||
|
|
||
| The `streamConfig` field uses a discriminated union so the mode and its handler can never get out of sync -no runtime assertions needed. `getResult` returns `null` for "no result" (never `""`). Metadata overrides (e.g. upstream `durationMs`) are spread on top of base-computed fields. | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,11 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
| import { EventEmitter, Readable } from "node:stream"; | ||
| import type { AgentAdapter, AgentInput, AgentMetadata } from "../../../../src/types/agent.js"; | ||
| import { createAgentAdapter, type SetupContext } from "../../../../src/adapters/base/agent-adapter.js"; | ||
| import { | ||
| createAgentAdapter, | ||
| type AgentAdapterSpec, | ||
| type SetupContext, | ||
| } from "../../../../src/adapters/base/agent-adapter.js"; | ||
|
|
||
| vi.mock("node:child_process", () => ({ | ||
| spawn: vi.fn(), | ||
|
|
@@ -24,7 +28,7 @@ function createMockProcess(opts: { | |
| const { stdout: stdoutLines = [], stderr: stderrLines = [], exitCode = 0, delayMs = 0, hang = false } = opts; | ||
| const stdout = new Readable({ read() {} }); | ||
| const stderr = new Readable({ read() {} }); | ||
| const stdin = { end: vi.fn() }; | ||
| const stdin = { end: vi.fn(), on: vi.fn() }; | ||
| const proc = Object.assign(new EventEmitter(), { stdout, stderr, stdin, kill: vi.fn() }); | ||
|
|
||
| setTimeout(() => { | ||
|
|
@@ -62,7 +66,9 @@ let setupCalls: SetupContext[] = []; | |
| let getResultCalls = 0; | ||
| let resultOverride: Partial<AgentMetadata> | null = null; | ||
|
|
||
| function createLinesTestAdapter(): AgentAdapter { | ||
| function createLinesTestAdapter( | ||
| overrides: Partial<AgentAdapterSpec<{ lines: string[]; result: string | null }>> = {}, | ||
| ): AgentAdapter { | ||
| setupCalls = []; | ||
| getResultCalls = 0; | ||
| resultOverride = null; | ||
|
|
@@ -96,6 +102,8 @@ function createLinesTestAdapter(): AgentAdapter { | |
| metadata: resultOverride ?? {}, | ||
| }; | ||
| }, | ||
|
|
||
| ...overrides, | ||
| }); | ||
| } | ||
|
|
||
|
|
@@ -424,6 +432,34 @@ describe("createAgentAdapter", () => { | |
| expect(out.metadata.error?.length).toBeLessThan(200_000); | ||
| }); | ||
|
|
||
| it("promptVia: stdin writes the prompt to child.stdin and resolves without throwing", async () => { | ||
| const fakeChild = createMockProcess({ stdout: ["ok\n"] }); | ||
| mockSpawn.mockImplementation((() => fakeChild) as any); | ||
|
|
||
| const adapter = createLinesTestAdapter({ promptVia: "stdin" }); | ||
|
|
||
| const prompt = "hello\0world"; | ||
| await expect(adapter.run(makeInput({ prompt }))).resolves.not.toThrow(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- test context ---'
sed -n '1,130p' test/unit/adapters/base/agent-adapter.test.ts
sed -n '420,470p' test/unit/adapters/base/agent-adapter.test.ts
printf '%s\n' '--- adapter run declarations and implementations ---'
rg -n -A12 -B8 'run\s*\(|interface AgentOutput|type AgentOutput|class AgentAdapter' src test package.json pnpm-lock.yaml yarn.lock package-lock.json 2>/dev/null | head -n 260
printf '%s\n' '--- vitest version/config ---'
rg -n '"vitest"|vitest@|expect' package.json pnpm-lock.yaml yarn.lock package-lock.json vitest.config.* 2>/dev/null | head -n 120Repository: netlify/axis Length of output: 21747 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- adapter factory implementation ---'
fd -t f 'agent-adapter\.ts$' src test
file="$(fd -t f 'agent-adapter\.ts$' src | head -n 1)"
cat -n "$file" | sed -n '1,360p'
printf '%s\n' '--- AgentMetadata contract ---'
cat -n src/types/agent.ts | sed -n '105,145p'Repository: netlify/axis Length of output: 17348 Use a value assertion for
🤖 Prompt for AI Agents |
||
|
|
||
| expect(fakeChild.stdin.end).toHaveBeenCalledWith(prompt); | ||
| }); | ||
|
|
||
| it("promptVia omitted: stdin.end is called with no data and argv is untouched", async () => { | ||
| const fakeChild = createMockProcess({ stdout: ["ok\n"] }); | ||
| let captured: string[] = []; | ||
| mockSpawn.mockImplementation(((_cmd: string, args: string[]) => { | ||
| captured = args; | ||
| return fakeChild; | ||
| }) as any); | ||
|
|
||
| const adapter = createLinesTestAdapter(); | ||
|
|
||
| await adapter.run(makeInput({ prompt: "hello\0world" })); | ||
|
|
||
| expect(fakeChild.stdin.end).toHaveBeenCalledWith(); | ||
| expect(captured).toEqual(["--flag"]); | ||
| }); | ||
|
|
||
| it("custom resolveCommand overrides default resolution", async () => { | ||
| let usedCmd = ""; | ||
| mockSpawn.mockImplementation(((cmd: string) => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Limit this stdin statement to
createAgentAdapter.This section covers both
createAgentAdapterandcreateAcpBasedAdapter, but ACP keeps stdin open for bidirectional JSON-RPC. The current bullet says that stdin is closed for both adapter factories. Scope the bullet to NDJSON adapters or document the ACP exception.🤖 Prompt for AI Agents