mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Canary releases test the published npm packages through a clean onboarding install. > - A package can be announced in registry metadata while a fresh install gets a tarball 404. > - The existing ETARGET retry does not cover that distinct error. > - This pull request retries only a verified public canary tarball acquisition failure. > - Permanent errors still fail, and onboarding runs once after acquisition succeeds. ## Linked Issues or Issue Description Refs: #15743 **What happened?** The [Release run](https://github.com/paperclipai/paperclip/actions/runs/37998601578) published canary `2026.1009.0-canary.17`. Its registry verification and publisher install passed. The separate fresh onboarding install then failed with a final npm E404 for that exact `@paperclipai/server` tarball. The tarball was available on a later read. This is a separate failure from ETARGET; it does not prove the cause of the registry visibility difference. **Expected behavior** Allow a bounded acquisition retry when the effective registry is public npm and fresh primary metadata announces the exact released canary tarball. Keep other E404, authentication, configuration, and onboarding errors fatal. **Steps to reproduce** 1. Run the offline helper fixture with a first-attempt E404 for an allowlisted package at the requested canary version. 2. Return canonical effective npm registry configuration and matching primary metadata. 3. Make the second install succeed. Confirm two installs and one onboarding invocation. 4. Keep the tarball unavailable. Confirm three installs, a final failure, and no onboarding invocation. **Paperclip version or commit** The observed run used commit `ce024457f3d747657501fbcfe8eac71f6772150f`. This fix builds on the merged acquisition helper and current master. **Deployment mode** Other: the published npm canary onboarding check in release CI. ## What Changed - Add a canary-only classifier for one unambiguous final E404 at the canonical tarball URL of a CI-published package from the existing release manifest. - Query effective npm registry configuration in the failed install's prefix, environment, and working directory. Keep bounded configuration output in memory without logging it. Respect scoped registry precedence. - Require fresh primary metadata to match the package, version, tarball URL, and valid SHA512 integrity syntax. Reject redirects, conflicting errors, truncated logs, other artifacts, and unknown or custom effective registries. - Share one five-second configuration and metadata deadline within the existing two-minute acquisition budget. Keep three install attempts, refreshed retry metadata, process cleanup, and one onboarding invocation. - Bound metadata header and body waits even if transport abort propagation stalls. Cancel the reader and any late response body without waiting for cancellation acknowledgements. - Preserve the existing ETARGET classifier and its character-based stderr tail. Add offline subprocess, actual npm configuration, body cancellation, budget, identity, and failure tests. Document the scope and limits. ## Verification - `node --test scripts/__tests__/release-verify-workflow.test.mjs scripts/cloud-source-verification.test.mjs scripts/standard-image-contract.test.mjs`: 73 passed, no skips, under Node 22 on the PR head. - `pnpm test:release-registry`: 153 passed, no skips, on the PR head. This includes the existing real Playwright cleanup and startup timeout tests. - [Hosted CI](https://github.com/paperclipai/paperclip/actions/runs/38011028296) passed on this head. The rollup has 53 successful checks and two expected Storybook skips. This includes typecheck, build, the full general and serialized test suites, E2E, and Canary Dry Run. - The new tests use fixture-owned npm configuration and local HTTP servers. No published canary tarballs were downloaded or installed for this change. No release or deployment was triggered. - `pnpm test:run` was not run locally. The full split test suites passed in hosted CI. Independent source checks on this head passed 46 committed tests, 22 boundary checks, and six negative controls. Greptile scored this head 5/5 with no unresolved threads. - The source diff and PR text were checked for secrets, private identifiers, and unintended artifacts. ## Risks - The new retry is conservative. A configuration capture over 4 KiB, metadata over 64 KiB, or a check that exceeds five seconds denies retry. A persistent tarball failure still fails after three installs. - Matching primary metadata proves the announced artifact identity and integrity syntax. It does not independently verify tarball bytes. The subsequent npm install retains its integrity check. - Registry checks use npm's resolved global and applicable scope values. They do not add credentials or change npm configuration. Existing ETARGET behavior, onboarding assertions, and overall startup deadlines remain unchanged. ## Model Used OpenAI GPT-6 (Codex). The exact model variant and context window are not exposed in this session. Used reasoning, code execution, offline fixtures, and GitHub API tools. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
263 lines
13 KiB
JavaScript
263 lines
13 KiB
JavaScript
import { spawn } from "node:child_process";
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { loadReleaseManifest } from "../../scripts/release-package-map.mjs";
|
|
|
|
// Leave three minutes for onboarding inside Playwright's existing five-minute
|
|
// startup deadline. This budget includes every install attempt and backoff.
|
|
export const INSTALL_BUDGET_MS = 120_000;
|
|
const MAX_INSTALL_ATTEMPTS = 3;
|
|
const STDERR_TAIL_CHARS = 64 * 1024;
|
|
export const TARBALL_METADATA_TIMEOUT_MS = 5_000;
|
|
const METADATA_LIMIT_BYTES = 64 * 1024;
|
|
const PRIMARY_REGISTRY = "https://registry.npmjs.org/";
|
|
const RELEASE_PACKAGE_NAMES = new Set(loadReleaseManifest()
|
|
.filter((entry) => entry.publishFromCi === true).map((entry) => entry.name));
|
|
|
|
function withAbort(pending, signal) {
|
|
return new Promise((resolve, reject) => {
|
|
const abort = () => {
|
|
signal.removeEventListener("abort", abort);
|
|
reject(signal.reason);
|
|
};
|
|
const finish = (settle) => (value) => {
|
|
signal.removeEventListener("abort", abort);
|
|
settle(value);
|
|
};
|
|
// Keep handlers on the original operation even after the bounded wait
|
|
// ends. A transport need not settle immediately when its signal aborts.
|
|
Promise.resolve(pending).then(finish(resolve), finish(reject));
|
|
signal.addEventListener("abort", abort, { once: true });
|
|
if (signal.aborted) abort();
|
|
});
|
|
}
|
|
|
|
export async function canRetryPublishedTarball404(stderr, version, {
|
|
signal, prefix, env = process.env, fetchMetadata = globalThis.fetch, runConfig = run,
|
|
}) {
|
|
// A package metadata 404, a third-party artifact, or another release must not
|
|
// be mistaken for availability of this canary's announced public tarball.
|
|
if (!/^\d+\.\d+\.\d+-canary\.\d+$/.test(version)) return false;
|
|
const codes = [...stderr.matchAll(/^npm (?:error|ERR!) code ([A-Z0-9_]+)\r?$/gm)];
|
|
const final = codes.at(-1);
|
|
if (codes.length !== 1 || final?.[1] !== "E404") return false;
|
|
const block = stderr.slice(final.index);
|
|
const requests = [...block.matchAll(/^npm (?:error|ERR!) 404 Not Found - GET (\S+) - Not found\r?$/gm)];
|
|
const httpLines = stderr.match(/^npm (?:error|ERR!).* - (?:GET|HEAD|POST|PUT|DELETE|PATCH) /gm) ?? [];
|
|
if (requests.length !== 1 || httpLines.length !== 1) return false;
|
|
const tarball = requests[0][1];
|
|
const packageName = [...RELEASE_PACKAGE_NAMES].find((name) => {
|
|
const basename = name.split("/").at(-1);
|
|
return tarball === `${PRIMARY_REGISTRY}${name}/-/${basename}-${version}.tgz`;
|
|
});
|
|
if (!packageName) return false;
|
|
const resources = [...block.matchAll(/^npm (?:error|ERR!) 404\s+(?:The requested resource )?'([^']+)'(?: could not be found| is not in this registry)/gm)];
|
|
if (resources.some((resource) => resource[1] !== `${packageName}@${tarball}`)) return false;
|
|
signal.throwIfAborted();
|
|
const timeout = new AbortController();
|
|
const timer = setTimeout(() => timeout.abort(new Error("Canary tarball metadata lookup timed out")), TARBALL_METADATA_TIMEOUT_MS);
|
|
const lookupSignal = AbortSignal.any([signal, timeout.signal]);
|
|
const metadataUrl = `${PRIMARY_REGISTRY}${encodeURIComponent(packageName)}/${version}`;
|
|
let response;
|
|
try {
|
|
// Query only registry values in the failed install's effective prefix,
|
|
// inherited cwd and environment. Keep even warnings out of the logs:
|
|
// configuration values can contain credentials. Config and HTTP share
|
|
// this short deadline, inside the unchanged installation budget.
|
|
const config = await runConfig(process.platform === "win32" ? "npm.cmd" : "npm", [
|
|
"config", "get", "registry", "@paperclipai:registry", "--prefix", prefix,
|
|
], { env, signal: lookupSignal, captureStdout: true, captureStderr: true, quiet: true, outputLimit: 4096 });
|
|
if (config.code !== 0 || config.signal || config.outputTruncated) return false;
|
|
const lines = config.stdout.trim().split(/\r?\n/);
|
|
if (lines.length !== 2 || !lines[0].startsWith("registry=") || !lines[1].startsWith("@paperclipai:registry=")) return false;
|
|
const globalRegistry = lines[0].slice("registry=".length);
|
|
const scoped = lines[1].slice("@paperclipai:registry=".length);
|
|
const effectiveRegistry = packageName.startsWith("@") && scoped !== "undefined" ? scoped : globalRegistry;
|
|
if (![PRIMARY_REGISTRY, PRIMARY_REGISTRY.slice(0, -1)].includes(effectiveRegistry)) return false;
|
|
response = await withAbort(Promise.resolve(fetchMetadata(metadataUrl, {
|
|
signal: lookupSignal, redirect: "error", credentials: "omit",
|
|
headers: { accept: "application/json", "cache-control": "no-cache" },
|
|
})).then((arrived) => {
|
|
if (lookupSignal.aborted) void arrived.body?.cancel().catch(() => {});
|
|
return arrived;
|
|
}), lookupSignal);
|
|
if (response.status !== 200 || response.redirected || response.url !== metadataUrl || !response.body) return false;
|
|
const reader = response.body.getReader();
|
|
const cancelReader = () => { void reader.cancel().catch(() => {}); };
|
|
lookupSignal.addEventListener("abort", cancelReader, { once: true });
|
|
const chunks = [];
|
|
let bytes = 0;
|
|
try {
|
|
while (true) {
|
|
lookupSignal.throwIfAborted();
|
|
const { done, value } = await withAbort(reader.read(), lookupSignal);
|
|
if (done) break;
|
|
bytes += value.byteLength;
|
|
if (bytes > METADATA_LIMIT_BYTES) return false;
|
|
chunks.push(value);
|
|
}
|
|
} finally {
|
|
// Initiate cleanup without extending either deadline for a transport's
|
|
// pending/rejected cancellation acknowledgement.
|
|
lookupSignal.removeEventListener("abort", cancelReader);
|
|
cancelReader();
|
|
reader.releaseLock();
|
|
}
|
|
lookupSignal.throwIfAborted();
|
|
const manifest = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
const integrity = manifest.dist?.integrity;
|
|
// This validates the integrity announced by primary metadata. No tarball
|
|
// is downloaded here, and this is not an independent content digest proof.
|
|
return manifest.name === packageName && manifest.version === version
|
|
&& manifest.dist?.tarball === tarball && typeof integrity === "string"
|
|
&& /^sha512-[A-Za-z0-9+/]{86}==$/.test(integrity)
|
|
&& Buffer.from(integrity.slice(7), "base64").toString("base64") === integrity.slice(7);
|
|
} catch {
|
|
signal.throwIfAborted();
|
|
return false;
|
|
} finally {
|
|
void response?.body?.cancel().catch(() => {});
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function run(command, args, { env, signal, captureStderr = false, captureStdout = false, quiet = false, outputLimit = STDERR_TAIL_CHARS }) {
|
|
return new Promise((resolve, reject) => {
|
|
signal.throwIfAborted();
|
|
const grouped = process.platform !== "win32";
|
|
const child = spawn(command, args, {
|
|
env, detached: grouped, stdio: ["ignore", captureStdout ? "pipe" : "inherit", captureStderr ? "pipe" : "inherit"],
|
|
});
|
|
let stderr = quiet ? Buffer.alloc(0) : "";
|
|
let stdout = quiet ? Buffer.alloc(0) : "";
|
|
let outputTruncated = false;
|
|
const retain = (previous, chunk) => {
|
|
if (quiet) {
|
|
outputTruncated ||= previous.length + chunk.byteLength > outputLimit;
|
|
return Buffer.concat([previous, chunk]).subarray(-outputLimit);
|
|
}
|
|
// Preserve the existing install stderr character tail and ETARGET
|
|
// behavior, including multibyte diagnostics. Only private config capture
|
|
// uses a byte bound; any truncated E404 evidence is rejected.
|
|
const next = previous + chunk.toString();
|
|
outputTruncated ||= next.length > outputLimit;
|
|
return next.slice(-outputLimit);
|
|
};
|
|
let stopping;
|
|
const kill = (kind) => {
|
|
try {
|
|
if (grouped) process.kill(-child.pid, kind);
|
|
else child.kill(kind);
|
|
} catch (error) {
|
|
if (error.code !== "ESRCH") throw error;
|
|
}
|
|
};
|
|
const stop = () => {
|
|
if (stopping || !child.pid) return;
|
|
kill("SIGTERM");
|
|
// Also stop any lifecycle-script children even if npm exits first.
|
|
stopping = new Promise((done) => setTimeout(() => { kill("SIGKILL"); done(); }, 500));
|
|
};
|
|
signal.addEventListener("abort", stop, { once: true });
|
|
child.stderr?.on("data", (chunk) => {
|
|
if (!quiet) process.stderr.write(chunk);
|
|
stderr = retain(stderr, chunk);
|
|
});
|
|
child.stdout?.on("data", (chunk) => {
|
|
if (!quiet) process.stdout.write(chunk);
|
|
stdout = retain(stdout, chunk);
|
|
});
|
|
child.on("error", reject);
|
|
child.on("close", async (code, exitSignal) => {
|
|
signal.removeEventListener("abort", stop);
|
|
await stopping;
|
|
if (signal.aborted) reject(signal.reason);
|
|
else resolve({ code, signal: exitSignal,
|
|
stderr: quiet ? stderr.toString("utf8") : stderr, stdout: quiet ? stdout.toString("utf8") : stdout, outputTruncated });
|
|
});
|
|
});
|
|
}
|
|
|
|
function delay(ms, signal) {
|
|
return new Promise((resolve, reject) => {
|
|
signal.throwIfAborted();
|
|
const timer = setTimeout(() => {
|
|
signal.removeEventListener("abort", stop);
|
|
resolve();
|
|
}, ms);
|
|
const stop = () => { clearTimeout(timer); reject(signal.reason); };
|
|
signal.addEventListener("abort", stop, { once: true });
|
|
});
|
|
}
|
|
|
|
export async function startPublishedCanary({
|
|
version, workspace, dataDir, env = process.env,
|
|
installBudgetMs = INSTALL_BUDGET_MS, retryDelayMs = 10_000,
|
|
signal = new AbortController().signal,
|
|
}) {
|
|
if (!version || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) {
|
|
throw new Error("An exact published Paperclip version is required");
|
|
}
|
|
let prefix;
|
|
try {
|
|
const budget = new AbortController();
|
|
const timer = setTimeout(() => budget.abort(new Error("Canary npm installation exceeded its startup budget")), installBudgetMs);
|
|
const installSignal = AbortSignal.any([signal, budget.signal]);
|
|
try {
|
|
for (let attempt = 1; attempt <= MAX_INSTALL_ATTEMPTS; attempt++) {
|
|
installSignal.throwIfAborted();
|
|
// Every attempt gets a clean prefix. The fresh per-smoke npm cache still
|
|
// retains downloaded tarballs, but --prefer-online refreshes metadata.
|
|
prefix = await mkdtemp(path.join(workspace, "npm-install-"));
|
|
const result = await run(process.platform === "win32" ? "npm.cmd" : "npm", [
|
|
"install", "--prefix", prefix, "--no-save", "--no-package-lock", "--no-audit", "--no-fund",
|
|
...(attempt > 1 ? ["--prefer-online"] : []), `paperclipai@${version}`,
|
|
], { env, signal: installSignal, captureStderr: true });
|
|
if (result.code === 0) break;
|
|
const codes = [...result.stderr.matchAll(/^npm (?:error|ERR!) code ([A-Z0-9_]+)\r?$/gm)];
|
|
const missingVersion = codes.at(-1)?.[1] === "ETARGET";
|
|
const retryable = !result.signal && attempt < MAX_INSTALL_ATTEMPTS
|
|
&& (missingVersion || (!result.outputTruncated
|
|
&& await canRetryPublishedTarball404(result.stderr, version, { signal: installSignal, prefix, env })));
|
|
if (!retryable) {
|
|
throw new Error(`Canary npm installation failed (exit ${result.code ?? result.signal}); onboarding was not started`);
|
|
}
|
|
await rm(prefix, { recursive: true, force: true });
|
|
prefix = undefined;
|
|
process.stderr.write(`Canary dependency publication is incomplete; retrying npm installation (${attempt}/${MAX_INSTALL_ATTEMPTS})\n`);
|
|
await delay(retryDelayMs * attempt, installSignal);
|
|
}
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
signal.throwIfAborted();
|
|
// Start the installed CLI exactly once. Its own failures must never trigger
|
|
// another onboarding run or another dependency-install attempt.
|
|
const result = await run(path.join(prefix, "node_modules", ".bin", process.platform === "win32" ? "paperclipai.cmd" : "paperclipai"),
|
|
["onboard", "--yes", "--data-dir", dataDir], { env, signal });
|
|
if (result.code !== 0) throw new Error(`Canary onboarding failed (exit ${result.code ?? result.signal})`);
|
|
} finally {
|
|
if (prefix) await rm(prefix, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
const [version, workspace, dataDir] = process.argv.slice(2);
|
|
const cancellation = new AbortController();
|
|
const stop = (kind) => cancellation.abort(new Error(`Canary startup stopped by ${kind}`));
|
|
const interrupt = () => stop("SIGINT");
|
|
const terminate = () => stop("SIGTERM");
|
|
process.on("SIGINT", interrupt);
|
|
process.on("SIGTERM", terminate);
|
|
try {
|
|
await startPublishedCanary({ version, workspace, dataDir, signal: cancellation.signal });
|
|
} catch (error) {
|
|
console.error(error.message);
|
|
process.exitCode = 1;
|
|
} finally {
|
|
process.removeListener("SIGINT", interrupt);
|
|
process.removeListener("SIGTERM", terminate);
|
|
}
|
|
}
|