Skip to content

Commit f3720ec

Browse files
committed
Support immutable runtime ZIP artifacts
1 parent c9c36d7 commit f3720ec

11 files changed

Lines changed: 363 additions & 15 deletions

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,11 +243,13 @@ const runtimeSourceInputs = (materializedRuntimeSources?.lowered ?? []).reduce((
243243
for (const [key, entries] of Object.entries(lowered)) input[key] = [...(input[key] ?? []), ...entries]
244244
return input
245245
}, {})
246+
const sourcePackageRoot = privateRuntimeSourceRoot ? join(privateRuntimeSourceRoot, "prepared-packages") : artifactsPath
246247

247248
const taskInput = {
248249
schema: "wp-codebox/agent-task-run-request/v1",
249250
task_id: runId,
250-
artifacts_path: artifactsPath,
251+
artifacts_path: artifactsPath,
252+
source_package_root: sourcePackageRoot,
251253
callback_data: record(request.callback_data),
252254
task_input: {
253255
schema: "wp-codebox/task-input/v1",

.github/scripts/run-agent-task/materialize-external-native-package.mjs

Lines changed: 204 additions & 7 deletions
Large diffs are not rendered by default.

.github/scripts/run-agent-task/prepare-agent-task-upload.mjs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ function redact(value) {
1414
return secretValues.reduce((output, secret) => output.split(secret).join("[REDACTED]"), value)
1515
}
1616

17+
function omitPrivateRuntimeSourcePaths(value) {
18+
if (Array.isArray(value)) return value.map(omitPrivateRuntimeSourcePaths)
19+
if (!value || typeof value !== "object") return value
20+
return Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => {
21+
if (runtimeSourceRoot && typeof entry === "string" && resolve(entry).startsWith(runtimeSourceRoot) && ["source", "path", "sourceRoot", "originalSource", "preparedPath", "requestedPath"].includes(key)) return []
22+
return [[key, omitPrivateRuntimeSourcePaths(entry)]]
23+
}))
24+
}
25+
1726
async function stageFile(source, destination) {
1827
if (runtimeSourceRoot && resolve(source).startsWith(runtimeSourceRoot)) {
1928
throw new Error("Runtime source files must never be staged for artifact upload.")
@@ -27,7 +36,10 @@ async function stageFile(source, destination) {
2736
await handle.close()
2837
if (!contents || contents.includes(0) || !isUtf8(contents)) return false
2938
await mkdir(resolve(destination, ".."), { recursive: true })
30-
const text = contents.toString("utf8")
39+
let text = contents.toString("utf8")
40+
if (source === join(workspace, ".codebox", "native-agent-task-input.json")) {
41+
text = `${JSON.stringify(omitPrivateRuntimeSourcePaths(JSON.parse(text)), null, 2)}\n`
42+
}
3143
if (runtimeSourceRoot && text.includes(runtimeSourceRoot)) throw new Error("Runtime source paths must never be persisted in artifact uploads.")
3244
await writeFile(destination, redact(text))
3345
return true

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ on:
1515
- "package.json"
1616
- "tests/agent-task-*.test.ts"
1717
- "tests/runtime-sources-materialization.test.ts"
18+
- "tests/runtime-sources-playground-integration.test.ts"
1819
- "tests/redaction.test.ts"
1920
- "tests/production-boundary-enforcement.test.ts"
2021
- "tests/runtime-tool-policy.test.ts"
@@ -32,6 +33,7 @@ on:
3233
- "package.json"
3334
- "tests/agent-task-*.test.ts"
3435
- "tests/runtime-sources-materialization.test.ts"
36+
- "tests/runtime-sources-playground-integration.test.ts"
3537
- "tests/redaction.test.ts"
3638
- "tests/production-boundary-enforcement.test.ts"
3739
- "tests/runtime-tool-policy.test.ts"
@@ -50,6 +52,9 @@ jobs:
5052
- run: npm ci
5153
- run: npm run build
5254
- run: npm run test:agent-task-contracts
55+
- run: npm run test:runtime-sources-playground-integration
56+
env:
57+
WP_CODEBOX_RUN_NETWORK_INTEGRATION: "1"
5358
- run: npm run test:redaction
5459
- run: npm run test:production-boundary-enforcement
5560
- run: npm run test:runtime-tool-policy

docs/agent-task-reusable-workflow.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ This release-coherence contract fixes [#1759](https://github.com/Automattic/wp-c
5656
- `external_package_source`: immutable descriptor with `repository`, full commit `revision`, one package-relative `.agent.json` `path`, and `digest`. Packages are supported only from publicly accessible GitHub repositories, fetched from canonical `https://github.com/OWNER/REPOSITORY.git` without credentials. `digest` is exactly `sha256-bytes-v1:<lowercase-sha256>` over the raw file bytes; filenames and JSON content are UTF-8-safe and are not normalized before hashing.
5757
- `EXTERNAL_PACKAGE_SOURCE_POLICY`: required reusable-workflow secret, supplied by the caller's operator-controlled secret configuration. Its strict version 1 JSON shape is `{"version":1,"repositories":{"owner/repository":["agents/example.agent.json"]}}`. Every entry is an exact standalone `.agent.json` path. The policy is validated in runner memory, is never part of task input, and is not uploaded.
5858

59-
`runtime_sources` is an optional versioned JSON array for immutable public runtime dependencies. Each descriptor declares `version: 1`, `role` (`component`, `provider_plugin`, or `bundled_library`), `repository`, a full 40-character `revision`, exact directory `path`, optional `sha256-git-archive-v1` digest, and role metadata. The operator-controlled policy extends the same secret with `runtime_sources`, for example `{"version":1,"repositories":{},"runtime_sources":{"owner/repository":["plugins/example"]}}`. The runner uses unauthenticated Git transport, verifies the commit, tree type, regular-file-only contents, and optional digest, stages outside the target workspace, and removes the staging root after execution. Runtime source descriptors lower into existing component, provider plugin, and runtime overlay inputs; artifacts retain provenance only.
59+
`runtime_sources` is an optional versioned JSON array for immutable public runtime dependencies. Git descriptors declare `version: 1`, `role` (`component`, `provider_plugin`, or `bundled_library`), `repository`, a full 40-character `revision`, exact directory `path`, optional `sha256-git-archive-v1` digest, and role metadata. Component and provider descriptors may instead declare `source: {"type":"https_zip","url":"https://...","sha256":"<64 lowercase hex>","archive_root":"plugin-directory"}`. The operator-controlled policy separately authorizes Git sources with `runtime_sources` and exact ZIP URLs with `runtime_artifacts`, for example `{"version":1,"repositories":{},"runtime_artifacts":[{"url":"https://downloads.wordpress.org/plugin/example.zip","sha256":"<64 lowercase hex>"}]}`. ZIP downloads require HTTPS, an exact allowlisted URL, a matching descriptor digest, bounded transfer and extraction sizes, and redirects only to another allowlisted URL on the original host. The runner verifies the digest before extraction; rejects traversal, symlink, special-file, encrypted, ZIP64, oversized, and multi-root archives; stages under a private temp root outside the workspace and artifacts; and removes the staging root after execution. Runtime source descriptors lower into existing component, provider plugin, and runtime overlay inputs; artifacts retain provenance only.
6060
- `target_repo`: `OWNER/REPO` target repository.
6161
- `prompt`, `writable_paths`, provider/model, `max_turns`, `time_budget_ms`, callback data, and artifact declarations: native task inputs.
6262
- `runner_workspace`: JSON runner-workspace publication request owned by the package.

fixtures/agent-task-runtime-sources-run-29299109269.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"run_id": "29299109269",
33
"runtime_sources": [
44
{ "version": 1, "role": "component", "repository": "automattic/agents-api", "revision": "59d1e6b473f22498e40e279130bbb4f9bcde3b73", "path": ".", "metadata": { "slug": "agents-api", "loadAs": "mu-plugin", "activate": false } },
5-
{ "version": 1, "role": "provider_plugin", "repository": "wordpress/ai-provider-for-openai", "revision": "a400c158707e3d0b6fcd0f1502f6aa1085b01b9b", "path": ".", "metadata": { "slug": "ai-provider-for-openai", "activate": true } },
5+
{ "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 } },
66
{ "version": 1, "role": "bundled_library", "repository": "wordpress/php-ai-client", "revision": "631704201d15ffeff7091ad3bc7156db74054956", "path": ".", "metadata": { "library": "php-ai-client", "strategy": "wordpress-scoped-bundle" } }
77
]
88
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
"test:agent-task-runtime-package-staging": "tsx tests/agent-task-runtime-package-staging.test.ts",
9191
"test:external-native-package-materialization": "tsx tests/external-native-package-materialization.test.ts",
9292
"test:runtime-sources-materialization": "tsx tests/runtime-sources-materialization.test.ts",
93+
"test:runtime-sources-playground-integration": "tsx tests/runtime-sources-playground-integration.test.ts",
9394
"test:browser-task-builder": "tsx tests/browser-task-builder.test.ts",
9495
"test:browser-runtime-generic-invoker": "tsx tests/browser-runtime-generic-invoker.test.ts",
9596
"test:browser-runtime-file-ops": "tsx tests/browser-runtime-file-ops.test.ts",

packages/runtime-core/src/agent-task-recipe.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export interface AgentTaskRunInput {
5454
session_id?: string
5555
sandbox_session_id?: string
5656
artifacts_path?: string
57+
source_package_root?: string
5758
wp?: string
5859
component_contracts?: Array<Record<string, unknown>>
5960
runtime_requirements?: Record<string, unknown>
@@ -86,16 +87,17 @@ export interface RuntimeDependencyPlanContractInput {
8687

8788
export function buildAgentTaskRecipe(input: AgentTaskRunInput, taskInput: TaskInput, wpVersion: string): WorkspaceRecipe {
8889
const artifacts = stringValue(input.artifacts_path)
90+
const sourcePackageRoot = stringValue(input.source_package_root) || artifacts
8991
const profile = runtimeOverlayProfileDefaults(input)
9092
const runtimeMounts = runtimeStateMounts(input)
9193
const sourceRoots = workspaceSourceRoots(input, taskInput)
9294
const runtimeTask = runtimeTaskWithInlineBundle(input.runtime_task, sourceRoots)
9395
const effectiveInput = { ...input, runtime_task: runtimeTask }
9496
const stagedFiles = stagedRuntimeSources(effectiveInput, taskInput, sourceRoots)
95-
const providerPlugins = providerPluginEntries(input, artifacts)
97+
const providerPlugins = providerPluginEntries(input, sourcePackageRoot)
9698
const providerSlugs = providerPlugins.map((plugin) => plugin.slug).join(",")
9799
const providerContracts = providerPlugins.map((plugin) => ({ slug: plugin.slug, pluginFile: plugin.pluginFile, loadAs: plugin.loadAs ?? "plugin" }))
98-
const componentPluginEntries = componentPlugins([...runtimeProfileComponentContracts(input), ...(input.component_contracts ?? [])], artifacts)
100+
const componentPluginEntries = componentPlugins([...runtimeProfileComponentContracts(input), ...(input.component_contracts ?? [])], sourcePackageRoot)
99101
const callerExtraPlugins = agentTaskExtraPlugins(input)
100102
const defaultRuntimeComponents = defaultAgentRuntimeComponentPlugins(componentPluginEntries, callerExtraPlugins)
101103
const extraPlugins = dedupeExtraPlugins([

packages/runtime-core/src/component-contracts.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export function resolvePluginEntrypointContract(contract: PluginEntrypointContra
2424
const loadAs = contract.loadAs === "mu-plugin" ? "mu-plugin" : "plugin"
2525

2626
if (contract.pluginFile) {
27-
return { source, slug, pluginFile: contract.pluginFile, loadAs, fallback: "explicit" }
27+
return { source, slug, pluginFile: canonicalPluginFile(slug, contract.pluginFile), loadAs, fallback: "explicit" }
2828
}
2929

3030
for (const [name, fallback] of [[`${slug}.php`, "slug"], ["plugin.php", "plugin"]] as const) {
@@ -41,6 +41,14 @@ export function resolvePluginEntrypointContract(contract: PluginEntrypointContra
4141
return { source, slug, pluginFile: `${slug}/${slug}.php`, loadAs, fallback: "default" }
4242
}
4343

44+
function canonicalPluginFile(slug: string, pluginFile: string): string {
45+
const normalized = pluginFile.trim().replace(/\\/g, "/").replace(/^\/+/, "")
46+
if (!normalized || normalized.split("/").some((part) => !part || part === "." || part === "..")) {
47+
throw new Error(`Plugin entrypoint must be a safe path inside ${slug}: ${pluginFile}`)
48+
}
49+
return normalized === slug || normalized.startsWith(`${slug}/`) ? normalized : `${slug}/${normalized}`
50+
}
51+
4452
export function sanitizePluginSlug(value: string): string {
4553
return value.trim().replace(/[^A-Za-z0-9_-]/g, "-")
4654
}

tests/runtime-sources-materialization.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { execFile } from "node:child_process"
33
import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"
44
import { join, relative } from "node:path"
55
import { promisify } from "node:util"
6-
import { materializeRuntimeSources, normalizeRuntimeSource, parseExternalPackageSourcePolicy } from "../.github/scripts/run-agent-task/materialize-external-native-package.mjs"
6+
import { inspectZipArchive, materializeRuntimeSources, normalizeRuntimeSource, normalizeRuntimeSources, parseExternalPackageSourcePolicy } from "../.github/scripts/run-agent-task/materialize-external-native-package.mjs"
77
import { withTempDir } from "../scripts/test-kit.js"
88
import { buildAgentTaskRecipe } from "../packages/runtime-core/src/agent-task-recipe.js"
99
import { normalizeTaskInput } from "../packages/runtime-core/src/task-input.js"
@@ -45,11 +45,15 @@ await withTempDir("wp-codebox-runtime-sources-", async (repository) => {
4545
const recipe = buildAgentTaskRecipe({ goal: "verify lowering", ...loweredInput }, normalizeTaskInput({ goal: "verify lowering" }), "latest")
4646
assert.ok(recipe.inputs?.extra_plugins?.some((plugin) => plugin.slug === "runtime" && plugin.loadAs === "mu-plugin"))
4747
assert.ok(recipe.inputs?.extra_plugins?.some((plugin) => plugin.slug === "example-provider" && plugin.activate === true))
48+
assert.equal(recipe.inputs?.extra_plugins?.find((plugin) => plugin.slug === "runtime")?.pluginFile, "runtime/runtime.php")
49+
assert.equal(recipe.inputs?.extra_plugins?.find((plugin) => plugin.slug === "example-provider")?.pluginFile, "example-provider/provider.php")
4850
assert.equal(recipe.runtime?.overlays?.[0].strategy, "scoped-bundle")
4951
await assert.rejects(materializeRuntimeSources([{ ...sources[0], revision: "main" }], { policy, remotes: { "example/runtime": repository } }), /immutable 40-character/)
5052
assert.throws(() => normalizeRuntimeSource({ ...sources[0], version: 2 }, policy), /version must be 1/)
5153
await assert.rejects(materializeRuntimeSources([{ ...sources[0], path: "../components/runtime" }], { policy, remotes: { "example/runtime": repository } }), /without traversal/)
5254
await assert.rejects(materializeRuntimeSources([{ ...sources[0], repository: "other/runtime" }], { policy, remotes: { "example/runtime": repository } }), /not authorized/)
55+
assert.throws(() => normalizeRuntimeSources([sources[0], { ...sources[1], metadata: { slug: "runtime" } }], policy), /duplicate plugin slug/)
56+
assert.throws(() => normalizeRuntimeSources([{ ...sources[0], metadata: { slug: "runtime", loadAs: "mu-plugin", pluginFile: "../runtime.php" } }], policy), /without traversal/)
5357
await assert.rejects(materializeRuntimeSources([{ ...sources[0], digest: `sha256-git-archive-v1:${"0".repeat(64)}` }], { policy, remotes: { "example/runtime": repository } }), /digest does not match/)
5458
assert.throws(() => normalizeRuntimeSource({ ...sources[0], metadata: { slug: "runtime", loadAs: "unknown" } }, policy), /loadAs/)
5559
await symlink("runtime.php", join(repository, "components/runtime/link.php"))
@@ -59,6 +63,37 @@ await withTempDir("wp-codebox-runtime-sources-", async (repository) => {
5963
await rm(materialized.root, { recursive: true, force: true })
6064
})
6165

66+
await withTempDir("wp-codebox-runtime-zip-source-", async (directory) => {
67+
const archiveRoot = join(directory, "example-provider")
68+
await mkdir(archiveRoot, { recursive: true })
69+
await writeFile(join(archiveRoot, "plugin.php"), "<?php /* Plugin Name: Example Provider */\n")
70+
await execFileAsync("zip", ["-q", "-r", "provider.zip", "example-provider"], { cwd: directory })
71+
const archive = await readFile(join(directory, "provider.zip"))
72+
const url = "https://downloads.example.test/provider.zip"
73+
const digest = (await execFileAsync("shasum", ["-a", "256", join(directory, "provider.zip")])).stdout.split(/\s+/)[0]
74+
const policy = parseExternalPackageSourcePolicy(JSON.stringify({ version: 1, repositories: {}, runtime_artifacts: [{ url, sha256: digest }] }))
75+
const source = { version: 1, role: "provider_plugin", source: { type: "https_zip", url, sha256: digest, archive_root: "example-provider" }, metadata: { slug: "example-provider", pluginFile: "plugin.php", activate: true } }
76+
const materialized = await materializeRuntimeSources([source], { policy, fetch: async () => new Response(archive) })
77+
assert.equal(materialized.lowered[0].provider_plugins[0].slug, "example-provider")
78+
assert.deepEqual(materialized.descriptors[0], { role: "provider_plugin", source: { type: "https_zip", url, sha256: digest, archive_root: "example-provider" } })
79+
await assert.rejects(materializeRuntimeSources([{ ...source, source: { ...source.source, sha256: "0".repeat(64) } }], { policy, fetch: async () => new Response(archive) }), /trusted policy/)
80+
assert.throws(() => normalizeRuntimeSource({ ...source, source: { ...source.source, url: "http://downloads.example.test/provider.zip" } }, policy), /HTTPS/)
81+
await assert.rejects(materializeRuntimeSources([{ ...source, source: { ...source.source, archive_root: "other" } }], { policy, fetch: async () => new Response(archive) }), /archive root/)
82+
assert.throws(() => normalizeRuntimeSource({ ...source, role: "bundled_library" }, policy), /component and provider_plugin/)
83+
const encrypted = Buffer.from(archive)
84+
const centralDirectory = encrypted.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02]))
85+
encrypted.writeUInt16LE(encrypted.readUInt16LE(centralDirectory + 8) | 1, centralDirectory + 8)
86+
assert.throws(() => inspectZipArchive(encrypted), /encrypted/)
87+
const bomb = Buffer.from(archive)
88+
bomb.writeUInt32LE(33 * 1024 * 1024, centralDirectory + 24)
89+
assert.throws(() => inspectZipArchive(bomb), /oversized/)
90+
await symlink("plugin.php", join(archiveRoot, "link.php"))
91+
await execFileAsync("zip", ["-q", "-y", "symlink.zip", "example-provider/link.php"], { cwd: directory })
92+
const symlinkArchive = await readFile(join(directory, "symlink.zip"))
93+
assert.throws(() => inspectZipArchive(symlinkArchive), /symlink|special-file/)
94+
await rm(materialized.root, { recursive: true, force: true })
95+
})
96+
6297
await withTempDir("wp-codebox-runtime-source-upload-", async (directory) => {
6398
const workspace = join(directory, "workspace")
6499
const artifacts = join(workspace, ".codebox", "agent-task-artifacts")
@@ -68,10 +103,19 @@ await withTempDir("wp-codebox-runtime-source-upload-", async (directory) => {
68103
await mkdir(privateRoot, { recursive: true })
69104
await writeFile(join(privateRoot, "source.php"), "<?php // private runtime source\n")
70105
await writeFile(join(artifacts, "safe.json"), JSON.stringify({ provenance: { role: "component", repository: "example/runtime", revision: "a".repeat(40), path: "plugin" } }))
106+
await writeFile(join(workspace, ".codebox", "native-agent-task-input.json"), JSON.stringify({
107+
component_contracts: [{
108+
path: privateRoot,
109+
metadata: { runtime_source: { role: "component", repository: "example/runtime", revision: "a".repeat(40), path: "plugin" } },
110+
}],
111+
}))
71112
const script = new URL("../.github/scripts/run-agent-task/prepare-agent-task-upload.mjs", import.meta.url)
72113
await execFileAsync(process.execPath, [script.pathname], { env: { ...process.env, AGENT_TASK_WORKSPACE: workspace, AGENT_TASK_UPLOAD_PATH: upload, WP_CODEBOX_RUNTIME_SOURCE_ROOT: privateRoot } })
73114
const staged = await readFile(join(upload, ".codebox", "agent-task-artifacts", "safe.json"), "utf8")
74115
assert.doesNotMatch(staged, /private-runtime-source|private runtime source/)
116+
const stagedInput = await readFile(join(upload, ".codebox", "native-agent-task-input.json"), "utf8")
117+
assert.doesNotMatch(stagedInput, /private-runtime-source/)
118+
assert.match(stagedInput, /"runtime_source"/)
75119
await writeFile(join(artifacts, "leak.json"), privateRoot)
76120
await assert.rejects(execFileAsync(process.execPath, [script.pathname], { env: { ...process.env, AGENT_TASK_WORKSPACE: workspace, AGENT_TASK_UPLOAD_PATH: upload, WP_CODEBOX_RUNTIME_SOURCE_ROOT: privateRoot } }), /Runtime source paths must never be persisted/)
77121
})

0 commit comments

Comments
 (0)