From 4409d0b33cf7e085883c4b059cad0916e8e07694 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 14 Jul 2026 04:29:27 -0400 Subject: [PATCH 1/4] Wire copy-on-write workspaces into agent tasks --- .../execute-native-agent-task.mjs | 47 +++-- .../runner-workspace-publisher.mjs | 55 ++++++ package.json | 3 + .../runtime-core/src/agent-task-run-result.ts | 12 +- packages/runtime-core/src/index.ts | 1 + .../src/runner-workspace-apply.ts | 150 +++++++++++++++ ...wp-codebox-host-runtime-config-builder.php | 24 +++ ...-wp-codebox-sandbox-workspace-executor.php | 163 ++++++++++++++++ .../php-sandbox-workspace-executor-smoke.php | 18 +- tests/agent-task-contracts.test.ts | 8 + tests/agent-task-reusable-workflow.test.ts | 13 +- ...ecute-native-agent-task-lifecycle.test.mjs | 182 ++++++++++++++++++ tests/runner-workspace-apply.test.ts | 58 ++++++ tests/runner-workspace-publisher.test.mjs | 53 +++++ 14 files changed, 762 insertions(+), 25 deletions(-) create mode 100644 .github/scripts/run-agent-task/runner-workspace-publisher.mjs create mode 100644 packages/runtime-core/src/runner-workspace-apply.ts create mode 100644 tests/execute-native-agent-task-lifecycle.test.mjs create mode 100644 tests/runner-workspace-apply.test.ts create mode 100644 tests/runner-workspace-publisher.test.mjs diff --git a/.github/scripts/run-agent-task/execute-native-agent-task.mjs b/.github/scripts/run-agent-task/execute-native-agent-task.mjs index ffc77121a..1f7d009a0 100644 --- a/.github/scripts/run-agent-task/execute-native-agent-task.mjs +++ b/.github/scripts/run-agent-task/execute-native-agent-task.mjs @@ -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()) @@ -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 "" } @@ -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" } @@ -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 ?? "" @@ -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), @@ -357,7 +359,7 @@ 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), @@ -365,8 +367,9 @@ const taskInput = { 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`) @@ -384,6 +387,14 @@ 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: [] } +if (execution.code === 0 && runtimeResult.success === true && request.runner_workspace?.enabled) { + const core = 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 core.applyRunnerWorkspacePatch({ artifactRoot: artifactsPath, artifactRefs: refs, workspaceRoot: workspace, writablePaths: String(request.writable_paths || "").split(",").map((value) => value.trim()).filter(Boolean) }) +} await cleanupPrivateRuntimeSources() assertNoRuntimeSourcePaths(runtimeResult, privateRuntimeSourceRootForSanitization) @@ -409,7 +420,15 @@ 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") { + const testPublisher = string(process.env.WP_CODEBOX_TEST_PUBLISHER_MODULE) + const publisher = testPublisher ? (await import(pathToFileURL(resolve(testPublisher)).href)).publishRunnerWorkspace : publishRunnerWorkspace + publication = await publisher({ request, workspace, changedFiles: workspaceApply.changedFiles, token: process.env.ACCESS_TOKEN || process.env.GITHUB_TOKEN }) + 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 { diff --git a/.github/scripts/run-agent-task/runner-workspace-publisher.mjs b/.github/scripts/run-agent-task/runner-workspace-publisher.mjs new file mode 100644 index 000000000..36c4109f3 --- /dev/null +++ b/.github/scripts/run-agent-task/runner-workspace-publisher.mjs @@ -0,0 +1,55 @@ +import { readFile } from "node:fs/promises" +import { resolve } from "node:path" + +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, workspace, changedFiles, 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 + } + const baseRef = await api("GET", `/git/ref/heads/${encodeURIComponent(base)}`) + const baseSha = string(baseRef.object?.sha) + const baseCommit = await api("GET", `/git/commits/${baseSha}`) + const tree = [] + for (const changed of changedFiles) { + const relativePath = string(changed) + if (!relativePath || relativePath.startsWith("/") || relativePath.split("/").some((part) => !part || part === "." || part === ".." || part === ".git")) throw new Error("Publication changed file path is invalid.") + const absolute = resolve(workspace, relativePath) + if (!absolute.startsWith(`${resolve(workspace)}/`)) throw new Error("Publication changed file escapes workspace.") + try { + const content = await readFile(absolute) + const blob = await api("POST", "/git/blobs", { content: content.toString("base64"), encoding: "base64" }) + tree.push({ path: relativePath, mode: "100644", type: "blob", sha: string(blob.sha) }) + } catch (error) { + if (error?.code === "ENOENT") tree.push({ path: relativePath, mode: "100644", type: "blob", sha: null }) + else throw error + } + } + const nextTree = await api("POST", "/git/trees", { base_tree: string(baseCommit.tree?.sha), tree }) + let parent = baseSha + let existing = null + 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 } + 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] }) + 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]) } } +} diff --git a/package.json b/package.json index 449fc3995..b8d1bf583 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/runtime-core/src/agent-task-run-result.ts b/packages/runtime-core/src/agent-task-run-result.ts index 6b6780a2d..f86a46c44 100644 --- a/packages/runtime-core/src/agent-task-run-result.ts +++ b/packages/runtime-core/src/agent-task-run-result.ts @@ -200,7 +200,7 @@ function normalizeArtifacts(result: Record, 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, "")) } @@ -266,6 +266,14 @@ function artifactFromResultArtifact(artifact: Record): AgentTas }) as AgentTaskRunArtifactRef } +function artifactRecords(value: unknown): Record[] { + 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): AgentTaskRunArtifactRef { const path = artifactPath(root, stringValue(metadata.artifact)) return stripUndefined({ @@ -296,7 +304,7 @@ function noOpMetadata(result: Record, agentResult: Record): Record { - return firstObject(objectValue(result.run).agentResult, result.agentResult) + return firstObject(objectValue(result.run).agentResult, result.agentResult, result.agent_result) } function completionOutcomeRecord(result: Record): Record { diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index c99bcd4b0..3e08009ce 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -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" diff --git a/packages/runtime-core/src/runner-workspace-apply.ts b/packages/runtime-core/src/runner-workspace-apply.ts new file mode 100644 index 000000000..3fbf50152 --- /dev/null +++ b/packages/runtime-core/src/runner-workspace-apply.ts @@ -0,0 +1,150 @@ +import { execFile } from "node:child_process" +import { readFile, lstat, realpath } from "node:fs/promises" +import { isAbsolute, resolve } from "node:path" +import { promisify } from "node:util" +import { createHash } from "node:crypto" +import { pathIsWithinRoot, relativePathMatchesExcludePattern } from "./file-tree-policy.js" + +const execFileAsync = promisify(execFile) +const MAX_PATCH_BYTES = 5 * 1024 * 1024 + +export interface RunnerWorkspaceArtifactRef { + kind: string + path: string + sha256?: string + size_bytes?: number +} + +export interface RunnerWorkspaceChangedFile { + path: string + status: "added" | "modified" | "deleted" + relativePath: string + beforeMode?: string + afterMode?: string +} + +export interface RunnerWorkspaceApplyRequest { + artifactRoot: string + artifactRefs: RunnerWorkspaceArtifactRef[] + workspaceRoot: string + writablePaths: string[] + verify?: () => Promise +} + +export interface RunnerWorkspaceApplyResult { + schema: "wp-codebox/runner-workspace-apply-result/v1" + status: "applied" | "no-op" + changedFiles: string[] + patchSha256?: string +} + +/** + * Promotes the canonical sandbox patch artifact into the checked-out workspace. + * Artifact references are treated as locators only after containment and digest + * checks; sandbox-provided paths and filesystem trees are never trusted. + */ +export async function applyRunnerWorkspacePatch(request: RunnerWorkspaceApplyRequest): Promise { + const artifactRoot = await realpath(resolve(request.artifactRoot)) + const workspaceRoot = await realpath(resolve(request.workspaceRoot)) + const patchRef = exactlyOne(request.artifactRefs, "codebox-patch") + const changedRef = exactlyOne(request.artifactRefs, "codebox-changed-files") + const patchPath = await artifactPath(artifactRoot, patchRef.path) + const changedPath = await artifactPath(artifactRoot, changedRef.path) + const [patch, changedRaw] = await Promise.all([readBoundedText(patchPath), readBoundedText(changedPath)]) + verifyDigest(patch, patchRef.sha256) + + const changed = parseChangedFiles(changedRaw) + validateChangedFiles(changed, request.writablePaths) + if (changed.length === 0) { + if (patch.trim()) throw new Error("Canonical patch is non-empty but changed-files declares no changes.") + return { schema: "wp-codebox/runner-workspace-apply-result/v1", status: "no-op", changedFiles: [] } + } + if (!patch.trim()) throw new Error("Canonical changed-files declares changes but patch is empty.") + validatePatchPaths(patch, changed) + + await execGit(workspaceRoot, ["apply", "--check", "--whitespace=error", "--", patchPath]) + await execGit(workspaceRoot, ["apply", "--whitespace=error", "--", patchPath]) + if (request.verify) await request.verify() + + return { + schema: "wp-codebox/runner-workspace-apply-result/v1", + status: "applied", + changedFiles: changed.map((file) => file.relativePath), + patchSha256: createHash("sha256").update(patch).digest("hex"), + } +} + +function exactlyOne(refs: RunnerWorkspaceArtifactRef[], kind: string): RunnerWorkspaceArtifactRef { + const matches = refs.filter((ref) => ref.kind === kind) + if (matches.length !== 1) throw new Error(`Expected exactly one canonical ${kind} artifact reference.`) + return matches[0] +} + +async function artifactPath(root: string, value: string): Promise { + if (!value) throw new Error("Artifact reference path is required.") + const candidate = await realpath(isAbsolute(value) ? resolve(value) : resolve(root, value)) + if (!pathIsWithinRoot(candidate, root)) throw new Error("Artifact reference escapes the trusted artifact root.") + return candidate +} + +async function readBoundedText(path: string): Promise { + const stat = await lstat(path) + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_PATCH_BYTES) throw new Error("Artifact must be a bounded regular file.") + const text = await readFile(path, "utf8") + if (text.includes("\0")) throw new Error("Artifact must be text.") + return text +} + +function verifyDigest(text: string, expected?: string): void { + if (!expected) return + const digest = createHash("sha256").update(text).digest("hex") + if (digest !== expected.replace(/^sha256:/, "")) throw new Error("Canonical patch digest does not match its artifact reference.") +} + +function parseChangedFiles(raw: string): RunnerWorkspaceChangedFile[] { + const value: unknown = JSON.parse(raw) + if (!value || typeof value !== "object" || Array.isArray(value) || (value as { schema?: unknown }).schema !== "wp-codebox/changed-files/v1" || !Array.isArray((value as { files?: unknown }).files)) { + throw new Error("Changed-files artifact does not match wp-codebox/changed-files/v1.") + } + return (value as { files: unknown[] }).files.map((file) => { + if (!file || typeof file !== "object" || Array.isArray(file)) throw new Error("Changed-files artifact contains an invalid file.") + const record = file as Record + const relativePath = typeof record.relativePath === "string" ? record.relativePath : "" + const status = record.status + if (!relativePath || !["added", "modified", "deleted"].includes(String(status))) throw new Error("Changed-files artifact contains an invalid change.") + return { path: typeof record.path === "string" ? record.path : relativePath, relativePath, status: status as RunnerWorkspaceChangedFile["status"], beforeMode: stringValue(record.beforeMode), afterMode: stringValue(record.afterMode) } + }) +} + +function validateChangedFiles(files: RunnerWorkspaceChangedFile[], writablePaths: string[]): void { + if (writablePaths.length === 0) throw new Error("A non-empty writable path policy is required.") + for (const file of files) { + const path = file.relativePath.replaceAll("\\", "/") + if (!path || path.startsWith("/") || path.split("/").some((part) => part === "" || part === "." || part === ".." || part === ".git" || part === ".codebox")) throw new Error(`Changed file has a denied path: ${file.relativePath}`) + if (![file.beforeMode, file.afterMode].filter(Boolean).every((mode) => mode === "100644")) throw new Error(`Changed file has an unsupported mode: ${file.relativePath}`) + if (!writablePaths.some((pattern) => relativePathMatchesExcludePattern(path, pattern))) throw new Error(`Changed file is outside writable_paths: ${file.relativePath}`) + } +} + +function validatePatchPaths(patch: string, changed: RunnerWorkspaceChangedFile[]): void { + const declared = new Set(changed.map((file) => file.relativePath)) + const paths = patch.split("\n").flatMap((line) => { + if (!line.startsWith("--- ") && !line.startsWith("+++ ")) return [] + const path = line.slice(4).split("\t", 1)[0].trim().replace(/^[ab]\//, "") + return path === "/dev/null" ? [] : [path] + }) + if (paths.length === 0 || paths.some((path) => !declared.has(path))) throw new Error("Patch paths do not exactly correspond to canonical changed-files.") +} + +async function execGit(cwd: string, args: string[]): Promise { + try { + await execFileAsync("git", args, { cwd, maxBuffer: MAX_PATCH_BYTES }) + } catch (error) { + const stderr = error && typeof error === "object" && "stderr" in error ? String((error as { stderr?: unknown }).stderr ?? "") : "" + throw new Error(`Host git apply failed: ${stderr || "patch rejected"}`) + } +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value ? value : undefined +} diff --git a/packages/wordpress-plugin/src/class-wp-codebox-host-runtime-config-builder.php b/packages/wordpress-plugin/src/class-wp-codebox-host-runtime-config-builder.php index c6ac0f05f..7c82bccbb 100644 --- a/packages/wordpress-plugin/src/class-wp-codebox-host-runtime-config-builder.php +++ b/packages/wordpress-plugin/src/class-wp-codebox-host-runtime-config-builder.php @@ -61,6 +61,30 @@ public function recipe_mounts( array $input ): array|WP_Error { /** @param array $input Ability input. @return array>|WP_Error */ public function recipe_workspaces( array $input ): array|WP_Error { $workspaces = is_array( $input['workspaces'] ?? null ) ? $input['workspaces'] : array(); + $runner_workspace = is_array( $input['runner_workspace'] ?? null ) ? $input['runner_workspace'] : array(); + if ( ! empty( $runner_workspace['enabled'] ) ) { + $checkout = WP_Codebox_Path_Policy::clean_host_path( (string) ( $runner_workspace['checkout_path'] ?? $runner_workspace['workspace_path'] ?? $runner_workspace['path'] ?? '' ) ); + if ( '' === $checkout || ! is_dir( $checkout ) ) { + return new WP_Error( 'wp_codebox_runner_workspace_checkout_missing', 'Enabled runner_workspace requires an existing checked-out host workspace path.', array( 'status' => 400 ) ); + } + foreach ( $workspaces as $workspace ) { + if ( is_array( $workspace ) && '/workspace' === (string) ( $workspace['target'] ?? '' ) ) { + return new WP_Error( 'wp_codebox_runner_workspace_target_conflict', 'runner_workspace reserves the sandbox target /workspace.', array( 'status' => 400 ) ); + } + } + // Recipe preparation copies this directory to an ephemeral source and a + // separate baseline. It is deliberately not represented as a mount. + $workspaces[] = array( + 'target' => '/workspace', + 'mode' => 'readwrite', + 'sourceMode' => 'repo-backed', + 'seed' => array( + 'type' => 'directory', + 'source' => $checkout, + 'excludePaths' => array( '.git/**', '.codebox/**', 'node_modules/**', 'vendor/**', 'dist/**', 'build/**', 'coverage/**', '.cache/**' ), + ), + ); + } foreach ( $workspaces as $index => $workspace ) { if ( ! is_array( $workspace ) || ! is_array( $workspace['seed'] ?? null ) ) { return new WP_Error( 'wp_codebox_workspace_invalid', 'Each WP Codebox workspace must include a seed object.', array( 'status' => 400, 'index' => $index ) ); diff --git a/packages/wordpress-plugin/src/class-wp-codebox-sandbox-workspace-executor.php b/packages/wordpress-plugin/src/class-wp-codebox-sandbox-workspace-executor.php index 1c1c87732..6a8a98888 100644 --- a/packages/wordpress-plugin/src/class-wp-codebox-sandbox-workspace-executor.php +++ b/packages/wordpress-plugin/src/class-wp-codebox-sandbox-workspace-executor.php @@ -196,6 +196,27 @@ final class WP_Codebox_Sandbox_Workspace_Executor { ), ), ), + 'client/workspace_show' => array( + 'base' => 'workspace_show', + 'capability' => 'workspace.files.read', + 'description' => 'Show bounded metadata for the sandbox workspace without exposing host paths.', + 'side_effects' => array(), + 'parameters' => array( 'type' => 'object', 'properties' => array() ), + ), + 'client/workspace_git_status' => array( + 'base' => 'workspace_git_status', + 'capability' => 'workspace.files.read', + 'description' => 'Compare the sandbox workspace to its captured baseline without invoking git or a shell.', + 'side_effects' => array(), + 'parameters' => array( 'type' => 'object', 'properties' => array() ), + ), + 'client/workspace_git_diff' => array( + 'base' => 'workspace_git_diff', + 'capability' => 'workspace.files.read', + 'description' => 'Return a bounded baseline-versus-current unified diff without invoking git or a shell.', + 'side_effects' => array(), + 'parameters' => array( 'type' => 'object', 'properties' => array() ), + ), ); private static bool $registered = false; @@ -376,6 +397,10 @@ public function executeWP_Agent_Tool_Call( array $tool_call, array $tool_definit return self::error_result( $tool_name, 'Sandbox working root is not configured or does not exist.', 'workspace_root_unavailable', $config, $started_at ); } + // Policy is supplied by the host's explicit workflow mapping, never inferred + // from arbitrary ambient tool arguments. + $parameters['_wp_codebox_writable_paths'] = self::writable_paths( $context ); + $parameters['_wp_codebox_baseline_root'] = self::baseline_root( $context ); $result = self::dispatch( $config['base'], $root, $parameters ); if ( is_array( $result ) && false === ( $result['success'] ?? true ) ) { return self::error_result( @@ -436,6 +461,12 @@ private static function dispatch( string $base, string $root, array $parameters return self::edit_file( $root, $parameters ); case 'workspace_apply_patch': return self::apply_patch( $root, $parameters ); + case 'workspace_show': + return self::workspace_show( $root, $parameters ); + case 'workspace_git_status': + return self::workspace_status( $root, $parameters ); + case 'workspace_git_diff': + return self::workspace_diff( $root, $parameters ); } return self::tool_error( 'unsupported_tool', 'Unsupported sandbox workspace tool.' ); @@ -654,6 +685,13 @@ private static function write_file( string $root, array $parameters ): array { if ( strlen( $content ) > self::MAX_WRITE_BYTES ) { return self::tool_error( 'content_too_large', sprintf( 'Content is %d bytes, exceeding the %d byte limit.', strlen( $content ), self::MAX_WRITE_BYTES ) ); } + if ( self::looks_binary( $content ) ) { + return self::tool_error( 'binary_content', 'Sandbox workspace writes accept text content only.' ); + } + $writable = self::writable_path( $relative, $parameters ); + if ( is_array( $writable ) ) { + return $writable; + } $path = self::contained_path( $root, $relative, false ); if ( is_array( $path ) ) { @@ -690,6 +728,10 @@ private static function edit_file( string $root, array $parameters ): array { if ( is_array( $relative ) ) { return $relative; } + $writable = self::writable_path( $relative, $parameters ); + if ( is_array( $writable ) ) { + return $writable; + } $old = self::first_string( $parameters, array( 'old_string', 'search', 'old' ) ); $new = self::first_string( $parameters, array( 'new_string', 'replace', 'new' ) ); @@ -709,6 +751,9 @@ private static function edit_file( string $root, array $parameters ): array { if ( null === $content ) { return self::tool_error( 'file_read_failed', 'Sandbox workspace file could not be read.' ); } + if ( self::looks_binary( $content ) ) { + return self::tool_error( 'binary_content', 'Sandbox workspace edits accept text files only.' ); + } $occurrences = substr_count( $content, $old ); if ( 0 === $occurrences ) { @@ -726,6 +771,9 @@ private static function edit_file( string $root, array $parameters ): array { if ( strlen( $updated ) > self::MAX_WRITE_BYTES ) { return self::tool_error( 'content_too_large', 'Edited content exceeds the write size limit.' ); } + if ( self::looks_binary( $updated ) ) { + return self::tool_error( 'binary_content', 'Sandbox workspace edits accept text content only.' ); + } if ( false === self::write_raw( $path, $updated ) ) { return self::tool_error( 'file_write_failed', 'Sandbox workspace file could not be written.' ); } @@ -750,6 +798,9 @@ private static function apply_patch( string $root, array $parameters ): array { if ( '' === trim( $patch ) ) { return self::tool_error( 'patch_required', 'Apply-patch requires a unified diff.' ); } + if ( strlen( $patch ) > self::MAX_WRITE_BYTES || self::looks_binary( $patch ) ) { + return self::tool_error( 'invalid_patch', 'Patch must be bounded text content.' ); + } $files = self::parse_unified_diff( $patch ); if ( is_array( $files ) && isset( $files['error'] ) ) { @@ -766,6 +817,10 @@ private static function apply_patch( string $root, array $parameters ): array { if ( is_array( $relative ) ) { return $relative; } + $writable = self::writable_path( $relative, $parameters ); + if ( is_array( $writable ) ) { + return $writable; + } // Containment is enforced here; existence is checked per-op below. $path = self::contained_path( $root, $relative, false ); @@ -1004,6 +1059,70 @@ private static function apply_hunks( string $original, array $hunks ): array { return array( 'content' => $content ); } + /** @param array $parameters @return array */ + private static function workspace_show( string $root, array $parameters ): array { + $files = self::iterate_files( $root ); + return array( + 'success' => true, + 'root' => '/workspace', + 'file_count' => count( $files ), + 'writable_paths' => self::writable_paths_from_parameters( $parameters ), + 'baseline' => '' !== self::baseline_from_parameters( $parameters ) ? 'available' : 'unavailable', + ); + } + + /** @param array $parameters @return array */ + private static function workspace_status( string $root, array $parameters ): array { + $baseline = self::baseline_from_parameters( $parameters ); + if ( '' === $baseline ) { + return self::tool_error( 'baseline_unavailable', 'Sandbox workspace baseline is unavailable.' ); + } + $current = self::workspace_file_map( $root ); + $before = self::workspace_file_map( $baseline ); + $changed = array(); + foreach ( array_unique( array_merge( array_keys( $before ), array_keys( $current ) ) ) as $path ) { + if ( ! isset( $before[ $path ] ) || ! isset( $current[ $path ] ) || ! hash_equals( $before[ $path ], $current[ $path ] ) ) { + $changed[] = $path; + } + } + sort( $changed ); + return array( 'success' => true, 'changed' => ! empty( $changed ), 'files' => $changed, 'dirty' => count( $changed ), 'baseline' => 'filesystem' ); + } + + /** @param array $parameters @return array */ + private static function workspace_diff( string $root, array $parameters ): array { + $status = self::workspace_status( $root, $parameters ); + if ( empty( $status['success'] ) ) { + return $status; + } + $baseline = self::baseline_from_parameters( $parameters ); + $patch = ''; + foreach ( $status['files'] as $relative ) { + $before = is_file( $baseline . '/' . $relative ) ? self::read_raw( $baseline . '/' . $relative ) : ''; + $after = is_file( $root . '/' . $relative ) ? self::read_raw( $root . '/' . $relative ) : ''; + if ( null === $before || null === $after || self::looks_binary( $before ) || self::looks_binary( $after ) ) { + return self::tool_error( 'binary_content', 'Workspace diff cannot represent binary content.' ); + } + $from = is_file( $baseline . '/' . $relative ) ? 'a/' . $relative : '/dev/null'; + $to = is_file( $root . '/' . $relative ) ? 'b/' . $relative : '/dev/null'; + $patch .= "--- {$from}\n+++ {$to}\n@@ -1," . substr_count( $before, "\n" ) . " +1," . substr_count( $after, "\n" ) . " @@\n"; + foreach ( explode( "\n", rtrim( $before, "\n" ) ) as $line ) { if ( '' !== $line || '' !== $before ) { $patch .= '-' . $line . "\n"; } } + foreach ( explode( "\n", rtrim( $after, "\n" ) ) as $line ) { if ( '' !== $line || '' !== $after ) { $patch .= '+' . $line . "\n"; } } + } + return array( 'success' => true, 'changed' => ! empty( $status['files'] ), 'files' => $status['files'], 'diff' => $patch, 'baseline' => 'filesystem' ); + } + + /** @return array */ + private static function workspace_file_map( string $root ): array { + $map = array(); + foreach ( self::iterate_files( $root ) as $file ) { + $relative = self::relative_to_root( $root, $file ); + $content = self::read_raw( $file ); + if ( null !== $content ) { $map[ $relative ] = hash( 'sha256', $content ); } + } + return $map; + } + // ----------------------------------------------------------------- // Workspace root + path containment. // ----------------------------------------------------------------- @@ -1058,6 +1177,50 @@ private static function resolve_workspace_root( array $context, array $parameter return ''; } + /** @return array */ + private static function writable_paths( array $context ): array { + $policy = is_array( $context['workflow_policy'] ?? null ) ? $context['workflow_policy'] : ( is_array( $context['runner_workspace_policy'] ?? null ) ? $context['runner_workspace_policy'] : array() ); + $paths = $policy['writable_paths'] ?? $context['writable_paths'] ?? array(); + if ( is_string( $paths ) ) { $paths = preg_split( '/\s*,\s*/', trim( $paths ) ) ?: array(); } + return array_values( array_filter( array_map( static fn( $path ): string => is_scalar( $path ) ? trim( (string) $path ) : '', is_array( $paths ) ? $paths : array() ) ) ); + } + + private static function baseline_root( array $context ): string { + $candidate = $context['workspace_baseline_root'] ?? ( is_array( $context['workspace'] ?? null ) ? ( $context['workspace']['baseline_root'] ?? '' ) : '' ); + $candidate = is_string( $candidate ) ? $candidate : ''; + $real = '' !== $candidate ? realpath( $candidate ) : false; + return false !== $real && is_dir( $real ) ? $real : ''; + } + + /** @param array $parameters @return array */ + private static function writable_paths_from_parameters( array $parameters ): array { + return is_array( $parameters['_wp_codebox_writable_paths'] ?? null ) ? $parameters['_wp_codebox_writable_paths'] : array(); + } + + /** @param array $parameters */ + private static function baseline_from_parameters( array $parameters ): string { + return is_string( $parameters['_wp_codebox_baseline_root'] ?? null ) ? $parameters['_wp_codebox_baseline_root'] : ''; + } + + /** @param array $parameters @return true|array */ + private static function writable_path( string $relative, array $parameters ) { + $parts = explode( '/', $relative ); + if ( in_array( '.git', $parts, true ) || in_array( '.codebox', $parts, true ) || str_starts_with( basename( $relative ), '.' ) ) { + return self::tool_error( 'control_path_denied', 'Sandbox workspace mutations cannot target control paths.' ); + } + $patterns = self::writable_paths_from_parameters( $parameters ); + if ( empty( $patterns ) ) { + return self::tool_error( 'writable_path_denied', 'No writable_paths policy permits this mutation.' ); + } + foreach ( $patterns as $pattern ) { + $pattern = ltrim( str_replace( '\\', '/', $pattern ), '/' ); + if ( $relative === $pattern || fnmatch( $pattern, $relative, FNM_PATHNAME ) || ( str_ends_with( $pattern, '/**' ) && str_starts_with( $relative . '/', substr( $pattern, 0, -2 ) ) ) ) { + return true; + } + } + return self::tool_error( 'writable_path_denied', 'Sandbox workspace mutation is outside workflow writable_paths.' ); + } + /** * Validate and resolve a relative path contained within the working root. * diff --git a/scripts/php-sandbox-workspace-executor-smoke.php b/scripts/php-sandbox-workspace-executor-smoke.php index 251881063..53cf3ee46 100644 --- a/scripts/php-sandbox-workspace-executor-smoke.php +++ b/scripts/php-sandbox-workspace-executor-smoke.php @@ -68,7 +68,12 @@ function assert_true( bool $condition, string $message ): void { $root_real = realpath( $root ); assert_true( is_string( $root_real ), 'working root realpath resolves' ); - $context = array( 'workspace_root' => $root_real ); + $baseline = sys_get_temp_dir() . '/wp-codebox-sandbox-baseline-' . bin2hex( random_bytes( 6 ) ); + mkdir( $baseline, 0755, true ); + copy( $root_real . '/README.md', $baseline . '/README.md' ); + mkdir( $baseline . '/src', 0755, true ); + copy( $root_real . '/src/app.php', $baseline . '/src/app.php' ); + $context = array( 'workspace_root' => $root_real, 'workspace_baseline_root' => $baseline, 'writable_paths' => array( 'src/**', 'notes/**', 'patch-target.txt', 'created/**' ) ); $executor = new WP_Codebox_Sandbox_Workspace_Executor(); /** @@ -140,6 +145,8 @@ function assert_true( bool $condition, string $message ): void { $edit_all = $call( 'workspace_edit', array( 'path' => 'src/app.php', 'old' => '//', 'new' => '#', 'replace_all' => true ) ); assert_true( 2 === $edit_all['result']['replacements'], 'edit replace_all counts every occurrence' ); + $unwritable = $call( 'workspace_write', array( 'path' => 'README.md', 'content' => 'blocked' ) ); + assert_true( false === $unwritable['success'] && 'writable_path_denied' === $unwritable['error_type'], 'write rejects paths outside writable policy' ); // --- apply-patch (modify) --------------------------------------- file_put_contents( $root_real . '/patch-target.txt', "one\ntwo\nthree\n" ); @@ -154,6 +161,12 @@ function assert_true( bool $condition, string $message ): void { $created = $call( 'workspace_apply_patch', array( 'patch' => $create_patch ) ); assert_true( true === $created['success'], 'apply-patch creates new file' ); assert_true( "fresh\ncontent\n" === file_get_contents( $root_real . '/created/new.txt' ), 'apply-patch wrote new file content' ); + $status = $call( 'workspace_git_status', array() ); + assert_true( true === $status['success'] && $status['result']['changed'], 'status compares sandbox workspace to baseline without git' ); + $diff = $call( 'workspace_git_diff', array() ); + assert_true( true === $diff['success'] && str_contains( $diff['result']['diff'], 'patch-target.txt' ), 'diff returns baseline patch without git' ); + $show = $call( 'workspace_show', array() ); + assert_true( '/workspace' === $show['result']['root'], 'show redacts host workspace root' ); // --- apply-patch fails closed on context mismatch ---------------- $bad_patch = "--- a/patch-target.txt\n+++ b/patch-target.txt\n@@ -1,3 +1,3 @@\n one\n-WRONG\n+x\n three\n"; @@ -211,7 +224,7 @@ function assert_true( bool $condition, string $message ): void { $sources = apply_filters( 'agents_api_tool_sources', array() ); assert_true( isset( $sources['client'] ), 'client tool source registered' ); $tools = $sources['client'](); - foreach ( array( 'client/workspace_read', 'client/workspace_ls', 'client/workspace_grep', 'client/workspace_write', 'client/workspace_edit', 'client/workspace_apply_patch' ) as $tool_name ) { + foreach ( array( 'client/workspace_read', 'client/workspace_ls', 'client/workspace_grep', 'client/workspace_write', 'client/workspace_edit', 'client/workspace_apply_patch', 'client/workspace_show', 'client/workspace_git_status', 'client/workspace_git_diff' ) as $tool_name ) { assert_true( isset( $tools[ $tool_name ] ), "tool source declares {$tool_name}" ); assert_true( 'wp-codebox/sandbox-workspace' === $tools[ $tool_name ]['runtime']['executor_target'], "{$tool_name} routes to sandbox executor target" ); assert_true( 'client' === $tools[ $tool_name ]['executor'], "{$tool_name} is a client-executed tool" ); @@ -229,6 +242,7 @@ function assert_true( bool $condition, string $message ): void { rmdir( $dir ); }; $rrmdir( $root_real ); + $rrmdir( $baseline ); fwrite( STDOUT, "OK php-sandbox-workspace-executor-smoke\n" ); } diff --git a/tests/agent-task-contracts.test.ts b/tests/agent-task-contracts.test.ts index 0657ecba1..9b5ccda18 100644 --- a/tests/agent-task-contracts.test.ts +++ b/tests/agent-task-contracts.test.ts @@ -251,6 +251,14 @@ assert.equal(failedBeforeArtifacts.status, "failed") assert.equal(failedBeforeArtifacts.success, false) assert.deepEqual(failedBeforeArtifacts.refs.artifact_bundles, []) +const objectArtifact = normalizeAgentTaskRunResult({ + success: true, + status: "succeeded", + artifacts: { kind: "codebox-patch", path: "files/patch.diff", sha256: "abc" }, + agent_result: { artifacts: { directory: "files" } }, +}) +assert.equal(objectArtifact.refs.patches[0]?.path, "files/patch.diff") + const malformedProviderOutput = normalizeAgentTaskRunResult({ success: false, status: "failed", diagnostics: [{ code: "wp-codebox.output.invalid-json", message: "Invalid JSON" }] }, { exitStatus: 0 }) assert.equal(malformedProviderOutput.status, "failed") assert.equal(malformedProviderOutput.diagnostics[0].code, "wp-codebox.output.invalid-json") diff --git a/tests/agent-task-reusable-workflow.test.ts b/tests/agent-task-reusable-workflow.test.ts index 7bc744a36..2315667cb 100644 --- a/tests/agent-task-reusable-workflow.test.ts +++ b/tests/agent-task-reusable-workflow.test.ts @@ -214,18 +214,17 @@ assert.deepEqual(result.verification, []) assert.doesNotMatch(JSON.stringify(result), /homeboy|agent-task-plan|run-plan/i) const nativeTaskInput = JSON.parse(await readFile(join(tmp, ".codebox", "native-agent-task-input.json"), "utf8")) const hostedDocsAgentToolSnapshot = [ - "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", + "workspace_read", "workspace_ls", "workspace_grep", "workspace_write", "workspace_edit", "workspace_apply_patch", + "workspace_show", "workspace_git_status", "workspace_git_diff", ] assert.deepEqual(nativeTaskInput.task_input.sandbox_tool_policy.tools, hostedDocsAgentToolSnapshot.map((id) => ({ id, runtime_tool_id: id, - execution_location: "parent", - transport_visibility: "visible", + execution_location: "sandbox", + transport_visibility: "sandbox", allowed: true, - runtime: { environment: "control_plane", capability_scope: "control_plane" }, -})), "Hosted Docs Agent run 29298164272 must emit canonical control-plane metadata for every parent tool") + runtime: { environment: "runtime_local", capability_scope: "runtime_local" }, +})), "Hosted Docs Agent run 29298164272 must emit canonical sandbox-local workspace metadata") const executeNativeAgentTask = new URL("../.github/scripts/run-agent-task/execute-native-agent-task.mjs", import.meta.url).pathname const executeAccessCase = async (candidate: Record, environment: Record) => { diff --git a/tests/execute-native-agent-task-lifecycle.test.mjs b/tests/execute-native-agent-task-lifecycle.test.mjs new file mode 100644 index 000000000..ac27cac2b --- /dev/null +++ b/tests/execute-native-agent-task-lifecycle.test.mjs @@ -0,0 +1,182 @@ +import assert from "node:assert/strict" +import { chmod, mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" +import { spawn } from "node:child_process" + +const root = resolve(".") +const executor = join(root, ".github/scripts/run-agent-task/execute-native-agent-task.mjs") + +async function run(mode = "success") { + const temp = await mkdtemp(join(tmpdir(), "wp-codebox-native-lifecycle-")) + const workspace = join(temp, "workspace") + await mkdir(join(workspace, ".codebox"), { recursive: true }) + await writeFile(join(workspace, "README.md"), "before\n") + + const request = { + schema: "wp-codebox/agent-task-workflow-request/v1", + model: { provider: "openai", name: "gpt-5" }, + external_package_source: { + repository: "owner/agents", + revision: "a".repeat(40), + path: "agent.agent.json", + digest: `sha256-bytes-v1:${"b".repeat(64)}`, + }, + runtime_sources: [], + workload: { id: "run-1", label: "Update" }, + target_repo: "owner/repo", + prompt: "Edit README using workspace_read and workspace_edit.", + writable_paths: mode === "deny" ? "src/**" : "README.md", + runner_workspace: { + enabled: true, + repo: "owner/repo", + base: "main", + branch_prefix: "wp-codebox/agent-task/", + }, + validation_dependencies: "echo validation >> .codebox/order", + verification_commands: [ + { command: mode === "verify-fail" ? "false" : "echo verification >> .codebox/order" }, + ], + drift_checks: [{ command: "echo drift >> .codebox/order" }], + success: { requires_pr: mode !== "no-op-maintenance" }, + access: { + caller_repo: "owner/repo", + allowed_repos: ["owner/repo"], + access_token_repos: ["owner/repo"], + }, + limits: { max_turns: 1, time_budget_ms: 1000 }, + artifacts: { expected: [], declarations: [] }, + outputs: { + projections: { + pr_url: mode === "no-op-maintenance" + ? { path: "metadata.runner_workspace_publication.pull_request.url", required: false } + : "metadata.runner_workspace_publication.pull_request.url", + }, + }, + callback_data: {}, + run_agent: true, + dry_run: false, + } + await writeFile(join(workspace, ".codebox", "agent-task-request.json"), JSON.stringify(request)) + + const fakeCli = join(temp, "fake-cli.mjs") + const noOp = mode.startsWith("no-op") + await writeFile(fakeCli, ` +import { mkdir, readFile, writeFile } from "node:fs/promises" +import { join } from "node:path" +const output = process.argv[process.argv.indexOf("--result-file") + 1] +const root = join(process.cwd(), ".codebox", "agent-task-artifacts", "files") +await mkdir(root, { recursive: true }) +const patch = ${noOp} ? "" : "--- a/README.md\\n+++ b/README.md\\n@@ -1 +1 @@\\n-before\\n+after\\n" +await writeFile(join(root, "patch.diff"), patch) +await writeFile(join(root, "changed-files.json"), JSON.stringify({ + schema: "wp-codebox/changed-files/v1", + files: ${noOp} ? [] : [{ path: "/workspace/README.md", relativePath: "README.md", status: "modified", beforeMode: "100644", afterMode: "100644" }], +})) +await writeFile(join(process.cwd(), ".codebox", "order"), "runtime\\n") +const summary = { + schema: "wp-codebox/agent-task-run-result/v1", + success: true, + status: "succeeded", + refs: { + patches: [{ kind: "codebox-patch", path: join(root, "patch.diff") }], + changed_files: [{ kind: "codebox-changed-files", path: join(root, "changed-files.json") }], + }, +} +const result = { + schema: "wp-codebox/agent-task-run/v1", + success: true, + status: "succeeded", + agent_task_run_result: summary, + agent_result: { + artifacts: { directory: root }, + changedFiles: { artifact: "changed-files.json", bytes: ${noOp} ? 0 : 1 }, + patch: { artifact: "patch.diff", bytes: ${noOp} ? 0 : 1 }, + }, + metadata: { imported_agent: { slug: "fixture-agent" }, tool_contract: { tools: ["workspace_read", "workspace_edit"] } }, +} +await writeFile(output, JSON.stringify(result)) +`) + + const publisher = join(temp, "publisher.mjs") + await writeFile(publisher, ` +import { appendFile } from "node:fs/promises" +import { join } from "node:path" +export async function publishRunnerWorkspace({ workspace }) { + await appendFile(join(workspace, ".codebox", "order"), "publish\\n") + return { + schema: "wp-codebox/runner-workspace-publication-result/v1", + success: true, + status: "published", + backend: "fixture", + pull_request: { url: "https://github.com/owner/repo/pull/1", reused: false, opened: true }, + } +} +`) + + const gh = join(temp, "gh") + await writeFile(gh, `#!/usr/bin/env node +process.stdout.write(JSON.stringify({ + html_url: "https://github.com/owner/repo/pull/1", + base: { repo: { full_name: "owner/repo" } }, +})) +`) + await chmod(gh, 0o755) + + const result = await new Promise((done) => { + const child = spawn(process.execPath, [executor], { + cwd: workspace, + env: { + ...process.env, + NODE_ENV: "test", + AGENT_TASK_REQUEST_PATH: join(workspace, ".codebox", "agent-task-request.json"), + AGENT_TASK_WORKSPACE: workspace, + WP_CODEBOX_WORKFLOW_ROOT: root, + WP_CODEBOX_CLI_PATH: fakeCli, + WP_CODEBOX_TEST_SKIP_MATERIALIZATION: "true", + WP_CODEBOX_TEST_PUBLISHER_MODULE: publisher, + GITHUB_TOKEN: "token", + EXPLICIT_ACCESS_TOKEN_CONFIGURED: "true", + EXTERNAL_PACKAGE_SOURCE_POLICY: JSON.stringify({ + version: 1, + repositories: { "owner/agents": ["agent.agent.json"] }, + }), + PATH: `${temp}:${process.env.PATH || ""}`, + }, + }) + let stderr = "" + child.stderr.on("data", (chunk) => { stderr += chunk }) + child.on("close", (code) => done({ code, stderr })) + }) + + return { + ...result, + workspace, + result: JSON.parse(await readFile(join(workspace, ".codebox", "agent-task-workflow-result.json"), "utf8")), + order: await readFile(join(workspace, ".codebox", "order"), "utf8").catch(() => ""), + } +} + +const success = await run() +assert.equal(success.code, 0, success.stderr) +assert.equal(await readFile(join(success.workspace, "README.md"), "utf8"), "after\n") +assert.match(success.order, /runtime\nvalidation\nverification\ndrift\npublish\n/) +assert.equal(success.result.runtime_result.metadata.runner_workspace_publication.pull_request.url, "https://github.com/owner/repo/pull/1") + +const failedVerification = await run("verify-fail") +assert.equal(failedVerification.code, 1) +assert(!failedVerification.order.includes("publish")) + +const denied = await run("deny") +assert.equal(denied.code, 1) +assert.equal(denied.order, "runtime\n") + +const noOpRequired = await run("no-op") +assert.equal(noOpRequired.code, 1) +assert(!noOpRequired.order.includes("publish")) + +const noOpMaintenance = await run("no-op-maintenance") +assert.equal(noOpMaintenance.code, 0) +assert(!noOpMaintenance.order.includes("publish")) + +console.log("native agent task lifecycle ok") diff --git a/tests/runner-workspace-apply.test.ts b/tests/runner-workspace-apply.test.ts new file mode 100644 index 000000000..ebc2455ea --- /dev/null +++ b/tests/runner-workspace-apply.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict" +import { execFile } from "node:child_process" +import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { promisify } from "node:util" +import { createHash } from "node:crypto" +import { applyRunnerWorkspacePatch } from "../packages/runtime-core/src/runner-workspace-apply.js" + +const exec = promisify(execFile) + +async function fixture(patch: string, files: unknown[]): Promise<{ workspace: string; artifacts: string; refs: Array<{ kind: string; path: string; sha256?: string }> }> { + const root = await mkdtemp(join(tmpdir(), "wp-codebox-apply-")) + const workspace = join(root, "workspace") + const artifacts = join(root, "artifacts") + await mkdir(workspace, { recursive: true }) + await mkdir(join(artifacts, "files"), { recursive: true }) + await writeFile(join(workspace, "README.md"), "before\n") + await exec("git", ["init", "--quiet"], { cwd: workspace }) + await writeFile(join(artifacts, "files", "patch.diff"), patch) + await writeFile(join(artifacts, "files", "changed-files.json"), JSON.stringify({ schema: "wp-codebox/changed-files/v1", files })) + return { workspace, artifacts, refs: [ + { kind: "codebox-patch", path: join(artifacts, "files", "patch.diff"), sha256: createHash("sha256").update(patch).digest("hex") }, + { kind: "codebox-changed-files", path: join(artifacts, "files", "changed-files.json") }, + ] } +} + +const patch = "--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-before\n+after\n" +const files = [{ path: "/workspace/README.md", relativePath: "README.md", status: "modified", beforeMode: "100644", afterMode: "100644" }] + +{ + const input = await fixture(patch, files) + const order: string[] = [] + const result = await applyRunnerWorkspacePatch({ artifactRoot: input.artifacts, artifactRefs: input.refs, workspaceRoot: input.workspace, writablePaths: ["README.md"], verify: async () => { order.push("verify") } }) + assert.equal(result.status, "applied") + assert.equal(await readFile(join(input.workspace, "README.md"), "utf8"), "after\n") + assert.deepEqual(order, ["verify"]) +} + +{ + const input = await fixture("", []) + const result = await applyRunnerWorkspacePatch({ artifactRoot: input.artifacts, artifactRefs: input.refs, workspaceRoot: input.workspace, writablePaths: ["README.md"] }) + assert.equal(result.status, "no-op") +} + +{ + const input = await fixture(patch, files) + await assert.rejects(() => applyRunnerWorkspacePatch({ artifactRoot: input.artifacts, artifactRefs: input.refs, workspaceRoot: input.workspace, writablePaths: ["src/**"] }), /outside writable_paths/) + assert.equal(await readFile(join(input.workspace, "README.md"), "utf8"), "before\n") +} + +{ + const input = await fixture(patch, files) + await assert.rejects(() => applyRunnerWorkspacePatch({ artifactRoot: input.artifacts, artifactRefs: input.refs, workspaceRoot: input.workspace, writablePaths: ["README.md"], verify: async () => { throw new Error("verification failed") } }), /verification failed/) + assert.equal(await readFile(join(input.workspace, "README.md"), "utf8"), "after\n") +} + +console.log("runner workspace apply ok") diff --git a/tests/runner-workspace-publisher.test.mjs b/tests/runner-workspace-publisher.test.mjs new file mode 100644 index 000000000..aec951c6d --- /dev/null +++ b/tests/runner-workspace-publisher.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict" +import { mkdtemp, writeFile, unlink } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { publishRunnerWorkspace } from "../.github/scripts/run-agent-task/runner-workspace-publisher.mjs" + +const workspace = await mkdtemp(join(tmpdir(), "wp-codebox-publisher-")) +await writeFile(join(workspace, "README.md"), "changed\n") +const request = { target_repo: "owner/repo", workload: { id: "run-1", label: "Update" }, runner_workspace: { enabled: true, repo: "owner/repo", base: "main", branch_prefix: "wp-codebox/agent-task/" }, access: { allowed_repos: ["owner/repo"] } } + +function response(status, body) { return { ok: status >= 200 && status < 300, status, json: async () => body } } +function fetchMock(existing = false) { + const calls = [] + return { calls, fetch: async (url, init) => { + calls.push([url, init.method]) + const parsed = new URL(url) + const path = parsed.pathname.replace("/repos/owner/repo", "") + if (path === "/git/ref/heads/main") return response(200, { object: { sha: "base" } }) + if (path === "/git/commits/base") return response(200, { tree: { sha: "tree" } }) + if (path.includes("/git/ref/heads/wp-codebox/agent-task/run-1")) return existing ? response(200, { object: { sha: "old" } }) : response(404, {}) + if (path === "/git/blobs") return response(201, { sha: "blob" }) + if (path === "/git/trees") return response(201, { sha: "next-tree" }) + if (path === "/git/commits") return response(201, { sha: "commit" }) + if (path === "/git/refs") return response(201, {}) + if (path.includes("/git/refs/heads/")) return response(200, {}) + if (path === "/pulls" && parsed.search) return response(200, existing ? [{ number: 4, html_url: "https://github.com/owner/repo/pull/4", base: { repo: { full_name: "owner/repo" }, ref: "main" }, head: { ref: "wp-codebox/agent-task/run-1" } }] : []) + if (path === "/pulls") return response(201, { number: 5, html_url: "https://github.com/owner/repo/pull/5", base: { repo: { full_name: "owner/repo" }, ref: "main" }, head: { ref: "wp-codebox/agent-task/run-1" } }) + throw new Error(`unexpected ${path}`) + } } +} + +{ + const mock = fetchMock(false) + const result = await publishRunnerWorkspace({ request, workspace, changedFiles: ["README.md"], token: "secret", fetchImpl: mock.fetch }) + assert.equal(result.pull_request.opened, true) + assert(mock.calls.some(([, method]) => method === "POST")) +} +{ + const mock = fetchMock(true) + const result = await publishRunnerWorkspace({ request, workspace, changedFiles: ["README.md"], token: "secret", fetchImpl: mock.fetch }) + assert.equal(result.pull_request.reused, true) + assert(mock.calls.some(([, method]) => method === "PATCH")) +} +await assert.rejects(() => publishRunnerWorkspace({ request: { ...request, runner_workspace: { ...request.runner_workspace, repo: "other/repo" } }, workspace, changedFiles: ["README.md"], token: "secret", fetchImpl: fetchMock().fetch }), /not authorized/) +await assert.rejects(() => publishRunnerWorkspace({ request, workspace, changedFiles: ["README.md"], token: "", fetchImpl: fetchMock().fetch }), /No GitHub token/) +await assert.rejects(() => publishRunnerWorkspace({ request, workspace, changedFiles: ["README.md"], token: "secret", fetchImpl: async () => response(500, {}) }), /GitHub API GET \/git\/ref\/heads\/main failed/) +await unlink(join(workspace, "README.md")) +{ + const mock = fetchMock(false) + await publishRunnerWorkspace({ request, workspace, changedFiles: ["README.md"], token: "secret", fetchImpl: mock.fetch }) + assert(mock.calls.some(([url]) => url.endsWith("/git/trees"))) +} +console.log("runner workspace publisher ok") From b9b384f1171a1b8e89fbe763478f579d40c20066 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 14 Jul 2026 04:39:29 -0400 Subject: [PATCH 2/4] Harden runner workspace publication --- .../execute-native-agent-task.mjs | 8 +- .../runner-workspace-publisher.mjs | 36 ++++---- .../src/runner-workspace-apply.ts | 86 ++++++++++++++++++- ...ass-wp-codebox-browser-runner-template.php | 27 +++++- tests/runner-workspace-apply.test.ts | 24 +++++- tests/runner-workspace-publisher.test.mjs | 21 ++--- 6 files changed, 157 insertions(+), 45 deletions(-) diff --git a/.github/scripts/run-agent-task/execute-native-agent-task.mjs b/.github/scripts/run-agent-task/execute-native-agent-task.mjs index 1f7d009a0..5c215a50c 100644 --- a/.github/scripts/run-agent-task/execute-native-agent-task.mjs +++ b/.github/scripts/run-agent-task/execute-native-agent-task.mjs @@ -388,12 +388,13 @@ 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) { - const core = await import(pathToFileURL(join(codeboxRoot, "packages/runtime-core/dist/runner-workspace-apply.js")).href) + 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 core.applyRunnerWorkspacePatch({ artifactRoot: artifactsPath, artifactRefs: refs, workspaceRoot: workspace, writablePaths: String(request.writable_paths || "").split(",").map((value) => value.trim()).filter(Boolean) }) + 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) @@ -422,9 +423,10 @@ const runtimeRecord = record(runtimeResult) const agentResult = record(runtimeRecord.agent_task_run_result) 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 - publication = await publisher({ request, workspace, changedFiles: workspaceApply.changedFiles, token: process.env.ACCESS_TOKEN || process.env.GITHUB_TOKEN }) + publication = await publisher({ request, changedFiles: workspaceApply.changedFiles, publicationFiles: workspaceApply.publicationFiles, token: process.env.ACCESS_TOKEN || process.env.GITHUB_TOKEN }) runtimeRecord.metadata = { ...record(runtimeRecord.metadata), runner_workspace_publication: publication } runtimeRecord.outputs = { ...record(runtimeRecord.outputs), runner_workspace_publication: publication } } diff --git a/.github/scripts/run-agent-task/runner-workspace-publisher.mjs b/.github/scripts/run-agent-task/runner-workspace-publisher.mjs index 36c4109f3..126155880 100644 --- a/.github/scripts/run-agent-task/runner-workspace-publisher.mjs +++ b/.github/scripts/run-agent-task/runner-workspace-publisher.mjs @@ -1,10 +1,8 @@ -import { readFile } from "node:fs/promises" -import { resolve } from "node:path" 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, workspace, changedFiles, token, fetchImpl = fetch }) { +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() @@ -23,28 +21,26 @@ export async function publishRunnerWorkspace({ request, workspace, changedFiles, if (!response.ok) throw new Error(`GitHub API ${method} ${path} failed with ${response.status}.`) return payload } - const baseRef = await api("GET", `/git/ref/heads/${encodeURIComponent(base)}`) - const baseSha = string(baseRef.object?.sha) + 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 = [] - for (const changed of changedFiles) { - const relativePath = string(changed) + 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.") - const absolute = resolve(workspace, relativePath) - if (!absolute.startsWith(`${resolve(workspace)}/`)) throw new Error("Publication changed file escapes workspace.") - try { - const content = await readFile(absolute) - const blob = await api("POST", "/git/blobs", { content: content.toString("base64"), encoding: "base64" }) - tree.push({ path: relativePath, mode: "100644", type: "blob", sha: string(blob.sha) }) - } catch (error) { - if (error?.code === "ENOENT") tree.push({ path: relativePath, mode: "100644", type: "blob", sha: null }) - else throw error - } + 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 }) - let parent = baseSha - let existing = null - 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 } 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] }) 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) }) diff --git a/packages/runtime-core/src/runner-workspace-apply.ts b/packages/runtime-core/src/runner-workspace-apply.ts index 3fbf50152..983cabf85 100644 --- a/packages/runtime-core/src/runner-workspace-apply.ts +++ b/packages/runtime-core/src/runner-workspace-apply.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process" -import { readFile, lstat, realpath } from "node:fs/promises" -import { isAbsolute, resolve } from "node:path" +import { readFile, lstat, realpath, readdir } from "node:fs/promises" +import { isAbsolute, resolve, relative } from "node:path" import { promisify } from "node:util" import { createHash } from "node:crypto" import { pathIsWithinRoot, relativePathMatchesExcludePattern } from "./file-tree-policy.js" @@ -36,6 +36,22 @@ export interface RunnerWorkspaceApplyResult { status: "applied" | "no-op" changedFiles: string[] patchSha256?: string + integrity?: RunnerWorkspaceIntegritySnapshot + publicationFiles?: RunnerWorkspacePublicationFile[] +} + +export interface RunnerWorkspacePublicationFile { + path: string + mode: "100644" | "100755" + content?: string + sha256?: string + deleted: boolean +} + +export interface RunnerWorkspaceIntegritySnapshot { + workspaceRoot: string + files: RunnerWorkspacePublicationFile[] + baseline: RunnerWorkspacePublicationFile[] } /** @@ -51,6 +67,7 @@ export async function applyRunnerWorkspacePatch(request: RunnerWorkspaceApplyReq const patchPath = await artifactPath(artifactRoot, patchRef.path) const changedPath = await artifactPath(artifactRoot, changedRef.path) const [patch, changedRaw] = await Promise.all([readBoundedText(patchPath), readBoundedText(changedPath)]) + const baseline = await snapshotWorkspace(workspaceRoot) verifyDigest(patch, patchRef.sha256) const changed = parseChangedFiles(changedRaw) @@ -64,6 +81,8 @@ export async function applyRunnerWorkspacePatch(request: RunnerWorkspaceApplyReq await execGit(workspaceRoot, ["apply", "--check", "--whitespace=error", "--", patchPath]) await execGit(workspaceRoot, ["apply", "--whitespace=error", "--", patchPath]) + const files = await snapshotWorkspace(workspaceRoot) + validateAppliedWorkspace(baseline, files, changed) if (request.verify) await request.verify() return { @@ -71,6 +90,19 @@ export async function applyRunnerWorkspacePatch(request: RunnerWorkspaceApplyReq status: "applied", changedFiles: changed.map((file) => file.relativePath), patchSha256: createHash("sha256").update(patch).digest("hex"), + integrity: { workspaceRoot, files, baseline }, + publicationFiles: changed.map((change) => { + const file = files.find((candidate) => candidate.path === change.relativePath) + return file ? file : { path: change.relativePath, mode: "100644", deleted: true } + }), + } +} + +/** Ensures checks did not mutate approved output or introduce unrelated files. */ +export async function verifyRunnerWorkspaceIntegrity(snapshot: RunnerWorkspaceIntegritySnapshot): Promise { + const current = await snapshotWorkspace(snapshot.workspaceRoot) + if (JSON.stringify(current) !== JSON.stringify(snapshot.files)) { + throw new Error("Runner workspace changed after approval; refusing publication.") } } @@ -121,7 +153,7 @@ function validateChangedFiles(files: RunnerWorkspaceChangedFile[], writablePaths for (const file of files) { const path = file.relativePath.replaceAll("\\", "/") if (!path || path.startsWith("/") || path.split("/").some((part) => part === "" || part === "." || part === ".." || part === ".git" || part === ".codebox")) throw new Error(`Changed file has a denied path: ${file.relativePath}`) - if (![file.beforeMode, file.afterMode].filter(Boolean).every((mode) => mode === "100644")) throw new Error(`Changed file has an unsupported mode: ${file.relativePath}`) + if (![file.beforeMode, file.afterMode].filter(Boolean).every((mode) => mode === "100644" || mode === "100755")) throw new Error(`Changed file has an unsupported mode: ${file.relativePath}`) if (!writablePaths.some((pattern) => relativePathMatchesExcludePattern(path, pattern))) throw new Error(`Changed file is outside writable_paths: ${file.relativePath}`) } } @@ -133,7 +165,53 @@ function validatePatchPaths(patch: string, changed: RunnerWorkspaceChangedFile[] const path = line.slice(4).split("\t", 1)[0].trim().replace(/^[ab]\//, "") return path === "/dev/null" ? [] : [path] }) - if (paths.length === 0 || paths.some((path) => !declared.has(path))) throw new Error("Patch paths do not exactly correspond to canonical changed-files.") + if (paths.length === 0 || paths.some((path) => !declared.has(path)) || [...declared].some((path) => !paths.includes(path))) throw new Error("Patch paths do not exactly correspond to canonical changed-files.") + for (const line of patch.split("\n")) { + if (/^(old mode|new mode|new file mode|deleted file mode) /.test(line)) { + const mode = line.split(" ").at(-1) + if (mode !== "100644" && mode !== "100755") throw new Error("Patch contains an unsupported file mode.") + } + if (/^(similarity index|rename from|rename to|copy from|copy to|Subproject commit)/.test(line)) throw new Error("Patch contains unsupported git metadata.") + } +} + +async function snapshotWorkspace(root: string): Promise { + const output: RunnerWorkspacePublicationFile[] = [] + async function visit(directory: string): Promise { + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.name === ".git" || entry.name === ".codebox") continue + const absolute = resolve(directory, entry.name) + const path = relative(root, absolute).replaceAll("\\", "/") + const stat = await lstat(absolute) + if (stat.isSymbolicLink()) throw new Error(`Runner workspace contains an unsupported path type: ${path}`) + if (stat.isDirectory()) { + await visit(absolute) + continue + } + if (!stat.isFile()) throw new Error(`Runner workspace contains an unsupported path type: ${path}`) + const mode = (stat.mode & 0o111) ? "100755" : "100644" + const bytes = await readFile(absolute) + output.push({ path, mode, content: bytes.toString("base64"), sha256: createHash("sha256").update(bytes).digest("hex"), deleted: false }) + } + } + await visit(root) + return output.sort((a, b) => a.path.localeCompare(b.path)) +} + +function validateAppliedWorkspace(baseline: RunnerWorkspacePublicationFile[], current: RunnerWorkspacePublicationFile[], changed: RunnerWorkspaceChangedFile[]): void { + const before = new Map(baseline.map((file) => [file.path, file])) + const after = new Map(current.map((file) => [file.path, file])) + const actual = new Set([...before.keys(), ...after.keys()].filter((path) => JSON.stringify(before.get(path)) !== JSON.stringify(after.get(path)))) + const declared = new Set(changed.map((file) => file.relativePath)) + if (actual.size !== declared.size || [...actual].some((path) => !declared.has(path))) throw new Error("Applied workspace differs from the canonical changed-files manifest.") + for (const file of changed) { + const value = after.get(file.relativePath) + if (file.status === "deleted") { + if (value) throw new Error(`Canonical deletion was not applied: ${file.relativePath}`) + continue + } + if (!value || value.mode !== file.afterMode) throw new Error(`Applied file mode does not match canonical manifest: ${file.relativePath}`) + } } async function execGit(cwd: string, args: string[]): Promise { diff --git a/packages/wordpress-plugin/src/class-wp-codebox-browser-runner-template.php b/packages/wordpress-plugin/src/class-wp-codebox-browser-runner-template.php index 8e9be020f..cc83af577 100644 --- a/packages/wordpress-plugin/src/class-wp-codebox-browser-runner-template.php +++ b/packages/wordpress-plugin/src/class-wp-codebox-browser-runner-template.php @@ -912,7 +912,10 @@ public static function runner_contract( string $runner_php ): array { /** Builds the generated PHP runtime-local tool registration fragment. */ public static function runtime_tool_registration_fragment(): string { - return ' + // The generated Playground runner is intentionally self-contained. Embed the + // same bounded executor used by the host registration, never a host bridge. + $sandbox_executor = file_get_contents( __DIR__ . '/class-wp-codebox-sandbox-workspace-executor.php' ); + return "\nif ( ! class_exists( 'WP_Codebox_Sandbox_Workspace_Executor' ) ) {\n" . $sandbox_executor . "\n}\n" . ' class WP_Codebox_Browser_Filesystem_Write_Tool { public function handle_tool_call( array $parameters, array $tool_def = array() ): array { global $wp_codebox_browser_artifact_environment; @@ -959,6 +962,10 @@ function wp_codebox_browser_runtime_tool_name( string $tool_id ): string { return \'filesystem_write\'; } +if ( preg_match( "/^(?:client\\/)?workspace_(?:show|ls|read|grep|write|edit|apply_patch|git_status|git_diff)$/", $tool_id ) ) { + return str_starts_with( $tool_id, "client/" ) ? substr( $tool_id, 7 ) : $tool_id; +} + return \'\'; } @@ -966,8 +973,11 @@ function wp_codebox_browser_runtime_tool_declarations( array $tool_names ): arra global $wp_codebox_browser_artifact_environment; $declarations = array(); -if ( ! in_array( \'filesystem_write\', $tool_names, true ) ) { - return $declarations; +foreach ( WP_Codebox_Sandbox_Workspace_Executor::tool_declarations() as $name => $declaration ) { + $base = substr( $name, strlen( "client/" ) ); + if ( in_array( $base, $tool_names, true ) ) { + $declarations[ $base ] = $declaration; + } } $environment = is_array( $wp_codebox_browser_artifact_environment ?? null ) ? $wp_codebox_browser_artifact_environment : array(); @@ -1119,7 +1129,16 @@ function wp_codebox_browser_runtime_replay_ability_lifecycle(): array { } function wp_codebox_browser_runtime_tool_callback( array $request, array $payload ) { -unset( $payload ); + +$workspace_tool = (string) ( $request[\'tool_name\'] ?? \'\' ); +if ( preg_match( "/^(?:client\\/)?workspace_(?:show|ls|read|grep|write|edit|apply_patch|git_status|git_diff)$/", $workspace_tool ) ) { + $task_input = is_array( $payload[\'task_input\'] ?? null ) ? $payload[\'task_input\'] : array(); + $context = array( + \'workspace_root\' => \'/workspace\', + \'writable_paths\' => is_array( $task_input[\'writable_paths\'] ?? null ) ? $task_input[\'writable_paths\'] : array(), + ); + return ( new WP_Codebox_Sandbox_Workspace_Executor() )->executeWP_Agent_Tool_Call( $request, is_array( $request[\'tool_def\'] ?? null ) ? $request[\'tool_def\'] : array(), $context ); +} if ( ! in_array( (string) ( $request[\'tool_name\'] ?? \'\' ), array( \'filesystem_write\', \'client/filesystem-write\' ), true ) ) { return null; diff --git a/tests/runner-workspace-apply.test.ts b/tests/runner-workspace-apply.test.ts index ebc2455ea..d13c2bdd3 100644 --- a/tests/runner-workspace-apply.test.ts +++ b/tests/runner-workspace-apply.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { promisify } from "node:util" import { createHash } from "node:crypto" -import { applyRunnerWorkspacePatch } from "../packages/runtime-core/src/runner-workspace-apply.js" +import { applyRunnerWorkspacePatch, verifyRunnerWorkspaceIntegrity } from "../packages/runtime-core/src/runner-workspace-apply.js" const exec = promisify(execFile) @@ -35,6 +35,7 @@ const files = [{ path: "/workspace/README.md", relativePath: "README.md", status assert.equal(result.status, "applied") assert.equal(await readFile(join(input.workspace, "README.md"), "utf8"), "after\n") assert.deepEqual(order, ["verify"]) + await verifyRunnerWorkspaceIntegrity(result.integrity!) } { @@ -43,6 +44,27 @@ const files = [{ path: "/workspace/README.md", relativePath: "README.md", status assert.equal(result.status, "no-op") } +{ + const executablePatch = "diff --git a/README.md b/README.md\nold mode 100644\nnew mode 100755\n--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-before\n+after\n" + const executableFiles = [{ ...files[0], afterMode: "100755" }] + const input = await fixture(executablePatch, executableFiles) + const result = await applyRunnerWorkspacePatch({ artifactRoot: input.artifacts, artifactRefs: input.refs, workspaceRoot: input.workspace, writablePaths: ["README.md"] }) + assert.equal(result.publicationFiles?.[0]?.mode, "100755") +} + +{ + const input = await fixture(patch, files) + await writeFile(join(input.artifacts, "files", "changed-files.json"), JSON.stringify({ schema: "wp-codebox/changed-files/v1", files: [{ ...files[0], relativePath: "other.md" }] })) + await assert.rejects(() => applyRunnerWorkspacePatch({ artifactRoot: input.artifacts, artifactRefs: input.refs, workspaceRoot: input.workspace, writablePaths: ["README.md", "other.md"] }), /exactly correspond/) +} + +{ + const input = await fixture(patch, files) + const result = await applyRunnerWorkspacePatch({ artifactRoot: input.artifacts, artifactRefs: input.refs, workspaceRoot: input.workspace, writablePaths: ["README.md"] }) + await writeFile(join(input.workspace, "extra.txt"), "unexpected\n") + await assert.rejects(() => verifyRunnerWorkspaceIntegrity(result.integrity!), /changed after approval/) +} + { const input = await fixture(patch, files) await assert.rejects(() => applyRunnerWorkspacePatch({ artifactRoot: input.artifacts, artifactRefs: input.refs, workspaceRoot: input.workspace, writablePaths: ["src/**"] }), /outside writable_paths/) diff --git a/tests/runner-workspace-publisher.test.mjs b/tests/runner-workspace-publisher.test.mjs index aec951c6d..30204d367 100644 --- a/tests/runner-workspace-publisher.test.mjs +++ b/tests/runner-workspace-publisher.test.mjs @@ -1,12 +1,8 @@ import assert from "node:assert/strict" -import { mkdtemp, writeFile, unlink } from "node:fs/promises" -import { tmpdir } from "node:os" -import { join } from "node:path" import { publishRunnerWorkspace } from "../.github/scripts/run-agent-task/runner-workspace-publisher.mjs" -const workspace = await mkdtemp(join(tmpdir(), "wp-codebox-publisher-")) -await writeFile(join(workspace, "README.md"), "changed\n") const request = { target_repo: "owner/repo", workload: { id: "run-1", label: "Update" }, runner_workspace: { enabled: true, repo: "owner/repo", base: "main", branch_prefix: "wp-codebox/agent-task/" }, access: { allowed_repos: ["owner/repo"] } } +const publicationFiles = [{ path: "README.md", mode: "100644", content: Buffer.from("changed\n").toString("base64"), deleted: false }] function response(status, body) { return { ok: status >= 200 && status < 300, status, json: async () => body } } function fetchMock(existing = false) { @@ -16,7 +12,7 @@ function fetchMock(existing = false) { const parsed = new URL(url) const path = parsed.pathname.replace("/repos/owner/repo", "") if (path === "/git/ref/heads/main") return response(200, { object: { sha: "base" } }) - if (path === "/git/commits/base") return response(200, { tree: { sha: "tree" } }) + if (path === "/git/commits/base" || path === "/git/commits/old") return response(200, { tree: { sha: path.endsWith("old") ? "prior-tree" : "tree" } }) if (path.includes("/git/ref/heads/wp-codebox/agent-task/run-1")) return existing ? response(200, { object: { sha: "old" } }) : response(404, {}) if (path === "/git/blobs") return response(201, { sha: "blob" }) if (path === "/git/trees") return response(201, { sha: "next-tree" }) @@ -31,23 +27,22 @@ function fetchMock(existing = false) { { const mock = fetchMock(false) - const result = await publishRunnerWorkspace({ request, workspace, changedFiles: ["README.md"], token: "secret", fetchImpl: mock.fetch }) + const result = await publishRunnerWorkspace({ request, changedFiles: ["README.md"], publicationFiles, token: "secret", fetchImpl: mock.fetch }) assert.equal(result.pull_request.opened, true) assert(mock.calls.some(([, method]) => method === "POST")) } { const mock = fetchMock(true) - const result = await publishRunnerWorkspace({ request, workspace, changedFiles: ["README.md"], token: "secret", fetchImpl: mock.fetch }) + const result = await publishRunnerWorkspace({ request, changedFiles: ["README.md"], publicationFiles, token: "secret", fetchImpl: mock.fetch }) assert.equal(result.pull_request.reused, true) assert(mock.calls.some(([, method]) => method === "PATCH")) } -await assert.rejects(() => publishRunnerWorkspace({ request: { ...request, runner_workspace: { ...request.runner_workspace, repo: "other/repo" } }, workspace, changedFiles: ["README.md"], token: "secret", fetchImpl: fetchMock().fetch }), /not authorized/) -await assert.rejects(() => publishRunnerWorkspace({ request, workspace, changedFiles: ["README.md"], token: "", fetchImpl: fetchMock().fetch }), /No GitHub token/) -await assert.rejects(() => publishRunnerWorkspace({ request, workspace, changedFiles: ["README.md"], token: "secret", fetchImpl: async () => response(500, {}) }), /GitHub API GET \/git\/ref\/heads\/main failed/) -await unlink(join(workspace, "README.md")) +await assert.rejects(() => publishRunnerWorkspace({ request: { ...request, runner_workspace: { ...request.runner_workspace, repo: "other/repo" } }, changedFiles: ["README.md"], publicationFiles, token: "secret", fetchImpl: fetchMock().fetch }), /not authorized/) +await assert.rejects(() => publishRunnerWorkspace({ request, changedFiles: ["README.md"], publicationFiles, token: "", fetchImpl: fetchMock().fetch }), /No GitHub token/) +await assert.rejects(() => publishRunnerWorkspace({ request, changedFiles: ["README.md"], publicationFiles, token: "secret", fetchImpl: async () => response(500, {}) }), /GitHub API GET \/git\/ref\/heads\//) { const mock = fetchMock(false) - await publishRunnerWorkspace({ request, workspace, changedFiles: ["README.md"], token: "secret", fetchImpl: mock.fetch }) + await publishRunnerWorkspace({ request, changedFiles: ["README.md"], publicationFiles: [{ path: "README.md", mode: "100755", content: Buffer.from("changed\n").toString("base64"), deleted: false }], token: "secret", fetchImpl: mock.fetch }) assert(mock.calls.some(([url]) => url.endsWith("/git/trees"))) } console.log("runner workspace publisher ok") From 7a94b4f3f2e8046659ced66bad73bd3c73093f3c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 14 Jul 2026 05:42:37 -0400 Subject: [PATCH 3/4] Use runtime tool overlays for sandbox workspaces --- .../execute-native-agent-task.mjs | 5 +- docs/agent-task-reusable-workflow.md | 13 +- ...-task-runtime-sources-run-29299109269.json | 2 +- .../src/runner-workspace-apply.ts | 5 +- ...class-wp-codebox-agent-runtime-invoker.php | 6 +- ...ass-wp-codebox-browser-runner-template.php | 49 ------ ...ss-wp-codebox-runtime-package-executor.php | 42 ++++- ...-wp-codebox-sandbox-workspace-executor.php | 148 ++++++------------ ...it-wp-codebox-abilities-browser-runner.php | 3 +- .../php-agents-api-adapter-contract-smoke.php | 4 +- .../php-sandbox-workspace-executor-smoke.php | 19 +-- ...ecute-native-agent-task-lifecycle.test.mjs | 11 +- tests/runner-workspace-apply.test.ts | 12 +- tests/runner-workspace-publisher.test.mjs | 5 +- ...ime-sources-playground-integration.test.ts | 70 ++++++--- 15 files changed, 187 insertions(+), 207 deletions(-) diff --git a/.github/scripts/run-agent-task/execute-native-agent-task.mjs b/.github/scripts/run-agent-task/execute-native-agent-task.mjs index 5c215a50c..0c58897c0 100644 --- a/.github/scripts/run-agent-task/execute-native-agent-task.mjs +++ b/.github/scripts/run-agent-task/execute-native-agent-task.mjs @@ -426,7 +426,10 @@ if (execution.code === 0 && runtimeRecord.success === true && verificationPassed 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 - publication = await publisher({ request, changedFiles: workspaceApply.changedFiles, publicationFiles: workspaceApply.publicationFiles, token: process.env.ACCESS_TOKEN || process.env.GITHUB_TOKEN }) + 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 } } diff --git a/docs/agent-task-reusable-workflow.md b/docs/agent-task-reusable-workflow.md index 9abc93194..5380edc1b 100644 --- a/docs/agent-task-reusable-workflow.md +++ b/docs/agent-task-reusable-workflow.md @@ -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 +Playground integration additionally boots the agent registry and OpenAI +provider path with a local HTTP interceptor. Its deterministic 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 diff --git a/fixtures/agent-task-runtime-sources-run-29299109269.json b/fixtures/agent-task-runtime-sources-run-29299109269.json index 38e486e05..3acc3f7ba 100644 --- a/fixtures/agent-task-runtime-sources-run-29299109269.json +++ b/fixtures/agent-task-runtime-sources-run-29299109269.json @@ -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" } } ] diff --git a/packages/runtime-core/src/runner-workspace-apply.ts b/packages/runtime-core/src/runner-workspace-apply.ts index 983cabf85..68508bb6d 100644 --- a/packages/runtime-core/src/runner-workspace-apply.ts +++ b/packages/runtime-core/src/runner-workspace-apply.ts @@ -114,7 +114,10 @@ function exactlyOne(refs: RunnerWorkspaceArtifactRef[], kind: string): RunnerWor async function artifactPath(root: string, value: string): Promise { if (!value) throw new Error("Artifact reference path is required.") - const candidate = await realpath(isAbsolute(value) ? resolve(value) : resolve(root, value)) + const requested = isAbsolute(value) ? resolve(value) : resolve(root, value) + const requestedStat = await lstat(requested) + if (!requestedStat.isFile() || requestedStat.isSymbolicLink()) throw new Error("Artifact must be a bounded regular file.") + const candidate = await realpath(requested) if (!pathIsWithinRoot(candidate, root)) throw new Error("Artifact reference escapes the trusted artifact root.") return candidate } diff --git a/packages/wordpress-plugin/src/class-wp-codebox-agent-runtime-invoker.php b/packages/wordpress-plugin/src/class-wp-codebox-agent-runtime-invoker.php index 7c7f86404..75151c3bd 100644 --- a/packages/wordpress-plugin/src/class-wp-codebox-agent-runtime-invoker.php +++ b/packages/wordpress-plugin/src/class-wp-codebox-agent-runtime-invoker.php @@ -194,7 +194,7 @@ function wp_codebox_browser_runtime_execute_ability( string $ability_name, array return $ability->execute( $input ); } -function wp_codebox_browser_runtime_prepare_input( array $payload, array $invocation, string $session_id, array $runtime_tool_declarations, array $ability_tools, array $allowed_tool_ids, array $sandbox_tool_ids ): array { +function wp_codebox_browser_runtime_prepare_input( array $payload, array $invocation, string $session_id, array $ability_tools, array $allowed_tool_ids, array $sandbox_tool_ids ): array { $agent = sanitize_key( (string) ( $payload['agent'] ?? '' ) ); if ( '' === $agent ) { $agent = 'wp-codebox-sandbox'; @@ -230,8 +230,6 @@ function wp_codebox_browser_runtime_prepare_input( array $payload, array $invoca 'client_name' => 'wp-codebox-browser-runner', 'caller_session_id' => $session_id, 'task_input' => $payload['task_input'] ?? array(), - 'runtime_tools' => $runtime_tool_declarations, - 'runtime_tool_callback' => 'wp_codebox_browser_runtime_tool_callback', 'ability_tools' => $ability_tools, ), ); @@ -249,7 +247,7 @@ function wp_codebox_browser_runtime_prepare_input( array $payload, array $invoca } $input = array_replace_recursive( $base_input, is_array( $invocation['input'] ?? null ) ? $invocation['input'] : array() ); -return function_exists( 'apply_filters' ) ? apply_filters( 'wp_codebox_browser_runtime_invocation_input', $input, $payload, $invocation, $session_id, $runtime_tool_declarations, $ability_tools, $allowed_tool_ids, $sandbox_tool_ids ) : $input; +return function_exists( 'apply_filters' ) ? apply_filters( 'wp_codebox_browser_runtime_invocation_input', $input, $payload, $invocation, $session_id, $ability_tools, $allowed_tool_ids, $sandbox_tool_ids ) : $input; } function wp_codebox_browser_runtime_import_agent_bundles( array $bundle_specs ): array { diff --git a/packages/wordpress-plugin/src/class-wp-codebox-browser-runner-template.php b/packages/wordpress-plugin/src/class-wp-codebox-browser-runner-template.php index cc83af577..95e2abd4d 100644 --- a/packages/wordpress-plugin/src/class-wp-codebox-browser-runner-template.php +++ b/packages/wordpress-plugin/src/class-wp-codebox-browser-runner-template.php @@ -969,44 +969,6 @@ function wp_codebox_browser_runtime_tool_name( string $tool_id ): string { return \'\'; } -function wp_codebox_browser_runtime_tool_declarations( array $tool_names ): array { -global $wp_codebox_browser_artifact_environment; - -$declarations = array(); -foreach ( WP_Codebox_Sandbox_Workspace_Executor::tool_declarations() as $name => $declaration ) { - $base = substr( $name, strlen( "client/" ) ); - if ( in_array( $base, $tool_names, true ) ) { - $declarations[ $base ] = $declaration; - } -} - -$environment = is_array( $wp_codebox_browser_artifact_environment ?? null ) ? $wp_codebox_browser_artifact_environment : array(); -$root = (string) ( $environment[\'root\'] ?? \'wp-codebox-output/\' ); -$base_path = rtrim( (string) ( $environment[\'base_path\'] ?? \'/wordpress/wp-content/uploads/wp-codebox/artifacts\' ), \'/\' ); -$declarations[\'filesystem_write\'] = array( - \'name\' => \'filesystem_write\', - \'source\' => \'client\', - \'description\' => sprintf( \'Write one generated artifact file inside %s/%s. Call this once per file required by the caller artifact contract.\', $base_path, $root ), - \'executor\' => \'client\', - \'scope\' => \'run\', - \'parameters\' => array( - \'type\' => \'object\', - \'required\' => array( \'path\', \'content\' ), - \'properties\' => array( - \'path\' => array( \'type\' => \'string\', \'description\' => sprintf( \'Relative artifact path under %s, for example %sindex.html.\', $root, $root ) ), - \'content\' => array( \'type\' => \'string\', \'description\' => \'Full file contents. Use UTF-8 text unless encoding is base64.\' ), - \'encoding\' => array( \'type\' => \'string\', \'enum\' => array( \'utf-8\', \'base64\' ) ), - ), - ), - \'runtime\' => array( - \'environment\' => \'runtime_local\', - \'capability_scope\' => \'runtime_local\', - ), -); - -return $declarations; -} - function wp_codebox_browser_runtime_ability_tool_declarations( array $payload ): array { $task_input = is_array( $payload[\'task_input\'] ?? null ) ? $payload[\'task_input\'] : array(); $declared = is_array( $task_input[\'ability_tools\'] ?? null ) ? $task_input[\'ability_tools\'] : array(); @@ -1129,17 +1091,6 @@ function wp_codebox_browser_runtime_replay_ability_lifecycle(): array { } function wp_codebox_browser_runtime_tool_callback( array $request, array $payload ) { - -$workspace_tool = (string) ( $request[\'tool_name\'] ?? \'\' ); -if ( preg_match( "/^(?:client\\/)?workspace_(?:show|ls|read|grep|write|edit|apply_patch|git_status|git_diff)$/", $workspace_tool ) ) { - $task_input = is_array( $payload[\'task_input\'] ?? null ) ? $payload[\'task_input\'] : array(); - $context = array( - \'workspace_root\' => \'/workspace\', - \'writable_paths\' => is_array( $task_input[\'writable_paths\'] ?? null ) ? $task_input[\'writable_paths\'] : array(), - ); - return ( new WP_Codebox_Sandbox_Workspace_Executor() )->executeWP_Agent_Tool_Call( $request, is_array( $request[\'tool_def\'] ?? null ) ? $request[\'tool_def\'] : array(), $context ); -} - if ( ! in_array( (string) ( $request[\'tool_name\'] ?? \'\' ), array( \'filesystem_write\', \'client/filesystem-write\' ), true ) ) { return null; } diff --git a/packages/wordpress-plugin/src/class-wp-codebox-runtime-package-executor.php b/packages/wordpress-plugin/src/class-wp-codebox-runtime-package-executor.php index e083518f8..2657c3d05 100644 --- a/packages/wordpress-plugin/src/class-wp-codebox-runtime-package-executor.php +++ b/packages/wordpress-plugin/src/class-wp-codebox-runtime-package-executor.php @@ -124,12 +124,39 @@ private function execute_workflow( array $task ): array|WP_Error { $input['message'] = $this->string_value( $input['prompt'] ?? '' ); } $input['runtime_package_task'] = $task; + $input['run_id'] = $this->runtime_run_id( $input ); + $workspace_context = $this->sandbox_workspace_context( $input ); $ability_object = function_exists( 'wp_get_ability' ) ? wp_get_ability( $ability ) : null; if ( ! is_object( $ability_object ) || ! method_exists( $ability_object, 'execute' ) ) { return new WP_Error( 'wp_codebox_runtime_package_workflow_unavailable', 'Runtime package workflow ability is unavailable.', array( 'status' => 500, 'ability' => $ability ) ); } - $result = $ability_object->execute( $input ); + $runtime_tool_declarations = static function ( array $declarations, $agent, array $runtime_context ) use ( $input, $workspace_context ): array { + if ( ! $agent instanceof \WP_Agent || ! hash_equals( (string) $input['agent'], (string) $runtime_context['agent_slug'] ) || ! hash_equals( (string) $input['run_id'], (string) $runtime_context['run_id'] ) ) { + return $declarations; + } + return array_merge( $declarations, WP_Codebox_Sandbox_Workspace_Executor::tool_declarations_for_enabled_tools( $agent->get_default_config(), $workspace_context ) ); + }; + $tool_executors = static function ( array $executors, array $runtime_context ) use ( $input, $workspace_context ): array { + if ( ! hash_equals( (string) $input['agent'], (string) ( $runtime_context['agent_slug'] ?? '' ) ) || ! hash_equals( (string) $input['run_id'], (string) ( $runtime_context['run_id'] ?? '' ) ) ) { + return $executors; + } + $executors[ WP_Codebox_Sandbox_Workspace_Executor::TARGET_ID ] = WP_Codebox_Sandbox_Workspace_Executor::executor_for_context( $workspace_context ); + return $executors; + }; + $filters_available = function_exists( 'add_filter' ) && function_exists( 'remove_filter' ); + if ( $filters_available ) { + add_filter( 'agents_api_runtime_tool_declarations', $runtime_tool_declarations, 20, 3 ); + add_filter( 'agents_api_tool_executors', $tool_executors, 20, 2 ); + } + try { + $result = $ability_object->execute( $input ); + } finally { + if ( $filters_available ) { + remove_filter( 'agents_api_runtime_tool_declarations', $runtime_tool_declarations, 20 ); + remove_filter( 'agents_api_tool_executors', $tool_executors, 20 ); + } + } if ( is_wp_error( $result ) ) { return $result; } @@ -148,6 +175,19 @@ private function execute_workflow( array $task ): array|WP_Error { return $result; } + /** @param array $input @return array */ + private function sandbox_workspace_context( array $input ): array { + $policy = is_array( $input['runner_workspace_policy'] ?? null ) ? $input['runner_workspace_policy'] : array(); + $paths = is_array( $policy['writable_paths'] ?? null ) ? $policy['writable_paths'] : array(); + return array( 'workspace_root' => '/workspace', 'writable_paths' => $paths ); + } + + /** @param array $input */ + private function runtime_run_id( array $input ): string { + $run_id = $this->string_value( $input['run_id'] ?? '' ); + return '' !== $run_id ? $run_id : 'wp-codebox-runtime-' . ( function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( '', true ) ); + } + /** @param array $task Runtime package task. @param array> $imports Import results. @return string|WP_Error */ private function imported_agent_slug( array $task, array $imports ): string|WP_Error { $package = is_array( $task['package'] ?? null ) ? $task['package'] : array(); diff --git a/packages/wordpress-plugin/src/class-wp-codebox-sandbox-workspace-executor.php b/packages/wordpress-plugin/src/class-wp-codebox-sandbox-workspace-executor.php index 6a8a98888..51e607829 100644 --- a/packages/wordpress-plugin/src/class-wp-codebox-sandbox-workspace-executor.php +++ b/packages/wordpress-plugin/src/class-wp-codebox-sandbox-workspace-executor.php @@ -233,9 +233,6 @@ public static function register(): bool { return false; } - add_filter( 'agents_api_tool_sources', array( self::class, 'register_tool_source' ), 20, 3 ); - add_filter( 'agents_api_executor_targets', array( self::class, 'register_executor_target' ), 20, 2 ); - add_filter( 'agents_api_execution_targets', array( self::class, 'register_executor_target' ), 20, 2 ); add_filter( 'agents_api_tool_executors', array( self::class, 'register_tool_executor' ), 20, 2 ); self::$registered = true; @@ -250,50 +247,6 @@ public static function substrate_exists(): bool { && class_exists( 'AgentsAPI\\AI\\Tools\\WP_Agent_Tool_Source_Registry' ); } - /** - * Register this adapter as an Agents API tool source. - * - * @param array $sources Existing sources. - * @param array $context Runtime context. - * @param mixed $registry Source registry. - * @return array - */ - public static function register_tool_source( array $sources, array $context = array(), $registry = null ): array { - unset( $context, $registry ); - - $existing = $sources[ self::SOURCE_SLUG ] ?? null; - $sources[ self::SOURCE_SLUG ] = static function ( array $source_context = array(), $source_registry = null ) use ( $existing ): array { - $tools = is_callable( $existing ) ? call_user_func( $existing, $source_context, $source_registry ) : array(); - if ( ! is_array( $tools ) ) { - $tools = array(); - } - - foreach ( self::tool_declarations() as $tool_name => $tool_declaration ) { - if ( ! isset( $tools[ $tool_name ] ) ) { - $tools[ $tool_name ] = $tool_declaration; - } - } - - return $tools; - }; - - return $sources; - } - - /** - * Register this adapter as an Agents API executor target. - * - * @param array $targets Existing targets. - * @param array $context Runtime context. - * @return array - */ - public static function register_executor_target( array $targets, array $context = array() ): array { - unset( $context ); - - $targets[ self::TARGET_ID ] = self::target_metadata(); - return $targets; - } - /** * Register the tool-call executor adapter for registry-based dispatch. * @@ -302,35 +255,34 @@ public static function register_executor_target( array $targets, array $context * @return array */ public static function register_tool_executor( array $executors, array $context = array() ): array { - unset( $context ); + $executors[ self::TARGET_ID ] = self::executor_for_context( $context ); - $executors[ self::TARGET_ID ] = new class( new self() ) implements \AgentsAPI\AI\Tools\WP_Agent_Tool_Executor { - public function __construct( private WP_Codebox_Sandbox_Workspace_Executor $adapter ) {} + return $executors; + } - /** - * @param array $tool_call Tool call. - * @param array $tool_definition Tool declaration. - * @param array $context Runtime context. - * @return array - */ + /** @param array $trusted_context */ + public static function executor_for_context( array $trusted_context ): \AgentsAPI\AI\Tools\WP_Agent_Tool_Executor { + return new class( new self(), $trusted_context ) implements \AgentsAPI\AI\Tools\WP_Agent_Tool_Executor { + /** @param array $trusted_context */ + public function __construct( private WP_Codebox_Sandbox_Workspace_Executor $adapter, private array $trusted_context ) {} + + /** @param array $tool_call @param array $tool_definition @param array $context @return array */ public function executeWP_Agent_Tool_Call( array $tool_call, array $tool_definition, array $context = array() ): array { - return $this->adapter->executeWP_Agent_Tool_Call( $tool_call, $tool_definition, $context ); + return $this->adapter->executeWP_Agent_Tool_Call( $tool_call, $tool_definition, array_replace( $context, $this->trusted_context ) ); } }; - - return $executors; } /** - * Agents API runtime tool declarations for the git-less sandbox surface. + * Canonical Agents API runtime tool declarations for the git-less sandbox surface. * * @return array> */ public static function tool_declarations(): array { $tools = array(); - foreach ( self::TOOL_MAP as $tool_name => $config ) { - $tools[ $tool_name ] = array( - 'name' => $tool_name, + foreach ( self::TOOL_MAP as $config ) { + $declaration = array( + 'name' => $config['base'], 'source' => self::SOURCE_SLUG, 'description' => $config['description'], 'parameters' => $config['parameters'], @@ -343,6 +295,29 @@ public static function tool_declarations(): array { 'side_effect_boundary' => 'wp-codebox-sandbox', ), ); + $tools[ $config['base'] ] = $declaration; + } + + return $tools; + } + + /** + * Return declarations only for canonical workspace tools enabled by the selected agent. + * + * @param array $agent_config + * @param array $context Trusted invocation-local context. + * @return array> + */ + public static function tool_declarations_for_enabled_tools( array $agent_config, array $context = array() ): array { + unset( $context ); + $enabled = is_array( $agent_config['enabled_tools'] ?? null ) ? $agent_config['enabled_tools'] : array(); + $all = self::tool_declarations(); + $tools = array(); + foreach ( $enabled as $name ) { + $name = is_string( $name ) ? trim( $name ) : ''; + if ( '' !== $name && isset( $all[ $name ] ) ) { + $tools[ $name ] = $all[ $name ]; + } } return $tools; @@ -1130,51 +1105,17 @@ private static function workspace_file_map( string $root ): array { /** * Resolve the bounded sandbox working root. * - * Priority: explicit run context, tool parameter, filter, constant, cwd. + * The host supplies this through the invocation-local executor context. * * @param array $context Runtime context. * @param array $parameters Tool parameters. * @return string Realpath of the working root, or '' when unavailable. */ private static function resolve_workspace_root( array $context, array $parameters ): string { - $candidates = array(); - - foreach ( array( 'workspace_root', 'sandbox_workspace_root' ) as $key ) { - if ( isset( $context[ $key ] ) && is_string( $context[ $key ] ) && '' !== trim( $context[ $key ] ) ) { - $candidates[] = trim( $context[ $key ] ); - } - } - if ( isset( $context['workspace'] ) && is_array( $context['workspace'] ) && isset( $context['workspace']['root'] ) && is_string( $context['workspace']['root'] ) ) { - $candidates[] = trim( $context['workspace']['root'] ); - } - if ( isset( $parameters['workspace_root'] ) && is_string( $parameters['workspace_root'] ) && '' !== trim( $parameters['workspace_root'] ) ) { - $candidates[] = trim( $parameters['workspace_root'] ); - } - - if ( function_exists( 'apply_filters' ) ) { - $filtered = apply_filters( 'wp_codebox_sandbox_workspace_root', '', $context, $parameters ); - if ( is_string( $filtered ) && '' !== trim( $filtered ) ) { - $candidates[] = trim( $filtered ); - } - } - - if ( defined( 'WP_CODEBOX_SANDBOX_WORKSPACE_ROOT' ) && is_string( constant( 'WP_CODEBOX_SANDBOX_WORKSPACE_ROOT' ) ) ) { - $candidates[] = (string) constant( 'WP_CODEBOX_SANDBOX_WORKSPACE_ROOT' ); - } - - $cwd = getcwd(); - if ( is_string( $cwd ) && '' !== $cwd ) { - $candidates[] = $cwd; - } - - foreach ( $candidates as $candidate ) { - $real = realpath( $candidate ); - if ( false !== $real && is_dir( $real ) ) { - return $real; - } - } - - return ''; + unset( $parameters ); + $candidate = $context['workspace_root'] ?? ''; + $real = is_string( $candidate ) && '' !== trim( $candidate ) ? realpath( $candidate ) : false; + return false !== $real && is_dir( $real ) ? $real : ''; } /** @return array */ @@ -1182,7 +1123,8 @@ private static function writable_paths( array $context ): array { $policy = is_array( $context['workflow_policy'] ?? null ) ? $context['workflow_policy'] : ( is_array( $context['runner_workspace_policy'] ?? null ) ? $context['runner_workspace_policy'] : array() ); $paths = $policy['writable_paths'] ?? $context['writable_paths'] ?? array(); if ( is_string( $paths ) ) { $paths = preg_split( '/\s*,\s*/', trim( $paths ) ) ?: array(); } - return array_values( array_filter( array_map( static fn( $path ): string => is_scalar( $path ) ? trim( (string) $path ) : '', is_array( $paths ) ? $paths : array() ) ) ); + $paths = array_values( array_filter( array_map( static fn( $path ): string => is_scalar( $path ) ? trim( (string) $path ) : '', is_array( $paths ) ? $paths : array() ) ) ); + return is_array( $paths ) ? array_values( array_filter( array_map( static fn( $path ): string => is_scalar( $path ) ? trim( (string) $path ) : '', $paths ) ) ) : array(); } private static function baseline_root( array $context ): string { diff --git a/packages/wordpress-plugin/src/trait-wp-codebox-abilities-browser-runner.php b/packages/wordpress-plugin/src/trait-wp-codebox-abilities-browser-runner.php index dfdb6243d..12c263d0d 100644 --- a/packages/wordpress-plugin/src/trait-wp-codebox-abilities-browser-runner.php +++ b/packages/wordpress-plugin/src/trait-wp-codebox-abilities-browser-runner.php @@ -323,10 +323,9 @@ private static function browser_agent_runner_php( array $task_input, string $ses } } $sandbox_tool_ids = array_values( array_unique( $sandbox_tool_ids ) ); -$runtime_tool_declarations = wp_codebox_browser_runtime_tool_declarations( $sandbox_tool_ids ); $ability_tool_ids = array_values( array_map( \'strval\', array_keys( $wp_codebox_browser_runtime_ability_tools ) ) ); $allowed_tool_ids = array_values( array_unique( array_merge( $sandbox_tool_ids, $ability_tool_ids ) ) ); -$input = wp_codebox_browser_runtime_prepare_input( $payload, $invocation, $session_id, $runtime_tool_declarations, $wp_codebox_browser_runtime_ability_tools, $allowed_tool_ids, $sandbox_tool_ids ); +$input = wp_codebox_browser_runtime_prepare_input( $payload, $invocation, $session_id, $wp_codebox_browser_runtime_ability_tools, $allowed_tool_ids, $sandbox_tool_ids ); $event_sink_attached = false; $event_sink = wp_codebox_browser_runtime_event_sink( $event_path, $input, $payload ); diff --git a/scripts/php-agents-api-adapter-contract-smoke.php b/scripts/php-agents-api-adapter-contract-smoke.php index 78d95f298..f0e3decab 100644 --- a/scripts/php-agents-api-adapter-contract-smoke.php +++ b/scripts/php-agents-api-adapter-contract-smoke.php @@ -182,7 +182,7 @@ function assert_no_agents_api_schema_leaks( mixed $value, string $path = '$' ): assert( array( 'slug' => 'example-agent', 'source' => $workspace_root . '/bundles/example-agent' ) === $GLOBALS['wp_codebox_test_abilities'][ $names['run_runtime_package'] ]->last_input['package'] ); assert( true === $GLOBALS['wp_codebox_test_abilities'][ $names['run_runtime_package'] ]->last_input['input']['wait_for_completion'] ); assert( 'coffee' === $GLOBALS['wp_codebox_test_abilities'][ $names['run_runtime_package'] ]->last_input['input']['topic'] ); -assert( array( 'provider' => 'codex', 'model' => 'gpt-5.5', 'wait_for_completion' => true ) === $GLOBALS['wp_codebox_test_abilities'][ $names['run_runtime_package'] ]->last_input['options'] ); +assert( array( 'provider' => 'codex', 'model' => 'gpt-5.5', 'wait_for_completion' => true, 'required_artifacts' => array() ) === $GLOBALS['wp_codebox_test_abilities'][ $names['run_runtime_package'] ]->last_input['options'] ); $package_with_options_only_controls = $adapter->run_runtime_package( array( @@ -194,7 +194,7 @@ function assert_no_agents_api_schema_leaks( mixed $value, string $path = '$' ): assert( 'tea' === $GLOBALS['wp_codebox_test_abilities'][ $names['run_runtime_package'] ]->last_input['input']['topic'] ); assert( true === $GLOBALS['wp_codebox_test_abilities'][ $names['run_runtime_package'] ]->last_input['input']['wait_for_completion'] ); assert( 1200000 === $GLOBALS['wp_codebox_test_abilities'][ $names['run_runtime_package'] ]->last_input['input']['time_budget_ms'] ); -assert( array( 'wait_for_completion' => true, 'time_budget_ms' => 1200000 ) === $GLOBALS['wp_codebox_test_abilities'][ $names['run_runtime_package'] ]->last_input['options'] ); +assert( array( 'wait_for_completion' => true, 'time_budget_ms' => 1200000, 'required_artifacts' => array() ) === $GLOBALS['wp_codebox_test_abilities'][ $names['run_runtime_package'] ]->last_input['options'] ); $default_chat_browser_input = WP_Codebox_Agents_API_Adapter::browser_runtime_invocation_input( array( 'client_context' => array() ), diff --git a/scripts/php-sandbox-workspace-executor-smoke.php b/scripts/php-sandbox-workspace-executor-smoke.php index 53cf3ee46..4e735102a 100644 --- a/scripts/php-sandbox-workspace-executor-smoke.php +++ b/scripts/php-sandbox-workspace-executor-smoke.php @@ -205,10 +205,6 @@ function assert_true( bool $condition, string $message ): void { $registered = WP_Codebox_Sandbox_Workspace_Executor::register(); assert_true( true === $registered, 'register returns true when substrate present' ); - $targets = apply_filters( 'agents_api_executor_targets', array() ); - assert_true( isset( $targets['wp-codebox/sandbox-workspace'] ), 'executor target registered under target_id' ); - assert_true( 'sandbox-workspace' === $targets['wp-codebox/sandbox-workspace']['kind'], 'target metadata kind' ); - $executors = apply_filters( 'agents_api_tool_executors', array() ); assert_true( isset( $executors['wp-codebox/sandbox-workspace'] ), 'executor adapter registered under target_id' ); assert_true( $executors['wp-codebox/sandbox-workspace'] instanceof AgentsAPI\AI\Tools\WP_Agent_Tool_Executor, 'executor satisfies the contract interface' ); @@ -221,13 +217,14 @@ function assert_true( bool $condition, string $message ): void { assert_true( true === $dispatched['success'], 'registered executor dispatches client/-prefixed tool name' ); assert_true( str_contains( $dispatched['result']['content'], 'Needle' ), 'registered executor returns file content' ); - $sources = apply_filters( 'agents_api_tool_sources', array() ); - assert_true( isset( $sources['client'] ), 'client tool source registered' ); - $tools = $sources['client'](); - foreach ( array( 'client/workspace_read', 'client/workspace_ls', 'client/workspace_grep', 'client/workspace_write', 'client/workspace_edit', 'client/workspace_apply_patch', 'client/workspace_show', 'client/workspace_git_status', 'client/workspace_git_diff' ) as $tool_name ) { - assert_true( isset( $tools[ $tool_name ] ), "tool source declares {$tool_name}" ); - assert_true( 'wp-codebox/sandbox-workspace' === $tools[ $tool_name ]['runtime']['executor_target'], "{$tool_name} routes to sandbox executor target" ); - assert_true( 'client' === $tools[ $tool_name ]['executor'], "{$tool_name} is a client-executed tool" ); + $tools = WP_Codebox_Sandbox_Workspace_Executor::tool_declarations_for_enabled_tools( + array( 'enabled_tools' => array( 'workspace_read', 'workspace_edit', 'workspace_worktree_add' ) ) + ); + assert_true( array( 'workspace_read', 'workspace_edit' ) === array_keys( $tools ), 'only enabled canonical workspace tools are declared' ); + foreach ( $tools as $tool_name => $tool ) { + assert_true( $tool_name === $tool['name'], "{$tool_name} has a canonical name" ); + assert_true( 'wp-codebox/sandbox-workspace' === $tool['runtime']['executor_target'], "{$tool_name} routes to sandbox executor target" ); + assert_true( isset( $tool['parameters']['properties'] ), "{$tool_name} includes a parameter schema" ); } // Cleanup. diff --git a/tests/execute-native-agent-task-lifecycle.test.mjs b/tests/execute-native-agent-task-lifecycle.test.mjs index ac27cac2b..ceeb5f679 100644 --- a/tests/execute-native-agent-task-lifecycle.test.mjs +++ b/tests/execute-native-agent-task-lifecycle.test.mjs @@ -99,11 +99,11 @@ await writeFile(output, JSON.stringify(result)) `) const publisher = join(temp, "publisher.mjs") + const publisherOrderPath = join(workspace, ".codebox", "order") await writeFile(publisher, ` import { appendFile } from "node:fs/promises" -import { join } from "node:path" -export async function publishRunnerWorkspace({ workspace }) { - await appendFile(join(workspace, ".codebox", "order"), "publish\\n") +export async function publishRunnerWorkspace({ testHook }) { + await appendFile(testHook.orderPath, "publish\\n") return { schema: "wp-codebox/runner-workspace-publication-result/v1", success: true, @@ -133,8 +133,9 @@ process.stdout.write(JSON.stringify({ AGENT_TASK_WORKSPACE: workspace, WP_CODEBOX_WORKFLOW_ROOT: root, WP_CODEBOX_CLI_PATH: fakeCli, - WP_CODEBOX_TEST_SKIP_MATERIALIZATION: "true", - WP_CODEBOX_TEST_PUBLISHER_MODULE: publisher, + WP_CODEBOX_TEST_SKIP_MATERIALIZATION: "true", + WP_CODEBOX_TEST_PUBLISHER_MODULE: publisher, + WP_CODEBOX_TEST_PUBLISHER_HOOK: JSON.stringify({ orderPath: publisherOrderPath }), GITHUB_TOKEN: "token", EXPLICIT_ACCESS_TOKEN_CONFIGURED: "true", EXTERNAL_PACKAGE_SOURCE_POLICY: JSON.stringify({ diff --git a/tests/runner-workspace-apply.test.ts b/tests/runner-workspace-apply.test.ts index d13c2bdd3..2f0209ecb 100644 --- a/tests/runner-workspace-apply.test.ts +++ b/tests/runner-workspace-apply.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import { execFile } from "node:child_process" -import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises" +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { promisify } from "node:util" @@ -77,4 +77,14 @@ const files = [{ path: "/workspace/README.md", relativePath: "README.md", status assert.equal(await readFile(join(input.workspace, "README.md"), "utf8"), "after\n") } +{ + const input = await fixture(patch, files) + const patchPath = join(input.artifacts, "files", "patch.diff") + const target = join(input.artifacts, "files", "patch-source.diff") + await writeFile(target, patch) + await rm(patchPath) + await symlink(target, patchPath) + await assert.rejects(() => applyRunnerWorkspacePatch({ artifactRoot: input.artifacts, artifactRefs: input.refs, workspaceRoot: input.workspace, writablePaths: ["README.md"] }), /bounded regular file/) +} + console.log("runner workspace apply ok") diff --git a/tests/runner-workspace-publisher.test.mjs b/tests/runner-workspace-publisher.test.mjs index 30204d367..2e225d010 100644 --- a/tests/runner-workspace-publisher.test.mjs +++ b/tests/runner-workspace-publisher.test.mjs @@ -8,7 +8,7 @@ function response(status, body) { return { ok: status >= 200 && status < 300, st function fetchMock(existing = false) { const calls = [] return { calls, fetch: async (url, init) => { - calls.push([url, init.method]) + calls.push([url, init.method, init.body ? JSON.parse(init.body) : undefined]) const parsed = new URL(url) const path = parsed.pathname.replace("/repos/owner/repo", "") if (path === "/git/ref/heads/main") return response(200, { object: { sha: "base" } }) @@ -36,6 +36,9 @@ function fetchMock(existing = false) { const result = await publishRunnerWorkspace({ request, changedFiles: ["README.md"], publicationFiles, token: "secret", fetchImpl: mock.fetch }) assert.equal(result.pull_request.reused, true) assert(mock.calls.some(([, method]) => method === "PATCH")) + const treeRequest = mock.calls.find(([url, method]) => new URL(url).pathname.endsWith("/git/trees") && method === "POST") + assert.equal(treeRequest?.[2]?.base_tree, "prior-tree", "existing branch publication must extend its current tree") + assert.deepEqual(treeRequest?.[2]?.tree, [{ path: "README.md", mode: "100644", type: "blob", sha: "blob" }], "the prior branch tree remains the base while only approved changed files are replaced") } await assert.rejects(() => publishRunnerWorkspace({ request: { ...request, runner_workspace: { ...request.runner_workspace, repo: "other/repo" } }, changedFiles: ["README.md"], publicationFiles, token: "secret", fetchImpl: fetchMock().fetch }), /not authorized/) await assert.rejects(() => publishRunnerWorkspace({ request, changedFiles: ["README.md"], publicationFiles, token: "", fetchImpl: fetchMock().fetch }), /No GitHub token/) diff --git a/tests/runtime-sources-playground-integration.test.ts b/tests/runtime-sources-playground-integration.test.ts index 639a8fdd9..fa33833a7 100644 --- a/tests/runtime-sources-playground-integration.test.ts +++ b/tests/runtime-sources-playground-integration.test.ts @@ -8,6 +8,7 @@ import { promisify } from "node:util" import { materializeRuntimeSources, parseExternalPackageSourcePolicy, validateRuntimeSourceModel } from "../.github/scripts/run-agent-task/materialize-external-native-package.mjs" import { buildAgentTaskRecipe } from "../packages/runtime-core/src/agent-task-recipe.js" import { normalizeTaskInput } from "../packages/runtime-core/src/task-input.js" +import { sandboxToolPolicyFromAllowedTools } from "../packages/runtime-core/src/sandbox-tool-policy.js" const execFileAsync = promisify(execFile) @@ -24,8 +25,9 @@ try { const materialized = await materializeRuntimeSources(fixture.runtime_sources, { policy, tempRoot: root, forbiddenRoots: [join(root, "artifacts")] }) const privatePackage = join(materialized.root, "flat-runtime-agent.agent.json") const interceptorPlugin = join(materialized.root, "openai-interceptor") + const workspaceSeed = join(materialized.root, "workspace-seed") const packageInstruction = "PACKAGE SYSTEM INSTRUCTION: selected imported agent" - const packageTool = "workspace_read" + const packageTools = ["workspace_read", "workspace_edit"] const genericSandboxInstruction = "Default sandbox agent" const genericSandboxTool = "deny-all" await mkdir(interceptorPlugin, { recursive: true }) @@ -42,9 +44,17 @@ add_filter( 'pre_http_request', static function( $preempt, $args, $url ) { $requests = is_array( $requests ) ? $requests : array(); $requests[] = array( 'url' => $url, 'body' => $args['body'] ?? null ); file_put_contents( $capture, wp_json_encode( $requests ) ); - $body = str_ends_with( $url, '/models' ) - ? array( 'object' => 'list', 'data' => array( array( 'id' => 'gpt-5.5', 'object' => 'model', 'created' => 0, 'owned_by' => 'openai' ) ) ) - : array( 'id' => 'resp-fixture', 'object' => 'response', 'status' => 'completed', 'output' => array( array( 'id' => 'msg-fixture', 'type' => 'message', 'status' => 'completed', 'role' => 'assistant', 'content' => array( array( 'type' => 'output_text', 'text' => 'Mocked runtime-package reply', 'annotations' => array() ) ) ) ), 'usage' => array( 'input_tokens' => 1, 'output_tokens' => 1, 'total_tokens' => 2 ) ); + if ( str_ends_with( $url, '/models' ) ) { + $body = array( 'object' => 'list', 'data' => array( array( 'id' => 'gpt-5.5', 'object' => 'model', 'created' => 0, 'owned_by' => 'openai' ) ) ); + } else { + $turn = count( array_filter( $requests, static fn( $request ) => str_ends_with( (string) $request['url'], '/responses' ) ) ); + $output = 1 === $turn + ? array( array( 'id' => 'fc-read', 'type' => 'function_call', 'call_id' => 'call-read', 'name' => 'workspace_read', 'arguments' => wp_json_encode( array( 'path' => 'README.md' ) ) ) ) + : ( 2 === $turn + ? array( array( 'id' => 'fc-edit', 'type' => 'function_call', 'call_id' => 'call-edit', 'name' => 'workspace_edit', 'arguments' => wp_json_encode( array( 'path' => 'README.md', 'old_string' => 'before', 'new_string' => 'after' ) ) ) ) + : array( array( 'id' => 'msg-fixture', 'type' => 'message', 'status' => 'completed', 'role' => 'assistant', 'content' => array( array( 'type' => 'output_text', 'text' => 'Workspace updated.', 'annotations' => array() ) ) ) ) ); + $body = array( 'id' => 'resp-fixture-' . $turn, 'object' => 'response', 'status' => 'completed', 'output' => $output, 'usage' => array( 'input_tokens' => 1, 'output_tokens' => 1, 'total_tokens' => 2 ) ); + } return array( 'headers' => array( 'content-type' => 'application/json' ), 'body' => wp_json_encode( $body ), 'response' => array( 'code' => 200, 'message' => 'OK' ), 'cookies' => array(), 'filename' => null ); }, 1000, 3 ); `) @@ -57,11 +67,13 @@ add_filter( 'pre_http_request', static function( $preempt, $args, $url ) { description: "Playground imported-agent selection fixture.", agent_config: { instructions: packageInstruction, - enabled_tools: [packageTool], + enabled_tools: packageTools, modes: ["chat"], }, }, }) + "\n") + await mkdir(workspaceSeed, { recursive: true }) + await writeFile(join(workspaceSeed, "README.md"), "before\n") const lowered = materialized.lowered.reduce((input: Record, source: Record) => { for (const [key, entries] of Object.entries(source)) input[key] = [...(input[key] ?? []), ...entries] return input @@ -107,6 +119,8 @@ echo wp_json_encode( array( 'imported_slug' => $imports[0]['agent_slug'] ?? '', model: "gpt-5.5", runtime_env: { OPENAI_API_KEY: "dummy-key" }, extra_plugins: [{ source: interceptorPlugin, slug: "openai-runtime-package-interceptor", pluginFile: "openai-runtime-package-interceptor/openai-interceptor.php", activate: true, loadAs: "plugin" }], + workspaces: [{ target: "/workspace", mode: "readwrite", seed: { type: "directory", source: workspaceSeed } }], + sandbox_tool_policy: sandboxToolPolicyFromAllowedTools(["workspace.read", "workspace.edit"], { source: "runtime-sources-playground-integration" }), ...lowered, runtime_task: { ability: "wp-codebox/run-runtime-package", @@ -114,7 +128,7 @@ echo wp_json_encode( array( 'imported_slug' => $imports[0]['agent_slug'] ?? '', schema: "wp-codebox/runtime-package-task/v1", package: { slug: "flat-runtime-agent", source: "public-external-package", external_source: { digest: packageDigest }, bootstrap: { encoding: "base64", bytes: packageBytes.toString("base64"), digest: packageDigest } }, workflow: { id: "agents/chat" }, - input: { prompt: "Return the mocked response.", provider: "openai", model: "gpt-5.5" }, + input: { prompt: "Read README.md, then replace before with after.", provider: "openai", model: "gpt-5.5", writable_paths: ["README.md"], runner_workspace_policy: { writable_paths: ["README.md"] } }, artifact_declarations: [], required_artifacts: [], }, @@ -123,7 +137,7 @@ echo wp_json_encode( array( 'imported_slug' => $imports[0]['agent_slug'] ?? '', const runtimeTaskArg = providerRecipe.workflow.steps[0].args?.find((arg) => arg.startsWith("runtime-task-json=")) assert.ok(runtimeTaskArg, "native runtime package task must be part of the Playground closure") const runtimeTask = JSON.parse(runtimeTaskArg.slice("runtime-task-json=".length)) - assert.deepEqual(runtimeTask.input.input, { prompt: "Return the mocked response.", provider: "openai", model: "gpt-5.5" }) + assert.deepEqual(runtimeTask.input.input, { prompt: "Read README.md, then replace before with after.", provider: "openai", model: "gpt-5.5", writable_paths: ["README.md"], runner_workspace_policy: { writable_paths: ["README.md"] } }) assert.throws(() => validateRuntimeSourceModel({ provider: "undeclared", name: "gpt-5.5" }, materialized.descriptors.map((descriptor: Record) => ({ ...descriptor, metadata: { providers: descriptor.providers } }))), /not declared/, "an undeclared provider must be rejected before a chat turn is constructed") const readCapturedRequests = { command: "wordpress.run-php", args: ["code=echo is_readable( WP_CONTENT_DIR . '/openai-runtime-package-requests.json' ) ? file_get_contents( WP_CONTENT_DIR . '/openai-runtime-package-requests.json' ) : '[]';"] } @@ -145,20 +159,40 @@ echo wp_json_encode( array( 'imported_slug' => $imports[0]['agent_slug'] ?? '', } } - const runtimePackageOutput = await runRuntimePackage() + const runtimePackageOutput = await runRuntimePackage() const runtimeExecution = runtimePackageOutput.executions?.find((execution: { stdout?: string }) => execution.stdout?.includes("agent_runtime")) - const runtime = JSON.parse(JSON.parse(runtimeExecution?.stdout ?? "{}").output ?? "{}") - assert.equal(runtime.agent_runtime?.success, true, JSON.stringify(runtimePackageOutput.executions?.map((execution: { command?: string, stdout?: string }) => ({ command: execution.command, stdout: execution.stdout })))) - assert.equal(runtime.agent_runtime.result.package.slug, "flat-runtime-agent", "the generated runtime must execute the imported agent identity") - const requests = JSON.parse(runtimePackageOutput.executions?.filter((execution: { command?: string }) => execution.command === "wordpress.run-php").at(-1)?.stdout ?? "[]") - const providerTurn = requests.find((request: { url: string }) => request.url.endsWith("/responses")) - assert.ok(providerTurn, "the OpenAI provider transport must execute through the local interception fixture") + const runtime = JSON.parse(JSON.parse(runtimeExecution?.stdout ?? "{}").output ?? "{}") + assert.equal(runtime.agent_runtime?.success, true, JSON.stringify(runtimePackageOutput.executions?.map((execution: { command?: string, stdout?: string }) => ({ command: execution.command, stdout: execution.stdout })))) + assert.equal(runtime.agent_runtime.result.package.slug, "flat-runtime-agent", "the generated runtime must execute the imported agent identity") + const requests = JSON.parse(runtimePackageOutput.executions?.filter((execution: { command?: string }) => execution.command === "wordpress.run-php").at(-1)?.stdout ?? "[]") + const providerTurns = requests.filter((request: { url: string }) => request.url.endsWith("/responses")) + assert.equal(providerTurns.length, 3, "the intercepted provider must receive read, edit, and terminal turns") + const providerTurn = providerTurns[0] + assert.ok(providerTurn, "the OpenAI provider transport must execute through the local interception fixture") assert.equal(JSON.parse(providerTurn.body).model, "gpt-5.5", "the selected OpenAI model must reach the provider transport") assert.match(providerTurn.body, new RegExp(packageInstruction), "the selected imported agent instruction must reach the provider transport") - assert.match(providerTurn.body, new RegExp(packageTool), "the selected imported agent tool schema must reach the provider transport") - assert.doesNotMatch(providerTurn.body, new RegExp(genericSandboxInstruction), "the generic sandbox instruction must not reach an imported-agent model request") - assert.doesNotMatch(providerTurn.body, new RegExp(genericSandboxTool), "the generic sandbox tool must not reach an imported-agent model request") - assert.match(providerTurn.url, /^https:\/\/api\.openai\.com\/v1\/responses$/, "the selected OpenAI provider must reach its provider transport") + for (const packageTool of packageTools) assert.match(providerTurn.body, new RegExp(packageTool), "the selected imported agent tool schema must reach the provider transport") + assert.doesNotMatch(providerTurn.body, new RegExp(genericSandboxInstruction), "the generic sandbox instruction must not reach an imported-agent model request") + assert.doesNotMatch(providerTurn.body, new RegExp(genericSandboxTool), "the generic sandbox tool must not reach an imported-agent model request") + assert.match(providerTurn.url, /^https:\/\/api\.openai\.com\/v1\/responses$/, "the selected OpenAI provider must reach its provider transport") + const readTurn = JSON.parse(providerTurns[1].body) + const editTurn = JSON.parse(providerTurns[2].body) + const readOutput = readTurn.input.find((item: { type?: string, call_id?: string }) => item.type === "function_call_output" && item.call_id === "call-read") + const editOutput = editTurn.input.find((item: { type?: string, call_id?: string }) => item.type === "function_call_output" && item.call_id === "call-edit") + const readResult = JSON.parse(readOutput.output) + assert.equal(readResult.success, true, "the second provider request must contain a successful sandbox read result") + assert.equal(readResult.tool_name, "workspace_read") + assert.deepEqual(readResult.result, { success: true, path: "README.md", content: "before\n", size: 7, lines_read: 2, offset: 1 }) + assert.deepEqual(readResult.runtime, { executor_target: "wp-codebox/sandbox-workspace", capability: "workspace.files.read", side_effects: [], side_effect_boundary: "wp-codebox-sandbox" }) + assert.equal(JSON.parse(editOutput.output).success, true, "the third provider request must receive the sandbox edit result") + assert.equal(JSON.parse(editOutput.output).result.replacements, 1, `the sandbox executor must edit the seeded README: ${editOutput.output}`) + assert.equal(JSON.parse(editOutput.output).runtime.executor_target, "wp-codebox/sandbox-workspace") + assert.doesNotMatch(providerTurn.body, /workspace_worktree_add|workspace_worktree_remove|workspace_primary_/i, "parent workspace mutation tools must not be exposed to the provider") + assert.equal(runtimePackageOutput.agentResult?.changedFiles?.count, 1, "the runtime must capture the changed sandbox file") + assert.ok(runtimePackageOutput.agentResult?.patch?.bytes > 0, "the runtime must capture a canonical patch") + const artifactDirectory = runtimePackageOutput.agentResult?.artifacts?.directory + assert.equal(JSON.parse(await readFile(join(artifactDirectory, "files", "changed-files.json"), "utf8")).files[0].relativePath, "README.md") + assert.match(await readFile(join(artifactDirectory, "files", "patch.diff"), "utf8"), /-before\n\+after/) for (const [label, mutateTask] of [ ["package", (task: Record) => { task.input.package.slug = "spoofed-agent" }], From 882ccd8c68e98cbfa80c4e066f6ae30776a61997 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 14 Jul 2026 05:51:31 -0400 Subject: [PATCH 4/4] Fix runner workspace publication parent --- .../run-agent-task/runner-workspace-publisher.mjs | 2 +- docs/agent-task-reusable-workflow.md | 10 +++++----- tests/agent-task-reusable-workflow.test.ts | 2 +- tests/runner-workspace-publisher.test.mjs | 4 ++++ 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/scripts/run-agent-task/runner-workspace-publisher.mjs b/.github/scripts/run-agent-task/runner-workspace-publisher.mjs index 126155880..586c99bd1 100644 --- a/.github/scripts/run-agent-task/runner-workspace-publisher.mjs +++ b/.github/scripts/run-agent-task/runner-workspace-publisher.mjs @@ -41,7 +41,7 @@ export async function publishRunnerWorkspace({ request, changedFiles, publicatio 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: [parent] }) + 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)}`) diff --git a/docs/agent-task-reusable-workflow.md b/docs/agent-task-reusable-workflow.md index 5380edc1b..3f6024442 100644 --- a/docs/agent-task-reusable-workflow.md +++ b/docs/agent-task-reusable-workflow.md @@ -106,11 +106,11 @@ package file is mounted into or visible from the agent workspace. The repository's native-loop and PHP runtime-package tests execute generated PHP with narrow WordPress and native agent-registry shims. The non-skippable -Playground integration additionally boots the agent registry and OpenAI -provider path with a local HTTP interceptor. Its deterministic 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. +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 diff --git a/tests/agent-task-reusable-workflow.test.ts b/tests/agent-task-reusable-workflow.test.ts index 2315667cb..7ba385b37 100644 --- a/tests/agent-task-reusable-workflow.test.ts +++ b/tests/agent-task-reusable-workflow.test.ts @@ -95,7 +95,7 @@ assert.match(docs, /wp-codebox\/reusable-workflow-interface\/v1/) assert.match(docs, /run-agent-task-reusable-workflow-interface\.v1\.json/) assert.match(docs, /WP_CODEBOX_DIR/) assert.match(docs, /Runtime Coverage/) -assert.match(docs, /not a WordPress Playground end-to-end test/) +assert.match(docs, /deterministic WordPress Playground end-to-end test/) assert.doesNotMatch(docs.slice(0, docs.indexOf("## Runtime Coverage")), /docs-agent|wp-codebox\/docs-agent-runner-recipe\/v1|recipe_path|recipe_json|wp_codebox_ref|datamachine|data machine|data-machine|agents api|sandbox mounts|ability ids|provider internals|homeboy|require_app_token/i) const tmp = await mkdtemp(join(tmpdir(), "wp-codebox-agent-task-workflow-")) diff --git a/tests/runner-workspace-publisher.test.mjs b/tests/runner-workspace-publisher.test.mjs index 2e225d010..e01e2686d 100644 --- a/tests/runner-workspace-publisher.test.mjs +++ b/tests/runner-workspace-publisher.test.mjs @@ -30,6 +30,8 @@ function fetchMock(existing = false) { const result = await publishRunnerWorkspace({ request, changedFiles: ["README.md"], publicationFiles, token: "secret", fetchImpl: mock.fetch }) assert.equal(result.pull_request.opened, true) assert(mock.calls.some(([, method]) => method === "POST")) + const commitRequest = mock.calls.find(([url, method]) => new URL(url).pathname.endsWith("/git/commits") && method === "POST") + assert.deepEqual(commitRequest?.[2]?.parents, ["base"], "new branch commits must use the base branch head as their parent") } { const mock = fetchMock(true) @@ -39,6 +41,8 @@ function fetchMock(existing = false) { const treeRequest = mock.calls.find(([url, method]) => new URL(url).pathname.endsWith("/git/trees") && method === "POST") assert.equal(treeRequest?.[2]?.base_tree, "prior-tree", "existing branch publication must extend its current tree") assert.deepEqual(treeRequest?.[2]?.tree, [{ path: "README.md", mode: "100644", type: "blob", sha: "blob" }], "the prior branch tree remains the base while only approved changed files are replaced") + const commitRequest = mock.calls.find(([url, method]) => new URL(url).pathname.endsWith("/git/commits") && method === "POST") + assert.deepEqual(commitRequest?.[2]?.parents, ["old"], "existing branch commits must use the existing branch head as their parent") } await assert.rejects(() => publishRunnerWorkspace({ request: { ...request, runner_workspace: { ...request.runner_workspace, repo: "other/repo" } }, changedFiles: ["README.md"], publicationFiles, token: "secret", fetchImpl: fetchMock().fetch }), /not authorized/) await assert.rejects(() => publishRunnerWorkspace({ request, changedFiles: ["README.md"], publicationFiles, token: "", fetchImpl: fetchMock().fetch }), /No GitHub token/)