Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17271186a3 | ||
|
|
6b101ff23b | ||
|
|
d077d879cc | ||
|
|
2775bf4e41 | ||
|
|
221a353394 | ||
|
|
6389e50337 | ||
|
|
d23be5c089 | ||
|
|
c0cbbf8d50 | ||
|
|
f4a0b45711 | ||
|
|
a7d13893a6 | ||
|
|
1bfa60ac20 | ||
|
|
c2cac9bfe2 | ||
|
|
9d8e780332 | ||
|
|
fd1ed0cf7b | ||
|
|
40428da1dd | ||
|
|
ef59174c1e | ||
|
|
53e0943223 | ||
|
|
b718ad166d | ||
|
|
690fc51854 | ||
|
|
23196daa11 | ||
|
|
0add6a5aae | ||
|
|
97abf177e2 | ||
|
|
0dbc046ca6 | ||
|
|
2a0410f171 | ||
|
|
4bfdadb7de | ||
|
|
134f6271ee | ||
|
|
56583f1705 | ||
|
|
00785c0401 | ||
|
|
b7ec2f6d6e | ||
|
|
d8477106c7 | ||
|
|
c09a9cd3c3 | ||
|
|
87e5199902 | ||
|
|
381fcbdb8c | ||
|
|
1629266e3c | ||
|
|
b3aadaf474 | ||
|
|
eb7acc70bf | ||
|
|
998882045b | ||
|
|
eb41f358cf | ||
|
|
17f581ff75 | ||
|
|
edbd291a28 | ||
|
|
2b263a7fe9 | ||
|
|
d98fc3859d |
No files matched your search
@@ -1,5 +1,10 @@
|
||||
name: Cache npm package downloads
|
||||
description: Restore npm's download cache and save it only from trusted default-branch events.
|
||||
description: Restore npm's download cache. Trusted default-branch jobs that opt in may save it.
|
||||
inputs:
|
||||
save:
|
||||
description: Save the shared download cache when this event is allowed to write it.
|
||||
required: false
|
||||
default: "false"
|
||||
outputs:
|
||||
can-save:
|
||||
description: Whether this run may update the shared npm download cache.
|
||||
@@ -23,20 +28,25 @@ runs:
|
||||
can_save=true
|
||||
fi
|
||||
cache_key="npm-download-${RUNNER_OS}-${runtime_arch}-${LOCKFILES_HASH}"
|
||||
# Same platform only. A lockfile change can reuse tarballs from the previous key.
|
||||
restore_key="npm-download-${RUNNER_OS}-${runtime_arch}-"
|
||||
{
|
||||
printf 'can-save=%s\n' "${can_save}"
|
||||
printf 'path=%s\n' "${cache_path}"
|
||||
printf 'key=%s\n' "${cache_key}"
|
||||
printf 'restore-key=%s\n' "${restore_key}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
- name: Restore and save npm package downloads
|
||||
if: steps.context.outputs.can-save == 'true'
|
||||
if: steps.context.outputs.can-save == 'true' && inputs.save == 'true'
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ${{ steps.context.outputs.path }}
|
||||
key: ${{ steps.context.outputs.key }}
|
||||
restore-keys: ${{ steps.context.outputs.restore-key }}
|
||||
- name: Restore npm package downloads without saving
|
||||
if: steps.context.outputs.can-save != 'true'
|
||||
if: steps.context.outputs.can-save != 'true' || inputs.save != 'true'
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ${{ steps.context.outputs.path }}
|
||||
key: ${{ steps.context.outputs.key }}
|
||||
restore-keys: ${{ steps.context.outputs.restore-key }}
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Cache retention
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
|
||||
# Qualification calls ci.yml with actions: read. A nested workflow cannot raise
|
||||
# that token, so cache deletion stays in this workflow.
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
|
||||
concurrency:
|
||||
group: cache-retention-${{ github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
retain:
|
||||
name: Retain Turbo snapshots and npm download caches
|
||||
# Checkout and run only a same-repository default-branch commit. Pull
|
||||
# requests, including forks, do not reach these steps. Static and build
|
||||
# save Turbo snapshots before later lanes finish, so a failed, timed-out,
|
||||
# or cancelled run still retains those snapshots.
|
||||
if: >-
|
||||
(github.event.workflow_run.conclusion == 'success' ||
|
||||
github.event.workflow_run.conclusion == 'failure' ||
|
||||
github.event.workflow_run.conclusion == 'timed_out' ||
|
||||
github.event.workflow_run.conclusion == 'cancelled') &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository &&
|
||||
github.event.workflow_run.head_branch == github.event.repository.default_branch &&
|
||||
(github.event.workflow_run.event == 'push' ||
|
||||
github.event.workflow_run.event == 'schedule' ||
|
||||
github.event.workflow_run.event == 'workflow_dispatch')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Retain current Turbo snapshots and npm download caches
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CI_REPOSITORY: ${{ github.repository }}
|
||||
CACHE_PREFIX: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-
|
||||
CACHE_KEEP_KEY: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'package-lock.json') }}-${{ github.event.workflow_run.head_sha }}
|
||||
CACHE_DEEP_PREFIX: rea-static-deep-v1-${{ runner.os }}-${{ runner.arch }}-
|
||||
CACHE_DEEP_KEEP_KEY: rea-static-deep-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'package-lock.json') }}-${{ github.event.workflow_run.head_sha }}
|
||||
CACHE_KNIP_PREFIX: knip-meta-v1-${{ runner.os }}-
|
||||
CACHE_KNIP_KEEP_KEY: knip-meta-v1-${{ runner.os }}-${{ hashFiles('knip.json', 'tsconfig.json', 'package.json', 'package-lock.json') }}-${{ github.event.workflow_run.head_sha }}
|
||||
CACHE_PORTABLE_PREFIX: rea-portable-build-v2-${{ runner.os }}-${{ runner.arch }}-
|
||||
DEDUPE_NPM_DOWNLOADS: "true"
|
||||
run: node scripts/ci/cache-retention.mjs
|
||||
+131
-11
@@ -15,6 +15,15 @@ on:
|
||||
description: Exact qualification source; must match the caller revision.
|
||||
required: true
|
||||
type: string
|
||||
remote_cache_write:
|
||||
description: Trusted release qualification may publish the remote cache.
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
secrets:
|
||||
TURBO_TOKEN:
|
||||
description: Remote cache token. Only trusted release qualification passes it.
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -29,6 +38,15 @@ concurrency:
|
||||
|
||||
env:
|
||||
HUSKY: "0"
|
||||
# Set Actions secret TURBO_TOKEN and variable TURBO_TEAM. OIDC can mint
|
||||
# TURBO_TOKEN via vercel/setup-turborepo-remote-cache-action; do not commit a token.
|
||||
# A called workflow sees the caller event, so workflow_call is not an event
|
||||
# name here. Release qualification sets remote_cache_write and passes the
|
||||
# token. Open pull requests get an empty token and the local cache only.
|
||||
# Remote reads need a credential, and pull requests do not receive one.
|
||||
TURBO_TOKEN: "${{ (inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'))) && secrets.TURBO_TOKEN || '' }}"
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
TURBO_CACHE: "${{ (inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'))) && 'local:rw,remote:rw' || 'local:rw' }}"
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
@@ -128,6 +146,9 @@ jobs:
|
||||
- name: Cache npm package downloads
|
||||
id: npm-cache
|
||||
uses: ./.github/actions/npm-cache
|
||||
with:
|
||||
# Linux x64 writer. Parallel Linux jobs restore this key.
|
||||
save: "true"
|
||||
- run: npm ci
|
||||
- name: Restore static task cache
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
@@ -151,14 +172,10 @@ jobs:
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
env:
|
||||
CI_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
CI_STATIC_CONTEXT_SCRIPT: check:ci:fast:tasks
|
||||
run: npm run ci:static-context
|
||||
- if: needs.changes.outputs.code == 'true'
|
||||
run: npm run check:ci
|
||||
- if: needs.changes.outputs.code == 'true'
|
||||
run: npm run verify:test-discovery
|
||||
- name: Check LLDB producer retention and launch contracts
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
run: npm run test:python
|
||||
run: npm run check:ci:fast
|
||||
- if: needs.changes.outputs.code != 'true'
|
||||
run: npm run format:check
|
||||
- name: Save validated static task cache
|
||||
@@ -180,6 +197,72 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
static-deep:
|
||||
name: Deep static checks
|
||||
needs: changes
|
||||
# Full qualification only. Provider lanes wait on fast static, not this job.
|
||||
if: needs.changes.outputs.tier == 'full' && needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Cache npm package downloads
|
||||
id: npm-cache
|
||||
uses: ./.github/actions/npm-cache
|
||||
- run: npm ci
|
||||
- name: Restore deep static task cache
|
||||
id: static-cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: .turbo/cache/
|
||||
key: rea-static-deep-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'package-lock.json') }}-${{ github.sha }}
|
||||
restore-keys: rea-static-deep-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'package-lock.json') }}-
|
||||
- name: Restore Knip analysis cache
|
||||
id: knip-cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: node_modules/.cache/knip
|
||||
# Each trusted commit publishes a new entry. Restore still matches the
|
||||
# configuration prefix from the previous successful full run.
|
||||
key: knip-meta-v1-${{ runner.os }}-${{ hashFiles('knip.json', 'tsconfig.json', 'package.json', 'package-lock.json') }}-${{ github.sha }}
|
||||
restore-keys: knip-meta-v1-${{ runner.os }}-${{ hashFiles('knip.json', 'tsconfig.json', 'package.json', 'package-lock.json') }}-
|
||||
- name: Record deep static task source and execution identity
|
||||
continue-on-error: true
|
||||
env:
|
||||
CI_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
CI_STATIC_CONTEXT_SCRIPT: check:ci:deep:tasks
|
||||
run: npm run ci:static-context
|
||||
- name: Run deep static checks
|
||||
run: npm run check:ci:deep
|
||||
- name: Save validated deep static task cache
|
||||
if: >-
|
||||
success() && steps.npm-cache.outputs.can-save == 'true' &&
|
||||
steps.static-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: .turbo/cache/
|
||||
key: ${{ steps.static-cache.outputs.cache-primary-key }}
|
||||
- name: Save Knip analysis cache
|
||||
if: >-
|
||||
success() && steps.npm-cache.outputs.can-save == 'true' &&
|
||||
steps.knip-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: node_modules/.cache/knip
|
||||
key: ${{ steps.knip-cache.outputs.cache-primary-key }}
|
||||
- name: Upload complete deep static diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: static-deep-check-reports
|
||||
path: .cache/static-checks/
|
||||
include-hidden-files: true
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
test-source:
|
||||
name: Source tests
|
||||
needs: changes
|
||||
@@ -219,9 +302,11 @@ jobs:
|
||||
# every shard's diagnostics and coverage. Passing PRs run the full suite.
|
||||
fail-fast: ${{ github.event_name == 'pull_request' && needs.changes.outputs.tier == 'fast' }}
|
||||
matrix:
|
||||
# Fast feedback spends the same six test jobs on one source group and
|
||||
# five built groups; full qualification retains six complete shards.
|
||||
shard: ${{ fromJSON(needs.changes.outputs.tier == 'fast' && '["1/5","2/5","3/5","4/5","5/5"]' || '["1/6","2/6","3/6","4/6","5/6","6/6"]') }}
|
||||
# Fast feedback uses one source group and eight built groups so the
|
||||
# slowest built shard job stays inside a ten-minute execution budget.
|
||||
# Classification, build, and report merge sit outside that job.
|
||||
# Full qualification retains six complete shards with coverage.
|
||||
shard: ${{ fromJSON(needs.changes.outputs.tier == 'fast' && '["1/8","2/8","3/8","4/8","5/8","6/8","7/8","8/8"]' || '["1/6","2/6","3/6","4/6","5/6","6/6"]') }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
@@ -373,8 +458,9 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
sqlite-runtime:
|
||||
# Preserve all selected coverage, but admit expensive host/provider work
|
||||
# only after static validation. Portable tests still provide early feedback.
|
||||
# Admit expensive host/provider work after fast static validation
|
||||
# (typecheck, lint, format). Deep static does not gate these lanes.
|
||||
# Portable tests still provide early feedback.
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.sqlite == 'true'
|
||||
uses: ./.github/workflows/sqlite-runtime.yml
|
||||
@@ -577,6 +663,9 @@ jobs:
|
||||
- name: Cache npm package downloads
|
||||
id: npm-cache
|
||||
uses: ./.github/actions/npm-cache
|
||||
with:
|
||||
# ubuntu-latest is the Linux x64 writer above. Other hosts save their own key.
|
||||
save: ${{ matrix.os != 'ubuntu-latest' }}
|
||||
- name: Verify native runtime identity
|
||||
env:
|
||||
EXPECTED_CI_PLATFORM: ${{ matrix.platform }}
|
||||
@@ -713,6 +802,8 @@ jobs:
|
||||
- name: Cache npm package downloads
|
||||
id: npm-cache
|
||||
uses: ./.github/actions/npm-cache
|
||||
with:
|
||||
save: "true"
|
||||
- run: npm ci
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
@@ -858,68 +949,96 @@ jobs:
|
||||
skills/reverse-engineer-anything
|
||||
if-no-files-found: error
|
||||
|
||||
# Provider lanes wait for fast static and the portable build, not deep static.
|
||||
browser:
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.browser == 'true'
|
||||
secrets:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
uses: ./.github/workflows/real-browser.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
remote_cache_write: ${{ inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) }}
|
||||
|
||||
binary_layout:
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.binary_layout == 'true'
|
||||
secrets:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
uses: ./.github/workflows/real-binary-layout.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
remote_cache_write: ${{ inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) }}
|
||||
|
||||
evm:
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.evm == 'true'
|
||||
secrets:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
uses: ./.github/workflows/real-evm-interface.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
remote_cache_write: ${{ inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) }}
|
||||
|
||||
javascript_recovery:
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.javascript_recovery == 'true'
|
||||
secrets:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
uses: ./.github/workflows/real-javascript-recovery.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
remote_cache_write: ${{ inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) }}
|
||||
|
||||
recorded_crash:
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.recorded_crash == 'true'
|
||||
secrets:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
uses: ./.github/workflows/real-recorded-crash.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
remote_cache_write: ${{ inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) }}
|
||||
|
||||
web_network:
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.web_network == 'true'
|
||||
secrets:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
uses: ./.github/workflows/real-web-network-captures.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
remote_cache_write: ${{ inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) }}
|
||||
|
||||
web_runtime:
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.web_runtime == 'true'
|
||||
secrets:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
uses: ./.github/workflows/real-web-runtime.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
remote_cache_write: ${{ inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) }}
|
||||
|
||||
web_source_map:
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.web_source_map == 'true'
|
||||
secrets:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
uses: ./.github/workflows/real-web-source-map.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
remote_cache_write: ${{ inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) }}
|
||||
|
||||
agentenv:
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.agentenv == 'true'
|
||||
secrets:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
uses: ./.github/workflows/real-agentenv.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
remote_cache_write: ${{ inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) }}
|
||||
|
||||
nativeaot:
|
||||
needs: [changes, static]
|
||||
@@ -944,6 +1063,7 @@ jobs:
|
||||
[
|
||||
changes,
|
||||
static,
|
||||
static-deep,
|
||||
build,
|
||||
reverse-tools-quality,
|
||||
test-shard,
|
||||
|
||||
@@ -14,6 +14,11 @@ concurrency:
|
||||
|
||||
env:
|
||||
HUSKY: "0"
|
||||
# Writes only on the default branch for push, schedule, manual dispatch,
|
||||
# and workflow_call. Pull requests get an empty token and remote read-only mode.
|
||||
TURBO_TOKEN: "${{ (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call')) && secrets.TURBO_TOKEN || '' }}"
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
TURBO_CACHE: "${{ (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call')) && 'local:rw,remote:rw' || 'local:rw,remote:r' }}"
|
||||
|
||||
jobs:
|
||||
mutation:
|
||||
|
||||
@@ -9,12 +9,28 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
remote_cache_write:
|
||||
description: Trusted release qualification may publish the remote cache.
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
secrets:
|
||||
TURBO_TOKEN:
|
||||
description: Remote cache token. Only trusted callers pass it.
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
HUSKY: "0"
|
||||
# A called workflow sees the caller event, so workflow_call is not an event
|
||||
# name here. Writes when remote_cache_write is set, or on the default branch
|
||||
# for push, schedule, and manual dispatch. Open pull requests stay empty-token
|
||||
# and use the local cache only.
|
||||
TURBO_TOKEN: "${{ (inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'))) && secrets.TURBO_TOKEN || '' }}"
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
TURBO_CACHE: "${{ (inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'))) && 'local:rw,remote:rw' || 'local:rw' }}"
|
||||
NODE_OPTIONS: --max-old-space-size=768
|
||||
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
||||
|
||||
|
||||
@@ -14,6 +14,15 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
remote_cache_write:
|
||||
description: Trusted release qualification may publish the remote cache.
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
secrets:
|
||||
TURBO_TOKEN:
|
||||
description: Remote cache token. Only trusted callers pass it.
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -21,6 +30,13 @@ permissions:
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=768
|
||||
HUSKY: "0"
|
||||
# A called workflow sees the caller event, so workflow_call is not an event
|
||||
# name here. Writes when remote_cache_write is set, or on the default branch
|
||||
# for push, schedule, and manual dispatch. Open pull requests stay empty-token
|
||||
# and use the local cache only.
|
||||
TURBO_TOKEN: "${{ (inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'))) && secrets.TURBO_TOKEN || '' }}"
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
TURBO_CACHE: "${{ (inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'))) && 'local:rw,remote:rw' || 'local:rw' }}"
|
||||
REA_VERIFY_LARGE_ELF_MCP: ${{ inputs.large_mcp && '1' || '0' }}
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -8,10 +8,26 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
remote_cache_write:
|
||||
description: Trusted release qualification may publish the remote cache.
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
secrets:
|
||||
TURBO_TOKEN:
|
||||
description: Remote cache token. Only trusted callers pass it.
|
||||
required: false
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
HUSKY: "0"
|
||||
# A called workflow sees the caller event, so workflow_call is not an event
|
||||
# name here. Writes when remote_cache_write is set, or on the default branch
|
||||
# for push, schedule, and manual dispatch. Open pull requests stay empty-token
|
||||
# and use the local cache only.
|
||||
TURBO_TOKEN: "${{ (inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'))) && secrets.TURBO_TOKEN || '' }}"
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
TURBO_CACHE: "${{ (inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'))) && 'local:rw,remote:rw' || 'local:rw' }}"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -8,6 +8,14 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
HUSKY: "0"
|
||||
# Writes only on the default branch for push, schedule, manual dispatch,
|
||||
# and workflow_call. Pull requests get an empty token and remote read-only mode.
|
||||
TURBO_TOKEN: "${{ (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call')) && secrets.TURBO_TOKEN || '' }}"
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
TURBO_CACHE: "${{ (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call')) && 'local:rw,remote:rw' || 'local:rw,remote:r' }}"
|
||||
|
||||
jobs:
|
||||
windows-native-artifact:
|
||||
name: Build Windows bridge security boundary
|
||||
|
||||
@@ -9,6 +9,15 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
remote_cache_write:
|
||||
description: Trusted release qualification may publish the remote cache.
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
secrets:
|
||||
TURBO_TOKEN:
|
||||
description: Remote cache token. Only trusted callers pass it.
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -16,6 +25,13 @@ permissions:
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=768
|
||||
HUSKY: "0"
|
||||
# A called workflow sees the caller event, so workflow_call is not an event
|
||||
# name here. Writes when remote_cache_write is set, or on the default branch
|
||||
# for push, schedule, and manual dispatch. Open pull requests stay empty-token
|
||||
# and use the local cache only.
|
||||
TURBO_TOKEN: "${{ (inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'))) && secrets.TURBO_TOKEN || '' }}"
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
TURBO_CACHE: "${{ (inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'))) && 'local:rw,remote:rw' || 'local:rw' }}"
|
||||
|
||||
jobs:
|
||||
real-evm-interface:
|
||||
|
||||
@@ -6,6 +6,11 @@ on:
|
||||
|
||||
env:
|
||||
HUSKY: "0"
|
||||
# Writes only on the default branch for push, schedule, manual dispatch,
|
||||
# and workflow_call. Pull requests get an empty token and remote read-only mode.
|
||||
TURBO_TOKEN: "${{ (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call')) && secrets.TURBO_TOKEN || '' }}"
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
TURBO_CACHE: "${{ (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call')) && 'local:rw,remote:rw' || 'local:rw,remote:r' }}"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -13,20 +13,8 @@ on:
|
||||
- "src/composition/go.ts"
|
||||
- "src/cli/goCommands.ts"
|
||||
- "src/server/registerGoTools.ts"
|
||||
- "src/server/createServer.ts"
|
||||
- "src/cli.ts"
|
||||
- "src/cliCommandNames.ts"
|
||||
- "src/contracts/toolContracts.ts"
|
||||
- "src/contracts/toolEffects.ts"
|
||||
- "src/artifacts/readStableArtifact*.ts"
|
||||
- "src/artifacts/ArtifactReader.ts"
|
||||
- "src/process/PrivateRuntimeRoot.ts"
|
||||
- "scripts/verify-go-binary.mjs"
|
||||
- "scripts/lib/mcp-verifier-results.mjs"
|
||||
- "scripts/rea.mjs"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- ".nvmrc"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -38,6 +26,11 @@ concurrency:
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=768
|
||||
HUSKY: "0"
|
||||
# Writes only on the default branch for push, schedule, manual dispatch,
|
||||
# and workflow_call. Pull requests get an empty token and remote read-only mode.
|
||||
TURBO_TOKEN: "${{ (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call')) && secrets.TURBO_TOKEN || '' }}"
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
TURBO_CACHE: "${{ (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call')) && 'local:rw,remote:rw' || 'local:rw,remote:r' }}"
|
||||
|
||||
jobs:
|
||||
real-go-binary:
|
||||
|
||||
@@ -5,6 +5,11 @@ on:
|
||||
|
||||
env:
|
||||
HUSKY: "0"
|
||||
# Writes only on the default branch for push, schedule, manual dispatch,
|
||||
# and workflow_call. Pull requests get an empty token and remote read-only mode.
|
||||
TURBO_TOKEN: "${{ (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call')) && secrets.TURBO_TOKEN || '' }}"
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
TURBO_CACHE: "${{ (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call')) && 'local:rw,remote:rw' || 'local:rw,remote:r' }}"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -5,6 +5,11 @@ on:
|
||||
|
||||
env:
|
||||
HUSKY: "0"
|
||||
# Writes only on the default branch for push, schedule, manual dispatch,
|
||||
# and workflow_call. Pull requests get an empty token and remote read-only mode.
|
||||
TURBO_TOKEN: "${{ (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call')) && secrets.TURBO_TOKEN || '' }}"
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
TURBO_CACHE: "${{ (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call')) && 'local:rw,remote:rw' || 'local:rw,remote:r' }}"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -8,6 +8,15 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
remote_cache_write:
|
||||
description: Trusted release qualification may publish the remote cache.
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
secrets:
|
||||
TURBO_TOKEN:
|
||||
description: Remote cache token. Only trusted callers pass it.
|
||||
required: false
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -15,6 +24,13 @@ permissions:
|
||||
|
||||
env:
|
||||
HUSKY: "0"
|
||||
# A called workflow sees the caller event, so workflow_call is not an event
|
||||
# name here. Writes when remote_cache_write is set, or on the default branch
|
||||
# for push, schedule, and manual dispatch. Open pull requests stay empty-token
|
||||
# and use the local cache only.
|
||||
TURBO_TOKEN: "${{ (inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'))) && secrets.TURBO_TOKEN || '' }}"
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
TURBO_CACHE: "${{ (inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'))) && 'local:rw,remote:rw' || 'local:rw' }}"
|
||||
NODE_OPTIONS: "--max-old-space-size=768"
|
||||
RAYON_NUM_THREADS: "1"
|
||||
|
||||
|
||||
@@ -20,21 +20,17 @@ on:
|
||||
- "src/composition/captures.ts"
|
||||
- "src/cli/captureCommands.ts"
|
||||
- "src/server/registerCaptureTools.ts"
|
||||
- "src/server/createServer.ts"
|
||||
- "src/server/sessionAvailabilityPolicy.ts"
|
||||
- "src/application/CapabilityInventory.ts"
|
||||
- "src/contracts/toolContracts.ts"
|
||||
- "src/contracts/toolEffects.ts"
|
||||
- "src/cli.ts"
|
||||
- "src/cliCommandNames.ts"
|
||||
- "src/process/**"
|
||||
- "src/artifacts/readStableArtifact.ts"
|
||||
- "scripts/verify-packet-capture.mjs"
|
||||
- ".github/workflows/real-packet-capture.yml"
|
||||
- "package*.json"
|
||||
- ".nvmrc"
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
HUSKY: "0"
|
||||
# Writes only on the default branch for push, schedule, manual dispatch,
|
||||
# and workflow_call. Pull requests get an empty token and remote read-only mode.
|
||||
TURBO_TOKEN: "${{ (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call')) && secrets.TURBO_TOKEN || '' }}"
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
TURBO_CACHE: "${{ (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call')) && 'local:rw,remote:rw' || 'local:rw,remote:r' }}"
|
||||
concurrency:
|
||||
group: real-packet-capture-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
@@ -9,6 +9,15 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
remote_cache_write:
|
||||
description: Trusted release qualification may publish the remote cache.
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
secrets:
|
||||
TURBO_TOKEN:
|
||||
description: Remote cache token. Only trusted callers pass it.
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -16,6 +25,13 @@ permissions:
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=768
|
||||
HUSKY: "0"
|
||||
# A called workflow sees the caller event, so workflow_call is not an event
|
||||
# name here. Writes when remote_cache_write is set, or on the default branch
|
||||
# for push, schedule, and manual dispatch. Open pull requests stay empty-token
|
||||
# and use the local cache only.
|
||||
TURBO_TOKEN: "${{ (inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'))) && secrets.TURBO_TOKEN || '' }}"
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
TURBO_CACHE: "${{ (inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'))) && 'local:rw,remote:rw' || 'local:rw' }}"
|
||||
UV_CONCURRENT_BUILDS: "1"
|
||||
UV_CONCURRENT_INSTALLS: "1"
|
||||
UV_PYTHON_DOWNLOADS: never
|
||||
|
||||
@@ -13,21 +13,17 @@ on:
|
||||
- "src/composition/signals.ts"
|
||||
- "src/cli/signalCommands.ts"
|
||||
- "src/server/registerSignalTools.ts"
|
||||
- "src/server/createServer.ts"
|
||||
- "src/server/sessionAvailabilityPolicy.ts"
|
||||
- "src/application/CapabilityInventory.ts"
|
||||
- "src/contracts/toolContracts.ts"
|
||||
- "src/contracts/toolEffects.ts"
|
||||
- "src/cli.ts"
|
||||
- "src/cliCommandNames.ts"
|
||||
- "src/process/**"
|
||||
- "src/artifacts/readStableArtifact.ts"
|
||||
- "scripts/verify-signal-recording.mjs"
|
||||
- ".github/workflows/real-signal-recording.yml"
|
||||
- "package*.json"
|
||||
- ".nvmrc"
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
HUSKY: "0"
|
||||
# Writes only on the default branch for push, schedule, manual dispatch,
|
||||
# and workflow_call. Pull requests get an empty token and remote read-only mode.
|
||||
TURBO_TOKEN: "${{ (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call')) && secrets.TURBO_TOKEN || '' }}"
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
TURBO_CACHE: "${{ (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call')) && 'local:rw,remote:rw' || 'local:rw,remote:r' }}"
|
||||
concurrency:
|
||||
group: real-signal-recording-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
@@ -12,6 +12,11 @@ permissions:
|
||||
|
||||
env:
|
||||
HUSKY: "0"
|
||||
# Writes only on the default branch for push, schedule, manual dispatch,
|
||||
# and workflow_call. Pull requests get an empty token and remote read-only mode.
|
||||
TURBO_TOKEN: "${{ (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call')) && secrets.TURBO_TOKEN || '' }}"
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
TURBO_CACHE: "${{ (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call')) && 'local:rw,remote:rw' || 'local:rw,remote:r' }}"
|
||||
|
||||
jobs:
|
||||
real-termux:
|
||||
|
||||
@@ -9,12 +9,28 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
remote_cache_write:
|
||||
description: Trusted release qualification may publish the remote cache.
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
secrets:
|
||||
TURBO_TOKEN:
|
||||
description: Remote cache token. Only trusted callers pass it.
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
HUSKY: "0"
|
||||
# A called workflow sees the caller event, so workflow_call is not an event
|
||||
# name here. Writes when remote_cache_write is set, or on the default branch
|
||||
# for push, schedule, and manual dispatch. Open pull requests stay empty-token
|
||||
# and use the local cache only.
|
||||
TURBO_TOKEN: "${{ (inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'))) && secrets.TURBO_TOKEN || '' }}"
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
TURBO_CACHE: "${{ (inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'))) && 'local:rw,remote:rw' || 'local:rw' }}"
|
||||
NODE_OPTIONS: --max-old-space-size=768
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -7,11 +7,27 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
remote_cache_write:
|
||||
description: Trusted release qualification may publish the remote cache.
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
secrets:
|
||||
TURBO_TOKEN:
|
||||
description: Remote cache token. Only trusted callers pass it.
|
||||
required: false
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
HUSKY: "0"
|
||||
# A called workflow sees the caller event, so workflow_call is not an event
|
||||
# name here. Writes when remote_cache_write is set, or on the default branch
|
||||
# for push, schedule, and manual dispatch. Open pull requests stay empty-token
|
||||
# and use the local cache only.
|
||||
TURBO_TOKEN: "${{ (inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'))) && secrets.TURBO_TOKEN || '' }}"
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
TURBO_CACHE: "${{ (inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'))) && 'local:rw,remote:rw' || 'local:rw' }}"
|
||||
jobs:
|
||||
real-web-runtime:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -7,11 +7,27 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
remote_cache_write:
|
||||
description: Trusted release qualification may publish the remote cache.
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
secrets:
|
||||
TURBO_TOKEN:
|
||||
description: Remote cache token. Only trusted callers pass it.
|
||||
required: false
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
HUSKY: "0"
|
||||
# A called workflow sees the caller event, so workflow_call is not an event
|
||||
# name here. Writes when remote_cache_write is set, or on the default branch
|
||||
# for push, schedule, and manual dispatch. Open pull requests stay empty-token
|
||||
# and use the local cache only.
|
||||
TURBO_TOKEN: "${{ (inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'))) && secrets.TURBO_TOKEN || '' }}"
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
TURBO_CACHE: "${{ (inputs.remote_cache_write == true || (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'))) && 'local:rw,remote:rw' || 'local:rw' }}"
|
||||
jobs:
|
||||
real-web-source-map:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -21,6 +21,12 @@ on:
|
||||
|
||||
env:
|
||||
HUSKY: "0"
|
||||
# Remote cache stays local when either value is empty.
|
||||
# Writes only on the default branch for push, schedule, manual dispatch,
|
||||
# and workflow_call. Pull requests get an empty token and remote read-only mode.
|
||||
TURBO_TOKEN: "${{ (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call')) && secrets.TURBO_TOKEN || '' }}"
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
TURBO_CACHE: "${{ (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call')) && 'local:rw,remote:rw' || 'local:rw,remote:r' }}"
|
||||
|
||||
concurrency:
|
||||
group: release-${{ inputs.release_branch || github.event.pull_request.merge_commit_sha || github.ref_name }}
|
||||
@@ -301,8 +307,11 @@ jobs:
|
||||
contents: read
|
||||
actions: read
|
||||
uses: ./.github/workflows/ci.yml
|
||||
secrets:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
with:
|
||||
source_sha: ${{ needs.merged-release.outputs.sha || needs.release-please.outputs.sha }}
|
||||
remote_cache_write: true
|
||||
|
||||
rehearsal:
|
||||
name: Rehearse qualified release on a clean runner
|
||||
|
||||
@@ -180,8 +180,21 @@ rea trace-dylib-resolution MyApp.app --root Contents/MacOS/MyApp --architecture
|
||||
|
||||
Load commands are parsed in TypeScript from Mach-O and FAT headers, including
|
||||
the `dylib_use_command` encoding newer linkers emit for flags such as delayed
|
||||
initialization. Nothing is executed and no Apple tool is required, so the tool
|
||||
also works off macOS.
|
||||
initialization, and `LC_LAZY_LOAD_DYLIB_INFO` (`0x3a`). That command stores its
|
||||
install name in a linkedit payload rather than in the load command itself.
|
||||
Its flag and chain-start image offsets must lie in the slice VM range, which
|
||||
is dyld's one-page floor extended by every segment except `__LINKEDIT`,
|
||||
measured from the `__TEXT` vmaddr. Payload strings must start after the 24-byte
|
||||
header. The load-path NUL must occur before the payload's final byte, matching
|
||||
dyld's scan. Distinct payload bytes and payload-range lookups stay within the 16 MiB
|
||||
limit, and a file range already read is reused. String scanning and UTF-8
|
||||
checks count every reference, including repeats and overlaps, and stop at that
|
||||
same aggregate. A slice retains at most 1024 of these commands; further copies are
|
||||
unsupported instead of becoming one traced edge each. Symbol references,
|
||||
including repeats, stop at 65536 per slice. A larger table is unsupported
|
||||
instead of caching one string per offset.
|
||||
Nothing is executed and no Apple tool is required, so the tool also works off
|
||||
macOS.
|
||||
|
||||
Resolution follows dyld:
|
||||
|
||||
@@ -222,8 +235,12 @@ cache rather than on disk, so REA does not check them against the host.
|
||||
`findings` are derived, not observed:
|
||||
|
||||
- `required-load-unresolved`, `weak-load-unresolved`, and `lazy-load-unresolved`.
|
||||
`LC_LAZY_LOAD_DYLIB` dependencies are resolved but not traversed, because dyld
|
||||
loads them on first use; an unresolved one is not a launch failure.
|
||||
`LC_LAZY_LOAD_DYLIB` and `LC_LAZY_LOAD_DYLIB_INFO` dependencies are resolved
|
||||
but not traversed, because dyld loads them on first use; an unresolved one is
|
||||
not a launch failure. `LC_LAZY_LOAD_DYLIB_INFO` has no compatibility version,
|
||||
so those fields are null. Its may-be-missing flag is reported as `weak`, and
|
||||
an unresolved dependency with that flag is not a first-use failure: dyld
|
||||
continues if the image is absent.
|
||||
- `earlier-rpath-candidate-absent`: a compatible Mach-O placed at an earlier
|
||||
modeled search path could take precedence, subject to unmodeled search inputs
|
||||
and code-signing library validation. These findings do not establish which
|
||||
|
||||
@@ -19,8 +19,24 @@ Inspect native SAZ or HAR 1.2 offline with REA, or connect Everywhere's separate
|
||||
- `analyze_web_bundle` / `rea analyze-web-bundle` parses selected script artifacts without execution and derives chunk edges, route and endpoint candidates, vendor fingerprints, static WebMCP declarations, and optionally source-map/original-source evidence.
|
||||
- `observe_web_session` / `rea observe-web-session` arms an observation window of the requested duration for an external user action and records ordered reload, SPA navigation, redirect, failure, lifecycle, and target-termination metadata.
|
||||
- `discover_webmcp_tools` / `rea discover-webmcp-tools` uses the experimental CDP WebMCP domain to return every in-scope registration and its complete observed `input_schema` declaration and canonical `input_schema_sha256` digest inline. Declarations are untrusted JSON data, including declared types, required fields, enums, examples and reference URLs. REA does not resolve schema references. These fields replace `input_schema_shape`. Registrations with the same name and URL in distinct observed frames retain separate identities. `tool_key` includes the observed frame ID; it is not a stable identity across navigation or browser sessions. Same-frame registration updates replace that owner only, and removal leaves other owners intact. Declared `readOnly`, `untrustedContent`, `consequential`, and `autosubmit` annotation hints are reported per registration: `true`/`false` are declared values, `null` means the declaration did not provide the hint in that capture, and a missing `consequential` key means the hint was not recorded, either in a historical capture or because the observed annotation was invalid. Consult discovery completeness and exclusions to distinguish these cases. Malformed annotation values retain the registration with incomplete coverage and an `invalid_protocol_value` exclusion. Other malformed protocol payloads can fail discovery explicitly. REA never exposes `WebMCP.invokeTool`.
|
||||
|
||||
- `compare_web_captures` / `rea compare-web-captures` compares passive captures or exact-step-aligned browser scenarios. Scenario results commit reproducible literal normalization and expose alignment failures plus artifact-level action, screenshot, DOM, accessibility, URL, history, storage, and event differences. Missing or truncated evidence is never treated as equivalence.
|
||||
|
||||
For `analyze_web_bundle`, each `webmcp_declarations` entry's
|
||||
`schema_property_names` is a source-derived projection of statically known own
|
||||
property keys, not schema validation. `schema_property_names_coverage` reports
|
||||
how complete that projection is: `complete` means a literal properties object
|
||||
was fully enumerated (including an empty literal); `partial` means a literal
|
||||
object was available but some computed or spread membership was unresolved,
|
||||
and the name array retains keys that were known; `unknown` means the schema or
|
||||
properties owner could not be enumerated as an object literal, including
|
||||
explicit non-object values; `not_present` means no effective schema or own
|
||||
`properties` member was present after the `inputSchema ?? input_schema` alias
|
||||
selection. `not_present` describes this projection only: it does not validate a
|
||||
schema or rule out properties expressed through other schema constructs. Older
|
||||
observations may omit the coverage field; treat omission as `unknown` rather
|
||||
than inferring completeness from the array.
|
||||
|
||||
Passive DOM comparison checks complete node inventories and complete frame
|
||||
parent/child structure independently. Frame IDs and sibling enumeration order
|
||||
are capture-local and do not establish a change; duplicate IDs, missing parents,
|
||||
@@ -288,6 +304,20 @@ Call `list_browser_targets` first because target IDs are browser-instance-specif
|
||||
|
||||
REA establishes a main-frame origin boundary before enabling Runtime, Debugger, or Network observation, rechecks it before document capture, and validates it again afterward. If the main frame navigates during final capture, REA discards the mixed result and returns `target_changed`.
|
||||
|
||||
The web-session timeline uses CDP monotonic seconds where the event producer
|
||||
provides a comparable timestamp; absent or invalid timestamps are `null` rather
|
||||
than a synthetic zero. Events buffered while REA samples the window boundary are
|
||||
retained only when their timestamp places them after that boundary; older events
|
||||
are excluded. A buffered event without a comparable timestamp may remain as
|
||||
contextual evidence with `timestamp: null`, accompanied by a limitation stating
|
||||
that it does not establish activity in the selected window. Main-frame scope
|
||||
enforcement still applies to contextual navigation events. Network redirect
|
||||
events retain their paired prior-hop response context when the timestamped
|
||||
next-request event falls inside the window. The pre-arm timeline buffer is bounded
|
||||
by original CDP message bytes. If clock sampling completes after that budget is
|
||||
exhausted, the operation returns `payload_limit` instead of a partial window.
|
||||
Cancellation and clock-request failures retain their own outcomes.
|
||||
|
||||
## Data minimization
|
||||
|
||||
The Link projection parses repeated CDP header field lines independently and
|
||||
@@ -331,7 +361,7 @@ REA retains local observation data and removes only URL userinfo credentials, ex
|
||||
WebSocket stream inventories and connection counts likewise require complete
|
||||
WebSocket coverage. Redirect resource types use only shared known metadata:
|
||||
an omitted type leaves equality unknown rather than proving a modification.
|
||||
- Console observations with a stack source retain call type, argument types, timestamp, and source location. When console text capture is selected, already-delivered primitive values are retained verbatim; objects, getters, and remote properties are never expanded.
|
||||
- Console observations with a stack source retain call type, argument types, the CDP `Runtime.Timestamp` in milliseconds since Unix epoch, and source location. An absent or invalid timestamp is `null` and makes `console_events` incomplete; an observed zero remains zero. This epoch-millisecond clock is separate from the monotonic-second timestamps in `observe-web-session`. When console text capture is selected, already-delivered primitive values are retained verbatim; objects, getters, and remote properties are never expanded.
|
||||
- WebSocket observations retain direction, opcode, and payload byte length. When shape capture is selected, text frames are classified as text or complete value-free JSON shape; binary bytes, hashes, prefixes, and raw frames are never retained.
|
||||
- Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` selection. Cookie fingerprints cover cookies applicable to the current main-frame URL, including its path, rather than every cookie path on that origin. Other storage remains scoped to the origin. Cache fingerprints hash all decoded response bytes, including bodies above 64 KiB, subject to the [64 MiB per-message CDP limit](#request-boundary). Missing or undecodable cache bodies and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`.
|
||||
- Unavailable or malformed storage inventories mark `storage_keys` unavailable, even without fingerprints. Comparisons suppress inferred key additions and removals while preserving modifications proven by complete fingerprints on both sides. Previously exported captures are not retroactively corrected; recapture them to obtain these coverage markers.
|
||||
|
||||
@@ -91,17 +91,20 @@ same bundle. Export writes only the caller-selected output file.
|
||||
|
||||
## Facts and uncertainty
|
||||
|
||||
| Family | Evidence used |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| .NET | A PE optional header declares a nonempty CLI directory. |
|
||||
| Unity Mono | A managed assembly below `*_Data/Managed` or `Contents/Resources/Data/Managed` has an exact UnityEngine assembly reference. |
|
||||
| Unity IL2CPP | An IL2CPP metadata header coexists with a conventionally named native `GameAssembly.dll`, `GameAssembly.so`, `GameAssembly.dylib` or `libil2cpp.so` image. Build correspondence remains unknown. |
|
||||
| Godot | A `GDPC` header at file start or a validated executable trailer offset. |
|
||||
| GameMaker | A bounded `FORM` container starts with a `GEN8` chunk. |
|
||||
| Unreal | `Content/Paks` path conventions or an IoStore TOC signature; arbitrary `.pak` filenames are insufficient. |
|
||||
| Electron | A `resources/app.asar` or `resources/default_app.asar` path convention; archive validity is unverified. |
|
||||
| Family | Evidence used |
|
||||
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| .NET | A PE optional header declares a nonempty CLI directory. |
|
||||
| Unity Mono | A managed assembly below `*_Data/Managed` or `Contents/Resources/Data/Managed` has an exact UnityEngine assembly reference. |
|
||||
| Unity IL2CPP | A recognized IL2CPP metadata magic (with the raw version when complete) coexists with a conventionally named native `GameAssembly.dll`, `GameAssembly.so`, `GameAssembly.dylib` or `libil2cpp.so` image. Build correspondence remains unknown. |
|
||||
| Godot | A `GDPC` header at file start or a validated executable trailer offset. |
|
||||
| GameMaker | A bounded `FORM` container starts with a `GEN8` chunk. |
|
||||
| Unreal | `Content/Paks` path conventions or an IoStore TOC signature; arbitrary `.pak` filenames are insufficient. |
|
||||
| Electron | A `resources/app.asar` or `resources/default_app.asar` path convention; archive validity is unverified. |
|
||||
|
||||
An observed metadata header with supported raw version 29, 31 or 39 offers an
|
||||
When the four-byte metadata magic is present but version bytes are incomplete,
|
||||
the fact retains `raw_version: null`; it does not offer an IL2CPP metadata
|
||||
inspection probe. A complete magic/version prefix with supported raw version 29,
|
||||
31 or 39 offers an
|
||||
`inspect_il2cpp_metadata` follow-up at its admitted file path, including when no
|
||||
native library was found. This probe does not establish full metadata validity
|
||||
or correspondence to a native binary. See [IL2CPP metadata](il2cpp-metadata.md).
|
||||
|
||||
@@ -6,6 +6,22 @@ For remote devices, pass a host with an optional port (`host[:port]`; bracket IP
|
||||
|
||||
MCP exposes `list_frida_devices`, `list_frida_processes`, `start_frida_session`, `load_frida_script`, `resume_frida_session`, `unload_frida_script`, `frida_session_status`, and `close_frida_session`. A spawned process starts paused so scripts can load first. Closing unloads session scripts and detaches. If a spawn remains paused, REA resumes it only after script cleanup or session detach confirms the instrumentation is released; if both cleanup paths fail, the target stays paused and the cleanup error is retained. REA never kills the target. MCP sessions end with the server process. The one-shot CLI command `instrument-with-frida` accepts one inline script or script file, observes messages for a bounded duration, and returns Evidence with the source hash and captured messages.
|
||||
|
||||
For state and capture-coverage checks, pass `{"session_id":"SESSION_ID","detail":"summary"}` to `frida_session_status`. Each call reads the observed session state and retains the complete observation. The reply is an `inspect_analysis_view` Evidence summary with the session identity, state, scripts, `messages_retained`, and `messages_truncated`; message payloads remain in its `parent_evidence_id`. The count covers retained queued messages, not every target event. Omitted `detail` or `detail=complete` preserves the existing complete reply.
|
||||
|
||||
A summary's selected fields look like this (excerpt):
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"state": "running",
|
||||
"messages_retained": 50,
|
||||
"messages_truncated": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use `get_evidence_bundle` with `detail="summary"` and `filters={"evidence_id":"PARENT_ID"}` to discover parent metadata without message payloads. Filters apply only to summary delivery. Use `export_evidence_bundle` for the complete canonical bundle, then select the record whose `evidence_id` matches `normalized_result.parent_evidence_id` from the status summary. To use the existing CLI `inspect-analysis-view`, extract that record into a standalone Evidence JSON file and select it as an Evidence-file source with `view={"kind":"summary"}`. Reading retained Evidence does not refresh a live session or verify a process after exit or PID reuse.
|
||||
|
||||
Failed startup cleanup retains acquired Frida handles, including a paused spawn that has not attached, for later cleanup retries. Its remote connection stays leased until session detach and target resume complete. CLI commands await provider cleanup before returning; MCP retains these resources for cleanup when its server closes. Server shutdown stops admitting new Frida operations and waits for admitted startup, discovery, and queued work before releasing their resources. A failed cleanup remains reported and can be retried by closing the server again, rather than treating an unregistered session as fully released.
|
||||
|
||||
Frida scripts execute inside the selected target with that process's memory, filesystem, and network authority. Message capture is bounded to 16 MiB and reports truncation. Script source is not stored in Evidence; its selected path and SHA-256 digest are. For a file, `source_sha256` identifies the original selected bytes. Invalid UTF-8 continues to use replacement decoding; Evidence then records that limitation and `raw_result.source_representation` with the encoding, decoding mode, and `loaded_source_sha256` of the UTF-8 string passed to Frida. Valid UTF-8 files and inline scripts do not need this additional representation fact. Evidence retains non-authentication target selection such as device or endpoint, mode, program or PID, and spawn arguments. Authentication values remain excluded. Results report the observed session and messages, while unsupported platform metadata remains unknown. A request cancelled before a Frida operation starts has no provider effects. If cancellation arrives during a native Frida call, REA waits for that call to return, then closes any session it created and returns the observed session as a partial observation. A cancelled script load on an existing session releases only its new script before queued operations can run; the session and previously loaded scripts remain available. If cancellation is observed when the native call returns, partial Evidence retains the source identity and captured messages even if that call rejects, including messages emitted during awaited cleanup. The caller's cancellation reason remains primary and a native rejection is retained as a sanitized diagnostic. A failure already observed before cleanup stays primary if cancellation arrives during cleanup. The cancellation cutoff is REA's observation of native completion, rather than a claim about unobservable native event order. `script_id` is REA's load-attempt identity. When creation rejects without returning a handle, `script_cleanup: "no_handle"` means REA acquired no handle to unload; it does not prove that the engine performed no work. Once the provider commits a successful load, later request cancellation does not unload that script; use `unload_frida_script` to release it. If script cleanup fails, the script remains owned and a paused target cannot resume until the pending instrumentation is released. Queued script loads, resumes, and unloads check cancellation before dispatching native effects. Cancellation Evidence separately reports whether a target resume was observed, whether instrumentation cleanup completed, and that final target liveness remains unknown. REA does not kill the target. Remote discovery retains its observations and reports a separate cleanup error if the temporary connection cannot be removed.
|
||||
|
||||
@@ -340,6 +340,13 @@ A scoped report has `scope.mode: "explicit"`. Its `scope_checks` determine
|
||||
`environment_healthy` summarizes the full audit. `--target` adds a target check,
|
||||
but the report remains audit-wide unless a scope option is also supplied.
|
||||
|
||||
An invalid `REA_LOG_LEVEL` fails every scope, including one client or the
|
||||
installed skill, because the executable rejects it before any command. A
|
||||
selected client also fails for every other setting that MCP startup rejects,
|
||||
including `REA_GHIDRA_STARTUP_TIMEOUT_MS` and `REA_HOPPER_STARTUP_TIMEOUT_MS`.
|
||||
Those settings still fail a selected provider and the audit-wide result. A
|
||||
skill-only report leaves them informational.
|
||||
|
||||
Follow the remediation for the failed check. If a native target has several
|
||||
available providers, choose one with `--provider` on the CLI or `provider_id`
|
||||
on `open_binary`. A `capability_unavailable` failure carries
|
||||
|
||||
+135
-39
@@ -69,8 +69,14 @@ Source projects also contain large capacity regressions; `test:fast` promises
|
||||
no compiled-runtime prerequisite, not a fixed time budget.
|
||||
|
||||
`npm run lint` prints errors and a warning summary; `npm run jscpd` prints
|
||||
duplication statistics. Complete reports remain in `.cache/static-checks/` and
|
||||
CI uploads them as `static-check-reports`, including after a failed static gate.
|
||||
duplication statistics. Complete reports remain in `.cache/static-checks/`.
|
||||
The fast gate uploads them as `static-check-reports`. Full qualification
|
||||
uploads duplication diagnostics from the deep job as
|
||||
`static-deep-check-reports`, including after that gate fails.
|
||||
Task-graph recording runs `check:ci:fast:tasks` and `check:ci:deep:tasks`
|
||||
without the artifacts lock, so it can record hashes while `check:pr` or
|
||||
`check:test` already holds that lock. The CI jobs still run the locked
|
||||
`check:ci:fast` and `check:ci:deep` commands.
|
||||
Use `npm run lint:details` or `npm run jscpd:details` for all console diagnostics.
|
||||
Both duplicate commands scan production and test code with the same 3% limit;
|
||||
the summary commands preserve checker failures and restore reports from cached
|
||||
@@ -140,6 +146,17 @@ the fixture cache. These subprocesses are outside aggregate Vitest coverage;
|
||||
subprocess coverage attachment remains disabled. Cold-start import checks launch
|
||||
independently without this fixture cache.
|
||||
|
||||
`analyzeJavaScriptDirectory` stdio MCP cases in one `cliTest` file share one
|
||||
server and that compile cache. The first case pays startup; later cases reuse
|
||||
the process. File teardown waits for startup still in flight, closes a server
|
||||
registered during that wait, and does not open a server after close. A failed
|
||||
handshake stays the caller error when its cleanup also fails, including a
|
||||
rejection without a reason. That cleanup stays owned until a later close or
|
||||
call releases it. Another server does not open while the cleanup remains, and
|
||||
a cleanup that already succeeded is not run again. Pass
|
||||
`mcpCall` when a case needs its own server, environment, or lifecycle. CLI
|
||||
cases still start a fresh process.
|
||||
|
||||
The Apple artifact verifier step shares a job-temporary bytecode cache among
|
||||
its fresh CLI/MCP children via `REA_ARTIFACT_NODE_COMPILE_CACHE`. The artifact
|
||||
helper maps this setting to child `NODE_COMPILE_CACHE`; npm, Turbo, and nested
|
||||
@@ -454,8 +471,8 @@ partial diagnostics and preflights do not establish complete mutation outcomes
|
||||
or qualify the release revision for mutation coverage.
|
||||
|
||||
Fast CI starts the source-only projects after classification, independently of
|
||||
build and static checks, and runs five built-dependent shards after the build.
|
||||
The six test jobs still select every portable test. Full qualification keeps six
|
||||
build and static checks, and runs eight built-dependent shards after the build.
|
||||
Those nine test jobs still select every portable test. Full qualification keeps six
|
||||
complete Vitest shards with coverage. Neither tier retries tests. Canonical
|
||||
project ownership lives in `scripts/lib/test-projects.mjs`, shared by Vitest,
|
||||
CI group execution, and development-test preparation. A test importing compiled
|
||||
@@ -481,14 +498,16 @@ real-system lanes remain separate jobs so one kind of evidence cannot stand in
|
||||
for another. Workflow syntax and expression validation runs inside the required
|
||||
static-check job when workflow changes select it, avoiding a separate runner.
|
||||
Native, provider, fixture, SQLite compatibility, and website lanes wait for
|
||||
static checks to succeed before allocating their runners. This avoids spending
|
||||
those runner slots on revisions that already fail static validation. Portable
|
||||
test shards and documentation checks remain independent of static results, so
|
||||
they can still provide diagnostic feedback. The required gate rejects skipped
|
||||
selected lanes when static checks fail; passing revisions retain all selected
|
||||
coverage. This ordering reduces peak demand on invalid revisions but can add
|
||||
static-check waiting to otherwise passing runs; it is not a measured reduction
|
||||
in end-to-end latency.
|
||||
the fast static checks (typecheck, built lint, and format) to succeed before
|
||||
allocating their runners. Knip, duplication, architecture guards, test
|
||||
discovery, and the Python producer run in the separate full-tier deep static
|
||||
job and do not gate provider admission. Portable test shards and documentation
|
||||
checks remain independent of static results, so they can still provide
|
||||
diagnostic feedback. The required gate rejects skipped selected lanes when
|
||||
static checks fail; on the full tier it also requires the deep static job.
|
||||
Passing revisions retain all selected coverage. This ordering reduces peak
|
||||
demand on invalid revisions but can add static-check waiting to otherwise
|
||||
passing runs; it is not a measured reduction in end-to-end latency.
|
||||
|
||||
Core jobs have execution limits of five minutes for classification, ten minutes
|
||||
for static checks, builds, docs, and report merging, and thirty minutes per
|
||||
@@ -514,11 +533,35 @@ JSON artifact members without executing artifact content. Reports and limits
|
||||
are retained in `ci-health-diagnostics`; its ten-minute job limit covers the
|
||||
existing four-minute timing collection plus the bounded diagnostic stages.
|
||||
|
||||
Shard-count reduction remains deferred: the available successful timing sample
|
||||
covers an older inventory, and file phase weights are not job wall time. The
|
||||
one-source/five-built split retains six test jobs; it makes no hosted speedup
|
||||
claim. Current complete timings and completed candidate hosted runs are needed
|
||||
before enabling a smaller adaptive matrix.
|
||||
Fast built shards target under ten minutes of uncontended shard-job execution.
|
||||
That figure is the `test-shard` job. The job waits for classification and the
|
||||
build, and the portable-test merge runs after the slowest shard. On 2026-10-20,
|
||||
run `39451548945` measured those surrounding jobs with runner queue excluded:
|
||||
classification 15 seconds, build 49 seconds, and the portable-test merge 29
|
||||
seconds. Shard 1/5 of that run spent 22 seconds before built Vitest and 3
|
||||
seconds after it, including `npm ci` at 9 seconds. `node_modules` is about 674
|
||||
MiB, so publishing it as a shared artifact would transfer more data than that
|
||||
install saves. The npm download cache stays the setup cache.
|
||||
|
||||
File timings from that run put the built suite at 7944 seconds of module time.
|
||||
The sequencer keeps each file's full duration on one worker. Extra workers
|
||||
overlap other files in the same sequence group, and `process-boundary` runs
|
||||
after that group. Scheduling the unsplit inventory onto five shards peaks at
|
||||
820 seconds of tests. That run's slowest built shard job lasted 925 seconds
|
||||
(shard 4/5), and shard 1/5 spent 874 seconds in built Vitest, so hosted job
|
||||
time sits above the file schedule. Six shards of the same inventory peak at
|
||||
684 seconds of tests. Eight shards, after the 578-second loader file is split
|
||||
by case count (54 cases stay in that file and 46 move into each of three
|
||||
files), peak at 513 seconds of tests. With the measured 25-second shard setup,
|
||||
that shard job is about 538 seconds. Adding the measured classification, build,
|
||||
and merge makes an uncontended portable critical path of about 631 seconds,
|
||||
still before the gap between this file schedule and hosted Vitest. Runner queue
|
||||
is separate and grows when more shard jobs compete for runners.
|
||||
`process-boundary` stays serial because process-tree sampling shares the host.
|
||||
Raising the worker cap from two to four on this four-core host cut a six-file
|
||||
acceptance sample from 96–101 seconds to 71 seconds and did not remove the
|
||||
single-file floor, so the cap stays two. Full qualification keeps six covered
|
||||
shards; its longer instrumentation is outside this fast budget.
|
||||
|
||||
The separate `CI timings` workflow batches recent completed CI and Release runs
|
||||
hourly or on manual dispatch. It uploads `ci-job-timings-batch` with raw API pages and
|
||||
@@ -560,8 +603,9 @@ metadata, and available manifests into `.cache/ci-build-cache/`.
|
||||
`portable-build-cache.json` records availability per task. Evicted or partially
|
||||
missing entries are omitted as whole entries, and consumers rebuild cache misses,
|
||||
including when every entry is unavailable. Malformed plans, non-file entries,
|
||||
and I/O errors other than disappearance still fail publication. Older cross-run entries stay in the
|
||||
original cache. The Windows lane relies on the Linux static job for typechecking. They retain the normal build and prepack
|
||||
and I/O errors other than disappearance still fail publication. After a trusted
|
||||
static run, superseded cross-run Turbo snapshots for that OS and architecture
|
||||
are deleted down to the two newest generations. The Windows lane relies on the Linux static job for typechecking. They retain the normal build and prepack
|
||||
commands, which restore matching outputs or rebuild on a cache miss. Native
|
||||
artifacts, dependency installs, package archives, and runtime verification remain
|
||||
host-local. Keep platform-dependent outputs out of this shared cache.
|
||||
@@ -1110,7 +1154,7 @@ suite, without the source-test shards or native package
|
||||
lanes. Classification compares the PR head with its merge base, so later
|
||||
base-branch changes do not expand that scope. Static and portable-test aggregate jobs
|
||||
remain present and fail if classification fails. Every code change retains the
|
||||
complete six-shard Linux suite; individual test files are not selected by
|
||||
complete portable Linux suite; individual test files are not selected by
|
||||
imports or filenames.
|
||||
|
||||
Ordinary PRs also produce an **observational** test-selection plan. The candidate
|
||||
@@ -1121,7 +1165,7 @@ tests. Shared inputs, unknown paths, missing owner tests, and removed tests fall
|
||||
back to the complete inventory. Renames account for both paths. Main, scheduled,
|
||||
manual, and `ci:full` runs always propose the full suite.
|
||||
|
||||
All six shards still execute the complete suite. The existing report merge
|
||||
The source group and every built shard together still execute the complete suite. The existing report merge
|
||||
compares the proposal with actual module outcomes and retains
|
||||
`.cache/ci-test-selection/report.json` in `test-results`, including selected and
|
||||
omitted paths, changed input paths, missed failures, unobserved files, scheduling weights, PR head and
|
||||
@@ -1167,13 +1211,16 @@ scripts also select it. Unrelated code edits retain the complete portable suite
|
||||
on the pinned Node version without starting four additional compatibility jobs.
|
||||
Full qualification retains every SQLite version.
|
||||
Shared process/contract/CLI boundaries, dependencies, other packaging inputs,
|
||||
unknown paths, and changes to the planner select the full baseline.
|
||||
unknown paths, and changes to the planner select the fast Linux x64 baseline:
|
||||
portable code checks and the installed-package runtime lane, without provider
|
||||
lanes or the multi-host package matrix. A shared file with exactly one owner
|
||||
still selects that owner. Full qualification still selects every lane.
|
||||
CI timing collection, timing history, and test-report helper edits retain static
|
||||
checks and the complete portable suite without selecting unrelated host/provider
|
||||
lanes. Classification, qualification, gate, release receipt, and unknown CI
|
||||
scripts keep the broad fallback.
|
||||
scripts keep that fast baseline.
|
||||
Package script-only edits select their owning verifier when its entrypoint is
|
||||
identifiable; unknown or shared script changes select everything. Android
|
||||
identifiable; unknown or shared script changes select the fast baseline. Android
|
||||
build-script edits retain the Linux suite and workflow validation; real Android
|
||||
verification stays manual. Renames account for both paths.
|
||||
|
||||
@@ -1197,9 +1244,10 @@ or provider effects outside its owners.
|
||||
Ordinary main pushes use the same owner scopes as PRs. Their diff starts at a
|
||||
successful ancestor push run of this CI workflow, rather than the preceding
|
||||
push: changes from failed or cancelled intermediate runs remain in scope.
|
||||
Missing, untrusted, or non-ancestor history selects every lane. The lookup is
|
||||
Missing, untrusted, or non-ancestor history selects the fast Linux x64
|
||||
baseline: portable checks and the Linux runtime lane. The lookup is
|
||||
bounded to the last 100 successful push runs; lookup failures broaden selection.
|
||||
Every code change still selects all six portable shards, without coverage
|
||||
Every code change still selects the source group and all eight fast built shards, without coverage
|
||||
instrumentation in this fast tier. Ordinary PR matrices cancel remaining
|
||||
shards after the first failure; passing PRs execute the complete suite. Main,
|
||||
`ci:full`, scheduled, manual, and release qualification retain every shard's
|
||||
@@ -1227,8 +1275,11 @@ immediately starts the separate `Full PR qualification` workflow against the
|
||||
label event's merge revision; subsequent PR revisions also select the full tier
|
||||
in normal CI while that label remains. Unrelated labels do not start validation
|
||||
jobs or replace the main CI checks. Manual dispatch also requests full validation.
|
||||
Fast-tier broad fallbacks retain all owner lanes and the three-host
|
||||
package matrix; Intel macOS and coverage remain full-tier obligations. Each
|
||||
Fast-tier broad fallbacks keep the portable suite and the Linux x64 runtime
|
||||
baseline. They do not start every owner lane or the three-host package
|
||||
matrix. Intel macOS and coverage remain full-tier obligations. Real provider
|
||||
lanes stay path-gated, and a shared registration file listed by many lanes
|
||||
does not start those lanes on a fast PR. Each
|
||||
classification job records its selection and broad-fallback reasons in the run
|
||||
summary. Representative Git-diff cases live in
|
||||
`tests/boundary/filesystem/ciChangeScope.test.ts`.
|
||||
@@ -1308,9 +1359,16 @@ report in each shard's snapshot. The bounded lookup verifies the artifact's
|
||||
producer; newer schedules that reuse qualification without producing timings do
|
||||
not hide the last full run's artifact.
|
||||
CI assigns discovered files by historical duration, with a deterministic
|
||||
fallback weight for new files, rather than dividing only by file count. Every
|
||||
discovered file still belongs to exactly one shard; worker limits and project
|
||||
ordering remain unchanged. The merge job rejects successful reports with missing,
|
||||
fallback weight for new files, rather than dividing only by file count.
|
||||
Each file keeps its full duration on one worker. Extra workers overlap other
|
||||
files in the same sequence group, and `process-boundary` runs after that
|
||||
group. History published before the competing-loader split still names only
|
||||
`javascriptLiteralPercentPaths.test.ts`. Until that artifact records one of
|
||||
the three new files, the sequencer assigns that recorded duration by the
|
||||
pre-split case counts: 54/192 to the remaining file and 46/192 to each new
|
||||
file. Every discovered file still belongs to exactly one shard; worker limits
|
||||
and project ordering remain unchanged. Fast qualification keeps eight built
|
||||
shards without coverage. The merge job rejects successful reports with missing,
|
||||
unexpected, or duplicate portable files. Coverage instrumentation and aggregate
|
||||
thresholds remain enforced in full qualification.
|
||||
Missing or unusable history falls back to Vitest's default assignment. Only
|
||||
@@ -1318,18 +1376,56 @@ timing data is reused; live provider results are never cached. Turbo outputs are
|
||||
restored by task hash, with trusted default-branch runs as cache writers and PRs
|
||||
as restore-only consumers. The static job also restores its Turbo snapshot;
|
||||
each task revalidates its own input hash before reusing outputs or diagnostics.
|
||||
The Node pin and npm install settings are global hash inputs. Knip includes its ignore rules and VitePress
|
||||
The Node pin and npm install settings are global hash inputs. Knip runs only
|
||||
in full qualification, with `--cache` and a GitHub cache of
|
||||
`node_modules/.cache/knip`. The saved key includes the commit SHA. Restore uses
|
||||
the configuration prefix, so the next trusted full run can publish a refreshed
|
||||
snapshot. That cache is an AST cache keyed by file mtime and size, so a
|
||||
fresh checkout often misses it; Turbo still hashes the Knip task inputs. Knip
|
||||
includes its ignore rules and VitePress
|
||||
entry, so changes to those inputs cannot reuse an older successful check.
|
||||
Jobs that run Turbo export `TURBO_TEAM`. A called workflow sees the caller
|
||||
event, so `workflow_call` is not an event name inside CI. `TURBO_TOKEN` is set
|
||||
when release qualification passes `remote_cache_write`, or when the ref is the
|
||||
default branch and the event is push, schedule, or workflow_dispatch, and only
|
||||
when that secret is available to the run. Release qualification passes
|
||||
`TURBO_TOKEN` and `remote_cache_write` into the called workflow. Labeled
|
||||
pull-request qualification does not. Pull requests receive an empty token and
|
||||
`TURBO_CACHE=local:rw`. Remote reads require `TURBO_TOKEN`, and this repository
|
||||
does not pass a separate read-only credential into pull requests. An empty
|
||||
token leaves the local Turbo cache in place.
|
||||
|
||||
The shared npm-cache action caches downloads, not `node_modules`, and every
|
||||
consumer still runs `npm ci`. Keys cover the runner OS, actual Node architecture,
|
||||
root lockfiles, and `.npmrc`. Only push, schedule, and manual events on the
|
||||
repository's default branch may save shared npm caches; PRs restore only.
|
||||
Static snapshots use the same write authority and are saved only after the
|
||||
static job's validation succeeds. Main warms the new cache namespace after
|
||||
merge; a cold cache or changed lockfile falls back to the normal install.
|
||||
This prevents each PR from saving another branch-scoped copy of the same
|
||||
download cache. It does not remove runner assignment waits.
|
||||
root lockfiles, and `.npmrc`. An exact-key miss restores the newest cache for
|
||||
that OS and architecture, so a lockfile change reuses unchanged tarballs while
|
||||
`npm ci` still verifies the lockfile. Only push, schedule, and manual events on
|
||||
the repository's default branch may save shared npm caches, and only from the
|
||||
opted-in writer for that host: Static checks for Linux x64, the package matrix
|
||||
for every other included host, and the Windows curated lane. Every other job
|
||||
restores without saving, so parallel jobs do not publish duplicate copies.
|
||||
Pull requests restore only. Static snapshots are saved only after the static
|
||||
job's validation succeeds. Cache deletion is a
|
||||
separate workflow, because reusable qualification calls this workflow and
|
||||
cannot raise its read-only token. After a push, schedule, or manual CI run on
|
||||
the default branch finishes, including when a later lane fails, times out, or
|
||||
is cancelled, that workflow runs on `ubuntu-latest` and deletes older
|
||||
`rea-static-v1`, `rea-static-deep-v1`, and `rea-portable-build-v2` entries for
|
||||
that runner's OS and architecture, plus older `knip-meta-v1` entries for that
|
||||
OS. It keeps two generations plus the key that run saved. A fast run that did
|
||||
not publish a deep or Knip snapshot still leaves the two newest entries in
|
||||
those families. It
|
||||
deletes an npm download record only when another record has the same key, cache
|
||||
version, and git ref, and it keeps the most recently accessed copy. A different
|
||||
version or ref is a different restore target and stays. One cache id is one
|
||||
record: a repeated id keeps the later access time when its key, version, ref,
|
||||
size, and creation time agree, and an inconsistent repeat deletes nothing. An
|
||||
incomplete or unexpected cache list deletes nothing.
|
||||
Distinct lockfile generations of the download cache stay, so the restore
|
||||
prefix still has a seed.
|
||||
Main warms the new cache namespace after merge; a cold cache falls back to the
|
||||
normal install. This prevents each PR from saving another branch-scoped copy of
|
||||
the same download cache. It does not remove runner assignment waits.
|
||||
|
||||
Portable owner-based test selection remains observational. Its reports compare
|
||||
proposed omissions with the full run; incomplete or skipped observations do not
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Count all actual client JSON inputs and preserve bounded family resource receipts."""
|
||||
from pathlib import Path
|
||||
import argparse, json, hashlib
|
||||
import tiktoken
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--root',type=Path,required=True)
|
||||
parser.add_argument('--output',type=Path,required=True)
|
||||
args=parser.parse_args();lab=args.root;enc=tiktoken.get_encoding('cl100k_base')
|
||||
compact=lambda value:json.dumps(value,ensure_ascii=False,separators=(',',':'),allow_nan=False)
|
||||
count=lambda value:len(enc.encode(compact(value),disallowed_special=()))
|
||||
def resources(root):
|
||||
scope=json.loads(Path(str(root)+'.log').read_text().splitlines()[-1]);o=scope['payload_resource_observation']
|
||||
assert scope['abort'] is None and scope['payload_resource_observation_verified'] and o['payload_exit_code']==0 and o['payload_cleanup_verified']
|
||||
for event in o['resource_events'].values():assert not any(event.values())
|
||||
return {'wall_seconds':scope['elapsed_seconds'],'cpu_seconds':o['cpu_stat']['usage_usec']/1e6,'family_peak_mib':o['memory_peak_bytes']/1048576,'payload_cleanup_verified':True,'final_whole_scope_counters_verified':scope['resource_counters_verified']}
|
||||
wire=[];sdk=[]
|
||||
for root in sorted(lab.glob('final-wire-*')):
|
||||
if not root.is_dir():continue
|
||||
report=json.loads((root/'report.json').read_text());requests=json.loads((root/'model-requests.json').read_text());assert report['status'] in ['passed','expected-incomplete-task'] and not report['cleanup_failures'] and report['appserver_exit_code']==0 and report['owned_scope_drain']['remaining_processes']==[]
|
||||
rows=[]
|
||||
for index,captured in enumerate(requests):
|
||||
body=captured['body'];namespace=[n for n in body.get('tools',[]) if n.get('type')=='namespace' and n.get('name')=='mcp__rea_audit'];compaction=not namespace
|
||||
if compaction:assert 'CONTEXT CHECKPOINT COMPACTION' in compact(body['input'][-1])
|
||||
else:assert len(namespace)==1 and len(namespace[0]['tools'])==210
|
||||
rows.append({'index':index+1,'kind':'compaction' if compaction else 'normal','whole_json_tokens_proxy':count(body),'rea_namespace_tokens_proxy':0 if not namespace else count(namespace[0]),'rea_function_count':0 if not namespace else len(namespace[0]['tools'])})
|
||||
wire.append({'case':root.name,'task_completed':report['task_completed'],'mode':report['mode'],'budget':report['observations']['tool_output_budget'],'requests':len(rows),'compactions':sum(r['kind']=='compaction' for r in rows),'whole_json_tokens_proxy':sum(r['whole_json_tokens_proxy'] for r in rows),'request_details':rows,**resources(root)})
|
||||
for root in sorted(lab.glob('final-sdk-*')):
|
||||
if not root.is_dir():continue
|
||||
report=json.loads((root/'report.json').read_text());assert report['status']=='passed' and not report['cleanup_failures'] and not report['receipt_failures']
|
||||
sdk.append({'case':root.name,'provider':report['provider'],'symbol':report['symbol'],'detail':report['detail'],'warm':report['warm'],'tool_calls':len(report['calls']),'exported_bundle_bytes':(root/'evidence.json').stat().st_size,'disassembly_calls':[r for r in report['calls'] if r['phase'].startswith('disassembly:')],**resources(root)})
|
||||
receipt={'measurement_scope':'Actual public SDK and Codex0.163 app-server with real GDB/BFD. Local deterministic SSE fixture and observed-ID checkpoint; no AI inference, model usage/quality/acceptance or billing claim. Whole compact JSON serialization tokens are proxies and include all schemas/replays/compaction. Incomplete default baseline is excluded from completed-workflow gains. Fresh processes have unflushed OS caches; warm SDK cases repeat the selected disassembly in the same server. Native target never executed.','tokenizer':{'package':tiktoken.__version__,'encoding':enc.name},'runtime_pins':{variant:{k:v for k,v in json.loads((lab/(variant+'-manifest.json')).read_text()).items() if k!='files'} for variant in ['baseline','candidate']},'native':json.loads((lab/'native-prerequisite-manifest.json').read_text()),'wire':wire,'sdk':sdk}
|
||||
args.output.write_text(json.dumps(receipt,indent=2)+'\n');print(json.dumps({'wire':[{k:v for k,v in row.items() if k!='request_details'} for row in wire],'sdk':[{k:v for k,v in row.items() if k!='disassembly_calls'} for row in sdk]},indent=2))
|
||||
@@ -0,0 +1,22 @@
|
||||
from pathlib import Path
|
||||
import struct,hashlib,json,argparse
|
||||
parser=argparse.ArgumentParser(description='Independent ELF64 little-endian native-command byte oracle');parser.add_argument('--input',type=Path,required=True);parser.add_argument('--output',type=Path,required=True);args=parser.parse_args();path=args.input;b=path.read_bytes();assert b[:6]==b'\x7fELF\x02\x01'
|
||||
shoff=struct.unpack_from('<Q',b,40)[0];entsize,n,strings=struct.unpack_from('<HHH',b,58)
|
||||
assert entsize==64 and shoff+n*entsize<=len(b)
|
||||
sections=[struct.unpack_from('<IIQQQQIIQQ',b,shoff+i*entsize) for i in range(n)]
|
||||
rows=[]
|
||||
for si,s in enumerate(sections):
|
||||
if s[1]!=11:continue
|
||||
text=sections[s[6]];names=b[text[4]:text[4]+text[5]]
|
||||
assert s[9]==24 and s[5]%s[9]==0
|
||||
for ordinal,off in enumerate(range(s[4],s[4]+s[5],24)):
|
||||
name,info,other,section,value,size=struct.unpack_from('<IBBHQQ',b,off)
|
||||
end=names.find(b'\0',name);assert end>=name
|
||||
if names[name:end] not in [b'execute_command',b'execute_command_internal'] or info&15!=2:continue
|
||||
target=sections[section];file_offset=target[4]+value-target[3]
|
||||
assert target[4]<=file_offset and file_offset+size<=target[4]+target[5]
|
||||
rows.append({'symbol_table_section':si,'symbol_ordinal':ordinal,'name':names[name:end].decode('ascii'),'address':value,'bytes':size,'file_offset':file_offset,'first_16_bytes_hex':b[file_offset:file_offset+16].hex()})
|
||||
assert rows
|
||||
result={'path':str(path),'artifact_sha256':hashlib.sha256(b).hexdigest(),'artifact_bytes':len(b),'elf_class':64,'byte_order':'little','elf_machine':struct.unpack_from('<H',b,18)[0],'symbols':rows,'scope':'Independent ELF64 little-endian dynamic symbol/section mapping; no target execution or REA parser.'}
|
||||
args.output.write_text(json.dumps(result,indent=2)+'\n')
|
||||
print(json.dumps(result))
|
||||
@@ -0,0 +1,212 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { finished } from "node:stream/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { parseArgs } from "node:util";
|
||||
import { createRetainedQueryCaller } from "./retained-query-calls.mjs";
|
||||
import { finalizeRetainedQueryReceipt } from "./retained-query-finalization.mjs";
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
runtime: { type: "string" },
|
||||
input: { type: "string" },
|
||||
oracle: { type: "string" },
|
||||
output: { type: "string" },
|
||||
provider: { type: "string" },
|
||||
symbol: { type: "string" },
|
||||
detail: { type: "string", default: "complete" },
|
||||
warm: { type: "boolean", default: false },
|
||||
},
|
||||
});
|
||||
for (const key of ["runtime", "input", "oracle", "output"])
|
||||
assert.equal(values[key], resolve(values[key]));
|
||||
assert.ok(["gdb", "objdump"].includes(values.provider));
|
||||
assert.ok(["complete", "summary"].includes(values.detail));
|
||||
const oracle = JSON.parse(await readFile(values.oracle, "utf8"));
|
||||
assert.ok(oracle.symbols.some((row) => row.name === values.symbol));
|
||||
const inputBytes = await readFile(values.input);
|
||||
assert.equal(
|
||||
createHash("sha256").update(inputBytes).digest("hex"),
|
||||
oracle.artifact_sha256,
|
||||
);
|
||||
const require = createRequire(join(values.runtime, "package.json"));
|
||||
const { Client } = await import(
|
||||
pathToFileURL(require.resolve("@modelcontextprotocol/client"))
|
||||
);
|
||||
const { StdioClientTransport } = await import(
|
||||
pathToFileURL(require.resolve("@modelcontextprotocol/client/stdio"))
|
||||
);
|
||||
await mkdir(values.output, { mode: 0o700 });
|
||||
const account = await mkdtemp(join(tmpdir(), "rea-command-views-"));
|
||||
const client = new Client({ name: "command-observation-task", version: "1" });
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [join(values.runtime, "scripts/rea.mjs"), "mcp"],
|
||||
env: {
|
||||
HOME: account,
|
||||
PATH: process.env.PATH,
|
||||
NODE_OPTIONS: "--max-old-space-size=2048",
|
||||
UV_THREADPOOL_SIZE: "1",
|
||||
REA_LOG_LEVEL: "silent",
|
||||
},
|
||||
stderr: "pipe",
|
||||
});
|
||||
const serverLog = createWriteStream(join(values.output, "server.log"));
|
||||
const serverLogFinished = finished(serverLog);
|
||||
serverLogFinished.catch(() => {});
|
||||
transport.stderr?.pipe(serverLog);
|
||||
const transcript = [];
|
||||
const report = {
|
||||
question:
|
||||
"Read architecture and the complete byte-annotated disassembly of one selected native function; export complete Evidence. No inferior execution.",
|
||||
provider: values.provider,
|
||||
symbol: values.symbol,
|
||||
detail: values.detail,
|
||||
warm: values.warm,
|
||||
calls: [],
|
||||
observations: [],
|
||||
};
|
||||
const call = createRetainedQueryCaller(client, (receipt, event) => {
|
||||
report.calls.push(receipt);
|
||||
transcript.push(event);
|
||||
});
|
||||
let sessionId;
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const catalog = await client.listTools();
|
||||
await writeFile(join(values.output, "catalog.json"), JSON.stringify(catalog));
|
||||
const definitions = new Map(catalog.tools.map((tool) => [tool.name, tool]));
|
||||
const originalCall = client.callTool.bind(client);
|
||||
client.callTool = (input, options) =>
|
||||
originalCall(input, {
|
||||
...options,
|
||||
timeout: 60000,
|
||||
toolDefinition: definitions.get(input.name),
|
||||
});
|
||||
const observe = async (name, input, phase = name) => {
|
||||
const response = await call(name, input, phase);
|
||||
assert.ok(!response.isError, JSON.stringify(response));
|
||||
report.observations.push({
|
||||
name,
|
||||
phase,
|
||||
evidence: response.structuredContent,
|
||||
});
|
||||
return response.structuredContent;
|
||||
};
|
||||
const completeParents = [];
|
||||
const disassemble = async (phase) => {
|
||||
const selection = values.detail === "summary" ? { detail: "summary" } : {};
|
||||
const result =
|
||||
values.provider === "gdb"
|
||||
? await observe(
|
||||
"gdb_console",
|
||||
{
|
||||
session_id: sessionId,
|
||||
command: `disassemble /r ${values.symbol}`,
|
||||
...selection,
|
||||
},
|
||||
phase,
|
||||
)
|
||||
: await observe(
|
||||
"inspect_with_objdump",
|
||||
{
|
||||
path: values.input,
|
||||
operation: "disassemble",
|
||||
symbol: values.symbol,
|
||||
...selection,
|
||||
},
|
||||
phase,
|
||||
);
|
||||
completeParents.push(result);
|
||||
};
|
||||
if (values.provider === "gdb") {
|
||||
const started = await observe("start_gdb_session", {});
|
||||
sessionId = started.session_id;
|
||||
for (const command of [
|
||||
"set auto-load off",
|
||||
"set debuginfod enabled off",
|
||||
`file ${values.input}`,
|
||||
"show architecture",
|
||||
])
|
||||
await observe("gdb_console", { session_id: sessionId, command });
|
||||
} else
|
||||
await observe("inspect_with_objdump", {
|
||||
path: values.input,
|
||||
operation: "file_headers",
|
||||
});
|
||||
await disassemble("disassembly:fresh");
|
||||
if (values.warm) await disassemble("disassembly:repeat");
|
||||
if (sessionId) {
|
||||
await observe("close_gdb_session", { session_id: sessionId });
|
||||
sessionId = undefined;
|
||||
}
|
||||
await observe("export_evidence_bundle", {
|
||||
path: join(values.output, "evidence.json"),
|
||||
});
|
||||
const bundle = JSON.parse(
|
||||
await readFile(join(values.output, "evidence.json"), "utf8"),
|
||||
);
|
||||
for (const delivered of completeParents) {
|
||||
const parent =
|
||||
values.detail === "complete"
|
||||
? delivered
|
||||
: bundle.records.find(
|
||||
(record) =>
|
||||
record.evidence_id ===
|
||||
delivered.normalized_result.parent_evidence_id,
|
||||
);
|
||||
assert.ok(parent);
|
||||
assert.equal(parent.normalized_result.completion_status, "complete");
|
||||
assert.equal(parent.normalized_result.output_truncated, false);
|
||||
if (values.provider === "gdb") assert.equal(parent.subject, null);
|
||||
else assert.equal(parent.subject.digest.sha256, oracle.artifact_sha256);
|
||||
if (values.detail === "summary") {
|
||||
const selected = delivered.normalized_result.summary;
|
||||
const { available_counts: counts, ...facts } = selected;
|
||||
const { console, target, log, ...records } = parent.normalized_result;
|
||||
assert.deepEqual(
|
||||
values.provider === "gdb" ? facts : selected,
|
||||
values.provider === "gdb" ? records : parent.normalized_result,
|
||||
);
|
||||
if (values.provider === "gdb")
|
||||
assert.equal(counts.console_utf16_units, console.length);
|
||||
assert.equal(delivered.raw_result, null);
|
||||
}
|
||||
}
|
||||
report.status = "passed";
|
||||
} catch (error) {
|
||||
report.status = "failed";
|
||||
report.failure = String(error?.stack ?? error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (sessionId)
|
||||
try {
|
||||
const closed = await call(
|
||||
"close_gdb_session",
|
||||
{ session_id: sessionId },
|
||||
"cleanup:gdb",
|
||||
);
|
||||
assert.ok(!closed.isError);
|
||||
} catch (error) {
|
||||
report.gdb_cleanup_failure = String(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
if (
|
||||
!(await finalizeRetainedQueryReceipt({
|
||||
report,
|
||||
client,
|
||||
transport,
|
||||
account,
|
||||
serverLog,
|
||||
serverLogFinished,
|
||||
output: values.output,
|
||||
transcript,
|
||||
}))
|
||||
)
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
from pathlib import Path
|
||||
import json,re,hashlib,argparse
|
||||
parser=argparse.ArgumentParser(description='Match every native instruction byte against independently mapped ELF file ranges');parser.add_argument('--root',type=Path,required=True);args=parser.parse_args();lab=args.root;oracle=json.loads((lab/'elf-oracle.json').read_text());image=Path(oracle['path']).read_bytes();assert hashlib.sha256(image).hexdigest()==oracle['artifact_sha256'];rows=[]
|
||||
for directory in sorted(lab.iterdir()):
|
||||
bundle=directory/'evidence.json'
|
||||
if not bundle.is_file():continue
|
||||
for parent in json.loads(bundle.read_text())['records']:
|
||||
if parent['operation'] not in ['gdb_console','inspect_with_objdump']:continue
|
||||
params=parent['parameters'];gdb=parent['operation']=='gdb_console'
|
||||
symbol=params.get('command','').removeprefix('disassemble /r ') if gdb else params.get('symbol')
|
||||
if symbol not in {s['name'] for s in oracle['symbols']}:continue
|
||||
spec=next(s for s in oracle['symbols'] if s['name']==symbol)
|
||||
report=parent['normalized_result'];assert report['completion_status']=='complete' and not report['output_truncated']
|
||||
if gdb:
|
||||
assert parent['subject'] is None
|
||||
console='\n'.join(line for line in report['records'] if line.startswith('~'));assert console==report['console']
|
||||
text=''.join(json.loads(line[1:]) for line in report['records'] if line.startswith('~'))
|
||||
pattern=r'^\s*(?:=>\s*)?0x([0-9a-f]+)[^:]*:\s*((?:[0-9a-f]{2}(?:\s+|$))+)'
|
||||
else:
|
||||
assert parent['subject']['digest']['sha256']==oracle['artifact_sha256'];text=report['stdout'];pattern=r'^\s*([0-9a-f]+):\s*((?:[0-9a-f]{2}(?:\s+|$))+)'
|
||||
address=spec['address'];collected=bytearray();count=0
|
||||
for line in text.splitlines():
|
||||
m=re.match(pattern,line)
|
||||
if not m:continue
|
||||
actual=int(m[1],16);raw=bytes.fromhex(m[2]);assert actual==address+len(collected),(directory,actual,address+len(collected));collected.extend(raw);count+=1
|
||||
expected=image[spec['file_offset']:spec['file_offset']+spec['bytes']];assert collected==expected,(directory,len(collected),len(expected));rows.append({'case':directory.name,'mode':'gdb' if gdb else 'objdump','symbol':symbol,'byte_bearing_lines':count,'bytes':len(collected),'exact_file_bytes':True,'parent_evidence_id':parent['evidence_id']})
|
||||
assert rows
|
||||
(lab/'native-final-verification.json').write_text(json.dumps({'status':'passed','artifact_sha256':oracle['artifact_sha256'],'records':rows},indent=2)+'\n');print(json.dumps(rows))
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,139 @@
|
||||
# Retained command observations
|
||||
|
||||
Addresses [#5584](https://github.com/morluto/rea/issues/5584). GDB command Evidence
|
||||
repeats ordered MI records in stream groups and a raw mirror; objdump repeats
|
||||
stdout/stderr in its raw mirror. Explicit `detail: summary` retains the complete
|
||||
parent and projects existing owner facts once. Default complete Evidence is
|
||||
unchanged. This adds no tool, MI decoder, live-state cache or command authority.
|
||||
|
||||
GDB summaries preserve **every ordered record**, including asynchronous and
|
||||
unrecognized records, correlated MI result, command, completion/truncation,
|
||||
invalid-UTF-8 and process-exit facts. They omit the convenience console/target/log
|
||||
groupings and report their original UTF-16 sizes. Objdump summaries preserve
|
||||
opaque stdout/stderr, exit/signal, completion and pre/post digest-change facts.
|
||||
Both carry source parameters/provider/profile, parent identity, limitations and
|
||||
unknowns. Subjectless observations stay subjectless. Historical observations do
|
||||
not establish current target state or global coverage. The existing
|
||||
`inspect_analysis_view` summary operation works after debugger close.
|
||||
|
||||
The domain codec now owns the command fact shapes used by contracts and GDB's
|
||||
capture type; protocol interpretation stays with its provider. MCP requires
|
||||
retention before command execution. CLI uses the existing shared projection and
|
||||
nonoverwriting bundle publisher after provider cleanup:
|
||||
|
||||
```sh
|
||||
rea inspect-with-objdump /usr/bin/bash disassemble --symbol execute_command \
|
||||
--detail summary --evidence_output /tmp/bash-command-evidence.json --json
|
||||
```
|
||||
|
||||
## Real input and correctness
|
||||
|
||||
Linux x86-64, Node 24.18.0/npm 11.16.0; GNU GDB 13.1 (Debian 13.1-3),
|
||||
GNU objdump 2.40, installed Bash ELF 1,265,648 bytes, SHA-256
|
||||
`55b89ab22bee4792a210f493a53fb066accd5d30b69837c28d98be5ff863efcf`.
|
||||
The [receipt](command-observation-views.json) pins native binaries, baseline main
|
||||
`c2858d2aa0916f0dd2d073d848b8c2195d9bf6b3`, source patch and packaged runtime digests.
|
||||
No target execution, attach, GUI or database mutation occurred.
|
||||
|
||||
An [independent ELF reader](command-observation-oracle.py) maps dynamic symbols
|
||||
to exact file ranges: `execute_command`, 239 bytes at `0x4ba50`, and
|
||||
`execute_command_internal`, 15,428 bytes at `0x47e00`. The
|
||||
[verifier](command-observation-verify.py) checks every disassembled byte and its
|
||||
contiguous address against those ranges. GDB reports 66/3,365 instructions;
|
||||
objdump byte lines also include continuations, so their count is not an
|
||||
instruction count. Ordered summary facts match each exported complete parent.
|
||||
GDB Evidence has a null subject; the external oracle does not manufacture a
|
||||
target binding. Actual CLI default objdump Evidence matches the baseline exactly,
|
||||
including identity; symbol/pathless summaries preserve it in bundles. CLI checks
|
||||
also cover rejected overwrite, missing destination before dispatch, and a real
|
||||
GDB command with a caller-owned PTY that remains available after debugger exit.
|
||||
|
||||
## Whole tasks and resource costs
|
||||
|
||||
The [SDK driver](command-observation-queries.mjs) discovers all 210 tools, reads
|
||||
architecture and symbol-scoped disassembly, closes GDB where applicable, exports
|
||||
complete Evidence and finalizes owned resources. GDB uses 8 calls; objdump uses 3.
|
||||
Warm cases repeat disassembly in the same process, adding one call. Fresh-process
|
||||
cases have **unflushed OS caches**, not disk-cold inputs. Native commands are
|
||||
rerun; results are not reused as current live state.
|
||||
|
||||
The [actual Codex 0.163 app-server driver](command-observation-wire.py) uses a
|
||||
loopback deterministic SSE fixture, no AI inference or paid service. All 210
|
||||
function schemas are present in every normal model-facing request. Matched tasks
|
||||
use full input/output profiles; only disassembly selects summary. The
|
||||
[counter](command-observation-costs.py) counts compact JSON serialization with
|
||||
`tiktoken 0.12.0 / cl100k_base` across **every** request, including schemas,
|
||||
replayed results and the actual client's local compaction request. The fixture
|
||||
answers compaction with observed identifiers and the retained-body location.
|
||||
These are serialization-token proxies, not model rendering, acceptance, answer
|
||||
quality, actual usage or billing. Compaction behavior is qualified only for this
|
||||
client/configuration; no inference quality is established.
|
||||
|
||||
| Completed GDB task | JSON token proxy before → after | Requests | Wall seconds | CPU seconds | Family peak MiB |
|
||||
| ------------------------------ | ------------------------------: | -------: | ------------: | ------------: | --------------: |
|
||||
| Small, 30,000 per-tool budget | 605,133 → 580,070 | 9 → 9 | 7.906 → 8.618 | 7.195 → 7.832 | 788.9 → 785.9 |
|
||||
| Large, 350,000 per-tool budget | 1,269,405 → 1,091,290 | 10 → 9 | 9.551 → 9.127 | 8.832 → 8.370 | 805.2 → 770.8 |
|
||||
|
||||
Whole-task context proxies decrease **4.14% / 14.03%**, rather than the much
|
||||
larger reply-byte reduction. The large baseline makes one compaction request;
|
||||
the candidate makes none. The small task is slower. These single paired samples
|
||||
do not establish a general speedup. With the client's default output budget,
|
||||
the small baseline delivers truncated invalid inner JSON and does **not**
|
||||
complete the question; summary completes it. That incomplete baseline is
|
||||
excluded from the percentage comparison.
|
||||
|
||||
| SDK task | Reply bytes before → after | Whole wall seconds | CPU seconds | Family peak MiB |
|
||||
| ------------- | -------------------------: | -----------------: | ------------: | --------------: |
|
||||
| GDB small | 54,123 → 18,009 | 6.631 → 5.447 | 6.102 → 5.240 | 536.3 → 535.8 |
|
||||
| GDB large | 2,763,983 → 700,453 | 6.965 → 6.660 | 6.729 → 6.451 | 595.0 → 545.8 |
|
||||
| Objdump small | 22,467 → 15,990 | 7.599 → 7.699 | 7.110 → 7.264 | 527.8 → 532.5 |
|
||||
| Objdump large | 964,889 → 487,219 | 7.737 → 7.499 | 7.334 → 6.935 | 537.0 → 536.6 |
|
||||
|
||||
Same-process repeat disassembly takes 1,100 → 1,016 ms for GDB and
|
||||
1,070 → 939 ms for objdump in these observations. Whole warm-task CPU improves
|
||||
7.613 → 7.162 s for GDB but increases 7.819 → 7.859 s for objdump. Timings include
|
||||
existing ownership checks; no check was removed or cached.
|
||||
|
||||
Retaining a summary also retains an additional derived record: large GDB bundles
|
||||
grow 1,310,073 → 1,640,022 bytes; objdump 474,288 → 712,656. This trades retained
|
||||
storage for smaller delivery; long-session heap growth is unmeasured. Retained-data lifetime follows the existing investigation ledger and cleanup
|
||||
owners. Output still scales
|
||||
with the requested complete disassembly; summary is not a fixed-size preview.
|
||||
|
||||
Each experiment runs in one owned systemd scope, one CPU, no swap, 4 GiB hard /
|
||||
3 GiB early memory thresholds and host-memory admission/stop thresholds. Recorded
|
||||
payload exit and cleanup are verified, with zero OOM/resource events. Final
|
||||
whole-scope counters disappear after teardown and remain explicitly unverified.
|
||||
The receipt includes CPU time, wall time, peak family memory, warm cases and
|
||||
bundle growth. Build/check scopes use separate bounded budgets. Host-memory
|
||||
check stops and initial fixture failures are preserved locally and are not passes.
|
||||
|
||||
## Coverage and reproduction
|
||||
|
||||
320 focused tests cover the new SDK schemas/views plus existing GDB queue,
|
||||
malformed output, cancellation, process exit, target identity and cleanup cases,
|
||||
objdump symbol selection and command admission. Real provider evidence is from
|
||||
the CLI/SDK/client runs above, not simulated-provider tests.
|
||||
|
||||
Run the oracle for the selected Bash image, then use the SDK driver with
|
||||
`--runtime`, `--input`, `--oracle`, `--output`, `--provider gdb|objdump`,
|
||||
`--symbol`, `--detail complete|summary` and optional `--warm`. The wire driver
|
||||
also takes the fixed Codex/Node paths, `--mode`, `--symbol` and `--budget`; it always performs the
|
||||
export (eight tool calls). Run each through `resource-run.py --observe-payload-exit`, preserving
|
||||
its sibling `.log`; the verifier and counter accept `--root` for those results.
|
||||
|
||||
Default Rizin execution returned ENOENT with no configured alternative. Cutter
|
||||
reported no live bridge; no configured selector or default bridge directory was
|
||||
present. Real Rizin/Cutter performance and GUI workflows remain unverified.
|
||||
Their command semantics were not changed. Existing objdump native symbol
|
||||
selection was reused. No claim covers arbitrary GDB target execution or another
|
||||
host client's output budget.
|
||||
|
||||
Review follow-up adds nine CLI publication regressions (16 CLI tests including the
|
||||
existing cleanup cases), three deterministic delivery-oracle tests, and a focused
|
||||
strict typecheck of the new CLI boundary and its transitive source. Real client
|
||||
small/default and large/generous summary runs pass the strengthened oracle and
|
||||
export complete parents; only the explicit complete/default baseline may finish
|
||||
with an incomplete answer. The unused export switch was removed: export is
|
||||
mandatory in this question. Product source and the original paired measurements
|
||||
are unchanged; the receipt records these additional validation runs separately.
|
||||
@@ -0,0 +1,301 @@
|
||||
"""Actual Codex wire capture with a deterministic local SSE backend; no AI inference."""
|
||||
import argparse
|
||||
import http.server
|
||||
import threading
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
import traceback
|
||||
import subprocess
|
||||
from command_observation_delivery import verify_command_delivery
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--runtime", type=Path, required=True)
|
||||
parser.add_argument("--codex", type=Path, required=True)
|
||||
parser.add_argument("--node", type=Path, required=True)
|
||||
parser.add_argument("--input-profile", choices=["full", "compact"], default="full")
|
||||
parser.add_argument("--output-profile", choices=["full", "off"], default="full")
|
||||
parser.add_argument("--input", type=Path, required=True)
|
||||
parser.add_argument("--oracle", type=Path, required=True)
|
||||
parser.add_argument("--expected-tools", type=int, default=210)
|
||||
parser.add_argument("--mode", choices=["complete", "summary"], required=True)
|
||||
parser.add_argument("--question", choices=["disassembly"], default="disassembly")
|
||||
parser.add_argument("--symbol", choices=["execute_command", "execute_command_internal"], required=True)
|
||||
parser.add_argument("--budget", type=int, default=30000)
|
||||
args = parser.parse_args()
|
||||
oracle=json.loads(args.oracle.read_text())
|
||||
target=str(args.input)
|
||||
assert args.input.is_absolute() and args.oracle.is_absolute() and args.output.is_absolute()
|
||||
assert hashlib.sha256(args.input.read_bytes()).hexdigest()==oracle["artifact_sha256"]
|
||||
def delivered(pieces):
|
||||
assert isinstance(pieces,str) and pieces.startswith("Wall time: "), pieces[:300]
|
||||
_,sep,payload=pieces.partition("Output:\n");assert sep
|
||||
try: value=json.loads(payload)
|
||||
except json.JSONDecodeError: return {"_invalid_json_payload":payload}
|
||||
return value["result"] if "result" in value and "normalized_result" not in value else value
|
||||
def outputs_of(body):
|
||||
return {item["call_id"]:delivered(item["output"]) for item in body.get("input",[]) if item.get("type")=="function_call_output"}
|
||||
steps=8
|
||||
args.output.mkdir(mode=0o700)
|
||||
audit_home = args.output / "account"
|
||||
codex_state = audit_home / ".codex"
|
||||
mcp_home = audit_home / "mcp"
|
||||
workspace = audit_home / "workspace"
|
||||
for directory in [codex_state, mcp_home, workspace]:
|
||||
directory.mkdir(parents=True)
|
||||
captured = []
|
||||
known_outputs = {}
|
||||
state = {"normal_requests":0,"compactions":0}
|
||||
class Capture(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
def do_POST(self):
|
||||
size = int(self.headers["Content-Length"])
|
||||
assert 0 < size <= 16 * 1024 * 1024
|
||||
assert self.headers.get("Content-Encoding", "identity") == "identity"
|
||||
body = json.loads(self.rfile.read(size))
|
||||
captured.append({"path": self.path, "body": body})
|
||||
(args.output / "model-requests.json").write_text(json.dumps(captured) + "\n")
|
||||
http_index = len(captured)
|
||||
known_outputs.update(outputs_of(body))
|
||||
names = [t.get("name", "") for ns in body.get("tools", []) if ns.get("type") == "namespace" and ns.get("name") == "mcp__rea_audit" for t in ns.get("tools", [])]
|
||||
if not names:
|
||||
last=body.get('input',[])[-1]
|
||||
assert 'CONTEXT CHECKPOINT COMPACTION' in json.dumps(last)
|
||||
state['compactions']+=1;assert state['compactions']<=1
|
||||
evidence=known_outputs['call_local_6'];assert 'normalized_result' in evidence
|
||||
captured_result=evidence['normalized_result'];command_report=captured_result.get('summary',captured_result)
|
||||
checkpoint={'fixture':'deterministic protocol checkpoint; no AI inference or model-quality claim','task':'Full native Bash function disassembly has been delivered to this compaction request. Retain complete Evidence and finish owned cleanup.','path':target,'artifact_sha256_external_oracle':oracle['artifact_sha256'],'symbol':args.symbol,'session_id':known_outputs['call_local_1']['session_id'],'parent_evidence_id':captured_result.get('parent_evidence_id',evidence['evidence_id']),'subject':evidence['subject'],'completion_status':command_report['completion_status'],'output_truncated':command_report['output_truncated'],'native_console_utf16_units':command_report.get('available_counts',{}).get('console_utf16_units',len(command_report.get('console','').encode('utf-16-le'))//2),'full_body_location':'same-session retained Evidence; export next to '+str(args.output/'evidence.json'),'remaining':['close_gdb_session','export_evidence_bundle']}
|
||||
text=json.dumps(checkpoint)
|
||||
item={"type":"message","id":f"msg_checkpoint_{state['compactions']}","role":"assistant","status":"completed","content":[{"type":"output_text","text":text,"annotations":[]}]}
|
||||
captured[-1]['kind']='deterministic-compaction'
|
||||
else:
|
||||
state['normal_requests']+=1;index=state['normal_requests'];assert index<=steps+1
|
||||
captured[-1]['kind']='tool-planning-or-final'
|
||||
if index<=steps:
|
||||
if index==1:
|
||||
operation="start_gdb_session";arguments={}
|
||||
else:
|
||||
session=known_outputs["call_local_1"]["session_id"]
|
||||
if index<=6:
|
||||
command=['set auto-load off','set debuginfod enabled off',f'file {target}','show architecture',f'disassemble /r {args.symbol}'][index-2]
|
||||
operation="gdb_console";arguments={"session_id":session,"command":command}
|
||||
if index==6 and args.mode=="summary":arguments["detail"]="summary"
|
||||
elif index==7:
|
||||
operation="close_gdb_session";arguments={"session_id":session}
|
||||
else:
|
||||
operation="export_evidence_bundle";arguments={"path":str(args.output/'evidence.json')}
|
||||
selected=[name for name in names if name.endswith(operation)];assert len(selected)==1
|
||||
item={"type":"function_call","id":f"fc_local_{index}","call_id":f"call_local_{index}","name":selected[0],"namespace":"mcp__rea_audit","arguments":json.dumps(arguments),"status":"completed"}
|
||||
else:
|
||||
item={"type":"message","id":"msg_local_done","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Scripted local GDB disassembly task complete.","annotations":[]}]}
|
||||
response_id=f"resp_local_{http_index}"
|
||||
base = {"id": response_id, "object": "response", "model": body.get("model"), "status": "completed", "output": [item]}
|
||||
events = [
|
||||
{"type": "response.created", "response": {**base, "status": "in_progress", "output": []}},
|
||||
{"type": "response.output_item.added", "output_index": 0, "item": {**item, "status": "in_progress"}},
|
||||
{"type": "response.output_item.done", "output_index": 0, "item": item},
|
||||
{"type": "response.completed", "response": base},
|
||||
]
|
||||
wire = "".join("event: " + e["type"] + "\ndata: " + json.dumps(e) + "\n\n" for e in events).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Content-Length", str(len(wire)))
|
||||
self.end_headers()
|
||||
self.wfile.write(wire)
|
||||
endpoint = http.server.HTTPServer(("127.0.0.1", 0), Capture)
|
||||
endpoint_thread = threading.Thread(target=endpoint.serve_forever, daemon=True)
|
||||
endpoint_thread.start()
|
||||
task_path = f"{args.node.parent}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
mcp_env = {"HOME": str(mcp_home), "PATH": task_path,
|
||||
"NODE_OPTIONS": "--max-old-space-size=2048", "UV_THREADPOOL_SIZE": "1",
|
||||
"REA_LOG_LEVEL": "silent", "REA_MCP_INPUT_SCHEMA_PROFILE": args.input_profile,
|
||||
"REA_MCP_OUTPUT_SCHEMA_PROFILE": args.output_profile}
|
||||
config = 'model_provider = "rea_capture"\nmodel = "rea-wire-probe"\n[analytics]\nenabled = false\n[mcp_servers.rea_audit]\n'
|
||||
config += f'command = {json.dumps(str(args.node))}\n'
|
||||
config += f'args = {json.dumps([str(args.runtime / "scripts/rea.mjs"), "mcp"])}\n'
|
||||
config += 'startup_timeout_sec = 60\ntool_timeout_sec = 60\n'
|
||||
config += '[mcp_servers.rea_audit.env]\n'
|
||||
config += ''.join(f'{key} = {json.dumps(value)}\n' for key, value in mcp_env.items())
|
||||
for name in ['start_gdb_session','gdb_console','close_gdb_session','export_evidence_bundle']:
|
||||
config += f'[mcp_servers.rea_audit.tools.{name}]\napproval_mode = "approve"\n'
|
||||
if args.budget: config += f'output_token_limit = {args.budget}\n'
|
||||
config += f'\n[model_providers.rea_capture]\nname = "Local scripted capture"\nbase_url = "http://127.0.0.1:{endpoint.server_port}/v1"\nwire_api = "responses"\nrequires_openai_auth = false\nsupports_websockets = false\nrequest_max_retries = 0\nstream_max_retries = 0\n'
|
||||
(codex_state / "config.toml").write_text(config)
|
||||
(args.output / "declared-config.toml").write_text(config)
|
||||
environment = {"HOME": str(audit_home), "CODEX_HOME": str(codex_state), "PATH": task_path,
|
||||
"NODE_OPTIONS": "--max-old-space-size=2048", "UV_THREADPOOL_SIZE": "1"}
|
||||
report = {"question": "Return architecture and complete byte-annotated disassembly for one selected native Bash function and export complete Evidence. No target execution.",
|
||||
"client": "Codex app-server 0.163.0", "model_turns_started": 1, "backend": "local deterministic SSE fixture; no AI model or paid service",
|
||||
"actual_model_usage": None, "input_profile": args.input_profile,
|
||||
"output_profile": args.output_profile, "calls": [], "observations": [],
|
||||
"unexpected_server_requests": [], "cleanup_failures": []}
|
||||
client_version = subprocess.check_output([str(args.codex), "--version"], env=environment, cwd=workspace, text=True, timeout=10).strip()
|
||||
assert client_version == "codex-cli 0.163.0", client_version
|
||||
transcript = []
|
||||
pending = {}
|
||||
completed_turn = asyncio.Event()
|
||||
next_id = 0
|
||||
stderr = (args.output / "server.log").open("wb")
|
||||
process = await asyncio.create_subprocess_exec(str(args.codex), "app-server", "--stdio",
|
||||
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=stderr,
|
||||
cwd=workspace, env=environment, limit=16 * 1024 * 1024)
|
||||
|
||||
async def send(message):
|
||||
encoded = (json.dumps(message, separators=(",", ":")) + "\n").encode()
|
||||
transcript.append({"direction": "client", "bytes": len(encoded), "message": message})
|
||||
process.stdin.write(encoded)
|
||||
await process.stdin.drain()
|
||||
|
||||
async def read_messages():
|
||||
try:
|
||||
while line := await process.stdout.readline():
|
||||
value = json.loads(line)
|
||||
transcript.append({"direction": "server", "bytes": len(line), "message": value})
|
||||
if value.get("method") == "turn/completed":
|
||||
report["turn"] = value["params"]["turn"]
|
||||
completed_turn.set()
|
||||
if "id" in value and "method" not in value:
|
||||
future = pending.pop(value["id"], None)
|
||||
if future and not future.done():
|
||||
future.set_result(value)
|
||||
elif "id" in value:
|
||||
report["unexpected_server_requests"].append(value["method"])
|
||||
await send({"id": value["id"], "error": {"code": -32601, "message": "Unexpected request in scripted metadata probe"}})
|
||||
raise RuntimeError("app-server stdout closed")
|
||||
except Exception as error:
|
||||
for future in pending.values():
|
||||
if not future.done():
|
||||
future.set_exception(error)
|
||||
|
||||
reader = asyncio.create_task(read_messages())
|
||||
|
||||
async def request(method, params):
|
||||
nonlocal next_id
|
||||
assert method in ["initialize", "thread/start", "mcpServerStatus/list", "mcpServer/tool/call", "turn/start"]
|
||||
next_id += 1
|
||||
message = {"id": next_id, "method": method, "params": params}
|
||||
future = asyncio.get_running_loop().create_future()
|
||||
pending[next_id] = future
|
||||
start = time.monotonic()
|
||||
await send(message)
|
||||
reply = await asyncio.wait_for(future, 90)
|
||||
report["calls"].append({"method": method, "elapsed_ms": (time.monotonic() - start) * 1000,
|
||||
"error": reply.get("error")})
|
||||
if "error" in reply:
|
||||
raise RuntimeError(json.dumps(reply["error"])[:4000])
|
||||
return reply["result"]
|
||||
|
||||
try:
|
||||
await request("initialize", {"clientInfo": {"name": "rea_host_probe", "title": "REA host compatibility probe", "version": "1"},
|
||||
"capabilities": {"experimentalApi": True, "explicitGatewayOauth": True}})
|
||||
await send({"method": "initialized"})
|
||||
created = await request("thread/start", {"cwd": str(workspace), "ephemeral": True,
|
||||
"approvalPolicy": "never", "sandbox": "read-only"})
|
||||
thread_id = created["thread"]["id"]
|
||||
assert not created["thread"].get("turns", [])
|
||||
inventory = await request("mcpServerStatus/list", {"threadId": thread_id,
|
||||
"serverName": "rea_audit", "detail": "toolsAndAuthOnly", "limit": 10})
|
||||
assert inventory.get("nextCursor") is None
|
||||
assert len(inventory["data"]) == 1
|
||||
server = inventory["data"][0]
|
||||
assert server["name"] == "rea_audit" and server.get("toolsError") is None
|
||||
tools = list(server["tools"].values())
|
||||
names = [tool["name"] for tool in tools]
|
||||
assert len(names) == len(set(names)) == args.expected_tools
|
||||
assert "gdb_console" in names
|
||||
output_schemas = sum(tool.get("outputSchema") is not None for tool in tools)
|
||||
assert output_schemas == (args.expected_tools if args.output_profile == "full" else 0)
|
||||
raw = json.dumps(inventory, separators=(",", ":"), ensure_ascii=False).encode()
|
||||
(args.output / "catalog.json").write_bytes(raw)
|
||||
report["catalog"] = {"tools": len(tools), "output_schemas": output_schemas,
|
||||
"bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest(),
|
||||
"server_info": server.get("serverInfo"), "runtime_status": server.get("runtimeStatus")}
|
||||
await request("turn/start", {"threadId":thread_id,"input":[{"type":"text","text":f"Read architecture and the complete byte-annotated disassembly of {args.symbol} in the selected Bash executable. Retain and export complete Evidence, then close the owned debugger. The local fixture scripts the required tools; no AI inference or target execution is used."}]})
|
||||
await asyncio.wait_for(completed_turn.wait(), 90)
|
||||
assert report["turn"]["status"] == "completed", report["turn"]
|
||||
assert state["normal_requests"]==steps+1
|
||||
assert len(captured)==steps+1+state["compactions"]
|
||||
outputs=known_outputs
|
||||
assert len(outputs)==steps
|
||||
bundle=json.loads((args.output/'evidence.json').read_text())
|
||||
evidence=next(e for e in bundle['records'] if e['operation']=='gdb_console' and e['parameters']['command']==f'disassemble /r {args.symbol}')
|
||||
assert evidence['subject'] is None
|
||||
assert evidence['normalized_result']['completion_status']=='complete' and not evidence['normalized_result']['output_truncated']
|
||||
delivered_result=outputs['call_local_6']
|
||||
delivery_complete=verify_command_delivery(delivered_result,evidence,args.mode,args.budget)
|
||||
if delivery_complete and args.mode=='summary':
|
||||
assert next(e for e in bundle['records'] if e['evidence_id']==delivered_result['evidence_id'])==delivered_result
|
||||
report['task_completed']=delivery_complete
|
||||
report['observations']={'parent_evidence_id':evidence['evidence_id'],'exported':True,'native_console_utf16_units':len(evidence['normalized_result']['console'].encode('utf-16-le'))//2,'client_delivered_full_evidence':delivery_complete,'selected_symbol':args.symbol,'tool_output_budget':args.budget or 'client default','subject':evidence['subject']}
|
||||
report["captured_requests"]=len(captured)
|
||||
report["compaction_requests"]=state["compactions"]
|
||||
report["compaction_backend"]="Deterministic checkpoint of observed identifiers and retained-body location; no AI inference or quality claim."
|
||||
report["mode"]=args.mode
|
||||
report["request_tool_counts"] = [len(c["body"].get("tools", [])) for c in captured]
|
||||
assert not report["unexpected_server_requests"]
|
||||
assert hashlib.sha256(args.input.read_bytes()).hexdigest()==oracle["artifact_sha256"]
|
||||
report["status"] = "passed" if report["task_completed"] else "expected-incomplete-task"
|
||||
except Exception:
|
||||
report["status"] = "failed"
|
||||
report["failure"] = traceback.format_exc()[-6000:]
|
||||
finally:
|
||||
process.stdin.close()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), 15)
|
||||
except asyncio.TimeoutError:
|
||||
report["cleanup_failures"].append("app-server did not exit after stdin close; terminated owned process")
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), 5)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
await reader
|
||||
# app-server can acknowledge exit before its MCP child finishes EOF
|
||||
# cleanup. Wait on the allocated resource scope; never target a PID.
|
||||
try:
|
||||
groups = [line[3:] for line in Path("/proc/self/cgroup").read_text().splitlines()
|
||||
if line.startswith("0::")]
|
||||
assert len(groups) == 1
|
||||
group = Path("/sys/fs/cgroup") / groups[0].lstrip("/")
|
||||
assert re.fullmatch(r"rea-efficiency-[0-9a-f]{32}\.scope", group.name)
|
||||
observer = os.getppid()
|
||||
observer_args = Path(f"/proc/{observer}/cmdline").read_bytes().split(b"\0")
|
||||
assert any(Path(os.fsdecode(value)).name == "resource_payload.py" for value in observer_args if value)
|
||||
expected = {os.getpid(), observer}
|
||||
until = time.monotonic() + 10
|
||||
while True:
|
||||
actors = {int(value) for value in (group / "cgroup.procs").read_text().splitlines()}
|
||||
assert expected <= actors
|
||||
remaining = sorted(actors - expected)
|
||||
if not remaining or time.monotonic() >= until:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
report["owned_scope_drain"] = {"remaining_processes": remaining}
|
||||
if remaining:
|
||||
report["cleanup_failures"].append("owned MCP child did not settle after app-server exit")
|
||||
except (OSError, ValueError, AssertionError) as error:
|
||||
report["owned_scope_drain"] = {"status": "unavailable", "reason": type(error).__name__}
|
||||
report["cleanup_failures"].append("allocated resource-scope cleanup could not be verified")
|
||||
endpoint.shutdown()
|
||||
endpoint.server_close()
|
||||
endpoint_thread.join(5)
|
||||
stderr.close()
|
||||
report["appserver_exit_code"] = process.returncode
|
||||
if process.returncode != 0:
|
||||
report["cleanup_failures"].append(f"app-server exit code: {process.returncode}")
|
||||
(args.output / "transcript.json").write_text(json.dumps(transcript) + "\n")
|
||||
(args.output / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
print(json.dumps({key: report.get(key) for key in ["status", "failure", "catalog", "observations", "cleanup_failures", "appserver_exit_code"]}))
|
||||
return 0 if report["status"] in ["passed","expected-incomplete-task"] and not report["cleanup_failures"] else 1
|
||||
|
||||
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Acceptance of the captured GDB answer, separate from native execution success."""
|
||||
|
||||
|
||||
def verify_command_delivery(delivered, parent, mode, budget):
|
||||
assert mode in ('complete', 'summary')
|
||||
if '_invalid_json_payload' in delivered:
|
||||
assert mode == 'complete' and budget == 0, (
|
||||
'Incomplete delivery is allowed only for the '
|
||||
'complete/default-budget baseline'
|
||||
)
|
||||
return False
|
||||
if mode == 'complete':
|
||||
assert delivered == parent
|
||||
return True
|
||||
|
||||
selected = delivered['normalized_result']
|
||||
assert selected['parent_evidence_id'] == parent['evidence_id']
|
||||
assert selected['artifact'] is None
|
||||
profile = parent['analysis_profile']
|
||||
expected_profile = None if profile is None else profile['digest']
|
||||
assert selected['source_analysis_profile_digest'] == expected_profile
|
||||
assert selected['source_parameters'] == parent['parameters']
|
||||
assert selected['source_provider'] == parent['provider']
|
||||
assert delivered['raw_result'] is None and delivered['subject'] is None
|
||||
expected = {
|
||||
key: value for key, value in parent['normalized_result'].items()
|
||||
if key not in ('console', 'target', 'log')
|
||||
}
|
||||
actual = {
|
||||
key: value for key, value in selected['summary'].items()
|
||||
if key != 'available_counts'
|
||||
}
|
||||
assert actual == expected
|
||||
counts = {'records': len(expected['records'])}
|
||||
for name in ('console', 'target', 'log'):
|
||||
text = parent['normalized_result'][name]
|
||||
counts[name + '_utf16_units'] = len(text.encode('utf-16-le')) // 2
|
||||
assert selected['summary']['available_counts'] == counts
|
||||
return True
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Count complete actual-client requests, not only smaller tool-reply bytes."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tiktoken
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--lab', type=Path, required=True)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
enc = tiktoken.get_encoding('cl100k_base')
|
||||
|
||||
|
||||
def tokens(value):
|
||||
return len(enc.encode(json.dumps(value, ensure_ascii=False, separators=(',', ':'), allow_nan=False), disallowed_special=()))
|
||||
|
||||
|
||||
def resources(root):
|
||||
receipt = json.loads(Path(str(root) + '.log').read_text().splitlines()[-1])
|
||||
payload = receipt['payload_resource_observation']
|
||||
assert receipt['abort'] is None and receipt['payload_resource_observation_verified']
|
||||
assert payload['payload_exit_code'] == 0 and payload['payload_cleanup_verified']
|
||||
for events in payload['resource_events'].values():
|
||||
assert not any(events.values())
|
||||
return {'wall_seconds': receipt['elapsed_seconds'], 'cpu_seconds': payload['cpu_stat']['usage_usec'] / 1e6,
|
||||
'family_peak_mib': payload['memory_peak_bytes'] / 1048576,
|
||||
'payload_cleanup_verified': True, 'final_whole_scope_counters_verified': receipt['resource_counters_verified']}
|
||||
|
||||
|
||||
wire, sdk = [], []
|
||||
for root in sorted(args.lab.glob('wire-v2-*')):
|
||||
if not root.is_dir():
|
||||
continue
|
||||
report = json.loads((root / 'report.json').read_text())
|
||||
assert report['status'] in ['passed', 'expected-incomplete-task'] and not report['cleanup_failures']
|
||||
assert report['appserver_exit_code'] == 0 and report['owned_scope_drain']['remaining_processes'] == []
|
||||
requests = json.loads((root / 'model-requests.json').read_text())
|
||||
details = []
|
||||
for captured in requests:
|
||||
body = captured['body']
|
||||
namespace = [n for n in body.get('tools', []) if n.get('type') == 'namespace' and n.get('name') == 'mcp__rea_audit']
|
||||
assert len(namespace) == 1 and len(namespace[0]['tools']) == 211, 'Unexpected compaction/catalog: audit before comparing'
|
||||
details.append({'whole_json_tokens_proxy': tokens(body), 'rea_namespace_tokens_proxy': tokens(namespace[0]), 'rea_functions': 211})
|
||||
wire.append({'case': root.name, 'task_completed': report['task_completed'], 'mode': report['mode'],
|
||||
'budget': report['observations']['tool_output_budget'], 'requests': len(details), 'compactions': report['compaction_requests'],
|
||||
'whole_json_tokens_proxy': sum(r['whole_json_tokens_proxy'] for r in details), 'request_details': details, **resources(root)})
|
||||
for root in sorted(args.lab.glob('sdk-*')):
|
||||
if not root.is_dir():
|
||||
continue
|
||||
report = json.loads((root / 'report.json').read_text())
|
||||
assert report['status'] == 'passed' and not report['cleanup_failures'] and not report['receipt_failures']
|
||||
transcript = json.loads((root / 'transcript.json').read_text())
|
||||
catalog = json.loads((root / 'catalog.json').read_text())
|
||||
assert len(catalog['tools']) == 211 and report['original_target_exited']
|
||||
sdk.append({'case': root.name, 'detail': report['detail'], 'capture': report['capture'], 'tool_calls': len(report['calls']),
|
||||
'exported_bundle_bytes': report['exported_bundle_bytes'], 'answer': report['answer'],
|
||||
'status_calls': [c for c in report['calls'] if c['name'] == 'frida_session_status'],
|
||||
'one_catalog_plus_all_sdk_calls_tokens_proxy': tokens(catalog) + sum(tokens({'name': t['name'], 'input': t['input'], 'reply': t['reply']}) for t in transcript), **resources(root)})
|
||||
assert len(sdk) == len(wire) == 4, 'A partial cohort cannot qualify a comparison'
|
||||
for capture in ['small', 'libc']:
|
||||
pair = [r for r in sdk if r['capture'] == capture]
|
||||
assert len(pair) == 2 and {r['detail'] for r in pair} == {'complete', 'summary'}
|
||||
assert {k: v for k, v in pair[0]['answer'].items() if k != 'pid'} == {k: v for k, v in pair[1]['answer'].items() if k != 'pid'}
|
||||
reports = [json.loads((args.lab / r['case'] / 'report.json').read_text()) for r in pair]
|
||||
assert reports[0]['load']['source_sha256'] == reports[1]['load']['source_sha256']
|
||||
completed = [r for r in wire if r['budget'] == 300000]
|
||||
assert len(completed) == 2 and all(r['task_completed'] for r in completed)
|
||||
answers = [json.loads((args.lab / r['case'] / 'report.json').read_text())['observations']['answers'] for r in completed]
|
||||
comparable_answers = [[{k: v for k, v in a.items() if k != 'parent_evidence_id'} for a in run] for run in answers]
|
||||
assert comparable_answers[0] == comparable_answers[1]
|
||||
result = {
|
||||
'scope': 'Real local Frida binding with controlled sleep target and caller-authored libc export inventory. Actual Codex0.163 app-server uses a deterministic local SSE fixture: no AI inference, model usage, quality or billing claim. Whole compact JSON token estimates include all 211 schemas and every request replay. Incomplete default baseline is excluded from completed-task gains. SDK exchange estimates are not model-input measurements. Fresh processes have unflushed OS caches; both status reads query live session observations. Timings include the eight-second target lifetime and cleanup; single pairs establish no general speedup. Payload-exit cgroup counters are verified; final whole-scope counters may be unavailable.',
|
||||
'tokenizer': {'package': tiktoken.__version__, 'encoding': enc.name},
|
||||
'runtime_pins': {v: {k: x for k, x in json.loads((args.lab / (v + '-manifest.json')).read_text()).items() if k != 'files'} for v in ['baseline', 'candidate']},
|
||||
'native': json.loads((args.lab / 'native-prerequisite-manifest.json').read_text()),
|
||||
'script_pins': {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in Path(__file__).parent.glob('frida*') if p.suffix in ['.py', '.mjs']},
|
||||
'wire': wire, 'sdk': sdk,
|
||||
}
|
||||
args.output.write_text(json.dumps(result, indent=2) + '\n')
|
||||
print(json.dumps({'wire': [{k: v for k, v in r.items() if k != 'request_details'} for r in wire], 'sdk': [{k: v for k, v in r.items() if k not in ['status_calls', 'answer']} for r in sdk]}, indent=2))
|
||||
@@ -0,0 +1,267 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { finished } from "node:stream/promises";
|
||||
import { parseArgs } from "node:util";
|
||||
import { createRetainedQueryCaller } from "./retained-query-calls.mjs";
|
||||
import { finalizeRetainedQueryReceipt } from "./retained-query-finalization.mjs";
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
runtime: { type: "string" },
|
||||
output: { type: "string" },
|
||||
detail: { type: "string", default: "complete" },
|
||||
capture: { type: "string", default: "libc" },
|
||||
},
|
||||
});
|
||||
const { runtime, output, detail, capture } = values;
|
||||
assert.ok(runtime && output);
|
||||
assert.ok(["complete", "summary"].includes(detail));
|
||||
assert.ok(["libc", "small"].includes(capture));
|
||||
const require = createRequire(join(runtime, "package.json"));
|
||||
const { Client } = await import(
|
||||
pathToFileURL(require.resolve("@modelcontextprotocol/client"))
|
||||
);
|
||||
const { StdioClientTransport } = await import(
|
||||
pathToFileURL(require.resolve("@modelcontextprotocol/client/stdio"))
|
||||
);
|
||||
const { parseLinuxProcessStat } = await import(
|
||||
pathToFileURL(join(runtime, "dist/process/LinuxProcessStat.js"))
|
||||
);
|
||||
await mkdir(output, { mode: 0o700 });
|
||||
const account = await mkdtemp(join(tmpdir(), "rea-frida-real-"));
|
||||
const serverLog = createWriteStream(join(output, "server.log"));
|
||||
const serverLogFinished = finished(serverLog);
|
||||
serverLogFinished.catch(() => {});
|
||||
const client = new Client({ name: "rea-frida-audit", version: "1" });
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [join(runtime, "scripts/rea.mjs"), "mcp"],
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HOME: account,
|
||||
NODE_OPTIONS: "--max-old-space-size=2048",
|
||||
UV_THREADPOOL_SIZE: "1",
|
||||
REA_LOG_LEVEL: "silent",
|
||||
},
|
||||
stderr: "pipe",
|
||||
});
|
||||
transport.stderr?.pipe(serverLog, { end: false });
|
||||
const report = {
|
||||
question:
|
||||
"Retain a complete caller-requested libc export inventory, then check only session identity/state and message capture coverage twice, preserving complete Evidence.",
|
||||
detail,
|
||||
capture,
|
||||
calls: [],
|
||||
status: "pending",
|
||||
};
|
||||
const transcript = [];
|
||||
const caller = createRetainedQueryCaller(client, (receipt, event) => {
|
||||
report.calls.push(receipt);
|
||||
transcript.push(event);
|
||||
});
|
||||
const call = async (name, input) => {
|
||||
const r = await caller(name, input, name);
|
||||
assert.ok(!r.isError, JSON.stringify(r));
|
||||
return r.structuredContent;
|
||||
};
|
||||
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
let session;
|
||||
let birth;
|
||||
const stat = async () => {
|
||||
try {
|
||||
return parseLinuxProcessStat(
|
||||
await readFile(`/proc/${session.pid}/stat`, "utf8"),
|
||||
);
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT") return null;
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const catalog = await client.listTools();
|
||||
await writeFile(join(output, "catalog.json"), JSON.stringify(catalog));
|
||||
report.catalog_tools = catalog.tools.length;
|
||||
const defs = new Map(catalog.tools.map((t) => [t.name, t]));
|
||||
const orig = client.callTool.bind(client);
|
||||
client.callTool = (input, options) =>
|
||||
orig(input, {
|
||||
...options,
|
||||
timeout: 30000,
|
||||
toolDefinition: defs.get(input.name),
|
||||
});
|
||||
report.devices = await call("list_frida_devices", {});
|
||||
session = await call("start_frida_session", {
|
||||
mode: "spawn",
|
||||
device_id: "local",
|
||||
program: "/usr/bin/sleep",
|
||||
argv: ["8"],
|
||||
});
|
||||
report.session = session;
|
||||
birth = await stat();
|
||||
assert.ok(birth);
|
||||
report.target_birth = birth;
|
||||
report.same_owned_cgroup =
|
||||
(await readFile(`/proc/${session.pid}/cgroup`, "utf8")) ===
|
||||
(await readFile("/proc/self/cgroup", "utf8"));
|
||||
assert.ok(report.same_owned_cgroup);
|
||||
const source = `send({kind:'ready',pid:Process.id});
|
||||
setTimeout(() => {
|
||||
if (${JSON.stringify(capture)} === 'small') { send({kind:'complete',total:0}); return; }
|
||||
const module = Process.enumerateModules().find(m => /(^|\\/)libc\\.so\\.6$/.test(m.path));
|
||||
if (!module) { send({kind:'missing-libc'}); return; }
|
||||
const exports = module.enumerateExports().map(e => ({type:e.type,name:e.name,address:e.address.toString()}));
|
||||
send({kind:'module',pid:Process.id,path:module.path,base:module.base.toString(),size:module.size,total:exports.length});
|
||||
for (let offset = 0; offset < exports.length; offset += 64) send({kind:'exports',offset,items:exports.slice(offset,offset+64)});
|
||||
send({kind:'complete',total:exports.length});
|
||||
}, 300);`;
|
||||
await writeFile(join(output, "selected-script.js"), source);
|
||||
report.source_sha256 = createHash("sha256").update(source).digest("hex");
|
||||
const loaded = await call("load_frida_script", {
|
||||
session_id: session.session_id,
|
||||
source_kind: "inline",
|
||||
source,
|
||||
});
|
||||
assert.equal(loaded.normalized_result.source_sha256, report.source_sha256);
|
||||
report.load = loaded.normalized_result;
|
||||
await call("resume_frida_session", { session_id: session.session_id });
|
||||
await delay(750);
|
||||
const snapshots = [];
|
||||
for (let n = 0; n < 2; n++)
|
||||
snapshots.push(
|
||||
await call("frida_session_status", {
|
||||
session_id: session.session_id,
|
||||
...(detail === "summary" ? { detail } : {}),
|
||||
}),
|
||||
);
|
||||
await call("export_evidence_bundle", { path: join(output, "evidence.json") });
|
||||
const bundle = JSON.parse(
|
||||
await readFile(join(output, "evidence.json"), "utf8"),
|
||||
);
|
||||
for (const snapshot of snapshots) {
|
||||
assert.equal(snapshot.raw_result, null);
|
||||
const parent =
|
||||
detail === "summary"
|
||||
? bundle.records.find(
|
||||
(e) =>
|
||||
e.evidence_id === snapshot.normalized_result.parent_evidence_id,
|
||||
)
|
||||
: snapshot;
|
||||
assert.ok(parent);
|
||||
assert.equal(parent.authority, "external-service");
|
||||
assert.equal(parent.environment, null);
|
||||
assert.equal(parent.raw_result, null);
|
||||
assert.equal(parent.operation, "frida_session_status");
|
||||
assert.deepEqual(parent.parameters, { session_id: session.session_id });
|
||||
const r = parent.normalized_result;
|
||||
assert.equal(r.pid, session.pid);
|
||||
assert.equal(r.state, "running");
|
||||
assert.equal(r.messages_truncated, false);
|
||||
const payloads = r.messages
|
||||
.filter((m) => m.type === "send")
|
||||
.map((m) => m.payload);
|
||||
const ready = payloads.find((p) => p?.kind === "ready");
|
||||
const complete = payloads.find((p) => p?.kind === "complete");
|
||||
assert.equal(ready?.pid, session.pid);
|
||||
assert.ok(complete);
|
||||
let count = 0;
|
||||
let header;
|
||||
if (capture === "libc") {
|
||||
header = payloads.find((p) => p?.kind === "module");
|
||||
assert.ok(header);
|
||||
assert.equal(header.pid, session.pid);
|
||||
for (const chunk of payloads.filter((p) => p?.kind === "exports")) {
|
||||
assert.equal(chunk.offset, count);
|
||||
count += chunk.items.length;
|
||||
}
|
||||
assert.equal(count, header.total);
|
||||
assert.ok(count > 1000);
|
||||
const maps = await readFile(`/proc/${session.pid}/maps`, "utf8");
|
||||
assert.ok(maps.includes(header.path));
|
||||
}
|
||||
assert.equal(count, complete.total);
|
||||
if (detail === "summary") {
|
||||
const view = snapshot.normalized_result;
|
||||
assert.equal(view.parent_operation, parent.operation);
|
||||
assert.equal(view.parent_digest, parent.evidence_id.slice(3));
|
||||
assert.equal(view.artifact, null);
|
||||
assert.deepEqual(view.source_parameters, parent.parameters);
|
||||
assert.deepEqual(view.source_provider, parent.provider);
|
||||
const { messages, ...metadata } = r;
|
||||
assert.deepEqual(view.summary, {
|
||||
...metadata,
|
||||
messages_retained: messages.length,
|
||||
});
|
||||
assert.ok(view.unknowns.length > 0);
|
||||
assert.ok(view.limitations.some((m) => m.includes("operating system")));
|
||||
assert.deepEqual(
|
||||
bundle.records.find((e) => e.evidence_id === snapshot.evidence_id),
|
||||
snapshot,
|
||||
);
|
||||
}
|
||||
report.answer = {
|
||||
pid: r.pid,
|
||||
state: r.state,
|
||||
scripts: r.scripts.length,
|
||||
messages: r.messages.length,
|
||||
messages_truncated: r.messages_truncated,
|
||||
exports: count,
|
||||
module_path: header?.path ?? null,
|
||||
};
|
||||
}
|
||||
report.native_oracle =
|
||||
"Caller-authored script digest and contiguous chunk coverage; PID/birthtime/cgroup and libc path independently checked in procfs. Export addresses and native export completeness not independently qualified.";
|
||||
report.exported_bundle_bytes = Buffer.byteLength(
|
||||
await readFile(join(output, "evidence.json")),
|
||||
);
|
||||
await call("close_frida_session", { session_id: session.session_id });
|
||||
session.closed = true;
|
||||
const deadline = performance.now() + 12000;
|
||||
while (true) {
|
||||
const current = await stat();
|
||||
if (
|
||||
current === null ||
|
||||
current.startTime !== birth.startTime ||
|
||||
["zombie", "dead"].includes(current.lifecycle)
|
||||
) {
|
||||
report.original_target_exited = true;
|
||||
break;
|
||||
}
|
||||
assert.ok(
|
||||
performance.now() < deadline,
|
||||
"Original target exit not observed",
|
||||
);
|
||||
await delay(50);
|
||||
}
|
||||
report.status = "passed";
|
||||
} catch (error) {
|
||||
report.status = "failed";
|
||||
report.failure = String(error?.stack ?? error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (session && !session.closed)
|
||||
try {
|
||||
await call("close_frida_session", { session_id: session.session_id });
|
||||
} catch (error) {
|
||||
report.session_cleanup_failure = String(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
if (
|
||||
!(await finalizeRetainedQueryReceipt({
|
||||
report,
|
||||
client,
|
||||
transport,
|
||||
account,
|
||||
serverLog,
|
||||
serverLogFinished,
|
||||
output,
|
||||
transcript,
|
||||
}))
|
||||
)
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
{
|
||||
"scope": "Real local Frida binding with controlled sleep target and caller-authored libc export inventory. Actual Codex0.163 app-server uses a deterministic local SSE fixture: no AI inference, model usage, quality or billing claim. Whole compact JSON token estimates include all 211 schemas and every request replay. Incomplete default baseline is excluded from completed-task gains. SDK exchange estimates are not model-input measurements. Fresh processes have unflushed OS caches; both status reads query live session observations. Timings include the eight-second target lifetime and cleanup; single pairs establish no general speedup. Payload-exit cgroup counters are verified; final whole-scope counters may be unavailable.",
|
||||
"tokenizer": {
|
||||
"package": "0.12.0",
|
||||
"encoding": "cl100k_base"
|
||||
},
|
||||
"runtime_pins": {
|
||||
"baseline": {
|
||||
"source_base": "17f581ff75d2b6cf643a997275e3e296f6c95760",
|
||||
"patch_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"archive_sha256": "cd16ca3e36633459bffc10bb90d1fe4ff5887fdd403729765b9a812969013807"
|
||||
},
|
||||
"candidate": {
|
||||
"source_base": "32a4690059907f09fb29e284ad492d94c1722278",
|
||||
"patch_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"archive_sha256": "4ac402f1de93f76f71b7ccdb8b8e36d8c8d0a87b1184812f06393bfc6fadccba"
|
||||
}
|
||||
},
|
||||
"native": {
|
||||
"host": "Linux-6.1.0-53-cloud-amd64-x86_64-with-glibc2.36",
|
||||
"toolchain": {
|
||||
"node": "24.18.0",
|
||||
"npm": "11.16.0"
|
||||
},
|
||||
"frida_binding_package_version": "17.23.2",
|
||||
"files": [
|
||||
{
|
||||
"path": "/usr/bin/sleep",
|
||||
"bytes": 43888,
|
||||
"sha256": "4add4bb89d8ca0e3b1bd861130ddd7ae0fd9617a8055de0a38c8d2ca1ac95723"
|
||||
},
|
||||
{
|
||||
"path": "/usr/lib/x86_64-linux-gnu/libc.so.6",
|
||||
"bytes": 1926232,
|
||||
"sha256": "6b4a45352fd0c540a9c7c718f35ce8c8e46a4e482f9d3885a910c32d1a0e1421"
|
||||
},
|
||||
{
|
||||
"path": "/home/yann/yann/rea-perf-next-audit-20261020/node_modules/frida/package.json",
|
||||
"bytes": 2252,
|
||||
"sha256": "7efb59d2050b7b6304e1617f463c4eb7b2f7cb7e3e80e677976825a76ee31e8d"
|
||||
},
|
||||
{
|
||||
"path": "/home/yann/yann/rea-perf-next-audit-20261020/node_modules/frida/build/frida_binding.node",
|
||||
"bytes": 112775824,
|
||||
"sha256": "5b2db5ae57c058ad0765e627d9d9c9c98e4e90c09746c6f727b7a80769eed7a5"
|
||||
}
|
||||
],
|
||||
"qualifications": {
|
||||
"Inspector": "real REA MCP target, concurrent native metadata only oracle, 776 rows matched hash/length; not global completeness or execution proof",
|
||||
"Frida": "local native binding and controlled sleep spawn; caller script digest and procfs PID/birthtime/cgroup/libc path verified; native export address/global completeness oracle unavailable",
|
||||
"ADB": "default executable unavailable in prior public call; no real device qualified"
|
||||
}
|
||||
},
|
||||
"script_pins": {
|
||||
"frida_status_delivery.py": "ed19bcd003165c7b522c8cdd4799bcf987178e4b79fc8d5c2442012f3bf60b8d",
|
||||
"frida-status-costs.py": "2f3196bcc3198a265f7f4842effc8dce31669723b42f07367f065afee0073906",
|
||||
"frida-status-queries.mjs": "c5919ae8bba046909858e92a945b4472645bbacbfcf88dd75c89ba959a986980",
|
||||
"frida-status-wire.py": "b4d20e64bf77b7815bd43b52569b3e4756983b254ab44c50ddae874d1964ef69"
|
||||
},
|
||||
"wire": [
|
||||
{
|
||||
"case": "wire-v2-baseline-0",
|
||||
"task_completed": false,
|
||||
"mode": "complete",
|
||||
"budget": "client default",
|
||||
"requests": 8,
|
||||
"compactions": 0,
|
||||
"whole_json_tokens_proxy": 540065,
|
||||
"request_details": [
|
||||
{
|
||||
"whole_json_tokens_proxy": 62729,
|
||||
"rea_namespace_tokens_proxy": 53518,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 62917,
|
||||
"rea_namespace_tokens_proxy": 53518,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 63599,
|
||||
"rea_namespace_tokens_proxy": 53518,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 63739,
|
||||
"rea_namespace_tokens_proxy": 53518,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 68261,
|
||||
"rea_namespace_tokens_proxy": 53518,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 72785,
|
||||
"rea_namespace_tokens_proxy": 53518,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 72926,
|
||||
"rea_namespace_tokens_proxy": 53518,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 73109,
|
||||
"rea_namespace_tokens_proxy": 53518,
|
||||
"rea_functions": 211
|
||||
}
|
||||
],
|
||||
"wall_seconds": 15.993,
|
||||
"cpu_seconds": 8.211179,
|
||||
"family_peak_mib": 830.6484375,
|
||||
"payload_cleanup_verified": true,
|
||||
"final_whole_scope_counters_verified": false
|
||||
},
|
||||
{
|
||||
"case": "wire-v2-baseline-300000",
|
||||
"task_completed": true,
|
||||
"mode": "complete",
|
||||
"budget": 300000,
|
||||
"requests": 8,
|
||||
"compactions": 0,
|
||||
"whole_json_tokens_proxy": 1049997,
|
||||
"request_details": [
|
||||
{
|
||||
"whole_json_tokens_proxy": 62715,
|
||||
"rea_namespace_tokens_proxy": 53518,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 62903,
|
||||
"rea_namespace_tokens_proxy": 53518,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 63577,
|
||||
"rea_namespace_tokens_proxy": 53518,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 63714,
|
||||
"rea_namespace_tokens_proxy": 53518,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 141108,
|
||||
"rea_namespace_tokens_proxy": 53518,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 218504,
|
||||
"rea_namespace_tokens_proxy": 53518,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 218646,
|
||||
"rea_namespace_tokens_proxy": 53518,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 218830,
|
||||
"rea_namespace_tokens_proxy": 53518,
|
||||
"rea_functions": 211
|
||||
}
|
||||
],
|
||||
"wall_seconds": 15.418,
|
||||
"cpu_seconds": 8.026944,
|
||||
"family_peak_mib": 824.28125,
|
||||
"payload_cleanup_verified": true,
|
||||
"final_whole_scope_counters_verified": false
|
||||
},
|
||||
{
|
||||
"case": "wire-v2-candidate-0",
|
||||
"task_completed": true,
|
||||
"mode": "summary",
|
||||
"budget": "client default",
|
||||
"requests": 8,
|
||||
"compactions": 0,
|
||||
"whole_json_tokens_proxy": 515539,
|
||||
"request_details": [
|
||||
{
|
||||
"whole_json_tokens_proxy": 62791,
|
||||
"rea_namespace_tokens_proxy": 53581,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 62978,
|
||||
"rea_namespace_tokens_proxy": 53581,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 63661,
|
||||
"rea_namespace_tokens_proxy": 53581,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 63803,
|
||||
"rea_namespace_tokens_proxy": 53581,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 64749,
|
||||
"rea_namespace_tokens_proxy": 53581,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 65697,
|
||||
"rea_namespace_tokens_proxy": 53581,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 65837,
|
||||
"rea_namespace_tokens_proxy": 53581,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 66023,
|
||||
"rea_namespace_tokens_proxy": 53581,
|
||||
"rea_functions": 211
|
||||
}
|
||||
],
|
||||
"wall_seconds": 15.889,
|
||||
"cpu_seconds": 7.939387,
|
||||
"family_peak_mib": 823.69921875,
|
||||
"payload_cleanup_verified": true,
|
||||
"final_whole_scope_counters_verified": false
|
||||
},
|
||||
{
|
||||
"case": "wire-v2-candidate-300000",
|
||||
"task_completed": true,
|
||||
"mode": "summary",
|
||||
"budget": 300000,
|
||||
"requests": 8,
|
||||
"compactions": 0,
|
||||
"whole_json_tokens_proxy": 515713,
|
||||
"request_details": [
|
||||
{
|
||||
"whole_json_tokens_proxy": 62804,
|
||||
"rea_namespace_tokens_proxy": 53581,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 62990,
|
||||
"rea_namespace_tokens_proxy": 53581,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 63677,
|
||||
"rea_namespace_tokens_proxy": 53581,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 63815,
|
||||
"rea_namespace_tokens_proxy": 53581,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 64772,
|
||||
"rea_namespace_tokens_proxy": 53581,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 65731,
|
||||
"rea_namespace_tokens_proxy": 53581,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 65870,
|
||||
"rea_namespace_tokens_proxy": 53581,
|
||||
"rea_functions": 211
|
||||
},
|
||||
{
|
||||
"whole_json_tokens_proxy": 66054,
|
||||
"rea_namespace_tokens_proxy": 53581,
|
||||
"rea_functions": 211
|
||||
}
|
||||
],
|
||||
"wall_seconds": 15.439,
|
||||
"cpu_seconds": 8.371664,
|
||||
"family_peak_mib": 813.6328125,
|
||||
"payload_cleanup_verified": true,
|
||||
"final_whole_scope_counters_verified": false
|
||||
}
|
||||
],
|
||||
"sdk": [
|
||||
{
|
||||
"case": "sdk-baseline-libc",
|
||||
"detail": "complete",
|
||||
"capture": "libc",
|
||||
"tool_calls": 8,
|
||||
"exported_bundle_bytes": 214214,
|
||||
"answer": {
|
||||
"pid": 517427,
|
||||
"state": "running",
|
||||
"scripts": 1,
|
||||
"messages": 50,
|
||||
"messages_truncated": false,
|
||||
"exports": 2963,
|
||||
"module_path": "/usr/lib/x86_64-linux-gnu/libc.so.6"
|
||||
},
|
||||
"status_calls": [
|
||||
{
|
||||
"name": "frida_session_status",
|
||||
"input": {
|
||||
"session_id": "ef8982d3-dbf2-43cb-8256-3a4f4bc83cc3"
|
||||
},
|
||||
"phase": "frida_session_status",
|
||||
"elapsed_ms": 197.58100300000024,
|
||||
"reply_bytes": 462831,
|
||||
"is_error": false
|
||||
},
|
||||
{
|
||||
"name": "frida_session_status",
|
||||
"input": {
|
||||
"session_id": "ef8982d3-dbf2-43cb-8256-3a4f4bc83cc3"
|
||||
},
|
||||
"phase": "frida_session_status",
|
||||
"elapsed_ms": 108.50572199999988,
|
||||
"reply_bytes": 462831,
|
||||
"is_error": false
|
||||
}
|
||||
],
|
||||
"one_catalog_plus_all_sdk_calls_tokens_proxy": 1186065,
|
||||
"wall_seconds": 13.759,
|
||||
"cpu_seconds": 6.097489,
|
||||
"family_peak_mib": 578.5625,
|
||||
"payload_cleanup_verified": true,
|
||||
"final_whole_scope_counters_verified": false
|
||||
},
|
||||
{
|
||||
"case": "sdk-baseline-small",
|
||||
"detail": "complete",
|
||||
"capture": "small",
|
||||
"tool_calls": 8,
|
||||
"exported_bundle_bytes": 2335,
|
||||
"answer": {
|
||||
"pid": 516970,
|
||||
"state": "running",
|
||||
"scripts": 1,
|
||||
"messages": 2,
|
||||
"messages_truncated": false,
|
||||
"exports": 0,
|
||||
"module_path": null
|
||||
},
|
||||
"status_calls": [
|
||||
{
|
||||
"name": "frida_session_status",
|
||||
"input": {
|
||||
"session_id": "3f1f778f-ca18-48ba-a7db-b58afbf4710d"
|
||||
},
|
||||
"phase": "frida_session_status",
|
||||
"elapsed_ms": 79.25232200000028,
|
||||
"reply_bytes": 2643,
|
||||
"is_error": false
|
||||
},
|
||||
{
|
||||
"name": "frida_session_status",
|
||||
"input": {
|
||||
"session_id": "3f1f778f-ca18-48ba-a7db-b58afbf4710d"
|
||||
},
|
||||
"phase": "frida_session_status",
|
||||
"elapsed_ms": 6.851230000000214,
|
||||
"reply_bytes": 2643,
|
||||
"is_error": false
|
||||
}
|
||||
],
|
||||
"one_catalog_plus_all_sdk_calls_tokens_proxy": 882323,
|
||||
"wall_seconds": 13.039,
|
||||
"cpu_seconds": 4.953337,
|
||||
"family_peak_mib": 577.140625,
|
||||
"payload_cleanup_verified": true,
|
||||
"final_whole_scope_counters_verified": false
|
||||
},
|
||||
{
|
||||
"case": "sdk-candidate-libc",
|
||||
"detail": "summary",
|
||||
"capture": "libc",
|
||||
"tool_calls": 8,
|
||||
"exported_bundle_bytes": 216931,
|
||||
"answer": {
|
||||
"pid": 517901,
|
||||
"state": "running",
|
||||
"scripts": 1,
|
||||
"messages": 50,
|
||||
"messages_truncated": false,
|
||||
"exports": 2963,
|
||||
"module_path": "/usr/lib/x86_64-linux-gnu/libc.so.6"
|
||||
},
|
||||
"status_calls": [
|
||||
{
|
||||
"name": "frida_session_status",
|
||||
"input": {
|
||||
"session_id": "9af7f188-05f8-4559-b570-98556196090e",
|
||||
"detail": "summary"
|
||||
},
|
||||
"phase": "frida_session_status",
|
||||
"elapsed_ms": 159.16798299999937,
|
||||
"reply_bytes": 5597,
|
||||
"is_error": false
|
||||
},
|
||||
{
|
||||
"name": "frida_session_status",
|
||||
"input": {
|
||||
"session_id": "9af7f188-05f8-4559-b570-98556196090e",
|
||||
"detail": "summary"
|
||||
},
|
||||
"phase": "frida_session_status",
|
||||
"elapsed_ms": 61.96135099999992,
|
||||
"reply_bytes": 5597,
|
||||
"is_error": false
|
||||
}
|
||||
],
|
||||
"one_catalog_plus_all_sdk_calls_tokens_proxy": 885626,
|
||||
"wall_seconds": 12.843,
|
||||
"cpu_seconds": 5.012703,
|
||||
"family_peak_mib": 579.0,
|
||||
"payload_cleanup_verified": true,
|
||||
"final_whole_scope_counters_verified": false
|
||||
},
|
||||
{
|
||||
"case": "sdk-candidate-small",
|
||||
"detail": "summary",
|
||||
"capture": "small",
|
||||
"tool_calls": 8,
|
||||
"exported_bundle_bytes": 5051,
|
||||
"answer": {
|
||||
"pid": 517162,
|
||||
"state": "running",
|
||||
"scripts": 1,
|
||||
"messages": 2,
|
||||
"messages_truncated": false,
|
||||
"exports": 0,
|
||||
"module_path": null
|
||||
},
|
||||
"status_calls": [
|
||||
{
|
||||
"name": "frida_session_status",
|
||||
"input": {
|
||||
"session_id": "1bcda0f5-e3cd-45ee-b82c-5886d8312bc6",
|
||||
"detail": "summary"
|
||||
},
|
||||
"phase": "frida_session_status",
|
||||
"elapsed_ms": 107.78244100000029,
|
||||
"reply_bytes": 5595,
|
||||
"is_error": false
|
||||
},
|
||||
{
|
||||
"name": "frida_session_status",
|
||||
"input": {
|
||||
"session_id": "1bcda0f5-e3cd-45ee-b82c-5886d8312bc6",
|
||||
"detail": "summary"
|
||||
},
|
||||
"phase": "frida_session_status",
|
||||
"elapsed_ms": 5.996030000000246,
|
||||
"reply_bytes": 5595,
|
||||
"is_error": false
|
||||
}
|
||||
],
|
||||
"one_catalog_plus_all_sdk_calls_tokens_proxy": 885676,
|
||||
"wall_seconds": 13.533,
|
||||
"cpu_seconds": 5.495048,
|
||||
"family_peak_mib": 576.48046875,
|
||||
"payload_cleanup_verified": true,
|
||||
"final_whole_scope_counters_verified": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
# Frida status delivery and runtime-observation audit
|
||||
|
||||
Issue: [#5640](https://github.com/morluto/rea/issues/5640). Paired runtime base:
|
||||
`17f581ff75d2b6cf643a997275e3e296f6c95760`. Final candidate source: `32a4690059907f09fb29e284ad492d94c1722278` (clean tree).
|
||||
Exact package, patch, native input,
|
||||
script and resource pins are in [the measurement record](frida-status-views.json).
|
||||
|
||||
The analyst requests a complete libc export inventory as retained evidence,
|
||||
then checks the session's identity/state, script identity, queued message count
|
||||
and truncation twice. The caller-authored script enumerates actual native module
|
||||
exports in a controlled `/usr/bin/sleep` spawn, in chunks of 64. Both workflows
|
||||
retain all captured messages and export the complete Evidence bundle.
|
||||
|
||||
Start, status and one-shot contracts share one canonical session envelope.
|
||||
Message JSON is validated at the Evidence owner; the summary validates the
|
||||
envelope without reinterpreting opaque payloads.
|
||||
|
||||
The owning application service now accepts optional `detail=summary` for
|
||||
`frida_session_status`. It reads the provider on every call, retains the original
|
||||
complete Evidence, then uses the existing shared analysis-view projector.
|
||||
The summary omits arbitrary message payloads and preserves all other status
|
||||
fields, the retained message count, truncation, exact parent identity/digest,
|
||||
provider and source parameters. Default and explicit complete delivery keep the
|
||||
original Evidence identity and payload. Historical CLI/file projections use the
|
||||
same projector. Source script decoding/representation Evidence and session
|
||||
resource ownership are unchanged.
|
||||
|
||||
## Completed tasks
|
||||
|
||||
The native Frida Node binding package 17.23.2 captured 2,963 libc exports in 50 queued
|
||||
messages. Independent checks establish the test-owned process birthtime, cgroup,
|
||||
PID and libc mapping; the selected script digest and contiguous chunks are
|
||||
checked against the complete retained parent. This does not independently
|
||||
validate every native export address or establish global event coverage.
|
||||
|
||||
| Public SDK task | Complete | Summary |
|
||||
| ------------------------------------------ | -------: | ------: |
|
||||
| Large-history status reply, bytes per call | 462,831 | 5,597 |
|
||||
| Small-history status reply, bytes per call | 2,643 | 5,595 |
|
||||
| Large complete bundle, bytes | 214,214 | 216,931 |
|
||||
| Small complete bundle, bytes | 2,335 | 5,051 |
|
||||
| Large task elapsed, seconds | 13.759 | 12.843 |
|
||||
| Large task CPU, seconds | 6.097 | 5.013 |
|
||||
| Large family peak, MiB | 578.6 | 579.0 |
|
||||
| Small task elapsed, seconds | 13.039 | 13.533 |
|
||||
| Small task CPU, seconds | 4.953 | 5.495 |
|
||||
| Small family peak, MiB | 577.1 | 576.5 |
|
||||
|
||||
Each SDK task discovers all 211 tools and executes eight public calls, including
|
||||
two status reads and complete export. Small history contains two readiness/
|
||||
completion messages. Fresh processes retain unflushed OS caches. The second
|
||||
status read is a repeated live observation, not a cache hit. Every task includes
|
||||
the controlled target's eight-second lifetime and verified natural exit.
|
||||
Single pairs and this fixed wait establish no general latency/CPU advantage.
|
||||
Large status calls took 159.2/62.0 ms versus 197.6/108.5 ms. The first small
|
||||
summary status was slower (107.8 versus 79.3 ms).
|
||||
|
||||
## Actual client context
|
||||
|
||||
Codex app-server 0.163.0 performs the native Frida workflow through the actual
|
||||
REA MCP package. A deterministic local Responses/SSE fixture selects the calls;
|
||||
there is no AI inference, paid model, model-quality or billing claim. The capture
|
||||
contains all 211 tool input schemas on each request and all history replays. `tiktoken`
|
||||
0.12.0 `cl100k_base` counts compact JSON as a token estimate, not actual model
|
||||
usage. Both completed paths use the same 300,000 tool-output budget.
|
||||
|
||||
| Completed client task | Complete | Summary |
|
||||
| -------------------------------------- | --------: | ------: |
|
||||
| Requests / compactions | 8 / 0 | 8 / 0 |
|
||||
| Whole request JSON token estimate, sum | 1,049,997 | 515,713 |
|
||||
| Elapsed seconds | 15.418 | 15.439 |
|
||||
| CPU seconds | 8.027 | 8.372 |
|
||||
| Family peak MiB | 824.3 | 813.6 |
|
||||
|
||||
The completed task uses **50.9% fewer estimated request tokens**, including
|
||||
schemas and continuations. Client elapsed and CPU time increased in this pair. At the client's
|
||||
default output budget, complete status JSON is truncated and the task is
|
||||
incomplete; summary status is intact and completes. That failed delivery is
|
||||
excluded from completed-task gains. Three acceptance tests exercise invalid
|
||||
JSON, incorrect counts/state/source linkage, missing chunks and wrong sessions.
|
||||
|
||||
The complete catalog plus all SDK exchanges also shrinks for the large case
|
||||
(1,186,065 to 885,626 estimated tokens), but grows for the small case (882,323 to
|
||||
885,676). These SDK exchange estimates include advertised output schemas and
|
||||
are **not** actual model-input measurements. Summary delivery is optional
|
||||
because its provenance wrapper costs more than a small complete observation.
|
||||
|
||||
## Resource accounting and limits
|
||||
|
||||
Runtime cases use one CPU, a 4 GiB cgroup ceiling, a 3 GiB early stop, no swap,
|
||||
192 tasks, and host-memory admission/early-stop guards. Payload exit, descendant
|
||||
cleanup and zero resource-limit/OOM events are verified while the scope observer
|
||||
remains alive. Final whole-scope counters disappear after teardown and remain
|
||||
unknown. Runtime family peaks stay below 1 GiB. Build/typecheck use separately
|
||||
guarded larger limits; they are not runtime performance measurements.
|
||||
|
||||
Full queued messages still undergo validation, Evidence creation and retention;
|
||||
this change primarily reduces delivery and repeated context. The extra derived
|
||||
record increases retained bundle size. Long-lived heap retention, many-script
|
||||
sessions, remote devices, binary messages and saturated capture budgets are not
|
||||
performance-qualified by these native cases. Boundary tests preserve malformed
|
||||
input, truncation, cancellation and cleanup semantics; simulated providers do
|
||||
not qualify real remote behavior.
|
||||
|
||||
## Other providers in this audit
|
||||
|
||||
Inspector was measured against a test-owned real REA MCP process while it
|
||||
handled a verified GitHub CLI Go build-information request. A concurrent native
|
||||
CDP metadata subscription matched all 776 retained scripts by hash and reported
|
||||
length: 565 file locations and 211 built-ins. The reply was 608,719 bytes;
|
||||
capture retained 996/996 events with no reported drop/truncation. The qualification
|
||||
run took 20.956 seconds, 18.076 CPU seconds and 987.4 MiB peak family memory,
|
||||
including the independent oracle. This is not a paired performance cohort,
|
||||
global completeness proof, source-byte validation or evidence of script
|
||||
execution. Only `Runtime.enable` and `Debugger.enable` were sent. A prior
|
||||
post-capture oracle missed 25 historical rows and was rejected; it does not
|
||||
establish an Inspector regression. Existing raw-null delivery, grouped location
|
||||
validation and eight-worker projection were already present and were preserved.
|
||||
|
||||
ADB's configured default executable is unavailable on this host, and no real
|
||||
device is qualified. This audit makes no ADB device-performance claim. Inspector
|
||||
selection/caching and Frida queue/filter redesign remain unimplemented without
|
||||
an equivalent completed-task measurement that justifies them.
|
||||
|
||||
The existing bundled Frida verifier also returned functional success, but its
|
||||
1,500 ms fixture outlived the verifier's 500 ms observation. Its payload-exit
|
||||
cleanup receipt was therefore unverified and is excluded from qualified
|
||||
resource/performance results. That unchanged verifier has no process birthtime
|
||||
stamp to safely wait on an arbitrary later PID. The paired harness instead owns
|
||||
the paused spawn and records birthtime/cgroup before resume, then waits for the
|
||||
original target's natural exit. No PID-only cleanup or target-kill policy was
|
||||
introduced.
|
||||
|
||||
The shared-service import also exposed an existing module-mock load-order
|
||||
assumption in the Evidence-file cancellation test. That one mocked test now
|
||||
runs in the existing isolated `process-global` lane, with an assertion that the
|
||||
mocked read actually ran. Other file/service tests keep the shared module graph.
|
||||
The final exact ten-file gate passes 225 tests with file parallelism disabled
|
||||
under one CPU. An earlier parallel gate hit one existing 30-second MCP lifecycle
|
||||
timeout; its serial retry passed without increasing a timeout or changing the
|
||||
production lifecycle contract.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Use the pinned Node/npm toolchain and native Frida dependency. Freeze separate
|
||||
baseline/candidate built packages and install the same locked dependencies.
|
||||
Run each case independently with the resource observer:
|
||||
|
||||
```sh
|
||||
python3 experiments/analysis-efficiency/resource-run.py --observe-payload-exit -- \
|
||||
node experiments/analysis-efficiency/frida-status-queries.mjs \
|
||||
--runtime /absolute/built-package --output /absolute/new-case \
|
||||
--capture libc --detail summary
|
||||
```
|
||||
|
||||
Repeat with `--detail complete` and `--capture small`. For actual-client captures,
|
||||
`frida-status-wire.py --help` names the runtime, pinned Codex and Node executables,
|
||||
output directory and selected script file (`selected-script.js` from the SDK
|
||||
case). Use both `--mode complete`/`summary` and `--budget 300000`/`0`.
|
||||
`frida-status-costs.py` checks task/resource receipts and counts complete requests.
|
||||
The first wire attempt used ambiguous suffix matching for load/unload names;
|
||||
the fixture now selects exact operation names. Failed attempts remain outside
|
||||
the performance cohort. No production behavior changed to accommodate them.
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Actual Codex wire capture with a deterministic local SSE backend; no AI inference."""
|
||||
import argparse
|
||||
import http.server
|
||||
import threading
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
import traceback
|
||||
import subprocess
|
||||
from frida_status_delivery import verify_frida_delivery
|
||||
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--runtime", type=Path, required=True)
|
||||
parser.add_argument("--codex", type=Path, required=True)
|
||||
parser.add_argument("--node", type=Path, required=True)
|
||||
parser.add_argument("--input-profile", choices=["full", "compact"], default="full")
|
||||
parser.add_argument("--output-profile", choices=["full", "off"], default="full")
|
||||
parser.add_argument("--script", type=Path, required=True)
|
||||
parser.add_argument("--expected-tools", type=int, default=211)
|
||||
parser.add_argument("--mode", choices=["complete", "summary"], required=True)
|
||||
parser.add_argument("--budget", type=int, default=30000)
|
||||
args = parser.parse_args()
|
||||
source=args.script.read_text()
|
||||
assert args.runtime.is_absolute() and args.output.is_absolute() and args.script.is_absolute()
|
||||
def delivered(pieces):
|
||||
assert isinstance(pieces,str) and pieces.startswith("Wall time: "), pieces[:300]
|
||||
_,sep,payload=pieces.partition("Output:\n");assert sep
|
||||
try: value=json.loads(payload)
|
||||
except json.JSONDecodeError: return {"_invalid_json_payload":payload}
|
||||
return value["result"] if "result" in value and "normalized_result" not in value else value
|
||||
def outputs_of(body):
|
||||
return {item["call_id"]:delivered(item["output"]) for item in body.get("input",[]) if item.get("type")=="function_call_output"}
|
||||
steps=7
|
||||
args.output.mkdir(mode=0o700)
|
||||
audit_home = args.output / "account"
|
||||
codex_state = audit_home / ".codex"
|
||||
mcp_home = audit_home / "mcp"
|
||||
workspace = audit_home / "workspace"
|
||||
for directory in [codex_state, mcp_home, workspace]:
|
||||
directory.mkdir(parents=True)
|
||||
captured = []
|
||||
known_outputs = {}
|
||||
state = {"normal_requests":0,"compactions":0}
|
||||
class Capture(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
def do_POST(self):
|
||||
size = int(self.headers["Content-Length"])
|
||||
assert 0 < size <= 16 * 1024 * 1024
|
||||
assert self.headers.get("Content-Encoding", "identity") == "identity"
|
||||
body = json.loads(self.rfile.read(size))
|
||||
captured.append({"path": self.path, "body": body})
|
||||
(args.output / "model-requests.json").write_text(json.dumps(captured) + "\n")
|
||||
http_index = len(captured)
|
||||
known_outputs.update(outputs_of(body))
|
||||
names = [t.get("name", "") for ns in body.get("tools", []) if ns.get("type") == "namespace" and ns.get("name") == "mcp__rea_audit" for t in ns.get("tools", [])]
|
||||
if not names:
|
||||
last=body.get('input',[])[-1]
|
||||
assert 'CONTEXT CHECKPOINT COMPACTION' in json.dumps(last)
|
||||
state['compactions']+=1;assert state['compactions']<=1
|
||||
checkpoint={'fixture':'deterministic observed-ID checkpoint, no AI inference','task':'Check Frida session state, queued message count and truncation twice; retain/export complete observations and close instrumentation.','session_id':known_outputs['call_local_1']['session_id'],'delivered_call_ids':list(known_outputs),'remaining':'Continue scripted tools; source message bodies remain in retained Evidence.'}
|
||||
text=json.dumps(checkpoint)
|
||||
item={"type":"message","id":f"msg_checkpoint_{state['compactions']}","role":"assistant","status":"completed","content":[{"type":"output_text","text":text,"annotations":[]}]}
|
||||
captured[-1]['kind']='deterministic-compaction'
|
||||
else:
|
||||
state['normal_requests']+=1;index=state['normal_requests'];assert index<=steps+1
|
||||
captured[-1]['kind']='tool-planning-or-final'
|
||||
if index<=steps:
|
||||
if index==1:
|
||||
operation='start_frida_session';arguments={'mode':'spawn','device_id':'local','program':'/usr/bin/sleep','argv':['8']}
|
||||
else:
|
||||
session=known_outputs['call_local_1']['session_id']
|
||||
if index==2:
|
||||
operation='load_frida_script';arguments={'session_id':session,'source_kind':'inline','source':source}
|
||||
elif index==3:
|
||||
operation='resume_frida_session';arguments={'session_id':session}
|
||||
elif index in [4,5]:
|
||||
if index==4: time.sleep(0.75)
|
||||
operation='frida_session_status';arguments={'session_id':session,**({'detail':'summary'} if args.mode=='summary' else {})}
|
||||
elif index==6:
|
||||
operation='close_frida_session';arguments={'session_id':session}
|
||||
else:
|
||||
operation='export_evidence_bundle';arguments={'path':str(args.output/'evidence.json')}
|
||||
selected=[name for name in names if name == operation];assert len(selected)==1
|
||||
item={"type":"function_call","id":f"fc_local_{index}","call_id":f"call_local_{index}","name":selected[0],"namespace":"mcp__rea_audit","arguments":json.dumps(arguments),"status":"completed"}
|
||||
else:
|
||||
item={"type":"message","id":"msg_local_done","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Scripted local Frida state and capture-coverage task complete.","annotations":[]}]}
|
||||
response_id=f"resp_local_{http_index}"
|
||||
base = {"id": response_id, "object": "response", "model": body.get("model"), "status": "completed", "output": [item]}
|
||||
events = [
|
||||
{"type": "response.created", "response": {**base, "status": "in_progress", "output": []}},
|
||||
{"type": "response.output_item.added", "output_index": 0, "item": {**item, "status": "in_progress"}},
|
||||
{"type": "response.output_item.done", "output_index": 0, "item": item},
|
||||
{"type": "response.completed", "response": base},
|
||||
]
|
||||
wire = "".join("event: " + e["type"] + "\ndata: " + json.dumps(e) + "\n\n" for e in events).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Content-Length", str(len(wire)))
|
||||
self.end_headers()
|
||||
self.wfile.write(wire)
|
||||
endpoint = http.server.HTTPServer(("127.0.0.1", 0), Capture)
|
||||
endpoint_thread = threading.Thread(target=endpoint.serve_forever, daemon=True)
|
||||
endpoint_thread.start()
|
||||
task_path = f"{args.node.parent}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
mcp_env = {"HOME": str(mcp_home), "PATH": task_path,
|
||||
"NODE_OPTIONS": "--max-old-space-size=2048", "UV_THREADPOOL_SIZE": "1",
|
||||
"REA_LOG_LEVEL": "silent", "REA_MCP_INPUT_SCHEMA_PROFILE": args.input_profile,
|
||||
"REA_MCP_OUTPUT_SCHEMA_PROFILE": args.output_profile}
|
||||
config = 'model_provider = "rea_capture"\nmodel = "rea-wire-probe"\n[analytics]\nenabled = false\n[mcp_servers.rea_audit]\n'
|
||||
config += f'command = {json.dumps(str(args.node))}\n'
|
||||
config += f'args = {json.dumps([str(args.runtime / "scripts/rea.mjs"), "mcp"])}\n'
|
||||
config += 'startup_timeout_sec = 60\ntool_timeout_sec = 60\n'
|
||||
config += '[mcp_servers.rea_audit.env]\n'
|
||||
config += ''.join(f'{key} = {json.dumps(value)}\n' for key, value in mcp_env.items())
|
||||
for name in ['start_frida_session','load_frida_script','resume_frida_session','frida_session_status','close_frida_session','export_evidence_bundle']:
|
||||
config += f'[mcp_servers.rea_audit.tools.{name}]\napproval_mode = "approve"\n'
|
||||
if args.budget: config += f'output_token_limit = {args.budget}\n'
|
||||
config += f'\n[model_providers.rea_capture]\nname = "Local scripted capture"\nbase_url = "http://127.0.0.1:{endpoint.server_port}/v1"\nwire_api = "responses"\nrequires_openai_auth = false\nsupports_websockets = false\nrequest_max_retries = 0\nstream_max_retries = 0\n'
|
||||
(codex_state / "config.toml").write_text(config)
|
||||
(args.output / "declared-config.toml").write_text(config)
|
||||
environment = {"HOME": str(audit_home), "CODEX_HOME": str(codex_state), "PATH": task_path,
|
||||
"NODE_OPTIONS": "--max-old-space-size=2048", "UV_THREADPOOL_SIZE": "1"}
|
||||
report = {"question": "Retain a caller-requested native libc export inventory, check session identity/state, queued message count and truncation twice, close instrumentation and export complete Evidence.",
|
||||
"client": "Codex app-server 0.163.0", "model_turns_started": 1, "backend": "local deterministic SSE fixture; no AI model or paid service",
|
||||
"actual_model_usage": None, "input_profile": args.input_profile,
|
||||
"output_profile": args.output_profile, "calls": [], "observations": [],
|
||||
"unexpected_server_requests": [], "cleanup_failures": []}
|
||||
client_version = subprocess.check_output([str(args.codex), "--version"], env=environment, cwd=workspace, text=True, timeout=10).strip()
|
||||
assert client_version == "codex-cli 0.163.0", client_version
|
||||
transcript = []
|
||||
pending = {}
|
||||
completed_turn = asyncio.Event()
|
||||
next_id = 0
|
||||
stderr = (args.output / "server.log").open("wb")
|
||||
process = await asyncio.create_subprocess_exec(str(args.codex), "app-server", "--stdio",
|
||||
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=stderr,
|
||||
cwd=workspace, env=environment, limit=16 * 1024 * 1024)
|
||||
|
||||
async def send(message):
|
||||
encoded = (json.dumps(message, separators=(",", ":")) + "\n").encode()
|
||||
transcript.append({"direction": "client", "bytes": len(encoded), "message": message})
|
||||
process.stdin.write(encoded)
|
||||
await process.stdin.drain()
|
||||
|
||||
async def read_messages():
|
||||
try:
|
||||
while line := await process.stdout.readline():
|
||||
value = json.loads(line)
|
||||
transcript.append({"direction": "server", "bytes": len(line), "message": value})
|
||||
if value.get("method") == "turn/completed":
|
||||
report["turn"] = value["params"]["turn"]
|
||||
completed_turn.set()
|
||||
if "id" in value and "method" not in value:
|
||||
future = pending.pop(value["id"], None)
|
||||
if future and not future.done():
|
||||
future.set_result(value)
|
||||
elif "id" in value:
|
||||
report["unexpected_server_requests"].append(value["method"])
|
||||
await send({"id": value["id"], "error": {"code": -32601, "message": "Unexpected request in scripted metadata probe"}})
|
||||
raise RuntimeError("app-server stdout closed")
|
||||
except Exception as error:
|
||||
for future in pending.values():
|
||||
if not future.done():
|
||||
future.set_exception(error)
|
||||
|
||||
reader = asyncio.create_task(read_messages())
|
||||
|
||||
async def request(method, params):
|
||||
nonlocal next_id
|
||||
assert method in ["initialize", "thread/start", "mcpServerStatus/list", "mcpServer/tool/call", "turn/start"]
|
||||
next_id += 1
|
||||
message = {"id": next_id, "method": method, "params": params}
|
||||
future = asyncio.get_running_loop().create_future()
|
||||
pending[next_id] = future
|
||||
start = time.monotonic()
|
||||
await send(message)
|
||||
reply = await asyncio.wait_for(future, 90)
|
||||
report["calls"].append({"method": method, "elapsed_ms": (time.monotonic() - start) * 1000,
|
||||
"error": reply.get("error")})
|
||||
if "error" in reply:
|
||||
raise RuntimeError(json.dumps(reply["error"])[:4000])
|
||||
return reply["result"]
|
||||
|
||||
try:
|
||||
await request("initialize", {"clientInfo": {"name": "rea_host_probe", "title": "REA host compatibility probe", "version": "1"},
|
||||
"capabilities": {"experimentalApi": True, "explicitGatewayOauth": True}})
|
||||
await send({"method": "initialized"})
|
||||
created = await request("thread/start", {"cwd": str(workspace), "ephemeral": True,
|
||||
"approvalPolicy": "never", "sandbox": "read-only"})
|
||||
thread_id = created["thread"]["id"]
|
||||
assert not created["thread"].get("turns", [])
|
||||
inventory = await request("mcpServerStatus/list", {"threadId": thread_id,
|
||||
"serverName": "rea_audit", "detail": "toolsAndAuthOnly", "limit": 10})
|
||||
assert inventory.get("nextCursor") is None
|
||||
assert len(inventory["data"]) == 1
|
||||
server = inventory["data"][0]
|
||||
assert server["name"] == "rea_audit" and server.get("toolsError") is None
|
||||
tools = list(server["tools"].values())
|
||||
names = [tool["name"] for tool in tools]
|
||||
assert len(names) == len(set(names)) == args.expected_tools
|
||||
assert "frida_session_status" in names
|
||||
output_schemas = sum(tool.get("outputSchema") is not None for tool in tools)
|
||||
assert output_schemas == (args.expected_tools if args.output_profile == "full" else 0)
|
||||
raw = json.dumps(inventory, separators=(",", ":"), ensure_ascii=False).encode()
|
||||
(args.output / "catalog.json").write_bytes(raw)
|
||||
report["catalog"] = {"tools": len(tools), "output_schemas": output_schemas,
|
||||
"bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest(),
|
||||
"server_info": server.get("serverInfo"), "runtime_status": server.get("runtimeStatus")}
|
||||
await request("turn/start", {"threadId":thread_id,"input":[{"type":"text","text":"Retain a complete libc export inventory using the selected caller-authored script. Check session state and queued message capture count/truncation twice, close Frida instrumentation and export complete Evidence. The local fixture scripts the tools; no AI inference."}]})
|
||||
await asyncio.wait_for(completed_turn.wait(), 90)
|
||||
assert report["turn"]["status"] == "completed", report["turn"]
|
||||
assert state["normal_requests"]==steps+1
|
||||
assert len(captured)==steps+1+state["compactions"]
|
||||
outputs=known_outputs
|
||||
assert len(outputs)==steps
|
||||
bundle=json.loads((args.output/'evidence.json').read_text())
|
||||
delivery_complete=True
|
||||
answers=[]
|
||||
for index in [4,5]:
|
||||
delivered_result=outputs[f'call_local_{index}']
|
||||
answer=verify_frida_delivery(delivered_result,bundle,args.mode,args.budget,outputs['call_local_1'])
|
||||
if answer is None: delivery_complete=False
|
||||
else: answers.append(answer)
|
||||
loaded=next(e for e in bundle['records'] if e['operation']=='load_frida_script')
|
||||
assert loaded['normalized_result']['source_sha256']==hashlib.sha256(source.encode()).hexdigest()
|
||||
report['task_completed']=delivery_complete
|
||||
report['observations']={'answers':answers,'exported':True,'exported_bundle_bytes':(args.output/'evidence.json').stat().st_size,'tool_output_budget':args.budget or 'client default','source_sha256':hashlib.sha256(source.encode()).hexdigest()}
|
||||
report["captured_requests"]=len(captured)
|
||||
report["compaction_requests"]=state["compactions"]
|
||||
report["compaction_backend"]="Deterministic checkpoint of observed identifiers and retained-body location; no AI inference or quality claim."
|
||||
report["mode"]=args.mode
|
||||
report["request_tool_counts"] = [len(c["body"].get("tools", [])) for c in captured]
|
||||
assert not report["unexpected_server_requests"]
|
||||
report["status"] = "passed" if report["task_completed"] else "expected-incomplete-task"
|
||||
except Exception:
|
||||
report["status"] = "failed"
|
||||
report["failure"] = traceback.format_exc()[-6000:]
|
||||
finally:
|
||||
process.stdin.close()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), 15)
|
||||
except asyncio.TimeoutError:
|
||||
report["cleanup_failures"].append("app-server did not exit after stdin close; terminated owned process")
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), 5)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
await reader
|
||||
# app-server can acknowledge exit before its MCP child finishes EOF
|
||||
# cleanup. Wait on the allocated resource scope; never target a PID.
|
||||
try:
|
||||
groups = [line[3:] for line in Path("/proc/self/cgroup").read_text().splitlines()
|
||||
if line.startswith("0::")]
|
||||
assert len(groups) == 1
|
||||
group = Path("/sys/fs/cgroup") / groups[0].lstrip("/")
|
||||
assert re.fullmatch(r"rea-efficiency-[0-9a-f]{32}\.scope", group.name)
|
||||
observer = os.getppid()
|
||||
observer_args = Path(f"/proc/{observer}/cmdline").read_bytes().split(b"\0")
|
||||
assert any(Path(os.fsdecode(value)).name == "resource_payload.py" for value in observer_args if value)
|
||||
expected = {os.getpid(), observer}
|
||||
until = time.monotonic() + 10
|
||||
while True:
|
||||
actors = {int(value) for value in (group / "cgroup.procs").read_text().splitlines()}
|
||||
assert expected <= actors
|
||||
remaining = sorted(actors - expected)
|
||||
if not remaining or time.monotonic() >= until:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
report["owned_scope_drain"] = {"remaining_processes": remaining}
|
||||
if remaining:
|
||||
report["cleanup_failures"].append("owned MCP child did not settle after app-server exit")
|
||||
except (OSError, ValueError, AssertionError) as error:
|
||||
report["owned_scope_drain"] = {"status": "unavailable", "reason": type(error).__name__}
|
||||
report["cleanup_failures"].append("allocated resource-scope cleanup could not be verified")
|
||||
endpoint.shutdown()
|
||||
endpoint.server_close()
|
||||
endpoint_thread.join(5)
|
||||
stderr.close()
|
||||
report["appserver_exit_code"] = process.returncode
|
||||
if process.returncode != 0:
|
||||
report["cleanup_failures"].append(f"app-server exit code: {process.returncode}")
|
||||
(args.output / "transcript.json").write_text(json.dumps(transcript) + "\n")
|
||||
(args.output / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
print(json.dumps({key: report.get(key) for key in ["status", "failure", "catalog", "observations", "cleanup_failures", "appserver_exit_code"]}))
|
||||
return 0 if report["status"] in ["passed","expected-incomplete-task"] and not report["cleanup_failures"] else 1
|
||||
|
||||
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Accept only complete client delivery of the selected Frida status question."""
|
||||
|
||||
|
||||
def verify_frida_delivery(delivered, bundle, mode, budget, session):
|
||||
if '_invalid_json_payload' in delivered:
|
||||
assert mode == 'complete' and budget == 0, 'A truncated summary never completes the task'
|
||||
return None
|
||||
value = delivered['normalized_result']
|
||||
parent = next(e for e in bundle['records'] if e['evidence_id'] == value['parent_evidence_id']) if mode == 'summary' else delivered
|
||||
assert parent in bundle['records'] and parent['operation'] == 'frida_session_status'
|
||||
assert parent['authority'] == 'external-service' and parent['environment'] is None
|
||||
assert parent['raw_result'] is None and parent['subject'] is None
|
||||
assert parent['parameters'] == {'session_id': session['session_id']}
|
||||
facts = parent['normalized_result']
|
||||
assert facts['state'] == 'running' and not facts['messages_truncated']
|
||||
assert facts['session_id'] == session['session_id'] and facts['pid'] == session['pid']
|
||||
payloads = [m['payload'] for m in facts['messages'] if m.get('type') == 'send']
|
||||
header = next(p for p in payloads if p['kind'] == 'module')
|
||||
complete = next(p for p in payloads if p['kind'] == 'complete')
|
||||
count = 0
|
||||
for chunk in [p for p in payloads if p['kind'] == 'exports']:
|
||||
assert chunk['offset'] == count
|
||||
count += len(chunk['items'])
|
||||
assert count == header['total'] == complete['total'] and count > 1000
|
||||
assert facts['pid'] == header['pid']
|
||||
if mode == 'summary':
|
||||
assert delivered in bundle['records']
|
||||
assert delivered['subject'] is None and delivered['raw_result'] is None
|
||||
assert delivered['authority'] == parent['authority']
|
||||
assert value['parent_operation'] == parent['operation']
|
||||
assert value['source_parameters'] == parent['parameters'] and value['source_provider'] == parent['provider']
|
||||
assert value['parent_digest'] == parent['evidence_id'][3:] and value['artifact'] is None
|
||||
assert value['summary'] == {**{k: v for k, v in facts.items() if k != 'messages'}, 'messages_retained': len(facts['messages'])}
|
||||
assert value['unknowns'] and value['limitations']
|
||||
return {'state': facts['state'], 'messages_retained': len(facts['messages']),
|
||||
'messages_truncated': facts['messages_truncated'], 'exports': count,
|
||||
'parent_evidence_id': parent['evidence_id']}
|
||||
@@ -0,0 +1,81 @@
|
||||
"""A truncated summary must never qualify as a completed investigation."""
|
||||
from copy import deepcopy
|
||||
import unittest
|
||||
from command_observation_delivery import verify_command_delivery
|
||||
|
||||
|
||||
class CommandDelivery(unittest.TestCase):
|
||||
def test_truncation_is_accepted_only_for_complete_default_baseline(self):
|
||||
truncated = {'_invalid_json_payload': '{truncated'}
|
||||
self.assertFalse(verify_command_delivery(truncated, {}, 'complete', 0))
|
||||
for mode, budget in [
|
||||
('summary', 0), ('summary', 30000), ('complete', 30000),
|
||||
]:
|
||||
with self.subTest(mode=mode, budget=budget):
|
||||
with self.assertRaises(AssertionError):
|
||||
verify_command_delivery(truncated, {}, mode, budget)
|
||||
|
||||
def test_valid_complete_answer_can_complete_under_either_budget(self):
|
||||
parent = {'evidence_id': 'parent'}
|
||||
for budget in [0, 30000]:
|
||||
self.assertTrue(
|
||||
verify_command_delivery(parent, parent, 'complete', budget)
|
||||
)
|
||||
with self.assertRaises(AssertionError):
|
||||
verify_command_delivery({'error': 'failed command'}, parent, 'complete', 0)
|
||||
|
||||
def test_summary_must_preserve_every_record_and_accurate_stream_counts(self):
|
||||
report = {
|
||||
'command': 'disassemble /r entry', 'mi': '7^done',
|
||||
'records': ['~"🧪\\n"', '=async', 'unknown', '7^done'],
|
||||
'console': '🧪', 'target': '', 'log': '',
|
||||
'completion_status': 'complete', 'output_truncated': False,
|
||||
}
|
||||
parent = {
|
||||
'evidence_id': 'parent',
|
||||
'parameters': {'command': report['command']},
|
||||
'provider': {'id': 'gdb'}, 'analysis_profile': None,
|
||||
'normalized_result': report,
|
||||
}
|
||||
facts = {
|
||||
key: value for key, value in report.items()
|
||||
if key not in ('console', 'target', 'log')
|
||||
}
|
||||
selected = {
|
||||
'evidence_id': 'derived', 'raw_result': None, 'subject': None,
|
||||
'normalized_result': {
|
||||
'parent_evidence_id': 'parent', 'artifact': None,
|
||||
'source_analysis_profile_digest': None,
|
||||
'source_parameters': parent['parameters'],
|
||||
'source_provider': parent['provider'],
|
||||
'summary': {
|
||||
**facts,
|
||||
'available_counts': {
|
||||
'records': 4, 'console_utf16_units': 2,
|
||||
'target_utf16_units': 0, 'log_utf16_units': 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
self.assertTrue(verify_command_delivery(selected, parent, 'summary', 0))
|
||||
for damage in ['records', 'counts', 'parameters', 'profile']:
|
||||
bad = deepcopy(selected)
|
||||
result = bad['normalized_result']
|
||||
if damage == 'records':
|
||||
result['summary']['records'].pop(1)
|
||||
elif damage == 'counts':
|
||||
result['summary']['available_counts']['console_utf16_units'] = 1
|
||||
elif damage == 'profile':
|
||||
result['source_analysis_profile_digest'] = 'wrong'
|
||||
else:
|
||||
result['source_parameters'] = {}
|
||||
with self.subTest(damage=damage):
|
||||
with self.assertRaises(AssertionError):
|
||||
verify_command_delivery(bad, parent, 'summary', 0)
|
||||
parent['analysis_profile'] = {'digest': 'recorded-profile'}
|
||||
selected['normalized_result']['source_analysis_profile_digest'] = 'recorded-profile'
|
||||
self.assertTrue(verify_command_delivery(selected, parent, 'summary', 0))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Reject incomplete status delivery and incorrect retained-source claims."""
|
||||
from copy import deepcopy
|
||||
import unittest
|
||||
from frida_status_delivery import verify_frida_delivery
|
||||
|
||||
|
||||
class FridaDelivery(unittest.TestCase):
|
||||
def fixture(self):
|
||||
session = {'session_id': 'selected', 'pid': 12}
|
||||
payloads = [{'kind': 'module', 'pid': 12, 'total': 1001}, {'kind': 'exports', 'offset': 0, 'items': [None] * 1001}, {'kind': 'complete', 'total': 1001}]
|
||||
facts = {**session, 'state': 'running', 'messages_truncated': False, 'messages': [{'type': 'send', 'payload': p} for p in payloads]}
|
||||
parent = {'evidence_id': 'ev_parent', 'operation': 'frida_session_status', 'authority': 'external-service', 'environment': None, 'raw_result': None, 'subject': None, 'parameters': {'session_id': 'selected'}, 'provider': {'id': 'frida'}, 'normalized_result': facts}
|
||||
summary = {'evidence_id': 'ev_summary', 'subject': None, 'raw_result': None, 'authority': 'external-service', 'normalized_result': {
|
||||
'parent_evidence_id': 'ev_parent', 'parent_digest': 'parent', 'parent_operation': 'frida_session_status', 'artifact': None,
|
||||
'source_parameters': parent['parameters'], 'source_provider': parent['provider'], 'unknowns': ['historical snapshot'], 'limitations': ['parent retained'],
|
||||
'summary': {**{k: v for k, v in facts.items() if k != 'messages'}, 'messages_retained': 3}}}
|
||||
return session, parent, summary
|
||||
|
||||
def test_truncated_summary_never_qualifies(self):
|
||||
truncated = {'_invalid_json_payload': 'truncated'}
|
||||
self.assertIsNone(verify_frida_delivery(truncated, {}, 'complete', 0, {}))
|
||||
for mode, budget in [('summary', 0), ('summary', 300000), ('complete', 300000)]:
|
||||
with self.subTest(mode=mode, budget=budget), self.assertRaises(AssertionError):
|
||||
verify_frida_delivery(truncated, {}, mode, budget, {})
|
||||
|
||||
def test_complete_and_summary_require_correct_facts_and_source(self):
|
||||
session, parent, summary = self.fixture()
|
||||
for mode, delivered in [('complete', parent), ('summary', summary)]:
|
||||
self.assertEqual(verify_frida_delivery(delivered, {'records': [parent, summary]}, mode, 300000, session)['exports'], 1001)
|
||||
for field in ['messages_retained', 'state', 'messages', 'source_parameters', 'parent_digest', 'source_provider']:
|
||||
broken = deepcopy(summary)
|
||||
target = broken['normalized_result']['summary'] if field in ['messages_retained', 'state', 'messages'] else broken['normalized_result']
|
||||
target[field] = 'incorrect'
|
||||
with self.subTest(field=field), self.assertRaises(AssertionError):
|
||||
verify_frida_delivery(broken, {'records': [parent, broken]}, 'summary', 0, session)
|
||||
|
||||
def test_missing_native_chunk_and_wrong_session_fail(self):
|
||||
session, parent, _ = self.fixture()
|
||||
broken = deepcopy(parent)
|
||||
broken['normalized_result']['messages'][1]['payload']['items'].pop()
|
||||
with self.assertRaises(AssertionError):
|
||||
verify_frida_delivery(broken, {'records': [broken]}, 'complete', 300000, session)
|
||||
with self.assertRaises(AssertionError):
|
||||
verify_frida_delivery(parent, {'records': [parent]}, 'complete', 300000, {**session, 'session_id': 'other'})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+8
-2
@@ -119,7 +119,13 @@
|
||||
"precheck:pr": "npm run deps:check",
|
||||
"check:pr": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint:built format:check knip metadata:check test:run && npm run test:python && npm run docs:check && npm run jscpd && npm run scan:todos",
|
||||
"precheck:ci": "npm run deps:check",
|
||||
"check:ci": "node scripts/run-exclusive.mjs artifacts npm run check:ci:tasks && npm run verify:architecture-guards",
|
||||
"precheck:ci:fast": "npm run deps:check",
|
||||
"precheck:ci:deep": "npm run deps:check",
|
||||
"check:ci": "npm run check:ci:fast && npm run check:ci:deep",
|
||||
"check:ci:fast": "node scripts/run-exclusive.mjs artifacts npm run check:ci:fast:tasks",
|
||||
"check:ci:fast:tasks": "turbo run typecheck lint:built format:check",
|
||||
"check:ci:deep": "node scripts/run-exclusive.mjs artifacts npm run check:ci:deep:tasks && npm run verify:architecture-guards && npm run verify:test-discovery && npm run test:python",
|
||||
"check:ci:deep:tasks": "turbo run knip metadata:check jscpd scan:todos",
|
||||
"check:ci:tasks": "turbo run typecheck lint:built format:check knip metadata:check jscpd scan:todos",
|
||||
"ci:static-context": "node scripts/ci/static-task-context.mjs",
|
||||
"build:cached": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:skills",
|
||||
@@ -131,7 +137,7 @@
|
||||
"lint:details": "oxlint && npm run verify:module-boundaries",
|
||||
"lint:built": "npm run lint",
|
||||
"lint:fix": "oxlint --fix && npm run verify:module-boundaries",
|
||||
"knip": "knip",
|
||||
"knip": "knip --cache",
|
||||
"jscpd": "node scripts/ci/static-check-report.mjs duplicates",
|
||||
"jscpd:details": "jscpd src/ tests/ --no-tips",
|
||||
"scan:todos": "node scripts/scan-todos.mjs",
|
||||
|
||||
@@ -49,6 +49,7 @@ export function selectStaticCheckArtifact(
|
||||
page: unknown,
|
||||
run: { id?: string | number; head_sha?: string },
|
||||
runAttempt: number,
|
||||
name?: string,
|
||||
): unknown | null;
|
||||
|
||||
export function readStaticCheckArtifact(options: {
|
||||
|
||||
@@ -7,6 +7,11 @@ import { promisify } from "node:util";
|
||||
import { parseStaticTaskContext } from "./static-task-context.mjs";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
/** Job display name owns the artifact that contains that gate's diagnostics. */
|
||||
export const STATIC_EVIDENCE_ARTIFACTS = {
|
||||
"Static checks": "static-check-reports",
|
||||
"Deep static checks": "static-deep-check-reports",
|
||||
};
|
||||
const MAX_ARCHIVE_BYTES = 32 * 1024 * 1024;
|
||||
const MAX_MEMBER_BYTES = 4 * 1024 * 1024;
|
||||
const MAX_DIAGNOSTICS = 10_000;
|
||||
@@ -43,7 +48,8 @@ export function classifyFailure({
|
||||
"run_event_or_repository_identity_unverified",
|
||||
],
|
||||
() => [
|
||||
job?.name !== mainJob?.name || job?.name !== "Static checks",
|
||||
job?.name !== mainJob?.name ||
|
||||
!Object.hasOwn(STATIC_EVIDENCE_ARTIFACTS, job?.name ?? ""),
|
||||
"failed_job_not_the_same_static_gate",
|
||||
],
|
||||
() => [
|
||||
@@ -134,7 +140,12 @@ export function safeSha(value) {
|
||||
? value.toLowerCase()
|
||||
: null;
|
||||
}
|
||||
export function selectStaticCheckArtifact(page, run, runAttempt) {
|
||||
export function selectStaticCheckArtifact(
|
||||
page,
|
||||
run,
|
||||
runAttempt,
|
||||
name = STATIC_EVIDENCE_ARTIFACTS["Static checks"],
|
||||
) {
|
||||
if (runAttempt !== 1) return null;
|
||||
if (
|
||||
!Array.isArray(page?.artifacts) ||
|
||||
@@ -143,9 +154,7 @@ export function selectStaticCheckArtifact(page, run, runAttempt) {
|
||||
page.artifacts.length >= 100
|
||||
)
|
||||
return null;
|
||||
const matches = page.artifacts.filter(
|
||||
(artifact) => artifact.name === "static-check-reports",
|
||||
);
|
||||
const matches = page.artifacts.filter((artifact) => artifact.name === name);
|
||||
if (matches.length !== 1) return null;
|
||||
const [artifact] = matches;
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
export interface RetainedCacheEntry {
|
||||
readonly id: number;
|
||||
readonly key: string;
|
||||
readonly createdAt: number;
|
||||
readonly accessedAt: number;
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
export interface CacheListRecord {
|
||||
readonly id: number;
|
||||
readonly key: string;
|
||||
readonly version: string;
|
||||
readonly ref: string;
|
||||
readonly created_at: string;
|
||||
readonly last_accessed_at?: string;
|
||||
readonly size_in_bytes: number;
|
||||
}
|
||||
|
||||
export interface RetentionFamily {
|
||||
readonly prefix: string;
|
||||
readonly keepKey?: string;
|
||||
}
|
||||
|
||||
export type CacheRequest = (
|
||||
method: string,
|
||||
endpoint: string,
|
||||
) => Promise<unknown>;
|
||||
|
||||
export function assertRetentionPrefix(prefix: string): void;
|
||||
|
||||
export function selectPrefixDeletions(
|
||||
caches: readonly unknown[],
|
||||
family?: RetentionFamily,
|
||||
keepGenerations?: number,
|
||||
): RetainedCacheEntry[];
|
||||
|
||||
export function selectNpmDownloadDuplicateDeletions(
|
||||
caches: readonly unknown[],
|
||||
): RetainedCacheEntry[];
|
||||
|
||||
export function listAllCaches(
|
||||
request: CacheRequest,
|
||||
repository: string,
|
||||
prefix: string,
|
||||
): Promise<readonly CacheListRecord[]>;
|
||||
|
||||
export function retainCaches(options: {
|
||||
repository: string;
|
||||
request: CacheRequest;
|
||||
deleteCache: (id: number) => Promise<void>;
|
||||
families?: readonly RetentionFamily[];
|
||||
dedupeNpmDownloads?: boolean;
|
||||
dryRun?: boolean;
|
||||
}): Promise<{
|
||||
dryRun: boolean;
|
||||
plan: readonly {
|
||||
prefix: string;
|
||||
deletions: readonly RetainedCacheEntry[];
|
||||
}[];
|
||||
npmDeletions: readonly RetainedCacheEntry[];
|
||||
}>;
|
||||
@@ -0,0 +1,358 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { realpathSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const MAX_BUFFER = 8 * 1024 * 1024;
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
const MAX_PAGES = 10;
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
// Keep the newest snapshots, not only the key just saved. A concurrent job may
|
||||
// still be restoring the previous generation; deleting that id fails its restore.
|
||||
// The following trusted run drops anything older than those two.
|
||||
const RETAINED_CACHE_GENERATIONS = 2;
|
||||
|
||||
const PREFIX_PATTERNS = [
|
||||
/^rea-static-v1-[A-Za-z0-9]+-[A-Za-z0-9]+-$/u,
|
||||
/^rea-static-deep-v1-[A-Za-z0-9]+-[A-Za-z0-9]+-$/u,
|
||||
/^rea-portable-build-v2-[A-Za-z0-9]+-[A-Za-z0-9]+-$/u,
|
||||
/^knip-meta-v1-[A-Za-z0-9]+-$/u,
|
||||
];
|
||||
|
||||
/** Reject prefixes that could match npm downloads or another platform. */
|
||||
export function assertRetentionPrefix(prefix) {
|
||||
if (
|
||||
typeof prefix !== "string" ||
|
||||
!PREFIX_PATTERNS.some((pattern) => pattern.test(prefix))
|
||||
)
|
||||
throw new Error(
|
||||
"Cache retention prefix must be one Turbo family for one OS and architecture, or one Knip metadata family for one OS",
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCache(cache, index) {
|
||||
if (typeof cache !== "object" || cache === null || Array.isArray(cache))
|
||||
throw new Error(`Cache entry ${String(index + 1)} is malformed`);
|
||||
const { id, key, size_in_bytes: size } = cache;
|
||||
if (!Number.isSafeInteger(id) || id <= 0)
|
||||
throw new Error(`Cache entry ${String(index + 1)} has an invalid id`);
|
||||
if (typeof key !== "string" || key.length === 0 || /[\r\n]/u.test(key))
|
||||
throw new Error(`Cache entry ${String(index + 1)} has an invalid key`);
|
||||
const createdAt = Date.parse(cache.created_at);
|
||||
const accessedAt = Date.parse(cache.last_accessed_at ?? cache.created_at);
|
||||
if (!Number.isFinite(createdAt) || !Number.isFinite(accessedAt))
|
||||
throw new Error(
|
||||
`Cache entry ${String(index + 1)} has an invalid timestamp`,
|
||||
);
|
||||
if (!Number.isSafeInteger(size) || size < 0)
|
||||
throw new Error(`Cache entry ${String(index + 1)} has an invalid size`);
|
||||
// actions/cache restores a key only when this metadata hash matches.
|
||||
const version = cache.version;
|
||||
if (typeof version !== "string" || !/^[a-f0-9]{64}$/u.test(version))
|
||||
throw new Error(`Cache entry ${String(index + 1)} has an invalid version`);
|
||||
// The git ref is the cache's access scope. Another branch cannot use it.
|
||||
const ref = cache.ref;
|
||||
if (typeof ref !== "string" || !/^refs\/[^\s]+$/u.test(ref))
|
||||
throw new Error(`Cache entry ${String(index + 1)} has an invalid ref`);
|
||||
return { id, key, version, ref, createdAt, accessedAt, size };
|
||||
}
|
||||
|
||||
function sameStableIdentity(left, right) {
|
||||
return (
|
||||
left.key === right.key &&
|
||||
left.version === right.version &&
|
||||
left.ref === right.ref &&
|
||||
left.size === right.size &&
|
||||
left.createdAt === right.createdAt
|
||||
);
|
||||
}
|
||||
|
||||
// The list API orders by last access. A restore between pages can place one
|
||||
// id on two pages. That is still one cache: collapse it, or abort when the
|
||||
// pages disagree, before any id can be both kept and deleted.
|
||||
function uniqueById(entries) {
|
||||
const byId = new Map();
|
||||
for (const entry of entries) {
|
||||
const existing = byId.get(entry.id);
|
||||
if (existing === undefined) {
|
||||
byId.set(entry.id, entry);
|
||||
continue;
|
||||
}
|
||||
if (!sameStableIdentity(existing, entry))
|
||||
throw new Error(
|
||||
`Cache id ${String(entry.id)} was listed with inconsistent fields`,
|
||||
);
|
||||
if (entry.accessedAt > existing.accessedAt) byId.set(entry.id, entry);
|
||||
}
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
function byNewest(left, right) {
|
||||
if (left.createdAt !== right.createdAt)
|
||||
return right.createdAt - left.createdAt;
|
||||
return right.id - left.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose Turbo snapshot ids to delete. `keepKey` is protected even when it is
|
||||
* older than the retained generations. Keys outside the prefix abort retention.
|
||||
*/
|
||||
export function selectPrefixDeletions(
|
||||
caches,
|
||||
{ prefix, keepKey } = {},
|
||||
keepGenerations = RETAINED_CACHE_GENERATIONS,
|
||||
) {
|
||||
assertRetentionPrefix(prefix);
|
||||
if (!Number.isSafeInteger(keepGenerations) || keepGenerations < 1)
|
||||
throw new Error("Cache retention must keep at least one generation");
|
||||
if (keepKey !== undefined) {
|
||||
if (
|
||||
typeof keepKey !== "string" ||
|
||||
!keepKey.startsWith(prefix) ||
|
||||
keepKey === prefix
|
||||
)
|
||||
throw new Error("Cache retention keep key must extend its prefix");
|
||||
}
|
||||
const entries = uniqueById(caches.map(normalizeCache));
|
||||
for (const entry of entries)
|
||||
if (!entry.key.startsWith(prefix))
|
||||
throw new Error(
|
||||
`Refusing to evaluate a cache outside ${prefix}: ${entry.key}`,
|
||||
);
|
||||
const protectedIds = new Set(
|
||||
[...entries]
|
||||
.sort(byNewest)
|
||||
.slice(0, keepGenerations)
|
||||
.map((entry) => entry.id),
|
||||
);
|
||||
if (keepKey !== undefined)
|
||||
for (const entry of entries)
|
||||
if (entry.key === keepKey) protectedIds.add(entry.id);
|
||||
return entries.filter((entry) => !protectedIds.has(entry.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop extra copies of one npm restore target. Records that share a user key
|
||||
* but differ by cache version or git ref stay, because a workflow can restore
|
||||
* only a matching version inside its visible scope.
|
||||
*/
|
||||
export function selectNpmDownloadDuplicateDeletions(caches) {
|
||||
const entries = uniqueById(caches.map(normalizeCache));
|
||||
for (const entry of entries)
|
||||
if (!entry.key.startsWith("npm-download-"))
|
||||
throw new Error(
|
||||
`Refusing to evaluate a non-download cache: ${entry.key}`,
|
||||
);
|
||||
const groups = new Map();
|
||||
for (const entry of entries) {
|
||||
const identity = `${entry.version}\n${entry.ref}\n${entry.key}`;
|
||||
const group = groups.get(identity) ?? [];
|
||||
group.push(entry);
|
||||
groups.set(identity, group);
|
||||
}
|
||||
const deletions = [];
|
||||
for (const group of groups.values()) {
|
||||
if (group.length < 2) continue;
|
||||
const ranked = [...group].sort((left, right) => {
|
||||
if (left.accessedAt !== right.accessedAt)
|
||||
return right.accessedAt - left.accessedAt;
|
||||
if (left.createdAt !== right.createdAt)
|
||||
return right.createdAt - left.createdAt;
|
||||
return right.id - left.id;
|
||||
});
|
||||
deletions.push(...ranked.slice(1));
|
||||
}
|
||||
return deletions;
|
||||
}
|
||||
|
||||
export async function listAllCaches(request, repository, prefix) {
|
||||
const caches = [];
|
||||
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
||||
const body = await request(
|
||||
"GET",
|
||||
`repos/${repository}/actions/caches?per_page=${String(PAGE_SIZE)}&page=${String(page)}&key=${encodeURIComponent(prefix)}`,
|
||||
);
|
||||
if (
|
||||
typeof body !== "object" ||
|
||||
body === null ||
|
||||
Array.isArray(body) ||
|
||||
!Array.isArray(body.actions_caches)
|
||||
)
|
||||
throw new Error("GitHub cache list is malformed");
|
||||
caches.push(...body.actions_caches);
|
||||
if (body.actions_caches.length < PAGE_SIZE) return caches;
|
||||
}
|
||||
throw new Error(
|
||||
`Cache list for ${prefix} exceeded ${String(MAX_PAGES)} pages; deleted nothing`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* List every requested family before deleting. A malformed list or an unsafe
|
||||
* prefix leaves the repository caches unchanged.
|
||||
*/
|
||||
export async function retainCaches({
|
||||
repository,
|
||||
request,
|
||||
deleteCache,
|
||||
families = [],
|
||||
dedupeNpmDownloads = false,
|
||||
dryRun = false,
|
||||
}) {
|
||||
if (
|
||||
typeof repository !== "string" ||
|
||||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)
|
||||
)
|
||||
throw new Error(
|
||||
"CI_REPOSITORY must be an owner/name repository identifier",
|
||||
);
|
||||
for (const family of families) {
|
||||
assertRetentionPrefix(family.prefix);
|
||||
if (
|
||||
family.keepKey !== undefined &&
|
||||
(typeof family.keepKey !== "string" ||
|
||||
!family.keepKey.startsWith(family.prefix) ||
|
||||
family.keepKey === family.prefix)
|
||||
)
|
||||
throw new Error("Cache retention keep key must extend its prefix");
|
||||
}
|
||||
const plan = [];
|
||||
for (const family of families) {
|
||||
const caches = await listAllCaches(request, repository, family.prefix);
|
||||
plan.push({
|
||||
prefix: family.prefix,
|
||||
deletions: selectPrefixDeletions(caches, family),
|
||||
});
|
||||
}
|
||||
const npmDeletions = dedupeNpmDownloads
|
||||
? selectNpmDownloadDuplicateDeletions(
|
||||
await listAllCaches(request, repository, "npm-download-"),
|
||||
)
|
||||
: [];
|
||||
if (!dryRun)
|
||||
for (const entry of [
|
||||
...plan.flatMap((family) => family.deletions),
|
||||
...npmDeletions,
|
||||
])
|
||||
await deleteCache(entry.id);
|
||||
return { dryRun, plan, npmDeletions };
|
||||
}
|
||||
|
||||
function repositoryFromEnv(env) {
|
||||
const repository = env.CI_REPOSITORY;
|
||||
if (
|
||||
typeof repository !== "string" ||
|
||||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)
|
||||
)
|
||||
throw new Error(
|
||||
"CI_REPOSITORY must be an owner/name repository identifier",
|
||||
);
|
||||
return repository;
|
||||
}
|
||||
|
||||
function familyWithKeepKey(env, prefixName, keepName) {
|
||||
const prefix = env[prefixName];
|
||||
const keepKey = env[keepName];
|
||||
const hasPrefix = typeof prefix === "string" && prefix.length > 0;
|
||||
const hasKeepKey = typeof keepKey === "string" && keepKey.length > 0;
|
||||
if (!hasPrefix && !hasKeepKey) return undefined;
|
||||
if (!hasPrefix || !hasKeepKey)
|
||||
throw new Error(`${keepName} is required with ${prefixName}`);
|
||||
return { prefix, keepKey };
|
||||
}
|
||||
|
||||
function familiesFromEnv(env) {
|
||||
const families = [
|
||||
familyWithKeepKey(env, "CACHE_PREFIX", "CACHE_KEEP_KEY"),
|
||||
familyWithKeepKey(env, "CACHE_DEEP_PREFIX", "CACHE_DEEP_KEEP_KEY"),
|
||||
familyWithKeepKey(env, "CACHE_KNIP_PREFIX", "CACHE_KNIP_KEEP_KEY"),
|
||||
typeof env.CACHE_PORTABLE_PREFIX === "string" &&
|
||||
env.CACHE_PORTABLE_PREFIX.length > 0
|
||||
? { prefix: env.CACHE_PORTABLE_PREFIX }
|
||||
: undefined,
|
||||
].filter((family) => family !== undefined);
|
||||
if (families.length === 0 && env.DEDUPE_NPM_DOWNLOADS !== "true")
|
||||
throw new Error(
|
||||
"Cache retention requires CACHE_PREFIX or CACHE_PORTABLE_PREFIX or npm dedupe",
|
||||
);
|
||||
return families;
|
||||
}
|
||||
|
||||
function failureText(error) {
|
||||
if (typeof error !== "object" || error === null) return String(error);
|
||||
const stderr =
|
||||
"stderr" in error && error.stderr !== undefined ? String(error.stderr) : "";
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
return `${message}\n${stderr}`;
|
||||
}
|
||||
|
||||
async function githubRequest(method, endpoint, env) {
|
||||
const { stdout } = await execFileAsync(
|
||||
"gh",
|
||||
["api", "--method", method, endpoint],
|
||||
{
|
||||
env,
|
||||
encoding: "utf8",
|
||||
maxBuffer: MAX_BUFFER,
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
if (method === "DELETE") return undefined;
|
||||
try {
|
||||
return JSON.parse(stdout);
|
||||
} catch (cause) {
|
||||
throw new Error("GitHub cache API returned malformed JSON", { cause });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const env = process.env;
|
||||
const dryRun = env.CACHE_RETENTION_DRY_RUN === "true";
|
||||
const report = await retainCaches({
|
||||
repository: repositoryFromEnv(env),
|
||||
request: (method, endpoint) => githubRequest(method, endpoint, env),
|
||||
deleteCache: async (id) => {
|
||||
try {
|
||||
await githubRequest(
|
||||
"DELETE",
|
||||
`repos/${repositoryFromEnv(env)}/actions/caches/${String(id)}`,
|
||||
env,
|
||||
);
|
||||
} catch (error) {
|
||||
if (/404|not found/iu.test(failureText(error))) return;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
families: familiesFromEnv(env),
|
||||
dedupeNpmDownloads: env.DEDUPE_NPM_DOWNLOADS === "true",
|
||||
dryRun,
|
||||
});
|
||||
const summary = {
|
||||
dryRun: report.dryRun,
|
||||
families: report.plan.map((family) => ({
|
||||
prefix: family.prefix,
|
||||
deleteCount: family.deletions.length,
|
||||
deleteBytes: family.deletions.reduce((sum, entry) => sum + entry.size, 0),
|
||||
})),
|
||||
npmDuplicateDeleteCount: report.npmDeletions.length,
|
||||
npmDuplicateDeleteBytes: report.npmDeletions.reduce(
|
||||
(sum, entry) => sum + entry.size,
|
||||
0,
|
||||
),
|
||||
};
|
||||
process.stdout.write(`${JSON.stringify(summary)}\n`);
|
||||
}
|
||||
|
||||
function isDirectRun() {
|
||||
const entry = process.argv[1];
|
||||
if (!entry) return false;
|
||||
try {
|
||||
return import.meta.url === pathToFileURL(realpathSync(entry)).href;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDirectRun()) await main();
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
readStaticCheckArtifact,
|
||||
safeSha,
|
||||
selectStaticCheckArtifact,
|
||||
STATIC_EVIDENCE_ARTIFACTS,
|
||||
validRepoPath,
|
||||
} from "./artifacts.mjs";
|
||||
import { createGhApiRequest } from "./github-api.mjs";
|
||||
@@ -366,12 +367,18 @@ export function createGitHubApi({
|
||||
return page.jobs;
|
||||
},
|
||||
async getStaticEvidence(repository, run, job) {
|
||||
if (job.name !== "Static checks") return null;
|
||||
const artifactName = STATIC_EVIDENCE_ARTIFACTS[job.name];
|
||||
if (!artifactName) return null;
|
||||
if (run.run_attempt !== 1) return null;
|
||||
const page = await request(
|
||||
`repos/${repository}/actions/runs/${positiveId(run.id)}/artifacts?per_page=100&page=1`,
|
||||
);
|
||||
const artifact = selectStaticCheckArtifact(page, run, run.run_attempt);
|
||||
const artifact = selectStaticCheckArtifact(
|
||||
page,
|
||||
run,
|
||||
run.run_attempt,
|
||||
artifactName,
|
||||
);
|
||||
if (!artifact) return null;
|
||||
return await readStaticCheckArtifact({
|
||||
repository,
|
||||
|
||||
+33
-2
@@ -42,8 +42,18 @@ const matchesAny = (file, patterns) =>
|
||||
patterns.some((pattern) => matchesGlob(file, pattern));
|
||||
const selected = Object.fromEntries(flags.map((flag) => [flag, false]));
|
||||
const reasons = [];
|
||||
// Shared process, contract, CLI, and CI-script edits are not a specific provider.
|
||||
// Fast qualification keeps the portable suite and the Linux x64 runtime
|
||||
// baseline. Full qualification still selects every lane.
|
||||
const SHARED_BOUNDARY =
|
||||
/^(src\/process\/|src\/filesystem\/|src\/contracts\/tool|src\/server\/createServer|src\/cli\.ts|scripts\/ci\/)/;
|
||||
const full = (reason) => {
|
||||
for (const flag of flags) selected[flag] = true;
|
||||
if (tier === "full") {
|
||||
for (const flag of flags) selected[flag] = true;
|
||||
} else {
|
||||
selected.code = true;
|
||||
selected.runtime = true;
|
||||
}
|
||||
reasons.push(reason);
|
||||
};
|
||||
const git = (...args) =>
|
||||
@@ -196,6 +206,28 @@ if (tier === "reused") {
|
||||
selected.code = true;
|
||||
continue;
|
||||
}
|
||||
// Timing and report helpers stay on the portable suite. They are not a
|
||||
// runtime or provider change.
|
||||
if (PORTABLE_CI_REPORTERS.has(file)) {
|
||||
selected.code = true;
|
||||
continue;
|
||||
}
|
||||
// A shared boundary is not evidence that every provider or host changed.
|
||||
// One matching owner is path evidence. Several owners means the same
|
||||
// registration file is listed by many lanes; fast qualification does not
|
||||
// fan those out or start the multi-host package matrix.
|
||||
if (tier !== "full" && SHARED_BOUNDARY.test(file)) {
|
||||
selected.code = true;
|
||||
selected.runtime = true;
|
||||
const owners = Object.entries(SCOPES)
|
||||
.filter(([, patterns]) =>
|
||||
patterns.some((pattern) => matchesGlob(file, pattern)),
|
||||
)
|
||||
.map(([owner]) => owner);
|
||||
if (owners.length === 1) selected[owners[0]] = true;
|
||||
reasons.push(`Shared boundary on fast tier: ${file}`);
|
||||
continue;
|
||||
}
|
||||
const owners = Object.entries(SCOPES)
|
||||
.filter(([, patterns]) =>
|
||||
patterns.some((pattern) => matchesGlob(file, pattern)),
|
||||
@@ -226,7 +258,6 @@ if (tier === "reused") {
|
||||
}
|
||||
if (matchesAny(file, READINESS_SOURCES)) selected.readiness = true;
|
||||
if (file.startsWith("tests/")) continue;
|
||||
if (PORTABLE_CI_REPORTERS.has(file)) continue;
|
||||
if (
|
||||
/^(src\/process\/|src\/filesystem\/|src\/contracts\/tool|src\/server\/createServer|src\/cli\.ts|scripts\/ci\/)/.test(
|
||||
file,
|
||||
|
||||
@@ -29,6 +29,7 @@ if (
|
||||
if (outputs.tier === "full" && selectedScopes.length !== CI_FLAGS.length)
|
||||
throw new Error("Full qualification requires every canonical CI scope");
|
||||
const required = reused ? ["changes"] : ["changes", "static", "test"];
|
||||
if (outputs.tier === "full") required.push("static-deep");
|
||||
const lanes = {
|
||||
code: [
|
||||
"build",
|
||||
|
||||
@@ -15,4 +15,5 @@ export function recordStaticTaskContext(options?: {
|
||||
root?: string;
|
||||
npmCli?: string;
|
||||
outputDirectory?: string;
|
||||
script?: string;
|
||||
}): Promise<StaticTaskContext>;
|
||||
@@ -6,6 +6,10 @@ import { promisify } from "node:util";
|
||||
import { z } from "zod";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const STATIC_CONTEXT_SCRIPTS = new Set([
|
||||
"check:ci:fast:tasks",
|
||||
"check:ci:deep:tasks",
|
||||
]);
|
||||
const contextSchema = z.object({
|
||||
version: z.literal(1),
|
||||
sourceSha: z.string().regex(/^[a-f0-9]{40}$/u),
|
||||
@@ -30,7 +34,10 @@ export async function recordStaticTaskContext({
|
||||
root = process.cwd(),
|
||||
npmCli = process.env.npm_execpath,
|
||||
outputDirectory = join(root, ".cache/static-checks"),
|
||||
script = process.env.CI_STATIC_CONTEXT_SCRIPT ?? "check:ci:fast:tasks",
|
||||
} = {}) {
|
||||
if (!STATIC_CONTEXT_SCRIPTS.has(script))
|
||||
throw new Error("Static context script is not a known check");
|
||||
if (typeof npmCli !== "string" || !npmCli)
|
||||
throw new Error(
|
||||
"Run static context recording through npm run ci:static-context",
|
||||
@@ -44,7 +51,7 @@ export async function recordStaticTaskContext({
|
||||
const [graph, source, status, npmVersion, turbo] = await Promise.all([
|
||||
execute(
|
||||
process.execPath,
|
||||
[npmCli, "run", "--silent", "check:ci:tasks", "--", "--dry=json"],
|
||||
[npmCli, "run", "--silent", script, "--", "--dry=json"],
|
||||
options,
|
||||
),
|
||||
execute("git", ["rev-parse", "HEAD"], options),
|
||||
|
||||
+24
-1
@@ -94,7 +94,9 @@ if (!(await compiledRuntimeExists(runtimeFiles))) {
|
||||
const cliArgs = normalizeCliArguments(args);
|
||||
const outputArguments = validateCliOutputArguments(cliArgs);
|
||||
const configurationError = cliConfigurationError(process.env);
|
||||
if (configurationError !== undefined) {
|
||||
// Doctor is the readiness report for this failure. Other commands still stop
|
||||
// here so they do not run with an unusable log level.
|
||||
if (configurationError !== undefined && !isDoctorCommand(cliArgs)) {
|
||||
process.stderr.write(`${configurationError}\n`);
|
||||
process.exitCode = 1;
|
||||
} else if (!outputArguments.ok) {
|
||||
@@ -135,6 +137,27 @@ if (!(await compiledRuntimeExists(runtimeFiles))) {
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the first positional command is `doctor`, after global output flags. */
|
||||
function isDoctorCommand(arguments_) {
|
||||
const valuedFlags = new Set([
|
||||
"--format",
|
||||
"--filter-output",
|
||||
"--token-limit",
|
||||
"--token-offset",
|
||||
]);
|
||||
for (let index = 0; index < arguments_.length; index += 1) {
|
||||
const argument = arguments_[index];
|
||||
if (argument === "--") return arguments_[index + 1] === "doctor";
|
||||
if (valuedFlags.has(argument)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (argument.startsWith("-")) continue;
|
||||
return argument === "doctor";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reader that closes the pipe early, as `rea ... | head` does, has declined
|
||||
* the rest of the output. Stop writing without reporting a failure, and exit
|
||||
|
||||
@@ -140,8 +140,12 @@ const verifyParserConformance = async (app, trace) => {
|
||||
join(app, image.path),
|
||||
slice.architecture,
|
||||
);
|
||||
// otool -l prints LC_LAZY_LOAD_DYLIB_INFO without its linkedit install
|
||||
// name, so this comparison cannot expect that edge. The structural
|
||||
// reader tests own the payload.
|
||||
const edges = trace.edges.filter(
|
||||
({ loader, architecture, root }) =>
|
||||
({ loader, architecture, root, command }) =>
|
||||
command !== "LC_LAZY_LOAD_DYLIB_INFO" &&
|
||||
loader === image.path &&
|
||||
architecture === slice.architecture &&
|
||||
root ===
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import {
|
||||
ExecFileOutputCancelledError,
|
||||
execFileBytes,
|
||||
@@ -183,6 +184,42 @@ it("does not infer cancellation from a foreign ABORT_ERR code", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
"projects a truncated adb capture as retryable only for volatile output (%s)",
|
||||
async (volatileOutput) => {
|
||||
vi.mocked(execFileBytes).mockRejectedValueOnce(
|
||||
Object.assign(new Error("too much"), {
|
||||
code: 1,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
signal: null,
|
||||
killed: false,
|
||||
outputTruncated: true,
|
||||
}),
|
||||
);
|
||||
const result = await runAdbCapture(
|
||||
volatileOutput ? "read_adb_logcat" : "install_adb_package",
|
||||
{ device: "device" },
|
||||
"/host/adb",
|
||||
volatileOutput ? ["logcat", "-d"] : ["install", "app.apk"],
|
||||
{ timeoutMs: 100, maxOutputBytes: 1024, volatileOutput },
|
||||
undefined,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected a capture allowance refusal");
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(projected.retryable).toBe(volatileOutput);
|
||||
expect(projected.remediation.action).toContain(
|
||||
"The adb output capture allowance",
|
||||
);
|
||||
expect(projected.remediation.action).toContain(
|
||||
volatileOutput
|
||||
? "live output may change between requests"
|
||||
: "Repeating the unchanged request fails the same way.",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a physical failure even when the later abort reason is that same error", async () => {
|
||||
const controller = new AbortController();
|
||||
const failure = new Error("physical command failure");
|
||||
|
||||
+15
-2
@@ -9,6 +9,7 @@ import type {
|
||||
AnalysisCapturedOutput,
|
||||
AnalysisError,
|
||||
} from "../domain/analysisErrorBase.js";
|
||||
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
execFileBytes,
|
||||
@@ -86,6 +87,11 @@ export const adbArguments = (
|
||||
export interface AdbRunLimits {
|
||||
readonly timeoutMs: number;
|
||||
readonly maxOutputBytes: number;
|
||||
/**
|
||||
* Live device observations can shrink between calls. Mutation commands omit
|
||||
* this so a truncated result stays non-retryable.
|
||||
*/
|
||||
readonly volatileOutput?: boolean;
|
||||
}
|
||||
|
||||
/** Keep the selected device separate from the file addressed by the command. */
|
||||
@@ -139,7 +145,14 @@ export const runAdbCapture = async (
|
||||
"memory",
|
||||
`adb output exceeded the ${String(limits.maxOutputBytes)}-byte capture budget`,
|
||||
{ max_output_bytes: limits.maxOutputBytes },
|
||||
{ cause },
|
||||
{
|
||||
cause,
|
||||
...fixedAllowanceOptions(
|
||||
"The adb output capture allowance",
|
||||
limits.volatileOutput === true ? "live" : "capture",
|
||||
),
|
||||
...(limits.volatileOutput === true ? { retryable: true } : {}),
|
||||
},
|
||||
),
|
||||
);
|
||||
const stderr = failure.stderr.trim();
|
||||
@@ -268,7 +281,7 @@ export const probeAdbPathExistence = async (
|
||||
{ device: serial, path },
|
||||
binary,
|
||||
adbArguments(serial, ["shell", "test", "-e", path]),
|
||||
limits,
|
||||
{ ...limits, volatileOutput: true },
|
||||
signal,
|
||||
);
|
||||
if (probed.ok) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "../process/ExecFileOutput.js";
|
||||
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
|
||||
import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
|
||||
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { adbCommandCapturedOutput } from "./AdbCommand.js";
|
||||
|
||||
@@ -146,7 +147,13 @@ export const inspectAdbClient = async (
|
||||
"memory",
|
||||
`adb version output exceeded the ${String(VERSION_MAX_OUTPUT_BYTES)}-byte capture budget`,
|
||||
{ max_output_bytes: VERSION_MAX_OUTPUT_BYTES },
|
||||
options,
|
||||
{
|
||||
...options,
|
||||
...fixedAllowanceOptions(
|
||||
"The adb version-output capture allowance",
|
||||
"capture",
|
||||
),
|
||||
},
|
||||
);
|
||||
if (failure?.code === "ETIMEDOUT")
|
||||
throw new AnalysisTimeoutError(
|
||||
|
||||
+15
-3
@@ -1326,7 +1326,7 @@ export class AdbProvider {
|
||||
"serial" in request.input ? request.input.serial : undefined,
|
||||
["shell", ...arguments_],
|
||||
),
|
||||
limits,
|
||||
{ ...limits, volatileOutput: true },
|
||||
signal,
|
||||
);
|
||||
switch (operation) {
|
||||
@@ -1346,6 +1346,7 @@ export class AdbProvider {
|
||||
{
|
||||
timeoutMs: DEVICES_TIMEOUT_MS,
|
||||
maxOutputBytes: DEVICES_MAX_OUTPUT_BYTES,
|
||||
volatileOutput: true,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
@@ -1382,6 +1383,7 @@ export class AdbProvider {
|
||||
{
|
||||
timeoutMs: SHELL_TIMEOUT_MS,
|
||||
maxOutputBytes: GETPROP_MAX_OUTPUT_BYTES,
|
||||
volatileOutput: true,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
@@ -1631,6 +1633,7 @@ export class AdbProvider {
|
||||
{
|
||||
timeoutMs: LISTING_TIMEOUT_MS,
|
||||
maxOutputBytes: LISTING_MAX_OUTPUT_BYTES,
|
||||
volatileOutput: true,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
@@ -1917,6 +1920,7 @@ export class AdbProvider {
|
||||
{
|
||||
timeoutMs: PM_PATH_TIMEOUT_MS,
|
||||
maxOutputBytes: PM_PATH_MAX_OUTPUT_BYTES,
|
||||
volatileOutput: true,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
@@ -2704,7 +2708,11 @@ export class AdbProvider {
|
||||
"android.intent.category.LAUNCHER",
|
||||
input.package,
|
||||
]),
|
||||
{ timeoutMs: SHELL_TIMEOUT_MS, maxOutputBytes: PM_PATH_MAX_OUTPUT_BYTES },
|
||||
{
|
||||
timeoutMs: SHELL_TIMEOUT_MS,
|
||||
maxOutputBytes: PM_PATH_MAX_OUTPUT_BYTES,
|
||||
volatileOutput: true,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
if (!resolved.ok) return resolved;
|
||||
@@ -2918,7 +2926,11 @@ export class AdbProvider {
|
||||
{ device: input.serial },
|
||||
selection.binary,
|
||||
adbArguments(input.serial, ["exec-out", "screencap", "-p"]),
|
||||
{ timeoutMs: SCREEN_TIMEOUT_MS, maxOutputBytes: SCREEN_MAX_OUTPUT_BYTES },
|
||||
{
|
||||
timeoutMs: SCREEN_TIMEOUT_MS,
|
||||
maxOutputBytes: SCREEN_MAX_OUTPUT_BYTES,
|
||||
volatileOutput: true,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
if (!captured.ok) return captured;
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
AnalysisUnsupportedTargetError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
@@ -194,6 +195,10 @@ const apktoolCommandFailure = (
|
||||
{
|
||||
cause,
|
||||
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
||||
...fixedAllowanceOptions(
|
||||
"The apktool output capture allowance",
|
||||
"capture",
|
||||
),
|
||||
},
|
||||
);
|
||||
// A physical launcher/capture failure does not establish target refusal.
|
||||
|
||||
@@ -3,9 +3,19 @@ import type { BrowserObservationPort } from "./BrowserObservationPort.js";
|
||||
import { listBrowserTargets } from "./BrowserObservationService.js";
|
||||
import type { BrowserTargetList } from "../domain/browserObservationSchemas.js";
|
||||
import { parseEvidence } from "../domain/evidence.js";
|
||||
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import {
|
||||
expectDroppedPartial,
|
||||
expectKeptPartial,
|
||||
expectOutputError,
|
||||
expectOwnerError,
|
||||
} from "./ownerObservationExpectation.fixture.js";
|
||||
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { passiveNetworkCapture } from "../../tests/fixtures/passiveNetworkCapture.js";
|
||||
import { inspectWebPage } from "./BrowserObservationService.js";
|
||||
import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js";
|
||||
@@ -71,11 +81,7 @@ it("does not let late cancellation hide invalid completed browser output", async
|
||||
provider(async () => ok(malformed)),
|
||||
input,
|
||||
);
|
||||
expect(output).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
});
|
||||
if (!output.ok) expect(output.error).toBeInstanceOf(AnalysisOutputError);
|
||||
expectOutputError(output);
|
||||
|
||||
const controller = new AbortController();
|
||||
const late = await listBrowserTargets(
|
||||
@@ -86,11 +92,7 @@ it("does not let late cancellation hide invalid completed browser output", async
|
||||
input,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(late).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
});
|
||||
if (!late.ok) expect(late.error).toBeInstanceOf(AnalysisOutputError);
|
||||
expectOutputError(late);
|
||||
});
|
||||
|
||||
it("converts a raw page inspection retained after browser cleanup failure", async () => {
|
||||
@@ -215,3 +217,162 @@ it("preserves a prior caller reason without admitting browser work", async () =>
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
});
|
||||
|
||||
it("keeps page cancellation when the retained inspection is incomplete", async () => {
|
||||
const reason = { requested: "stop during page inspection" };
|
||||
const partial = {
|
||||
target: { target_id: "page" },
|
||||
network: {},
|
||||
capture_window: {},
|
||||
};
|
||||
const failure = new AnalysisCancelledError("inspect_web_page", {
|
||||
cause: reason,
|
||||
partialObservation: partial as never,
|
||||
});
|
||||
const browser = {
|
||||
...provider(async () => ok(report)),
|
||||
inspectPage: async () => err(failure),
|
||||
} as unknown as BrowserObservationPort;
|
||||
const pageInput = inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: input.cdp_endpoint.recorded,
|
||||
allowed_origins: [],
|
||||
target_id: "page",
|
||||
observation_ms: 0,
|
||||
include_script_sources: false,
|
||||
});
|
||||
const result = await inspectWebPage(browser, pageInput);
|
||||
expectKeptPartial(expectOwnerError(result, failure, reason), partial);
|
||||
});
|
||||
|
||||
it("keeps bundle cancellation and an earlier failure when retained facts are incomplete", async () => {
|
||||
const reason = { requested: "stop during bundle analysis" };
|
||||
const partial = { observations: [], capture: { status: "partial" } };
|
||||
const cancelled = new AnalysisCancelledError("analyze_web_bundle", {
|
||||
cause: reason,
|
||||
partialObservation: partial as never,
|
||||
});
|
||||
const timedOut = new BrowserObservationError(
|
||||
"analyze_web_bundle",
|
||||
"timeout",
|
||||
{ partialObservation: partial as never },
|
||||
);
|
||||
const selected = analyzeWebBundleInputSchema.parse({
|
||||
cdp_endpoint: input.cdp_endpoint.recorded,
|
||||
target_id: "page",
|
||||
observation_ms: 0,
|
||||
});
|
||||
for (const failure of [cancelled, timedOut]) {
|
||||
const browser = {
|
||||
...provider(async () => ok(report)),
|
||||
analyzeBundle: async () => err(failure),
|
||||
} as unknown as BrowserObservationPort;
|
||||
const result = await analyzeWebBundle(browser, selected);
|
||||
expectKeptPartial(
|
||||
expectOwnerError(result, failure, failure.cause),
|
||||
partial,
|
||||
failure === timedOut ? "provider_timeout" : "cancelled",
|
||||
);
|
||||
}
|
||||
expect(projectAnalysisError(cancelled).code).toBe("cancelled");
|
||||
});
|
||||
|
||||
const pageInput = () =>
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: input.cdp_endpoint.recorded,
|
||||
allowed_origins: [],
|
||||
target_id: "page",
|
||||
observation_ms: 0,
|
||||
include_script_sources: false,
|
||||
});
|
||||
|
||||
const bundleInput = () =>
|
||||
analyzeWebBundleInputSchema.parse({
|
||||
cdp_endpoint: input.cdp_endpoint.recorded,
|
||||
target_id: "page",
|
||||
observation_ms: 0,
|
||||
});
|
||||
|
||||
const browserReturning = (
|
||||
failure: BrowserObservationError | AnalysisCancelledError,
|
||||
) =>
|
||||
({
|
||||
...provider(async () => ok(report)),
|
||||
inspectPage: async () => err(failure),
|
||||
analyzeBundle: async () => err(failure),
|
||||
}) as unknown as BrowserObservationPort;
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "bundle bytes",
|
||||
operation: "analyze_web_bundle" as const,
|
||||
partial: { observations: [], capture: { bytes: Number.NaN } },
|
||||
},
|
||||
{
|
||||
name: "bundle observations",
|
||||
operation: "analyze_web_bundle" as const,
|
||||
partial: { observations: [undefined], capture: {} },
|
||||
},
|
||||
{
|
||||
name: "page requests",
|
||||
operation: "inspect_web_page" as const,
|
||||
partial: {
|
||||
target: { target_id: "page" },
|
||||
network: { requests: Number.NaN },
|
||||
capture_window: {},
|
||||
},
|
||||
},
|
||||
])(
|
||||
"drops a non-JSON $name partial and keeps cancellation primary",
|
||||
async ({ operation, partial }) => {
|
||||
const reason = { requested: `stop ${operation}` };
|
||||
const failure = new AnalysisCancelledError(operation, {
|
||||
cause: reason,
|
||||
partialObservation: partial as never,
|
||||
});
|
||||
const result =
|
||||
operation === "inspect_web_page"
|
||||
? await inspectWebPage(browserReturning(failure), pageInput())
|
||||
: await analyzeWebBundle(browserReturning(failure), bundleInput());
|
||||
const error = expectOwnerError(result, failure, reason);
|
||||
expectDroppedPartial(
|
||||
error,
|
||||
"Browser provider retained a non-JSON incomplete observation.",
|
||||
);
|
||||
expect(projectAnalysisError(error).details).not.toHaveProperty(
|
||||
"partial_observation",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "schema-shaped page",
|
||||
partial: {
|
||||
target: { target_id: "page" },
|
||||
network: { requests: Number.NaN },
|
||||
capture_window: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "shapeless page",
|
||||
partial: { requests: Number.NaN },
|
||||
},
|
||||
])(
|
||||
"drops a non-JSON $name partial and keeps timeout primary",
|
||||
async ({ partial }) => {
|
||||
const failure = new BrowserObservationError("inspect_web_page", "timeout", {
|
||||
partialObservation: partial as never,
|
||||
});
|
||||
const error = expectOwnerError(
|
||||
await inspectWebPage(browserReturning(failure), pageInput()),
|
||||
failure,
|
||||
undefined,
|
||||
);
|
||||
expectDroppedPartial(
|
||||
error,
|
||||
"Browser provider retained a non-JSON incomplete observation.",
|
||||
"provider_timeout",
|
||||
);
|
||||
expect(projectAnalysisError(error).category).toBe("timeout");
|
||||
},
|
||||
);
|
||||
@@ -25,9 +25,11 @@ import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
import type { BrowserObservationPort } from "./BrowserObservationPort.js";
|
||||
import { createBrowserEvidence } from "./BrowserEvidence.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
|
||||
import {
|
||||
completeProviderEvidence,
|
||||
keepIncompleteObservation,
|
||||
} from "./ProviderOperationExecution.js";
|
||||
import { webPageInspectionSchema } from "../domain/browserObservationSchemas.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
|
||||
/** List browser targets within the endpoint and origin scope in the request. */
|
||||
export const listBrowserTargets = async (
|
||||
@@ -275,7 +277,10 @@ const browserEvidence = (
|
||||
);
|
||||
};
|
||||
|
||||
/** Retain the completed capture or bundle facts under their actual operation. */
|
||||
const NON_JSON_BROWSER_OBSERVATION =
|
||||
"Browser provider retained a non-JSON incomplete observation.";
|
||||
|
||||
/** Promote completed page or bundle facts; keep only a JSON-safe incomplete observation. */
|
||||
const retainBrowserObservation = (
|
||||
error: AnalysisError,
|
||||
input: InspectWebPageInput | AnalyzeWebBundleInput,
|
||||
@@ -291,59 +296,42 @@ const retainBrowserObservation = (
|
||||
"capture" in partial
|
||||
) {
|
||||
const bundle = webBundleAnalysisSchema.safeParse(partial);
|
||||
if (!bundle.success) {
|
||||
const malformed = new AnalysisOutputError(
|
||||
errorOperation,
|
||||
"Browser provider retained malformed completed bundle facts.",
|
||||
{ cause: bundle.error },
|
||||
);
|
||||
return err(
|
||||
error.cleanupIncomplete
|
||||
? analysisErrorWithCleanupFailure(
|
||||
malformed,
|
||||
error,
|
||||
errorOperation,
|
||||
).bindCleanupDependencies([malformed, error])
|
||||
: malformed,
|
||||
if (bundle.success) {
|
||||
error.partialObservation = createBrowserEvidence(
|
||||
"analyze_web_bundle",
|
||||
input,
|
||||
bundle.data,
|
||||
provider.identity(),
|
||||
);
|
||||
return err(error);
|
||||
}
|
||||
error.partialObservation = createBrowserEvidence(
|
||||
"analyze_web_bundle",
|
||||
input,
|
||||
bundle.data,
|
||||
provider.identity(),
|
||||
);
|
||||
return err(error);
|
||||
}
|
||||
if (
|
||||
partial === undefined ||
|
||||
"evidence_id" in partial ||
|
||||
!("target" in partial) ||
|
||||
!("network" in partial) ||
|
||||
!("capture_window" in partial)
|
||||
)
|
||||
return err(error);
|
||||
const inspection = webPageInspectionSchema.safeParse(partial);
|
||||
if (!inspection.success) {
|
||||
if (error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
errorOperation,
|
||||
"Browser provider retained a malformed completed page inspection.",
|
||||
{ cause: inspection.error },
|
||||
),
|
||||
} else if (
|
||||
partial !== undefined &&
|
||||
!("evidence_id" in partial) &&
|
||||
"target" in partial &&
|
||||
"network" in partial &&
|
||||
"capture_window" in partial
|
||||
) {
|
||||
const inspection = webPageInspectionSchema.safeParse(partial);
|
||||
if (inspection.success) {
|
||||
const pageInput: InspectWebPageInput =
|
||||
"fetch_source_maps" in input
|
||||
? (({ fetch_source_maps: _fetchSourceMaps, ...page }) => page)(input)
|
||||
: input;
|
||||
error.partialObservation = createBrowserEvidence(
|
||||
"inspect_web_page",
|
||||
pageInput,
|
||||
inspection.data,
|
||||
provider.identity(),
|
||||
);
|
||||
return err(error);
|
||||
return err(error);
|
||||
}
|
||||
}
|
||||
const pageInput: InspectWebPageInput =
|
||||
"fetch_source_maps" in input
|
||||
? (({ fetch_source_maps: _fetchSourceMaps, ...page }) => page)(input)
|
||||
: input;
|
||||
error.partialObservation = createBrowserEvidence(
|
||||
"inspect_web_page",
|
||||
pageInput,
|
||||
inspection.data,
|
||||
provider.identity(),
|
||||
return err(
|
||||
keepIncompleteObservation(
|
||||
error,
|
||||
errorOperation,
|
||||
NON_JSON_BROWSER_OBSERVATION,
|
||||
),
|
||||
);
|
||||
return err(error);
|
||||
};
|
||||
@@ -1,13 +1,26 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
||||
import { parseEvidence } from "../domain/evidence.js";
|
||||
import { browserScenarioInputSchema } from "../domain/browserScenario.js";
|
||||
import { err } from "../domain/result.js";
|
||||
import type {
|
||||
BrowserScenarioCapture,
|
||||
BrowserScenarioPartialObservation,
|
||||
} from "../domain/browserScenarioCapture.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { scriptScenarioFixture } from "../../tests/fixtures/webScriptCapture.js";
|
||||
import type { BrowserScenarioCapturePort } from "./BrowserScenarioCapturePort.js";
|
||||
import { captureBrowserScenario } from "./BrowserScenarioCaptureService.js";
|
||||
import {
|
||||
expectDroppedPartial,
|
||||
expectKeptPartial,
|
||||
expectOwnerError,
|
||||
} from "./ownerObservationExpectation.fixture.js";
|
||||
|
||||
const scenario = browserScenarioInputSchema.parse({
|
||||
browser: { mode: "launch", executable_path: "/fixture/chrome" },
|
||||
@@ -15,6 +28,34 @@ const scenario = browserScenarioInputSchema.parse({
|
||||
actions: [{ step_id: "settle", action: "wait_for_timeout", duration_ms: 1 }],
|
||||
});
|
||||
const capture = scriptScenarioFixture();
|
||||
const scenarioIdentity = {
|
||||
id: "scenario-fixture",
|
||||
name: "Scenario fixture",
|
||||
version: "1",
|
||||
};
|
||||
const runScenario = (
|
||||
failure: AnalysisError,
|
||||
id = { id: "fixture", name: "fixture", version: "1" },
|
||||
) =>
|
||||
captureBrowserScenario(
|
||||
{
|
||||
identity: () => id,
|
||||
captureScenario: async () => err(failure),
|
||||
},
|
||||
scenario,
|
||||
);
|
||||
const expectRetainedCapture = (
|
||||
result: Awaited<ReturnType<typeof runScenario>>,
|
||||
failure: AnalysisError,
|
||||
) => {
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected the retained capture");
|
||||
expect(result.error).toBe(failure);
|
||||
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
||||
operation: "capture_browser_scenario",
|
||||
normalized_result: capture,
|
||||
});
|
||||
};
|
||||
|
||||
it("converts a retained raw scenario capture into partial Evidence", async () => {
|
||||
const failure = new AnalysisCancelledError("capture_browser_scenario", {
|
||||
@@ -23,23 +64,7 @@ it("converts a retained raw scenario capture into partial Evidence", async () =>
|
||||
capture,
|
||||
},
|
||||
});
|
||||
const provider: BrowserScenarioCapturePort = {
|
||||
identity: () => ({
|
||||
id: "scenario-fixture",
|
||||
name: "Scenario fixture",
|
||||
version: "1",
|
||||
}),
|
||||
captureScenario: async () => err(failure),
|
||||
};
|
||||
const result = await captureBrowserScenario(provider, scenario);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBe(failure);
|
||||
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
||||
operation: "capture_browser_scenario",
|
||||
normalized_result: capture,
|
||||
});
|
||||
}
|
||||
expectRetainedCapture(await runScenario(failure, scenarioIdentity), failure);
|
||||
});
|
||||
|
||||
it("converts a raw scenario capture retained after cleanup failure", async () => {
|
||||
@@ -57,24 +82,9 @@ it("converts a raw scenario capture retained after cleanup failure", async () =>
|
||||
},
|
||||
},
|
||||
);
|
||||
const provider: BrowserScenarioCapturePort = {
|
||||
identity: () => ({
|
||||
id: "scenario-fixture",
|
||||
name: "Scenario fixture",
|
||||
version: "1",
|
||||
}),
|
||||
captureScenario: async () => err(failure),
|
||||
};
|
||||
const result = await captureBrowserScenario(provider, scenario);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBe(failure);
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
||||
operation: "capture_browser_scenario",
|
||||
normalized_result: capture,
|
||||
});
|
||||
}
|
||||
const result = await runScenario(failure, scenarioIdentity);
|
||||
expectRetainedCapture(result, failure);
|
||||
if (!result.ok) expect(result.error.cleanupIncomplete).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -103,3 +113,63 @@ it.each([
|
||||
expect(calls).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps startup cancellation and its incomplete capture", async () => {
|
||||
const reason = { requested: "stop during initial navigation" };
|
||||
const completed = scriptScenarioFixture();
|
||||
const {
|
||||
completeness: _completeness,
|
||||
steps: _steps,
|
||||
browser,
|
||||
...retained
|
||||
} = completed;
|
||||
const partial: BrowserScenarioPartialObservation = {
|
||||
kind: "browser-scenario-observation",
|
||||
capture: {
|
||||
...retained,
|
||||
browser: { ...browser, cleanup: "incomplete" },
|
||||
steps: [],
|
||||
},
|
||||
};
|
||||
const failure = new AnalysisCancelledError("capture_browser_scenario", {
|
||||
cause: reason,
|
||||
partialObservation: partial,
|
||||
});
|
||||
const result = await runScenario(failure);
|
||||
expectKeptPartial(expectOwnerError(result, failure, reason), partial);
|
||||
});
|
||||
|
||||
it.each([{ steps: [undefined] }, { started_at: Number.NaN }])(
|
||||
"drops a non-JSON incomplete scenario capture and keeps cancellation primary",
|
||||
async (capturePartial) => {
|
||||
const reason = { requested: "stop during initial navigation" };
|
||||
const partial: BrowserScenarioPartialObservation = {
|
||||
kind: "browser-scenario-observation",
|
||||
capture: capturePartial as never,
|
||||
};
|
||||
const failure = new AnalysisCancelledError("capture_browser_scenario", {
|
||||
cause: reason,
|
||||
partialObservation: partial,
|
||||
});
|
||||
const result = await runScenario(failure);
|
||||
expectDroppedPartial(
|
||||
expectOwnerError(result, failure, reason),
|
||||
"Browser scenario provider retained a non-JSON incomplete observation.",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a successful capture that is not a completed scenario", async () => {
|
||||
const provider: BrowserScenarioCapturePort = {
|
||||
identity: () => ({ id: "fixture", name: "fixture", version: "1" }),
|
||||
captureScenario: async () => ok({} as BrowserScenarioCapture),
|
||||
};
|
||||
const result = await captureBrowserScenario(provider, scenario);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected a completed-capture refusal");
|
||||
expect(result.error).toBeInstanceOf(AnalysisOutputError);
|
||||
expect(result.error.message).toContain(
|
||||
"returned a malformed completed capture",
|
||||
);
|
||||
expect(result.error.partialObservation).toBeUndefined();
|
||||
});
|
||||
@@ -11,7 +11,10 @@ import type { Evidence } from "../domain/evidence.js";
|
||||
import type { BrowserScenario } from "../domain/browserScenario.js";
|
||||
import { err, type Result } from "../domain/result.js";
|
||||
import { browserScenarioCaptureSchema } from "../domain/browserScenarioCapture.js";
|
||||
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
|
||||
import {
|
||||
completeProviderEvidence,
|
||||
keepIncompleteObservation,
|
||||
} from "./ProviderOperationExecution.js";
|
||||
|
||||
const OPERATION = "capture_browser_scenario" as const;
|
||||
const PROVIDER_ID = "rea-playwright-browser-scenario";
|
||||
@@ -43,6 +46,8 @@ export const captureBrowserScenario = async (
|
||||
partial.kind === "browser-scenario-observation"
|
||||
) {
|
||||
const report = browserScenarioCaptureSchema.safeParse(partial.capture);
|
||||
// Only a completed capture becomes Evidence. A JSON-safe incomplete
|
||||
// observation stays on the owner error, including typed cancellation.
|
||||
if (
|
||||
report.success &&
|
||||
report.data.steps.every(({ status }) => status === "completed")
|
||||
@@ -52,19 +57,16 @@ export const captureBrowserScenario = async (
|
||||
report.data,
|
||||
provider.identity(),
|
||||
);
|
||||
} else if (!report.success) {
|
||||
if (captured.error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Browser scenario provider retained a malformed completed capture.",
|
||||
{ cause: report.error },
|
||||
),
|
||||
);
|
||||
return captured;
|
||||
}
|
||||
}
|
||||
return captured;
|
||||
return err(
|
||||
keepIncompleteObservation(
|
||||
captured.error,
|
||||
OPERATION,
|
||||
"Browser scenario provider retained a non-JSON incomplete observation.",
|
||||
),
|
||||
);
|
||||
}
|
||||
const report = browserScenarioCaptureSchema.safeParse(captured.value);
|
||||
if (!report.success)
|
||||
|
||||
@@ -89,7 +89,7 @@ describe("doctor Hopper startup timeout", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps an invalid Hopper timeout informational outside the Hopper scope", async () => {
|
||||
it("keeps an invalid Hopper timeout informational when no provider is selected", async () => {
|
||||
const selectedHost = timeoutHost("0");
|
||||
const result = await runDoctor(undefined, selectedHost, { providers: [] });
|
||||
expect(result.healthy).toBe(true);
|
||||
@@ -100,6 +100,273 @@ describe("doctor Hopper startup timeout", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires an invalid Hopper timeout for a ready non-Hopper provider", async () => {
|
||||
const ghidra = {
|
||||
providerInspections: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: "ghidra" as const,
|
||||
configured: true,
|
||||
available: true,
|
||||
providerVersion: "12.1.4",
|
||||
registrationEnvironment: { GHIDRA_INSTALL_DIR: "/tools/ghidra" },
|
||||
checks: [
|
||||
{
|
||||
name: "configuration",
|
||||
ok: true,
|
||||
code: null,
|
||||
detail: "/tools/ghidra",
|
||||
remediation: null,
|
||||
classification: "config_drift" as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
};
|
||||
const blocked = await runDoctor(undefined, timeoutHost("abc", ghidra), {
|
||||
providers: ["ghidra"],
|
||||
});
|
||||
expect(blocked.healthy).toBe(false);
|
||||
expect(blocked.scope_checks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "hopper-startup-timeout",
|
||||
ok: false,
|
||||
classification: "config_drift",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
blocked.scope_checks.filter(
|
||||
({ name }) => name === "hopper-startup-timeout",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
|
||||
const ready = await runDoctor(undefined, timeoutHost("120000", ghidra), {
|
||||
providers: ["ghidra"],
|
||||
});
|
||||
expect(ready.healthy).toBe(true);
|
||||
expect(ready.scope_checks).toContainEqual(
|
||||
expect.objectContaining({ name: "hopper-startup-timeout", ok: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("doctor native analysis configuration", () => {
|
||||
const configurationHost = (
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
): DoctorHost => ({
|
||||
...systemDoctorHost({ environment: { ...environment } }),
|
||||
...host({
|
||||
installationPaths: () => Promise.resolve([]),
|
||||
clientRegistrations: () => Promise.resolve([]),
|
||||
}),
|
||||
});
|
||||
|
||||
it.each([
|
||||
["REA_ANALYSIS_PROVIDER", "Hopper"],
|
||||
["REA_GHIDRA_STARTUP_TIMEOUT_MS", "abc"],
|
||||
["REA_SWIFTC_COMPILE_TIMEOUT_MS", "0"],
|
||||
["REA_LOG_LEVEL", "verbose"],
|
||||
["REA_GHIDRA_COMPILER_SPEC_ID", "gcc"],
|
||||
] as const)(
|
||||
"rejects %s before scoped Hopper readiness reports success",
|
||||
async (setting, value) => {
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
configurationHost({ [setting]: value }),
|
||||
{ providers: ["hopper"] },
|
||||
);
|
||||
|
||||
expect(result.healthy).toBe(false);
|
||||
expect(result.environment_healthy).toBe(false);
|
||||
expect(result.scope_checks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: `runtime-configuration:${setting}`,
|
||||
ok: false,
|
||||
classification: "config_drift",
|
||||
detail: expect.stringContaining(setting),
|
||||
remediation: expect.stringContaining(setting),
|
||||
details: { setting },
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a lowercase provider preference ready for the selected engine", async () => {
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
configurationHost({ REA_ANALYSIS_PROVIDER: "hopper" }),
|
||||
{ providers: ["hopper"] },
|
||||
);
|
||||
|
||||
expect(result.healthy).toBe(true);
|
||||
expect(
|
||||
result.checks.some(({ name }) =>
|
||||
name.startsWith("runtime-configuration:"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps an invalid native setting informational when nothing starts MCP", async () => {
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
configurationHost({ REA_GHIDRA_STARTUP_TIMEOUT_MS: "abc" }),
|
||||
{ providers: [] },
|
||||
);
|
||||
|
||||
expect(result.healthy).toBe(true);
|
||||
expect(result.environment_healthy).toBe(false);
|
||||
expect(result.scope_checks.map(({ name }) => name)).not.toContain(
|
||||
"runtime-configuration:REA_GHIDRA_STARTUP_TIMEOUT_MS",
|
||||
);
|
||||
expect(result.informational_checks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "runtime-configuration:REA_GHIDRA_STARTUP_TIMEOUT_MS",
|
||||
ok: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat a JEB-only timeout as native analysis configuration", async () => {
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
configurationHost({ REA_JEB_MCP_TIMEOUT_MS: "abc" }),
|
||||
{ providers: ["hopper"] },
|
||||
);
|
||||
|
||||
expect(result.healthy).toBe(true);
|
||||
expect(
|
||||
result.checks.some(({ name }) =>
|
||||
name.startsWith("runtime-configuration:"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("doctor MCP startup readiness", () => {
|
||||
const configurationHost = (
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
): DoctorHost => ({
|
||||
...systemDoctorHost({ environment: { ...environment } }),
|
||||
...host({
|
||||
installationPaths: () => Promise.resolve([]),
|
||||
clientRegistrations: () => Promise.resolve([]),
|
||||
}),
|
||||
});
|
||||
const alignedCodex: ClientRegistrationStatus = {
|
||||
client: "codex",
|
||||
config_path: "/home/user/.codex/config.toml",
|
||||
command: ["/current/rea", "mcp"],
|
||||
state: "aligned",
|
||||
remediation: null,
|
||||
};
|
||||
const selectedHost = (
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
): DoctorHost => ({
|
||||
...configurationHost(environment),
|
||||
clientRegistrations: () => Promise.resolve([alignedCodex]),
|
||||
});
|
||||
|
||||
it("requires an invalid log level for a client and for the skill", async () => {
|
||||
const client = await runDoctor(
|
||||
undefined,
|
||||
selectedHost({
|
||||
REA_LOG_LEVEL: "verbose",
|
||||
REA_GHIDRA_STARTUP_TIMEOUT_MS: "abc",
|
||||
}),
|
||||
{ clients: ["codex"] },
|
||||
);
|
||||
expect(client.healthy).toBe(false);
|
||||
expect(client.scope_checks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "runtime-configuration:REA_LOG_LEVEL",
|
||||
ok: false,
|
||||
details: { setting: "REA_LOG_LEVEL" },
|
||||
}),
|
||||
);
|
||||
expect(client.scope_checks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "runtime-configuration:REA_GHIDRA_STARTUP_TIMEOUT_MS",
|
||||
ok: false,
|
||||
}),
|
||||
);
|
||||
expect(client.scope_checks).toContainEqual(
|
||||
expect.objectContaining({ name: "registration:codex", ok: true }),
|
||||
);
|
||||
|
||||
const skill = await runDoctor(
|
||||
undefined,
|
||||
selectedHost({
|
||||
REA_LOG_LEVEL: "verbose",
|
||||
REA_GHIDRA_STARTUP_TIMEOUT_MS: "abc",
|
||||
}),
|
||||
{ skill: true },
|
||||
);
|
||||
expect(skill.healthy).toBe(false);
|
||||
expect(skill.scope_checks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "runtime-configuration:REA_LOG_LEVEL",
|
||||
ok: false,
|
||||
}),
|
||||
);
|
||||
expect(skill.scope_checks.map(({ name }) => name)).not.toContain(
|
||||
"runtime-configuration:REA_GHIDRA_STARTUP_TIMEOUT_MS",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires every MCP startup failure for an aligned client", async () => {
|
||||
const ghidraTimeout = await runDoctor(
|
||||
undefined,
|
||||
selectedHost({ REA_GHIDRA_STARTUP_TIMEOUT_MS: "abc" }),
|
||||
{ clients: ["codex"] },
|
||||
);
|
||||
expect(ghidraTimeout.healthy).toBe(false);
|
||||
expect(ghidraTimeout.scope_checks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "runtime-configuration:REA_GHIDRA_STARTUP_TIMEOUT_MS",
|
||||
ok: false,
|
||||
}),
|
||||
);
|
||||
expect(ghidraTimeout.scope_checks).toContainEqual(
|
||||
expect.objectContaining({ name: "registration:codex", ok: true }),
|
||||
);
|
||||
|
||||
const hopperTimeout = await runDoctor(
|
||||
undefined,
|
||||
selectedHost({ REA_HOPPER_STARTUP_TIMEOUT_MS: "abc" }),
|
||||
{ clients: ["codex"] },
|
||||
);
|
||||
expect(hopperTimeout.healthy).toBe(false);
|
||||
expect(
|
||||
hopperTimeout.scope_checks.filter(
|
||||
({ name }) => name === "hopper-startup-timeout",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
|
||||
const hopperAndClient = await runDoctor(
|
||||
undefined,
|
||||
selectedHost({ REA_HOPPER_STARTUP_TIMEOUT_MS: "abc" }),
|
||||
{ clients: ["codex"], providers: ["hopper"] },
|
||||
);
|
||||
expect(
|
||||
hopperAndClient.scope_checks.filter(
|
||||
({ name }) => name === "hopper-startup-timeout",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
|
||||
const skill = await runDoctor(
|
||||
undefined,
|
||||
selectedHost({ REA_GHIDRA_STARTUP_TIMEOUT_MS: "abc" }),
|
||||
{ skill: true },
|
||||
);
|
||||
expect(skill.healthy).toBe(true);
|
||||
expect(skill.informational_checks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "runtime-configuration:REA_GHIDRA_STARTUP_TIMEOUT_MS",
|
||||
ok: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("doctor runtime check", () => {
|
||||
|
||||
@@ -11,7 +11,10 @@ import { constants } from "node:fs";
|
||||
import { access, readdir, stat } from "node:fs/promises";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import type { RetainedResourceScope } from "./AnalysisProvider.js";
|
||||
import { nativeAnalysisConfigurationError } from "../config/parseConfig.js";
|
||||
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
|
||||
import type { ConfigurationSettingIssue } from "../domain/configurationErrors.js";
|
||||
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { analysisErrorRemediationAction } from "../domain/analysisErrorPresentation.js";
|
||||
@@ -109,6 +112,11 @@ export interface DoctorHost {
|
||||
readonly homeDirectory: string;
|
||||
readonly configuredHopperPath?: string;
|
||||
readonly configuredHopperStartupTimeout?: string;
|
||||
/**
|
||||
* Settings `parseConfig` rejects before native analysis starts.
|
||||
* Undefined means this host did not run that admission.
|
||||
*/
|
||||
readonly configurationIssues?: readonly ConfigurationSettingIssue[];
|
||||
readonly configuredIlspyCmdPath?: string;
|
||||
macosVersion(): Promise<string | undefined>;
|
||||
linuxDistribution(): Promise<LinuxDistribution | undefined>;
|
||||
@@ -508,6 +516,9 @@ export const systemDoctorHost = (
|
||||
const platform = options.platform ?? process.platform;
|
||||
const architecture = options.architecture ?? process.arch;
|
||||
const environment = options.environment ?? process.env;
|
||||
const configurationError = nativeAnalysisConfigurationError(
|
||||
snapshotEnvironment(environment, platform),
|
||||
);
|
||||
const hostExecFileOutput = options.execFileOutput ?? execFileOutput;
|
||||
const homeDirectory = homeDirectoryFromEnvironment(environment, platform);
|
||||
const commandEnvironment = { env: environment };
|
||||
@@ -548,6 +559,7 @@ export const systemDoctorHost = (
|
||||
configuredHopperStartupTimeout:
|
||||
environment.REA_HOPPER_STARTUP_TIMEOUT_MS,
|
||||
}),
|
||||
configurationIssues: configurationError?.settings ?? [],
|
||||
...(environment.REA_ILSPY_CMD_PATH === undefined
|
||||
? {}
|
||||
: { configuredIlspyCmdPath: environment.REA_ILSPY_CMD_PATH }),
|
||||
|
||||
@@ -37,6 +37,7 @@ export const collectDoctorDiagnostics = async (
|
||||
checks.push(hopperCheck(host, hopperPath));
|
||||
const hopperTimeoutCheck = optionalHopperStartupTimeoutCheck(host);
|
||||
if (hopperTimeoutCheck !== undefined) checks.push(hopperTimeoutCheck);
|
||||
checks.push(...runtimeConfigurationChecks(host));
|
||||
const hopperVersionCheck = await optionalHopperVersionCheck(host, hopperPath);
|
||||
if (hopperVersionCheck !== undefined) checks.push(hopperVersionCheck);
|
||||
|
||||
@@ -230,6 +231,44 @@ const optionalHopperStartupTimeoutCheck = (
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Project every native-analysis admission failure except the Hopper timeout,
|
||||
* which already has its own check. Provider scopes include engine settings.
|
||||
* Settings that block process start are marked for every doctor scope.
|
||||
*/
|
||||
const runtimeConfigurationChecks = (
|
||||
host: DoctorHost,
|
||||
): readonly DoctorCheck[] => {
|
||||
const issues = host.configurationIssues;
|
||||
if (issues === undefined || issues.length === 0) return [];
|
||||
const grouped: { setting: string; constraints: string[] }[] = [];
|
||||
for (const issue of issues) {
|
||||
if (issue.setting === "REA_HOPPER_STARTUP_TIMEOUT_MS") continue;
|
||||
const existing = grouped.find((item) => item.setting === issue.setting);
|
||||
if (existing === undefined)
|
||||
grouped.push({ setting: issue.setting, constraints: [issue.constraint] });
|
||||
else existing.constraints.push(issue.constraint);
|
||||
}
|
||||
return grouped.map(({ setting, constraints }) => ({
|
||||
...check(
|
||||
`runtime-configuration:${setting}`,
|
||||
false,
|
||||
constraints
|
||||
.map((constraint) =>
|
||||
constraint.includes(setting)
|
||||
? constraint.replace(/\.$/u, "")
|
||||
: `${setting}: ${constraint}`,
|
||||
)
|
||||
.join("; "),
|
||||
{
|
||||
classification: "config_drift",
|
||||
remediation: `Correct ${setting} in the environment that starts REA (your shell or the MCP client's registration), then rerun.`,
|
||||
},
|
||||
),
|
||||
details: { setting },
|
||||
}));
|
||||
};
|
||||
|
||||
const optionalHopperVersionCheck = async (
|
||||
host: DoctorHost,
|
||||
hopperPath: string | undefined,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { uniqueSorted } from "../domain/canonicalOrdering.js";
|
||||
import { blocksProcessStart } from "../logger.js";
|
||||
import type {
|
||||
ClientRegistrationStatus,
|
||||
UnhealthyClientRegistrationStatus,
|
||||
@@ -80,8 +81,33 @@ export const scopeDoctorChecks = (
|
||||
const hopperHostChecks = input.scope.providers.includes("hopper")
|
||||
? input.checks.filter(({ name }) => name === "host")
|
||||
: [];
|
||||
// parseConfig rejects these before the MCP server starts. Hopper's own
|
||||
// scope already includes hopper-startup-timeout through its provider checks.
|
||||
const blocksMcpStartup = (check: DoctorCheck): boolean =>
|
||||
check.name.startsWith("runtime-configuration:") ||
|
||||
check.name === "hopper-startup-timeout";
|
||||
const blocksProcess = (check: DoctorCheck): boolean => {
|
||||
const setting = check.details?.setting;
|
||||
return (
|
||||
check.name.startsWith("runtime-configuration:") &&
|
||||
typeof setting === "string" &&
|
||||
blocksProcessStart(setting)
|
||||
);
|
||||
};
|
||||
const exercisesMcpStartup =
|
||||
input.scope.clients.length > 0 || input.scope.providers.length > 0;
|
||||
const startupChecks = (
|
||||
exercisesMcpStartup
|
||||
? input.checks.filter(blocksMcpStartup)
|
||||
: input.checks.filter(blocksProcess)
|
||||
).filter(
|
||||
(check) =>
|
||||
check.name !== "hopper-startup-timeout" ||
|
||||
!input.scope.providers.includes("hopper"),
|
||||
);
|
||||
const scopeChecks = [
|
||||
...coreChecks,
|
||||
...startupChecks,
|
||||
...hopperHostChecks,
|
||||
...selectedProviderChecks,
|
||||
...clientChecks,
|
||||
@@ -91,6 +117,7 @@ export const scopeDoctorChecks = (
|
||||
return {
|
||||
healthy:
|
||||
coreChecks.every(({ ok }) => ok) &&
|
||||
startupChecks.every(({ ok }) => ok) &&
|
||||
hopperHostChecks.every(({ ok }) => ok) &&
|
||||
clientChecks.every(({ ok }) => ok) &&
|
||||
skillChecks.every(({ ok }) => ok) &&
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import { executeProviderOperation } from "./ProviderOperationExecution.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import {
|
||||
executeProviderOperation,
|
||||
keepIncompleteObservation,
|
||||
} from "./ProviderOperationExecution.js";
|
||||
|
||||
it("projects request-schema issues without exposing the request envelope", async () => {
|
||||
const execute = vi.fn();
|
||||
@@ -87,3 +97,84 @@ it("preserves operation correction outside the input envelope", async () => {
|
||||
]);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const cancelledScenario = (partial: unknown) =>
|
||||
new AnalysisCancelledError("capture_browser_scenario", {
|
||||
cause: { requested: "stop during capture" },
|
||||
partialObservation: partial as never,
|
||||
});
|
||||
|
||||
const wrapScenarioCleanup = (primary: AnalysisCancelledError) =>
|
||||
analysisErrorWithCleanupFailure(
|
||||
primary,
|
||||
new ProviderCleanupError("fixture", ["browser"], {}),
|
||||
"capture_browser_scenario",
|
||||
);
|
||||
|
||||
it("clears a non-JSON partial from the cleanup primary projection returns after retry", () => {
|
||||
const partial = { steps: [] as unknown[] };
|
||||
const primary = cancelledScenario(partial);
|
||||
const composed = wrapScenarioCleanup(primary);
|
||||
partial.steps.push(undefined);
|
||||
const reason =
|
||||
"Browser scenario provider retained a non-JSON incomplete observation.";
|
||||
const retained = keepIncompleteObservation(
|
||||
composed,
|
||||
"capture_browser_scenario",
|
||||
reason,
|
||||
);
|
||||
|
||||
expect(retained).toBe(composed);
|
||||
expect(composed.partialObservation).toBeUndefined();
|
||||
expect(primary.partialObservation).toBeUndefined();
|
||||
expect(composed.secondaryFailures).toMatchObject([
|
||||
{ _tag: "AnalysisOutputError", reason },
|
||||
]);
|
||||
expect(primary.secondaryFailures[0]).toBeInstanceOf(AnalysisOutputError);
|
||||
expect(projectAnalysisError(composed).code).toBe("cleanup_incomplete");
|
||||
|
||||
composed.resolveCleanup();
|
||||
expect(projectAnalysisError(composed)).toMatchObject({
|
||||
code: "cancelled",
|
||||
category: "cancelled",
|
||||
});
|
||||
expect(projectAnalysisError(composed).details).not.toHaveProperty(
|
||||
"partial_observation",
|
||||
);
|
||||
});
|
||||
|
||||
it("freezes one shared snapshot for a cleanup wrapper and its primary", () => {
|
||||
const partial = { steps: ["kept"] };
|
||||
const primary = cancelledScenario(partial);
|
||||
const composed = wrapScenarioCleanup(primary);
|
||||
keepIncompleteObservation(
|
||||
composed,
|
||||
"capture_browser_scenario",
|
||||
"unused because the partial is JSON",
|
||||
);
|
||||
|
||||
expect(composed.partialObservation).toBe(primary.partialObservation);
|
||||
expect(composed.partialObservation).toEqual({ steps: ["kept"] });
|
||||
expect(Object.isFrozen(composed.partialObservation)).toBe(true);
|
||||
expect(composed.secondaryFailures).toHaveLength(0);
|
||||
expect(primary.secondaryFailures).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("freezes a retained partial so a later mutation cannot break projection", () => {
|
||||
const partial = { steps: [] as unknown[] };
|
||||
const primary = cancelledScenario(partial);
|
||||
keepIncompleteObservation(
|
||||
primary,
|
||||
"capture_browser_scenario",
|
||||
"unused because the partial is JSON",
|
||||
);
|
||||
partial.steps.push(undefined);
|
||||
|
||||
expect(primary.partialObservation).toEqual({ steps: [] });
|
||||
expect(Object.isFrozen(primary.partialObservation)).toBe(true);
|
||||
expect(primary.secondaryFailures).toHaveLength(0);
|
||||
expect(projectAnalysisError(primary)).toMatchObject({
|
||||
code: "cancelled",
|
||||
details: { partial_observation: { steps: [] } },
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,19 @@ import { z } from "zod";
|
||||
import type {
|
||||
AnalysisError,
|
||||
AnalysisErrorOptions,
|
||||
AnalysisPartialObservation,
|
||||
} from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import {
|
||||
freezeJsonSnapshot,
|
||||
isImmutableJsonSnapshot,
|
||||
} from "../domain/immutableJson.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { projectInputIssues } from "../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
@@ -92,3 +99,49 @@ export const completeProviderEvidence = (
|
||||
);
|
||||
return ok(evidence);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retain an incomplete observation when projection can serialize it.
|
||||
* A JSON partial is replaced by one frozen snapshot, so a later mutation
|
||||
* cannot make projection throw and projection does not copy it again.
|
||||
* A partial that is not JSON is cleared from the returned error and from
|
||||
* every cleanup primary projection can select later. Each cleared error
|
||||
* records an output failure, leaving cancellation or timeout primary.
|
||||
*/
|
||||
export const keepIncompleteObservation = (
|
||||
error: AnalysisError,
|
||||
operation: string,
|
||||
reason: string,
|
||||
): AnalysisError => {
|
||||
const seen = new Set<AnalysisError>();
|
||||
// One source object can sit on a wrapper and its cleanup primary.
|
||||
const judged = new Map<object, AnalysisPartialObservation | undefined>();
|
||||
let current: AnalysisError | undefined = error;
|
||||
while (current !== undefined && !seen.has(current)) {
|
||||
seen.add(current);
|
||||
const partial = current.partialObservation;
|
||||
if (
|
||||
partial !== undefined &&
|
||||
!isImmutableJsonSnapshot(partial) &&
|
||||
typeof partial === "object"
|
||||
) {
|
||||
// safeParse clones only after validation succeeds. Freeze that copy once.
|
||||
let snapshot = judged.get(partial);
|
||||
if (!judged.has(partial)) {
|
||||
const parsed = jsonValueSchema.safeParse(partial);
|
||||
snapshot = parsed.success
|
||||
? (freezeJsonSnapshot(parsed.data) as AnalysisPartialObservation)
|
||||
: undefined;
|
||||
judged.set(partial, snapshot);
|
||||
}
|
||||
if (snapshot === undefined) {
|
||||
current.partialObservation = undefined;
|
||||
current.retainSecondaryFailure(
|
||||
new AnalysisOutputError(operation, reason),
|
||||
);
|
||||
} else current.partialObservation = snapshot;
|
||||
}
|
||||
current = current.cleanupPrimary;
|
||||
}
|
||||
return error;
|
||||
};
|
||||
@@ -6,10 +6,20 @@ import {
|
||||
webListenersFixture,
|
||||
} from "./WebRuntimeService.fixture.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { AnalysisTimeoutError } from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import {
|
||||
expectDroppedPartial,
|
||||
expectKeptPartial,
|
||||
expectOwnerError,
|
||||
} from "./ownerObservationExpectation.fixture.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { parseEvidence } from "../domain/evidence.js";
|
||||
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||
|
||||
it("rejects malformed input and prior cancellation without calling a provider", async () => {
|
||||
const provider = recordingWebRuntimePort();
|
||||
@@ -83,11 +93,7 @@ it("converts raw execution retained by provider cleanup failure into partial Evi
|
||||
{ operation: "observe_web_execution", partialObservation: execution },
|
||||
);
|
||||
const result = await new WebRuntimeService({
|
||||
identity: () => ({
|
||||
id: "web-runtime",
|
||||
name: "Runtime fixture",
|
||||
version: "1",
|
||||
}),
|
||||
identity: runtimeIdentity,
|
||||
observeExecution: async () => err(failure),
|
||||
inspectEventListeners: async () => err(failure),
|
||||
}).observe(args);
|
||||
@@ -161,3 +167,118 @@ it("retains completed execution Evidence on late abort and keeps invalid output
|
||||
});
|
||||
if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError);
|
||||
});
|
||||
|
||||
const runtimeIdentity = () => ({
|
||||
id: "web-runtime",
|
||||
name: "Runtime fixture",
|
||||
version: "1",
|
||||
});
|
||||
|
||||
const callRuntime = (
|
||||
operation: "observe" | "inspect",
|
||||
failure: AnalysisError,
|
||||
) => {
|
||||
const service = new WebRuntimeService({
|
||||
identity: runtimeIdentity,
|
||||
observeExecution: async () => err(failure),
|
||||
inspectEventListeners: async () => err(failure),
|
||||
});
|
||||
return operation === "observe"
|
||||
? service.observe({
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "selected-page",
|
||||
observation_ms: 5,
|
||||
})
|
||||
: service.inspect({
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "selected-page",
|
||||
selector: "#selected",
|
||||
});
|
||||
};
|
||||
|
||||
it.each([
|
||||
{
|
||||
operation: "observe" as const,
|
||||
partial: {
|
||||
instrumentation: { cleanup: "unconfirmed" },
|
||||
window: { requested_ms: 5 },
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: "inspect" as const,
|
||||
partial: {
|
||||
selected_node: { selector: "#selected" },
|
||||
listeners: [],
|
||||
},
|
||||
},
|
||||
])(
|
||||
"keeps $operation cancellation when the retained observation is incomplete",
|
||||
async ({ operation, partial }) => {
|
||||
const reason = { requested: `stop incomplete ${operation}` };
|
||||
const failure = new AnalysisCancelledError(
|
||||
operation === "observe"
|
||||
? "observe_web_execution"
|
||||
: "inspect_web_event_listeners",
|
||||
{ cause: reason, partialObservation: partial as never },
|
||||
);
|
||||
expectKeptPartial(
|
||||
expectOwnerError(await callRuntime(operation, failure), failure, reason),
|
||||
partial,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
operation: "observe" as const,
|
||||
partial: {
|
||||
instrumentation: { cleanup: "unconfirmed" },
|
||||
window: { requested_ms: Number.NaN },
|
||||
},
|
||||
reason: "Provider retained a non-JSON incomplete execution observation.",
|
||||
},
|
||||
{
|
||||
operation: "inspect" as const,
|
||||
partial: {
|
||||
selected_node: { selector: "#selected" },
|
||||
listeners: [undefined],
|
||||
},
|
||||
reason:
|
||||
"Provider retained a non-JSON incomplete event-listener observation.",
|
||||
},
|
||||
])(
|
||||
"drops a non-JSON $operation partial and keeps cancellation primary",
|
||||
async ({ operation, partial, reason: outputReason }) => {
|
||||
const reason = { requested: `stop unsafe ${operation}` };
|
||||
const failure = new AnalysisCancelledError(
|
||||
operation === "observe"
|
||||
? "observe_web_execution"
|
||||
: "inspect_web_event_listeners",
|
||||
{ cause: reason, partialObservation: partial as never },
|
||||
);
|
||||
expectDroppedPartial(
|
||||
expectOwnerError(await callRuntime(operation, failure), failure, reason),
|
||||
outputReason,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("drops a non-JSON execution partial and keeps timeout primary", async () => {
|
||||
const failure = new AnalysisTimeoutError("observe_web_execution", 5, {
|
||||
partialObservation: {
|
||||
instrumentation: { samples: [undefined] },
|
||||
window: { requested_ms: 5 },
|
||||
} as never,
|
||||
});
|
||||
const error = expectOwnerError(
|
||||
await callRuntime("observe", failure),
|
||||
failure,
|
||||
undefined,
|
||||
);
|
||||
expectDroppedPartial(
|
||||
error,
|
||||
"Provider retained a non-JSON incomplete execution observation.",
|
||||
"provider_timeout",
|
||||
);
|
||||
expect(projectAnalysisError(error).category).toBe("timeout");
|
||||
});
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
inspectWebEventListenersInputSchema,
|
||||
webEventListenersSchema,
|
||||
} from "../domain/webEventListeners.js";
|
||||
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
|
||||
import {
|
||||
completeProviderEvidence,
|
||||
keepIncompleteObservation,
|
||||
} from "./ProviderOperationExecution.js";
|
||||
|
||||
/** Shared public workflows keep instrumentation and listener inspection independently selectable. */
|
||||
export class WebRuntimeService {
|
||||
@@ -38,35 +41,19 @@ export class WebRuntimeService {
|
||||
);
|
||||
if (!parsed.ok) return parsed;
|
||||
const result = await this.provider.observeExecution(parsed.value, options);
|
||||
if (!result.ok) {
|
||||
const partial = result.error.partialObservation;
|
||||
if (
|
||||
partial !== undefined &&
|
||||
!("evidence_id" in partial) &&
|
||||
"instrumentation" in partial &&
|
||||
"window" in partial
|
||||
) {
|
||||
const verifiedPartial = webExecutionSchema.safeParse(partial);
|
||||
if (verifiedPartial.success) {
|
||||
const evidence = this.evidence(
|
||||
operation,
|
||||
parsed.value,
|
||||
verifiedPartial.data,
|
||||
);
|
||||
if (evidence.ok) result.error.partialObservation = evidence.value;
|
||||
else if (result.error instanceof AnalysisCancelledError)
|
||||
return evidence;
|
||||
} else if (result.error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"Provider retained a malformed completed execution observation.",
|
||||
{ cause: verifiedPartial.error },
|
||||
),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (!result.ok)
|
||||
return this.retainRuntimePartial(
|
||||
result.error,
|
||||
operation,
|
||||
"Provider retained a non-JSON incomplete execution observation.",
|
||||
(partial) => {
|
||||
if (!("instrumentation" in partial) || !("window" in partial))
|
||||
return undefined;
|
||||
const verified = webExecutionSchema.safeParse(partial);
|
||||
if (!verified.success) return undefined;
|
||||
return this.evidence(operation, parsed.value, verified.data);
|
||||
},
|
||||
);
|
||||
const verified = webExecutionSchema.safeParse(result.value);
|
||||
if (!verified.success)
|
||||
return err(new AnalysisOutputError(operation, verified.error.message));
|
||||
@@ -100,35 +87,19 @@ export class WebRuntimeService {
|
||||
parsed.value,
|
||||
options,
|
||||
);
|
||||
if (!result.ok) {
|
||||
const partial = result.error.partialObservation;
|
||||
if (
|
||||
partial !== undefined &&
|
||||
!("evidence_id" in partial) &&
|
||||
"selected_node" in partial &&
|
||||
"listeners" in partial
|
||||
) {
|
||||
const verifiedPartial = webEventListenersSchema.safeParse(partial);
|
||||
if (verifiedPartial.success) {
|
||||
const evidence = this.evidence(
|
||||
operation,
|
||||
parsed.value,
|
||||
verifiedPartial.data,
|
||||
);
|
||||
if (evidence.ok) result.error.partialObservation = evidence.value;
|
||||
else if (result.error instanceof AnalysisCancelledError)
|
||||
return evidence;
|
||||
} else if (result.error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"Provider retained malformed completed event-listener data.",
|
||||
{ cause: verifiedPartial.error },
|
||||
),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (!result.ok)
|
||||
return this.retainRuntimePartial(
|
||||
result.error,
|
||||
operation,
|
||||
"Provider retained a non-JSON incomplete event-listener observation.",
|
||||
(partial) => {
|
||||
if (!("selected_node" in partial) || !("listeners" in partial))
|
||||
return undefined;
|
||||
const verified = webEventListenersSchema.safeParse(partial);
|
||||
if (!verified.success) return undefined;
|
||||
return this.evidence(operation, parsed.value, verified.data);
|
||||
},
|
||||
);
|
||||
const verified = webEventListenersSchema.safeParse(result.value);
|
||||
if (!verified.success)
|
||||
return err(new AnalysisOutputError(operation, verified.error.message));
|
||||
@@ -145,6 +116,29 @@ export class WebRuntimeService {
|
||||
return this.evidence(operation, parsed.value, verified.data, options);
|
||||
}
|
||||
|
||||
/** Promote a schema-valid partial, or keep a JSON snapshot of an incomplete one. */
|
||||
private retainRuntimePartial(
|
||||
error: AnalysisError,
|
||||
operation: string,
|
||||
nonJsonReason: string,
|
||||
promote: (
|
||||
partial: NonNullable<AnalysisError["partialObservation"]>,
|
||||
) => Result<Evidence, AnalysisError> | undefined,
|
||||
): Result<Evidence, AnalysisError> {
|
||||
const partial = error.partialObservation;
|
||||
if (partial !== undefined && !("evidence_id" in partial)) {
|
||||
const evidence = promote(partial);
|
||||
if (evidence !== undefined) {
|
||||
if (evidence.ok) {
|
||||
error.partialObservation = evidence.value;
|
||||
return err(error);
|
||||
}
|
||||
if (error instanceof AnalysisCancelledError) return evidence;
|
||||
}
|
||||
}
|
||||
return err(keepIncompleteObservation(error, operation, nonJsonReason));
|
||||
}
|
||||
|
||||
private evidence(
|
||||
operation: string,
|
||||
input:
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
environmentManifestCaptureFixture,
|
||||
environmentStateCaptureFixture,
|
||||
} from "../../../tests/fixtures/analysisView.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js";
|
||||
import { createEvidence, parseEvidence } from "../../domain/evidence.js";
|
||||
@@ -38,11 +38,8 @@ import {
|
||||
scenarioCaptureEvidence,
|
||||
scenarioCaptureFixture,
|
||||
} from "../../../tests/fixtures/browserScenarioCapture.js";
|
||||
import { readEvidenceFileSource } from "./evidenceFileSource.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
|
||||
vi.mock("./evidenceFileSource.js", { spy: true });
|
||||
|
||||
it.each(["layout", "javascript"])(
|
||||
"carries the retained %s source snapshot into repeatable shared views",
|
||||
(kind) => {
|
||||
@@ -1077,7 +1074,7 @@ describe("inspectAnalysisViewWithFiles", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("inspectAnalysisViewWithFiles scenario parents and cancellation", () => {
|
||||
describe("inspectAnalysisViewWithFiles scenario parents", () => {
|
||||
const view = manifestView;
|
||||
|
||||
it("accepts from a file a scenario capture whose response bodies exceed the inline budget", async () => {
|
||||
@@ -1109,33 +1106,4 @@ describe("inspectAnalysisViewWithFiles scenario parents and cancellation", () =>
|
||||
parent_evidence_id: parent.evidence_id,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports cancellation rather than the authentication failure of a file aborted while it is authenticated", async () => {
|
||||
const controller = new AbortController();
|
||||
const parent = analysisViewCaptureEvidence(
|
||||
environmentManifestCaptureFixture(),
|
||||
);
|
||||
const path = await writeEvidenceFile({
|
||||
...parent,
|
||||
evidence_id: `ev_${"0".repeat(64)}`,
|
||||
});
|
||||
const { readEvidenceFileSource: read } = await vi.importActual<
|
||||
typeof import("./evidenceFileSource.js")
|
||||
>("./evidenceFileSource.js");
|
||||
// The abort arrives once the read has finished, while the stale ID is being authenticated.
|
||||
vi.mocked(readEvidenceFileSource).mockImplementationOnce(
|
||||
async (...args) => {
|
||||
const loaded = await read(...args);
|
||||
controller.abort();
|
||||
return loaded;
|
||||
},
|
||||
);
|
||||
expect(
|
||||
await inspectAnalysisViewWithFiles(
|
||||
{ source: { kind: "evidence-file", path }, view },
|
||||
undefined,
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import { jsonObjectSchema } from "../../domain/jsonValue.js";
|
||||
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { err, type Result } from "../../domain/result.js";
|
||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
|
||||
@@ -35,7 +36,13 @@ export class FirmwareAnalysisService {
|
||||
);
|
||||
const parsed = firmwareRequestSchema.safeParse({ operation, input });
|
||||
if (!parsed.success)
|
||||
return err(new AnalysisInputError(operation, { cause: parsed.error }));
|
||||
return err(
|
||||
new AnalysisInputError(
|
||||
operation,
|
||||
{ cause: parsed.error },
|
||||
projectInputIssues(parsed.error.issues, { operation, input }),
|
||||
),
|
||||
);
|
||||
const executed = await this.provider.execute(parsed.data, options);
|
||||
if (!executed.ok) return executed;
|
||||
const execution = executed.value;
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisInputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
import { ok } from "../../domain/result.js";
|
||||
|
||||
const createSession = () => ({
|
||||
sessionId: "00000000-0000-4000-8000-000000000001",
|
||||
@@ -65,6 +67,109 @@ const createProvider = (
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("keeps default Frida status identity and messages and refreshes every summary", () => {
|
||||
let reads = 0;
|
||||
const provider = createProvider({
|
||||
status() {
|
||||
reads++;
|
||||
return {
|
||||
...createSession(),
|
||||
state: reads === 1 ? "paused" : "detached",
|
||||
targetResumeObserved: false,
|
||||
scripts: [],
|
||||
messages: [{ observed: reads }],
|
||||
messagesTruncated: reads > 1,
|
||||
};
|
||||
},
|
||||
});
|
||||
const service = new FridaInstrumentationService(provider);
|
||||
const records = new Map<string, Evidence>();
|
||||
const retention = {
|
||||
evidenceById: (id: string) => records.get(id),
|
||||
recordEvidence(evidence: Evidence) {
|
||||
records.set(evidence.evidence_id, evidence);
|
||||
return ok("added" as const);
|
||||
},
|
||||
};
|
||||
const input = { session_id: createSession().sessionId };
|
||||
const complete = service.inspectStatus(input);
|
||||
if (!complete.ok) throw complete.error;
|
||||
expect(complete.value.normalized_result).toMatchObject({
|
||||
state: "paused",
|
||||
messages: [{ observed: 1 }],
|
||||
messages_truncated: false,
|
||||
});
|
||||
const summary = service.inspectStatus(
|
||||
{ ...input, detail: "summary" },
|
||||
retention,
|
||||
);
|
||||
if (!summary.ok) throw summary.error;
|
||||
expect(summary.value.normalized_result).toMatchObject({
|
||||
parent_operation: "frida_session_status",
|
||||
artifact: null,
|
||||
source_provider: complete.value.provider,
|
||||
source_parameters: input,
|
||||
summary: {
|
||||
state: "detached",
|
||||
messages_retained: 1,
|
||||
messages_truncated: true,
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(summary.value)).not.toContain('"messages":');
|
||||
expect(reads).toBe(2);
|
||||
const parent = [...records.values()][0];
|
||||
expect(parent?.normalized_result).toMatchObject({
|
||||
messages: [{ observed: 2 }],
|
||||
});
|
||||
expect(parent?.authority).toBe("external-service");
|
||||
expect(parent?.environment).toBeNull();
|
||||
expect(parent?.raw_result).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
{ recordEvidence: undefined, evidenceById: () => undefined },
|
||||
{ recordEvidence: () => ok("added" as const), evidenceById: undefined },
|
||||
])(
|
||||
"rejects missing Frida summary retention before reading live state: %j",
|
||||
(retention) => {
|
||||
let reads = 0;
|
||||
const service = new FridaInstrumentationService(
|
||||
createProvider({
|
||||
status() {
|
||||
reads++;
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const result = service.inspectStatus(
|
||||
{ session_id: createSession().sessionId, detail: "summary" },
|
||||
retention,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
||||
expect(reads).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects malformed Frida status selections and preserves unknown-session errors", () => {
|
||||
const service = new FridaInstrumentationService(createProvider());
|
||||
for (const input of [
|
||||
{ session_id: "invalid" },
|
||||
{ session_id: createSession().sessionId, detail: "messages" },
|
||||
]) {
|
||||
const result = service.inspectStatus(input);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error._tag).toBe("AnalysisInputError");
|
||||
}
|
||||
const unknown = service.inspectStatus({
|
||||
session_id: createSession().sessionId,
|
||||
});
|
||||
expect(unknown.ok).toBe(false);
|
||||
if (!unknown.ok) expect(unknown.error._tag).toBe("AnalysisInputError");
|
||||
});
|
||||
|
||||
it.each(["startSession", "instrument"] as const)(
|
||||
"does not start Frida when %s receives an already-cancelled signal",
|
||||
async (method) => {
|
||||
|
||||
@@ -14,6 +14,14 @@ import {
|
||||
AnalysisInputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { fridaSessionStatusInputSchema } from "../../domain/fridaSessionStatus.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import {
|
||||
requireAnalysisViewRetention,
|
||||
summarizeRetainedAnalysis,
|
||||
type AnalysisViewRetention,
|
||||
} from "../analysisView/AnalysisViewService.js";
|
||||
import { unknownFridaSession } from "./fridaInputErrors.js";
|
||||
import { FridaScriptLoadCancelledError } from "./FridaInstrumentationPort.js";
|
||||
import type {
|
||||
FridaDeviceSelector,
|
||||
@@ -237,13 +245,45 @@ export class FridaInstrumentationService {
|
||||
return this.#provider.status(sessionId);
|
||||
}
|
||||
|
||||
statusEvidence(sessionId: string): Evidence | undefined {
|
||||
const status = this.#provider.status(sessionId);
|
||||
return status === undefined
|
||||
? undefined
|
||||
: fridaEvidence("frida_session_status", sessionStatusOutput(status), {
|
||||
session_id: sessionId,
|
||||
});
|
||||
inspectStatus(
|
||||
rawInput: unknown,
|
||||
retention: AnalysisViewRetention = {
|
||||
recordEvidence: undefined,
|
||||
evidenceById: undefined,
|
||||
},
|
||||
): Result<Evidence, AnalysisError> {
|
||||
const input = fridaSessionStatusInputSchema.safeParse(rawInput);
|
||||
if (!input.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
"frida_session_status",
|
||||
input.error.issues,
|
||||
rawInput,
|
||||
{ cause: input.error },
|
||||
),
|
||||
);
|
||||
if (input.data.detail === "summary") {
|
||||
const ready = requireAnalysisViewRetention(
|
||||
"frida_session_status",
|
||||
retention,
|
||||
);
|
||||
if (!ready.ok) return ready;
|
||||
}
|
||||
const status = this.#provider.status(input.data.session_id);
|
||||
if (status === undefined)
|
||||
return err(
|
||||
unknownFridaSession("frida_session_status", input.data.session_id),
|
||||
);
|
||||
const evidence = fridaEvidence(
|
||||
"frida_session_status",
|
||||
sessionStatusOutput(status),
|
||||
{
|
||||
session_id: input.data.session_id,
|
||||
},
|
||||
);
|
||||
return input.data.detail === "summary"
|
||||
? summarizeRetainedAnalysis(evidence, retention)
|
||||
: ok(evidence);
|
||||
}
|
||||
|
||||
async instrument(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { classifyBrowserCompleteness } from "../../domain/browserCompleteness.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
@@ -12,14 +11,27 @@ import {
|
||||
electronTargetListSchema,
|
||||
inspectElectronPageInputSchema,
|
||||
} from "../../domain/javascript/electronObservation.js";
|
||||
import { emptyElectronPageCoverage } from "../../domain/javascript/electronPageInspection.fixture.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import type { ElectronObservationPort } from "./ElectronObservationPort.js";
|
||||
import {
|
||||
inspectElectronPage,
|
||||
listElectronTargets,
|
||||
} from "./ElectronObservationService.js";
|
||||
import {
|
||||
expectDroppedPartial,
|
||||
expectKeptPartial,
|
||||
expectOwnerError,
|
||||
} from "../ownerObservationExpectation.fixture.js";
|
||||
|
||||
const identity = { id: "electron-fixture", name: "Fixture", version: "1" };
|
||||
const pageProvider = (failure: AnalysisError): ElectronObservationPort => ({
|
||||
identity: () => identity,
|
||||
listTargets: async () =>
|
||||
err(new AnalysisCancelledError("list_electron_targets")),
|
||||
inspectPage: async () => err(failure),
|
||||
});
|
||||
const input = inspectElectronPageInputSchema.parse({
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "electron-page",
|
||||
@@ -40,33 +52,7 @@ const inspection = electronPageInspectionSchema.parse({
|
||||
file_path: "/app/index.html",
|
||||
attached: false,
|
||||
},
|
||||
capture_window: {
|
||||
started_at: "2026-01-01T00:00:00.000Z",
|
||||
ended_at: "2026-01-01T00:00:00.000Z",
|
||||
observation_ms: 0,
|
||||
},
|
||||
completeness: classifyBrowserCompleteness({
|
||||
policyFilteredSections: new Set(),
|
||||
attachLimitedSections: new Set(),
|
||||
truncatedSections: new Set(),
|
||||
unavailableSections: new Set(),
|
||||
excluded: [],
|
||||
droppedEvents: {
|
||||
scripts: 0,
|
||||
network_requests: 0,
|
||||
console_events: 0,
|
||||
websocket_connections: 0,
|
||||
websocket_frames: 0,
|
||||
webmcp_tools: 0,
|
||||
timeline_events: 0,
|
||||
},
|
||||
}),
|
||||
frames: [],
|
||||
dom: { total_nodes: 0, nodes: [] },
|
||||
scripts: { total: 0, items: [] },
|
||||
resources: [],
|
||||
workers: [],
|
||||
limitations: [],
|
||||
...emptyElectronPageCoverage(),
|
||||
});
|
||||
|
||||
it("converts a retained raw Electron inspection into partial Evidence", async () => {
|
||||
@@ -77,13 +63,7 @@ it("converts a retained raw Electron inspection into partial Evidence", async ()
|
||||
cleanup: "transport-closed",
|
||||
},
|
||||
});
|
||||
const provider: ElectronObservationPort = {
|
||||
identity: () => identity,
|
||||
listTargets: async () =>
|
||||
err(new AnalysisCancelledError("list_electron_targets")),
|
||||
inspectPage: async () => err(failure),
|
||||
};
|
||||
const result = await inspectElectronPage(provider, input);
|
||||
const result = await inspectElectronPage(pageProvider(failure), input);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBe(failure);
|
||||
@@ -116,13 +96,7 @@ it("converts a raw inspection retained by provider cleanup failure", async () =>
|
||||
},
|
||||
},
|
||||
);
|
||||
const provider: ElectronObservationPort = {
|
||||
identity: () => identity,
|
||||
listTargets: async () =>
|
||||
err(new AnalysisCancelledError("list_electron_targets")),
|
||||
inspectPage: async () => err(failure),
|
||||
};
|
||||
const result = await inspectElectronPage(provider, input);
|
||||
const result = await inspectElectronPage(pageProvider(failure), input);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBe(failure);
|
||||
@@ -244,3 +218,43 @@ it.each(["list", "inspect"] as const)(
|
||||
expect(providerCalls).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps cancellation when the retained Electron inspection is incomplete", async () => {
|
||||
const reason = { requested: "stop before page inspection completed" };
|
||||
const partial = {
|
||||
kind: "electron-page-observation",
|
||||
inspection: { target_id: "electron-page" },
|
||||
cleanup: "incomplete",
|
||||
};
|
||||
const failure = new AnalysisCancelledError("inspect_electron_page", {
|
||||
cause: reason,
|
||||
partialObservation: partial as never,
|
||||
});
|
||||
const result = await inspectElectronPage(pageProvider(failure), input);
|
||||
expectKeptPartial(expectOwnerError(result, failure, reason), partial);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ inspection: { requests: Number.NaN }, cleanup: "incomplete" as const },
|
||||
{
|
||||
inspection: { scripts: [undefined] },
|
||||
cleanup: "transport-closed" as const,
|
||||
},
|
||||
])(
|
||||
"drops a non-JSON Electron inspection and keeps cancellation primary",
|
||||
async (unsafe) => {
|
||||
const reason = { requested: "stop before page inspection completed" };
|
||||
const failure = new AnalysisCancelledError("inspect_electron_page", {
|
||||
cause: reason,
|
||||
partialObservation: {
|
||||
kind: "electron-page-observation",
|
||||
...unsafe,
|
||||
} as never,
|
||||
});
|
||||
const result = await inspectElectronPage(pageProvider(failure), input);
|
||||
expectDroppedPartial(
|
||||
expectOwnerError(result, failure, reason),
|
||||
"Electron provider retained a non-JSON incomplete observation.",
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
electronPageInspectionSchema,
|
||||
electronTargetListSchema,
|
||||
} from "../../domain/javascript/electronObservation.js";
|
||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
import {
|
||||
completeProviderEvidence,
|
||||
keepIncompleteObservation,
|
||||
} from "../ProviderOperationExecution.js";
|
||||
|
||||
/** List local file pages exposed by the explicitly selected endpoint. */
|
||||
export const listElectronTargets = async (
|
||||
@@ -79,34 +82,32 @@ export const inspectElectronPage = async (
|
||||
partial.kind === "electron-page-observation"
|
||||
) {
|
||||
const report = electronPageInspectionSchema.safeParse(partial.inspection);
|
||||
if (!report.success) {
|
||||
if (result.error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"inspect_electron_page",
|
||||
"Electron provider retained a malformed completed page inspection.",
|
||||
{ cause: report.error },
|
||||
),
|
||||
);
|
||||
if (report.success) {
|
||||
const inspection = {
|
||||
...report.data,
|
||||
limitations: [
|
||||
...report.data.limitations,
|
||||
partial.cleanup === "transport-closed"
|
||||
? "REA closed its CDP transport; this does not imply target shutdown."
|
||||
: "CDP transport cleanup remains incomplete.",
|
||||
],
|
||||
} satisfies ElectronPageInspection;
|
||||
result.error.partialObservation = createElectronEvidence(
|
||||
"inspect_electron_page",
|
||||
input,
|
||||
inspection,
|
||||
ready.value.identity(),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
const inspection = {
|
||||
...report.data,
|
||||
limitations: [
|
||||
...report.data.limitations,
|
||||
partial.cleanup === "transport-closed"
|
||||
? "REA closed its CDP transport; this does not imply target shutdown."
|
||||
: "CDP transport cleanup remains incomplete.",
|
||||
],
|
||||
} satisfies ElectronPageInspection;
|
||||
result.error.partialObservation = createElectronEvidence(
|
||||
"inspect_electron_page",
|
||||
input,
|
||||
inspection,
|
||||
ready.value.identity(),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
return err(
|
||||
keepIncompleteObservation(
|
||||
result.error,
|
||||
"inspect_electron_page",
|
||||
"Electron provider retained a non-JSON incomplete observation.",
|
||||
),
|
||||
);
|
||||
}
|
||||
const report = electronPageInspectionSchema.safeParse(result.value);
|
||||
if (!report.success)
|
||||
|
||||
@@ -343,8 +343,9 @@ export const reconstructJavaScriptArtifact = async (
|
||||
const OPERATION = "analyze_javascript_application";
|
||||
|
||||
/**
|
||||
* Resolve the caller-selected input. A missing or unreadable selection is a
|
||||
* caller or host-permission failure, not a damaged artifact.
|
||||
* Resolve the caller-selected input. A missing, looping, overlong, or
|
||||
* unreadable selection is a caller or host-resolution failure, not a damaged
|
||||
* artifact.
|
||||
*/
|
||||
const resolveSelectedInput = async (inputPath: string): Promise<string> => {
|
||||
try {
|
||||
@@ -366,10 +367,26 @@ const resolveSelectedInput = async (inputPath: string): Promise<string> => {
|
||||
message: `No file or directory exists at the selected input path (${code}): ${inputPath}`,
|
||||
},
|
||||
]);
|
||||
if (code === "ELOOP" || code === "ENAMETOOLONG")
|
||||
throw new AnalysisInputError(OPERATION, { cause }, [
|
||||
{
|
||||
path: ["input_path"],
|
||||
reason: "invalid_value",
|
||||
message: selectedInputResolutionMessage(code, inputPath),
|
||||
},
|
||||
]);
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
const selectedInputResolutionMessage = (
|
||||
code: "ELOOP" | "ENAMETOOLONG",
|
||||
inputPath: string,
|
||||
): string =>
|
||||
code === "ENAMETOOLONG"
|
||||
? `Selected input path exceeds the host path or component length limit (ENAMETOOLONG): ${inputPath}`
|
||||
: `Selected input path could not be resolved because symbolic-link traversal looped or exceeded the host limit (ELOOP): ${inputPath}`;
|
||||
|
||||
/** The selected input's kind, checked against the caller's requested format. */
|
||||
const resolveFormat = async (
|
||||
path: string,
|
||||
|
||||
@@ -14,6 +14,35 @@ import {
|
||||
listJavaScriptRuntimeTargets,
|
||||
observeJavaScriptRuntime,
|
||||
} from "./JavaScriptRuntimeObservationService.js";
|
||||
import {
|
||||
expectDroppedPartial,
|
||||
expectKeptPartial,
|
||||
expectOutputError,
|
||||
expectOwnerError,
|
||||
expectPreemptedCancellation,
|
||||
} from "../ownerObservationExpectation.fixture.js";
|
||||
|
||||
const observeCancelled = async (partial: unknown) => {
|
||||
const reason = { requested: "stop before runtime observation completed" };
|
||||
const failure = new AnalysisCancelledError("observe_javascript_runtime", {
|
||||
cause: reason,
|
||||
partialObservation: partial as never,
|
||||
});
|
||||
const result = await observeJavaScriptRuntime(
|
||||
{
|
||||
identity: () => ({ id: "test", name: "Test", version: "1" }),
|
||||
listTargets: async () =>
|
||||
err(new AnalysisCancelledError("list_javascript_runtime_targets")),
|
||||
observe: async () => err(failure),
|
||||
},
|
||||
{
|
||||
inspector_endpoint: "http://127.0.0.1:9229",
|
||||
target_id: "target",
|
||||
observation_ms: 0,
|
||||
},
|
||||
);
|
||||
return { result, failure, reason };
|
||||
};
|
||||
|
||||
it("preserves failed Inspector cleanup when cancellation occurs during observation", async () => {
|
||||
const controller = new AbortController();
|
||||
@@ -126,11 +155,7 @@ it("does not let late cancellation hide an invalid completed Inspector report",
|
||||
},
|
||||
{ inspector_endpoint: "http://127.0.0.1:9229" },
|
||||
);
|
||||
expect(output).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
});
|
||||
if (!output.ok) expect(output.error).toBeInstanceOf(AnalysisOutputError);
|
||||
expectOutputError(output);
|
||||
|
||||
const controller = new AbortController();
|
||||
const late = await listJavaScriptRuntimeTargets(
|
||||
@@ -147,11 +172,7 @@ it("does not let late cancellation hide an invalid completed Inspector report",
|
||||
{ inspector_endpoint: "http://127.0.0.1:9229" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(late).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
});
|
||||
if (!late.ok) expect(late.error).toBeInstanceOf(AnalysisOutputError);
|
||||
expectOutputError(late);
|
||||
});
|
||||
|
||||
it("converts a raw runtime observation retained by cleanup failure", async () => {
|
||||
@@ -309,11 +330,23 @@ it.each(["list", "observe"] as const)(
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(result.error.cause).toBe(reason);
|
||||
}
|
||||
expect(providerCalls).toBe(0);
|
||||
expectPreemptedCancellation(result, reason, providerCalls);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps cancellation when the retained runtime observation is incomplete", async () => {
|
||||
const partial = { runtime: "partial", scripts: [] };
|
||||
const { result, failure, reason } = await observeCancelled(partial);
|
||||
expectKeptPartial(expectOwnerError(result, failure, reason), partial);
|
||||
});
|
||||
|
||||
it.each([{ scripts: [undefined] }, { elapsed_ms: Number.NaN }])(
|
||||
"drops a non-JSON runtime partial and keeps cancellation primary",
|
||||
async (partial) => {
|
||||
const { result, failure, reason } = await observeCancelled(partial);
|
||||
expectDroppedPartial(
|
||||
expectOwnerError(result, failure, reason),
|
||||
"Inspector retained a non-JSON incomplete observation.",
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -13,7 +13,10 @@ import type {
|
||||
ObserveJavaScriptRuntimeInput,
|
||||
} from "../../domain/javascript/javascriptRuntimeObservation.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
import {
|
||||
completeProviderEvidence,
|
||||
keepIncompleteObservation,
|
||||
} from "../ProviderOperationExecution.js";
|
||||
import {
|
||||
javascriptRuntimeObservationSchema,
|
||||
javascriptRuntimeTargetListSchema,
|
||||
@@ -76,15 +79,7 @@ export const observeJavaScriptRuntime = async (
|
||||
!("kind" in partial)
|
||||
) {
|
||||
const report = javascriptRuntimeObservationSchema.safeParse(partial);
|
||||
if (!report.success && result.error instanceof AnalysisCancelledError)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"observe_javascript_runtime",
|
||||
"Inspector retained a malformed completed runtime observation.",
|
||||
{ cause: report.error },
|
||||
),
|
||||
);
|
||||
if (report.success)
|
||||
if (report.success) {
|
||||
result.error.partialObservation =
|
||||
createJavaScriptRuntimeObservationEvidence(
|
||||
"observe_javascript_runtime",
|
||||
@@ -92,8 +87,16 @@ export const observeJavaScriptRuntime = async (
|
||||
report.data,
|
||||
ready.value.identity(),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
return err(
|
||||
keepIncompleteObservation(
|
||||
result.error,
|
||||
"observe_javascript_runtime",
|
||||
"Inspector retained a non-JSON incomplete observation.",
|
||||
),
|
||||
);
|
||||
}
|
||||
const report = javascriptRuntimeObservationSchema.safeParse(result.value);
|
||||
if (!report.success)
|
||||
|
||||
@@ -172,6 +172,56 @@ it("continues to project an ordinary member access as a property read", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a directly returned or passed property in backward provenance", () => {
|
||||
const source = `
|
||||
const settings = { index: "products" };
|
||||
function viaLocal() {
|
||||
const value = settings.index;
|
||||
return value;
|
||||
}
|
||||
function viaMember() {
|
||||
return settings.index;
|
||||
}
|
||||
function viaParen() {
|
||||
return (settings.index);
|
||||
}
|
||||
const arrow = () => settings.index;
|
||||
function take(value) { return value; }
|
||||
function added() { return settings.index + "!"; }
|
||||
const a = viaLocal();
|
||||
const b = viaMember();
|
||||
const direct = settings.index;
|
||||
const c = viaParen();
|
||||
const d = arrow();
|
||||
const e = take(settings.index);
|
||||
const g = take((settings.index));
|
||||
const kept = settings.index;
|
||||
const local = take(kept);
|
||||
const concatenated = added();
|
||||
`;
|
||||
const graph = graphFor(source);
|
||||
const provenance = (binding: string, candidates: boolean) => {
|
||||
const node = graph.nodes.find(
|
||||
(item) => item.kind === "binding" && item.label === binding,
|
||||
);
|
||||
if (node === undefined) throw new Error(`missing ${binding}`);
|
||||
const trace = queryJavaScriptSemanticGraph(graph, {
|
||||
seed: { kind: "semantic-node", node_id: node.node_id },
|
||||
direction: "backward-provenance",
|
||||
include_ambiguous_dynamic_edges: candidates,
|
||||
});
|
||||
return trace.nodes.some(
|
||||
(item) => item.kind === "literal" && item.properties.value === "products",
|
||||
);
|
||||
};
|
||||
for (const binding of ["a", "b", "direct", "c", "d", "e", "g", "local"]) {
|
||||
expect(provenance(binding, false), binding).toBe(true);
|
||||
expect(provenance(binding, true), `${binding} candidates`).toBe(true);
|
||||
}
|
||||
expect(provenance("concatenated", false)).toBe(false);
|
||||
expect(provenance("concatenated", true)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps dynamic calls explicit and returns complete deterministic results", () => {
|
||||
const graph = graphFor(`
|
||||
const handlers = { ready() { return 1; } };
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
projectSemanticFrontiers,
|
||||
projectSemanticPromises,
|
||||
projectSemanticReturnValues,
|
||||
semanticObjectReadOccurrence,
|
||||
type SemanticFlowProjectionContext,
|
||||
} from "./JavaScriptSemanticGraphFlowProjection.js";
|
||||
import {
|
||||
@@ -828,6 +829,15 @@ const projectCalls = (context: FileContext): void => {
|
||||
? "resolved"
|
||||
: "candidate",
|
||||
});
|
||||
addRelation(context.state, {
|
||||
source: semanticObjectReadOccurrence(
|
||||
context,
|
||||
argument.valueExpressionLocation,
|
||||
),
|
||||
target: argumentNode,
|
||||
relation: "aliases",
|
||||
resolution: "resolved",
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const flow of context.ir.argumentFlows) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js";
|
||||
import { javaScriptSemanticNodeId } from "../../domain/javascript/javascriptSemanticGraph.js";
|
||||
import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js";
|
||||
import { sourceRangesEqual } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
|
||||
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
|
||||
@@ -190,6 +191,57 @@ const addPromiseUnknown = (
|
||||
});
|
||||
};
|
||||
|
||||
interface ObjectReadIndex {
|
||||
readonly state: SemanticGraphProjectionState;
|
||||
readonly nodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
|
||||
}
|
||||
|
||||
const objectReadIndexes = new WeakMap<JavaScriptSemanticIr, ObjectReadIndex>();
|
||||
|
||||
const objectReadRangeKey = (location: JavaScriptSourceRange): string =>
|
||||
`${String(location.start.line)}:${String(location.start.column)}:${String(location.end.line)}:${String(location.end.column)}`;
|
||||
|
||||
/**
|
||||
* The object projection owns a member read's occurrence. Index it once per
|
||||
* file so return and argument flow can connect that expression without
|
||||
* scanning every property read again.
|
||||
*/
|
||||
const objectReadOccurrences = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
): ReadonlyMap<string, JavaScriptSemanticGraphNode> => {
|
||||
const cached = objectReadIndexes.get(context.ir);
|
||||
if (cached !== undefined && cached.state === context.state)
|
||||
return cached.nodes;
|
||||
const nodes = new Map<string, JavaScriptSemanticGraphNode>();
|
||||
for (const operation of context.ir.objectOperations) {
|
||||
if (operation.kind !== "read") continue;
|
||||
const node = context.state.nodes.get(
|
||||
javaScriptSemanticNodeId({
|
||||
kind: "expression",
|
||||
identity: {
|
||||
artifact_sha256: context.file.sha256,
|
||||
module_path: context.file.path,
|
||||
source_range: operation.location,
|
||||
role_key: operation.objectOperationId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (node !== undefined)
|
||||
nodes.set(objectReadRangeKey(operation.location), node);
|
||||
}
|
||||
objectReadIndexes.set(context.ir, { state: context.state, nodes });
|
||||
return nodes;
|
||||
};
|
||||
|
||||
/** Return the retained read occurrence for one unwrapped member expression. */
|
||||
export const semanticObjectReadOccurrence = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
location: JavaScriptSourceRange | null,
|
||||
): JavaScriptSemanticGraphNode | undefined =>
|
||||
location === null
|
||||
? undefined
|
||||
: objectReadOccurrences(context).get(objectReadRangeKey(location));
|
||||
|
||||
/** Link direct return expressions to their retained semantic references. */
|
||||
export const projectSemanticReturnValues = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
@@ -212,6 +264,15 @@ export const projectSemanticReturnValues = (
|
||||
? "resolved"
|
||||
: "candidate",
|
||||
});
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: semanticObjectReadOccurrence(
|
||||
context,
|
||||
site.valueExpressionLocation,
|
||||
),
|
||||
target: returnNode,
|
||||
relation: "aliases",
|
||||
resolution: "resolved",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ProviderIdentity } from "../AnalysisProvider.js";
|
||||
import type {
|
||||
ModelArtifact,
|
||||
ModelArtifactPartial,
|
||||
ModelArtifactInput,
|
||||
} from "../../domain/model/modelArtifact.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
@@ -32,3 +33,21 @@ export const createModelArtifactEvidence = (
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
/** Evidence for completed GGUF facts retained before final identity verification. */
|
||||
export const createModelArtifactPartialEvidence = (
|
||||
partial: ModelArtifactPartial,
|
||||
provider: ProviderIdentity,
|
||||
parameters: ModelArtifactInput,
|
||||
): Evidence =>
|
||||
createEvidence(undefined, provider, {
|
||||
operation: "inspect_model_artifact",
|
||||
parameters: { path: parameters.path, hash: parameters.hash },
|
||||
result: partial,
|
||||
confidence: "observed",
|
||||
subjectUnavailableReason:
|
||||
"The interrupted read did not complete final file identity verification; digest and identity remain unknown.",
|
||||
// Each retained row already owns its exact source offset and length.
|
||||
locations: [{ kind: "artifact-path", path: partial.artifact.path }],
|
||||
limitations: partial.limitations,
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { expect } from "vitest";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
|
||||
/** Assert the service returned the same owner error and its cause. */
|
||||
export const expectOwnerError = <T>(
|
||||
result: Result<T, AnalysisError>,
|
||||
failure: AnalysisError,
|
||||
cause: unknown,
|
||||
): AnalysisError => {
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected the owner failure");
|
||||
expect(result.error).toBe(failure);
|
||||
expect(result.error.cause).toBe(cause);
|
||||
return result.error;
|
||||
};
|
||||
|
||||
/** A JSON-safe incomplete observation stays on the owner error. */
|
||||
export const expectKeptPartial = (
|
||||
error: AnalysisError,
|
||||
partial: unknown,
|
||||
code: "cancelled" | "provider_timeout" = "cancelled",
|
||||
): void => {
|
||||
expect(error.partialObservation).toEqual(partial);
|
||||
expect(error.secondaryFailures).toHaveLength(0);
|
||||
expect(projectAnalysisError(error)).toMatchObject({
|
||||
code,
|
||||
details: { partial_observation: partial },
|
||||
});
|
||||
};
|
||||
|
||||
/** A completed value failed output validation, including after a late abort. */
|
||||
export const expectOutputError = (
|
||||
result: Result<unknown, AnalysisError>,
|
||||
): void => {
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
});
|
||||
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError);
|
||||
};
|
||||
|
||||
/** Cancellation won before the provider ran. */
|
||||
export const expectPreemptedCancellation = (
|
||||
result: Result<unknown, AnalysisError>,
|
||||
cause: unknown,
|
||||
providerCalls: number,
|
||||
): void => {
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(result.error.cause).toBe(cause);
|
||||
expect(providerCalls).toBe(0);
|
||||
};
|
||||
|
||||
/** A non-JSON partial is cleared and recorded as a secondary output failure. */
|
||||
export const expectDroppedPartial = (
|
||||
error: AnalysisError,
|
||||
reason: string,
|
||||
code: "cancelled" | "provider_timeout" = "cancelled",
|
||||
): void => {
|
||||
expect(error.partialObservation).toBeUndefined();
|
||||
expect(error.secondaryFailures).toMatchObject([
|
||||
{ _tag: "AnalysisOutputError", reason },
|
||||
]);
|
||||
expect(projectAnalysisError(error).code).toBe(code);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { PcapAnalysisService } from "./PcapAnalysisService.js";
|
||||
|
||||
it("preserves the exact caller reason when pcap admission is already aborted", async () => {
|
||||
const reason = Object.freeze({ requested: "stop" });
|
||||
const controller = new AbortController();
|
||||
controller.abort(reason);
|
||||
let calls = 0;
|
||||
const service = new PcapAnalysisService({
|
||||
inspectAvailability: () => Promise.reject(new Error("unused")),
|
||||
close: () => Promise.resolve(),
|
||||
execute: () => {
|
||||
calls += 1;
|
||||
return Promise.reject(new Error("provider must not run"));
|
||||
},
|
||||
});
|
||||
const result = await service.execute(
|
||||
"inspect_pcap_client",
|
||||
{},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
@@ -24,7 +24,11 @@ export class PcapAnalysisService {
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
if (options?.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: options.signal.reason,
|
||||
}),
|
||||
);
|
||||
const request = pcapRequestSchema.safeParse({ operation, input });
|
||||
if (!request.success)
|
||||
return err(new AnalysisInputError(operation, { cause: request.error }));
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
AnalysisInputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import type { AnalysisErrorProjection } from "../../domain/analysisErrorProjection.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import { createEvidence, parseEvidence } from "../../domain/evidence.js";
|
||||
import { describeValidationFailure } from "../../domain/evidenceBundle.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
@@ -31,10 +33,8 @@ import { readJsonFile } from "../JsonFiles.js";
|
||||
import { RegularFileCleanupFailure } from "../RegularFileRead.js";
|
||||
|
||||
/** Safe process-command failure returned to the CLI adapter. */
|
||||
export interface ProcessCliErrorOutput {
|
||||
export interface ProcessCliErrorOutput extends AnalysisErrorProjection {
|
||||
readonly error: "Process command failed";
|
||||
readonly category: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
/** Identify the process workflow's typed diagnostic result for CLI exit status. */
|
||||
@@ -48,7 +48,16 @@ export const isProcessCliFailure = (
|
||||
"category" in value &&
|
||||
typeof value.category === "string" &&
|
||||
"message" in value &&
|
||||
typeof value.message === "string";
|
||||
typeof value.message === "string" &&
|
||||
"code" in value &&
|
||||
typeof value.code === "string" &&
|
||||
"retryable" in value &&
|
||||
typeof value.retryable === "boolean" &&
|
||||
"remediation" in value &&
|
||||
typeof value.remediation === "object" &&
|
||||
value.remediation !== null &&
|
||||
"action" in value.remediation &&
|
||||
typeof value.remediation.action === "string";
|
||||
|
||||
/** Capture one JSON scenario through the shared process harness and Evidence contract. */
|
||||
export const captureProcessScenarioFile = async (
|
||||
@@ -102,7 +111,7 @@ export const captureProcessScenarioFile = async (
|
||||
return createProcessCaptureEvidence(parsed.data, captured.value);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
||||
return projectProcessCliError(cause);
|
||||
return projectProcessCliError(cause, "capture_process_scenario");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -155,7 +164,7 @@ export const compareProcessEvidenceFiles = async (
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
||||
return projectProcessCliError(cause);
|
||||
return projectProcessCliError(cause, "compare_process_captures");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -170,15 +179,17 @@ const parseCaptureEvidence = (input: unknown, side: "left" | "right") => {
|
||||
{ cause },
|
||||
projectInputIssues(cause.issues, input),
|
||||
);
|
||||
throw invalidCaptureEvidence(cause);
|
||||
throw invalidCaptureEvidence(cause, side);
|
||||
}
|
||||
if (
|
||||
evidence.operation !== "capture_process_scenario" ||
|
||||
evidence.predicate_type !== "rea.process-capture"
|
||||
) {
|
||||
throw new ProcessCliFailure(
|
||||
"invalid_input",
|
||||
"Capture evidence is not from the current process-capture workflow. Create new capture evidence, then try again.",
|
||||
throw processInputFailure(
|
||||
"compare_process_captures",
|
||||
`Capture evidence is not from the current process-capture workflow. Create new capture evidence, then try again.`,
|
||||
[side],
|
||||
"invalid_value",
|
||||
);
|
||||
}
|
||||
if (!isProcessEvidenceProvider(evidence.provider))
|
||||
@@ -196,7 +207,7 @@ const parseCaptureEvidence = (input: unknown, side: "left" | "right") => {
|
||||
locations: evidence.locations,
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
throw invalidCaptureEvidence(cause);
|
||||
throw invalidCaptureEvidence(cause, side);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -214,10 +225,12 @@ const parseTraceSpecification = (input: unknown) => {
|
||||
);
|
||||
};
|
||||
|
||||
const invalidCaptureEvidence = (cause: unknown): ProcessCliFailure =>
|
||||
new ProcessCliFailure(
|
||||
"invalid_input",
|
||||
const invalidCaptureEvidence = (cause: unknown, side: "left" | "right") =>
|
||||
processInputFailure(
|
||||
"compare_process_captures",
|
||||
`Capture evidence is malformed (${describeValidationFailure(cause)}). Create new capture evidence, then try again.`,
|
||||
[side],
|
||||
"invalid_value",
|
||||
);
|
||||
|
||||
const readJson = async (
|
||||
@@ -237,23 +250,27 @@ const readJson = async (
|
||||
if (signal?.aborted) throw new AnalysisCancelledError(operation);
|
||||
if (loaded.ok) return loaded.value;
|
||||
if (loaded.error._tag !== "EvidenceFileError") throw loaded.error;
|
||||
if (loaded.error.reason === "io") throw loaded.error;
|
||||
const detail = describeValidationFailure(loaded.error.cause);
|
||||
throw new ProcessCliFailure(
|
||||
"invalid_input",
|
||||
throw processInputFailure(
|
||||
operation,
|
||||
loaded.error.reason === "invalid-json"
|
||||
? `Process input file is not valid UTF-8 JSON: ${path} (${detail}). Repair the file, then try again.`
|
||||
: `Process input file could not be read: ${path} (${detail}). Check the reported filesystem constraint and retry.`,
|
||||
["input_file"],
|
||||
"invalid_value",
|
||||
);
|
||||
};
|
||||
|
||||
class ProcessCliFailure extends Error {
|
||||
constructor(
|
||||
readonly category: string,
|
||||
readonly userMessage: string,
|
||||
) {
|
||||
super(userMessage);
|
||||
}
|
||||
}
|
||||
const processInputFailure = (
|
||||
operation: string,
|
||||
userMessage: string,
|
||||
path: readonly (string | number)[],
|
||||
reason: "invalid_value" | "missing_argument",
|
||||
): AnalysisInputError =>
|
||||
new AnalysisInputError(operation, { userMessage }, [
|
||||
{ path, reason, message: userMessage },
|
||||
]);
|
||||
|
||||
const cliAnalysisError = (error: AnalysisError): ProcessCliErrorOutput => ({
|
||||
error: "Process command failed",
|
||||
@@ -263,17 +280,14 @@ const cliAnalysisError = (error: AnalysisError): ProcessCliErrorOutput => ({
|
||||
/** Preserve typed failures and local diagnostics at the process CLI boundary. */
|
||||
export const projectProcessCliError = (
|
||||
cause: unknown,
|
||||
operation: "capture_process_scenario" | "compare_process_captures",
|
||||
): ProcessCliErrorOutput => {
|
||||
if (cause instanceof ProcessCliFailure)
|
||||
return {
|
||||
error: "Process command failed",
|
||||
category: cause.category,
|
||||
message: cause.userMessage,
|
||||
};
|
||||
if (cause instanceof AnalysisError) return cliAnalysisError(cause);
|
||||
return {
|
||||
error: "Process command failed",
|
||||
category: "execution_failure",
|
||||
message: `Process command could not complete: ${describeValidationFailure(cause)}`,
|
||||
};
|
||||
return cliAnalysisError(
|
||||
new ProviderAdapterError(PROCESS_PROVIDER.id, operation, {
|
||||
cause,
|
||||
diagnostics: { phase: "process_cli" },
|
||||
userMessage: `Process command could not complete: ${describeValidationFailure(cause)}`,
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { EvidenceProvider } from "../../domain/evidence.js";
|
||||
import type {
|
||||
ObjdumpInput,
|
||||
ObjdumpSelection,
|
||||
RizinInput,
|
||||
} from "../../domain/reverseEngineering.js";
|
||||
|
||||
@@ -14,6 +14,6 @@ export interface ReverseEngineeringProviderCommand {
|
||||
|
||||
/** Provider-specific command construction stays outside the application layer. */
|
||||
export interface ReverseEngineeringCommandPort {
|
||||
objdump(input: ObjdumpInput): ReverseEngineeringProviderCommand;
|
||||
objdump(input: ObjdumpSelection): ReverseEngineeringProviderCommand;
|
||||
rizin(input: RizinInput): ReverseEngineeringProviderCommand;
|
||||
}
|
||||
@@ -3,6 +3,11 @@ import { createReadStream } from "node:fs";
|
||||
import { stat as statPath } from "node:fs/promises";
|
||||
import type { Stats } from "node:fs";
|
||||
|
||||
import {
|
||||
requireAnalysisViewRetention,
|
||||
summarizeRetainedAnalysis,
|
||||
type AnalysisViewRetention,
|
||||
} from "../analysisView/AnalysisViewService.js";
|
||||
import {
|
||||
AnalysisAccessDeniedError,
|
||||
AnalysisCancelledError,
|
||||
@@ -11,11 +16,12 @@ import {
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import type {
|
||||
AnalysisCleanupObservation,
|
||||
import {
|
||||
AnalysisError,
|
||||
type AnalysisCleanupObservation,
|
||||
} from "../../domain/analysisErrorBase.js";
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
@@ -179,29 +185,41 @@ export class ReverseEngineeringService {
|
||||
if (!cleanup.ok) throw cleanup.error;
|
||||
}
|
||||
|
||||
inspectWithObjdump(
|
||||
async inspectWithObjdump(
|
||||
rawInput: unknown,
|
||||
options: { readonly signal?: AbortSignal } = {},
|
||||
retention: AnalysisViewRetention = {
|
||||
evidenceById: undefined,
|
||||
recordEvidence: undefined,
|
||||
},
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
const parsed = objdumpInputSchema.safeParse(rawInput);
|
||||
if (!parsed.success)
|
||||
return Promise.resolve(
|
||||
err(new AnalysisInputError("inspect_with_objdump")),
|
||||
return err(new AnalysisInputError("inspect_with_objdump"));
|
||||
const { detail, ...input } = parsed.data;
|
||||
if (detail === "summary") {
|
||||
const ready = requireAnalysisViewRetention(
|
||||
"inspect_with_objdump",
|
||||
retention,
|
||||
);
|
||||
const input = parsed.data;
|
||||
if (!ready.ok) return ready;
|
||||
}
|
||||
const command = this.#commands.objdump(input);
|
||||
let parameters: Readonly<Record<string, JsonValue>>;
|
||||
if (input.operation === "disassemble") {
|
||||
const { symbol, ...base } = input;
|
||||
parameters = symbol === undefined ? base : { ...base, symbol };
|
||||
} else parameters = input;
|
||||
return this.#observe(
|
||||
const observed = await this.#observe(
|
||||
"inspect_with_objdump",
|
||||
command,
|
||||
input.operation === "architectures" ? undefined : input.path,
|
||||
parameters,
|
||||
options.signal,
|
||||
);
|
||||
return observed.ok && detail === "summary"
|
||||
? summarizeRetainedAnalysis(observed.value, retention)
|
||||
: observed;
|
||||
}
|
||||
|
||||
executeRizinCommand(
|
||||
@@ -232,7 +250,9 @@ export class ReverseEngineeringService {
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
if (signal?.aborted)
|
||||
return Promise.resolve(err(new AnalysisCancelledError(operation)));
|
||||
return Promise.resolve(
|
||||
err(new AnalysisCancelledError(operation, { cause: signal.reason })),
|
||||
);
|
||||
if (this.#closing)
|
||||
return Promise.resolve(
|
||||
err(
|
||||
@@ -293,7 +313,10 @@ export class ReverseEngineeringService {
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
const { executable: command, arguments: args } = profile;
|
||||
if (signal?.aborted) return err(new AnalysisCancelledError(operation));
|
||||
if (signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, { cause: signal.reason }),
|
||||
);
|
||||
const cleanup = await this.retryCleanup(operation);
|
||||
if (!cleanup.ok) return cleanup;
|
||||
const absolutePath =
|
||||
@@ -303,9 +326,13 @@ export class ReverseEngineeringService {
|
||||
if (absolutePath !== undefined)
|
||||
digest = await this.#digest(absolutePath, signal, this.#stat);
|
||||
} catch (cause) {
|
||||
const code = (cause as NodeJS.ErrnoException).code;
|
||||
if (signal?.aborted)
|
||||
return err(new AnalysisCancelledError(operation, { cause }));
|
||||
const code = commandFailureCode(cause);
|
||||
if (preflightCancelled(signal, cause, code))
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: callerCancellationCause(signal, cause),
|
||||
}),
|
||||
);
|
||||
if (code === "EACCES" || code === "EPERM") {
|
||||
const deniedPath = absolutePath === undefined ? command : absolutePath;
|
||||
return err(
|
||||
@@ -318,7 +345,48 @@ export class ReverseEngineeringService {
|
||||
return err(
|
||||
new AnalysisTimeoutError(operation, DEFAULT_TIMEOUT_MS, { cause }),
|
||||
);
|
||||
return err(new AnalysisInputError(operation, { cause }));
|
||||
if (
|
||||
code !== "ENOENT" &&
|
||||
code !== "ENOTDIR" &&
|
||||
code !== "EINVAL" &&
|
||||
code !== "ELOOP" &&
|
||||
code !== "ENAMETOOLONG"
|
||||
) {
|
||||
if (cause instanceof AnalysisError) return err(cause);
|
||||
return err(
|
||||
new ProviderAdapterError(profile.identity.id, operation, {
|
||||
cause,
|
||||
diagnostics: {
|
||||
phase: "target_preflight",
|
||||
...(code === undefined ? {} : { system_code: code }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
const targetReason =
|
||||
code === "EINVAL"
|
||||
? "The selected target is not a regular file."
|
||||
: code === "ELOOP"
|
||||
? "The selected target path contains a symbolic-link loop."
|
||||
: code === "ENAMETOOLONG"
|
||||
? "The selected target path is too long."
|
||||
: "The selected target was not found.";
|
||||
return err(
|
||||
new AnalysisInputError(
|
||||
operation,
|
||||
{
|
||||
cause,
|
||||
userMessage: `${targetReason} Selected path: ${absolutePath}. Check the path and choose a readable regular file before retrying.`,
|
||||
},
|
||||
[
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_value",
|
||||
message: `${targetReason}${code === undefined ? "" : ` (host error ${code})`}`,
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
@@ -384,8 +452,7 @@ export class ReverseEngineeringService {
|
||||
},
|
||||
);
|
||||
const capture = execFileOutputFailure(cause);
|
||||
const code =
|
||||
capture?.code ?? (cause as NodeJS.ErrnoException).code ?? undefined;
|
||||
const code = capture?.code ?? commandFailureCode(cause);
|
||||
if (earlyFailure === undefined && code === "E2BIG")
|
||||
earlyFailure = new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
@@ -406,9 +473,9 @@ export class ReverseEngineeringService {
|
||||
code === "ENOENT" ||
|
||||
code === "ERR_PROVIDER_LAUNCH")
|
||||
) {
|
||||
if (signal?.aborted || code === "ABORT_ERR")
|
||||
if (commandCancelled(signal, cause, code))
|
||||
earlyFailure = new AnalysisCancelledError(operation, {
|
||||
cause,
|
||||
cause: callerCancellationCause(signal, cause),
|
||||
...ownedCleanupOptions(cause),
|
||||
});
|
||||
else if (code === "ETIMEDOUT")
|
||||
@@ -462,9 +529,9 @@ export class ReverseEngineeringService {
|
||||
: null,
|
||||
signal: capture.signal,
|
||||
};
|
||||
if (signal?.aborted || code === "ABORT_ERR")
|
||||
if (commandCancelled(signal, cause, code))
|
||||
failure = new AnalysisCancelledError(operation, {
|
||||
cause,
|
||||
cause: callerCancellationCause(signal, cause),
|
||||
capturedOutput,
|
||||
...ownedCleanupOptions(cause),
|
||||
});
|
||||
@@ -547,6 +614,7 @@ export class ReverseEngineeringService {
|
||||
}
|
||||
if (signal?.aborted && failure === undefined) {
|
||||
failure = new AnalysisCancelledError(operation, {
|
||||
cause: signal.reason,
|
||||
capturedOutput: {
|
||||
stdout,
|
||||
stderr,
|
||||
@@ -914,6 +982,56 @@ const digestFile = async (
|
||||
});
|
||||
};
|
||||
|
||||
const PHYSICAL_FAILURE_CODES = new Set([
|
||||
"EACCES",
|
||||
"EPERM",
|
||||
"ETIMEDOUT",
|
||||
"ENOENT",
|
||||
"ENOTDIR",
|
||||
"EINVAL",
|
||||
"ELOOP",
|
||||
"ENAMETOOLONG",
|
||||
"E2BIG",
|
||||
"EBUSY",
|
||||
"ERR_PROVIDER_LAUNCH",
|
||||
]);
|
||||
|
||||
/**
|
||||
* A late abort does not replace a recognized physical failure. Other rejections
|
||||
* observed while the caller signal is aborted remain cancellation. A bare
|
||||
* `ABORT_ERR` is not cancellation until the command boundary, which already
|
||||
* used that code for an aborted provider process.
|
||||
*/
|
||||
const preflightCancelled = (
|
||||
signal: AbortSignal | undefined,
|
||||
cause: unknown,
|
||||
code: string | number | undefined,
|
||||
): boolean => {
|
||||
if (cause instanceof AnalysisCancelledError) return true;
|
||||
if (signal?.aborted !== true) return false;
|
||||
if (cause === signal.reason) return true;
|
||||
return typeof code !== "string" || !PHYSICAL_FAILURE_CODES.has(code);
|
||||
};
|
||||
|
||||
const commandCancelled = (
|
||||
signal: AbortSignal | undefined,
|
||||
cause: unknown,
|
||||
code: string | number | undefined,
|
||||
): boolean => code === "ABORT_ERR" || preflightCancelled(signal, cause, code);
|
||||
|
||||
const callerCancellationCause = (
|
||||
signal: AbortSignal | undefined,
|
||||
cause: unknown,
|
||||
): unknown => (signal?.aborted === true ? signal.reason : cause);
|
||||
|
||||
const commandFailureCode = (cause: unknown): string | number | undefined => {
|
||||
if (typeof cause !== "object" || cause === null) return undefined;
|
||||
const code: unknown = Reflect.get(cause, "code");
|
||||
return typeof code === "string" || typeof code === "number"
|
||||
? code
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const statWithDeadline = (
|
||||
path: string,
|
||||
signal: AbortSignal | undefined,
|
||||
@@ -923,22 +1041,24 @@ const statWithDeadline = (
|
||||
return Promise.reject(new AnalysisCancelledError("artifact_digest"));
|
||||
return new Promise<Stats>((resolveStat, rejectStat) => {
|
||||
let settled = false;
|
||||
const finish = (cause?: unknown, value?: Stats): void => {
|
||||
const finish = (outcome: Result<Stats, unknown>): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", abort);
|
||||
if (cause !== undefined) rejectStat(cause);
|
||||
else resolveStat(value!);
|
||||
if (outcome.ok) resolveStat(outcome.value);
|
||||
else rejectStat(outcome.error);
|
||||
};
|
||||
const abort = (): void =>
|
||||
finish(new AnalysisCancelledError("artifact_digest"));
|
||||
finish(err(new AnalysisCancelledError("artifact_digest")));
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
finish(
|
||||
Object.assign(new Error("Artifact stat timed out"), {
|
||||
code: "ETIMEDOUT",
|
||||
}),
|
||||
err(
|
||||
Object.assign(new Error("Artifact stat timed out"), {
|
||||
code: "ETIMEDOUT",
|
||||
}),
|
||||
),
|
||||
),
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
@@ -951,11 +1071,11 @@ const statWithDeadline = (
|
||||
// stat rejection is observed and cannot become an unhandled rejection.
|
||||
try {
|
||||
void stat(path).then(
|
||||
(info) => finish(undefined, info),
|
||||
(cause: unknown) => finish(cause),
|
||||
(info) => finish(ok(info)),
|
||||
(cause: unknown) => finish(err(cause)),
|
||||
);
|
||||
} catch (cause) {
|
||||
finish(cause);
|
||||
finish(err(cause));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -145,3 +145,25 @@ it("preserves completed observed evidence when cancellation arrives after provid
|
||||
value: { normalized_result: value },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the exact caller reason when inspection is already aborted", async () => {
|
||||
const reason = Object.freeze({ requested: "stop" });
|
||||
const controller = new AbortController();
|
||||
controller.abort(reason);
|
||||
let calls = 0;
|
||||
const service = new SqliteDatabaseService({
|
||||
inspect: () => {
|
||||
calls += 1;
|
||||
return Promise.reject(new Error("provider must not run"));
|
||||
},
|
||||
});
|
||||
const result = await service.inspect(
|
||||
{ path: "/selected.db" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
@@ -71,7 +71,11 @@ export class SqliteDatabaseService {
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
return err(
|
||||
new AnalysisCancelledError(OPERATION, {
|
||||
cause: options.signal.reason,
|
||||
}),
|
||||
);
|
||||
const input = inspectSqliteDatabaseInputSchema.safeParse(rawInput);
|
||||
if (!input.success)
|
||||
return err(
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { createEvidence } from "../../domain/evidence.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { wasmInterfaceComparisonSchema } from "../../contracts/wasm/wasmComparisonContracts.js";
|
||||
import { compareWasmInterfaces } from "./WasmInterfaceComparisonService.js";
|
||||
import type { WasmInterface } from "../../domain/wasm/wasmInterface.js";
|
||||
import {
|
||||
WASM_INTERFACE_REPRESENTATION_BYTES,
|
||||
wasmInterfaceEntryRepresentationBytes,
|
||||
type WasmInterface,
|
||||
} from "../../domain/wasm/wasmInterface.js";
|
||||
import { wasmArtifactFixture } from "../../../tests/fixtures/wasm/artifact.js";
|
||||
|
||||
const facts = (
|
||||
@@ -179,6 +184,72 @@ it("resolves exact retained references and permits cancellation between comparis
|
||||
});
|
||||
});
|
||||
|
||||
/** One inventory under the comparison cap; two of them exceed it together. */
|
||||
const admittedInventory = (label: string): WasmInterface => {
|
||||
const base = facts(label).entries[0]!;
|
||||
const minimum = Math.floor(WASM_INTERFACE_REPRESENTATION_BYTES / 2) + 4096;
|
||||
let low = 0;
|
||||
let high = 4_000_000;
|
||||
let best = 0;
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
const name = `${label}-${"n".repeat(mid)}`;
|
||||
const bytes = wasmInterfaceEntryRepresentationBytes({
|
||||
...base,
|
||||
name,
|
||||
wat_form: `(export "${name}" (func 0))`,
|
||||
});
|
||||
if (bytes >= minimum) {
|
||||
best = mid;
|
||||
high = mid - 1;
|
||||
} else low = mid + 1;
|
||||
}
|
||||
const name = `${label}-${"n".repeat(best)}`;
|
||||
const entry = {
|
||||
...base,
|
||||
name,
|
||||
wat_form: `(export "${name}" (func 0))`,
|
||||
};
|
||||
const bytes = wasmInterfaceEntryRepresentationBytes(entry);
|
||||
if (
|
||||
bytes >= WASM_INTERFACE_REPRESENTATION_BYTES ||
|
||||
bytes + 512 >= WASM_INTERFACE_REPRESENTATION_BYTES
|
||||
)
|
||||
throw new Error("Sized inventory is not individually admitted");
|
||||
return { state: "complete", issues: [], entries: [entry] };
|
||||
};
|
||||
|
||||
it("refuses two admitted inventories that together exceed the comparison cap", async () => {
|
||||
const left = admittedInventory("left");
|
||||
const right = admittedInventory("right");
|
||||
const empty: WasmInterface = { state: "complete", issues: [], entries: [] };
|
||||
const alone = await compareWasmInterfaces({
|
||||
left: evidence(left),
|
||||
right: evidence(empty),
|
||||
scope: "both",
|
||||
});
|
||||
expect(alone.ok).toBe(true);
|
||||
const combined = await compareWasmInterfaces({
|
||||
left: evidence(left),
|
||||
right: evidence(right),
|
||||
scope: "both",
|
||||
});
|
||||
expect(combined.ok).toBe(false);
|
||||
if (combined.ok) throw new Error("Expected comparison allowance refusal");
|
||||
const projected = projectAnalysisError(combined.error);
|
||||
expect(projected.retryable).toBe(false);
|
||||
expect(projected.code).toBe("resource_constraint");
|
||||
expect(combined.error).toMatchObject({ resource: "capacity" });
|
||||
expect(projected.remediation.action).toContain(
|
||||
"The WASM interface comparison inventory",
|
||||
);
|
||||
expect(projected.remediation.action).toContain("fixed byte allowance");
|
||||
expect(projected.remediation.action).not.toContain("fixed count");
|
||||
expect(projected.remediation.action).not.toMatch(
|
||||
/Wait for an active analysis/u,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"keeps a one-sided unsupported interface unknown with reverse=%s",
|
||||
async (reverse) => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
AnalysisInputError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
@@ -133,6 +134,10 @@ export const compareWasmInterfaces = async (
|
||||
"capacity",
|
||||
"WASM comparison exceeds typed interface representation capacity",
|
||||
{ maximum_bytes: WASM_INTERFACE_REPRESENTATION_BYTES },
|
||||
fixedAllowanceOptions(
|
||||
"The WASM interface comparison inventory",
|
||||
"bytes",
|
||||
),
|
||||
);
|
||||
if (++work % 256 === 0) {
|
||||
await setImmediate();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import type { DesktopFile, DesktopFact } from "../domain/desktopApplication.js";
|
||||
import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
|
||||
import { IL2CPP_METADATA_MAGIC } from "../domain/il2cpp/il2cppMetadata.js";
|
||||
import { observeIl2cppMetadataHeaderPrefix } from "../domain/il2cpp/il2cppMetadataHeaderPrefix.js";
|
||||
import { parsePeRecord, peRvaToFileOffset } from "../domain/binaryTarget.js";
|
||||
import { readManagedMetadataLayout } from "../dotnet/ManagedMetadataLayout.js";
|
||||
import { readAssemblyReferenceName } from "../dotnet/ManagedMetadataInventoryRows.js";
|
||||
@@ -98,13 +98,14 @@ export const inspectDesktopFile = async (
|
||||
row.facts.push(observation);
|
||||
};
|
||||
const prefix = await read(0, Math.min(size, 64));
|
||||
if (prefix.length >= 8 && prefix.readUInt32LE(0) === IL2CPP_METADATA_MAGIC)
|
||||
const metadataPrefix = observeIl2cppMetadataHeaderPrefix(prefix);
|
||||
if (metadataPrefix.kind === "il2cpp")
|
||||
fact({
|
||||
kind: "il2cpp-metadata-header",
|
||||
offset: 0,
|
||||
value: {
|
||||
magic: "0xfab11baf",
|
||||
raw_version: prefix.readUInt32LE(4),
|
||||
raw_version: metadataPrefix.rawVersion,
|
||||
},
|
||||
});
|
||||
if (prefix.length >= 20 && prefix.toString("latin1", 0, 4) === "GDPC")
|
||||
|
||||
@@ -130,6 +130,130 @@ export const machoImage = (options: {
|
||||
return bytes;
|
||||
};
|
||||
|
||||
/**
|
||||
* Little-endian `LC_LAZY_LOAD_DYLIB_INFO` (0x3a) payload.
|
||||
* The 24-byte header is load-path offset, flag image offset, flags,
|
||||
* pointer format, chain-start image offset, symbol count, and the
|
||||
* symbol-offset array offset. Strings are NUL-terminated and the payload
|
||||
* is padded to 8 bytes, matching a one-symbol `@rpath/libl.dylib` payload
|
||||
* of 56 bytes.
|
||||
*/
|
||||
export const lazyLoadDylibInfoPayload = (options: {
|
||||
readonly path: string;
|
||||
readonly symbols?: readonly string[];
|
||||
readonly flags?: number;
|
||||
readonly flagImageOffset?: number;
|
||||
readonly chainStartImageOffset?: number;
|
||||
}): Uint8Array => {
|
||||
const symbols = options.symbols ?? [];
|
||||
const headerSize = 24;
|
||||
const pathBytes = encoder.encode(options.path);
|
||||
const symbolBytes = symbols.map((symbol) => encoder.encode(symbol));
|
||||
const symbolArrayOffset = headerSize;
|
||||
const pathOffset = symbolArrayOffset + symbols.length * 4;
|
||||
let cursor = pathOffset + pathBytes.length + 1;
|
||||
const symbolOffsets = symbolBytes.map((bytes) => {
|
||||
const offset = cursor;
|
||||
cursor += bytes.length + 1;
|
||||
return offset;
|
||||
});
|
||||
const payload = new Uint8Array(Math.ceil(cursor / 8) * 8);
|
||||
const view = new DataView(payload.buffer);
|
||||
view.setUint32(0, pathOffset, true);
|
||||
view.setUint32(4, options.flagImageOffset ?? 0, true);
|
||||
view.setUint16(8, options.flags ?? 0, true);
|
||||
view.setUint32(12, options.chainStartImageOffset ?? 0, true);
|
||||
view.setUint32(16, symbols.length, true);
|
||||
view.setUint32(20, symbolArrayOffset, true);
|
||||
symbolOffsets.forEach((offset, index) =>
|
||||
view.setUint32(symbolArrayOffset + index * 4, offset, true),
|
||||
);
|
||||
payload.set(pathBytes, pathOffset);
|
||||
symbolBytes.forEach((bytes, index) => {
|
||||
const offset = symbolOffsets[index];
|
||||
if (offset !== undefined) payload.set(bytes, offset);
|
||||
});
|
||||
return payload;
|
||||
};
|
||||
|
||||
/**
|
||||
* Place one `LC_LAZY_LOAD_DYLIB_INFO` command among the other commands and
|
||||
* append its payload after the image. `dataoff` is relative to this slice.
|
||||
*/
|
||||
/** `LC_SEGMENT` or `LC_SEGMENT_64` with a name and VM range, and no sections. */
|
||||
export const segmentCommand = (options: {
|
||||
readonly name: string;
|
||||
readonly vmaddr: bigint;
|
||||
readonly vmsize: bigint;
|
||||
readonly wide?: boolean;
|
||||
}): Uint8Array => {
|
||||
const wide = options.wide ?? true;
|
||||
const bytes = new Uint8Array(wide ? 72 : 56);
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint32(0, wide ? 0x19 : 0x1, true);
|
||||
view.setUint32(4, bytes.length, true);
|
||||
bytes.set(encoder.encode(options.name).subarray(0, 16), 8);
|
||||
if (wide) {
|
||||
view.setBigUint64(24, options.vmaddr, true);
|
||||
view.setBigUint64(32, options.vmsize, true);
|
||||
} else {
|
||||
view.setUint32(24, Number(options.vmaddr), true);
|
||||
view.setUint32(28, Number(options.vmsize), true);
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
/** 16-byte `LC_LAZY_LOAD_DYLIB_INFO` pointing at a slice-relative payload. */
|
||||
export const lazyLoadDylibInfoCommand = (
|
||||
dataOffset: number,
|
||||
dataSize: number,
|
||||
): Uint8Array => {
|
||||
const bytes = new Uint8Array(16);
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint32(0, 0x3a, true);
|
||||
view.setUint32(4, bytes.length, true);
|
||||
view.setUint32(8, dataOffset, true);
|
||||
view.setUint32(12, dataSize, true);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
export const machoImageWithLazyLoadDylibInfo = (options: {
|
||||
readonly path: string;
|
||||
readonly symbols?: readonly string[];
|
||||
readonly flags?: number;
|
||||
readonly flagImageOffset?: number;
|
||||
readonly chainStartImageOffset?: number;
|
||||
readonly cpu?: { readonly type: number; readonly subtype: number };
|
||||
readonly fileType?: number;
|
||||
readonly wide?: boolean;
|
||||
readonly commandsBefore?: readonly Uint8Array[];
|
||||
readonly commandsAfter?: readonly Uint8Array[];
|
||||
}): Uint8Array => {
|
||||
const payload = lazyLoadDylibInfoPayload(options);
|
||||
const command = new Uint8Array(16);
|
||||
const commandView = new DataView(command.buffer);
|
||||
commandView.setUint32(0, 0x3a, true);
|
||||
commandView.setUint32(4, command.length, true);
|
||||
const wide = options.wide ?? true;
|
||||
const commandsBefore = options.commandsBefore ?? [];
|
||||
const image = machoImage({
|
||||
...(options.cpu === undefined ? {} : { cpu: options.cpu }),
|
||||
...(options.fileType === undefined ? {} : { fileType: options.fileType }),
|
||||
wide,
|
||||
commands: [...commandsBefore, command, ...(options.commandsAfter ?? [])],
|
||||
});
|
||||
const out = new Uint8Array(image.length + payload.length);
|
||||
out.set(image);
|
||||
out.set(payload, image.length);
|
||||
const headerSize = wide ? 32 : 28;
|
||||
const commandAt =
|
||||
headerSize + commandsBefore.reduce((total, item) => total + item.length, 0);
|
||||
const view = new DataView(out.buffer);
|
||||
view.setUint32(commandAt + 8, image.length, true);
|
||||
view.setUint32(commandAt + 12, payload.length, true);
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Thin Mach-O with one unknown command, for width and command-size regressions. */
|
||||
export const machoImageWithUnknownCommand = (
|
||||
wide: boolean,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir, rename, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -14,12 +14,18 @@ import {
|
||||
dylibCommand,
|
||||
dylibUseCommand,
|
||||
fatImage,
|
||||
lazyLoadDylibInfoCommand,
|
||||
lazyLoadDylibInfoPayload,
|
||||
machoImage,
|
||||
machoImageWithLazyLoadDylibInfo,
|
||||
segmentCommand,
|
||||
readerOf,
|
||||
rpathCommand,
|
||||
versionMinCommand,
|
||||
} from "./MachoImage.fixture.js";
|
||||
import {
|
||||
MAX_LAZY_INFO_DEPENDENCIES,
|
||||
MAX_LAZY_INFO_SYMBOL_REFERENCES,
|
||||
MAX_LOAD_COMMAND_BYTES,
|
||||
hasMachoMagic,
|
||||
readMachoImage,
|
||||
@@ -353,6 +359,650 @@ describe("Mach-O load command reader: decodes thin-image load commands", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("LC_LAZY_LOAD_DYLIB_INFO payload", () => {
|
||||
it("reads install names from the linkedit payload in load-command order", async () => {
|
||||
const payload = lazyLoadDylibInfoPayload({
|
||||
path: "@rpath/libl.dylib",
|
||||
symbols: ["_lf"],
|
||||
});
|
||||
expect(payload).toHaveLength(56);
|
||||
const image = machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
symbols: ["_lf"],
|
||||
commandsBefore: [
|
||||
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib"),
|
||||
],
|
||||
commandsAfter: [dylibCommand(LC.LOAD_DYLIB, "@rpath/libafter.dylib")],
|
||||
});
|
||||
const facts = await read(image);
|
||||
if (facts.status !== "parsed")
|
||||
throw new Error(
|
||||
facts.status === "not-mach-o" ? facts.status : facts.reason,
|
||||
);
|
||||
expect(
|
||||
facts.slices[0]?.dependencies.map(
|
||||
({ command, install_name: name, encoding, weak, current_version }) => ({
|
||||
command,
|
||||
name,
|
||||
encoding,
|
||||
weak,
|
||||
current_version,
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
command: "LC_LOAD_DYLIB",
|
||||
name: "/usr/lib/libSystem.B.dylib",
|
||||
encoding: "dylib_command",
|
||||
weak: false,
|
||||
current_version: "1.0.0",
|
||||
},
|
||||
{
|
||||
command: "LC_LAZY_LOAD_DYLIB_INFO",
|
||||
name: "@rpath/libl.dylib",
|
||||
encoding: "lazy_load_dylib_info",
|
||||
weak: false,
|
||||
current_version: null,
|
||||
},
|
||||
{
|
||||
command: "LC_LOAD_DYLIB",
|
||||
name: "@rpath/libafter.dylib",
|
||||
encoding: "dylib_command",
|
||||
weak: false,
|
||||
current_version: "1.0.0",
|
||||
},
|
||||
]);
|
||||
const weak = await read(
|
||||
machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
flags: 0x1,
|
||||
}),
|
||||
);
|
||||
if (weak.status !== "parsed")
|
||||
throw new Error(weak.status === "not-mach-o" ? weak.status : weak.reason);
|
||||
expect(weak.slices[0]?.dependencies[0]).toMatchObject({ weak: true });
|
||||
const universal = await read(
|
||||
fatImage([
|
||||
{
|
||||
cpu: CPU.arm64,
|
||||
bytes: machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
symbols: ["_lf"],
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
if (universal.status !== "parsed")
|
||||
throw new Error(
|
||||
universal.status === "not-mach-o" ? universal.status : universal.reason,
|
||||
);
|
||||
expect(universal.slices[0]?.dependencies[0]?.install_name).toBe(
|
||||
"@rpath/libl.dylib",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a lazy-info payload that is truncated, mis-pointed, or has unknown flags", async () => {
|
||||
const outside = machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
});
|
||||
const view = new DataView(outside.buffer);
|
||||
const commandAt = 32;
|
||||
view.setUint32(commandAt + 8, outside.length, true);
|
||||
expect((await read(outside)).status).toBe("malformed");
|
||||
|
||||
const badPayload = lazyLoadDylibInfoPayload({
|
||||
path: "@rpath/libl.dylib",
|
||||
});
|
||||
const badPath = machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
});
|
||||
const payloadAt = badPath.length - badPayload.length;
|
||||
new DataView(badPath.buffer).setUint32(payloadAt, badPath.length, true);
|
||||
expect(await read(badPath)).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("load path"),
|
||||
});
|
||||
|
||||
const unknownFlags = machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
flags: 0x2,
|
||||
});
|
||||
expect(await read(unknownFlags)).toMatchObject({
|
||||
status: "unsupported",
|
||||
reason: expect.stringContaining("unrecognized bits"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LC_LAZY_LOAD_DYLIB_INFO image offsets", () => {
|
||||
it("rejects flag and chain-start offsets outside the slice VM range", async () => {
|
||||
const beyondPage = await read(
|
||||
machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
flagImageOffset: 0x4001,
|
||||
}),
|
||||
);
|
||||
expect(beyondPage).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("flag image offset 0x4001"),
|
||||
});
|
||||
const beyondChain = await read(
|
||||
machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
chainStartImageOffset: 0x4001,
|
||||
}),
|
||||
);
|
||||
expect(beyondChain).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("chain start image offset 0x4001"),
|
||||
});
|
||||
const onPage = await read(
|
||||
machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
flagImageOffset: 0x4000,
|
||||
chainStartImageOffset: 0x4000,
|
||||
}),
|
||||
);
|
||||
expect(onPage.status).toBe("parsed");
|
||||
});
|
||||
|
||||
it("measures the VM range from __TEXT and ignores __LINKEDIT", async () => {
|
||||
const text = segmentCommand({
|
||||
name: "__TEXT",
|
||||
vmaddr: 0x100000000n,
|
||||
vmsize: 0x8000n,
|
||||
});
|
||||
const inside = await read(
|
||||
machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
flagImageOffset: 0x8000,
|
||||
commandsBefore: [text],
|
||||
}),
|
||||
);
|
||||
expect(inside.status).toBe("parsed");
|
||||
const outside = await read(
|
||||
machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
chainStartImageOffset: 0x8001,
|
||||
commandsBefore: [text],
|
||||
}),
|
||||
);
|
||||
expect(outside).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("slice VM range 0x8000"),
|
||||
});
|
||||
const linkedit = await read(
|
||||
machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
flagImageOffset: 0x4001,
|
||||
commandsBefore: [
|
||||
segmentCommand({
|
||||
name: "__LINKEDIT",
|
||||
vmaddr: 0n,
|
||||
vmsize: 0x100000n,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(linkedit.status).toBe("malformed");
|
||||
});
|
||||
|
||||
it("keeps a short segment parseable until a lazy-info command needs its VM range", async () => {
|
||||
const short = new Uint8Array(16);
|
||||
const view = new DataView(short.buffer);
|
||||
view.setUint32(0, 0x19, true);
|
||||
view.setUint32(4, short.length, true);
|
||||
expect((await read(machoImage({ commands: [short] }))).status).toBe(
|
||||
"parsed",
|
||||
);
|
||||
const withLazy = await read(
|
||||
machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
commandsBefore: [short],
|
||||
}),
|
||||
);
|
||||
expect(withLazy).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("slice VM range"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LC_LAZY_LOAD_DYLIB_INFO payload budget", () => {
|
||||
it("reads one shared payload range once", async () => {
|
||||
const payload = lazyLoadDylibInfoPayload({
|
||||
path: "@rpath/libl.dylib",
|
||||
symbols: ["_lf"],
|
||||
});
|
||||
const dataOffset = 32 + 16 + 16 + 16;
|
||||
const image = machoImage({
|
||||
commands: [
|
||||
lazyLoadDylibInfoCommand(dataOffset, payload.length),
|
||||
lazyLoadDylibInfoCommand(dataOffset, payload.length),
|
||||
],
|
||||
});
|
||||
const bytes = new Uint8Array(image.length + payload.length);
|
||||
bytes.set(image);
|
||||
bytes.set(payload, image.length);
|
||||
let payloadReads = 0;
|
||||
const facts = await readMachoImage(async (offset, length) => {
|
||||
if (offset === image.length && length === payload.length)
|
||||
payloadReads += 1;
|
||||
return bytes.subarray(offset, offset + length);
|
||||
}, bytes.length);
|
||||
expect(payloadReads).toBe(1);
|
||||
expect(facts).toMatchObject({
|
||||
status: "parsed",
|
||||
slices: [
|
||||
{
|
||||
dependencies: [
|
||||
{ install_name: "@rpath/libl.dylib" },
|
||||
{ install_name: "@rpath/libl.dylib" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses distinct lazy-info payloads whose combined size exceeds the aggregate limit", async () => {
|
||||
const payload = lazyLoadDylibInfoPayload({ path: "@rpath/libl.dylib" });
|
||||
const dataOffset = 32 + 16 + 32;
|
||||
const image = machoImage({
|
||||
commands: [
|
||||
lazyLoadDylibInfoCommand(dataOffset, payload.length),
|
||||
lazyLoadDylibInfoCommand(0, MAX_LOAD_COMMAND_BYTES),
|
||||
],
|
||||
});
|
||||
const bytes = new Uint8Array(MAX_LOAD_COMMAND_BYTES);
|
||||
bytes.set(image);
|
||||
bytes.set(payload, image.length);
|
||||
expect(await read(bytes)).toMatchObject({
|
||||
status: "unsupported",
|
||||
reason: expect.stringContaining("aggregate limit"),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reread a payload range contained in an earlier read", async () => {
|
||||
const payload = lazyLoadDylibInfoPayload({
|
||||
path: "@rpath/libl.dylib",
|
||||
symbols: ["_lf"],
|
||||
});
|
||||
const image = machoImage({
|
||||
commands: [
|
||||
lazyLoadDylibInfoCommand(0, payload.length + 8),
|
||||
lazyLoadDylibInfoCommand(0, payload.length),
|
||||
],
|
||||
});
|
||||
const bytes = new Uint8Array(image.length + payload.length + 8);
|
||||
bytes.set(image);
|
||||
bytes.set(payload, image.length);
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint32(32 + 8, image.length, true);
|
||||
view.setUint32(48 + 8, image.length, true);
|
||||
let payloadReads = 0;
|
||||
const facts = await readMachoImage(async (offset, length) => {
|
||||
if (offset === image.length) payloadReads += 1;
|
||||
return bytes.subarray(offset, offset + length);
|
||||
}, bytes.length);
|
||||
expect(payloadReads).toBe(1);
|
||||
expect(facts).toMatchObject({
|
||||
status: "parsed",
|
||||
slices: [
|
||||
{
|
||||
dependencies: [
|
||||
{ install_name: "@rpath/libl.dylib" },
|
||||
{ install_name: "@rpath/libl.dylib" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses distinct non-nested lazy-info payloads under the dependency limit", async () => {
|
||||
const count = 8;
|
||||
const payloadSize = 26;
|
||||
const payload = new Uint8Array(payloadSize);
|
||||
new DataView(payload.buffer).setUint32(0, 24, true);
|
||||
new DataView(payload.buffer).setUint32(20, 24, true);
|
||||
const dataOffset = 32 + 16 + count * 16;
|
||||
const commands = Array.from({ length: count }, (_, index) =>
|
||||
lazyLoadDylibInfoCommand(dataOffset + index * payloadSize, payloadSize),
|
||||
);
|
||||
const image = machoImage({ commands });
|
||||
const bytes = new Uint8Array(image.length + count * payloadSize);
|
||||
bytes.set(image);
|
||||
for (let index = 0; index < count; index += 1)
|
||||
bytes.set(payload, image.length + index * payloadSize);
|
||||
const facts = await read(bytes);
|
||||
if (facts.status !== "parsed")
|
||||
throw new Error(
|
||||
facts.status === "not-mach-o" ? facts.status : facts.reason,
|
||||
);
|
||||
expect(facts.slices[0]?.dependencies).toHaveLength(count);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LC_LAZY_LOAD_DYLIB_INFO dependency limit", () => {
|
||||
it("retains one result per command until the dependency limit", async () => {
|
||||
const payload = lazyLoadDylibInfoPayload({ path: "@rpath/libl.dylib" });
|
||||
const imageFor = (count: number) => {
|
||||
const dataOffset = 32 + 16 + count * 16;
|
||||
const image = machoImage({
|
||||
commands: Array.from({ length: count }, () =>
|
||||
lazyLoadDylibInfoCommand(dataOffset, payload.length),
|
||||
),
|
||||
});
|
||||
const bytes = new Uint8Array(image.length + payload.length);
|
||||
bytes.set(image);
|
||||
bytes.set(payload, image.length);
|
||||
return bytes;
|
||||
};
|
||||
const atLimit = await read(imageFor(MAX_LAZY_INFO_DEPENDENCIES));
|
||||
if (atLimit.status !== "parsed")
|
||||
throw new Error(
|
||||
atLimit.status === "not-mach-o" ? atLimit.status : atLimit.reason,
|
||||
);
|
||||
expect(atLimit.slices[0]?.dependencies).toHaveLength(
|
||||
MAX_LAZY_INFO_DEPENDENCIES,
|
||||
);
|
||||
expect(await read(imageFor(MAX_LAZY_INFO_DEPENDENCIES + 1))).toMatchObject({
|
||||
status: "unsupported",
|
||||
reason: expect.stringContaining("dependency limit"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LC_LAZY_LOAD_DYLIB_INFO string bounds", () => {
|
||||
it("does not reuse a cached path that extends past a shorter payload", async () => {
|
||||
const payload = new Uint8Array(48);
|
||||
const view = new DataView(payload.buffer);
|
||||
const path = new TextEncoder().encode("@rpath/libl.dylib");
|
||||
view.setUint32(0, 24, true);
|
||||
view.setUint32(20, 24, true);
|
||||
payload.set(path, 24);
|
||||
const image = machoImage({
|
||||
commands: [
|
||||
lazyLoadDylibInfoCommand(0, payload.length),
|
||||
lazyLoadDylibInfoCommand(0, 24),
|
||||
],
|
||||
});
|
||||
const bytes = new Uint8Array(image.length + payload.length);
|
||||
bytes.set(image);
|
||||
bytes.set(payload, image.length);
|
||||
const patched = new DataView(bytes.buffer);
|
||||
patched.setUint32(32 + 8, image.length, true);
|
||||
patched.setUint32(48 + 8, image.length, true);
|
||||
expect(await read(bytes)).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("outside the payload"),
|
||||
});
|
||||
const truncated = machoImage({
|
||||
commands: [
|
||||
lazyLoadDylibInfoCommand(0, payload.length),
|
||||
lazyLoadDylibInfoCommand(0, 40),
|
||||
],
|
||||
});
|
||||
const truncatedBytes = new Uint8Array(truncated.length + payload.length);
|
||||
truncatedBytes.set(truncated);
|
||||
truncatedBytes.set(payload, truncated.length);
|
||||
const truncatedView = new DataView(truncatedBytes.buffer);
|
||||
truncatedView.setUint32(32 + 8, truncated.length, true);
|
||||
truncatedView.setUint32(48 + 8, truncated.length, true);
|
||||
expect(await read(truncatedBytes)).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("unterminated"),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a load path taken from the payload header", async () => {
|
||||
const payload = new Uint8Array(24);
|
||||
const view = new DataView(payload.buffer);
|
||||
view.setUint32(0, 4, true);
|
||||
view.setUint32(20, 24, true);
|
||||
const image = machoImage({
|
||||
commands: [lazyLoadDylibInfoCommand(0, payload.length)],
|
||||
});
|
||||
const bytes = new Uint8Array(image.length + payload.length);
|
||||
bytes.set(image);
|
||||
bytes.set(payload, image.length);
|
||||
new DataView(bytes.buffer).setUint32(32 + 8, image.length, true);
|
||||
expect(await read(bytes)).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("inside the payload header"),
|
||||
});
|
||||
const symbolPayload = lazyLoadDylibInfoPayload({
|
||||
path: "@rpath/libl.dylib",
|
||||
symbols: ["_lf"],
|
||||
});
|
||||
new DataView(symbolPayload.buffer).setUint32(24, 4, true);
|
||||
const symbolImage = machoImage({
|
||||
commands: [lazyLoadDylibInfoCommand(0, symbolPayload.length)],
|
||||
});
|
||||
const symbolBytes = new Uint8Array(
|
||||
symbolImage.length + symbolPayload.length,
|
||||
);
|
||||
symbolBytes.set(symbolImage);
|
||||
symbolBytes.set(symbolPayload, symbolImage.length);
|
||||
new DataView(symbolBytes.buffer).setUint32(
|
||||
32 + 8,
|
||||
symbolImage.length,
|
||||
true,
|
||||
);
|
||||
expect(await read(symbolBytes)).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("symbol 0"),
|
||||
});
|
||||
});
|
||||
|
||||
it("charges repeated and overlapping symbol scans against the string budget", async () => {
|
||||
const path = new TextEncoder().encode("@rpath/libl.dylib");
|
||||
const stringBytes = 64 * 1024;
|
||||
const imageFor = (symbolCount: number, step: number) => {
|
||||
const pathOffset = 24 + symbolCount * 4;
|
||||
const stringOffset = pathOffset + path.length + 1;
|
||||
const payload = new Uint8Array(stringOffset + stringBytes + 1);
|
||||
const view = new DataView(payload.buffer);
|
||||
view.setUint32(0, pathOffset, true);
|
||||
view.setUint32(16, symbolCount, true);
|
||||
view.setUint32(20, 24, true);
|
||||
payload.set(path, pathOffset);
|
||||
payload.fill(0x61, stringOffset, stringOffset + stringBytes);
|
||||
for (let symbol = 0; symbol < symbolCount; symbol += 1)
|
||||
view.setUint32(
|
||||
24 + symbol * 4,
|
||||
stringOffset + (step === 0 ? 0 : symbol % 8),
|
||||
true,
|
||||
);
|
||||
const image = machoImage({
|
||||
commands: [lazyLoadDylibInfoCommand(0, payload.length)],
|
||||
});
|
||||
const bytes = new Uint8Array(image.length + payload.length);
|
||||
bytes.set(image);
|
||||
bytes.set(payload, image.length);
|
||||
new DataView(bytes.buffer).setUint32(32 + 8, image.length, true);
|
||||
return bytes;
|
||||
};
|
||||
expect(imageFor(2, 0).length).toBeLessThan(MAX_LOAD_COMMAND_BYTES);
|
||||
expect(await read(imageFor(2, 0))).toMatchObject({
|
||||
status: "parsed",
|
||||
slices: [{ dependencies: [{ install_name: "@rpath/libl.dylib" }] }],
|
||||
});
|
||||
const over = Math.floor(MAX_LOAD_COMMAND_BYTES / (stringBytes + 1)) + 2;
|
||||
expect(await read(imageFor(over, 0))).toMatchObject({
|
||||
status: "unsupported",
|
||||
reason: expect.stringContaining("string validation"),
|
||||
});
|
||||
expect(await read(imageFor(over, 1))).toMatchObject({
|
||||
status: "unsupported",
|
||||
reason: expect.stringContaining("string validation"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const lazyInfoPayloadWithSymbolCount = (
|
||||
symbolCount: number,
|
||||
pathText: string,
|
||||
): Uint8Array => {
|
||||
const path = new TextEncoder().encode(pathText);
|
||||
const pathOffset = 24 + symbolCount * 4;
|
||||
// One byte after the path NUL, because a load-path terminator in the
|
||||
// payload's final byte is unterminated.
|
||||
const payload = new Uint8Array(pathOffset + path.length + 2);
|
||||
const view = new DataView(payload.buffer);
|
||||
view.setUint32(0, pathOffset, true);
|
||||
view.setUint32(16, symbolCount, true);
|
||||
view.setUint32(20, 24, true);
|
||||
payload.set(path, pathOffset);
|
||||
const emptySymbol = pathOffset + path.length;
|
||||
for (let symbol = 0; symbol < symbolCount; symbol += 1)
|
||||
view.setUint32(24 + symbol * 4, emptySymbol, true);
|
||||
return payload;
|
||||
};
|
||||
|
||||
const imageWithLazyPayload = (payload: Uint8Array): Uint8Array => {
|
||||
const image = machoImage({
|
||||
commands: [lazyLoadDylibInfoCommand(0, payload.length)],
|
||||
});
|
||||
const bytes = new Uint8Array(image.length + payload.length);
|
||||
bytes.set(image);
|
||||
bytes.set(payload, image.length);
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint32(32 + 8, image.length, true);
|
||||
view.setUint32(32 + 12, payload.length, true);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
describe("LC_LAZY_LOAD_DYLIB_INFO symbol cardinality", () => {
|
||||
it("stops before caching one string for every symbol offset", async () => {
|
||||
const atLimit = lazyInfoPayloadWithSymbolCount(
|
||||
MAX_LAZY_INFO_SYMBOL_REFERENCES,
|
||||
"@rpath/libl.dylib",
|
||||
);
|
||||
expect(await read(imageWithLazyPayload(atLimit))).toMatchObject({
|
||||
status: "parsed",
|
||||
slices: [{ dependencies: [{ install_name: "@rpath/libl.dylib" }] }],
|
||||
});
|
||||
const overLimit = lazyInfoPayloadWithSymbolCount(
|
||||
MAX_LAZY_INFO_SYMBOL_REFERENCES + 1,
|
||||
"@rpath/libl.dylib",
|
||||
);
|
||||
expect(await read(imageWithLazyPayload(overLimit))).toMatchObject({
|
||||
status: "unsupported",
|
||||
reason: expect.stringContaining("65536-reference limit"),
|
||||
});
|
||||
});
|
||||
|
||||
it("counts symbol references across distinct lazy-info payloads", async () => {
|
||||
const first = lazyInfoPayloadWithSymbolCount(
|
||||
MAX_LAZY_INFO_SYMBOL_REFERENCES,
|
||||
"@rpath/libl.dylib",
|
||||
);
|
||||
const second = lazyInfoPayloadWithSymbolCount(1, "@rpath/libm.dylib");
|
||||
const image = machoImage({
|
||||
commands: [
|
||||
lazyLoadDylibInfoCommand(0, first.length),
|
||||
lazyLoadDylibInfoCommand(0, second.length),
|
||||
],
|
||||
});
|
||||
const bytes = new Uint8Array(image.length + first.length + second.length);
|
||||
bytes.set(image);
|
||||
bytes.set(first, image.length);
|
||||
bytes.set(second, image.length + first.length);
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint32(32 + 8, image.length, true);
|
||||
view.setUint32(32 + 12, first.length, true);
|
||||
view.setUint32(48 + 8, image.length + first.length, true);
|
||||
view.setUint32(48 + 12, second.length, true);
|
||||
expect(await read(bytes)).toMatchObject({
|
||||
status: "unsupported",
|
||||
reason: expect.stringContaining("65536-reference limit"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LC_LAZY_LOAD_DYLIB_INFO path terminator", () => {
|
||||
it("rejects a load path whose NUL is the payload's final byte", async () => {
|
||||
const payload = new Uint8Array(27);
|
||||
const view = new DataView(payload.buffer);
|
||||
view.setUint32(0, 24, true);
|
||||
view.setUint32(20, 24, true);
|
||||
payload[24] = 0x61;
|
||||
const imageFor = (size: number) => {
|
||||
const image = machoImage({
|
||||
commands: [lazyLoadDylibInfoCommand(0, size)],
|
||||
});
|
||||
const bytes = new Uint8Array(image.length + payload.length);
|
||||
bytes.set(image);
|
||||
bytes.set(payload, image.length);
|
||||
new DataView(bytes.buffer).setUint32(32 + 8, image.length, true);
|
||||
new DataView(bytes.buffer).setUint32(32 + 12, size, true);
|
||||
return bytes;
|
||||
};
|
||||
expect((await read(imageFor(payload.length))).status).toBe("parsed");
|
||||
expect(await read(imageFor(26))).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("unterminated"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LC_LAZY_LOAD_DYLIB_INFO resolution", () => {
|
||||
it("reports the lazy edge and does not treat an absent image as a launch failure", async () => {
|
||||
const root = await createTestTempDirectory("rea-lazy-info-");
|
||||
const executablePath = "lazy";
|
||||
const dylibPath = "lib/libl.dylib";
|
||||
const executable = machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
symbols: ["_lf"],
|
||||
commandsBefore: [
|
||||
buildVersionCommand(1),
|
||||
rpathCommand("@executable_path/lib"),
|
||||
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib"),
|
||||
],
|
||||
});
|
||||
const dylib = machoImage({
|
||||
fileType: FILE_TYPE.dylib,
|
||||
commands: [
|
||||
buildVersionCommand(1),
|
||||
dylibCommand(LC.ID_DYLIB, "@rpath/libl.dylib"),
|
||||
dylibCommand(LC.LOAD_DYLIB, "@rpath/libdeep.dylib"),
|
||||
],
|
||||
});
|
||||
await mkdir(join(root, "lib"));
|
||||
await writeFile(join(root, executablePath), executable);
|
||||
await writeFile(join(root, dylibPath), dylib);
|
||||
const trace = () =>
|
||||
traceDylibResolution({
|
||||
rootPath: root,
|
||||
targetPath: join(root, executablePath),
|
||||
targetSha256: createHash("sha256").update(executable).digest("hex"),
|
||||
enumerateRoots: false,
|
||||
parameters: { roots: [executablePath] },
|
||||
});
|
||||
const present = await trace();
|
||||
expect(present.coverage.status).toBe("complete");
|
||||
expect(present.edges.map(({ install_name: name }) => name)).toEqual([
|
||||
"/usr/lib/libSystem.B.dylib",
|
||||
"@rpath/libl.dylib",
|
||||
]);
|
||||
expect(present.edges[1]).toMatchObject({
|
||||
command: "LC_LAZY_LOAD_DYLIB_INFO",
|
||||
encoding: "lazy_load_dylib_info",
|
||||
current_version: null,
|
||||
compatibility_version: null,
|
||||
resolution: { status: "resolved", image: dylibPath },
|
||||
});
|
||||
expect(present.findings).toEqual([]);
|
||||
await rename(join(root, dylibPath), `${join(root, dylibPath)}.moved`);
|
||||
const absent = await trace();
|
||||
expect(absent.coverage.status).toBe("complete");
|
||||
expect(absent.findings.map(({ kind }) => kind)).toEqual([
|
||||
"lazy-load-unresolved",
|
||||
]);
|
||||
expect(absent.edges[1]?.resolution.status).toBe("unresolved");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mach-O load command reader: reads and validates universal binary slices", () => {
|
||||
it("reads every slice of FAT and FAT_64 containers in either byte order", async () => {
|
||||
for (const [wide, littleEndian] of [
|
||||
|
||||
@@ -18,6 +18,18 @@ export type ReadAt = (offset: number, length: number) => Promise<Uint8Array>;
|
||||
|
||||
/** Load commands larger than this are refused instead of buffered. */
|
||||
export const MAX_LOAD_COMMAND_BYTES = 16 * 1024 * 1024;
|
||||
/**
|
||||
* Identical `LC_LAZY_LOAD_DYLIB_INFO` commands are 16 bytes, so the load-command
|
||||
* byte cap alone would retain about a million dependency results. Stop before
|
||||
* those results are kept or traced.
|
||||
*/
|
||||
export const MAX_LAZY_INFO_DEPENDENCIES = 1024;
|
||||
/**
|
||||
* Symbol names are validated and then discarded. Distinct one-byte strings
|
||||
* stay under the 16 MiB byte budgets while retaining one cache entry per
|
||||
* offset, so stop before that map and those decodes grow.
|
||||
*/
|
||||
export const MAX_LAZY_INFO_SYMBOL_REFERENCES = 64 * 1024;
|
||||
const MH_MAGIC = 0xfeedface;
|
||||
const MH_MAGIC_64 = 0xfeedfacf;
|
||||
const MH_CIGAM = 0xcefaedfe;
|
||||
@@ -27,11 +39,14 @@ const FAT_MAGIC_64 = 0xcafebabf;
|
||||
const FAT_CIGAM = 0xbebafeca;
|
||||
const FAT_CIGAM_64 = 0xbfbafeca;
|
||||
|
||||
const LC_SEGMENT = 0x1;
|
||||
const LC_SEGMENT_64 = 0x19;
|
||||
const LC_ID_DYLIB = 0xd;
|
||||
const LC_LOAD_DYLIB = 0xc;
|
||||
const LC_LOAD_WEAK_DYLIB = 0x80000018;
|
||||
const LC_REEXPORT_DYLIB = 0x8000001f;
|
||||
const LC_LAZY_LOAD_DYLIB = 0x20;
|
||||
const LC_LAZY_LOAD_DYLIB_INFO = 0x3a;
|
||||
const LC_LOAD_UPWARD_DYLIB = 0x80000023;
|
||||
const LC_RPATH = 0x8000001c;
|
||||
const LC_DYLD_ENVIRONMENT = 0x27;
|
||||
@@ -85,6 +100,14 @@ const legacyVersionMinPlatform = (
|
||||
|
||||
/** `mach-o/loader.h` `dylib_use_command`: `nameoff == 28` and this marker. */
|
||||
const DYLIB_USE_MARKER = 0x1a741800;
|
||||
const LAZY_INFO_HEADER_BYTES = 24;
|
||||
/** ld64 may-be-missing bit: the lazy image is allowed to be absent. */
|
||||
const LAZY_INFO_MAY_BE_MISSING = 0x1;
|
||||
/**
|
||||
* dyld's linkedit image-offset checks start at one page, then grow to the
|
||||
* runtime end of every segment except `__LINKEDIT`.
|
||||
*/
|
||||
const LAZY_INFO_MIN_VM_OFFSET = 0x4000n;
|
||||
const DYLIB_USE_WEAK_LINK = 0x1;
|
||||
const DYLIB_USE_REEXPORT = 0x2;
|
||||
const DYLIB_USE_UPWARD = 0x4;
|
||||
@@ -293,7 +316,7 @@ const readSlice = async (
|
||||
commandBytes,
|
||||
"load commands",
|
||||
);
|
||||
const slice = decodeCommands(commands, {
|
||||
const decoded = decodeCommands(commands, {
|
||||
wide,
|
||||
cpuType: identity.cpuType,
|
||||
cpuSubtype: identity.cpuSubtype,
|
||||
@@ -305,6 +328,15 @@ const readSlice = async (
|
||||
fatCpuSubtype: fat?.fatCpuSubtype ?? null,
|
||||
fatAlignmentExponent: fat?.alignmentExponent ?? null,
|
||||
});
|
||||
const slice = decoded.slice;
|
||||
await appendDependencies(
|
||||
slice,
|
||||
decoded.dependencies,
|
||||
readAt,
|
||||
base,
|
||||
length,
|
||||
decoded.maxVmOffset,
|
||||
);
|
||||
const distinctPlatforms = [...new Set(slice.platforms)];
|
||||
slice.platform =
|
||||
distinctPlatforms.length === 1 ? (distinctPlatforms[0] ?? null) : null;
|
||||
@@ -334,7 +366,14 @@ const decodeCommands = (
|
||||
readonly fatCpuSubtype: number | null;
|
||||
readonly fatAlignmentExponent: number | null;
|
||||
},
|
||||
): MachoSlice => {
|
||||
): {
|
||||
readonly slice: MachoSlice;
|
||||
readonly dependencies: readonly (
|
||||
| { readonly kind: "parsed"; readonly value: MachoDependency }
|
||||
| LazyLoadDylibInfoCommand
|
||||
)[];
|
||||
readonly maxVmOffset: bigint;
|
||||
} => {
|
||||
const view = viewOf(bytes);
|
||||
const slice: MachoSlice = {
|
||||
architecture: architectureName(header.cpuType, header.cpuSubtype),
|
||||
@@ -355,6 +394,14 @@ const decodeCommands = (
|
||||
dyld_environment: [],
|
||||
code_signature_present: false,
|
||||
};
|
||||
const dependencies: Array<
|
||||
| { readonly kind: "parsed"; readonly value: MachoDependency }
|
||||
| LazyLoadDylibInfoCommand
|
||||
> = [];
|
||||
const segments: SegmentSpan[] = [];
|
||||
let unreadableSegment: number | undefined;
|
||||
let lazyInfo = false;
|
||||
let lazyInfoCount = 0;
|
||||
let offset = 0;
|
||||
for (let index = 0; index < header.commandCount; index++) {
|
||||
if (offset + 8 > bytes.byteLength)
|
||||
@@ -374,7 +421,22 @@ const decodeCommands = (
|
||||
`load command ${index} at offset ${offset} has invalid cmdsize ${size}`,
|
||||
);
|
||||
const body = bytes.subarray(offset, offset + size);
|
||||
decodeCommand(command, body, index, slice);
|
||||
if (command === LC_SEGMENT || command === LC_SEGMENT_64) {
|
||||
const span = readSegmentSpan(body, command === LC_SEGMENT_64);
|
||||
if (span === undefined) unreadableSegment ??= index;
|
||||
else segments.push(span);
|
||||
}
|
||||
if (command === LC_LAZY_LOAD_DYLIB_INFO) {
|
||||
lazyInfoCount += 1;
|
||||
if (lazyInfoCount > MAX_LAZY_INFO_DEPENDENCIES)
|
||||
throw new MachoFormatIssue(
|
||||
"unsupported",
|
||||
`LC_LAZY_LOAD_DYLIB_INFO commands exceed the ${MAX_LAZY_INFO_DEPENDENCIES}-dependency limit`,
|
||||
);
|
||||
lazyInfo = true;
|
||||
}
|
||||
const decoded = decodeCommand(command, body, index, slice);
|
||||
if (decoded !== undefined) dependencies.push(decoded);
|
||||
offset += size;
|
||||
}
|
||||
if (offset !== bytes.byteLength)
|
||||
@@ -382,7 +444,150 @@ const decodeCommands = (
|
||||
"malformed",
|
||||
`load commands declare ${header.commandCount} commands but sizeofcmds holds ${bytes.byteLength} bytes`,
|
||||
);
|
||||
return slice;
|
||||
if (lazyInfo && unreadableSegment !== undefined)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${unreadableSegment} is too short to establish the slice VM range for LC_LAZY_LOAD_DYLIB_INFO`,
|
||||
);
|
||||
return { slice, dependencies, maxVmOffset: maximumVmOffset(segments) };
|
||||
};
|
||||
|
||||
interface LazyLoadDylibInfoCommand {
|
||||
readonly kind: "lazy-info";
|
||||
readonly index: number;
|
||||
readonly dataOffset: number;
|
||||
readonly dataSize: number;
|
||||
}
|
||||
|
||||
interface SegmentSpan {
|
||||
readonly name: string;
|
||||
readonly vmaddr: bigint;
|
||||
readonly vmsize: bigint;
|
||||
}
|
||||
|
||||
/** Runtime end of the mapped image, excluding `__LINKEDIT`, floored at one page. */
|
||||
const maximumVmOffset = (segments: readonly SegmentSpan[]): bigint => {
|
||||
let textVmAddr = 0n;
|
||||
for (const segment of segments) {
|
||||
if (segment.name === "__TEXT") {
|
||||
textVmAddr = segment.vmaddr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let max = LAZY_INFO_MIN_VM_OFFSET;
|
||||
for (const segment of segments) {
|
||||
if (segment.name === "__LINKEDIT") continue;
|
||||
const end = segment.vmaddr - textVmAddr + segment.vmsize;
|
||||
if (end > max) max = end;
|
||||
}
|
||||
return max;
|
||||
};
|
||||
|
||||
const readSegmentSpan = (
|
||||
body: Uint8Array,
|
||||
wide: boolean,
|
||||
): SegmentSpan | undefined => {
|
||||
if (body.byteLength < (wide ? 72 : 56)) return undefined;
|
||||
const view = viewOf(body);
|
||||
const nameBytes = body.subarray(8, 24);
|
||||
const end = nameBytes.indexOf(0);
|
||||
return {
|
||||
name: new TextDecoder("utf-8", { fatal: false }).decode(
|
||||
end < 0 ? nameBytes : nameBytes.subarray(0, end),
|
||||
),
|
||||
vmaddr: wide
|
||||
? view.getBigUint64(24, true)
|
||||
: BigInt(view.getUint32(24, true)),
|
||||
vmsize: wide
|
||||
? view.getBigUint64(32, true)
|
||||
: BigInt(view.getUint32(28, true)),
|
||||
};
|
||||
};
|
||||
|
||||
interface CachedLazyString {
|
||||
readonly text: string;
|
||||
/** Absolute file offset of the terminating NUL. */
|
||||
readonly end: number;
|
||||
}
|
||||
|
||||
interface LazyPayloadWork {
|
||||
readonly dependencies: Map<string, MachoDependency>;
|
||||
readonly reads: { fileOffset: number; bytes: Uint8Array }[];
|
||||
readonly strings: Map<number, CachedLazyString>;
|
||||
payloadBytes: number;
|
||||
/** Scanned string bytes. Repeats and overlaps each count in full. */
|
||||
stringBytes: number;
|
||||
/** Symbol-string references. Repeats each count. */
|
||||
symbolReferences: number;
|
||||
rangeChecks: number;
|
||||
}
|
||||
|
||||
const containedPayload = (
|
||||
reads: readonly { fileOffset: number; bytes: Uint8Array }[],
|
||||
fileOffset: number,
|
||||
size: number,
|
||||
work: LazyPayloadWork,
|
||||
): Uint8Array | undefined => {
|
||||
for (const read of reads) {
|
||||
work.rangeChecks += 1;
|
||||
if (work.rangeChecks > MAX_LOAD_COMMAND_BYTES)
|
||||
throw new MachoFormatIssue(
|
||||
"unsupported",
|
||||
`LC_LAZY_LOAD_DYLIB_INFO payload range lookup exceeds the ${MAX_LOAD_COMMAND_BYTES}-check limit`,
|
||||
);
|
||||
const start = fileOffset - read.fileOffset;
|
||||
if (
|
||||
start >= 0 &&
|
||||
Number.isSafeInteger(start) &&
|
||||
start + size <= read.bytes.byteLength
|
||||
)
|
||||
return read.bytes.subarray(start, start + size);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const appendDependencies = async (
|
||||
slice: MachoSlice,
|
||||
dependencies: readonly (
|
||||
| { readonly kind: "parsed"; readonly value: MachoDependency }
|
||||
| LazyLoadDylibInfoCommand
|
||||
)[],
|
||||
readAt: ReadAt,
|
||||
base: number,
|
||||
length: number,
|
||||
maxVmOffset: bigint,
|
||||
): Promise<void> => {
|
||||
const work: LazyPayloadWork = {
|
||||
dependencies: new Map(),
|
||||
reads: [],
|
||||
strings: new Map(),
|
||||
payloadBytes: 0,
|
||||
stringBytes: 0,
|
||||
symbolReferences: 0,
|
||||
rangeChecks: 0,
|
||||
};
|
||||
for (const dependency of dependencies) {
|
||||
if (dependency.kind === "parsed") {
|
||||
slice.dependencies.push(dependency.value);
|
||||
continue;
|
||||
}
|
||||
const key = `${dependency.dataOffset}:${dependency.dataSize}`;
|
||||
const cached = work.dependencies.get(key);
|
||||
if (cached !== undefined) {
|
||||
slice.dependencies.push(cached);
|
||||
continue;
|
||||
}
|
||||
const value = await readLazyLoadDylibInfo(
|
||||
readAt,
|
||||
base,
|
||||
length,
|
||||
dependency,
|
||||
maxVmOffset,
|
||||
work,
|
||||
);
|
||||
work.dependencies.set(key, value);
|
||||
slice.dependencies.push(value);
|
||||
}
|
||||
};
|
||||
|
||||
const decodeCommand = (
|
||||
@@ -390,7 +595,10 @@ const decodeCommand = (
|
||||
body: Uint8Array,
|
||||
index: number,
|
||||
slice: MachoSlice,
|
||||
): void => {
|
||||
):
|
||||
| { readonly kind: "parsed"; readonly value: MachoDependency }
|
||||
| LazyLoadDylibInfoCommand
|
||||
| undefined => {
|
||||
if (command === LC_ID_DYLIB) {
|
||||
slice.install_name = commandString(body, 24, index);
|
||||
return;
|
||||
@@ -440,9 +648,270 @@ const decodeCommand = (
|
||||
slice.platforms.push(legacyPlatform);
|
||||
return;
|
||||
}
|
||||
if (command === LC_LAZY_LOAD_DYLIB_INFO)
|
||||
return decodeLazyLoadDylibInfoCommand(body, index);
|
||||
const name = DEPENDENCY_COMMANDS.get(command);
|
||||
if (name !== undefined)
|
||||
slice.dependencies.push(decodeDependency(name, body, index));
|
||||
return { kind: "parsed", value: decodeDependency(name, body, index) };
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* `LC_LAZY_LOAD_DYLIB_INFO` is a linkedit-data command. The install name is
|
||||
* not in the command; it is a NUL-terminated string in the payload.
|
||||
*/
|
||||
const decodeLazyLoadDylibInfoCommand = (
|
||||
body: Uint8Array,
|
||||
index: number,
|
||||
): LazyLoadDylibInfoCommand => {
|
||||
if (body.byteLength < 16)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} is too short for LC_LAZY_LOAD_DYLIB_INFO`,
|
||||
);
|
||||
const view = viewOf(body);
|
||||
const dataOffset = view.getUint32(8, true);
|
||||
const dataSize = view.getUint32(12, true);
|
||||
if (dataSize < LAZY_INFO_HEADER_BYTES)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO payload is shorter than its 24-byte header`,
|
||||
);
|
||||
if (dataSize > MAX_LOAD_COMMAND_BYTES)
|
||||
throw new MachoFormatIssue(
|
||||
"unsupported",
|
||||
`LC_LAZY_LOAD_DYLIB_INFO payload occupies ${dataSize} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte limit`,
|
||||
);
|
||||
return { kind: "lazy-info", index, dataOffset, dataSize };
|
||||
};
|
||||
|
||||
const readLazyPayloadBytes = async (
|
||||
readAt: ReadAt,
|
||||
fileOffset: number,
|
||||
size: number,
|
||||
index: number,
|
||||
work: LazyPayloadWork,
|
||||
): Promise<Uint8Array> => {
|
||||
const covered = containedPayload(work.reads, fileOffset, size, work);
|
||||
if (covered !== undefined) return covered;
|
||||
work.payloadBytes += size;
|
||||
if (work.payloadBytes > MAX_LOAD_COMMAND_BYTES)
|
||||
throw new MachoFormatIssue(
|
||||
"unsupported",
|
||||
`LC_LAZY_LOAD_DYLIB_INFO payloads occupy ${work.payloadBytes} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte aggregate limit`,
|
||||
);
|
||||
const payload = await readExact(
|
||||
readAt,
|
||||
fileOffset,
|
||||
size,
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO payload`,
|
||||
);
|
||||
work.reads.push({ fileOffset, bytes: payload });
|
||||
return payload;
|
||||
};
|
||||
|
||||
const readLazyLoadDylibInfo = async (
|
||||
readAt: ReadAt,
|
||||
base: number,
|
||||
length: number,
|
||||
command: LazyLoadDylibInfoCommand,
|
||||
maxVmOffset: bigint,
|
||||
work: LazyPayloadWork,
|
||||
): Promise<MachoDependency> => {
|
||||
if (
|
||||
command.dataOffset > length ||
|
||||
command.dataSize > length - command.dataOffset
|
||||
)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${command.index} LC_LAZY_LOAD_DYLIB_INFO payload extends beyond the Mach-O slice`,
|
||||
);
|
||||
const fileOffset = base + command.dataOffset;
|
||||
if (!Number.isSafeInteger(fileOffset))
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${command.index} LC_LAZY_LOAD_DYLIB_INFO payload offset is not a safe integer`,
|
||||
);
|
||||
const payload = await readLazyPayloadBytes(
|
||||
readAt,
|
||||
fileOffset,
|
||||
command.dataSize,
|
||||
command.index,
|
||||
work,
|
||||
);
|
||||
return decodeLazyLoadDylibInfoPayload(
|
||||
payload,
|
||||
command.index,
|
||||
fileOffset,
|
||||
maxVmOffset,
|
||||
work,
|
||||
);
|
||||
};
|
||||
|
||||
const decodeLazyLoadDylibInfoPayload = (
|
||||
payload: Uint8Array,
|
||||
index: number,
|
||||
fileOffset: number,
|
||||
maxVmOffset: bigint,
|
||||
work: LazyPayloadWork,
|
||||
): MachoDependency => {
|
||||
const view = viewOf(payload);
|
||||
const pathOffset = view.getUint32(0, true);
|
||||
const flagImageOffset = view.getUint32(4, true);
|
||||
const flags = view.getUint16(8, true);
|
||||
const chainStartImageOffset = view.getUint32(12, true);
|
||||
const symbolCount = view.getUint32(16, true);
|
||||
const symbolArrayOffset = view.getUint32(20, true);
|
||||
if (BigInt(flagImageOffset) > maxVmOffset)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO flag image offset 0x${flagImageOffset.toString(16)} is beyond the slice VM range 0x${maxVmOffset.toString(16)}`,
|
||||
);
|
||||
if (BigInt(chainStartImageOffset) > maxVmOffset)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO chain start image offset 0x${chainStartImageOffset.toString(16)} is beyond the slice VM range 0x${maxVmOffset.toString(16)}`,
|
||||
);
|
||||
if ((flags & ~LAZY_INFO_MAY_BE_MISSING) !== 0)
|
||||
throw new MachoFormatIssue(
|
||||
"unsupported",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO flags 0x${flags.toString(16)} include unrecognized bits`,
|
||||
);
|
||||
if (
|
||||
symbolArrayOffset > payload.byteLength ||
|
||||
symbolCount > (payload.byteLength - symbolArrayOffset) / 4
|
||||
)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO symbol table extends beyond the payload`,
|
||||
);
|
||||
const installName = payloadString(
|
||||
payload,
|
||||
pathOffset,
|
||||
fileOffset,
|
||||
index,
|
||||
"load path",
|
||||
work,
|
||||
true,
|
||||
);
|
||||
const symbolReferences = work.symbolReferences + symbolCount;
|
||||
if (symbolReferences > MAX_LAZY_INFO_SYMBOL_REFERENCES)
|
||||
throw new MachoFormatIssue(
|
||||
"unsupported",
|
||||
`LC_LAZY_LOAD_DYLIB_INFO symbol validation exceeds the ${MAX_LAZY_INFO_SYMBOL_REFERENCES}-reference limit`,
|
||||
);
|
||||
work.symbolReferences = symbolReferences;
|
||||
for (let symbol = 0; symbol < symbolCount; symbol += 1) {
|
||||
const offset = view.getUint32(symbolArrayOffset + symbol * 4, true);
|
||||
payloadString(
|
||||
payload,
|
||||
offset,
|
||||
fileOffset,
|
||||
index,
|
||||
`symbol ${symbol}`,
|
||||
work,
|
||||
false,
|
||||
);
|
||||
}
|
||||
return {
|
||||
command: "LC_LAZY_LOAD_DYLIB_INFO",
|
||||
encoding: "lazy_load_dylib_info",
|
||||
install_name: installName,
|
||||
weak: (flags & LAZY_INFO_MAY_BE_MISSING) !== 0,
|
||||
upward: false,
|
||||
reexport: false,
|
||||
delayed_init: false,
|
||||
current_version: null,
|
||||
compatibility_version: null,
|
||||
};
|
||||
};
|
||||
|
||||
const chargeLazyString = (work: LazyPayloadWork, examined: number): void => {
|
||||
const occupied = work.stringBytes + examined;
|
||||
if (occupied > MAX_LOAD_COMMAND_BYTES)
|
||||
throw new MachoFormatIssue(
|
||||
"unsupported",
|
||||
`LC_LAZY_LOAD_DYLIB_INFO string validation occupies ${occupied} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte limit`,
|
||||
);
|
||||
work.stringBytes = occupied;
|
||||
};
|
||||
|
||||
/**
|
||||
* NUL-terminated UTF-8 string after the fixed payload header.
|
||||
* dyld's load-path scan stops before the payload's final byte, so a NUL
|
||||
* in that byte does not terminate the path.
|
||||
*/
|
||||
const payloadString = (
|
||||
payload: Uint8Array,
|
||||
offset: number,
|
||||
fileOffset: number,
|
||||
index: number,
|
||||
label: string,
|
||||
work: LazyPayloadWork,
|
||||
excludeFinalByte: boolean,
|
||||
): string => {
|
||||
if (offset < LAZY_INFO_HEADER_BYTES)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO ${label} offset ${offset} is inside the payload header`,
|
||||
);
|
||||
if (offset >= payload.byteLength)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO ${label} offset ${offset} is outside the payload`,
|
||||
);
|
||||
const absolute = Number.isSafeInteger(fileOffset + offset)
|
||||
? fileOffset + offset
|
||||
: undefined;
|
||||
const cached =
|
||||
absolute === undefined ? undefined : work.strings.get(absolute);
|
||||
const terminatorLimit = excludeFinalByte
|
||||
? payload.byteLength - 1
|
||||
: payload.byteLength;
|
||||
if (cached !== undefined && absolute !== undefined) {
|
||||
const end = cached.end - fileOffset;
|
||||
if (end < offset || end >= terminatorLimit)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO ${label} is unterminated`,
|
||||
);
|
||||
chargeLazyString(work, cached.end - absolute + 1);
|
||||
return cached.text;
|
||||
}
|
||||
if (offset >= terminatorLimit)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO ${label} is unterminated`,
|
||||
);
|
||||
const remaining = MAX_LOAD_COMMAND_BYTES - work.stringBytes;
|
||||
const windowEnd = Math.min(terminatorLimit, offset + remaining);
|
||||
const end = payload.subarray(0, windowEnd).indexOf(0, offset);
|
||||
if (end < 0) {
|
||||
if (windowEnd < terminatorLimit)
|
||||
throw new MachoFormatIssue(
|
||||
"unsupported",
|
||||
`LC_LAZY_LOAD_DYLIB_INFO string validation occupies ${work.stringBytes + (windowEnd - offset) + 1} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte limit`,
|
||||
);
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO ${label} is unterminated`,
|
||||
);
|
||||
}
|
||||
chargeLazyString(work, end - offset + 1);
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
||||
payload.subarray(offset, end),
|
||||
);
|
||||
} catch {
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO ${label} is not UTF-8`,
|
||||
);
|
||||
}
|
||||
if (absolute !== undefined && Number.isSafeInteger(fileOffset + end))
|
||||
work.strings.set(absolute, { text, end: fileOffset + end });
|
||||
return text;
|
||||
};
|
||||
|
||||
const decodeDependency = (
|
||||
|
||||
Loaded 100 of 275 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user