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 diff --git a/.github/workflows/runner-full-stack-e2e.yml b/.github/workflows/runner-full-stack-e2e.yml index 2cf62531fd..dc353c27c8 100644 --- a/.github/workflows/runner-full-stack-e2e.yml +++ b/.github/workflows/runner-full-stack-e2e.yml @@ -140,9 +140,56 @@ jobs: echo '::notice title=Paid runner routing::RUNNER_E2E_AWS_ENABLED is not true; using the proven GitHub-hosted 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 @@ -163,6 +210,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 @@ -257,7 +324,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: @@ -274,6 +341,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' @@ -363,7 +450,7 @@ jobs: build_runner_artifacts: name: Build reusable runner campaign artifacts - needs: [authorize, catalog] + needs: [authorize, target_lock, catalog] if: github.event_name != 'schedule' || vars.RUNNER_FULL_STACK_E2E_NIGHTLY_ENABLED == 'true' # Compile native binaries on the same reviewed image used to execute them, # avoiding libc/architecture drift between GitHub-hosted and AWS lanes. @@ -379,6 +466,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 @@ -456,7 +563,8 @@ jobs: build_remote_provider_pack: name: Build reusable remote provider pack - needs: [authorize, catalog, daytona_image, build_runner_artifacts] + needs: + [authorize, target_lock, catalog, daytona_image, build_runner_artifacts] if: github.event_name != 'schedule' || vars.RUNNER_FULL_STACK_E2E_NIGHTLY_ENABLED == 'true' runs-on: ubuntu-latest timeout-minutes: 15 @@ -475,6 +583,28 @@ jobs: ref: ${{ needs.authorize.outputs.target_sha }} persist-credentials: false + - name: Download resolved target lockfile + if: needs.catalog.outputs.needs_remote_provider_pack == 'true' + 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 + if: needs.catalog.outputs.needs_remote_provider_pack == 'true' + 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" + - if: needs.catalog.outputs.needs_remote_provider_pack == 'true' uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 with: @@ -559,6 +689,7 @@ jobs: needs: [ authorize, + target_lock, catalog, daytona_image, build_runner_artifacts, @@ -598,6 +729,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 @@ -606,7 +757,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: Download immutable campaign outputs uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 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/pnpm-lock.yaml b/pnpm-lock.yaml index 9c03ac51fd..f658db0b99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,7 +17,7 @@ patchedDependencies: hash: axsvv3fhdlmmbmnhpi2cywic6q path: patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch '@agentclientprotocol/codex-acp@1.6.2': - hash: jb7mkuk6elgpjomlxcdukdpiyy + hash: grbydorkxatbzksnwwfuawwtim path: patches/@agentclientprotocol__codex-acp@1.6.2.patch acpx@0.12.0: hash: b2ggqg6aj4hdob4whk6vq6jmc4 @@ -176,7 +176,7 @@ importers: dependencies: '@agentclientprotocol/codex-acp': specifier: ^1.6.2 - version: 1.6.2(patch_hash=jb7mkuk6elgpjomlxcdukdpiyy) + version: 1.6.2(patch_hash=grbydorkxatbzksnwwfuawwtim) '@paperclipai/adapter-utils': specifier: workspace:* version: link:../../adapter-utils @@ -479,7 +479,7 @@ importers: version: 0.70.0(patch_hash=fymctidcjqjhi4cj72qtivlxry)(@anthropic-ai/sdk@0.121.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3)) '@agentclientprotocol/codex-acp': specifier: 1.6.2 - version: 1.6.2(patch_hash=jb7mkuk6elgpjomlxcdukdpiyy) + version: 1.6.2(patch_hash=grbydorkxatbzksnwwfuawwtim) acpx: specifier: 0.13.1 version: 0.13.1(patch_hash=lzpwjtiaybzoijy455dfycwavu) @@ -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 @@ -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==} @@ -1931,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==} @@ -2448,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] @@ -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 @@ -7747,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': '*' @@ -8521,7 +8530,7 @@ snapshots: - '@anthropic-ai/sdk' - '@modelcontextprotocol/sdk' - '@agentclientprotocol/codex-acp@1.6.2(patch_hash=jb7mkuk6elgpjomlxcdukdpiyy)': + '@agentclientprotocol/codex-acp@1.6.2(patch_hash=grbydorkxatbzksnwwfuawwtim)': dependencies: '@agentclientprotocol/sdk': 1.4.0(zod@4.4.3) '@openai/codex': 0.148.0 @@ -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 @@ -9492,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 @@ -9791,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': @@ -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' @@ -12228,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' @@ -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 @@ -15425,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" 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/__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/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..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, @@ -101,6 +106,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 { @@ -242,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)"; @@ -267,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", ); } @@ -562,6 +582,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}). ` + @@ -1810,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/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, }, }); 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; +} diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index 8a718db612..167c7265af 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -274,12 +274,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, shared-build, provider-pack, 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 diff --git a/tests/runner-e2e/SECURITY.md b/tests/runner-e2e/SECURITY.md index f4fc612ec8..6148d71d1c 100644 --- a/tests/runner-e2e/SECURITY.md +++ b/tests/runner-e2e/SECURITY.md @@ -20,10 +20,19 @@ 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, +shared-build, provider-pack, 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 +65,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. diff --git a/tests/runner-e2e/workflow-security.test.ts b/tests/runner-e2e/workflow-security.test.ts index 9398012fac..7ce47a3c42 100644 --- a/tests/runner-e2e/workflow-security.test.ts +++ b/tests/runner-e2e/workflow-security.test.ts @@ -4,7 +4,11 @@ import { describe, expect, it } from "vitest"; const repositoryRoot = path.resolve(import.meta.dirname, "../.."); const fullStackTestNeeds = - /needs:\s*\[\s*authorize,\s*catalog,\s*daytona_image,\s*build_runner_artifacts,\s*build_remote_provider_pack,?\s*\]/u; + /needs:\s*\[\s*authorize,\s*target_lock,\s*catalog,\s*daytona_image,\s*build_runner_artifacts,\s*build_remote_provider_pack,?\s*\]/u; +const buildRunnerNeeds = + /needs:\s*\[\s*authorize,\s*target_lock,\s*catalog,?\s*\]/u; +const buildRemoteProviderPackNeeds = + /needs:\s*\[\s*authorize,\s*target_lock,\s*catalog,\s*daytona_image,\s*build_runner_artifacts,?\s*\]/u; describe("public repository paid workflow security", () => { it("gates every provider-secret job with stable actor IDs", async () => { @@ -68,6 +72,10 @@ 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( @@ -84,13 +92,44 @@ 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).toMatch(fullStackTestNeeds); 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 ]'); @@ -103,13 +142,64 @@ 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 }}", ); + const targetCodeJobs = [ + fullStack.slice( + fullStack.indexOf(" catalog:"), + fullStack.indexOf(" daytona_image:"), + ), + fullStack.slice( + fullStack.indexOf(" daytona_image:"), + fullStack.indexOf(" build_runner_artifacts:"), + ), + fullStack.slice( + fullStack.indexOf(" build_runner_artifacts:"), + fullStack.indexOf(" build_remote_provider_pack:"), + ), + fullStack.slice( + fullStack.indexOf(" build_remote_provider_pack:"), + 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( + 5, + ); + expect(fullStack.match(/Restore resolved target lockfile/g)).toHaveLength( + 5, + ); expect( fullStack.match( /ref: \$\{\{ needs\.authorize\.outputs\.target_sha \}\}/g, ), - ).toHaveLength(5); + ).toHaveLength(6); expect(fullStack.match(/ref: \$\{\{ github\.sha \}\}/g)).toHaveLength(2); - expect(fullStack.match(/persist-credentials: false/g)).toHaveLength(7); + expect(fullStack.match(/persist-credentials: false/g)).toHaveLength(8); expect(fullStack).not.toContain("ref: ${{ inputs.target_branch }}"); expect(fullStack).toContain( "PAPERCLIP_RUNNER_SOURCE_REVISION=${TARGET_SHA}", @@ -123,10 +213,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"); expect(fullStack).toContain( "if: always() && !cancelled() && needs.catalog.result == 'success'", ); @@ -201,10 +293,8 @@ describe("public repository paid workflow security", () => { expect(buildJobStart).toBeGreaterThan(0); expect(testJobStart).toBeGreaterThan(buildJobStart); - expect(buildJob).toContain("needs: [authorize, catalog]"); - expect(buildJob).toContain( - "needs: [authorize, catalog, daytona_image, build_runner_artifacts]", - ); + expect(buildJob).toMatch(buildRunnerNeeds); + expect(buildJob).toMatch(buildRemoteProviderPackNeeds); expect(buildJob).not.toContain("environment:"); expect(buildJob).not.toContain("secrets."); expect(buildJob).toContain( 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:*",