Skip to content

Commit c63e600

Browse files
committed
Materialize immutable native runtime sources
1 parent 988e2f2 commit c63e600

13 files changed

Lines changed: 219 additions & 9 deletions

.github/scripts/run-agent-task/build-codebox-task-request.mjs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { mkdirSync, writeFileSync, appendFileSync } from "node:fs"
2-
import { normalizeExternalPackageSource, parseExternalPackageSourcePolicy } from "./materialize-external-native-package.mjs"
2+
import { normalizeExternalPackageSource, normalizeRuntimeSources, parseExternalPackageSourcePolicy } from "./materialize-external-native-package.mjs"
33

44
function parseJson(name, fallback, expected) {
55
const raw = process.env[name]
@@ -64,9 +64,11 @@ function commandList(name) {
6464
}
6565

6666
const artifactDeclarations = parseJson("ARTIFACT_DECLARATIONS", [], "array")
67+
const externalPackagePolicy = parseExternalPackageSourcePolicy(requiredString("EXTERNAL_PACKAGE_SOURCE_POLICY"))
6768
const request = {
6869
schema: "wp-codebox/agent-task-workflow-request/v1",
69-
external_package_source: normalizeExternalPackageSource(parseJson("EXTERNAL_PACKAGE_SOURCE", {}, "object"), parseExternalPackageSourcePolicy(requiredString("EXTERNAL_PACKAGE_SOURCE_POLICY"))),
70+
external_package_source: normalizeExternalPackageSource(parseJson("EXTERNAL_PACKAGE_SOURCE", {}, "object"), externalPackagePolicy),
71+
runtime_sources: normalizeRuntimeSources(parseJson("RUNTIME_SOURCES", [], "array"), externalPackagePolicy),
7072
workload: {
7173
id: process.env.WORKLOAD_ID || "agent-task",
7274
label: process.env.WORKLOAD_LABEL || "Run Agent Task",

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import { rmSync } from "node:fs"
12
import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"
23
import { join, resolve } from "node:path"
34
import { spawn } from "node:child_process"
4-
import { materializeExternalNativePackage, normalizeExternalPackageSource, parseExternalPackageSourcePolicy } from "./materialize-external-native-package.mjs"
5+
import { materializeExternalNativePackage, materializeRuntimeSources, normalizeExternalPackageSource, normalizeRuntimeSources, parseExternalPackageSourcePolicy } from "./materialize-external-native-package.mjs"
56
import { readNativeResult } from "./native-result-file.mjs"
67

78
const requestPath = process.env.AGENT_TASK_REQUEST_PATH || ".codebox/agent-task-request.json"
@@ -189,6 +190,7 @@ const driftChecks = commandEntries(request.drift_checks, "drift_checks")
189190
const runId = `${request.workload?.id || "agent-task"}-${process.env.GITHUB_RUN_ID || "local"}`.replace(/[^A-Za-z0-9._-]+/g, "-")
190191
const externalPackagePolicy = parseExternalPackageSourcePolicy(string(process.env.EXTERNAL_PACKAGE_SOURCE_POLICY))
191192
const externalPackageSource = normalizeExternalPackageSource(request.external_package_source, externalPackagePolicy)
193+
const runtimeSources = normalizeRuntimeSources(request.runtime_sources ?? [], externalPackagePolicy)
192194
const artifactsPath = join(workspace, ".codebox", "agent-task-artifacts")
193195
const runtimeInputPath = join(workspace, ".codebox", "native-agent-task-input.json")
194196
const resultPath = join(workspace, ".codebox", "agent-task-workflow-result.json")
@@ -220,6 +222,14 @@ if (accessError) {
220222
const materializedPackage = request.run_agent && !request.dry_run
221223
? await materializeExternalNativePackage(externalPackageSource, { policy: externalPackagePolicy })
222224
: undefined
225+
const materializedRuntimeSources = request.run_agent && !request.dry_run
226+
? await materializeRuntimeSources(runtimeSources, { policy: externalPackagePolicy })
227+
: undefined
228+
process.once("exit", () => { if (materializedRuntimeSources?.root) rmSync(materializedRuntimeSources.root, { recursive: true, force: true }) })
229+
const runtimeSourceInputs = (materializedRuntimeSources?.lowered ?? []).reduce((input, lowered) => {
230+
for (const [key, entries] of Object.entries(lowered)) input[key] = [...(input[key] ?? []), ...entries]
231+
return input
232+
}, {})
223233

224234
const taskInput = {
225235
schema: "wp-codebox/agent-task-run-request/v1",
@@ -235,6 +245,7 @@ const taskInput = {
235245
provider: request.model?.provider,
236246
model: request.model?.name,
237247
secret_env: ["OPENAI_API_KEY", "MODEL_PROVIDER_SECRET_1", "MODEL_PROVIDER_SECRET_2", "MODEL_PROVIDER_SECRET_3", "MODEL_PROVIDER_SECRET_4", "MODEL_PROVIDER_SECRET_5"].filter((name) => process.env[name]),
248+
...runtimeSourceInputs,
238249
allowed_tools: runnerWorkspaceTools,
239250
sandbox_tool_policy: {
240251
schema: "wp-codebox/sandbox-tool-policy/v1",
@@ -265,7 +276,7 @@ const taskInput = {
265276
artifact_declarations: request.artifacts?.declarations || [],
266277
required_artifacts: request.artifacts?.expected || [],
267278
output_projections: [],
268-
metadata: { workload: request.workload, ...(materializedPackage ? { imported_agent: materializedPackage.identity } : {}) },
279+
metadata: { workload: request.workload, ...(materializedPackage ? { imported_agent: materializedPackage.identity } : {}), runtime_sources: materializedRuntimeSources?.descriptors.map(({ metadata, ...provenance }) => ({ ...provenance, metadata })) ?? [] },
269280
},
270281
},
271282
},
@@ -285,6 +296,7 @@ const runtimeResult = request.run_agent && !request.dry_run
285296
? await readNativeResult(nativeResultPath, controlledCodeboxPath, secretValues, redact)
286297
: {}
287298
await rm(nativeResultPath, { force: true })
299+
await rm(materializedRuntimeSources?.root ?? "", { recursive: true, force: true })
288300

289301
await redactArtifactFiles(artifactsPath)
290302

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

Lines changed: 116 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createHash } from "node:crypto"
2-
import { mkdtemp, rm } from "node:fs/promises"
2+
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"
33
import { tmpdir } from "node:os"
44
import { join } from "node:path"
55
import { spawn } from "node:child_process"
@@ -10,6 +10,7 @@ const DIGEST = /^[0-9a-f]{64}$/i
1010
const DIGEST_SCHEME = "sha256-bytes-v1"
1111
const MAX_PACKAGE_BYTES = 1024 * 1024
1212
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
13+
const ROLE = /^(component|provider_plugin|bundled_library)$/
1314
const string = (value) => typeof value === "string" ? value.trim() : ""
1415

1516
export function canonicalExternalNativeAgentIdentity(bytes) {
@@ -38,6 +39,11 @@ function normalizePath(value) {
3839
return path
3940
}
4041

42+
function normalizeRuntimePath(value) {
43+
if (string(value) === ".") return "."
44+
return normalizePath(value)
45+
}
46+
4147
export function sha256BytesV1(bytes) {
4248
return `${DIGEST_SCHEME}:${createHash("sha256").update(bytes).digest("hex")}`
4349
}
@@ -96,8 +102,115 @@ export function parseExternalPackageSourcePolicy(raw) {
96102
return agentPath
97103
})
98104
}
99-
if (Object.keys(repositories).length === 0) throw new Error("EXTERNAL_PACKAGE_SOURCE_POLICY must authorize at least one repository.")
100-
return { version: 1, repositories }
105+
const runtime_sources = normalizeRuntimeSourcePolicy(policy.runtime_sources)
106+
if (Object.keys(repositories).length === 0 && Object.keys(runtime_sources).length === 0) throw new Error("EXTERNAL_PACKAGE_SOURCE_POLICY must authorize at least one repository.")
107+
return { version: 1, repositories, runtime_sources }
108+
}
109+
110+
function normalizeRuntimeSourcePolicy(value) {
111+
if (value === undefined) return {}
112+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("EXTERNAL_PACKAGE_SOURCE_POLICY runtime_sources must be a repositories mapping.")
113+
const repositories = {}
114+
for (const [repository, paths] of Object.entries(value)) {
115+
const normalizedRepository = string(repository).toLowerCase()
116+
if (!REPOSITORY.test(normalizedRepository) || !Array.isArray(paths) || paths.length === 0) throw new Error("EXTERNAL_PACKAGE_SOURCE_POLICY contains an invalid runtime source repository entry.")
117+
repositories[normalizedRepository] = paths.map((path) => normalizeRuntimePath(string(path)))
118+
}
119+
return repositories
120+
}
121+
122+
export function normalizeRuntimeSources(value, policy = {}) {
123+
if (!Array.isArray(value)) throw new Error("runtime_sources must be a JSON array.")
124+
return value.map((entry, index) => normalizeRuntimeSource(entry, policy, `runtime_sources[${index}]`))
125+
}
126+
127+
export function normalizeRuntimeSource(value, policy = {}, label = "runtime_source") {
128+
const source = value && typeof value === "object" && !Array.isArray(value) ? value : {}
129+
if (source.version !== 1) throw new Error(`${label}.version must be 1.`)
130+
const role = string(source.role)
131+
const repository = string(source.repository).toLowerCase()
132+
const revision = string(source.revision).toLowerCase()
133+
const path = normalizeRuntimePath(string(source.path))
134+
if (!ROLE.test(role)) throw new Error(`${label}.role must be component, provider_plugin, or bundled_library.`)
135+
if (!REPOSITORY.test(repository)) throw new Error(`${label}.repository must be an OWNER/REPO identifier.`)
136+
if (!COMMIT.test(revision)) throw new Error(`${label}.revision must be a full immutable 40-character commit SHA.`)
137+
const allowedPaths = policy.runtime_sources?.[repository]
138+
if (!Array.isArray(allowedPaths) || !allowedPaths.includes(path)) throw new Error("Runtime source repository or path is not authorized.")
139+
const digest = source.digest === undefined || source.digest === "" ? undefined : normalizeRuntimeDigest(source.digest, label)
140+
const metadata = source.metadata && typeof source.metadata === "object" && !Array.isArray(source.metadata) ? source.metadata : {}
141+
const normalized = { version: 1, role, repository, revision, path, ...(digest ? { digest } : {}), metadata: normalizeRuntimeMetadata(role, metadata, label) }
142+
return normalized
143+
}
144+
145+
function normalizeRuntimeDigest(value, label) {
146+
const [scheme, digest] = string(value).split(":", 2)
147+
if (scheme !== "sha256-git-archive-v1" || !DIGEST.test(digest)) throw new Error(`${label}.digest must use sha256-git-archive-v1:<64 lowercase hexadecimal characters>.`)
148+
return `${scheme}:${digest.toLowerCase()}`
149+
}
150+
151+
function normalizeRuntimeMetadata(role, metadata, label) {
152+
const slug = string(metadata.slug)
153+
const pluginFile = string(metadata.pluginFile)
154+
const activate = metadata.activate
155+
if (role === "component") {
156+
if (!SLUG.test(slug) || !["plugin", "mu-plugin"].includes(metadata.loadAs)) throw new Error(`${label}.metadata must declare a slug and loadAs plugin or mu-plugin for a component.`)
157+
if (pluginFile && !normalizePath(pluginFile)) throw new Error(`${label}.metadata.pluginFile must be a safe relative path.`)
158+
if (activate !== undefined && typeof activate !== "boolean") throw new Error(`${label}.metadata.activate must be boolean when provided.`)
159+
return { slug, loadAs: metadata.loadAs, ...(pluginFile ? { pluginFile } : {}), ...(activate === undefined ? {} : { activate }) }
160+
}
161+
if (role === "provider_plugin") {
162+
if (slug && !SLUG.test(slug)) throw new Error(`${label}.metadata.slug must be a stable plugin slug.`)
163+
if (pluginFile && !normalizePath(pluginFile)) throw new Error(`${label}.metadata.pluginFile must be a safe relative path.`)
164+
if (activate !== undefined && typeof activate !== "boolean") throw new Error(`${label}.metadata.activate must be boolean when provided.`)
165+
return { ...(slug ? { slug } : {}), ...(pluginFile ? { pluginFile } : {}), ...(activate === undefined ? { activate: true } : { activate }) }
166+
}
167+
if (!SLUG.test(string(metadata.library)) || !SLUG.test(string(metadata.strategy))) throw new Error(`${label}.metadata must declare library and strategy for a bundled library.`)
168+
const target = string(metadata.target)
169+
if (target && (!target.startsWith("/") || target.split("/").includes(".."))) throw new Error(`${label}.metadata.target must be an absolute path without traversal.`)
170+
return { library: string(metadata.library), strategy: string(metadata.strategy), ...(target ? { target } : {}) }
171+
}
172+
173+
export function sha256GitArchiveV1(bytes) {
174+
return `sha256-git-archive-v1:${createHash("sha256").update(bytes).digest("hex")}`
175+
}
176+
177+
export async function materializeRuntimeSources(sources, options = {}) {
178+
const descriptors = normalizeRuntimeSources(sources, options.policy)
179+
const root = await mkdtemp(join(options.tempRoot ?? tmpdir(), "wp-codebox-runtime-sources-"))
180+
try {
181+
const lowered = []
182+
for (const [index, descriptor] of descriptors.entries()) {
183+
const checkout = join(root, `source-${index}`)
184+
const source = join(checkout, "source")
185+
const environment = publicGitEnvironment(root)
186+
const remote = options.remotes?.[descriptor.repository] ?? canonicalPublicGithubRepositorySource(descriptor.repository)
187+
await run("git", ["init", "--quiet", checkout], { env: environment })
188+
await run("git", ["remote", "add", "origin", remote], { cwd: checkout, env: environment })
189+
await run("git", ["-c", "credential.helper=", "-c", "http.extraHeader=", "fetch", "--depth=1", "origin", descriptor.revision], { cwd: checkout, env: environment })
190+
const commit = (await run("git", ["rev-parse", "FETCH_HEAD^{commit}"], { cwd: checkout, env: environment })).toString("utf8").trim().toLowerCase()
191+
if (commit !== descriptor.revision) throw new Error("Runtime source revision did not resolve to the requested immutable commit.")
192+
const objectType = (await run("git", ["cat-file", "-t", `${descriptor.revision}:${descriptor.path}`], { cwd: checkout, env: environment })).toString("utf8").trim()
193+
if (objectType !== "tree") throw new Error("Runtime source path must identify a directory.")
194+
const entries = (await run("git", ["ls-tree", "-r", "-z", descriptor.revision, "--", descriptor.path], { cwd: checkout, env: environment })).toString("utf8").split("\0").filter(Boolean)
195+
if (entries.length === 0 || entries.some((entry) => !/^100(?:644|755)\s+blob\s+[0-9a-f]{40}\t/.test(entry))) throw new Error("Runtime source must contain only regular files; symlinks and special files are rejected.")
196+
const archive = await run("git", ["archive", "--format=tar", descriptor.revision, descriptor.path], { cwd: checkout, env: environment })
197+
if (descriptor.digest && sha256GitArchiveV1(archive) !== descriptor.digest) throw new Error("Runtime source archive digest does not match the trusted descriptor.")
198+
await mkdir(source, { recursive: true })
199+
const archivePath = join(checkout, "source.tar")
200+
await writeFile(archivePath, archive)
201+
await run("tar", ["-xf", archivePath, "-C", source], { cwd: checkout, env: environment })
202+
const materializedPath = join(source, descriptor.path)
203+
lowered.push(lowerRuntimeSource(descriptor, materializedPath))
204+
}
205+
return { root, descriptors, lowered }
206+
} catch (error) { await rm(root, { recursive: true, force: true }); throw error }
207+
}
208+
209+
export function lowerRuntimeSource(descriptor, source) {
210+
const provenance = { role: descriptor.role, repository: descriptor.repository, revision: descriptor.revision, path: descriptor.path, ...(descriptor.digest ? { digest: descriptor.digest } : {}) }
211+
if (descriptor.role === "component") return { component_contracts: [{ path: source, ...descriptor.metadata, metadata: { runtime_source: provenance } }] }
212+
if (descriptor.role === "provider_plugin") return { provider_plugin_paths: [source], provider_plugins: [{ source, ...descriptor.metadata, metadata: { runtime_source: provenance } }] }
213+
return { runtime_overlays: [{ kind: "bundled-library", source, ...descriptor.metadata, metadata: { runtime_source: provenance } }] }
101214
}
102215

103216
function run(command, args, options = {}) {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ on:
1313
- "package-lock.json"
1414
- "package.json"
1515
- "tests/agent-task-*.test.ts"
16+
- "tests/runtime-sources-materialization.test.ts"
1617
- "tests/redaction.test.ts"
1718
- "tests/production-boundary-enforcement.test.ts"
1819
- "tests/runtime-tool-policy.test.ts"
@@ -28,6 +29,7 @@ on:
2829
- "package-lock.json"
2930
- "package.json"
3031
- "tests/agent-task-*.test.ts"
32+
- "tests/runtime-sources-materialization.test.ts"
3133
- "tests/redaction.test.ts"
3234
- "tests/production-boundary-enforcement.test.ts"
3335
- "tests/runtime-tool-policy.test.ts"

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ name: Run Agent Task (reusable)
1111
description: "JSON descriptor for exactly one standalone .agent.json file: repository, immutable revision, file path, and sha256-bytes-v1 digest."
1212
type: string
1313
required: true
14+
runtime_sources:
15+
description: Versioned JSON array of trusted immutable public runtime source descriptors.
16+
type: string
17+
default: '[]'
1418
workload_id:
1519
description: Stable workload identifier for this run.
1620
type: string
@@ -219,6 +223,7 @@ jobs:
219223
id: plan
220224
env:
221225
EXTERNAL_PACKAGE_SOURCE: ${{ inputs.external_package_source }}
226+
RUNTIME_SOURCES: ${{ inputs.runtime_sources }}
222227
EXTERNAL_PACKAGE_SOURCE_POLICY: ${{ secrets.EXTERNAL_PACKAGE_SOURCE_POLICY }}
223228
WORKLOAD_ID: ${{ inputs.workload_id }}
224229
WORKLOAD_LABEL: ${{ inputs.workload_label }}

contracts/agent-task-workflow-request.fixture.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"path": "packages/example-agent.agent.json",
77
"digest": "sha256-bytes-v1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
88
},
9+
"runtime_sources": [],
910
"workload": {
1011
"id": "example-maintenance",
1112
"label": "Run example maintenance",

contracts/run-agent-task-reusable-workflow-interface.v1.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"inputs": {
55
"wp_codebox_release_ref": { "required": true, "type": "string" },
66
"external_package_source": { "required": true, "type": "string" },
7+
"runtime_sources": { "required": false, "type": "string", "default": "[]" },
78
"workload_id": { "required": false, "type": "string", "default": "agent-task" },
89
"workload_label": { "required": false, "type": "string", "default": "Run Agent Task" },
910
"component_id": { "required": false, "type": "string", "default": "agent-task" },

docs/agent-task-reusable-workflow.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ This release-coherence contract fixes [#1759](https://github.com/Automattic/wp-c
5555
- `wp_codebox_release_ref`: required exact immutable WP Codebox release tag in `vX.Y.Z` form.
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.
58+
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.
5860
- `target_repo`: `OWNER/REPO` target repository.
5961
- `prompt`, `writable_paths`, provider/model, `max_turns`, `time_budget_ms`, callback data, and artifact declarations: native task inputs.
6062
- `runner_workspace`: JSON runner-workspace publication request owned by the package.

0 commit comments

Comments
 (0)