Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions .github/scripts/run-agent-task/execute-native-agent-task.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
50 changes: 26 additions & 24 deletions .github/scripts/run-agent-task/trusted-artifact-snapshot.mjs
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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 }
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/runtime-playground/src/artifact-bundle-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions packages/runtime-playground/src/trusted-apply-artifact-channel.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 }),
])
}
11 changes: 10 additions & 1 deletion tests/execute-native-agent-task-lifecycle.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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")
Expand Down
85 changes: 85 additions & 0 deletions tests/trusted-apply-artifact-channel.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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")
Loading