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
49 changes: 17 additions & 32 deletions .github/scripts/run-agent-task/execute-native-agent-task.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { rmSync } from "node:fs"
import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"
import { isAbsolute, join, relative, resolve } from "node:path"
import { join, resolve } from "node:path"
import { spawn } from "node:child_process"
import { materializeExternalNativePackage, materializeRuntimeSources, normalizeExternalPackageSource, normalizeRuntimeSources, parseExternalPackageSourcePolicy, validateRuntimeSourceModel } from "./materialize-external-native-package.mjs"
import { readNativeResult } from "./native-result-file.mjs"
import { assertNoRuntimeSourcePaths, sanitizeRuntimeSourceJson, sanitizeRuntimeSourceText, sanitizeRuntimeSourceValue } from "./runtime-source-sanitizer.mjs"

const requestPath = process.env.AGENT_TASK_REQUEST_PATH || ".codebox/agent-task-request.json"
const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd())
Expand All @@ -13,7 +14,6 @@ const outputPath = process.env.GITHUB_OUTPUT
const MAX_CAPTURE_BYTES = 32768
const MAX_OUTPUT_CHARS = 8192
const secretValues = ["OPENAI_API_KEY", "MODEL_PROVIDER_SECRET_1", "MODEL_PROVIDER_SECRET_2", "MODEL_PROVIDER_SECRET_3", "MODEL_PROVIDER_SECRET_4", "MODEL_PROVIDER_SECRET_5", "GITHUB_TOKEN", "GH_TOKEN", "ACCESS_TOKEN", "EXTERNAL_PACKAGE_SOURCE_POLICY"].map((name) => process.env[name]).filter(Boolean)
const PRIVATE_RUNTIME_PATH_FIELDS = new Set(["source", "path", "sourceRoot", "originalSource", "preparedPath", "requestedPath", "source_package_root", "artifacts_path", "runtime_input_path", "task_path", "result_path", "event_stream_path", "materialization_result_path"])
function redact(value) {
if (typeof value === "string") return secretValues.reduce((output, secret) => output.split(secret).join("[REDACTED]"), value)
if (Array.isArray(value)) return value.map(redact)
Expand All @@ -22,7 +22,7 @@ function redact(value) {
}

function bounded(value, limit = MAX_CAPTURE_BYTES) {
const safe = redact(value)
const safe = sanitizeRuntimeSourceText(redact(value), privateRuntimeSourceRoot)
if (typeof safe !== "string") return safe
return safe.length > limit ? `${safe.slice(0, limit)}\n[TRUNCATED ${safe.length - limit} characters]` : safe
}
Expand Down Expand Up @@ -96,28 +96,6 @@ function record(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : {}
}

function isPrivateRuntimePath(value) {
if (!privateRuntimeSourceRoot || typeof value !== "string") return false
const path = resolve(value)
const contained = relative(privateRuntimeSourceRoot, path)
return path === privateRuntimeSourceRoot || (contained !== ".." && !contained.startsWith(`..${String.fromCharCode(47)}`) && !isAbsolute(contained))
}

function omitPrivateRuntimeSourcePaths(value) {
if (Array.isArray(value)) return value.map(omitPrivateRuntimeSourcePaths)
if (!value || typeof value !== "object") return value
return Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => {
if (PRIVATE_RUNTIME_PATH_FIELDS.has(key) && isPrivateRuntimePath(entry)) return []
return [[key, omitPrivateRuntimeSourcePaths(entry)]]
}))
}

function assertNoPrivateRuntimePaths(value) {
if (privateRuntimeSourceRoot && JSON.stringify(value).includes(privateRuntimeSourceRoot)) {
throw new Error("Runtime source paths must never be persisted in workflow results or artifacts.")
}
}

function string(value) {
return typeof value === "string" ? value.trim() : ""
}
Expand Down Expand Up @@ -200,9 +178,13 @@ async function redactArtifactFiles(directory) {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = join(directory, entry.name)
if (entry.isDirectory()) await redactArtifactFiles(path)
if (entry.isFile() && (await stat(path)).size <= 4 * 1024 * 1024) {
if (entry.isFile() && path.endsWith(".json") && (await stat(path)).size <= 4 * 1024 * 1024) {
const contents = await readFile(path, "utf8").catch(() => null)
if (contents !== null) await writeFile(path, bounded(contents, 4 * 1024 * 1024))
if (contents !== null) {
const sanitized = sanitizeRuntimeSourceJson(bounded(contents, 4 * 1024 * 1024), privateRuntimeSourceRootForSanitization)
assertNoRuntimeSourcePaths(sanitized, privateRuntimeSourceRootForSanitization)
await writeFile(path, sanitized)
}
}
}
}
Expand All @@ -221,6 +203,7 @@ const resultPath = join(workspace, ".codebox", "agent-task-workflow-result.json"
const controlledCodeboxPath = resolve(requestPath, "..")
const nativeResultPath = join(controlledCodeboxPath, "native-agent-task-result.json")
let privateRuntimeSourceRoot = ""
let privateRuntimeSourceRootForSanitization = ""
let cleaningPrivateRuntimeSources = false
async function cleanupPrivateRuntimeSources() {
if (cleaningPrivateRuntimeSources || !privateRuntimeSourceRoot) return
Expand Down Expand Up @@ -263,6 +246,7 @@ const materializedRuntimeSources = request.run_agent && !request.dry_run
? await materializeRuntimeSources(runtimeSources, { policy: externalPackagePolicy, forbiddenRoots: [workspace, artifactsPath] })
: undefined
privateRuntimeSourceRoot = materializedRuntimeSources?.root ?? ""
privateRuntimeSourceRootForSanitization = privateRuntimeSourceRoot
await output("runtime_source_root", privateRuntimeSourceRoot)
const runtimeSourceInputs = (materializedRuntimeSources?.lowered ?? []).reduce((input, lowered) => {
for (const [key, entries] of Object.entries(lowered)) input[key] = [...(input[key] ?? []), ...entries]
Expand Down Expand Up @@ -340,11 +324,10 @@ const nativeRuntimeResult = request.run_agent && !request.dry_run
? await readNativeResult(nativeResultPath, controlledCodeboxPath, secretValues, redact)
: {}
await rm(nativeResultPath, { force: true })
assertNoPrivateRuntimePaths(nativeRuntimeResult)
const runtimeResult = omitPrivateRuntimeSourcePaths(nativeRuntimeResult)
assertNoPrivateRuntimePaths(runtimeResult)
const runtimeResult = sanitizeRuntimeSourceValue(nativeRuntimeResult, privateRuntimeSourceRootForSanitization)
assertNoRuntimeSourcePaths(runtimeResult, privateRuntimeSourceRootForSanitization)
await cleanupPrivateRuntimeSources()
assertNoPrivateRuntimePaths(runtimeResult)
assertNoRuntimeSourcePaths(runtimeResult, privateRuntimeSourceRootForSanitization)

await redactArtifactFiles(artifactsPath)

Expand Down Expand Up @@ -408,7 +391,9 @@ const result = {
...(projectionError ? { projection_error: projectionError } : {}),
}

await writeFile(resultPath, `${JSON.stringify(redact(result), null, 2)}\n`)
const sanitizedResult = sanitizeRuntimeSourceValue(redact(result), privateRuntimeSourceRootForSanitization)
assertNoRuntimeSourcePaths(sanitizedResult, privateRuntimeSourceRootForSanitization)
await writeFile(resultPath, `${JSON.stringify(sanitizedResult, null, 2)}\n`)
await output("job_status", status)
await output("transcript_json", JSON.stringify(agentResult.refs?.transcripts || []))
await output("transcript_summary", `${request.workload?.label || "Run Agent Task"}: ${status}`)
Expand Down
38 changes: 6 additions & 32 deletions .github/scripts/run-agent-task/prepare-agent-task-upload.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import { constants } from "node:fs"
import { lstat, mkdir, open, readdir, readFile, rm, writeFile } from "node:fs/promises"
import { isUtf8 } from "node:buffer"
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 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"))
const secretValues = ["OPENAI_API_KEY", "MODEL_PROVIDER_SECRET_1", "MODEL_PROVIDER_SECRET_2", "MODEL_PROVIDER_SECRET_3", "MODEL_PROVIDER_SECRET_4", "MODEL_PROVIDER_SECRET_5", "GITHUB_TOKEN", "GH_TOKEN", "ACCESS_TOKEN", "EXTERNAL_PACKAGE_SOURCE_POLICY"].map((name) => process.env[name]).filter(Boolean)
const runtimeSourceRoot = process.env.WP_CODEBOX_RUNTIME_SOURCE_ROOT ? resolve(process.env.WP_CODEBOX_RUNTIME_SOURCE_ROOT) : ""
const runtimeSourcePrefix = process.env.WP_CODEBOX_RUNTIME_SOURCE_PREFIX ? resolve(process.env.WP_CODEBOX_RUNTIME_SOURCE_PREFIX) : ""
const runtimeSourceRoots = [runtimeSourceRoot, runtimeSourcePrefix].filter(Boolean)
const RUNTIME_SOURCE_TREE = /(^|\/)(prepared-plugins|agents-api|ai-provider-for-openai)(\/|$)/
const RUNTIME_SOURCE_FILE = /^(agents-api\.php|plugin\.php)$/
const RUNTIME_SOURCE_CONTENT = /(?:Plugin Name:|WP_Agents_Registry|OpenAiProvider)/
Expand All @@ -17,44 +20,15 @@ function redact(value) {
return secretValues.reduce((output, secret) => output.split(secret).join("[REDACTED]"), value)
}

const PRIVATE_RUNTIME_PATH_FIELDS = new Set(["source", "path", "sourceRoot", "originalSource", "preparedPath", "requestedPath", "source_package_root", "artifacts_path", "runtime_input_path", "task_path", "result_path", "event_stream_path", "materialization_result_path"])

function isPrivateRuntimePath(value) {
if (!runtimeSourceRoot || typeof value !== "string") return false
const path = resolve(value)
const contained = relative(runtimeSourceRoot, path)
return path === runtimeSourceRoot || (contained !== ".." && !contained.startsWith(`..${String.fromCharCode(47)}`) && !isAbsolute(contained))
}

function omitPrivateRuntimeSourcePaths(value) {
if (Array.isArray(value)) return value.map(omitPrivateRuntimeSourcePaths)
if (!value || typeof value !== "object") return value
return Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => {
if (PRIVATE_RUNTIME_PATH_FIELDS.has(key) && isPrivateRuntimePath(entry)) return []
if (key === "runtime_sources" && Array.isArray(entry)) return [[key, entry.map(runtimeSourceProvenance)]]
return [[key, omitPrivateRuntimeSourcePaths(entry)]]
}))
}

function runtimeSourceProvenance(source) {
if (!source || typeof source !== "object" || Array.isArray(source)) return source
const descriptor = source
const provenance = { role: descriptor.role }
if (descriptor.source?.type === "https_zip") {
provenance.source = { type: "https_zip", url: descriptor.source.url, sha256: descriptor.source.sha256, ...(descriptor.source.archive_root ? { archive_root: descriptor.source.archive_root } : {}) }
} else {
Object.assign(provenance, ...["repository", "revision", "path", "digest"].flatMap((key) => descriptor[key] ? [{ [key]: descriptor[key] }] : []))
}
if (descriptor.role === "provider_plugin" && Array.isArray(descriptor.metadata?.providers)) provenance.providers = descriptor.metadata.providers
return provenance
}

function sanitizeText(text) {
try {
return `${JSON.stringify(omitPrivateRuntimeSourcePaths(JSON.parse(text)), null, 2)}\n`
} catch {
return text
}
return sanitizeRuntimeSourceJson(text, runtimeSourceRoots)
}

async function stageFile(source, destination) {
Expand All @@ -78,7 +52,7 @@ async function stageFile(source, destination) {
throw new Error("Prepared runtime plugin source contents must never be staged for artifact upload.")
}
text = sanitizeText(text)
if (runtimeSourceRoot && text.includes(runtimeSourceRoot)) throw new Error("Runtime source paths must never be persisted in artifact uploads.")
assertNoRuntimeSourcePaths(text, runtimeSourceRoots, "Runtime source paths must never be persisted in artifact uploads.")
await writeFile(destination, redact(text))
return true
}
Expand All @@ -100,7 +74,7 @@ async function assertNoPrivateRuntimePaths(directory) {
if (entry.isDirectory()) await assertNoPrivateRuntimePaths(path)
else if (entry.isFile()) {
const contents = await readFile(path, "utf8")
if (runtimeSourceRoot && contents.includes(runtimeSourceRoot)) throw new Error("Runtime source paths must never be persisted in artifact uploads.")
assertNoRuntimeSourcePaths(contents, runtimeSourceRoots, "Runtime source paths must never be persisted in artifact uploads.")
if (RUNTIME_SOURCE_TREE.test(path) || RUNTIME_SOURCE_FILE.test(entry.name) || RUNTIME_SOURCE_CONTENT.test(contents)) throw new Error("Prepared runtime plugin sources must never be persisted in artifact uploads.")
}
}
Expand Down
52 changes: 52 additions & 0 deletions .github/scripts/run-agent-task/runtime-source-sanitizer.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { resolve } from "node:path"

const PRIVATE_RUNTIME_SOURCE_FIELDS = new Set(["source_package_root"])
export const RUNTIME_SOURCE_PLACEHOLDER = "[runtime-source]"

function runtimeSourceRoots(root) {
return (Array.isArray(root) ? root : [root]).filter((entry) => typeof entry === "string" && entry).map((entry) => resolve(entry)).sort((left, right) => right.length - left.length)
}

function runtimeSourceProvenance(source) {
if (!source || typeof source !== "object" || Array.isArray(source)) return source
const descriptor = source
const provenance = { role: descriptor.role }
if (descriptor.source?.type === "https_zip") {
provenance.source = { type: "https_zip", url: descriptor.source.url, sha256: descriptor.source.sha256, ...(descriptor.source.archive_root ? { archive_root: descriptor.source.archive_root } : {}) }
} else {
Object.assign(provenance, ...["repository", "revision", "path", "digest"].flatMap((key) => descriptor[key] ? [{ [key]: descriptor[key] }] : []))
}
if (descriptor.role === "provider_plugin" && Array.isArray(descriptor.metadata?.providers)) provenance.providers = descriptor.metadata.providers
return provenance
}

export function sanitizeRuntimeSourceText(value, root) {
if (typeof value !== "string") return value
return runtimeSourceRoots(root).reduce((sanitized, sourceRoot) => sanitized.split(sourceRoot).join(RUNTIME_SOURCE_PLACEHOLDER), value)
}

// Runtime results may place paths anywhere, including diagnostics, stack traces,
// command arguments, metadata, and object keys.
export function sanitizeRuntimeSourceValue(value, root) {
if (typeof value === "string") return sanitizeRuntimeSourceText(value, root)
if (Array.isArray(value)) return value.map((entry) => sanitizeRuntimeSourceValue(entry, root))
if (!value || typeof value !== "object") return value
return Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => {
if (PRIVATE_RUNTIME_SOURCE_FIELDS.has(key)) return []
if (key === "runtime_sources" && Array.isArray(entry)) return [[key, entry.map(runtimeSourceProvenance).map((source) => sanitizeRuntimeSourceValue(source, root))]]
return [[sanitizeRuntimeSourceText(key, root), sanitizeRuntimeSourceValue(entry, root)]]
}))
}

export function sanitizeRuntimeSourceJson(text, root) {
try {
return `${JSON.stringify(sanitizeRuntimeSourceValue(JSON.parse(text), root), null, 2)}\n`
} catch {
return sanitizeRuntimeSourceText(text, root)
}
}

export function assertNoRuntimeSourcePaths(value, root, message = "Runtime source paths must never be persisted in workflow results or artifacts.") {
const serialized = JSON.stringify(value)
if (runtimeSourceRoots(root).some((sourceRoot) => serialized.includes(sourceRoot))) throw new Error(message)
}
2 changes: 2 additions & 0 deletions .github/workflows/agent-task-contracts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ on:
- "docs/agent-task-reusable-workflow.md"
- "fixtures/agent-task-reusable-workflow-consumer*.yml"
- "fixtures/agent-task-runtime-sources-run-29299109269.json"
- "fixtures/agent-task-runtime-paths-run-29305012941.json"
- "package-lock.json"
- "package.json"
- "tests/agent-task-*.test.ts"
Expand All @@ -29,6 +30,7 @@ on:
- "docs/agent-task-reusable-workflow.md"
- "fixtures/agent-task-reusable-workflow-consumer*.yml"
- "fixtures/agent-task-runtime-sources-run-29299109269.json"
- "fixtures/agent-task-runtime-paths-run-29305012941.json"
- "package-lock.json"
- "package.json"
- "tests/agent-task-*.test.ts"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/run-agent-task.yml
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ jobs:
AGENT_TASK_WORKSPACE: ${{ github.workspace }}/workspace
AGENT_TASK_REQUEST_PATH: ${{ github.workspace }}/.codebox/agent-task-request.json
AGENT_TASK_UPLOAD_PATH: ${{ github.workspace }}/workspace/.codebox/agent-task-upload
WP_CODEBOX_RUNTIME_SOURCE_ROOT: ${{ steps.execute.outputs.runtime_source_root }}
WP_CODEBOX_RUNTIME_SOURCE_PREFIX: ${{ runner.temp }}/wp-codebox-runtime-sources-
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
MODEL_PROVIDER_SECRET_1: ${{ secrets.MODEL_PROVIDER_SECRET_1 }}
MODEL_PROVIDER_SECRET_2: ${{ secrets.MODEL_PROVIDER_SECRET_2 }}
Expand Down
38 changes: 38 additions & 0 deletions fixtures/agent-task-runtime-paths-run-29305012941.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"run_id": "29305012941",
"runtime_root": "/tmp/wp-codebox-runtime-sources-HrTMNi",
"success": {
"schema": "wp-codebox/agent-task-run/v1",
"success": true,
"status": "succeeded",
"agent_task_run_result": {
"schema": "wp-codebox/agent-task-run-result/v1",
"success": true,
"status": "succeeded"
},
"outputs": {
"metadata": {
"source_package_root": "/tmp/wp-codebox-runtime-sources-HrTMNi/prepared-runtime-sources",
"nested": {
"/tmp/wp-codebox-runtime-sources-HrTMNi/key": "/tmp/wp-codebox-runtime-sources-HrTMNi/value"
}
},
"command": {
"args": ["--source=/tmp/wp-codebox-runtime-sources-HrTMNi/source-0", "/tmp/wp-codebox-runtime-sources-HrTMNi/source-1"]
}
}
},
"failure": {
"schema": "wp-codebox/agent-task-run/v1",
"success": false,
"status": "failed",
"agent_task_run_result": {
"schema": "wp-codebox/agent-task-run-result/v1",
"success": false,
"status": "failed"
},
"diagnostics": [{ "message": "Prepared source at /tmp/wp-codebox-runtime-sources-HrTMNi/source-0 failed" }],
"stack": "Error: failed\n at /tmp/wp-codebox-runtime-sources-HrTMNi/source-0/plugin.php:1:1",
"paths": ["/tmp/wp-codebox-runtime-sources-HrTMNi", "/tmp/wp-codebox-runtime-sources-HrTMNi-suffix"]
}
}
Loading
Loading