Skip to content

Commit 9ad48e6

Browse files
authored
Wire copy-on-write workspaces into agent tasks (#1785)
* Wire copy-on-write workspaces into agent tasks * Harden runner workspace publication * Use runtime tool overlays for sandbox workspaces * Fix runner workspace publication parent
1 parent 7dd32b7 commit 9ad48e6

22 files changed

Lines changed: 1036 additions & 203 deletions

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

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { rmSync } from "node:fs"
22
import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"
33
import { join, relative, resolve } from "node:path"
4+
import { pathToFileURL } from "node:url"
45
import { spawn } from "node:child_process"
56
import { materializeExternalNativePackage, materializeRuntimeSources, normalizeExternalPackageSource, normalizeRuntimeSources, parseExternalPackageSourcePolicy, validateRuntimeSourceModel } from "./materialize-external-native-package.mjs"
67
import { readNativeResult } from "./native-result-file.mjs"
78
import { assertNoRuntimeSourcePaths, sanitizeRuntimeSourceJson, sanitizeRuntimeSourceText, sanitizeRuntimeSourceValue } from "./runtime-source-sanitizer.mjs"
9+
import { publishRunnerWorkspace } from "./runner-workspace-publisher.mjs"
810

911
const requestPath = process.env.AGENT_TASK_REQUEST_PATH || ".codebox/agent-task-request.json"
1012
const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd())
@@ -127,7 +129,7 @@ function accessFailure(request) {
127129
if (!allowed.includes(target) || !tokenRepos.includes(target)) return "Target repository is not explicitly authorized by allowed_repos and access_token_repos."
128130
if (!caller) return "Caller repository is required for publication authorization."
129131
if (target !== caller && process.env.EXPLICIT_ACCESS_TOKEN_CONFIGURED !== "true") return "An explicit ACCESS_TOKEN is required for cross-repository publication."
130-
if (!string(process.env.GITHUB_TOKEN)) return "No effective GitHub token is available for runner workspace tools."
132+
if (!string(process.env.ACCESS_TOKEN || process.env.GITHUB_TOKEN)) return "No effective GitHub token is available for runner workspace tools."
131133
return ""
132134
}
133135

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

285286
function runtimeMetadataForExecutionLocation(executionLocation) {
286287
if (executionLocation === "sandbox") return { environment: "runtime_local", capability_scope: "runtime_local" }
@@ -297,10 +298,11 @@ if (accessError) {
297298
throw error
298299
}
299300

300-
const materializedPackage = request.run_agent && !request.dry_run
301+
const skipMaterialization = process.env.NODE_ENV === "test" && process.env.WP_CODEBOX_TEST_SKIP_MATERIALIZATION === "true"
302+
const materializedPackage = request.run_agent && !request.dry_run && !skipMaterialization
301303
? await materializeExternalNativePackage(externalPackageSource, { policy: externalPackagePolicy })
302304
: undefined
303-
const materializedRuntimeSources = request.run_agent && !request.dry_run
305+
const materializedRuntimeSources = request.run_agent && !request.dry_run && !skipMaterialization
304306
? await materializeRuntimeSources(runtimeSources, { policy: externalPackagePolicy, forbiddenRoots: [workspace, artifactsPath] })
305307
: undefined
306308
privateRuntimeSourceRoot = materializedRuntimeSources?.root ?? ""
@@ -330,11 +332,11 @@ const taskInput = {
330332
structured_artifacts: request.artifacts?.declarations || [],
331333
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]),
332334
...runtimeSourceInputs,
333-
allowed_tools: runnerWorkspaceTools,
335+
allowed_tools: runnerWorkspaceTools,
334336
sandbox_tool_policy: {
335337
schema: "wp-codebox/sandbox-tool-policy/v1",
336338
version: 1,
337-
tools: runnerWorkspaceTools.map((id) => ({ id, runtime_tool_id: id, execution_location: "parent", transport_visibility: "visible", allowed: true, runtime: runtimeMetadataForExecutionLocation("parent") })),
339+
tools: runnerWorkspaceTools.map((id) => ({ id, runtime_tool_id: id, execution_location: "sandbox", transport_visibility: "sandbox", allowed: true, runtime: runtimeMetadataForExecutionLocation("sandbox") })),
338340
},
339341
max_turns: request.limits?.max_turns,
340342
task_timeout_seconds: Math.ceil(Number(request.limits?.time_budget_ms || 0) / 1000),
@@ -357,16 +359,17 @@ const taskInput = {
357359
runner_workspace: { ...record(request.runner_workspace), allowed_repos: request.access.allowed_repos },
358360
target_repo: request.target_repo,
359361
writable_paths: request.writable_paths,
360-
runner_workspace_policy: { allowed_repos: request.access.allowed_repos },
362+
runner_workspace_policy: { allowed_repos: request.access.allowed_repos, writable_paths: request.writable_paths },
361363
},
362364
artifact_declarations: request.artifacts?.declarations || [],
363365
required_artifacts: requiredArtifacts(request.artifacts?.declarations),
364366
output_projections: [],
365367
metadata: { workload: request.workload, ...(materializedPackage ? { imported_agent: materializedPackage.identity } : {}), runtime_sources: materializedRuntimeSources?.descriptors ?? [] },
366368
},
367369
},
368-
},
369-
}
370+
},
371+
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/**"] } }] : [],
372+
}
370373

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

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

@@ -409,7 +421,19 @@ if (execution.code === 0 && request.run_agent && !request.dry_run) {
409421
const verificationPassed = verification.every((check) => check.success)
410422
const runtimeRecord = record(runtimeResult)
411423
const agentResult = record(runtimeRecord.agent_task_run_result)
412-
const publication = resultValue(runtimeRecord, "outputs.artifact_result.result.outputs.runner_workspace_publication")
424+
let publication = resultValue(runtimeRecord, "metadata.runner_workspace_publication")
425+
if (execution.code === 0 && runtimeRecord.success === true && verificationPassed && workspaceApply.status === "applied") {
426+
await runnerWorkspaceCore.verifyRunnerWorkspaceIntegrity(workspaceApply.integrity)
427+
const testPublisher = string(process.env.WP_CODEBOX_TEST_PUBLISHER_MODULE)
428+
const publisher = testPublisher ? (await import(pathToFileURL(resolve(testPublisher)).href)).publishRunnerWorkspace : publishRunnerWorkspace
429+
const testHook = testPublisher && process.env.WP_CODEBOX_TEST_PUBLISHER_HOOK
430+
? JSON.parse(process.env.WP_CODEBOX_TEST_PUBLISHER_HOOK)
431+
: undefined
432+
publication = await publisher({ request, changedFiles: workspaceApply.changedFiles, publicationFiles: workspaceApply.publicationFiles, token: process.env.ACCESS_TOKEN || process.env.GITHUB_TOKEN, ...(testHook ? { testHook } : {}) })
433+
runtimeRecord.metadata = { ...record(runtimeRecord.metadata), runner_workspace_publication: publication }
434+
runtimeRecord.outputs = { ...record(runtimeRecord.outputs), runner_workspace_publication: publication }
435+
}
436+
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.")
413437
let evaluatedProjections = {}
414438
let projectionError = ""
415439
try {
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
2+
function string(value) { return typeof value === "string" ? value.trim() : "" }
3+
function record(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {} }
4+
5+
export async function publishRunnerWorkspace({ request, changedFiles, publicationFiles, workspace, token, fetchImpl = fetch }) {
6+
const targetRepo = string(request.target_repo).toLowerCase()
7+
const config = record(request.runner_workspace)
8+
const configuredRepo = string(config.repo).toLowerCase()
9+
const allowed = Array.isArray(record(request.access).allowed_repos) ? request.access.allowed_repos.map((value) => string(value).toLowerCase()) : []
10+
if (!token) throw new Error("No GitHub token is available for runner workspace publication.")
11+
if (!targetRepo || targetRepo !== configuredRepo || !allowed.includes(targetRepo)) throw new Error("Runner workspace publication repository is not authorized.")
12+
const base = string(config.base || config.base_branch || "main")
13+
const prefix = string(config.branch_prefix || "wp-codebox/agent-task/")
14+
const runId = string(config.run_id || request.workload?.id || "agent-task").replace(/[^A-Za-z0-9._/-]+/g, "-")
15+
const head = `${prefix}${runId}`
16+
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.")
17+
18+
const api = async (method, path, body) => {
19+
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) } : {}) })
20+
const payload = await response.json().catch(() => ({}))
21+
if (!response.ok) throw new Error(`GitHub API ${method} ${path} failed with ${response.status}.`)
22+
return payload
23+
}
24+
let existing = null
25+
try { existing = await api("GET", `/git/ref/heads/${head.split("/").map(encodeURIComponent).join("/")}`) } catch (error) { if (!String(error.message).includes(" 404.")) throw error }
26+
// Existing PR branches are append-only publication targets. Their current tree
27+
// is the base so files from earlier agent turns cannot disappear.
28+
const parent = string(existing?.object?.sha)
29+
const baseRef = parent ? null : await api("GET", `/git/ref/heads/${encodeURIComponent(base)}`)
30+
const baseSha = parent || string(baseRef.object?.sha)
31+
const baseCommit = await api("GET", `/git/commits/${baseSha}`)
32+
const tree = []
33+
const captured = Array.isArray(publicationFiles) ? publicationFiles : []
34+
if (!captured.length) throw new Error("Runner workspace publication requires immutable approved file content.")
35+
for (const changed of captured) {
36+
const relativePath = string(changed?.path)
37+
if (!relativePath || relativePath.startsWith("/") || relativePath.split("/").some((part) => !part || part === "." || part === ".." || part === ".git")) throw new Error("Publication changed file path is invalid.")
38+
if (changed.deleted) { tree.push({ path: relativePath, mode: "100644", type: "blob", sha: null }); continue }
39+
if (changed.mode !== "100644" && changed.mode !== "100755" || typeof changed.content !== "string") throw new Error("Publication file is not an approved regular file.")
40+
const blob = await api("POST", "/git/blobs", { content: changed.content, encoding: "base64" })
41+
tree.push({ path: relativePath, mode: changed.mode, type: "blob", sha: string(blob.sha) })
42+
}
43+
const nextTree = await api("POST", "/git/trees", { base_tree: string(baseCommit.tree?.sha), tree })
44+
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] })
45+
if (existing) await api("PATCH", `/git/refs/heads/${head.split("/").map(encodeURIComponent).join("/")}`, { sha: string(commit.sha), force: false })
46+
else await api("POST", "/git/refs", { ref: `refs/heads/${head}`, sha: string(commit.sha) })
47+
const pulls = await api("GET", `/pulls?state=open&head=${encodeURIComponent(`${targetRepo.split("/")[0]}:${head}`)}&base=${encodeURIComponent(base)}`)
48+
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 || "") })
49+
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.")
50+
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]) } }
51+
}

docs/agent-task-reusable-workflow.md

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,12 @@ package file is mounted into or visible from the agent workspace.
105105
## Runtime Coverage
106106

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

116115
To run the optional cross-repository package coverage, use a Docs Agent checkout
117116
pinned to commit `3da1b8076359db9bf9f4ee7dadcc3932c080ed71`, which contains

fixtures/agent-task-runtime-sources-run-29299109269.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"run_id": "29299109269",
33
"runtime_sources": [
4-
{ "version": 1, "role": "component", "repository": "automattic/agents-api", "revision": "59d1e6b473f22498e40e279130bbb4f9bcde3b73", "path": ".", "metadata": { "slug": "agents-api", "loadAs": "mu-plugin", "activate": false } },
4+
{ "version": 1, "role": "component", "repository": "automattic/agents-api", "revision": "fbd5641d412af76a1b8288426a577e750838b4be", "path": ".", "metadata": { "slug": "agents-api", "loadAs": "mu-plugin", "activate": false } },
55
{ "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"] } },
66
{ "version": 1, "role": "bundled_library", "repository": "wordpress/php-ai-client", "revision": "631704201d15ffeff7091ad3bc7156db74054956", "path": ".", "metadata": { "library": "php-ai-client", "strategy": "wordpress-scoped-bundle" } }
77
]

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,9 @@
127127
"test:source-package-compiler-primitives": "tsx tests/source-package-compiler-primitives.test.ts",
128128
"test:source-root-preparation": "tsx tests/source-root-preparation.test.ts",
129129
"test:workspace-preload-artifacts": "tsx tests/workspace-preload-artifacts.test.ts",
130+
"test:runner-workspace-apply": "tsx tests/runner-workspace-apply.test.ts",
131+
"test:runner-workspace-publisher": "node tests/runner-workspace-publisher.test.mjs",
132+
"test:native-agent-task-lifecycle": "node tests/execute-native-agent-task-lifecycle.test.mjs",
130133
"test:bench-command-step-behavior": "tsx tests/bench-command-step-behavior.test.ts",
131134
"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",
132135
"test:browser-artifact-session": "tsx tests/browser-artifact-session.test.ts",

packages/runtime-core/src/agent-task-run-result.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ function normalizeArtifacts(result: Record<string, unknown>, agentResult: Record
200200
const artifacts: AgentTaskRunArtifactRef[] = []
201201
const artifactPolicy = objectValue(result.workspace_artifact_policy ?? result.workspaceArtifactPolicy)
202202
const publicUrlRoot = stringValue(artifactPolicy.public_url_root ?? artifactPolicy.publicUrlRoot)
203-
for (const artifact of arrayObjects(result.artifacts)) {
203+
for (const artifact of artifactRecords(result.artifacts)) {
204204
appendUniqueArtifact(artifacts, withPublicArtifactUrl(artifactFromResultArtifact(artifact), publicUrlRoot, ""))
205205
}
206206

@@ -266,6 +266,14 @@ function artifactFromResultArtifact(artifact: Record<string, unknown>): AgentTas
266266
}) as AgentTaskRunArtifactRef
267267
}
268268

269+
function artifactRecords(value: unknown): Record<string, unknown>[] {
270+
const list = arrayObjects(value)
271+
if (list.length > 0) return list
272+
const artifact = objectValue(value)
273+
if (stringValue(artifact.kind)) return [artifact]
274+
return arrayObjects(artifact.items ?? artifact.files ?? artifact.artifacts)
275+
}
276+
269277
function artifactFromAgentResult(id: string, kind: string, root: string, metadata: Record<string, unknown>): AgentTaskRunArtifactRef {
270278
const path = artifactPath(root, stringValue(metadata.artifact))
271279
return stripUndefined({
@@ -296,7 +304,7 @@ function noOpMetadata(result: Record<string, unknown>, agentResult: Record<strin
296304
}
297305

298306
function agentResultRecord(result: Record<string, unknown>): Record<string, unknown> {
299-
return firstObject(objectValue(result.run).agentResult, result.agentResult)
307+
return firstObject(objectValue(result.run).agentResult, result.agentResult, result.agent_result)
300308
}
301309

302310
function completionOutcomeRecord(result: Record<string, unknown>): Record<string, unknown> {

packages/runtime-core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export * from "./fanout-execution.js"
7171
export * from "./live-progress.js"
7272
export * from "./recipe-run-summary.js"
7373
export * from "./runner-workspace-publication.js"
74+
export * from "./runner-workspace-apply.js"
7475
export * from "./artifact-storage.js"
7576
export * from "./browser-session-origin.js"
7677
export * from "./browser-contained-site-contracts.js"

0 commit comments

Comments
 (0)