mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
feat: qualify macOS Codex runner defaults and release artifacts
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
6ca302426b
commit
7b8d29a856
26 files changed
+1185
-111
No files matched your search
@@ -23,6 +23,11 @@ on:
|
||||
required: false
|
||||
default: release-smoke
|
||||
type: string
|
||||
qualification_source_sha:
|
||||
description: Optional immutable source SHA for provider-free three-target package qualification
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
paperclip_version:
|
||||
@@ -36,6 +41,13 @@ on:
|
||||
required: false
|
||||
default: release-smoke
|
||||
type: string
|
||||
qualification_source_sha:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# The Docker smoke below can never exercise the background-service leg of
|
||||
@@ -45,6 +57,7 @@ jobs:
|
||||
# on the runner VM's systemd and require the installed service to end up
|
||||
# serving.
|
||||
smoke_service:
|
||||
if: inputs.qualification_source_sha == ''
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
@@ -98,6 +111,7 @@ jobs:
|
||||
retention-days: 14
|
||||
|
||||
smoke:
|
||||
if: inputs.qualification_source_sha == ''
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
|
||||
@@ -198,3 +212,121 @@ jobs:
|
||||
- name: Stop Docker smoke container
|
||||
if: always()
|
||||
run: docker rm -f "$SMOKE_CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||
|
||||
qualification_daemons:
|
||||
if: inputs.qualification_source_sha != ''
|
||||
uses: ./.github/workflows/release-verify.yml
|
||||
with:
|
||||
ref: ${{ inputs.qualification_source_sha }}
|
||||
runner_binaries_only: true
|
||||
|
||||
qualification_pack:
|
||||
if: inputs.qualification_source_sha != ''
|
||||
needs: qualification_daemons
|
||||
name: Assemble and verify public Linux packages
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ inputs.qualification_source_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
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: ${{ needs.qualification_daemons.outputs.runner_artifact_prefix }}-*
|
||||
path: ${{ runner.temp }}/release-runner-inputs
|
||||
merge-multiple: true
|
||||
- name: Bind the immutable Linux producer source
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.qualification_source_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]] || exit 1
|
||||
[[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] || exit 1
|
||||
- name: Build normal workspace outputs and public CLI
|
||||
run: |
|
||||
pnpm install --no-frozen-lockfile
|
||||
pnpm build
|
||||
bash scripts/build-npm.sh --skip-checks --skip-typecheck
|
||||
mv cli/package.dev.json cli/package.json
|
||||
- name: Assemble exact-source daemons before server packing
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.qualification_source_sha }}
|
||||
run: |
|
||||
node packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs \
|
||||
assemble-daemons "$RUNNER_TEMP/release-runner-inputs" "$SOURCE_SHA"
|
||||
cp -R packages/paperclip-runner/dist/bin/. server/dist/vendor/paperclip-runner/bin/
|
||||
- name: Verify clean public installation and retain the same package bytes
|
||||
env:
|
||||
PAPERCLIP_RUNNER_PUBLIC_PACKS_DIR: ${{ runner.temp }}/public-runner-packs
|
||||
run: node scripts/verify-grok-npm-install.mjs
|
||||
- name: Locate completed package assembly
|
||||
id: pack_identity
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
if [[ -s "$RUNNER_TEMP/public-runner-packs/manifest.json" ]]; then
|
||||
echo "available=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Retain completed package assembly even after a later Linux failure
|
||||
if: ${{ !cancelled() && steps.pack_identity.outputs.available == 'true' }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: ${{ inputs.artifact_name }}-public-packages
|
||||
path: ${{ runner.temp }}/public-runner-packs/
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
qualification_mac:
|
||||
if: ${{ !cancelled() && inputs.qualification_source_sha != '' && needs.qualification_daemons.result == 'success' }}
|
||||
needs: [qualification_daemons, qualification_pack]
|
||||
name: Clean installed package (${{ matrix.target }})
|
||||
runs-on: ${{ matrix.host }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: darwin-arm64
|
||||
host: macos-15
|
||||
- target: darwin-x64
|
||||
host: macos-15-intel
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ inputs.qualification_source_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
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: ${{ inputs.artifact_name }}-public-packages
|
||||
path: ${{ runner.temp }}/public-runner-packs
|
||||
- name: Install only the existing verifier tooling
|
||||
run: pnpm install --ignore-scripts --no-frozen-lockfile
|
||||
- name: Verify host identity and actual clean npm consumer
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.qualification_source_sha }}
|
||||
TARGET: ${{ matrix.target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] || exit 1
|
||||
[[ "$(node -p 'process.platform + "-" + process.arch')" == "$TARGET" ]] || exit 1
|
||||
node scripts/verify-grok-npm-install.mjs --mac-consumer \
|
||||
"$RUNNER_TEMP/public-runner-packs" "$SOURCE_SHA" "$RUNNER_TEMP/mac-public-install.json"
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
if: always()
|
||||
with:
|
||||
name: ${{ inputs.artifact_name }}-${{ matrix.target }}
|
||||
path: ${{ runner.temp }}/mac-public-install.json
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
@@ -7,18 +7,28 @@ on:
|
||||
description: Commit SHA, branch, or tag to verify
|
||||
required: true
|
||||
type: string
|
||||
runner_binaries_only:
|
||||
description: Build exact-source release daemons without publishing or provider calls
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
outputs:
|
||||
runner_artifact_prefix:
|
||||
value: ${{ jobs.runner_binaries.outputs.artifact_prefix }}
|
||||
|
||||
# Caller-provided refs may name unmerged PR code. AWS is eligible only when
|
||||
# the caller runs on canonical master and verifies that event's exact SHA.
|
||||
# The organization group also restricts these workflow files to master.
|
||||
jobs:
|
||||
runner_chaos_evals:
|
||||
if: ${{ !inputs.runner_binaries_only }}
|
||||
name: Pre-release Runner chaos evals
|
||||
uses: ./.github/workflows/runner-chaos-evals.yml
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
typecheck:
|
||||
if: ${{ !inputs.runner_binaries_only }}
|
||||
name: Typecheck
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
@@ -97,6 +107,7 @@ jobs:
|
||||
run: pnpm -r typecheck
|
||||
|
||||
general_tests:
|
||||
if: ${{ !inputs.runner_binaries_only }}
|
||||
name: General tests (${{ matrix.group_label }})
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
@@ -205,6 +216,7 @@ jobs:
|
||||
fi
|
||||
|
||||
serialized_tests:
|
||||
if: ${{ !inputs.runner_binaries_only }}
|
||||
name: Serialized tests (${{ matrix.shard_label }})
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
@@ -254,6 +266,7 @@ jobs:
|
||||
run: pnpm test:run:serialized -- --shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }}
|
||||
|
||||
runner_workflow_evals:
|
||||
if: ${{ !inputs.runner_binaries_only }}
|
||||
name: Runner workflow eval scorer contract
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 10
|
||||
@@ -284,6 +297,7 @@ jobs:
|
||||
run: pnpm test:runner-workflow-evals
|
||||
|
||||
verify_paperclip_runner:
|
||||
if: ${{ !inputs.runner_binaries_only }}
|
||||
name: Verify Paperclip Runner (${{ matrix.lane }})
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
@@ -412,6 +426,7 @@ jobs:
|
||||
run: pnpm --filter @paperclipai/paperclip-runner build:rust
|
||||
|
||||
build:
|
||||
if: ${{ !inputs.runner_binaries_only }}
|
||||
name: Build
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
@@ -440,3 +455,65 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
runner_binaries:
|
||||
if: inputs.runner_binaries_only
|
||||
name: Release daemon (${{ matrix.target }})
|
||||
runs-on: ${{ matrix.host }}
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: linux-x64
|
||||
host: ubuntu-latest
|
||||
- target: darwin-arm64
|
||||
host: macos-15
|
||||
- target: darwin-x64
|
||||
host: macos-15-intel
|
||||
outputs:
|
||||
artifact_prefix: ${{ steps.identity.outputs.artifact_prefix }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
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: Bind immutable producer source and target
|
||||
id: identity
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.ref }}
|
||||
TARGET: ${{ matrix.target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]] || exit 1
|
||||
[[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] || exit 1
|
||||
[[ "$(node -p 'process.platform + "-" + process.arch')" == "$TARGET" ]] || exit 1
|
||||
echo "artifact_prefix=runner-release-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
|
||||
- name: Install dependencies and build production TypeScript contract
|
||||
run: |
|
||||
pnpm install --no-frozen-lockfile
|
||||
pnpm --filter @paperclipai/paperclip-runner build:typescript
|
||||
- name: Build the host daemon with the pinned toolchain
|
||||
working-directory: packages/paperclip-runner
|
||||
run: pnpm build:binary
|
||||
- name: Record actual daemon metadata and immutable bytes
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.ref }}
|
||||
TARGET: ${{ matrix.target }}
|
||||
run: |
|
||||
node packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs record "$SOURCE_SHA" "$TARGET" \
|
||||
packages/paperclip-runner/dist/bin/paperclip-runnerd "$RUNNER_TEMP/runner-release/$TARGET"
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: ${{ steps.identity.outputs.artifact_prefix }}-${{ matrix.target }}
|
||||
path: ${{ runner.temp }}/runner-release/
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
@@ -336,9 +336,54 @@ jobs:
|
||||
SOURCE_SHA: ${{ github.sha }}
|
||||
run: node scripts/cloud-source-verification.mjs "$SOURCE_SHA"
|
||||
|
||||
release_runner_source:
|
||||
# Old promotions retain their already-shipped packaging contract. New
|
||||
# sources opt into mandatory three-target assembly in their release.sh.
|
||||
needs: [verify_canary, select_nightly, select_beta, preflight_stable]
|
||||
if: >-
|
||||
!cancelled() && (
|
||||
(github.event_name == 'push' && needs.verify_canary.result == 'success') ||
|
||||
(needs.select_nightly.result == 'success' && needs.select_nightly.outputs.proceed == 'true') ||
|
||||
needs.select_beta.result == 'success' || needs.preflight_stable.result == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
sha: ${{ steps.source.outputs.sha }}
|
||||
required: ${{ steps.source.outputs.required }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.preflight_stable.outputs.sha || needs.select_beta.outputs.sha || needs.select_nightly.outputs.sha || github.sha }}
|
||||
persist-credentials: false
|
||||
- name: Read the exact source packaging contract
|
||||
id: source
|
||||
env:
|
||||
SOURCE_SHA: ${{ needs.preflight_stable.outputs.sha || needs.select_beta.outputs.sha || needs.select_nightly.outputs.sha || github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]] || exit 1
|
||||
[[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] || exit 1
|
||||
echo "sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT"
|
||||
source_script="$(git show "$SOURCE_SHA:scripts/release.sh")" || exit 1
|
||||
if printf '%s\n' "$source_script" | grep -F 'assemble-daemons "$PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR"' > /dev/null; then
|
||||
echo "required=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "required=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
release_runner_binaries:
|
||||
needs: release_runner_source
|
||||
if: needs.release_runner_source.outputs.required == 'true'
|
||||
uses: ./.github/workflows/release-verify.yml
|
||||
with:
|
||||
ref: ${{ needs.release_runner_source.outputs.sha }}
|
||||
runner_binaries_only: true
|
||||
|
||||
publish_canary:
|
||||
if: github.event_name == 'push'
|
||||
needs: verify_canary
|
||||
if: ${{ !cancelled() && github.event_name == 'push' && needs.verify_canary.result == 'success' && needs.release_runner_source.result == 'success' && (needs.release_runner_source.outputs.required == 'false' || needs.release_runner_binaries.result == 'success') }}
|
||||
needs: [verify_canary, release_runner_source, release_runner_binaries]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
environment: npm-canary
|
||||
@@ -356,6 +401,17 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download exact-source release daemon receipts
|
||||
if: needs.release_runner_source.outputs.required == 'true'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-*
|
||||
path: ${{ runner.temp }}/release-runner-inputs
|
||||
merge-multiple: true
|
||||
- name: Bind daemon inputs for release assembly
|
||||
if: needs.release_runner_source.outputs.required == 'true'
|
||||
run: echo "PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR=$RUNNER_TEMP/release-runner-inputs" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
@@ -587,12 +643,14 @@ jobs:
|
||||
artifact_name: nightly-release-smoke
|
||||
|
||||
publish_nightly:
|
||||
needs: [select_nightly, smoke_nightly]
|
||||
needs: [select_nightly, smoke_nightly, release_runner_source, release_runner_binaries]
|
||||
# Publish when smoke passed, or when smoke was deliberately skipped by a
|
||||
# dry-run dispatch (the publish itself is a dry-run in that case).
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.select_nightly.outputs.proceed == 'true' &&
|
||||
needs.release_runner_source.result == 'success' &&
|
||||
(needs.release_runner_source.outputs.required == 'false' || needs.release_runner_binaries.result == 'success') &&
|
||||
(needs.smoke_nightly.result == 'success' ||
|
||||
(needs.smoke_nightly.result == 'skipped' && github.event_name == 'workflow_dispatch' && inputs.dry_run))
|
||||
runs-on: ubuntu-latest
|
||||
@@ -618,6 +676,17 @@ jobs:
|
||||
ref: ${{ needs.select_nightly.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download exact-source release daemon receipts
|
||||
if: needs.release_runner_source.outputs.required == 'true'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-*
|
||||
path: ${{ runner.temp }}/release-runner-inputs
|
||||
merge-multiple: true
|
||||
- name: Bind daemon inputs for release assembly
|
||||
if: needs.release_runner_source.outputs.required == 'true'
|
||||
run: echo "PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR=$RUNNER_TEMP/release-runner-inputs" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
@@ -855,10 +924,12 @@ jobs:
|
||||
ref: ${{ needs.select_beta.outputs.sha }}
|
||||
|
||||
publish_beta:
|
||||
needs: [select_beta, verify_beta_candidate]
|
||||
needs: [select_beta, verify_beta_candidate, release_runner_source, release_runner_binaries]
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.select_beta.result == 'success' &&
|
||||
needs.release_runner_source.result == 'success' &&
|
||||
(needs.release_runner_source.outputs.required == 'false' || needs.release_runner_binaries.result == 'success') &&
|
||||
(needs.verify_beta_candidate.result == 'success' ||
|
||||
(needs.verify_beta_candidate.result == 'skipped' && needs.select_beta.outputs.mode == 'promote'))
|
||||
runs-on: ubuntu-latest
|
||||
@@ -884,6 +955,17 @@ jobs:
|
||||
ref: ${{ needs.select_beta.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download exact-source release daemon receipts
|
||||
if: needs.release_runner_source.outputs.required == 'true'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-*
|
||||
path: ${{ runner.temp }}/release-runner-inputs
|
||||
merge-multiple: true
|
||||
- name: Bind daemon inputs for release assembly
|
||||
if: needs.release_runner_source.outputs.required == 'true'
|
||||
run: echo "PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR=$RUNNER_TEMP/release-runner-inputs" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
@@ -1240,8 +1322,8 @@ jobs:
|
||||
ref: ${{ needs.preflight_stable.outputs.sha }}
|
||||
|
||||
preview_stable:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && inputs.dry_run
|
||||
needs: [preflight_stable, verify_stable]
|
||||
if: ${{ !cancelled() && github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && inputs.dry_run && needs.preflight_stable.result == 'success' && needs.verify_stable.result == 'success' && needs.release_runner_source.result == 'success' && (needs.release_runner_source.outputs.required == 'false' || needs.release_runner_binaries.result == 'success') }}
|
||||
needs: [preflight_stable, verify_stable, release_runner_source, release_runner_binaries]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
@@ -1254,6 +1336,17 @@ jobs:
|
||||
fetch-depth: 0
|
||||
ref: ${{ needs.preflight_stable.outputs.sha }}
|
||||
|
||||
- name: Download exact-source release daemon receipts
|
||||
if: needs.release_runner_source.outputs.required == 'true'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-*
|
||||
path: ${{ runner.temp }}/release-runner-inputs
|
||||
merge-multiple: true
|
||||
- name: Bind daemon inputs for release assembly
|
||||
if: needs.release_runner_source.outputs.required == 'true'
|
||||
run: echo "PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR=$RUNNER_TEMP/release-runner-inputs" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
@@ -1282,8 +1375,8 @@ jobs:
|
||||
./scripts/release.sh "${args[@]}"
|
||||
|
||||
publish_stable:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run
|
||||
needs: [preflight_stable, verify_stable]
|
||||
if: ${{ !cancelled() && github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run && needs.preflight_stable.result == 'success' && needs.verify_stable.result == 'success' && needs.release_runner_source.result == 'success' && (needs.release_runner_source.outputs.required == 'false' || needs.release_runner_binaries.result == 'success') }}
|
||||
needs: [preflight_stable, verify_stable, release_runner_source, release_runner_binaries]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
environment: npm-stable
|
||||
@@ -1301,6 +1394,17 @@ jobs:
|
||||
fetch-depth: 0
|
||||
ref: ${{ needs.preflight_stable.outputs.sha }}
|
||||
|
||||
- name: Download exact-source release daemon receipts
|
||||
if: needs.release_runner_source.outputs.required == 'true'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-*
|
||||
path: ${{ runner.temp }}/release-runner-inputs
|
||||
merge-multiple: true
|
||||
- name: Bind daemon inputs for release assembly
|
||||
if: needs.release_runner_source.outputs.required == 'true'
|
||||
run: echo "PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR=$RUNNER_TEMP/release-runner-inputs" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
|
||||
@@ -1,6 +1,67 @@
|
||||
# Runner defaults: staged delivery
|
||||
|
||||
Updated 2026-10-09.
|
||||
Updated 2026-10-12.
|
||||
|
||||
## Current amendment: include macOS in this PR
|
||||
|
||||
The user requires macOS support in the same Codex-default PR. The earlier
|
||||
Linux-only scope and macOS deferral below are superseded. Qualify Linux x64,
|
||||
macOS arm64 and macOS x64 through the shipped package path. Windows and Linux
|
||||
ARM keep their legacy defaults. Existing agents retain recorded execution.
|
||||
The user explicitly invoked `dotta:paperclip-test-drive`. Real task/follow-up
|
||||
acceptance is required on this Apple Silicon Mac. Intel builds must be attempted;
|
||||
an attended Intel provider journey is not required. Reuse the healthy test drive
|
||||
and the existing Product E2E helpers rather than create another test system.
|
||||
|
||||
Keep the current PR and branch: [#15683](https://github.com/paperclipai/paperclip/pull/15683),
|
||||
`codex/codex-runner-default`. The last pushed source is
|
||||
`6ca302426b768791b499d2d563bbca4276f2c908`. Its prior CI has 52 passing checks,
|
||||
four conditional skips, fresh 5/5 review and eight resolved threads. These do
|
||||
not qualify the new macOS changes. Current master has real merge conflicts;
|
||||
integrate the fetched master after checkpointing the owned macOS changes.
|
||||
|
||||
The [test-drive server](http://127.0.0.1:3125/COD/agents/all) is running through
|
||||
the normal dev supervisor at the pushed source. Health and bootstrap are ready;
|
||||
the actual listener belongs to this worktree. The preserved Codex QA agent is
|
||||
idle on `codex_local`, with its saved unrelated title edit intact. Dependencies
|
||||
and ignored configuration had been cleared, so startup restored them and the
|
||||
normal CLI signing keys while retaining the database and encrypted account key.
|
||||
The existing cached development daemon is from a different Rust tree. It avoids
|
||||
a local Rust build and is not macOS qualification evidence for this candidate.
|
||||
|
||||
Implementation ownership:
|
||||
|
||||
- Packaging worker: reuse the existing daemon assembler, release verification
|
||||
and release-smoke workflows to produce source-bound Linux and both macOS
|
||||
artifacts. Pack the normal npm graph, then test those same tarballs on actual
|
||||
Mac hosts. Retain the Linux and provider-pack controls. Never bundle Codex
|
||||
native payloads; use the installed official npm dependency and real protocol
|
||||
compatibility. Compile Rust and Docker only on remote hosts.
|
||||
- Selection worker: extend the shared availability contract and existing
|
||||
creation, onboarding, discovery, approval, import and UI fixtures. Preserve
|
||||
explicit legacy choices, external overrides and existing execution.
|
||||
- Runtime worker: verify the existing Mac native launch, account isolation,
|
||||
file delivery and continuity seams; fix only actual gaps. Keep the Linux-only
|
||||
sandboxed workspace-command capability gated on unsupported platforms.
|
||||
- Lead: integrate, run targeted and required checks, verify useful Mac native
|
||||
task/follow-up artifacts and ordinary editing, refresh the test-drive on the
|
||||
verified revision, and update the same PR and evidence record.
|
||||
|
||||
Reuse existing evaluations and assertions. Provider-free packaging and protocol
|
||||
checks do not substitute for authenticated task completion. Preserve the $250
|
||||
ceiling and historical unknown/cleanup holds; reconcile only documented
|
||||
unstarted future reservations before any new provider turns. No additional
|
||||
paid Linux campaign is authorized by this amendment. Embedded-browser-only
|
||||
login, no automatic agent migration, and no merge or production deployment
|
||||
remain constraints.
|
||||
|
||||
Next action: integrate the macOS packaging and policy changes, reconcile the
|
||||
confirmed master conflicts, freeze the source, and run remote qualification for
|
||||
the Mac artifacts. Native Apple Silicon live acceptance, new-head CI/review, managed
|
||||
staging execution, code-owner approval and actual post-merge Cloud composition
|
||||
remain unverified. Prior staging image/preflight evidence is retained at
|
||||
[37962635959](https://github.com/paperclipai/paperclip/actions/runs/37962635959)
|
||||
and [37965658123](https://github.com/paperclipai/paperclip-cloud/actions/runs/37965658123).
|
||||
|
||||
## Outcome and ownership
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { cpSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -9,6 +9,7 @@ import test from "node:test";
|
||||
import profiles from "../acpx-profiles.json" with { type: "json" };
|
||||
import distributions from "../cursor-distributions.json" with { type: "json" };
|
||||
import { verifyReleaseProviderPack } from "./release-provider-pack.mjs";
|
||||
import { existingReleaseProviderPack } from "./stage-release-runner-binaries.mjs";
|
||||
|
||||
const revision = "a".repeat(40);
|
||||
const digest = "sha256:" + "b".repeat(64);
|
||||
@@ -47,7 +48,7 @@ test("rejects omitted Cursor, conflicting legacy inventory, stale sources and fo
|
||||
});
|
||||
|
||||
test("release assembly rejects rehashed stale or missing packs before staging any files", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "release-provider-pack-"));
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), "release-provider-pack-")));
|
||||
const source = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
try {
|
||||
// Run the actual assembler in an isolated layout so it cannot overwrite built artifacts.
|
||||
@@ -77,3 +78,19 @@ test("release assembly rejects rehashed stale or missing packs before staging an
|
||||
}
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
|
||||
test("daemon-only assembly retains an existing same-source Linux provider-pack identity", () => {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), "release-provider-retained-")));
|
||||
try {
|
||||
assert.equal(existingReleaseProviderPack(root, revision), undefined);
|
||||
const file = join(root, "dist/remote-provider-packs/linux-x64/provider-pack.json");
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
const pack = seal(fixture()); writeFileSync(file, JSON.stringify(pack));
|
||||
assert.deepEqual(existingReleaseProviderPack(root, revision), pack);
|
||||
assert.throws(() => existingReleaseProviderPack(root, "c".repeat(40)), /release source/);
|
||||
const wrongTarget = fixture(); wrongTarget.target = { platform: "darwin", architecture: "arm64" };
|
||||
writeFileSync(file, JSON.stringify(seal(wrongTarget)));
|
||||
assert.throws(() => existingReleaseProviderPack(root, revision), /Linux x64 target/);
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -3,53 +3,133 @@
|
||||
// Manifest: {sourceRevision, platforms: {"darwin-arm64": {path, sha256}, ...}, remoteProviderPack: {path, sha256}}.
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { chmodSync, copyFileSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runnerBinaryTarget } from "../src/live/runner-binary.ts";
|
||||
import { verifyReleaseProviderPack } from "./release-provider-pack.mjs";
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const required = ["darwin-arm64", "darwin-x64", "linux-x64"];
|
||||
if (process.argv.length !== 3) throw Error("Usage: stage-release-runner-binaries.mjs MANIFEST.json");
|
||||
const manifest = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
||||
if (!/^[a-f0-9]{40}$/.test(manifest.sourceRevision) || !manifest.platforms || Object.keys(manifest.platforms).sort().join() !== [...required].sort().join()) throw Error("Release daemon manifest must bind one source and all three supported targets");
|
||||
const verified = required.map(target => {
|
||||
const artifact = manifest.platforms[target];
|
||||
if (!artifact || !isAbsolute(artifact.path) || !/^sha256:[a-f0-9]{64}$/.test(artifact.sha256)) throw Error(`Invalid ${target} release artifact`);
|
||||
const bytes = readFileSync(artifact.path), sha256 = "sha256:" + createHash("sha256").update(bytes).digest("hex");
|
||||
if (sha256 !== artifact.sha256 || runnerBinaryTarget(bytes) !== target) throw Error(`Release daemon ${target} digest or architecture mismatch`);
|
||||
return { target, source: artifact.path, sha256 };
|
||||
});
|
||||
if (!manifest.remoteProviderPack) throw Error("Release assembly requires the matching Linux remote provider pack");
|
||||
const entry = manifest.remoteProviderPack;
|
||||
if (!isAbsolute(entry.path) || !/^sha256:[a-f0-9]{64}$/.test(entry.sha256)) throw Error("Invalid remote provider-pack release artifact");
|
||||
const packBytes = readFileSync(entry.path);
|
||||
if (packBytes.length > 512 * 1024 || "sha256:" + createHash("sha256").update(packBytes).digest("hex") !== entry.sha256) throw Error("Remote provider-pack release manifest digest mismatch");
|
||||
const packIdentity = verifyReleaseProviderPack(JSON.parse(packBytes), manifest.sourceRevision);
|
||||
const platforms = {};
|
||||
for (const artifact of verified) {
|
||||
const relative = `${artifact.target}/paperclip-runnerd`, destination = join(root, "dist", "bin", relative);
|
||||
mkdirSync(dirname(destination), { recursive: true }); copyFileSync(artifact.source, destination); chmodSync(destination, 0o755);
|
||||
// Copied Mach-O inodes need a fresh ad-hoc signature on macOS. Record the
|
||||
// resulting packaged bytes, as well as the verified build artifact identity.
|
||||
if (process.platform === "darwin" && artifact.target.startsWith("darwin-")) {
|
||||
execFileSync("codesign", ["--force", "--sign", "-", destination], { stdio: "pipe" });
|
||||
}
|
||||
const sha256 = "sha256:" + createHash("sha256").update(readFileSync(destination)).digest("hex");
|
||||
platforms[artifact.target] = { path: relative, sha256, sourceSha256: artifact.sha256 };
|
||||
export const RELEASE_RUNNER_TARGETS = Object.freeze(["darwin-arm64", "darwin-x64", "linux-x64"]);
|
||||
const required = RELEASE_RUNNER_TARGETS;
|
||||
const digest = bytes => "sha256:" + createHash("sha256").update(bytes).digest("hex");
|
||||
function regularFile(path, maximumSize) {
|
||||
const stat = lstatSync(path);
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size < 1 || stat.size > maximumSize
|
||||
|| realpathSync(path) !== resolve(path)) throw Error("Release artifact must be a bounded canonical regular file");
|
||||
return readFileSync(path);
|
||||
}
|
||||
{
|
||||
const destination = join(root, "dist", "remote-provider-packs", "linux-x64", "provider-pack.json");
|
||||
mkdirSync(dirname(destination), { recursive: true });
|
||||
// Replace the inode so repeat assembly can preserve a read-only manifest.
|
||||
const staged = `${destination}.staging-${process.pid}`;
|
||||
try {
|
||||
writeFileSync(staged, packBytes, { mode: 0o444, flag: "wx" });
|
||||
renameSync(staged, destination);
|
||||
} finally {
|
||||
rmSync(staged, { force: true });
|
||||
}
|
||||
|
||||
// Production TypeScript contracts are built by the existing producer step.
|
||||
// Foreign artifacts are data: the assembler never executes another target.
|
||||
async function metadataContract() {
|
||||
const { PAPERCLIP_RUNNER_BUILD_METADATA } = await import("../dist/evals/build-metadata.js");
|
||||
const { parsePaperclipRunnerdBuildMetadata } = await import("../dist/evals/runnerd-artifact.js");
|
||||
return metadata => verifyReleaseRunnerMetadata(parsePaperclipRunnerdBuildMetadata(metadata), PAPERCLIP_RUNNER_BUILD_METADATA);
|
||||
}
|
||||
writeFileSync(join(root, "dist", "bin", "release-manifest.json"), JSON.stringify({schema:"paperclip.runner.release-binaries.v1",sourceRevision:manifest.sourceRevision,platforms,
|
||||
remoteProviderPack: { target: "linux-x64", digest: packIdentity.digest, sourceRevision: packIdentity.payload.runnerSourceRevision }},null,2)+"\n");
|
||||
console.log("Staged verified release daemons for " + required.join(", "));
|
||||
export function verifyReleaseRunnerMetadata(metadata, expected) {
|
||||
if (metadata.binaryContractVersion !== expected.contracts.runnerdArtifact
|
||||
|| metadata.nativeExecutionVersion !== expected.contracts.nativeExecution
|
||||
|| metadata.harnessDriverVersion !== expected.contracts.harnessDriver
|
||||
|| metadata.packageName !== expected.package.name || metadata.packageVersion !== expected.package.version
|
||||
|| metadata.prp?.name !== expected.prp.name || metadata.prp.minimumVersion > expected.prp.maximumVersion
|
||||
|| metadata.prp.maximumVersion < expected.prp.minimumVersion) throw Error("Release daemon build metadata is incompatible with the production contract");
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function releaseDaemonManifest(artifactDirectory, sourceRevision, verifyMetadata) {
|
||||
if (!/^[a-f0-9]{40}$/.test(sourceRevision)) throw Error("Release daemons require an immutable source revision");
|
||||
if (readdirSync(artifactDirectory).sort().join() !== [...required].sort().join()) throw Error("Release daemons require exactly the three supported target directories");
|
||||
const platforms = {};
|
||||
for (const target of required) {
|
||||
const directory = join(resolve(artifactDirectory), target);
|
||||
if (readdirSync(directory).sort().join() !== "paperclip-runnerd,receipt.json") throw Error("Release target must contain only its daemon and receipt");
|
||||
const receipt = JSON.parse(regularFile(join(directory, "receipt.json"), 64 * 1024));
|
||||
const path = join(directory, "paperclip-runnerd"), bytes = regularFile(path, 256 * 1024 * 1024);
|
||||
if (receipt.schema !== "paperclip.runner.release-artifact.v1" || receipt.sourceRevision !== sourceRevision
|
||||
|| receipt.target !== target || receipt.sha256 !== digest(bytes) || runnerBinaryTarget(bytes) !== target) {
|
||||
throw Error(`Release daemon ${target} source, digest or architecture mismatch`);
|
||||
}
|
||||
verifyMetadata(receipt.buildMetadata);
|
||||
platforms[target] = { path, sha256: receipt.sha256, buildMetadata: receipt.buildMetadata };
|
||||
}
|
||||
return { sourceRevision, platforms };
|
||||
}
|
||||
|
||||
export function existingReleaseProviderPack(packageRoot, sourceRevision) {
|
||||
const file = join(packageRoot, "dist/remote-provider-packs/linux-x64/provider-pack.json");
|
||||
if (!existsSync(file)) return undefined;
|
||||
return verifyReleaseProviderPack(JSON.parse(regularFile(file, 512 * 1024)), sourceRevision);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.argv[2] === "record") {
|
||||
const [, , , sourceRevision, target, source, output] = process.argv;
|
||||
if (process.argv.length !== 7 || !/^[a-f0-9]{40}$/.test(sourceRevision) || !required.includes(target)
|
||||
|| target !== `${process.platform}-${process.arch}`) throw Error("Record requires the exact source and actual producer host target");
|
||||
const bytes = regularFile(resolve(source), 256 * 1024 * 1024);
|
||||
if (runnerBinaryTarget(bytes) !== target) throw Error("Built daemon architecture does not match its producer");
|
||||
const buildMetadata = JSON.parse(execFileSync(resolve(source), ["--build-metadata"], {
|
||||
timeout: 15_000, maxBuffer: 64 * 1024, encoding: "utf8",
|
||||
}));
|
||||
(await metadataContract())(buildMetadata);
|
||||
mkdirSync(output, { recursive: true });
|
||||
copyFileSync(source, join(output, "paperclip-runnerd"));
|
||||
writeFileSync(join(output, "receipt.json"), JSON.stringify({ schema: "paperclip.runner.release-artifact.v1",
|
||||
sourceRevision, target, sha256: digest(bytes), buildMetadata }, null, 2) + "\n");
|
||||
return;
|
||||
}
|
||||
const daemonOnly = process.argv[2] === "assemble-daemons";
|
||||
if (daemonOnly ? process.argv.length !== 5 : process.argv.length !== 3) throw Error("Usage: stage-release-runner-binaries.mjs MANIFEST.json | assemble-daemons ARTIFACT_DIRECTORY SOURCE_SHA");
|
||||
const manifest = daemonOnly
|
||||
? releaseDaemonManifest(process.argv[3], process.argv[4], await metadataContract())
|
||||
: JSON.parse(regularFile(resolve(process.argv[2]), 512 * 1024));
|
||||
if (!/^[a-f0-9]{40}$/.test(manifest.sourceRevision) || !manifest.platforms || Object.keys(manifest.platforms).sort().join() !== [...required].sort().join()) throw Error("Release daemon manifest must bind one source and all three supported targets");
|
||||
const verified = required.map(target => {
|
||||
const artifact = manifest.platforms[target];
|
||||
if (!artifact || !isAbsolute(artifact.path) || !/^sha256:[a-f0-9]{64}$/.test(artifact.sha256)) throw Error(`Invalid ${target} release artifact`);
|
||||
const bytes = regularFile(artifact.path, 256 * 1024 * 1024), sha256 = "sha256:" + createHash("sha256").update(bytes).digest("hex");
|
||||
if (sha256 !== artifact.sha256 || runnerBinaryTarget(bytes) !== target) throw Error(`Release daemon ${target} digest or architecture mismatch`);
|
||||
return { target, source: artifact.path, sha256 };
|
||||
});
|
||||
if (!manifest.remoteProviderPack && !daemonOnly) throw Error("Release assembly requires the matching Linux remote provider pack");
|
||||
let packBytes, packIdentity;
|
||||
if (manifest.remoteProviderPack) {
|
||||
const entry = manifest.remoteProviderPack;
|
||||
if (!isAbsolute(entry.path) || !/^sha256:[a-f0-9]{64}$/.test(entry.sha256)) throw Error("Invalid remote provider-pack release artifact");
|
||||
packBytes = regularFile(entry.path, 512 * 1024);
|
||||
if (packBytes.length > 512 * 1024 || "sha256:" + createHash("sha256").update(packBytes).digest("hex") !== entry.sha256) throw Error("Remote provider-pack release manifest digest mismatch");
|
||||
packIdentity = verifyReleaseProviderPack(JSON.parse(packBytes), manifest.sourceRevision);
|
||||
} else if (daemonOnly) {
|
||||
// Preserve a qualified same-source Linux pack already materialized by the
|
||||
// normal producer. It never becomes a Mac provider-pack qualification.
|
||||
packIdentity = existingReleaseProviderPack(root, manifest.sourceRevision);
|
||||
}
|
||||
const platforms = {};
|
||||
for (const artifact of verified) {
|
||||
const relative = `${artifact.target}/paperclip-runnerd`, destination = join(root, "dist", "bin", relative);
|
||||
mkdirSync(dirname(destination), { recursive: true }); copyFileSync(artifact.source, destination); chmodSync(destination, 0o755);
|
||||
// Copied Mach-O inodes need a fresh ad-hoc signature on macOS. Record the
|
||||
// resulting packaged bytes, as well as the verified build artifact identity.
|
||||
if (process.platform === "darwin" && artifact.target.startsWith("darwin-")) {
|
||||
execFileSync("codesign", ["--force", "--sign", "-", destination], { stdio: "pipe", timeout: 30_000 });
|
||||
}
|
||||
const sha256 = "sha256:" + createHash("sha256").update(readFileSync(destination)).digest("hex");
|
||||
platforms[artifact.target] = { path: relative, sha256, sourceSha256: artifact.sha256 };
|
||||
}
|
||||
if (packBytes) {
|
||||
const destination = join(root, "dist", "remote-provider-packs", "linux-x64", "provider-pack.json");
|
||||
mkdirSync(dirname(destination), { recursive: true });
|
||||
// Replace the inode so repeat assembly can preserve a read-only manifest.
|
||||
const staged = `${destination}.staging-${process.pid}`;
|
||||
try {
|
||||
writeFileSync(staged, packBytes, { mode: 0o444, flag: "wx" });
|
||||
renameSync(staged, destination);
|
||||
} finally {
|
||||
rmSync(staged, { force: true });
|
||||
}
|
||||
}
|
||||
writeFileSync(join(root, "dist", "bin", "release-manifest.json"), JSON.stringify({schema:"paperclip.runner.release-binaries.v1",sourceRevision:manifest.sourceRevision,platforms,
|
||||
...(packIdentity ? { remoteProviderPack: { target: "linux-x64", digest: packIdentity.digest, sourceRevision: packIdentity.payload.runnerSourceRevision } } : {})},null,2)+"\n");
|
||||
console.log("Staged verified release daemons for " + required.join(", "));
|
||||
}
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
|
||||
@@ -1,11 +1,66 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, symlink, writeFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { stageRunnerBinary } from "./stage-runner-binary.mjs";
|
||||
import { RELEASE_RUNNER_TARGETS, releaseDaemonManifest, verifyReleaseRunnerMetadata } from "./stage-release-runner-binaries.mjs";
|
||||
|
||||
const contract = { package: { name: "@paperclipai/paperclip-runner", version: "0.0.0" },
|
||||
contracts: { runnerdArtifact: 2, nativeExecution: 1, harnessDriver: 1 }, prp: { name: "paperclip.runner", minimumVersion: 1, maximumVersion: 3 } };
|
||||
const metadata = { binaryContractVersion: 2, nativeExecutionVersion: 1, harnessDriverVersion: 1,
|
||||
packageName: "@paperclipai/paperclip-runner", packageVersion: "0.0.0", prp: { name: "paperclip.runner", minimumVersion: 1, maximumVersion: 2 } };
|
||||
|
||||
test("release assembly binds all three actual targets, source, hashes and compatible metadata", async () => {
|
||||
const root = await realpath(await mkdtemp(path.join(tmpdir(), "runner-release-inputs-"))), source = "a".repeat(40);
|
||||
const verify = value => verifyReleaseRunnerMetadata(value, contract);
|
||||
const receiptPath = target => path.join(root, target, "receipt.json");
|
||||
try {
|
||||
for (const target of RELEASE_RUNNER_TARGETS) {
|
||||
await mkdir(path.join(root, target));
|
||||
const bytes = Buffer.alloc(64);
|
||||
if (target === "linux-x64") {
|
||||
bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(62, 18);
|
||||
} else { bytes.writeUInt32LE(0xfeedfacf); bytes.writeUInt32LE(target === "darwin-x64" ? 0x01000007 : 0x0100000c, 4); }
|
||||
await writeFile(path.join(root, target, "paperclip-runnerd"), bytes);
|
||||
await writeFile(receiptPath(target), JSON.stringify({ schema: "paperclip.runner.release-artifact.v1", sourceRevision: source,
|
||||
target, sha256: "sha256:" + createHash("sha256").update(bytes).digest("hex"), buildMetadata: metadata }));
|
||||
}
|
||||
const actual = releaseDaemonManifest(root, source, verify);
|
||||
assert.deepEqual(Object.keys(actual.platforms), RELEASE_RUNNER_TARGETS);
|
||||
assert.equal(actual.remoteProviderPack, undefined, "Daemon-only data does not qualify a provider pack");
|
||||
assert.throws(() => releaseDaemonManifest(root, "b".repeat(40), verify), /source, digest or architecture/);
|
||||
const original = await readFile(receiptPath("darwin-arm64"), "utf8"), receipt = JSON.parse(original);
|
||||
for (const delta of [{ target: "darwin-x64" }, { sha256: "sha256:" + "0".repeat(64) }, { sourceRevision: "b".repeat(40) },
|
||||
{ buildMetadata: { ...metadata, binaryContractVersion: 99 } }]) {
|
||||
await writeFile(receiptPath("darwin-arm64"), JSON.stringify({ ...receipt, ...delta }));
|
||||
assert.throws(() => releaseDaemonManifest(root, source, verify));
|
||||
}
|
||||
await writeFile(receiptPath("darwin-arm64"), original);
|
||||
const binary = path.join(root, "darwin-arm64/paperclip-runnerd"), bytes = await readFile(binary);
|
||||
await writeFile(binary, await readFile(path.join(root, "darwin-x64/paperclip-runnerd")));
|
||||
assert.throws(() => releaseDaemonManifest(root, source, verify), /digest or architecture/);
|
||||
await rm(binary); await symlink(path.join(root, "darwin-x64/paperclip-runnerd"), binary);
|
||||
assert.throws(() => releaseDaemonManifest(root, source, verify), /regular file/);
|
||||
await rm(binary); await writeFile(binary, bytes);
|
||||
await mkdir(path.join(root, "win32-x64"));
|
||||
assert.throws(() => releaseDaemonManifest(root, source, verify), /exactly the three/);
|
||||
await rm(path.join(root, "win32-x64"), { recursive: true });
|
||||
await rm(receiptPath("linux-x64"));
|
||||
assert.throws(() => releaseDaemonManifest(root, source, verify), /only its daemon and receipt/);
|
||||
} finally { await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("release metadata rejects incompatible native, harness and protocol contracts without semver heuristics", () => {
|
||||
assert.equal(verifyReleaseRunnerMetadata(metadata, contract), metadata);
|
||||
for (const delta of [{ nativeExecutionVersion: 9 }, { harnessDriverVersion: 9 }, { packageName: "untrusted" },
|
||||
{ prp: { ...metadata.prp, name: "other" } }, { prp: { ...metadata.prp, minimumVersion: 4, maximumVersion: 5 } }]) {
|
||||
assert.throws(() => verifyReleaseRunnerMetadata({ ...metadata, ...delta }, contract), /incompatible/);
|
||||
}
|
||||
});
|
||||
|
||||
async function withOwnedChildren(root, action) {
|
||||
const owned = [];
|
||||
|
||||
@@ -12,10 +12,11 @@ export function paperclipRunnerProfileForHarness(harness: string): { provider: "
|
||||
return harness === "codex_local" ? { provider: "codex" } : undefined;
|
||||
}
|
||||
|
||||
/** Public server packages currently ship a Linux x64 daemon; source-build targets are not release qualification. */
|
||||
/** Creation defaults follow the shipped Linux x64 and macOS release artifacts. */
|
||||
export function paperclipRunnerSupportsPlatform(harness: string, platform: string, architecture: string): boolean {
|
||||
return !!paperclipRunnerProfileForHarness(harness)
|
||||
&& platform === "linux" && architecture === "x64";
|
||||
&& ((platform === "linux" && architecture === "x64")
|
||||
|| (platform === "darwin" && (architecture === "arm64" || architecture === "x64")));
|
||||
}
|
||||
|
||||
/** Recover harness identity without changing execution or guessing unknown providers. */
|
||||
|
||||
@@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledCodexPlatformProvenance, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource } from '../grok-public-install-sandbox.mjs';
|
||||
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledCodexPlatformProvenance, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource, installedDaemonProbeSource, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource } from '../grok-public-install-sandbox.mjs';
|
||||
|
||||
const paths = { assets: '/private/staging/assets', consumer: '/private/staging/consumer', cache: '/private/staging/cache', uid: 1001, gid: 1001 };
|
||||
const values = (args, flag) => args.flatMap((value, index) => value === flag ? [args[index + 1]] : []);
|
||||
@@ -141,6 +141,18 @@ test('the installed Codex probe exercises the public export and rejects incomple
|
||||
writeFileSync(legacyCommand, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
writeFileSync(index, `export const resolvePinnedCodexCommand = () => ${JSON.stringify(legacyCommand)};`);
|
||||
result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /Selected Codex platform version must match the qualified pin/);
|
||||
// Native Codex uses protocol compatibility, not the ACPX version allowlist.
|
||||
// Keep exact official-npm host provenance and the selected resource boundary.
|
||||
mkdirSync(join(legacy, 'vendor'));
|
||||
writeFileSync(legacyCommand, '#!/bin/sh\nprintf "codex-cli 0.156.1\\n"\n', { mode: 0o755 });
|
||||
writeFileSync(security, securitySource.replaceAll(JSON.stringify(nativeVendor), JSON.stringify(join(legacy, 'vendor'))));
|
||||
writeFileSync(probe, installedCodexProbeSource(index, consumer, undefined, { nativeCodex: true }));
|
||||
result = run(); assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(JSON.parse(result.stdout).installedCodexVersion, 'codex-cli 0.156.1');
|
||||
assert.equal(JSON.parse(result.stdout).codexQualifiedInstallationVerified, undefined);
|
||||
writeFileSync(probe, installedCodexProbeSource(index, consumer, '0.160.0'));
|
||||
writeFileSync(security, securitySource);
|
||||
|
||||
writeFileSync(index, `import { writeFileSync } from 'node:fs'; export const resolvePinnedCodexCommand = () => {
|
||||
writeFileSync(${JSON.stringify(join(platform, 'package.json'))}, ${JSON.stringify(JSON.stringify({ ...manifest, altered: true }))});
|
||||
return ${JSON.stringify(command)}; };`);
|
||||
@@ -229,3 +241,47 @@ test('consumer provenance rejects bundled, foreign, inconsistent and non-officia
|
||||
assert.throws(() => check(entry, [{ path: 'node_modules/@openai/codex-darwin-arm64', manifest }]), /only the host/);
|
||||
assert.throws(() => check(entry, [{ path, manifest: { ...manifest, name: 'untrusted' } }]), /identity/);
|
||||
});
|
||||
|
||||
|
||||
test('hosted Mac lifecycle denies OS networking and writes outside owned scratch', () => {
|
||||
const profile = macPublicInstallLifecycleProfile('/Users/runner/work/_temp/owned-check');
|
||||
assert.match(profile, /\(deny network\*\)/);
|
||||
assert.match(profile, /\(deny file-write\*/);
|
||||
assert.match(profile, /subpath "\/Users\/runner\/work\/_temp\/owned-check"/);
|
||||
assert.throws(() => macPublicInstallLifecycleProfile('relative'), /absolute/);
|
||||
assert.throws(() => macPublicInstallLifecycleProfile('/tmp/owned\0bad'), /absolute/);
|
||||
const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8');
|
||||
assert.match(source, /process.env.GITHUB_ACTIONS, 'true'/);
|
||||
assert.match(source, /networkProbe, 'allowed'/);
|
||||
assert.match(source, /networkProbe, 'denied'/);
|
||||
assert.match(source, /error.code,'EPERM'/);
|
||||
assert.match(source, /'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts'/);
|
||||
assert.match(source, /readFileSync\(sentinel, 'utf8'\), 'ok'/);
|
||||
assert.match(source, /readFileSync\(join\(consumer, 'package-lock.json'\), 'utf8'\), lock/);
|
||||
assert.match(source, /finally \{ rmSync\(root, \{ recursive: true, force: true \}\); \}/);
|
||||
});
|
||||
|
||||
test('installed daemon proof uses the production host resolver and complete source-bound manifest', () => {
|
||||
const source = installedDaemonProbeSource('/consumer/server/dist/vendor/paperclip-runner', '/consumer', 'a'.repeat(40));
|
||||
assert.match(source, /defaultCapabilityRunnerdBinary\(\)/);
|
||||
assert.match(source, /Object.keys\(manifest.platforms\).sort\(\), targets.sort\(\)/);
|
||||
assert.match(source, /manifest.sourceRevision, "a{40}"/);
|
||||
assert.match(source, /runnerBinaryTarget\(bytes\), target/);
|
||||
assert.match(source, /createHash\('sha256'\)/);
|
||||
assert.match(source, /metadata.binaryContractVersion, expected.contracts.runnerdArtifact/);
|
||||
assert.doesNotMatch(source, /PAPERCLIP_RUNNERD_BINARY|runner\/target\/debug|execFileSync\('paperclip-runnerd'/);
|
||||
});
|
||||
|
||||
|
||||
test('Mac startup wiring normalizes only validated manifest digests and uses server-scoped tooling', () => {
|
||||
const source = macInstalledStartupProbeSource('/owned/consumer', '/owned/consumer/server', 'sha256:' + 'a'.repeat(64), '/source/tests/runner-e2e/installed-cli.ts');
|
||||
assert.match(source, /expectedRunnerSha256:"a{64}"/);
|
||||
assert.doesNotMatch(source, /expectedRunnerSha256:"sha256:/);
|
||||
for (const digest of ['a'.repeat(64), 'sha256:bad', 'sha512:' + 'a'.repeat(64), 'sha256:' + 'A'.repeat(64)]) {
|
||||
assert.throws(() => macInstalledStartupProbeSource('/owned/consumer', '/owned/consumer/server', digest, '/source/helper.ts'), /verified SHA-256/);
|
||||
}
|
||||
const verifier = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8');
|
||||
assert.match(verifier, /\['--import', 'tsx', startupFile\], join\(repo, 'server'\)/);
|
||||
const closedEnvironment = verifier.match(/const env = \{ PATH: `\$\{tools\}[\s\S]*?\};/)[0];
|
||||
assert.doesNotMatch(closedEnvironment, /NODE_PATH|NODE_OPTIONS|\.\.\.process.env/);
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
@@ -38,14 +39,14 @@ test("chaos verification isolates callers that verify the same source commit", (
|
||||
|
||||
test("canary reuses exact-source proof while stable keeps full verification", () => {
|
||||
const releaseWorkflow = readWorkflow("release.yml");
|
||||
const canary = releaseWorkflow.split(" verify_canary:\n")[1].split("\n publish_canary:")[0];
|
||||
const canary = releaseWorkflow.match(/ verify_canary:\n[\s\S]*?(?=\n [a-z_]+:)/)[0];
|
||||
assert.match(canary, /github\.repository == 'paperclipai\/paperclip' && github\.event_name == 'push' && github\.ref == 'refs\/heads\/master'/);
|
||||
assert.match(canary, /actions: read/);
|
||||
assert.match(canary, /ref: \$\{\{ github\.sha \}\}/);
|
||||
assert.match(canary, /SOURCE_SHA: \$\{\{ github\.sha \}\}/);
|
||||
assert.match(canary, /run: node scripts\/cloud-source-verification\.mjs "\$SOURCE_SHA"/);
|
||||
assert.doesNotMatch(canary, /release-verify\.yml|continue-on-error|always\(\)/);
|
||||
assert.match(releaseWorkflow, /publish_canary:\n\s+if: github\.event_name == 'push'\n\s+needs: verify_canary/);
|
||||
assert.match(releaseWorkflow, /publish_canary:\n\s+if: .*github\.event_name == 'push'.*\n\s+needs: \[verify_canary, release_runner_source, release_runner_binaries\]/);
|
||||
// The stable lane is gated on the stable channel since the nightly lane
|
||||
// was added; a `needs:` line (for example a preflight job) may sit between
|
||||
// the gate and the delegation.
|
||||
@@ -480,3 +481,85 @@ test("direct protocol concurrency override only lowers the configured ceiling",
|
||||
if (expected !== null) assert.equal(result.stdout, expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("release daemon production uses exact-source native hosts and no publisher or provider permissions", () => {
|
||||
const workflow = readWorkflow("release-verify.yml");
|
||||
const producer = workflow.split(" runner_binaries:\n")[1];
|
||||
for (const [target, host] of [["linux-x64", "ubuntu-latest"], ["darwin-arm64", "macos-15"], ["darwin-x64", "macos-15-intel"]]) {
|
||||
assert.ok(producer.includes(`- target: ${target}\n host: ${host}`));
|
||||
}
|
||||
assert.match(producer, /persist-credentials: false/);
|
||||
assert.match(producer, /contents: read/);
|
||||
assert.doesNotMatch(producer, /secrets\.|id-token: write|contents: write|npm publish|environment:/);
|
||||
assert.match(producer, /\[\[ "\$SOURCE_SHA" =~ \^\[a-f0-9\]\{40\}\$ \]\]/);
|
||||
assert.match(producer, /git rev-parse HEAD/);
|
||||
assert.match(producer, /process.platform \+ "-" \+ process.arch/);
|
||||
assert.match(producer, /pnpm build:binary/);
|
||||
assert.match(producer, /stage-release-runner-binaries.mjs record/);
|
||||
for (const job of ["runner_chaos_evals", "typecheck", "general_tests", "serialized_tests", "runner_workflow_evals", "verify_paperclip_runner", "build"]) {
|
||||
assert.ok(workflow.includes(`${job}:\n if: \${{ !inputs.runner_binaries_only }}`));
|
||||
}
|
||||
const release = readWorkflow("release.yml");
|
||||
for (const job of ["publish_canary", "publish_nightly", "publish_beta", "preview_stable", "publish_stable"]) {
|
||||
const block = release.split(` ${job}:\n`)[1].split(/\n [a-z_]+:\n/)[0];
|
||||
assert.match(block, /needs: .*release_runner_binaries/);
|
||||
assert.match(block, /pattern: \$\{\{ needs.release_runner_binaries.outputs.runner_artifact_prefix \}\}-\*/);
|
||||
assert.match(block, /PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR/);
|
||||
}
|
||||
const script = readFileSync(path.join(repoRoot, 'scripts/release.sh'), 'utf8');
|
||||
assert.ok(script.indexOf('assemble-daemons "$PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR"') < script.indexOf('scripts/build-standalone-public-packages.mjs'));
|
||||
assert.match(script, /cp -R .*paperclip-runner\/dist\/bin\/\." .*server\/dist\/vendor\/paperclip-runner\/bin\//);
|
||||
assert.match(script, /elif \[ "\$dry_run" = false \]; then[\s\S]*?release_fail "Public releases require/);
|
||||
});
|
||||
|
||||
test("premerge Mac qualification consumes the same Linux-packed npm bytes without publishing", () => {
|
||||
const smoke = readWorkflow("release-smoke.yml");
|
||||
assert.match(smoke, /smoke_service:\n\s+if: inputs.qualification_source_sha == ''/);
|
||||
assert.match(smoke, /smoke:\n\s+if: inputs.qualification_source_sha == ''/);
|
||||
const qualification = smoke.split(" qualification_daemons:\n")[1];
|
||||
assert.doesNotMatch(qualification, /secrets\.|npm publish|id-token: write|contents: write|continue-on-error/);
|
||||
assert.match(qualification, /runner_binaries_only: true/);
|
||||
assert.match(qualification, /PAPERCLIP_RUNNER_PUBLIC_PACKS_DIR/);
|
||||
assert.match(qualification, /if: \$\{\{ !cancelled\(\) && steps.pack_identity.outputs.available == 'true' \}\}/);
|
||||
assert.match(qualification, /name: \$\{\{ inputs.artifact_name \}\}-public-packages/);
|
||||
assert.match(qualification, /--mac-consumer/);
|
||||
assert.match(qualification, /host: macos-15\n/);
|
||||
assert.match(qualification, /host: macos-15-intel\n/);
|
||||
assert.match(qualification, /pnpm install --ignore-scripts --no-frozen-lockfile/);
|
||||
});
|
||||
|
||||
|
||||
test("historical promotion sources keep their packaging path while new sources require daemon assembly", () => {
|
||||
const workflow = readWorkflow("release.yml");
|
||||
const selector = workflow.split(' - name: Read the exact source packaging contract')[1].split('\n release_runner_binaries:')[0];
|
||||
const script = selector.split(' run: |\n')[1].split('\n').map(line => line.startsWith(' ') ? line.slice(10) : line).join('\n');
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'release-source-contract-'));
|
||||
try {
|
||||
const tools = path.join(root, 'tools'); mkdirSync(tools);
|
||||
writeFileSync(path.join(tools, 'git'), '#!/bin/sh\ncase "$1" in\nrev-parse) printf "%s\\n" "$FIXTURE_HEAD";;\nshow) cat "$FIXTURE_SOURCE";;\n*) exit 3;;\nesac\n', { mode: 0o755 });
|
||||
const source = path.join(root, 'release.sh'), output = path.join(root, 'outputs');
|
||||
for (const [contents, required] of [['pnpm build\n', false], ['assemble-daemons "$PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR" "$SOURCE_SHA"\n', true]]) {
|
||||
writeFileSync(source, contents); writeFileSync(output, '');
|
||||
const result = spawnSync('bash', ['-euo', 'pipefail', '-c', script], { encoding: 'utf8', env: {
|
||||
PATH: `${tools}:/usr/bin:/bin`, SOURCE_SHA: 'a'.repeat(40), FIXTURE_HEAD: 'a'.repeat(40), FIXTURE_SOURCE: source, GITHUB_OUTPUT: output,
|
||||
} });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(readFileSync(output, 'utf8'), new RegExp(`required=${required}`));
|
||||
}
|
||||
const result = spawnSync('bash', ['-euo', 'pipefail', '-c', script], { encoding: 'utf8', env: {
|
||||
PATH: `${tools}:/usr/bin:/bin`, SOURCE_SHA: 'a'.repeat(40), FIXTURE_HEAD: 'b'.repeat(40), FIXTURE_SOURCE: source, GITHUB_OUTPUT: output,
|
||||
} });
|
||||
assert.equal(result.status, 1, 'Wrong checkout cannot opt out of mandatory assembly');
|
||||
const unreadable = spawnSync('bash', ['-euo', 'pipefail', '-c', script], { encoding: 'utf8', env: {
|
||||
PATH: `${tools}:/usr/bin:/bin`, SOURCE_SHA: 'a'.repeat(40), FIXTURE_HEAD: 'a'.repeat(40), FIXTURE_SOURCE: path.join(root, 'absent'), GITHUB_OUTPUT: output,
|
||||
} });
|
||||
assert.equal(unreadable.status, 1, 'A failed immutable source read must not masquerade as a legacy source');
|
||||
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
for (const job of ['publish_canary','publish_nightly','publish_beta','preview_stable','publish_stable']) {
|
||||
const block = workflow.split(` ${job}:\n`)[1].split(/\n [a-z_]+:\n/)[0];
|
||||
assert.match(block, /needs.release_runner_source.result == 'success'/);
|
||||
assert.match(block, /needs.release_runner_source.outputs.required == 'false' \|\| needs.release_runner_binaries.result == 'success'/);
|
||||
assert.match(block, /Download exact-source release daemon receipts\n\s+if: needs.release_runner_source.outputs.required == 'true'/);
|
||||
}
|
||||
});
|
||||
@@ -43,15 +43,25 @@ export function paperclipRunnerBinaryNeedsBuild({
|
||||
nativeRunnerRequired,
|
||||
configuredBinary,
|
||||
platform = process.platform,
|
||||
architecture = process.arch,
|
||||
}) {
|
||||
if (!nativeRunnerRequired) return false;
|
||||
if (configuredBinary?.trim()) return false;
|
||||
|
||||
const executable = platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd";
|
||||
const packageRoot = path.join(repoRoot, "packages", "paperclip-runner");
|
||||
const stagedBinary = path.join(packageRoot, "dist", "bin", executable);
|
||||
const binaryStat = statSync(stagedBinary, { throwIfNoEntry: false });
|
||||
if (!binaryStat?.isFile()) return true;
|
||||
// Match production resolution: a staged release target takes precedence over
|
||||
// the flat source-build output. Remote Mac builds must not trigger Cargo just
|
||||
// because only the normal darwin-<arch> release executable is present.
|
||||
let binaryStat;
|
||||
for (const binary of [
|
||||
path.join(packageRoot, "dist", "bin", `${platform}-${architecture}`, executable),
|
||||
path.join(packageRoot, "dist", "bin", executable),
|
||||
]) {
|
||||
const candidate = statSync(binary, { throwIfNoEntry: false });
|
||||
if (candidate?.isFile()) { binaryStat = candidate; break; }
|
||||
}
|
||||
if (!binaryStat) return true;
|
||||
|
||||
const runnerRoot = path.join(packageRoot, "runner");
|
||||
const buildInputs = [
|
||||
|
||||
@@ -60,7 +60,7 @@ export function assertInstalledCodexPlatformProvenance(lock, installed, target)
|
||||
|
||||
// Execute this in the existing offline public consumer, never in the checkout.
|
||||
// The public index and returned command must both come from the installed graph.
|
||||
export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersion) {
|
||||
export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersion, { nativeCodex = false } = {}) {
|
||||
return `
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
@@ -118,11 +118,11 @@ export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersi
|
||||
assert.ok(selectedPlatform, 'Codex wrapper must resolve an official host platform package from its installed graph');
|
||||
const selectedMetadata = JSON.parse(readFileSync(selectedPlatformManifest, 'utf8'));
|
||||
assert.deepEqual(selectedMetadata, selectedPlatform.manifest, 'Selected Codex platform metadata must match its scanned provenance');
|
||||
assert.equal(selectedMetadata.version, ${JSON.stringify(expectedVersion)} + '-' + process.platform + '-' + process.arch,
|
||||
'Selected Codex platform version must match the qualified pin');
|
||||
${nativeCodex ? '' : `assert.equal(selectedMetadata.version, ${JSON.stringify(expectedVersion)} + '-' + process.platform + '-' + process.arch,
|
||||
'Selected Codex platform version must match the qualified pin');`}
|
||||
const version = execFileSync(command, ['--version'], { timeout: 30_000, maxBuffer: 128 * 1024, encoding: 'utf8',
|
||||
env: { PATH: '/usr/local/bin:/usr/bin:/bin', HOME: '/tmp', NODE_PATH: '' } }).trim();
|
||||
assert.equal(version, 'codex-cli ' + ${JSON.stringify(expectedVersion)}, 'Installed Codex version must match the qualified pin');
|
||||
env: { PATH: process.env.PATH, HOME: process.env.HOME, NODE_PATH: '' } }).trim();
|
||||
${nativeCodex ? "assert.match(version, /^codex-cli /, 'Installed official Codex command must execute');" : `assert.equal(version, 'codex-cli ' + ${JSON.stringify(expectedVersion)}, 'Installed Codex version must match the qualified pin');`}
|
||||
const vendor = dirname(index);
|
||||
const integrityPath = join(vendor, 'drivers/acpx/installation-integrity.js');
|
||||
const profilesPath = join(vendor, 'drivers/acpx/qualified-profiles.js');
|
||||
@@ -138,19 +138,81 @@ export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersi
|
||||
assert.ok(!readRoots.includes(platformRoot) && !readRoots.includes(root) && !readRoots.includes(sandboxSource.HOME), 'Codex sandbox must not grant enclosing npm or credential-home access');
|
||||
const sandboxArgs = createIsolatedCodexAppServerArgs(sandboxSource, readRoots).join('\\n');
|
||||
assert.ok(sandboxArgs.includes(JSON.stringify(nativeVendor) + '="read"'), 'Codex sandbox configuration must retain the selected native executable resources');
|
||||
const { createAcpxPackageJsonResolver, verifyQualifiedAcpxInstallation } = await import(pathToFileURL(integrityPath).href);
|
||||
${nativeCodex ? '' : `const { createAcpxPackageJsonResolver, verifyQualifiedAcpxInstallation } = await import(pathToFileURL(integrityPath).href);
|
||||
const { resolveQualifiedAcpxProfile } = await import(pathToFileURL(profilesPath).href);
|
||||
const serverRoot = resolve(vendor, '../../..');
|
||||
assert.ok(contained(serverRoot), 'Codex package authority must be the installed server');
|
||||
const installation = await verifyQualifiedAcpxInstallation(resolveQualifiedAcpxProfile('codex', 'gpt-5.4'),
|
||||
createAcpxPackageJsonResolver(serverRoot, join(serverRoot, 'package.json')));
|
||||
const lease = await installation.openCommand();
|
||||
await lease.close();
|
||||
console.log(JSON.stringify({ pinnedCodexCommandVerified: true, pinnedCodexVersion: ${JSON.stringify(expectedVersion)},
|
||||
...provenance, codexSelectedPlatformPath: selectedPlatform.path, codexSelectedPlatformVersion: selectedMetadata.version, codexNativeSandboxResourcesVerified: true, codexQualifiedInstallationVerified: true, codexCommandLeaseVerified: true, providerCalls: 0 }));
|
||||
await lease.close();`}
|
||||
console.log(JSON.stringify({ pinnedCodexCommandVerified: true, installedCodexVersion: version,
|
||||
...provenance, codexSelectedPlatformPath: selectedPlatform.path, codexSelectedPlatformVersion: selectedMetadata.version,
|
||||
codexNativeSandboxResourcesVerified: true,
|
||||
${nativeCodex ? '' : `pinnedCodexVersion: ${JSON.stringify(expectedVersion)}, codexQualifiedInstallationVerified: true, codexCommandLeaseVerified: true,`}
|
||||
providerCalls: 0 }));
|
||||
`;
|
||||
}
|
||||
|
||||
/** The installed consumer must select its packaged host daemon, never PATH. */
|
||||
export function installedDaemonProbeSource(outputRoot, consumerRoot, sourceRevision) {
|
||||
return `
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, realpathSync } from 'node:fs';
|
||||
import { isAbsolute, join, relative, sep } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const root = realpathSync(${JSON.stringify(consumerRoot)}), output = realpathSync(${JSON.stringify(outputRoot)});
|
||||
const inside = path => { const value = relative(root, realpathSync(path)); return value !== '' && value !== '..' && !value.startsWith('..' + sep) && !isAbsolute(value); };
|
||||
assert.ok(inside(output));
|
||||
const { defaultCapabilityRunnerdBinary } = await import(pathToFileURL(join(output, 'live/runnerd-codex-transport.js')).href);
|
||||
const { runnerBinaryTarget } = await import(pathToFileURL(join(output, 'live/runner-binary.js')).href);
|
||||
const { parsePaperclipRunnerdBuildMetadata } = await import(pathToFileURL(join(output, 'evals/runnerd-artifact.js')).href);
|
||||
const { PAPERCLIP_RUNNER_BUILD_METADATA: expected } = await import(pathToFileURL(join(output, 'evals/build-metadata.js')).href);
|
||||
const manifest = JSON.parse(readFileSync(join(output, 'bin/release-manifest.json'), 'utf8'));
|
||||
const targets = ['darwin-arm64', 'darwin-x64', 'linux-x64'];
|
||||
assert.equal(manifest.schema, 'paperclip.runner.release-binaries.v1');
|
||||
assert.equal(manifest.sourceRevision, ${JSON.stringify(sourceRevision)});
|
||||
assert.deepEqual(Object.keys(manifest.platforms).sort(), targets.sort());
|
||||
for (const target of targets) {
|
||||
const record = manifest.platforms[target];
|
||||
assert.equal(record.path, target + '/paperclip-runnerd');
|
||||
const file = join(output, 'bin', record.path); assert.ok(inside(file));
|
||||
const bytes = readFileSync(file);
|
||||
assert.equal(runnerBinaryTarget(bytes), target);
|
||||
assert.equal('sha256:' + createHash('sha256').update(bytes).digest('hex'), record.sha256);
|
||||
}
|
||||
const target = process.platform + '-' + process.arch, selected = defaultCapabilityRunnerdBinary();
|
||||
assert.ok(inside(selected));
|
||||
assert.equal(realpathSync(selected), realpathSync(join(output, 'bin', manifest.platforms[target].path)), 'Production must select the packaged host daemon');
|
||||
const metadata = parsePaperclipRunnerdBuildMetadata(JSON.parse(execFileSync(selected, ['--build-metadata'], {
|
||||
timeout: 15000, maxBuffer: 64 * 1024, encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, NODE_PATH: '' },
|
||||
})));
|
||||
assert.equal(metadata.binaryContractVersion, expected.contracts.runnerdArtifact);
|
||||
assert.equal(metadata.nativeExecutionVersion, expected.contracts.nativeExecution);
|
||||
assert.equal(metadata.harnessDriverVersion, expected.contracts.harnessDriver);
|
||||
assert.equal(metadata.prp.name, expected.prp.name);
|
||||
assert.ok(metadata.prp.minimumVersion <= expected.prp.maximumVersion && metadata.prp.maximumVersion >= expected.prp.minimumVersion);
|
||||
console.log(JSON.stringify({ installedRunnerDaemonVerified: true, target, sourceRevision: manifest.sourceRevision,
|
||||
sha256: manifest.platforms[target].sha256, buildMetadata: metadata, providerCalls: 0 }));
|
||||
`;
|
||||
}
|
||||
|
||||
// Hosted, credential-free Mac consumers defer only npm lifecycle execution.
|
||||
// Offline npm flags are not an OS network boundary; sandbox-exec supplies it.
|
||||
export function macPublicInstallLifecycleProfile(ownedRoot) {
|
||||
if (!ownedRoot.startsWith('/') || ownedRoot.includes('\0')) throw new Error('Mac lifecycle requires an absolute owned root');
|
||||
return `(version 1)\n(allow default)\n(deny network*)\n(deny file-write* (require-all (require-not (subpath ${JSON.stringify(ownedRoot)})) (require-not (literal "/dev/null"))))\n`;
|
||||
}
|
||||
|
||||
export function macInstalledStartupProbeSource(consumerRoot, serverRoot, runnerDigest, helperPath) {
|
||||
if (!/^sha256:[a-f0-9]{64}$/.test(runnerDigest)) throw new Error('Installed Mac startup requires the verified SHA-256 manifest identity');
|
||||
return `import { probeInstalledCodexStartup } from ${JSON.stringify(helperPath)};
|
||||
console.log(JSON.stringify(await probeInstalledCodexStartup({consumerRoot:${JSON.stringify(consumerRoot)},
|
||||
serverRoot:${JSON.stringify(serverRoot)},expectedRunnerSha256:${JSON.stringify(runnerDigest.slice(7))}})));`;
|
||||
}
|
||||
|
||||
export function grokConsumerDockerArgs({ assets, consumer, cache, command, uid, gid, download = false, prerequisite, temporarySizeMiB = 256, temporaryExecutable = false }) {
|
||||
if (!Number.isSafeInteger(uid) || uid <= 0 || !Number.isSafeInteger(gid) || gid <= 0) {
|
||||
throw new Error('Public-install verification requires an unprivileged host user');
|
||||
|
||||
@@ -284,6 +284,16 @@ release_info ""
|
||||
release_info "==> Step 2/7: Building workspace artifacts..."
|
||||
cd "$REPO_ROOT"
|
||||
pnpm build
|
||||
# Hosted producers supply data-only, hash/source/architecture-bound daemon
|
||||
# receipts. Assemble after the build (which stages its host binary), before
|
||||
# public packages are packed. The server vendors this same compiled runner.
|
||||
if [ -n "${PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR:-}" ]; then
|
||||
node "$REPO_ROOT/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs" \
|
||||
assemble-daemons "$PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR" "$(git rev-parse HEAD)"
|
||||
cp -R "$REPO_ROOT/packages/paperclip-runner/dist/bin/." "$REPO_ROOT/server/dist/vendor/paperclip-runner/bin/"
|
||||
elif [ "$dry_run" = false ]; then
|
||||
release_fail "Public releases require the three hosted runner daemon artifacts; use the supported Release workflow."
|
||||
fi
|
||||
node "$REPO_ROOT/scripts/build-standalone-public-packages.mjs"
|
||||
bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh"
|
||||
for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do
|
||||
|
||||
@@ -4,14 +4,95 @@
|
||||
// explicit and separate from npm installation, and is removed in finally.
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { materializePublishManifest, prepareBundledPackage } from './prepare-bundled-package.mjs';
|
||||
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource } from './grok-public-install-sandbox.mjs';
|
||||
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource, installedDaemonProbeSource, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource } from './grok-public-install-sandbox.mjs';
|
||||
import { retainRunnerQualificationPackages } from './retain-runner-qualification-packages.mjs';
|
||||
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
async function verifyMacConsumer(packageDirectory, sourceRevision, receiptPath) {
|
||||
assert.equal(process.env.GITHUB_ACTIONS, 'true', 'Mac clean consumers run only on disposable hosted CI');
|
||||
assert.equal(process.platform, 'darwin');
|
||||
assert.ok(['arm64', 'x64'].includes(process.arch));
|
||||
assert.match(sourceRevision, /^[a-f0-9]{40}$/);
|
||||
const packages = realpathSync(packageDirectory);
|
||||
const manifest = JSON.parse(readFileSync(join(packages, 'manifest.json'), 'utf8'));
|
||||
assert.equal(manifest.schema, 'paperclip.runner.public-packages/v1');
|
||||
assert.equal(manifest.sourceRevision, sourceRevision);
|
||||
assert.ok(manifest.archives.some(entry => entry.name === '@paperclipai/server'));
|
||||
assert.ok(manifest.archives.some(entry => entry.name === 'paperclipai'));
|
||||
assert.equal(new Set(manifest.archives.map(entry => entry.name)).size, manifest.archives.length);
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), 'paperclip-mac-public-install-')));
|
||||
try {
|
||||
const consumer = join(root, 'consumer'), home = join(root, 'home'), tools = join(root, 'tools');
|
||||
for (const directory of [consumer, home, tools, join(root, 'tmp'), join(root, 'cache')]) mkdirSync(directory);
|
||||
const npmCli = realpathSync(execFileSync('/usr/bin/which', ['npm'], { encoding: 'utf8' }).trim());
|
||||
symlinkSync(process.execPath, join(tools, 'node'));
|
||||
// npm subprocesses get a Node-only owned tool directory; no ambient harness
|
||||
// command can hide missing installed dependencies.
|
||||
const env = { PATH: `${tools}:/usr/bin:/bin:/usr/sbin:/sbin`, HOME: home, TMPDIR: join(root, 'tmp'),
|
||||
npm_config_cache: join(root, 'cache'), npm_config_audit: 'false', npm_config_fund: 'false' };
|
||||
const run = (command, args, cwd = consumer) => execFileSync(command, args, { cwd, env,
|
||||
timeout: 10 * 60_000, maxBuffer: 32 * 1024 * 1024, encoding: 'utf8' });
|
||||
const tarballs = manifest.archives.map(entry => {
|
||||
assert.match(entry.file, /^[A-Za-z0-9._-]+\.tgz$/); assert.match(entry.sha256, /^[a-f0-9]{64}$/);
|
||||
const path = join(packages, entry.file);
|
||||
assert.equal(realpathSync(path), path);
|
||||
assert.equal(createHash('sha256').update(readFileSync(path)).digest('hex'), entry.sha256);
|
||||
assertNoBundledCodexPayloads(run('/usr/bin/tar', ['-tzf', path]).trim().split('\n'));
|
||||
return path;
|
||||
});
|
||||
writeFileSync(join(consumer, 'package.json'), JSON.stringify({ private: true, type: 'module' }));
|
||||
const sentinelSource = join(root, 'lifecycle-sentinel'); mkdirSync(sentinelSource);
|
||||
writeFileSync(join(sentinelSource, 'package.json'), JSON.stringify({ name: 'paperclip-verification-lifecycle-sentinel', version: '1.0.0',
|
||||
scripts: { postinstall: 'node -e "require(\'node:fs\').writeFileSync(\'lifecycle-ran\', \'ok\')"' } }));
|
||||
run(process.execPath, [npmCli, 'pack', '--ignore-scripts', '--pack-destination', root], sentinelSource);
|
||||
const sentinelTar = join(root, readdirSync(root).find(name => name.endsWith('.tgz')));
|
||||
run(process.execPath, [npmCli, 'install', '--ignore-scripts', '--omit=dev', ...tarballs, sentinelTar]);
|
||||
const sentinel = join(consumer, 'node_modules/paperclip-verification-lifecycle-sentinel/lifecycle-ran');
|
||||
assert.equal(existsSync(sentinel), false);
|
||||
const lock = readFileSync(join(consumer, 'package-lock.json'), 'utf8');
|
||||
const profile = join(root, 'lifecycle.sb'); writeFileSync(profile, macPublicInstallLifecycleProfile(root));
|
||||
const listener = createServer(socket => socket.end());
|
||||
await new Promise((resolve, reject) => { listener.once('error', reject); listener.listen(0, '127.0.0.1', resolve); });
|
||||
try {
|
||||
const port = listener.address().port;
|
||||
const networkProbe = `import assert from 'node:assert/strict'; import net from 'node:net';
|
||||
const socket = net.connect({host:'127.0.0.1',port:${port}}); socket.setTimeout(3000,()=>process.exit(2));
|
||||
socket.once('connect',()=>{socket.destroy(); ${"process.argv[1] === 'denied' ? process.exit(3) : process.exit(0)"};});
|
||||
socket.once('error',error=>{assert.equal(process.argv[1],'denied'); assert.equal(error.code,'EPERM');});`;
|
||||
run(process.execPath, ['--input-type=module', '-e', networkProbe, 'allowed']);
|
||||
run('/usr/bin/sandbox-exec', ['-f', profile, process.execPath, '--input-type=module', '-e', networkProbe, 'denied']);
|
||||
} finally { await new Promise(resolve => listener.close(resolve)); }
|
||||
run('/usr/bin/sandbox-exec', ['-f', profile, process.execPath, npmCli, 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts', '--foreground-scripts']);
|
||||
assert.equal(readFileSync(sentinel, 'utf8'), 'ok');
|
||||
assert.equal(readFileSync(join(consumer, 'package-lock.json'), 'utf8'), lock);
|
||||
for (const entry of manifest.archives) {
|
||||
assert.equal(JSON.parse(readFileSync(join(consumer, 'node_modules', entry.name, 'package.json'))).version, manifest.releaseVersion);
|
||||
}
|
||||
const server = join(consumer, 'node_modules/@paperclipai/server'), installed = join(server, 'dist/vendor/paperclip-runner');
|
||||
const probe = (name, source) => { const file = join(root, name); writeFileSync(file, source); return JSON.parse(run(process.execPath, [file]).trim()); };
|
||||
const daemon = probe('daemon-probe.mjs', installedDaemonProbeSource(installed, consumer, sourceRevision));
|
||||
const codex = probe('codex-probe.mjs', installedCodexProbeSource(join(installed, 'index.js'), consumer, undefined, { nativeCodex: true }));
|
||||
const startupScript = macInstalledStartupProbeSource(consumer, server, daemon.sha256, join(repo, 'tests/runner-e2e/installed-cli.ts'));
|
||||
const startupFile = join(root, 'startup-probe.mts'); writeFileSync(startupFile, startupScript);
|
||||
const startup = JSON.parse(run(process.execPath, ['--import', 'tsx', startupFile], join(repo, 'server')).trim());
|
||||
const receipt = { schema: 'paperclip.runner.mac-public-install.v1', sourceRevision, platform: process.platform, architecture: process.arch,
|
||||
cleanNpmInstall: true, packageCount: manifest.archives.length, codexPackedPayloadsOmitted: true,
|
||||
consumerLockPreserved: true, lifecycleSentinelVerified: true, lifecycleNetwork: 'sandbox-exec denied (loopback negative control)',
|
||||
...daemon, ...codex, startup, providerCalls: 0 };
|
||||
writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + '\n');
|
||||
console.log(JSON.stringify(receipt));
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
}
|
||||
if (process.argv[2] === '--mac-consumer') {
|
||||
assert.equal(process.argv.length, 6, 'Usage: --mac-consumer PACKAGE_DIRECTORY SOURCE_SHA RECEIPT_PATH');
|
||||
await verifyMacConsumer(process.argv[3], process.argv[4], process.argv[5]);
|
||||
} else {
|
||||
assert.equal(process.platform, 'linux', 'Run this verification on disposable EC2 Linux, not a developer host');
|
||||
const root = mkdtempSync(join(tmpdir(), 'paperclip-grok-public-install-'));
|
||||
const prerequisite = join(root, 'native/grok');
|
||||
@@ -64,6 +145,24 @@ try {
|
||||
assertNoBundledCodexPayloads(run('tar', ['-tzf', tarball]).toString().trim().split('\n'));
|
||||
tarballs.push(tarball);
|
||||
}
|
||||
// Preserve the exact Linux-produced public tarballs for independent Mac
|
||||
// consumers even if a later Linux runtime probe fails. This is a packaging
|
||||
// receipt, never an installation or provider-admission pass.
|
||||
if (process.env.PAPERCLIP_RUNNER_PUBLIC_PACKS_DIR) {
|
||||
const output = resolve(process.env.PAPERCLIP_RUNNER_PUBLIC_PACKS_DIR);
|
||||
assert.equal(JSON.parse(readFileSync(join(repo, 'server/dist/build-info.json'))).commit, sourceRevision);
|
||||
assert.ok(!existsSync(output), 'Never replace retained public packages');
|
||||
mkdirSync(output, { recursive: true });
|
||||
const { createHash } = await import('node:crypto');
|
||||
const archives = [...needed].map((name, index) => {
|
||||
const file = basename(tarballs[index]); cpSync(tarballs[index], join(output, file));
|
||||
return { name, file, sha256: createHash('sha256').update(readFileSync(tarballs[index])).digest('hex') };
|
||||
});
|
||||
writeFileSync(join(output, 'manifest.json'), JSON.stringify({
|
||||
schema: 'paperclip.runner.public-packages/v1', sourceRevision, releaseVersion,
|
||||
publicInstallationVerified: false, providerCalls: 0, archives,
|
||||
}, null, 2) + '\n');
|
||||
}
|
||||
const assets = join(root, 'assets'); mkdirSync(assets, { mode: 0o755 });
|
||||
const consumer = join(root, 'consumer'); mkdirSync(consumer);
|
||||
const cache = join(root, 'cache'); mkdirSync(cache);
|
||||
@@ -153,3 +252,4 @@ try {
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -168,7 +168,10 @@ describe("adapter routes", () => {
|
||||
|
||||
it.each([
|
||||
[{ driver: "ssh", platform: "linux", architecture: "x64" }, "paperclip"],
|
||||
[{ driver: "ssh", platform: "darwin", architecture: "arm64" }, "legacy"],
|
||||
[{ driver: "ssh", platform: "darwin", architecture: "arm64" }, "paperclip"],
|
||||
[{ driver: "ssh", platform: "darwin", architecture: "x64" }, "paperclip"],
|
||||
[{ driver: "ssh", platform: "linux", architecture: "arm64" }, "legacy"],
|
||||
[{ driver: "local", platform: "win32", architecture: "x64" }, "legacy"],
|
||||
])("projects the selected environment once for the whole adapter inventory (%s)", async (target, defaultRunner) => {
|
||||
const resolveRunnerTarget = vi.fn(async () => target);
|
||||
const response = await request(createApp({}, { resolveRunnerTarget })).get("/api/adapters?companyId=company-1&environmentId=selected");
|
||||
|
||||
@@ -1260,7 +1260,9 @@ describe("agent skill routes", () => {
|
||||
const codexRunnerCases = [
|
||||
{ adapterType: "codex_local", runner: "auto", platform: "linux", expectedType: "paperclip_runner" },
|
||||
{ adapterType: "codex_local", runner: "legacy", platform: "linux", expectedType: "codex_local" },
|
||||
{ adapterType: "codex_local", runner: "auto", platform: "darwin", expectedType: "codex_local" },
|
||||
{ adapterType: "codex_local", runner: "auto", platform: "darwin", expectedType: "paperclip_runner" },
|
||||
{ adapterType: "codex_local", runner: "legacy", platform: "darwin", expectedType: "codex_local" },
|
||||
{ adapterType: "codex_local", runner: "auto", platform: "win32", expectedType: "codex_local" },
|
||||
];
|
||||
it.each([...codexRunnerCases, { adapterType: "claude_local", runner: "auto", platform: "linux", expectedType: "claude_local" }].flatMap((runnerCase) => [
|
||||
["agents", "paperclipai/paperclip/paperclip-create-agent"],
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { agentService } from "../services/agents.ts";
|
||||
import { approvalService } from "../services/approvals.ts";
|
||||
import { paperclipRunnerSupportsPlatform } from "@paperclipai/shared";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
@@ -71,9 +72,7 @@ describeEmbeddedPostgres("pending approval agent config integrity", () => {
|
||||
const native = await lifecycle.requestHire(companyId, { name: "New Codex", adapterType: "codex_local", adapterConfig: { modelReasoningEffort: "high" }, status: "pending_approval" });
|
||||
const legacy = await lifecycle.requestHire(companyId, { name: "Legacy Codex", adapterType: "codex_local", runner: "legacy", adapterConfig: { extraArgs: ["--search"] }, status: "pending_approval" });
|
||||
const reviewedLegacy = await lifecycle.requestHire(companyId, { name: "Historical approved hire", adapterType: "codex_local", adapterConfig: {}, status: "pending_approval" }, { runnerResolved: true });
|
||||
// Only public Linux x64 artifacts qualify an automatic native default;
|
||||
// source-built macOS/Windows agents keep legacy until their packaging is qualified.
|
||||
if (process.platform === "linux" && process.arch === "x64") {
|
||||
if (paperclipRunnerSupportsPlatform("codex_local", process.platform, process.arch)) {
|
||||
expect(native).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", modelReasoningEffort: "high" } });
|
||||
} else {
|
||||
expect(native).toMatchObject({ adapterType: "codex_local", adapterConfig: { modelReasoningEffort: "high" } });
|
||||
|
||||
@@ -267,7 +267,9 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
])).toThrow("defaultAdapterType must be allowed");
|
||||
});
|
||||
|
||||
it("lazily provisions one agent per company/key and updates the same row on setup", async () => {
|
||||
it.each([{ platform: "linux", architecture: "x64" }, { platform: "darwin", architecture: "arm64" }, { platform: "darwin", architecture: "x64" }])("lazily provisions one agent per company/key and updates the same row on setup ($platform/$architecture)", async ({ platform, architecture }) => {
|
||||
runnerTarget.platform = platform;
|
||||
runnerTarget.architecture = architecture;
|
||||
const companyId = await seedCompany();
|
||||
const svc = builtInAgentService(db);
|
||||
|
||||
@@ -327,9 +329,9 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
expect(switchedBack.agent).toMatchObject({ adapterType: "codex_local", adapterConfig: savedSettings });
|
||||
const switchedAgain = await svc.ensure(companyId, "briefs", { runner: "paperclip" });
|
||||
expect(switchedAgain.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", ...savedSettings } });
|
||||
// A saved native profile remains editable on source-built macOS installs.
|
||||
runnerTarget.platform = "darwin";
|
||||
runnerTarget.architecture = "arm64";
|
||||
// Existing native execution survives editing on an unqualified target.
|
||||
runnerTarget.platform = "win32";
|
||||
runnerTarget.architecture = "x64";
|
||||
const edited = await svc.ensure(companyId, "briefs", { adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } });
|
||||
expect(edited.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.4" } });
|
||||
});
|
||||
@@ -339,8 +341,8 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
const result = await builtInAgentService(db).provision(companyId, "briefs", { adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" } }, { requestedByUserId: "board-user" });
|
||||
expect(result.approval?.payload).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.4" } });
|
||||
// Restart and approval activate the reviewed config even if eligibility changed.
|
||||
runnerTarget.platform = "darwin";
|
||||
runnerTarget.architecture = "arm64";
|
||||
runnerTarget.platform = "win32";
|
||||
runnerTarget.architecture = "x64";
|
||||
await reconcileBuiltInAgentsOnStartup(db);
|
||||
await approvalService(db).approve(result.approval!.id, "board-user");
|
||||
const approved = await builtInAgentService(db).get(companyId, "briefs");
|
||||
|
||||
@@ -6067,6 +6067,7 @@ describe("company portability", () => {
|
||||
|
||||
it.each([
|
||||
{ runner: "legacy", platform: "linux" },
|
||||
{ runner: "legacy", platform: "darwin" },
|
||||
{ runner: "paperclip", platform: "linux" },
|
||||
{ runner: "paperclip", platform: "darwin" },
|
||||
] as const)("exports the resolved Codex runner and preserves it on import ($runner/$platform)", async ({ runner, platform }) => {
|
||||
@@ -6099,7 +6100,9 @@ describe("company portability", () => {
|
||||
it.each([
|
||||
{ action: "create", platform: "linux", expectedType: "paperclip_runner" },
|
||||
{ action: "update", platform: "linux", expectedType: "codex_local" },
|
||||
{ action: "create", platform: "darwin", expectedType: "codex_local" },
|
||||
{ action: "create", platform: "darwin", expectedType: "paperclip_runner" },
|
||||
{ action: "update", platform: "darwin", expectedType: "codex_local" },
|
||||
{ action: "create", platform: "win32", expectedType: "codex_local" },
|
||||
])("applies qualified Codex defaults only to new imports without a runner ($platform/$action)", async ({ action, platform, expectedType }) => {
|
||||
runnerTarget.platform = platform;
|
||||
runnerTarget.architecture = platform === "darwin" ? "arm64" : "x64";
|
||||
@@ -6118,6 +6121,7 @@ describe("company portability", () => {
|
||||
|
||||
it.each([
|
||||
{ existingRunner: "legacy", existingHarness: "codex_local", expectedType: "codex_local", platform: "linux" },
|
||||
{ existingRunner: "legacy", existingHarness: "codex_local", expectedType: "codex_local", platform: "darwin" },
|
||||
{ existingRunner: "paperclip", existingHarness: "codex_local", expectedType: "paperclip_runner", platform: "linux" },
|
||||
{ existingRunner: "paperclip", existingHarness: "codex_local", expectedType: "paperclip_runner", platform: "darwin" },
|
||||
{ existingRunner: "legacy", existingHarness: "claude_local", expectedType: "paperclip_runner", platform: "linux" },
|
||||
|
||||
@@ -110,6 +110,24 @@ describe("paperclip runner native dev prerequisite", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["arm64", "x64"])("uses the staged Mac %s target before the flat build output", architecture => {
|
||||
const checkout = createRunnerCheckout();
|
||||
const target = path.join(path.dirname(checkout.binary), `darwin-${architecture}`, "paperclip-runnerd");
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, "target-runnerd");
|
||||
const old = new Date(Date.now() - 2000), current = new Date(Date.now() + 2000);
|
||||
fs.utimesSync(checkout.binary, old, old);
|
||||
fs.utimesSync(target, current, current);
|
||||
const input = { repoRoot: checkout.root, nativeRunnerRequired: true, platform: "darwin", architecture };
|
||||
expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(false);
|
||||
fs.rmSync(checkout.binary);
|
||||
expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(false);
|
||||
fs.utimesSync(target, old, old);
|
||||
expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(true);
|
||||
fs.rmSync(target);
|
||||
expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not build a workspace binary when an explicit binary is configured", () => {
|
||||
expect(
|
||||
paperclipRunnerBinaryNeedsBuild({
|
||||
|
||||
@@ -284,7 +284,9 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("creates managed agents with the most-used compatible company adapter", async () => {
|
||||
it.each([{ platform: "linux", architecture: "x64" }, { platform: "darwin", architecture: "arm64" }, { platform: "darwin", architecture: "x64" }])("creates managed agents with the most-used compatible company adapter ($platform/$architecture)", async ({ platform, architecture }) => {
|
||||
runnerTarget.platform = platform;
|
||||
runnerTarget.architecture = architecture;
|
||||
const pluginManifest = manifest();
|
||||
pluginManifest.agents![0] = {
|
||||
...pluginManifest.agents![0]!,
|
||||
@@ -336,8 +338,8 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
|
||||
const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
expect(reconciled.agent?.adapterConfig).toEqual(created.agent?.adapterConfig);
|
||||
// Reset declaration defaults without requalifying an existing native agent.
|
||||
runnerTarget.platform = "darwin";
|
||||
runnerTarget.architecture = "arm64";
|
||||
runnerTarget.platform = "win32";
|
||||
runnerTarget.architecture = "x64";
|
||||
const reset = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" });
|
||||
expect(reset.agent).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } });
|
||||
});
|
||||
|
||||
@@ -9,10 +9,11 @@ vi.mock("./environment-config.js", () => ({ resolveEnvironmentDriverConfigForRun
|
||||
vi.mock("@paperclipai/adapter-utils/ssh", () => ({ runSshCommand: state.ssh }));
|
||||
import { agentRunnerAvailability, resolveAgentRunnerTargetForCompany, resolveNewAgentRunner, resolveNewAgentRunnerForCompany } from "./agent-runner-selection.js";
|
||||
const target = { driver: "local", platform: "linux", architecture: "x64" };
|
||||
const qualifiedTargets = [target, { driver: "local", platform: "darwin", architecture: "arm64" }, { driver: "local", platform: "darwin", architecture: "x64" }];
|
||||
|
||||
describe("server-owned Codex runner selection", () => {
|
||||
beforeEach(() => { state.disabled = []; state.overridden.clear(); state.settings = {}; state.getEnvironment.mockReset(); state.bindings.mockReset().mockResolvedValue([]); state.managedEnvironment.mockReset(); state.ssh.mockReset().mockResolvedValue({ stdout: "Linux\nx86_64\n" }); state.resolveEnvironment.mockReset().mockResolvedValue({ driver: "ssh", config: {} }); });
|
||||
it("uses native Codex automatically with compatible configuration and is idempotent", () => {
|
||||
it.each(qualifiedTargets)("uses native Codex automatically with compatible configuration and is idempotent on $platform/$architecture", target => {
|
||||
const input = { adapterType: "codex_local", target, adapterConfig: { model: "gpt-5.6-sol", modelReasoningEffort: "high", env: { OPENAI_API_KEY: { type: "secret_ref", secretId: "secret" } }, cwd: "/workspace", instructionsFilePath: "/workspace/AGENTS.md", workspaceStrategy: { type: "git_worktree" }, timeoutSec: 60, search: false, fastMode: false, dangerouslyBypassApprovalsAndSandbox: true } };
|
||||
const resolved = resolveNewAgentRunner(input);
|
||||
expect(resolved).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", codexPermissionMode: "never", modelReasoningEffort: "high", env: input.adapterConfig.env, workspaceStrategy: input.adapterConfig.workspaceStrategy, timeoutSec: 60 } });
|
||||
@@ -23,7 +24,7 @@ describe("server-owned Codex runner selection", () => {
|
||||
it.each(["claude_local", "opencode_local", "grok_local", "cursor", "pi_local", "process", "external"])("keeps %s default unchanged", adapterType => {
|
||||
expect(resolveNewAgentRunner({ adapterType, target, adapterConfig: { custom: "preserved" } })).toEqual({ adapterType, adapterConfig: { custom: "preserved" } });
|
||||
});
|
||||
it("allows an explicit legacy runner and native to legacy round trip", () => {
|
||||
it.each(qualifiedTargets)("allows an explicit legacy runner and native to legacy round trip on $platform/$architecture", target => {
|
||||
expect(resolveNewAgentRunner({ adapterType: "codex_local", target, runner: "legacy", adapterConfig: { extraArgs: ["--search"] } }).adapterType).toBe("codex_local");
|
||||
expect(resolveNewAgentRunner({ adapterType: "paperclip_runner", runner: "legacy", adapterConfig: { provider: "codex", model: "gpt-5.6-sol", lifecycleMode: "per_turn", codexPermissionMode: "never", modelReasoningEffort: "high" } })).toEqual({ adapterType: "codex_local", adapterConfig: { model: "gpt-5.6-sol", modelReasoningEffort: "high", dangerouslyBypassApprovalsAndSandbox: true } });
|
||||
});
|
||||
@@ -33,23 +34,26 @@ describe("server-owned Codex runner selection", () => {
|
||||
expect(resolveNewAgentRunner({ adapterType: "paperclip_runner", runner: "legacy", adapterConfig: { provider: "codex", codexPermissionMode: "never", lifecycleMode: "per_turn", dangerouslyBypassApprovalsAndSandbox: false } }))
|
||||
.toEqual({ adapterType: "codex_local", adapterConfig: { dangerouslyBypassApprovalsAndSandbox: false } });
|
||||
});
|
||||
it.each(["darwin", "win32"])("keeps unqualified %s targets legacy while preserving explicit native profiles", platform => {
|
||||
const input = { adapterType: "codex_local", target: { driver: "local", platform, architecture: "x64" } };
|
||||
it.each([{ platform: "win32", architecture: "x64" }, { platform: "linux", architecture: "arm64" }, { platform: "darwin", architecture: "ia32" }])("keeps unqualified $platform/$architecture targets legacy while preserving explicit native profiles", ({ platform, architecture }) => {
|
||||
const input = { adapterType: "codex_local", target: { driver: "local", platform, architecture } };
|
||||
expect(resolveNewAgentRunner(input).adapterType).toBe("codex_local");
|
||||
expect(() => resolveNewAgentRunner({ ...input, runner: "paperclip" })).toThrow("unavailable");
|
||||
const saved = { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" }, target: input.target };
|
||||
expect(resolveNewAgentRunner(saved)).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig });
|
||||
expect(resolveNewAgentRunner({ ...saved, runner: "paperclip" })).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig });
|
||||
});
|
||||
it("automatically keeps unsupported targets and active external overrides legacy", () => {
|
||||
it.each(qualifiedTargets)("automatically keeps unsupported targets and active external overrides legacy on $platform/$architecture", target => {
|
||||
const input = { adapterType: "codex_local", target: { driver: "local", platform: "win32", architecture: "x64" } };
|
||||
expect(resolveNewAgentRunner(input).adapterType).toBe("codex_local");
|
||||
expect(() => resolveNewAgentRunner({ ...input, runner: "paperclip" })).toThrow("unavailable");
|
||||
state.overridden.add("codex_local");
|
||||
expect(agentRunnerAvailability("codex_local", target).defaultRunner).toBe("legacy");
|
||||
expect(resolveNewAgentRunner({ adapterType: "codex_local", target }).adapterType).toBe("codex_local");
|
||||
state.overridden.clear();
|
||||
state.overridden.add("paperclip_runner");
|
||||
expect(resolveNewAgentRunner({ adapterType: "codex_local", target }).adapterType).toBe("codex_local");
|
||||
});
|
||||
it("does not select a disabled runner", () => {
|
||||
it.each(qualifiedTargets)("does not select a disabled runner on $platform/$architecture", target => {
|
||||
state.disabled = ["paperclip_runner"];
|
||||
expect(resolveNewAgentRunner({ adapterType: "codex_local", target }).adapterType).toBe("codex_local");
|
||||
expect(() => resolveNewAgentRunner({ adapterType: "codex_local", target, runner: "paperclip" })).toThrow("unavailable");
|
||||
@@ -80,7 +84,9 @@ describe("server-owned Codex runner selection", () => {
|
||||
});
|
||||
it.each([
|
||||
["Linux\nx86_64\n", "paperclip", "paperclip_runner"],
|
||||
["Darwin\narm64\n", "legacy", "codex_local"],
|
||||
["Darwin\narm64\n", "paperclip", "paperclip_runner"],
|
||||
["Darwin\nx86_64\n", "paperclip", "paperclip_runner"],
|
||||
["Linux\naarch64\n", "legacy", "codex_local"],
|
||||
])("uses the same selected SSH target for discovery and saving (%s)", async (stdout, expectedRunner, expectedAdapter) => {
|
||||
state.getEnvironment.mockResolvedValue({ id: "selected", driver: "ssh", status: "active", config: {} });
|
||||
state.ssh.mockResolvedValue({ stdout });
|
||||
@@ -113,9 +119,10 @@ describe("Codex creation runner contract", () => {
|
||||
expect(agentRunner("codex_local")).toBe("legacy");
|
||||
expect(agentRunner("paperclip_runner")).toBe("paperclip");
|
||||
});
|
||||
it("qualifies only the daemon actually shipped in public server packages", () => {
|
||||
expect(paperclipRunnerSupportsPlatform("codex_local", "linux", "x64")).toBe(true);
|
||||
for (const [platform, architecture] of [["darwin", "arm64"], ["darwin", "x64"], ["linux", "arm64"], ["win32", "x64"], ["freebsd", "x64"]]) expect(paperclipRunnerSupportsPlatform("codex_local", platform, architecture)).toBe(false);
|
||||
it("qualifies only the Codex daemon targets shipped in public server packages", () => {
|
||||
for (const { platform, architecture } of qualifiedTargets) expect(paperclipRunnerSupportsPlatform("codex_local", platform, architecture)).toBe(true);
|
||||
for (const [platform, architecture] of [["darwin", "ia32"], ["linux", "arm64"], ["win32", "x64"], ["win32", "arm64"], ["freebsd", "x64"]]) expect(paperclipRunnerSupportsPlatform("codex_local", platform, architecture)).toBe(false);
|
||||
expect(paperclipRunnerSupportsPlatform("claude_local", "darwin", "arm64")).toBe(false);
|
||||
});
|
||||
it("keeps auto out of saved update defaults and validates request intent", () => {
|
||||
expect(createAgentSchema.parse({ name: "Codex", adapterType: "codex_local" }).runner).toBeUndefined();
|
||||
|
||||
@@ -2001,12 +2001,31 @@ Run the explicit `paperclipai runtime setup pi` for that installation first.
|
||||
This lane rejects candidate flags, local/remote daemon overrides, provider asset
|
||||
or pack overrides, and Node injection. It launches the installed JavaScript CLI
|
||||
without a TypeScript loader, rechecks its pins on controller restart, and records
|
||||
`installed-cli-admission.json` in private attempt evidence. It supports only the
|
||||
explicit Pi extended, native, controls and warm suites, which require none of the
|
||||
`installed-cli-admission.json` in private attempt evidence. Pi uses the explicit
|
||||
extended, native, controls and warm suites, which require none of the
|
||||
source-only response barriers. Cursor/Copilot qualification gates stay intact.
|
||||
Pi's historical `qualificationCandidate` fixture selector remains a roster key;
|
||||
it no longer grants an opt-in when the source Pi declaration admits normal use.
|
||||
|
||||
The same four installed-package pins also admit three existing local native
|
||||
Codex cells: `first-task.runner-codex.local.task-card-accept`,
|
||||
`everyday-workflows.runner-codex.local.build-revise`, and
|
||||
`agent-chat.runner-codex.local.continuity-restart`. Select exact IDs; other
|
||||
Codex cells, legacy profiles and remote environments remain outside this lane.
|
||||
These reuse the onboarding, independently checked file/revision, and restart
|
||||
graders. They require separately authorized provider access and are not executed
|
||||
by the provider-free release smoke.
|
||||
|
||||
The release consumer calls `probeInstalledCodexStartup` from `installed-cli.ts`
|
||||
against its installed server, normal platform daemon and contained npm Codex.
|
||||
It checks a real native read-only session and joined cleanup using empty homes,
|
||||
file-only authentication storage and a closed environment. It sends no provider
|
||||
turn, consults no account, records the actual CLI version without an equality
|
||||
gate, and exposes no session IDs. Build/source, Mach-O/ELF and official npm
|
||||
provenance checks precede this probe. Startup proof does not establish useful-file
|
||||
delivery, authentication, reasoning effort or follow-up behavior; the existing
|
||||
live journeys must prove those separately.
|
||||
|
||||
Installed Pi Daytona cells also require the published plugin fixture inputs
|
||||
`PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN` (canonical package root),
|
||||
`PAPERCLIP_RUNNER_E2E_INSTALLED_DAYTONA_PLUGIN_AUTHORITY` (canonical JSON file),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { runnerMatrix } from "./catalog.js";
|
||||
import { assertInstalledCliSelection, installedCliKeys, verifyInstalledCli } from "./installed-cli.js";
|
||||
import { assertInstalledCliSelection, installedCliKeys, probeInstalledCodexStartup, verifyInstalledCli } from "./installed-cli.js";
|
||||
import { buildPaperclipServerEnvironment, buildRunnerE2EProcessEnvironment } from "./harness-env.js";
|
||||
const cells = runnerMatrix.filter(e => e.profile.id === "runner-acpx-pi");
|
||||
const roots: string[] = [];
|
||||
@@ -25,6 +25,20 @@ it("admits all26 current Pi cases through pinned installed public CLI/server wit
|
||||
const { env, server } = await fixture(); expect(cells).toHaveLength(26);
|
||||
for (const cell of cells) expect(await verifyInstalledCli(env, [cell])).toMatchObject({ serverRoot: server, defaultRuntimeResolution: true, qualificationOverride: false });
|
||||
});
|
||||
it("admits existing Codex onboarding, useful-file revision and restart journeys without broadening other cells", async () => {
|
||||
const { env, server } = await fixture();
|
||||
for (const id of ["first-task.runner-codex.local.task-card-accept", "everyday-workflows.runner-codex.local.build-revise", "agent-chat.runner-codex.local.continuity-restart"]) {
|
||||
const cell = runnerMatrix.find(e => e.id === id)!;
|
||||
expect(cell).toBeDefined();
|
||||
expect(await verifyInstalledCli(env, [cell])).toMatchObject({ serverRoot: server, defaultRuntimeResolution: true, qualificationOverride: false });
|
||||
for (const key of ["PAPERCLIP_RUNNER_BINARY", "NODE_PATH"]) expect(() => assertInstalledCliSelection({ ...env, [key]: "foreign" }, [cell])).toThrow("forbids");
|
||||
}
|
||||
for (const id of ["first-task.legacy-codex.local.task-card-accept", "first-task.runner-codex.local.interview-first-response", "everyday-workflows.runner-codex.daytona.build-revise"]) {
|
||||
const cell = runnerMatrix.find(e => e.id === id)!;
|
||||
expect(cell).toBeDefined();
|
||||
expect(() => assertInstalledCliSelection(env, [cell])).toThrow("explicit supported Pi or Codex");
|
||||
}
|
||||
});
|
||||
it("qualified providers drop ambient admission while pending Copilot retains its exact gate", () => {
|
||||
for (const cell of cells) expect(buildRunnerE2EProcessEnvironment({ PAPERCLIP_RUNNER_ACPX_QUALIFICATION: "ambient" }, [cell]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION).toBeUndefined();
|
||||
for (const agent of ["copilot"]) {
|
||||
@@ -92,3 +106,60 @@ it("startup-only accepts only explicit uncredentialed local hello and zero retri
|
||||
expect(()=>assertInstalledStartupOnly({...f.env,KIMI_MODEL_API_KEY:"dummy-not-a-real-key"},[cell],options)).toThrow("credential inputs");
|
||||
expect(()=>assertInstalledStartupOnly({},[cell],options)).toThrow("startup-only");
|
||||
});
|
||||
|
||||
async function startupFixture(mode: "normal" | "wrong-model" | "ambient-codex" = "normal") {
|
||||
const f = await fixture();
|
||||
const vendor = join(f.server, "dist/vendor/paperclip-runner");
|
||||
const daemon = join(vendor, "bin", `${process.platform}-${process.arch}`, "paperclip-runnerd");
|
||||
const codex = join(f.root, "node_modules/@openai/codex/bin/codex");
|
||||
const observations = join(f.root, "startup-observations.json");
|
||||
await mkdir(join(vendor, "bin", `${process.platform}-${process.arch}`), { recursive: true });
|
||||
await mkdir(join(f.server, "dist/services/native-runtime"), { recursive: true });
|
||||
await mkdir(join(f.root, "node_modules/@openai/codex/bin"), { recursive: true });
|
||||
await writeFile(daemon, "fixture-daemon");
|
||||
await writeFile(codex, "#!/bin/sh\nprintf 'codex-cli 0.149.5\\n'\n");
|
||||
await chmod(codex, 0o755);
|
||||
await writeFile(join(f.server, "dist/services/native-runtime/native-codex-runner.js"), `export function resolvePaperclipRunnerBinary() { return ${JSON.stringify(daemon)}; }`);
|
||||
await writeFile(join(vendor, "index.js"), `
|
||||
import {writeFileSync} from "node:fs";
|
||||
const state={calls:[],environmentKeys:[],fileAuthPinned:false};let retired=false;
|
||||
const save=()=>writeFileSync(${JSON.stringify(observations)},JSON.stringify(state));
|
||||
export function resolveCodexCommand(){return ${JSON.stringify(mode === "ambient-codex" ? process.execPath : codex)};}
|
||||
export function createIsolatedCodexAppServerArgs(){return ["app-server"];}
|
||||
export function createRunnerdCodexTransport(options){
|
||||
state.environmentKeys=Object.keys(options.environment).sort();
|
||||
state.fileAuthPinned=options.codexArgs.includes('cli_auth_credentials_store="file"');
|
||||
save();return {transport:{async close(){retired=true;state.calls.push("transport-close");save();}},
|
||||
evidence:()=>({runnerPid:111,codexPid:222,providerDriver:"codex_app_server",runnerExited:retired})};}
|
||||
export class CodexAppServerDriver {
|
||||
constructor(options){this.options=options;}
|
||||
async openSession(){state.calls.push("open");save();const model=${JSON.stringify(mode === "wrong-model" ? "wrong-model" : "gpt-5.6-sol")};
|
||||
return {ids:()=>({driverSessionId:"PRIVATE-DRIVER-ID",providerSessionId:"PRIVATE-PROVIDER-ID"}),
|
||||
events:()=>({async *[Symbol.asyncIterator](){yield {eventType:"session.started",payload:{context:{model,sandbox:{permissionProfile:{id:"paperclip-runner-workspace-read-only"}}}}};}}),
|
||||
startTurn(){state.calls.push("FORBIDDEN-TURN");save();throw new Error("No provider turn is permitted");},
|
||||
async close(){retired=true;state.calls.push("session-close");save();}};}
|
||||
}
|
||||
`);
|
||||
return { ...f, observations, expectedRunnerSha256: hash("fixture-daemon") };
|
||||
}
|
||||
|
||||
it.skipIf(process.platform === "win32")("installed native startup uses closed homes, file-only auth and no turn or version equality gate", async () => {
|
||||
const f = await startupFixture();
|
||||
const receipt = await probeInstalledCodexStartup({ consumerRoot: f.root, serverRoot: f.server, expectedRunnerSha256: f.expectedRunnerSha256 });
|
||||
expect(receipt).toMatchObject({ nativeSessionStarted: true, cleanupConfirmed: true, providerTurns: 0, credentialsUsed: false,
|
||||
codexVersion: "codex-cli 0.149.5", defaultRuntimeResolution: true, reasoningEffortExercised: false });
|
||||
expect(await readFile(f.observations, "utf8").then(JSON.parse)).toMatchObject({ calls: ["open", "session-close"], fileAuthPinned: true,
|
||||
environmentKeys: ["CODEX_HOME", "HOME", "LC_ALL", "PAPERCLIP_RUNNER_NETWORK_ACCESS", "PATH", "TMPDIR", "TZ"] });
|
||||
expect(JSON.stringify(receipt)).not.toMatch(/PRIVATE-(?:DRIVER|PROVIDER)-ID/);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")("installed startup rejects stale daemon/ambient Codex and cleans a mismatched native session", async () => {
|
||||
const f = await startupFixture();
|
||||
await expect(probeInstalledCodexStartup({ consumerRoot: f.root, serverRoot: f.server, expectedRunnerSha256: "0".repeat(64) })).rejects.toThrow("reviewed pin");
|
||||
await expect(readFile(f.observations)).rejects.toThrow();
|
||||
const ambient = await startupFixture("ambient-codex");
|
||||
await expect(probeInstalledCodexStartup({ consumerRoot: ambient.root, serverRoot: ambient.server, expectedRunnerSha256: ambient.expectedRunnerSha256 })).rejects.toThrow("ambient Codex");
|
||||
const wrong = await startupFixture("wrong-model");
|
||||
await expect(probeInstalledCodexStartup({ consumerRoot: wrong.root, serverRoot: wrong.server, expectedRunnerSha256: wrong.expectedRunnerSha256 })).rejects.toThrow("matching native identity");
|
||||
expect(await readFile(wrong.observations, "utf8").then(JSON.parse)).toMatchObject({ calls: ["open", "session-close"] });
|
||||
});
|
||||
@@ -1,21 +1,33 @@
|
||||
/** Optional published-install lane. No production hook or admission override. */
|
||||
import { constants } from "node:fs";
|
||||
import { lstat, open, realpath } from "node:fs/promises";
|
||||
import { lstat, mkdir, mkdtemp, open, realpath, rm } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { findPackageJSON } from "node:module";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { CREDENTIAL_NAMES, type MatrixExecution } from "./types.js";
|
||||
|
||||
export const installedCliKeys = ["PAPERCLIP_RUNNER_E2E_INSTALLED_CLI", "PAPERCLIP_RUNNER_E2E_INSTALLED_CLI_SHA256", "PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_ROOT", "PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_SHA256"] as const;
|
||||
const overrideKeys = ["PAPERCLIP_RUNNER_BINARY", "PAPERCLIP_RUNNER_REMOTE_BINARY_PATH", "PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH", "PAPERCLIP_RUNNER_ACPX_QUALIFICATION", "PAPERCLIP_ACPX_BUILTIN_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST"] as const;
|
||||
const suites = new Set(["extended-harnesses", "pi-native", "pi-controls", "rich-acp-warm-continuity"]);
|
||||
// Reuse the production-onboarding, independent file/revision, and restart
|
||||
// journeys. Installed proof does not add cases or relax their existing graders.
|
||||
const installedCodexCells = new Set([
|
||||
"first-task.runner-codex.local.task-card-accept",
|
||||
"everyday-workflows.runner-codex.local.build-revise",
|
||||
"agent-chat.runner-codex.local.continuity-restart",
|
||||
]);
|
||||
export function usesInstalledCli(env: NodeJS.ProcessEnv): boolean {
|
||||
return installedCliKeys.some(key => env[key] !== undefined);
|
||||
}
|
||||
export function assertInstalledCliSelection(env: NodeJS.ProcessEnv, executions: readonly MatrixExecution[]): void {
|
||||
if (!usesInstalledCli(env)) return;
|
||||
if (!installedCliKeys.every(key => Boolean(env[key])) || executions.length === 0 || executions.some(e => e.profile.id !== "runner-acpx-pi" || e.profile.qualificationCandidate !== "pi" || !e.suite.manualOnly || !suites.has(e.suite.id))) throw new Error("Installed CLI proof requires complete pins and explicit supported Pi cases");
|
||||
if (!installedCliKeys.every(key => Boolean(env[key])) || executions.length === 0 || executions.some(e => !(
|
||||
(e.profile.id === "runner-acpx-pi" && e.profile.qualificationCandidate === "pi" && e.suite.manualOnly && suites.has(e.suite.id)) ||
|
||||
(e.profile.id === "runner-codex" && e.profile.provider === "codex" && e.environment.id === "local" && installedCodexCells.has(e.id))
|
||||
))) throw new Error("Installed CLI proof requires complete pins and explicit supported Pi or Codex cases");
|
||||
for (const key of overrideKeys) if (env[key] !== undefined) throw new Error(`Installed CLI proof forbids ${key}`);
|
||||
if (env.NODE_OPTIONS !== undefined || env.NODE_PATH !== undefined) throw new Error("Installed CLI proof forbids ambient Node injection");
|
||||
}
|
||||
@@ -51,6 +63,89 @@ export async function verifyInstalledCli(env: NodeJS.ProcessEnv, executions: rea
|
||||
return { schema: "paperclip.e2e.installed-cli-admission/v1", entry, cliRoot, cliSha256, serverRoot, serverEntry, serverSha256, version: cli.version, defaultRuntimeResolution: true, qualificationOverride: false };
|
||||
}
|
||||
|
||||
/** Clean-install protocol proof only: no credentials, task, or provider turn. */
|
||||
export async function probeInstalledCodexStartup(input: {
|
||||
consumerRoot: string;
|
||||
serverRoot: string;
|
||||
expectedRunnerSha256: string;
|
||||
timeoutMs?: number;
|
||||
}) {
|
||||
for (const key of [...overrideKeys, "NODE_OPTIONS", "NODE_PATH"]) {
|
||||
if (process.env[key] !== undefined) throw new Error(`Installed Codex startup forbids ${key}`);
|
||||
}
|
||||
const inside = (root: string, filename: string) => {
|
||||
const name = relative(root, filename);
|
||||
return name !== "" && name !== ".." && !name.startsWith(`..${sep}`) && !isAbsolute(name);
|
||||
};
|
||||
for (const filename of [input.consumerRoot, input.serverRoot]) {
|
||||
if (!isAbsolute(filename) || resolve(filename) !== filename || await realpath(filename) !== filename) throw new Error("Installed Codex startup paths must be canonical and unlinked");
|
||||
}
|
||||
if (!inside(join(input.consumerRoot, "node_modules"), input.serverRoot) || !/^[a-f0-9]{64}$/.test(input.expectedRunnerSha256)) throw new Error("Installed Codex startup requires a contained server and reviewed daemon digest");
|
||||
const manifest = JSON.parse((await readOwned(join(input.serverRoot, "package.json"), 65536)).toString());
|
||||
if (manifest.name !== "@paperclipai/server" || manifest.exports?.["."]?.import !== "./dist/index.js") throw new Error("Installed Codex startup requires the published server");
|
||||
const vendorRoot = join(input.serverRoot, "dist/vendor/paperclip-runner");
|
||||
const entries = [join(vendorRoot, "index.js"), join(input.serverRoot, "dist/services/native-runtime/native-codex-runner.js")];
|
||||
for (const filename of entries) if (await realpath(filename) !== filename) throw new Error("Installed Codex startup runtime escapes its package");
|
||||
const [runtime, production] = await Promise.all(entries.map(filename => import(pathToFileURL(filename).href)));
|
||||
const runnerBinary = await realpath(production.resolvePaperclipRunnerBinary());
|
||||
if (runnerBinary !== join(vendorRoot, "bin", `${process.platform}-${process.arch}`, "paperclip-runnerd")) throw new Error("Installed Codex startup requires the normal platform daemon; no source or flat fallback");
|
||||
const digest = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex");
|
||||
const runnerSha256 = digest(await readOwned(runnerBinary, 512 * 1024 * 1024));
|
||||
if (runnerSha256 !== input.expectedRunnerSha256) throw new Error("Installed Codex startup daemon differs from its reviewed pin");
|
||||
const directory = await realpath(await mkdtemp(join(tmpdir(), "pc-codex-startup-")));
|
||||
const home = join(directory, "home"), codexHome = join(directory, "codex-home"), workspace = join(directory, "workspace");
|
||||
for (const filename of [home, codexHome, workspace]) await mkdir(filename, { mode: 0o700 });
|
||||
const environment = { PATH: `${dirname(process.execPath)}:/usr/bin:/bin:/usr/sbin`, HOME: home, CODEX_HOME: codexHome,
|
||||
TMPDIR: directory, LC_ALL: "C", TZ: "UTC", PAPERCLIP_RUNNER_NETWORK_ACCESS: "disabled" };
|
||||
let session: any;
|
||||
let bundle: any;
|
||||
let cleanupConfirmed = false;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(new Error("Installed Codex startup timed out")), Math.max(1, Math.min(input.timeoutMs ?? 45000, 120000)));
|
||||
try {
|
||||
const codexCommand = await realpath(runtime.resolveCodexCommand(undefined, environment, workspace));
|
||||
if (!inside(join(input.consumerRoot, "node_modules"), codexCommand)) throw new Error("Installed Codex startup resolved an ambient Codex executable");
|
||||
const codexSha256 = digest(await readOwned(codexCommand, 512 * 1024 * 1024));
|
||||
const codexVersion = execFileSync(codexCommand, ["--version"], { cwd: workspace, env: environment, encoding: "utf8", timeout: 10000, maxBuffer: 16384 }).trim();
|
||||
if (!codexVersion || codexVersion.length > 200) throw new Error("Installed Codex startup returned no bounded version identity");
|
||||
// Keep the production security arguments; pin file-only auth for this
|
||||
// credential-free fixture so macOS keychain accounts cannot be consulted.
|
||||
const args = runtime.createIsolatedCodexAppServerArgs(environment);
|
||||
args.splice(args.indexOf("app-server"), 0, "-c", 'cli_auth_credentials_store="file"');
|
||||
bundle = runtime.createRunnerdCodexTransport({ provider: "codex", stateDirectory: directory, environment,
|
||||
sourceCodexHome: "", runnerBinary, codexCommand, codexArgs: args, closeGraceMs: 10000,
|
||||
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } });
|
||||
const driver = new runtime.CodexAppServerDriver({
|
||||
taskEnvelope: { schema: "paperclip.skillless_task.v1", objective: "Credential-free installed startup proof.",
|
||||
completionContract: { revision: "installed-startup-v1", criteria: [] }, constraints: [], expectedResultSchema: "paperclip.run_result.v1" },
|
||||
model: "gpt-5.6-sol", conversationMode: "direct", requestedCollaborationMode: "plan", approvalPolicy: "never",
|
||||
includeCollaborationModeInstructions: false, dynamicTools: [], environment,
|
||||
transportFactory: () => bundle.transport, requireProviderSessionIdentity: true,
|
||||
});
|
||||
session = await driver.openSession({ runId: "installed-startup", normalizedSessionId: "installed-startup", workingDirectory: workspace, signal: controller.signal });
|
||||
const event = await session.events()[Symbol.asyncIterator]().next();
|
||||
const identity = session.ids();
|
||||
const evidence = bundle.evidence();
|
||||
if (event.value?.eventType !== "session.started" || event.value.payload?.context?.model !== "gpt-5.6-sol" ||
|
||||
event.value.payload?.context?.sandbox?.permissionProfile?.id !== "paperclip-runner-workspace-read-only" ||
|
||||
!identity.driverSessionId || !identity.providerSessionId || evidence.providerDriver !== "codex_app_server" ||
|
||||
!(evidence.runnerPid > 0) || !(evidence.codexPid > 0)) throw new Error("Installed Codex startup returned no matching native identity or read-only model context");
|
||||
await session.close({ reason: "credential-free installed startup complete" });
|
||||
session = undefined;
|
||||
if (!bundle.evidence().runnerExited) throw new Error("Installed Codex startup cleanup is incomplete");
|
||||
cleanupConfirmed = true;
|
||||
return { schema: "paperclip.e2e.installed-codex-startup/v1", platform: `${process.platform}-${process.arch}`,
|
||||
runnerSha256, codexSha256, codexVersion, providerDriver: "codex_app_server", model: "gpt-5.6-sol",
|
||||
nativeSessionStarted: true, cleanupConfirmed: true, providerTurns: 0, credentialsUsed: false,
|
||||
defaultRuntimeResolution: true, reasoningEffortExercised: false };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
if (session) await session.close({ reason: "installed startup cleanup" });
|
||||
else if (bundle && !cleanupConfirmed) await bundle.transport.close("installed startup cleanup");
|
||||
if (!bundle || bundle.evidence().runnerExited) await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** This prep lane cannot create agents or read the normal local credential file. */
|
||||
export function assertInstalledStartupOnly(env: NodeJS.ProcessEnv, executions: readonly MatrixExecution[], options: { ids: string[]; maxParallel: number; maxAutomaticRetries: number; headed: boolean; ui: boolean; debug: boolean; list: boolean; matrixJson: boolean }) {
|
||||
assertInstalledCliSelection(env, executions);
|
||||
|
||||
@@ -200,10 +200,10 @@ afterEach(async () => {
|
||||
container.remove();
|
||||
});
|
||||
describe("New agent setup", () => {
|
||||
it("records an explicit legacy choice when it matches the Mac default, then preserves it on Linux", async () => {
|
||||
it("records an explicit legacy choice when it matches the Windows default, then preserves it on Linux", async () => {
|
||||
settings.get.mockResolvedValue({ defaultEnvironmentId: "local-1" });
|
||||
envApi.list.mockResolvedValue([
|
||||
{ id: "local-1", name: "Mac", driver: "local", status: "active", config: {} },
|
||||
{ id: "local-1", name: "Windows", driver: "local", status: "active", config: {} },
|
||||
{ id: "linux-ssh", name: "Linux", driver: "ssh", status: "active", config: {} },
|
||||
]);
|
||||
adapterApi.list.mockImplementation(async (context?: { environmentId?: string | null }) => state.adapters.map((adapter: any) =>
|
||||
@@ -242,7 +242,7 @@ describe("New agent setup", () => {
|
||||
it("discovers the runner for the selected target and rechecks after changing environments", async () => {
|
||||
settings.get.mockResolvedValue({ defaultEnvironmentId: "linux-ssh" });
|
||||
envApi.list.mockResolvedValue([
|
||||
{ id: "local-1", name: "Mac", driver: "local", status: "active", config: {} },
|
||||
{ id: "local-1", name: "Windows", driver: "local", status: "active", config: {} },
|
||||
{ id: "linux-ssh", name: "Linux", driver: "ssh", status: "active", config: {} },
|
||||
]);
|
||||
adapterApi.list.mockImplementation(async (context?: { companyId: string; environmentId?: string | null }) => state.adapters.map((adapter: any) =>
|
||||
@@ -287,9 +287,13 @@ describe("New agent setup", () => {
|
||||
expect(api.testEnvironment.mock.calls[0][2]).toMatchObject({ runner: "legacy" });
|
||||
expect(api.hire.mock.calls[0][1]).toMatchObject({ runner: "legacy" });
|
||||
});
|
||||
it("uses automatic Codex resolution through setup and hire without the experimental gate", async () => {
|
||||
it.each(["Linux x64", "Mac arm64", "Mac x64"])("uses automatic Codex resolution through setup and hire without the experimental gate (%s)", async name => {
|
||||
settings.getExperimental.mockResolvedValue({ enableNativeRunner: false });
|
||||
envApi.list.mockResolvedValue([{ id: "local-1", name, driver: "local", status: "active", config: {} }]);
|
||||
state.adapters = (state.adapters as any[]).map(adapter => adapter.type === "codex_local" ? { ...adapter, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" } : adapter);
|
||||
await render("codex_local");
|
||||
await act(async () => (container.querySelector("summary") as HTMLElement).click());
|
||||
expect(container.querySelector('[aria-label="Runner"]')?.textContent).toBe("Paperclip Runner (default)");
|
||||
await connect("OpenAI");
|
||||
await click("Finish setup");
|
||||
expect(api.hire.mock.calls[0][1].adapterType).toBe("codex_local");
|
||||
|
||||
Reference in new issue
Block a user