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 from39b8ee2960tof038633bf5(#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:
commit
9e5f0e60fa
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
368
pnpm-lock.yaml
368
pnpm-lock.yaml
|
|
@ -17,7 +17,7 @@ patchedDependencies:
|
|||
hash: axsvv3fhdlmmbmnhpi2cywic6q
|
||||
path: patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch
|
||||
'@agentclientprotocol/codex-acp@1.6.2':
|
||||
hash: jb7mkuk6elgpjomlxcdukdpiyy
|
||||
hash: grbydorkxatbzksnwwfuawwtim
|
||||
path: patches/@agentclientprotocol__codex-acp@1.6.2.patch
|
||||
acpx@0.12.0:
|
||||
hash: b2ggqg6aj4hdob4whk6vq6jmc4
|
||||
|
|
@ -176,7 +176,7 @@ importers:
|
|||
dependencies:
|
||||
'@agentclientprotocol/codex-acp':
|
||||
specifier: ^1.6.2
|
||||
version: 1.6.2(patch_hash=jb7mkuk6elgpjomlxcdukdpiyy)
|
||||
version: 1.6.2(patch_hash=grbydorkxatbzksnwwfuawwtim)
|
||||
'@paperclipai/adapter-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../adapter-utils
|
||||
|
|
@ -479,7 +479,7 @@ importers:
|
|||
version: 0.70.0(patch_hash=fymctidcjqjhi4cj72qtivlxry)(@anthropic-ai/sdk@0.121.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))
|
||||
'@agentclientprotocol/codex-acp':
|
||||
specifier: 1.6.2
|
||||
version: 1.6.2(patch_hash=jb7mkuk6elgpjomlxcdukdpiyy)
|
||||
version: 1.6.2(patch_hash=grbydorkxatbzksnwwfuawwtim)
|
||||
acpx:
|
||||
specifier: 0.13.1
|
||||
version: 0.13.1(patch_hash=lzpwjtiaybzoijy455dfycwavu)
|
||||
|
|
@ -948,8 +948,8 @@ importers:
|
|||
specifier: ^13.1.3
|
||||
version: 13.1.3
|
||||
sharp:
|
||||
specifier: ^0.35.3
|
||||
version: 0.35.3(@types/node@24.13.3)
|
||||
specifier: ^0.35.4
|
||||
version: 0.35.4(@types/node@24.13.3)
|
||||
ssh2:
|
||||
specifier: ^1.17.0
|
||||
version: 1.17.0
|
||||
|
|
@ -1027,8 +1027,8 @@ importers:
|
|||
specifier: 0.48.0
|
||||
version: 0.48.0(typescript@7.0.2)
|
||||
'@mdxeditor/editor':
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1(@codemirror/language@6.12.4)(@lezer/highlight@1.2.3)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)(yjs@13.6.29)
|
||||
specifier: ^4.2.3
|
||||
version: 4.2.3(@codemirror/language@6.12.4)(@lezer/highlight@1.2.3)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)(yjs@13.6.29)
|
||||
'@paperclipai/adapter-claude-local':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/claude-local
|
||||
|
|
@ -1740,8 +1740,8 @@ packages:
|
|||
'@codemirror/language@6.12.4':
|
||||
resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==}
|
||||
|
||||
'@codemirror/legacy-modes@6.5.3':
|
||||
resolution: {integrity: sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==}
|
||||
'@codemirror/legacy-modes@6.5.4':
|
||||
resolution: {integrity: sha512-/cZr6qZyl08iYNLGsJ862CXXNI51LryRFRE40ejgoIjXZz0C1rGkD3/Ek5jM/8w1ceRjqtt4qx/KLMh4zBTgew==}
|
||||
|
||||
'@codemirror/lint@6.9.4':
|
||||
resolution: {integrity: sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==}
|
||||
|
|
@ -1758,6 +1758,12 @@ packages:
|
|||
'@codemirror/state@6.7.1':
|
||||
resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==}
|
||||
|
||||
'@codemirror/state@6.7.2':
|
||||
resolution: {integrity: sha512-U3RiPX62Wl/Gx4ftQ7UxLlloSfsFTQqKa+7vFBYteZGCzkp6oBqcsD7iSwniWRGobCAmDcwZSY+Six3+3ztdfg==}
|
||||
|
||||
'@codemirror/view@6.43.11':
|
||||
resolution: {integrity: sha512-2+esucbQX6wB2JYi1eDvdCPFTA31BN8oSy6xCmk3G6CloV11yOvEjYk+gH7kLrP0MuHG94E8WDhjs5oMiu3+Wg==}
|
||||
|
||||
'@codemirror/view@6.43.9':
|
||||
resolution: {integrity: sha512-sTuUzTpPMFebRhg6dawChoKKgndIwfjmJgKVxBefPElcU2NwQ6AFroupk0SFqEerQyZOGRfDNnSN8Dw/lMAsXw==}
|
||||
|
||||
|
|
@ -1931,8 +1937,8 @@ packages:
|
|||
'@emnapi/runtime@1.11.0':
|
||||
resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==}
|
||||
|
||||
'@emnapi/runtime@1.11.2':
|
||||
resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==}
|
||||
'@emnapi/runtime@1.11.3':
|
||||
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
|
||||
|
||||
'@emnapi/runtime@1.9.2':
|
||||
resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==}
|
||||
|
|
@ -2448,144 +2454,144 @@ packages:
|
|||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.35.3':
|
||||
resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==}
|
||||
'@img/sharp-darwin-arm64@0.35.4':
|
||||
resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-darwin-x64@0.35.3':
|
||||
resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==}
|
||||
'@img/sharp-darwin-x64@0.35.4':
|
||||
resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-freebsd-wasm32@0.35.3':
|
||||
resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==}
|
||||
'@img/sharp-freebsd-wasm32@0.35.4':
|
||||
resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
os: [freebsd]
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.3.2':
|
||||
resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==}
|
||||
'@img/sharp-libvips-darwin-arm64@1.3.3':
|
||||
resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.3.2':
|
||||
resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==}
|
||||
'@img/sharp-libvips-darwin-x64@1.3.3':
|
||||
resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.3.2':
|
||||
resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==}
|
||||
'@img/sharp-libvips-linux-arm64@1.3.3':
|
||||
resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.3.2':
|
||||
resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==}
|
||||
'@img/sharp-libvips-linux-arm@1.3.3':
|
||||
resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.3.2':
|
||||
resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==}
|
||||
'@img/sharp-libvips-linux-ppc64@1.3.3':
|
||||
resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.3.2':
|
||||
resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==}
|
||||
'@img/sharp-libvips-linux-riscv64@1.3.3':
|
||||
resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.3.2':
|
||||
resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==}
|
||||
'@img/sharp-libvips-linux-s390x@1.3.3':
|
||||
resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.3.2':
|
||||
resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==}
|
||||
'@img/sharp-libvips-linux-x64@1.3.3':
|
||||
resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.3.2':
|
||||
resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==}
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.3.3':
|
||||
resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.3.2':
|
||||
resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==}
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.3.3':
|
||||
resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-arm64@0.35.3':
|
||||
resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==}
|
||||
'@img/sharp-linux-arm64@0.35.4':
|
||||
resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-arm@0.35.3':
|
||||
resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==}
|
||||
'@img/sharp-linux-arm@0.35.4':
|
||||
resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.35.3':
|
||||
resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==}
|
||||
'@img/sharp-linux-ppc64@0.35.4':
|
||||
resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.35.3':
|
||||
resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==}
|
||||
'@img/sharp-linux-riscv64@0.35.4':
|
||||
resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-s390x@0.35.3':
|
||||
resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==}
|
||||
'@img/sharp-linux-s390x@0.35.4':
|
||||
resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-x64@0.35.3':
|
||||
resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==}
|
||||
'@img/sharp-linux-x64@0.35.4':
|
||||
resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.35.3':
|
||||
resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==}
|
||||
'@img/sharp-linuxmusl-arm64@0.35.4':
|
||||
resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.35.3':
|
||||
resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==}
|
||||
'@img/sharp-linuxmusl-x64@0.35.4':
|
||||
resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-wasm32@0.35.3':
|
||||
resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==}
|
||||
'@img/sharp-wasm32@0.35.4':
|
||||
resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
|
||||
'@img/sharp-webcontainers-wasm32@0.35.3':
|
||||
resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==}
|
||||
'@img/sharp-webcontainers-wasm32@0.35.4':
|
||||
resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@img/sharp-win32-arm64@0.35.3':
|
||||
resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==}
|
||||
'@img/sharp-win32-arm64@0.35.4':
|
||||
resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-ia32@0.35.3':
|
||||
resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==}
|
||||
'@img/sharp-win32-ia32@0.35.4':
|
||||
resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==}
|
||||
engines: {node: ^20.9.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-x64@0.35.3':
|
||||
resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==}
|
||||
'@img/sharp-win32-x64@0.35.4':
|
||||
resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
|
@ -2847,8 +2853,8 @@ packages:
|
|||
'@lezer/markdown@1.7.2':
|
||||
resolution: {integrity: sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==}
|
||||
|
||||
'@lezer/php@1.0.5':
|
||||
resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==}
|
||||
'@lezer/php@1.0.6':
|
||||
resolution: {integrity: sha512-QJ2xNXPmsm/Z3IpThq8fnJrEIVpxhsIoj6GZBW0JYEd/l9zxpJZW216X4fzqQY4vgpdGIumypvI4uQjd4tnYtw==}
|
||||
|
||||
'@lezer/python@1.1.19':
|
||||
resolution: {integrity: sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==}
|
||||
|
|
@ -2868,14 +2874,17 @@ packages:
|
|||
'@marijn/find-cluster-break@1.0.3':
|
||||
resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==}
|
||||
|
||||
'@marijn/find-cluster-break@1.0.4':
|
||||
resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
|
||||
|
||||
'@mdx-js/react@3.1.1':
|
||||
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
|
||||
peerDependencies:
|
||||
'@types/react': '>=16'
|
||||
react: ^19.2.8
|
||||
|
||||
'@mdxeditor/editor@4.2.1':
|
||||
resolution: {integrity: sha512-s2mgq7xvL958hfb0Atbto3nq3Uwo8qxBwvtbUkIWb+HF56cnDMLUSzW7CxBlCH8ZmcSk/IDAEBWhakxcnb2nYQ==}
|
||||
'@mdxeditor/editor@4.2.3':
|
||||
resolution: {integrity: sha512-5uz0vjZ8YhFFk2k23BJKPZyvAmV6dHXW3vJ9YzDeM6o7gz0G7V0Dpjj3ngmuez/9+lkx6wRlnaRfLWRqzQopAA==}
|
||||
engines: {node: '>=16'}
|
||||
peerDependencies:
|
||||
react: ^19.2.8
|
||||
|
|
@ -7499,8 +7508,8 @@ packages:
|
|||
peerDependencies:
|
||||
react: ^19.2.8
|
||||
|
||||
react-hook-form@7.86.0:
|
||||
resolution: {integrity: sha512-4kbWJrh5jPZt1+YqVcXcGKffGcXV/XVbozknLh0Yjh0KhpoAkus21TAQhzRYqNwFkkObmnSvRlZZ3GT+ehoIrA==}
|
||||
react-hook-form@7.87.0:
|
||||
resolution: {integrity: sha512-zhFzWvLxNHH+8839OnZcUxgMZw88ah2jZWDWvKWgF3Tpbnd0vKL+dlcuU3nZVWESZQjd81EW8K+wU+cYfYAc0w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
peerDependencies:
|
||||
react: ^19.2.8
|
||||
|
|
@ -7747,8 +7756,8 @@ packages:
|
|||
setprototypeof@1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
|
||||
sharp@0.35.3:
|
||||
resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==}
|
||||
sharp@0.35.4:
|
||||
resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '*'
|
||||
|
|
@ -8521,7 +8530,7 @@ snapshots:
|
|||
- '@anthropic-ai/sdk'
|
||||
- '@modelcontextprotocol/sdk'
|
||||
|
||||
'@agentclientprotocol/codex-acp@1.6.2(patch_hash=jb7mkuk6elgpjomlxcdukdpiyy)':
|
||||
'@agentclientprotocol/codex-acp@1.6.2(patch_hash=grbydorkxatbzksnwwfuawwtim)':
|
||||
dependencies:
|
||||
'@agentclientprotocol/sdk': 1.4.0(zod@4.4.3)
|
||||
'@openai/codex': 0.148.0
|
||||
|
|
@ -9100,8 +9109,8 @@ snapshots:
|
|||
'@codemirror/commands@6.11.0':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/view': 6.43.9
|
||||
'@codemirror/state': 6.7.2
|
||||
'@codemirror/view': 6.43.11
|
||||
'@lezer/common': 1.5.2
|
||||
|
||||
'@codemirror/lang-angular@0.1.4':
|
||||
|
|
@ -9122,7 +9131,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/state': 6.7.2
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/css': 1.3.6
|
||||
|
||||
|
|
@ -9130,7 +9139,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/state': 6.7.2
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/go': 1.0.1
|
||||
|
||||
|
|
@ -9140,8 +9149,8 @@ snapshots:
|
|||
'@codemirror/lang-css': 6.3.1
|
||||
'@codemirror/lang-javascript': 6.2.5
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/view': 6.43.9
|
||||
'@codemirror/state': 6.7.2
|
||||
'@codemirror/view': 6.43.11
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/css': 1.3.6
|
||||
'@lezer/html': 1.3.13
|
||||
|
|
@ -9166,8 +9175,8 @@ snapshots:
|
|||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/lang-html': 6.4.12
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/view': 6.43.9
|
||||
'@codemirror/state': 6.7.2
|
||||
'@codemirror/view': 6.43.11
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
|
@ -9190,8 +9199,8 @@ snapshots:
|
|||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/lang-html': 6.4.12
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/view': 6.43.9
|
||||
'@codemirror/state': 6.7.2
|
||||
'@codemirror/view': 6.43.11
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
|
@ -9201,8 +9210,8 @@ snapshots:
|
|||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/lang-html': 6.4.12
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/view': 6.43.9
|
||||
'@codemirror/state': 6.7.2
|
||||
'@codemirror/view': 6.43.11
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/markdown': 1.7.2
|
||||
|
||||
|
|
@ -9210,15 +9219,15 @@ snapshots:
|
|||
dependencies:
|
||||
'@codemirror/lang-html': 6.4.12
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/state': 6.7.2
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/php': 1.0.5
|
||||
'@lezer/php': 1.0.6
|
||||
|
||||
'@codemirror/lang-python@6.2.1':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/state': 6.7.2
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/python': 1.1.19
|
||||
|
||||
|
|
@ -9231,7 +9240,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@codemirror/lang-css': 6.3.1
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/state': 6.7.2
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/sass': 1.1.0
|
||||
|
||||
|
|
@ -9239,7 +9248,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/state': 6.7.2
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
|
@ -9264,8 +9273,8 @@ snapshots:
|
|||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/view': 6.43.9
|
||||
'@codemirror/state': 6.7.2
|
||||
'@codemirror/view': 6.43.11
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/xml': 1.0.6
|
||||
|
||||
|
|
@ -9273,7 +9282,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/state': 6.7.2
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
|
@ -9303,7 +9312,7 @@ snapshots:
|
|||
'@codemirror/lang-xml': 6.1.0
|
||||
'@codemirror/lang-yaml': 6.1.3
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/legacy-modes': 6.5.3
|
||||
'@codemirror/legacy-modes': 6.5.4
|
||||
|
||||
'@codemirror/language@6.12.4':
|
||||
dependencies:
|
||||
|
|
@ -9314,7 +9323,7 @@ snapshots:
|
|||
'@lezer/lr': 1.4.10
|
||||
style-mod: 4.1.3
|
||||
|
||||
'@codemirror/legacy-modes@6.5.3':
|
||||
'@codemirror/legacy-modes@6.5.4':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.4
|
||||
|
||||
|
|
@ -9333,8 +9342,8 @@ snapshots:
|
|||
'@codemirror/merge@6.12.2':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/view': 6.43.9
|
||||
'@codemirror/state': 6.7.2
|
||||
'@codemirror/view': 6.43.11
|
||||
'@lezer/highlight': 1.2.3
|
||||
style-mod: 4.1.3
|
||||
|
||||
|
|
@ -9348,6 +9357,17 @@ snapshots:
|
|||
dependencies:
|
||||
'@marijn/find-cluster-break': 1.0.3
|
||||
|
||||
'@codemirror/state@6.7.2':
|
||||
dependencies:
|
||||
'@marijn/find-cluster-break': 1.0.4
|
||||
|
||||
'@codemirror/view@6.43.11':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.7.2
|
||||
crelt: 1.0.7
|
||||
style-mod: 4.1.3
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
'@codemirror/view@6.43.9':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.7.1
|
||||
|
|
@ -9492,7 +9512,7 @@ snapshots:
|
|||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/runtime@1.11.2':
|
||||
'@emnapi/runtime@1.11.3':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
|
@ -9791,108 +9811,108 @@ snapshots:
|
|||
|
||||
'@img/colour@1.1.0': {}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.35.3':
|
||||
'@img/sharp-darwin-arm64@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.2
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-x64@0.35.3':
|
||||
'@img/sharp-darwin-x64@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.2
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-freebsd-wasm32@0.35.3':
|
||||
'@img/sharp-freebsd-wasm32@0.35.4':
|
||||
dependencies:
|
||||
'@img/sharp-wasm32': 0.35.3
|
||||
'@img/sharp-wasm32': 0.35.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.3.2':
|
||||
'@img/sharp-libvips-darwin-arm64@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.3.2':
|
||||
'@img/sharp-libvips-darwin-x64@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.3.2':
|
||||
'@img/sharp-libvips-linux-arm64@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.3.2':
|
||||
'@img/sharp-libvips-linux-arm@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.3.2':
|
||||
'@img/sharp-libvips-linux-ppc64@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.3.2':
|
||||
'@img/sharp-libvips-linux-riscv64@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.3.2':
|
||||
'@img/sharp-libvips-linux-s390x@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.3.2':
|
||||
'@img/sharp-libvips-linux-x64@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.3.2':
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.3.2':
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm64@0.35.3':
|
||||
'@img/sharp-linux-arm64@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.2
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm@0.35.3':
|
||||
'@img/sharp-linux-arm@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm': 1.3.2
|
||||
'@img/sharp-libvips-linux-arm': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-ppc64@0.35.3':
|
||||
'@img/sharp-linux-ppc64@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.2
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-riscv64@0.35.3':
|
||||
'@img/sharp-linux-riscv64@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.2
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-s390x@0.35.3':
|
||||
'@img/sharp-linux-s390x@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.2
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-x64@0.35.3':
|
||||
'@img/sharp-linux-x64@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-x64': 1.3.2
|
||||
'@img/sharp-libvips-linux-x64': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.35.3':
|
||||
'@img/sharp-linuxmusl-arm64@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.2
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.35.3':
|
||||
'@img/sharp-linuxmusl-x64@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.2
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-wasm32@0.35.3':
|
||||
'@img/sharp-wasm32@0.35.4':
|
||||
dependencies:
|
||||
'@emnapi/runtime': 1.11.2
|
||||
'@emnapi/runtime': 1.11.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-webcontainers-wasm32@0.35.3':
|
||||
'@img/sharp-webcontainers-wasm32@0.35.4':
|
||||
dependencies:
|
||||
'@img/sharp-wasm32': 0.35.3
|
||||
'@img/sharp-wasm32': 0.35.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-arm64@0.35.3':
|
||||
'@img/sharp-win32-arm64@0.35.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-ia32@0.35.3':
|
||||
'@img/sharp-win32-ia32@0.35.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-x64@0.35.3':
|
||||
'@img/sharp-win32-x64@0.35.4':
|
||||
optional: true
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
|
|
@ -10220,7 +10240,7 @@ snapshots:
|
|||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
|
||||
'@lezer/php@1.0.5':
|
||||
'@lezer/php@1.0.6':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
|
|
@ -10258,20 +10278,22 @@ snapshots:
|
|||
|
||||
'@marijn/find-cluster-break@1.0.3': {}
|
||||
|
||||
'@marijn/find-cluster-break@1.0.4': {}
|
||||
|
||||
'@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8)':
|
||||
dependencies:
|
||||
'@types/mdx': 2.0.14
|
||||
'@types/react': 19.2.18
|
||||
react: 19.2.8
|
||||
|
||||
'@mdxeditor/editor@4.2.1(@codemirror/language@6.12.4)(@lezer/highlight@1.2.3)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)(yjs@13.6.29)':
|
||||
'@mdxeditor/editor@4.2.3(@codemirror/language@6.12.4)(@lezer/highlight@1.2.3)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)(yjs@13.6.29)':
|
||||
dependencies:
|
||||
'@codemirror/commands': 6.11.0
|
||||
'@codemirror/lang-markdown': 6.5.2
|
||||
'@codemirror/language-data': 6.5.2
|
||||
'@codemirror/merge': 6.12.2
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/view': 6.43.9
|
||||
'@codemirror/state': 6.7.2
|
||||
'@codemirror/view': 6.43.11
|
||||
'@lexical/clipboard': 0.48.0(typescript@7.0.2)
|
||||
'@lexical/extension': 0.48.0(typescript@7.0.2)
|
||||
'@lexical/history': 0.48.0(typescript@7.0.2)
|
||||
|
|
@ -10294,7 +10316,7 @@ snapshots:
|
|||
'@radix-ui/react-toolbar': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-tooltip': 1.2.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
classnames: 2.5.1
|
||||
cm6-theme-basic-light: 0.2.0(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.9)(@lezer/highlight@1.2.3)
|
||||
cm6-theme-basic-light: 0.2.0(@codemirror/language@6.12.4)(@codemirror/state@6.7.2)(@codemirror/view@6.43.11)(@lezer/highlight@1.2.3)
|
||||
codemirror: 6.0.2
|
||||
downshift: 7.6.2(react@19.2.8)
|
||||
js-yaml: 4.3.1
|
||||
|
|
@ -10323,7 +10345,7 @@ snapshots:
|
|||
micromark-util-symbol: 2.0.1
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
react-hook-form: 7.86.0(react@19.2.8)
|
||||
react-hook-form: 7.87.0(react@19.2.8)
|
||||
unidiff: 1.0.4
|
||||
transitivePeerDependencies:
|
||||
- '@codemirror/language'
|
||||
|
|
@ -12228,7 +12250,7 @@ snapshots:
|
|||
|
||||
'@types/sharp@0.32.0(@types/node@24.13.3)':
|
||||
dependencies:
|
||||
sharp: 0.35.3(@types/node@24.13.3)
|
||||
sharp: 0.35.4(@types/node@24.13.3)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
|
||||
|
|
@ -12773,11 +12795,11 @@ snapshots:
|
|||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
cm6-theme-basic-light@0.2.0(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.9)(@lezer/highlight@1.2.3):
|
||||
cm6-theme-basic-light@0.2.0(@codemirror/language@6.12.4)(@codemirror/state@6.7.2)(@codemirror/view@6.43.11)(@lezer/highlight@1.2.3):
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/view': 6.43.9
|
||||
'@codemirror/state': 6.7.2
|
||||
'@codemirror/view': 6.43.11
|
||||
'@lezer/highlight': 1.2.3
|
||||
|
||||
cmdk@1.1.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||
|
|
@ -15106,7 +15128,7 @@ snapshots:
|
|||
react: 19.2.8
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-hook-form@7.86.0(react@19.2.8):
|
||||
react-hook-form@7.87.0(react@19.2.8):
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
|
||||
|
|
@ -15425,37 +15447,37 @@ snapshots:
|
|||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
sharp@0.35.3(@types/node@24.13.3):
|
||||
sharp@0.35.4(@types/node@24.13.3):
|
||||
dependencies:
|
||||
'@img/colour': 1.1.0
|
||||
detect-libc: 2.1.2
|
||||
semver: 7.8.5
|
||||
optionalDependencies:
|
||||
'@img/sharp-darwin-arm64': 0.35.3
|
||||
'@img/sharp-darwin-x64': 0.35.3
|
||||
'@img/sharp-freebsd-wasm32': 0.35.3
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.2
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.2
|
||||
'@img/sharp-libvips-linux-arm': 1.3.2
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.2
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.2
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.2
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.2
|
||||
'@img/sharp-libvips-linux-x64': 1.3.2
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.2
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.2
|
||||
'@img/sharp-linux-arm': 0.35.3
|
||||
'@img/sharp-linux-arm64': 0.35.3
|
||||
'@img/sharp-linux-ppc64': 0.35.3
|
||||
'@img/sharp-linux-riscv64': 0.35.3
|
||||
'@img/sharp-linux-s390x': 0.35.3
|
||||
'@img/sharp-linux-x64': 0.35.3
|
||||
'@img/sharp-linuxmusl-arm64': 0.35.3
|
||||
'@img/sharp-linuxmusl-x64': 0.35.3
|
||||
'@img/sharp-webcontainers-wasm32': 0.35.3
|
||||
'@img/sharp-win32-arm64': 0.35.3
|
||||
'@img/sharp-win32-ia32': 0.35.3
|
||||
'@img/sharp-win32-x64': 0.35.3
|
||||
'@img/sharp-darwin-arm64': 0.35.4
|
||||
'@img/sharp-darwin-x64': 0.35.4
|
||||
'@img/sharp-freebsd-wasm32': 0.35.4
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.3
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.3
|
||||
'@img/sharp-libvips-linux-arm': 1.3.3
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.3
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.3
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.3
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.3
|
||||
'@img/sharp-libvips-linux-x64': 1.3.3
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.3
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.3
|
||||
'@img/sharp-linux-arm': 0.35.4
|
||||
'@img/sharp-linux-arm64': 0.35.4
|
||||
'@img/sharp-linux-ppc64': 0.35.4
|
||||
'@img/sharp-linux-riscv64': 0.35.4
|
||||
'@img/sharp-linux-s390x': 0.35.4
|
||||
'@img/sharp-linux-x64': 0.35.4
|
||||
'@img/sharp-linuxmusl-arm64': 0.35.4
|
||||
'@img/sharp-linuxmusl-x64': 0.35.4
|
||||
'@img/sharp-webcontainers-wasm32': 0.35.4
|
||||
'@img/sharp-win32-arm64': 0.35.4
|
||||
'@img/sharp-win32-ia32': 0.35.4
|
||||
'@img/sharp-win32-x64': 0.35.4
|
||||
'@types/node': 24.13.3
|
||||
|
||||
shebang-command@2.0.0:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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", "");
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
Loading…
Reference in New Issue