diff --git a/.github/scripts/run-agent-task/build-codebox-task-request.mjs b/.github/scripts/run-agent-task/build-codebox-task-request.mjs index ccb5446ed..b597a7964 100644 --- a/.github/scripts/run-agent-task/build-codebox-task-request.mjs +++ b/.github/scripts/run-agent-task/build-codebox-task-request.mjs @@ -1,5 +1,5 @@ import { mkdirSync, writeFileSync, appendFileSync } from "node:fs" -import { normalizeExternalPackageSource, parseExternalPackageSourcePolicy } from "./materialize-external-native-package.mjs" +import { normalizeExternalPackageSource, normalizeRuntimeSources, parseExternalPackageSourcePolicy } from "./materialize-external-native-package.mjs" function parseJson(name, fallback, expected) { const raw = process.env[name] @@ -64,9 +64,11 @@ function commandList(name) { } const artifactDeclarations = parseJson("ARTIFACT_DECLARATIONS", [], "array") +const externalPackagePolicy = parseExternalPackageSourcePolicy(requiredString("EXTERNAL_PACKAGE_SOURCE_POLICY")) const request = { schema: "wp-codebox/agent-task-workflow-request/v1", - external_package_source: normalizeExternalPackageSource(parseJson("EXTERNAL_PACKAGE_SOURCE", {}, "object"), parseExternalPackageSourcePolicy(requiredString("EXTERNAL_PACKAGE_SOURCE_POLICY"))), + external_package_source: normalizeExternalPackageSource(parseJson("EXTERNAL_PACKAGE_SOURCE", {}, "object"), externalPackagePolicy), + runtime_sources: normalizeRuntimeSources(parseJson("RUNTIME_SOURCES", [], "array"), externalPackagePolicy), workload: { id: process.env.WORKLOAD_ID || "agent-task", label: process.env.WORKLOAD_LABEL || "Run Agent Task", diff --git a/.github/scripts/run-agent-task/execute-native-agent-task.mjs b/.github/scripts/run-agent-task/execute-native-agent-task.mjs index e1b3e0228..b34c2dd71 100644 --- a/.github/scripts/run-agent-task/execute-native-agent-task.mjs +++ b/.github/scripts/run-agent-task/execute-native-agent-task.mjs @@ -1,7 +1,8 @@ +import { rmSync } from "node:fs" import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises" import { join, resolve } from "node:path" import { spawn } from "node:child_process" -import { materializeExternalNativePackage, normalizeExternalPackageSource, parseExternalPackageSourcePolicy } from "./materialize-external-native-package.mjs" +import { materializeExternalNativePackage, materializeRuntimeSources, normalizeExternalPackageSource, normalizeRuntimeSources, parseExternalPackageSourcePolicy } from "./materialize-external-native-package.mjs" import { readNativeResult } from "./native-result-file.mjs" const requestPath = process.env.AGENT_TASK_REQUEST_PATH || ".codebox/agent-task-request.json" @@ -12,6 +13,7 @@ const outputPath = process.env.GITHUB_OUTPUT const MAX_CAPTURE_BYTES = 32768 const MAX_OUTPUT_CHARS = 8192 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) +const PRIVATE_RUNTIME_PATH_FIELDS = new Set(["source", "path", "sourceRoot", "originalSource", "preparedPath", "requestedPath", "source_package_root", "artifacts_path", "runtime_input_path", "task_path", "result_path", "event_stream_path", "materialization_result_path"]) function redact(value) { if (typeof value === "string") return secretValues.reduce((output, secret) => output.split(secret).join("[REDACTED]"), value) if (Array.isArray(value)) return value.map(redact) @@ -94,6 +96,27 @@ function record(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {} } +function isPrivateRuntimePath(value) { + if (!privateRuntimeSourceRoot || typeof value !== "string") return false + const path = resolve(value) + return path === privateRuntimeSourceRoot || path.startsWith(`${privateRuntimeSourceRoot}/`) +} + +function omitPrivateRuntimeSourcePaths(value) { + if (Array.isArray(value)) return value.map(omitPrivateRuntimeSourcePaths) + if (!value || typeof value !== "object") return value + return Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => { + if (PRIVATE_RUNTIME_PATH_FIELDS.has(key) && isPrivateRuntimePath(entry)) return [] + return [[key, omitPrivateRuntimeSourcePaths(entry)]] + })) +} + +function assertNoPrivateRuntimePaths(value) { + if (privateRuntimeSourceRoot && JSON.stringify(value).includes(privateRuntimeSourceRoot)) { + throw new Error("Runtime source paths must never be persisted in workflow results or artifacts.") + } +} + function string(value) { return typeof value === "string" ? value.trim() : "" } @@ -189,11 +212,25 @@ const driftChecks = commandEntries(request.drift_checks, "drift_checks") const runId = `${request.workload?.id || "agent-task"}-${process.env.GITHUB_RUN_ID || "local"}`.replace(/[^A-Za-z0-9._-]+/g, "-") const externalPackagePolicy = parseExternalPackageSourcePolicy(string(process.env.EXTERNAL_PACKAGE_SOURCE_POLICY)) const externalPackageSource = normalizeExternalPackageSource(request.external_package_source, externalPackagePolicy) +const runtimeSources = normalizeRuntimeSources(request.runtime_sources ?? [], externalPackagePolicy) const artifactsPath = join(workspace, ".codebox", "agent-task-artifacts") const runtimeInputPath = join(workspace, ".codebox", "native-agent-task-input.json") const resultPath = join(workspace, ".codebox", "agent-task-workflow-result.json") const controlledCodeboxPath = resolve(requestPath, "..") const nativeResultPath = join(controlledCodeboxPath, "native-agent-task-result.json") +let privateRuntimeSourceRoot = "" +let cleaningPrivateRuntimeSources = false +async function cleanupPrivateRuntimeSources() { + if (cleaningPrivateRuntimeSources || !privateRuntimeSourceRoot) return + cleaningPrivateRuntimeSources = true + const root = privateRuntimeSourceRoot + privateRuntimeSourceRoot = "" + await rm(root, { recursive: true, force: true }) +} +process.once("exit", () => { if (privateRuntimeSourceRoot) rmSync(privateRuntimeSourceRoot, { recursive: true, force: true }) }) +for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { + process.once(signal, () => { cleanupPrivateRuntimeSources().finally(() => process.exit(128)) }) +} const runnerWorkspaceTools = [ "workspace-read", "workspace-ls", "workspace-grep", "workspace-write", "workspace-edit", "workspace-apply-patch", "workspace-git-status", "workspace-git-diff", "workspace-git-add", "workspace-git-commit", "workspace-git-push", @@ -220,11 +257,22 @@ if (accessError) { const materializedPackage = request.run_agent && !request.dry_run ? await materializeExternalNativePackage(externalPackageSource, { policy: externalPackagePolicy }) : undefined +const materializedRuntimeSources = request.run_agent && !request.dry_run + ? await materializeRuntimeSources(runtimeSources, { policy: externalPackagePolicy, forbiddenRoots: [workspace, artifactsPath] }) + : undefined +privateRuntimeSourceRoot = materializedRuntimeSources?.root ?? "" +const runtimeSourceInputs = (materializedRuntimeSources?.lowered ?? []).reduce((input, lowered) => { + for (const [key, entries] of Object.entries(lowered)) input[key] = [...(input[key] ?? []), ...entries] + return input +}, {}) +const sourcePackageRoot = privateRuntimeSourceRoot ? join(privateRuntimeSourceRoot, "prepared-packages") : artifactsPath +const executionInputPath = privateRuntimeSourceRoot ? join(privateRuntimeSourceRoot, "native-agent-task-input.json") : runtimeInputPath const taskInput = { schema: "wp-codebox/agent-task-run-request/v1", task_id: runId, - artifacts_path: artifactsPath, + artifacts_path: artifactsPath, + source_package_root: sourcePackageRoot, callback_data: record(request.callback_data), task_input: { schema: "wp-codebox/task-input/v1", @@ -235,6 +283,7 @@ const taskInput = { provider: request.model?.provider, model: request.model?.name, 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]), + ...runtimeSourceInputs, allowed_tools: runnerWorkspaceTools, sandbox_tool_policy: { schema: "wp-codebox/sandbox-tool-policy/v1", @@ -265,26 +314,30 @@ const taskInput = { artifact_declarations: request.artifacts?.declarations || [], required_artifacts: request.artifacts?.expected || [], output_projections: [], - metadata: { workload: request.workload, ...(materializedPackage ? { imported_agent: materializedPackage.identity } : {}) }, + metadata: { workload: request.workload, ...(materializedPackage ? { imported_agent: materializedPackage.identity } : {}), runtime_sources: materializedRuntimeSources?.descriptors ?? [] }, }, }, }, } -await writeFile(runtimeInputPath, `${JSON.stringify(taskInput, null, 2)}\n`) +await writeFile(executionInputPath, `${JSON.stringify(taskInput, null, 2)}\n`) let execution = { code: 0, stdout: "", stderr: "", stdout_truncated: false, stderr_truncated: false } if (request.run_agent && !request.dry_run) { - execution = await command("node", [codeboxCliPath, "agent-task-run", "--input-file", runtimeInputPath, "--result-file", nativeResultPath], workspace, agentEnvironment()) + execution = await command("node", [codeboxCliPath, "agent-task-run", "--input-file", executionInputPath, "--result-file", nativeResultPath], workspace, agentEnvironment()) } // Public package bytes are embedded in the runtime recipe and consumed only by // the Playground bootstrap before the agent's tools are resolved. -const runtimeResult = request.run_agent && !request.dry_run +const nativeRuntimeResult = request.run_agent && !request.dry_run ? await readNativeResult(nativeResultPath, controlledCodeboxPath, secretValues, redact) : {} await rm(nativeResultPath, { force: true }) +assertNoPrivateRuntimePaths(nativeRuntimeResult) +const runtimeResult = omitPrivateRuntimeSourcePaths(nativeRuntimeResult) +assertNoPrivateRuntimePaths(runtimeResult) +await cleanupPrivateRuntimeSources() await redactArtifactFiles(artifactsPath) diff --git a/.github/scripts/run-agent-task/materialize-external-native-package.mjs b/.github/scripts/run-agent-task/materialize-external-native-package.mjs index 6fb2fa07a..b43af8dc1 100644 --- a/.github/scripts/run-agent-task/materialize-external-native-package.mjs +++ b/.github/scripts/run-agent-task/materialize-external-native-package.mjs @@ -1,7 +1,7 @@ import { createHash } from "node:crypto" -import { mkdtemp, rm } from "node:fs/promises" +import { mkdtemp, rm, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" -import { join } from "node:path" +import { isAbsolute, join, relative, resolve } from "node:path" import { spawn } from "node:child_process" const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/ @@ -10,6 +10,13 @@ const DIGEST = /^[0-9a-f]{64}$/i const DIGEST_SCHEME = "sha256-bytes-v1" const MAX_PACKAGE_BYTES = 1024 * 1024 const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ +const ROLE = /^(component|provider_plugin|bundled_library)$/ +const HTTPS_URL = /^https:\/\//i +const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024 +const MAX_ZIP_ENTRIES = 10_000 +const MAX_ZIP_UNCOMPRESSED_BYTES = 128 * 1024 * 1024 +const MAX_ZIP_FILE_BYTES = 32 * 1024 * 1024 +const DOWNLOAD_TIMEOUT_MS = 30_000 const string = (value) => typeof value === "string" ? value.trim() : "" export function canonicalExternalNativeAgentIdentity(bytes) { @@ -38,6 +45,11 @@ function normalizePath(value) { return path } +function normalizeRuntimePath(value) { + if (string(value) === ".") return "." + return normalizePath(value) +} + export function sha256BytesV1(bytes) { return `${DIGEST_SCHEME}:${createHash("sha256").update(bytes).digest("hex")}` } @@ -96,18 +108,334 @@ export function parseExternalPackageSourcePolicy(raw) { return agentPath }) } - if (Object.keys(repositories).length === 0) throw new Error("EXTERNAL_PACKAGE_SOURCE_POLICY must authorize at least one repository.") - return { version: 1, repositories } + const runtime_sources = normalizeRuntimeSourcePolicy(policy.runtime_sources) + const runtime_artifacts = normalizeRuntimeArtifactPolicy(policy.runtime_artifacts) + if (Object.keys(repositories).length === 0 && Object.keys(runtime_sources).length === 0 && Object.keys(runtime_artifacts).length === 0) throw new Error("EXTERNAL_PACKAGE_SOURCE_POLICY must authorize at least one source.") + return { version: 1, repositories, runtime_sources, runtime_artifacts } +} + +function normalizeRuntimeArtifactPolicy(value) { + if (value === undefined) return {} + if (!Array.isArray(value)) throw new Error("EXTERNAL_PACKAGE_SOURCE_POLICY runtime_artifacts must be an array.") + const artifacts = {} + for (const entry of value) { + const artifact = entry && typeof entry === "object" && !Array.isArray(entry) ? entry : {} + const url = normalizeHttpsUrl(artifact.url, "EXTERNAL_PACKAGE_SOURCE_POLICY runtime_artifacts.url") + const sha256 = normalizeSha256(artifact.sha256, "EXTERNAL_PACKAGE_SOURCE_POLICY runtime_artifacts.sha256") + if (Object.hasOwn(artifacts, url)) throw new Error("EXTERNAL_PACKAGE_SOURCE_POLICY runtime_artifacts must not repeat URLs.") + artifacts[url] = sha256 + } + return artifacts +} + +function normalizeRuntimeSourcePolicy(value) { + if (value === undefined) return {} + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("EXTERNAL_PACKAGE_SOURCE_POLICY runtime_sources must be a repositories mapping.") + const repositories = {} + for (const [repository, paths] of Object.entries(value)) { + const normalizedRepository = string(repository).toLowerCase() + if (!REPOSITORY.test(normalizedRepository) || !Array.isArray(paths) || paths.length === 0) throw new Error("EXTERNAL_PACKAGE_SOURCE_POLICY contains an invalid runtime source repository entry.") + repositories[normalizedRepository] = paths.map((path) => normalizeRuntimePath(string(path))) + } + return repositories +} + +export function normalizeRuntimeSources(value, policy = {}) { + if (!Array.isArray(value)) throw new Error("runtime_sources must be a JSON array.") + const sources = value.map((entry, index) => normalizeRuntimeSource(entry, policy, `runtime_sources[${index}]`)) + const pluginSlugs = new Set() + const overlayTargets = new Set() + for (const source of sources) { + if (source.role === "bundled_library") { + const target = source.metadata.target || `${source.metadata.library}/${source.metadata.strategy}` + if (overlayTargets.has(target)) throw new Error(`runtime_sources contains a colliding bundled library target: ${target}`) + overlayTargets.add(target) + continue + } + const slug = source.metadata.slug + if (pluginSlugs.has(slug)) throw new Error(`runtime_sources contains a duplicate plugin slug: ${slug}`) + pluginSlugs.add(slug) + } + return sources +} + +export function normalizeRuntimeSource(value, policy = {}, label = "runtime_source") { + const source = value && typeof value === "object" && !Array.isArray(value) ? value : {} + if (source.version !== 1) throw new Error(`${label}.version must be 1.`) + const role = string(source.role) + const sourceDescriptor = source.source && typeof source.source === "object" && !Array.isArray(source.source) ? source.source : undefined + if (sourceDescriptor?.type === "https_zip") return normalizeHttpsZipRuntimeSource(source, sourceDescriptor, policy, label) + const repository = string(source.repository).toLowerCase() + const revision = string(source.revision).toLowerCase() + const path = normalizeRuntimePath(string(source.path)) + if (!ROLE.test(role)) throw new Error(`${label}.role must be component, provider_plugin, or bundled_library.`) + if (!REPOSITORY.test(repository)) throw new Error(`${label}.repository must be an OWNER/REPO identifier.`) + if (!COMMIT.test(revision)) throw new Error(`${label}.revision must be a full immutable 40-character commit SHA.`) + const allowedPaths = policy.runtime_sources?.[repository] + if (!Array.isArray(allowedPaths) || !allowedPaths.includes(path)) throw new Error("Runtime source repository or path is not authorized.") + const digest = source.digest === undefined || source.digest === "" ? undefined : normalizeRuntimeDigest(source.digest, label) + const metadata = source.metadata && typeof source.metadata === "object" && !Array.isArray(source.metadata) ? source.metadata : {} + const normalized = { version: 1, role, repository, revision, path, ...(digest ? { digest } : {}), metadata: normalizeRuntimeMetadata(role, metadata, label) } + return normalized +} + +function normalizeHttpsZipRuntimeSource(source, artifact, policy, label) { + const role = string(source.role) + if (!/^(component|provider_plugin)$/.test(role)) throw new Error(`${label}.source https_zip supports component and provider_plugin roles.`) + if (Object.keys(source).some((key) => ["repository", "revision", "path", "digest"].includes(key))) throw new Error(`${label}.source https_zip must not mix Git source fields.`) + const url = normalizeHttpsUrl(artifact.url, `${label}.source.url`) + const sha256 = normalizeSha256(artifact.sha256, `${label}.source.sha256`) + const archive_root = artifact.archive_root === undefined ? "" : normalizePath(string(artifact.archive_root)) + if (Object.keys(artifact).some((key) => !["type", "url", "sha256", "archive_root"].includes(key))) throw new Error(`${label}.source https_zip contains an unsupported field.`) + const expectedDigest = policy.runtime_artifacts?.[url] + if (expectedDigest === undefined) throw new Error("Runtime artifact URL is not authorized.") + if (expectedDigest !== sha256) throw new Error("Runtime artifact digest does not match its trusted policy.") + const metadata = source.metadata && typeof source.metadata === "object" && !Array.isArray(source.metadata) ? source.metadata : {} + return { version: 1, role, source: { type: "https_zip", url, sha256, ...(archive_root ? { archive_root } : {}) }, metadata: normalizeRuntimeMetadata(role, metadata, label) } +} + +function normalizeHttpsUrl(value, label) { + const url = string(value) + if (!HTTPS_URL.test(url)) throw new Error(`${label} must be an HTTPS URL.`) + let parsed + try { parsed = new URL(url) } catch { throw new Error(`${label} must be an HTTPS URL.`) } + if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.hash) throw new Error(`${label} must be a canonical HTTPS URL without credentials or fragments.`) + return parsed.toString() +} + +function normalizeSha256(value, label) { + const digest = string(value).toLowerCase() + if (!DIGEST.test(digest)) throw new Error(`${label} must be exactly 64 hexadecimal SHA-256 characters.`) + return digest +} + +function normalizeRuntimeDigest(value, label) { + const [scheme, digest] = string(value).split(":", 2) + if (scheme !== "sha256-git-archive-v1" || !DIGEST.test(digest)) throw new Error(`${label}.digest must use sha256-git-archive-v1:<64 lowercase hexadecimal characters>.`) + return `${scheme}:${digest.toLowerCase()}` +} + +function normalizeRuntimeMetadata(role, metadata, label) { + const slug = string(metadata.slug) + const pluginFile = string(metadata.pluginFile) + const activate = metadata.activate + if (role === "component") { + 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.`) + if (pluginFile && !normalizePath(pluginFile)) throw new Error(`${label}.metadata.pluginFile must be a safe relative path.`) + if (activate !== undefined && typeof activate !== "boolean") throw new Error(`${label}.metadata.activate must be boolean when provided.`) + return { slug, loadAs: metadata.loadAs, ...(pluginFile ? { pluginFile } : {}), ...(activate === undefined ? {} : { activate }) } + } + if (role === "provider_plugin") { + if (slug && !SLUG.test(slug)) throw new Error(`${label}.metadata.slug must be a stable plugin slug.`) + if (pluginFile && !normalizePath(pluginFile)) throw new Error(`${label}.metadata.pluginFile must be a safe relative path.`) + if (activate !== undefined && typeof activate !== "boolean") throw new Error(`${label}.metadata.activate must be boolean when provided.`) + return { ...(slug ? { slug } : {}), ...(pluginFile ? { pluginFile } : {}), ...(activate === undefined ? { activate: true } : { activate }) } + } + 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.`) + const target = string(metadata.target) + if (target && (!target.startsWith("/") || target.split("/").includes(".."))) throw new Error(`${label}.metadata.target must be an absolute path without traversal.`) + return { library: string(metadata.library), strategy: string(metadata.strategy), ...(target ? { target } : {}) } +} + +export function sha256GitArchiveV1(bytes) { + return `sha256-git-archive-v1:${createHash("sha256").update(bytes).digest("hex")}` +} + +export async function materializeRuntimeSources(sources, options = {}) { + const descriptors = normalizeRuntimeSources(sources, options.policy) + const root = await mkdtemp(join(options.tempRoot ?? tmpdir(), "wp-codebox-runtime-sources-")) + try { + assertPrivateRuntimeRoot(root, options.forbiddenRoots) + const lowered = [] + for (const [index, descriptor] of descriptors.entries()) { + if (descriptor.source?.type === "https_zip") { + const materializedPath = await materializeHttpsZipRuntimeSource(descriptor, root, index, options) + await assertRuntimePluginEntrypoint(descriptor, materializedPath) + lowered.push(lowerRuntimeSource(descriptor, materializedPath)) + continue + } + const checkout = join(root, `source-${index}`) + const source = join(checkout, "source") + const environment = publicGitEnvironment(root) + const remote = options.remotes?.[descriptor.repository] ?? canonicalPublicGithubRepositorySource(descriptor.repository) + await run("git", ["init", "--quiet", checkout], { env: environment }) + await run("git", ["remote", "add", "origin", remote], { cwd: checkout, env: environment }) + await run("git", ["-c", "credential.helper=", "-c", "http.extraHeader=", "fetch", "--depth=1", "origin", descriptor.revision], { cwd: checkout, env: environment }) + const commit = (await run("git", ["rev-parse", "FETCH_HEAD^{commit}"], { cwd: checkout, env: environment })).toString("utf8").trim().toLowerCase() + if (commit !== descriptor.revision) throw new Error("Runtime source revision did not resolve to the requested immutable commit.") + const sourceObject = descriptor.path === "." ? `${descriptor.revision}^{tree}` : `${descriptor.revision}:${descriptor.path}` + const objectType = (await run("git", ["cat-file", "-t", sourceObject], { cwd: checkout, env: environment })).toString("utf8").trim() + if (objectType !== "tree") throw new Error("Runtime source path must identify a directory.") + const entries = (await run("git", descriptor.path === "." ? ["ls-tree", "-r", "-z", descriptor.revision] : ["ls-tree", "-r", "-z", descriptor.revision, "--", descriptor.path], { cwd: checkout, env: environment })).toString("utf8").split("\0").filter(Boolean) + 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.") + const archive = await run("git", descriptor.path === "." ? ["archive", "--format=tar", descriptor.revision] : ["archive", "--format=tar", descriptor.revision, descriptor.path], { cwd: checkout, env: environment }) + if (descriptor.digest && sha256GitArchiveV1(archive) !== descriptor.digest) throw new Error("Runtime source archive digest does not match the trusted descriptor.") + await mkdir(source, { recursive: true }) + const archivePath = join(checkout, "source.tar") + await writeFile(archivePath, archive) + await run("tar", ["-xf", archivePath, "-C", source], { cwd: checkout, env: environment }) + const materializedPath = descriptor.path === "." ? source : join(source, descriptor.path) + if (descriptor.role !== "bundled_library") await assertRuntimePluginEntrypoint(descriptor, materializedPath) + lowered.push(lowerRuntimeSource(descriptor, materializedPath)) + } + return { root, descriptors: descriptors.map(runtimeSourceProvenance), lowered } + } catch (error) { await rm(root, { recursive: true, force: true }); throw error } +} + +async function materializeHttpsZipRuntimeSource(descriptor, root, index, options) { + const checkout = join(root, `source-${index}`) + const archive = await fetchTrustedZip(descriptor.source.url, options.policy, options.fetch) + if (archive.length === 0 || archive.length > MAX_ARTIFACT_BYTES) throw new Error("Runtime ZIP artifact exceeds the bounded download size.") + if (createHash("sha256").update(archive).digest("hex") !== descriptor.source.sha256) throw new Error("Runtime ZIP artifact digest does not match the descriptor.") + const entries = inspectZipArchive(archive, descriptor.source.archive_root) + const extractionRoot = join(checkout, "source") + await mkdir(extractionRoot, { recursive: true }) + const archivePath = join(checkout, "source.zip") + await writeFile(archivePath, archive) + for (const entry of entries) { + const destination = join(extractionRoot, entry.name) + if (entry.directory) { + await mkdir(destination, { recursive: true }) + continue + } + await mkdir(join(destination, ".."), { recursive: true }) + const contents = await run("unzip", ["-p", archivePath, entry.name], { maxOutputBytes: entry.uncompressedSize + 1 }) + if (contents.length !== entry.uncompressedSize) throw new Error("Runtime ZIP artifact entry size did not match its verified central directory.") + await writeFile(destination, contents) + } + return join(extractionRoot, descriptor.source.archive_root || entries[0].name.split("/")[0]) +} + +async function fetchTrustedZip(url, policy, fetchImpl = fetch) { + let current = url + for (let redirects = 0; redirects < 3; redirects++) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS) + let response + try { response = await fetchImpl(current, { redirect: "manual", signal: controller.signal }) } finally { clearTimeout(timer) } + if (response.status >= 300 && response.status < 400) { + const next = response.headers.get("location") + if (!next) throw new Error("Runtime ZIP artifact redirect was missing a location.") + current = new URL(next, current).toString() + if (policy.runtime_artifacts?.[current] === undefined || new URL(current).protocol !== "https:" || new URL(current).host !== new URL(url).host) throw new Error("Runtime ZIP artifact redirect is not an allowlisted HTTPS URL on the original host.") + continue + } + if (!response.ok || !response.body) throw new Error(`Runtime ZIP artifact download failed with HTTP ${response.status}.`) + const length = Number(response.headers.get("content-length") || 0) + if (length > MAX_ARTIFACT_BYTES) throw new Error("Runtime ZIP artifact exceeds the bounded download size.") + const chunks = []; let size = 0 + for await (const chunk of response.body) { + size += chunk.length + if (size > MAX_ARTIFACT_BYTES) throw new Error("Runtime ZIP artifact exceeds the bounded download size.") + chunks.push(chunk) + } + return Buffer.concat(chunks) + } + throw new Error("Runtime ZIP artifact exceeded the redirect limit.") +} + +export function inspectZipArchive(bytes, expectedRoot = "") { + const directory = [] + const endOfCentralDirectory = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])) + if (endOfCentralDirectory < 0 || endOfCentralDirectory + 22 > bytes.length) throw new Error("Runtime ZIP artifact is missing a valid central directory.") + const entriesCount = bytes.readUInt16LE(endOfCentralDirectory + 10) + const centralDirectorySize = bytes.readUInt32LE(endOfCentralDirectory + 12) + const centralDirectoryOffset = bytes.readUInt32LE(endOfCentralDirectory + 16) + if (entriesCount === 0xffff || centralDirectorySize === 0xffffffff || centralDirectoryOffset === 0xffffffff || centralDirectoryOffset + centralDirectorySize > endOfCentralDirectory) throw new Error("Runtime ZIP artifact contains unsupported ZIP64 entries.") + const centralDirectoryEnd = centralDirectoryOffset + centralDirectorySize + for (let offset = centralDirectoryOffset; offset < centralDirectoryEnd;) { + if (offset + 46 > centralDirectoryEnd || bytes.readUInt32LE(offset) !== 0x02014b50) throw new Error("Runtime ZIP artifact central directory is malformed.") + const flags = bytes.readUInt16LE(offset + 8) + const compressedSize = bytes.readUInt32LE(offset + 20) + const uncompressedSize = bytes.readUInt32LE(offset + 24) + const nameLength = bytes.readUInt16LE(offset + 28) + const extraLength = bytes.readUInt16LE(offset + 30) + const commentLength = bytes.readUInt16LE(offset + 32) + const externalAttributes = bytes.readUInt32LE(offset + 38) + const end = offset + 46 + nameLength + extraLength + commentLength + if (end > centralDirectoryEnd || flags & 1 || compressedSize === 0xffffffff || uncompressedSize === 0xffffffff) throw new Error("Runtime ZIP artifact contains unsupported encrypted or ZIP64 entries.") + const name = bytes.subarray(offset + 46, offset + 46 + nameLength).toString("utf8") + const directoryEntry = name.endsWith("/") + const mode = externalAttributes >>> 16 + const type = mode & 0o170000 + if (!safeZipEntryName(name) || (!directoryEntry && type && type !== 0o100000) || (directoryEntry && type && type !== 0o040000)) throw new Error("Runtime ZIP artifact contains a traversal, symlink, or special-file entry.") + if (uncompressedSize > MAX_ZIP_FILE_BYTES) throw new Error("Runtime ZIP artifact contains an oversized entry.") + directory.push({ name: directoryEntry ? name.slice(0, -1) : name, directory: directoryEntry, uncompressedSize }) + offset = end + } + if (directory.length !== entriesCount || directory.length === 0 || directory.length > MAX_ZIP_ENTRIES) throw new Error("Runtime ZIP artifact has an invalid number of entries.") + if (directory.reduce((total, entry) => total + entry.uncompressedSize, 0) > MAX_ZIP_UNCOMPRESSED_BYTES) throw new Error("Runtime ZIP artifact exceeds the bounded extraction size.") + const roots = new Set(directory.map((entry) => entry.name.split("/")[0]).filter(Boolean)) + if (roots.size !== 1) throw new Error("Runtime ZIP artifact must contain exactly one archive root.") + const root = [...roots][0] + if (expectedRoot && root !== expectedRoot) throw new Error("Runtime ZIP artifact archive root does not match the descriptor.") + return directory +} + +function safeZipEntryName(name) { + const normalized = name.endsWith("/") ? name.slice(0, -1) : name + return Boolean(normalized) && !normalized.includes("\\") && !normalized.includes("\0") && !normalized.startsWith("/") && !normalized.split("/").some((part) => !part || part === "." || part === "..") +} + +async function assertRuntimePluginEntrypoint(descriptor, source) { + const entries = await readdir(source, { withFileTypes: true }) + const phpFiles = entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".php")) + .map((entry) => entry.name) + .sort() + const headers = [] + for (const file of phpFiles) { + if (/^[\s\S]{0,8192}?Plugin Name:\s*\S/m.test(await readFile(join(source, file), "utf8"))) headers.push(file) + } + if (headers.length > 1) throw new Error(`Runtime plugin source contains multiple plugin entrypoints: ${headers.join(", ")}`) + const declared = descriptor.metadata.pluginFile ? pluginFileWithinSource(descriptor.metadata.pluginFile, descriptor.metadata.slug) : "" + const fallback = phpFiles.includes(`${descriptor.metadata.slug}.php`) ? `${descriptor.metadata.slug}.php` : phpFiles.includes("plugin.php") ? "plugin.php" : headers[0] + const entrypoint = declared || fallback + if (!entrypoint) throw new Error(`Runtime plugin source does not contain a plugin entrypoint for slug ${descriptor.metadata.slug}; top-level entries: ${entries.map((entry) => entry.name).sort().join(", ") || "none"}`) + try { + if (!(await stat(join(source, entrypoint))).isFile()) throw new Error() + } catch { + throw new Error(`Runtime plugin source does not contain declared plugin entrypoint ${entrypoint}`) + } +} + +function pluginFileWithinSource(pluginFile, slug) { + const normalized = normalizePath(pluginFile) + return normalized.startsWith(`${slug}/`) ? normalized.slice(slug.length + 1) : normalized +} + +export function runtimeSourceProvenance(descriptor) { + if (descriptor.source?.type === "https_zip") return { role: descriptor.role, source: { type: "https_zip", url: descriptor.source.url, sha256: descriptor.source.sha256, ...(descriptor.source.archive_root ? { archive_root: descriptor.source.archive_root } : {}) } } + return { role: descriptor.role, repository: descriptor.repository, revision: descriptor.revision, path: descriptor.path, ...(descriptor.digest ? { digest: descriptor.digest } : {}) } +} + +export function assertPrivateRuntimeRoot(root, forbiddenRoots = []) { + const privateRoot = resolve(root) + for (const forbiddenRoot of forbiddenRoots) { + if (!forbiddenRoot) continue + const boundary = resolve(forbiddenRoot) + const path = relative(boundary, privateRoot) + if (privateRoot === boundary || (!path.startsWith(`..${String.fromCharCode(47)}`) && path !== ".." && !isAbsolute(path))) { + throw new Error("Runtime sources must be materialized outside target workspaces and artifacts.") + } + } +} + +export function lowerRuntimeSource(descriptor, source) { + const provenance = runtimeSourceProvenance(descriptor) + if (descriptor.role === "component") return { component_contracts: [{ path: source, ...descriptor.metadata, metadata: { runtime_source: provenance } }] } + if (descriptor.role === "provider_plugin") return { provider_plugin_paths: [source], provider_plugins: [{ source, ...descriptor.metadata, metadata: { runtime_source: provenance } }] } + return { runtime_overlays: [{ kind: "bundled-library", source, ...descriptor.metadata, metadata: { runtime_source: provenance } }] } } function run(command, args, options = {}) { return new Promise((resolveRun, reject) => { const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: options.stdio ?? ["ignore", "pipe", "pipe"] }) - const stdout = []; const stderr = [] - child.stdout?.on("data", (chunk) => stdout.push(chunk)); child.stderr?.on("data", (chunk) => stderr.push(chunk)) + const stdout = []; const stderr = []; let outputBytes = 0; let overflow = false + child.stdout?.on("data", (chunk) => { outputBytes += chunk.length; if (options.maxOutputBytes && outputBytes > options.maxOutputBytes) { overflow = true; child.kill() } else stdout.push(chunk) }); child.stderr?.on("data", (chunk) => stderr.push(chunk)) child.on("error", reject) if (options.input) child.stdin?.end(options.input) - child.on("close", (code) => code === 0 ? resolveRun(Buffer.concat(stdout)) : reject(new Error(`${command} failed: ${Buffer.concat(stderr).toString("utf8").trim()}`))) + child.on("close", (code) => overflow ? reject(new Error(`${command} exceeded the bounded output size.`)) : code === 0 ? resolveRun(Buffer.concat(stdout)) : reject(new Error(`${command} failed: ${Buffer.concat(stderr).toString("utf8").trim()}`))) }) } diff --git a/.github/scripts/run-agent-task/prepare-agent-task-upload.mjs b/.github/scripts/run-agent-task/prepare-agent-task-upload.mjs index c0e63d491..3e506f635 100644 --- a/.github/scripts/run-agent-task/prepare-agent-task-upload.mjs +++ b/.github/scripts/run-agent-task/prepare-agent-task-upload.mjs @@ -1,5 +1,5 @@ import { constants } from "node:fs" -import { lstat, mkdir, open, readdir, rm, writeFile } from "node:fs/promises" +import { lstat, mkdir, open, readdir, readFile, rm, writeFile } from "node:fs/promises" import { isUtf8 } from "node:buffer" import { join, resolve } from "node:path" @@ -8,12 +8,41 @@ const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd()) const uploadPath = resolve(process.env.AGENT_TASK_UPLOAD_PATH || join(workspace, ".codebox", "agent-task-upload")) const requestPath = resolve(process.env.AGENT_TASK_REQUEST_PATH || join(workspace, ".codebox", "agent-task-request.json")) 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) +const runtimeSourceRoot = process.env.WP_CODEBOX_RUNTIME_SOURCE_ROOT ? resolve(process.env.WP_CODEBOX_RUNTIME_SOURCE_ROOT) : "" function redact(value) { return secretValues.reduce((output, secret) => output.split(secret).join("[REDACTED]"), value) } +const PRIVATE_RUNTIME_PATH_FIELDS = new Set(["source", "path", "sourceRoot", "originalSource", "preparedPath", "requestedPath", "source_package_root", "artifacts_path", "runtime_input_path", "task_path", "result_path", "event_stream_path", "materialization_result_path"]) + +function isPrivateRuntimePath(value) { + if (!runtimeSourceRoot || typeof value !== "string") return false + const path = resolve(value) + return path === runtimeSourceRoot || path.startsWith(`${runtimeSourceRoot}/`) +} + +function omitPrivateRuntimeSourcePaths(value) { + if (Array.isArray(value)) return value.map(omitPrivateRuntimeSourcePaths) + if (!value || typeof value !== "object") return value + return Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => { + if (PRIVATE_RUNTIME_PATH_FIELDS.has(key) && isPrivateRuntimePath(entry)) return [] + return [[key, omitPrivateRuntimeSourcePaths(entry)]] + })) +} + +function sanitizeText(text) { + try { + return `${JSON.stringify(omitPrivateRuntimeSourcePaths(JSON.parse(text)), null, 2)}\n` + } catch { + return text + } +} + async function stageFile(source, destination) { + if (isPrivateRuntimePath(source)) { + throw new Error("Runtime source files must never be staged for artifact upload.") + } const metadata = await lstat(source).catch(() => null) if (!metadata?.isFile() || metadata.size > MAX_UPLOAD_FILE_BYTES) return false const handle = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW).catch(() => null) @@ -23,7 +52,10 @@ async function stageFile(source, destination) { await handle.close() if (!contents || contents.includes(0) || !isUtf8(contents)) return false await mkdir(resolve(destination, ".."), { recursive: true }) - await writeFile(destination, redact(contents.toString("utf8"))) + let text = contents.toString("utf8") + text = sanitizeText(text) + if (runtimeSourceRoot && text.includes(runtimeSourceRoot)) throw new Error("Runtime source paths must never be persisted in artifact uploads.") + await writeFile(destination, redact(text)) return true } @@ -38,6 +70,17 @@ async function stageDirectory(source, destination) { } } +async function assertNoPrivateRuntimePaths(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + if (entry.isDirectory()) await assertNoPrivateRuntimePaths(path) + else if (entry.isFile()) { + const contents = await readFile(path, "utf8") + if (runtimeSourceRoot && contents.includes(runtimeSourceRoot)) throw new Error("Runtime source paths must never be persisted in artifact uploads.") + } + } +} + await rm(uploadPath, { recursive: true, force: true }) await mkdir(uploadPath, { recursive: true }) await stageFile(requestPath, join(uploadPath, ".codebox", "agent-task-request.json")) @@ -45,3 +88,4 @@ for (const path of [".codebox/agent-task-workflow-result.json", ".codebox/native await stageFile(join(workspace, path), join(uploadPath, path)) } await stageDirectory(join(workspace, ".codebox", "agent-task-artifacts"), join(uploadPath, ".codebox", "agent-task-artifacts")) +await assertNoPrivateRuntimePaths(uploadPath) diff --git a/.github/workflows/agent-task-contracts.yml b/.github/workflows/agent-task-contracts.yml index d62dd65e4..f67f1adf6 100644 --- a/.github/workflows/agent-task-contracts.yml +++ b/.github/workflows/agent-task-contracts.yml @@ -10,9 +10,12 @@ on: - "contracts/run-agent-task-reusable-workflow-interface.v1.json" - "docs/agent-task-reusable-workflow.md" - "fixtures/agent-task-reusable-workflow-consumer*.yml" + - "fixtures/agent-task-runtime-sources-run-29299109269.json" - "package-lock.json" - "package.json" - "tests/agent-task-*.test.ts" + - "tests/runtime-sources-materialization.test.ts" + - "tests/runtime-sources-playground-integration.test.ts" - "tests/redaction.test.ts" - "tests/production-boundary-enforcement.test.ts" - "tests/runtime-tool-policy.test.ts" @@ -25,9 +28,12 @@ on: - "contracts/run-agent-task-reusable-workflow-interface.v1.json" - "docs/agent-task-reusable-workflow.md" - "fixtures/agent-task-reusable-workflow-consumer*.yml" + - "fixtures/agent-task-runtime-sources-run-29299109269.json" - "package-lock.json" - "package.json" - "tests/agent-task-*.test.ts" + - "tests/runtime-sources-materialization.test.ts" + - "tests/runtime-sources-playground-integration.test.ts" - "tests/redaction.test.ts" - "tests/production-boundary-enforcement.test.ts" - "tests/runtime-tool-policy.test.ts" @@ -46,6 +52,9 @@ jobs: - run: npm ci - run: npm run build - run: npm run test:agent-task-contracts + - run: npm run test:runtime-sources-playground-integration + env: + WP_CODEBOX_RUN_NETWORK_INTEGRATION: "1" - run: npm run test:redaction - run: npm run test:production-boundary-enforcement - run: npm run test:runtime-tool-policy diff --git a/.github/workflows/run-agent-task.yml b/.github/workflows/run-agent-task.yml index ae91c8ac0..a6983064c 100644 --- a/.github/workflows/run-agent-task.yml +++ b/.github/workflows/run-agent-task.yml @@ -11,6 +11,10 @@ name: Run Agent Task (reusable) description: "JSON descriptor for exactly one standalone .agent.json file: repository, immutable revision, file path, and sha256-bytes-v1 digest." type: string required: true + runtime_sources: + description: Versioned JSON array of trusted immutable public runtime source descriptors. + type: string + default: '[]' workload_id: description: Stable workload identifier for this run. type: string @@ -219,6 +223,7 @@ jobs: id: plan env: EXTERNAL_PACKAGE_SOURCE: ${{ inputs.external_package_source }} + RUNTIME_SOURCES: ${{ inputs.runtime_sources }} EXTERNAL_PACKAGE_SOURCE_POLICY: ${{ secrets.EXTERNAL_PACKAGE_SOURCE_POLICY }} WORKLOAD_ID: ${{ inputs.workload_id }} WORKLOAD_LABEL: ${{ inputs.workload_label }} @@ -278,6 +283,7 @@ jobs: AGENT_TASK_WORKSPACE: ${{ github.workspace }}/workspace AGENT_TASK_REQUEST_PATH: ${{ github.workspace }}/.codebox/agent-task-request.json AGENT_TASK_UPLOAD_PATH: ${{ github.workspace }}/workspace/.codebox/agent-task-upload + WP_CODEBOX_RUNTIME_SOURCE_ROOT: ${{ runner.temp }}/wp-codebox-runtime-sources- OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} MODEL_PROVIDER_SECRET_1: ${{ secrets.MODEL_PROVIDER_SECRET_1 }} MODEL_PROVIDER_SECRET_2: ${{ secrets.MODEL_PROVIDER_SECRET_2 }} diff --git a/contracts/agent-task-workflow-request.fixture.json b/contracts/agent-task-workflow-request.fixture.json index 8e0c8c22f..023392840 100644 --- a/contracts/agent-task-workflow-request.fixture.json +++ b/contracts/agent-task-workflow-request.fixture.json @@ -6,6 +6,7 @@ "path": "packages/example-agent.agent.json", "digest": "sha256-bytes-v1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "runtime_sources": [], "workload": { "id": "example-maintenance", "label": "Run example maintenance", diff --git a/contracts/run-agent-task-reusable-workflow-interface.v1.json b/contracts/run-agent-task-reusable-workflow-interface.v1.json index 238d295c9..0b59d0bbb 100644 --- a/contracts/run-agent-task-reusable-workflow-interface.v1.json +++ b/contracts/run-agent-task-reusable-workflow-interface.v1.json @@ -4,6 +4,7 @@ "inputs": { "wp_codebox_release_ref": { "required": true, "type": "string" }, "external_package_source": { "required": true, "type": "string" }, + "runtime_sources": { "required": false, "type": "string", "default": "[]" }, "workload_id": { "required": false, "type": "string", "default": "agent-task" }, "workload_label": { "required": false, "type": "string", "default": "Run Agent Task" }, "component_id": { "required": false, "type": "string", "default": "agent-task" }, diff --git a/docs/agent-task-reusable-workflow.md b/docs/agent-task-reusable-workflow.md index 5ba4a8093..3c8f9faa9 100644 --- a/docs/agent-task-reusable-workflow.md +++ b/docs/agent-task-reusable-workflow.md @@ -55,6 +55,8 @@ This release-coherence contract fixes [#1759](https://github.com/Automattic/wp-c - `wp_codebox_release_ref`: required exact immutable WP Codebox release tag in `vX.Y.Z` form. - `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:` over the raw file bytes; filenames and JSON content are UTF-8-safe and are not normalized before hashing. - `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. + +`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>"}]}`. Every exact `runtime_artifacts` URL requires `sha256`; the descriptor digest must exactly equal that trusted policy digest, so callers cannot authorize mutable current bytes. 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; removes the staging root after execution; and strips private materialization paths from upload staging before scanning staged files for leaks. Runtime source descriptors lower into existing component, provider plugin, and runtime overlay inputs; artifacts retain provenance only. - `target_repo`: `OWNER/REPO` target repository. - `prompt`, `writable_paths`, provider/model, `max_turns`, `time_budget_ms`, callback data, and artifact declarations: native task inputs. - `runner_workspace`: JSON runner-workspace publication request owned by the package. diff --git a/fixtures/agent-task-runtime-sources-run-29299109269.json b/fixtures/agent-task-runtime-sources-run-29299109269.json new file mode 100644 index 000000000..8f104a35f --- /dev/null +++ b/fixtures/agent-task-runtime-sources-run-29299109269.json @@ -0,0 +1,8 @@ +{ + "run_id": "29299109269", + "runtime_sources": [ + { "version": 1, "role": "component", "repository": "automattic/agents-api", "revision": "59d1e6b473f22498e40e279130bbb4f9bcde3b73", "path": ".", "metadata": { "slug": "agents-api", "loadAs": "mu-plugin", "activate": false } }, + { "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 } }, + { "version": 1, "role": "bundled_library", "repository": "wordpress/php-ai-client", "revision": "631704201d15ffeff7091ad3bc7156db74054956", "path": ".", "metadata": { "library": "php-ai-client", "strategy": "wordpress-scoped-bundle" } } + ] +} diff --git a/package.json b/package.json index f566dee16..8ecf0576e 100644 --- a/package.json +++ b/package.json @@ -85,10 +85,12 @@ "generate:fanout-aggregation-contract": "tsx scripts/generate-fanout-aggregation-contract-fixture.ts", "smoke": "tsx scripts/run-smoke.ts", "test:redaction": "tsx tests/redaction.test.ts", - "test:agent-task-contracts": "tsx tests/agent-task-contracts.test.ts && npm run test:agent-task-workflow-interface && tsx tests/agent-task-reusable-workflow.test.ts", + "test:agent-task-contracts": "tsx tests/agent-task-contracts.test.ts && npm run test:agent-task-workflow-interface && npm run test:runtime-sources-materialization && tsx tests/agent-task-reusable-workflow.test.ts", "test:agent-task-workflow-interface": "tsx tests/run-agent-task-reusable-workflow-interface.test.ts", "test:agent-task-runtime-package-staging": "tsx tests/agent-task-runtime-package-staging.test.ts", "test:external-native-package-materialization": "tsx tests/external-native-package-materialization.test.ts", + "test:runtime-sources-materialization": "tsx tests/runtime-sources-materialization.test.ts", + "test:runtime-sources-playground-integration": "tsx tests/runtime-sources-playground-integration.test.ts", "test:browser-task-builder": "tsx tests/browser-task-builder.test.ts", "test:browser-runtime-generic-invoker": "tsx tests/browser-runtime-generic-invoker.test.ts", "test:browser-runtime-file-ops": "tsx tests/browser-runtime-file-ops.test.ts", diff --git a/packages/runtime-core/src/agent-task-recipe.ts b/packages/runtime-core/src/agent-task-recipe.ts index 28fae9f68..365b1a7d7 100644 --- a/packages/runtime-core/src/agent-task-recipe.ts +++ b/packages/runtime-core/src/agent-task-recipe.ts @@ -26,6 +26,7 @@ export interface AgentTaskRunInput { provider?: string model?: string provider_plugin_paths?: string[] + provider_plugins?: Array> runtime_overlay_profiles?: string[] runtime_env?: Record runtimeEnv?: Record @@ -53,6 +54,7 @@ export interface AgentTaskRunInput { session_id?: string sandbox_session_id?: string artifacts_path?: string + source_package_root?: string wp?: string component_contracts?: Array> runtime_requirements?: Record @@ -85,16 +87,17 @@ export interface RuntimeDependencyPlanContractInput { export function buildAgentTaskRecipe(input: AgentTaskRunInput, taskInput: TaskInput, wpVersion: string): WorkspaceRecipe { const artifacts = stringValue(input.artifacts_path) + const sourcePackageRoot = stringValue(input.source_package_root) || artifacts const profile = runtimeOverlayProfileDefaults(input) const runtimeMounts = runtimeStateMounts(input) const sourceRoots = workspaceSourceRoots(input, taskInput) const runtimeTask = runtimeTaskWithInlineBundle(input.runtime_task, sourceRoots) const effectiveInput = { ...input, runtime_task: runtimeTask } const stagedFiles = stagedRuntimeSources(effectiveInput, taskInput, sourceRoots) - const providerPlugins = providerPluginEntries(input, artifacts) + const providerPlugins = providerPluginEntries(input, sourcePackageRoot) const providerSlugs = providerPlugins.map((plugin) => plugin.slug).join(",") const providerContracts = providerPlugins.map((plugin) => ({ slug: plugin.slug, pluginFile: plugin.pluginFile, loadAs: plugin.loadAs ?? "plugin" })) - const componentPluginEntries = componentPlugins([...runtimeProfileComponentContracts(input), ...(input.component_contracts ?? [])], artifacts) + const componentPluginEntries = componentPlugins([...runtimeProfileComponentContracts(input), ...(input.component_contracts ?? [])], sourcePackageRoot) const callerExtraPlugins = agentTaskExtraPlugins(input) const defaultRuntimeComponents = defaultAgentRuntimeComponentPlugins(componentPluginEntries, callerExtraPlugins) const extraPlugins = dedupeExtraPlugins([ @@ -683,7 +686,8 @@ function agentTaskExtraPlugins(input: AgentTaskRunInput): WorkspaceRecipeExtraPl function providerPluginEntries(input: AgentTaskRunInput, artifactsRoot: string): WorkspaceRecipeExtraPlugin[] { const pathEntries: Array> = stringList(input.provider_plugin_paths).map((source) => ({ source })) const profileEntries = runtimeProfileObjectList(input, "provider_plugins") - return [...profileEntries, ...pathEntries].flatMap((entry) => { + const explicitEntries = objectList(input.provider_plugins) + return [...profileEntries, ...explicitEntries, ...pathEntries].flatMap((entry) => { const source = stringValue(entry.source) || stringValue(entry.path) if (!source) return [] const slug = stringValue(entry.slug) || slugFromComposerPackage(source) || slugFromPath(source) diff --git a/packages/runtime-core/src/component-contracts.ts b/packages/runtime-core/src/component-contracts.ts index d7bce7fd1..2f3afc490 100644 --- a/packages/runtime-core/src/component-contracts.ts +++ b/packages/runtime-core/src/component-contracts.ts @@ -24,7 +24,7 @@ export function resolvePluginEntrypointContract(contract: PluginEntrypointContra const loadAs = contract.loadAs === "mu-plugin" ? "mu-plugin" : "plugin" if (contract.pluginFile) { - return { source, slug, pluginFile: contract.pluginFile, loadAs, fallback: "explicit" } + return { source, slug, pluginFile: canonicalPluginFile(slug, contract.pluginFile), loadAs, fallback: "explicit" } } for (const [name, fallback] of [[`${slug}.php`, "slug"], ["plugin.php", "plugin"]] as const) { @@ -41,6 +41,14 @@ export function resolvePluginEntrypointContract(contract: PluginEntrypointContra return { source, slug, pluginFile: `${slug}/${slug}.php`, loadAs, fallback: "default" } } +function canonicalPluginFile(slug: string, pluginFile: string): string { + const normalized = pluginFile.trim().replace(/\\/g, "/").replace(/^\/+/, "") + if (!normalized || normalized.split("/").some((part) => !part || part === "." || part === "..")) { + throw new Error(`Plugin entrypoint must be a safe path inside ${slug}: ${pluginFile}`) + } + return normalized === slug || normalized.startsWith(`${slug}/`) ? normalized : `${slug}/${normalized}` +} + export function sanitizePluginSlug(value: string): string { return value.trim().replace(/[^A-Za-z0-9_-]/g, "-") } diff --git a/tests/agent-task-reusable-workflow.test.ts b/tests/agent-task-reusable-workflow.test.ts index 49bad17c4..96934d826 100644 --- a/tests/agent-task-reusable-workflow.test.ts +++ b/tests/agent-task-reusable-workflow.test.ts @@ -16,6 +16,7 @@ assert.match(workflow, /^name: Run Agent Task \(reusable\)$/m) assert.match(workflow, /workflow_call:/) assert.match(workflow, /wp_codebox_release_ref:/) assert.match(workflow, /external_package_source:/) +assert.match(workflow, /runtime_sources:/) assert.match(workflow, /EXTERNAL_PACKAGE_SOURCE_POLICY:/) assert.doesNotMatch(publicWorkflowSurface, /external_package_allowed_repositories:|external_package_allowed_paths:/) assert.match(workflow, /runner_workspace:/) @@ -151,6 +152,7 @@ await execFileAsync("node", [new URL("../.github/scripts/run-agent-task/build-co ...process.env, GITHUB_OUTPUT: outputPath, EXTERNAL_PACKAGE_SOURCE: '{"repository":"Automattic/example-agent-packages","revision":"0123456789abcdef0123456789abcdef01234567","path":"packages/example-agent.agent.json","digest":"sha256-bytes-v1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}', + RUNTIME_SOURCES: "[]", EXTERNAL_PACKAGE_SOURCE_POLICY: '{"version":1,"repositories":{"automattic/example-agent-packages":["packages/example-agent.agent.json"]}}', WORKLOAD_ID: "example-maintenance", WORKLOAD_LABEL: "Run example maintenance", diff --git a/tests/runtime-sources-materialization.test.ts b/tests/runtime-sources-materialization.test.ts new file mode 100644 index 000000000..35560061c --- /dev/null +++ b/tests/runtime-sources-materialization.test.ts @@ -0,0 +1,189 @@ +import assert from "node:assert/strict" +import { execFile } from "node:child_process" +import { access, chmod, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises" +import { join, relative } from "node:path" +import { promisify } from "node:util" +import { inspectZipArchive, materializeRuntimeSources, normalizeRuntimeSource, normalizeRuntimeSources, parseExternalPackageSourcePolicy, sha256BytesV1 } from "../.github/scripts/run-agent-task/materialize-external-native-package.mjs" +import { withTempDir } from "../scripts/test-kit.js" +import { buildAgentTaskRecipe } from "../packages/runtime-core/src/agent-task-recipe.js" +import { normalizeTaskInput } from "../packages/runtime-core/src/task-input.js" + +const execFileAsync = promisify(execFile) +const hostedRegression = JSON.parse(await readFile(new URL("../fixtures/agent-task-runtime-sources-run-29299109269.json", import.meta.url), "utf8")) +assert.equal(hostedRegression.run_id, "29299109269") +assert.deepEqual(hostedRegression.runtime_sources.map((source: { role: string }) => source.role), ["component", "provider_plugin", "bundled_library"]) + +await withTempDir("wp-codebox-runtime-sources-", async (repository) => { + for (const path of ["components/runtime", "providers/example", "libraries/client"]) await mkdir(join(repository, path), { recursive: true }) + await writeFile(join(repository, "components/runtime/runtime.php"), " { + for (const [key, entries] of Object.entries(lowered)) (input as Record)[key] = [...((input as Record)[key] ?? []), ...(entries as unknown[])] + return input + }, {} as Record) + const recipe = buildAgentTaskRecipe({ goal: "verify lowering", ...loweredInput }, normalizeTaskInput({ goal: "verify lowering" }), "latest") + assert.ok(recipe.inputs?.extra_plugins?.some((plugin) => plugin.slug === "runtime" && plugin.loadAs === "mu-plugin")) + assert.ok(recipe.inputs?.extra_plugins?.some((plugin) => plugin.slug === "example-provider" && plugin.activate === true)) + assert.equal(recipe.inputs?.extra_plugins?.find((plugin) => plugin.slug === "runtime")?.pluginFile, "runtime/runtime.php") + assert.equal(recipe.inputs?.extra_plugins?.find((plugin) => plugin.slug === "example-provider")?.pluginFile, "example-provider/provider.php") + assert.equal(recipe.runtime?.overlays?.[0].strategy, "scoped-bundle") + await assert.rejects(materializeRuntimeSources([{ ...sources[0], revision: "main" }], { policy, remotes: { "example/runtime": repository } }), /immutable 40-character/) + assert.throws(() => normalizeRuntimeSource({ ...sources[0], version: 2 }, policy), /version must be 1/) + await assert.rejects(materializeRuntimeSources([{ ...sources[0], path: "../components/runtime" }], { policy, remotes: { "example/runtime": repository } }), /without traversal/) + await assert.rejects(materializeRuntimeSources([{ ...sources[0], repository: "other/runtime" }], { policy, remotes: { "example/runtime": repository } }), /not authorized/) + assert.throws(() => normalizeRuntimeSources([sources[0], { ...sources[1], metadata: { slug: "runtime" } }], policy), /duplicate plugin slug/) + assert.throws(() => normalizeRuntimeSources([{ ...sources[0], metadata: { slug: "runtime", loadAs: "mu-plugin", pluginFile: "../runtime.php" } }], policy), /without traversal/) + await assert.rejects(materializeRuntimeSources([{ ...sources[0], digest: `sha256-git-archive-v1:${"0".repeat(64)}` }], { policy, remotes: { "example/runtime": repository } }), /digest does not match/) + assert.throws(() => normalizeRuntimeSource({ ...sources[0], metadata: { slug: "runtime", loadAs: "unknown" } }, policy), /loadAs/) + await symlink("runtime.php", join(repository, "components/runtime/link.php")) + await execFileAsync("git", ["add", "."], { cwd: repository }); await execFileAsync("git", ["commit", "--quiet", "-m", "symlink"], { cwd: repository }) + const symlinkRevision = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: repository })).stdout.trim() + await assert.rejects(materializeRuntimeSources([{ ...sources[0], revision: symlinkRevision }], { policy, remotes: { "example/runtime": repository } }), /symlinks and special files/) + await rm(materialized.root, { recursive: true, force: true }) +}) + +await withTempDir("wp-codebox-runtime-zip-source-", async (directory) => { + const archiveRoot = join(directory, "example-provider") + await mkdir(archiveRoot, { recursive: true }) + await writeFile(join(archiveRoot, "plugin.php"), " new Response(archive) }) + assert.equal(materialized.lowered[0].provider_plugins[0].slug, "example-provider") + assert.deepEqual(materialized.descriptors[0], { role: "provider_plugin", source: { type: "https_zip", url, sha256: digest, archive_root: "example-provider" } }) + await assert.rejects(materializeRuntimeSources([{ ...source, source: { ...source.source, sha256: "0".repeat(64) } }], { policy, fetch: async () => new Response(archive) }), /trusted policy/) + assert.throws(() => parseExternalPackageSourcePolicy(JSON.stringify({ version: 1, repositories: {}, runtime_artifacts: [{ url }] })), /sha256/) + assert.throws(() => normalizeRuntimeSource({ ...source, source: { ...source.source, url: "http://downloads.example.test/provider.zip" } }, policy), /HTTPS/) + await assert.rejects(materializeRuntimeSources([{ ...source, source: { ...source.source, archive_root: "other" } }], { policy, fetch: async () => new Response(archive) }), /archive root/) + assert.throws(() => normalizeRuntimeSource({ ...source, role: "bundled_library" }, policy), /component and provider_plugin/) + const encrypted = Buffer.from(archive) + const centralDirectory = encrypted.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02])) + encrypted.writeUInt16LE(encrypted.readUInt16LE(centralDirectory + 8) | 1, centralDirectory + 8) + assert.throws(() => inspectZipArchive(encrypted), /encrypted/) + const bomb = Buffer.from(archive) + bomb.writeUInt32LE(33 * 1024 * 1024, centralDirectory + 24) + assert.throws(() => inspectZipArchive(bomb), /oversized/) + await symlink("plugin.php", join(archiveRoot, "link.php")) + await execFileAsync("zip", ["-q", "-y", "symlink.zip", "example-provider/link.php"], { cwd: directory }) + const symlinkArchive = await readFile(join(directory, "symlink.zip")) + assert.throws(() => inspectZipArchive(symlinkArchive), /symlink|special-file/) + await rm(materialized.root, { recursive: true, force: true }) +}) + +await withTempDir("wp-codebox-runtime-source-upload-", async (directory) => { + const workspace = join(directory, "workspace") + const artifacts = join(workspace, ".codebox", "agent-task-artifacts") + const upload = join(workspace, ".codebox", "agent-task-upload") + const privateRoot = join(directory, "private-runtime-source") + await mkdir(artifacts, { recursive: true }) + await mkdir(privateRoot, { recursive: true }) + await writeFile(join(privateRoot, "source.php"), " { + const repository = join(directory, "repository") + const workspace = join(directory, "workspace") + const codebox = join(workspace, ".codebox") + const upload = join(codebox, "agent-task-upload") + const tools = join(directory, "tools") + const temp = join(directory, "temp") + await mkdir(join(repository, "plugin"), { recursive: true }) + await mkdir(join(workspace, ".codebox", "agent-task-artifacts"), { recursive: true }) + await mkdir(tools, { recursive: true }) + await mkdir(temp, { recursive: true }) + const packageBytes = Buffer.from('{"schema_version":1,"bundle_slug":"fixture-agent","agent":{"agent_slug":"fixture-agent"}}\n') + await writeFile(join(repository, "fixture.agent.json"), packageBytes) + await writeFile(join(repository, "plugin", "plugin.php"), " value.startsWith("https://github.com/") ? ${JSON.stringify(repository)} : value)\nconst result = spawnSync(${JSON.stringify(gitPath)}, args, { stdio: "inherit" })\nprocess.exit(result.status ?? 1)\n`) + await chmod(join(tools, "git"), 0o755) + await writeFile(join(directory, "fake-cli.mjs"), `import { writeFile } from "node:fs/promises"\nconst result = process.argv[process.argv.indexOf("--result-file") + 1]\nawait writeFile(result, JSON.stringify({ schema: "wp-codebox/agent-task-run/v1", success: true, status: "succeeded", agent_task_run_result: { schema: "wp-codebox/agent-task-run-result/v1", success: true, status: "succeeded" }, outputs: {} }))\n`) + const request = { + workload: { id: "runtime-sources-workflow", label: "Runtime sources workflow" }, + access: { caller_repo: "example/target", allowed_repos: ["example/target"], access_token_repos: ["example/target"] }, + target_repo: "example/target", + run_agent: true, + dry_run: false, + prompt: "Verify private runtime source upload isolation", + external_package_source: { repository: "example/source", revision, path: "fixture.agent.json", digest: sha256BytesV1(packageBytes) }, + runtime_sources: [{ version: 1, role: "provider_plugin", repository: "example/source", revision, path: "plugin", metadata: { slug: "fixture", pluginFile: "plugin.php", activate: true } }], + verification_commands: [], + drift_checks: [], + artifacts: { declarations: [], expected: [] }, + callback_data: {}, + outputs: { projections: {} }, + success: { requires_pr: false }, + } + await writeFile(join(codebox, "agent-task-request.json"), JSON.stringify(request)) + await writeFile(join(codebox, "agent-task-artifacts", "safe.json"), JSON.stringify({ provenance: { repository: "example/source", revision } })) + const environment = { ...process.env, PATH: `${tools}:${process.env.PATH}`, TMPDIR: temp, AGENT_TASK_WORKSPACE: workspace, AGENT_TASK_REQUEST_PATH: join(codebox, "agent-task-request.json"), WP_CODEBOX_CLI_PATH: join(directory, "fake-cli.mjs"), GITHUB_TOKEN: "test-token", EXTERNAL_PACKAGE_SOURCE_POLICY: JSON.stringify({ version: 1, repositories: { "example/source": ["fixture.agent.json"] }, runtime_sources: { "example/source": ["plugin"] } }) } + const executorPath = new URL("../.github/scripts/run-agent-task/execute-native-agent-task.mjs", import.meta.url) + await execFileAsync(process.execPath, [executorPath.pathname], { env: environment }) + await assert.rejects(access(join(codebox, "native-agent-task-input.json")), /ENOENT/, "runtime source native input must remain private") + const uploaderPath = new URL("../.github/scripts/run-agent-task/prepare-agent-task-upload.mjs", import.meta.url) + await execFileAsync(process.execPath, [uploaderPath.pathname], { env: { ...environment, AGENT_TASK_UPLOAD_PATH: upload, WP_CODEBOX_RUNTIME_SOURCE_ROOT: join(temp, "wp-codebox-runtime-sources-") } }) + const privateRuntimePrefix = join(temp, "wp-codebox-runtime-sources-") + for (const path of [".codebox/agent-task-request.json", ".codebox/agent-task-workflow-result.json", ".codebox/agent-task-artifacts/safe.json"]) { + assert.ok(!(await readFile(join(upload, path), "utf8")).includes(privateRuntimePrefix)) + } +}) + +const executor = await readFile(new URL("../.github/scripts/run-agent-task/execute-native-agent-task.mjs", import.meta.url), "utf8") +assert.match(executor, /for \(const signal of \["SIGINT", "SIGTERM", "SIGHUP"\]\)/) +assert.match(executor, /cleanupPrivateRuntimeSources\(\)\.finally\(\(\) => process\.exit\(128\)\)/) +assert.match(executor, /const executionInputPath = privateRuntimeSourceRoot \? join\(privateRuntimeSourceRoot, "native-agent-task-input\.json"\) : runtimeInputPath/) +assert.match(executor, /assertNoPrivateRuntimePaths\(nativeRuntimeResult\)/) + +console.log("runtime sources materialization ok") diff --git a/tests/runtime-sources-playground-integration.test.ts b/tests/runtime-sources-playground-integration.test.ts new file mode 100644 index 000000000..58836e91d --- /dev/null +++ b/tests/runtime-sources-playground-integration.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict" +import { execFile } from "node:child_process" +import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { promisify } from "node:util" +import { materializeRuntimeSources, parseExternalPackageSourcePolicy } from "../.github/scripts/run-agent-task/materialize-external-native-package.mjs" +import { buildAgentTaskRecipe } from "../packages/runtime-core/src/agent-task-recipe.js" +import { normalizeTaskInput } from "../packages/runtime-core/src/task-input.js" + +const execFileAsync = promisify(execFile) + +if (process.env.WP_CODEBOX_RUN_NETWORK_INTEGRATION !== "1") { + console.log("runtime sources Playground integration skipped: set WP_CODEBOX_RUN_NETWORK_INTEGRATION=1; CI enables this pinned-public-source test") + process.exit(0) +} + +const fixture = JSON.parse(await readFile(new URL("../fixtures/agent-task-runtime-sources-run-29299109269.json", import.meta.url), "utf8")) +const policy = parseExternalPackageSourcePolicy(JSON.stringify({ + version: 1, + repositories: {}, + runtime_sources: Object.fromEntries(fixture.runtime_sources.filter((source: { repository?: string }) => source.repository).map((source: { repository: string; path: string }) => [source.repository, [source.path]])), + runtime_artifacts: fixture.runtime_sources.filter((source: { source?: { type?: string } }) => source.source?.type === "https_zip").map((source: { source: { url: string; sha256: string } }) => ({ url: source.source.url, sha256: source.source.sha256 })), +})) +const root = await mkdtemp(join(tmpdir(), "wp-codebox-runtime-sources-playground-")) + +try { + const materialized = await materializeRuntimeSources(fixture.runtime_sources, { policy, tempRoot: root, forbiddenRoots: [join(root, "artifacts")] }) + const privatePackage = join(materialized.root, "flat-runtime-agent.agent.json") + await writeFile(privatePackage, '{"schema_version":1,"bundle_slug":"flat-runtime-agent","agent":{"agent_slug":"flat-runtime-agent"}}\n') + const lowered = materialized.lowered.reduce((input: Record, source: Record) => { + for (const [key, entries] of Object.entries(source)) input[key] = [...(input[key] ?? []), ...entries] + return input + }, {}) + const recipe = buildAgentTaskRecipe({ + goal: "Verify pinned public runtime sources", + artifacts_path: join(materialized.root, "private-artifacts"), + source_package_root: join(materialized.root, "prepared-packages"), + runtime_env: { OPENAI_API_KEY: "dummy-key" }, + stagedFiles: [{ source: privatePackage, target: "/tmp/flat-runtime-agent.agent.json" }], + ...lowered, + }, normalizeTaskInput({ goal: "Verify pinned public runtime sources" }), "latest") + recipe.workflow = { + steps: [{ + command: "wordpress.run-php", + args: ["code=" + String.raw`$imports = wp_agent_import_runtime_bundles( array( array( 'source' => '/tmp/flat-runtime-agent.agent.json', 'slug' => 'flat-runtime-agent', 'on_conflict' => 'upgrade' ) ), array( 'owner_id' => 1 ) ); +if ( ! is_array( $imports ) || empty( $imports[0]['success'] ) ) { throw new RuntimeException( 'Canonical importer did not import the flat package: ' . wp_json_encode( $imports ) ); } +$client = \WordPress\AiClient\AiClient::defaultRegistry(); +$provider_class = 'WordPress\\OpenAiAiProvider\\Provider\\OpenAiProvider'; +$provider_resolved = is_object( $client ) && method_exists( $client, 'hasProvider' ) && class_exists( $provider_class ) && $client->hasProvider( $provider_class ); +echo wp_json_encode( array( 'imported_slug' => $imports[0]['agent_slug'] ?? '', 'agents_registry' => class_exists( 'WP_Agents_Registry' ), 'provider_active' => is_plugin_active( 'ai-provider-for-openai/plugin.php' ), 'provider_class' => $provider_class, 'provider_resolved' => $provider_resolved, 'client' => is_object( $client ) ) );`], + }], + } + const recipePath = join(root, "recipe.json") + await writeFile(recipePath, `${JSON.stringify(recipe)}\n`) + const result = await execFileAsync(process.execPath, ["packages/cli/dist/index.js", "recipe-run", "--recipe", recipePath, "--json"], { + cwd: process.cwd(), + timeout: 300_000, + env: { ...process.env, OPENAI_API_KEY: "dummy-key" }, + maxBuffer: 2 * 1024 * 1024, + }) + const output = JSON.parse(result.stdout) + const stdout = output.executions?.filter((execution: { command?: string }) => execution.command === "wordpress.run-php").at(-1)?.stdout ?? "" + const checks = JSON.parse(stdout) + assert.deepEqual(checks, { imported_slug: "flat-runtime-agent", agents_registry: true, provider_active: true, provider_class: "WordPress\\OpenAiAiProvider\\Provider\\OpenAiProvider", provider_resolved: true, client: true }) + await rm(materialized.root, { recursive: true, force: true }) + await assert.rejects(access(privatePackage), /ENOENT/, "private source package must be removed with its materialization root") + console.log(`runtime sources Playground integration ok: ${JSON.stringify(checks)}`) +} catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (/fetch failed|Could not resolve host|Connection timed out|network is unreachable/i.test(message)) { + console.log(`runtime sources Playground integration skipped: pinned public sources were unreachable (${message})`) + } else { + throw error + } +} finally { + await rm(root, { recursive: true, force: true }) +}