Compare commits

...
Author SHA1 Message Date
N0zoM1z0 05c72e9305 chore: integrate main after receiver regression test repair 2026-10-10 18:20:51 +08:00
YsopwnableandGitHub f9ce32b474 test(javascript): verify escaped receivers through CLI and MCP 2026-10-10 17:50:36 +08:00
N0zoM1z0 9a4fdbeb52 fix(process): coalesce delayed capture progress
Retain one pending intermediate observation and drop stale live status before terminal cleanup. Preserve monotonic counts and final delivery while an observer is slow or rejects an update.

Verify the send rate at its controlled-clock owner and lifecycle notification density at the CLI/MCP boundary, where receive timestamps include buffering and scheduling.
2026-10-10 17:33:17 +08:00
N0zoM1z0 2fc47976a0 chore: integrate main for process progress verification 2026-10-10 17:33:17 +08:00
46c9cba03a fix(javascript): expand an escaped receiver's methods once (#1495) (#1517)
Since #1471, escaping an object also escapes every method's results. Each
call through the object escapes it as the receiver, and each escape
re-expanded all of its methods. N methods returning one shared object,
each called once, did N times N work: 2,000 calls took 3.0 s of semantic
analysis and 4,000 took 12 s.

Expansion below references depends only on syntax, so escapes now reuse
it per expression and path. Identifiers that resolve to the same binding
and path collapse to one, because the escape walk treats them alike.
Every escape site is still recorded. The semantic IR is byte-identical
on 50 sources, and the issue's case takes 253 ms at 2,000 and 638 ms at
4,000.

Closes #1495

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:16:30 -07:00
b4db35f092 fix: bind identity and coverage to the producing rule (#1513)
* fix: bind identity and coverage to the producing rule

Logical paths, container families, examined bytes, path-segment conventions, destination collisions, file-id leases, Mach-O CPU selection, and firmware cleanup now follow the observation that owns each rule.

* fix: fail unmatched Mach-O slices and keep firmware roots independent

A fat target whose architecture lipo did not list now stops before slice tools run. Chained fixups still count unread LC_DYLD_INFO rebase bytes, and a firmware cleanup failure no longer blocks the next workspace.

* test: cover path identity and runtime families at the projection boundary

NFC joins and segment conventions are now asserted by the path resolver, the Apple projection, and a real APK inventory instead of helper unit tests.

* test: expect unread dyld rebase bytes in the Apple dispatch fixture

The legacy LC_DYLD_INFO fixture keeps a rebase range this reader does not decode, so the public CLI and MCP check now requires that partial coverage beside the bind opcodes.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-10-10 17:14:47 +08:00
7a56fe6260 fix(setup): honor active client configuration and skill roots (#1504)
* refactor(setup): resolve skill destinations per client

* fix(setup): honor active client configuration and skill roots

* fix(setup): validate effective client scopes and Gemini trust

* fix(setup): treat empty OpenCode overrides as unset

An empty OPENCODE_CONFIG_DIR now stays on the XDG directory, and doctor
and setup inspect global OpenCode files when a custom directory is absent.
Uninstall reports one status per client so a missing earlier layer does
not hide a removed registration.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-10-10 17:03:43 +08:00
YsopwnableandGitHub b25922e4e8 test(clients): verify native Qwen Pi and Hermes workflows (#1511) 2026-10-10 16:59:32 +08:00
b6ad1f2c3d feat(mcp): compact input schema profile for size-capped providers (kimi-cli/Moonshot) (#1514)
* feat(mcp): add compact input schema profile for size-capped providers

Moonshot's function-calling API rejects a request while any single tool
parameters schema exceeds its literal serialized size cap, so clients
such as kimi-cli fail every request while the default catalog is
attached (#1484).

Add an opt-in REA_MCP_INPUT_SCHEMA_PROFILE=compact server presentation.
Each advertised input schema is rendered within a 13,312-byte budget,
measured to sit below the observed provider cap: annotation prose
(nested descriptions, examples, defaults, titles) is dropped, repeated
annotation-free subschemas are shared through schema-local $defs/$ref
references the provider validates without expansion, and the three
structurally oversized schemas are advertised in a documented reduced
form. The canonical catalog, tool set, output schemas, and server-side
canonical validation are unchanged; the default full profile is
byte-identical to today's advertisement.

Boundary tests pin every compact schema to the budget, keep them valid
JSON Schema that still accepts every canonical example, hold reduced
presentations at three or fewer, and verify canonical rejection still
guards a permissive advertisement. Verified against the real Moonshot
API through kimi-cli: prompts succeed with the compact catalog attached
where the default catalog fails every request with 400.

* fix(mcp): preserve compact schemas through Kimi conversion

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-10 16:58:43 +08:00
N0zoM1z0 2b6f14f528 fix(process)!: version literal scenario replacement semantics
Keep caller-selected replacement text literal with a callback, preserving
empty-search positions instead of changing their split/join behavior.
Commit the replacement interpretation for captures using custom patterns so
legacy records cannot report this producer correction as a target change.

BREAKING CHANGE: Captures with custom normalization patterns use
literal-replacements-v1 and cannot be compared with legacy unversioned
pattern captures. Capture both sides with the same replacement semantics;
legacy captures remain readable and captures without patterns are unaffected.
2026-10-10 16:57:06 +08:00
openhandsandYsopwnable f51503ef1c fix(process): normalize scenario replacements literally
capture_process_scenario declares normalization patterns as literal text, but\nreplaceAll expands \$&\, \$\`, and \$1\ in the replacement as a replacement\npattern. A caller normalizing text that contains a dollar sign silently gets\nduplicated, spliced, or dropped content in the recorded Evidence instead of\nthe requested literal replacement.\n\nUse split/join, which is literal on both sides and is already the idiom used\nfor exact-literal scenario rules in browserScenarioNormalization.ts.
2026-10-10 16:57:06 +08:00
2bf812772d fix(apple): bound binary plist expansion before decoding keyed archives (#1515)
inspect_keyed_archive passed caller-selected binary plists straight to
plist.parseBinary, which recurses on every object reference and copies
shared containers. A 484-byte archive of forty nested [next, next] arrays
grew the process past 4 GB until it died with no result, which would also
end an MCP server. A 125-byte self-referencing array overflowed the stack
and was reported as an artifact too large to process.

The iterative reference walk that already guards decode_interface_builder
now runs first, with an 8x keyed-archive decode budget. Over-budget depth
or expansion is a limit; a reference cycle is a malformed archive. Its
"decoder" bounds admit the same layouts plist.parseBinary reads, so
accepted archives stay accepted. Interface Builder behavior is unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:44:46 -07:00
a936303723 fix(json): name a leading byte-order mark in rejected JSON input (#1510)
Strict JSON file inputs reject a leading UTF-8 byte-order mark, but the
diagnostic was the parser's own message, which quotes the invisible U+FEFF
as the unexpected token: Unexpected token '', "{"invento"... is not
valid JSON. Editors such as Windows PowerShell 5.1 write that mark by
default, and nothing in the message showed it.

CLI JSON files (whole and streamed), Evidence and snapshot JSON files, and
process scenario files now report that the input begins with a UTF-8
byte-order mark and how to save it. Acceptance is unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:37:41 -07:00
93ca276921 fix(browser): accept HAR files that begin with a byte-order mark (#1509)
HAR 1.2 lets writers emit a UTF-8 byte-order mark and requires readers to
ignore it. The decoder passed ignoreBOM: true to TextDecoder, which keeps
the mark in the decoded text, so a BOM-prefixed export failed as "HAR is
malformed JSON." Decoding now uses the default, which skips one leading
mark. The artifact digest still covers the selected file bytes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:31:31 -07:00
911323c5dd fix(native): page every retained function dossier collection (#1507)
inspect_analysis_view is the recovery path when an analyze_function
dossier exceeds the MCP response budget, but its native facets covered
only procedure, pseudocode, assembly, callers/callees, references, and
value flow. Basic blocks, comments, unresolved calls, referenced strings
and names, and the native API record could not be read from retained
Evidence at all; basic blocks grow with the function just like assembly.

These are now facets: collections page by rows, and native_api is a
singleton. A dossier without an unresolved_calls record reports "not
recorded" rather than the value-flow failure reason.

Refs #1247 and #1065.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:31:15 -07:00
f63f12d8ff docs: align the Indonesian README with the other translations (#1512)
Keep the Bahasa Indonesia onboarding text from #1508, and fix the formatting
that left it outside the shared README layout: drop the leading space, point
in-page links at the translated headings, align the analysis table, and link
Bahasa Indonesia from every language selector.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Dev Cee <24010110166@universitasbumigora.ac.id>
2026-10-10 16:26:52 +08:00
983b070d4d fix(artifacts): record each case collision once at its diverging segment (#1505)
Inventory paired every path under one case spelling with every path under
each other spelling and listed all of them on each occurrence. An archive
with Foo/ and foo/ subtrees of N files each produced N-entry lists on 2N
occurrences: 4,000 such ZIP members yielded a 120 MB inspect_artifact
result and close to 1 GB of memory. The trie walks also recursed once per
path component, so a ZIP name with tens of thousands of components threw
RangeError.

A collision is now recorded at the segment where spellings diverge and
shared down that subtree by an iterative walk. Each occurrence names the
colliding prefix and the first other spelling, plus a count of further
spellings, each of which is its own occurrence.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:16:16 -07:00
9e22da845b feat(setup): add native Pi client support (#1376)
* feat(setup): add native Pi client support

* fix(setup): isolate invalid Pi agent directory overrides

Keep malformed Pi path evidence on its client entry so unrelated selected clients can complete setup and doctor. Preserve explicit Pi rejection and block filesystem operations rather than falling back to another directory.

* fix(setup): parse Pi configuration as strict JSON

Pi's installed MCP loader reads mcp.json with a bare JSON.parse and no
BOM tolerance, so comments, trailing commas, a BOM, or an empty existing
file yield loader errors and no servers. Parsing the Pi dialect with
objectSchema.parse(JSON.parse(text)) before the shared empty/JSONC
shortcut rejects those files at every mutation boundary instead of
reporting configured registrations Pi cannot load. A missing file still
initializes a fresh strict-JSON document, other dialects keep their
JSONC/BOM/empty tolerance, and malformed files are never rewritten.

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-10 16:10:39 +08:00
f970b886aa feat: add Hermes as a supported MCP client (#1403)
* feat: add Hermes as a supported MCP client

Hermes Agent reads stdio MCP servers from `mcp_servers.<name>` in
`config.yaml`. REA had no Hermes client, so setup could not register it and
Hermes users had to wire the server up by hand.

- SupportedClients: add the `hermes` format and client definition, resolving
  the config path from HERMES_HOME, else the platform default that Hermes's own
  resolver uses (LOCALAPPDATA/hermes on Windows, ~/.hermes elsewhere), including
  HERMES_DATA_DIR_SUFFIX.
- ClientConfigurationDocument: parse and serialize Hermes YAML, upserting the
  rea entry into the parsed document so user comments and unrelated settings are
  preserved; emit `enabled: true` since Hermes connects entries unless disabled.
- ClientRegistrationStatus: forward the Hermes location variables so status
  checks resolve the same path as setup.
- Tests: cover path resolution on both platforms and the register/read/uninstall
  round trip, and clear the Hermes location variables between tests.

- delilah 🌸

* docs: document the Hermes client and its config path

- delilah 🌸

* refactor(setup): resolve skill destinations per client

* fix(setup): install Hermes skills in the active profile

* fix(setup): identify each client skill action uniquely

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-10 16:03:25 +08:00
a0e5e9db2b perf(artifacts)!: name each edge's child through its occurrence (#1493)
Every inventory edge is created from one occurrence and copied that
occurrence's artifact_id and logical_path as child_artifact_id and
logical_path. Edges now keep the occurrence_id reference alone. Edge IDs
still hash the same child and path, read from the occurrence, so they are
unchanged; validation and artifact comparison read the occurrence too.

inspect_artifact output: Transmission.app 1.10 MB to 0.97 MB, Visual
Studio Code.app 15.37 MB to 13.48 MB.

BREAKING CHANGE: Artifact inventory edges no longer include
child_artifact_id or logical_path; read them from the occurrence named by
occurrence_id. The graph commitment changes, so inventory Evidence
recorded by earlier versions no longer validates.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:58:05 -07:00
1f692c83e2 perf(artifacts): stop repeating every artifact path as Evidence locations (#1492)
inventory_artifact, inspect_artifact, and extract_artifact copied each
occurrence or extracted file path into Evidence locations, although the
result already names every one. inspect_artifact did so twice, in its own
Evidence and in the nested inventory Evidence. Like other whole-target
Evidence, these now report no locations.

Visual Studio Code.app: inspect_artifact 15.37 MB to 13.07 MB,
extract_artifact 1.40 MB to 1.06 MB.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:47:51 -07:00
YsopwnableandGitHub 782e0147cd fix(javascript): retain returned-reference mutation uncertainty (#1471) 2026-10-10 15:46:24 +08:00
9defed9725 perf(mcp): stop advertising placeholder input descriptions (#1479)
Input schema presentation gave every undescribed property a generic
"Value for <name>." sentence. That only restates the property name, yet
it covered 1,958 properties: 70 KB of the 522 KB of advertised input
schemas that clients load into model context. Authored descriptions,
referenced descriptions, and the name-pattern sentences that add meaning
(maximums, digests, paths, Evidence IDs) remain; other properties now
advertise no description.

The schema validity test now rejects placeholder descriptions instead of
requiring a description on every property.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:32:29 -07:00
5f6bb7a5ba perf(apple): state each NIB object's class, label, and ID once (#1477)
The NIBArchive projection synthesizes customClass, label, and objectID so
its records share the ibtool dictionary parser. The parser then returned
those values as class_name, name, and the object ID, and the graph node
kept the synthesized copies in attributes as well. Attributes now omit a
key whose value its canonical field already carries; differing authored
values stay. Transmission.app decode_interface_builder output shrinks
from 6.45 MB to 6.14 MB.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:31:00 -07:00
morluto 2596f8da14 test(android): retry verified cleanup during fixture teardown 2026-10-10 15:29:42 +08:00
morluto b12d0c6361 test(artifacts): respect destination filesystem case sensitivity 2026-10-10 15:29:42 +08:00
N0zoM1z0 cbcba2902d docs(website): align Blog community links with the site 2026-10-10 15:28:13 +08:00
N0zoM1z0 07d416505c docs(website): finalize the Touhou essay for publication 2026-10-10 15:28:13 +08:00
N0zoM1z0 3ea0233342 docs(website): simplify the Blog introduction 2026-10-10 15:28:13 +08:00
N0zoM1z0 cf6957e796 feat(website): link the Blog from the homepage introduction 2026-10-10 15:28:13 +08:00
N0zoM1z0 ed200a2302 docs(website): emphasize the reconstruction essay takeaways 2026-10-10 15:28:13 +08:00
N0zoM1z0 7df2050606 docs(website): write the Touhou reconstruction essay 2026-10-10 15:28:13 +08:00
N0zoM1z0 130ffdf6bc docs(website): outline the Touhou reconstruction workflow 2026-10-10 15:28:13 +08:00
N0zoM1z0 b09d02d93c feat(website): add a blog for methods and project notes 2026-10-10 15:28:13 +08:00
1f0775684b fix(artifacts): keep case-distinct archive names in inventory (#1480)
ZIP member names are case-sensitive. Inventory was folding them with a
filesystem case rule, so shrunk APKs never became evidence for the Android
application graph. Logical registration now keeps both spellings and records
an English Unicode case-folding limitation. Extraction fails only when the
destination directory already holds the other spelling.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-10-10 15:21:16 +08:00
7296f2b5bf perf(javascript)!: stop repeating the semantic identifier strategy per fact (#1474)
Every semantic graph node and relation carried an identifier_strategy
object whose schema admits exactly one value per record type. The
constant made up about 9% of analyze_javascript_application output
(Obsidian app.asar: 12.04 MB to 10.96 MB), and semantic traces repeated
it on every returned fact.

The node_id and relation_id schema descriptions now state how each
identifier is derived.

BREAKING CHANGE: Semantic graph nodes and relations no longer include
identifier_strategy, and relation IDs no longer hash that constant.
Evidence recorded by earlier versions does not validate against the new
semantic graph schema.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:13:34 -07:00
3c7b176a3d perf(artifacts): deliver the inspected artifact graph once (#1473)
inspect_artifact nested the complete inventory Evidence and then restated
every artifact node and occurrence as an observation and every edge as a
derived relationship. No caller reads those copies; comparison and tests
use the nested inventory Evidence. On Transmission.app the result shrinks
from 2.46 MB to 1.10 MB.

Observations keep the root manifest and integrity findings.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:12:17 -07:00
aa23ec883f feat(clients): register Qwen Code during setup (#1415)
* feat(clients): register Qwen Code during setup

* fix(clients): honor Qwen home and shared skill discovery

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-10 15:12:09 +08:00
keemsisiandGitHub 934ffd28d1 perf(javascript): derive the application graph ID once
createJavaScriptApplicationGraph hashed the whole graph to derive its
graph_id, then parsed it with the public schema, whose invariant check
hashed the same content again to verify that ID. Validate the content
with every other invariant first, then derive graph_id once from the
validated copy. The public schema still verifies graph_id for stored
and untrusted graphs, and created graphs are unchanged.
2026-10-10 15:09:18 +08:00
79f6756239 docs(website): link the Discord community and REA on X (#1469)
Add an X link beside the GitHub and Discord header icons on every content
page, name the footer links "Join the community" and "Follow us on X", and
close the homepage with a "Join the community" section.

On narrow screens, pad the footer so the floating Top link no longer
covers its last row of links.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:00:09 +08:00
YsopwnableandGitHub 78e1308f49 docs(process): clarify normalized terminal line representation 2026-10-10 14:58:15 +08:00
YsopwnableandGitHub 4e3d335439 test(android): retain incomplete cleanup failure reasons (#1443)
Include the full stop disposition in the fixture assertion message, so incomplete owned teardown retains its typed reason in Vitest failure reporters. Preserve the existing cleanup requirement without retries or relaxed ownership checks.

Seven process-boundary tests, full serial typecheck/lint/duplication/format checks, and 19 final-head CI checks pass after integrating the combined-main fixture fix. A controlled failure-rendering probe verifies that the explicit message preserves the reason. The original CI cleanup failure remains unreproduced; this is diagnostic work with a synthetic JADX producer, not a root-cause or real-engine fix.

Refs #1441, #1446, #1448, #1065.
2026-10-10 14:37:49 +08:00
YsopwnableandGitHub ea92328775 fix(mcp): validate doctor probe against its discovered output contract (#1461)
Validate the target-free probe against the named definition discovered from the complete production catalog. Preserve initialized server and inventory observations when the SDK rejects the probe, and retain the schema failure reason, absolute deadline and owned-child cleanup.

The negative stdio replay regression fails on original main and passes the fix; actual production-boundary fault injection confirms rejection. Nine doctor/help tests, serial full static checks and real small-target CLI/MCP parity pass. Successful doctor reports remain byte-identical. No performance or model-token reduction is claimed.

Closes #1454. Refs #1065, #723, #1433.
2026-10-10 14:33:57 +08:00
morlutoandGitHub 28d3cb545c Merge pull request #1463 from morluto/codex/fix-skill-source-discovery
fix(skills): expose authored skill to repository discovery
2026-10-10 14:28:42 +08:00
b118f04492 perf(apple): stop repeating the archive path in Interface Builder evidence (#1460)
Every Interface Builder node and edge evidence record prefixed its
description with the archive's relative path, which artifact_path in the
same record already states. Bundles carry thousands of objects across
localized nibs, so the repeated path made up 6-10% of
decode_interface_builder output (XLD: 7.38 MB to 6.62 MB).

Descriptions now name only the decoded item, such as "object 81" or
"outlet o_quality".

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:21:24 -07:00
ef96657d6b perf(javascript): hash each semantic evidence context once (#1457)
The semantic graph's evidence-context registry canonicalized the full
provenance context three times for every node, relation, unknown and
fingerprint: once to hash its ID and twice more to compare against the
stored row. A file has only a few distinct contexts, so nearly all of
that work repeated a known answer.

Interning now keys contexts by their canonical text. Each fact
canonicalizes once, and only a new context is hashed and checked for a
digest collision. IDs and output are unchanged; Obsidian's 334 MB
Evidence is byte-identical and analysis drops from about 47.0 s to
44.4 s.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:21:07 -07:00
morluto 696fb834b8 fix(skills): expose authored skill to repository discovery 2026-10-10 14:20:22 +08:00
2781cad201 perf(process): stop padding rendered terminal lines to full width (#1450)
Rendered frames padded every visible row with trailing spaces to the
terminal width. Blank rows cost a full row each and every frame repeats
them, so the padding made up 14-39% of capture Evidence (2 KB of sparse
output produced 228 KB). The padding carried no information: columns is
recorded and serialized_state keeps the exact terminal state.

Lines are now trimmed after normalization, so line.padEnd(columns)
restores the previous value. The comparison contract commits
rendered_line_format so older padded captures do not compare as the
same contract.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:18:13 -07:00
YsopwnableandGitHub c89c8f5318 docs(mcp): record real Windows first-query recovery 2026-10-10 14:05:56 +08:00
1ec490f361 fix(javascript): stop replaying earlier writes for each object property write
Repeated property writes to one object made member-mutation collection
cubic. Each write evaluated its target binding to rule out a primitive,
and every evaluation folded all earlier mutation paths, copying every
slot once per path. 2,000 writes to one object took 27 s and 4,000 took
203 s.

- A recorded write always leaves a non-primitive binding value, so only
  the first write to a binding needs the primitive check.
- Apply mutation paths per slot in one pass with the same ordering
  semantics instead of one full slot copy per path.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com>
2026-10-10 14:04:31 +08:00
YsopwnableandGitHub 66863b609f test(javascript): reuse comparison node fixtures
Use the established typed graph fixture constructor while retaining right-hand comparison provenance assertions. Restore the unchanged duplicate-code gate on combined main.

Closes #1446. Refs #1434, #1443, #1065.
2026-10-10 14:02:02 +08:00
morlutoandGitHub 817efaca9a Merge pull request #1391 from marnixpluim/fix/electron-import-aliases
fix(javascript): recognize Electron APIs through renamed bindings
2026-10-10 13:57:24 +08:00
morluto 0d2082d36b fix(javascript): recognize unchanged var and let Electron aliases 2026-10-10 13:56:40 +08:00
morlutoandGitHub f1231d4a2c Merge pull request #1382 from BaaRai/fix/cross-branch-multiblock-callee
fix(ghidra): keep the cross-format branch callee multi-block
2026-10-10 13:50:51 +08:00
morlutoandGitHub a957f397ad Merge pull request #1303 from kvalls/codex/windows-ghidra-native-dlls
feat(ghidra): support native PE DLLs on Windows
2026-10-10 13:50:11 +08:00
YsopwnableandGitHub e09cfe9b59 fix(javascript): invalidate references stored through property targets (#1445)
* fix(javascript): invalidate references stored through property targets

* refactor(javascript): share rest keys and return-shape fixtures
2026-10-10 13:47:15 +08:00
morluto ea1dbda1c7 test(ghidra): share result projection client setup 2026-10-10 13:47:06 +08:00
morluto 7633dd8d5d fix(ghidra): publish Windows verification success after cleanup 2026-10-10 13:43:01 +08:00
morluto 941dc425ca chore: merge main into Windows DLL support branch 2026-10-10 13:42:54 +08:00
33b9b31a6f perf(javascript): index change-graph nodes once per comparison
Resolve each compared graph node through a per-side index while preserving right-hand evidence selection and complete comparison results.

Refs #1065.

Co-authored-by: keemsisi <7439375+keemsisi@users.noreply.github.com>
2026-10-10 13:21:13 +08:00
Kaoru0822andGitHub eeb83f6581 perf(cli): defer Playwright and isomorphic-git until a command needs them
Load browser composition after CLI scenario validation and load Git support only for local reference trees with Git metadata. Preserve default outputs and actual command behavior.

Refs #1065.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
2026-10-10 13:16:44 +08:00
YsopwnableandGitHub bb6fa1c6f5 docs(process): verify independent full-report hashing budgets (#1440) 2026-10-10 13:13:05 +08:00
YsopwnableandGitHub ce16f7bc95 test(native): verify macOS provenance through real CLI and MCP
Verify five native operations through the public CLI and real SDK stdio MCP using a source-built macOS fixture. Retain normalized provenance, response parity and unchanged input bytes.

Refs #1430, #1065, #723.
2026-10-10 13:05:12 +08:00
YsopwnableandGitHub 0a14ca41b4 fix(javascript): preserve effects through yielded array references (#1437) 2026-10-10 13:00:11 +08:00
YsopwnableandGitHub dc5af6d68f perf(mcp): reuse output validators across catalog refreshes (#1433)
Advertise content- and dialect-bound identities for self-contained output schemas so the SDK can reuse unchanged Ajv validators after complete tools/list refreshes. Preserve explicit IDs and relative reference bases.

Verify changed schemas, availability transitions, reconnects, numeric and selector constraints, explicit IDs, relative references, and advertised dialects through the pinned SDK boundaries.

Refs #1065, #723
2026-10-10 12:49:09 +08:00
0b9815b6e7 docs(skill): save CLI application Evidence and read a summary view (#1429)
The skill's CLI fallback printed the complete application Evidence to
stdout. A real ASAR produces hundreds of megabytes (334 MB for Obsidian),
which an agent then reads into its context. Save the Evidence to a file
and project a summary or module page with inspect-analysis-view instead.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:38:52 -07:00
6375ccaf04 perf(native): record macOS command provenance once in its Evidence (#1430)
inspect_macho, inspect_signature, inspect_plist, list_architectures and
demangle_swift already return their command provenance inside the
normalized result. The provider also copied the same array into
raw_result, so every response carried it twice. raw_result now stays
null, as docs/mcp-contracts.md describes for results without a distinct
provider representation.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:35:50 -07:00
c29931ce81 fix(javascript): scan direct returns without recursive stack growth
Use the shared AST child enumeration with an explicit stack, preserving return order and nested callable boundaries.

Refs #1065.

Co-authored-by: rudycelekli <47457359+rudycelekli@users.noreply.github.com>
Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com>
2026-10-10 12:25:00 +08:00
YsopwnableandGitHub f4a0c1ce95 fix(javascript)!: avoid event payload copies in graph metadata (#1427)
Share the canonical string identity rule with endpoint projection, scope event keys to each emitter, and keep complete event/listener names in properties. Verify real CLI and stdio MCP with large names, registration/removal, distinct emitters, exact name seeds, reserved-prefix values, and unresolved dynamic names.

BREAKING CHANGE: New analyses use digest-based IDs for long or reserved-prefix event names and shorter event/listener display labels. Select IDs from the parent graph and read exact names from event_name properties. Stored graphs and their original IDs remain accepted for semantic tracing.
2026-10-10 12:20:05 +08:00
a9ee2360fd feat(native): inspect oversized retained function dossiers via bounded views
Add caller-selected native dossier facets with preserved Evidence identity, provenance, unknowns and transport recovery.

Refs #1247 and #1065.

Co-authored-by: jhuang-tw <77732008+jhuang-tw@users.noreply.github.com>
Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com>
2026-10-10 12:18:50 +08:00
YsopwnableandGitHub 4d95f2b2e7 fix(validation): retain unmatched union correction details
Preserve actionable field paths and schema-declared variants for unknown or missing discriminators.

Closes #1421. Refs #1065.
2026-10-10 12:15:11 +08:00
28595276fa fix(javascript): bound member-mutation paths through reassignment loops
Fixes #1401. Refs #1065. Preserve exact member facts and equal-depth write effects while bounding the observed reassignment-loop expansion.

Validated with the JavaScript semantic/application suite, focused alias regressions, full static checks, real compiled CLI and SDK MCP Notion workflows, and all 20 checks on the final PR head.

Co-Authored-By: Marnix Pluim <286664873+marnixpluim@users.noreply.github.com>
Co-Authored-By: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com>
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-10 12:03:13 +08:00
YsopwnableandGitHub 98022a8352 fix(javascript)!: avoid endpoint payload copies in graph metadata (#1423)
Keep exact endpoint observations and request properties while using compact display labels and collision-safe canonical value digests for long artifact-local endpoint keys. Verify real CLI/stdio MCP tracing and version comparison without truncating evidence.

BREAKING CHANGE: New analyses use compact endpoint labels and digest-based IDs for long or reserved-prefix endpoint keys. Select IDs from the parent graph and read full values from endpoint properties. Previously stored graphs retain their original identities and remain valid workflow inputs.
2026-10-10 12:02:24 +08:00
YsopwnableandGitHub 81cdafc132 docs: celebrate 50,000 GitHub stars 🎉 2026-10-10 03:45:42 +00:00
YsopwnableandGitHub 4d8ef3f350 perf(javascript): aggregate semantic family coverage once (#1410)
Reduce repeated semantic relation and unknown family scans while preserving exact complete Evidence and coverage.
2026-10-10 11:30:42 +08:00
ca3131b36c perf(cli): write compact JSON when output is not a terminal (#1413)
`--json` / `--format json` always indented its streamed result. Agents and
scripts read REA through pipes and redirects, where indentation carries no
information: on real application results it added 16-45% model tokens
(o200k proxy) and 25-73% bytes. The skill's CLI fallback uses `--json`.

Indent only when stdout is a TTY, as `gh --json` does; piped or redirected
JSON is compact. The JSON value, `jsonl`, `--filter-output`,
`--full-output` and the token-window paths are unchanged. `jq .` restores
indentation for files.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:29:48 -07:00
YsopwnableandGitHub de8fc67195 fix(javascript): retain shorter literal metadata encodings (#1408)
Choose the shorter complete JSON or digest key and string display label.
Short values keep their existing identities and labels, while large values
retain the payload-copy reduction without an arbitrary truncation limit.
2026-10-10 11:23:47 +08:00
Cursor Agent 3006514c4c feat(process): report live capture progress through CLI and MCP
Long captures emitted only a final result, so a quiet child looked idle.
Pass the shared rate-limited progress reporter through the capture lifecycle
and report the observed phase, elapsed time, and collected-unit counts.
CLI writes those lines to stderr; MCP forwards them only when the client
supplied a progress token. Final Evidence on stdout keeps its shape.
2026-10-10 03:15:21 +00:00
YsopwnableandGitHub 8550913cda fix(javascript)!: stop copying literals into semantic metadata (#1407)
Commit literal values with canonical JSON SHA-256 in semantic role keys
and use a type label for strings. Preserve complete values, provenance and
literal queries while avoiding two payload copies in each string node.

Verify real compiled CLI/default stdio MCP, typed union seeds, empty
strings and trace continuation with a large source-owned fixture.

Refs #723

BREAKING CHANGE: Newly produced semantic literal node IDs use
value-sha256 role keys, and string labels read "string literal". Read exact
values from properties.value and obtain node IDs from the selected parent
graph. Previously stored Evidence and its original IDs remain valid.
2026-10-09 20:00:53 -07:00
1f0f728c63 perf(hopper): stop repeating Hopper replies as raw_result (#1405)
HopperProvider passed the bridge reply as rawResult and, for every
operation except address_to_file_offset, the same object as the result.
Every Hopper Evidence record (decompilation, procedure and string
listings, xrefs, ...) therefore carried its complete result twice, and
an MCP response four times counting its text copy.

raw_result is now null unless REA transformed the reply, which only the
file-offset mapping does; that case keeps Hopper's original reply.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:00:35 -07:00
7623e21ff0 perf(native): record pwntools diagnostics once in their Evidence (#1404)
inspect_binary_layout and inspect_recorded_crash passed the decoded report
as both result and rawResult, so every Evidence record carried the whole
report twice (and an MCP response four times, counting its text copy).
The decoded report is the only representation, so raw_result is now null,
as for other single-representation operations.

The real pwntools verifiers asserted the duplicate; they now assert null.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:00:11 -07:00
499e2f54ef perf(evidence): stop copying normalized results into raw_result (#1402)
createAnalysisExecution defaulted an omitted rawResult to a second
complete copy of the result. Every caller that had no distinct provider
representation therefore doubled its Evidence: decode_interface_builder,
inspect_keyed_archive, inspect_asset_catalog, trace_dylib_resolution,
inspect_native_dispatch_metadata, observe_native_calls,
observe_native_ui/capture_native_ui_scenario (including screenshots), and
small Ghidra/IDA lifecycle records. Sibling operations of the same
providers already recorded null.

An omitted rawResult now records null. Normalized results are unchanged.
MCP responses repeat Evidence in text and structured content, so each
response and its share of the 10 MiB transport budget shrink by the same
half; e.g. trace_dylib_resolution on a real app bundle drops from
3,217,119 to 1,612,524 bytes per representation.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:52:54 -07:00
6875d4a64c fix(cli): accept JSON files for web capture and screenshot comparisons (#1397)
compare-web-captures and compare-web-screenshots parsed their arguments as
inline JSON only, unlike every other JSON-input command. Screenshot
artifacts and scenario captures embed base64 PNG bytes, so a typical
viewport screenshot exceeds the command-line length limit (128 KiB per
argument on Linux, 1 MiB total on macOS) and could not be compared from
the CLI at all, while MCP accepts it. A file path was reported as
malformed JSON, and screenshot schema failures returned no issues.

Each argument now accepts inline JSON or a JSON file path through
parseCliJsonInput; file failures keep its path diagnostics, malformed
inline JSON keeps the field issue, and screenshot validation reports
projected schema issues. The tests share one CLI JSON process fixture.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:29:42 -07:00
2b6e9ec03f fix(browser): classify selected web trace file failures by cause (#1396)
trace_web_module_imports and trace_web_source_location reported a missing
or non-file manifest, source map or import map as artifact_operation_failed
("Artifact could not be read or written" / "unsafe or conflicting internal
path"). These are caller selections: report ENOENT/ENOTDIR and non-regular
files as invalid input on the selected field, and EACCES/EPERM as a host
access denial. A missing manifest-derived source file stays an artifact fault.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:29:31 -07:00
498cec042a fix(cli): stop advertising multi-letter single-dash option aliases (#1395)
Incur's `alias` map registers one-letter short flags and already accepts
kebab-case spellings of camelCase options. Twenty options registered their
kebab-case long name as an "alias", so help and completions advertised
flags such as `-case-sensitive` and `-relative-path` that the parser reads
as stacked short flags and rejects with "Unknown flag: -c".

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:29:20 -07:00
Minghang ChamlingandGitHub 5fc4d792ca fix(javascript): handle oversized inline source maps (#1386)
* fix(javascript): handle oversized inline source maps

* fix(javascript): avoid source map directive regex overflow
2026-10-10 10:24:38 +08:00
Claude e4a8e0b728 fix(javascript): recognize Electron APIs through renamed bindings
Electron recognition matched callee spellings, so a bundler rename such as
esbuild's `import { ipcMain as ipcMain2 } from "electron"`, or a destructured
`const { contextBridge: bridge } = require("electron")`, hid every IPC
handler, BrowserWindow, contextBridge and utility process behind it.

Resolve each call and construction root lexically to its Electron import or
require origin, and classify the callee under that export's name. Shadowed
locals keep their own meaning.

On Hermes Desktop v0.21.6, whose main process is one esbuild ESM bundle,
recognized main IPC handlers rise from 1 to 204, BrowserWindow constructions
from 0 to 11, and paired renderer transmissions from 1 to 201.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPY2GmufMNGQKoEgRUx1pd
2026-10-10 00:44:32 +00:00
GRAB Racem e17290cd2a ci: re-run the suite on the corrected main checkpoint
b8a8190b restored the guarded-mutation uncertainty the failing test
encodes, so the shard should pass against the new merge base.
2026-10-10 02:34:52 +02:00
GRAB Racem 40f632a155 ci: re-run the suite after the main-branch shard flake
The failed shard is the JavaScriptSemanticGraphBuilder short-circuit
mutation test, which also failed on main run 38003459598 at the same
base commit (08ec2bf8) before this branch started CI, and passed on
main run 38003459600 minutes later at the same commit.
2026-10-10 02:06:15 +02:00
morluto 7aa4d768eb chore(release): sync corrected 6.3 checkpoint 2026-10-10 07:57:04 +08:00
GRAB Racem e10c934d5a fix(ghidra): keep the cross-format branch callee multi-block
The cross-format oracle requires a callee whose recovered control flow
has more than one basic block, and rea_cross_branch provided it through
a symmetric if/else of tail calls. Clang 21 folds that shape into a
conditional negate plus one tail branch, a single basic block, so the
lane rejected a correct Ghidra analysis of the fixture.

Give the branch a loop whose trip count depends on a volatile global:
no compiler can eliminate the backedge, and the callee stays multi-block
at every optimization level on every toolchain.
2026-10-10 01:36:11 +02:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
08ec2bf82f chore(main): release rea-agents 6.3.0 (#1341)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-10 07:14:55 +08:00
kvalls be6863e9a1 feat(ghidra): support native PE DLLs on Windows 2026-10-09 16:11:13 +01:00
325 changed files with 16166 additions and 1496 deletions
@@ -2,7 +2,7 @@
name: reverse-engineer-anything name: reverse-engineer-anything
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis. description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
metadata: metadata:
version: "34" version: "35"
--- ---
# REA # REA
@@ -62,13 +62,20 @@ with a version-pinned `rea-agents` package or continue through the CLI.
A concrete CLI fallback for an operator-supplied JavaScript tree or ASAR is: A concrete CLI fallback for an operator-supplied JavaScript tree or ASAR is:
```bash ```bash
npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app --json npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app --json > app-evidence.json
jq -c '{source: {kind: "inline", evidence: .}, view: {kind: "summary"}}' app-evidence.json > app-view.json
npx -y rea-agents@latest inspect-analysis-view app-view.json
``` ```
No MCP registration or native engine is required. Read the returned Evidence, No MCP registration or native engine is required. The CLI returns the complete
graph, limitations, and unknowns with the same care as an MCP result; the CLI Evidence record directly, and a real application can produce hundreds of
returns the Evidence record directly. This fallback does not establish that megabytes, so save it to a file rather than reading it into context. Start from
MCP is configured. Native CLI tasks still require their selected engine. the summary view, then request a module page
(`{"kind": "page", "collection": "modules", "offset": 0, "limit": 32}`) or one
module the same way, or pass the saved Evidence inline to
`trace-application-feature`. Read graph, limitations, and unknowns with the
same care as an MCP result. This fallback does not establish that MCP is
configured. Native CLI tasks still require their selected engine.
## Route the target first ## Route the target first
@@ -34,6 +34,15 @@ cross-references. Addresses and recovered pseudocode are analysis observations,
not original source. Provider unavailability and unsupported metadata remain not original source. Provider unavailability and unsupported metadata remain
unknown rather than false. unknown rather than false.
For a completed `analyze_function` dossier, use `inspect_analysis_view` with
`view: {kind: "native", facet: "value_flow_operations", offset: 0, limit: 32}`
and the exact retained Evidence ID to read selected facts without reanalysis.
Native facets also include procedure, pseudocode, assembly, basic blocks,
comments, callers/callees, references, unresolved calls, referenced strings and
names, the native API record, and value-flow summaries. Pseudocode offsets and limits count UTF-16
code units; use the returned `next_offset` for continuation. Provider limitations
and unavailable facts remain explicit; the complete parent Evidence is retained.
To see which functions or Objective-C methods a Mach-O actually calls in one To see which functions or Objective-C methods a Mach-O actually calls in one
run, use `observe_native_calls` with explicit breakpoints and a bounded run, use `observe_native_calls` with explicit breakpoints and a bounded
`duration_ms`/`max_events`. It launches a new process under LLDB, so the target `duration_ms`/`max_events`. It launches a new process under LLDB, so the target
+1 -1
View File
@@ -28,7 +28,7 @@ intentionally not included. -->
- Provider, bridge, target-format, or platform compatibility: - Provider, bridge, target-format, or platform compatibility:
- Evidence, artifact, provenance, or reconstruction contract: - Evidence, artifact, provenance, or reconstruction contract:
- Process execution, authorization, cleanup, or containment impact: - Process execution, authorization, cleanup, or containment impact:
- Generated metadata (`docs/public/product-catalog.json`), package, or installation impact (CI generates and validates ignored catalogs and `skills/`; edit authored instructions in `skill-src/` and never commit build outputs): - Generated metadata (`docs/public/product-catalog.json`), package, or installation impact (CI generates and validates ignored catalogs and `skills/`; edit authored instructions in `.agents/skills/reverse-engineer-anything/` and never commit build outputs):
## Evidence and regression coverage ## Evidence and regression coverage
+20
View File
@@ -64,6 +64,14 @@ jobs:
env: env:
REA_ANALYSIS_PROVIDER: ghidra REA_ANALYSIS_PROVIDER: ghidra
run: npm run verify:ghidra:windows -- --x86 | Tee-Object -FilePath windows-ghidra-x86-proof.log run: npm run verify:ghidra:windows -- --x86 | Tee-Object -FilePath windows-ghidra-x86-proof.log
- name: Verify native x64 DLL Windows Ghidra operations
env:
REA_ANALYSIS_PROVIDER: ghidra
run: npm run verify:ghidra:windows -- --dll | Tee-Object -FilePath windows-ghidra-dll-proof.log
- name: Verify native x86 DLL Windows Ghidra operations
env:
REA_ANALYSIS_PROVIDER: ghidra
run: npm run verify:ghidra:windows -- --x86 --dll | Tee-Object -FilePath windows-ghidra-x86-dll-proof.log
- name: Verify ordinary-user CLI and MCP operations - name: Verify ordinary-user CLI and MCP operations
env: env:
REA_ANALYSIS_PROVIDER: ghidra REA_ANALYSIS_PROVIDER: ghidra
@@ -72,6 +80,14 @@ jobs:
env: env:
REA_ANALYSIS_PROVIDER: ghidra REA_ANALYSIS_PROVIDER: ghidra
run: npm run verify:ghidra:windows:package -- --x86 | Tee-Object -FilePath windows-ghidra-x86-package-proof.log run: npm run verify:ghidra:windows:package -- --x86 | Tee-Object -FilePath windows-ghidra-x86-package-proof.log
- name: Verify ordinary-user x64 DLL CLI and MCP operations
env:
REA_ANALYSIS_PROVIDER: ghidra
run: npm run verify:ghidra:windows:package -- --dll | Tee-Object -FilePath windows-ghidra-dll-package-proof.log
- name: Verify ordinary-user x86 DLL CLI and MCP operations
env:
REA_ANALYSIS_PROVIDER: ghidra
run: npm run verify:ghidra:windows:package -- --x86 --dll | Tee-Object -FilePath windows-ghidra-x86-dll-package-proof.log
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
@@ -81,4 +97,8 @@ jobs:
windows-ghidra-package-proof.log windows-ghidra-package-proof.log
windows-ghidra-x86-proof.log windows-ghidra-x86-proof.log
windows-ghidra-x86-package-proof.log windows-ghidra-x86-package-proof.log
windows-ghidra-dll-proof.log
windows-ghidra-x86-dll-proof.log
windows-ghidra-dll-package-proof.log
windows-ghidra-x86-dll-package-proof.log
retention-days: 30 retention-days: 30
+1 -1
View File
@@ -35,7 +35,7 @@ Use [.nvmrc](.nvmrc) and `package.json#packageManager` for the pinned toolchain.
Use relevant checks for the change. The complete local gate, `npm run check:pr`, is optional for broad changes or CI diagnosis. See [CONTRIBUTING.md](CONTRIBUTING.md) for development, generated-file ownership, and release conventions. Use relevant checks for the change. The complete local gate, `npm run check:pr`, is optional for broad changes or CI diagnosis. See [CONTRIBUTING.md](CONTRIBUTING.md) for development, generated-file ownership, and release conventions.
`docs/public/product-catalog.json`, `docs/verification/managed-conformance-*.json`, and `skills/` are ignored outputs. Edit source contracts and `skill-src/`, then regenerate as needed. `.cache/mcp-tool-catalog.json` is test metadata; runtime builds and source checking do not consume it. Never commit binaries, provider project documents, credentials, `dist/`, `node_modules/`, or local planning artifacts. `docs/public/product-catalog.json`, `docs/verification/managed-conformance-*.json`, and `skills/` are ignored outputs. Edit source contracts and `.agents/skills/reverse-engineer-anything/`, then regenerate as needed. `.cache/mcp-tool-catalog.json` is test metadata; runtime builds and source checking do not consume it. Never commit binaries, provider project documents, credentials, `dist/`, `node_modules/`, or local planning artifacts.
## Tool and Test Changes ## Tool and Test Changes
+6 -2
View File
@@ -95,8 +95,12 @@ Pre-commit formats and lints staged files; pre-push runs `check:fast`.
`evidence:generate` prepares only the managed commitment and its runtime/skill `evidence:generate` prepares only the managed commitment and its runtime/skill
dependencies. The ignored outputs are `docs/public/product-catalog.json`, dependencies. The ignored outputs are `docs/public/product-catalog.json`,
`docs/verification/managed-conformance-*.json`, and `skills/`. Edit authored `docs/verification/managed-conformance-*.json`, and `skills/`. Edit authored
skill instructions in `skill-src/`. CI validates generated outputs and retains skill instructions in `.agents/skills/reverse-engineer-anything/`, where Skills
them as artifacts; do not commit them. Reviewed source metadata, including can discover the public bundle directly from a clean checkout. `verify:package`
installs that bundle with Skills 1.7.2 using default repository-root discovery
and checks every installed file, alongside the packaged setup checks.
CI validates generated outputs and retains them as artifacts; do not commit
them. Reviewed source metadata, including
`src/generatedPackageMetadata.ts` and `docs/error-contract.schema.json`, remains `src/generatedPackageMetadata.ts` and `docs/error-contract.schema.json`, remains
tracked and checked for freshness. tracked and checked for freshness.
+2 -2
View File
@@ -1,6 +1,6 @@
<div align="center"> <div align="center">
**English** · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) **English** · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
# REA: Reverse Engineer Anything # REA: Reverse Engineer Anything
@@ -307,7 +307,7 @@ Report vulnerabilities through [SECURITY.md](SECURITY.md).
## Star history ## Star history
🎉 **40,000 GitHub stars — thank you!** 🎉 **50,000 GitHub stars — thank you!**
Thanks to everyone using REA, reporting bugs, requesting features, testing builds, and contributing fixes. Thanks to everyone using REA, reporting bugs, requesting features, testing builds, and contributing fixes.
+2 -2
View File
@@ -1,6 +1,6 @@
<div align="center"> <div align="center">
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · **العربية** · [فارسی](README_fa.md) [English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · **العربية** · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
# REA: هندسة عكسية لأي شيء # REA: هندسة عكسية لأي شيء
@@ -258,7 +258,7 @@ npx rea-agents@latest setup
## سجل النجوم ## سجل النجوم
🎉 **40,000 نجمة على GitHub — شكرًا لكم!** 🎉 **50,000 نجمة على GitHub — شكرًا لكم!**
شكرًا لكل من يستخدم REA، ويبلّغ عن الأخطاء، ويقترح ميزات، ويختبر إصدارات البناء، ويساهم بالإصلاحات. شكرًا لكل من يستخدم REA، ويبلّغ عن الأخطاء، ويقترح ميزات، ويختبر إصدارات البناء، ويساهم بالإصلاحات.
+2 -2
View File
@@ -1,6 +1,6 @@
<div align="center"> <div align="center">
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · **Deutsch** · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) [English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · **Deutsch** · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
# REA: Alles reverse-engineeren # REA: Alles reverse-engineeren
@@ -256,7 +256,7 @@ Melde Sicherheitslücken gemäß [SECURITY.md](SECURITY.md).
## Sternverlauf ## Sternverlauf
🎉 **40.000 GitHub-Sterne – vielen Dank!** 🎉 **50.000 GitHub-Sterne – vielen Dank!**
Danke an alle, die REA nutzen, Fehler melden, Funktionswünsche äußern, Builds testen und Fehlerbehebungen beitragen. Danke an alle, die REA nutzen, Fehler melden, Funktionswünsche äußern, Builds testen und Fehlerbehebungen beitragen.
+2 -2
View File
@@ -1,6 +1,6 @@
<div align="center"> <div align="center">
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · **Español** · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) [English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · **Español** · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
# REA: Ingeniería inversa de cualquier cosa # REA: Ingeniería inversa de cualquier cosa
@@ -256,7 +256,7 @@ Informa de vulnerabilidades siguiendo [SECURITY.md](SECURITY.md).
## Historial de estrellas ## Historial de estrellas
🎉 **40.000 estrellas en GitHub: ¡gracias!** 🎉 **50.000 estrellas en GitHub: ¡gracias!**
Gracias a quienes usan REA, informan de errores, solicitan funciones, prueban compilaciones y contribuyen con correcciones. Gracias a quienes usan REA, informan de errores, solicitan funciones, prueban compilaciones y contribuyen con correcciones.
+2 -2
View File
@@ -1,6 +1,6 @@
<div align="center" dir="rtl"> <div align="center" dir="rtl">
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · **فارسی** [English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · **فارسی** · [Bahasa Indonesia](README_id.md)
# REA: مهندسی معکوس هر چیزی # REA: مهندسی معکوس هر چیزی
@@ -241,7 +241,7 @@ npx rea-agents@latest setup
## تاریخچهٔ ستاره‌ها ## تاریخچهٔ ستاره‌ها
🎉 **۴۰٬۰۰۰ ستارهٔ GitHub — سپاسگزاریم!** 🎉 **۵۰٬۰۰۰ ستارهٔ GitHub — سپاسگزاریم!**
از همهٔ کسانی که از REA استفاده می‌کنند، خطاها را گزارش می‌دهند، قابلیت‌های جدید پیشنهاد می‌کنند، buildها را آزمایش می‌کنند و در بهبود پروژه مشارکت دارند سپاسگزاریم. از همهٔ کسانی که از REA استفاده می‌کنند، خطاها را گزارش می‌دهند، قابلیت‌های جدید پیشنهاد می‌کنند، buildها را آزمایش می‌کنند و در بهبود پروژه مشارکت دارند سپاسگزاریم.
+2 -2
View File
@@ -1,6 +1,6 @@
<div align="center"> <div align="center">
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · **Français** · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) [English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · **Français** · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
# REA : rétro-ingénierie de tout # REA : rétro-ingénierie de tout
@@ -257,7 +257,7 @@ Signalez les vulnérabilités via [SECURITY.md](SECURITY.md).
## Historique des étoiles ## Historique des étoiles
🎉 **40 000 étoiles GitHub — merci !** 🎉 **50 000 étoiles GitHub — merci !**
Merci à toutes les personnes qui utilisent REA, signalent des bugs, proposent des fonctionnalités, testent les builds et contribuent des correctifs. Merci à toutes les personnes qui utilisent REA, signalent des bugs, proposent des fonctionnalités, testent les builds et contribuent des correctifs.
+307
View File
@@ -0,0 +1,307 @@
<div align="center">
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · **Bahasa Indonesia**
# REA: Rekayasa Balik Apa Pun
### Satu MCP untuk rekayasa balik pada berbagai biner, aplikasi, dan perilaku saat runtime.
**Melihat fitur yang Anda sukai. Pahami cara kerjanya hingga ke tingkat biner.**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM)
<a href="https://trendshift.io/repositories/82054?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-82054" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/82054" alt="morluto%2Frea | Trendshift" width="250" height="55"/></a>
**[Situs Web](https://rea.tools/) · [Panduan](https://rea.tools/guides/) · [Contoh Penggunaan](https://rea.tools/showcase/)**
[Mulai Cepat](#mulai-cepat) · [Cara Kerja REA](#cara-kerja-rea) · [Hal yang Dapat Anda Analisis](#hal-yang-dapat-anda-analisis) · [Contoh Penggunaan](#contoh-penggunaan) · [FAQ](#faq) · [Dokumentasi](#dokumentasi)
<code>npx rea-agents setup</code>
<br />
<img src="docs/assets/rea-hopper-analysis.png" alt="REA menjalankan jembatan analisisnya di dalam Hopper saat memeriksa biner native" width="1200" />
<br /><br />
<table aria-label="Komunitas REA">
<tr>
<td align="center" width="360">
<a href="https://discord.gg/GkcryMnJDM">
<img src="docs/assets/discord.svg" height="42" alt="Discord" /><br />
<strong>Bergabunglah dengan Komunitas Rekayasa Balik</strong>
</a><br />
<sub>Discord · Tanya Jawab · Berbagi Hasil</sub>
</td>
</tr>
</table>
<br />
</div>
---
Melihat fitur dalam suatu aplikasi yang ingin Anda hadirkan di produk sendiri? Minta agen Anda menyelidikinya menggunakan REA. REA dapat memeriksa aplikasi tanpa kode sumbernya, menjelaskan cara kerja fitur tersebut, menunjukkan bukti, dan membangun versi untuk proyek Anda.
REA menghubungkan agen Anda dengan berbagai alat untuk memeriksa biner native, aplikasi JavaScript dan Electron, assembly .NET, serta situs web. Anda juga dapat menggunakan alat yang sama dari terminal. Analisis dijalankan secara lokal, dan hasilnya mencakup bukti serta keterbatasan yang mendasari setiap kesimpulan.
Proses penyiapan mendaftarkan REA ke agen Anda dan memasang instruksi alur kerja yang sesuai. Analisis native dapat menggunakan instalasi Hopper, Ghidra, atau IDA yang sudah ada. Proses penyiapan juga dapat memasang Hopper secara opsional setelah mendapatkan persetujuan. Analisis JavaScript statis tidak memerlukan mesin analisis native.
> **[Kunjungi situs web REA](https://rea.tools/)** untuk mendapatkan petunjuk penyiapan, panduan bergambar, dan studi kasus nyata.
## Mulai Cepat
### Siapkan agen Anda
Dengan Node.js dan npm yang telah terpasang, jalankan:
```bash
npx rea-agents setup
```
Pilih agen yang ingin digunakan, tinjau perubahan yang diusulkan, lalu setujui perubahan tersebut. Proses penyiapan menambahkan server MCP REA dan instruksi alur kerja yang sesuai, serta membuat cadangan konfigurasi yang sudah ada. Setelah itu, mulai ulang agen Anda.
Penyiapan mendukung Claude Code, Codex, Cursor, Gemini CLI, Grok Build, dan [agen lainnya](docs/installation.md#supported-agents). Lihat [instalasi dan penyiapan](docs/installation.md) untuk konfigurasi penyedia dan pendaftaran MCP secara manual.
### Minta agen Anda
```text
Pahami cara kerja pencarian di aplikasi Notes, tunjukkan buktinya, dan buat fitur serupa untuk proyek saya.
```
Ganti Notes dengan aplikasi target dan fitur yang ingin Anda pahami.
### Gunakan terminal
Periksa direktori aplikasi JavaScript/Electron yang telah diekstrak atau berkas ASAR:
```bash
npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app --json
```
Hasilnya mencakup modul, impor, batas antarkomponen Electron, dan bukti yang mendasarinya. Ganti jalur tersebut dengan lokasi target Anda, misalnya `"D:/apps/example"` di Windows.
Untuk memasang perintah `rea` agar dapat digunakan secara rutin:
```bash
npm install --global rea-agents
rea --help
```
Untuk analisis native, konfigurasikan penyedia terlebih dahulu. Lihat [Panduan CLI dan Bukti](docs/cli.md) untuk perintah native, pemilihan penyedia, snapshot, dan pembuatan skrip.
### Perbarui REA
REA berkembang dengan cepat, dan rilis baru sering menyertakan perbaikan bug. Pastikan instalasi Anda selalu diperbarui.
Untuk CLI yang dipasang melalui npm:
```bash
rea update
```
Untuk memperbarui pendaftaran agen dan skill Anda, jalankan perintah penyiapan yang ditampilkan oleh proses pembaruan.
Jika Anda menggunakan `npx`, perbarui penyiapan agen dengan:
```bash
npx rea-agents@latest setup
```
Tinjau perubahan penyiapan dan mulai ulang agen Anda. Untuk menjalankan perintah CLI sekali pakai, gunakan `npx rea-agents@latest` diikuti dengan perintah yang diinginkan.
## Cara Kerja REA
Agen Anda memanggil REA melalui MCP untuk memeriksa target dan menelusuri kode yang relevan. REA mengembalikan temuan beserta bukti pendukungnya. Agen kemudian menggunakan temuan tersebut untuk mengajukan pertanyaan lanjutan, menjelaskan perilaku, atau menulis dan menguji implementasi.
Perintah CLI menggunakan alur kerja yang sama.
![Alur investigasi REA: agen Anda menanyakan sesuatu tentang target lokal, REA memeriksa dan menelusurinya menggunakan alat analisis, lalu agen menggunakan kode, referensi, dan hal-hal yang belum diketahui dari hasil tersebut untuk menjelaskan, mengimplementasikan, dan menguji.](website/public/assets/figures/rea-investigation-flow.svg)
[Buka ilustrasi dalam ukuran penuh](website/public/assets/figures/rea-investigation-flow.svg).
<a id="current-status"></a>
## Hal yang Dapat Anda Analisis
REA memerlukan Node.js 22.x (>=22.19), 24.x (>=24.11), atau 26+, beserta npm.
Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis:
| Target | Hasil yang diberikan REA | Persyaratan dan panduan |
| -------------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| Biner native | Pseudocode, assembly, string, simbol, pemanggilan, dan referensi | Hopper, Ghidra, atau IDA; [analisis native](https://rea.tools/guides/native/) |
| Tata letak ELF offline | Bagian, segmen, simbol/relokasi asli, dan kandidat mitigasi statis | pwntools yang disediakan pemanggil pada Linux x64; [diagnostik biner](docs/binary-diagnostics.md) |
| Bytecode EVM | Selector dispatch, offset byte, argumen yang disimpulkan, dan sifat mutabilitas | Pembawa data raw/hex lokal; [panduan EVM offline](docs/evm-bytecode.md) |
| Crash Linux yang direkam | Catatan mentah, register/sinyal setiap thread yang direkam, dan kandidat pemetaan opsional | pwntools yang disediakan pemanggil; GDB/pwndbg opsional; [crash yang direkam](docs/recorded-crashes.md) |
| JavaScript / Electron | Modul, impor, source map, rute, IPC, dan hubungan add-on native | Node.js dan npm; [analisis aplikasi](https://rea.tools/guides/javascript/) |
| Situs web | Struktur halaman, skrip, pengamatan jaringan, dan tangkapan layar yang diminta | Peramban berbasis Chrome; [analisis peramban](https://rea.tools/guides/browser/) |
| Rekaman jaringan tersimpan | Permintaan, respons, payload yang terekspos, dan lokasi sumber | HAR; mitmdump di Linux untuk rekaman native mitmproxy; [panduan rekaman](docs/web-network-captures.md) |
| Assembly .NET | Metadata, instruksi CIL, dependensi native yang dideklarasikan, dan perbandingan build | Tidak ada (hanya analisis statis); [panduan kode terkelola](docs/managed-code-analysis.md) |
| APK Android | Deklarasi manifest, kelas, metode hasil dekompilasi, dan referensi | JADX mode headless dan JDK lengkap di Linux/macOS/Windows x64; [panduan Android](docs/android-analysis.md) |
| Firmware | Wilayah, hasil ekstraksi, dan pengalihan ke analisis native | Binwalk / Unblob di Linux; [panduan firmware](docs/firmware-analysis.md) |
| Paket dan sumber daya | Inventaris berkas, digest, plist, struktur paket Apple, dan sumber daya yang diekstrak | [Panduan artefak dan JavaScript](docs/javascript-artifact-reconstruction.md), [aplikasi Apple](docs/apple-application-analysis.md) |
| Perilaku proses | Keluaran terminal, interaksi, pengamatan proses keluar dan sistem berkas, serta perbandingan eksekusi | Linux/macOS dengan PTY native; [perekaman proses](docs/process-capture.md) |
Pemeriksaan JavaScript dan .NET statis membaca berkas yang disediakan tanpa menjalankan aplikasinya. Perekaman runtime menjalankan atau berinteraksi dengan target yang dipilih menggunakan izin pengguna Anda. Setiap panduan runtime menjelaskan dampaknya.
<a id="choosing-a-deep-analysis-provider"></a>
Format native dan dukungan sistem host berbeda-beda menurut penyedia. Lihat [penyiapan Hopper dan Ghidra](docs/installation.md#hopper), [panduan IDA](docs/ida-provider.md), dan [dukungan eksperimental Ghidra di Windows](docs/windows-ghidra-p0.md).
Ghidra juga mendukung [analisis DOS 16-bit](docs/ghidra-dos.md).
Untuk biner berukuran besar, tingkatkan batas waktu startup menggunakan `REA_GHIDRA_STARTUP_TIMEOUT_MS`.
Untuk memilih penyedia, lihat [panduan CLI](docs/cli.md#choose-a-provider).
Periksa [ketersediaan rilis](docs/installation.md#released-package-and-main) untuk mengetahui fitur yang ditambahkan setelah rilis npm terbaru.
## Contoh Penggunaan
[![Ilustrasi contoh penggunaan DX-Ball sound-pan, Notion clipboard-bridge, dan TH04 bullet-ring](docs/assets/rea-showcases.png)](https://rea.tools/showcase/)
### DX-Ball: merekonstruksi perhitungan sound-pan
Ikuti pemanggilan suara hingga ke fungsi pembantu yang mengubah posisi menjadi nilai pan, periksa instruksinya, lalu ubah pseudocode yang belum lengkap menjadi C. Hasil rekonstruksi tersebut lolos pada 3.205 kasus x86 asli dan mereproduksi seluruh 63 byte fungsi hasil kompilasi.
[Baca studi kasus](https://rea.tools/showcase/dx-ball/) · [Repositori rekonstruksi](https://github.com/N0zoM1z0/dx-ball)
### Notion: menelusuri jembatan clipboard Electron
Temukan API clipboard pada renderer, telusuri jalurnya melalui preload dan IPC hingga ke proses utama, lalu periksa format clipboard kaya.
[Baca studi kasus](https://rea.tools/showcase/notion/)
### TH04: memulihkan perhitungan bullet-ring DOS
Periksa instruksi 16-bit dari gim PC-98 asli, pulihkan perhitungan sudut tetap dan sudut yang diarahkan, lalu bandingkan C++ hasil rekonstruksi dengan keluaran historis kompiler.
[Baca studi kasus](https://rea.tools/showcase/th04/) · [Repositori rekonstruksi](https://github.com/N0zoM1z0/th04)
Jika Anda telah menggunakan REA untuk menganalisis sesuatu yang menarik, kami ingin melihat hasilnya. Bagikan kasus Anda melalui [issue](https://github.com/morluto/rea/issues) atau [pull request](https://github.com/morluto/rea/pulls), sertakan target, pertanyaan Anda, bagaimana REA membantu, dan apa yang Anda temukan.
## FAQ
<details>
<summary><strong>Agen apa saja yang dapat menggunakan REA?</strong></summary>
Agen apa pun yang mendukung server MCP lokal dapat menggunakan REA. Proses penyiapan mengonfigurasi [agen yang didukung](docs/installation.md#supported-agents). Klien lain dapat menggunakan [pendaftaran MCP manual](docs/installation.md#mcp-registry).
</details>
<details>
<summary><strong>Apakah saya memerlukan Hopper, Ghidra, atau IDA?</strong></summary>
Analisis native mendalam menggunakan salah satu dari ketiganya. Pemeriksaan JavaScript dan .NET statis dapat dilakukan tanpa mesin analisis native.
Proses penyiapan dapat memasang Hopper setelah memperoleh persetujuan. Ghidra dan IDA menggunakan instalasi yang sudah tersedia. Lihat [penyiapan penyedia](docs/installation.md#hopper).
</details>
<details>
<summary><strong>Apakah saya harus menjalankan Hopper terlebih dahulu?</strong></summary>
REA akan menjalankan Hopper saat suatu operasi membutuhkannya. Di macOS, dialog saat pertama kali dijalankan mungkin meminta Anda memilih mode demo atau mengaktifkan lisensi.
Lihat [startup dan pemecahan masalah Hopper](docs/installation.md#launcher-paths-and-troubleshooting).
</details>
<details>
<summary><strong>Apa fungsi memasang skill dari skills.sh?</strong></summary>
Skill tersebut menyediakan instruksi investigasi untuk agen Anda. Gunakan `npx rea-agents setup` untuk mendaftarkan server MCP REA dan memasang instruksi yang sesuai, lalu mulai ulang agen Anda.
Lihat [instalasi khusus skill](docs/installation.md#skill-only-installation).
</details>
<details>
<summary><strong>Kode seperti apa yang dihasilkan REA?</strong></summary>
Analisis native menghasilkan pseudocode dan assembly. Analisis JavaScript/Electron memulihkan modul dan hubungan antarmodulnya. Agen Anda menggunakan temuan tersebut untuk menulis dan menguji implementasi. [Contoh penggunaan](#contoh-penggunaan) menyediakan contoh yang telah dikerjakan.
</details>
<details>
<summary><strong>Apakah REA mengunggah aplikasi saya?</strong></summary>
REA menganalisis target secara lokal. Agen Anda menerima hasil alat, sedangkan penyedia model yang digunakannya memiliki kebijakan datanya sendiri.
</details>
<details>
<summary><strong>Apa yang harus saya lakukan jika menemukan bug?</strong></summary>
Perbarui terlebih dahulu. Rilis terbaru mungkin sudah memperbaiki masalah tersebut.
Untuk CLI yang dipasang melalui npm:
```bash
rea update
```
Untuk penyiapan agen melalui `npx`:
```bash
npx rea-agents@latest setup
```
Jika Anda menggunakan agen, selesaikan [pembaruan penyiapan](#perbarui-rea) dan mulai ulang agen tersebut.
Coba kembali tugas yang sama. Jika masalah masih terjadi, [buat issue](https://github.com/morluto/rea/issues) dengan menyertakan versi REA, jenis target, langkah-langkah untuk mereproduksi masalah, dan keluaran kesalahan.
</details>
## Dokumentasi
Mulailah dengan [panduan praktik di situs web](https://rea.tools/guides/).
Untuk mengetahui opsi, prasyarat, dan kontrak hasil secara tepat:
- [Instalasi dan penyiapan](docs/installation.md): pendaftaran agen, konfigurasi penyedia, pembaruan, dan penghapusan instalasi.
- [Kesiapan dan pemecahan masalah](docs/installation.md#check-readiness-for-your-task): mendiagnosis satu agen atau mesin analisis.
- [CLI dan Bukti](docs/cli.md): perintah, pemilihan penyedia, snapshot, impor/ekspor, dan status keluar.
- [Kontrak MCP](docs/mcp-contracts.md) dan [prompt agen](docs/mcp-prompts.md): hasil alat, sesi, dan investigasi terpandu.
- [Katalog alat](docs/mcp-contracts.md#generated-catalog): inventaris alat, penyedia, dan perintah CLI yang dibuat secara otomatis.
- [Peta jalan](docs/roadmap.md): pekerjaan yang direncanakan dan pelacak kemampuan.
Laporkan kerentanan melalui [SECURITY.md](SECURITY.md).
## Riwayat Bintang
🎉 **50.000 bintang GitHub, terima kasih!**
Terima kasih kepada semua orang yang menggunakan REA, melaporkan bug, meminta fitur, menguji build, dan berkontribusi dalam perbaikan.
<a href="https://www.star-history.com/?repos=morluto%2Frea&amp;type=date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=morluto/rea&amp;type=date&amp;theme=dark&amp;legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=morluto/rea&amp;type=date" />
<img alt="Riwayat bintang GitHub REA" src="https://api.star-history.com/chart?repos=morluto/rea&amp;type=date" />
</picture>
</a>
## Penafian
REA menyediakan alat untuk penelitian, analisis, dan rekonstruksi rekayasa balik yang sah. Anda bertanggung jawab untuk memperoleh otorisasi yang diperlukan dan mematuhi hukum yang berlaku. Proyek ini tidak mendukung penggunaan ilegal atau tanpa izin.
REA adalah proyek perangkat lunak sumber terbuka. Kami tidak menerbitkan atau mendukung mata uang kripto maupun token apa pun. Token yang menggunakan nama REA tidak berafiliasi dengan proyek ini.
## Kontribusi
Kami sangat menghargai bantuan Anda untuk REA! [Buat issue](https://github.com/morluto/rea/issues) untuk melaporkan bug atau menyarankan fitur, atau [kirim pull request](https://github.com/morluto/rea/pulls) untuk meningkatkan kode atau dokumentasi.
Lihat [CONTRIBUTING.md](CONTRIBUTING.md) untuk penyiapan pengembangan dan pemeriksaan, [pengujian](docs/testing.md) untuk tahapan verifikasi, serta [peta arsitektur](docs/architecture.mermaid) untuk struktur proyek.
## Lisensi
[MIT](LICENSE)
+2 -2
View File
@@ -1,6 +1,6 @@
<div align="center"> <div align="center">
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · **日本語** · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) [English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · **日本語** · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
# REA:あらゆるものをリバースエンジニアリング # REA:あらゆるものをリバースエンジニアリング
@@ -258,7 +258,7 @@ npx rea-agents@latest setup
## スター履歴 ## スター履歴
🎉 **GitHub スター 40,000 件、ありがとうございます!** 🎉 **GitHub スター 50,000 件、ありがとうございます!**
REA の利用、バグ報告、機能の要望、ビルドのテスト、修正への貢献に感謝します。 REA の利用、バグ報告、機能の要望、ビルドのテスト、修正への貢献に感謝します。
+2 -2
View File
@@ -1,6 +1,6 @@
<div align="center"> <div align="center">
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · **한국어** · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) [English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · **한국어** · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
# REA: 무엇이든 리버스 엔지니어링 # REA: 무엇이든 리버스 엔지니어링
@@ -258,7 +258,7 @@ npx rea-agents@latest setup
## 스타 기록 ## 스타 기록
🎉 **GitHub 스타 40,000개, 감사합니다!** 🎉 **GitHub 스타 50,000개, 감사합니다!**
REA를 사용하고, 버그를 보고하고, 기능을 요청하고, 빌드를 테스트하고, 수정에 기여해 주신 모든 분께 감사드립니다. REA를 사용하고, 버그를 보고하고, 기능을 요청하고, 빌드를 테스트하고, 수정에 기여해 주신 모든 분께 감사드립니다.
+2 -2
View File
@@ -1,6 +1,6 @@
<div align="center"> <div align="center">
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · **Polski** · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) [English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · **Polski** · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
# REA: Reverse Engineer Anything # REA: Reverse Engineer Anything
@@ -256,7 +256,7 @@ Zgłaszaj podatności zgodnie z [SECURITY.md](SECURITY.md).
## Historia gwiazdek ## Historia gwiazdek
🎉 **40 000 gwiazdek na GitHubie — dziękujemy!** 🎉 **50 000 gwiazdek na GitHubie — dziękujemy!**
Dziękujemy wszystkim, którzy używają REA, zgłaszają błędy, proponują nowe funkcje, testują kompilacje i przesyłają poprawki. Dziękujemy wszystkim, którzy używają REA, zgłaszają błędy, proponują nowe funkcje, testują kompilacje i przesyłają poprawki.
+2 -2
View File
@@ -1,6 +1,6 @@
<div align="center"> <div align="center">
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · **Português (Brasil)** · [العربية](README_ar.md) · [فارسی](README_fa.md) [English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · **Português (Brasil)** · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
# REA: Faça engenharia reversa de qualquer coisa # REA: Faça engenharia reversa de qualquer coisa
@@ -256,7 +256,7 @@ Relate vulnerabilidades conforme [SECURITY.md](SECURITY.md).
## Histórico de estrelas ## Histórico de estrelas
🎉 **40.000 estrelas no GitHub — muito obrigado!** 🎉 **50.000 estrelas no GitHub — muito obrigado!**
Obrigado a todos que usam o REA, relatam bugs, sugerem funcionalidades, testam builds e contribuem com correções. Obrigado a todos que usam o REA, relatam bugs, sugerem funcionalidades, testam builds e contribuem com correções.
+2 -2
View File
@@ -1,6 +1,6 @@
<div align="center"> <div align="center">
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · **Русский** · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) [English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · **Русский** · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
# REA: Реверс-инжиниринг чего угодно # REA: Реверс-инжиниринг чего угодно
@@ -256,7 +256,7 @@ npx rea-agents@latest setup
## История звёзд ## История звёзд
🎉 **40 000 звёзд на GitHub — спасибо!** 🎉 **50 000 звёзд на GitHub — спасибо!**
Спасибо всем, кто использует REA, сообщает об ошибках, предлагает новые функции, тестирует сборки и помогает с исправлениями. Спасибо всем, кто использует REA, сообщает об ошибках, предлагает новые функции, тестирует сборки и помогает с исправлениями.
+2 -2
View File
@@ -1,6 +1,6 @@
<div align="center"> <div align="center">
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · **ไทย** · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) [English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · **ไทย** · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
# REA: Reverse Engineer Anything # REA: Reverse Engineer Anything
@@ -256,7 +256,7 @@ npx rea-agents@latest setup
## ประวัติดาว ## ประวัติดาว
🎉 **40,000 ดาวบน GitHub — ขอบคุณทุกคน!** 🎉 **50,000 ดาวบน GitHub — ขอบคุณทุกคน!**
ขอบคุณทุกคนที่ใช้ REA รายงานบั๊ก เสนอฟีเจอร์ ทดสอบบิลด์ และช่วยแก้ไข ขอบคุณทุกคนที่ใช้ REA รายงานบั๊ก เสนอฟีเจอร์ ทดสอบบิลด์ และช่วยแก้ไข
+2 -2
View File
@@ -1,6 +1,6 @@
<div align="center"> <div align="center">
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · **Türkçe** · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) [English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · **Türkçe** · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
# REA: Her şeyi tersine mühendislikle inceleyin # REA: Her şeyi tersine mühendislikle inceleyin
@@ -256,7 +256,7 @@ Güvenlik açıklarını [SECURITY.md](SECURITY.md) üzerinden bildirin.
## Yıldız geçmişi ## Yıldız geçmişi
🎉 **GitHub'da 40.000 yıldız — teşekkürler!** 🎉 **GitHub'da 50.000 yıldız — teşekkürler!**
REA'yı kullanan, hata bildiren, özellik talep eden, derlemeleri test eden ve düzeltmelere katkıda bulunan herkese teşekkürler. REA'yı kullanan, hata bildiren, özellik talep eden, derlemeleri test eden ve düzeltmelere katkıda bulunan herkese teşekkürler.
+2 -2
View File
@@ -1,6 +1,6 @@
<div align="center"> <div align="center">
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · **Українська** · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) [English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · **Українська** · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
# REA: Реверс-інжиніринг будь-чого # REA: Реверс-інжиніринг будь-чого
@@ -256,7 +256,7 @@ npx rea-agents@latest setup
## Історія зірок ## Історія зірок
🎉 **40 000 зірок на GitHub — дякуємо!** 🎉 **50 000 зірок на GitHub — дякуємо!**
Дякуємо всім, хто використовує REA, повідомляє про помилки, пропонує нові функції, тестує збірки й допомагає з виправленнями. Дякуємо всім, хто використовує REA, повідомляє про помилки, пропонує нові функції, тестує збірки й допомагає з виправленнями.
+2 -2
View File
@@ -1,6 +1,6 @@
<div align="center"> <div align="center">
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · **Tiếng Việt** · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) [English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · **Tiếng Việt** · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
# REA: Kỹ thuật dịch ngược cho mọi thứ # REA: Kỹ thuật dịch ngược cho mọi thứ
@@ -256,7 +256,7 @@ Báo cáo lỗ hổng theo [SECURITY.md](SECURITY.md).
## Lịch sử sao ## Lịch sử sao
🎉 **40.000 sao trên GitHub — xin cảm ơn!** 🎉 **50.000 sao trên GitHub — xin cảm ơn!**
Cảm ơn mọi người đã sử dụng REA, báo lỗi, đề xuất tính năng, kiểm thử bản dựng và đóng góp bản sửa lỗi. Cảm ơn mọi người đã sử dụng REA, báo lỗi, đề xuất tính năng, kiểm thử bản dựng và đóng góp bản sửa lỗi.
+2 -2
View File
@@ -1,6 +1,6 @@
<div align="center"> <div align="center">
[English](README.md) · [简体中文](README_zh.md) · **繁體中文** · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) [English](README.md) · [简体中文](README_zh.md) · **繁體中文** · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
# REA:逆向分析一切 # REA:逆向分析一切
@@ -258,7 +258,7 @@ npx rea-agents@latest setup
## Star 歷史 ## Star 歷史
🎉 **GitHub Star 達到 40,000 個,感謝大家!** 🎉 **GitHub Star 達到 50,000 個,感謝大家!**
感謝每一位使用 REA、報告錯誤、提出功能需求、測試建置和貢獻修復的朋友。 感謝每一位使用 REA、報告錯誤、提出功能需求、測試建置和貢獻修復的朋友。
+2 -2
View File
@@ -1,6 +1,6 @@
<div align="center"> <div align="center">
[English](README.md) · **简体中文** · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) [English](README.md) · **简体中文** · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
# REA:逆向分析一切 # REA:逆向分析一切
@@ -258,7 +258,7 @@ npx rea-agents@latest setup
## Star 历史 ## Star 历史
🎉 **GitHub Star 达到 40,000 个,感谢大家!** 🎉 **GitHub Star 达到 50,000 个,感谢大家!**
感谢每一位使用 REA、报告错误、提出功能需求、测试构建和贡献修复的朋友。 感谢每一位使用 REA、报告错误、提出功能需求、测试构建和贡献修复的朋友。
+7 -2
View File
@@ -126,8 +126,13 @@ also stops target hashing before provider admission.
These operations do not execute the APK or provide split APK/AAB handling, These operations do not execute the APK or provide split APK/AAB handling,
signature validation, full resource-table semantics, native-library analysis or signature validation, full resource-table semantics, native-library analysis or
Android runtime capture. Existing artifact inventory/extraction tools can supply Android runtime capture. An `.aab` suffix is a ZIP archive, not an APK, so
archive evidence. Project that Evidence with the execution-free inventory tool: Android tools and the application graph do not treat it as a standalone APK.
Existing artifact inventory/extraction tools can supply archive evidence.
Inventory keeps ZIP names that differ only in case and records that a
case-insensitive destination cannot store both spellings. Extraction fails
only when that destination directory cannot keep both names. Project that
Evidence with the execution-free inventory tool:
```sh ```sh
rea project-android-application-graph '{"inventory_evidence":[<inventory_artifact Evidence>]}' rea project-android-application-graph '{"inventory_evidence":[<inventory_artifact Evidence>]}'
+1
View File
@@ -18,6 +18,7 @@ REA can attach to a user-owned Chrome-family browser through the Chrome DevTools
- `compare_web_captures` / `rea compare-web-captures` compares passive captures or exact-step-aligned browser scenarios. Scenario results commit reproducible literal normalization and expose alignment failures plus artifact-level action, screenshot, DOM, accessibility, URL, history, storage, and event differences. Missing or truncated evidence is never treated as equivalence. - `compare_web_captures` / `rea compare-web-captures` compares passive captures or exact-step-aligned browser scenarios. Scenario results commit reproducible literal normalization and expose alignment failures plus artifact-level action, screenshot, DOM, accessibility, URL, history, storage, and event differences. Missing or truncated evidence is never treated as equivalence.
- `capture_web_screenshot` / `rea capture-web-screenshot` returns a content-addressed visible-viewport PNG. - `capture_web_screenshot` / `rea capture-web-screenshot` returns a content-addressed visible-viewport PNG.
- `compare_web_screenshots` / `rea compare-web-screenshots` performs bounded local PNG pixel comparison without OCR or external services. - `compare_web_screenshots` / `rea compare-web-screenshots` performs bounded local PNG pixel comparison without OCR or external services.
- The CLI comparison commands accept each capture, screenshot artifact or normalization policy as inline JSON or a JSON file path. Use files for screenshot artifacts and scenario captures, whose embedded PNG bytes can exceed the host's command-line length limit.
- Every surface has equivalent CLI and MCP contracts and returns Evidence provenance. - Every surface has equivalent CLI and MCP contracts and returns Evidence provenance.
- MCP tools return complete results inline and include the Evidence ID. Session evidence can be exported with `export_evidence_bundle`. - MCP tools return complete results inline and include the Evidence ID. Session evidence can be exported with `export_evidence_bundle`.
+21 -1
View File
@@ -20,6 +20,23 @@ files when neither `--provider` nor `--snapshot` is supplied. See
[JavaScript artifact reconstruction](javascript-artifact-reconstruction.md) [JavaScript artifact reconstruction](javascript-artifact-reconstruction.md)
for results, integrity checks and coverage. for results, integrity checks and coverage.
The complete application Evidence can be hundreds of megabytes for a real
application. When an agent or a person reads the result, save it to a file and
project a summary, a module page or one module from the saved Evidence:
```bash
rea analyze-javascript-application /absolute/path/to/app --json > app-evidence.json
jq -c '{source: {kind: "inline", evidence: .}, view: {kind: "summary"}}' app-evidence.json > app-view.json
rea inspect-analysis-view app-view.json
```
For example, a 334 MB Obsidian application Evidence file yields a summary view
of about 10 KB. Replace the view with
`{"kind": "page", "collection": "modules", "offset": 0, "limit": 32}` for a
module page. See
[JavaScript application workflows](javascript-application-workflows.md) for
views, feature traces and comparisons that take the same saved Evidence.
## Native analysis ## Native analysis
Configure [Hopper or Ghidra](installation.md#hopper), or the Configure [Hopper or Ghidra](installation.md#hopper), or the
@@ -179,7 +196,10 @@ user permissions; consult the chosen guide for host requirements and effects.
## Output and exit status ## Output and exit status
The default terminal format is TOON. Use `--json` when saving results for a The default terminal format is TOON. Use `--json` when saving results for a
JSON consumer. Output selection and formatting do not change operation status. JSON consumer. `--json` is indented on a terminal and compact when piped or
redirected, which keeps large results smaller for agents and scripts; pipe
through `jq .` for indented files. Output selection and formatting do not change
operation status.
| Status | Meaning | | Status | Meaning |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
+5 -2
View File
@@ -62,11 +62,14 @@ Mach-O target coverage. It requires `clang`, LLD, and `lld-link`; set
these tools before compilation. Keeping this matrix separate lets Linux these tools before compilation. Keeping this matrix separate lets Linux
host/provider acceptance run with only the host compiler. host/provider acceptance run with only the host compiler.
`npm run verify:ghidra:windows` and its `-- --x86` variant check the source-owned native x86 and x86-64 PE `npm run verify:ghidra:windows` checks source-owned native x86-64 PE applications;
fixture, all 25 read-only operations, target/snapshot/import SHA-256 linkage, `-- --x86` selects x86 and `-- --dll` selects DLLs, and the flags can be combined.
These lanes check all 25 read-only operations, target/snapshot/import SHA-256 linkage,
and project, endpoint, process, and runtime cleanup. The independent native and project, endpoint, process, and runtime cleanup. The independent native
lane checks DACLs, handle admission, cancellation, and Job Object lifecycle. lane checks DACLs, handle admission, cancellation, and Job Object lifecycle.
`npm run verify:ghidra:windows:package` packs and installs REA into an isolated `npm run verify:ghidra:windows:package` packs and installs REA into an isolated
prefix, then checks ordinary-user CLI/MCP operations against canonical contracts. prefix, then checks ordinary-user CLI/MCP operations against canonical contracts.
It accepts the same architecture and DLL flags and verifies the selected PE role
in the analysis profile.
All lanes require matching native controls; package startup alone establishes All lanes require matching native controls; package startup alone establishes
only package compatibility. only package compatibility.
+80 -10
View File
@@ -95,7 +95,7 @@ npx skills add morluto/rea --skill reverse-engineer-anything
This installs agent instructions and bundled references, not REA MCP This installs agent instructions and bundled references, not REA MCP
registration or analysis engines. Follow the skill's registration or analysis engines. Follow the skill's
[conditional connection guide](https://github.com/morluto/rea/blob/main/skill-src/reverse-engineer-anything/SKILL.md#connect-only-when-needed). [conditional connection guide](https://github.com/morluto/rea/blob/main/.agents/skills/reverse-engineer-anything/SKILL.md#connect-only-when-needed).
Working tools can be used immediately. If tools are missing, inspect the current Working tools can be used immediately. If tools are missing, inspect the current
client's registration with `doctor --client codex --json` (substitute its client client's registration with `doctor --client codex --json` (substitute its client
ID), then plan repairs with `setup --client codex --dry-run --json`. Show and ID), then plan repairs with `setup --client codex --dry-run --json`. Show and
@@ -135,26 +135,95 @@ listed after the table because its connector is not one of these files:
| Antigravity | `antigravity` | | Antigravity | `antigravity` |
| GitHub Copilot CLI | `copilot_cli` | | GitHub Copilot CLI | `copilot_cli` |
| Command Code | `commandcode` | | Command Code | `commandcode` |
| Qwen Code | `qwen_code` |
| VS Code | `vscode` | | VS Code | `vscode` |
| Grok Build | `grok_build` | | Grok Build | `grok_build` |
| OMP | `omp` | | OMP | `omp` |
| Pi | `pi` |
| Hermes | `hermes` |
For [Pi](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/mcp.md),
setup writes a `type: "stdio"` entry under `mcpServers.rea` in the user-level
`~/.pi/agent/mcp.json` and installs the bundled skill under `~/.agents/skills`.
Use `rea setup --client pi --dry-run --json` to inspect the plan, then
`rea setup --client pi --yes --json` to approve it. Check the registration with
`rea doctor --client pi --json`. Pi reads `mcp.json` as strict JSON, like its
own loader: comments, trailing commas, a byte-order mark, or an empty existing
file are invalid, and setup reports them instead of rewriting the file. Repair
such a file to plain JSON first, then rerun setup.
Setup follows Pi's `PI_CODING_AGENT_DIR`: unset or empty selects the default;
`~` and `~/` expand to the home directory; absolute paths and file URLs select
that directory; relative paths stay relative to the command's working directory.
On Windows, native paths, `~\`, and Git Bash/MSYS, Cygwin, and WSL drive paths
are normalized as Pi does. Run REA and Pi from the same working directory when
using a relative override. Pi has no profiles: `OMP_PROFILE`, `PI_PROFILE`,
`PI_CONFIG_DIR`, and OMP's `enabledServers`/`disabledServers` lists do not affect
Pi registration. Doctor accepts an omitted `type` for stdio but reports a
non-stdio type as invalid; `enabled: false` stays disabled.
REA configures only the user scope. Pi also reads `.pi/mcp.json` in trusted
projects; a same-name project entry can replace the user entry or override its
enabled state and exposure. Check that file if the user registration is aligned
but unavailable in a project. An extension that registers `/mcp` can replace
Pi's built-in MCP support and use its own configuration instead. Run `/reload`
in an existing Pi session after changing servers outside the session.
For Qwen Code, setup registers `rea` in the `mcpServers` table of the
user-level `~/.qwen/settings.json`, or `$QWEN_HOME/settings.json` when configured.
Setup and doctor follow Qwen Code's tilde and working-directory-relative home
overrides. Qwen Code also discovers personal skills in `~/.agents/skills`,
where setup installs the bundled workflow.
Gemini CLI uses `$GEMINI_CLI_HOME/.gemini/settings.json` when that home override
is nonempty, including its shared personal skills at
`$GEMINI_CLI_HOME/.agents/skills`. Setup and doctor check MCP allow/exclude
policies from system defaults, user settings, trusted workspace settings, and
system overrides. Workspace trust follows `GEMINI_CLI_TRUST_WORKSPACE`,
`GEMINI_RESTRICTED_MODE`, the effective `security.folderTrust.enabled` setting,
and the selected `trustedFolders.json` (including
`GEMINI_CLI_TRUSTED_FOLDERS_PATH`). Settings in an untrusted workspace do not
participate. A workspace or system `mcpServers.rea` replaces the entire user
entry; setup stops if the winning definition conflicts with its planned entry.
Review those files manually: setup never edits administrator/workspace settings
or grants workspace trust. Trust supplied only by a running IDE is outside this
standalone CLI check.
Codex treats an unset or empty `CODEX_HOME` as `~/.codex`. Devin honors
`XDG_CONFIG_HOME` for its `devin/mcp_config.json` on non-Windows systems.
For OMP, setup writes a `type: "stdio"` entry to the user-level For OMP, setup writes a `type: "stdio"` entry to the user-level
`~/.omp/agent/mcp.json`. It follows `PI_CONFIG_DIR`, an absolute `~/.omp/agent/mcp.json`. It follows `PI_CONFIG_DIR` and
`PI_CODING_AGENT_DIR`, and the profile selected by `OMP_PROFILE` or `PI_CODING_AGENT_DIR` (relative to the setup working directory), and the profile selected by `OMP_PROFILE` or
`PI_PROFILE` (`~/.omp/profiles/<name>/agent/mcp.json`). Setup also removes `PI_PROFILE` (`~/.omp/profiles/<name>/agent/mcp.json`). Setup also removes
`rea` from that file's `disabledServers` list, which would otherwise hide the `rea` from that file's `disabledServers` list, which would otherwise hide the
registration. Doctor treats an `enabled: false` entry as active when registration. Doctor treats an `enabled: false` entry as active when
`enabledServers` lists `rea`, as OMP does, unless `disabledServers` also lists `enabledServers` lists `rea`, as OMP does, unless `disabledServers` also lists
it. Run setup under each profile that should load REA. it. Run setup under each profile that should load REA.
For Hermes, setup writes a `mcp_servers.rea` entry to `config.yaml`, preserving
comments and unrelated settings. It resolves that file from `HERMES_HOME`, else
from the platform default Hermes itself uses — `%LOCALAPPDATA%\hermes` on
Windows, `~/.hermes` elsewhere — including any `HERMES_DATA_DIR_SUFFIX`. Hermes
connects an entry unless `enabled` is false, so setup states `enabled: true`.
Hermes personal skills install under the active Hermes home's `skills` directory.
REA follows `HERMES_HOME` whitespace trimming, environment-variable expansion,
and home expansion before resolving that profile. Setup previews the resolved
configuration and skill destinations; doctor and uninstall use those same paths.
For OpenCode, setup writes the V1 `mcp.rea` entry, which OpenCode V1 and V2 For OpenCode, setup writes the V1 `mcp.rea` entry, which OpenCode V1 and V2
both load. If the configuration already uses OpenCode V2's native both load. If the configuration already uses OpenCode V2's native
`mcp.servers` table, setup registers REA there instead and replaces any earlier `mcp.servers` table, setup registers REA there instead and replaces any earlier
`mcp.rea` entry from REA. `mcp.rea` entry from REA. Setup honors `OPENCODE_CONFIG_DIR` and
`OPENCODE_CONFIG`, preferring the later-loaded JSONC file when both JSON and
JSONC exist. The ordinary XDG global configuration still participates when a
custom directory is selected. Doctor checks the merged user registration; uninstall removes
REA-owned entries from every applicable user file so an older entry cannot
become active again. Malformed applicable files block setup before changes.
Grok Build loads `[mcp_servers.rea]` from `$GROK_HOME/config.toml`, or from Grok Build loads `[mcp_servers.rea]` from `$GROK_HOME/config.toml`, or from
`~/.grok/config.toml` when `GROK_HOME` is unset. Setup edits that server `~/.grok/config.toml` when `GROK_HOME` is unset or empty. Setup edits that server
table, `[mcp_servers.rea.env]`, and a root `disabled_mcp_servers` entry that table, `[mcp_servers.rea.env]`, and a root `disabled_mcp_servers` entry that
names `rea`. It sets `startup_timeout_sec = 30` and leaves every other name names `rea`. It sets `startup_timeout_sec = 30` and leaves every other name
in that list. The shared skill installed under `~/.agents/skills` is already in that list. The shared skill installed under `~/.agents/skills` is already
@@ -194,10 +263,11 @@ save verified paths for an existing Ghidra installation.
The bundled skill is installed where each selected client discovers personal The bundled skill is installed where each selected client discovers personal
skills: Claude Code uses `~/.claude/skills` (or skills: Claude Code uses `~/.claude/skills` (or
`$CLAUDE_CONFIG_DIR/skills` when configured), while other supported clients `$CLAUDE_CONFIG_DIR/skills` when configured). Gemini CLI uses `.agents/skills`
use the shared `~/.agents/skills` directory. A mixed selection plans both under its selected home; Antigravity 2.0 uses `~/.gemini/config/skills`.
paths. Selecting the skill without a client uses the shared directory. Setup Other integrations, including Qwen Code, use `~/.agents/skills`.
leaves existing skill copies in other locations untouched. A mixed selection plans each distinct destination. Selecting the skill without a client uses the shared
directory. Setup leaves existing skill copies in other locations untouched.
`doctor --skill --json` verifies the selected copies against the bundled `doctor --skill --json` verifies the selected copies against the bundled
instructions and references. Consumers should use `identity.skill.state`, instructions and references. Consumers should use `identity.skill.state`,
@@ -489,7 +559,7 @@ entry comments atomically and returns refreshed analysis. These session metadata
edits leave executable bytes unchanged and are discarded on close. GUI controls edits leave executable bytes unchanged and are discarded on close. GUI controls
require Hopper; Windows P0 remains read-only. require Hopper; Windows P0 remains read-only.
Windows P0 admits native x86 and x86-64 PE applications on fixed local NTFS volumes. Windows P0 admits native x86 and x86-64 PE applications and DLLs on fixed local NTFS volumes.
The npm package bundles native Job Object ownership, protected private runtime The npm package bundles native Job Object ownership, protected private runtime
DACLs, and handle-based path admission; no separate addon installation is needed. DACLs, and handle-based path admission; no separate addon installation is needed.
See the [Windows Ghidra P0 guide](windows-ghidra-p0.md) for verified scope and See the [Windows Ghidra P0 guide](windows-ghidra-p0.md) for verified scope and
+45 -2
View File
@@ -90,6 +90,44 @@ keep the result ambiguous. Runtime Evidence can later corroborate an exact
mapped candidate, but structural reachability, semantic influence, runtime mapped candidate, but structural reachability, semantic influence, runtime
observation, and causal proof remain separate claims. observation, and causal proof remain separate claims.
Semantic literal nodes keep their complete value in `properties.value`; literal
queries match that field. String labels use `string literal` when that is
shorter than the complete JSON value. A literal's `identity.role_key` commits
its canonical JSON value as `value-sha256:<digest>` when the digest key is
shorter; smaller values keep their existing JSON key. This keeps large values
out of identity and display metadata without expanding short values or
truncating their Evidence.
New analyses therefore produce different IDs for hashed literals from the
earlier payload-bearing role keys. Use IDs returned by the selected parent
graph and read literal values from `properties.value`, rather than decoding
role keys or labels. Previously stored graphs and their original IDs remain
valid inputs.
Endpoint nodes keep the exact endpoint in each observation's `properties.value`;
semantic request and response nodes keep it in `properties.endpoint`. Endpoint
identity keys use `value-sha256:<digest>` over the canonical JSON string when
that is shorter, or when the original value starts with the reserved digest
prefix. Other short endpoint keys remain unchanged. Endpoint labels use the
shorter of the exact value and `<kind> endpoint`; semantic request/response
labels use the shorter of the endpoint and operation method. These labels are
display metadata, not endpoint lookup values.
New analyses therefore change hashed endpoint IDs and longer endpoint labels.
Use IDs from the selected graph and read the complete endpoint properties;
previously stored graphs still support their original IDs. Literal endpoint
search, semantic tracing, and version comparison use the preserved values.
Event and listener nodes likewise preserve the complete event name in
`properties.event_name`. Event role keys use the same reserved-prefix string
digest rule, scoped to the emitter; repeated registrations, removals, and
dispatches keep one event per exact emitter/name pair. Long event labels use
`event`, and listener labels use the shorter of `<method>:<name>` and
`<method>:listener`. Dynamic names remain `null` with explicit uncertainty. New analyses
change hashed event IDs and longer event/listener labels; query the preserved
name or select IDs from the parent graph. Previously stored graphs keep their
original IDs and remain valid trace inputs.
## Version comparison ## Version comparison
REA pairs entities only when a tier produces one unique candidate on each side. REA pairs entities only when a tier produces one unique candidate on each side.
@@ -145,8 +183,13 @@ Direct return expressions, including expression-bodied arrows, are evaluated
through an execution-free value lattice. Literal object fields and direct through an execution-free value lattice. Literal object fields and direct
return sites are represented in the result. Calls, dynamic spreads, computed return sites are represented in the result. Calls, dynamic spreads, computed
keys, and parser recovery remain partial or unknown. keys, and parser recovery remain partial or unknown.
Objects and arrays passed to calls, constructors, or tagged templates, or used Objects and arrays passed to calls, constructors, or tagged templates, used
as method receivers, are not assumed unchanged after the invocation. Aliases as method receivers, or stored through property targets are not assumed
unchanged afterward. Property stores conservatively retain reference escape
uncertainty rather than pretending to execute later writes. References
handed back by local functions, methods, constructors, async functions, and
generators also retain escape uncertainty, including when the call is read-only;
this does not establish that a mutation occurred. Aliases
and shared children in spread and rest copies retain that uncertainty; copied and shared children in spread and rest copies retain that uncertainty; copied
primitive slots and unrelated containing properties remain known. Object rest primitive slots and unrelated containing properties remain known. Object rest
excludes consumed keys, array rest excludes the consumed prefix for known excludes consumed keys, array rest excludes the consumed prefix for known
+4
View File
@@ -82,6 +82,10 @@ x86-64 ELF and native Windows PE targets analyzed on Linux x64 with Ghidra
See [NativeAOT recovery](ghidra-nativeaot.md) for build/configuration, recovered See [NativeAOT recovery](ghidra-nativeaot.md) for build/configuration, recovered
metadata, derived-memory provenance, and the real verification lane. metadata, derived-memory provenance, and the real verification lane.
REA's experimental Windows Ghidra boundary admits native x86 and x86-64 PE
applications and DLLs for static analysis. This does not establish Windows
NativeAOT metadata-recovery coverage.
For NativeAOT analysis, preserve the selected native artifact's path and digest For NativeAOT analysis, preserve the selected native artifact's path and digest
and report recovered type data as provider observations. Linking those types and report recovered type data as provider observations. Linking those types
to native function addresses requires verified to native function addresses requires verified
+87 -6
View File
@@ -36,6 +36,56 @@ after SDK conversion and check advertised validation and actual calls.
Individual model APIs can impose additional nesting limits; complete producer Individual model APIs can impose additional nesting limits; complete producer
captures can exceed ten structural levels. captures can exceed ten structural levels.
### Compact input schema profile
Some function-calling providers reject a request while any single tool's
`parameters` schema exceeds a literal serialized size cap; Moonshot clients
such as kimi-cli fail every request while the default catalog is attached
([#1484](https://github.com/morluto/rea/issues/1484)). Setting the server's
`REA_MCP_INPUT_SCHEMA_PROFILE` environment variable to `compact` renders every
advertised input schema within a 13,312-byte budget, measured to sit below
Moonshot's observed cap (community measurements: a 13.7 KB schema is accepted
while a ~15 KB one is rejected). The setting is read at startup; restart the
server to apply changes, as with `REA_MCP_MAX_RESPONSE_BYTES`. The default
value `full` keeps the complete advertisement unchanged.
The compact profile renders the same tool set, output schemas, annotations,
and canonical server-side validation. It changes only the advertised input
JSON Schema:
- Annotation prose leaves the advertised form: nested property descriptions,
literal examples, defaults, and titles are dropped. The root keeps its
description because it states the accepted input groups.
- Schema-local references are inlined within the budget. Kimi Code expands
references before sending provider requests, so sharing definitions cannot
establish the provider-facing size. Expansion stops at the budget before
allocating an oversized presentation. Unconstrained JSON fields advertise
all JSON value types explicitly so Kimi cannot infer string for Evidence
objects or arrays.
- The few schemas whose validation structure alone exceeds the budget
(`compare_web_captures`, `build_reconstruction_obligation_ledger`,
`evaluate_reconstruction_coverage`, `project_managed_application_graph`,
`compare_application_versions`, `capture_browser_scenario`) are advertised
in a reduced form: property names, reference-resolved types (including union
alternatives), first-level guidance, and the root's required-field constraints,
with nested validation detail elided. The
canonical schema still rejects malformed calls server-side, so callers
following the reduced advertisement receive the canonical typed error
instead of silent acceptance.
Boundary tests pin every advertised compact schema to the budget, keep them
valid JSON Schema that still accepts every canonical example, and hold the
set of six reduced presentations as contracts evolve. Client verification
also checks provider-facing sizes and canonical examples after conversion by
the actual Kimi Code CLI; local captures do not establish Moonshot acceptance.
Self-contained output schemas advertise a content-bound `$id`, including their
declared dialect. SDK validators can reuse compiled schemas across complete
catalog refreshes and equivalent tool outputs; changing the schema changes its
identity. Explicit schema IDs and relative external reference bases are
preserved. This keeps every tool and validation rule in discovery and does not
change the SDK's catalog invalidation or availability checks.
`compare_web_captures` accepts exactly one of two input shapes: `compare_web_captures` accepts exactly one of two input shapes:
- Passive: `before` and `after` each contain `inspection`, the complete - Passive: `before` and `after` each contain `inspection`, the complete
@@ -94,6 +144,12 @@ if retention is unavailable or fails, the diagnostic reports that reason.
REA accepts ordinary `tools/call` progress tokens. Updates are monotonic, REA accepts ordinary `tools/call` progress tokens. Updates are monotonic,
rate-bounded to at most one intermediate update per 100 ms, and always allow a rate-bounded to at most one intermediate update per 100 ms, and always allow a
terminal update. Unknown totals are omitted; REA does not fabricate percentages. terminal update. Unknown totals are omitted; REA does not fabricate percentages.
`capture_process_scenario` uses that boundary only when the client supplies a
progress token. Notifications set `progress` to the count of collected terminal
frames, process samples, and interaction events, omit `total`, and put the
lifecycle phase, elapsed time, and final cleanup status in `message`. A client
that does not send a progress token receives no progress notifications. The
tool result is unchanged either way.
Provider calls receive the request cancellation signal. Artifact traversal, Provider calls receive the request cancellation signal. Artifact traversal,
hashing, version comparisons, Hopper requests, and process capture hashing, version comparisons, Hopper requests, and process capture
check the same signal. Cancellation is distinct from timeout. A cleanup failure check the same signal. Cancellation is distinct from timeout. A cleanup failure
@@ -186,10 +242,27 @@ the current client, so it is not a fresh-start recovery. On the same connection:
This close/reopen flow was exercised on one real Linux stdio connection after a This close/reopen flow was exercised on one real Linux stdio connection after a
controlled startup timeout, followed by a successful overview and function controlled startup timeout, followed by a successful overview and function
analysis. It is not a Windows/macOS coverage claim. REA cleans only resources it analysis. A separate Windows x64 stdio run with Ghidra 12.1.4, JDK 21 and Node
owns and never switches to another provider automatically. A provider timeout, 22.19.0 exercised the same connection and caller-selected native PE: the default
installation failure, or host permission denial needs its own reported recovery; 60-second client request timed out, close succeeded, and reopening before a
increasing a client deadline alone does not fix those failures. 240-second request completed the cold overview in about 163 seconds. The next
overview took about 19 ms. The cold and warm overviews reported two procedures
and two segments; the input digest was unchanged and no owned runtime root or
observed process-family member remained after final close. This was a small
1,024-byte fixture, so the longer request setting remains an example, not a
guarantee for larger targets. No macOS cold-start recovery was exercised.
With a supplied progress token, the Windows run received an operation-start
notification before timeout and start/completion notifications for the
successful retry. It received no intermediate import or auto-analysis progress.
These operation markers do not measure analysis work completed or extend the
client's deadline. Each new provider session still imports the target into a
fresh temporary project.
REA cleans only resources it owns and never switches to another provider
automatically. A provider timeout, installation failure, or host permission
denial needs its own reported recovery; increasing a client deadline alone does
not fix those failures.
## Tool results ## Tool results
@@ -198,7 +271,9 @@ Custom clients upgrading from 6.1 should follow the
Evidence-producing tools return the complete canonical Evidence record in both Evidence-producing tools return the complete canonical Evidence record in both
text and structured content. Read `structuredContent.normalized_result` for the text and structured content. Read `structuredContent.normalized_result` for the
operation result and `structuredContent.evidence_id` for its identity. The same operation result and `structuredContent.evidence_id` for its identity.
`raw_result` holds a provider representation distinct from that result, such
as an upstream tool's original report, and is `null` when there is none. The same
record is retained in the session bundle. `analysis_profile` is always present: record is retained in the session bundle. `analysis_profile` is always present:
a concrete profile object or `null`; either value participates in semantic a concrete profile object or `null`; either value participates in semantic
identity. Records omitting this field are rejected. Pass the returned Evidence identity. Records omitting this field are rejected. Pass the returned Evidence
@@ -235,7 +310,13 @@ Ordinary responses keep their existing complete result contract.
## Retained application Evidence inputs ## Retained application Evidence inputs
`inspect_analysis_view` projects a caller-selected view of already completed `inspect_analysis_view` projects a caller-selected view of already completed
`inspect_binary_layout` or `analyze_javascript_application` Evidence. Source is `inspect_binary_layout`, `analyze_javascript_application`, or `analyze_function`
Evidence. Native views select procedure, pseudocode, assembly, basic blocks,
comments, callers/callees, references, unresolved calls, referenced strings and
names, the native API record, and high-pcode facets without starting a provider. Native offset
and limit default to 0 and 64; pseudocode uses UTF-16 code units and never
splits surrogate pairs, while other pages count rows. Unavailable facts and
provider limitations remain explicit. Source is
an exact same-session retained reference or portable inline Evidence. Views are an exact same-session retained reference or portable inline Evidence. Views are
a summary, a layout mitigations or linkage facet, one section/symbol/module, or a summary, a layout mitigations or linkage facet, one section/symbol/module, or
a stable page with a caller-selected positive `limit`. Module pages include a stable page with a caller-selected positive `limit`. Module pages include
+1 -1
View File
@@ -379,7 +379,7 @@ the tool fails with a `debugger-attach-denied` reason.
Install a Ghidra 12.1.x release and the 64-bit full JDK it declares, then configure REA to Install a Ghidra 12.1.x release and the 64-bit full JDK it declares, then configure REA to
use them. Ghidra analysis supports Linux x64/arm64 and macOS x64/arm64; each installation requires use them. Ghidra analysis supports Linux x64/arm64 and macOS x64/arm64; each installation requires
the matching native decompiler. Experimental Windows x64 P0 admits native the matching native decompiler. Experimental Windows x64 P0 admits native
x86-64 PE applications on local NTFS using bundled Job Object ownership, x86 and x86-64 PE applications and DLLs on local NTFS using bundled Job Object ownership,
protected runtime DACLs, and handle-based path admission. See [Windows Ghidra P0](windows-ghidra-p0.md) and protected runtime DACLs, and handle-based path admission. See [Windows Ghidra P0](windows-ghidra-p0.md) and
[issue #527](https://github.com/morluto/rea/issues/527). [issue #527](https://github.com/morluto/rea/issues/527).
On Linux and macOS, `annotate_native_function` atomically edits a function name On Linux and macOS, `annotate_native_function` atomically edits a function name
+89 -2
View File
@@ -51,6 +51,34 @@ format is TOON. The file contains the complete capture Evidence record.
Choose an output file distinct from the scenario input: shell redirection opens Choose an output file distinct from the scenario input: shell redirection opens
and truncates the output before REA reads the scenario. and truncates the output before REA reads the scenario.
While the capture runs, REA writes live status to stderr as one JSON object per
line. Stdout stays the final Evidence document:
```json
{
"rea_progress": {
"phase": "running",
"completed": 3,
"total": null,
"message": "elapsed_ms=1200 frames=1 samples=2 interactions=0",
"sequence": 4
}
}
```
`phase` is the observed lifecycle stage: `prepare`, `running`, `settling`, or
`cleanup`. `completed` counts collected terminal frames, process samples, and
interaction events. `total` stays null; REA does not invent a unit total or a
percentage. `message` reports elapsed time and those counts. The final line uses
`phase` `cleanup`, sets `terminal` to true, and adds `disposition` (`exited`,
`timeout`, `idle_timeout`, `cancelled`, or `failed`) plus the owned-process,
renderer, and temporary-root cleanup states. Updates use the shared progress
boundary and are limited to one intermediate line per 100 ms; the terminal line
is always emitted. If a receiver is slow, intermediate observations are merged
into the latest status, and stale live observations are discarded before final
cleanup status. Progress never copies child output. Child output remains in the
capture Evidence on stdout.
For example: For example:
```json ```json
@@ -110,6 +138,23 @@ while comparisons and trace assertions use the normalized text. Retaining the
original adds at most `limits.output_bytes` of text, because it comes from the original adds at most `limits.output_bytes` of text, because it comes from the
same admitted PTY chunks rather than a second unbounded stream. same admitted PTY chunks rather than a second unbounded stream.
Rendered frame `lines` hold the visible rows after normalization, with trailing
U+0020 spaces removed. Blank rows and internal spaces remain. `columns` records
terminal cell width; normalization and Unicode can make string lengths differ
from that width. `serialized_state` holds terminal serialization after the
selected normalization. Original admitted PTY chunks are available through
`raw_data ?? data`, as described above. Captures commit this line format in their
comparison contract, so a capture whose lines were padded to full width does not
compare as the same contract.
Custom `normalization.patterns` replace literal text with the caller's literal
replacement, including dollar sequences such as `$&` and `$$`. An empty pattern
also matches the first and last string positions. Captures with custom patterns
commit `pattern_normalization_version: "literal-replacements-v1"`. Legacy captures
remain readable. Cross-version comparison is rejected; capture both sides with
the same replacement semantics. Captures without custom patterns keep their
existing comparison contract.
Port normalization recognizes explicit `port`, `tcp_port`, `udp_port`, and Port normalization recognizes explicit `port`, `tcp_port`, `udp_port`, and
`listen` fields, URL authorities, IP endpoints, and `localhost` endpoints, with `listen` fields, URL authorities, IP endpoints, and `localhost` endpoints, with
ports from 0 through 65535. A port may end a sentence, as in `port: 8080.`; ports from 0 through 65535. A port may end a sentence, as in `port: 8080.`;
@@ -159,8 +204,11 @@ Trace assertions use coverage for the sources they select, so an assertion
about complete raw terminal output need not fail because rendered snapshots about complete raw terminal output need not fail because rendered snapshots
were omitted. Captures missing `truncation_details` are rejected on import; were omitted. Captures missing `truncation_details` are rejected on import;
preserve them as historical files and recapture for current comparisons. preserve them as historical files and recapture for current comparisons.
These diagnostics use the existing scenario `limits.file_bytes` is already the cumulative whole-file hashing budget,
budgets; they do not introduce a separate file-hashing budget. independent of `limits.output_bytes`, which bounds retained terminal content.
Each filesystem checkpoint starts with a fresh `file_bytes` budget across all
selected roots; it is not a per-file allowance. File contents are read in
64 KiB chunks for hashing and are not embedded in capture Evidence.
When the host withholds an unrelated process’s ownership token, REA leaves that When the host withholds an unrelated process’s ownership token, REA leaves that
process untouched and records its PID and reason in `cleanup.unverified_processes` process untouched and records its PID and reason in `cleanup.unverified_processes`
@@ -176,6 +224,45 @@ as well. A newly started unreadable process can prevent verified cleanup even
after the selected command exits. REA preserves this uncertainty and leaves after the selected command exits. REA preserves this uncertainty and leaves
that process untouched. that process untouched.
## Hash a report independently of terminal output
Select the producer's report directory and give `file_bytes` enough room for
the complete files you need, including other files in those roots. For example,
save this scenario and run `rea capture-process ./scenario.json --json > capture.json`:
```json
{
"executable": "node",
"arguments": [
"-e",
"const fs=require('node:fs');fs.mkdirSync('reports',{recursive:true});fs.writeFileSync('reports/final.json',JSON.stringify({status:'complete',data:'X'.repeat(131072)}));console.log('x'.repeat(8192));"
],
"working_directory": ".",
"filesystem_observation_paths": ["./reports"],
"limits": { "output_bytes": 1024, "file_bytes": 262144 }
}
```
The report exceeds the terminal budget but fits the independent 256 KiB hash
budget. Its complete SHA-256 appears in `normalized_result.files_after` at
`root_0:final.json`; check `truncation_details.filesystem_after` for omissions
and actual bytes hashed. The verbose terminal output is truncated without
preventing the report digest. The same scenario object is the MCP
`capture_process_scenario` input. REA never returns a prefix hash as a full-file
digest: an insufficient remaining budget produces a null digest and an
explicit omission reason.
A digest binds the complete bytes observed at that checkpoint, not the
producer's notion of a finished report. A stable periodic or running report
can also have a complete digest. Choose the final file and verify the
producer's documented completion marker, exit outcome and settlement
observations before calling it final. `after_settlement` names a filesystem
checkpoint, not a guarantee that an application finished successfully or
that an unobserved writer will never change the file. REA checks opened-file
identity/state around hashing and reports observed changes or unavailable
files; cancellation stops the hash. If an external sealer produces a manifest,
keep its completion claims and digest separate from REA's checkpoint facts.
## Compare two captures ## Compare two captures
Compare saved capture Evidence with: Compare saved capture Evidence with:
+15
View File
@@ -88,6 +88,21 @@ entrypoint and include its verifier/fixture files in their cache inputs.
## End-to-end, integration and golden evidence ## End-to-end, integration and golden evidence
The optional `verify:qwen-client`, `verify:pi-client`, and `verify:hermes-client`
lanes require an installed native client. Select its executable with
`REA_VERIFY_QWEN_COMMAND`, `REA_VERIFY_PI_COMMAND`, or `REA_VERIFY_HERMES_COMMAND`.
They configure disposable profiles through the public REA CLI, preserve caller
settings and backups, and exercise real stdio MCP calls and skill loading. The
`call` mode checks complete catalog discovery and actual JavaScript Evidence;
pass `-- chat` to check plain chat with REA enabled. Qwen also reads the full
result that its client offloads, and Pi exercises default codemode execution.
Use `REA_VERIFY_RUNTIME_ROOT` to select a production-only installed REA package.
Receipts retain client versions, result digests, host coverage and owned-process
cleanup. These POSIX lanes use a deterministic loopback model, so they do not
prove live model-provider or native Windows compatibility. Qwen and Pi use
caller-configured skill directories to isolate the fixture account from the OS
home; this does not prove their default home-directory discovery.
Full E2E tests invoke the production command dispatcher and real providers, Full E2E tests invoke the production command dispatcher and real providers,
without fake launchers, runners or responses. `verify:keyed-archive` writes an without fake launchers, runners or responses. `verify:keyed-archive` writes an
actual Foundation binary archive, runs the CLI and a separate stdio MCP actual Foundation binary archive, runs the CLI and a separate stdio MCP
+13 -2
View File
@@ -17,7 +17,7 @@ additional permission flag or degraded mode is required.
- Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+; - Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+;
- an operator-installed Ghidra 12.1.x distribution (verified with 12.1.4); - an operator-installed Ghidra 12.1.x distribution (verified with 12.1.4);
- a 64-bit full JDK inside that installation's declared Java range (JDK 21 or newer, with no maximum, for current 12.1 releases); - a 64-bit full JDK inside that installation's declared Java range (JDK 21 or newer, with no maximum, for current 12.1 releases);
- an explicit native, non-managed, non-DLL x86 or x86-64 PE application; and - an explicit native, non-managed x86 or x86-64 PE application or DLL; and
- the 25 read-only Ghidra inventory, memory, and function-analysis operations. - the 25 read-only Ghidra inventory, memory, and function-analysis operations.
Loaded memory reads and file offsets preserve Ghidra's observed source mappings. Loaded memory reads and file offsets preserve Ghidra's observed source mappings.
@@ -38,6 +38,13 @@ REA selects its packaged bridge scripts by explicit path. Same-named files or
directories in the caller's working directory do not select a different directories in the caller's working directory do not select a different
script or require users to clean that directory before analysis. script or require users to clean that directory before analysis.
Native DLL analysis preserves the PE shared-library role in the analysis profile.
REA imports an immutable copy for static analysis; it does not load the library
into the analyst process or execute its entry routine. The source-owned DLL
fixtures have no entry routine or imports and expose two exported functions.
The same native snapshot, path-admission and cleanup requirements apply to EXEs
and DLLs.
## Configuration ## Configuration
```powershell ```powershell
@@ -100,7 +107,7 @@ and artifact SHA-256 before loading the package-owned addon. Native failure
reasons remain distinct from a valid installation and an unsupported target. reasons remain distinct from a valid installation and an unsupported target.
PE header classification and SHA-256 come from the same open file. The Windows PE header classification and SHA-256 come from the same open file. The Windows
provider admits native x86 and x86-64 PE applications and rejects unsupported roles, provider admits native x86 and x86-64 PE applications and DLLs and rejects unsupported roles,
architectures, managed images, and malformed headers. The original selected architectures, managed images, and malformed headers. The original selected
source coordinate is preserved for native admission. source coordinate is preserved for native admission.
@@ -159,7 +166,11 @@ artifact and manifest.
npm run verify:windows-native npm run verify:windows-native
npm run verify:ghidra:windows npm run verify:ghidra:windows
npm run verify:ghidra:windows -- --x86 npm run verify:ghidra:windows -- --x86
npm run verify:ghidra:windows -- --dll
npm run verify:ghidra:windows -- --x86 --dll
npm run verify:ghidra:windows:package -- --x86 npm run verify:ghidra:windows:package -- --x86
npm run verify:ghidra:windows:package -- --dll
npm run verify:ghidra:windows:package -- --x86 --dll
npm run verify:ghidra:windows:package -- C:\fixtures\installed-rea C:\fixtures\sample.exe npm run verify:ghidra:windows:package -- C:\fixtures\installed-rea C:\fixtures\sample.exe
``` ```
+2 -2
View File
@@ -41,6 +41,7 @@
"stream-json": "3.7.0", "stream-json": "3.7.0",
"write-file-atomic": "7.0.0", "write-file-atomic": "7.0.0",
"ws": "8.21.0", "ws": "8.21.0",
"yaml": "2.9.0",
"zod": "4.4.3" "zod": "4.4.3"
}, },
"bin": { "bin": {
@@ -64,8 +65,7 @@
"turbo": "2.11.7", "turbo": "2.11.7",
"typescript": "7.0.2", "typescript": "7.0.2",
"vitepress": "1.6.4", "vitepress": "1.6.4",
"vitest": "5.0.3", "vitest": "5.0.3"
"yaml": "2.9.0"
}, },
"engines": { "engines": {
"node": "^22.19.0 || ^24.11.0 || >=26.0.0" "node": "^22.19.0 || ^24.11.0 || >=26.0.0"
+6 -3
View File
@@ -179,6 +179,9 @@
"verify:readiness": "npm run build:cached && npm run verify:fixtures && node scripts/verify-reconstruction-readiness.mjs", "verify:readiness": "npm run build:cached && npm run verify:fixtures && node scripts/verify-reconstruction-readiness.mjs",
"verify:package": "npm run build:cached && node scripts/verify-package.mjs", "verify:package": "npm run build:cached && node scripts/verify-package.mjs",
"verify:package:windows": "npm run build:cached && node scripts/verify-windows-package.mjs", "verify:package:windows": "npm run build:cached && node scripts/verify-windows-package.mjs",
"verify:qwen-client": "npm run build:cached && node scripts/verify-qwen-client.mjs",
"verify:pi-client": "npm run build:cached && node scripts/verify-native-agent-client.mjs pi",
"verify:hermes-client": "npm run build:cached && node scripts/verify-native-agent-client.mjs hermes",
"probe:windows-capabilities": "npm run build:cached && node scripts/windows-capabilities.mjs", "probe:windows-capabilities": "npm run build:cached && node scripts/windows-capabilities.mjs",
"verify:published": "node scripts/verify-published-package.mjs", "verify:published": "node scripts/verify-published-package.mjs",
"config:print": "node scripts/print-mcp-config.mjs", "config:print": "node scripts/print-mcp-config.mjs",
@@ -269,7 +272,8 @@
"write-file-atomic": "7.0.0", "write-file-atomic": "7.0.0",
"ws": "8.21.0", "ws": "8.21.0",
"zod": "4.4.3", "zod": "4.4.3",
"evmole": "0.9.3" "evmole": "0.9.3",
"yaml": "2.9.0"
}, },
"overrides": { "overrides": {
"vitepress": { "vitepress": {
@@ -293,7 +297,6 @@
"turbo": "2.11.7", "turbo": "2.11.7",
"typescript": "7.0.2", "typescript": "7.0.2",
"vitepress": "1.6.4", "vitepress": "1.6.4",
"vitest": "5.0.3", "vitest": "5.0.3"
"yaml": "2.9.0"
} }
} }
+95 -60
View File
@@ -3,68 +3,103 @@ import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
// Source-owned static fixtures: no compiler, target loading or execution.
for (const architecture of ["x86_64", "x86"]) { for (const architecture of ["x86_64", "x86"]) {
const x86 = architecture === "x86"; for (const dll of [false, true]) {
const output = resolve( const x86 = architecture === "x86";
root, const output = resolve(
"build", root,
"fixtures", "build",
x86 ? "rea-ghidra-windows-x86.exe" : "rea-ghidra-windows.exe", "fixtures",
); `rea-ghidra-windows${x86 ? "-x86" : ""}.${dll ? "dll" : "exe"}`,
const bytes = Buffer.alloc(1024); );
const peOffset = 0x80; const bytes = Buffer.alloc(1024);
const optionalHeader = peOffset + 24; const peOffset = 0x80;
const optionalHeaderSize = x86 ? 0xe0 : 0xf0; const optionalHeader = peOffset + 24;
const sectionTable = optionalHeader + optionalHeaderSize; const optionalHeaderSize = x86 ? 0xe0 : 0xf0;
const sectionTable = optionalHeader + optionalHeaderSize;
const directories = optionalHeader + (x86 ? 96 : 112);
bytes.write("MZ", 0, "ascii"); bytes.write("MZ", 0, "ascii");
bytes.writeUInt32LE(peOffset, 0x3c); bytes.writeUInt32LE(peOffset, 0x3c);
bytes.write("PE\0\0", peOffset, "binary"); bytes.write("PE\0\0", peOffset, "binary");
bytes.writeUInt16LE(x86 ? 0x14c : 0x8664, peOffset + 4); bytes.writeUInt16LE(x86 ? 0x14c : 0x8664, peOffset + 4);
bytes.writeUInt16LE(1, peOffset + 6); bytes.writeUInt16LE(1, peOffset + 6);
bytes.writeUInt16LE(optionalHeaderSize, peOffset + 20); bytes.writeUInt16LE(optionalHeaderSize, peOffset + 20);
bytes.writeUInt16LE(x86 ? 0x102 : 0x22, peOffset + 22); bytes.writeUInt16LE(
(x86 ? 0x102 : 0x22) | (dll ? 0x2000 : 0),
peOffset + 22,
);
bytes.writeUInt16LE(x86 ? 0x10b : 0x20b, optionalHeader); bytes.writeUInt16LE(x86 ? 0x10b : 0x20b, optionalHeader);
bytes.writeUInt32LE(0x200, optionalHeader + 4); bytes.writeUInt32LE(0x200, optionalHeader + 4);
bytes.writeUInt32LE(0x1000, optionalHeader + 16); bytes.writeUInt32LE(dll ? 0 : 0x1000, optionalHeader + 16);
bytes.writeUInt32LE(0x1000, optionalHeader + 20); bytes.writeUInt32LE(0x1000, optionalHeader + 20);
if (x86) bytes.writeUInt32LE(0x400000, optionalHeader + 28); if (x86) bytes.writeUInt32LE(0x400000, optionalHeader + 28);
else bytes.writeBigUInt64LE(0x1_4000_0000n, optionalHeader + 24); else bytes.writeBigUInt64LE(0x1_4000_0000n, optionalHeader + 24);
bytes.writeUInt32LE(0x1000, optionalHeader + 32); bytes.writeUInt32LE(0x1000, optionalHeader + 32);
bytes.writeUInt32LE(0x200, optionalHeader + 36); bytes.writeUInt32LE(0x200, optionalHeader + 36);
bytes.writeUInt16LE(6, optionalHeader + 40); bytes.writeUInt16LE(6, optionalHeader + 40);
bytes.writeUInt16LE(6, optionalHeader + 48); bytes.writeUInt16LE(6, optionalHeader + 48);
bytes.writeUInt32LE(0x2000, optionalHeader + 56); bytes.writeUInt32LE(0x2000, optionalHeader + 56);
bytes.writeUInt32LE(0x200, optionalHeader + 60); bytes.writeUInt32LE(0x200, optionalHeader + 60);
bytes.writeUInt16LE(3, optionalHeader + 68); bytes.writeUInt16LE(3, optionalHeader + 68);
bytes.writeUInt16LE(x86 ? 0x8140 : 0x8160, optionalHeader + 70); bytes.writeUInt16LE(x86 ? 0x8140 : 0x8160, optionalHeader + 70);
if (x86) { if (x86) {
bytes.writeUInt32LE(0x10_0000, optionalHeader + 72); bytes.writeUInt32LE(0x10_0000, optionalHeader + 72);
bytes.writeUInt32LE(0x1000, optionalHeader + 76); bytes.writeUInt32LE(0x1000, optionalHeader + 76);
bytes.writeUInt32LE(0x10_0000, optionalHeader + 80); bytes.writeUInt32LE(0x10_0000, optionalHeader + 80);
bytes.writeUInt32LE(0x1000, optionalHeader + 84); bytes.writeUInt32LE(0x1000, optionalHeader + 84);
bytes.writeUInt32LE(16, optionalHeader + 92); bytes.writeUInt32LE(16, optionalHeader + 92);
} else { } else {
bytes.writeBigUInt64LE(0x10_0000n, optionalHeader + 72); bytes.writeBigUInt64LE(0x10_0000n, optionalHeader + 72);
bytes.writeBigUInt64LE(0x1000n, optionalHeader + 80); bytes.writeBigUInt64LE(0x1000n, optionalHeader + 80);
bytes.writeBigUInt64LE(0x10_0000n, optionalHeader + 88); bytes.writeBigUInt64LE(0x10_0000n, optionalHeader + 88);
bytes.writeBigUInt64LE(0x1000n, optionalHeader + 96); bytes.writeBigUInt64LE(0x1000n, optionalHeader + 96);
bytes.writeUInt32LE(16, optionalHeader + 108); bytes.writeUInt32LE(16, optionalHeader + 108);
}
bytes.write(".text", sectionTable, "ascii");
bytes.writeUInt32LE(dll ? 0x180 : 0x16, sectionTable + 8);
bytes.writeUInt32LE(0x1000, sectionTable + 12);
bytes.writeUInt32LE(0x200, sectionTable + 16);
bytes.writeUInt32LE(0x200, sectionTable + 20);
bytes.writeUInt32LE(0x6000_0020, sectionTable + 36);
// EXE entry returns zero. DLL caller adds one to the callee's 42.
bytes.set(
dll
? [0xe8, 0x0b, 0, 0, 0, 0x83, 0xc0, 1, 0xc3]
: [0xe8, 0x0b, 0, 0, 0, 0x31, 0xc0, 0xc3],
0x200,
);
bytes.set([0xb8, 0x2a, 0, 0, 0, 0xc3], 0x210);
if (dll) {
// Export both functions; an entry-point-free library is discovered by exports.
bytes.writeUInt32LE(0x1040, directories);
bytes.writeUInt32LE(0x130, directories + 4);
const exports = 0x240;
bytes.writeUInt32LE(0x1100, exports + 12);
bytes.writeUInt32LE(1, exports + 16);
bytes.writeUInt32LE(2, exports + 20);
bytes.writeUInt32LE(2, exports + 24);
bytes.writeUInt32LE(0x1080, exports + 28);
bytes.writeUInt32LE(0x1088, exports + 32);
bytes.writeUInt32LE(0x1090, exports + 36);
bytes.writeUInt32LE(0x1000, 0x280);
bytes.writeUInt32LE(0x1010, 0x284);
bytes.writeUInt32LE(0x1120, 0x288);
bytes.writeUInt32LE(0x1140, 0x28c);
bytes.writeUInt16LE(0, 0x290);
bytes.writeUInt16LE(1, 0x292);
bytes.write("rea-fixture.dll\0", 0x300, "binary");
bytes.write("fixture_caller\0", 0x320, "binary");
bytes.write("fixture_return42\0", 0x340, "binary");
}
await mkdir(dirname(output), { recursive: true });
await writeFile(output, bytes);
if (!x86 && !dll) process.stdout.write(`${output}\n`);
} }
bytes.write(".text", sectionTable, "ascii");
bytes.writeUInt32LE(0x16, sectionTable + 8);
bytes.writeUInt32LE(0x1000, sectionTable + 12);
bytes.writeUInt32LE(0x200, sectionTable + 16);
bytes.writeUInt32LE(0x200, sectionTable + 20);
bytes.writeUInt32LE(0x6000_0020, sectionTable + 36);
// entry calls the second function, returns zero; second returns 42.
bytes.set([0xe8, 0x0b, 0, 0, 0, 0x31, 0xc0, 0xc3], 0x200);
bytes.set([0xb8, 0x2a, 0, 0, 0, 0xc3], 0x210);
await mkdir(dirname(output), { recursive: true });
await writeFile(output, bytes);
if (!x86) process.stdout.write(`${output}\n`);
} }
+1 -1
View File
@@ -17,7 +17,7 @@ const lock = JSON.parse(
await readFile(join(root, "package-lock.json"), "utf8"), await readFile(join(root, "package-lock.json"), "utf8"),
); );
const skill = await readFile( const skill = await readFile(
join(root, "skill-src/reverse-engineer-anything/SKILL.md"), join(root, ".agents/skills/reverse-engineer-anything/SKILL.md"),
"utf8", "utf8",
); );
const skillVersion = /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(skill)?.[1]; const skillVersion = /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(skill)?.[1];
+2 -2
View File
@@ -10,7 +10,7 @@ for (const argument of arguments_)
throw new Error(`Unknown skill metadata option: ${argument}`); throw new Error(`Unknown skill metadata option: ${argument}`);
const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const sourceRoot = join(root, "skill-src/reverse-engineer-anything"); const sourceRoot = join(root, ".agents/skills/reverse-engineer-anything");
const outputRoot = join(root, "skills/reverse-engineer-anything"); const outputRoot = join(root, "skills/reverse-engineer-anything");
const check = arguments_.has("--check"); const check = arguments_.has("--check");
const current = ( const current = (
@@ -25,7 +25,7 @@ const { PRODUCT_IDENTITY } = await import(
); );
if (/^\s{2}(?:tool_count|catalog_digest):/mu.test(current)) if (/^\s{2}(?:tool_count|catalog_digest):/mu.test(current))
throw new Error( throw new Error(
"Catalog metadata belongs in the generated skill, not skill-src", "Catalog metadata belongs in the generated skill, not the authored source",
); );
const versionLine = /^ {2}version: "[^"\r\n]+"$/mu; const versionLine = /^ {2}version: "[^"\r\n]+"$/mu;
if (!versionLine.test(current)) if (!versionLine.test(current))
+1 -1
View File
@@ -1283,7 +1283,7 @@ async function inspect(
evidence.provider.version, evidence.provider.version,
"pwntools@4.15.0;pyelftools@0.33;unicorn@2.1.2", "pwntools@4.15.0;pyelftools@0.33;unicorn@2.1.2",
); );
assert.deepEqual(evidence.normalized_result, evidence.raw_result); assert.equal(evidence.raw_result, null);
assert.equal(evidence.normalized_result.diagnostics.truncated, false); assert.equal(evidence.normalized_result.diagnostics.truncated, false);
return evidence.normalized_result; return evidence.normalized_result;
} }
+456
View File
@@ -0,0 +1,456 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import {
TOOL_CONTRACTS,
toolContract,
} from "../dist/contracts/toolContracts.js";
import { exec } from "./lib/verify-package-core.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
import { createOpenAiModelFixture } from "./verify/clients/model-fixture.mjs";
const client = process.argv[2];
const mode = process.argv[3] ?? "call";
assert(["pi", "hermes"].includes(client), "Select pi or hermes.");
assert(["chat", "call"].includes(mode), "Select chat or call.");
assert(
process.platform !== "win32",
"This real-client lane requires POSIX; native Windows is unverified.",
);
const repo = fileURLToPath(new URL("..", import.meta.url));
const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo;
const command =
process.env[`REA_VERIFY_${client.toUpperCase()}_COMMAND`] ?? client;
const lab = await mkdtemp(join(tmpdir(), `rea-${client}-client-`));
const account = join(lab, "account");
const profile = join(lab, "selected profile");
const workspace = join(lab, "workspace");
const target = join(workspace, "fixture α测试");
const artifactPath = join(workspace, "evidence.json");
const skillPath = join(
client === "pi" ? join(account, ".agents") : profile,
"skills",
"reverse-engineer-anything",
"SKILL.md",
);
const verifier = createVerifierRun();
await Promise.all([
mkdir(account),
mkdir(profile),
mkdir(target, { recursive: true }),
]);
await writeFile(
join(target, "app.js"),
'export function result() { return { clientCompatibility: "REA_CLIENT_FIXTURE", count: 7 }; }\n',
);
const environment = {
PATH: process.env.PATH ?? "",
USERPROFILE: account,
[client === "pi" ? "PI_CODING_AGENT_DIR" : "HERMES_HOME"]: profile,
PI_OFFLINE: "1",
REA_PROCESS_RUN_ID: verifier.run_id,
};
const configPath = join(profile, client === "pi" ? "mcp.json" : "config.yaml");
const original =
client === "pi"
? JSON.stringify({
mcpServers: { other: { command: "unrelated-server", enabled: false } },
})
: "# Keep caller settings.\nmcp_servers:\n other:\n command: unrelated-server\n enabled: false\n";
await writeFile(configPath, original);
if (client === "pi") {
// The CLI fixture account and native os.homedir() differ; use Pi's caller-owned skill setting.
await writeFile(
join(profile, "settings.json"),
JSON.stringify({ skills: [join(account, ".agents", "skills")] }),
);
}
const clientVersion = (
await exec(command, ["--version"], { env: environment, timeout: 30_000 })
).stdout.trim();
for (const args of [
["setup", "--client", client, "--dry-run"],
["setup", "--client", client, "--yes"],
["doctor", "--client", client, "--skill"],
]) {
const result = await exec(
process.execPath,
[join(runtimeRoot, "scripts/rea.mjs"), ...args, "--json"],
{ env: environment, cwd: workspace, timeout: 60_000 },
);
const parsed = JSON.parse(result.stdout);
if (args[0] === "doctor") assert.equal(parsed.healthy, true);
else
assert.equal(
parsed.status,
args.includes("--dry-run") ? "planned" : "ready",
);
await writeFile(
join(lab, `${args[0]}${args.includes("--dry-run") ? "-plan" : ""}.json`),
result.stdout,
);
}
assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original);
const installedConfiguration = await readFile(configPath, "utf8");
assert(installedConfiguration.includes("unrelated-server"));
if (client === "hermes")
assert(installedConfiguration.includes("# Keep caller settings."));
const repeated = await exec(
process.execPath,
[
join(runtimeRoot, "scripts/rea.mjs"),
"setup",
"--client",
client,
"--yes",
"--json",
],
{ env: environment, cwd: workspace, timeout: 60_000 },
);
assert.deepEqual(JSON.parse(repeated.stdout).appliedActions, []);
assert.equal(await readFile(configPath, "utf8"), installedConfiguration);
assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original);
assert(
(await readFile(skillPath, "utf8")).includes(
"name: reverse-engineer-anything",
),
);
let evidenceSeen = false;
let skillLoaded = false;
let skillRequested = false;
let callRequested = false;
let searchRequested = false;
let describeRequested = false;
let finalSeen = false;
let catalog;
let artifactDigest;
const extractEvidence = (value) => {
if (typeof value === "string") {
if (
value.startsWith("<untrusted_tool_result ") &&
value.endsWith("</untrusted_tool_result>")
) {
// Hermes wraps external MCP content with its own instruction banner.
// Decode only that observed envelope, retaining the original request artifact.
const start = value.indexOf("\n\n");
if (start >= 0)
return extractEvidence(
value
.slice(start + 2, value.lastIndexOf("</untrusted_tool_result>"))
.trim(),
);
}
try {
return extractEvidence(JSON.parse(value));
} catch {
return undefined;
}
}
if (value && typeof value === "object") {
if (
typeof value.evidence_id === "string" &&
value.normalized_result?.graph?.nodes
)
return value;
for (const child of Object.values(value)) {
const found = extractEvidence(child);
if (found) return found;
}
}
};
const validateEvidence = async (value) => {
const evidence = toolContract(
"analyze_javascript_application",
).outputSchema.parse(value);
assert.equal(evidence.subject.local_path, target);
const shape = evidence.normalized_result.graph.nodes
.flatMap((n) => n.observations)
.map((o) => o.properties)
.find(
(p) =>
p.semantic_role === "export-return-shapes" &&
p.exported_name === "result",
);
assert(shape?.return_shape_coverage?.projection_complete);
const fields = shape.static_return_shapes.flatMap((s) => s.fields);
assert(
fields.some(
(f) =>
f.path === "/clientCompatibility" &&
f.state === "literal" &&
f.value === "REA_CLIENT_FIXTURE",
),
);
assert(
fields.some(
(f) => f.path === "/count" && f.state === "literal" && f.value === 7,
),
);
const serialized = JSON.stringify(evidence);
await writeFile(join(lab, "evidence.json"), serialized);
artifactDigest = createHash("sha256").update(serialized).digest("hex");
evidenceSeen = true;
};
const fixture = await createOpenAiModelFixture({
directory: lab,
prefix: `${client}-${mode}`,
onRequest: async ({ body, tools, requestIndex }) => {
assert(
requestIndex <= 6,
"Client did not complete the bounded fixture workflow.",
);
const messages = body.messages ?? [];
const toolMessages = messages.filter((m) => m.role === "tool");
const serializedTools = JSON.stringify(toolMessages);
const call = (name, args) => ({
role: "assistant",
content: null,
tool_calls: [
{
id: `call_rea_${requestIndex}`,
type: "function",
function: { name, arguments: JSON.stringify(args) },
},
],
});
if (
client === "pi" &&
callRequested &&
serializedTools.includes("REA_CLIENT_FIXTURE")
) {
const fact = toolMessages
.flatMap((m) => {
const text =
typeof m.content === "string"
? m.content
: JSON.stringify(m.content);
const marker = text.indexOf('{"REA_CLIENT_FIXTURE":true');
if (marker < 0) return [];
const end = text.indexOf("}\n", marker);
try {
return [
JSON.parse(text.slice(marker, end < 0 ? undefined : end + 1)),
];
} catch {
return [];
}
})
.find((v) => Array.isArray(v.catalog));
assert(
fact,
"Pi codemode must return its actual catalog and skill-read receipt.",
);
catalog = fact.catalog;
skillLoaded = fact.skill_loaded === true;
await validateEvidence(JSON.parse(await readFile(artifactPath, "utf8")));
} else if (client === "hermes") {
const search = tools.find((t) => t.name === "tool_search");
const exposed = tools
.filter((t) => t.name.startsWith("mcp__rea__"))
.map((t) => t.name.slice(10));
const deferred = [
...(search?.description ?? "").matchAll(/^- mcp__rea__(\w+):/gmu),
].map((m) => m[1]);
const native = [...new Set([...exposed, ...deferred])];
// Hermes advertises MCP prompt utilities beside server tools.
if (native.length)
catalog = native.filter(
(name) => !["get_prompt", "list_prompts"].includes(name),
);
skillLoaded ||=
serializedTools.includes(skillPath) &&
serializedTools.includes("reverse-engineer-anything");
for (const message of toolMessages) {
const evidence = extractEvidence(message.content);
if (evidence) await validateEvidence(evidence);
}
}
if (mode === "chat" || evidenceSeen) {
finalSeen = true;
return { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" };
}
if (client === "pi") {
const tool = tools.find((t) => t.name === "codemode");
assert(
tool,
`Native Pi codemode unavailable: ${tools.map((t) => t.name).join(", ")}`,
);
assert(
JSON.stringify(messages).includes(skillPath),
"Pi must advertise the installed REA skill.",
);
callRequested = true;
return call(tool.name, {
code: `await describeNamespace("rea"); const skill = await tools.read({path:${JSON.stringify(skillPath)}}); const result = await tools.mcp__rea__analyze_javascript_application({input_path:${JSON.stringify(target)},format:"directory"}); if(result.isError) throw new Error(JSON.stringify(result)); const evidence = result.structuredContent ?? JSON.parse(result.content.find(c=>c.type==="text").text); await tools.write({path:${JSON.stringify(artifactPath)},content:JSON.stringify(evidence)}); text({REA_CLIENT_FIXTURE:true,skill_loaded:typeof skill==="string"&&skill.includes("name: reverse-engineer-anything"),catalog:ALL_TOOLS.filter(t=>t.name.startsWith("mcp__rea__")).map(t=>t.name.slice(10))});`,
});
}
if (!skillRequested) {
const tool = tools.find((t) => t.name === "skill_view");
assert(tool, "Hermes skill_view must be available.");
skillRequested = true;
return call(tool.name, { name: "reverse-engineer-anything" });
}
if (!searchRequested) {
const tool = tools.find((t) => t.name === "tool_search");
assert(tool, "Hermes native deferred tool_search must be available.");
searchRequested = true;
return call(tool.name, { queries: ["analyze_javascript_application"] });
}
assert(
serializedTools.includes("mcp__rea__analyze_javascript_application"),
"Hermes must discover the analysis tool.",
);
if (!describeRequested) {
const tool = tools.find((t) => t.name === "tool_describe");
assert(tool, "Hermes must load the actual parameter schema.");
describeRequested = true;
return call(tool.name, {
names: ["mcp__rea__analyze_javascript_application"],
});
}
const tool = tools.find((t) => t.name === "tool_call");
assert(tool, "Hermes native deferred tool_call must be available.");
callRequested = true;
return call(tool.name, {
calls: [
{
name: "mcp__rea__analyze_javascript_application",
arguments: { input_path: target, format: "directory" },
},
],
});
},
});
if (client === "pi") {
await writeFile(
join(profile, "models.json"),
JSON.stringify({
providers: {
"rea-fixture": {
baseUrl: fixture.baseUrl,
api: "openai-completions",
apiKey: "local-fixture-key",
models: [
{
id: "rea-client-fixture",
reasoning: false,
contextWindow: 200000,
maxTokens: 4096,
},
],
},
},
}),
);
} else {
const configured = await readFile(configPath, "utf8");
await writeFile(
configPath,
configured +
`\nmodel:\n default: rea-client-fixture\n provider: custom\n base_url: ${fixture.baseUrl}\nagent:\n max_turns: 6\n`,
);
}
const args =
client === "pi"
? [
"--offline",
"--provider",
"rea-fixture",
"--model",
"rea-client-fixture",
"--no-session",
"--no-context-files",
"--mode",
"json",
"-p",
mode === "chat"
? "Say hello briefly."
: `Use REA to analyze ${target}.`,
]
: [
"--oneshot",
mode === "chat"
? "Say hello briefly."
: `Use REA to analyze ${target}.`,
"--provider",
"custom",
"--model",
"rea-client-fixture",
"--reasoning",
"none",
];
let failure;
try {
const execution = exec(command, args, {
env: {
...environment,
OPENAI_API_KEY: "local-fixture-key",
OPENAI_BASE_URL: fixture.baseUrl,
},
cwd: workspace,
timeout: 120_000,
maxBuffer: 10 * 1024 * 1024,
});
// Pi consumes piped input before its first prompt; an unused open pipe is not EOF.
execution.child.stdin.end();
const result = await execution;
await writeFile(join(lab, "client.stdout"), result.stdout);
await writeFile(join(lab, "client.stderr"), result.stderr);
assert.equal(fixture.failure, undefined);
assert(finalSeen);
assert(result.stdout.includes("REA_CLIENT_COMPATIBILITY_OK"));
if (mode === "call") {
assert(evidenceSeen);
assert(
skillLoaded,
"Native client must actually read the installed skill.",
);
assert.deepEqual(
catalog?.sort(),
TOOL_CONTRACTS.map((t) => t.name).sort(),
"The complete REA catalog must survive native client discovery.",
);
}
} catch (cause) {
failure = cause;
if (typeof cause?.stdout === "string")
await writeFile(join(lab, "client.stdout"), cause.stdout);
if (typeof cause?.stderr === "string")
await writeFile(join(lab, "client.stderr"), cause.stderr);
} finally {
await fixture.close();
const receipt = {
client,
clientVersion,
mode,
passed: failure === undefined,
artifacts: lab,
runtimeRoot,
host: {
platform: process.platform,
architecture: process.arch,
node: process.versions.node,
},
modelFixture:
"loopback deterministic OpenAI-compatible endpoint; no live provider claim",
skillDiscovery:
client === "pi"
? "caller-configured isolated shared directory; default OS home discovery unverified"
: "native HERMES_HOME personal directory",
catalogSize: catalog?.length,
evidenceSeen,
skillLoaded,
artifactDigest,
finalSeen,
requests: fixture.requests,
probes: fixture.probes,
fixtureFailure: fixture.failure,
verifier: await completeVerifierRun(verifier),
};
await writeFile(join(lab, "receipt.json"), JSON.stringify(receipt, null, 2));
console.log(JSON.stringify(receipt));
}
if (failure !== undefined) throw failure;
+5
View File
@@ -85,7 +85,12 @@ export async function verifyPackageEnvironment({
PI_CODING_AGENT_DIR: undefined, PI_CODING_AGENT_DIR: undefined,
PI_CONFIG_DIR: undefined, PI_CONFIG_DIR: undefined,
PI_PROFILE: undefined, PI_PROFILE: undefined,
QWEN_HOME: undefined,
OPENCODE_CONFIG: undefined, OPENCODE_CONFIG: undefined,
OPENCODE_CONFIG_DIR: undefined,
GEMINI_CLI_HOME: undefined,
GEMINI_CLI_SYSTEM_SETTINGS_PATH: undefined,
GEMINI_CLI_SYSTEM_DEFAULTS_PATH: undefined,
XDG_CONFIG_HOME: join(home, ".config"), XDG_CONFIG_HOME: join(home, ".config"),
PATH: `${fakeBin}:${process.env.PATH ?? ""}`, PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
REA_ANALYSIS_PROVIDER: "auto", REA_ANALYSIS_PROVIDER: "auto",
+10 -1
View File
@@ -13,7 +13,16 @@ export async function verifyPackageInstall({
}) { }) {
await exec( await exec(
"npm", "npm",
["install", "--global", "--ignore-scripts", "--prefix", prefix, tarball], [
"install",
"--global",
"--ignore-scripts",
"--omit=dev",
"--install-strategy=nested",
"--prefix",
prefix,
tarball,
],
{ env: environment }, { env: environment },
); );
const cli = join(prefix, "bin", "rea"); const cli = join(prefix, "bin", "rea");
+1 -1
View File
@@ -38,7 +38,7 @@ export async function verifyPackagePack({ root, workspace }) {
throw new Error("package did not expose both rea command entry points"); throw new Error("package did not expose both rea command entry points");
if (packedManifest.mcpName !== "io.github.morluto/rea") if (packedManifest.mcpName !== "io.github.morluto/rea")
throw new Error("package did not retain its MCP Registry ownership marker"); throw new Error("package did not retain its MCP Registry ownership marker");
if (packedFiles.some((path) => path.startsWith("package/skill-src/"))) if (packedFiles.some((path) => path.startsWith("package/.agents/")))
throw new Error( throw new Error(
"package included authored skill sources instead of only the generated bundle", "package included authored skill sources instead of only the generated bundle",
); );
+4
View File
@@ -17,6 +17,7 @@ import { verifyPackageMcp } from "./verify-package-mcp.mjs";
import { verifyPackagePack } from "./verify-package-pack.mjs"; import { verifyPackagePack } from "./verify-package-pack.mjs";
import { verifyPackagePlatform } from "./verify-package-platform.mjs"; import { verifyPackagePlatform } from "./verify-package-platform.mjs";
import { verifyPackageSetup } from "./verify-package-setup.mjs"; import { verifyPackageSetup } from "./verify-package-setup.mjs";
import { verifyRepositorySkill } from "./verify-repository-skill.mjs";
import { verifyManaged } from "./verify-package-managed.mjs"; import { verifyManaged } from "./verify-package-managed.mjs";
import { verifyUnknownProvider } from "./verify-package-unknown-provider.mjs"; import { verifyUnknownProvider } from "./verify-package-unknown-provider.mjs";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs"; import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
@@ -34,9 +35,11 @@ let tarball;
let mcpStartup; let mcpStartup;
let mcpModuleLoading; let mcpModuleLoading;
let update; let update;
let repositorySkill;
let report; let report;
try { try {
repositorySkill = await verifyRepositorySkill({ root, workspace });
({ tarball } = await verifyPackagePack({ root, workspace })); ({ tarball } = await verifyPackagePack({ root, workspace }));
const environmentData = await verifyPackageEnvironment({ const environmentData = await verifyPackageEnvironment({
root, root,
@@ -156,6 +159,7 @@ try {
configSymlinkLifecycle: supportedSetupHost, configSymlinkLifecycle: supportedSetupHost,
skill: supportedSetupHost, skill: supportedSetupHost,
skillReferences: supportedSetupHost, skillReferences: supportedSetupHost,
repositorySkill,
mcpTools: TOOL_CONTRACTS.length, mcpTools: TOOL_CONTRACTS.length,
mcpPrompts: prompts.names.length, mcpPrompts: prompts.names.length,
promptCompletion: true, promptCompletion: true,
+102 -21
View File
@@ -15,7 +15,7 @@ import {
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import { Client } from "@modelcontextprotocol/client"; import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
@@ -24,10 +24,50 @@ if (process.platform !== "win32" || process.arch !== "x64")
throw new Error( throw new Error(
"Packaged Windows Ghidra acceptance requires a Windows x64 host.", "Packaged Windows Ghidra acceptance requires a Windows x64 host.",
); );
const workspace = await mkdtemp(join(tmpdir(), "rea-packaged-ghidra-"));
const exec = promisify(execFile); const exec = promisify(execFile);
const x86Fixture = process.argv[2] === "--x86"; const arguments_ = process.argv.slice(2);
const packageRootArgument = x86Fixture ? undefined : process.argv[2]; const internalWorkspace = arguments_[0] === "--internal-workspace";
const fixtureArguments = internalWorkspace ? arguments_.slice(2) : arguments_;
const x86Fixture = fixtureArguments.includes("--x86");
const dllFixture = fixtureArguments.includes("--dll");
const [packageRootArgument, targetArgument, reportArgument] =
fixtureArguments.filter(
(argument) => argument !== "--x86" && argument !== "--dll",
);
// Windows locks the loaded package-owned addon until the verifier exits.
// Keep installation cleanup in a parent that never loads that addon.
if (!internalWorkspace) {
const ownedWorkspace = await mkdtemp(join(tmpdir(), "rea-packaged-ghidra-"));
let result;
try {
result = await exec(
process.execPath,
[
fileURLToPath(import.meta.url),
"--internal-workspace",
ownedWorkspace,
...arguments_,
],
{ timeout: 900_000, maxBuffer: 8 * 1024 * 1024 },
);
} catch (cause) {
if (typeof cause.stdout === "string") process.stdout.write(cause.stdout);
if (typeof cause.stderr === "string") process.stderr.write(cause.stderr);
throw cause;
} finally {
await rm(ownedWorkspace, { recursive: true, force: true });
}
const report = JSON.parse(result.stdout);
report.workspaceCleanup = true;
report.ok = true;
if (reportArgument !== undefined)
await writeFile(reportArgument, `${JSON.stringify(report)}\n`);
process.stdout.write(`${JSON.stringify(report)}\n`);
process.stderr.write(result.stderr);
process.exit(0);
}
assert.ok(arguments_[1] !== undefined, "Missing verifier workspace.");
const workspace = resolve(arguments_[1]);
let packageRoot = resolve(packageRootArgument ?? "."); let packageRoot = resolve(packageRootArgument ?? ".");
// The default lane verifies the npm artifact in an isolated prefix. Explicit // The default lane verifies the npm artifact in an isolated prefix. Explicit
// package roots support an already-installed artifact without a second install. // package roots support an already-installed artifact without a second install.
@@ -75,17 +115,17 @@ if (packageRootArgument === undefined) {
); );
packageRoot = join(prefix, "node_modules", "rea-agents"); packageRoot = join(prefix, "node_modules", "rea-agents");
} catch (cause) { } catch (cause) {
await rm(workspace, { recursive: true, force: true }); throw new Error("Packaged Windows verifier installation failed.", {
throw cause; cause,
});
} }
} }
const target = resolve( const target = resolve(
process.argv[3] ?? targetArgument ??
(x86Fixture `build/fixtures/rea-ghidra-windows${x86Fixture ? "-x86" : ""}.${dllFixture ? "dll" : "exe"}`,
? "build/fixtures/rea-ghidra-windows-x86.exe"
: "build/fixtures/rea-ghidra-windows.exe"),
); );
let TOOL_CONTRACTS, native, token, sha256; let TOOL_CONTRACTS, native, token, sha256;
let parsedTarget;
try { try {
({ TOOL_CONTRACTS } = await import( ({ TOOL_CONTRACTS } = await import(
pathToFileURL(join(packageRoot, "dist/contracts/toolContracts.js")) pathToFileURL(join(packageRoot, "dist/contracts/toolContracts.js"))
@@ -107,9 +147,25 @@ try {
); );
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
sha256 = digest(await readFile(target)); sha256 = digest(await readFile(target));
const { parseBinaryTarget } = await import(
pathToFileURL(join(packageRoot, "dist/application/BinaryTargetResolver.js"))
);
parsedTarget = await parseBinaryTarget(target);
assert.equal(parsedTarget.ok, true);
assert.equal(parsedTarget.value.format, "pe");
assert.equal(parsedTarget.value.managed, false);
if (targetArgument === undefined) {
assert.equal(
parsedTarget.value.architecture,
x86Fixture ? "x86" : "x86_64",
);
assert.equal(
parsedTarget.value.executableRole,
dllFixture ? "shared-library" : "application",
);
}
} catch (cause) { } catch (cause) {
await rm(workspace, { recursive: true, force: true }); throw new Error("Packaged Windows verifier prerequisites failed.", { cause });
throw cause;
} }
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
const runtimeParent = join(workspace, "runtime with spaces"); const runtimeParent = join(workspace, "runtime with spaces");
@@ -145,6 +201,8 @@ const report = {
architecture: "x64", architecture: "x64",
nonAdmin: true, nonAdmin: true,
targetSha256: sha256, targetSha256: sha256,
targetArchitecture: parsedTarget.value.architecture,
targetRole: parsedTarget.value.executableRole,
cli: {}, cli: {},
mcpOperations: [], mcpOperations: [],
nativeControls: "available", nativeControls: "available",
@@ -208,8 +266,8 @@ try {
}); });
const call = async (name, arguments_) => { const call = async (name, arguments_) => {
report.lastOperation = name; report.lastOperation = name;
if (process.argv[4] !== undefined) if (reportArgument !== undefined)
await writeFile(process.argv[4], `${JSON.stringify(report)}\n`); await writeFile(reportArgument, `${JSON.stringify(report)}\n`);
const contract = TOOL_CONTRACTS.find((value) => value.name === name); const contract = TOOL_CONTRACTS.find((value) => value.name === name);
assert.ok(contract !== undefined, `Missing canonical contract: ${name}`); assert.ok(contract !== undefined, `Missing canonical contract: ${name}`);
const argumentsParsed = contract.inputSchema.parse(arguments_); const argumentsParsed = contract.inputSchema.parse(arguments_);
@@ -230,6 +288,25 @@ try {
await call("list_names", {}); await call("list_names", {});
const procedures = await call("list_procedures", {}); const procedures = await call("list_procedures", {});
assert.ok(procedures.length > 0); assert.ok(procedures.length > 0);
if (targetArgument === undefined) {
const calleeAddress = x86Fixture ? "0x401010" : "0x140001010";
const calleeCode = await call("procedure_pseudo_code", {
procedure: calleeAddress,
});
assert.match(calleeCode, /\b(?:42|0x2a)\b/iu);
if (dllFixture) {
const callerAddress = x86Fixture ? "0x401000" : "0x140001000";
assert.ok(
(
await call("procedure_callees", { procedure: callerAddress })
).includes(calleeAddress),
);
assert.match(
await call("procedure_pseudo_code", { procedure: callerAddress }),
/\+\s*1\b/u,
);
}
}
assert.ok((await call("list_segments", {})).length > 0); assert.ok((await call("list_segments", {})).length > 0);
await call("list_strings", {}); await call("list_strings", {});
const procedure = const procedure =
@@ -296,6 +373,12 @@ try {
"Packaged verification omitted an admitted operation.", "Packaged verification omitted an admitted operation.",
); );
const session = await call("binary_session", {}); const session = await call("binary_session", {});
assert.equal(
session.analysis_provider_binding?.analysis_profile?.parameters
?.executable_role,
parsedTarget.value.executableRole,
"MCP profile must preserve the selected PE role.",
);
assert.equal( assert.equal(
session.analysis_provider_binding?.selection_source, session.analysis_provider_binding?.selection_source,
"auto-single-candidate", "auto-single-candidate",
@@ -326,9 +409,9 @@ try {
assert.deepEqual(await readdir(join(callerDirectory, name)), []); assert.deepEqual(await readdir(join(callerDirectory, name)), []);
report.callerScriptCollisionsPreserved = true; report.callerScriptCollisionsPreserved = true;
report.runtimeCleanup = true; report.runtimeCleanup = true;
report.ok = true; // Only the parent can attest success after removing the installation.
if (process.argv[4] !== undefined) if (reportArgument !== undefined)
await writeFile(process.argv[4], `${JSON.stringify(report)}\n`); await writeFile(reportArgument, `${JSON.stringify(report)}\n`);
process.stdout.write(`${JSON.stringify(report)}\n`); process.stdout.write(`${JSON.stringify(report)}\n`);
} catch (cause) { } catch (cause) {
report.failure = { report.failure = {
@@ -338,9 +421,7 @@ try {
.replaceAll(target, "<target>") .replaceAll(target, "<target>")
.replaceAll(workspace, "<workspace>"), .replaceAll(workspace, "<workspace>"),
}; };
if (process.argv[4] !== undefined) if (reportArgument !== undefined)
await writeFile(process.argv[4], `${JSON.stringify(report)}\n`); await writeFile(reportArgument, `${JSON.stringify(report)}\n`);
throw cause; throw cause;
} finally {
await rm(workspace, { recursive: true, force: true });
} }
+403
View File
@@ -0,0 +1,403 @@
import assert from "node:assert/strict";
import { createOpenAiModelFixture } from "./verify/clients/model-fixture.mjs";
import {
mkdir,
writeFile,
readFile,
mkdtemp,
realpath,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { createHash } from "node:crypto";
import { exec } from "./lib/verify-package-core.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
import {
TOOL_CONTRACTS,
toolContract,
} from "../dist/contracts/toolContracts.js";
assert(
process.platform !== "win32",
"The Qwen real-client verifier currently requires a POSIX host; native Windows is unverified.",
);
const repo = fileURLToPath(new URL("..", import.meta.url));
const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo;
const lab = await mkdtemp(join(tmpdir(), "rea-qwen-client-"));
const client = "qwen";
const mode = process.argv[2] ?? "call";
assert(
["chat", "call"].includes(mode),
"Usage: node scripts/verify-qwen-client.mjs [chat|call]",
);
const command = process.env.REA_VERIFY_QWEN_COMMAND ?? "qwen";
const verifier = createVerifierRun();
const profile = join(lab, "selected profile");
const account = join(lab, "account");
const runtime = join(profile, "runtime");
const workspace = join(lab, "workspace");
const sharedSkills = join(account, ".agents", "skills");
await Promise.all([
mkdir(profile, { recursive: true }),
mkdir(account),
mkdir(workspace),
]);
const baseEnvironment = {
PATH: process.env.PATH ?? "",
USERPROFILE: account,
QWEN_HOME: profile,
QWEN_RUNTIME_DIR: runtime,
REA_PROCESS_RUN_ID: verifier.run_id,
};
let clientVersion;
try {
clientVersion = (
await exec(command, ["--version"], {
env: baseEnvironment,
timeout: 30_000,
})
).stdout.trim();
} catch (cause) {
throw new Error(
`Qwen client preflight failed for ${command}; install Qwen Code or set REA_VERIFY_QWEN_COMMAND. ${String(cause)}`,
);
}
const original = JSON.stringify(
{
security: {
auth: { selectedType: "openai" },
folderTrust: { enabled: false },
},
telemetry: { enabled: false },
model: { name: "rea-client-fixture" },
// Qwen uses os.homedir(), while REA's isolated CLI account uses USERPROFILE.
// This caller-owned setting lets the real client load the REA-installed bundle.
skills: { directories: [sharedSkills] },
mcpServers: { other: { command: "unrelated-server", disabled: true } },
},
null,
2,
);
await writeFile(join(profile, "settings.json"), original);
for (const step of [
["setup", "--client", "qwen_code", "--dry-run"],
["setup", "--client", "qwen_code", "--yes"],
["doctor", "--client", "qwen_code", "--skill"],
]) {
const response = await exec(
process.execPath,
[join(runtimeRoot, "scripts/rea.mjs"), ...step, "--json"],
{ env: baseEnvironment, cwd: repo, timeout: 60_000 },
);
const result = JSON.parse(response.stdout);
if (step.includes("--dry-run"))
assert(
result.plannedActions.every(
(a) =>
a.target.startsWith(profile + "/") ||
a.target.startsWith(sharedSkills + "/"),
),
);
await writeFile(
join(lab, `${step[0]}${step.includes("--dry-run") ? "-plan" : ""}.json`),
response.stdout,
);
}
assert.equal(
await readFile(join(profile, "settings.json.rea.backup"), "utf8"),
original,
);
const configured = JSON.parse(
await readFile(join(profile, "settings.json"), "utf8"),
);
assert.deepEqual(configured.model, { name: "rea-client-fixture" });
assert.deepEqual(configured.skills, { directories: [sharedSkills] });
assert.deepEqual(configured.mcpServers.other, {
command: "unrelated-server",
disabled: true,
});
const target = join(lab, "fixture α测试");
await mkdir(target, { recursive: true });
await writeFile(
`${target}/app.js`,
'export function result() { return { clientCompatibility: "REA_CLIENT_FIXTURE", count: 7 }; }\n',
);
let evidenceSeen = false;
let skillSeen = false;
let finalSeen = false;
let skillRequested = false;
let searchRequested = false;
let artifactReadByAgent = false;
let artifactPath;
let selectedSkillLoaded = false;
let artifactDigest;
const extractEvidence = (value) => {
if (typeof value === "string") {
try {
return extractEvidence(JSON.parse(value));
} catch {
return null;
}
}
if (Array.isArray(value)) {
for (const item of value) {
const result = extractEvidence(item);
if (result) return result;
}
} else if (value && typeof value === "object") {
if (
typeof value.evidence_id === "string" &&
value.normalized_result?.graph?.nodes?.length > 0
)
return value;
for (const nested of Object.values(value)) {
const result = extractEvidence(nested);
if (result) return result;
}
}
return null;
};
const fixture = await createOpenAiModelFixture({
directory: lab,
prefix: `${client}-${mode}`,
onRequest: async ({ body, tools, requestIndex }) => {
skillSeen ||= JSON.stringify(body.messages ?? []).includes(
"reverse-engineer-anything",
);
selectedSkillLoaded ||= JSON.stringify(body.messages ?? []).includes(
join(sharedSkills, "reverse-engineer-anything"),
);
const toolText = JSON.stringify(
(body.messages ?? []).filter((m) => m.role === "tool"),
);
if (
toolText.includes("REA_CLIENT_FIXTURE") &&
/(?:\\?"REA_CLIENT_FIXTURE\\?"\s*:\s*true)/u.test(toolText)
) {
artifactReadByAgent = true;
assert(artifactPath);
const artifact = await readFile(artifactPath);
artifactDigest = createHash("sha256").update(artifact).digest("hex");
const evidence = toolContract(
"analyze_javascript_application",
).outputSchema.parse(JSON.parse(artifact.toString("utf8")));
assert(extractEvidence(evidence));
assert.equal(evidence.subject.local_path, target);
const shape = evidence.normalized_result.graph.nodes
.flatMap((n) => n.observations)
.map((o) => o.properties)
.find(
(p) =>
p.semantic_role === "export-return-shapes" &&
p.exported_name === "result",
);
assert(shape?.return_shape_coverage?.projection_complete);
const fields = shape.static_return_shapes.flatMap((s) => s.fields);
assert(
fields.some(
(f) =>
f.path === "/clientCompatibility" &&
f.state === "literal" &&
f.value === "REA_CLIENT_FIXTURE",
),
);
assert(
fields.some(
(f) => f.path === "/count" && f.state === "literal" && f.value === 7,
),
);
evidenceSeen = true;
await writeFile(
`${lab}/${client}-${mode}-evidence.json`,
JSON.stringify(evidence),
);
}
for (const message of body.messages ?? []) {
if (message.role !== "tool") continue;
const evidence = extractEvidence(message.content);
if (evidence) {
assert(
JSON.stringify(evidence.normalized_result).includes(
"REA_CLIENT_FIXTURE",
),
);
evidenceSeen = true;
await writeFile(
`${lab}/${client}-${mode}-evidence.json`,
JSON.stringify(evidence),
);
}
}
const selected = tools.find((t) =>
t.name.includes("analyze_javascript_application"),
);
const reviewedNames = [
...JSON.stringify(
(body.messages ?? []).filter((m) => m.role === "tool"),
).matchAll(
/(?:\\?"name\\?"\s*:\s*\\?")([^"\\]*analyze_javascript_application)(?:\\?")/g,
),
]
.map((m) => m[1])
.sort((a, b) => b.length - a.length);
const search = tools.find((t) => t.name === "tool_search");
const invoke = tools.find((t) => t.name === "tool_call");
const skill = tools.find((t) => t.name === "skill");
const shell = tools.find((t) => t.name === "run_shell_command");
const persisted = toolText.match(
/Full output saved to: ([^\\"\n]+)(?:\\n|\n)/u,
);
const call = (name, args) => ({
role: "assistant",
content: null,
tool_calls: [
{
id: `call_rea_fixture_${requestIndex}`,
type: "function",
function: { name, arguments: JSON.stringify(args) },
},
],
});
let message;
if (mode === "chat" || evidenceSeen) {
message = { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" };
finalSeen = true;
} else if (skill && !skillRequested) {
skillRequested = true;
message = call(skill.name, { skill: "reverse-engineer-anything" });
} else if (persisted && shell) {
artifactPath = persisted[1];
assert(
(await realpath(artifactPath)).startsWith(
(await realpath(runtime)) + "/",
),
);
const script = `const fs=require('node:fs');const e=JSON.parse(fs.readFileSync(${JSON.stringify(artifactPath)},'utf8'));console.log(JSON.stringify({REA_CLIENT_FIXTURE:JSON.stringify(e.normalized_result).includes('REA_CLIENT_FIXTURE'),evidence_id:e.evidence_id,graph_nodes:e.normalized_result.graph.nodes.length}));`;
const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
message = call(shell.name, {
command: `node -e ${quote(script)}`,
description:
"Read the complete saved REA Evidence and verify the fixture facts.",
});
} else if (selected)
message = call(selected.name, {
input_path: target,
format: "directory",
});
else if (reviewedNames.length && invoke)
message = call(invoke.name, {
name: reviewedNames[0],
arguments: { input_path: target, format: "directory" },
});
else if (search && !searchRequested) {
searchRequested = true;
message = call(search.name, { query: "analyze_javascript_application" });
} else
throw new Error(
`REA analysis tool not exposed; tools: ${tools.map((t) => t.name).join(", ")}`,
);
return message;
},
});
const endpoint = fixture.baseUrl;
const prompt =
mode === "chat"
? "Say hello briefly."
: `Use REA to analyze the JavaScript directory ${target}. Report the result.`;
const env = {
...baseEnvironment,
OPENAI_API_KEY: "local-fixture-key",
OPENAI_BASE_URL: endpoint,
};
const args = [
"--auth-type",
"openai",
"--openai-base-url",
endpoint,
"--model",
"rea-client-fixture",
"--approval-mode",
mode === "chat" ? "default" : "yolo",
"--allowed-mcp-server-names",
"rea",
"--output-format",
"stream-json",
"--max-session-turns",
"8",
"-p",
prompt,
];
let response;
let failure;
try {
response = await exec(command, args, {
env,
cwd: workspace,
timeout: 120_000,
maxBuffer: 10 * 1024 * 1024,
});
await writeFile(join(lab, `${mode}.stdout`), response.stdout);
await writeFile(join(lab, `${mode}.stderr`), response.stderr);
const initialized = response.stdout
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line))
.find((v) => v.type === "system" && v.subtype === "init");
assert(initialized);
const observed = initialized.tools
.filter((n) => n.startsWith("mcp__rea__"))
.sort();
assert.deepEqual(
observed,
TOOL_CONTRACTS.map((c) => `mcp__rea__${c.name}`).sort(),
"Qwen must discover the complete REA catalog",
);
assert.equal(fixture.failure, undefined);
assert(finalSeen);
if (mode === "call") {
assert(evidenceSeen);
assert(selectedSkillLoaded);
assert(artifactReadByAgent);
}
} catch (cause) {
failure = cause;
if (typeof cause?.stdout === "string")
await writeFile(join(lab, `${mode}.stdout`), cause.stdout);
if (typeof cause?.stderr === "string")
await writeFile(join(lab, `${mode}.stderr`), cause.stderr);
} finally {
await fixture.close();
const lineage = await completeVerifierRun(verifier);
const receipt = {
client: "Qwen Code",
clientVersion,
mode,
passed: failure === undefined,
artifacts: lab,
runtimeRoot,
host: {
platform: process.platform,
architecture: process.arch,
node: process.versions.node,
},
modelFixture:
"loopback deterministic OpenAI-compatible endpoint; no live model-provider claim",
skillDiscovery:
"caller-configured isolated shared directory; default OS home discovery unverified",
catalogSize: TOOL_CONTRACTS.length,
requests: fixture.requests,
probes: fixture.probes,
evidenceSeen,
selectedSkillLoaded,
artifactReadByAgent,
artifactPath,
artifactDigest,
finalSeen,
fixtureFailure: fixture.failure,
verifier: lineage,
};
await writeFile(join(lab, "receipt.json"), JSON.stringify(receipt, null, 2));
console.log(JSON.stringify(receipt));
}
if (failure !== undefined) throw failure;
+21 -6
View File
@@ -62,21 +62,21 @@ if (
); );
const root = resolve(fileURLToPath(new URL("..", import.meta.url))); const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
const architecture = process.argv[2] === "--x86" ? "x86" : "x86_64"; const architecture = process.argv.includes("--x86") ? "x86" : "x86_64";
const dllFixture = process.argv.includes("--dll");
const role = dllFixture ? "shared-library" : "application";
const targetPath = resolve( const targetPath = resolve(
root, root,
"build", "build",
"fixtures", "fixtures",
architecture === "x86" `rea-ghidra-windows${architecture === "x86" ? "-x86" : ""}.${dllFixture ? "dll" : "exe"}`,
? "rea-ghidra-windows-x86.exe"
: "rea-ghidra-windows.exe",
); );
const target = await parseBinaryTarget(targetPath); const target = await parseBinaryTarget(targetPath);
if ( if (
!target.ok || !target.ok ||
target.value.format !== "pe" || target.value.format !== "pe" ||
target.value.architecture !== architecture || target.value.architecture !== architecture ||
target.value.executableRole !== "application" || target.value.executableRole !== role ||
target.value.managed !== false target.value.managed !== false
) )
throw new Error( throw new Error(
@@ -89,6 +89,8 @@ const profile = await resolveGhidraAnalysisProfile(
); );
if (!profile.ok || profile.value.profile === null) if (!profile.ok || profile.value.profile === null)
throw new Error("Windows Ghidra profile could not be committed"); throw new Error("Windows Ghidra profile could not be committed");
if (profile.value.profile.parameters.executable_role !== role)
throw new Error("Windows Ghidra profile lost the observed PE role");
const client = new GhidraClient({ const client = new GhidraClient({
platform: installation.platform, platform: installation.platform,
@@ -162,6 +164,19 @@ try {
throw new Error( throw new Error(
"Windows fixture decompilation lost its known return value", "Windows fixture decompilation lost its known return value",
); );
if (dllFixture) {
const callerAddress = architecture === "x86" ? "0x401000" : "0x140001000";
const callees = await functionOperation("procedure_callees", {
procedure: callerAddress,
});
if (!callees.includes(callee.address))
throw new Error("DLL export caller lost its controlled call reference");
const callerCode = await functionOperation("procedure_pseudo_code", {
procedure: callerAddress,
});
if (typeof callerCode !== "string" || !/\+\s*1\b/u.test(callerCode))
throw new Error("DLL export caller lost its addition of one");
}
await inventory("inspect_native_load_image", {}); await inventory("inspect_native_load_image", {});
const memory = await inventory("read_bytes", { const memory = await inventory("read_bytes", {
@@ -254,7 +269,7 @@ try {
limitations: [ limitations: [
"approved-non-sensitive-fixtures-only", "approved-non-sensitive-fixtures-only",
"windows-x64-local-ntfs-only", "windows-x64-local-ntfs-only",
"native-x86-and-x86-64-pe-applications-only", "native-x86-and-x86-64-pe-applications-and-dlls-only",
"no-gui-or-mutation-authority", "no-gui-or-mutation-authority",
], ],
}; };
+97
View File
@@ -0,0 +1,97 @@
import { cp, mkdir, readFile, readdir } from "node:fs/promises";
import { dirname, join, relative } from "node:path";
import { exec, pathExists } from "./lib/verify-package-core.mjs";
/** Exercise Skills' default root discovery without ignored build outputs. */
export async function verifyRepositorySkill({ root, workspace }) {
const snapshot = join(workspace, "repository");
const consumer = join(workspace, "skill-consumer");
const home = join(workspace, "skill-consumer-home");
const tracked = (
await exec(
"git",
["ls-files", "--cached", "--format=%(objectmode) %(path)", "-z"],
{ cwd: root },
)
).stdout
.split("\0")
.filter(Boolean);
for (const entry of tracked) {
const mode = entry.slice(0, 6);
const path = entry.slice(7);
const destination = join(snapshot, path);
// A GitHub clone without submodule initialization has only these directories.
if (mode === "160000") {
await mkdir(destination, { recursive: true });
continue;
}
await mkdir(dirname(destination), { recursive: true });
await cp(join(root, path), destination, { verbatimSymlinks: true });
}
if (await pathExists(join(snapshot, "skills")))
throw new Error("repository skill check must run without generated skills");
await mkdir(consumer, { recursive: true });
await mkdir(home, { recursive: true });
const skillsVersion = "1.7.2";
await exec(
"npm",
[
"exec",
"--yes",
"--package",
`skills@${skillsVersion}`,
"--",
"skills",
"add",
snapshot,
"--skill",
"reverse-engineer-anything",
"--agent",
"codex",
"--yes",
],
{
cwd: consumer,
env: {
...process.env,
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: join(home, ".config"),
CODEX_HOME: join(home, ".codex"),
DO_NOT_TRACK: "1",
SKILLS_NO_TELEMETRY: "1",
CI: "1",
},
timeout: 120_000,
},
);
const authoredRoot = join(
snapshot,
".agents/skills/reverse-engineer-anything",
);
const installedSkills = join(consumer, ".agents/skills");
const installedRoot = join(installedSkills, "reverse-engineer-anything");
const files = await filePaths(authoredRoot);
if (
JSON.stringify(await readdir(installedSkills)) !==
JSON.stringify(["reverse-engineer-anything"]) ||
JSON.stringify(await filePaths(installedRoot)) !== JSON.stringify(files)
)
throw new Error(
"Skills did not install exactly the selected public bundle",
);
for (const path of files) {
const authored = await readFile(join(authoredRoot, path));
if (!authored.equals(await readFile(join(installedRoot, path))))
throw new Error(`Skills changed authored skill content: ${path}`);
}
return { skillsVersion, files };
}
async function filePaths(root) {
return (await readdir(root, { recursive: true, withFileTypes: true }))
.filter((entry) => entry.isFile())
.map((entry) => relative(root, join(entry.parentPath, entry.name)))
.sort();
}
+13 -3
View File
@@ -29,7 +29,7 @@ const LINK_MODES = [
{ {
mode: "dyld-info", mode: "dyld-info",
flags: ["-Wl,-no_fixup_chains"], flags: ["-Wl,-no_fixup_chains"],
fixups: "LC_DYLD_INFO bind opcodes", fixups: "LC_DYLD_INFO rebase opcodes were not decoded",
swift: true, swift: true,
}, },
{ mode: "chained", flags: [], fixups: "chained fixups:", swift: true }, { mode: "chained", flags: [], fixups: "chained fixups:", swift: true },
@@ -135,7 +135,12 @@ const checkObjc = (metadata, mode) => {
item.selector === "reaFixtureLength", item.selector === "reaFixtureLength",
) || ) ||
objcCoverage?.status !== "complete" || objcCoverage?.status !== "complete" ||
fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true ||
(mode.mode === "dyld-info") !==
(fixupCoverage(metadata)?.reason?.includes(
"LC_DYLD_INFO bind opcodes",
) ===
true)
) )
throw new Error( throw new Error(
`Apple metadata fixture drifted (${mode.mode}): ${JSON.stringify(metadata)}`, `Apple metadata fixture drifted (${mode.mode}): ${JSON.stringify(metadata)}`,
@@ -165,7 +170,12 @@ const checkSwift = (metadata, mode) => {
item.owner === "ReaWitnessFixture: ReaWitnessFixtureProtocol" && item.owner === "ReaWitnessFixture: ReaWitnessFixtureProtocol" &&
item.implementation_address !== null, item.implementation_address !== null,
) || ) ||
fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true ||
(mode.mode === "dyld-info") !==
(fixupCoverage(metadata)?.reason?.includes(
"LC_DYLD_INFO bind opcodes",
) ===
true)
) )
throw new Error( throw new Error(
`Swift metadata fixture drifted (${mode.mode}): ${JSON.stringify(metadata)}`, `Swift metadata fixture drifted (${mode.mode}): ${JSON.stringify(metadata)}`,
+3
View File
@@ -17,6 +17,7 @@ import {
import { verifyDyldEnvironment } from "./dyld-environment.mjs"; import { verifyDyldEnvironment } from "./dyld-environment.mjs";
import { verifyDylibResolution } from "./macos-dylib-e2e.mjs"; import { verifyDylibResolution } from "./macos-dylib-e2e.mjs";
import { verifyMachOOccurrences } from "./macho-occurrences.mjs"; import { verifyMachOOccurrences } from "./macho-occurrences.mjs";
import { verifyNativeProvenance } from "./native-provenance.mjs";
const exec = promisify(execFile); const exec = promisify(execFile);
@@ -69,6 +70,7 @@ const EXPECTED_BUNDLES = [
let report; let report;
try { try {
const { app, executable } = await buildMacosBundleFixture(root); const { app, executable } = await buildMacosBundleFixture(root);
const nativeProvenance = await verifyNativeProvenance(app, executable);
const machoOccurrences = await verifyMachOOccurrences(app, executable); const machoOccurrences = await verifyMachOOccurrences(app, executable);
const zip = join(root, "MacFixture.zip"); const zip = join(root, "MacFixture.zip");
await exec("/usr/bin/ditto", ["-c", "-k", "--keepParent", app, zip]); await exec("/usr/bin/ditto", ["-c", "-k", "--keepParent", app, zip]);
@@ -166,6 +168,7 @@ try {
containers: containers.map(({ format }) => format), containers: containers.map(({ format }) => format),
bundles: EXPECTED_BUNDLES.length, bundles: EXPECTED_BUNDLES.length,
dmg_detached: true, dmg_detached: true,
native_provenance: nativeProvenance,
macho_occurrences: machoOccurrences, macho_occurrences: machoOccurrences,
dylib_resolution: dylibs, dylib_resolution: dylibs,
dyld_environment: dyldEnvironment, dyld_environment: dyldEnvironment,
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { toolContract } from "../../../dist/contracts/toolContracts.js";
import {
artifactCliEvidence,
withArtifactMcp,
} from "../../lib/artifact-e2e.mjs";
/** Inspect the compiled bundle with real native utilities through both adapters. */
export async function verifyNativeProvenance(app, executable) {
const original = await readFile(executable);
const sha256 = createHash("sha256").update(original).digest("hex");
const symbols = ["$s4main3FooV", "plain_symbol"];
const operations = [
["inspect_macho", []],
["inspect_signature", []],
["inspect_plist", []],
["list_architectures", []],
["demangle_swift", symbols],
];
const observations = [];
await withArtifactMcp(app, async (client) => {
const definitions = new Map(
(await client.listTools()).tools.map((tool) => [tool.name, tool]),
);
for (const [operation, arguments_] of operations) {
const cli = toolContract(operation).outputSchema.parse(
await artifactCliEvidence(
operation.replaceAll("_", "-"),
app,
arguments_,
),
);
const response = await client.callTool(
{
name: operation,
arguments: operation === "demangle_swift" ? { symbols } : {},
},
{ toolDefinition: definitions.get(operation) },
);
assert.notEqual(response.isError, true, JSON.stringify(response));
const mcp = toolContract(operation).outputSchema.parse(
response.structuredContent,
);
assert.equal(cli.raw_result, null);
assert.equal(mcp.raw_result, null);
assert.ok(cli.normalized_result.provenance.length > 0);
assert.deepEqual(mcp.normalized_result, cli.normalized_result);
assert.deepEqual(
JSON.parse(response.content.find((item) => item.type === "text").text),
response.structuredContent,
);
await client.ping();
observations.push({
operation,
commands: cli.normalized_result.provenance.length,
cli_bytes: Buffer.byteLength(JSON.stringify(cli)),
mcp_bytes: Buffer.byteLength(JSON.stringify(response)),
});
}
});
assert.deepEqual(await readFile(executable), original);
return {
real_native_utilities: true,
target_sha256: sha256,
bytes_unchanged: true,
observations,
};
}
+171
View File
@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
/** A loopback model fixture exercises real clients without claiming live-provider compatibility. */
export const createOpenAiModelFixture = async ({
directory,
prefix,
onRequest,
}) => {
const requests = [];
const probes = [];
let incoming = 0;
let failure;
const server = createServer(async (request, response) => {
try {
if (request.method === "GET" && request.url?.endsWith("/models")) {
response.setHeader("Content-Type", "application/json");
response.end(
JSON.stringify({
object: "list",
data: [
{
id: "rea-client-fixture",
object: "model",
owned_by: "local-fixture",
},
],
}),
);
return;
}
const chunks = [];
let bytes = 0;
for await (const chunk of request) {
bytes += chunk.length;
assert(
bytes <= 10 * 1024 * 1024,
"Local model fixture exceeded its 10 MiB request resource budget",
);
chunks.push(chunk);
}
const rawBody = Buffer.concat(chunks).toString("utf8");
const body = rawBody.length === 0 ? null : JSON.parse(rawBody);
await writeFile(
join(directory, `${prefix}-incoming-${++incoming}.json`),
JSON.stringify({ method: request.method, path: request.url, body }),
);
if (request.url !== "/v1/chat/completions") {
// Hermes probes custom endpoints for Ollama/llama.cpp metadata.
// Unsupported HTTP endpoints return 404, as on an OpenAI-only server.
probes.push({
method: request.method,
path: request.url,
body,
status: 404,
});
response.statusCode = 404;
response.setHeader("Content-Type", "application/json");
response.end(
JSON.stringify({
error:
"This OpenAI fixture exposes models and chat completions only.",
}),
);
return;
}
assert.equal(request.method, "POST");
assert(body !== null && typeof body === "object" && !Array.isArray(body));
assert(Array.isArray(body.messages));
const tools = (body.tools ?? [])
.filter((t) => t.type === "function")
.map((t) => t.function ?? t);
requests.push({
path: request.url,
tools: tools.map((t) => t.name),
stream: body.stream,
messageRoles: body.messages.map((m) => m.role),
});
await writeFile(
join(directory, `${prefix}-request-${requests.length}.json`),
JSON.stringify(body),
);
const message = await onRequest({
body,
tools,
requestIndex: requests.length,
});
const base = {
id: `chatcmpl-rea-${requests.length}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: body.model,
};
const finish = message.tool_calls ? "tool_calls" : "stop";
const usage = {
prompt_tokens: 100,
completion_tokens: 20,
total_tokens: 120,
};
if (body.stream) {
response.setHeader("Content-Type", "text/event-stream");
const emit = (delta, finishReason = null) =>
response.write(
`data: ${JSON.stringify({ ...base, object: "chat.completion.chunk", choices: [{ index: 0, delta, finish_reason: finishReason }] })}\n\n`,
);
if (message.tool_calls) {
const fragments = message.tool_calls.map((call, index) => {
const split = Math.floor(call.function.arguments.length / 2);
return {
first: {
index,
...call,
function: {
name: call.function.name,
arguments: call.function.arguments.slice(0, split),
},
},
second: {
index,
function: { arguments: call.function.arguments.slice(split) },
},
};
});
emit({
role: "assistant",
content: null,
tool_calls: fragments.map((f) => f.first),
});
emit({ tool_calls: fragments.map((f) => f.second) });
} else emit(message);
emit({}, finish);
response.write(
`data: ${JSON.stringify({ ...base, object: "chat.completion.chunk", choices: [], usage })}\n\n`,
);
response.end("data: [DONE]\n\n");
} else {
response.setHeader("Content-Type", "application/json");
response.end(
JSON.stringify({
...base,
choices: [{ index: 0, message, finish_reason: finish }],
usage,
}),
);
}
} catch (cause) {
failure = String(cause);
response.statusCode = 400;
response.setHeader("Content-Type", "application/json");
response.end(
JSON.stringify({
error: { message: failure, type: "invalid_request_error" },
}),
);
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
assert(address !== null && typeof address !== "string");
return {
baseUrl: `http://127.0.0.1:${address.port}/v1`,
requests,
probes,
get failure() {
return failure;
},
close: () => new Promise((resolve) => server.close(resolve)),
};
};
+3 -3
View File
@@ -54,15 +54,15 @@ try {
data: { ...evidence, normalized_result: { payload: [marker] } }, data: { ...evidence, normalized_result: { payload: [marker] } },
meta: metadata, meta: metadata,
}; };
const skeleton = JSON.stringify(template, null, format === "json" ? 2 : 0); // The destination is a redirected file, so both formats are compact.
const skeleton = JSON.stringify(template);
const encodedMarker = JSON.stringify(marker); const encodedMarker = JSON.stringify(marker);
const markerIndex = skeleton.indexOf(encodedMarker); const markerIndex = skeleton.indexOf(encodedMarker);
assert.ok(markerIndex >= 0); assert.ok(markerIndex >= 0);
assert.equal(markerIndex, skeleton.lastIndexOf(encodedMarker)); assert.equal(markerIndex, skeleton.lastIndexOf(encodedMarker));
const prefix = skeleton.slice(0, markerIndex); const prefix = skeleton.slice(0, markerIndex);
const suffix = `${skeleton.slice(markerIndex + encodedMarker.length)}\n`; const suffix = `${skeleton.slice(markerIndex + encodedMarker.length)}\n`;
const indentation = prefix.slice(prefix.lastIndexOf("\n") + 1); const separator = ",";
const separator = format === "json" ? `,\n${indentation}` : ",";
const expectedHash = createHash("sha256"); const expectedHash = createHash("sha256");
expectedHash.update(prefix); expectedHash.update(prefix);
for (let index = 0; index < count; index += 1) { for (let index = 0; index < count; index += 1) {
+1 -1
View File
@@ -80,7 +80,7 @@ export async function connectRecordedCrash({ entrypoint, environment }) {
evidence.confidence, evidence.confidence,
debuggerContext ? "derived" : "observed", debuggerContext ? "derived" : "observed",
); );
assert.deepEqual(evidence.raw_result, report); assert.equal(evidence.raw_result, null);
assert.equal(evidence.subject.local_path, path); assert.equal(evidence.subject.local_path, path);
assert.equal(report.artifact.path, path); assert.equal(report.artifact.path, path);
assert.equal( assert.equal(
+22
View File
@@ -0,0 +1,22 @@
import { expect, it } from "vitest";
import { createAnalysisExecution } from "./AnalysisProvider.js";
const PROVIDER = { id: "fixture", name: "Fixture", version: "1" };
it("records no raw result unless the provider supplies a distinct one", () => {
const result = { entries: [{ value: "normalized" }] };
expect(createAnalysisExecution(result, PROVIDER)).toMatchObject({
result,
rawResult: null,
});
expect(
createAnalysisExecution(result, PROVIDER, {
rawResult: { stdout: "raw" },
}).rawResult,
).toEqual({ stdout: "raw" });
expect(
createAnalysisExecution(result, PROVIDER, { rawResult: null }).rawResult,
).toBeNull();
});
+8 -2
View File
@@ -41,7 +41,13 @@ export interface AnalysisExecution {
readonly subject: EvidenceSubjectTarget | null; readonly subject: EvidenceSubjectTarget | null;
} }
/** Build a validated atomic provider observation at an adapter boundary. */ /**
* Build a validated atomic provider observation at an adapter boundary.
*
* Pass `rawResult` only for a provider representation distinct from
* `result`. Omitting it records `null` rather than a second complete copy of
* the normalized result in every Evidence record and tool response.
*/
export const createAnalysisExecution = ( export const createAnalysisExecution = (
result: unknown, result: unknown,
provider: ProviderIdentity, provider: ProviderIdentity,
@@ -56,7 +62,7 @@ export const createAnalysisExecution = (
result: jsonValueSchema.parse(result), result: jsonValueSchema.parse(result),
rawResult: rawResult:
options.rawResult === undefined options.rawResult === undefined
? jsonValueSchema.parse(result) ? null
: jsonValueSchema.parse(options.rawResult), : jsonValueSchema.parse(options.rawResult),
provider, provider,
...(options.analysisProfile === undefined ...(options.analysisProfile === undefined
@@ -1,4 +1,9 @@
import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import {
parse as parseYaml,
parseDocument as parseYamlDocument,
stringify as stringifyYaml,
} from "yaml";
import { isDeepStrictEqual } from "node:util"; import { isDeepStrictEqual } from "node:util";
import { import {
applyEdits, applyEdits,
@@ -70,6 +75,7 @@ export const clientConfigurationServersKey = (
switch (format) { switch (format) {
case "toml": case "toml":
case "grok": case "grok":
case "hermes":
return "mcp_servers"; return "mcp_servers";
case "opencode": case "opencode":
return "mcp"; return "mcp";
@@ -93,6 +99,12 @@ const parseDocument = (
): Record<string, unknown> => { ): Record<string, unknown> => {
if (format === "toml" || format === "grok") if (format === "toml" || format === "grok")
return objectSchema.parse(parseToml(text)); return objectSchema.parse(parseToml(text));
// Pi loads `mcp.json` with a strict `JSON.parse` and no BOM tolerance, so
// comments, trailing commas, a BOM, or an empty existing file must be
// rejected instead of registering into a document Pi cannot load.
if (format === "pi") return objectSchema.parse(JSON.parse(text));
// Hermes stores MCP servers in `mcp_servers` inside a YAML document.
if (format === "hermes") return objectSchema.parse(parseYaml(text));
// An empty file, which some clients create before any server is added, // An empty file, which some clients create before any server is added,
// holds no settings to preserve. // holds no settings to preserve.
if (isEmptyClientConfigurationText(text)) return {}; if (isEmptyClientConfigurationText(text)) return {};
@@ -1265,6 +1277,8 @@ export const serializeClientConfiguration = (
return serializeCodexConfiguration(document, originalText); return serializeCodexConfiguration(document, originalText);
if (format === "grok") if (format === "grok")
return serializeGrokConfiguration(document, originalText); return serializeGrokConfiguration(document, originalText);
if (format === "hermes")
return serializeHermesConfiguration(document, originalText);
// An empty original holds no comments or settings to preserve. Writing a // An empty original holds no comments or settings to preserve. Writing a
// fresh document also keeps a lone BOM from shifting past the closing brace. // fresh document also keeps a lone BOM from shifting past the closing brace.
if ( if (
@@ -1303,6 +1317,34 @@ export const legacyClientServerPath = (
? [parsed.serversPath[0] ?? "mcp", name] ? [parsed.serversPath[0] ?? "mcp", name]
: undefined; : undefined;
/**
* Keep an existing Hermes `config.yaml` intact aside from the REA server entry.
* Hermes config files carry user comments and unrelated settings, so the rea
* entry is upserted into the parsed YAML document (which retains formatting and
* comments) rather than re-serializing the whole object.
*/
const serializeHermesConfiguration = (
document: Record<string, unknown>,
originalText: string | undefined,
): string => {
if (
originalText === undefined ||
isEmptyClientConfigurationText(originalText)
)
return stringifyYaml(document);
const serversKey = clientConfigurationServersKey("hermes");
const servers = document[serversKey];
const entry =
typeof servers === "object" && servers !== null
? (servers as Record<string, unknown>)[PRODUCT_IDENTITY.mcpServerKey]
: undefined;
const yamlDocument = parseYamlDocument(originalText);
if (entry === undefined)
yamlDocument.deleteIn([serversKey, PRODUCT_IDENTITY.mcpServerKey]);
else yamlDocument.setIn([serversKey, PRODUCT_IDENTITY.mcpServerKey], entry);
return yamlDocument.toString();
};
/** Build the stdio entry shape expected by one client's configuration dialect. */ /** Build the stdio entry shape expected by one client's configuration dialect. */
export const clientRegistrationEntry = ( export const clientRegistrationEntry = (
format: ClientRegistrationDialect, format: ClientRegistrationDialect,
@@ -1331,6 +1373,7 @@ export const clientRegistrationEntry = (
}; };
case "vscode": case "vscode":
case "omp": case "omp":
case "pi":
return { return {
type: "stdio", type: "stdio",
command: executable, command: executable,
@@ -1353,6 +1396,15 @@ export const clientRegistrationEntry = (
args, args,
...(Object.keys(environment).length === 0 ? {} : { env: environment }), ...(Object.keys(environment).length === 0 ? {} : { env: environment }),
}; };
case "hermes":
// Hermes reads stdio servers from `mcp_servers.<name>` and connects them
// unless `enabled` is false, so the entry states it explicitly.
return {
command: executable,
args,
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
enabled: true,
};
default: default:
return { return {
command: executable, command: executable,
@@ -1361,3 +1413,45 @@ export const clientRegistrationEntry = (
}; };
} }
}; };
/** Parse one Gemini policy at its source boundary before consolidating scopes. */
export const parseGeminiMcpPolicy = (parsed: ClientConfigurationDocument) =>
z
.object({
allowed: z.array(z.string()).optional(),
excluded: z.array(z.string()).optional(),
})
.passthrough()
.parse(parsed.document.mcp ?? {});
/** Gemini consolidates excludes by union and allowlists by normalized intersection. */
export const geminiServerPolicyBlock = (
policies: readonly ReturnType<typeof parseGeminiMcpPolicy>[],
serverKey: string,
): string | undefined => {
if (policies.some(({ excluded }) => excluded?.includes(serverKey)))
return `Gemini mcp.excluded blocks ${serverKey}. Review the selected settings policies before rerunning setup.`;
const allowlists = policies.flatMap(({ allowed }) =>
allowed === undefined ? [] : [allowed],
);
const allowed = allowlists.reduce<string[] | undefined>(
(previous, current) => {
if (previous === undefined) return current;
const normalized = new Set(
current.map((name) => name.toLowerCase().trim()),
);
return previous.filter((name) =>
normalized.has(name.toLowerCase().trim()),
);
},
undefined,
);
// Gemini's manager applies the consolidated allowlist only when nonempty.
if (
allowed !== undefined &&
allowed.length > 0 &&
!allowed.includes(serverKey)
)
return `Gemini mcp.allowed does not include ${serverKey}. Review the selected settings policies before rerunning setup.`;
return undefined;
};
+108
View File
@@ -0,0 +1,108 @@
import { err, ok, type Result } from "../domain/result.js";
import { readFile } from "node:fs/promises";
import {
effectiveClientServer,
parseClientConfiguration,
type ClientConfigurationDocument,
} from "./ClientConfigurationDocument.js";
import type { SetupClient } from "./SupportedClients.js";
/** A named source failure that setup can explain without losing the bad path. */
export interface ClientConfigurationFileError {
readonly kind: "unreadable" | "malformed";
readonly path: string;
readonly detail: string;
}
/** Preserve named read failures and distinguish a missing source from an empty file. */
export const readConfigurationText = async (
path: string,
): Promise<Result<string | undefined, ClientConfigurationFileError>> => {
try {
return ok(await readFile(path, "utf8"));
} catch (cause: unknown) {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
return ok(undefined);
return err({
kind: "unreadable",
path,
detail: `Configuration ${path} could not be read: ${cause instanceof Error ? cause.message : String(cause)}`,
});
}
};
/** Read and parse one configuration at its source boundary. */
export const readConfigurationFile = async (
path: string,
format: SetupClient["format"],
): Promise<
Result<ClientConfigurationDocument | undefined, ClientConfigurationFileError>
> => {
const source = await readConfigurationText(path);
if (!source.ok) return source;
const content = source.value;
if (content === undefined) return ok(undefined);
try {
return ok(parseClientConfiguration(content, format));
} catch (cause: unknown) {
return err({
kind: "malformed",
path,
detail: `Configuration ${path} is malformed: ${cause instanceof Error ? cause.message : String(cause)}`,
});
}
};
/** Read every applicable user file; malformed lower-priority files also stop setup. */
export const readClientConfigurationFiles = async (
client: SetupClient,
): Promise<
Result<readonly ClientConfigurationDocument[], ClientConfigurationFileError>
> => {
const documents: ClientConfigurationDocument[] = [];
for (const path of client.configPaths ?? [client.configPath]) {
const document = await readConfigurationFile(path, client.format);
if (!document.ok) return document;
if (document.value !== undefined) documents.push(document.value);
}
return ok(documents);
};
/** OpenCode merges server objects in file order, replacing arrays and scalar values. */
const mergeConfigurationValue = (previous: unknown, next: unknown): unknown => {
if (!isObject(previous) || !isObject(next)) return next;
return Object.fromEntries(
[...new Set([...Object.keys(previous), ...Object.keys(next)])].map(
(key) => [
key,
Object.hasOwn(next, key)
? mergeConfigurationValue(previous[key], next[key])
: previous[key],
],
),
);
};
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
/** Project a client's effective user registration without modifying its source files. */
export const effectiveClientConfiguration = (
documents: readonly ClientConfigurationDocument[],
): ClientConfigurationDocument | undefined => {
const last = documents.at(-1);
if (last === undefined) return undefined;
if (documents.length === 1) return last;
const servers: Record<string, unknown> = {};
for (const document of documents) {
const names = new Set([
...Object.keys(document.legacyServers),
...Object.keys(document.servers),
]);
for (const name of names)
servers[name] = mergeConfigurationValue(
servers[name],
effectiveClientServer(document, name),
);
}
return { ...last, servers, legacyServers: {} };
};
+72 -30
View File
@@ -1,10 +1,14 @@
import { readClientPolicyBlock } from "./GeminiClientSettings.js";
import {
effectiveClientConfiguration,
readClientConfigurationFiles,
} from "./ClientConfigurationFiles.js";
import { import {
clientServerForcedEnabled, clientServerForcedEnabled,
clientServerListedDisabled, clientServerListedDisabled,
effectiveClientServer, effectiveClientServer,
parseClientConfiguration,
} from "./ClientConfigurationDocument.js"; } from "./ClientConfigurationDocument.js";
import { access, readFile } from "node:fs/promises"; import { access } from "node:fs/promises";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { z } from "zod"; import { z } from "zod";
@@ -16,6 +20,7 @@ import {
npxRegistrationCommand, npxRegistrationCommand,
} from "./ClientRegistrationIdentity.js"; } from "./ClientRegistrationIdentity.js";
import { import {
clientEvidencePaths,
manualRegistrationRemediation, manualRegistrationRemediation,
supportedClients, supportedClients,
} from "./SupportedClients.js"; } from "./SupportedClients.js";
@@ -78,28 +83,19 @@ export const readClientRegistrationStatuses = async (
for (const client of supportedClients( for (const client of supportedClients(
home, home,
options.platform, options.platform,
options.environment === undefined options.environment,
? undefined
: {
APPDATA: options.environment.APPDATA,
CLAUDE_CONFIG_DIR: options.environment.CLAUDE_CONFIG_DIR,
CODEX_HOME: options.environment.CODEX_HOME,
COPILOT_HOME: options.environment.COPILOT_HOME,
GROK_HOME: options.environment.GROK_HOME,
OMP_PROFILE: options.environment.OMP_PROFILE,
OPENCODE_CONFIG: options.environment.OPENCODE_CONFIG,
PI_CODING_AGENT_DIR: options.environment.PI_CODING_AGENT_DIR,
PI_CONFIG_DIR: options.environment.PI_CONFIG_DIR,
PI_PROFILE: options.environment.PI_PROFILE,
SAND_DATA_ROOT: options.environment.SAND_DATA_ROOT,
XDG_CONFIG_HOME: options.environment.XDG_CONFIG_HOME,
},
)) { )) {
if ( if (client.configPathError !== undefined) {
!(await exists(client.markerPath)) && statuses.push({
!(await exists(client.configPath)) client: client.name,
) config_path: client.configPath,
command: [],
state: "invalid",
remediation: client.configPathError,
});
continue; continue;
}
if (!(await clientEvidencePresent(client))) continue;
const manualRemediation = manualRegistrationRemediation(client.name); const manualRemediation = manualRegistrationRemediation(client.name);
if (client.format === "unsupported") { if (client.format === "unsupported") {
if (manualRemediation !== undefined) if (manualRemediation !== undefined)
@@ -113,8 +109,36 @@ export const readClientRegistrationStatuses = async (
continue; continue;
} }
try { try {
const content = await readFile(client.configPath, "utf8"); const files = await readClientConfigurationFiles(client);
const parsed = parseClientConfiguration(content, client.format); if (!files.ok) {
statuses.push({
client: client.name,
config_path: client.configPath,
command: [],
state: "invalid",
remediation: files.error.detail,
});
continue;
}
const parsed = effectiveClientConfiguration(files.value);
if (parsed === undefined) {
statuses.push(
unavailableStatus(client.name, client.configPath, "missing"),
);
continue;
}
const policy = await readClientPolicyBlock(client, parsed);
if (!policy.ok) {
statuses.push({
client: client.name,
config_path: client.configPath,
command: [],
state: "invalid",
remediation: policy.error.detail,
});
continue;
}
const policyBlock = policy.value;
const raw = effectiveClientServer(parsed, PRODUCT_IDENTITY.mcpServerKey); const raw = effectiveClientServer(parsed, PRODUCT_IDENTITY.mcpServerKey);
if (raw === undefined) { if (raw === undefined) {
statuses.push( statuses.push(
@@ -139,9 +163,14 @@ export const readClientRegistrationStatuses = async (
options.platform ?? process.platform, options.platform ?? process.platform,
clientServerForcedEnabled(parsed, PRODUCT_IDENTITY.mcpServerKey), clientServerForcedEnabled(parsed, PRODUCT_IDENTITY.mcpServerKey),
) && ) &&
!clientServerListedDisabled(parsed, PRODUCT_IDENTITY.mcpServerKey) !clientServerListedDisabled(
parsed,
PRODUCT_IDENTITY.mcpServerKey,
) &&
policyBlock === undefined
? "aligned" ? "aligned"
: "stale", : "stale",
policyBlock,
), ),
); );
} catch (cause: unknown) { } catch (cause: unknown) {
@@ -168,7 +197,7 @@ const registrationAligned = (
): boolean => { ): boolean => {
const command = [registration.command, ...registration.args]; const command = [registration.command, ...registration.args];
if ( if (
registration.disabled === true || (client.format !== "grok" && registration.disabled === true) ||
(registration.enabled === false && !forcedEnabled) (registration.enabled === false && !forcedEnabled)
) )
return false; return false;
@@ -181,9 +210,9 @@ const registrationAligned = (
) )
return false; return false;
if (client.format === "vscode" && registration.type !== "stdio") return false; if (client.format === "vscode" && registration.type !== "stdio") return false;
// OMP infers stdio for a command entry without an explicit type. // OMP and Pi infer stdio for a command entry without an explicit type.
if ( if (
client.format === "omp" && (client.format === "omp" || client.format === "pi") &&
registration.type !== undefined && registration.type !== undefined &&
registration.type !== "stdio" registration.type !== "stdio"
) )
@@ -245,7 +274,7 @@ const parseRegistration = (
if (client.format === "commandcode" && registration.transport !== "stdio") if (client.format === "commandcode" && registration.transport !== "stdio")
throw new TypeError("Expected an stdio registration"); throw new TypeError("Expected an stdio registration");
if ( if (
client.format === "omp" && (client.format === "omp" || client.format === "pi") &&
registration.type !== undefined && registration.type !== undefined &&
registration.type !== "stdio" registration.type !== "stdio"
) )
@@ -261,10 +290,17 @@ const configuredStatus = (
configPath: string, configPath: string,
command: RegistrationCommand, command: RegistrationCommand,
state: "aligned" | "stale", state: "aligned" | "stale",
policyRemediation?: string,
): ClientRegistrationStatus => ): ClientRegistrationStatus =>
state === "aligned" state === "aligned"
? { client, config_path: configPath, command, state, remediation: null } ? { client, config_path: configPath, command, state, remediation: null }
: { client, config_path: configPath, command, state, remediation }; : {
client,
config_path: configPath,
command,
state,
remediation: policyRemediation ?? remediation,
};
const unavailableStatus = ( const unavailableStatus = (
client: string, client: string,
@@ -278,6 +314,12 @@ const unavailableStatus = (
remediation, remediation,
}); });
const clientEvidencePresent = async (client: SetupClient): Promise<boolean> => {
for (const path of clientEvidencePaths(client))
if (await exists(path)) return true;
return false;
};
const exists = async (path: string | undefined): Promise<boolean> => { const exists = async (path: string | undefined): Promise<boolean> => {
if (path === undefined) return false; if (path === undefined) return false;
try { try {
+2 -2
View File
@@ -16,7 +16,6 @@ import {
type LinuxDistribution, type LinuxDistribution,
} from "./LinuxHopper.js"; } from "./LinuxHopper.js";
import { import {
claudeCodeSkillsDirectory,
readInstalledSkillIdentity, readInstalledSkillIdentity,
type InstalledSkillIdentity, type InstalledSkillIdentity,
} from "./SetupSkill.js"; } from "./SetupSkill.js";
@@ -441,7 +440,8 @@ export const systemDoctorHost = (
readInstalledSkillIdentity( readInstalledSkillIdentity(
homeDirectory, homeDirectory,
clientIds, clientIds,
claudeCodeSkillsDirectory(homeDirectory, environment), environment,
platform,
), ),
clientRegistrations: () => clientRegistrations: () =>
readClientRegistrationStatuses(homeDirectory, undefined, { readClientRegistrationStatuses(homeDirectory, undefined, {
+202
View File
@@ -0,0 +1,202 @@
import { realpath } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { stripComments } from "jsonc-parser";
import { z } from "zod";
import { err, ok, type Result } from "../domain/result.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
import {
clientConfigurationValuesEqual,
clientRegistrationEntry,
geminiServerPolicyBlock,
parseGeminiMcpPolicy,
type ClientConfigurationDocument,
} from "./ClientConfigurationDocument.js";
import {
readConfigurationFile,
readConfigurationText,
type ClientConfigurationFileError,
} from "./ClientConfigurationFiles.js";
import type { SetupClient } from "./SupportedClients.js";
type SettingsSource = { path: string; document: ClientConfigurationDocument };
const folderTrustSchema = z
.object({
security: z
.object({
folderTrust: z
.object({ enabled: z.boolean().optional() })
.passthrough()
.optional(),
})
.passthrough()
.optional(),
})
.passthrough();
const trustRulesSchema = z.record(
z.string(),
z.enum(["TRUST_FOLDER", "TRUST_PARENT", "DO_NOT_TRUST"]),
);
const sourceFailure = (
path: string,
cause: unknown,
): ClientConfigurationFileError => ({
kind: "malformed",
path,
detail: `Gemini settings in ${path} are malformed: ${cause instanceof Error ? cause.message : String(cause)}`,
});
// Gemini resolves symlinks when possible and compares macOS/Windows paths without case.
const normalizedPath = async (
path: string,
platform: NodeJS.Platform,
): Promise<string> => {
const physical = await realpath(path).catch(() => path);
const absolute = resolve(physical).replaceAll("\\", "/");
return platform === "win32" || platform === "darwin"
? absolute.toLowerCase()
: absolute;
};
const workspaceTrusted = async (
settings: NonNullable<SetupClient["geminiSettings"]>,
sources: readonly SettingsSource[],
): Promise<Result<boolean, ClientConfigurationFileError>> => {
let enabled = true;
for (const { path, document } of sources) {
const parsed = folderTrustSchema.safeParse(document.document);
if (!parsed.success) return err(sourceFailure(path, parsed.error));
enabled = parsed.data.security?.folderTrust?.enabled ?? enabled;
}
if (settings.trustOverride !== undefined) return ok(settings.trustOverride);
if (!enabled) return ok(true);
const text = await readConfigurationText(settings.trustedFoldersPath);
if (!text.ok) return text;
if (text.value === undefined) return ok(false);
let rules: z.output<typeof trustRulesSchema>;
try {
// Gemini strips comments, but rejects trailing commas, BOMs and invalid trust levels.
rules = trustRulesSchema.parse(JSON.parse(stripComments(text.value)));
} catch (cause: unknown) {
return err(sourceFailure(settings.trustedFoldersPath, cause));
}
const workspace = await normalizedPath(
settings.workspaceDirectory,
settings.platform,
);
let longest = -1;
let trusted = false;
for (const [path, level] of Object.entries(rules)) {
const effective = await normalizedPath(
level === "TRUST_PARENT" ? dirname(path) : path,
settings.platform,
);
if (
(workspace === effective ||
workspace.startsWith(
effective.endsWith("/") ? effective : `${effective}/`,
)) &&
path.length > longest
) {
longest = path.length;
trusted = level !== "DO_NOT_TRUST";
}
}
return ok(trusted);
};
/** Check effective Gemini scopes while preserving their authority and source identity. */
export const readClientPolicyBlock = async (
client: SetupClient,
userDocument?: ClientConfigurationDocument,
desiredRegistration?: unknown,
): Promise<Result<string | undefined, ClientConfigurationFileError>> => {
if (client.name !== "gemini_cli" || client.geminiSettings === undefined)
return ok(undefined);
const settings = client.geminiSettings;
const sources: SettingsSource[] = [];
const higher: SettingsSource[] = [];
for (const path of [
settings.defaultsPath,
client.configPath,
settings.systemPath,
]) {
const parsed =
path === client.configPath && userDocument !== undefined
? ok(userDocument)
: await readConfigurationFile(path, "json");
if (!parsed.ok) return parsed;
if (parsed.value !== undefined)
sources.push({ path, document: parsed.value });
}
const workspace = await normalizedPath(
settings.workspaceDirectory,
settings.platform,
);
const home = await normalizedPath(
dirname(dirname(client.configPath)),
settings.platform,
);
if (workspace !== home) {
const path = resolve(
settings.workspaceDirectory,
".gemini",
"settings.json",
);
const document = await readConfigurationFile(path, "json");
if (!document.ok) return document;
if (document.value !== undefined) {
const trusted = await workspaceTrusted(settings, sources);
if (!trusted.ok) return trusted;
if (trusted.value) {
const source = { path, document: document.value };
const systemIndex = sources.findIndex(
(item) => item.path === settings.systemPath,
);
sources.splice(
systemIndex < 0 ? sources.length : systemIndex,
0,
source,
);
higher.push(source);
}
}
}
const system = sources.find(({ path }) => path === settings.systemPath);
if (system !== undefined) higher.push(system);
const policies: ReturnType<typeof parseGeminiMcpPolicy>[] = [];
for (const { path, document } of sources) {
try {
policies.push(parseGeminiMcpPolicy(document));
} catch (cause: unknown) {
return err(sourceFailure(path, cause));
}
}
const policy = geminiServerPolicyBlock(
policies,
PRODUCT_IDENTITY.mcpServerKey,
);
if (policy !== undefined)
return ok(
`${policy} Settings sources: ${sources.map(({ path }) => path).join(", ")}.`,
);
// mcpServers uses shallow merge: the entire same-name definition is replaced.
const override = higher.findLast(({ document }) =>
Object.hasOwn(document.servers, PRODUCT_IDENTITY.mcpServerKey),
);
const desired =
desiredRegistration ??
userDocument?.servers[PRODUCT_IDENTITY.mcpServerKey] ??
clientRegistrationEntry("json", npxRegistrationCommand(), {});
if (
override !== undefined &&
!clientConfigurationValuesEqual(
override.document.servers[PRODUCT_IDENTITY.mcpServerKey],
desired,
)
)
return ok(
`Gemini mcpServers.${PRODUCT_IDENTITY.mcpServerKey} in ${override.path} overrides the user registration. Review that settings file before rerunning setup; REA does not modify system or workspace settings.`,
);
return ok(undefined);
};
+33
View File
@@ -0,0 +1,33 @@
import { expect, it } from "vitest";
import { createProgressReporter } from "./ProgressReporter.js";
it("limits intermediate sends to 100 ms and always delivers terminal status", async () => {
let now = 0;
const sent: number[] = [];
const reporter = createProgressReporter(
async () => {
sent.push(now);
},
{ now: () => now },
);
for (const [completed, elapsed] of [0, 25, 50, 99, 100, 199, 200].entries()) {
now = elapsed;
await reporter.report({
phase: "running",
completed,
total: null,
message: "Observed capture status",
});
}
expect(sent).toEqual([0, 100, 200]);
now = 201;
await reporter.report({
phase: "cleanup",
completed: 7,
total: null,
message: "Owned cleanup complete",
terminal: true,
});
expect(sent).toEqual([0, 100, 200, 201]);
});
@@ -1,6 +1,10 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { posix } from "node:path"; import { posix } from "node:path";
import {
logicalPathEscapesRoot,
normalizeJoinedLogicalPath,
} from "../domain/artifactIdentity.js";
import { import {
classifyReferenceSourcePath, classifyReferenceSourcePath,
detectReferenceSourceLanguage, detectReferenceSourceLanguage,
@@ -83,10 +87,10 @@ const resolveInternalSpecifier = (
): { to: string; resolution: "internal" | "unresolved" } => { ): { to: string; resolution: "internal" | "unresolved" } => {
if (posix.isAbsolute(specifier)) if (posix.isAbsolute(specifier))
return { to: specifier, resolution: "unresolved" }; return { to: specifier, resolution: "unresolved" };
const normalized = posix.normalize( const normalized = normalizeJoinedLogicalPath(
posix.join(posix.dirname(fromPath), specifier), posix.join(posix.dirname(fromPath), specifier),
); );
if (normalized.startsWith("../") || normalized === "..") if (logicalPathEscapesRoot(normalized))
return { to: normalized, resolution: "unresolved" }; return { to: normalized, resolution: "unresolved" };
const suffixes = ["", ".ts", ".js", ".mjs", ".cjs", ".tsx", ".jsx"]; const suffixes = ["", ".ts", ".js", ".mjs", ".cjs", ".tsx", ".jsx"];
const candidates = suffixes.flatMap((suffix) => [ const candidates = suffixes.flatMap((suffix) => [
+2 -2
View File
@@ -2,8 +2,6 @@ import fs from "node:fs";
import { lstat, readFile } from "node:fs/promises"; import { lstat, readFile } from "node:fs/promises";
import { join, resolve } from "node:path"; import { join, resolve } from "node:path";
import { resolveRef } from "isomorphic-git";
export type ReferenceSourceVcsInfo = export type ReferenceSourceVcsInfo =
| { | {
readonly kind: "git"; readonly kind: "git";
@@ -42,6 +40,8 @@ export const readReferenceSourceVcs = async (
}; };
const resolveSourceHead = async (root: string): Promise<string> => { const resolveSourceHead = async (root: string): Promise<string> => {
// isomorphic-git is loaded on first use so CLI and MCP startup skip it.
const { resolveRef } = await import("isomorphic-git");
const dotgit = join(root, ".git"); const dotgit = join(root, ".git");
if (!(await lstat(dotgit)).isFile()) if (!(await lstat(dotgit)).isFile())
return resolveRef({ fs, dir: root, ref: "HEAD" }); return resolveRef({ fs, dir: root, ref: "HEAD" });
+4 -1
View File
@@ -1,3 +1,4 @@
import { skillDestinations } from "./SetupSkill.js";
import { import {
type ClientConfigurationInspection, type ClientConfigurationInspection,
type ClientConfigurationResult, type ClientConfigurationResult,
@@ -18,7 +19,9 @@ import { setupRegistrationCommand } from "./SetupHost.js";
export class FakeSetupHost implements SetupHost { export class FakeSetupHost implements SetupHost {
readonly platform: NodeJS.Platform; readonly platform: NodeJS.Platform;
readonly homeDirectory = "/fixture/home"; readonly homeDirectory = "/fixture/home";
readonly claudeCodeSkillsDirectory = "/fixture/home/.claude/skills"; skillDestinations(clientIds: readonly string[]) {
return skillDestinations(this.homeDirectory, clientIds);
}
readonly registrationCommand: readonly string[]; readonly registrationCommand: readonly string[];
nodeVersion = "24.18.0"; nodeVersion = "24.18.0";
version: string | undefined = "14.5"; version: string | undefined = "14.5";
+70 -1
View File
@@ -1,3 +1,5 @@
import { readClientPolicyBlock } from "./GeminiClientSettings.js";
import { readClientConfigurationFiles } from "./ClientConfigurationFiles.js";
import { import {
clientRegistrationEntry, clientRegistrationEntry,
clientConfigurationValuesEqual, clientConfigurationValuesEqual,
@@ -33,6 +35,8 @@ export const configureClientConfiguration = (
environment: SetupProviderEnvironment = {}, environment: SetupProviderEnvironment = {},
command: readonly string[] = npxRegistrationCommand(), command: readonly string[] = npxRegistrationCommand(),
): Promise<ClientConfigurationResult> => { ): Promise<ClientConfigurationResult> => {
if (client.configPathError !== undefined)
return Promise.resolve({ status: "failed", reason: "path" });
if (client.format === undefined || client.format === "unsupported") if (client.format === undefined || client.format === "unsupported")
return Promise.resolve({ status: "failed", reason: "readback" }); return Promise.resolve({ status: "failed", reason: "readback" });
return configureClientDocument(client, environment, command, client.format); return configureClientDocument(client, environment, command, client.format);
@@ -44,6 +48,15 @@ const configureClientDocument = async (
command: readonly string[], command: readonly string[],
format: NonNullable<SetupClient["format"]>, format: NonNullable<SetupClient["format"]>,
): Promise<ClientConfigurationResult> => { ): Promise<ClientConfigurationResult> => {
const files = await readClientConfigurationFiles(client);
if (!files.ok) return { status: "failed", reason: "readback" };
const policy = await readClientPolicyBlock(
client,
undefined,
clientConfigurationDesired(client, environment, command, client.format),
);
if (!policy.ok || policy.value !== undefined)
return { status: "failed", reason: "readback" };
const transactionPath = await resolveClientConfigTransactionPath( const transactionPath = await resolveClientConfigTransactionPath(
client.configPath, client.configPath,
); );
@@ -61,6 +74,13 @@ const configureClientDocument = async (
original ?? (format === "toml" || format === "grok" ? "" : "{}"), original ?? (format === "toml" || format === "grok" ? "" : "{}"),
format, format,
); );
const policy = await readClientPolicyBlock(
client,
parsed,
clientConfigurationDesired(client, environment, command, parsed.dialect),
);
if (!policy.ok || policy.value !== undefined)
return { status: "failed", reason: "readback" };
} catch (cause: unknown) { } catch (cause: unknown) {
// Malformed existing configuration fails the readback gate. // Malformed existing configuration fails the readback gate.
void cause; void cause;
@@ -154,10 +174,14 @@ export const clientConfigurationAligned = async (
providerEnvironment: SetupProviderEnvironment, providerEnvironment: SetupProviderEnvironment,
command: readonly string[], command: readonly string[],
): Promise<boolean> => { ): Promise<boolean> => {
if (client.configPathError !== undefined) return false;
try { try {
const files = await readClientConfigurationFiles(client);
if (!files.ok) return false;
const original = await readFile(client.configPath, "utf8"); const original = await readFile(client.configPath, "utf8");
const parsed = parseClientConfiguration(original, client.format); const parsed = parseClientConfiguration(original, client.format);
return registrationCurrent( const policy = await readClientPolicyBlock(
client,
parsed, parsed,
clientConfigurationDesired( clientConfigurationDesired(
client, client,
@@ -166,6 +190,19 @@ export const clientConfigurationAligned = async (
parsed.dialect, parsed.dialect,
), ),
); );
return (
policy.ok &&
policy.value === undefined &&
registrationCurrent(
parsed,
clientConfigurationDesired(
client,
providerEnvironment,
command,
parsed.dialect,
),
)
);
} catch (cause: unknown) { } catch (cause: unknown) {
// Unreadable configuration is treated as not aligned so setup repairs it. // Unreadable configuration is treated as not aligned so setup repairs it.
void cause; void cause;
@@ -179,7 +216,25 @@ export const inspectClientConfiguration = async (
providerEnvironment: SetupProviderEnvironment, providerEnvironment: SetupProviderEnvironment,
command: readonly string[], command: readonly string[],
): Promise<ClientConfigurationInspection> => { ): Promise<ClientConfigurationInspection> => {
if (client.configPathError !== undefined)
return { status: "invalid", remediation: client.configPathError };
if (client.format === "unsupported") return { status: "already_current" }; if (client.format === "unsupported") return { status: "already_current" };
const files = await readClientConfigurationFiles(client);
if (!files.ok) return { status: "invalid", remediation: files.error.detail };
const policy = await readClientPolicyBlock(
client,
undefined,
clientConfigurationDesired(
client,
providerEnvironment,
command,
client.format,
),
);
if (!policy.ok)
return { status: "invalid", remediation: policy.error.detail };
if (policy.value !== undefined)
return { status: "invalid", remediation: policy.value };
const transactionPath = await resolveClientConfigTransactionPath( const transactionPath = await resolveClientConfigTransactionPath(
client.configPath, client.configPath,
); );
@@ -202,6 +257,20 @@ export const inspectClientConfiguration = async (
} }
try { try {
const parsed = parseClientConfiguration(original, client.format); const parsed = parseClientConfiguration(original, client.format);
const policy = await readClientPolicyBlock(
client,
parsed,
clientConfigurationDesired(
client,
providerEnvironment,
command,
parsed.dialect,
),
);
if (!policy.ok)
return { status: "invalid", remediation: policy.error.detail };
if (policy.value !== undefined)
return { status: "invalid", remediation: policy.value };
const desired = clientConfigurationDesired( const desired = clientConfigurationDesired(
client, client,
providerEnvironment, providerEnvironment,
+19 -14
View File
@@ -14,9 +14,13 @@ import {
systemLinuxHopperInstallHost, systemLinuxHopperInstallHost,
} from "./LinuxHopper.js"; } from "./LinuxHopper.js";
import { installMacHopper, systemMacHopperInstallHost } from "./MacHopper.js"; import { installMacHopper, systemMacHopperInstallHost } from "./MacHopper.js";
import { supportedClients, type SetupClient } from "./SupportedClients.js";
import { import {
claudeCodeSkillsDirectory as resolveClaudeCodeSkillsDirectory, clientEvidencePaths,
supportedClients,
type SetupClient,
} from "./SupportedClients.js";
import {
skillDestinations,
canonicalSkillNeedsInstall, canonicalSkillNeedsInstall,
installCanonicalSkill, installCanonicalSkill,
} from "./SetupSkill.js"; } from "./SetupSkill.js";
@@ -90,14 +94,11 @@ export const systemSetupHost = (
const doctorHost = selectedDoctorHost ?? systemDoctorHost({ environment }); const doctorHost = selectedDoctorHost ?? systemDoctorHost({ environment });
const platform = doctorHost.platform; const platform = doctorHost.platform;
const { homeDirectory } = doctorHost; const { homeDirectory } = doctorHost;
const claudeSkillsDirectory = resolveClaudeCodeSkillsDirectory(
homeDirectory,
environment,
);
return { return {
platform, platform,
homeDirectory, homeDirectory,
claudeCodeSkillsDirectory: claudeSkillsDirectory, skillDestinations: (clientIds) =>
skillDestinations(homeDirectory, clientIds, environment, platform),
registrationCommand: setupRegistrationCommand( registrationCommand: setupRegistrationCommand(
platform, platform,
environment.npm_command === "exec", environment.npm_command === "exec",
@@ -153,10 +154,11 @@ export const systemSetupHost = (
canonicalSkillNeedsInstall( canonicalSkillNeedsInstall(
homeDirectory, homeDirectory,
clientIds, clientIds,
claudeSkillsDirectory, environment,
platform,
), ),
installSkill: (clientIds) => installSkill: (clientIds) =>
installCanonicalSkill(homeDirectory, clientIds, claudeSkillsDirectory), installCanonicalSkill(homeDirectory, clientIds, environment, platform),
doctor: (scope) => runDoctor(undefined, doctorHost, scope), doctor: (scope) => runDoctor(undefined, doctorHost, scope),
}; };
}; };
@@ -169,17 +171,20 @@ export const detectClients = async (
): Promise<readonly SetupClient[]> => { ): Promise<readonly SetupClient[]> => {
const detected: SetupClient[] = []; const detected: SetupClient[] = [];
for (const candidate of supportedClients(home, platform, environment)) { for (const candidate of supportedClients(home, platform, environment)) {
const [hasConfig, hasMarker] = await Promise.all([ if (candidate.configPathError !== undefined) continue;
exists(candidate.configPath), if (await clientEvidencePresent(candidate)) detected.push(candidate);
candidate.markerPath === undefined ? false : exists(candidate.markerPath),
]);
if (hasConfig || hasMarker) detected.push(candidate);
} }
return detected; return detected;
}; };
const major = (version: string): number => const major = (version: string): number =>
Number.parseInt(version.split(".")[0] ?? "0", 10); Number.parseInt(version.split(".")[0] ?? "0", 10);
const clientEvidencePresent = async (client: SetupClient): Promise<boolean> => {
for (const path of clientEvidencePaths(client))
if (await exists(path)) return true;
return false;
};
const exists = async (path: string): Promise<boolean> => { const exists = async (path: string): Promise<boolean> => {
try { try {
await access(path); await access(path);
+5 -12
View File
@@ -5,7 +5,7 @@ import {
linuxHopperInstallDisclosure, linuxHopperInstallDisclosure,
type LinuxPackageFamily, type LinuxPackageFamily,
} from "./LinuxHopper.js"; } from "./LinuxHopper.js";
import { skillDestinations } from "./SetupSkill.js"; import type { SkillDestination } from "./SetupSkill.js";
import { macHopperInstallDisclosure } from "./MacHopper.js"; import { macHopperInstallDisclosure } from "./MacHopper.js";
import type { import type {
SetupAction, SetupAction,
@@ -188,7 +188,7 @@ export const planSetupActions = async (input: {
installHopper: input.discovery.installHopper, installHopper: input.discovery.installHopper,
installSkill: input.installSkill, installSkill: input.installSkill,
skillClientIds: input.skillClientIds, skillClientIds: input.skillClientIds,
claudeCodeSkillsDirectory: input.host.claudeCodeSkillsDirectory, skillDestinations: input.host.skillDestinations(input.skillClientIds),
clients: clientPlans, clients: clientPlans,
providerEnvironment: input.providerEnvironment, providerEnvironment: input.providerEnvironment,
...(input.discovery.linuxPackageFamily === undefined ...(input.discovery.linuxPackageFamily === undefined
@@ -210,7 +210,7 @@ const setupPlan = (input: {
readonly installHopper: boolean; readonly installHopper: boolean;
readonly installSkill: boolean; readonly installSkill: boolean;
readonly skillClientIds: readonly string[]; readonly skillClientIds: readonly string[];
readonly claudeCodeSkillsDirectory: string; readonly skillDestinations: readonly SkillDestination[];
readonly clients: readonly { readonly clients: readonly {
readonly client: SetupClient; readonly client: SetupClient;
readonly operation: "create" | "update"; readonly operation: "create" | "update";
@@ -268,15 +268,8 @@ const setupPlan = (input: {
...(backupPath === undefined ? {} : { backupPath }), ...(backupPath === undefined ? {} : { backupPath }),
})), })),
...(input.installSkill ...(input.installSkill
? skillDestinations( ? input.skillDestinations.map(({ client, path }): SetupAction => ({
input.homeDirectory, id: client === "shared" ? "install_skill" : `install_skill:${client}`,
input.skillClientIds,
input.claudeCodeSkillsDirectory,
).map(({ client, path }): SetupAction => ({
id:
client === "claude_code"
? "install_skill:claude_code"
: "install_skill",
kind: "install_skill", kind: "install_skill",
label: label:
client === "claude_code" client === "claude_code"
+27 -44
View File
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
import writeFileAtomic from "write-file-atomic"; import writeFileAtomic from "write-file-atomic";
import { PRODUCT_IDENTITY } from "../identity.js"; import { PRODUCT_IDENTITY } from "../identity.js";
import { clientSkillDirectories } from "./SupportedClients.js";
const SKILL_FILES = [ const SKILL_FILES = [
"SKILL.md", "SKILL.md",
@@ -21,7 +22,7 @@ interface CanonicalSkillFile {
/** Managed skill location selected for a client family. */ /** Managed skill location selected for a client family. */
export interface SkillDestination { export interface SkillDestination {
readonly client: "shared" | "claude_code"; readonly client: string;
readonly path: string; readonly path: string;
} }
@@ -32,44 +33,19 @@ export interface InstalledSkillIdentity {
readonly canonical: boolean; readonly canonical: boolean;
} }
/** Resolve Claude Code personal skills from the selected environment. */
export const claudeCodeSkillsDirectory = (
home: string,
environment: { readonly CLAUDE_CONFIG_DIR?: string } = {},
): string =>
join(environment.CLAUDE_CONFIG_DIR ?? join(home, ".claude"), "skills");
/** Resolve the skill roots required by a client selection, or all owned roots. */ /** Resolve the skill roots required by a client selection, or all owned roots. */
export const skillDestinations = ( export const skillDestinations = (
home: string, home: string,
clientIds: readonly string[] | undefined, clientIds: readonly string[] | undefined,
claudeSkillsDirectory = claudeCodeSkillsDirectory(home), environment: Readonly<NodeJS.ProcessEnv> = {},
): readonly SkillDestination[] => { platform: NodeJS.Platform = process.platform,
const includeClaude = ): readonly SkillDestination[] =>
clientIds === undefined || clientIds.includes("claude_code"); clientSkillDirectories(home, clientIds, environment, platform).map(
const includeShared = ({ client, directory }) => ({
clientIds === undefined || client,
clientIds.length === 0 || path: join(directory, PRODUCT_IDENTITY.skillName),
clientIds.some((clientId) => clientId !== "claude_code"); }),
return [ );
...(includeShared
? [
{
client: "shared" as const,
path: join(home, ".agents/skills", PRODUCT_IDENTITY.skillName),
},
]
: []),
...(includeClaude
? [
{
client: "claude_code" as const,
path: join(claudeSkillsDirectory, PRODUCT_IDENTITY.skillName),
},
]
: []),
];
};
const readOptionalText = async (path: string): Promise<string | undefined> => { const readOptionalText = async (path: string): Promise<string | undefined> => {
try { try {
@@ -84,10 +60,11 @@ const readOptionalText = async (path: string): Promise<string | undefined> => {
const canonicalSkillFiles = async ( const canonicalSkillFiles = async (
home: string, home: string,
clientIds: readonly string[], clientIds: readonly string[],
claudeSkillsDirectory?: string, environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
): Promise<readonly CanonicalSkillFile[]> => ): Promise<readonly CanonicalSkillFile[]> =>
Promise.all( Promise.all(
skillDestinations(home, clientIds, claudeSkillsDirectory).flatMap( skillDestinations(home, clientIds, environment, platform).flatMap(
({ path: root }) => ({ path: root }) =>
SKILL_FILES.map(async (relativePath) => { SKILL_FILES.map(async (relativePath) => {
const destination = join(root, relativePath); const destination = join(root, relativePath);
@@ -110,11 +87,12 @@ const canonicalSkillFiles = async (
export const canonicalSkillNeedsInstall = async ( export const canonicalSkillNeedsInstall = async (
home: string, home: string,
clientIds: readonly string[] = [], clientIds: readonly string[] = [],
claudeSkillsDirectory?: string, environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
): Promise<boolean> => { ): Promise<boolean> => {
try { try {
return ( return (
await canonicalSkillFiles(home, clientIds, claudeSkillsDirectory) await canonicalSkillFiles(home, clientIds, environment, platform)
).some(({ content, original }) => original !== content); ).some(({ content, original }) => original !== content);
} catch (cause: unknown) { } catch (cause: unknown) {
// Unreadable skill state fails open to install so setup can repair it. // Unreadable skill state fails open to install so setup can repair it.
@@ -139,14 +117,16 @@ const restoreSkillFiles = async (
export const installCanonicalSkill = async ( export const installCanonicalSkill = async (
home: string, home: string,
clientIds: readonly string[] = [], clientIds: readonly string[] = [],
claudeSkillsDirectory?: string, environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
): Promise<"installed" | "unchanged" | "failed"> => { ): Promise<"installed" | "unchanged" | "failed"> => {
let changed: readonly CanonicalSkillFile[] = []; let changed: readonly CanonicalSkillFile[] = [];
try { try {
const canonical = await canonicalSkillFiles( const canonical = await canonicalSkillFiles(
home, home,
clientIds, clientIds,
claudeSkillsDirectory, environment,
platform,
); );
changed = canonical.filter(({ content, original }) => original !== content); changed = canonical.filter(({ content, original }) => original !== content);
if (changed.length === 0) return "unchanged"; if (changed.length === 0) return "unchanged";
@@ -179,12 +159,14 @@ export const installCanonicalSkill = async (
export const readInstalledSkillIdentity = async ( export const readInstalledSkillIdentity = async (
home: string, home: string,
clientIds: readonly string[], clientIds: readonly string[],
claudeSkillsDirectory?: string, environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
): Promise<InstalledSkillIdentity | undefined> => { ): Promise<InstalledSkillIdentity | undefined> => {
const destinations = skillDestinations( const destinations = skillDestinations(
home, home,
clientIds, clientIds,
claudeSkillsDirectory, environment,
platform,
); );
let content: string | undefined; let content: string | undefined;
for (const destination of destinations) { for (const destination of destinations) {
@@ -202,7 +184,8 @@ export const readInstalledSkillIdentity = async (
canonical: !(await canonicalSkillNeedsInstall( canonical: !(await canonicalSkillNeedsInstall(
home, home,
clientIds, clientIds,
claudeSkillsDirectory, environment,
platform,
)), )),
version: /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1] ?? null, version: /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1] ?? null,
toolCount: toolCount === undefined ? null : Number.parseInt(toolCount, 10), toolCount: toolCount === undefined ? null : Number.parseInt(toolCount, 10),
+2 -1
View File
@@ -1,5 +1,6 @@
import type { DoctorReport, DoctorScope, runDoctor } from "./Doctor.js"; import type { DoctorReport, DoctorScope, runDoctor } from "./Doctor.js";
import type { LinuxDistribution } from "./LinuxHopper.js"; import type { LinuxDistribution } from "./LinuxHopper.js";
import type { SkillDestination } from "./SetupSkill.js";
import type { SetupClient } from "./SupportedClients.js"; import type { SetupClient } from "./SupportedClients.js";
import type { import type {
SetupFailureCode, SetupFailureCode,
@@ -42,7 +43,7 @@ export type ClientConfigurationInspection =
export interface SetupHost { export interface SetupHost {
readonly platform: NodeJS.Platform; readonly platform: NodeJS.Platform;
readonly homeDirectory: string; readonly homeDirectory: string;
readonly claudeCodeSkillsDirectory: string; skillDestinations(clientIds: readonly string[]): readonly SkillDestination[];
readonly registrationCommand: readonly string[]; readonly registrationCommand: readonly string[];
readonly nodeVersion: string; readonly nodeVersion: string;
macosVersion(): Promise<string | undefined>; macosVersion(): Promise<string | undefined>;
+354 -60
View File
@@ -1,4 +1,5 @@
import { isAbsolute, join } from "node:path"; import { dirname, isAbsolute, join, resolve, win32 } from "node:path";
import { fileURLToPath } from "node:url";
import { lstatSync } from "node:fs"; import { lstatSync } from "node:fs";
/** One supported client configuration location. */ /** One supported client configuration location. */
@@ -6,6 +7,19 @@ export interface SetupClient {
readonly name: string; readonly name: string;
readonly displayName?: string; readonly displayName?: string;
readonly configPath: string; readonly configPath: string;
/** User configuration files in increasing precedence, including the write target. */
readonly configPaths?: readonly string[];
/** Read-only Gemini scopes and trust inputs; never setup write targets. */
readonly geminiSettings?: {
readonly systemPath: string;
readonly defaultsPath: string;
readonly workspaceDirectory: string;
readonly trustedFoldersPath: string;
readonly trustOverride?: boolean;
readonly platform: NodeJS.Platform;
};
/** Unresolved path evidence only; consumers must not perform I/O on it. */
readonly configPathError?: string;
readonly markerPath?: string; readonly markerPath?: string;
readonly format?: readonly format?:
| "json" | "json"
@@ -16,6 +30,8 @@ export interface SetupClient {
| "commandcode" | "commandcode"
| "grok" | "grok"
| "omp" | "omp"
| "pi"
| "hermes"
| "unsupported"; | "unsupported";
} }
@@ -24,20 +40,7 @@ type ClientPath = readonly string[] | ((paths: ClientPathContext) => string);
interface ClientPathContext { interface ClientPathContext {
readonly home: string; readonly home: string;
readonly platform: NodeJS.Platform; readonly platform: NodeJS.Platform;
readonly env: { readonly env: Readonly<NodeJS.ProcessEnv>;
readonly APPDATA?: string | undefined;
readonly CLAUDE_CONFIG_DIR?: string | undefined;
readonly CODEX_HOME?: string | undefined;
readonly COPILOT_HOME?: string | undefined;
readonly GROK_HOME?: string | undefined;
readonly OMP_PROFILE?: string | undefined;
readonly OPENCODE_CONFIG?: string | undefined;
readonly PI_CODING_AGENT_DIR?: string | undefined;
readonly PI_CONFIG_DIR?: string | undefined;
readonly PI_PROFILE?: string | undefined;
readonly SAND_DATA_ROOT?: string | undefined;
readonly XDG_CONFIG_HOME?: string | undefined;
};
} }
interface ClientDefinition { interface ClientDefinition {
@@ -46,6 +49,11 @@ interface ClientDefinition {
readonly configPath: ClientPath; readonly configPath: ClientPath;
readonly markerPath: ClientPath; readonly markerPath: ClientPath;
readonly format: NonNullable<SetupClient["format"]>; readonly format: NonNullable<SetupClient["format"]>;
readonly skillPath?: ClientPath;
readonly configPaths?: (context: ClientPathContext) => readonly string[];
readonly geminiSettings?: (
context: ClientPathContext,
) => NonNullable<SetupClient["geminiSettings"]>;
} }
const vscodeUserDirectory = ({ const vscodeUserDirectory = ({
@@ -83,10 +91,73 @@ const claudeCodeMarkerDirectory = ({ home, env }: ClientPathContext): string =>
env.CLAUDE_CONFIG_DIR ?? join(home, ".claude"); env.CLAUDE_CONFIG_DIR ?? join(home, ".claude");
const codexDirectory = ({ home, env }: ClientPathContext): string => const codexDirectory = ({ home, env }: ClientPathContext): string =>
env.CODEX_HOME ?? join(home, ".codex"); env.CODEX_HOME || join(home, ".codex");
/** Match Qwen Code's home override, including tilde and cwd-relative paths. */
const qwenCodeDirectory = ({ home, env }: ClientPathContext): string => {
const override = env.QWEN_HOME;
if (!override) return join(home, ".qwen");
const expanded =
override === "~"
? home
: override.startsWith("~/") || override.startsWith("~\\")
? join(
home,
...override
.slice(2)
.split(/[/\\]+/)
.filter(Boolean),
)
: override;
return isAbsolute(expanded) ? expanded : resolve(expanded);
};
const grokDirectory = ({ home, env }: ClientPathContext): string => const grokDirectory = ({ home, env }: ClientPathContext): string =>
env.GROK_HOME ?? join(home, ".grok"); env.GROK_HOME || join(home, ".grok");
/**
* Hermes Agent home. `HERMES_HOME` wins; otherwise the platform default mirrors
* Hermes's own resolver (`%LOCALAPPDATA%\\hermes` on Windows, `~/.hermes`
* elsewhere), including the optional `HERMES_DATA_DIR_SUFFIX` that is appended
* to the literal `hermes`/`.hermes` directory name.
*/
const hermesDirectory = ({
home,
platform,
env,
}: ClientPathContext): string => {
const override = env.HERMES_HOME?.trim();
if (override) {
const expandedVariables = override.replace(
/\$(\w+|\{[^}]*\})/gu,
(match, variable: string) => {
const key = variable.startsWith("{") ? variable.slice(1, -1) : variable;
return env[key] ?? match;
},
);
const expanded =
platform === "win32"
? expandedVariables.replace(
/%([^%]+)%/gu,
(match, variable: string) => env[variable] ?? match,
)
: expandedVariables;
if (expanded === "~") return home;
if (
expanded.startsWith("~/") ||
(platform === "win32" && expanded.startsWith("~\\"))
)
return join(home, expanded.slice(2));
return resolve(expanded);
}
const suffix = env.HERMES_DATA_DIR_SUFFIX ?? "";
return platform === "win32"
? join(
env.LOCALAPPDATA?.trim() || join(home, "AppData", "Local"),
`hermes${suffix}`,
)
: join(home, `.hermes${suffix}`);
};
/** Grok Bot uses an absolute SAND_DATA_ROOT; anything else stays ~/.grokbot. */ /** Grok Bot uses an absolute SAND_DATA_ROOT; anything else stays ~/.grokbot. */
const grokBotDirectory = ({ home, env }: ClientPathContext): string => { const grokBotDirectory = ({ home, env }: ClientPathContext): string => {
@@ -132,17 +203,49 @@ const ompProfile = ({ env }: ClientPathContext): string | undefined => {
/** /**
* The OMP agent directory whose `mcp.json` holds user-scope MCP servers. OMP * The OMP agent directory whose `mcp.json` holds user-scope MCP servers. OMP
* joins PI_CONFIG_DIR to the home directory, and a named profile ignores * joins PI_CONFIG_DIR to the home directory, and a named profile ignores
* PI_CODING_AGENT_DIR. OMP resolves a relative agent directory against each * PI_CODING_AGENT_DIR. Relative overrides use the setup process working directory.
* process's working directory, so only an absolute override names one file.
*/ */
const ompAgentDirectory = (context: ClientPathContext): string => { const ompAgentDirectory = (context: ClientPathContext): string => {
const root = join(context.home, context.env.PI_CONFIG_DIR || ".omp"); const root = join(context.home, context.env.PI_CONFIG_DIR || ".omp");
const profile = ompProfile(context); const profile = ompProfile(context);
if (profile !== undefined) return join(root, "profiles", profile, "agent"); if (profile !== undefined) return join(root, "profiles", profile, "agent");
const override = context.env.PI_CODING_AGENT_DIR; const override = context.env.PI_CODING_AGENT_DIR;
return override !== undefined && override !== "" && isAbsolute(override) return override ? resolve(override) : join(root, "agent");
? override };
: join(root, "agent");
/**
* Pi's public getAgentDir()/normalizePath semantics, not OMP's profiles.
* Keep relative overrides relative: filesystem consumers resolve them in cwd.
*/
const piAgentDirectory = ({
home,
platform,
env,
}: ClientPathContext): string => {
const paths = platform === "win32" ? win32 : { join };
let directory = env.PI_CODING_AGENT_DIR;
if (!directory) return paths.join(home, ".pi", "agent");
if (
platform === "win32" &&
directory.startsWith("/") &&
!directory.startsWith("//") &&
!directory.includes("\\")
) {
const drive = /^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/iu.exec(
directory,
);
if (drive !== null)
directory = `${drive[1]?.toUpperCase()}:\\${drive[2]?.replaceAll("/", "\\") ?? ""}`;
}
if (directory === "~") return home;
if (
directory.startsWith("~/") ||
(platform === "win32" && directory.startsWith("~\\"))
)
return paths.join(home, directory.slice(2));
if (directory.startsWith("file://"))
return fileURLToPath(directory, { windows: platform === "win32" });
return directory;
}; };
const copilotDirectory = ({ home, env }: ClientPathContext): string => const copilotDirectory = ({ home, env }: ClientPathContext): string =>
@@ -151,21 +254,66 @@ const copilotDirectory = ({ home, env }: ClientPathContext): string =>
const commandCodeDirectory = ({ home }: ClientPathContext): string => const commandCodeDirectory = ({ home }: ClientPathContext): string =>
join(home, ".commandcode"); join(home, ".commandcode");
const opencodeDirectory = ({ home, env }: ClientPathContext): string => /**
join(env.XDG_CONFIG_HOME ?? join(home, ".config"), "opencode"); * OpenCode appends `OPENCODE_CONFIG_DIR` only when the value is non-empty.
* An empty variable is the same as unset and leaves the XDG directory in place.
*/
const openCodeOverrideDirectory = (
env: ClientPathContext["env"],
): string | undefined => {
const directory = env.OPENCODE_CONFIG_DIR;
return directory === undefined || directory === "" ? undefined : directory;
};
const opencodeDirectory = (context: ClientPathContext): string =>
openCodeOverrideDirectory(context.env) ??
join(
context.env.XDG_CONFIG_HOME ?? join(context.home, ".config"),
"opencode",
);
/** OpenCode loads global JSON before JSONC, then explicit file and directory overrides. */
const openCodeConfigurationPaths = (
context: ClientPathContext,
): readonly string[] => {
const directory = join(
context.env.XDG_CONFIG_HOME ?? join(context.home, ".config"),
"opencode",
);
const overrideDirectory = openCodeOverrideDirectory(context.env);
const paths = [
join(directory, "config.json"),
join(directory, "opencode.json"),
join(directory, "opencode.jsonc"),
...(context.env.OPENCODE_CONFIG ? [context.env.OPENCODE_CONFIG] : []),
...(overrideDirectory !== undefined
? [
join(overrideDirectory, "opencode.json"),
join(overrideDirectory, "opencode.jsonc"),
]
: []),
];
const resolved = paths.map((path) => resolve(path));
return resolved.filter((path, index) => resolved.lastIndexOf(path) === index);
};
const existingOpenCodeConfigPath = (context: ClientPathContext): string => { const existingOpenCodeConfigPath = (context: ClientPathContext): string => {
if (context.env.OPENCODE_CONFIG !== undefined) // The selected directory is loaded after an explicit file by OpenCode V1.
return context.env.OPENCODE_CONFIG; const overrideDirectory = openCodeOverrideDirectory(context.env);
const directory = opencodeDirectory(context); const candidates =
const candidates = [ overrideDirectory !== undefined
"opencode.json", ? [
"opencode.jsonc", join(overrideDirectory, "opencode.json"),
".opencode.json", join(overrideDirectory, "opencode.jsonc"),
".opencode.jsonc", ].map((path) => resolve(path))
].map((filename) => join(directory, filename)); : context.env.OPENCODE_CONFIG
? [resolve(context.env.OPENCODE_CONFIG)]
: [
join(opencodeDirectory(context), "opencode.json"),
join(opencodeDirectory(context), "opencode.jsonc"),
].map((path) => resolve(path));
return ( return (
candidates.find((path) => { [...candidates].reverse().find((path) => {
try { try {
lstatSync(path); lstatSync(path);
return true; return true;
@@ -177,21 +325,60 @@ const existingOpenCodeConfigPath = (context: ClientPathContext): string => {
); );
} }
}) ?? }) ??
candidates[0] ?? resolve(
join(directory, "opencode.json") overrideDirectory !== undefined
? join(overrideDirectory, "opencode.json")
: context.env.OPENCODE_CONFIG ||
join(opencodeDirectory(context), "opencode.json"),
)
); );
}; };
const geminiSettings = ({
home,
platform,
env,
}: ClientPathContext): NonNullable<SetupClient["geminiSettings"]> => {
const system =
env.GEMINI_CLI_SYSTEM_SETTINGS_PATH ||
(platform === "darwin"
? "/Library/Application Support/GeminiCli/settings.json"
: platform === "win32"
? "C:\\ProgramData\\gemini-cli\\settings.json"
: "/etc/gemini-cli/settings.json");
const trustOverride =
env.GEMINI_RESTRICTED_MODE === "true" ||
env.GEMINI_CLI_TRUST_WORKSPACE === "false"
? false
: env.GEMINI_CLI_TRUST_WORKSPACE === "true"
? true
: undefined;
return {
systemPath: system,
defaultsPath:
env.GEMINI_CLI_SYSTEM_DEFAULTS_PATH ||
join(dirname(system), "system-defaults.json"),
workspaceDirectory: process.cwd(),
trustedFoldersPath:
env.GEMINI_CLI_TRUSTED_FOLDERS_PATH ||
join(env.GEMINI_CLI_HOME || home, ".gemini", "trustedFolders.json"),
...(trustOverride === undefined ? {} : { trustOverride }),
platform,
};
};
const devinDirectory = ({ home, platform, env }: ClientPathContext): string => const devinDirectory = ({ home, platform, env }: ClientPathContext): string =>
platform === "win32" platform === "win32"
? join(env.APPDATA ?? join(home, "AppData", "Roaming"), "devin") ? join(env.APPDATA ?? join(home, "AppData", "Roaming"), "devin")
: join(home, ".config", "devin"); : join(env.XDG_CONFIG_HOME || join(home, ".config"), "devin");
/** Stable product metadata used to derive setup discovery and documentation. */ /** Stable product metadata used to derive setup discovery and documentation. */
export const SUPPORTED_CLIENT_DEFINITIONS = [ export const SUPPORTED_CLIENT_DEFINITIONS = [
{ {
name: "claude_code", name: "claude_code",
displayName: "Claude Code", displayName: "Claude Code",
skillPath: ({ home, env }: ClientPathContext) =>
join(env.CLAUDE_CONFIG_DIR ?? join(home, ".claude"), "skills"),
configPath: (context: ClientPathContext) => configPath: (context: ClientPathContext) =>
join(claudeCodeConfigDirectory(context), ".claude.json"), join(claudeCodeConfigDirectory(context), ".claude.json"),
markerPath: claudeCodeMarkerDirectory, markerPath: claudeCodeMarkerDirectory,
@@ -223,8 +410,13 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
{ {
name: "gemini_cli", name: "gemini_cli",
displayName: "Gemini CLI", displayName: "Gemini CLI",
configPath: [".gemini", "settings.json"], geminiSettings,
markerPath: [".gemini"], configPath: ({ home, env }: ClientPathContext) =>
join(env.GEMINI_CLI_HOME || home, ".gemini", "settings.json"),
markerPath: ({ home, env }: ClientPathContext) =>
join(env.GEMINI_CLI_HOME || home, ".gemini"),
skillPath: ({ home, env }: ClientPathContext) =>
join(env.GEMINI_CLI_HOME || home, ".agents", "skills"),
format: "json", format: "json",
}, },
{ {
@@ -246,12 +438,14 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
name: "opencode", name: "opencode",
displayName: "OpenCode", displayName: "OpenCode",
configPath: existingOpenCodeConfigPath, configPath: existingOpenCodeConfigPath,
configPaths: openCodeConfigurationPaths,
markerPath: opencodeDirectory, markerPath: opencodeDirectory,
format: "opencode", format: "opencode",
}, },
{ {
name: "antigravity", name: "antigravity",
displayName: "Antigravity", displayName: "Antigravity",
skillPath: [".gemini", "config", "skills"],
configPath: [".gemini", "config", "mcp_config.json"], configPath: [".gemini", "config", "mcp_config.json"],
markerPath: [".gemini", "config"], markerPath: [".gemini", "config"],
format: "json", format: "json",
@@ -272,6 +466,14 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
markerPath: commandCodeDirectory, markerPath: commandCodeDirectory,
format: "commandcode", format: "commandcode",
}, },
{
name: "qwen_code",
displayName: "Qwen Code",
configPath: (context: ClientPathContext) =>
join(qwenCodeDirectory(context), "settings.json"),
markerPath: qwenCodeDirectory,
format: "json",
},
{ {
name: "vscode", name: "vscode",
displayName: "VS Code", displayName: "VS Code",
@@ -296,6 +498,27 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
markerPath: ompAgentDirectory, markerPath: ompAgentDirectory,
format: "omp", format: "omp",
}, },
{
name: "pi",
displayName: "Pi",
configPath: (context: ClientPathContext) =>
(context.platform === "win32" ? win32 : { join }).join(
piAgentDirectory(context),
"mcp.json",
),
markerPath: piAgentDirectory,
format: "pi",
},
{
name: "hermes",
displayName: "Hermes",
skillPath: (context: ClientPathContext) =>
join(hermesDirectory(context), "skills"),
configPath: (context: ClientPathContext) =>
join(hermesDirectory(context), "config.yaml"),
markerPath: hermesDirectory,
format: "hermes",
},
{ {
name: "grok_bot", name: "grok_bot",
displayName: "Grok Bot", displayName: "Grok Bot",
@@ -305,6 +528,23 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
}, },
] as const satisfies readonly ClientDefinition[]; ] as const satisfies readonly ClientDefinition[];
/**
* Paths whose presence means doctor and setup should inspect the client.
* Layered sources count: OpenCode still loads its XDG files when a custom
* directory override does not exist.
*/
export const clientEvidencePaths = (
client: Pick<SetupClient, "configPath" | "configPaths" | "markerPath">,
): readonly string[] => [
...new Set(
[
client.configPath,
...(client.configPaths ?? []),
client.markerPath,
].filter((path): path is string => path !== undefined && path.length > 0),
),
];
const resolvePath = (path: ClientPath, context: ClientPathContext): string => const resolvePath = (path: ClientPath, context: ClientPathContext): string =>
typeof path === "function" ? path(context) : join(context.home, ...path); typeof path === "function" ? path(context) : join(context.home, ...path);
@@ -312,27 +552,81 @@ const resolvePath = (path: ClientPath, context: ClientPathContext): string =>
export const supportedClients = ( export const supportedClients = (
home: string, home: string,
platform: NodeJS.Platform = process.platform, platform: NodeJS.Platform = process.platform,
env: ClientPathContext["env"] = { env: ClientPathContext["env"] = process.env,
APPDATA: process.env.APPDATA,
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
CODEX_HOME: process.env.CODEX_HOME,
COPILOT_HOME: process.env.COPILOT_HOME,
GROK_HOME: process.env.GROK_HOME,
OMP_PROFILE: process.env.OMP_PROFILE,
OPENCODE_CONFIG: process.env.OPENCODE_CONFIG,
PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR,
PI_CONFIG_DIR: process.env.PI_CONFIG_DIR,
PI_PROFILE: process.env.PI_PROFILE,
SAND_DATA_ROOT: process.env.SAND_DATA_ROOT,
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
},
): readonly SetupClient[] => { ): readonly SetupClient[] => {
const context = { home, platform, env }; const context = { home, platform, env };
return SUPPORTED_CLIENT_DEFINITIONS.map((definition) => ({ const definitions: readonly ClientDefinition[] = SUPPORTED_CLIENT_DEFINITIONS;
name: definition.name, return definitions.map((definition) => {
displayName: definition.displayName, try {
configPath: resolvePath(definition.configPath, context), const configPath = resolvePath(definition.configPath, context);
markerPath: resolvePath(definition.markerPath, context), return {
format: definition.format, name: definition.name,
})); displayName: definition.displayName,
configPath,
...(definition.geminiSettings === undefined
? {}
: { geminiSettings: definition.geminiSettings(context) }),
...(definition.configPaths === undefined
? {}
: {
configPaths: [
...new Set([...definition.configPaths(context), configPath]),
],
}),
markerPath: resolvePath(definition.markerPath, context),
format: definition.format,
};
} catch (cause: unknown) {
if (definition.name !== "pi") throw cause;
return {
name: definition.name,
displayName: definition.displayName,
format: definition.format,
configPath: env.PI_CODING_AGENT_DIR ?? "",
configPathError: `Invalid PI_CODING_AGENT_DIR: ${cause instanceof Error ? cause.message : String(cause)}. Repair the override before configuring Pi.`,
};
}
});
};
/** Resolve personal skill roots using the same client environment as setup. */
export const clientSkillDirectories = (
home: string,
clientIds: readonly string[] | undefined,
environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
): readonly { readonly client: string; readonly directory: string }[] => {
const definitions: readonly ClientDefinition[] = SUPPORTED_CLIENT_DEFINITIONS;
const selected =
clientIds === undefined
? [
"shared",
...definitions
.filter(({ skillPath }) => skillPath !== undefined)
.map(({ name }) => name),
]
: clientIds.length === 0
? ["shared"]
: clientIds;
const destinations = new Map<string, { client: string; directory: string }>();
for (const client of selected) {
const definition = definitions.find(({ name }) => name === client);
const directory =
definition?.skillPath === undefined
? join(home, ".agents", "skills")
: resolvePath(definition.skillPath, {
home,
platform,
env: environment,
});
if (!destinations.has(directory))
destinations.set(directory, {
client:
directory === join(home, ".agents", "skills") ? "shared" : client,
directory,
});
}
return [...destinations.values()].sort((left, right) =>
left.directory.localeCompare(right.directory),
);
}; };
+88 -15
View File
@@ -15,7 +15,7 @@ import writeFileAtomic from "write-file-atomic";
import { z } from "zod"; import { z } from "zod";
import { PRODUCT_IDENTITY } from "../identity.js"; import { PRODUCT_IDENTITY } from "../identity.js";
import { claudeCodeSkillsDirectory, skillDestinations } from "./SetupSkill.js"; import { skillDestinations } from "./SetupSkill.js";
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js"; import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js"; import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import { import {
@@ -124,11 +124,12 @@ export const runUninstall = async (
status: "retained", status: "retained",
detail: "Hopper is not owned by REA uninstall.", detail: "Hopper is not owned by REA uninstall.",
}); });
const reported = collapseUninstallItems(items);
return { return {
status: items.some(({ status }) => status === "failed") status: reported.some(({ status }) => status === "failed")
? "failed" ? "failed"
: "complete", : "complete",
items, items: reported,
}; };
}; };
@@ -171,7 +172,16 @@ export const systemUninstallHost = (
selectedHome ?? homeDirectoryFromEnvironment(environment, platform); selectedHome ?? homeDirectoryFromEnvironment(environment, platform);
return { return {
clients: () => clients: () =>
Promise.resolve(supportedClients(home, platform, environment)), Promise.resolve(
supportedClients(home, platform, environment).flatMap((client) =>
client.configPaths === undefined
? [client]
: client.configPaths.map((configPath) => ({
...client,
configPath,
})),
),
),
inspectClient: async (client) => { inspectClient: async (client) => {
const read = await readClientConfiguration(client, fileSystem); const read = await readClientConfiguration(client, fileSystem);
return read.kind === "item" && read.item.status === "failed" return read.kind === "item" && read.item.status === "failed"
@@ -179,7 +189,8 @@ export const systemUninstallHost = (
: undefined; : undefined;
}, },
removeClient: (client) => removeClient(client, fileSystem), removeClient: (client) => removeClient(client, fileSystem),
removeSkill: () => removeManagedSkills(home, fileSystem, environment), removeSkill: () =>
removeManagedSkills(home, fileSystem, environment, platform),
purgeData: async () => [ purgeData: async () => [
await removeManagedPath(join(home, ".rea/cache"), "cache", fileSystem), await removeManagedPath(join(home, ".rea/cache"), "cache", fileSystem),
await removeManagedPath(join(home, ".rea/state"), "state", fileSystem), await removeManagedPath(join(home, ".rea/state"), "state", fileSystem),
@@ -200,6 +211,8 @@ const readClientConfiguration = async (
client: SetupClient, client: SetupClient,
fileSystem: UninstallFileSystem, fileSystem: UninstallFileSystem,
): Promise<ClientConfigurationRead> => { ): Promise<ClientConfigurationRead> => {
if (client.configPathError !== undefined)
return itemRead(item(client.name, "failed", client.configPathError));
if (client.format === "unsupported") if (client.format === "unsupported")
return itemRead( return itemRead(
item( item(
@@ -404,18 +417,16 @@ const removeManagedSkills = async (
home: string, home: string,
fileSystem: UninstallFileSystem, fileSystem: UninstallFileSystem,
environment: Readonly<NodeJS.ProcessEnv>, environment: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform,
): Promise<UninstallItem> => { ): Promise<UninstallItem> => {
const results = await Promise.all( const results = await Promise.all(
skillDestinations( skillDestinations(home, undefined, environment, platform).map(
home, ({ client, path }) =>
undefined, removeManagedPath(
claudeCodeSkillsDirectory(home, environment), path,
).map(({ client, path }) => client === "claude_code" ? "Claude Code skill" : "skill",
removeManagedPath( fileSystem,
path, ),
client === "claude_code" ? "Claude Code skill" : "skill",
fileSystem,
),
), ),
); );
const failed = results.find(({ status }) => status === "failed"); const failed = results.find(({ status }) => status === "failed");
@@ -451,6 +462,68 @@ const item = (
status: UninstallItem["status"], status: UninstallItem["status"],
detail: string, detail: string,
): UninstallItem => ({ name, status, detail }); ): UninstallItem => ({ name, status, detail });
/**
* Layered clients are removed file by file, but callers look up one status by
* client name. A missing earlier file must not hide a later removal.
*/
const collapseUninstallItems = (
items: readonly UninstallItem[],
): UninstallItem[] => {
const collapsed: UninstallItem[] = [];
for (const next of items) {
const index = collapsed.findIndex(({ name }) => name === next.name);
const current = collapsed[index];
if (current === undefined) {
collapsed.push(next);
continue;
}
collapsed[index] = mergeUninstallItems(current, next);
}
return collapsed;
};
const mergeUninstallItems = (
current: UninstallItem,
next: UninstallItem,
): UninstallItem => {
const status = preferredUninstallStatus(current.status, next.status);
const details = [current, next]
.filter(
(entry) =>
entry.status === status ||
(status === "removed" && entry.status === "retained"),
)
.map(({ detail }) => detail);
return item(current.name, status, [...new Set(details)].join(" "));
};
const preferredUninstallStatus = (
current: UninstallItem["status"],
next: UninstallItem["status"],
): UninstallItem["status"] =>
uninstallStatusPriority(next) > uninstallStatusPriority(current)
? next
: current;
const uninstallStatusPriority = (status: UninstallItem["status"]): number => {
switch (status) {
case "failed":
return 3;
case "removed":
return 2;
case "retained":
return 1;
case "skipped":
return 0;
default: {
const exhaustive: never = status;
throw new TypeError(
`Unhandled uninstall item status: ${String(exhaustive)}`,
);
}
}
};
const registrationSchema = z const registrationSchema = z
.object({ command: z.string(), args: z.array(z.string()) }) .object({ command: z.string(), args: z.array(z.string()) })
.passthrough(); .passthrough();
+9
View File
@@ -7,6 +7,13 @@ import { safeParseJson, type SafeJsonParseResult } from "../domain/safeJson.js";
export const JSON_INPUT_RESOURCE_REMEDIATION = export const JSON_INPUT_RESOURCE_REMEDIATION =
"Provide a smaller valid JSON value. For Evidence workflows, re-analyze a smaller selection of the original target and use its complete Evidence; splitting JSON text or trimming Evidence fields does not produce valid workflow input."; "Provide a smaller valid JSON value. For Evidence workflows, re-analyze a smaller selection of the original target and use its complete Evidence; splitting JSON text or trimming Evidence fields does not produce valid workflow input.";
/**
* Strict JSON input keeps rejecting a leading byte-order mark, but names it:
* the parser's own message quotes the invisible U+FEFF as the bad token.
*/
export const JSON_BYTE_ORDER_MARK_MESSAGE =
"JSON input begins with a UTF-8 byte-order mark (EF BB BF), which strict JSON input does not accept. Save the file as UTF-8 without a byte-order mark.";
/** Decode JSON bytes without confusing a runtime string limit with malformed input. */ /** Decode JSON bytes without confusing a runtime string limit with malformed input. */
export const parseUtf8Json = ( export const parseUtf8Json = (
bytes: Uint8Array, bytes: Uint8Array,
@@ -77,5 +84,7 @@ export const parseUtf8Json = (
cause, cause,
}; };
} }
if (text.startsWith("\uFEFF"))
return { ok: false, error: JSON_BYTE_ORDER_MARK_MESSAGE, cause: undefined };
return safeParseJson(text); return safeParseJson(text);
}; };
@@ -2,6 +2,8 @@ import { expect, it } from "vitest";
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js"; import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js";
import { createEvidence, parseEvidence } from "../../domain/evidence.js"; import { createEvidence, parseEvidence } from "../../domain/evidence.js";
import { functionDossierSchema } from "../../domain/hopperValues.js";
import { ghidraFunctionDossier } from "../../domain/ghidraValues.fixture.js";
import { import {
analysisViewJavaScriptAnalysisWithSource, analysisViewJavaScriptAnalysisWithSource,
analysisViewJavaScriptEvidence, analysisViewJavaScriptEvidence,
@@ -9,6 +11,96 @@ import {
} from "../../../tests/fixtures/analysisView.js"; } from "../../../tests/fixtures/analysisView.js";
import { inspectAnalysisView } from "./AnalysisViewService.js"; import { inspectAnalysisView } from "./AnalysisViewService.js";
it.each(["/fixtures/unknown.exe", ""])(
"preserves recorded empty native identity fields: path=%j",
(path) => {
const original = functionDossierSchema.parse(ghidraFunctionDossier());
const parent = createEvidence(
{ path, format: "pe", sha256: "c".repeat(64) },
{ id: "ghidra", name: "Ghidra", version: "12.1.4" },
{
operation: "analyze_function",
parameters: {},
result: {
...original,
procedure: {
...original.procedure,
address: "",
body: { available: false, reason: "Not recorded" },
},
},
},
);
const result = inspectAnalysisView({
source: { kind: "inline", evidence: parent },
view: {
kind: "native",
facet: "procedure",
offset: 0,
limit: 64,
},
});
if (!result.ok) throw result.error;
expect(result.value.normalized_result).toMatchObject({
procedure_address: "",
artifact: { path },
item: { address: "" },
});
expect(result.value.locations).toEqual(
path.length > 0 ? [{ kind: "artifact-path", path }] : [],
);
},
);
it("projects a small native view from an authenticated >10 MiB retained dossier", () => {
const original = functionDossierSchema.parse(ghidraFunctionDossier());
const parent = createEvidence(
{ path: "/fixtures/large.exe", format: "pe", sha256: "c".repeat(64) },
{ id: "ghidra", name: "Ghidra", version: "12.1.4" },
{
operation: "analyze_function",
parameters: { address: "0x401000" },
result: { ...original, pseudocode: "X".repeat(11 * 1024 * 1024) },
limitations: ["Synthetic fixture"],
},
);
const input = {
source: {
kind: "retained-evidence" as const,
evidence_id: parent.evidence_id,
},
view: {
kind: "native" as const,
facet: "pseudocode" as const,
offset: 0,
limit: 128,
},
};
const view = inspectAnalysisView(input, (id) =>
id === parent.evidence_id ? parent : undefined,
);
if (!view.ok) throw view.error;
expect(view.value.evidence_links).toEqual([parent.evidence_id]);
expect(view.value.normalized_result).toMatchObject({
kind: "native",
parent_operation: "analyze_function",
parent_evidence_id: parent.evidence_id,
procedure_address: "0x401000",
artifact: { path: "/fixtures/large.exe", sha256: "c".repeat(64) },
coverage: { total: 11 * 1024 * 1024, examined: 128, next_offset: 128 },
});
expect(view.value.locations).toEqual([
{ kind: "artifact-path", path: "/fixtures/large.exe" },
{ kind: "address", address: "0x401000" },
]);
expect(JSON.stringify(view.value).length).toBeLessThan(12000);
expect(JSON.stringify(parent).length).toBeGreaterThan(10 * 1024 * 1024);
expect(inspectAnalysisView(input, () => undefined)).toMatchObject({
ok: false,
error: { _tag: "EvidenceIntegrityError" },
});
});
it("projects inline layout Evidence and retains a derived view record", () => { it("projects inline layout Evidence and retains a derived view record", () => {
const parent = analysisViewLayoutEvidence(); const parent = analysisViewLayoutEvidence();
const result = inspectAnalysisView({ const result = inspectAnalysisView({
@@ -53,6 +53,13 @@ export const inspectAnalysisViewValidated = (
operation: parent.operation, operation: parent.operation,
normalizedResult: parent.normalized_result, normalizedResult: parent.normalized_result,
limitations: parent.limitations, limitations: parent.limitations,
artifact:
parent.subject === null
? null
: {
path: parent.subject.local_path,
sha256: parent.subject.digest.sha256,
},
}, },
input.view, input.view,
); );
@@ -126,7 +133,16 @@ const createAnalysisViewEvidence = (
confidence: "derived", confidence: "derived",
authority: parent.authority, authority: parent.authority,
limitations: result.limitations, limitations: result.limitations,
locations: [{ kind: "artifact-path", path: result.artifact.path }], locations: [
...(result.artifact.path.length > 0
? [{ kind: "artifact-path" as const, path: result.artifact.path }]
: []),
...(result.kind === "native" &&
result.procedure_address !== null &&
result.procedure_address.length > 0
? [{ kind: "address" as const, address: result.procedure_address }]
: []),
],
evidenceLinks: [parent.evidence_id], evidenceLinks: [parent.evidence_id],
}); });
}; };
@@ -5,6 +5,7 @@ import {
BINARY_LAYOUT_TEST_PROVIDER, BINARY_LAYOUT_TEST_PROVIDER,
} from "../../../tests/fixtures/binaryDiagnostics/layout.js"; } from "../../../tests/fixtures/binaryDiagnostics/layout.js";
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js"; import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
import { parseEvidence } from "../../domain/evidence.js";
import { err, ok } from "../../domain/result.js"; import { err, ok } from "../../domain/result.js";
it.each([ it.each([
@@ -28,6 +29,19 @@ it.each([
}, },
); );
it("records the decoded layout once in its Evidence", async () => {
const layout = binaryLayoutFixture();
const service = new BinaryLayoutService({
identity: BINARY_LAYOUT_TEST_PROVIDER,
inspect: () => Promise.resolve(ok(layout)),
});
const result = await service.inspect({ path: layout.artifact.path });
if (!result.ok) throw result.error;
const evidence = parseEvidence(result.value);
expect(evidence.normalized_result).toEqual(layout);
expect(evidence.raw_result).toBeNull();
});
it("preserves a provider's actionable unsupported reason", async () => { it("preserves a provider's actionable unsupported reason", async () => {
const failure = new AnalysisCapabilityUnavailableError( const failure = new AnalysisCapabilityUnavailableError(
"fixture-layout", "fixture-layout",
@@ -78,7 +78,8 @@ export class BinaryLayoutService {
operation: OPERATION, operation: OPERATION,
parameters: input.data, parameters: input.data,
result: value, result: value,
rawResult: value, // The decoded report is the only representation; do not repeat it.
rawResult: null,
confidence: "observed", confidence: "observed",
limitations: value.limitations, limitations: value.limitations,
locations: [{ kind: "artifact-path", path: value.artifact.path }], locations: [{ kind: "artifact-path", path: value.artifact.path }],
@@ -24,6 +24,7 @@ it("labels an envelope containing debugger-derived maps as derived", async () =>
const evidence = parseEvidence(result.value); const evidence = parseEvidence(result.value);
expect(evidence.confidence).toBe("derived"); expect(evidence.confidence).toBe("derived");
expect(evidence.normalized_result).toEqual(fixture); expect(evidence.normalized_result).toEqual(fixture);
expect(evidence.raw_result).toBeNull();
}); });
it.each([ it.each([
@@ -83,7 +83,8 @@ export class RecordedCrashService {
operation: OPERATION, operation: OPERATION,
parameters: input.data, parameters: input.data,
result: value, result: value,
rawResult: value, // The decoded report is the only representation; do not repeat it.
rawResult: null,
confidence: confidence:
value.debugger.status === "available" ? "derived" : "observed", value.debugger.status === "available" ? "derived" : "observed",
limitations: value.limitations, limitations: value.limitations,
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { compactStringIdentityKey } from "../../domain/compactStringIdentity.js";
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js"; import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
@@ -162,16 +163,15 @@ const addEndpoint = (
const source = const source =
sourceNodeFor(context, input.file.path, input.value.module_key) ?? sourceNodeFor(context, input.file.path, input.value.module_key) ??
input.asset; input.asset;
const key = compactStringIdentityKey(input.value.value);
const label = `${input.value.kind} endpoint`;
const node = context.accumulator.addNode({ const node = context.accumulator.addNode({
kind: "endpoint", kind: "endpoint",
identity: artifactLocalIdentity( identity: artifactLocalIdentity(input.file.sha256, input.value.kind, key),
input.file.sha256,
input.value.kind,
input.value.value,
),
observations: [ observations: [
{ {
label: input.value.value, label:
input.value.value.length <= label.length ? input.value.value : label,
properties: { properties: {
endpoint_kind: input.value.kind, endpoint_kind: input.value.kind,
value: input.value.value, value: input.value.value,
@@ -67,6 +67,36 @@ describe("artifact-local graph identity", () => {
}); });
}); });
describe("logical path identity", () => {
it("resolves an NFD relative import to the NFC inventory path and rejects a root escape", () => {
const nfc = "caf\u00e9".normalize("NFC");
const nfd = nfc.normalize("NFD");
const files = fileMap([
file("dir/index.js", "root"),
file(`dir/${nfc}.js`, "root"),
]);
expect(
resolve({
declaredPath: `./${nfd}.js`,
sourcePath: "dir/index.js",
context: "module-specifier",
files,
}),
).toMatchObject({
resolution_status: "resolved",
resolved_path: `dir/${nfc}.js`,
});
expect(
resolve({
declaredPath: "../escape.js",
sourcePath: "index.js",
context: "module-specifier",
files,
}),
).toMatchObject({ resolution_status: "rejected", resolved_path: null });
});
});
describe("contextual JavaScript artifact path resolution", () => { describe("contextual JavaScript artifact path resolution", () => {
it.each([ it.each([
["main.js", "app/main.js"], ["main.js", "app/main.js"],
@@ -1,6 +1,10 @@
import { isBuiltin } from "node:module"; import { isBuiltin } from "node:module";
import { posix } from "node:path"; import { posix } from "node:path";
import {
normalizeJoinedLogicalPath,
logicalPathEscapesRoot,
} from "../../domain/artifactIdentity.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { import {
admitsCanonicalPathSyntax, admitsCanonicalPathSyntax,
@@ -326,12 +330,8 @@ const confineCandidate = (
input: ResolveArtifactPathInput, input: ResolveArtifactPathInput,
candidate: string, candidate: string,
): string | ArtifactPathResolution => { ): string | ArtifactPathResolution => {
const normalized = posix.normalize(candidate); const normalized = normalizeJoinedLogicalPath(candidate);
if ( if (logicalPathEscapesRoot(normalized))
normalized === ".." ||
normalized.startsWith("../") ||
normalized.startsWith("/")
)
return unresolvedOutcome(input, "rejected", [ return unresolvedOutcome(input, "rejected", [
"The resolved candidate escapes the canonical artifact root.", "The resolved candidate escapes the canonical artifact root.",
]); ]);
@@ -1,3 +1,4 @@
import { compactStringIdentityKey } from "../../domain/compactStringIdentity.js";
import { createJavaScriptSemanticGraphUnknown } from "../../domain/javascript/javascriptSemanticGraph.js"; import { createJavaScriptSemanticGraphUnknown } from "../../domain/javascript/javascriptSemanticGraph.js";
import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js";
import type { import type {
@@ -21,7 +22,7 @@ export const projectSemanticEvents = (
const eventKey = const eventKey =
operation.eventName === null operation.eventName === null
? operation.eventId ? operation.eventId
: `${operation.emitterKey}\u0000${operation.eventName}`; : `${operation.emitterKey}\u0000${compactStringIdentityKey(operation.eventName)}`;
const event = const event =
eventNodes.get(eventKey) ?? addEventNode(context, operation, eventKey); eventNodes.get(eventKey) ?? addEventNode(context, operation, eventKey);
if (event === null) continue; if (event === null) continue;
@@ -59,7 +60,11 @@ const addEventNode = (
kind: "event", kind: "event",
roleKey: `event:${eventKey}`, roleKey: `event:${eventKey}`,
location: operation.eventName === null ? operation.location : null, location: operation.eventName === null ? operation.location : null,
label: operation.eventName, label:
operation.eventName === null ||
operation.eventName.length <= "event".length
? operation.eventName
: "event",
functionNodeId: null, functionNodeId: null,
properties: { properties: {
emitter_key: operation.emitterKey, emitter_key: operation.emitterKey,
@@ -78,7 +83,9 @@ const addListenerNode = (
label: label:
operation.eventName === null operation.eventName === null
? operation.method ? operation.method
: `${operation.method}:${operation.eventName}`, : operation.eventName.length <= "listener".length
? `${operation.method}:${operation.eventName}`
: `${operation.method}:listener`,
functionNodeId: functionNodeId:
operation.ownerCallableId === null operation.ownerCallableId === null
? null ? null
@@ -175,7 +175,21 @@ export const createJavaScriptSemanticGraphProjection =
): Value => { ): Value => {
bindSemanticGraphApplicationNodes(state, applicationGraph); bindSemanticGraphApplicationNodes(state, applicationGraph);
if (state.roots.size === 0) addFallbackRoot(rootArtifactSha256, state); if (state.roots.size === 0) addFallbackRoot(rootArtifactSha256, state);
const relations = [...state.relations.values()];
const unknowns = [...state.unknowns.values()]; const unknowns = [...state.unknowns.values()];
type Family = (typeof JAVASCRIPT_SEMANTIC_RELATION_FAMILIES)[number];
const retainedByFamily = new Map<Family, number>();
const unknownIdsByFamily = new Map<Family, string[]>();
for (const relation of relations) {
const family = JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation.relation];
retainedByFamily.set(family, (retainedByFamily.get(family) ?? 0) + 1);
}
for (const unknown of unknowns) {
const identifiers = unknownIdsByFamily.get(unknown.family);
if (identifiers === undefined)
unknownIdsByFamily.set(unknown.family, [unknown.unknown_id]);
else identifiers.push(unknown.unknown_id);
}
const graph = factory({ const graph = factory({
schema: "JavaScriptSemanticRelationGraph", schema: "JavaScriptSemanticRelationGraph",
root_artifact_sha256: rootArtifactSha256, root_artifact_sha256: rootArtifactSha256,
@@ -183,7 +197,7 @@ export const createJavaScriptSemanticGraphProjection =
root_node_ids: [...state.roots], root_node_ids: [...state.roots],
evidence_contexts: state.evidenceContexts.contexts, evidence_contexts: state.evidenceContexts.contexts,
nodes: [...state.nodes.values()], nodes: [...state.nodes.values()],
relations: [...state.relations.values()], relations,
fingerprints, fingerprints,
unknowns, unknowns,
coverage: { coverage: {
@@ -204,15 +218,9 @@ export const createJavaScriptSemanticGraphProjection =
families: JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map((family) => ({ families: JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map((family) => ({
family, family,
status: semanticFamilyStatus(family), status: semanticFamilyStatus(family),
retained_relations: [...state.relations.values()].filter( retained_relations: retainedByFamily.get(family) ?? 0,
(relation) =>
JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation.relation] ===
family,
).length,
omitted_relations: truncatedFiles > 0 ? null : 0, omitted_relations: truncatedFiles > 0 ? null : 0,
unknown_ids: unknowns unknown_ids: unknownIdsByFamily.get(family) ?? [],
.filter((unknown) => unknown.family === family)
.map(({ unknown_id: identifier }) => identifier),
})), })),
}, },
limitations: [ limitations: [
@@ -137,7 +137,11 @@ const addRequestNode = (
kind: operation.kind === "request" ? "request" : "response", kind: operation.kind === "request" ? "request" : "response",
roleKey: operation.requestId, roleKey: operation.requestId,
location: operation.location, location: operation.location,
label: operation.endpoint ?? operation.method, label:
operation.endpoint !== null &&
operation.endpoint.length <= operation.method.length
? operation.endpoint
: operation.method,
functionNodeId: functionNodeId:
operation.ownerCallableId === null operation.ownerCallableId === null
? null ? null
@@ -2,6 +2,7 @@ import {
semanticContainer, semanticContainer,
semanticPropertyPointer, semanticPropertyPointer,
} from "../../domain/javascript/javascriptSemanticSlots.js"; } from "../../domain/javascript/javascriptSemanticSlots.js";
import { digestCanonicalValue } from "../../domain/canonicalDigest.js";
import type { JavaScriptSemanticBinding } from "../../domain/javascript/javascriptSemanticIr.js"; import type { JavaScriptSemanticBinding } from "../../domain/javascript/javascriptSemanticIr.js";
import type { import type {
JavaScriptSemanticProperty, JavaScriptSemanticProperty,
@@ -153,16 +154,27 @@ const addLiteralNode = (
binding: JavaScriptSemanticBinding, binding: JavaScriptSemanticBinding,
value: string | number | boolean | null, value: string | number | boolean | null,
role: string, role: string,
) => ) => {
retainSemanticGraphNode(context.state, context.file, { const encodedValue = JSON.stringify(value);
const valueDigest = `value-sha256:${digestCanonicalValue(value, "Semantic literal")}`;
const valueKey =
encodedValue.length <= valueDigest.length ? encodedValue : valueDigest;
const stringLabel = "string literal";
return retainSemanticGraphNode(context.state, context.file, {
kind: "literal", kind: "literal",
roleKey: `literal:${binding.bindingId}:${role}:${JSON.stringify(value)}`, // Identity needs an exact value commitment, not another payload copy.
// Keep the complete literal in properties for queries and Evidence export.
roleKey: `literal:${binding.bindingId}:${role}:${valueKey}`,
location: binding.definitions[0]?.location ?? null, location: binding.definitions[0]?.location ?? null,
label: JSON.stringify(value), label:
typeof value === "string" && encodedValue.length > stringLabel.length
? stringLabel
: encodedValue,
functionNodeId: functionNodeId:
context.bindingNodes.get(binding.bindingId)?.function_node_id ?? null, context.bindingNodes.get(binding.bindingId)?.function_node_id ?? null,
properties: { value }, properties: { value },
}); });
};
/** Create one canonical slot for a root binding and exact static property path. */ /** Create one canonical slot for a root binding and exact static property path. */
export const semanticPropertySlot = ( export const semanticPropertySlot = (
+17 -5
View File
@@ -73,15 +73,27 @@ export const traceNativeUiAction = async (
), ),
); );
graph = joinInterfaceBuilderDispatch(decoded.data.graph, metadata.result); graph = joinInterfaceBuilderDispatch(decoded.data.graph, metadata.result);
const archiveDecode = decoded.data.graph.coverage.find(
(facet) => facet.facet === "archive_decode",
);
const documentsFound = decoded.data.documents.length > 0;
coverage.push({ coverage.push({
facet: "interface_builder_archives", facet: "interface_builder_archives",
status: decoded.data.documents.length > 0 ? "complete" : "unsupported", status:
archiveDecode?.status === "partial" ||
archiveDecode?.status === "complete" ||
archiveDecode?.status === "unsupported"
? archiveDecode.status
: documentsFound
? "complete"
: "unsupported",
reason: reason:
decoded.data.documents.length > 0 archiveDecode?.reason ??
(documentsFound
? null ? null
: "no_compiled_interface_builder_archives_found", : "no_compiled_interface_builder_archives_found"),
examined: decoded.data.documents.length, examined: archiveDecode?.examined ?? decoded.data.documents.length,
omitted: 0, omitted: archiveDecode?.omitted ?? 0,
}); });
const bounded = boundUiGraph(graph, input.max_nodes, input.max_edges); const bounded = boundUiGraph(graph, input.max_nodes, input.max_edges);
graph = bounded.graph; graph = bounded.graph;
@@ -6,6 +6,7 @@ import {
captureProcessScenarioFile, captureProcessScenarioFile,
projectProcessCliError, projectProcessCliError,
} from "./ProcessCli.js"; } from "./ProcessCli.js";
import { JSON_BYTE_ORDER_MARK_MESSAGE } from "../Utf8JsonInput.js";
const roots: string[] = []; const roots: string[] = [];
afterEach(async () => { afterEach(async () => {
@@ -87,4 +88,14 @@ describe("process CLI input diagnostics", () => {
}), }),
}); });
}); });
it("names a leading byte-order mark instead of quoting it as a token", async () => {
const path = join(await fixture(), "scenario.json");
await writeFile(path, '\uFEFF{"executable":"/bin/echo"}');
expect(await captureProcessScenarioFile(path)).toEqual({
error: "Process command failed",
category: "invalid_input",
message: `Process input file is not valid UTF-8 JSON: ${path} (${JSON_BYTE_ORDER_MARK_MESSAGE}). Repair the file, then try again.`,
});
});
}); });
+13
View File
@@ -15,9 +15,11 @@ import { processTraceSpecificationSchema } from "../../domain/process/processTra
import { processScenarioSchema } from "../../domain/process/processScenario.js"; import { processScenarioSchema } from "../../domain/process/processScenario.js";
import { compareProcessCaptures } from "../../domain/process/processComparison.js"; import { compareProcessCaptures } from "../../domain/process/processComparison.js";
import { parseProcessCapture } from "../../domain/process/processCaptureParsing.js"; import { parseProcessCapture } from "../../domain/process/processCaptureParsing.js";
import { createProgressReporter } from "../ProgressReporter.js";
import { captureProcessScenario } from "../../process/capture/ProcessHarness.js"; import { captureProcessScenario } from "../../process/capture/ProcessHarness.js";
import { PROCESS_PROVIDER } from "../../domain/process/processEvidenceProvider.js"; import { PROCESS_PROVIDER } from "../../domain/process/processEvidenceProvider.js";
import { createProcessCaptureEvidence } from "./ProcessEvidence.js"; import { createProcessCaptureEvidence } from "./ProcessEvidence.js";
import { JSON_BYTE_ORDER_MARK_MESSAGE } from "../Utf8JsonInput.js";
/** Safe process-command failure returned to the CLI adapter. */ /** Safe process-command failure returned to the CLI adapter. */
export interface ProcessCliErrorOutput { export interface ProcessCliErrorOutput {
@@ -55,11 +57,20 @@ export const captureProcessScenarioFile = async (
input, input,
{ cause: parsed.error }, { cause: parsed.error },
); );
const progress = createProgressReporter(
async (update) => {
process.stderr.write(`${JSON.stringify({ rea_progress: update })}\n`);
},
{ minimumIntervalMs: 100 },
);
const captured = await captureProcessScenario( const captured = await captureProcessScenario(
parsed.data, parsed.data,
signal, signal,
process.platform, process.platform,
environment, environment,
undefined,
undefined,
progress,
); );
if (!captured.ok) return cliAnalysisError(captured.error); if (!captured.ok) return cliAnalysisError(captured.error);
return createProcessCaptureEvidence(parsed.data, captured.value); return createProcessCaptureEvidence(parsed.data, captured.value);
@@ -181,6 +192,8 @@ const readJson = async (path: string): Promise<unknown> => {
fatal: true, fatal: true,
ignoreBOM: true, ignoreBOM: true,
}).decode(bytes); }).decode(bytes);
if (text.startsWith("\uFEFF"))
throw new Error(JSON_BYTE_ORDER_MARK_MESSAGE);
const parsed: unknown = JSON.parse(text); const parsed: unknown = JSON.parse(text);
return parsed; return parsed;
} catch (cause: unknown) { } catch (cause: unknown) {
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import {
annotateEnglishUnicodeCaseCollisions,
ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION,
} from "./ArtifactPaths.js";
const NOTE =
"a case-insensitive destination cannot store both. This comparison is not a filesystem observation.";
const annotate = (paths: readonly string[]) => {
const occurrences = paths.map((logical_path) => ({
logical_path,
limitations: [] as string[],
}));
const limitation = annotateEnglishUnicodeCaseCollisions(occurrences);
return {
limitation,
byPath: new Map(
occurrences.map(({ logical_path, limitations }) => [
logical_path,
limitations,
]),
),
};
};
describe("English Unicode case collisions", () => {
it("names the other spelling of a colliding file", () => {
const { limitation, byPath } = annotate([".", "Main.js", "main.js", "x"]);
expect(limitation).toBe(ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION);
expect(byPath.get("Main.js")).toEqual([
`Logical path Main.js collides under English (en-US) Unicode case folding with main.js; ${NOTE}`,
]);
expect(byPath.get("main.js")).toEqual([
`Logical path main.js collides under English (en-US) Unicode case folding with Main.js; ${NOTE}`,
]);
expect(byPath.get("x")).toEqual([]);
});
it("records a directory collision once at the diverging segment", () => {
const { byPath } = annotate([
"res/Foo",
"res/Foo/a.txt",
"res/foo",
"res/foo/b.txt",
]);
expect(byPath.get("res/Foo")).toEqual([
`Logical path res/Foo collides under English (en-US) Unicode case folding with res/foo; ${NOTE}`,
]);
expect(byPath.get("res/foo/b.txt")).toEqual([
`Logical path res/foo/b.txt is under res/foo, which collides under English (en-US) Unicode case folding with res/Foo; ${NOTE}`,
]);
});
it("counts further spellings, each of which is its own occurrence", () => {
const { byPath } = annotate(["A.txt", "a.txt", "a.TXT"]);
expect(byPath.get("a.txt")).toEqual([
`Logical path a.txt collides under English (en-US) Unicode case folding with A.txt and 1 other spelling; ${NOTE}`,
]);
});
it("stays linear for large case-variant subtrees", () => {
const count = 5_000;
const paths = Array.from({ length: count }, (_, index) => [
`Foo/a${String(index)}.txt`,
`foo/b${String(index)}.txt`,
]).flat();
const { byPath } = annotate(paths);
for (const path of paths) {
const limitations = byPath.get(path) ?? [];
expect(limitations).toHaveLength(1);
expect(limitations[0]?.length).toBeLessThan(300);
}
});
it("walks names with more components than the call stack holds", () => {
const deep = Array.from({ length: 30_000 }, () => "a").join("/");
const { byPath } = annotate([deep, `${deep}/X`, `${deep}/x`]);
expect(byPath.get(`${deep}/X`)).toHaveLength(1);
expect(byPath.get(deep)).toEqual([]);
});
});
+184 -12
View File
@@ -1,9 +1,25 @@
import { posix } from "node:path"; import { posix } from "node:path";
import { logicalPathNfc } from "../domain/artifactIdentity.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js"; import { ArtifactReaderFailure } from "./ArtifactReader.js";
const DRIVE_OR_UNC = /^(?:[A-Za-z]:|\\|\/\/)/u; const DRIVE_OR_UNC = /^(?:[A-Za-z]:|\\|\/\/)/u;
/**
* English Unicode case folding used only to describe logical names a
* case-insensitive destination might collapse. It is not a filesystem case table.
*/
const ENGLISH_UNICODE_CASE_NOTE =
"This comparison is not a filesystem observation.";
/** Inventory limitation when logical names collide under English Unicode case folding. */
export const ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION = `Logical archive names collide under English (en-US) Unicode case folding; a case-insensitive destination cannot store both spellings. ${ENGLISH_UNICODE_CASE_NOTE}`;
/** User-facing prefix for a destination that cannot keep two logical spellings. */
export const DESTINATION_CASE_COLLISION_PREFIX =
"Destination filesystem cannot store both ";
/** Normalize an untrusted logical archive path without touching filesystem. */ /** Normalize an untrusted logical archive path without touching filesystem. */
export const normalizeArtifactPath = (input: string): string => { export const normalizeArtifactPath = (input: string): string => {
if ( if (
@@ -16,7 +32,7 @@ export const normalizeArtifactPath = (input: string): string => {
"path", "path",
`Artifact path is absolute or unsafe: ${JSON.stringify(input)}`, `Artifact path is absolute or unsafe: ${JSON.stringify(input)}`,
); );
const normalized = input.normalize("NFC").replace(/\/+$/u, ""); const normalized = logicalPathNfc(input).replace(/\/+$/u, "");
const parts = normalized.split("/"); const parts = normalized.split("/");
if ( if (
normalized.length === 0 || normalized.length === 0 ||
@@ -30,7 +46,7 @@ export const normalizeArtifactPath = (input: string): string => {
return normalized; return normalized;
}; };
/** Reject exact, Unicode-normalized, case-folded, and prefix collisions. */ /** Reject exact and same-spelling prefix collisions. Case-distinct names stay distinct. */
export class ArtifactPathRegistry { export class ArtifactPathRegistry {
readonly #root: PathTrieNode = { kind: undefined, children: new Map() }; readonly #root: PathTrieNode = { kind: undefined, children: new Map() };
@@ -43,16 +59,10 @@ export class ArtifactPathRegistry {
"path", "path",
`Artifact prefix conflict: ${path}`, `Artifact prefix conflict: ${path}`,
); );
const foldedPart = part.toLocaleLowerCase("en-US"); let child = node.children.get(part);
let child = node.children.get(foldedPart);
if (child !== undefined && child.spelling !== part)
throw new ArtifactReaderFailure(
"path",
`Artifact path collision: ${path} differs only in case from ${[...parts.slice(0, index), child.spelling].join("/")}`,
);
if (child === undefined) { if (child === undefined) {
child = { spelling: part, kind: undefined, children: new Map() }; child = { kind: undefined, children: new Map() };
node.children.set(foldedPart, child); node.children.set(part, child);
} }
node = child; node = child;
if (index === parts.length - 1) { if (index === parts.length - 1) {
@@ -73,7 +83,169 @@ export class ArtifactPathRegistry {
} }
interface PathTrieNode { interface PathTrieNode {
readonly spelling?: string;
kind: "file" | "directory" | "symlink" | "slice" | undefined; kind: "file" | "directory" | "symlink" | "slice" | undefined;
readonly children: Map<string, PathTrieNode>; readonly children: Map<string, PathTrieNode>;
} }
interface CaseNode {
readonly bySpelling: Map<string, CaseNode>;
readonly spellingsByFold: Map<string, string[]>;
readonly terminals: string[];
}
const emptyCaseNode = (): CaseNode => ({
bySpelling: new Map(),
spellingsByFold: new Map(),
terminals: [],
});
const englishCaseFold = (part: string): string =>
part.toLocaleLowerCase("en-US");
/**
* Record English (en-US) Unicode case collisions on each affected occurrence.
* Returns the inventory limitation when any logical names collide.
*/
export const annotateEnglishUnicodeCaseCollisions = (
occurrences: readonly {
readonly logical_path: string;
limitations: string[];
}[],
): string | undefined => {
const collisions = englishUnicodeCaseCollisions(
occurrences.map(({ logical_path }) => logical_path),
);
if (collisions.size === 0) return undefined;
for (const occurrence of occurrences)
for (const collision of collisions.get(occurrence.logical_path) ?? [])
occurrence.limitations.push(
caseCollisionLimitation(occurrence.logical_path, collision),
);
return ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION;
};
/** One logical path segment whose spelling folds to another sibling's. */
interface CaseCollision {
/** Logical path through the colliding segment. */
readonly prefix: string;
/** First other sibling spelling, as a logical path, in code-point order. */
readonly other: string;
/** Count of further sibling spellings, each its own occurrence. */
readonly additional: number;
}
const caseCollisionLimitation = (
path: string,
{ prefix, other, additional }: CaseCollision,
): string => {
const others =
additional === 0
? other
: `${other} and ${String(additional)} other spelling${additional === 1 ? "" : "s"}`;
const subject =
prefix === path
? `Logical path ${path} collides`
: `Logical path ${path} is under ${prefix}, which collides`;
return `${subject} under English (en-US) Unicode case folding with ${others}; a case-insensitive destination cannot store both. ${ENGLISH_UNICODE_CASE_NOTE}`;
};
/**
* Name the directory-listing spelling that differs from the requested segment
* only by English (en-US) Unicode case. Exact matches are the same logical name.
*/
export const destinationCaseCollisionMessage = (
requestedPath: string,
existingNames: readonly string[],
): string | undefined => {
const slash = requestedPath.lastIndexOf("/");
const segment = slash < 0 ? requestedPath : requestedPath.slice(slash + 1);
if (existingNames.includes(segment)) return undefined;
const folded = englishCaseFold(segment);
const existing = existingNames
.filter((name) => englishCaseFold(name) === folded)
.sort(compareUnicodeCodePoints)[0];
if (existing === undefined) return undefined;
const parent = slash < 0 ? "" : requestedPath.slice(0, slash + 1);
return `${DESTINATION_CASE_COLLISION_PREFIX}${requestedPath} and ${parent}${existing}; inventory retains both logical names.`;
};
const englishUnicodeCaseCollisions = (
paths: readonly string[],
): ReadonlyMap<string, readonly CaseCollision[]> => {
const root = emptyCaseNode();
for (const path of paths) {
if (path.length === 0 || path === ".") continue;
let node = root;
for (const part of path.split("/")) {
const folded = englishCaseFold(part);
const spellings = node.spellingsByFold.get(folded);
if (spellings === undefined) node.spellingsByFold.set(folded, [part]);
else if (!spellings.includes(part)) spellings.push(part);
let child = node.bySpelling.get(part);
if (child === undefined) {
child = emptyCaseNode();
node.bySpelling.set(part, child);
}
node = child;
}
node.terminals.push(path);
}
// A collision is recorded once, at the segment where spellings diverge, and
// shared down the subtree. Pairing every descendant of one spelling with
// every descendant of another grew quadratically with the subtree sizes.
// The walk is iterative because a ZIP name can hold more components than
// the call stack allows.
const collisions = new Map<string, readonly CaseCollision[]>();
const pending: {
readonly node: CaseNode;
readonly prefix: string | undefined;
readonly chain: CollisionChain | undefined;
}[] = [{ node: root, prefix: undefined, chain: undefined }];
for (let item = pending.pop(); item !== undefined; item = pending.pop()) {
const { node, prefix, chain } = item;
if (chain !== undefined)
for (const path of node.terminals)
collisions.set(path, collisionsAlong(chain));
for (const spellings of node.spellingsByFold.values())
spellings.sort(compareUnicodeCodePoints);
for (const [spelling, child] of node.bySpelling) {
const childPrefix =
prefix === undefined ? spelling : `${prefix}/${spelling}`;
const spellings =
node.spellingsByFold.get(englishCaseFold(spelling)) ?? [];
const other = spellings[0] === spelling ? spellings[1] : spellings[0];
pending.push({
node: child,
prefix: childPrefix,
chain:
other === undefined
? chain
: {
collision: {
prefix: childPrefix,
other: prefix === undefined ? other : `${prefix}/${other}`,
additional: spellings.length - 2,
},
parent: chain,
},
});
}
}
return collisions;
};
/** Collisions on one path, linked from the deepest segment to the root. */
interface CollisionChain {
readonly collision: CaseCollision;
readonly parent: CollisionChain | undefined;
}
const collisionsAlong = (chain: CollisionChain): CaseCollision[] => {
const collisions: CaseCollision[] = [];
let link: CollisionChain | undefined = chain;
while (link !== undefined) {
collisions.push(link.collision);
link = link.parent;
}
return collisions.reverse();
};
+7 -14
View File
@@ -194,10 +194,8 @@ class ArtifactClient implements AnalysisClient {
this.target.sourcePath ?? this.target.path, this.target.sourcePath ?? this.target.path,
result.manifest, result.manifest,
), ),
locations: result.artifacts.map(({ relative_path: path }) => ({ // Each extracted file's path is already in artifacts.
kind: "artifact-path" as const, locations: [],
path,
})),
}), }),
); );
} }
@@ -211,10 +209,8 @@ class ArtifactClient implements AnalysisClient {
this.target.sourcePath ?? this.target.path, this.target.sourcePath ?? this.target.path,
result.manifest, result.manifest,
), ),
locations: result.occurrences.map(({ logical_path: path }) => ({ // Each occurrence's logical path is already in the result.
kind: "artifact-path" as const, locations: [],
path,
})),
}), }),
); );
} catch (cause: unknown) { } catch (cause: unknown) {
@@ -323,17 +319,14 @@ class ArtifactClient implements AnalysisClient {
this.target.sourcePath ?? this.target.path, this.target.sourcePath ?? this.target.path,
inventory.manifest, inventory.manifest,
); );
const locations = inventory.occurrences.map(({ logical_path: path }) => ({
kind: "artifact-path" as const,
path,
}));
const inventoryEvidence = createEvidence(subject, ARTIFACT_GRAPH_PROVIDER, { const inventoryEvidence = createEvidence(subject, ARTIFACT_GRAPH_PROVIDER, {
operation: "inventory_artifact", operation: "inventory_artifact",
parameters: inventoryParameters, parameters: inventoryParameters,
result: inventory, result: inventory,
rawResult: null, rawResult: null,
limitations: inventory.limitations, limitations: inventory.limitations,
locations, // The nested inventory already names every occurrence's logical path.
locations: [],
}); });
const result = createArtifactInspection(inventoryEvidence); const result = createArtifactInspection(inventoryEvidence);
await options?.progress?.report({ await options?.progress?.report({
@@ -347,7 +340,7 @@ class ArtifactClient implements AnalysisClient {
rawResult: null, rawResult: null,
limitations: result.limitations, limitations: result.limitations,
subject, subject,
locations, locations: [],
}), }),
); );
} }
+33 -2
View File
@@ -4,6 +4,7 @@ import {
lstat, lstat,
mkdir, mkdir,
open, open,
readdir,
realpath, realpath,
rm, rm,
type FileHandle, type FileHandle,
@@ -14,6 +15,7 @@ import { streamChunkToBuffer } from "./StreamBytes.js";
import { import {
ArtifactPathRegistry, ArtifactPathRegistry,
destinationCaseCollisionMessage,
normalizeArtifactPath, normalizeArtifactPath,
} from "./ArtifactPaths.js"; } from "./ArtifactPaths.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js"; import { ArtifactReaderFailure } from "./ArtifactReader.js";
@@ -134,7 +136,8 @@ export class SafeOutputTree {
constants.O_WRONLY | constants.O_WRONLY |
constants.O_NOFOLLOW, constants.O_NOFOLLOW,
0o600, 0o600,
).catch((cause: unknown) => { ).catch(async (cause: unknown) => {
await throwIfDestinationCaseCollision(dirname(destination), path, cause);
throw new ArtifactReaderFailure( throw new ArtifactReaderFailure(
"path", "path",
`Could not exclusively create extraction path: ${path}`, `Could not exclusively create extraction path: ${path}`,
@@ -245,17 +248,28 @@ export class SafeOutputTree {
if (fileName === undefined) if (fileName === undefined)
throw new ArtifactReaderFailure("path", "Invalid extraction path"); throw new ArtifactReaderFailure("path", "Invalid extraction path");
let current = this.#outputRoot; let current = this.#outputRoot;
let logicalParent = "";
for (const part of parts) { for (const part of parts) {
const parentDirectory = current;
const logicalPath =
logicalParent.length === 0 ? part : `${logicalParent}/${part}`;
current = join(current, part); current = join(current, part);
let created = true;
await mkdir(current, { mode: 0o700 }).catch((cause: unknown) => { await mkdir(current, { mode: 0o700 }).catch((cause: unknown) => {
if (!isAlreadyExists(cause)) throw cause; if (!isAlreadyExists(cause)) throw cause;
created = false;
}); });
if (!created)
await throwIfDestinationCaseCollision(parentDirectory, logicalPath);
const metadata = await lstat(current); const metadata = await lstat(current);
if (!metadata.isDirectory() || metadata.isSymbolicLink()) if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
await throwIfDestinationCaseCollision(parentDirectory, logicalPath);
throw new ArtifactReaderFailure( throw new ArtifactReaderFailure(
"path", "path",
`Unsafe extraction parent: ${relativePath}`, `Unsafe extraction parent: ${relativePath}`,
); );
}
logicalParent = logicalPath;
} }
return join(current, fileName); return join(current, fileName);
} }
@@ -330,6 +344,23 @@ const isNotFound = (cause: unknown): boolean =>
const isAlreadyExists = (cause: unknown): boolean => const isAlreadyExists = (cause: unknown): boolean =>
cause instanceof Error && "code" in cause && cause.code === "EEXIST"; cause instanceof Error && "code" in cause && cause.code === "EEXIST";
/** Fail when this directory already holds another spelling of the requested segment. */
const throwIfDestinationCaseCollision = async (
parentDirectory: string,
logicalPath: string,
cause?: unknown,
): Promise<void> => {
const names = await readdir(parentDirectory).catch(() => undefined);
if (names === undefined) return;
const message = destinationCaseCollisionMessage(logicalPath, names);
if (message === undefined) return;
throw new ArtifactReaderFailure(
"path",
message,
cause === undefined ? undefined : { cause },
);
};
const abortIfNeeded = (signal?: AbortSignal): void => { const abortIfNeeded = (signal?: AbortSignal): void => {
if (signal?.aborted === true) if (signal?.aborted === true)
throw new ArtifactReaderFailure( throw new ArtifactReaderFailure(

Some files were not shown because too many files have changed in this diff Show More