Skip to content

Commit 4409d0b

Browse files
committed
Wire copy-on-write workspaces into agent tasks
1 parent 7dd32b7 commit 4409d0b

14 files changed

Lines changed: 762 additions & 25 deletions

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

Lines changed: 33 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,14 @@ 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+
if (execution.code === 0 && runtimeResult.success === true && request.runner_workspace?.enabled) {
392+
const core = await import(pathToFileURL(join(codeboxRoot, "packages/runtime-core/dist/runner-workspace-apply.js")).href)
393+
const publicCore = await import(pathToFileURL(join(codeboxRoot, "packages/runtime-core/dist/public.js")).href)
394+
const refs = publicCore.normalizePublicArtifactRefDTOs(runtimeResult)
395+
.filter((ref) => ref.kind === "codebox-patch" || ref.kind === "codebox-changed-files")
396+
workspaceApply = await core.applyRunnerWorkspacePatch({ artifactRoot: artifactsPath, artifactRefs: refs, workspaceRoot: workspace, writablePaths: String(request.writable_paths || "").split(",").map((value) => value.trim()).filter(Boolean) })
397+
}
387398
await cleanupPrivateRuntimeSources()
388399
assertNoRuntimeSourcePaths(runtimeResult, privateRuntimeSourceRootForSanitization)
389400

@@ -409,7 +420,15 @@ if (execution.code === 0 && request.run_agent && !request.dry_run) {
409420
const verificationPassed = verification.every((check) => check.success)
410421
const runtimeRecord = record(runtimeResult)
411422
const agentResult = record(runtimeRecord.agent_task_run_result)
412-
const publication = resultValue(runtimeRecord, "outputs.artifact_result.result.outputs.runner_workspace_publication")
423+
let publication = resultValue(runtimeRecord, "metadata.runner_workspace_publication")
424+
if (execution.code === 0 && runtimeRecord.success === true && verificationPassed && workspaceApply.status === "applied") {
425+
const testPublisher = string(process.env.WP_CODEBOX_TEST_PUBLISHER_MODULE)
426+
const publisher = testPublisher ? (await import(pathToFileURL(resolve(testPublisher)).href)).publishRunnerWorkspace : publishRunnerWorkspace
427+
publication = await publisher({ request, workspace, changedFiles: workspaceApply.changedFiles, token: process.env.ACCESS_TOKEN || process.env.GITHUB_TOKEN })
428+
runtimeRecord.metadata = { ...record(runtimeRecord.metadata), runner_workspace_publication: publication }
429+
runtimeRecord.outputs = { ...record(runtimeRecord.outputs), runner_workspace_publication: publication }
430+
}
431+
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.")
413432
let evaluatedProjections = {}
414433
let projectionError = ""
415434
try {
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { readFile } from "node:fs/promises"
2+
import { resolve } from "node:path"
3+
4+
function string(value) { return typeof value === "string" ? value.trim() : "" }
5+
function record(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {} }
6+
7+
export async function publishRunnerWorkspace({ request, workspace, changedFiles, token, fetchImpl = fetch }) {
8+
const targetRepo = string(request.target_repo).toLowerCase()
9+
const config = record(request.runner_workspace)
10+
const configuredRepo = string(config.repo).toLowerCase()
11+
const allowed = Array.isArray(record(request.access).allowed_repos) ? request.access.allowed_repos.map((value) => string(value).toLowerCase()) : []
12+
if (!token) throw new Error("No GitHub token is available for runner workspace publication.")
13+
if (!targetRepo || targetRepo !== configuredRepo || !allowed.includes(targetRepo)) throw new Error("Runner workspace publication repository is not authorized.")
14+
const base = string(config.base || config.base_branch || "main")
15+
const prefix = string(config.branch_prefix || "wp-codebox/agent-task/")
16+
const runId = string(config.run_id || request.workload?.id || "agent-task").replace(/[^A-Za-z0-9._/-]+/g, "-")
17+
const head = `${prefix}${runId}`
18+
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.")
19+
20+
const api = async (method, path, body) => {
21+
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) } : {}) })
22+
const payload = await response.json().catch(() => ({}))
23+
if (!response.ok) throw new Error(`GitHub API ${method} ${path} failed with ${response.status}.`)
24+
return payload
25+
}
26+
const baseRef = await api("GET", `/git/ref/heads/${encodeURIComponent(base)}`)
27+
const baseSha = string(baseRef.object?.sha)
28+
const baseCommit = await api("GET", `/git/commits/${baseSha}`)
29+
const tree = []
30+
for (const changed of changedFiles) {
31+
const relativePath = string(changed)
32+
if (!relativePath || relativePath.startsWith("/") || relativePath.split("/").some((part) => !part || part === "." || part === ".." || part === ".git")) throw new Error("Publication changed file path is invalid.")
33+
const absolute = resolve(workspace, relativePath)
34+
if (!absolute.startsWith(`${resolve(workspace)}/`)) throw new Error("Publication changed file escapes workspace.")
35+
try {
36+
const content = await readFile(absolute)
37+
const blob = await api("POST", "/git/blobs", { content: content.toString("base64"), encoding: "base64" })
38+
tree.push({ path: relativePath, mode: "100644", type: "blob", sha: string(blob.sha) })
39+
} catch (error) {
40+
if (error?.code === "ENOENT") tree.push({ path: relativePath, mode: "100644", type: "blob", sha: null })
41+
else throw error
42+
}
43+
}
44+
const nextTree = await api("POST", "/git/trees", { base_tree: string(baseCommit.tree?.sha), tree })
45+
let parent = baseSha
46+
let existing = null
47+
try { existing = await api("GET", `/git/ref/heads/${head.split("/").map(encodeURIComponent).join("/")}`); parent = string(existing.object?.sha) || baseSha } catch (error) { if (!String(error.message).includes(" 404.")) throw error }
48+
const commit = await api("POST", "/git/commits", { message: string(config.commit_message || request.workload?.label || "Apply agent task changes"), tree: string(nextTree.sha), parents: [parent] })
49+
if (existing) await api("PATCH", `/git/refs/heads/${head.split("/").map(encodeURIComponent).join("/")}`, { sha: string(commit.sha), force: false })
50+
else await api("POST", "/git/refs", { ref: `refs/heads/${head}`, sha: string(commit.sha) })
51+
const pulls = await api("GET", `/pulls?state=open&head=${encodeURIComponent(`${targetRepo.split("/")[0]}:${head}`)}&base=${encodeURIComponent(base)}`)
52+
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 || "") })
53+
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.")
54+
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]) } }
55+
}

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)