From 0798c77fde0ad969a3a1eed880ca01982253cce4 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:01:47 -0500 Subject: [PATCH 1/6] Secure Cloud canonical runtime identity (#12766) Accept and persist Cloud-signed canonical runtime identity before activation, then route absolute self-URLs through the durable runtime identity provider. Co-Authored-By: Codex --- doc/DATABASE.md | 12 + doc/DEPLOYMENT-MODES.md | 18 + .../__tests__/cloud-runtime-identity.test.ts | 261 +++++++++++ .../__tests__/generic-mcp-connection.test.ts | 12 + server/src/__tests__/routines-service.test.ts | 7 + server/src/app.ts | 2 + server/src/index.ts | 7 + .../src/middleware/cloud-runtime-identity.ts | 33 ++ server/src/routes/access.ts | 3 + server/src/routes/health.ts | 8 + server/src/routes/tool-access.ts | 7 +- .../services/cloud-runtime-identity.test.ts | 123 +++++ server/src/services/cloud-runtime-identity.ts | 429 ++++++++++++++++++ .../paperclip-cloud-connector-enrollment.ts | 4 +- server/src/services/routines.ts | 13 +- 15 files changed, 933 insertions(+), 6 deletions(-) create mode 100644 server/src/__tests__/cloud-runtime-identity.test.ts create mode 100644 server/src/middleware/cloud-runtime-identity.ts create mode 100644 server/src/services/cloud-runtime-identity.test.ts create mode 100644 server/src/services/cloud-runtime-identity.ts diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 8d7edfdf3c..ff04d6f919 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -175,6 +175,18 @@ When authoring migrations or one-time backfills: - Do not hand-edit a snapshot to resolve a merge conflict. Renumber your migration and run `generate` again, as `packages/db/.gitattributes` describes. - `packages/db/src/migration-snapshot-drift.test.ts` is the enforcement backstop. It repeats the diff that `generate` performs and fails when the newest snapshot no longer matches `packages/db/src/schema/`. +## Cloud runtime identity singleton + +The private `instance_settings` row whose singleton key is +`cloud-runtime-identity/v1` records the immutable Cloud stack id, warm-pool +claim id, previous pool origin, canonical origin, and stack slug accepted from +Cloud's signed pre-activation assertion. It is separate from the normal +`default` settings row and never appears in the settings API. This is +intentionally instance-scoped rather than company-scoped: an instance has one +public identity, and the existing unique singleton-key index makes concurrent +or later attempts to replace it fail closed. The server loads the row before +constructing URL-dependent runtime services on every boot. + ## Resource membership tables Paperclip stores current-user sidebar membership state in: diff --git a/doc/DEPLOYMENT-MODES.md b/doc/DEPLOYMENT-MODES.md index e020462e69..49f6e5f0d5 100644 --- a/doc/DEPLOYMENT-MODES.md +++ b/doc/DEPLOYMENT-MODES.md @@ -64,6 +64,24 @@ Paperclip now treats **bind** as a separate concern from auth: - recommended bind is `loopback` behind a reverse proxy; direct `lan/custom` is advanced - local stdio MCP runtime slots fail closed by default; set `PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST` only when a trusted worker/runtime host is configured to supervise those processes. Remote HTTP MCP remains the preferred public-hosted path. +### Paperclip Cloud warm-pool identity + +A Cloud-managed warm-pool process initially boots under a `pool-*` origin. It +receives only Cloud's public verification set in +`PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS`. Before Cloud activates a claimed stack, +the existing server-to-server health request carries a short-lived Ed25519 JWS +that binds the immutable `PAPERCLIP_CLOUD_STACK_ID`, pool claim, previous +origin, canonical HTTPS origin, and slug. Paperclip verifies and persists that +one-time assertion, updates its live public/API URL provider, and acknowledges +the exact origin in `/api/health` before the first user request is admitted. + +The Harness signing private key is never present in Paperclip, browsers, or +other tenant stacks. A different claim or destination cannot replace the +persisted identity. On restart, the durable identity is loaded before auth, +routes, and child-runtime configuration, even when provider variables are +temporarily stale. Self-hosted deployments continue to use their configured +`PAPERCLIP_PUBLIC_URL` and do not participate in this protocol. + ## 4. Onboarding UX Contract Default onboarding remains interactive and flagless: diff --git a/server/src/__tests__/cloud-runtime-identity.test.ts b/server/src/__tests__/cloud-runtime-identity.test.ts new file mode 100644 index 0000000000..ae2cd9f9da --- /dev/null +++ b/server/src/__tests__/cloud-runtime-identity.test.ts @@ -0,0 +1,261 @@ +import { generateKeyPairSync, sign } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { createDb, instanceSettings } from "@paperclipai/db"; +import { + applyCloudRuntimeIdentityAssertion, + CLOUD_RUNTIME_IDENTITY_AUDIENCE, + CLOUD_RUNTIME_IDENTITY_ISSUER, + CLOUD_RUNTIME_IDENTITY_JWS_TYPE, + getCloudRuntimeIdentity, + initializeCloudRuntimeIdentity, + resetCloudRuntimeIdentityForTests, + runtimePublicOrigin, +} from "../services/cloud-runtime-identity.js"; +import { routineWebhookUrl } from "../services/routines.js"; +import { paperclipCloudConnectorEnrollmentStatus } from "../services/paperclip-cloud-connector-enrollment.js"; +import { cloudRuntimeIdentityMiddleware } from "../middleware/cloud-runtime-identity.js"; +import { healthRoutes } from "../routes/health.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +const STACK_ID = "stack-pool-123"; +const POOL_ORIGIN = "https://pool-123.staging.paperclip.app"; +const CANONICAL_ORIGIN = "https://gonzo.staging.paperclip.app"; +const NOW = new Date("2099-01-01T00:00:00.000Z"); + +const pair = generateKeyPairSync("ed25519"); +const publicJwk = { + ...pair.publicKey.export({ format: "jwk" }), + kid: "runtime-identity-test-key", + use: "sig", + alg: "EdDSA", +}; + +function encodeJson(value: Record) { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function assertion(input: { + claims?: Record; + header?: Record; + signingKey?: typeof pair.privateKey; +} = {}) { + const iat = Math.floor(NOW.getTime() / 1000); + const header = encodeJson({ + alg: "EdDSA", + typ: CLOUD_RUNTIME_IDENTITY_JWS_TYPE, + kid: publicJwk.kid, + ...input.header, + }); + const payload = encodeJson({ + v: 1, + iss: CLOUD_RUNTIME_IDENTITY_ISSUER, + aud: CLOUD_RUNTIME_IDENTITY_AUDIENCE, + sub: STACK_ID, + claimId: "pool-entry-123", + previousOrigin: POOL_ORIGIN, + canonicalOrigin: CANONICAL_ORIGIN, + stackSlug: "gonzo", + iat, + exp: iat + 300, + ...input.claims, + }); + const signature = sign( + null, + Buffer.from(`${header}.${payload}`, "ascii"), + input.signingKey ?? pair.privateKey, + ).toString("base64url"); + return `${header}.${payload}.${signature}`; +} + +describeEmbeddedPostgres("Cloud runtime identity", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + const originalEnv = { ...process.env }; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-cloud-runtime-identity-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + beforeEach(async () => { + await db.delete(instanceSettings); + resetCloudRuntimeIdentityForTests(); + process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "test-tenant-server-token"; + process.env.PAPERCLIP_CLOUD_STACK_ID = STACK_ID; + process.env.PAPERCLIP_CLOUD_API_ORIGIN = "https://my-staging.paperclip.app"; + process.env.PAPERCLIP_PUBLIC_URL = POOL_ORIGIN; + process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL = POOL_ORIGIN; + process.env.PAPERCLIP_API_URL = POOL_ORIGIN; + process.env.PAPERCLIP_PRIMARY_HOST = "pool-123.staging.paperclip.app"; + process.env.PAPERCLIP_STACK_SLUG = "pool-123"; + process.env.PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS = JSON.stringify({ keys: [publicJwk] }); + process.env.PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID = "managed-instance"; + process.env.PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY = "managed-signing-key"; + process.env.PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY = "managed-sealing-key"; + process.env.PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT = "staging"; + process.env.PAPERCLIP_CLOUD_CONNECTOR_BASE_URL = "https://my-staging.paperclip.app"; + await initializeCloudRuntimeIdentity(db); + }); + + afterEach(() => { + for (const key of [ + "PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", + "PAPERCLIP_CLOUD_STACK_ID", + "PAPERCLIP_CLOUD_API_ORIGIN", + "PAPERCLIP_PUBLIC_URL", + "PAPERCLIP_AUTH_PUBLIC_BASE_URL", + "PAPERCLIP_API_URL", + "PAPERCLIP_PRIMARY_HOST", + "PAPERCLIP_STACK_SLUG", + "PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS", + "PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID", + "PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY", + "PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY", + "PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT", + "PAPERCLIP_CLOUD_CONNECTOR_BASE_URL", + "PAPERCLIP_RUNTIME_API_CANDIDATES_JSON", + ]) { + const original = originalEnv[key]; + if (original === undefined) delete process.env[key]; + else process.env[key] = original; + } + resetCloudRuntimeIdentityForTests(); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("persists and immediately applies a valid one-time claim", async () => { + const applied = await applyCloudRuntimeIdentityAssertion({ + db, + compactJws: assertion(), + now: NOW, + }); + + expect(applied.canonicalOrigin).toBe(CANONICAL_ORIGIN); + expect(getCloudRuntimeIdentity()?.stackSlug).toBe("gonzo"); + expect(runtimePublicOrigin()).toBe(CANONICAL_ORIGIN); + expect(process.env.PAPERCLIP_PUBLIC_URL).toBe(CANONICAL_ORIGIN); + expect(process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL).toBe(CANONICAL_ORIGIN); + expect(process.env.PAPERCLIP_API_URL).toBe(CANONICAL_ORIGIN); + expect(process.env.PAPERCLIP_PRIMARY_HOST).toBe("gonzo.staging.paperclip.app"); + expect(process.env.PAPERCLIP_STACK_SLUG).toBe("gonzo"); + expect(routineWebhookUrl("hook-1")).toBe( + "https://gonzo.staging.paperclip.app/api/routine-triggers/public/hook-1/fire", + ); + expect(paperclipCloudConnectorEnrollmentStatus()).toMatchObject({ + configured: true, + status: "active", + origins: [CANONICAL_ORIGIN], + }); + }); + + it("applies the assertion on the existing health request and acknowledges the exact origin", async () => { + const requestTime = Math.floor(Date.now() / 1000); + const app = express(); + app.use(cloudRuntimeIdentityMiddleware(db)); + app.use("/api/health", healthRoutes(db, { + deploymentMode: "authenticated", + deploymentExposure: "public", + authReady: true, + companyDeletionEnabled: false, + })); + + const response = await request(app) + .get("/api/health") + .set("x-paperclip-cloud-runtime-identity", assertion({ + claims: { iat: requestTime, exp: requestTime + 300 }, + })); + + expect(response.status).toBe(200); + expect(response.body.cloud.runtimeIdentity).toEqual({ + canonicalOrigin: CANONICAL_ORIGIN, + stackSlug: "gonzo", + }); + }); + + it("restores the canonical identity before consumers read stale startup variables", async () => { + await applyCloudRuntimeIdentityAssertion({ db, compactJws: assertion(), now: NOW }); + resetCloudRuntimeIdentityForTests(); + process.env.PAPERCLIP_PUBLIC_URL = POOL_ORIGIN; + process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL = POOL_ORIGIN; + process.env.PAPERCLIP_API_URL = POOL_ORIGIN; + + await initializeCloudRuntimeIdentity(db); + + expect(runtimePublicOrigin()).toBe(CANONICAL_ORIGIN); + expect(process.env.PAPERCLIP_API_URL).toBe(CANONICAL_ORIGIN); + }); + + it("accepts the identical claim after a restart with already-aligned provider variables", async () => { + await applyCloudRuntimeIdentityAssertion({ db, compactJws: assertion(), now: NOW }); + resetCloudRuntimeIdentityForTests(); + process.env.PAPERCLIP_PUBLIC_URL = CANONICAL_ORIGIN; + process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL = CANONICAL_ORIGIN; + process.env.PAPERCLIP_API_URL = CANONICAL_ORIGIN; + await initializeCloudRuntimeIdentity(db); + + await expect(applyCloudRuntimeIdentityAssertion({ + db, + compactJws: assertion(), + now: NOW, + })).resolves.toMatchObject({ canonicalOrigin: CANONICAL_ORIGIN }); + }); + + it("accepts an identical replay but rejects a different claim or destination", async () => { + await applyCloudRuntimeIdentityAssertion({ db, compactJws: assertion(), now: NOW }); + await expect(applyCloudRuntimeIdentityAssertion({ + db, + compactJws: assertion(), + now: NOW, + })).resolves.toMatchObject({ canonicalOrigin: CANONICAL_ORIGIN }); + await expect(applyCloudRuntimeIdentityAssertion({ + db, + compactJws: assertion({ claims: { claimId: "another-claim" } }), + now: NOW, + })).rejects.toThrow("already claimed"); + }); + + it.each([ + ["expired", { exp: Math.floor(NOW.getTime() / 1000) - 1 }], + ["cross-stack", { sub: "stack-someone-else" }], + ["wrong previous origin", { previousOrigin: "https://another.staging.paperclip.app" }], + ["path-bearing destination", { canonicalOrigin: `${CANONICAL_ORIGIN}/GON` }], + ["slug mismatch", { stackSlug: "kermit" }], + ])("rejects %s assertions", async (_label, claims) => { + await expect(applyCloudRuntimeIdentityAssertion({ + db, + compactJws: assertion({ claims }), + now: NOW, + })).rejects.toThrow(); + }); + + it("rejects unknown keys and altered signed destinations", async () => { + await expect(applyCloudRuntimeIdentityAssertion({ + db, + compactJws: assertion({ header: { kid: "unknown" } }), + now: NOW, + })).rejects.toThrow("unknown signing key"); + + const valid = assertion(); + const [header, payload, signature] = valid.split("."); + const altered = encodeJson({ + ...JSON.parse(Buffer.from(payload!, "base64url").toString("utf8")), + canonicalOrigin: "https://attacker.example.com", + }); + await expect(applyCloudRuntimeIdentityAssertion({ + db, + compactJws: `${header}.${altered}.${signature}`, + now: NOW, + })).rejects.toThrow("signature is invalid"); + }); +}); diff --git a/server/src/__tests__/generic-mcp-connection.test.ts b/server/src/__tests__/generic-mcp-connection.test.ts index 6d496912d3..0da6aad5ec 100644 --- a/server/src/__tests__/generic-mcp-connection.test.ts +++ b/server/src/__tests__/generic-mcp-connection.test.ts @@ -2146,6 +2146,18 @@ describeEmbeddedPostgres("generic remote MCP connections", () => { expect(JSON.stringify(response.body)).not.toContain(company.id); }); + it("uses the configured auth origin for self-hosted OAuth callbacks", async () => { + vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://public.paperclip.example"); + vi.stubEnv("PAPERCLIP_AUTH_PUBLIC_BASE_URL", "https://auth.paperclip.example"); + const app = createRouteApp(db); + + const response = await request(app).get("/api/tools/oauth/client-metadata").expect(200); + + expect(response.body.redirect_uris).toEqual([ + "https://auth.paperclip.example/api/tools/oauth/callback", + ]); + }); + it("uses the managed runtime origin when no explicit callback origin is configured", async () => { vi.stubEnv("PAPERCLIP_PUBLIC_URL", ""); vi.stubEnv("PAPERCLIP_AUTH_PUBLIC_BASE_URL", ""); diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 817a5a0f10..e02a24932e 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -39,6 +39,7 @@ import { secretService } from "../services/secrets.ts"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; const originalSecretsProviderEnv = process.env.PAPERCLIP_SECRETS_PROVIDER; +const originalPaperclipApiUrlEnv = process.env.PAPERCLIP_API_URL; if (!embeddedPostgresSupport.supported) { console.warn( @@ -51,6 +52,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { let tempDb: Awaited> | null = null; beforeAll(async () => { + process.env.PAPERCLIP_API_URL = "http://localhost:3100"; tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-service-"); db = createDb(tempDb.connectionString); }, 20_000); @@ -86,6 +88,11 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { afterAll(async () => { await tempDb?.cleanup(); + if (originalPaperclipApiUrlEnv === undefined) { + delete process.env.PAPERCLIP_API_URL; + } else { + process.env.PAPERCLIP_API_URL = originalPaperclipApiUrlEnv; + } }); async function seedFixture(opts?: { diff --git a/server/src/app.ts b/server/src/app.ts index 851399c992..3c0b284341 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -19,6 +19,7 @@ import { } from "./services/company-import-transfers.js"; import { companyTransferRunService } from "./services/company-transfer-runs.js"; import { healthRoutes } from "./routes/health.js"; +import { cloudRuntimeIdentityMiddleware } from "./middleware/cloud-runtime-identity.js"; import { cloudRoutes } from "./routes/cloud.js"; import { companyRoutes } from "./routes/companies.js"; import { companySkillRoutes } from "./routes/company-skills.js"; @@ -364,6 +365,7 @@ export async function createApp( bindHost: opts.bindHost, }), ); + app.use(cloudRuntimeIdentityMiddleware(db)); // Connection-intent tools carry their own short-lived, run-bound bearer and // must be reachable by remote adapters that intentionally do not receive an // agent API key. Every request revalidates the active heartbeat row. diff --git a/server/src/index.ts b/server/src/index.ts index d114eb8815..7d12d95cfa 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -101,6 +101,7 @@ import { finalizeServerShutdown, loadWithoutCoordinatedShutdownSignalHooks, } from "./shutdown.js"; +import { initializeCloudRuntimeIdentity } from "./services/cloud-runtime-identity.js"; import { systemdNotify } from "./services/systemd-notify.js"; import { flushInFlightRunLogMirrors } from "./services/run-log-store.js"; import { @@ -562,6 +563,12 @@ export async function startServer(): Promise { startupDbInfo = { mode: "embedded-postgres", dataDir, port }; } + // A claimed warm-pool stack may restart while its provider environment still + // names the pool host. Restore the signed, durable identity before Better + // Auth, routes, or child-runtime configuration capture any public URL. + const restoredCloudRuntimeIdentity = await initializeCloudRuntimeIdentity(db as any); + if (restoredCloudRuntimeIdentity) config = loadConfig(); + if (config.deploymentMode === "local_trusted" && !isLoopbackHost(config.host)) { throw new Error( `local_trusted mode requires loopback host binding (received: ${config.host}). ` + diff --git a/server/src/middleware/cloud-runtime-identity.ts b/server/src/middleware/cloud-runtime-identity.ts new file mode 100644 index 0000000000..f439491f71 --- /dev/null +++ b/server/src/middleware/cloud-runtime-identity.ts @@ -0,0 +1,33 @@ +import type { RequestHandler } from "express"; +import type { Db } from "@paperclipai/db"; +import { logger } from "./logger.js"; +import { + applyCloudRuntimeIdentityAssertion, + CLOUD_RUNTIME_IDENTITY_HEADER, +} from "../services/cloud-runtime-identity.js"; + +/** + * Accepts Cloud's signed identity only on the existing bootstrap health call. + * The JWS is sufficient authorization; the browser-facing proxy strips this + * header, and possession of the shared tenant-session token cannot mint it. + */ +export function cloudRuntimeIdentityMiddleware(db: Db): RequestHandler { + return async (req, res, next) => { + const assertion = req.get(CLOUD_RUNTIME_IDENTITY_HEADER)?.trim(); + if (!assertion) { + next(); + return; + } + if (req.method !== "GET" || req.path !== "/api/health") { + res.status(400).json({ error: "cloud_runtime_identity_wrong_endpoint" }); + return; + } + try { + await applyCloudRuntimeIdentityAssertion({ db, compactJws: assertion }); + next(); + } catch (error) { + logger.warn({ err: error }, "Rejected Cloud runtime identity assertion"); + res.status(401).json({ error: "invalid_cloud_runtime_identity" }); + } + }; +} diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 139cc1bbf6..a6052861cc 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -57,6 +57,7 @@ import { tooManyRequests } from "../errors.js"; import { getHiddenSettings } from "../services/settings-visibility.js"; +import { runtimeCanonicalOrigin } from "../services/cloud-runtime-identity.js"; /** * Floor: when the hosting operator hides the Instance Access surface @@ -153,6 +154,8 @@ function requestBaseUrl(req: Request) { } function resolveBaseUrl(req: Request, authPublicBaseUrl?: string): string { + const runtimeOrigin = runtimeCanonicalOrigin(); + if (runtimeOrigin) return runtimeOrigin; if (authPublicBaseUrl) return authPublicBaseUrl.replace(/\/+$/, ""); return requestBaseUrl(req); } diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 6cc8a4a4b5..90c36e625d 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -12,6 +12,7 @@ import { isCloudManagedInstance, type CloudInstanceEnv, } from "../services/cloud-instance.js"; +import { getCloudRuntimeIdentity } from "../services/cloud-runtime-identity.js"; import { getHiddenSettings } from "../services/settings-visibility.js"; import { inspectDatabaseBackupHealth, @@ -88,12 +89,19 @@ function redactedDatabaseBackupHealth(databaseBackup: DatabaseBackupHealthStatus function getCloudHealthStatus(env: CloudInstanceEnv) { const context = getCloudStackContext(env); if (!context) return undefined; + const runtimeIdentity = env === process.env ? getCloudRuntimeIdentity() : null; return { managed: true as const, managedBy: "paperclip-cloud" as const, stackSlug: context.stackSlug, cloudBaseUrl: context.cloudOrigin, + ...(runtimeIdentity ? { + runtimeIdentity: { + canonicalOrigin: runtimeIdentity.canonicalOrigin, + stackSlug: runtimeIdentity.stackSlug, + }, + } : {}), }; } diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index b76638af13..2eb41454a1 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -60,6 +60,7 @@ import { type PaperclipCloudConnector, paperclipCloudConnectorCapabilitiesFromEnv, } from "../services/paperclip-cloud-connector.js"; +import { runtimeCanonicalOrigin } from "../services/cloud-runtime-identity.js"; import { completePaperclipCloudConnectorEnrollment, loadPaperclipCloudConnectorIdentity, @@ -298,12 +299,14 @@ export function toolAccessRoutes( } function configuredPublicBaseUrl() { + const runtimeOrigin = runtimeCanonicalOrigin(); + if (runtimeOrigin) return runtimeOrigin; const raw = ( - process.env.PAPERCLIP_PUBLIC_URL?.trim() - || process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() + process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() || process.env.BETTER_AUTH_URL?.trim() || process.env.BETTER_AUTH_BASE_URL?.trim() || options.authPublicBaseUrl?.trim() + || process.env.PAPERCLIP_PUBLIC_URL?.trim() || process.env.PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL?.trim() ); if (!raw) return null; diff --git a/server/src/services/cloud-runtime-identity.test.ts b/server/src/services/cloud-runtime-identity.test.ts new file mode 100644 index 0000000000..bdfffb47e5 --- /dev/null +++ b/server/src/services/cloud-runtime-identity.test.ts @@ -0,0 +1,123 @@ +import { generateKeyPairSync, sign, type KeyObject } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + CLOUD_RUNTIME_IDENTITY_AUDIENCE, + CLOUD_RUNTIME_IDENTITY_ISSUER, + CLOUD_RUNTIME_IDENTITY_JWS_TYPE, + runtimeCanonicalOrigin, + runtimePublicOrigin, + verifyCloudRuntimeIdentityAssertion, +} from "./cloud-runtime-identity.js"; + +const STACK_ID = "stack-pool-123"; +const POOL_ORIGIN = "https://pool-123.staging.paperclip.app"; +const CANONICAL_ORIGIN = "https://gonzo.staging.paperclip.app"; +const NOW = new Date("2099-01-01T00:00:00.000Z"); +const pair = generateKeyPairSync("ed25519"); +const publicJwk = { + ...pair.publicKey.export({ format: "jwk" }), + kid: "runtime-identity-test-key", + use: "sig", + alg: "EdDSA", +}; +const env = { + PAPERCLIP_CLOUD_STACK_ID: STACK_ID, + PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS: JSON.stringify({ keys: [publicJwk] }), +} as NodeJS.ProcessEnv; + +function encodeJson(value: Record) { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function assertion(input: { + claims?: Record; + header?: Record; + signingKey?: KeyObject; +} = {}) { + const iat = Math.floor(NOW.getTime() / 1000); + const header = encodeJson({ + alg: "EdDSA", + typ: CLOUD_RUNTIME_IDENTITY_JWS_TYPE, + kid: publicJwk.kid, + ...input.header, + }); + const payload = encodeJson({ + v: 1, + iss: CLOUD_RUNTIME_IDENTITY_ISSUER, + aud: CLOUD_RUNTIME_IDENTITY_AUDIENCE, + sub: STACK_ID, + claimId: "pool-entry-123", + previousOrigin: POOL_ORIGIN, + canonicalOrigin: CANONICAL_ORIGIN, + stackSlug: "gonzo", + iat, + exp: iat + 300, + ...input.claims, + }); + const signature = sign( + null, + Buffer.from(`${header}.${payload}`, "ascii"), + input.signingKey ?? pair.privateKey, + ).toString("base64url"); + return `${header}.${payload}.${signature}`; +} + +function verifyAssertion(compactJws: string, overrides: Partial = {}) { + return verifyCloudRuntimeIdentityAssertion({ + compactJws, + env: { ...env, ...overrides }, + now: NOW, + expectedPreviousOrigin: POOL_ORIGIN, + }); +} + +describe("verifyCloudRuntimeIdentityAssertion", () => { + it("preserves distinct self-hosted public and authentication origins", () => { + const selfHostedEnv = { + PAPERCLIP_PUBLIC_URL: "https://app.example.test", + PAPERCLIP_AUTH_PUBLIC_BASE_URL: "https://auth.example.test", + PAPERCLIP_API_URL: "https://api.example.test", + } as NodeJS.ProcessEnv; + + expect(runtimePublicOrigin(selfHostedEnv)).toBe("https://app.example.test"); + expect(runtimeCanonicalOrigin()).toBeNull(); + }); + + it("accepts a Cloud-signed claim for this exact stack and pool origin", () => { + expect(verifyAssertion(assertion())).toMatchObject({ + sub: STACK_ID, + claimId: "pool-entry-123", + canonicalOrigin: CANONICAL_ORIGIN, + stackSlug: "gonzo", + }); + }); + + it("rejects forged signatures and unknown signing keys", () => { + const attacker = generateKeyPairSync("ed25519"); + expect(() => verifyAssertion(assertion({ signingKey: attacker.privateKey }))).toThrow("signature is invalid"); + expect(() => verifyAssertion(assertion({ header: { kid: "unknown" } }))).toThrow("unknown signing key"); + }); + + it.each([ + ["expired", { exp: Math.floor(NOW.getTime() / 1000) - 1 }], + ["wrong audience", { aud: "someone-else" }], + ["cross-stack", { sub: "stack-someone-else" }], + ["wrong pool origin", { previousOrigin: "https://someone-else.staging.paperclip.app" }], + ["non-HTTPS destination", { canonicalOrigin: "http://gonzo.staging.paperclip.app" }], + ["path-bearing destination", { canonicalOrigin: `${CANONICAL_ORIGIN}/GON` }], + ["slug mismatch", { stackSlug: "kermit" }], + ["partial claims", { claimId: undefined }], + ])("rejects %s assertions", (_label, claims) => { + expect(() => verifyAssertion(assertion({ claims }))).toThrow(); + }); + + it("rejects an altered signed destination", () => { + const valid = assertion(); + const [header, payload, signature] = valid.split("."); + const altered = encodeJson({ + ...JSON.parse(Buffer.from(payload!, "base64url").toString("utf8")), + canonicalOrigin: "https://attacker.example.com", + }); + expect(() => verifyAssertion(`${header}.${altered}.${signature}`)).toThrow("signature is invalid"); + }); +}); diff --git a/server/src/services/cloud-runtime-identity.ts b/server/src/services/cloud-runtime-identity.ts new file mode 100644 index 0000000000..5e1b8b0dae --- /dev/null +++ b/server/src/services/cloud-runtime-identity.ts @@ -0,0 +1,429 @@ +import { createPublicKey, timingSafeEqual, verify, type JsonWebKey } from "node:crypto"; +import { eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { instanceSettings } from "@paperclipai/db"; + +export const CLOUD_RUNTIME_IDENTITY_HEADER = "x-paperclip-cloud-runtime-identity"; +export const CLOUD_RUNTIME_IDENTITY_AUDIENCE = "paperclip-runtime-identity/v1"; +export const CLOUD_RUNTIME_IDENTITY_ISSUER = "paperclip-cloud"; +export const CLOUD_RUNTIME_IDENTITY_JWS_TYPE = "paperclip-cloud-runtime-identity+jwt"; + +const SINGLETON_KEY = "cloud-runtime-identity/v1"; +const MAX_ASSERTION_LIFETIME_SECONDS = 10 * 60; +const MAX_CLOCK_SKEW_SECONDS = 30; +const STACK_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + +type PersistedRuntimeIdentity = { + stackId: string; + claimId: string; + previousOrigin: string; + canonicalOrigin: string; + stackSlug: string; + appliedAt: Date; +}; + +type RuntimeIdentityDb = Pick; + +export type CloudRuntimeIdentitySnapshot = { + stackId: string; + claimId: string; + previousOrigin: string; + canonicalOrigin: string; + stackSlug: string; + appliedAt: Date; +}; + +export type RuntimeIdentityClaims = { + v: 1; + iss: typeof CLOUD_RUNTIME_IDENTITY_ISSUER; + aud: typeof CLOUD_RUNTIME_IDENTITY_AUDIENCE; + sub: string; + claimId: string; + previousOrigin: string; + canonicalOrigin: string; + stackSlug: string; + iat: number; + exp: number; +}; + +let initialized = false; +let startupOrigin: string | null = null; +let currentIdentity: CloudRuntimeIdentitySnapshot | null = null; + +function nonEmpty(value: string | undefined): string | null { + const normalized = value?.trim(); + return normalized ? normalized : null; +} + +function exactHttpsOrigin(value: unknown): string | null { + if (typeof value !== "string" || value.length === 0 || value.length > 2048) return null; + try { + const parsed = new URL(value); + if ( + parsed.protocol !== "https:" + || parsed.username + || parsed.password + || parsed.pathname !== "/" + || parsed.search + || parsed.hash + || parsed.origin !== value + ) { + return null; + } + return parsed.origin; + } catch { + return null; + } +} + +function configuredStartupOrigin(env: NodeJS.ProcessEnv): string | null { + const candidate = nonEmpty(env.PAPERCLIP_PUBLIC_URL) + ?? nonEmpty(env.PAPERCLIP_AUTH_PUBLIC_BASE_URL) + ?? nonEmpty(env.PAPERCLIP_API_URL); + return candidate ? exactHttpsOrigin(candidate) : null; +} + +function snapshot(row: PersistedRuntimeIdentity): CloudRuntimeIdentitySnapshot { + return { + stackId: row.stackId, + claimId: row.claimId, + previousOrigin: row.previousOrigin, + canonicalOrigin: row.canonicalOrigin, + stackSlug: row.stackSlug, + appliedAt: row.appliedAt, + }; +} + +function parsePersistedIdentity(row: { + general: Record; + createdAt: Date; +}): PersistedRuntimeIdentity { + const value = row.general; + if ( + value.v !== 1 + || typeof value.stackId !== "string" + || typeof value.claimId !== "string" + || typeof value.previousOrigin !== "string" + || typeof value.canonicalOrigin !== "string" + || typeof value.stackSlug !== "string" + ) { + throw new Error("Persisted Cloud runtime identity is malformed"); + } + return { + stackId: value.stackId, + claimId: value.claimId, + previousOrigin: value.previousOrigin, + canonicalOrigin: value.canonicalOrigin, + stackSlug: value.stackSlug, + appliedAt: row.createdAt, + }; +} + +async function readPersistedIdentity(db: RuntimeIdentityDb): Promise { + const row = await db + .select({ + general: instanceSettings.general, + createdAt: instanceSettings.createdAt, + }) + .from(instanceSettings) + .where(eq(instanceSettings.singletonKey, SINGLETON_KEY)) + .limit(1) + .then((rows) => rows[0] ?? null); + return row ? parsePersistedIdentity(row) : null; +} + +function applyCompatibilityEnvironment(identity: CloudRuntimeIdentitySnapshot, env: NodeJS.ProcessEnv) { + const hostname = new URL(identity.canonicalOrigin).hostname; + env.PAPERCLIP_PUBLIC_URL = identity.canonicalOrigin; + env.PAPERCLIP_AUTH_PUBLIC_BASE_URL = identity.canonicalOrigin; + env.PAPERCLIP_API_URL = identity.canonicalOrigin; + env.PAPERCLIP_PRIMARY_HOST = hostname; + env.PAPERCLIP_STACK_SLUG = identity.stackSlug; + + const existingCandidates = (() => { + try { + const parsed = JSON.parse(env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON ?? "[]"); + return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === "string") : []; + } catch { + return []; + } + })(); + env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = JSON.stringify([ + identity.canonicalOrigin, + ...existingCandidates.filter((candidate) => candidate !== identity.canonicalOrigin), + ]); +} + +function assertPersistedIdentityMatchesStack(row: PersistedRuntimeIdentity, env: NodeJS.ProcessEnv) { + const configuredStackId = nonEmpty(env.PAPERCLIP_CLOUD_STACK_ID); + if (!configuredStackId || configuredStackId !== row.stackId) { + throw new Error("Persisted Cloud runtime identity does not match PAPERCLIP_CLOUD_STACK_ID"); + } + if (!exactHttpsOrigin(row.previousOrigin) || !exactHttpsOrigin(row.canonicalOrigin)) { + throw new Error("Persisted Cloud runtime identity contains an invalid origin"); + } + if (!STACK_SLUG_PATTERN.test(row.stackSlug) || new URL(row.canonicalOrigin).hostname.split(".")[0] !== row.stackSlug) { + throw new Error("Persisted Cloud runtime identity contains an invalid stack slug"); + } +} + +/** Load the durable claim before auth, routes, and child-runtime configuration. */ +export async function initializeCloudRuntimeIdentity( + db: Db, + env: NodeJS.ProcessEnv = process.env, +): Promise { + startupOrigin = configuredStartupOrigin(env); + // Self-hosted servers have no Cloud stack identity to restore. Avoid touching + // the singleton table on that path; besides keeping the feature inert, this + // preserves lightweight startup/test database seams that intentionally do + // not construct a database client. + if (!nonEmpty(env.PAPERCLIP_CLOUD_STACK_ID)) { + initialized = true; + currentIdentity = null; + return null; + } + const row = await readPersistedIdentity(db); + initialized = true; + if (!row) { + currentIdentity = null; + return null; + } + assertPersistedIdentityMatchesStack(row, env); + currentIdentity = snapshot(row); + applyCompatibilityEnvironment(currentIdentity, env); + return currentIdentity; +} + +export function getCloudRuntimeIdentity(): CloudRuntimeIdentitySnapshot | null { + return currentIdentity ? { ...currentIdentity } : null; +} + +/** The live canonical origin, falling back to startup configuration off Cloud. */ +export function runtimePublicOrigin(env: NodeJS.ProcessEnv = process.env): string | null { + if (env === process.env && currentIdentity) return currentIdentity.canonicalOrigin; + const candidate = nonEmpty(env.PAPERCLIP_PUBLIC_URL) + ?? nonEmpty(env.PAPERCLIP_AUTH_PUBLIC_BASE_URL) + ?? nonEmpty(env.PAPERCLIP_API_URL); + if (!candidate) return null; + try { + return new URL(candidate).origin; + } catch { + return null; + } +} + +/** The asserted Cloud origin only. Callers retain their non-Cloud precedence. */ +export function runtimeCanonicalOrigin(): string | null { + return currentIdentity?.canonicalOrigin ?? null; +} + +function decodeJsonPart(part: string, label: string): Record { + if (!/^[A-Za-z0-9_-]+$/.test(part)) throw new Error(`Cloud runtime identity has an invalid ${label}`); + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(part, "base64url").toString("utf8")); + } catch { + throw new Error(`Cloud runtime identity has an invalid ${label}`); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Cloud runtime identity has an invalid ${label}`); + } + return parsed as Record; +} + +function publicKeyForKid(env: NodeJS.ProcessEnv, kid: string) { + const raw = nonEmpty(env.PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS); + if (!raw) throw new Error("PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS is not configured"); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error("PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS is invalid"); + } + const keys = parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as { keys?: unknown }).keys + : undefined; + if (!Array.isArray(keys)) throw new Error("PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS is invalid"); + const matches = keys.filter((candidate): candidate is JsonWebKey & { kid: string } => { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return false; + const key = candidate as JsonWebKey & { kid?: unknown }; + return key.kid === kid; + }); + if (matches.length !== 1) throw new Error("Cloud runtime identity uses an unknown signing key"); + const jwk = matches[0]; + if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || jwk.use !== "sig" || jwk.alg !== "EdDSA" || !jwk.x || jwk.d) { + throw new Error("Cloud runtime identity signing key is invalid"); + } + return createPublicKey({ key: jwk, format: "jwk" }); +} + +function verifyClaims(input: { + compactJws: string; + env: NodeJS.ProcessEnv; + now: Date; +}): RuntimeIdentityClaims { + const parts = input.compactJws.split("."); + if (parts.length !== 3 || parts.some((part) => part.length === 0)) { + throw new Error("Cloud runtime identity assertion is not a compact JWS"); + } + const [encodedHeader, encodedPayload, encodedSignature] = parts; + const header = decodeJsonPart(encodedHeader, "protected header"); + if ( + header.alg !== "EdDSA" + || header.typ !== CLOUD_RUNTIME_IDENTITY_JWS_TYPE + || typeof header.kid !== "string" + || !header.kid + ) { + throw new Error("Cloud runtime identity protected header is invalid"); + } + const key = publicKeyForKid(input.env, header.kid); + const signature = Buffer.from(encodedSignature, "base64url"); + const signingInput = Buffer.from(`${encodedHeader}.${encodedPayload}`, "ascii"); + if (!verify(null, signingInput, key, signature)) { + throw new Error("Cloud runtime identity signature is invalid"); + } + + const payload = decodeJsonPart(encodedPayload, "payload"); + const nowSeconds = Math.floor(input.now.getTime() / 1000); + if ( + payload.v !== 1 + || payload.iss !== CLOUD_RUNTIME_IDENTITY_ISSUER + || payload.aud !== CLOUD_RUNTIME_IDENTITY_AUDIENCE + || typeof payload.sub !== "string" + || typeof payload.claimId !== "string" + || typeof payload.previousOrigin !== "string" + || typeof payload.canonicalOrigin !== "string" + || typeof payload.stackSlug !== "string" + || typeof payload.iat !== "number" + || !Number.isInteger(payload.iat) + || typeof payload.exp !== "number" + || !Number.isInteger(payload.exp) + ) { + throw new Error("Cloud runtime identity claims are incomplete"); + } + if ( + payload.exp <= nowSeconds + || payload.iat > nowSeconds + MAX_CLOCK_SKEW_SECONDS + || payload.exp <= payload.iat + || payload.exp - payload.iat > MAX_ASSERTION_LIFETIME_SECONDS + ) { + throw new Error("Cloud runtime identity assertion is expired or has an invalid lifetime"); + } + return payload as RuntimeIdentityClaims; +} + +/** Verify that an assertion is signed for this exact, still-unclaimed instance. */ +export function verifyCloudRuntimeIdentityAssertion(input: { + compactJws: string; + env?: NodeJS.ProcessEnv; + now?: Date; + expectedPreviousOrigin: string | null; +}): RuntimeIdentityClaims { + const env = input.env ?? process.env; + const claims = verifyClaims({ compactJws: input.compactJws, env, now: input.now ?? new Date() }); + const configuredStackId = nonEmpty(env.PAPERCLIP_CLOUD_STACK_ID); + if (!configuredStackId || claims.sub !== configuredStackId) { + throw new Error("Cloud runtime identity stack does not match this instance"); + } + const previousOrigin = exactHttpsOrigin(claims.previousOrigin); + const canonicalOrigin = exactHttpsOrigin(claims.canonicalOrigin); + if (!previousOrigin || !canonicalOrigin || previousOrigin !== input.expectedPreviousOrigin) { + throw new Error("Cloud runtime identity previous or canonical origin is invalid"); + } + if ( + !STACK_SLUG_PATTERN.test(claims.stackSlug) + || new URL(canonicalOrigin).hostname.split(".")[0] !== claims.stackSlug + || claims.claimId.length > 256 + || claims.claimId.trim() !== claims.claimId + || !claims.claimId + ) { + throw new Error("Cloud runtime identity destination is invalid"); + } + return claims; +} + +function assertionsEqual(row: PersistedRuntimeIdentity, claims: RuntimeIdentityClaims): boolean { + const left = Buffer.from(JSON.stringify([ + row.stackId, + row.claimId, + row.previousOrigin, + row.canonicalOrigin, + row.stackSlug, + ])); + const right = Buffer.from(JSON.stringify([ + claims.sub, + claims.claimId, + claims.previousOrigin, + claims.canonicalOrigin, + claims.stackSlug, + ])); + return left.length === right.length && timingSafeEqual(left, right); +} + +/** Verify and durably apply the one-time Cloud claim assertion. */ +export async function applyCloudRuntimeIdentityAssertion(input: { + db: Db; + compactJws: string; + env?: NodeJS.ProcessEnv; + now?: Date; +}): Promise { + const env = input.env ?? process.env; + if (!initialized) throw new Error("Cloud runtime identity provider is not initialized"); + const claims = verifyCloudRuntimeIdentityAssertion({ + compactJws: input.compactJws, + env, + now: input.now, + // After a natural restart the provider env may already be canonical, but + // an identical retry of the original claim is still safe and idempotent. + // The durable row preserves the pool origin that assertion had to match + // on first application. + expectedPreviousOrigin: currentIdentity?.previousOrigin ?? startupOrigin, + }); + const previousOrigin = claims.previousOrigin; + const canonicalOrigin = claims.canonicalOrigin; + + const row = await input.db.transaction(async (tx) => { + const existing = await readPersistedIdentity(tx); + if (existing) { + if (!assertionsEqual(existing, claims)) { + throw new Error("Cloud runtime identity is already claimed by another assertion"); + } + return existing; + } + + const now = input.now ?? new Date(); + await tx + .insert(instanceSettings) + .values({ + singletonKey: SINGLETON_KEY, + general: { + v: 1, + stackId: claims.sub, + claimId: claims.claimId, + previousOrigin, + canonicalOrigin, + stackSlug: claims.stackSlug, + }, + experimental: {}, + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing({ target: instanceSettings.singletonKey }); + const durable = await readPersistedIdentity(tx); + if (!durable || !assertionsEqual(durable, claims)) { + throw new Error("Cloud runtime identity is already claimed by another assertion"); + } + return durable; + }); + + currentIdentity = snapshot(row); + applyCompatibilityEnvironment(currentIdentity, env); + return { ...currentIdentity }; +} + +/** Test seam for modules that intentionally share a process. */ +export function resetCloudRuntimeIdentityForTests() { + initialized = false; + startupOrigin = null; + currentIdentity = null; +} diff --git a/server/src/services/paperclip-cloud-connector-enrollment.ts b/server/src/services/paperclip-cloud-connector-enrollment.ts index 103c34aedf..ba5de762ba 100644 --- a/server/src/services/paperclip-cloud-connector-enrollment.ts +++ b/server/src/services/paperclip-cloud-connector-enrollment.ts @@ -11,6 +11,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSy import path from "node:path"; import { resolvePaperclipInstanceRoot } from "../home-paths.js"; +import { runtimePublicOrigin } from "./cloud-runtime-identity.js"; const IDENTITY_VERSION = 1; const ENROLLMENT_FILE = "paperclip-cloud-connector.json"; @@ -90,7 +91,8 @@ export function paperclipCloudConnectorEnrollmentStatus( origins: [], }; } - const publicOrigin = env.PAPERCLIP_PUBLIC_URL ? normalizeInstanceOrigin(env.PAPERCLIP_PUBLIC_URL) : undefined; + const resolvedOrigin = runtimePublicOrigin(env); + const publicOrigin = resolvedOrigin ? normalizeInstanceOrigin(resolvedOrigin) : undefined; return { configured: true, status: "active", diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index f09ac75821..9414d5ea45 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -77,6 +77,7 @@ import { import { queueIssueAssignmentWakeup, type IssueAssignmentWakeupDeps } from "./issue-assignment-wakeup.js"; import { logActivity } from "./activity-log.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; +import { runtimePublicOrigin } from "./cloud-runtime-identity.js"; const OPEN_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked"]; const LIVE_HEARTBEAT_RUN_STATUSES = ["queued", "running", "scheduled_retry"]; @@ -102,6 +103,12 @@ const WEEKDAY_INDEX: Record = { Sat: 6, }; +export function routineWebhookUrl(publicId: string): string { + const baseUrl = runtimePublicOrigin() ?? process.env.PAPERCLIP_API_URL?.trim(); + if (!baseUrl) throw new Error("PAPERCLIP_API_URL is required to create a routine webhook"); + return `${baseUrl.replace(/\/+$/, "")}/api/routine-triggers/public/${publicId}/fire`; +} + type ExecutionIssueTransientFailureStatus = (typeof EXECUTION_ISSUE_TRANSIENT_FAILURE_STATUSES)[number]; function executionIssueTransientFailureReason(status: ExecutionIssueTransientFailureStatus) { @@ -2438,7 +2445,7 @@ export function routineService( const created = await createWebhookSecret(routine.companyId, routine.id, actor); secretId = created.secret.id; secretMaterial = { - webhookUrl: `${process.env.PAPERCLIP_API_URL}/api/routine-triggers/public/${publicId}/fire`, + webhookUrl: routineWebhookUrl(publicId), webhookSecret: created.secretValue, }; } @@ -2621,7 +2628,7 @@ export function routineService( return { trigger: trigger as RoutineTrigger, secretMaterial: { - webhookUrl: `${process.env.PAPERCLIP_API_URL}/api/routine-triggers/public/${existing.publicId}/fire`, + webhookUrl: routineWebhookUrl(existing.publicId), webhookSecret: secretValue, }, revision, @@ -2701,7 +2708,7 @@ export function routineService( secretId: created.secret.id, secretMaterial: { triggerId: trigger.id, - webhookUrl: `${process.env.PAPERCLIP_API_URL}/api/routine-triggers/public/${publicId}/fire`, + webhookUrl: routineWebhookUrl(publicId), webhookSecret: created.secretValue, }, }); From 174e35a1443de27090a2ff9a6efa3b9e9624dfc2 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 3 Sep 2026 10:02:42 -0700 Subject: [PATCH 2/6] fix(server): stop paging Sentry for supervised boot races in managed cloud (#12772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The server refuses to boot when its database is not migrated, or when an authenticated public deployment has no `DATABASE_URL`. These refusals are deliberate and correct. > - In managed cloud, a supervisor creates each stack, migrates its fresh database, applies configuration, and restarts the app. The app container often boots before those steps finish. > - Each early boot hits one of the two refusals, exits, and captures the refusal to Sentry. One fleet build batch produces hundreds of identical expected events. Real errors get buried. > - This pull request classifies exactly those two refusals as expected transients when `PAPERCLIP_CLOUD_API_ORIGIN` marks a supervised deployment, and skips only the Sentry capture for them. > - The benefit is a clean error signal: expected provisioning noise stops, and every real failure still reports. ## Linked Issues or Issue Description **What happened?** A managed-cloud stack boots its app container before the supervisor migrates the empty database or finishes applying configuration. The container refuses to start, crash-loops briefly, and converges after the supervisor restarts it. Every refused boot sends an error event to Sentry. A batch of new stacks produces hundreds of these expected events. **Expected behavior** The refusal logs and exits nonzero, so the supervisor can act. Sentry receives no event for an expected provisioning transient. Sentry still receives events for real failures: schema drift, malformed configuration, and every refusal outside managed cloud. **Steps to reproduce** 1. Set `PAPERCLIP_MIGRATION_AUTO_APPLY=false`, `PAPERCLIP_MIGRATION_PROMPT=never`, `SENTRY_DSN`, and `PAPERCLIP_CLOUD_API_ORIGIN`. 2. Point `DATABASE_URL` at an empty database and start the server. 3. The server refuses to start. Before this change it also captures the refusal to Sentry on every boot. **Deployment mode** Authenticated public (managed cloud). ## What Changed - New `server/src/startup-refusals.ts`: a `StartupRefusalError` class for refusals whose remedy belongs to the deployment supervisor, `migrationRefusalError()` to classify a pending-migrations refusal (zero applied migrations = never migrated = supervised transient; any applied history = drift = plain always-reported `Error`), and `shouldReportStartupFailure()` for the capture decision. - `server/src/index.ts`: the pending-migrations refusal uses the classifier; the missing-`DATABASE_URL` refusal under the authenticated-public contract becomes a `StartupRefusalError` (the malformed-URL refusal stays a plain `Error`); the startup crash handler consults `shouldReportStartupFailure()` before `captureException`. Logging and the nonzero exit are unchanged. - New `server/src/__tests__/startup-refusals.test.ts` covering the classification and decision matrix, including the unchanged self-hosted paths. ## Verification - `pnpm vitest run src/__tests__/startup-refusals.test.ts` — 7 passed. - Review the decision matrix in the test file: refusals report when `PAPERCLIP_CLOUD_API_ORIGIN` is absent or blank; non-refusal errors and non-`Error` throwables always report; drift always reports. ## Risks - Low risk. The change only skips a Sentry capture in one narrow, marker-gated case. Boot behavior, logging, and the exit code do not change. - Self-hosted deployments do not set `PAPERCLIP_CLOUD_API_ORIGIN`, so their reporting is unchanged, and the tests pin that. - A supervised deployment with a genuinely stuck migration runner loses per-boot Sentry events for that stack. The supervisor's own health checks and monitoring own that signal, and the container logs still carry the refusal. ## Model Used Claude Fable 5 (claude-fable-5) via Claude Code, extended thinking with tool use. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above (none found for startup Sentry suppression) - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (module doc comment; no user-facing docs affected) - [x] I have considered and documented any risks above --- server/src/__tests__/startup-refusals.test.ts | 75 +++++++++++++++++++ server/src/index.ts | 32 +++++++- server/src/startup-refusals.ts | 73 ++++++++++++++++++ 3 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 server/src/__tests__/startup-refusals.test.ts create mode 100644 server/src/startup-refusals.ts diff --git a/server/src/__tests__/startup-refusals.test.ts b/server/src/__tests__/startup-refusals.test.ts new file mode 100644 index 0000000000..763b91ac32 --- /dev/null +++ b/server/src/__tests__/startup-refusals.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; + +import { + StartupRefusalError, + migrationRefusalError, + shouldReportStartupFailure, +} from "../startup-refusals.ts"; + +describe("migrationRefusalError", () => { + const message = "PostgreSQL has pending migrations (…). Refusing to start."; + + it("classifies a never-migrated database as a supervised-transient refusal", () => { + const error = migrationRefusalError({ appliedMigrations: [], tableCount: 0 }, message); + expect(error).toBeInstanceOf(StartupRefusalError); + expect((error as StartupRefusalError).kind).toBe("schema-not-yet-migrated"); + expect(error.message).toContain("Refusing to start"); + }); + + it("keeps pending migrations on a migrated database as a plain, always-reported error", () => { + const error = migrationRefusalError( + { appliedMigrations: ["0000_init.sql"], tableCount: 41 }, + message, + ); + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(StartupRefusalError); + }); + + it("treats an empty journal beside existing tables as drift, not a fresh database", () => { + // A wiped or never-populated migration journal next to real tables is + // a persistent failure; it must keep reporting. + const error = migrationRefusalError({ appliedMigrations: [], tableCount: 17 }, message); + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(StartupRefusalError); + }); +}); + +describe("shouldReportStartupFailure", () => { + const refusal = new StartupRefusalError( + "database-contract-unmet", + "authenticated public deployments require DATABASE_URL", + ); + + it("always reports non-refusal startup failures, managed cloud or not", () => { + expect(shouldReportStartupFailure(new Error("boom"), {})).toBe(true); + expect( + shouldReportStartupFailure(new Error("boom"), { + PAPERCLIP_CLOUD_API_ORIGIN: "https://cloud.example.com", + }), + ).toBe(true); + }); + + it("reports supervised-transient refusals outside managed-cloud deployments", () => { + expect(shouldReportStartupFailure(refusal, {})).toBe(true); + }); + + it("suppresses supervised-transient refusals when a cloud supervisor owns the deployment", () => { + expect( + shouldReportStartupFailure(refusal, { + PAPERCLIP_CLOUD_API_ORIGIN: "https://cloud.example.com", + }), + ).toBe(false); + }); + + it("treats a blank cloud origin as unset", () => { + expect(shouldReportStartupFailure(refusal, { PAPERCLIP_CLOUD_API_ORIGIN: " " })).toBe(true); + }); + + it("reports non-Error throwables unconditionally", () => { + expect( + shouldReportStartupFailure("string failure", { + PAPERCLIP_CLOUD_API_ORIGIN: "https://cloud.example.com", + }), + ).toBe(true); + }); +}); diff --git a/server/src/index.ts b/server/src/index.ts index 7d12d95cfa..d35563665a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -35,6 +35,11 @@ import detectPort from "detect-port"; import { createApp } from "./app.js"; import { loadConfig } from "./config.js"; import { logger } from "./middleware/logger.js"; +import { + StartupRefusalError, + migrationRefusalError, + shouldReportStartupFailure, +} from "./startup-refusals.js"; import { getManagedInstanceConfig, type ManagedInstanceConfig, @@ -243,12 +248,20 @@ export async function startServer(): Promise { const apply = autoApply ? true : await promptApplyMigrations(state.pendingMigrations); if (!apply) { - throw new Error( + // A database with zero applied migrations and zero tables has + // never been migrated: under a managed-cloud supervisor that is + // the expected first-boot race (the harness migrates and + // restarts), so the refusal carries the supervised-transient + // class. Applied history — or pre-existing tables beside an empty + // journal — means drift and keeps the plain, always-reported + // Error. + throw migrationRefusalError( + state, `${label} has pending migrations (${formatPendingMigrationSummary(state.pendingMigrations)}). ` + "Refusing to start against a stale schema. Run pnpm db:migrate or set PAPERCLIP_MIGRATION_AUTO_APPLY=true.", ); } - + logger.info({ pendingMigrations: state.pendingMigrations }, `Applying ${state.pendingMigrations.length} pending migrations for ${label}`); await applyPendingMigrations(connectionString); return "applied (pending migrations)"; @@ -268,7 +281,13 @@ export async function startServer(): Promise { return; } if (!config.databaseUrl) { - throw new Error( + // Under a managed-cloud supervisor a missing DATABASE_URL on boot + // is the config-application race (the container can start before + // the staged variables land), not operator error — the supervisor + // restarts once the config holds. A malformed value below is a + // real misconfiguration and stays an always-reported Error. + throw new StartupRefusalError( + "database-contract-unmet", "authenticated public deployments require DATABASE_URL or config.database.connectionString; refusing embedded PostgreSQL fallback", ); } @@ -1817,7 +1836,12 @@ function isMainModule(metaUrl: string): boolean { if (isMainModule(import.meta.url)) { void startServer().catch(async (err) => { logger.error({ err }, "Paperclip server failed to start"); - captureException(err); + // Supervised-transient refusals in managed-cloud deployments are an + // expected provisioning phase (see startup-refusals.ts) — they log + // and exit nonzero but do not page Sentry. + if (shouldReportStartupFailure(err)) { + captureException(err); + } await shutdownSentry(); process.exit(1); }); diff --git a/server/src/startup-refusals.ts b/server/src/startup-refusals.ts new file mode 100644 index 0000000000..08c3153ffd --- /dev/null +++ b/server/src/startup-refusals.ts @@ -0,0 +1,73 @@ +/** + * Deliberate boot refusals and whether they should page Sentry. + * + * Some startup preconditions the server must not repair itself: an + * unmigrated schema when auto-apply is off (the operator's migration + * runner owns schema), or an unmet database contract for authenticated + * public deployments. The server logs the refusal and exits nonzero so + * whatever supervises the deployment can act. + * + * In supervised managed-cloud deployments (`PAPERCLIP_CLOUD_API_ORIGIN` + * set), two of these refusals are a routine provisioning phase rather + * than an incident: a freshly created stack's app container boots + * before the harness has migrated the empty database or finished + * applying its committed configuration, crash-loops briefly, and is + * restarted by the harness once the precondition holds. Reporting every + * such boot to Sentry buries real errors under hundreds of expected + * events per fleet build batch, so the crash handler skips the capture + * for exactly this class — the refusal still logs and still exits + * nonzero. Everywhere else (self-hosted, local dev) reporting is + * unchanged. + */ + +export type StartupRefusalKind = + | "schema-not-yet-migrated" + | "database-contract-unmet"; + +/** + * A boot refusal whose remedy belongs to the deployment's supervisor. + * Only refusals that are *expected transients* under managed-cloud + * provisioning use this class; refusals that always indicate operator + * error (schema drift, a malformed DATABASE_URL) stay plain `Error`s. + */ +export class StartupRefusalError extends Error { + readonly kind: StartupRefusalKind; + + constructor(kind: StartupRefusalKind, message: string) { + super(message); + this.name = "StartupRefusalError"; + this.kind = kind; + } +} + +/** + * Chooses the error class for a pending-migrations refusal. A database + * with zero applied migrations AND zero tables is not stale — it has + * never been migrated at all, which under a supervisor means "not yet" + * rather than "drifted". Any applied history, or any pre-existing + * tables beside an empty or wiped migration journal, makes pending + * migrations a drift signal that must keep reporting. + */ +export function migrationRefusalError( + state: { appliedMigrations: string[]; tableCount: number }, + message: string, +): Error { + const neverMigrated = state.appliedMigrations.length === 0 && state.tableCount === 0; + return neverMigrated + ? new StartupRefusalError("schema-not-yet-migrated", message) + : new Error(message); +} + +/** + * Whether a startup failure should be captured to Sentry. Everything + * reports except a supervised-transient refusal in a managed-cloud + * deployment. + */ +export function shouldReportStartupFailure( + error: unknown, + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (!(error instanceof StartupRefusalError)) return true; + const cloudOrigin = env.PAPERCLIP_CLOUD_API_ORIGIN?.trim(); + return !cloudOrigin; +} From d1d396c44a3f0d6cd6d419705ef81b6aab9aac9d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:12:21 -0700 Subject: [PATCH 3/6] chore(deps): bump @mdxeditor/editor from 4.2.1 to 4.2.3 (#12564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@mdxeditor/editor](https://github.com/mdx-editor/editor) from 4.2.1 to 4.2.3.
Release notes

Sourced from @​mdxeditor/editor's releases.

v4.2.3

4.2.3 (2026-08-27)

Bug Fixes

  • prevent JSX kind mismatches from crashing the editor (a5f5763)

v4.2.2

4.2.2 (2026-08-26)

Bug Fixes

  • prevent stale table actions from crashing (1b43250), closes #961
Commits
  • 300dfd5 Merge pull request #963 from mdx-editor/petyosi/jsx-kind-mismatch-policy
  • a5f5763 fix: prevent JSX kind mismatches from crashing the editor
  • 1b43250 fix: prevent stale table actions from crashing
  • See full diff in compare view

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 110 +++++++++++++++++++++++++++++------------------- ui/package.json | 2 +- 2 files changed, 67 insertions(+), 45 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2e36cb61a..bab129d832 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1027,8 +1027,8 @@ importers: specifier: 0.48.0 version: 0.48.0(typescript@7.0.2) '@mdxeditor/editor': - specifier: ^4.2.1 - version: 4.2.1(@codemirror/language@6.12.4)(@lezer/highlight@1.2.3)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)(yjs@13.6.29) + specifier: ^4.2.3 + version: 4.2.3(@codemirror/language@6.12.4)(@lezer/highlight@1.2.3)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)(yjs@13.6.29) '@paperclipai/adapter-claude-local': specifier: workspace:* version: link:../packages/adapters/claude-local @@ -1740,8 +1740,8 @@ packages: '@codemirror/language@6.12.4': resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==} - '@codemirror/legacy-modes@6.5.3': - resolution: {integrity: sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==} + '@codemirror/legacy-modes@6.5.4': + resolution: {integrity: sha512-/cZr6qZyl08iYNLGsJ862CXXNI51LryRFRE40ejgoIjXZz0C1rGkD3/Ek5jM/8w1ceRjqtt4qx/KLMh4zBTgew==} '@codemirror/lint@6.9.4': resolution: {integrity: sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==} @@ -1758,6 +1758,12 @@ packages: '@codemirror/state@6.7.1': resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==} + '@codemirror/state@6.7.2': + resolution: {integrity: sha512-U3RiPX62Wl/Gx4ftQ7UxLlloSfsFTQqKa+7vFBYteZGCzkp6oBqcsD7iSwniWRGobCAmDcwZSY+Six3+3ztdfg==} + + '@codemirror/view@6.43.11': + resolution: {integrity: sha512-2+esucbQX6wB2JYi1eDvdCPFTA31BN8oSy6xCmk3G6CloV11yOvEjYk+gH7kLrP0MuHG94E8WDhjs5oMiu3+Wg==} + '@codemirror/view@6.43.9': resolution: {integrity: sha512-sTuUzTpPMFebRhg6dawChoKKgndIwfjmJgKVxBefPElcU2NwQ6AFroupk0SFqEerQyZOGRfDNnSN8Dw/lMAsXw==} @@ -2847,8 +2853,8 @@ packages: '@lezer/markdown@1.7.2': resolution: {integrity: sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==} - '@lezer/php@1.0.5': - resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==} + '@lezer/php@1.0.6': + resolution: {integrity: sha512-QJ2xNXPmsm/Z3IpThq8fnJrEIVpxhsIoj6GZBW0JYEd/l9zxpJZW216X4fzqQY4vgpdGIumypvI4uQjd4tnYtw==} '@lezer/python@1.1.19': resolution: {integrity: sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==} @@ -2868,14 +2874,17 @@ packages: '@marijn/find-cluster-break@1.0.3': resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==} + '@marijn/find-cluster-break@1.0.4': + resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==} + '@mdx-js/react@3.1.1': resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==} peerDependencies: '@types/react': '>=16' react: ^19.2.8 - '@mdxeditor/editor@4.2.1': - resolution: {integrity: sha512-s2mgq7xvL958hfb0Atbto3nq3Uwo8qxBwvtbUkIWb+HF56cnDMLUSzW7CxBlCH8ZmcSk/IDAEBWhakxcnb2nYQ==} + '@mdxeditor/editor@4.2.3': + resolution: {integrity: sha512-5uz0vjZ8YhFFk2k23BJKPZyvAmV6dHXW3vJ9YzDeM6o7gz0G7V0Dpjj3ngmuez/9+lkx6wRlnaRfLWRqzQopAA==} engines: {node: '>=16'} peerDependencies: react: ^19.2.8 @@ -7499,8 +7508,8 @@ packages: peerDependencies: react: ^19.2.8 - react-hook-form@7.86.0: - resolution: {integrity: sha512-4kbWJrh5jPZt1+YqVcXcGKffGcXV/XVbozknLh0Yjh0KhpoAkus21TAQhzRYqNwFkkObmnSvRlZZ3GT+ehoIrA==} + react-hook-form@7.87.0: + resolution: {integrity: sha512-zhFzWvLxNHH+8839OnZcUxgMZw88ah2jZWDWvKWgF3Tpbnd0vKL+dlcuU3nZVWESZQjd81EW8K+wU+cYfYAc0w==} engines: {node: '>=18.0.0'} peerDependencies: react: ^19.2.8 @@ -9100,8 +9109,8 @@ snapshots: '@codemirror/commands@6.11.0': dependencies: '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.9 + '@codemirror/state': 6.7.2 + '@codemirror/view': 6.43.11 '@lezer/common': 1.5.2 '@codemirror/lang-angular@0.1.4': @@ -9122,7 +9131,7 @@ snapshots: dependencies: '@codemirror/autocomplete': 6.20.3 '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.1 + '@codemirror/state': 6.7.2 '@lezer/common': 1.5.2 '@lezer/css': 1.3.6 @@ -9130,7 +9139,7 @@ snapshots: dependencies: '@codemirror/autocomplete': 6.20.3 '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.1 + '@codemirror/state': 6.7.2 '@lezer/common': 1.5.2 '@lezer/go': 1.0.1 @@ -9140,8 +9149,8 @@ snapshots: '@codemirror/lang-css': 6.3.1 '@codemirror/lang-javascript': 6.2.5 '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.9 + '@codemirror/state': 6.7.2 + '@codemirror/view': 6.43.11 '@lezer/common': 1.5.2 '@lezer/css': 1.3.6 '@lezer/html': 1.3.13 @@ -9166,8 +9175,8 @@ snapshots: '@codemirror/autocomplete': 6.20.3 '@codemirror/lang-html': 6.4.12 '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.9 + '@codemirror/state': 6.7.2 + '@codemirror/view': 6.43.11 '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.10 @@ -9190,8 +9199,8 @@ snapshots: '@codemirror/autocomplete': 6.20.3 '@codemirror/lang-html': 6.4.12 '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.9 + '@codemirror/state': 6.7.2 + '@codemirror/view': 6.43.11 '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.10 @@ -9201,8 +9210,8 @@ snapshots: '@codemirror/autocomplete': 6.20.3 '@codemirror/lang-html': 6.4.12 '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.9 + '@codemirror/state': 6.7.2 + '@codemirror/view': 6.43.11 '@lezer/common': 1.5.2 '@lezer/markdown': 1.7.2 @@ -9210,15 +9219,15 @@ snapshots: dependencies: '@codemirror/lang-html': 6.4.12 '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.1 + '@codemirror/state': 6.7.2 '@lezer/common': 1.5.2 - '@lezer/php': 1.0.5 + '@lezer/php': 1.0.6 '@codemirror/lang-python@6.2.1': dependencies: '@codemirror/autocomplete': 6.20.3 '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.1 + '@codemirror/state': 6.7.2 '@lezer/common': 1.5.2 '@lezer/python': 1.1.19 @@ -9231,7 +9240,7 @@ snapshots: dependencies: '@codemirror/lang-css': 6.3.1 '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.1 + '@codemirror/state': 6.7.2 '@lezer/common': 1.5.2 '@lezer/sass': 1.1.0 @@ -9239,7 +9248,7 @@ snapshots: dependencies: '@codemirror/autocomplete': 6.20.3 '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.1 + '@codemirror/state': 6.7.2 '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.10 @@ -9264,8 +9273,8 @@ snapshots: dependencies: '@codemirror/autocomplete': 6.20.3 '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.9 + '@codemirror/state': 6.7.2 + '@codemirror/view': 6.43.11 '@lezer/common': 1.5.2 '@lezer/xml': 1.0.6 @@ -9273,7 +9282,7 @@ snapshots: dependencies: '@codemirror/autocomplete': 6.20.3 '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.1 + '@codemirror/state': 6.7.2 '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.10 @@ -9303,7 +9312,7 @@ snapshots: '@codemirror/lang-xml': 6.1.0 '@codemirror/lang-yaml': 6.1.3 '@codemirror/language': 6.12.4 - '@codemirror/legacy-modes': 6.5.3 + '@codemirror/legacy-modes': 6.5.4 '@codemirror/language@6.12.4': dependencies: @@ -9314,7 +9323,7 @@ snapshots: '@lezer/lr': 1.4.10 style-mod: 4.1.3 - '@codemirror/legacy-modes@6.5.3': + '@codemirror/legacy-modes@6.5.4': dependencies: '@codemirror/language': 6.12.4 @@ -9333,8 +9342,8 @@ snapshots: '@codemirror/merge@6.12.2': dependencies: '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.9 + '@codemirror/state': 6.7.2 + '@codemirror/view': 6.43.11 '@lezer/highlight': 1.2.3 style-mod: 4.1.3 @@ -9348,6 +9357,17 @@ snapshots: dependencies: '@marijn/find-cluster-break': 1.0.3 + '@codemirror/state@6.7.2': + dependencies: + '@marijn/find-cluster-break': 1.0.4 + + '@codemirror/view@6.43.11': + dependencies: + '@codemirror/state': 6.7.2 + crelt: 1.0.7 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + '@codemirror/view@6.43.9': dependencies: '@codemirror/state': 6.7.1 @@ -10220,7 +10240,7 @@ snapshots: '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 - '@lezer/php@1.0.5': + '@lezer/php@1.0.6': dependencies: '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 @@ -10258,20 +10278,22 @@ snapshots: '@marijn/find-cluster-break@1.0.3': {} + '@marijn/find-cluster-break@1.0.4': {} + '@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8)': dependencies: '@types/mdx': 2.0.14 '@types/react': 19.2.18 react: 19.2.8 - '@mdxeditor/editor@4.2.1(@codemirror/language@6.12.4)(@lezer/highlight@1.2.3)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)(yjs@13.6.29)': + '@mdxeditor/editor@4.2.3(@codemirror/language@6.12.4)(@lezer/highlight@1.2.3)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)(yjs@13.6.29)': dependencies: '@codemirror/commands': 6.11.0 '@codemirror/lang-markdown': 6.5.2 '@codemirror/language-data': 6.5.2 '@codemirror/merge': 6.12.2 - '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.9 + '@codemirror/state': 6.7.2 + '@codemirror/view': 6.43.11 '@lexical/clipboard': 0.48.0(typescript@7.0.2) '@lexical/extension': 0.48.0(typescript@7.0.2) '@lexical/history': 0.48.0(typescript@7.0.2) @@ -10294,7 +10316,7 @@ snapshots: '@radix-ui/react-toolbar': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-tooltip': 1.2.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) classnames: 2.5.1 - cm6-theme-basic-light: 0.2.0(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.9)(@lezer/highlight@1.2.3) + cm6-theme-basic-light: 0.2.0(@codemirror/language@6.12.4)(@codemirror/state@6.7.2)(@codemirror/view@6.43.11)(@lezer/highlight@1.2.3) codemirror: 6.0.2 downshift: 7.6.2(react@19.2.8) js-yaml: 4.3.1 @@ -10323,7 +10345,7 @@ snapshots: micromark-util-symbol: 2.0.1 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - react-hook-form: 7.86.0(react@19.2.8) + react-hook-form: 7.87.0(react@19.2.8) unidiff: 1.0.4 transitivePeerDependencies: - '@codemirror/language' @@ -12773,11 +12795,11 @@ snapshots: clsx@2.1.1: {} - cm6-theme-basic-light@0.2.0(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.9)(@lezer/highlight@1.2.3): + cm6-theme-basic-light@0.2.0(@codemirror/language@6.12.4)(@codemirror/state@6.7.2)(@codemirror/view@6.43.11)(@lezer/highlight@1.2.3): dependencies: '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.9 + '@codemirror/state': 6.7.2 + '@codemirror/view': 6.43.11 '@lezer/highlight': 1.2.3 cmdk@1.1.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): @@ -15106,7 +15128,7 @@ snapshots: react: 19.2.8 scheduler: 0.27.0 - react-hook-form@7.86.0(react@19.2.8): + react-hook-form@7.87.0(react@19.2.8): dependencies: react: 19.2.8 diff --git a/ui/package.json b/ui/package.json index 2c467096c9..d9b1bf32a5 100644 --- a/ui/package.json +++ b/ui/package.json @@ -35,7 +35,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@lexical/link": "0.48.0", - "@mdxeditor/editor": "^4.2.1", + "@mdxeditor/editor": "^4.2.3", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-cloud": "workspace:*", From 6826452856d395836bb0063a91943f63ac422dc4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:16:58 -0700 Subject: [PATCH 4/6] chore(deps): bump sharp from 0.35.3 to 0.35.4 (#12563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [sharp](https://github.com/lovell/sharp) from 0.35.3 to 0.35.4.
Release notes

Sourced from sharp's releases.

v0.35.4

https://github.com/lovell/sharp-libvips/releases/tag/v1.3.3

v0.35.4-rc.0

Commits
  • 7f1a0a2 Release v0.35.4
  • f927818 Upgrade to sharp-libvips v1.3.3
  • e802092 Prerelease v0.35.4-rc.0
  • e13eb2f CI: Fix wasm32 build (#4589)
  • a82a0b3 Upgrade to libvips v8.18.6
  • 8044fe4 Bound resize dimensions to coordinate limit
  • 147f859 Docs: changelog entries for #4578 #4584
  • ee5bfb8 Tests: use yauzl directly rather than via extract-zip wrapper
  • 7a77889 Bump uraimo/run-on-arch-action from 3.1.0 to 3.2.0 (#4588)
  • ea5bef2 Improve support for input Streams finishing before output is requested (#4584)
  • Additional commits viewable in compare view

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 250 ++++++++++++++++++++++---------------------- server/package.json | 2 +- 2 files changed, 126 insertions(+), 126 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bab129d832..f658db0b99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -948,8 +948,8 @@ importers: specifier: ^13.1.3 version: 13.1.3 sharp: - specifier: ^0.35.3 - version: 0.35.3(@types/node@24.13.3) + specifier: ^0.35.4 + version: 0.35.4(@types/node@24.13.3) ssh2: specifier: ^1.17.0 version: 1.17.0 @@ -1937,8 +1937,8 @@ packages: '@emnapi/runtime@1.11.0': resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==} - '@emnapi/runtime@1.11.2': - resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} '@emnapi/runtime@1.9.2': resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} @@ -2454,144 +2454,144 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.3': - resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.3': - resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.3': - resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.2': - resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.2': - resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.2': - resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linux-arm@1.3.2': - resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} cpu: [arm] os: [linux] - '@img/sharp-libvips-linux-ppc64@1.3.2': - resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} cpu: [ppc64] os: [linux] - '@img/sharp-libvips-linux-riscv64@1.3.2': - resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} cpu: [riscv64] os: [linux] - '@img/sharp-libvips-linux-s390x@1.3.2': - resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} cpu: [s390x] os: [linux] - '@img/sharp-libvips-linux-x64@1.3.2': - resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} cpu: [x64] os: [linux] - '@img/sharp-libvips-linuxmusl-arm64@1.3.2': - resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linuxmusl-x64@1.3.2': - resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} cpu: [x64] os: [linux] - '@img/sharp-linux-arm64@0.35.3': - resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - '@img/sharp-linux-arm@0.35.3': - resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] - '@img/sharp-linux-ppc64@0.35.3': - resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] - '@img/sharp-linux-riscv64@0.35.3': - resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] - '@img/sharp-linux-s390x@0.35.3': - resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] - '@img/sharp-linux-x64@0.35.3': - resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - '@img/sharp-linuxmusl-arm64@0.35.3': - resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - '@img/sharp-linuxmusl-x64@0.35.3': - resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - '@img/sharp-wasm32@0.35.3': - resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.3': - resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.3': - resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.3': - resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.3': - resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -7756,8 +7756,8 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - sharp@0.35.3: - resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -9512,7 +9512,7 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.11.2': + '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 optional: true @@ -9811,108 +9811,108 @@ snapshots: '@img/colour@1.1.0': {} - '@img/sharp-darwin-arm64@0.35.3': + '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-arm64': 1.3.3 optional: true - '@img/sharp-darwin-x64@0.35.3': + '@img/sharp-darwin-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.3 optional: true - '@img/sharp-freebsd-wasm32@0.35.3': + '@img/sharp-freebsd-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.3 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.2': + '@img/sharp-libvips-darwin-arm64@1.3.3': optional: true - '@img/sharp-libvips-darwin-x64@1.3.2': + '@img/sharp-libvips-darwin-x64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm64@1.3.2': + '@img/sharp-libvips-linux-arm64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm@1.3.2': + '@img/sharp-libvips-linux-arm@1.3.3': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.2': + '@img/sharp-libvips-linux-ppc64@1.3.3': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.2': + '@img/sharp-libvips-linux-riscv64@1.3.3': optional: true - '@img/sharp-libvips-linux-s390x@1.3.2': + '@img/sharp-libvips-linux-s390x@1.3.3': optional: true - '@img/sharp-libvips-linux-x64@1.3.2': + '@img/sharp-libvips-linux-x64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.2': + '@img/sharp-libvips-linuxmusl-x64@1.3.3': optional: true - '@img/sharp-linux-arm64@0.35.3': + '@img/sharp-linux-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.3 optional: true - '@img/sharp-linux-arm@0.35.3': + '@img/sharp-linux-arm@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.3 optional: true - '@img/sharp-linux-ppc64@0.35.3': + '@img/sharp-linux-ppc64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.3 optional: true - '@img/sharp-linux-riscv64@0.35.3': + '@img/sharp-linux-riscv64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.3 optional: true - '@img/sharp-linux-s390x@0.35.3': + '@img/sharp-linux-s390x@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.3 optional: true - '@img/sharp-linux-x64@0.35.3': + '@img/sharp-linux-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.3 optional: true - '@img/sharp-linuxmusl-arm64@0.35.3': + '@img/sharp-linuxmusl-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 optional: true - '@img/sharp-linuxmusl-x64@0.35.3': + '@img/sharp-linuxmusl-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 optional: true - '@img/sharp-wasm32@0.35.3': + '@img/sharp-wasm32@0.35.4': dependencies: - '@emnapi/runtime': 1.11.2 + '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.3': + '@img/sharp-webcontainers-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.3 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-win32-arm64@0.35.3': + '@img/sharp-win32-arm64@0.35.4': optional: true - '@img/sharp-win32-ia32@0.35.3': + '@img/sharp-win32-ia32@0.35.4': optional: true - '@img/sharp-win32-x64@0.35.3': + '@img/sharp-win32-x64@0.35.4': optional: true '@isaacs/cliui@8.0.2': @@ -12250,7 +12250,7 @@ snapshots: '@types/sharp@0.32.0(@types/node@24.13.3)': dependencies: - sharp: 0.35.3(@types/node@24.13.3) + sharp: 0.35.4(@types/node@24.13.3) transitivePeerDependencies: - '@types/node' @@ -15447,37 +15447,37 @@ snapshots: setprototypeof@1.2.0: {} - sharp@0.35.3(@types/node@24.13.3): + sharp@0.35.4(@types/node@24.13.3): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.3 - '@img/sharp-darwin-x64': 0.35.3 - '@img/sharp-freebsd-wasm32': 0.35.3 - '@img/sharp-libvips-darwin-arm64': 1.3.2 - '@img/sharp-libvips-darwin-x64': 1.3.2 - '@img/sharp-libvips-linux-arm': 1.3.2 - '@img/sharp-libvips-linux-arm64': 1.3.2 - '@img/sharp-libvips-linux-ppc64': 1.3.2 - '@img/sharp-libvips-linux-riscv64': 1.3.2 - '@img/sharp-libvips-linux-s390x': 1.3.2 - '@img/sharp-libvips-linux-x64': 1.3.2 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 - '@img/sharp-libvips-linuxmusl-x64': 1.3.2 - '@img/sharp-linux-arm': 0.35.3 - '@img/sharp-linux-arm64': 0.35.3 - '@img/sharp-linux-ppc64': 0.35.3 - '@img/sharp-linux-riscv64': 0.35.3 - '@img/sharp-linux-s390x': 0.35.3 - '@img/sharp-linux-x64': 0.35.3 - '@img/sharp-linuxmusl-arm64': 0.35.3 - '@img/sharp-linuxmusl-x64': 0.35.3 - '@img/sharp-webcontainers-wasm32': 0.35.3 - '@img/sharp-win32-arm64': 0.35.3 - '@img/sharp-win32-ia32': 0.35.3 - '@img/sharp-win32-x64': 0.35.3 + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 '@types/node': 24.13.3 shebang-command@2.0.0: diff --git a/server/package.json b/server/package.json index 498feca490..46cb5c1476 100644 --- a/server/package.json +++ b/server/package.json @@ -80,7 +80,7 @@ "pino": "^10.0.0", "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", - "sharp": "^0.35.3", + "sharp": "^0.35.4", "ssh2": "^1.17.0", "ws": "^8.21.3", "zod": "^4.4.3" From 39898ab22fee8e393620908c9d245f94c936fd08 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:23:42 -0700 Subject: [PATCH 5/6] chore(deps): bump paperclipai/paperclip/.github/workflows/pr-trusted.yml from 39b8ee2960541d14b380f95365deecba6723d9bd to f038633bf5b04163ff985ef0542876bd9f455379 (#12562) Bumps [paperclipai/paperclip/.github/workflows/pr-trusted.yml](https://github.com/paperclipai/paperclip) from 39b8ee2960541d14b380f95365deecba6723d9bd to f038633bf5b04163ff985ef0542876bd9f455379.
Changelog

Sourced from paperclipai/paperclip/.github/workflows/pr-trusted.yml's changelog.

Release Automation Setup

This document covers the GitHub and npm setup required for the current Paperclip release model:

  • automatic canaries from master
  • manual stable promotion from a chosen source ref
  • npm trusted publishing via GitHub OIDC
  • protected release infrastructure in a public repository

Repo-side files that depend on this setup:

  • .github/workflows/release.yml
  • .github/CODEOWNERS

Note:

  • the release workflows intentionally use pnpm install --no-frozen-lockfile
  • this matches the repo's current policy where pnpm-lock.yaml is refreshed by GitHub automation after manifest changes land on master
  • the publish jobs then restore pnpm-lock.yaml before running scripts/release.sh, so the release script still sees a clean worktree

1. Merge the Repo Changes First

Before touching GitHub or npm settings, merge the release automation code so the referenced workflow filenames already exist on the default branch.

Required files:

  • .github/workflows/release.yml
  • .github/CODEOWNERS

2. Configure npm Trusted Publishing

Do this for every public package that Paperclip publishes.

At minimum that includes:

  • paperclipai
  • @paperclipai/server
  • @paperclipai/ui
  • public packages under packages/

2.1. In npm, open each package settings page

For each package:

  1. open npm as an owner of the package
  2. go to the package settings / publishing access area
  3. add a trusted publisher for the GitHub repository paperclipai/paperclip

2.2. Add one trusted publisher entry per package

... (truncated)

Commits

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ebecc3c651..3883269071 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -10,4 +10,4 @@ permissions: jobs: ci: - uses: paperclipai/paperclip/.github/workflows/pr-trusted.yml@39b8ee2960541d14b380f95365deecba6723d9bd + uses: paperclipai/paperclip/.github/workflows/pr-trusted.yml@f038633bf5b04163ff985ef0542876bd9f455379 From 6e50ca9d0ae5936d4966acde271b791fbf198f6e Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:39:18 -0500 Subject: [PATCH 6/6] ci(runner): prepare target lockfile once for paid validation (#12774) ## Thinking Path > - The trusted target-branch runner workflow checks out PR code before paid tests. > - PR policy intentionally forbids manual lockfile commits. > - Some runner changes legitimately alter pnpm patch hashes. > - Frozen installs therefore fail before test selection. > - Resolve one script-disabled lockfile from the authorized immutable target SHA and distribute it by exact artifact ID and digest. > - Keep provider credentials and trusted reporting outside this resolution job. ## Linked Issues or Issue Description Target-branch paid runner campaigns currently fail frozen install when a PR changes pnpm patch content, even though ordinary PR CI regenerates the lockfile. ## What Changed - Added one credential-free target-lock job that resolves the authorized immutable target SHA with lifecycle scripts disabled. - Uploaded the resolved lockfile with its SHA-256 and restored it by exact artifact ID before every target-code frozen install. - Left trusted reporting and history jobs on the workflow SHA. - Changed the disabled-AWS fallback from unavailable ubuntu-latest-m to ubuntu-latest. ## Risks The workflow evaluates pnpm lockfile resolution from authorized target code. That job receives no provider credentials, disables lifecycle scripts, rejects unrelated workspace mutations, and exposes only a digest-verified lockfile artifact. Paid-secret jobs consume only that lockfile after exact artifact-ID and SHA-256 validation. ## Verification - Runner workflow-security focused tests pass. - actionlint passes. - Prettier and git diff checks pass. ## Model Used OpenAI Codex, GPT-5. ## Checklist - [x] Change is narrowly scoped to paid runner orchestration. - [x] Target lock resolution has no provider credentials and disables lifecycle scripts. - [x] Downloaded artifacts are selected by exact artifact ID and verified by SHA-256. - [x] Trusted reporting and history jobs remain on the workflow SHA. --- .github/workflows/runner-full-stack-e2e.yml | 120 +++++++++++++++++++- tests/runner-e2e/README.md | 26 +++-- tests/runner-e2e/SECURITY.md | 27 +++-- tests/runner-e2e/workflow-security.test.ts | 93 +++++++++++++-- 4 files changed, 236 insertions(+), 30 deletions(-) diff --git a/.github/workflows/runner-full-stack-e2e.yml b/.github/workflows/runner-full-stack-e2e.yml index 19bf0fda71..376bc16658 100644 --- a/.github/workflows/runner-full-stack-e2e.yml +++ b/.github/workflows/runner-full-stack-e2e.yml @@ -121,7 +121,7 @@ jobs: AWS_PAID_RUNNER_ENABLED: ${{ vars.RUNNER_E2E_AWS_ENABLED }} run: | set -euo pipefail - github_runner='ubuntu-latest-m' + github_runner='ubuntu-latest' aws_runner='runs-on/fleet=paperclip-public-pr-x64/env=public-ci' if [ "$AWS_PAID_RUNNER_ENABLED" = true ]; then @@ -140,9 +140,56 @@ jobs: echo '::notice title=Paid runner routing::RUNNER_E2E_AWS_ENABLED is not true; using the existing paid runner' fi + target_lock: + name: Resolve target pnpm lockfile + needs: authorize + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + outputs: + artifact_id: ${{ steps.upload.outputs.artifact-id }} + lock_sha256: ${{ steps.lock.outputs.sha256 }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.authorize.outputs.target_sha }} + persist-credentials: false + + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + with: + version: 9.15.4 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + + - name: Resolve target lockfile without lifecycle scripts + id: lock + run: | + set -euo pipefail + pnpm install --ignore-scripts --no-frozen-lockfile --lockfile-only + test -s pnpm-lock.yaml + unexpected="$(git status --short | awk '$2 != "pnpm-lock.yaml" { print }')" + if [ -n "$unexpected" ]; then + echo "Lockfile resolution changed files other than pnpm-lock.yaml:" >&2 + echo "$unexpected" >&2 + exit 1 + fi + echo "sha256=$(sha256sum pnpm-lock.yaml | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT" + + - name: Upload resolved target lockfile + id: upload + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: runner-e2e-target-pnpm-lock-${{ github.run_id }}-${{ github.run_attempt }} + path: pnpm-lock.yaml + retention-days: 30 + if-no-files-found: error + catalog: name: Validate catalog and select cells - needs: authorize + needs: [authorize, target_lock] if: github.event_name != 'schedule' || vars.RUNNER_FULL_STACK_E2E_NIGHTLY_ENABLED == 'true' runs-on: ubuntu-latest timeout-minutes: 10 @@ -160,6 +207,26 @@ jobs: ref: ${{ needs.authorize.outputs.target_sha }} persist-credentials: false + - name: Download resolved target lockfile + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + artifact-ids: ${{ needs.target_lock.outputs.artifact_id }} + path: ${{ runner.temp }}/runner-e2e-target-lock + + - name: Restore resolved target lockfile + env: + TARGET_SHA: ${{ needs.authorize.outputs.target_sha }} + EXPECTED_LOCK_SHA256: ${{ needs.target_lock.outputs.lock_sha256 }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$TARGET_SHA" + lock="$RUNNER_TEMP/runner-e2e-target-lock/pnpm-lock.yaml" + test -f "$lock" + test "$(find "$(dirname "$lock")" -type f | wc -l | tr -d ' ')" = 1 + test "$(sha256sum "$lock" | cut -d ' ' -f 1)" = "$EXPECTED_LOCK_SHA256" + cp "$lock" pnpm-lock.yaml + test "$(sha256sum pnpm-lock.yaml | cut -d ' ' -f 1)" = "$EXPECTED_LOCK_SHA256" + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 with: version: 9.15.4 @@ -249,7 +316,7 @@ jobs: daytona_image: name: Publish verified Daytona image - needs: [authorize, catalog] + needs: [authorize, target_lock, catalog] runs-on: ubuntu-latest timeout-minutes: 45 permissions: @@ -266,6 +333,26 @@ jobs: ref: ${{ needs.authorize.outputs.target_sha }} persist-credentials: false + - name: Download resolved target lockfile + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + artifact-ids: ${{ needs.target_lock.outputs.artifact_id }} + path: ${{ runner.temp }}/runner-e2e-target-lock + + - name: Restore resolved target lockfile + env: + TARGET_SHA: ${{ needs.authorize.outputs.target_sha }} + EXPECTED_LOCK_SHA256: ${{ needs.target_lock.outputs.lock_sha256 }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$TARGET_SHA" + lock="$RUNNER_TEMP/runner-e2e-target-lock/pnpm-lock.yaml" + test -f "$lock" + test "$(find "$(dirname "$lock")" -type f | wc -l | tr -d ' ')" = 1 + test "$(sha256sum "$lock" | cut -d ' ' -f 1)" = "$EXPECTED_LOCK_SHA256" + cp "$lock" pnpm-lock.yaml + test "$(sha256sum pnpm-lock.yaml | cut -d ' ' -f 1)" = "$EXPECTED_LOCK_SHA256" + - name: No Daytona image needed id: local_only if: needs.catalog.outputs.needs_daytona != 'true' @@ -355,7 +442,7 @@ jobs: test: name: ${{ matrix.executionId }} - needs: [authorize, catalog, daytona_image] + needs: [authorize, target_lock, catalog, daytona_image] # The authorize job selects only one of two literal, reviewed runner labels; # no dispatch input or repository variable can inject an arbitrary label. runs-on: ${{ needs.authorize.outputs.test_runner }} @@ -390,6 +477,26 @@ jobs: ref: ${{ needs.authorize.outputs.target_sha }} persist-credentials: false + - name: Download resolved target lockfile + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + artifact-ids: ${{ needs.target_lock.outputs.artifact_id }} + path: ${{ runner.temp }}/runner-e2e-target-lock + + - name: Restore resolved target lockfile + env: + TARGET_SHA: ${{ needs.authorize.outputs.target_sha }} + EXPECTED_LOCK_SHA256: ${{ needs.target_lock.outputs.lock_sha256 }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$TARGET_SHA" + lock="$RUNNER_TEMP/runner-e2e-target-lock/pnpm-lock.yaml" + test -f "$lock" + test "$(find "$(dirname "$lock")" -type f | wc -l | tr -d ' ')" = 1 + test "$(sha256sum "$lock" | cut -d ' ' -f 1)" = "$EXPECTED_LOCK_SHA256" + cp "$lock" pnpm-lock.yaml + test "$(sha256sum pnpm-lock.yaml | cut -d ' ' -f 1)" = "$EXPECTED_LOCK_SHA256" + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 with: version: 9.15.4 @@ -398,7 +505,10 @@ jobs: with: node-version: 24 - - run: pnpm install --frozen-lockfile + # This job receives provider credentials only in the final paid-test + # step. Keep target-selected dependency lifecycle code from running in + # the protected environment during setup. + - run: pnpm install --frozen-lockfile --ignore-scripts - name: Build runner TypeScript prerequisites run: pnpm --filter @paperclipai/paperclip-eval-kernel build diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index 29f8179757..24aa2f8f9a 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -269,12 +269,21 @@ auto-stop/archive/delete values remain as cancellation backstops. never runs for a pull request or ordinary push. Start the trusted workflow from the default branch. A CODEOWNER can set the optional `target_branch` input to any branch in `paperclipai/paperclip`. The authorization job resolves that -branch to one immutable commit before any checkout. Catalog, image, and paid -test jobs check out that exact commit. Report sanitization and AWS history -publication explicitly check out the trusted workflow commit. The workflow -definition, runner-group permission, and protected-environment deployment still -come from the default branch. Do not select the target branch in GitHub's **Use -workflow from** control. +branch to one immutable commit before any checkout. A separate credential-free +job checks out the resolved commit and regenerates `pnpm-lock.yaml` once with +`--ignore-scripts --no-frozen-lockfile --lockfile-only`. It uploads that exact +lockfile under a run-attempt-scoped artifact ID and records its SHA-256. Catalog, +image, and paid test jobs download the artifact by ID, verify its digest, and +restore it before setup or a frozen install. The paid test job disables +dependency lifecycle scripts, and provider secrets are introduced only in the +final test step. This permits an authorized target branch to exercise an +intentionally uncommitted workspace patch while keeping every target job on one +identical dependency resolution. Report sanitization and AWS history +publication do not consume the target lockfile; they explicitly check out and +install from the trusted workflow commit. The workflow definition, runner-group +permission, and protected-environment deployment still come from the default +branch. Do not select the target branch in GitHub's **Use workflow from** +control. Because this repository is public, manual campaigns fail before checkout unless the trusted workflow runs from the default branch and both the original actor @@ -311,8 +320,9 @@ only after the live acceptance ladder in the architecture plan is green. Set `RUNNER_E2E_AWS_ENABLED=true` to route paid cells to the repository-scoped ephemeral AWS RunsOn fleet selected by `runs-on/fleet=paperclip-public-pr-x64/env=public-ci`. Any other value retains -the existing `ubuntu-latest-m` target. Set `RUNNER_E2E_MAX_PARALLEL` to an -integer from 1–100 on AWS (default 100); use at least 71 to run the current +the standard GitHub-hosted `ubuntu-latest` target. Set +`RUNNER_E2E_MAX_PARALLEL` to an integer from 1–100 on AWS (default 100); use at +least 71 to run the current complete catalog in one wave. The fallback runner retains its 1–57 limit and default of 32. Multi-turn steps are sequential inside their cell while independent cells overlap. Artifacts and merged HTML/JUnit/normalized reports diff --git a/tests/runner-e2e/SECURITY.md b/tests/runner-e2e/SECURITY.md index d99294444c..d730ff4770 100644 --- a/tests/runner-e2e/SECURITY.md +++ b/tests/runner-e2e/SECURITY.md @@ -20,10 +20,18 @@ gh api users/LOGIN --jq '{login,id}' The paid workflows reject manual dispatches when the workflow definition does not come from the default branch. A trusted dispatcher may name any branch in `paperclipai/paperclip` as the code under test. The authorization job resolves -that branch through the GitHub API and passes only its immutable commit SHA to -the catalog, image, and paid test checkouts. Report sanitization and AWS history -publication explicitly use the trusted workflow commit. Never run the workflow -definition from the target branch. +that branch through the GitHub API and passes only its immutable commit SHA to a +credential-free target-lock job. That job checks out the commit, regenerates +`pnpm-lock.yaml` once with lifecycle scripts disabled and lockfile-only mode, +then uploads the file under a run-attempt-scoped artifact ID. Catalog, image, +and paid test jobs download that exact artifact by ID, verify its recorded +SHA-256, and restore it before setup or a frozen dependency install. The lock +resolver receives no provider credentials and must never run repository +lifecycle scripts. The paid test job also installs with lifecycle scripts +disabled, and provider secrets are scoped only to its final test step rather +than dependency setup. Report sanitization and AWS history publication +explicitly use the trusted workflow commit and do not consume the target +lockfile. Never run the workflow definition from the target branch. The workflows verify both the original actor and triggering actor for every scheduled or manual attempt, including human reruns. Every @@ -56,8 +64,8 @@ only `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY`, and organization-level Actions secrets: environment scoping is the boundary that prevents branch or pull-request jobs from requesting them. Require approval from an account in `RUNNER_E2E_ALLOWED_ACTOR_IDS` for this environment and -disable administrator bypass. The authorize, -catalog, image, report, history, and Pages jobs receive none of these secrets. +disable administrator bypass. The authorize, target-lock, catalog, image, +report, history, and Pages jobs receive none of these secrets. Each full-stack matrix cell receives only its selected profile credential, plus Daytona only for Daytona cells. Secret-bearing and OIDC jobs use frozen installs without a shared dependency cache. @@ -75,9 +83,10 @@ the actor gate, environment branch restriction, and protected default branch. When `RUNNER_E2E_AWS_ENABLED=true`, paid matrix cells use the exact RunsOn fleet selector `runs-on/fleet=paperclip-public-pr-x64/env=public-ci`, matching the AWS fleet selected by `pr-trusted.yml` only after its stable numeric-ID trust gate. -Any other or missing toggle value falls back to the existing `ubuntu-latest-m` -paid runner and its lower concurrency ceiling. The workflow chooses between -those two reviewed literal labels; it never evaluates a configured runner label. +Any other or missing toggle value falls back to the standard GitHub-hosted +`ubuntu-latest` runner and its lower concurrency ceiling. The workflow chooses +between those two reviewed literal labels; it never evaluates a configured +runner label. Keep both runner targets restricted to `paperclipai/paperclip` and workflows that independently authorize trusted source revisions. Never let a fork or diff --git a/tests/runner-e2e/workflow-security.test.ts b/tests/runner-e2e/workflow-security.test.ts index eb0745fb12..d9f23b786c 100644 --- a/tests/runner-e2e/workflow-security.test.ts +++ b/tests/runner-e2e/workflow-security.test.ts @@ -66,12 +66,16 @@ describe("public repository paid workflow security", () => { ); const authorizeJob = fullStack.slice( fullStack.indexOf(" authorize:"), + fullStack.indexOf(" target_lock:"), + ); + const targetLockJob = fullStack.slice( + fullStack.indexOf(" target_lock:"), fullStack.indexOf(" catalog:"), ); expect(authorizeJob).toContain( "aws_runner='runs-on/fleet=paperclip-public-pr-x64/env=public-ci'", ); - expect(authorizeJob).toContain("github_runner='ubuntu-latest-m'"); + expect(authorizeJob).toContain("github_runner='ubuntu-latest'"); expect(authorizeJob).toContain( "AWS_PAID_RUNNER_ENABLED: ${{ vars.RUNNER_E2E_AWS_ENABLED }}", ); @@ -82,13 +86,46 @@ describe("public repository paid workflow security", () => { "repos/$REPOSITORY/branches/$encoded_branch", ); expect(authorizeJob).toContain('echo "sha=$target_sha"'); + expect(authorizeJob).not.toContain("actions/checkout@"); + expect(authorizeJob).not.toContain("pnpm install"); + expect(targetLockJob).toContain("name: Resolve target pnpm lockfile"); + expect(targetLockJob).toContain("needs: authorize"); + expect(targetLockJob).toContain( + "ref: ${{ needs.authorize.outputs.target_sha }}", + ); + expect(targetLockJob).toContain("persist-credentials: false"); + expect(targetLockJob).toContain( + "pnpm install --ignore-scripts --no-frozen-lockfile --lockfile-only", + ); + expect(targetLockJob).toContain( + "artifact_id: ${{ steps.upload.outputs.artifact-id }}", + ); + expect(targetLockJob).toContain("lock_sha256:"); + expect(targetLockJob).toContain( + "runner-e2e-target-pnpm-lock-${{ github.run_id }}-${{ github.run_attempt }}", + ); + expect(targetLockJob).not.toContain("name: runner-e2e-paid"); + expect(targetLockJob).not.toMatch( + /(?:OPENAI|ANTHROPIC|OPENROUTER|DAYTONA)_API_KEY/, + ); expect(paidJob).toContain( "runs-on: ${{ needs.authorize.outputs.test_runner }}", ); - expect(paidJob).toContain("needs: [authorize, catalog, daytona_image]"); + expect(paidJob).toContain( + "needs: [authorize, target_lock, catalog, daytona_image]", + ); expect(paidJob).toContain("name: runner-e2e-paid"); expect(paidJob).toMatch( - /Reauthorize paid execution before provider access[\s\S]*actions\/checkout@[0-9a-f]{40}[\s\S]*persist-credentials: false/, + /Reauthorize paid execution before provider access[\s\S]*actions\/checkout@[0-9a-f]{40}[\s\S]*persist-credentials: false[\s\S]*Download resolved target lockfile/, + ); + const paidInstall = paidJob.indexOf( + "pnpm install --frozen-lockfile --ignore-scripts", + ); + const paidExecution = paidJob.indexOf("- name: Run paid cell"); + expect(paidInstall).toBeGreaterThan(0); + expect(paidExecution).toBeGreaterThan(paidInstall); + expect(paidJob.slice(0, paidExecution)).not.toMatch( + /secrets\.(?:OPENAI|ANTHROPIC|OPENROUTER|DAYTONA)_API_KEY/, ); expect(authorizeJob).toContain('echo "max_parallel_limit=100"'); expect(fullStack).toContain('[ "$MAX_PARALLEL_LIMIT" -gt 100 ]'); @@ -101,13 +138,51 @@ describe("public repository paid workflow security", () => { expect(fullStack).toContain( "cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' && inputs.target_branch != '' && inputs.target_branch != github.event.repository.default_branch }}", ); - expect( - fullStack.match( - /ref: \$\{\{ needs\.authorize\.outputs\.target_sha \}\}/g, + const targetCodeJobs = [ + fullStack.slice( + fullStack.indexOf(" catalog:"), + fullStack.indexOf(" daytona_image:"), ), - ).toHaveLength(3); + fullStack.slice( + fullStack.indexOf(" daytona_image:"), + fullStack.indexOf(" test:"), + ), + paidJob, + ]; + for (const targetCodeJob of targetCodeJobs) { + const checkout = targetCodeJob.indexOf("actions/checkout@"); + const downloadLock = targetCodeJob.indexOf( + "Download resolved target lockfile", + ); + const restoreLock = targetCodeJob.indexOf( + "Restore resolved target lockfile", + ); + const setupNode = targetCodeJob.indexOf("actions/setup-node@"); + const install = targetCodeJob.indexOf("pnpm install --frozen-lockfile"); + expect(checkout).toBeGreaterThan(0); + expect(downloadLock).toBeGreaterThan(checkout); + expect(restoreLock).toBeGreaterThan(downloadLock); + if (setupNode >= 0) { + expect(setupNode).toBeGreaterThan(restoreLock); + } + if (install >= 0) { + expect(install).toBeGreaterThan(restoreLock); + } + expect(targetCodeJob).toContain( + "artifact-ids: ${{ needs.target_lock.outputs.artifact_id }}", + ); + expect(targetCodeJob).toContain( + "EXPECTED_LOCK_SHA256: ${{ needs.target_lock.outputs.lock_sha256 }}", + ); + } + expect(fullStack.match(/Download resolved target lockfile/g)).toHaveLength( + 3, + ); + expect(fullStack.match(/Restore resolved target lockfile/g)).toHaveLength( + 3, + ); expect(fullStack.match(/ref: \$\{\{ github\.sha \}\}/g)).toHaveLength(2); - expect(fullStack.match(/persist-credentials: false/g)).toHaveLength(5); + expect(fullStack.match(/persist-credentials: false/g)).toHaveLength(6); expect(fullStack).not.toContain("ref: ${{ inputs.target_branch }}"); expect(fullStack).toContain( "PAPERCLIP_RUNNER_SOURCE_REVISION=${TARGET_SHA}", @@ -121,10 +196,12 @@ describe("public repository paid workflow security", () => { expect(reportJob).not.toContain( "ref: ${{ needs.authorize.outputs.target_sha }}", ); + expect(reportJob).not.toContain("Download resolved target lockfile"); expect(historyJob).toContain("ref: ${{ github.sha }}"); expect(historyJob).not.toContain( "ref: ${{ needs.authorize.outputs.target_sha }}", ); + expect(historyJob).not.toContain("Download resolved target lockfile"); for (const [secret, condition] of Object.entries({ OPENAI_API_KEY: "matrix.credentialName == 'OPENAI_API_KEY'", ANTHROPIC_API_KEY: "matrix.credentialName == 'ANTHROPIC_API_KEY'",