diff --git a/.github/workflows/real-binary-layout.yml b/.github/workflows/real-binary-layout.yml index a8d84a2234..e312baa5af 100644 --- a/.github/workflows/real-binary-layout.yml +++ b/.github/workflows/real-binary-layout.yml @@ -55,12 +55,12 @@ jobs: - uses: ./.github/actions/portable-build-cache with: build_cache_artifact: ${{ inputs.build_cache_artifact }} - - name: Install ELF32 cross toolchains for source-owned fixtures + - name: Install AArch64 and ELF32 cross toolchains for source-owned fixtures run: | sudo apt-get update sudo apt-get install --yes --no-install-recommends \ - gcc-i686-linux-gnu gcc-arm-linux-gnueabi gcc-mips-linux-gnu gcc-mipsel-linux-gnu \ - libc6-dev-i386-cross libc6-dev-armel-cross libc6-dev-mips-cross libc6-dev-mipsel-cross + gcc-aarch64-linux-gnu gcc-i686-linux-gnu gcc-arm-linux-gnueabi gcc-mips-linux-gnu gcc-mipsel-linux-gnu \ + libc6-dev-arm64-cross libc6-dev-i386-cross libc6-dev-armel-cross libc6-dev-mips-cross libc6-dev-mipsel-cross - name: Acquire exact upstream libraries in an isolated runner environment shell: bash run: | diff --git a/.github/workflows/website-check.yml b/.github/workflows/website-check.yml index dc17b4b969..a7106ac7c2 100644 --- a/.github/workflows/website-check.yml +++ b/.github/workflows/website-check.yml @@ -21,6 +21,10 @@ jobs: python-version: "3.12" cache: pip cache-dependency-path: website/requirements.txt + - name: Set up Node.js for the website Worker checks + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .nvmrc - name: Install sharing-image dependencies run: | sudo apt-get update diff --git a/.github/workflows/website-pages.yml b/.github/workflows/website-pages.yml index 491b028a65..d4184a879b 100644 --- a/.github/workflows/website-pages.yml +++ b/.github/workflows/website-pages.yml @@ -54,6 +54,10 @@ jobs: python-version: "3.12" cache: pip cache-dependency-path: website/requirements.txt + - name: Set up Node.js for the website Worker checks + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .nvmrc - name: Install sharing-image dependencies run: | sudo apt-get update @@ -75,10 +79,21 @@ jobs: PY - name: Configure Pages uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0 + # upload-pages-artifact excludes dot directories, including .well-known. + - name: Package the website including agent discovery + run: | + tar --dereference --hard-dereference --directory website/_site \ + --exclude=.git --exclude=.github -cf "$RUNNER_TEMP/artifact.tar" . + tar -tf "$RUNNER_TEMP/artifact.tar" \ + ./.well-known/ard.json ./.well-known/ai-catalog.json \ + ./.well-known/agent-skills/index.json > /dev/null - name: Upload the website - uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - path: website/_site + name: github-pages + path: ${{ runner.temp }}/artifact.tar + retention-days: 1 + if-no-files-found: error deploy: needs: prepare @@ -130,14 +145,24 @@ jobs: PUBLISH_TARGET: ${{ inputs.target }} run: | python3 -u - <<'PY' + import hashlib import json import os import sys import time from urllib.error import URLError - from urllib.parse import urljoin + from urllib.parse import urljoin, urlsplit from urllib.request import Request, urlopen + def open_public(url, accept=None): + headers = { + "Cache-Control": "no-cache", + "User-Agent": "REA-Website-Verifier/1.0 (+https://github.com/morluto/rea)", + } + if accept: + headers["Accept"] = accept + return urlopen(Request(url, headers=headers), timeout=10) + expected = os.environ["GITHUB_SHA"] pages_url = os.environ["PAGES_URL"] if not pages_url: @@ -153,11 +178,7 @@ jobs: for name, base in list(pending.items()): url = urljoin(base, "deployment.json") + "?commit=" + expected try: - request = Request(url, headers={ - "Cache-Control": "no-cache", - "User-Agent": "REA-Website-Verifier/1.0 (+https://github.com/morluto/rea)", - }) - with urlopen(request, timeout=10) as response: + with open_public(url) as response: actual = json.load(response).get("commit") if actual == expected: print(f"{name} serves {expected}: {base}") @@ -172,6 +193,27 @@ jobs: time.sleep(5) if pending: sys.exit("Could not verify published revision on: " + ", ".join(pending)) + for name, base in hosts.items(): + with open_public(urljoin(base, ".well-known/agent-skills/index.json")) as response: + skill, = json.load(response)["skills"] + # Fetch from each selected host, including the /rea/ mirror. + artifact_url = urljoin(base, urlsplit(skill["url"]).path.lstrip("/")) + with open_public(artifact_url) as response: + digest = "sha256:" + hashlib.sha256(response.read()).hexdigest() + if digest != skill["digest"]: + sys.exit(name + " serves a skill archive with the wrong digest") + for manifest in ("ard.json", "ai-catalog.json"): + with open_public(urljoin(base, ".well-known/" + manifest)) as response: + if not json.load(response).get("entries"): + sys.exit(name + " serves an empty discovery manifest") + if name == "Cloudflare": + with open_public(base, accept="text/markdown") as response: + vary = {value.strip().lower() for value in response.headers.get("Vary", "").split(",")} + if response.headers.get_content_type() != "text/markdown" or "accept" not in vary: + sys.exit("Cloudflare did not negotiate Markdown with Vary: Accept") + if 'rel="ard"' not in response.headers.get("Link", ""): + sys.exit("Cloudflare is missing its discovery Link header") + print(name + " serves the discovery manifests and verified skill archive") with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary: links = " and ".join(f"[{name}]({url})" for name, url in hosts.items()) summary.write(f"Published commit `{expected}` to {links}.\n") diff --git a/bridge/pwntools/layout_impl.py b/bridge/pwntools/layout_impl.py index 86ec3f623b..a9c112fb70 100644 --- a/bridge/pwntools/layout_impl.py +++ b/bridge/pwntools/layout_impl.py @@ -16,10 +16,10 @@ PROFILE = "pwntools@4.15.0;pyelftools@0.33;unicorn@2.1.2" PACKAGES = {"pwntools": "4.15.0", "pyelftools": "0.33", "unicorn": "2.1.2"} OUTPUT_BYTES = 64 * 1024 * 1024 # (e_machine, ELF class bits, byte order) -> architecture pwntools must also report. -PROFILES = {(62, 64, "little"): "amd64", (3, 32, "little"): "i386", (40, 32, "little"): "arm", - (8, 32, "little"): "mips", (8, 32, "big"): "mips"} -PROFILE_SCOPE = ("Layout profile requires ELF64 x86-64, ELF32 i386, ELF32 ARM little-endian " - "or ELF32 MIPS little- or big-endian objects.") +PROFILES = {(62, 64, "little"): "amd64", (183, 64, "little"): "aarch64", (3, 32, "little"): "i386", + (40, 32, "little"): "arm", (8, 32, "little"): "mips", (8, 32, "big"): "mips"} +PROFILE_SCOPE = ("Layout profile requires ELF64 x86-64, ELF64 AArch64 little-endian, ELF32 i386, " + "ELF32 ARM little-endian or ELF32 MIPS little- or big-endian objects.") class LayoutFailure(Exception): diff --git a/docs/binary-diagnostics.md b/docs/binary-diagnostics.md index 56427673af..cf074b10c3 100644 --- a/docs/binary-diagnostics.md +++ b/docs/binary-diagnostics.md @@ -9,9 +9,11 @@ ordinary binary inspection use the same modular capability. ## Bring your own engine Real verification covers **Linux x64** hosts and **EXEC/DYN/REL** objects in -these profiles: ELF64 x86-64, ELF32 i386, ELF32 little-endian ARM and ELF32 -little- or big-endian MIPS. Other machines, classes and byte orders, including -AArch64 and big-endian ARM, return `unsupported_target`. Provide an absolute +these profiles: ELF64 x86-64, ELF64 little-endian AArch64, ELF32 i386, ELF32 +little-endian ARM and ELF32 little- or big-endian MIPS. Other machines, classes +and byte orders, including big-endian ARM, return `unsupported_target`. Other +hosts, including Linux arm64, return `capability_unavailable` naming the +detected host before any target is read. Provide an absolute Python executable whose environment already contains unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2: diff --git a/docs/evm-bytecode.md b/docs/evm-bytecode.md index 0b4d97bd65..e215f0028e 100644 --- a/docs/evm-bytecode.md +++ b/docs/evm-bytecode.md @@ -28,6 +28,15 @@ The host must already provide util-linux `prlimit`, normally `/usr/bin/prlimit`; no system tools and changes no user configuration. [Upstream provenance and license](https://github.com/morluto/rea/blob/main/third_party/evmole/README.md) are packaged. +Limiter discovery validates the selected path without launching it. A relative +path or directory is reported as `not_configured`, a missing file as +`executable_missing`, and denied access or another filesystem-probe failure as +`runtime_unavailable`. Recovery names the selected path and +`REA_EVM_PRLIMIT_COMMAND`; denied access asks for execute permission and +filesystem access. The original admission or system code stays in diagnostics. +An available filesystem probe does not verify the selected tool's version or +util-linux compatibility, and REA does not substitute another command. + ## Evidence semantics - `encoding` is required: raw bytes or UTF-8 hex, with optional `0x` prefix and diff --git a/docs/firmware-analysis.md b/docs/firmware-analysis.md index 28d70d4f27..de61db9794 100644 --- a/docs/firmware-analysis.md +++ b/docs/firmware-analysis.md @@ -26,6 +26,9 @@ REA does not install these tools, their dependencies or system packages. It checks the tool's `--version` banner when executing a request. Binwalk must print `binwalk ` and Unblob must print the version alone. Inspection and extraction have independent prerequisites; one does not require the other. +Capability discovery recommends `REA_BINWALK_COMMAND` for missing inspection +and `REA_UNBLOB_COMMAND` for missing extraction. Configuring the other engine +does not enable the unavailable operation. Both require util-linux `prlimit`, normally `/usr/bin/prlimit`; override its absolute path with `REA_FIRMWARE_PRLIMIT_COMMAND` if needed. `rea doctor` does not currently inspect firmware readiness. Engine selection/version checks run diff --git a/docs/javascript-runtime-reconciliation.md b/docs/javascript-runtime-reconciliation.md index 85bfbff428..16b8fdc1e0 100644 --- a/docs/javascript-runtime-reconciliation.md +++ b/docs/javascript-runtime-reconciliation.md @@ -93,6 +93,10 @@ coverage. It does not print captured source text. Runtime targets, frames, scripts, and workers become capture-scoped graph nodes. REA evaluates compatible static candidates conservatively: +V8 Inspector targets whose protocol type is `node` use JavaScript asset/module +candidates for their verified entry location. A target-only correspondence does +not mark that script loaded; load-state evidence requires a script observation. + 1. A captured-source SHA-256 plus one matching mapped location yields a content-and-location match. 2. When no static candidate exists at a mapped location, one unique exact diff --git a/docs/kuna-provider.md b/docs/kuna-provider.md index fdbde7526d..cf5199206c 100644 --- a/docs/kuna-provider.md +++ b/docs/kuna-provider.md @@ -48,6 +48,10 @@ completes. Cleanup retries keep both the observations and the exact file owner. | `REA_KUNA_TIMEOUT_MS` | 120 s | Deadline for one single-function decompile, and for the inventory run REA makes when the export ends without one. | | `REA_KUNA_JOBS` | 1 | `decompile-project --jobs`. 1 is Kuna's reference answer; more jobs finish sooner but lose callee-first prototypes, and change about 40–60% of function bodies on the measured binaries. | +Pseudocode read from a parallel project export includes a prototype-context +limitation in its Evidence, including in function dossiers and composed +workflows. Early single-function results have their own source limitation. + A single-function decompile, inventory, string, graph or xref run that exceeds its deadline fails as `provider_timeout`, naming the setting that owns the deadline and that raising it takes a restart. A project export that exceeds `REA_KUNA_PROJECT_TIMEOUT_MS` is an export failure instead: requests that need the export report `capability_unavailable` with the export's own retry guidance. Windows is not supported yet. Supported targets are ELF and thin Mach-O executables for x86-64, AArch64 and ARM; PE and universal Mach-O are rejected with a typed reason. diff --git a/docs/native-investigation.md b/docs/native-investigation.md index abd1424ea5..83b414f5bd 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -388,6 +388,11 @@ sites remain distinct. A static route does not establish runtime reachability. Swift/closure dispatch requires resolved static references; unavailable ABI forms remain unknown. +Encoded Objective-C method records retain their own locations in dispatch +Evidence. A handler node uses the implementation address with an unknown file +offset unless that function already has an independently located graph node; +the method-record offset does not locate the handler's code. + Incomplete reference kinds do not discard known typed calls or partial provider call references. A `provider_call` with `observed` resolution retains the provider-reported call site and endpoint without claiming generic direct, diff --git a/docs/testing-lanes.md b/docs/testing-lanes.md index 5e54caf8a2..f6ade79672 100644 --- a/docs/testing-lanes.md +++ b/docs/testing-lanes.md @@ -421,7 +421,10 @@ the first cold pseudocode answer comes from a single-function run or an export that already finished, assembly waits for and serves from that export, and pseudocode and both dossier facets match their recorded file spans and digests, a fresh server reuses the export without rebuilding it, staging is empty after -a completed export, and no owned process survives. Pass +a completed export, and no owned process survives. A separate forced 1 ms +project deadline checks that a timed-out inventory recommends raising the +project deadline without changing jobs; a fresh server retries with 60 seconds +and the same two-worker setting. Pass `--targets DIR --functions a,b` to add caller-built binaries laid out as `DIR//` beside `.stripped`; addresses come from the symbol-bearing build. `--compare-ghidra` with `GHIDRA_INSTALL_DIR` runs those @@ -1108,13 +1111,13 @@ coverage limits. ### Offline binary layout `npm run verify:binary:layout` requires Linux x64, GCC/binutils, the -`i686-linux-gnu`, `arm-linux-gnueabi`, `mips-linux-gnu` and `mipsel-linux-gnu` -cross `gcc`/`readelf` toolchains, absolute +`aarch64-linux-gnu`, `i686-linux-gnu`, `arm-linux-gnueabi`, `mips-linux-gnu` and +`mipsel-linux-gnu` cross `gcc`/`readelf` toolchains, absolute `REA_PWNTOOLS_PYTHON` with pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2 and absolute `REA_VERIFY_STRACE_COMMAND`. It compiles ephemeral source-owned ELF fixtures and checks public CLI/MCP, lossless addresses/names, file ranges, mitigation inferences, malformed/unsupported input, original file hashes and -released process ownership. Cross-compiled ELF32 executables and relocatable +released process ownership. Cross-compiled ELF64 AArch64 and ELF32 executables and relocatable objects check the reported class, machine and byte order, and every section name, address, offset and size against GNU `readelf`. Exec syscall tracing must identify only the declared Node/Python launchers; no target binary is executed. Core/debugger claims need diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index 7f5e4d3839..03bcf518d3 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -704,7 +704,9 @@ export const CI_LANES = { agentenv: sameJobs(PATH_SCOPES.agentenv, ["agentenv"]), nativeaot: sameJobs(PATH_SCOPES.nativeaot, ["nativeaot"]), ghidra_bridge: sameJobs(PATH_SCOPES.ghidra_bridge, ["ghidra_bridge"]), - website: sameJobs(PATH_SCOPES.website, ["website"]), + website: sameJobs(PATH_SCOPES.website, ["website"], { + earlyInputs: [".agents/skills/reverse-engineer-anything/**"], + }), }; // An input owner omitted from the registry must never silently lose a lane. for (const [name, paths] of Object.entries(PATH_SCOPES)) diff --git a/scripts/prepare-website.py b/scripts/prepare-website.py index cd284d3e47..33881a25e1 100644 --- a/scripts/prepare-website.py +++ b/scripts/prepare-website.py @@ -7,6 +7,7 @@ import xml.etree.ElementTree as ET from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo from website import PageMetadata, page_url +from website_agents import prepare_discovery, prepare_markdown EXAMPLE_FILES = ( @@ -81,6 +82,8 @@ def prepare_assets(site): prepare_sharing_image(site) prepare_sitemap(site) prepare_notes_example(site) + prepare_discovery(Path(__file__).resolve().parent.parent, site) + prepare_markdown(site) if __name__ == "__main__": diff --git a/scripts/test-website-agents.py b/scripts/test-website-agents.py new file mode 100644 index 0000000000..d5349e1b1a --- /dev/null +++ b/scripts/test-website-agents.py @@ -0,0 +1,109 @@ +"""Check the public build artifacts and Markdown content contract.""" + +import hashlib +import json +from pathlib import Path +import tempfile +import unittest +from zipfile import ZipFile + +from bs4 import BeautifulSoup +import yaml + +from website_agents import prepare_discovery, prepare_markdown + + +ROOT = Path(__file__).resolve().parent.parent + + +class AgentPublishingChecks(unittest.TestCase): + def test_published_skill_digest_covers_every_reference_and_catalogs_resolve(self): + site = ROOT / "website/_site" + index = json.loads((site / ".well-known/agent-skills/index.json").read_text()) + self.assertEqual(index["$schema"], "https://schemas.agentskills.io/discovery/0.2.0/schema.json") + skill, = index["skills"] + self.assertEqual(skill["name"], "reverse-engineer-anything") + self.assertEqual(skill["type"], "archive") + artifact = site / skill["url"].removeprefix("https://rea.tools/") + self.assertEqual(skill["digest"], "sha256:" + hashlib.sha256(artifact.read_bytes()).hexdigest()) + source = ROOT / ".agents/skills/reverse-engineer-anything" + with ZipFile(artifact) as archive: + expected = {f"{source.name}/{p.relative_to(source).as_posix()}": p.read_bytes() + for p in source.rglob("*.md")} + self.assertEqual(set(archive.namelist()), set(expected)) + for name, data in expected.items(): + self.assertEqual(archive.read(name), data) + manifest = archive.read(f"{source.name}/SKILL.md").decode() + metadata = yaml.safe_load(manifest[4:].split("\n---\n", 1)[0]) + self.assertEqual(skill["description"], metadata["description"]) + catalog = json.loads((site / ".well-known/ai-catalog.json").read_text()) + self.assertEqual(catalog["specVersion"], "1.0") + entry, = catalog["entries"] + self.assertEqual(entry["identifier"], "urn:air:rea.tools:skill:reverse-engineer-anything") + self.assertEqual(entry["url"], skill["url"]) + self.assertNotIn("data", entry) + ard = json.loads((site / ".well-known/ard.json").read_text()) + resource, = ard["entries"] + self.assertEqual(resource["url"], skill["url"]) + self.assertTrue(2 <= len(resource["representativeQueries"]) <= 5) + + def test_reference_changes_invalidate_digest_and_yaml_scalars_are_parsed(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / ".agents/skills/reverse-engineer-anything" + source.mkdir(parents=True) + (source / "SKILL.md").write_text( + '---\nname: reverse-engineer-anything\ndescription: >-\n Inspect programs:\n preserve evidence.\n---\n# REA\n' + ) + (source / "references").mkdir() + reference = source / "references/evidence.md" + reference.write_text("First revision") + site = root / "site" + def build(): + prepare_discovery(root, site) + return json.loads((site / ".well-known/agent-skills/index.json").read_text())["skills"][0] + before = build() + self.assertEqual(before["description"], "Inspect programs: preserve evidence.") + self.assertEqual(build(), before) + reference.write_text("Second revision") + self.assertNotEqual(build()["digest"], before["digest"]) + + def test_markdown_preserves_content_and_resources_without_publishing_excluded_pages(self): + with tempfile.TemporaryDirectory() as directory: + site = Path(directory) + page = site / "zh-CN/guides/test/index.html" + page.parent.mkdir(parents=True) + page.write_text('''REA: test +
+

Inspect a program

+
  1. Readinput.exeKeep evidence.
+

Another guide

+
const value = a < b ? "x_y" : "&";\n  keepIndent();
+
Evidence

Hidden evidence remains available.

+ Investigation diagram +
''') + fixture = site / "examples/notes-web/index.html" + fixture.parent.mkdir(parents=True) + fixture.write_text("
Target application
") + fallback = site / "zh-CN/fallback/index.html" + fallback.parent.mkdir(parents=True) + fallback.write_text('
English fallback notice.
') + prepare_markdown(site) + markdown = page.with_suffix(".md").read_text() + self.assertIn('# Inspect a program', markdown) + self.assertIn('const value = a < b ? "x_y" : "&";\n keepIndent();', markdown) + self.assertIn("Hidden evidence remains available.", markdown) + self.assertIn("Shortened display paths.", markdown) + self.assertNotIn("Read`input.exe`", markdown) + self.assertIn("https://rea.tools/zh-CN/guides/other/?q=a%2Fb#code", markdown) + self.assertIn("https://rea.tools/assets/diagram.svg", markdown) + for text in ("Outside navigation", "Contents navigation", "Copy code", "doNotPublish"): + self.assertNotIn(text, markdown) + self.assertFalse(fixture.with_suffix(".md").exists()) + self.assertFalse(fallback.with_suffix(".md").exists()) + html = BeautifulSoup(page.read_text(), "html.parser") + self.assertEqual(html.find("link", rel="alternate")["href"], "https://rea.tools/zh-CN/guides/test/index.md") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-binary-layout.mjs b/scripts/verify-binary-layout.mjs index 64a1b430f4..428daf53f5 100644 --- a/scripts/verify-binary-layout.mjs +++ b/scripts/verify-binary-layout.mjs @@ -38,15 +38,16 @@ if ( !isAbsolute(strace ?? "") ) throw new Error( - "verify:binary:layout requires Linux x64, absolute REA_PWNTOOLS_PYTHON (pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2) and REA_VERIFY_STRACE_COMMAND. gcc, ld, strip and the i686/ARM/MIPS/MIPSel linux-gnu cross gcc and readelf must be available. No target is executed.", + "verify:binary:layout requires Linux x64, absolute REA_PWNTOOLS_PYTHON (pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2) and REA_VERIFY_STRACE_COMMAND. gcc, ld, strip and the AArch64/i686/ARM/MIPS/MIPSel linux-gnu cross gcc and readelf must be available. No target is executed.", ); const run = createVerifierRun(); const execute = promisify(execFile); -const ELF32_TOOLCHAINS = [ - ["i686-linux-gnu", "EM_386", "little"], - ["arm-linux-gnueabi", "EM_ARM", "little"], - ["mips-linux-gnu", "EM_MIPS", "big"], - ["mipsel-linux-gnu", "EM_MIPS", "little"], +const CROSS_TOOLCHAINS = [ + ["aarch64-linux-gnu", "EM_AARCH64", 64, "little"], + ["i686-linux-gnu", "EM_386", 32, "little"], + ["arm-linux-gnueabi", "EM_ARM", 32, "little"], + ["mips-linux-gnu", "EM_MIPS", 32, "big"], + ["mipsel-linux-gnu", "EM_MIPS", 32, "little"], ]; for (const command of [ "gcc", @@ -54,7 +55,7 @@ for (const command of [ "strip", "/usr/bin/prlimit", strace, - ...ELF32_TOOLCHAINS.flatMap(([prefix]) => [ + ...CROSS_TOOLCHAINS.flatMap(([prefix]) => [ `${prefix}-gcc`, `${prefix}-readelf`, ]), @@ -347,14 +348,15 @@ try { cases++; } } - // ELF32 profiles: cross-compiled, never executed, checked against GNU readelf. + // Cross profiles: cross-compiled, never executed, checked against GNU readelf. for (const [ prefix, machine, + bits, byteOrder, kind, flags, - ] of ELF32_TOOLCHAINS.flatMap((toolchain) => [ + ] of CROSS_TOOLCHAINS.flatMap((toolchain) => [ [...toolchain, "executable", ["-fPIE", "-pie"]], [...toolchain, "relocatable", ["-c", "-fPIC"]], ])) { @@ -423,16 +425,18 @@ try { `readelf relocation count: ${name}`, ); for (const [entryIndex, [, offset, info]] of rows.entries()) { - // ELF32 r_info uses the low byte for type and the upper 24 bits for - // the symbol index. readelf has already decoded the file's byte order. + // ELF32 r_info packs an 8-bit type under a 24-bit symbol index; + // ELF64 packs a 32-bit type under a 32-bit index. readelf has already + // decoded the file's byte order. const decodedInfo = BigInt(`0x${info}`); + const typeBits = bits === 32 ? 8n : 32n; expectedRelocations.push({ section_index: section.index, entry_index: entryIndex, reported_offset: BigInt(`0x${offset}`), - type: Number(decodedInfo & 0xffn), + type: Number(decodedInfo & ((1n << typeBits) - 1n)), symbol_table_index: section.link, - symbol_index: Number(decodedInfo >> 8n), + symbol_index: Number(decodedInfo >> typeBits), }); } } @@ -445,7 +449,7 @@ try { const value = await inspect(mode, path); assert.deepEqual(value.architecture, { machine, - bits: 32, + bits, byte_order: byteOrder, }); assert.equal( @@ -1312,7 +1316,8 @@ try { } } } - unsupported.writeUInt16LE(183, 18); + // EM_RISCV: AArch64 (183) is now an admitted profile. + unsupported.writeUInt16LE(243, 18); const core = Buffer.from(object); core.writeUInt16LE(4, 16); for (const [name, bytes, category] of [ @@ -1337,7 +1342,7 @@ try { "invalid_input", ], ["undersized-program-header", undersizedProgramHeader, "invalid_input"], - ["arm64", unsupported, "unsupported_target"], + ["riscv64", unsupported, "unsupported_target"], ["core", core, "unsupported_target"], ]) { const path = join(root.path, name); diff --git a/scripts/verify-real-kuna.mjs b/scripts/verify-real-kuna.mjs index 13659746b8..de799e3ddd 100644 --- a/scripts/verify-real-kuna.mjs +++ b/scripts/verify-real-kuna.mjs @@ -28,7 +28,10 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { inspectKunaInstallation } from "../dist/kuna/KunaInstallation.js"; import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js"; import { AnalysisCleanupLease } from "../dist/artifacts/AnalysisCleanupLease.js"; -import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs"; +import { + requireMcpOperationResult, + requireMcpToolError, +} from "./lib/mcp-verifier-results.mjs"; import { verifyKunaReferences } from "./lib/kuna-reference-verifier.mjs"; import { nativeKunaStrings, @@ -172,7 +175,7 @@ const sampleTree = async (root) => { }; /** One MCP server with a selected provider, sampled while it runs. */ -const openServer = async (provider) => { +const openServer = async (provider, kunaSettings = {}) => { const owner = Symbol(provider.id); pendingServers.add(owner); const transport = new StdioClientTransport({ @@ -185,6 +188,7 @@ const openServer = async (provider) => { REA_PROCESS_RUN_ID: run.run_id, REA_KUNA_COMMAND: kuna, REA_KUNA_CACHE_DIR: cacheRoot, + ...kunaSettings, ...(process.env.GHIDRA_INSTALL_DIR === undefined ? {} : { GHIDRA_INSTALL_DIR: process.env.GHIDRA_INSTALL_DIR }), @@ -229,6 +233,13 @@ const openServer = async (provider) => { structured: result.structuredContent, }; }; + const callError = async (name, arguments_ = {}) => { + const result = await client.callTool( + { name, arguments: arguments_ }, + { timeout: 3_600_000 }, + ); + return requireMcpToolError(result); + }; const close = async (priorFailure) => { clearInterval(timer); await sample(); @@ -270,7 +281,59 @@ const openServer = async (provider) => { // A rejected connection/target still owns its transport and workspace. await close(cause); } - return { call, close }; + return { call, callError, close }; +}; + +/** Verify a forced inventory timeout can be retried without changing jobs. */ +const verifyProjectTimeoutRecovery = async (path, address) => { + const before = createHash("sha256") + .update(await readFile(path)) + .digest("hex"); + const settings = { + REA_KUNA_JOBS: "2", + REA_KUNA_TIMEOUT_MS: "60000", + REA_KUNA_PROJECT_TIMEOUT_MS: "1", + }; + const timed = await openServer({ path, id: "kuna" }, settings); + let timedFailure; + try { + const failure = await timed.callError("procedure_assembly", { + procedure: address, + }); + assert.equal(failure.code, "capability_unavailable"); + assert.match(failure.message, /kuna functions/u); + assert.match(failure.remediation.action, /REA_KUNA_PROJECT_TIMEOUT_MS/u); + assert.doesNotMatch(failure.remediation.action, /REA_KUNA_JOBS/u); + } catch (cause) { + timedFailure = cause; + } finally { + await timed.close(timedFailure); + } + const retry = await openServer( + { path, id: "kuna" }, + { ...settings, REA_KUNA_PROJECT_TIMEOUT_MS: "60000" }, + ); + let retryFailure; + try { + const assembly = await retry.call("procedure_assembly", { + procedure: address, + }); + assert.equal(typeof assembly.value, "string"); + assert.ok(assembly.value.length > 0); + assert.equal(assembly.structured.analysis_profile.parameters.jobs, 2); + } catch (cause) { + retryFailure = cause; + } finally { + await retry.close(retryFailure); + } + assert.equal( + createHash("sha256") + .update(await readFile(path)) + .digest("hex"), + before, + "Kuna timeout recovery changed the target", + ); + return { forced_deadline_ms: 1, retry_deadline_ms: 60000, jobs: 2 }; }; /** Count structuring and diagnostic markers in provider pseudocode. */ @@ -535,6 +598,7 @@ const verifyVariant = async ({ flavor, directory, symbols }, variant) => { }; const results = []; +let timeoutRecovery; const failures = []; let inspectionResourcesClean = true; try { @@ -596,6 +660,11 @@ try { }), }); + timeoutRecovery = await verifyProjectTimeoutRecovery( + built, + fixtureFunctions.get("rea_checksum"), + ); + // Caller-built matrix. for (const pair of callerPairs) for (const variant of [pair.file, `${pair.file}.stripped`]) @@ -650,6 +719,9 @@ const report = { platform: `${process.platform}/${process.arch}`, verifier_run: lifecycle, results, + ...(timeoutRecovery === undefined + ? {} + : { timeout_recovery: timeoutRecovery }), limitations: [ "Targets are analyzed statically; they are never executed.", "Memory is the sampled peak resident set of the MCP server process tree; CPU is its sampled cumulative time. A short-lived peak between 200 ms samples is not observed.", diff --git a/scripts/website.py b/scripts/website.py index 4891347ff9..b6b3a39f19 100644 --- a/scripts/website.py +++ b/scripts/website.py @@ -327,8 +327,9 @@ def main(): refresh_contributors(root) elif args.command == "check": sync_pages(root, check=True) - for name in ("prepare-website.py", "verify-website.py", "test-website.py"): + for name in ("prepare-website.py", "verify-website.py", "test-website.py", "test-website-agents.py"): run_script(root, name) + subprocess.run(["node", "--test", "website/worker.test.mjs"], cwd=root, check=True) elif args.command == "links": run_script(root, "audit-website-links.py", ["--report", args.report] if args.report else []) else: diff --git a/scripts/website_agents.py b/scripts/website_agents.py new file mode 100644 index 0000000000..74a3e0a5d5 --- /dev/null +++ b/scripts/website_agents.py @@ -0,0 +1,116 @@ +"""Build public agent discovery and Markdown from their maintained sources.""" + +import hashlib +import json +from urllib.parse import urljoin +from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo + +from bs4 import BeautifulSoup +from markdownify import MarkdownConverter +import yaml + +from website import NOTES_FIXTURES, PageMetadata, SITE_ORIGIN, page_url + + +def write_json(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def prepare_discovery(root, site): + """Publish the complete skill, including its relative reference documents.""" + source = root / ".agents/skills/reverse-engineer-anything" + skill = (source / "SKILL.md").read_text(encoding="utf-8") + if not skill.startswith("---\n") or "\n---\n" not in skill[4:]: + raise ValueError("Public REA skill needs YAML frontmatter") + metadata = yaml.safe_load(skill[4:].split("\n---\n", 1)[0]) + if not isinstance(metadata, dict): + raise ValueError("Public REA skill needs YAML metadata") + name, description = metadata["name"], metadata["description"] + if name != source.name or not isinstance(description, str) or not description.strip(): + raise ValueError("Invalid public REA skill metadata") + discovery = site / ".well-known/agent-skills" + discovery.mkdir(parents=True, exist_ok=True) + artifact = discovery / f"{name}.zip" + with ZipFile(artifact, "w") as archive: + for path in [source / "SKILL.md", *sorted(source.glob("references/**/*.md"))]: + relative = path.relative_to(source) + entry = ZipInfo(f"{name}/{relative.as_posix()}", (1980, 1, 1, 0, 0, 0)) + entry.create_system = 3 + entry.external_attr = 0o100644 << 16 + archive.writestr(entry, path.read_bytes(), compress_type=ZIP_DEFLATED) + artifact_url = f"{SITE_ORIGIN}/.well-known/agent-skills/{artifact.name}" + write_json(discovery / "index.json", { + "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", + "skills": [{ + "name": name, "type": "archive", "description": description, + "url": artifact_url, + "digest": "sha256:" + hashlib.sha256(artifact.read_bytes()).hexdigest(), + }], + }) + entry = { + "identifier": f"urn:air:rea.tools:skill:{name}", + "displayName": "REA reverse engineering skill", + "description": description, + "type": "application/zip", + "url": artifact_url, + } + write_json(site / ".well-known/ai-catalog.json", { + "specVersion": "1.0", + "host": {"displayName": "REA", "documentationUrl": f"{SITE_ORIGIN}/get-started/"}, + "entries": [entry], + }) + write_json(site / ".well-known/ard.json", {"entries": [{ + **entry, + "representativeQueries": [ + "Explain how a feature works in a shipped executable using REA.", + "Trace an Electron application's behavior back to its packaged JavaScript.", + "Build an AgentEnv bundle from a web application capture.", + ], + }]}) + print("Prepared agent skill archive, discovery index, AI Catalog and ARD manifest.") + + +def prepare_markdown(site): + """Convert rendered reading pages, keeping code and resolving resource URLs.""" + converter = MarkdownConverter(heading_style="ATX", bullets="-", escape_underscores=False) + count = 0 + for path in sorted(site.rglob("*.html")): + if path.relative_to(site).as_posix() in NOTES_FIXTURES: + continue + source = path.read_text(encoding="utf-8") + metadata = PageMetadata() + metadata.feed(source) + # Drafts and English fallbacks keep their HTML indexing policy intact. + if metadata.noindex: + continue + soup = BeautifulSoup(source, "html.parser") + main = soup.find("main") + if main is None: + continue + # Preserve fallback notices and closed disclosures; remove interface chrome. + for node in main.select('script, style, nav, button, svg, .home-page-nav, [aria-hidden="true"]'): + if node.parent is not None: + node.decompose() + # These spans are separate lines in the shared flow component's CSS. + for node in main.select("span.flow-label, span.flow-node, span.flow-note"): + node.name = "div" + canonical = page_url(site, path) + for node in main.select("[href], [src]"): + for attribute in ("href", "src"): + if node.has_attr(attribute): + node[attribute] = urljoin(canonical, node[attribute]) + title = metadata.titles[0] if metadata.titles else "REA" + header = yaml.safe_dump({"title": title, "url": canonical}, allow_unicode=True, sort_keys=False) + markdown = converter.convert_soup(main).strip() + path.with_suffix(".md").write_text(f"---\n{header}---\n\n{markdown}\n", encoding="utf-8") + alternate = page_url(site, path.with_suffix(".md")) + links = ( + f'\n' + f'\n' + f'\n' + f'\n' + ) + path.write_text(source.replace("", links + "", 1), encoding="utf-8") + count += 1 + print(f"Prepared Markdown for {count} reading pages.") diff --git a/src/application/CapabilityInventory.test.ts b/src/application/CapabilityInventory.test.ts index 089685cd33..8425bd1ab9 100644 --- a/src/application/CapabilityInventory.test.ts +++ b/src/application/CapabilityInventory.test.ts @@ -880,6 +880,62 @@ describe("trace_feature requirements", () => { }); }); +describe("firmware operation recovery", () => { + it.each([ + [false, false], + [true, false], + [false, true], + [true, true], + ])( + "keeps inspection=%s and extraction=%s independent", + (inspection, extraction) => { + const inventory = buildCapabilityInventory(status(), { + processCaptureEnabled: false, + firmwareInspectionEnabled: inspection, + firmwareExtractionEnabled: extraction, + }); + for (const [name, enabled, command, otherCommand] of [ + [ + "inspect_firmware_regions", + inspection, + "REA_BINWALK_COMMAND", + "REA_UNBLOB_COMMAND", + ], + [ + "extract_firmware", + extraction, + "REA_UNBLOB_COMMAND", + "REA_BINWALK_COMMAND", + ], + ] as const) { + const tool = inventory.find((candidate) => candidate.name === name); + expect(tool).toMatchObject({ + available: enabled, + reason: enabled ? "available" : "provider_missing", + }); + if (enabled) { + expect(tool?.remediation).toBeNull(); + } else { + expect(tool?.remediation).toContain(command); + expect(tool?.remediation).not.toContain(otherCommand); + expect(tool?.remediation).toContain("prlimit"); + expect(tool?.remediation).toContain("procps ps"); + expect(tool?.remediation?.includes("external extractor")).toBe( + name === "extract_firmware", + ); + } + } + const byName = (left: ToolAvailability, right: ToolAvailability) => + left.name.localeCompare(right.name); + expect( + expandToolAvailability(compactToolAvailability(inventory)).toSorted( + byName, + ), + ).toEqual(inventory.toSorted(byName)); + }, + ); +}); + const HOST_RESTRICTED_TOOLS = [ ["inspect_packet_capture", "packetCapture", "packetCaptureEnabled"], ["inspect_packet_fields", "packetCapture", "packetCaptureEnabled"], diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index c2cb4642c3..b1316b5d6f 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -1,6 +1,9 @@ import { PCAP_CAPTURE_UNSUPPORTED_HOST_REASON } from "../domain/pcap/pcapAnalysis.js"; import { DEFAULT_CALL_RELATIONSHIP_DIRECTION } from "../contracts/enhancedInputs.js"; -import { FIRMWARE_TOOL_REQUIREMENTS } from "../contracts/firmware/firmwareToolRequirements.js"; +import { + FIRMWARE_OPERATION_ENGINES, + FIRMWARE_TOOL_REQUIREMENTS, +} from "../contracts/firmware/firmwareToolRequirements.js"; import { CALL_RELATIONSHIP_TRAVERSALS } from "./CallRelationshipTraversal.js"; import { NATIVE_DISPATCH_METADATA_SOURCES } from "./native/NativeDispatchMetadataInspection.js"; import { NATIVE_UI_ACTION_OPERATIONS } from "./native/NativeUiActionTrace.js"; @@ -595,15 +598,19 @@ const workflowAvailabilityFor = ({ "On Linux x64, set absolute REA_PWNTOOLS_PYTHON to caller-supplied Python with pwntools 4.15.0. No active binary target is required.", ); if (kind === "firmware-provider") { - const { binwalk, unblob } = FIRMWARE_TOOL_REQUIREMENTS; const inspection = name === "inspect_firmware_regions"; + const operation = inspection + ? "inspect_firmware_regions" + : "extract_firmware"; + const requirement = + FIRMWARE_TOOL_REQUIREMENTS[FIRMWARE_OPERATION_ENGINES[operation]]; return hostRestrictedAvailability( policy, inspection ? "firmwareInspection" : "firmwareExtraction", inspection ? policy.firmwareInspectionEnabled : policy.firmwareExtractionEnabled, - `On Linux, provide an absolute ${binwalk.commandEnvironment} (${binwalk.name} ${binwalk.line}, verified with ${binwalk.version}) or ${unblob.commandEnvironment} (${unblob.name} ${unblob.line}, verified with ${unblob.version}) path and util-linux prlimit. Extraction also requires the selected format’s external extractor.`, + `On Linux, provide an absolute ${requirement.commandEnvironment} (${requirement.requirement}) path, util-linux prlimit and procps ps.${inspection ? "" : " Extraction also requires the selected format’s external extractor."}`, ); } if (kind === "android-provider") diff --git a/src/application/binaryDiagnostics/BinaryLayoutService.test.ts b/src/application/binaryDiagnostics/BinaryLayoutService.test.ts index f8a4c6b878..c087a83a64 100644 --- a/src/application/binaryDiagnostics/BinaryLayoutService.test.ts +++ b/src/application/binaryDiagnostics/BinaryLayoutService.test.ts @@ -58,6 +58,7 @@ it("records the decoded layout once in its Evidence", async () => { it.each([ [{ machine: "EM_X86_64", bits: 64, byte_order: "little" }, "x86_64"], + [{ machine: "EM_AARCH64", bits: 64, byte_order: "little" }, "arm64"], [{ machine: "EM_386", bits: 32, byte_order: "little" }, "x86"], [{ machine: "EM_ARM", bits: 32, byte_order: "little" }, "arm"], [{ machine: "EM_MIPS", bits: 32, byte_order: "big" }, "mips"], diff --git a/src/application/binaryDiagnostics/BinaryLayoutService.ts b/src/application/binaryDiagnostics/BinaryLayoutService.ts index 87dd9723b6..757ab7eb6b 100644 --- a/src/application/binaryDiagnostics/BinaryLayoutService.ts +++ b/src/application/binaryDiagnostics/BinaryLayoutService.ts @@ -14,6 +14,7 @@ import { parseAnalysisInput } from "../AnalysisInput.js"; const OPERATION = "inspect_binary_layout"; const EVIDENCE_ARCHITECTURES = { EM_X86_64: "x86_64", + EM_AARCH64: "arm64", EM_386: "x86", EM_ARM: "arm", EM_MIPS: "mips", diff --git a/src/cli/firmwareCommands.ts b/src/cli/firmwareCommands.ts index 43223a58cd..564b563c3e 100644 --- a/src/cli/firmwareCommands.ts +++ b/src/cli/firmwareCommands.ts @@ -1,3 +1,4 @@ +import { FIRMWARE_OPERATION_ENGINES } from "../contracts/firmware/firmwareToolRequirements.js"; import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { z } from "incur"; import { FirmwareAnalysisService } from "../application/firmware/FirmwareAnalysisService.js"; @@ -36,7 +37,7 @@ export const registerFirmwareCommands = ( callerPath, () => service.execute(operation, input, { signal }), service, - operation === "inspect_firmware_regions" ? "binwalk" : "unblob", + FIRMWARE_OPERATION_ENGINES[operation], operation, signal, ).then(projectCliAnalysisResult), diff --git a/src/contracts/firmware/firmwareToolContracts.ts b/src/contracts/firmware/firmwareToolContracts.ts index 2bb8e19980..c210212271 100644 --- a/src/contracts/firmware/firmwareToolContracts.ts +++ b/src/contracts/firmware/firmwareToolContracts.ts @@ -5,7 +5,10 @@ import { import type { ToolContract } from "../toolContractTypes.js"; import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; import { toolContractMetadata } from "../toolEffects.js"; -import { FIRMWARE_TOOL_REQUIREMENTS } from "./firmwareToolRequirements.js"; +import { + FIRMWARE_OPERATION_ENGINES, + FIRMWARE_TOOL_REQUIREMENTS, +} from "./firmwareToolRequirements.js"; /** Explicit local firmware observations and extraction, backed by caller-supplied tools. */ export const FIRMWARE_TOOL_CONTRACTS = [ @@ -13,7 +16,7 @@ export const FIRMWARE_TOOL_CONTRACTS = [ name: "inspect_firmware_regions", ...toolContractMetadata("inspect_firmware_regions"), kind: "firmware-provider", - description: `Inspect signature candidates in a local firmware file with ${FIRMWARE_TOOL_REQUIREMENTS.binwalk.requirement}. Returns offsets, reported lengths, descriptions and raw confidence values. Length validation and runtime addresses remain unknown. No extraction or target execution. Linux, caller-supplied tool, util-linux prlimit and procps ps required; 128 MiB input limit, one worker. Adjust analysis_timeout_ms for the analysis wall-clock budget (default 120000); fixed per-process limits are unchanged.`, + description: `Inspect signature candidates in a local firmware file with ${FIRMWARE_TOOL_REQUIREMENTS[FIRMWARE_OPERATION_ENGINES.inspect_firmware_regions].requirement}. Returns offsets, reported lengths, descriptions and raw confidence values. Length validation and runtime addresses remain unknown. No extraction or target execution. Linux, caller-supplied tool, util-linux prlimit and procps ps required; 128 MiB input limit, one worker. Adjust analysis_timeout_ms for the analysis wall-clock budget (default 120000); fixed per-process limits are unchanged.`, inputSchema: firmwareInputSchemas.inspect_firmware_regions, outputSchema: evidenceResultOf( firmwareResultSchemas.inspect_firmware_regions, @@ -29,7 +32,7 @@ export const FIRMWARE_TOOL_CONTRACTS = [ name: "extract_firmware", ...toolContractMetadata("extract_firmware"), kind: "firmware-provider", - description: `Extract caller-selected firmware bytes with ${FIRMWARE_TOOL_REQUIREMENTS.unblob.requirement} into an absent output directory. Returns verified child paths/digests, task derivations, unknown chunks and uncovered selected input ranges, extraction diagnostics and explicit extraction_gaps rows naming unextracted regions, missing extractor dependencies and a continuation_id referencing shared extraction_continuations recovery instructions. Preserves the default sandbox; publishes regular files only. Linux, caller-supplied tool, format-specific extractors, util-linux prlimit and procps ps required. No mounts or extracted-code execution. Select a returned native file with open_binary for Ghidra analysis; decompressed offsets are not original-file addresses.`, + description: `Extract caller-selected firmware bytes with ${FIRMWARE_TOOL_REQUIREMENTS[FIRMWARE_OPERATION_ENGINES.extract_firmware].requirement} into an absent output directory. Returns verified child paths/digests, task derivations, unknown chunks and uncovered selected input ranges, extraction diagnostics and explicit extraction_gaps rows naming unextracted regions, missing extractor dependencies and a continuation_id referencing shared extraction_continuations recovery instructions. Preserves the default sandbox; publishes regular files only. Linux, caller-supplied tool, format-specific extractors, util-linux prlimit and procps ps required. No mounts or extracted-code execution. Select a returned native file with open_binary for Ghidra analysis; decompressed offsets are not original-file addresses.`, inputSchema: firmwareInputSchemas.extract_firmware, outputSchema: evidenceResultOf(firmwareResultSchemas.extract_firmware), examples: [ diff --git a/src/contracts/firmware/firmwareToolRequirements.ts b/src/contracts/firmware/firmwareToolRequirements.ts index fb0816fe97..824a43816a 100644 --- a/src/contracts/firmware/firmwareToolRequirements.ts +++ b/src/contracts/firmware/firmwareToolRequirements.ts @@ -1,3 +1,5 @@ +import type { FirmwareOperation } from "../../domain/firmware/firmwareAnalysis.js"; + /** Derive public support facts from one audited, stable release number. */ const defineToolRequirement = (release: { readonly name: string; @@ -39,3 +41,9 @@ export const FIRMWARE_TOOL_REQUIREMENTS = { /** Caller-supplied firmware engine selected by the operation. */ export type FirmwareEngineName = keyof typeof FIRMWARE_TOOL_REQUIREMENTS; + +/** The engine required by each existing firmware operation, shared by discovery and execution. */ +export const FIRMWARE_OPERATION_ENGINES = { + inspect_firmware_regions: "binwalk", + extract_firmware: "unblob", +} as const satisfies Readonly>; diff --git a/src/contracts/native/binaryDiagnosticsToolContracts.ts b/src/contracts/native/binaryDiagnosticsToolContracts.ts index eba10d04fa..7bc97ecbc4 100644 --- a/src/contracts/native/binaryDiagnosticsToolContracts.ts +++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts @@ -245,7 +245,7 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_binary_layout"), kind: "native-provider", description: - "Inspect an explicit local binary without launching it as a host process. Returns file-backed section/segment ranges, linked addresses, original symbol/REL/RELA table identities, complete encoded RELR tables and upstream-derived offsets with unknown per-word locations/addends, raw name bytes, dependency/interpreter names and static mitigation inferences inline with artifact SHA-256 Evidence. Profile: EXEC/DYN/REL ELF64 x86-64, ELF32 i386, ELF32 little-endian ARM and ELF32 little- or big-endian MIPS on Linux x64 via caller-supplied Python with unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2. Runtime addresses/library paths, overall relocation inventory completeness and complete derived GOT/PLT coverage remain unknown. Zero executable entry values mean absence; relocatable entries are not applicable. Upstream can emulate PLT instructions with Unicorn for derived static maps. Uses bounded complete output and owned temporary files/processes; malformed, unsupported or oversized objects return no partial success. Select detail summary to retain the complete layout Evidence in this session and receive only its summary view, then inspect sections, symbols, mitigations or linkage with inspect_analysis_view. pwntools_timeout_ms is each owned pwntools command's deadline (omitted means 30000 ms, range 1..2147483647, next call). cpuSeconds 30 and the output, diagnostic, and address-space byte allowances have no runtime override.", + "Inspect an explicit local binary without launching it as a host process. Returns file-backed section/segment ranges, linked addresses, original symbol/REL/RELA table identities, complete encoded RELR tables and upstream-derived offsets with unknown per-word locations/addends, raw name bytes, dependency/interpreter names and static mitigation inferences inline with artifact SHA-256 Evidence. Profile: EXEC/DYN/REL ELF64 x86-64, ELF64 little-endian AArch64, ELF32 i386, ELF32 little-endian ARM and ELF32 little- or big-endian MIPS on Linux x64 via caller-supplied Python with unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2. Runtime addresses/library paths, overall relocation inventory completeness and complete derived GOT/PLT coverage remain unknown. Zero executable entry values mean absence; relocatable entries are not applicable. Upstream can emulate PLT instructions with Unicorn for derived static maps. Uses bounded complete output and owned temporary files/processes; malformed, unsupported or oversized objects return no partial success. Select detail summary to retain the complete layout Evidence in this session and receive only its summary view, then inspect sections, symbols, mitigations or linkage with inspect_analysis_view. pwntools_timeout_ms is each owned pwntools command's deadline (omitted means 30000 ms, range 1..2147483647, next call). cpuSeconds 30 and the output, diagnostic, and address-space byte allowances have no runtime override.", inputSchema: inspectBinaryLayoutRequestSchema, outputSchema: analysisDetailOutputSchemaOf( binaryLayoutSchema, diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index 45a42da025..16ba5d0b66 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -224,7 +224,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "search_strings", - `Search every provider-reported string object or candidate using literal matching by default or regex when requested. Results are deterministic and complete over that provider inventory, not every text sequence in artifact bytes. Kuna scans mapped sequences of at least five characters, including unterminated fragments, without computing references. Ghidra may omit packed or undefined literals, including Rust strings; an empty result does not prove text is absent. If an address is known, read_bytes and xrefs inspect its bytes and references. Hopper retains typed byte decoding, native display, and explicit decoding unknowns. Adjacent long-literal fragments match independently. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`, + `Search every provider-reported string object or candidate using literal matching by default or regex when requested. Results are deterministic and complete over that provider inventory, not every text sequence in artifact bytes. Kuna scans mapped sequences of at least five characters, including unterminated fragments, without computing references. Ghidra may omit packed or undefined literals, including Rust strings; an empty result does not prove text is absent. If an address is known, xrefs can look for references; read_bytes can inspect bytes where supported (not available with Kuna). Hopper retains typed byte decoding, native display, and explicit decoding unknowns. Adjacent long-literal fragments match independently. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`, z.object({ ...nativeSearchInput, detail: analysisDetailSchema }), { evidenceParameters: "omit-detail" }, ), diff --git a/src/domain/javascript/javascriptRuntimeLoadState.ts b/src/domain/javascript/javascriptRuntimeLoadState.ts index 275de19d37..e49d60c8f7 100644 --- a/src/domain/javascript/javascriptRuntimeLoadState.ts +++ b/src/domain/javascript/javascriptRuntimeLoadState.ts @@ -57,7 +57,10 @@ const classifyLayer = ( const direct = new Map(); const ambiguous = new Set(); const incomplete = new Set(); - for (const match of matches) + // A target's entry location establishes correspondence, not script presence. + for (const match of matches.filter( + ({ entity_kind }) => entity_kind === "script", + )) if ( match.status === "matched" && match.static_layer_id === layer.layerId && diff --git a/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts b/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts index b0be2de5bd..613d47175c 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts @@ -94,7 +94,10 @@ const targetEntity = ( frameKey: null, scriptKey: null, location, - staticKind: "renderer", + staticKind: + capture.kind === "v8-inspector" && target.type === "node" + ? "javascript" + : "renderer", sourceSha256: null, label: target.title, properties: { diff --git a/src/domain/javascript/javascriptSemanticQuerySchemas.ts b/src/domain/javascript/javascriptSemanticQuerySchemas.ts index 6002e09c41..fa211e757d 100644 --- a/src/domain/javascript/javascriptSemanticQuerySchemas.ts +++ b/src/domain/javascript/javascriptSemanticQuerySchemas.ts @@ -42,9 +42,11 @@ export const javaScriptSemanticQuerySeedSchema = z.discriminatedUnion("kind", [ kind: z.literal("property"), name: z.string(), }), + // Analysis preserves the complete literal endpoint, including the empty + // string, so the seed matches any retained value exactly, as event does. z.strictObject({ kind: z.literal("endpoint"), - value: z.string().min(1), + value: z.string(), }), z.strictObject({ kind: z.literal("event"), diff --git a/src/domain/native/binaryLayout.test.ts b/src/domain/native/binaryLayout.test.ts index 172c0c0e99..1d4e8a9410 100644 --- a/src/domain/native/binaryLayout.test.ts +++ b/src/domain/native/binaryLayout.test.ts @@ -122,6 +122,9 @@ it("retains high linked values and NOBITS without inventing file backing", () => }); it.each([ + [{ machine: "EM_AARCH64", bits: 64, byte_order: "little" }, true], + [{ machine: "EM_AARCH64", bits: 64, byte_order: "big" }, false], + [{ machine: "EM_AARCH64", bits: 32, byte_order: "little" }, false], [{ machine: "EM_386", bits: 32, byte_order: "little" }, true], [{ machine: "EM_ARM", bits: 32, byte_order: "little" }, true], [{ machine: "EM_MIPS", bits: 32, byte_order: "little" }, true], @@ -150,7 +153,7 @@ it.each([ if (problem === "file-backed-outside") section.file_backing = "file"; if (problem === "header-outside") section.header_location.offset = "0x80"; if (problem === "wrong-index") section.index = 1; - if (problem === "wrong-machine") value.architecture.machine = "EM_AARCH64"; + if (problem === "wrong-machine") value.architecture.machine = "EM_RISCV"; if (problem === "outside-elf64") value.entry_point.reported_value = "0x10000000000000000"; if (problem === "noncanonical-base64") section.name.bytes_base64 = "AR=="; diff --git a/src/domain/native/binaryLayout.ts b/src/domain/native/binaryLayout.ts index c60a728d51..d80c9ca4ca 100644 --- a/src/domain/native/binaryLayout.ts +++ b/src/domain/native/binaryLayout.ts @@ -35,7 +35,7 @@ const binaryLayoutObjectSchema = z.strictObject({ format: z.literal("elf"), architecture: z.union([ z.strictObject({ - machine: z.literal("EM_X86_64"), + machine: z.enum(["EM_X86_64", "EM_AARCH64"]), bits: z.literal(64), byte_order: z.literal("little"), }), diff --git a/src/domain/native/nativeInvestigationGraph.test.ts b/src/domain/native/nativeInvestigationGraph.test.ts index 13cb25c001..4975466635 100644 --- a/src/domain/native/nativeInvestigationGraph.test.ts +++ b/src/domain/native/nativeInvestigationGraph.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { objcSwiftMetadataSchema } from "./objcSwiftMetadata.js"; +import { + inspectNativeDispatchMetadata, + objcSwiftMetadataSchema, +} from "./objcSwiftMetadata.js"; import { nativeInvestigationGraphSchema, @@ -191,9 +194,18 @@ describe("native investigation graph traces", () => { }); describe("Interface Builder dispatch joins", () => { - it.each(["BuildViewController", "BuildView", "BuildButton", "BuildHandler"])( - "joins an action to its declared %s receiver class", - (className) => { + it.each([ + { + className: "BuildViewController", + recordOffset: 1944, + existingOffset: null, + }, + { className: "BuildView", recordOffset: 6040, existingOffset: null }, + { className: "BuildButton", recordOffset: null, existingOffset: null }, + { className: "BuildHandler", recordOffset: 1944, existingOffset: 1916 }, + ])( + "joins the declared $className without mixing record and implementation offsets", + ({ className, recordOffset, existingOffset }) => { const ui = buildInterfaceBuilderAnalysis({ targetSha256: "b".repeat(64), toolVersion: "test", @@ -221,38 +233,80 @@ describe("Interface Builder dispatch joins", () => { ], limits: interfaceBuilderLimitsSchema.parse({}), }); - const joined = joinInterfaceBuilderDispatch(ui.graph, { - objc_classes: [], - objc_protocols: [], - objc_categories: [], - swift_decls: [], - objc_ivars: [], - objc_protocol_records: [], + const recordLocation = { + address: recordOffset === null ? "0x1000" : "0x2000", + file_offset: recordOffset, + }; + const recordEvidence = [ + { + kind: recordOffset === null ? "symbol" : "binary_metadata", + description: "Explicit synthetic method-record location", + location: recordLocation, + artifact_path: "/synthetic/Fixture", + artifact_sha256: "b".repeat(64), + }, + ]; + const mappedEvidence = [ + { + ...recordEvidence[0], + kind: "reference", + description: "Explicit independently mapped handler", + location: { address: "0x1000", file_offset: existingOffset }, + }, + ]; + const inputGraph = + existingOffset === null + ? ui.graph + : nativeInvestigationGraphSchema.parse({ + ...ui.graph, + nodes: [ + ...ui.graph.nodes, + { + id: "native:function:0x1000", + kind: "function", + name: "Already mapped handler", + location: { address: "0x1000", file_offset: existingOffset }, + attributes: {}, + evidence: mappedEvidence, + }, + ], + }); + const metadata = objcSwiftMetadataSchema.parse({ + ...inspectNativeDispatchMetadata([], 1), objc_dispatch_implementations: [ { class_name: className, selector: "buildTapped:", method_type: "instance", implementation_address: "0x1000", - location: { address: "0x1000", file_offset: 0 }, + location: recordLocation, decode: { status: "decoded", reason: null }, - evidence, + evidence: recordEvidence, }, ], - swift_conformances: [], - swift_dispatch_slots: [], - swift_symbols: [], - relative_pointers: [], - coverage: [], - db_save_result: null, }); + const joined = joinInterfaceBuilderDispatch(inputGraph, metadata); expect(joined.edges).toContainEqual( expect.objectContaining({ relation: "objc_dispatch", resolution: "resolved", to: "native:function:0x1000", + evidence: expect.arrayContaining(recordEvidence), }), ); + const handler = joined.nodes.find( + ({ id }) => id === "native:function:0x1000", + ); + expect(handler?.location).toEqual({ + address: "0x1000", + file_offset: existingOffset, + }); + expect(handler?.evidence).toEqual( + existingOffset === null ? recordEvidence : mappedEvidence, + ); + expect(metadata.objc_dispatch_implementations[0]?.location).toEqual( + recordLocation, + ); }, ); }); diff --git a/src/domain/native/nativeInvestigationGraph.ts b/src/domain/native/nativeInvestigationGraph.ts index b51ad6f63e..03c56bc705 100644 --- a/src/domain/native/nativeInvestigationGraph.ts +++ b/src/domain/native/nativeInvestigationGraph.ts @@ -391,7 +391,8 @@ export const joinInterfaceBuilderDispatch = ( name: `${implementation.class_name} ${selector}`, location: { address: implementation.implementation_address, - file_offset: implementation.location.file_offset, + // The implementation location identifies the method record, not its code. + file_offset: null, }, attributes: { class_name: implementation.class_name, diff --git a/src/evm/EvmoleInterfaceProvider.ts b/src/evm/EvmoleInterfaceProvider.ts index 2a72fef70d..4dfa48ba8e 100644 --- a/src/evm/EvmoleInterfaceProvider.ts +++ b/src/evm/EvmoleInterfaceProvider.ts @@ -1,4 +1,5 @@ import { EvmAdmissionError } from "./EvmAdmissionError.js"; +import type { ProviderRejectionCode } from "../domain/providerSelection.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { safeParseUtf8Json } from "../domain/safeJson.js"; @@ -115,6 +116,7 @@ type EvmLimiterSelection = { readonly diagnostics: Readonly>; }; type EvmLimiterUnavailable = EvmLimiterSelection & { + readonly code: ProviderRejectionCode; readonly reason: string; readonly cause: unknown; }; @@ -159,16 +161,39 @@ const selectEvmLimiter = async ( if (cause instanceof EvmAdmissionError) return err({ path, + code: "not_configured", diagnostics: { ...diagnostics, code: cause.code, ...cause.detail, }, - reason: cause.code, + reason: `Set REA_EVM_PRLIMIT_COMMAND to an absolute executable regular file for caller-supplied util-linux prlimit. The selected path ${path} ${cause.code === "prlimit_not_absolute" ? "is not absolute" : "is not a regular file"}.`, cause, }); - const reason = `Offline EVM interface inspection requires caller-supplied util-linux prlimit: ${path}; ${cause instanceof Error ? cause.message : String(cause)}`; - return err({ path, diagnostics, reason, cause }); + const systemCode = + cause instanceof Error && + "code" in cause && + typeof cause.code === "string" + ? cause.code + : undefined; + const permissionDenied = systemCode === "EACCES" || systemCode === "EPERM"; + const missing = systemCode === "ENOENT" || systemCode === "ENOTDIR"; + const recovery = permissionDenied + ? "Grant execute permission and filesystem access to the selected file, or set REA_EVM_PRLIMIT_COMMAND to an accessible absolute executable regular file for util-linux prlimit." + : missing + ? "Set REA_EVM_PRLIMIT_COMMAND to an existing absolute executable regular file for util-linux prlimit." + : "Check the selected file and filesystem access, or set REA_EVM_PRLIMIT_COMMAND to an accessible absolute executable regular file for util-linux prlimit."; + const reason = `Offline EVM interface inspection requires caller-supplied util-linux prlimit: ${path}; ${cause instanceof Error ? cause.message : String(cause)}. ${recovery}`; + return err({ + path, + code: missing ? "executable_missing" : "runtime_unavailable", + diagnostics: { + ...diagnostics, + ...(systemCode === undefined ? {} : { system_code: systemCode }), + }, + reason, + cause, + }); } }; @@ -193,7 +218,7 @@ export const evmLimiterAvailability = async ( } : { status: "unavailable", - code: "executable_missing", + code: selected.error.code, reason: selected.error.reason, // The limiter owner does not yet author a separate repair (#8508). remediation: selected.error.reason, diff --git a/src/firmware/FirmwareProvider.ts b/src/firmware/FirmwareProvider.ts index 377095073d..c0653816ab 100644 --- a/src/firmware/FirmwareProvider.ts +++ b/src/firmware/FirmwareProvider.ts @@ -1,3 +1,4 @@ +import { FIRMWARE_OPERATION_ENGINES } from "../contracts/firmware/firmwareToolRequirements.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { assertArtifactCommandPath } from "../process/ArtifactCommandPath.js"; import { join } from "node:path"; @@ -332,8 +333,7 @@ export class FirmwareProvider implements FirmwareAnalysisPort { const environment = snapshotEnvironment(this.environment); let root: PrivateRuntimeRoot | undefined; let rootLease: AnalysisCleanupLease | undefined; - const engineName = - request.operation === "inspect_firmware_regions" ? "binwalk" : "unblob"; + const engineName = FIRMWARE_OPERATION_ENGINES[request.operation]; const withCaller = (phase: FirmwareDeadline) => options?.signal === undefined ? phase.signal diff --git a/src/kuna/KunaProjectReader.ts b/src/kuna/KunaProjectReader.ts index fb86a7ef9e..f9df8dc7dc 100644 --- a/src/kuna/KunaProjectReader.ts +++ b/src/kuna/KunaProjectReader.ts @@ -350,7 +350,12 @@ export class KunaProjectReader { .slice(text.value.indexOf("\n") + 1) .replace(/\n+$/u, ""), files, - limitations: [], + limitations: + this.project.manifest.inputs.jobs > 1 + ? [ + "Parallel Kuna project export does not propagate callee-first prototypes between workers; function signatures and call arguments may differ from a serial export.", + ] + : [], }); } diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index f3640d91c9..04baa3eaa6 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -258,7 +258,7 @@ const exportUnavailable = ( "REA reads Kuna settings when it starts, so restart REA, then reopen the target."; const next = failure instanceof AnalysisTimeoutError - ? `To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above ${String(failure.timeoutMs)} ms or raise REA_KUNA_JOBS in the environment that starts REA. ${restart}` + ? `To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above ${String(failure.timeoutMs)} ms in the environment that starts REA. ${restart}` : failure instanceof AnalysisResourceConstraintError && failure.remediationAction !== undefined ? // A fixed buffer (`memory`) is no setting, so a restart cannot help. @@ -1057,6 +1057,9 @@ export class KunaSessionClient implements AnalysisClient { err( new AnalysisInputError(operation, { userMessage: kunaDataEntryMessage(entry, index.data.get(entry)!), + conformsToInputContract: true, + remediationAction: + "Inspect the data address with address_name or xrefs, or select a procedure entry from list_procedures. Do not repeat the unchanged call.", }), ); const selected = (entry: KunaInventoryFunction) => { @@ -1076,6 +1079,9 @@ export class KunaSessionClient implements AnalysisClient { return err( new AnalysisInputError(operation, { userMessage: `${address} is not a procedure entry Kuna found; list_procedures shows the entries it found.`, + conformsToInputContract: true, + remediationAction: + "Inspect list_procedures and select a discovered entry if it answers your question. An unlisted address is not proof of a function; REA cannot use it as a Kuna procedure in this session. Do not repeat the unchanged call.", }), ); return refused(entry) ? dataRefusal(entry) : selected(entry); @@ -1088,9 +1094,20 @@ export class KunaSessionClient implements AnalysisClient { return dataRefusal(named[0]); return err( new AnalysisInputError(operation, { + conformsToInputContract: true, + remediationAction: + matches.length === 0 && named.length === 0 + ? field === "procedure" + ? "Use search_procedures or list_procedures to inspect Kuna's discovered entries, then select a matching entry. Do not repeat the unchanged call." + : "Inspect the selected target for the intended address and use that address explicitly. Do not repeat the unchanged call." + : matches.length === 0 + ? "Inspect the data addresses with address_name or xrefs, or select a procedure entry from list_procedures. Do not repeat the unchanged call." + : "Select the intended entry by its exact address. Do not repeat the unchanged call.", userMessage: matches.length === 0 && named.length === 0 - ? `Kuna found no procedure named ${selector}; search_procedures finds similar names.` + ? field === "procedure" + ? `Kuna found no procedure named ${selector}; search_procedures finds similar names.` + : `Kuna found no inventory entry named ${selector}.` : matches.length === 0 ? `${selector} names ${String(named.length)} Kuna entries that lie in Mach-O data regions (${named.map(({ address }) => address).join(", ")}); they are data, not procedures.` : `${selector} names ${String(matches.length)} Kuna entries (${matches.map(({ address }) => address).join(", ")}); select one by address.`, diff --git a/src/native/pwntools/PwntoolsDecoder.ts b/src/native/pwntools/PwntoolsDecoder.ts index 60b8bc967a..5bfbc1ea1f 100644 --- a/src/native/pwntools/PwntoolsDecoder.ts +++ b/src/native/pwntools/PwntoolsDecoder.ts @@ -69,8 +69,12 @@ import { PWNTOOLS_LIMITS } from "./PwntoolsRelease.js"; import { PWNTOOLS_PROVIDER_IDENTITY } from "./PwntoolsProviderIdentity.js"; import { pwntoolsInputBytes } from "./PwntoolsInputLimit.js"; -const HOST_REQUIREMENT = - "The initial real-verified pwntools artifact profile supports Linux x64 only."; +/** Names the detected host so a host refusal is not read as a target refusal. */ +export const pwntoolsHostRequirement = ( + platform: NodeJS.Platform, + arch: string, +): string => + `The real-verified pwntools artifact profile runs only on a Linux x64 host; this host is ${platform}/${arch}. The selected target was not read.`; /** The pwntools profile runs only on Linux x64; discovery and execution share this check. */ export const pwntoolsHostSupported = ( @@ -290,13 +294,18 @@ export class PwntoolsDecoder< throw new AnalysisCancelledError(OPERATION, { cause: options.signal.reason, }); - if (!pwntoolsHostSupported(process.platform, process.arch)) + if (!pwntoolsHostSupported(process.platform, process.arch)) { + const requirement = pwntoolsHostRequirement( + process.platform, + process.arch, + ); throw new AnalysisCapabilityUnavailableError( this.identity.id, OPERATION, - HOST_REQUIREMENT, - { userMessage: HOST_REQUIREMENT }, + requirement, + { userMessage: requirement }, ); + } if (!isAbsolute(selectedPath)) throw pwntoolsUnavailable( "Set REA_PWNTOOLS_PYTHON to an absolute caller-supplied Python executable with pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2; REA never installs Python or packages.", diff --git a/src/native/pwntools/PwntoolsFailures.test.ts b/src/native/pwntools/PwntoolsFailures.test.ts index bec9ac4be3..fb3840b066 100644 --- a/src/native/pwntools/PwntoolsFailures.test.ts +++ b/src/native/pwntools/PwntoolsFailures.test.ts @@ -2,6 +2,10 @@ import { expect, it } from "vitest"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { OwnedCommandFailure } from "../../process/OwnedCommand.js"; +import { + pwntoolsHostRequirement, + pwntoolsHostSupported, +} from "./PwntoolsDecoder.js"; import { pwntoolsDecoderFailure } from "./PwntoolsFailures.js"; it.each([undefined, null, false, 0, "stop", { stop: true }, new Error("stop")])( @@ -149,3 +153,11 @@ it("retains legacy owner causes without guessing cancellation from error text", "ProviderAdapterError", ); }); + +it("names the refused host instead of implying the target was refused", () => { + expect(pwntoolsHostSupported("linux", "arm64")).toBe(false); + const message = pwntoolsHostRequirement("linux", "arm64"); + expect(message).toContain("Linux x64 host"); + expect(message).toContain("this host is linux/arm64"); + expect(message).toContain("The selected target was not read."); +}); diff --git a/src/server/ToolResultDelivery.ts b/src/server/ToolResultDelivery.ts index 04302ad04a..fea17b889c 100644 --- a/src/server/ToolResultDelivery.ts +++ b/src/server/ToolResultDelivery.ts @@ -12,6 +12,7 @@ import { type AnalysisErrorProjection, } from "../domain/analysisErrorProjection.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { NoBinaryOpenError } from "../domain/configurationErrors.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import type { Result } from "../domain/result.js"; import { estimateJsonStorage } from "../application/JsonAuthenticationHeadroom.js"; @@ -108,12 +109,22 @@ export class ToolResultDelivery { selected?: JsonValue, viewSource?: JsonValue, ) => Readonly> = () => ({}), + private readonly noTargetRecovery?: string, ) { this.maximumResponseBytes = maximumResponseBytes; this.resultBudgetBytes = maximumResponseBytes - 1024; Object.freeze(this); } + /** Bind scope-specific recovery without mutating a shared delivery policy. */ + withNoTargetRecovery(recovery: string): ToolResultDelivery { + return new ToolResultDelivery( + this.maximumResponseBytes, + this.nativeContinuation, + recovery, + ); + } + /** Serialize a plain application result or its actionable error. */ toCallToolResult( result: Result, @@ -193,10 +204,20 @@ export class ToolResultDelivery { error: AnalysisError, targetContent: boolean, ): CallToolResult { - return this.projectedErrorResult( - projectAnalysisError(error), - targetContent, - ); + const projected = projectAnalysisError(error); + if ( + error instanceof NoBinaryOpenError && + this.noTargetRecovery !== undefined + ) + return this.projectedErrorResult( + { + ...projected, + message: this.noTargetRecovery, + remediation: { action: this.noTargetRecovery }, + }, + targetContent, + ); + return this.projectedErrorResult(projected, targetContent); } /** Retain valid partial Evidence and reference it only after recording succeeds. */ diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 17d6143487..1ccaf78e7b 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -3,6 +3,7 @@ import { type CapabilityServiceOptions, } from "../composition/capabilityServices.js"; import { registerToolFamilies } from "../contracts/toolFamilyIds.js"; +import { SCOPED_NO_TARGET_RECOVERY } from "./recoveryInstructions.js"; import { AnalysisSettings } from "../application/AnalysisSettings.js"; import { parseMcpInputBudget } from "../config/mcpInputBudget.js"; import { registerAnalysisSettingsTools } from "./registerAnalysisSettingsTools.js"; @@ -174,7 +175,13 @@ export const createServer = ( options.providerEnvironment, environment, ); - const delivery = selectToolResultDelivery(environment, options.delivery); + const baseDelivery = selectToolResultDelivery(environment, options.delivery); + const delivery = + session !== undefined && + advertisedToolFamilies !== undefined && + !advertisedToolFamilies.has("session") + ? baseDelivery.withNoTargetRecovery(SCOPED_NO_TARGET_RECOVERY) + : baseDelivery; const capabilities = createCapabilityServices({ environment, providerEnvironment, diff --git a/src/server/recoveryInstructions.ts b/src/server/recoveryInstructions.ts index 60483ea373..ee64745c24 100644 --- a/src/server/recoveryInstructions.ts +++ b/src/server/recoveryInstructions.ts @@ -16,3 +16,7 @@ export const recoveryInstructions = ( runtimeSettingsAvailable: boolean, ): string => `${RECOVERY_START}${runtimeSettingsAvailable ? RUNTIME_SETTINGS_RECOVERY : LAUNCH_SETTINGS_RECOVERY}${EXPLICIT_ARGUMENT_RECOVERY}${RECOVERY_END}`; + +/** Recovery when a scoped session cannot advertise the target lifecycle. */ +export const SCOPED_NO_TARGET_RECOVERY = + "No target is open, and this MCP connection does not advertise open_binary. Preserve needed outputs before restarting; retained in-memory Evidence and Unknown history will not carry over. Add session to REA_MCP_TOOL_FAMILIES in the environment that starts REA and restart the server; then call open_binary with the chosen local path."; diff --git a/tests/acceptance/applications/javascriptEventPayload.test.ts b/tests/acceptance/applications/javascriptEventPayload.test.ts index c4689250de..fbe3a9422a 100644 --- a/tests/acceptance/applications/javascriptEventPayload.test.ts +++ b/tests/acceptance/applications/javascriptEventPayload.test.ts @@ -162,3 +162,64 @@ cliTest( }, 120_000, ); + +cliTest( + "traces a retained empty literal endpoint by its preserved value", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-empty-endpoint-seed-"); + await writeFile( + join(root, "main.js"), + 'export const load = async () => (await fetch("")).json();\n', + ); + const analyzed = await cli.run({ + arguments: [ + "analyze-javascript-application", + root, + "--artifact-format", + "directory", + "--json", + ], + timeoutMs: 60_000, + }); + expect(analyzed.exitCode, analyzed.stderr).toBe(0); + const application = toolContract( + "analyze_javascript_application", + ).outputSchema.parse(analyzed.json); + const graph = javascriptApplicationAnalysisResultSchema.parse( + application.normalized_result, + ).semantic_graph; + // Analysis keeps the complete literal endpoint, here the empty string. + const retained = new Set( + graph.nodes + .filter( + (node) => + (node.kind === "request" || node.kind === "response") && + node.properties.endpoint === "", + ) + .map((node) => node.node_id), + ); + expect(retained.size).toBeGreaterThan(0); + const request = join(root, "endpoint-query.json"); + await writeFile( + request, + JSON.stringify({ + application, + query: { + seed: { kind: "endpoint", value: "" }, + direction: "forward-influence", + }, + }), + ); + const traced = await cli.run({ + arguments: ["trace-javascript-semantics", request, "--json"], + timeoutMs: 60_000, + }); + expect(traced.exitCode, traced.stdout).toBe(0); + const trace = javaScriptSemanticTraceResultSchema.parse( + toolContract("trace_javascript_semantics").outputSchema.parse(traced.json) + .normalized_result, + ); + expect(new Set(trace.seed_node_ids)).toEqual(retained); + }, + 120_000, +); diff --git a/tests/acceptance/applications/javascriptNodeTargetReconciliationCli.test.ts b/tests/acceptance/applications/javascriptNodeTargetReconciliationCli.test.ts new file mode 100644 index 0000000000..357bc04959 --- /dev/null +++ b/tests/acceptance/applications/javascriptNodeTargetReconciliationCli.test.ts @@ -0,0 +1,52 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { javascriptRuntimeReconciliationResultSchema } from "../../../src/domain/javascript/javascriptRuntimeReconciliationSchemas.js"; +import { nodeReconciliationFixture } from "../../fixtures/nodeRuntimeReconciliation.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "reconciles synthetic Node target entry Evidence through %s", + async (surface, { cli }) => { + const input = nodeReconciliationFixture(false); + let document: unknown; + if (surface === "CLI") { + const root = await createTestTempDirectory( + "rea-node-reconciliation-cli-", + ); + const path = join(root, "input.json"); + await writeFile(path, JSON.stringify(input)); + const response = await cli.run({ + arguments: ["reconcile-javascript-runtime", path, "--format", "json"], + }); + expect(response.exitCode, response.stderr).toBe(0); + document = response.json; + } else { + const call = await cli.sharedMcpCall!(); + const response = await call("reconcile_javascript_runtime", input); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + document = response.structuredContent; + } + const result = javascriptRuntimeReconciliationResultSchema.parse( + (document as { normalized_result: unknown }).normalized_result, + ); + expect(result.reconciliations).toEqual([ + expect.objectContaining({ + entity_kind: "target", + status: "matched", + reason: "unique-location-match", + confidence: "medium", + candidate_static_count: 1, + }), + ]); + expect(result.summary).toMatchObject({ + runtime_scripts: 0, + static_loaded: 0, + static_not_observed: 0, + }); + expect( + result.graph.edges.filter(({ relation }) => relation === "observed_as"), + ).toHaveLength(1); + }, +); diff --git a/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts b/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts index 1f2156ce19..b1e816758b 100644 --- a/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts +++ b/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts @@ -677,6 +677,11 @@ it.runIf(unsupportedHost)( const projected = projectAnalysisError(result.error); expect(projected.message).toContain("Linux x64"); expect(projected.remediation.action).toContain("Linux x64"); + // The refusal names this host, not the target, which was never read. + expect(projected.message).toContain( + `this host is ${process.platform}/${process.arch}`, + ); + expect(projected.message).toContain("The selected target was not read."); }, ); diff --git a/tests/boundary/evm/evmLimiterAvailability.test.ts b/tests/boundary/evm/evmLimiterAvailability.test.ts index d8ad789adf..7684146571 100644 --- a/tests/boundary/evm/evmLimiterAvailability.test.ts +++ b/tests/boundary/evm/evmLimiterAvailability.test.ts @@ -1,4 +1,7 @@ import { expect, it } from "vitest"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { withVerifierCleanup } from "../../../scripts/lib/verifier-cleanup.mjs"; import { buildCapabilityInventory } from "../../../src/application/CapabilityInventory.js"; @@ -41,33 +44,43 @@ it("probes the default limiter with executable and regular-file checks", async ( ]); }); -it("uses the exact absolute override and reports a failed executable check", async () => { - const limiter = "/fixture/util-linux/prlimit"; - const availability = await evmLimiterAvailability( - { REA_EVM_PRLIMIT_COMMAND: limiter }, - { - ...linux, - fs: { - access: async (path) => { - expect(path).toBe(limiter); - throw Object.assign(new Error("permission denied"), { - code: "EACCES", - }); - }, - stat: async () => { - throw new Error("stat must not run after access failure"); +it.each(["EACCES", "EPERM"])( + "uses the exact absolute override and reports denied access: %s", + async (code) => { + const limiter = "/fixture/util-linux/prlimit"; + const availability = await evmLimiterAvailability( + { REA_EVM_PRLIMIT_COMMAND: limiter }, + { + ...linux, + fs: { + access: async (path) => { + expect(path).toBe(limiter); + throw Object.assign(new Error("permission denied"), { + code, + }); + }, + stat: async () => { + throw new Error("stat must not run after access failure"); + }, }, }, - }, - ); + ); - expect(availability).toMatchObject({ - status: "unavailable", - code: "executable_missing", - reason: expect.stringContaining(limiter), - diagnostics: { limiter_path: limiter }, - }); -}); + expect(availability).toMatchObject({ + status: "unavailable", + code: "runtime_unavailable", + reason: expect.stringContaining(limiter), + diagnostics: { limiter_path: limiter, system_code: code }, + }); + if (availability === undefined) throw new Error("Expected limiter probe"); + const entry = buildCapabilityInventory(status, { + ...basePolicy, + evmLimiterAvailability: availability, + }).find(({ name }) => name === "inspect_evm_interface"); + expect(entry?.remediation).toContain("REA_EVM_PRLIMIT_COMMAND"); + expect(entry?.remediation).toContain("execute permission"); + }, +); it("rejects a relative override and non-file without probing an alternate command", async () => { const relative = await evmLimiterAvailability( @@ -84,7 +97,8 @@ it("rejects a relative override and non-file without probing an alternate comman ); expect(relative).toMatchObject({ status: "unavailable", - reason: "prlimit_not_absolute", + code: "not_configured", + reason: expect.stringContaining("absolute executable regular file"), diagnostics: { limiter_path: "prlimit", code: "prlimit_not_absolute", @@ -104,11 +118,53 @@ it("rejects a relative override and non-file without probing an alternate comman ); expect(directory).toMatchObject({ status: "unavailable", - reason: "prlimit_not_file", + code: "not_configured", + reason: expect.stringContaining("absolute executable regular file"), diagnostics: expect.objectContaining({ code: "prlimit_not_file" }), }); }); +it("delivers selected-path recovery from real filesystem probes to capability output", async () => { + const root = await createTestTempDirectory("rea-evm-limiter-recovery-"); + const executable = join(root, "caller-selected-limiter"); + await writeFile(executable, "not executed by the readiness probe", { + mode: 0o700, + }); + for (const [path, expectedCode, expectedReason] of [ + ["prlimit", "not_configured", "absolute executable regular file"], + [root, "not_configured", "absolute executable regular file"], + [ + join(root, "missing-limiter"), + "executable_missing", + "REA_EVM_PRLIMIT_COMMAND", + ], + [executable, null, null], + ] as const) { + const availability = await evmLimiterAvailability( + { REA_EVM_PRLIMIT_COMMAND: path }, + linux, + ); + expect(availability?.code).toBe(expectedCode); + expect(availability?.diagnostics.limiter_path).toBe(path); + if (availability === undefined) throw new Error("Expected limiter probe"); + const entry = buildCapabilityInventory(status, { + ...basePolicy, + evmLimiterAvailability: availability, + }).find(({ name }) => name === "inspect_evm_interface"); + if (expectedReason === null) { + expect(entry).toMatchObject({ available: true, remediation: null }); + } else { + expect(entry).toMatchObject({ + available: false, + reason: "provider_missing", + }); + expect(entry?.remediation).toContain(expectedReason); + expect(entry?.remediation).toContain(path); + expect(entry?.remediation).toContain("util-linux prlimit"); + } + } +}); + it("rechecks on each observation so inventory follows the selected executable's current state", async () => { let accessible = true; const fs = { diff --git a/tests/boundary/evm/evmoleInterfaceProvider.test.ts b/tests/boundary/evm/evmoleInterfaceProvider.test.ts index 9edd10309e..1e98c38490 100644 --- a/tests/boundary/evm/evmoleInterfaceProvider.test.ts +++ b/tests/boundary/evm/evmoleInterfaceProvider.test.ts @@ -970,7 +970,7 @@ it.runIf(!unsupportedHost).each(["directory", "missing-interpreter"])( }); if (scenario === "directory") expect(result.error).toMatchObject({ - reason: "prlimit_not_file", + reason: expect.stringContaining("absolute executable regular file"), diagnostics: { limiter_path: limiter, code: "prlimit_not_file", diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index 351223eb05..e5710daa25 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -879,7 +879,14 @@ it.each([ ["install.sh", { code: true, runtime: true, package: true, evm: false }], [ ".agents/skills/reverse-engineer-anything/SKILL.md", - { code: true, runtime: true, docs: true, package: false, evm: false }, + { + code: true, + runtime: true, + docs: true, + website: true, + package: false, + evm: false, + }, ], [ "scripts/verify-real-ghidra.mjs", diff --git a/tests/boundary/inspector/javascriptRuntimeNodeTarget.test.ts b/tests/boundary/inspector/javascriptRuntimeNodeTarget.test.ts new file mode 100644 index 0000000000..c56840195b --- /dev/null +++ b/tests/boundary/inspector/javascriptRuntimeNodeTarget.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "vitest"; +import { reconcileJavaScriptRuntimeEvidence } from "../../../src/application/javascript/JavaScriptRuntimeReconciliationService.js"; +import { javascriptRuntimeReconciliationResultSchema } from "../../../src/domain/javascript/javascriptRuntimeReconciliationSchemas.js"; +import { nodeReconciliationFixture } from "../../fixtures/nodeRuntimeReconciliation.js"; + +test.each([false, true])( + "matches a synthetic Node entry target without inventing script presence: %s", + (scriptObserved) => { + const reconciled = reconcileJavaScriptRuntimeEvidence( + nodeReconciliationFixture(scriptObserved), + ); + if (!reconciled.ok) throw reconciled.error; + const result = javascriptRuntimeReconciliationResultSchema.parse( + reconciled.value.evidence.normalized_result, + ); + const target = result.reconciliations.find( + ({ entity_kind }) => entity_kind === "target", + ); + expect(target).toMatchObject({ + status: "matched", + basis: "artifact-path", + confidence: "medium", + reason: "unique-location-match", + candidate_static_count: 1, + }); + expect( + result.graph.nodes.find( + ({ node_id }) => node_id === target?.static_node_id, + )?.kind, + ).toBe("javascript-asset"); + expect(result.summary).toMatchObject({ + runtime_scripts: scriptObserved ? 1 : 0, + static_loaded: scriptObserved ? 1 : 0, + static_not_observed: 0, + }); + }, +); diff --git a/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts b/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts index 941253bf05..d0ce556ea1 100644 --- a/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts +++ b/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts @@ -14,6 +14,11 @@ import { } from "../../../src/domain/analysisErrorCore.js"; import { inspectNativeDispatchMetadata } from "../../../src/domain/native/objcSwiftMetadata.js"; import { nativeInvestigationTraceSchema } from "../../../src/domain/native/nativeInvestigationGraph.js"; +import { + buildInterfaceBuilderAnalysis, + interfaceBuilderLimitsSchema, +} from "../../../src/domain/apple/interfaceBuilderGraph.js"; +import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; import { err, ok, type Result } from "../../../src/domain/result.js"; import { createServer } from "../../../src/server/createServer.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; @@ -111,6 +116,12 @@ const noClassifiedReferences = { }; type Plan = { + readonly ui?: ReturnType; + readonly implementations?: ReturnType< + typeof inspectNativeDispatchMetadata + >["objc_dispatch_implementations"]; + readonly action?: string; + readonly maxDepth?: number; readonly references?: unknown; readonly referencesFailure?: AnalysisError; readonly uiFailure?: AnalysisError; @@ -142,6 +153,12 @@ function publicTrace(plan: Plan, maxNodes = 12, maxEdges = 500) { plan.referencesFailure !== undefined ) return err(plan.referencesFailure); + if (operation === "decode_interface_builder" && plan.ui !== undefined) + return ok( + createAnalysisExecution(jsonValueSchema.parse(plan.ui), identity, { + subject, + }), + ); if (operation === "decode_interface_builder") return err( new AnalysisCapabilityUnavailableError( @@ -157,7 +174,12 @@ function publicTrace(plan: Plan, maxNodes = 12, maxEdges = 500) { target_sha256: sha256, provider: identity, analysis_profile_digest: null, - result: inspectNativeDispatchMetadata([], 1), + result: { + ...inspectNativeDispatchMetadata([], 1), + ...(plan.implementations === undefined + ? {} + : { objc_dispatch_implementations: plan.implementations }), + }, }, identity, { subject }, @@ -220,10 +242,10 @@ function publicTrace(plan: Plan, maxNodes = 12, maxEdges = 500) { const result = await client.callTool({ name: "trace_native_ui_action", arguments: { - action: "0x1000", + action: plan.action ?? "0x1000", max_nodes: maxNodes, max_edges: maxEdges, - max_depth: 1, + max_depth: plan.maxDepth ?? 1, }, }); return result; @@ -237,6 +259,93 @@ const traceFrom = (result: { structuredContent?: unknown }) => }) .parse(result.structuredContent).normalized_result; +it.each([ + { label: "thin record", recordOffset: 1944 }, + { label: "selected-slice container record", recordOffset: 6040 }, + { label: "symbol-only record", recordOffset: null }, +])( + "keeps handler offsets distinct from $label Evidence through public MCP", + async ({ recordOffset }) => { + // Explicit synthetic decoded facts; no AppKit, Ghidra or Kuna runtime claim. + const ui = buildInterfaceBuilderAnalysis({ + targetSha256: sha256, + toolVersion: "synthetic", + documents: [ + { + relativePath: "Main.storyboardc/scene.nib/objects.nib", + archiveSha256: "b".repeat(64), + documentKind: "storyboard_scene", + raw: { + "com.apple.ibtool.document.objects": { + receiver: { customClass: "AuditController" }, + button: { class: "UIButton" }, + }, + "com.apple.ibtool.document.connections": { + button: [ + { + type: "action", + label: "runAction:", + destinationId: "receiver", + }, + ], + }, + }, + }, + ], + limits: interfaceBuilderLimitsSchema.parse({}), + }); + const recordLocation = { + address: recordOffset === null ? "0x10000077c" : "0x100000798", + file_offset: recordOffset, + }; + const recordEvidence = [ + { + kind: + recordOffset === null + ? ("symbol" as const) + : ("binary_metadata" as const), + description: "Explicit synthetic method entry", + location: recordLocation, + artifact_path: subject.path, + artifact_sha256: sha256, + }, + ]; + const response = await publicTrace({ + ui, + action: "runAction:", + maxDepth: 4, + references: completeReferences([]), + callees: [], + implementations: [ + { + class_name: "AuditController", + selector: "runAction:", + method_type: "instance", + implementation_address: "0x10000077c", + location: recordLocation, + decode: { status: "decoded", reason: null }, + evidence: recordEvidence, + }, + ], + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const trace = traceFrom(response); + const handler = trace.nodes.find( + ({ id }) => id === "native:function:0x10000077c", + ); + expect(handler?.location).toEqual({ + address: "0x10000077c", + file_offset: null, + }); + expect(handler?.evidence).toEqual(recordEvidence); + expect( + trace.edges.find(({ relation }) => relation === "objc_dispatch") + ?.evidence, + ).toEqual(expect.arrayContaining(recordEvidence)); + expect(recordEvidence[0]?.location).toEqual(recordLocation); + }, +); + it("keeps typed callsite evidence and distinct fallback-provider routes for mixed reference kinds", async () => { const response = await publicTrace({}); expect(response.isError).not.toBe(true); diff --git a/tests/boundary/mcp/targetContentFramingMcp.test.ts b/tests/boundary/mcp/targetContentFramingMcp.test.ts index 2b575e5368..044509f6ab 100644 --- a/tests/boundary/mcp/targetContentFramingMcp.test.ts +++ b/tests/boundary/mcp/targetContentFramingMcp.test.ts @@ -1,4 +1,8 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { serverInstructions } from "../../../src/server/serverInstructions.js"; +import { SCOPED_NO_TARGET_RECOVERY } from "../../../src/server/recoveryInstructions.js"; +import { NoBinaryOpenError } from "../../../src/domain/configurationErrors.js"; import { UNTRUSTED_CONTENT_INSTRUCTIONS } from "../../../src/server/toolResultEncoding.js"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import type { @@ -8,6 +12,8 @@ import type { import { afterEach, describe, expect, it } from "vitest"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { peExportImage } from "../../fixtures/binaryDiagnostics/nativeExports.js"; import { observed } from "../../fixtures/analysisExecution.js"; import { err, ok } from "../../../src/domain/result.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; @@ -272,3 +278,83 @@ describe.each([ }, ); }); + +it.each([ + { scope: "direct", sessionAvailable: false }, + { scope: "session,direct", sessionAvailable: true }, + { scope: undefined, sessionAvailable: true }, +])( + "guides a missing target using the advertised scope ($scope)", + async ({ scope, sessionAvailable }) => { + const environment = { ...process.env }; + if (scope === undefined) delete environment.REA_MCP_TOOL_FAMILIES; + else environment.REA_MCP_TOOL_FAMILIES = scope; + const client = await connect(environment); + const names = (await client.listTools()).tools.map(({ name }) => name); + expect(names.includes("open_binary")).toBe(sessionAvailable); + const { error } = parseMcpToolError( + await client.callTool({ + name: "read_bytes", + arguments: { address: "0x1000", length: 4 }, + }), + ); + expect(error.code).toBe("target_unavailable"); + if (sessionAvailable) { + expect(error.remediation.action).toBe(new NoBinaryOpenError().message); + } else { + expect(error.message).toBe(SCOPED_NO_TARGET_RECOVERY); + expect(error.remediation.action).toBe(SCOPED_NO_TARGET_RECOVERY); + expect(error.remediation.action).toContain("REA_MCP_TOOL_FAMILIES"); + expect(error.remediation.action).toContain("open_binary"); + await expect( + client.callTool({ name: "open_binary", arguments: { path: "/tmp/x" } }), + ).rejects.toThrow(/disabled/u); + } + }, +); + +it("warns that retained evidence does not survive the scoped restart", async () => { + const directory = await createTestTempDirectory("rea-scoped-retained-"); + const path = join(directory, "fixture.exe"); + await writeFile(path, peExportImage({})); + const before = await connect({ + ...process.env, + REA_MCP_TOOL_FAMILIES: "direct,native,application", + }); + const observed = await before.callTool({ + name: "inspect_native_exports", + arguments: { path, detail: "summary" }, + }); + expect(observed.isError).not.toBe(true); + const summary = observed.structuredContent as { + normalized_result: { parent_evidence_id: string }; + }; + const view = { + source: { + kind: "retained-evidence", + evidence_id: summary.normalized_result.parent_evidence_id, + }, + view: { kind: "native-exports", collection: "exports" }, + }; + expect( + (await before.callTool({ name: "inspect_analysis_view", arguments: view })) + .isError, + ).not.toBe(true); + const failure = parseMcpToolError( + await before.callTool({ + name: "read_bytes", + arguments: { address: "0x1000", length: 4 }, + }), + ); + expect(failure.error.remediation.action).toBe(SCOPED_NO_TARGET_RECOVERY); + await before.close(); + + const after = await connect({ + ...process.env, + REA_MCP_TOOL_FAMILIES: "session,direct,native,application", + }); + const missing = parseMcpToolError( + await after.callTool({ name: "inspect_analysis_view", arguments: view }), + ); + expect(missing.error.code).toBe("evidence_integrity_mismatch"); +}); diff --git a/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts b/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts index a633278d30..329ee3c8b2 100644 --- a/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts +++ b/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts @@ -201,7 +201,7 @@ test("traces a procedure found only by its alias through the composed feature tr test("omits a data entry an alias names while keeping the code entry that shares it", async ({ mcp, }) => { - const { search, observe } = await aliasSession( + const { client, search, observe } = await aliasSession( mcp, "rea-kuna-alias-macho-", { @@ -234,4 +234,52 @@ test("omits a data entry an alias names while keeping the code entry that shares ), ); expect(await search({ pattern: "global_count" })).toEqual([]); + const { error } = parseMcpToolError( + await client.callTool({ + name: "procedure_address", + arguments: { procedure: "global_count" }, + }), + ); + expect(error.recovery).toBeUndefined(); + expect(error.remediation.action).toContain("address_name"); +}); + +test("reports lookup failures with recovery for the selected target, not the input schema", async ({ + mcp, +}) => { + const { client } = await aliasSession(mcp, "rea-kuna-lookup-recovery-", { + functions: VERSIONED, + }); + for (const [procedure, guidance] of [ + ["0x1335", "list_procedures"], + ["absent_name", "search_procedures"], + ["audit_transform", "exact address"], + ]) { + const response = await client.callTool({ + name: "procedure_address", + arguments: { procedure }, + }); + const { error } = parseMcpToolError(response); + expect(error.code).toBe("invalid_request"); + expect(error.recovery).toBeUndefined(); + expect(error.remediation.action).toContain(guidance); + expect(error.remediation.action).not.toContain("input schema"); + } + const assemblyError = parseMcpToolError( + await client.callTool({ + name: "procedure_assembly", + arguments: { procedure: "0x1335" }, + }), + ).error; + expect(assemblyError.remediation.action).toContain("Kuna procedure"); + expect(assemblyError.remediation.action).not.toContain("decompile"); + const missingAddress = parseMcpToolError( + await client.callTool({ + name: "xrefs", + arguments: { address: "absent_name" }, + }), + ).error; + expect(missingAddress.recovery).toBeUndefined(); + expect(missingAddress.message).toContain("no inventory entry named"); + expect(missingAddress.remediation.action).toContain("address"); }); diff --git a/tests/boundary/providers/kuna/kunaParallelMcp.test.ts b/tests/boundary/providers/kuna/kunaParallelMcp.test.ts new file mode 100644 index 0000000000..a4133e93a9 --- /dev/null +++ b/tests/boundary/providers/kuna/kunaParallelMcp.test.ts @@ -0,0 +1,83 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { AnalysisProviderRegistry } from "../../../../src/application/binary/AnalysisProviderRegistry.js"; +import { composeBinarySession } from "../../../../src/application/binary/BinarySessionComposition.js"; +import { parseEvidence } from "../../../../src/domain/evidence.js"; +import { KunaProvider } from "../../../../src/kuna/KunaProvider.js"; +import { elfNoteFixture } from "../../../../src/native/elf/ElfNotes.fixture.js"; +import { createServer } from "../../../../src/server/createServer.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { mcpTest } from "../../../support/mcp/mcpFixture.js"; +import { writeFakeKuna } from "./fakeKuna.js"; + +const test = mcpTest.skipIf(process.platform === "win32"); +const caveat = + "Parallel Kuna project export does not propagate callee-first prototypes"; + +for (const jobs of [1, 2]) { + test(`retains source limitations in batch and paged dossier with ${String(jobs)} worker(s)`, async ({ + mcp, + }) => { + const root = await createTestTempDirectory("rea-kuna-parallel-mcp-"); + const kuna = await writeFakeKuna(root, { + functions: [{ name: "main", addr: "0x1000" }], + }); + const target = join(root, "program"); + await writeFile(target, elfNoteFixture({ bits: 64, type: 2 })); + const session = composeBinarySession( + new AnalysisProviderRegistry( + [ + new KunaProvider( + { + REA_KUNA_COMMAND: kuna, + REA_KUNA_CACHE_DIR: join(root, "cache"), + REA_KUNA_JOBS: String(jobs), + }, + { platform: process.platform }, + ), + ], + "kuna", + ), + ); + const client = await mcp.connect( + createServer({ kind: "session", session }), + ); + const call = async (name: string, arguments_: Record) => { + const response = await client.callTool({ name, arguments: arguments_ }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + return response.structuredContent; + }; + await call("open_binary", { path: target }); + // Assembly waits for the complete project, avoiding timing-based source selection. + await call("procedure_assembly", { procedure: "main" }); + const batch = parseEvidence( + await call("batch_decompile", { + addresses: ["0x1000"], + detail: "complete", + }), + ); + expect(batch.limitations.some((item) => item.includes(caveat))).toBe( + jobs > 1, + ); + const dossier = parseEvidence( + await call("analyze_function", { procedure: "main", detail: "summary" }), + ); + const summary = dossier.normalized_result as { parent_evidence_id: string }; + const view = parseEvidence( + await call("inspect_analysis_view", { + source: { + kind: "retained-evidence", + evidence_id: summary.parent_evidence_id, + }, + view: { kind: "native", facet: "pseudocode" }, + }), + ); + expect(view.limitations.some((item) => item.includes(caveat))).toBe( + jobs > 1, + ); + await call("close_binary", {}); + }); +} diff --git a/tests/boundary/providers/kuna/kunaProvider.test.ts b/tests/boundary/providers/kuna/kunaProvider.test.ts index 2b94145d65..690b4fee9c 100644 --- a/tests/boundary/providers/kuna/kunaProvider.test.ts +++ b/tests/boundary/providers/kuna/kunaProvider.test.ts @@ -235,6 +235,39 @@ it("lists the selected document without starting target analysis", async () => { } }); +it("qualifies only parallel project pseudocode, including dossier results", async () => { + const { open } = await session( + "rea-kuna-parallel-context-", + { delayMs: 300 }, + { REA_KUNA_JOBS: "2" }, + ); + const client = open(); + const caveat = + "Parallel Kuna project export does not propagate callee-first prototypes"; + const cold = await run(client, "procedure_pseudo_code", { + procedure: "main", + }); + expect(cold.rawResult).toMatchObject({ source: { kind: "single-function" } }); + expect(cold.limitations.join("\n")).not.toContain(caveat); + await run(client, "procedure_assembly", { procedure: "main" }); + const warm = await run(client, "procedure_pseudo_code", { + procedure: "main", + }); + expect(warm.rawResult).toMatchObject({ source: { kind: "project-export" } }); + expect(warm.limitations.join("\n")).toContain(caveat); + const dossier = await run(client, "analyze_function", { procedure: "main" }); + expect(dossier.limitations.join("\n")).toContain(caveat); + expect( + (dossier.result as { limitations: string[] }).limitations.join("\n"), + ).toContain(caveat); + const missing = await run(client, "procedure_pseudo_code", { + procedure: "broken", + }); + expect(missing.result).toBeNull(); + expect(missing.limitations.join("\n")).not.toContain(caveat); + await client.close(); +}); + it("answers a cold request from one function, then serves the finished export", async () => { const { kuna, cacheRoot, open } = await session("rea-kuna-cold-", { delayMs: 300, @@ -866,7 +899,7 @@ it.each([ behavior: { project: "hang" } as const, environment: { REA_KUNA_PROJECT_TIMEOUT_MS: "300" }, cause: "AnalysisTimeoutError", - next: "To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above 300 ms or raise REA_KUNA_JOBS in the environment that starts REA. REA reads Kuna settings when it starts, so restart REA, then reopen the target.", + next: "To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above 300 ms in the environment that starts REA. REA reads Kuna settings when it starts, so restart REA, then reopen the target.", details: { timeout_ms: 300, captured_output: { truncated: expect.any(Boolean) }, diff --git a/tests/fixtures/nodeRuntimeReconciliation.ts b/tests/fixtures/nodeRuntimeReconciliation.ts new file mode 100644 index 0000000000..cf6e75ba23 --- /dev/null +++ b/tests/fixtures/nodeRuntimeReconciliation.ts @@ -0,0 +1,73 @@ +import { createJavaScriptRuntimeObservationEvidence } from "../../src/application/javascript/JavaScriptRuntimeObservationEvidence.js"; +import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../src/contracts/javascript/javascriptRuntimeReconciliationExample.js"; +import { javascriptRuntimeObservationSchema } from "../../src/domain/javascript/javascriptRuntimeObservation.js"; +import { V8_INSPECTOR_PROVIDER_IDENTITY } from "../../src/inspector/providerIdentity.js"; + +/** Synthetic, schema-validated capture; this is not a live Inspector observation. */ +export const nodeReconciliationFixture = (scriptObserved: boolean) => { + const location = { + kind: "file", + file_path: "/Applications/Example.app/Contents/Resources/app/renderer.js", + }; + const runtime = javascriptRuntimeObservationSchema.parse({ + runtime: { + product: "node.js/v24.22.1", + protocol_version: "1.3", + v8_version: null, + }, + target: { + target_id: "synthetic-node", + protocol_type: "node", + attached: null, + location, + runtime_kind: "unknown", + runtime_kind_authority: "not-declared", + }, + capture: { + observation_ms: 100, + events_observed: scriptObserved ? 1 : 0, + events_retained: scriptObserved ? 1 : 0, + events_dropped: 0, + metadata_bytes_retained: 0, + truncated: false, + truncation_reasons: [], + }, + scripts: { + items: scriptObserved + ? [ + { + script_key: `v8_script_${"4".repeat(64)}`, + location, + execution_context_key: null, + cdp_hash: null, + length: null, + is_module: null, + status: "observed-loaded", + }, + ] + : [], + observed_total: scriptObserved ? 1 : 0, + excluded: { unsupported_location: 0, invalid_protocol_value: 0 }, + }, + execution_contexts: [], + directly_observed: [], + unavailable_without_instrumentation: [], + unknowns: ["Synthetic bounded capture."], + limitations: ["Synthetic fixture, not a live runtime."], + }); + return { + static_layers: [{ analysis: JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE }], + runtime_observations: [ + createJavaScriptRuntimeObservationEvidence( + "observe_javascript_runtime", + { + inspector_endpoint: "http://127.0.0.1:9229", + target_id: "synthetic-node", + observation_ms: 100, + }, + runtime, + V8_INSPECTOR_PROVIDER_IDENTITY, + ), + ], + }; +}; diff --git a/website/README.md b/website/README.md index 47ea63ca14..d8cd2d5f41 100644 --- a/website/README.md +++ b/website/README.md @@ -486,10 +486,66 @@ The mask SVG is drawn from the seventh decoded mask. It selects zero-based position 21 and compares its character code with 55 (`7`). Keep the figure, solver constants and evidence notes aligned when changing this case. +## Agent discovery and Markdown + +`scripts/website_agents.py` runs during asset preparation, after translations. +It generates these public files in ignored `_site/`: + +- `.well-known/agent-skills/index.json`: the draft v0.2.0 skills discovery index. + Its archive contains the maintained + `.agents/skills/reverse-engineer-anything/SKILL.md` and Markdown references. + The name and description come from YAML frontmatter; SHA-256 covers the + complete, reproducible ZIP, so reference changes invalidate the digest too. +- `.well-known/ard.json`: an ARD manifest with representative analyst queries. +- `.well-known/ai-catalog.json`: an AI Catalog 1.0 document listing that same + downloadable skill. These advertise installation instructions for the local + tool, without claiming a hosted analysis endpoint. +- `index.md` beside each indexable rendered reading page, including translations. + Markdown retains code, evidence disclosures and image descriptions, with + absolute resource links. Drafts, labeled English fallbacks and target example + applications retain their HTML and indexing policy. + +The [skills discovery draft](https://github.com/cloudflare/agent-skills-discovery-rfc), +[ARD proposal](https://agenticresourcediscovery.org/spec/) and +[AI Catalog proposal](https://ai-catalog.io/) are evolving formats. ARD v0.91 +uses `ard.json` and `rel="ard"`; AI Catalog uses its separate `ai-catalog.json` +envelope. Both entries point to the same ZIP with the registered +`application/zip` media type. + +Cloudflare's Worker selects Markdown when an explicit `Accept: text/markdown` +has higher quality than HTML. HTML wins ties and remains the default. Successful +reading-page responses include `Vary: Accept` and an alternate Markdown Link; +Markdown uses `Content-Type: text/markdown; charset=utf-8`. Static `_headers` +advertises discovery resources, sets discovery CORS to `*`, and supplies MIME +types. Build-generated HTML `` elements also advertise these resources. + +GitHub Pages and the Python preview serve the same files and HTML links, with +direct Markdown URLs such as `guides/javascript/index.md`. They do not execute +the Worker or apply Cloudflare's `_headers`: content negotiation, custom MIME +overrides and discovery CORS headers are Cloudflare behavior. The publish +workflow packages `.well-known` explicitly because `upload-pages-artifact` +excludes dot directories by default. + +`python3 scripts/website.py check` includes artifact/content regressions and +Worker request-contract tests. To check the actual local Cloudflare runtime +after preparing assets, use the repository's pinned Node.js and Wrangler: + +```sh +npx wrangler@4.149.0 dev --cwd website --env production --local +curl -i -H 'Accept: text/markdown' http://localhost:8787/guides/javascript/ +curl -I http://localhost:8787/.well-known/agent-skills/index.json +``` + +The `production` environment here skips the preview build and uses the already +verified artifact; `--local` runs on your machine. Check HTML and Markdown, +GET and HEAD, discovery CORS, the downloaded ZIP digest, and unknown routes. +Production remains published only by the manual website workflow. + ## Cloudflare Workers `wrangler.toml` serves `website/_site/` through Workers Static Assets. There is -no Worker script or frontend framework. Before a preview or deployment, Wrangler +one small Worker (`worker.mjs`) for Markdown content negotiation and no frontend +framework. Before a preview or deployment, Wrangler runs the Python scripts to build the language editions, prepare the example ZIP and verify the website. Node.js and Python 3 must be available; the commands below use a pinned Wrangler version without adding it to REA's package dependencies. diff --git a/website/public/_headers b/website/public/_headers new file mode 100644 index 0000000000..2065dd5df3 --- /dev/null +++ b/website/public/_headers @@ -0,0 +1,11 @@ +/* + Link: ; rel="ard", ; rel="ai-catalog", ; rel="related"; type="application/json"; title="Agent skills index" + +/.well-known/* + Access-Control-Allow-Origin: * + +/.well-known/ai-catalog.json + Content-Type: application/ai-catalog+json + +/*.md + Content-Type: text/markdown; charset=utf-8 diff --git a/website/requirements.txt b/website/requirements.txt index e6099d25dc..67e1541cfd 100644 --- a/website/requirements.txt +++ b/website/requirements.txt @@ -2,3 +2,6 @@ CairoSVG==2.9.1 # Standard gettext PO/POT catalogs; no translation service is used by builds. polib==1.2.0 +# Build Markdown reading pages and parse the maintained skill's YAML metadata. +markdownify==1.2.3 +PyYAML==6.0.3 diff --git a/website/worker.mjs b/website/worker.mjs new file mode 100644 index 0000000000..b87bf8acfb --- /dev/null +++ b/website/worker.mjs @@ -0,0 +1,103 @@ +// Negotiate prebuilt Markdown while keeping the static asset service's routing. +function prefersMarkdown(accept) { + const ranges = (accept ?? "") + .toLowerCase() + .split(",") + .map((range) => { + const [type, ...parameters] = range.trim().split(";"); + const quality = parameters + .map((value) => value.trim()) + .find((value) => value.startsWith("q=")); + const q = quality === undefined ? 1 : Number(quality.slice(2)); + return { + type: type.trim(), + q: Number.isFinite(q) && q >= 0 && q <= 1 ? q : 0, + }; + }); + const quality = (type) => { + for (const match of [type, "text/*", "*/*"]) { + const matches = ranges.filter((range) => range.type === match); + if (matches.length) return Math.max(...matches.map((range) => range.q)); + } + return 0; + }; + // Wildcards alone never opt a browser into Markdown; HTML wins ties. + return ( + ranges.some((range) => range.type === "text/markdown") && + quality("text/markdown") > 0 && + quality("text/markdown") > quality("text/html") + ); +} + +function advertiseMarkdown(response, path) { + const vary = new Set( + (response.headers.get("Vary") ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); + if (![...vary].some((s) => s.toLowerCase() === "accept" || s === "*")) + vary.add("Accept"); + response.headers.set("Vary", [...vary].join(", ")); + response.headers.append( + "Link", + `<${path}>; rel="alternate"; type="text/markdown"`, + ); + return response; +} + +export default { + async fetch(request, env) { + const useMarkdown = prefersMarkdown(request.headers.get("Accept")); + // HTML validators describe a different representation. Probe the route first, + // then let the asset service evaluate validators/ranges against the Markdown. + const probe = new Request(request); + if (useMarkdown) { + for (const name of [ + "If-None-Match", + "If-Modified-Since", + "If-Match", + "If-Unmodified-Since", + "If-Range", + "Range", + ]) + probe.headers.delete(name); + } + const original = await env.ASSETS.fetch(probe); + if ( + ![200, 206, 304].includes(original.status) || + !["GET", "HEAD"].includes(request.method) || + !original.headers.get("Content-Type")?.startsWith("text/html") + ) + return original; + + const url = new URL(request.url); + if (!url.pathname.endsWith("/") && !url.pathname.endsWith(".html")) + return original; + const markdown = new URL(url); + markdown.pathname = url.pathname.endsWith("/") + ? `${url.pathname}index.md` + : url.pathname.slice(0, -5) + ".md"; + const candidate = await env.ASSETS.fetch( + useMarkdown + ? new Request(markdown, request) + : new Request(markdown, { method: "HEAD" }), + ); + if (!candidate.ok && ![304, 412, 416].includes(candidate.status)) { + await candidate.body?.cancel(); + return original; + } + const response = new Response( + useMarkdown ? candidate.body : original.body, + useMarkdown ? candidate : original, + ); + if (useMarkdown) { + await original.body?.cancel(); + if (candidate.status < 400) + response.headers.set("Content-Type", "text/markdown; charset=utf-8"); + } else { + await candidate.body?.cancel(); + } + return advertiseMarkdown(response, markdown.pathname); + }, +}; diff --git a/website/worker.test.mjs b/website/worker.test.mjs new file mode 100644 index 0000000000..7c3ae74f9f --- /dev/null +++ b/website/worker.test.mjs @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import worker from "./worker.mjs"; + +function assets({ markdownStatus = 200 } = {}) { + return { + ASSETS: { + async fetch(request) { + const url = new URL(request.url); + const markdown = url.pathname.endsWith("index.md"); + if (url.pathname === "/missing/") + return new Response("Missing", { status: 404 }); + if (url.pathname === "/guide") + return Response.redirect(`${url.origin}/guide/`, 307); + const headers = { + "Content-Type": markdown ? "text/plain" : "text/html", + ETag: markdown ? '"markdown"' : '"html"', + Link: '; rel="ard"', + }; + const status = + request.headers.get("If-None-Match") === headers.ETag + ? 304 + : markdown + ? markdownStatus + : 200; + return new Response( + request.method === "HEAD" || status === 304 + ? null + : markdown + ? "# Guide" + : "

Guide

", + { headers, status }, + ); + }, + }, + }; +} + +await test("HTML is the default and explicit Accept preferences select Markdown", async () => { + for (const [accept, markdown] of [ + [null, false], + ["*/*", false], + ["text/html", false], + ["text/markdown", true], + ["text/markdown;q=0", false], + ["text/markdown;q=0.5,text/html", false], + ["text/markdown,text/html;q=0.5", true], + ["text/markdown,text/html", false], + ["TEXT/MARKDOWN; q=1,text/*;q=0.5", true], + ["text/markdown;q=bad", false], + ["text/markdown;q=0,*/*;q=1", false], + ]) { + const response = await worker.fetch( + new Request("https://rea.tools/guide/", { + headers: accept === null ? {} : { Accept: accept }, + }), + assets(), + ); + assert.equal( + response.headers.get("Content-Type"), + markdown ? "text/markdown; charset=utf-8" : "text/html", + accept, + ); + assert.equal( + await response.text(), + markdown ? "# Guide" : "

Guide

", + accept, + ); + assert.match(response.headers.get("Vary"), /Accept/); + assert.match(response.headers.get("Link"), /rel="ard"/); + assert.match( + response.headers.get("Link"), + /<\/guide\/index.md>; rel="alternate"/, + ); + } +}); + +await test("HEAD and validators use the selected representation", async () => { + for (const method of ["GET", "HEAD"]) { + const response = await worker.fetch( + new Request("https://rea.tools/guide/", { + method, + headers: { Accept: "text/markdown", "If-None-Match": '"html"' }, + }), + assets(), + ); + assert.equal(response.status, 200); + assert.equal(response.headers.get("ETag"), '"markdown"'); + assert.equal(await response.text(), method === "HEAD" ? "" : "# Guide"); + } + for (const [accept, tag] of [ + ["text/html", '"html"'], + ["text/markdown", '"markdown"'], + ]) { + const response = await worker.fetch( + new Request("https://rea.tools/guide/", { + headers: { Accept: accept, "If-None-Match": tag }, + }), + assets(), + ); + assert.equal(response.status, 304); + assert.equal(await response.text(), ""); + assert.match(response.headers.get("Vary"), /Accept/); + } +}); + +await test("missing Markdown keeps HTML, and asset redirects and missing routes survive", async () => { + const html = await worker.fetch( + new Request("https://rea.tools/guide/", { + headers: { Accept: "text/markdown" }, + }), + assets({ markdownStatus: 404 }), + ); + assert.equal(await html.text(), "

Guide

"); + for (const [path, status] of [ + ["/guide", 307], + ["/missing/", 404], + ]) { + const response = await worker.fetch( + new Request(`https://rea.tools${path}`, { + headers: { Accept: "text/markdown" }, + }), + assets(), + ); + assert.equal(response.status, status); + } +}); diff --git a/website/wrangler.toml b/website/wrangler.toml index cb3f21b386..649d31cfe1 100644 --- a/website/wrangler.toml +++ b/website/wrangler.toml @@ -1,18 +1,26 @@ # Serve only the public website. The default deployment is a workers.dev preview. name = "rea-website-preview" +main = "worker.mjs" compatibility_date = "2026-10-08" workers_dev = true preview_urls = false [assets] directory = "./_site" +binding = "ASSETS" +run_worker_first = ["/", "/*/", "/*.html"] html_handling = "auto-trailing-slash" not_found_handling = "none" [build] cwd = ".." command = "python3 scripts/website.py check" -watch_dir = ["./public", "./locales", "./templates"] +watch_dir = [ + "./public", + "./locales", + "./templates", + "../.agents/skills/reverse-engineer-anything", +] # Public HTTPS depends on zone activation and certificate readiness. [env.production]