Skip to content
Merged
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
149 changes: 142 additions & 7 deletions .github/scripts/run-agent-task/prepare-agent-task-upload.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { constants } from "node:fs"
import { lstat, mkdir, open, readdir, readFile, rm, writeFile } from "node:fs/promises"
import { lstat, mkdir, open, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises"
import { isUtf8 } from "node:buffer"
import { createHash } from "node:crypto"
import { fileURLToPath, pathToFileURL } from "node:url"
import { isAbsolute, join, relative, resolve } from "node:path"
import { assertNoRuntimeSourcePaths, sanitizeRuntimeSourceJson } from "./runtime-source-sanitizer.mjs"

const MAX_UPLOAD_FILE_BYTES = 4 * 1024 * 1024
const MAX_TRANSCRIPT_EXECUTIONS = 64
const MAX_REVIEW_TEXT_BYTES = 32 * 1024
const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd())
const uploadPath = resolve(process.env.AGENT_TASK_UPLOAD_PATH || join(workspace, ".codebox", "agent-task-upload"))
const requestPath = resolve(process.env.AGENT_TASK_REQUEST_PATH || join(workspace, ".codebox", "agent-task-request.json"))
Expand All @@ -14,6 +18,7 @@ const runtimeSourceRoot = process.env.WP_CODEBOX_RUNTIME_SOURCE_ROOT ? resolve(p
const runtimeSourcePrefix = process.env.WP_CODEBOX_RUNTIME_SOURCE_PREFIX ? resolve(process.env.WP_CODEBOX_RUNTIME_SOURCE_PREFIX) : ""
const runtimeSourceRoots = [runtimeSourceRoot, runtimeSourcePrefix].filter(Boolean)
const privateUploadRoots = [...runtimeSourceRoots, workspace]
const codeboxRoot = resolve(fileURLToPath(new URL("../../..", import.meta.url)))
const SOURCE_TREE = /(^|\/)(prepared-plugins|prepared-source-packages|source-package[^/]*)(\/|$)/i
const SOURCE_FILE = /\.(?:php|phtml|js|mjs|cjs|jsx|ts|tsx)$/i
const PHP_OPENING_TAG = /<\?(?:php|=)(?:\s|$)/i
Expand All @@ -34,6 +39,7 @@ function redact(value) {

function sanitizeText(text) {
return sanitizeRuntimeSourceJson(text, privateUploadRoots)
.replace(/\/(?:Users|home|private|var|tmp|opt|Volumes)\/[^\s"'\\]+/g, "[host-path]")
}

function compactNativeInput(text) {
Expand Down Expand Up @@ -135,12 +141,129 @@ async function stageTextFile(source, destination, options = {}) {
const text = redact(sanitizeSeedSnapshotJson(sanitized))
assertNoRuntimeSourcePaths(text, privateUploadRoots, "Runtime source or workspace paths must never be persisted in artifact uploads.")
assertNoSeedSnapshotPaths(text)
if (containsRuntimeSourceContent(text)) throw new Error("Prepared runtime plugin source contents must never be staged for artifact upload.")
if (!options.allowTargetCode && containsRuntimeSourceContent(text)) throw new Error("Prepared runtime plugin source contents must never be staged for artifact upload.")
await mkdir(resolve(destination, ".."), { recursive: true })
await writeFile(destination, text)
return true
}

async function canonicalTranscript(result) {
const publicCore = await import(pathToFileURL(join(codeboxRoot, "packages/runtime-core/dist/public.js")).href)
const refs = publicCore.normalizePublicArtifactRefDTOs(record(result).runtime_result)
.filter((ref) => ref.kind === "codebox-transcript")
if (refs.length === 0) return undefined
if (refs.length !== 1 || !safeRelativeArtifactPath(refs[0].path)) throw new Error("Canonical transcript requires exactly one trusted codebox-transcript path.")
return refs[0]
}

function digest(bytes) {
return createHash("sha256").update(bytes).digest("hex")
}

function boundedText(value) {
if (typeof value !== "string") return undefined
const text = redact(sanitizeText(value)).slice(0, MAX_REVIEW_TEXT_BYTES)
return containsRuntimeSourceContent(text) ? "[redacted-source-content]" : text
}

function safeTargetPath(value) {
const path = safeRelativeArtifactPath(value)
return path ? `workspace/${path}` : undefined
}

function omittedText(value) {
if (typeof value !== "string") return undefined
const bytes = Buffer.byteLength(value)
return { bytes, sha256: digest(Buffer.from(value)) }
}

function projectArguments(value) {
const entry = record(value)
const paths = [entry.path, ...(Array.isArray(entry.paths) ? entry.paths : [])].map(safeTargetPath).filter(Boolean).slice(0, 32)
const counts = Object.fromEntries(["bytes", "byte_count", "match_count", "matches", "change_count", "changes", "count"].flatMap((key) => typeof entry[key] === "number" ? [[key, entry[key]]] : []))
const payloads = Object.fromEntries(["content", "patch", "diff", "write", "old_string", "new_string", "text"].flatMap((key) => omittedText(entry[key]) ? [[key, omittedText(entry[key])]] : []))
return Object.fromEntries(Object.entries({ paths: paths.length ? paths : undefined, counts: Object.keys(counts).length ? counts : undefined, omitted_payloads: Object.keys(payloads).length ? payloads : undefined }).filter(([, item]) => item !== undefined))
}

function projectToolCall(value) {
const entry = record(value)
const tool = boundedText(entry.tool_id ?? entry.toolId ?? entry.name ?? entry.tool_name)
const args = projectArguments(entry.args ?? entry.arguments)
const paths = [...(args.paths ?? []), ...[safeTargetPath(entry.path)].filter(Boolean)].slice(0, 32)
const result = record(entry.result ?? entry.output)
const resultSummary = projectArguments({ ...result, content: result.content ?? entry.content, path: result.path ?? entry.path })
return Object.fromEntries(Object.entries({ tool, paths: paths.length ? paths : undefined, status: boundedText(entry.status), arguments: Object.keys(args).length ? args : undefined, result: Object.keys(resultSummary).length ? resultSummary : undefined, error_code: boundedText(record(entry.error).code ?? entry.error_code), error: boundedText(record(entry.error).message ?? entry.error) }).filter(([, item]) => item !== undefined))
}

function projectParsed(value) {
const parsed = record(value)
const messages = Array.isArray(parsed.messages) ? parsed.messages : Array.isArray(parsed.model_messages) ? parsed.model_messages : []
const tools = Array.isArray(parsed.tool_calls) ? parsed.tool_calls : Array.isArray(parsed.toolCalls) ? parsed.toolCalls : []
const results = Array.isArray(parsed.tool_results) ? parsed.tool_results : Array.isArray(parsed.toolResults) ? parsed.toolResults : []
const errors = Array.isArray(parsed.errors) ? parsed.errors : parsed.error ? [parsed.error] : []
const agent = record(parsed.agent ?? parsed.agent_metadata)
return Object.fromEntries(Object.entries({
agent: Object.fromEntries(["id", "name", "model", "provider", "status"].flatMap((key) => boundedText(agent[key]) ? [[key, boundedText(agent[key])]] : [])),
model_messages: messages.slice(0, MAX_TRANSCRIPT_EXECUTIONS).flatMap((message) => boundedText(record(message).content ?? record(message).text ?? message) ? [{ role: boundedText(record(message).role), content: boundedText(record(message).content ?? record(message).text ?? message) }] : []),
tool_calls: tools.slice(0, MAX_TRANSCRIPT_EXECUTIONS).map(projectToolCall),
tool_results: results.slice(0, MAX_TRANSCRIPT_EXECUTIONS).map(projectToolCall),
errors: errors.slice(0, MAX_TRANSCRIPT_EXECUTIONS).flatMap((error) => boundedText(record(error).message ?? error) ? [boundedText(record(error).message ?? error)] : []),
}).filter(([, item]) => Array.isArray(item) ? item.length > 0 : Object.keys(item).length > 0))
}

async function trustedTranscriptFile(path) {
const root = await realpath(artifactsPath).catch(() => "")
if (!root) return { unavailable: "artifact-root-missing" }
const parts = path.split("/")
let current = root
for (const part of parts) {
current = join(current, part)
const stat = await lstat(current).catch((error) => error?.code === "ENOENT" ? undefined : Promise.reject(error))
if (!stat) return { unavailable: "referenced-file-missing" }
if (stat.isSymbolicLink()) throw new Error("Canonical transcript must not traverse symlinks.")
}
const stat = await lstat(current)
if (!stat.isFile() || stat.size > MAX_UPLOAD_FILE_BYTES) throw new Error("Canonical transcript must be a bounded regular file.")
const resolved = await realpath(current)
const contained = relative(root, resolved)
if (contained === ".." || contained.startsWith(`..${String.fromCharCode(47)}`) || isAbsolute(contained)) throw new Error("Canonical transcript escapes the trusted artifact root.")
return { source: resolved }
}

async function stageCanonicalTranscript(result) {
const ref = await canonicalTranscript(result)
if (!ref) return undefined
const path = safeRelativeArtifactPath(ref.path)
if (sourceCategory(path, resolve(artifactsPath, path))) throw new Error("Canonical transcript must stay under the trusted artifact root.")
const trusted = await trustedTranscriptFile(path)
if (trusted.unavailable) return { unavailable: trusted.unavailable, provenance: { kind: ref.kind, artifact_path: path } }
const source = trusted.source
const bytes = await readFile(source)
if (bytes.includes(0) || !isUtf8(bytes)) throw new Error("Canonical transcript must be UTF-8 JSON.")
const actualDigest = digest(bytes)
const expectedDigest = typeof ref.sha256 === "string" ? ref.sha256 : record(ref.digest).value
if (expectedDigest && expectedDigest !== actualDigest) throw new Error("Canonical transcript digest does not match its normalized ref.")
const raw = parseJsonOrEmpty(bytes.toString("utf8"))
if (raw.schema !== "wp-codebox/agent-transcript/v1" || !Array.isArray(raw.executions) || raw.executions.length > MAX_TRANSCRIPT_EXECUTIONS) throw new Error("Canonical transcript must be a bounded wp-codebox/agent-transcript/v1 envelope.")
const projection = {
schema: "wp-codebox/reviewer-agent-transcript/v1",
executions: raw.executions.map((execution, index) => {
const entry = record(execution)
if (typeof entry.command !== "string" || typeof entry.exitCode !== "number") throw new Error("Canonical transcript execution is malformed.")
return Object.fromEntries(Object.entries({ execution_index: typeof entry.executionIndex === "number" ? entry.executionIndex : index, command: boundedText(entry.command), status: entry.exitCode === 0 ? "succeeded" : "failed", exit_code: entry.exitCode, parsed: Object.keys(record(entry.parsed)).length ? projectParsed(entry.parsed) : undefined, error: boundedText(entry.stderr) }).filter(([, item]) => item !== undefined))
}),
}
const destination = join(uploadPath, ".codebox", "agent-task-artifacts", "transcript.json")
await mkdir(resolve(destination, ".."), { recursive: true })
await writeFile(destination, `${JSON.stringify(projection, null, 2)}\n`)
const projectionDigest = digest(await readFile(destination))
return {
path: ".codebox/agent-task-artifacts/transcript.json",
sha256: projectionDigest,
provenance: { kind: ref.kind, artifact_path: path, source_sha256: actualDigest, projection_sha256: projectionDigest },
}
}

function record(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : {}
}
Expand Down Expand Up @@ -171,7 +294,7 @@ function declaredArtifactPaths(result, allowed) {
return [...paths].sort()
}

async function exclusions(root, declaredPaths) {
async function exclusions(root, declaredPaths, transcript) {
const counts = new Map()
const count = (category) => counts.set(category, (counts.get(category) || 0) + 1)
const visit = async (directory) => {
Expand All @@ -188,7 +311,10 @@ async function exclusions(root, declaredPaths) {
}
}
await visit(root)
return [...counts.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([category, count]) => ({ category, count }))
return {
exclusions: [...counts.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([category, count]) => ({ category, count })),
...(transcript ? { canonical_transcripts: [transcript] } : {}),
}
}

function runtimeProvenance(request) {
Expand Down Expand Up @@ -216,7 +342,7 @@ async function finalScan(directory) {
const text = isUtf8(bytes) ? bytes.toString("utf8") : ""
assertNoRuntimeSourcePaths(text, privateUploadRoots, "Runtime source or workspace paths must never be persisted in artifact uploads.")
assertNoSeedSnapshotPaths(text)
if (containsRuntimeSourceContent(text)) throw new Error("Prepared runtime plugin source contents must never be persisted in artifact uploads.")
if (relativePath !== ".codebox/agent-task-artifacts/transcript.json" && containsRuntimeSourceContent(text)) throw new Error("Prepared runtime plugin source contents must never be persisted in artifact uploads.")
} else throw new Error("Only regular files may be persisted in artifact uploads.")
}
}
Expand All @@ -237,11 +363,20 @@ await stageTextFile(join(workspace, ".codebox", "native-agent-task-input.json"),
for (const path of declaredPaths) {
const source = resolve(artifactsPath, path)
if (relative(artifactsPath, source).startsWith("..") || sourceCategory(path, source)) {
throw new Error("Declared reviewer artifacts must not reference source files or private runtime internals.")
// Package declarations cannot authorize source trees or escape the root.
// Keep staging independent of an untrusted alias, including transcripts.
continue
}
await stageTextFile(source, join(uploadPath, ".codebox", "agent-task-artifacts", path))
}
const transcript = await stageCanonicalTranscript(result)
if (transcript?.provenance?.source_sha256) {
const stagedResultPath = join(uploadPath, ".codebox", "agent-task-workflow-result.json")
const stagedResult = parseJsonOrEmpty(await readFile(stagedResultPath, "utf8"))
stagedResult.artifact_upload = { canonical_transcript: transcript.provenance }
await writeFile(stagedResultPath, `${JSON.stringify(stagedResult, null, 2)}\n`)
}
await mkdir(join(uploadPath, ".codebox", "agent-task-artifacts"), { recursive: true })
await writeFile(join(uploadPath, ".codebox", "agent-task-artifacts", "runtime-provenance.json"), `${JSON.stringify({ schema: "wp-codebox/agent-task-runtime-provenance/v1", sources: runtimeProvenance(request) }, null, 2)}\n`)
await writeFile(join(uploadPath, ".codebox", "agent-task-artifacts", "exclusions.json"), `${JSON.stringify({ schema: "wp-codebox/agent-task-upload-exclusions/v1", exclusions: await exclusions(artifactsPath, declaredPaths) }, null, 2)}\n`)
await writeFile(join(uploadPath, ".codebox", "agent-task-artifacts", "exclusions.json"), `${JSON.stringify({ schema: "wp-codebox/agent-task-upload-exclusions/v1", ...(await exclusions(artifactsPath, declaredPaths, transcript)) }, null, 2)}\n`)
await finalScan(uploadPath)
2 changes: 2 additions & 0 deletions docs/agent-runtime-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ Generic command runners and host tool registries should write tool-call evidence

The artifact verifier checks that transcript artifact refs are listed in `manifest.json`, stay within the bundle, and match their declared SHA-256 digests when present. This lets a generic command runner branch merge by materializing its command/tool transcript into `files/runtime-evidence/tool-calls/transcript.json` and appending the referenced input/output artifacts through the existing runtime-evidence manifest update path.

The reusable agent-task workflow separately handles one normalized `codebox-transcript` ref as optional reviewer evidence. A missing referenced file is recorded as unavailable; an existing file must be a bounded `wp-codebox/agent-transcript/v1` regular JSON file whose canonical path stays under the artifact root without symlink traversal. When the normalized ref provides a digest it must match the original bytes. The workflow stages a compact `wp-codebox/reviewer-agent-transcript/v1` projection at `.codebox/agent-task-artifacts/transcript.json`, not the raw transcript or command stdout. Its provenance records verified source and projection SHA-256 values in both the exclusions manifest and staged result. The projection retains execution command/status, typed workspace tool content, model messages, tool calls/results, errors, and agent metadata while removing setup stacks, package/bootstrap payloads, plugin inventories, unknown fields, secrets, and private host/runtime/source/snapshot paths. Package declarations cannot authorize alternate transcript paths or dependency source files.

## Runner Workspace Publication

Runner workspace publication is a separate exported contract in runtime-core:
Expand Down
10 changes: 9 additions & 1 deletion docs/agent-task-reusable-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,15 @@ runner token, so a fabricated publication result cannot satisfy the gate.

The result artifact includes the executable task input, normalized runtime
result, evaluated projections, verification records, and runner-owned
publication result. Runtime input, result, and diagnostics are uploaded from
publication result. A single canonical `codebox-transcript` ref from the
normalized runtime result is handled independently of package declarations at
`.codebox/agent-task-artifacts/transcript.json`. Missing canonical evidence is
recorded as unavailable. Present evidence is verified against its normalized
digest when supplied, then converted from `wp-codebox/agent-transcript/v1` into
a compact `wp-codebox/reviewer-agent-transcript/v1` projection. The projection
records verified source and output digests in `exclusions.json`, retains bounded
diagnostic tool and model data, and excludes raw stdout, source trees, secrets,
private paths, setup stacks, and package payloads. Runtime input, result, and diagnostics are uploaded from
`workspace/.codebox/` with `if: always()`, including execution failures. A request
artifact alone is not a successful task result.

Expand Down
2 changes: 2 additions & 0 deletions tests/execute-native-agent-task-playground-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ add_filter( 'pre_http_request', static function( $preempt, $args, $url ) {
const serialized = JSON.stringify(result)
for (const privateValue of ["e2e-github-secret", "e2e-openai-secret", "PRIVATE_WORKSPACE_SENTINEL", "PRIVATE_NPM_SENTINEL", "PRIVATE_NETRC_SENTINEL", "PRIVATE_KEY_SENTINEL", "PRIVATE_CODEBOX_SENTINEL", "PRIVATE_NODE_MODULES_SENTINEL", sources.root, workspace]) assert.doesNotMatch(serialized, new RegExp(privateValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
await execFileAsync(process.execPath, [uploader], { cwd: workspace, env: { ...process.env, AGENT_TASK_WORKSPACE: workspace, AGENT_TASK_REQUEST_PATH: join(workspace, ".codebox", "agent-task-request.json"), AGENT_TASK_UPLOAD_PATH: join(workspace, ".codebox", "agent-task-upload"), GITHUB_TOKEN: "e2e-github-secret", OPENAI_API_KEY: "e2e-openai-secret" } })
const exclusions = JSON.parse(await readFile(join(workspace, ".codebox", "agent-task-upload", ".codebox", "agent-task-artifacts", "exclusions.json"), "utf8"))
assert.equal(exclusions.canonical_transcripts[0].unavailable, "referenced-file-missing", "missing canonical evidence remains optional")
const staged = JSON.stringify(await Promise.all((await readdir(join(workspace, ".codebox", "agent-task-upload"), { recursive: true })).map((path) => readFile(join(workspace, ".codebox", "agent-task-upload", path), "utf8").catch(() => ""))))
for (const privateValue of ["wp-codebox-runner-workspace-seed-", workspace, "PRIVATE_WORKSPACE_SENTINEL", "PRIVATE_NPM_SENTINEL", "PRIVATE_NETRC_SENTINEL", "PRIVATE_KEY_SENTINEL"]) assert.doesNotMatch(staged, new RegExp(privateValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
const stagedInput = JSON.parse(await readFile(join(workspace, ".codebox", "agent-task-upload", ".codebox", "native-agent-task-input.json"), "utf8"))
Expand Down
Loading
Loading