Skip to content

Commit f24630c

Browse files
authored
Transport native task results through a result file (#1764)
1 parent d0c85ed commit f24630c

9 files changed

Lines changed: 152 additions & 19 deletions

File tree

.github/scripts/run-agent-task/execute-native-agent-task.mjs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"
1+
import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"
22
import { join, resolve } from "node:path"
33
import { spawn } from "node:child_process"
44
import { materializeExternalNativePackage, normalizeExternalPackageSource, parseExternalPackageSourcePolicy } from "./materialize-external-native-package.mjs"
5+
import { readNativeResult } from "./native-result-file.mjs"
56

67
const requestPath = process.env.AGENT_TASK_REQUEST_PATH || ".codebox/agent-task-request.json"
78
const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd())
@@ -191,6 +192,8 @@ const externalPackageSource = normalizeExternalPackageSource(request.external_pa
191192
const artifactsPath = join(workspace, ".codebox", "agent-task-artifacts")
192193
const runtimeInputPath = join(workspace, ".codebox", "native-agent-task-input.json")
193194
const resultPath = join(workspace, ".codebox", "agent-task-workflow-result.json")
195+
const controlledCodeboxPath = resolve(requestPath, "..")
196+
const nativeResultPath = join(controlledCodeboxPath, "native-agent-task-result.json")
194197
const runnerWorkspaceTools = [
195198
"workspace-read", "workspace-ls", "workspace-grep", "workspace-write", "workspace-edit", "workspace-apply-patch",
196199
"workspace-git-status", "workspace-git-diff", "workspace-git-add", "workspace-git-commit", "workspace-git-push",
@@ -266,18 +269,16 @@ await writeFile(runtimeInputPath, `${JSON.stringify(taskInput, null, 2)}\n`)
266269

267270
let execution = { code: 0, stdout: "", stderr: "", stdout_truncated: false, stderr_truncated: false }
268271
if (request.run_agent && !request.dry_run) {
269-
execution = await command("node", [codeboxCliPath, "agent-task-run", "--input-file", runtimeInputPath, "--json"], workspace, agentEnvironment())
272+
execution = await command("node", [codeboxCliPath, "agent-task-run", "--input-file", runtimeInputPath, "--result-file", nativeResultPath], workspace, agentEnvironment())
270273
}
271274

272275
// Public package bytes are embedded in the runtime recipe and consumed only by
273276
// the Playground bootstrap before the agent's tools are resolved.
274277

275-
let runtimeResult = {}
276-
try {
277-
runtimeResult = redact(JSON.parse(execution.stdout))
278-
} catch {
279-
runtimeResult = { success: false, diagnostics: [{ code: "wp-codebox.agent-task.invalid-result", message: "Native agent-task-run did not return JSON.", stderr: execution.stderr }] }
280-
}
278+
const runtimeResult = request.run_agent && !request.dry_run
279+
? await readNativeResult(nativeResultPath, controlledCodeboxPath, secretValues, redact)
280+
: {}
281+
await rm(nativeResultPath, { force: true })
281282

282283
await redactArtifactFiles(artifactsPath)
283284

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { lstat, readFile } from "node:fs/promises"
2+
import { resolve } from "node:path"
3+
4+
export const MAX_NATIVE_RESULT_BYTES = 1024 * 1024
5+
const TERMINAL_STATUSES = new Set(["succeeded", "failed", "no_op", "timeout", "provider_error", "unable_to_remediate"])
6+
7+
export function nativeResultFailure(code, message) {
8+
return { success: false, diagnostics: [{ code, message }] }
9+
}
10+
11+
export async function readNativeResult(path, controlledDirectory, secretValues, redact) {
12+
const resolvedPath = resolve(path)
13+
const resolvedDirectory = resolve(controlledDirectory)
14+
if (!resolvedPath.startsWith(`${resolvedDirectory}/`)) {
15+
return nativeResultFailure("wp-codebox.agent-task.result-path", "Native agent-task result path is outside the controlled .codebox directory.")
16+
}
17+
try {
18+
const info = await lstat(resolvedPath)
19+
if (!info.isFile() || info.isSymbolicLink()) {
20+
return nativeResultFailure("wp-codebox.agent-task.result-file", "Native agent-task result file must be a regular non-symlink file.")
21+
}
22+
if (info.size > MAX_NATIVE_RESULT_BYTES) {
23+
return nativeResultFailure("wp-codebox.agent-task.result-too-large", `Native agent-task result exceeds the ${MAX_NATIVE_RESULT_BYTES}-byte limit.`)
24+
}
25+
const contents = await readFile(resolvedPath, "utf8")
26+
if (Buffer.byteLength(contents) !== info.size) {
27+
return nativeResultFailure("wp-codebox.agent-task.result-file", "Native agent-task result changed while it was being read.")
28+
}
29+
if (secretValues.some((secret) => contents.includes(secret))) {
30+
return nativeResultFailure("wp-codebox.agent-task.result-secret", "Native agent-task result contains a configured secret.")
31+
}
32+
const result = JSON.parse(contents)
33+
const taskResult = record(result)
34+
const summary = record(taskResult.agent_task_run_result)
35+
if (taskResult.schema !== "wp-codebox/agent-task-run/v1" || typeof taskResult.success !== "boolean" || !TERMINAL_STATUSES.has(taskResult.status) || summary.schema !== "wp-codebox/agent-task-run-result/v1" || typeof summary.success !== "boolean" || !TERMINAL_STATUSES.has(summary.status) || taskResult.success !== summary.success || taskResult.status !== summary.status) {
36+
return nativeResultFailure("wp-codebox.agent-task.result-schema", "Native agent-task result did not match the required result schema.")
37+
}
38+
return redact(taskResult)
39+
} catch (error) {
40+
const code = error && typeof error === "object" && error.code === "ENOENT" ? "wp-codebox.agent-task.result-missing" : "wp-codebox.agent-task.result-malformed"
41+
return nativeResultFailure(code, code.endsWith("missing") ? "Native agent-task did not produce a result file." : "Native agent-task result file was malformed.")
42+
}
43+
}
44+
45+
function record(value) {
46+
return value && typeof value === "object" && !Array.isArray(value) ? value : {}
47+
}

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1361,7 +1361,7 @@ Generic caller-owned `request.json` payloads may use this shape:
13611361
}
13621362
```
13631363

1364-
WP Codebox normalizes the task input, writes the private temporary recipe, runs `wp-codebox recipe-run`, then deletes temporary recipe/seed files. The JSON response keeps `schema: "wp-codebox/agent-task-run/v1"` and includes `agent_task_run_result` with `schema: "wp-codebox/agent-task-run-result/v1"`. Consumers read `agent_task_run_result.status`, `agent_task_run_result.success`, `agent_task_run_result.refs`, `agent_task_run_result.metadata`, and `failure_evidence` as the stable result envelope. Secret values are never accepted in the request or returned in the response; `secret_env` carries names only.
1364+
WP Codebox normalizes the task input, writes the private temporary recipe, runs `wp-codebox recipe-run`, then deletes temporary recipe/seed files. The JSON response keeps `schema: "wp-codebox/agent-task-run/v1"` and includes `agent_task_run_result` with `schema: "wp-codebox/agent-task-run-result/v1"`. Consumers read `agent_task_run_result.status`, `agent_task_run_result.success`, `agent_task_run_result.refs`, `agent_task_run_result.metadata`, and `failure_evidence` as the stable result envelope. `--result-file <path>` atomically writes that final JSON with a private temporary file and a rename; callers that require structured output should use it instead of stdout. Secret values are never accepted in the request or returned in the response; `secret_env` carries names only.
13651365

13661366
Failed `agent-task-run` responses also include `failure_evidence` with `schema: "wp-codebox/agent-task-run-failure-evidence/v1"`. This block is intentionally safe for parent orchestrators to persist alongside their own task failure record. It includes the best available `phase`, `command`, `exit_code`, message, stdout/stderr snippets, runtime and sandbox identifiers, artifact directory or bundle identifiers, recipe-run status/run ID, diagnostics, phase evidence, and the serialized error. If recipe-run fails before normal runtime artifacts exist or returns malformed output, `agent-task-run` still emits this fallback evidence block in the CLI JSON payload and references it from `evidence_refs` with kind `codebox-agent-task-failure-evidence`.
13671367

docs/agent-runtime-contract.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ WP Codebox rejects product ability paths that pass raw `code` or `code_file`. De
143143
}
144144
```
145145

146-
`agent_task_result` is the sandbox semantic result `wp-codebox/agent-task-result/v1`. `agent_task_run_result` is the caller-facing `wp-codebox/agent-task-run-result/v1` contract emitted by the CLI and WordPress ability surfaces. Orchestrators read `agent_task_run_result.status`, `agent_task_run_result.success`, `agent_task_run_result.refs`, and `agent_task_run_result.metadata` as the stable result envelope. Node consumers can also normalize older or nested envelopes with `normalizeAgentTaskRunResult()` from `@automattic/wp-codebox-core` or `wp-codebox-workspace/core`; the package exports `AGENT_TASK_RUN_RESULT_SCHEMA` and `AGENT_TASK_RUN_RESULT_JSON_SCHEMA` for contract checks. The normalized envelope groups stable artifact refs into `artifact_bundles`, `changed_files`, `patches`, `transcripts`, `logs`, and `runtimes`, and classifies terminal statuses including `succeeded`, `failed`, `no_op`, `timeout`, `provider_error`, and `unable_to_remediate`.
146+
`agent_task_result` is the sandbox semantic result `wp-codebox/agent-task-result/v1`. `agent_task_run_result` is the caller-facing `wp-codebox/agent-task-run-result/v1` contract emitted by the CLI and WordPress ability surfaces. Orchestrators read `agent_task_run_result.status`, `agent_task_run_result.success`, `agent_task_run_result.refs`, and `agent_task_run_result.metadata` as the stable result envelope. For process execution, pass `--result-file <path>`: the CLI writes the final envelope through a mode `0600` temporary file and atomic rename. stdout and stderr remain diagnostic streams and must not be used as required structured transport. Node consumers can also normalize older or nested envelopes with `normalizeAgentTaskRunResult()` from `@automattic/wp-codebox-core` or `wp-codebox-workspace/core`; the package exports `AGENT_TASK_RUN_RESULT_SCHEMA` and `AGENT_TASK_RUN_RESULT_JSON_SCHEMA` for contract checks. The normalized envelope groups stable artifact refs into `artifact_bundles`, `changed_files`, `patches`, `transcripts`, `logs`, and `runtimes`, and classifies terminal statuses including `succeeded`, `failed`, `no_op`, `timeout`, `provider_error`, and `unable_to_remediate`.
147147

148148
Failed `agent-task-run` responses include `wp-codebox/agent-task-run-failure-evidence/v1` in `failure_evidence` when available. This block is safe for orchestrators to persist with job failure records and includes phase, command, exit code, redacted stdout/stderr snippets, runtime and sandbox identifiers, artifact references, diagnostics, and serialized error data.
149149

docs/agent-task-reusable-workflow.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,4 +183,4 @@ workflow itself cannot inspect the caller's `uses:` declaration at runtime.
183183

184184
The reusable workflow stages every uploaded file through a fail-closed policy. Only regular UTF-8 files of 4 MiB or less are uploaded, after configured secret values are redacted. Symlinks, special files, binary files, and files larger than 4 MiB are excluded from the upload staging directory. This applies to task artifacts and workflow request, input, and result files.
185185

186-
Native task execution, validation-dependency installation, verification, drift checks, and GitHub pull-request validation retain at most 32 KiB from each stdout and stderr stream while continuing to drain both streams. Command results expose `stdout_truncated` and `stderr_truncated` when retained output is incomplete.
186+
Native task execution, validation-dependency installation, verification, drift checks, and GitHub pull-request validation retain at most 32 KiB from each stdout and stderr stream while continuing to drain both streams. Command results expose `stdout_truncated` and `stderr_truncated` when retained output is incomplete. Native `agent-task-run` structured output is instead read from a controlled `.codebox/native-agent-task-result.json` file outside the agent-writable target checkout. The executor accepts only a regular, non-symlink JSON file up to 1 MiB with the required result schemas, rejects configured secret values, redacts the accepted record before persistence, and removes the transient native result file after reading it.

packages/cli/src/commands/agent-task-run.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
1+
import { lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"
22
import { tmpdir } from "node:os"
3-
import { join } from "node:path"
3+
import { dirname, join } from "node:path"
44
import { AGENT_TASK_RUN_REQUEST_SCHEMA, HEADLESS_AGENT_TASK_REQUEST_SCHEMA, artifactResultEnvelope, buildAgentTaskRecipe, DEFAULT_WORDPRESS_VERSION, headlessAgentTaskRequestToRunInput, normalizeAgentRuntimeWorkload, normalizeAgentTaskRunResult, normalizeAgentTerminalResult, normalizeArtifactResultTypedArtifacts, normalizeHeadlessAgentTaskRequest, normalizeHeadlessAgentTaskResult, normalizeTaskInput, parseCommandJson, parseCommandOptions, resolveEffectiveRuntimeToolPolicy, type AgentTaskRunInput, type AgentTaskRunResultSummary, type AgentTerminalResult, type ArtifactResultEnvelope, type HeadlessAgentTaskResult, type SandboxToolPolicySnapshot, type TypedArtifactDTO } from "@automattic/wp-codebox-core"
55
import { stripUndefined } from "@automattic/wp-codebox-core/internals"
66
import { runRecipeRunCommand } from "./recipe-run.js"
@@ -10,6 +10,7 @@ export type { AgentTaskRunInput } from "@automattic/wp-codebox-core"
1010
export interface AgentTaskRunOptions {
1111
inputPath: string
1212
json: boolean
13+
resultFile?: string
1314
previewHoldSeconds?: string
1415
previewPublicUrl?: string
1516
previewPort?: string
@@ -79,9 +80,14 @@ export async function runAgentTaskRunCommand(args: string[]): Promise<number> {
7980
const options = parseAgentTaskRunOptions(args)
8081
const input = normalizeAgentTaskRunCliInput(JSON.parse(await readFile(options.inputPath, "utf8")))
8182
const output = await runAgentTask(input, options)
83+
const jsonOutput = agentTaskRunJsonOutput(output)
84+
85+
if (options.resultFile) {
86+
await writeAgentTaskRunResultFile(options.resultFile, jsonOutput)
87+
}
8288

8389
if (options.json) {
84-
process.stdout.write(`${JSON.stringify(agentTaskRunJsonOutput(output), null, 2)}\n`)
90+
process.stdout.write(`${JSON.stringify(jsonOutput, null, 2)}\n`)
8591
return agentTaskRunExitCode(output)
8692
}
8793

@@ -391,7 +397,7 @@ function parseAgentTaskRunOptions(args: string[]): AgentTaskRunOptions {
391397
throw new Error(`Unknown agent-task-run option: ${positionals[0]}`)
392398
}
393399
for (const name of options.keys()) {
394-
if (!["--input-file", "--json", "--format", "--preview-hold-seconds", "--preview-public-url", "--preview-port", "--preview-bind", "--preview-hold-blocking", "--preview-lease-json"].includes(name)) {
400+
if (!["--input-file", "--json", "--format", "--result-file", "--preview-hold-seconds", "--preview-public-url", "--preview-port", "--preview-bind", "--preview-hold-blocking", "--preview-lease-json"].includes(name)) {
395401
throw new Error(`Unknown agent-task-run option: ${name}`)
396402
}
397403
}
@@ -402,6 +408,7 @@ function parseAgentTaskRunOptions(args: string[]): AgentTaskRunOptions {
402408
return {
403409
inputPath,
404410
json: options.get("--json") === true || stringOption(options, "--format") === "json",
411+
resultFile: stringOption(options, "--result-file") || undefined,
405412
previewHoldSeconds: stringOption(options, "--preview-hold-seconds"),
406413
previewPublicUrl: stringOption(options, "--preview-public-url"),
407414
previewPort: stringOption(options, "--preview-port"),
@@ -411,6 +418,32 @@ function parseAgentTaskRunOptions(args: string[]): AgentTaskRunOptions {
411418
}
412419
}
413420

421+
/** Write caller-owned structured output without exposing an incomplete result. */
422+
export async function writeAgentTaskRunResultFile(path: string, output: AgentTaskRunOutput | HeadlessAgentTaskResult): Promise<void> {
423+
const parent = dirname(path)
424+
await mkdir(parent, { recursive: true, mode: 0o700 })
425+
const parentStat = await lstat(parent)
426+
if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) {
427+
throw new Error("agent-task-run result-file parent must be a non-symlink directory")
428+
}
429+
try {
430+
const targetStat = await lstat(path)
431+
if (!targetStat.isFile() || targetStat.isSymbolicLink()) {
432+
throw new Error("agent-task-run result-file must be a regular file when it already exists")
433+
}
434+
} catch (error) {
435+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
436+
}
437+
438+
const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`
439+
try {
440+
await writeFile(temporaryPath, `${JSON.stringify(output, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" })
441+
await rename(temporaryPath, path)
442+
} finally {
443+
await rm(temporaryPath, { force: true })
444+
}
445+
}
446+
414447
function objectRecord(value: unknown): Record<string, unknown> | undefined {
415448
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined
416449
}

packages/cli/src/output.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,7 @@ export function printHelp(): void {
331331
wp-codebox fuzz-minimize-case --input-file <path> [--format=json] [--dry-run]
332332
wp-codebox run-wordpress-workload --input-file <path> [--format=json] [--dry-run]
333333
wp-codebox run-agent-task --input-file <path> [--json] [--preview-hold-seconds <n>] [--preview-hold-blocking] [--preview-port <port>] [--preview-bind <host>] [--preview-public-url <url>] [--preview-lease-json <json>]
334-
wp-codebox agent-task-run --input-file <path> [--json] [--preview-hold-seconds <n>] [--preview-hold-blocking] [--preview-port <port>] [--preview-bind <host>] [--preview-public-url <url>] [--preview-lease-json <json>]
334+
wp-codebox agent-task-run --input-file <path> [--json] [--result-file <path>] [--preview-hold-seconds <n>] [--preview-hold-blocking] [--preview-port <port>] [--preview-bind <host>] [--preview-public-url <url>] [--preview-lease-json <json>]
335335
wp-codebox validate-blueprint --blueprint <json|file> [options]
336336
wp-codebox materialize-replay-package --snapshot <path> --output <dir> [--snapshot-ref <ref>] [--json]
337337
wp-codebox recipe-run --recipe <path> [options]
@@ -343,6 +343,7 @@ Options:
343343
--options <path> Recipe builder options JSON file for recipe build.
344344
--output <path> Recipe build output JSON path, or materialize-replay-package output directory.
345345
--input-file <path> Input JSON for public workload/fuzz commands or agent-task-run.
346+
--result-file <path> Atomically write the final agent-task-run JSON result to a caller-owned file.
346347
--format=json Emit machine-readable JSON; accepted by public workload/fuzz commands.
347348
--preview-hold-seconds <n>
348349
Keep preview runtimes alive after run-agent-task/agent-task-run/recipe-run.

0 commit comments

Comments
 (0)