Skip to content

Commit f723ac1

Browse files
committed
Fix agent task workspace change normalization
1 parent 81d32d9 commit f723ac1

10 files changed

Lines changed: 349 additions & 40 deletions

File tree

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

Lines changed: 81 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { rmSync } from "node:fs"
22
import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"
3-
import { join, relative, resolve } from "node:path"
3+
import { isAbsolute, join, relative, resolve } from "node:path"
44
import { pathToFileURL } from "node:url"
55
import { spawn } from "node:child_process"
6-
import { materializeExternalNativePackage, materializeRuntimeSources, normalizeExternalPackageSource, normalizeRuntimeSources, parseExternalPackageSourcePolicy, validateRuntimeSourceModel } from "./materialize-external-native-package.mjs"
6+
import { canonicalExternalNativeAgentIdentity, materializeExternalNativePackage, materializeRuntimeSources, normalizeExternalPackageSource, normalizeRuntimeSources, parseExternalPackageSourcePolicy, sha256BytesV1, validateRuntimeSourceModel } from "./materialize-external-native-package.mjs"
77
import { readNativeResult } from "./native-result-file.mjs"
88
import { assertNoRuntimeSourcePaths, sanitizeRuntimeSourceJson, sanitizeRuntimeSourceText, sanitizeRuntimeSourceValue } from "./runtime-source-sanitizer.mjs"
99
import { publishRunnerWorkspace } from "./runner-workspace-publisher.mjs"
10+
import { createRunnerWorkspaceSeedSnapshot, RUNNER_WORKSPACE_SEED_EXCLUDES } from "./runner-workspace-seed-snapshot.mjs"
1011

1112
const requestPath = process.env.AGENT_TASK_REQUEST_PATH || ".codebox/agent-task-request.json"
1213
const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd())
@@ -18,6 +19,7 @@ const MAX_OUTPUT_CHARS = 8192
1819
const secretValues = ["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", "GH_TOKEN", "ACCESS_TOKEN", "EXTERNAL_PACKAGE_SOURCE_POLICY"].map((name) => process.env[name]).filter(Boolean)
1920
let privateRuntimeSourceRoot = ""
2021
let privateRuntimeSourceRootForSanitization = ""
22+
let runnerWorkspaceSeedSnapshot
2123
function redact(value) {
2224
if (typeof value === "string") return secretValues.reduce((output, secret) => output.split(secret).join("[REDACTED]"), value)
2325
if (Array.isArray(value)) return value.map(redact)
@@ -197,6 +199,44 @@ function requiredArtifacts(declarations) {
197199
})))
198200
}
199201

202+
async function testRuntimeFixtures(externalPackageSource) {
203+
if (process.env.NODE_ENV !== "test") return {}
204+
const packagePath = string(process.env.WP_CODEBOX_TEST_EXTERNAL_PACKAGE_PATH)
205+
const runtimeSourceInputs = string(process.env.WP_CODEBOX_TEST_RUNTIME_SOURCE_INPUTS)
206+
if (!packagePath && !runtimeSourceInputs) return {}
207+
const fixtures = {}
208+
if (packagePath) {
209+
const bytes = await readFile(packagePath)
210+
if (sha256BytesV1(bytes) !== externalPackageSource.digest) throw new Error("Test external package fixture digest does not match the request.")
211+
fixtures.materializedPackage = { bytes, descriptor: externalPackageSource, identity: canonicalExternalNativeAgentIdentity(bytes) }
212+
}
213+
if (runtimeSourceInputs) {
214+
try {
215+
fixtures.runtimeSourceInputs = JSON.parse(runtimeSourceInputs)
216+
} catch {
217+
throw new Error("WP_CODEBOX_TEST_RUNTIME_SOURCE_INPUTS must be valid JSON.")
218+
}
219+
}
220+
return fixtures
221+
}
222+
223+
function runtimeSourceFixtureRoot(value) {
224+
const paths = []
225+
const collect = (entry) => {
226+
if (typeof entry === "string") {
227+
if (isAbsolute(entry)) paths.push(resolve(entry))
228+
return
229+
}
230+
if (Array.isArray(entry)) return entry.forEach(collect)
231+
if (entry && typeof entry === "object") Object.values(entry).forEach(collect)
232+
}
233+
collect(value)
234+
if (paths.length === 0) return ""
235+
236+
const shared = paths.map((path) => path.split("/").filter(Boolean)).reduce((prefix, parts) => prefix.filter((part, index) => parts[index] === part))
237+
return shared.length > 0 ? `/${shared.join("/")}` : ""
238+
}
239+
200240
function workflowPath(path) {
201241
const relativePath = relative(workspace, resolve(path))
202242
return relativePath && !relativePath.startsWith("..") ? relativePath.replaceAll("\\", "/") : ".codebox/agent-task-workflow-result.json"
@@ -261,19 +301,26 @@ const externalPackagePolicy = parseExternalPackageSourcePolicy(string(process.en
261301
const externalPackageSource = normalizeExternalPackageSource(request.external_package_source, externalPackagePolicy)
262302
const runtimeSources = normalizeRuntimeSources(request.runtime_sources ?? [], externalPackagePolicy)
263303
const requestedModel = validateRuntimeSourceModel(request.model, runtimeSources)
304+
const writablePaths = String(request.writable_paths || "").split(",").map((value) => value.trim()).filter(Boolean)
264305
const artifactsPath = join(workspace, ".codebox", "agent-task-artifacts")
265306
const runtimeInputPath = join(workspace, ".codebox", "native-agent-task-input.json")
266307
const resultPath = join(workspace, ".codebox", "agent-task-workflow-result.json")
267308
const controlledCodeboxPath = resolve(requestPath, "..")
268-
const nativeResultPath = join(controlledCodeboxPath, "native-agent-task-result.json")
309+
const nativeResultPath = join(controlledCodeboxPath, "native-agent-task-result.json")
269310
let cleaningPrivateRuntimeSources = false
270-
async function cleanupPrivateRuntimeSources() {
311+
async function cleanupPrivateRuntimeSources() {
271312
if (cleaningPrivateRuntimeSources || !privateRuntimeSourceRoot) return
272313
cleaningPrivateRuntimeSources = true
273314
const root = privateRuntimeSourceRoot
274315
privateRuntimeSourceRoot = ""
275316
await rm(root, { recursive: true, force: true })
276-
}
317+
}
318+
async function cleanupRunnerWorkspaceSeedSnapshot() {
319+
if (!runnerWorkspaceSeedSnapshot) return
320+
const source = runnerWorkspaceSeedSnapshot.source
321+
runnerWorkspaceSeedSnapshot = undefined
322+
await rm(source, { recursive: true, force: true })
323+
}
277324
process.once("exit", () => { if (privateRuntimeSourceRoot) rmSync(privateRuntimeSourceRoot, { recursive: true, force: true }) })
278325
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
279326
process.once(signal, () => { cleanupPrivateRuntimeSources().finally(() => process.exit(128)) })
@@ -291,17 +338,20 @@ function runtimeMetadataForExecutionLocation(executionLocation) {
291338

292339
await mkdir(artifactsPath, { recursive: true })
293340

294-
const accessError = accessFailure(request)
341+
const accessError = accessFailure(request)
295342
if (accessError) {
296343
const error = new Error(accessError)
297344
error.code = "wp-codebox.agent-task.policy"
298-
throw error
299-
}
345+
throw error
346+
}
300347

301-
const skipMaterialization = process.env.NODE_ENV === "test" && process.env.WP_CODEBOX_TEST_SKIP_MATERIALIZATION === "true"
348+
if (request.runner_workspace?.enabled) runnerWorkspaceSeedSnapshot = await createRunnerWorkspaceSeedSnapshot(workspace)
349+
350+
const testFixtures = await testRuntimeFixtures(externalPackageSource)
351+
const skipMaterialization = process.env.NODE_ENV === "test" && (process.env.WP_CODEBOX_TEST_SKIP_MATERIALIZATION === "true" || Boolean(testFixtures.materializedPackage || testFixtures.runtimeSourceInputs))
302352
const materializedPackage = request.run_agent && !request.dry_run && !skipMaterialization
303353
? await materializeExternalNativePackage(externalPackageSource, { policy: externalPackagePolicy })
304-
: undefined
354+
: testFixtures.materializedPackage
305355
const materializedRuntimeSources = request.run_agent && !request.dry_run && !skipMaterialization
306356
? await materializeRuntimeSources(runtimeSources, { policy: externalPackagePolicy, forbiddenRoots: [workspace, artifactsPath] })
307357
: undefined
@@ -311,7 +361,8 @@ await output("runtime_source_root", privateRuntimeSourceRoot)
311361
const runtimeSourceInputs = (materializedRuntimeSources?.lowered ?? []).reduce((input, lowered) => {
312362
for (const [key, entries] of Object.entries(lowered)) input[key] = [...(input[key] ?? []), ...entries]
313363
return input
314-
}, {})
364+
}, testFixtures.runtimeSourceInputs ?? {})
365+
const runtimeSourceOutputRoots = [privateRuntimeSourceRoot, runtimeSourceFixtureRoot(testFixtures.runtimeSourceInputs)]
315366
// Runtime source preparation must remain beside the private checkout, never in
316367
// the target artifact directory that is collected after the run.
317368
const privatePreparationRoot = privateRuntimeSourceRoot ? join(privateRuntimeSourceRoot, "prepared-runtime-sources") : ""
@@ -358,27 +409,28 @@ const taskInput = {
358409
model: requestedModel.name,
359410
runner_workspace: { ...record(request.runner_workspace), allowed_repos: request.access.allowed_repos },
360411
target_repo: request.target_repo,
361-
writable_paths: request.writable_paths,
362-
runner_workspace_policy: { allowed_repos: request.access.allowed_repos, writable_paths: request.writable_paths },
412+
writable_paths: writablePaths,
413+
runner_workspace_policy: { allowed_repos: request.access.allowed_repos, writable_paths: writablePaths },
363414
},
364415
artifact_declarations: request.artifacts?.declarations || [],
365416
required_artifacts: requiredArtifacts(request.artifacts?.declarations),
366417
output_projections: [],
367-
metadata: { workload: request.workload, ...(materializedPackage ? { imported_agent: materializedPackage.identity } : {}), runtime_sources: materializedRuntimeSources?.descriptors ?? [] },
418+
metadata: { workload: request.workload, ...(materializedPackage ? { imported_agent: materializedPackage.identity } : {}), runtime_sources: materializedRuntimeSources?.descriptors ?? [], ...(runnerWorkspaceSeedSnapshot ? { runner_workspace_seed: runnerWorkspaceSeedSnapshot.provenance } : {}) },
368419
},
369420
},
370-
// agent-task-run unwraps task_input before building the recipe. Keep the
371-
// runner seed on that canonical input rather than on the request envelope.
372-
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/**"] } }] : [],
421+
// agent-task-run unwraps task_input before building the recipe. The
422+
// external snapshot prevents the recipe from ever mounting the checkout.
423+
workspaces: runnerWorkspaceSeedSnapshot ? [{ target: "/workspace", mode: "readwrite", sourceMode: "repo-backed", seed: { type: "directory", source: runnerWorkspaceSeedSnapshot.source, excludePaths: RUNNER_WORKSPACE_SEED_EXCLUDES } }] : [],
373424
},
374425
}
375426

376427
await writeFile(executionInputPath, `${JSON.stringify(taskInput, null, 2)}\n`)
377428

378429
let execution = { code: 0, stdout: "", stderr: "", stdout_truncated: false, stderr_truncated: false }
379-
if (request.run_agent && !request.dry_run) {
380-
execution = await command("node", [codeboxCliPath, "agent-task-run", "--input-file", executionInputPath, "--result-file", nativeResultPath], workspace, agentEnvironment())
381-
}
430+
if (request.run_agent && !request.dry_run) {
431+
execution = await command("node", [codeboxCliPath, "agent-task-run", "--input-file", executionInputPath, "--result-file", nativeResultPath], workspace, agentEnvironment())
432+
}
433+
await cleanupRunnerWorkspaceSeedSnapshot()
382434

383435
// Public package bytes are embedded in the runtime recipe and consumed only by
384436
// the Playground bootstrap before the agent's tools are resolved.
@@ -387,7 +439,7 @@ const nativeRuntimeResult = request.run_agent && !request.dry_run
387439
? await readNativeResult(nativeResultPath, controlledCodeboxPath, secretValues, redact)
388440
: {}
389441
await rm(nativeResultPath, { force: true })
390-
const runtimeResult = sanitizeRuntimeSourceValue(nativeRuntimeResult, privateRuntimeSourceRootForSanitization)
442+
let runtimeResult = sanitizeRuntimeSourceValue(nativeRuntimeResult, privateRuntimeSourceRootForSanitization)
391443
assertNoRuntimeSourcePaths(runtimeResult, privateRuntimeSourceRootForSanitization)
392444
let workspaceApply = { status: "no-op", changedFiles: [] }
393445
let runnerWorkspaceCore = null
@@ -398,12 +450,14 @@ if (execution.code === 0 && runtimeResult.success === true && request.runner_wor
398450
const publicCore = await import(pathToFileURL(join(codeboxRoot, "packages/runtime-core/dist/public.js")).href)
399451
const refs = publicCore.normalizePublicArtifactRefDTOs(runtimeResult)
400452
.filter((ref) => ref.kind === "codebox-patch" || ref.kind === "codebox-changed-files")
401-
workspaceApply = await runnerWorkspaceCore.applyRunnerWorkspacePatch({ artifactRoot: artifactsPath, artifactRefs: refs, workspaceRoot: workspace, writablePaths: String(request.writable_paths || "").split(",").map((value) => value.trim()).filter(Boolean) })
453+
workspaceApply = await runnerWorkspaceCore.applyRunnerWorkspacePatch({ artifactRoot: artifactsPath, artifactRefs: refs, workspaceRoot: workspace, writablePaths })
402454
} catch (error) {
403455
downstreamFailure = { stage: "apply", message: bounded(error instanceof Error ? error.message : String(error), MAX_OUTPUT_CHARS) }
404456
}
405457
}
406458
await cleanupPrivateRuntimeSources()
459+
runtimeResult = sanitizeRuntimeSourceValue(runtimeResult, runtimeSourceOutputRoots)
460+
privateRuntimeSourceRootForSanitization = runtimeSourceOutputRoots
407461
assertNoRuntimeSourcePaths(runtimeResult, privateRuntimeSourceRootForSanitization)
408462

409463
await redactArtifactFiles(artifactsPath)
@@ -514,6 +568,11 @@ try {
514568
try {
515569
await writeNormalizedFailure(error)
516570
} finally {
571+
if (runnerWorkspaceSeedSnapshot) {
572+
const source = runnerWorkspaceSeedSnapshot.source
573+
runnerWorkspaceSeedSnapshot = undefined
574+
await rm(source, { recursive: true, force: true })
575+
}
517576
if (privateRuntimeSourceRoot) {
518577
const root = privateRuntimeSourceRoot
519578
privateRuntimeSourceRoot = ""
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { createHash } from "node:crypto"
2+
import { chmod, copyFile, lstat, mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises"
3+
import { tmpdir } from "node:os"
4+
import { join, relative, resolve } from "node:path"
5+
6+
export const RUNNER_WORKSPACE_SEED_EXCLUDES = [".git/**", ".codebox/**", "node_modules/**", "vendor/**", "dist/**", "build/**", "coverage/**", ".cache/**"]
7+
const EXCLUDED_ROOT_NAMES = new Set(RUNNER_WORKSPACE_SEED_EXCLUDES.map((pattern) => pattern.slice(0, -3)))
8+
const MAX_FILES = 10_000
9+
const MAX_BYTES = 256 * 1024 * 1024
10+
11+
function snapshotError(message) {
12+
const error = new Error(message)
13+
error.code = "wp-codebox.agent-task.runner-workspace-snapshot"
14+
return error
15+
}
16+
17+
function fileMode(stat) {
18+
return stat.mode & 0o111 ? 0o755 : 0o644
19+
}
20+
21+
export async function createRunnerWorkspaceSeedSnapshot(source) {
22+
const sourceRoot = resolve(source)
23+
const root = await mkdtemp(join(tmpdir(), "wp-codebox-runner-workspace-seed-"))
24+
const digest = createHash("sha256")
25+
let fileCount = 0
26+
let byteCount = 0
27+
28+
try {
29+
async function copyTree(currentSource, currentTarget) {
30+
const entries = await readdir(currentSource, { withFileTypes: true })
31+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
32+
if (EXCLUDED_ROOT_NAMES.has(entry.name)) continue
33+
const input = join(currentSource, entry.name)
34+
const output = join(currentTarget, entry.name)
35+
const stat = await lstat(input)
36+
const path = relative(sourceRoot, input).replaceAll("\\", "/")
37+
if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) {
38+
throw snapshotError(`Runner workspace seed contains unsupported filesystem entry: ${path}`)
39+
}
40+
if (stat.isDirectory()) {
41+
await mkdir(output, { recursive: true, mode: 0o755 })
42+
await chmod(output, 0o755)
43+
digest.update(`directory\0${path}\n`)
44+
await copyTree(input, output)
45+
continue
46+
}
47+
fileCount += 1
48+
byteCount += stat.size
49+
if (fileCount > MAX_FILES) throw snapshotError(`Runner workspace seed exceeds the ${MAX_FILES} file limit.`)
50+
if (byteCount > MAX_BYTES) throw snapshotError(`Runner workspace seed exceeds the ${MAX_BYTES} byte limit.`)
51+
const bytes = await readFile(input)
52+
digest.update(`file\0${path}\0${fileMode(stat).toString(8)}\0${bytes.length}\n`)
53+
digest.update(bytes)
54+
await copyFile(input, output)
55+
await chmod(output, fileMode(stat))
56+
}
57+
}
58+
59+
const sourceStat = await lstat(sourceRoot)
60+
if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) throw snapshotError("Runner workspace seed source must be a real directory.")
61+
await copyTree(sourceRoot, root)
62+
return {
63+
source: root,
64+
provenance: {
65+
schema: "wp-codebox/runner-workspace-seed-snapshot/v1",
66+
digest: { sha256: digest.digest("hex") },
67+
files: fileCount,
68+
bytes: byteCount,
69+
excludes: RUNNER_WORKSPACE_SEED_EXCLUDES,
70+
},
71+
}
72+
} catch (error) {
73+
await rm(root, { recursive: true, force: true })
74+
throw error
75+
}
76+
}

.github/workflows/agent-task-contracts.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ on:
1717
- "tests/agent-task-*.test.ts"
1818
- "tests/runtime-sources-materialization.test.ts"
1919
- "tests/runtime-sources-playground-integration.test.ts"
20+
- "tests/execute-native-agent-task-playground-e2e.test.ts"
2021
- "tests/redaction.test.ts"
2122
- "tests/production-boundary-enforcement.test.ts"
2223
- "tests/runtime-tool-policy.test.ts"
@@ -36,6 +37,7 @@ on:
3637
- "tests/agent-task-*.test.ts"
3738
- "tests/runtime-sources-materialization.test.ts"
3839
- "tests/runtime-sources-playground-integration.test.ts"
40+
- "tests/execute-native-agent-task-playground-e2e.test.ts"
3941
- "tests/redaction.test.ts"
4042
- "tests/production-boundary-enforcement.test.ts"
4143
- "tests/runtime-tool-policy.test.ts"
@@ -55,6 +57,7 @@ jobs:
5557
- run: npm run build
5658
- run: npm run test:agent-task-contracts
5759
- run: npm run test:runtime-sources-playground-integration
60+
- run: npm run test:native-agent-task-playground-e2e
5861
- run: npm run test:redaction
5962
- run: npm run test:production-boundary-enforcement
6063
- run: npm run test:runtime-tool-policy

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@
130130
"test:runner-workspace-apply": "tsx tests/runner-workspace-apply.test.ts",
131131
"test:runner-workspace-publisher": "node tests/runner-workspace-publisher.test.mjs",
132132
"test:native-agent-task-lifecycle": "node tests/execute-native-agent-task-lifecycle.test.mjs",
133+
"test:native-agent-task-playground-e2e": "tsx tests/execute-native-agent-task-playground-e2e.test.ts",
133134
"test:bench-command-step-behavior": "tsx tests/bench-command-step-behavior.test.ts",
134135
"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",
135136
"test:browser-artifact-session": "tsx tests/browser-artifact-session.test.ts",

0 commit comments

Comments
 (0)