From 710fad8acfff7d6d34a72ba3ceb02db84ee01a1d Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Mon, 2 Nov 2026 05:14:34 -0800 Subject: [PATCH 01/38] feat(evidence): discover missing Evidence links without transferring records (#8633) * feat(evidence): discover missing Evidence links without transferring records An agent could only learn that a retained record's source was absent by reading complete records or by having a downstream analysis refuse. The summary said nothing about links, and complete-record retention was easy to mistake for source closure. - Add detail: links to get_evidence_bundle and CLI inspect-evidence-bundle: paged link rows with retained or missing status and no record payloads, for selected records against the whole session or file. - Add retained-closure traversal, the walk composed workflows require, reporting complete or incomplete. Direct reads make no closure claim. - Count direct and missing direct links in the summary. Fixes #8454 Co-Authored-By: Claude Opus 5.5 * fix(evidence): count a repeated Evidence link once in every delivery The Evidence schema does not deduplicate evidence_links, so the summary counted a repeated link twice while link rows and counts reported it once. Both now read one helper for a record's distinct direct links. Co-Authored-By: Claude Opus 5.5 * test(evidence): validate link delivery against the advertised schemas The test never called tools/list, so neither the shared validator nor the MCP client checked its replies against what each profile advertises. A schema that rejected the links result could have passed. Fetch the advertised definition and validate every request and reply against it. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/mcp-contracts.md | 19 +- src/application/EvidenceBundleCommands.ts | 4 +- .../investigation/InspectEvidenceBundle.ts | 73 +++- src/cli/analysisViewCommands.ts | 4 +- src/contracts/sessionToolContracts.ts | 2 +- src/contracts/sessionToolSchemas.ts | 23 +- src/contracts/toolOutputSchemaGroups.ts | 2 + src/domain/analysisErrorPresentation.ts | 4 + src/domain/evidenceRecordSummary.ts | 161 ++++++- src/server/ToolResultDelivery.ts | 3 + .../bundleResourceRootCliMcp.test.ts | 11 +- .../tools/inspectorFileUrlIdentity.test.ts | 9 +- .../reconstructionScopedUnknowns.test.ts | 9 +- tests/boundary/cli/analysisViewCli.test.ts | 48 +++ .../mcp/evidenceLinkInspectionMcp.test.ts | 398 ++++++++++++++++++ .../mcp/workflowSnapshotReplayMcp.test.ts | 8 +- .../process/mcpVerifierResults.test.ts | 2 + 17 files changed, 736 insertions(+), 44 deletions(-) create mode 100644 tests/boundary/mcp/evidenceLinkInspectionMcp.test.ts diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index ce4c7b75100..a08d611f80f 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -390,9 +390,26 @@ that revision. If a list response overflows, retry on the same connection with the same filters and offset and a smaller limit; export if one head still cannot fit. +To find missing sources before a downstream analysis refuses them, read +`missing_direct_links` from the summary, or call `get_evidence_bundle` with +`detail: "links"`. The result (`kind: "evidence-link-inspection"`) transfers no +record payload. `filters` select the starting records, while membership is +checked against every record in the session or file, so a filtered-out source is +`retained`, not missing. Rows (`evidence_id`, `linked_evidence_id`, `status`) are +in canonical order, default to `link_status: "missing"` and page with `offset` and +`limit` (default 64); `links` counts every inspected link regardless of that +selection. `traversal: "direct"` (the default) reads the selected records' own +links and sets `retained_closure` to null, because it makes no closure claim. +`traversal: "retained-closure"` also follows retained links, as composed workflows +such as `build_call_path` do, and reports `complete` or `incomplete`; anything +beyond a missing link remains unknown. Missing means absent from this session or +file, not nonexistent, and neither status describes analysis coverage. Importing +a missing source changes this membership without changing any Evidence ID. + For a portable exported bundle, the CLI shares this projection: `rea inspect-evidence-bundle '{"path":"/tmp/evidence.json","detail":"summary","filters":{"operation":"analyze_function"}}' --format json`. -`"detail":"records"` with an `evidence_id` filter returns that complete record. +`"detail":"records"` with an `evidence_id` filter returns that complete record, +and `"detail":"links"` reads link membership. The CLI validates and materializes that local bundle under its existing memory budget; summary delivery does not make arbitrary large files cheap to import. diff --git a/src/application/EvidenceBundleCommands.ts b/src/application/EvidenceBundleCommands.ts index d88e555dd52..26bf80e4377 100644 --- a/src/application/EvidenceBundleCommands.ts +++ b/src/application/EvidenceBundleCommands.ts @@ -297,10 +297,12 @@ const projectWrite = ( export const inspectEvidenceBundleCommand = async ( input: { readonly path: string; - readonly detail: "complete" | "summary" | "records"; + readonly detail: "complete" | "summary" | "records" | "links"; readonly filters?: EvidenceRecordFilters | undefined; readonly offset?: number | undefined; readonly limit?: number | undefined; + readonly traversal?: "direct" | "retained-closure" | undefined; + readonly link_status?: "missing" | "retained" | "all" | undefined; }, signal?: AbortSignal, ) => { diff --git a/src/application/investigation/InspectEvidenceBundle.ts b/src/application/investigation/InspectEvidenceBundle.ts index 8831becc2f8..1a186bb02c2 100644 --- a/src/application/investigation/InspectEvidenceBundle.ts +++ b/src/application/investigation/InspectEvidenceBundle.ts @@ -2,24 +2,41 @@ import type { EvidenceBundle } from "../../domain/evidenceBundle.js"; import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import { err, ok, type Result } from "../../domain/result.js"; import { + inspectEvidenceLinks, selectEvidenceRecords, summarizeEvidenceBundle, type EvidenceBundleSummary, + type EvidenceLinkInspection, type EvidenceRecordFilters, type EvidenceRecordSelection, } from "../../domain/evidenceRecordSummary.js"; -/** Select complete bundle delivery, discovery metadata, or a page of complete selected records. */ +type InspectionDetail = "complete" | "summary" | "records" | "links"; + +/** Fields owned by particular deliveries, refused elsewhere rather than ignored. */ +const DELIVERY_FIELDS = [ + ["offset", ["records", "links"]], + ["limit", ["records", "links"]], + ["traversal", ["links"]], + ["link_status", ["links"]], +] as const; + +/** Select complete delivery, discovery metadata, complete records, or link membership. */ export const inspectEvidenceBundle = ( bundle: EvidenceBundle, options: { - readonly detail: "complete" | "summary" | "records"; + readonly detail: InspectionDetail; readonly filters?: EvidenceRecordFilters | undefined; readonly offset?: number | undefined; readonly limit?: number | undefined; + readonly traversal?: "direct" | "retained-closure" | undefined; + readonly link_status?: "missing" | "retained" | "all" | undefined; }, ): Result< - EvidenceBundle | EvidenceBundleSummary | EvidenceRecordSelection, + | EvidenceBundle + | EvidenceBundleSummary + | EvidenceRecordSelection + | EvidenceLinkInspection, AnalysisInputError > => { if (options.detail === "complete" && options.filters !== undefined) @@ -29,29 +46,34 @@ export const inspectEvidenceBundle = ( path: ["filters"], reason: "invalid_value", message: - "Record filters require detail: summary or records; complete returns the canonical bundle.", - expected: "detail: summary or records", + "Record filters require detail: summary, records or links; complete returns the canonical bundle.", + expected: "detail: summary, records or links", }, ]), ); - if (options.detail !== "records") { - const paging = (["offset", "limit"] as const).filter( - (field) => options[field] !== undefined, - ); - if (paging.length > 0) - return err( - new AnalysisInputError( - "get_evidence_bundle", - undefined, - paging.map((field) => ({ + const misplaced = DELIVERY_FIELDS.filter( + ([field, details]) => + options[field] !== undefined && + !(details as readonly InspectionDetail[]).includes(options.detail), + ); + if (misplaced.length > 0) + return err( + new AnalysisInputError( + "get_evidence_bundle", + undefined, + misplaced.map(([field, details]) => { + const expected = details + .map((detail) => `detail: ${detail}`) + .join(" or "); + return { path: [field], reason: "invalid_value" as const, - message: `${field} pages complete records and requires detail: records.`, - expected: "detail: records", - })), - ), - ); - } + message: `${field} requires ${expected}.`, + expected, + }; + }), + ), + ); if (options.detail === "records") return ok( selectEvidenceRecords(bundle, options.filters, { @@ -59,6 +81,15 @@ export const inspectEvidenceBundle = ( limit: options.limit, }), ); + if (options.detail === "links") + return ok( + inspectEvidenceLinks(bundle, options.filters, { + traversal: options.traversal, + linkStatus: options.link_status, + offset: options.offset, + limit: options.limit, + }), + ); return ok( options.detail === "summary" ? summarizeEvidenceBundle(bundle, options.filters) diff --git a/src/cli/analysisViewCommands.ts b/src/cli/analysisViewCommands.ts index a6fea44e697..427ab6709bd 100644 --- a/src/cli/analysisViewCommands.ts +++ b/src/cli/analysisViewCommands.ts @@ -26,12 +26,12 @@ export const registerAnalysisViewCommands = ( ): void => { cli.command(CLI_COMMANDS.inspectEvidenceBundle, { description: - "Inspect a local Evidence bundle completely, as discovery metadata, or as a page of selected complete records", + "Inspect a local Evidence bundle completely, as discovery metadata, as a page of selected complete records, or as direct-link and retained-closure membership.", args: z.object({ inputJson: z .string() .describe( - "JSON or JSON file with absolute path, detail, optional record filters, and records offset/limit", + "JSON or JSON file with absolute path, detail, optional record filters, records or links offset/limit, and links traversal/link_status", ), }), run: ({ args }) => diff --git a/src/contracts/sessionToolContracts.ts b/src/contracts/sessionToolContracts.ts index 87af0a7368f..44d5655bf65 100644 --- a/src/contracts/sessionToolContracts.ts +++ b/src/contracts/sessionToolContracts.ts @@ -173,7 +173,7 @@ export const SESSION_TOOL_CONTRACTS = [ ), session( "get_evidence_bundle", - "Return every retained Evidence record and residual unknown as a complete canonical bundle by default. detail=summary discovers retained records without copying or transferring their payloads; optionally filter by exact Evidence ID, operation, target SHA-256, analysis-profile digest or canonical native procedure address. Metadata distinguishes complete record retention from native value-flow truncation or unavailable/unknown coverage. detail=records returns a page (offset, limit) of the matching complete records with unchanged IDs and their missing direct evidence_links; it is not an importable bundle. Use a discovered evidence_id with inspect_analysis_view without restarting a provider. Filters apply to summary and records delivery.", + "Return every retained Evidence record and residual unknown as a complete canonical bundle by default. detail=summary discovers retained records without copying or transferring their payloads; optionally filter by exact Evidence ID, operation, target SHA-256, analysis-profile digest or canonical native procedure address. Metadata distinguishes complete record retention from native value-flow truncation or unavailable/unknown coverage. detail=records returns a page (offset, limit) of the matching complete records with unchanged IDs and their missing direct evidence_links; it is not an importable bundle. detail=links reads which evidence_links of the selected records this session or bundle retains, without their payloads: traversal retained-closure also follows retained links, the walk composed workflows require, and rows default to missing links. Use a discovered evidence_id with inspect_analysis_view without restarting a provider. Filters apply to summary, records and links delivery.", getEvidenceBundleInputSchema, ), { diff --git a/src/contracts/sessionToolSchemas.ts b/src/contracts/sessionToolSchemas.ts index a8a0f31adaa..25c344d7907 100644 --- a/src/contracts/sessionToolSchemas.ts +++ b/src/contracts/sessionToolSchemas.ts @@ -5,26 +5,41 @@ import { bundleComparisonInputSchema } from "../domain/bundleComparison.js"; import { prefixedDigestSchema } from "./../domain/digests.js"; import { + EVIDENCE_LINK_INSPECTION_LIMIT, EVIDENCE_RECORD_SELECTION_LIMIT, + evidenceLinkStatusFilterSchema, + evidenceLinkTraversalSchema, evidenceRecordFiltersSchema, } from "../domain/evidenceRecordSummary.js"; import { residualUnknownSchema } from "../domain/residualUnknown.js"; -/** Complete retained Evidence, selected discovery metadata, or a page of selected complete records. */ +/** Complete retained Evidence, discovery metadata, a page of complete records, or link membership. */ export const getEvidenceBundleInputSchema = z.strictObject({ - detail: z.enum(["complete", "summary", "records"]).default("complete"), + detail: z + .enum(["complete", "summary", "records", "links"]) + .default("complete"), filters: evidenceRecordFiltersSchema.optional(), offset: z .int() .nonnegative() .optional() - .describe("records only: first matching record to return; default 0."), + .describe("records or links: first matching row to return; default 0."), limit: z .int() .positive() .optional() .describe( - `records only: maximum complete records to return; default ${String(EVIDENCE_RECORD_SELECTION_LIMIT)}.`, + `records: maximum complete records, default ${String(EVIDENCE_RECORD_SELECTION_LIMIT)}. links: maximum link rows, default ${String(EVIDENCE_LINK_INSPECTION_LIMIT)}.`, + ), + traversal: evidenceLinkTraversalSchema + .optional() + .describe( + "links only: direct (default) reads the selected records' own links; retained-closure also follows retained links.", + ), + link_status: evidenceLinkStatusFilterSchema + .optional() + .describe( + "links only: rows to return, default missing. Counts always cover every inspected link.", ), }); diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 669eb0a0e8a..c418be82fdc 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -46,6 +46,7 @@ import { jsonRecordSchema } from "../domain/jsonRecord.js"; import { byteSearchResultSchema } from "../domain/native/bytePattern.js"; import { evidenceBundleSummarySchema, + evidenceLinkInspectionSchema, evidenceRecordSelectionSchema, } from "../domain/evidenceRecordSummary.js"; import { liveBinaryTargetSummaryVariants } from "../domain/liveBinaryTargetSummary.js"; @@ -537,6 +538,7 @@ export const sessionOutputSchemas = { evidenceBundleSchema, evidenceBundleSummarySchema, evidenceRecordSelectionSchema, + evidenceLinkInspectionSchema, ]), ), get_navigation_context: lifecycleResultOf( diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 76a4e0ff401..22853036991 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -56,6 +56,10 @@ export const MCP_TRANSPORT_EVIDENCE_DISCOVERY = `Discover retained records with /** Next step when one MCP frame cannot carry a selected page of complete records. */ export const MCP_TRANSPORT_EVIDENCE_RECORDS = `Request fewer records with get_evidence_bundle detail: records, a smaller limit or an exact evidence_id filter; inspect compatible records in pages through inspect_analysis_view. A single record larger than one response needs export. ${MCP_TRANSPORT_EVIDENCE_EXPORT}`; +/** Next step when one MCP frame cannot carry a selected page of link rows. */ +export const MCP_TRANSPORT_EVIDENCE_LINKS = + "Request fewer rows with get_evidence_bundle detail: links, a smaller limit or an exact evidence_id filter, then follow next_offset."; + /** Name the next step that is true for how long the caller keeps REA's runtime and session. */ export const analysisErrorRemediationAction = ( error: AnalysisError, diff --git a/src/domain/evidenceRecordSummary.ts b/src/domain/evidenceRecordSummary.ts index 01c4ed22785..373d028fdd5 100644 --- a/src/domain/evidenceRecordSummary.ts +++ b/src/domain/evidenceRecordSummary.ts @@ -53,6 +53,12 @@ export const evidenceBundleSummarySchema = z.strictObject({ total_retained_records: countSchema, matching_records: countSchema, unknown_revisions: countSchema, + direct_links: countSchema.describe( + "Distinct evidence_links of each matching record; detail links names them.", + ), + missing_direct_links: countSchema.describe( + "Those direct links whose records this session or bundle does not retain. Zero establishes no transitive closure or analysis coverage.", + ), records: z.array( z.strictObject({ evidence_id: evidenceSchema.shape.evidence_id, @@ -149,13 +155,24 @@ function* matchingEvidenceRecords( } } +/** A record's distinct direct links; the Evidence schema does not deduplicate them. */ +const directLinksOf = ({ evidence_links: links }: Evidence): string[] => + uniqueSorted(links); + /** Discover retained Evidence without reading or copying its complete payloads. */ export const summarizeEvidenceBundle = ( bundle: EvidenceBundle, filters: EvidenceRecordFilters = {}, ): EvidenceBundleSummary => { const records: EvidenceBundleSummary["records"] = []; - for (const { evidence, native } of matchingEvidenceRecords(bundle, filters)) + const retained = new Set(bundle.records.map(({ evidence_id: id }) => id)); + let directLinks = 0; + let missingDirectLinks = 0; + for (const { evidence, native } of matchingEvidenceRecords(bundle, filters)) { + for (const link of directLinksOf(evidence)) { + directLinks += 1; + if (!retained.has(link)) missingDirectLinks += 1; + } records.push({ evidence_id: evidence.evidence_id, subject: evidence.subject, @@ -168,12 +185,15 @@ export const summarizeEvidenceBundle = ( limitation_count: evidence.limitations.length, native_dossier: native, }); + } return { kind: "evidence-bundle-summary", filters: selectedFilters(filters), total_retained_records: bundle.records.length, matching_records: records.length, unknown_revisions: bundle.unknowns.length, + direct_links: directLinks, + missing_direct_links: missingDirectLinks, records, }; }; @@ -243,3 +263,142 @@ export const selectEvidenceRecords = ( limitations: [...EVIDENCE_RECORD_SELECTION_LIMITATIONS], }; }; + +/** How far link inspection reads from the selected records. */ +export const evidenceLinkTraversalSchema = z.enum([ + "direct", + "retained-closure", +]); +/** Which inspected links a page returns; counts always cover all of them. */ +export const evidenceLinkStatusFilterSchema = z.enum([ + "missing", + "retained", + "all", +]); + +/** Default page size for link rows, which carry two identifiers each. */ +export const EVIDENCE_LINK_INSPECTION_LIMIT = 64; + +/** Bounds what link membership establishes, in every inspection. */ +export const EVIDENCE_LINK_INSPECTION_LIMITATIONS = [ + "Link status is membership in this session or bundle: missing means absent here, not nonexistent, and neither status describes analysis coverage or a record's meaning.", +] as const; + +/** Direct links, or the retained closure, of selected records without their payloads. */ +export const evidenceLinkInspectionSchema = z.strictObject({ + kind: z.literal("evidence-link-inspection"), + filters: z.record(z.string(), z.string()), + traversal: evidenceLinkTraversalSchema, + link_status: evidenceLinkStatusFilterSchema, + total_retained_records: countSchema, + matching_records: countSchema, + examined_records: countSchema.describe( + "Retained records whose evidence_links were read: the matching records, plus those reached through retained links when traversal is retained-closure.", + ), + links: z + .strictObject({ + total: countSchema, + retained: countSchema, + missing: countSchema, + }) + .describe("Every link read from the examined records, before link_status."), + retained_closure: z + .enum(["complete", "incomplete"]) + .nullable() + .describe( + "With retained-closure traversal, incomplete means a link could not be followed, so what lies beyond it remains unknown. Null for direct traversal or an empty selection, which make no closure claim.", + ), + offset: countSchema, + limit: z.int().positive(), + matching_links: countSchema, + next_offset: countSchema.nullable(), + rows: z.array( + z.strictObject({ + evidence_id: prefixedDigestSchema("ev"), + linked_evidence_id: prefixedDigestSchema("ev"), + status: z.enum(["retained", "missing"]), + }), + ), + limitations: z.array(z.string()), +}); +export type EvidenceLinkInspection = z.infer< + typeof evidenceLinkInspectionSchema +>; + +/** Read link membership without copying or parsing any record payload. */ +export const inspectEvidenceLinks = ( + bundle: EvidenceBundle, + filters: EvidenceRecordFilters = {}, + options: { + readonly traversal?: + | z.infer + | undefined; + readonly linkStatus?: + | z.infer + | undefined; + readonly offset?: number | undefined; + readonly limit?: number | undefined; + } = {}, +): EvidenceLinkInspection => { + const traversal = options.traversal ?? "direct"; + const linkStatus = options.linkStatus ?? "missing"; + const offset = options.offset ?? 0; + const limit = options.limit ?? EVIDENCE_LINK_INSPECTION_LIMIT; + const retained = new Map( + bundle.records.map((record) => [record.evidence_id, record]), + ); + const examined = new Set(); + const pending: string[] = []; + for (const { evidence } of matchingEvidenceRecords(bundle, filters)) + pending.push(evidence.evidence_id); + const matching = pending.length; + while (pending.length > 0) { + const id = pending.pop(); + if (id === undefined || examined.has(id)) continue; + examined.add(id); + if (traversal === "direct") continue; + for (const link of retained.get(id)?.evidence_links ?? []) + if (retained.has(link) && !examined.has(link)) pending.push(link); + } + const rows: EvidenceLinkInspection["rows"] = []; + const counts = { total: 0, retained: 0, missing: 0 }; + let matchingLinks = 0; + // Bundle order is canonical, so a page stays stable across requests. + for (const evidence of bundle.records) { + const id = evidence.evidence_id; + if (!examined.has(id)) continue; + for (const link of directLinksOf(evidence)) { + const status = retained.has(link) ? "retained" : "missing"; + counts.total += 1; + counts[status] += 1; + if (linkStatus !== "all" && linkStatus !== status) continue; + if (matchingLinks >= offset && rows.length < limit) + rows.push({ evidence_id: id, linked_evidence_id: link, status }); + matchingLinks += 1; + } + } + const end = offset + rows.length; + return { + kind: "evidence-link-inspection", + filters: selectedFilters(filters), + traversal, + link_status: linkStatus, + total_retained_records: bundle.records.length, + matching_records: matching, + examined_records: examined.size, + links: counts, + // Nothing selected, or a direct read, supports no claim about a closure. + retained_closure: + traversal === "direct" || examined.size === 0 + ? null + : counts.missing === 0 + ? "complete" + : "incomplete", + offset, + limit, + matching_links: matchingLinks, + next_offset: end < matchingLinks ? end : null, + rows, + limitations: [...EVIDENCE_LINK_INSPECTION_LIMITATIONS], + }; +}; diff --git a/src/server/ToolResultDelivery.ts b/src/server/ToolResultDelivery.ts index f16ae5b30a8..594ed43bcd7 100644 --- a/src/server/ToolResultDelivery.ts +++ b/src/server/ToolResultDelivery.ts @@ -16,6 +16,7 @@ import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js" import { MCP_TRANSPORT_EVIDENCE_DISCOVERY, MCP_TRANSPORT_EVIDENCE_EXPORT, + MCP_TRANSPORT_EVIDENCE_LINKS, MCP_TRANSPORT_EVIDENCE_RECORDS, } from "../domain/analysisErrorPresentation.js"; import { @@ -85,6 +86,8 @@ const evidenceBundleOverflowStep = (value: JsonValue): string => { // A summary already used detail: summary; export writes outside one frame. if (projection === "evidence-bundle-summary") return MCP_TRANSPORT_EVIDENCE_EXPORT; + if (projection === "evidence-link-inspection") + return MCP_TRANSPORT_EVIDENCE_LINKS; return projection === "evidence-record-selection" ? MCP_TRANSPORT_EVIDENCE_RECORDS : MCP_TRANSPORT_EVIDENCE_DISCOVERY; diff --git a/tests/acceptance/applications/bundleResourceRootCliMcp.test.ts b/tests/acceptance/applications/bundleResourceRootCliMcp.test.ts index f9aa6a7532f..a0df8e8c2c1 100644 --- a/tests/acceptance/applications/bundleResourceRootCliMcp.test.ts +++ b/tests/acceptance/applications/bundleResourceRootCliMcp.test.ts @@ -1,3 +1,4 @@ +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; import { build } from "plist"; import { lstat, mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -216,11 +217,13 @@ cliTest.skipIf(process.platform !== "darwin")( const retained = await call("get_evidence_bundle", {}); expect(retained.isError).not.toBe(true); - const evidenceBundle = toolContract( - "get_evidence_bundle", - ).outputSchema.parse(retained.structuredContent); + const evidenceBundle = evidenceBundleSchema.parse( + toolContract("get_evidence_bundle").outputSchema.parse( + retained.structuredContent, + ).result, + ); for (const operation of resourceOperations) { - const record = evidenceBundle.result.records.find( + const record = evidenceBundle.records.find( (record) => record.operation === operation, ); expectResourceEvidence(record, operation, firstBundle); diff --git a/tests/acceptance/tools/inspectorFileUrlIdentity.test.ts b/tests/acceptance/tools/inspectorFileUrlIdentity.test.ts index e36bc6659ae..a465ce02b8f 100644 --- a/tests/acceptance/tools/inspectorFileUrlIdentity.test.ts +++ b/tests/acceptance/tools/inspectorFileUrlIdentity.test.ts @@ -1,3 +1,4 @@ +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; import { spawn } from "node:child_process"; import { once } from "node:events"; import { readFile, writeFile } from "node:fs/promises"; @@ -117,9 +118,11 @@ it("preserves file URL scheme spelling and script identity after real Inspector const retained = await call("get_evidence_bundle", {}); expect(retained.isError, JSON.stringify(retained.content)).not.toBe(true); - const records = toolContract("get_evidence_bundle").outputSchema.parse( - retained.structuredContent, - ).result.records; + const { records } = evidenceBundleSchema.parse( + toolContract("get_evidence_bundle").outputSchema.parse( + retained.structuredContent, + ).result, + ); const original = records.find( (record) => record.evidence_id === first.evidence_id, ); diff --git a/tests/acceptance/tools/reconstructionScopedUnknowns.test.ts b/tests/acceptance/tools/reconstructionScopedUnknowns.test.ts index 9ff527edda4..0463442fe61 100644 --- a/tests/acceptance/tools/reconstructionScopedUnknowns.test.ts +++ b/tests/acceptance/tools/reconstructionScopedUnknowns.test.ts @@ -1,3 +1,4 @@ +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; import { expect, it } from "vitest"; import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js"; @@ -141,9 +142,11 @@ it.each(["pseudocode", "overall"] as const)( claims: { items: [{ unknown_ids: [] }] }, }); const exported = await call("get_evidence_bundle", { detail: "complete" }); - const bundle = toolContract("get_evidence_bundle").outputSchema.parse( - exported.structuredContent, - ).result; + const bundle = evidenceBundleSchema.parse( + toolContract("get_evidence_bundle").outputSchema.parse( + exported.structuredContent, + ).result, + ); for (const source of [left, right, comparison]) expect( bundle.records.find( diff --git a/tests/boundary/cli/analysisViewCli.test.ts b/tests/boundary/cli/analysisViewCli.test.ts index c0c10c32460..d4b0aac2137 100644 --- a/tests/boundary/cli/analysisViewCli.test.ts +++ b/tests/boundary/cli/analysisViewCli.test.ts @@ -719,6 +719,54 @@ cliTest( }, ); +cliTest( + "reads link membership from a deliberately partial portable bundle", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-evidence-links-cli-"); + const path = join(root, "partial.json"); + const absent = analysisViewLayoutEvidence(); + const linked = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "trace_feature", + parameters: {}, + result: {}, + evidenceLinks: [absent.evidence_id], + }, + ); + const bundle = createEvidenceBundle([linked]); + await writeFile(path, JSON.stringify(bundle)); + const options = { + detail: "links", + traversal: "retained-closure", + } as const; + const result = await cli.run({ + arguments: [ + "inspect-evidence-bundle", + JSON.stringify({ path, ...options }), + "--json", + ], + environment: { HOME: root, XDG_CONFIG_HOME: root, XDG_CACHE_HOME: root }, + }); + expect(result.exitCode).toBe(0); + const expected = inspectEvidenceBundle(bundle, options); + if (!expected.ok) throw expected.error; + expect(result.json).toEqual(expected.value); + expect(result.json).toMatchObject({ + kind: "evidence-link-inspection", + retained_closure: "incomplete", + rows: [ + { + evidence_id: linked.evidence_id, + linked_evidence_id: absent.evidence_id, + status: "missing", + }, + ], + }); + }, +); + cliTest( "formats the full caller-admitted native inventory page as Markdown and JSON", async ({ cli }) => { diff --git a/tests/boundary/mcp/evidenceLinkInspectionMcp.test.ts b/tests/boundary/mcp/evidenceLinkInspectionMcp.test.ts new file mode 100644 index 00000000000..2981381ca35 --- /dev/null +++ b/tests/boundary/mcp/evidenceLinkInspectionMcp.test.ts @@ -0,0 +1,398 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterAll, expect, it } from "vitest"; +import { z } from "zod"; +import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js"; +import { MCP_TRANSPORT_EVIDENCE_LINKS } from "../../../src/domain/analysisErrorPresentation.js"; +import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { + EVIDENCE_LINK_INSPECTION_LIMIT, + EVIDENCE_LINK_INSPECTION_LIMITATIONS, + evidenceBundleSummarySchema, + evidenceLinkInspectionSchema, +} from "../../../src/domain/evidenceRecordSummary.js"; +import { createToolResultDelivery } from "../../../src/server/toolResult.js"; +import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const schemaValidation = createMcpSchemaValidation(); +afterAll(() => schemaValidation.clear()); + +const GHIDRA = { id: "ghidra", name: "Ghidra", version: "12.1.4" }; +const TARGET = { + path: "/fixtures/a.macho", + format: "mach-o" as const, + sha256: "a".repeat(64), +}; +const record = (operation: string, evidenceLinks: readonly string[] = []) => + createEvidence(TARGET, GHIDRA, { + operation, + parameters: {}, + result: { operation }, + evidenceLinks, + }); + +// top -> middle -> absent source; top -> leaf; unrelated stands alone. +const source = record("xrefs"); +const leaf = record("procedure_address"); +const middle = record("trace_feature", [source.evidence_id]); +const top = record("analyze_function_summary", [ + middle.evidence_id, + leaf.evidence_id, +]); +const unrelated = record("list_names"); +const PARTIAL = [top, middle, leaf, unrelated]; + +const connect = async ( + options: { + readonly profile?: "full" | "compact"; + readonly records?: readonly ReturnType[]; + readonly maximumResponseBytes?: number; + } = {}, +) => { + const session = createTestBinarySession(() => { + throw new Error("Link inspection must not open a provider"); + }); + const { client } = await createApplicationMcpHarness({ + session, + clientIdentity: { name: "evidence-link-inspection", version: "1" }, + schemaValidator: schemaValidation, + serverOptions: { + environment: { + ...process.env, + REA_MCP_INPUT_SCHEMA_PROFILE: options.profile ?? "full", + REA_MCP_OUTPUT_SCHEMA_PROFILE: options.profile ?? "full", + }, + ...(options.maximumResponseBytes === undefined + ? {} + : { delivery: createToolResultDelivery(options.maximumResponseBytes) }), + }, + }); + for (const retained of options.records ?? PARTIAL) + expect(session.recordEvidence(retained).ok).toBe(true); + const reply = (args: Record) => + client.callTool({ name: "get_evidence_bundle", arguments: args }); + // Every delivered result must satisfy what this profile actually advertises. + const { tools } = await client.listTools(); + const advertised = tools.find(({ name }) => name === "get_evidence_bundle"); + if (advertised?.outputSchema === undefined) + throw new Error("get_evidence_bundle advertises no output schema"); + const inputSchema: Record = advertised.inputSchema; + const outputSchema: Record = advertised.outputSchema; + const validation = schemaValidation.assertions; + expect(validation.validateSchema(inputSchema)).toBe(true); + expect(validation.validateSchema(outputSchema)).toBe(true); + const resultOf = async (args: Record) => { + expect( + validation.validate(inputSchema, args), + JSON.stringify(validation.errors), + ).toBe(true); + const result = await reply(args); + expect(result.isError, JSON.stringify(result)).not.toBe(true); + expect( + validation.validate(outputSchema, result.structuredContent), + JSON.stringify(validation.errors), + ).toBe(true); + return z.object({ result: z.unknown() }).parse(result.structuredContent) + .result; + }; + const links = async (args: Record = {}) => + evidenceLinkInspectionSchema.parse( + await resultOf({ detail: "links", ...args }), + ); + const summary = async (args: Record = {}) => + evidenceBundleSummarySchema.parse( + await resultOf({ detail: "summary", ...args }), + ); + return { client, session, reply, links, summary }; +}; + +const exactly = (record_: { readonly evidence_id: string }) => ({ + filters: { evidence_id: record_.evidence_id }, +}); + +it.each(["full", "compact"] as const)( + "reads direct membership against the whole session, not the selection (%s)", + async (profile) => { + const { links, summary } = await connect({ profile }); + + // A retained-but-unselected source is retained, and direct makes no closure claim. + const selected = await links(exactly(top)); + expect(selected).toEqual({ + kind: "evidence-link-inspection", + filters: exactly(top).filters, + traversal: "direct", + link_status: "missing", + total_retained_records: 4, + matching_records: 1, + examined_records: 1, + links: { total: 2, retained: 2, missing: 0 }, + retained_closure: null, + offset: 0, + limit: EVIDENCE_LINK_INSPECTION_LIMIT, + matching_links: 0, + next_offset: null, + rows: [], + limitations: [...EVIDENCE_LINK_INSPECTION_LIMITATIONS], + }); + + // The record that names the absent source reports it without any payload. + const missing = await links(exactly(middle)); + expect(missing.links).toEqual({ total: 1, retained: 0, missing: 1 }); + expect(missing.rows).toEqual([ + { + evidence_id: middle.evidence_id, + linked_evidence_id: source.evidence_id, + status: "missing", + }, + ]); + expect(JSON.stringify(missing)).not.toContain("trace_feature"); + + // The summary counts direct links only for its matching records. + expect(await summary()).toMatchObject({ + matching_records: 4, + direct_links: 3, + missing_direct_links: 1, + }); + expect(await summary(exactly(top))).toMatchObject({ + matching_records: 1, + direct_links: 2, + missing_direct_links: 0, + }); + }, +); + +it("follows retained links only when asked and leaves the rest unknown", async () => { + const { links } = await connect(); + const walk = { traversal: "retained-closure" }; + + // Top looks closed directly, but its closure reaches the absent source. + const closure = await links({ ...exactly(top), ...walk }); + expect(closure).toMatchObject({ + traversal: "retained-closure", + matching_records: 1, + examined_records: 3, + links: { total: 3, retained: 2, missing: 1 }, + retained_closure: "incomplete", + rows: [ + { + evidence_id: middle.evidence_id, + linked_evidence_id: source.evidence_id, + status: "missing", + }, + ], + }); + + // A branch without an absent link is complete, including a record with no links. + expect(await links({ ...exactly(leaf), ...walk })).toMatchObject({ + examined_records: 1, + links: { total: 0, retained: 0, missing: 0 }, + retained_closure: "complete", + }); + + // Selecting nothing supports no claim about a closure. + expect( + await links({ filters: { operation: "absent_operation" }, ...walk }), + ).toMatchObject({ + matching_records: 0, + examined_records: 0, + links: { total: 0 }, + retained_closure: null, + rows: [], + }); +}); + +it("counts a repeated link once in every delivery", async () => { + const repeated = record("trace_feature", [ + source.evidence_id, + source.evidence_id, + ]); + const { links, summary } = await connect({ records: [repeated] }); + expect(await summary()).toMatchObject({ + direct_links: 1, + missing_direct_links: 1, + }); + const inspected = await links(); + expect(inspected.links).toEqual({ total: 1, retained: 0, missing: 1 }); + expect(inspected.rows).toHaveLength(1); +}); + +it("pages selected link rows in a stable order without skips or duplicates", async () => { + const { links } = await connect(); + const everything = await links({ link_status: "all" }); + expect(everything).toMatchObject({ + link_status: "all", + links: { total: 3, retained: 2, missing: 1 }, + matching_links: 3, + next_offset: null, + }); + const key = ({ + evidence_id: id, + linked_evidence_id: link, + }: (typeof everything.rows)[number]) => `${id}>${link}`; + const order = everything.rows.map(key); + expect(order).toEqual( + [...order].sort((left, right) => (left < right ? -1 : 1)), + ); + + const paged: string[] = []; + let offset: number | null = 0; + while (offset !== null) { + const page: z.output = await links({ + link_status: "all", + offset, + limit: 1, + }); + expect(page.rows).toHaveLength(1); + // Counts describe every inspected link on every page. + expect(page.links.total).toBe(3); + paged.push(...page.rows.map(key)); + offset = page.next_offset; + } + expect(paged).toEqual(order); + + const retainedOnly = await links({ link_status: "retained" }); + expect(retainedOnly.matching_links).toBe(2); + expect(retainedOnly.rows.every(({ status }) => status === "retained")).toBe( + true, + ); + expect(await links({ link_status: "all", offset: 9 })).toMatchObject({ + matching_links: 3, + next_offset: null, + rows: [], + }); +}); + +it("reports the source a composed workflow refuses, and clears it on import", async () => { + const dossier = FUNCTION_COMPARISON_EXAMPLE.left; + if (dossier.subject === null) throw new Error("Expected a subject"); + const health = record("get_project_health"); + const linked = createEvidence( + { + path: dossier.subject.local_path, + sha256: dossier.subject.digest.sha256, + format: dossier.subject.format, + }, + dossier.provider, + { + operation: dossier.operation, + parameters: dossier.parameters, + result: dossier.normalized_result, + confidence: dossier.confidence, + authority: dossier.authority, + evidenceLinks: [health.evidence_id], + }, + ); + const { client, links, summary } = await connect({ records: [linked] }); + const walk = { traversal: "retained-closure" }; + expect(await links(walk)).toMatchObject({ + retained_closure: "incomplete", + rows: [ + { + evidence_id: linked.evidence_id, + linked_evidence_id: health.evidence_id, + }, + ], + }); + expect(await summary()).toMatchObject({ missing_direct_links: 1 }); + + const procedure = z + .looseObject({ procedure: z.looseObject({ address: z.string() }) }) + .parse(dossier.normalized_result).procedure.address; + const callPath = () => + client.callTool({ + name: "build_call_path", + arguments: { + start: { address: procedure }, + goal: { address: procedure }, + functions: [ + { kind: "retained-evidence", evidence_id: linked.evidence_id }, + ], + }, + }); + // The inspection predicts the same absent ID the workflow refuses. + expect(JSON.stringify(parseMcpToolError(await callPath()))).toContain( + health.evidence_id, + ); + + const root = await createTestTempDirectory("rea-evidence-links-"); + const bundlePath = join(root, "source.json"); + await writeFile(bundlePath, JSON.stringify(createEvidenceBundle([health]))); + const imported = await client.callTool({ + name: "import_evidence_bundle", + arguments: { path: bundlePath }, + }); + expect(imported.isError, JSON.stringify(imported)).not.toBe(true); + + // Membership changes, the original record does not, and the workflow proceeds. + expect(await links(walk)).toMatchObject({ + examined_records: 2, + links: { total: 1, retained: 1, missing: 0 }, + retained_closure: "complete", + rows: [], + }); + expect(await summary()).toMatchObject({ missing_direct_links: 0 }); + const proceeded = await callPath(); + expect(proceeded.isError, JSON.stringify(proceeded)).not.toBe(true); + expect(parseEvidence(proceeded.structuredContent).evidence_links).toContain( + linked.evidence_id, + ); +}); + +it("refuses link selections outside link delivery and names a smaller page", async () => { + const { reply } = await connect(); + for (const [detail, field, value, expected] of [ + ["summary", "traversal", "direct", "detail: links"], + ["records", "link_status", "all", "detail: links"], + ["complete", "traversal", "retained-closure", "detail: links"], + ["summary", "offset", 1, "detail: records or detail: links"], + ["summary", "limit", 1, "detail: records or detail: links"], + ] as const) + expect( + parseMcpToolError(await reply({ detail, [field]: value })).error, + ).toMatchObject({ + code: "invalid_request", + details: { + issues: [expect.objectContaining({ path: [field], expected })], + }, + }); + expect( + parseMcpToolError( + await reply({ detail: "complete", filters: exactly(top).filters }), + ).error, + ).toMatchObject({ code: "invalid_request" }); + + // Many links on one record overflow a small response budget with a named step. + const many = Array.from( + { length: 64_000 }, + (_, index) => `ev_${index.toString(16).padStart(64, "0")}`, + ); + const crowded = await connect({ + records: [record("trace_feature", many)], + maximumResponseBytes: 10 * 1024 * 1024, + }); + const large = await crowded.reply({ detail: "links", limit: 64_000 }); + expect(parseMcpToolError(large).error).toMatchObject({ + code: "resource_constraint", + remediation: { + action: expect.stringContaining(MCP_TRANSPORT_EVIDENCE_LINKS), + }, + }); + expect( + evidenceLinkInspectionSchema.parse( + z + .object({ result: z.unknown() }) + .parse( + (await crowded.reply({ detail: "links", limit: 2 })) + .structuredContent, + ).result, + ), + ).toMatchObject({ + links: { total: 64_000, missing: 64_000 }, + next_offset: 2, + }); +}); diff --git a/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts b/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts index 7660265016d..ef1dac92942 100644 --- a/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts +++ b/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts @@ -356,9 +356,11 @@ describe("MCP composed workflow snapshot replay", () => { arguments: {}, }); expect(retained.isError, JSON.stringify(retained.content)).not.toBe(true); - const retainedBundle = toolContract( - "get_evidence_bundle", - ).outputSchema.parse(retained.structuredContent).result; + const retainedBundle = evidenceBundleSchema.parse( + toolContract("get_evidence_bundle").outputSchema.parse( + retained.structuredContent, + ).result, + ); expect( retainedBundle.records.find( ({ evidence_id }) => evidence_id === primitiveEvidence.evidence_id, diff --git a/tests/boundary/process/mcpVerifierResults.test.ts b/tests/boundary/process/mcpVerifierResults.test.ts index 0aa4891be63..a249e08f80b 100644 --- a/tests/boundary/process/mcpVerifierResults.test.ts +++ b/tests/boundary/process/mcpVerifierResults.test.ts @@ -199,6 +199,8 @@ it("retains summary discovery in the generic lifecycle reader but refuses it as total_retained_records: 0, matching_records: 0, unknown_revisions: 0, + direct_links: 0, + missing_direct_links: 0, records: [], }; expect( From 6a0ccf7eeb4768a43560f63e723f0154881285ed Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Mon, 2 Nov 2026 05:14:50 -0800 Subject: [PATCH 02/38] fix(doctor): report provider settings a client registration does not record (#8645) * fix(doctor): report provider settings a client registration does not record Provider checks read the terminal running doctor, so a registration with no env block looked ready while its server reported not_configured. Compare what an available provider relied on with the registration's own file and report the setting names in a non-blocking registration_environment check. The client's effective environment stays unknown, so health and the exit status are unchanged. Fixes #8202 Co-Authored-By: Claude Opus 5.5 * test(doctor): write the Cordis fixture at its literal path The test resolved the path through supportedClients, whose default environment argument CodeQL traces to the fixture's writes even though the test passes {}. It only needed the path, so spell it like the other fixtures in this file and drop a leftover directory it never used. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/client-configuration.md | 4 + docs/installation.md | 14 + .../ClientRegistrationEnvironment.ts | 56 ++++ src/application/ClientRegistrationStatus.ts | 28 +- src/application/Doctor.ts | 112 ++++++- .../DoctorRegistrationEnvironment.test.ts | 184 +++++++++++ src/application/DoctorScope.ts | 20 ++ .../SetupRegistrationEnvironment.ts | 14 + .../cli/doctorRegistrationEnvironment.test.ts | 289 +++++++++++++++++ ...clientRegistrationProviderSettings.test.ts | 290 ++++++++++++++++++ 10 files changed, 1001 insertions(+), 10 deletions(-) create mode 100644 src/application/DoctorRegistrationEnvironment.test.ts create mode 100644 tests/boundary/cli/doctorRegistrationEnvironment.test.ts create mode 100644 tests/boundary/filesystem/clientRegistrationProviderSettings.test.ts diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 3461bd68551..87f55ed5305 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -45,6 +45,10 @@ the client's configured command or reproduce the client's environment. If doctor passes but the client disconnects, inspect the client's startup error and registered launcher; see [verification scope](client-verification.md#launch-environment-verification). +`rea doctor` has the same limit for provider checks, and reports a provider setting +the registration's file does not carry as a +[non-blocking unknown](installation.md#check-readiness-for-your-task). + ## Runtime settings and client deadlines `update_analysis_settings` changes curated defaults inside the running REA diff --git a/docs/installation.md b/docs/installation.md index 041df39f82e..a9ff7d110a0 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -390,6 +390,20 @@ A scoped report has `scope.mode: "explicit"`. Its `scope_checks` determine `environment_healthy` summarizes the full audit. `--target` adds a target check, but the report remains audit-wide unless a scope option is also supplied. +Provider checks read the terminal running doctor. A client-started server may +receive a different environment, and doctor cannot observe it. When an available +provider relies on a setting that a registration's file does not record, or +records with other text, doctor adds a non-blocking +`registration_environment:` check with `classification: "unknown"`. The +setting names appear in that registration's `provider_settings`. Selecting the +client or an affected provider places the check in `scope_checks`; it never +changes `healthy`, because the client may still pass the setting on. Preview +`rea setup --client --skill=false --dry-run` from that terminal and +approve it to record the settings, then restart the client. To see what the +running server can use, call `binary_session` in the client and read +`result.tool_availability`. For Hopper, only an explicit `HOPPER_LAUNCHER_PATH` +is compared. + An invalid `REA_LOG_LEVEL` fails every scope, including one client or the installed skill, because the executable rejects it before any command. A selected client also fails for every other setting that MCP startup rejects, diff --git a/src/application/ClientRegistrationEnvironment.ts b/src/application/ClientRegistrationEnvironment.ts index 8a1b84afa72..1173106394d 100644 --- a/src/application/ClientRegistrationEnvironment.ts +++ b/src/application/ClientRegistrationEnvironment.ts @@ -127,6 +127,62 @@ export const clientRegistrationEnvironmentDetails = ( return validatedDetails(environment, validation, platform); }; +/** Setting names a registration does not carry as the value selected here. */ +export interface RecordedProviderSettings { + /** Selected here and absent from the registration's recorded environment. */ + readonly unrecorded: readonly string[]; + /** Recorded with text other than the value selected here. */ + readonly differing: readonly string[]; + /** Recorded as an expression, or in a map REA could not read. */ + readonly unresolved: readonly string[]; +} + +/** + * Compare settings selected in this process with a registration's own file. + * The server's environment also depends on what its client passes on, which is + * not observable here, so a gap is never a claim about the effective value. + * Returns only names: recorded values stay in the file that owns them. + */ +export const recordedProviderSettings = ( + parsed: ClientConfigurationDocument, + selected: Readonly>, + platform: NodeJS.Platform = process.platform, +): RecordedProviderSettings | undefined => { + const names = Object.keys(selected).sort(compareUnicodeCodePoints); + if (names.length === 0) return undefined; + const details = clientRegistrationEnvironmentDetails( + parsed, + {}, + "fragment", + platform, + ); + if (!details.ok) return { unrecorded: [], differing: [], unresolved: names }; + const recorded = snapshotEnvironment(details.value.values, platform); + // Expression names fold like every other environment name on this platform. + const expressions = snapshotEnvironment( + Object.fromEntries( + [...details.value.unresolvedSettings].map((name) => [name, ""]), + ), + platform, + ); + const facts = { + unrecorded: names.filter( + (name) => recorded[name] === undefined && expressions[name] === undefined, + ), + differing: names.filter( + (name) => + recorded[name] !== undefined && recorded[name] !== selected[name], + ), + unresolved: names.filter((name) => expressions[name] !== undefined), + }; + return facts.unrecorded.length + + facts.differing.length + + facts.unresolved.length === + 0 + ? undefined + : facts; +}; + /** Resolve the async provider-specific budget through its retained reader owner. */ export const clientRegistrationEnvironmentDetailsWithBudget = async ( parsed: ClientConfigurationDocument | undefined, diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index 24266348a95..f1396eefa86 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -34,8 +34,14 @@ import { import type { SetupClient } from "./SupportedClients.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import { dshDocument } from "./DshCordisPatch.js"; -import { clientRegistrationEnvironmentDetailsWithBudget } from "./ClientRegistrationEnvironment.js"; -import type { ProviderRequestBudgetResolver } from "./ClientRegistrationEnvironment.js"; +import { + clientRegistrationEnvironmentDetailsWithBudget, + recordedProviderSettings, +} from "./ClientRegistrationEnvironment.js"; +import type { + ProviderRequestBudgetResolver, + RecordedProviderSettings, +} from "./ClientRegistrationEnvironment.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { @@ -52,6 +58,8 @@ interface ClientRegistrationStatusBase { readonly package_reference?: string; /** Non-blocking timeout guidance; caller-selected values remain aligned. */ readonly advisory?: string; + /** Provider settings this process selected that the registration's file does not carry. */ + readonly provider_settings?: RecordedProviderSettings; } type RegistrationCommand = readonly [string, ...string[]]; @@ -99,6 +107,8 @@ interface ClientRegistrationReadOptions { */ readonly evidence?: "readiness" | "ownership"; readonly resolveProviderRequestBudget?: ProviderRequestBudgetResolver; + /** Settings to compare with each registration's file; omitted skips that read. */ + readonly providerSettings?: Readonly>; } /** Registration facts shared by diagnostics and update maintenance. */ @@ -362,6 +372,13 @@ export const readClientRegistrationInspections = async ( registrationEnvironment.value.timeoutInputs, ) : undefined, + options.providerSettings === undefined + ? undefined + : recordedProviderSettings( + parsed, + options.providerSettings, + options.platform, + ), ), activation, commandFacts.identity, @@ -494,6 +511,7 @@ const configuredStatus = ( policyRemediation?: string, packageReference?: string, advisory?: string, + providerSettings?: RecordedProviderSettings, ): ClientRegistrationStatus => state === "aligned" ? { @@ -506,6 +524,9 @@ const configuredStatus = ( state, remediation: null, ...(advisory === undefined ? {} : { advisory }), + ...(providerSettings === undefined + ? {} + : { provider_settings: providerSettings }), } : { client: client.name, @@ -516,6 +537,9 @@ const configuredStatus = ( : { package_reference: packageReference }), state, remediation: policyRemediation ?? remediation(client), + ...(providerSettings === undefined + ? {} + : { provider_settings: providerSettings }), }; const timeoutAdvisory = ( diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 4d7092c5e9c..3b096e53ca4 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -57,6 +57,7 @@ import { type UnhealthyClientRegistrationStatus, } from "./ClientRegistrationStatus.js"; import type { ProviderRequestBudgetResolver } from "./ClientRegistrationEnvironment.js"; +import { registrationSpelledSettings } from "./SetupRegistrationEnvironment.js"; import { collectDoctorDiagnostics, doctorHealthy, @@ -146,6 +147,7 @@ export interface DoctorHost { close?(): Promise; clientRegistrations?( selectedClientIds?: readonly string[], + providerSettings?: Readonly>, ): Promise; ilspyCmdVersion?(path: string): Promise; } @@ -276,18 +278,26 @@ const inspectDoctor = async ( hopperPath, providerInspections, } = await collectDoctorDiagnostics(target, host); - const identity = await collectDoctorIdentity(host, scope); + const hopperAvailable = + hopperPath !== undefined && + diagnosticChecks + .filter(({ name }) => name.startsWith("hopper")) + .every(({ ok }) => ok); + const selections = providerSettingSelections( + providerInspections, + hopperAvailable && hopperPath === host.configuredHopperPath + ? hopperPath + : undefined, + ); + const identity = await collectDoctorIdentity(host, scope, selections); const checks = [ ...diagnosticChecks, ...identity.checks, ...registrationAdvisoryChecks(identity.value.registrations), + ...registrationEnvironmentChecks(identity.value.registrations, selections), ]; const providers = { - hopperAvailable: - hopperPath !== undefined && - checks - .filter(({ name }) => name.startsWith("hopper")) - .every(({ ok }) => ok), + hopperAvailable, hopperConfigured: host.configuredHopperPath !== undefined, providerInspections, }; @@ -329,6 +339,77 @@ const registrationAdvisoryChecks = ( ], ); +/** Settings an available provider's checks in this report relied on. */ +interface ProviderSettingSelection { + readonly provider: string; + readonly settings: Readonly>; +} + +const providerSettingSelections = ( + inspections: readonly DoctorProviderInspection[] | undefined, + configuredHopperPath: string | undefined, +): readonly ProviderSettingSelection[] => [ + ...(inspections ?? []) + .filter(({ available }) => available) + .map(({ id, registrationEnvironment }) => ({ + provider: id, + settings: registrationEnvironment, + })), + // Startup probes the fixed and renamed locations on its own, so only an + // explicit path is something this terminal selected for the server. + ...(configuredHopperPath === undefined + ? [] + : [ + { + provider: "hopper", + settings: { HOPPER_LAUNCHER_PATH: configuredHopperPath }, + }, + ]), +]; + +/** + * A healthy provider check describes this terminal, not the server a client + * starts. Report what the registration's file leaves out without gating health: + * the client may still pass the setting on, which doctor cannot observe. + */ +const registrationEnvironmentChecks = ( + registrations: readonly ClientRegistrationStatus[], + selections: readonly ProviderSettingSelection[], +): readonly DoctorCheck[] => + registrations.flatMap(({ client, provider_settings: settings }) => { + if (settings === undefined) return []; + const { unrecorded, differing, unresolved } = settings; + const names = [...unrecorded, ...differing, ...unresolved]; + return [ + { + name: `registration_environment:${client}`, + ok: true, + classification: "unknown" as const, + detail: [ + `Provider checks used this terminal's environment; what the server ${client} starts receives is unknown.`, + ...(unrecorded.length === 0 + ? [] + : [`Its registration does not record ${unrecorded.join(", ")}.`]), + ...(differing.length === 0 + ? [] + : [`It records other values for ${differing.join(", ")}.`]), + ...(unresolved.length === 0 + ? [] + : [`REA could not resolve its ${unresolved.join(", ")}.`]), + ].join(" "), + details: { + effective_environment: "unknown", + providers: selections + .filter(({ settings: selected }) => + names.some((name) => selected[name] !== undefined), + ) + .map(({ provider }) => provider), + }, + remediation: `Preview rea setup --client ${client} --skill=false --dry-run from this terminal, apply it to record these settings and restart the client, or read binary_session result.tool_availability in the client.`, + }, + ]; + }); + const closeDoctorHost = async ( host: DoctorHost, ): Promise => { @@ -369,12 +450,19 @@ const doctorReportWithCleanup = ( const collectDoctorIdentity = async ( host: DoctorHost, scope: DoctorScopeReport, + selections: readonly ProviderSettingSelection[], ): Promise<{ readonly checks: readonly DoctorCheck[]; readonly value: DoctorIdentity; }> => { const installationPaths = (await host.installationPaths?.()) ?? []; - const registrations = (await host.clientRegistrations?.(scope.clients)) ?? []; + const registrations = + (await host.clientRegistrations?.( + scope.clients, + Object.fromEntries( + selections.flatMap(({ settings }) => Object.entries(settings)), + ), + )) ?? []; const skillClientIds = scope.skill_clients ?? (scope.clients.length > 0 ? scope.clients : undefined); @@ -786,12 +874,20 @@ export const systemDoctorHost = ( throw failure; } }, - clientRegistrations: (selectedClientIds) => + clientRegistrations: (selectedClientIds, providerSettings) => readClientRegistrationStatuses(homeDirectory, undefined, { platform, environment, resources, ...(selectedClientIds === undefined ? {} : { selectedClientIds }), + ...(providerSettings === undefined + ? {} + : { + providerSettings: registrationSpelledSettings( + providerSettings, + environment, + ), + }), ...(resolveProviderRequestBudget === undefined ? {} : { diff --git a/src/application/DoctorRegistrationEnvironment.test.ts b/src/application/DoctorRegistrationEnvironment.test.ts new file mode 100644 index 00000000000..58844fafbbf --- /dev/null +++ b/src/application/DoctorRegistrationEnvironment.test.ts @@ -0,0 +1,184 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { createDoctorHostFixture as host } from "./Doctor.fixture.js"; +import { runDoctor, systemDoctorHost, type DoctorHost } from "./Doctor.js"; + +const inspection = ( + id: string, + registrationEnvironment: Readonly>, + available = true, +) => ({ + id, + configured: true, + available, + providerVersion: null, + registrationEnvironment, + checks: [], +}); +const recordingHost = (overrides: Partial) => { + const requested: Readonly>[] = []; + return { + requested, + host: host({ + clientRegistrations: (_clients, providerSettings) => { + requested.push(providerSettings ?? {}); + return Promise.resolve([ + { + client: "cursor", + config_path: "/home/user/.cursor/mcp.json", + command: ["/current/rea", "mcp"], + state: "aligned", + remediation: null, + provider_settings: { + unrecorded: ["GHIDRA_INSTALL_DIR"], + differing: [], + unresolved: [], + }, + }, + ]); + }, + ...overrides, + }), + }; +}; +const selectedProviders = { + providerInspections: () => + Promise.resolve([ + inspection("ghidra", { GHIDRA_INSTALL_DIR: "/opt/ghidra" }), + inspection("jadx", { REA_JADX_MCP_JAR: "/opt/jadx.jar" }), + inspection("kuna", { REA_KUNA_COMMAND: "/opt/kuna" }, false), + ]), +}; +const CHECK = "registration_environment:cursor"; + +it("compares only settings an available provider relied on", async () => { + const { host: selectedHost, requested } = recordingHost(selectedProviders); + await runDoctor(undefined, selectedHost); + expect(requested).toEqual([ + { + GHIDRA_INSTALL_DIR: "/opt/ghidra", + REA_JADX_MCP_JAR: "/opt/jadx.jar", + }, + ]); +}); + +it.each([ + [{ providers: ["ghidra"] }, true], + [{ clients: ["cursor"] }, true], + [{ clients: ["codex"] }, false], + [{ providers: ["jadx"] }, false], + [{ skill: true }, false], +])("follows the selected scope %j", async (scope, selected) => { + const report = await runDoctor( + undefined, + recordingHost(selectedProviders).host, + scope, + ); + const inScope = report.scope_checks.some(({ name }) => name === CHECK); + expect(inScope).toBe(selected); + expect(report.informational_checks.some(({ name }) => name === CHECK)).toBe( + !selected, + ); +}); + +it("does not gate health or name unaffected providers", async () => { + const report = await runDoctor( + undefined, + recordingHost(selectedProviders).host, + { providers: ["ghidra"] }, + ); + expect(report.healthy).toBe(true); + expect(report.checks).toContainEqual( + expect.objectContaining({ + name: CHECK, + ok: true, + classification: "unknown", + details: { effective_environment: "unknown", providers: ["ghidra"] }, + }), + ); +}); + +it.each([ + ["/custom/Hopper", { HOPPER_LAUNCHER_PATH: "/custom/Hopper" }], + // A default location is found again without this terminal's environment. + [undefined, {}], +])( + "includes Hopper only for an explicit available path (%s)", + async (configuredHopperPath, expected) => { + const { host: selectedHost, requested } = recordingHost( + configuredHopperPath === undefined ? {} : { configuredHopperPath }, + ); + await runDoctor(undefined, selectedHost); + expect(requested).toEqual([expected]); + }, +); + +it("does not include an explicit Hopper path that fails its checks", async () => { + const { host: selectedHost, requested } = recordingHost({ + configuredHopperPath: "/custom/missing", + executable: () => Promise.resolve(false), + }); + await runDoctor(undefined, selectedHost); + expect(requested).toEqual([{}]); +}); + +it("compares a caller selection as setup spells it", async () => { + const home = await createTestTempDirectory("rea-doctor-spelling-"); + await writeFile( + join(home, ".claude.json"), + JSON.stringify({ + mcpServers: { + rea: { + command: "/current/rea", + args: ["mcp"], + env: { + GHIDRA_INSTALL_DIR: "/opt/ghidra", + JAVA_HOME: "/selected/alias", + }, + }, + }, + }), + ); + const system = systemDoctorHost({ + environment: { HOME: home, JAVA_HOME: "/selected/alias" }, + }); + const statuses = await system.clientRegistrations?.(["claude_code"], { + GHIDRA_INSTALL_DIR: "/opt/ghidra", + // The provider's observed home; setup records the caller's spelling. + JAVA_HOME: "/observed/jdk", + }); + await system.close?.(); + expect(statuses?.[0]?.client).toBe("claude_code"); + expect(statuses?.[0]).not.toHaveProperty("provider_settings"); +}); + +it("states each comparison result without a claim it did not establish", async () => { + const report = await runDoctor( + undefined, + host({ + clientRegistrations: () => + Promise.resolve([ + { + client: "cursor", + config_path: "/cursor.json", + command: ["/current/rea", "mcp"], + state: "aligned", + remediation: null, + provider_settings: { + unrecorded: [], + differing: ["JAVA_HOME"], + unresolved: ["GHIDRA_INSTALL_DIR"], + }, + }, + ]), + }), + ); + const detail = report.checks.find(({ name }) => name === CHECK)?.detail; + expect(detail).toContain("what the server cursor starts receives is unknown"); + expect(detail).toContain("records other values for JAVA_HOME"); + expect(detail).toContain("could not resolve its GHIDRA_INSTALL_DIR"); + expect(detail).not.toContain("does not record"); +}); diff --git a/src/application/DoctorScope.ts b/src/application/DoctorScope.ts index 1c8b18d8362..870562b8c94 100644 --- a/src/application/DoctorScope.ts +++ b/src/application/DoctorScope.ts @@ -72,6 +72,9 @@ export const scopeDoctorChecks = ( input.registrations.find((candidate) => candidate.client === client), ), ); + const environmentChecks = input.checks.filter((check) => + registrationEnvironmentInScope(check, input.scope), + ); const skillChecks = input.scope.skill ? [selectedSkillCheck(input.skillState, input.checks)] : []; @@ -111,6 +114,7 @@ export const scopeDoctorChecks = ( ...hopperHostChecks, ...selectedProviderChecks, ...clientChecks, + ...environmentChecks, ...skillChecks, ]; const selectedNames = new Set(scopeChecks.map(({ name }) => name)); @@ -129,6 +133,22 @@ export const scopeDoctorChecks = ( }; }; +/** Follow the caller's question: a named client, or a provider it relies on. */ +const registrationEnvironmentInScope = ( + { name, details }: DoctorCheck, + scope: DoctorScopeReport, +): boolean => { + const [family, client] = name.split(":"); + if (family !== "registration_environment" || client === undefined) + return false; + const providers = details?.providers; + return ( + scope.clients.includes(client) || + (Array.isArray(providers) && + scope.providers.some((provider) => providers.includes(provider))) + ); +}; + const isProviderCheck = ( name: string, inspections: readonly DoctorProviderInspection[] | undefined, diff --git a/src/application/SetupRegistrationEnvironment.ts b/src/application/SetupRegistrationEnvironment.ts index 401e18806da..335c86488a3 100644 --- a/src/application/SetupRegistrationEnvironment.ts +++ b/src/application/SetupRegistrationEnvironment.ts @@ -107,3 +107,17 @@ export const providerRegistrationEnvironment = ( ) .sort(([left], [right]) => compareUnicodeCodePoints(left, right)), ); + +/** Spell selected settings as setup records them: an explicit selection wins. */ +export const registrationSpelledSettings = ( + settings: Readonly>, + environment: Readonly>, +): Readonly> => { + const explicit = explicitSetupRegistrationEnvironment(environment); + return Object.fromEntries( + Object.entries(settings).map(([key, value]) => [ + key, + explicit[key] ?? value, + ]), + ); +}; diff --git a/tests/boundary/cli/doctorRegistrationEnvironment.test.ts b/tests/boundary/cli/doctorRegistrationEnvironment.test.ts new file mode 100644 index 00000000000..888c6e186d5 --- /dev/null +++ b/tests/boundary/cli/doctorRegistrationEnvironment.test.ts @@ -0,0 +1,289 @@ +import { chmod, mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { z } from "zod"; + +import { elf, thinMach } from "../../../src/domain/binaryTarget.fixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; + +/** An installation doctor admits; nothing here starts an analysis JVM. */ +const writeAdmittedGhidra = async (root: string) => { + const jdk = join(root, "jdk"); + const install = join(root, "ghidra"); + const native = join( + install, + "Ghidra/Features/Decompiler/os", + `${process.platform === "darwin" ? "mac" : "linux"}_${process.arch === "arm64" ? "arm_64" : "x86_64"}`, + "decompile", + ); + for (const path of [ + join(jdk, "bin"), + join(install, "support"), + join(native, ".."), + ]) + await mkdir(path, { recursive: true }); + await writeFile( + join(install, "Ghidra/application.properties"), + "application.version=12.1.4\napplication.java.min=21\n", + ); + await writeFile( + join(install, "support/analyzeHeadless"), + "#!/bin/sh\nexit 99\n", + { mode: 0o700 }, + ); + await writeFile( + native, + process.platform === "darwin" + ? thinMach(0xfeedfacf, process.arch === "arm64" ? 0x0100000c : 0x01000007) + : elf(2, 1, process.arch === "arm64" ? 183 : 62), + ); + await chmod(native, 0o700); + const properties = [ + "java.version = 21.0.11", + `java.home = ${jdk}`, + "sun.arch.data.model = 64", + `os.arch = ${process.arch === "arm64" ? "aarch64" : "amd64"}`, + `os.name = ${process.platform === "darwin" ? "Mac OS X" : "Linux"}`, + "", + ].join("\n"); + await writeFile( + join(jdk, "bin/java"), + `#!${process.execPath}\nprocess.stderr.write(${JSON.stringify(properties)});\n`, + { mode: 0o700 }, + ); + await writeFile(join(jdk, "bin/javac"), "#!/bin/sh\nexit 0\n", { + mode: 0o700, + }); + return { install, jdk }; +}; + +const reportSchema = z.object({ + healthy: z.boolean(), + scope_checks: z.array(z.object({ name: z.string() }).loose()), + informational_checks: z.array(z.object({ name: z.string() }).loose()), + identity: z.object({ + registrations: z.array(z.object({ client: z.string() }).loose()), + }), +}); + +const run = async ( + cli: TestCli, + arguments_: readonly string[], + environment: NodeJS.ProcessEnv, +) => { + const result = await cli.run({ + arguments: [...arguments_, "--json"], + environment, + timeoutMs: 30_000, + }); + return { result, report: reportSchema.safeParse(result.json) }; +}; + +const doctor = async ( + cli: TestCli, + scope: readonly string[], + environment: NodeJS.ProcessEnv, +) => { + const { result, report } = await run(cli, ["doctor", ...scope], environment); + if (!report.success) throw new Error(result.stdout + result.stderr); + return { exitCode: result.exitCode, report: report.data }; +}; + +const names = (checks: readonly { readonly name: string }[]) => + checks.map(({ name }) => name); + +const CHECK = "registration_environment:claude_code"; + +cliTest.skipIf(!["darwin", "linux"].includes(process.platform))( + "doctor does not let a configured terminal stand in for an unconfigured registration", + { timeout: 120_000 }, + async ({ cli }) => { + const root = await createTestTempDirectory("rea-doctor-registration-env-"); + const home = join(root, "home"); + await mkdir(home); + const { install, jdk } = await writeAdmittedGhidra(root); + const clientEnvironment = { + HOME: home, + USERPROFILE: home, + // The host's own Ghidra or Java must not satisfy this fixture. + PATH: "/usr/bin:/bin", + HOPPER_LAUNCHER_PATH: join(root, "missing-hopper"), + REA_LOG_LEVEL: "silent", + }; + const terminal = { + ...clientEnvironment, + GHIDRA_INSTALL_DIR: install, + JAVA_HOME: jdk, + }; + const configPath = join(home, ".claude.json"); + const saved = async () => + z + .object({ + mcpServers: z.object({ + rea: z.object({ env: z.record(z.string(), z.string()) }), + }), + }) + .parse(JSON.parse(await readFile(configPath, "utf8"))).mcpServers.rea + .env; + + // Setup ran where the provider settings were not visible. + const unconfigured = await run( + cli, + ["setup", "--client", "claude_code", "--skill=false", "--yes"], + clientEnvironment, + ); + expect(unconfigured.result.exitCode, unconfigured.result.stdout).toBe(0); + expect(await saved()).not.toHaveProperty("GHIDRA_INSTALL_DIR"); + + // The registration's own environment is what a client without these + // settings gives its server; it does not reach the terminal's result. + const served = await doctor(cli, ["--provider", "ghidra"], { + ...(await saved()), + HOME: home, + USERPROFILE: home, + PATH: clientEnvironment.PATH, + }); + expect(served.exitCode).toBe(1); + expect(served.report.scope_checks).toContainEqual( + expect.objectContaining({ + name: "ghidra", + ok: false, + classification: "missing_analysis_engine", + }), + ); + expect(names(served.report.scope_checks)).not.toContain(CHECK); + + for (const scope of [ + ["--provider", "ghidra"], + ["--client", "claude_code"], + ["--provider", "ghidra", "--client", "claude_code"], + ]) { + const scoped = await doctor(cli, scope, terminal); + expect(scoped.report.scope_checks, scope.join(" ")).toContainEqual( + expect.objectContaining({ + name: CHECK, + ok: true, + classification: "unknown", + detail: expect.stringContaining( + "does not record GHIDRA_INSTALL_DIR, JAVA_HOME", + ), + details: { + effective_environment: "unknown", + providers: ["ghidra"], + }, + remediation: expect.stringContaining( + "rea setup --client claude_code --skill=false --dry-run", + ), + }), + ); + // An unobserved environment is neither a failure nor a healthy match. + expect(scoped.report.healthy, scope.join(" ")).toBe(true); + expect(scoped.exitCode, scope.join(" ")).toBe(0); + expect(scoped.report.identity.registrations).toEqual([ + expect.objectContaining({ + client: "claude_code", + state: "aligned", + provider_settings: { + unrecorded: ["GHIDRA_INSTALL_DIR", "JAVA_HOME"], + differing: [], + unresolved: [], + }, + }), + ]); + } + + const unrelated = await doctor(cli, ["--skill"], terminal); + expect(names(unrelated.report.scope_checks)).not.toContain(CHECK); + expect(names(unrelated.report.informational_checks)).toContain(CHECK); + + // Doctor reads; the configuration is unchanged until setup is approved. + const before = await readFile(configPath, "utf8"); + await doctor(cli, [], terminal); + expect(await readFile(configPath, "utf8")).toBe(before); + + // The named remediation records exactly these settings and then clears it. + const planned = await run( + cli, + ["setup", "--client", "claude_code", "--skill=false", "--dry-run"], + terminal, + ); + expect(planned.result.stdout).toContain(`GHIDRA_INSTALL_DIR=${install}`); + expect(planned.result.stdout).toContain(`JAVA_HOME=${jdk}`); + expect(await readFile(configPath, "utf8")).toBe(before); + const applied = await run( + cli, + ["setup", "--client", "claude_code", "--skill=false", "--yes"], + terminal, + ); + expect(applied.result.exitCode, applied.result.stdout).toBe(0); + expect(await saved()).toMatchObject({ + GHIDRA_INSTALL_DIR: install, + JAVA_HOME: jdk, + }); + expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(before); + + const recorded = await doctor(cli, ["--provider", "ghidra"], terminal); + expect(recorded.exitCode).toBe(0); + expect(names(recorded.report.scope_checks)).not.toContain(CHECK); + expect(recorded.report.identity.registrations[0]).not.toHaveProperty( + "provider_settings", + ); + + // Another terminal selecting a different JDK spelling is reported as that. + const alias = join(root, "jdk-alias"); + await symlink(jdk, alias); + const otherTerminal = { ...terminal, JAVA_HOME: alias }; + const other = await doctor(cli, ["--provider", "ghidra"], otherTerminal); + expect(other.report.identity.registrations[0]).toMatchObject({ + provider_settings: { + unrecorded: [], + differing: ["JAVA_HOME"], + unresolved: [], + }, + }); + + // Setup's own spelling of a caller selection compares as recorded. + await run( + cli, + ["setup", "--client", "claude_code", "--skill=false", "--yes"], + otherTerminal, + ); + expect(await saved()).toMatchObject({ JAVA_HOME: alias }); + const aliased = await doctor(cli, ["--provider", "ghidra"], otherTerminal); + expect(names(aliased.report.scope_checks)).not.toContain(CHECK); + }, +); + +cliTest.skipIf(!["darwin", "linux"].includes(process.platform))( + "doctor adds nothing when the terminal's provider is not available", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-doctor-registration-off-"); + const home = join(root, "home"); + await mkdir(home); + const environment = { + HOME: home, + USERPROFILE: home, + PATH: "/usr/bin:/bin", + HOPPER_LAUNCHER_PATH: join(root, "missing-hopper"), + REA_LOG_LEVEL: "silent", + }; + await run( + cli, + ["setup", "--client", "claude_code", "--skill=false", "--yes"], + environment, + ); + // A provider setting that fails its own check has no green result to + // misread; that failure is the report. + const { report } = await doctor(cli, ["--provider", "ghidra"], { + ...environment, + GHIDRA_INSTALL_DIR: join(root, "missing-ghidra"), + }); + expect(report.healthy).toBe(false); + expect(names(report.scope_checks)).not.toContain(CHECK); + expect(names(report.informational_checks)).not.toContain(CHECK); + expect(report.identity.registrations[0]).not.toHaveProperty( + "provider_settings", + ); + }, +); diff --git a/tests/boundary/filesystem/clientRegistrationProviderSettings.test.ts b/tests/boundary/filesystem/clientRegistrationProviderSettings.test.ts new file mode 100644 index 00000000000..25c3abf32c2 --- /dev/null +++ b/tests/boundary/filesystem/clientRegistrationProviderSettings.test.ts @@ -0,0 +1,290 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +import { recordedProviderSettings } from "../../../src/application/ClientRegistrationEnvironment.js"; +import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; +import { PRODUCT_IDENTITY } from "../../../src/identity.js"; +import { CURRENT_ENTRYPOINT_PATH } from "../../../src/process/CurrentEntrypointPath.js"; +import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +beforeEach(clearClientLocationEnvironment); +afterEach(() => { + vi.unstubAllEnvs(); +}); + +const selected = { + GHIDRA_INSTALL_DIR: "/opt/ghidra", + JAVA_HOME: "/opt/jdk", +}; +const entry = (env: unknown) => + JSON.stringify({ + mcpServers: { + rea: { + command: process.execPath, + args: [CURRENT_ENTRYPOINT_PATH, "mcp"], + ...(env === undefined ? {} : { env }), + }, + }, + }); +const readClaudeCode = async (env: unknown) => { + const home = await createTestTempDirectory("rea-recorded-settings-"); + await writeFile(join(home, ".claude.json"), entry(env)); + const [status] = await readClientRegistrationStatuses( + home, + CURRENT_ENTRYPOINT_PATH, + { environment: {}, providerSettings: selected }, + ); + return status; +}; + +it.each([ + [ + "a registration with no environment map", + undefined, + { + unrecorded: ["GHIDRA_INSTALL_DIR", "JAVA_HOME"], + differing: [], + unresolved: [], + }, + ], + [ + "a registration recording only part of the selection", + { GHIDRA_INSTALL_DIR: "/opt/ghidra" }, + { unrecorded: ["JAVA_HOME"], differing: [], unresolved: [] }, + ], + [ + "a registration recording another value", + { GHIDRA_INSTALL_DIR: "/opt/other", JAVA_HOME: "/opt/jdk" }, + { unrecorded: [], differing: ["GHIDRA_INSTALL_DIR"], unresolved: [] }, + ], +])("reports names only for %s", async (_label, env, provider_settings) => { + expect(await readClaudeCode(env)).toMatchObject({ + client: "claude_code", + provider_settings, + }); +}); + +it.each([ + ["a non-string value", { GHIDRA_INSTALL_DIR: 1 }], + ["a peer setting the runtime parser rejects", { REA_LOG_LEVEL: "shout" }], +])( + "leaves a malformed map with %s to the invalid registration result", + async (_label, env) => { + const status = await readClaudeCode(env); + expect(status?.state).toBe("invalid"); + expect(status).not.toHaveProperty("provider_settings"); + }, +); + +it("omits the comparison when the registration records the selection", async () => { + expect(await readClaudeCode(selected)).not.toHaveProperty( + "provider_settings", + ); +}); + +it("keeps recorded values out of the status", async () => { + const status = await readClaudeCode({ + GHIDRA_INSTALL_DIR: "/recorded/secret-path", + }); + expect(status?.provider_settings?.differing).toEqual(["GHIDRA_INSTALL_DIR"]); + expect(JSON.stringify(status)).not.toContain("/recorded/secret-path"); +}); + +it("does not read the comparison for callers that did not ask", async () => { + const home = await createTestTempDirectory("rea-recorded-settings-off-"); + await writeFile(join(home, ".claude.json"), entry(undefined)); + const [status] = await readClientRegistrationStatuses( + home, + CURRENT_ENTRYPOINT_PATH, + { environment: {} }, + ); + expect(status).not.toHaveProperty("provider_settings"); +}); + +it("retains the comparison on a stale launcher, which setup can still repair", async () => { + const home = await createTestTempDirectory("rea-recorded-settings-stale-"); + await writeFile( + join(home, ".claude.json"), + JSON.stringify({ + mcpServers: { + rea: { + command: "npx", + args: ["-y", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"], + }, + }, + }), + ); + const [status] = await readClientRegistrationStatuses(home, "/current/rea", { + environment: {}, + providerSettings: selected, + }); + expect(status).toMatchObject({ + state: "stale", + provider_settings: { unrecorded: ["GHIDRA_INSTALL_DIR", "JAVA_HOME"] }, + }); +}); + +it("reads Codex's environment table", async () => { + const home = await createTestTempDirectory("rea-recorded-settings-codex-"); + await mkdir(join(home, ".codex")); + await writeFile( + join(home, ".codex/config.toml"), + `[mcp_servers.rea]\ncommand = ${JSON.stringify(process.execPath)}\nargs = [${JSON.stringify(CURRENT_ENTRYPOINT_PATH)}, "mcp"]\nstartup_timeout_sec = 30\ntool_timeout_sec = 3600\n[mcp_servers.rea.env]\nGHIDRA_INSTALL_DIR = "/opt/ghidra"\n`, + ); + const [status] = await readClientRegistrationStatuses( + home, + CURRENT_ENTRYPOINT_PATH, + { environment: {}, providerSettings: selected }, + ); + expect(status).toMatchObject({ + client: "codex", + provider_settings: { + unrecorded: ["JAVA_HOME"], + differing: [], + unresolved: [], + }, + }); +}); + +it("reads OpenCode's differently named environment member", async () => { + const home = await createTestTempDirectory("rea-recorded-settings-open-"); + await mkdir(join(home, ".config/opencode"), { recursive: true }); + await writeFile( + join(home, ".config/opencode/opencode.json"), + JSON.stringify({ + mcp: { + rea: { + type: "local", + command: [process.execPath, CURRENT_ENTRYPOINT_PATH, "mcp"], + environment: { JAVA_HOME: "/opt/jdk" }, + }, + }, + }), + ); + const [status] = await readClientRegistrationStatuses( + home, + CURRENT_ENTRYPOINT_PATH, + { environment: {}, providerSettings: selected }, + ); + expect(status).toMatchObject({ + client: "opencode", + provider_settings: { + unrecorded: ["GHIDRA_INSTALL_DIR"], + differing: [], + unresolved: [], + }, + }); +}); + +it("reads the effective view of layered OpenCode files", async () => { + const home = await createTestTempDirectory("rea-recorded-settings-layer-"); + await mkdir(join(home, ".config/opencode"), { recursive: true }); + const layer = (environment: Readonly>) => + JSON.stringify({ + mcp: { + rea: { + type: "local", + command: [process.execPath, CURRENT_ENTRYPOINT_PATH, "mcp"], + environment, + }, + }, + }); + const override = join(home, "override.json"); + await writeFile( + join(home, ".config/opencode/opencode.json"), + layer({ JAVA_HOME: "/opt/jdk" }), + ); + await writeFile(override, layer({ GHIDRA_INSTALL_DIR: "/opt/ghidra" })); + const statuses = await readClientRegistrationStatuses( + home, + CURRENT_ENTRYPOINT_PATH, + { + environment: { OPENCODE_CONFIG: override }, + providerSettings: selected, + }, + ); + expect( + statuses.find(({ client }) => client === "opencode"), + ).not.toHaveProperty("provider_settings"); +}); + +it("keeps a Cordis expression unresolved instead of absent or different", async () => { + const home = await createTestTempDirectory("rea-recorded-settings-dsh-"); + await mkdir(join(home, ".dsh")); + await writeFile( + join(home, ".dsh/cordis.patch.yml"), + `- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: ${JSON.stringify(process.execPath)} + args: [${JSON.stringify(CURRENT_ENTRYPOINT_PATH)}, mcp] + env: + GHIDRA_INSTALL_DIR: !!js process.env.GHIDRA_INSTALL_DIR + JAVA_HOME: /opt/other +`, + ); + const statuses = await readClientRegistrationStatuses( + home, + CURRENT_ENTRYPOINT_PATH, + { environment: {}, platform: "linux", providerSettings: selected }, + ); + expect(statuses.find((status) => status.client === "dsh")).toMatchObject({ + provider_settings: { + unrecorded: [], + differing: ["JAVA_HOME"], + unresolved: ["GHIDRA_INSTALL_DIR"], + }, + }); +}); + +it("folds recorded environment names on Windows", () => { + const servers = { + rea: { env: { java_home: "/opt/jdk" } }, + }; + const parsed = { + document: { mcpServers: servers }, + servers, + serversPath: ["mcpServers"], + dialect: "json", + environmentKey: "env", + legacyServers: {}, + } as const; + expect(recordedProviderSettings(parsed, selected, "win32")).toEqual({ + unrecorded: ["GHIDRA_INSTALL_DIR"], + differing: [], + unresolved: [], + }); + expect(recordedProviderSettings(parsed, selected, "linux")).toEqual({ + unrecorded: ["GHIDRA_INSTALL_DIR", "JAVA_HOME"], + differing: [], + unresolved: [], + }); + expect(recordedProviderSettings(parsed, {}, "linux")).toBeUndefined(); +}); + +it("does not treat a map it could not read as absent", () => { + const servers = { rea: { env: { GHIDRA_INSTALL_DIR: 1 } } }; + expect( + recordedProviderSettings( + { + document: { mcpServers: servers }, + servers, + serversPath: ["mcpServers"], + dialect: "json", + environmentKey: "env", + legacyServers: {}, + }, + selected, + "linux", + ), + ).toEqual({ + unrecorded: [], + differing: [], + unresolved: ["GHIDRA_INSTALL_DIR", "JAVA_HOME"], + }); +}); From 4a01211f1142b4d20d155e9934e9fc5232c21e37 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Mon, 2 Nov 2026 05:16:16 -0800 Subject: [PATCH 03/38] fix(javascript): keep static facts after semantic results use the parent budget (#8648) Static checkpoints were admitted against the same budget as semantic and application results, half of the parent's free heap. Once those charges used it, the next checkpoint stopped the analysis and every remaining source lost its static facts, with most of the parent heap still free. Semantic and application results still stop at that budget. Static checkpoints may now continue to three quarters of the free heap while the live heap also has room for another copy of what it holds. Without that room, admission falls back to the result budget. The live condition is needed because the limit V8 reports counts young-generation capacity that retained objects cannot use: without it, the parent aborted on heap exhaustion at small heap limits. Reserved relationships are given up only when the larger share runs short. A stop still fails with a typed error, which now reports the limit that applied, what was available, the live heap in use and what the checkpoint needed. Co-authored-by: Claude Opus 5.5 --- docs/javascript-artifact-reconstruction.md | 18 +- .../analysis/JavaScriptAnalysisWorker.ts | 97 ++++++--- .../javascriptCheckpointReserveCli.test.ts | 71 ++++++ ...ascriptStaticProjectionReclamation.test.ts | 205 +++++++++++++++--- 4 files changed, 332 insertions(+), 59 deletions(-) diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 7ec2febebb3..1dd3c99d585 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -876,8 +876,22 @@ and Evidence. If that expansion does not fit, the file node retains `static_analysis` and any admitted `semantic_module_analysis` observations, including their exact values and source locations. Use the public modules item view to read them. The graph marks those unexpanded relationships as unknown. -If cumulative result capacity is exhausted, analysis stops; inventoried but -unexamined files remain present with explicit unknown scopes. + +The parent charges everything it retains against the heap that was free when +analysis started, less a transfer reserve. Semantic facts and expanded +relationships stop at half of that capacity, and a source refused there keeps +its static facts with the refusal reported. Static facts can continue to three +quarters of it while the live heap also has room for another copy of what it +holds, so results charged for earlier sources do not cost later sources their +static facts when the heap can still hold them. When that share runs short, +relationships that were reserved but not yet expanded are given up before any +static facts, and each affected file reports the omission. + +If static facts themselves exhaust that share, or the parent's live heap cannot +hold the next checkpoint, analysis stops; inventoried but unexamined files remain +present with explicit unknown scopes. The failure reports the limit that applied +in bytes, what was still available, the live heap in use and what the checkpoint +needed. ### Large local JSON inputs diff --git a/src/javascript/analysis/JavaScriptAnalysisWorker.ts b/src/javascript/analysis/JavaScriptAnalysisWorker.ts index 056a23332bc..f6a5e92553d 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorker.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorker.ts @@ -83,6 +83,12 @@ const OPERATION = "analyze_javascript_application"; const PROVIDER = "javascript-static-analysis"; const MIB = 1024 * 1024; const STATIC_CHECKPOINT_RETENTION_EXPANSION = 6; +/** + * Share of the parent's free heap that static checkpoints may fill once the + * result budget is used. Unlike the live heap, the charges also cover application + * relationships that are reserved but not yet materialized. + */ +const STATIC_CAPACITY_SHARE = 0.75; /** The worker runs on this executable; Bun accepts but ignores V8 heap flags. */ const WORKER_HEAP_FLAGS_ENFORCED = javaScriptRuntime(process.versions).id === "node"; @@ -130,10 +136,11 @@ export class JavaScriptAnalysisWorker { #revokedStaticProjections: JavaScriptRevokedStaticProjection[] = []; #nextSourceIndex = 0; #applicationProjectionFailure: AnalysisResourceConstraintError | undefined; - #parentCapacityExhausted = false; + #parentCapacityExhaustion: Readonly> | undefined; #sourcesRemaining: number | undefined; #deadline: ProviderStartupDeadline | undefined; #resultBudgetBytes = 0; + #staticCapacityBytes = 0; /** Null when the runtime enforces the V8 heap flags instead. */ readonly #processMemoryLimitBytes: number | null; readonly #onStdout = (chunk: Buffer | string): void => { @@ -248,7 +255,7 @@ export class JavaScriptAnalysisWorker { this.#staticProjectionReservations = []; this.#revokedStaticProjections = []; this.#nextSourceIndex = 0; - this.#parentCapacityExhausted = false; + this.#parentCapacityExhaustion = undefined; this.#applicationProjectionFailure = undefined; this.#refreshResultBudget(); return true; @@ -262,17 +269,20 @@ export class JavaScriptAnalysisWorker { /** * Reserve host-side control, schema/Evidence work, and a maximum record's - * transient copies. Admission also accounts for retained earlier files. + * transient copies. Everything retained is charged against what is left. + * + * Semantic and application results stop at half of it. Static checkpoints + * may go on to a larger share while the live heap has room for them, so a + * charge for earlier results cannot cost a later source its static facts. */ #refreshResultBudget(): void { - this.#resultBudgetBytes = Math.max( + const capacity = Math.max( 0, - Math.floor( - (this.#remainingParentHeapBytes() - - 24 * JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES) / - 2, - ), + this.#remainingParentHeapBytes() - + 24 * JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES, ); + this.#resultBudgetBytes = Math.floor(capacity / 2); + this.#staticCapacityBytes = Math.floor(capacity * STATIC_CAPACITY_SHARE); } /** Analyze inert source; hard worker failure does not terminate this process. */ @@ -290,7 +300,7 @@ export class JavaScriptAnalysisWorker { const id = randomUUID(); const sourceIndex = this.#nextSourceIndex++; this.#applicationProjectionFailure = undefined; - this.#parentCapacityExhausted = false; + this.#parentCapacityExhaustion = undefined; this.#revokedStaticProjections = []; if (this.#sourcesRemaining !== undefined) this.#sourcesRemaining = Math.max(0, this.#sourcesRemaining - 1); @@ -388,20 +398,8 @@ export class JavaScriptAnalysisWorker { sourceIndex, response, ); - if (this.#parentCapacityExhausted) - throw new AnalysisResourceConstraintError( - OPERATION, - "memory", - `The parent's result capacity was exhausted after retaining static facts for ${file.path}; remaining sources were not analyzed`, - { - ...this.#limits(), - parent_result_budget_bytes: this.#resultBudgetBytes, - parent_result_budget_exhausted: true, - }, - { - remediationAction: JAVASCRIPT_PARENT_RESULT_BUDGET_REMEDIATION, - }, - ); + const exhausted = this.#parentCapacityFailure(file.path); + if (exhausted !== undefined) throw exhausted; offeredResultBudget = this.#applicationProjectionFailure !== undefined && this.#sourcesRemaining !== undefined && @@ -799,11 +797,21 @@ export class JavaScriptAnalysisWorker { // Application relationships are materialized only after all source files // have been examined. Reclaim their pending reservations to keep room for // later static observations, without relaxing the actual retained bound. - const needed = - retainedBytes - (this.#resultBudgetBytes - this.#estimatedRetainedBytes); + const { heap_size_limit, used_heap_size } = getHeapStatistics(); const liveAvailable = - this.#remainingParentHeapBytes() - + heap_size_limit - + used_heap_size - 24 * JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES; + // Past the result budget, the live heap must also keep room for another + // copy of what it holds, which final graph and Evidence work needs. The + // limit read here counts young-generation capacity that retained objects + // cannot use, so being merely below it does not protect the parent. + const staticCeiling = + retainedBytes + used_heap_size <= liveAvailable + ? this.#staticCapacityBytes + : this.#resultBudgetBytes; + const needed = + retainedBytes - (staticCeiling - this.#estimatedRetainedBytes); if (needed > 0 && retainedBytes <= liveAvailable) { // Plan all revocations first so an insufficient set leaves the earlier // relationships intact rather than sacrificing them for a failed read. @@ -836,12 +844,17 @@ export class JavaScriptAnalysisWorker { // admission so a second read cannot reject after revocations commit. const remaining = Math.max( 0, - Math.min( - this.#resultBudgetBytes - this.#estimatedRetainedBytes, - liveAvailable, - ), + Math.min(staticCeiling - this.#estimatedRetainedBytes, liveAvailable), ); - this.#parentCapacityExhausted = retainedBytes > remaining; + this.#parentCapacityExhaustion = + retainedBytes > remaining + ? { + parent_static_capacity_bytes: staticCeiling, + parent_available_static_capacity_bytes: remaining, + parent_used_heap_bytes: used_heap_size, + static_retention_estimate_bytes: retainedBytes, + } + : undefined; this.#estimatedRetainedBytes += retainedBytes; const expandedBytes = estimateJavaScriptStaticApplicationProjection( facts, @@ -873,6 +886,26 @@ export class JavaScriptAnalysisWorker { return facts; } + #parentCapacityFailure( + path: string, + ): AnalysisResourceConstraintError | undefined { + if (this.#parentCapacityExhaustion === undefined) return undefined; + return new AnalysisResourceConstraintError( + OPERATION, + "memory", + `The parent's result capacity was exhausted after retaining static facts for ${path}; remaining sources were not analyzed`, + { + ...this.#limits(), + parent_result_budget_bytes: this.#resultBudgetBytes, + ...this.#parentCapacityExhaustion, + parent_result_budget_exhausted: true, + }, + { + remediationAction: JAVASCRIPT_PARENT_RESULT_BUDGET_REMEDIATION, + }, + ); + } + #limits() { return { ...(WORKER_HEAP_FLAGS_ENFORCED diff --git a/tests/acceptance/applications/javascriptCheckpointReserveCli.test.ts b/tests/acceptance/applications/javascriptCheckpointReserveCli.test.ts index 5e612caf9e7..51935ae1789 100644 --- a/tests/acceptance/applications/javascriptCheckpointReserveCli.test.ts +++ b/tests/acceptance/applications/javascriptCheckpointReserveCli.test.ts @@ -170,3 +170,74 @@ cliTest.skipIf(process.versions.bun !== undefined).for([ expect(response.stderr).not.toMatch(/FATAL ERROR:.*heap/iu); }, ); + +cliTest.skipIf(process.versions.bun !== undefined)( + "keeps every source's static facts after semantic results use the parent result budget", + { timeout: 90_000 }, + async ({ cli }) => { + const root = await createTestTempDirectory("rea-js-static-capacity-"); + // Plain functions produce semantic graphs large enough to use the result + // budget of this heap. The later sources' storage findings then produce + // static checkpoints that exceed its reserve for the next checkpoint. + const vendors = Array.from({ length: 12 }, (_, vendor) => [ + `a-vendor-${String(vendor)}.mjs`, + Array.from( + { length: 100 }, + (_, index) => + `export function v${String(vendor)}_${String(index)}(input) { const value = input + ${String(index)}; const pair = { value }; return pair.value; }`, + ).join("\n"), + ]); + const callsPerSource = 12_000; + const sources = Array.from({ length: 5 }, (_, source) => [ + `z-source-${String(source)}.mjs`, + Array.from( + { length: callsPerSource }, + (_, index) => + `localStorage.getItem("k${String(source)}_${String(index)}");`, + ).join("\n"), + ]); + for (const [name, text] of [...vendors, ...sources]) + await writeFile(join(root, name!), text!); + const response = await cli.run({ + arguments: ["analyze", root, "--format", "json"], + environment: { + NODE_OPTIONS: "--max-old-space-size=640 --max-semi-space-size=8", + }, + timeoutMs: 75_000, + }); + expect(response.stderr).not.toMatch(/FATAL ERROR:.*heap/iu); + expect( + response.exitCode, + JSON.stringify(response.json)?.slice(0, 2000), + ).toBe(0); + const result = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(response.json).normalized_result, + ); + expect(result.statistics).toMatchObject({ + parsed_javascript_files: vendors.length + sources.length, + skipped_javascript_files: 0, + parse_failures: 0, + }); + // The omission is reported where a reader finds the retained facts. + for (const [name, text] of sources) { + const digest = createHash("sha256").update(text!).digest("hex"); + const node = result.graph.nodes.find( + ({ kind, identity }) => + kind === "javascript-asset" && + identity.strategy === "content-digest" && + identity.sha256 === digest, + ); + const observation = node?.observations.find( + ({ properties }) => + properties.application_projection_failure !== undefined, + ); + expect(observation?.label, name).toBe(name); + expect( + z + .object({ storage: z.array(z.unknown()) }) + .parse(observation?.properties.static_analysis).storage, + ).toHaveLength(callsPerSource); + } + expect(result.graph.coverage.status).toBe("partial"); + }, +); diff --git a/tests/boundary/javascript/javascriptStaticProjectionReclamation.test.ts b/tests/boundary/javascript/javascriptStaticProjectionReclamation.test.ts index ef1c99e458c..11b78f9819c 100644 --- a/tests/boundary/javascript/javascriptStaticProjectionReclamation.test.ts +++ b/tests/boundary/javascript/javascriptStaticProjectionReclamation.test.ts @@ -1,7 +1,9 @@ import { createHash } from "node:crypto"; import { expect, it, vi } from "vitest"; +import { z } from "zod"; const heap = vi.hoisted(() => ({ + usedBytes: 1024 * 1024, remainingBytes: 200 * 1024 * 1024, readings: [] as number[], })); @@ -13,9 +15,9 @@ vi.mock("node:v8", async (load) => { const current = actual.getHeapStatistics(); return { ...current, + used_heap_size: heap.usedBytes, heap_size_limit: - current.used_heap_size + - (heap.readings.shift() ?? heap.remainingBytes), + heap.usedBytes + (heap.readings.shift() ?? heap.remainingBytes), }; }, }; @@ -37,32 +39,56 @@ vi.mock( import { JavaScriptAnalysisWorker } from "../../../src/javascript/analysis/JavaScriptAnalysisWorker.js"; import { AnalysisResourceConstraintError } from "../../../src/domain/analysisErrorCore.js"; import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js"; -const file = (path: string, count: number): JavaScriptArtifactFile => { +const sourceText = (text: string) => ({ + sha256: createHash("sha256").update(text).digest("hex"), + bytes: Buffer.byteLength(text), + text: { included: true as const, value: text }, +}); +const file = (path: string, count: number): JavaScriptArtifactFile => ({ + path, + container_sha256: "a".repeat(64), + inventory_artifact_id: path, + kind: "javascript", + unpacked: false, + ...sourceText( + Array.from( + { length: count }, + (_, i) => `localStorage.setItem('k${i}', 'v');`, + ).join("\n"), + ), +}); +const expectCapacityRemains = ( + result: Awaited>, +): void => { + if ( + !result.ok && + result.error.error instanceof AnalysisResourceConstraintError + ) + expect( + result.error.error.reportedLimits?.parent_result_budget_exhausted, + ).not.toBe(true); +}; +const functions = (count: number): JavaScriptArtifactFile => { const text = Array.from( { length: count }, - (_, i) => `localStorage.setItem('k${i}', 'v');`, + (_, i) => `export function f${i}(a) { const b = a + ${i}; return b; }`, ).join("\n"); - return { - path, - container_sha256: "a".repeat(64), - sha256: createHash("sha256").update(text).digest("hex"), - bytes: Buffer.byteLength(text), - inventory_artifact_id: path, - kind: "javascript", - unpacked: false, - text: { included: true, value: text }, - }; + return { ...file("functions.js", 0), ...sourceText(text) }; }; +// The mocked heap leaves a 4 MiB result budget and 6 MiB of static capacity. +// A 50-line source reserves about 1.1 MiB of relationships; each later 500-line +// source is charged about 0.5 MiB, so reclamation starts at the eleventh. +const SOURCES_UNTIL_RECLAMATION = 12; it("uses one live heap observation for revocation and static admission", async () => { const run = async (changeHeapAt?: number) => { const worker = new JavaScriptAnalysisWorker( { heapMb: 512, timeoutMs: 30_000 }, undefined, - 8, + SOURCES_UNTIL_RECLAMATION, ); const results = []; try { - for (let i = 0; i < 8; i++) { + for (let i = 0; i < SOURCES_UNTIL_RECLAMATION; i++) { const index = i; results.push( await worker.analyze( @@ -108,15 +134,15 @@ it("uses one live heap observation for revocation and static admission", async ( ).not.toBe(true); }); -it("reclaims prior unmaterialized projections only when a complete static checkpoint would otherwise exhaust the result budget", async () => { +it("reclaims prior unmaterialized projections only when a complete static checkpoint would otherwise exhaust the static capacity", async () => { const worker = new JavaScriptAnalysisWorker( { heapMb: 512, timeoutMs: 30_000 }, undefined, - 8, + SOURCES_UNTIL_RECLAMATION, ); const results = []; try { - for (let i = 0; i < 8; i++) + for (let i = 0; i < SOURCES_UNTIL_RECLAMATION; i++) results.push( await worker.analyze(file(`${i}.js`, i === 0 ? 50 : 500), { nodes: 1, @@ -149,13 +175,13 @@ it("keeps earlier projections when reclaiming them cannot admit the checkpoint", const worker = new JavaScriptAnalysisWorker( { heapMb: 512, timeoutMs: 30_000 }, undefined, - 5, + 6, ); try { const results = []; - for (let i = 0; i < 5; i++) + for (let i = 0; i < 6; i++) results.push( - await worker.analyze(file(`${i}.js`, i === 0 ? 10 : 1000), { + await worker.analyze(file(`${i}.js`, i === 0 ? 10 : 1400), { nodes: 1, unknowns: 1, }), @@ -173,13 +199,140 @@ it("keeps earlier projections when reclaiming them cannot admit the checkpoint", if (last === undefined || last.ok) throw new Error("Expected capacity refusal"); expect(last.error.error).toMatchObject({ - reportedLimits: { parent_result_budget_exhausted: true }, + reportedLimits: { + parent_result_budget_bytes: 4 * 1024 * 1024, + parent_static_capacity_bytes: 6 * 1024 * 1024, + parent_result_budget_exhausted: true, + }, }); + const { + static_retention_estimate_bytes: required, + parent_available_static_capacity_bytes: available, + } = z + .object({ + static_retention_estimate_bytes: z.number(), + parent_available_static_capacity_bytes: z.number(), + }) + .parse( + last.error.error instanceof AnalysisResourceConstraintError + ? last.error.error.reportedLimits + : null, + ); + expect(required).toBeGreaterThan(available); } finally { await worker.close(); } }); +it("retains static facts past the result budget, keeping earlier relationships while the static capacity lasts", async () => { + const sources = 9; + const worker = new JavaScriptAnalysisWorker( + { heapMb: 512, timeoutMs: 30_000 }, + undefined, + sources, + ); + try { + const details = []; + for (let i = 0; i < sources; i++) { + const result = await worker.analyze(file(`${i}.js`, i === 0 ? 50 : 500), { + nodes: 1, + unknowns: 1, + }); + expectCapacityRemains(result); + details.push(result.ok ? result.value : result.error); + } + expect(details.map((detail) => detail.javascript?.storage.length)).toEqual( + details.map((_, i) => (i === 0 ? 50 : 500)), + ); + expect( + details.flatMap((detail) => detail.revokedStaticProjections ?? []), + ).toEqual([]); + // Relationships that do not fit the budget are refused, explicitly. + expect(details[0]?.applicationProjectionFailure).toBeUndefined(); + for (const detail of details.slice(1)) + expect(detail.applicationProjectionFailure).toMatchObject({ + reason: expect.stringContaining("exceed the parent's result budget"), + }); + } finally { + await worker.close(); + } +}); + +it("retains static facts for later sources after semantic results use the result budget", async () => { + const sources = 4; + const worker = new JavaScriptAnalysisWorker( + { heapMb: 512, timeoutMs: 30_000 }, + undefined, + sources, + ); + try { + // Semantic results are already adopted, so nothing can be reclaimed from them. + const first = await worker.analyze(functions(10), { + nodes: 20_000, + unknowns: 20_000, + }); + if (!first.ok) throw first.error.error; + expect(first.value.module?.callables).toHaveLength(10); + expect(first.value.projection).not.toBeNull(); + const later = []; + for (let i = 1; i < sources; i++) { + const result = await worker.analyze(file(`${i}.js`, 1000), { + nodes: 20_000, + unknowns: 20_000, + }); + expectCapacityRemains(result); + later.push(result.ok ? result.value : result.error); + } + expect(later.map((detail) => detail.javascript?.storage.length)).toEqual( + later.map(() => 1000), + ); + expect( + later.flatMap((detail) => detail.revokedStaticProjections ?? []), + ).toEqual([]); + } finally { + await worker.close(); + } +}); + +it("stays within the result budget when the live heap has no room for another copy", async () => { + // Each later checkpoint is charged about 1 MiB, against 8 MiB of live room. + heap.usedBytes = 7.5 * 1024 * 1024; + const sources = 9; + const worker = new JavaScriptAnalysisWorker( + { heapMb: 512, timeoutMs: 30_000 }, + undefined, + sources, + ); + try { + const results = []; + for (let i = 0; i < sources; i++) + results.push( + await worker.analyze(file(`${i}.js`, i === 0 ? 10 : 1000), { + nodes: 1, + unknowns: 1, + }), + ); + const stopped = results + .flatMap((result) => (result.ok ? [] : [result.error.error])) + .find( + (error) => + error instanceof AnalysisResourceConstraintError && + error.reportedLimits?.parent_result_budget_exhausted === true, + ); + expect(stopped).toMatchObject({ + reportedLimits: { + parent_result_budget_bytes: 4 * 1024 * 1024, + parent_static_capacity_bytes: 4 * 1024 * 1024, + parent_used_heap_bytes: 7.5 * 1024 * 1024, + parent_result_budget_exhausted: true, + }, + }); + } finally { + heap.usedBytes = 1024 * 1024; + await worker.close(); + } +}); + it("resets projection reservations and source indices when an idle worker is reused", async () => { const worker = new JavaScriptAnalysisWorker( { heapMb: 512, timeoutMs: 30_000 }, @@ -192,9 +345,11 @@ it("resets projection reservations and source indices when an idle worker is reu unknowns: 1, }); expect(first.ok).toBe(true); - expect(worker.prepareForAnotherAnalysis(8)).toBe(true); + expect(worker.prepareForAnotherAnalysis(SOURCES_UNTIL_RECLAMATION)).toBe( + true, + ); const results = []; - for (let i = 0; i < 7; i++) + for (let i = 0; i < SOURCES_UNTIL_RECLAMATION; i++) results.push( await worker.analyze(file(`new-${i}.js`, i === 0 ? 50 : 500), { nodes: 1, From 144843ccc26bd372d61b8c6a27b37a01a5b0fb61 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Mon, 2 Nov 2026 05:16:34 -0800 Subject: [PATCH 04/38] fix(debugger): let REA exit when a debugger could not be stopped (#8652) * fix(debugger): let REA exit when a debugger could not be stopped `rea debug-with-gdb` and `rea debug-with-rizin` printed cleanup_incomplete and set exit status 1, but the retained child and its pipes kept the event loop alive, so the process never exited. A stdio MCP server whose client disconnected hung the same way. closeOwnedDebuggerSessions is the owner both managers finish through. It now unreferences retained sessions, as supervised providers already do after incomplete cleanup, restores the references on retry, and names the retained process IDs. The one-shot commands report those IDs, since no later command can retry. Debugger ownership, signals and detach behavior are unchanged. GDB cleanup requests also no longer hold the host until their 120 s command deadline, because cleanup already bounds its own wait. Co-Authored-By: Claude Opus 5.5 * fix(debugger): name retained debuggers when an MCP server shuts down An MCP server that exits with a retained debugger logged only "REA provider cleanup failed" and "could not close cleanly", so nothing named the process left for the caller to check. The PIDs were on OwnedDebuggerCleanupError, but each shutdown reporter read only the outer aggregate's message, and the debug failure_cause description dropped the field too. The failure type now lives in its own dependency-free module with one reader that walks composed failures. The shutdown report, the server's onclose log, the debug description and the one-shot CLI all use it, and the shutdown report writes the same remediation the CLI returns. The fake debuggers now model each request exactly, with real reply shapes, and a request they do not model fails the test. Co-Authored-By: Claude Opus 5.5 * test(debugger): compare retained-debugger guidance with its formatter The new checks froze the wording "Check process ... and its target". docs/testing.md asks guidance checks to compare with the owning formatter, alongside independent checks of the facts. They now do, and each still proves the process ID is named, because a formatter comparison alone would pass if the formatter dropped it. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/cli.md | 11 + scripts/ci/scopes.mjs | 2 + src/cli/reverseEngineeringCommands.ts | 17 +- src/gdb/GdbSessionManager.ts | 2 + src/main/shutdown.ts | 15 +- src/process/ownedDebuggerCleanupError.ts | 45 ++ src/process/ownedDebuggerSession.ts | 51 ++- src/server/createServer.ts | 6 +- .../boundary/cli/debuggerOneShotExit.test.ts | 423 ++++++++++++++++++ .../cli/reverseEngineeringCommands.test.ts | 12 +- .../boundary/filesystem/ciChangeScope.test.ts | 1 + tests/boundary/mcp/shutdown.test.ts | 77 ++++ .../process/ownedDebuggerSession.test.ts | 128 +++++- 13 files changed, 757 insertions(+), 33 deletions(-) create mode 100644 src/process/ownedDebuggerCleanupError.ts create mode 100644 tests/boundary/cli/debuggerOneShotExit.test.ts diff --git a/docs/cli.md b/docs/cli.md index f6b7d55b083..76a3e5ef5ca 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -569,6 +569,17 @@ prints a failure, so it omits the same-session reuse and `parameter`) for a new invocation and rerun. For example, a Ghidra decompile deadline names `REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS`. +`rea debug-with-gdb` and `rea debug-with-rizin` hold their debugger only for the +command's own process. If cleanup cannot confirm the debugger stopped, they print +`cleanup_incomplete` and exit nonzero without stopping it. `remediation.action` +names the process, as does `retained_debugger_pids` in `details.diagnostics`, or +in `details.diagnostics.cleanup_error.details.diagnostics` when the command also +failed or was interrupted. Exiting closes the debugger's command and output pipes. +REA does not establish what the debugger or its target does next, so check that +process. An MCP server that shuts down likewise exits with status 1 instead of +waiting on a debugger it could not stop. It names the process on stderr and as +`retained_debugger_pids` in its error log. + If one-shot analysis fails and closing its session also fails, the original error details remain inline and `details.cleanup_error` describes the later close failure. Each diagnostic retains its own reported resources; the later diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index c0dff3ed66b..051406c6254 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -76,6 +76,7 @@ const PATH_SCOPES = { "src/frida/frida-bindings.d.ts", "src/objdump/**", "src/cli/reverseEngineeringCommands.ts", + "src/process/ownedDebuggerCleanupError.ts", "src/process/ownedDebuggerSession.ts", "src/rizin/**", "src/contracts/*ToolContracts.ts", @@ -103,6 +104,7 @@ const PATH_SCOPES = { "tests/boundary/providers/RizinInferiorTty.test.ts", "tests/boundary/providers/RizinTestInferiorTty.ts", "tests/boundary/process/ownedDebuggerSession.test.ts", + "tests/boundary/cli/debuggerOneShotExit.test.ts", "tests/boundary/cli/gdbStartupCleanupCli.test.ts", "tests/boundary/cli/reverseEngineeringCommands.test.ts", "tests/boundary/mcp/cutter*.test.ts", diff --git a/src/cli/reverseEngineeringCommands.ts b/src/cli/reverseEngineeringCommands.ts index b07da0176b3..9998c2217bc 100644 --- a/src/cli/reverseEngineeringCommands.ts +++ b/src/cli/reverseEngineeringCommands.ts @@ -28,6 +28,11 @@ import { createRizinDebugSessionManager, } from "../composition/debuggerSessions.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { + retainedDebuggerDiagnostics, + retainedDebuggerProcessIds, + retainedDebuggerRemediation, +} from "../process/ownedDebuggerCleanupError.js"; import { analysisEvidenceDeliveryOptions, cliEvidenceDeliveryInputError, @@ -144,6 +149,9 @@ const closeDebuggerCommandSession = async ( try { await manager.closeAll(); } catch (cause) { + // This process is the only owner, so a caller gets the debugger to check + // rather than a retry that no later command could perform. + const retainedPids = retainedDebuggerProcessIds(cause); throw new ProviderCleanupError( providerId, sessionId === undefined ? [] : [sessionId], @@ -158,8 +166,15 @@ const closeDebuggerCommandSession = async ( : cause instanceof Error ? cause.message : String(cause), + ...retainedDebuggerDiagnostics(cause), + }, + { + cause, + operation, + ...(retainedPids.length === 0 + ? {} + : { remediationAction: retainedDebuggerRemediation(retainedPids) }), }, - { cause, operation }, ); } }; diff --git a/src/gdb/GdbSessionManager.ts b/src/gdb/GdbSessionManager.ts index cf9047bc6dc..8eb8c3d7fb1 100644 --- a/src/gdb/GdbSessionManager.ts +++ b/src/gdb/GdbSessionManager.ts @@ -2164,6 +2164,8 @@ export class GdbSessionManager { ); }); }, this.#commandTimeoutMs); + // Cleanup bounds its own wait, so a late reply must not hold the host open. + if (session.cleanupStarted) timer.unref(); pending = { token, command, diff --git a/src/main/shutdown.ts b/src/main/shutdown.ts index 5614bc13a5d..124df659e34 100644 --- a/src/main/shutdown.ts +++ b/src/main/shutdown.ts @@ -3,6 +3,12 @@ import type { Logger } from "pino"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { JsonValue } from "../domain/jsonValue.js"; +import { + OwnedDebuggerCleanupError, + retainedDebuggerDiagnostics, + retainedDebuggerProcessIds, + retainedDebuggerRemediation, +} from "../process/ownedDebuggerCleanupError.js"; import type { McpServerHandle, RuntimeDependencies } from "./types.js"; import { MCP_SHUTDOWN_FAILED } from "./messages.js"; @@ -103,10 +109,14 @@ export const reportMcpShutdownFailure = ( "MCP shutdown rejected", ); dependencies.setExitCode(1); - serverLogger.error(MCP_SHUTDOWN_FAILED); + serverLogger.error(retainedDebuggerDiagnostics(cause), MCP_SHUTDOWN_FAILED); dependencies.writeStderr(`${MCP_SHUTDOWN_FAILED}\n`); if (cause instanceof AnalysisError) dependencies.writeStderr(`${projectAnalysisError(cause).message}\n`); + // This process is about to end, so it is the last chance to name them. + const retainedPids = retainedDebuggerProcessIds(cause); + if (retainedPids.length > 0) + dependencies.writeStderr(`${retainedDebuggerRemediation(retainedPids)}\n`); }; const describeShutdownFailure = ( @@ -134,6 +144,9 @@ const describeShutdownFailureNode = ( (typeof code === "number" && Number.isFinite(code)) ? { code } : {}), + ...(cause instanceof OwnedDebuggerCleanupError + ? { retained_debugger_pids: [...cause.retainedProcessIds] } + : {}), ...(cause instanceof AggregateError ? { errors: cause.errors.map((error: unknown) => diff --git a/src/process/ownedDebuggerCleanupError.ts b/src/process/ownedDebuggerCleanupError.ts new file mode 100644 index 00000000000..20460a5d76c --- /dev/null +++ b/src/process/ownedDebuggerCleanupError.ts @@ -0,0 +1,45 @@ +/** Closing all sessions could not confirm that these debuggers stopped. */ +export class OwnedDebuggerCleanupError extends AggregateError { + /** Process IDs when cleanup gave up. Observations, not lasting proof of identity. */ + readonly retainedProcessIds: readonly number[]; + + constructor( + failures: readonly Error[], + message: string, + retainedProcessIds: readonly number[], + ) { + super(failures, message); + this.retainedProcessIds = retainedProcessIds; + } +} + +/** Retained debugger process IDs anywhere in a composed cleanup failure. */ +export const retainedDebuggerProcessIds = ( + cause: unknown, +): readonly number[] => { + const found = new Set(); + const visited = new WeakSet(); + const visit = (node: unknown): void => { + if (typeof node !== "object" || node === null || visited.has(node)) return; + visited.add(node); + if (node instanceof OwnedDebuggerCleanupError) + for (const pid of node.retainedProcessIds) found.add(pid); + if (node instanceof AggregateError) + for (const error of node.errors) visit(error); + if (node instanceof Error) visit(node.cause); + }; + visit(cause); + return [...found]; +}; + +/** Log or diagnostic fields naming those processes; empty when none are known. */ +export const retainedDebuggerDiagnostics = ( + cause: unknown, +): { readonly retained_debugger_pids?: number[] } => { + const pids = retainedDebuggerProcessIds(cause); + return pids.length === 0 ? {} : { retained_debugger_pids: [...pids] }; +}; + +/** What a caller must do once REA ends with these debuggers unconfirmed. */ +export const retainedDebuggerRemediation = (pids: readonly number[]): string => + `REA exits without confirming that the debugger stopped, and no later REA command can retry this cleanup. Check process ${pids.join(", ")} and its target, and stop them if they are still running.`; diff --git a/src/process/ownedDebuggerSession.ts b/src/process/ownedDebuggerSession.ts index 009bb42e829..8ba4b4e2bc9 100644 --- a/src/process/ownedDebuggerSession.ts +++ b/src/process/ownedDebuggerSession.ts @@ -4,6 +4,7 @@ import type { DebuggerAdmissionLimit } from "../config/debuggerSessionLimits.js" import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { parseLinuxProcessStat } from "./LinuxProcessStat.js"; +import { OwnedDebuggerCleanupError } from "./ownedDebuggerCleanupError.js"; /** Result of terminating one debugger process owned by this application. */ export type OwnedDebuggerStopResult = @@ -364,42 +365,58 @@ export const stopOwnedDebuggerProcess = async ( ); }; +/** What closing all sessions needs from a session it may have to retain. */ +interface RetainableDebuggerSession { + readonly id: string; + readonly launched: { + readonly process: { readonly pid?: number | undefined }; + }; + readonly supervisor: { ref(): void; unref(): void }; +} + /** * Stop every owned session and retain failed sessions for possible recovery. * Callers must block replacement admission and drain in-flight starts until * this settles; incomplete cleanup leaves the old owner in the map for retry. + * + * Closing all sessions means their owner is finishing, so a retained session + * stops holding the host's event loop, as a supervised provider does after + * incomplete cleanup. Ownership in this process is unchanged, and a retry + * restores the references. */ export const closeOwnedDebuggerSessions = async < - Session extends { id: string }, + Session extends RetainableDebuggerSession, >( sessions: Map, stop: (session: Session) => Promise, debuggerName: string, ): Promise => { + const entries = [...sessions.entries()]; + for (const [, session] of entries) session.supervisor.ref(); const results = await Promise.allSettled( - [...sessions.entries()].map(async ([key, session]) => ({ - key, - session, - result: await stop(session), - })), + entries.map(async ([, session]) => stop(session)), ); const failures: Error[] = []; - for (const settled of results) { + const retained: Session[] = []; + for (const [index, settled] of results.entries()) { + const [key, session] = entries[index]!; if (settled.status === "rejected") { failures.push( settled.reason instanceof Error ? settled.reason : new Error(String(settled.reason)), ); - continue; - } - const { key, session, result } = settled.value; - if (result.status === "incomplete") failures.push(new Error(result.reason)); - else if (sessions.get(key) === session) sessions.delete(key); + retained.push(session); + } else if (settled.value.status === "incomplete") { + failures.push(new Error(settled.value.reason)); + retained.push(session); + } else if (sessions.get(key) === session) sessions.delete(key); } - if (failures.length > 0) - throw new AggregateError( - failures, - `One or more owned ${debuggerName} processes did not stop cleanly`, - ); + if (failures.length === 0) return; + for (const session of retained) session.supervisor.unref(); + throw new OwnedDebuggerCleanupError( + failures, + `One or more owned ${debuggerName} processes did not stop cleanly`, + retained.flatMap(({ launched }) => launched.process.pid ?? []), + ); }; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 5808bd1ba02..030a94eede1 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -13,6 +13,7 @@ import { registerCaptureTools } from "./registerCaptureTools.js"; import { registerSignalTools } from "./registerSignalTools.js"; import { registerWasmTools } from "./registerWasmTools.js"; import { registerApkSignatureTools } from "./registerApkSignatureTools.js"; +import { retainedDebuggerDiagnostics } from "../process/ownedDebuggerCleanupError.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { captureProviderEnvironment } from "./providerEnvironment.js"; import { createByteEntropyService } from "../composition/binaryDiagnostics.js"; @@ -305,7 +306,10 @@ export const createServer = ( previousOnclose?.(); void closeProviders().catch((cause: unknown) => { logger.error( - { error: cause instanceof Error ? cause.message : String(cause) }, + { + error: cause instanceof Error ? cause.message : String(cause), + ...retainedDebuggerDiagnostics(cause), + }, "Provider cleanup failed during server shutdown", ); }); diff --git a/tests/boundary/cli/debuggerOneShotExit.test.ts b/tests/boundary/cli/debuggerOneShotExit.test.ts new file mode 100644 index 00000000000..b5d8b949f43 --- /dev/null +++ b/tests/boundary/cli/debuggerOneShotExit.test.ts @@ -0,0 +1,423 @@ +import { chmod, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +import { describe, expect, onTestFinished } from "vitest"; + +import { MCP_SHUTDOWN_FAILED } from "../../../src/main/messages.js"; +import { retainedDebuggerRemediation } from "../../../src/process/ownedDebuggerCleanupError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + cliProcessEnvironment, + cliTest, +} from "../../support/cli/cliFixture.js"; +import type { + ProcessResult, + TestProcesses, +} from "../../support/process/processFixture.js"; +import { rizinTestInferiorTty } from "../providers/RizinTestInferiorTty.js"; + +/** + * Fake debuggers behind the compiled public entrypoint. Real Rizin and GDB are + * not exercised: what they do once REA's end of their pipes closes is unverified. + * Every retained mode stays alive after stdin ends, like a debugger that ignores + * EOF, so a passing test also shows that REA did not stop what it reports retained. + * A request neither fake models is noted, and that note fails the test. + */ +const fakeDebuggerPrelude = `#!${process.execPath} +const { appendFileSync } = require("node:fs"); +const note = (line) => appendFileSync(process.env.FAKE_DEBUGGER_LOG, line + "\\n"); +const mode = process.env.FAKE_DEBUGGER_MODE; +note("pid " + process.pid); +process.on("SIGTERM", () => { note("sigterm"); process.exit(0); }); +process.stdin.setEncoding("utf8"); +process.stdin.on("end", () => note("stdin-end")); +setInterval(() => {}, 1000); +const onLines = (receive) => { + let input = ""; + process.stdin.on("data", (chunk) => { + input += chunk; + for (let end = input.indexOf("\\n"); end >= 0; end = input.indexOf("\\n")) { + receive(input.slice(0, end)); + input = input.slice(end + 1); + } + }); +}; +`; + +const fakeRizin = `${fakeDebuggerPrelude} +process.stdout.write("\\0"); +let silent = false; +onLines((line) => { + if (line === "dr") process.stdout.write("rax=0x1234\\0"); + else if (line === "olj") { + if (silent) return; + note("inventory-request"); + if (mode === "unanswered") silent = true; + else process.stdout.write(mode === "clean" ? "[]\\0" : "not-json\\0"); + } else if (/^!echo __REA_(COMMAND|CLEANUP)_COMPLETE_[0-9a-f]+__$/u.test(line)) { + if (!silent) process.stdout.write(line.slice(6) + "\\n\\0"); + } else { + note("unmodeled " + line); + process.stdout.write("unmodeled\\0"); + } +}); +`; + +const fakeGdb = `${fakeDebuggerPrelude} +process.stdout.write("(gdb)\\n"); +let tty = ""; +let inventoryRequests = 0; +let ttyReads = 0; +const liveGroup = '^done,groups=[{id="i1",type="process",pid="4194305",threads=[{id="1",state="stopped"}]}]'; +onLines((line) => { + const [, token, request] = /^(\\d+)(.*)$/u.exec(line) ?? []; + const reply = (tail) => process.stdout.write(token + tail + "\\n(gdb)\\n"); + if (request === "-gdb-set auto-load off") reply("^done"); + else if (request?.startsWith("-inferior-tty-set ")) { + tty = JSON.parse(request.slice("-inferior-tty-set ".length)); + reply("^done"); + } else if (request === "-inferior-tty-show") { + ttyReads += 1; + reply('^done,inferior_tty_terminal="' + (ttyReads > 1 ? "/dev/null" : tty) + '"'); + } else if (request === '-interpreter-exec console "show version"') { + process.stdout.write('~"GNU gdb (fake)\\\\n"\\n'); + reply("^done"); + } else if (request === "-list-thread-groups --recurse 1") { + inventoryRequests += 1; + if (mode === "clean") reply("^done,groups=[]"); + else if (mode === "malformed") reply('^error,msg="unavailable"'); + else if (inventoryRequests <= 2) reply(liveGroup); + else note("inventory-request"); + } else if (request === "-gdb-exit") { + process.stdout.write(token + "^exit\\n"); + process.exit(0); + } else { + note("unmodeled " + line); + if (token !== undefined) reply('^error,msg="unmodeled request"'); + } +}); +`; + +const debuggers = { + rizin: { + environmentKey: "REA_RIZIN_COMMAND", + source: fakeRizin, + arguments: (tty: string) => [ + "debug-with-rizin", + process.execPath, + "dr", + "--inferior-tty", + tty, + "--format", + "json", + ], + operation: "rizin_debug_command", + startTool: { + name: "start_rizin_debug_session", + arguments: (tty: string) => ({ + path: process.execPath, + inferior_tty: tty, + }), + }, + }, + gdb: { + environmentKey: "REA_GDB_COMMAND", + source: fakeGdb, + arguments: (tty: string) => [ + "debug-with-gdb", + "show version", + "--inferior-tty", + tty, + "--format", + "json", + ], + operation: "gdb_console", + startTool: { + name: "start_gdb_session", + arguments: (tty: string) => ({ inferior_tty: tty }), + }, + }, +} as const; + +type DebuggerName = keyof typeof debuggers; +type FakeMode = "clean" | "malformed" | "unanswered"; + +const EXIT_DEADLINE_MS = 20_000; +const TEST_DEADLINE_MS = 2 * EXIT_DEADLINE_MS; + +const isAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +/** Launch the compiled CLI against one fake debugger, on any POSIX host. */ +const prepareLaunch = async ( + name: DebuggerName, + mode: FakeMode, + compileCache: string, +) => { + const root = await createTestTempDirectory("rea-debugger-one-shot-exit-"); + const log = join(root, "debugger.log"); + await writeFile(log, ""); + const provider = join(root, name); + await writeFile(provider, debuggers[name].source); + await chmod(provider, 0o755); + // Persistent debugger sessions are Linux-only behind this gate. These modes + // stop before any /proc read, so the same workflow also runs on macOS. + const linuxGate = join(root, "linux-gate.mjs"); + await writeFile( + linuxGate, + 'Object.defineProperty(process, "platform", { value: "linux" });\n', + ); + const notes = async (): Promise => + (await readFile(log, "utf8")).split("\n").filter(Boolean); + const debuggerPid = async (): Promise => { + const line = (await notes()).find((note) => note.startsWith("pid ")); + if (line === undefined) throw new Error("The fake debugger did not start"); + return Number(line.slice("pid ".length)); + }; + onTestFinished(async () => { + const pid = await debuggerPid().catch(() => undefined); + if (pid !== undefined && isAlive(pid)) process.kill(pid, "SIGKILL"); + expect( + (await notes()).filter((note) => note.startsWith("unmodeled ")), + ).toEqual([]); + }); + return { + notes, + debuggerPid, + command: [ + "--import", + linuxGate, + resolve("scripts/rea.mjs"), + ] as readonly string[], + env: cliProcessEnvironment( + { + [debuggers[name].environmentKey]: provider, + FAKE_DEBUGGER_LOG: log, + FAKE_DEBUGGER_MODE: mode, + }, + compileCache, + ), + }; +}; + +/** Comparing with the formatter alone would still pass if it dropped the process. */ +const expectNamesProcess = (guidance: string, pid: number): void => { + expect(guidance).toMatch(new RegExp(`\\b${String(pid)}\\b`, "u")); +}; + +const expectRetainedOutcome = ( + name: DebuggerName, + result: ProcessResult, + pid: number, +): void => { + expect(JSON.parse(result.stdout)).toMatchObject({ + code: "cleanup_incomplete", + retryable: false, + remediation: { action: retainedDebuggerRemediation([pid]) }, + details: { + operation: debuggers[name].operation, + cleanup: "incomplete", + partial_observation: { + operation: debuggers[name].operation, + normalized_result: { completion_status: "complete" }, + }, + }, + }); + expectNamesProcess(retainedDebuggerRemediation([pid]), pid); + expect(result.stdout).toContain(`"retained_debugger_pids":[${String(pid)}]`); +}; + +/** Serve one MCP stdio client that has started a debugger session. */ +const startMcpSession = async ( + processes: TestProcesses, + name: DebuggerName, + launch: Awaited>, + tty: string, +) => { + const child = processes.spawn(process.execPath, [...launch.command, "mcp"], { + env: launch.env, + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => (stdout += String(chunk))); + child.stderr.on("data", (chunk: Buffer) => (stderr += String(chunk))); + const request = async ( + id: number, + method: string, + params: object, + ): Promise => { + child.stdin.write( + `${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`, + ); + await expect + .poll(() => stdout, { timeout: EXIT_DEADLINE_MS }) + .toContain(`"id":${String(id)}`); + }; + await request(1, "initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "debugger-one-shot-exit", version: "1" }, + }); + child.stdin.write( + `${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`, + ); + const { startTool } = debuggers[name]; + await request(2, "tools/call", { + name: startTool.name, + arguments: startTool.arguments(tty), + }); + expect(stdout).toContain("session_id"); + return { child, stderr: () => stderr }; +}; + +describe.skipIf(process.platform === "win32")( + "debugger processes that outlive their cleanup", + () => { + cliTest.for([ + ["rizin", "malformed"], + ["rizin", "unanswered"], + ["gdb", "malformed"], + ["gdb", "unanswered"], + ] as const)( + "ends one-shot %s with a retained debugger whose cleanup is %s", + { timeout: TEST_DEADLINE_MS }, + async ([name, mode], { processes, cliCompileCache }) => { + const tty = await rizinTestInferiorTty(); + const launch = await prepareLaunch(name, mode, cliCompileCache); + + const result = await processes.run( + process.execPath, + [...launch.command, ...debuggers[name].arguments(tty)], + { env: launch.env, timeoutMs: EXIT_DEADLINE_MS }, + ); + + expect(result).toMatchObject({ exitCode: 1, signal: null }); + const pid = await launch.debuggerPid(); + expectRetainedOutcome(name, result, pid); + // REA reports the debugger as retained and did not stop it. Exiting + // closes its command stream; that is the only thing it sees from REA. + await expect + .poll(launch.notes, { timeout: EXIT_DEADLINE_MS }) + .toContain("stdin-end"); + expect(await launch.notes()).not.toContain("sigterm"); + expect(isAlive(pid)).toBe(true); + }, + ); + + cliTest.for(["rizin", "gdb"] as const)( + "keeps a one-shot %s success and its exit status when cleanup is confirmed", + { timeout: TEST_DEADLINE_MS }, + async (name, { processes, cliCompileCache }) => { + const tty = await rizinTestInferiorTty(); + const launch = await prepareLaunch(name, "clean", cliCompileCache); + + const result = await processes.run( + process.execPath, + [...launch.command, ...debuggers[name].arguments(tty)], + { env: launch.env, timeoutMs: EXIT_DEADLINE_MS }, + ); + + expect(result).toMatchObject({ exitCode: 0, signal: null }); + expect(JSON.parse(result.stdout)).toMatchObject({ + operation: debuggers[name].operation, + normalized_result: { completion_status: "complete" }, + }); + expect(result.stdout).not.toContain("retained_debugger_pids"); + const pid = await launch.debuggerPid(); + await expect + .poll(() => isAlive(pid), { timeout: EXIT_DEADLINE_MS }) + .toBe(false); + }, + ); + + cliTest.for(["rizin", "gdb"] as const)( + "ends one-shot %s with its signal status when interrupted during final cleanup", + { timeout: TEST_DEADLINE_MS }, + async (name, { processes, cliCompileCache }) => { + const tty = await rizinTestInferiorTty(); + const launch = await prepareLaunch(name, "unanswered", cliCompileCache); + + const child = processes.spawn( + process.execPath, + [...launch.command, ...debuggers[name].arguments(tty)], + { env: launch.env }, + ); + let stdout = ""; + child.stdout.on("data", (chunk: Buffer) => (stdout += String(chunk))); + child.stdin.end(); + // The unanswered request is issued by final cleanup, after the command. + await expect + .poll(launch.notes, { timeout: EXIT_DEADLINE_MS }) + .toContain("inventory-request"); + child.kill("SIGINT"); + await expect + .poll(() => child.exitCode, { timeout: EXIT_DEADLINE_MS }) + .toBe(130); + await expect + .poll(() => stdout, { timeout: EXIT_DEADLINE_MS }) + .toContain("retained_debugger_pids"); + + const pid = await launch.debuggerPid(); + expectRetainedOutcome( + name, + { exitCode: 130, signal: null, stdout, stderr: "" }, + pid, + ); + expect(await launch.notes()).not.toContain("sigterm"); + expect(isAlive(pid)).toBe(true); + }, + ); + + cliTest.for([ + ["rizin", "client disconnect"], + ["gdb", "client disconnect"], + ["rizin", "SIGTERM"], + ] as const)( + "ends the stdio MCP server holding a retained %s session on %s and names it", + { timeout: TEST_DEADLINE_MS }, + async ([name, trigger], { processes, cliCompileCache }) => { + const tty = await rizinTestInferiorTty(); + const launch = await prepareLaunch(name, "malformed", cliCompileCache); + + const { child, stderr } = await startMcpSession( + processes, + name, + launch, + tty, + ); + + if (trigger === "SIGTERM") child.kill("SIGTERM"); + else child.stdin.end(); + + await expect + .poll(() => child.exitCode, { timeout: EXIT_DEADLINE_MS }) + .toBe(1); + const pid = await launch.debuggerPid(); + // Nothing else reports these once the server has exited. + await expect + .poll(stderr, { timeout: EXIT_DEADLINE_MS }) + .toContain(`${retainedDebuggerRemediation([pid])}\n`); + expectNamesProcess(retainedDebuggerRemediation([pid]), pid); + const logs = stderr() + .split("\n") + .filter((line) => line.startsWith("{")) + .map((line): unknown => JSON.parse(line)); + for (const msg of [ + "Provider cleanup failed during server shutdown", + MCP_SHUTDOWN_FAILED, + ]) + expect(logs).toContainEqual( + expect.objectContaining({ msg, retained_debugger_pids: [pid] }), + ); + expect(await launch.notes()).not.toContain("sigterm"); + expect(isAlive(pid)).toBe(true); + }, + ); + }, +); diff --git a/tests/boundary/cli/reverseEngineeringCommands.test.ts b/tests/boundary/cli/reverseEngineeringCommands.test.ts index 77a7ceef67c..854daed96fe 100644 --- a/tests/boundary/cli/reverseEngineeringCommands.test.ts +++ b/tests/boundary/cli/reverseEngineeringCommands.test.ts @@ -552,7 +552,16 @@ it.each(["incomplete", "interrupt", "control"] as const)( rawResult: { output: "registers" }, }, ); - const sessions = new Map([["fixture-session", { id: "fixture-session" }]]); + const sessions = new Map([ + [ + "fixture-session", + { + id: "fixture-session", + launched: { process: { pid: 4242 } }, + supervisor: { ref: vi.fn(), unref: vi.fn() }, + }, + ], + ]); vi.spyOn(RizinDebugSessionManager.prototype, "start").mockResolvedValue( ok({ session_id: "fixture-session", backend: null }), ); @@ -642,6 +651,7 @@ it.each(["incomplete", "interrupt", "control"] as const)( details: { cleanup: "incomplete", resources: ["fixture-session"], + diagnostics: { retained_debugger_pids: [4242] }, partial_observation: { operation: "rizin_debug_command", parameters: { session_id: "fixture-session" }, diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index 03b936b5d06..1be899b47ff 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -346,6 +346,7 @@ it.each([ "tests/support/reverseEngineeringService.ts", "tests/support/ownedRizinCommandEvidenceTests.ts", "tests/support/reverseDebuggerMcpTestHelpers.ts", + "tests/boundary/cli/debuggerOneShotExit.test.ts", "tests/boundary/cli/gdbStartupCleanupCli.test.ts", "vitest.reverse-tools.config.ts", "vitest.reverse-tools-semantics.config.ts", diff --git a/tests/boundary/mcp/shutdown.test.ts b/tests/boundary/mcp/shutdown.test.ts index 21222d7bd5a..bf01cebbd25 100644 --- a/tests/boundary/mcp/shutdown.test.ts +++ b/tests/boundary/mcp/shutdown.test.ts @@ -6,6 +6,10 @@ import { expect, it } from "vitest"; import { createManagedBinarySession } from "../../../src/composition/binary.js"; import { MCP_SHUTDOWN_FAILED } from "../../../src/main/messages.js"; import { createShutdown } from "../../../src/main/shutdown.js"; +import { + OwnedDebuggerCleanupError, + retainedDebuggerRemediation, +} from "../../../src/process/ownedDebuggerCleanupError.js"; import { registerProcessShutdown } from "../../../src/main/processShutdown.js"; import type { RuntimeDependencies } from "../../../src/main/types.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; @@ -75,6 +79,79 @@ it("keeps shutdown output stable while logging the rejected cause at debug level ); }); +it("names retained debuggers in stderr and every log level when shutdown ends", async () => { + const logs: string[] = []; + const output: string[] = []; + const requests: Array<() => void> = []; + const retained = new OwnedDebuggerCleanupError( + [new Error("detach unverified")], + "One or more owned GDB processes did not stop cleanly", + [4242], + ); + const dependencies: RuntimeDependencies = { + env: {}, + serve: () => { + throw new Error("unused fixture transport"); + }, + writeStderr: (text) => output.push(text), + setExitCode: () => undefined, + registerShutdown: (request) => { + requests.push(request); + return () => undefined; + }, + }; + createShutdown({ + // Runtime owners wrap this failure before it reaches the shutdown report. + handle: { + close: () => + Promise.reject( + new AggregateError( + [new AggregateError([retained], "REA provider cleanup failed")], + "REA server cleanup failed", + ), + ), + }, + session: createManagedBinarySession(), + dependencies, + serverLogger: pino( + { level: "debug" }, + { write: (line) => logs.push(line) }, + ), + }); + + requests[0]?.(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(output).toEqual([ + `${MCP_SHUTDOWN_FAILED}\n`, + `${retainedDebuggerRemediation([4242])}\n`, + ]); + // Comparing with the formatter alone would still pass if it dropped the process. + expect(output[1]).toMatch(/\b4242\b/u); + const parsed = logs.map((line) => JSON.parse(line)); + expect(parsed).toContainEqual( + expect.objectContaining({ + level: 50, + msg: MCP_SHUTDOWN_FAILED, + retained_debugger_pids: [4242], + }), + ); + expect(parsed).toContainEqual( + expect.objectContaining({ + level: 20, + failure_cause: expect.objectContaining({ + errors: [ + expect.objectContaining({ + errors: [ + expect.objectContaining({ retained_debugger_pids: [4242] }), + ], + }), + ], + }), + }), + ); +}); + it("reports a typed binary cleanup failure and retains the client for retry", async () => { const [target] = await createBinarySessionTargets(); const failure = new ProviderCleanupError("fixture", ["owned-runtime"], { diff --git a/tests/boundary/process/ownedDebuggerSession.test.ts b/tests/boundary/process/ownedDebuggerSession.test.ts index 155f7457e16..c46a66d7ab1 100644 --- a/tests/boundary/process/ownedDebuggerSession.test.ts +++ b/tests/boundary/process/ownedDebuggerSession.test.ts @@ -9,8 +9,14 @@ import { spawn as spawnPty } from "@lydell/node-pty"; import { expect, it, onTestFinished, vi } from "vitest"; +import { + OwnedDebuggerCleanupError, + retainedDebuggerProcessIds, + retainedDebuggerRemediation, +} from "../../../src/process/ownedDebuggerCleanupError.js"; import { closeOwnedDebuggerSessions, + type OwnedDebuggerStopResult, stopOwnedDebuggerProcess, verifyOwnedInferiorsEnded, } from "../../../src/process/ownedDebuggerSession.js"; @@ -29,6 +35,15 @@ const createFakeSupervisor = () => ({ limitAdditionalOutputBytes: vi.fn(), dispose: vi.fn(), unrefOutputStreams: vi.fn(), + ref: vi.fn(), + unref: vi.fn(), +}); + +/** A session reduced to what closing all sessions reads from it. */ +const closableSession = (id: string, pid?: number) => ({ + id, + launched: { process: { pid } }, + supervisor: { ref: vi.fn(), unref: vi.fn() }, }); const startStopProbe = (source: string, pipeInput = false) => { @@ -533,7 +548,7 @@ it("retains an exited debugger after provider detach failed", async () => { get exited() { return exited; }, - launched: { process: { kill: () => true } }, + launched: { process: { pid: 101, kill: () => true } }, detachInferior: async () => { throw new Error("MI detach failed"); }, @@ -545,6 +560,8 @@ it("retains an exited debugger after provider detach failed", async () => { disposals += 1; }, unrefOutputStreams: () => {}, + ref: () => {}, + unref: () => {}, }, }; const sessions = new Map([[session.id, session]]); @@ -580,7 +597,7 @@ it("recovers failed detach only after the identified inferior ends and output se get exited() { return exited; }, - launched: { process: { kill: vi.fn(() => true) } }, + launched: { process: { pid: 101, kill: vi.fn(() => true) } }, detachInferior: async () => { throw new Error("detach failed"); }, @@ -765,7 +782,7 @@ it("retains an exited session while a timed-out detach attempt is still pending" get exited() { return exited; }, - launched: { process: { kill: () => true } }, + launched: { process: { pid: 101, kill: () => true } }, detachInferior: () => { detachCalls += 1; return new Promise<{ readonly status: "no-inferior" }>((resolve) => { @@ -778,6 +795,8 @@ it("retains an exited session while a timed-out detach attempt is still pending" waitForOutputClose: async () => true, dispose: vi.fn(), unrefOutputStreams: vi.fn(), + ref: vi.fn(), + unref: vi.fn(), }, }; const sessions = new Map([[session.id, session]]); @@ -1365,8 +1384,8 @@ it.skipIf(process.platform === "win32")( it("removes stopped sessions and retains incomplete sessions when closing all", async () => { const sessions = new Map([ - ["stopped", { id: "stopped" }], - ["incomplete", { id: "incomplete" }], + ["stopped", closableSession("stopped")], + ["incomplete", closableSession("incomplete")], ]); await expect( @@ -1385,8 +1404,8 @@ it("removes stopped sessions and retains incomplete sessions when closing all", }); it("does not remove a replacement session installed under the same key while stopping", async () => { - const original = { id: "shared-key", generation: "old" }; - const replacement = { id: "shared-key", generation: "new" }; + const original = closableSession("shared-key"); + const replacement = closableSession("shared-key"); const sessions = new Map([["shared-key", original]]); let finishStop!: (result: { readonly status: "stopped" }) => void; const stopping = new Promise<{ readonly status: "stopped" }>((resolve) => { @@ -1407,7 +1426,7 @@ it("does not remove a replacement session installed under the same key while sto }); it("closes an empty owned-session collection", async () => { - const sessions = new Map(); + const sessions = new Map>(); await expect( closeOwnedDebuggerSessions( sessions, @@ -1419,10 +1438,10 @@ it("closes an empty owned-session collection", async () => { it("settles every stop and retains rejected sessions while removing successful ones", async () => { const sessions = new Map([ - ["stopped", { id: "stopped" }], - ["rejected", { id: "rejected" }], - ["string-rejected", { id: "string-rejected" }], - ["incomplete", { id: "incomplete" }], + ["stopped", closableSession("stopped")], + ["rejected", closableSession("rejected")], + ["string-rejected", closableSession("string-rejected")], + ["incomplete", closableSession("incomplete")], ]); const calls: string[] = []; @@ -1455,3 +1474,88 @@ it("settles every stop and retains rejected sessions while removing successful o "incomplete", ]); }); + +it("stops retained sessions holding the host and names them, leaving stopped peers alone", async () => { + const stopped = closableSession("stopped", 101); + const rejected = closableSession("rejected", 102); + const incomplete = closableSession("incomplete", 103); + const unidentified = closableSession("unidentified"); + const sessions = new Map( + [stopped, rejected, incomplete, unidentified].map((session) => [ + session.id, + session, + ]), + ); + + const failure: unknown = await closeOwnedDebuggerSessions( + sessions, + async (session) => { + if (session === stopped) return { status: "stopped" }; + if (session === rejected) throw new Error("signal failed"); + return { status: "incomplete", reason: "still alive" }; + }, + "Rizin", + ).catch((cause: unknown) => cause); + + expect(failure).toBeInstanceOf(OwnedDebuggerCleanupError); + expect(failure).toBeInstanceOf(AggregateError); + expect((failure as OwnedDebuggerCleanupError).retainedProcessIds).toEqual([ + 102, 103, + ]); + expect(stopped.supervisor.unref).not.toHaveBeenCalled(); + for (const retained of [rejected, incomplete, unidentified]) + expect(retained.supervisor.unref).toHaveBeenCalledOnce(); + expect([...sessions.keys()]).toEqual([ + "rejected", + "incomplete", + "unidentified", + ]); +}); + +it("holds the host again while retrying a retained session and leaves it held on success", async () => { + const session = closableSession("retained", 101); + const sessions = new Map([[session.id, session]]); + const order: string[] = []; + session.supervisor.ref.mockImplementation(() => order.push("ref")); + session.supervisor.unref.mockImplementation(() => order.push("unref")); + let stopResult: OwnedDebuggerStopResult = { + status: "incomplete", + reason: "still alive", + }; + const stop = async () => { + order.push("stop"); + return stopResult; + }; + + await expect( + closeOwnedDebuggerSessions(sessions, stop, "GDB"), + ).rejects.toBeInstanceOf(OwnedDebuggerCleanupError); + expect(order).toEqual(["ref", "stop", "unref"]); + + order.length = 0; + stopResult = { status: "stopped" }; + await closeOwnedDebuggerSessions(sessions, stop, "GDB"); + expect(order).toEqual(["ref", "stop"]); + expect(sessions.size).toBe(0); +}); + +it("finds retained debugger IDs through composed failures without looping on cycles", () => { + const first = new OwnedDebuggerCleanupError([], "first", [101, 102]); + const second = new OwnedDebuggerCleanupError([], "second", [102, 103]); + const cyclic = new Error("cyclic", { cause: second }); + Object.assign(second, { cause: cyclic }); + const composed = new AggregateError( + [first, new Error("wrapped", { cause: cyclic }), new Error("peer"), null], + "outer", + ); + + expect(retainedDebuggerProcessIds(composed)).toEqual([101, 102, 103]); + expect(retainedDebuggerProcessIds(new Error("peer"))).toEqual([]); + expect(retainedDebuggerProcessIds(undefined)).toEqual([]); +}); + +it("names every retained debugger in its remediation", () => { + const remediation = retainedDebuggerRemediation([4242, 4243]); + expect(remediation).toMatch(/\b4242\b/u); + expect(remediation).toMatch(/\b4243\b/u); +}); From 26c3e9e4134971c79e822bbcb16fb42bb7dd8e8d Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Mon, 2 Nov 2026 07:57:32 -0800 Subject: [PATCH 05/38] fix(native): select off-screen windows so the UI lane can complete (#8654) * fix(native): stop calling an off-screen window absent The native UI helper looked up its selected window with `.optionIncludingWindow`, which returned nothing while that window was off screen. An existing window owned by the selected process was reported as `window-mismatch`, before any permission check, so the verifier could not reach its permission boundary. Select from all windows instead; absent and foreign windows still fail. The verifier's changed-bytes check also read `error.message`, but since cc90771af a selection failure carries its helper code in `error.issues`, so the check could not pass. Read the issue. Together these let the permissions lane complete on main. Co-Authored-By: Claude Opus 5.5 * test(native): own UI test processes from the moment they launch The off-screen test registered cleanup only after parsing the ready record, so a startup failure left `/usr/bin/open` and its app running. The lane test killed only the verifier on a timeout, leaving its launcher and detached fixture alive until they expired. Register the off-screen owner immediately after the launch; the app inherits the run token, so the sweep needs no ready record. In the lane test, every stand-in records itself and leaves on one release the test's cleanup awaits. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 + bridge/native/ReaNativeUI.swift | 5 +- docs/native-investigation.md | 4 +- scripts/ci/scopes.mjs | 3 + scripts/verify-native-ui.mjs | 8 +- .../boundary/filesystem/ciChangeScope.test.ts | 12 + .../process/nativeUiVerifierLane.test.ts | 222 ++++++++++++++++++ .../native/nativeUiOffscreenWindow.test.ts | 138 +++++++++++ tests/conformance/native/ui-offscreen.swift | 14 ++ 9 files changed, 405 insertions(+), 3 deletions(-) create mode 100644 tests/boundary/process/nativeUiVerifierLane.test.ts create mode 100644 tests/boundary/providers/native/nativeUiOffscreenWindow.test.ts create mode 100644 tests/conformance/native/ui-offscreen.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57b3c6d32a7..6880245b921 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -807,6 +807,8 @@ jobs: tests/boundary/mcp/nativeDemangleMcp.test.ts tests/boundary/mcp/nativeMachoSummaryMcp.test.ts tests/boundary/providers/native/nativeSignatureTargetBinding.test.ts + tests/boundary/providers/native/nativeUiOffscreenWindow.test.ts + tests/boundary/process/nativeUiVerifierLane.test.ts tests/boundary/cli/nativeSignatureCli.test.ts tests/boundary/cli/nativeMachoArm64_32Cli.test.ts tests/boundary/cli/nativeMachoEncryptionCli.test.ts diff --git a/bridge/native/ReaNativeUI.swift b/bridge/native/ReaNativeUI.swift index b123a57f245..2c7bd739926 100644 --- a/bridge/native/ReaNativeUI.swift +++ b/bridge/native/ReaNativeUI.swift @@ -65,7 +65,10 @@ func observe(_ request: Request) async throws -> [String: Any] { guard SHA256.hash(data: executableBytes).map({ String(format: "%02x", $0) }).joined() == request.sha256 else { try fail("target-mismatch", "Running executable bytes differ from the active target digest") } - guard let windows = CGWindowListCopyWindowInfo([.optionIncludingWindow], request.window_id) as? [[String: Any]], + // .optionIncludingWindow omits a selected window while it is off screen, which + // is not absence: select from every window, and let capture report what it can. + guard let windows = (CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as? [[String: Any]])? + .filter({ ($0[kCGWindowNumber as String] as? NSNumber)?.uint32Value == request.window_id }), windows.count == 1, let window = windows.first, (window[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value == request.pid, (window[kCGWindowLayer as String] as? NSNumber)?.intValue == 0, diff --git a/docs/native-investigation.md b/docs/native-investigation.md index fe02a2b9d4f..abd1424ea56 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -1004,7 +1004,9 @@ with a path-specific gap and incomplete capture coverage. Null alone does not establish that an accessibility value was successfully read or absent. An initial PID or window mismatch is `invalid_request` on `pid` or -`window_id` and leaves provider availability intact. An unavailable executable +`window_id` and leaves provider availability intact. A selected window that +exists off screen is not a mismatch: selection succeeds and the permission and +capture checks report their own outcome. An unavailable executable URL or unresolved executable path leaves identity unknown and reports `capability_unavailable`. Identity or window loss after the initial capture remains an observation failure with collected facts. diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index 051406c6254..7f5e4d3839e 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -165,6 +165,9 @@ const PATH_SCOPES = { "tests/boundary/mcp/nativeDemangleMcp.test.ts", "tests/boundary/mcp/nativeMachoSummaryMcp.test.ts", "tests/boundary/providers/native/nativeSignatureTargetBinding.test.ts", + "tests/boundary/providers/native/nativeUiOffscreenWindow.test.ts", + "tests/boundary/process/nativeUiVerifierLane.test.ts", + "tests/conformance/native/ui-offscreen.swift", "tests/boundary/cli/nativeSignatureCli.test.ts", "tests/boundary/cli/nativeMachoArm64_32Cli.test.ts", "tests/boundary/cli/nativeMachoEncryptionCli.test.ts", diff --git a/scripts/verify-native-ui.mjs b/scripts/verify-native-ui.mjs index 91a9f6a1699..b7bfb435367 100644 --- a/scripts/verify-native-ui.mjs +++ b/scripts/verify-native-ui.mjs @@ -245,7 +245,13 @@ try { scope, { environment: process.env }, ); - if (mismatch.ok || !mismatch.error.message.includes("target-mismatch")) + // A selection failure carries its helper code in the issue, not in its message. + if ( + mismatch.ok || + !mismatch.error.issues?.some(({ message }) => + message.startsWith("target-mismatch:"), + ) + ) throw new Error("Real helper did not reject changed target bytes"); report = { ok: true, diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index 1be899b47ff..351223eb05a 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -897,6 +897,18 @@ it.each([ "tests/boundary/cli/nativePlistCli.test.ts", { code: true, apple: true, package: false, runtime: false }, ], + [ + "tests/boundary/process/nativeUiVerifierLane.test.ts", + { code: true, apple: true, package: false, runtime: false }, + ], + [ + "tests/boundary/providers/native/nativeUiOffscreenWindow.test.ts", + { code: true, apple: true, package: false, runtime: false }, + ], + [ + "tests/conformance/native/ui-offscreen.swift", + { code: true, apple: true, package: false, runtime: false }, + ], [ "src/artifacts/InesArtifactReader.ts", { code: true, apple: true, package: false, runtime: true }, diff --git a/tests/boundary/process/nativeUiVerifierLane.test.ts b/tests/boundary/process/nativeUiVerifierLane.test.ts new file mode 100644 index 00000000000..5ddf216c587 --- /dev/null +++ b/tests/boundary/process/nativeUiVerifierLane.test.ts @@ -0,0 +1,222 @@ +import { spawn } from "node:child_process"; +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; +import { expect, it, onTestFinished } from "vitest"; +import { z } from "zod"; + +import { systemProcessOwnershipHost } from "../../../src/process/ProcessOwnershipObservation.js"; +import { liveProcesses } from "../../../src/process/ProcessOwnershipProcessTree.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +// Stands in for /usr/bin/open: starts a detached app that inherits its environment. +// Each stand-in records itself and leaves when the test releases it, or expires. +const standInPrelude = ` +import { appendFileSync, existsSync, readFileSync } from "node:fs"; +const config = JSON.parse(readFileSync(new URL("./config.json", import.meta.url), "utf8")); +const record = (kind) => appendFileSync(config.events, JSON.stringify({ kind, pid: process.pid }) + "\\n"); +setInterval(() => { + if (existsSync(config.release)) process.exit(0); +}, 10); +setTimeout(() => process.exit(0), 60000); +`; + +const launcherSource = ` +import { spawn } from "node:child_process"; +import { openSync } from "node:fs"; +${standInPrelude} +record("launcher"); +const args = process.argv.slice(2); +const coordinates = args[args.indexOf("--stdout") + 1]; +spawn(process.execPath, [new URL("./fixture.mjs", import.meta.url).pathname], { + detached: true, + stdio: ["ignore", openSync(coordinates, "a"), "ignore"], +}).unref(); +`; + +const fixtureSource = ` +import { writeSync } from "node:fs"; +${standInPrelude} +record("fixture"); +writeSync(1, JSON.stringify({ pid: process.pid, window_id: 7 }) + "\\n"); +`; + +// Runs the actual verifier and observation mapping. Only the compiler and launcher +// are replaced: the compiled helper is an authored script answering as the real +// helper does, so this covers the lane's assertions and not native capture. +const preloadSource = ` +import { chmodSync, copyFileSync, readFileSync, writeFileSync } from "node:fs"; +import { createRequire, syncBuiltinESMExports } from "node:module"; +import { basename } from "node:path"; +import { promisify } from "node:util"; +const config = JSON.parse(readFileSync(new URL("./config.json", import.meta.url), "utf8")); +const childProcess = createRequire(import.meta.url)("node:child_process"); +const { execFile, spawn } = childProcess; +const script = (path, body) => { + writeFileSync(path, "#!/bin/sh\\n" + body + "\\n"); + chmodSync(path, 0o755); +}; +const compile = (args) => { + const output = args[args.indexOf("-o") + 1]; + if (basename(output) === "fixture") copyFileSync("/usr/bin/true", output); + else if (basename(output) === "child-retrieval-test") + script(output, "printf '%s\\\\n' '{\\"a\\":\\"b\\"}' '{\\"n\\":1.0}'"); + else script(output, config.helper); +}; +const compiled = (file, args) => file === "/usr/bin/xcrun" && args.includes("-o"); +childProcess.execFile = (file, args, ...rest) => { + if (!compiled(file, args)) return execFile(file, args, ...rest); + compile(args); + const done = rest.at(-1); + queueMicrotask(() => done(null, "", "")); + return spawn("/usr/bin/true", [], { stdio: "ignore" }); +}; +childProcess.execFile[promisify.custom] = (file, args, ...rest) => { + if (!compiled(file, args)) return execFile[promisify.custom](file, args, ...rest); + compile(args); + return Promise.resolve({ stdout: "", stderr: "" }); +}; +childProcess.spawn = (file, args, options) => { + if (compiled(file, args)) { + compile(args); + return spawn("/usr/bin/true", [], options); + } + return file === "/usr/bin/open" + ? spawn(process.execPath, [config.launcherEntry, ...args], options) + : spawn(file, args, options); +}; +syncBuiltinESMExports(); +`; + +const denied = `printf '%s' '{"ok":false,"code":"screen-recording-denied","message":"authored permission denial"}'`; +const answersChangedBytes = `case "$1" in *'"sha256":"0000000000'*) printf '%s' '{"ok":false,"code":"target-mismatch","message":"authored digest refusal"}'; exit 0;; esac; ${denied}`; + +const standInSchema = z.object({ + kind: z.enum(["launcher", "fixture"]), + pid: z.number().int().positive(), +}); + +const runVerifier = async (helper: string, flags: readonly string[]) => { + const workspace = await createTestTempDirectory("rea-native-ui-lane-"); + const paths = { + events: join(workspace, "events.jsonl"), + release: join(workspace, "release"), + launcherEntry: join(workspace, "launcher.mjs"), + }; + await writeFile(paths.events, ""); + await writeFile(paths.launcherEntry, launcherSource); + await writeFile(join(workspace, "fixture.mjs"), fixtureSource); + await writeFile(join(workspace, "preload.mjs"), preloadSource); + await writeFile( + join(workspace, "config.json"), + JSON.stringify({ ...paths, helper }), + ); + const child = spawn( + process.execPath, + [ + "--import", + pathToFileURL(join(workspace, "preload.mjs")).href, + resolve("scripts/verify-native-ui.mjs"), + ...flags, + ], + { + env: { ...process.env, TMPDIR: workspace }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const standIns = async () => + (await readFile(paths.events, "utf8")) + .split("\n") + .filter((line) => line.length > 0) + .map((line) => standInSchema.parse(JSON.parse(line))); + const isLive = async (pid: number) => + liveProcesses(await systemProcessOwnershipHost.listProcesses()).some( + (process) => process.pid === pid, + ); + const untilGone = async (pid: number) => { + const deadline = performance.now() + 15_000; + while ((await isLive(pid)) && performance.now() < deadline) await delay(25); + return !(await isLive(pid)); + }; + // Runs on a timeout or failed assertion too: the verifier cannot release a stand-in + // once killed, and its fixture would otherwise outlive the test until it expired. + onTestFinished(async () => { + child.kill("SIGKILL"); + await writeFile(paths.release, ""); + for (const { pid } of await standIns()) + expect(await untilGone(pid)).toBe(true); + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + const code = await new Promise((settle) => { + child.once("close", settle); + }); + const fixture = (await standIns()).find(({ kind }) => kind === "fixture"); + expect(fixture, stderr).toBeDefined(); + return { + code, + stdout, + stderr, + fixtureLive: !(await untilGone(fixture?.pid ?? -1)), + roots: (await readdir(workspace)).filter((name) => + name.startsWith("rea-ui-fixture-"), + ), + }; +}; + +// The verifier refuses other hosts before any fixture is created. +const macosIt = it.skipIf(process.platform !== "darwin"); + +macosIt( + "completes at the permission boundary when the helper refuses changed target bytes", + { timeout: 30_000 }, + async () => { + const result = await runVerifier(answersChangedBytes, [ + "--allow-permission-denial", + ]); + expect(result.code, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + verification_status: "permission-boundary-only", + positive_e2e: false, + mismatch_rejected: true, + }); + expect(result.fixtureLive).toBe(false); + expect(result.roots).toEqual([]); + }, +); + +macosIt( + "fails when the helper does not refuse changed target bytes", + { timeout: 30_000 }, + async () => { + const result = await runVerifier(denied, ["--allow-permission-denial"]); + expect(result.code).toBe(1); + expect(result.stderr).toContain( + "Real helper did not reject changed target bytes", + ); + expect(result.stdout).toBe(""); + expect(result.fixtureLive).toBe(false); + expect(result.roots).toEqual([]); + }, +); + +macosIt( + "keeps a permission denial a failure in the positive lane", + { timeout: 30_000 }, + async () => { + const result = await runVerifier(answersChangedBytes, []); + expect(result.code).toBe(1); + expect(result.stderr).toContain("requires successful capture"); + expect(result.stdout).toBe(""); + expect(result.fixtureLive).toBe(false); + expect(result.roots).toEqual([]); + }, +); diff --git a/tests/boundary/providers/native/nativeUiOffscreenWindow.test.ts b/tests/boundary/providers/native/nativeUiOffscreenWindow.test.ts new file mode 100644 index 00000000000..a2de1978634 --- /dev/null +++ b/tests/boundary/providers/native/nativeUiOffscreenWindow.test.ts @@ -0,0 +1,138 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { promisify } from "node:util"; +import { expect, it, onTestFinished } from "vitest"; +import { z } from "zod"; + +import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; +import { AnalysisInputError } from "../../../../src/domain/analysisErrorCore.js"; +import { createNativeUiHelperRuntime } from "../../../../src/native/NativeUiHelperRuntime.js"; +import { observeNativeUi } from "../../../../src/native/NativeUiObservation.js"; +import { cleanupOwnedProcessGroup } from "../../../../src/process/ProcessOwnership.js"; +import { + ProviderProcessSupervisor, + spawnOwnedProviderProcess, +} from "../../../../src/process/ProviderProcess.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +const scopeSchema = z.object({ + pid: z.number().int().positive(), + window_id: z.number().int().positive(), +}); + +// Ownership starts with the launch: the app inherits the launcher's run token, so +// the sweep finds it with or without a ready record. /usr/bin/open's own token is +// withheld, so its retained handle must stop it before the sweep can succeed. +const launch = async (bundle: string, name: string) => { + const coordinates = join(bundle, "..", `${name}.json`); + await writeFile(coordinates, ""); + const owned = await spawnOwnedProviderProcess({ + command: "/usr/bin/open", + arguments: ["-W", "-n", "-g", "--stdout", coordinates, bundle], + runId: randomUUID(), + sweepTokenOwnedProcesses: true, + }); + const launcher = new ProviderProcessSupervisor( + { process: owned.process, ownsProcessLifetime: true }, + { maxDiagnosticBytes: { stdout: 0, stderr: 64 * 1024 } }, + ); + onTestFinished(async () => { + const stopped = await launcher.stop(); + const swept = await cleanupOwnedProcessGroup(owned.ownership); + expect(stopped.status).not.toBe("incomplete"); + expect(swept).toMatchObject({ cleaned: true }); + }); + const deadline = performance.now() + 30_000; + for (;;) { + const [line] = (await readFile(coordinates, "utf8")).split("\n"); + if (line !== undefined && line.length > 0) + return scopeSchema.parse(JSON.parse(line)); + if (performance.now() >= deadline) + throw new Error("The off-screen fixture never reported its window"); + await delay(50); + } +}; + +// The fixture window is real and never ordered in; the helper is compiled and run +// by the production runtime. Capture is off for the raw helper reads, so these +// facts need no Accessibility or Screen Recording permission. +it.skipIf(process.platform !== "darwin")( + "selects a window that exists off screen and still refuses absent and foreign windows", + { timeout: 240_000 }, + async () => { + const root = await createTestTempDirectory("rea-native-ui-offscreen-"); + const bundle = join(root, "Fixture.app"); + const contents = join(bundle, "Contents"); + await mkdir(join(contents, "MacOS"), { recursive: true }); + await writeFile( + join(contents, "Info.plist"), + `CFBundleExecutablefixtureCFBundleIdentifierio.rea.source-owned-offscreen-fixtureCFBundleNameREA Offscreen FixtureCFBundlePackageTypeAPPL`, + ); + const executable = join(contents, "MacOS", "fixture"); + await promisify(execFile)("/usr/bin/xcrun", [ + "swiftc", + "-module-cache-path", + join(root, "modules"), + resolve("tests/conformance/native/ui-offscreen.swift"), + "-o", + executable, + ]); + const selected = await launch(bundle, "selected"); + const foreign = await launch(bundle, "foreign"); + const target = await parseBinaryTarget(executable); + if (!target.ok) throw target.error; + + const runtime = createNativeUiHelperRuntime(process.env); + onTestFinished(() => runtime.close()); + const read = (windowId: number) => + runtime.invoke({ + pid: selected.pid, + window_id: windowId, + executable: target.value.path, + sha256: target.value.sha256, + screenshot: false, + accessibility: false, + max_nodes: 1, + }); + + expect(await read(selected.window_id)).toMatchObject({ + ok: true, + result: { window: { pid: selected.pid, window_id: selected.window_id } }, + }); + for (const windowId of [foreign.window_id, 2_147_483_000]) + expect(await read(windowId)).toMatchObject({ + ok: false, + code: "window-mismatch", + }); + + // Through the shared observation, the window is no longer called absent. Its + // outcome depends on the host's permissions, which this test does not select. + const options = { environment: process.env, runtime }; + const observed = await observeNativeUi( + target.value, + "observe_native_ui", + selected, + options, + ); + if (!observed.ok) + expect(JSON.stringify(observed.error)).not.toContain("window-mismatch"); + + // The fact the verifier's changed-bytes check reads. + const changed = await observeNativeUi( + { ...target.value, sha256: "0".repeat(64) }, + "observe_native_ui", + selected, + options, + ); + expect(changed.ok).toBe(false); + if (changed.ok) return; + expect(changed.error).toBeInstanceOf(AnalysisInputError); + expect((changed.error as AnalysisInputError).issues?.[0]).toMatchObject({ + path: ["pid"], + message: expect.stringMatching(/^target-mismatch:/u), + }); + }, +); diff --git a/tests/conformance/native/ui-offscreen.swift b/tests/conformance/native/ui-offscreen.swift new file mode 100644 index 00000000000..599a1a836f8 --- /dev/null +++ b/tests/conformance/native/ui-offscreen.swift @@ -0,0 +1,14 @@ +import AppKit + +// A real window that exists and is never ordered in, so it stays off screen and +// never appears on the desktop. It expires in case its launcher loses track of it. +let app = NSApplication.shared +app.setActivationPolicy(.accessory) +let window = NSWindow(contentRect: NSRect(x: 120, y: 120, width: 240, height: 80), styleMask: [.titled], backing: .buffered, defer: false) +window.title = "REA off-screen window fixture" +DispatchQueue.main.async { + let record = "{\"pid\":\(ProcessInfo.processInfo.processIdentifier),\"window_id\":\(window.windowNumber)}\n" + FileHandle.standardOutput.write(Data(record.utf8)) +} +DispatchQueue.main.asyncAfter(deadline: .now() + 300) { exit(0) } +app.run() From 2cb29693c3d574c4898832047fe309ac1ad00e9d Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:21:16 +0800 Subject: [PATCH 06/38] fix(ci): preserve release proposal overflow notes --- patches/release-please+17.3.0.patch | 45 +++++++++ scripts/lib/release-proposal.d.mts | 2 + scripts/lib/release-proposal.mjs | 9 ++ scripts/release-proposal.mjs | 3 +- .../filesystem/releaseProposal.test.ts | 94 +++++++++++++++++++ 5 files changed, 152 insertions(+), 1 deletion(-) diff --git a/patches/release-please+17.3.0.patch b/patches/release-please+17.3.0.patch index 9acd1481741..54c1c792309 100644 --- a/patches/release-please+17.3.0.patch +++ b/patches/release-please+17.3.0.patch @@ -81,6 +81,51 @@ // In this case, there is no squashed merge commit. This could be a simple // merge commit, a rebase merge commit, or a direct commit to the branch. // Fallback to fetching the list of commits from the REST API. In the future +@@ -1154,15 +1154,40 @@ + * @returns {string} HTML URL of the new file + */ + async createFileOnNewBranch(filename, contents, newBranchName, baseBranchName) { +- // create or update new branch to match base branch +- await this.forkBranch(newBranchName, baseBranchName); +- // use the single file upload API ++ // Preserve existing notes until the replacement file is committed. ++ // A failed write must not leave an open proposal pointing at a missing file. ++ if (!(await this.getBranchSha(newBranchName))) { ++ const baseSha = await this.getBranchSha(baseBranchName); ++ if (!baseSha) { ++ throw new Error(`Unable to find base branch: ${baseBranchName}`); ++ } ++ await this.createNewBranch(newBranchName, baseSha); ++ } ++ let existingSha; ++ try { ++ const { data } = await this.octokit.repos.getContent({ ++ owner: this.repository.owner, ++ repo: this.repository.repo, ++ path: filename, ++ ref: newBranchName, ++ }); ++ if (Array.isArray(data) || data.type !== 'file' || !data.sha) { ++ throw new Error(`Expected a file at ${filename} on branch: ${newBranchName}`); ++ } ++ existingSha = data.sha; ++ } ++ catch (error) { ++ if (!(error instanceof request_error_1.RequestError && error.status === 404)) { ++ throw error; ++ } ++ } + const { data: { content }, } = await this.octokit.repos.createOrUpdateFileContents({ + owner: this.repository.owner, + repo: this.repository.repo, + path: filename, + // contents need to be base64 encoded +- content: Buffer.from(contents, 'binary').toString('base64'), ++ content: Buffer.from(contents, 'utf8').toString('base64'), ++ ...(existingSha ? { sha: existingSha } : {}), + message: 'Saving release notes', + branch: newBranchName, + }); --- a/build/src/github.d.ts +++ b/build/src/github.d.ts @@ -47,6 +47,8 @@ diff --git a/scripts/lib/release-proposal.d.mts b/scripts/lib/release-proposal.d.mts index bd92aaba19b..76aaaaa5d3d 100644 --- a/scripts/lib/release-proposal.d.mts +++ b/scripts/lib/release-proposal.d.mts @@ -1,5 +1,7 @@ export function applyReleasePleasePatch(directory?: string): Promise; +export function describeReleaseProposalError(error: unknown): string; + export function releaseProposalSearchDepth( run: (command: string, args: string[]) => Promise, sourceSha: string, diff --git a/scripts/lib/release-proposal.mjs b/scripts/lib/release-proposal.mjs index 1bbb709c611..c6b4e5bc2c7 100644 --- a/scripts/lib/release-proposal.mjs +++ b/scripts/lib/release-proposal.mjs @@ -13,6 +13,15 @@ const patch = fileURLToPath( new URL("../../patches/release-please+17.3.0.patch", import.meta.url), ); +export function describeReleaseProposalError(error) { + if (!(error instanceof Error)) return String(error); + const description = error.message || error.name || "Unknown error"; + const status = error.status; + return Number.isInteger(status) && status >= 400 && status <= 599 + ? `${description} (HTTP ${status})` + : description; +} + export async function applyReleasePleasePatch( directory = dirname(require.resolve("release-please/package.json")), ) { diff --git a/scripts/release-proposal.mjs b/scripts/release-proposal.mjs index 3f85424ef32..78a22c56b48 100644 --- a/scripts/release-proposal.mjs +++ b/scripts/release-proposal.mjs @@ -4,6 +4,7 @@ import { appendFile } from "node:fs/promises"; import { parseArgs, promisify } from "node:util"; import { applyReleasePleasePatch, + describeReleaseProposalError, releaseProposalSearchDepth, } from "./lib/release-proposal.mjs"; @@ -63,7 +64,7 @@ async function main() { await main().catch((error) => { process.stderr.write( - `Release proposal failed: ${error instanceof Error ? error.message : String(error)}\n`, + `Release proposal failed: ${describeReleaseProposalError(error)}\n`, ); process.exitCode = 1; }); diff --git a/tests/boundary/filesystem/releaseProposal.test.ts b/tests/boundary/filesystem/releaseProposal.test.ts index f952091603f..c5d27b566f5 100644 --- a/tests/boundary/filesystem/releaseProposal.test.ts +++ b/tests/boundary/filesystem/releaseProposal.test.ts @@ -10,11 +10,13 @@ import { createRequire } from "node:module"; import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; +import { RequestError } from "@octokit/request-error"; import { afterEach, beforeAll, expect, it, vi } from "vitest"; import { z } from "zod"; import { applyReleasePleasePatch, + describeReleaseProposalError, releaseProposalSearchDepth, } from "../../../scripts/lib/release-proposal.mjs"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -33,6 +35,16 @@ const silent = { trace() {}, }; +it("reports an HTTP status when a release proposal error has no message", () => { + const failure = Object.assign(new Error(""), { + name: "HttpError", + status: 504, + request: { headers: { authorization: "secret" } }, + }); + expect(describeReleaseProposalError(failure)).toBe("HttpError (HTTP 504)"); + expect(describeReleaseProposalError(new Error("broken"))).toBe("broken"); +}); + beforeAll(async () => { upstream = await import("release-please"); githubRuntime = require("release-please/build/src/github.js"); @@ -40,6 +52,88 @@ beforeAll(async () => { afterEach(() => vi.restoreAllMocks()); +it("preserves the existing overflow notes if their replacement fails", async () => { + const write = vi + .fn() + .mockRejectedValueOnce(new Error("upload failed")) + .mockResolvedValueOnce({ + data: { content: { html_url: "https://example.test/notes" } }, + }); + const getContent = vi.fn().mockResolvedValue({ + data: { type: "file", sha: "existing-blob" }, + }); + const createNewBranch = vi.fn(); + const forkBranch = vi.fn(); + const github = { + repository: { owner: "fixture", repo: "rea" }, + getBranchSha: vi.fn().mockResolvedValue("existing-branch"), + createNewBranch, + forkBranch, + octokit: { repos: { getContent, createOrUpdateFileContents: write } }, + } as unknown as Awaited>; + const update = () => + upstream.GitHub.prototype.createFileOnNewBranch.call( + github, + "release-notes.md", + "Notes: café", + "notes", + "main", + ); + await expect(update()).rejects.toThrow("upload failed"); + expect(forkBranch).not.toHaveBeenCalled(); + expect(createNewBranch).not.toHaveBeenCalled(); + await expect(update()).resolves.toBe("https://example.test/notes"); + expect(write).toHaveBeenLastCalledWith( + expect.objectContaining({ + branch: "notes", + sha: "existing-blob", + content: Buffer.from("Notes: café", "utf8").toString("base64"), + }), + ); +}); + +it("creates overflow notes on a new branch without requiring an existing file", async () => { + const write = vi.fn().mockResolvedValue({ + data: { content: { html_url: "https://example.test/notes" } }, + }); + const createNewBranch = vi.fn(); + const github = { + repository: { owner: "fixture", repo: "rea" }, + getBranchSha: vi + .fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce("base-sha"), + createNewBranch, + octokit: { + repos: { + getContent: vi.fn().mockRejectedValue( + new RequestError("missing", 404, { + request: { + method: "GET", + url: "https://example.test/notes", + headers: {}, + }, + }), + ), + createOrUpdateFileContents: write, + }, + }, + } as unknown as Awaited>; + await expect( + upstream.GitHub.prototype.createFileOnNewBranch.call( + github, + "release-notes.md", + "Notes", + "notes", + "main", + ), + ).resolves.toBe("https://example.test/notes"); + expect(createNewBranch).toHaveBeenCalledWith("notes", "base-sha"); + expect(write).toHaveBeenCalledWith( + expect.not.objectContaining({ sha: expect.anything() }), + ); +}); + function respond(value: unknown, status = 200) { return new Response(JSON.stringify(value), { status, From 3c05b4d3fef94eb91c966ecd675ff97fef3e364e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:10:56 +0800 Subject: [PATCH 07/38] fix(ci): remove undeclared dependency and handle PTY chunks --- patches/release-please+17.3.0.patch | 2 +- .../boundary/filesystem/releaseProposal.test.ts | 15 +++++---------- .../boundary/process/processSessionMcp.test.ts | 17 +++++++++++++---- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/patches/release-please+17.3.0.patch b/patches/release-please+17.3.0.patch index 54c1c792309..37dbf71d224 100644 --- a/patches/release-please+17.3.0.patch +++ b/patches/release-please+17.3.0.patch @@ -111,7 +111,7 @@ + existingSha = data.sha; + } + catch (error) { -+ if (!(error instanceof request_error_1.RequestError && error.status === 404)) { ++ if (!(error && error.status === 404)) { + throw error; + } + } diff --git a/tests/boundary/filesystem/releaseProposal.test.ts b/tests/boundary/filesystem/releaseProposal.test.ts index c5d27b566f5..234d4967d99 100644 --- a/tests/boundary/filesystem/releaseProposal.test.ts +++ b/tests/boundary/filesystem/releaseProposal.test.ts @@ -10,7 +10,6 @@ import { createRequire } from "node:module"; import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; -import { RequestError } from "@octokit/request-error"; import { afterEach, beforeAll, expect, it, vi } from "vitest"; import { z } from "zod"; @@ -106,15 +105,11 @@ it("creates overflow notes on a new branch without requiring an existing file", createNewBranch, octokit: { repos: { - getContent: vi.fn().mockRejectedValue( - new RequestError("missing", 404, { - request: { - method: "GET", - url: "https://example.test/notes", - headers: {}, - }, - }), - ), + getContent: vi + .fn() + .mockRejectedValue( + Object.assign(new Error("missing"), { status: 404 }), + ), createOrUpdateFileContents: write, }, }, diff --git a/tests/boundary/process/processSessionMcp.test.ts b/tests/boundary/process/processSessionMcp.test.ts index 565f16a7449..094757bd90c 100644 --- a/tests/boundary/process/processSessionMcp.test.ts +++ b/tests/boundary/process/processSessionMcp.test.ts @@ -115,11 +115,20 @@ itWithCaptureCapability( }; const expanded = await capture("x".repeat(500)); - expect(expanded.frames).toHaveLength(0); expect(expanded.truncated).toBe(true); + const retainedNormalized = expanded.frames.map(({ data }) => data).join(""); + const retainedRaw = expanded.frames + .map(({ raw_data, data }) => raw_data ?? data) + .join(""); + // PTY read boundaries need not match the child's write. A small chunk can + // fit even after another chunk was omitted for exceeding the budget. + expect(retainedNormalized).toBe( + retainedRaw.replaceAll("a", "x".repeat(500)), + ); + expect(Buffer.byteLength(retainedNormalized)).toBeLessThanOrEqual(1_000); expect(expanded.truncation_details.normalized_terminal).toMatchObject({ budget_bytes: 1_000, - retained_bytes: 0, + retained_bytes: Buffer.byteLength(retainedNormalized), omitted_frames: expect.any(Number), }); expect( @@ -128,9 +137,9 @@ itWithCaptureCapability( expect(expanded.truncation_details.raw_terminal).toMatchObject({ budget_bytes: 1_000, observed_bytes: expect.any(Number), - retained_bytes: 0, + retained_bytes: Buffer.byteLength(retainedRaw), observed_frames: expect.any(Number), - retained_frames: 0, + retained_frames: expanded.frames.length, }); expect( expanded.limitations.some((limitation) => From 75c3cab5401dd48b831c1405ff4589af84f30614 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:34:40 +0800 Subject: [PATCH 08/38] fix(kuna)!: separate verification policy from analysis identity (#8699) * feat(kuna): verify release 1.989 * fix(kuna)!: separate verification policy from analysis identity * docs(kuna): show historical evidence recovery * test(kuna): cover verification warnings through MCP --- THIRD_PARTY_NOTICES.md | 2 +- docs/kuna-provider.md | 20 ++++++++++++- docs/testing-lanes.md | 5 ++++ src/kuna/KunaProvider.ts | 1 - src/kuna/KunaRelease.ts | 2 +- src/kuna/KunaSessionClient.ts | 19 ++++++++++-- .../kuna/kunaBatchDecompileMcp.test.ts | 30 +++++++++++++++++++ .../providers/kuna/kunaDoctor.test.ts | 10 +++---- .../providers/kuna/kunaInstallation.test.ts | 8 ++--- .../providers/kuna/kunaProvider.test.ts | 28 ++++++++++++++--- 10 files changed, 106 insertions(+), 19 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index e58c4d94f26..61426ff708e 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -60,7 +60,7 @@ pin inspection sources; they are not included in the npm package. | ghidra-nativeaot | [MIT](https://github.com/Washi1337/ghidra-nativeaot/blob/effeb734fc570c32650f88b159608979dc7b423e/LICENSE.md) | Pinned inspection source; derived parser attribution above. | | JADX | [Apache-2.0](https://github.com/skylot/jadx/blob/v1.5.6/LICENSE) | The audited jadx-headless-mcp source uses JADX 1.5.6 for Android decompilation; the engine JAR is separately supplied. | | jadx-headless-mcp | [Apache-2.0](https://github.com/1013503897/jadx-headless-mcp/blob/5844800a486d2248fa949b74c9896285f5b54de2/LICENSE) | v0.7.1; pinned submodule and user-supplied JAR. | -| Kuna | [Apache-2.0](https://github.com/Noelo-Lab/kuna/blob/v1.988/LICENSE), with its [NOTICE](https://github.com/Noelo-Lab/kuna/blob/v1.988/NOTICE) | Bring your own `kuna`; REA verified 1.988 and never downloads, installs or bundles it. REA invokes it as a separate process. | +| Kuna | [Apache-2.0](https://github.com/Noelo-Lab/kuna/blob/v1.989/LICENSE), with its [NOTICE](https://github.com/Noelo-Lab/kuna/blob/v1.989/NOTICE) | Bring your own `kuna`; REA verified 1.989 and never downloads, installs or bundles it. REA invokes it as a separate process. | | Apktool | [Apache-2.0](https://github.com/iBotPeaches/Apktool/blob/master/LICENSE.md) | User-supplied Android resource decoder. | | Binwalk | [MIT](https://github.com/ReFirmLabs/binwalk/blob/4fdab3d464d97b68e0af9088df3f9e2e1545b21c/LICENSE) | v3.1.0; pinned submodule and user-supplied firmware engine. | | Unblob | [MIT](https://github.com/onekey-sec/unblob/blob/1fcc7a0a584a70a96c31f5a276c20944d199a089/LICENSE) | 26.6.4; pinned submodule and user-supplied firmware engine. | diff --git a/docs/kuna-provider.md b/docs/kuna-provider.md index c6266ce7186..fdbde7526da 100644 --- a/docs/kuna-provider.md +++ b/docs/kuna-provider.md @@ -1,9 +1,27 @@ # Kuna provider -[Kuna](https://github.com/Noelo-Lab/kuna) is an Apache-2.0 decompiler distributed as one `kuna` executable. For a new installation, use Kuna 1.988, REA's recommended verified release. REA uses a `kuna` you supply; it does not download, install, update or bundle Kuna. REA accepts other releases, which it reports as unverified in `rea doctor` and in each analysis profile. The verified-release flag compares the reported version; it does not authenticate the executable. The 1.988 real-provider checks ran on macOS arm64; Linux and other hosts have not been verified with that release. +[Kuna](https://github.com/Noelo-Lab/kuna) is an Apache-2.0 decompiler distributed as one `kuna` executable. For a new installation, use Kuna 1.989, REA's recommended verified release. REA uses a `kuna` you supply; it does not download, install, update or bundle Kuna. REA accepts other releases; `rea doctor` and analysis Evidence report them as unverified. Verification compares the reported version; it does not authenticate the executable. Analysis profiles retain the observed version and executable digest, independently of REA's current verified-release policy. The 1.989 real-provider checks ran on macOS arm64; Linux and other hosts have not been verified with that release. Kuna is static: it reads the selected image and never executes it. +This profile change makes earlier Kuna analysis snapshots and resumable directory +exports incompatible, even with the same Kuna executable. Keep the old files: +their recorded Evidence remains historical. To continue analysis, open the +binary without the old snapshot and save a new one, or choose a new export +directory. Existing snapshot Evidence can be extracted from its +`evidence_bundle` JSON member and imported as a separate Evidence bundle; that +does not restore cached workflow entries or resume the old directory export. +For example, using `jq` and a new output path: + +```bash +jq -e '.evidence_bundle | select(type == "object")' old-snapshot.json > history.json +rea evidence-import history.json --json +``` + +The CLI command validates the bundle in a one-shot session. To use the records +in an MCP session, call `import_evidence_bundle` with the extracted file path. +This validates the embedded bundle, not the rest of the snapshot container. + ## Configure ```bash diff --git a/docs/testing-lanes.md b/docs/testing-lanes.md index 4a8dcd65cfb..5e54caf8a28 100644 --- a/docs/testing-lanes.md +++ b/docs/testing-lanes.md @@ -451,6 +451,11 @@ Mach-O fixture and caller-built symbol-bearing and stripped x86-64 ELF fixtures. Linux and other hosts have not been verified with 1.988; this is not a repeat of the larger 1.833 matrix. +Kuna 1.989 passed the real-provider lane on macOS arm64 with the generated +symbol-bearing and stripped Mach-O fixtures, including cold and warm reads, +references, and cleanup. Linux, other hosts, and the larger 1.833 target matrix +were not verified with 1.989. + ## DOS Ghidra analysis `npm run verify:ghidra:dos` requires the supported Ghidra and JDK installation diff --git a/src/kuna/KunaProvider.ts b/src/kuna/KunaProvider.ts index 33960e3cc73..1b0b91981ec 100644 --- a/src/kuna/KunaProvider.ts +++ b/src/kuna/KunaProvider.ts @@ -265,7 +265,6 @@ export class KunaProvider implements AnalysisProviderCandidate { kuna_sha256: value.sha256, kuna_command: value.configuration.command, kuna_executable: value.executable, - verified_release: value.verified, jobs: value.configuration.jobs, export: "decompile-project", reference_graph: { diff --git a/src/kuna/KunaRelease.ts b/src/kuna/KunaRelease.ts index a467c488a2f..1d46a2aa30b 100644 --- a/src/kuna/KunaRelease.ts +++ b/src/kuna/KunaRelease.ts @@ -1,5 +1,5 @@ /** Kuna release whose CLI and project layout REA verified; others report as unverified. */ -export const KUNA_VERIFIED_VERSION = "1.988"; +export const KUNA_VERIFIED_VERSION = "1.989"; /** Stable provider ID shared by readiness, doctor and Evidence. */ export const KUNA_PROVIDER_ID = "kuna"; diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index d7123dd106f..f3640d91c9b 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -89,7 +89,7 @@ import { kunaLimitations, type KunaOperation, } from "./KunaProviderCapabilities.js"; -import { KUNA_PROVIDER_ID } from "./KunaRelease.js"; +import { KUNA_PROVIDER_ID, KUNA_VERIFIED_VERSION } from "./KunaRelease.js"; import { KunaTargetCommands } from "./KunaTargetCommands.js"; import { KunaReferenceQueries } from "./KunaReferenceQueries.js"; import { KunaQueryWork } from "./KunaQueryWork.js"; @@ -285,6 +285,16 @@ const exportUnavailable = ( ); }; +/** Version verification is observation context, not an analysis input. */ +const kunaVerificationLimitations = ( + installation: KunaInstallation, +): readonly string[] => + installation.verified + ? [] + : [ + `Kuna ${installation.version} is not REA's verified ${KUNA_VERIFIED_VERSION} release; its project layout is unverified. Version matching does not authenticate the executable.`, + ]; + /** Kuna names a procedure by its inventory entry or by an exact entry address. */ const procedureSelectorSchema = z.string().min(1); @@ -652,7 +662,11 @@ export class KunaSessionClient implements AnalysisClient { : { strings: answer.stringSource }), }, }), - limitations: [...operationLimitations, ...answer.limitations], + limitations: [ + ...operationLimitations, + ...kunaVerificationLimitations(installation), + ...answer.limitations, + ], ...(answer.relationshipCoverage === undefined ? {} : { relationshipCoverage: answer.relationshipCoverage }), @@ -1469,6 +1483,7 @@ export class KunaSessionClient implements AnalysisClient { this.#deadline, this.#heapRequest, ), + ...kunaVerificationLimitations(installation), ...pseudocode.value.limitations, KUNA_RELATIONSHIP_LIMITATION, ...(relationships.ok diff --git a/tests/boundary/providers/kuna/kunaBatchDecompileMcp.test.ts b/tests/boundary/providers/kuna/kunaBatchDecompileMcp.test.ts index 4e8f191fafd..59365f847ea 100644 --- a/tests/boundary/providers/kuna/kunaBatchDecompileMcp.test.ts +++ b/tests/boundary/providers/kuna/kunaBatchDecompileMcp.test.ts @@ -89,6 +89,36 @@ test("keeps each Kuna no-pseudocode observation's limitations on its batch item" }) ).structuredContent, ); + // The provider's version policy travels with this observation, while its + // profile commits only analysis inputs. Composed and retained views must + // preserve the same warning for agents reading those results. + const verificationWarning = + "Kuna 1.833 is not REA's verified 1.989 release"; + expect(batch.limitations).toContainEqual( + expect.stringContaining(verificationWarning), + ); + expect(broken.analysis_profile?.parameters).not.toHaveProperty( + "verified_release", + ); + const summary = parseEvidence( + (await call("analyze_function", { procedure: "main", detail: "summary" })) + .structuredContent, + ); + const parent = summary.normalized_result as { parent_evidence_id: string }; + const page = parseEvidence( + ( + await call("inspect_analysis_view", { + source: { + kind: "retained-evidence", + evidence_id: parent.parent_evidence_id, + }, + view: { kind: "native", facet: "pseudocode" }, + }) + ).structuredContent, + ); + expect(page.limitations).toContainEqual( + expect.stringContaining(verificationWarning), + ); if (batchDecompile === undefined || getEvidenceBundle === undefined) throw new Error( "Missing batch_decompile or get_evidence_bundle contract", diff --git a/tests/boundary/providers/kuna/kunaDoctor.test.ts b/tests/boundary/providers/kuna/kunaDoctor.test.ts index 8c3a5f052a1..6de81dfebaa 100644 --- a/tests/boundary/providers/kuna/kunaDoctor.test.ts +++ b/tests/boundary/providers/kuna/kunaDoctor.test.ts @@ -34,7 +34,7 @@ it("reports an unconfigured Kuna as a missing optional engine", async () => { it("publishes the caller's explicit settings once the installation validates", async () => { const root = await createTestTempDirectory("rea-kuna-doctor-"); const kuna = await writeFakeKuna(root, { - version: { stdout: "kuna 1.988\n" }, + version: { stdout: "kuna 1.989\n" }, }); const cache = join(root, "cache"); @@ -48,7 +48,7 @@ it("publishes the caller's explicit settings once the installation validates", a expect(inspection).toMatchObject({ configured: true, available: true, - providerVersion: "1.988", + providerVersion: "1.989", registrationEnvironment: { REA_KUNA_COMMAND: kuna, REA_KUNA_CACHE_DIR: cache, @@ -59,10 +59,10 @@ it("publishes the caller's explicit settings once the installation validates", a name: "installation", ok: true, detail: expect.stringMatching( - /reports Kuna 1\.988 \(sha256 [a-f0-9]{64}\)\.$/u, + /reports Kuna 1\.989 \(sha256 [a-f0-9]{64}\)\.$/u, ), remediation: null, - details: { version: "1.988", verified_version: "1.988" }, + details: { version: "1.989", verified_version: "1.989" }, }, { name: "cache", @@ -88,7 +88,7 @@ it("flags an unverified release without failing the installation", async () => { "You may continue with the selected unverified release.", ), remediation: expect.stringContaining("setting REA_KUNA_COMMAND"), - details: { verified_version: "1.988" }, + details: { verified_version: "1.989" }, }); }); diff --git a/tests/boundary/providers/kuna/kunaInstallation.test.ts b/tests/boundary/providers/kuna/kunaInstallation.test.ts index 1f662539933..6082bf50632 100644 --- a/tests/boundary/providers/kuna/kunaInstallation.test.ts +++ b/tests/boundary/providers/kuna/kunaInstallation.test.ts @@ -22,7 +22,7 @@ const it = test.skipIf(process.platform === "win32"); it("identifies a caller-selected kuna by version and digest", async () => { const root = await createTestTempDirectory("rea-kuna-install-"); const kuna = await writeFakeKuna(root, { - version: { stdout: "kuna 1.988\n" }, + version: { stdout: "kuna 1.989\n" }, }); const link = join(root, "current-kuna"); await symlink(kuna, link); @@ -37,7 +37,7 @@ it("identifies a caller-selected kuna by version and digest", async () => { value: { configuration: { command: link, cacheRoot: join(root, "cache") }, executable: await realpath(kuna), - version: "1.988", + version: "1.989", verified: true, // A `#!` launcher reports its interpreter; ownership checks must not expect the path. script: true, @@ -104,14 +104,14 @@ fs.renameSync(process.argv[1] + ".new", process.argv[1]); it("accepts another release but reports it as unverified", async () => { const root = await createTestTempDirectory("rea-kuna-unverified-"); const kuna = await writeFakeKuna(root, { - version: { stdout: "kuna 1.986\n" }, + version: { stdout: "kuna 1.988\n" }, }); const result = await inspectKunaInstallation({ REA_KUNA_COMMAND: kuna }); expect(result).toMatchObject({ ok: true, - value: { version: "1.986", verified: false }, + value: { version: "1.988", verified: false }, }); }); diff --git a/tests/boundary/providers/kuna/kunaProvider.test.ts b/tests/boundary/providers/kuna/kunaProvider.test.ts index 89076787185..2b94145d658 100644 --- a/tests/boundary/providers/kuna/kunaProvider.test.ts +++ b/tests/boundary/providers/kuna/kunaProvider.test.ts @@ -182,22 +182,42 @@ it("waits for an aborted pre-session hash before retrying its late close owner", it("commits the observed Kuna release and digest to the analysis profile", async () => { const { profile, kuna } = await session("rea-kuna-profile-", { - version: { stdout: "kuna 1.988\n" }, + version: { stdout: "kuna 1.989\n" }, }); expect(profile).toMatchObject({ - provider: { id: "kuna", name: "Kuna", version: "1.988" }, + provider: { id: "kuna", name: "Kuna", version: "1.989" }, parameters: { - kuna_version: "1.988", + kuna_version: "1.989", kuna_sha256: expect.stringMatching(/^[0-9a-f]{64}$/u), kuna_command: kuna, - verified_release: true, jobs: 1, cache_policy: "live", }, }); }); +it("reports an unverified release on observations without binding policy into the profile", async () => { + const { profile, open } = await session("rea-kuna-policy-", { + version: { stdout: "kuna 1.988\n" }, + }); + expect(profile.parameters).not.toHaveProperty("verified_release"); + expect(profile.parameters).toMatchObject({ + kuna_version: "1.988", + kuna_sha256: expect.stringMatching(/^[0-9a-f]{64}$/u), + }); + const client = open(); + const inventory = await run(client, "list_procedures"); + const warning = "Kuna 1.988 is not REA's verified 1.989 release"; + expect(inventory.limitations.join("\n")).toContain(warning); + const dossier = await run(client, "analyze_function", { procedure: "main" }); + expect(dossier.limitations.join("\n")).toContain(warning); + expect( + (dossier.result as { limitations: string[] }).limitations.join("\n"), + ).toContain(warning); + await client.close(); +}); + it("lists the selected document without starting target analysis", async () => { const { kuna, target, cacheRoot, open } = await session( "rea-kuna-document-metadata-", From 55078b9f575528e451c888d8b7a85da2f188bbb7 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:13:21 +0800 Subject: [PATCH 09/38] feat(mcp): page retained incoming reference addresses (#8591) --- docs/mcp-contracts.md | 6 ++ .../NativeObservationViews.test.ts | 97 ++++++++++--------- src/contracts/officialToolContracts.ts | 2 +- src/domain/analysisView/analysisViewResult.ts | 3 + .../analysisView/nativeObservationSchemas.ts | 1 + .../analysisView/nativeObservationView.ts | 4 +- src/domain/native/nativeObservation.ts | 2 + .../mcp/nativeObservationViewsMcp.test.ts | 32 +++++- 8 files changed, 97 insertions(+), 50 deletions(-) diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index a08d611f80f..6ac4ac44115 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -292,6 +292,12 @@ limitation and reason IDs index that page's `items.messages` dictionary. Expand those references to recover exact text, repeated messages, and absent versus empty optional fields. Failed nodes retain their original error. +Retained `xrefs` Evidence also supports `native-observation` address pages and +`native-context` source caveats. `xrefs` still returns complete Evidence by +default and does not accept `detail`; when its MCP response exceeds the budget, +follow the returned continuation to page the retained result. These addresses +remain untyped; paging does not add reference kinds or establish completeness. + These views reference complete source context through `source_context.digest` and caveat counts. Read it using the same parent with `view: {"kind":"native-context","facet":"overview"}`. The other facets are diff --git a/src/application/analysisView/NativeObservationViews.test.ts b/src/application/analysisView/NativeObservationViews.test.ts index c3e2de8d8de..be9092a4e07 100644 --- a/src/application/analysisView/NativeObservationViews.test.ts +++ b/src/application/analysisView/NativeObservationViews.test.ts @@ -121,53 +121,56 @@ const graph = (): NativeCallGraph => limitations: ["Graph caveat", "Graph caveat"], }); -it("returns useful caller/callee facts with exact source context identity and partial coverage", async () => { - const source = parent("procedure_callees", [ - "ram:0x0010", - "ram:0x0010", - "ram:0x0020", - ]); - const before = JSON.stringify(source); - const summary = await project(source, { kind: "summary" }); - expect(summary.value).toMatchObject({ - kind: "summary", - parent_evidence_id: source.evidence_id, - source_provider: source.provider, - source_parameters: source.parameters, - source_analysis_profile_digest: source.analysis_profile?.digest, - source_context: { - evidence_limitation_count: 1, - result_limitation_count: null, - }, - summary: { observations: 3 }, - items: { - type: "addresses", - rows: [ - { source_index: 0, address: "ram:0x0010" }, - { source_index: 1, address: "ram:0x0010" }, - { source_index: 2, address: "ram:0x0020" }, - ], - }, - }); - expect(summary.evidence.relationship_coverage).toEqual( - source.relationship_coverage, - ); - const page = await project(source, { - kind: "native-observation", - offset: 1, - limit: 1, - }); - expect(page.value).toMatchObject({ - items: { rows: [{ source_index: 1, address: "ram:0x0010" }] }, - coverage: { total: 3, next_offset: 2, exhausted: false }, - }); - expect(page.value).toHaveProperty( - "source_context.digest", - contextDigest(summary.value), - ); - expect(JSON.stringify(summary.value)).not.toContain('"analysis_profile":'); - expect(JSON.stringify(source)).toBe(before); -}); +it.each(["procedure_callees", "xrefs"])( + "pages %s addresses with exact source context and duplicate identity", + async (operation) => { + const source = parent(operation, [ + "ram:0x0010", + "ram:0x0010", + "ram:0x0020", + ]); + const before = JSON.stringify(source); + const summary = await project(source, { kind: "summary" }); + expect(summary.value).toMatchObject({ + kind: "summary", + parent_evidence_id: source.evidence_id, + source_provider: source.provider, + source_parameters: source.parameters, + source_analysis_profile_digest: source.analysis_profile?.digest, + source_context: { + evidence_limitation_count: 1, + result_limitation_count: null, + }, + summary: { observations: 3 }, + items: { + type: "addresses", + rows: [ + { source_index: 0, address: "ram:0x0010" }, + { source_index: 1, address: "ram:0x0010" }, + { source_index: 2, address: "ram:0x0020" }, + ], + }, + }); + expect(summary.evidence.relationship_coverage).toEqual( + source.relationship_coverage, + ); + const page = await project(source, { + kind: "native-observation", + offset: 1, + limit: 1, + }); + expect(page.value).toMatchObject({ + items: { rows: [{ source_index: 1, address: "ram:0x0010" }] }, + coverage: { total: 3, next_offset: 2, exhausted: false }, + }); + expect(page.value).toHaveProperty( + "source_context.digest", + contextDigest(summary.value), + ); + expect(JSON.stringify(summary.value)).not.toContain('"analysis_profile":'); + expect(JSON.stringify(source)).toBe(before); + }, +); it("pages complete context fields and changes its digest when source profile or caveats change", async () => { const source = parent("procedure_callers", []); diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index 9d1ab1a8995..45a42da0250 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -260,7 +260,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "xrefs", - "Return analyzed references to a code or data address. Hopper may default to its current cursor; headless providers such as Ghidra require an explicit address. Use to connect strings, globals, selectors, and functions; bare addresses are untyped and indirect references may be incomplete. Use inspect_native_instruction at the target address for typed incoming references where supported.", + "Return analyzed references to a code or data address. Hopper may default to its current cursor; headless providers such as Ghidra require an explicit address. Use to connect strings, globals, selectors, and functions; bare addresses are untyped and indirect references may be incomplete. Page a retained result with inspect_analysis_view and native-observation; use native-context for source caveats. Use inspect_native_instruction at the target address for typed incoming references where supported.", z.object({ document, address: optionalAddress }), ), ] as const satisfies readonly ToolContract[]; diff --git a/src/domain/analysisView/analysisViewResult.ts b/src/domain/analysisView/analysisViewResult.ts index 98328d9b2c8..2b5e9d3cd3b 100644 --- a/src/domain/analysisView/analysisViewResult.ts +++ b/src/domain/analysisView/analysisViewResult.ts @@ -382,6 +382,7 @@ export const nativeObservationSummaryViewResultSchemas = { procedure_address: nativeObservationSummaryFor("procedure_address"), procedure_callees: nativeObservationSummaryFor("procedure_callees"), procedure_callers: nativeObservationSummaryFor("procedure_callers"), + xrefs: nativeObservationSummaryFor("xrefs"), resolve_containing_procedure: nativeObservationSummaryFor( "resolve_containing_procedure", ), @@ -393,6 +394,7 @@ const nativeObservationSummaryViewResultSchema = z.discriminatedUnion( nativeObservationSummaryViewResultSchemas.procedure_address, nativeObservationSummaryViewResultSchemas.procedure_callees, nativeObservationSummaryViewResultSchemas.procedure_callers, + nativeObservationSummaryViewResultSchemas.xrefs, nativeObservationSummaryViewResultSchemas.resolve_containing_procedure, nativeObservationSummaryViewResultSchemas.get_call_graph, ], @@ -412,6 +414,7 @@ const nativeObservationPageViewResultSchema = z.discriminatedUnion( nativeObservationPageFor("procedure_address"), nativeObservationPageFor("procedure_callees"), nativeObservationPageFor("procedure_callers"), + nativeObservationPageFor("xrefs"), nativeObservationPageFor("resolve_containing_procedure"), nativeObservationPageFor("get_call_graph"), ], diff --git a/src/domain/analysisView/nativeObservationSchemas.ts b/src/domain/analysisView/nativeObservationSchemas.ts index 50f42c6c996..bd526d2379f 100644 --- a/src/domain/analysisView/nativeObservationSchemas.ts +++ b/src/domain/analysisView/nativeObservationSchemas.ts @@ -158,6 +158,7 @@ export const nativeObservationItemsSchemas = { procedure_address: addresses, procedure_callees: addresses, procedure_callers: addresses, + xrefs: addresses, resolve_containing_procedure: resolutions, get_call_graph: nativeGraphViewItemsSchema, }; diff --git a/src/domain/analysisView/nativeObservationView.ts b/src/domain/analysisView/nativeObservationView.ts index a532242c2d4..48ef919da29 100644 --- a/src/domain/analysisView/nativeObservationView.ts +++ b/src/domain/analysisView/nativeObservationView.ts @@ -45,7 +45,8 @@ const readSource = ( ): Result => { switch (parent.operation) { case "procedure_callees": - case "procedure_callers": { + case "procedure_callers": + case "xrefs": { const read = readAddresses(parent); return read.ok ? ok({ operation: parent.operation, result: read.value }) @@ -138,6 +139,7 @@ const observationRows = ( }; case "procedure_callees": case "procedure_callers": + case "xrefs": return { parent_operation: source.operation, total: source.result.length, diff --git a/src/domain/native/nativeObservation.ts b/src/domain/native/nativeObservation.ts index e09d51e31f0..3844302437d 100644 --- a/src/domain/native/nativeObservation.ts +++ b/src/domain/native/nativeObservation.ts @@ -66,6 +66,7 @@ export const NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS = [ export const NATIVE_OBSERVATION_OPERATIONS = [ ...NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS, "get_call_graph", + "xrefs", ] as const; export type NativeObservationOperation = (typeof NATIVE_OBSERVATION_OPERATIONS)[number]; @@ -74,6 +75,7 @@ export const nativeObservationSourceSchemas = { procedure_address: z.string(), procedure_callees: nativeAddressListSchema, procedure_callers: nativeAddressListSchema, + xrefs: nativeAddressListSchema, resolve_containing_procedure: containingProcedureResolutionSchema, get_call_graph: nativeCallGraphSchema, }; diff --git a/tests/boundary/mcp/nativeObservationViewsMcp.test.ts b/tests/boundary/mcp/nativeObservationViewsMcp.test.ts index c5a12668651..e4222343d9e 100644 --- a/tests/boundary/mcp/nativeObservationViewsMcp.test.ts +++ b/tests/boundary/mcp/nativeObservationViewsMcp.test.ts @@ -119,6 +119,33 @@ it("delivers native primitive and graph summaries with exact retained parents an ); expect(rejected.isError).toBe(true); } + const references = await call("xrefs", { address: "0x401000" }); + const source = { + kind: "retained-evidence", + evidence_id: references.evidence_id, + }; + const before = harness.calls.length; + const page = await call("inspect_analysis_view", { + source, + view: { kind: "native-observation", offset: 0, limit: 1 }, + }); + expect(page.normalized_result).toMatchObject({ + parent_evidence_id: references.evidence_id, + parent_operation: "xrefs", + items: { + type: "addresses", + rows: [{ source_index: 0, address: "0x401001" }], + }, + coverage: { total: 1, exhausted: true }, + }); + const caveats = await call("inspect_analysis_view", { + source, + view: { kind: "native-context", facet: "limitations" }, + }); + expect(caveats.normalized_result).toMatchObject({ + parent_operation: "xrefs", + }); + expect(harness.calls).toHaveLength(before); } finally { await harness.close(); } @@ -177,9 +204,12 @@ it("follows native overflow continuations through MCP without repeating provider for (const name of [ ...NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS, "get_call_graph", + "xrefs", ] as const) { const input = - name === "get_call_graph" || name === "resolve_containing_procedure" + name === "get_call_graph" || + name === "resolve_containing_procedure" || + name === "xrefs" ? { address: "0x1000" } : { procedure: "fixture_main" }; const complete = await client.callTool({ name, arguments: input }); From 920d634e835ede760831bee51baabb054924f041 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:18:55 +0800 Subject: [PATCH 10/38] ci: exempt designated authors from readiness declaration --- .github/pull_request_template.md | 3 +- AGENTS.md | 4 +- CONTRIBUTING.md | 12 +++-- scripts/ci/pr-readiness.d.mts | 3 +- scripts/ci/pr-readiness.mjs | 14 +++++ tests/boundary/filesystem/prReadiness.test.ts | 54 +++++++++++++++++++ 6 files changed, 82 insertions(+), 8 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 91063384cc2..5420b790ad6 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -106,7 +106,8 @@ limits, security/privacy/containment implications, and meaningful proof gaps. -- ## Author readiness - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 55221d1261a..744cb9361f7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,7 +93,8 @@ Before requesting maintainer review, the human owner must: passing. Follow the [verification requirements](#development-feedback-and-pr-verification) for required checks and real-provider coverage. - Complete the **Author readiness** declaration in the PR template, explicitly - stating that the PR is ready for initial review. + stating that the PR is ready for initial review, unless the PR author is + exempt from the declaration as listed below. Keep the PR description concise and current. Explain the problem, observable outcome, approach, and useful evidence; link long logs or put them in optional @@ -114,9 +115,12 @@ validation current as work changes. AI agents must leave human declarations to the human owner and must not attest to a human review on their behalf. The `PR readiness` check reports pending for drafts and requires a named human -owner and all three template declarations for non-drafts. It checks that the -declaration is filled, not whether the code or evidence is correct. Existing PRs -can copy the **Author readiness** section from the current template. +owner and all three template declarations for non-drafts. PRs authored by +@morluto, @kaoru0822-kitauji, @N0zoM1z0, or @gensokyo-dev are exempt from +the declaration; their human ownership, review, and evidence responsibilities +above still apply. The check identifies authors by GitHub account ID. It checks +that the declaration is filled, not whether the code or evidence is correct. +Existing PRs can copy the **Author readiness** section from the current template. Open PRs must have distinct head commits because GitHub shares commit statuses. Maintainers enable merge enforcement after the workflow is merged: run diff --git a/scripts/ci/pr-readiness.d.mts b/scripts/ci/pr-readiness.d.mts index c42684eb1c1..7997e9cfdd3 100644 --- a/scripts/ci/pr-readiness.d.mts +++ b/scripts/ci/pr-readiness.d.mts @@ -3,6 +3,7 @@ export interface ReadinessPullRequest { readonly state: "open" | "closed"; readonly draft: boolean; readonly body: string | null; + readonly user: { readonly id: number } | null; readonly head: { readonly sha: string }; readonly base: { readonly ref: string }; } @@ -53,7 +54,7 @@ export interface ReadinessGitHub { >; } export function evaluateReadiness( - pull: Pick, + pull: Pick, template: string, ): ReadinessResult; export function runReadinessCheck(input: { diff --git a/scripts/ci/pr-readiness.mjs b/scripts/ci/pr-readiness.mjs index 3f534381561..1043af92ecc 100644 --- a/scripts/ci/pr-readiness.mjs +++ b/scripts/ci/pr-readiness.mjs @@ -2,6 +2,14 @@ import { readFile } from "node:fs/promises"; const result = (state, description) => ({ state, description }); +// GitHub account IDs remain stable when a login changes. +const EXEMPT_AUTHOR_IDS = new Set([ + 76467478, // morluto + 208281322, // kaoru0822-kitauji + 161784452, // N0zoM1z0 + 342580529, // gensokyo-dev +]); + const OPEN_HEADS_QUERY = `query ReadinessOpenHeads($owner: String!, $name: String!, $after: String) { repository(owner: $owner, name: $name) { pullRequests(first: 100, after: $after, states: OPEN, orderBy: {field: CREATED_AT, direction: ASC}) { @@ -105,6 +113,11 @@ export function evaluateReadiness(pull, template) { "pending", "Draft PR: complete Author readiness before requesting review.", ); + if (EXEMPT_AUTHOR_IDS.has(pull.user?.id)) + return result( + "success", + "PR author is exempt from the readiness declaration.", + ); const expected = readinessLines(template).flatMap((line) => { const match = /^- \[ \] (.+)$/u.exec(line); return match ? [match[1]] : []; @@ -210,6 +223,7 @@ export async function runReadinessCheck({ github, context, core }) { latest.head.sha !== pull.head.sha || latest.body !== pull.body || latest.draft !== pull.draft || + latest.user?.id !== pull.user?.id || latest.base.ref !== pull.base.ref ) verdict = result( diff --git a/tests/boundary/filesystem/prReadiness.test.ts b/tests/boundary/filesystem/prReadiness.test.ts index 4d3e7273e24..8e136c9da49 100644 --- a/tests/boundary/filesystem/prReadiness.test.ts +++ b/tests/boundary/filesystem/prReadiness.test.ts @@ -30,6 +30,7 @@ const makePull = ( state: "open", draft: false, body: readyBody, + user: { id: 123456 }, head: { sha: "a".repeat(40) }, base: { ref: "main" }, ...changes, @@ -94,6 +95,30 @@ function fixture(changes: Partial = {}) { } describe("initial author declaration", () => { + it.each([76467478, 208281322, 161784452, 342580529])( + "exempts the configured author ID %s, but keeps drafts pending", + (id) => { + expect( + evaluateReadiness(makePull({ user: { id }, body: null }), template), + ).toMatchObject({ + state: "success", + description: expect.stringContaining("exempt"), + }); + expect( + evaluateReadiness( + makePull({ user: { id }, body: null, draft: true }), + template, + ).state, + ).toBe("pending"); + }, + ); + + it("does not exempt unknown or missing author identities", () => { + for (const user of [{ id: 123456 }, null]) + expect( + evaluateReadiness(makePull({ user, body: null }), template).state, + ).toBe("failure"); + }); it("accepts the filled current template and uppercase checkmarks", () => { expect(evaluateReadiness(makePull(), template).state).toBe("success"); expect( @@ -212,6 +237,35 @@ describe("initial author declaration", () => { }); describe("GitHub status boundary", () => { + it.each([76467478, 208281322, 161784452, 342580529])( + "publishes a labeled exemption for author ID %s after checking the head", + async (id) => { + const input = fixture({ user: { id }, body: null }); + expect(await runReadinessCheck(input)).toMatchObject({ + state: "success", + description: expect.stringContaining("exempt"), + }); + expect(input.paginate).toHaveBeenCalledOnce(); + expect(input.createCommitStatus).toHaveBeenLastCalledWith( + expect.objectContaining({ state: "success", sha: input.pull.head.sha }), + ); + }, + ); + it("does not let an exempt author lend success to another PR sharing the head", async () => { + const input = fixture({ user: { id: 76467478 }, body: null }); + input.paginate.mockResolvedValue([input.pull, makePull({ number: 43 })]); + expect((await runReadinessCheck(input))?.state).toBe("failure"); + }); + + it("does not publish an exemption if the author changes during the check", async () => { + const input = fixture({ user: { id: 76467478 }, body: null }); + input.get + .mockResolvedValueOnce({ data: input.pull }) + .mockResolvedValueOnce({ + data: makePull({ user: { id: 123456 }, body: null }), + }); + expect((await runReadinessCheck(input))?.state).toBe("pending"); + }); it("publishes failure on the actual PR head when declarations are unchecked", async () => { const input = fixture({ body: readyBody.replaceAll("[x]", "[ ]") }); expect((await runReadinessCheck(input))?.state).toBe("failure"); From e749160ab0e79b1d7555cd893e20ba574a307c8a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 3 Nov 2026 03:29:59 +0800 Subject: [PATCH 11/38] fix(mcp): qualify string byte-read guidance (#8704) * fix(mcp): qualify string byte-read guidance * fix(mcp): avoid implying complete string references --- src/contracts/officialToolContracts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index 45a42da0250..16ba5d0b667 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -224,7 +224,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "search_strings", - `Search every provider-reported string object or candidate using literal matching by default or regex when requested. Results are deterministic and complete over that provider inventory, not every text sequence in artifact bytes. Kuna scans mapped sequences of at least five characters, including unterminated fragments, without computing references. Ghidra may omit packed or undefined literals, including Rust strings; an empty result does not prove text is absent. If an address is known, read_bytes and xrefs inspect its bytes and references. Hopper retains typed byte decoding, native display, and explicit decoding unknowns. Adjacent long-literal fragments match independently. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`, + `Search every provider-reported string object or candidate using literal matching by default or regex when requested. Results are deterministic and complete over that provider inventory, not every text sequence in artifact bytes. Kuna scans mapped sequences of at least five characters, including unterminated fragments, without computing references. Ghidra may omit packed or undefined literals, including Rust strings; an empty result does not prove text is absent. If an address is known, xrefs can look for references; read_bytes can inspect bytes where supported (not available with Kuna). Hopper retains typed byte decoding, native display, and explicit decoding unknowns. Adjacent long-literal fragments match independently. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`, z.object({ ...nativeSearchInput, detail: analysisDetailSchema }), { evidenceParameters: "omit-detail" }, ), From 9bea563e30fd255d639d6877a91109ba9ff2eb00 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 3 Nov 2026 03:30:19 +0800 Subject: [PATCH 12/38] fix(kuna): qualify parallel project pseudocode (#8694) * fix(kuna): qualify parallel project pseudocode * test(kuna): cover parallel caveats through MCP --- docs/kuna-provider.md | 4 + src/kuna/KunaProjectReader.ts | 7 +- .../providers/kuna/kunaParallelMcp.test.ts | 83 +++++++++++++++++++ .../providers/kuna/kunaProvider.test.ts | 33 ++++++++ 4 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 tests/boundary/providers/kuna/kunaParallelMcp.test.ts diff --git a/docs/kuna-provider.md b/docs/kuna-provider.md index fdbde7526da..cf5199206c2 100644 --- a/docs/kuna-provider.md +++ b/docs/kuna-provider.md @@ -48,6 +48,10 @@ completes. Cleanup retries keep both the observations and the exact file owner. | `REA_KUNA_TIMEOUT_MS` | 120 s | Deadline for one single-function decompile, and for the inventory run REA makes when the export ends without one. | | `REA_KUNA_JOBS` | 1 | `decompile-project --jobs`. 1 is Kuna's reference answer; more jobs finish sooner but lose callee-first prototypes, and change about 40–60% of function bodies on the measured binaries. | +Pseudocode read from a parallel project export includes a prototype-context +limitation in its Evidence, including in function dossiers and composed +workflows. Early single-function results have their own source limitation. + A single-function decompile, inventory, string, graph or xref run that exceeds its deadline fails as `provider_timeout`, naming the setting that owns the deadline and that raising it takes a restart. A project export that exceeds `REA_KUNA_PROJECT_TIMEOUT_MS` is an export failure instead: requests that need the export report `capability_unavailable` with the export's own retry guidance. Windows is not supported yet. Supported targets are ELF and thin Mach-O executables for x86-64, AArch64 and ARM; PE and universal Mach-O are rejected with a typed reason. diff --git a/src/kuna/KunaProjectReader.ts b/src/kuna/KunaProjectReader.ts index fb86a7ef9eb..f9df8dc7dc3 100644 --- a/src/kuna/KunaProjectReader.ts +++ b/src/kuna/KunaProjectReader.ts @@ -350,7 +350,12 @@ export class KunaProjectReader { .slice(text.value.indexOf("\n") + 1) .replace(/\n+$/u, ""), files, - limitations: [], + limitations: + this.project.manifest.inputs.jobs > 1 + ? [ + "Parallel Kuna project export does not propagate callee-first prototypes between workers; function signatures and call arguments may differ from a serial export.", + ] + : [], }); } diff --git a/tests/boundary/providers/kuna/kunaParallelMcp.test.ts b/tests/boundary/providers/kuna/kunaParallelMcp.test.ts new file mode 100644 index 00000000000..a4133e93a9f --- /dev/null +++ b/tests/boundary/providers/kuna/kunaParallelMcp.test.ts @@ -0,0 +1,83 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { AnalysisProviderRegistry } from "../../../../src/application/binary/AnalysisProviderRegistry.js"; +import { composeBinarySession } from "../../../../src/application/binary/BinarySessionComposition.js"; +import { parseEvidence } from "../../../../src/domain/evidence.js"; +import { KunaProvider } from "../../../../src/kuna/KunaProvider.js"; +import { elfNoteFixture } from "../../../../src/native/elf/ElfNotes.fixture.js"; +import { createServer } from "../../../../src/server/createServer.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { mcpTest } from "../../../support/mcp/mcpFixture.js"; +import { writeFakeKuna } from "./fakeKuna.js"; + +const test = mcpTest.skipIf(process.platform === "win32"); +const caveat = + "Parallel Kuna project export does not propagate callee-first prototypes"; + +for (const jobs of [1, 2]) { + test(`retains source limitations in batch and paged dossier with ${String(jobs)} worker(s)`, async ({ + mcp, + }) => { + const root = await createTestTempDirectory("rea-kuna-parallel-mcp-"); + const kuna = await writeFakeKuna(root, { + functions: [{ name: "main", addr: "0x1000" }], + }); + const target = join(root, "program"); + await writeFile(target, elfNoteFixture({ bits: 64, type: 2 })); + const session = composeBinarySession( + new AnalysisProviderRegistry( + [ + new KunaProvider( + { + REA_KUNA_COMMAND: kuna, + REA_KUNA_CACHE_DIR: join(root, "cache"), + REA_KUNA_JOBS: String(jobs), + }, + { platform: process.platform }, + ), + ], + "kuna", + ), + ); + const client = await mcp.connect( + createServer({ kind: "session", session }), + ); + const call = async (name: string, arguments_: Record) => { + const response = await client.callTool({ name, arguments: arguments_ }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + return response.structuredContent; + }; + await call("open_binary", { path: target }); + // Assembly waits for the complete project, avoiding timing-based source selection. + await call("procedure_assembly", { procedure: "main" }); + const batch = parseEvidence( + await call("batch_decompile", { + addresses: ["0x1000"], + detail: "complete", + }), + ); + expect(batch.limitations.some((item) => item.includes(caveat))).toBe( + jobs > 1, + ); + const dossier = parseEvidence( + await call("analyze_function", { procedure: "main", detail: "summary" }), + ); + const summary = dossier.normalized_result as { parent_evidence_id: string }; + const view = parseEvidence( + await call("inspect_analysis_view", { + source: { + kind: "retained-evidence", + evidence_id: summary.parent_evidence_id, + }, + view: { kind: "native", facet: "pseudocode" }, + }), + ); + expect(view.limitations.some((item) => item.includes(caveat))).toBe( + jobs > 1, + ); + await call("close_binary", {}); + }); +} diff --git a/tests/boundary/providers/kuna/kunaProvider.test.ts b/tests/boundary/providers/kuna/kunaProvider.test.ts index 2b94145d658..6ab5eb1d815 100644 --- a/tests/boundary/providers/kuna/kunaProvider.test.ts +++ b/tests/boundary/providers/kuna/kunaProvider.test.ts @@ -235,6 +235,39 @@ it("lists the selected document without starting target analysis", async () => { } }); +it("qualifies only parallel project pseudocode, including dossier results", async () => { + const { open } = await session( + "rea-kuna-parallel-context-", + { delayMs: 300 }, + { REA_KUNA_JOBS: "2" }, + ); + const client = open(); + const caveat = + "Parallel Kuna project export does not propagate callee-first prototypes"; + const cold = await run(client, "procedure_pseudo_code", { + procedure: "main", + }); + expect(cold.rawResult).toMatchObject({ source: { kind: "single-function" } }); + expect(cold.limitations.join("\n")).not.toContain(caveat); + await run(client, "procedure_assembly", { procedure: "main" }); + const warm = await run(client, "procedure_pseudo_code", { + procedure: "main", + }); + expect(warm.rawResult).toMatchObject({ source: { kind: "project-export" } }); + expect(warm.limitations.join("\n")).toContain(caveat); + const dossier = await run(client, "analyze_function", { procedure: "main" }); + expect(dossier.limitations.join("\n")).toContain(caveat); + expect( + (dossier.result as { limitations: string[] }).limitations.join("\n"), + ).toContain(caveat); + const missing = await run(client, "procedure_pseudo_code", { + procedure: "broken", + }); + expect(missing.result).toBeNull(); + expect(missing.limitations.join("\n")).not.toContain(caveat); + await client.close(); +}); + it("answers a cold request from one function, then serves the finished export", async () => { const { kuna, cacheRoot, open } = await session("rea-kuna-cold-", { delayMs: 300, From 2d11fd92d233f65dd0f3904583e09078a5a3342c Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Tue, 3 Nov 2026 08:54:39 +0800 Subject: [PATCH 13/38] fix(javascript): reconcile Node targets with entry assets (#8670) * fix(javascript): reconcile Node targets with entry assets * test(inspector): read reconciled evidence payload * Update tests/boundary/inspector/javascriptRuntimeNodeTarget.test.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * test(inspector): read the workflow evidence payload * Update tests/boundary/inspector/javascriptRuntimeNodeTarget.test.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * test(javascript): restore Evidence workflow result access --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- docs/javascript-runtime-reconciliation.md | 4 + .../javascript/javascriptRuntimeLoadState.ts | 5 +- .../javascriptRuntimeReconciliationRuntime.ts | 5 +- ...ascriptNodeTargetReconciliationCli.test.ts | 52 +++++++++++++ .../javascriptRuntimeNodeTarget.test.ts | 37 ++++++++++ tests/fixtures/nodeRuntimeReconciliation.ts | 73 +++++++++++++++++++ 6 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 tests/acceptance/applications/javascriptNodeTargetReconciliationCli.test.ts create mode 100644 tests/boundary/inspector/javascriptRuntimeNodeTarget.test.ts create mode 100644 tests/fixtures/nodeRuntimeReconciliation.ts diff --git a/docs/javascript-runtime-reconciliation.md b/docs/javascript-runtime-reconciliation.md index 85bfbff428e..16b8fdc1e05 100644 --- a/docs/javascript-runtime-reconciliation.md +++ b/docs/javascript-runtime-reconciliation.md @@ -93,6 +93,10 @@ coverage. It does not print captured source text. Runtime targets, frames, scripts, and workers become capture-scoped graph nodes. REA evaluates compatible static candidates conservatively: +V8 Inspector targets whose protocol type is `node` use JavaScript asset/module +candidates for their verified entry location. A target-only correspondence does +not mark that script loaded; load-state evidence requires a script observation. + 1. A captured-source SHA-256 plus one matching mapped location yields a content-and-location match. 2. When no static candidate exists at a mapped location, one unique exact diff --git a/src/domain/javascript/javascriptRuntimeLoadState.ts b/src/domain/javascript/javascriptRuntimeLoadState.ts index 275de19d370..e49d60c8f76 100644 --- a/src/domain/javascript/javascriptRuntimeLoadState.ts +++ b/src/domain/javascript/javascriptRuntimeLoadState.ts @@ -57,7 +57,10 @@ const classifyLayer = ( const direct = new Map(); const ambiguous = new Set(); const incomplete = new Set(); - for (const match of matches) + // A target's entry location establishes correspondence, not script presence. + for (const match of matches.filter( + ({ entity_kind }) => entity_kind === "script", + )) if ( match.status === "matched" && match.static_layer_id === layer.layerId && diff --git a/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts b/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts index b0be2de5bd2..613d47175c8 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts @@ -94,7 +94,10 @@ const targetEntity = ( frameKey: null, scriptKey: null, location, - staticKind: "renderer", + staticKind: + capture.kind === "v8-inspector" && target.type === "node" + ? "javascript" + : "renderer", sourceSha256: null, label: target.title, properties: { diff --git a/tests/acceptance/applications/javascriptNodeTargetReconciliationCli.test.ts b/tests/acceptance/applications/javascriptNodeTargetReconciliationCli.test.ts new file mode 100644 index 00000000000..357bc049594 --- /dev/null +++ b/tests/acceptance/applications/javascriptNodeTargetReconciliationCli.test.ts @@ -0,0 +1,52 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { javascriptRuntimeReconciliationResultSchema } from "../../../src/domain/javascript/javascriptRuntimeReconciliationSchemas.js"; +import { nodeReconciliationFixture } from "../../fixtures/nodeRuntimeReconciliation.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "reconciles synthetic Node target entry Evidence through %s", + async (surface, { cli }) => { + const input = nodeReconciliationFixture(false); + let document: unknown; + if (surface === "CLI") { + const root = await createTestTempDirectory( + "rea-node-reconciliation-cli-", + ); + const path = join(root, "input.json"); + await writeFile(path, JSON.stringify(input)); + const response = await cli.run({ + arguments: ["reconcile-javascript-runtime", path, "--format", "json"], + }); + expect(response.exitCode, response.stderr).toBe(0); + document = response.json; + } else { + const call = await cli.sharedMcpCall!(); + const response = await call("reconcile_javascript_runtime", input); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + document = response.structuredContent; + } + const result = javascriptRuntimeReconciliationResultSchema.parse( + (document as { normalized_result: unknown }).normalized_result, + ); + expect(result.reconciliations).toEqual([ + expect.objectContaining({ + entity_kind: "target", + status: "matched", + reason: "unique-location-match", + confidence: "medium", + candidate_static_count: 1, + }), + ]); + expect(result.summary).toMatchObject({ + runtime_scripts: 0, + static_loaded: 0, + static_not_observed: 0, + }); + expect( + result.graph.edges.filter(({ relation }) => relation === "observed_as"), + ).toHaveLength(1); + }, +); diff --git a/tests/boundary/inspector/javascriptRuntimeNodeTarget.test.ts b/tests/boundary/inspector/javascriptRuntimeNodeTarget.test.ts new file mode 100644 index 00000000000..c56840195b8 --- /dev/null +++ b/tests/boundary/inspector/javascriptRuntimeNodeTarget.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "vitest"; +import { reconcileJavaScriptRuntimeEvidence } from "../../../src/application/javascript/JavaScriptRuntimeReconciliationService.js"; +import { javascriptRuntimeReconciliationResultSchema } from "../../../src/domain/javascript/javascriptRuntimeReconciliationSchemas.js"; +import { nodeReconciliationFixture } from "../../fixtures/nodeRuntimeReconciliation.js"; + +test.each([false, true])( + "matches a synthetic Node entry target without inventing script presence: %s", + (scriptObserved) => { + const reconciled = reconcileJavaScriptRuntimeEvidence( + nodeReconciliationFixture(scriptObserved), + ); + if (!reconciled.ok) throw reconciled.error; + const result = javascriptRuntimeReconciliationResultSchema.parse( + reconciled.value.evidence.normalized_result, + ); + const target = result.reconciliations.find( + ({ entity_kind }) => entity_kind === "target", + ); + expect(target).toMatchObject({ + status: "matched", + basis: "artifact-path", + confidence: "medium", + reason: "unique-location-match", + candidate_static_count: 1, + }); + expect( + result.graph.nodes.find( + ({ node_id }) => node_id === target?.static_node_id, + )?.kind, + ).toBe("javascript-asset"); + expect(result.summary).toMatchObject({ + runtime_scripts: scriptObserved ? 1 : 0, + static_loaded: scriptObserved ? 1 : 0, + static_not_observed: 0, + }); + }, +); diff --git a/tests/fixtures/nodeRuntimeReconciliation.ts b/tests/fixtures/nodeRuntimeReconciliation.ts new file mode 100644 index 00000000000..cf6e75ba234 --- /dev/null +++ b/tests/fixtures/nodeRuntimeReconciliation.ts @@ -0,0 +1,73 @@ +import { createJavaScriptRuntimeObservationEvidence } from "../../src/application/javascript/JavaScriptRuntimeObservationEvidence.js"; +import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../src/contracts/javascript/javascriptRuntimeReconciliationExample.js"; +import { javascriptRuntimeObservationSchema } from "../../src/domain/javascript/javascriptRuntimeObservation.js"; +import { V8_INSPECTOR_PROVIDER_IDENTITY } from "../../src/inspector/providerIdentity.js"; + +/** Synthetic, schema-validated capture; this is not a live Inspector observation. */ +export const nodeReconciliationFixture = (scriptObserved: boolean) => { + const location = { + kind: "file", + file_path: "/Applications/Example.app/Contents/Resources/app/renderer.js", + }; + const runtime = javascriptRuntimeObservationSchema.parse({ + runtime: { + product: "node.js/v24.22.1", + protocol_version: "1.3", + v8_version: null, + }, + target: { + target_id: "synthetic-node", + protocol_type: "node", + attached: null, + location, + runtime_kind: "unknown", + runtime_kind_authority: "not-declared", + }, + capture: { + observation_ms: 100, + events_observed: scriptObserved ? 1 : 0, + events_retained: scriptObserved ? 1 : 0, + events_dropped: 0, + metadata_bytes_retained: 0, + truncated: false, + truncation_reasons: [], + }, + scripts: { + items: scriptObserved + ? [ + { + script_key: `v8_script_${"4".repeat(64)}`, + location, + execution_context_key: null, + cdp_hash: null, + length: null, + is_module: null, + status: "observed-loaded", + }, + ] + : [], + observed_total: scriptObserved ? 1 : 0, + excluded: { unsupported_location: 0, invalid_protocol_value: 0 }, + }, + execution_contexts: [], + directly_observed: [], + unavailable_without_instrumentation: [], + unknowns: ["Synthetic bounded capture."], + limitations: ["Synthetic fixture, not a live runtime."], + }); + return { + static_layers: [{ analysis: JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE }], + runtime_observations: [ + createJavaScriptRuntimeObservationEvidence( + "observe_javascript_runtime", + { + inspector_endpoint: "http://127.0.0.1:9229", + target_id: "synthetic-node", + observation_ms: 100, + }, + runtime, + V8_INSPECTOR_PROVIDER_IDENTITY, + ), + ], + }; +}; From 510e83eadc5251ee5cc9469b894c7895ffe36ef2 Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Tue, 3 Nov 2026 09:17:33 +0800 Subject: [PATCH 14/38] fix(firmware): recommend the operation-specific engine (#8668) --- docs/firmware-analysis.md | 3 + src/application/CapabilityInventory.test.ts | 56 +++++++++++++++++++ src/application/CapabilityInventory.ts | 13 ++++- src/cli/firmwareCommands.ts | 3 +- .../firmware/firmwareToolContracts.ts | 9 ++- .../firmware/firmwareToolRequirements.ts | 8 +++ src/firmware/FirmwareProvider.ts | 4 +- 7 files changed, 87 insertions(+), 9 deletions(-) diff --git a/docs/firmware-analysis.md b/docs/firmware-analysis.md index 28d70d4f27b..de61db97940 100644 --- a/docs/firmware-analysis.md +++ b/docs/firmware-analysis.md @@ -26,6 +26,9 @@ REA does not install these tools, their dependencies or system packages. It checks the tool's `--version` banner when executing a request. Binwalk must print `binwalk ` and Unblob must print the version alone. Inspection and extraction have independent prerequisites; one does not require the other. +Capability discovery recommends `REA_BINWALK_COMMAND` for missing inspection +and `REA_UNBLOB_COMMAND` for missing extraction. Configuring the other engine +does not enable the unavailable operation. Both require util-linux `prlimit`, normally `/usr/bin/prlimit`; override its absolute path with `REA_FIRMWARE_PRLIMIT_COMMAND` if needed. `rea doctor` does not currently inspect firmware readiness. Engine selection/version checks run diff --git a/src/application/CapabilityInventory.test.ts b/src/application/CapabilityInventory.test.ts index 089685cd33d..8425bd1ab9c 100644 --- a/src/application/CapabilityInventory.test.ts +++ b/src/application/CapabilityInventory.test.ts @@ -880,6 +880,62 @@ describe("trace_feature requirements", () => { }); }); +describe("firmware operation recovery", () => { + it.each([ + [false, false], + [true, false], + [false, true], + [true, true], + ])( + "keeps inspection=%s and extraction=%s independent", + (inspection, extraction) => { + const inventory = buildCapabilityInventory(status(), { + processCaptureEnabled: false, + firmwareInspectionEnabled: inspection, + firmwareExtractionEnabled: extraction, + }); + for (const [name, enabled, command, otherCommand] of [ + [ + "inspect_firmware_regions", + inspection, + "REA_BINWALK_COMMAND", + "REA_UNBLOB_COMMAND", + ], + [ + "extract_firmware", + extraction, + "REA_UNBLOB_COMMAND", + "REA_BINWALK_COMMAND", + ], + ] as const) { + const tool = inventory.find((candidate) => candidate.name === name); + expect(tool).toMatchObject({ + available: enabled, + reason: enabled ? "available" : "provider_missing", + }); + if (enabled) { + expect(tool?.remediation).toBeNull(); + } else { + expect(tool?.remediation).toContain(command); + expect(tool?.remediation).not.toContain(otherCommand); + expect(tool?.remediation).toContain("prlimit"); + expect(tool?.remediation).toContain("procps ps"); + expect(tool?.remediation?.includes("external extractor")).toBe( + name === "extract_firmware", + ); + } + } + const byName = (left: ToolAvailability, right: ToolAvailability) => + left.name.localeCompare(right.name); + expect( + expandToolAvailability(compactToolAvailability(inventory)).toSorted( + byName, + ), + ).toEqual(inventory.toSorted(byName)); + }, + ); +}); + const HOST_RESTRICTED_TOOLS = [ ["inspect_packet_capture", "packetCapture", "packetCaptureEnabled"], ["inspect_packet_fields", "packetCapture", "packetCaptureEnabled"], diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index c2cb4642c34..b1316b5d6f4 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -1,6 +1,9 @@ import { PCAP_CAPTURE_UNSUPPORTED_HOST_REASON } from "../domain/pcap/pcapAnalysis.js"; import { DEFAULT_CALL_RELATIONSHIP_DIRECTION } from "../contracts/enhancedInputs.js"; -import { FIRMWARE_TOOL_REQUIREMENTS } from "../contracts/firmware/firmwareToolRequirements.js"; +import { + FIRMWARE_OPERATION_ENGINES, + FIRMWARE_TOOL_REQUIREMENTS, +} from "../contracts/firmware/firmwareToolRequirements.js"; import { CALL_RELATIONSHIP_TRAVERSALS } from "./CallRelationshipTraversal.js"; import { NATIVE_DISPATCH_METADATA_SOURCES } from "./native/NativeDispatchMetadataInspection.js"; import { NATIVE_UI_ACTION_OPERATIONS } from "./native/NativeUiActionTrace.js"; @@ -595,15 +598,19 @@ const workflowAvailabilityFor = ({ "On Linux x64, set absolute REA_PWNTOOLS_PYTHON to caller-supplied Python with pwntools 4.15.0. No active binary target is required.", ); if (kind === "firmware-provider") { - const { binwalk, unblob } = FIRMWARE_TOOL_REQUIREMENTS; const inspection = name === "inspect_firmware_regions"; + const operation = inspection + ? "inspect_firmware_regions" + : "extract_firmware"; + const requirement = + FIRMWARE_TOOL_REQUIREMENTS[FIRMWARE_OPERATION_ENGINES[operation]]; return hostRestrictedAvailability( policy, inspection ? "firmwareInspection" : "firmwareExtraction", inspection ? policy.firmwareInspectionEnabled : policy.firmwareExtractionEnabled, - `On Linux, provide an absolute ${binwalk.commandEnvironment} (${binwalk.name} ${binwalk.line}, verified with ${binwalk.version}) or ${unblob.commandEnvironment} (${unblob.name} ${unblob.line}, verified with ${unblob.version}) path and util-linux prlimit. Extraction also requires the selected format’s external extractor.`, + `On Linux, provide an absolute ${requirement.commandEnvironment} (${requirement.requirement}) path, util-linux prlimit and procps ps.${inspection ? "" : " Extraction also requires the selected format’s external extractor."}`, ); } if (kind === "android-provider") diff --git a/src/cli/firmwareCommands.ts b/src/cli/firmwareCommands.ts index 43223a58cd6..564b563c3e0 100644 --- a/src/cli/firmwareCommands.ts +++ b/src/cli/firmwareCommands.ts @@ -1,3 +1,4 @@ +import { FIRMWARE_OPERATION_ENGINES } from "../contracts/firmware/firmwareToolRequirements.js"; import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { z } from "incur"; import { FirmwareAnalysisService } from "../application/firmware/FirmwareAnalysisService.js"; @@ -36,7 +37,7 @@ export const registerFirmwareCommands = ( callerPath, () => service.execute(operation, input, { signal }), service, - operation === "inspect_firmware_regions" ? "binwalk" : "unblob", + FIRMWARE_OPERATION_ENGINES[operation], operation, signal, ).then(projectCliAnalysisResult), diff --git a/src/contracts/firmware/firmwareToolContracts.ts b/src/contracts/firmware/firmwareToolContracts.ts index 2bb8e199800..c2102122714 100644 --- a/src/contracts/firmware/firmwareToolContracts.ts +++ b/src/contracts/firmware/firmwareToolContracts.ts @@ -5,7 +5,10 @@ import { import type { ToolContract } from "../toolContractTypes.js"; import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; import { toolContractMetadata } from "../toolEffects.js"; -import { FIRMWARE_TOOL_REQUIREMENTS } from "./firmwareToolRequirements.js"; +import { + FIRMWARE_OPERATION_ENGINES, + FIRMWARE_TOOL_REQUIREMENTS, +} from "./firmwareToolRequirements.js"; /** Explicit local firmware observations and extraction, backed by caller-supplied tools. */ export const FIRMWARE_TOOL_CONTRACTS = [ @@ -13,7 +16,7 @@ export const FIRMWARE_TOOL_CONTRACTS = [ name: "inspect_firmware_regions", ...toolContractMetadata("inspect_firmware_regions"), kind: "firmware-provider", - description: `Inspect signature candidates in a local firmware file with ${FIRMWARE_TOOL_REQUIREMENTS.binwalk.requirement}. Returns offsets, reported lengths, descriptions and raw confidence values. Length validation and runtime addresses remain unknown. No extraction or target execution. Linux, caller-supplied tool, util-linux prlimit and procps ps required; 128 MiB input limit, one worker. Adjust analysis_timeout_ms for the analysis wall-clock budget (default 120000); fixed per-process limits are unchanged.`, + description: `Inspect signature candidates in a local firmware file with ${FIRMWARE_TOOL_REQUIREMENTS[FIRMWARE_OPERATION_ENGINES.inspect_firmware_regions].requirement}. Returns offsets, reported lengths, descriptions and raw confidence values. Length validation and runtime addresses remain unknown. No extraction or target execution. Linux, caller-supplied tool, util-linux prlimit and procps ps required; 128 MiB input limit, one worker. Adjust analysis_timeout_ms for the analysis wall-clock budget (default 120000); fixed per-process limits are unchanged.`, inputSchema: firmwareInputSchemas.inspect_firmware_regions, outputSchema: evidenceResultOf( firmwareResultSchemas.inspect_firmware_regions, @@ -29,7 +32,7 @@ export const FIRMWARE_TOOL_CONTRACTS = [ name: "extract_firmware", ...toolContractMetadata("extract_firmware"), kind: "firmware-provider", - description: `Extract caller-selected firmware bytes with ${FIRMWARE_TOOL_REQUIREMENTS.unblob.requirement} into an absent output directory. Returns verified child paths/digests, task derivations, unknown chunks and uncovered selected input ranges, extraction diagnostics and explicit extraction_gaps rows naming unextracted regions, missing extractor dependencies and a continuation_id referencing shared extraction_continuations recovery instructions. Preserves the default sandbox; publishes regular files only. Linux, caller-supplied tool, format-specific extractors, util-linux prlimit and procps ps required. No mounts or extracted-code execution. Select a returned native file with open_binary for Ghidra analysis; decompressed offsets are not original-file addresses.`, + description: `Extract caller-selected firmware bytes with ${FIRMWARE_TOOL_REQUIREMENTS[FIRMWARE_OPERATION_ENGINES.extract_firmware].requirement} into an absent output directory. Returns verified child paths/digests, task derivations, unknown chunks and uncovered selected input ranges, extraction diagnostics and explicit extraction_gaps rows naming unextracted regions, missing extractor dependencies and a continuation_id referencing shared extraction_continuations recovery instructions. Preserves the default sandbox; publishes regular files only. Linux, caller-supplied tool, format-specific extractors, util-linux prlimit and procps ps required. No mounts or extracted-code execution. Select a returned native file with open_binary for Ghidra analysis; decompressed offsets are not original-file addresses.`, inputSchema: firmwareInputSchemas.extract_firmware, outputSchema: evidenceResultOf(firmwareResultSchemas.extract_firmware), examples: [ diff --git a/src/contracts/firmware/firmwareToolRequirements.ts b/src/contracts/firmware/firmwareToolRequirements.ts index fb0816fe972..824a43816a1 100644 --- a/src/contracts/firmware/firmwareToolRequirements.ts +++ b/src/contracts/firmware/firmwareToolRequirements.ts @@ -1,3 +1,5 @@ +import type { FirmwareOperation } from "../../domain/firmware/firmwareAnalysis.js"; + /** Derive public support facts from one audited, stable release number. */ const defineToolRequirement = (release: { readonly name: string; @@ -39,3 +41,9 @@ export const FIRMWARE_TOOL_REQUIREMENTS = { /** Caller-supplied firmware engine selected by the operation. */ export type FirmwareEngineName = keyof typeof FIRMWARE_TOOL_REQUIREMENTS; + +/** The engine required by each existing firmware operation, shared by discovery and execution. */ +export const FIRMWARE_OPERATION_ENGINES = { + inspect_firmware_regions: "binwalk", + extract_firmware: "unblob", +} as const satisfies Readonly>; diff --git a/src/firmware/FirmwareProvider.ts b/src/firmware/FirmwareProvider.ts index 377095073de..c0653816abd 100644 --- a/src/firmware/FirmwareProvider.ts +++ b/src/firmware/FirmwareProvider.ts @@ -1,3 +1,4 @@ +import { FIRMWARE_OPERATION_ENGINES } from "../contracts/firmware/firmwareToolRequirements.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { assertArtifactCommandPath } from "../process/ArtifactCommandPath.js"; import { join } from "node:path"; @@ -332,8 +333,7 @@ export class FirmwareProvider implements FirmwareAnalysisPort { const environment = snapshotEnvironment(this.environment); let root: PrivateRuntimeRoot | undefined; let rootLease: AnalysisCleanupLease | undefined; - const engineName = - request.operation === "inspect_firmware_regions" ? "binwalk" : "unblob"; + const engineName = FIRMWARE_OPERATION_ENGINES[request.operation]; const withCaller = (phase: FirmwareDeadline) => options?.signal === undefined ? phase.signal From eef8f44003e6bf781baf4f885f84d03b31ea6c3a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:33:41 +0800 Subject: [PATCH 15/38] feat(website): publish agent discovery and Markdown responses --- .github/workflows/website-check.yml | 4 + .github/workflows/website-pages.yml | 44 +++++- scripts/ci/scopes.mjs | 4 +- scripts/prepare-website.py | 3 + scripts/test-website-agents.py | 109 +++++++++++++++ scripts/website.py | 3 +- scripts/website_agents.py | 116 ++++++++++++++++ .../boundary/filesystem/ciChangeScope.test.ts | 9 +- website/README.md | 58 +++++++- website/public/_headers | 11 ++ website/requirements.txt | 3 + website/worker.mjs | 103 ++++++++++++++ website/worker.test.mjs | 127 ++++++++++++++++++ website/wrangler.toml | 5 +- 14 files changed, 591 insertions(+), 8 deletions(-) create mode 100644 scripts/test-website-agents.py create mode 100644 scripts/website_agents.py create mode 100644 website/public/_headers create mode 100644 website/worker.mjs create mode 100644 website/worker.test.mjs diff --git a/.github/workflows/website-check.yml b/.github/workflows/website-check.yml index dc17b4b9691..a7106ac7c27 100644 --- a/.github/workflows/website-check.yml +++ b/.github/workflows/website-check.yml @@ -21,6 +21,10 @@ jobs: python-version: "3.12" cache: pip cache-dependency-path: website/requirements.txt + - name: Set up Node.js for the website Worker checks + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .nvmrc - name: Install sharing-image dependencies run: | sudo apt-get update diff --git a/.github/workflows/website-pages.yml b/.github/workflows/website-pages.yml index 491b028a652..9bfa7b630e7 100644 --- a/.github/workflows/website-pages.yml +++ b/.github/workflows/website-pages.yml @@ -54,6 +54,10 @@ jobs: python-version: "3.12" cache: pip cache-dependency-path: website/requirements.txt + - name: Set up Node.js for the website Worker checks + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .nvmrc - name: Install sharing-image dependencies run: | sudo apt-get update @@ -75,10 +79,21 @@ jobs: PY - name: Configure Pages uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0 + # upload-pages-artifact excludes dot directories, including .well-known. + - name: Package the website including agent discovery + run: | + tar --dereference --hard-dereference --directory website/_site \ + --exclude=.git --exclude=.github -cf "$RUNNER_TEMP/artifact.tar" . + tar -tf "$RUNNER_TEMP/artifact.tar" \ + ./.well-known/ard.json ./.well-known/ai-catalog.json \ + ./.well-known/agent-skills/index.json > /dev/null - name: Upload the website - uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - path: website/_site + name: github-pages + path: ${{ runner.temp }}/artifact.tar + retention-days: 1 + if-no-files-found: error deploy: needs: prepare @@ -130,12 +145,13 @@ jobs: PUBLISH_TARGET: ${{ inputs.target }} run: | python3 -u - <<'PY' + import hashlib import json import os import sys import time from urllib.error import URLError - from urllib.parse import urljoin + from urllib.parse import urljoin, urlsplit from urllib.request import Request, urlopen expected = os.environ["GITHUB_SHA"] @@ -172,6 +188,28 @@ jobs: time.sleep(5) if pending: sys.exit("Could not verify published revision on: " + ", ".join(pending)) + for name, base in hosts.items(): + with urlopen(urljoin(base, ".well-known/agent-skills/index.json"), timeout=15) as response: + skill, = json.load(response)["skills"] + # Fetch from each selected host, including the /rea/ mirror. + artifact_url = urljoin(base, urlsplit(skill["url"]).path.lstrip("/")) + with urlopen(artifact_url, timeout=15) as response: + digest = "sha256:" + hashlib.sha256(response.read()).hexdigest() + if digest != skill["digest"]: + sys.exit(name + " serves a skill archive with the wrong digest") + for manifest in ("ard.json", "ai-catalog.json"): + with urlopen(urljoin(base, ".well-known/" + manifest), timeout=15) as response: + if not json.load(response).get("entries"): + sys.exit(name + " serves an empty discovery manifest") + if name == "Cloudflare": + request = Request(base, headers={"Accept": "text/markdown"}) + with urlopen(request, timeout=15) as response: + vary = {value.strip().lower() for value in response.headers.get("Vary", "").split(",")} + if response.headers.get_content_type() != "text/markdown" or "accept" not in vary: + sys.exit("Cloudflare did not negotiate Markdown with Vary: Accept") + if 'rel="ard"' not in response.headers.get("Link", ""): + sys.exit("Cloudflare is missing its discovery Link header") + print(name + " serves the discovery manifests and verified skill archive") with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary: links = " and ".join(f"[{name}]({url})" for name, url in hosts.items()) summary.write(f"Published commit `{expected}` to {links}.\n") diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index 7f5e4d3839e..03bcf518d39 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -704,7 +704,9 @@ export const CI_LANES = { agentenv: sameJobs(PATH_SCOPES.agentenv, ["agentenv"]), nativeaot: sameJobs(PATH_SCOPES.nativeaot, ["nativeaot"]), ghidra_bridge: sameJobs(PATH_SCOPES.ghidra_bridge, ["ghidra_bridge"]), - website: sameJobs(PATH_SCOPES.website, ["website"]), + website: sameJobs(PATH_SCOPES.website, ["website"], { + earlyInputs: [".agents/skills/reverse-engineer-anything/**"], + }), }; // An input owner omitted from the registry must never silently lose a lane. for (const [name, paths] of Object.entries(PATH_SCOPES)) diff --git a/scripts/prepare-website.py b/scripts/prepare-website.py index cd284d3e476..33881a25e13 100644 --- a/scripts/prepare-website.py +++ b/scripts/prepare-website.py @@ -7,6 +7,7 @@ from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo from website import PageMetadata, page_url +from website_agents import prepare_discovery, prepare_markdown EXAMPLE_FILES = ( @@ -81,6 +82,8 @@ def prepare_assets(site): prepare_sharing_image(site) prepare_sitemap(site) prepare_notes_example(site) + prepare_discovery(Path(__file__).resolve().parent.parent, site) + prepare_markdown(site) if __name__ == "__main__": diff --git a/scripts/test-website-agents.py b/scripts/test-website-agents.py new file mode 100644 index 00000000000..d5349e1b1a3 --- /dev/null +++ b/scripts/test-website-agents.py @@ -0,0 +1,109 @@ +"""Check the public build artifacts and Markdown content contract.""" + +import hashlib +import json +from pathlib import Path +import tempfile +import unittest +from zipfile import ZipFile + +from bs4 import BeautifulSoup +import yaml + +from website_agents import prepare_discovery, prepare_markdown + + +ROOT = Path(__file__).resolve().parent.parent + + +class AgentPublishingChecks(unittest.TestCase): + def test_published_skill_digest_covers_every_reference_and_catalogs_resolve(self): + site = ROOT / "website/_site" + index = json.loads((site / ".well-known/agent-skills/index.json").read_text()) + self.assertEqual(index["$schema"], "https://schemas.agentskills.io/discovery/0.2.0/schema.json") + skill, = index["skills"] + self.assertEqual(skill["name"], "reverse-engineer-anything") + self.assertEqual(skill["type"], "archive") + artifact = site / skill["url"].removeprefix("https://rea.tools/") + self.assertEqual(skill["digest"], "sha256:" + hashlib.sha256(artifact.read_bytes()).hexdigest()) + source = ROOT / ".agents/skills/reverse-engineer-anything" + with ZipFile(artifact) as archive: + expected = {f"{source.name}/{p.relative_to(source).as_posix()}": p.read_bytes() + for p in source.rglob("*.md")} + self.assertEqual(set(archive.namelist()), set(expected)) + for name, data in expected.items(): + self.assertEqual(archive.read(name), data) + manifest = archive.read(f"{source.name}/SKILL.md").decode() + metadata = yaml.safe_load(manifest[4:].split("\n---\n", 1)[0]) + self.assertEqual(skill["description"], metadata["description"]) + catalog = json.loads((site / ".well-known/ai-catalog.json").read_text()) + self.assertEqual(catalog["specVersion"], "1.0") + entry, = catalog["entries"] + self.assertEqual(entry["identifier"], "urn:air:rea.tools:skill:reverse-engineer-anything") + self.assertEqual(entry["url"], skill["url"]) + self.assertNotIn("data", entry) + ard = json.loads((site / ".well-known/ard.json").read_text()) + resource, = ard["entries"] + self.assertEqual(resource["url"], skill["url"]) + self.assertTrue(2 <= len(resource["representativeQueries"]) <= 5) + + def test_reference_changes_invalidate_digest_and_yaml_scalars_are_parsed(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / ".agents/skills/reverse-engineer-anything" + source.mkdir(parents=True) + (source / "SKILL.md").write_text( + '---\nname: reverse-engineer-anything\ndescription: >-\n Inspect programs:\n preserve evidence.\n---\n# REA\n' + ) + (source / "references").mkdir() + reference = source / "references/evidence.md" + reference.write_text("First revision") + site = root / "site" + def build(): + prepare_discovery(root, site) + return json.loads((site / ".well-known/agent-skills/index.json").read_text())["skills"][0] + before = build() + self.assertEqual(before["description"], "Inspect programs: preserve evidence.") + self.assertEqual(build(), before) + reference.write_text("Second revision") + self.assertNotEqual(build()["digest"], before["digest"]) + + def test_markdown_preserves_content_and_resources_without_publishing_excluded_pages(self): + with tempfile.TemporaryDirectory() as directory: + site = Path(directory) + page = site / "zh-CN/guides/test/index.html" + page.parent.mkdir(parents=True) + page.write_text('''REA: test +
+

Inspect a program

+
  1. Readinput.exeKeep evidence.
+

Another guide

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

Hidden evidence remains available.

+ Investigation diagram +
''') + fixture = site / "examples/notes-web/index.html" + fixture.parent.mkdir(parents=True) + fixture.write_text("
Target application
") + fallback = site / "zh-CN/fallback/index.html" + fallback.parent.mkdir(parents=True) + fallback.write_text('
English fallback notice.
') + prepare_markdown(site) + markdown = page.with_suffix(".md").read_text() + self.assertIn('# Inspect a program', markdown) + self.assertIn('const value = a < b ? "x_y" : "&";\n keepIndent();', markdown) + self.assertIn("Hidden evidence remains available.", markdown) + self.assertIn("Shortened display paths.", markdown) + self.assertNotIn("Read`input.exe`", markdown) + self.assertIn("https://rea.tools/zh-CN/guides/other/?q=a%2Fb#code", markdown) + self.assertIn("https://rea.tools/assets/diagram.svg", markdown) + for text in ("Outside navigation", "Contents navigation", "Copy code", "doNotPublish"): + self.assertNotIn(text, markdown) + self.assertFalse(fixture.with_suffix(".md").exists()) + self.assertFalse(fallback.with_suffix(".md").exists()) + html = BeautifulSoup(page.read_text(), "html.parser") + self.assertEqual(html.find("link", rel="alternate")["href"], "https://rea.tools/zh-CN/guides/test/index.md") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/website.py b/scripts/website.py index 4891347ff96..b6b3a39f190 100644 --- a/scripts/website.py +++ b/scripts/website.py @@ -327,8 +327,9 @@ def main(): refresh_contributors(root) elif args.command == "check": sync_pages(root, check=True) - for name in ("prepare-website.py", "verify-website.py", "test-website.py"): + for name in ("prepare-website.py", "verify-website.py", "test-website.py", "test-website-agents.py"): run_script(root, name) + subprocess.run(["node", "--test", "website/worker.test.mjs"], cwd=root, check=True) elif args.command == "links": run_script(root, "audit-website-links.py", ["--report", args.report] if args.report else []) else: diff --git a/scripts/website_agents.py b/scripts/website_agents.py new file mode 100644 index 00000000000..74a3e0a5d5e --- /dev/null +++ b/scripts/website_agents.py @@ -0,0 +1,116 @@ +"""Build public agent discovery and Markdown from their maintained sources.""" + +import hashlib +import json +from urllib.parse import urljoin +from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo + +from bs4 import BeautifulSoup +from markdownify import MarkdownConverter +import yaml + +from website import NOTES_FIXTURES, PageMetadata, SITE_ORIGIN, page_url + + +def write_json(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def prepare_discovery(root, site): + """Publish the complete skill, including its relative reference documents.""" + source = root / ".agents/skills/reverse-engineer-anything" + skill = (source / "SKILL.md").read_text(encoding="utf-8") + if not skill.startswith("---\n") or "\n---\n" not in skill[4:]: + raise ValueError("Public REA skill needs YAML frontmatter") + metadata = yaml.safe_load(skill[4:].split("\n---\n", 1)[0]) + if not isinstance(metadata, dict): + raise ValueError("Public REA skill needs YAML metadata") + name, description = metadata["name"], metadata["description"] + if name != source.name or not isinstance(description, str) or not description.strip(): + raise ValueError("Invalid public REA skill metadata") + discovery = site / ".well-known/agent-skills" + discovery.mkdir(parents=True, exist_ok=True) + artifact = discovery / f"{name}.zip" + with ZipFile(artifact, "w") as archive: + for path in [source / "SKILL.md", *sorted(source.glob("references/**/*.md"))]: + relative = path.relative_to(source) + entry = ZipInfo(f"{name}/{relative.as_posix()}", (1980, 1, 1, 0, 0, 0)) + entry.create_system = 3 + entry.external_attr = 0o100644 << 16 + archive.writestr(entry, path.read_bytes(), compress_type=ZIP_DEFLATED) + artifact_url = f"{SITE_ORIGIN}/.well-known/agent-skills/{artifact.name}" + write_json(discovery / "index.json", { + "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", + "skills": [{ + "name": name, "type": "archive", "description": description, + "url": artifact_url, + "digest": "sha256:" + hashlib.sha256(artifact.read_bytes()).hexdigest(), + }], + }) + entry = { + "identifier": f"urn:air:rea.tools:skill:{name}", + "displayName": "REA reverse engineering skill", + "description": description, + "type": "application/zip", + "url": artifact_url, + } + write_json(site / ".well-known/ai-catalog.json", { + "specVersion": "1.0", + "host": {"displayName": "REA", "documentationUrl": f"{SITE_ORIGIN}/get-started/"}, + "entries": [entry], + }) + write_json(site / ".well-known/ard.json", {"entries": [{ + **entry, + "representativeQueries": [ + "Explain how a feature works in a shipped executable using REA.", + "Trace an Electron application's behavior back to its packaged JavaScript.", + "Build an AgentEnv bundle from a web application capture.", + ], + }]}) + print("Prepared agent skill archive, discovery index, AI Catalog and ARD manifest.") + + +def prepare_markdown(site): + """Convert rendered reading pages, keeping code and resolving resource URLs.""" + converter = MarkdownConverter(heading_style="ATX", bullets="-", escape_underscores=False) + count = 0 + for path in sorted(site.rglob("*.html")): + if path.relative_to(site).as_posix() in NOTES_FIXTURES: + continue + source = path.read_text(encoding="utf-8") + metadata = PageMetadata() + metadata.feed(source) + # Drafts and English fallbacks keep their HTML indexing policy intact. + if metadata.noindex: + continue + soup = BeautifulSoup(source, "html.parser") + main = soup.find("main") + if main is None: + continue + # Preserve fallback notices and closed disclosures; remove interface chrome. + for node in main.select('script, style, nav, button, svg, .home-page-nav, [aria-hidden="true"]'): + if node.parent is not None: + node.decompose() + # These spans are separate lines in the shared flow component's CSS. + for node in main.select("span.flow-label, span.flow-node, span.flow-note"): + node.name = "div" + canonical = page_url(site, path) + for node in main.select("[href], [src]"): + for attribute in ("href", "src"): + if node.has_attr(attribute): + node[attribute] = urljoin(canonical, node[attribute]) + title = metadata.titles[0] if metadata.titles else "REA" + header = yaml.safe_dump({"title": title, "url": canonical}, allow_unicode=True, sort_keys=False) + markdown = converter.convert_soup(main).strip() + path.with_suffix(".md").write_text(f"---\n{header}---\n\n{markdown}\n", encoding="utf-8") + alternate = page_url(site, path.with_suffix(".md")) + links = ( + f'\n' + f'\n' + f'\n' + f'\n' + ) + path.write_text(source.replace("", links + "", 1), encoding="utf-8") + count += 1 + print(f"Prepared Markdown for {count} reading pages.") diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index 351223eb05a..e5710daa259 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -879,7 +879,14 @@ it.each([ ["install.sh", { code: true, runtime: true, package: true, evm: false }], [ ".agents/skills/reverse-engineer-anything/SKILL.md", - { code: true, runtime: true, docs: true, package: false, evm: false }, + { + code: true, + runtime: true, + docs: true, + website: true, + package: false, + evm: false, + }, ], [ "scripts/verify-real-ghidra.mjs", diff --git a/website/README.md b/website/README.md index 47ea63ca145..d8cd2d5f418 100644 --- a/website/README.md +++ b/website/README.md @@ -486,10 +486,66 @@ The mask SVG is drawn from the seventh decoded mask. It selects zero-based position 21 and compares its character code with 55 (`7`). Keep the figure, solver constants and evidence notes aligned when changing this case. +## Agent discovery and Markdown + +`scripts/website_agents.py` runs during asset preparation, after translations. +It generates these public files in ignored `_site/`: + +- `.well-known/agent-skills/index.json`: the draft v0.2.0 skills discovery index. + Its archive contains the maintained + `.agents/skills/reverse-engineer-anything/SKILL.md` and Markdown references. + The name and description come from YAML frontmatter; SHA-256 covers the + complete, reproducible ZIP, so reference changes invalidate the digest too. +- `.well-known/ard.json`: an ARD manifest with representative analyst queries. +- `.well-known/ai-catalog.json`: an AI Catalog 1.0 document listing that same + downloadable skill. These advertise installation instructions for the local + tool, without claiming a hosted analysis endpoint. +- `index.md` beside each indexable rendered reading page, including translations. + Markdown retains code, evidence disclosures and image descriptions, with + absolute resource links. Drafts, labeled English fallbacks and target example + applications retain their HTML and indexing policy. + +The [skills discovery draft](https://github.com/cloudflare/agent-skills-discovery-rfc), +[ARD proposal](https://agenticresourcediscovery.org/spec/) and +[AI Catalog proposal](https://ai-catalog.io/) are evolving formats. ARD v0.91 +uses `ard.json` and `rel="ard"`; AI Catalog uses its separate `ai-catalog.json` +envelope. Both entries point to the same ZIP with the registered +`application/zip` media type. + +Cloudflare's Worker selects Markdown when an explicit `Accept: text/markdown` +has higher quality than HTML. HTML wins ties and remains the default. Successful +reading-page responses include `Vary: Accept` and an alternate Markdown Link; +Markdown uses `Content-Type: text/markdown; charset=utf-8`. Static `_headers` +advertises discovery resources, sets discovery CORS to `*`, and supplies MIME +types. Build-generated HTML `` elements also advertise these resources. + +GitHub Pages and the Python preview serve the same files and HTML links, with +direct Markdown URLs such as `guides/javascript/index.md`. They do not execute +the Worker or apply Cloudflare's `_headers`: content negotiation, custom MIME +overrides and discovery CORS headers are Cloudflare behavior. The publish +workflow packages `.well-known` explicitly because `upload-pages-artifact` +excludes dot directories by default. + +`python3 scripts/website.py check` includes artifact/content regressions and +Worker request-contract tests. To check the actual local Cloudflare runtime +after preparing assets, use the repository's pinned Node.js and Wrangler: + +```sh +npx wrangler@4.149.0 dev --cwd website --env production --local +curl -i -H 'Accept: text/markdown' http://localhost:8787/guides/javascript/ +curl -I http://localhost:8787/.well-known/agent-skills/index.json +``` + +The `production` environment here skips the preview build and uses the already +verified artifact; `--local` runs on your machine. Check HTML and Markdown, +GET and HEAD, discovery CORS, the downloaded ZIP digest, and unknown routes. +Production remains published only by the manual website workflow. + ## Cloudflare Workers `wrangler.toml` serves `website/_site/` through Workers Static Assets. There is -no Worker script or frontend framework. Before a preview or deployment, Wrangler +one small Worker (`worker.mjs`) for Markdown content negotiation and no frontend +framework. Before a preview or deployment, Wrangler runs the Python scripts to build the language editions, prepare the example ZIP and verify the website. Node.js and Python 3 must be available; the commands below use a pinned Wrangler version without adding it to REA's package dependencies. diff --git a/website/public/_headers b/website/public/_headers new file mode 100644 index 00000000000..2065dd5df36 --- /dev/null +++ b/website/public/_headers @@ -0,0 +1,11 @@ +/* + Link: ; rel="ard", ; rel="ai-catalog", ; rel="related"; type="application/json"; title="Agent skills index" + +/.well-known/* + Access-Control-Allow-Origin: * + +/.well-known/ai-catalog.json + Content-Type: application/ai-catalog+json + +/*.md + Content-Type: text/markdown; charset=utf-8 diff --git a/website/requirements.txt b/website/requirements.txt index e6099d25dcf..67e1541cfd1 100644 --- a/website/requirements.txt +++ b/website/requirements.txt @@ -2,3 +2,6 @@ CairoSVG==2.9.1 # Standard gettext PO/POT catalogs; no translation service is used by builds. polib==1.2.0 +# Build Markdown reading pages and parse the maintained skill's YAML metadata. +markdownify==1.2.3 +PyYAML==6.0.3 diff --git a/website/worker.mjs b/website/worker.mjs new file mode 100644 index 00000000000..b87bf8acfb4 --- /dev/null +++ b/website/worker.mjs @@ -0,0 +1,103 @@ +// Negotiate prebuilt Markdown while keeping the static asset service's routing. +function prefersMarkdown(accept) { + const ranges = (accept ?? "") + .toLowerCase() + .split(",") + .map((range) => { + const [type, ...parameters] = range.trim().split(";"); + const quality = parameters + .map((value) => value.trim()) + .find((value) => value.startsWith("q=")); + const q = quality === undefined ? 1 : Number(quality.slice(2)); + return { + type: type.trim(), + q: Number.isFinite(q) && q >= 0 && q <= 1 ? q : 0, + }; + }); + const quality = (type) => { + for (const match of [type, "text/*", "*/*"]) { + const matches = ranges.filter((range) => range.type === match); + if (matches.length) return Math.max(...matches.map((range) => range.q)); + } + return 0; + }; + // Wildcards alone never opt a browser into Markdown; HTML wins ties. + return ( + ranges.some((range) => range.type === "text/markdown") && + quality("text/markdown") > 0 && + quality("text/markdown") > quality("text/html") + ); +} + +function advertiseMarkdown(response, path) { + const vary = new Set( + (response.headers.get("Vary") ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); + if (![...vary].some((s) => s.toLowerCase() === "accept" || s === "*")) + vary.add("Accept"); + response.headers.set("Vary", [...vary].join(", ")); + response.headers.append( + "Link", + `<${path}>; rel="alternate"; type="text/markdown"`, + ); + return response; +} + +export default { + async fetch(request, env) { + const useMarkdown = prefersMarkdown(request.headers.get("Accept")); + // HTML validators describe a different representation. Probe the route first, + // then let the asset service evaluate validators/ranges against the Markdown. + const probe = new Request(request); + if (useMarkdown) { + for (const name of [ + "If-None-Match", + "If-Modified-Since", + "If-Match", + "If-Unmodified-Since", + "If-Range", + "Range", + ]) + probe.headers.delete(name); + } + const original = await env.ASSETS.fetch(probe); + if ( + ![200, 206, 304].includes(original.status) || + !["GET", "HEAD"].includes(request.method) || + !original.headers.get("Content-Type")?.startsWith("text/html") + ) + return original; + + const url = new URL(request.url); + if (!url.pathname.endsWith("/") && !url.pathname.endsWith(".html")) + return original; + const markdown = new URL(url); + markdown.pathname = url.pathname.endsWith("/") + ? `${url.pathname}index.md` + : url.pathname.slice(0, -5) + ".md"; + const candidate = await env.ASSETS.fetch( + useMarkdown + ? new Request(markdown, request) + : new Request(markdown, { method: "HEAD" }), + ); + if (!candidate.ok && ![304, 412, 416].includes(candidate.status)) { + await candidate.body?.cancel(); + return original; + } + const response = new Response( + useMarkdown ? candidate.body : original.body, + useMarkdown ? candidate : original, + ); + if (useMarkdown) { + await original.body?.cancel(); + if (candidate.status < 400) + response.headers.set("Content-Type", "text/markdown; charset=utf-8"); + } else { + await candidate.body?.cancel(); + } + return advertiseMarkdown(response, markdown.pathname); + }, +}; diff --git a/website/worker.test.mjs b/website/worker.test.mjs new file mode 100644 index 00000000000..490e8205a9d --- /dev/null +++ b/website/worker.test.mjs @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import worker from "./worker.mjs"; + +function assets({ markdownStatus = 200 } = {}) { + return { + ASSETS: { + async fetch(request) { + const url = new URL(request.url); + const markdown = url.pathname.endsWith("index.md"); + if (url.pathname === "/missing/") + return new Response("Missing", { status: 404 }); + if (url.pathname === "/guide") + return Response.redirect(`${url.origin}/guide/`, 307); + const headers = { + "Content-Type": markdown ? "text/plain" : "text/html", + ETag: markdown ? '"markdown"' : '"html"', + Link: '; rel="ard"', + }; + const status = + request.headers.get("If-None-Match") === headers.ETag + ? 304 + : markdown + ? markdownStatus + : 200; + return new Response( + request.method === "HEAD" || status === 304 + ? null + : markdown + ? "# Guide" + : "

Guide

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

Guide

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

Guide

"); + for (const [path, status] of [ + ["/guide", 307], + ["/missing/", 404], + ]) { + const response = await worker.fetch( + new Request(`https://rea.tools${path}`, { + headers: { Accept: "text/markdown" }, + }), + assets(), + ); + assert.equal(response.status, status); + } +}); diff --git a/website/wrangler.toml b/website/wrangler.toml index cb3f21b3865..7c64886b300 100644 --- a/website/wrangler.toml +++ b/website/wrangler.toml @@ -1,18 +1,21 @@ # Serve only the public website. The default deployment is a workers.dev preview. name = "rea-website-preview" +main = "worker.mjs" compatibility_date = "2026-10-08" workers_dev = true preview_urls = false [assets] directory = "./_site" +binding = "ASSETS" +run_worker_first = ["/", "/*/", "/*.html"] html_handling = "auto-trailing-slash" not_found_handling = "none" [build] cwd = ".." command = "python3 scripts/website.py check" -watch_dir = ["./public", "./locales", "./templates"] +watch_dir = ["./public", "./locales", "./templates", "../.agents/skills/reverse-engineer-anything"] # Public HTTPS depends on zone activation and certificate readiness. [env.production] From 6442ea02cbdabd8cbaa4106bdbe934ea07d0dcef Mon Sep 17 00:00:00 2001 From: JANG BYUNGKUN Date: Tue, 3 Nov 2026 10:37:41 +0900 Subject: [PATCH 16/38] feat(binary-layout): admit ELF64 AArch64 and name the refused host (#8711) * feat(binary-layout): admit ELF64 AArch64 and name the refused host inspect_binary_layout refused every AArch64 ELF, and on a non-x64 host the refusal read "supports Linux x64 only", which looked like a target refusal (#8709 ran on arm64 WSL and suspected an e_machine check). The pwntools adapter now admits little-endian ELF64 AArch64 EXEC/DYN/REL objects alongside the existing profiles, pwntools must report aarch64/64/ little, and the Evidence subject names arm64. The host refusal now names the detected platform/arch and says the target was not read; the host check itself is unchanged. verify:binary:layout cross-compiles an AArch64 executable and relocatable object (never executed) and checks sections, symbols and relocations against GNU readelf, decoding ELF64 r_info as a 32-bit type under a 32-bit symbol index. The real-binary-layout workflow installs the toolchain. Refs #8709 * test(binary-layout): refuse RISC-V as the unsupported machine and assert the host refusal path verify:binary:layout used e_machine 183 (AArch64) as its unsupported target, which this branch now admits; use EM_RISCV (243) instead. The unsupported-host provider test now asserts the refusal names this host's platform/arch and says the target was not read, so reverting the decoder to the old message fails it. --- .github/workflows/real-binary-layout.yml | 6 +-- bridge/pwntools/layout_impl.py | 8 ++-- docs/binary-diagnostics.md | 8 ++-- docs/testing-lanes.md | 6 +-- scripts/verify-binary-layout.mjs | 37 +++++++++++-------- .../BinaryLayoutService.test.ts | 1 + .../binaryDiagnostics/BinaryLayoutService.ts | 1 + .../native/binaryDiagnosticsToolContracts.ts | 2 +- src/domain/native/binaryLayout.test.ts | 5 ++- src/domain/native/binaryLayout.ts | 2 +- src/native/pwntools/PwntoolsDecoder.ts | 19 +++++++--- src/native/pwntools/PwntoolsFailures.test.ts | 12 ++++++ .../pwntoolsLayoutProvider.test.ts | 5 +++ 13 files changed, 75 insertions(+), 37 deletions(-) diff --git a/.github/workflows/real-binary-layout.yml b/.github/workflows/real-binary-layout.yml index a8d84a2234a..e312baa5af2 100644 --- a/.github/workflows/real-binary-layout.yml +++ b/.github/workflows/real-binary-layout.yml @@ -55,12 +55,12 @@ jobs: - uses: ./.github/actions/portable-build-cache with: build_cache_artifact: ${{ inputs.build_cache_artifact }} - - name: Install ELF32 cross toolchains for source-owned fixtures + - name: Install AArch64 and ELF32 cross toolchains for source-owned fixtures run: | sudo apt-get update sudo apt-get install --yes --no-install-recommends \ - gcc-i686-linux-gnu gcc-arm-linux-gnueabi gcc-mips-linux-gnu gcc-mipsel-linux-gnu \ - libc6-dev-i386-cross libc6-dev-armel-cross libc6-dev-mips-cross libc6-dev-mipsel-cross + gcc-aarch64-linux-gnu gcc-i686-linux-gnu gcc-arm-linux-gnueabi gcc-mips-linux-gnu gcc-mipsel-linux-gnu \ + libc6-dev-arm64-cross libc6-dev-i386-cross libc6-dev-armel-cross libc6-dev-mips-cross libc6-dev-mipsel-cross - name: Acquire exact upstream libraries in an isolated runner environment shell: bash run: | diff --git a/bridge/pwntools/layout_impl.py b/bridge/pwntools/layout_impl.py index 86ec3f623ba..a9c112fb70d 100644 --- a/bridge/pwntools/layout_impl.py +++ b/bridge/pwntools/layout_impl.py @@ -16,10 +16,10 @@ PACKAGES = {"pwntools": "4.15.0", "pyelftools": "0.33", "unicorn": "2.1.2"} OUTPUT_BYTES = 64 * 1024 * 1024 # (e_machine, ELF class bits, byte order) -> architecture pwntools must also report. -PROFILES = {(62, 64, "little"): "amd64", (3, 32, "little"): "i386", (40, 32, "little"): "arm", - (8, 32, "little"): "mips", (8, 32, "big"): "mips"} -PROFILE_SCOPE = ("Layout profile requires ELF64 x86-64, ELF32 i386, ELF32 ARM little-endian " - "or ELF32 MIPS little- or big-endian objects.") +PROFILES = {(62, 64, "little"): "amd64", (183, 64, "little"): "aarch64", (3, 32, "little"): "i386", + (40, 32, "little"): "arm", (8, 32, "little"): "mips", (8, 32, "big"): "mips"} +PROFILE_SCOPE = ("Layout profile requires ELF64 x86-64, ELF64 AArch64 little-endian, ELF32 i386, " + "ELF32 ARM little-endian or ELF32 MIPS little- or big-endian objects.") class LayoutFailure(Exception): diff --git a/docs/binary-diagnostics.md b/docs/binary-diagnostics.md index 56427673afc..cf074b10c3f 100644 --- a/docs/binary-diagnostics.md +++ b/docs/binary-diagnostics.md @@ -9,9 +9,11 @@ ordinary binary inspection use the same modular capability. ## Bring your own engine Real verification covers **Linux x64** hosts and **EXEC/DYN/REL** objects in -these profiles: ELF64 x86-64, ELF32 i386, ELF32 little-endian ARM and ELF32 -little- or big-endian MIPS. Other machines, classes and byte orders, including -AArch64 and big-endian ARM, return `unsupported_target`. Provide an absolute +these profiles: ELF64 x86-64, ELF64 little-endian AArch64, ELF32 i386, ELF32 +little-endian ARM and ELF32 little- or big-endian MIPS. Other machines, classes +and byte orders, including big-endian ARM, return `unsupported_target`. Other +hosts, including Linux arm64, return `capability_unavailable` naming the +detected host before any target is read. Provide an absolute Python executable whose environment already contains unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2: diff --git a/docs/testing-lanes.md b/docs/testing-lanes.md index 5e54caf8a28..4aea0bebc09 100644 --- a/docs/testing-lanes.md +++ b/docs/testing-lanes.md @@ -1108,13 +1108,13 @@ coverage limits. ### Offline binary layout `npm run verify:binary:layout` requires Linux x64, GCC/binutils, the -`i686-linux-gnu`, `arm-linux-gnueabi`, `mips-linux-gnu` and `mipsel-linux-gnu` -cross `gcc`/`readelf` toolchains, absolute +`aarch64-linux-gnu`, `i686-linux-gnu`, `arm-linux-gnueabi`, `mips-linux-gnu` and +`mipsel-linux-gnu` cross `gcc`/`readelf` toolchains, absolute `REA_PWNTOOLS_PYTHON` with pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2 and absolute `REA_VERIFY_STRACE_COMMAND`. It compiles ephemeral source-owned ELF fixtures and checks public CLI/MCP, lossless addresses/names, file ranges, mitigation inferences, malformed/unsupported input, original file hashes and -released process ownership. Cross-compiled ELF32 executables and relocatable +released process ownership. Cross-compiled ELF64 AArch64 and ELF32 executables and relocatable objects check the reported class, machine and byte order, and every section name, address, offset and size against GNU `readelf`. Exec syscall tracing must identify only the declared Node/Python launchers; no target binary is executed. Core/debugger claims need diff --git a/scripts/verify-binary-layout.mjs b/scripts/verify-binary-layout.mjs index 64a1b430f4a..428daf53f52 100644 --- a/scripts/verify-binary-layout.mjs +++ b/scripts/verify-binary-layout.mjs @@ -38,15 +38,16 @@ if ( !isAbsolute(strace ?? "") ) throw new Error( - "verify:binary:layout requires Linux x64, absolute REA_PWNTOOLS_PYTHON (pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2) and REA_VERIFY_STRACE_COMMAND. gcc, ld, strip and the i686/ARM/MIPS/MIPSel linux-gnu cross gcc and readelf must be available. No target is executed.", + "verify:binary:layout requires Linux x64, absolute REA_PWNTOOLS_PYTHON (pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2) and REA_VERIFY_STRACE_COMMAND. gcc, ld, strip and the AArch64/i686/ARM/MIPS/MIPSel linux-gnu cross gcc and readelf must be available. No target is executed.", ); const run = createVerifierRun(); const execute = promisify(execFile); -const ELF32_TOOLCHAINS = [ - ["i686-linux-gnu", "EM_386", "little"], - ["arm-linux-gnueabi", "EM_ARM", "little"], - ["mips-linux-gnu", "EM_MIPS", "big"], - ["mipsel-linux-gnu", "EM_MIPS", "little"], +const CROSS_TOOLCHAINS = [ + ["aarch64-linux-gnu", "EM_AARCH64", 64, "little"], + ["i686-linux-gnu", "EM_386", 32, "little"], + ["arm-linux-gnueabi", "EM_ARM", 32, "little"], + ["mips-linux-gnu", "EM_MIPS", 32, "big"], + ["mipsel-linux-gnu", "EM_MIPS", 32, "little"], ]; for (const command of [ "gcc", @@ -54,7 +55,7 @@ for (const command of [ "strip", "/usr/bin/prlimit", strace, - ...ELF32_TOOLCHAINS.flatMap(([prefix]) => [ + ...CROSS_TOOLCHAINS.flatMap(([prefix]) => [ `${prefix}-gcc`, `${prefix}-readelf`, ]), @@ -347,14 +348,15 @@ try { cases++; } } - // ELF32 profiles: cross-compiled, never executed, checked against GNU readelf. + // Cross profiles: cross-compiled, never executed, checked against GNU readelf. for (const [ prefix, machine, + bits, byteOrder, kind, flags, - ] of ELF32_TOOLCHAINS.flatMap((toolchain) => [ + ] of CROSS_TOOLCHAINS.flatMap((toolchain) => [ [...toolchain, "executable", ["-fPIE", "-pie"]], [...toolchain, "relocatable", ["-c", "-fPIC"]], ])) { @@ -423,16 +425,18 @@ try { `readelf relocation count: ${name}`, ); for (const [entryIndex, [, offset, info]] of rows.entries()) { - // ELF32 r_info uses the low byte for type and the upper 24 bits for - // the symbol index. readelf has already decoded the file's byte order. + // ELF32 r_info packs an 8-bit type under a 24-bit symbol index; + // ELF64 packs a 32-bit type under a 32-bit index. readelf has already + // decoded the file's byte order. const decodedInfo = BigInt(`0x${info}`); + const typeBits = bits === 32 ? 8n : 32n; expectedRelocations.push({ section_index: section.index, entry_index: entryIndex, reported_offset: BigInt(`0x${offset}`), - type: Number(decodedInfo & 0xffn), + type: Number(decodedInfo & ((1n << typeBits) - 1n)), symbol_table_index: section.link, - symbol_index: Number(decodedInfo >> 8n), + symbol_index: Number(decodedInfo >> typeBits), }); } } @@ -445,7 +449,7 @@ try { const value = await inspect(mode, path); assert.deepEqual(value.architecture, { machine, - bits: 32, + bits, byte_order: byteOrder, }); assert.equal( @@ -1312,7 +1316,8 @@ try { } } } - unsupported.writeUInt16LE(183, 18); + // EM_RISCV: AArch64 (183) is now an admitted profile. + unsupported.writeUInt16LE(243, 18); const core = Buffer.from(object); core.writeUInt16LE(4, 16); for (const [name, bytes, category] of [ @@ -1337,7 +1342,7 @@ try { "invalid_input", ], ["undersized-program-header", undersizedProgramHeader, "invalid_input"], - ["arm64", unsupported, "unsupported_target"], + ["riscv64", unsupported, "unsupported_target"], ["core", core, "unsupported_target"], ]) { const path = join(root.path, name); diff --git a/src/application/binaryDiagnostics/BinaryLayoutService.test.ts b/src/application/binaryDiagnostics/BinaryLayoutService.test.ts index f8a4c6b8784..c087a83a643 100644 --- a/src/application/binaryDiagnostics/BinaryLayoutService.test.ts +++ b/src/application/binaryDiagnostics/BinaryLayoutService.test.ts @@ -58,6 +58,7 @@ it("records the decoded layout once in its Evidence", async () => { it.each([ [{ machine: "EM_X86_64", bits: 64, byte_order: "little" }, "x86_64"], + [{ machine: "EM_AARCH64", bits: 64, byte_order: "little" }, "arm64"], [{ machine: "EM_386", bits: 32, byte_order: "little" }, "x86"], [{ machine: "EM_ARM", bits: 32, byte_order: "little" }, "arm"], [{ machine: "EM_MIPS", bits: 32, byte_order: "big" }, "mips"], diff --git a/src/application/binaryDiagnostics/BinaryLayoutService.ts b/src/application/binaryDiagnostics/BinaryLayoutService.ts index 87dd9723b66..757ab7eb6b3 100644 --- a/src/application/binaryDiagnostics/BinaryLayoutService.ts +++ b/src/application/binaryDiagnostics/BinaryLayoutService.ts @@ -14,6 +14,7 @@ import { parseAnalysisInput } from "../AnalysisInput.js"; const OPERATION = "inspect_binary_layout"; const EVIDENCE_ARCHITECTURES = { EM_X86_64: "x86_64", + EM_AARCH64: "arm64", EM_386: "x86", EM_ARM: "arm", EM_MIPS: "mips", diff --git a/src/contracts/native/binaryDiagnosticsToolContracts.ts b/src/contracts/native/binaryDiagnosticsToolContracts.ts index eba10d04fa3..7bc97ecbc4e 100644 --- a/src/contracts/native/binaryDiagnosticsToolContracts.ts +++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts @@ -245,7 +245,7 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_binary_layout"), kind: "native-provider", description: - "Inspect an explicit local binary without launching it as a host process. Returns file-backed section/segment ranges, linked addresses, original symbol/REL/RELA table identities, complete encoded RELR tables and upstream-derived offsets with unknown per-word locations/addends, raw name bytes, dependency/interpreter names and static mitigation inferences inline with artifact SHA-256 Evidence. Profile: EXEC/DYN/REL ELF64 x86-64, ELF32 i386, ELF32 little-endian ARM and ELF32 little- or big-endian MIPS on Linux x64 via caller-supplied Python with unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2. Runtime addresses/library paths, overall relocation inventory completeness and complete derived GOT/PLT coverage remain unknown. Zero executable entry values mean absence; relocatable entries are not applicable. Upstream can emulate PLT instructions with Unicorn for derived static maps. Uses bounded complete output and owned temporary files/processes; malformed, unsupported or oversized objects return no partial success. Select detail summary to retain the complete layout Evidence in this session and receive only its summary view, then inspect sections, symbols, mitigations or linkage with inspect_analysis_view. pwntools_timeout_ms is each owned pwntools command's deadline (omitted means 30000 ms, range 1..2147483647, next call). cpuSeconds 30 and the output, diagnostic, and address-space byte allowances have no runtime override.", + "Inspect an explicit local binary without launching it as a host process. Returns file-backed section/segment ranges, linked addresses, original symbol/REL/RELA table identities, complete encoded RELR tables and upstream-derived offsets with unknown per-word locations/addends, raw name bytes, dependency/interpreter names and static mitigation inferences inline with artifact SHA-256 Evidence. Profile: EXEC/DYN/REL ELF64 x86-64, ELF64 little-endian AArch64, ELF32 i386, ELF32 little-endian ARM and ELF32 little- or big-endian MIPS on Linux x64 via caller-supplied Python with unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2. Runtime addresses/library paths, overall relocation inventory completeness and complete derived GOT/PLT coverage remain unknown. Zero executable entry values mean absence; relocatable entries are not applicable. Upstream can emulate PLT instructions with Unicorn for derived static maps. Uses bounded complete output and owned temporary files/processes; malformed, unsupported or oversized objects return no partial success. Select detail summary to retain the complete layout Evidence in this session and receive only its summary view, then inspect sections, symbols, mitigations or linkage with inspect_analysis_view. pwntools_timeout_ms is each owned pwntools command's deadline (omitted means 30000 ms, range 1..2147483647, next call). cpuSeconds 30 and the output, diagnostic, and address-space byte allowances have no runtime override.", inputSchema: inspectBinaryLayoutRequestSchema, outputSchema: analysisDetailOutputSchemaOf( binaryLayoutSchema, diff --git a/src/domain/native/binaryLayout.test.ts b/src/domain/native/binaryLayout.test.ts index 172c0c0e993..1d4e8a9410d 100644 --- a/src/domain/native/binaryLayout.test.ts +++ b/src/domain/native/binaryLayout.test.ts @@ -122,6 +122,9 @@ it("retains high linked values and NOBITS without inventing file backing", () => }); it.each([ + [{ machine: "EM_AARCH64", bits: 64, byte_order: "little" }, true], + [{ machine: "EM_AARCH64", bits: 64, byte_order: "big" }, false], + [{ machine: "EM_AARCH64", bits: 32, byte_order: "little" }, false], [{ machine: "EM_386", bits: 32, byte_order: "little" }, true], [{ machine: "EM_ARM", bits: 32, byte_order: "little" }, true], [{ machine: "EM_MIPS", bits: 32, byte_order: "little" }, true], @@ -150,7 +153,7 @@ it.each([ if (problem === "file-backed-outside") section.file_backing = "file"; if (problem === "header-outside") section.header_location.offset = "0x80"; if (problem === "wrong-index") section.index = 1; - if (problem === "wrong-machine") value.architecture.machine = "EM_AARCH64"; + if (problem === "wrong-machine") value.architecture.machine = "EM_RISCV"; if (problem === "outside-elf64") value.entry_point.reported_value = "0x10000000000000000"; if (problem === "noncanonical-base64") section.name.bytes_base64 = "AR=="; diff --git a/src/domain/native/binaryLayout.ts b/src/domain/native/binaryLayout.ts index c60a728d511..d80c9ca4ca1 100644 --- a/src/domain/native/binaryLayout.ts +++ b/src/domain/native/binaryLayout.ts @@ -35,7 +35,7 @@ const binaryLayoutObjectSchema = z.strictObject({ format: z.literal("elf"), architecture: z.union([ z.strictObject({ - machine: z.literal("EM_X86_64"), + machine: z.enum(["EM_X86_64", "EM_AARCH64"]), bits: z.literal(64), byte_order: z.literal("little"), }), diff --git a/src/native/pwntools/PwntoolsDecoder.ts b/src/native/pwntools/PwntoolsDecoder.ts index 60b8bc967ad..5bfbc1ea1fd 100644 --- a/src/native/pwntools/PwntoolsDecoder.ts +++ b/src/native/pwntools/PwntoolsDecoder.ts @@ -69,8 +69,12 @@ import { PWNTOOLS_LIMITS } from "./PwntoolsRelease.js"; import { PWNTOOLS_PROVIDER_IDENTITY } from "./PwntoolsProviderIdentity.js"; import { pwntoolsInputBytes } from "./PwntoolsInputLimit.js"; -const HOST_REQUIREMENT = - "The initial real-verified pwntools artifact profile supports Linux x64 only."; +/** Names the detected host so a host refusal is not read as a target refusal. */ +export const pwntoolsHostRequirement = ( + platform: NodeJS.Platform, + arch: string, +): string => + `The real-verified pwntools artifact profile runs only on a Linux x64 host; this host is ${platform}/${arch}. The selected target was not read.`; /** The pwntools profile runs only on Linux x64; discovery and execution share this check. */ export const pwntoolsHostSupported = ( @@ -290,13 +294,18 @@ export class PwntoolsDecoder< throw new AnalysisCancelledError(OPERATION, { cause: options.signal.reason, }); - if (!pwntoolsHostSupported(process.platform, process.arch)) + if (!pwntoolsHostSupported(process.platform, process.arch)) { + const requirement = pwntoolsHostRequirement( + process.platform, + process.arch, + ); throw new AnalysisCapabilityUnavailableError( this.identity.id, OPERATION, - HOST_REQUIREMENT, - { userMessage: HOST_REQUIREMENT }, + requirement, + { userMessage: requirement }, ); + } if (!isAbsolute(selectedPath)) throw pwntoolsUnavailable( "Set REA_PWNTOOLS_PYTHON to an absolute caller-supplied Python executable with pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2; REA never installs Python or packages.", diff --git a/src/native/pwntools/PwntoolsFailures.test.ts b/src/native/pwntools/PwntoolsFailures.test.ts index bec9ac4be39..fb3840b0666 100644 --- a/src/native/pwntools/PwntoolsFailures.test.ts +++ b/src/native/pwntools/PwntoolsFailures.test.ts @@ -2,6 +2,10 @@ import { expect, it } from "vitest"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { OwnedCommandFailure } from "../../process/OwnedCommand.js"; +import { + pwntoolsHostRequirement, + pwntoolsHostSupported, +} from "./PwntoolsDecoder.js"; import { pwntoolsDecoderFailure } from "./PwntoolsFailures.js"; it.each([undefined, null, false, 0, "stop", { stop: true }, new Error("stop")])( @@ -149,3 +153,11 @@ it("retains legacy owner causes without guessing cancellation from error text", "ProviderAdapterError", ); }); + +it("names the refused host instead of implying the target was refused", () => { + expect(pwntoolsHostSupported("linux", "arm64")).toBe(false); + const message = pwntoolsHostRequirement("linux", "arm64"); + expect(message).toContain("Linux x64 host"); + expect(message).toContain("this host is linux/arm64"); + expect(message).toContain("The selected target was not read."); +}); diff --git a/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts b/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts index 1f2156ce19d..b1e816758bb 100644 --- a/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts +++ b/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts @@ -677,6 +677,11 @@ it.runIf(unsupportedHost)( const projected = projectAnalysisError(result.error); expect(projected.message).toContain("Linux x64"); expect(projected.remediation.action).toContain("Linux x64"); + // The refusal names this host, not the target, which was never read. + expect(projected.message).toContain( + `this host is ${process.platform}/${process.arch}`, + ); + expect(projected.message).toContain("The selected target was not read."); }, ); From b7911b2d30b9612969390e2d6a7bcbd7a24e67bf Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Tue, 3 Nov 2026 09:38:01 +0800 Subject: [PATCH 17/38] fix(evm): explain limiter configuration and access failures (#8669) --- docs/evm-bytecode.md | 9 ++ src/evm/EvmoleInterfaceProvider.ts | 33 +++++- .../evm/evmLimiterAvailability.test.ts | 108 +++++++++++++----- .../evm/evmoleInterfaceProvider.test.ts | 2 +- 4 files changed, 121 insertions(+), 31 deletions(-) diff --git a/docs/evm-bytecode.md b/docs/evm-bytecode.md index 0b4d97bd65b..e215f0028e0 100644 --- a/docs/evm-bytecode.md +++ b/docs/evm-bytecode.md @@ -28,6 +28,15 @@ The host must already provide util-linux `prlimit`, normally `/usr/bin/prlimit`; no system tools and changes no user configuration. [Upstream provenance and license](https://github.com/morluto/rea/blob/main/third_party/evmole/README.md) are packaged. +Limiter discovery validates the selected path without launching it. A relative +path or directory is reported as `not_configured`, a missing file as +`executable_missing`, and denied access or another filesystem-probe failure as +`runtime_unavailable`. Recovery names the selected path and +`REA_EVM_PRLIMIT_COMMAND`; denied access asks for execute permission and +filesystem access. The original admission or system code stays in diagnostics. +An available filesystem probe does not verify the selected tool's version or +util-linux compatibility, and REA does not substitute another command. + ## Evidence semantics - `encoding` is required: raw bytes or UTF-8 hex, with optional `0x` prefix and diff --git a/src/evm/EvmoleInterfaceProvider.ts b/src/evm/EvmoleInterfaceProvider.ts index 2a72fef70d0..4dfa48ba8e0 100644 --- a/src/evm/EvmoleInterfaceProvider.ts +++ b/src/evm/EvmoleInterfaceProvider.ts @@ -1,4 +1,5 @@ import { EvmAdmissionError } from "./EvmAdmissionError.js"; +import type { ProviderRejectionCode } from "../domain/providerSelection.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { safeParseUtf8Json } from "../domain/safeJson.js"; @@ -115,6 +116,7 @@ type EvmLimiterSelection = { readonly diagnostics: Readonly>; }; type EvmLimiterUnavailable = EvmLimiterSelection & { + readonly code: ProviderRejectionCode; readonly reason: string; readonly cause: unknown; }; @@ -159,16 +161,39 @@ const selectEvmLimiter = async ( if (cause instanceof EvmAdmissionError) return err({ path, + code: "not_configured", diagnostics: { ...diagnostics, code: cause.code, ...cause.detail, }, - reason: cause.code, + reason: `Set REA_EVM_PRLIMIT_COMMAND to an absolute executable regular file for caller-supplied util-linux prlimit. The selected path ${path} ${cause.code === "prlimit_not_absolute" ? "is not absolute" : "is not a regular file"}.`, cause, }); - const reason = `Offline EVM interface inspection requires caller-supplied util-linux prlimit: ${path}; ${cause instanceof Error ? cause.message : String(cause)}`; - return err({ path, diagnostics, reason, cause }); + const systemCode = + cause instanceof Error && + "code" in cause && + typeof cause.code === "string" + ? cause.code + : undefined; + const permissionDenied = systemCode === "EACCES" || systemCode === "EPERM"; + const missing = systemCode === "ENOENT" || systemCode === "ENOTDIR"; + const recovery = permissionDenied + ? "Grant execute permission and filesystem access to the selected file, or set REA_EVM_PRLIMIT_COMMAND to an accessible absolute executable regular file for util-linux prlimit." + : missing + ? "Set REA_EVM_PRLIMIT_COMMAND to an existing absolute executable regular file for util-linux prlimit." + : "Check the selected file and filesystem access, or set REA_EVM_PRLIMIT_COMMAND to an accessible absolute executable regular file for util-linux prlimit."; + const reason = `Offline EVM interface inspection requires caller-supplied util-linux prlimit: ${path}; ${cause instanceof Error ? cause.message : String(cause)}. ${recovery}`; + return err({ + path, + code: missing ? "executable_missing" : "runtime_unavailable", + diagnostics: { + ...diagnostics, + ...(systemCode === undefined ? {} : { system_code: systemCode }), + }, + reason, + cause, + }); } }; @@ -193,7 +218,7 @@ export const evmLimiterAvailability = async ( } : { status: "unavailable", - code: "executable_missing", + code: selected.error.code, reason: selected.error.reason, // The limiter owner does not yet author a separate repair (#8508). remediation: selected.error.reason, diff --git a/tests/boundary/evm/evmLimiterAvailability.test.ts b/tests/boundary/evm/evmLimiterAvailability.test.ts index d8ad789adfc..76841465718 100644 --- a/tests/boundary/evm/evmLimiterAvailability.test.ts +++ b/tests/boundary/evm/evmLimiterAvailability.test.ts @@ -1,4 +1,7 @@ import { expect, it } from "vitest"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { withVerifierCleanup } from "../../../scripts/lib/verifier-cleanup.mjs"; import { buildCapabilityInventory } from "../../../src/application/CapabilityInventory.js"; @@ -41,33 +44,43 @@ it("probes the default limiter with executable and regular-file checks", async ( ]); }); -it("uses the exact absolute override and reports a failed executable check", async () => { - const limiter = "/fixture/util-linux/prlimit"; - const availability = await evmLimiterAvailability( - { REA_EVM_PRLIMIT_COMMAND: limiter }, - { - ...linux, - fs: { - access: async (path) => { - expect(path).toBe(limiter); - throw Object.assign(new Error("permission denied"), { - code: "EACCES", - }); - }, - stat: async () => { - throw new Error("stat must not run after access failure"); +it.each(["EACCES", "EPERM"])( + "uses the exact absolute override and reports denied access: %s", + async (code) => { + const limiter = "/fixture/util-linux/prlimit"; + const availability = await evmLimiterAvailability( + { REA_EVM_PRLIMIT_COMMAND: limiter }, + { + ...linux, + fs: { + access: async (path) => { + expect(path).toBe(limiter); + throw Object.assign(new Error("permission denied"), { + code, + }); + }, + stat: async () => { + throw new Error("stat must not run after access failure"); + }, }, }, - }, - ); + ); - expect(availability).toMatchObject({ - status: "unavailable", - code: "executable_missing", - reason: expect.stringContaining(limiter), - diagnostics: { limiter_path: limiter }, - }); -}); + expect(availability).toMatchObject({ + status: "unavailable", + code: "runtime_unavailable", + reason: expect.stringContaining(limiter), + diagnostics: { limiter_path: limiter, system_code: code }, + }); + if (availability === undefined) throw new Error("Expected limiter probe"); + const entry = buildCapabilityInventory(status, { + ...basePolicy, + evmLimiterAvailability: availability, + }).find(({ name }) => name === "inspect_evm_interface"); + expect(entry?.remediation).toContain("REA_EVM_PRLIMIT_COMMAND"); + expect(entry?.remediation).toContain("execute permission"); + }, +); it("rejects a relative override and non-file without probing an alternate command", async () => { const relative = await evmLimiterAvailability( @@ -84,7 +97,8 @@ it("rejects a relative override and non-file without probing an alternate comman ); expect(relative).toMatchObject({ status: "unavailable", - reason: "prlimit_not_absolute", + code: "not_configured", + reason: expect.stringContaining("absolute executable regular file"), diagnostics: { limiter_path: "prlimit", code: "prlimit_not_absolute", @@ -104,11 +118,53 @@ it("rejects a relative override and non-file without probing an alternate comman ); expect(directory).toMatchObject({ status: "unavailable", - reason: "prlimit_not_file", + code: "not_configured", + reason: expect.stringContaining("absolute executable regular file"), diagnostics: expect.objectContaining({ code: "prlimit_not_file" }), }); }); +it("delivers selected-path recovery from real filesystem probes to capability output", async () => { + const root = await createTestTempDirectory("rea-evm-limiter-recovery-"); + const executable = join(root, "caller-selected-limiter"); + await writeFile(executable, "not executed by the readiness probe", { + mode: 0o700, + }); + for (const [path, expectedCode, expectedReason] of [ + ["prlimit", "not_configured", "absolute executable regular file"], + [root, "not_configured", "absolute executable regular file"], + [ + join(root, "missing-limiter"), + "executable_missing", + "REA_EVM_PRLIMIT_COMMAND", + ], + [executable, null, null], + ] as const) { + const availability = await evmLimiterAvailability( + { REA_EVM_PRLIMIT_COMMAND: path }, + linux, + ); + expect(availability?.code).toBe(expectedCode); + expect(availability?.diagnostics.limiter_path).toBe(path); + if (availability === undefined) throw new Error("Expected limiter probe"); + const entry = buildCapabilityInventory(status, { + ...basePolicy, + evmLimiterAvailability: availability, + }).find(({ name }) => name === "inspect_evm_interface"); + if (expectedReason === null) { + expect(entry).toMatchObject({ available: true, remediation: null }); + } else { + expect(entry).toMatchObject({ + available: false, + reason: "provider_missing", + }); + expect(entry?.remediation).toContain(expectedReason); + expect(entry?.remediation).toContain(path); + expect(entry?.remediation).toContain("util-linux prlimit"); + } + } +}); + it("rechecks on each observation so inventory follows the selected executable's current state", async () => { let accessible = true; const fs = { diff --git a/tests/boundary/evm/evmoleInterfaceProvider.test.ts b/tests/boundary/evm/evmoleInterfaceProvider.test.ts index 9edd10309ee..1e98c384903 100644 --- a/tests/boundary/evm/evmoleInterfaceProvider.test.ts +++ b/tests/boundary/evm/evmoleInterfaceProvider.test.ts @@ -970,7 +970,7 @@ it.runIf(!unsupportedHost).each(["directory", "missing-interpreter"])( }); if (scenario === "directory") expect(result.error).toMatchObject({ - reason: "prlimit_not_file", + reason: expect.stringContaining("absolute executable regular file"), diagnostics: { limiter_path: limiter, code: "prlimit_not_file", From 22c493ad5c9d2fc9825e28ab0f6cf9fb1fdf0871 Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Tue, 3 Nov 2026 09:38:12 +0800 Subject: [PATCH 18/38] fix(native): preserve implementation file locations (#8667) fix(native): keep UI handler offsets distinct from method records --- docs/native-investigation.md | 5 + .../native/nativeInvestigationGraph.test.ts | 92 +++++++++++--- src/domain/native/nativeInvestigationGraph.ts | 3 +- .../mcp/nativeUiPartialCallRoutesMcp.test.ts | 115 +++++++++++++++++- 4 files changed, 192 insertions(+), 23 deletions(-) diff --git a/docs/native-investigation.md b/docs/native-investigation.md index abd1424ea56..83b414f5bd2 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -388,6 +388,11 @@ sites remain distinct. A static route does not establish runtime reachability. Swift/closure dispatch requires resolved static references; unavailable ABI forms remain unknown. +Encoded Objective-C method records retain their own locations in dispatch +Evidence. A handler node uses the implementation address with an unknown file +offset unless that function already has an independently located graph node; +the method-record offset does not locate the handler's code. + Incomplete reference kinds do not discard known typed calls or partial provider call references. A `provider_call` with `observed` resolution retains the provider-reported call site and endpoint without claiming generic direct, diff --git a/src/domain/native/nativeInvestigationGraph.test.ts b/src/domain/native/nativeInvestigationGraph.test.ts index 13cb25c0016..49754666352 100644 --- a/src/domain/native/nativeInvestigationGraph.test.ts +++ b/src/domain/native/nativeInvestigationGraph.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { objcSwiftMetadataSchema } from "./objcSwiftMetadata.js"; +import { + inspectNativeDispatchMetadata, + objcSwiftMetadataSchema, +} from "./objcSwiftMetadata.js"; import { nativeInvestigationGraphSchema, @@ -191,9 +194,18 @@ describe("native investigation graph traces", () => { }); describe("Interface Builder dispatch joins", () => { - it.each(["BuildViewController", "BuildView", "BuildButton", "BuildHandler"])( - "joins an action to its declared %s receiver class", - (className) => { + it.each([ + { + className: "BuildViewController", + recordOffset: 1944, + existingOffset: null, + }, + { className: "BuildView", recordOffset: 6040, existingOffset: null }, + { className: "BuildButton", recordOffset: null, existingOffset: null }, + { className: "BuildHandler", recordOffset: 1944, existingOffset: 1916 }, + ])( + "joins the declared $className without mixing record and implementation offsets", + ({ className, recordOffset, existingOffset }) => { const ui = buildInterfaceBuilderAnalysis({ targetSha256: "b".repeat(64), toolVersion: "test", @@ -221,38 +233,80 @@ describe("Interface Builder dispatch joins", () => { ], limits: interfaceBuilderLimitsSchema.parse({}), }); - const joined = joinInterfaceBuilderDispatch(ui.graph, { - objc_classes: [], - objc_protocols: [], - objc_categories: [], - swift_decls: [], - objc_ivars: [], - objc_protocol_records: [], + const recordLocation = { + address: recordOffset === null ? "0x1000" : "0x2000", + file_offset: recordOffset, + }; + const recordEvidence = [ + { + kind: recordOffset === null ? "symbol" : "binary_metadata", + description: "Explicit synthetic method-record location", + location: recordLocation, + artifact_path: "/synthetic/Fixture", + artifact_sha256: "b".repeat(64), + }, + ]; + const mappedEvidence = [ + { + ...recordEvidence[0], + kind: "reference", + description: "Explicit independently mapped handler", + location: { address: "0x1000", file_offset: existingOffset }, + }, + ]; + const inputGraph = + existingOffset === null + ? ui.graph + : nativeInvestigationGraphSchema.parse({ + ...ui.graph, + nodes: [ + ...ui.graph.nodes, + { + id: "native:function:0x1000", + kind: "function", + name: "Already mapped handler", + location: { address: "0x1000", file_offset: existingOffset }, + attributes: {}, + evidence: mappedEvidence, + }, + ], + }); + const metadata = objcSwiftMetadataSchema.parse({ + ...inspectNativeDispatchMetadata([], 1), objc_dispatch_implementations: [ { class_name: className, selector: "buildTapped:", method_type: "instance", implementation_address: "0x1000", - location: { address: "0x1000", file_offset: 0 }, + location: recordLocation, decode: { status: "decoded", reason: null }, - evidence, + evidence: recordEvidence, }, ], - swift_conformances: [], - swift_dispatch_slots: [], - swift_symbols: [], - relative_pointers: [], - coverage: [], - db_save_result: null, }); + const joined = joinInterfaceBuilderDispatch(inputGraph, metadata); expect(joined.edges).toContainEqual( expect.objectContaining({ relation: "objc_dispatch", resolution: "resolved", to: "native:function:0x1000", + evidence: expect.arrayContaining(recordEvidence), }), ); + const handler = joined.nodes.find( + ({ id }) => id === "native:function:0x1000", + ); + expect(handler?.location).toEqual({ + address: "0x1000", + file_offset: existingOffset, + }); + expect(handler?.evidence).toEqual( + existingOffset === null ? recordEvidence : mappedEvidence, + ); + expect(metadata.objc_dispatch_implementations[0]?.location).toEqual( + recordLocation, + ); }, ); }); diff --git a/src/domain/native/nativeInvestigationGraph.ts b/src/domain/native/nativeInvestigationGraph.ts index b51ad6f63e3..03c56bc7054 100644 --- a/src/domain/native/nativeInvestigationGraph.ts +++ b/src/domain/native/nativeInvestigationGraph.ts @@ -391,7 +391,8 @@ export const joinInterfaceBuilderDispatch = ( name: `${implementation.class_name} ${selector}`, location: { address: implementation.implementation_address, - file_offset: implementation.location.file_offset, + // The implementation location identifies the method record, not its code. + file_offset: null, }, attributes: { class_name: implementation.class_name, diff --git a/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts b/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts index 941253bf059..d0ce556ea17 100644 --- a/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts +++ b/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts @@ -14,6 +14,11 @@ import { } from "../../../src/domain/analysisErrorCore.js"; import { inspectNativeDispatchMetadata } from "../../../src/domain/native/objcSwiftMetadata.js"; import { nativeInvestigationTraceSchema } from "../../../src/domain/native/nativeInvestigationGraph.js"; +import { + buildInterfaceBuilderAnalysis, + interfaceBuilderLimitsSchema, +} from "../../../src/domain/apple/interfaceBuilderGraph.js"; +import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; import { err, ok, type Result } from "../../../src/domain/result.js"; import { createServer } from "../../../src/server/createServer.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; @@ -111,6 +116,12 @@ const noClassifiedReferences = { }; type Plan = { + readonly ui?: ReturnType; + readonly implementations?: ReturnType< + typeof inspectNativeDispatchMetadata + >["objc_dispatch_implementations"]; + readonly action?: string; + readonly maxDepth?: number; readonly references?: unknown; readonly referencesFailure?: AnalysisError; readonly uiFailure?: AnalysisError; @@ -142,6 +153,12 @@ function publicTrace(plan: Plan, maxNodes = 12, maxEdges = 500) { plan.referencesFailure !== undefined ) return err(plan.referencesFailure); + if (operation === "decode_interface_builder" && plan.ui !== undefined) + return ok( + createAnalysisExecution(jsonValueSchema.parse(plan.ui), identity, { + subject, + }), + ); if (operation === "decode_interface_builder") return err( new AnalysisCapabilityUnavailableError( @@ -157,7 +174,12 @@ function publicTrace(plan: Plan, maxNodes = 12, maxEdges = 500) { target_sha256: sha256, provider: identity, analysis_profile_digest: null, - result: inspectNativeDispatchMetadata([], 1), + result: { + ...inspectNativeDispatchMetadata([], 1), + ...(plan.implementations === undefined + ? {} + : { objc_dispatch_implementations: plan.implementations }), + }, }, identity, { subject }, @@ -220,10 +242,10 @@ function publicTrace(plan: Plan, maxNodes = 12, maxEdges = 500) { const result = await client.callTool({ name: "trace_native_ui_action", arguments: { - action: "0x1000", + action: plan.action ?? "0x1000", max_nodes: maxNodes, max_edges: maxEdges, - max_depth: 1, + max_depth: plan.maxDepth ?? 1, }, }); return result; @@ -237,6 +259,93 @@ const traceFrom = (result: { structuredContent?: unknown }) => }) .parse(result.structuredContent).normalized_result; +it.each([ + { label: "thin record", recordOffset: 1944 }, + { label: "selected-slice container record", recordOffset: 6040 }, + { label: "symbol-only record", recordOffset: null }, +])( + "keeps handler offsets distinct from $label Evidence through public MCP", + async ({ recordOffset }) => { + // Explicit synthetic decoded facts; no AppKit, Ghidra or Kuna runtime claim. + const ui = buildInterfaceBuilderAnalysis({ + targetSha256: sha256, + toolVersion: "synthetic", + documents: [ + { + relativePath: "Main.storyboardc/scene.nib/objects.nib", + archiveSha256: "b".repeat(64), + documentKind: "storyboard_scene", + raw: { + "com.apple.ibtool.document.objects": { + receiver: { customClass: "AuditController" }, + button: { class: "UIButton" }, + }, + "com.apple.ibtool.document.connections": { + button: [ + { + type: "action", + label: "runAction:", + destinationId: "receiver", + }, + ], + }, + }, + }, + ], + limits: interfaceBuilderLimitsSchema.parse({}), + }); + const recordLocation = { + address: recordOffset === null ? "0x10000077c" : "0x100000798", + file_offset: recordOffset, + }; + const recordEvidence = [ + { + kind: + recordOffset === null + ? ("symbol" as const) + : ("binary_metadata" as const), + description: "Explicit synthetic method entry", + location: recordLocation, + artifact_path: subject.path, + artifact_sha256: sha256, + }, + ]; + const response = await publicTrace({ + ui, + action: "runAction:", + maxDepth: 4, + references: completeReferences([]), + callees: [], + implementations: [ + { + class_name: "AuditController", + selector: "runAction:", + method_type: "instance", + implementation_address: "0x10000077c", + location: recordLocation, + decode: { status: "decoded", reason: null }, + evidence: recordEvidence, + }, + ], + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const trace = traceFrom(response); + const handler = trace.nodes.find( + ({ id }) => id === "native:function:0x10000077c", + ); + expect(handler?.location).toEqual({ + address: "0x10000077c", + file_offset: null, + }); + expect(handler?.evidence).toEqual(recordEvidence); + expect( + trace.edges.find(({ relation }) => relation === "objc_dispatch") + ?.evidence, + ).toEqual(expect.arrayContaining(recordEvidence)); + expect(recordEvidence[0]?.location).toEqual(recordLocation); + }, +); + it("keeps typed callsite evidence and distinct fallback-provider routes for mixed reference kinds", async () => { const response = await publicTrace({}); expect(response.isError).not.toBe(true); From 79766ca96a22ee0b6e04c3681546d492702b4ae7 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:41:33 +0800 Subject: [PATCH 19/38] fix(website): identify all public deployment verification requests --- .github/workflows/website-pages.yml | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/.github/workflows/website-pages.yml b/.github/workflows/website-pages.yml index 9bfa7b630e7..d4184a879ba 100644 --- a/.github/workflows/website-pages.yml +++ b/.github/workflows/website-pages.yml @@ -154,6 +154,15 @@ jobs: from urllib.parse import urljoin, urlsplit from urllib.request import Request, urlopen + def open_public(url, accept=None): + headers = { + "Cache-Control": "no-cache", + "User-Agent": "REA-Website-Verifier/1.0 (+https://github.com/morluto/rea)", + } + if accept: + headers["Accept"] = accept + return urlopen(Request(url, headers=headers), timeout=10) + expected = os.environ["GITHUB_SHA"] pages_url = os.environ["PAGES_URL"] if not pages_url: @@ -169,11 +178,7 @@ jobs: for name, base in list(pending.items()): url = urljoin(base, "deployment.json") + "?commit=" + expected try: - request = Request(url, headers={ - "Cache-Control": "no-cache", - "User-Agent": "REA-Website-Verifier/1.0 (+https://github.com/morluto/rea)", - }) - with urlopen(request, timeout=10) as response: + with open_public(url) as response: actual = json.load(response).get("commit") if actual == expected: print(f"{name} serves {expected}: {base}") @@ -189,21 +194,20 @@ jobs: if pending: sys.exit("Could not verify published revision on: " + ", ".join(pending)) for name, base in hosts.items(): - with urlopen(urljoin(base, ".well-known/agent-skills/index.json"), timeout=15) as response: + with open_public(urljoin(base, ".well-known/agent-skills/index.json")) as response: skill, = json.load(response)["skills"] # Fetch from each selected host, including the /rea/ mirror. artifact_url = urljoin(base, urlsplit(skill["url"]).path.lstrip("/")) - with urlopen(artifact_url, timeout=15) as response: + with open_public(artifact_url) as response: digest = "sha256:" + hashlib.sha256(response.read()).hexdigest() if digest != skill["digest"]: sys.exit(name + " serves a skill archive with the wrong digest") for manifest in ("ard.json", "ai-catalog.json"): - with urlopen(urljoin(base, ".well-known/" + manifest), timeout=15) as response: + with open_public(urljoin(base, ".well-known/" + manifest)) as response: if not json.load(response).get("entries"): sys.exit(name + " serves an empty discovery manifest") if name == "Cloudflare": - request = Request(base, headers={"Accept": "text/markdown"}) - with urlopen(request, timeout=15) as response: + with open_public(base, accept="text/markdown") as response: vary = {value.strip().lower() for value in response.headers.get("Vary", "").split(",")} if response.headers.get_content_type() != "text/markdown" or "accept" not in vary: sys.exit("Cloudflare did not negotiate Markdown with Vary: Accept") From 6259ac41a48cd79dc0b7815970ba00283dde5a48 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:49:49 +0800 Subject: [PATCH 20/38] fix(mcp): guide target recovery for scoped tools (#8659) * fix(mcp): guide target recovery for scoped tools * test(mcp): cover unscoped target recovery * fix(mcp): warn before scoped session restart * refactor(mcp): own scoped target recovery guidance * fix(mcp): include unknown history in restart guidance --- src/server/ToolResultDelivery.ts | 29 ++++++- src/server/createServer.ts | 9 +- src/server/recoveryInstructions.ts | 4 + .../mcp/targetContentFramingMcp.test.ts | 86 +++++++++++++++++++ 4 files changed, 123 insertions(+), 5 deletions(-) diff --git a/src/server/ToolResultDelivery.ts b/src/server/ToolResultDelivery.ts index 594ed43bcd7..3fce413975c 100644 --- a/src/server/ToolResultDelivery.ts +++ b/src/server/ToolResultDelivery.ts @@ -10,6 +10,7 @@ import { type AnalysisErrorProjection, } from "../domain/analysisErrorProjection.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { NoBinaryOpenError } from "../domain/configurationErrors.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import type { Result } from "../domain/result.js"; import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; @@ -104,12 +105,22 @@ export class ToolResultDelivery { selected?: JsonValue, viewSource?: JsonValue, ) => Readonly> = () => ({}), + private readonly noTargetRecovery?: string, ) { this.maximumResponseBytes = maximumResponseBytes; this.resultBudgetBytes = maximumResponseBytes - 1024; Object.freeze(this); } + /** Bind scope-specific recovery without mutating a shared delivery policy. */ + withNoTargetRecovery(recovery: string): ToolResultDelivery { + return new ToolResultDelivery( + this.maximumResponseBytes, + this.nativeContinuation, + recovery, + ); + } + /** Serialize a plain application result or its actionable error. */ toCallToolResult( result: Result, @@ -184,10 +195,20 @@ export class ToolResultDelivery { error: AnalysisError, targetContent: boolean, ): CallToolResult { - return this.projectedErrorResult( - projectAnalysisError(error), - targetContent, - ); + const projected = projectAnalysisError(error); + if ( + error instanceof NoBinaryOpenError && + this.noTargetRecovery !== undefined + ) + return this.projectedErrorResult( + { + ...projected, + message: this.noTargetRecovery, + remediation: { action: this.noTargetRecovery }, + }, + targetContent, + ); + return this.projectedErrorResult(projected, targetContent); } /** Retain valid partial Evidence and reference it only after recording succeeds. */ diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 030a94eede1..ee9227d0824 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -3,6 +3,7 @@ import { type CapabilityServiceOptions, } from "../composition/capabilityServices.js"; import { registerToolFamilies } from "../contracts/toolFamilyIds.js"; +import { SCOPED_NO_TARGET_RECOVERY } from "./recoveryInstructions.js"; import { AnalysisSettings } from "../application/AnalysisSettings.js"; import { parseMcpInputBudget } from "../config/mcpInputBudget.js"; import { registerAnalysisSettingsTools } from "./registerAnalysisSettingsTools.js"; @@ -174,7 +175,13 @@ export const createServer = ( options.providerEnvironment, environment, ); - const delivery = selectToolResultDelivery(environment, options.delivery); + const baseDelivery = selectToolResultDelivery(environment, options.delivery); + const delivery = + session !== undefined && + advertisedToolFamilies !== undefined && + !advertisedToolFamilies.has("session") + ? baseDelivery.withNoTargetRecovery(SCOPED_NO_TARGET_RECOVERY) + : baseDelivery; const capabilities = createCapabilityServices({ environment, providerEnvironment, diff --git a/src/server/recoveryInstructions.ts b/src/server/recoveryInstructions.ts index 60483ea3732..ee64745c24c 100644 --- a/src/server/recoveryInstructions.ts +++ b/src/server/recoveryInstructions.ts @@ -16,3 +16,7 @@ export const recoveryInstructions = ( runtimeSettingsAvailable: boolean, ): string => `${RECOVERY_START}${runtimeSettingsAvailable ? RUNTIME_SETTINGS_RECOVERY : LAUNCH_SETTINGS_RECOVERY}${EXPLICIT_ARGUMENT_RECOVERY}${RECOVERY_END}`; + +/** Recovery when a scoped session cannot advertise the target lifecycle. */ +export const SCOPED_NO_TARGET_RECOVERY = + "No target is open, and this MCP connection does not advertise open_binary. Preserve needed outputs before restarting; retained in-memory Evidence and Unknown history will not carry over. Add session to REA_MCP_TOOL_FAMILIES in the environment that starts REA and restart the server; then call open_binary with the chosen local path."; diff --git a/tests/boundary/mcp/targetContentFramingMcp.test.ts b/tests/boundary/mcp/targetContentFramingMcp.test.ts index 2b575e5368a..044509f6ab7 100644 --- a/tests/boundary/mcp/targetContentFramingMcp.test.ts +++ b/tests/boundary/mcp/targetContentFramingMcp.test.ts @@ -1,4 +1,8 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { serverInstructions } from "../../../src/server/serverInstructions.js"; +import { SCOPED_NO_TARGET_RECOVERY } from "../../../src/server/recoveryInstructions.js"; +import { NoBinaryOpenError } from "../../../src/domain/configurationErrors.js"; import { UNTRUSTED_CONTENT_INSTRUCTIONS } from "../../../src/server/toolResultEncoding.js"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import type { @@ -8,6 +12,8 @@ import type { import { afterEach, describe, expect, it } from "vitest"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { peExportImage } from "../../fixtures/binaryDiagnostics/nativeExports.js"; import { observed } from "../../fixtures/analysisExecution.js"; import { err, ok } from "../../../src/domain/result.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; @@ -272,3 +278,83 @@ describe.each([ }, ); }); + +it.each([ + { scope: "direct", sessionAvailable: false }, + { scope: "session,direct", sessionAvailable: true }, + { scope: undefined, sessionAvailable: true }, +])( + "guides a missing target using the advertised scope ($scope)", + async ({ scope, sessionAvailable }) => { + const environment = { ...process.env }; + if (scope === undefined) delete environment.REA_MCP_TOOL_FAMILIES; + else environment.REA_MCP_TOOL_FAMILIES = scope; + const client = await connect(environment); + const names = (await client.listTools()).tools.map(({ name }) => name); + expect(names.includes("open_binary")).toBe(sessionAvailable); + const { error } = parseMcpToolError( + await client.callTool({ + name: "read_bytes", + arguments: { address: "0x1000", length: 4 }, + }), + ); + expect(error.code).toBe("target_unavailable"); + if (sessionAvailable) { + expect(error.remediation.action).toBe(new NoBinaryOpenError().message); + } else { + expect(error.message).toBe(SCOPED_NO_TARGET_RECOVERY); + expect(error.remediation.action).toBe(SCOPED_NO_TARGET_RECOVERY); + expect(error.remediation.action).toContain("REA_MCP_TOOL_FAMILIES"); + expect(error.remediation.action).toContain("open_binary"); + await expect( + client.callTool({ name: "open_binary", arguments: { path: "/tmp/x" } }), + ).rejects.toThrow(/disabled/u); + } + }, +); + +it("warns that retained evidence does not survive the scoped restart", async () => { + const directory = await createTestTempDirectory("rea-scoped-retained-"); + const path = join(directory, "fixture.exe"); + await writeFile(path, peExportImage({})); + const before = await connect({ + ...process.env, + REA_MCP_TOOL_FAMILIES: "direct,native,application", + }); + const observed = await before.callTool({ + name: "inspect_native_exports", + arguments: { path, detail: "summary" }, + }); + expect(observed.isError).not.toBe(true); + const summary = observed.structuredContent as { + normalized_result: { parent_evidence_id: string }; + }; + const view = { + source: { + kind: "retained-evidence", + evidence_id: summary.normalized_result.parent_evidence_id, + }, + view: { kind: "native-exports", collection: "exports" }, + }; + expect( + (await before.callTool({ name: "inspect_analysis_view", arguments: view })) + .isError, + ).not.toBe(true); + const failure = parseMcpToolError( + await before.callTool({ + name: "read_bytes", + arguments: { address: "0x1000", length: 4 }, + }), + ); + expect(failure.error.remediation.action).toBe(SCOPED_NO_TARGET_RECOVERY); + await before.close(); + + const after = await connect({ + ...process.env, + REA_MCP_TOOL_FAMILIES: "session,direct,native,application", + }); + const missing = parseMcpToolError( + await after.callTool({ name: "inspect_analysis_view", arguments: view }), + ); + expect(missing.error.code).toBe("evidence_integrity_mismatch"); +}); From d496d803a4f50dc82248db0630042894c1136d00 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:50:10 +0800 Subject: [PATCH 21/38] fix(kuna): give actionable lookup recovery (#8656) * fix(kuna): give actionable lookup recovery * fix(kuna): keep lookup advice operation neutral * style(kuna): apply repository formatter --- src/kuna/KunaSessionClient.ts | 19 ++++++- .../providers/kuna/kunaAliasSearchMcp.test.ts | 50 ++++++++++++++++++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index f3640d91c9b..c6cae4b09fd 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -1057,6 +1057,9 @@ export class KunaSessionClient implements AnalysisClient { err( new AnalysisInputError(operation, { userMessage: kunaDataEntryMessage(entry, index.data.get(entry)!), + conformsToInputContract: true, + remediationAction: + "Inspect the data address with address_name or xrefs, or select a procedure entry from list_procedures. Do not repeat the unchanged call.", }), ); const selected = (entry: KunaInventoryFunction) => { @@ -1076,6 +1079,9 @@ export class KunaSessionClient implements AnalysisClient { return err( new AnalysisInputError(operation, { userMessage: `${address} is not a procedure entry Kuna found; list_procedures shows the entries it found.`, + conformsToInputContract: true, + remediationAction: + "Inspect list_procedures and select a discovered entry if it answers your question. An unlisted address is not proof of a function; REA cannot use it as a Kuna procedure in this session. Do not repeat the unchanged call.", }), ); return refused(entry) ? dataRefusal(entry) : selected(entry); @@ -1088,9 +1094,20 @@ export class KunaSessionClient implements AnalysisClient { return dataRefusal(named[0]); return err( new AnalysisInputError(operation, { + conformsToInputContract: true, + remediationAction: + matches.length === 0 && named.length === 0 + ? field === "procedure" + ? "Use search_procedures or list_procedures to inspect Kuna's discovered entries, then select a matching entry. Do not repeat the unchanged call." + : "Inspect the selected target for the intended address and use that address explicitly. Do not repeat the unchanged call." + : matches.length === 0 + ? "Inspect the data addresses with address_name or xrefs, or select a procedure entry from list_procedures. Do not repeat the unchanged call." + : "Select the intended entry by its exact address. Do not repeat the unchanged call.", userMessage: matches.length === 0 && named.length === 0 - ? `Kuna found no procedure named ${selector}; search_procedures finds similar names.` + ? field === "procedure" + ? `Kuna found no procedure named ${selector}; search_procedures finds similar names.` + : `Kuna found no inventory entry named ${selector}.` : matches.length === 0 ? `${selector} names ${String(named.length)} Kuna entries that lie in Mach-O data regions (${named.map(({ address }) => address).join(", ")}); they are data, not procedures.` : `${selector} names ${String(matches.length)} Kuna entries (${matches.map(({ address }) => address).join(", ")}); select one by address.`, diff --git a/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts b/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts index a633278d30c..329ee3c8b2d 100644 --- a/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts +++ b/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts @@ -201,7 +201,7 @@ test("traces a procedure found only by its alias through the composed feature tr test("omits a data entry an alias names while keeping the code entry that shares it", async ({ mcp, }) => { - const { search, observe } = await aliasSession( + const { client, search, observe } = await aliasSession( mcp, "rea-kuna-alias-macho-", { @@ -234,4 +234,52 @@ test("omits a data entry an alias names while keeping the code entry that shares ), ); expect(await search({ pattern: "global_count" })).toEqual([]); + const { error } = parseMcpToolError( + await client.callTool({ + name: "procedure_address", + arguments: { procedure: "global_count" }, + }), + ); + expect(error.recovery).toBeUndefined(); + expect(error.remediation.action).toContain("address_name"); +}); + +test("reports lookup failures with recovery for the selected target, not the input schema", async ({ + mcp, +}) => { + const { client } = await aliasSession(mcp, "rea-kuna-lookup-recovery-", { + functions: VERSIONED, + }); + for (const [procedure, guidance] of [ + ["0x1335", "list_procedures"], + ["absent_name", "search_procedures"], + ["audit_transform", "exact address"], + ]) { + const response = await client.callTool({ + name: "procedure_address", + arguments: { procedure }, + }); + const { error } = parseMcpToolError(response); + expect(error.code).toBe("invalid_request"); + expect(error.recovery).toBeUndefined(); + expect(error.remediation.action).toContain(guidance); + expect(error.remediation.action).not.toContain("input schema"); + } + const assemblyError = parseMcpToolError( + await client.callTool({ + name: "procedure_assembly", + arguments: { procedure: "0x1335" }, + }), + ).error; + expect(assemblyError.remediation.action).toContain("Kuna procedure"); + expect(assemblyError.remediation.action).not.toContain("decompile"); + const missingAddress = parseMcpToolError( + await client.callTool({ + name: "xrefs", + arguments: { address: "absent_name" }, + }), + ).error; + expect(missingAddress.recovery).toBeUndefined(); + expect(missingAddress.message).toContain("no inventory entry named"); + expect(missingAddress.remediation.action).toContain("address"); }); From 193e01abb15917cb4d1ac0f630645f526c02754c Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:10:11 +0800 Subject: [PATCH 22/38] fix(kuna): keep timeout recovery from changing workers (#8687) * fix(kuna): keep timeout recovery from changing workers * test(kuna): verify timeout recovery with real provider --- docs/testing-lanes.md | 5 +- scripts/verify-real-kuna.mjs | 78 ++++++++++++++++++- src/kuna/KunaSessionClient.ts | 2 +- .../providers/kuna/kunaProvider.test.ts | 2 +- 4 files changed, 81 insertions(+), 6 deletions(-) diff --git a/docs/testing-lanes.md b/docs/testing-lanes.md index 4aea0bebc09..f6ade796721 100644 --- a/docs/testing-lanes.md +++ b/docs/testing-lanes.md @@ -421,7 +421,10 @@ the first cold pseudocode answer comes from a single-function run or an export that already finished, assembly waits for and serves from that export, and pseudocode and both dossier facets match their recorded file spans and digests, a fresh server reuses the export without rebuilding it, staging is empty after -a completed export, and no owned process survives. Pass +a completed export, and no owned process survives. A separate forced 1 ms +project deadline checks that a timed-out inventory recommends raising the +project deadline without changing jobs; a fresh server retries with 60 seconds +and the same two-worker setting. Pass `--targets DIR --functions a,b` to add caller-built binaries laid out as `DIR//` beside `.stripped`; addresses come from the symbol-bearing build. `--compare-ghidra` with `GHIDRA_INSTALL_DIR` runs those diff --git a/scripts/verify-real-kuna.mjs b/scripts/verify-real-kuna.mjs index 13659746b8d..de799e3ddd6 100644 --- a/scripts/verify-real-kuna.mjs +++ b/scripts/verify-real-kuna.mjs @@ -28,7 +28,10 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { inspectKunaInstallation } from "../dist/kuna/KunaInstallation.js"; import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js"; import { AnalysisCleanupLease } from "../dist/artifacts/AnalysisCleanupLease.js"; -import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs"; +import { + requireMcpOperationResult, + requireMcpToolError, +} from "./lib/mcp-verifier-results.mjs"; import { verifyKunaReferences } from "./lib/kuna-reference-verifier.mjs"; import { nativeKunaStrings, @@ -172,7 +175,7 @@ const sampleTree = async (root) => { }; /** One MCP server with a selected provider, sampled while it runs. */ -const openServer = async (provider) => { +const openServer = async (provider, kunaSettings = {}) => { const owner = Symbol(provider.id); pendingServers.add(owner); const transport = new StdioClientTransport({ @@ -185,6 +188,7 @@ const openServer = async (provider) => { REA_PROCESS_RUN_ID: run.run_id, REA_KUNA_COMMAND: kuna, REA_KUNA_CACHE_DIR: cacheRoot, + ...kunaSettings, ...(process.env.GHIDRA_INSTALL_DIR === undefined ? {} : { GHIDRA_INSTALL_DIR: process.env.GHIDRA_INSTALL_DIR }), @@ -229,6 +233,13 @@ const openServer = async (provider) => { structured: result.structuredContent, }; }; + const callError = async (name, arguments_ = {}) => { + const result = await client.callTool( + { name, arguments: arguments_ }, + { timeout: 3_600_000 }, + ); + return requireMcpToolError(result); + }; const close = async (priorFailure) => { clearInterval(timer); await sample(); @@ -270,7 +281,59 @@ const openServer = async (provider) => { // A rejected connection/target still owns its transport and workspace. await close(cause); } - return { call, close }; + return { call, callError, close }; +}; + +/** Verify a forced inventory timeout can be retried without changing jobs. */ +const verifyProjectTimeoutRecovery = async (path, address) => { + const before = createHash("sha256") + .update(await readFile(path)) + .digest("hex"); + const settings = { + REA_KUNA_JOBS: "2", + REA_KUNA_TIMEOUT_MS: "60000", + REA_KUNA_PROJECT_TIMEOUT_MS: "1", + }; + const timed = await openServer({ path, id: "kuna" }, settings); + let timedFailure; + try { + const failure = await timed.callError("procedure_assembly", { + procedure: address, + }); + assert.equal(failure.code, "capability_unavailable"); + assert.match(failure.message, /kuna functions/u); + assert.match(failure.remediation.action, /REA_KUNA_PROJECT_TIMEOUT_MS/u); + assert.doesNotMatch(failure.remediation.action, /REA_KUNA_JOBS/u); + } catch (cause) { + timedFailure = cause; + } finally { + await timed.close(timedFailure); + } + const retry = await openServer( + { path, id: "kuna" }, + { ...settings, REA_KUNA_PROJECT_TIMEOUT_MS: "60000" }, + ); + let retryFailure; + try { + const assembly = await retry.call("procedure_assembly", { + procedure: address, + }); + assert.equal(typeof assembly.value, "string"); + assert.ok(assembly.value.length > 0); + assert.equal(assembly.structured.analysis_profile.parameters.jobs, 2); + } catch (cause) { + retryFailure = cause; + } finally { + await retry.close(retryFailure); + } + assert.equal( + createHash("sha256") + .update(await readFile(path)) + .digest("hex"), + before, + "Kuna timeout recovery changed the target", + ); + return { forced_deadline_ms: 1, retry_deadline_ms: 60000, jobs: 2 }; }; /** Count structuring and diagnostic markers in provider pseudocode. */ @@ -535,6 +598,7 @@ const verifyVariant = async ({ flavor, directory, symbols }, variant) => { }; const results = []; +let timeoutRecovery; const failures = []; let inspectionResourcesClean = true; try { @@ -596,6 +660,11 @@ try { }), }); + timeoutRecovery = await verifyProjectTimeoutRecovery( + built, + fixtureFunctions.get("rea_checksum"), + ); + // Caller-built matrix. for (const pair of callerPairs) for (const variant of [pair.file, `${pair.file}.stripped`]) @@ -650,6 +719,9 @@ const report = { platform: `${process.platform}/${process.arch}`, verifier_run: lifecycle, results, + ...(timeoutRecovery === undefined + ? {} + : { timeout_recovery: timeoutRecovery }), limitations: [ "Targets are analyzed statically; they are never executed.", "Memory is the sampled peak resident set of the MCP server process tree; CPU is its sampled cumulative time. A short-lived peak between 200 ms samples is not observed.", diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index c6cae4b09fd..04baa3eaa68 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -258,7 +258,7 @@ const exportUnavailable = ( "REA reads Kuna settings when it starts, so restart REA, then reopen the target."; const next = failure instanceof AnalysisTimeoutError - ? `To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above ${String(failure.timeoutMs)} ms or raise REA_KUNA_JOBS in the environment that starts REA. ${restart}` + ? `To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above ${String(failure.timeoutMs)} ms in the environment that starts REA. ${restart}` : failure instanceof AnalysisResourceConstraintError && failure.remediationAction !== undefined ? // A fixed buffer (`memory`) is no setting, so a restart cannot help. diff --git a/tests/boundary/providers/kuna/kunaProvider.test.ts b/tests/boundary/providers/kuna/kunaProvider.test.ts index 6ab5eb1d815..690b4fee9c7 100644 --- a/tests/boundary/providers/kuna/kunaProvider.test.ts +++ b/tests/boundary/providers/kuna/kunaProvider.test.ts @@ -899,7 +899,7 @@ it.each([ behavior: { project: "hang" } as const, environment: { REA_KUNA_PROJECT_TIMEOUT_MS: "300" }, cause: "AnalysisTimeoutError", - next: "To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above 300 ms or raise REA_KUNA_JOBS in the environment that starts REA. REA reads Kuna settings when it starts, so restart REA, then reopen the target.", + next: "To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above 300 ms in the environment that starts REA. REA reads Kuna settings when it starts, so restart REA, then reopen the target.", details: { timeout_ms: 300, captured_output: { truncated: expect.any(Boolean) }, From b912ec184ca2ecbd5b59d046187be5b4a59ca3c1 Mon Sep 17 00:00:00 2001 From: Lewis Wang Date: Tue, 3 Nov 2026 10:10:33 +0800 Subject: [PATCH 23/38] fix(javascript): accept retained empty endpoint seeds (#6462) Application analysis preserves the complete literal request endpoint, including the empty string, and the event seed already accepts a retained empty name. The endpoint seed still required a non-empty value, so trace-javascript-semantics refused to select a retained "" endpoint with invalid_request. The endpoint seed now accepts any string and keeps exact matching. Boundary-field seeds keep their minimum because boundary nodes are only produced from method names. Found while fixing #6458; the event half of that report landed in cf5e4f6fa. Co-authored-by: Claude Fable 5.1 --- .../javascriptSemanticQuerySchemas.ts | 4 +- .../javascriptEventPayload.test.ts | 61 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/domain/javascript/javascriptSemanticQuerySchemas.ts b/src/domain/javascript/javascriptSemanticQuerySchemas.ts index 6002e09c417..fa211e757d1 100644 --- a/src/domain/javascript/javascriptSemanticQuerySchemas.ts +++ b/src/domain/javascript/javascriptSemanticQuerySchemas.ts @@ -42,9 +42,11 @@ export const javaScriptSemanticQuerySeedSchema = z.discriminatedUnion("kind", [ kind: z.literal("property"), name: z.string(), }), + // Analysis preserves the complete literal endpoint, including the empty + // string, so the seed matches any retained value exactly, as event does. z.strictObject({ kind: z.literal("endpoint"), - value: z.string().min(1), + value: z.string(), }), z.strictObject({ kind: z.literal("event"), diff --git a/tests/acceptance/applications/javascriptEventPayload.test.ts b/tests/acceptance/applications/javascriptEventPayload.test.ts index c4689250de7..fbe3a9422ab 100644 --- a/tests/acceptance/applications/javascriptEventPayload.test.ts +++ b/tests/acceptance/applications/javascriptEventPayload.test.ts @@ -162,3 +162,64 @@ cliTest( }, 120_000, ); + +cliTest( + "traces a retained empty literal endpoint by its preserved value", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-empty-endpoint-seed-"); + await writeFile( + join(root, "main.js"), + 'export const load = async () => (await fetch("")).json();\n', + ); + const analyzed = await cli.run({ + arguments: [ + "analyze-javascript-application", + root, + "--artifact-format", + "directory", + "--json", + ], + timeoutMs: 60_000, + }); + expect(analyzed.exitCode, analyzed.stderr).toBe(0); + const application = toolContract( + "analyze_javascript_application", + ).outputSchema.parse(analyzed.json); + const graph = javascriptApplicationAnalysisResultSchema.parse( + application.normalized_result, + ).semantic_graph; + // Analysis keeps the complete literal endpoint, here the empty string. + const retained = new Set( + graph.nodes + .filter( + (node) => + (node.kind === "request" || node.kind === "response") && + node.properties.endpoint === "", + ) + .map((node) => node.node_id), + ); + expect(retained.size).toBeGreaterThan(0); + const request = join(root, "endpoint-query.json"); + await writeFile( + request, + JSON.stringify({ + application, + query: { + seed: { kind: "endpoint", value: "" }, + direction: "forward-influence", + }, + }), + ); + const traced = await cli.run({ + arguments: ["trace-javascript-semantics", request, "--json"], + timeoutMs: 60_000, + }); + expect(traced.exitCode, traced.stdout).toBe(0); + const trace = javaScriptSemanticTraceResultSchema.parse( + toolContract("trace_javascript_semantics").outputSchema.parse(traced.json) + .normalized_result, + ); + expect(new Set(trace.seed_node_ids)).toEqual(retained); + }, + 120_000, +); From 9ebbe67e05af08b567e215340da993c08dae378f Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:04:33 +0800 Subject: [PATCH 24/38] fix(ci): await website worker tests and format Wrangler config --- website/worker.test.mjs | 6 +++--- website/wrangler.toml | 7 ++++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/website/worker.test.mjs b/website/worker.test.mjs index 490e8205a9d..7c3ae74f9f5 100644 --- a/website/worker.test.mjs +++ b/website/worker.test.mjs @@ -36,7 +36,7 @@ function assets({ markdownStatus = 200 } = {}) { }; } -test("HTML is the default and explicit Accept preferences select Markdown", async () => { +await test("HTML is the default and explicit Accept preferences select Markdown", async () => { for (const [accept, markdown] of [ [null, false], ["*/*", false], @@ -75,7 +75,7 @@ test("HTML is the default and explicit Accept preferences select Markdown", asyn } }); -test("HEAD and validators use the selected representation", async () => { +await test("HEAD and validators use the selected representation", async () => { for (const method of ["GET", "HEAD"]) { const response = await worker.fetch( new Request("https://rea.tools/guide/", { @@ -104,7 +104,7 @@ test("HEAD and validators use the selected representation", async () => { } }); -test("missing Markdown keeps HTML, and asset redirects and missing routes survive", async () => { +await test("missing Markdown keeps HTML, and asset redirects and missing routes survive", async () => { const html = await worker.fetch( new Request("https://rea.tools/guide/", { headers: { Accept: "text/markdown" }, diff --git a/website/wrangler.toml b/website/wrangler.toml index 7c64886b300..649d31cfe12 100644 --- a/website/wrangler.toml +++ b/website/wrangler.toml @@ -15,7 +15,12 @@ not_found_handling = "none" [build] cwd = ".." command = "python3 scripts/website.py check" -watch_dir = ["./public", "./locales", "./templates", "../.agents/skills/reverse-engineer-anything"] +watch_dir = [ + "./public", + "./locales", + "./templates", + "../.agents/skills/reverse-engineer-anything", +] # Public HTTPS depends on zone activation and certificate readiness. [env.production] From d892e7861aea534bae6235407af72af9b9cdb88a Mon Sep 17 00:00:00 2001 From: Beqa <91750923+BeqaKs@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:45:03 +0400 Subject: [PATCH 25/38] fix(docs): drop duplicate native-inspection guide row (#7480) Remove the incomplete Choose a guide duplicate that omitted kernel-modules.md so the docs index keeps one complete native-inspection entry. Fixes #7405 --- docs/index.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 09a8845890f..bd9cbc36a52 100644 --- a/docs/index.md +++ b/docs/index.md @@ -51,7 +51,6 @@ For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md). | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) | | Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [PE TLS/load config](pe-load-config.md), [ELF notes](elf-notes.md), [Linux kernel modules](kernel-modules.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) | -| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [PE TLS/load config](pe-load-config.md), [ELF notes](elf-notes.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) | | Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [GameCube DOL](ghidra-dol.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [Kuna](kuna-provider.md), [JEB](jeb-analysis.md) | | Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) | | Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md), [Electron fuses](electron-fuses.md) | From f251009e0a30e35e881a3f3396cc9fd21d7a2259 Mon Sep 17 00:00:00 2001 From: Ha1baraA1 <243105435+Ha1baraA11@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:45:36 +0800 Subject: [PATCH 26/38] fix(artifacts): report required bytes for ASAR header limits (#7452) --- .../ArtifactGraphConstruction.test.ts | 41 +++++++++++++++++++ .../inventory/ArtifactGraphConstruction.ts | 5 ++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.test.ts b/src/artifacts/inventory/ArtifactGraphConstruction.test.ts index 3075f65f91c..f23e26223cf 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.test.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.test.ts @@ -14,6 +14,10 @@ import { resolveOccurrenceParents, } from "./ArtifactGraphConstruction.js"; import { createArtifactClassificationProbe } from "../ArtifactClassificationProbe.js"; +import { createAsarHeaderSignatureProbe } from "../AsarHeader.js"; +import { ArtifactReaderFailure } from "../ArtifactReader.js"; +import { translateArtifactFailure } from "../ArtifactOperationFailure.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { artifactIdForContent } from "../../domain/artifactIdentity.js"; import { pe } from "../../domain/binaryTarget.fixture.js"; import type { ArtifactEntry } from "../ArtifactReader.js"; @@ -26,6 +30,7 @@ import { } from "../../domain/firmware/uf2.fixture.js"; import { classifyArtifactBytesAtPath } from "../../../tests/fixtures/artifactClassification.js"; +import { asarHeaderBytes } from "../../../tests/fixtures/asarHeader.js"; import { ESP32C3_APP_SEGMENTS, esp8266Image, @@ -73,6 +78,42 @@ it("classifies original DOS header padding as an executable", () => { }); }); +it("projects the parsed ASAR header size when admission rejects an archive", () => { + const bytes = asarHeaderBytes("{}", { padding: Buffer.alloc(8, 0x20) }); + const headerBytes = bytes.readUInt32LE(4); + const probe = createAsarHeaderSignatureProbe({ + maximumBytes: headerBytes - 1, + source: "setting", + }); + probe.write(bytes); + const asarHeader = probe.finish(bytes.length); + expect(asarHeader).toMatchObject({ + state: "limit", + extents: { headerBytes }, + }); + + let failure: unknown; + try { + classifyArtifactContent( + "app.asar", + { prefix: bytes, asarHeader }, + bytes.length, + ); + } catch (cause) { + failure = cause; + } + expect(failure).toBeInstanceOf(ArtifactReaderFailure); + if (!(failure instanceof ArtifactReaderFailure)) + throw new Error("ASAR admission failure was not classified"); + + const projected = projectAnalysisError( + translateArtifactFailure("inspect_artifact", failure), + ); + expect(projected.details?.limit).toMatchObject({ + required_value: headerBytes, + }); +}); + const entry = (path: string, kind: ArtifactEntry["kind"]): ArtifactEntry => ({ path, kind, diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.ts b/src/artifacts/inventory/ArtifactGraphConstruction.ts index 75614bcb2c4..b0c77906f8a 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.ts @@ -557,7 +557,10 @@ export const classifyArtifactContent = ( asarHeader.state !== "rejected" ) { if (asarHeader.state === "limit") - throw asarHeaderAdmissionFailure(asarHeader.admission); + throw asarHeaderAdmissionFailure( + asarHeader.admission, + asarHeader.extents?.headerBytes, + ); if (asarHeader.state === "matched") role = { kind: "container", format: "asar" }; else role = artifactRoleForFormat(path, contentFormat); From 5ed283d1682ec2326ba2132168df590dcd3e6868 Mon Sep 17 00:00:00 2001 From: Jeremy Levartovsky <140487034+JayOfTheKeyboard@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:46:10 +1100 Subject: [PATCH 27/38] fix(har): accept Chrome DevTools bracketed IPv6 server addresses (#7225) Chrome DevTools HAR exports declare creator WebInspector 537.36 and report IPv6 serverIPAddress values in brackets: its HAR writer strips the port with lastIndexOf(':') but its bracket removal, replace(/\[\]/g, ''), only matches the literal pair '[]'. The pinned schema then rejects the whole archive at the first such entry. Extend the existing Playwright 1.58.2 adaptation to this declared producer. Brackets are removed only in the disposable validation copy and only around a valid IPv6 literal; reported spellings are retained and the per-entry limitation names the declared producer. --- docs/web-network-captures.md | 8 +++- src/browser/history/HarCapture.test.ts | 34 ++++++++++++++++ src/browser/history/HarCapture.ts | 17 ++++---- src/browser/history/HarSchema.ts | 54 +++++++++++++++++++------- 4 files changed, 89 insertions(+), 24 deletions(-) diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 1310b2d578d..ada3ab10fd0 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -97,9 +97,13 @@ it does not invent an empty string or body bytes. The observed Playwright 1.58.2 profile can report a bracketed IPv6 `serverIPAddress`, forwarded from Chromium's `remoteIPAddress` through [Playwright's HAR tracer](https://github.com/microsoft/playwright/blob/v1.58.2/packages/playwright-core/src/server/har/harTracer.ts). +Chromium DevTools exports (declared as `WebInspector` `537.36`, the frozen +AppleWebKit version in Chromium's user agent) report the same bracketed form: +[its HAR writer](https://github.com/ChromeDevTools/devtools-frontend/blob/63739e8ec45ca01b9e4be9b8b05946acdadd8f1f/front_end/models/har/Log.ts) +strips the port but not the brackets. REA preserves the original spelling in reported Evidence and removes brackets -only from its validation copy, only for this declared producer and a valid IPv6 -literal. Invalid addresses, bracketed IPv4, ports and zone identifiers remain +only from its validation copy, only for these declared producers and a valid +IPv6 literal. Invalid addresses, bracketed IPv4, ports and zone identifiers remain validation failures. Producer declarations do not authenticate origin. Native mitmproxy decoding requires a caller-supplied **mitmdump 12.2.3 on diff --git a/src/browser/history/HarCapture.test.ts b/src/browser/history/HarCapture.test.ts index 6e58f32367e..19e7b68aa38 100644 --- a/src/browser/history/HarCapture.test.ts +++ b/src/browser/history/HarCapture.test.ts @@ -805,12 +805,46 @@ it.each(["[2001:db8::1]", "[::1]", "[::ffff:192.0.2.1]"])( }, ); +it.each(["[2620:0:860:ed1a::1]", "[::1]"])( + "retains Chromium DevTools bracketed IPv6 spelling in reported Evidence: %s", + (address) => { + const fixture = historicalHar(); + const first = fixture.log.entries[0]; + if (first === undefined) throw new Error("fixture missing"); + const input = { + log: { + ...fixture.log, + creator: { name: "WebInspector", version: "537.36" }, + entries: [ + { ...first, serverIPAddress: address }, + structuredClone(first), + ], + }, + }; + const decoded = decodeHarCapture(JSON.stringify(input), []); + expect(decoded.records[0]).toMatchObject({ + reported: { serverIPAddress: address }, + }); + expect(decoded.records[0]?.limitations).toContain( + "This capture declares Chromium DevTools (WebInspector 537.36) and reports bracketed IPv6 serverIPAddress. Original spellings are retained; brackets were removed only from valid IPv6 values in the validation copy. Producer declarations are not authenticated.", + ); + expect( + decoded.records[1]?.limitations.some((limitation) => + limitation.includes("bracketed IPv6 serverIPAddress"), + ), + ).toBe(false); + }, +); + it.each([ { name: "other", version: "1.58.2", address: "[2001:db8::1]" }, { name: "Playwright", version: "1.58.1", address: "[2001:db8::1]" }, { name: "Playwright", version: "1.58.2", address: "[192.0.2.1]" }, { name: "Playwright", version: "1.58.2", address: "[2001:db8::1]:443" }, { name: "Playwright", version: "1.58.2", address: "[fe80::1%eth0]" }, + { name: "WebInspector", version: "605.1.15", address: "[2001:db8::1]" }, + { name: "WebInspector", version: "537.36", address: "[192.0.2.1]" }, + { name: "WebInspector", version: "537.36", address: "[2001:db8::1]:443" }, ])( "rejects unrecognized bracketed HAR address input: $name $version $address", ({ name, version, address }) => { diff --git a/src/browser/history/HarCapture.ts b/src/browser/history/HarCapture.ts index 91c67f9efe2..4353e9b6dec 100644 --- a/src/browser/history/HarCapture.ts +++ b/src/browser/history/HarCapture.ts @@ -33,9 +33,14 @@ export const decodeHarCapture = ( ): DecodedCapture => { const raw = parseCaptureJson(text, "HAR"); const validation = projectHar(raw, [], "validation"); - const adaptedPlaywrightEntries = new Set( - createHarValidator()(validation.reported), - ); + const ipv6Adaptation = createHarValidator()(validation.reported); + const adaptedIpv6Entries = new Set(ipv6Adaptation?.entries); + const ipv6Limitations = + ipv6Adaptation === null + ? [] + : [ + `This capture declares ${ipv6Adaptation.producer} and reports bracketed IPv6 serverIPAddress. Original spellings are retained; brackets were removed only from valid IPv6 values in the validation copy. Producer declarations are not authenticated.`, + ]; if ( !captureObject(raw) || !captureObject(raw.log) || @@ -76,11 +81,7 @@ export const decodeHarCapture = ( ...projection, limitations: [ "HAR text without an encoding declaration is producer-decoded Unicode. Unsupported declared body encodings remain opaque; original body bytes remain unknown unless content explicitly uses valid base64. Declared sizes are preserved independently of retained bytes. mitmproxy 12.2.3 SaveHar may report null optional postData.text; that null is retained and omitted only from the upstream validation copy.", - ...(adaptedPlaywrightEntries.has(ordinal) - ? [ - "This capture declares Playwright 1.58.2 and reports bracketed IPv6 serverIPAddress. Original spellings are retained; brackets were removed only from valid IPv6 values in the validation copy. Producer declarations are not authenticated.", - ] - : []), + ...(adaptedIpv6Entries.has(ordinal) ? ipv6Limitations : []), ...(recordsExcluded ? [ "This record's payload and sidecars are excluded because its structural ancestor property was explicitly marked sensitive.", diff --git a/src/browser/history/HarSchema.ts b/src/browser/history/HarSchema.ts index f50d91d3920..43d5422a0da 100644 --- a/src/browser/history/HarSchema.ts +++ b/src/browser/history/HarSchema.ts @@ -30,10 +30,28 @@ const timingsSchema = { }, }; +// Observed producers that copy Chromium's bracketed IPv6 remote address into +// serverIPAddress. Chromium DevTools declares WebInspector with the frozen +// AppleWebKit version from its user agent. +const BRACKETED_IPV6_PRODUCERS = [ + { name: "Playwright", version: "1.58.2", label: "Playwright 1.58.2" }, + { + name: "WebInspector", + version: "537.36", + label: "Chromium DevTools (WebInspector 537.36)", + }, +] as const; + +/** Entries whose bracketed IPv6 address was adapted, and the declared producer. */ +interface BracketedIpv6Adaptation { + readonly producer: string; + readonly entries: readonly number[]; +} + /** Validate with pinned HAR 1.2 draft-06 schemas and the owned schema corrections. */ export const createHarValidator = (): (( value: unknown, -) => readonly number[]) => { +) => BracketedIpv6Adaptation | null) => { const require = createRequire(import.meta.url); const schemas = z .record(z.string(), z.record(z.string(), z.unknown())) @@ -78,8 +96,7 @@ export const createHarValidator = (): (( if (validate === undefined) throw new Error("Pinned HAR root schema is unavailable"); return (value) => { - const adaptedPlaywrightEntries = - allowObservedProducerRepresentations(value); + const adaptations = allowObservedProducerRepresentations(value); if (!validate(value)) { const error = validate.errors?.[0]; // AJV messages name the violated constraint, such as the missing @@ -92,36 +109,43 @@ export const createHarValidator = (): (( error?.instancePath ?? "", ); } - return adaptedPlaywrightEntries; + return adaptations; }; }; +/** Name the declared producer when it is observed to bracket IPv6 addresses. */ +const bracketedIpv6ProducerOf = ( + creator: Record, +): string | null => + BRACKETED_IPV6_PRODUCERS.find( + ({ name, version }) => creator.name === name && creator.version === version, + )?.label ?? null; + /** Adapt only observed producer representations in the disposable validation copy. */ const allowObservedProducerRepresentations = ( value: unknown, -): readonly number[] => { - if (!captureObject(value) || !captureObject(value.log)) return []; +): BracketedIpv6Adaptation | null => { + if (!captureObject(value) || !captureObject(value.log)) return null; const log = value.log; - if (!captureObject(log.creator) || !Array.isArray(log.entries)) return []; + if (!captureObject(log.creator) || !Array.isArray(log.entries)) return null; const mitmproxy = log.creator.name === "mitmproxy" && log.creator.version === "12.2.3"; - const playwright = - log.creator.name === "Playwright" && log.creator.version === "1.58.2"; - if (!mitmproxy && !playwright) return []; - const adaptedPlaywrightEntries: number[] = []; + const bracketedIpv6Producer = bracketedIpv6ProducerOf(log.creator); + if (!mitmproxy && bracketedIpv6Producer === null) return null; + const adaptedEntries: number[] = []; for (const [ordinal, entry] of log.entries.entries()) { const candidate: unknown = entry; if (!captureObject(candidate)) continue; const address = candidate.serverIPAddress; if ( - playwright && + bracketedIpv6Producer !== null && typeof address === "string" && address.startsWith("[") && address.endsWith("]") && isIP(address.slice(1, -1)) === 6 ) { candidate.serverIPAddress = address.slice(1, -1); - adaptedPlaywrightEntries.push(ordinal); + adaptedEntries.push(ordinal); } if (!mitmproxy) continue; const request = candidate.request; @@ -129,7 +153,9 @@ const allowObservedProducerRepresentations = ( const body = request.postData; if (captureObject(body) && body.text === null) delete body.text; } - return adaptedPlaywrightEntries; + return bracketedIpv6Producer === null + ? null + : { producer: bracketedIpv6Producer, entries: adaptedEntries }; }; const HAR_REQUEST_URL_FORMAT = "absolute-url"; From 53fca98e3acefcd4409dd6a10c979fcf880c47b9 Mon Sep 17 00:00:00 2001 From: Hasan Zayni <36798269+dark13b@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:46:43 +0300 Subject: [PATCH 28/38] fix(setup): omit manual connectors from automatic selection (#8009) Retain detected manual connectors and explicit selection while excluding unsupported formats from automatic setup readiness. Fixes #7522. --- src/application/Setup.planning.test.ts | 32 ++++++- src/application/SetupPlan.ts | 4 +- .../setup/automaticClientSetup.test.ts | 95 +++++++++++++++++++ .../setup/repeatedClientSetup.test.ts | 1 + 4 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 tests/acceptance/setup/automaticClientSetup.test.ts diff --git a/src/application/Setup.planning.test.ts b/src/application/Setup.planning.test.ts index 527f213856d..2b7dd3a07c2 100644 --- a/src/application/Setup.planning.test.ts +++ b/src/application/Setup.planning.test.ts @@ -301,10 +301,13 @@ describe("setup client selection", () => { configPath: "/codex.toml", format: "toml" as const, }; - host.clients = [codex]; + host.clients = [ + codex, + { name: "grok_bot", configPath: "/grokbot", format: "unsupported" }, + ]; host.availableClients = [ { name: "cursor", configPath: "/cursor.json" }, - codex, + ...host.clients, ]; const result = await runSetup( @@ -331,6 +334,31 @@ describe("setup client selection", () => { expect(host.skillInstalls).toBe(0); }); + it.each([undefined, false])( + "omits manual clients from automatic readiness while preserving detection (skill: %s)", + async (installSkill) => { + const host = new FakeSetupHost(); + host.clients = [ + { name: "grok_bot", configPath: "/grokbot", format: "unsupported" }, + ]; + const result = await runSetup( + { + ...options(true), + allDetectedClients: true, + ...(installSkill === undefined ? {} : { installSkill }), + }, + host, + ); + expect(result.status).toBe("ready"); + expect(result.doctor.scope.clients).toEqual([]); + expect(result.clientStates).toMatchObject([ + { client: { name: "grok_bot" }, detected: true }, + ]); + expect(result.clients).toEqual({}); + expect(host.configurations).toBe(0); + }, + ); + it.each([true, false])( "handles repeated clients once in first-selected order (dry run: %s)", async (dryRun) => { diff --git a/src/application/SetupPlan.ts b/src/application/SetupPlan.ts index cf6da52fc87..5cc37628413 100644 --- a/src/application/SetupPlan.ts +++ b/src/application/SetupPlan.ts @@ -139,7 +139,9 @@ export const discoverSetupState = async (input: { installHopper, hopperInstall, clients, - detectedClientIds: [...detectedIds], + detectedClientIds: detectedClients + .filter(({ format }) => format !== "unsupported") + .map(({ name }) => name), defaultClientIds, clientStates, ...(linuxDistribution?.packageFamily === undefined diff --git a/tests/acceptance/setup/automaticClientSetup.test.ts b/tests/acceptance/setup/automaticClientSetup.test.ts new file mode 100644 index 00000000000..ea93f2119ce --- /dev/null +++ b/tests/acceptance/setup/automaticClientSetup.test.ts @@ -0,0 +1,95 @@ +import { access, mkdir } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for([ + { marker: true, skill: true }, + { marker: true, skill: false }, + { marker: false, skill: true }, +])( + "automatic setup excludes manual hosted connectors (marker: $marker, skill: $skill)", + async ({ marker, skill }, { cli }) => { + const home = await createTestTempDirectory("rea-automatic-clients-"); + if (marker) await mkdir(join(home, ".grokbot")); + const run = () => + cli.run({ + arguments: [ + "setup", + "--all-detected", + "--yes", + "--json", + ...(skill ? [] : ["--skill=false"]), + ], + cwd: home, + environment: { HOME: home, USERPROFILE: home, TMPDIR: home }, + }); + const first = await run(); + expect(first.exitCode, first.stdout).toBe(0); + expect(first.json).toMatchObject({ + status: "ready", + appliedActions: skill ? ["installed_skill"] : [], + clients: {}, + doctor: { healthy: true, scope: { clients: [], skill } }, + }); + if (marker) + expect(first.json).toMatchObject({ + clientStates: expect.arrayContaining([ + expect.objectContaining({ + client: expect.objectContaining({ name: "grok_bot" }), + detected: true, + }), + ]), + }); + const repeated = await run(); + expect(repeated.exitCode, repeated.stdout).toBe(0); + expect(repeated.json).toMatchObject({ + status: "ready", + plannedActions: [], + appliedActions: [], + doctor: { healthy: true, scope: { clients: [], skill } }, + }); + await expect( + access(join(home, ".grokbot", "mcp.json")), + ).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + +cliTest.for([true, false])( + "explicit Grok Bot setup retains manual instructions (all detected: %s)", + async (allDetected, { cli }) => { + const home = await createTestTempDirectory("rea-explicit-manual-client-"); + await mkdir(join(home, ".grokbot")); + const result = await cli.run({ + arguments: [ + "setup", + "--client", + "grok_bot", + "--skill=false", + "--yes", + "--json", + ...(allDetected ? ["--all-detected"] : []), + ], + cwd: home, + environment: { HOME: home, USERPROFILE: home, TMPDIR: home }, + }); + expect(result.exitCode, result.stdout).toBe(1); + expect(result.json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + remediation: expect.stringContaining("hosted computer"), + doctor: { scope: { clients: ["grok_bot"] } }, + }); + await expect( + access(join(home, ".grokbot", "mcp.json")), + ).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); diff --git a/tests/acceptance/setup/repeatedClientSetup.test.ts b/tests/acceptance/setup/repeatedClientSetup.test.ts index def26a75bbc..143ed803d12 100644 --- a/tests/acceptance/setup/repeatedClientSetup.test.ts +++ b/tests/acceptance/setup/repeatedClientSetup.test.ts @@ -88,6 +88,7 @@ cliTest( "rea-explicit-detected-clients-", ); await mkdir(join(home, ".codex")); + await mkdir(join(home, ".grokbot")); const run = (selection: readonly string[], mode: string) => cli.run({ arguments: ["setup", ...selection, "--skill=false", "--json", mode], From b3e22620a5fcd84a7ab658838f914edd1a763b24 Mon Sep 17 00:00:00 2001 From: Ha1baraA1 <243105435+Ha1baraA11@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:47:16 +0800 Subject: [PATCH 29/38] fix(mcp): keep scoped doctor output parseable (#7648) --- src/mcpDoctor.ts | 14 +++- tests/boundary/mcp/mcpDoctor.test.ts | 104 ++++++++++++++++++++++++++- 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/src/mcpDoctor.ts b/src/mcpDoctor.ts index 504814c7049..841e013cb3a 100644 --- a/src/mcpDoctor.ts +++ b/src/mcpDoctor.ts @@ -232,6 +232,14 @@ interface DoctorCatalogExpectation { readonly expectedPromptNames: readonly string[]; } +const listPromptsWhenAdvertised = ( + client: Client, + request: ReturnType, +) => + client.getServerCapabilities()?.prompts + ? client.listPrompts(undefined, request) + : Promise.resolve({ prompts: [] }); + const inspectProductionMcpSession = async ( client: Client, deadline: number, @@ -240,9 +248,13 @@ const inspectProductionMcpSession = async ( budget: McpPhaseBudget, ) => { const request = requestOptions(deadline, signal); + // The SDK logs to stdout when listPrompts is called without the advertised + // capability, which corrupts the doctor's structured output. An absent + // capability is an empty prompt inventory for this diagnostic. + const promptsRequest = listPromptsWhenAdvertised(client, request); const [tools, prompts] = await Promise.all([ client.listTools(undefined, request), - client.listPrompts(undefined, request), + promptsRequest, ]); const sessionTool = tools.tools.find(({ name }) => name === "binary_session"); // A scope without the session family cannot serve request flow at all; diff --git a/tests/boundary/mcp/mcpDoctor.test.ts b/tests/boundary/mcp/mcpDoctor.test.ts index 64e55f5972e..005be263e4c 100644 --- a/tests/boundary/mcp/mcpDoctor.test.ts +++ b/tests/boundary/mcp/mcpDoctor.test.ts @@ -1,8 +1,8 @@ import { contractsForToolFamilies } from "../../../src/contracts/toolFamilies.js"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; -import { execFile } from "node:child_process"; -import { readFile, writeFile } from "node:fs/promises"; +import { execFile, spawn } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { promisify } from "node:util"; @@ -50,6 +50,42 @@ const expectDoctorDeadline = ( ); }; +const captureProductionCli = async ( + arguments_: readonly string[], + environment: NodeJS.ProcessEnv, +) => + new Promise<{ + readonly exitCode: number | null; + readonly stdout: string; + readonly stderr: string; + }>((finish, reject) => { + const child = spawn( + process.execPath, + [resolve("scripts/rea.mjs"), ...arguments_], + { + cwd: process.cwd(), + env: environment, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + const timeout = setTimeout(() => child.kill("SIGTERM"), 15_000); + child.once("error", reject); + child.once("close", (exitCode) => { + clearTimeout(timeout); + finish({ exitCode, stdout, stderr }); + }); + }); + afterEach(() => vi.useRealTimers()); const assertCliCatalogIdentity = async ( @@ -358,6 +394,70 @@ describe("production MCP doctor family scopes", () => { }); describe("production MCP doctor output", () => { + it("keeps both structured stdout formats parseable when prompts are not advertised", async () => { + const root = await createTestTempDirectory("rea-mcp-doctor-output-"); + const directories = { + home: join(root, "home"), + config: join(root, "config"), + data: join(root, "data"), + cache: join(root, "cache"), + state: join(root, "state"), + temp: join(root, "temp"), + }; + await Promise.all( + Object.values(directories).map((path) => + mkdir(path, { recursive: true }), + ), + ); + const environment = { + ...process.env, + REA_MCP_TOOL_FAMILIES: "browser", + HOME: directories.home, + XDG_CONFIG_HOME: directories.config, + XDG_DATA_HOME: directories.data, + XDG_CACHE_HOME: directories.cache, + XDG_STATE_HOME: directories.state, + TMPDIR: directories.temp, + }; + + const json = await captureProductionCli( + ["mcp", "doctor", "--json"], + environment, + ); + const jsonl = await captureProductionCli( + ["mcp", "doctor", "--format", "jsonl"], + environment, + ); + + expect([json.exitCode, jsonl.exitCode]).toEqual([1, 1]); + const parseable = [json.stdout, jsonl.stdout].map((output) => { + try { + JSON.parse(output); + return true; + } catch { + return false; + } + }); + expect(parseable).toEqual([true, true]); + expect(JSON.parse(json.stdout)).toMatchObject({ + healthy: false, + inventory: { + prompts: { expected: 0, observed: 0, missing: [], unexpected: [] }, + }, + }); + expect(JSON.parse(jsonl.stdout)).toMatchObject({ + $rea: "jsonl/v1", + type: "error", + ok: false, + result: { + healthy: false, + inventory: { + prompts: { expected: 0, observed: 0, missing: [], unexpected: [] }, + }, + }, + }); + }, 35_000); + it.each([false, true])( "frames unhealthy JSONL diagnostics and retains the selected envelope (%s)", async (full) => { From 4f4e2552cc97a4aa94100f1840eba7e0beb97e3c Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Tue, 3 Nov 2026 14:47:49 +0800 Subject: [PATCH 30/38] fix(verification): compare retained Ghidra function evidence (#8672) * fix(verification): compare retained Ghidra function evidence * test(verification): require the exact missing Evidence refusal --- scripts/lib/native-value-e2e.mjs | 37 +++---- .../nativeValueVerifierComparison.test.ts | 102 ++++++++++++++++++ 2 files changed, 118 insertions(+), 21 deletions(-) create mode 100644 tests/boundary/process/nativeValueVerifierComparison.test.ts diff --git a/scripts/lib/native-value-e2e.mjs b/scripts/lib/native-value-e2e.mjs index 88fba60cbf7..a20ccb2e3ae 100644 --- a/scripts/lib/native-value-e2e.mjs +++ b/scripts/lib/native-value-e2e.mjs @@ -253,20 +253,9 @@ async function verifyNativeValueAdapters( functionEvidence.analysis_profile, ); assert.deepEqual(functionProfile, upstreamProfile); - const comparison = await client.callTool( - { - name: "compare_functions", - arguments: { - left: analyzed.structuredContent, - right: analyzed.structuredContent, - }, - }, - { timeout: 180000 }, - ); - assert.notEqual(comparison.isError, true, JSON.stringify(comparison)); - assert.notEqual(comparison.structuredContent, undefined); - const comparisonEvidence = parseEvidence( - requireMcpOutput(comparison, "compare_functions"), + const comparisonEvidence = await compareRetainedFunctionEvidence( + client, + functionEvidence, ); assert.equal( comparisonEvidence.normalized_result.status, @@ -395,21 +384,27 @@ async function verifyUnresolvedCallEvidence(client) { assert.equal(trace.goal_status, "unknown"); assert.equal(trace.truncated, true); assert.ok(trace.residual_unknowns.length > 0); + const compared = await compareRetainedFunctionEvidence(client, evidence); + assert.equal(compared.normalized_result.status, "unknown"); +} + +/** Compare an already admitted function record without retransmitting its payload. */ +export async function compareRetainedFunctionEvidence(client, evidence) { + const reference = { + kind: "retained-evidence", + evidence_id: evidence.evidence_id, + }; const compared = await client.callTool( { name: "compare_functions", arguments: { - left: analyzed.structuredContent, - right: analyzed.structuredContent, + left: reference, + right: reference, }, }, { timeout: 180000 }, ); assert.notEqual(compared.isError, true, JSON.stringify(compared)); assert.notEqual(compared.structuredContent, undefined); - assert.equal( - parseEvidence(requireMcpOutput(compared, "compare_functions")) - .normalized_result.status, - "unknown", - ); + return parseEvidence(requireMcpOutput(compared, "compare_functions")); } diff --git a/tests/boundary/process/nativeValueVerifierComparison.test.ts b/tests/boundary/process/nativeValueVerifierComparison.test.ts new file mode 100644 index 00000000000..f73878257a0 --- /dev/null +++ b/tests/boundary/process/nativeValueVerifierComparison.test.ts @@ -0,0 +1,102 @@ +import { fileURLToPath } from "node:url"; +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { processTest } from "../../support/process/processFixture.js"; + +const moduleUrl = (path: string) => + JSON.stringify(new URL(`../../../${path}`, import.meta.url).href); + +processTest.for([ + { coverage: "complete", status: "unchanged" }, + { coverage: "partial", status: "unknown" }, +] as const)( + "compares retained $coverage function Evidence within default MCP limits", + async ({ coverage, status }, { processes }) => { + const root = await createTestTempDirectory("rea-verifier-comparison-"); + const source = ` + import assert from "node:assert/strict"; + import { writeFile } from "node:fs/promises"; + import { join } from "node:path"; + import { Client } from "@modelcontextprotocol/client"; + import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; + import { FUNCTION_COMPARISON_EXAMPLE } from ${moduleUrl("dist/contracts/functionComparisonExample.js")}; + import { createEvidence } from ${moduleUrl("dist/domain/evidence.js")}; + import { createEvidenceBundle } from ${moduleUrl("dist/domain/evidenceBundle.js")}; + import { functionDossierSchema } from ${moduleUrl("dist/domain/native/functionDossier.js")}; + import { compareRetainedFunctionEvidence } from ${moduleUrl("scripts/lib/native-value-e2e.mjs")}; + import { requireMcpEvidenceBundle } from ${moduleUrl("scripts/lib/mcp-verifier-results.mjs")}; + + // Explicit synthetic facts qualify this adapter boundary, not real Ghidra. + const dossier = functionDossierSchema.parse( + structuredClone(FUNCTION_COMPARISON_EXAMPLE.left.normalized_result)); + dossier.assembly = Array.from({ length: 1200 }, (_, i) => "synthetic instruction " + i); + const evidence = createEvidence({ path: "/synthetic/function.elf", + sha256: "a".repeat(64), format: "elf", architecture: "x86_64" }, + { id: "synthetic-verifier-facts", name: "Explicit synthetic facts", version: "1" }, + { operation: "analyze_function", parameters: { procedure: "main" }, result: dossier, + relationshipCoverage: { status: ${JSON.stringify(coverage)}, + ...(${JSON.stringify(coverage)} === "partial" + ? { limitations: ["Explicit synthetic unresolved relationships."] } : {}) } }); + const bundlePath = join(process.argv[1], "source.json"); + await writeFile(bundlePath, JSON.stringify(createEvidenceBundle([evidence], []))); + const transport = new StdioClientTransport({ command: process.execPath, + args: [${JSON.stringify(fileURLToPath(new URL("../../../scripts/rea.mjs", import.meta.url)))}, "mcp"], + env: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent", + HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper" }, stderr: "pipe" }); + const client = new Client({ name: "synthetic-verifier-comparison", version: "1" }); + try { + await client.connect(transport); + const imported = await client.callTool({ name: "import_evidence_bundle", + arguments: { path: bundlePath } }); + assert.notEqual(imported.isError, true, JSON.stringify(imported)); + const inline = await client.callTool({ name: "compare_functions", + arguments: { left: evidence, right: evidence } }); + assert.equal(inline.isError, true, JSON.stringify(inline)); + const error = JSON.parse(inline.content.find(item => item.type === "text").text).error; + assert.equal(error.code, "resource_constraint"); + assert.equal(error.details.reported_limits.max_input_elements, 1024); + + // Execute the same request producer used by both real-provider controls. + const compared = await compareRetainedFunctionEvidence(client, evidence); + assert.equal(compared.normalized_result.status, ${JSON.stringify(status)}); + assert.ok(compared.evidence_links.includes(evidence.evidence_id)); + const bundle = requireMcpEvidenceBundle(await client.callTool({ + name: "get_evidence_bundle", arguments: {} })); + for (const record of [evidence, compared]) + assert.deepEqual(bundle.records.find(item => item.evidence_id === record.evidence_id), record); + const missingId = "ev_" + "b".repeat(64); + let missingFailure; + const checkedClient = { callTool: async (...args) => { + const reply = await client.callTool(...args); + assert.equal(reply.isError, true); + missingFailure = JSON.parse(reply.content.find(item => item.type === "text").text).error; + return reply; + } }; + await assert.rejects(() => compareRetainedFunctionEvidence(checkedClient, + { ...evidence, evidence_id: missingId }), { name: "AssertionError" }); + assert.equal(missingFailure.details.reason, "missing"); + assert.equal(missingFailure.details.evidence_id, missingId); + assert.match(missingFailure.message, /not available from the selected source/); + console.log(JSON.stringify({ status: compared.normalized_result.status, + exact_source_link: true, complete_records_retained: true, missing_reference_refused: true })); + } finally { + await client.close(); + await transport.close(); + } + `; + const result = await processes.run(process.execPath, [ + "--input-type=module", + "--eval", + source, + root, + ]); + expect(result.exitCode, `${result.stdout}\n${result.stderr}`).toBe(0); + expect(JSON.parse(result.stdout.trim())).toEqual({ + status, + exact_source_link: true, + complete_records_retained: true, + missing_reference_refused: true, + }); + }, +); From f59d884f35a3fd87e9b108c29c75426180feca65 Mon Sep 17 00:00:00 2001 From: Beqa <91750923+BeqaKs@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:48:21 +0400 Subject: [PATCH 31/38] fix(flutter): read unlabeled Dart Version::String() from engines (#7232) * fix(flutter): read unlabeled Dart Version::String() from engines Release libflutter.so embeds Dart's Version::String() without the standalone VM's "Dart VM version:" label, so identify_flutter_build left dart_version null on real APKs. Accept that channel-bearing form beside the labeled scanner path. * fix(flutter): qualify unlabeled version candidates --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/flutter-analysis.md | 21 ++-- docs/testing-lanes.md | 6 +- .../flutter-gallery-expectations.json | 6 +- src/domain/flutter/flutterBuildAnalysis.ts | 2 +- src/flutter/FlutterBuildProvider.ts | 2 +- src/flutter/FlutterPayloadScan.test.ts | 104 ++++++++++++++---- src/flutter/FlutterPayloadScan.ts | 42 +++++-- .../filesystem/flutterBuildProvider.test.ts | 25 +++++ 8 files changed, 160 insertions(+), 48 deletions(-) diff --git a/docs/flutter-analysis.md b/docs/flutter-analysis.md index 2d18753d639..7ffb31d2d65 100644 --- a/docs/flutter-analysis.md +++ b/docs/flutter-analysis.md @@ -109,9 +109,13 @@ server; older servers keep the complete request after schema discovery. header check does not establish valid table contents or exported symbols. - **`libflutter` facts** are per-ABI observations: byte size, SHA-256 digest, GNU build IDs from ELF-declared `PT_NOTE` / `SHT_NOTE` ranges, - and any _labeled_ strings the build - carries. Release engine builds often strip the `Dart VM version` string — - absence is reported as `null`, not guessed. Android clang/LLVM toolchain + and Dart/toolchain strings the build carries. `dart_version` accepts the + labeled `Dart VM version:` form and the unlabeled Dart + `Version::String()` form (` () ()` with an + optional ` on "_"` suffix) that release engines embed. An + unlabeled match is a candidate based on its shape; it is not bound to a + Dart symbol and another string can match. `null` means no supported pattern + was found, not proof that no version exists. Android clang/LLVM toolchain lines are reported verbatim as build provenance. `build_id_candidates` preserves distinct IDs. `build_id` is populated only when all declared note ranges were examined and one ID was found; @@ -122,10 +126,13 @@ server; older servers keep the complete request after schema discovery. Compressed `SHT_NOTE` sections are skipped with an unsupported-compression limitation and partial coverage; IDs from other note ranges remain candidates. Encoded note storage is never interpreted as note headers or inflated. -- Labeled strings must fit their complete grammar. `dart_version_capped` - and `toolchain_capped` independently report labels omitted by grammar or - projection bounds; either makes identification coverage partial. Values - split across read chunks are retained without publishing truncated prefixes. +- Version and toolchain strings must fit their complete grammar. + `dart_version_capped` and `toolchain_capped` independently report labels + omitted by grammar or projection bounds; either makes identification + coverage partial. Values split across read chunks are retained without + publishing truncated prefixes. Unlabeled runs that miss the + `Version::String()` grammar are ignored and do not set + `dart_version_capped`. - **`coverage: "partial"`** also appears when an ABI carries `libapp.so` without `libflutter.so` (or vice versa). - The APK itself is digested before parsing, so `target.sha256` binds the diff --git a/docs/testing-lanes.md b/docs/testing-lanes.md index f6ade796721..223ed210d16 100644 --- a/docs/testing-lanes.md +++ b/docs/testing-lanes.md @@ -269,8 +269,8 @@ APK for the negative check. No engine or external tool is involved. Record: Flutter Gallery 2.9.2 (112 MB, arm64-v8a / armeabi-v7a / x86_64) — every ABI reports the same agreeing snapshot hash (`65817c30a78bb44c3dc3771876b6010a`, two sections each) read 20 bytes after -the Dart snapshot magic, per-ABI engine GNU build-ids, no labeled Dart -version string (expected for a release engine build), and the Android +the Dart snapshot magic, per-ABI engine GNU build-ids, an unlabeled +Dart-shaped version candidate, and the Android clang/LLVM toolchain line; the AOT inspection locates all four snapshot symbols through the dynamic symbol table on both ELF64 and ELF32 images, validates the data-section headers, agrees with the identification hash, @@ -284,7 +284,7 @@ prefixes, with no private local-file dependency. The verifier now compares compiled CLI and stdio MCP Evidence on every ABI, including data-header kind and declared lengths. Add `--expectations scripts/fixtures/flutter-gallery-expectations.json` for the -documented Gallery APK to assert its pinned digest, hash, exact engine labels +documented Gallery APK to assert its pinned digest, hash, exact engine strings and per-ABI URI counts. The verifier requires complete structural snapshot coverage, reports bounded pool coverage as partial/unverified, and reports exact labels/counts on other APKs unverified without an expectations file. diff --git a/scripts/fixtures/flutter-gallery-expectations.json b/scripts/fixtures/flutter-gallery-expectations.json index 909d86e661c..9d5e58550a9 100644 --- a/scripts/fixtures/flutter-gallery-expectations.json +++ b/scripts/fixtures/flutter-gallery-expectations.json @@ -3,19 +3,19 @@ "snapshot_hash": "65817c30a78bb44c3dc3771876b6010a", "engines": { "x86_64": { - "dart_version": null, + "dart_version": "2.14.0-151.0.dev (dev) (Tue May 25 10:58:39 2021 -0700)", "toolchain_lines": [ "Android (5900059 based on r365631c) clang version 9.0.8 (https://android.googlesource.com/toolchain/llvm-project 207d7abc1a2abf3ef8d4301736d6a7ebc224a290) (based on LLVM 9.0.8svn)" ] }, "armeabi-v7a": { - "dart_version": null, + "dart_version": "2.14.0-151.0.dev (dev) (Tue May 25 10:58:39 2021 -0700)", "toolchain_lines": [ "Android (5900059 based on r365631c) clang version 9.0.8 (https://android.googlesource.com/toolchain/llvm-project 207d7abc1a2abf3ef8d4301736d6a7ebc224a290) (based on LLVM 9.0.8svn)" ] }, "arm64-v8a": { - "dart_version": null, + "dart_version": "2.14.0-151.0.dev (dev) (Tue May 25 10:58:39 2021 -0700)", "toolchain_lines": [ "Android (5900059 based on r365631c) clang version 9.0.8 (https://android.googlesource.com/toolchain/llvm-project 207d7abc1a2abf3ef8d4301736d6a7ebc224a290) (based on LLVM 9.0.8svn)" ] diff --git a/src/domain/flutter/flutterBuildAnalysis.ts b/src/domain/flutter/flutterBuildAnalysis.ts index d1f00ed5a10..0bd925fbabd 100644 --- a/src/domain/flutter/flutterBuildAnalysis.ts +++ b/src/domain/flutter/flutterBuildAnalysis.ts @@ -142,7 +142,7 @@ export const flutterResultSchemas = { .string() .nullable() .describe( - "Labeled 'Dart VM version' string, when a build carries one", + "Observed string matching the labeled 'Dart VM version' or unlabeled Dart Version::String() pattern; unlabeled matches are candidates rather than verified source identities", ), toolchain_lines: z.array(z.string().min(1)), /** Dart labels omitted by grammar or single-version projection bounds. */ diff --git a/src/flutter/FlutterBuildProvider.ts b/src/flutter/FlutterBuildProvider.ts index 8cf8cbc0564..6af0a5a35a8 100644 --- a/src/flutter/FlutterBuildProvider.ts +++ b/src/flutter/FlutterBuildProvider.ts @@ -1023,7 +1023,7 @@ export class FlutterBuildProvider { limitations: [ "The APK is read through an owned read-only snapshot; concurrent changes to the original cannot mix into this observation", "The snapshot hash identifies a Dart SDK build only against an external mapping dataset; REA performs no lookup", - "Release engine builds often carry no labeled Dart version string; absence is reported, not guessed", + "Unlabeled Dart version values are identified by their printable-string shape, not a symbol or other source identity; a matching unrelated string may be reported and a null value means no supported pattern was found", "Engine build IDs come only from ELF-declared PT_NOTE/SHT_NOTE records; conflicting or incompletely examined notes preserve candidates and report partial coverage", ...[...facts].flatMap(([abi, value]) => [ ...value.libflutter.buildIdFailures.map( diff --git a/src/flutter/FlutterPayloadScan.test.ts b/src/flutter/FlutterPayloadScan.test.ts index 4e36d78efa4..85ba203f412 100644 --- a/src/flutter/FlutterPayloadScan.test.ts +++ b/src/flutter/FlutterPayloadScan.test.ts @@ -124,22 +124,50 @@ it("distinguishes a full candidate set from omitted conflicts", () => { expect(scanner.result().candidates).toHaveLength(64); }); -describe("LabeledStringScanner", () => { - const assertSingleToolchainLine = (line: string): void => { +const assertSingleToolchainLine = (line: string): void => { + const scanner = new LabeledStringScanner(); + scanner.push( + Uint8Array.from( + Array.from(`\x00${line}\x00`, (character) => character.charCodeAt(0)), + ), + ); + expect(scanner.result().toolchainLines).toEqual([line]); +}; + +describe("LabeledStringScanner toolchain lines", () => { + it("collects a full clang line within the 500-character bound", () => { + assertSingleToolchainLine( + "Android (5900059 based on r365631c) clang version 9.0.8 (https://android.googlesource.com/toolchain/llvm-project 207d7abc1a2abf3ef8d4301736d6a7ebc224a290) (based on LLVM 9.0.8svn)", + ); + }); + + it("collects the labeled Android clang line", () => { + assertSingleToolchainLine( + "Android (5900059 based on r365631c) clang version 9.0.8 (https://android.googlesource.com/toolchain/llvm-project 207d7abc) (based on LLVM 9.0.8svn)", + ); + }); + + it("does not claim a toolchain cap for unrelated overlong strings", () => { const scanner = new LabeledStringScanner(); + const license = `Apache License Version 2.0 ${"x".repeat(700)}`; scanner.push( Uint8Array.from( - Array.from(`\x00${line}\x00`, (character) => character.charCodeAt(0)), + Array.from(`\x00${license}\x00`, (character) => + character.charCodeAt(0), + ), ), ); - expect(scanner.result().toolchainLines).toEqual([line]); - }; - it("collects a full clang line within the 500-character bound", () => { - assertSingleToolchainLine( - "Android (5900059 based on r365631c) clang version 9.0.8 (https://android.googlesource.com/toolchain/llvm-project 207d7abc1a2abf3ef8d4301736d6a7ebc224a290) (based on LLVM 9.0.8svn)", - ); + const result = scanner.result(); + expect(result.toolchainCapped).toBe(false); + expect(result.toolchainLines).toEqual([]); + }); + + it("preserves trailing whitespace on LLVM toolchain lines", () => { + assertSingleToolchainLine("based on LLVM 9 "); }); +}); +describe("LabeledStringScanner Dart version strings", () => { it("reads a labeled Dart VM version string when a build carries one", () => { const scanner = new LabeledStringScanner(); scanner.push( @@ -155,19 +183,44 @@ describe("LabeledStringScanner", () => { ); }); - it("does not claim a toolchain cap for unrelated overlong strings", () => { + it.each([ + [ + "release engine", + '3.7.2 (stable) (Tue Mar 11 04:27:50 2025 -0700) on "android_arm64"', + ], + [ + "Flutter Gallery engine", + "2.14.0-151.0.dev (dev) (Tue May 25 10:58:39 2021 -0700)", + ], + ] as const)( + "reads an unlabeled Dart Version::String() from a %s", + (_label, line) => { + const scanner = new LabeledStringScanner(); + scanner.push(Buffer.from(`\x00${line}\x00`)); + expect(scanner.result()).toMatchObject({ + dartVersion: line, + dartVersionCapped: false, + }); + }, + ); + + it("ignores unlabeled version-shaped text without a channel token", () => { const scanner = new LabeledStringScanner(); - const license = `Apache License Version 2.0 ${"x".repeat(700)}`; scanner.push( - Uint8Array.from( - Array.from(`\x00${license}\x00`, (character) => - character.charCodeAt(0), - ), + Buffer.from( + '1.2.3 (Tue May 25 10:58:39 2021 -0700) on "android_arm64"\x00', ), ); - const result = scanner.result(); - expect(result.toolchainCapped).toBe(false); - expect(result.toolchainLines).toEqual([]); + expect(scanner.result().dartVersion).toBeNull(); + }); + + it("does not claim a Dart cap for unlabeled runs that miss the grammar", () => { + const scanner = new LabeledStringScanner(); + scanner.push(Buffer.from("3.7.2 (stable)\x00")); + expect(scanner.result()).toMatchObject({ + dartVersion: null, + dartVersionCapped: false, + }); }); it("claims the cap only for an overlong run shaped like a toolchain line", () => { @@ -194,6 +247,13 @@ describe("LabeledStringScanner", () => { it.each([ { label: "Dart", line: `Dart VM version:${"x".repeat(200)}`, dart: true }, + { + label: "embedded Dart", + line: '3.7.2 (stable) (Tue Mar 11 04:27:50 2025 -0700) on "android_arm64"', + dart: true, + // Trailing junk breaks the whole-run grammar; do not treat as a cap. + invalidCaps: false, + }, { label: "Android clang", line: `Android (${"r".repeat(80)}) clang version ${"x".repeat(500)}`, @@ -202,7 +262,7 @@ describe("LabeledStringScanner", () => { { label: "LLVM", line: `based on LLVM 9${"x".repeat(80)}`, dart: false }, ])( "requires the complete $label run at its own grammar boundary", - ({ line, dart }) => { + ({ line, dart, invalidCaps }) => { for (const suffix of ["", "x", "x".repeat(1000)]) { const payload = Buffer.from(`${line}${suffix}\x00`); for (const chunkSize of [1, 7, payload.length]) { @@ -210,9 +270,10 @@ describe("LabeledStringScanner", () => { for (let offset = 0; offset < payload.length; offset += chunkSize) scanner.push(payload.subarray(offset, offset + chunkSize)); const valid = suffix === ""; + const capsInvalid = invalidCaps ?? true; expect(scanner.result()).toEqual({ dartVersion: dart && valid ? line : null, - dartVersionCapped: dart && !valid, + dartVersionCapped: dart && !valid && capsInvalid, toolchainLines: !dart && valid ? [line] : [], toolchainCapped: !dart && !valid, }); @@ -221,8 +282,7 @@ describe("LabeledStringScanner", () => { }, ); - it("preserves whitespace and separates distinct Dart-label omissions", () => { - assertSingleToolchainLine("based on LLVM 9 "); + it("separates distinct Dart-label omissions", () => { const scanner = new LabeledStringScanner(); scanner.push(Buffer.from("Dart VM version: 3.1\0Dart VM version: 3.2\0")); expect(scanner.result()).toMatchObject({ diff --git a/src/flutter/FlutterPayloadScan.ts b/src/flutter/FlutterPayloadScan.ts index a58ee5cc472..733f3e184fb 100644 --- a/src/flutter/FlutterPayloadScan.ts +++ b/src/flutter/FlutterPayloadScan.ts @@ -5,8 +5,8 @@ import { Buffer } from "node:buffer"; * * All facts are observations of byte patterns at read time: the Dart * snapshot hash sits 20 bytes after each snapshot magic, the GNU build-id - * is an ELF note, and version facts are labeled ASCII strings. Nothing is - * inferred or looked up externally. + * is an ELF note, and version facts are ASCII strings. Nothing is inferred or + * looked up externally. */ /** First index of `needle` in `haystack` at or after `from`, or -1. */ @@ -124,6 +124,13 @@ export interface LabeledStringScan { } const DART_VERSION_PATTERN = /^Dart VM version:[^\x00\n]{0,200}$/u; +/** + * Unlabeled Dart `Version::String()` form embedded in release engines: + * ` () ()` with optional ` on "_"`. + * Requires a channel token so ordinary dotted version text is ignored. + */ +const DART_EMBEDDED_VERSION_PATTERN = + /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)? \((?:stable|beta|dev|main)\) \([^)\x00\n]{1,80}\)(?: on "[a-z0-9._-]+")?$/u; const TOOLCHAIN_PATTERN = /^(?:Android \([^)\x00\n]{0,80}\) clang version [^\x00\n]{0,500}|based on LLVM \d[^\x00\n]{0,80})$/u; const MAX_TOOLCHAIN_LINES = 4; @@ -135,11 +142,11 @@ const MAX_TOOLCHAIN_LINES = 4; const CARRY_BYTES = 640; /** - * Incremental ASCII string collector that keeps only labeled version and - * toolchain facts. A match is committed only when a printable run is - * terminated by a delimiter, so a value split across chunk boundaries is - * never committed truncated; the carry holds the longest accepted match - * so boundaries in either pattern survive. + * Incremental ASCII string collector that keeps Dart version and toolchain + * facts. A match is committed only when a printable run is terminated by a + * delimiter, so a value split across chunk boundaries is never committed + * truncated; the carry holds the longest accepted match so boundaries in + * either pattern survive. */ export class LabeledStringScanner { #dartVersion: string | null = null; @@ -173,15 +180,28 @@ export class LabeledStringScanner { this.overflowed = false; } + #recordDartVersion(text: string, accepted: boolean): void { + if (!accepted) { + this.#dartVersionCapped = true; + return; + } + if (this.#dartVersion === null) this.#dartVersion = text; + else if (this.#dartVersion !== text) this.#dartVersionCapped = true; + } + #consider(text: string): void { // Keep a bounded prefix plus overflow state, never a sliding suffix. // Every accepted label must consume its whole printable run so a // pattern's shorter grammar cannot publish a fabricated prefix. if (text.startsWith("Dart VM version:")) { - if (this.overflowed || !DART_VERSION_PATTERN.test(text)) - this.#dartVersionCapped = true; - else if (this.#dartVersion === null) this.#dartVersion = text; - else if (this.#dartVersion !== text) this.#dartVersionCapped = true; + this.#recordDartVersion( + text, + !this.overflowed && DART_VERSION_PATTERN.test(text), + ); + return; + } + if (DART_EMBEDDED_VERSION_PATTERN.test(text)) { + this.#recordDartVersion(text, !this.overflowed); return; } if (!text.startsWith("Android (") && !text.startsWith("based on LLVM ")) diff --git a/tests/boundary/filesystem/flutterBuildProvider.test.ts b/tests/boundary/filesystem/flutterBuildProvider.test.ts index 40aaf9093c2..3c437d9535f 100644 --- a/tests/boundary/filesystem/flutterBuildProvider.test.ts +++ b/tests/boundary/filesystem/flutterBuildProvider.test.ts @@ -1237,6 +1237,31 @@ it.each(["identify_flutter_build", "inspect_dart_aot"] as const)( }, ); +it("qualifies unlabeled Dart-shaped strings as candidates", async () => { + const candidate = "1.2.3 (stable) (unrelated string)"; + const path = await buildApk("dart-pattern.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + { + path: "lib/arm64-v8a/libflutter.so", + bytes: Buffer.concat([libflutter(), Buffer.from(`\0${candidate}\0`)]), + }, + ]); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "identify_flutter_build", + input: { path }, + }); + if (!result.ok) throw result.error; + const value = flutterResultSchemas.identify_flutter_build.parse( + result.value.result, + ); + expect(value.abis[0]?.libflutter.dart_version).toBe(candidate); + expect(result.value.limitations.join(" ")).toContain( + "a matching unrelated string may be reported", + ); +}); + it("reports omitted Dart labels independently of toolchain labels", async () => { const path = await buildApk("dart-label.apk", [ { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, From f5cfe09c07e6110b23301c198340014f8b8db6be Mon Sep 17 00:00:00 2001 From: Wenyu Chiou <162016108+WenyuChiou@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:48:54 -0500 Subject: [PATCH 32/38] fix(environment): disclose truncated comparison differences (#7673) Preserve the twenty-entry diagnostic prefix and report the full count, omitted count and truncation fact at the owning report projection. Document the recovery limitation and cover the public HAR-to-comparison workflow. Fixes #7621 Validation: 121 source tests; three selected CLI regressions (five other cases unselected); nine README conformance cases; full --noEmit typecheck; documentation facts/metadata/schema/licenses; module boundaries and architecture guards. VitePress rendering is blocked by Windows EPERM on its worktree-local Vue symlink. Full aggregate coverage remains for Draft CI. Independent code review: APPROVE, relayed by parent task 01a17716-6299-75fe-87e9-b0398b8c9019. Trigger: >=50 changed lines / >=3 files. Reviewed tree: ba964efcd6abde5a31ed019871c762d0907683dd Reviewed full-index patch SHA256: 972dcfc219dd7b9606175b1848068f92210fa3f7d0a8a244c1ac7ce296ae32d9 --- docs/agent-environments.md | 7 +- .../fidelity-compare/spec.md | 5 +- .../environment/environmentCompare.test.ts | 50 +++++++ .../environment/environmentCompare.ts | 11 +- .../cli/environmentCompareCli.test.ts | 135 ++++++++++++++++++ 5 files changed, 205 insertions(+), 3 deletions(-) diff --git a/docs/agent-environments.md b/docs/agent-environments.md index 8bcc62be86d..71fd35bd652 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -1149,7 +1149,12 @@ global conflict involving an equal pair is not used to normalize either pair; th A step agrees when the statuses and the normalised bodies are equal. The report gives agreement overall, per probe and per tool, `agreement_ignoring_order` for steps that differ only in item order, and each step's differences, at most 20, -with a JSON pointer and both values cut short: +with a JSON pointer and both values cut short. Each step reports `differences_total` before that cap, +`differences_omitted`, and `differences_truncated` (true when entries were omitted). Agreement and categories use the +complete comparison. Omitted entries are not retained, so `--full-output` cannot recover them; inspect the original +capture and probe Evidence to investigate beyond the returned pointers. + +The difference categories are: | Category | Meaning | | --------------- | ------------------------------------------------------------------------------------------------------------ | diff --git a/docs/specs/agent-environments/fidelity-compare/spec.md b/docs/specs/agent-environments/fidelity-compare/spec.md index 9fe70914131..56fce988eb6 100644 --- a/docs/specs/agent-environments/fidelity-compare/spec.md +++ b/docs/specs/agent-environments/fidelity-compare/spec.md @@ -86,7 +86,10 @@ credentials and resets it between sequences. environment refused the call), `not-served`, `no-tool` and `unmapped-id`, each with a JSON pointer and short values. - The report gives agreement overall, per probe and per tool, agreement ignoring item order beside it, the counts per category, the ids mapped and the values normalised, the records left uncompared, and each step's differences (at most - 20 per step). + 20 per step). Each step gives `differences_total` before that cap, `differences_omitted`, and + `differences_truncated`, true when entries were omitted. Equality and categories use all differences. + Omitted entries are not retained and `--full-output` cannot recover them; further investigation uses the original + capture and probe Evidence. ## Non-goals diff --git a/src/application/environment/environmentCompare.test.ts b/src/application/environment/environmentCompare.test.ts index c69917b7860..fea061cf5f0 100644 --- a/src/application/environment/environmentCompare.test.ts +++ b/src/application/environment/environmentCompare.test.ts @@ -64,6 +64,56 @@ const compare = (...probes: (readonly TimedCaptureRow[])[]) => { return report.value; }; +describe("step difference coverage", () => { + it("keeps categories from differences beyond the returned prefix", () => { + const fields = Object.fromEntries( + Array.from({ length: 20 }, (_, index) => [`field${String(index)}`, 0]), + ); + const capture = timedCaptureEvidence([["GET", "/api/config", fields]]); + const served = environmentStateServeFixture(capture); + const report = compareEnvironment({ + source: environmentServeSource(served), + captureValue: capture, + probes: [ + { + label: "changed", + value: timedCaptureEvidence([ + [ + "GET", + "/api/config", + { + ...Object.fromEntries( + Object.keys(fields).map((key) => [key, 1]), + ), + z_missing: true, + }, + ], + ]), + }, + ], + operation: "environment-compare", + }); + if (!report.ok) throw report.error; + const step = report.value.probes[0]!.results[0]!; + expect(step).toMatchObject({ + equal: false, + equal_ignoring_order: false, + categories: ["value", "missing-field"], + differences_total: 21, + differences_omitted: 1, + differences_truncated: true, + }); + expect(step.differences).toHaveLength(20); + expect(step.differences.every(({ category }) => category === "value")).toBe( + true, + ); + expect(report.value.summary.categories).toMatchObject({ + value: 1, + "missing-field": 1, + }); + }); +}); + describe("matchProbeRequest", () => { it("compares a repeated-query probe against the matching recorded response", () => { const recorded = timedCaptureEvidence([ diff --git a/src/application/environment/environmentCompare.ts b/src/application/environment/environmentCompare.ts index 1fa4eb3cf77..63c7254bc67 100644 --- a/src/application/environment/environmentCompare.ts +++ b/src/application/environment/environmentCompare.ts @@ -81,6 +81,10 @@ export interface CompareStep { readonly equal_ignoring_order: boolean; readonly categories: DifferenceCategory[]; readonly differences: Difference[]; + /** All differences computed for this step, before the returned list is capped. */ + readonly differences_total: number; + readonly differences_omitted: number; + readonly differences_truncated: boolean; readonly ignored_query: string[]; } @@ -287,6 +291,8 @@ const stepOf = ( outcome: { env_status: number | null; differences: Difference[] }, ): CompareStep => { const categories = [...new Set(outcome.differences.map((d) => d.category))]; + const differences = outcome.differences.slice(0, STEP_DIFFERENCE_LIMIT); + const omitted = outcome.differences.length - differences.length; return { ...base, tool: call?.tool.name ?? null, @@ -296,7 +302,10 @@ const stepOf = ( (difference) => difference.category === "order", ), categories, - differences: outcome.differences.slice(0, STEP_DIFFERENCE_LIMIT), + differences, + differences_total: outcome.differences.length, + differences_omitted: omitted, + differences_truncated: omitted > 0, ignored_query: [...(call?.ignoredQuery ?? [])], }; }; diff --git a/tests/boundary/cli/environmentCompareCli.test.ts b/tests/boundary/cli/environmentCompareCli.test.ts index b7776f3ac9d..0113e4b1a00 100644 --- a/tests/boundary/cli/environmentCompareCli.test.ts +++ b/tests/boundary/cli/environmentCompareCli.test.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { describe, expect } from "vitest"; +import type { CompareReport } from "../../../src/application/environment/environmentCompare.js"; import { analysisViewCaptureEvidence, environmentManifestCaptureFixture, @@ -28,6 +29,140 @@ import { import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +describe("rea environment-compare difference coverage", () => { + cliTest( + "discloses the bounded differences in ordinary and full output", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-environment-compare-coverage-", + ); + const counts = [0, 19, 20, 21, 25]; + const originals = new Map(); + for (const [name, value] of [ + ["capture", 0], + ["probe", 1], + ] as const) { + const entries = counts.map((count, index) => { + const text = JSON.stringify( + Object.fromEntries( + Array.from({ length: count }, (_, field) => [ + `field${String(field).padStart(2, "0")}`, + value, + ]), + ), + ); + return { + startedDateTime: new Date( + Date.parse("2026-10-27T20:00:00Z") + index * 1000, + ).toISOString(), + time: 0, + cache: {}, + timings: { send: 0, wait: 0, receive: 0 }, + request: { + method: "GET", + url: `https://app.example/api/config${String(count)}`, + httpVersion: "HTTP/1.1", + headers: [], + queryString: [], + cookies: [], + headersSize: -1, + bodySize: 0, + }, + response: { + status: 200, + statusText: "OK", + httpVersion: "HTTP/1.1", + headers: [], + cookies: [], + redirectURL: "", + headersSize: -1, + bodySize: -1, + content: { + mimeType: "application/json", + size: Buffer.byteLength(text), + text, + }, + }, + }; + }); + const har = join(root, `${name}.har`); + const text = JSON.stringify({ + log: { + version: "1.2", + creator: { name: "coverage-test", version: "1" }, + entries, + }, + }); + await writeFile(har, text); + originals.set(har, text); + const captured = await cli.run({ + arguments: ["inspect-web-network-capture", har, "har", "--json"], + }); + expect(captured.exitCode, captured.stderr).toBe(0); + const path = join(root, `${name}.json`); + const evidence = JSON.stringify(captured.json); + await writeFile(path, evidence); + originals.set(path, evidence); + } + const view = join(root, "view.json"); + await writeFile( + view, + JSON.stringify({ + source: { + kind: "inline", + evidence: JSON.parse(originals.get(join(root, "capture.json"))!), + }, + view: { kind: "environment-manifest" }, + }), + ); + const projected = await cli.run({ + arguments: ["inspect-analysis-view", view, "--json"], + }); + expect(projected.exitCode, projected.stderr).toBe(0); + const manifest = join(root, "manifest.json"); + const manifestText = JSON.stringify(projected.json); + await writeFile(manifest, manifestText); + originals.set(manifest, manifestText); + const args = [ + "environment-compare", + manifest, + "--evidence", + join(root, "capture.json"), + "--probe", + join(root, "probe.json"), + "--json", + ]; + const result = await cli.run({ arguments: args }); + expect(result.exitCode, result.stderr).toBe(0); + const report = result.json as CompareReport; + expect(report).toMatchObject({ + status: "complete", + summary: { steps: 5, equal: 1, categories: { value: 4 } }, + }); + expect(report.probes[0]!.results).toHaveLength(counts.length); + for (const [index, count] of counts.entries()) { + const step = report.probes[0]!.results[index]!; + expect(step).toMatchObject({ + equal: count === 0, + equal_ignoring_order: count === 0, + categories: count === 0 ? [] : ["value"], + differences_total: count, + differences_omitted: Math.max(0, count - 20), + differences_truncated: count > 20, + }); + expect(step.differences).toHaveLength(Math.min(count, 20)); + if (count >= 20) + expect(step.differences.at(-1)?.pointer).toBe("/field19"); + } + const full = await cli.run({ arguments: [...args, "--full-output"] }); + expect(full.exitCode, full.stderr).toBe(0); + expect(full.json).toMatchObject({ data: report }); + for (const [path, text] of originals) + expect(await readFile(path, "utf8")).toBe(text); + }, + ); +}); + /** The books environment and a probe of the application re-seeded under other ids, written as Evidence files. */ const writeCompareFiles = async (root: string) => { const fixture = environmentStateServeFixture(timedCaptureEvidence(BOOK_ROWS)); From e01fcfb4b2981a96ab817fe640eff35808e6153c Mon Sep 17 00:00:00 2001 From: Wenyu Chiou <162016108+WenyuChiou@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:49:28 -0500 Subject: [PATCH 33/38] fix(reference): Exclude Git pointer files from source identities (#7667) * fix(reference): Exclude Git pointer files from source identities Match .git pointer files in the default import policy after the existing directory rules, preserving ordinary-checkout exclusion provenance and Git HEAD discovery. Cover linked-worktree identities, repeated imports, nested metadata, similarly named source files, and configured-secret precedence. Independent review: parent independent review APPROVE for Draft CI on patch 61bddf108952c77858fa71c333b4bb8a05ab33e8b03f5c6bccd879cd19c7c379 and source tree 2d2372a9ec136eddac7743ce724c1b3ffc23013b. Review triggers: delegated shipping output, source inventory/file I/O behavior, and more than 50 lines. Validation: npm run check passed with Node 24.22.1/npm 11.16.0 on Windows; tracked Vitest regression 1 passed/3 Windows skips; test discovery passed. Eight offline Linux production imports passed on cached Node 22.19.0 as supporting evidence only. Pinned Linux/macOS Vitest cases await hosted CI. Fixes #7551 * test(reference): Isolate worktree fixtures from host Git settings Route every fixture Git command through one wrapper with a filtered child environment, owned empty global config/template/hook paths, and no system config reads. Keep resource cleanup under the existing temporary owner. These settings are scoped to synthetic child processes; host Git settings and this repository's commit/push checks are unchanged. Targeted independent review: parent review APPROVE on patch 4be9a2af3e40edac3d64f687170e966a87fcf762f1877f2c55016e120bcd90ac and tree a2ba91ee9cdb03873ff5fa81030a5f0608172930. The reviewer confirmed the native proof helper hash, complete fixture command coverage, and owned cleanup. Review triggers: delegated follow-up and fixture file/process boundaries. Validation: npm run check:fast passed; targeted Vitest 1 passed/3 native Windows skips. On pinned Node 24.22.1, the actual helper rejects inherited hooks/templates while commit, worktree creation, HEAD and status checks pass. The unisolated control copies the injected template and its commit fails. Refs #7551 --- src/application/ReferenceSourceImportTypes.ts | 2 + ...eferenceSourceGitMetadataExclusion.test.ts | 208 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 tests/boundary/filesystem/referenceSourceGitMetadataExclusion.test.ts diff --git a/src/application/ReferenceSourceImportTypes.ts b/src/application/ReferenceSourceImportTypes.ts index d9c2324b747..34ccdbd24ec 100644 --- a/src/application/ReferenceSourceImportTypes.ts +++ b/src/application/ReferenceSourceImportTypes.ts @@ -125,6 +125,8 @@ export interface ReferenceSourceImportOptions { export const DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS = [ ".git/", ".git/hooks/", + // Match pointer files after directory rules to preserve their provenance. + ".git", "node_modules/", "dist/", "build/", diff --git a/tests/boundary/filesystem/referenceSourceGitMetadataExclusion.test.ts b/tests/boundary/filesystem/referenceSourceGitMetadataExclusion.test.ts new file mode 100644 index 00000000000..1f072bd554e --- /dev/null +++ b/tests/boundary/filesystem/referenceSourceGitMetadataExclusion.test.ts @@ -0,0 +1,208 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import ignore from "ignore"; +import { describe, expect, it } from "vitest"; +import { DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS } from "../../../src/application/ReferenceSourceImportTypes.js"; +import { createHistoricalSourceManifest } from "../../../src/domain/referenceSourceGraph.js"; +import { execFileOutput } from "../../../src/process/ExecFileOutput.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { importReferenceSource } from "../../support/referenceSourceResourceScope.js"; + +const importTree = async ( + root: string, + secretPatterns: readonly string[] = [], + excludePaths: readonly string[] = [], +) => { + const result = await importReferenceSource({ + root, + caller: "git-metadata-test", + policy: { secretPatterns }, + excludePaths, + }); + if (!result.ok) throw result.error; + return result.value; +}; + +const fixtureGit = ( + directory: string, + configuration: string, + args: readonly string[], +) => + execFileOutput( + "git", + ["-c", `core.hooksPath=${join(configuration, "no-hooks")}`, ...args], + { + cwd: directory, + env: { + ...Object.fromEntries( + Object.entries(process.env).filter( + ([name]) => !name.toUpperCase().startsWith("GIT_"), + ), + ), + GIT_CONFIG_GLOBAL: join(configuration, "gitconfig"), + GIT_CONFIG_NOSYSTEM: "1", + GIT_TEMPLATE_DIR: join(configuration, "no-template"), + }, + }, + ); + +it("excludes Git metadata files and directories without broadening directory-only defaults", () => { + const matcher = ignore().add([...DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS]); + for (const path of [".git", ".git/", "nested/.git", "nested/.git/"]) + expect(matcher.ignores(path), path).toBe(true); + for (const path of [".gitignore", ".github", ".gitkeep", "dist"]) + expect(matcher.ignores(path), path).toBe(false); + expect(matcher.ignores("dist/")).toBe(true); + expect(matcher.test(".git").rule?.pattern).toBe(".git"); + expect(matcher.test(".git/").rule?.pattern).toBe(".git/"); +}); + +describe.skipIf(process.platform === "win32")( + "reference source Git metadata exclusions", + () => { + it("keeps linked-worktree identities stable while retaining HEAD and ordinary-checkout provenance", async () => { + const root = await createTestTempDirectory("rea-reference-git-metadata-"); + const repository = join(root, "repository"); + await Promise.all([ + mkdir(join(root, "no-hooks")), + mkdir(join(root, "no-template")), + writeFile(join(root, "gitconfig"), ""), + ]); + await mkdir(join(repository, "src"), { recursive: true }); + await writeFile( + join(repository, "src", "main.js"), + "export const value = 7;\n", + ); + await writeFile(join(repository, ".gitignore"), "*.log\n"); + const git = (...args: string[]) => fixtureGit(repository, root, args); + await git("init", "--initial-branch=main"); + await git("add", ".gitignore", "src/main.js"); + await git( + "-c", + "user.name=REA fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "fixture", + ); + const head = (await git("rev-parse", "HEAD")).stdout.trim(); + const leftRoot = join(root, "left"); + const rightRoot = join(root, "right"); + for (const worktree of [leftRoot, rightRoot]) { + await git("worktree", "add", "--detach", worktree, head); + expect( + (await fixtureGit(worktree, root, ["status", "--porcelain"])).stdout, + ).toBe(""); + } + expect(await readFile(join(leftRoot, ".git"), "utf8")).not.toBe( + await readFile(join(rightRoot, ".git"), "utf8"), + ); + const importSelectedTree = async (source: string) => { + const graph = await importTree(source); + expect(graph.vcs).toEqual({ kind: "git", head, dirty: null }); + expect( + graph.entries + .filter(({ kind }) => kind === "file") + .map(({ path }) => path), + ).toEqual([".gitignore", "src/main.js"]); + expect( + graph.entries.some( + ({ path }) => path === ".git" || path.startsWith(".git/"), + ), + ).toBe(false); + return graph; + }; + const left = await importSelectedTree(leftRoot); + const right = await importSelectedTree(rightRoot); + const repeated = await importSelectedTree(leftRoot); + expect(createHistoricalSourceManifest(left)).toEqual( + createHistoricalSourceManifest(right), + ); + expect(createHistoricalSourceManifest(repeated)).toEqual( + createHistoricalSourceManifest(left), + ); + for (const graph of [left, right]) + expect(graph.exclusions).toEqual([ + { + path: ".git", + entry_kind: "file", + reason: "default-ignored", + pattern: ".git", + }, + ]); + const ordinary = await importSelectedTree(repository); + expect(ordinary.exclusions).toEqual([ + { + path: ".git", + entry_kind: "directory", + reason: "default-ignored", + pattern: ".git/", + }, + ]); + }); + + it.each(["file", "directory"] as const)( + "prunes nested .git %s metadata and preserves configured-secret precedence", + async (kind) => { + const root = await createTestTempDirectory("rea-reference-nested-git-"); + const nested = join(root, "nested"); + await mkdir(nested); + if (kind === "file") + await writeFile( + join(nested, ".git"), + "gitdir: /checkout-specific/metadata\n", + ); + else { + await mkdir(join(nested, ".git", "hooks"), { recursive: true }); + await writeFile( + join(nested, ".git", "hooks", "pre-commit"), + "metadata\n", + ); + } + for (const name of [ + ".gitignore", + ".github", + ".gitkeep", + "dist", + "source.js", + ]) + await writeFile(join(nested, name), ""); + for (const secretPatterns of [[], [".git"]]) { + const graph = await importTree( + root, + secretPatterns, + secretPatterns.length === 0 ? [] : ["!.git"], + ); + expect(graph.exclusions).toEqual([ + { + path: "nested/.git", + entry_kind: kind, + reason: + secretPatterns.length === 0 + ? "default-ignored" + : "configured-secret", + pattern: + secretPatterns.length === 0 && kind === "directory" + ? ".git/" + : ".git", + }, + ]); + expect( + graph.entries + .filter(({ kind }) => kind === "file") + .map(({ path }) => path), + ).toEqual([ + "nested/.github", + "nested/.gitignore", + "nested/.gitkeep", + "nested/dist", + "nested/source.js", + ]); + } + }, + ); + }, +); From 8db538a7ca85318d320e83fd8d4d714cfd978c14 Mon Sep 17 00:00:00 2001 From: Paritosh Lahre <155938586+SandstromPL@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:20:00 +0530 Subject: [PATCH 34/38] fix(browser): keep negative script columns Chrome reports (#8684) * fix(browser): keep negative script columns Chrome reports Chrome reported a Debugger.scriptParsed event with startColumn -60 and endColumn -15 for an inline script in WordPress's helper frame. CDP declares these columns as plain integers, but REA's admission required min(0), so one script failed the whole observe-web-execution run with unreadable_output. CDP resource columns now share one representation, cdpResourceColumnSchema, for script start/end columns, initiator call frames, parser initiators and event listeners. Positions on a script's first line include its start column, so keeping the reported values keeps the documented script-relative conversion consistent. The domain contract and web runtime docs state that a negative column does not locate a column in the resource. Fixes #8674 Co-Authored-By: Claude Opus 5.5 * fix(browser): apply the CDP column policy to passive capture inspect_web_page and discover_webmcp_tools still read CDP columns through cdpSourceCoordinates, whose own >= 0 check turned a negative column into null and reported invalid_protocol_value. WebMCP discovery also dropped the tool's whole registration_source. cdpResourceColumnSchema now lives with the domain's CDP observation schemas. Runtime admission, the passive coordinate helper and the runtime, console, initiator and WebMCP output schemas all use it. Lines keep their non-negative rule. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/browser-observation.md | 2 +- docs/web-runtime.md | 2 +- src/browser/CdpCaptureEventHelpers.ts | 19 ++-- src/browser/CdpNetworkEventFacts.ts | 3 +- src/browser/CdpScriptContextFacts.ts | 5 +- src/browser/execution/CdpRuntimeProtocol.ts | 5 +- src/domain/browserObservationSchemas.ts | 5 +- src/domain/cdpObservationSchemas.ts | 9 ++ src/domain/webMcpDiscovery.ts | 3 +- src/domain/webRuntime.ts | 12 +- .../browser/cdpWebRuntimeReview.test.ts | 94 +++++++++++++++- .../mcp/browserSourceCoordinates.test.ts | 103 +++++++++++++++--- tests/boundary/mcp/webRuntimeMcp.test.ts | 90 +++++++++++++++ tests/fixtures/webRuntime.ts | 52 +++++++++ 14 files changed, 366 insertions(+), 38 deletions(-) diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 22265bd53fe..c2830a91a0f 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -500,7 +500,7 @@ REA retains local observation data and removes only URL userinfo credentials, ex connection/frame inventories; matching direction/opcode sequences alone do not prove alignment after drops. Redirect resource types use only shared known metadata: an omitted type leaves equality unknown rather than proving a modification. -- Console observations with a stack source retain call type, argument types, the CDP `Runtime.Timestamp` in milliseconds since Unix epoch, and source location. An absent or invalid timestamp is `null` and makes `console_events` incomplete; an observed zero remains zero. Argument types are listed by CDP argument position. An absent or non-array argument inventory is `null`, and a malformed argument keeps its position as `unknown`; either makes `console_events` incomplete, while an observed empty inventory remains `[]`. A malformed stack or top stack frame is not replaced by its caller: the console event is excluded, and a request initiator location or WebMCP `registration_source` is `null`; each is reported as `invalid_protocol_value`. This epoch-millisecond clock is separate from the monotonic-second timestamps in `observe-web-session`. When console text capture is selected, already-delivered primitive values are retained verbatim with their original `argument_index`; objects, getters, and remote properties are never expanded. Text capture is `unavailable` when the argument inventory is unknown. +- Console observations with a stack source retain call type, argument types, the CDP `Runtime.Timestamp` in milliseconds since Unix epoch, and source location. An absent or invalid timestamp is `null` and makes `console_events` incomplete; an observed zero remains zero. Argument types are listed by CDP argument position. An absent or non-array argument inventory is `null`, and a malformed argument keeps its position as `unknown`; either makes `console_events` incomplete, while an observed empty inventory remains `[]`. A malformed stack or top stack frame is not replaced by its caller: the console event is excluded, and a request initiator location or WebMCP `registration_source` is `null`; each is reported as `invalid_protocol_value`. Source locations are CDP's zero-based lines and UTF-16 columns. A negative column, which Chrome reports on the first line of some inline scripts, is kept as reported and does not locate a column in the source URL; an invalid line or a non-integer column is `null` (WebMCP `registration_source` is `null`) and is reported as `invalid_protocol_value`. This epoch-millisecond clock is separate from the monotonic-second timestamps in `observe-web-session`. When console text capture is selected, already-delivered primitive values are retained verbatim with their original `argument_index`; objects, getters, and remote properties are never expanded. Text capture is `unavailable` when the argument inventory is unknown. - WebSocket observations retain direction, opcode, and payload byte length. A missing or malformed CDP opcode is returned as `null`, with opcode-dependent payload byte length and shape also `null`, and marks `websocket_frames` incomplete. Missing or wrong-typed payload data is not treated as empty; its byte length is `null`, and a text payload shape is unknown. Numeric opcode `0` and an observed empty payload remain distinct from missing data. Opcode is nullable in the public inspection schema, so clients must handle unknown protocol values. When shape capture is selected, text messages are classified as text or complete value-free JSON shape; binary bytes, hashes, prefixes, and raw payloads are never retained. - Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` selection. Cookie fingerprints cover cookies applicable to the current main-frame URL, including its path, rather than every cookie path on that origin. Other storage remains scoped to the origin. Cache fingerprints hash all decoded response bytes, including bodies above 64 KiB, subject to the [64 MiB per-message CDP limit](#request-boundary). Missing or undecodable cache bodies and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`. - Unavailable or malformed storage inventories mark `storage_keys` unavailable, even without fingerprints. Comparisons suppress inferred key additions and removals while preserving modifications proven by complete fingerprints on both sides. Previously exported captures are not retroactively corrected; recapture them to obtain these coverage markers. diff --git a/docs/web-runtime.md b/docs/web-runtime.md index abccf9dc557..189d6fc4390 100644 --- a/docs/web-runtime.md +++ b/docs/web-runtime.md @@ -22,7 +22,7 @@ Results retain: - Main-document script inventory, exact session script IDs, resource start coordinates, sourceURL/source-map declarations, producer hash and independently computed UTF-8 text digest. - Armed-window requests from the selected main frame and selected origins, complete reported initiator objects (including stack hierarchy/descriptions), derived callsites, exact script-ID associations and unresolved async parent IDs. Source association does not establish UI causality. Callsite and listener `line_number`/`column_number` values are CDP's zero-based lines and UTF-16 columns in the resource named by `url`. With a `script_id`, that is the script's enclosing resource: the script file for an external script, or the HTML document for an inline script. `resource_start` is where the script text starts in that resource. A callsite with an empty `script_id` comes from the request's reported initiator rather than a script call frame, such as markup that the parser read in a document or a module import in a script file. It still locates a position in `url`, but has no session-script association, so it cannot be joined to captured source or `resource_start`. `trace_web_source_location` takes one-based, script-relative lines and zero-based UTF-16 columns. -For an external script, where `url` is the script file, use `line = line_number + 1` and keep `column = column_number`; this also applies to an empty-`script_id` initiator position in a script file. For an inline script, which requires a `script_id` and its `resource_start`, use `line = line_number - resource_start.line_number + 1`. Subtract `resource_start.column_number` from `column_number` only when `line_number === resource_start.line_number`; keep the reported column on later lines. Columns remain zero-based. This conversion requires known source-start and column coordinates; preserve unavailable coordinates as unknown. +For an external script, where `url` is the script file, use `line = line_number + 1` and keep `column = column_number`; this also applies to an empty-`script_id` initiator position in a script file. For an inline script, which requires a `script_id` and its `resource_start`, use `line = line_number - resource_start.line_number + 1`. Subtract `resource_start.column_number` from `column_number` only when `line_number === resource_start.line_number`; keep the reported column on later lines. Columns remain zero-based. This conversion requires known source-start and column coordinates; preserve unavailable coordinates as unknown. Chrome reports a negative start column for some inline scripts, such as one in a helper frame, and positions on that script's first line include it. REA keeps those columns as reported rather than failing the observation; a negative column does not locate a column in `url`, but the subtraction above still gives the script-relative column. The coverage counter interval starts at backend acceptance before the armed receipt and ends at the resetting sample after the local timer. Counts may include execution during these command intervals; request events are restricted to the locally armed window. The result retains both backend timestamps and the distinct local clock. diff --git a/src/browser/CdpCaptureEventHelpers.ts b/src/browser/CdpCaptureEventHelpers.ts index e3e6621e41d..f328862c7ae 100644 --- a/src/browser/CdpCaptureEventHelpers.ts +++ b/src/browser/CdpCaptureEventHelpers.ts @@ -1,5 +1,6 @@ import { err, ok, type Result } from "../domain/result.js"; import { mediaTypeEssence } from "../domain/analysisView/recordedMediaType.js"; +import { cdpResourceColumnSchema } from "../domain/cdpObservationSchemas.js"; import { admitRecords, isHttpUrl, @@ -57,18 +58,22 @@ export const exclusionReasonForUrl = ( ? "disallowed_origin" : "unsupported_url"; -/** Preserve omitted and invalid protocol offsets as unknown, never invented coordinates. */ +/** + * Preserve omitted and invalid protocol offsets as unknown, never invented + * coordinates. Columns follow the shared CDP resource-column policy, which + * keeps a negative column Chrome reports. + */ export const cdpSourceCoordinates = (frame: UnknownRecord | undefined) => { - const valid = (value: unknown): value is number => + const validLine = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0; const line = frame?.lineNumber; - const column = frame?.columnNumber; + const column = cdpResourceColumnSchema.safeParse(frame?.columnNumber); return { - line: valid(line) ? line : null, - column: valid(column) ? column : null, + line: validLine(line) ? line : null, + column: column.success ? column.data : null, invalid: - (line !== undefined && !valid(line)) || - (column !== undefined && !valid(column)), + (line !== undefined && !validLine(line)) || + (frame?.columnNumber !== undefined && !column.success), }; }; diff --git a/src/browser/CdpNetworkEventFacts.ts b/src/browser/CdpNetworkEventFacts.ts index 54f3f266326..ae7fa548d3d 100644 --- a/src/browser/CdpNetworkEventFacts.ts +++ b/src/browser/CdpNetworkEventFacts.ts @@ -11,6 +11,7 @@ import { recordValue, type UnknownRecord, } from "./CdpCaptureValues.js"; +import { cdpResourceColumnSchema } from "../domain/cdpObservationSchemas.js"; const integer = z.int().min(0); /** Active runtime admission remains stricter than passive request observations. */ @@ -25,7 +26,7 @@ export const runtimeRequestSchema = z.object({ stack: z.unknown().optional(), url: z.string().optional(), lineNumber: integer.optional(), - columnNumber: integer.optional(), + columnNumber: cdpResourceColumnSchema.optional(), }) .passthrough(), }); diff --git a/src/browser/CdpScriptContextFacts.ts b/src/browser/CdpScriptContextFacts.ts index 0ba1ae0cf9c..a95d1db86a4 100644 --- a/src/browser/CdpScriptContextFacts.ts +++ b/src/browser/CdpScriptContextFacts.ts @@ -4,6 +4,7 @@ import { numberValue, recordValue, } from "./CdpCaptureValues.js"; +import { cdpResourceColumnSchema } from "../domain/cdpObservationSchemas.js"; const integer = z.int().min(0); const contextAuxDataSchema = z @@ -31,9 +32,9 @@ export const runtimeScriptParsedSchema = scriptDeclaration.extend({ scriptId: z.string().min(1), executionContextId: integer, startLine: integer, - startColumn: integer, + startColumn: cdpResourceColumnSchema, endLine: integer, - endColumn: integer, + endColumn: cdpResourceColumnSchema, hash: z.string().optional(), sourceMapURL: z.string().optional(), hasSourceURL: z.boolean().optional(), diff --git a/src/browser/execution/CdpRuntimeProtocol.ts b/src/browser/execution/CdpRuntimeProtocol.ts index a9fdb5bff2b..2b90e09d8cf 100644 --- a/src/browser/execution/CdpRuntimeProtocol.ts +++ b/src/browser/execution/CdpRuntimeProtocol.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { cdpResourceColumnSchema } from "../../domain/cdpObservationSchemas.js"; const integer = z.int().min(0); const range = z.object({ @@ -30,7 +31,7 @@ export const runtimeStackSchema = z.object({ scriptId: z.string(), url: z.string(), lineNumber: integer, - columnNumber: integer, + columnNumber: cdpResourceColumnSchema, functionName: z.string(), }), ), @@ -49,7 +50,7 @@ export const runtimeListenersSchema = z.object({ once: z.boolean(), scriptId: z.string(), lineNumber: integer, - columnNumber: integer, + columnNumber: cdpResourceColumnSchema, }), ), }); diff --git a/src/domain/browserObservationSchemas.ts b/src/domain/browserObservationSchemas.ts index bafa37e644b..b020db89db1 100644 --- a/src/domain/browserObservationSchemas.ts +++ b/src/domain/browserObservationSchemas.ts @@ -4,6 +4,7 @@ import { utcDateTimeSchema } from "./offsetDateTime.js"; import { browserCompletenessSchema } from "./browserCompleteness.js"; import { cdpDomSnapshotSchema, + cdpResourceColumnSchema, cdpScriptSourceSchema, cdpScriptMetadataSchema, } from "./cdpObservationSchemas.js"; @@ -125,7 +126,7 @@ const browserNetworkRequestSchema = z.object({ type: z.string(), url: z.string().nullable(), line: z.int().min(0).nullable(), - column: z.int().min(0).nullable(), + column: cdpResourceColumnSchema.nullable(), }), body_shapes: z.object({ status: z.enum(["not_approved", "included", "partial", "unavailable"]), @@ -149,7 +150,7 @@ const browserConsoleEventSchema = z.object({ ), url: z.string().nullable(), line: z.int().min(0).nullable(), - column: z.int().min(0).nullable(), + column: cdpResourceColumnSchema.nullable(), text_capture: z.object({ status: z.enum(["not_approved", "included", "unavailable"]), values: z.array( diff --git a/src/domain/cdpObservationSchemas.ts b/src/domain/cdpObservationSchemas.ts index 8af603df056..6e705d11924 100644 --- a/src/domain/cdpObservationSchemas.ts +++ b/src/domain/cdpObservationSchemas.ts @@ -1,6 +1,15 @@ import { z } from "zod"; import { webTextArtifactSchema } from "./webContentArtifact.js"; +/** + * Zero-based UTF-16 column in a resource, as CDP reports it. CDP declares + * columns as plain integers. Chrome reports a negative start column for some + * inline scripts, and positions on a script's first line include that start + * column, so a negative column is kept as reported. It does not locate a + * column in the resource. + */ +export const cdpResourceColumnSchema = z.int(); + /** Structural DOM facts shared by passive web and Electron observations. */ export const cdpDomNodeSchema = z.object({ index: z.number().int().min(0), diff --git a/src/domain/webMcpDiscovery.ts b/src/domain/webMcpDiscovery.ts index a44dae347f0..bbcbce5fa25 100644 --- a/src/domain/webMcpDiscovery.ts +++ b/src/domain/webMcpDiscovery.ts @@ -7,6 +7,7 @@ import { browserObservationEndpointSchema, } from "./browserObservation.js"; import { browserVersionSchema } from "./browserObservationSchemas.js"; +import { cdpResourceColumnSchema } from "./cdpObservationSchemas.js"; import { jsonValueSchema } from "./jsonValue.js"; import { digestSchema, prefixedDigestSchema } from "./../domain/digests.js"; @@ -44,7 +45,7 @@ const webMcpToolSchema = z.object({ .object({ url: z.string(), line: z.int().min(0).nullable(), - column: z.int().min(0).nullable(), + column: cdpResourceColumnSchema.nullable(), }) .nullable(), trust: z.literal("page-declared-untrusted"), diff --git a/src/domain/webRuntime.ts b/src/domain/webRuntime.ts index b707cb3a224..d5657502156 100644 --- a/src/domain/webRuntime.ts +++ b/src/domain/webRuntime.ts @@ -9,6 +9,7 @@ import { webRuntimeCommandTimeoutSchema, } from "./browserDeadline.js"; import { jsonObjectSchema } from "./jsonValue.js"; +import { cdpResourceColumnSchema } from "./cdpObservationSchemas.js"; // CDP positions are zero-based and relative to the resource named by url. // With a script ID that is the script's enclosing resource: the script file, @@ -18,17 +19,18 @@ import { jsonObjectSchema } from "./jsonValue.js"; // association to join captured source or resource_start. // trace_web_source_location takes one-based, script-relative lines, so the // advertised units keep callers from passing a position off by one or by an -// inline script's offset. +// inline script's offset. Columns are kept as reported even when Chrome reports +// a negative one, so subtracting resource_start's column stays consistent. const SCRIPT_ID = "CDP session script ID; empty for a position a request initiator reported, which has no session-script association for joining captured source or resource_start."; const RESOURCE_LINE = "Zero-based line in the resource named by url, as reported by CDP: the script file for an external script (including a module-import initiator), or the HTML document for an inline script or parser markup."; const RESOURCE_COLUMN = - "Zero-based UTF-16 column on that line of the resource named by url, as reported by CDP."; + "Zero-based UTF-16 column on that line of the resource named by url, as reported by CDP. Chrome can report a negative column, for example on the first line of a script whose resource_start column is negative; it then does not locate a column in url."; const START_LINE = "Zero-based line where the script text starts in its enclosing resource (the HTML document for an inline script)."; const START_COLUMN = - "Zero-based UTF-16 column where the script text starts on that line of its enclosing resource."; + "Zero-based UTF-16 column where the script text starts on that line of its enclosing resource. Chrome reports a negative column for some inline scripts; it is kept as reported and does not locate a column in that resource."; /** Scope for one externally owned page; omitted origins select its current origin. */ export const webRuntimeScopeSchema = z.strictObject({ @@ -53,7 +55,7 @@ export const webRuntimeSourceSchema = z.object({ resource_start: z .object({ line_number: z.int().min(0).describe(START_LINE), - column_number: z.int().min(0).describe(START_COLUMN), + column_number: cdpResourceColumnSchema.describe(START_COLUMN), }) .nullable(), source: z.discriminatedUnion("state", [ @@ -77,7 +79,7 @@ export const webRuntimeLocationSchema = z.object({ script_id: z.string().describe(SCRIPT_ID), url: z.string().nullable(), line_number: z.int().min(0).describe(RESOURCE_LINE), - column_number: z.int().min(0).nullable().describe(RESOURCE_COLUMN), + column_number: cdpResourceColumnSchema.nullable().describe(RESOURCE_COLUMN), function_name: z.string().nullable(), source_association: z.enum(["script_id", "unknown"]), }); diff --git a/tests/boundary/browser/cdpWebRuntimeReview.test.ts b/tests/boundary/browser/cdpWebRuntimeReview.test.ts index f8243d36e4a..f6338ee9d22 100644 --- a/tests/boundary/browser/cdpWebRuntimeReview.test.ts +++ b/tests/boundary/browser/cdpWebRuntimeReview.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it, onTestFinished } from "vitest"; import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRuntimeProvider.js"; import { observeWebExecutionInputSchema } from "../../../src/domain/webExecution.js"; import { inspectWebEventListenersInputSchema } from "../../../src/domain/webEventListeners.js"; -import { startRuntimeBrowser } from "../../fixtures/webRuntime.js"; +import { + negativeColumnScript, + negativeColumnScriptUrl, + startNegativeColumnBrowser, + startRuntimeBrowser, +} from "../../fixtures/webRuntime.js"; describe("runtime document identity across same-document URL changes", () => { it.each(["execution", "listeners"] as const)( @@ -217,3 +222,90 @@ describe("runtime collector failure and uncertain source metadata", () => { ).toBe(false); }); }); + +it("keeps negative columns Chrome reports for an inline script and its first-line callsites", async () => { + const browser = await startNegativeColumnBrowser(); + onTestFinished(() => browser.close()); + const scriptUrl = negativeColumnScriptUrl(browser.endpoint); + const result = await new CdpWebRuntimeProvider().observeExecution( + observeWebExecutionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + target_id: "allowed-page", + observation_ms: 30, + }), + { + progress: { + report: async (update) => { + if (update.completed !== 1) return; + for (const [requestId, initiator] of [ + [ + "script-request", + { + type: "script", + stack: { + callFrames: [ + { + scriptId: negativeColumnScript.scriptId, + url: scriptUrl, + functionName: "", + lineNumber: 21, + columnNumber: -40, + }, + ], + }, + }, + ], + [ + "parser-request", + { + type: "parser", + url: scriptUrl, + lineNumber: 21, + columnNumber: -30, + }, + ], + ] as const) + browser.emitEvent({ + method: "Network.requestWillBeSent", + sessionId: "session-1", + params: { + requestId, + frameId: "runtime-main", + timestamp: 12, + request: { + url: `${browser.endpoint}/${requestId}`, + method: "GET", + }, + initiator, + }, + }); + }, + }, + }, + ); + if (!result.ok) throw result.error; + expect( + result.value.requests.map(({ callsites }) => + callsites.map(({ script_id, line_number, column_number }) => ({ + script_id, + line_number, + column_number, + })), + ), + ).toEqual([ + [ + { + script_id: negativeColumnScript.scriptId, + line_number: 21, + column_number: -40, + }, + ], + [{ script_id: "", line_number: 21, column_number: -30 }], + ]); + expect(result.value.sources).toContainEqual( + expect.objectContaining({ + script_id: negativeColumnScript.scriptId, + resource_start: { line_number: 21, column_number: -60 }, + }), + ); +}); diff --git a/tests/boundary/mcp/browserSourceCoordinates.test.ts b/tests/boundary/mcp/browserSourceCoordinates.test.ts index 3724412a2f5..c26384beddb 100644 --- a/tests/boundary/mcp/browserSourceCoordinates.test.ts +++ b/tests/boundary/mcp/browserSourceCoordinates.test.ts @@ -1,22 +1,21 @@ +import { Ajv2020 } from "ajv/dist/2020.js"; import { expect, it, onTestFinished } from "vitest"; import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; -it("reports malformed source coordinates as unknown through MCP", async () => { +/** Report one call frame for a console event, a request initiator and a WebMCP registration. */ +const inspectSourceCoordinates = async (position: { + readonly lineNumber: number; + readonly columnNumber: number; +}) => { const browser = await startFakeCdpBrowser({ webMcpTools: true, webMcpStackTrace: (origin) => ({ - callFrames: [ - { url: `${origin}/app.js`, lineNumber: -3, columnNumber: 7.5 }, - ], + callFrames: [{ url: `${origin}/app.js`, ...position }], }), commandEvents(command, origin) { - const frame = { - url: `${origin}/app.js`, - lineNumber: -3, - columnNumber: 7.5, - }; + const frame = { url: `${origin}/app.js`, ...position }; const session = command.sessionId === undefined ? {} : { sessionId: command.sessionId }; if (command.method === "Runtime.enable") @@ -38,7 +37,7 @@ it("reports malformed source coordinates as unknown through MCP", async () => { ...session, method: "Network.requestWillBeSent", params: { - requestId: "bad-coordinates", + requestId: "coordinate-request", timestamp: 1, type: "Script", request: { url: `${origin}/app.js`, method: "GET" }, @@ -71,6 +70,18 @@ it("reports malformed source coordinates as unknown through MCP", async () => { name: "inspect_web_page", arguments: args, }); + const tools = await client.callTool({ + name: "discover_webmcp_tools", + arguments: args, + }); + return { client, page, tools }; +}; + +it("reports malformed source coordinates as unknown through MCP", async () => { + const { page, tools } = await inspectSourceCoordinates({ + lineNumber: -3, + columnNumber: 7.5, + }); expect(page.isError).not.toBe(true); expect(page.structuredContent).toMatchObject({ normalized_result: { @@ -80,7 +91,7 @@ it("reports malformed source coordinates as unknown through MCP", async () => { network: { requests: expect.arrayContaining([ expect.objectContaining({ - request_id: "bad-coordinates", + request_id: "coordinate-request", initiator: expect.objectContaining({ line: null, column: null }), }), ]), @@ -99,10 +110,6 @@ it("reports malformed source coordinates as unknown through MCP", async () => { }, }, }); - const tools = await client.callTool({ - name: "discover_webmcp_tools", - arguments: args, - }); expect(tools.isError).not.toBe(true); expect(tools.structuredContent).toMatchObject({ normalized_result: { @@ -120,3 +127,69 @@ it("reports malformed source coordinates as unknown through MCP", async () => { }, }); }, 20_000); + +it("keeps a negative column Chrome reports in passive source locations", async () => { + const { client, page, tools } = await inspectSourceCoordinates({ + lineNumber: 21, + columnNumber: -40, + }); + const advertised = (await client.listTools()).tools; + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + for (const [name, response] of [ + ["inspect_web_page", page], + ["discover_webmcp_tools", tools], + ] as const) { + expect(response.isError).not.toBe(true); + const outputSchema: Record | undefined = advertised.find( + (tool) => tool.name === name, + )?.outputSchema; + if (outputSchema === undefined) + throw new Error(`Missing advertised output schema: ${name}`); + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + } + const location = { line: 21, column: -40 }; + expect(page.structuredContent).toMatchObject({ + normalized_result: { + console: { events: [expect.objectContaining(location)] }, + network: { + requests: expect.arrayContaining([ + expect.objectContaining({ + request_id: "coordinate-request", + initiator: expect.objectContaining(location), + }), + ]), + }, + }, + }); + expect(tools.structuredContent).toMatchObject({ + normalized_result: { + tools: { + items: [ + expect.objectContaining({ + registration_source: expect.objectContaining(location), + }), + ], + }, + }, + }); + for (const [response, section] of [ + [page, "console_events"], + [page, "network_initiators"], + [tools, "webmcp_tools"], + ] as const) + expect(response.structuredContent).toMatchObject({ + normalized_result: { + completeness: { + excluded: expect.not.arrayContaining([ + expect.objectContaining({ + section, + reason: "invalid_protocol_value", + }), + ]), + }, + }, + }); +}, 20_000); diff --git a/tests/boundary/mcp/webRuntimeMcp.test.ts b/tests/boundary/mcp/webRuntimeMcp.test.ts index 915214b2ce7..68ea80bdd24 100644 --- a/tests/boundary/mcp/webRuntimeMcp.test.ts +++ b/tests/boundary/mcp/webRuntimeMcp.test.ts @@ -11,8 +11,10 @@ import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { cliTest } from "../../support/cli/cliFixture.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { + negativeColumnScript, runtimeSourceA, runtimeSourceB, + startNegativeColumnBrowser, startRuntimeBrowser, } from "../../fixtures/webRuntime.js"; @@ -179,6 +181,94 @@ cliTest( 20_000, ); +cliTest( + "delivers negative columns Chrome reports through MCP and the compiled CLI", + async ({ cli }) => { + const browser = await startNegativeColumnBrowser(); + const provider = new CdpWebRuntimeProvider(); + const session = createTestBinarySession(() => { + throw new Error("Binary provider must not start"); + }); + const server = createServer( + { kind: "session", session }, + { webRuntime: new WebRuntimeService(provider) }, + ); + const client = new Client({ + name: "runtime-negative-columns", + version: "1", + }); + onTestFinished(async () => { + await client.close(); + await server.close(); + await provider.close(); + await session.close(); + await browser.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const advertised = (await client.listTools()).tools.find( + ({ name }) => name === "inspect_web_event_listeners", + )?.outputSchema; + if (advertised === undefined) + throw new Error("Missing advertised listener output schema"); + const outputSchema: Record = advertised; + const response = await client.callTool({ + name: "inspect_web_event_listeners", + arguments: { + cdp_endpoint: browser.endpoint, + target_id: "allowed-page", + allowed_origins: [browser.allowedOrigin], + selector: "#selected", + }, + }); + expect(response.isError).not.toBe(true); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const cliResult = await cli.run({ + arguments: [ + "inspect-web-event-listeners", + browser.endpoint, + "allowed-page", + "#selected", + "--allowed-origins", + browser.allowedOrigin, + "--json", + ], + environment: process.env, + }); + expect(cliResult.exitCode).toBe(0); + for (const evidence of [ + toolContract("inspect_web_event_listeners").outputSchema.parse( + response.structuredContent, + ), + cliResult.json, + ]) + expect(parseEvidence(evidence).normalized_result).toMatchObject({ + listeners: [ + { + location: { + script_id: negativeColumnScript.scriptId, + line_number: 21, + column_number: -50, + }, + }, + ], + sources: [ + { + script_id: negativeColumnScript.scriptId, + resource_start: { line_number: 21, column_number: -60 }, + }, + ], + }); + }, + 20_000, +); + it.each([ { name: "observe_web_execution" as const, diff --git a/tests/fixtures/webRuntime.ts b/tests/fixtures/webRuntime.ts index 5b93bccd6d3..9d7dcc1db00 100644 --- a/tests/fixtures/webRuntime.ts +++ b/tests/fixtures/webRuntime.ts @@ -162,3 +162,55 @@ const runtimeEvents = (command: FakeCdpCommand, origin: string) => { }, })); }; + +/** Positions Chrome 156 reported for an inline script in a helper frame. */ +export const negativeColumnScript = { + scriptId: "helper-inline", + executionContextId: 7, + startLine: 21, + startColumn: -60, + endLine: 21, + endColumn: -15, + scriptLanguage: "JavaScript", +} as const; +export const negativeColumnScriptUrl = (origin: string) => + `${origin}/empty.html?cross-origin-isolated=1`; + +/** + * Replays that script and a listener on its first line. Positions on a + * script's first line include its start column, so the listener's is negative. + */ +export const startNegativeColumnBrowser = () => + startRuntimeBrowser({ + commandEvents: (command, origin) => + command.method === "Debugger.enable" + ? [ + { + ...(command.sessionId === undefined + ? {} + : { sessionId: command.sessionId }), + method: "Debugger.scriptParsed", + params: { + ...negativeColumnScript, + url: negativeColumnScriptUrl(origin), + }, + }, + ] + : undefined, + commandResult: (command) => + command.method === "DOMDebugger.getEventListeners" + ? { + listeners: [ + { + type: "message", + useCapture: false, + passive: false, + once: false, + scriptId: negativeColumnScript.scriptId, + lineNumber: 21, + columnNumber: -50, + }, + ], + } + : undefined, + }); From 4b961e3715375971aee6dec848827c9c072d947b Mon Sep 17 00:00:00 2001 From: Liu Jie Date: Tue, 3 Nov 2026 19:50:33 +1300 Subject: [PATCH 35/38] fix(cli): protect Amp settings during default registration (#8076) --- src/cli.ts | 43 ++- src/cliBuiltinArguments.ts | 25 +- .../cli/defaultAmpRegistration.test.ts | 362 ++++++++++++++++++ 3 files changed, 421 insertions(+), 9 deletions(-) create mode 100644 tests/boundary/cli/defaultAmpRegistration.test.ts diff --git a/src/cli.ts b/src/cli.ts index b9829e68dd1..84b7b135224 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -252,7 +252,7 @@ export const createCli = ( ); cli.serve = (arguments_ = process.argv.slice(2), options) => { if (complete(arguments_, options ?? {})) return Promise.resolve(); - const normalized = normalizeCliArguments(arguments_); + let normalized = normalizeCliArguments(arguments_); const { command, flags } = splitIncurBuiltinArguments(normalized); const builtin = command[0] === PRODUCT_IDENTITY.cliBinary ? 1 : 0; if ( @@ -320,10 +320,34 @@ export const createCli = ( // prevents a save. Guard that path: reject a settings file REA cannot // update, back up the file, preserve the caller's environment, and verify // the registration landed. + // SyncMcp.register also writes Amp when no agents are selected. Mode + // switches return before registration and must not touch its settings. + // Use the same normalized arguments for selection and dispatch, including + // non-Amp equals forms. Incur consumes command values before agent flags. + normalized = serveArguments; + const registration = splitIncurBuiltinArguments(serveArguments); + const rest = registration.command.slice(builtin + 2); + const agents: string[] = []; + for (let index = 0; index < rest.length; index += 1) { + if ( + (rest[index] === "--command" || rest[index] === "-c") && + rest[index + 1] + ) + index += 1; + else if (rest[index] === "--agent" && rest[index + 1]) + agents.push(rest[++index]!); + } const ampRequested = - command.some( - (token, index) => token === "--agent" && command[index + 1] === "amp", - ) || command.includes("--agent=amp"); + ![ + "--help", + "-h", + "--llms", + "--llms-full", + "--mcp", + "--update", + "--incur-update-check", + ].some((flag) => registration.flags.has(flag)) && + (agents.length === 0 || agents.includes("amp")); if (ampRequested) { const settingsPath = join(homedir(), ".config", "amp", "settings.json"); let priorEnv: unknown; @@ -409,9 +433,17 @@ export const createCli = ( } const runRegistration = (backupPath?: string): Promise => { + let failed = false; const result = serveStrict( serveArguments, - options ?? {}, + { + ...options, + exit: (code) => { + failed ||= code !== 0; + if (options?.exit !== undefined) options.exit(code); + else process.exitCode = code; + }, + }, (tokens, selected) => serveDeclared(tokens, selected, (declared, declaredOptions) => serve( @@ -421,6 +453,7 @@ export const createCli = ( ), ); return result.then(() => { + if (failed) return; let failure: string | undefined; try { if (!existsSync(settingsPath)) diff --git a/src/cliBuiltinArguments.ts b/src/cliBuiltinArguments.ts index a75495a56b4..2dafd941ae4 100644 --- a/src/cliBuiltinArguments.ts +++ b/src/cliBuiltinArguments.ts @@ -46,8 +46,8 @@ export interface IncurBuiltinArgumentSplit { /** * Mirror Incur's builtin pass over normalized arguments without stopping at a - * rejected value, so callers can still see every later token. REA's boolean - * timing global follows the same valueless pass; no config flag is registered. + * rejected value, so callers can still see every later token. Then mirror the + * boolean timing global pass over the remaining tokens; no config is registered. */ export const splitIncurBuiltinArguments = ( arguments_: readonly string[], @@ -59,7 +59,7 @@ export const splitIncurBuiltinArguments = ( const argument = arguments_[index]!; const next = arguments_[index + 1]; if ( - INCUR_BUILTIN_FLAGS.has(argument) || + (INCUR_BUILTIN_FLAGS.has(argument) && argument !== "--timings") || // A following value belongs to a command-local `--version` option. (argument === "--version" && (next === undefined || next.startsWith("-"))) ) { @@ -73,7 +73,24 @@ export const splitIncurBuiltinArguments = ( } command.push(argument); } - return { command, flags, outputValues }; + // Incur extracts builtin output values first, then parses globals until `--`. + // A timing-shaped output value is data; a command option's value is still + // subject to this global pass before the registration builtin consumes it. + let globalsEnded = false; + const remaining = command.filter((token) => { + if (token === "--") globalsEnded = true; + if ( + !globalsEnded && + (token === "--timings" || + token === "--no-timings" || + token.startsWith("--timings=")) + ) { + flags.add(token); + return false; + } + return true; + }); + return { command: remaining, flags, outputValues }; }; /** diff --git a/tests/boundary/cli/defaultAmpRegistration.test.ts b/tests/boundary/cli/defaultAmpRegistration.test.ts new file mode 100644 index 00000000000..0531cbba8db --- /dev/null +++ b/tests/boundary/cli/defaultAmpRegistration.test.ts @@ -0,0 +1,362 @@ +import { + mkdir, + readFile, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { delimiter, join } from "node:path"; +import { expect, it } from "vitest"; +import { Parser, z } from "incur"; + +import { splitIncurBuiltinArguments } from "../../../src/cliBuiltinArguments.js"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const fixture = async (initial: string) => { + const home = await createTestTempDirectory("rea-default-amp-"); + const bin = join(home, "bin"); + const log = join(home, "runner.jsonl"); + const script = join(home, "runner.cjs"); + const directory = join(home, ".config", "amp"); + const settings = join(directory, "settings.json"); + await mkdir(bin); + await mkdir(directory, { recursive: true }); + await writeFile(settings, initial); + await writeFile(log, ""); + await writeFile( + script, + `require("node:fs").appendFileSync(process.env.REA_TEST_RUNNER_LOG, +JSON.stringify(process.argv.slice(2)) + "\\n"); +if (process.env.REA_TEST_RUNNER_FAIL) { + console.error("fixture runner failure"); + process.exitCode = 23; +} else console.log("│ ✓ Fixture Client: fixture │"); +`, + ); + for (const runner of ["npx", "bun", "bunx", "pnpx"]) + await writeFile( + join(bin, process.platform === "win32" ? `${runner}.cmd` : runner), + process.platform === "win32" + ? `@echo off\r\n"%REA_TEST_NODE%" "%REA_TEST_RUNNER_SCRIPT%" %*\r\n` + : `#!/bin/sh\nexec "$REA_TEST_NODE" "$REA_TEST_RUNNER_SCRIPT" "$@"\n`, + { mode: 0o700 }, + ); + const environment: NodeJS.ProcessEnv = { + HOME: home, + USERPROFILE: home, + PATH: [bin, process.env.PATH ?? ""].join(delimiter), + REA_TEST_NODE: process.execPath, + REA_TEST_RUNNER_SCRIPT: script, + REA_TEST_RUNNER_LOG: log, + npm_config_user_agent: "npm/11 node/v24.22.1", + npm_execpath: "", + }; + for (const key of [ + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_CACHE_HOME", + "XDG_STATE_HOME", + "TMPDIR", + ]) { + environment[key] = join(home, key); + await mkdir(environment[key]!); + } + return { home, log, directory, settings, environment }; +}; + +const original = `${JSON.stringify({ + "amp.mcpServers": { + rea: { command: "old", env: { KEEP_ME: "1" } }, + peer: { command: "peer" }, + }, + theme: "dark", +})}\n`; + +cliTest( + "default registration preserves Amp settings and recovery in both output modes", + async ({ cli }) => { + for (const output of [[], ["--json"]]) { + const f = await fixture(original); + for (let attempt = 0; attempt < 2; attempt += 1) { + const before = await readFile(f.settings, "utf8"); + const result = await cli.run({ + arguments: [ + "mcp", + "add", + "--command", + "fixture-server --mcp", + ...output, + ], + cwd: f.home, + environment: f.environment, + timeoutMs: 20_000, + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Amp"); + expect(JSON.parse(await readFile(f.settings, "utf8"))).toEqual({ + "amp.mcpServers": { + rea: { + command: "fixture-server", + args: ["--mcp"], + env: { KEEP_ME: "1" }, + }, + peer: { command: "peer" }, + }, + theme: "dark", + }); + const backup = result.stderr.trim().split(" retained at ")[1]!; + expect(await readFile(backup, "utf8")).toBe(before); + if (process.platform !== "win32") + expect((await stat(backup)).mode & 0o777).toBe(0o600); + expect(await readFile(`${f.settings}.rea.backup`, "utf8")).toBe( + original, + ); + } + const calls = (await readFile(f.log, "utf8")).trim().split("\n"); + expect(calls.map((line) => JSON.parse(line))).toEqual( + Array.from({ length: 2 }, () => [ + "add-mcp", + "fixture-server --mcp", + "--name", + "rea", + "-y", + "-g", + ]), + ); + } + }, +); + +cliTest( + "default registration refuses invalid settings and unavailable backups before the runner", + async ({ cli }) => { + for (const initial of [ + "broken JSON\n", + "null\n", + '{"amp.mcpServers":[]}\n', + original, + ]) { + const f = await fixture(initial); + if (initial === original) await mkdir(`${f.settings}.rea.backup`); + for (const output of [[], ["--json"]]) { + const result = await cli.run({ + arguments: ["mcp", "add", ...output], + cwd: f.home, + environment: f.environment, + timeoutMs: 20_000, + }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("VALIDATION_ERROR"); + if (output.length > 0) + expect(JSON.parse(result.stdout)).toMatchObject({ + code: "VALIDATION_ERROR", + }); + expect(await readFile(f.settings, "utf8")).toBe(initial); + expect(await readFile(f.log, "utf8")).toBe(""); + expect(await readdir(f.directory)).toEqual( + initial === original + ? ["settings.json", "settings.json.rea.backup"] + : ["settings.json"], + ); + } + } + }, +); + +cliTest( + "non-Amp selections, command values and discovery do not touch Amp settings", + async ({ cli }) => { + const f = await fixture("broken JSON\n"); + for (const selection of [ + ["--agent", "claude-code"], + ["--agent=claude-code"], + ["--agent", "cursor", "--agent", "claude-code"], + ["--command", "--agent", "amp", "--agent", "claude-code"], + ["-c", "--agent", "amp", "--agent", "claude-code"], + ]) { + const result = await cli.run({ + arguments: ["mcp", "add", ...selection, "--json"], + cwd: f.home, + environment: f.environment, + timeoutMs: 20_000, + }); + expect(result.exitCode, result.stdout).toBe(0); + expect(await readFile(f.settings, "utf8")).toBe("broken JSON\n"); + expect(await readdir(f.directory)).toEqual(["settings.json"]); + } + const calls = (await readFile(f.log, "utf8")).trim().split("\n"); + expect(calls).toHaveLength(5); + for (const line of calls) expect(JSON.parse(line)).toContain("claude-code"); + await writeFile(f.log, ""); + for (const flag of [ + "--help", + "-h", + "--llms", + "--llms-full", + "--schema", + "--version", + ]) { + const result = await cli.run({ + arguments: ["mcp", "add", flag, "--json"], + cwd: f.home, + environment: f.environment, + timeoutMs: 20_000, + }); + expect(result.exitCode).toBe(flag === "--schema" ? 1 : 0); + expect(result.stdout).not.toContain("VALIDATION_ERROR"); + expect(await readFile(f.settings, "utf8")).toBe("broken JSON\n"); + expect(await readdir(f.directory)).toEqual(["settings.json"]); + } + expect(await readFile(f.log, "utf8")).toBe(""); + }, +); + +cliTest( + "default runner failure retains the original settings and failure status", + async ({ cli }) => { + for (const output of [[], ["--json"]]) { + const f = await fixture(original); + const result = await cli.run({ + arguments: ["mcp", "add", ...output], + cwd: f.home, + environment: { ...f.environment, REA_TEST_RUNNER_FAIL: "1" }, + timeoutMs: 20_000, + }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("MCP_ADD_FAILED"); + expect(result.stdout).not.toContain("VALIDATION_ERROR"); + expect(result.stdout).not.toContain("Registered rea"); + expect(result.stderr).not.toContain("backup retained"); + expect(await readFile(f.settings, "utf8")).toBe(original); + expect(await readFile(`${f.settings}.rea.backup`, "utf8")).toBe(original); + expect((await readFile(f.log, "utf8")).trim().split("\n")).toHaveLength( + 1, + ); + } + }, +); + +cliTest( + "timing globals cannot bypass Amp preservation or validation", + async ({ cli }) => { + const cases = [ + ["--no-timings", "mcp", "add"], + ["--timings=false", "mcp", "add", "--json"], + ["mcp", "--timings=true", "add"], + ["mcp", "add", "--agent", "--no-timings", "amp", "--json"], + ["mcp", "add", "--agent", "--timings=false", "amp"], + ]; + for (const arguments_ of cases) { + const f = await fixture(original); + const run = () => + cli.run({ + arguments: arguments_, + cwd: f.home, + environment: f.environment, + timeoutMs: 20_000, + }); + const registered = await run(); + expect(registered.exitCode, registered.stdout).toBe(0); + expect( + JSON.parse(await readFile(f.settings, "utf8"))["amp.mcpServers"].rea + .env, + ).toEqual({ KEEP_ME: "1" }); + expect(await readFile(`${f.settings}.rea.backup`, "utf8")).toBe(original); + await writeFile(f.settings, "broken JSON\n"); + await writeFile(f.log, ""); + const rejected = await run(); + expect(rejected.exitCode).toBe(1); + expect(rejected.stdout).toContain("VALIDATION_ERROR"); + expect(await readFile(f.settings, "utf8")).toBe("broken JSON\n"); + expect(await readFile(f.log, "utf8")).toBe(""); + expect(await readFile(`${f.settings}.rea.backup`, "utf8")).toBe(original); + } + const f = await fixture("broken JSON\n"); + for (const arguments_ of [ + [ + "--no-timings", + "mcp", + "add", + "--agent", + "--timings=false", + "claude-code", + ], + ["--timings=false", "mcp", "add", "--help"], + ]) { + const result = await cli.run({ + arguments: arguments_, + cwd: f.home, + environment: f.environment, + timeoutMs: 20_000, + }); + expect(result.exitCode, result.stdout).toBe(0); + expect(await readFile(f.settings, "utf8")).toBe("broken JSON\n"); + expect(await readdir(f.directory)).toEqual(["settings.json"]); + } + const calls = (await readFile(f.log, "utf8")).trim().split("\n"); + expect(calls).toHaveLength(1); + expect(JSON.parse(calls[0]!)).toContain("claude-code"); + }, +); + +it("timing splitting matches Incur after output values and before the terminator", () => { + for (const flag of [ + "--timings", + "--no-timings", + "--timings=false", + "--timings=true", + "--timings=other", + ]) { + for (const command of [ + [flag, "mcp", "add"], + ["mcp", "add", "--agent", flag, "amp"], + ["mcp", "add", "--command", flag], + ["mcp", "add", "--", flag], + ]) { + const expected = Parser.parseGlobals( + command, + z.object({ timings: z.boolean().default(false) }), + undefined, + { validate: false }, + ).rest; + expect(splitIncurBuiltinArguments(command).command).toEqual(expected); + // The builtin output pass consumes this flag-shaped value before globals. + expect( + splitIncurBuiltinArguments(["--filter-output", flag, ...command]) + .command, + ).toEqual(expected); + } + } +}); + +cliTest( + "expanded discovery values never inspect or back up Amp settings", + async ({ cli }) => { + for (const initial of [undefined, "broken JSON\n", original]) { + const f = await fixture(initial ?? ""); + if (initial === undefined) await rm(f.settings); + for (const flag of ["--help", "--llms"]) { + const result = await cli.run({ + arguments: ["mcp", "add", `--command=${flag}`], + cwd: f.home, + environment: f.environment, + timeoutMs: 20_000, + }); + expect(result.exitCode, result.stdout).toBe(0); + expect(result.stdout).not.toContain("VALIDATION_ERROR"); + expect(result.stdout).not.toContain("Registered rea"); + expect(result.stdout.length).toBeGreaterThan(0); + expect(result.stderr).not.toContain("backup retained"); + expect(await readdir(f.directory)).toEqual( + initial === undefined ? [] : ["settings.json"], + ); + if (initial !== undefined) + expect(await readFile(f.settings, "utf8")).toBe(initial); + expect(await readFile(f.log, "utf8")).toBe(""); + } + } + }, +); From 4f7c4cc21834b1183655b532df104990180c66f6 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:45:28 +0800 Subject: [PATCH 36/38] docs(readme): add Join us on Discord button (#8754) --- README.md | 2 ++ docs/readme/README_ar.md | 2 ++ docs/readme/README_de.md | 2 ++ docs/readme/README_el.md | 2 ++ docs/readme/README_es.md | 2 ++ docs/readme/README_fa.md | 2 ++ docs/readme/README_fr.md | 2 ++ docs/readme/README_id.md | 2 ++ docs/readme/README_it.md | 2 ++ docs/readme/README_ja.md | 2 ++ docs/readme/README_ko.md | 2 ++ docs/readme/README_ml.md | 2 ++ docs/readme/README_pl.md | 2 ++ docs/readme/README_pt-BR.md | 2 ++ docs/readme/README_ro.md | 2 ++ docs/readme/README_ru.md | 2 ++ docs/readme/README_th.md | 2 ++ docs/readme/README_tr.md | 2 ++ docs/readme/README_uk.md | 2 ++ docs/readme/README_ur-Latn.md | 2 ++ docs/readme/README_vi.md | 2 ++ docs/readme/README_zh-TW.md | 2 ++ docs/readme/README_zh.md | 2 ++ 23 files changed, 46 insertions(+) diff --git a/README.md b/README.md index 474ecfbec32..33e60f3c303 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ morluto%2Frea | Trendshift +Join the REA community on Discord + **[Website](https://rea.tools/) · [Guides](https://rea.tools/guides/) · [Showcases](https://rea.tools/showcase/)** [Quick start](#quick-start) · [How REA works](#how-rea-works) · [What you can analyze](#what-you-can-analyze) · [Showcases](#showcases) · [Build RL environments](#build-rl-environments) · [FAQ](#faq) · [Documentation](#documentation) diff --git a/docs/readme/README_ar.md b/docs/readme/README_ar.md index 694c093e0c4..3dfb53b4f28 100644 --- a/docs/readme/README_ar.md +++ b/docs/readme/README_ar.md @@ -18,6 +18,8 @@ morluto%2Frea | Trendshift +انضم إلى مجتمع REA على Discord + **[الموقع](https://rea.tools/ar/) · [الأدلة](https://rea.tools/ar/guides/) · [دراسات الحالة](https://rea.tools/ar/showcase/)** [البدء السريع](#البدء-السريع) · [كيف يعمل REA](#كيف-يعمل-rea) · [ما الذي يمكنك تحليله](#ما-الذي-يمكنك-تحليله) · [دراسات الحالة](#دراسات-الحالة) · [إنشاء بيئات التعلم المعزز](#build-rl-environments) · [الأسئلة الشائعة](#الأسئلة-الشائعة) · [أدوات GNU objdump وRizin](../reverse-engineering-tools.md) · [التوثيق](#التوثيق) diff --git a/docs/readme/README_de.md b/docs/readme/README_de.md index d71d77ab73f..8dca059bf83 100644 --- a/docs/readme/README_de.md +++ b/docs/readme/README_de.md @@ -18,6 +18,8 @@ morluto%2Frea | Trendshift +Der REA-Community auf Discord beitreten + **[Website](https://rea.tools/de/) · [Anleitungen](https://rea.tools/de/guides/) · [Fallstudien](https://rea.tools/de/showcase/)** [Schnellstart](#schnellstart) · [So funktioniert REA](#so-funktioniert-rea) · [Was du analysieren kannst](#was-du-analysieren-kannst) · [Fallstudien](#fallstudien) · [RL-Umgebungen erstellen](#build-rl-environments) · [Häufige Fragen](#häufige-fragen) · [Dokumentation](#dokumentation) diff --git a/docs/readme/README_el.md b/docs/readme/README_el.md index 6bd0fbc3694..9eb7dd47b57 100644 --- a/docs/readme/README_el.md +++ b/docs/readme/README_el.md @@ -17,6 +17,8 @@ morluto%2Frea | Trendshift +Έλα στην κοινότητα REA στο Discord + **[Ιστότοπος](https://rea.tools/) · [Οδηγοί](https://rea.tools/guides/) · [Μελέτες περίπτωσης](https://rea.tools/showcase/)** [Γρήγορη εκκίνηση](#γρήγορη-εκκίνηση) · [Πώς λειτουργεί το REA](#πώς-λειτουργεί-το-rea) · [Τι μπορείς να αναλύσεις](#τι-μπορείς-να-αναλύσεις) · [Μελέτες περίπτωσης](#μελέτες-περίπτωσης) · [Δημιουργία περιβαλλόντων RL](#build-rl-environments) · [Συχνές ερωτήσεις](#συχνές-ερωτήσεις) · [Τεκμηρίωση](#τεκμηρίωση) diff --git a/docs/readme/README_es.md b/docs/readme/README_es.md index bcf465c0f5c..4ed0788cf01 100644 --- a/docs/readme/README_es.md +++ b/docs/readme/README_es.md @@ -18,6 +18,8 @@ morluto%2Frea | Trendshift +Únete a la comunidad de REA en Discord + **[Sitio web](https://rea.tools/es/) · [Guías](https://rea.tools/es/guides/) · [Casos prácticos](https://rea.tools/es/showcase/)** [Inicio rápido](#inicio-rápido) · [Cómo funciona REA](#cómo-funciona-rea) · [Qué puedes analizar](#qué-puedes-analizar) · [Casos prácticos](#casos-prácticos) · [Crear entornos de RL](#build-rl-environments) · [Preguntas frecuentes](#preguntas-frecuentes) · [Documentación](#documentación) diff --git a/docs/readme/README_fa.md b/docs/readme/README_fa.md index b90329aa873..b19ab2f1439 100644 --- a/docs/readme/README_fa.md +++ b/docs/readme/README_fa.md @@ -18,6 +18,8 @@ morluto%2Frea | Trendshift +به انجمن REA در دیسکورد بپیوندید + **[وب‌سایت](https://rea.tools/fa/) · [راهنماها](https://rea.tools/fa/guides/) · [نمونه‌های عملی](https://rea.tools/fa/showcase/)** [شروع سریع](#شروع-سریع) · [REA چگونه کار می‌کند؟](#rea-چگونه-کار-می‌کند) · [چه چیزهایی را میتوان تحلیل کرد؟](#چه-چیزهایی-را-می‌توان-تحلیل-کرد) · [نمونه‌های عملی](#نمونه‌های-عملی) · [ساخت محیط‌های RL](#build-rl-environments) · [پرسش‌های متداول](#پرسش‌های-متداول) · [مستندات](#مستندات) diff --git a/docs/readme/README_fr.md b/docs/readme/README_fr.md index 48832fdb1fb..b3ed852b324 100644 --- a/docs/readme/README_fr.md +++ b/docs/readme/README_fr.md @@ -18,6 +18,8 @@ morluto%2Frea | Trendshift +Rejoignez la communauté REA sur Discord + **[Site web](https://rea.tools/fr/) · [Guides](https://rea.tools/fr/guides/) · [Études de cas](https://rea.tools/fr/showcase/)** [Démarrage rapide](#démarrage-rapide) · [Comment fonctionne REA](#comment-fonctionne-rea) · [Ce que vous pouvez analyser](#ce-que-vous-pouvez-analyser) · [Études de cas](#études-de-cas) · [Créer des environnements RL](#build-rl-environments) · [Questions fréquentes](#questions-fréquentes) · [Documentation](#documentation) diff --git a/docs/readme/README_id.md b/docs/readme/README_id.md index 4a994ea102c..66453fbea58 100644 --- a/docs/readme/README_id.md +++ b/docs/readme/README_id.md @@ -18,6 +18,8 @@ morluto%2Frea | Trendshift +Bergabunglah dengan komunitas REA di Discord + **[Situs Web](https://rea.tools/id/) · [Panduan](https://rea.tools/id/guides/) · [Contoh Penggunaan](https://rea.tools/id/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) · [Membangun lingkungan RL](#build-rl-environments) · [FAQ](#faq) · [Dokumentasi](#dokumentasi) diff --git a/docs/readme/README_it.md b/docs/readme/README_it.md index f3f305e22c1..8abaf245193 100644 --- a/docs/readme/README_it.md +++ b/docs/readme/README_it.md @@ -18,6 +18,8 @@ morluto%2Frea | Trendshift +Unisciti alla community REA su Discord + **[Sito web](https://rea.tools/it/) · [Guide](https://rea.tools/it/guides/) · [Showcase](https://rea.tools/it/showcase/)** [Avvio rapido](#avvio-rapido) · [Come funziona REA](#come-funziona-rea) · [Cosa puoi analizzare](#cosa-puoi-analizzare) · [Showcase](#showcase) · [Creare ambienti RL](#build-rl-environments) · [FAQ](#faq) · [Documentazione](#documentazione) diff --git a/docs/readme/README_ja.md b/docs/readme/README_ja.md index 31dc75c7c80..e1e99b9429b 100644 --- a/docs/readme/README_ja.md +++ b/docs/readme/README_ja.md @@ -18,6 +18,8 @@ morluto%2Frea | Trendshift +DiscordのREAコミュニティに参加 + **[ウェブサイト](https://rea.tools/ja/) · [ガイド](https://rea.tools/ja/guides/) · [事例](https://rea.tools/ja/showcase/)** [クイックスタート](#クイックスタート) · [REA の仕組み](#rea-の仕組み) · [分析できる対象](#分析できる対象) · [事例](#事例) · [RL 環境を構築する](#build-rl-environments) · [よくある質問](#よくある質問) · [ドキュメント](#ドキュメント) diff --git a/docs/readme/README_ko.md b/docs/readme/README_ko.md index cd420f6b31d..6c4d28d7511 100644 --- a/docs/readme/README_ko.md +++ b/docs/readme/README_ko.md @@ -18,6 +18,8 @@ morluto%2Frea | Trendshift +Discord에서 REA 커뮤니티에 참여하세요 + **[웹사이트](https://rea.tools/ko/) · [가이드](https://rea.tools/ko/guides/) · [사례](https://rea.tools/ko/showcase/)** [빠른 시작](#빠른-시작) · [REA의 작동 방식](#rea의-작동-방식) · [분석할 수 있는 대상](#분석할-수-있는-대상) · [사례](#사례) · [RL 환경 만들기](#build-rl-environments) · [자주 묻는 질문](#자주-묻는-질문) · [문서](#문서) diff --git a/docs/readme/README_ml.md b/docs/readme/README_ml.md index c71fa179fcd..c21fcc248df 100644 --- a/docs/readme/README_ml.md +++ b/docs/readme/README_ml.md @@ -18,6 +18,8 @@ morluto%2Frea | Trendshift +Discord-ൽ REA കമ്മ്യൂണിറ്റിയിൽ ചേരുക + **[വെബ്സൈറ്റ്](https://rea.tools/) · [ഗൈഡുകൾ](https://rea.tools/guides/) · [ഉദാഹരണങ്ങൾ](https://rea.tools/showcase/)** [ദ്രുതാരംഭം](#ദ്രുതാരംഭം) · [REA എങ്ങനെ പ്രവർത്തിക്കുന്നു](#rea-എങ്ങനെ-പ്രവർത്തിക്കുന്നു) · [എന്തൊക്കെ വിശകലനം ചെയ്യാം](#എന്തൊക്കെ-വിശകലനം-ചെയ്യാം) · [ഉദാഹരണങ്ങൾ](#ഉദാഹരണങ്ങൾ) · [RL പരിതസ്ഥിതികൾ നിർമ്മിക്കുക](#build-rl-environments) · [പതിവുചോദ്യങ്ങൾ](#പതിവുചോദ്യങ്ങൾ) · [ഡോക്യുമെന്റേഷൻ](#ഡോക്യുമെന്റേഷൻ) diff --git a/docs/readme/README_pl.md b/docs/readme/README_pl.md index 7954c97566d..7119b72383b 100644 --- a/docs/readme/README_pl.md +++ b/docs/readme/README_pl.md @@ -18,6 +18,8 @@ morluto%2Frea | Trendshift +Dołącz do społeczności REA na Discordzie + **[Strona internetowa](https://rea.tools/pl/) · [Przewodniki](https://rea.tools/pl/guides/) · [Przykłady zastosowań](https://rea.tools/pl/showcase/)** [Szybki start](#szybki-start) · [Jak działa REA](#jak-działa-rea) · [Co możesz analizować](#co-możesz-analizować) · [Przykłady zastosowań](#przykłady-zastosowań) · [Tworzenie środowisk RL](#build-rl-environments) · [FAQ](#faq) · [Dokumentacja](#dokumentacja) diff --git a/docs/readme/README_pt-BR.md b/docs/readme/README_pt-BR.md index 4be1a409c0d..b1c9934fa6a 100644 --- a/docs/readme/README_pt-BR.md +++ b/docs/readme/README_pt-BR.md @@ -18,6 +18,8 @@ morluto%2Frea | Trendshift +Participe da comunidade REA no Discord + **[Site](https://rea.tools/pt-BR/) · [Guias](https://rea.tools/pt-BR/guides/) · [Casos de uso](https://rea.tools/pt-BR/showcase/)** [Início rápido](#início-rápido) · [Como o REA funciona](#como-o-rea-funciona) · [O que você pode analisar](#o-que-você-pode-analisar) · [Casos de uso](#casos-de-uso) · [Criar ambientes de RL](#build-rl-environments) · [Perguntas frequentes](#perguntas-frequentes) · [Documentação](#documentação) diff --git a/docs/readme/README_ro.md b/docs/readme/README_ro.md index 5755c5b54a1..d9179665c13 100644 --- a/docs/readme/README_ro.md +++ b/docs/readme/README_ro.md @@ -18,6 +18,8 @@ morluto%2Frea | Trendshift +Alătură-te comunității REA pe Discord + **[Site web](https://rea.tools/) · [Ghiduri](https://rea.tools/guides/) · [Studii de caz](https://rea.tools/showcase/)** [Pornire rapidă](#pornire-rapidă) · [Cum funcționează REA](#cum-funcționează-rea) · [Ce poți analiza](#ce-poți-analiza) · [Studii de caz](#studii-de-caz) · [Construiește medii RL](#build-rl-environments) · [Întrebări frecvente](#întrebări-frecvente) · [Documentație](#documentație) diff --git a/docs/readme/README_ru.md b/docs/readme/README_ru.md index 13e9ba09ad9..d952a50bcef 100644 --- a/docs/readme/README_ru.md +++ b/docs/readme/README_ru.md @@ -18,6 +18,8 @@ morluto%2Frea | Trendshift +Присоединяйтесь к сообществу REA в Discord + **[Сайт](https://rea.tools/ru/) · [Руководства](https://rea.tools/ru/guides/) · [Примеры](https://rea.tools/ru/showcase/)** [Быстрый старт](#быстрый-старт) · [Как работает REA](#как-работает-rea) · [Что можно анализировать](#что-можно-анализировать) · [Примеры](#примеры) · [Создание RL-окружений](#build-rl-environments) · [Частые вопросы](#частые-вопросы) · [Документация](#документация) diff --git a/docs/readme/README_th.md b/docs/readme/README_th.md index 9f442bfea12..052391156f1 100644 --- a/docs/readme/README_th.md +++ b/docs/readme/README_th.md @@ -18,6 +18,8 @@ morluto%2Frea | Trendshift +เข้าร่วมชุมชน REA บน Discord + **[เว็บไซต์](https://rea.tools/th/) · [คู่มือ](https://rea.tools/th/guides/) · [ตัวอย่างการใช้งาน](https://rea.tools/th/showcase/)** [เริ่มต้นอย่างรวดเร็ว](#เริ่มต้นอย่างรวดเร็ว) · [REA ทำงานอย่างไร](#rea-ทำงานอย่างไร) · [สิ่งที่วิเคราะห์ได้](#สิ่งที่วิเคราะห์ได้) · [ตัวอย่างการใช้งาน](#ตัวอย่างการใช้งาน) · [สร้างสภาพแวดล้อม RL](#build-rl-environments) · [คำถามที่พบบ่อย](#คำถามที่พบบ่อย) · [เอกสาร](#เอกสาร) diff --git a/docs/readme/README_tr.md b/docs/readme/README_tr.md index 6037f0e2f07..872cb972e41 100644 --- a/docs/readme/README_tr.md +++ b/docs/readme/README_tr.md @@ -18,6 +18,8 @@ morluto%2Frea | Trendshift +Discord'da REA topluluğuna katılın + **[Web sitesi](https://rea.tools/tr/) · [Rehberler](https://rea.tools/tr/guides/) · [Örnek çalışmalar](https://rea.tools/tr/showcase/)** [Hızlı başlangıç](#hızlı-başlangıç) · [REA nasıl çalışır](#rea-nasıl-çalışır) · [Neleri analiz edebilirsiniz](#neleri-analiz-edebilirsiniz) · [Örnek çalışmalar](#örnek-çalışmalar) · [RL ortamları oluşturun](#build-rl-environments) · [Sık sorulan sorular](#sık-sorulan-sorular) · [GNU objdump ve Rizin araçları](../reverse-engineering-tools.md) · [Belgeler](#belgeler) diff --git a/docs/readme/README_uk.md b/docs/readme/README_uk.md index 937e62dec14..6bc84b92713 100644 --- a/docs/readme/README_uk.md +++ b/docs/readme/README_uk.md @@ -18,6 +18,8 @@ morluto%2Frea | Trendshift +Приєднуйтеся до спільноти REA в Discord + **[Сайт](https://rea.tools/uk/) · [Посібники](https://rea.tools/uk/guides/) · [Приклади](https://rea.tools/uk/showcase/)** [Швидкий старт](#швидкий-старт) · [Як працює REA](#як-працює-rea) · [Що можна аналізувати](#що-можна-аналізувати) · [Приклади](#приклади) · [Створення RL-середовищ](#build-rl-environments) · [Поширені запитання](#поширені-запитання) · [Документація](#документація) diff --git a/docs/readme/README_ur-Latn.md b/docs/readme/README_ur-Latn.md index b8f3c86e569..e1f0e6042c4 100644 --- a/docs/readme/README_ur-Latn.md +++ b/docs/readme/README_ur-Latn.md @@ -18,6 +18,8 @@ morluto%2Frea | Trendshift +Discord par REA community mein shamil hon + **[Website](https://rea.tools/) · [Guides](https://rea.tools/guides/) · [Showcases](https://rea.tools/showcase/)** [Jaldi shuru karein](#jaldi-shuru-karein) · [REA kaise kaam karta hai](#rea-kaise-kaam-karta-hai) · [Kya kuch analyze kar sakte hain](#kya-kuch-analyze-kar-sakte-hain) · [Amli misaalein](#amli-misaalein) · [RL environments banayein](#build-rl-environments) · [FAQ](#faq) · [Documentation](#documentation) diff --git a/docs/readme/README_vi.md b/docs/readme/README_vi.md index bf1e56258ce..c0cce1a181a 100644 --- a/docs/readme/README_vi.md +++ b/docs/readme/README_vi.md @@ -18,6 +18,8 @@ morluto%2Frea | Trendshift +Tham gia cộng đồng REA trên Discord + **[Trang web](https://rea.tools/vi/) · [Hướng dẫn](https://rea.tools/vi/guides/) · [Các nghiên cứu điển hình](https://rea.tools/vi/showcase/)** [Bắt đầu nhanh](#bắt-đầu-nhanh) · [Cách REA hoạt động](#cách-rea-hoạt-động) · [Những gì bạn có thể phân tích](#những-gì-bạn-có-thể-phân-tích) · [Các nghiên cứu điển hình](#các-nghiên-cứu-điển-hình) · [Xây dựng môi trường RL](#build-rl-environments) · [Câu hỏi thường gặp](#câu-hỏi-thường-gặp) · [Tài liệu](#tài-liệu) diff --git a/docs/readme/README_zh-TW.md b/docs/readme/README_zh-TW.md index d12645c4eca..3dd028de4e0 100644 --- a/docs/readme/README_zh-TW.md +++ b/docs/readme/README_zh-TW.md @@ -18,6 +18,8 @@ morluto%2Frea | Trendshift +加入 REA 的 Discord 社群 + **[網站](https://rea.tools/zh-TW/) · [指南](https://rea.tools/zh-TW/guides/) · [案例](https://rea.tools/zh-TW/showcase/)** [快速開始](#快速開始) · [REA 的工作原理](#rea-的工作原理) · [可以分析什麼](#可以分析什麼) · [案例](#案例) · [建立 RL 環境](#build-rl-environments) · [常見問題](#常見問題) · [文件](#文件) diff --git a/docs/readme/README_zh.md b/docs/readme/README_zh.md index cfc29c463db..8efabd3db77 100644 --- a/docs/readme/README_zh.md +++ b/docs/readme/README_zh.md @@ -18,6 +18,8 @@ morluto%2Frea | Trendshift +加入 REA 的 Discord 社区 + **[网站](https://rea.tools/zh-CN/) · [指南](https://rea.tools/zh-CN/guides/) · [案例](https://rea.tools/zh-CN/showcase/)** [快速开始](#快速开始) · [REA 的工作原理](#rea-的工作原理) · [可以分析什么](#可以分析什么) · [案例](#案例) · [构建 RL 环境](#build-rl-environments) · [常见问题](#常见问题) · [GNU objdump 和 Rizin 工具](../reverse-engineering-tools.md) · [文档](#文档) From 28bffe92c82c520d1863165d586884edc9fce9ce Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:51:45 +0800 Subject: [PATCH 37/38] docs(readme): move Discord button below site links (#8755) --- README.md | 2 ++ docs/readme/README_ar.md | 2 ++ docs/readme/README_de.md | 2 ++ docs/readme/README_el.md | 2 ++ docs/readme/README_es.md | 2 ++ docs/readme/README_fa.md | 2 ++ docs/readme/README_fr.md | 2 ++ docs/readme/README_id.md | 2 ++ docs/readme/README_it.md | 2 ++ docs/readme/README_ja.md | 2 ++ docs/readme/README_ko.md | 2 ++ docs/readme/README_ml.md | 2 ++ docs/readme/README_pl.md | 2 ++ docs/readme/README_pt-BR.md | 2 ++ docs/readme/README_ro.md | 2 ++ docs/readme/README_ru.md | 2 ++ docs/readme/README_th.md | 2 ++ docs/readme/README_tr.md | 2 ++ docs/readme/README_uk.md | 2 ++ docs/readme/README_ur-Latn.md | 2 ++ docs/readme/README_vi.md | 2 ++ docs/readme/README_zh-TW.md | 2 ++ docs/readme/README_zh.md | 2 ++ 23 files changed, 46 insertions(+) diff --git a/README.md b/README.md index 33e60f3c303..7b0b7f50635 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ **[Website](https://rea.tools/) · [Guides](https://rea.tools/guides/) · [Showcases](https://rea.tools/showcase/)** +Join the REA community on Discord + [Quick start](#quick-start) · [How REA works](#how-rea-works) · [What you can analyze](#what-you-can-analyze) · [Showcases](#showcases) · [Build RL environments](#build-rl-environments) · [FAQ](#faq) · [Documentation](#documentation) npx rea-agents setup diff --git a/docs/readme/README_ar.md b/docs/readme/README_ar.md index 3dfb53b4f28..86004382502 100644 --- a/docs/readme/README_ar.md +++ b/docs/readme/README_ar.md @@ -22,6 +22,8 @@ **[الموقع](https://rea.tools/ar/) · [الأدلة](https://rea.tools/ar/guides/) · [دراسات الحالة](https://rea.tools/ar/showcase/)** +انضم إلى مجتمع REA على Discord + [البدء السريع](#البدء-السريع) · [كيف يعمل REA](#كيف-يعمل-rea) · [ما الذي يمكنك تحليله](#ما-الذي-يمكنك-تحليله) · [دراسات الحالة](#دراسات-الحالة) · [إنشاء بيئات التعلم المعزز](#build-rl-environments) · [الأسئلة الشائعة](#الأسئلة-الشائعة) · [أدوات GNU objdump وRizin](../reverse-engineering-tools.md) · [التوثيق](#التوثيق) npx rea-agents setup diff --git a/docs/readme/README_de.md b/docs/readme/README_de.md index 8dca059bf83..bef8b950a08 100644 --- a/docs/readme/README_de.md +++ b/docs/readme/README_de.md @@ -22,6 +22,8 @@ **[Website](https://rea.tools/de/) · [Anleitungen](https://rea.tools/de/guides/) · [Fallstudien](https://rea.tools/de/showcase/)** +Der REA-Community auf Discord beitreten + [Schnellstart](#schnellstart) · [So funktioniert REA](#so-funktioniert-rea) · [Was du analysieren kannst](#was-du-analysieren-kannst) · [Fallstudien](#fallstudien) · [RL-Umgebungen erstellen](#build-rl-environments) · [Häufige Fragen](#häufige-fragen) · [Dokumentation](#dokumentation) npx rea-agents setup diff --git a/docs/readme/README_el.md b/docs/readme/README_el.md index 9eb7dd47b57..35f429def2d 100644 --- a/docs/readme/README_el.md +++ b/docs/readme/README_el.md @@ -21,6 +21,8 @@ **[Ιστότοπος](https://rea.tools/) · [Οδηγοί](https://rea.tools/guides/) · [Μελέτες περίπτωσης](https://rea.tools/showcase/)** +Έλα στην κοινότητα REA στο Discord + [Γρήγορη εκκίνηση](#γρήγορη-εκκίνηση) · [Πώς λειτουργεί το REA](#πώς-λειτουργεί-το-rea) · [Τι μπορείς να αναλύσεις](#τι-μπορείς-να-αναλύσεις) · [Μελέτες περίπτωσης](#μελέτες-περίπτωσης) · [Δημιουργία περιβαλλόντων RL](#build-rl-environments) · [Συχνές ερωτήσεις](#συχνές-ερωτήσεις) · [Τεκμηρίωση](#τεκμηρίωση) npx rea-agents setup diff --git a/docs/readme/README_es.md b/docs/readme/README_es.md index 4ed0788cf01..1c991812bb0 100644 --- a/docs/readme/README_es.md +++ b/docs/readme/README_es.md @@ -22,6 +22,8 @@ **[Sitio web](https://rea.tools/es/) · [Guías](https://rea.tools/es/guides/) · [Casos prácticos](https://rea.tools/es/showcase/)** +Únete a la comunidad de REA en Discord + [Inicio rápido](#inicio-rápido) · [Cómo funciona REA](#cómo-funciona-rea) · [Qué puedes analizar](#qué-puedes-analizar) · [Casos prácticos](#casos-prácticos) · [Crear entornos de RL](#build-rl-environments) · [Preguntas frecuentes](#preguntas-frecuentes) · [Documentación](#documentación) npx rea-agents setup diff --git a/docs/readme/README_fa.md b/docs/readme/README_fa.md index b19ab2f1439..9280e4a84ad 100644 --- a/docs/readme/README_fa.md +++ b/docs/readme/README_fa.md @@ -22,6 +22,8 @@ **[وب‌سایت](https://rea.tools/fa/) · [راهنماها](https://rea.tools/fa/guides/) · [نمونه‌های عملی](https://rea.tools/fa/showcase/)** +به انجمن REA در دیسکورد بپیوندید + [شروع سریع](#شروع-سریع) · [REA چگونه کار می‌کند؟](#rea-چگونه-کار-می‌کند) · [چه چیزهایی را میتوان تحلیل کرد؟](#چه-چیزهایی-را-می‌توان-تحلیل-کرد) · [نمونه‌های عملی](#نمونه‌های-عملی) · [ساخت محیط‌های RL](#build-rl-environments) · [پرسش‌های متداول](#پرسش‌های-متداول) · [مستندات](#مستندات) npx rea-agents setup diff --git a/docs/readme/README_fr.md b/docs/readme/README_fr.md index b3ed852b324..efe6c288298 100644 --- a/docs/readme/README_fr.md +++ b/docs/readme/README_fr.md @@ -22,6 +22,8 @@ **[Site web](https://rea.tools/fr/) · [Guides](https://rea.tools/fr/guides/) · [Études de cas](https://rea.tools/fr/showcase/)** +Rejoignez la communauté REA sur Discord + [Démarrage rapide](#démarrage-rapide) · [Comment fonctionne REA](#comment-fonctionne-rea) · [Ce que vous pouvez analyser](#ce-que-vous-pouvez-analyser) · [Études de cas](#études-de-cas) · [Créer des environnements RL](#build-rl-environments) · [Questions fréquentes](#questions-fréquentes) · [Documentation](#documentation) npx rea-agents setup diff --git a/docs/readme/README_id.md b/docs/readme/README_id.md index 66453fbea58..e06acc279a1 100644 --- a/docs/readme/README_id.md +++ b/docs/readme/README_id.md @@ -22,6 +22,8 @@ **[Situs Web](https://rea.tools/id/) · [Panduan](https://rea.tools/id/guides/) · [Contoh Penggunaan](https://rea.tools/id/showcase/)** +Bergabunglah dengan komunitas REA di Discord + [Mulai Cepat](#mulai-cepat) · [Cara Kerja REA](#cara-kerja-rea) · [Hal yang Dapat Anda Analisis](#hal-yang-dapat-anda-analisis) · [Contoh Penggunaan](#contoh-penggunaan) · [Membangun lingkungan RL](#build-rl-environments) · [FAQ](#faq) · [Dokumentasi](#dokumentasi) npx rea-agents setup diff --git a/docs/readme/README_it.md b/docs/readme/README_it.md index 8abaf245193..7426875711d 100644 --- a/docs/readme/README_it.md +++ b/docs/readme/README_it.md @@ -22,6 +22,8 @@ **[Sito web](https://rea.tools/it/) · [Guide](https://rea.tools/it/guides/) · [Showcase](https://rea.tools/it/showcase/)** +Unisciti alla community REA su Discord + [Avvio rapido](#avvio-rapido) · [Come funziona REA](#come-funziona-rea) · [Cosa puoi analizzare](#cosa-puoi-analizzare) · [Showcase](#showcase) · [Creare ambienti RL](#build-rl-environments) · [FAQ](#faq) · [Documentazione](#documentazione) npx rea-agents setup diff --git a/docs/readme/README_ja.md b/docs/readme/README_ja.md index e1e99b9429b..cd1460c4b46 100644 --- a/docs/readme/README_ja.md +++ b/docs/readme/README_ja.md @@ -22,6 +22,8 @@ **[ウェブサイト](https://rea.tools/ja/) · [ガイド](https://rea.tools/ja/guides/) · [事例](https://rea.tools/ja/showcase/)** +DiscordのREAコミュニティに参加 + [クイックスタート](#クイックスタート) · [REA の仕組み](#rea-の仕組み) · [分析できる対象](#分析できる対象) · [事例](#事例) · [RL 環境を構築する](#build-rl-environments) · [よくある質問](#よくある質問) · [ドキュメント](#ドキュメント) npx rea-agents setup diff --git a/docs/readme/README_ko.md b/docs/readme/README_ko.md index 6c4d28d7511..ef423e7b25c 100644 --- a/docs/readme/README_ko.md +++ b/docs/readme/README_ko.md @@ -22,6 +22,8 @@ **[웹사이트](https://rea.tools/ko/) · [가이드](https://rea.tools/ko/guides/) · [사례](https://rea.tools/ko/showcase/)** +Discord에서 REA 커뮤니티에 참여하세요 + [빠른 시작](#빠른-시작) · [REA의 작동 방식](#rea의-작동-방식) · [분석할 수 있는 대상](#분석할-수-있는-대상) · [사례](#사례) · [RL 환경 만들기](#build-rl-environments) · [자주 묻는 질문](#자주-묻는-질문) · [문서](#문서) npx rea-agents setup diff --git a/docs/readme/README_ml.md b/docs/readme/README_ml.md index c21fcc248df..d6832ca3720 100644 --- a/docs/readme/README_ml.md +++ b/docs/readme/README_ml.md @@ -22,6 +22,8 @@ **[വെബ്സൈറ്റ്](https://rea.tools/) · [ഗൈഡുകൾ](https://rea.tools/guides/) · [ഉദാഹരണങ്ങൾ](https://rea.tools/showcase/)** +Discord-ൽ REA കമ്മ്യൂണിറ്റിയിൽ ചേരുക + [ദ്രുതാരംഭം](#ദ്രുതാരംഭം) · [REA എങ്ങനെ പ്രവർത്തിക്കുന്നു](#rea-എങ്ങനെ-പ്രവർത്തിക്കുന്നു) · [എന്തൊക്കെ വിശകലനം ചെയ്യാം](#എന്തൊക്കെ-വിശകലനം-ചെയ്യാം) · [ഉദാഹരണങ്ങൾ](#ഉദാഹരണങ്ങൾ) · [RL പരിതസ്ഥിതികൾ നിർമ്മിക്കുക](#build-rl-environments) · [പതിവുചോദ്യങ്ങൾ](#പതിവുചോദ്യങ്ങൾ) · [ഡോക്യുമെന്റേഷൻ](#ഡോക്യുമെന്റേഷൻ) npx rea-agents setup diff --git a/docs/readme/README_pl.md b/docs/readme/README_pl.md index 7119b72383b..230e91e0bbf 100644 --- a/docs/readme/README_pl.md +++ b/docs/readme/README_pl.md @@ -22,6 +22,8 @@ **[Strona internetowa](https://rea.tools/pl/) · [Przewodniki](https://rea.tools/pl/guides/) · [Przykłady zastosowań](https://rea.tools/pl/showcase/)** +Dołącz do społeczności REA na Discordzie + [Szybki start](#szybki-start) · [Jak działa REA](#jak-działa-rea) · [Co możesz analizować](#co-możesz-analizować) · [Przykłady zastosowań](#przykłady-zastosowań) · [Tworzenie środowisk RL](#build-rl-environments) · [FAQ](#faq) · [Dokumentacja](#dokumentacja) npx rea-agents setup diff --git a/docs/readme/README_pt-BR.md b/docs/readme/README_pt-BR.md index b1c9934fa6a..f3efb729797 100644 --- a/docs/readme/README_pt-BR.md +++ b/docs/readme/README_pt-BR.md @@ -22,6 +22,8 @@ **[Site](https://rea.tools/pt-BR/) · [Guias](https://rea.tools/pt-BR/guides/) · [Casos de uso](https://rea.tools/pt-BR/showcase/)** +Participe da comunidade REA no Discord + [Início rápido](#início-rápido) · [Como o REA funciona](#como-o-rea-funciona) · [O que você pode analisar](#o-que-você-pode-analisar) · [Casos de uso](#casos-de-uso) · [Criar ambientes de RL](#build-rl-environments) · [Perguntas frequentes](#perguntas-frequentes) · [Documentação](#documentação) npx rea-agents setup diff --git a/docs/readme/README_ro.md b/docs/readme/README_ro.md index d9179665c13..aae2dd62875 100644 --- a/docs/readme/README_ro.md +++ b/docs/readme/README_ro.md @@ -22,6 +22,8 @@ **[Site web](https://rea.tools/) · [Ghiduri](https://rea.tools/guides/) · [Studii de caz](https://rea.tools/showcase/)** +Alătură-te comunității REA pe Discord + [Pornire rapidă](#pornire-rapidă) · [Cum funcționează REA](#cum-funcționează-rea) · [Ce poți analiza](#ce-poți-analiza) · [Studii de caz](#studii-de-caz) · [Construiește medii RL](#build-rl-environments) · [Întrebări frecvente](#întrebări-frecvente) · [Documentație](#documentație) npx rea-agents setup diff --git a/docs/readme/README_ru.md b/docs/readme/README_ru.md index d952a50bcef..e085c519a59 100644 --- a/docs/readme/README_ru.md +++ b/docs/readme/README_ru.md @@ -22,6 +22,8 @@ **[Сайт](https://rea.tools/ru/) · [Руководства](https://rea.tools/ru/guides/) · [Примеры](https://rea.tools/ru/showcase/)** +Присоединяйтесь к сообществу REA в Discord + [Быстрый старт](#быстрый-старт) · [Как работает REA](#как-работает-rea) · [Что можно анализировать](#что-можно-анализировать) · [Примеры](#примеры) · [Создание RL-окружений](#build-rl-environments) · [Частые вопросы](#частые-вопросы) · [Документация](#документация) npx rea-agents setup diff --git a/docs/readme/README_th.md b/docs/readme/README_th.md index 052391156f1..047051e7b87 100644 --- a/docs/readme/README_th.md +++ b/docs/readme/README_th.md @@ -22,6 +22,8 @@ **[เว็บไซต์](https://rea.tools/th/) · [คู่มือ](https://rea.tools/th/guides/) · [ตัวอย่างการใช้งาน](https://rea.tools/th/showcase/)** +เข้าร่วมชุมชน REA บน Discord + [เริ่มต้นอย่างรวดเร็ว](#เริ่มต้นอย่างรวดเร็ว) · [REA ทำงานอย่างไร](#rea-ทำงานอย่างไร) · [สิ่งที่วิเคราะห์ได้](#สิ่งที่วิเคราะห์ได้) · [ตัวอย่างการใช้งาน](#ตัวอย่างการใช้งาน) · [สร้างสภาพแวดล้อม RL](#build-rl-environments) · [คำถามที่พบบ่อย](#คำถามที่พบบ่อย) · [เอกสาร](#เอกสาร) npx rea-agents setup diff --git a/docs/readme/README_tr.md b/docs/readme/README_tr.md index 872cb972e41..1d668bf520e 100644 --- a/docs/readme/README_tr.md +++ b/docs/readme/README_tr.md @@ -22,6 +22,8 @@ **[Web sitesi](https://rea.tools/tr/) · [Rehberler](https://rea.tools/tr/guides/) · [Örnek çalışmalar](https://rea.tools/tr/showcase/)** +Discord'da REA topluluğuna katılın + [Hızlı başlangıç](#hızlı-başlangıç) · [REA nasıl çalışır](#rea-nasıl-çalışır) · [Neleri analiz edebilirsiniz](#neleri-analiz-edebilirsiniz) · [Örnek çalışmalar](#örnek-çalışmalar) · [RL ortamları oluşturun](#build-rl-environments) · [Sık sorulan sorular](#sık-sorulan-sorular) · [GNU objdump ve Rizin araçları](../reverse-engineering-tools.md) · [Belgeler](#belgeler) npx rea-agents setup diff --git a/docs/readme/README_uk.md b/docs/readme/README_uk.md index 6bc84b92713..28ebb452f2f 100644 --- a/docs/readme/README_uk.md +++ b/docs/readme/README_uk.md @@ -22,6 +22,8 @@ **[Сайт](https://rea.tools/uk/) · [Посібники](https://rea.tools/uk/guides/) · [Приклади](https://rea.tools/uk/showcase/)** +Приєднуйтеся до спільноти REA в Discord + [Швидкий старт](#швидкий-старт) · [Як працює REA](#як-працює-rea) · [Що можна аналізувати](#що-можна-аналізувати) · [Приклади](#приклади) · [Створення RL-середовищ](#build-rl-environments) · [Поширені запитання](#поширені-запитання) · [Документація](#документація) npx rea-agents setup diff --git a/docs/readme/README_ur-Latn.md b/docs/readme/README_ur-Latn.md index e1f0e6042c4..e1ce63e36f6 100644 --- a/docs/readme/README_ur-Latn.md +++ b/docs/readme/README_ur-Latn.md @@ -22,6 +22,8 @@ **[Website](https://rea.tools/) · [Guides](https://rea.tools/guides/) · [Showcases](https://rea.tools/showcase/)** +Discord par REA community mein shamil hon + [Jaldi shuru karein](#jaldi-shuru-karein) · [REA kaise kaam karta hai](#rea-kaise-kaam-karta-hai) · [Kya kuch analyze kar sakte hain](#kya-kuch-analyze-kar-sakte-hain) · [Amli misaalein](#amli-misaalein) · [RL environments banayein](#build-rl-environments) · [FAQ](#faq) · [Documentation](#documentation) npx rea-agents setup diff --git a/docs/readme/README_vi.md b/docs/readme/README_vi.md index c0cce1a181a..d279d268062 100644 --- a/docs/readme/README_vi.md +++ b/docs/readme/README_vi.md @@ -22,6 +22,8 @@ **[Trang web](https://rea.tools/vi/) · [Hướng dẫn](https://rea.tools/vi/guides/) · [Các nghiên cứu điển hình](https://rea.tools/vi/showcase/)** +Tham gia cộng đồng REA trên Discord + [Bắt đầu nhanh](#bắt-đầu-nhanh) · [Cách REA hoạt động](#cách-rea-hoạt-động) · [Những gì bạn có thể phân tích](#những-gì-bạn-có-thể-phân-tích) · [Các nghiên cứu điển hình](#các-nghiên-cứu-điển-hình) · [Xây dựng môi trường RL](#build-rl-environments) · [Câu hỏi thường gặp](#câu-hỏi-thường-gặp) · [Tài liệu](#tài-liệu) npx rea-agents setup diff --git a/docs/readme/README_zh-TW.md b/docs/readme/README_zh-TW.md index 3dd028de4e0..41ff4396363 100644 --- a/docs/readme/README_zh-TW.md +++ b/docs/readme/README_zh-TW.md @@ -22,6 +22,8 @@ **[網站](https://rea.tools/zh-TW/) · [指南](https://rea.tools/zh-TW/guides/) · [案例](https://rea.tools/zh-TW/showcase/)** +加入 REA 的 Discord 社群 + [快速開始](#快速開始) · [REA 的工作原理](#rea-的工作原理) · [可以分析什麼](#可以分析什麼) · [案例](#案例) · [建立 RL 環境](#build-rl-environments) · [常見問題](#常見問題) · [文件](#文件) npx rea-agents setup diff --git a/docs/readme/README_zh.md b/docs/readme/README_zh.md index 8efabd3db77..6986ecf49cd 100644 --- a/docs/readme/README_zh.md +++ b/docs/readme/README_zh.md @@ -22,6 +22,8 @@ **[网站](https://rea.tools/zh-CN/) · [指南](https://rea.tools/zh-CN/guides/) · [案例](https://rea.tools/zh-CN/showcase/)** +加入 REA 的 Discord 社区 + [快速开始](#快速开始) · [REA 的工作原理](#rea-的工作原理) · [可以分析什么](#可以分析什么) · [案例](#案例) · [构建 RL 环境](#build-rl-environments) · [常见问题](#常见问题) · [GNU objdump 和 Rizin 工具](../reverse-engineering-tools.md) · [文档](#文档) npx rea-agents setup From 8c857ce56c68ca46d202db0c9571277974c9e167 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 3 Nov 2026 08:49:14 +0000 Subject: [PATCH 38/38] docs(readme): move Discord button below site links Place the Join us on Discord button under each hero's website, guides, and showcases line. The invite and alt text stay the same. --- README.md | 2 -- docs/readme/README_ar.md | 2 -- docs/readme/README_de.md | 2 -- docs/readme/README_el.md | 2 -- docs/readme/README_es.md | 2 -- docs/readme/README_fa.md | 2 -- docs/readme/README_fr.md | 2 -- docs/readme/README_id.md | 2 -- docs/readme/README_it.md | 2 -- docs/readme/README_ja.md | 2 -- docs/readme/README_ko.md | 2 -- docs/readme/README_ml.md | 2 -- docs/readme/README_pl.md | 2 -- docs/readme/README_pt-BR.md | 2 -- docs/readme/README_ro.md | 2 -- docs/readme/README_ru.md | 2 -- docs/readme/README_th.md | 2 -- docs/readme/README_tr.md | 2 -- docs/readme/README_uk.md | 2 -- docs/readme/README_ur-Latn.md | 2 -- docs/readme/README_vi.md | 2 -- docs/readme/README_zh-TW.md | 2 -- docs/readme/README_zh.md | 2 -- 23 files changed, 46 deletions(-) diff --git a/README.md b/README.md index 7b0b7f50635..a6343e167a3 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,6 @@ morluto%2Frea | Trendshift -Join the REA community on Discord - **[Website](https://rea.tools/) · [Guides](https://rea.tools/guides/) · [Showcases](https://rea.tools/showcase/)** Join the REA community on Discord diff --git a/docs/readme/README_ar.md b/docs/readme/README_ar.md index 86004382502..e4e4c2dd847 100644 --- a/docs/readme/README_ar.md +++ b/docs/readme/README_ar.md @@ -18,8 +18,6 @@ morluto%2Frea | Trendshift -انضم إلى مجتمع REA على Discord - **[الموقع](https://rea.tools/ar/) · [الأدلة](https://rea.tools/ar/guides/) · [دراسات الحالة](https://rea.tools/ar/showcase/)** انضم إلى مجتمع REA على Discord diff --git a/docs/readme/README_de.md b/docs/readme/README_de.md index bef8b950a08..1301036c8d6 100644 --- a/docs/readme/README_de.md +++ b/docs/readme/README_de.md @@ -18,8 +18,6 @@ morluto%2Frea | Trendshift -Der REA-Community auf Discord beitreten - **[Website](https://rea.tools/de/) · [Anleitungen](https://rea.tools/de/guides/) · [Fallstudien](https://rea.tools/de/showcase/)** Der REA-Community auf Discord beitreten diff --git a/docs/readme/README_el.md b/docs/readme/README_el.md index 35f429def2d..e5e450cce20 100644 --- a/docs/readme/README_el.md +++ b/docs/readme/README_el.md @@ -17,8 +17,6 @@ morluto%2Frea | Trendshift -Έλα στην κοινότητα REA στο Discord - **[Ιστότοπος](https://rea.tools/) · [Οδηγοί](https://rea.tools/guides/) · [Μελέτες περίπτωσης](https://rea.tools/showcase/)** Έλα στην κοινότητα REA στο Discord diff --git a/docs/readme/README_es.md b/docs/readme/README_es.md index 1c991812bb0..0d276c8a425 100644 --- a/docs/readme/README_es.md +++ b/docs/readme/README_es.md @@ -18,8 +18,6 @@ morluto%2Frea | Trendshift -Únete a la comunidad de REA en Discord - **[Sitio web](https://rea.tools/es/) · [Guías](https://rea.tools/es/guides/) · [Casos prácticos](https://rea.tools/es/showcase/)** Únete a la comunidad de REA en Discord diff --git a/docs/readme/README_fa.md b/docs/readme/README_fa.md index 9280e4a84ad..494317aa501 100644 --- a/docs/readme/README_fa.md +++ b/docs/readme/README_fa.md @@ -18,8 +18,6 @@ morluto%2Frea | Trendshift -به انجمن REA در دیسکورد بپیوندید - **[وب‌سایت](https://rea.tools/fa/) · [راهنماها](https://rea.tools/fa/guides/) · [نمونه‌های عملی](https://rea.tools/fa/showcase/)** به انجمن REA در دیسکورد بپیوندید diff --git a/docs/readme/README_fr.md b/docs/readme/README_fr.md index efe6c288298..2f165982fef 100644 --- a/docs/readme/README_fr.md +++ b/docs/readme/README_fr.md @@ -18,8 +18,6 @@ morluto%2Frea | Trendshift -Rejoignez la communauté REA sur Discord - **[Site web](https://rea.tools/fr/) · [Guides](https://rea.tools/fr/guides/) · [Études de cas](https://rea.tools/fr/showcase/)** Rejoignez la communauté REA sur Discord diff --git a/docs/readme/README_id.md b/docs/readme/README_id.md index e06acc279a1..b283dba9b3c 100644 --- a/docs/readme/README_id.md +++ b/docs/readme/README_id.md @@ -18,8 +18,6 @@ morluto%2Frea | Trendshift -Bergabunglah dengan komunitas REA di Discord - **[Situs Web](https://rea.tools/id/) · [Panduan](https://rea.tools/id/guides/) · [Contoh Penggunaan](https://rea.tools/id/showcase/)** Bergabunglah dengan komunitas REA di Discord diff --git a/docs/readme/README_it.md b/docs/readme/README_it.md index 7426875711d..ad7f25de508 100644 --- a/docs/readme/README_it.md +++ b/docs/readme/README_it.md @@ -18,8 +18,6 @@ morluto%2Frea | Trendshift -Unisciti alla community REA su Discord - **[Sito web](https://rea.tools/it/) · [Guide](https://rea.tools/it/guides/) · [Showcase](https://rea.tools/it/showcase/)** Unisciti alla community REA su Discord diff --git a/docs/readme/README_ja.md b/docs/readme/README_ja.md index cd1460c4b46..1918517ff69 100644 --- a/docs/readme/README_ja.md +++ b/docs/readme/README_ja.md @@ -18,8 +18,6 @@ morluto%2Frea | Trendshift -DiscordのREAコミュニティに参加 - **[ウェブサイト](https://rea.tools/ja/) · [ガイド](https://rea.tools/ja/guides/) · [事例](https://rea.tools/ja/showcase/)** DiscordのREAコミュニティに参加 diff --git a/docs/readme/README_ko.md b/docs/readme/README_ko.md index ef423e7b25c..4a4755cb048 100644 --- a/docs/readme/README_ko.md +++ b/docs/readme/README_ko.md @@ -18,8 +18,6 @@ morluto%2Frea | Trendshift -Discord에서 REA 커뮤니티에 참여하세요 - **[웹사이트](https://rea.tools/ko/) · [가이드](https://rea.tools/ko/guides/) · [사례](https://rea.tools/ko/showcase/)** Discord에서 REA 커뮤니티에 참여하세요 diff --git a/docs/readme/README_ml.md b/docs/readme/README_ml.md index d6832ca3720..05e2d110a22 100644 --- a/docs/readme/README_ml.md +++ b/docs/readme/README_ml.md @@ -18,8 +18,6 @@ morluto%2Frea | Trendshift -Discord-ൽ REA കമ്മ്യൂണിറ്റിയിൽ ചേരുക - **[വെബ്സൈറ്റ്](https://rea.tools/) · [ഗൈഡുകൾ](https://rea.tools/guides/) · [ഉദാഹരണങ്ങൾ](https://rea.tools/showcase/)** Discord-ൽ REA കമ്മ്യൂണിറ്റിയിൽ ചേരുക diff --git a/docs/readme/README_pl.md b/docs/readme/README_pl.md index 230e91e0bbf..590f32e636f 100644 --- a/docs/readme/README_pl.md +++ b/docs/readme/README_pl.md @@ -18,8 +18,6 @@ morluto%2Frea | Trendshift -Dołącz do społeczności REA na Discordzie - **[Strona internetowa](https://rea.tools/pl/) · [Przewodniki](https://rea.tools/pl/guides/) · [Przykłady zastosowań](https://rea.tools/pl/showcase/)** Dołącz do społeczności REA na Discordzie diff --git a/docs/readme/README_pt-BR.md b/docs/readme/README_pt-BR.md index f3efb729797..366325f3fcc 100644 --- a/docs/readme/README_pt-BR.md +++ b/docs/readme/README_pt-BR.md @@ -18,8 +18,6 @@ morluto%2Frea | Trendshift -Participe da comunidade REA no Discord - **[Site](https://rea.tools/pt-BR/) · [Guias](https://rea.tools/pt-BR/guides/) · [Casos de uso](https://rea.tools/pt-BR/showcase/)** Participe da comunidade REA no Discord diff --git a/docs/readme/README_ro.md b/docs/readme/README_ro.md index aae2dd62875..c16fde180d6 100644 --- a/docs/readme/README_ro.md +++ b/docs/readme/README_ro.md @@ -18,8 +18,6 @@ morluto%2Frea | Trendshift -Alătură-te comunității REA pe Discord - **[Site web](https://rea.tools/) · [Ghiduri](https://rea.tools/guides/) · [Studii de caz](https://rea.tools/showcase/)** Alătură-te comunității REA pe Discord diff --git a/docs/readme/README_ru.md b/docs/readme/README_ru.md index e085c519a59..b873e2cd0f9 100644 --- a/docs/readme/README_ru.md +++ b/docs/readme/README_ru.md @@ -18,8 +18,6 @@ morluto%2Frea | Trendshift -Присоединяйтесь к сообществу REA в Discord - **[Сайт](https://rea.tools/ru/) · [Руководства](https://rea.tools/ru/guides/) · [Примеры](https://rea.tools/ru/showcase/)** Присоединяйтесь к сообществу REA в Discord diff --git a/docs/readme/README_th.md b/docs/readme/README_th.md index 047051e7b87..9621f787931 100644 --- a/docs/readme/README_th.md +++ b/docs/readme/README_th.md @@ -18,8 +18,6 @@ morluto%2Frea | Trendshift -เข้าร่วมชุมชน REA บน Discord - **[เว็บไซต์](https://rea.tools/th/) · [คู่มือ](https://rea.tools/th/guides/) · [ตัวอย่างการใช้งาน](https://rea.tools/th/showcase/)** เข้าร่วมชุมชน REA บน Discord diff --git a/docs/readme/README_tr.md b/docs/readme/README_tr.md index 1d668bf520e..58b79a5428d 100644 --- a/docs/readme/README_tr.md +++ b/docs/readme/README_tr.md @@ -18,8 +18,6 @@ morluto%2Frea | Trendshift -Discord'da REA topluluğuna katılın - **[Web sitesi](https://rea.tools/tr/) · [Rehberler](https://rea.tools/tr/guides/) · [Örnek çalışmalar](https://rea.tools/tr/showcase/)** Discord'da REA topluluğuna katılın diff --git a/docs/readme/README_uk.md b/docs/readme/README_uk.md index 28ebb452f2f..a181184b4bf 100644 --- a/docs/readme/README_uk.md +++ b/docs/readme/README_uk.md @@ -18,8 +18,6 @@ morluto%2Frea | Trendshift -Приєднуйтеся до спільноти REA в Discord - **[Сайт](https://rea.tools/uk/) · [Посібники](https://rea.tools/uk/guides/) · [Приклади](https://rea.tools/uk/showcase/)** Приєднуйтеся до спільноти REA в Discord diff --git a/docs/readme/README_ur-Latn.md b/docs/readme/README_ur-Latn.md index e1ce63e36f6..cff973fdbf4 100644 --- a/docs/readme/README_ur-Latn.md +++ b/docs/readme/README_ur-Latn.md @@ -18,8 +18,6 @@ morluto%2Frea | Trendshift -Discord par REA community mein shamil hon - **[Website](https://rea.tools/) · [Guides](https://rea.tools/guides/) · [Showcases](https://rea.tools/showcase/)** Discord par REA community mein shamil hon diff --git a/docs/readme/README_vi.md b/docs/readme/README_vi.md index d279d268062..dd1ed47c8da 100644 --- a/docs/readme/README_vi.md +++ b/docs/readme/README_vi.md @@ -18,8 +18,6 @@ morluto%2Frea | Trendshift -Tham gia cộng đồng REA trên Discord - **[Trang web](https://rea.tools/vi/) · [Hướng dẫn](https://rea.tools/vi/guides/) · [Các nghiên cứu điển hình](https://rea.tools/vi/showcase/)** Tham gia cộng đồng REA trên Discord diff --git a/docs/readme/README_zh-TW.md b/docs/readme/README_zh-TW.md index 41ff4396363..a01bb80e282 100644 --- a/docs/readme/README_zh-TW.md +++ b/docs/readme/README_zh-TW.md @@ -18,8 +18,6 @@ morluto%2Frea | Trendshift -加入 REA 的 Discord 社群 - **[網站](https://rea.tools/zh-TW/) · [指南](https://rea.tools/zh-TW/guides/) · [案例](https://rea.tools/zh-TW/showcase/)** 加入 REA 的 Discord 社群 diff --git a/docs/readme/README_zh.md b/docs/readme/README_zh.md index 6986ecf49cd..5a0609472e1 100644 --- a/docs/readme/README_zh.md +++ b/docs/readme/README_zh.md @@ -18,8 +18,6 @@ morluto%2Frea | Trendshift -加入 REA 的 Discord 社区 - **[网站](https://rea.tools/zh-CN/) · [指南](https://rea.tools/zh-CN/guides/) · [案例](https://rea.tools/zh-CN/showcase/)** 加入 REA 的 Discord 社区