Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d874f8a70 | ||
|
|
0b46b37d37 | ||
|
|
84b35d8e7f | ||
|
|
3afb5412fb | ||
|
|
554898f859 | ||
|
|
f78d137b42 | ||
|
|
1a3a96d36b | ||
|
|
fa318995f5 | ||
|
|
9a44fea347 | ||
|
|
2819defeea | ||
|
|
937b4e69c8 | ||
|
|
1b668a527d | ||
|
|
1e377e6965 | ||
|
|
fb6e2b07e0 | ||
|
|
c4608a2962 | ||
|
|
225f26a5f8 | ||
|
|
a004061abc | ||
|
|
01a1b3fda6 | ||
|
|
3d2846f237 |
No files matched your search
+36
-944
@@ -1,959 +1,51 @@
|
||||
name: CI
|
||||
|
||||
name: Experimental Windows readonly fixture mutation oracle
|
||||
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"
|
||||
|
||||
actions: read
|
||||
jobs:
|
||||
changes:
|
||||
name: Classify changes
|
||||
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
|
||||
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' }}
|
||||
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"]') }}
|
||||
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
|
||||
- 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"
|
||||
fi
|
||||
done
|
||||
sudo apt-get update
|
||||
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/
|
||||
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
|
||||
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
|
||||
with:
|
||||
name: windows-process-boundary-fixture
|
||||
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:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
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: [without-normal-fix, fixed]
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
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
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
name: windows-native-controls
|
||||
github-token: ${{ github.token }}
|
||||
run-id: ${{ matrix.variant == 'fixed' && '38662148662' || '38660840144' }}
|
||||
name: windows-creation-owner-native-fixed
|
||||
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
|
||||
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
|
||||
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
|
||||
- name: Verify permanent fixture detects missing NORMAL fallback
|
||||
shell: bash
|
||||
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
|
||||
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
|
||||
EXPECTED_VARIANT: ${{ matrix.variant }}
|
||||
run: |
|
||||
node --input-type=module <<'JS'
|
||||
import assert from 'node:assert/strict';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
const result=spawnSync(process.execPath,['scripts/verify-windows-native.mjs','.'],{encoding:'utf8',timeout:180000});
|
||||
if(result.error)throw result.error;
|
||||
process.stdout.write(result.stdout);
|
||||
process.stderr.write(result.stderr);
|
||||
if(process.env.EXPECTED_VARIANT==='fixed'){
|
||||
assert.equal(result.status,0);
|
||||
const observed=JSON.parse(result.stdout.trim());
|
||||
assert.equal(observed.ok,true);
|
||||
assert.equal(observed.controls.readonlyFileWithoutArchiveAttributeRemoved,true);
|
||||
}else{
|
||||
assert.notEqual(result.status,0);
|
||||
assert.match(result.stderr,/Delete owned object by handle failed/);
|
||||
assert.match(result.stderr,/Win32 5/);
|
||||
}
|
||||
console.log(JSON.stringify({ok:true,variant:process.env.EXPECTED_VARIANT,permanentVerifierStatus:result.status,baselineMissingNormalDetected:process.env.EXPECTED_VARIANT!=='fixed'}));
|
||||
JS
|
||||
@@ -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" +
|
||||
|
||||
@@ -148,8 +148,10 @@ 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. Setup forwards these explicitly selected REA deadlines and the input
|
||||
schema profile into the registration; unrelated process values are omitted.
|
||||
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
|
||||
@@ -241,8 +243,9 @@ 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. These values are
|
||||
client-side deadlines, so increase them in `~/.codex/config.toml` if a
|
||||
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.
|
||||
|
||||
|
||||
@@ -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. An upstream SDK request that reaches this deadline returns `provider_timeout` with its operation and configured budget. 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. Expiry of this polling deadline while the worker is still busy also 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
|
||||
|
||||
|
||||
+35
-15
@@ -60,6 +60,15 @@ napi_value wrap(napi_env env, std::unique_ptr<Resource> value) {
|
||||
value.release();
|
||||
return result;
|
||||
}
|
||||
void publishRuntimeHandle(napi_env env, napi_value observation, std::unique_ptr<Runtime>& value) {
|
||||
auto handle = object(env);
|
||||
check(napi_type_tag_object(env, handle, &RESOURCE_TAG));
|
||||
// Complete the result before transferring cleanup ownership. Any earlier
|
||||
// N-API failure leaves the unpublished runtime in its environment owner.
|
||||
set(env, observation, "handle", handle);
|
||||
check(napi_wrap(env, handle, value.get(), finalize, nullptr, nullptr));
|
||||
value.release();
|
||||
}
|
||||
Resource& resource(napi_env env, napi_value value, Kind kind) {
|
||||
bool tagged = false;
|
||||
check(napi_check_object_type_tag(env, value, &RESOURCE_TAG, &tagged));
|
||||
@@ -77,8 +86,15 @@ napi_value failureValue(napi_env env, const Failure& failure) {
|
||||
std::wstring detail = message == nullptr ? L"" : message;
|
||||
if (message != nullptr) LocalFree(message);
|
||||
const std::wstring constraint(failure.constraint.begin(), failure.constraint.end());
|
||||
auto text = string(env, constraint + (failure.path.empty() ? L"" : L" [" + failure.path + L"]") +
|
||||
L" (Win32 " + std::to_wstring(failure.win32) + L"): " + detail);
|
||||
std::wstring renderedMessage = constraint + (failure.path.empty() ? L"" : L" [" + failure.path + L"]") +
|
||||
L" (Win32 " + std::to_wstring(failure.win32) + L"): " + detail;
|
||||
if (!failure.cleanupConstraint.empty()) {
|
||||
const std::wstring cleanup(failure.cleanupConstraint.begin(), failure.cleanupConstraint.end());
|
||||
renderedMessage += L"; cleanup also failed: " + cleanup +
|
||||
(failure.cleanupPath.empty() ? L"" : L" [" + failure.cleanupPath + L"]") +
|
||||
L" (Win32 " + std::to_wstring(failure.cleanupWin32) + L")";
|
||||
}
|
||||
auto text = string(env, renderedMessage);
|
||||
napi_value error; napi_create_error(env, nullptr, text, &error);
|
||||
const char* code = failure.win32 == ERROR_FILE_NOT_FOUND || failure.win32 == ERROR_PATH_NOT_FOUND ? "ENOENT"
|
||||
: failure.win32 == ERROR_ACCESS_DENIED ? "EACCES" : "ERR_REA_WINDOWS_NATIVE";
|
||||
@@ -87,21 +103,24 @@ napi_value failureValue(napi_env env, const Failure& failure) {
|
||||
set(env, error, "requestedPath", string(env, failure.path));
|
||||
set(env, error, "win32Code", number(env, failure.win32));
|
||||
set(env, error, "win32Message", string(env, detail));
|
||||
if (!failure.cleanupConstraint.empty()) {
|
||||
set(env, error, "cleanupConstraint", string(env, failure.cleanupConstraint));
|
||||
set(env, error, "cleanupRequestedPath", string(env, failure.cleanupPath));
|
||||
set(env, error, "cleanupWin32Code", number(env, failure.cleanupWin32));
|
||||
}
|
||||
if (failure.runtimeDirectoryUnallocated)
|
||||
set(env, error, "runtimeAllocation", string(env, std::string("not-started")));
|
||||
return error;
|
||||
}
|
||||
|
||||
struct InspectionContext { std::unique_ptr<Runtime> probe; };
|
||||
|
||||
static void cleanupInspection(void* data) noexcept {
|
||||
std::unique_ptr<InspectionContext> context(static_cast<InspectionContext*>(data));
|
||||
if (!context->probe) return;
|
||||
try { closeRuntime(*context->probe); }
|
||||
static void cleanupRuntimeContext(void* data) noexcept {
|
||||
std::unique_ptr<RuntimeContext> context(static_cast<RuntimeContext*>(data));
|
||||
if (!context->pending) return;
|
||||
try { closeRuntime(*context->pending); }
|
||||
catch (const Failure& failure) {
|
||||
// Environment teardown cannot retain an owner or return an error. Report
|
||||
// terminal cleanup failure instead of claiming the directory was removed.
|
||||
std::fprintf(stderr, "REA Windows probe cleanup failed (Win32 %lu) path=\"",
|
||||
std::fprintf(stderr, "REA Windows runtime cleanup failed (Win32 %lu) path=\"",
|
||||
static_cast<unsigned long>(failure.win32));
|
||||
// Emit reversible UTF-16 escapes without allocation or CRT locale conversion.
|
||||
for (wchar_t unit : failure.path) {
|
||||
@@ -112,10 +131,10 @@ static void cleanupInspection(void* data) noexcept {
|
||||
std::fprintf(stderr, "\"\n");
|
||||
}
|
||||
catch (const std::exception& failure) {
|
||||
std::fprintf(stderr, "REA Windows probe cleanup failed: %s\n", failure.what());
|
||||
std::fprintf(stderr, "REA Windows runtime cleanup failed: %s\n", failure.what());
|
||||
}
|
||||
catch (...) {
|
||||
std::fprintf(stderr, "REA Windows probe cleanup failed: unknown native exception\n");
|
||||
std::fprintf(stderr, "REA Windows runtime cleanup failed: unknown native exception\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,12 +147,13 @@ static napi_value call(napi_env env, napi_callback_info info) {
|
||||
require(count == 2, "Native call requires operation and arguments", L"", ERROR_INVALID_PARAMETER);
|
||||
const auto operation = wide(env, args[0]);
|
||||
const auto values = array(env, args[1]);
|
||||
auto& context = *static_cast<RuntimeContext*>(data);
|
||||
if (operation == L"inspect") {
|
||||
require(values.empty(), "Inspect takes no arguments", L"", ERROR_INVALID_PARAMETER);
|
||||
return inspect(env, static_cast<InspectionContext*>(data)->probe);
|
||||
return inspect(env, context.pending);
|
||||
}
|
||||
if (operation.rfind(L"process_", 0) == 0) return processCall(env, operation, values);
|
||||
return filesystemCall(env, operation, values);
|
||||
return filesystemCall(env, operation, values, context);
|
||||
} catch (const Failure& failure) {
|
||||
auto error = failureValue(env, failure);
|
||||
napi_throw(env, error);
|
||||
@@ -145,8 +165,8 @@ static napi_value call(napi_env env, napi_callback_info info) {
|
||||
}
|
||||
|
||||
static napi_value initialize(napi_env env, napi_value exports) {
|
||||
auto context = std::make_unique<InspectionContext>();
|
||||
if (napi_add_env_cleanup_hook(env, cleanupInspection, context.get()) != napi_ok) return nullptr;
|
||||
auto context = std::make_unique<RuntimeContext>();
|
||||
if (napi_add_env_cleanup_hook(env, cleanupRuntimeContext, context.get()) != napi_ok) return nullptr;
|
||||
// The environment owns callback data before a function can expose it. Failed
|
||||
// initialization still leaves a valid context for the registered cleanup hook.
|
||||
auto* data = context.release();
|
||||
|
||||
@@ -33,6 +33,9 @@ struct Failure : std::runtime_error {
|
||||
DWORD win32;
|
||||
std::wstring path;
|
||||
std::string constraint;
|
||||
std::string cleanupConstraint;
|
||||
DWORD cleanupWin32 = ERROR_SUCCESS;
|
||||
std::wstring cleanupPath;
|
||||
// Affirmative proof for a failed runtime-parent admission; omission is unknown.
|
||||
bool runtimeDirectoryUnallocated = false;
|
||||
Failure(std::string constraint, std::wstring path = L"", DWORD code = GetLastError())
|
||||
@@ -96,6 +99,9 @@ struct Runtime : Resource {
|
||||
std::atomic_bool snapshotCancelled{false};
|
||||
Runtime() : Resource(Kind::Runtime) {}
|
||||
};
|
||||
// A failed, not-yet-published runtime remains owned by its N-API environment
|
||||
// until a later admission or environment teardown can retry cleanup by handle.
|
||||
struct RuntimeContext { std::unique_ptr<Runtime> pending; };
|
||||
struct Process : Resource {
|
||||
Handle job, process, stdoutRead, stderrRead, stdinWrite;
|
||||
// Present only for a job with a commit limit; receives the job's notifications.
|
||||
@@ -120,21 +126,24 @@ std::wstring wide(napi_env env, napi_value value);
|
||||
double numeric(napi_env env, napi_value value);
|
||||
std::vector<napi_value> array(napi_env env, napi_value value);
|
||||
napi_value wrap(napi_env env, std::unique_ptr<Resource> resource);
|
||||
void publishRuntimeHandle(napi_env env, napi_value observation, std::unique_ptr<Runtime>& runtime);
|
||||
Resource& resource(napi_env env, napi_value value, Kind kind);
|
||||
|
||||
std::unique_ptr<File> openFile(const std::wstring& path, DWORD access = GENERIC_READ,
|
||||
bool directory = false,
|
||||
DWORD finalSharing = FILE_SHARE_READ);
|
||||
napi_value identity(napi_env env, HANDLE handle, const std::wstring& path);
|
||||
std::unique_ptr<Runtime> createRuntime(const std::wstring& parent, const std::wstring& prefix);
|
||||
void createRuntime(const std::wstring& parent, const std::wstring& prefix,
|
||||
std::unique_ptr<Runtime>& owner);
|
||||
void cleanupFailedRuntime(std::unique_ptr<Runtime>& owner, Failure& primary);
|
||||
void closeRuntime(Runtime& runtime);
|
||||
napi_value filesystemCall(napi_env env, const std::wstring& operation,
|
||||
const std::vector<napi_value>& args);
|
||||
const std::vector<napi_value>& args, RuntimeContext& context);
|
||||
std::unique_ptr<Process> spawnProcess(const std::wstring& command, const std::wstring& commandLine,
|
||||
const std::wstring& cwd, const std::vector<std::wstring>& environment,
|
||||
bool protocolStdin = false, ULONGLONG memoryLimit = 0);
|
||||
napi_value processCall(napi_env env, const std::wstring& operation,
|
||||
const std::vector<napi_value>& args);
|
||||
napi_value inspect(napi_env env, std::unique_ptr<Runtime>& probe);
|
||||
napi_value inspect(napi_env env, std::unique_ptr<Runtime>& pending);
|
||||
|
||||
} // namespace rea
|
||||
@@ -184,6 +184,29 @@ static Handle createRelative(HANDLE parent, const std::wstring& name, DWORD acce
|
||||
return result;
|
||||
}
|
||||
|
||||
void cleanupFailedRuntime(std::unique_ptr<Runtime>& owner, Failure& primary) {
|
||||
if (!owner || !owner->directory.valid()) {
|
||||
owner.reset();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
closeRuntime(*owner);
|
||||
owner.reset();
|
||||
} catch (const Failure& cleanup) {
|
||||
primary.cleanupConstraint = cleanup.constraint;
|
||||
primary.cleanupWin32 = cleanup.win32;
|
||||
primary.cleanupPath = cleanup.path;
|
||||
} catch (const std::exception& cleanup) {
|
||||
primary.cleanupConstraint = cleanup.what();
|
||||
primary.cleanupWin32 = ERROR_GEN_FAILURE;
|
||||
primary.cleanupPath = owner->path;
|
||||
} catch (...) {
|
||||
primary.cleanupConstraint = "Unknown runtime cleanup failure";
|
||||
primary.cleanupWin32 = ERROR_GEN_FAILURE;
|
||||
primary.cleanupPath = owner->path;
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup relative to an already-owned directory; path is diagnostic only.
|
||||
static Handle openRelative(HANDLE parent, const std::wstring& name, DWORD access,
|
||||
DWORD sharing, ULONG kind, const std::wstring& path) {
|
||||
@@ -337,7 +360,11 @@ static napi_value readCutterBridgeDescriptor(napi_env env, const std::vector<nap
|
||||
return result;
|
||||
}
|
||||
|
||||
std::unique_ptr<Runtime> createRuntime(const std::wstring& parentPath, const std::wstring& prefix) {
|
||||
void createRuntime(const std::wstring& parentPath, const std::wstring& prefix,
|
||||
std::unique_ptr<Runtime>& owner) {
|
||||
// A previous failed admission retains the original root handle. Retry that
|
||||
// owner before allowing this environment to allocate another runtime.
|
||||
if (owner) { closeRuntime(*owner); owner.reset(); }
|
||||
std::unique_ptr<File> parent;
|
||||
try { parent = openFile(parentPath, 0, true); }
|
||||
catch (Failure& failure) {
|
||||
@@ -349,24 +376,29 @@ std::unique_ptr<Runtime> createRuntime(const std::wstring& parentPath, const std
|
||||
std::array<unsigned char, 16> random;
|
||||
require(BCryptGenRandom(nullptr, random.data(), random.size(), BCRYPT_USE_SYSTEM_PREFERRED_RNG) == 0,
|
||||
"Generate runtime identity failed", parentPath, ERROR_GEN_FAILURE);
|
||||
Security security;
|
||||
auto result = std::make_unique<Runtime>();
|
||||
const auto name = prefix + hex(random.data(), random.size());
|
||||
result->path = parent->path + (parent->path.back() == L'\\' ? L"" : L"\\") + name;
|
||||
Security security;
|
||||
result->directory = createRelative(parent->get(), name,
|
||||
DELETE | FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | READ_CONTROL,
|
||||
true, security, result->path);
|
||||
result->parents = std::move(parent->handles);
|
||||
owner = std::move(result);
|
||||
try {
|
||||
verifyPrivate(result->directory.get(), result->path, true);
|
||||
// The owned child pins the parent nonempty before write sharing is relaxed.
|
||||
parent->handles.back() = openComponent(parent->path, 0, true, FILE_SHARE_READ | FILE_SHARE_WRITE);
|
||||
result->parents = std::move(parent->handles);
|
||||
} catch (...) {
|
||||
FILE_DISPOSITION_INFO deletion{TRUE};
|
||||
SetFileInformationByHandle(result->directory.get(), FileDispositionInfo, &deletion, sizeof(deletion));
|
||||
owner->directory = createRelative(owner->parents.back().get(), name,
|
||||
DELETE | FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | FILE_WRITE_ATTRIBUTES | READ_CONTROL,
|
||||
true, security, owner->path);
|
||||
// FILE_CREATE proves ownership and pins the parent nonempty independently
|
||||
// of admission validation. A failed validation must not retain a strict
|
||||
// parent lease that prevents renaming the still-owned child before retry.
|
||||
owner->parents.back() = openComponent(parent->path, 0, true, FILE_SHARE_READ | FILE_SHARE_WRITE);
|
||||
verifyPrivate(owner->directory.get(), owner->path, true);
|
||||
} catch (Failure& primary) {
|
||||
cleanupFailedRuntime(owner, primary);
|
||||
throw;
|
||||
} catch (const std::exception& error) {
|
||||
Failure primary(error.what(), owner->path, ERROR_GEN_FAILURE);
|
||||
cleanupFailedRuntime(owner, primary);
|
||||
throw primary;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static std::wstring relativePath(Runtime& root, const std::wstring& relative) {
|
||||
@@ -441,6 +473,9 @@ static void dispose(HANDLE handle, const std::wstring& path) {
|
||||
require(GetFileInformationByHandleEx(handle, FileBasicInfo, &info, sizeof(info)), "Read cleanup attributes failed", path);
|
||||
if ((info.FileAttributes & FILE_ATTRIBUTE_READONLY) != 0) {
|
||||
info.FileAttributes &= ~FILE_ATTRIBUTE_READONLY;
|
||||
// Zero means "leave attributes unchanged" to FileBasicInfo. A file whose
|
||||
// sole attribute was READONLY needs NORMAL to actually clear that marker.
|
||||
if (info.FileAttributes == 0) info.FileAttributes = FILE_ATTRIBUTE_NORMAL;
|
||||
require(SetFileInformationByHandle(handle, FileBasicInfo, &info, sizeof(info)), "Clear owned readonly attribute failed", path);
|
||||
}
|
||||
FILE_DISPOSITION_INFO deletion{TRUE};
|
||||
@@ -657,7 +692,8 @@ static napi_value snapshot(napi_env env, napi_value rootValue, Runtime& root,
|
||||
return promise;
|
||||
}
|
||||
|
||||
napi_value filesystemCall(napi_env env, const std::wstring& operation, const std::vector<napi_value>& args) {
|
||||
napi_value filesystemCall(napi_env env, const std::wstring& operation,
|
||||
const std::vector<napi_value>& args, RuntimeContext& context) {
|
||||
if (operation == L"cutter_bridge_verify_directory") return verifyCutterBridgeDirectory(env, args);
|
||||
if (operation == L"cutter_bridge_read_descriptor") return readCutterBridgeDescriptor(env, args);
|
||||
const auto expected = operation == L"open" || operation == L"close" || operation == L"runtime_close" || operation == L"runtime_snapshot_cancel" ? 1
|
||||
@@ -691,12 +727,21 @@ napi_value filesystemCall(napi_env env, const std::wstring& operation, const std
|
||||
return result;
|
||||
}
|
||||
if (operation == L"runtime_create") {
|
||||
auto root = createRuntime(wide(env, args[0]), wide(env, args[1]));
|
||||
auto result = identity(env, root->directory.get(), root->path);
|
||||
set(env, result, "path", string(env, root->path));
|
||||
set(env, result, "privateDacl", boolean(env, true));
|
||||
set(env, result, "handle", wrap(env, std::move(root)));
|
||||
return result;
|
||||
createRuntime(wide(env, args[0]), wide(env, args[1]), context.pending);
|
||||
try {
|
||||
auto result = identity(env, context.pending->directory.get(), context.pending->path);
|
||||
set(env, result, "path", string(env, context.pending->path));
|
||||
set(env, result, "privateDacl", boolean(env, true));
|
||||
publishRuntimeHandle(env, result, context.pending);
|
||||
return result;
|
||||
} catch (Failure& primary) {
|
||||
cleanupFailedRuntime(context.pending, primary);
|
||||
throw;
|
||||
} catch (const std::exception& error) {
|
||||
Failure primary(error.what(), context.pending ? context.pending->path : L"", ERROR_GEN_FAILURE);
|
||||
cleanupFailedRuntime(context.pending, primary);
|
||||
throw primary;
|
||||
}
|
||||
}
|
||||
auto& root = static_cast<Runtime&>(resource(env, args[0], Kind::Runtime));
|
||||
require(!root.closing || operation == L"runtime_close", "Runtime cleanup is pending", root.path, ERROR_BUSY);
|
||||
|
||||
@@ -454,17 +454,27 @@ napi_value processCall(napi_env env, const std::wstring& operation, const std::v
|
||||
throw Failure("Unknown native process operation", L"", ERROR_INVALID_PARAMETER);
|
||||
}
|
||||
|
||||
napi_value inspect(napi_env env, std::unique_ptr<Runtime>& probe) {
|
||||
napi_value inspect(napi_env env, std::unique_ptr<Runtime>& pending) {
|
||||
// Settle the previous owner before admitting another probe. Failed cleanup
|
||||
// retains its handles, including when identity construction itself failed.
|
||||
if (probe) { closeRuntime(*probe); probe.reset(); }
|
||||
if (pending) { closeRuntime(*pending); pending.reset(); }
|
||||
wchar_t temp[32768];
|
||||
const auto length = GetTempPathW(32768, temp);
|
||||
require(length > 0 && length < 32768, "Read temporary directory coordinate failed");
|
||||
probe = createRuntime(std::wstring(temp, length), L"rea-native-probe-");
|
||||
auto filesystem = identity(env, probe->directory.get(), probe->path);
|
||||
closeRuntime(*probe);
|
||||
probe.reset();
|
||||
createRuntime(std::wstring(temp, length), L"rea-native-probe-", pending);
|
||||
napi_value filesystem;
|
||||
try {
|
||||
filesystem = identity(env, pending->directory.get(), pending->path);
|
||||
} catch (Failure& primary) {
|
||||
cleanupFailedRuntime(pending, primary);
|
||||
throw;
|
||||
} catch (const std::exception& error) {
|
||||
Failure primary(error.what(), pending ? pending->path : L"", ERROR_GEN_FAILURE);
|
||||
cleanupFailedRuntime(pending, primary);
|
||||
throw primary;
|
||||
}
|
||||
closeRuntime(*pending);
|
||||
pending.reset();
|
||||
auto job = createJob();
|
||||
Attributes attributes(1);
|
||||
HANDLE raw = job.get();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
access,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
if (process.platform !== "win32")
|
||||
throw new Error("Requires actual Windows NTFS");
|
||||
const [rootArgument, artifactArgument, expected] = process.argv.slice(2);
|
||||
assert.ok(rootArgument && artifactArgument);
|
||||
assert.ok(expected === "expect-leak" || expected === "retain-owner");
|
||||
const root = resolve(rootArgument);
|
||||
const artifact = resolve(artifactArgument);
|
||||
const manifest = JSON.parse(
|
||||
await readFile(join(artifact, "manifest.json"), "utf8"),
|
||||
);
|
||||
const image = await readFile(join(artifact, manifest.artifact));
|
||||
assert.equal(
|
||||
createHash("sha256").update(image).digest("hex"),
|
||||
manifest.artifactSha256,
|
||||
);
|
||||
assert.equal(manifest.abiVersion, 1);
|
||||
assert.equal(manifest.nodeApiVersion, 8);
|
||||
const native = createRequire(join(root, "package.json"))(
|
||||
join(artifact, manifest.artifact),
|
||||
);
|
||||
const workspace = await mkdtemp(join(tmpdir(), "rea-failed-creation-probe-"));
|
||||
process.env.REA_NATIVE_CREATION_FAILURE = "1";
|
||||
let retry;
|
||||
const failures = [];
|
||||
try {
|
||||
let failure;
|
||||
try {
|
||||
native.call("runtime_create", [workspace, "owned-"]);
|
||||
} catch (cause) {
|
||||
failure = cause;
|
||||
}
|
||||
assert.equal(failure?.constraint, "Read private DACL by handle failed");
|
||||
assert.equal(failure?.win32Code, 5);
|
||||
assert.notEqual(failure.runtimeAllocation, "not-started");
|
||||
if (expected === "retain-owner") {
|
||||
assert.equal(
|
||||
failure.cleanupConstraint,
|
||||
"Delete owned object by handle failed",
|
||||
);
|
||||
assert.equal(failure.cleanupWin32Code, 5);
|
||||
} else assert.equal(failure.cleanupConstraint, undefined);
|
||||
const [name, ...extra] = await readdir(workspace);
|
||||
assert.ok(name && name.startsWith("owned-"));
|
||||
assert.equal(extra.length, 0);
|
||||
const allocated = join(workspace, name);
|
||||
const identity = await stat(allocated, { bigint: true });
|
||||
assert.equal(failure.requestedPath, allocated);
|
||||
const moved = allocated + "-moved";
|
||||
await writeFile(join(allocated, "owned-child.txt"), "owned child");
|
||||
await rename(allocated, moved);
|
||||
await mkdir(allocated);
|
||||
const replacement = await stat(allocated, { bigint: true });
|
||||
assert.notEqual(replacement.ino, identity.ino);
|
||||
const sentinel = join(allocated, "unrelated.txt");
|
||||
await writeFile(sentinel, "preserve replacement");
|
||||
|
||||
retry = native.call("runtime_create", [workspace, "retry-"]);
|
||||
native.call("runtime_close", [retry.handle]);
|
||||
retry = undefined;
|
||||
if (expected === "expect-leak") {
|
||||
assert.equal((await stat(moved, { bigint: true })).ino, identity.ino);
|
||||
assert.equal(
|
||||
await readFile(join(moved, "owned-child.txt"), "utf8"),
|
||||
"owned child",
|
||||
);
|
||||
} else await assert.rejects(access(moved), { code: "ENOENT" });
|
||||
assert.equal((await stat(allocated, { bigint: true })).ino, replacement.ino);
|
||||
assert.equal(await readFile(sentinel, "utf8"), "preserve replacement");
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
expected,
|
||||
postAllocationValidationDenied: true,
|
||||
rollbackDispositionDenied: true,
|
||||
baselineLostOwner: expected === "expect-leak",
|
||||
renamedOwnerRemovedOnRetry: expected === "retain-owner",
|
||||
unrelatedReplacementPreserved: true,
|
||||
fixtureArtifactSha256: manifest.artifactSha256,
|
||||
originalHostDeleteDenialReproduced: false,
|
||||
}),
|
||||
);
|
||||
} catch (failure) {
|
||||
failures.push(failure);
|
||||
console.error(failure);
|
||||
}
|
||||
try {
|
||||
if (retry) native.call("runtime_close", [retry.handle]);
|
||||
native.call("inspect", []);
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
} catch (cleanupFailure) {
|
||||
failures.push(cleanupFailure);
|
||||
}
|
||||
if (failures.length !== 0)
|
||||
throw new AggregateError(failures, "Creation ownership probe failed");
|
||||
@@ -55,14 +55,29 @@ try {
|
||||
join(workspace, "dist", "windows", "WindowsNativeLoader.js"),
|
||||
).href;
|
||||
const source = `import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {existsSync,mkdirSync,readFileSync,readdirSync,renameSync,statSync,writeFileSync} from 'node:fs';
|
||||
import {join} from 'node:path';
|
||||
import {systemWindowsNativeAuthority} from ${JSON.stringify(loader)};
|
||||
const temp=process.env.TEMP;
|
||||
const first=systemWindowsNativeAuthority();
|
||||
assert.equal(first.available,false);
|
||||
assert.equal(first.retryable,true);
|
||||
assert.match(first.reason,/Delete owned object by handle failed.*Win32 5/s);
|
||||
const mode=process.argv[2];
|
||||
const creation=mode.startsWith('create-');
|
||||
const native=creation?createRequire(import.meta.url)(${JSON.stringify(join(workspace, "native", "windows", "build", "rea-windows-x64.node"))}):null;
|
||||
if(creation){
|
||||
let failure;
|
||||
try{native.call('runtime_create',[temp,'rea-native-created-'])}catch(error){failure=error}
|
||||
assert.ok(failure instanceof Error,'runtime_create unexpectedly passed injected post-create validation failure');
|
||||
assert.equal(failure.constraint,'Read private DACL by handle failed');
|
||||
assert.equal(failure.win32Code,5);
|
||||
assert.equal(failure.cleanupConstraint,'Delete owned object by handle failed');
|
||||
assert.equal(failure.cleanupWin32Code,5);
|
||||
assert.notEqual(failure.runtimeAllocation,'not-started');
|
||||
} else {
|
||||
const first=systemWindowsNativeAuthority();
|
||||
assert.equal(first.available,false);
|
||||
assert.equal(first.retryable,true);
|
||||
assert.match(first.reason,/Delete owned object by handle failed.*Win32 5/s);
|
||||
}
|
||||
const [name,...extra]=readdirSync(temp);assert.ok(name);assert.equal(extra.length,0);
|
||||
const path=join(temp,name);const identity=statSync(path,{bigint:true});
|
||||
// Delete sharing permits rename; ownership follows the retained NTFS handle.
|
||||
@@ -73,26 +88,40 @@ assert.equal(moved.dev,identity.dev);assert.equal(moved.ino,identity.ino);
|
||||
mkdirSync(path);const replacement=statSync(path,{bigint:true});
|
||||
assert.notEqual(replacement.ino,identity.ino);
|
||||
const sentinel=join(path,'unrelated.txt');writeFileSync(sentinel,'unrelated replacement');
|
||||
if(process.argv[2]!=='teardown'){
|
||||
if(mode==='retry'){
|
||||
const second=systemWindowsNativeAuthority();assert.equal(second.available,true);
|
||||
assert.equal(existsSync(renamed),false);
|
||||
assert.deepEqual(readdirSync(temp),[name]);
|
||||
assert.equal(readFileSync(sentinel,'utf8'),'unrelated replacement');
|
||||
assert.equal(statSync(path,{bigint:true}).ino,replacement.ino);
|
||||
assert.equal(systemWindowsNativeAuthority(),second);
|
||||
} else if(mode==='create-retry') {
|
||||
const second=native.call('runtime_create',[temp,'rea-native-created-retry-']);
|
||||
assert.equal(existsSync(renamed),false);
|
||||
assert.equal(readFileSync(sentinel,'utf8'),'unrelated replacement');
|
||||
native.call('runtime_close',[second.handle]);
|
||||
assert.equal(existsSync(second.path),false);
|
||||
assert.equal(statSync(path,{bigint:true}).ino,replacement.ino);
|
||||
}
|
||||
console.log(JSON.stringify({mode:process.argv[2],firstFailed:true,retainedOwner:true,name,renamed,device:identity.dev.toString(),inode:identity.ino.toString(),replacementInode:replacement.ino.toString()}));
|
||||
console.log(JSON.stringify({mode,firstFailed:true,retainedOwner:true,name,renamed,device:identity.dev.toString(),inode:identity.ino.toString(),replacementInode:replacement.ino.toString()}));
|
||||
`;
|
||||
const fixture = join(workspace, "probe.mjs");
|
||||
await writeFile(fixture, source);
|
||||
for (const mode of ["retry", "teardown"]) {
|
||||
for (const mode of ["retry", "teardown", "create-retry", "create-teardown"]) {
|
||||
const temp = join(workspace, mode);
|
||||
await mkdir(temp);
|
||||
const child = spawnSync(process.execPath, [fixture, mode], {
|
||||
encoding: "utf8",
|
||||
timeout: 20_000,
|
||||
windowsHide: true,
|
||||
env: { ...process.env, TEMP: temp, TMP: temp },
|
||||
env: {
|
||||
...process.env,
|
||||
TEMP: temp,
|
||||
TMP: temp,
|
||||
...(mode.startsWith("create-")
|
||||
? { REA_NATIVE_CREATION_FAILURE: "1" }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
if (child.error !== undefined) throw child.error;
|
||||
assert.equal(child.status, 0, child.stderr);
|
||||
@@ -119,7 +148,7 @@ console.log(JSON.stringify({mode:process.argv[2],firstFailed:true,retainedOwner:
|
||||
);
|
||||
}
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ ok: true, controlledDispositionFailure: true, retainedOwner: true, renamedOwnerCleansChildren: true, unrelatedReplacementPreserved: true, retryRecovers: true, successfulAdmissionCached: true, environmentTeardownCleansProbe: true, issue1701HostCauseReproduced: false })}\n`,
|
||||
`${JSON.stringify({ ok: true, inspectionPostCreateValidationFailure: true, runtimeCreatePostCreateValidationFailure: true, controlledRollbackDispositionFailure: true, retainedOwner: true, renamedOwnerCleansChildren: true, unrelatedReplacementPreserved: true, retryRecoversByInspectionAndCreation: true, successfulAdmissionCached: true, environmentTeardownCleansProbeAndCreation: true, issue1701HostCauseReproduced: false })}\n`,
|
||||
);
|
||||
} finally {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
|
||||
@@ -48,6 +48,13 @@ const { PrivateRuntimeRootUnavailableError } = await import(
|
||||
)
|
||||
);
|
||||
const workspace = await mkdtemp(join(tmpdir(), "rea-native-conformance-"));
|
||||
// A pwsh caller can supply module paths incompatible with Windows PowerShell.
|
||||
// Each independent observer must discover its own module path.
|
||||
const windowsPowerShellEnvironment = Object.fromEntries(
|
||||
Object.entries(process.env).filter(
|
||||
([name]) => name.toLowerCase() !== "psmodulepath",
|
||||
),
|
||||
);
|
||||
const environment = Object.entries(process.env)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.map(([key, value]) => `${key}=${value}`);
|
||||
@@ -309,6 +316,7 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe
|
||||
]);
|
||||
const sentinel = join(sourceDirectory, "sentinel");
|
||||
await writeFile(sentinel, "preserve");
|
||||
const runtimeVerificationErrors = [];
|
||||
try {
|
||||
const acl = JSON.parse(
|
||||
execFileSync(
|
||||
@@ -317,18 +325,13 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"$a=Get-Acl -LiteralPath $env:REA_TEST_PRIVATE_RUNTIME_PATH; $u=[Security.Principal.WindowsIdentity]::GetCurrent().User; @{protected=$a.AreAccessRulesProtected; ownerMatchesUser=($a.GetOwner([Security.Principal.SecurityIdentifier]).Value -eq $u.Value); trusteesOnlyUserAndSystem=(@($a.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier]) | Where-Object {$_.IdentityReference.Value -ne $u.Value -and $_.IdentityReference.Value -ne 'S-1-5-18'}).Count -eq 0); count=@($a.Access).Count} | ConvertTo-Json -Compress",
|
||||
"$ErrorActionPreference='Stop'; $p=$env:REA_TEST_PRIVATE_RUNTIME_PATH; $a=Get-Acl -LiteralPath $p; $u=[Security.Principal.WindowsIdentity]::GetCurrent().User; [IO.File]::SetAttributes($p,[IO.FileAttributes]([int][IO.File]::GetAttributes($p) -bor 1)); @{protected=$a.AreAccessRulesProtected; ownerMatchesUser=($a.GetOwner([Security.Principal.SecurityIdentifier]).Value -eq $u.Value); trusteesOnlyUserAndSystem=(@($a.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier]) | Where-Object {$_.IdentityReference.Value -ne $u.Value -and $_.IdentityReference.Value -ne 'S-1-5-18'}).Count -eq 0); count=@($a.Access).Count; rootReadonly=(([int][IO.File]::GetAttributes($p) -band 1) -ne 0)} | ConvertTo-Json -Compress",
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
// A pwsh caller can supply module paths incompatible with the
|
||||
// Windows PowerShell observer. Let that observer build its own path.
|
||||
timeout: 15_000,
|
||||
env: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(process.env).filter(
|
||||
([name]) => name.toLowerCase() !== "psmodulepath",
|
||||
),
|
||||
),
|
||||
...windowsPowerShellEnvironment,
|
||||
REA_TEST_PRIVATE_RUNTIME_PATH: runtime.path,
|
||||
},
|
||||
},
|
||||
@@ -339,6 +342,7 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe
|
||||
ownerMatchesUser: true,
|
||||
trusteesOnlyUserAndSystem: true,
|
||||
count: 2,
|
||||
rootReadonly: true,
|
||||
});
|
||||
native.call("runtime_mkdir", [runtime.handle, "inputs"]);
|
||||
native.call("runtime_write", [
|
||||
@@ -484,7 +488,10 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe
|
||||
{
|
||||
encoding: "utf8",
|
||||
timeout: 15_000,
|
||||
env: { ...process.env, REA_SNAPSHOT_READER_PATH: snapshotPath },
|
||||
env: {
|
||||
...windowsPowerShellEnvironment,
|
||||
REA_SNAPSHOT_READER_PATH: snapshotPath,
|
||||
},
|
||||
},
|
||||
).trim();
|
||||
assert.equal(readerDigest, readbackSnapshot.sha256);
|
||||
@@ -574,12 +581,52 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe
|
||||
asynchronousSnapshot: true,
|
||||
snapshotCancellation: true,
|
||||
};
|
||||
} finally {
|
||||
native.call("runtime_close", [runtime.handle]);
|
||||
const readonlyChildren = JSON.parse(
|
||||
execFileSync(
|
||||
"powershell.exe",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"$ErrorActionPreference='Stop'; $items=ConvertFrom-Json -InputObject $env:REA_TEST_READONLY_CHILDREN; @($items | ForEach-Object { $current=[int][IO.File]::GetAttributes($_); $next=if(($current -band 16) -ne 0){$current -bor 1}else{1}; [IO.File]::SetAttributes($_,[IO.FileAttributes]$next); $a=[int][IO.File]::GetAttributes($_); @{readonly=(($a -band 1) -ne 0); directory=(($a -band 16) -ne 0); onlyReadonly=($a -eq 1)} }) | ConvertTo-Json -Compress",
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
timeout: 15_000,
|
||||
env: {
|
||||
...windowsPowerShellEnvironment,
|
||||
REA_TEST_READONLY_CHILDREN: JSON.stringify([
|
||||
join(runtime.path, "inputs"),
|
||||
join(runtime.path, "inputs", "target.bin"),
|
||||
]),
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
assert.deepEqual(readonlyChildren, [
|
||||
{ readonly: true, directory: true, onlyReadonly: false },
|
||||
{ readonly: true, directory: false, onlyReadonly: true },
|
||||
]);
|
||||
} catch (failure) {
|
||||
runtimeVerificationErrors.push(failure);
|
||||
}
|
||||
try {
|
||||
native.call("runtime_close", [runtime.handle]);
|
||||
} catch (cleanupFailure) {
|
||||
runtimeVerificationErrors.push(cleanupFailure);
|
||||
}
|
||||
if (runtimeVerificationErrors.length === 1)
|
||||
throw runtimeVerificationErrors[0];
|
||||
if (runtimeVerificationErrors.length > 1)
|
||||
throw new AggregateError(
|
||||
runtimeVerificationErrors,
|
||||
"Native runtime verification and cleanup failed",
|
||||
);
|
||||
assert.equal(await exists(runtime.path), false);
|
||||
assert.equal(await readFile(sentinel, "utf8"), "preserve");
|
||||
report.controls.cleanupDoesNotTraverseJunctions = true;
|
||||
report.controls.readonlyRootAndChildrenRemoved = true;
|
||||
report.controls.readonlyFileWithoutArchiveAttributeRemoved = true;
|
||||
const fixture = join(workspace, "tree.mjs");
|
||||
await writeFile(
|
||||
fixture,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
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 'only'){1}elseif($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 === "only" ? "only" : readonly ? "set" : "clear",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
assert.equal(result.readonly, Boolean(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 stationary = create();
|
||||
attributes(stationary.currentPath, true);
|
||||
if (expected === "deny-clear") {
|
||||
assert.throws(
|
||||
() => close(stationary),
|
||||
(error) =>
|
||||
error.win32Code === 5 &&
|
||||
error.constraint === "Clear owned readonly attribute failed",
|
||||
);
|
||||
attributes(stationary.currentPath, false);
|
||||
}
|
||||
close(stationary);
|
||||
await assert.rejects(access(stationary.currentPath), { code: "ENOENT" });
|
||||
|
||||
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(children.currentPath, true);
|
||||
attributes(join(children.currentPath, "nested"), true);
|
||||
attributes(join(children.currentPath, "nested", "readonly.txt"), true);
|
||||
if (expected === "deny-clear") {
|
||||
assert.throws(
|
||||
() => close(children),
|
||||
(error) =>
|
||||
error.win32Code === 5 &&
|
||||
error.constraint === "Clear owned readonly attribute failed",
|
||||
);
|
||||
attributes(children.currentPath, false);
|
||||
}
|
||||
close(children);
|
||||
await assert.rejects(access(children.currentPath), { code: "ENOENT" });
|
||||
const onlyReadonly = create();
|
||||
native.call("runtime_write", [onlyReadonly.runtime.handle, "only-readonly.txt", Buffer.from("owned file")]);
|
||||
const onlyReadonlyPath = join(onlyReadonly.currentPath, "only-readonly.txt");
|
||||
assert.equal(attributes(onlyReadonlyPath, "only").attributes, 1);
|
||||
if (expected === "deny-clear") {
|
||||
assert.throws(() => close(onlyReadonly), error => error.win32Code === 5 && error.constraint === "Delete owned object by handle failed");
|
||||
attributes(onlyReadonlyPath, false);
|
||||
}
|
||||
close(onlyReadonly);
|
||||
await assert.rejects(access(onlyReadonly.currentPath), { code: "ENOENT" });
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
expected,
|
||||
rootReadonlyConfirmed: true,
|
||||
stationaryRootRemoved: true,
|
||||
populatedReadonlyRootRemoved: true,
|
||||
renamedOwnedRootRemoved: true,
|
||||
unrelatedReplacementPreserved: true,
|
||||
readonlyChildFileAndDirectoryRemoved: true,
|
||||
readonlyFileWithoutOtherAttributesRemoved: 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 });
|
||||
}
|
||||
@@ -14,6 +14,12 @@ 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>>;
|
||||
@@ -41,6 +47,7 @@ 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(
|
||||
@@ -48,7 +55,11 @@ export const clientRegistrationEnvironmentDetails = (
|
||||
validation,
|
||||
);
|
||||
if (!environment.ok) return environment;
|
||||
return validatedDetails(environment.value, validation);
|
||||
return validatedDetails(
|
||||
environment.value,
|
||||
validation,
|
||||
resolveProviderRequestBudget,
|
||||
);
|
||||
}
|
||||
const key =
|
||||
parsed?.dialect === "opencode" || parsed?.dialect === "opencode_v2"
|
||||
@@ -78,18 +89,29 @@ export const clientRegistrationEnvironmentDetails = (
|
||||
// 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);
|
||||
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;
|
||||
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]) =>
|
||||
@@ -103,6 +125,9 @@ const validatedDetails = (
|
||||
...(validated.value.REA_JEB_MCP_TIMEOUT_MS === undefined
|
||||
? {}
|
||||
: { jebMcpTimeoutMs: validated.value.REA_JEB_MCP_TIMEOUT_MS }),
|
||||
...(providerRequestBudget.value === undefined
|
||||
? {}
|
||||
: { providerRequestTimeoutMs: providerRequestBudget.value }),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -35,6 +35,7 @@ import type { SetupClient } from "./SupportedClients.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import { dshDocument } from "./DshCordisPatch.js";
|
||||
import { clientRegistrationEnvironmentDetails } from "./ClientRegistrationEnvironment.js";
|
||||
import type { ProviderRequestBudgetResolver } from "./ClientRegistrationEnvironment.js";
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
|
||||
interface ClientRegistrationStatusBase {
|
||||
@@ -98,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. */
|
||||
@@ -227,10 +229,26 @@ export const readClientRegistrationInspections = async (
|
||||
const registration = parseRegistration(raw, effectiveClient);
|
||||
const registrationEnvironment =
|
||||
client.name === "kimi" || client.name === "codex"
|
||||
? clientRegistrationEnvironmentDetails(parsed)
|
||||
? 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,
|
||||
|
||||
@@ -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[]>;
|
||||
@@ -431,6 +434,7 @@ export interface SystemDoctorHostOptions {
|
||||
readonly providerRegistrationEnvironment?: () => Readonly<
|
||||
Record<string, string>
|
||||
>;
|
||||
readonly resolveProviderRequestBudget?: ProviderRequestBudgetResolver;
|
||||
readonly linuxDemoRuntimeCheck?: () => Promise<DoctorCheck>;
|
||||
}
|
||||
|
||||
@@ -547,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);
|
||||
@@ -599,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" ||
|
||||
|
||||
@@ -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,
|
||||
@@ -26,6 +27,7 @@ import writeFileAtomic from "write-file-atomic";
|
||||
import {
|
||||
clientRegistrationEnvironment,
|
||||
clientRegistrationEnvironmentDetails,
|
||||
type ProviderRequestBudgetResolver,
|
||||
} from "./ClientRegistrationEnvironment.js";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
@@ -81,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")
|
||||
@@ -118,6 +127,7 @@ const configureClientDocument = async (
|
||||
command,
|
||||
existing,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
);
|
||||
if (!policy.ok) {
|
||||
@@ -156,6 +166,7 @@ const configureClientDocument = async (
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
);
|
||||
if (!policy.ok) {
|
||||
@@ -184,6 +195,7 @@ const configureClientDocument = async (
|
||||
servers: { ...fallback.value, ...parsed.servers },
|
||||
},
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
);
|
||||
if (registrationCurrent(parsed, desired)) return { status: "unchanged" };
|
||||
return persistClientConfiguration({
|
||||
@@ -353,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 {
|
||||
@@ -372,6 +385,7 @@ export const clientConfigurationAligned = async (
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
);
|
||||
if (!policy.ok) {
|
||||
@@ -388,6 +402,7 @@ export const clientConfigurationAligned = async (
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
)
|
||||
);
|
||||
@@ -404,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;
|
||||
@@ -423,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 };
|
||||
@@ -456,6 +474,7 @@ const inspectClientDocument = async (
|
||||
command,
|
||||
existing,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
);
|
||||
if (!policy.ok) {
|
||||
@@ -505,6 +524,7 @@ const inspectClientDocument = async (
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
);
|
||||
if (!policy.ok) {
|
||||
@@ -522,6 +542,7 @@ const inspectClientDocument = async (
|
||||
command,
|
||||
parsed,
|
||||
files.value,
|
||||
resolveProviderRequestBudget,
|
||||
);
|
||||
if (registrationCurrent(parsed, desired))
|
||||
return { status: "already_current" };
|
||||
@@ -532,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 {
|
||||
@@ -660,6 +683,7 @@ const clientConfigurationDesired = (
|
||||
command: readonly string[],
|
||||
parsed: ClientConfigurationDocument | undefined,
|
||||
sources: readonly ClientConfigurationSource[],
|
||||
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
|
||||
) => {
|
||||
const effective = effectiveClientConfiguration(sources, {
|
||||
client,
|
||||
@@ -685,6 +709,10 @@ const clientConfigurationDesired = (
|
||||
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
|
||||
|
||||
@@ -244,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,
|
||||
@@ -288,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,
|
||||
@@ -297,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,
|
||||
@@ -311,6 +322,7 @@ const clientConfigurationOperations = (resources: ArtifactResourceScope) => ({
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
resolveProviderRequestBudget,
|
||||
)),
|
||||
),
|
||||
inspectClientConfiguration: (
|
||||
@@ -319,7 +331,12 @@ const clientConfigurationOperations = (resources: ArtifactResourceScope) => ({
|
||||
command: readonly string[],
|
||||
) =>
|
||||
runClientConfigurationOperation(resources, () =>
|
||||
inspectClientConfiguration(client, providerEnvironment, command),
|
||||
inspectClientConfiguration(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
resolveProviderRequestBudget,
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} 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", () => {
|
||||
@@ -144,60 +145,88 @@ const failingHttpConnection = (
|
||||
);
|
||||
};
|
||||
|
||||
it("preserves the SDK request timeout as a typed IDA timeout", async () => {
|
||||
const timeoutMs = 40;
|
||||
const pendingCall = vi.fn();
|
||||
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 message = JSON.parse(String(init?.body ?? "")) as {
|
||||
readonly id?: string | number;
|
||||
readonly method: string;
|
||||
readonly params?: Record<string, unknown>;
|
||||
};
|
||||
const json = (value: unknown) =>
|
||||
new Response(JSON.stringify(value), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (message.method === "initialize")
|
||||
return json({
|
||||
const request = JSON.parse(
|
||||
String(init?.body ?? ""),
|
||||
) as MockedIdaHttpRequest;
|
||||
if (request.method === "initialize")
|
||||
return jsonRpcResponse({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
id: request.id,
|
||||
result: {
|
||||
protocolVersion: message.params?.protocolVersion,
|
||||
protocolVersion: request.params?.protocolVersion,
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: "ida-timeout-fixture", version: "1" },
|
||||
serverInfo: { name: serverName, version: "1" },
|
||||
},
|
||||
});
|
||||
if (
|
||||
message.method === "notifications/initialized" ||
|
||||
message.method === "notifications/cancelled"
|
||||
request.method === "notifications/initialized" ||
|
||||
request.method === "notifications/cancelled"
|
||||
)
|
||||
return new Response(null, { status: 202 });
|
||||
if (message.method === "tools/list")
|
||||
return json({
|
||||
if (request.method === "tools/list")
|
||||
return jsonRpcResponse({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: { tools: [] },
|
||||
id: request.id,
|
||||
result: { tools },
|
||||
});
|
||||
if (message.method !== "tools/call")
|
||||
throw new Error(`Unexpected fixture request: ${message.method}`);
|
||||
pendingCall();
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
const signal = init?.signal;
|
||||
if (signal?.aborted === true) {
|
||||
reject(signal.reason);
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() =>
|
||||
reject(new DOMException("The request was aborted", "AbortError")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
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",
|
||||
@@ -210,11 +239,14 @@ it("preserves the SDK request timeout as a typed IDA timeout", async () => {
|
||||
try {
|
||||
await expect(connection.connect()).resolves.toEqual([]);
|
||||
const failure = await connection
|
||||
.call("decompile", {})
|
||||
.call("decompile", {}, { timeoutMs: requestTimeoutMs })
|
||||
.catch((cause: unknown) => cause);
|
||||
expect(pendingCall).toHaveBeenCalledOnce();
|
||||
expect(failure).toBeInstanceOf(AnalysisTimeoutError);
|
||||
expect(failure).toMatchObject({ operation: "decompile", timeoutMs });
|
||||
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);
|
||||
@@ -224,6 +256,75 @@ it("preserves the SDK request timeout as a typed IDA timeout", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
|
||||
@@ -35,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;
|
||||
@@ -349,6 +350,7 @@ export const createIdaMcpConnection = (
|
||||
const transportFailure = (
|
||||
cause: unknown,
|
||||
operation = "ida_mcp_response",
|
||||
requestTimeoutMs = config.timeoutMs,
|
||||
): never => {
|
||||
const bufferLimit =
|
||||
cause instanceof Error
|
||||
@@ -392,7 +394,7 @@ export const createIdaMcpConnection = (
|
||||
stderr_bytes: stderrBytes,
|
||||
};
|
||||
if (cause instanceof SdkError && cause.code === SdkErrorCode.RequestTimeout)
|
||||
throw new AnalysisTimeoutError(operation, config.timeoutMs, {
|
||||
throw new AnalysisTimeoutError(operation, requestTimeoutMs, {
|
||||
cause: failure,
|
||||
capturedOutput,
|
||||
});
|
||||
@@ -458,11 +460,17 @@ export const createIdaMcpConnection = (
|
||||
.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,
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface McpToolTimeoutInputs {
|
||||
readonly ghidraStartupTimeoutMs: number;
|
||||
readonly hopperStartupTimeoutMs: number;
|
||||
readonly jebMcpTimeoutMs?: number;
|
||||
readonly providerRequestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Allow for known startup phases and caller-selected upstream request budgets. */
|
||||
@@ -40,7 +41,11 @@ export const mcpProviderDeadlineAllowanceMs = (
|
||||
inputs.ghidraStartupTimeoutMs,
|
||||
inputs.hopperStartupTimeoutMs,
|
||||
);
|
||||
return Math.max(selectedTimeout, inputs.jebMcpTimeoutMs ?? 0);
|
||||
return Math.max(
|
||||
selectedTimeout,
|
||||
inputs.jebMcpTimeoutMs ?? 0,
|
||||
inputs.providerRequestTimeoutMs ?? 0,
|
||||
);
|
||||
};
|
||||
|
||||
/** Add operation headroom to the parsed provider deadline allowance. */
|
||||
|
||||
@@ -322,6 +322,142 @@ cliTest(
|
||||
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" },
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
+19
-6
@@ -3,11 +3,11 @@
|
||||
#include "../../native/windows/src/authority.hpp"
|
||||
#include <atomic>
|
||||
|
||||
static BOOL inspectionDisposition(HANDLE handle, FILE_INFO_BY_HANDLE_CLASS kind,
|
||||
LPVOID value, DWORD size) {
|
||||
// A real NTFS probe is created normally. Fail its first disposition call so
|
||||
// the verifier can observe ownership after failure, independently of host
|
||||
// filter drivers or the intermittent error reported in issue #1701.
|
||||
static BOOL fixtureDisposition(HANDLE handle, FILE_INFO_BY_HANDLE_CLASS kind,
|
||||
LPVOID value, DWORD size) {
|
||||
// A real NTFS runtime is created normally. Fail its first rollback
|
||||
// disposition so the verifier can observe the owner after post-create
|
||||
// validation fails, independent of host filesystem behavior.
|
||||
static std::atomic_flag failed = ATOMIC_FLAG_INIT;
|
||||
if (kind == FileDispositionInfo && !failed.test_and_set()) {
|
||||
SetLastError(ERROR_ACCESS_DENIED);
|
||||
@@ -16,5 +16,18 @@ static BOOL inspectionDisposition(HANDLE handle, FILE_INFO_BY_HANDLE_CLASS kind,
|
||||
return SetFileInformationByHandle(handle, kind, value, size);
|
||||
}
|
||||
|
||||
#define SetFileInformationByHandle inspectionDisposition
|
||||
static DWORD inspectionSecurityInfo(HANDLE handle, SE_OBJECT_TYPE kind,
|
||||
SECURITY_INFORMATION information,
|
||||
PSID* owner, PSID* group, PACL* dacl, PACL* sacl,
|
||||
PSECURITY_DESCRIPTOR* descriptor) {
|
||||
// Force validation to fail only after FILE_CREATE has allocated the root.
|
||||
static std::atomic_flag failed = ATOMIC_FLAG_INIT;
|
||||
wchar_t enabled[2];
|
||||
if (GetEnvironmentVariableW(L"REA_NATIVE_CREATION_FAILURE", enabled, 2) > 0 &&
|
||||
!failed.test_and_set()) return ERROR_ACCESS_DENIED;
|
||||
return GetSecurityInfo(handle, kind, information, owner, group, dacl, sacl, descriptor);
|
||||
}
|
||||
|
||||
#define SetFileInformationByHandle fixtureDisposition
|
||||
#define GetSecurityInfo inspectionSecurityInfo
|
||||
#include "../../native/windows/src/filesystem.cc"
|
||||
Reference in new issue
Block a user