mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
Port upstream runtime while preserving Boat staging migration history
This commit is contained in:
1 parent
780ecec585
commit
d50dd0f49b
30 files changed
+4089
-698
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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:%';
|
||||
+1872
-8
File diff suppressed because it is too large.
Load diff
+7
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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); }
|
||||
}));
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user