Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93dc810170 | ||
|
|
fa318995f5 | ||
|
|
9a44fea347 | ||
|
|
2819defeea | ||
|
|
937b4e69c8 | ||
|
|
1b668a527d | ||
|
|
1e377e6965 | ||
|
|
fb6e2b07e0 | ||
|
|
c4608a2962 | ||
|
|
225f26a5f8 | ||
|
|
a004061abc | ||
|
|
01a1b3fda6 | ||
|
|
3d2846f237 | ||
|
|
709e99d781 | ||
|
|
bb49252523 | ||
|
|
4946688c33 | ||
|
|
333e0ab0df | ||
|
|
c0cfccb45f | ||
|
|
fbdd040324 | ||
|
|
dadf909dce | ||
|
|
097a4bce6f | ||
|
|
c831c23633 | ||
|
|
5a46fbacd5 |
No files matched your search
+41
-899
@@ -1,509 +1,33 @@
|
||||
name: CI
|
||||
name: Experimental Windows readonly-root cleanup red-green
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main, "release/*"]
|
||||
schedule:
|
||||
- cron: "23 0-2,4-23 * * *"
|
||||
- cron: "23 3 * * *"
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
source_sha:
|
||||
description: Exact qualification source; must match the caller revision.
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
# Complete qualification finishes even while main receives more pushes.
|
||||
# Main feedback also finishes, coalescing newer pushes to one pending run.
|
||||
# Scheduled/manual main runs coalesce to one active and the latest pending;
|
||||
# release and label-triggered qualification use exact-revision groups.
|
||||
group: ci-${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || inputs.source_sha != '' || contains(github.event.pull_request.labels.*.name, 'ci:full')) && 'qualification' || 'feedback' }}-${{ inputs.source_sha || github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' && inputs.source_sha == '' && !contains(github.event.pull_request.labels.*.name, 'ci:full') }}
|
||||
|
||||
env:
|
||||
HUSKY: "0"
|
||||
group: windows-readonly-root-red-green-${{ github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Classify changes
|
||||
build-native-pair:
|
||||
name: Build ${{ matrix.variant }} native artifact
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
tier: ${{ steps.plan.outputs.tier }}
|
||||
qualification_run_id: ${{ steps.qualification.outputs.run_id }}
|
||||
code: ${{ steps.plan.outputs.code }}
|
||||
reverse_tools: ${{ steps.plan.outputs.reverse_tools }}
|
||||
sqlite: ${{ steps.plan.outputs.sqlite }}
|
||||
runtime: ${{ steps.plan.outputs.runtime }}
|
||||
package: ${{ steps.plan.outputs.package }}
|
||||
apple: ${{ steps.plan.outputs.apple }}
|
||||
fixtures: ${{ steps.plan.outputs.fixtures }}
|
||||
readiness: ${{ steps.plan.outputs.readiness }}
|
||||
docs: ${{ steps.plan.outputs.docs }}
|
||||
workflows: ${{ steps.plan.outputs.workflows }}
|
||||
browser: ${{ steps.plan.outputs.browser }}
|
||||
binary_layout: ${{ steps.plan.outputs.binary_layout }}
|
||||
evm: ${{ steps.plan.outputs.evm }}
|
||||
javascript_recovery: ${{ steps.plan.outputs.javascript_recovery }}
|
||||
recorded_crash: ${{ steps.plan.outputs.recorded_crash }}
|
||||
web_network: ${{ steps.plan.outputs.web_network }}
|
||||
web_runtime: ${{ steps.plan.outputs.web_runtime }}
|
||||
web_source_map: ${{ steps.plan.outputs.web_source_map }}
|
||||
nativeaot: ${{ steps.plan.outputs.nativeaot }}
|
||||
website: ${{ steps.plan.outputs.website }}
|
||||
matrix: ${{ steps.plan.outputs.matrix }}
|
||||
test_selection: ${{ steps.plan.outputs.test_selection }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
ref: ${{ inputs.source_sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Bind qualification to the caller revision
|
||||
if: inputs.source_sha != ''
|
||||
env:
|
||||
SOURCE_SHA: ${{ inputs.source_sha }}
|
||||
run: |
|
||||
test "${SOURCE_SHA}" = "${GITHUB_SHA}"
|
||||
test "${SOURCE_SHA}" = "$(git rev-parse HEAD)"
|
||||
- name: Find a verified ancestor for push selection
|
||||
id: baseline
|
||||
if: github.event_name == 'push' && inputs.source_sha == ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
CI_REPOSITORY: ${{ github.repository }}
|
||||
CI_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: node scripts/ci/baseline.mjs
|
||||
- name: Admit scheduled full qualification
|
||||
id: qualification
|
||||
if: github.event_name == 'schedule' && inputs.source_sha == ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
CI_REPOSITORY: ${{ github.repository }}
|
||||
CI_REPOSITORY_ID: ${{ github.repository_id }}
|
||||
CI_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
CI_FORCE_FULL: ${{ github.event.schedule == '23 3 * * *' }}
|
||||
run: node scripts/ci/qualification.mjs
|
||||
- name: Select verification lanes
|
||||
id: plan
|
||||
env:
|
||||
BASE_SHA: ${{ steps.baseline.outputs.base || github.event.pull_request.base.sha }}
|
||||
BASELINE_REASON: ${{ steps.baseline.outputs.reason }}
|
||||
HEAD_SHA: ${{ inputs.source_sha || github.event.pull_request.head.sha || github.sha }}
|
||||
CI_FULL: ${{ inputs.source_sha != '' || contains(github.event.pull_request.labels.*.name, 'ci:full') }}
|
||||
CI_SKIP_FULL: ${{ steps.qualification.outputs.run_full == 'false' }}
|
||||
run: node scripts/ci/plan.mjs
|
||||
|
||||
static:
|
||||
name: Static checks
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && needs.changes.outputs.tier != 'reused' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Require successful change classification
|
||||
env:
|
||||
CHANGES_RESULT: ${{ needs.changes.result }}
|
||||
run: test "${CHANGES_RESULT}" = "success"
|
||||
- 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 static task cache
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
id: static-cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: .turbo/cache/
|
||||
key: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'package-lock.json') }}-${{ github.sha }}
|
||||
# Turbo validates each task's inputs after restoring this snapshot.
|
||||
restore-keys: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'package-lock.json') }}-
|
||||
- name: Validate workflow syntax and expressions
|
||||
if: needs.changes.outputs.workflows == 'true'
|
||||
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 -shellcheck= -pyflakes=
|
||||
- name: Record static task source and execution identity
|
||||
continue-on-error: true
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
env:
|
||||
CI_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
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
|
||||
- if: needs.changes.outputs.code != 'true'
|
||||
run: npm run format:check
|
||||
- name: Save validated static task cache
|
||||
if: >-
|
||||
success() && needs.changes.outputs.code == 'true' &&
|
||||
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: Upload complete static diagnostics
|
||||
if: always() && needs.changes.outputs.code == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: static-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
|
||||
if: needs.changes.outputs.code == 'true' && needs.changes.outputs.tier == 'fast'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
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: Run source tests without waiting for build
|
||||
run: npm run test:fast -- --reporter=default --reporter=blob
|
||||
env:
|
||||
VITEST_BLOB_LABEL: source
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: vitest-report-source
|
||||
path: .vitest/blob/*
|
||||
include-hidden-files: true
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
test-shard:
|
||||
name: Test shard (${{ matrix.shard }})
|
||||
needs: [changes, build]
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
# Failed PRs release the remaining matrix slots; qualification retains
|
||||
# every shard's diagnostics and coverage. Passing PRs run the full suite.
|
||||
fail-fast: ${{ github.event_name == 'pull_request' && needs.changes.outputs.tier == 'fast' }}
|
||||
fail-fast: false
|
||||
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"]') }}
|
||||
variant: [baseline, fixed]
|
||||
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
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: rea-test-runtime
|
||||
path: .
|
||||
- name: Run complete portable suite with coverage
|
||||
if: needs.changes.outputs.tier == 'full'
|
||||
run: npm run test:ci:shard:run -- --shard=${{ matrix.shard }}
|
||||
env:
|
||||
REA_CI_TEST_HISTORY: .cache/ci-test-history/test-results.json
|
||||
- name: Run built-dependent tests for fast feedback
|
||||
if: needs.changes.outputs.tier == 'fast'
|
||||
run: npm run test:built -- --reporter=default --reporter=blob --shard=${{ matrix.shard }}
|
||||
env:
|
||||
VITEST_BLOB_LABEL: built
|
||||
REA_CI_TEST_HISTORY: .cache/ci-test-history/test-results.json
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: vitest-report-built-${{ strategy.job-index }}
|
||||
path: .vitest/blob/*
|
||||
include-hidden-files: true
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 7
|
||||
|
||||
reverse-tools-quality:
|
||||
name: Reverse tools quality
|
||||
needs: [changes, static]
|
||||
if: needs.changes.outputs.reverse_tools == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
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: Run reverse tools tests with coverage
|
||||
run: npm run test:coverage:reverse-tools
|
||||
- name: Preflight both reverse tools mutation configurations
|
||||
run: |
|
||||
node scripts/prepare-reverse-tools-mutation.mjs
|
||||
npx --no-install stryker run stryker.config.mjs --dryRunOnly
|
||||
npx --no-install stryker run stryker.semantics.config.mjs --dryRunOnly
|
||||
- name: Retain available reverse tools diagnostics
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: reverse-tools-quality-${{ needs.changes.outputs.tier }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
coverage/reverse-tools/
|
||||
reports/mutation/
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
build:
|
||||
name: Build package
|
||||
needs: changes
|
||||
# Reusable provider workflows consume this run's portable build artifact,
|
||||
# even when a workflow-only edit selects the lane without portable tests.
|
||||
if: >-
|
||||
needs.changes.outputs.code == 'true' ||
|
||||
needs.changes.outputs.browser == 'true' ||
|
||||
needs.changes.outputs.binary_layout == 'true' ||
|
||||
needs.changes.outputs.evm == 'true' ||
|
||||
needs.changes.outputs.javascript_recovery == 'true' ||
|
||||
needs.changes.outputs.recorded_crash == 'true' ||
|
||||
needs.changes.outputs.web_network == 'true' ||
|
||||
needs.changes.outputs.web_runtime == 'true' ||
|
||||
needs.changes.outputs.web_source_map == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
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: Reuse portable build outputs across runs
|
||||
uses: ./.github/actions/portable-build-cache
|
||||
with:
|
||||
save: ${{ github.event_name != 'pull_request' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
|
||||
- run: npm run test:prepare
|
||||
- name: Find available trusted full-suite timing history
|
||||
id: history
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CI_REPOSITORY: ${{ github.repository }}
|
||||
CI_REPOSITORY_ID: ${{ github.repository_id }}
|
||||
CI_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: node scripts/ci/test-history.mjs
|
||||
- name: Download previous timing report
|
||||
if: steps.history.outputs.run-id != ''
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: test-timings
|
||||
run-id: ${{ steps.history.outputs.run-id }}
|
||||
github-token: ${{ github.token }}
|
||||
path: .cache/ci-test-history
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: rea-test-runtime
|
||||
path: |
|
||||
dist/
|
||||
skills/
|
||||
.cache/mcp-tool-catalog.json
|
||||
.cache/ci-test-history/test-results.json
|
||||
docs/public/product-catalog.json
|
||||
docs/verification/managed-conformance-*.json
|
||||
include-hidden-files: true
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
# test:prepare caches only portable JS, skill, and catalog outputs.
|
||||
# Native package jobs still run their build and prepack checks; Turbo
|
||||
# restores matching outputs or rebuilds when their inputs differ.
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: rea-build-cache
|
||||
path: .turbo/cache/
|
||||
include-hidden-files: true
|
||||
if-no-files-found: error
|
||||
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.
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.sqlite == 'true'
|
||||
uses: ./.github/workflows/sqlite-runtime.yml
|
||||
|
||||
test:
|
||||
name: Portable tests
|
||||
needs: [changes, test-source, test-shard]
|
||||
if: ${{ !cancelled() && needs.changes.outputs.tier != 'reused' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Require successful change classification
|
||||
env:
|
||||
CHANGES_RESULT: ${{ needs.changes.result }}
|
||||
run: test "${CHANGES_RESULT}" = "success"
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
if: needs.changes.outputs.code == 'true' && success()
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
if: needs.changes.outputs.code == 'true' && success()
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Cache npm package downloads
|
||||
if: needs.changes.outputs.code == 'true' && success()
|
||||
id: npm-cache
|
||||
uses: ./.github/actions/npm-cache
|
||||
- if: needs.changes.outputs.code == 'true' && success()
|
||||
run: npm ci
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: >-
|
||||
!cancelled() && needs.changes.outputs.code == 'true' &&
|
||||
(needs.test-shard.result == 'success' || needs.test-shard.result == 'failure' ||
|
||||
(needs.changes.outputs.tier == 'fast' &&
|
||||
(needs.test-source.result == 'success' || needs.test-source.result == 'failure')))
|
||||
with:
|
||||
path: .vitest/blob
|
||||
pattern: vitest-report-*
|
||||
merge-multiple: true
|
||||
- name: Merge coverage, JUnit, and timing reports
|
||||
id: merge
|
||||
if: >-
|
||||
!cancelled() && needs.changes.outputs.code == 'true' &&
|
||||
needs.changes.outputs.tier == 'full' &&
|
||||
(needs.test-shard.result == 'success' || needs.test-shard.result == 'failure')
|
||||
run: >-
|
||||
npm run test:ci:merge --
|
||||
--coverage.reportsDirectory=coverage
|
||||
--coverage.reporter=text
|
||||
--coverage.reporter=json-summary
|
||||
--coverage.reporter=lcov
|
||||
--reporter=default
|
||||
--reporter=junit
|
||||
--reporter=json
|
||||
--reporter=./scripts/ci/test-timing-reporter.mjs
|
||||
--outputFile.junit=test-results.xml
|
||||
--outputFile.json=test-results.json
|
||||
- name: Merge fast-suite JUnit and timing reports
|
||||
id: merge-fast
|
||||
if: >-
|
||||
!cancelled() && needs.changes.outputs.code == 'true' &&
|
||||
needs.changes.outputs.tier == 'fast' &&
|
||||
(needs.test-shard.result == 'success' || needs.test-shard.result == 'failure' ||
|
||||
needs.test-source.result == 'success' || needs.test-source.result == 'failure')
|
||||
run: >-
|
||||
npm exec -- vitest --merge-reports
|
||||
--reporter=default
|
||||
--reporter=junit
|
||||
--reporter=json
|
||||
--reporter=./scripts/ci/test-timing-reporter.mjs
|
||||
--outputFile.junit=test-results.xml
|
||||
--outputFile.json=test-results.json
|
||||
- name: Summarize slowest test files
|
||||
if: needs.changes.outputs.code == 'true' && !cancelled()
|
||||
env:
|
||||
SHARD_RESULT: ${{ needs.test-shard.result }}
|
||||
SOURCE_REQUIRED: ${{ needs.changes.outputs.tier == 'fast' }}
|
||||
SOURCE_RESULT: ${{ needs.test-source.result }}
|
||||
MERGE_RESULT: ${{ needs.changes.outputs.tier == 'full' && steps.merge.outcome || steps.merge-fast.outcome }}
|
||||
CI_VERIFY_TEST_INVENTORY: "true"
|
||||
CI_TEST_SELECTION: ${{ needs.changes.outputs.test_selection }}
|
||||
CI_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: node scripts/ci/test-report.mjs
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-results.json') != ''
|
||||
with:
|
||||
name: test-results
|
||||
path: |
|
||||
test-results.xml
|
||||
test-results.json
|
||||
test-timings.json
|
||||
coverage/
|
||||
.cache/ci-test-selection/report.json
|
||||
include-hidden-files: true
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
- name: Publish successful timing history
|
||||
if: needs.changes.outputs.code == 'true' && needs.changes.outputs.tier == 'full' && success() && needs.test-shard.result == 'success'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: test-timings
|
||||
path: .cache/ci-test-history/test-results.json
|
||||
include-hidden-files: true
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
- name: Require successful test shards
|
||||
if: ${{ !cancelled() }}
|
||||
env:
|
||||
CODE_REQUIRED: ${{ needs.changes.outputs.code }}
|
||||
SHARD_RESULT: ${{ needs.test-shard.result }}
|
||||
SOURCE_REQUIRED: ${{ needs.changes.outputs.tier == 'fast' }}
|
||||
SOURCE_RESULT: ${{ needs.test-source.result }}
|
||||
run: |
|
||||
if [ "${CODE_REQUIRED}" = "true" ]; then
|
||||
test "${SHARD_RESULT}" = "success" || exit 1
|
||||
test "${SOURCE_REQUIRED}" != "true" || test "${SOURCE_RESULT}" = "success"
|
||||
fi
|
||||
|
||||
conformance-fixtures:
|
||||
name: Source-built conformance fixtures
|
||||
needs: [changes, static]
|
||||
if: needs.changes.outputs.fixtures == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- name: Verify fixtures from a clean checkout
|
||||
run: npm run verify:fixtures
|
||||
|
||||
windows-native-build:
|
||||
name: Build bundled Windows x64 native controls
|
||||
needs: [changes, static]
|
||||
if: needs.changes.outputs.runtime == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
ref: ${{ matrix.variant == 'baseline' && 'fa318995f5654b2c2cb76f496da9220d49fc12ca' || github.sha }}
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Prepare cross compiler
|
||||
run: |
|
||||
# Slow Azure archive transfers can exhaust the job before compilation.
|
||||
# Keep the same signed packages and avoid slow HTTP mirror fallback.
|
||||
for source in /etc/apt/apt-mirrors.txt /etc/apt/sources.list /etc/apt/sources.list.d/ubuntu.sources; do
|
||||
if [ -f "$source" ]; then
|
||||
sudo sed -i 's|azure.archive.ubuntu.com|archive.ubuntu.com|g; s|http://archive.ubuntu.com|https://archive.ubuntu.com|g' "$source"
|
||||
@@ -513,447 +37,65 @@ jobs:
|
||||
sudo apt-get install --no-install-recommends -y g++-mingw-w64-x86-64
|
||||
- run: npm run build:windows-native
|
||||
- run: npm run verify:windows-native:artifact
|
||||
- run: node scripts/build-windows-native.mjs --inspection-fixture
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: windows-native-inspection-fixture
|
||||
path: .cache/windows-native-inspection-fixture/
|
||||
name: windows-readonly-root-native-${{ matrix.variant }}
|
||||
path: native/windows/build/
|
||||
if-no-files-found: error
|
||||
# The process boundary observations are independent of the addon: they
|
||||
# report the caller's and child's own token, environment and breakaway
|
||||
# results from a separate executable. Cross-build it in the lane that
|
||||
# already has MinGW so the Windows job can observe them too.
|
||||
- name: Build Windows process boundary fixture
|
||||
- name: Build process boundary lease fixture
|
||||
if: matrix.variant == 'fixed'
|
||||
run: |
|
||||
mkdir -p .cache/windows-process-boundary
|
||||
x86_64-w64-mingw32-g++ -municode -std=c++17 -O2 -static -Wall -Wextra \
|
||||
-o .cache/windows-process-boundary/process-boundary.exe \
|
||||
tests/fixtures/windows/processBoundary.cc -ladvapi32
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: matrix.variant == 'fixed'
|
||||
with:
|
||||
name: windows-process-boundary-fixture
|
||||
name: windows-readonly-root-process-boundary
|
||||
path: .cache/windows-process-boundary/
|
||||
if-no-files-found: error
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: windows-native-controls
|
||||
path: native/windows/build/
|
||||
if-no-files-found: error
|
||||
|
||||
package-e2e:
|
||||
name: Native package baseline (${{ matrix.platform }} ${{ matrix.architecture }})
|
||||
needs: [changes, static, build, windows-native-build]
|
||||
if: needs.changes.outputs.runtime == 'true'
|
||||
verify-native-pair:
|
||||
name: Verify ${{ matrix.variant }} artifact on Windows x64
|
||||
needs: build-native-pair
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# The four platform jobs use independent hosted runners; keep each
|
||||
# runner's existing heap and threadpool limits while avoiding a queued
|
||||
# second platform wave.
|
||||
max-parallel: 4
|
||||
matrix: ${{ fromJSON(needs.changes.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=1024"
|
||||
UV_THREADPOOL_SIZE: "2"
|
||||
matrix:
|
||||
variant: [baseline, fixed]
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
ref: ${{ matrix.variant == 'baseline' && 'fa318995f5654b2c2cb76f496da9220d49fc12ca' || github.sha }}
|
||||
- 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
|
||||
- name: Verify native runtime identity
|
||||
env:
|
||||
EXPECTED_CI_PLATFORM: ${{ matrix.platform }}
|
||||
EXPECTED_CI_ARCHITECTURE: ${{ matrix.architecture }}
|
||||
run: >-
|
||||
node -e "const assert = require('node:assert/strict');
|
||||
assert.equal(process.platform, process.env.EXPECTED_CI_PLATFORM);
|
||||
assert.equal(process.arch, process.env.EXPECTED_CI_ARCHITECTURE);
|
||||
console.log(JSON.stringify({ platform: process.platform, architecture: process.arch, node: process.version }));"
|
||||
- run: npm ci
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: rea-build-cache
|
||||
path: .turbo/cache/
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: windows-native-controls
|
||||
path: native/windows/build/
|
||||
- run: npm run verify:windows-native:artifact
|
||||
- name: Verify NativeAOT diagnostic applicability on this host
|
||||
run: npm run test:focused -- tests/evaluation/package/nativeAotDiagnostics.test.ts
|
||||
- run: npm run verify:package
|
||||
env:
|
||||
npm_config_cache: ${{ steps.npm-cache.outputs.path }}
|
||||
REA_VERIFIED_PACKAGE_DIRECTORY: ${{ needs.changes.outputs.tier == 'full' && matrix.platform == 'linux' && matrix.architecture == 'x64' && '.cache/verified-package' || '' }}
|
||||
- name: Verify real Node Inspector discovery and observation
|
||||
timeout-minutes: 10
|
||||
run: npm run verify:inspector
|
||||
- name: Retain the exact qualified release package
|
||||
if: needs.changes.outputs.tier == 'full' && matrix.platform == 'linux' && matrix.architecture == 'x64'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: verified-release-package
|
||||
path: .cache/verified-package/
|
||||
include-hidden-files: true
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 7
|
||||
|
||||
apple-artifacts:
|
||||
name: Real Apple artifact CLI/MCP E2E
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.apple == 'true'
|
||||
runs-on: macos-15
|
||||
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
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: rea-build-cache
|
||||
path: .turbo/cache/
|
||||
- name: Verify Apple artifact formats and metadata
|
||||
env:
|
||||
REA_ARTIFACT_NODE_COMPILE_CACHE: ${{ runner.temp }}/rea-artifact-node-cache
|
||||
run: |
|
||||
npm run verify:keyed-archive
|
||||
npm run verify:asset-catalog
|
||||
npm run verify:macos-bundle
|
||||
npm run verify:legacy-simulator
|
||||
npm run verify:apple-dispatch
|
||||
npm run verify:interface-builder
|
||||
- name: Run native Apple and process boundary regressions
|
||||
run: >-
|
||||
npm run test:focused --
|
||||
tests/boundary/filesystem/installScript.test.ts
|
||||
tests/boundary/filesystem/nibNativeHierarchy.test.ts
|
||||
tests/boundary/filesystem/interfaceBuilderBundle.test.ts
|
||||
tests/boundary/cli/nativePlistCli.test.ts
|
||||
tests/boundary/cli/nativePlistPrototypeKeyCli.test.ts
|
||||
tests/boundary/cli/nativePlistSelectionFailureCli.test.ts
|
||||
tests/boundary/cli/nativeDemangleCli.test.ts
|
||||
tests/boundary/mcp/nativeDemangleMcp.test.ts
|
||||
tests/boundary/providers/native/nativeSignatureTargetBinding.test.ts
|
||||
tests/boundary/cli/nativeSignatureCli.test.ts
|
||||
tests/boundary/mcp/nativeSignatureTargetBindingMcp.test.ts
|
||||
src/process/capture/FilesystemSnapshot.test.ts
|
||||
tests/process-global/filesystemSnapshotFallbackCancellation.test.ts
|
||||
tests/boundary/process/darwinProcessRunTokenReader.test.ts
|
||||
tests/boundary/process/directAnalysisSignals.test.ts
|
||||
tests/boundary/process/processCaptureLifecycle.test.ts
|
||||
tests/boundary/process/processCli.test.ts
|
||||
tests/boundary/mcp/sessionMcp.test.ts
|
||||
|
||||
reconstruction-readiness:
|
||||
name: Reconstruction readiness
|
||||
needs: [changes, static]
|
||||
if: needs.changes.outputs.readiness == '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: Run the public CLI/MCP reconstruction journey
|
||||
env:
|
||||
REA_READINESS_REPORT_PATH: readiness-report.json
|
||||
run: |
|
||||
set -o pipefail
|
||||
npm run verify:readiness | tee readiness-summary.json
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: reconstruction-readiness
|
||||
path: |
|
||||
readiness-summary.json
|
||||
readiness-report.json
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
windows-curated:
|
||||
name: Windows capability and package lane
|
||||
needs: [changes, static, build, windows-native-build]
|
||||
if: needs.changes.outputs.package == 'true'
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 25
|
||||
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
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: rea-build-cache
|
||||
path: .turbo/cache/
|
||||
- run: npm run build:cached
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: windows-native-controls
|
||||
name: windows-readonly-root-native-${{ matrix.variant }}
|
||||
path: native/windows/build/
|
||||
- run: npm run verify:windows-native:artifact
|
||||
- name: Record named Windows host capabilities
|
||||
run: node scripts/windows-capabilities.mjs | Out-File -Encoding utf8NoBOM windows-capabilities.json
|
||||
- name: Validate Windows host capability report
|
||||
run: >-
|
||||
node -e "const report = JSON.parse(require('node:fs').readFileSync('windows-capabilities.json', 'utf8'));
|
||||
if (report.platform !== 'win32' || report.architecture !== 'x64')
|
||||
throw new Error('expected a Windows x64 capability report');"
|
||||
# Keep this list aligned with tests/process-global/curatedWindowsLane.test.ts,
|
||||
# which fails when any path below is renamed or removed. Vitest silently
|
||||
# ignores unmatched paths, so a stale entry would otherwise report green
|
||||
# while skipping coverage.
|
||||
#
|
||||
# The matching native artifact establishes real Job Object and private
|
||||
# runtime boundaries for these fixtures. Ghidra and Java are not needed;
|
||||
# real engine claims remain in the separate controlled-provider workflow.
|
||||
- name: Run curated Windows capability tests
|
||||
run: >-
|
||||
npx vitest run --coverage.enabled=false
|
||||
src/process/capture/FilesystemSnapshot.test.ts
|
||||
tests/process-global/filesystemSnapshotFallbackCancellation.test.ts
|
||||
tests/boundary/filesystem/binaryTarget.test.ts
|
||||
tests/boundary/filesystem/safeOutputTree.test.ts
|
||||
tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts
|
||||
tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts
|
||||
tests/boundary/filesystem/setupUnavailableProvider.test.ts
|
||||
tests/boundary/filesystem/windowsNpxRegistration.test.ts
|
||||
tests/boundary/android/workspaceGuardianRetry.test.ts
|
||||
tests/acceptance/setup/homeDirectoryFallback.test.ts
|
||||
tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts
|
||||
tests/boundary/filesystem/webScriptManifestPaths.test.ts
|
||||
tests/boundary/process/ProcessEvidence.host.test.ts
|
||||
tests/boundary/process/windowsOutputCapture.test.ts
|
||||
tests/acceptance/webModuleCli.test.ts
|
||||
tests/acceptance/webSourceLocationCli.test.ts
|
||||
tests/boundary/cli/referenceSourceImportStatus.test.ts
|
||||
tests/boundary/filesystem/asarInventoryFreshness.test.ts
|
||||
tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts
|
||||
tests/conformance/providers/ghidra/windowsCapabilities.test.ts
|
||||
src/ghidra/GhidraInstallation.test.ts
|
||||
tests/boundary/filesystem/ghidraJavaProbe.test.ts
|
||||
tests/boundary/providers/ghidra/ghidraTransport.test.ts
|
||||
tests/boundary/providers/ghidra/ghidraTargetSnapshot.test.ts
|
||||
tests/boundary/providers/ghidra/ghidraLauncher.test.ts
|
||||
tests/boundary/providers/hopper/hopperClient.cleanupRetry.test.ts
|
||||
src/windows/WindowsNativeLoader.test.ts
|
||||
src/windows/WindowsOwnedProcess.test.ts
|
||||
src/ghidra/GhidraLauncherEnvironment.test.ts
|
||||
src/process/ProcessOwnership.test.ts
|
||||
src/process/ProcessOwnership.part2.test.ts
|
||||
src/process/ProcessOwnership.part3.test.ts
|
||||
src/process/ProcessOwnership.validation.test.ts
|
||||
src/application/Update.test.ts
|
||||
tests/process-global/updateMaintenance.test.ts
|
||||
tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: windows-process-boundary-fixture
|
||||
name: windows-readonly-root-process-boundary
|
||||
path: .cache/windows-process-boundary/
|
||||
# Passing the fixture adds the breakaway, caller/child token, environment
|
||||
# semantics and ancestor reparse observations, which the lane skips
|
||||
# entirely when it is absent.
|
||||
- run: >-
|
||||
npm run verify:windows-native -- .
|
||||
"${{ github.workspace }}\.cache\windows-process-boundary\process-boundary.exe"
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
- run: npm run verify:windows-native:artifact
|
||||
- name: Checkout fixed-source probe helper
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
name: windows-native-inspection-fixture
|
||||
path: .cache/windows-native-inspection-fixture/
|
||||
- name: Verify native inspection failure ownership and recovery
|
||||
run: node scripts/verify-windows-native-inspection.mjs
|
||||
- name: Verify real Node Inspector discovery and observation
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=1024"
|
||||
UV_THREADPOOL_SIZE: "2"
|
||||
run: npm run verify:inspector
|
||||
- run: npm run verify:package:windows
|
||||
- name: Verify native directory identity on Node 24
|
||||
ref: ${{ github.sha }}
|
||||
path: .cache/windows-readonly-root-harness
|
||||
- name: Assert baseline fails specifically clearing readonly directory attributes
|
||||
if: matrix.variant == 'baseline'
|
||||
run: >-
|
||||
npx vitest run --coverage.enabled=false
|
||||
tests/boundary/filesystem/directoryArtifactReaderOpen.test.ts
|
||||
tests/boundary/filesystem/javascriptPackagePrecedence.test.ts
|
||||
tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22.19.0
|
||||
- name: Verify native path boundaries on Node 22
|
||||
run: >-
|
||||
npx vitest run --coverage.enabled=false
|
||||
tests/boundary/filesystem/directoryArtifactReaderOpen.test.ts
|
||||
tests/boundary/filesystem/javascriptPackagePrecedence.test.ts
|
||||
tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts
|
||||
tests/boundary/process/sourceMapUncHosts.test.ts
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: always()
|
||||
with:
|
||||
name: windows-capabilities
|
||||
path: windows-capabilities.json
|
||||
retention-days: 30
|
||||
|
||||
docs:
|
||||
name: Docs Generation
|
||||
needs: changes
|
||||
if: needs.changes.outputs.docs == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
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
|
||||
- run: npm run docs:check
|
||||
- name: Generation leaves authored files unchanged
|
||||
run: git diff --exit-code
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: generated-docs
|
||||
path: |
|
||||
docs/public/product-catalog.json
|
||||
docs/verification/managed-conformance-*.json
|
||||
skills/reverse-engineer-anything
|
||||
if-no-files-found: error
|
||||
|
||||
browser:
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.browser == 'true'
|
||||
uses: ./.github/workflows/real-browser.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
|
||||
binary_layout:
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.binary_layout == 'true'
|
||||
uses: ./.github/workflows/real-binary-layout.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
|
||||
evm:
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.evm == 'true'
|
||||
uses: ./.github/workflows/real-evm-interface.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
|
||||
javascript_recovery:
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.javascript_recovery == 'true'
|
||||
uses: ./.github/workflows/real-javascript-recovery.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
|
||||
recorded_crash:
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.recorded_crash == 'true'
|
||||
uses: ./.github/workflows/real-recorded-crash.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
|
||||
web_network:
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.web_network == 'true'
|
||||
uses: ./.github/workflows/real-web-network-captures.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
|
||||
web_runtime:
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.web_runtime == 'true'
|
||||
uses: ./.github/workflows/real-web-runtime.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
|
||||
web_source_map:
|
||||
needs: [changes, static, build]
|
||||
if: needs.changes.outputs.web_source_map == 'true'
|
||||
uses: ./.github/workflows/real-web-source-map.yml
|
||||
with:
|
||||
build_cache_artifact: rea-build-cache
|
||||
|
||||
nativeaot:
|
||||
needs: [changes, static]
|
||||
if: needs.changes.outputs.nativeaot == 'true'
|
||||
uses: ./.github/workflows/nativeaot-fixtures.yml
|
||||
|
||||
website:
|
||||
needs: [changes, static]
|
||||
if: needs.changes.outputs.website == 'true'
|
||||
uses: ./.github/workflows/website-check.yml
|
||||
|
||||
required:
|
||||
name: CI required
|
||||
# Report failed dependencies, but release superseded PR concurrency slots.
|
||||
if: ${{ !cancelled() }}
|
||||
needs:
|
||||
[
|
||||
changes,
|
||||
static,
|
||||
build,
|
||||
reverse-tools-quality,
|
||||
test-shard,
|
||||
test-source,
|
||||
sqlite-runtime,
|
||||
test,
|
||||
conformance-fixtures,
|
||||
windows-native-build,
|
||||
package-e2e,
|
||||
apple-artifacts,
|
||||
reconstruction-readiness,
|
||||
windows-curated,
|
||||
docs,
|
||||
browser,
|
||||
binary_layout,
|
||||
evm,
|
||||
javascript_recovery,
|
||||
recorded_crash,
|
||||
web_network,
|
||||
web_runtime,
|
||||
web_source_map,
|
||||
nativeaot,
|
||||
website,
|
||||
]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Require every selected lane to pass
|
||||
id: gate
|
||||
env:
|
||||
CI_JOBS: ${{ toJSON(needs) }}
|
||||
CI_QUALIFICATION_DIRECTORY: .cache/ci-qualification
|
||||
run: node scripts/ci/required.mjs
|
||||
- name: Upload full qualification receipt artifact
|
||||
if: needs.changes.outputs.tier == 'full' && success()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: ${{ steps.gate.outputs.receipt_name }}
|
||||
path: ${{ steps.gate.outputs.receipt_path }}
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 7
|
||||
node .cache/windows-readonly-root-harness/scripts/verify-windows-readonly-root-experiment.mjs
|
||||
. deny-clear
|
||||
- name: Assert readonly cleanup succeeds, then run full native boundary suite
|
||||
if: matrix.variant == 'fixed'
|
||||
run: |
|
||||
node .cache/windows-readonly-root-harness/scripts/verify-windows-readonly-root-experiment.mjs . allow
|
||||
npm run verify:windows-native -- . `
|
||||
"${{ github.workspace }}\.cache\windows-process-boundary\process-boundary.exe"
|
||||
@@ -934,7 +934,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
String value = decompiledText(decompile(function));
|
||||
JsonObject result = functionObservation(
|
||||
value == null ? JsonNull.INSTANCE : GSON.toJsonTree(value),
|
||||
noReturnCallLimitations(
|
||||
functionBoundaryLimitations(
|
||||
function,
|
||||
currentProgram.getListing().getInstructions(function.getBody(), true)
|
||||
)
|
||||
@@ -1027,7 +1027,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
InstructionScan scan = scanInstructions(function, Integer.MAX_VALUE);
|
||||
return functionObservation(
|
||||
GSON.toJsonTree(renderAssembly(scan.instructions)),
|
||||
noReturnCallLimitations(function, scan.instructions)
|
||||
functionBoundaryLimitations(function, scan.instructions)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1046,7 +1046,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
limitations.add(
|
||||
"The fast path does not invoke the decompiler or scan whole-program names and strings."
|
||||
);
|
||||
List<String> boundaryLimitations = noReturnCallLimitations(function, scan.instructions);
|
||||
List<String> boundaryLimitations = functionBoundaryLimitations(function, scan.instructions);
|
||||
for (String limitation : boundaryLimitations) limitations.add(limitation);
|
||||
JsonObject result = new JsonObject();
|
||||
result.add("procedure", procedureIdentity(function));
|
||||
@@ -1069,7 +1069,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
result.add(canonicalAddress(item.getEntryPoint()));
|
||||
}
|
||||
if (callers) {
|
||||
JsonObject observation = functionObservation(result, noReturnCallLimitations(function,
|
||||
JsonObject observation = functionObservation(result, functionBoundaryLimitations(function,
|
||||
currentProgram.getListing().getInstructions(function.getBody(), true)
|
||||
));
|
||||
observation.add("relationship_coverage", callRelationshipCoverage(null,
|
||||
@@ -1099,7 +1099,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
result.add("classification", functionClassification(function));
|
||||
return functionObservation(
|
||||
result,
|
||||
noReturnCallLimitations(
|
||||
functionBoundaryLimitations(
|
||||
function,
|
||||
currentProgram.getListing().getInstructions(function.getBody(), true)
|
||||
)
|
||||
@@ -1131,12 +1131,22 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
result.addProperty("direction", direction);
|
||||
result.add("references", edges);
|
||||
result.addProperty("reference_kinds_available", true);
|
||||
JsonArray unresolvedCalls = direction.equals("outgoing")
|
||||
? callBoundaryObservation(
|
||||
List<String> limitations;
|
||||
JsonArray unresolvedCalls = new JsonArray();
|
||||
if (direction.equals("outgoing")) {
|
||||
CallBoundaryObservation calls = callBoundaryObservation(
|
||||
function, currentProgram.getListing().getInstructions(function.getBody(), true), null, true, references
|
||||
).unresolvedCalls() : new JsonArray();
|
||||
);
|
||||
unresolvedCalls = calls.unresolvedCalls();
|
||||
limitations = calls.limitations();
|
||||
} else {
|
||||
limitations = functionBoundaryLimitations(
|
||||
function,
|
||||
currentProgram.getListing().getInstructions(function.getBody(), true)
|
||||
);
|
||||
}
|
||||
result.add("unresolved_calls", unresolvedCalls);
|
||||
return result;
|
||||
return functionObservation(result, limitations);
|
||||
}
|
||||
|
||||
private JsonObject inspectNativeDataType(JsonObject params) throws Exception {
|
||||
@@ -1337,12 +1347,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
result.add("references", references);
|
||||
if (includeIncoming) {
|
||||
TreeMap<String, Reference> incoming = new TreeMap<>();
|
||||
ReferenceIterator incomingIterator = currentProgram.getReferenceManager().getReferencesTo(address);
|
||||
while (incomingIterator.hasNext()) {
|
||||
monitor.checkCancelled();
|
||||
Reference reference = incomingIterator.next();
|
||||
if (!reference.isEntryPointReference()) addReference(incoming, reference);
|
||||
}
|
||||
visitObservedReferencesTo(address, reference -> addReference(incoming, reference));
|
||||
List<Reference> orderedIncoming = new ArrayList<>(incoming.values());
|
||||
orderedIncoming.sort(REFERENCE_ORDER);
|
||||
JsonArray incomingReferences = new JsonArray();
|
||||
@@ -1394,16 +1399,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
requireKeys(params, Set.of("document", "address"));
|
||||
requireDocument(params);
|
||||
Address address = requireAddress(params, "address");
|
||||
ReferenceIterator references = currentProgram.getReferenceManager().getReferencesTo(address);
|
||||
Set<Address> sources = new HashSet<>();
|
||||
while (references.hasNext()) {
|
||||
monitor.checkCancelled();
|
||||
Reference reference = references.next();
|
||||
if (reference.isEntryPointReference()) {
|
||||
continue;
|
||||
}
|
||||
sources.add(reference.getFromAddress());
|
||||
}
|
||||
visitObservedReferencesTo(address, reference -> sources.add(reference.getFromAddress()));
|
||||
List<Address> ordered = new ArrayList<>(sources);
|
||||
ordered.sort(Address::compareTo);
|
||||
JsonArray result = new JsonArray();
|
||||
@@ -3431,51 +3428,27 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
return omitted[0];
|
||||
}
|
||||
|
||||
private List<String> noReturnCallLimitations(
|
||||
private List<String> functionBoundaryLimitations(
|
||||
Function function, Iterable<Instruction> instructions)
|
||||
throws Exception {
|
||||
List<String> limitations = new ArrayList<>();
|
||||
Instruction beforePrevious = null;
|
||||
Instruction previous = null;
|
||||
for (Instruction instruction : instructions) {
|
||||
monitor.checkCancelled();
|
||||
String limitation = terminalCallLimitation(instruction);
|
||||
if (limitation != null) limitations.add(limitation);
|
||||
if (beforePrevious != null && previous != null) {
|
||||
String seh = borlandSehHandlerLimitation(
|
||||
function, beforePrevious, previous, instruction);
|
||||
if (seh != null) limitations.add(seh);
|
||||
}
|
||||
beforePrevious = previous;
|
||||
previous = instruction;
|
||||
}
|
||||
return limitations;
|
||||
FunctionBoundaryLimitations boundary = new FunctionBoundaryLimitations(function);
|
||||
for (Instruction instruction : instructions) boundary.observe(instruction);
|
||||
return boundary.values();
|
||||
}
|
||||
|
||||
private CallBoundaryObservation callBoundaryObservation(
|
||||
Function function, Iterable<Instruction> instructions, Set<Function> returnedCallees,
|
||||
boolean includeUnresolvedSites, List<Reference> observedReferences)
|
||||
throws Exception {
|
||||
List<String> limitations = new ArrayList<>();
|
||||
FunctionBoundaryLimitations boundary = new FunctionBoundaryLimitations(function);
|
||||
CallBoundaryReferences calls = new CallBoundaryReferences(returnedCallees, includeUnresolvedSites);
|
||||
Instruction beforePrevious = null;
|
||||
Instruction previous = null;
|
||||
if (observedReferences == null) visitReferences(function, "outgoing", calls::observe);
|
||||
else for (Reference reference : observedReferences) {
|
||||
monitor.checkCancelled();
|
||||
calls.observe(reference);
|
||||
}
|
||||
for (Instruction instruction : instructions) {
|
||||
monitor.checkCancelled();
|
||||
String limitation = terminalCallLimitation(instruction);
|
||||
if (limitation != null) limitations.add(limitation);
|
||||
if (beforePrevious != null && previous != null) {
|
||||
String seh = borlandSehHandlerLimitation(
|
||||
function, beforePrevious, previous, instruction);
|
||||
if (seh != null) limitations.add(seh);
|
||||
}
|
||||
beforePrevious = previous;
|
||||
previous = instruction;
|
||||
boundary.observe(instruction);
|
||||
if (!instruction.getFlowType().isCall()) continue;
|
||||
boolean destination = false;
|
||||
for (Reference reference : instruction.getReferencesFrom()) {
|
||||
@@ -3489,10 +3462,38 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
"Instruction decoder identifies a call but ReferenceManager supplies no call destination");
|
||||
}
|
||||
}
|
||||
return new CallBoundaryObservation(List.copyOf(limitations),
|
||||
return new CallBoundaryObservation(boundary.values(),
|
||||
returnedCallees == null ? null : calls.unrepresented, calls.unresolvedCalls());
|
||||
}
|
||||
|
||||
private final class FunctionBoundaryLimitations {
|
||||
private final Function function;
|
||||
private final List<String> limitations = new ArrayList<>();
|
||||
private Instruction beforePrevious;
|
||||
private Instruction previous;
|
||||
|
||||
private FunctionBoundaryLimitations(Function function) {
|
||||
this.function = function;
|
||||
}
|
||||
|
||||
private void observe(Instruction instruction) throws Exception {
|
||||
monitor.checkCancelled();
|
||||
String limitation = terminalCallLimitation(instruction);
|
||||
if (limitation != null) limitations.add(limitation);
|
||||
if (beforePrevious != null && previous != null) {
|
||||
String seh = borlandSehHandlerLimitation(
|
||||
function, beforePrevious, previous, instruction);
|
||||
if (seh != null) limitations.add(seh);
|
||||
}
|
||||
beforePrevious = previous;
|
||||
previous = instruction;
|
||||
}
|
||||
|
||||
private List<String> values() {
|
||||
return List.copyOf(limitations);
|
||||
}
|
||||
}
|
||||
|
||||
private String borlandSehHandlerLimitation(
|
||||
Function function, Instruction handlerPush, Instruction framePush,
|
||||
Instruction frameLink) {
|
||||
@@ -3674,24 +3675,36 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
monitor.checkCancelled();
|
||||
Address address = addresses.next();
|
||||
if (outgoing) {
|
||||
for (Reference reference : currentProgram.getReferenceManager()
|
||||
.getReferencesFrom(address)) {
|
||||
monitor.checkCancelled();
|
||||
if (!reference.isEntryPointReference()) visitor.accept(reference);
|
||||
}
|
||||
visitObservedReferencesFrom(address, visitor);
|
||||
}
|
||||
else {
|
||||
ReferenceIterator iterator = currentProgram.getReferenceManager()
|
||||
.getReferencesTo(address);
|
||||
while (iterator.hasNext()) {
|
||||
monitor.checkCancelled();
|
||||
Reference reference = iterator.next();
|
||||
if (!reference.isEntryPointReference()) visitor.accept(reference);
|
||||
}
|
||||
visitObservedReferencesTo(address, visitor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void visitObservedReferencesFrom(Address address, ReferenceVisitor visitor)
|
||||
throws Exception {
|
||||
for (Reference reference : currentProgram.getReferenceManager().getReferencesFrom(address)) {
|
||||
monitor.checkCancelled();
|
||||
visitObservedReference(reference, visitor);
|
||||
}
|
||||
}
|
||||
|
||||
private void visitObservedReferencesTo(Address address, ReferenceVisitor visitor)
|
||||
throws Exception {
|
||||
ReferenceIterator references = currentProgram.getReferenceManager().getReferencesTo(address);
|
||||
while (references.hasNext()) {
|
||||
monitor.checkCancelled();
|
||||
visitObservedReference(references.next(), visitor);
|
||||
}
|
||||
}
|
||||
|
||||
private static void visitObservedReference(Reference reference, ReferenceVisitor visitor)
|
||||
throws Exception {
|
||||
if (!reference.isEntryPointReference()) visitor.accept(reference);
|
||||
}
|
||||
|
||||
private static void addReference(TreeMap<String, Reference> observed, Reference reference) {
|
||||
String key = canonicalAddress(reference.getFromAddress()) + "\u0000" +
|
||||
canonicalAddress(reference.getToAddress()) + "\u0000" +
|
||||
|
||||
@@ -140,8 +140,23 @@ profiles](mcp-contracts.md#output-schemas).
|
||||
Setup registers `rea` in `~/.kimi-code/mcp.json`, or
|
||||
`$KIMI_CODE_HOME/mcp.json` when set, while preserving unrelated settings and
|
||||
creating a `.rea.backup` before updating an existing file. Kimi Code infers
|
||||
stdio from the `command` entry, so setup does not add a `type` field. The
|
||||
registration includes `startupTimeoutMs = 30000` and `toolTimeoutMs = 120000`.
|
||||
stdio from the `command` entry, so setup does not add a `type` field. New
|
||||
registrations include `startupTimeoutMs = 30000` and a `toolTimeoutMs` derived
|
||||
from REA's known provider allowance plus 120 seconds of operation
|
||||
headroom. The default is 450000 ms, covering the default 330-second Ghidra
|
||||
startup; an explicit Ghidra startup timeout of 2700000 ms produces a 2820000 ms
|
||||
tool timeout. The startup allowance follows an explicitly selected Ghidra or
|
||||
Hopper provider, and otherwise uses the larger known Ghidra/Hopper deadline.
|
||||
An explicit `REA_JEB_MCP_TIMEOUT_MS` request budget also contributes to that
|
||||
maximum. For `ida` or `auto` selection, a valid IDA registration's parsed
|
||||
`timeoutMs` also contributes to that maximum; its default is 300000 ms.
|
||||
Setup forwards these explicitly selected REA deadlines and the input schema
|
||||
profile into the registration; unrelated process values are omitted.
|
||||
This headroom is a practical default, not a guarantee for every analysis
|
||||
duration. Setup preserves positive timeout values already in the `rea` entry.
|
||||
You can edit `startupTimeoutMs` and `toolTimeoutMs` there; doctor keeps positive
|
||||
caller-selected values aligned and reports a non-blocking advisory when a tool
|
||||
timeout is shorter than the known provider allowance.
|
||||
Setup selects the compact input-schema advertisement for new or refreshed Kimi
|
||||
registrations unless their stored configuration or setup environment explicitly
|
||||
selects `REA_MCP_INPUT_SCHEMA_PROFILE=full` or `compact`. This keeps REA's
|
||||
@@ -219,8 +234,20 @@ standalone CLI check.
|
||||
|
||||
## Codex and Devin
|
||||
|
||||
Codex treats an unset or empty `CODEX_HOME` as `~/.codex`. Devin honors
|
||||
`XDG_CONFIG_HOME` for its `devin/mcp_config.json` on non-Windows systems.
|
||||
Codex treats an unset or empty `CODEX_HOME` as `~/.codex`. Its MCP server
|
||||
registration supports `startup_timeout_sec` for connection initialization and
|
||||
`tool_timeout_sec` for each tool call. New REA registrations use a 30-second
|
||||
startup timeout and a tool timeout derived from the known provider
|
||||
allowance plus 120 seconds of operation headroom. This yields 450 seconds for
|
||||
the default Ghidra allowance; an explicit Ghidra startup timeout of 2700
|
||||
seconds yields 2820 seconds. Setup preserves positive caller-selected timeout
|
||||
values in the REA entry, and doctor reports a non-blocking advisory when a tool
|
||||
timeout is shorter than the known provider allowance. An explicitly selected
|
||||
JEB request budget also contributes to the allowance. A configured IDA
|
||||
registration's parsed `timeoutMs` contributes for `ida` or `auto` selection.
|
||||
These values are client-side deadlines, so increase them in `~/.codex/config.toml` if a
|
||||
workload needs more time. Devin honors `XDG_CONFIG_HOME` for its
|
||||
`devin/mcp_config.json` on non-Windows systems.
|
||||
|
||||
## OMP
|
||||
|
||||
|
||||
@@ -153,8 +153,8 @@ overview merely because its source artifact is unchanged.
|
||||
|
||||
Function extents and instruction completeness refer to Ghidra's analyzed body.
|
||||
An inferred no-return call can terminate that body before subsequent bytes.
|
||||
Pseudocode, assembly, procedure info, instruction windows, callee and caller
|
||||
reads, and dossiers report observed terminal call sites in Evidence limitations,
|
||||
Pseudocode, assembly, procedure info, instruction windows, incoming and outgoing
|
||||
references, callee and caller reads, and dossiers report observed terminal call sites in Evidence limitations,
|
||||
including resolved callee names, addresses, and FunctionManager flags when
|
||||
available. On x86-32, the same views also report the exact Borland/Delphi-style
|
||||
FS exception-registration pattern when its handler address falls outside the
|
||||
|
||||
@@ -131,9 +131,9 @@ Use a package version that actually includes this adapter; repository main can b
|
||||
{"name":"close_binary","arguments":{}}
|
||||
```
|
||||
|
||||
Opening an MCP target starts its selected provider session; headless opening waits for auto-analysis. Set the agent's MCP request deadline long enough for the target. The registration's optional `timeoutMs` defaults to 300000 for upstream requests. A freshly opened headless worker can briefly report `status: "busy"` from `server_health`; REA re-reads the health every 100 ms until the worker answers, the caller cancels, or `timeoutMs` has elapsed since the first read. Only expiry while the worker is still busy produces a retryable `provider_timeout`; no further read is started once the deadline has passed. That error names the busy tool of the last observation, or `unknown` when the worker reported none or a value that is not tool-name-shaped. Cancellation during the wait produces `cancelled`. Any other unexpected health payload, including a busy payload with an invalid shape, remains `unreadable_output`. Cancellation drains the current upstream request before cleanup; it does not force-stop GUI analysis. An open request that times out without a completed response retains the private workspace because worker inventory alone cannot prove the request has stopped. `binary_session` reports actual tool availability. Repeated doctor/setup calls are unnecessary when the registration already works.
|
||||
Opening an MCP target starts its selected provider session; headless opening waits for auto-analysis. Set the agent's MCP request deadline long enough for the target. The registration's optional `timeoutMs` defaults to 300000 for upstream requests. An upstream SDK request that reaches this limit returns `provider_timeout` with its operation and actual request budget. A freshly opened headless worker can briefly report `status: "busy"` from `server_health`; REA re-reads health every 100 ms until the worker answers, the caller cancels, or `timeoutMs` has elapsed since the first read. Each health request is capped by the time remaining in that overall wait, so a stalled read cannot extend the readiness deadline by another full request timeout. Expiry while the worker is still busy produces a retryable `provider_timeout`; no further read is started after the deadline. That error names the busy tool from the last observation, or `unknown` when the worker reported none or a value that is not tool-name-shaped. If a health request itself consumes the remaining wait budget, the same last-busy timeout is returned with the stalled request's captured output. Cancellation during the wait produces `cancelled`. Any other unexpected health payload, including a busy payload with an invalid shape, remains `unreadable_output`. Cancellation drains the current upstream request before cleanup; it does not force-stop GUI analysis. An open request that times out without a completed response retains the private workspace because worker inventory alone cannot prove the request has stopped. `binary_session` reports actual tool availability. Repeated doctor/setup calls are unnecessary when the registration already works.
|
||||
|
||||
For guided registration, set `REA_IDA_MCP_CONFIG` before `rea setup --client <client>`. Setup includes that file reference in its reviewed plan and preserves it after approval; it does not copy upstream credentials into the agent configuration. `rea doctor --provider ida --json` validates the registration without starting IDA and explicitly leaves runtime compatibility and target binding for analysis.
|
||||
For guided registration, set `REA_IDA_MCP_CONFIG` before `rea setup --client <client>`. Setup includes that file reference in its reviewed plan and preserves it after approval; it does not copy upstream credentials into the agent configuration. For `ida` or `auto` selection, Codex/Kimi setup and doctor use the effective client registration's parsed `timeoutMs` (default 300000 ms) when recommending the agent's per-tool deadline. Existing positive client deadlines remain caller choices. `rea doctor --provider ida --json` validates the registration without starting IDA and explicitly leaves runtime compatibility and target binding for analysis.
|
||||
|
||||
## Coverage and evidence
|
||||
|
||||
|
||||
+16
-1
@@ -147,7 +147,8 @@ identity remains available at `identity.catalog`.
|
||||
|
||||
Setup pins package-runner MCP registrations to the exact installed REA version,
|
||||
installs the matching skill and on-demand references in the same plan, and adds
|
||||
`startup_timeout_sec = 30` for Codex and Grok Build.
|
||||
`startup_timeout_sec = 30` for Codex and Grok Build. Codex also receives a
|
||||
`tool_timeout_sec` derived from known provider startup plus operation headroom.
|
||||
|
||||
## Limit REA to one project or agent profile
|
||||
|
||||
@@ -634,6 +635,20 @@ If startup reports `Cannot launch Hopper! Connection is invalid.` or waits for
|
||||
bridge readiness, check Hopper for a pending demo, license, or loader dialog.
|
||||
Complete the dialog and retry. If REA reports `cleanup_incomplete`, follow the
|
||||
retained-resource guidance below before reopening the target.
|
||||
|
||||
On macOS, Hopper can show an `Executing Python Script...` overlay while the REA
|
||||
bridge serves an active MCP connection. Long-lived MCP clients can keep that
|
||||
connection open across agent turns, so the overlay may remain visible after the
|
||||
analysis calls finish; by itself, it does not establish a stalled startup. REA
|
||||
does not automatically idle-close a still-connected target. When the
|
||||
investigation is finished, export an Evidence bundle first if you need to keep
|
||||
the session ledger, then call `close_binary`; set `snapshot_path` to save a
|
||||
provider-neutral analysis snapshot as part of closing. A successful close
|
||||
shuts down the REA bridge and clears its overlay; REA does not control the
|
||||
macOS Hopper application lifetime.
|
||||
The Hopper GUI Cancel button's behavior for this bridge has not been verified;
|
||||
`close_binary` is the supported cleanup path.
|
||||
|
||||
Hopper serializes analysis requests. Cancelling a wait can leave provider work
|
||||
running, which the session reports. Successful decompilation is cached until
|
||||
a relevant rename or comment changes it.
|
||||
|
||||
@@ -22,6 +22,15 @@ inspection and decompilation while refusing `open_jeb_project`. Catalog discover
|
||||
uses one 60-second deadline across connection and pagination; a responsive server
|
||||
cannot keep discovery running indefinitely by returning more pages.
|
||||
|
||||
Each upstream tool call has an absolute 60-second request deadline by default.
|
||||
Set `REA_JEB_MCP_TIMEOUT_MS` to a positive decimal number of milliseconds to
|
||||
select a different per-request budget, for example `REA_JEB_MCP_TIMEOUT_MS=300000`
|
||||
for a five-minute decompilation. This budget starts after discovery and applies
|
||||
to each individual `tools/call`; it does not extend the separate catalog deadline
|
||||
or establish one end-to-end deadline across a whole CLI or MCP operation. A
|
||||
request that reaches its budget returns a typed timeout, while caller
|
||||
cancellation remains a cancellation.
|
||||
|
||||
Verified against JEB 5.48.0 (Linux x64, licensed build) serving `jeb-mcp-server` 1.3.0 over streamable HTTP.
|
||||
|
||||
## Operations
|
||||
|
||||
+26
-5
@@ -517,6 +517,14 @@ These deadlines have different owners:
|
||||
| Darwin swiftc helper | REA allows 120,000 ms by default for ownership-helper compilation; `REA_SWIFTC_COMPILE_TIMEOUT_MS` accepts decimal integers from 1–2,147,483,647 ms. Invalid values fail configuration validation. |
|
||||
| Individual tool call | The client bounds its wait, including cold engine startup. The pinned client SDK 2.3.1 defaults to 60,000 ms and can cancel earlier than REA's startup deadline. |
|
||||
|
||||
For [Codex and Kimi registrations](client-configuration.md), setup supplies
|
||||
missing tool deadlines from the known provider allowance plus 120 seconds of
|
||||
operation headroom: 450 seconds with the default Ghidra allowance, or 2820
|
||||
seconds with `REA_GHIDRA_STARTUP_TIMEOUT_MS=2700000`. Positive caller-selected
|
||||
deadlines remain unchanged. Doctor reports an informational recommendation
|
||||
when a configured deadline is shorter than the known provider allowance. An
|
||||
explicit JEB upstream request budget also contributes to that allowance.
|
||||
|
||||
For an already connected client using the pinned SDK, request options are the
|
||||
**second** argument of `callTool`:
|
||||
|
||||
@@ -546,10 +554,20 @@ Waiting notifications stop when startup settles or the caller cancels. They do
|
||||
not measure engine work completed.
|
||||
|
||||
The pinned SDK can reset the request's inactivity timeout on progress with
|
||||
`resetTimeoutOnProgress: true`. Also set `maxTotalTimeout` to bound the complete
|
||||
request; the provider's startup deadline still applies. These options belong in
|
||||
the same second argument of `callTool`. Without that explicit client policy, the
|
||||
default 60-second request deadline still expires despite waiting notifications.
|
||||
`resetTimeoutOnProgress: true`. These options belong in the same second
|
||||
argument of `callTool`. Without that explicit client policy, the default
|
||||
60-second request deadline still expires despite waiting notifications.
|
||||
|
||||
SDK 2.3.1 checks `maxTotalTimeout` when progress resets the timer; it does not
|
||||
schedule a separate absolute timer for that setting. A silent interval can
|
||||
therefore run beyond the declared total. For an absolute caller deadline,
|
||||
also pass `signal: AbortSignal.timeout(totalBudgetMs)`. If the caller already
|
||||
has a cancellation signal, preserve both with
|
||||
`AbortSignal.any([callerSignal, AbortSignal.timeout(totalBudgetMs)])`.
|
||||
`maxTotalTimeout` can still bound progress resets, and REA's provider startup
|
||||
deadline still applies. Choose the total budget for the expected workload;
|
||||
neither server configuration nor progress can override a remote caller's
|
||||
deadline.
|
||||
|
||||
The 240-second setting is a measured example, not a universal timeout: a
|
||||
controlled Linux x64/WSL2 fixture took about 64 seconds for its first overview
|
||||
@@ -690,7 +708,10 @@ reported by strict-depth MCP clients in [#2813](https://github.com/morluto/rea/i
|
||||
WorkBuddy/CodeBuddy gateways have also rejected full REA catalogs with an
|
||||
opaque `11133` error while accepting the compact profile
|
||||
([#2999](https://github.com/morluto/rea/issues/2999)); compatibility depends on
|
||||
the selected gateway and model.
|
||||
the selected gateway and model. That report used ZCode through a gateway to a
|
||||
Kimi-K3 backend, not the WorkBuddy MCP client. Set the profile on REA's
|
||||
registration in the actual MCP host; the host's name alone does not identify
|
||||
the downstream model or its schema constraints.
|
||||
|
||||
The compact profile renders the same tool set, output schemas, annotations,
|
||||
and canonical server-side validation. It changes only the advertised input
|
||||
|
||||
@@ -67,7 +67,10 @@ std::unique_ptr<File> openFile(const std::wstring& requested, DWORD access, bool
|
||||
result->requestedPath = requested;
|
||||
const auto& path = result->path;
|
||||
require(directory || path.size() > 3, "Target is not a regular file", requested, ERROR_DIRECTORY);
|
||||
result->handles.push_back(openComponent(path.substr(0, 3), 0, true));
|
||||
// The volume root cannot be replaced or deleted. Match the later root
|
||||
// reopen's write sharing here so a root writer cannot block admission.
|
||||
result->handles.push_back(openComponent(path.substr(0, 3), 0, true,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE));
|
||||
wchar_t filesystem[64]{};
|
||||
DWORD flags = 0;
|
||||
require(GetVolumeInformationByHandleW(result->handles.front().get(), nullptr, 0, nullptr, nullptr,
|
||||
@@ -436,7 +439,8 @@ static void writeBytes(HANDLE file, const void* bytes, DWORD count, const std::w
|
||||
static void dispose(HANDLE handle, const std::wstring& path) {
|
||||
FILE_BASIC_INFO info{};
|
||||
require(GetFileInformationByHandleEx(handle, FileBasicInfo, &info, sizeof(info)), "Read cleanup attributes failed", path);
|
||||
if ((info.FileAttributes & FILE_ATTRIBUTE_READONLY) != 0) {
|
||||
if ((info.FileAttributes & FILE_ATTRIBUTE_READONLY) != 0 &&
|
||||
(info.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0) {
|
||||
info.FileAttributes &= ~FILE_ATTRIBUTE_READONLY;
|
||||
require(SetFileInformationByHandle(handle, FileBasicInfo, &info, sizeof(info)), "Clear owned readonly attribute failed", path);
|
||||
}
|
||||
|
||||
@@ -75,7 +75,57 @@ for (const architecture of ["x86_64", "x86"]) {
|
||||
: [0xe8, 0x0b, 0, 0, 0, 0xe8, 0x16, 0, 0, 0, 0x31, 0xc0, 0xc3],
|
||||
0x200,
|
||||
);
|
||||
bytes.set([0xb8, 0x2a, 0, 0, 0, 0xc3], 0x210);
|
||||
bytes.set(
|
||||
x86 && !dll
|
||||
? [0xb8, 0x2a, 0, 0, 0, 0xe8, 0x66, 0, 0, 0, 0xc3]
|
||||
: [0xb8, 0x2a, 0, 0, 0, 0xc3],
|
||||
0x210,
|
||||
);
|
||||
|
||||
if (x86 && !dll) {
|
||||
// A statically analyzed Borland-style SEH registration whose handler
|
||||
// entry is referenced but is not part of the registering function body.
|
||||
bytes.set(
|
||||
[
|
||||
0x55, // PUSH EBP
|
||||
0x89,
|
||||
0xe5, // MOV EBP, ESP
|
||||
0x68,
|
||||
0xc0,
|
||||
0x10,
|
||||
0x40,
|
||||
0x00, // PUSH 0x4010c0
|
||||
0x64,
|
||||
0xff,
|
||||
0x35,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0, // PUSH DWORD PTR FS:[0]
|
||||
0x64,
|
||||
0x89,
|
||||
0x25,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0, // MOV DWORD PTR FS:[0], ESP
|
||||
0x64,
|
||||
0x8f,
|
||||
0x05,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0, // POP DWORD PTR FS:[0]
|
||||
0x83,
|
||||
0xc4,
|
||||
0x04, // ADD ESP, 4
|
||||
0x5d, // POP EBP
|
||||
0xc3, // RET
|
||||
],
|
||||
0x280,
|
||||
);
|
||||
bytes.set([0xc3], 0x2c0);
|
||||
}
|
||||
|
||||
if (!dll) {
|
||||
const dataSection = sectionTable + 40;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const limitationPattern = /Borland\/Delphi-style SEH frame registration/u;
|
||||
|
||||
/** Verify Ghidra reports, and does not absorb, the fixture's handler region. */
|
||||
export async function verifyGhidraBorlandSeh(functionObservation, x86) {
|
||||
assert.equal(x86, true, "The SEH control exists only in the x86 PE fixture");
|
||||
const caller = "0x401015";
|
||||
const procedure = "0x401080";
|
||||
const handler = "0x4010c0";
|
||||
|
||||
const ordinary = await functionObservation("procedure_info", {
|
||||
procedure: "0x401010",
|
||||
});
|
||||
assert.ok(
|
||||
!ordinary.limitations.some((item) => limitationPattern.test(item)),
|
||||
"The ordinary return-42 control unexpectedly reports an SEH limitation",
|
||||
);
|
||||
|
||||
const info = await functionObservation("procedure_info", { procedure });
|
||||
assert.ok(info.limitations.some((item) => limitationPattern.test(item)));
|
||||
|
||||
const outgoing = await functionObservation("procedure_references", {
|
||||
procedure,
|
||||
direction: "outgoing",
|
||||
});
|
||||
assert.equal(outgoing.value.direction, "outgoing");
|
||||
assert.ok(outgoing.limitations.some((item) => limitationPattern.test(item)));
|
||||
assert.ok(
|
||||
outgoing.value.references.some(
|
||||
(reference) =>
|
||||
reference.source_address === "0x401083" &&
|
||||
reference.target_address === handler &&
|
||||
reference.kind.available &&
|
||||
reference.kind.data &&
|
||||
!reference.kind.call,
|
||||
),
|
||||
"The pushed handler address must remain an observed data reference, without inventing a call",
|
||||
);
|
||||
|
||||
const incoming = await functionObservation("procedure_references", {
|
||||
procedure,
|
||||
direction: "incoming",
|
||||
});
|
||||
assert.equal(incoming.value.direction, "incoming");
|
||||
assert.ok(incoming.limitations.some((item) => limitationPattern.test(item)));
|
||||
assert.ok(
|
||||
incoming.value.references.some(
|
||||
(reference) =>
|
||||
reference.source_address === caller &&
|
||||
reference.target_address === procedure,
|
||||
),
|
||||
"The caller-to-registration procedure reference was not observed",
|
||||
);
|
||||
|
||||
const dossier = await functionObservation("analyze_function", {
|
||||
procedure,
|
||||
});
|
||||
assert.ok(dossier.limitations.some((item) => limitationPattern.test(item)));
|
||||
assert.ok(
|
||||
dossier.value.limitations.some((item) => limitationPattern.test(item)),
|
||||
"The normalized function dossier lost the SEH limitation",
|
||||
);
|
||||
assert.equal(dossier.value.procedure.body.available, true);
|
||||
assert.ok(dossier.value.procedure.body.ranges.length > 0);
|
||||
assert.ok(
|
||||
!dossier.value.procedure.body.ranges.some(
|
||||
(range) =>
|
||||
BigInt(range.start) <= BigInt(handler) &&
|
||||
BigInt(range.end) >= BigInt(handler),
|
||||
),
|
||||
"The handler was incorrectly claimed as part of the procedure body",
|
||||
);
|
||||
|
||||
return {
|
||||
limitation: true,
|
||||
ordinary_control: true,
|
||||
procedure_references_incoming_and_outgoing: true,
|
||||
handler_data_reference: true,
|
||||
caller_reference: true,
|
||||
dossier_limitation: true,
|
||||
handler_body_not_claimed: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { createInterface } from "node:readline";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const withDeadline = async (promise, milliseconds, description) => {
|
||||
let timer;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
reject(new Error(`${description} exceeded ${milliseconds} ms.`)),
|
||||
milliseconds,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
export async function verifyVolumeRootAppendLease({
|
||||
fixturePath,
|
||||
volumeRoot,
|
||||
verifyAdmission,
|
||||
}) {
|
||||
const lease = spawn(
|
||||
resolve(fixturePath),
|
||||
["volume-root-append-lease", volumeRoot],
|
||||
{ stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
|
||||
);
|
||||
const lines = createInterface({ input: lease.stdout });
|
||||
let leaseStderr = "";
|
||||
lease.stderr.setEncoding("utf8");
|
||||
lease.stderr.on("data", (chunk) => {
|
||||
if (leaseStderr.length < 8192) leaseStderr += chunk;
|
||||
});
|
||||
|
||||
const closed = once(lease, "close").then(([code, signal]) => ({
|
||||
code,
|
||||
signal,
|
||||
}));
|
||||
const leaseLine = once(lines, "line").then(([line]) => ({ line }));
|
||||
const leaseError = once(lease, "error").then(([error]) => {
|
||||
throw error;
|
||||
});
|
||||
let verificationFailure;
|
||||
try {
|
||||
const ready = await withDeadline(
|
||||
Promise.race([leaseLine, closed, leaseError]),
|
||||
10_000,
|
||||
"Volume-root append lease readiness",
|
||||
);
|
||||
assert.equal(
|
||||
"line" in ready,
|
||||
true,
|
||||
`Volume-root append lease exited before readiness (code ${ready.code}, signal ${ready.signal}): ${leaseStderr}`,
|
||||
);
|
||||
const observation = JSON.parse(ready.line);
|
||||
assert.equal(
|
||||
observation.writeAccessGranted,
|
||||
true,
|
||||
`Windows test token cannot hold FILE_ADD_SUBDIRECTORY on ${volumeRoot} (Win32 ${observation.win32Code}).`,
|
||||
);
|
||||
await verifyAdmission();
|
||||
} catch (error) {
|
||||
verificationFailure = error;
|
||||
}
|
||||
|
||||
lines.close();
|
||||
if (
|
||||
lease.exitCode === null &&
|
||||
lease.signalCode === null &&
|
||||
!lease.stdin.destroyed
|
||||
)
|
||||
lease.stdin.end("x");
|
||||
|
||||
let releaseFailure;
|
||||
try {
|
||||
const result = await withDeadline(
|
||||
closed,
|
||||
10_000,
|
||||
"Volume-root append lease release",
|
||||
).catch(async (error) => {
|
||||
lease.kill();
|
||||
try {
|
||||
return await withDeadline(
|
||||
closed,
|
||||
5_000,
|
||||
"Volume-root append lease termination",
|
||||
);
|
||||
} catch (terminationError) {
|
||||
throw new AggregateError(
|
||||
[error, terminationError],
|
||||
`Volume-root append lease could not be released: ${leaseStderr}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
assert.equal(
|
||||
result.code,
|
||||
0,
|
||||
`Volume-root append lease exited with code ${result.code}, signal ${result.signal}: ${leaseStderr}`,
|
||||
);
|
||||
} catch (error) {
|
||||
releaseFailure = error;
|
||||
}
|
||||
|
||||
if (verificationFailure !== undefined && releaseFailure !== undefined)
|
||||
throw new AggregateError(
|
||||
[verificationFailure, releaseFailure],
|
||||
"Volume-root append lease verification and cleanup both failed.",
|
||||
);
|
||||
if (verificationFailure !== undefined) throw verificationFailure;
|
||||
if (releaseFailure !== undefined) throw releaseFailure;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
|
||||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
import { resolveGhidraVerifierTimeouts } from "./lib/ghidra-verifier-timeouts.mjs";
|
||||
import { verifyGhidraCompactReferences } from "./lib/real-ghidra-compact-references.mjs";
|
||||
import { verifyGhidraBorlandSeh } from "./lib/real-ghidra-borland-seh.mjs";
|
||||
import { verifyGhidraFileOffsets } from "./lib/real-ghidra-file-offsets.mjs";
|
||||
import { verifyWindowsGhidraByteSearch } from "./lib/ghidra-windows-byte-search.mjs";
|
||||
|
||||
@@ -273,6 +274,10 @@ try {
|
||||
functionOperation,
|
||||
architecture === "x86",
|
||||
);
|
||||
const borlandSeh =
|
||||
architecture === "x86" && !dllFixture
|
||||
? await verifyGhidraBorlandSeh(functionObservationOperation, true)
|
||||
: null;
|
||||
report = {
|
||||
ok: true,
|
||||
provider: { id: "ghidra", version: SUPPORTED_GHIDRA_VERSION },
|
||||
@@ -289,6 +294,7 @@ try {
|
||||
byte_search: byteSearch,
|
||||
cleanup: "complete",
|
||||
compact_references: compactReferences,
|
||||
borland_seh: borlandSeh,
|
||||
native_controls: {
|
||||
job_object: true,
|
||||
protected_dacl: true,
|
||||
@@ -338,6 +344,11 @@ async function inventory(operation, parameters) {
|
||||
}
|
||||
|
||||
async function functionOperation(operation, parameters) {
|
||||
const observation = await functionObservationOperation(operation, parameters);
|
||||
return observation.value;
|
||||
}
|
||||
|
||||
async function functionObservationOperation(operation, parameters) {
|
||||
const input = parseGhidraFunctionInput(operation, parameters);
|
||||
if (!input.ok) throw input.error;
|
||||
const called = await client.callTool(operation, input.value);
|
||||
@@ -345,7 +356,7 @@ async function functionOperation(operation, parameters) {
|
||||
const result = parseGhidraFunctionResult(operation, called.value);
|
||||
if (!result.ok) throw result.error;
|
||||
observed.add(operation);
|
||||
return result.value.value;
|
||||
return result.value;
|
||||
}
|
||||
|
||||
function assertSession(session, targetSha256, profileDigest) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { dirname, join, parse, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { verifyWindowsProtocolInput } from "./lib/windows-protocol-input.mjs";
|
||||
import { verifyWindowsJobMemoryLimit } from "./lib/windows-job-memory-limit.mjs";
|
||||
import { verifyVolumeRootAppendLease } from "./lib/windows-volume-root-append-lease.mjs";
|
||||
|
||||
if (process.platform !== "win32")
|
||||
throw new Error(
|
||||
@@ -251,6 +252,17 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe
|
||||
}
|
||||
const unrelatedFile = join(workspace, "unrelated-file");
|
||||
await writeFile(unrelatedFile, "preserve unrelated writes");
|
||||
if (process.argv[3] !== undefined) {
|
||||
await verifyVolumeRootAppendLease({
|
||||
fixturePath: process.argv[3],
|
||||
volumeRoot: parse(source).root,
|
||||
verifyAdmission: () => {
|
||||
const rootGuard = native.call("open", [source]);
|
||||
native.call("close", [rootGuard.handle]);
|
||||
},
|
||||
});
|
||||
report.controls.volumeRootAdmissionWithAppendHandle = true;
|
||||
}
|
||||
const admitted = native.call("open", [source]);
|
||||
try {
|
||||
await assert.rejects(
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
access,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
if (process.platform !== "win32")
|
||||
throw new Error("Requires actual Windows NTFS");
|
||||
const [rootArgument, expected] = process.argv.slice(2);
|
||||
assert.ok(rootArgument);
|
||||
assert.ok(expected === "deny-clear" || expected === "allow");
|
||||
const root = resolve(rootArgument);
|
||||
const { requireWindowsNativeAuthority } = await import(
|
||||
pathToFileURL(join(root, "dist/windows/WindowsNativeLoader.js"))
|
||||
);
|
||||
const native = requireWindowsNativeAuthority();
|
||||
const workspace = await mkdtemp(join(tmpdir(), "rea-readonly-root-probe-"));
|
||||
const roots = [];
|
||||
const attributes = (path, readonly) => {
|
||||
const script = `$ErrorActionPreference='Stop';
|
||||
$path=$env:REA_OWNED_ATTRIBUTE_PROBE;
|
||||
$current=[int][IO.File]::GetAttributes($path);
|
||||
$next=if($env:REA_ATTRIBUTE_PROBE_MODE -eq 'set'){ $current -bor 1 }else{ $current -band (-bnot 1) };
|
||||
[IO.File]::SetAttributes($path,[IO.FileAttributes]$next);
|
||||
$observed=[int][IO.File]::GetAttributes($path);
|
||||
@{readonly=(($observed -band 1) -ne 0); attributes=$observed} | ConvertTo-Json -Compress`;
|
||||
const result = JSON.parse(
|
||||
execFileSync(
|
||||
"powershell.exe",
|
||||
["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script],
|
||||
{
|
||||
encoding: "utf8",
|
||||
timeout: 10000,
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
REA_OWNED_ATTRIBUTE_PROBE: path,
|
||||
REA_ATTRIBUTE_PROBE_MODE: readonly ? "set" : "clear",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
assert.equal(result.readonly, readonly);
|
||||
return result;
|
||||
};
|
||||
const create = () => {
|
||||
const runtime = native.call("runtime_create", [workspace, "owned-"]);
|
||||
const owned = { runtime, currentPath: runtime.path, closed: false };
|
||||
roots.push(owned);
|
||||
return owned;
|
||||
};
|
||||
const close = (owned) => {
|
||||
native.call("runtime_close", [owned.runtime.handle]);
|
||||
owned.closed = true;
|
||||
};
|
||||
try {
|
||||
const normal = create();
|
||||
close(normal);
|
||||
await assert.rejects(access(normal.currentPath), { code: "ENOENT" });
|
||||
|
||||
const owned = create();
|
||||
const original = owned.currentPath;
|
||||
const originalIdentity = await stat(original, { bigint: true });
|
||||
const marked = attributes(original, true);
|
||||
assert.notEqual(marked.attributes & 16, 0, "Probe must be a directory");
|
||||
const moved = original + "-moved";
|
||||
await rename(original, moved);
|
||||
owned.currentPath = moved;
|
||||
await mkdir(original);
|
||||
const replacementIdentity = await stat(original, { bigint: true });
|
||||
assert.notEqual(replacementIdentity.ino, originalIdentity.ino);
|
||||
const sentinel = join(original, "unrelated.txt");
|
||||
await writeFile(sentinel, "replacement must survive");
|
||||
let failure;
|
||||
try {
|
||||
close(owned);
|
||||
} catch (cause) {
|
||||
failure = cause;
|
||||
}
|
||||
if (expected === "deny-clear") {
|
||||
assert.equal(failure?.win32Code, 5);
|
||||
assert.equal(failure?.constraint, "Clear owned readonly attribute failed");
|
||||
assert.equal(
|
||||
(await stat(moved, { bigint: true })).ino,
|
||||
originalIdentity.ino,
|
||||
);
|
||||
attributes(moved, false);
|
||||
close(owned);
|
||||
} else assert.equal(failure, undefined, failure?.message);
|
||||
await assert.rejects(access(moved), { code: "ENOENT" });
|
||||
assert.equal(
|
||||
(await stat(original, { bigint: true })).ino,
|
||||
replacementIdentity.ino,
|
||||
);
|
||||
assert.equal(await readFile(sentinel, "utf8"), "replacement must survive");
|
||||
|
||||
const children = create();
|
||||
native.call("runtime_mkdir", [children.runtime.handle, "nested"]);
|
||||
native.call("runtime_write", [
|
||||
children.runtime.handle,
|
||||
"nested/readonly.txt",
|
||||
Buffer.from("owned readonly file"),
|
||||
]);
|
||||
attributes(join(children.currentPath, "nested"), true);
|
||||
attributes(join(children.currentPath, "nested", "readonly.txt"), true);
|
||||
close(children);
|
||||
await assert.rejects(access(children.currentPath), { code: "ENOENT" });
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
expected,
|
||||
rootReadonlyConfirmed: true,
|
||||
renamedOwnedRootRemoved: true,
|
||||
unrelatedReplacementPreserved: true,
|
||||
readonlyChildFileAndDirectoryRemoved: true,
|
||||
baselineConstraint: failure?.constraint ?? null,
|
||||
baselineWin32: failure?.win32Code ?? null,
|
||||
originalIssueDeleteDenialReproduced: false,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
for (const owned of roots) {
|
||||
if (owned.closed) continue;
|
||||
attributes(owned.currentPath, false);
|
||||
close(owned);
|
||||
}
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
@@ -13,6 +13,18 @@ import {
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import { dshDocument } from "./DshCordisPatch.js";
|
||||
import type { McpToolTimeoutInputs } from "../mcpStartupPolicy.js";
|
||||
import type { AnalysisProviderSelector } from "../contracts/providerSelection.js";
|
||||
|
||||
export type ProviderRequestBudgetResolver = (
|
||||
analysisProvider: AnalysisProviderSelector,
|
||||
environment: Readonly<Record<string, string>>,
|
||||
) => Result<number | undefined, ConfigurationError>;
|
||||
|
||||
export interface ClientRegistrationEnvironmentDetails {
|
||||
readonly values: Readonly<Record<string, string>>;
|
||||
readonly timeoutInputs: McpToolTimeoutInputs;
|
||||
}
|
||||
|
||||
/** Read explicit REA overrides without dropping peers when one value is invalid. */
|
||||
export const clientRegistrationEnvironment = (
|
||||
@@ -22,6 +34,33 @@ export const clientRegistrationEnvironment = (
|
||||
): Result<Readonly<Record<string, string>>, ConfigurationError> => {
|
||||
if (parsed?.dialect === "dsh")
|
||||
return dshDocument(parsed.document).environment(overrides, validation);
|
||||
const details = clientRegistrationEnvironmentDetails(
|
||||
parsed,
|
||||
overrides,
|
||||
validation,
|
||||
);
|
||||
return details.ok ? ok(details.value.values) : details;
|
||||
};
|
||||
|
||||
/** Retain original strings and the parser-owned timeout facts. */
|
||||
export const clientRegistrationEnvironmentDetails = (
|
||||
parsed: ClientConfigurationDocument | undefined,
|
||||
overrides: Readonly<Record<string, string>> = {},
|
||||
validation: "fragment" | "effective" = "effective",
|
||||
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
||||
): Result<ClientRegistrationEnvironmentDetails, ConfigurationError> => {
|
||||
if (parsed?.dialect === "dsh") {
|
||||
const environment = dshDocument(parsed.document).environment(
|
||||
overrides,
|
||||
validation,
|
||||
);
|
||||
if (!environment.ok) return environment;
|
||||
return validatedDetails(
|
||||
environment.value,
|
||||
validation,
|
||||
resolveProviderRequestBudget,
|
||||
);
|
||||
}
|
||||
const key =
|
||||
parsed?.dialect === "opencode" || parsed?.dialect === "opencode_v2"
|
||||
? "environment"
|
||||
@@ -50,16 +89,45 @@ export const clientRegistrationEnvironment = (
|
||||
// Validate known settings with the runtime's parser, but retain the original
|
||||
// string overrides, including unknown client-specific keys and no defaults.
|
||||
const environment = { ...existing.data, ...overrides };
|
||||
return validatedDetails(
|
||||
environment,
|
||||
validation,
|
||||
resolveProviderRequestBudget,
|
||||
);
|
||||
};
|
||||
|
||||
const validatedDetails = (
|
||||
environment: Readonly<Record<string, string>>,
|
||||
validation: "fragment" | "effective",
|
||||
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
||||
): Result<ClientRegistrationEnvironmentDetails, ConfigurationError> => {
|
||||
const validated =
|
||||
validation === "fragment"
|
||||
? parseEnvironmentValues(environment)
|
||||
: parseEnvironment(environment);
|
||||
if (!validated.ok) return validated;
|
||||
return ok(
|
||||
Object.fromEntries(
|
||||
const providerRequestBudget =
|
||||
resolveProviderRequestBudget?.(
|
||||
validated.value.REA_ANALYSIS_PROVIDER,
|
||||
environment,
|
||||
) ?? ok(undefined);
|
||||
if (!providerRequestBudget.ok) return providerRequestBudget;
|
||||
return ok({
|
||||
values: Object.fromEntries(
|
||||
Object.entries(environment).sort(([left], [right]) =>
|
||||
compareUnicodeCodePoints(left, right),
|
||||
),
|
||||
),
|
||||
);
|
||||
timeoutInputs: {
|
||||
analysisProvider: validated.value.REA_ANALYSIS_PROVIDER,
|
||||
ghidraStartupTimeoutMs: validated.value.REA_GHIDRA_STARTUP_TIMEOUT_MS,
|
||||
hopperStartupTimeoutMs: validated.value.REA_HOPPER_STARTUP_TIMEOUT_MS,
|
||||
...(validated.value.REA_JEB_MCP_TIMEOUT_MS === undefined
|
||||
? {}
|
||||
: { jebMcpTimeoutMs: validated.value.REA_JEB_MCP_TIMEOUT_MS }),
|
||||
...(providerRequestBudget.value === undefined
|
||||
? {}
|
||||
: { providerRequestTimeoutMs: providerRequestBudget.value }),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -14,7 +14,13 @@ import { valid } from "semver";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
|
||||
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
|
||||
import {
|
||||
MCP_STARTUP_POLICY,
|
||||
mcpProviderDeadlineAllowanceMs,
|
||||
recommendedMcpToolTimeoutMs,
|
||||
recommendedMcpToolTimeoutSeconds,
|
||||
type McpToolTimeoutInputs,
|
||||
} from "../mcpStartupPolicy.js";
|
||||
import {
|
||||
clientRegistrationPackageReference,
|
||||
classifyClientRegistrationCommand,
|
||||
@@ -28,7 +34,8 @@ import {
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import { dshDocument } from "./DshCordisPatch.js";
|
||||
import { clientRegistrationEnvironment } from "./ClientRegistrationEnvironment.js";
|
||||
import { clientRegistrationEnvironmentDetails } from "./ClientRegistrationEnvironment.js";
|
||||
import type { ProviderRequestBudgetResolver } from "./ClientRegistrationEnvironment.js";
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
|
||||
interface ClientRegistrationStatusBase {
|
||||
@@ -36,6 +43,8 @@ interface ClientRegistrationStatusBase {
|
||||
readonly config_path: string;
|
||||
/** Exact package reference requested by npx/bunx, when registration uses one. */
|
||||
readonly package_reference?: string;
|
||||
/** Non-blocking timeout guidance; caller-selected values remain aligned. */
|
||||
readonly advisory?: string;
|
||||
}
|
||||
|
||||
type RegistrationCommand = readonly [string, ...string[]];
|
||||
@@ -74,6 +83,7 @@ const registrationSchema = z
|
||||
command: z.string().min(1),
|
||||
args: z.array(z.string()).default([]),
|
||||
startup_timeout_sec: z.number().positive().optional(),
|
||||
tool_timeout_sec: z.number().positive().optional(),
|
||||
startupTimeoutMs: z.number().positive().optional(),
|
||||
toolTimeoutMs: z.number().positive().optional(),
|
||||
type: z.string().optional(),
|
||||
@@ -89,6 +99,7 @@ interface ClientRegistrationReadOptions {
|
||||
readonly platform?: NodeJS.Platform;
|
||||
readonly environment?: NodeJS.ProcessEnv;
|
||||
readonly resources?: ArtifactResourceScope;
|
||||
readonly resolveProviderRequestBudget?: ProviderRequestBudgetResolver;
|
||||
}
|
||||
|
||||
/** Registration facts shared by diagnostics and update maintenance. */
|
||||
@@ -217,11 +228,27 @@ export const readClientRegistrationInspections = async (
|
||||
: client;
|
||||
const registration = parseRegistration(raw, effectiveClient);
|
||||
const registrationEnvironment =
|
||||
client.name === "kimi"
|
||||
? clientRegistrationEnvironment(parsed)
|
||||
client.name === "kimi" || client.name === "codex"
|
||||
? clientRegistrationEnvironmentDetails(
|
||||
parsed,
|
||||
{},
|
||||
"effective",
|
||||
options.resolveProviderRequestBudget,
|
||||
)
|
||||
: undefined;
|
||||
if (registrationEnvironment !== undefined && !registrationEnvironment.ok)
|
||||
throw registrationEnvironment.error;
|
||||
if (
|
||||
registrationEnvironment !== undefined &&
|
||||
!registrationEnvironment.ok
|
||||
) {
|
||||
record({
|
||||
client: client.name,
|
||||
config_path: configPath,
|
||||
command: [],
|
||||
state: "invalid",
|
||||
remediation: registrationEnvironment.error.message,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const command: RegistrationCommand = [
|
||||
registration.command,
|
||||
...registration.args,
|
||||
@@ -239,7 +266,7 @@ export const readClientRegistrationInspections = async (
|
||||
platform: options.platform ?? process.platform,
|
||||
kimiInputSchemaProfileSelected:
|
||||
registrationEnvironment?.ok === true &&
|
||||
registrationEnvironment.value.REA_MCP_INPUT_SCHEMA_PROFILE !==
|
||||
registrationEnvironment.value.values.REA_MCP_INPUT_SCHEMA_PROFILE !==
|
||||
undefined,
|
||||
});
|
||||
record(
|
||||
@@ -263,6 +290,13 @@ export const readClientRegistrationInspections = async (
|
||||
? unknownEntryPointRemediation()
|
||||
: undefined),
|
||||
packageReference,
|
||||
registrationEnvironment?.ok === true
|
||||
? timeoutAdvisory(
|
||||
client,
|
||||
registration,
|
||||
registrationEnvironment.value.timeoutInputs,
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
activation,
|
||||
);
|
||||
@@ -316,16 +350,21 @@ const registrationAligned = ({
|
||||
if (identity === "unknown") return "unknown";
|
||||
if (identity === "foreign") return "stale";
|
||||
if (
|
||||
(client.name === "codex" || client.name === "grok_build") &&
|
||||
client.name === "grok_build" &&
|
||||
registration.startup_timeout_sec !==
|
||||
MCP_STARTUP_POLICY.codexStartupTimeoutSeconds
|
||||
)
|
||||
return "stale";
|
||||
if (
|
||||
client.name === "codex" &&
|
||||
(registration.startup_timeout_sec === undefined ||
|
||||
registration.tool_timeout_sec === undefined)
|
||||
)
|
||||
return "stale";
|
||||
if (
|
||||
client.name === "kimi" &&
|
||||
(registration.startupTimeoutMs !==
|
||||
MCP_STARTUP_POLICY.kimiStartupTimeoutMs ||
|
||||
registration.toolTimeoutMs !== MCP_STARTUP_POLICY.kimiToolTimeoutMs)
|
||||
(registration.startupTimeoutMs === undefined ||
|
||||
registration.toolTimeoutMs === undefined)
|
||||
)
|
||||
return "stale";
|
||||
if (client.name === "kimi" && !kimiInputSchemaProfileSelected) return "stale";
|
||||
@@ -454,6 +493,7 @@ const configuredStatus = (
|
||||
state: "aligned" | "stale" | "unknown",
|
||||
policyRemediation?: string,
|
||||
packageReference?: string,
|
||||
advisory?: string,
|
||||
): ClientRegistrationStatus =>
|
||||
state === "aligned"
|
||||
? {
|
||||
@@ -465,6 +505,7 @@ const configuredStatus = (
|
||||
: { package_reference: packageReference }),
|
||||
state,
|
||||
remediation: null,
|
||||
...(advisory === undefined ? {} : { advisory }),
|
||||
}
|
||||
: {
|
||||
client: client.name,
|
||||
@@ -477,6 +518,35 @@ const configuredStatus = (
|
||||
remediation: policyRemediation ?? remediation(client),
|
||||
};
|
||||
|
||||
const timeoutAdvisory = (
|
||||
client: SetupClient,
|
||||
registration: z.output<typeof registrationSchema>,
|
||||
timeoutInputs: McpToolTimeoutInputs,
|
||||
): string | undefined => {
|
||||
const toolTimeout =
|
||||
client.name === "kimi"
|
||||
? registration.toolTimeoutMs
|
||||
: client.name === "codex"
|
||||
? registration.tool_timeout_sec
|
||||
: undefined;
|
||||
if (toolTimeout === undefined) return undefined;
|
||||
const providerAllowance = mcpProviderDeadlineAllowanceMs(timeoutInputs);
|
||||
const isKimi = client.name === "kimi";
|
||||
const toolTimeoutMs = isKimi ? toolTimeout : toolTimeout * 1000;
|
||||
if (toolTimeoutMs >= providerAllowance) return undefined;
|
||||
const setting = isKimi ? "toolTimeoutMs" : "tool_timeout_sec";
|
||||
const currentValue = isKimi
|
||||
? `${String(toolTimeout)} ms`
|
||||
: `${String(toolTimeout)} seconds`;
|
||||
const recommendedValue = isKimi
|
||||
? `${String(recommendedMcpToolTimeoutMs(timeoutInputs))} ms`
|
||||
: `${String(recommendedMcpToolTimeoutSeconds(timeoutInputs))} seconds`;
|
||||
const headroom = isKimi
|
||||
? `${String(MCP_STARTUP_POLICY.toolOperationHeadroomMs)} ms`
|
||||
: `${String(MCP_STARTUP_POLICY.toolOperationHeadroomMs / 1000)} seconds`;
|
||||
return `The configured ${setting} (${currentValue}) is shorter than REA's known provider allowance (${String(providerAllowance)} ms), so provider startup or an upstream request may exceed this client deadline. REA recommends ${setting} = ${recommendedValue} to cover that allowance plus ${headroom} of operation headroom; this additional headroom is a recommendation, not a guarantee for every analysis.`;
|
||||
};
|
||||
|
||||
const exactRegisteredPackageVersion = (
|
||||
packageReference: string | undefined,
|
||||
): string | undefined => {
|
||||
|
||||
@@ -363,6 +363,38 @@ describe("doctor scoped readiness", () => {
|
||||
);
|
||||
expect(staleSelected.healthy).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a caller-selected short timeout healthy and reports its advisory as informational", async () => {
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
host({
|
||||
clientRegistrations: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
client: "kimi",
|
||||
config_path: "/home/user/.kimi-code/mcp.json",
|
||||
command: ["/current/rea", "mcp"],
|
||||
state: "aligned",
|
||||
remediation: null,
|
||||
advisory: "The configured toolTimeoutMs is shorter than startup.",
|
||||
},
|
||||
]),
|
||||
}),
|
||||
{ clients: ["kimi"] },
|
||||
);
|
||||
|
||||
expect(result.healthy).toBe(true);
|
||||
expect(result.scope_checks).toContainEqual(
|
||||
expect.objectContaining({ name: "registration:kimi", ok: true }),
|
||||
);
|
||||
expect(result.informational_checks).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "registration_timeout:kimi",
|
||||
ok: true,
|
||||
detail: "The configured toolTimeoutMs is shorter than startup.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Grok Bot readiness", () => {
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
type ClientRegistrationStatus,
|
||||
type UnhealthyClientRegistrationStatus,
|
||||
} from "./ClientRegistrationStatus.js";
|
||||
import type { ProviderRequestBudgetResolver } from "./ClientRegistrationEnvironment.js";
|
||||
import {
|
||||
collectDoctorDiagnostics,
|
||||
doctorHealthy,
|
||||
@@ -105,6 +106,8 @@ export interface DoctorHost {
|
||||
brewHopperPath(): Promise<string | undefined>;
|
||||
manualHopperPaths(): Promise<readonly string[]>;
|
||||
providerInspections?(): Promise<readonly DoctorProviderInspection[]>;
|
||||
/** Resolve a parsed upstream request budget from the effective client environment. */
|
||||
resolveProviderRequestBudget?: ProviderRequestBudgetResolver;
|
||||
/** Validated provider settings preserved by setup, independent of readiness. */
|
||||
providerRegistrationEnvironment?(): Readonly<Record<string, string>>;
|
||||
installationPaths?(): Promise<readonly string[]>;
|
||||
@@ -220,7 +223,11 @@ const inspectDoctor = async (
|
||||
providerInspections,
|
||||
} = await collectDoctorDiagnostics(target, host);
|
||||
const identity = await collectDoctorIdentity(host, scope);
|
||||
const checks = [...diagnosticChecks, ...identity.checks];
|
||||
const checks = [
|
||||
...diagnosticChecks,
|
||||
...identity.checks,
|
||||
...registrationAdvisoryChecks(identity.value.registrations),
|
||||
];
|
||||
const providers = {
|
||||
hopperAvailable:
|
||||
hopperPath !== undefined &&
|
||||
@@ -252,6 +259,22 @@ const inspectDoctor = async (
|
||||
};
|
||||
};
|
||||
|
||||
const registrationAdvisoryChecks = (
|
||||
registrations: readonly ClientRegistrationStatus[],
|
||||
): readonly DoctorCheck[] =>
|
||||
registrations.flatMap(({ client, advisory }) =>
|
||||
advisory === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: `registration_timeout:${client}`,
|
||||
ok: true,
|
||||
classification: "healthy" as const,
|
||||
detail: advisory,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const closeDoctorHost = async (
|
||||
host: DoctorHost,
|
||||
): Promise<string | undefined> => {
|
||||
@@ -411,6 +434,7 @@ export interface SystemDoctorHostOptions {
|
||||
readonly providerRegistrationEnvironment?: () => Readonly<
|
||||
Record<string, string>
|
||||
>;
|
||||
readonly resolveProviderRequestBudget?: ProviderRequestBudgetResolver;
|
||||
readonly linuxDemoRuntimeCheck?: () => Promise<DoctorCheck>;
|
||||
}
|
||||
|
||||
@@ -527,6 +551,9 @@ export const systemDoctorHost = (
|
||||
providerRegistrationEnvironment:
|
||||
options.providerRegistrationEnvironment,
|
||||
}),
|
||||
...(options.resolveProviderRequestBudget === undefined
|
||||
? {}
|
||||
: { resolveProviderRequestBudget: options.resolveProviderRequestBudget }),
|
||||
async ilspyCmdVersion(path) {
|
||||
try {
|
||||
await access(path, constants.X_OK);
|
||||
@@ -579,6 +606,12 @@ export const systemDoctorHost = (
|
||||
platform,
|
||||
environment,
|
||||
resources,
|
||||
...(options.resolveProviderRequestBudget === undefined
|
||||
? {}
|
||||
: {
|
||||
resolveProviderRequestBudget:
|
||||
options.resolveProviderRequestBudget,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ export const workflowAnalysisProfile = (
|
||||
): AnalysisProfileCommitment =>
|
||||
createAnalysisProfile(REA_WORKFLOW_PROVIDER, {
|
||||
upstream_analysis_profile: upstream,
|
||||
source_limitations: "provider-operation-caveats-v1",
|
||||
...(operation === "binary_overview"
|
||||
? { workflow_revision: 4 }
|
||||
: operation === "batch_decompile" ||
|
||||
|
||||
@@ -206,9 +206,10 @@ describe("configured Linux Hopper selection", () => {
|
||||
host.hopperLauncherConfigured = true;
|
||||
host.linuxDemoRuntimeMissing = true;
|
||||
const doctor = await host.doctor();
|
||||
const initial = await host.initialSetupState();
|
||||
const discovery = await discoverSetupState({
|
||||
host,
|
||||
providerEnvironment: { HOPPER_LAUNCHER_PATH: host.hopper },
|
||||
providerConfigured: initial.providerConfigured,
|
||||
forceHopperInstall: false,
|
||||
proposeHopper: true,
|
||||
hopperLauncherConfigured: true,
|
||||
@@ -227,9 +228,10 @@ describe("configured Linux Hopper selection", () => {
|
||||
};
|
||||
host.hopper = "/opt/hopper/bin/Hopper";
|
||||
host.unsupportedHopperVersion = true;
|
||||
const initial = await host.initialSetupState();
|
||||
const discovery = await discoverSetupState({
|
||||
host,
|
||||
providerEnvironment: { HOPPER_LAUNCHER_PATH: host.hopper },
|
||||
providerConfigured: initial.providerConfigured,
|
||||
forceHopperInstall: false,
|
||||
proposeHopper: true,
|
||||
initialDoctor: await host.doctor(),
|
||||
|
||||
@@ -74,6 +74,7 @@ export class FakeSetupHost implements SetupHost {
|
||||
...(this.hopperLauncherConfigured
|
||||
? { hopperLauncherConfigured: true }
|
||||
: {}),
|
||||
providerConfigured: this.hopper !== undefined || this.ghidra !== undefined,
|
||||
providerEnvironment: {
|
||||
...(this.hopper === undefined
|
||||
? {}
|
||||
|
||||
@@ -111,7 +111,7 @@ const runSetupWorkflow = async (
|
||||
let { hopperPath, providerEnvironment } = initialState;
|
||||
const discovery = await discoverSetupState({
|
||||
host,
|
||||
providerEnvironment,
|
||||
providerConfigured: initialState.providerConfigured,
|
||||
forceHopperInstall: options.installHopper,
|
||||
hopperLauncherConfigured: initialState.hopperLauncherConfigured === true,
|
||||
proposeHopper:
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type ClientConfigurationSource,
|
||||
} from "./ClientConfigurationFiles.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||
import {
|
||||
clientRegistrationEntry,
|
||||
clientConfigurationValuesEqual,
|
||||
@@ -23,11 +24,19 @@ import { dshDocument, DshCordisDocument } from "./DshCordisPatch.js";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
import { clientRegistrationEnvironment } from "./ClientRegistrationEnvironment.js";
|
||||
import {
|
||||
clientRegistrationEnvironment,
|
||||
clientRegistrationEnvironmentDetails,
|
||||
type ProviderRequestBudgetResolver,
|
||||
} from "./ClientRegistrationEnvironment.js";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
||||
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
|
||||
import {
|
||||
MCP_STARTUP_POLICY,
|
||||
recommendedMcpToolTimeoutMs,
|
||||
recommendedMcpToolTimeoutSeconds,
|
||||
} from "../mcpStartupPolicy.js";
|
||||
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
||||
import { packageReadTargetChanged } from "./MsixPackageStorage.js";
|
||||
import {
|
||||
@@ -74,16 +83,23 @@ export const configureClientConfiguration = (
|
||||
client: SetupClient,
|
||||
environment: SetupProviderEnvironment = {},
|
||||
command: readonly string[] = npxRegistrationCommand(),
|
||||
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
||||
): Promise<ClientConfigurationResult> => {
|
||||
if (client.configPathError !== undefined)
|
||||
return Promise.resolve({ status: "failed", reason: "path" });
|
||||
return configureClientDocument(client, environment, command);
|
||||
return configureClientDocument(
|
||||
client,
|
||||
environment,
|
||||
command,
|
||||
resolveProviderRequestBudget,
|
||||
);
|
||||
};
|
||||
|
||||
const configureClientDocument = async (
|
||||
client: SetupClient,
|
||||
environment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
||||
): Promise<ClientConfigurationResult> => {
|
||||
const format = client.format;
|
||||
if (format === undefined || format === "unsupported")
|
||||
@@ -111,6 +127,7 @@ const configureClientDocument = async (
|
||||
command,
|
||||
existing,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
);
|
||||
if (!policy.ok) {
|
||||
@@ -149,6 +166,7 @@ const configureClientDocument = async (
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
);
|
||||
if (!policy.ok) {
|
||||
@@ -177,6 +195,7 @@ const configureClientDocument = async (
|
||||
servers: { ...fallback.value, ...parsed.servers },
|
||||
},
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
);
|
||||
if (registrationCurrent(parsed, desired)) return { status: "unchanged" };
|
||||
return persistClientConfiguration({
|
||||
@@ -346,6 +365,7 @@ export const clientConfigurationAligned = async (
|
||||
client: SetupClient,
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
||||
): Promise<boolean> => {
|
||||
if (client.configPathError !== undefined) return false;
|
||||
try {
|
||||
@@ -365,6 +385,7 @@ export const clientConfigurationAligned = async (
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
);
|
||||
if (!policy.ok) {
|
||||
@@ -381,6 +402,7 @@ export const clientConfigurationAligned = async (
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
)
|
||||
);
|
||||
@@ -397,11 +419,13 @@ export const inspectClientConfiguration = async (
|
||||
client: SetupClient,
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
||||
): Promise<ClientConfigurationInspection> => {
|
||||
const inspection = await inspectClientDocument(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
resolveProviderRequestBudget,
|
||||
);
|
||||
if (inspection.status !== "create" && inspection.status !== "update")
|
||||
return inspection;
|
||||
@@ -416,6 +440,7 @@ const inspectClientDocument = async (
|
||||
client: SetupClient,
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
||||
): Promise<ClientConfigurationInspection> => {
|
||||
if (client.configPathError !== undefined)
|
||||
return { status: "invalid", remediation: client.configPathError };
|
||||
@@ -449,6 +474,7 @@ const inspectClientDocument = async (
|
||||
command,
|
||||
existing,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
);
|
||||
if (!policy.ok) {
|
||||
@@ -498,6 +524,7 @@ const inspectClientDocument = async (
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
);
|
||||
if (!policy.ok) {
|
||||
@@ -515,6 +542,7 @@ const inspectClientDocument = async (
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
);
|
||||
if (registrationCurrent(parsed, desired))
|
||||
return { status: "already_current" };
|
||||
@@ -525,6 +553,8 @@ const inspectClientDocument = async (
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
||||
if (cause instanceof ConfigurationError)
|
||||
return { status: "invalid", remediation: cause.message };
|
||||
// Malformed configuration is reported with the invalid remediation.
|
||||
void cause;
|
||||
return {
|
||||
@@ -653,20 +683,21 @@ const clientConfigurationDesired = (
|
||||
command: readonly string[],
|
||||
parsed: ClientConfigurationDocument | undefined,
|
||||
sources: readonly ClientConfigurationSource[],
|
||||
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
||||
) => {
|
||||
const effective = effectiveClientConfiguration(sources, {
|
||||
client,
|
||||
document: parsed,
|
||||
});
|
||||
const validated = clientRegistrationEnvironment(
|
||||
effective,
|
||||
providerEnvironment,
|
||||
);
|
||||
if (!validated.ok) throw validated.error;
|
||||
const format = parsed?.dialect ?? client.format ?? "json";
|
||||
const launcher =
|
||||
command.length === 0 ? [PRODUCT_IDENTITY.cliBinary, "mcp"] : command;
|
||||
if (format === "dsh") {
|
||||
const validated = clientRegistrationEnvironment(
|
||||
effective,
|
||||
providerEnvironment,
|
||||
);
|
||||
if (!validated.ok) throw validated.error;
|
||||
const desired = (
|
||||
parsed === undefined
|
||||
? new DshCordisDocument("")
|
||||
@@ -675,8 +706,18 @@ const clientConfigurationDesired = (
|
||||
if (!desired.ok) throw desired.error;
|
||||
return desired.value;
|
||||
}
|
||||
const validated = clientRegistrationEnvironmentDetails(
|
||||
effective,
|
||||
providerEnvironment,
|
||||
"effective",
|
||||
client.name === "codex" || client.name === "kimi"
|
||||
? resolveProviderRequestBudget
|
||||
: undefined,
|
||||
);
|
||||
if (!validated.ok) throw validated.error;
|
||||
// Preserve explicitly configured server settings, never the ambient process
|
||||
// environment. Newly discovered provider paths win only for their own keys.
|
||||
// environment. The effective layered view validates precedence and supplies
|
||||
// timeout facts; the target file's fragment remains the value being written.
|
||||
const environment = clientRegistrationEnvironment(
|
||||
parsed,
|
||||
providerEnvironment,
|
||||
@@ -708,14 +749,40 @@ const clientConfigurationDesired = (
|
||||
...registration,
|
||||
...(client.name === "codex" || client.name === "grok_build"
|
||||
? {
|
||||
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
|
||||
startup_timeout_sec:
|
||||
client.name === "codex"
|
||||
? (positiveTimeout(previous, "startup_timeout_sec") ??
|
||||
MCP_STARTUP_POLICY.codexStartupTimeoutSeconds)
|
||||
: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
|
||||
}
|
||||
: {}),
|
||||
...(client.name === "codex"
|
||||
? {
|
||||
tool_timeout_sec:
|
||||
positiveTimeout(previous, "tool_timeout_sec") ??
|
||||
recommendedMcpToolTimeoutSeconds(validated.value.timeoutInputs),
|
||||
}
|
||||
: {}),
|
||||
...(client.name === "kimi"
|
||||
? {
|
||||
startupTimeoutMs: MCP_STARTUP_POLICY.kimiStartupTimeoutMs,
|
||||
toolTimeoutMs: MCP_STARTUP_POLICY.kimiToolTimeoutMs,
|
||||
startupTimeoutMs:
|
||||
positiveTimeout(previous, "startupTimeoutMs") ??
|
||||
MCP_STARTUP_POLICY.kimiStartupTimeoutMs,
|
||||
toolTimeoutMs:
|
||||
positiveTimeout(previous, "toolTimeoutMs") ??
|
||||
recommendedMcpToolTimeoutMs(validated.value.timeoutInputs),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
const positiveTimeout = (
|
||||
registration: unknown,
|
||||
key: string,
|
||||
): number | undefined => {
|
||||
if (typeof registration !== "object" || registration === null) return;
|
||||
const value: unknown = Reflect.get(registration, key);
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? value
|
||||
: undefined;
|
||||
};
|
||||
@@ -45,7 +45,10 @@ import {
|
||||
RegularFileCleanupFailure,
|
||||
retryRegularFileCleanup,
|
||||
} from "./RegularFileRead.js";
|
||||
import { providerRegistrationEnvironment } from "./SetupRegistrationEnvironment.js";
|
||||
import {
|
||||
explicitSetupRegistrationEnvironment,
|
||||
providerRegistrationEnvironment,
|
||||
} from "./SetupRegistrationEnvironment.js";
|
||||
import {
|
||||
msixPackagesDirectory,
|
||||
packagePrivateLauncherBlocker,
|
||||
@@ -57,6 +60,7 @@ import type {
|
||||
} from "./SetupTypes.js";
|
||||
import type { DoctorScope } from "./Doctor.js";
|
||||
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
|
||||
import { analysisProviderSelectorSchema } from "../contracts/providerSelection.js";
|
||||
|
||||
/** Resolve the executable and arguments used in a managed MCP registration. */
|
||||
export const setupRegistrationCommand = (
|
||||
@@ -166,6 +170,17 @@ export const systemSetupHost = (
|
||||
scope?: DoctorScope,
|
||||
): Promise<SetupInitialState> => {
|
||||
const diagnosis = await runDoctor(undefined, doctorHost, scope);
|
||||
const providerEnvironment: SetupProviderEnvironment = {
|
||||
...providerRegistrationEnvironment(diagnosis.providerInspections ?? []),
|
||||
...doctorHost.providerRegistrationEnvironment?.(),
|
||||
...explicitSetupRegistrationEnvironment(environment),
|
||||
...(diagnosis.hopperPath === undefined
|
||||
? {}
|
||||
: { HOPPER_LAUNCHER_PATH: diagnosis.hopperPath }),
|
||||
};
|
||||
const requestedProvider = analysisProviderSelectorSchema.safeParse(
|
||||
environment.REA_ANALYSIS_PROVIDER,
|
||||
);
|
||||
return {
|
||||
...(diagnosis.hopperPath === undefined
|
||||
? {}
|
||||
@@ -175,15 +190,14 @@ export const systemSetupHost = (
|
||||
diagnosis.hopperPath === environment.HOPPER_LAUNCHER_PATH
|
||||
? { hopperLauncherConfigured: true }
|
||||
: {}),
|
||||
providerEnvironment: {
|
||||
...providerRegistrationEnvironment(
|
||||
diagnosis.providerInspections ?? [],
|
||||
),
|
||||
...doctorHost.providerRegistrationEnvironment?.(),
|
||||
...(diagnosis.hopperPath === undefined
|
||||
? {}
|
||||
: { HOPPER_LAUNCHER_PATH: diagnosis.hopperPath }),
|
||||
},
|
||||
providerConfigured:
|
||||
diagnosis.hopperPath !== undefined ||
|
||||
diagnosis.providerInspections?.some(
|
||||
({ configured }) => configured,
|
||||
) === true ||
|
||||
providerEnvironment.REA_JEB_MCP_URL !== undefined ||
|
||||
(requestedProvider.success && requestedProvider.data !== "auto"),
|
||||
providerEnvironment,
|
||||
doctor: diagnosis,
|
||||
};
|
||||
},
|
||||
@@ -230,7 +244,10 @@ export const systemSetupHost = (
|
||||
detectedClients: () => detectClients(homeDirectory, platform, environment),
|
||||
supportedClients: () =>
|
||||
Promise.resolve(supportedClients(homeDirectory, platform, environment)),
|
||||
...clientConfigurationOperations(resources),
|
||||
...clientConfigurationOperations(
|
||||
resources,
|
||||
doctorHost.resolveProviderRequestBudget,
|
||||
),
|
||||
skillNeedsInstall: (clientIds) =>
|
||||
canonicalSkillNeedsInstall(
|
||||
homeDirectory,
|
||||
@@ -274,7 +291,10 @@ const setupInstallerDiagnostic = (
|
||||
: `${normalized.slice(0, 397).trimEnd()}...`;
|
||||
};
|
||||
|
||||
const clientConfigurationOperations = (resources: ArtifactResourceScope) => ({
|
||||
const clientConfigurationOperations = (
|
||||
resources: ArtifactResourceScope,
|
||||
resolveProviderRequestBudget: DoctorHost["resolveProviderRequestBudget"],
|
||||
) => ({
|
||||
configureClient: (
|
||||
client: SetupClient,
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
@@ -283,7 +303,12 @@ const clientConfigurationOperations = (resources: ArtifactResourceScope) => ({
|
||||
runClientConfigurationOperation(resources, async () =>
|
||||
client.format === "unsupported"
|
||||
? { status: "skipped" as const }
|
||||
: configureClientConfiguration(client, providerEnvironment, command),
|
||||
: configureClientConfiguration(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
),
|
||||
clientNeedsConfigure: (
|
||||
client: SetupClient,
|
||||
@@ -297,6 +322,7 @@ const clientConfigurationOperations = (resources: ArtifactResourceScope) => ({
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
resolveProviderRequestBudget,
|
||||
)),
|
||||
),
|
||||
inspectClientConfiguration: (
|
||||
@@ -305,7 +331,12 @@ const clientConfigurationOperations = (resources: ArtifactResourceScope) => ({
|
||||
command: readonly string[],
|
||||
) =>
|
||||
runClientConfigurationOperation(resources, () =>
|
||||
inspectClientConfiguration(client, providerEnvironment, command),
|
||||
inspectClientConfiguration(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export interface SetupDiscovery {
|
||||
/** Discover supported clients and current registrations without mutation. */
|
||||
export const discoverSetupState = async (input: {
|
||||
readonly host: SetupHost;
|
||||
readonly providerEnvironment: SetupProviderEnvironment;
|
||||
readonly providerConfigured: boolean;
|
||||
readonly forceHopperInstall: boolean;
|
||||
readonly hopperLauncherConfigured?: boolean;
|
||||
readonly proposeHopper: boolean;
|
||||
@@ -65,8 +65,7 @@ export const discoverSetupState = async (input: {
|
||||
input.forceHopperInstall ||
|
||||
(hopperInstallSupported &&
|
||||
input.proposeHopper &&
|
||||
(Object.keys(input.providerEnvironment).length === 0 ||
|
||||
linuxHopperRepairNeeded));
|
||||
(!input.providerConfigured || linuxHopperRepairNeeded));
|
||||
const [clients, detectedClients] = await Promise.all([
|
||||
input.host.supportedClients(),
|
||||
input.host.detectedClients(),
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import type { DoctorProviderInspection } from "./Doctor.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
const SELECTED_REGISTRATION_SETTINGS = [
|
||||
"REA_ANALYSIS_PROVIDER",
|
||||
"REA_GHIDRA_STARTUP_TIMEOUT_MS",
|
||||
"REA_HOPPER_STARTUP_TIMEOUT_MS",
|
||||
"REA_JEB_MCP_TIMEOUT_MS",
|
||||
"REA_MCP_INPUT_SCHEMA_PROFILE",
|
||||
] as const;
|
||||
|
||||
/** Preserve selected setup settings without copying unrelated process values. */
|
||||
export const explicitSetupRegistrationEnvironment = (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): Readonly<Record<string, string>> =>
|
||||
Object.fromEntries(
|
||||
SELECTED_REGISTRATION_SETTINGS.flatMap((key) => {
|
||||
const value = environment[key];
|
||||
return value === undefined ? [] : [[key, value]];
|
||||
}),
|
||||
);
|
||||
|
||||
/** Preserve adapter-published settings independently of runtime readiness. */
|
||||
export const providerRegistrationEnvironment = (
|
||||
inspections: readonly DoctorProviderInspection[],
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface SetupInitialState {
|
||||
readonly hopperPath?: string;
|
||||
/** Whether the caller explicitly selected a Hopper launcher in its environment. */
|
||||
readonly hopperLauncherConfigured?: boolean;
|
||||
/** Whether the caller or host has an analysis provider configuration. */
|
||||
readonly providerConfigured: boolean;
|
||||
readonly providerEnvironment: SetupProviderEnvironment;
|
||||
readonly doctor: DoctorReport;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type {
|
||||
AnalysisOperation,
|
||||
ProviderIdentity,
|
||||
} from "./AnalysisProvider.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** One provider-owned caveat tied to the operation that reported it. */
|
||||
export interface SourceLimitation {
|
||||
readonly provider: ProviderIdentity;
|
||||
readonly operation: AnalysisOperation;
|
||||
readonly limitation: string;
|
||||
}
|
||||
|
||||
/** Stable exact-provenance key for deduplication before rendering. */
|
||||
export const sourceLimitationKey = ({
|
||||
provider,
|
||||
operation,
|
||||
limitation,
|
||||
}: SourceLimitation): string =>
|
||||
JSON.stringify([
|
||||
provider.id,
|
||||
provider.name,
|
||||
provider.version,
|
||||
operation,
|
||||
limitation,
|
||||
]);
|
||||
|
||||
/** Render unique source caveats without losing their provider or operation. */
|
||||
export const renderSourceLimitations = (
|
||||
limitations: readonly SourceLimitation[],
|
||||
): string[] => {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
readonly provider: ProviderIdentity;
|
||||
readonly limitation: string;
|
||||
readonly operations: Set<AnalysisOperation>;
|
||||
}
|
||||
>();
|
||||
for (const { provider, operation, limitation } of limitations) {
|
||||
const key = JSON.stringify([
|
||||
provider.id,
|
||||
provider.name,
|
||||
provider.version,
|
||||
limitation,
|
||||
]);
|
||||
const group = groups.get(key) ?? {
|
||||
provider,
|
||||
limitation,
|
||||
operations: new Set<AnalysisOperation>(),
|
||||
};
|
||||
group.operations.add(operation);
|
||||
groups.set(key, group);
|
||||
}
|
||||
return [...groups.values()]
|
||||
.map(({ provider, limitation, operations }) => {
|
||||
const version = provider.version === null ? "" : ` ${provider.version}`;
|
||||
const operationList = [...operations].sort(compareUnicodeCodePoints);
|
||||
return `${provider.name} (${provider.id}${version}) ${operationList.join(", ")}: ${limitation}`;
|
||||
})
|
||||
.sort(compareUnicodeCodePoints);
|
||||
};
|
||||
@@ -95,16 +95,18 @@ it("retains unique upstream limits in canonical order across concurrent observat
|
||||
let calls = 0;
|
||||
const options = { signal: new AbortController().signal };
|
||||
const analysis: AnalysisOperationPort = {
|
||||
execute: async (_, parameters, forwarded) => {
|
||||
execute: async (operation, parameters, forwarded) => {
|
||||
expect(parameters).toEqual({ marker: true });
|
||||
expect(forwarded).toBe(options);
|
||||
const call = calls++;
|
||||
if (call === 0) await gate;
|
||||
else release();
|
||||
const limitation =
|
||||
operation === "list_strings"
|
||||
? ["first", "shared"]
|
||||
: ["second", "shared"];
|
||||
return ok(
|
||||
createAnalysisExecution([], provider, {
|
||||
limitations: call === 0 ? ["first", "shared"] : ["second", "shared"],
|
||||
}),
|
||||
createAnalysisExecution([], provider, { limitations: limitation }),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -117,7 +119,48 @@ it("retains unique upstream limits in canonical order across concurrent observat
|
||||
});
|
||||
expect(calls).toBe(2);
|
||||
expect(result).toEqual(
|
||||
ok({ result: { count: 0 }, limitations: ["first", "second", "shared"] }),
|
||||
ok({
|
||||
result: { count: 0 },
|
||||
limitations: [
|
||||
"Fixture (fixture 1) list_procedures, list_strings: shared",
|
||||
"Fixture (fixture 1) list_procedures: second",
|
||||
"Fixture (fixture 1) list_strings: first",
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("deduplicates exact source caveats without merging providers or operations", async () => {
|
||||
const firstProvider = { id: "first", name: "First", version: "1" };
|
||||
const secondProvider = { id: "second", name: "Second", version: "1" };
|
||||
const result = await executeComposedWorkflow(
|
||||
{
|
||||
execute: async (operation, parameters) =>
|
||||
ok(
|
||||
createAnalysisExecution(
|
||||
null,
|
||||
parameters.provider === "second" ? secondProvider : firstProvider,
|
||||
{ limitations: ["same source caveat"] },
|
||||
),
|
||||
),
|
||||
},
|
||||
async (observed) => {
|
||||
await observed.execute("list_strings", { provider: "first" });
|
||||
await observed.execute("list_strings", { provider: "first" });
|
||||
await observed.execute("list_strings", { provider: "second" });
|
||||
await observed.execute("list_procedures", { provider: "first" });
|
||||
return ok({ count: 0 });
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual(
|
||||
ok({
|
||||
result: { count: 0 },
|
||||
limitations: [
|
||||
"First (first 1) list_procedures, list_strings: same source caveat",
|
||||
"Second (second 1) list_strings: same source caveat",
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,11 @@ import {
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { UnknownRegistryPort } from "./investigation/InvestigationRecordPort.js";
|
||||
import { UnknownRegistryError } from "../domain/unknownRegistryError.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import {
|
||||
renderSourceLimitations,
|
||||
sourceLimitationKey,
|
||||
type SourceLimitation,
|
||||
} from "./SourceLimitations.js";
|
||||
|
||||
/** Retain the limits of exactly the observations used by one composed call. */
|
||||
export const executeComposedWorkflow = async (
|
||||
@@ -37,21 +41,26 @@ export const executeComposedWorkflow = async (
|
||||
AnalysisError
|
||||
>
|
||||
> => {
|
||||
const limitations = new Set<string>();
|
||||
const limitations = new Map<string, SourceLimitation>();
|
||||
const result = await execute({
|
||||
execute: async (operation, parameters, options) => {
|
||||
const execution = await analysis.execute(operation, parameters, options);
|
||||
if (execution.ok)
|
||||
execution.value.limitations.forEach((limitation) =>
|
||||
limitations.add(limitation),
|
||||
);
|
||||
for (const limitation of execution.value.limitations) {
|
||||
const fact = {
|
||||
provider: execution.value.provider,
|
||||
operation,
|
||||
limitation,
|
||||
} satisfies SourceLimitation;
|
||||
limitations.set(sourceLimitationKey(fact), fact);
|
||||
}
|
||||
return execution;
|
||||
},
|
||||
});
|
||||
if (!result.ok) return result;
|
||||
return ok({
|
||||
result: result.value,
|
||||
limitations: [...limitations].sort(compareUnicodeCodePoints),
|
||||
limitations: renderSourceLimitations([...limitations.values()]),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -257,6 +257,56 @@ describe("native UI action trace", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
it("carries source-owned reference limitations into trace output without changing coverage", async () => {
|
||||
const limitation = "SEH handler targets may be outside the function body.";
|
||||
const run = async (limitations: readonly string[]) => {
|
||||
const result = await new EnhancedTools(
|
||||
testAnalysis({
|
||||
procedure_address: () => execution("procedure_address", "0x1000"),
|
||||
procedure_references: () =>
|
||||
ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
reference_kinds_available: false,
|
||||
references: [],
|
||||
unresolved_calls: [],
|
||||
},
|
||||
{ id: "ghidra", name: "Ghidra", version: "fixture" },
|
||||
{ limitations },
|
||||
),
|
||||
),
|
||||
procedure_callees: (parameters) =>
|
||||
execution(
|
||||
"procedure_callees",
|
||||
parameters.procedure === "0x1000" ? ["0x2000"] : [],
|
||||
),
|
||||
}),
|
||||
).execute("trace_native_ui_action", { action: "0x1000" });
|
||||
if (!result.ok) throw result.error;
|
||||
return nativeInvestigationTraceSchema.parse(result.value);
|
||||
};
|
||||
|
||||
const withSourceLimitation = await run([limitation]);
|
||||
const withoutSourceLimitation = await run([]);
|
||||
|
||||
expect(withSourceLimitation.limitations).toContain(
|
||||
`Ghidra (ghidra fixture) procedure_references: ${limitation}`,
|
||||
);
|
||||
expect(
|
||||
withSourceLimitation.limitations.filter((entry) =>
|
||||
entry.endsWith(`procedure_references: ${limitation}`),
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(withSourceLimitation.coverage).toEqual(
|
||||
withoutSourceLimitation.coverage,
|
||||
);
|
||||
expect(withSourceLimitation.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "static_native_calls",
|
||||
status: "partial",
|
||||
}),
|
||||
);
|
||||
});
|
||||
it("accepts an explicit native seed without an authored UI connection", async () => {
|
||||
const result = await new EnhancedTools(
|
||||
testAnalysis({
|
||||
|
||||
@@ -34,6 +34,12 @@ describe("untyped native call route fallback", () => {
|
||||
operation === "procedure_references"
|
||||
? referenceProvider
|
||||
: fallbackProvider,
|
||||
{
|
||||
limitations:
|
||||
operation === "procedure_references"
|
||||
? ["Reference-kind metadata is incomplete."]
|
||||
: ["Callee enumeration is limited to named functions."],
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -54,6 +60,18 @@ describe("untyped native call route fallback", () => {
|
||||
resolution: "inferred",
|
||||
})),
|
||||
unknowns: [{ ...unknown, provider: referenceProvider }],
|
||||
limitations: [
|
||||
{
|
||||
provider: referenceProvider,
|
||||
operation: "procedure_references",
|
||||
limitation: "Reference-kind metadata is incomplete.",
|
||||
},
|
||||
{
|
||||
provider: fallbackProvider,
|
||||
operation: "procedure_callees",
|
||||
limitation: "Callee enumeration is limited to named functions.",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -105,6 +123,7 @@ describe("typed native call routes", () => {
|
||||
],
|
||||
},
|
||||
provider,
|
||||
{ limitations: ["SEH handler targets may be outside the body."] },
|
||||
),
|
||||
),
|
||||
},
|
||||
@@ -126,6 +145,13 @@ describe("typed native call routes", () => {
|
||||
},
|
||||
],
|
||||
unknowns: [{ address: "0x1008" }],
|
||||
limitations: [
|
||||
{
|
||||
provider,
|
||||
operation: "procedure_references",
|
||||
limitation: "SEH handler targets may be outside the body.",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -146,12 +172,26 @@ describe("typed native call routes", () => {
|
||||
});
|
||||
expect(calls).toBe(1);
|
||||
const malformed = await readNativeCallRoutes(
|
||||
{ execute: async () => ok(createAnalysisExecution({}, provider)) },
|
||||
{
|
||||
execute: async () =>
|
||||
ok(
|
||||
createAnalysisExecution({}, provider, {
|
||||
limitations: ["Malformed output still has source caveats."],
|
||||
}),
|
||||
),
|
||||
},
|
||||
"0x1000",
|
||||
);
|
||||
expect(malformed).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
limitations: [
|
||||
{
|
||||
provider,
|
||||
operation: "procedure_references",
|
||||
limitation: "Malformed output still has source caveats.",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
it("rejects omitted unresolved-call observations instead of claiming none", async () => {
|
||||
|
||||
@@ -9,6 +9,14 @@ import {
|
||||
referenceKindSchema,
|
||||
} from "../../domain/hopperValues.js";
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
import type { SourceLimitation } from "../SourceLimitations.js";
|
||||
|
||||
const limitationsFrom = (
|
||||
operation: SourceLimitation["operation"],
|
||||
provider: ProviderIdentity,
|
||||
limitations: readonly string[],
|
||||
): SourceLimitation[] =>
|
||||
limitations.map((limitation) => ({ provider, operation, limitation }));
|
||||
|
||||
const referencesSchema = z.object({
|
||||
reference_kinds_available: z.boolean(),
|
||||
@@ -24,6 +32,33 @@ const referencesSchema = z.object({
|
||||
z.object({ address: z.string(), reason: z.string() }),
|
||||
),
|
||||
});
|
||||
|
||||
const typedCallRoutes = (
|
||||
references: z.infer<typeof referencesSchema>["references"],
|
||||
) => {
|
||||
const calls = references.filter(({ kind }) => kind.available && kind.call);
|
||||
const countBySite = new Map<string, Set<string>>();
|
||||
for (const call of calls) {
|
||||
const targets = countBySite.get(call.source_address) ?? new Set<string>();
|
||||
targets.add(call.target_address);
|
||||
countBySite.set(call.source_address, targets);
|
||||
}
|
||||
return calls.map((call) => ({
|
||||
address: call.target_procedure?.address ?? call.target_address,
|
||||
site: call.source_address,
|
||||
relation:
|
||||
call.kind.available && (call.kind.computed || call.kind.indirect)
|
||||
? ("indirect_call" as const)
|
||||
: ("direct_call" as const),
|
||||
resolution:
|
||||
(countBySite.get(call.source_address)?.size ?? 0) > 1
|
||||
? ("ambiguous" as const)
|
||||
: call.kind.available && (call.kind.computed || call.kind.indirect)
|
||||
? ("resolved" as const)
|
||||
: ("observed" as const),
|
||||
}));
|
||||
};
|
||||
|
||||
/** Prefer typed static call references, preserving computed targets and targetless sites. */
|
||||
export const readNativeCallRoutes = async (
|
||||
analysis: AnalysisOperationPort,
|
||||
@@ -45,7 +80,13 @@ export const readNativeCallRoutes = async (
|
||||
reason: string;
|
||||
provider: ProviderIdentity;
|
||||
}> = [];
|
||||
let limitations: SourceLimitation[] = [];
|
||||
if (execution.ok) {
|
||||
limitations = limitationsFrom(
|
||||
"procedure_references",
|
||||
execution.value.provider,
|
||||
execution.value.limitations,
|
||||
);
|
||||
const parsed = referencesSchema.safeParse(execution.value.result);
|
||||
if (!parsed.success)
|
||||
return {
|
||||
@@ -55,6 +96,7 @@ export const readNativeCallRoutes = async (
|
||||
"Invalid typed call references",
|
||||
),
|
||||
unknowns,
|
||||
limitations,
|
||||
};
|
||||
// Missing detailed reference flags do not invalidate observed targetless
|
||||
// calls. Keep their producing provider even if another provider supplies
|
||||
@@ -68,48 +110,38 @@ export const readNativeCallRoutes = async (
|
||||
parsed.data.reference_kinds_available === true ||
|
||||
(references.length > 0 && references.every(({ kind }) => kind.available))
|
||||
) {
|
||||
const calls = references.filter(
|
||||
({ kind }) => kind.available && kind.call,
|
||||
);
|
||||
const countBySite = new Map<string, Set<string>>();
|
||||
for (const call of calls) {
|
||||
const targets =
|
||||
countBySite.get(call.source_address) ?? new Set<string>();
|
||||
targets.add(call.target_address);
|
||||
countBySite.set(call.source_address, targets);
|
||||
}
|
||||
return {
|
||||
ok: true as const,
|
||||
value: {
|
||||
provider: execution.value.provider,
|
||||
routes: calls.map((call) => ({
|
||||
address: call.target_procedure?.address ?? call.target_address,
|
||||
site: call.source_address,
|
||||
relation:
|
||||
call.kind.available && (call.kind.computed || call.kind.indirect)
|
||||
? ("indirect_call" as const)
|
||||
: ("direct_call" as const),
|
||||
resolution:
|
||||
(countBySite.get(call.source_address)?.size ?? 0) > 1
|
||||
? ("ambiguous" as const)
|
||||
: call.kind.available &&
|
||||
(call.kind.computed || call.kind.indirect)
|
||||
? ("resolved" as const)
|
||||
: ("observed" as const),
|
||||
})),
|
||||
routes: typedCallRoutes(references),
|
||||
unknowns,
|
||||
limitations,
|
||||
},
|
||||
};
|
||||
}
|
||||
} else if (execution.error._tag !== "AnalysisCapabilityUnavailableError")
|
||||
return { ok: false as const, error: execution.error, unknowns };
|
||||
return {
|
||||
ok: false as const,
|
||||
error: execution.error,
|
||||
unknowns,
|
||||
limitations,
|
||||
};
|
||||
const callees = await analysis.execute(
|
||||
"procedure_callees",
|
||||
{ procedure },
|
||||
options,
|
||||
);
|
||||
if (!callees.ok)
|
||||
return { ok: false as const, error: callees.error, unknowns };
|
||||
return { ok: false as const, error: callees.error, unknowns, limitations };
|
||||
limitations = [
|
||||
...limitations,
|
||||
...limitationsFrom(
|
||||
"procedure_callees",
|
||||
callees.value.provider,
|
||||
callees.value.limitations,
|
||||
),
|
||||
];
|
||||
const parsed = parseRelatedAddresses(callees.value.result, "callees");
|
||||
return parsed.ok
|
||||
? {
|
||||
@@ -123,7 +155,8 @@ export const readNativeCallRoutes = async (
|
||||
resolution: "inferred" as const,
|
||||
})),
|
||||
unknowns,
|
||||
limitations,
|
||||
},
|
||||
}
|
||||
: { ok: false as const, error: parsed.error, unknowns };
|
||||
: { ok: false as const, error: parsed.error, unknowns, limitations };
|
||||
};
|
||||
@@ -14,6 +14,10 @@ import { nativeDispatchMetadataResultSchema } from "../../domain/native/objcSwif
|
||||
import { inspectNativeDispatch } from "./NativeDispatchMetadataInspection.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import { readNativeCallRoutes } from "./NativeCallRoutes.js";
|
||||
import {
|
||||
renderSourceLimitations,
|
||||
type SourceLimitation,
|
||||
} from "../SourceLimitations.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
@@ -244,6 +248,10 @@ export const traceNativeUiAction = async (
|
||||
: graph.truncated && trace.reason === null
|
||||
? "native_trace_output_bound_reached"
|
||||
: trace.reason,
|
||||
limitations: [
|
||||
...trace.limitations,
|
||||
...renderSourceLimitations(expanded.limitations),
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -398,6 +406,7 @@ const addDirectCallees = async (input: {
|
||||
let examined = 0;
|
||||
let omitted = 0;
|
||||
let truncated = false;
|
||||
const sourceLimitations: SourceLimitation[] = [];
|
||||
while (queueIndex < queue.length) {
|
||||
const current = queue[queueIndex++];
|
||||
if (current === undefined || visited.has(current.address)) continue;
|
||||
@@ -419,6 +428,9 @@ const addDirectCallees = async (input: {
|
||||
"provider",
|
||||
);
|
||||
examined += 1;
|
||||
sourceLimitations.push(
|
||||
...(execution.ok ? execution.value.limitations : execution.limitations),
|
||||
);
|
||||
if (!execution.ok && execution.error._tag === "AnalysisCancelledError")
|
||||
return { error: execution.error };
|
||||
const unknowns = execution.ok
|
||||
@@ -539,6 +551,7 @@ const addDirectCallees = async (input: {
|
||||
examined,
|
||||
omitted,
|
||||
truncated,
|
||||
limitations: sourceLimitations,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -291,31 +291,32 @@ const nativeFlowDossier = (
|
||||
});
|
||||
};
|
||||
|
||||
const callBindingAnalysis = (
|
||||
callerAddress: string,
|
||||
calleeAddress: string,
|
||||
caller: ReturnType<typeof jsonValueSchema.parse>,
|
||||
callee: ReturnType<typeof jsonValueSchema.parse>,
|
||||
): AnalysisOperationPort => ({
|
||||
const callBindingAnalysis = (input: {
|
||||
readonly callerAddress: string;
|
||||
readonly calleeAddress: string;
|
||||
readonly caller: ReturnType<typeof jsonValueSchema.parse>;
|
||||
readonly callee: ReturnType<typeof jsonValueSchema.parse>;
|
||||
readonly targetLimitations?: readonly string[];
|
||||
}): AnalysisOperationPort => ({
|
||||
execute: async (operation, parameters) => {
|
||||
if (operation === "resolve_native_call_targets")
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
call_site: String(parameters.address),
|
||||
procedure: calleeAddress,
|
||||
procedure: input.calleeAddress,
|
||||
status: "direct",
|
||||
mechanism: "direct",
|
||||
targets: [
|
||||
{
|
||||
address: calleeAddress,
|
||||
procedure: calleeAddress,
|
||||
address: input.calleeAddress,
|
||||
procedure: input.calleeAddress,
|
||||
status: "direct",
|
||||
basis: "provider-reference",
|
||||
references: [],
|
||||
},
|
||||
],
|
||||
limitations: [],
|
||||
limitations: [...(input.targetLimitations ?? [])],
|
||||
},
|
||||
provider,
|
||||
{ subject },
|
||||
@@ -324,13 +325,15 @@ const callBindingAnalysis = (
|
||||
if (operation !== "analyze_function")
|
||||
throw new Error(`Unexpected native trace operation: ${operation}`);
|
||||
if (
|
||||
parameters.procedure !== callerAddress &&
|
||||
parameters.procedure !== calleeAddress
|
||||
parameters.procedure !== input.callerAddress &&
|
||||
parameters.procedure !== input.calleeAddress
|
||||
)
|
||||
throw new Error(`Unexpected procedure: ${String(parameters.procedure)}`);
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
parameters.procedure === callerAddress ? caller : callee,
|
||||
parameters.procedure === input.callerAddress
|
||||
? input.caller
|
||||
: input.callee,
|
||||
provider,
|
||||
{ subject },
|
||||
),
|
||||
@@ -430,6 +433,40 @@ describe("native value failed-callee traversal", () => {
|
||||
});
|
||||
|
||||
describe("native value call-binding fan-out", () => {
|
||||
it("retains limitations embedded in resolved call-target results", async () => {
|
||||
const callerAddress = "0x1000";
|
||||
const calleeAddress = "0x2000";
|
||||
const base = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
const caller = nativeFlowDossier(base, {
|
||||
address: callerAddress,
|
||||
name: "caller",
|
||||
operations: [node("call", callerAddress, "CALL")],
|
||||
parameters: [],
|
||||
});
|
||||
const callee = nativeFlowDossier(base, {
|
||||
address: calleeAddress,
|
||||
name: "callee",
|
||||
operations: [],
|
||||
parameters: [],
|
||||
});
|
||||
const caveat = "Only statically resolved call sites are included.";
|
||||
const result = await traceNativeValues(
|
||||
callBindingAnalysis({
|
||||
callerAddress,
|
||||
calleeAddress,
|
||||
caller,
|
||||
callee,
|
||||
targetLimitations: [caveat],
|
||||
}),
|
||||
{ procedure: callerAddress, max_functions: 2 },
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(nativeValueTraceSchema.parse(result.value).limitations).toContain(
|
||||
`Fixture (fixture 1) resolve_native_call_targets: ${caveat}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves binding order and edge-budget behavior for repeated callees", async () => {
|
||||
const callCount = 128;
|
||||
const callerAddress = "0x1000";
|
||||
@@ -455,12 +492,12 @@ describe("native value call-binding fan-out", () => {
|
||||
operations: [node("return", calleeAddress, "RETURN")],
|
||||
parameters: [{ ordinal: 0, name: "value", data_type: "/int" }],
|
||||
});
|
||||
const fanoutAnalysis = callBindingAnalysis(
|
||||
const fanoutAnalysis = callBindingAnalysis({
|
||||
callerAddress,
|
||||
calleeAddress,
|
||||
caller,
|
||||
callee,
|
||||
);
|
||||
});
|
||||
const expectedEdges = [
|
||||
...Array.from({ length: callCount }, (_, index) => ({
|
||||
source: `${callerAddress}/call-${index}`,
|
||||
@@ -803,7 +840,12 @@ describe("native value byte-bound call bindings", () => {
|
||||
],
|
||||
});
|
||||
const result = await traceNativeValues(
|
||||
callBindingAnalysis("0x1000", "0x2000", caller, callee),
|
||||
callBindingAnalysis({
|
||||
callerAddress: "0x1000",
|
||||
calleeAddress: "0x2000",
|
||||
caller,
|
||||
callee,
|
||||
}),
|
||||
{ procedure: "0x1000" },
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
|
||||
@@ -13,6 +13,10 @@ import { err, ok } from "../../domain/result.js";
|
||||
import type { AnalysisOperationPort } from "../AnalysisProvider.js";
|
||||
import type { EnhancedResult } from "../EnhancedToolTypes.js";
|
||||
import { jsonParts } from "../../domain/jsonSerialization.js";
|
||||
import {
|
||||
renderSourceLimitations,
|
||||
type SourceLimitation,
|
||||
} from "../SourceLimitations.js";
|
||||
|
||||
const NODE_PAYLOAD_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
@@ -27,6 +31,7 @@ export const traceNativeValues = async (
|
||||
const edges: typeof nativeValueTraceSchema._output.edges = [];
|
||||
const procedures: typeof nativeValueTraceSchema._output.procedures = [];
|
||||
const unknowns: typeof nativeValueTraceSchema._output.unknowns = [];
|
||||
const sourceLimitations: SourceLimitation[] = [];
|
||||
const callBindings: {
|
||||
caller: string;
|
||||
callee: string;
|
||||
@@ -280,6 +285,13 @@ export const traceNativeValues = async (
|
||||
"Invalid static call resolution",
|
||||
),
|
||||
);
|
||||
sourceLimitations.push(
|
||||
...targets.data.limitations.map((limitation) => ({
|
||||
provider: resolved.value.provider,
|
||||
operation: "resolve_native_call_targets" as const,
|
||||
limitation,
|
||||
})),
|
||||
);
|
||||
if (targets.data.targets.length === 0)
|
||||
unknowns.push({
|
||||
procedure: address,
|
||||
@@ -424,6 +436,7 @@ export const traceNativeValues = async (
|
||||
"Static decompiler def-use graphs cover recovered block members; when membership is unavailable they exclude operations marked dead. Logical CALL argument positions bind to recovered HighParam ordinals and RETURN values bind to recovered CALL outputs; these are decompiler-derived dependencies, not original source or runtime observations. Missing parameters, variadic arguments and alias dependencies remain unknown.",
|
||||
"LOAD and STORE are memory operations; object fields, persistent state and RNG semantics are not inferred from names or pseudocode.",
|
||||
"Traversal caps depth, decompilations, call-site resolutions, nodes, edges and node payloads (8 MiB). Complete serialized output is bounded to 32 MiB. Pagination reruns analysis and returns edges whose source is on the node page; endpoints outside the page retain stable identities.",
|
||||
...renderSourceLimitations(sourceLimitations),
|
||||
],
|
||||
});
|
||||
if (Buffer.byteLength(JSON.stringify(result)) > 32 * 1024 * 1024)
|
||||
|
||||
+32
-22
@@ -1,8 +1,9 @@
|
||||
import type { JebAnalysisPort } from "../application/jeb/JebAnalysisPort.js";
|
||||
import { parseJebMcpTimeoutMs } from "../config/environment.js";
|
||||
import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import { err } from "../domain/result.js";
|
||||
import type { JebMcpConnectionFactory } from "../jeb/JebMcpConnection.js";
|
||||
import { parseJebMcpEndpoint } from "../jeb/JebMcpEndpoint.js";
|
||||
import { JebProvider } from "../jeb/JebProvider.js";
|
||||
|
||||
/**
|
||||
@@ -14,35 +15,44 @@ export const createJebAnalysisProvider = (
|
||||
environment: Readonly<Record<string, string | undefined>> = process.env,
|
||||
connectionFactory?: JebMcpConnectionFactory,
|
||||
): JebAnalysisPort => {
|
||||
const endpoint = parseJebMcpEndpoint(environment.REA_JEB_MCP_URL);
|
||||
if (!endpoint.ok) {
|
||||
const requestTimeout = parseJebMcpTimeoutMs(
|
||||
environment.REA_JEB_MCP_TIMEOUT_MS,
|
||||
);
|
||||
if (!requestTimeout.ok) return unconfiguredJebProvider(requestTimeout.error);
|
||||
try {
|
||||
return new JebProvider({
|
||||
environment,
|
||||
...(requestTimeout.value === undefined
|
||||
? {}
|
||||
: { requestTimeoutMs: requestTimeout.value }),
|
||||
...(connectionFactory === undefined ? {} : { connectionFactory }),
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (!(cause instanceof AnalysisInputError)) throw cause;
|
||||
const constraint =
|
||||
typeof endpoint.error.cause === "string"
|
||||
? endpoint.error.cause
|
||||
: endpoint.error.message;
|
||||
typeof cause.cause === "string" ? cause.cause : cause.message;
|
||||
return unconfiguredJebProvider(
|
||||
new ConfigurationError("Invalid REA environment configuration", {
|
||||
cause: endpoint.error,
|
||||
cause,
|
||||
settings: [{ setting: "REA_JEB_MCP_URL", constraint }],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return new JebProvider({
|
||||
environment,
|
||||
...(connectionFactory === undefined ? {} : { connectionFactory }),
|
||||
});
|
||||
};
|
||||
|
||||
const unconfiguredJebProvider = (
|
||||
error: ConfigurationError,
|
||||
): JebAnalysisPort => ({
|
||||
inspectAvailability: () =>
|
||||
Promise.resolve({
|
||||
status: "unavailable",
|
||||
code: "not_configured",
|
||||
reason: `REA_JEB_MCP_URL is invalid: ${error.settings[0]?.constraint ?? error.message}`,
|
||||
diagnostics: { setting: "REA_JEB_MCP_URL" },
|
||||
}),
|
||||
close: () => Promise.resolve(),
|
||||
execute: () => Promise.resolve(err(error)),
|
||||
});
|
||||
): JebAnalysisPort => {
|
||||
const setting = error.settings[0]?.setting ?? "REA_JEB_MCP_URL";
|
||||
return {
|
||||
inspectAvailability: () =>
|
||||
Promise.resolve({
|
||||
status: "unavailable",
|
||||
code: "not_configured",
|
||||
reason: `${setting} is invalid: ${error.settings[0]?.constraint ?? error.message}`,
|
||||
diagnostics: { setting },
|
||||
}),
|
||||
close: () => Promise.resolve(),
|
||||
execute: () => Promise.resolve(err(error)),
|
||||
};
|
||||
};
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
defaultHopperLauncherPath,
|
||||
parseConfig,
|
||||
} from "./config/parseConfig.js";
|
||||
import {
|
||||
parseEnvironmentValues,
|
||||
parseJebMcpTimeoutMs,
|
||||
} from "./config/environment.js";
|
||||
import { projectAnalysisError } from "./domain/analysisErrorProjection.js";
|
||||
|
||||
it("reports a configuration failure for a NUL in Hopper loader arguments", () => {
|
||||
@@ -328,6 +332,40 @@ describe("configuration failure diagnostics", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("JEB MCP timeout configuration", () => {
|
||||
it("stays optional and validates the caller-selected environment value", () => {
|
||||
expect(parseJebMcpTimeoutMs(undefined)).toEqual({
|
||||
ok: true,
|
||||
value: undefined,
|
||||
});
|
||||
expect(parseJebMcpTimeoutMs("900000")).toEqual({
|
||||
ok: true,
|
||||
value: 900_000,
|
||||
});
|
||||
expect(
|
||||
parseEnvironmentValues({ REA_JEB_MCP_TIMEOUT_MS: "900000" }),
|
||||
).toMatchObject({ ok: true, value: { REA_JEB_MCP_TIMEOUT_MS: 900_000 } });
|
||||
});
|
||||
|
||||
it.each(["", "0", "-1", "1.5", "1e3", "invalid", "2147483648"])(
|
||||
"rejects malformed timeout %j with its setting",
|
||||
(value) => {
|
||||
const result = parseJebMcpTimeoutMs(value);
|
||||
if (result.ok) throw new Error("Expected invalid JEB MCP timeout");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
details: {
|
||||
settings: [
|
||||
{
|
||||
setting: "REA_JEB_MCP_TIMEOUT_MS",
|
||||
constraint: expect.any(String),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("default Linux Hopper launcher", () => {
|
||||
const home = "/fixture/home";
|
||||
const withExecutables =
|
||||
|
||||
@@ -30,6 +30,7 @@ const environmentSchema = z.object({
|
||||
.min(1)
|
||||
.refine(isAbsolute, "REA_IDA_MCP_CONFIG must be absolute")
|
||||
.optional(),
|
||||
REA_JEB_MCP_TIMEOUT_MS: providerStartupTimeoutSchema.optional(),
|
||||
GHIDRA_INSTALL_DIR: z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -77,6 +78,27 @@ const environmentSchema = z.object({
|
||||
REA_REFERENCE_SECRET_PATTERNS_JSON: z.string().default("[]"),
|
||||
});
|
||||
|
||||
/** Parse the JEB-only timeout at its isolated provider-composition boundary. */
|
||||
export const parseJebMcpTimeoutMs = (
|
||||
value: string | undefined,
|
||||
): Result<number | undefined, ConfigurationError> => {
|
||||
if (value === undefined) return ok(undefined);
|
||||
const parsed = providerStartupTimeoutSchema.safeParse(value);
|
||||
if (parsed.success) return ok(parsed.data);
|
||||
return err(
|
||||
new ConfigurationError("Invalid REA environment configuration", {
|
||||
cause: parsed.error,
|
||||
settings: [
|
||||
{
|
||||
setting: "REA_JEB_MCP_TIMEOUT_MS",
|
||||
constraint:
|
||||
parsed.error.issues[0]?.message ?? "must be a valid timeout",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
type Environment = Omit<
|
||||
z.infer<typeof environmentSchema>,
|
||||
"HOPPER_LOADER_ARGS_JSON" | "REA_REFERENCE_SECRET_PATTERNS_JSON"
|
||||
|
||||
@@ -21,7 +21,7 @@ export const compactPresentationForMcpInputSchemaProfile = (
|
||||
: undefined;
|
||||
|
||||
const profileConstraint =
|
||||
'REA_MCP_INPUT_SCHEMA_PROFILE must be "full" (default) or "compact". The compact profile advertises input schemas without nested annotation prose for providers that cap each tool schema\'s serialized size.';
|
||||
'REA_MCP_INPUT_SCHEMA_PROFILE must be "full" (default) or "compact". The compact profile advertises bounded input schemas with fewer nested annotations for providers with schema compatibility constraints.';
|
||||
|
||||
export type McpInputSchemaProfile = z.infer<typeof mcpInputSchemaProfileSchema>;
|
||||
|
||||
|
||||
@@ -69,12 +69,12 @@ export const exportEvidenceBundleInputSchema = z.strictObject({
|
||||
export const SESSION_TOOL_CONTRACTS = [
|
||||
session(
|
||||
"open_binary",
|
||||
"Open a local executable, application bundle, archive, JavaScript, source map, plist, or analysis database after validation. format=dos-com explicitly interprets 1..65280 headerless bytes as a DOS COM analysis image. format=raw-binary interprets headerless bytes with a required architecture and entry_point; file offset 0 maps at that address. Omission preserves header-based detection. provider_id selects one deep provider or deterministic auto selection; the binding remains stable until close or an explicit switch, with no failure fallback. Reopening the same target and profile retains its active database and metadata edits. An optional analysis snapshot is imported atomically and must match the binary identity, concrete provider, and canonical analysis profile exactly; importing into a session with metadata mutations requires closing and reopening it first.",
|
||||
"Open a local executable, application bundle, archive, JavaScript, source map, plist, or analysis database after validation. format=dos-com explicitly interprets 1..65280 headerless bytes as a DOS COM analysis image. format=raw-binary interprets headerless bytes with a required architecture and entry_point; file offset 0 maps at that address. Omission preserves header-based detection. provider_id selects one deep provider or deterministic auto selection; the binding remains stable until close or an explicit switch, with no failure fallback. Reopening the same target and profile retains its active database and metadata edits. An optional analysis snapshot is imported atomically and must match the binary identity, concrete provider, and canonical analysis profile exactly; importing into a session with metadata mutations requires closing and reopening it first. Call close_binary when the investigation is finished.",
|
||||
openBinaryInputSchema,
|
||||
),
|
||||
session(
|
||||
"close_binary",
|
||||
"Drain earlier provider requests, optionally write a provider-neutral analysis snapshot atomically to the caller-supplied path, then close the active target and every provider resource started for it. Later provider requests wait until this lifecycle operation finishes. Existing files require explicit overwrite; a failed save leaves the session open so cached analysis is not lost. Metadata edits prevent immutable snapshot saves until the session is recreated; export_evidence_bundle preserves mutable observations before closing without a snapshot.",
|
||||
"When the investigation is finished, drain earlier provider requests, optionally write a provider-neutral analysis snapshot atomically to the caller-supplied path, then close the active target and every provider resource started for it. Later provider requests wait until this lifecycle operation finishes. Existing files require explicit overwrite; a failed save leaves the session open so cached analysis is not lost. Metadata edits prevent immutable snapshot saves until the session is recreated; export_evidence_bundle preserves mutable observations before closing without a snapshot.",
|
||||
closeBinaryInputSchema,
|
||||
),
|
||||
session(
|
||||
|
||||
@@ -7,8 +7,10 @@ import { fileURLToPath } from "node:url";
|
||||
import { inspectJadxProvider } from "./android/JadxDoctor.js";
|
||||
import { inspectSystemGhidraProvider } from "./ghidra/GhidraDoctor.js";
|
||||
import { inspectIdaRegistration } from "./ida/IdaDoctor.js";
|
||||
import { readIdaConfiguration } from "./ida/IdaConfiguration.js";
|
||||
import { parseJebMcpEndpoint } from "./jeb/JebMcpEndpoint.js";
|
||||
import { selectLinuxPrivateDisplayStrategy } from "./hopper/LinuxPrivateDisplayProbe.js";
|
||||
import { ok } from "./domain/result.js";
|
||||
|
||||
/** Compose provider diagnostics at the outer CLI adapter boundary. */
|
||||
export const createSystemDoctorHost = (
|
||||
@@ -24,6 +26,21 @@ export const createSystemDoctorHost = (
|
||||
return systemDoctorHost({
|
||||
environment,
|
||||
providerRegistrationEnvironment: () => selectedJebRegistrationEnvironment,
|
||||
resolveProviderRequestBudget: (
|
||||
analysisProvider,
|
||||
registrationEnvironment,
|
||||
) => {
|
||||
if (analysisProvider !== "ida" && analysisProvider !== "auto")
|
||||
return ok(undefined);
|
||||
const path = registrationEnvironment.REA_IDA_MCP_CONFIG;
|
||||
if (path === undefined) return ok(undefined);
|
||||
const registration = readIdaConfiguration(path);
|
||||
if (registration.ok) return ok(registration.value.timeoutMs);
|
||||
// An invalid IDA registration cannot be selected automatically. Keep it
|
||||
// out of recommendations for another available provider, but surface the
|
||||
// parser-owned error when IDA itself was explicitly selected.
|
||||
return analysisProvider === "ida" ? registration : ok(undefined);
|
||||
},
|
||||
providerInspections: async () => [
|
||||
await inspectSystemGhidraProvider(environment),
|
||||
inspectIdaRegistration(environment.REA_IDA_MCP_CONFIG),
|
||||
|
||||
@@ -42,6 +42,9 @@ import {
|
||||
} from "../domain/native/nativeAotPe.js";
|
||||
|
||||
const callRelationshipCoverage = "function-body-call-coverage-v5";
|
||||
const functionBoundaryObservations = "ghidra-function-boundary-limitations-v4";
|
||||
const instructionReferenceCoverage =
|
||||
"exact-address-incoming-without-synthetic-entry-v2";
|
||||
|
||||
/**
|
||||
* The processor language REA pins for this target, or undefined when Ghidra may
|
||||
@@ -136,9 +139,8 @@ export const resolveGhidraAnalysisProfile = async (
|
||||
procedure_file_offset_resolution:
|
||||
"address-first-unique-target-source-mapping-v2",
|
||||
instruction_flow_evidence: "decoded-return-pcode-v1",
|
||||
instruction_references: "exact-address-incoming-v1",
|
||||
function_boundary_observations:
|
||||
"ghidra-function-boundary-limitations-v3",
|
||||
instruction_references: instructionReferenceCoverage,
|
||||
function_boundary_observations: functionBoundaryObservations,
|
||||
no_return_repair:
|
||||
installation.platform === "win32"
|
||||
? "disabled-windows-p0"
|
||||
@@ -236,9 +238,8 @@ export const resolveGhidraAnalysisProfile = async (
|
||||
procedure_file_offset_resolution:
|
||||
"address-first-unique-target-source-mapping-v2",
|
||||
instruction_flow_evidence: "decoded-return-pcode-v1",
|
||||
instruction_references: "exact-address-incoming-v1",
|
||||
function_boundary_observations:
|
||||
"ghidra-function-boundary-limitations-v3",
|
||||
instruction_references: instructionReferenceCoverage,
|
||||
function_boundary_observations: functionBoundaryObservations,
|
||||
no_return_repair:
|
||||
installation.platform === "win32"
|
||||
? "disabled-windows-p0"
|
||||
|
||||
@@ -27,6 +27,7 @@ const observationOperations: ReadonlySet<GhidraFunctionOperation> = new Set([
|
||||
"procedure_callers",
|
||||
"procedure_info",
|
||||
"procedure_pseudo_code",
|
||||
"procedure_references",
|
||||
"read_function_instructions",
|
||||
]);
|
||||
|
||||
@@ -240,6 +241,43 @@ describe("Ghidra relationship metadata validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves handler-region limitations on procedure references", () => {
|
||||
const limitation =
|
||||
"Ghidra Listing shows a Borland/Delphi-style SEH frame registration at 0x401008 (push 0x4010a0 followed by fs-segment stack chaining); handler 0x4010a0 lies outside this procedure's body, so callees, strings, and cross-references may omit the handler region.";
|
||||
const parsed = parseGhidraFunctionResult("procedure_references", {
|
||||
value: {
|
||||
procedure: ghidraFunctionIdentity(),
|
||||
direction: "outgoing",
|
||||
references: [ghidraReferenceEdge()],
|
||||
reference_kinds_available: true,
|
||||
unresolved_calls: [],
|
||||
},
|
||||
limitations: [limitation],
|
||||
});
|
||||
expect(parsed).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
value: {
|
||||
procedure: ghidraFunctionIdentity(),
|
||||
direction: "outgoing",
|
||||
references: [ghidraReferenceEdge()],
|
||||
reference_kinds_available: true,
|
||||
unresolved_calls: [],
|
||||
},
|
||||
limitations: [limitation],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
parseGhidraFunctionResult("procedure_references", {
|
||||
procedure: ghidraFunctionIdentity(),
|
||||
direction: "outgoing",
|
||||
references: [],
|
||||
reference_kinds_available: true,
|
||||
unresolved_calls: [],
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
|
||||
});
|
||||
|
||||
it.each(["procedure_callees", "procedure_callers"] as const)(
|
||||
"requires the function-observation envelope to carry terminal-call limitations for %s",
|
||||
(operation) => {
|
||||
|
||||
@@ -418,6 +418,7 @@ const functionBoundaryObservationOperations: ReadonlySet<GhidraFunctionOperation
|
||||
"procedure_callers",
|
||||
"procedure_info",
|
||||
"procedure_pseudo_code",
|
||||
"procedure_references",
|
||||
"read_function_instructions",
|
||||
]);
|
||||
|
||||
|
||||
@@ -190,9 +190,19 @@ describe("Ghidra relationship coverage replay", () => {
|
||||
|
||||
describe("Ghidra snapshot profile admission", () => {
|
||||
it.each([
|
||||
{
|
||||
parameter: "function_boundary_observations",
|
||||
current: "ghidra-function-boundary-limitations-v4",
|
||||
legacy: "ghidra-function-boundary-limitations-v3",
|
||||
},
|
||||
{
|
||||
parameter: "instruction_references",
|
||||
current: "exact-address-incoming-v1",
|
||||
current: "exact-address-incoming-without-synthetic-entry-v2",
|
||||
legacy: "exact-address-incoming-v1",
|
||||
},
|
||||
{
|
||||
parameter: "instruction_references",
|
||||
current: "exact-address-incoming-without-synthetic-entry-v2",
|
||||
legacy: undefined,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -61,6 +61,34 @@ it("sleeps the fixed retry delay between busy reads and returns the settled answ
|
||||
expect(sleeps, "two fixed 100 ms pauses").toEqual([100, 100]);
|
||||
});
|
||||
|
||||
it("returns an expired readiness timeout without starting a health read", async () => {
|
||||
const timeoutMs = 10;
|
||||
const { connection, reads } = scripted([ok]);
|
||||
let clockReads = 0;
|
||||
const clock = {
|
||||
now: () => (clockReads++ === 0 ? 0 : 11),
|
||||
delay: async () => {
|
||||
throw new Error("An expired wait must not schedule a pause");
|
||||
},
|
||||
};
|
||||
|
||||
const failure = await readSettledHealth(connection, {
|
||||
database: "db",
|
||||
timeoutMs,
|
||||
clock,
|
||||
}).catch((cause: unknown) => cause);
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "AnalysisTimeoutError",
|
||||
operation: "server_health",
|
||||
timeoutMs,
|
||||
});
|
||||
expect(
|
||||
reads,
|
||||
"an expired budget never becomes an SDK timeout of zero",
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("clamps the last pause to the remaining budget and stops without another read at expiry", async () => {
|
||||
const { connection, reads } = scripted([busy("config_json_get")]);
|
||||
const { clock, sleeps } = virtualClock();
|
||||
|
||||
@@ -29,6 +29,11 @@ const isBusyStatus = (raw: JsonValue): boolean =>
|
||||
!Array.isArray(raw) &&
|
||||
raw["status"] === "busy";
|
||||
|
||||
interface LastBusyHealth {
|
||||
readonly raw: JsonValue;
|
||||
readonly operation: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads `server_health` until the worker stops reporting a transient busy
|
||||
* state. The wait ends when the worker answers, the caller aborts, or the
|
||||
@@ -53,22 +58,62 @@ export const readSettledHealth = async (
|
||||
): Promise<JsonValue> => {
|
||||
const started = clock.now();
|
||||
const remaining = (): number => timeoutMs - (clock.now() - started);
|
||||
let lastBusy: LastBusyHealth | undefined;
|
||||
for (;;) {
|
||||
const raw = await connection.call("server_health", { database });
|
||||
const budgetMs = remaining();
|
||||
if (budgetMs <= 0)
|
||||
throw lastBusy === undefined
|
||||
? new AnalysisTimeoutError("server_health", timeoutMs)
|
||||
: busyTimeout(lastBusy, timeoutMs);
|
||||
const requestTimeoutMs = Math.min(timeoutMs, Math.ceil(budgetMs));
|
||||
let raw: JsonValue;
|
||||
try {
|
||||
raw = await connection.call(
|
||||
"server_health",
|
||||
{ database },
|
||||
{ timeoutMs: requestTimeoutMs },
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
lastBusy !== undefined &&
|
||||
cause instanceof AnalysisTimeoutError &&
|
||||
cause.timeoutMs === requestTimeoutMs &&
|
||||
requestTimeoutMs < timeoutMs
|
||||
) {
|
||||
throw new AnalysisTimeoutError(lastBusy.operation, timeoutMs, {
|
||||
cause: lastBusy.raw,
|
||||
capturedOutput: cause.capturedOutput,
|
||||
diagnostics: {
|
||||
health_request_operation: cause.operation,
|
||||
health_request_timeout_ms: cause.timeoutMs,
|
||||
},
|
||||
});
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
const busy = busyHealthSchema.safeParse(raw);
|
||||
if (!busy.success) {
|
||||
if (isBusyStatus(raw)) throw busy.error;
|
||||
return raw;
|
||||
}
|
||||
const observedBusy = {
|
||||
raw,
|
||||
operation: `server_health: headless worker still busy in ${busyToolName(busy.data.busy_tool)}`,
|
||||
};
|
||||
lastBusy = observedBusy;
|
||||
const expired = (): AnalysisTimeoutError =>
|
||||
new AnalysisTimeoutError(
|
||||
`server_health: headless worker still busy in ${busyToolName(busy.data.busy_tool)}`,
|
||||
timeoutMs,
|
||||
{ cause: raw },
|
||||
);
|
||||
busyTimeout(observedBusy, timeoutMs);
|
||||
if (remaining() <= 0) throw expired();
|
||||
await clock.delay(Math.min(BUSY_HEALTH_RETRY_MS, remaining()), signal);
|
||||
signal?.throwIfAborted();
|
||||
if (remaining() <= 0) throw expired();
|
||||
}
|
||||
};
|
||||
|
||||
const busyTimeout = (
|
||||
lastBusy: LastBusyHealth,
|
||||
timeoutMs: number,
|
||||
): AnalysisTimeoutError =>
|
||||
new AnalysisTimeoutError(lastBusy.operation, timeoutMs, {
|
||||
cause: lastBusy.raw,
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
decodeIdaToolResult,
|
||||
} from "./IdaMcpConnection.js";
|
||||
import { createIdaDiagnosticRedactor } from "./IdaDiagnosticRedactor.js";
|
||||
import { AnalysisTimeoutError } from "../domain/analysisErrorCore.js";
|
||||
import { readSettledHealth } from "./IdaBusyHealth.js";
|
||||
|
||||
describe("upstream IDA tool result decoding", () => {
|
||||
it("leaves successful legacy, structured, and native evidence unchanged", () => {
|
||||
@@ -143,6 +145,186 @@ const failingHttpConnection = (
|
||||
);
|
||||
};
|
||||
|
||||
interface MockedIdaHttpRequest {
|
||||
readonly id?: string | number;
|
||||
readonly method: string;
|
||||
readonly params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const jsonRpcResponse = (value: unknown): Response =>
|
||||
new Response(JSON.stringify(value), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
const stalledResponse = (signal: AbortSignal | null | undefined) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
const aborted = () =>
|
||||
reject(new DOMException("The request was aborted", "AbortError"));
|
||||
if (signal?.aborted === true) {
|
||||
aborted();
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", aborted, { once: true });
|
||||
});
|
||||
|
||||
const mockedHttpBoundary = ({
|
||||
serverName,
|
||||
tools,
|
||||
call,
|
||||
}: {
|
||||
readonly serverName: string;
|
||||
readonly tools: readonly {
|
||||
readonly name: string;
|
||||
readonly inputSchema: object;
|
||||
}[];
|
||||
readonly call: (
|
||||
request: MockedIdaHttpRequest,
|
||||
signal: AbortSignal | null | undefined,
|
||||
) => Response | Promise<Response>;
|
||||
}): void => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
const request = JSON.parse(
|
||||
String(init?.body ?? ""),
|
||||
) as MockedIdaHttpRequest;
|
||||
if (request.method === "initialize")
|
||||
return jsonRpcResponse({
|
||||
jsonrpc: "2.0",
|
||||
id: request.id,
|
||||
result: {
|
||||
protocolVersion: request.params?.protocolVersion,
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: serverName, version: "1" },
|
||||
},
|
||||
});
|
||||
if (
|
||||
request.method === "notifications/initialized" ||
|
||||
request.method === "notifications/cancelled"
|
||||
)
|
||||
return new Response(null, { status: 202 });
|
||||
if (request.method === "tools/list")
|
||||
return jsonRpcResponse({
|
||||
jsonrpc: "2.0",
|
||||
id: request.id,
|
||||
result: { tools },
|
||||
});
|
||||
if (request.method === "tools/call") return call(request, init?.signal);
|
||||
throw new Error(`Unexpected fixture request: ${request.method}`);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
it("preserves the SDK request timeout as a typed IDA timeout", async () => {
|
||||
const timeoutMs = 1000;
|
||||
const requestTimeoutMs = 40;
|
||||
const pendingCall = vi.fn();
|
||||
mockedHttpBoundary({
|
||||
serverName: "ida-timeout-fixture",
|
||||
tools: [],
|
||||
call: (_request, signal) => {
|
||||
pendingCall();
|
||||
return stalledResponse(signal);
|
||||
},
|
||||
});
|
||||
const connection = createIdaMcpConnection(
|
||||
{
|
||||
url: "http://127.0.0.1:12345/mcp",
|
||||
headers: { Authorization: "Bearer fixture-secret" },
|
||||
mode: "attached",
|
||||
timeoutMs,
|
||||
},
|
||||
{},
|
||||
);
|
||||
try {
|
||||
await expect(connection.connect()).resolves.toEqual([]);
|
||||
const failure = await connection
|
||||
.call("decompile", {}, { timeoutMs: requestTimeoutMs })
|
||||
.catch((cause: unknown) => cause);
|
||||
expect(pendingCall).toHaveBeenCalledOnce();
|
||||
expect(failure).toBeInstanceOf(AnalysisTimeoutError);
|
||||
expect(failure).toMatchObject({
|
||||
operation: "decompile",
|
||||
timeoutMs: requestTimeoutMs,
|
||||
});
|
||||
if (!(failure instanceof AnalysisTimeoutError))
|
||||
throw new Error("Expected a typed IDA request timeout");
|
||||
expect(failure.cause).toBeInstanceOf(Error);
|
||||
expect(inspectRetainedError(failure)).not.toContain("fixture-secret");
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds a stalled health poll by the remaining readiness deadline", async () => {
|
||||
const timeoutMs = 800;
|
||||
const pendingCall = vi.fn();
|
||||
let clockNow = 0;
|
||||
const clock = {
|
||||
now: () => clockNow,
|
||||
delay: async (ms: number) => {
|
||||
clockNow += ms;
|
||||
},
|
||||
};
|
||||
let pendingCalls = 0;
|
||||
const busy = {
|
||||
status: "busy",
|
||||
busy_tool: "config_json_get",
|
||||
busy_sec: 1,
|
||||
queued_calls: 0,
|
||||
};
|
||||
mockedHttpBoundary({
|
||||
serverName: "ida-health-deadline-fixture",
|
||||
tools: [{ name: "server_health", inputSchema: { type: "object" } }],
|
||||
call: (request, signal) => {
|
||||
pendingCalls += 1;
|
||||
if (pendingCalls === 1) {
|
||||
clockNow += 450;
|
||||
const result = {
|
||||
content: [{ type: "text", text: JSON.stringify(busy) }],
|
||||
structuredContent: busy,
|
||||
};
|
||||
return jsonRpcResponse({ jsonrpc: "2.0", id: request.id, result });
|
||||
}
|
||||
return stalledResponse(signal);
|
||||
},
|
||||
});
|
||||
const connection = createIdaMcpConnection(
|
||||
{
|
||||
url: "http://127.0.0.1:12345/mcp",
|
||||
headers: {},
|
||||
mode: "headless",
|
||||
timeoutMs,
|
||||
},
|
||||
{},
|
||||
);
|
||||
try {
|
||||
await connection.connect();
|
||||
const failure = await readSettledHealth(connection, {
|
||||
database: "fixture-db",
|
||||
timeoutMs,
|
||||
clock,
|
||||
}).catch((cause: unknown) => cause);
|
||||
|
||||
expect(pendingCalls).toBe(2);
|
||||
expect(failure).toBeInstanceOf(AnalysisTimeoutError);
|
||||
expect(failure).toMatchObject({
|
||||
operation: "server_health: headless worker still busy in config_json_get",
|
||||
timeoutMs,
|
||||
cause: busy,
|
||||
diagnostics: {
|
||||
health_request_operation: "server_health",
|
||||
health_request_timeout_ms: 250,
|
||||
},
|
||||
});
|
||||
if (!(failure instanceof AnalysisTimeoutError))
|
||||
throw new Error("Expected the readiness deadline to expire");
|
||||
expect(failure.capturedOutput).toBeDefined();
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
|
||||
const inspectRetainedError = (error: unknown): string =>
|
||||
inspect(error, { depth: null, maxStringLength: null, breakLength: Infinity });
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
import {
|
||||
Client,
|
||||
ProtocolError,
|
||||
SdkError,
|
||||
SdkErrorCode,
|
||||
SdkHttpError,
|
||||
StreamableHTTPClientTransport,
|
||||
@@ -22,6 +23,7 @@ import type { IdaConfiguration } from "./IdaConfiguration.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
AnalysisTimeoutError,
|
||||
AnalysisProtocolError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
@@ -33,6 +35,7 @@ export interface IdaMcpConnection {
|
||||
call(
|
||||
name: string,
|
||||
args: Readonly<Record<string, JsonValue>>,
|
||||
options?: { readonly timeoutMs?: number | undefined },
|
||||
): Promise<JsonValue>;
|
||||
readResource?(uri: string): Promise<JsonValue>;
|
||||
serverInfo(): JsonValue;
|
||||
@@ -347,6 +350,7 @@ export const createIdaMcpConnection = (
|
||||
const transportFailure = (
|
||||
cause: unknown,
|
||||
operation = "ida_mcp_response",
|
||||
requestTimeoutMs = config.timeoutMs,
|
||||
): never => {
|
||||
const bufferLimit =
|
||||
cause instanceof Error
|
||||
@@ -389,6 +393,11 @@ export const createIdaMcpConnection = (
|
||||
stdout_bytes: 0,
|
||||
stderr_bytes: stderrBytes,
|
||||
};
|
||||
if (cause instanceof SdkError && cause.code === SdkErrorCode.RequestTimeout)
|
||||
throw new AnalysisTimeoutError(operation, requestTimeoutMs, {
|
||||
cause: failure,
|
||||
capturedOutput,
|
||||
});
|
||||
if (bufferLimit !== null)
|
||||
throw new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
@@ -443,19 +452,25 @@ export const createIdaMcpConnection = (
|
||||
async connect() {
|
||||
await client
|
||||
.connect(transport, { timeout: config.timeoutMs })
|
||||
.catch(transportFailure);
|
||||
.catch((cause: unknown) => transportFailure(cause, "ida_mcp_connect"));
|
||||
const catalog = await client
|
||||
.listTools(undefined, {
|
||||
timeout: config.timeoutMs,
|
||||
})
|
||||
.catch(transportFailure);
|
||||
.catch((cause: unknown) => transportFailure(cause, "ida_mcp_catalog"));
|
||||
return catalog.tools.map(({ name }) => name);
|
||||
},
|
||||
async call(name, args) {
|
||||
async call(name, args, options) {
|
||||
const requestTimeoutMs = Math.min(
|
||||
config.timeoutMs,
|
||||
options?.timeoutMs ?? config.timeoutMs,
|
||||
);
|
||||
return decodeIdaToolResult(
|
||||
await client
|
||||
.callTool({ name, arguments: args }, { timeout: config.timeoutMs })
|
||||
.catch((cause: unknown) => transportFailure(cause, name)),
|
||||
.callTool({ name, arguments: args }, { timeout: requestTimeoutMs })
|
||||
.catch((cause: unknown) =>
|
||||
transportFailure(cause, name, requestTimeoutMs),
|
||||
),
|
||||
config.protocol,
|
||||
redactor,
|
||||
);
|
||||
@@ -463,7 +478,9 @@ export const createIdaMcpConnection = (
|
||||
async readResource(uri) {
|
||||
const response = await client
|
||||
.readResource({ uri }, { timeout: config.timeoutMs })
|
||||
.catch(transportFailure);
|
||||
.catch((cause: unknown) =>
|
||||
transportFailure(cause, "ida_mcp_read_resource"),
|
||||
);
|
||||
const resource = z
|
||||
.object({
|
||||
contents: z
|
||||
|
||||
@@ -5,7 +5,171 @@ import {
|
||||
decodeJebToolResult,
|
||||
createStreamableHttpJebMcpConnection,
|
||||
} from "./JebMcpConnection.js";
|
||||
import { AnalysisProtocolError } from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisProtocolError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
|
||||
const jebTools = [
|
||||
"get_client_information",
|
||||
"open_project",
|
||||
"list_units",
|
||||
"decompile_code_item",
|
||||
] as const;
|
||||
|
||||
const waitForRequestCount = async (
|
||||
readCount: () => number,
|
||||
expected: number,
|
||||
method: string,
|
||||
): Promise<void> => {
|
||||
const deadline = performance.now() + 1_000;
|
||||
while (readCount() < expected && performance.now() < deadline)
|
||||
await new Promise((wake) => setTimeout(wake, 5));
|
||||
if (readCount() < expected)
|
||||
throw new Error(`Expected ${expected} ${method} requests`);
|
||||
};
|
||||
|
||||
const startJebHttpFixture = (
|
||||
responseDelayMs: number,
|
||||
catalogRefreshDelayMs = 0,
|
||||
) => {
|
||||
let callCount = 0;
|
||||
let catalogCount = 0;
|
||||
const pendingTimers = new Set<NodeJS.Timeout>();
|
||||
const server = createServer((request, response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
request.on("end", () => {
|
||||
if (chunks.length === 0) {
|
||||
response.writeHead(202).end();
|
||||
return;
|
||||
}
|
||||
const message: {
|
||||
readonly id?: string | number;
|
||||
readonly method: string;
|
||||
readonly params?: Record<string, unknown>;
|
||||
} = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
if (message.method === "notifications/initialized") {
|
||||
response.writeHead(202).end();
|
||||
return;
|
||||
}
|
||||
if (message.method === "notifications/cancelled") {
|
||||
response.writeHead(202).end();
|
||||
return;
|
||||
}
|
||||
if (message.method === "initialize") {
|
||||
response.writeHead(200, { "Content-Type": "application/json" }).end(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: {
|
||||
protocolVersion: message.params?.protocolVersion,
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: "jeb-timeout-fixture", version: "1" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (message.method === "tools/list") {
|
||||
catalogCount++;
|
||||
let responseClosed = false;
|
||||
response.on("close", () => {
|
||||
if (!response.writableEnded) responseClosed = true;
|
||||
});
|
||||
const sendCatalog = () => {
|
||||
if (responseClosed) return;
|
||||
response.writeHead(200, { "Content-Type": "application/json" }).end(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: {
|
||||
tools: jebTools.map((name) => ({
|
||||
name,
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
})),
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
if (catalogCount > 1 && catalogRefreshDelayMs > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
pendingTimers.delete(timer);
|
||||
sendCatalog();
|
||||
}, catalogRefreshDelayMs);
|
||||
pendingTimers.add(timer);
|
||||
} else {
|
||||
sendCatalog();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.method !== "tools/call") {
|
||||
response.writeHead(202).end();
|
||||
return;
|
||||
}
|
||||
callCount++;
|
||||
let responseClosed = false;
|
||||
response.on("close", () => {
|
||||
if (!response.writableEnded) {
|
||||
responseClosed = true;
|
||||
}
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
pendingTimers.delete(timer);
|
||||
if (responseClosed) return;
|
||||
response.writeHead(200, { "Content-Type": "application/json" }).end(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: {
|
||||
structuredContent: { success: true, text: "finished" },
|
||||
content: [{ type: "text", text: '{"success":true}' }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, responseDelayMs);
|
||||
pendingTimers.add(timer);
|
||||
});
|
||||
});
|
||||
|
||||
return new Promise<{
|
||||
readonly endpoint: URL;
|
||||
readonly callCount: () => number;
|
||||
readonly waitForCall: (count?: number) => Promise<void>;
|
||||
readonly waitForCatalog: (count?: number) => Promise<void>;
|
||||
readonly close: () => Promise<void>;
|
||||
}>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") {
|
||||
reject(new Error("Expected TCP listener"));
|
||||
return;
|
||||
}
|
||||
server.removeListener("error", reject);
|
||||
resolve({
|
||||
endpoint: new URL(`http://127.0.0.1:${address.port}/mcp`),
|
||||
callCount: () => callCount,
|
||||
waitForCall: (count = 1) =>
|
||||
waitForRequestCount(() => callCount, count, "tools/call"),
|
||||
waitForCatalog: (count = 1) =>
|
||||
waitForRequestCount(() => catalogCount, count, "tools/list"),
|
||||
close: async () => {
|
||||
for (const timer of pendingTimers) clearTimeout(timer);
|
||||
pendingTimers.clear();
|
||||
const closed = new Promise<void>((done, fail) =>
|
||||
server.close((cause) =>
|
||||
cause === undefined ? done() : fail(cause),
|
||||
),
|
||||
);
|
||||
server.closeAllConnections();
|
||||
await closed;
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
describe("decodeJebToolResult", () => {
|
||||
it("passes refusal envelopes through so the provider preserves the engine reason", () => {
|
||||
@@ -118,3 +282,93 @@ it("refuses HTTP redirects before sending requests to another endpoint", async (
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("applies the selected timeout to real MCP tool calls", async () => {
|
||||
const fixture = await startJebHttpFixture(180);
|
||||
const short = createStreamableHttpJebMcpConnection(fixture.endpoint, {
|
||||
requestTimeoutMs: 50,
|
||||
});
|
||||
const extended = createStreamableHttpJebMcpConnection(fixture.endpoint, {
|
||||
requestTimeoutMs: 500,
|
||||
});
|
||||
try {
|
||||
await short.connect();
|
||||
await expect(short.call("decompile_code_item", {})).rejects.toBeInstanceOf(
|
||||
AnalysisTimeoutError,
|
||||
);
|
||||
expect(fixture.callCount()).toBe(1);
|
||||
|
||||
await extended.connect();
|
||||
await expect(
|
||||
extended.call("decompile_code_item", {}),
|
||||
).resolves.toMatchObject({ success: true, text: "finished" });
|
||||
expect(fixture.callCount()).toBe(2);
|
||||
} finally {
|
||||
try {
|
||||
await Promise.all([short.close(), extended.close()]);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps caller abort and connection close as cancellations for active HTTP calls", async () => {
|
||||
const fixture = await startJebHttpFixture(1_000);
|
||||
const connection = createStreamableHttpJebMcpConnection(fixture.endpoint, {
|
||||
requestTimeoutMs: 2_000,
|
||||
});
|
||||
try {
|
||||
await connection.connect();
|
||||
const alreadyAborted = new AbortController();
|
||||
alreadyAborted.abort();
|
||||
await expect(
|
||||
connection.call("decompile_code_item", {}, alreadyAborted.signal),
|
||||
).rejects.toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(fixture.callCount()).toBe(0);
|
||||
|
||||
const caller = new AbortController();
|
||||
const callerCall = connection.call(
|
||||
"decompile_code_item",
|
||||
{},
|
||||
caller.signal,
|
||||
);
|
||||
await fixture.waitForCall(1);
|
||||
caller.abort();
|
||||
await expect(callerCall).rejects.toBeInstanceOf(AnalysisCancelledError);
|
||||
|
||||
const closingCall = connection.call("decompile_code_item", {});
|
||||
await fixture.waitForCall(2);
|
||||
await connection.close();
|
||||
await expect(closingCall).rejects.toBeInstanceOf(AnalysisCancelledError);
|
||||
} finally {
|
||||
try {
|
||||
await connection.close();
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps caller abort and connection close as cancellations during catalog refresh", async () => {
|
||||
const fixture = await startJebHttpFixture(0, 1_000);
|
||||
const connection = createStreamableHttpJebMcpConnection(fixture.endpoint);
|
||||
try {
|
||||
await connection.connect();
|
||||
const caller = new AbortController();
|
||||
const callerRefresh = connection.listTools(caller.signal);
|
||||
await fixture.waitForCatalog(2);
|
||||
caller.abort();
|
||||
await expect(callerRefresh).rejects.toBeInstanceOf(AnalysisCancelledError);
|
||||
|
||||
const closingRefresh = connection.listTools();
|
||||
await fixture.waitForCatalog(3);
|
||||
await connection.close();
|
||||
await expect(closingRefresh).rejects.toBeInstanceOf(AnalysisCancelledError);
|
||||
} finally {
|
||||
try {
|
||||
await connection.close();
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
+64
-15
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
Client,
|
||||
DEFAULT_REQUEST_TIMEOUT_MSEC,
|
||||
SdkError,
|
||||
SdkErrorCode,
|
||||
StreamableHTTPClientTransport,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { z } from "zod";
|
||||
@@ -82,16 +84,27 @@ const isJebFailureEnvelope = (value: JsonValue): boolean =>
|
||||
!Array.isArray(value) &&
|
||||
value.success === false;
|
||||
|
||||
export interface JebMcpConnectionTimeouts {
|
||||
/** Absolute catalog connection and pagination deadline. */
|
||||
readonly discoveryTimeoutMs?: number;
|
||||
/** Absolute timeout for each upstream tools/call request. */
|
||||
readonly requestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export type JebMcpConnectionFactory = (
|
||||
endpoint: URL,
|
||||
timeoutMs?: number,
|
||||
timeouts?: JebMcpConnectionTimeouts,
|
||||
) => JebMcpConnection;
|
||||
|
||||
/** Serve JEB requests from a running engine client over streamable HTTP. */
|
||||
export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = (
|
||||
endpoint,
|
||||
timeoutMs = DEFAULT_REQUEST_TIMEOUT_MSEC,
|
||||
timeouts,
|
||||
) => {
|
||||
const discoveryTimeoutMs =
|
||||
timeouts?.discoveryTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MSEC;
|
||||
const requestTimeoutMs =
|
||||
timeouts?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MSEC;
|
||||
interface ConnectionState {
|
||||
readonly client: Client;
|
||||
readonly transport: StreamableHTTPClientTransport;
|
||||
@@ -184,7 +197,7 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = (
|
||||
state = owner;
|
||||
const acquisition = (async () => {
|
||||
const deadline = new ProviderStartupDeadline(
|
||||
timeoutMs,
|
||||
discoveryTimeoutMs,
|
||||
owner.signal.signal,
|
||||
);
|
||||
try {
|
||||
@@ -197,7 +210,9 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = (
|
||||
} catch (cause: unknown) {
|
||||
const failure =
|
||||
deadline.interruption === "timeout"
|
||||
? new AnalysisTimeoutError("jeb_mcp_catalog", timeoutMs, { cause })
|
||||
? new AnalysisTimeoutError("jeb_mcp_catalog", discoveryTimeoutMs, {
|
||||
cause,
|
||||
})
|
||||
: cause;
|
||||
owner.signal.abort(failure);
|
||||
try {
|
||||
@@ -234,19 +249,30 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = (
|
||||
// could detach this waiter before that owner has disposed a failed client.
|
||||
const tools = await connect(signal);
|
||||
if (!existing) return tools;
|
||||
const deadline = new ProviderStartupDeadline(timeoutMs, signal);
|
||||
const owner = state;
|
||||
if (owner === undefined)
|
||||
throw new AnalysisProtocolError("JEB MCP client failed to connect");
|
||||
const discoverySignal =
|
||||
signal === undefined
|
||||
? owner.signal.signal
|
||||
: AbortSignal.any([signal, owner.signal.signal]);
|
||||
const deadline = new ProviderStartupDeadline(
|
||||
discoveryTimeoutMs,
|
||||
discoverySignal,
|
||||
);
|
||||
try {
|
||||
const owner = state;
|
||||
if (owner === undefined)
|
||||
throw new AnalysisProtocolError("JEB MCP client failed to connect");
|
||||
const refreshed = await readToolNames(owner.client, deadline.signal);
|
||||
owner.tools = refreshed;
|
||||
return refreshed;
|
||||
} catch (cause: unknown) {
|
||||
if (deadline.interruption === "timeout")
|
||||
throw new AnalysisTimeoutError("jeb_mcp_catalog", timeoutMs, {
|
||||
cause,
|
||||
});
|
||||
throw new AnalysisTimeoutError(
|
||||
"jeb_mcp_catalog",
|
||||
discoveryTimeoutMs,
|
||||
{ cause },
|
||||
);
|
||||
if (deadline.interruption === "cancelled")
|
||||
throw new AnalysisCancelledError("jeb_mcp_catalog", { cause });
|
||||
throw cause;
|
||||
} finally {
|
||||
deadline.dispose();
|
||||
@@ -257,11 +283,34 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = (
|
||||
const owner = state;
|
||||
if (owner === undefined || owner.tools === undefined)
|
||||
throw new AnalysisProtocolError("JEB MCP client failed to connect");
|
||||
const result = await owner.client.callTool(
|
||||
{ name, arguments: args },
|
||||
signal === undefined ? {} : { signal },
|
||||
const requestSignal =
|
||||
signal === undefined
|
||||
? owner.signal.signal
|
||||
: AbortSignal.any([signal, owner.signal.signal]);
|
||||
const deadline = new ProviderStartupDeadline(
|
||||
requestTimeoutMs,
|
||||
requestSignal,
|
||||
);
|
||||
return decodeJebToolResult(result);
|
||||
try {
|
||||
const result = await owner.client.callTool(
|
||||
{ name, arguments: args },
|
||||
{ timeout: deadline.remainingMs(), signal: deadline.signal },
|
||||
);
|
||||
return decodeJebToolResult(result);
|
||||
} catch (cause: unknown) {
|
||||
if (deadline.interruption === "timeout")
|
||||
throw new AnalysisTimeoutError(name, requestTimeoutMs, { cause });
|
||||
if (requestSignal.aborted)
|
||||
throw new AnalysisCancelledError(name, { cause });
|
||||
if (
|
||||
cause instanceof SdkError &&
|
||||
cause.code === SdkErrorCode.RequestTimeout
|
||||
)
|
||||
throw new AnalysisTimeoutError(name, requestTimeoutMs, { cause });
|
||||
throw cause;
|
||||
} finally {
|
||||
deadline.dispose();
|
||||
}
|
||||
},
|
||||
async close() {
|
||||
closed = true;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createJebAnalysisProvider } from "../composition/jeb.js";
|
||||
import { JebProvider } from "./JebProvider.js";
|
||||
import type { JebMcpConnection } from "./JebMcpConnection.js";
|
||||
import type {
|
||||
JebMcpConnection,
|
||||
JebMcpConnectionTimeouts,
|
||||
} from "./JebMcpConnection.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisInputError,
|
||||
@@ -42,6 +46,36 @@ const providerWith = (connection: JebMcpConnection) =>
|
||||
});
|
||||
|
||||
describe("JebProvider live availability", () => {
|
||||
it("passes its validated environment timeout to the production connection", async () => {
|
||||
let configured: JebMcpConnectionTimeouts | undefined;
|
||||
const provider = createJebAnalysisProvider(
|
||||
{ REA_JEB_MCP_TIMEOUT_MS: "900000" },
|
||||
(_endpoint, timeouts) => {
|
||||
configured = timeouts;
|
||||
return scriptedConnection({});
|
||||
},
|
||||
);
|
||||
expect(configured).toEqual({ requestTimeoutMs: 900_000 });
|
||||
await provider.close();
|
||||
});
|
||||
|
||||
it("rejects an invalid JEB timeout before constructing its MCP connection", async () => {
|
||||
let constructed = false;
|
||||
const provider = createJebAnalysisProvider(
|
||||
{ REA_JEB_MCP_TIMEOUT_MS: "0" },
|
||||
() => {
|
||||
constructed = true;
|
||||
return scriptedConnection({});
|
||||
},
|
||||
);
|
||||
expect(constructed).toBe(false);
|
||||
await expect(provider.inspectAvailability()).resolves.toMatchObject({
|
||||
status: "unavailable",
|
||||
diagnostics: { setting: "REA_JEB_MCP_TIMEOUT_MS" },
|
||||
});
|
||||
await provider.close();
|
||||
});
|
||||
|
||||
it("reports only the operations advertised by the live engine", async () => {
|
||||
const connection = scriptedConnection({
|
||||
get_client_information: {
|
||||
|
||||
+10
-4
@@ -39,6 +39,8 @@ export const JEB_PROVIDER_IDENTITY: ProviderIdentity = {
|
||||
export interface JebProviderOptions {
|
||||
/** Caller environment carrying REA_JEB_MCP_URL. */
|
||||
readonly environment?: Readonly<Record<string, string | undefined>>;
|
||||
/** Per-tools/call budget selected by REA_JEB_MCP_TIMEOUT_MS. */
|
||||
readonly requestTimeoutMs?: number;
|
||||
/** Injectable MCP boundary; defaults to the streamable HTTP client. */
|
||||
readonly connectionFactory?: JebMcpConnectionFactory;
|
||||
}
|
||||
@@ -111,10 +113,14 @@ export class JebProvider implements JebAnalysisPort {
|
||||
const endpoint = parseJebMcpEndpoint(environment.REA_JEB_MCP_URL);
|
||||
if (!endpoint.ok) throw endpoint.error;
|
||||
this.#endpoint = endpoint.value;
|
||||
this.#connection = (
|
||||
options.connectionFactory ??
|
||||
((url: URL) => createStreamableHttpJebMcpConnection(url))
|
||||
)(this.#endpoint);
|
||||
const createConnection =
|
||||
options.connectionFactory ?? createStreamableHttpJebMcpConnection;
|
||||
this.#connection =
|
||||
options.requestTimeoutMs === undefined
|
||||
? createConnection(this.#endpoint)
|
||||
: createConnection(this.#endpoint, {
|
||||
requestTimeoutMs: options.requestTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
async inspectAvailability(
|
||||
|
||||
+47
-3
@@ -1,17 +1,61 @@
|
||||
import type { AnalysisProviderSelector } from "./contracts/providerSelection.js";
|
||||
|
||||
/**
|
||||
* Supported MCP startup deadlines and regression budgets.
|
||||
* Supported MCP startup deadlines and operation headroom.
|
||||
*
|
||||
* The Codex timeout is caller-facing configuration. The smaller phase budgets
|
||||
* Client timeouts are caller-facing configuration. The smaller phase budgets
|
||||
* preserve operational headroom, while the doctor deadline covers additional
|
||||
* inventory requests after initialization.
|
||||
*/
|
||||
export const MCP_STARTUP_POLICY = {
|
||||
codexStartupTimeoutSeconds: 30,
|
||||
kimiStartupTimeoutMs: 30_000,
|
||||
kimiToolTimeoutMs: 120_000,
|
||||
toolOperationHeadroomMs: 120_000,
|
||||
initializeBudgetMs: 15_000,
|
||||
toolsListBudgetMs: 5_000,
|
||||
firstCatalogBudgetMs: 20_000,
|
||||
doctorDeadlineMs: 30_000,
|
||||
doctorStderrBytes: 64 * 1024,
|
||||
} as const;
|
||||
|
||||
/** Parsed startup and upstream request budgets affecting a client's tool deadline. */
|
||||
export interface McpToolTimeoutInputs {
|
||||
readonly analysisProvider: AnalysisProviderSelector;
|
||||
readonly ghidraStartupTimeoutMs: number;
|
||||
readonly hopperStartupTimeoutMs: number;
|
||||
readonly jebMcpTimeoutMs?: number;
|
||||
readonly providerRequestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Allow for known startup phases and caller-selected upstream request budgets. */
|
||||
export const mcpProviderDeadlineAllowanceMs = (
|
||||
inputs: McpToolTimeoutInputs,
|
||||
): number => {
|
||||
const selectedProvider = inputs.analysisProvider;
|
||||
const selectedTimeout =
|
||||
selectedProvider === "ghidra"
|
||||
? inputs.ghidraStartupTimeoutMs
|
||||
: selectedProvider === "hopper"
|
||||
? inputs.hopperStartupTimeoutMs
|
||||
: Math.max(
|
||||
inputs.ghidraStartupTimeoutMs,
|
||||
inputs.hopperStartupTimeoutMs,
|
||||
);
|
||||
return Math.max(
|
||||
selectedTimeout,
|
||||
inputs.jebMcpTimeoutMs ?? 0,
|
||||
inputs.providerRequestTimeoutMs ?? 0,
|
||||
);
|
||||
};
|
||||
|
||||
/** Add operation headroom to the parsed provider deadline allowance. */
|
||||
export const recommendedMcpToolTimeoutMs = (
|
||||
inputs: McpToolTimeoutInputs,
|
||||
): number =>
|
||||
mcpProviderDeadlineAllowanceMs(inputs) +
|
||||
MCP_STARTUP_POLICY.toolOperationHeadroomMs;
|
||||
|
||||
/** Round up so Codex's whole-second deadline still covers the millisecond budget. */
|
||||
export const recommendedMcpToolTimeoutSeconds = (
|
||||
inputs: McpToolTimeoutInputs,
|
||||
): number => Math.ceil(recommendedMcpToolTimeoutMs(inputs) / 1000);
|
||||
@@ -137,7 +137,7 @@ import { RizinDebugSessionManager } from "../rizin/RizinDebugSessionManager.js";
|
||||
import { registerRizinDebugTools } from "./registerRizinDebugTools.js";
|
||||
|
||||
const TARGET_FREE_INSTRUCTIONS =
|
||||
"REA provides reverse-engineering tools for local artifacts, native binaries, managed code, browser pages, and runtimes. Use the tool that directly answers the question; discover targets or inspect inventory only when needed. Tool results include inline Evidence and report their coverage and limitations.";
|
||||
"REA provides reverse-engineering tools for local artifacts, native binaries, managed code, browser pages, and runtimes. Use the tool that directly answers the question; discover targets or inspect inventory only when needed. Tool results include inline Evidence and report their coverage and limitations. An opened target persists across turns. When finished, export needed Evidence and call close_binary; its snapshot_path can retain analysis.";
|
||||
|
||||
const ACTIVE_TARGET_INSTRUCTIONS =
|
||||
"REA analyzes the active reverse-engineering target. Use the analysis tool that answers the question directly. Search or list symbols when discovery is needed; analyze_function provides a function dossier, and focused procedure tools return individual facets.";
|
||||
|
||||
@@ -7,6 +7,7 @@ import { afterEach, expect, it, vi } from "vitest";
|
||||
import { createDoctorHostFixture } from "./application/Doctor.fixture.js";
|
||||
import * as macHopperInstaller from "./application/MacHopper.js";
|
||||
import { runSetup } from "./application/Setup.js";
|
||||
import { discoverSetupState } from "./application/SetupPlan.js";
|
||||
import { options } from "./application/Setup.fixture.js";
|
||||
import { systemSetupHost } from "./application/SetupHost.js";
|
||||
import { systemUninstallHost } from "./application/Uninstall.js";
|
||||
@@ -176,6 +177,60 @@ it("preserves a validated selected JEB endpoint in setup registrations without c
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "registration-only settings",
|
||||
environment: {
|
||||
REA_ANALYSIS_PROVIDER: "auto",
|
||||
REA_GHIDRA_STARTUP_TIMEOUT_MS: "2700000",
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
|
||||
},
|
||||
providerConfigured: false,
|
||||
installHopper: true,
|
||||
},
|
||||
{
|
||||
name: "an explicit unready provider selection",
|
||||
environment: { REA_ANALYSIS_PROVIDER: "ghidra" },
|
||||
providerConfigured: true,
|
||||
installHopper: false,
|
||||
},
|
||||
])(
|
||||
"derives Hopper proposal behavior from setup-host facts for $name",
|
||||
async ({ environment, providerConfigured, installHopper }) => {
|
||||
const doctorHost = createDoctorHostFixture({
|
||||
platform: "darwin",
|
||||
validTarget: () => Promise.resolve(false),
|
||||
executable: () => Promise.resolve(false),
|
||||
});
|
||||
const setupHost = systemSetupHost(doctorHost, {
|
||||
HOME: "/fixture/home",
|
||||
USERPROFILE: "/fixture/home",
|
||||
...environment,
|
||||
});
|
||||
try {
|
||||
const initial = await setupHost.initialSetupState();
|
||||
expect(initial.providerConfigured).toBe(providerConfigured);
|
||||
if ("REA_MCP_INPUT_SCHEMA_PROFILE" in environment) {
|
||||
expect(initial.providerEnvironment).toMatchObject({
|
||||
REA_ANALYSIS_PROVIDER: "auto",
|
||||
REA_GHIDRA_STARTUP_TIMEOUT_MS: "2700000",
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
|
||||
});
|
||||
}
|
||||
const discovery = await discoverSetupState({
|
||||
host: setupHost,
|
||||
providerConfigured: initial.providerConfigured,
|
||||
forceHopperInstall: false,
|
||||
proposeHopper: true,
|
||||
initialDoctor: initial.doctor,
|
||||
});
|
||||
expect(discovery.installHopper).toBe(installHopper);
|
||||
} finally {
|
||||
await setupHost.close?.();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("passes only selected environment to updater subprocesses and drops npm invocation identity", async () => {
|
||||
vi.stubEnv("REA_UPDATE_MARKER", "ambient");
|
||||
const result = await runUpdateCommand(
|
||||
|
||||
@@ -236,3 +236,279 @@ cliTest(
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"public setup propagates selected provider deadlines and input profile to Codex and Kimi",
|
||||
async ({ cli }) => {
|
||||
const home = await createTestTempDirectory(
|
||||
"rea-selected-registration-env-",
|
||||
);
|
||||
const codexHome = join(home, "codex");
|
||||
const kimiHome = join(home, "kimi");
|
||||
await Promise.all(
|
||||
[codexHome, kimiHome].map((path) => mkdir(path, { recursive: true })),
|
||||
);
|
||||
const environment = {
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
XDG_CONFIG_HOME: join(home, ".config"),
|
||||
CODEX_HOME: codexHome,
|
||||
KIMI_CODE_HOME: kimiHome,
|
||||
HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"),
|
||||
REA_ANALYSIS_PROVIDER: "auto",
|
||||
REA_GHIDRA_STARTUP_TIMEOUT_MS: "2700001",
|
||||
REA_HOPPER_STARTUP_TIMEOUT_MS: "2800002",
|
||||
REA_JEB_MCP_TIMEOUT_MS: "2900003",
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
|
||||
REA_UNRELATED_MARKER: "do-not-persist",
|
||||
};
|
||||
const run = (arguments_: readonly string[]) =>
|
||||
cli.run({
|
||||
arguments: [...arguments_, "--json"],
|
||||
cwd: home,
|
||||
environment,
|
||||
});
|
||||
|
||||
for (const client of ["codex", "kimi"] as const) {
|
||||
const setup = await run([
|
||||
"setup",
|
||||
"--client",
|
||||
client,
|
||||
"--skill=false",
|
||||
"--yes",
|
||||
]);
|
||||
expect(setup.exitCode, setup.stderr).toBe(0);
|
||||
expect(setup.json).toMatchObject({ status: "ready" });
|
||||
}
|
||||
|
||||
const codexConfig = await readFile(join(codexHome, "config.toml"), "utf8");
|
||||
const codexRegistration = effectiveClientServer(
|
||||
parseClientConfiguration(codexConfig, "toml"),
|
||||
"rea",
|
||||
);
|
||||
expect(codexRegistration).toMatchObject({ tool_timeout_sec: 3021 });
|
||||
expect(codexConfig).toContain('REA_JEB_MCP_TIMEOUT_MS = "2900003"');
|
||||
expect(codexConfig).not.toContain("REA_UNRELATED_MARKER");
|
||||
|
||||
const kimiPath = join(kimiHome, "mcp.json");
|
||||
const kimiRegistration = effectiveClientServer(
|
||||
parseClientConfiguration(await readFile(kimiPath, "utf8"), "kimi"),
|
||||
"rea",
|
||||
);
|
||||
expect(kimiRegistration).toMatchObject({
|
||||
toolTimeoutMs: 3_020_003,
|
||||
env: {
|
||||
REA_ANALYSIS_PROVIDER: "auto",
|
||||
REA_GHIDRA_STARTUP_TIMEOUT_MS: "2700001",
|
||||
REA_HOPPER_STARTUP_TIMEOUT_MS: "2800002",
|
||||
REA_JEB_MCP_TIMEOUT_MS: "2900003",
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
|
||||
},
|
||||
});
|
||||
expect(await readFile(kimiPath, "utf8")).not.toContain(
|
||||
"REA_UNRELATED_MARKER",
|
||||
);
|
||||
|
||||
const doctor = await run(["doctor", "--client", "kimi"]);
|
||||
expect(doctor.exitCode, doctor.stderr).toBe(0);
|
||||
expect(doctor.json).toMatchObject({
|
||||
identity: {
|
||||
registrations: expect.arrayContaining([
|
||||
expect.objectContaining({ client: "kimi", state: "aligned" }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
|
||||
for (const [client, provider] of [
|
||||
["codex", "ida"],
|
||||
["kimi", "ida"],
|
||||
["kimi", "auto"],
|
||||
] as const)
|
||||
cliTest(
|
||||
`public ${client} setup and doctor use the effective IDA registration request budget with ${provider} selection`,
|
||||
async ({ cli }) => {
|
||||
const home = await createTestTempDirectory(
|
||||
"rea-ida-registration-budget-",
|
||||
);
|
||||
const clientHome = join(home, client);
|
||||
await mkdir(clientHome, { recursive: true });
|
||||
const idaConfig = join(home, "ida.json");
|
||||
const writeRegistration = (timeoutMs: number) =>
|
||||
writeFile(
|
||||
idaConfig,
|
||||
JSON.stringify({
|
||||
url: "http://127.0.0.1:12345/mcp",
|
||||
headers: { Authorization: "Bearer fixture-credential" },
|
||||
timeoutMs,
|
||||
}),
|
||||
);
|
||||
await writeRegistration(1_800_001);
|
||||
const environment = {
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
XDG_CONFIG_HOME: join(home, ".config"),
|
||||
CODEX_HOME: clientHome,
|
||||
KIMI_CODE_HOME: clientHome,
|
||||
HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"),
|
||||
};
|
||||
const setupArguments = [
|
||||
"setup",
|
||||
"--client",
|
||||
client,
|
||||
"--skill=false",
|
||||
"--yes",
|
||||
"--json",
|
||||
];
|
||||
const configured = await cli.run({
|
||||
arguments: setupArguments,
|
||||
cwd: home,
|
||||
environment: {
|
||||
...environment,
|
||||
REA_ANALYSIS_PROVIDER: provider,
|
||||
REA_IDA_MCP_CONFIG: idaConfig,
|
||||
},
|
||||
});
|
||||
expect(configured.exitCode, configured.stdout).toBe(0);
|
||||
const config = join(
|
||||
clientHome,
|
||||
client === "codex" ? "config.toml" : "mcp.json",
|
||||
);
|
||||
const text = await readFile(config, "utf8");
|
||||
expect(
|
||||
effectiveClientServer(
|
||||
parseClientConfiguration(text, client === "codex" ? "toml" : "kimi"),
|
||||
"rea",
|
||||
),
|
||||
).toMatchObject({
|
||||
...(client === "codex"
|
||||
? { tool_timeout_sec: 1921 }
|
||||
: { toolTimeoutMs: 1_920_001 }),
|
||||
env: { REA_ANALYSIS_PROVIDER: provider, REA_IDA_MCP_CONFIG: idaConfig },
|
||||
});
|
||||
expect(text).not.toContain("fixture-credential");
|
||||
const run = (arguments_: readonly string[]) =>
|
||||
cli.run({ arguments: arguments_, cwd: home, environment });
|
||||
const healthy = await run(["doctor", "--client", client, "--json"]);
|
||||
expect(healthy.exitCode, healthy.stdout).toBe(0);
|
||||
expect(JSON.stringify(healthy.json)).not.toContain('"advisory"');
|
||||
|
||||
// Only the stored client's provider path identifies this changed budget;
|
||||
// the doctor/setup process has no IDA configuration in its environment.
|
||||
await writeRegistration(2_400_001);
|
||||
const doctor = await run(["doctor", "--client", client, "--json"]);
|
||||
expect(doctor.exitCode, doctor.stdout).toBe(0);
|
||||
expect(doctor.json).toMatchObject({
|
||||
identity: {
|
||||
registrations: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
client,
|
||||
state: "aligned",
|
||||
advisory: expect.stringContaining(
|
||||
client === "codex"
|
||||
? "tool_timeout_sec = 2521 seconds"
|
||||
: "toolTimeoutMs = 2520001 ms",
|
||||
),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
const repeated = await run(setupArguments);
|
||||
expect(repeated.exitCode, repeated.stdout).toBe(0);
|
||||
expect(repeated.json).toMatchObject({
|
||||
status: "ready",
|
||||
plannedActions: [],
|
||||
appliedActions: [],
|
||||
});
|
||||
expect(await readFile(config, "utf8")).toBe(text);
|
||||
await expect(access(`${config}.rea.backup`)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
if (provider === "ida") {
|
||||
await writeRegistration(0);
|
||||
const invalidDoctor = await run([
|
||||
"doctor",
|
||||
"--client",
|
||||
client,
|
||||
"--json",
|
||||
]);
|
||||
expect(invalidDoctor.exitCode).toBe(1);
|
||||
expect(invalidDoctor.json).toMatchObject({
|
||||
identity: {
|
||||
registrations: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
client,
|
||||
state: "invalid",
|
||||
remediation: expect.stringContaining("REA_IDA_MCP_CONFIG"),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
const invalidSetup = await run(setupArguments);
|
||||
expect(invalidSetup.exitCode).toBe(1);
|
||||
expect(invalidSetup.stdout).toContain("REA_IDA_MCP_CONFIG");
|
||||
expect(await readFile(config, "utf8")).toBe(text);
|
||||
await expect(access(`${config}.rea.backup`)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
}
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
|
||||
for (const invalidSetting of [
|
||||
{ setting: "REA_MCP_INPUT_SCHEMA_PROFILE", value: "unknown" },
|
||||
{ setting: "REA_GHIDRA_STARTUP_TIMEOUT_MS", value: "0" },
|
||||
{ setting: "REA_JEB_MCP_TIMEOUT_MS", value: "1.5" },
|
||||
] as const)
|
||||
cliTest(
|
||||
`setup rejects malformed selected ${invalidSetting.setting} before writing`,
|
||||
async ({ cli }) => {
|
||||
const home = await createTestTempDirectory(
|
||||
"rea-invalid-selected-registration-env-",
|
||||
);
|
||||
const kimiHome = join(home, "kimi");
|
||||
await mkdir(kimiHome, { recursive: true });
|
||||
const config = join(kimiHome, "mcp.json");
|
||||
const environment = {
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
XDG_CONFIG_HOME: join(home, ".config"),
|
||||
KIMI_CODE_HOME: kimiHome,
|
||||
HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"),
|
||||
[invalidSetting.setting]: invalidSetting.value,
|
||||
};
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"setup",
|
||||
"--client",
|
||||
"kimi",
|
||||
"--skill=false",
|
||||
"--yes",
|
||||
"--json",
|
||||
],
|
||||
cwd: home,
|
||||
environment,
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "configuration_invalid",
|
||||
category: "invalid_input",
|
||||
details: {
|
||||
settings: [
|
||||
expect.objectContaining({ setting: invalidSetting.setting }),
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(`${result.stdout}\n${result.stderr}`).toContain(
|
||||
invalidSetting.setting,
|
||||
);
|
||||
await expect(access(config)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(access(`${config}.rea.backup`)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
@@ -65,11 +65,16 @@ describe("headless IDA timeout through the one-shot CLI", () => {
|
||||
expect(result.json).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
provider_id: "ida",
|
||||
cleanup: "incomplete",
|
||||
execution_failure: "provider_timeout",
|
||||
operation: "idb_open",
|
||||
timeout_ms: 500,
|
||||
resources: [expect.stringContaining(workspaceRoot)],
|
||||
cleanup_error: {
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
provider_id: "ida",
|
||||
operation: "close",
|
||||
diagnostics: {
|
||||
upstream_sessions: {
|
||||
state: "unverified",
|
||||
|
||||
@@ -112,6 +112,7 @@ describe("client configuration filesystem lifecycle", () => {
|
||||
expect(configured).toContain("[mcp_servers.rea]");
|
||||
expect(configured).toContain('command = "rea"');
|
||||
expect(configured).toContain("startup_timeout_sec = 30");
|
||||
expect(configured).toContain("tool_timeout_sec = 450");
|
||||
expect(configured).toContain('GHIDRA_INSTALL_DIR = "/opt/ghidra"');
|
||||
expect(configured).toContain('JAVA_HOME = "/opt/jdk-21"');
|
||||
expect(
|
||||
@@ -134,7 +135,7 @@ describe("client configuration filesystem lifecycle", () => {
|
||||
await mkdir(dirname(configPath), { recursive: true });
|
||||
await writeFile(
|
||||
configPath,
|
||||
`[mcp_servers.rea]\ncommand = "rea"\nargs = ["mcp"]\nstartup_timeout_sec = 30\n\n[mcp_servers.rea.env]\nJAVA_HOME = "/opt/jdk-21"\nHOPPER_LAUNCHER_PATH = "/Hopper Path"\nGHIDRA_INSTALL_DIR = "/opt/ghidra"\n`,
|
||||
`[mcp_servers.rea]\ncommand = "rea"\nargs = ["mcp"]\nstartup_timeout_sec = 30\ntool_timeout_sec = 450\n\n[mcp_servers.rea.env]\nJAVA_HOME = "/opt/jdk-21"\nHOPPER_LAUNCHER_PATH = "/Hopper Path"\nGHIDRA_INSTALL_DIR = "/opt/ghidra"\n`,
|
||||
);
|
||||
|
||||
const original = await readFile(configPath, "utf8");
|
||||
@@ -154,7 +155,52 @@ describe("client configuration filesystem lifecycle", () => {
|
||||
readFile(`${configPath}.rea.backup`, "utf8"),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codex MCP tool deadlines", () => {
|
||||
it("derives the Codex tool deadline from an explicit Ghidra startup allowance", async () => {
|
||||
const home = await createTestTempDirectory("rea-codex-long-timeout-");
|
||||
roots.push(home);
|
||||
const configPath = join(home, ".codex/config.toml");
|
||||
await mkdir(dirname(configPath), { recursive: true });
|
||||
expect(
|
||||
await configureClientConfiguration(
|
||||
{ name: "codex", configPath, format: "toml" },
|
||||
{ REA_GHIDRA_STARTUP_TIMEOUT_MS: "2700001" },
|
||||
["rea", "mcp"],
|
||||
),
|
||||
).toMatchObject({ status: "configured" });
|
||||
expect(await readFile(configPath, "utf8")).toContain(
|
||||
"tool_timeout_sec = 2821",
|
||||
);
|
||||
expect(await readFile(configPath, "utf8")).toContain(
|
||||
'REA_GHIDRA_STARTUP_TIMEOUT_MS = "2700001"',
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves positive caller-selected Codex startup and tool deadlines", async () => {
|
||||
const home = await createTestTempDirectory("rea-codex-custom-timeout-");
|
||||
roots.push(home);
|
||||
const configPath = join(home, ".codex/config.toml");
|
||||
await mkdir(dirname(configPath), { recursive: true });
|
||||
await writeFile(
|
||||
configPath,
|
||||
'[mcp_servers.rea]\ncommand = "old-rea"\nargs = ["mcp"]\nstartup_timeout_sec = 5\ntool_timeout_sec = 10\n',
|
||||
);
|
||||
expect(
|
||||
await configureClientConfiguration(
|
||||
{ name: "codex", configPath, format: "toml" },
|
||||
{},
|
||||
["rea", "mcp"],
|
||||
),
|
||||
).toMatchObject({ status: "configured" });
|
||||
const configured = await readFile(configPath, "utf8");
|
||||
expect(configured).toContain("startup_timeout_sec = 5");
|
||||
expect(configured).toContain("tool_timeout_sec = 10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("client configuration filesystem lifecycle", () => {
|
||||
it("updates a symlink target without replacing the TOML config symlink", async () => {
|
||||
const home = await createTestTempDirectory("rea-toml-symlink-");
|
||||
roots.push(home);
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("client registration status", () => {
|
||||
]);
|
||||
await writeFile(
|
||||
join(home, ".codex/config.toml"),
|
||||
`[mcp_servers.rea]\ncommand = "npx"\nargs = ["-y", "${PRODUCT_IDENTITY.registrationPackageSpecifier}", "mcp"]\nstartup_timeout_sec = 30\n`,
|
||||
`[mcp_servers.rea]\ncommand = "npx"\nargs = ["-y", "${PRODUCT_IDENTITY.registrationPackageSpecifier}", "mcp"]\nstartup_timeout_sec = 30\ntool_timeout_sec = 450\n`,
|
||||
);
|
||||
await writeFile(
|
||||
join(home, ".cursor/mcp.json"),
|
||||
@@ -309,14 +309,14 @@ describe("commandcode registration status", () => {
|
||||
|
||||
describe("Node-wrapped registration policy", () => {
|
||||
it.each([1, 30])(
|
||||
"checks Codex startup timeout %d independently of launcher",
|
||||
"accepts Codex caller-selected startup timeout %d independently of launcher",
|
||||
async (timeout) => {
|
||||
const home = await createTestTempDirectory("rea-node-registration-");
|
||||
await mkdir(join(home, ".codex"));
|
||||
const entry = resolve("scripts/rea.mjs");
|
||||
await writeFile(
|
||||
join(home, ".codex/config.toml"),
|
||||
`[mcp_servers.rea]\ncommand = ${JSON.stringify(process.execPath)}\nargs = [${JSON.stringify(entry)}, "mcp"]\nstartup_timeout_sec = ${String(timeout)}\n`,
|
||||
`[mcp_servers.rea]\ncommand = ${JSON.stringify(process.execPath)}\nargs = [${JSON.stringify(entry)}, "mcp"]\nstartup_timeout_sec = ${String(timeout)}\ntool_timeout_sec = 450\n`,
|
||||
);
|
||||
const statuses = await readClientRegistrationStatuses(home, entry, {
|
||||
environment: {},
|
||||
@@ -324,12 +324,30 @@ describe("Node-wrapped registration policy", () => {
|
||||
expect(statuses).toEqual([
|
||||
expect.objectContaining({
|
||||
client: "codex",
|
||||
state: timeout === 30 ? "aligned" : "stale",
|
||||
state: "aligned",
|
||||
}),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a short positive Codex tool timeout aligned and reports an advisory", async () => {
|
||||
const home = await createTestTempDirectory("rea-codex-short-tool-timeout-");
|
||||
await mkdir(join(home, ".codex"));
|
||||
const entry = resolve("scripts/rea.mjs");
|
||||
await writeFile(
|
||||
join(home, ".codex/config.toml"),
|
||||
`[mcp_servers.rea]\ncommand = ${JSON.stringify(process.execPath)}\nargs = [${JSON.stringify(entry)}, "mcp"]\nstartup_timeout_sec = 5\ntool_timeout_sec = 10\n`,
|
||||
);
|
||||
const [status] = await readClientRegistrationStatuses(home, entry, {
|
||||
environment: {},
|
||||
});
|
||||
expect(status).toMatchObject({
|
||||
client: "codex",
|
||||
state: "aligned",
|
||||
advisory: expect.stringContaining("tool_timeout_sec = 450 seconds"),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([1, 30])(
|
||||
"checks Grok Build startup timeout %d independently of launcher",
|
||||
async (timeout) => {
|
||||
|
||||
@@ -470,14 +470,14 @@ const verifyWindowsSnapshotAdmission = async (
|
||||
};
|
||||
|
||||
it.each([
|
||||
{ tool: "binary_overview", arguments: {}, priorRevision: 3 },
|
||||
{ tool: "binary_overview", arguments: {}, priorRevision: 4 },
|
||||
{
|
||||
tool: "inspect_native_api",
|
||||
arguments: { procedure: "0x1000" },
|
||||
priorRevision: undefined,
|
||||
priorRevision: 1,
|
||||
},
|
||||
] as const)(
|
||||
"refreshes pre-coverage $tool Evidence and replays the current revision",
|
||||
"refreshes unattributed $tool Evidence and replays the current revision",
|
||||
async (scenario) => {
|
||||
const fixture = await createSnapshotReplayFixture("rea-prior-coverage-");
|
||||
const limitation = "Original PDB names may be missing.";
|
||||
@@ -523,7 +523,9 @@ it.each([
|
||||
options,
|
||||
),
|
||||
);
|
||||
expect(refreshed.limitations).toContain(limitation);
|
||||
expect(refreshed.limitations).toEqual(
|
||||
expect.arrayContaining(first.limitations),
|
||||
);
|
||||
expect(refreshed.analysis_profile?.digest).not.toBe(priorProfile.digest);
|
||||
expect(refreshed.normalized_result).toEqual(first.normalized_result);
|
||||
const starts = [...fixture.starts];
|
||||
|
||||
@@ -41,12 +41,13 @@ const exists = async (path: string): Promise<boolean> => {
|
||||
const expectedRegistration = (
|
||||
launcher: readonly string[],
|
||||
profile = "compact",
|
||||
toolTimeoutMs = 450_000,
|
||||
) => ({
|
||||
command: launcher[0],
|
||||
args: launcher.slice(1),
|
||||
env: { REA_MCP_INPUT_SCHEMA_PROFILE: profile },
|
||||
startupTimeoutMs: MCP_STARTUP_POLICY.kimiStartupTimeoutMs,
|
||||
toolTimeoutMs: MCP_STARTUP_POLICY.kimiToolTimeoutMs,
|
||||
toolTimeoutMs,
|
||||
});
|
||||
|
||||
describe("Kimi Code configuration paths and skills", () => {
|
||||
@@ -111,6 +112,68 @@ describe("Kimi Code registration lifecycle", () => {
|
||||
).toBe("unchanged");
|
||||
});
|
||||
|
||||
it("preserves selected timeout values through refresh and keeps backups idempotent", async () => {
|
||||
const { home, client } = await kimiClient();
|
||||
const original = JSON.stringify({
|
||||
mcpServers: {
|
||||
rea: {
|
||||
...expectedRegistration(["old-rea", "mcp"]),
|
||||
startupTimeoutMs: 90_000,
|
||||
toolTimeoutMs: 300_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
await writeFile(client.configPath, original);
|
||||
|
||||
expect(
|
||||
await configureClientConfiguration(client, {}, command),
|
||||
).toMatchObject({
|
||||
status: "configured",
|
||||
backupPath: `${client.configPath}.rea.backup`,
|
||||
});
|
||||
expect(
|
||||
JSON.parse(await readFile(client.configPath, "utf8")).mcpServers.rea,
|
||||
).toEqual({
|
||||
...expectedRegistration(command),
|
||||
startupTimeoutMs: 90_000,
|
||||
toolTimeoutMs: 300_000,
|
||||
});
|
||||
expect(await readFile(`${client.configPath}.rea.backup`, "utf8")).toBe(
|
||||
original,
|
||||
);
|
||||
expect(await configureClientConfiguration(client, {}, command)).toEqual({
|
||||
status: "unchanged",
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await readClientRegistrationStatuses(home, undefined, {
|
||||
environment: {},
|
||||
})
|
||||
).find(({ client: name }) => name === "kimi"),
|
||||
).toMatchObject({
|
||||
state: "aligned",
|
||||
advisory: expect.stringContaining("toolTimeoutMs = 450000 ms"),
|
||||
});
|
||||
});
|
||||
|
||||
it("derives a client deadline beyond Node's timer ceiling for an alternative provider selector", async () => {
|
||||
const { client } = await kimiClient();
|
||||
expect(
|
||||
await configureClientConfiguration(
|
||||
client,
|
||||
{
|
||||
REA_ANALYSIS_PROVIDER: "ida",
|
||||
REA_GHIDRA_STARTUP_TIMEOUT_MS: "2147483647",
|
||||
},
|
||||
command,
|
||||
),
|
||||
).toMatchObject({ status: "configured" });
|
||||
expect(
|
||||
JSON.parse(await readFile(client.configPath, "utf8")).mcpServers.rea
|
||||
.toolTimeoutMs,
|
||||
).toBe(2_147_603_647);
|
||||
});
|
||||
|
||||
it("registers without type, preserves unrelated settings, backs up, and is idempotent", async () => {
|
||||
const { home, client } = await kimiClient();
|
||||
const original = `{
|
||||
@@ -267,7 +330,7 @@ describe("Kimi Code input-schema profile selection", () => {
|
||||
command: command[0],
|
||||
args: command.slice(1),
|
||||
startupTimeoutMs: MCP_STARTUP_POLICY.kimiStartupTimeoutMs,
|
||||
toolTimeoutMs: MCP_STARTUP_POLICY.kimiToolTimeoutMs,
|
||||
toolTimeoutMs: 120_000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -285,7 +348,7 @@ describe("Kimi Code input-schema profile selection", () => {
|
||||
).toBe("configured");
|
||||
expect(
|
||||
JSON.parse(await readFile(client.configPath, "utf8")).mcpServers.rea,
|
||||
).toEqual(expectedRegistration(command));
|
||||
).toEqual(expectedRegistration(command, "compact", 120_000));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -439,7 +502,7 @@ describe("Kimi Code transport validation", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("Kimi Code timeout validation", () => {
|
||||
describe("Kimi Code timeout alignment", () => {
|
||||
const kimiClient = async () => {
|
||||
const home = await createTestTempDirectory("rea-kimi-timeout-");
|
||||
const client = kimiPath(home, process.platform, {});
|
||||
@@ -450,11 +513,11 @@ describe("Kimi Code timeout validation", () => {
|
||||
};
|
||||
|
||||
it.each([
|
||||
["startupTimeoutMs", 1],
|
||||
["toolTimeoutMs", 1],
|
||||
["startupTimeoutMs", undefined],
|
||||
["toolTimeoutMs", undefined],
|
||||
] as const)("reports stale when %s is %j", async (field, value) => {
|
||||
["startupTimeoutMs", 1, "aligned"],
|
||||
["toolTimeoutMs", 1, "aligned"],
|
||||
["startupTimeoutMs", undefined, "stale"],
|
||||
["toolTimeoutMs", undefined, "stale"],
|
||||
] as const)("reports %s timeout %j as %s", async (field, value, state) => {
|
||||
const { home, client } = await kimiClient();
|
||||
const registration = {
|
||||
...expectedRegistration(command),
|
||||
@@ -470,7 +533,7 @@ describe("Kimi Code timeout validation", () => {
|
||||
environment: {},
|
||||
})
|
||||
).find(({ client: name }) => name === "kimi"),
|
||||
).toMatchObject({ state: "stale" });
|
||||
).toMatchObject({ state });
|
||||
});
|
||||
|
||||
it("uses a direct installed REA command without requiring a package runner", async () => {
|
||||
|
||||
@@ -124,8 +124,8 @@ describe("enhanced MCP graph caveats", () => {
|
||||
limitations: [
|
||||
"Artifact identity is unavailable for this observation.",
|
||||
"Derived by an REA composed workflow.",
|
||||
caveat,
|
||||
leafCaveat,
|
||||
`Fixture analysis provider (fixture 1) procedure_callees: ${caveat}`,
|
||||
`Fixture analysis provider (fixture 1) procedure_callees: ${leafCaveat}`,
|
||||
],
|
||||
normalized_result: {
|
||||
graph: {
|
||||
@@ -153,9 +153,6 @@ describe("enhanced MCP graph caveats", () => {
|
||||
const normalized = JSON.stringify(evidence.normalized_result);
|
||||
for (const item of [caveat, leafCaveat]) {
|
||||
expect(normalized.split(item)).toHaveLength(2);
|
||||
expect(
|
||||
evidence.limitations.filter((limit) => limit === item),
|
||||
).toHaveLength(1);
|
||||
}
|
||||
|
||||
const trace = await client.callTool({
|
||||
@@ -172,7 +169,11 @@ describe("enhanced MCP graph caveats", () => {
|
||||
});
|
||||
const traceEvidence = parseEvidence(trace.structuredContent);
|
||||
expect(
|
||||
traceEvidence.limitations.filter((item) => item === caveat),
|
||||
traceEvidence.limitations.filter(
|
||||
(item) =>
|
||||
item ===
|
||||
`Fixture analysis provider (fixture 1) procedure_callees: ${caveat}`,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(JSON.stringify(traceEvidence)).not.toContain(leafCaveat);
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ it("retains provider coverage caveats in a composed MCP overview without repeate
|
||||
expect(evidence.limitations).toEqual([
|
||||
"Artifact identity is unavailable for this observation.",
|
||||
"Derived by an REA composed workflow.",
|
||||
limitation,
|
||||
`Fixture (fixture 1) current_document, list_procedures, list_segments, list_strings: ${limitation}`,
|
||||
]);
|
||||
expect(evidence.normalized_result).toMatchObject({
|
||||
document: "fixture",
|
||||
|
||||
@@ -4,7 +4,10 @@ import { EnhancedTools } from "../../../src/application/EnhancedTools.js";
|
||||
import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js";
|
||||
import { functionDossierSchema } from "../../../src/domain/hopperValues.js";
|
||||
import { functionComparisonResultSchema } from "../../../src/domain/functionComparisonSchemas.js";
|
||||
import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js";
|
||||
import {
|
||||
ghidraFunctionDossier,
|
||||
ghidraFunctionIdentity,
|
||||
} from "../../../src/domain/ghidraValues.fixture.js";
|
||||
import {
|
||||
jsonValueSchema,
|
||||
type JsonValue,
|
||||
@@ -186,7 +189,7 @@ it("retains terminal-call qualifications across function views and snapshot repl
|
||||
expect(evidence.limitations, name).toContain(limitation);
|
||||
expect(evidence.analysis_profile?.parameters).toMatchObject({
|
||||
function_boundary_observations:
|
||||
"ghidra-function-boundary-limitations-v3",
|
||||
"ghidra-function-boundary-limitations-v4",
|
||||
});
|
||||
const replay = await harness.session.execute(name, evidence.parameters);
|
||||
if (!replay.ok) throw replay.error;
|
||||
@@ -202,6 +205,61 @@ it("retains terminal-call qualifications across function views and snapshot repl
|
||||
}
|
||||
});
|
||||
|
||||
it("retains handler-region limitations on incoming and outgoing reference Evidence", async () => {
|
||||
const limitation =
|
||||
"Ghidra Listing shows a Borland/Delphi-style SEH frame registration at 0x40108f (push 0x4010c0 followed by fs-segment stack chaining); handler 0x4010c0 lies outside this procedure's body, so callees, strings, and cross-references may omit the handler region.";
|
||||
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
const harness = await connectGhidraMcp(
|
||||
"ghidra-seh-reference-evidence",
|
||||
(operation, input) => {
|
||||
if (operation !== "procedure_references")
|
||||
throw new Error(`Unexpected operation: ${operation}`);
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
value: jsonValueSchema.parse({
|
||||
procedure: ghidraFunctionIdentity(),
|
||||
direction: input.direction ?? "outgoing",
|
||||
references: [],
|
||||
reference_kinds_available: true,
|
||||
unresolved_calls: [],
|
||||
}),
|
||||
limitations: [limitation],
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
try {
|
||||
for (const direction of ["incoming", "outgoing"] as const) {
|
||||
const reply = await harness.mcp.callTool({
|
||||
name: "procedure_references",
|
||||
arguments: { procedure: dossier.procedure.address, direction },
|
||||
});
|
||||
expect(reply.isError).not.toBe(true);
|
||||
const evidence = sessionEvidence(
|
||||
harness.session,
|
||||
reply.structuredContent,
|
||||
);
|
||||
expect(evidence.normalized_result).toMatchObject({ direction });
|
||||
expect(evidence.raw_result).toMatchObject({ limitations: [limitation] });
|
||||
expect(evidence.limitations).toContain(limitation);
|
||||
const replay = await harness.session.execute(
|
||||
"procedure_references",
|
||||
evidence.parameters,
|
||||
);
|
||||
if (!replay.ok) throw replay.error;
|
||||
expect(replay.value.limitations).toContain(limitation);
|
||||
}
|
||||
expect(harness.calls).toEqual([
|
||||
"procedure_references",
|
||||
"procedure_references",
|
||||
"procedure_references",
|
||||
"procedure_references",
|
||||
]);
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects contradictory annotation readback across the provider and MCP boundaries", async () => {
|
||||
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
const annotations = {
|
||||
|
||||
@@ -274,6 +274,7 @@ const functionObservationOperations: ReadonlySet<GhidraOperation> = new Set([
|
||||
"procedure_callers",
|
||||
"procedure_info",
|
||||
"procedure_pseudo_code",
|
||||
"procedure_references",
|
||||
"read_function_instructions",
|
||||
]);
|
||||
|
||||
|
||||
@@ -409,7 +409,11 @@ describe("active-target availability over MCP", () => {
|
||||
const session = createTestBinarySession(
|
||||
availabilityProvider(["inspect_macho", "inspect_artifact"]),
|
||||
);
|
||||
const server = createServer({ kind: "session", session });
|
||||
// Availability does not depend on compiling every advertised output schema.
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{ environment: { REA_MCP_OUTPUT_SCHEMA_PROFILE: "off" } },
|
||||
);
|
||||
const client = new Client({ name: "availability-test", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
|
||||
@@ -76,7 +76,7 @@ it("reads every JEB catalog page and refreshes the connected engine catalog", as
|
||||
throw new Error("Expected an ephemeral TCP listener");
|
||||
const connection = createStreamableHttpJebMcpConnection(
|
||||
new URL(`http://127.0.0.1:${address.port}/mcp`),
|
||||
1_000,
|
||||
{ discoveryTimeoutMs: 1_000 },
|
||||
);
|
||||
try {
|
||||
expect(await connection.listTools()).toEqual([
|
||||
@@ -109,7 +109,7 @@ it("reads every JEB catalog page and refreshes the connected engine catalog", as
|
||||
// Shared initial acquisition also owns a finite deadline and can recover.
|
||||
const acquiring = createStreamableHttpJebMcpConnection(
|
||||
new URL(`http://127.0.0.1:${address.port}/mcp`),
|
||||
1_000,
|
||||
{ discoveryTimeoutMs: 1_000 },
|
||||
);
|
||||
try {
|
||||
infinitePages = true;
|
||||
|
||||
@@ -42,7 +42,10 @@ it("retains provider coverage caveats in a composed CLI result and closes the se
|
||||
{},
|
||||
);
|
||||
expect(output).toMatchObject({
|
||||
limitations: ["Derived by an REA composed workflow.", limitation],
|
||||
limitations: [
|
||||
"Derived by an REA composed workflow.",
|
||||
`Fixture (fixture 1) current_document, list_procedures, list_segments, list_strings: ${limitation}`,
|
||||
],
|
||||
normalized_result: {
|
||||
document: "fixture",
|
||||
segment_count: 0,
|
||||
|
||||
+17
@@ -97,6 +97,23 @@ int wmain(int count, wchar_t** args) {
|
||||
? "true" : "false", observed);
|
||||
return 0;
|
||||
}
|
||||
if (count == 3 && std::wstring(args[1]) == L"volume-root-append-lease") {
|
||||
// A real directory append right participates in Windows' write-share
|
||||
// check. The lease performs no mutation; it stays open while the native
|
||||
// admission probe requests the same volume root for directory listing.
|
||||
HANDLE root = CreateFileW(args[2], FILE_ADD_SUBDIRECTORY,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr,
|
||||
OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, nullptr);
|
||||
if (root == INVALID_HANDLE_VALUE) {
|
||||
std::printf("{\"writeAccessGranted\":false,\"win32Code\":%lu}\n", GetLastError());
|
||||
return 0;
|
||||
}
|
||||
std::puts("{\"writeAccessGranted\":true}");
|
||||
std::fflush(stdout);
|
||||
const int release = std::getchar();
|
||||
CloseHandle(root);
|
||||
return release == 'x' || release == EOF ? 0 : 10;
|
||||
}
|
||||
if (count > 1 && std::wstring(args[1]) == L"breakaway") {
|
||||
wchar_t executable[32768]; GetModuleFileNameW(nullptr, executable, 32768);
|
||||
std::wstring line = L"\"" + std::wstring(executable) + L"\" exit";
|
||||
|
||||
Reference in new issue
Block a user