Skip to content

Commit 54c2f9a

Browse files
authored
Materialize external native agent packages (#1754)
* Materialize external native agent packages * Harden external native package sources * Harden standalone external agent imports * Secure private native package transport * Clean private package transport on signals * Scope external packages to public Git sources * Bind external packages to declared agent identity * Align external packages with flat agent contract * Preserve runtime package workflow contract
1 parent 6782703 commit 54c2f9a

25 files changed

Lines changed: 687 additions & 309 deletions

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

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

34
function parseJson(name, fallback, expected) {
45
const raw = process.env[name]
@@ -65,7 +66,7 @@ function commandList(name) {
6566
const artifactDeclarations = parseJson("ARTIFACT_DECLARATIONS", [], "array")
6667
const request = {
6768
schema: "wp-codebox/agent-task-workflow-request/v1",
68-
agent_bundle: requiredString("AGENT_BUNDLE"),
69+
external_package_source: normalizeExternalPackageSource(parseJson("EXTERNAL_PACKAGE_SOURCE", {}, "object"), parseExternalPackageSourcePolicy(requiredString("EXTERNAL_PACKAGE_SOURCE_POLICY"))),
6970
workload: {
7071
id: process.env.WORKLOAD_ID || "agent-task",
7172
label: process.env.WORKLOAD_LABEL || "Run Agent Task",
@@ -120,7 +121,6 @@ if (!request.access.allowed_repos.includes(request.target_repo)) {
120121
if (!request.access.access_token_repos.includes(request.target_repo)) {
121122
throw new Error("ACCESS_TOKEN_REPOS must explicitly include TARGET_REPO.")
122123
}
123-
124124
const runId = `${request.workload.id}-${process.env.GITHUB_RUN_ID || "local"}`.replace(/[^A-Za-z0-9._-]+/g, "-")
125125
mkdirSync(".codebox", { recursive: true })
126126
writeFileSync(".codebox/agent-task-request.json", `${JSON.stringify(request, null, 2)}\n`)

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

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"
2-
import { basename, join, resolve } from "node:path"
2+
import { join, resolve } from "node:path"
33
import { spawn } from "node:child_process"
4+
import { materializeExternalNativePackage, normalizeExternalPackageSource, parseExternalPackageSourcePolicy } from "./materialize-external-native-package.mjs"
45

56
const requestPath = process.env.AGENT_TASK_REQUEST_PATH || ".codebox/agent-task-request.json"
67
const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd())
@@ -9,8 +10,7 @@ const codeboxCliPath = process.env.WP_CODEBOX_CLI_PATH || join(codeboxRoot, "pac
910
const outputPath = process.env.GITHUB_OUTPUT
1011
const MAX_CAPTURE_BYTES = 32768
1112
const MAX_OUTPUT_CHARS = 8192
12-
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"].map((name) => process.env[name]).filter(Boolean)
13-
13+
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)
1414
function redact(value) {
1515
if (typeof value === "string") return secretValues.reduce((output, secret) => output.split(secret).join("[REDACTED]"), value)
1616
if (Array.isArray(value)) return value.map(redact)
@@ -183,8 +183,8 @@ const request = JSON.parse(await readFile(requestPath, "utf8"))
183183
const verificationCommands = commandEntries(request.verification_commands, "verification_commands")
184184
const driftChecks = commandEntries(request.drift_checks, "drift_checks")
185185
const runId = `${request.workload?.id || "agent-task"}-${process.env.GITHUB_RUN_ID || "local"}`.replace(/[^A-Za-z0-9._-]+/g, "-")
186-
const packageSlug = basename(string(request.agent_bundle).replace(/\/+$/, ""))
187-
const runtimePackageSource = `/workspace/${basename(workspace)}/${string(request.agent_bundle).replace(/^\/+/, "")}`
186+
const externalPackagePolicy = parseExternalPackageSourcePolicy(string(process.env.EXTERNAL_PACKAGE_SOURCE_POLICY))
187+
const externalPackageSource = normalizeExternalPackageSource(request.external_package_source, externalPackagePolicy)
188188
const artifactsPath = join(workspace, ".codebox", "agent-task-artifacts")
189189
const runtimeInputPath = join(workspace, ".codebox", "native-agent-task-input.json")
190190
const resultPath = join(workspace, ".codebox", "agent-task-workflow-result.json")
@@ -205,6 +205,10 @@ if (accessError) {
205205
process.exit()
206206
}
207207

208+
const materializedPackage = request.run_agent && !request.dry_run
209+
? await materializeExternalNativePackage(externalPackageSource, { policy: externalPackagePolicy })
210+
: undefined
211+
208212
const taskInput = {
209213
schema: "wp-codebox/agent-task-run-request/v1",
210214
task_id: runId,
@@ -216,7 +220,6 @@ const taskInput = {
216220
target: { kind: "repo", materialization: { root: workspace } },
217221
expected_artifacts: request.artifacts?.expected || [],
218222
structured_artifacts: request.artifacts?.declarations || [],
219-
agent_bundles: [{ slug: packageSlug, source: runtimePackageSource }],
220223
provider: request.model?.provider,
221224
model: request.model?.name,
222225
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]),
@@ -233,8 +236,13 @@ const taskInput = {
233236
ability: "wp-codebox/run-runtime-package",
234237
input: {
235238
schema: "wp-codebox/runtime-package-task/v1",
236-
package: { slug: packageSlug, source: runtimePackageSource },
237-
workflow: { id: packageSlug },
239+
package: {
240+
slug: materializedPackage?.identity.slug || "external-agent-pending-materialization",
241+
source: "public-external-package",
242+
external_source: externalPackageSource,
243+
bootstrap: materializedPackage ? { encoding: "base64", bytes: materializedPackage.bytes.toString("base64"), digest: externalPackageSource.digest } : undefined,
244+
},
245+
workflow: { id: "agents/chat" },
238246
input: {
239247
prompt: request.prompt,
240248
runner_workspace: { ...record(request.runner_workspace), allowed_repos: request.access.allowed_repos },
@@ -245,7 +253,7 @@ const taskInput = {
245253
artifact_declarations: request.artifacts?.declarations || [],
246254
required_artifacts: request.artifacts?.expected || [],
247255
output_projections: [],
248-
metadata: { workload: request.workload },
256+
metadata: { workload: request.workload, ...(materializedPackage ? { imported_agent: materializedPackage.identity } : {}) },
249257
},
250258
},
251259
},
@@ -258,6 +266,9 @@ if (request.run_agent && !request.dry_run) {
258266
execution = await command("node", [codeboxCliPath, "agent-task-run", "--input-file", runtimeInputPath, "--json"], workspace, agentEnvironment())
259267
}
260268

269+
// Public package bytes are embedded in the runtime recipe and consumed only by
270+
// the Playground bootstrap before the agent's tools are resolved.
271+
261272
let runtimeResult = {}
262273
try {
263274
runtimeResult = redact(JSON.parse(execution.stdout))
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { createHash } from "node:crypto"
2+
import { mkdtemp, rm } from "node:fs/promises"
3+
import { tmpdir } from "node:os"
4+
import { join } from "node:path"
5+
import { spawn } from "node:child_process"
6+
7+
const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/
8+
const COMMIT = /^[0-9a-f]{40}$/i
9+
const DIGEST = /^[0-9a-f]{64}$/i
10+
const DIGEST_SCHEME = "sha256-bytes-v1"
11+
const MAX_PACKAGE_BYTES = 1024 * 1024
12+
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
13+
const string = (value) => typeof value === "string" ? value.trim() : ""
14+
15+
export function canonicalExternalNativeAgentIdentity(bytes) {
16+
let packageDocument
17+
try {
18+
packageDocument = JSON.parse(Buffer.from(bytes).toString("utf8"))
19+
} catch {
20+
throw new Error("External native package must contain valid UTF-8 JSON.")
21+
}
22+
if (!packageDocument || typeof packageDocument !== "object" || Array.isArray(packageDocument) || packageDocument.schema_version !== 1 || !SLUG.test(packageDocument.bundle_slug)) {
23+
throw new Error("External native package must use the canonical flat schema_version 1 and bundle_slug contract.")
24+
}
25+
const agent = packageDocument.agent
26+
if (!agent || typeof agent !== "object" || Array.isArray(agent) || typeof agent.agent_slug !== "string" || !SLUG.test(agent.agent_slug)) {
27+
throw new Error("External native package must declare exactly one canonical agent.agent_slug identity.")
28+
}
29+
if (["slug", "agent_slug", "package_slug", "agents"].some((field) => Object.hasOwn(packageDocument, field)) || Object.hasOwn(agent, "slug")) {
30+
throw new Error("External native package contains ambiguous agent identities.")
31+
}
32+
return { slug: agent.agent_slug }
33+
}
34+
35+
function normalizePath(value) {
36+
const path = value.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")
37+
if (!path || path.split("/").some((part) => !part || part === "." || part === "..")) throw new Error("external_package_source.path must be a non-empty relative path without traversal.")
38+
return path
39+
}
40+
41+
export function sha256BytesV1(bytes) {
42+
return `${DIGEST_SCHEME}:${createHash("sha256").update(bytes).digest("hex")}`
43+
}
44+
45+
export function canonicalPublicGithubRepositorySource(repository) {
46+
const normalized = string(repository).toLowerCase()
47+
if (!REPOSITORY.test(normalized)) throw new Error("external_package_source.repository must be an OWNER/REPO identifier.")
48+
return `https://github.com/${normalized}.git`
49+
}
50+
51+
function normalizeDigest(value) {
52+
const [scheme, digest] = string(value).split(":", 2)
53+
if (scheme !== DIGEST_SCHEME || !DIGEST.test(digest)) throw new Error(`external_package_source.digest must use ${DIGEST_SCHEME}:<64 lowercase hexadecimal characters>.`)
54+
return `${scheme}:${digest.toLowerCase()}`
55+
}
56+
57+
export function normalizeExternalPackageSource(value, policy = {}) {
58+
const source = value && typeof value === "object" && !Array.isArray(value) ? value : {}
59+
const repository = string(source.repository).toLowerCase()
60+
const revision = string(source.revision).toLowerCase()
61+
const path = normalizePath(string(source.path))
62+
const digest = normalizeDigest(source.digest)
63+
if (!REPOSITORY.test(repository)) throw new Error("external_package_source.repository must be an OWNER/REPO identifier.")
64+
if (!COMMIT.test(revision)) throw new Error("external_package_source.revision must be a full immutable 40-character commit SHA.")
65+
if (!path.endsWith(".agent.json")) throw new Error("external_package_source.path must identify exactly one standalone .agent.json file.")
66+
const allowedPaths = policy.repositories?.[repository]
67+
if (!Array.isArray(allowedPaths)) throw new Error("External package repository is not authorized.")
68+
if (!allowedPaths.some((entry) => {
69+
const pattern = string(entry)
70+
if (!pattern) return false
71+
if (pattern.endsWith("/*")) return path.startsWith(`${normalizePath(pattern.slice(0, -2))}/`)
72+
return path === normalizePath(pattern)
73+
})) throw new Error("External package path is not authorized.")
74+
return { repository, revision, path, digest }
75+
}
76+
77+
export function parseExternalPackageSourcePolicy(raw) {
78+
let policy
79+
try {
80+
policy = JSON.parse(string(raw))
81+
} catch {
82+
throw new Error("EXTERNAL_PACKAGE_SOURCE_POLICY must be valid JSON.")
83+
}
84+
if (!policy || typeof policy !== "object" || Array.isArray(policy) || policy.version !== 1 || !policy.repositories || typeof policy.repositories !== "object" || Array.isArray(policy.repositories)) {
85+
throw new Error("EXTERNAL_PACKAGE_SOURCE_POLICY must be a version 1 policy with a repositories mapping.")
86+
}
87+
const repositories = {}
88+
for (const [repository, paths] of Object.entries(policy.repositories)) {
89+
const normalizedRepository = string(repository).toLowerCase()
90+
if (!REPOSITORY.test(normalizedRepository) || !Array.isArray(paths) || paths.length === 0) throw new Error("EXTERNAL_PACKAGE_SOURCE_POLICY contains an invalid repository entry.")
91+
repositories[normalizedRepository] = paths.map((path) => {
92+
const value = string(path)
93+
if (!value || value.includes("*")) throw new Error("EXTERNAL_PACKAGE_SOURCE_POLICY paths must be exact standalone .agent.json paths.")
94+
const agentPath = normalizePath(value)
95+
if (!agentPath.endsWith(".agent.json")) throw new Error("EXTERNAL_PACKAGE_SOURCE_POLICY paths must identify standalone .agent.json files.")
96+
return agentPath
97+
})
98+
}
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 }
101+
}
102+
103+
function run(command, args, options = {}) {
104+
return new Promise((resolveRun, reject) => {
105+
const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: options.stdio ?? ["ignore", "pipe", "pipe"] })
106+
const stdout = []; const stderr = []
107+
child.stdout?.on("data", (chunk) => stdout.push(chunk)); child.stderr?.on("data", (chunk) => stderr.push(chunk))
108+
child.on("error", reject)
109+
if (options.input) child.stdin?.end(options.input)
110+
child.on("close", (code) => code === 0 ? resolveRun(Buffer.concat(stdout)) : reject(new Error(`${command} failed: ${Buffer.concat(stderr).toString("utf8").trim()}`)))
111+
})
112+
}
113+
114+
export function publicGitEnvironment(home) {
115+
return {
116+
PATH: process.env.PATH || "",
117+
HOME: home,
118+
XDG_CONFIG_HOME: join(home, "config"),
119+
GIT_CONFIG_GLOBAL: "/dev/null",
120+
GIT_CONFIG_NOSYSTEM: "1",
121+
GIT_TERMINAL_PROMPT: "0",
122+
GIT_ASKPASS: "/bin/false",
123+
}
124+
}
125+
126+
export async function materializeExternalNativePackage(source, options = {}) {
127+
const descriptor = normalizeExternalPackageSource(source, options.policy)
128+
const root = await mkdtemp(join(options.tempRoot ?? tmpdir(), "wp-codebox-native-package-")); const checkout = join(root, "checkout")
129+
try {
130+
// Source acquisition is always an unauthenticated public Git transport. The
131+
// optional remote exists only for hermetic transport tests; workflow callers
132+
// always use the canonical GitHub HTTPS origin.
133+
const remote = options.remote ?? canonicalPublicGithubRepositorySource(descriptor.repository)
134+
const environment = publicGitEnvironment(root)
135+
await run("git", ["init", "--quiet", checkout], { env: environment }); await run("git", ["remote", "add", "origin", remote], { cwd: checkout, env: environment })
136+
await run("git", ["-c", "credential.helper=", "-c", "http.extraHeader=", "fetch", "--depth=1", "origin", descriptor.revision], { cwd: checkout, env: environment })
137+
const commit = (await run("git", ["rev-parse", "FETCH_HEAD^{commit}"], { cwd: checkout, env: environment })).toString("utf8").trim().toLowerCase()
138+
if (commit !== descriptor.revision) throw new Error("External package revision did not resolve to the requested immutable commit.")
139+
const objectType = (await run("git", ["cat-file", "-t", `${descriptor.revision}:${descriptor.path}`], { cwd: checkout, env: environment })).toString("utf8").trim()
140+
if (objectType !== "blob") throw new Error("External native package source must identify a standalone .agent.json file, not a directory or package envelope.")
141+
const bytes = await run("git", ["show", `${descriptor.revision}:${descriptor.path}`], { cwd: checkout, env: environment })
142+
if (bytes.length === 0 || bytes.length > MAX_PACKAGE_BYTES) throw new Error("External native package source must be between 1 byte and 1 MiB.")
143+
if (sha256BytesV1(bytes) !== descriptor.digest) throw new Error("External package byte digest does not match the trusted descriptor.")
144+
const identity = canonicalExternalNativeAgentIdentity(bytes)
145+
await rm(root, { recursive: true, force: true })
146+
return { bytes, descriptor, identity }
147+
} catch (error) { await rm(root, { recursive: true, force: true }); throw error }
148+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const MAX_UPLOAD_FILE_BYTES = 4 * 1024 * 1024
77
const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd())
88
const uploadPath = resolve(process.env.AGENT_TASK_UPLOAD_PATH || join(workspace, ".codebox", "agent-task-upload"))
99
const requestPath = resolve(process.env.AGENT_TASK_REQUEST_PATH || join(workspace, ".codebox", "agent-task-request.json"))
10-
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"].map((name) => process.env[name]).filter(Boolean)
10+
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)
1111

1212
function redact(value) {
1313
return secretValues.reduce((output, secret) => output.split(secret).join("[REDACTED]"), value)

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ name: Run Agent Task (reusable)
33
'on':
44
workflow_call:
55
inputs:
6-
agent_bundle:
7-
description: Repository-relative path to the agent bundle selected by the caller.
6+
external_package_source:
7+
description: "JSON descriptor for exactly one standalone .agent.json file: repository, immutable revision, file path, and sha256-bytes-v1 digest."
88
type: string
99
required: true
1010
workload_id:
@@ -125,8 +125,11 @@ name: Run Agent Task (reusable)
125125
MODEL_PROVIDER_SECRET_5:
126126
required: false
127127
ACCESS_TOKEN:
128-
description: GitHub token with access to every repository in access_token_repos.
128+
description: GitHub token for target-repository publication only; it is never used to fetch external packages.
129129
required: false
130+
EXTERNAL_PACKAGE_SOURCE_POLICY:
131+
description: Trusted versioned JSON policy authorizing external native package repositories and exact standalone .agent.json paths.
132+
required: true
130133
outputs:
131134
job_status:
132135
value: ${{ jobs.run-agent-task.outputs.job_status }}
@@ -203,7 +206,8 @@ jobs:
203206
- name: Build Codebox task request
204207
id: plan
205208
env:
206-
AGENT_BUNDLE: ${{ inputs.agent_bundle }}
209+
EXTERNAL_PACKAGE_SOURCE: ${{ inputs.external_package_source }}
210+
EXTERNAL_PACKAGE_SOURCE_POLICY: ${{ secrets.EXTERNAL_PACKAGE_SOURCE_POLICY }}
207211
WORKLOAD_ID: ${{ inputs.workload_id }}
208212
WORKLOAD_LABEL: ${{ inputs.workload_label }}
209213
COMPONENT_ID: ${{ inputs.component_id }}
@@ -252,6 +256,7 @@ jobs:
252256
# environment variable. It is never written to task inputs or artifacts.
253257
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN || github.token }}
254258
ACCESS_TOKEN_CONFIGURED: ${{ secrets.ACCESS_TOKEN != '' }}
259+
EXTERNAL_PACKAGE_SOURCE_POLICY: ${{ secrets.EXTERNAL_PACKAGE_SOURCE_POLICY }}
255260
run: node .wp-codebox-workflow/.github/scripts/run-agent-task/execute-native-agent-task.mjs
256261

257262
- name: Prepare Codebox task uploads
@@ -268,6 +273,7 @@ jobs:
268273
MODEL_PROVIDER_SECRET_4: ${{ secrets.MODEL_PROVIDER_SECRET_4 }}
269274
MODEL_PROVIDER_SECRET_5: ${{ secrets.MODEL_PROVIDER_SECRET_5 }}
270275
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN || github.token }}
276+
EXTERNAL_PACKAGE_SOURCE_POLICY: ${{ secrets.EXTERNAL_PACKAGE_SOURCE_POLICY }}
271277

272278
- name: Upload Codebox task request
273279
if: always()

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
{
22
"schema": "wp-codebox/agent-task-workflow-request/v1",
3-
"agent_bundle": "bundles/example-agent",
3+
"external_package_source": {
4+
"repository": "automattic/example-agent-packages",
5+
"revision": "0123456789abcdef0123456789abcdef01234567",
6+
"path": "packages/example-agent.agent.json",
7+
"digest": "sha256-bytes-v1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
8+
},
49
"workload": {
510
"id": "example-maintenance",
611
"label": "Run example maintenance",

0 commit comments

Comments
 (0)