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
17 changes: 12 additions & 5 deletions .github/scripts/run-agent-task/execute-native-agent-task.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { rmSync } from "node:fs"
import { appendFile, lstat, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"
import { isUtf8 } from "node:buffer"
import { isAbsolute, join, relative, resolve } from "node:path"
import { pathToFileURL } from "node:url"
import { spawn } from "node:child_process"
Expand All @@ -9,6 +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"

const requestPath = process.env.AGENT_TASK_REQUEST_PATH || ".codebox/agent-task-request.json"
const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd())
Expand Down Expand Up @@ -474,10 +476,10 @@ async function redactArtifactFiles(directory, artifactRoot = directory) {
const path = join(directory, entry.name)
if (reviewerEvidence?.transcript && path === resolve(artifactRoot, reviewerEvidence.transcript.path)) continue
if (entry.isDirectory()) await redactArtifactFiles(path, artifactRoot)
if (entry.isFile() && path.endsWith(".json") && (await stat(path)).size <= 4 * 1024 * 1024) {
const contents = await readFile(path, "utf8").catch(() => null)
if (contents !== null) {
const sanitized = sanitizeRuntimeSourceJson(bounded(contents, 4 * 1024 * 1024), privateRuntimeSourceRootForSanitization)
if (entry.isFile() && (await stat(path)).size <= 4 * 1024 * 1024) {
const contents = await readFile(path).catch(() => null)
if (contents && !contents.includes(0) && isUtf8(contents)) {
const sanitized = sanitizeRuntimeSourceJson(redact(bounded(contents.toString("utf8"), 4 * 1024 * 1024)), privateRuntimeSourceRootForSanitization)
assertNoRuntimeSourcePaths(sanitized, privateRuntimeSourceRootForSanitization)
await writeFile(path, sanitized)
}
Expand Down Expand Up @@ -636,7 +638,12 @@ 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")
workspaceApply = await runnerWorkspaceCore.applyRunnerWorkspacePatch({ artifactRoot: artifactsPath, artifactRefs: refs, workspaceRoot: workspace, writablePaths, seedIdentity: runnerWorkspaceSeedSnapshot?.provenance.identity })
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 })
}
} catch (error) {
downstreamFailure = { stage: "apply", message: bounded(error instanceof Error ? error.message : String(error), MAX_WORKFLOW_OUTPUT_BYTES), ...(error?.evidence ? { evidence: error.evidence } : {}) }
}
Expand Down
38 changes: 38 additions & 0 deletions .github/scripts/run-agent-task/trusted-artifact-snapshot.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { isAbsolute, join, relative, resolve } from "node:path"

const MAX_SNAPSHOT_FILE_BYTES = 5 * 1024 * 1024

function underRoot(root, candidate) {
const contained = relative(root, candidate)
return candidate === root || (contained !== ".." && !contained.startsWith(`..${String.fromCharCode(47)}`) && !isAbsolute(contained))
}

/**
* 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.
*/
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
}
}
14 changes: 9 additions & 5 deletions tests/execute-native-agent-task-lifecycle.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ 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")
await writeFile(join(workspace, "README.md"), "OPENAI_API_KEY\nbefore\n")

const request = {
schema: "wp-codebox/agent-task-workflow-request/v1",
Expand Down Expand Up @@ -74,7 +74,7 @@ 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 patch = ${JSON.stringify(noOp ? "" : applyReject ? "--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-not-before\n+after\n" : "--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-before\n+after\n")}
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",
Expand Down Expand Up @@ -146,6 +146,7 @@ process.stdout.write(JSON.stringify({
WP_CODEBOX_TEST_PUBLISHER_MODULE: publisher,
WP_CODEBOX_TEST_PUBLISHER_HOOK: JSON.stringify({ orderPath: publisherOrderPath }),
GITHUB_TOKEN: "token",
MODEL_PROVIDER_SECRET_1: "OPENAI_API_KEY",
EXPLICIT_ACCESS_TOKEN_CONFIGURED: "true",
EXTERNAL_PACKAGE_SOURCE_POLICY: JSON.stringify({
version: 1,
Expand All @@ -169,7 +170,10 @@ process.stdout.write(JSON.stringify({

const success = await run()
assert.equal(success.code, 0, success.stderr)
assert.equal(await readFile(join(success.workspace, "README.md"), "utf8"), "after\n")
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")
assert.match(durablePatch, /\[REDACTED\]/)
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")
assert.equal(success.result.runtime_result.agent_runtime_diagnostics.prepared_paths.workspaces[0].target, "/workspace")
Expand Down Expand Up @@ -214,12 +218,12 @@ await execFileAsync(process.execPath, [uploader], {
AGENT_TASK_UPLOAD_PATH: join(mismatch.workspace, ".codebox", "agent-task-upload"),
},
})
assert.equal(await readFile(join(mismatch.workspace, ".codebox", "agent-task-upload", ".codebox", "agent-task-artifacts", "apply-failure", "rejected.patch"), "utf8"), "--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-before\n+after\n")
assert.equal(await readFile(join(mismatch.workspace, ".codebox", "agent-task-upload", ".codebox", "agent-task-artifacts", "apply-failure", "rejected.patch"), "utf8"), "--- a/README.md\n+++ b/README.md\n@@ -1,2 +1,2 @@\n [REDACTED]\n-before\n+after\n")
assert.match(await readFile(join(mismatch.workspace, ".codebox", "agent-task-upload", ".codebox", "agent-task-artifacts", "apply-failure", "changed-files.json"), "utf8"), /README\.md/)

const applyReject = await run("apply-reject")
assert.equal(applyReject.code, 1)
assert.equal(await readFile(join(applyReject.workspace, "README.md"), "utf8"), "before\n", "a rejected patch does not partially mutate the matching host workspace")
assert.equal(await readFile(join(applyReject.workspace, "README.md"), "utf8"), "OPENAI_API_KEY\nbefore\n", "a rejected patch does not partially mutate the matching host workspace")
assert.equal(applyReject.result.failure.stage, "apply")
assert.match(applyReject.result.failure.message, /Host git apply failed/)
assert.deepEqual(applyReject.result.failure.evidence.expected_identity, applyReject.result.failure.evidence.actual_identity, "the failure records the matching seed and host identities")
Expand Down
Loading