|
| 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 | +} |
0 commit comments