diff --git a/npm/agentplugins/lib/bootstrap.js b/npm/agentplugins/lib/bootstrap.js index 8a0664431..0e7d227e6 100644 --- a/npm/agentplugins/lib/bootstrap.js +++ b/npm/agentplugins/lib/bootstrap.js @@ -3,17 +3,30 @@ const crypto = require("node:crypto"); const fs = require("node:fs"); const fsp = require("node:fs/promises"); -const https = require("node:https"); const os = require("node:os"); const path = require("node:path"); const { cacheRoot, detectPlatform, expectedAssetName } = require("./platform"); +const verifier = require("./verifier"); +const { sha256File, validCachedBinary, downloadFile } = verifier; + +function acquireLock(target, options = {}) { + const lockRoot = options.lockRoot || path.join(cacheRoot( + options.environment || process.env, options.platform || process.platform, + options.home || os.homedir() + ), ".locks"); + return verifier.acquireLock(target, { ...options, lockRoot }); +} + +function installVerifiedBinary(downloaded, binaryPath, release, platformInfo, lockRoot) { + return verifier.installVerifiedBinary(downloaded, binaryPath, release.asset, { + osName: platformInfo.osName, lockRoot + }); +} + const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; const DIGEST = /^[0-9a-f]{64}$/; -const MAX_REDIRECTS = 5; -const DOWNLOAD_TIMEOUT_MS = 30_000; -const LOCK_TIMEOUT_MS = 30_000; const PROOF_MODE = "local-frozen-release-asset-v1"; const PRODUCER_REPOSITORY = "777genius/plugin-kit-ai"; const HISTORICAL_EVIDENCE_COMMIT = "4b25a45e1574bab7a4f49e48905a3b3b2647e917"; @@ -92,30 +105,6 @@ function loadRelease(packageRoot, platformInfo) { return { asset, manifest, version }; } -async function sha256File(file) { - const hash = crypto.createHash("sha256"); - const stream = fs.createReadStream(file); - for await (const chunk of stream) { - hash.update(chunk); - } - return hash.digest("hex"); -} - -async function validCachedBinary(file, expectedHash) { - try { - const stat = await fsp.lstat(file); - if (!stat.isFile() || stat.isSymbolicLink()) { - return false; - } - return (await sha256File(file)) === expectedHash; - } catch (error) { - if (error && error.code === "ENOENT") { - return false; - } - throw error; - } -} - function localProofAsset(environment = {}) { const mode = String(environment.AGENTPLUGINS_INTERNAL_PROOF_MODE || "").trim(); const file = String(environment.AGENTPLUGINS_INTERNAL_PROOF_BINARY || "").trim(); @@ -134,240 +123,6 @@ async function verifyLocalProofAsset(file, expected) { } } -function validateDownloadURL(value) { - const parsed = new URL(value); - if (parsed.protocol !== "https:" || parsed.port) { - throw new Error("binary download and every redirect must use an approved GitHub HTTPS host"); - } - if (parsed.username || parsed.password) { - throw new Error("binary download URL cannot contain credentials"); - } - const pathAndQuery = `${parsed.pathname}${parsed.search}`; - switch (parsed.hostname) { - case "github.com": - return { hostname: "github.com", path: pathAndQuery, url: parsed }; - case "release-assets.githubusercontent.com": - return { hostname: "release-assets.githubusercontent.com", path: pathAndQuery, url: parsed }; - default: - throw new Error("binary download and every redirect must use an approved GitHub HTTPS host"); - } -} - -function requestApprovedTarget(target, requestOptions) { - const options = { - ...requestOptions, - method: "GET", - path: target.path, - port: 443, - protocol: "https:" - }; - switch (target.hostname) { - case "github.com": - return https.get({ ...options, hostname: "github.com" }); - case "release-assets.githubusercontent.com": - return https.get({ ...options, hostname: "release-assets.githubusercontent.com" }); - default: - throw new Error("binary download host was not validated"); - } -} - -async function downloadFile(value, destination, expected, options = {}, redirects = MAX_REDIRECTS) { - const target = validateDownloadURL(value); - await new Promise((resolve, reject) => { - const requestOptions = { - headers: { - Accept: "application/octet-stream", - "User-Agent": "agentplugins-npm-bootstrap" - } - }; - const request = typeof options.request === "function" - ? options.request(target.url, requestOptions) - : requestApprovedTarget(target, requestOptions); - request.setTimeout(DOWNLOAD_TIMEOUT_MS, () => request.destroy(new Error("binary download timed out"))); - request.once("error", reject); - request.once("response", (response) => { - if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) { - response.resume(); - if (redirects <= 0) { - reject(new Error("too many binary download redirects")); - return; - } - const next = new URL(response.headers.location, target.url).toString(); - downloadFile(next, destination, expected, options, redirects - 1).then(resolve, reject); - return; - } - if (response.statusCode < 200 || response.statusCode > 299) { - response.resume(); - reject(new Error(`binary download failed with HTTP ${response.statusCode}`)); - return; - } - const declaredLength = Number(response.headers["content-length"] || 0); - if (declaredLength && declaredLength !== expected.size) { - response.resume(); - reject(new Error("binary download size does not match embedded metadata")); - return; - } - const output = fs.createWriteStream(destination, { flags: "wx", mode: 0o600 }); - const hash = crypto.createHash("sha256"); - let size = 0; - let settled = false; - let verified = false; - let pendingError; - const fail = (error) => { - if (settled || pendingError) return; - pendingError = error; - response.destroy(); - output.destroy(); - }; - output.once("error", fail); - response.once("error", fail); - response.on("data", (chunk) => { - size += chunk.length; - if (size > expected.size) { - fail(new Error("binary download exceeded embedded size")); - return; - } - hash.update(chunk); - }); - response.pipe(output); - output.once("finish", () => { - if (settled || pendingError) return; - if (size !== expected.size || hash.digest("hex") !== expected.sha256) { - fail(new Error("binary download failed embedded SHA-256 verification")); - return; - } - verified = true; - }); - output.once("close", () => { - if (settled) return; - settled = true; - if (pendingError) { - reject(pendingError); - return; - } - if (!verified) { - reject(new Error("binary download closed before verification completed")); - return; - } - resolve(); - }); - }); - }); -} - -function delay(milliseconds) { - return new Promise((resolve) => setTimeout(resolve, milliseconds)); -} - -async function acquireLock(target, options = {}) { - const lockRoot = options.lockRoot || path.join(cacheRoot( - options.environment || process.env, - options.platform || process.platform, - options.home || os.homedir() - ), ".locks"); - await fsp.mkdir(lockRoot, { recursive: true, mode: 0o700 }); - const rootStat = await fsp.lstat(lockRoot); - const expectedUid = typeof process.geteuid === "function" ? process.geteuid() : null; - if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || - (expectedUid !== null && rootStat.uid !== expectedUid) || - (process.platform !== "win32" && (rootStat.mode & 0o777) !== 0o700)) { - throw new Error("agentplugins cache lock root must be a user-owned mode-0700 real directory"); - } - const name = crypto.createHash("sha256").update(target).digest("hex") + ".lock"; - const lockPath = path.join(lockRoot, name); - const started = Date.now(); - const timeoutMs = options.timeoutMs ?? LOCK_TIMEOUT_MS; - const pollMs = options.pollMs ?? 50; - let released = false; - while (true) { - try { - const handle = await fsp.open(lockPath, "wx", 0o600); - try { - await handle.writeFile(JSON.stringify({ - pid: process.pid, - nonce: crypto.randomBytes(16).toString("hex") - }) + "\n"); - } catch (error) { - await handle.close().catch(() => {}); - await fsp.rm(lockPath, { force: true }).catch(() => {}); - throw error; - } - return async () => { - if (released) return; - released = true; - let closeError; - try { - await handle.close(); - } catch (error) { - closeError = error; - } - await fsp.rm(lockPath, { force: true }); - if (closeError) throw closeError; - }; - } catch (error) { - if (!error || error.code !== "EEXIST") throw error; - if (Date.now() - started > timeoutMs) { - throw new Error(`timed out waiting for the agentplugins binary cache lock at ${lockPath}; remove it only after confirming no agentplugins or npm process is running`); - } - await delay(pollMs); - } - } -} - -async function installVerifiedBinary(downloaded, binaryPath, release, platformInfo, lockRoot) { - const releaseLock = await acquireLock(binaryPath, { lockRoot }); - try { - if (await validCachedBinary(binaryPath, release.asset.sha256)) { - return binaryPath; - } - try { - const existing = await fsp.lstat(binaryPath); - if (!existing.isFile() || existing.isSymbolicLink()) { - throw new Error("binary cache target exists but is not a regular file; refusing to move or replace it"); - } - } catch (error) { - if (!error || error.code !== "ENOENT") throw error; - } - const parent = path.dirname(binaryPath); - await fsp.mkdir(parent, { recursive: true, mode: 0o700 }); - const staging = path.join(parent, `.agentplugins-staging-${process.pid}-${crypto.randomBytes(6).toString("hex")}`); - const quarantine = path.join(parent, `.agentplugins-replaced-${process.pid}-${crypto.randomBytes(6).toString("hex")}`); - await fsp.copyFile(downloaded, staging, fs.constants.COPYFILE_EXCL); - if (platformInfo.osName !== "windows") { - await fsp.chmod(staging, 0o755); - } - if (!(await validCachedBinary(staging, release.asset.sha256))) { - await fsp.rm(staging, { force: true }); - throw new Error("staged binary failed repeated SHA-256 verification"); - } - let replaced = false; - try { - await fsp.rename(binaryPath, quarantine); - replaced = true; - } catch (error) { - if (!error || error.code !== "ENOENT") { - await fsp.rm(staging, { force: true }); - throw error; - } - } - try { - await fsp.rename(staging, binaryPath); - } catch (error) { - if (replaced) { - await fsp.rename(quarantine, binaryPath).catch(() => {}); - } - throw error; - } - await fsp.rm(quarantine, { force: true }); - if (!(await validCachedBinary(binaryPath, release.asset.sha256))) { - throw new Error("committed binary failed repeated SHA-256 verification"); - } - return binaryPath; - } finally { - await releaseLock(); - } -} - async function ensureBinary(options = {}) { const packageRoot = options.packageRoot || path.resolve(__dirname, ".."); const platformInfo = detectPlatform(options.platform, options.arch); diff --git a/npm/agentplugins/lib/verifier.js b/npm/agentplugins/lib/verifier.js new file mode 100644 index 000000000..f772a723e --- /dev/null +++ b/npm/agentplugins/lib/verifier.js @@ -0,0 +1,402 @@ +"use strict"; + +// Metadata-neutral primitives. Release selection stays in each explicit facade. +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const fsp = require("node:fs/promises"); +const https = require("node:https"); +const path = require("node:path"); +const MAX_REDIRECTS = 5; +const DOWNLOAD_TIMEOUT_MS = 30_000; +const LOCK_TIMEOUT_MS = 30_000; + +async function sha256File(file) { + const hash = crypto.createHash("sha256"); + const stream = fs.createReadStream(file); + for await (const chunk of stream) { + hash.update(chunk); + } + return hash.digest("hex"); +} + +async function validCachedBinary(file, expectedHash) { + try { + const stat = await fsp.lstat(file); + if (!stat.isFile() || stat.isSymbolicLink()) { + return false; + } + return (await sha256File(file)) === expectedHash; + } catch (error) { + if (error && error.code === "ENOENT") { + return false; + } + throw error; + } +} + +function validateDownloadURL(value) { + const parsed = new URL(value); + if (parsed.protocol !== "https:" || parsed.port) { + throw new Error("binary download and every redirect must use an approved GitHub HTTPS host"); + } + if (parsed.username || parsed.password) { + throw new Error("binary download URL cannot contain credentials"); + } + const pathAndQuery = `${parsed.pathname}${parsed.search}`; + switch (parsed.hostname) { + case "github.com": + return { hostname: "github.com", path: pathAndQuery, url: parsed }; + case "release-assets.githubusercontent.com": + return { hostname: "release-assets.githubusercontent.com", path: pathAndQuery, url: parsed }; + default: + throw new Error("binary download and every redirect must use an approved GitHub HTTPS host"); + } +} + +function requestApprovedTarget(target, requestOptions) { + const options = { + ...requestOptions, + method: "GET", + path: target.path, + port: 443, + protocol: "https:" + }; + switch (target.hostname) { + case "github.com": + return https.get({ ...options, hostname: "github.com" }); + case "release-assets.githubusercontent.com": + return https.get({ ...options, hostname: "release-assets.githubusercontent.com" }); + default: + throw new Error("binary download host was not validated"); + } +} + +async function downloadFile(value, destination, expected, options = {}, redirects = MAX_REDIRECTS) { + const target = validateDownloadURL(value); + await new Promise((resolve, reject) => { + const requestOptions = { + headers: { + Accept: "application/octet-stream", + "User-Agent": "agentplugins-npm-bootstrap" + } + }; + const request = typeof options.request === "function" + ? options.request(target.url, requestOptions) + : requestApprovedTarget(target, requestOptions); + request.setTimeout(DOWNLOAD_TIMEOUT_MS, () => request.destroy(new Error("binary download timed out"))); + let streamFailure; + request.once("error", error => streamFailure ? streamFailure(error) : reject(error)); + request.once("response", (response) => { + if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) { + response.resume(); + if (redirects <= 0) { + reject(new Error("too many binary download redirects")); + return; + } + try { + const next = new URL(response.headers.location, target.url).toString(); + downloadFile(next, destination, expected, options, redirects - 1).then(resolve, reject); + } catch (error) { reject(error); } + return; + } + if (response.statusCode < 200 || response.statusCode > 299) { + response.resume(); + reject(new Error(`binary download failed with HTTP ${response.statusCode}`)); + return; + } + const length = response.headers["content-length"]; + const numericLength = Number(length); + if (length !== undefined && (!/^[0-9]+$/.test(String(length)) || + !Number.isSafeInteger(numericLength) || numericLength <= 0 || numericLength !== expected.size)) { + response.resume(); + reject(new Error("binary download size does not match embedded metadata")); + return; + } + const output = fs.createWriteStream(destination, { flags: "wx", mode: 0o600 }); + const hash = crypto.createHash("sha256"); + let size = 0; + let settled = false; + let verified = false; + let pendingError; + const fail = (error) => { + if (settled || pendingError) return; + pendingError = error; + response.destroy(); + output.destroy(); + }; + streamFailure = fail; + let ended = false; + response.once("end", () => { ended = true; }); + response.once("close", () => { if (!ended) fail(new Error("binary download response closed before completion")); }); + output.once("error", fail); + response.once("error", fail); + response.on("data", (chunk) => { + size += chunk.length; + if (size > expected.size) { + fail(new Error("binary download exceeded embedded size")); + return; + } + hash.update(chunk); + }); + response.pipe(output); + output.once("finish", () => { + if (settled || pendingError) return; + if (size !== expected.size || hash.digest("hex") !== expected.sha256) { + fail(new Error("binary download failed embedded SHA-256 verification")); + return; + } + verified = true; + }); + output.once("close", () => { + if (settled) return; + settled = true; + if (pendingError) { + reject(pendingError); + return; + } + if (!verified) { + reject(new Error("binary download closed before verification completed")); + return; + } + resolve(); + }); + }); + }); +} + +function cancelled(signal) { + if (signal && signal.aborted) throw new Error("binary acquisition cancelled"); +} + +function delay(milliseconds, signal) { + return new Promise((resolve, reject) => { + const abort = () => { clearTimeout(timer); signal?.removeEventListener("abort", abort); reject(new Error("binary acquisition cancelled")); }; + const timer = setTimeout(() => { signal?.removeEventListener("abort", abort); resolve(); }, milliseconds); + signal?.addEventListener("abort", abort, { once: true }); + if (signal?.aborted) abort(); + }); +} + +const sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino; +const uid = () => typeof process.geteuid === "function" ? process.geteuid() : null; +function ownedFile(stat) { + if (!stat.isFile() || stat.nlink !== 1 || (uid() !== null && stat.uid !== uid()) || + (process.platform !== "win32" && (stat.mode & 0o7022))) { + throw new Error("unsafe binary cache file; refusing to move or replace it"); + } +} + +// Every component is real. Shared ancestors may be root-owned; a writable +// ancestor requires sticky protection. The root and all descendants are 0700. +async function privateDirectory(root, directory = root, io = fsp) { + if (typeof root !== "string" || !path.isAbsolute(root) || path.resolve(root) !== root || + root.includes("\0") || root.split(/[\\/]/).includes("..") || + !(directory === root || directory.startsWith(root + path.sep))) throw new Error("unsafe private cache root"); + let current = path.parse(directory).root; + for (const part of directory.slice(current.length).split(path.sep).filter(Boolean)) { + current = path.join(current, part); + const below = current !== root && current.startsWith(root + path.sep); + if (below) { + try { await io.mkdir(current, { mode: 0o700 }); } + catch (error) { if (error.code !== "EEXIST") throw error; } + } + const stat = await io.lstat(current); + const privatePart = below || current === root; + if (!stat.isDirectory() || stat.isSymbolicLink() || + (uid() !== null && stat.uid !== uid() && (privatePart || stat.uid !== 0)) || + (process.platform !== "win32" && (privatePart ? (stat.mode & 0o7777) !== 0o700 : + ((stat.mode & 0o022) !== 0 && (stat.mode & 0o1000) === 0)))) { + throw new Error("private cache requires owned mode-0700 real directories and safe ancestors"); + } + } +} + +async function strictCachedBinary(file, pin, options = {}) { + const io = options.io || fsp; + let before; + try { before = await io.lstat(file); } + catch (error) { if (error.code === "ENOENT") return false; throw error; } + ownedFile(before); + if (before.size !== pin.size) return false; + if (options.osName !== "windows" && (before.mode & 0o777) !== 0o755) return false; + const handle = await io.open(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)); + try { + const opened = await handle.stat(); + ownedFile(opened); + if (!sameFile(before, opened)) throw new Error("binary cache file changed while opening"); + const hash = crypto.createHash("sha256"); + const buffer = Buffer.alloc(Math.min(64 * 1024, pin.size)); + let size = 0; + while (size <= pin.size) { + cancelled(options.signal); + const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, pin.size + 1 - size), null); + if (!bytesRead) break; + size += bytesRead; + hash.update(buffer.subarray(0, bytesRead)); + } + const after = await handle.stat(); + const named = await io.lstat(file); + ownedFile(after); ownedFile(named); + if (!sameFile(before, named) || after.size !== before.size || after.mode !== before.mode || + after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs) throw new Error("binary cache file changed while verifying"); + return size === pin.size && hash.digest("hex") === pin.sha256; + } finally { await handle.close(); } +} + +async function removeOwned(file, identity, io) { + let named; + try { named = await io.lstat(file); } + catch (error) { if (error.code === "ENOENT") return; throw error; } + if (!sameFile(named, identity) || !named.isFile() || named.nlink !== 1) { + throw new Error("owned cleanup identity changed; preserved named object"); + } + await io.unlink(file); +} + +function failures(primary, cleanup) { + if (!cleanup.length) return primary; + return new Error(`${primary ? primary.message + "; " : ""}cleanup/rollback uncertainty: ${cleanup.map(e => e.message).join("; ")}`); +} + +async function acquireLock(target, options = {}) { + const io = options.io || fsp; + const { lockRoot, signal } = options; + const timeoutMs = options.timeoutMs ?? LOCK_TIMEOUT_MS; + const pollMs = options.pollMs ?? 50; + if (!Number.isFinite(timeoutMs) || timeoutMs < 0 || timeoutMs > 300_000 || + !Number.isFinite(pollMs) || pollMs <= 0 || pollMs > 1000) throw new Error("invalid finite lock wait"); + cancelled(signal); + await io.mkdir(lockRoot, { recursive: true, mode: 0o700 }); + const rootStat = await io.lstat(lockRoot); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || + (uid() !== null && rootStat.uid !== uid()) || + (process.platform !== "win32" && (rootStat.mode & 0o777) !== 0o700)) { + throw new Error("agentplugins cache lock root must be a user-owned mode-0700 real directory"); + } + const lockPath = path.join(lockRoot, crypto.createHash("sha256").update(target).digest("hex") + ".lock"); + const started = Date.now(); + while (true) { + cancelled(signal); + let handle; + try { handle = await io.open(lockPath, "wx", 0o600); } + catch (error) { + if (error.code !== "EEXIST") throw error; + if (Date.now() - started >= timeoutMs) { + throw new Error(`timed out waiting for the agentplugins binary cache lock at ${lockPath}; remove it only after confirming no agentplugins or npm process is running`); + } + await delay(Math.min(pollMs, Math.max(1, timeoutMs - (Date.now() - started))), signal); + continue; + } + let identity, released = false; + const release = async () => { + if (released) return; + released = true; + const errors = []; + // Inode identity is held open through comparison/removal, preventing reuse. + if (identity) await removeOwned(lockPath, identity, io).catch(e => errors.push(e)); + await handle.close().catch(e => errors.push(e)); + if (errors.length) throw failures(null, errors); + }; + try { + identity = await handle.stat(); + await handle.writeFile(JSON.stringify({ pid: process.pid, nonce: crypto.randomBytes(16).toString("hex") }) + "\n"); + cancelled(signal); + return release; + } catch (error) { + const errors = []; + if (!identity) errors.push(new Error("lock identity unavailable; retained lock")); + await release().catch(e => errors.push(e)); + throw failures(error, errors); + } + } +} + +// Caller holding the target lock uses commitVerifiedBinary directly. Both +// facades use this same stage/quarantine/publish algorithm; strict validation is +// private-only. Two renames have an absence interval, not crash durability. +async function commitVerifiedBinary(input, binaryPath, pin, options = {}) { + const io = options.io || fsp; + const valid = file => options.strict ? strictCachedBinary(file, pin, options) : validCachedBinary(file, pin.sha256); + cancelled(options.signal); + if (await valid(binaryPath)) return binaryPath; + let previous; + try { + previous = await io.lstat(binaryPath); + if (options.strict) ownedFile(previous); + else if (!previous.isFile() || previous.isSymbolicLink()) { + throw new Error("binary cache target exists but is not a regular file; refusing to move or replace it"); + } + } catch (error) { if (error.code !== "ENOENT") throw error; } + const parent = path.dirname(binaryPath); + await io.mkdir(parent, { recursive: true, mode: 0o700 }); + const suffix = `${process.pid}-${crypto.randomBytes(6).toString("hex")}`; + const staging = path.join(parent, `.agentplugins-staging-${suffix}`); + const quarantine = path.join(parent, `.agentplugins-replaced-${suffix}`); + let staged, replaced = false, published = false, primary; + const errors = []; + try { + // Reserve before writing/copying so partial-write cleanup has an identity. + const handle = await io.open(staging, "wx", 0o600); + try { + staged = await handle.stat(); + if (Buffer.isBuffer(input)) await handle.writeFile(input); + } catch (error) { + if (!staged) errors.push(new Error("staging identity unavailable; retained staging file")); + throw error; + } finally { await handle.close().catch(e => { errors.push(e); }); } + if (errors.length) throw new Error("staging close failed"); + if (!Buffer.isBuffer(input)) await io.copyFile(input, staging); + if (options.osName !== "windows") await io.chmod(staging, 0o755); + if (!(await valid(staging))) throw new Error("staged binary failed repeated SHA-256 verification"); + cancelled(options.signal); + if (previous) { + // Never overwrite a colliding quarantine or a changed target. + try { await io.lstat(quarantine); throw new Error("quarantine collision"); } + catch (error) { if (error.code !== "ENOENT") throw error; } + const named = await io.lstat(binaryPath); + if (!sameFile(named, previous)) throw new Error("binary cache target changed before commit"); + await io.rename(binaryPath, quarantine); + replaced = true; + } + try { await io.lstat(binaryPath); throw new Error("binary cache target appeared before publication"); } + catch (error) { if (error.code !== "ENOENT") throw error; } + await io.rename(staging, binaryPath); + published = true; + if (!(await valid(binaryPath))) throw new Error("committed binary failed repeated SHA-256 verification"); + cancelled(options.signal); + } catch (error) { primary = error; } + if (primary && published) { + await removeOwned(binaryPath, staged, io).then(() => { published = false; }).catch(e => errors.push(e)); + } + if (primary && replaced && !published) { + try { + let absent = false; + try { await io.lstat(binaryPath); } catch (e) { if (e.code !== "ENOENT") throw e; absent = true; } + if (!absent) throw new Error("rollback target occupied; retained quarantine"); + const named = await io.lstat(quarantine); + if (!sameFile(named, previous)) throw new Error("rollback identity changed; retained quarantine"); + await io.rename(quarantine, binaryPath); + replaced = false; + } catch (e) { errors.push(e); } + } + if (!primary && replaced) await removeOwned(quarantine, previous, io).catch(e => errors.push(e)); + if (staged && !published) await removeOwned(staging, staged, io).catch(e => errors.push(e)); + if (primary || errors.length) throw failures(primary, errors); + return binaryPath; +} + +async function installVerifiedBinary(input, binaryPath, pin, options = {}) { + const unlock = await acquireLock(binaryPath, options); + let result, primary; + try { result = await commitVerifiedBinary(input, binaryPath, pin, options); } + catch (error) { primary = error; } + const errors = []; + await unlock().catch(e => errors.push(e)); + if (primary || errors.length) throw failures(primary, errors); + return result; +} + +module.exports = { + sha256File, validCachedBinary, downloadFile, acquireLock, installVerifiedBinary, + commitVerifiedBinary, strictCachedBinary, privateDirectory, cancelled, failures +}; diff --git a/npm/agentplugins/scripts/private-npm/CONTRACT.md b/npm/agentplugins/scripts/private-npm/CONTRACT.md new file mode 100644 index 000000000..c1a28e39d --- /dev/null +++ b/npm/agentplugins/scripts/private-npm/CONTRACT.md @@ -0,0 +1,69 @@ +# Private N1 acquisition contract + +`ensureBinary(product, options)` is an explicit internal API. `product` is the +fixed shim constant `agentplugins` or `plugin-kit-ai`; options supply absolute +`packageRoot`, existing owner-only `cacheRoot`, exact `target`, and a local +`candidateRoot` for cold acquisition. Optional `signal` cancels acquisition; +`lockOptions.timeoutMs/pollMs` bound cooperating waits (defaults 30 s / 50 ms). +There is no environment dispatcher, launcher or npm lifecycle hook in N1. + +The package contains canonical, bounded `package.json`, `candidate.json` and +`private-release.json`. Package name/version/private flag and the sole product +bin mapping `bin/.js` are checked. The descriptor has exactly: + +```json +{ + "schema": "dual-authoring-npm/v1", + "product": "agentplugins", + "npm_package": "universal-agent-plugins", + "identity": { + "repository": "777genius/universal-agent-plugins", + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "engine_revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "versions": { "agentplugins": "0.1.23", "plugin-kit-ai": "2.0.0" } + }, + "asset_scope": "linux-amd64-pair", + "authoring_mode": "vertical-slice-v1", + "candidate_sha256": "" +} +``` + +This example explains fields, not literal canonical fixture bytes. Encoding is +exactly the existing candidate `encode` helper. Identity/schema/archive helpers +remain owned by the candidate producer. Expected mode is mandatory and compared +exactly; this base supports only `vertical-slice-v1`. B must integrate its new +producer/validator mode before any release-mode consumer acceptance. + +Trust starts at the controlled builder and independently retained pack/candidate +digest. The descriptor cannot authenticate a malicious replacement package. +N1 never executes assets or parses Go build info. N2 must require the existing +trusted staging verifier and committed wrapper blob closure at the integrated +source SHA. Candidate status remains CANDIDATE and release_eligible remains false. + +The selected source file is frozen once, after acquiring the target lock. +Source files/root must be sealed; candidate basename restrictions are retained, +while ancestor/package/cache paths allow spaces. Exact source entries and +manifest bytes are checked. Raw pins agree; archives verify compressed pins, +existing canonical bounded `unpack`, then inner pins. No network or v1 fallback. + +Cache keys include schema/mode, candidate digest, product/version, target and +binary digest. Roots cannot overlap package/candidate inputs. Every cache +consumer, including warm hits, takes the same finite, non-stealable lock and +rechecks ownership, directory ancestors, regular type, link count, size, hash +and POSIX 0755 mode. Ordinary owned corruption is repairable; unsafe objects are +preserved. Warm success requires current embedded identity, but no source root. + +One core transaction reserves an exclusive stage, closes/reverifies it, uses a +bounded quarantine/rollback and reverifies publication before success. Cleanup +compares held inode identities; failures report rollback/cleanup uncertainty and +retain inspectable debris where cleanup cannot be proven. It is a private, +quiescent same-user namespace with cooperating consumers: no continuous path +availability, crash durability, hostile same-user or native platform claim. + +Default agentplugins retains historical metadata/evidence and acquisition-before- +commit-lock behavior. Its warm hash-only policy and exports stay unchanged. +Shared compatible fault fixes reject malformed declared lengths, turn malformed +redirects/premature stream closes into settled errors, close before download +failure, refuse changed cleanup identities, and report commit/rollback cleanup +failures. No defaults, public package/workflow metadata or plugin-kit v1 files +change. N2 owns packs, launchers, signals and native journeys after A/B integration. diff --git a/npm/agentplugins/scripts/private-npm/bootstrap.js b/npm/agentplugins/scripts/private-npm/bootstrap.js new file mode 100644 index 000000000..a40c1ed35 --- /dev/null +++ b/npm/agentplugins/scripts/private-npm/bootstrap.js @@ -0,0 +1,133 @@ +"use strict"; + +// Explicit PRIVATE N1 adapter. No launcher, environment selection or public +// fallback. Identity is trusted only through an independently verified pack. +const fs = require("node:fs"); +const fsp = require("node:fs/promises"); +const path = require("node:path"); +const c = require("../dual-authoring-candidate"); +const v = require("../../lib/verifier"); +const SCHEMA = "dual-authoring-npm/v1"; +const MAX_JSON = 1024 * 1024; +const MAX_BINARY = 128 * 1024 * 1024; // Same bound as the candidate reader/unpack. +const PACKAGES = Object.freeze({ agentplugins: "universal-agent-plugins", "plugin-kit-ai": "plugin-kit-ai" }); +const inside = (a, b) => a === b || a.startsWith(b + path.sep); +const hash = value => typeof value === "string" && /^[0-9a-f]{64}$/.test(value); + +function json(file) { + const bytes = c.readFile(file, MAX_JSON); + const value = JSON.parse(bytes); + if (!bytes.equals(c.encode(value))) throw new Error("noncanonical private JSON"); + return { bytes, value }; +} + +function pin(value) { + if (!hash(value.sha256) || !Number.isSafeInteger(value.size) || value.size <= 0 || value.size > MAX_BINARY) { + throw new Error("invalid bounded private binary/asset pin"); + } +} + +function loadRelease(product, packageRoot, target) { + if (!Object.hasOwn(PACKAGES, product)) throw new Error("unknown fixed private product"); + c.safeDirectory(packageRoot); + const { value: descriptor } = json(path.join(packageRoot, "private-release.json")); + c.keys(descriptor, ["schema", "product", "npm_package", "identity", "asset_scope", "authoring_mode", "candidate_sha256"], "private release"); + c.identity(descriptor.identity); + if (descriptor.schema !== SCHEMA || descriptor.product !== product || descriptor.npm_package !== PACKAGES[product] || + !hash(descriptor.candidate_sha256)) throw new Error("private release binding is invalid"); + // B owns future producer/validator modes. Never accept an either-mode check. + if (descriptor.authoring_mode !== "vertical-slice-v1") throw new Error("unsupported explicit expected candidate mode"); + if (!c.scopeTargets(descriptor.asset_scope).includes(target)) throw new Error("unsupported private target for expected scope"); + const { value: pkg } = json(path.join(packageRoot, "package.json")); + c.keys(pkg.bin, [product], "private npm bin"); + if (pkg.name !== PACKAGES[product] || pkg.version !== descriptor.identity.versions[product] || + pkg.private !== true || pkg.bin[product] !== `bin/${product}.js`) throw new Error("private npm package binding is invalid"); + const { bytes, value: manifest } = json(path.join(packageRoot, "candidate.json")); + if (c.digest(bytes) !== descriptor.candidate_sha256) throw new Error("candidate manifest digest mismatch"); + c.manifestShape(manifest, descriptor.identity, descriptor.asset_scope); + if (manifest.build.authoring_mode !== descriptor.authoring_mode) throw new Error("candidate mode does not match expected mode"); + const seen = new Set(); + for (const p of c.PRODUCTS) for (const asset of Object.values(manifest.products[p].assets)) { + pin(asset); pin(asset.binary); + if (p === "agentplugins" && (asset.sha256 !== asset.binary.sha256 || asset.size !== asset.binary.size)) { + throw new Error("raw asset and binary pins must agree"); + } + if (seen.has(asset.binary.sha256)) throw new Error("duplicate executable bytes across products/targets"); + seen.add(asset.binary.sha256); + } + return { descriptor, manifest, bytes, asset: manifest.products[product].assets[target], version: pkg.version }; +} + +function sourcePlacement(root, packageRoot, cacheRoot) { + if (typeof root !== "string" || !path.isAbsolute(root) || path.resolve(root) !== root || + root.split(/[\\/]/).includes("..") || root.includes("\0") || + !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(path.basename(root))) throw new Error("unsafe local candidate root"); + for (const other of [packageRoot, cacheRoot]) { + if (inside(root, other) || inside(other, root)) throw new Error("private roots overlap"); + } +} + +function freezeSelected(root, release) { + c.safeDirectory(root); + const names = ["candidate.json", ...c.PRODUCTS.flatMap(p => Object.values(release.manifest.products[p].assets).map(a => a.file))]; + if (fs.readdirSync(root).sort().join("\n") !== names.sort().join("\n")) throw new Error("candidate contains extra or missing files"); + for (const name of ["", "candidate.json", release.asset.file]) { + const stat = fs.lstatSync(path.join(root, name)); + if (process.platform !== "win32" && (stat.mode & 0o222)) throw new Error("candidate inputs must be immutable"); + } + const sourceManifest = c.readFile(path.join(root, "candidate.json"), MAX_JSON); + if (!sourceManifest.equals(release.bytes)) throw new Error("source candidate manifest changed"); + const bytes = c.readFile(path.join(root, release.asset.file), MAX_BINARY); + c.keys(release.asset.binary, ["file", "sha256", "size"], "binary"); + if (bytes.length !== release.asset.size || c.digest(bytes) !== release.asset.sha256) throw new Error("compressed/raw asset digest or size mismatch"); + const binary = release.descriptor.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 binary digest or size mismatch"); + return binary; +} + +function cachePath(root, product, target, release) { + return path.join(root, "dual-authoring-npm-v1", release.descriptor.authoring_mode, + release.descriptor.candidate_sha256, product, release.version, target, release.asset.binary.sha256, release.asset.binary.file); +} + +// hooks are internal fault/observation seams for offline structural tests. They +// are never populated from JSON/environment and cannot replace acquisition. +async function ensureBinary(product, options = {}, hooks = {}) { + const { packageRoot, cacheRoot, candidateRoot, signal } = options; + v.cancelled(signal); + const release = loadRelease(product, packageRoot, options.target); + c.safeDirectory(cacheRoot); + if (inside(cacheRoot, packageRoot) || inside(packageRoot, cacheRoot)) throw new Error("private cache overlaps package input"); + // Validate a supplied locator even on warm hits, without opening its source. + if (candidateRoot !== undefined) sourcePlacement(candidateRoot, packageRoot, cacheRoot); + const io = hooks.io || fsp; + await v.privateDirectory(cacheRoot, cacheRoot, io); + const binaryPath = cachePath(cacheRoot, product, options.target, release); + await v.privateDirectory(cacheRoot, path.dirname(binaryPath), io); + const lockRoot = path.join(cacheRoot, ".locks"); + await v.privateDirectory(cacheRoot, lockRoot, io); + const settings = { io, signal, strict: true, osName: options.target.split("-")[0] }; + const unlock = await v.acquireLock(binaryPath, { ...options.lockOptions, io, signal, lockRoot }); + let result, primary; + try { + v.cancelled(signal); + // Warm consumers use the identical lock, including validation under it. + await v.privateDirectory(cacheRoot, path.dirname(binaryPath), io); + const hit = await v.strictCachedBinary(binaryPath, release.asset.binary, settings); + if (!hit) { + sourcePlacement(candidateRoot, packageRoot, cacheRoot); + const binary = freezeSelected(candidateRoot, release); + await hooks.afterFreeze?.(); + v.cancelled(signal); + await v.commitVerifiedBinary(binary, binaryPath, release.asset.binary, settings); + } + v.cancelled(signal); + result = { binaryPath, version: release.version, cacheHit: hit, product, candidate_sha256: release.descriptor.candidate_sha256 }; + } catch (error) { primary = error; } + const cleanup = []; + await unlock().catch(e => cleanup.push(e)); + if (primary || cleanup.length) throw v.failures(primary, cleanup); + return result; +} + +module.exports = { SCHEMA, loadRelease, ensureBinary, cachePath }; diff --git a/npm/agentplugins/test/bootstrap.test.js b/npm/agentplugins/test/bootstrap.test.js index 07370f07e..c07832646 100644 --- a/npm/agentplugins/test/bootstrap.test.js +++ b/npm/agentplugins/test/bootstrap.test.js @@ -4,7 +4,8 @@ const assert = require("node:assert/strict"); const crypto = require("node:crypto"); const fs = require("node:fs"); const fsp = require("node:fs/promises"); -const http = require("node:http"); +const { EventEmitter } = require("node:events"); +const { PassThrough } = require("node:stream"); const os = require("node:os"); const path = require("node:path"); const { spawnSync } = require("node:child_process"); @@ -82,16 +83,33 @@ async function fixturePackage(t, binary = BINARY) { return { binary, file, root }; } +// Preserve the historical transport assertions with injected streams, no socket. +const endpoints = new Map(); async function listen(t, handler) { - const server = http.createServer(handler); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - t.after(() => new Promise((resolve) => server.close(resolve))); - const address = server.address(); - return { server, url: `http://127.0.0.1:${address.port}` }; + const url = `fixture:${endpoints.size}`; + endpoints.set(url, handler); + const server = { close(done) { endpoints.delete(url); done(); } }; + t.after(() => endpoints.delete(url)); + return { server, url }; } function requestThrough(endpoint) { - return (_target, options) => http.get(endpoint, options); + return (_target, options) => { + const request = new EventEmitter(); + request.setTimeout = () => {}; + request.destroy = error => request.emit("error", error); + process.nextTick(() => { + const handler = endpoints.get(endpoint); + if (!handler) return request.emit("error", new Error("fixture offline")); + const response = new PassThrough(); + response.statusCode = 200; + response.headers = {}; + response.writeHead = (status, headers) => { response.statusCode = status; response.headers = headers; }; + handler({ headers: options.headers }, response); + request.emit("response", response); + }); + return request; + }; } test("cold, warm, corrupted, and concurrent cache paths stay verified", async (t) => { @@ -263,7 +281,7 @@ test("redirected public download never inherits GITHUB_TOKEN", async (t) => { await ensureBinary({ packageRoot: fixture.root, cacheRoot: cache, - request: (url, options) => http.get(url.hostname === "github.com" ? redirect.url : target.url, options), + request: (url, options) => requestThrough(url.hostname === "github.com" ? redirect.url : target.url)(url, options), platform: "linux", arch: "x64" }); @@ -493,7 +511,7 @@ test("a redirect to an unapproved host is rejected before a second request", asy }, { request: (_target, options) => { requests += 1; - return http.get(endpoint.url, options); + return requestThrough(endpoint.url)(_target, options); } }), /approved GitHub HTTPS host/ @@ -522,3 +540,18 @@ test("a non-regular binary cache target is preserved", async (t) => { }), /not a regular file/); assert.equal(await fsp.readFile(path.join(binaryPath, "owned.txt"), "utf8"), "keep"); }); + +test("default facade delegates canonical primitives without selecting private metadata", async (t) => { + const facade = require("../lib/bootstrap"); + const core = require("../lib/verifier"); + for (const key of ["sha256File", "validCachedBinary", "downloadFile"]) assert.equal(facade[key], core[key]); + assert.deepEqual(Object.keys(facade).sort(), ["PROOF_MODE", "acquireLock", "downloadFile", "ensureBinary", + "formatBootstrapError", "loadRelease", "localProofAsset", "sha256File", "validCachedBinary"].sort()); + const fixture = await fixturePackage(t); + await fsp.writeFile(path.join(fixture.root, "private-release.json"), "invalid private descriptor"); + await fsp.writeFile(path.join(fixture.root, "candidate.json"), "invalid private candidate"); + assert.equal(loadRelease(fixture.root, detectPlatform("linux", "x64")).manifest.repository, "777genius/plugin-kit-ai"); + const file = path.join(fixture.root, "ordinary-cache"); + await fsp.writeFile(file, BINARY, { mode: 0o600 }); + assert.equal(await facade.validCachedBinary(file, crypto.createHash("sha256").update(BINARY).digest("hex")), true); +}); diff --git a/npm/agentplugins/test/private-npm-bootstrap.test.js b/npm/agentplugins/test/private-npm-bootstrap.test.js new file mode 100644 index 000000000..e773a7407 --- /dev/null +++ b/npm/agentplugins/test/private-npm-bootstrap.test.js @@ -0,0 +1,548 @@ +"use strict"; + +// POSIX STRUCTURAL evidence only: payloads are never executed. Retain private +// fixture roots for audit; these tests do not need recursive cleanup. +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const fsp = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const { spawn, spawnSync } = require("node:child_process"); +const nodeTest = require("node:test"); +const test = (name, fn) => nodeTest(name, { skip: process.platform !== "linux" ? "Linux structural filesystem fixture; native lanes unproven" : false }, fn); +const zlib = require("node:zlib"); +const c = require("../scripts/dual-authoring-candidate"); +const p = require("../scripts/private-npm/bootstrap"); +const v = require("../lib/verifier"); +const TARGET = "linux-amd64"; +const ID = { repository: c.REPOSITORY, commit: "a".repeat(40), engine_revision: "a".repeat(40), + versions: { agentplugins: "0.1.23", "plugin-kit-ai": "2.0.0" } }; +const mkdir = name => fs.mkdirSync(name, { mode: 0o700 }); +const write = (file, body) => { if (fs.existsSync(file)) fs.chmodSync(file, 0o600); fs.writeFileSync(file, body); }; + +function fixture(scope = "linux-amd64-pair", identity = ID) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "private-npm-fixture-")); + const ancestor = path.join(root, "inputs with spaces"); mkdir(ancestor); + const source = path.join(ancestor, "candidate"); mkdir(source); + const cache = path.join(root, "cache with spaces"); mkdir(cache); + const manifest = { schema: c.SCHEMA, status: "CANDIDATE", identity: structuredClone(identity), asset_scope: scope, + build: { method: "controlled-git-archive-go-build/v1", go_version: "go1.25.13", go_sha256: "b".repeat(64), + source_archive_sha256: "c".repeat(64), authoring_mode: "vertical-slice-v1" }, products: {}, release_eligible: false }; + const packages = {}, descriptors = {}, bodies = {}; + for (const product of c.PRODUCTS) { + const packageRoot = path.join(root, product + " package"); mkdir(packageRoot); + packages[product] = packageRoot; + manifest.products[product] = { version: identity.versions[product], assets: {} }; + for (const target of c.scopeTargets(scope)) { + const binary = Buffer.from(`STRUCTURAL ${product} ${target} ${identity.versions[product]}\n`); + const asset = product === "plugin-kit-ai" ? c.archive(binary, c.executableName(product, target)) : binary; + const file = c.assetName(product, identity.versions[product], target); + bodies[product + target] = binary; + write(path.join(source, file), asset); + manifest.products[product].assets[target] = { file, ...c.metadata(asset), binary: { file: c.executableName(product, target), ...c.metadata(binary) } }; + } + descriptors[product] = { schema: p.SCHEMA, product, npm_package: product === "agentplugins" ? "universal-agent-plugins" : product, + identity: structuredClone(identity), asset_scope: scope, authoring_mode: "vertical-slice-v1", candidate_sha256: "" }; + write(path.join(packageRoot, "package.json"), c.encode({ name: descriptors[product].npm_package, + version: identity.versions[product], private: true, bin: { [product]: `bin/${product}.js` } })); + } + function save() { + fs.chmodSync(source, 0o700); + const bytes = c.encode(manifest); + write(path.join(source, "candidate.json"), bytes); + for (const product of c.PRODUCTS) { + descriptors[product].candidate_sha256 = c.digest(bytes); + write(path.join(packages[product], "candidate.json"), bytes); + write(path.join(packages[product], "private-release.json"), c.encode(descriptors[product])); + } + for (const file of fs.readdirSync(source)) fs.chmodSync(path.join(source, file), 0o444); + fs.chmodSync(source, 0o555); + } + save(); + const options = (product, extra = {}) => ({ packageRoot: packages[product], cacheRoot: cache, candidateRoot: source, target: TARGET, ...extra }); + const release = product => p.loadRelease(product, packages[product], TARGET); + const binaryPath = product => p.cachePath(cache, product, TARGET, release(product)); + return { root, source, cache, manifest, packages, descriptors, bodies, save, options, release, binaryPath }; +} + +function snapshot(root) { + return fs.readdirSync(root).sort().map(name => { + const stat = fs.lstatSync(path.join(root, name)); + return [name, stat.mode, stat.ino, stat.nlink, c.digest(fs.readFileSync(path.join(root, name)))]; + }); +} +const locks = f => fs.existsSync(path.join(f.cache, ".locks")) ? fs.readdirSync(path.join(f.cache, ".locks")) : []; +const run = (f, product = "agentplugins", extra = {}, hooks = {}) => p.ensureBinary(product, f.options(product, extra), hooks); +function changeJSON(file, change) { const value = JSON.parse(fs.readFileSync(file)); change(value); write(file, c.encode(value)); } + +for (const product of c.PRODUCTS) test(`STRUCTURAL ${product}: cold, source-free warm, and ordinary corruption repair`, async () => { + const f = fixture(); const before = snapshot(f.source); const rootMode = fs.statSync(f.source).mode; + let acquisitions = 0; const hooks = { afterFreeze() { acquisitions++; } }; + const first = await run(f, product, {}, hooks); + assert.equal(first.cacheHit, false); + assert.deepEqual(fs.readFileSync(first.binaryPath), f.bodies[product + TARGET]); + assert.equal(fs.statSync(first.binaryPath).mode & 0o777, 0o755); + for (const corrupt of [file => write(file, Buffer.alloc(f.bodies[product + TARGET].length)), + file => write(file, "truncated"), file => fs.chmodSync(file, 0o644)]) { + corrupt(first.binaryPath); + const repaired = await run(f, product, {}, hooks); + assert.equal(repaired.cacheHit, false); + assert.deepEqual(fs.readFileSync(first.binaryPath), f.bodies[product + TARGET]); + } + fs.renameSync(f.source, f.source + "-unavailable"); + assert.equal((await run(f, product, {}, hooks)).cacheHit, true); + assert.equal((await run(f, product, { candidateRoot: undefined }, hooks)).cacheHit, true); + assert.equal(acquisitions, 4); + assert.deepEqual(snapshot(f.source + "-unavailable"), before); + assert.equal(fs.statSync(f.source + "-unavailable").mode, rootMode); + assert.deepEqual(locks(f), []); +}); + +test("STRUCTURAL explicit roots, complete identity and product isolation; legacy bytes never selected", async () => { + const f = fixture(); + const stale = path.join(f.root, "vendor"); mkdir(stale); write(path.join(stale, "agentplugins"), "stale engine"); + mkdir(path.join(f.cache, ID.versions.agentplugins)); + write(path.join(f.cache, ID.versions.agentplugins, "agentplugins"), "wrong engine"); + const [a, b] = await Promise.all(c.PRODUCTS.map(product => run(f, product))); + assert.notEqual(a.binaryPath, b.binaryPath); + assert.match(a.binaryPath, new RegExp(f.descriptors.agentplugins.candidate_sha256)); + assert.match(a.binaryPath, new RegExp(f.manifest.products.agentplugins.assets[TARGET].binary.sha256)); + const newer = fixture("linux-amd64-pair", { ...ID, versions: { agentplugins: "0.1.24", "plugin-kit-ai": "2.0.1" } }); + const unlockVersion = await v.acquireLock(a.binaryPath, { lockRoot: path.join(f.cache, ".locks") }); + try { + const next = await run(newer, "agentplugins", { cacheRoot: f.cache, lockOptions: { timeoutMs: 20 } }); + assert.notEqual(a.binaryPath, next.binaryPath); + } finally { await unlockVersion(); } + f.manifest.build.source_archive_sha256 = "d".repeat(64); f.save(); + const changedIdentity = await run(f); + assert.notEqual(a.binaryPath, changedIdentity.binaryPath); + assert.equal(fs.readFileSync(path.join(stale, "agentplugins"), "utf8"), "stale engine"); + assert.equal(fs.readFileSync(path.join(f.cache, ID.versions.agentplugins, "agentplugins"), "utf8"), "wrong engine"); + for (const value of [undefined, "relative", f.packages.agentplugins, f.cache + "/../cache with spaces"]) { + await assert.rejects(run(f, "agentplugins", { cacheRoot: value })); + } +}); + +const identityMutations = [ + ["repository alias", id => { id.repository = "777genius/plugin-kit-ai"; }], + ["short commit", id => { id.commit = "a"; }], ["uppercase commit", id => { id.commit = "A".repeat(40); }], + ["wrong commit", id => { id.commit = id.engine_revision = "d".repeat(40); }], + ["unequal engine", id => { id.engine_revision = "d".repeat(40); }], + ["same versions", id => { id.versions.agentplugins = id.versions["plugin-kit-ai"]; }], + ["other version", id => { id.versions["plugin-kit-ai"] = "2.0.1"; }], + ["numeric version", id => { id.versions.agentplugins = 1; }], + ["latest", id => { id.versions.agentplugins = "latest"; }], + ["pre-release", id => { id.versions.agentplugins = "0.1.23-rc.1"; }], + ["extra identity", id => { id.extra = true; }], ["missing version", id => { delete id.versions.agentplugins; }] +]; +for (const [name, mutate] of identityMutations) test(`private expected identity rejects ${name} even warm`, async () => { + const f = fixture(); const cached = await run(f); + changeJSON(path.join(f.packages.agentplugins, "private-release.json"), d => mutate(d.identity)); + await assert.rejects(run(f)); + assert.deepEqual(fs.readFileSync(cached.binaryPath), f.bodies["agentplugins" + TARGET]); +}); + +const descriptorMutations = [ + ["schema", d => { d.schema = c.SCHEMA; }], ["product", d => { d.product = "plugin-kit-ai"; }], + ["npm name", d => { d.npm_package = "agentplugins"; }], ["missing mode", d => { delete d.authoring_mode; }], + ["release mode before B", d => { d.authoring_mode = "release-cli-contract-v1"; }], + ["wrong scope", d => { d.asset_scope = "six-platform-pair"; }], ["unknown scope", d => { d.asset_scope = "all"; }], + ["digest", d => { d.candidate_sha256 = "e".repeat(64); }], ["digest type", d => { d.candidate_sha256 = ["e".repeat(64)]; }], + ["extra field", d => { d.environment = {}; }], ["missing identity", d => { delete d.identity; }] +]; +for (const [name, mutate] of descriptorMutations) test(`closed descriptor rejects ${name} before cache effects`, async () => { + const f = fixture(); + changeJSON(path.join(f.packages.agentplugins, "private-release.json"), mutate); + await assert.rejects(run(f)); assert.deepEqual(fs.readdirSync(f.cache), []); +}); +for (const [name, mutate] of [ + ["name", x => { x.name = "agentplugins"; }], ["version", x => { x.version = "0.1.24"; }], + ["bin", x => { x.bin.agentplugins = "../plugin-kit-ai"; }], ["bin alias", x => { x.bin.other = x.bin.agentplugins; }], + ["private flag", x => { x.private = false; }] +]) test(`package binding rejects ${name}`, async () => { + const f = fixture(); changeJSON(path.join(f.packages.agentplugins, "package.json"), mutate); + await assert.rejects(run(f)); assert.deepEqual(fs.readdirSync(f.cache), []); +}); + +const candidateMutations = [ + ["public schema", m => { m.schema = 2; }], ["legacy schema", m => { m.schema = 1; }], + ["release eligible", m => { m.release_eligible = true; }], ["status", m => { m.status = "RELEASE"; }], + ["extra", m => { m.attested = true; }], ["missing products", m => { delete m.products["plugin-kit-ai"]; }], + ["extra asset", m => { m.products.agentplugins.assets["linux-386"] = m.products.agentplugins.assets[TARGET]; }], + ["missing asset", m => { delete m.products.agentplugins.assets[TARGET]; }], + ["extra product", m => { m.products.other = m.products.agentplugins; }], + ["unsafe name", m => { m.products.agentplugins.assets[TARGET].file = "../agentplugins"; }], + ["wrong binary name", m => { m.products["plugin-kit-ai"].assets[TARGET].binary.file = "agentplugins"; }], + ["archive confusion", m => { m.products["plugin-kit-ai"].assets[TARGET].file = "plugin-kit-ai"; }], + ["raw pins disagree", m => { m.products.agentplugins.assets[TARGET].binary.sha256 = "e".repeat(64); }], + ["duplicate executable", m => { Object.assign(m.products["plugin-kit-ai"].assets[TARGET].binary, c.metadata(Buffer.from("STRUCTURAL agentplugins linux-amd64 0.1.23\n"))); }], + ["missing mode", m => { delete m.build.authoring_mode; }], ["wrong mode", m => { m.build.authoring_mode = "release-cli-contract-v1"; }], + ["wrong compiler", m => { m.build.go_version = "go1.0"; }], ["wrong build", m => { m.build.method = "untrusted"; }], + ["extra binary field", m => { m.products.agentplugins.assets[TARGET].binary.extra = 1; }] +]; +for (const [name, mutate] of candidateMutations) test(`repinned candidate rejects ${name}`, async () => { + const f = fixture(); mutate(f.manifest); f.save(); + await assert.rejects(run(f)); assert.deepEqual(fs.readdirSync(f.cache), []); +}); + +test("every asset and inner pin is bounded and typed, including unselected assets", async () => { + for (const inner of [false, true]) for (const field of ["size", "sha256"]) { + for (const value of field === "size" ? [0, -1, 1.5, "5", null, 128 * 1024 * 1024 + 1, Number.MAX_SAFE_INTEGER + 1] : + ["A".repeat(64), "short", ["a".repeat(64)], null, 12]) { + const f = fixture(); const asset = f.manifest.products["plugin-kit-ai"].assets[TARGET]; + (inner ? asset.binary : asset)[field] = value; f.save(); + await assert.rejects(run(f), /pin/); assert.deepEqual(fs.readdirSync(f.cache), []); + } + } +}); + +test("canonical JSON and bounded regular metadata at each trusted level", async () => { + for (const file of ["private-release.json", "candidate.json", "package.json"]) { + for (const mutate of [body => Buffer.from(body.toString().replace('{', '{"duplicate":1,"duplicate":2,')), + body => Buffer.concat([body, Buffer.from(" ")]), () => Buffer.alloc(1024 * 1024 + 1, 32), + () => Buffer.from([0xff, 0xfe]), () => Buffer.alloc(0)]) { + const f = fixture(); const filename = path.join(f.packages.agentplugins, file); + write(filename, mutate(fs.readFileSync(filename))); + await assert.rejects(run(f)); assert.deepEqual(fs.readdirSync(f.cache), []); + } + } +}); + +test("closed scopes and all twelve target entries validate structurally without claiming native support", async () => { + const f = fixture("six-platform-pair"); + for (const product of c.PRODUCTS) for (const target of c.TARGETS) { + const result = await run(f, product, { target }); + assert.deepEqual(fs.readFileSync(result.binaryPath), f.bodies[product + target]); + } + for (const target of [undefined, "linux-x64", "linux-386", "../linux-amd64", [TARGET]]) await assert.rejects(run(f, "agentplugins", { target })); + for (const product of ["universal-agent-plugins", "other", "__proto__"]) await assert.rejects(run(f, product)); +}); + +for (const product of c.PRODUCTS) test(`${product}: immutable source faults preserve inputs and unowned entries`, async () => { + for (const kind of ["bytes", "truncated", "manifest", "extra", "missing", "writable", "root writable", "symlink", "hardlink", "directory", "fifo"]) { + const f = fixture(); const file = path.join(f.source, f.manifest.products[product].assets[TARGET].file); + fs.chmodSync(f.source, 0o700); + if (kind === "bytes") write(file, Buffer.alloc(fs.statSync(file).size)); + if (kind === "truncated") write(file, "x"); + if (kind === "manifest") write(path.join(f.source, "candidate.json"), "{}"); + if (kind === "extra") write(path.join(f.source, "sentinel"), "preserve"); + if (["missing", "symlink", "hardlink", "directory", "fifo"].includes(kind)) fs.unlinkSync(file); + const sentinel = path.join(f.root, "sentinel"); write(sentinel, "preserve"); + if (kind === "symlink") fs.symlinkSync(sentinel, file); + if (kind === "hardlink") fs.linkSync(sentinel, file); + if (kind === "directory") mkdir(file); + if (kind === "fifo") assert.equal(spawnSync("mkfifo", [file]).status, 0); + for (const name of fs.readdirSync(f.source)) if (!fs.lstatSync(path.join(f.source, name)).isSymbolicLink()) fs.chmodSync(path.join(f.source, name), 0o444); + if (kind === "writable") fs.chmodSync(file, 0o644); + if (kind !== "root writable") fs.chmodSync(f.source, 0o555); + const before = fs.readdirSync(f.source); + await assert.rejects(run(f, product)); + assert.equal(fs.existsSync(f.binaryPath(product)), false); + assert.equal(fs.readFileSync(sentinel, "utf8"), "preserve"); + assert.deepEqual(fs.readdirSync(f.source), before); assert.deepEqual(locks(f), []); + } +}); + +test("strict archive rejects compressed and inner pins, corruption, alternate records and bounded bombs", async () => { + const edits = [ + ["outer pin", (body) => Buffer.alloc(body.length)], + ["gzip corruption", body => Buffer.from(body).fill(0, 0, 10)], + ["raw as archive", () => Buffer.from("raw binary bytes")], + ["inner bytes", () => c.archive(Buffer.from("other binary"), "plugin-kit-ai")], + ["wrong root", () => c.archive(Buffer.from("other binary"), "agentplugins")], + ...[0, 100, 156, 257].map(offset => [`alternate header ${offset}`, body => { + const tar = zlib.gunzipSync(body); tar[offset] ^= 1; return zlib.gzipSync(tar); + }]), + ["extra record", body => zlib.gzipSync(Buffer.concat([zlib.gunzipSync(body), Buffer.alloc(512)]))], + ["pax record", body => { const tar = zlib.gunzipSync(body); tar[156] = 120; return zlib.gzipSync(tar); }], + ["hardlink record", body => { const tar = zlib.gunzipSync(body); tar[156] = 49; return zlib.gzipSync(tar); }], + ["symlink record", body => { const tar = zlib.gunzipSync(body); tar[156] = 50; return zlib.gzipSync(tar); }], + ["gzip bomb", () => zlib.gzipSync(Buffer.alloc(128 * 1024 * 1024 + 2049))] + ]; + for (const [label, edit] of edits) { + const f = fixture(); const asset = f.manifest.products["plugin-kit-ai"].assets[TARGET]; + const file = path.join(f.source, asset.file); const bytes = edit(fs.readFileSync(file)); + write(file, bytes); + if (label !== "outer pin") Object.assign(asset, c.metadata(bytes)); + f.save(); await assert.rejects(run(f, "plugin-kit-ai"), undefined, label); + assert.equal(fs.existsSync(f.binaryPath("plugin-kit-ai")), false); assert.deepEqual(locks(f), []); + } +}); + +test("selected source is read once then materialized only from the frozen bytes", async () => { + const f = fixture(); const file = path.join(f.source, f.manifest.products.agentplugins.assets[TARGET].file); + const originalRead = fs.readFileSync; let reads = 0; + fs.readFileSync = function (name, ...args) { + // candidate reader uses a descriptor; identify its current inode. + if (typeof name === "number" && fs.fstatSync(name).ino === fs.statSync(file).ino) reads++; + return originalRead.call(this, name, ...args); + }; + try { + const result = await run(f, "agentplugins", {}, { afterFreeze() { write(file, "changed after freeze"); } }); + assert.equal(reads, 1); assert.deepEqual(originalRead(result.binaryPath), f.bodies["agentplugins" + TARGET]); + } finally { fs.readFileSync = originalRead; } +}); + +test("source changing during the existing descriptor freeze is rejected", async () => { + const f = fixture(); const file = path.join(f.source, f.manifest.products.agentplugins.assets[TARGET].file); + const ino = fs.statSync(file).ino; const original = fs.readFileSync; + fs.readFileSync = function (name, ...args) { + const bytes = original.call(this, name, ...args); + if (typeof name === "number" && fs.fstatSync(name).ino === ino) write(file, "changed during freeze"); + return bytes; + }; + try { await assert.rejects(run(f), /changed while freezing/); } + finally { fs.readFileSync = original; } + assert.equal(fs.existsSync(f.binaryPath("agentplugins")), false); assert.deepEqual(locks(f), []); +}); + +function child(f, product, extra = {}) { + const marker = path.join(f.root, "acquisitions"); + const code = `const fs=require('node:fs');const p=require(${JSON.stringify(require.resolve("../scripts/private-npm/bootstrap"))}); + p.ensureBinary(${JSON.stringify(product)},${JSON.stringify(f.options(product, extra))}, { + async afterFreeze(){fs.appendFileSync(${JSON.stringify(marker)},'acquired\\n'); await new Promise(r=>setTimeout(r,100));} + }).then(x=>process.stdout.write(JSON.stringify(x))).catch(e=>{process.stderr.write(e.message);process.exitCode=1;});`; + const proc = spawn(process.execPath, ["-e", code], { env: { HOME: f.root, TMPDIR: f.root, PATH: path.dirname(process.execPath) }, stdio: ["ignore", "pipe", "pipe"] }); + return new Promise((resolve, reject) => { + let out = "", err = ""; + const timeout = setTimeout(() => { proc.kill("SIGKILL"); reject(new Error("fixture process deadline")); }, 10_000); + proc.stdout.on("data", b => { out += b; }); proc.stderr.on("data", b => { err += b; }); + proc.on("error", reject); + proc.on("close", status => { clearTimeout(timeout); status === 0 ? resolve(JSON.parse(out)) : reject(new Error(err)); }); + }); +} +for (const product of c.PRODUCTS) test(`two independent ${product} consumers acquire once for both cold and corrupt entries`, async () => { + const f = fixture(); + for (const corrupt of [false, true]) { + if (corrupt) write(f.binaryPath(product), "corrupt"); + const results = await Promise.all([child(f, product), child(f, product)]); + assert.equal(results[0].binaryPath, results[1].binaryPath); + assert.equal(results.filter(r => !r.cacheHit).length, 1); + assert.deepEqual(fs.readFileSync(results[0].binaryPath), f.bodies[product + TARGET]); + } + assert.equal(fs.readFileSync(path.join(f.root, "acquisitions"), "utf8"), "acquired\nacquired\n"); + assert.deepEqual(locks(f), []); +}); + +test("warm readers lock too; cancelled waiters and stale-looking locks preserve owner; products do not block", async () => { + const f = fixture(); const cached = await run(f); + const unlock = await v.acquireLock(cached.binaryPath, { lockRoot: path.join(f.cache, ".locks") }); + const lock = path.join(f.cache, ".locks", locks(f)[0]); const body = fs.readFileSync(lock); + fs.utimesSync(lock, new Date(0), new Date(0)); + await assert.rejects(run(f, "agentplugins", { lockOptions: { timeoutMs: 20, pollMs: 2 } }), /timed out/); + const controller = new AbortController(); + const waiting = run(f, "agentplugins", { signal: controller.signal }); + setTimeout(() => controller.abort(), 20); + await assert.rejects(waiting, /cancelled/); assert.deepEqual(fs.readFileSync(lock), body); + assert.equal((await run(f, "plugin-kit-ai", { lockOptions: { timeoutMs: 20 } })).cacheHit, false); + await unlock(); await unlock(); assert.deepEqual(locks(f), []); + const before = fs.readFileSync(cached.binaryPath); + const owner = new AbortController(); write(cached.binaryPath, "bad"); + await assert.rejects(run(f, "agentplugins", { signal: owner.signal }, { afterFreeze() { owner.abort(); } }), /cancelled/); + assert.equal(fs.readFileSync(cached.binaryPath, "utf8"), "bad"); + assert.deepEqual(locks(f), []); assert.equal(before.length, f.bodies["agentplugins" + TARGET].length); +}); + +test("unsafe cache files and directory ancestors are preserved without repair", async () => { + for (const kind of ["symlink", "hardlink", "directory", "fifo", "writable", "setuid"]) { + const f = fixture(); const { binaryPath } = await run(f); fs.unlinkSync(binaryPath); + const sentinel = path.join(f.root, "sentinel"); write(sentinel, "preserve"); + if (kind === "symlink") fs.symlinkSync(sentinel, binaryPath); + if (kind === "hardlink") fs.linkSync(sentinel, binaryPath); + if (kind === "directory") mkdir(binaryPath); + if (kind === "fifo") assert.equal(spawnSync("mkfifo", [binaryPath]).status, 0); + if (["writable", "setuid"].includes(kind)) { write(binaryPath, "unsafe"); fs.chmodSync(binaryPath, kind === "writable" ? 0o777 : 0o4755); } + const before = fs.lstatSync(binaryPath); + await assert.rejects(run(f), /unsafe/); + assert.equal(fs.lstatSync(binaryPath).ino, before.ino); assert.equal(fs.readFileSync(sentinel, "utf8"), "preserve"); + assert.deepEqual(locks(f), []); + } + for (const location of ["root", "descendant", "ancestor", "symlink ancestor"]) { + const f = fixture(); await run(f); + if (location === "root") fs.chmodSync(f.cache, 0o755); + if (location === "descendant") fs.chmodSync(path.dirname(f.binaryPath("agentplugins")), 0o755); + if (location === "ancestor") fs.chmodSync(f.root, 0o777); + if (location === "symlink ancestor") { + fs.renameSync(f.cache, f.cache + "-real"); fs.symlinkSync(f.cache + "-real", f.cache); + } + await assert.rejects(run(f)); + } +}); + +test("input/cache overlap, source basename and linked package ancestors are rejected", async () => { + const f = fixture(); + for (const root of [f.cache, f.root, path.join(f.cache, "source"), f.packages.agentplugins, + path.join(f.root, "candidate with spaces"), "https://github.com/release", f.source + "/../candidate"]) { + await assert.rejects(run(f, "agentplugins", { candidateRoot: root })); + } + fs.renameSync(f.packages.agentplugins, f.packages.agentplugins + "-real"); + fs.symlinkSync(f.packages.agentplugins + "-real", f.packages.agentplugins); + await assert.rejects(run(f), /symlink/); +}); + +// Narrow filesystem fault proxy: real bytes/directories except at the specified +// operation. Tests keep named sentinels and inspect every remaining own entry. +function faults(intercept) { + return new Proxy(fsp, { get(target, key) { + if (typeof target[key] !== "function") return target[key]; + return async (...args) => intercept(key, args, () => target[key](...args)); + } }); +} +const fault = code => Object.assign(new Error(`injected ${code}`), { code }); +function handleProxy(handle, intercept) { + return new Proxy(handle, { get(target, key) { + const value = Reflect.get(target, key, target); + return typeof value === "function" ? (...args) => intercept(key, args, () => value.apply(target, args)) : value; + } }); +} + +for (const operation of ["write", "close", "chmod", "quarantine", "publish", "verify", "unlink quarantine", "rollback", "unlink staging", "unlock"]) + test(`owned transaction fault: ${operation} is terminal and preserves bounded recovery evidence`, async () => { + const f = fixture(); const { binaryPath } = await run(f); write(binaryPath, "prior corrupt bytes"); + const sentinel = path.join(path.dirname(binaryPath), "unrelated"); write(sentinel, "preserve"); + let injected = false; + const io = faults(async (key, args, real) => { + const file = String(args[0]); const destination = String(args[1]); + if (key === "open" && file.includes(".agentplugins-staging-")) { + const handle = await real(); + return handleProxy(handle, async (method, values, call) => { + if (method === "writeFile" && ["write", "unlink staging"].includes(operation)) { + await handle.writeFile(Buffer.from("partial")); injected = true; throw fault("ENOSPC"); + } + if (method === "close" && operation === "close") { await call(); injected = true; throw fault("EIO close"); } + return call(); + }); + } + if (key === "chmod" && operation === "chmod") { injected = true; throw fault("EPERM chmod"); } + if (key === "rename" && destination.includes(".agentplugins-replaced-") && operation === "quarantine") { injected = true; throw fault("EACCES quarantine"); } + if (key === "rename" && file.includes(".agentplugins-staging-") && ["publish", "rollback"].includes(operation)) { injected = true; throw fault("EIO publish"); } + if (key === "rename" && file.includes(".agentplugins-replaced-") && operation === "rollback") throw fault("EIO rollback"); + if (key === "rename" && file.includes(".agentplugins-staging-") && operation === "verify") { + await real(); write(binaryPath, "committed corruption"); injected = true; return; + } + if (key === "unlink" && ((operation === "unlink quarantine" && file.includes(".agentplugins-replaced-")) || + (operation === "unlink staging" && file.includes(".agentplugins-staging-")) || (operation === "unlock" && file.endsWith(".lock")))) { + injected = true; throw fault("EACCES cleanup"); + } + return real(); + }); + await assert.rejects(run(f, "agentplugins", {}, { io }), operation.startsWith("unlink") || ["rollback", "unlock"].includes(operation) ? /uncertainty/ : /injected|verification/); + assert.equal(injected, true); assert.equal(fs.readFileSync(sentinel, "utf8"), "preserve"); + const entries = fs.readdirSync(path.dirname(binaryPath)); + if (["unlink quarantine", "unlock"].includes(operation)) assert.deepEqual(fs.readFileSync(binaryPath), f.bodies["agentplugins" + TARGET]); + else if (operation === "rollback") assert.equal(fs.existsSync(binaryPath), false); + else assert.equal(fs.readFileSync(binaryPath, "utf8"), "prior corrupt bytes"); + assert.equal(entries.some(x => x.startsWith(".agentplugins-staging-")), operation === "unlink staging"); + assert.equal(entries.some(x => x.startsWith(".agentplugins-replaced-")), ["rollback", "unlink quarantine"].includes(operation)); + assert.equal(locks(f).length, operation === "unlock" ? 1 : 0); + }); + +test("copy failure, permission denial and ownership mismatch preserve the exact entry", async () => { + const f = fixture(); const { binaryPath } = await run(f); write(binaryPath, "prior"); + const io = faults((key, args, real) => { if (key === "copyFile") throw fault("ENOSPC copy"); return real(); }); + const file = path.join(f.root, "downloaded"); write(file, f.bodies["agentplugins" + TARGET]); + await assert.rejects(v.installVerifiedBinary(file, binaryPath, f.release("agentplugins").asset.binary, { + lockRoot: path.join(f.cache, ".locks"), osName: "linux", strict: true, io + }), /ENOSPC/); + assert.equal(fs.readFileSync(binaryPath, "utf8"), "prior"); + assert.deepEqual(fs.readdirSync(path.dirname(binaryPath)), ["agentplugins"]); + for (const location of [f.cache, binaryPath]) { + const wrongOwner = faults(async (key, args, real) => { + const value = await real(); if (key === "lstat" && args[0] === location) value.uid = 98765; return value; + }); + await assert.rejects(run(f, "agentplugins", {}, { io: wrongOwner }), /owned|unsafe/); + } + const denied = faults((key, args, real) => { if (key === "open" && String(args[0]).endsWith(".lock")) throw fault("EACCES"); return real(); }); + await assert.rejects(run(f, "agentplugins", {}, { io: denied }), /EACCES/); assert.deepEqual(locks(f), []); +}); + +test("unlock only removes owned inode and failed lock write cleans only its own identity", async () => { + const f = fixture(); await run(f); const lockRoot = path.join(f.cache, ".locks"); + const unlock = await v.acquireLock(f.binaryPath("agentplugins"), { lockRoot }); + const lock = path.join(lockRoot, locks(f)[0]); fs.renameSync(lock, path.join(f.root, "held-lock")); + write(lock, "unowned sentinel"); + await assert.rejects(unlock(), /identity changed/); await unlock(); + assert.equal(fs.readFileSync(lock, "utf8"), "unowned sentinel"); fs.unlinkSync(lock); + const io = faults(async (key, args, real) => { + const value = await real(); + if (key === "open" && String(args[0]).endsWith(".lock")) return handleProxy(value, (method, values, call) => { + if (method === "writeFile") throw fault("ENOSPC lock"); return call(); + }); + return value; + }); + await assert.rejects(v.acquireLock(f.binaryPath("agentplugins"), { lockRoot, io }), /ENOSPC/); + assert.deepEqual(locks(f), []); + for (const timeoutMs of [Infinity, NaN, -1]) await assert.rejects(v.acquireLock("target", { lockRoot, timeoutMs }), /finite/); +}); + +test("cancellation during staging and after publication restores prior bytes under the same lock", async () => { + for (const phase of ["stage", "publish"]) { + const f = fixture(); const { binaryPath } = await run(f); write(binaryPath, "prior"); + const controller = new AbortController(); + const io = faults(async (key, args, real) => { + const result = await real(); + if ((phase === "stage" && key === "chmod") || + (phase === "publish" && key === "rename" && String(args[0]).includes(".agentplugins-staging-"))) controller.abort(); + return result; + }); + await assert.rejects(run(f, "agentplugins", { signal: controller.signal }, { io }), /cancelled/); + assert.equal(fs.readFileSync(binaryPath, "utf8"), "prior"); + assert.deepEqual(fs.readdirSync(path.dirname(binaryPath)), ["agentplugins"]); assert.deepEqual(locks(f), []); + } +}); + +test("quarantine collision and newly appeared target preserve unowned sentinels", async () => { + for (const collision of ["quarantine", "target"]) { + const f = fixture(); const { binaryPath } = await run(f); write(binaryPath, "prior"); + let sentinel; + const io = faults(async (key, args, real) => { + if (collision === "quarantine" && key === "lstat" && String(args[0]).includes(".agentplugins-replaced-")) { + sentinel = args[0]; if (!fs.existsSync(sentinel)) write(sentinel, "unowned"); + } + const value = await real(); + if (collision === "target" && key === "rename" && String(args[1]).includes(".agentplugins-replaced-")) { + sentinel = binaryPath; write(sentinel, "unowned"); + } + return value; + }); + await assert.rejects(run(f, "agentplugins", {}, { io }), /collision|appeared/); + assert.equal(fs.readFileSync(sentinel, "utf8"), "unowned"); assert.deepEqual(locks(f), []); + } +}); + +test("private path never consults legacy options, tokens, environment cache or transports", async () => { + const f = fixture(); + const environment = new Proxy({}, { get() { throw new Error("ambient environment read"); } }); + const result = await run(f, "plugin-kit-ai", { environment, request() { throw new Error("transport called"); }, + repository: "legacy", version: "latest", vendor: f.root }); + assert.deepEqual(fs.readFileSync(result.binaryPath), f.bodies["plugin-kit-ai" + TARGET]); + await assert.rejects(run(f, "agentplugins", { candidateRoot: undefined }), /local candidate root/); + assert.equal(fs.existsSync(f.binaryPath("agentplugins")), false); +}); + +test("staging replacement is preserved when identity-owned cleanup fails closed", async () => { + const f = fixture(); let sentinel; + const io = faults(async (key, args, real) => { + const result = await real(); + if (key === "chmod" && String(args[0]).includes(".agentplugins-staging-")) { + sentinel = args[0]; fs.renameSync(sentinel, path.join(f.root, "held-staging")); write(sentinel, "unowned"); + } + return result; + }); + await assert.rejects(run(f, "agentplugins", {}, { io }), /cleanup\/rollback uncertainty.*identity changed/); + assert.equal(fs.readFileSync(sentinel, "utf8"), "unowned"); + assert.equal(fs.existsSync(f.binaryPath("agentplugins")), false); assert.deepEqual(locks(f), []); +}); + +test("metadata symlinks/hardlinks and source ancestor links fail without opening alternate bytes", async () => { + for (const filename of ["candidate.json", "private-release.json", "package.json"]) for (const link of ["symbolic", "hard"]) { + const f = fixture(); const file = path.join(f.packages.agentplugins, filename); + const saved = path.join(f.root, "saved"); fs.renameSync(file, saved); + if (link === "symbolic") fs.symlinkSync(saved, file); + else { fs.linkSync(saved, file); } + await assert.rejects(run(f)); assert.deepEqual(fs.readdirSync(f.cache), []); + } + const f = fixture(); const ancestor = path.dirname(f.source); + fs.renameSync(ancestor, ancestor + "-real"); fs.symlinkSync(ancestor + "-real", ancestor); + await assert.rejects(run(f), /symlink/); assert.deepEqual(locks(f), []); +}); diff --git a/npm/agentplugins/test/verifier.test.js b/npm/agentplugins/test/verifier.test.js new file mode 100644 index 000000000..dc0b0aa27 --- /dev/null +++ b/npm/agentplugins/test/verifier.test.js @@ -0,0 +1,160 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const fsp = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const http = require("node:http"); +const { EventEmitter } = require("node:events"); +const { PassThrough, Duplex } = require("node:stream"); +const test = require("node:test"); +const v = require("../lib/verifier"); +const c = require("../scripts/dual-authoring-candidate"); +const BODY = Buffer.from("bounded injected stream\n"); +const PIN = c.metadata(BODY); +const URL = "https://github.com/owner/repo/releases/download/exact/binary"; + +function transport(makeResponse, timeout = false) { + return (url, options) => { + assert.deepEqual(Object.keys(options.headers).sort(), ["Accept", "User-Agent"]); + const request = new EventEmitter(); + request.destroy = error => request.emit("error", error); + request.setTimeout = (ms, callback) => { assert.equal(ms, 30_000); if (timeout) process.nextTick(callback); }; + if (!timeout) process.nextTick(() => { + const response = new PassThrough(); response.statusCode = 200; response.headers = {}; + makeResponse(response, request, url); + }); + return request; + }; +} +function destination() { + return path.join(fs.mkdtempSync(path.join(os.tmpdir(), "verifier-stream-")), "download"); +} + +test("canonical downloader closes before verified success with or without Content-Length", async () => { + for (const declared of [false, true]) { + const file = destination(); + await v.downloadFile(URL, file, PIN, { request: transport((res, req) => { + if (declared) res.headers["content-length"] = String(BODY.length); + req.emit("response", res); res.end(BODY); + }) }); + assert.deepEqual(fs.readFileSync(file), BODY); assert.equal(fs.statSync(file).mode & 0o777, 0o600); + fs.unlinkSync(file); assert.equal(fs.existsSync(file), false); + } +}); + +for (const delta of [0, -1, 1]) { + test(`real HTTP parser preserves padded Content-Length with size delta ${delta}`, async () => { + const length = "000" + (BODY.length + delta); + const file = destination(); + let parsed; + const request = (url, options) => { + assert.equal(url.protocol, "https:"); + assert.equal(url.hostname, "github.com"); + assert.deepEqual(Object.keys(options.headers).sort(), ["Accept", "User-Agent"]); + let sent = false; + const socket = new Duplex({ + read() {}, + write(chunk, encoding, callback) { + callback(); + if (sent) return; + sent = true; + process.nextTick(() => { + this.push(Buffer.concat([Buffer.from( + `HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: ${length}\r\n\r\n` + ), BODY])); + this.push(null); + }); + } + }); + socket.setTimeout = () => socket; + const req = http.get({ hostname: "fixture.invalid", headers: options.headers, + createConnection: () => socket }); + req.prependOnceListener("response", res => { parsed = res.headers["content-length"]; }); + return req; + }; + const download = v.downloadFile(URL, file, PIN, { request }); + if (delta === 0) { + await download; + assert.deepEqual(fs.readFileSync(file), BODY); + assert.equal(fs.statSync(file).mode & 0o777, 0o600); + fs.unlinkSync(file); + } else { + await assert.rejects(download, /size does not match embedded metadata/); + assert.equal(fs.existsSync(file), false); + } + assert.equal(parsed, length); + }); +} + +test("declared length rejects zero, malformed, ambiguous and inexact values", async () => { + for (const length of ["0", "000", "NaN", "abc", "-1", "+23", "1.5", "01", "9999999999999999999999", "3", "", " 22", "22,22"]) { + const file = destination(); + await assert.rejects(v.downloadFile(URL, file, PIN, { request: transport((res, req) => { + res.headers["content-length"] = length; req.emit("response", res); res.end(BODY); + }) }), /size/); + assert.equal(fs.existsSync(file), false); + } +}); + +test("declared zero and unsafe sizes reject even when embedded size matches numerically", async () => { + for (const length of ["000", "09007199254740992"]) { + const file = destination(); + await assert.rejects(v.downloadFile(URL, file, { ...PIN, size: Number(length) }, { + request: transport((res, req) => { + res.headers["content-length"] = length; req.emit("response", res); res.end(BODY); + }) + }), /size does not match embedded metadata/); + assert.equal(fs.existsSync(file), false); + } +}); + +test("stream overflow, truncation, hash failure, response and request errors settle after output closes", async () => { + for (const scenario of ["overflow", "truncated", "hash", "response error", "request error", "premature close"]) { + const file = destination(); let closed = false; + const create = fs.createWriteStream; + fs.createWriteStream = (...args) => { const stream = create(...args); stream.on("close", () => { closed = true; }); return stream; }; + try { + await assert.rejects(v.downloadFile(URL, file, PIN, { request: transport((res, req) => { + req.emit("response", res); + if (scenario === "overflow") res.end(Buffer.concat([BODY, BODY])); + if (scenario === "truncated") res.end(BODY.subarray(1)); + if (scenario === "hash") res.end(Buffer.alloc(BODY.length)); + if (scenario === "response error") res.destroy(new Error("injected response error")); + if (scenario === "request error") req.emit("error", new Error("injected request error")); + if (scenario === "premature close") res.destroy(); + }) })); + assert.equal(closed, true, scenario); + if (fs.existsSync(file)) fs.unlinkSync(file); + } finally { fs.createWriteStream = create; } + } +}); + +test("timeout, HTTP status, redirects, URL credentials and exclusive output are bounded", async () => { + await assert.rejects(v.downloadFile(URL, destination(), PIN, { request: transport(() => {}, true) }), /timed out/); + for (const status of [403, 404, 500]) await assert.rejects(v.downloadFile(URL, destination(), PIN, { + request: transport((res, req) => { res.statusCode = status; req.emit("response", res); res.end(); }) + }), /HTTP/); + let requests = 0; + await assert.rejects(v.downloadFile(URL, destination(), PIN, { request: transport((res, req) => { + requests++; res.statusCode = 302; res.headers.location = URL; req.emit("response", res); res.end(); + }) }), /too many/); assert.equal(requests, 6); + for (const location of ["https://[", "http://github.com/path", "https://user:pass@github.com/path", "https://evil.invalid/path"]) { + await assert.rejects(v.downloadFile(URL, destination(), PIN, { request: transport((res, req) => { + res.statusCode = 302; res.headers.location = location; req.emit("response", res); res.end(); + }) })); + } + const file = destination(); fs.writeFileSync(file, "unowned sentinel"); + await assert.rejects(v.downloadFile(URL, file, PIN, { request: transport((res, req) => { + req.emit("response", res); res.end(BODY); + }) }), /EEXIST/); assert.equal(fs.readFileSync(file, "utf8"), "unowned sentinel"); +}); + +test("hash and original facade cache helpers remain canonical", async () => { + const file = destination(); fs.writeFileSync(file, BODY); + assert.equal(await v.sha256File(file), PIN.sha256); + assert.equal(await v.validCachedBinary(file, PIN.sha256), true); + fs.unlinkSync(file); assert.equal(await v.validCachedBinary(file, PIN.sha256), false); + assert.equal(typeof fsp.open, "function"); +});