diff --git a/npm/agentplugins/lib/public-authoring-contract.js b/npm/agentplugins/lib/public-authoring-contract.js new file mode 100644 index 00000000..a01b2e01 --- /dev/null +++ b/npm/agentplugins/lib/public-authoring-contract.js @@ -0,0 +1,210 @@ +"use strict"; + +// Pure structural codec; authentication and custody remain external. +const c = require("../scripts/dual-authoring-candidate"); +const { TextDecoder } = require("node:util"); + +const INPUT_SCHEMA = "authoring-native-inputs/v1"; +const DESCRIPTOR_SCHEMA = "dual-authoring-public-npm/v2"; +const INPUT_FILE = "native-inputs.json"; +const MODE = "release-cli-contract-v1"; +const SCOPE = "six-platform-pair"; +const WORKFLOW = ".github/workflows/agentplugins-release.yml"; +const MAX_INPUT_BYTES = 1024 * 1024; +const MAX_DESCRIPTOR_BYTES = 64 * 1024; +const MAX_NATIVE_BYTES = 128 * 1024 * 1024; +const PACKAGES = Object.freeze({ agentplugins: "universal-agent-plugins", "plugin-kit-ai": "plugin-kit-ai" }); + +function fail(label) { throw new Error(`structural consistency only: ${label}`); } +function fixed(value, expected, label) { + if (value !== expected) fail(`${label} mismatch`); + return value; +} +function hash(value, label) { + if (typeof value !== "string" || value.length !== 64 || !/^[0-9a-f]{64}$/.test(value) || /^0+$/.test(value)) fail(`${label} SHA256`); + return value; +} +function positive(value, maximum, label) { + if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) fail(`${label} positive bounded integer`); + return value; +} + +// Only ordinary own enumerable data fields. Do not silently discard symbols, +// hidden claims or custom prototypes, or invoke accessors/toJSON while encoding. +function fields(value, names, label) { + if (!value || typeof value !== "object" || + ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail(`${label} data object`); + const own = Reflect.ownKeys(value); + if (own.length !== names.length || own.some(key => typeof key !== "string" || !names.includes(key))) { + fail(`${label} unexpected or missing fields`); + } + for (const key of own) { + const d = Object.getOwnPropertyDescriptor(value, key); + if (!d.enumerable || !("value" in d)) fail(`${label} own enumerable data fields`); + } + c.keys(value, names, label); +} + +function identity(value) { + fields(value, ["repository", "commit", "engine_revision", "versions"], "identity"); + fields(value.versions, c.PRODUCTS, "versions"); + // Bound strings before calling the existing candidate version validator. + for (const product of c.PRODUCTS) { + if (typeof value.versions[product] !== "string" || value.versions[product].length > MAX_INPUT_BYTES || + /[\r\n]/.test(value.versions[product])) { + fail("version string limit/type"); + } + } + // Candidate's $-anchored regex also matches before a final newline; this + // contract requires exactly forty source characters and stable versions. + if (typeof value.commit !== "string" || value.commit.length !== 40) fail("exact source revision length/type"); + c.identity(value); + if (/^0+$/.test(value.commit)) fail("nonzero source revision required"); + fixed(value.versions["plugin-kit-ai"], "2.0.0", "kit version"); + return { repository: value.repository, commit: value.commit, engine_revision: value.engine_revision, + versions: { agentplugins: value.versions.agentplugins, "plugin-kit-ai": value.versions["plugin-kit-ai"] } }; +} + +function pin(value, file, label) { + fields(value, ["file", "sha256", "size"], label); + return { file: fixed(value.file, file, `${label} file`), sha256: hash(value.sha256, label), + size: positive(value.size, MAX_NATIVE_BYTES, `${label} size`) }; +} + +function inputs(value) { + fields(value, ["schema", "identity", "authoring_mode", "asset_scope", "candidate_sha256", + "pair_marker_sha256", "products", "preparation", "producer"], "inputs"); + fixed(value.schema, INPUT_SCHEMA, "input schema"); + const id = identity(value.identity); + fixed(value.authoring_mode, MODE, "authoring mode"); + fixed(value.asset_scope, SCOPE, "asset scope"); + fields(value.products, c.PRODUCTS, "products"); + const products = {}, binaries = new Set(); + for (const product of c.PRODUCTS) { + const p = value.products[product], assets = {}; + fields(p, ["tag", "manifest_sha256", "checksums_sha256", "assets"], "product"); + const tag = (product === "agentplugins" ? "agentplugins-v" : "v") + id.versions[product]; + fixed(p.tag, tag, "product tag"); + fields(p.assets, c.TARGETS, "assets"); + for (const target of c.TARGETS) { + const a = p.assets[target]; + fields(a, ["file", "sha256", "size", "binary"], "asset"); + const file = c.assetName(product, id.versions[product], target); + const binary = pin(a.binary, c.executableName(product, target), "binary"); + const outer = pin({ file: a.file, sha256: a.sha256, size: a.size }, file, "asset"); + if (product === "agentplugins" && (outer.sha256 !== binary.sha256 || outer.size !== binary.size)) { + fail("raw agent outer/inner pins disagree"); + } + if (binaries.has(binary.sha256)) fail("twelve distinct binary hashes required"); + binaries.add(binary.sha256); + assets[target] = { file: outer.file, sha256: outer.sha256, size: outer.size, binary }; + } + products[product] = { tag, manifest_sha256: hash(p.manifest_sha256, "manifest"), + checksums_sha256: hash(p.checksums_sha256, "checksums"), assets }; + } + const prep = value.preparation, producer = value.producer; + fields(prep, ["sha256", "artifact"], "preparation"); + fields(prep.artifact, ["run_id", "run_attempt", "artifact_id", "artifact_sha256"], "preparation artifact"); + const a = prep.artifact; + fields(producer, ["workflow", "source", "run_id", "run_attempt"], "producer"); + return { schema: INPUT_SCHEMA, identity: id, authoring_mode: MODE, asset_scope: SCOPE, + candidate_sha256: hash(value.candidate_sha256, "candidate"), pair_marker_sha256: hash(value.pair_marker_sha256, "pair marker"), + products, preparation: { sha256: hash(prep.sha256, "preparation"), artifact: { + run_id: positive(a.run_id, Number.MAX_SAFE_INTEGER, "preparation run"), + run_attempt: positive(a.run_attempt, 1000, "preparation attempt"), + artifact_id: positive(a.artifact_id, Number.MAX_SAFE_INTEGER, "preparation artifact ID"), + artifact_sha256: hash(a.artifact_sha256, "preparation artifact") } }, + producer: { workflow: fixed(producer.workflow, WORKFLOW, "producer workflow"), + source: fixed(producer.source, id.commit, "producer source"), + run_id: positive(producer.run_id, Number.MAX_SAFE_INTEGER, "producer run"), + run_attempt: positive(producer.run_attempt, 1000, "producer attempt") } }; +} + +function bytes(value, maximum) { + if (!Buffer.isBuffer(value) || value.length === 0 || value.length > maximum) fail("nonempty bounded Buffer required"); + return value; +} +function encoded(value, maximum) { + return bytes(c.encode(value), maximum); +} +function parsed(body, maximum) { + bytes(body, maximum); + const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(body); + // Both fixed contracts have objects only, at most six levels deep. Bound + // nesting before JSON.parse; canonical re-encoding rejects duplicate keys, + // escapes, alternate number spellings, whitespace, BOM and trailing data. + let depth = 0, quoted = false, escaped = false; + for (const ch of text) { + if (quoted) { + if (escaped) escaped = false; + else if (ch === "\\") escaped = true; + else if (ch === '"') quoted = false; + } else if (ch === '"') quoted = true; + else if (ch === "[") fail("arrays are outside the fixed contracts"); + else if (ch === "{" && ++depth > 6) fail("object depth limit"); + else if (ch === "}") depth--; + } + return JSON.parse(text); +} + +/** Encode a complete I object in fixed order. Structural consistency only. */ +function encodeInputs(value) { return encoded(inputs(value), MAX_INPUT_BYTES); } + +/** Decode canonical I bytes to a fresh data object. Structural consistency only. */ +function decodeInputs(body) { + const value = inputs(parsed(body, MAX_INPUT_BYTES)); + if (!body.equals(encoded(value, MAX_INPUT_BYTES))) fail("noncanonical input bytes"); + return value; +} + +function descriptor(value, inputBytes, product) { + if (!c.PRODUCTS.includes(product)) fail("explicit selected product required"); + const input = decodeInputs(inputBytes); + fields(value, ["schema", "product", "npm_package", "identity", "authoring_mode", "asset_scope", + "candidate_sha256", "release_manifest_sha256", "input_binding"], "descriptor"); + fixed(value.schema, DESCRIPTOR_SCHEMA, "descriptor schema"); + fixed(value.product, product, "selected product"); + fixed(value.npm_package, PACKAGES[product], "npm package"); + const id = identity(value.identity); + if (!c.encode(id).equals(c.encode(input.identity))) fail("descriptor/input identity mismatch"); + fields(value.input_binding, ["file", "sha256"], "input binding"); + return { schema: DESCRIPTOR_SCHEMA, product, npm_package: PACKAGES[product], identity: id, + authoring_mode: fixed(value.authoring_mode, MODE, "descriptor mode"), + asset_scope: fixed(value.asset_scope, SCOPE, "descriptor scope"), + candidate_sha256: fixed(value.candidate_sha256, input.candidate_sha256, "descriptor candidate"), + release_manifest_sha256: fixed(value.release_manifest_sha256, input.products[product].manifest_sha256, "descriptor manifest"), + input_binding: { file: fixed(value.input_binding.file, INPUT_FILE, "input binding file"), + sha256: fixed(value.input_binding.sha256, c.digest(inputBytes), "exact input bytes digest") } }; +} + +/** Encode complete v2 data against exact canonical I bytes and a required product. + * Structural consistency only; this does not generate or qualify a package. */ +function encodeDescriptor(value, inputBytes, product) { + return encoded(descriptor(value, inputBytes, product), MAX_DESCRIPTOR_BYTES); +} + +/** Decode v2 with the same required I bytes/product. Structural consistency only. + * No authentication, signing, acquisition, eligibility or acceptance is implied. */ +function decodeDescriptor(body, inputBytes, product) { + const value = descriptor(parsed(body, MAX_DESCRIPTOR_BYTES), inputBytes, product); + if (!body.equals(encoded(value, MAX_DESCRIPTOR_BYTES))) fail("noncanonical descriptor bytes"); + return value; +} + +// Fixed schema-3 projection, shared with staging. This reconstructs consistency, +// not custody of the absent original candidate, preparation or pair marker. +function projectionBytes(input, product) { + if (!c.PRODUCTS.includes(product)) fail("explicit selected product required"); + const id = input.identity; + const manifest = c.encode({ schema_version: 3, status: "CANDIDATE", product, repository: id.repository, + tag: input.products[product].tag, version: id.versions[product], commit: id.commit, engine_revision: id.engine_revision, + versions: id.versions, candidate_sha256: input.candidate_sha256, authoring_mode: input.authoring_mode, + asset_scope: input.asset_scope, assets: input.products[product].assets, + release_eligible: false, platform_acceptance: false, attested: false }); + const checksums = Buffer.from([...Object.values(input.products[product].assets).map(a => `${a.sha256} ${a.file}`), + `${c.digest(manifest)} release-manifest.json`].join("\n") + "\n"); + return { manifest, checksums }; +} + +module.exports = Object.freeze({ encodeInputs, decodeInputs, encodeDescriptor, decodeDescriptor, projectionBytes, INPUT_SCHEMA, DESCRIPTOR_SCHEMA, INPUT_FILE, MODE, SCOPE, WORKFLOW, PACKAGES, MAX_INPUT_BYTES, MAX_DESCRIPTOR_BYTES, MAX_NATIVE_BYTES, + checks: Object.freeze({ fail, fixed, hash, positive, fields }) }); diff --git a/npm/agentplugins/lib/public-authoring-input.js b/npm/agentplugins/lib/public-authoring-input.js new file mode 100644 index 00000000..00055d01 --- /dev/null +++ b/npm/agentplugins/lib/public-authoring-input.js @@ -0,0 +1,121 @@ +"use strict"; + +// Bounded observations in an owned, quiescent namespace. These path checks do +// not exclude hostile same-UID writers, mount replacement or every host race. +const fs = require("node:fs"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const digest = bytes => crypto.createHash("sha256").update(bytes).digest("hex"); +const fail = message => { throw new Error(`public snapshot: ${message}`); }; +const directoryKeys = ["dev", "ino", "mode", "uid", "gid"]; +const fileKeys = [...directoryKeys, "size", "nlink", "mtimeMs", "ctimeMs"]; +const same = (a, b, keys) => keys.every(key => a[key] === b[key]); +const within = (a, b) => { const r = path.relative(b, a); return r === "" || (!r.startsWith(`..${path.sep}`) && r !== ".." && !path.isAbsolute(r)); }; +const overlaps = (a, b) => within(a, b) || within(b, a); + +function canonical(file) { + if (typeof file !== "string" || !file || file.includes("\0") || !path.isAbsolute(file) || + path.resolve(file) !== file || file.split(/[\\/]/).some(p => p === "." || p === "..")) fail("canonical absolute path required"); + return file; +} +function cancelled(signal) { + if (signal && signal.aborted) { const error = new Error("public snapshot cancelled"); error.code = "ABORT_ERR"; throw error; } +} +function uid() { + if (typeof process.geteuid !== "function" || !fs.constants.O_NOFOLLOW) fail("host ownership/no-follow support required"); + return process.geteuid(); +} +function directory(name, owner) { + const stat = fs.lstatSync(name); + if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync(name) !== name || + (stat.uid !== 0 && stat.uid !== owner) || ((stat.mode & 0o022) && !(stat.mode & 0o1000))) fail("unsafe ancestor"); + return stat; +} +function ancestors(name, owner, missing = false) { + canonical(name); + let current = path.parse(name).root; + const pins = [[current, directory(current, owner)]]; + for (const part of name.slice(current.length).split(path.sep).filter(Boolean)) { + current = path.join(current, part); + try { pins.push([current, directory(current, owner)]); } + catch (error) { if (missing && error.code === "ENOENT") break; throw error; } + } + return pins; +} +function recheckDirectories(pins, owner) { + for (const [name, pin] of pins) if (!same(directory(name, owner), pin, directoryKeys)) fail("ancestor changed"); +} + +// Validate the entire configured cache boundary without creating it. Existing +// ancestor aliases are rejected even if the final cache directory is absent. +function publicPlacement(packageRoot, cacheRoot) { + canonical(packageRoot); canonical(cacheRoot); + if (overlaps(packageRoot, cacheRoot)) fail("cache overlaps package"); + const owner = uid(), packages = ancestors(packageRoot, owner), cache = ancestors(cacheRoot, owner, true); + const p = packages[packages.length - 1], q = cache[cache.length - 1]; + if (q[0] === cacheRoot && p[1].dev === q[1].dev && p[1].ino === q[1].ino) fail("cache aliases package"); + return { recheck() { recheckDirectories(packages, owner); recheckDirectories(cache, owner); } }; +} + +function snapshotPublicFile(file, { kind, maximum, exactSize, protectedRoots = [], signal } = {}) { + cancelled(signal); canonical(file); + if (!["metadata", "local asset"].includes(kind) || !Number.isSafeInteger(maximum) || maximum <= 0 || + maximum > (kind === "metadata" ? 1024 * 1024 : 128 * 1024 * 1024) || + (exactSize !== undefined && (!Number.isSafeInteger(exactSize) || exactSize <= 0 || exactSize > maximum))) fail("fixed bounded policy required"); + const owner = uid(), parent = path.dirname(file), parents = ancestors(parent, owner); + const extra = []; + if (kind === "local asset") { + const direct = parents[parents.length - 1][1]; + if (direct.uid !== owner || (direct.mode & 0o7777) !== 0o700) fail("owned private custody parent required"); + for (const root of protectedRoots) { + canonical(root); + if (overlaps(parent, root)) fail("custody overlaps protected root"); + const pins = ancestors(root, owner, true), last = pins[pins.length - 1]; + if (last[0] === root && direct.dev === last[1].dev && direct.ino === last[1].ino) fail("custody aliases protected root"); + extra.push(...pins); + } + } + const before = fs.lstatSync(file); + const regular = stat => stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1 && stat.size > 0 && stat.size <= maximum && + (exactSize === undefined || stat.size === exactSize) && + (kind !== "local asset" || (stat.uid === owner && !(stat.mode & 0o7022))); + if (!regular(before)) fail("regular nonempty bounded single-link file required"); + let fd, closed = false; + function close() { if (fd !== undefined && !closed) { closed = true; fs.closeSync(fd); } } + function identities() { + cancelled(signal); + if (closed) fail("closed descriptor"); + recheckDirectories([...parents, ...extra], owner); + const named = fs.lstatSync(file), opened = fs.fstatSync(fd); + if (!regular(named) || !regular(opened) || !same(before, named, fileKeys) || !same(before, opened, fileKeys)) fail("file identity or metadata changed"); + } + function read() { + // Allocate only the admitted initial size plus one overflow detection byte; + // positional reads also make repeated rechecks independent of fd offsets. + const body = Buffer.alloc(before.size + 1); + let offset = 0; + while (offset < body.length) { + cancelled(signal); + const count = fs.readSync(fd, body, offset, Math.min(64 * 1024, body.length - offset), offset); + if (!Number.isSafeInteger(count) || count < 0 || count > Math.min(64 * 1024, body.length - offset)) fail("invalid bounded read result"); + if (count === 0) break; + offset += count; + } + if (offset !== before.size) fail("short read or growth"); + return body.subarray(0, offset); + } + try { + fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + identities(); + const bytes = read(); + identities(); + const pin = digest(bytes); + return { get bytes() { return Buffer.from(bytes); }, close, + recheck() { identities(); const current = read(); identities(); if (digest(current) !== pin) fail("file contents changed"); } }; + } catch (primary) { + try { close(); } catch (error) { throw new AggregateError([primary, error], "public snapshot read and close failed"); } + throw primary; + } +} + +module.exports = { snapshotPublicFile, publicPlacement }; diff --git a/npm/agentplugins/lib/public-authoring.js b/npm/agentplugins/lib/public-authoring.js index 9262a5e9..f915dc7a 100644 --- a/npm/agentplugins/lib/public-authoring.js +++ b/npm/agentplugins/lib/public-authoring.js @@ -3,6 +3,7 @@ // Package consistency, not a signature verifier. The future protected public // stager must authenticate the signed pair before emitting a binding. Preparation // emits null. No runtime environment/CLI option can qualify a preparation pack. +const fs = require("node:fs"); const fsp = require("node:fs/promises"); const os = require("node:os"); const path = require("node:path"); @@ -48,24 +49,32 @@ function qualification(descriptor) { } } -function loadRelease(product, packageRoot, target) { +function validateRelease(product, packageRoot, target, read = json, inputBytes) { if (!Object.hasOwn(PACKAGES, product) || !c.TARGETS.includes(target)) throw new Error("unsupported public product/target"); c.safeDirectory(packageRoot); - const { value: d, bytes: descriptorBytes } = json(path.join(packageRoot, "public-release.json")); + const { value: d, bytes: descriptorBytes } = read(path.join(packageRoot, "public-release.json")); + const v2 = inputBytes !== undefined; + const contract = v2 ? require("./public-authoring-contract") : null; + if (v2) contract.decodeDescriptor(descriptorBytes, inputBytes, product); c.keys(d, ["schema", "product", "npm_package", "identity", "authoring_mode", "asset_scope", - "candidate_sha256", "release_manifest_sha256", "qualification"], "public release"); + "candidate_sha256", "release_manifest_sha256", v2 ? "input_binding" : "qualification"], "public release"); c.identity(d.identity); - if (d.schema !== SCHEMA || d.product !== product || d.npm_package !== PACKAGES[product] || + if (d.schema !== (v2 ? contract.DESCRIPTOR_SCHEMA : SCHEMA) || d.product !== product || d.npm_package !== PACKAGES[product] || /^0{40}$/.test(d.identity.commit) || d.identity.versions["plugin-kit-ai"] !== "2.0.0" || d.authoring_mode !== MODE || d.asset_scope !== SCOPE || !hash(d.candidate_sha256) || !hash(d.release_manifest_sha256)) { throw new Error("public release identity mismatch"); } - const { value: pkg } = json(path.join(packageRoot, "package.json")); + const { value: pkg } = read(path.join(packageRoot, "package.json")); c.keys(pkg, ["name", "version", "description", "license", "homepage", "repository", "bugs", "keywords", "engines", "publishConfig", "files", "bin", "scripts", "private", ...(product === "agentplugins" ? ["os", "cpu"] : [])], "public package"); const closure = ["LICENSE", "README.md", "package.json", `bin/${product}.js`, "bin/package.json", "lib/package.json", "lib/platform.js", "lib/verifier.js", "lib/public-authoring.js", "scripts/package.json", "scripts/dual-authoring-candidate.js", product === "agentplugins" ? "lib/bootstrap.js" : "lib/install.js", "public-release.json", "release-manifest.json"]; + if (v2) closure.push("native-inputs.json", "lib/public-authoring-contract.js", "lib/public-authoring-input.js"); + if (v2 && (pkg.private !== false || (product === "agentplugins" && + (!equal(pkg.os, ["darwin", "linux", "win32"]) || !equal(pkg.cpu, ["x64", "arm64"]))))) { + throw new Error("public v2 package private/OS/CPU mismatch"); + } if (typeof pkg.private !== "boolean" || !Array.isArray(pkg.files) || !equal(pkg.files, closure.sort())) { throw new Error("public package closure mismatch"); } @@ -75,7 +84,7 @@ function loadRelease(product, packageRoot, target) { if (pkg.name !== PACKAGES[product] || pkg.version !== d.identity.versions[product] || pkg.bin[product] !== `bin/${product}.js` || pkg.engines.node !== (product === "agentplugins" ? ">=22" : ">=18") || !equal(pkg.scripts, scripts)) throw new Error("public npm package binding mismatch"); - const { value: m, bytes } = json(path.join(packageRoot, "release-manifest.json")); + const { value: m, bytes } = read(path.join(packageRoot, "release-manifest.json")); c.keys(m, ["schema_version", "status", "product", "repository", "tag", "version", "commit", "engine_revision", "versions", "candidate_sha256", "authoring_mode", "asset_scope", "assets", "release_eligible", "platform_acceptance", "attested"], "producer projection"); if (c.digest(bytes) !== d.release_manifest_sha256 || m.schema_version !== 3 || m.status !== "CANDIDATE" || @@ -99,19 +108,101 @@ function loadRelease(product, packageRoot, target) { } seen.add(a.binary.sha256); } - qualification(d); // Always before effects, including warm cache lookup. + if (v2) { + const input = contract.decodeInputs(inputBytes); + for (const peer of c.PRODUCTS) { + const projection = contract.projectionBytes(input, peer), pins = input.products[peer]; + if (c.digest(projection.manifest) !== pins.manifest_sha256 || c.digest(projection.checksums) !== pins.checksums_sha256 || + (peer === product && !bytes.equals(projection.manifest))) throw new Error("public pair projection mismatch"); + } + } else qualification(d); // Always before effects, including warm cache lookup. return { descriptor: d, manifest: m, asset: m.assets[target], version: pkg.version, binding: c.digest(descriptorBytes), tag: m.tag }; } +// Bounded schema dispatch lives in the existing shipped runtime so v1 packages +// do not import either new helper. Actual v1 validation retains its old reader. +function descriptorSchema(file) { + const named = fs.lstatSync(file); + if (!named.isFile() || named.isSymbolicLink() || named.nlink !== 1 || named.size <= 0 || named.size > 1024 * 1024) { + throw new Error("invalid bounded public descriptor"); + } + const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)); + let primary, schema; + try { + const bytes = Buffer.alloc(named.size + 1); + let offset = 0, count; + while (offset < bytes.length) { + const length = Math.min(65536, bytes.length - offset); + count = fs.readSync(fd, bytes, offset, length, offset); + if (!Number.isSafeInteger(count) || count < 0 || count > length) throw new Error("invalid bounded descriptor read"); + if (count === 0) break; + offset += count; + } + const opened = fs.fstatSync(fd); + if (offset !== named.size || ["dev", "ino", "size", "mode", "nlink", "uid", "gid", "mtimeMs", "ctimeMs"].some(k => named[k] !== opened[k])) { + throw new Error("public descriptor changed"); + } + schema = JSON.parse(bytes.subarray(0, offset)).schema; + } catch (error) { primary = error; } + try { fs.closeSync(fd); } catch (error) { throw v.failures(primary, [error]); } + if (primary) throw primary; + return schema; +} + +function snapshotRelease(product, packageRoot, target, signal) { + if (!Object.hasOwn(PACKAGES, product) || !c.TARGETS.includes(target)) throw new Error("unsupported public product/target"); + c.safeDirectory(packageRoot); + const schema = descriptorSchema(path.join(packageRoot, "public-release.json")); + if (schema === SCHEMA) { + const release = validateRelease(product, packageRoot, target); + return { release, close() {}, recheck() { + if (validateRelease(product, packageRoot, target).binding !== release.binding) throw new Error("public release changed during acquisition"); + } }; + } + if (schema !== "dual-authoring-public-npm/v2") throw new Error("unsupported public release schema"); + const { snapshotPublicFile } = require("./public-authoring-input"); + const contract = require("./public-authoring-contract"), snapshots = new Map(); + function close() { + const errors = []; + for (const snapshot of snapshots.values()) try { snapshot.close(); } catch (error) { errors.push(error); } + if (errors.length) throw v.failures(null, errors); + } + try { + for (const [name, maximum] of [["public-release.json", contract.MAX_DESCRIPTOR_BYTES], [contract.INPUT_FILE, contract.MAX_INPUT_BYTES], + ["release-manifest.json", 1024 * 1024], ["package.json", 1024 * 1024]]) { + snapshots.set(path.join(packageRoot, name), snapshotPublicFile(path.join(packageRoot, name), { kind: "metadata", maximum, signal })); + } + const read = file => { + const bytes = snapshots.get(file).bytes, value = JSON.parse(bytes); + if (!bytes.equals(c.encode(value))) throw new Error("noncanonical public JSON"); + return { bytes, value }; + }; + const release = validateRelease(product, packageRoot, target, read, snapshots.get(path.join(packageRoot, contract.INPUT_FILE)).bytes); + const recheck = () => { for (const snapshot of snapshots.values()) snapshot.recheck(); }; + recheck(); + return { release, close, recheck }; + } catch (primary) { + try { close(); } catch (error) { throw v.failures(primary, [error]); } + throw primary; + } +} + +function loadRelease(product, packageRoot, target) { + const snapshot = snapshotRelease(product, packageRoot, target); + snapshot.close(); + return snapshot.release; +} +const releaseNamespace = release => release.descriptor.schema === SCHEMA ? "public-authoring-v1" : "public-authoring-v2"; + function cachePath(root, product, target, release) { - return path.join(root, "public-authoring-v1", MODE, release.descriptor.identity.commit, + return path.join(root, releaseNamespace(release), MODE, release.descriptor.identity.commit, release.descriptor.candidate_sha256, product, release.version, target, release.asset.binary.sha256, release.asset.binary.file); } // Create only missing directories; never chmod an existing historical cache. // PR167 validates every ancestor before the next child can be created. -async function namespace(root, io) { +async function namespace(root, io, name = "public-authoring-v1") { if (typeof root !== "string" || !path.isAbsolute(root) || path.resolve(root) !== root || root.includes("\0") || root.split(/[\\/]/).includes("..")) throw new Error("unsafe public cache root"); let current = path.parse(root).root; @@ -127,7 +218,7 @@ async function namespace(root, io) { throw new Error("public cache requires safe ancestors"); } } - const owned = path.join(root, "public-authoring-v1"); + const owned = path.join(root, name); try { await io.mkdir(owned, { mode: 0o700 }); } catch (e) { if (e.code !== "EEXIST") throw e; } await v.privateDirectory(owned, owned, io); @@ -145,12 +236,50 @@ async function ensureBinary(product, options = {}) { if (options.target !== undefined && options.target !== target) throw new Error("unexpected public native target"); const packageRoot = options.packageRoot || path.resolve(__dirname, ".."); v.cancelled(options.signal); - const release = loadRelease(product, packageRoot, target); + const snapshot = snapshotRelease(product, packageRoot, target, options.signal); + let assetSnapshot, primary, result; + try { + const release = snapshot.release; + const root = options.cacheRoot || path.join(os.homedir(), ".cache", "universal-agent-plugins"); + let placement, localBinary; + if (release.descriptor.schema !== SCHEMA) { + const input = require("./public-authoring-input"); + placement = input.publicPlacement(packageRoot, root); + const environment = options.environment || process.env; + if (Object.hasOwn(environment, "UAP_PUBLIC_AUTHORING_ASSET_FILE")) { + const file = environment.UAP_PUBLIC_AUTHORING_ASSET_FILE; + if (typeof file !== "string" || !file || path.basename(file) !== release.asset.file) throw new Error("invalid public asset locator basename"); + assetSnapshot = input.snapshotPublicFile(file, { kind: "local asset", maximum: release.asset.size, + exactSize: release.asset.size, protectedRoots: [packageRoot, root], signal: options.signal }); + localBinary = checkedBinary(product, assetSnapshot.bytes, release.asset); + } + } + const recheck = () => { snapshot.recheck(); if (placement) placement.recheck(); if (assetSnapshot) assetSnapshot.recheck(); }; + if (release.descriptor.schema !== SCHEMA) recheck(); + result = await acquireBinary(product, options, platform, target, packageRoot, release, localBinary, recheck); + // Acquisition finalization awaits cleanup and unlock while snapshots remain held. + v.cancelled(options.signal); + recheck(); + } catch (error) { primary = error; } + const errors = []; + for (const held of [assetSnapshot, snapshot]) if (held) try { held.close(); } catch (error) { errors.push(error); } + if (primary || errors.length) throw v.failures(primary, errors); + return result; +} + +function checkedBinary(product, bytes, asset) { + if (bytes.length !== asset.size || c.digest(bytes) !== asset.sha256) throw new Error("outer public asset mismatch"); + const binary = product === "plugin-kit-ai" ? c.unpack(bytes, asset.binary.file) : bytes; + if (!Buffer.isBuffer(binary) || binary.length !== asset.binary.size || c.digest(binary) !== asset.binary.sha256) throw new Error("inner public binary mismatch"); + return Buffer.from(binary); +} + +async function acquireBinary(product, options, platform, target, packageRoot, release, localBinary, recheck) { // No historical repository/version/cache override is consulted on this path. const root = options.cacheRoot || path.join(os.homedir(), ".cache", "universal-agent-plugins"); if (inside(root, packageRoot) || inside(packageRoot, root)) throw new Error("public cache overlaps package"); const io = options.io || fsp; - const owned = await namespace(root, io); + const owned = await namespace(root, io, releaseNamespace(release)); const binaryPath = cachePath(root, product, target, release); await v.privateDirectory(owned, path.dirname(binaryPath), io); const lockRoot = path.join(owned, ".locks"); @@ -171,24 +300,30 @@ async function ensureBinary(product, options = {}) { await unchangedDirectories(); const hit = await v.strictCachedBinary(binaryPath, release.asset.binary, settings); if (!hit) { - temporary = await io.mkdtemp(path.join(owned, ".download-")); - temporaryStat = await io.lstat(temporary); - await v.privateDirectory(owned, temporary, io); - const file = path.join(temporary, "asset"); - await v.downloadFile(`https://github.com/${c.REPOSITORY}/releases/download/${release.tag}/${release.asset.file}`, - file, release.asset, { request: options.request, signal: options.signal, onOpen: stat => { downloadStat = stat; } }); - v.cancelled(options.signal); - const bytes = c.readFile(file); - if (bytes.length !== release.asset.size || c.digest(bytes) !== release.asset.sha256) throw new Error("outer public asset mismatch"); - const binary = product === "plugin-kit-ai" ? c.unpack(bytes, release.asset.binary.file) : bytes; - if (binary.length !== release.asset.binary.size || c.digest(binary) !== release.asset.binary.sha256) throw new Error("inner public binary mismatch"); + let binary = localBinary; + if (!binary) { + temporary = await io.mkdtemp(path.join(owned, ".download-")); + temporaryStat = await io.lstat(temporary); + await v.privateDirectory(owned, temporary, io); + const file = path.join(temporary, "asset"); + await v.downloadFile(`https://github.com/${c.REPOSITORY}/releases/download/${release.tag}/${release.asset.file}`, + file, release.asset, { request: options.request, signal: options.signal, onOpen: stat => { downloadStat = stat; } }); + v.cancelled(options.signal); + const bytes = c.readFile(file); + binary = checkedBinary(product, bytes, release.asset); + } + if (release.descriptor.schema !== SCHEMA) recheck(); await unchangedDirectories(); await v.commitVerifiedBinary(binary, binaryPath, release.asset.binary, settings); } v.cancelled(options.signal); // A concurrent metadata edit cannot turn this operation into a warm bypass. - if (loadRelease(product, packageRoot, target).binding !== release.binding) throw new Error("public release changed during acquisition"); + recheck(); await unchangedDirectories(); + if (release.descriptor.schema !== SCHEMA && !await v.strictCachedBinary(binaryPath, release.asset.binary, settings)) { + throw new Error("final public binary verification failed"); + } + if (release.descriptor.schema !== SCHEMA) recheck(); result = { binaryPath, version: release.version, tag: release.tag, product, cacheHit: hit, publicAuthoring: true, repository: c.REPOSITORY }; } catch (e) { primary = e; } diff --git a/npm/agentplugins/scripts/authoring-native-inputs.js b/npm/agentplugins/scripts/authoring-native-inputs.js index e08942c5..64ae5e3a 100644 --- a/npm/agentplugins/scripts/authoring-native-inputs.js +++ b/npm/agentplugins/scripts/authoring-native-inputs.js @@ -6,194 +6,9 @@ const c = require("./dual-authoring-candidate"); const fs = require("node:fs"); const path = require("node:path"); -const { TextDecoder, isDeepStrictEqual: equal } = require("node:util"); +const { isDeepStrictEqual: equal } = require("node:util"); -const INPUT_SCHEMA = "authoring-native-inputs/v1"; -const DESCRIPTOR_SCHEMA = "dual-authoring-public-npm/v2"; -const INPUT_FILE = "native-inputs.json"; -const MODE = "release-cli-contract-v1"; -const SCOPE = "six-platform-pair"; -const WORKFLOW = ".github/workflows/agentplugins-release.yml"; -const MAX_INPUT_BYTES = 1024 * 1024; -const MAX_DESCRIPTOR_BYTES = 64 * 1024; -const MAX_NATIVE_BYTES = 128 * 1024 * 1024; -const PACKAGES = Object.freeze({ agentplugins: "universal-agent-plugins", "plugin-kit-ai": "plugin-kit-ai" }); - -function fail(label) { throw new Error(`structural consistency only: ${label}`); } -function fixed(value, expected, label) { - if (value !== expected) fail(`${label} mismatch`); - return value; -} -function hash(value, label) { - if (typeof value !== "string" || value.length !== 64 || !/^[0-9a-f]{64}$/.test(value) || /^0+$/.test(value)) fail(`${label} SHA256`); - return value; -} -function positive(value, maximum, label) { - if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) fail(`${label} positive bounded integer`); - return value; -} - -// Only ordinary own enumerable data fields. Do not silently discard symbols, -// hidden claims or custom prototypes, or invoke accessors/toJSON while encoding. -function fields(value, names, label) { - if (!value || typeof value !== "object" || - ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail(`${label} data object`); - const own = Reflect.ownKeys(value); - if (own.length !== names.length || own.some(key => typeof key !== "string" || !names.includes(key))) { - fail(`${label} unexpected or missing fields`); - } - for (const key of own) { - const d = Object.getOwnPropertyDescriptor(value, key); - if (!d.enumerable || !("value" in d)) fail(`${label} own enumerable data fields`); - } - c.keys(value, names, label); -} - -function identity(value) { - fields(value, ["repository", "commit", "engine_revision", "versions"], "identity"); - fields(value.versions, c.PRODUCTS, "versions"); - // Bound strings before calling the existing candidate version validator. - for (const product of c.PRODUCTS) { - if (typeof value.versions[product] !== "string" || value.versions[product].length > MAX_INPUT_BYTES || - /[\r\n]/.test(value.versions[product])) { - fail("version string limit/type"); - } - } - // Candidate's $-anchored regex also matches before a final newline; this - // contract requires exactly forty source characters and stable versions. - if (typeof value.commit !== "string" || value.commit.length !== 40) fail("exact source revision length/type"); - c.identity(value); - if (/^0+$/.test(value.commit)) fail("nonzero source revision required"); - fixed(value.versions["plugin-kit-ai"], "2.0.0", "kit version"); - return { repository: value.repository, commit: value.commit, engine_revision: value.engine_revision, - versions: { agentplugins: value.versions.agentplugins, "plugin-kit-ai": value.versions["plugin-kit-ai"] } }; -} - -function pin(value, file, label) { - fields(value, ["file", "sha256", "size"], label); - return { file: fixed(value.file, file, `${label} file`), sha256: hash(value.sha256, label), - size: positive(value.size, MAX_NATIVE_BYTES, `${label} size`) }; -} - -function inputs(value) { - fields(value, ["schema", "identity", "authoring_mode", "asset_scope", "candidate_sha256", - "pair_marker_sha256", "products", "preparation", "producer"], "inputs"); - fixed(value.schema, INPUT_SCHEMA, "input schema"); - const id = identity(value.identity); - fixed(value.authoring_mode, MODE, "authoring mode"); - fixed(value.asset_scope, SCOPE, "asset scope"); - fields(value.products, c.PRODUCTS, "products"); - const products = {}, binaries = new Set(); - for (const product of c.PRODUCTS) { - const p = value.products[product], assets = {}; - fields(p, ["tag", "manifest_sha256", "checksums_sha256", "assets"], "product"); - const tag = (product === "agentplugins" ? "agentplugins-v" : "v") + id.versions[product]; - fixed(p.tag, tag, "product tag"); - fields(p.assets, c.TARGETS, "assets"); - for (const target of c.TARGETS) { - const a = p.assets[target]; - fields(a, ["file", "sha256", "size", "binary"], "asset"); - const file = c.assetName(product, id.versions[product], target); - const binary = pin(a.binary, c.executableName(product, target), "binary"); - const outer = pin({ file: a.file, sha256: a.sha256, size: a.size }, file, "asset"); - if (product === "agentplugins" && (outer.sha256 !== binary.sha256 || outer.size !== binary.size)) { - fail("raw agent outer/inner pins disagree"); - } - if (binaries.has(binary.sha256)) fail("twelve distinct binary hashes required"); - binaries.add(binary.sha256); - assets[target] = { file: outer.file, sha256: outer.sha256, size: outer.size, binary }; - } - products[product] = { tag, manifest_sha256: hash(p.manifest_sha256, "manifest"), - checksums_sha256: hash(p.checksums_sha256, "checksums"), assets }; - } - const prep = value.preparation, producer = value.producer; - fields(prep, ["sha256", "artifact"], "preparation"); - fields(prep.artifact, ["run_id", "run_attempt", "artifact_id", "artifact_sha256"], "preparation artifact"); - const a = prep.artifact; - fields(producer, ["workflow", "source", "run_id", "run_attempt"], "producer"); - return { schema: INPUT_SCHEMA, identity: id, authoring_mode: MODE, asset_scope: SCOPE, - candidate_sha256: hash(value.candidate_sha256, "candidate"), pair_marker_sha256: hash(value.pair_marker_sha256, "pair marker"), - products, preparation: { sha256: hash(prep.sha256, "preparation"), artifact: { - run_id: positive(a.run_id, Number.MAX_SAFE_INTEGER, "preparation run"), - run_attempt: positive(a.run_attempt, 1000, "preparation attempt"), - artifact_id: positive(a.artifact_id, Number.MAX_SAFE_INTEGER, "preparation artifact ID"), - artifact_sha256: hash(a.artifact_sha256, "preparation artifact") } }, - producer: { workflow: fixed(producer.workflow, WORKFLOW, "producer workflow"), - source: fixed(producer.source, id.commit, "producer source"), - run_id: positive(producer.run_id, Number.MAX_SAFE_INTEGER, "producer run"), - run_attempt: positive(producer.run_attempt, 1000, "producer attempt") } }; -} - -function bytes(value, maximum) { - if (!Buffer.isBuffer(value) || value.length === 0 || value.length > maximum) fail("nonempty bounded Buffer required"); - return value; -} -function encoded(value, maximum) { - return bytes(c.encode(value), maximum); -} -function parsed(body, maximum) { - bytes(body, maximum); - const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(body); - // Both fixed contracts have objects only, at most six levels deep. Bound - // nesting before JSON.parse; canonical re-encoding rejects duplicate keys, - // escapes, alternate number spellings, whitespace, BOM and trailing data. - let depth = 0, quoted = false, escaped = false; - for (const ch of text) { - if (quoted) { - if (escaped) escaped = false; - else if (ch === "\\") escaped = true; - else if (ch === '"') quoted = false; - } else if (ch === '"') quoted = true; - else if (ch === "[") fail("arrays are outside the fixed contracts"); - else if (ch === "{" && ++depth > 6) fail("object depth limit"); - else if (ch === "}") depth--; - } - return JSON.parse(text); -} - -/** Encode a complete I object in fixed order. Structural consistency only. */ -function encodeInputs(value) { return encoded(inputs(value), MAX_INPUT_BYTES); } - -/** Decode canonical I bytes to a fresh data object. Structural consistency only. */ -function decodeInputs(body) { - const value = inputs(parsed(body, MAX_INPUT_BYTES)); - if (!body.equals(encoded(value, MAX_INPUT_BYTES))) fail("noncanonical input bytes"); - return value; -} - -function descriptor(value, inputBytes, product) { - if (!c.PRODUCTS.includes(product)) fail("explicit selected product required"); - const input = decodeInputs(inputBytes); - fields(value, ["schema", "product", "npm_package", "identity", "authoring_mode", "asset_scope", - "candidate_sha256", "release_manifest_sha256", "input_binding"], "descriptor"); - fixed(value.schema, DESCRIPTOR_SCHEMA, "descriptor schema"); - fixed(value.product, product, "selected product"); - fixed(value.npm_package, PACKAGES[product], "npm package"); - const id = identity(value.identity); - if (!c.encode(id).equals(c.encode(input.identity))) fail("descriptor/input identity mismatch"); - fields(value.input_binding, ["file", "sha256"], "input binding"); - return { schema: DESCRIPTOR_SCHEMA, product, npm_package: PACKAGES[product], identity: id, - authoring_mode: fixed(value.authoring_mode, MODE, "descriptor mode"), - asset_scope: fixed(value.asset_scope, SCOPE, "descriptor scope"), - candidate_sha256: fixed(value.candidate_sha256, input.candidate_sha256, "descriptor candidate"), - release_manifest_sha256: fixed(value.release_manifest_sha256, input.products[product].manifest_sha256, "descriptor manifest"), - input_binding: { file: fixed(value.input_binding.file, INPUT_FILE, "input binding file"), - sha256: fixed(value.input_binding.sha256, c.digest(inputBytes), "exact input bytes digest") } }; -} - -/** Encode complete v2 data against exact canonical I bytes and a required product. - * Structural consistency only; this does not generate or qualify a package. */ -function encodeDescriptor(value, inputBytes, product) { - return encoded(descriptor(value, inputBytes, product), MAX_DESCRIPTOR_BYTES); -} - -/** Decode v2 with the same required I bytes/product. Structural consistency only. - * No authentication, signing, acquisition, eligibility or acceptance is implied. */ -function decodeDescriptor(body, inputBytes, product) { - const value = descriptor(parsed(body, MAX_DESCRIPTOR_BYTES), inputBytes, product); - if (!body.equals(encoded(value, MAX_DESCRIPTOR_BYTES))) fail("noncanonical descriptor bytes"); - return value; -} +const { encodeInputs, decodeInputs, encodeDescriptor, decodeDescriptor, INPUT_SCHEMA, DESCRIPTOR_SCHEMA, INPUT_FILE, MODE, SCOPE, WORKFLOW, PACKAGES, MAX_INPUT_BYTES, MAX_DESCRIPTOR_BYTES, MAX_NATIVE_BYTES, checks: { fail, fixed, hash, positive, fields } } = require("../lib/public-authoring-contract"); const agree = (a, b, label) => { if (!equal(a, b)) throw new Error(`C1 provenance ${label} mismatch`); }; function artifact(value) { diff --git a/npm/agentplugins/scripts/packed-installer-bridge.md b/npm/agentplugins/scripts/packed-installer-bridge.md index ca52892f..89adab56 100644 --- a/npm/agentplugins/scripts/packed-installer-bridge.md +++ b/npm/agentplugins/scripts/packed-installer-bridge.md @@ -233,3 +233,27 @@ The final integrated source (including the independently owned preparation initialization fix) still needs owner-scheduled public native execution and packed planner acceptance. These focused synthetic tests do not satisfy that E2E or the external release/required-check prerequisites. + +### C2 production acquisition boundary + +The same public launchers and kit postinstall accept the fixed v2 descriptor +and complete `native-inputs.json` binding. V1 qualification/null behavior stays +unchanged. V2 alone recognizes `UAP_PUBLIC_AUTHORING_ASSET_FILE` as an untrusted +absolute locator in an owned private custody directory. Every supplied locator +is checked before cache effects, including on warm hits; invalid input never +falls back to download. Outer and inner pins come only from package metadata. +Local and absent-locator anonymous acquisition converge on the existing locked, +verified cache under `public-authoring-v2`. The supplied file is never executed. + +Bounded retained metadata/asset snapshots are rechecked before commit and return; +close or cleanup uncertainty returns failure. The child environment removes the +locator and existing proof controls. These checks assume an owned, quiescent +namespace, without hostile same-UID, mount-replacement or all-host guarantees. +Callers must separately keep project/client/evidence outputs outside custody. + +C2 source fixtures establish structural and interface behavior only. They do +not authenticate C1 custody, qualify native inputs, or change either existing +bridge intake. Genuine C3 execution needs a separately reviewed authenticated +intake and actual installed launchers/postinstall and installer lifecycle. +N2, C3 E, P/Q, B, anonymous public readbacks, pair channels, PyPI/Homebrew, +full-platform E2E, release and phases 0–11 remain open. diff --git a/npm/agentplugins/scripts/stage-authoring-npm.js b/npm/agentplugins/scripts/stage-authoring-npm.js index dbaf68ca..19ac6718 100644 --- a/npm/agentplugins/scripts/stage-authoring-npm.js +++ b/npm/agentplugins/scripts/stage-authoring-npm.js @@ -10,10 +10,12 @@ const adapter = require("./authoring-release"); const packing = require("./stage-dual-authoring-npm"); const runtime = require("../lib/public-authoring"); const inputs = require("./authoring-native-inputs"); +const { projectionBytes } = require("../lib/public-authoring-contract"); const crypto = require("node:crypto"); const { TextDecoder } = require("node:util"); const PREFIX = "npm/agentplugins/"; const COMMON = Object.freeze(["lib/verifier.js", "lib/public-authoring.js", "scripts/dual-authoring-candidate.js"]); +const STAGE_COMMON = Object.freeze([...COMMON, "lib/public-authoring-contract.js", "lib/public-authoring-input.js"]); const ownFiles = product => ["LICENSE", "README.md", "package.json", `bin/${product}.js`, "lib/platform.js", product === "agentplugins" ? "lib/bootstrap.js" : "lib/install.js"]; const ALLOWLIST = Object.freeze([...new Set([...COMMON.map(n => PREFIX + n), @@ -23,6 +25,7 @@ const ALLOWLIST = Object.freeze([...new Set([...COMMON.map(n => PREFIX + n), // Separate future stage provenance inventory. Never extend the legacy // preparation wrapper_blobs receipt or ship these producer helpers in a pack. const STAGE_ALLOWLIST = Object.freeze([...ALLOWLIST, + ...STAGE_COMMON.filter(n => !COMMON.includes(n)).map(n => PREFIX + n), ...["authoring-native-inputs.js", "authoring-promotion.js", "authoring-native-qualification.js", "platform-proof.js", "npm-public-contract.js"].map(n => PREFIX + "scripts/" + n), "scripts/read-authoring-evidence-zip.py", ".github/workflows/agentplugins-release.yml", @@ -91,7 +94,7 @@ function stageBytes(value, maximum) { if (!Buffer.isBuffer(value) || value.length === 0 || value.length > maximum) throw new Error("stage nonempty bounded Buffer required"); return value; } -const stageClosure = product => [...ownFiles(product), ...COMMON, "bin/package.json", "lib/package.json", +const stageClosure = product => [...ownFiles(product), ...STAGE_COMMON, "bin/package.json", "lib/package.json", "scripts/package.json", "public-release.json", "release-manifest.json", inputs.INPUT_FILE].sort(); function stageDescriptors(input, inputBytes) { @@ -127,20 +130,15 @@ function pairedPackageFiles(source, manifests, inputBytes) { const descriptors = stageDescriptors(input, inputBytes); stageFields(source, STAGE_ALLOWLIST, "stage source closure"); for (const name of STAGE_ALLOWLIST) stageBlob(source[name], true); + for (const name of STAGE_COMMON.filter(n => !COMMON.includes(n))) { + stageEqual(source[PREFIX + name].mode, "100644", "v2 helper mode"); + } stageFields(manifests, c.PRODUCTS, "paired manifest bytes"); for (const product of c.PRODUCTS) { - const body = stageBytes(manifests[product], inputs.MAX_INPUT_BYTES), id = input.identity; - // Exact existing authoring-release productManifest encoding, using the - // already validated I assets. No candidate rebuild, archive or second reader. - const expected = c.encode({ schema_version: 3, status: "CANDIDATE", product, repository: id.repository, - tag: input.products[product].tag, version: id.versions[product], commit: id.commit, engine_revision: id.engine_revision, - versions: id.versions, candidate_sha256: input.candidate_sha256, authoring_mode: input.authoring_mode, - asset_scope: input.asset_scope, assets: input.products[product].assets, - release_eligible: false, platform_acceptance: false, attested: false }); + const body = stageBytes(manifests[product], inputs.MAX_INPUT_BYTES); + const { manifest: expected, checksums } = projectionBytes(input, product); if (!body.equals(expected)) throw new Error("stage projection bytes differ from I"); stageEqual(c.digest(body), input.products[product].manifest_sha256, "selected manifest hash"); - const checksums = Buffer.from([...Object.values(input.products[product].assets).map(a => `${a.sha256} ${a.file}`), - `${c.digest(body)} release-manifest.json`].join("\n") + "\n"); stageEqual(c.digest(checksums), input.products[product].checksums_sha256, "projection checksums hash"); const baseBytes = source[`npm/${product}/package.json`].bytes; const base = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(baseBytes)); @@ -160,6 +158,7 @@ function pairedPackageFiles(source, manifests, inputBytes) { identity: input.identity, manifestDigest: input.candidate_sha256 }); files["public-release.json"] = descriptors[product]; files[inputs.INPUT_FILE] = inputBytes; + for (const name of STAGE_COMMON) files[name] = source[PREFIX + name].bytes; files["package.json"] = c.encode({ ...JSON.parse(files["package.json"]), private: false, files: stageClosure(product) }); // Own the returned buffers, including each product's I and shared runtime. pair[product] = Object.fromEntries(Object.entries(files).map(([name, body]) => [name, Buffer.from(body)])); @@ -207,7 +206,7 @@ function stageRecord(value, inputBytes) { for (const name of ownFiles(product).filter(n => n !== "package.json")) { stageEqual(g[name], wrapper_blobs[`npm/${product}/${name}`].sha256, "generated source file"); } - for (const name of COMMON) stageEqual(g[name], wrapper_blobs[PREFIX + name].sha256, "shared runtime"); + for (const name of STAGE_COMMON) stageEqual(g[name], wrapper_blobs[PREFIX + name].sha256, "shared runtime"); for (const dir of ["bin", "lib", "scripts"]) { stageEqual(g[`${dir}/package.json`], c.digest(c.encode({ type: "commonjs" })), "CommonJS scope"); } @@ -397,7 +396,7 @@ function manifestsFrom(snapshot) { return Object.fromEntries(c.PRODUCTS.map(p => [p, snapshot.files[`${p}/release-manifest.json`]])); } function generatedPins(pair) { - for (const name of [...COMMON, inputs.INPUT_FILE]) agreeStage(pair.agentplugins[name], pair["plugin-kit-ai"][name], "shared generated bytes"); + for (const name of [...STAGE_COMMON, inputs.INPUT_FILE]) agreeStage(pair.agentplugins[name], pair["plugin-kit-ai"][name], "shared generated bytes"); return Object.fromEntries(c.PRODUCTS.map(p => [p, Object.fromEntries(Object.entries(pair[p]).map(([n, b]) => [n, c.digest(b)]))])); } diff --git a/npm/agentplugins/scripts/stage-dual-authoring-npm.js b/npm/agentplugins/scripts/stage-dual-authoring-npm.js index 11495eb1..231b3a40 100644 --- a/npm/agentplugins/scripts/stage-dual-authoring-npm.js +++ b/npm/agentplugins/scripts/stage-dual-authoring-npm.js @@ -50,7 +50,8 @@ function blobs(repo, commit, env, closure = "private") { // Check the newly executing helper at the same commit without extending the // historical private/public preparation wrapper_blobs receipt inventories. const packHelper = PREFIX + "scripts/npm-public-contract.js"; - for (const name of [...new Set([...allowlist, packHelper])]) { + const codecHelper = PREFIX + "lib/public-authoring-contract.js"; + for (const name of [...new Set([...allowlist, packHelper, ...(closure === "public" ? [codecHelper] : [])])]) { const entry = run("/usr/bin/git", ["ls-tree", "-z", commit, "--", name], env, repo).toString(); const match = /^(100644|100755) blob ([0-9a-f]{40})\t([^\0]+)\0$/.exec(entry); if (!match || match[3] !== name) throw new Error(`required regular Git blob missing: ${name}`); @@ -61,11 +62,17 @@ function blobs(repo, commit, env, closure = "private") { // A dirty caller may not manufacture an exact-source claim using old blobs. for (const name of ["scripts/npm-public-contract.js", "scripts/stage-dual-authoring-npm.js", "scripts/stage-dual-authoring-candidate.js", "scripts/dual-authoring-candidate.js", ...(closure === "public" ? - ["scripts/stage-authoring-npm.js", "scripts/authoring-release.js", "lib/public-authoring.js"] : [])]) { + ["scripts/stage-authoring-npm.js", "scripts/authoring-release.js", "lib/public-authoring.js", "lib/public-authoring-contract.js"] : [])]) { if (!c.readFile(path.resolve(__dirname, "..", name)).equals(result[PREFIX + name].bytes)) { throw new Error(`executing stager differs from committed source: ${name}`); } } + if (closure === "public") { + const file = path.resolve(__dirname, "../lib/public-authoring-contract.js"); + if ((fs.lstatSync(file).mode & 0o777) !== (result[codecHelper].mode === "100755" ? 0o755 : 0o644)) { + throw new Error("executing codec mode differs from committed source"); + } + } if (closure === "stage") { // Every listed checkout byte AND every executing-tree byte must be F. Keep // legacy preparation inventories/checks unchanged; stage has its own set. diff --git a/npm/agentplugins/test/authoring-public-stage.test.js b/npm/agentplugins/test/authoring-public-stage.test.js index e33c2d9f..b2ea7d28 100644 --- a/npm/agentplugins/test/authoring-public-stage.test.js +++ b/npm/agentplugins/test/authoring-public-stage.test.js @@ -18,7 +18,7 @@ const clone = v => JSON.parse(JSON.stringify(v)); const inventory = p => ["LICENSE", "README.md", "package.json", `bin/${p}.js`, "bin/package.json", "lib/package.json", "lib/platform.js", "lib/verifier.js", "lib/public-authoring.js", p === "agentplugins" ? "lib/bootstrap.js" : "lib/install.js", "scripts/package.json", - "scripts/dual-authoring-candidate.js", "public-release.json", "release-manifest.json", "native-inputs.json"].sort(); + "scripts/dual-authoring-candidate.js", "public-release.json", "release-manifest.json", "native-inputs.json", "lib/public-authoring-contract.js", "lib/public-authoring-input.js"].sort(); const assertions = ["authenticated_native_inputs", "exact_preparation_binding", "exact_source_blobs", "exact_generated_closures", "exact_pack_entries_modes_bytes", "both_products_complete", "shared_runtime_bytes_equal", "pack_once", "inputs_unchanged", "no_native_execution", "no_publication"]; @@ -377,14 +377,14 @@ test("C1 pure v1 regression: same descriptor, metadata, null/private and all oth const f = fixture(), pair = stage.pairedPackageFiles(f.source, f.manifests, f.inputBytes); for (const p of products) { const v1 = stage.packageFiles(p, f.source, f.manifests[p], { identity: f.input.identity, manifestDigest: f.input.candidate_sha256 }); - assert.deepEqual(Object.keys(v1).sort(), inventory(p).filter(n => n !== "native-inputs.json")); + assert.deepEqual(Object.keys(v1).sort(), inventory(p).filter(n => !["native-inputs.json", "lib/public-authoring-contract.js", "lib/public-authoring-input.js"].includes(n))); assert.deepEqual(v1["public-release.json"], json({ schema: "dual-authoring-public-npm/v1", product: p, npm_package: inputs.PACKAGES[p], identity: f.input.identity, authoring_mode: "release-cli-contract-v1", asset_scope: "six-platform-pair", candidate_sha256: f.input.candidate_sha256, release_manifest_sha256: c.digest(f.manifests[p]), qualification: null })); const base = JSON.parse(f.source[`npm/${p}/package.json`].bytes); assert.deepEqual(v1["package.json"], json({ ...base, version: f.input.identity.versions[p], private: true, - files: inventory(p).filter(n => n !== "native-inputs.json") })); + files: inventory(p).filter(n => !["native-inputs.json", "lib/public-authoring-contract.js", "lib/public-authoring-input.js"].includes(n)) })); for (const n of Object.keys(v1).filter(n => !["package.json", "public-release.json"].includes(n))) assert.deepEqual(v1[n], pair[p][n]); } }); @@ -398,6 +398,7 @@ test("C1 pure inventories: separate exact stage additions and unchanged legacy e .map(n => prefix + "scripts/" + n)]; assert.deepEqual(stage.ALLOWLIST, legacy); assert.deepEqual(stage.STAGE_ALLOWLIST, [...legacy, + prefix + "lib/public-authoring-contract.js", prefix + "lib/public-authoring-input.js", ...["authoring-native-inputs.js", "authoring-promotion.js", "authoring-native-qualification.js", "platform-proof.js", "npm-public-contract.js"].map(n => prefix + "scripts/" + n), "scripts/read-authoring-evidence-zip.py", ".github/workflows/agentplugins-release.yml", ".github/workflows/agentplugins-npm-publish.yml"]); @@ -406,25 +407,21 @@ test("C1 pure inventories: separate exact stage additions and unchanged legacy e "encodeStage", "decodeStage", "pairedPackageFiles", "STAGE_ALLOWLIST", "stagePrepublication", "readStage", "validateUnsignedStage", "main"]); }); -test("C1 pure runtime regression: existing loadRelease rejects v2 and v1/null using only in-memory reads", t => { +test("C1 pure runtime regression: loadRelease accepts structural v2 and rejects v1/null using only in-memory reads", t => { const f = fixture(), pair = stage.pairedPackageFiles(f.source, f.manifests, f.inputBytes); - let files, reads; - t.mock.method(c, "safeDirectory", root => root); - t.mock.method(c, "readFile", file => { - const name = file.slice("/unit-package/".length); reads.push(name); - assert.ok(Object.hasOwn(files, name), `unexpected read ${file}`); return files[name]; - }); + const { memory } = require("./public-authoring-v2.test"); for (const p of products) { - files = pair[p]; reads = []; - assert.throws(() => runtime.loadRelease(p, "/unit-package", "linux-amd64"), /public release: unexpected or missing fields/); - assert.deepEqual(reads, ["public-release.json"]); - // Real metadata contract for v1 is closed; supply its required fixture keys. - files = stage.packageFiles(p, f.source, f.manifests[p], { identity: f.input.identity, manifestDigest: f.input.candidate_sha256 }); - const pkg = JSON.parse(files["package.json"]); pkg.bugs = { url: "https://example.invalid/unit" }; - if (p === "agentplugins") { pkg.os = ["darwin", "linux", "win32"]; pkg.cpu = ["x64", "arm64"]; } - files["package.json"] = json(pkg); reads = []; - assert.throws(() => runtime.loadRelease(p, "/unit-package", "linux-amd64"), /not qualified: preparation package/); - assert.deepEqual(reads, ["public-release.json", "package.json", "release-manifest.json"]); + for (const v2 of [true, false]) { + const files = v2 ? pair[p] : stage.packageFiles(p, f.source, f.manifests[p], { identity: f.input.identity, manifestDigest: f.input.candidate_sha256 }); + const pkg = JSON.parse(files["package.json"]); pkg.bugs = { url: "https://example.invalid/unit" }; + if (p === "agentplugins") { pkg.os = ["darwin", "linux", "win32"]; pkg.cpu = ["x64", "arm64"]; } + files["package.json"] = json(pkg); + const m = memory(t, files); + t.mock.method(c, "readFile", file => files[file.slice("/unit-package/".length)]); + if (v2) assert.deepEqual(runtime.loadRelease(p, m.root, "linux-amd64").asset, f.input.products[p].assets["linux-amd64"]); + else assert.throws(() => runtime.loadRelease(p, m.root, "linux-amd64"), /not qualified: preparation package/); + assert.equal(m.handles.size, 0); t.mock.restoreAll(); + } } }); diff --git a/npm/agentplugins/test/public-authoring-v2-pack.test.js b/npm/agentplugins/test/public-authoring-v2-pack.test.js new file mode 100644 index 00000000..ba4494b6 --- /dev/null +++ b/npm/agentplugins/test/public-authoring-v2-pack.test.js @@ -0,0 +1,177 @@ +"use strict"; + +// In-memory package closure and actual JavaScript launcher interfaces only. +const test = require("node:test"); +const assert = require("node:assert/strict"); +const vm = require("node:vm"); +const path = require("node:path"); +const fs = require("node:fs"); +const cp = require("node:child_process"); +const { EventEmitter } = require("node:events"); +const c = require("../scripts/dual-authoring-candidate"); +const codec = require("../lib/public-authoring-contract"); +const facade = require("../scripts/authoring-native-inputs"); +const stage = require("../scripts/stage-authoring-npm"); +const packing = require("../scripts/stage-dual-authoring-npm"); +const { fixture, source, LOCATOR } = require("./public-authoring-v2.test"); +const root = path.resolve(__dirname, "../../.."); +const inventory = p => ["LICENSE", "README.md", "package.json", `bin/${p}.js`, "bin/package.json", "lib/package.json", + "lib/platform.js", "lib/verifier.js", "lib/public-authoring.js", "lib/public-authoring-contract.js", "lib/public-authoring-input.js", + p === "agentplugins" ? "lib/bootstrap.js" : "lib/install.js", "scripts/package.json", "scripts/dual-authoring-candidate.js", + "public-release.json", "release-manifest.json", "native-inputs.json"].sort(); + +function loader(files, overrides = {}, processValue = process) { + const cache = new Map(), loaded = [], builtins = new Set(["fs", "fs/promises", "os", "path", "crypto", "util", "child_process", "https", "http", "zlib", "stream", "stream/promises", "url"]); + function load(name, main = false) { + if (cache.has(name)) return cache.get(name).exports; + assert.ok(Object.hasOwn(files, name), `require escaped the fixed package closure: ${name}`); + const module = { exports: {} }; cache.set(name, module); loaded.push(name); + function localRequire(request) { + const builtin = request.replace(/^node:/, ""); + if (!request.startsWith(".")) { + assert.ok(builtins.has(builtin), `unapproved external module ${request}`); + return Object.hasOwn(overrides, builtin) ? overrides[builtin] : require(`node:${builtin}`); + } + let target = path.posix.normalize(path.posix.join(path.posix.dirname(name), request)); + if (!path.posix.extname(target)) target += ".js"; + return load(target); + } + localRequire.main = main ? module : null; + if (name.endsWith(".json")) module.exports = JSON.parse(files[name]); + else { + const code = files[name].toString().replace(/^#![^\n]*\n/, ""); + const fn = vm.runInNewContext(`(function(require,module,exports,__filename,__dirname){${code}\n})`, + { Buffer, process: processValue, console, setTimeout, clearTimeout, URL, AbortController }, { filename: name }); + fn(localRequire, module, module.exports, `/packed/${name}`, `/packed/${path.posix.dirname(name)}`); + } + return module.exports; + } + return { load, loaded }; +} + +async function launchers(f) { + for (const p of c.PRODUCTS) for (const failed of [false, true]) for (const postinstall of p === "plugin-kit-ai" ? [false, true] : [false]) { + const calls = [], child = new EventEmitter(); + const env = { KEEP: "yes", [LOCATOR]: "/custody/untrusted", NODE_OPTIONS: "preload", UAP_PRIVATE_NPM_ROOT: "private", AGENTPLUGINS_INTERNAL_PROOF_BINARY: "private" }; + const proc = { env, argv: ["node", "launcher", "argument with spaces", "--format", "json"], pid: 12, + platform: "linux", arch: "x64", cwd: () => "/caller", stderr: { write: text => calls.push(["stderr", text]) }, + exit: code => { proc.exitCode = code; }, kill: (pid, signal) => calls.push(["kill", pid, signal]) }; + const fakeFS = { ...fs, lstatSync: () => ({ isFile: () => true }), + readFileSync: file => { assert.equal(file, "/packed/package.json"); return f.pair[p]["package.json"]; } }; + const l = loader(f.pair[p], { fs: fakeFS, child_process: { spawn: (file, args, options) => { + calls.push(["spawn", file, args, options]); return child; + } } }, proc); + const api = l.load("lib/public-authoring.js"); + api.ensureBinary = async (product, options) => { + calls.push(["runtime", product, options]); assert.equal(product, p); assert.equal(options.packageRoot, "/packed"); + if (failed) throw new Error("synthetic runtime rejection"); + return { binaryPath: "/checked/cache/binary", publicAuthoring: true }; + }; + l.load(postinstall ? "lib/install.js" : `bin/${p}.js`, postinstall); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(calls.filter(c => c[0] === "runtime").length, 1); + const spawned = calls.find(c => c[0] === "spawn"); + if (failed || postinstall) assert.equal(spawned, undefined); + else { + assert.equal(spawned[1], "/checked/cache/binary"); assert.deepEqual([...spawned[2]], proc.argv.slice(2)); + assert.equal(spawned[3].cwd, undefined); assert.equal(spawned[3].stdio, "inherit"); + assert.deepEqual({ ...spawned[3].env }, { KEEP: "yes" }); + const exit = child.listeners("exit")[0]; + child.emit("exit", 7, null); assert.equal(proc.exitCode, 7); + exit(null, "SIGTERM"); assert.deepEqual(calls.at(-1), ["kill", 12, "SIGTERM"]); + child.emit("error", new Error("mock spawn")); assert.equal(proc.exitCode, 1); + } + if (failed) assert.equal(proc.exitCode, 1); + } +} + +if (require.main === module) { +test("C2 closure both public packages remain self contained", async () => { + const f = fixture(); + for (const p of c.PRODUCTS) { + const files = f.pair[p]; assert.deepEqual(Object.keys(files).sort(), inventory(p)); assert.equal(Object.keys(files).length, 17); + const pkg = JSON.parse(files["package.json"]), base = JSON.parse(source[`npm/${p}/package.json`].bytes); + assert.deepEqual(pkg, { ...base, version: f.input.identity.versions[p], private: false, files: inventory(p) }); + for (const dir of ["bin", "lib", "scripts"]) assert.deepEqual(JSON.parse(files[`${dir}/package.json`]), { type: "commonjs" }); + const modes = Object.fromEntries(Object.keys(files).map(n => [n, n === `bin/${p}.js` ? 0o755 : 0o644])); + assert.equal(Object.values(modes).filter(n => n === 0o755).length, 1); + const l = loader(files); + for (const n of ["lib/public-authoring.js", "lib/public-authoring-contract.js", "lib/public-authoring-input.js", p === "agentplugins" ? "lib/bootstrap.js" : "lib/install.js"]) l.load(n); + assert.ok(l.loaded.includes("scripts/dual-authoring-candidate.js")); + assert.equal(l.loaded.some(n => /promotion|native-inputs\.js|provider|workflow/.test(n)), false); + const missing = { ...files }; delete missing["lib/public-authoring-contract.js"]; + assert.throws(() => loader(missing).load("lib/public-authoring-contract.js"), /closure/); + } + for (const n of ["native-inputs.json", "lib/public-authoring-contract.js", "lib/public-authoring-input.js", "lib/public-authoring.js"]) { + assert.deepEqual(f.pair.agentplugins[n], f.pair["plugin-kit-ai"][n]); assert.notStrictEqual(f.pair.agentplugins[n], f.pair["plugin-kit-ai"][n]); + } + await launchers(f); +}); + +test("C2 closure executing helpers are pinned at F", t => { + const f = fixture(), commit = f.input.identity.commit; + for (const problem of [null, "HEAD", "missing", "blob", "mode", "checkout", "executing"]) { + const helper = "npm/agentplugins/lib/public-authoring-contract.js"; + const byBlob = new Map(Object.values(source).map(pin => [pin.git_blob, pin.bytes])); + t.mock.method(c, "safeDirectory", value => value); + t.mock.method(cp, "execFileSync", (exe, args) => { + assert.equal(exe, "/usr/bin/git"); + if (args[0] === "rev-parse") return Buffer.from(problem === "HEAD" ? "b".repeat(40) : commit); + if (args[0] === "ls-tree") { + const name = args.at(-1), pin = source[name]; + if (name === helper && problem === "missing") return Buffer.alloc(0); + return Buffer.from(`${name === helper && problem === "mode" ? "120000" : pin.mode} blob ${pin.git_blob}\t${name}\0`); + } + assert.equal(args[0], "cat-file"); + if (problem === "blob" && args[2] === source[helper].git_blob) return Buffer.from("drift"); + return byBlob.get(args[2]); + }); + t.mock.method(c, "readFile", file => { + const name = file.startsWith("/checkout/") ? file.slice(10) : path.relative(root, file); + if (name === helper && ((problem === "checkout" && file.startsWith("/checkout/")) || (problem === "executing" && !file.startsWith("/checkout/")))) return Buffer.from("drift"); + return source[name].bytes; + }); + t.mock.method(fs, "lstatSync", file => ({ mode: source[file.startsWith("/checkout/") ? file.slice(10) : path.relative(root, file)]?.mode === "100755" ? 0o755 : 0o644 })); + if (problem) assert.throws(() => packing.blobs("/checkout", commit, {}, "stage")); + else { + assert.deepEqual(Object.keys(packing.blobs("/checkout", commit, {}, "stage")), stage.STAGE_ALLOWLIST); + assert.deepEqual(Object.keys(packing.blobs("/checkout", commit, {}, "public")), stage.ALLOWLIST); + } + t.mock.restoreAll(); + } + for (const helper of ["lib/public-authoring-contract.js", "lib/public-authoring-input.js"]) { + const name = "npm/agentplugins/" + helper; + for (const mutate of [s => { delete s[name]; }, s => { s.extra = s[name]; }, s => { s[name] = { ...s[name], sha256: "f".repeat(64) }; }, s => { s[name] = { ...s[name], mode: "100755" }; }, + s => { s[name] = { ...s[name], bytes: Buffer.from("changed") }; }]) { + const altered = { ...source }; mutate(altered); assert.throws(() => stage.pairedPackageFiles(altered, f.manifests, f.inputBytes)); + } + } +}); + +test("C2 closure producer codecs and v1 bytes stay compatible", () => { + const f = fixture(); + for (const name of ["encodeInputs", "decodeInputs", "encodeDescriptor", "decodeDescriptor"]) assert.strictEqual(facade[name], codec[name]); + assert.equal(Object.hasOwn(facade, "checks"), false); + for (const name of ["produceInputs", "readInputs", "inputSubjects"]) assert.equal(Object.hasOwn(codec, name), false); + assert.equal(codec.MAX_INPUT_BYTES, 1024 * 1024); assert.equal(codec.MAX_DESCRIPTOR_BYTES, 64 * 1024); + const before = codec.encodeInputs(f.input); assert.deepEqual(codec.decodeInputs(before), f.input); assert.deepEqual(codec.encodeInputs(f.input), before); + for (const p of c.PRODUCTS) { + const v1 = stage.packageFiles(p, source, f.manifests[p], { identity: f.input.identity, manifestDigest: f.input.candidate_sha256 }); + assert.deepEqual(Object.keys(v1).sort(), inventory(p).filter(n => !["native-inputs.json", "lib/public-authoring-contract.js", "lib/public-authoring-input.js"].includes(n))); + const l = loader(v1); l.load("lib/public-authoring.js"); + assert.equal(l.loaded.some(n => /public-authoring-(contract|input)/.test(n)), false); + assert.equal(JSON.parse(v1["public-release.json"]).qualification, null); + for (const name of Object.keys(v1).filter(n => !["public-release.json", "package.json"].includes(n))) assert.deepEqual(v1[name], f.pair[p][name]); + const descriptor = f.pair[p]["public-release.json"]; + assert.deepEqual(codec.encodeDescriptor(codec.decodeDescriptor(descriptor, before, p), before, p), descriptor); + for (const malformed of [Buffer.alloc(0), Buffer.concat([descriptor, Buffer.from(" ")]), Buffer.from("{}")]) { + let first, second; + try { facade.decodeDescriptor(malformed, before, p); } catch (error) { first = error.message; } + try { codec.decodeDescriptor(malformed, before, p); } catch (error) { second = error.message; } + assert.ok(first); assert.equal(first, second); + } + } +}); + +} +module.exports = { loader, launchers }; diff --git a/npm/agentplugins/test/public-authoring-v2.test.js b/npm/agentplugins/test/public-authoring-v2.test.js new file mode 100644 index 00000000..1d97aa48 --- /dev/null +++ b/npm/agentplugins/test/public-authoring-v2.test.js @@ -0,0 +1,438 @@ +"use strict"; + +// Source/unit/interface evidence only: harmless bytes, deterministic fs and +// external-engine outcomes. No archive, native process, transport or custody proof. +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const c = require("../scripts/dual-authoring-candidate"); +const codec = require("../lib/public-authoring-contract"); +const snapshots = require("../lib/public-authoring-input"); +const runtime = require("../lib/public-authoring"); +const v = require("../lib/verifier"); +const stage = require("../scripts/stage-authoring-npm"); +const realpath = fs.realpathSync; +require("../lib/platform"); +const ROOT = path.resolve(__dirname, "../../.."); +const source = Object.fromEntries(stage.STAGE_ALLOWLIST.map(name => { + const bytes = fs.readFileSync(path.join(ROOT, name)); + return [name, { bytes, mode: (fs.lstatSync(path.join(ROOT, name)).mode & 0o111) ? "100755" : "100644", + sha256: c.digest(bytes), git_blob: crypto.createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex") }]; +})); +const clone = value => structuredClone(value); +const hash = n => String(n).padStart(64, "0"); +const LOCATOR = "UAP_PUBLIC_AUTHORING_ASSET_FILE"; +const native = (product, target) => Buffer.from(`harmless ${product} ${target}\n`); +function fixture() { + const input = { schema: codec.INPUT_SCHEMA, identity: { repository: c.REPOSITORY, commit: "a".repeat(40), + engine_revision: "a".repeat(40), versions: { agentplugins: "0.1.99", "plugin-kit-ai": "2.0.0" } }, + authoring_mode: codec.MODE, asset_scope: codec.SCOPE, candidate_sha256: hash(30), pair_marker_sha256: hash(31), products: {}, + preparation: { sha256: hash(32), artifact: { run_id: 101, run_attempt: 2, artifact_id: 301, artifact_sha256: hash(33) } }, + producer: { workflow: codec.WORKFLOW, source: "a".repeat(40), run_id: 201, run_attempt: 3 } }; + const manifests = {}, outers = {}; + for (const product of c.PRODUCTS) { + const assets = {}, version = input.identity.versions[product]; + for (const target of c.TARGETS) { + const binary = native(product, target), outer = product === "agentplugins" ? binary : Buffer.concat([Buffer.from("mock outer\n"), binary]); + assets[target] = { file: c.assetName(product, version, target), ...c.metadata(outer), + binary: { file: c.executableName(product, target), ...c.metadata(binary) } }; + outers[assets[target].file] = outer; + } + input.products[product] = { tag: (product === "agentplugins" ? "agentplugins-v" : "v") + version, + manifest_sha256: hash(40), checksums_sha256: hash(41), assets }; + // Independent literal oracle: no call to the shared projection constructor. + manifests[product] = Buffer.from(JSON.stringify({ schema_version: 3, status: "CANDIDATE", product, + repository: "777genius/universal-agent-plugins", tag: input.products[product].tag, version, + commit: "a".repeat(40), engine_revision: "a".repeat(40), versions: { agentplugins: "0.1.99", "plugin-kit-ai": "2.0.0" }, + candidate_sha256: hash(30), authoring_mode: "release-cli-contract-v1", asset_scope: "six-platform-pair", assets, + release_eligible: false, platform_acceptance: false, attested: false }, null, 2) + "\n"); + const p = input.products[product]; p.manifest_sha256 = c.digest(manifests[product]); + p.checksums_sha256 = c.digest(Buffer.from(c.TARGETS.map(t => `${assets[t].sha256} ${assets[t].file}\n`).join("") + `${p.manifest_sha256} release-manifest.json\n`)); + } + const inputBytes = codec.encodeInputs(input), pair = stage.pairedPackageFiles(source, manifests, inputBytes); + return { input, inputBytes, manifests, pair, outers }; +} + +function memory(t, files, root = "/unit-package") { + const nodes = new Map(), handles = new Map(), calls = [], owner = process.geteuid(); + let serial = 1, fd = 200; + function put(name, body, overrides = {}) { + const dir = body === null; + const node = { body, dev: 1, ino: serial++, mode: dir ? 0o40700 : 0o100644, uid: owner, gid: owner, + nlink: 1, size: dir ? 0 : body.length, mtimeMs: 1, ctimeMs: 1, type: dir ? "directory" : "file", ...overrides }; + nodes.set(name, node); return node; + } + function parents(name) { + let current = path.dirname(name); + while (!nodes.has(current)) { put(current, null, { mode: 0o40755 }); if (current === "/") break; current = path.dirname(current); } + } + for (const [name, bytes] of Object.entries(files)) { const full = path.join(root, name); parents(full); put(full, Buffer.from(bytes)); } + const fault = { action: () => {} }; + const stat = node => ({ ...node, isFile: () => node.type === "file", isDirectory: () => node.type === "directory", isSymbolicLink: () => node.type === "link" }); + function get(name) { const node = nodes.get(name); if (!node) throw Object.assign(new Error(`missing ${name}`), { code: "ENOENT" }); return node; } + for (const [name, fn] of Object.entries({ + lstatSync: name => stat(get(name)), realpathSync: name => { if (name.startsWith(ROOT + "/npm/")) return realpath(name); get(name); return name; }, + openSync: (name, flags) => { assert.equal(flags, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); const id = fd++; handles.set(id, get(name)); return id; }, + fstatSync: id => stat(handles.get(id)), + readSync: (id, buffer, offset, length, position) => { + assert.ok(length <= 65536); return handles.get(id).body.copy(buffer, offset, position, position + length); + }, + closeSync: id => { assert.ok(handles.has(id), "descriptor closed exactly once"); handles.delete(id); } + })) t.mock.method(fs, name, (...args) => { calls.push([name, ...args]); fault.action(name, args); return fn(...args); }); + return { nodes, handles, calls, fault, put, parents, root }; +} + +function engine(t, f, product, warm = false) { + const calls = [], asset = f.input.products[product].assets["linux-amd64"]; + let valid = warm; + const fault = { action: () => {} }; + const invoke = name => { calls.push(name); fault.action(name); }; + const stat = { dev: 2, ino: 2, uid: process.geteuid(), mode: 0o40700, nlink: 1, + isDirectory: () => true, isSymbolicLink: () => false, isFile: () => true }; + const io = Object.fromEntries(["mkdir", "lstat", "mkdtemp", "unlink", "rmdir"].map(name => [name, async () => { + invoke(name); return name === "lstat" ? { ...stat } : name === "mkdtemp" ? "/unit-cache/public-authoring-v2/.download-unit" : undefined; + }])); + t.mock.method(v, "privateDirectory", async () => invoke("directory")); + t.mock.method(v, "acquireLock", async () => { invoke("lock"); return async () => invoke("unlock"); }); + t.mock.method(v, "strictCachedBinary", async (file, pin) => { invoke("strict"); assert.deepEqual(pin, asset.binary); return valid; }); + t.mock.method(v, "commitVerifiedBinary", async (bytes, file, pin) => { + invoke("commit"); assert.deepEqual(bytes, native(product, "linux-amd64")); assert.deepEqual(pin, asset.binary); valid = true; + }); + t.mock.method(v, "downloadFile", async (url, file, pin, options) => { + invoke("download"); assert.equal(url, `https://github.com/${c.REPOSITORY}/releases/download/${f.input.products[product].tag}/${asset.file}`); + assert.deepEqual(pin, asset); assert.deepEqual(Object.keys(options).sort(), ["onOpen", "request", "signal"]); + }); + t.mock.method(c, "readFile", () => { invoke("read-download"); return Buffer.from(f.outers[asset.file]); }); + t.mock.method(c, "unpack", (bytes, name) => { invoke("unpack"); assert.deepEqual(bytes, f.outers[asset.file]); assert.equal(name, asset.binary.file); return native(product, "linux-amd64"); }); + return { calls, fault, stat, options: { packageRoot: "/unit-package", cacheRoot: "/unit-cache", platform: "linux", arch: "x64", io, environment: {} } }; +} +function local(m, f, product) { + const a = f.input.products[product].assets["linux-amd64"], parent = "/custody space ü", file = `${parent}/${a.file}`; + m.put(parent, null); m.put(file, Buffer.from(f.outers[a.file])); return file; +} +function noEffects(e) { assert.deepEqual(e.calls, []); } + +if (require.main === module) { + test("C2 schema both products and six targets", t => { + const f = fixture(); + for (const p of c.PRODUCTS) { + const oracle = codec.projectionBytes(f.input, p); + assert.deepEqual(oracle.manifest, f.manifests[p]); + assert.equal(c.digest(oracle.checksums), f.input.products[p].checksums_sha256); + const m = memory(t, f.pair[p]); + for (const target of c.TARGETS) { + t.diagnostic(`schema ${p}/${target}`); + const r = runtime.loadRelease(p, m.root, target); + assert.deepEqual(r.asset, f.input.products[p].assets[target]); assert.equal(r.descriptor.identity.commit, f.input.identity.commit); + assert.equal(m.handles.size, 0); + } + t.mock.restoreAll(); + } + }); + + test("C2 schema closed metadata and immutable bindings", async t => { + const f = fixture(); + // The 13 existing structural tables cover every nested key/type/canonical + // codec case; here exercise their integration and all package-only fields. + for (const p of c.PRODUCTS) for (const field of ["name", "version", "bin", "engines", "scripts", "private", "files", ...(p === "agentplugins" ? ["os", "cpu"] : [])]) { + t.diagnostic(`package mismatch ${p}/${field}`); + const files = { ...f.pair[p] }, pkg = JSON.parse(files["package.json"]); pkg[field] = "wrong"; files["package.json"] = c.encode(pkg); + const m = memory(t, files), e = engine(t, f, p); + await assert.rejects(runtime.ensureBinary(p, e.options)); noEffects(e); assert.equal(m.handles.size, 0); t.mock.restoreAll(); + } + for (const name of ["native-inputs.json", "public-release.json", "release-manifest.json", "package.json"]) { + const m = memory(t, f.pair.agentplugins), e = engine(t, f, "agentplugins", true); + e.fault.action = phase => { if (phase === "strict") m.nodes.get(`${m.root}/${name}`).ctimeMs++; }; + await assert.rejects(runtime.ensureBinary("agentplugins", e.options), /changed/); assert.equal(m.handles.size, 0); t.mock.restoreAll(); + } + for (const p of c.PRODUCTS) { + const input = clone(f.input); input.products[p].checksums_sha256 = hash(99); + const body = codec.encodeInputs(input), files = { ...f.pair.agentplugins, "native-inputs.json": body }; + const d = JSON.parse(files["public-release.json"]); d.input_binding.sha256 = c.digest(body); files["public-release.json"] = codec.encodeDescriptor(d, body, "agentplugins"); + const m = memory(t, files), e = engine(t, f, "agentplugins"); + await assert.rejects(runtime.ensureBinary("agentplugins", e.options), /projection/); noEffects(e); assert.equal(m.handles.size, 0); t.mock.restoreAll(); + } + }); + + test("C2 locator validates before cold and warm effects", async t => { + const f = fixture(); + const bad = [undefined, null, "", " ", 17, "relative", "file:///custody/asset", "~/asset", "/missing", "/a\0b"]; + for (const warm of [false, true]) { + for (const value of bad) { + t.diagnostic(`locator warm=${warm} value=${JSON.stringify(value)}`); + const m = memory(t, f.pair.agentplugins), e = engine(t, f, "agentplugins", warm); e.options.environment[LOCATOR] = value; + await assert.rejects(runtime.ensureBinary("agentplugins", e.options)); noEffects(e); assert.equal(m.handles.size, 0); t.mock.restoreAll(); + } + for (const change of [ + (m, file) => file.replace("/custody space ü/", "/custody space ü/./"), + (m, file) => file.replace("/custody space ü/", "/custody space ü/../custody space ü/"), + (m, file) => { m.nodes.delete(file); return file; }, + ...["link", "directory", "fifo", "socket", "device"].map(type => (m, file) => { m.nodes.get(file).type = type; return file; }), + ...[{ nlink: 2 }, { uid: process.geteuid() + 1 }, { mode: 0o100666 }, { mode: 0o104644 }, { size: 0 }].map(x => (m, file) => { Object.assign(m.nodes.get(file), x); return file; }), + ...[{ mode: 0o40755 }, { mode: 0o41700 }, { uid: process.geteuid() + 1 }, { type: "link" }].map(x => (m, file) => { Object.assign(m.nodes.get(path.dirname(file)), x); return file; }), + (m, file, e) => { e.options.cacheRoot = "/"; return file; }, + (m, file, e) => { e.options.cacheRoot = path.dirname(file); return file; }, + (m, file, e) => { e.options.cacheRoot = `${path.dirname(file)}/output`; return file; }, + (m, file, e) => { e.options.cacheRoot = m.root; return file; }, + (m, file) => { const dest = `${m.root}/${path.basename(file)}`; m.nodes.set(dest, m.nodes.get(file)); return dest; } + ]) { + const m = memory(t, f.pair.agentplugins), e = engine(t, f, "agentplugins", warm), file = local(m, f, "agentplugins"); + e.options.environment[LOCATOR] = change(m, file, e); + await assert.rejects(runtime.ensureBinary("agentplugins", e.options)); noEffects(e); assert.equal(m.handles.size, 0); t.mock.restoreAll(); + } + } + for (const warm of [false, true]) { + const m = memory(t, f.pair.agentplugins), e = engine(t, f, "agentplugins", warm), file = local(m, f, "agentplugins"); + const old = fs.realpathSync; + t.mock.method(fs, "realpathSync", name => name === path.dirname(file) ? "/alias" : old(name)); + e.options.environment[LOCATOR] = file; + await assert.rejects(runtime.ensureBinary("agentplugins", e.options), /ancestor/); noEffects(e); assert.equal(m.handles.size, 0); t.mock.restoreAll(); + } + + }); + + test("C2 snapshot bounds identity and closure", t => { + const body = Buffer.from("bounded fixture"); + for (const delta of [0, 1, -1]) { + const m = memory(t, { file: body }); + if (delta === 0) { const s = snapshots.snapshotPublicFile(`${m.root}/file`, { kind: "metadata", maximum: body.length }); assert.deepEqual(s.bytes, body); s.bytes.fill(0); s.recheck(); s.close(); s.close(); } + else assert.throws(() => snapshots.snapshotPublicFile(`${m.root}/file`, { kind: "metadata", maximum: body.length + delta, exactSize: body.length + delta })); + assert.equal(m.handles.size, 0); t.mock.restoreAll(); + } + for (const field of ["ino", "dev", "mode", "uid", "gid", "size", "nlink", "mtimeMs", "ctimeMs", "content", "ancestor", "replacement", "missing"]) { + t.diagnostic(`snapshot recheck ${field}`); + const m = memory(t, { file: body }), file = `${m.root}/file`, s = snapshots.snapshotPublicFile(file, { kind: "metadata", maximum: 100 }); + const node = m.nodes.get(file); + if (field === "content") node.body = Buffer.alloc(body.length, 120); + else if (field === "ancestor") m.nodes.get(m.root).ino++; + else if (field === "replacement") m.put(file, body); + else if (field === "missing") m.nodes.delete(file); + else node[field]++; + assert.throws(() => s.recheck()); s.close(); assert.equal(m.handles.size, 0); t.mock.restoreAll(); + } + for (const fault of ["openSync", "readSync", "fstatSync", "lstatSync", "closeSync", "growth", "short"]) { + t.diagnostic(`snapshot fault ${fault}`); + const m = memory(t, { file: body }); let fired = false; + m.fault.action = (name, args) => { + if (!fired && name === (fault === "growth" || fault === "short" ? "readSync" : fault)) { + fired = true; + if (fault === "growth" || fault === "short") m.nodes.get(`${m.root}/file`).body = fault === "growth" ? Buffer.concat([body, body]) : body.subarray(1); + else if (fault === "closeSync") { m.handles.delete(args[0]); throw new Error("mock close failure"); } + else throw new Error(`mock ${fault}`); + } + }; + assert.throws(() => { const s = snapshots.snapshotPublicFile(`${m.root}/file`, { kind: "metadata", maximum: 100 }); s.close(); }); + assert.equal(fired, true); assert.equal(m.handles.size, 0); t.mock.restoreAll(); + } + for (const phase of ["initial", "recheck"]) { + const m = memory(t, { file: body }), controller = new AbortController(); + if (phase === "initial") controller.abort(); + let held; + assert.throws(() => { + held = snapshots.snapshotPublicFile(`${m.root}/file`, { kind: "metadata", maximum: 100, signal: controller.signal }); + controller.abort(); held.recheck(); + }, /cancelled/); + if (held) held.close(); assert.equal(m.handles.size, 0); t.mock.restoreAll(); + } + + }); + + test("C2 local acquisition uses the shared checked engine", async t => { + const f = fixture(); + for (const p of c.PRODUCTS) for (const warm of [false, true]) { + t.diagnostic(`local ${p} warm=${warm}`); + const m = memory(t, f.pair[p]), e = engine(t, f, p, warm), file = local(m, f, p); + e.options.environment[LOCATOR] = file; + const result = await runtime.ensureBinary(p, e.options); + assert.equal(result.cacheHit, warm); assert.notEqual(result.binaryPath, file); assert.equal(e.calls.includes("download"), false); + assert.equal(e.calls.filter(n => n === "unpack").length, p === "plugin-kit-ai" ? 1 : 0); + assert.equal(e.calls.includes("commit"), !warm); assert.equal(m.handles.size, 0); t.mock.restoreAll(); + } + for (const failure of ["outer", "inner", "unpack"]) for (const warm of [false, true]) { + t.diagnostic(`local pin failure ${failure} warm=${warm}`); + const p = "plugin-kit-ai", m = memory(t, f.pair[p]), e = engine(t, f, p, warm), file = local(m, f, p); + e.options.environment[LOCATOR] = file; + if (failure === "outer") m.nodes.get(file).body = Buffer.alloc(m.nodes.get(file).size, 120); + else t.mock.method(c, "unpack", () => { if (failure === "unpack") throw new Error("mock unpack"); return Buffer.from("wrong"); }); + await assert.rejects(runtime.ensureBinary(p, e.options)); noEffects(e); assert.equal(m.handles.size, 0); t.mock.restoreAll(); + } + }); + + test("C2 anonymous acquisition keeps canonical transport", async t => { + const f = fixture(); + for (const p of c.PRODUCTS) { + const m = memory(t, f.pair[p]), e = engine(t, f, p); + e.options.environment = { PLUGIN_KIT_AI_REPOSITORY: "wrong", PLUGIN_KIT_AI_VERSION: "wrong", UAP_PUBLIC_AUTHORING_VERIFIED: "true" }; + const result = await runtime.ensureBinary(p, e.options); + assert.equal(result.repository, c.REPOSITORY); assert.ok(e.calls.indexOf("download") < e.calls.indexOf("commit")); assert.equal(m.handles.size, 0); t.mock.restoreAll(); + } + }); + + test("C2 v2 cache isolation and lock ordering", async t => { + const f = fixture(), paths = new Set(); + for (const p of c.PRODUCTS) for (const target of c.TARGETS) { + const m = memory(t, f.pair[p]), r = runtime.loadRelease(p, m.root, target); + const pathname = runtime.cachePath("/cache", p, target, r); paths.add(pathname); + assert.ok(pathname.startsWith("/cache/public-authoring-v2/")); + assert.notEqual(pathname, runtime.cachePath("/cache", p, target, { ...r, descriptor: { ...r.descriptor, schema: runtime.SCHEMA } })); + for (const changed of [{ ...r, version: "3.0.0" }, { ...r, descriptor: { ...r.descriptor, identity: { ...r.descriptor.identity, commit: "b".repeat(40) } } }, + { ...r, asset: { ...r.asset, binary: { ...r.asset.binary, sha256: hash(99) } } }]) assert.notEqual(pathname, runtime.cachePath("/cache", p, target, changed)); + t.mock.restoreAll(); + } + assert.equal(paths.size, 12); + let expected; + for (const supplied of [false, true]) for (const warm of [false, true]) { + const m = memory(t, f.pair.agentplugins), e = engine(t, f, "agentplugins", warm); + if (supplied) e.options.environment[LOCATOR] = local(m, f, "agentplugins"); + const r = await runtime.ensureBinary("agentplugins", e.options); + if (expected) assert.equal(r.binaryPath, expected); expected = r.binaryPath; + assert.ok(e.calls.indexOf("lock") < e.calls.indexOf("strict")); assert.equal(e.calls.at(-1), "unlock"); + assert.equal(e.calls.filter(n => n === "strict").length, 2); assert.equal(m.handles.size, 0); t.mock.restoreAll(); + } + for (const problem of ["timeout", "unsafe", "directory replacement", "final binary"]) { + t.diagnostic(`cache fault ${problem}`); + const m = memory(t, f.pair.agentplugins), e = engine(t, f, "agentplugins", true); + let strict = 0; + e.fault.action = phase => { + if (problem === "timeout" && phase === "lock") throw Object.assign(new Error("mock lock timeout"), { code: "ETIMEDOUT" }); + if (problem === "unsafe" && phase === "strict") throw new Error("mock unsafe cached object"); + if (problem === "directory replacement" && phase === "strict") e.stat.ino++; + }; + if (problem === "final binary") t.mock.method(v, "strictCachedBinary", async () => ++strict === 1); + await assert.rejects(runtime.ensureBinary("agentplugins", e.options), /timeout|unsafe|replaced|final public/); + assert.equal(e.calls.filter(n => n === "lock").length, 1); assert.equal(e.calls.includes("commit"), false); + assert.equal(m.handles.size, 0); t.mock.restoreAll(); + } + + }); + + test("C2 failures cancel and preserve caller state", async t => { + const f = fixture(); + for (const phase of ["mkdir", "directory", "lock", "strict", "download", "commit", "unlock", "cancel", "precommit", "final", "close"]) { + t.diagnostic(`acquisition failure ${phase}`); + const m = memory(t, f.pair.agentplugins), e = engine(t, f, "agentplugins"), controller = new AbortController(); e.options.signal = controller.signal; + const original = Buffer.from(m.nodes.get(`${m.root}/package.json`).body); let fired = false; + if (phase === "close") m.fault.action = (name, args) => { if (name === "closeSync" && e.calls.includes("commit")) { m.handles.delete(args[0]); throw new Error("mock close failure"); } }; + else e.fault.action = name => { + if (phase === "cancel" && name === "lock") controller.abort(); + if (phase === "precommit" && name === "read-download") m.nodes.get(`${m.root}/package.json`).ino++; + if (phase === "final" && name === "commit") m.nodes.get(`${m.root}/package.json`).ctimeMs++; + if (name === phase && !fired) { fired = true; throw new Error(`mock ${phase}`); } + }; + await assert.rejects(runtime.ensureBinary("agentplugins", e.options)); + assert.deepEqual(m.nodes.get(`${m.root}/package.json`).body, original); assert.equal(m.handles.size, 0); t.mock.restoreAll(); + } + for (const warm of [false, true]) for (const checkpoint of warm ? ["unpack", "lock", "strict"] : ["unpack", "lock", "strict", "commit"]) { + for (const field of ["ino", "uid", "mode", "nlink", "mtimeMs", "ctimeMs", "content", "parent"]) { + t.diagnostic(`custody recheck warm=${warm} checkpoint=${checkpoint} field=${field}`); + const p = "plugin-kit-ai", m = memory(t, f.pair[p]), e = engine(t, f, p, warm), file = local(m, f, p); + e.options.environment[LOCATOR] = file; let changed = false; + e.fault.action = phase => { + if (phase !== checkpoint || changed) return; changed = true; + if (field === "content") m.nodes.get(file).body = Buffer.alloc(m.nodes.get(file).size, 120); + else if (field === "parent") m.nodes.get(path.dirname(file)).ino++; + else m.nodes.get(file)[field]++; + }; + await assert.rejects(runtime.ensureBinary(p, e.options), /changed|unsafe|regular|custody/); + assert.equal(changed, true); assert.equal(m.handles.size, 0); + if (checkpoint === "unpack") assert.deepEqual(e.calls, ["unpack"]); + t.mock.restoreAll(); + } + } + for (const checkpoint of ["lock", "download", "commit"]) { + const m = memory(t, f.pair.agentplugins), e = engine(t, f, "agentplugins"); + const primary = new Error(`mock primary ${checkpoint}`), cleanup = new Error("mock unlock uncertainty"); + e.fault.action = phase => { if (phase === checkpoint) throw primary; if (phase === "unlock") throw cleanup; }; + let caught; + try { await runtime.ensureBinary("agentplugins", e.options); } catch (error) { caught = error; } + assert.ok(caught); + if (checkpoint === "lock") assert.strictEqual(caught, primary); + else { assert.ok(caught.message.includes(primary.message)); assert.ok(caught.message.includes(cleanup.message)); } + assert.equal(m.handles.size, 0); t.mock.restoreAll(); + } + + }); + + test("C2 terminal cancellation rejects after successful awaited unlock", async t => { + const f = fixture(); + for (const p of c.PRODUCTS) for (const warm of [false, true]) for (const supplied of [false, true]) { + t.diagnostic(`terminal cancel ${p} warm=${warm} local=${supplied}`); + const m = memory(t, f.pair[p]), e = engine(t, f, p, warm), controller = new AbortController(); + e.options.signal = controller.signal; + if (supplied) e.options.environment[LOCATOR] = local(m, f, p); + t.mock.method(v, "acquireLock", async () => { + e.calls.push("lock"); + return async () => { await Promise.resolve(); assert.ok(m.handles.size >= 4); e.calls.push("unlock"); controller.abort(); }; + }); + await assert.rejects(runtime.ensureBinary(p, e.options), /cancelled/); + assert.equal(controller.signal.aborted, true); assert.equal(e.calls.at(-1), "unlock"); + assert.equal(e.calls.includes("commit"), !warm); assert.equal(m.handles.size, 0); + // A late rejection may leave the verified cache entry for a fresh operation. + e.options.signal = undefined; + e.fault.action = () => {}; + t.mock.method(v, "acquireLock", async () => async () => {}); + assert.equal((await runtime.ensureBinary(p, e.options)).cacheHit, true); + assert.equal(m.handles.size, 0); t.mock.restoreAll(); + } + }); + + test("C2 terminal retained inputs and placement reject after successful awaited unlock", async t => { + const f = fixture(); + for (const p of c.PRODUCTS) for (const warm of [false, true]) for (const field of ["package.json", "native-inputs.json", "public-release.json", "release-manifest.json", "locator", "placement"]) { + t.diagnostic(`terminal replacement ${p} warm=${warm} field=${field}`); + const m = memory(t, f.pair[p]), e = engine(t, f, p, warm), file = local(m, f, p); + e.options.environment[LOCATOR] = file; + if (field === "placement") m.put("/unit-cache", null); + let changed = false; + t.mock.method(v, "acquireLock", async () => { + e.calls.push("lock"); + return async () => { + await Promise.resolve(); assert.equal(m.handles.size, 5); e.calls.push("unlock"); + if (field === "placement") m.put("/unit-cache", null, { type: "link" }); + else { const name = field === "locator" ? file : `${m.root}/${field}`; m.put(name, Buffer.from(m.nodes.get(name).body)); } + changed = true; + }; + }); + await assert.rejects(runtime.ensureBinary(p, e.options), /changed|unsafe ancestor/); + assert.equal(changed, true); assert.equal(e.calls.at(-1), "unlock"); + assert.equal(e.calls.includes("commit"), !warm); assert.equal(m.handles.size, 0); t.mock.restoreAll(); + } + }); + + test("C2 terminal primary and snapshot close errors aggregate", async t => { + const f = fixture(), m = memory(t, f.pair.agentplugins), e = engine(t, f, "agentplugins"), controller = new AbortController(); + e.options.signal = controller.signal; + e.fault.action = phase => { if (phase === "unlock") controller.abort(); }; + m.fault.action = (name, args) => { + if (name === "closeSync" && e.calls.includes("unlock")) { m.handles.delete(args[0]); throw new Error("terminal close uncertainty"); } + }; + await assert.rejects(runtime.ensureBinary("agentplugins", e.options), error => { + assert.match(error.message, /cancelled/); assert.match(error.message, /terminal close uncertainty/); return true; + }); + assert.equal(m.handles.size, 0); + }); + + test("C2 v1 null and legacy contracts remain unchanged", async t => { + const f = fixture(); + for (const p of c.PRODUCTS) { + const files = stage.packageFiles(p, source, f.manifests[p], { identity: f.input.identity, manifestDigest: f.input.candidate_sha256 }); + assert.equal(Object.keys(files).length, 14); assert.equal(JSON.parse(files["package.json"]).private, true); + for (const locator of ["/anything", ""]) { + const m = memory(t, files), e = engine(t, f, p); + t.mock.method(c, "readFile", file => files[path.basename(file)]); + e.options.environment[LOCATOR] = locator; + await assert.rejects(runtime.ensureBinary(p, e.options), /not qualified/); noEffects(e); assert.equal(m.handles.size, 0); t.mock.restoreAll(); + } + } + }); + + test("C2 child environment removes the locator", async () => { + await require("./public-authoring-v2-pack.test").launchers(fixture()); + assert.deepEqual(runtime.childEnvironment({ [LOCATOR]: "/untrusted", NODE_OPTIONS: "preload", UAP_PUBLIC_AUTHORING_TEST: "true", + UAP_PRIVATE_NPM_ROOT: "private", AGENTPLUGINS_INTERNAL_PROOF_BINARY: "private", PLUGIN_KIT_AI_VERSION: "wrong", + PLUGIN_KIT_AI_REPOSITORY: "wrong", PLUGIN_KIT_AI_RELEASE_BASE_URL: "wrong", KEEP: "yes", PATH: "/ordinary" }), { KEEP: "yes", PATH: "/ordinary" }); + }); +} +module.exports = { fixture, memory, engine, source, local, LOCATOR, native }; diff --git a/npm/agentplugins/test/public-authoring.test.js b/npm/agentplugins/test/public-authoring.test.js index e29634a7..210d051b 100644 --- a/npm/agentplugins/test/public-authoring.test.js +++ b/npm/agentplugins/test/public-authoring.test.js @@ -57,6 +57,15 @@ function fixture(product, binaryFor = (p, t) => Buffer.from(`fixture ${p} ${t}\n }; return { root, packageRoot, cacheRoot, descriptor, manifest, bodies, files, identity, qualify, save }; } +function mockNativeBytes(t) { + const prefix = Buffer.from("synthetic outer\n"); + t.mock.method(c, "archive", (bytes, name) => Buffer.concat([prefix, Buffer.from(name + "\n"), bytes])); + t.mock.method(c, "unpack", (bytes, name) => { + const header = Buffer.concat([prefix, Buffer.from(name + "\n")]); + assert.deepEqual(bytes.subarray(0, header.length), header); + return bytes.subarray(header.length); + }); +} function transport(f, seen = [], alter = res => res) { return (url, options) => { seen.push(url.toString()); @@ -81,7 +90,8 @@ const target = `${process.platform === "win32" ? "windows" : process.platform}-$ if (require.main === module) { for (const p of c.PRODUCTS) { - test(`${p}: preparation and forged bypass reject before any cache/download effect`, async () => { + test(`${p}: preparation and forged bypass reject before any cache/download effect`, async t => { + mockNativeBytes(t); const f = fixture(p); const before = fs.readdirSync(f.cacheRoot); let requests = 0; @@ -91,7 +101,8 @@ if (require.main === module) { assert.equal(requests, 0); assert.deepEqual(fs.readdirSync(f.cacheRoot), before); } finally { delete process.env.UAP_PUBLIC_AUTHORING_VERIFIED; } }); - test(`${p}: cold, offline warm, corrupt recovery and isolation`, async () => { + test(`${p}: cold, offline warm, corrupt recovery and isolation`, async t => { + mockNativeBytes(t); const f = fixture(p); f.qualify(); const seen = []; const cold = await publicAPI.ensureBinary(p, options(f, { request: transport(f, seen) })); assert.equal(cold.cacheHit, false); assert.equal(seen.length, 1); @@ -139,7 +150,8 @@ if (require.main === module) { await assert.rejects(publicAPI.ensureBinary(p, options(f, { request: () => assert.fail("invalid metadata downloaded") }))); } }); - test(`${p}: canonical JSON, package binding and unsafe metadata files`, async () => { + test(`${p}: canonical JSON, package binding and unsafe metadata files`, async t => { + mockNativeBytes(t); for (const name of ["public-release.json", "release-manifest.json", "package.json"]) { for (const change of [b => Buffer.from(b.toString().replace("{", '{"duplicate":1,"duplicate":2,')), b => Buffer.concat([b, Buffer.from([0xff])]), b => Buffer.from(b.toString().trim())]) {