Merge remote-tracking branch 'origin/master' into fix/runner-paid-matrix-integrity

* origin/master:
  ci(runner): prepare target lockfile once for paid validation (#12774)
  chore(deps): bump paperclipai/paperclip/.github/workflows/pr-trusted.yml from 39b8ee2960 to f038633bf5 (#12562)
  chore(deps): bump sharp from 0.35.3 to 0.35.4 (#12563)
  chore(deps): bump @mdxeditor/editor from 4.2.1 to 4.2.3 (#12564)
  fix(server): stop paging Sentry for supervised boot races in managed cloud (#12772)
  Secure Cloud canonical runtime identity (#12766)

# Conflicts:
#	.github/workflows/runner-full-stack-e2e.yml
#	tests/runner-e2e/README.md
#	tests/runner-e2e/SECURITY.md
#	tests/runner-e2e/workflow-security.test.ts
This commit is contained in:
Dotta 2026-09-03 12:42:04 -05:00
commit 9e5f0e60fa
25 changed files with 1594 additions and 211 deletions

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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"

View File

@ -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<string, unknown>) {
return Buffer.from(JSON.stringify(value)).toString("base64url");
}
function assertion(input: {
claims?: Record<string, unknown>;
header?: Record<string, unknown>;
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<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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");
});
});

View File

@ -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", "");

View File

@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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?: {

View File

@ -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);
});
});

View File

@ -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.

View File

@ -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<StartedServer> {
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<StartedServer> {
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<StartedServer> {
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);
});

View File

@ -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" });
}
};
}

View File

@ -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);
}

View File

@ -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,
},
} : {}),
};
}

View File

@ -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;

View File

@ -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<string, unknown>) {
return Buffer.from(JSON.stringify(value)).toString("base64url");
}
function assertion(input: {
claims?: Record<string, unknown>;
header?: Record<string, unknown>;
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<NodeJS.ProcessEnv> = {}) {
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");
});
});

View File

@ -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<Db, "select" | "insert">;
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<string, unknown>;
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<PersistedRuntimeIdentity | null> {
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<CloudRuntimeIdentitySnapshot | null> {
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<string, unknown> {
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<string, unknown>;
}
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<CloudRuntimeIdentitySnapshot> {
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;
}

View File

@ -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",

View File

@ -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<string, number> = {
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,
},
});

View File

@ -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;
}

View File

@ -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

View File

@ -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.

View File

@ -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(

View File

@ -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:*",