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
52 changes: 38 additions & 14 deletions .github/scripts/run-agent-task/execute-native-agent-task.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { rmSync } from "node:fs"
import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"
import { join, relative, resolve } from "node:path"
import { pathToFileURL } from "node:url"
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"
import { publishRunnerWorkspace } from "./runner-workspace-publisher.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 Down Expand Up @@ -127,7 +129,7 @@ function accessFailure(request) {
if (!allowed.includes(target) || !tokenRepos.includes(target)) return "Target repository is not explicitly authorized by allowed_repos and access_token_repos."
if (!caller) return "Caller repository is required for publication authorization."
if (target !== caller && process.env.EXPLICIT_ACCESS_TOKEN_CONFIGURED !== "true") return "An explicit ACCESS_TOKEN is required for cross-repository publication."
if (!string(process.env.GITHUB_TOKEN)) return "No effective GitHub token is available for runner workspace tools."
if (!string(process.env.ACCESS_TOKEN || process.env.GITHUB_TOKEN)) return "No effective GitHub token is available for runner workspace tools."
return ""
}

Expand Down Expand Up @@ -276,11 +278,10 @@ process.once("exit", () => { if (privateRuntimeSourceRoot) rmSync(privateRuntime
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
process.once(signal, () => { cleanupPrivateRuntimeSources().finally(() => process.exit(128)) })
}
const runnerWorkspaceTools = [
"workspace-read", "workspace-ls", "workspace-grep", "workspace-write", "workspace-edit", "workspace-apply-patch",
"workspace-git-status", "workspace-git-diff", "workspace-git-add", "workspace-git-commit", "workspace-git-push",
"create-github-pull-request", "create-github-issue", "comment-github-pull-request",
]
const runnerWorkspaceTools = [
"workspace_read", "workspace_ls", "workspace_grep", "workspace_write", "workspace_edit", "workspace_apply_patch",
"workspace_show", "workspace_git_status", "workspace_git_diff",
]

function runtimeMetadataForExecutionLocation(executionLocation) {
if (executionLocation === "sandbox") return { environment: "runtime_local", capability_scope: "runtime_local" }
Expand All @@ -297,10 +298,11 @@ if (accessError) {
throw error
}

const materializedPackage = request.run_agent && !request.dry_run
const skipMaterialization = process.env.NODE_ENV === "test" && process.env.WP_CODEBOX_TEST_SKIP_MATERIALIZATION === "true"
const materializedPackage = request.run_agent && !request.dry_run && !skipMaterialization
? await materializeExternalNativePackage(externalPackageSource, { policy: externalPackagePolicy })
: undefined
const materializedRuntimeSources = request.run_agent && !request.dry_run
const materializedRuntimeSources = request.run_agent && !request.dry_run && !skipMaterialization
? await materializeRuntimeSources(runtimeSources, { policy: externalPackagePolicy, forbiddenRoots: [workspace, artifactsPath] })
: undefined
privateRuntimeSourceRoot = materializedRuntimeSources?.root ?? ""
Expand Down Expand Up @@ -330,11 +332,11 @@ const taskInput = {
structured_artifacts: request.artifacts?.declarations || [],
secret_env: ["OPENAI_API_KEY", "MODEL_PROVIDER_SECRET_1", "MODEL_PROVIDER_SECRET_2", "MODEL_PROVIDER_SECRET_3", "MODEL_PROVIDER_SECRET_4", "MODEL_PROVIDER_SECRET_5"].filter((name) => process.env[name]),
...runtimeSourceInputs,
allowed_tools: runnerWorkspaceTools,
allowed_tools: runnerWorkspaceTools,
sandbox_tool_policy: {
schema: "wp-codebox/sandbox-tool-policy/v1",
version: 1,
tools: runnerWorkspaceTools.map((id) => ({ id, runtime_tool_id: id, execution_location: "parent", transport_visibility: "visible", allowed: true, runtime: runtimeMetadataForExecutionLocation("parent") })),
tools: runnerWorkspaceTools.map((id) => ({ id, runtime_tool_id: id, execution_location: "sandbox", transport_visibility: "sandbox", allowed: true, runtime: runtimeMetadataForExecutionLocation("sandbox") })),
},
max_turns: request.limits?.max_turns,
task_timeout_seconds: Math.ceil(Number(request.limits?.time_budget_ms || 0) / 1000),
Expand All @@ -357,16 +359,17 @@ const taskInput = {
runner_workspace: { ...record(request.runner_workspace), allowed_repos: request.access.allowed_repos },
target_repo: request.target_repo,
writable_paths: request.writable_paths,
runner_workspace_policy: { allowed_repos: request.access.allowed_repos },
runner_workspace_policy: { allowed_repos: request.access.allowed_repos, writable_paths: request.writable_paths },
},
artifact_declarations: request.artifacts?.declarations || [],
required_artifacts: requiredArtifacts(request.artifacts?.declarations),
output_projections: [],
metadata: { workload: request.workload, ...(materializedPackage ? { imported_agent: materializedPackage.identity } : {}), runtime_sources: materializedRuntimeSources?.descriptors ?? [] },
},
},
},
}
},
workspaces: request.runner_workspace?.enabled ? [{ target: "/workspace", mode: "readwrite", sourceMode: "repo-backed", seed: { type: "directory", source: workspace, excludePaths: [".git/**", ".codebox/**", "node_modules/**", "vendor/**", "dist/**", "build/**", "coverage/**", ".cache/**"] } }] : [],
}

await writeFile(executionInputPath, `${JSON.stringify(taskInput, null, 2)}\n`)

Expand All @@ -384,6 +387,15 @@ const nativeRuntimeResult = request.run_agent && !request.dry_run
await rm(nativeResultPath, { force: true })
const runtimeResult = sanitizeRuntimeSourceValue(nativeRuntimeResult, privateRuntimeSourceRootForSanitization)
assertNoRuntimeSourcePaths(runtimeResult, privateRuntimeSourceRootForSanitization)
let workspaceApply = { status: "no-op", changedFiles: [] }
let runnerWorkspaceCore = null
if (execution.code === 0 && runtimeResult.success === true && request.runner_workspace?.enabled) {
runnerWorkspaceCore = await import(pathToFileURL(join(codeboxRoot, "packages/runtime-core/dist/runner-workspace-apply.js")).href)
const publicCore = await import(pathToFileURL(join(codeboxRoot, "packages/runtime-core/dist/public.js")).href)
const refs = publicCore.normalizePublicArtifactRefDTOs(runtimeResult)
.filter((ref) => ref.kind === "codebox-patch" || ref.kind === "codebox-changed-files")
workspaceApply = await runnerWorkspaceCore.applyRunnerWorkspacePatch({ artifactRoot: artifactsPath, artifactRefs: refs, workspaceRoot: workspace, writablePaths: String(request.writable_paths || "").split(",").map((value) => value.trim()).filter(Boolean) })
}
await cleanupPrivateRuntimeSources()
assertNoRuntimeSourcePaths(runtimeResult, privateRuntimeSourceRootForSanitization)

Expand All @@ -409,7 +421,19 @@ if (execution.code === 0 && request.run_agent && !request.dry_run) {
const verificationPassed = verification.every((check) => check.success)
const runtimeRecord = record(runtimeResult)
const agentResult = record(runtimeRecord.agent_task_run_result)
const publication = resultValue(runtimeRecord, "outputs.artifact_result.result.outputs.runner_workspace_publication")
let publication = resultValue(runtimeRecord, "metadata.runner_workspace_publication")
if (execution.code === 0 && runtimeRecord.success === true && verificationPassed && workspaceApply.status === "applied") {
await runnerWorkspaceCore.verifyRunnerWorkspaceIntegrity(workspaceApply.integrity)
const testPublisher = string(process.env.WP_CODEBOX_TEST_PUBLISHER_MODULE)
const publisher = testPublisher ? (await import(pathToFileURL(resolve(testPublisher)).href)).publishRunnerWorkspace : publishRunnerWorkspace
const testHook = testPublisher && process.env.WP_CODEBOX_TEST_PUBLISHER_HOOK
? JSON.parse(process.env.WP_CODEBOX_TEST_PUBLISHER_HOOK)
: undefined
publication = await publisher({ request, changedFiles: workspaceApply.changedFiles, publicationFiles: workspaceApply.publicationFiles, token: process.env.ACCESS_TOKEN || process.env.GITHUB_TOKEN, ...(testHook ? { testHook } : {}) })
runtimeRecord.metadata = { ...record(runtimeRecord.metadata), runner_workspace_publication: publication }
runtimeRecord.outputs = { ...record(runtimeRecord.outputs), runner_workspace_publication: publication }
}
if (request.success?.requires_pr === true && workspaceApply.status === "no-op" && !publication) throw new Error("success_requires_pr cannot succeed for a no-op runner workspace task.")
let evaluatedProjections = {}
let projectionError = ""
try {
Expand Down
51 changes: 51 additions & 0 deletions .github/scripts/run-agent-task/runner-workspace-publisher.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@

function string(value) { return typeof value === "string" ? value.trim() : "" }
function record(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {} }

export async function publishRunnerWorkspace({ request, changedFiles, publicationFiles, workspace, token, fetchImpl = fetch }) {
const targetRepo = string(request.target_repo).toLowerCase()
const config = record(request.runner_workspace)
const configuredRepo = string(config.repo).toLowerCase()
const allowed = Array.isArray(record(request.access).allowed_repos) ? request.access.allowed_repos.map((value) => string(value).toLowerCase()) : []
if (!token) throw new Error("No GitHub token is available for runner workspace publication.")
if (!targetRepo || targetRepo !== configuredRepo || !allowed.includes(targetRepo)) throw new Error("Runner workspace publication repository is not authorized.")
const base = string(config.base || config.base_branch || "main")
const prefix = string(config.branch_prefix || "wp-codebox/agent-task/")
const runId = string(config.run_id || request.workload?.id || "agent-task").replace(/[^A-Za-z0-9._/-]+/g, "-")
const head = `${prefix}${runId}`
if (!/^[A-Za-z0-9._/-]+$/.test(prefix) || !head.startsWith(prefix) || head.includes("..") || !/^[A-Za-z0-9._/-]+$/.test(base)) throw new Error("Runner workspace branch configuration is invalid.")

const api = async (method, path, body) => {
const response = await fetchImpl(`https://api.github.com/repos/${targetRepo}${path}`, { method, headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, "X-GitHub-Api-Version": "2022-11-28", ...(body ? { "Content-Type": "application/json" } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) })
const payload = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(`GitHub API ${method} ${path} failed with ${response.status}.`)
return payload
}
let existing = null
try { existing = await api("GET", `/git/ref/heads/${head.split("/").map(encodeURIComponent).join("/")}`) } catch (error) { if (!String(error.message).includes(" 404.")) throw error }
// Existing PR branches are append-only publication targets. Their current tree
// is the base so files from earlier agent turns cannot disappear.
const parent = string(existing?.object?.sha)
const baseRef = parent ? null : await api("GET", `/git/ref/heads/${encodeURIComponent(base)}`)
const baseSha = parent || string(baseRef.object?.sha)
const baseCommit = await api("GET", `/git/commits/${baseSha}`)
const tree = []
const captured = Array.isArray(publicationFiles) ? publicationFiles : []
if (!captured.length) throw new Error("Runner workspace publication requires immutable approved file content.")
for (const changed of captured) {
const relativePath = string(changed?.path)
if (!relativePath || relativePath.startsWith("/") || relativePath.split("/").some((part) => !part || part === "." || part === ".." || part === ".git")) throw new Error("Publication changed file path is invalid.")
if (changed.deleted) { tree.push({ path: relativePath, mode: "100644", type: "blob", sha: null }); continue }
if (changed.mode !== "100644" && changed.mode !== "100755" || typeof changed.content !== "string") throw new Error("Publication file is not an approved regular file.")
const blob = await api("POST", "/git/blobs", { content: changed.content, encoding: "base64" })
tree.push({ path: relativePath, mode: changed.mode, type: "blob", sha: string(blob.sha) })
}
const nextTree = await api("POST", "/git/trees", { base_tree: string(baseCommit.tree?.sha), tree })
const commit = await api("POST", "/git/commits", { message: string(config.commit_message || request.workload?.label || "Apply agent task changes"), tree: string(nextTree.sha), parents: [baseSha] })
if (existing) await api("PATCH", `/git/refs/heads/${head.split("/").map(encodeURIComponent).join("/")}`, { sha: string(commit.sha), force: false })
else await api("POST", "/git/refs", { ref: `refs/heads/${head}`, sha: string(commit.sha) })
const pulls = await api("GET", `/pulls?state=open&head=${encodeURIComponent(`${targetRepo.split("/")[0]}:${head}`)}&base=${encodeURIComponent(base)}`)
const pull = Array.isArray(pulls) && pulls[0] ? pulls[0] : await api("POST", "/pulls", { title: string(config.title || request.workload?.label || "Apply agent task changes"), head, base, body: string(config.body || "") })
if (string(pull.base?.repo?.full_name).toLowerCase() !== targetRepo || string(pull.head?.ref) !== head || string(pull.base?.ref) !== base || !/^https:\/\/github\.com\//.test(string(pull.html_url))) throw new Error("GitHub publication response did not match the requested repository and branches.")
return { schema: "wp-codebox/runner-workspace-publication-result/v1", success: true, status: "published", backend: "github-rest", branch: { base, head, name: head }, commit: { sha: string(commit.sha) }, pull_request: { number: pull.number, url: pull.html_url, reused: Boolean(Array.isArray(pulls) && pulls[0]), opened: !(Array.isArray(pulls) && pulls[0]) } }
}
13 changes: 6 additions & 7 deletions docs/agent-task-reusable-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,12 @@ package file is mounted into or visible from the agent workspace.
## Runtime Coverage

The repository's native-loop and PHP runtime-package tests execute generated
PHP with narrow WordPress and native agent-registry
shims. They prove digest-then-schema validation, canonical import, exact slug
resolution, `agents/chat` selection, invocation order, and temporary-file
cleanup. They are not a WordPress Playground end-to-end test: this repository
does not provide a fixture that boots both the agent-registry plugin and a real
provider-backed chat turn in Playground. The existing Playground CLI tests use
injected CLI modules and do not exercise that plugin/provider path.
PHP with narrow WordPress and native agent-registry shims. The non-skippable
deterministic WordPress Playground end-to-end test additionally boots the
agent registry and OpenAI provider path with a local HTTP interceptor. Its
three-turn fixture reads and edits a seeded sandbox workspace, verifies
provider tool outputs, and captures the canonical changed-files manifest and
patch without network access or provider billing.

To run the optional cross-repository package coverage, use a Docs Agent checkout
pinned to commit `3da1b8076359db9bf9f4ee7dadcc3932c080ed71`, which contains
Expand Down
2 changes: 1 addition & 1 deletion fixtures/agent-task-runtime-sources-run-29299109269.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"run_id": "29299109269",
"runtime_sources": [
{ "version": 1, "role": "component", "repository": "automattic/agents-api", "revision": "59d1e6b473f22498e40e279130bbb4f9bcde3b73", "path": ".", "metadata": { "slug": "agents-api", "loadAs": "mu-plugin", "activate": false } },
{ "version": 1, "role": "component", "repository": "automattic/agents-api", "revision": "fbd5641d412af76a1b8288426a577e750838b4be", "path": ".", "metadata": { "slug": "agents-api", "loadAs": "mu-plugin", "activate": false } },
{ "version": 1, "role": "provider_plugin", "source": { "type": "https_zip", "url": "https://downloads.wordpress.org/plugin/ai-provider-for-openai.1.0.3.zip", "sha256": "48f3c0c714b3164cda79d320829830d5a0ea1116e0b19653da8af898a22d3bb6", "archive_root": "ai-provider-for-openai" }, "metadata": { "slug": "ai-provider-for-openai", "pluginFile": "plugin.php", "activate": true, "providers": ["openai"] } },
{ "version": 1, "role": "bundled_library", "repository": "wordpress/php-ai-client", "revision": "631704201d15ffeff7091ad3bc7156db74054956", "path": ".", "metadata": { "library": "php-ai-client", "strategy": "wordpress-scoped-bundle" } }
]
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@
"test:source-package-compiler-primitives": "tsx tests/source-package-compiler-primitives.test.ts",
"test:source-root-preparation": "tsx tests/source-root-preparation.test.ts",
"test:workspace-preload-artifacts": "tsx tests/workspace-preload-artifacts.test.ts",
"test:runner-workspace-apply": "tsx tests/runner-workspace-apply.test.ts",
"test:runner-workspace-publisher": "node tests/runner-workspace-publisher.test.mjs",
"test:native-agent-task-lifecycle": "node tests/execute-native-agent-task-lifecycle.test.mjs",
"test:bench-command-step-behavior": "tsx tests/bench-command-step-behavior.test.ts",
"test:generic-primitives": "npm run test:artifact-path-primitives && npm run test:browser-callback-materialization-contracts && npm run test:source-package-compiler-primitives && npm run test:bench-command-step-behavior && npm run test:generic-ability-runtime-run",
"test:browser-artifact-session": "tsx tests/browser-artifact-session.test.ts",
Expand Down
12 changes: 10 additions & 2 deletions packages/runtime-core/src/agent-task-run-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ function normalizeArtifacts(result: Record<string, unknown>, agentResult: Record
const artifacts: AgentTaskRunArtifactRef[] = []
const artifactPolicy = objectValue(result.workspace_artifact_policy ?? result.workspaceArtifactPolicy)
const publicUrlRoot = stringValue(artifactPolicy.public_url_root ?? artifactPolicy.publicUrlRoot)
for (const artifact of arrayObjects(result.artifacts)) {
for (const artifact of artifactRecords(result.artifacts)) {
appendUniqueArtifact(artifacts, withPublicArtifactUrl(artifactFromResultArtifact(artifact), publicUrlRoot, ""))
}

Expand Down Expand Up @@ -266,6 +266,14 @@ function artifactFromResultArtifact(artifact: Record<string, unknown>): AgentTas
}) as AgentTaskRunArtifactRef
}

function artifactRecords(value: unknown): Record<string, unknown>[] {
const list = arrayObjects(value)
if (list.length > 0) return list
const artifact = objectValue(value)
if (stringValue(artifact.kind)) return [artifact]
return arrayObjects(artifact.items ?? artifact.files ?? artifact.artifacts)
}

function artifactFromAgentResult(id: string, kind: string, root: string, metadata: Record<string, unknown>): AgentTaskRunArtifactRef {
const path = artifactPath(root, stringValue(metadata.artifact))
return stripUndefined({
Expand Down Expand Up @@ -296,7 +304,7 @@ function noOpMetadata(result: Record<string, unknown>, agentResult: Record<strin
}

function agentResultRecord(result: Record<string, unknown>): Record<string, unknown> {
return firstObject(objectValue(result.run).agentResult, result.agentResult)
return firstObject(objectValue(result.run).agentResult, result.agentResult, result.agent_result)
}

function completionOutcomeRecord(result: Record<string, unknown>): Record<string, unknown> {
Expand Down
1 change: 1 addition & 0 deletions packages/runtime-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export * from "./fanout-execution.js"
export * from "./live-progress.js"
export * from "./recipe-run-summary.js"
export * from "./runner-workspace-publication.js"
export * from "./runner-workspace-apply.js"
export * from "./artifact-storage.js"
export * from "./browser-session-origin.js"
export * from "./browser-contained-site-contracts.js"
Expand Down
Loading
Loading