Port upstream runtime while preserving Boat staging migration history

This commit is contained in:
Dotta committed 2026-10-11 00:01:05 -05:00
1 parent 780ecec585
commit d50dd0f49b
30 files changed
+4089 -698

No files matched your search

+9
View File
@@ -502,6 +502,15 @@ jobs:
# across three dedicated runners (~143s each), and the remaining
# ~2755s levels across twelve duration-balanced shards at ~230s
# each, in line with the other ~200-290s lanes.
#
# The balance only holds while the manifest is fresh. By 2026-10-10
# the without-chat group had grown to 871 suites and ~5031s of serial
# vitest time (mean of runs 38076844891 and 38076972444), 230 of
# them unrecorded and weighted at the median, so the twelve shards
# ran 313-554s of tests (server 2/12 at 618s was the slowest check
# in the run). Re-recorded durations level them to ~415s each; the
# shard count stays at twelve because Build and workspaces-b sit
# above that already.
- group: general-server-without-chat
group_label: server (1/12)
shard_index: 0
+19
View File
@@ -361,6 +361,25 @@ jobs:
ref: ${{ github.sha }}
fetch-depth: 0
- name: Attach verified canary source to local master
env:
SOURCE_SHA: ${{ github.sha }}
run: |
set -euo pipefail
if [ "$GITHUB_REPOSITORY" != "paperclipai/paperclip" ] ||
[ "$GITHUB_EVENT_NAME" != "push" ] ||
[ "$GITHUB_REF" != "refs/heads/master" ]; then
echo "Error: canary branch attachment requires a trusted master push." >&2
exit 1
fi
if ! [[ "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]] ||
[ "$(git rev-parse HEAD)" != "$SOURCE_SHA" ]; then
echo "Error: canary checkout does not match the pinned source SHA." >&2
exit 1
fi
git checkout -B master "$SOURCE_SHA"
test "$(git rev-parse HEAD)" = "$SOURCE_SHA"
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
+8
View File
@@ -433,6 +433,14 @@ run/turn ids, payload digest, attempt/acknowledgement state, and one of `steered
`issue_thread_interactions.result`. Deleting the interaction cascades its receipt,
while deleting a referenced run clears that run pointer without deleting history.
The question-history index covers `(company_id, idempotency_key)` for every
`question-response:` wake state, including failed, cancelled, and skipped
receipts excluded from the delivery-idempotency fence. Migration `0332` creates
this nonunique index concurrently through the standard migration executor.
Apply it with `pnpm db:migrate` on a direct connection before enabling the new
sleep guard. The executor retries interrupted invalid builds and records history
only after the index is valid. It does not delete delivery or wake records.
## Plugin database namespaces
The plugin runtime tracks plugin-owned database namespaces and migrations in `plugin_database_namespaces` and `plugin_migrations`. Hosted deployments that separate runtime and migration connections should set `DATABASE_MIGRATION_URL`; plugin namespace migration work uses the migration connection when present.
+22 -1
View File
@@ -64,6 +64,10 @@ internal traceability tag.
## Core Invariants
- canaries publish from `master`
- the canary publisher checks out the exact master push SHA. It verifies the
repository, event, branch, and checked-out commit before attaching local
`master` at that same SHA. It never switches to a newer branch tip. The local
release script still rejects detached or non-master checkouts.
- nightlies republish a commit that already shipped a canary (the commit must
carry a `canary/v*` tag), and only after the release smoke suite passes
against that exact published canary
@@ -100,7 +104,7 @@ It:
- publishes the user-facing `paperclipai` package last, so `paperclipai@canary` does not advance before the full package set exists
- verifies that `canary` resolves to the just-published version and that published internal dependencies exist on npm
- installs `paperclipai@canary` into a clean temporary prefix as the final npm gate
- starts the exact published canary through a separate fresh npm install for the onboarding smoke; only npm `ETARGET` install failures retry, up to three attempts within two minutes, before onboarding runs once under the existing five-minute startup deadline
- starts the exact published canary through a separate fresh npm install for the onboarding smoke; npm `ETARGET` and verified canary tarball availability failures retry, up to three attempts within two minutes, before onboarding runs once under the existing five-minute startup deadline
- fails by default if npm leaves `latest` pointing at a canary; use `--allow-canary-latest` only when that state is intentional
- creates a git tag `canary/vYYYY.MDD.P-canary.N`
@@ -112,6 +116,23 @@ current patches and manifests while the separate lockfile-refresh PR is still
pending, without resolving new dependencies in the smoke job. The smoke still installs and tests
the exact published canary version, not a workspace build.
The tarball retry applies only to an unambiguous npm `E404` for a canonical
public-registry tarball of a CI-published Paperclip package at that exact canary
version. The helper queries npm's effective global and applicable scoped registry
in the failed install's prefix, working directory, and environment. A custom or
unknown effective registry fails. Configuration output stays private in memory.
Fresh primary-registry metadata must match the package, version, and tarball URL
and announce a valid SHA512 integrity value. This establishes the announced
artifact identity; it does not download the tarball or independently verify its
contents. The actual npm install still checks tarball integrity.
Configuration and metadata checks share a five-second deadline, including the
response body, within the same two-minute acquisition budget. Metadata is limited
to 64 KiB. Redirects, authentication errors, missing metadata, conflicting npm
errors, and other artifacts fail. A persistent promised-tarball failure also
fails after three installs. Onboarding errors never restart acquisition or
repeat onboarding.
The lockfile artifact is retained for 14 days. A publisher-job rerun replaces its
source-named artifact; a smoke-only rerun uses the existing artifact. A missing
artifact fails the job rather than falling back to a different dependency set.
+19 -15
View File
@@ -1712,27 +1712,31 @@ The broker implements this in `discoverOAuthEndpoints`
unconditional. `oauthEndpointsForConnection` resolves endpoints in this
order:
1. If the manifest's method `defaults` ship a **complete** pair
(`authorizationEndpoint` **and** `tokenEndpoint`), those are used
unconditionally. `discoverOAuthEndpoints` never runs in this case, so
endpoints stored on the connection's own OAuth config and 401 challenge
hints are **not consulted at all**.
2. Otherwise, for `mcp_remote` connections, the broker calls
1. A method's `defaults.discoveryUrl` is authoritative. The broker resolves
that document before cached endpoints, including when a saved connection
resumes or reconnects. It must describe the MCP resource or its authorization
server, not a separate web/API OAuth service. Failure stops setup.
2. A complete manifest pair (`authorizationEndpoint` and `tokenEndpoint`)
replaces discovery when the method does not support dynamic registration,
or when the connection uses an operator-entered client. A method that can
register automatically still discovers its MCP authorization server.
3. Otherwise, for `mcp_remote` connections, the broker calls
`discoverOAuthEndpoints`, which first checks endpoints already stored on
the connection's own OAuth config (falling back field-by-field to the 401
challenge hints); a complete stored/hinted pair is used as-is — no
`.well-known` fetch.
3. Only when neither of the above yields a complete pair does the broker run
4. Only when none of the above yields a complete pair does the broker run
the RFC 9728 → RFC 8414 discovery chain above.
Consequence: complete manifest endpoint hints are **authoritative, not
hints** — they override even endpoints that an earlier discovery persisted
on the connection, and if they go stale the broker keeps using them. For
discovery-capable vendors, ship only `serverUrl` in `defaults` (as
`notion.json` does) so the broker discovers fresh endpoints at connect
time; add explicit `authorizationEndpoint`/`tokenEndpoint` only for vendors
that do not publish RFC 9728/8414 metadata, and then own keeping them
current.
For discovery-capable vendors, prefer `serverUrl` alone (as `notion.json`
does), or pin the reviewed protected-resource document when the provider
requires an exact path. Keep explicit metadata and endpoint URLs current.
When changing discovery precedence, test each affected catalog method against
its actual metadata shape. Cover both new setup and a saved registration.
A metadata preflight that finds registration somewhere in its search is not
proof that sign-in selects that issuer. Sentry's web/API and MCP hosts both
serve valid metadata, but only its MCP issuer supports automatic registration;
see [Sentry](./SENTRY.md).
### Dynamic client registration (RFC 7591)
+1 -1
View File
@@ -13,7 +13,7 @@ Runtime authentication: [AI Connections](./AI-CONNECTIONS.md).
Long-term memory: [Experimental memory connectors](./MEMORY.md).
Provider notes: [Google Workspace](./GOOGLE-WORKSPACE.md),
[Gmail](./GMAIL.md), [Asana](./ASANA.md), [PostHog](./POSTHOG.md), [Neon](./NEON.md), [Superagent](./SUPERAGENT.md), [Gauge](./GAUGE.md),
[Gmail](./GMAIL.md), [Asana](./ASANA.md), [Sentry](./SENTRY.md), [PostHog](./POSTHOG.md), [Neon](./NEON.md), [Superagent](./SUPERAGENT.md), [Gauge](./GAUGE.md),
[AgentMail](./AGENTMAIL.md), [Telem.AI](./TELEM.md), [iMessage Photon](./IMESSAGE-PHOTON.md), and
[Enterpret](./ENTERPRET.md). Optional credential custody:
[Vercel Connect](./VERCEL-CONNECT.md).
+44
View File
@@ -0,0 +1,44 @@
# Sentry
Paperclip connects to Sentry's [official MCP server](https://mcp.sentry.dev/)
at `https://mcp.sentry.dev/mcp`. Browser sign-in uses the MCP authorization
server. A normal connection does not require a customer-created OAuth app.
## Discovery
The method pins the [MCP protected-resource metadata](https://mcp.sentry.dev/.well-known/oauth-protected-resource/mcp).
That document names `https://mcp.sentry.dev` as its authorization server.
Its [authorization-server metadata](https://mcp.sentry.dev/.well-known/oauth-authorization-server)
advertises automatic client registration, PKCE S256, refresh tokens, and
client ID metadata documents. Paperclip retains its normal client selection
and binding checks.
Do not use `https://sentry.io/.well-known/oauth-authorization-server` for this
method. That server describes the web/API OAuth service. It does not advertise
MCP client registration. Both metadata URLs return valid JSON, so checking
only HTTP status or the presence of authorization/token endpoints misses this
failure.
## Repair an existing connection
After deploying the corrected catalog, resume or reconnect the existing Sentry
connection. Keep its connection identity and access policies. Complete browser
consent if required. Confirm the intended organization and projects through a
read-only tool call from the assigned agent.
The catalog repair fixes setup and reconnect discovery. It cannot restore an
expired or revoked grant without the owner's consent. If the saved connection
changes to **Finish setup**, inspect its health error, refresh outcome, and
transition timestamp separately. A setup error alone does not prove why the
previous grant stopped working.
## Verification
The service regression uses separate, realistic metadata for both Sentry
hosts. It checks new registration and reuse of an existing MCP registration,
the authorization URL, resource, requested scopes, and PKCE. These fixtures
prove Paperclip's endpoint selection; they do not prove live account consent.
Before claiming restoration, verify browser consent, catalog discovery, and a
real agent read. Record token refresh separately. Do not publish credentials
or unredacted authorization URLs in evidence.
+10
View File
@@ -129,6 +129,16 @@ company/agent's linked run has finished without retry or accounting debt.
Image-setup history still blocks: promotion can succeed before teardown is
confirmed, so a terminal label does not prove that its provider resource is gone.
Completed question-answer deliveries permit sleep only after acknowledgement
and completion of their exact target run in the same company, without retry or
accounting debt. A fallback without a target run requires its exact question's
finished durable wake receipt in the same company. A coalesced fallback also
requires its exact company/agent's linked run to finish without retry or
accounting debt. The question-history index covers every wake status.
Pending deliveries, missing
completion evidence, and unfinished handoffs still block. Failed deliveries
without a pending target are history; other work checks still apply.
Every enabled plugin blocks sleep. A version label does not prove that arbitrary
worker code has no background activity. No plugin approval or configuration
bypass is supported. Scheduled and externally triggered work needs a durable
+1 -1
View File
@@ -61,7 +61,7 @@
"smoke:posthog-live": "node scripts/smoke/posthog-live.mjs",
"smoke:pipelines-tutorial": "./scripts/smoke/pipelines-tutorial-smoke.sh",
"smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs",
"test:release-registry": "node --test scripts/__tests__/canary-startup-playwright.test.mjs scripts/__tests__/cursor-public-install-sandbox.test.mjs scripts/__tests__/grok-public-install-sandbox.test.mjs scripts/__tests__/retain-runner-qualification-packages.test.mjs scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs scripts/service-onboard-smoke.test.mjs scripts/docker-onboard-smoke.test.mjs scripts/preview-artifacts.test.mjs scripts/cloud-migrator-artifacts.test.mjs",
"test:release-registry": "node --test scripts/__tests__/canary-startup-playwright.test.mjs scripts/__tests__/cursor-public-install-sandbox.test.mjs scripts/__tests__/grok-public-install-sandbox.test.mjs scripts/__tests__/prefetch-child.test.mjs scripts/__tests__/retain-runner-qualification-packages.test.mjs scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs scripts/service-onboard-smoke.test.mjs scripts/docker-onboard-smoke.test.mjs scripts/preview-artifacts.test.mjs scripts/cloud-migrator-artifacts.test.mjs",
"storybook-visual:baseline": "node scripts/storybook-visual-baseline.mjs",
"test:storybook-visual": "node scripts/storybook-visual-baseline.mjs download && node scripts/storybook-visual-baseline.mjs verify && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts",
"test:storybook-visual:update": "node scripts/storybook-visual-baseline.mjs download && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts --update-snapshots && node scripts/storybook-visual-baseline.mjs pack",
+13 -7
View File
@@ -467,12 +467,12 @@ async function recordMigrationHistoryEntry(
);
}
// These idempotent privacy migrations commit keyset batches inside their DO
// blocks. Execute each statement at top level so DDL locks and completed batches
// are released before the next batch. History is recorded only after completion.
const MIGRATIONS_WITH_BATCH_COMMITS = new Set([
// These idempotent migrations require top-level statements for batch commits
// or concurrent index builds. History is recorded only after completion.
const MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS = new Set([
"0313_private_task_access.sql",
"0314_private_task_draft_assets.sql",
"0333_furry_butterfly.sql",
]);
async function applyPendingMigrationsManually(
@@ -493,7 +493,13 @@ async function applyPendingMigrationsManually(
try {
// A session lock survives each batch COMMIT. Reserve the connection so
// pool rotation cannot release it while another migrator is waiting.
await sql`SELECT pg_advisory_lock(hashtextextended('paperclip:migrations', 0))`;
// A blocking lock SELECT retains its statement snapshot while waiting.
// A partial concurrent index build can wait for that snapshot, forming a
// deadlock with this lock. Each failed try ends its snapshot before sleep.
while (!(await sql<{ locked: boolean }[]>`
SELECT pg_try_advisory_lock(hashtextextended('paperclip:migrations', 0)) AS locked`)[0]?.locked) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
const { migrationTableSchema, columnNames } = await ensureMigrationJournalTable(sql);
const qualifiedTable = `${quoteIdentifier(migrationTableSchema)}.${quoteIdentifier(DRIZZLE_MIGRATIONS_TABLE)}`;
@@ -511,7 +517,7 @@ async function applyPendingMigrationsManually(
const applyMigration = async () => {
for (const statement of splitMigrationStatements(migrationContent)) {
if (MIGRATIONS_WITH_BATCH_COMMITS.has(migrationFile)) {
if (MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS.has(migrationFile)) {
// A cancelled concurrent build leaves an invalid index. IF NOT
// EXISTS alone would skip it on retry and journal an incomplete index.
const index = statement.replace(/^\s*--.*$/gm, "").trim()
@@ -545,7 +551,7 @@ async function applyPendingMigrationsManually(
folderMillisByFileName.get(migrationFile) ?? Date.now(),
);
};
if (MIGRATIONS_WITH_BATCH_COMMITS.has(migrationFile)) {
if (MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS.has(migrationFile)) {
await applyMigration();
} else {
await runInTransaction(sql, applyMigration);
@@ -229,7 +229,8 @@ describeEmbeddedPostgres("heartbeat run issue privacy migration", () => {
contender = applyPendingMigrations(database.connectionString).then(() => null, error => error);
await expect.poll(async () => {
const [row] = await sql`SELECT count(*)::int AS n FROM pg_stat_activity
WHERE datname = current_database() AND wait_event = 'advisory'
WHERE datname = current_database() AND query LIKE '%SELECT pg_try_advisory_lock%'
AND query NOT LIKE '%FROM pg_stat_activity%'
AND strpos(query, 'paperclip:migrations') > 0`;
return row.n;
}, { timeout: 10_000 }).toBe(1);
@@ -0,0 +1 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS "agent_wakeup_requests_question_response_history_idx" ON "agent_wakeup_requests" USING btree ("company_id","idempotency_key") WHERE "agent_wakeup_requests"."idempotency_key" LIKE 'question-response:%';
+7
View File
@@ -2318,6 +2318,13 @@
"when": 1791660557369,
"tag": "0332_confused_doctor_strange",
"breakpoints": true
},
{
"idx": 333,
"version": "7",
"when": 1791694801825,
"tag": "0333_furry_butterfly",
"breakpoints": true
}
]
}
@@ -0,0 +1,53 @@
import { createHash, randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import postgres from "postgres";
import { describe, expect, it } from "vitest";
import { applyPendingMigrations } from "./client.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
const support = await getEmbeddedPostgresTestSupport();
const indexName = "agent_wakeup_requests_question_response_history_idx";
const migrationFile = "0333_furry_butterfly.sql";
(support.supported ? describe : describe.skip)("question-response history migration", () => {
it("repairs an interrupted concurrent index build and serializes replay without deleting wakes", async () => {
const database = await startEmbeddedPostgresTestDatabase("question-history-migration-");
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
try {
const companyId = randomUUID();
const agentId = randomUUID();
await sql`INSERT INTO companies (id, name, issue_prefix) VALUES (${companyId}, 'Migration fixture', 'QHM')`;
await sql`INSERT INTO agents (id, company_id, name, role, adapter_type)
VALUES (${agentId}, ${companyId}, 'Fixture', 'engineer', 'process')`;
await sql`INSERT INTO agent_wakeup_requests (company_id, agent_id, source, idempotency_key, status)
SELECT ${companyId}::uuid, ${agentId}::uuid, 'automation', 'question-response:' || n::text, 'failed'
FROM generate_series(1, 3) n`;
const content = await readFile(new URL(`./migrations/${migrationFile}`, import.meta.url), "utf8");
const hash = createHash("sha256").update(content).digest("hex");
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${hash}`;
await sql.unsafe(`DROP INDEX ${indexName}`);
// A failed concurrent UNIQUE build leaves the same invalid-index state
// as an interrupted nonunique build, without relying on timing a cancel.
await expect(sql.unsafe(`CREATE UNIQUE INDEX CONCURRENTLY ${indexName} ON agent_wakeup_requests (company_id)`))
.rejects.toThrow();
const [invalid] = await sql`SELECT i.indisvalid FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
WHERE c.relname = ${indexName}`;
expect(invalid?.indisvalid).toBe(false);
await Promise.all([applyPendingMigrations(database.connectionString), applyPendingMigrations(database.connectionString)]);
const [rebuilt] = await sql`SELECT i.indisvalid, i.indisunique, pg_get_indexdef(i.indexrelid) AS definition
FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid WHERE c.relname = ${indexName}`;
expect(rebuilt?.indisvalid).toBe(true);
expect(rebuilt?.indisunique).toBe(false);
expect(rebuilt?.definition).toContain("company_id, idempotency_key");
expect(rebuilt?.definition).toContain("question-response:%");
const [wakes] = await sql`SELECT count(*)::int AS n FROM agent_wakeup_requests WHERE company_id = ${companyId}`;
expect(wakes?.n).toBe(3);
const [history] = await sql`SELECT count(*)::int AS n FROM drizzle.__drizzle_migrations WHERE hash = ${hash}`;
expect(history?.n).toBe(1);
await applyPendingMigrations(database.connectionString);
} finally {
await sql.end();
await database.cleanup();
}
}, 60_000);
});
@@ -75,6 +75,12 @@ export const agentWakeupRequests = pgTable(
)
.on(table.companyId, table.idempotencyKey)
.where(sql`(${table.idempotencyKey} LIKE 'question-response:%' OR ${table.idempotencyKey} LIKE 'interaction:%') AND ${table.status} NOT IN ('skipped', 'failed', 'cancelled')`),
// Sleep checks also need failed/cancelled/skipped receipts, which the
// delivery idempotency fence deliberately excludes.
questionResponseHistoryIdx: index("agent_wakeup_requests_question_response_history_idx")
.on(table.companyId, table.idempotencyKey)
.concurrently()
.where(sql`${table.idempotencyKey} LIKE 'question-response:%'`),
connectionIntentDeliveryIdempotencyUq: uniqueIndex("agent_wakeup_requests_connection_intent_delivery_idempotency_uq")
.on(table.companyId, table.idempotencyKey)
.where(sql`${table.idempotencyKey} LIKE 'connection-intent:%' AND ${table.status} NOT IN ('skipped', 'failed', 'cancelled')`),
@@ -689,6 +689,13 @@ describe("AppDefinition catalog", () => {
consoleLinks: { register: "https://app.asana.com/0/my-apps" },
});
expect(method("linear")?.ownershipModes).toEqual(["dcr", "customer"]);
expect(method("sentry")).toMatchObject({
key: "mcp-oauth",
defaults: {
serverUrl: "https://mcp.sentry.dev/mcp",
discoveryUrl: "https://mcp.sentry.dev/.well-known/oauth-protected-resource/mcp",
},
});
expect(method("zapier")).toMatchObject({
key: "generated-url",
auth: "none",
@@ -26,7 +26,7 @@
"whenToUse": "Use the provider-hosted connection for the quickest setup.",
"defaults": {
"serverUrl": "https://mcp.sentry.dev/mcp",
"discoveryUrl": "https://sentry.io/.well-known/oauth-authorization-server",
"discoveryUrl": "https://mcp.sentry.dev/.well-known/oauth-protected-resource/mcp",
"scopesHint": [
"org:read",
"project:write",
+37 -4
View File
@@ -10,7 +10,8 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."
// Exercise the real startup helper and child-process boundary without registry
// traffic. The npm fixture installs a real executable into its isolated prefix.
export async function canaryStartup({ mode, onboard = "success", budget = 5000, delay = 5, cancelAtKind, playwright = false }) {
export async function canaryStartup({ mode, onboard = "success", budget = 5000, delay = 5, cancelAtKind, playwright = false,
config = "public", metadata = "valid", version = "2026.1009.0-canary.1" }) {
const root = mkdtempSync(path.join(os.tmpdir(), "canary-startup-test-"));
const calls = path.join(root, "calls.jsonl");
const fixtureBin = path.join(root, "bin");
@@ -39,15 +40,35 @@ if (process.env.FIXTURE_ONBOARD === "serve") {
const fs = require("node:fs");
const path = require("node:path");
const args = process.argv.slice(2);
if (args[0] === "config") {
fs.appendFileSync(process.env.FIXTURE_CALLS, JSON.stringify({kind:"config", pid:process.pid, args}) + "\\n");
if (process.env.FIXTURE_CONFIG === "hang") {
const { spawn } = require("node:child_process");
const child = spawn(process.execPath, ["-e", "process.on('SIGTERM',()=>{}); setTimeout(()=>process.exit(0),10000)"], {stdio:"ignore"});
fs.appendFileSync(process.env.FIXTURE_CALLS, JSON.stringify({kind:"config-descendant", pid:child.pid}) + "\\n");
process.on("SIGTERM", () => {});
setTimeout(() => process.exit(0), 10000);
} else {
if (process.env.FIXTURE_CONFIG === "large") console.log("x".repeat(8192));
console.log("registry=" + (process.env.FIXTURE_CONFIG === "private" ? "https://fixture-user:fixture-password@registry.example.invalid/" : "https://registry.npmjs.org/") + "\\n@paperclipai:registry=undefined");
console.error("fixture config diagnostic must stay private");
}
} else {
const entries = fs.existsSync(process.env.FIXTURE_CALLS) ? fs.readFileSync(process.env.FIXTURE_CALLS, "utf8").trim().split("\\n").filter(Boolean).map(JSON.parse) : [];
const attempt = entries.filter(e => e.kind === "npm").length + 1;
fs.appendFileSync(process.env.FIXTURE_CALLS, JSON.stringify({kind:"npm", pid:process.pid, args, cache:process.env.npm_config_cache}) + "\\n");
const mode = process.env.FIXTURE_MODE;
if (mode === "hang") { setTimeout(() => {}, 10000); }
else if (mode === "permanent" || (mode === "recover" && attempt === 1)) { console.error("npm error code ETARGET"); process.exit(1); }
else if (mode === "multibyte-recover" && attempt === 1) { console.error("npm error code ETARGET\\n" + "界".repeat(30 * 1024)); process.exit(1); }
else if (mode === "auth") { console.error("npm error code E401"); process.exit(1); }
else if (mode === "mixed") { console.error("npm error code ETARGET\\nnpm error code E401"); process.exit(1); }
else if (mode === "network") { console.error("npm error code ECONNRESET"); process.exit(1); }
else if (mode === "tarball-permanent" || mode === "tarball-large" || (mode === "tarball-recover" && attempt === 1)) {
const url = "https://registry.npmjs.org/@paperclipai/server/-/server-" + process.env.PAPERCLIPAI_VERSION + ".tgz";
if (mode === "tarball-large") console.error("npm error code E401\\n" + "x".repeat(70 * 1024));
console.error("npm error code E404\\nnpm error 404 Not Found - GET " + url + " - Not found"); process.exit(1);
}
else {
const prefix = args[args.indexOf("--prefix") + 1];
const bin = path.join(prefix, "node_modules", ".bin");
@@ -55,12 +76,24 @@ else {
fs.writeFileSync(path.join(bin, "paperclipai"), ${JSON.stringify(cli).replaceAll('$','\$')});
fs.chmodSync(path.join(bin, "paperclipai"), 0o755);
}
}
`);
chmodSync(npmFixture, 0o755);
const helper = new URL("../../tests/canary-onboarding/start-published-canary.mjs", import.meta.url).href;
const driver = path.join(root, "driver.mjs");
writeFileSync(driver, `import { startPublishedCanary } from ${JSON.stringify(helper)};
try { await startPublishedCanary({version:"2026.1009.0-canary.1", workspace:${JSON.stringify(root)}, dataDir:${JSON.stringify(path.join(root,"data"))}, installBudgetMs:${budget}, retryDelayMs:${delay}}); }
import { appendFileSync } from "node:fs";
globalThis.fetch = async (url, options) => {
appendFileSync(process.env.FIXTURE_CALLS, JSON.stringify({kind:"metadata", url, redirect:options.redirect, credentials:options.credentials, headers:options.headers}) + "\\n");
const manifest = {name:"@paperclipai/server",version:process.env.PAPERCLIPAI_VERSION,dist:{tarball:"https://registry.npmjs.org/@paperclipai/server/-/server-" + process.env.PAPERCLIPAI_VERSION + ".tgz",integrity:"sha512-" + Buffer.alloc(64).toString("base64")}};
const body = process.env.FIXTURE_METADATA === "hang" ? new ReadableStream({
start(controller) { options.signal.addEventListener("abort", () => controller.error(options.signal.reason), {once:true}); },
}) : JSON.stringify(manifest);
const response = new Response(body, {status:process.env.FIXTURE_METADATA === "missing" ? 404 : 200});
Object.defineProperty(response,"url",{value:url});
return response;
};
try { await startPublishedCanary({version:${JSON.stringify(version)}, workspace:${JSON.stringify(root)}, dataDir:${JSON.stringify(path.join(root,"data"))}, installBudgetMs:${budget}, retryDelayMs:${delay}}); }
catch (error) { console.error(error.message); process.exitCode = 1; }
`);
const playwrightConfig = path.join(root, "playwright.config.mjs");
@@ -80,8 +113,8 @@ export default {...original, testDir:${JSON.stringify(root)}, testMatch:"fixture
? [fileURLToPath(helper), "2026.1009.0-canary.1", root, path.join(root, "data")]
: [driver], { cwd: repoRoot, env: {
...process.env, PATH: fixtureBin + path.delimiter + process.env.PATH,
FIXTURE_MODE: mode, FIXTURE_ONBOARD: onboard, FIXTURE_CALLS: calls, FIXTURE_PORT: String(port),
PAPERCLIPAI_VERSION: "2026.1009.0-canary.1", PAPERCLIP_CANARY_SMOKE_BASE_URL: `http://127.0.0.1:${port}`, PAPERCLIP_CANARY_SMOKE_SERVER_LOG: path.join(root,"server.log"),
FIXTURE_MODE: mode, FIXTURE_ONBOARD: onboard, FIXTURE_CALLS: calls, FIXTURE_PORT: String(port), FIXTURE_CONFIG: config, FIXTURE_METADATA: metadata,
PAPERCLIPAI_VERSION: version, PAPERCLIP_CANARY_SMOKE_BASE_URL: `http://127.0.0.1:${port}`, PAPERCLIP_CANARY_SMOKE_SERVER_LOG: path.join(root,"server.log"),
npm_config_cache: path.join(root, "fresh-cache"),
}, stdio: ["ignore", "pipe", "pipe"] });
let output = "";
@@ -81,6 +81,35 @@ test('verification never elevates PR-controlled provisioning or cleanup on the h
assert.ok(source.includes("const prerequisite = join(root, 'native/grok')"));
});
test('the image and Grok downloads start before staging and join without weakening the npm checks', () => {
const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8');
const position = fragment => { const index = source.indexOf(fragment); assert.notEqual(index, -1, `verifier must contain ${fragment}`); return index; };
// Exactly the pinned consumer image is pulled, for the platform the probes run on.
const imagePull = position("prefetch('image', 'docker', ['pull', '--platform', 'linux/amd64', GROK_PUBLIC_INSTALL_IMAGE])");
// Grok is provisioned into the prefetch directory, never straight into `native/grok`.
const grokProvision = position("prefetch('grok', process.execPath, [join(repo, 'packages/paperclip-runner/scripts/provision-grok.mjs'), stagedGrok])");
assert.ok(source.includes("const stagedGrok = join(prefetched, 'grok')"));
assert.doesNotMatch(source, /provision-grok\.mjs'\), prerequisite\]/);
// Both downloads start before any package is staged and before the first sandbox runs.
const staging = position("visit('@paperclipai/server')");
const firstSandbox = position("isolated(['npm', 'install', '--ignore-scripts', '--omit=dev'");
assert.ok(imagePull < staging && grokProvision < staging);
assert.ok(position("await awaited('image', imageReady)") < firstSandbox);
// The binary moves to the canonical host path only after npm is proven not to
// have provisioned it, after the negative probe, and before the positive probe.
const npmCheck = position("'npm must not provision Grok'");
const negativeProbe = position("isolated(['node', '/packages/probe.mjs', 'missing'])");
const grokJoin = position("await awaited('grok', grokReady)");
const move = position('renameSync(stagedGrok, prerequisite)');
const positiveProbe = position("isolated(['node', '/packages/probe.mjs', 'present'], { prerequisite })");
assert.ok(npmCheck < negativeProbe && negativeProbe < grokJoin && grokJoin < move && move < positiveProbe);
// The children run through the helper covered by prefetch-child.test.mjs, and
// cleanup waits for them before removing the directory they write into.
assert.ok(source.includes("import { startPrefetch, stopPrefetches } from './prefetch-child.mjs'"));
assert.ok(position('await stopPrefetches(prefetches)') < position('rmSync(root, { recursive: true, force: true })'));
assert.equal(source.match(/rmSync\(root/g).length, 1);
});
test('the installed Codex probe exercises the public export and rejects incomplete or mismatched packages', () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'installed-codex-probe-')));
try {
+93
View File
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { startPrefetch, stopPrefetches } from '../prefetch-child.mjs';
// Small local Node children stand in for the image pull and the Grok download;
// none of these tests needs Docker or a network.
const withScratch = async body => {
const root = mkdtempSync(join(tmpdir(), 'prefetch-child-'));
try { await body(root); } finally { rmSync(root, { recursive: true, force: true }); }
};
const start = (root, name, script, cmd = process.execPath, args = ['-e', script]) =>
startPrefetch({ name, cmd, args, cwd: root, env: process.env, logPath: join(root, `${name}.log`) });
const blockFor = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
const untilLogHas = async (path, text) => {
for (let attempt = 0; attempt < 400; attempt++) {
try { if (readFileSync(path, 'utf8').includes(text)) return; } catch {}
await new Promise(resolve => setTimeout(resolve, 25));
}
throw new Error(`log never contained ${text}`);
};
test('a successful prefetch resolves, keeps its output, and is left alone by stop', async () => withScratch(async root => {
const handle = start(root, 'ok', "setTimeout(() => process.stdout.write('fetched\\n'), 150)");
await handle.done;
assert.equal(handle.child.exitCode, 0);
assert.equal(readFileSync(join(root, 'ok.log'), 'utf8'), 'fetched\n');
assert.ok(handle.durationMs >= 100 && handle.durationMs < 5000, `durationMs ${handle.durationMs}`);
await stopPrefetches([handle]);
assert.equal(handle.child.signalCode, null);
}));
test('the duration ends at the download, not when a blocked parent notices the exit', async () => withScratch(async root => {
const handle = start(root, 'early', "setTimeout(() => process.stdout.write('Status: Downloaded\\n'), 100)");
// Synchronous work in the parent, as package staging is in the verifier.
blockFor(1500);
const noticed = Date.now();
await handle.done;
assert.ok(Date.now() - noticed < 500, 'the exit was pending by the time the parent yielded');
assert.ok(handle.durationMs < 900, `durationMs ${handle.durationMs} must not include the 1500ms the parent was blocked`);
}));
test('a nonzero exit rejects with the log tail where the result is awaited', async () => withScratch(async root => {
const handle = start(root, 'bad', "process.stderr.write('registry unreachable\\n'); process.exit(3)");
await assert.rejects(handle.done, /^Error: bad prefetch failed \(exit 3\): registry unreachable$/);
await stopPrefetches([handle]);
}));
test('a command that cannot start rejects instead of hanging the join or the stop', async () => withScratch(async root => {
const handle = start(root, 'missing', '', join(root, 'no-such-command'), []);
await assert.rejects(handle.done, /missing prefetch failed \(.*ENOENT/);
await stopPrefetches([handle]);
}));
test('stopping a running prefetch waits for it to exit so its directory can be removed safely', async () => withScratch(async root => {
const handle = start(root, 'slow', "process.stdout.write('started\\n'); setInterval(() => {}, 1000)");
await untilLogHas(join(root, 'slow.log'), 'started');
const stopping = Date.now();
await stopPrefetches([handle]);
assert.ok(Date.now() - stopping < 4000);
assert.equal(handle.child.signalCode, 'SIGTERM');
assert.equal(handle.child.exitCode, null);
await assert.rejects(handle.done, /slow prefetch failed \(SIGTERM\): started/);
}));
test('a prefetch that ignores SIGTERM is killed after the grace period', async () => withScratch(async root => {
const handle = start(root, 'stubborn', "process.on('SIGTERM', () => {}); process.stdout.write('ignoring\\n'); setInterval(() => {}, 1000)");
await untilLogHas(join(root, 'stubborn.log'), 'ignoring');
const stopping = Date.now();
await stopPrefetches([handle], { graceMs: 200 });
assert.ok(Date.now() - stopping >= 200 && Date.now() - stopping < 4000);
assert.equal(handle.child.signalCode, 'SIGKILL');
}));
test('an unawaited failure never becomes an unhandled rejection', () => {
const script = `
import { startPrefetch } from ${JSON.stringify(new URL('../prefetch-child.mjs', import.meta.url).href)};
import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path';
const root = mkdtempSync(join(tmpdir(), 'prefetch-unawaited-'));
try {
startPrefetch({ name: 'ignored', cmd: process.execPath, args: ['-e', 'process.exit(2)'], cwd: root, env: process.env, logPath: join(root, 'ignored.log') });
await new Promise(resolve => setTimeout(resolve, 500));
console.log('still running');
} finally { rmSync(root, { recursive: true, force: true }); }
`;
const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], { encoding: 'utf8', timeout: 10_000 });
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /still running/);
assert.doesNotMatch(result.stderr, /unhandled/i);
});
@@ -5,7 +5,9 @@ import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { createServer } from "node:http";
import { canaryStartup } from "./canary-startup-fixture.mjs";
import { canRetryPublishedTarball404, TARBALL_METADATA_TIMEOUT_MS } from "../../tests/canary-onboarding/start-published-canary.mjs";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
@@ -102,6 +104,131 @@ test("canary reuses exact-source proof while stable keeps full verification", ()
);
});
function canaryAttachmentScript() {
const publish = readWorkflow("release.yml").split(" publish_canary:\n")[1].split(" smoke_canary_onboarding:\n")[0];
const step = publish.split(" - name: Attach verified canary source to local master\n")[1]?.split("\n - ")[0];
assert.ok(step, "the trusted publisher must attach its pinned checkout before releasing");
assert.match(step, /SOURCE_SHA: \$\{\{ github\.sha \}\}/);
const script = step.match(/run: \|\n((?: {10}[^\n]*\n)+)/)?.[1];
assert.ok(script, "missing inline canary branch attachment shell");
return script.replace(/^ {10}/gm, "");
}
function withCanaryGitFixture(check) {
const root = mkdtempSync(path.join(os.tmpdir(), "canary-publisher-git-"));
const cwd = path.join(root, "repository with spaces");
mkdirSync(cwd);
const git = (...args) => {
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
return result.stdout.trim();
};
try {
git("init", "--initial-branch=master");
git("config", "user.name", "Release fixture");
git("config", "user.email", "release-fixture@example.invalid");
git("config", "commit.gpgsign", "false");
git("config", "core.hooksPath", "/dev/null");
writeFileSync(path.join(cwd, "source.txt"), "pinned source\n");
git("add", "source.txt");
git("commit", "-m", "Create pinned source");
const pinned = git("rev-parse", "HEAD");
writeFileSync(path.join(cwd, "source.txt"), "newer master source\n");
git("commit", "-am", "Advance master");
const newer = git("rev-parse", "HEAD");
git("update-ref", "refs/remotes/origin/master", newer);
git("checkout", "--detach", pinned);
const env = {
...process.env, REPO_ROOT: cwd, SOURCE_SHA: pinned,
GITHUB_ACTIONS: "true", GITHUB_REPOSITORY: "paperclipai/paperclip",
GITHUB_EVENT_NAME: "push", GITHUB_REF: "refs/heads/master",
};
const attach = (overrides = {}) => spawnSync("bash", ["--noprofile", "--norc", "-c", canaryAttachmentScript()], {
cwd, env: { ...env, ...overrides }, encoding: "utf8", timeout: 5_000,
});
// Run the original release guard without running release.sh, npm, or a publisher.
const guard = () => spawnSync("bash", ["-c", 'source "$1"; require_on_master_branch', "guard", path.join(repoRoot, "scripts/release-lib.sh")], {
cwd, env, encoding: "utf8", timeout: 5_000,
});
check({ cwd, git, pinned, newer, attach, guard });
} finally {
rmSync(root, { recursive: true, force: true });
}
}
test("canary publisher attaches the exact source while local and remote master are newer", () => {
withCanaryGitFixture(({ cwd, git, pinned, newer, attach, guard }) => {
assert.equal(guard().status, 1, "the unchanged release guard must reject detached HEAD even in Actions");
assert.equal(git("rev-parse", "master"), newer);
const result = attach();
assert.equal(result.status, 0, result.stderr);
assert.equal(git("symbolic-ref", "--short", "HEAD"), "master");
assert.equal(git("rev-parse", "HEAD"), pinned);
assert.equal(git("rev-parse", "master"), pinned);
assert.equal(git("rev-parse", "origin/master"), newer, "do not fetch or follow floating remote master");
assert.equal(readFileSync(path.join(cwd, "source.txt"), "utf8"), "pinned source\n");
assert.equal(git("status", "--porcelain"), "");
assert.equal(guard().status, 0, "the real release guard must accept the pinned local master");
});
});
for (const [name, overrides] of [
["fork repository", { GITHUB_REPOSITORY: "example/paperclip" }],
["manual dispatch", { GITHUB_EVENT_NAME: "workflow_dispatch" }],
["scheduled release", { GITHUB_EVENT_NAME: "schedule" }],
["non-master push", { GITHUB_REF: "refs/heads/topic" }],
["tag push", { GITHUB_REF: "refs/tags/canary/v1" }],
["missing source SHA", { SOURCE_SHA: "" }],
["abbreviated source SHA", { SOURCE_SHA: "a".repeat(12) }],
["malformed source SHA", { SOURCE_SHA: "z".repeat(40) }],
]) {
test(`canary publisher rejects ${name} before changing any Git reference`, () => {
withCanaryGitFixture(({ git, pinned, attach, guard }) => {
const refs = git("show-ref");
const result = attach(overrides);
assert.equal(result.status, 1, result.stderr);
assert.equal(git("show-ref"), refs);
assert.equal(git("rev-parse", "HEAD"), pinned);
assert.equal(guard().status, 1, "the rejected checkout must remain detached");
});
});
}
test("canary publisher rejects a different valid source SHA before changing Git references", () => {
withCanaryGitFixture(({ git, pinned, newer, attach, guard }) => {
const refs = git("show-ref");
const result = attach({ SOURCE_SHA: newer });
assert.equal(result.status, 1, result.stderr);
assert.equal(git("show-ref"), refs);
assert.equal(git("rev-parse", "HEAD"), pinned);
assert.equal(guard().status, 1);
});
});
test("the original canary release guard still rejects a non-master local branch in Actions", () => {
withCanaryGitFixture(({ git, pinned, guard }) => {
git("checkout", "-b", "topic", pinned);
const refs = git("show-ref");
assert.equal(guard().status, 1);
assert.equal(git("symbolic-ref", "--short", "HEAD"), "topic");
assert.equal(git("show-ref"), refs);
});
});
test("canary branch attachment keeps exact-source checkout and artifact order in its publishing lane", () => {
const workflow = readWorkflow("release.yml");
const publish = workflow.split(" publish_canary:\n")[1].split(" smoke_canary_onboarding:\n")[0];
assert.equal(workflow.split("name: Attach verified canary source to local master").length, 2);
assert.match(publish, /ref: \$\{\{ github\.sha \}\}/);
assert.match(publish, /name: canary-smoke-lockfile-\$\{\{ github\.sha \}\}/);
const steps = ["Checkout repository", "Attach verified canary source to local master", "Install dependencies", "Save canary smoke lockfile", "Restore tracked install-time changes", "Publish canary"];
for (let i = 1; i < steps.length; i++) {
assert.ok(publish.indexOf(`name: ${steps[i - 1]}`) < publish.indexOf(`name: ${steps[i]}`));
}
assert.match(publish, /run: \.\/scripts\/release\.sh canary --skip-verify/);
assert.doesNotMatch(canaryAttachmentScript(), /git fetch|git pull|checkout (?:master|origin\/master)(?:\s|$)/);
});
test("source proof requires every source check and does not wait on image publication", () => {
const readiness = readWorkflow("cloud-readiness.yml");
const proof = readiness.split(" source_verified:\n")[1];
@@ -378,7 +505,7 @@ test("release verify workflow covers the same split test surface as stable PR ve
assert.match(buildJob, /persist-credentials: false/);
assert.doesNotMatch(buildJob, /cache: pnpm/);
for (const group of ["general-server-without-chat-or-native-runner", "general-chat", "general-workspaces-a", "general-workspaces-b"]) {
for (const group of ["general-server-without-chat-or-native-runner", "general-chat", "general-workspaces-a", "general-workspaces-b", "general-workspaces-c"]) {
assert.match(verifyWorkflow, new RegExp(`group: ${group}`));
}
for (const [group, count] of [["general-server-without-chat-or-native-runner", 10], ["general-chat", 3]]) {
@@ -705,3 +832,327 @@ test("nested ETARGET output does not retry a final authentication or network fai
assert.equal(result.calls.length, 1);
}
});
const exactCanary = "2026.1009.0-canary.1";
const canaryTarball = `https://registry.npmjs.org/@paperclipai/server/-/server-${exactCanary}.tgz`;
const tarballFailure = (url = canaryTarball, prefix = "npm error") => `${prefix} code E404\n${prefix} 404 Not Found - GET ${url} - Not found\n${prefix} 404\n${prefix} 404 The requested resource '@paperclipai/server@${url}' could not be found or you do not have permission to access it.\n`;
function tarballProof({ config = {}, manifest = {}, status = 200, redirected = false, responseUrl, rawBody } = {}) {
const calls = [];
const dist = { tarball: canaryTarball, integrity: `sha512-${Buffer.alloc(64).toString("base64")}`, ...manifest.dist };
const data = { name: "@paperclipai/server", version: exactCanary, ...manifest, dist };
return { calls, options: {
signal: new AbortController().signal, prefix: "/fixture/install", env: {},
runConfig: async (command, args, options) => {
calls.push({ kind: "config", command, args, options });
return { code: 0, stdout: "registry=https://registry.npmjs.org/\n@paperclipai:registry=undefined\n", ...config };
},
fetchMetadata: async (url, options) => {
calls.push({ kind: "metadata", url, options });
return { status, redirected, url: responseUrl ?? url, body: new Response(rawBody ?? JSON.stringify(data)).body };
},
} };
}
test("exact published tarball proof uses quiet effective config and fresh public metadata without credentials", async () => {
for (const prefix of ["npm error", "npm ERR!"]) {
const fixture = tarballProof();
assert.equal(await canRetryPublishedTarball404(tarballFailure(canaryTarball, prefix), exactCanary, fixture.options), true);
const [config, metadata] = fixture.calls;
assert.deepEqual(config.args, ["config", "get", "registry", "@paperclipai:registry", "--prefix", "/fixture/install"]);
assert.equal(config.options.env, fixture.options.env);
assert.equal(config.options.quiet, true);
assert.equal(config.options.captureStdout, true);
assert.equal(config.options.captureStderr, true);
assert.equal(config.options.outputLimit, 4096);
assert.equal(metadata.url, `https://registry.npmjs.org/%40paperclipai%2Fserver/${exactCanary}`);
assert.equal(metadata.options.signal, config.options.signal);
assert.equal(metadata.options.redirect, "error");
assert.equal(metadata.options.credentials, "omit");
assert.deepEqual(metadata.options.headers, { accept: "application/json", "cache-control": "no-cache" });
}
});
test("mixed npm errors and noncanonical or unreleased tarballs never read config or metadata", async () => {
const errors = [
"npm error code ETARGET\n", "npm error code E401\n", "npm error code ECONNRESET\n",
`${tarballFailure()}npm error code E401\n`, `${tarballFailure()}npm error code 1\n`,
`npm error code ENEEDAUTH\n${tarballFailure()}`, `${tarballFailure()}${tarballFailure()}`,
`${tarballFailure()}npm error 404 Not Found - GET https://registry.example.invalid/a.tgz - Not found\n`,
`${tarballFailure()}npm error 404 The requested resource 'other@${canaryTarball}' could not be found\n`,
tarballFailure().replace(" - GET ", " - PUT "),
...[
canaryTarball + "?token=fixture", canaryTarball + "#fragment", canaryTarball.replace("npmjs.org/", "npmjs.org:443/"),
canaryTarball.replace("npmjs.org/", "npmjs.org.example.invalid/"), canaryTarball.replace("npmjs.org/", "fixture@npmjs.org/"),
canaryTarball.replace("https:", "http:"), canaryTarball.replace("@paperclipai/server", "%40paperclipai%2Fserver"),
canaryTarball.replace("@paperclipai/server", "@paperclipai/../server"), canaryTarball.replace(exactCanary, "2026.1009.0-canary.2"),
"https://registry.npmjs.org/@paperclipai/not-released/-/not-released-2026.1009.0-canary.1.tgz",
"https://registry.npmjs.org/@paperclipai/plugin-createos/-/plugin-createos-2026.1009.0-canary.1.tgz",
"https://registry.npmjs.org/%40paperclipai%2Fserver/2026.1009.0-canary.1",
].map((url) => tarballFailure(url)),
];
for (const error of errors) {
const fixture = tarballProof();
assert.equal(await canRetryPublishedTarball404(error, exactCanary, fixture.options), false, error);
assert.deepEqual(fixture.calls, [], error);
}
for (const version of ["2026.1009.0", "2026.1009.0-nightly.1", "2026.1009.0-beta.1", "2026.1009.0-canary.1+build"]) {
const fixture = tarballProof();
assert.equal(await canRetryPublishedTarball404(tarballFailure(canaryTarball.replace(exactCanary, version)), version, fixture.options), false);
assert.deepEqual(fixture.calls, []);
}
});
test("effective registry authority respects scope precedence and rejects unknown or private config", async () => {
const configs = [
[{ stdout: "registry=https://registry.example.invalid/\n@paperclipai:registry=https://registry.npmjs.org/\n" }, true],
[{ stdout: "registry=undefined\n@paperclipai:registry=https://registry.npmjs.org/\n" }, true],
[{ stdout: "registry=https://registry.npmjs.org/\n@paperclipai:registry=https://registry.example.invalid/\n" }, false],
[{ stdout: "registry=https://registry.example.invalid/\n@paperclipai:registry=undefined\n" }, false],
[{ stdout: "registry=undefined\n@paperclipai:registry=undefined\n" }, false],
[{ stdout: "registry=https://registry.npmjs.org/\n@paperclipai:registry=\n" }, false],
[{ stdout: "registry=https://fixture:secret@registry.npmjs.org/\n@paperclipai:registry=undefined\n" }, false],
[{ stdout: "unexpected output\n" }, false], [{ code: 1 }, false], [{ signal: "SIGTERM" }, false], [{ outputTruncated: true }, false],
];
for (const [config, expected] of configs) {
const fixture = tarballProof({ config });
assert.equal(await canRetryPublishedTarball404(tarballFailure(), exactCanary, fixture.options), expected);
assert.equal(fixture.calls.filter((call) => call.kind === "metadata").length, expected ? 1 : 0);
}
});
test("metadata must identify the exact published artifact with valid SHA512 syntax, not merely exist", async () => {
const fixtures = [
{ status: 404 }, { status: 401 }, { status: 403 }, { status: 302 }, { redirected: true },
{ responseUrl: "https://registry.example.invalid/metadata" }, { rawBody: "not json" },
{ manifest: { name: "@paperclipai/shared" } }, { manifest: { version: "2026.1009.0-canary.2" } },
{ manifest: { dist: { tarball: canaryTarball + "?query=1" } } },
...[undefined, "", "sha512-A===", `sha256-${Buffer.alloc(32).toString("base64")}`, `sha512-${Buffer.alloc(64).toString("base64")} extra`]
.map((integrity) => ({ manifest: { dist: { integrity } } })),
{ rawBody: JSON.stringify({ padding: "x".repeat(65 * 1024) }) },
];
for (const input of fixtures) {
const fixture = tarballProof(input);
assert.equal(await canRetryPublishedTarball404(tarballFailure(), exactCanary, fixture.options), false, JSON.stringify(input).slice(0, 200));
}
});
test("rejected and oversized native response bodies cancel without waiting for slow or rejected cleanup", async () => {
for (const [status, pending] of [[200, true], [200, false], [404, true], [404, false]]) {
let cancelled = false;
const fixture = tarballProof();
fixture.options.fetchMetadata = async (url) => ({ status, redirected: false, url, body: new Response(new ReadableStream({
start(controller) { controller.enqueue(new Uint8Array(65 * 1024)); },
cancel() { cancelled = true; return pending ? new Promise(() => {}) : Promise.reject(new Error("fixture rejected cancellation")); },
})).body });
const started = Date.now();
assert.equal(await canRetryPublishedTarball404(tarballFailure(), exactCanary, fixture.options), false);
assert.equal(cancelled, true);
assert.ok(Date.now() - started < 1000, "cancellation acknowledgement must not extend a rejection");
}
});
test("aborting a stalled metadata read cancels its reader without waiting for acknowledgement", { timeout: 2000 }, async () => {
let cancelled = false;
const fixture = tarballProof();
fixture.options.fetchMetadata = async (url) => ({ status: 200, redirected: false, url,
body: new Response(new ReadableStream({
cancel() { cancelled = true; return new Promise(() => {}); },
})).body });
const budget = new AbortController();
const timer = setTimeout(() => budget.abort(new Error("fixture body budget expired")), 100);
const started = Date.now();
try {
await assert.rejects(canRetryPublishedTarball404(tarballFailure(), exactCanary, {
...fixture.options, signal: budget.signal,
}), /fixture body budget expired/);
assert.equal(cancelled, true);
assert.ok(Date.now() - started < 1000);
} finally { clearTimeout(timer); }
});
test("metadata headers cannot outlive the budget and late response bodies are cancelled", { timeout: 2000 }, async () => {
let headersArrive;
let cancelled = false;
const fixture = tarballProof();
fixture.options.fetchMetadata = (url) => new Promise((resolve) => {
headersArrive = () => resolve({ status: 200, redirected: false, url,
body: new Response(new ReadableStream({ cancel() { cancelled = true; } })).body });
});
const budget = new AbortController();
const timer = setTimeout(() => budget.abort(new Error("fixture headers budget expired")), 100);
const started = Date.now();
try {
await assert.rejects(canRetryPublishedTarball404(tarballFailure(), exactCanary, {
...fixture.options, signal: budget.signal,
}), /fixture headers budget expired/);
assert.ok(Date.now() - started < 1000);
headersArrive();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(cancelled, true);
} finally { clearTimeout(timer); }
});
test("real npm effective registry reads isolated prefix, user, and environment configuration", async () => {
const root = mkdtempSync(path.join(os.tmpdir(), "canary-config-proof-"));
mkdirSync(path.join(root, "prefix"));
writeFileSync(path.join(root, "global.npmrc"), "");
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.toLowerCase().startsWith("npm_config_")));
env.npm_config_userconfig = path.join(root, "user.npmrc");
env.npm_config_globalconfig = path.join(root, "global.npmrc");
try {
for (const [user, project, override, expected] of [
["registry=https://registry.npmjs.org/\n", "", undefined, true],
["registry=https://registry.example.invalid/\n", "@paperclipai:registry=https://registry.npmjs.org/\n", undefined, true],
["registry=https://registry.npmjs.org/\n", "@paperclipai:registry=https://registry.example.invalid/\n", undefined, false],
["registry=https://registry.npmjs.org/\n", "", "https://registry.example.invalid/", false],
]) {
writeFileSync(path.join(root, "user.npmrc"), user);
writeFileSync(path.join(root, "prefix", ".npmrc"), project);
const fixture = tarballProof();
delete fixture.options.runConfig;
assert.equal(await canRetryPublishedTarball404(tarballFailure(), exactCanary, {
...fixture.options, prefix: path.join(root, "prefix"), env: { ...env, ...(override ? { npm_config_registry: override } : {}) },
}), expected);
assert.equal(fixture.calls.length, expected ? 1 : 0);
}
} finally { rmSync(root, { recursive: true, force: true }); }
});
async function stalledMetadata(run) {
let closed = false;
let closing = false;
let observeClose;
const connectionClosed = new Promise((resolve) => { observeClose = resolve; });
const server = createServer((_request, response) => {
response.on("close", () => { closed = true; observeClose(); });
response.writeHead(200, { "content-type": "application/json" });
response.write('{"name":');
});
// An HTTP client can replace an aborted pooled connection while shutdown
// starts. Own late connections too, so server.close cannot wait on them.
server.on("connection", (socket) => { if (closing) socket.destroy(); });
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const fixture = tarballProof();
fixture.options.fetchMetadata = async (url, options) => {
const response = await fetch(`http://127.0.0.1:${server.address().port}/metadata`, options);
Object.defineProperty(response, "url", { value: url });
return response;
};
let closeDeadline;
try {
await run(fixture.options);
await Promise.race([connectionClosed, new Promise((_, reject) => {
closeDeadline = setTimeout(() => reject(new Error("metadata connection outlived cancellation")), 1000);
})]);
assert.equal(closed, true, "metadata connection must close before fixture-forced cleanup");
}
finally {
clearTimeout(closeDeadline);
closing = true;
await new Promise((resolve) => {
server.close(resolve);
server.closeAllConnections();
});
}
}
test("shared acquisition cancellation aborts the metadata body before the lookup deadline", async () => {
await stalledMetadata(async (options) => {
const budget = new AbortController();
const timer = setTimeout(() => budget.abort(new Error("fixture shared budget expired")), 250);
try {
await assert.rejects(canRetryPublishedTarball404(tarballFailure(), exactCanary, { ...options, signal: budget.signal }), /fixture shared budget expired/);
} finally { clearTimeout(timer); }
});
});
test("the short config and metadata deadline also covers a stalled real HTTP body", { timeout: 10_000 }, async () => {
assert.equal(TARBALL_METADATA_TIMEOUT_MS, 5000);
const started = Date.now();
await stalledMetadata(async (options) => {
assert.equal(await canRetryPublishedTarball404(tarballFailure(), exactCanary, options), false);
});
assert.ok(Date.now() - started < 7000, "metadata must not consume the 120s acquisition budget");
});
test("a verified canary tarball404 retries acquisition and runs onboarding once", async () => {
const result = await canaryStartup({ mode: "tarball-recover" });
assert.equal(result.code, 0, result.output);
assert.equal(result.calls.filter((call) => call.kind === "npm").length, 2);
assert.equal(result.calls.filter((call) => call.kind === "config").length, 1);
assert.equal(result.calls.filter((call) => call.kind === "metadata").length, 1);
assert.equal(result.calls.filter((call) => call.kind === "onboard").length, 1);
assert.doesNotMatch(result.output, /fixture config diagnostic/);
});
test("persistent promised tarball404s stop at three installs without a final lookup or onboarding", async () => {
const result = await canaryStartup({ mode: "tarball-permanent" });
assert.equal(result.code, 1);
assert.equal(result.calls.filter((call) => call.kind === "npm").length, 3);
assert.equal(result.calls.filter((call) => call.kind === "config").length, 2);
assert.equal(result.calls.filter((call) => call.kind === "metadata").length, 2);
assert.equal(result.calls.filter((call) => call.kind === "onboard").length, 0);
assert.match(result.output, /installation failed.*onboarding was not started/);
});
test("private or oversized config and unpublished metadata404 fail without retry or leaking config", async () => {
for (const input of [{ config: "private" }, { config: "large" }, { metadata: "missing" }]) {
const result = await canaryStartup({ mode: "tarball-permanent", ...input });
assert.equal(result.code, 1);
assert.equal(result.calls.filter((call) => call.kind === "npm").length, 1);
assert.equal(result.calls.filter((call) => call.kind === "onboard").length, 0);
assert.doesNotMatch(result.output, /fixture-user|fixture-password|fixture config diagnostic/);
}
});
test("truncated npm output cannot hide an earlier auth failure and qualify a tarball retry", async () => {
const result = await canaryStartup({ mode: "tarball-large" });
assert.equal(result.code, 1);
assert.equal(result.calls.filter((call) => call.kind === "npm").length, 1);
assert.equal(result.calls.filter((call) => call.kind === "config").length, 0);
assert.equal(result.calls.filter((call) => call.kind === "onboard").length, 0);
});
test("tarball recovery does not replay onboarding failures or change ETARGET for other exact versions", async () => {
const failed = await canaryStartup({ mode: "tarball-recover", onboard: "fail" });
assert.equal(failed.code, 1);
assert.equal(failed.calls.filter((call) => call.kind === "npm").length, 2);
assert.equal(failed.calls.filter((call) => call.kind === "onboard").length, 1);
assert.match(failed.output, /onboarding failed \(exit 17\)/);
const other = await canaryStartup({ mode: "recover", version: "2026.1009.0-nightly.1" });
assert.equal(other.code, 0, other.output);
assert.equal(other.calls.filter((call) => call.kind === "npm").length, 2);
assert.equal(other.calls.filter((call) => call.kind === "onboard").length, 1);
assert.equal(other.calls.filter((call) => call.kind === "config").length, 0);
});
test("existing ETARGET character-tail behavior survives long multibyte npm diagnostics", async () => {
const result = await canaryStartup({ mode: "multibyte-recover" });
assert.equal(result.code, 0, result.output);
assert.equal(result.calls.filter((call) => call.kind === "npm").length, 2);
assert.equal(result.calls.filter((call) => call.kind === "onboard").length, 1);
assert.equal(result.calls.filter((call) => call.kind === "config").length, 0);
});
test("metadata lookup and backoff both consume the unchanged shared acquisition budget", async () => {
for (const input of [{ metadata: "hang" }, { delay: 5000 }]) {
const result = await canaryStartup({ mode: "tarball-permanent", budget: 1000, ...input });
assert.equal(result.code, 1);
assert.equal(result.calls.filter((call) => call.kind === "npm").length, 1);
assert.equal(result.calls.filter((call) => call.kind === "onboard").length, 0);
assert.match(result.output, /exceeded its startup budget/);
assert.ok(result.elapsed < 4000, `shared-budget cancellation took ${result.elapsed}ms`);
}
});
test("cancellation reaps a config process and its descendant that ignore SIGTERM", async () => {
const result = await canaryStartup({ mode: "tarball-permanent", config: "hang", cancelAtKind: "config-descendant" });
assert.equal(result.code, 1);
assert.equal(result.calls.filter((call) => call.kind === "npm").length, 1);
assert.equal(result.calls.filter((call) => call.kind === "onboard").length, 0);
for (const child of result.calls.filter((call) => ["config", "config-descendant"].includes(call.kind))) {
assert.throws(() => process.kill(child.pid, 0), { code: "ESRCH" });
}
assert.match(result.output, /stopped by SIGTERM/);
});
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -783,7 +783,7 @@ const apps = [
{
serverUrl: "https://mcp.sentry.dev/mcp",
discoveryUrl:
"https://sentry.io/.well-known/oauth-authorization-server",
"https://mcp.sentry.dev/.well-known/oauth-protected-resource/mcp",
},
"S2",
"Connect the Sentry organization and projects agents need for incident work.",
+60
View File
@@ -0,0 +1,60 @@
// Background downloads for verification scripts: start a child early with its
// output in a log file, join it where the result is needed, and stop it before
// the scratch directory it writes into is removed. No shell, no inherited
// terminal, and a failure is reported where the caller awaits it.
import { spawn } from 'node:child_process';
import { closeSync, openSync, readFileSync, statSync } from 'node:fs';
const running = child => child.pid !== undefined && child.exitCode === null && child.signalCode === null;
// Returns { name, child, done, durationMs }. `done` resolves on exit code 0
// and otherwise rejects with the tail of the log; the rejection is observed
// here so an unawaited failure never surfaces as an unhandled rejection.
export function startPrefetch({ name, cmd, args, cwd, env, logPath }) {
const startedAt = Date.now();
const log = openSync(logPath, 'w');
let child;
try {
child = spawn(cmd, args, { cwd, env, stdio: ['ignore', log, log] });
} finally {
closeSync(log); // the child holds its own descriptor
}
const handle = { name, child, done: null, durationMs: null };
let settled = false;
handle.done = new Promise((settle, reject) => {
const finish = failure => {
if (settled) return;
settled = true;
// The caller may be blocked in synchronous work when the child exits, so
// this event can arrive long after the fact. When the child wrote
// anything, its last write is the end time; both downloads this serves
// print their final status line as they complete. A silent child falls
// back to the time the exit was observed.
let finishedAt = Date.now();
try {
const { mtimeMs, size } = statSync(logPath);
if (size > 0) finishedAt = Math.min(finishedAt, mtimeMs);
} catch {}
handle.durationMs = Math.max(0, Math.round(finishedAt - startedAt));
if (failure === null) return settle();
let output = '';
try { output = readFileSync(logPath, 'utf8').trim().slice(-2000); } catch {}
reject(new Error(`${name} prefetch failed (${failure}): ${output}`));
};
child.once('error', error => finish(error.message));
child.once('exit', (code, signal) => finish(code === 0 ? null : (signal ?? `exit ${code}`)));
});
handle.done.catch(() => {});
return handle;
}
// Stops every prefetch that is still running and resolves only once all of
// them have exited, so the caller can remove their working directory without
// racing a write. A child that ignores SIGTERM is killed after `graceMs`.
export async function stopPrefetches(prefetches, { graceMs = 5000 } = {}) {
await Promise.all(prefetches.map(async ({ child, done }) => {
if (running(child)) child.kill('SIGTERM');
const escalate = setTimeout(() => { if (running(child)) child.kill('SIGKILL'); }, graceMs);
try { await done; } catch {} finally { clearTimeout(escalate); }
}));
}
+49 -4
View File
@@ -4,12 +4,13 @@
// explicit and separate from npm installation, and is removed in finally.
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { materializePublishManifest, prepareBundledPackage } from './prepare-bundled-package.mjs';
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource } from './grok-public-install-sandbox.mjs';
import { startPrefetch, stopPrefetches } from './prefetch-child.mjs';
import { retainRunnerQualificationPackages } from './retain-runner-qualification-packages.mjs';
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
assert.equal(process.platform, 'linux', 'Run this verification on disposable EC2 Linux, not a developer host');
@@ -19,7 +20,31 @@ const env = { ...process.env, NODE_PATH: '', PAPERCLIP_RELEASE_REUSE_UI_DIST: '1
const run = (cmd, args, cwd = root) => execFileSync(cmd, args, { cwd, env, stdio: 'pipe', maxBuffer: 32 * 1024 * 1024 });
const sourceRevision = run('git', ['rev-parse', 'HEAD'], repo).toString().trim();
const releaseVersion = `0.0.0-grok-verify.${sourceRevision.slice(0, 12)}`;
// The two network downloads the probes need do not depend on anything packed
// below, so they overlap the CPU-bound staging instead of running after it.
// The consumer image is pinned by digest and the Grok binary is checked against
// its pinned sha256 by the provisioning script, so an early start changes no
// verified property. Neither touches the consumer, the assets, nor the
// `native/grok` path that the post-install assertion inspects.
const prefetched = join(root, 'prefetch'); mkdirSync(prefetched);
const stagedGrok = join(prefetched, 'grok');
const prefetches = [];
const timing = {};
// A prefetch failure surfaces where its result is awaited, never as an
// unhandled rejection that would mask an earlier staging error.
const prefetch = (name, cmd, args) => {
const handle = startPrefetch({ name, cmd, args, cwd: root, env, logPath: join(prefetched, `${name}.log`) });
prefetches.push(handle);
return handle.done;
};
let phaseStarted = performance.now();
const phase = name => { const now = performance.now(); timing[`${name}_ms`] = Math.round(now - phaseStarted); phaseStarted = now; };
const awaited = async (name, promise) => { const started = performance.now(); await promise; timing[`${name}_wait_ms`] = Math.round(performance.now() - started); };
try {
const imageReady = prefetch('image', 'docker', ['pull', '--platform', 'linux/amd64', GROK_PUBLIC_INSTALL_IMAGE]);
// Provision as the unprivileged verification user, never into the host's /opt.
// Only the positive probe sees this file at the canonical sandbox path.
const grokReady = prefetch('grok', process.execPath, [join(repo, 'packages/paperclip-runner/scripts/provision-grok.mjs'), stagedGrok]);
const listing = run(process.execPath, [join(repo, 'scripts/release-package-map.mjs'), 'list'], repo).toString().trim().split('\n').map(line => line.split('\t'));
const packages = new Map(listing.map(([dir, name]) => [name, { dir, manifest: JSON.parse(readFileSync(join(repo, dir, 'package.json'), 'utf8')) }]));
const needed = new Set();
@@ -81,9 +106,12 @@ try {
const sentinel = join(consumer, 'node_modules/paperclip-verification-lifecycle-sentinel/lifecycle-ran');
const consumerUid = process.getuid();
const isolated = (command, options = {}) => run('docker', grokConsumerDockerArgs({ assets, consumer, cache, uid: consumerUid, gid: process.getgid(), command, ...options }));
phase('stage_packages');
await awaited('image', imageReady);
// npm resolution is intentionally the public consumer graph, not the pnpm
// workspace graph. Freeze that result before any lifecycle script can run.
isolated(['npm', 'install', '--ignore-scripts', '--omit=dev', ...readdirSync(assets).filter(file => file.endsWith('.tgz')).map(file => `/packages/${file}`)], { download: true });
phase('consumer_install');
assert.equal(existsSync(sentinel), false, 'Dependency download must not run lifecycle scripts');
const consumerLock = readFileSync(join(consumer, 'package-lock.json'), 'utf8');
// npm ci rejects bundled optional platform dependencies absent from its own
@@ -92,6 +120,7 @@ try {
isolated(GROK_PUBLIC_INSTALL_LIFECYCLE);
assert.equal(readFileSync(sentinel, 'utf8'), 'ok', 'Offline lifecycle scripts must actually execute');
assert.equal(readFileSync(join(consumer, 'package-lock.json'), 'utf8'), consumerLock, 'Lifecycle execution must preserve the resolved consumer lock');
phase('lifecycle_rebuild');
for (const name of needed) {
const installedManifest = JSON.parse(readFileSync(join(consumer, 'node_modules', name, 'package.json'), 'utf8'));
assert.equal(installedManifest.version, releaseVersion, `Installed release version for ${name}`);
@@ -104,6 +133,7 @@ try {
writeFileSync(join(assets, 'codex-probe.mjs'), installedCodexProbeSource(
'/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/index.js', '/consumer', codexVersion), { mode: 0o644 });
const codex = JSON.parse(isolated(['node', '/packages/codex-probe.mjs']).toString().trim());
phase('codex_probe');
assert.equal(codex.codexConsumerHostOnly, true);
assert.equal(codex.codexPlatformPackageSource, 'official npm');
assert.ok(existsSync(join(installed, 'providers/grok/launcher.cjs')));
@@ -122,10 +152,14 @@ try {
`;
writeFileSync(join(assets, 'probe.mjs'), probe, { mode: 0o644 });
isolated(['node', '/packages/probe.mjs', 'missing']);
// Provision as the unprivileged verification user, never into the host's /opt.
// Only the positive probe sees this file at the canonical sandbox path.
run(process.execPath, [join(repo, 'packages/paperclip-runner/scripts/provision-grok.mjs'), prerequisite]);
// The binary was provisioned outside `native/grok` while the packages were
// staged, so the assertion above still proves npm put nothing there. It moves
// into place only now, so only the positive probe sees it.
await awaited('grok', grokReady);
mkdirSync(dirname(prerequisite), { recursive: true });
renameSync(stagedGrok, prerequisite);
isolated(['node', '/packages/probe.mjs', 'present'], { prerequisite });
phase('grok_probes');
console.log(JSON.stringify({ schema: 'paperclip.grok.public-npm-install.v1', sourceRevision, releaseVersion, lifecycleScriptsEnabled: true, lifecycleSentinelVerified: true, lifecycleNetwork: 'none', consumerImage: GROK_PUBLIC_INSTALL_IMAGE, consumerUid, consumerLockPreserved: true, cleanNpmInstall: true, packageCount: needed.size, codexPackedPayloadsOmitted: true, codexPackedPackagesChecked: tarballs.length, ...codex, builtinLauncherPresent: true, separateGrokPackage: false, npmProvisionedBinary: false, missingPrerequisiteRejected: true, provisionedBinaryVerified: true, commandLeaseVerified: true, providerCalls: 0 }));
// Exercise Pi's public CLI and installed server, never a private workspace
// package or binary override. Public dependency downloads are explicit and
@@ -137,6 +171,7 @@ try {
// temporary capacity than the smaller offline lifecycle probes.
const setup = isolated(['node', '/consumer/node_modules/paperclipai/dist/index.js', 'runtime', 'setup', 'pi'], { download: true, temporarySizeMiB: 2048 }).toString();
const receipt = JSON.parse(setup.trim());
phase('pi_setup');
assert.equal(receipt.status, 'installed_verified');
assert.equal(receipt.target, 'linux-x64');
assert.equal(readFileSync(join(consumer, 'package-lock.json'), 'utf8'), consumerLock, 'Pi setup must preserve the consumer dependency graph');
@@ -144,6 +179,13 @@ try {
// Docker defaults tmpfs to noexec. The offline admission probe must execute
// its verified private snapshot while keeping lifecycle/download scratch noexec.
console.log(isolated(['node', '/packages/pi-public-install-probe.mjs', '/consumer/node_modules/@paperclipai/server'], { temporarySizeMiB: 2048, temporaryExecutable: true }).toString().trim());
phase('pi_probe');
// Wall time per phase, so the slow part of this step is visible in the CI log
// without re-running it. `*_prefetch_ms` runs from a download's start to its
// last output, and `*_wait_ms` is how long the main flow waited for a
// prefetch that had not finished yet; zero means the overlap hid it entirely.
const downloads = Object.fromEntries(prefetches.map(({ name, durationMs }) => [`${name}_prefetch_ms`, durationMs]));
console.log(JSON.stringify({ schema: 'paperclip.public-npm-install.timing.v1', ...timing, ...downloads }));
if (process.env.PAPERCLIP_RUNNER_QUALIFICATION_PACKAGES_DIR) {
const retained = retainRunnerQualificationPackages({ repo, output: process.env.PAPERCLIP_RUNNER_QUALIFICATION_PACKAGES_DIR, sourceRevision, releaseVersion, env,
publicArchives: [...needed].map((name, index) => ({ name, file: tarballs[index] })),
@@ -151,5 +193,8 @@ try {
console.log(JSON.stringify(retained));
}
} finally {
// An earlier failure must not leave a prefetch running, and scratch is
// removed only once both children have stopped writing into it.
await stopPrefetches(prefetches);
rmSync(root, { recursive: true, force: true });
}
+148 -1
View File
@@ -5,7 +5,8 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest
import {
agentInstructionWorkingCopies, agents, agentApiKeys, agentWakeupRequests, companies, companySecretProposals, createDb,
adapterAuthSessions, environments, environmentLeases, executionWorkspaces,
heartbeatRuns, issues, issueWatchdogs, projects, routines, type Db,
heartbeatRuns, issues, issueWatchdogs, issueThreadInteractions, issueQuestionResponseDeliveries,
projects, routines, type Db,
} from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { readIdleSleepSafety, type IdleSleepDrainStatus } from "../services/idle-sleep-safety.js";
@@ -184,6 +185,152 @@ if (!support.supported) console.warn(`Skipping idle sleep Postgres tests: ${supp
return { companyId, agentId, runId };
}
async function seedQuestionDelivery(change: Partial<typeof issueQuestionResponseDeliveries.$inferInsert> = {}) {
const seeded = await seed();
await db.update(heartbeatRuns).set({ finishedAt: new Date(now) }).where(eq(heartbeatRuns.id, seeded.runId));
const issueId = randomUUID();
const interactionId = randomUUID();
await db.insert(issues).values({ id: issueId, companyId: seeded.companyId, title: "Finished question", status: "done" });
await db.insert(issueThreadInteractions).values({
id: interactionId, companyId: seeded.companyId, issueId,
kind: "ask_user_questions", status: "answered", payload: {},
});
await db.insert(issueQuestionResponseDeliveries).values({
companyId: seeded.companyId, issueId, interactionId, sourceRunId: seeded.runId,
targetRunId: seeded.runId, correlationId: randomUUID(), payloadSha256: "fixture",
status: "fallback_queued", deliveryMode: "wake_fallback", acknowledgedAt: new Date(now), ...change,
});
return { ...seeded, issueId, interactionId };
}
async function seedQuestionWake(
delivery: Awaited<ReturnType<typeof seedQuestionDelivery>>,
change: Partial<typeof agentWakeupRequests.$inferInsert> = {},
) {
await db.insert(agentWakeupRequests).values({
companyId: delivery.companyId, agentId: delivery.agentId, source: "automation",
idempotencyKey: `question-response:${delivery.interactionId}`,
status: "completed", finishedAt: new Date(now), ...change,
});
}
it.each(["pending", "delivering"])("keeps a %s question delivery awake", async status => {
await seedQuestionDelivery({ status, acknowledgedAt: null });
expect(await read()).toEqual(present);
});
it.each(["delivered", "fallback_queued"])("permits acknowledged %s history after its target run finishes", async status => {
await seedQuestionDelivery({ status });
expect(await read()).toEqual(none);
});
it.each(["delivered", "fallback_queued"])("keeps an unacknowledged %s question delivery awake", async status => {
await seedQuestionDelivery({ status, acknowledgedAt: null });
expect(await read()).toEqual(present);
});
it.each([
["queued", { status: "queued", finishedAt: null }],
["running", { status: "running", finishedAt: null }],
["unfinished", { finishedAt: null }],
["retry", { scheduledRetryAt: new Date(now + 1000) }],
["accounting", { costAccountingPending: true }],
] as const)("keeps an acknowledged question delivery awake with %s target evidence", async (_name, change) => {
const { runId } = await seedQuestionDelivery();
await db.update(heartbeatRuns).set(change).where(eq(heartbeatRuns.id, runId));
expect(await read()).toEqual(present);
});
it("does not accept a foreign company's finished question target", async () => {
const foreign = await seedQuestionDelivery({ status: "failed", targetRunId: null });
await seedQuestionDelivery({ targetRunId: foreign.runId });
expect(await read()).toEqual(present);
});
it("keeps delivered history with a missing target awake", async () => {
await seedQuestionDelivery({ status: "delivered", targetRunId: null });
expect(await read()).toEqual(present);
});
it("keeps fallback history without a durable wake receipt awake", async () => {
await seedQuestionDelivery({ targetRunId: null });
expect(await read()).toEqual(present);
});
it.each(["completed", "failed", "cancelled", "skipped", "timed_out"])(
"permits fallback history after its exact durable wake is %s and finished", async status => {
const delivery = await seedQuestionDelivery({ targetRunId: null });
await seedQuestionWake(delivery, { status });
expect(await read()).toEqual(none);
},
);
it.each(["queued", "deferred_issue_execution", "coalesced"])("keeps an unresolved %s question wake awake", async status => {
const delivery = await seedQuestionDelivery({ targetRunId: null });
await seedQuestionWake(delivery, { status, finishedAt: null });
expect(await read()).toEqual(present);
});
it("keeps a terminal question wake without completion evidence awake", async () => {
const delivery = await seedQuestionDelivery({ targetRunId: null });
await seedQuestionWake(delivery, { finishedAt: null });
expect(await read()).toEqual(present);
});
it("permits a deferred question fallback after its coalesced continuation finishes", async () => {
const delivery = await seedQuestionDelivery({ targetRunId: null });
await seedQuestionWake(delivery, { status: "deferred_issue_execution", finishedAt: null });
expect(await read()).toEqual(present);
await db.update(agentWakeupRequests).set({
status: "coalesced", runId: delivery.runId, finishedAt: new Date(now),
}).where(eq(agentWakeupRequests.companyId, delivery.companyId));
expect(await read()).toEqual(none);
});
it.each(["missing_run", "wrong_company", "wrong_agent", "unfinished", "retry", "accounting"])(
"keeps a coalesced question fallback awake with %s linked-run evidence", async kind => {
const delivery = await seedQuestionDelivery({ targetRunId: null });
let runId = delivery.runId;
if (kind === "missing_run") runId = randomUUID();
if (kind === "wrong_company") runId = (await seedQuestionDelivery({ status: "failed", targetRunId: null })).runId;
if (kind === "wrong_agent") {
const otherAgent = randomUUID();
await db.insert(agents).values({ id: otherAgent, companyId: delivery.companyId, name: "Other", role: "engineer", status: "idle", adapterType: "process" });
await db.update(heartbeatRuns).set({ agentId: otherAgent }).where(eq(heartbeatRuns.id, runId));
}
if (kind === "unfinished") await db.update(heartbeatRuns).set({ finishedAt: null }).where(eq(heartbeatRuns.id, runId));
if (kind === "retry") await db.update(heartbeatRuns).set({ scheduledRetryAt: new Date(now + 1000) }).where(eq(heartbeatRuns.id, runId));
if (kind === "accounting") await db.update(heartbeatRuns).set({ costAccountingPending: true }).where(eq(heartbeatRuns.id, runId));
await seedQuestionWake(delivery, { status: "coalesced", runId });
expect(await read()).toEqual(present);
},
);
it("does not accept another interaction's or company's question wake receipt", async () => {
const delivery = await seedQuestionDelivery({ targetRunId: null });
await seedQuestionWake(delivery, { idempotencyKey: `question-response:${randomUUID()}` });
const foreign = await seedQuestionDelivery({ status: "failed", targetRunId: null });
await seedQuestionWake(delivery, { companyId: foreign.companyId, agentId: foreign.agentId });
expect(await read()).toEqual(present);
});
it("permits failed question-delivery history without a pending target", async () => {
await seedQuestionDelivery({ status: "failed", targetRunId: null, acknowledgedAt: null });
expect(await read()).toEqual(none);
});
it("still blocks other work after a question handoff finishes", async () => {
const { issueId } = await seedQuestionDelivery();
await db.update(issues).set({ status: "todo" }).where(eq(issues.id, issueId));
expect(await read()).toEqual(present);
});
it("requires a current quiet owner even with completed question history", async () => {
await seedQuestionDelivery();
expect(await readIdleSleepSafety(db, owned, () => now, "stale-owner", emptyLocal)).toEqual(unknown);
expect(await readIdleSleepSafety(db, owned, () => now + 60_001, ownerId, emptyLocal)).toEqual(unknown);
});
it("keeps saved agent files awake until deferred cleanup completes", async () => {
const { companyId, agentId, runId } = await seed();
await db.insert(agentInstructionWorkingCopies).values({
@@ -12952,6 +12952,91 @@ describeEmbeddedPostgres("tool access service", () => {
);
});
it.each(["new", "saved"])("starts Sentry MCP sign-in for a %s connection without a manual OAuth app", async (state) => {
const company = await createCompany(db);
const service = createTestToolAccessService(db);
const connected = await service.connectGalleryApp(company.id, {
galleryKey: "sentry", connectionMethodKey: "mcp-oauth", name: "Sentry",
});
const redirectUri = "http://localhost:3200/api/tools/oauth/callback";
const issuer = "https://mcp.sentry.dev";
const resource = `${issuer}/mcp`;
const scopes = ["org:read", "project:write", "team:write", "event:write", "alerts:write"];
if (state === "saved") {
const [connection] = await db.select().from(toolConnections)
.where(eq(toolConnections.id, connected.connectionId));
// An existing MCP registration must survive catalog changes. This is the
// state before a reconnect, with endpoints cached by successful discovery.
await db.update(toolConnections).set({ config: {
...connection.config,
oauth: {
...connection.config.oauth as Record<string, unknown>,
provider: "sentry", issuer, resource,
authorizationUrl: `${issuer}/oauth/authorize`, tokenUrl: `${issuer}/oauth/token`,
metadataUrl: `${issuer}/.well-known/oauth-authorization-server`,
clientId: "saved-sentry-client", clientRegistrationSource: "dcr",
clientIssuer: issuer, clientResource: resource,
clientCompanyId: company.id, clientRedirectUri: redirectUri,
clientTokenEndpointAuthMethod: "none",
},
} }).where(eq(toolConnections.id, connected.connectionId));
}
const calls: string[] = [];
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
const href = String(url);
calls.push(href);
// Both hosts publish valid OAuth metadata. The web/API issuer cannot
// register an MCP client; discovery must select the resource's issuer.
if (href === "https://sentry.io/.well-known/oauth-authorization-server") {
return mcpHttpResponse({
issuer: "https://sentry.io", authorization_endpoint: "https://sentry.io/oauth/authorize/",
token_endpoint: "https://sentry.io/oauth/token/", code_challenge_methods_supported: ["S256"],
});
}
if (href === `${issuer}/.well-known/oauth-protected-resource/mcp`) {
return mcpHttpResponse({ resource, authorization_servers: [issuer], scopes_supported: scopes });
}
if (href === `${issuer}/.well-known/oauth-authorization-server`) {
return mcpHttpResponse({
issuer, authorization_endpoint: `${issuer}/oauth/authorize`, token_endpoint: `${issuer}/oauth/token`,
registration_endpoint: `${issuer}/oauth/register`, scopes_supported: scopes,
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
client_id_metadata_document_supported: true,
});
}
if (href === `${issuer}/oauth/register`) {
expect(JSON.parse(String(init?.body))).toMatchObject({
redirect_uris: [redirectUri], token_endpoint_auth_method: "none",
});
return mcpHttpResponse({
client_id: "new-sentry-client", redirect_uris: [redirectUri],
grant_types: ["authorization_code", "refresh_token"], response_types: ["code"],
token_endpoint_auth_method: "none",
});
}
throw new Error(`unexpected fetch ${href}`);
});
const started = await service.startOAuth(company.id, connected.connectionId, {
redirectUri, actor: { actorType: "user", actorId: "board" },
});
const authorization = new URL(started.authorizationUrl);
expect(authorization.origin + authorization.pathname).toBe(`${issuer}/oauth/authorize`);
expect(authorization.searchParams.get("resource")).toBe(resource);
expect(authorization.searchParams.get("scope")?.split(" ")).toEqual(scopes);
expect(authorization.searchParams.get("code_challenge_method")).toBe("S256");
expect(authorization.searchParams.get("client_id")).toBe(`${state}-sentry-client`);
expect(calls).not.toContain("https://sentry.io/.well-known/oauth-authorization-server");
expect(calls.filter((url) => url === `${issuer}/oauth/register`)).toHaveLength(state === "new" ? 1 : 0);
const [saved] = await db.select().from(toolConnections)
.where(eq(toolConnections.id, connected.connectionId));
expect(saved.config.oauth).toMatchObject({
clientId: `${state}-sentry-client`, clientIssuer: issuer, clientResource: resource,
clientCompanyId: company.id, clientRedirectUri: redirectUri,
});
});
it("uses Asana v2 metadata instead of stale v1 endpoints for a saved custom app", async () => {
const company = await createCompany(db);
const service = createTestToolAccessService(db);
+26 -1
View File
@@ -93,13 +93,38 @@ const WORK_CHECKS = [
THEN s.provider_lease_id::uuid END)
AND (l.status IN ('released', 'expired') AND l.cleanup_status = 'success'
AND l.released_at IS NOT NULL) IS NOT TRUE))`,
// Answer delivery history is not work once its acknowledged handoff has
// finished. Keep pending deliveries, unfinished target runs, and missing or
// unfinished fallback wake receipts awake. Other work checks still apply.
`SELECT 1 FROM issue_question_response_deliveries d WHERE
d.status NOT IN ('delivered', 'fallback_queued', 'failed')
OR (d.status IN ('delivered', 'fallback_queued') AND d.acknowledged_at IS NULL)
OR (d.status = 'delivered' AND d.target_run_id IS NULL)
OR (d.target_run_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM heartbeat_runs r WHERE r.id = d.target_run_id
AND r.company_id = d.company_id
AND r.status IN ('succeeded', 'failed', 'cancelled', 'timed_out', 'interrupted')
AND r.finished_at IS NOT NULL AND r.scheduled_retry_at IS NULL
AND r.cost_accounting_pending = false))
OR (d.status = 'fallback_queued' AND d.target_run_id IS NULL AND NOT EXISTS (
SELECT 1 FROM agent_wakeup_requests w WHERE w.company_id = d.company_id
AND w.idempotency_key = 'question-response:' || d.interaction_id::text
AND w.idempotency_key LIKE 'question-response:%'
AND w.finished_at IS NOT NULL
AND (w.status IN ('completed', 'failed', 'cancelled', 'skipped', 'timed_out')
OR (w.status = 'coalesced' AND EXISTS (
SELECT 1 FROM heartbeat_runs r WHERE r.id = w.run_id
AND r.company_id = w.company_id AND r.agent_id = w.agent_id
AND r.status IN ('succeeded', 'failed', 'cancelled', 'timed_out', 'interrupted')
AND r.finished_at IS NOT NULL AND r.scheduled_retry_at IS NULL
AND r.cost_accounting_pending = false)))))`,
// These less common work sources fail closed on any retained state. Their
// terminal-state exceptions can be added with tests for the owning service.
...[
"chat_endpoints", "chat_deliveries", "chat_publications", "chat_actions",
"chat_completion_deliveries", "connection_event_deliveries",
"connection_intent_deliveries", "tool_action_requests", "tool_action_deliveries",
"issue_question_response_deliveries", "workspace_runtime_services",
"workspace_runtime_services",
"execution_workspace_runtime_leases", "pipeline_automation_executions",
"native_run_finalizations", "company_transfer_runs", "decisions", "decision_effect_executions",
"decision_archive_notification_outbox", "browser_use_sessions", "browser_use_runs",
@@ -2,21 +2,148 @@ 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 run(command, args, { env, signal, captureStderr = false }) {
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", "inherit", captureStderr ? "pipe" : "inherit"],
env, detached: grouped, stdio: ["ignore", captureStdout ? "pipe" : "inherit", captureStderr ? "pipe" : "inherit"],
});
let stderr = "";
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 {
@@ -34,15 +161,20 @@ function run(command, args, { env, signal, captureStderr = false }) {
};
signal.addEventListener("abort", stop, { once: true });
child.stderr?.on("data", (chunk) => {
process.stderr.write(chunk);
stderr = (stderr + chunk.toString()).slice(-STDERR_TAIL_CHARS);
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 });
else resolve({ code, signal: exitSignal,
stderr: quiet ? stderr.toString("utf8") : stderr, stdout: quiet ? stdout.toString("utf8") : stdout, outputTruncated });
});
});
}
@@ -85,7 +217,10 @@ export async function startPublishedCanary({
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";
if (!missingVersion || result.signal || attempt === MAX_INSTALL_ATTEMPTS) {
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 });