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 41023496..162f7748 100644 --- a/.github/scripts/run-agent-task/execute-native-agent-task.mjs +++ b/.github/scripts/run-agent-task/execute-native-agent-task.mjs @@ -10,7 +10,7 @@ import { readNativeResult } from "./native-result-file.mjs" import { assertNoRuntimeSourcePaths, sanitizeRuntimeSourceJson, sanitizeRuntimeSourceText, sanitizeRuntimeSourceValue } from "./runtime-source-sanitizer.mjs" import { publishRunnerWorkspace } from "./runner-workspace-publisher.mjs" import { createRunnerWorkspaceSeedSnapshot, RUNNER_WORKSPACE_SEED_EXCLUDES } from "./runner-workspace-seed-snapshot.mjs" -import { createTrustedArtifactSnapshot } from "./trusted-artifact-snapshot.mjs" +import { createTrustedArtifactApplyChannel, trustedArtifactApplyRefs } from "./trusted-artifact-snapshot.mjs" const requestPath = process.env.AGENT_TASK_REQUEST_PATH || ".codebox/agent-task-request.json" const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd()) @@ -24,6 +24,7 @@ let privateRuntimeSourceRoot = "" let privateRuntimeSourceRootForSanitization = "" let runnerWorkspaceSeedSnapshot let reviewerEvidence +let trustedApplyArtifactRoot const SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 } let materializedSourceCleanup @@ -37,6 +38,10 @@ function claimMaterializedSourcePaths() { paths.push(privateRuntimeSourceRoot) privateRuntimeSourceRoot = "" } + if (trustedApplyArtifactRoot) { + paths.push(trustedApplyArtifactRoot) + trustedApplyArtifactRoot = "" + } return paths } // Single idempotent cleanup coordinator for the private runtime source @@ -152,8 +157,8 @@ function safeEnvironment(extra = {}) { return { PATH: process.env.PATH || "", HOME: process.env.HOME || "", CI: process.env.CI || "true", ...extra } } -function agentEnvironment() { - return safeEnvironment(Object.fromEntries(["OPENAI_API_KEY", "MODEL_PROVIDER_SECRET_1", "MODEL_PROVIDER_SECRET_2", "MODEL_PROVIDER_SECRET_3", "MODEL_PROVIDER_SECRET_4", "MODEL_PROVIDER_SECRET_5", "GITHUB_TOKEN"].map((name) => [name, process.env[name]]).filter(([, value]) => value))) +function agentEnvironment(extra = {}) { + return safeEnvironment({ ...Object.fromEntries(["OPENAI_API_KEY", "MODEL_PROVIDER_SECRET_1", "MODEL_PROVIDER_SECRET_2", "MODEL_PROVIDER_SECRET_3", "MODEL_PROVIDER_SECRET_4", "MODEL_PROVIDER_SECRET_5", "GITHUB_TOKEN"].map((name) => [name, process.env[name]]).filter(([, value]) => value)), ...extra }) } function command(command, args, cwd, env = safeEnvironment()) { @@ -605,8 +610,9 @@ const taskInput = { await writeFile(executionInputPath, `${JSON.stringify(taskInput, null, 2)}\n`) let execution = { code: 0, stdout: "", stderr: "", stdout_truncated: false, stderr_truncated: false } - if (request.run_agent && !request.dry_run) { - execution = await command("node", [codeboxCliPath, "agent-task-run", "--input-file", executionInputPath, "--result-file", nativeResultPath], workspace, agentEnvironment()) +if (request.run_agent && !request.dry_run) { + if (request.runner_workspace?.enabled) trustedApplyArtifactRoot = await createTrustedArtifactApplyChannel() + execution = await command("node", [codeboxCliPath, "agent-task-run", "--input-file", executionInputPath, "--result-file", nativeResultPath], workspace, agentEnvironment(trustedApplyArtifactRoot ? { WP_CODEBOX_TRUSTED_APPLY_ARTIFACT_ROOT: trustedApplyArtifactRoot } : {})) } // Public package bytes are embedded in the runtime recipe and consumed only by @@ -638,16 +644,16 @@ if (execution.code === 0 && runtimeResult.success === true && request.runner_wor 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") - const trustedArtifacts = await createTrustedArtifactSnapshot(artifactsPath, refs) - try { - workspaceApply = await runnerWorkspaceCore.applyRunnerWorkspacePatch({ artifactRoot: trustedArtifacts.root, artifactRefs: trustedArtifacts.refs, workspaceRoot: workspace, writablePaths, seedIdentity: runnerWorkspaceSeedSnapshot?.provenance.identity }) - } finally { - await rm(trustedArtifacts.root, { recursive: true, force: true }) - } + const trustedArtifacts = await trustedArtifactApplyRefs(trustedApplyArtifactRoot, refs) + workspaceApply = await runnerWorkspaceCore.applyRunnerWorkspacePatch({ artifactRoot: trustedArtifacts.root, artifactRefs: trustedArtifacts.refs, workspaceRoot: workspace, writablePaths, seedIdentity: runnerWorkspaceSeedSnapshot?.provenance.identity }) } catch (error) { downstreamFailure = { stage: "apply", message: bounded(error instanceof Error ? error.message : String(error), MAX_WORKFLOW_OUTPUT_BYTES), ...(error?.evidence ? { evidence: error.evidence } : {}) } } } +if (trustedApplyArtifactRoot) { + await rm(trustedApplyArtifactRoot, { recursive: true, force: true }) + trustedApplyArtifactRoot = "" +} runtimeResult = sanitizeRuntimeSourceValue(runtimeResult, runtimeSourceOutputRoots) privateRuntimeSourceRootForSanitization = runtimeSourceOutputRoots assertNoRuntimeSourcePaths(runtimeResult, privateRuntimeSourceRootForSanitization) diff --git a/.github/scripts/run-agent-task/trusted-artifact-snapshot.mjs b/.github/scripts/run-agent-task/trusted-artifact-snapshot.mjs index 0df2700c..57bdd8a2 100644 --- a/.github/scripts/run-agent-task/trusted-artifact-snapshot.mjs +++ b/.github/scripts/run-agent-task/trusted-artifact-snapshot.mjs @@ -1,4 +1,5 @@ -import { lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises" +import { createHash } from "node:crypto" +import { lstat, mkdtemp, readFile, realpath } from "node:fs/promises" import { tmpdir } from "node:os" import { isAbsolute, join, relative, resolve } from "node:path" @@ -10,29 +11,30 @@ function underRoot(root, candidate) { } /** - * Copies selected regular artifacts to a private temporary root before a later - * durable-artifact transformation. Callers retain their artifact references, - * with paths rewritten relative to the trusted copy. + * Opens a workflow-owned private root that the runtime may use before durable + * artifact redaction. This root is never part of the artifact bundle. */ -export async function createTrustedArtifactSnapshot(artifactRoot, refs, maxFileBytes = MAX_SNAPSHOT_FILE_BYTES) { - const root = await realpath(resolve(artifactRoot)) - const snapshotRoot = await mkdtemp(join(tmpdir(), "wp-codebox-trusted-artifacts-")) - try { - const copiedRefs = await Promise.all(refs.map(async (ref) => { - const requested = isAbsolute(ref.path) ? resolve(ref.path) : resolve(root, ref.path) - const stat = await lstat(requested) - if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxFileBytes) throw new Error("Trusted artifact snapshot requires a bounded regular file.") - const source = await realpath(requested) - if (!underRoot(root, source)) throw new Error("Trusted artifact snapshot escapes the artifact root.") - const path = relative(root, source).replaceAll("\\", "/") - const destination = join(snapshotRoot, path) - await mkdir(resolve(destination, ".."), { recursive: true }) - await writeFile(destination, await readFile(source)) - return { ...ref, path } - })) - return { root: snapshotRoot, refs: copiedRefs } - } catch (error) { - await rm(snapshotRoot, { recursive: true, force: true }) - throw error +export async function createTrustedArtifactApplyChannel() { + return mkdtemp(join(tmpdir(), "wp-codebox-trusted-apply-")) +} + +/** Validates and projects only the two canonical private apply artifacts. */ +export async function trustedArtifactApplyRefs(root, refs, maxFileBytes = MAX_SNAPSHOT_FILE_BYTES) { + const channelRoot = await realpath(resolve(root)) + const expectedPaths = { + "codebox-patch": "files/patch.diff", + "codebox-changed-files": "files/changed-files.json", } + const copiedRefs = await Promise.all(refs.map(async (ref) => { + const path = expectedPaths[ref.kind] + if (!path) throw new Error("Trusted apply artifacts must use canonical artifact kinds.") + const requested = join(channelRoot, path) + const stat = await lstat(requested) + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxFileBytes) throw new Error("Trusted artifact snapshot requires a bounded regular file.") + const source = await realpath(requested) + if (!underRoot(channelRoot, source)) throw new Error("Trusted artifact snapshot escapes the artifact root.") + const bytes = await readFile(source) + return { ...ref, path, ...(ref.kind === "codebox-patch" ? { sha256: createHash("sha256").update(bytes).digest("hex") } : {}) } + })) + return { root: channelRoot, refs: copiedRefs } } diff --git a/package.json b/package.json index 1e931b09..fdb00c73 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,7 @@ "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:trusted-apply-artifact-channel": "tsx tests/trusted-apply-artifact-channel.integration.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:native-agent-task-interruption": "node tests/execute-native-agent-task-interruption.test.mjs", diff --git a/packages/runtime-playground/src/artifact-bundle-builder.ts b/packages/runtime-playground/src/artifact-bundle-builder.ts index 5afbb872..bb3bcd7d 100644 --- a/packages/runtime-playground/src/artifact-bundle-builder.ts +++ b/packages/runtime-playground/src/artifact-bundle-builder.ts @@ -45,6 +45,7 @@ import { import { normalizeJsonValue, stripUndefined } from "@automattic/wp-codebox-core/internals" import type { BrowserArtifact } from "./browser-artifacts.js" import { firstCommandWordPressAdminAuthRequirement } from "./command-auth-requirements.js" +import { writeTrustedApplyArtifacts } from "./trusted-apply-artifact-channel.js" import { ArtifactRedactor, artifactContentDigest, @@ -160,6 +161,7 @@ export class ArtifactBundleBuilder { const replayExportPackageFiles = replayExportPackageManifestFiles(source.artifactRoot, source.commands) const capturedMounts = await source.captureMountedFiles(filesDirectory, redactor) const { mountDiffs, changedFiles, patch, diagnostics: mountDiffDiagnostics } = await source.captureMountDiffs(filesDirectory, redactor) + await writeTrustedApplyArtifacts(changedFiles, patch) const changedFilesJson = redactor.redact(CHANGED_FILES_ARTIFACT_PATH, `${JSON.stringify(changedFiles, null, 2)}\n`) const redactedPatch = redactor.redact("files/patch.diff", patch) const patchContentDigest = artifactContentDigest(changedFilesJson, redactedPatch) diff --git a/packages/runtime-playground/src/trusted-apply-artifact-channel.ts b/packages/runtime-playground/src/trusted-apply-artifact-channel.ts new file mode 100644 index 00000000..695e2414 --- /dev/null +++ b/packages/runtime-playground/src/trusted-apply-artifact-channel.ts @@ -0,0 +1,30 @@ +import { lstat, mkdir, writeFile } from "node:fs/promises" +import { join, resolve } from "node:path" + +const CHANNEL_ROOT_ENV = "WP_CODEBOX_TRUSTED_APPLY_ARTIFACT_ROOT" +const MAX_APPLY_ARTIFACT_BYTES = 5 * 1024 * 1024 + +/** + * The workflow creates this private temporary root. It is deliberately outside + * the durable bundle so canonical apply bytes never enter manifests or uploads. + */ +export async function writeTrustedApplyArtifacts(changedFiles: unknown, patch: string): Promise { + const root = process.env[CHANNEL_ROOT_ENV] + if (!root) return + + const changedFilesContents = `${JSON.stringify(changedFiles, null, 2)}\n` + if (Buffer.byteLength(changedFilesContents) > MAX_APPLY_ARTIFACT_BYTES || Buffer.byteLength(patch) > MAX_APPLY_ARTIFACT_BYTES) { + throw new Error("Trusted apply artifacts must be bounded.") + } + + const rootStat = await lstat(resolve(root)) + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error("Trusted apply artifact root must be a directory.") + const files = join(root, "files") + await mkdir(files, { recursive: true, mode: 0o700 }) + const filesStat = await lstat(files) + if (!filesStat.isDirectory() || filesStat.isSymbolicLink()) throw new Error("Trusted apply artifact files directory must be a directory.") + await Promise.all([ + writeFile(join(files, "changed-files.json"), changedFilesContents, { mode: 0o600 }), + writeFile(join(files, "patch.diff"), patch, { mode: 0o600 }), + ]) +} diff --git a/tests/execute-native-agent-task-lifecycle.test.mjs b/tests/execute-native-agent-task-lifecycle.test.mjs index a9857a19..9588e4ba 100644 --- a/tests/execute-native-agent-task-lifecycle.test.mjs +++ b/tests/execute-native-agent-task-lifecycle.test.mjs @@ -74,12 +74,21 @@ const output = process.argv[process.argv.indexOf("--result-file") + 1] const input = JSON.parse(await readFile(process.argv[process.argv.indexOf("--input-file") + 1], "utf8")) const root = join(process.cwd(), ".codebox", "agent-task-artifacts", "files") await mkdir(root, { recursive: true }) +const trustedRoot = process.env.WP_CODEBOX_TRUSTED_APPLY_ARTIFACT_ROOT +if (trustedRoot) await mkdir(join(trustedRoot, "files"), { recursive: true }) const patch = ${JSON.stringify(noOp ? "" : applyReject ? "--- a/README.md\n+++ b/README.md\n@@ -1,2 +1,2 @@\n OPENAI_API_KEY\n-not-before\n+after\n" : "--- a/README.md\n+++ b/README.md\n@@ -1,2 +1,2 @@\n OPENAI_API_KEY\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" }], })) +if (trustedRoot) { + await writeFile(join(trustedRoot, "files", "patch.diff"), patch) + await writeFile(join(trustedRoot, "files", "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" }], + })) + } ${mismatch ? "await writeFile(join(process.cwd(), \"README.md\"), \"diverged\\n\")" : ""} await writeFile(join(process.cwd(), ".codebox", "order"), "runtime\\n") const summary = { @@ -169,7 +178,7 @@ process.stdout.write(JSON.stringify({ } const success = await run() -assert.equal(success.code, 0, success.stderr) +assert.equal(success.code, 0, `${success.stderr}\n${JSON.stringify(success.result)}`) assert.equal(await readFile(join(success.workspace, "README.md"), "utf8"), "OPENAI_API_KEY\nafter\n") const durablePatch = await readFile(join(success.workspace, ".codebox", "agent-task-artifacts", "files", "patch.diff"), "utf8") assert(!durablePatch.includes("OPENAI_API_KEY"), "durable patch artifacts redact configured secret values") diff --git a/tests/trusted-apply-artifact-channel.integration.test.ts b/tests/trusted-apply-artifact-channel.integration.test.ts new file mode 100644 index 00000000..bc29468f --- /dev/null +++ b/tests/trusted-apply-artifact-channel.integration.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict" +import { execFile } from "node:child_process" +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { promisify } from "node:util" +import { ArtifactBundleBuilder } from "../packages/runtime-playground/src/artifact-bundle-builder.js" +import { captureMountedFiles, captureMountDiffs } from "../packages/runtime-playground/src/mounted-artifact-capture.js" +import { applyRunnerWorkspacePatch } from "../packages/runtime-core/src/runner-workspace-apply.js" + +const execFileAsync = promisify(execFile) +const root = await mkdtemp(join(tmpdir(), "wp-codebox-trusted-apply-integration-")) +const artifactRoot = join(root, "artifacts") +const trustedRoot = join(root, "trusted") +const baseline = join(root, "baseline") +const mounted = join(root, "mounted") +const host = join(root, "host") +const secretName = "CONFIGURED_SECRET" +const previousChannel = process.env.WP_CODEBOX_TRUSTED_APPLY_ARTIFACT_ROOT + +try { + for (const directory of [baseline, mounted, host, trustedRoot]) { + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, "README.md"), `${secretName}\nbefore\n`) + } + await writeFile(join(mounted, "README.md"), `${secretName}\nafter\n`) + await execFileAsync("git", ["init"], { cwd: host }) + process.env.WP_CODEBOX_TRUSTED_APPLY_ARTIFACT_ROOT = trustedRoot + + const mounts = [{ source: mounted, target: "/workspace", mode: "readwrite", metadata: { baselineSource: baseline } }] + await new ArtifactBundleBuilder({ + artifactRoot, + runtimeId: "trusted-apply-test", + runtimeCreatedAt: new Date().toISOString(), + spec: { environment: { blueprint: {} }, secretEnv: { [secretName]: "secret-value" } }, + mounts, + commands: [], observations: [], snapshots: [], events: [], + info: async () => ({ id: "trusted-apply-test", backend: "playground", status: "ready", environment: { version: "latest" } }), + previewInfo: async () => undefined, + browserReviewSummary: () => undefined, + browserArtifacts: () => [], + captureMountedFiles: (filesDirectory, redactor) => captureMountedFiles(filesDirectory, mounts, redactor), + captureMountDiffs: (filesDirectory, redactor) => captureMountDiffs(artifactRoot, filesDirectory, mounts, redactor), + redactBrowserArtifacts: async () => {}, redactPluginCheckArtifacts: async () => {}, redactThemeCheckArtifacts: async () => {}, + browserManifestFiles: () => [], pluginCheckArtifactPaths: () => [], themeCheckArtifactPaths: () => [], observationManifestFiles: () => [], pluginCheckManifestFiles: () => [], themeCheckManifestFiles: () => [], + formatRuntimeLog: () => "", formatCommandsLog: () => "", recordArtifactsCollected: () => {}, + } as any).build() + + const durablePatch = await readFile(join(artifactRoot, "files", "patch.diff"), "utf8") + assert(!durablePatch.includes(secretName), "durable artifact redacts configured secret names") + assert.match(durablePatch, /\[REDACTED:configured-secret-name\]/) + await assert.rejects( + applyRunnerWorkspacePatch({ + artifactRoot, + artifactRefs: [ + { kind: "codebox-patch", path: "files/patch.diff" }, + { kind: "codebox-changed-files", path: "files/changed-files.json" }, + ], + workspaceRoot: host, + writablePaths: ["README.md"], + }), + /Host git apply failed/, + "the durable redacted patch cannot apply where configured secret context is unchanged", + ) + const trustedPatch = await readFile(join(trustedRoot, "files", "patch.diff"), "utf8") + assert.match(trustedPatch, new RegExp(secretName), "private apply bytes retain unchanged diff context") + + const applied = await applyRunnerWorkspacePatch({ + artifactRoot: trustedRoot, + artifactRefs: [ + { kind: "codebox-patch", path: "files/patch.diff" }, + { kind: "codebox-changed-files", path: "files/changed-files.json" }, + ], + workspaceRoot: host, + writablePaths: ["README.md"], + }) + assert.equal(applied.status, "applied", "the host applies pre-redaction bytes") + assert.equal(await readFile(join(host, "README.md"), "utf8"), `${secretName}\nafter\n`) +} finally { + if (previousChannel === undefined) delete process.env.WP_CODEBOX_TRUSTED_APPLY_ARTIFACT_ROOT + else process.env.WP_CODEBOX_TRUSTED_APPLY_ARTIFACT_ROOT = previousChannel + await rm(root, { recursive: true, force: true }) +} + +console.log("trusted apply artifact channel integration ok")