From 0beedcd712e39a7d93e4558ee56fd596eaa22838 Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:27:31 +0800 Subject: [PATCH 001/151] perf(mcp): add opt-in hot/cold tool discovery (#9046) Add per-connection discovery that loads canonical named tools on demand while preserving the default full catalog. Bound discovery replies before changing visibility, preserve family exclusions and Evidence workflows, and reset visibility on HTTP reconnect. Verified with focused protocol tests and real Codex CLI artifact investigations; token costs remain task-dependent, so the profile is opt-in. --- docs/client-configuration.md | 27 +++ docs/mcp-contracts.md | 45 +++- docs/tool-design.md | 7 +- scripts/lib/artifact-e2e.mjs | 36 +-- src/application/DirectAnalysisStatus.ts | 23 +- .../SetupRegistrationEnvironment.ts | 1 + src/catalogDiscovery.ts | 110 ++++++++++ src/config/environment.ts | 2 + src/config/mcpToolCatalogProfile.ts | 23 ++ src/config/parseConfig.ts | 3 + src/config/types.ts | 2 + src/main/deferredRuntime.ts | 2 + src/mcpDoctor.ts | 40 +++- src/server/DeferredEvidenceMcpServer.test.ts | 42 ++++ src/server/EvidenceMcpServerCore.ts | 54 ++++- src/server/ProgressiveToolCatalog.test.ts | 206 ++++++++++++++++++ src/server/ProgressiveToolCatalog.ts | 163 ++++++++++++++ src/server/createServer.ts | 6 + src/setupEnvironment.test.ts | 2 + tests/boundary/mcp/deferredRuntime.test.ts | 122 ++++++++++- tests/boundary/mcp/mcpDoctor.test.ts | 29 +++ tests/boundary/mcp/streamableHttpMcp.test.ts | 53 ++++- 22 files changed, 956 insertions(+), 42 deletions(-) create mode 100644 src/catalogDiscovery.ts create mode 100644 src/config/mcpToolCatalogProfile.ts create mode 100644 src/server/ProgressiveToolCatalog.test.ts create mode 100644 src/server/ProgressiveToolCatalog.ts diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 73d7c387d4..38250bf6a1 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -102,6 +102,33 @@ does not repair a client's naming behavior or infer permission drift from an unfamiliar prefix. The unavailable fork reported in [#5512](https://github.com/morluto/rea/issues/5512) has not been reverified. +## Hot/cold catalogs + +For a client that supports MCP catalog refresh, set +`REA_MCP_TOOL_CATALOG_PROFILE=hot-cold` in REA's server environment and restart +the connection. REA then advertises a small initial catalog and loads other +named tools through `discover_tools`. Setup preserves this explicit setting. +See the [discovery contract](mcp-contracts.md#hotcold-catalog-discovery) for +pagination, identity and family-scope semantics. + +Codex CLI 0.165.0 requires its experimental `mcp_tool_list_changed` feature for +this workflow. Merge these entries into the existing `~/.codex/config.toml` +without replacing other feature or REA settings: + +```toml +[features] +mcp_tool_list_changed = true + +[mcp_servers.rea.env] +REA_MCP_TOOL_CATALOG_PROFILE = "hot-cold" +``` + +The server setting alone does not enable client refresh. If a successful +discovery call is followed by a missing tool, check the client's refresh feature +and tool allow/deny lists. Clients without refresh should use the default +`REA_MCP_TOOL_CATALOG_PROFILE=full`; reconnect after changing it. Other clients' +hot/cold behavior has not been verified. + ## Fiddler Everywhere alongside REA Connect Fiddler Everywhere's MCP server separately for HTTP traffic debugging, diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index adaf8956df..58e4b6b651 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -899,7 +899,7 @@ registrations, reports their command vectors as aligned, stale, missing, or invalid, and keeps live-server state `unknown` unless the active connection supplies identity. -`tools/list` returns the complete canonical tool inventory, including tools that +By default, `tools/list` returns the complete canonical tool inventory, including tools that are currently unavailable. Opening or closing a target or observing a provider health transition leaves that catalog unchanged and does not emit `notifications/tools/list_changed`. @@ -911,7 +911,7 @@ share status facts. `tool_availability.client_requirements` is the baseline for each group; a group's optional `client_requirements` replaces that baseline for the group's tools. Expand each group's `names` into one tool entry, carrying its availability, reason, remediation, default-mode status, modes, and client -requirements. Compare the union of group names with `tools/list` when checking +requirements. With the default full catalog, compare the union of group names with `tools/list` when checking that a client received the full catalog. An operation absent from all groups is missing from the complete inventory; it is not an implicit unavailable entry. @@ -1873,6 +1873,47 @@ check full-profile local-path rules. Compact MCP workflow tests verify canonical rejection of invalid inputs before provider work, independent of how much detail the selected advertisement retains. +### Hot/cold catalog discovery + +`REA_MCP_TOOL_CATALOG_PROFILE=hot-cold` opts into per-connection loading for +clients that refresh `tools/list` after `notifications/tools/list_changed`. +The default, `full`, retains the complete upfront advertisement. Restart the +server to change profiles. See [client configuration](client-configuration.md#hotcold-catalogs). + +Hot/cold initially advertises `binary_session`, `open_binary`, `close_binary`, +`get_evidence_bundle`, `inspect_analysis_view`, and the adapter's `discover_tools`. +An explicit tool-family scope can remove any of those domain tools. +`discover_tools` searches canonical names and descriptions using case-insensitive +words, or accepts exact `names`. It loads the matching page and returns names +and abbreviated descriptions with their configured input schemas, so the next +call can use the returned arguments contract. Output schemas remain in +`tools/list`. Search defaults to three results, allows up to +20 per page, and returns `total_matches` and `next_offset`. Exact names preserve +request order and reject unknown or excluded names before loading anything. +Use fewer keywords when no result matches; search requires every word to match. +Both reply representations must fit the configured response budget before any +tools load. An oversized page returns a resource constraint; reduce `limit` or +request fewer exact names and retry. Input schemas are retained in full within +the selected input profile. + +Loaded definitions retain their original names, complete configured schemas, +annotations, handlers and client approval rules. Loading does not execute an +analysis, open a target, or initialize the deferred analysis runtime. A batch +emits one list-change notification; repeating it emits none. Loaded tools remain +available until the connection ends. There is no generic execution gateway. +If a loaded tool does not appear in the client, enable client catalog refresh +or reconnect using `REA_MCP_TOOL_CATALOG_PROFILE=full`. + +The catalog identity covers the complete in-scope inventory, the discovery +contract and the hot/cold policy; its count includes `discover_tools`. It differs +from full advertisement and remains stable as tools load. `rea mcp doctor` +checks the profile's initial advertisement and identity. The domain inventory in +`binary_session.tool_availability` continues to describe provider and target +availability, independently of whether a definition is loaded. Load a listed +cold tool before calling it. Provider health and target changes do not load, +unload or remove definitions. The input/output schema profiles and explicit +family exclusions continue to apply. + ### Tool family advertising scope Agents pay context for every advertised tool schema, and clients that send diff --git a/docs/tool-design.md b/docs/tool-design.md index 4a7f84a0e4..df084789c0 100644 --- a/docs/tool-design.md +++ b/docs/tool-design.md @@ -8,8 +8,11 @@ Extend a tool when intent and result contract match; add one for a distinct outcome or materially different authority. Public names and semantics remain provider-neutral, with exact coverage reported per provider. -Keep the catalog complete; report capability/session availability instead of -truncating schemas. Serialized bytes alone do not establish agent usability. +Keep the discoverable inventory complete within the caller's selected scope; +report capability/session availability independently of catalog visibility. +An opt-in dynamic catalog must retain canonical named contracts and provide +discovery and recovery for clients without refresh. Serialized bytes alone do +not establish agent usability. ## Choose the tool shape diff --git a/scripts/lib/artifact-e2e.mjs b/scripts/lib/artifact-e2e.mjs index 15d5915fb3..572a268ef0 100644 --- a/scripts/lib/artifact-e2e.mjs +++ b/scripts/lib/artifact-e2e.mjs @@ -7,9 +7,21 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { withVerifierCleanup } from "./verifier-cleanup.mjs"; import { INLINE_EVIDENCE_MCP_ENVIRONMENT } from "./mcp-verification-environment.mjs"; -// REA registers process-static tool contracts. Bind their parsed definitions to -// the SDK's negotiated connection identity, including when a Client reconnects. +// Bind parsed definitions to this connection. Hot/cold catalogs may add tools; +// refresh a missing name once without throwing away already validated bindings. const toolBindings = new WeakMap(); +const readDefinitions = (client) => + client.listTools().then(({ tools }) => { + const definitions = new Map(); + for (const tool of tools) { + assert.ok( + !definitions.has(tool.name), + `Duplicate MCP tool: ${tool.name}`, + ); + definitions.set(tool.name, tool); + } + return definitions; + }); const advertisedTool = async (client, name) => { const connection = client.getServerVersion(); @@ -18,23 +30,19 @@ const advertisedTool = async (client, name) => { if (binding?.connection !== connection) { binding = { connection, - definitions: client.listTools().then(({ tools }) => { - const definitions = new Map(); - for (const tool of tools) { - assert.ok( - !definitions.has(tool.name), - `Duplicate MCP tool: ${tool.name}`, - ); - definitions.set(tool.name, tool); - } - return definitions; - }), + definitions: readDefinitions(client), }; toolBindings.set(client, binding); } let definitions; + const snapshot = binding.definitions; try { - definitions = await binding.definitions; + definitions = await snapshot; + if (!definitions.has(name)) { + if (binding.definitions === snapshot) + binding.definitions = readDefinitions(client); + definitions = await binding.definitions; + } assert.equal( client.getServerVersion(), connection, diff --git a/src/application/DirectAnalysisStatus.ts b/src/application/DirectAnalysisStatus.ts index 8214f5b203..94056d8df5 100644 --- a/src/application/DirectAnalysisStatus.ts +++ b/src/application/DirectAnalysisStatus.ts @@ -8,6 +8,7 @@ import { providerCapabilitySets } from "./binary/BinarySessionStatus.js"; import { sqliteDatabaseCapability } from "./sqlite/SqliteDatabaseCapability.js"; import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js"; import { catalogIdentityForToolFamilies } from "../catalogIdentity.js"; +import { progressiveCatalogIdentity } from "../catalogDiscovery.js"; import { compactPresentationForMcpInputSchemaProfile } from "../config/mcpInputSchemaProfile.js"; import { directAnalysisCleanupFailure, @@ -31,6 +32,15 @@ export const runSessionStatus = async ( session, async () => { const status = jsonObjectSchema.parse(await session.status()); + const catalog = catalogIdentityForToolFamilies( + compactPresentationForMcpInputSchemaProfile( + config.value.mcpInputSchemaProfile, + ), + config.value.mcpOutputSchemaProfile ?? "full", + config.value.mcpToolFamilies === undefined + ? undefined + : new Set(config.value.mcpToolFamilies), + ); return { ...status, capabilities: [ @@ -42,15 +52,10 @@ export const runSessionStatus = async ( server_identity: createServerIdentity({ startedAt: new Date().toISOString(), commandPath: CURRENT_ENTRYPOINT_PATH, - catalog: catalogIdentityForToolFamilies( - compactPresentationForMcpInputSchemaProfile( - config.value.mcpInputSchemaProfile, - ), - config.value.mcpOutputSchemaProfile ?? "full", - config.value.mcpToolFamilies === undefined - ? undefined - : new Set(config.value.mcpToolFamilies), - ), + catalog: + config.value.mcpToolCatalogProfile === "hot-cold" + ? progressiveCatalogIdentity(catalog) + : catalog, }), }; }, diff --git a/src/application/SetupRegistrationEnvironment.ts b/src/application/SetupRegistrationEnvironment.ts index 335c86488a..c909059442 100644 --- a/src/application/SetupRegistrationEnvironment.ts +++ b/src/application/SetupRegistrationEnvironment.ts @@ -72,6 +72,7 @@ const SELECTED_REGISTRATION_SETTINGS = [ "REA_MCP_INPUT_SCHEMA_PROFILE", "REA_MCP_TOOL_FAMILIES", "REA_MCP_OUTPUT_SCHEMA_PROFILE", + "REA_MCP_TOOL_CATALOG_PROFILE", "REA_MCP_MAX_RESPONSE_BYTES", "REA_MCP_MAX_INPUT_ELEMENTS", "REA_NATIVE_MATCHING_REPRESENTATION_BYTES", diff --git a/src/catalogDiscovery.ts b/src/catalogDiscovery.ts new file mode 100644 index 0000000000..1b4b31a64e --- /dev/null +++ b/src/catalogDiscovery.ts @@ -0,0 +1,110 @@ +import { createHash } from "node:crypto"; +import { z } from "zod"; +import type { createCatalogIdentity } from "./catalogIdentity.js"; + +export const HOT_TOOL_NAMES: ReadonlySet = new Set([ + "binary_session", + "open_binary", + "close_binary", + "get_evidence_bundle", + "inspect_analysis_view", +]); + +// One page activates at most 20 schemas: callers can page a broad search instead +// of inadvertently expanding the entire catalog into their client's context. +const pageSize = z.int().min(1).max(20).default(3); +const inputSchema = z + .strictObject({ + query: z + .string() + .trim() + .optional() + .describe( + "Case-insensitive words matched against tool names and descriptions; all words must match. Omit to browse.", + ), + names: z + .array(z.string().min(1)) + .min(1) + .max(20) + .optional() + .describe("Exact tool names to load instead of searching."), + offset: z.int().min(0).default(0), + limit: pageSize.describe( + "Search page size; does not apply to exact names.", + ), + }) + .refine( + (input) => + input.names === undefined || + (input.query === undefined && input.offset === 0), + { + message: "Use names alone, or query with offset/limit.", + }, + ); +const outputSchema = z.strictObject({ + profile: z.literal("hot-cold"), + inventory_count: z.int().nonnegative(), + loaded_count: z.int().nonnegative(), + total_matches: z.int().nonnegative(), + next_offset: z.int().nonnegative().nullable(), + tools: z.array( + z.strictObject({ + name: z.string(), + description: z + .string() + .describe( + "Abbreviated canonical description; read tools/list for the complete contract.", + ), + input_schema: z + .record(z.string(), z.unknown()) + .describe( + "The configured advertised input schema for calling this named tool.", + ), + }), + ), + next_step: z.string(), +}); + +export const DISCOVER_TOOLS = { + name: "discover_tools", + description: + "Find and load REA tools on demand. Search by task keywords (for example javascript, sqlite, strings, imports), or supply exact names. Returned tools become callable with their original names and full schemas after your client refreshes tools/list. Loaded tools remain available for this connection. This only changes catalog visibility; it does not run analysis or grant execution authority. If tools do not appear, enable MCP catalog refresh in your client or use REA_MCP_TOOL_CATALOG_PROFILE=full.", + inputSchema, + outputSchema, + annotations: { + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, +} as const; + +export type CatalogIdentity = ReturnType; +const digest = (value: unknown): string => + createHash("sha256").update(JSON.stringify(value)).digest("hex"); + +/** Identity covers the discoverable inventory and policy, not mutable visibility. */ +export const progressiveCatalogIdentity = ( + base: CatalogIdentity, +): CatalogIdentity => { + const policy = { + profile: "hot-cold", + hot: [...HOT_TOOL_NAMES], + tool: { + ...DISCOVER_TOOLS, + inputSchema: z.toJSONSchema(inputSchema, { io: "input" }), + outputSchema: z.toJSONSchema(outputSchema), + }, + }; + const tools_sha256 = digest({ base: base.digests.tools_sha256, policy }); + return { + counts: { ...base.counts, mcp_tools: base.counts.mcp_tools + 1 }, + digests: { + tools_sha256, + prompts_sha256: base.digests.prompts_sha256, + combined_sha256: digest({ + base: base.digests.combined_sha256, + tools_sha256, + }), + }, + }; +}; diff --git a/src/config/environment.ts b/src/config/environment.ts index ef9b25739c..d07e3d1bbc 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -26,6 +26,7 @@ import { } from "../domain/analysisResourceLimits.js"; import { mcpInputSchemaProfileSchema } from "./mcpInputSchemaProfile.js"; import { mcpOutputSchemaProfileSchema } from "./mcpOutputSchemaProfile.js"; +import { mcpToolCatalogProfileSchema } from "./mcpToolCatalogProfile.js"; import { mcpResponseBudgetSchema } from "./mcpResponseBudget.js"; import { asarMaxHeaderBytesSchema } from "./asarHeaderAdmission.js"; import { bridgeMaxRequestBytesSchema } from "./bridgeMaxRequestBytes.js"; @@ -62,6 +63,7 @@ const environmentSchema = z.object({ REA_MCP_INPUT_SCHEMA_PROFILE: mcpInputSchemaProfileSchema.optional(), REA_MCP_TOOL_FAMILIES: mcpToolFamiliesSchema.optional(), REA_MCP_OUTPUT_SCHEMA_PROFILE: mcpOutputSchemaProfileSchema.optional(), + REA_MCP_TOOL_CATALOG_PROFILE: mcpToolCatalogProfileSchema.optional(), REA_IDA_MCP_CONFIG: z .string() .min(1) diff --git a/src/config/mcpToolCatalogProfile.ts b/src/config/mcpToolCatalogProfile.ts new file mode 100644 index 0000000000..05960b82f1 --- /dev/null +++ b/src/config/mcpToolCatalogProfile.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; +import { ConfigurationError } from "../domain/configurationErrors.js"; +import { err, ok, type Result } from "../domain/result.js"; + +/** Full advertisement remains the default for clients without catalog refresh. */ +export const mcpToolCatalogProfileSchema = z.enum(["full", "hot-cold"]); +export type McpToolCatalogProfile = z.infer; + +export const parseMcpToolCatalogProfile = ( + value: string | undefined, +): Result => { + if (value === undefined) return ok(undefined); + const parsed = mcpToolCatalogProfileSchema.safeParse(value); + const constraint = + 'REA_MCP_TOOL_CATALOG_PROFILE must be "full" (default) or "hot-cold". Hot/cold requires a client that refreshes tools after notifications/tools/list_changed.'; + return parsed.success + ? ok(parsed.data) + : err( + new ConfigurationError(constraint, { + settings: [{ setting: "REA_MCP_TOOL_CATALOG_PROFILE", constraint }], + }), + ); +}; diff --git a/src/config/parseConfig.ts b/src/config/parseConfig.ts index e401d43405..5fe9781fdb 100644 --- a/src/config/parseConfig.ts +++ b/src/config/parseConfig.ts @@ -119,6 +119,9 @@ export const parseConfig = ( ...(env.REA_MCP_OUTPUT_SCHEMA_PROFILE === undefined ? {} : { mcpOutputSchemaProfile: env.REA_MCP_OUTPUT_SCHEMA_PROFILE }), + ...(env.REA_MCP_TOOL_CATALOG_PROFILE === undefined + ? {} + : { mcpToolCatalogProfile: env.REA_MCP_TOOL_CATALOG_PROFILE }), analysisProvider: env.REA_ANALYSIS_PROVIDER, ...(env.REA_IDA_MCP_CONFIG === undefined ? {} diff --git a/src/config/types.ts b/src/config/types.ts index 1abacfd785..b2e3b724cf 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -7,6 +7,7 @@ import type { AnalysisProviderSelector } from "../domain/providerSelection.js"; import type { GhidraLanguageOverride } from "./ghidraLanguageOverride.js"; import type { McpInputSchemaProfile } from "./mcpInputSchemaProfile.js"; import type { McpOutputSchemaProfile } from "./mcpOutputSchemaProfile.js"; +import type { McpToolCatalogProfile } from "./mcpToolCatalogProfile.js"; import type { BridgeMaxRequestBytes } from "./bridgeMaxRequestBytes.js"; import type { RegexMatchingDeadline } from "./regexMatchingDeadline.js"; import type { RegexWorkerHeapRequest } from "./regexWorkerHeapRequest.js"; @@ -18,6 +19,7 @@ export interface AppConfig { readonly mcpMaxInputElements?: number; readonly mcpInputSchemaProfile?: McpInputSchemaProfile; readonly mcpOutputSchemaProfile?: McpOutputSchemaProfile; + readonly mcpToolCatalogProfile?: McpToolCatalogProfile; readonly analysisProvider: AnalysisProviderSelector; readonly idaMcpConfigPath?: string; readonly ghidraInstallDir: string | undefined; diff --git a/src/main/deferredRuntime.ts b/src/main/deferredRuntime.ts index 6df57038e2..e8c981ac4a 100644 --- a/src/main/deferredRuntime.ts +++ b/src/main/deferredRuntime.ts @@ -217,6 +217,8 @@ export const runDeferred = async ( }), config.mcpMaxInputElements, ); + if (config.mcpToolCatalogProfile === "hot-cold") + server.enableProgressiveCatalog(); return server; }); const previous = server.server.onclose; diff --git a/src/mcpDoctor.ts b/src/mcpDoctor.ts index 841e013cb3..35e61bb7a4 100644 --- a/src/mcpDoctor.ts +++ b/src/mcpDoctor.ts @@ -18,6 +18,12 @@ import { } from "./config/mcpInputSchemaProfile.js"; import { parseMcpOutputSchemaProfile } from "./config/mcpOutputSchemaProfile.js"; import { parseMcpToolFamilies } from "./config/mcpToolFamilies.js"; +import { parseMcpToolCatalogProfile } from "./config/mcpToolCatalogProfile.js"; +import { + DISCOVER_TOOLS, + HOT_TOOL_NAMES, + progressiveCatalogIdentity, +} from "./catalogDiscovery.js"; import { contractsForToolFamilies } from "./contracts/toolFamilies.js"; import { promptsForToolFamilies } from "./contracts/promptToolFamilyScope.js"; import { err, ok } from "./domain/result.js"; @@ -600,18 +606,30 @@ const doctorExpectationForEnvironment = ( : undefined; const contracts = contractsForToolFamilies(selection); const promptContracts = promptsForToolFamilies(selection); + const catalogProfile = parseMcpToolCatalogProfile( + environment.REA_MCP_TOOL_CATALOG_PROFILE, + ); + const progressive = catalogProfile.ok && catalogProfile.value === "hot-cold"; + const catalog = createCatalogIdentity({ + compactPresentation: compactPresentationForMcpInputSchemaProfile( + inputProfile.ok ? inputProfile.value : undefined, + ), + contracts, + outputSchemaProfile: outputProfile.ok + ? (outputProfile.value ?? "full") + : "full", + promptContracts, + }); return { - catalog: createCatalogIdentity({ - compactPresentation: compactPresentationForMcpInputSchemaProfile( - inputProfile.ok ? inputProfile.value : undefined, - ), - contracts, - outputSchemaProfile: outputProfile.ok - ? (outputProfile.value ?? "full") - : "full", - promptContracts, - }), - expectedToolNames: contracts.map(({ name }) => name), + catalog: progressive ? progressiveCatalogIdentity(catalog) : catalog, + expectedToolNames: progressive + ? [ + ...contracts + .map(({ name }) => name) + .filter((name) => HOT_TOOL_NAMES.has(name)), + DISCOVER_TOOLS.name, + ] + : contracts.map(({ name }) => name), expectedPromptNames: promptContracts.map(({ name }) => name), }; }; diff --git a/src/server/DeferredEvidenceMcpServer.test.ts b/src/server/DeferredEvidenceMcpServer.test.ts index 425ac88557..44b0ff6e93 100644 --- a/src/server/DeferredEvidenceMcpServer.test.ts +++ b/src/server/DeferredEvidenceMcpServer.test.ts @@ -80,6 +80,7 @@ const fixture = async ( readonly failAfterTool?: boolean; readonly maximumInputElements?: number; readonly evidenceReferenceAlternative?: boolean; + readonly progressive?: boolean; } = {}, ) => { const started = barrier(); @@ -146,6 +147,7 @@ const fixture = async ( options.maximumInputElements, ); const client = new Client({ name: "deferred-fixture-client", version: "1" }); + if (options.progressive) server.enableProgressiveCatalog(); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); @@ -165,6 +167,46 @@ const fixture = async ( }; }; +it("loads cold definitions without initializing analysis and preserves canonical validation and identity", async () => { + const f = await fixture({ progressive: true }); + const identity = f.server.catalogIdentity; + expect(identity.digests.combined_sha256).not.toBe( + catalog.identity.digests.combined_sha256, + ); + expect(identity.counts.mcp_tools).toBe(2); + expect((await f.client.listTools()).tools.map(({ name }) => name)).toEqual([ + "discover_tools", + ]); + const changed = vi.fn(); + f.client.setNotificationHandler("notifications/tools/list_changed", changed); + const discover = () => + f.client.callTool({ + name: "discover_tools", + arguments: { names: ["fixture"] }, + }); + expect((await discover()).isError).not.toBe(true); + expect(f.loads()).toBe(0); + expect(changed).toHaveBeenCalledTimes(1); + await discover(); + expect(changed).toHaveBeenCalledTimes(1); + expect( + (await f.client.callTool({ name: "fixture", arguments: { n: 1.5 } })) + .isError, + ).toBe(true); + expect(f.loads()).toBe(1); + expect(f.calls).not.toHaveBeenCalled(); + expect( + (await f.client.callTool({ name: "fixture", arguments: {} })) + .structuredContent, + ).toEqual({ n: 3 }); + expect(f.server.catalogIdentity).toEqual(identity); + expect(changed).toHaveBeenCalledTimes(1); + const independent = await fixture({ progressive: true }); + expect( + (await independent.client.listTools()).tools.map(({ name }) => name), + ).toEqual(["discover_tools"]); +}); + it("refuses excess input before loading a deferred runtime, then admits a valid call", async () => { const f = await fixture({ maximumInputElements: 2 }); const result = await f.client.callTool({ diff --git a/src/server/EvidenceMcpServerCore.ts b/src/server/EvidenceMcpServerCore.ts index accf187fa8..2d451eb78b 100644 --- a/src/server/EvidenceMcpServerCore.ts +++ b/src/server/EvidenceMcpServerCore.ts @@ -61,6 +61,8 @@ import { TARGET_CONTENT_RESULT_META, } from "./toolResultEncoding.js"; import { RequestTrackingMcpServer } from "./RequestTrackingMcpServer.js"; +import { DISCOVER_TOOLS } from "../catalogDiscovery.js"; +import { ProgressiveToolCatalog } from "./ProgressiveToolCatalog.js"; import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; import { canReferenceInlineEvidence } from "./evidenceInputRegistration.js"; import { @@ -298,6 +300,10 @@ export interface StandardToolRegistration { /** Bind oversized MCP error recovery to this server's Evidence ledger. */ export class EvidenceMcpServerCore extends RequestTrackingMcpServer { + private readonly registrations = new Map(); + private progressiveCatalog: ProgressiveToolCatalog | undefined; + private catalogChangeDepth = 0; + private catalogChanged = false; analysisSettings: AnalysisSettings | undefined; /** Transport-owned Evidence uses the same lifetime as application writes. */ withAdmittedAnalysis: WithAdmittedAnalysis | undefined; @@ -307,7 +313,42 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer { get catalogIdentity(): ReturnType { if (this.identity === undefined) throw new Error("MCP catalog identity has not been configured"); - return this.identity; + return this.progressiveCatalog?.identity ?? this.identity; + } + + /** Install discovery after canonical registrations, before connecting. */ + enableProgressiveCatalog(): void { + if (this.progressiveCatalog !== undefined) return; + const catalog = new ProgressiveToolCatalog( + this.registrations, + this.catalogIdentity, + (change) => { + this.catalogChangeDepth++; + try { + change(); + } finally { + this.catalogChangeDepth--; + if (this.catalogChangeDepth === 0 && this.catalogChanged) { + this.catalogChanged = false; + super.sendToolListChanged(); + } + } + }, + () => this.delivery, + ); + this.progressiveCatalog = catalog; + const { name, ...config } = DISCOVER_TOOLS; + // Transport discovery has no target or analysis lifecycle. Domain tools + // continue through registerTool and their original named handlers. + super.registerTool(name, config, (input, context) => { + context.mcpReq.signal.throwIfAborted(); + return catalog.discover(input); + }); + } + + override sendToolListChanged(): void { + if (this.catalogChangeDepth > 0) this.catalogChanged = true; + else super.sendToolListChanged(); } /** Attach the process-owned session only after lazy runtime construction. */ @@ -462,11 +503,20 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer { config: StandardToolRegistration, callback: ToolCallback, ): RegisteredTool { - return super.registerTool(name, config as never, callback as never); + const registered = super.registerTool( + name, + config as never, + callback as never, + ); + this.registrations.set(name, registered); + return registered; } /** Preserve the SDK transport lifecycle while retaining oversized errors. */ override connect(transport: Transport): Promise { + // HTTP reuses the analysis server after draining the previous transport. + // Reset visibility while disconnected, before the next initialize/list. + if (!this.isConnected()) this.progressiveCatalog?.reset(); return super.connect( new ErrorEvidenceTransport( transport, diff --git a/src/server/ProgressiveToolCatalog.test.ts b/src/server/ProgressiveToolCatalog.test.ts new file mode 100644 index 0000000000..bfde259bc3 --- /dev/null +++ b/src/server/ProgressiveToolCatalog.test.ts @@ -0,0 +1,206 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { afterEach, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { EvidenceMcpServerCore } from "./EvidenceMcpServerCore.js"; +import { ToolResultDelivery } from "./ToolResultDelivery.js"; + +const cleanup: Array<() => Promise> = []; +afterEach(async () => { + for (const close of cleanup.splice(0).reverse()) await close(); +}); + +const connect = async ( + options: { replyBytes?: number; bulk?: boolean } = {}, +) => { + const bulkNames = options.bulk + ? Array.from( + { length: 20 }, + (_, index) => `bulk_${String(index).padStart(2, "0")}`, + ) + : []; + const server = new EvidenceMcpServerCore( + { name: "catalog-test", version: "1" }, + { capabilities: {} }, + undefined, + new ToolResultDelivery(options.replyBytes ?? 1024 * 1024), + undefined, + "full", + { + counts: { + cli_commands: 0, + mcp_tools: 3 + bulkNames.length, + mcp_prompts: 0, + }, + digests: { + tools_sha256: "tools", + prompts_sha256: "prompts", + combined_sha256: "combined", + }, + }, + ); + const forbidden = vi.fn(); + for (const name of [ + "binary_session", + "alpha_sqlite", + "beta_sqlite", + "excluded_sqlite", + ...bulkNames, + ]) { + const registration = server.registerTool( + name, + { + description: `${name} SQLite database inspection`, + inputSchema: name.startsWith("bulk_") + ? z.strictObject({ + payload: z.string().describe("schema guidance ".repeat(64)), + }) + : z.strictObject({}), + }, + () => { + if (name === "excluded_sqlite") forbidden(); + return { content: [] }; + }, + ); + if (name === "excluded_sqlite") registration.disable(); + } + server.enableProgressiveCatalog(); + const client = new Client({ name: "catalog-test-client", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + cleanup.push( + () => server.close(), + () => client.close(), + ); + await server.connect(serverTransport); + await client.connect(clientTransport); + const changed = vi.fn(); + client.setNotificationHandler("notifications/tools/list_changed", changed); + const discover = (args: Record) => + client.callTool({ name: "discover_tools", arguments: args }); + return { server, client, changed, discover, forbidden }; +}; + +it("refuses an oversized maximum page before loading, then delivers every original contract in smaller pages", async () => { + const replyBytes = 8 * 1024; + const f = await connect({ replyBytes, bulk: true }); + const initial = await f.client.listTools(); + const refused = await f.discover({ query: "bulk", limit: 20 }); + expect(refused.isError).toBe(true); + expect(Buffer.byteLength(JSON.stringify(refused))).toBeLessThanOrEqual( + replyBytes - 1024, + ); + const text = refused.content.find((part) => part.type === "text")?.text; + expect(JSON.parse(text ?? "null")).toMatchObject({ + error: { + code: "resource_constraint", + remediation: { action: expect.stringContaining("Reduce limit") }, + }, + }); + expect(await f.client.listTools()).toEqual(initial); + expect(f.changed).not.toHaveBeenCalled(); + const seen: string[] = []; + for (let offset = 0; offset < 20; offset++) { + const result = await f.discover({ query: "bulk", limit: 1, offset }); + expect(result.isError).not.toBe(true); + expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThanOrEqual( + replyBytes - 1024, + ); + expect(result.structuredContent).toMatchObject({ + total_matches: 20, + next_offset: offset === 19 ? null : offset + 1, + loaded_count: offset + 3, + }); + const page = JSON.parse( + result.content.find((part) => part.type === "text")?.text ?? "null", + ); + const tool = page.tools[0]; + seen.push(tool.name); + expect( + (await f.client.listTools()).tools.find(({ name }) => name === tool.name) + ?.inputSchema, + ).toEqual(tool.input_schema); + } + expect(new Set(seen).size).toBe(20); + expect(f.changed).toHaveBeenCalledTimes(20); +}); + +it("bounds an oversized unknown-name failure without changing visibility", async () => { + const replyBytes = 8 * 1024; + const f = await connect({ replyBytes }); + const initial = await f.client.listTools(); + const result = await f.discover({ names: ["unknown".repeat(4096)] }); + expect(result.isError).toBe(true); + expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThanOrEqual( + replyBytes - 1024, + ); + expect(await f.client.listTools()).toEqual(initial); + expect(f.changed).not.toHaveBeenCalled(); +}); + +it("batches visibility changes, pages searches, and keeps excluded tools outside discovery", async () => { + const f = await connect(); + const identity = f.server.catalogIdentity; + const first = await f.discover({ query: "sqlite", limit: 1 }); + expect(first.structuredContent).toMatchObject({ + inventory_count: 4, + loaded_count: 3, + total_matches: 3, + next_offset: 1, + tools: [{ name: "alpha_sqlite" }], + }); + const next = await f.discover({ query: "sqlite", offset: 1, limit: 2 }); + expect(next.structuredContent).toMatchObject({ + loaded_count: 4, + next_offset: null, + tools: [{ name: "beta_sqlite" }, { name: "binary_session" }], + }); + expect(f.changed).toHaveBeenCalledTimes(2); + await f.discover({ names: ["alpha_sqlite", "beta_sqlite"] }); + expect(f.changed).toHaveBeenCalledTimes(2); + expect( + (await f.discover({ query: "missing_word" })).structuredContent, + ).toMatchObject({ total_matches: 0, tools: [], next_offset: null }); + expect((await f.discover({ names: ["excluded_sqlite"] })).isError).toBe(true); + await expect( + f.client.callTool({ name: "excluded_sqlite", arguments: {} }), + ).rejects.toThrow("disabled"); + expect(f.forbidden).not.toHaveBeenCalled(); + expect(f.server.catalogIdentity).toEqual(identity); +}); + +it("loads exact names atomically and emits one notification for a batch", async () => { + const f = await connect(); + expect( + (await f.discover({ names: ["alpha_sqlite", "missing"] })).isError, + ).toBe(true); + expect((await f.client.listTools()).tools.map(({ name }) => name)).toEqual([ + "binary_session", + "discover_tools", + ]); + expect(f.changed).not.toHaveBeenCalled(); + const result = await f.discover({ + names: ["beta_sqlite", "alpha_sqlite", "alpha_sqlite"], + }); + expect(result.structuredContent).toMatchObject({ + tools: [{ name: "beta_sqlite" }, { name: "alpha_sqlite" }], + total_matches: 2, + }); + expect(f.changed).toHaveBeenCalledTimes(1); +}); + +it.each([ + { names: [] }, + { names: ["alpha_sqlite"], query: "sqlite" }, + { names: ["alpha_sqlite"], offset: 1 }, + { limit: 21 }, + { offset: -1 }, + { names: ["alpha_sqlite"], extra: true }, +])( + "rejects malformed discovery before changing visibility: %j", + async (args) => { + const f = await connect(); + expect((await f.discover(args)).isError).toBe(true); + expect((await f.client.listTools()).tools).toHaveLength(2); + expect(f.changed).not.toHaveBeenCalled(); + }, +); diff --git a/src/server/ProgressiveToolCatalog.ts b/src/server/ProgressiveToolCatalog.ts new file mode 100644 index 0000000000..b55fa64886 --- /dev/null +++ b/src/server/ProgressiveToolCatalog.ts @@ -0,0 +1,163 @@ +import type { RegisteredTool } from "@modelcontextprotocol/server"; +import type { z } from "zod"; +import { + AnalysisInputError, + AnalysisResourceConstraintError, +} from "../domain/analysisErrorCore.js"; +import { ownedJsonObjectSchema } from "../domain/jsonValue.js"; +import { encodeToolResult } from "./toolResultEncoding.js"; +import type { ToolResultDelivery } from "./ToolResultDelivery.js"; +import { + DISCOVER_TOOLS, + HOT_TOOL_NAMES, + progressiveCatalogIdentity, + type CatalogIdentity, +} from "../catalogDiscovery.js"; + +/** Per-connection visibility; canonical SDK handles retain validation and authority. */ +export class ProgressiveToolCatalog { + readonly identity: CatalogIdentity; + private readonly tools: ReadonlyMap< + string, + { registered: RegisteredTool; description: string; search: string } + >; + + constructor( + registrations: ReadonlyMap, + identity: CatalogIdentity, + private readonly batchChanges: (change: () => void) => void, + private readonly delivery: () => ToolResultDelivery, + ) { + this.identity = progressiveCatalogIdentity(identity); + // Disabled family registrations are outside the discoverable scope. Never + // let discovery re-enable a tool the caller excluded from this server. + this.tools = new Map( + [...registrations] + .filter(([, tool]) => tool.enabled) + .map(([name, registered]) => [ + name, + { + registered, + description: (registered.description ?? "").slice(0, 240), + search: + `${name} ${name.replaceAll("_", " ")} ${registered.description ?? ""}`.toLowerCase(), + }, + ]), + ); + this.reset(); + } + + /** A reused HTTP server starts each new transport with the hot inventory. */ + reset(): void { + this.batchChanges(() => { + for (const [name, { registered }] of this.tools) { + if (registered.enabled && !HOT_TOOL_NAMES.has(name)) + registered.disable(); + } + }); + } + + discover(input: z.infer) { + const names = + input.names === undefined ? undefined : [...new Set(input.names)]; + const unknown = names?.filter((name) => !this.tools.has(name)) ?? []; + if (unknown.length > 0) + return this.delivery().toErrorToolResult( + new AnalysisInputError( + DISCOVER_TOOLS.name, + { + userMessage: + "Unknown or excluded tool names. No tools were loaded.", + remediationAction: + "Search with query to find exact in-scope names.", + conformsToInputContract: true, + }, + unknown.map((name) => ({ + path: ["names"], + reason: "invalid_value", + message: name, + })), + ), + false, + ); + const words = (input.query ?? "") + .toLowerCase() + .split(/\s+/u) + .filter(Boolean); + const matches = + names ?? + [...this.tools] + .filter(([, tool]) => words.every((word) => tool.search.includes(word))) + .map(([name]) => name) + .sort((left, right) => { + const score = (name: string) => + words.filter((word) => name.includes(word)).length; + return ( + score(right) - score(left) || + (left < right ? -1 : left > right ? 1 : 0) + ); + }); + const page = + names ?? matches.slice(input.offset, input.offset + input.limit); + const selected = page.map((name) => ({ name, ...this.tools.get(name)! })); + const result: z.infer = { + profile: "hot-cold", + inventory_count: this.tools.size + 1, + loaded_count: + 1 + + [...this.tools.values()].filter(({ registered }) => registered.enabled) + .length + + selected.filter(({ registered }) => !registered.enabled).length, + total_matches: matches.length, + next_offset: + names === undefined && input.offset + page.length < matches.length + ? input.offset + page.length + : null, + tools: selected.map(({ name, description, registered }) => ({ + name, + description, + input_schema: registered.inputSchema?.["~standard"].jsonSchema.input({ + target: "draft-2020-12", + }) ?? { type: "object" }, + })), + next_step: + selected.length === 0 + ? "No matches. Try fewer or different task keywords, or browse without query." + : "Call the returned tool by its original name using input_schema. Your client must refresh tools/list first; if it cannot, reconnect with REA_MCP_TOOL_CATALOG_PROFILE=full.", + }; + // This reply borrows read-only SDK schema projections for synchronous + // encoding; validate the new reply without copying their complete trees. + const payload = ownedJsonObjectSchema.parse(result); + const delivery = this.delivery(); + const encoded = encodeToolResult(payload, delivery.resultBudgetBytes); + if (!encoded.ok) + return delivery.toErrorToolResult( + new AnalysisResourceConstraintError( + DISCOVER_TOOLS.name, + "transport", + "The discovery page exceeds the reply budget. No tools were loaded.", + { + result_budget_bytes: delivery.resultBudgetBytes, + response_bytes_at_least: encoded.bytesAtLeast, + constraint: encoded.constraint, + }, + { + remediationAction: + "Reduce limit or request fewer exact names, then retry. For one oversized input contract, reconnect with REA_MCP_INPUT_SCHEMA_PROFILE=compact.", + }, + ), + false, + ); + // Visibility changes only after both reply representations fit. A failed + // page can be retried without silently enabling unreported definitions. + this.batchChanges(() => { + for (const { registered } of selected) { + if (!registered.enabled) registered.enable(); + } + }); + return { + content: [{ type: "text" as const, text: encoded.text }], + structuredContent: payload, + }; + } +} diff --git a/src/server/createServer.ts b/src/server/createServer.ts index e016e7f7b6..2eb4ef63b2 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -43,6 +43,7 @@ import { registerFridaTools } from "./registerFridaTools.js"; import { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import { catalogIdentityForToolFamilies } from "../catalogIdentity.js"; import { parseMcpToolFamilies } from "../config/mcpToolFamilies.js"; +import { parseMcpToolCatalogProfile } from "../config/mcpToolCatalogProfile.js"; import { createToolResultDelivery } from "./toolResult.js"; import type { ToolResultDelivery } from "./ToolResultDelivery.js"; import { parseMcpResponseBudget } from "../config/mcpResponseBudget.js"; @@ -171,6 +172,10 @@ export const createServer = ( const compactInputPresentation = selectCompactInputSchemaPresentation(environment); const selectedOutputSchemaProfile = outputSchemaProfile(environment); + const catalogProfile = parseMcpToolCatalogProfile( + environment.REA_MCP_TOOL_CATALOG_PROFILE, + ); + if (!catalogProfile.ok) throw catalogProfile.error; const advertisedToolFamilies = selectAdvertisedToolFamilies(environment); const providerEnvironment = captureProviderEnvironment( options.providerEnvironment, @@ -810,6 +815,7 @@ export const createServer = ( }, }); registerGuidedPrompts(server, analysis, session); + if (catalogProfile.value === "hot-cold") server.enableProgressiveCatalog(); return server; }; diff --git a/src/setupEnvironment.test.ts b/src/setupEnvironment.test.ts index 5a5ed8d9ad..1a9efce788 100644 --- a/src/setupEnvironment.test.ts +++ b/src/setupEnvironment.test.ts @@ -195,6 +195,7 @@ it.each([ REA_GHIDRA_STARTUP_TIMEOUT_MS: "2700000", REA_HOPPER_STARTUP_TIMEOUT_MS: "120000", REA_MCP_INPUT_SCHEMA_PROFILE: "full", + REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold", REA_CAPTURE_DECODER_OLD_GENERATION_MIB: "256", REA_REGEX_MATCHING_DEADLINE_MS: "10000", REA_REGEX_WORKER_HEAP_REQUEST_MIB: "256", @@ -235,6 +236,7 @@ it.each([ REA_GHIDRA_STARTUP_TIMEOUT_MS: "2700000", REA_HOPPER_STARTUP_TIMEOUT_MS: "120000", REA_MCP_INPUT_SCHEMA_PROFILE: "full", + REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold", REA_CAPTURE_DECODER_OLD_GENERATION_MIB: "256", REA_REGEX_MATCHING_DEADLINE_MS: "10000", REA_REGEX_WORKER_HEAP_REQUEST_MIB: "256", diff --git a/tests/boundary/mcp/deferredRuntime.test.ts b/tests/boundary/mcp/deferredRuntime.test.ts index 3a5e7ba962..6e64ee1d61 100644 --- a/tests/boundary/mcp/deferredRuntime.test.ts +++ b/tests/boundary/mcp/deferredRuntime.test.ts @@ -20,6 +20,10 @@ import { TOOL_FAMILY_DEFINITIONS } from "../../../src/contracts/toolFamilies.js" import { evidenceSchema } from "../../../src/domain/evidence.js"; import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; import { SERVER_START_FAILED } from "../../../src/main/messages.js"; +import { HOT_TOOL_NAMES } from "../../../src/catalogDiscovery.js"; +import { DatabaseSync } from "node:sqlite"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import type { AnySchemaObject } from "ajv"; const connect = async ( mode: "legacy" | "auto", @@ -56,8 +60,117 @@ const connect = async ( return { client, stderr: () => stderr }; }; +it.each([ + { mode: "legacy", environment: {} }, + { mode: "auto", environment: {} }, + { mode: "legacy", environment: { REA_MCP_OUTPUT_SCHEMA_PROFILE: "off" } }, + { + mode: "auto", + environment: { + REA_MCP_OUTPUT_SCHEMA_PROFILE: "compact", + REA_MCP_TOOL_FAMILIES: "session,application,artifact", + }, + }, +] as const)( + "discovers and executes cold tools with $mode and $environment", + async ({ mode, environment }) => { + const directory = await createTestTempDirectory("rea-cold-sqlite-"); + const path = join(directory, "app.db"); + const database = new DatabaseSync(path); + try { + database.exec( + "CREATE TABLE catalog_probe (id INTEGER PRIMARY KEY, name TEXT); INSERT INTO catalog_probe VALUES (7, 'pelican');", + ); + } finally { + database.close(); + } + const { client, stderr } = await connect(mode, { + ...environment, + REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold", + }); + const initial = await client.listTools(); + expect(new Set(initial.tools.map(({ name }) => name))).toEqual( + new Set([...HOT_TOOL_NAMES, "discover_tools"]), + ); + for (const tool of initial.tools) { + const ajv = new Ajv2020({ strict: false }); + expect(ajv.validateSchema(tool.inputSchema)).toBe(true); + if (tool.outputSchema !== undefined) + expect(ajv.validateSchema(tool.outputSchema as AnySchemaObject)).toBe( + true, + ); + } + const call = async (name: string, args: Record) => { + if (environment.REA_MCP_OUTPUT_SCHEMA_PROFILE === "off") { + const definition = (await client.listTools()).tools.find( + (tool) => tool.name === name, + ); + if (definition === undefined) + throw new Error(`Not advertised: ${name}`); + return client.callTool( + { name, arguments: args }, + { toolDefinition: definition }, + ); + } + return artifactMcpCall(client, { name, arguments: args }); + }; + const invalid = await call("discover_tools", { + names: ["inspect_sqlite_database", "missing_tool"], + }); + expect(invalid.isError).toBe(true); + expect(await client.listTools()).toEqual(initial); + const loaded = await call("discover_tools", { + names: ["list_unknowns", "list_unknowns"], + }); + expect(loaded.structuredContent).toMatchObject({ + loaded_count: 7, + total_matches: 1, + tools: [{ name: "list_unknowns" }], + }); + expect((await call("list_unknowns", {})).isError).not.toBe(true); + const materialized = await client.listTools(); + expect(materialized.tools).toHaveLength(7); + expect( + materialized.tools.find(({ name }) => name === "inspect_sqlite_database"), + ).toBeUndefined(); + const found = await call("discover_tools", { query: "sqlite", limit: 1 }); + expect(found.structuredContent).toMatchObject({ + total_matches: 1, + tools: [{ name: "inspect_sqlite_database" }], + }); + const advertisedSqlite = (await client.listTools()).tools.find( + ({ name }) => name === "inspect_sqlite_database", + ); + expect(found.structuredContent).toMatchObject({ + tools: [{ input_schema: advertisedSqlite?.inputSchema }], + }); + const result = await call("inspect_sqlite_database", { + path, + table: "catalog_probe", + columns: ["id", "name"], + row_limit: 1, + }); + expect(result.isError).not.toBe(true); + expect(JSON.stringify(result.structuredContent)).toContain("pelican"); + expect(JSON.stringify(result.structuredContent)).toContain("evidence_id"); + expect((await client.listTools()).tools).toHaveLength(8); + if ("REA_MCP_TOOL_FAMILIES" in environment) { + expect( + (await call("discover_tools", { names: ["current_address"] })).isError, + ).toBe(true); + expect((await client.listTools()).tools).toHaveLength(8); + } + expect(stderr()).toBe(""); + }, +); + it.each([ { name: "deferred", environment: {}, eager: false }, + { + name: "hot/cold deferred", + environment: { REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold" }, + eager: false, + }, { name: "compact deferred", environment: { REA_MCP_INPUT_SCHEMA_PROFILE: "compact" }, @@ -113,8 +226,15 @@ registerHooks({ const { client, stderr } = await connect("legacy", environment, [ `--import=${hook}`, ]); - const discovery = await client.listTools(); + let discovery = await client.listTools(); await client.listPrompts(); + if (environment.REA_MCP_TOOL_CATALOG_PROFILE === "hot-cold") { + await client.callTool({ + name: "discover_tools", + arguments: { names: ["list_unknowns"] }, + }); + discovery = await client.listTools(); + } expect(await loadedModules()).toEqual( new Set(eager ? ["entrypoint", ...modules] : ["entrypoint"]), ); diff --git a/tests/boundary/mcp/mcpDoctor.test.ts b/tests/boundary/mcp/mcpDoctor.test.ts index 005be263e4..c45846280d 100644 --- a/tests/boundary/mcp/mcpDoctor.test.ts +++ b/tests/boundary/mcp/mcpDoctor.test.ts @@ -1,4 +1,5 @@ import { contractsForToolFamilies } from "../../../src/contracts/toolFamilies.js"; +import { progressiveCatalogIdentity } from "../../../src/catalogDiscovery.js"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { execFile, spawn } from "node:child_process"; @@ -19,6 +20,7 @@ import { runProductionMcpDoctorCli, } from "../../../src/mcpDoctor.js"; import { + DEFAULT_CATALOG_IDENTITY, FULL_CATALOG_IDENTITY, catalogIdentityForToolFamilies, createCatalogIdentity, @@ -195,6 +197,33 @@ const catalogProfiles = [ }, ]; +it("recognizes a hot/cold initial catalog and its inventory identity", async () => { + const result = await runProductionMcpDoctor({ + command: process.execPath, + args: [resolve("scripts/rea.mjs"), "mcp"], + cwd: process.cwd(), + environment: { ...process.env, REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold" }, + }); + expect(result.healthy).toBe(true); + expect(result.inventory?.tools).toMatchObject({ + expected: 6, + observed: 6, + missing: [], + unexpected: [], + }); + expect(result.identity?.observed_catalog_digest).toBe( + progressiveCatalogIdentity(DEFAULT_CATALOG_IDENTITY).digests + .combined_sha256, + ); + const environment = { + ...process.env, + REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold", + }; + const identity = progressiveCatalogIdentity(DEFAULT_CATALOG_IDENTITY); + await assertStatusCatalogIdentity(environment, identity); + await assertCliCatalogIdentity(environment, identity); +}); + describe("production MCP doctor", () => { it("reuses immutable compiled profile digests and preserves custom-budget identity", async () => { // Exercise the generated module with Node, outside Vitest's source loader. diff --git a/tests/boundary/mcp/streamableHttpMcp.test.ts b/tests/boundary/mcp/streamableHttpMcp.test.ts index 11150c8ce8..38c28d65a1 100644 --- a/tests/boundary/mcp/streamableHttpMcp.test.ts +++ b/tests/boundary/mcp/streamableHttpMcp.test.ts @@ -6,7 +6,10 @@ import { describe, expect, onTestFinished } from "vitest"; import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js"; import { expectForeignHostRefused } from "../../support/http/rawRequest.js"; -import { startMcpHttpProcess } from "../../support/mcp/streamableHttpProcess.js"; +import { + startMcpHttpProcess, + startListeningProcess, +} from "../../support/mcp/streamableHttpProcess.js"; import { processTest } from "../../support/process/processFixture.js"; import { assertAdvertisedMcpContracts } from "./mcpContractHarness.js"; @@ -44,6 +47,54 @@ const expectIdentity = async ({ }; describe("rea mcp --http", () => { + processTest( + "keeps hot/cold visibility within its HTTP session and refreshes named contracts", + async ({ processes }) => { + const started = await startListeningProcess( + processes, + ["mcp", "--http", "--port", "0"], + { REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold" }, + ); + const first = await openSession(started.url, "hot-cold-first"); + expect((await first.client.listTools()).tools).toHaveLength(6); + let changed = 0; + first.client.setNotificationHandler( + "notifications/tools/list_changed", + () => { + changed++; + }, + ); + const discovered = await first.client.callTool({ + name: "discover_tools", + arguments: { names: ["list_unknowns"] }, + }); + expect(discovered.isError).not.toBe(true); + const { toolDefinition } = await advertisedMcpToolSchemas( + first.client, + "list_unknowns", + ); + expect(changed).toBe(1); + expect( + ( + await first.client.callTool( + { name: "list_unknowns", arguments: {} }, + { toolDefinition }, + ) + ).isError, + ).not.toBe(true); + await first.transport.terminateSession(); + await first.client.close(); + const second = await openSession(started.url, "hot-cold-second"); + expect( + (await second.client.listTools()).tools.map(({ name }) => name), + ).not.toContain("list_unknowns"); + expect((await second.client.listTools()).tools).toHaveLength(6); + await expectIdentity(second); + await second.transport.terminateSession(); + }, + HTTP_BOUNDARY_TIMEOUT_MS, + ); + processTest( "serves initialize, tools/list, a tool call and DELETE, then keeps serving later clients", async ({ processes }) => { From a7574d4274728fcb0a13414d6ae43f1597864d94 Mon Sep 17 00:00:00 2001 From: 2SinceJuly Date: Fri, 6 Nov 2026 07:57:14 +0800 Subject: [PATCH 002/151] fix(filesystem): settle a Windows ChangeTime before recording a baseline (#8979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(filesystem): settle a Windows ChangeTime before recording a baseline Windows advances a file's ChangeTime shortly after it is written while security components attach catalog and file-hash extended attributes. A baseline captured during that advance disagrees with the next observation, so an untouched file was reported as changed: "Root artifact changed before inventory" and "Selected input changed while being read". Dropping ctime on Windows is not an option because it is the only field that catches a same-size rewrite whose modification time was restored. Instead, keep ctime at full strength and stop capturing it mid-flight: on win32 a ChangeTime still inside the drift window is observed again until two consecutive observations agree, and the settled value becomes the baseline. Applied where a baseline is captured and later compared: openRegularFile's admitted metadata and the inventory root snapshot. The exhaustive process capture walk is left alone, since settling there would delay every freshly written file in the tree. Refs #8975 Co-Authored-By: Claude Sonnet 5 * fix(filesystem): wait out the drift window instead of a repeated value Settling on two matching observations was not enough. Two reads taken before Windows performs its delayed ChangeTime update agree with each other and disagree with every later one, so an unchanged file still failed a later check: the reported drift lands 0.3-1.0 s after the write, well past a pair of reads 25 ms apart. Decide on the observation's age in the drift window instead, and keep observing until the ChangeTime itself has aged out. Both the first read and the empty-observation path now go through the same window check. The window-exit test could not fail before: its clock was frozen, so the window never closed and the exit branch never ran. Advance the clock while sleeping and cover a match that precedes a delayed update. Refs #8975 Co-Authored-By: Claude Sonnet 5 * fix(filesystem): configure Windows ChangeTime settle limits REA_WINDOWS_CHANGE_TIME_DRIFT_MS, REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS, and REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS keep today's 2000 ms, 25 ms, and 40-poll defaults. Invalid values name the setting, current value, source, and range, and say to restart REA. * fix(filesystem): settle on the selected environment and stop when cancelled `settleChangeTimeBaseline` resolved its limits from `process.env`, but the inventory already carries the environment its owner selected. A Windows runtime with explicit settle settings therefore ran on ambient values or the defaults, and an invalid ambient value could fail a runtime whose own configuration was valid. `scanCanonical` now passes `options.environment` through. The wait loop also never observed the owning operation's signal, so a cancelled inventory could keep polling for the whole drift window — up to a minute at the configured maximum — and closing the artifact scope waited on it. The loop now checks the signal before each poll and after the sleep, and both call sites pass theirs. Refs morluto/rea#8979 * fix(filesystem): keep the caller's typed error on cancellation Checking the signal was not enough: `throwIfAborted` threw the raw `signal.reason`, which `openRegularFile` passed through unchanged. Its callers recognise cancellation through `RegularFileOperationCancelled`, so a cancelled admission surfaced as an `ArtifactReaderFailure` with reason `io` instead of a cancellation. Let the seam supply the error a caller matches on, and have `openRegularFile` pass its own cancellation type. Refs morluto/rea#8979 * fix(filesystem): keep inventory cancellation typed during ChangeTime settle scanCanonical passed the abort signal into settleChangeTimeBaseline without a cancellationError, so an aborted inventory threw the raw AbortError and analyze reported execution_failure. Inventory now throws ArtifactReaderFailure reason cancelled, which callers already translate as cancellation. * fix(filesystem): skip ChangeTime settling for private PYZ images External admission still waits out a fresh Windows ChangeTime. A private image REA just wrote is recorded as written, so a compressed PYZ spool does not add about a second per image. --------- Co-authored-by: 2SinceJuly <202121119+2SinceJuly@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 Co-authored-by: Cursor Agent Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/client-configuration.md | 2 +- docs/design/limits-and-recovery.md | 13 + .../SetupRegistrationEnvironment.ts | 3 + src/artifacts/ChangeTimeBaseline.test.ts | 227 ++++++++++++++++++ .../ArtifactInventoryFinalization.test.ts | 22 ++ src/artifacts/inventory/scanCanonical.ts | 14 +- .../PyInstallerPrivateImageBaseline.test.ts | 67 ++++++ .../pyinstaller/PyInstallerPyzStore.ts | 3 + src/config/environment.ts | 10 + src/config/windowsChangeTimeSettle.test.ts | 132 ++++++++++ src/config/windowsChangeTimeSettle.ts | 199 +++++++++++++++ src/filesystem/ChangeTimeBaseline.ts | 119 +++++++++ src/filesystem/RegularFile.ts | 34 ++- src/setupEnvironment.test.ts | 16 +- .../clientConfigurationEnvironment.test.ts | 15 ++ 15 files changed, 866 insertions(+), 10 deletions(-) create mode 100644 src/artifacts/ChangeTimeBaseline.test.ts create mode 100644 src/artifacts/pyinstaller/PyInstallerPrivateImageBaseline.test.ts create mode 100644 src/config/windowsChangeTimeSettle.test.ts create mode 100644 src/config/windowsChangeTimeSettle.ts create mode 100644 src/filesystem/ChangeTimeBaseline.ts diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 38250bf6a1..998719288e 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -334,7 +334,7 @@ This headroom is a practical default, not a guarantee for every analysis duration. Setup preserves positive timeout values already in the `rea` entry. Setup also forwards explicitly selected input/output schema profiles, -`REA_MCP_MAX_RESPONSE_BYTES`, `REA_MCP_MAX_INPUT_ELEMENTS`, provider paths and supported Ghidra, JADX and Kuna +`REA_MCP_MAX_RESPONSE_BYTES`, `REA_MCP_MAX_INPUT_ELEMENTS`, the Windows ChangeTime settle settings `REA_WINDOWS_CHANGE_TIME_DRIFT_MS`, `REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS`, and `REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS`, provider paths and supported Ghidra, JADX and Kuna settings. It also forwards explicit executable and resource selections for ADB, apktool, packet capture, GDB/Rizin/objdump, Cutter, WABT, Wakaru, firmware and signal tools, browser inspection, and pwntools/pwndbg. This includes their diff --git a/docs/design/limits-and-recovery.md b/docs/design/limits-and-recovery.md index c6ea9dde98..ad3afb24d1 100644 --- a/docs/design/limits-and-recovery.md +++ b/docs/design/limits-and-recovery.md @@ -125,6 +125,19 @@ raise REA's reserved string limit or Node's string ceiling. Like the regex deadline, it is in `SELECTED_REGISTRATION_SETTINGS`, so setup forwards an explicit selection into the client registration. +Windows ChangeTime admission waits out a fresh timestamp before recording a +baseline. `REA_WINDOWS_CHANGE_TIME_DRIFT_MS` (default 2000 ms, floor 1 ms, +maximum 60000 ms), `REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS` (default 25 ms, +floor 1 ms, maximum 1000 ms), and `REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS` +(default 40, floor 1, maximum 1000) are launch settings. Unset keeps those +defaults. An invalid value is a configuration error: it names the setting, the +rejected value, source `setting`, the default, the floor, and the maximum, and +says to set a positive decimal integer in the environment that starts REA and +then restart REA. There is no runtime override. Setup forwards an explicit +selection. When the poll budget runs out, admission keeps the last observation +instead of failing the readable file. Raising a setting only changes how long +that wait can last. It does not change Windows ChangeTime itself. + A per-call budget can have a runtime default without having an environment setting. JavaScript `max_heap_mb` (CLI `--max-heap-mb`) is an integer from 128 to 16384 MiB, default 1024. It diff --git a/src/application/SetupRegistrationEnvironment.ts b/src/application/SetupRegistrationEnvironment.ts index c909059442..54e2d4183e 100644 --- a/src/application/SetupRegistrationEnvironment.ts +++ b/src/application/SetupRegistrationEnvironment.ts @@ -78,6 +78,9 @@ const SELECTED_REGISTRATION_SETTINGS = [ "REA_NATIVE_MATCHING_REPRESENTATION_BYTES", "REA_REGEX_MATCHING_DEADLINE_MS", "REA_REGEX_WORKER_HEAP_REQUEST_MIB", + "REA_WINDOWS_CHANGE_TIME_DRIFT_MS", + "REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS", + "REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS", "REA_MCP_INITIALIZE_BUDGET_MS", "REA_MCP_TOOLS_LIST_BUDGET_MS", "REA_MCP_FIRST_CATALOG_BUDGET_MS", diff --git a/src/artifacts/ChangeTimeBaseline.test.ts b/src/artifacts/ChangeTimeBaseline.test.ts new file mode 100644 index 0000000000..2c6948fb20 --- /dev/null +++ b/src/artifacts/ChangeTimeBaseline.test.ts @@ -0,0 +1,227 @@ +import type { Stats } from "node:fs"; + +import { expect, it } from "vitest"; + +import { + changeTimeMayStillDrift, + settleChangeTimeBaseline, +} from "../filesystem/ChangeTimeBaseline.js"; + +const DRIFT_MS = 2_000; + +const statsAt = (changeTimeMs: number): Stats => + ({ isFile: () => true, ctimeMs: changeTimeMs }) as Stats; + +/** A read seam whose ChangeTime follows a scripted timeline. */ +const scriptedRead = (changeTimes: readonly number[]) => { + let index = 0; + const read = async (): Promise => { + const value = changeTimes[Math.min(index, changeTimes.length - 1)]!; + index += 1; + return statsAt(value); + }; + return { read, reads: () => index }; +}; + +/** + * A clock that advances only when the helper sleeps, the way a real wait does. + * A frozen clock would let a test pass without the drift-window check ever + * running, which is how the previous version of this file hid a real defect. + */ +const advancingSeams = (startMs: number, pollMs: number) => { + let now = startMs; + const sleeps: number[] = []; + return { + platform: "win32" as const, + now: () => now, + sleep: async (delayMs: number) => { + sleeps.push(delayMs); + now += pollMs; + }, + sleeps, + }; +}; + +it("treats a non-Windows host as already settled", async () => { + const { read, reads } = scriptedRead([1_000]); + const settled = await settleChangeTimeBaseline(read, { + platform: "darwin", + now: () => 1_001, + }); + expect(settled.ctimeMs).toBe(1_000); + expect(reads()).toBe(1); +}); + +it("does not re-enter the drift window for an established file", async () => { + const { read, reads } = scriptedRead([1_000]); + const settled = await settleChangeTimeBaseline( + read, + advancingSeams(1_000 + DRIFT_MS + 1, 100), + ); + expect(settled.ctimeMs).toBe(1_000); + expect(reads()).toBe(1); +}); + +it("keeps observing past a match that precedes a delayed Windows update", async () => { + // Three reads agree at 1_500 because the delayed update has not landed yet. + // Settling on a matching pair would return 1_500 and misreport the later 1_700. + const { read, reads } = scriptedRead([1_500, 1_500, 1_500, 1_700, 1_700]); + const settled = await settleChangeTimeBaseline( + read, + advancingSeams(1_500, 25), + ); + expect(settled.ctimeMs).toBe(1_700); + expect(reads()).toBeGreaterThan(2); +}); + +it("exits on the window closing rather than on a repeated value", async () => { + const { read, reads } = scriptedRead([1_500, 1_500, 1_500]); + const seams = advancingSeams(1_500, 1_000); + const settled = await settleChangeTimeBaseline(read, seams); + expect(settled.ctimeMs).toBe(1_500); + // Both observations already agree after the first poll. Settling on that + // match would stop there; this stops only once the clock leaves the window. + expect(reads()).toBe(3); + expect(seams.sleeps.length).toBe(2); +}); + +it("settles an unreadable ChangeTime rather than trusting it", () => { + expect( + changeTimeMayStillDrift(statsAt(Number.NaN), { platform: "win32" }), + ).toBe(true); + expect( + changeTimeMayStillDrift(statsAt(Number.NaN), { platform: "linux" }), + ).toBe(false); +}); + +it("gives up settling instead of stalling admission forever", async () => { + const { read, reads } = scriptedRead([1_000]); + const sleeps: number[] = []; + const settled = await settleChangeTimeBaseline(read, { + platform: "win32", + // A clock that never advances leaves the window permanently open. + now: () => 1_000, + sleep: async (delayMs: number) => { + sleeps.push(delayMs); + }, + }); + expect(settled.ctimeMs).toBe(1_000); + expect(sleeps.length).toBe(40); + expect(reads()).toBeLessThanOrEqual(41); +}); + +it("uses the configured drift window, poll, and poll budget", async () => { + const established = scriptedRead([1_000]); + const settled = await settleChangeTimeBaseline(established.read, { + platform: "win32", + now: () => 1_500, + environment: { REA_WINDOWS_CHANGE_TIME_DRIFT_MS: "100" }, + }); + expect(settled.ctimeMs).toBe(1_000); + expect(established.reads()).toBe(1); + + const drifting = scriptedRead([1_000]); + const sleeps: number[] = []; + await settleChangeTimeBaseline(drifting.read, { + platform: "win32", + now: () => 1_000, + sleep: async (delayMs: number) => { + sleeps.push(delayMs); + }, + environment: { + REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS: "7", + REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS: "2", + }, + }); + expect(sleeps).toEqual([7, 7]); + expect(drifting.reads()).toBe(3); +}); + +it("refuses an invalid drift setting instead of using the default", async () => { + const { read, reads } = scriptedRead([1_000]); + await expect( + settleChangeTimeBaseline(read, { + platform: "win32", + environment: { REA_WINDOWS_CHANGE_TIME_DRIFT_MS: "1e3" }, + }), + ).rejects.toMatchObject({ + _tag: "ConfigurationError", + settings: [{ setting: "REA_WINDOWS_CHANGE_TIME_DRIFT_MS" }], + }); + expect(reads()).toBe(0); +}); + +it("reads the settle limits from the selected runtime environment", async () => { + // The owning runtime selects its own environment; ambient values can hold a + // different drift window, or an invalid one that fails a valid runtime + // (greptile review on rea#8979). + const observeWith = async (environment: Record) => { + const { read, reads } = scriptedRead([1_000]); + await settleChangeTimeBaseline(read, { + platform: "win32", + now: () => 1_001, + environment, + sleep: async () => {}, + }); + return reads(); + }; + + // A 50 s selected window keeps a 1 s-old ChangeTime drifting; the ambient + // 2 s default would call it settled after a single read. + expect( + await observeWith({ REA_WINDOWS_CHANGE_TIME_DRIFT_MS: "50000" }), + ).toBeGreaterThan(1); +}); + +it("refuses an invalid value from the selected environment", async () => { + // The ambient environment must not decide whether a valid runtime starts. + const { read } = scriptedRead([1_000]); + await expect( + settleChangeTimeBaseline(read, { + platform: "win32", + now: () => 1_001, + environment: { REA_WINDOWS_CHANGE_TIME_DRIFT_MS: "nonsense" }, + }), + ).rejects.toThrow(/REA_WINDOWS_CHANGE_TIME_DRIFT_MS/); +}); + +it("stops settling as soon as the owning operation is cancelled", async () => { + // A cancelled scope must not sit out the whole drift window; the previous + // fixed budget bounded that at about one second (greptile review on rea#8979). + const controller = new AbortController(); + const { read, reads } = scriptedRead([1_000, 1_000, 1_000]); + const seams = advancingSeams(1_001, 25); + await expect( + settleChangeTimeBaseline(read, { + ...seams, + sleep: async (delayMs: number) => { + await seams.sleep(delayMs); + controller.abort(new Error("scope closed")); + }, + signal: controller.signal, + }), + ).rejects.toThrow("scope closed"); + // One observation, one sleep: it never re-read after the abort. + expect(reads()).toBe(1); +}); + +it("throws the caller's typed cancellation rather than the raw reason", async () => { + // Callers recognise cancellation through their own error class; a raw + // signal reason reads as a read failure to each of them + // (greptile review on rea#8979). + class Cancelled extends Error {} + const controller = new AbortController(); + const { read } = scriptedRead([1_000]); + const seams = advancingSeams(1_001, 25); + await expect( + settleChangeTimeBaseline(read, { + ...seams, + sleep: async (delayMs: number) => { + await seams.sleep(delayMs); + controller.abort(new Error("raw reason")); + }, + signal: controller.signal, + cancellationError: (reason) => new Cancelled(String(reason)), + }), + ).rejects.toBeInstanceOf(Cancelled); +}); diff --git a/src/artifacts/inventory/ArtifactInventoryFinalization.test.ts b/src/artifacts/inventory/ArtifactInventoryFinalization.test.ts index d3f4b6b5af..1b484db159 100644 --- a/src/artifacts/inventory/ArtifactInventoryFinalization.test.ts +++ b/src/artifacts/inventory/ArtifactInventoryFinalization.test.ts @@ -17,6 +17,28 @@ import { afterEach(() => vi.restoreAllMocks()); +it("reports a cancelled ChangeTime settle as inventory cancellation", async () => { + const root = await createTestTempDirectory("rea-inventory-settle-cancel-"); + await writeFile(join(root, "main.js"), "export const observed = 1;\n"); + const scope = new ArtifactResourceScope(); + const controller = new AbortController(); + const reason = new DOMException("This operation was aborted", "AbortError"); + controller.abort(reason); + const failure: unknown = await scope + .run(() => + scanCanonicalArtifactInventoryInScope(root, { + resourceScope: scope, + signal: controller.signal, + }), + ) + .catch((cause: unknown) => cause); + expect(failure).toBeInstanceOf(ArtifactReaderFailure); + expect(failure).toMatchObject({ + reason: "cancelled", + cancellation: { reason }, + }); +}); + const zipFixture = async () => { const directory = await createTestTempDirectory( "rea-inventory-finalization-", diff --git a/src/artifacts/inventory/scanCanonical.ts b/src/artifacts/inventory/scanCanonical.ts index f8d5e14d12..2d024c1d9c 100644 --- a/src/artifacts/inventory/scanCanonical.ts +++ b/src/artifacts/inventory/scanCanonical.ts @@ -11,6 +11,7 @@ import { import { lstat } from "node:fs/promises"; import type { Stats } from "node:fs"; +import { settleChangeTimeBaseline } from "../../filesystem/ChangeTimeBaseline.js"; import type { RootInventorySource } from "./classify.js"; import { isZipPackageFormat } from "../../domain/zipPackageFormat.js"; @@ -107,7 +108,18 @@ const scanCanonical = async ( readonly snapshot: ArtifactInventorySnapshot; readonly transferredSource: TransferredArtifactInventorySource | undefined; }> => { - const metadata = await lstat(path); + const metadata = await settleChangeTimeBaseline(() => lstat(path), { + // The owner selected this runtime's environment; the settle limits must + // come from it rather than the daemon's ambient one. + environment: options.environment, + signal: options.signal, + // Callers match ArtifactReaderFailure reason "cancelled". The raw + // signal.reason is an AbortError and is reported as an execution failure. + cancellationError: (reason) => + new ArtifactReaderFailure("cancelled", "Artifact inventory cancelled", { + cause: reason, + }), + }); const { format: rootFormat, digest: rootDigest, diff --git a/src/artifacts/pyinstaller/PyInstallerPrivateImageBaseline.test.ts b/src/artifacts/pyinstaller/PyInstallerPrivateImageBaseline.test.ts new file mode 100644 index 0000000000..145b74e668 --- /dev/null +++ b/src/artifacts/pyinstaller/PyInstallerPrivateImageBaseline.test.ts @@ -0,0 +1,67 @@ +import { expect, it, vi } from "vitest"; + +import { withPyInstallerReader } from "../../../tests/fixtures/pyinstallerReader.js"; +import { openRegularFile } from "../../filesystem/RegularFile.js"; +import { + cArchiveExecutable, + pyzArchive, + PYTHON_39_MAGIC, +} from "./CArchive.fixture.js"; + +const recordedSleeps = vi.hoisted(() => [] as number[]); + +vi.mock("../../filesystem/ChangeTimeBaseline.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../filesystem/ChangeTimeBaseline.js") + >(); + return { + ...actual, + settleChangeTimeBaseline: ( + read: Parameters[0], + seams?: Parameters[1], + ) => + actual.settleChangeTimeBaseline(read, { + ...seams, + // A frozen Windows clock keeps a fresh ChangeTime inside the drift + // window, so a real settle records one sleep per poll. + platform: "win32", + now: () => 0, + sleep: async (delayMs: number) => { + recordedSleeps.push(delayMs); + }, + }), + }; +}); + +it("does not settle ChangeTime for a private compressed PYZ image", async () => { + const bytes = cArchiveExecutable({ + layout: "2.1", + pythonVersion: 309, + pythonLibrary: "libpython3.9.so", + members: [ + { + name: "PYZ.pyz", + typeCode: "z", + compress: true, + payload: pyzArchive({ + magic: PYTHON_39_MAGIC, + modules: [{ name: "child", typeCode: 0, body: Buffer.from("child") }], + }), + }, + ], + }); + await withPyInstallerReader(bytes, async (reader, _handle, path) => { + recordedSleeps.length = 0; + const paths: string[] = []; + for await (const entry of reader.entries()) paths.push(entry.path); + expect(paths.some((entry) => entry.endsWith("child.pyc"))).toBe(true); + expect(recordedSleeps).toEqual([]); + + recordedSleeps.length = 0; + const admitted = await openRegularFile(path, { symlinks: "reject" }); + await admitted.handle.close(); + expect(recordedSleeps.length).toBeGreaterThan(0); + expect(recordedSleeps.every((delayMs) => delayMs === 25)).toBe(true); + }); +}); diff --git a/src/artifacts/pyinstaller/PyInstallerPyzStore.ts b/src/artifacts/pyinstaller/PyInstallerPyzStore.ts index 60ac6d65de..0ae63c443e 100644 --- a/src/artifacts/pyinstaller/PyInstallerPyzStore.ts +++ b/src/artifacts/pyinstaller/PyInstallerPyzStore.ts @@ -243,6 +243,9 @@ export class PyInstallerPyzStore { const source = await openRegularFile(path, { symlinks: "reject", signal, + // REA just wrote this private image. Settling would wait out a fresh + // Windows ChangeTime once per image; external archives still settle. + changeTime: "as-written", }); this.#retain(path, new OwnedFileHandle(source.handle)); return source; diff --git a/src/config/environment.ts b/src/config/environment.ts index d07e3d1bbc..a54073e5b2 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -34,6 +34,11 @@ import { captureDecoderOldGenerationMiBSchema } from "./captureDecoderHeap.js"; import { debuggerSessionLimitEnvironmentShape } from "./debuggerSessionLimits.js"; import { regexMatchingDeadlineMsSchema } from "./regexMatchingDeadline.js"; import { regexWorkerHeapRequestMiBSchema } from "./regexWorkerHeapRequest.js"; +import { + windowsChangeTimeDriftMsSchema, + windowsChangeTimeSettleMaxPollsSchema, + windowsChangeTimeSettlePollMsSchema, +} from "./windowsChangeTimeSettle.js"; import { mcpDoctorDeadlineMsSchema, mcpFirstCatalogBudgetMsSchema, @@ -56,6 +61,11 @@ const environmentSchema = z.object({ captureDecoderOldGenerationMiBSchema.optional(), REA_REGEX_MATCHING_DEADLINE_MS: regexMatchingDeadlineMsSchema.optional(), REA_REGEX_WORKER_HEAP_REQUEST_MIB: regexWorkerHeapRequestMiBSchema.optional(), + REA_WINDOWS_CHANGE_TIME_DRIFT_MS: windowsChangeTimeDriftMsSchema.optional(), + REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS: + windowsChangeTimeSettlePollMsSchema.optional(), + REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS: + windowsChangeTimeSettleMaxPollsSchema.optional(), REA_MCP_INITIALIZE_BUDGET_MS: mcpInitializeBudgetMsSchema.optional(), REA_MCP_TOOLS_LIST_BUDGET_MS: mcpToolsListBudgetMsSchema.optional(), REA_MCP_FIRST_CATALOG_BUDGET_MS: mcpFirstCatalogBudgetMsSchema.optional(), diff --git a/src/config/windowsChangeTimeSettle.test.ts b/src/config/windowsChangeTimeSettle.test.ts new file mode 100644 index 0000000000..b41e8326c0 --- /dev/null +++ b/src/config/windowsChangeTimeSettle.test.ts @@ -0,0 +1,132 @@ +import { expect, it } from "vitest"; + +import { ConfigurationError } from "../domain/configurationErrors.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { parseEnvironment } from "./environment.js"; +import { + DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_LIMITS, + WINDOWS_CHANGE_TIME_DRIFT_MS, + WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS, + WINDOWS_CHANGE_TIME_SETTLE_POLL_MS, + resolveWindowsChangeTimeSettle, + windowsChangeTimeSettleRecovery, + windowsChangeTimeSettingFailure, + windowsChangeTimeSettingMessage, +} from "./windowsChangeTimeSettle.js"; + +const requiredFacts = ( + message: string, + fact: { + readonly setting: string; + readonly currentValue: string; + readonly source: string; + readonly defaultValue: number; + readonly floor: number; + readonly maximum: number; + }, +): void => { + expect(message).toContain(fact.setting); + expect(message).toContain(fact.currentValue); + expect(message).toContain(`source ${fact.source}`); + expect(message).toContain(String(fact.defaultValue)); + expect(message).toContain(String(fact.floor)); + expect(message).toContain(String(fact.maximum)); + expect(message).toContain("restart REA"); +}; + +it("keeps today's settle defaults when the settings are unset", () => { + expect(resolveWindowsChangeTimeSettle({})).toEqual({ + ok: true, + value: DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_LIMITS, + }); + const startup = parseEnvironment({}); + if (!startup.ok) throw new Error("unset settle settings were rejected"); + expect(startup.value.REA_WINDOWS_CHANGE_TIME_DRIFT_MS).toBeUndefined(); + expect(startup.value.REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS).toBeUndefined(); + expect( + startup.value.REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS, + ).toBeUndefined(); + requiredFacts( + windowsChangeTimeSettleRecovery(DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_LIMITS), + { + setting: WINDOWS_CHANGE_TIME_DRIFT_MS.setting, + currentValue: "2000", + source: "default", + defaultValue: 2_000, + floor: 1, + maximum: 60_000, + }, + ); +}); + +it("uses valid settle overrides", () => { + expect( + resolveWindowsChangeTimeSettle({ + REA_WINDOWS_CHANGE_TIME_DRIFT_MS: "60", + REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS: "1000", + REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS: "1", + }), + ).toEqual({ + ok: true, + value: { + driftMs: 60, + driftSource: "setting", + pollMs: 1_000, + pollSource: "setting", + maxPolls: 1, + maxPollsSource: "setting", + }, + }); + const startup = parseEnvironment({ + REA_WINDOWS_CHANGE_TIME_DRIFT_MS: "60000", + REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS: "1", + REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS: "1000", + }); + if (!startup.ok) throw new Error("valid settle settings were rejected"); + expect(startup.value.REA_WINDOWS_CHANGE_TIME_DRIFT_MS).toBe(60_000); + expect(startup.value.REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS).toBe(1); + expect(startup.value.REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS).toBe(1_000); +}); + +it.each([ + [WINDOWS_CHANGE_TIME_DRIFT_MS, "0"], + [WINDOWS_CHANGE_TIME_DRIFT_MS, "60001"], + [WINDOWS_CHANGE_TIME_DRIFT_MS, "1e3"], + [WINDOWS_CHANGE_TIME_DRIFT_MS, "0x10"], + [WINDOWS_CHANGE_TIME_DRIFT_MS, ""], + [WINDOWS_CHANGE_TIME_DRIFT_MS, "02000"], + [WINDOWS_CHANGE_TIME_SETTLE_POLL_MS, "0"], + [WINDOWS_CHANGE_TIME_SETTLE_POLL_MS, "1001"], + [WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS, "0"], + [WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS, "1001"], +] as const)("rejects %s=%j", (bound, value) => { + const failure = windowsChangeTimeSettingFailure(bound, value); + expect(failure).toEqual({ + setting: bound.setting, + currentValue: value, + source: "setting", + defaultValue: bound.defaultValue, + floor: bound.floor, + maximum: bound.maximum, + unit: bound.unit, + restartRequired: true, + }); + const message = windowsChangeTimeSettingMessage(failure); + requiredFacts(message, failure); + const resolved = resolveWindowsChangeTimeSettle({ [bound.setting]: value }); + if (resolved.ok) throw new Error(`Accepted ${bound.setting}=${value}`); + expect(resolved.error).toBeInstanceOf(ConfigurationError); + expect(resolved.error.settings).toEqual([ + { setting: bound.setting, constraint: message }, + ]); + expect(projectAnalysisError(resolved.error)).toMatchObject({ + code: "configuration_invalid", + details: { settings: [{ setting: bound.setting, constraint: message }] }, + }); + const startup = parseEnvironment({ [bound.setting]: value }); + if (startup.ok) throw new Error(`Startup accepted ${bound.setting}=${value}`); + expect(startup.error.settings).toContainEqual({ + setting: bound.setting, + constraint: message, + }); +}); diff --git a/src/config/windowsChangeTimeSettle.ts b/src/config/windowsChangeTimeSettle.ts new file mode 100644 index 0000000000..c77d917a91 --- /dev/null +++ b/src/config/windowsChangeTimeSettle.ts @@ -0,0 +1,199 @@ +import { z } from "zod"; + +import { ConfigurationError } from "../domain/configurationErrors.js"; +import { err, ok, type Result } from "../domain/result.js"; + +const decimalInteger = /^[1-9]\d*$/u; + +interface WindowsChangeTimeBound { + readonly setting: string; + readonly defaultValue: number; + readonly floor: number; + readonly maximum: number; + readonly unit: "ms" | "polls"; +} + +/** + * How long a fresh Windows ChangeTime may still move after a write. + * Unset keeps the 2000 ms window this admission used before the setting existed. + */ +export const WINDOWS_CHANGE_TIME_DRIFT_MS = { + setting: "REA_WINDOWS_CHANGE_TIME_DRIFT_MS", + defaultValue: 2_000, + floor: 1, + maximum: 60_000, + unit: "ms", +} as const satisfies WindowsChangeTimeBound; + +/** Delay between ChangeTime observations. Unset keeps the 25 ms poll. */ +export const WINDOWS_CHANGE_TIME_SETTLE_POLL_MS = { + setting: "REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS", + defaultValue: 25, + floor: 1, + maximum: 1_000, + unit: "ms", +} as const satisfies WindowsChangeTimeBound; + +/** + * How many times admission re-reads a ChangeTime that is still inside the + * drift window. Unset keeps 40 polls, then returns the last observation. + */ +export const WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS = { + setting: "REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS", + defaultValue: 40, + floor: 1, + maximum: 1_000, + unit: "polls", +} as const satisfies WindowsChangeTimeBound; + +/** Rejected launch value for one Windows ChangeTime settle setting. */ +export interface WindowsChangeTimeSettingFailure { + readonly setting: string; + readonly currentValue: string; + readonly source: "setting"; + readonly defaultValue: number; + readonly floor: number; + readonly maximum: number; + readonly unit: "ms" | "polls"; + readonly restartRequired: true; +} + +/** Effective settle budget and whether each part came from the default or a setting. */ +export interface WindowsChangeTimeSettleLimits { + readonly driftMs: number; + readonly driftSource: "default" | "setting"; + readonly pollMs: number; + readonly pollSource: "default" | "setting"; + readonly maxPolls: number; + readonly maxPollsSource: "default" | "setting"; +} + +export const DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_LIMITS: WindowsChangeTimeSettleLimits = + { + driftMs: WINDOWS_CHANGE_TIME_DRIFT_MS.defaultValue, + driftSource: "default", + pollMs: WINDOWS_CHANGE_TIME_SETTLE_POLL_MS.defaultValue, + pollSource: "default", + maxPolls: WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS.defaultValue, + maxPollsSource: "default", + }; + +export const windowsChangeTimeSettingFailure = ( + bound: WindowsChangeTimeBound, + currentValue: string, +): WindowsChangeTimeSettingFailure => ({ + setting: bound.setting, + currentValue, + source: "setting", + defaultValue: bound.defaultValue, + floor: bound.floor, + maximum: bound.maximum, + unit: bound.unit, + restartRequired: true, +}); + +/** Name the rejected value, its source, the allowed range, and the required restart. */ +export const windowsChangeTimeSettingMessage = ( + failure: WindowsChangeTimeSettingFailure, +): string => + `The effective ${failure.setting} value is ${failure.currentValue} ${failure.unit} (source ${failure.source}; default ${String(failure.defaultValue)} ${failure.unit}, floor ${String(failure.floor)} ${failure.unit}, maximum ${String(failure.maximum)} ${failure.unit}). Set ${failure.setting} in the environment that starts REA to a positive decimal integer from ${String(failure.floor)} to ${String(failure.maximum)}, then restart REA.`; + +const inRange = (bound: WindowsChangeTimeBound, value: string): boolean => { + if (!decimalInteger.test(value)) return false; + const parsed = BigInt(value); + return parsed >= BigInt(bound.floor) && parsed <= BigInt(bound.maximum); +}; + +/** Decimal integer inside one settle setting's floor and maximum. */ +export const windowsChangeTimeSettingSchema = (bound: WindowsChangeTimeBound) => + z + .string() + .superRefine((value, context) => { + if (inRange(bound, value)) return; + context.addIssue({ + code: "custom", + message: windowsChangeTimeSettingMessage( + windowsChangeTimeSettingFailure(bound, value), + ), + }); + }) + .transform(Number); + +export const windowsChangeTimeDriftMsSchema = windowsChangeTimeSettingSchema( + WINDOWS_CHANGE_TIME_DRIFT_MS, +); +export const windowsChangeTimeSettlePollMsSchema = + windowsChangeTimeSettingSchema(WINDOWS_CHANGE_TIME_SETTLE_POLL_MS); +export const windowsChangeTimeSettleMaxPollsSchema = + windowsChangeTimeSettingSchema(WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS); + +const readBound = ( + bound: WindowsChangeTimeBound, + environment: Readonly>, + failures: WindowsChangeTimeSettingFailure[], +): number | undefined => { + const value = environment[bound.setting]; + if (value === undefined) return undefined; + const parsed = windowsChangeTimeSettingSchema(bound).safeParse(value); + if (!parsed.success) { + failures.push(windowsChangeTimeSettingFailure(bound, value)); + return undefined; + } + return parsed.data; +}; + +/** Resolve the settle budget from one environment. Invalid values are refused. */ +export const resolveWindowsChangeTimeSettle = ( + environment: Readonly>, +): Result => { + const failures: WindowsChangeTimeSettingFailure[] = []; + const driftMs = readBound( + WINDOWS_CHANGE_TIME_DRIFT_MS, + environment, + failures, + ); + const pollMs = readBound( + WINDOWS_CHANGE_TIME_SETTLE_POLL_MS, + environment, + failures, + ); + const maxPolls = readBound( + WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS, + environment, + failures, + ); + if (failures.length > 0) + return err( + new ConfigurationError( + failures.map(windowsChangeTimeSettingMessage).join(" "), + { + settings: failures.map((failure) => ({ + setting: failure.setting, + constraint: windowsChangeTimeSettingMessage(failure), + })), + }, + ), + ); + return ok({ + driftMs: driftMs ?? WINDOWS_CHANGE_TIME_DRIFT_MS.defaultValue, + driftSource: driftMs === undefined ? "default" : "setting", + pollMs: pollMs ?? WINDOWS_CHANGE_TIME_SETTLE_POLL_MS.defaultValue, + pollSource: pollMs === undefined ? "default" : "setting", + maxPolls: maxPolls ?? WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS.defaultValue, + maxPollsSource: maxPolls === undefined ? "default" : "setting", + }); +}; + +/** + * Describe the effective budget. Admission still returns the last observation + * when the poll budget runs out; these settings only change how long it waits. + */ +export const windowsChangeTimeSettleRecovery = ( + limits: WindowsChangeTimeSettleLimits, +): string => + [ + `The effective ${WINDOWS_CHANGE_TIME_DRIFT_MS.setting} value is ${String(limits.driftMs)} ms (source ${limits.driftSource}; default ${String(WINDOWS_CHANGE_TIME_DRIFT_MS.defaultValue)} ms, floor ${String(WINDOWS_CHANGE_TIME_DRIFT_MS.floor)} ms, maximum ${String(WINDOWS_CHANGE_TIME_DRIFT_MS.maximum)} ms).`, + `The effective ${WINDOWS_CHANGE_TIME_SETTLE_POLL_MS.setting} value is ${String(limits.pollMs)} ms (source ${limits.pollSource}; default ${String(WINDOWS_CHANGE_TIME_SETTLE_POLL_MS.defaultValue)} ms, floor ${String(WINDOWS_CHANGE_TIME_SETTLE_POLL_MS.floor)} ms, maximum ${String(WINDOWS_CHANGE_TIME_SETTLE_POLL_MS.maximum)} ms).`, + `The effective ${WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS.setting} value is ${String(limits.maxPolls)} polls (source ${limits.maxPollsSource}; default ${String(WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS.defaultValue)} polls, floor ${String(WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS.floor)} polls, maximum ${String(WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS.maximum)} polls).`, + "Set them in the environment that starts REA, then restart REA. They do not change Windows ChangeTime itself.", + ].join(" "); diff --git a/src/filesystem/ChangeTimeBaseline.ts b/src/filesystem/ChangeTimeBaseline.ts new file mode 100644 index 0000000000..4f4ac98dec --- /dev/null +++ b/src/filesystem/ChangeTimeBaseline.ts @@ -0,0 +1,119 @@ +import type { Stats } from "node:fs"; + +import { + resolveWindowsChangeTimeSettle, + type WindowsChangeTimeSettleLimits, +} from "../config/windowsChangeTimeSettle.js"; + +/** + * Windows advances a file's ChangeTime shortly after it is written, without any + * content change: security components attach catalog and file-hash extended + * attributes asynchronously. A baseline captured while that is still in flight + * disagrees with a later observation and reports an untouched file as changed. + * + * The drift window is only an upper bound on when a write's ChangeTime has + * finished moving. `REA_WINDOWS_CHANGE_TIME_DRIFT_MS`, + * `REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS`, and + * `REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS` keep today's 2000 ms, 25 ms, and + * 40-poll defaults. That keeps `ctime` at full strength as a rewrite signal + * instead of dropping it, which Windows needs because restoring the + * modification time is possible and ordinary tools cannot restore the + * ChangeTime. + */ + +export interface ChangeTimeSettleSeams { + readonly platform?: NodeJS.Platform | undefined; + readonly now?: (() => number) | undefined; + readonly sleep?: ((delayMs: number) => Promise) | undefined; + /** Launch environment. Omission reads `process.env`. */ + readonly environment?: + | Readonly> + | undefined; + /** Already resolved budget. Omission parses `environment`. */ + readonly limits?: WindowsChangeTimeSettleLimits | undefined; + /** Cancels the wait; the last observation is not returned once aborted. */ + readonly signal?: AbortSignal | undefined; + /** + * Builds the error a cancellation throws. Callers that recognise their own + * typed cancellation must supply it: throwing the raw `signal.reason` reads + * as a read failure to everyone matching on their own error class. + */ + readonly cancellationError?: ((reason: unknown) => Error) | undefined; +} + +const limitsFrom = ( + seams: ChangeTimeSettleSeams, +): WindowsChangeTimeSettleLimits => { + if (seams.limits !== undefined) return seams.limits; + const resolved = resolveWindowsChangeTimeSettle( + seams.environment ?? process.env, + ); + if (!resolved.ok) throw resolved.error; + return resolved.value; +}; + +/** Stop settling as soon as the owning operation is cancelled. */ +const throwIfAborted = (seams: ChangeTimeSettleSeams): void => { + if (seams.signal?.aborted !== true) return; + const reason = seams.signal.reason; + throw seams.cancellationError ? seams.cancellationError(reason) : reason; +}; + +const defaultSleep = (delayMs: number): Promise => + new Promise((resolve) => setTimeout(resolve, delayMs)); + +const changeTimeMs = (stats: Stats): number => + typeof stats.ctimeMs === "number" ? stats.ctimeMs : Number.NaN; + +/** + * Whether an observed ChangeTime can still move on its own, so recording it now + * would create a baseline a later observation is entitled to disagree with. + */ +export const changeTimeMayStillDrift = ( + stats: Stats, + seams: ChangeTimeSettleSeams = {}, +): boolean => { + const platform = seams.platform ?? process.platform; + if (platform !== "win32") return false; + const changeTime = changeTimeMs(stats); + // An unreadable ChangeTime cannot establish that it has settled; re-observation + // is the only way to obtain a comparable value. + if (!Number.isFinite(changeTime)) return true; + const now = seams.now?.() ?? Date.now(); + return now - changeTime < limitsFrom(seams).driftMs; +}; + +/** + * Observe until the ChangeTime itself has aged out of the drift window, then + * return it as the baseline. Waiting for two consecutive reads to agree is not + * enough: two observations taken before Windows performs a delayed update agree + * with each other and disagree with every later one. + * + * Only a ChangeTime still inside the window is re-observed, so an established + * file costs exactly one read. + * + * Returns the last observation if the wait budget runs out: the caller's + * comparisons still see that value, and refusing to admit a readable file would + * trade a false "changed" report for a failure to inspect it. + */ +export const settleChangeTimeBaseline = async ( + read: () => Promise, + seams: ChangeTimeSettleSeams = {}, +): Promise => { + const limits = limitsFrom(seams); + const settled = { ...seams, limits }; + const sleep = seams.sleep ?? defaultSleep; + throwIfAborted(seams); + let observed = await read(); + for (let poll = 0; poll < limits.maxPolls; poll += 1) { + if (!changeTimeMayStillDrift(observed, settled)) return observed; + // A settle window is bounded by driftMs, not by the poll budget, so a + // cancelled scope must not keep an inventory waiting it out. + throwIfAborted(seams); + await sleep(limits.pollMs); + throwIfAborted(seams); + observed = await read(); + } + throwIfAborted(seams); + return observed; +}; diff --git a/src/filesystem/RegularFile.ts b/src/filesystem/RegularFile.ts index 35e7ff7566..a66fb7f91e 100644 --- a/src/filesystem/RegularFile.ts +++ b/src/filesystem/RegularFile.ts @@ -3,6 +3,7 @@ import { constants as osConstants } from "node:os"; import type { BigIntStats, Stats } from "node:fs"; import { lstat, open, stat, type FileHandle } from "node:fs/promises"; +import { settleChangeTimeBaseline } from "./ChangeTimeBaseline.js"; import { OwnedFileHandle } from "./OwnedFileHandle.js"; /** Cancellation observed at a file operation's explicit checkpoint. */ @@ -99,12 +100,20 @@ export interface StableRegularFileDescriptor { readonly initial: Stats; } +/** + * Whether admission waits out a fresh Windows ChangeTime. + * External files settle. A private file REA just wrote is recorded as written. + */ +export type ChangeTimeAdmission = "settle" | "as-written"; + /** Admit a non-blocking regular-file descriptor with its validated initial facts. */ export const openRegularFile = async ( path: string, options: { readonly symlinks: "follow" | "reject"; readonly signal?: AbortSignal | undefined; + /** Omission settles. Private files REA just wrote pass "as-written". */ + readonly changeTime?: ChangeTimeAdmission | undefined; }, ): Promise => { assertRegularFileActive(options.signal); @@ -126,9 +135,28 @@ export const openRegularFile = async ( const owner = new OwnedFileHandle(handle); try { assertRegularFileActive(options.signal); - const metadata = await handle.stat(); - if (!metadata.isFile()) - throw new NonRegularFileReadError(path, metadata.isDirectory()); + // The admitted facts become the baseline every later comparison tests + // against. External files wait out a Windows ChangeTime that can still + // move. A private file REA just wrote is recorded as written: settling + // each one costs about a second, and a compressed PYZ spool does that + // once per image. The first observation still validates the file type, + // so a directory is refused before any settling delay. + const readMetadata = async (): Promise => { + assertRegularFileActive(options.signal); + const observed = await handle.stat(); + if (!observed.isFile()) + throw new NonRegularFileReadError(path, observed.isDirectory()); + return observed; + }; + const metadata = + options.changeTime === "as-written" + ? await readMetadata() + : await settleChangeTimeBaseline(readMetadata, { + signal: options.signal, + // Callers match on this type to tell cancellation from a read fault. + cancellationError: (reason) => + new RegularFileOperationCancelled(reason), + }); assertRegularFileActive(options.signal); // O_NOFOLLOW is unavailable on Windows. Recheck the name only on hosts // where open could have followed a symlink, while the descriptor is owned. diff --git a/src/setupEnvironment.test.ts b/src/setupEnvironment.test.ts index 1a9efce788..cb82c433d0 100644 --- a/src/setupEnvironment.test.ts +++ b/src/setupEnvironment.test.ts @@ -1,4 +1,8 @@ -import { currentClientRegistrationCommand } from "./application/ClientRegistrationIdentity.js"; +import { + currentClientRegistrationCommand, + invokingPackageRunner, + withoutPackageRunnerIdentity, +} from "./application/ClientRegistrationIdentity.js"; import { UpdateCommandRunner } from "./application/UpdateCommandRunner.js"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -15,10 +19,6 @@ import { systemSetupHost } from "./application/SetupHost.js"; import { systemUninstallHost } from "./application/Uninstall.js"; import { createSystemDoctorHost } from "./doctorRuntime.js"; import { PRODUCT_IDENTITY } from "./identity.js"; -import { - invokingPackageRunner, - withoutPackageRunnerIdentity, -} from "./application/ClientRegistrationIdentity.js"; import { snapshotEnvironment } from "./process/snapshotEnvironment.js"; afterEach(() => vi.unstubAllEnvs()); @@ -203,6 +203,9 @@ it.each([ REA_MCP_TOOLS_LIST_BUDGET_MS: "6000", REA_MCP_FIRST_CATALOG_BUDGET_MS: "21000", REA_MCP_DOCTOR_DEADLINE_MS: "31000", + REA_WINDOWS_CHANGE_TIME_DRIFT_MS: "4000", + REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS: "50", + REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS: "80", }, providerConfigured: false, installHopper: true, @@ -244,6 +247,9 @@ it.each([ REA_MCP_TOOLS_LIST_BUDGET_MS: "6000", REA_MCP_FIRST_CATALOG_BUDGET_MS: "21000", REA_MCP_DOCTOR_DEADLINE_MS: "31000", + REA_WINDOWS_CHANGE_TIME_DRIFT_MS: "4000", + REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS: "50", + REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS: "80", }); } else { expect(initial.providerEnvironment).not.toHaveProperty( diff --git a/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts b/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts index 5e0afcf89b..6c0d5cdc4c 100644 --- a/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts +++ b/tests/boundary/filesystem/clientConfigurationEnvironment.test.ts @@ -863,6 +863,21 @@ it.each([ { REA_GHIDRA_COMPILER_SPEC_ID: "borlandcpp" }, "REA_GHIDRA_COMPILER_SPEC_ID", ], + [ + "Windows ChangeTime drift window of zero", + { REA_WINDOWS_CHANGE_TIME_DRIFT_MS: "0" }, + "REA_WINDOWS_CHANGE_TIME_DRIFT_MS", + ], + [ + "Windows ChangeTime poll that is not decimal", + { REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS: "1e2" }, + "REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS", + ], + [ + "Windows ChangeTime poll budget above its maximum", + { REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS: "1001" }, + "REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS", + ], ] as const)( "refuses %s before changing the configuration", async (_name, env, setting) => { From c363f3e27b485d3f6330c3634d5d9c8f80d98d72 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 5 Nov 2026 18:01:25 -0800 Subject: [PATCH 003/151] feat(desktop): pick seed and end by checkpoint, and keep the capture host out of bundles (#9037) * feat(desktop): capture a Linux desktop application in use Add `capture-desktop-application`, a CLI-only, Linux and X11 capture that launches an installed desktop application, watches it while it is used and writes capture Evidence plus SHA-256 addressed content: - inventory: desktop entry, launch command, executable, package and its apt sources (or snap, flatpak, AppImage), Electron markers and candidate state directories; - session: the launched process tree, client windows from xprop with the first application window time and a window match, and files held open; - state: a stat walk of the home directory before launch, at checkpoints read from stdin, at stop and after exit, with created, modified, deleted and retyped paths and the content of changed files and state directories. It refuses non-Linux hosts, missing DISPLAY or xprop, a non-empty output folder and an application whose window is already open, and signals only processes it started. Co-Authored-By: Claude Opus 5.5 * fix(desktop): own launched processes, confirm walk coverage and keep cancelled captures - Launch through the owned provider path with a run token; release with SIGTERM, then the token-verified group cleanup, after every session. Processes without the token are never signalled; each records whether it carries it, and the session records whether cleanup was verified. - Report creations and deletions only where a walk listed the nearest existing directory; count the rest as unconfirmed. - Retain a file only when it is still the walked version, through owned no-follow reads; charge the budget only for new content; create files exclusively in a private output folder owned by the caller. - Walk content roots outside the home directory, compare paths in their physical spelling, and run executables from the caller's directory. - Refuse when the window list cannot be read, re-evaluate window ownership each sample, handle one queued request per sample with a bounded stdin queue, and write the capture with end reason `cancelled` on interruption. Co-Authored-By: Claude Opus 5.5 * fix(desktop): keep owned cleanup failures and fresh digests - Allow a capture cancelled before any requested checkpoint to hold only the baseline. - Retry an incomplete stop once; if cleanup is still unverified, write the capture and fail with the run token and process group as cleanup resources. - Classify path changes after a walk (ENOTDIR and other selected-file errors) as changed_since_walk instead of failing the capture. - Treat a non-directory ancestor as proof that nothing existed beneath it. - Report a before digest only for the file version it was read from. - Derive stderr_truncated from stderr's own byte counts. Co-Authored-By: Claude Opus 5.5 * wip(desktop): state view over a capture folder Co-Authored-By: Claude Opus 5.5 * fix(desktop): keep failed file-handle closes owned for retry Content reads pass retainFailedClose, so a handle whose close fails stays owned by the content store; the capture retries those closes after it is written and fails with the output folder as the cleanup resource if one still cannot be closed. Co-Authored-By: Claude Opus 5.5 * feat(desktop): read a captured application's state Add inspect-desktop-application-state, a CLI command that reads a capture folder into the application's state: every retained or changed file with its role and reasons, a typed JSON view of each version (SQLite through the shared worker, dconf through a private dconf dump, JSON, XML, key files, text) and the kinds of change between versions (lines, values, keys, rows). Co-Authored-By: Claude Opus 5.5 * fix(desktop): report process and handle cleanup failures together When both the launched processes and a retained file's handle remain unresolved after the capture is written, one cleanup error now lists both reasons and both resource sets. Co-Authored-By: Claude Opus 5.5 * feat(desktop): export a captured application as an agentenv computer app Add export-desktop-application, which writes an agentenv bundle with one computer_app environment from a capture folder and its state view: the apt package, a boot launch matched on the captured window class with a window ready check, owned paths from the state roles, a seed of the baseline files, data hooks that call agentenv's add/get/reset helpers with a typed JSON view on get, a skill, a README and a deploy smoke task. The desktop CLI commands now load their services lazily. Co-Authored-By: Claude Opus 5.5 * fix(desktop): keep state views faithful to the capture - Read key files, XML attributes, dconf groups and SQLite tables into dictionaries without inherited properties, so captured keys such as __proto__ stay data. - Version databases with their write-ahead log as of each checkpoint, and stage only digest-verified bytes for the SQLite and dconf readers. - Detect the format per version, report format changes, keep JSON null and empty containers as values, keep XML text and preserved whitespace, and mark truncated views (including XML depth) and comparisons of them. - Number versions and changes by checkpoint index, decide baseline presence from the captured type, and keep tagged SQLite integers, text bytes and blobs distinct. - Carry the capture's limitations and omitted content forward, match state directories by their home path, index changes once per capture, keep failed handle closes owned, and finish the CLI through the shared owner. Co-Authored-By: Claude Opus 5.5 * fix(desktop): keep coverage, logs and handles honest in the state view - Read coverage flags only from formats whose readers write them, so JSON fields named truncated stay data. - Fail a SQLite version whose write-ahead log existed but was not retained or does not match its digest, instead of reading the database alone. - Close retained content on every exit, including cancellation, and report a close that still fails together with the primary failure. - Finish the CLI with the canonical operation name. Co-Authored-By: Claude Opus 5.5 * fix(desktop): export only what the computer can install and seed - Refuse packages not from the Ubuntu 24.04 archive, since the computer installs by name from that archive. - Start the launch command in the captured working directory. - Respell the home path only in files the state view read as text. - Report files that were not retained instead of dropping them silently. - Load captured dconf keys for apps that own no files. - Keep home-level files as owned paths; rename reserved Python class names. - Mark truncated SQLite and text views in the class's get view, and read file types the way the state view does. - Release the output tree through a resource scope, and read seeds through an owner closed on every exit. Co-Authored-By: Claude Opus 5.5 * fix(desktop): keep staged folders owned, symlinked state roots and XML values - Stage database bytes in a private runtime root released through a resource scope, so a failed removal keeps its owner and is reported with the read's outcome. - Report cleanup facts and owners from desktop failures instead of wrapping them as a plain adapter error. - Record the state directories as walked, in their physical spelling, and match roles against both spellings, so files in a symlinked state directory are not mistaken for selected documents. - Keep whitespace-only XML leaf values; drop only formatting between elements. Co-Authored-By: Claude Opus 5.5 * fix(desktop): read XML in the exported app the way the state view does Keep whitespace-only leaf values and xml:space="preserve" text in the generated get view; drop only formatting between elements. Co-Authored-By: Claude Opus 5.5 * fix(desktop): release retained handles with their owners, read older captures - Release retained-content handles whose close failed through a resource scope on every exit; a release that fails again reports the exact handle owners. A read that finished keeps its bytes when only its close failed. - Accept captures written before state directories were recorded, and say in the state view's limitations that symlinked state directories may be misclassed for them. - Compute each XML element's whitespace context once instead of scanning siblings for every text run. Co-Authored-By: Claude Opus 5.5 * fix(desktop): boot without the working folder, bound XML, accept mirrors - Try the captured working directory only when an argument is relative, and start the app even when that folder is missing. - Cut the generated XML view at REA's node and depth limits, and keep one unreadable file from failing data/get. - Accept Ubuntu 24.04 mirrors, not only the canonical hosts; PPAs and other repositories are still refused. - Read seeds through the retained-content owner's new signature. Co-Authored-By: Claude Opus 5.5 * fix(desktop): state view review round - Stage a retained -journal beside its database so the SQLite worker accepts an inactive journal and refuses a hot one; a -wal or -journal that existed but was not retained (including one omitted at the baseline) makes the version unreadable. - Keep tables that read when another fails, and let cancellation and unfinished cleanup propagate instead of becoming view errors. - Read text as a key file only when every line parses as one. - Rank a file named in a window title or selected by the caller as a document even when the application holds it open; match titles on file-name boundaries; leave the display name out of dconf matching. - Bound JSON and key file views (4 MiB, 500 levels, exact numbers) and report too-large, missing and altered retained content separately. - Add no version or change while a database stays absent. - Read staged databases in place instead of copying them again. - Give Evidence the same limitations as the result. Co-Authored-By: Claude Opus 5.5 * fix(desktop): export review round - Load captured dconf keys without D-Bus: install dconf-cli, compile the existing and captured key files into the user database, and warn on failure instead of stopping the server. - Start relative-argument launches in the captured working directory, the home included, since the framework launches from the app folder; respell the home itself and option values under it. - Read views in a thread, skip files over 64 MiB, align the generated readers with the state view's, and close SQLite connections. - Never own the home or a shared base folder; map a symlinked state directory's files back to their home spelling. - Report omitted files and unretained opened folders. - Name the module so it cannot shadow another, and keep the class off the module's own names; derive names the bundle check accepts. - Write seeds as they are read, and remove an unpublished bundle on failure. Co-Authored-By: Claude Opus 5.5 * fix(desktop): capture review round - Ask the launch process group and token carriers to exit before killing them, on errors too; report incomplete cleanup with its facts. - Count descendants of the launch as started by the capture even when their environment cannot be read; treat a reused pid as a new process. - Follow symlinked desktop entries; name a flatpak's state after its application id; match flatpak windows by name only. - Stop on end of input; refuse empty, duplicate and reserved checkpoint names; require whitespace after the checkpoint keyword. - Stay on each walk root's device, and never walk or retain `/` or a directory containing the home; say so in the limitations. - Refuse a window manager that does not publish _NET_CLIENT_LIST. - Remove retained content when a capture fails before it is written. - Skip reading a file larger than the remaining content budget. - Record apt's archive origin for each package source. Co-Authored-By: Claude Opus 5.5 * fix(desktop): read an unset client list as no windows when supported xfwm4 leaves _NET_CLIENT_LIST unset while it manages no windows, so the capture refused an ordinary desktop with nothing open. Read _NET_SUPPORTED with the list: an unset list means no windows when the window manager supports it, and the capture is refused only when it does not. `xprop`'s "no such atom" output now reads as unset too. Co-Authored-By: Claude Opus 5.5 * fix(desktop): accept packages by apt's archive origin Accept a package source when apt reports its origin as Ubuntu for a noble suite, so mirrors count and PPAs or other repositories that merely have "ubuntu" in their URL do not. Captures made before origins were recorded fall back to Canonical's own archive hosts. Seed reads report why retained content could not be used. Co-Authored-By: Claude Opus 5.5 * feat(desktop): capture and export apps from vendor apt repositories A closed-source app installed from its vendor's apt repository, such as Typora, can now be captured and exported as a computer app. - The capture records each non-Ubuntu repository listing the installed version (uri, suite, components from apt's sources files, one-line or deb822) and the key that signs it: the signed-by key, or the trusted key that gpgv shows verifies the repository's signed index. The key is retained with the capture. - The capture records the packages owning the libraries the executable loads (DT_NEEDED with RUNPATH and the linker cache, through REA's ELF readers), since a vendor package may not depend on them. - The export declares a verified vendor repository as an AptSource with its key, installs a vendor package's library packages, and adds --no-sandbox to an Electron app's launch, since a container denies Chromium's sandbox. Co-Authored-By: Claude Opus 5.5 * fix(desktop): report a locked database at once in data/get A database the running app holds locked made each data/get wait sqlite's five-second busy timeout before reporting it. Record the Typora run in an agentenv computer in the verification. Co-Authored-By: Claude Opus 5.5 * perf(desktop): detect key files without splitting the whole text Read lines one at a time and stop at the first that a key file view would drop, instead of splitting a file of up to 64 MiB into arrays before its format is known. Co-Authored-By: Claude Opus 5.5 * fix(desktop): keep symlinked homes and state folders under the home in exports - Respell a path under its state folder's logical spelling only when that spelling is under the captured home, so a symlinked HOME keeps its files; pair walked and logical state folders only when the capture lists the same number, and match the innermost folder first. - When two files map to one home path, keep the one present before the capture and name the other in the limitations. - Read key files in the generated data/get with the state view's rule, checked against it by running the helper under python3. Co-Authored-By: Claude Opus 5.5 * fix(desktop): end captures on zombies and keep cleanup owners - A zombie or dead process no longer keeps the session open. - Remove only the partial capture file this run created. - Keep the exact owners of handles and processes whose cleanup failed on the returned error, so a retry can release them; test the published capture's cleanup-failure branch with mixed outcomes and retries. - Release the application once even when the exit checkpoint fails. - A cancellation before the capture is written removes what it retained. Co-Authored-By: Claude Opus 5.5 * fix(desktop): vendor package review round - Match apt sources through `apt-get indextargets`, which covers flat `./` and `/` suites and gives each release file and sources entry. - Accept a key whose signature verifies even when the release carries another signature without a key here; keep only the verifying key from a keyring holding several (OpenPGP packet splitter). - Install the package from its repository's release (`name/`), declare one source per URI and suite, support only the capture host's architecture, and refuse Ubuntu Pro repositories. - List only library packages the computer can install; walk libraries only for vendor installs, with RPATH inherited as ld.so does, normalized paths, and DT_NEEDED bounded while reading. - Put --no-sandbox after the program an env wrapper launches; keep the gpgv folder's owner when its removal fails; respect --max-file-bytes for keys; wait 0.25 s on a busy SQLite database in data/get. Co-Authored-By: Claude Opus 5.5 * docs(desktop): keep the vendor spec's gpgv command on one line A code span broken across lines left `` for VitePress to read as an unclosed HTML tag, so the docs build failed. Co-Authored-By: Claude Opus 5.5 * feat(desktop): add a validated task to exported desktop applications Each capture can now yield one agentenv task in its exported bundle. An agent writes the task (a prompt plus declarative checks over the app's data/get views); REA validates it and stays model-free. - `describe-desktop-application-task` summarises a capture's net change per file (role, format, views before and after) and drafts checks. - Checks: file presence, lines present/absent/counted, JSON-pointer values, SQLite rows and counts. REA keeps a task only if a check fails on the seed and every check passes on the captured end state; a file that was not retained at the baseline counts as absent, as the computer starts. - `export-desktop-application --task` writes tasks/NAME.json (deploy, agent, skills, recording, prompt, verifier), a compiled verify.py that reads data/get, and an eval. A boundary test runs the verifier under python3 and checks it agrees with REA's evaluator on every check kind. - Docs, spec and skill guidance, with the solve-rate recipe. Co-Authored-By: Claude Opus 5.5 * test(desktop): keep the seed test in its own block Co-Authored-By: Claude Opus 5.5 * docs(desktop): record the task verifier driven in an agentenv computer Co-Authored-By: Claude Opus 5.5 * docs(desktop): record the Typora task run in an agentenv computer Co-Authored-By: Claude Opus 5.5 * fix(desktop): keep task checks bounded, complete and attributable - line_count counts lines holding a substring (contains) instead of matching a regular expression, which could stall the verifier on agent-written text and read differently in Python. - value_equals of a whole object or array is undecided on a truncated view. - The draft keeps a database whose rows changed under an unchanged hash, and a file whose truncated views match while it changed. - A task file that cannot be read keeps the reader's detail, and a failed cleanup keeps the read failure as its primary. Co-Authored-By: Claude Opus 5.5 * fix(desktop): leave a file with one version out of the task draft Co-Authored-By: Claude Opus 5.5 * docs(desktop): update the task verification's test counts Co-Authored-By: Claude Opus 5.5 * docs(desktop): record the task solve rate with Claude Code and Sonnet 5.5 Co-Authored-By: Claude Opus 5.5 * feat(desktop): capture and export apps installed from a downloaded .deb capture-desktop-application --package-file reads the file's control fields with dpkg-deb and records its SHA-256, size and dependencies when it holds the installed package; the file itself is never kept. The library walk now also runs for local installs. export-desktop-application --package-url declares agentenv's Download(url, sha256) for that file, its dependencies and library packages as apt packages, and a run script that installs it with dpkg. Co-Authored-By: Claude Opus 5.5 * fix(desktop): seed app-named dotfiles and respell UTF-16 seeds The capture now treats dotfiles in the home named after the application (.varicad-cfg.ini, .notesrc) as state roots, so a file the app only reads during the capture is still seeded. The exported seeding respells the captured home in UTF-16 text files with a byte-order mark, keeping the mark. Co-Authored-By: Claude Opus 5.5 * fix(desktop): hold readiness until the app's own window shows An exported app is ready only once a window of its class that the taskbar lists is shown, so a splash screen of the class, which skips the taskbar, no longer counts as the app being up. Co-Authored-By: Claude Opus 5.5 * docs(desktop): record VariCAD captured, exported and run in an agentenv computer Co-Authored-By: Claude Opus 5.5 * fix(desktop): keep exported views strict JSON for infinite and inexact numbers The exported data/get spells infinite and negative-zero SQLite reals as REA's SQLite reader does ("Infinity", "-Infinity", "-0"), and refuses a JSON file holding a number that reading would change (1e999, integers past 2^53, -0, rounded fractions) with the state view's own error, instead of a view with an infinity in it. Co-Authored-By: Claude Opus 5.5 * fix(desktop): keep package-file facts tied to one file and URLs unechoed - A --package-url refusal never repeats the URL, and credentials are checked before the scheme. - The package file's identity must hold between dpkg-deb's read and the hash. - A failed close keeps its cleanup owner even when the capture is cancelled. - Readiness also ignores windows typed as splash screens. Co-Authored-By: Claude Opus 5.5 * fix(desktop): record database logs and keep state changes distinct - A selected path that is not a directory brings its -wal and -journal as content roots, so the capture records whether they exist and retains them. - The state view treats a file as absent only under recorded content roots and refuses a database whose log presence is unknown. - JSON changes list containers too, so an array that becomes an object with the same members is a change. - Key file entries escape the key as a JSON pointer segment, so [a/b] c and [a] b/c stay apart. Co-Authored-By: Claude Opus 5.5 * fix(desktop): keep watching through unread samples and late workers - An unreadable window list is unknown, not zero windows, and is named in the limitations. - Before ending the session, live run-token carriers no parent link reached are tracked; an unreadable process table leaves the exit unknown. - An env prefix's assignments find the program and name its XDG folders. - flatpak run options that take a value are skipped with it, -- is honoured and a /arch/branch suffix is dropped from the id. - State directories include the entry's full id. - Path options refuse empty values; roots count toward the entry limit. - A capture file whose close failed stays owned and is retried on discard. Co-Authored-By: Claude Opus 5.5 * fix(desktop): bound apt sources, keep older captures and cleanup owners - Sources files parse into one stanza per line or deb822 paragraph with its URIs and suites kept as lists, so no URI x suite product is built. - Repository architectures and release are optional, so captures written before they were recorded parse; the export treats them as unrestricted and unknown. - Library sources are matched against every index of a declared repository's URI, suite and components. - Two failures side by side keep both causes and both cleanup owners, and a host file whose close fails reports a lease on its handle. Co-Authored-By: Claude Opus 5.5 * fix(desktop): match escaped key file entries and suggest containers through members - The draft's key file lookup uses the state view's escaped `group/key`, so a key holding `/` or `~` still gets its suggestion. - JSON and XML changes now list containers too; the draft suggests an added or removed container through its listed members, and a container only when none of its members is listed. Co-Authored-By: Claude Opus 5.5 * fix(desktop): read relative PATH entries from the launch folder A program, or an empty or relative PATH entry, is resolved from the folder the program starts in, as the launch does, so `env PATH=. app` with the entry's Path still finds the app. Co-Authored-By: Claude Opus 5.5 * perf(desktop): compare JSON views on one shared path JSON and XML changes are found by walking both trees with one shared pointer path, building a pointer only for a listed value, so memory follows depth rather than every container's full path. A pointer over 4096 characters is counted but not listed. A 256-level document with 4000-character keys went from about 251 MiB of heap to under 1 MiB. Co-Authored-By: Claude Opus 5.5 * fix(desktop): keep JSON Lines as text in the exported reader The exported reader parses the whole text before an inexact number makes it JSON with an error, so JSON Lines with an integer above 2^53 stay text as in REA. Over-long integer spellings are unsafe without converting them. The guide no longer claims large integers keep their value. Co-Authored-By: Claude Opus 5.5 * fix(desktop): keep the state-path help free of dash-led words The CLI alias check reads `-wal` and `-journal` in help text as short flags, so the help names the write-ahead log and rollback journal instead. Co-Authored-By: Claude Opus 5.5 * fix(desktop): never record or export apt source credentials - Source URIs lose any user name and password as they are parsed, the way apt splits them, and so do index-target URIs and descriptions and the sources apt-cache policy lists, so matching works whether or not apt prints them. - A repository whose entry held credentials is marked `uri_credentials_removed`, and the export refuses it, or an older capture whose URI still holds them, with a reason that names no URL. Co-Authored-By: Claude Opus 5.5 * fix(desktop): look programs up without normalizing their paths Program paths and PATH entries are joined rather than resolved, so `..` after a symbolic link means what it means to the launch. The lookup moves to its own module. Co-Authored-By: Claude Opus 5.5 * fix(desktop): note inexact JSON numbers with a flag, not a list The exported reader only needs to know whether any number was inexact, so it keeps a boolean instead of every rejected spelling. Co-Authored-By: Claude Opus 5.5 * fix(desktop): refuse older captures that still hold apt credentials A capture written before credentials were removed counted the credentialed repository's libraries as installable, so the export refuses it whole and asks for a new capture rather than declaring packages no source offers. Recorded policy sources are redacted in export messages too. Co-Authored-By: Claude Opus 5.5 * test(desktop): keep the credential fixture on an example host A login on a real host reads as a secret to the commit scanner, so the older capture's fixture uses apt.example.com like the other credential tests. Co-Authored-By: Claude Opus 5.5 * fix(desktop): tidy looked-up paths and search relative entries only for env Empty and `.` segments are dropped from a looked-up program path, which keeps `/snap/bin/` entries recognisable as snaps, while `..` is still left for the kernel. Relative and empty PATH entries are searched only in the PATH an `env` prefix sets, which travels with the exported launch; the host's own PATH does not. Co-Authored-By: Claude Opus 5.5 * fix(desktop): keep excluded and unwalked database logs unknown The capture records the paths every walk left out, and the state view treats a log as absent only under content roots the baseline walked completely and outside those exclusions, so an excluded or unvisited log leaves the database unread rather than read without it. Co-Authored-By: Claude Opus 5.5 * fix(desktop): judge JSON numbers after duplicate keys settle The exported reader marks an inexact number instead of flagging it during the parse, then looks for marks in the parsed value, so a number a later duplicate key overwrote no longer refuses the file, as in REA. Co-Authored-By: Claude Opus 5.5 * fix(desktop): redact a repository's policy source when it names it An older capture whose policy source matched no sources entry kept that text whole, and the export named the repository by it; the export now names it without any user name or password. Co-Authored-By: Claude Opus 5.5 * fix(desktop): never signal the launch group on unknown membership When the current read cannot observe a carrier's process group, the carrier is signalled on its own instead of letting an older process-table row grant a group-wide SIGTERM. Co-Authored-By: Claude Opus 5.5 * fix(desktop): look programs up in the launch's order and refuse host-relative finds The lookup searches PATH as the launch does, an empty entry meaning the launch folder, so inspection and launch name the same program. A program found only through a relative or empty entry of the host's own PATH is refused, since the exported computer would not have it; an env prefix's PATH travels with the command. Co-Authored-By: Claude Opus 5.5 * fix(desktop): count unchecked paths and keep logs behind them unknown Each walk counts the paths it could not stat and the directories on another device it did not enter, and the checkpoint records it as `unchecked_paths`. The state view treats a log as absent only when the baseline left nothing unchecked, so a log whose stat failed stays unknown. Co-Authored-By: Claude Opus 5.5 * perf(desktop): scan parsed JSON with one iterator per level The check for marked numbers walks the parsed value with an iterator per nesting level instead of copying each container's children; a million-entry array went from about 15.7 MiB to 8.1 MiB of peak traced memory. Co-Authored-By: Claude Opus 5.5 * fix(desktop): start a relative program from its captured folder An exported launch whose program path is relative, such as `./app` from an entry's Path, now changes to the captured working directory first, as it already did for relative arguments. Co-Authored-By: Claude Opus 5.5 * feat(desktop): read zipped documents and name XML elements in task checks - A file that starts as a zip archive gets a `zip` view listing its entries in archive order, each with its own format and view, within limits on entries, bytes and expansion; the exported Python reader matches it. - A pointer segment applied to an array may be a selector, `[KEY=VALUE]` or `[@KEY=VALUE]` for XML attributes, matching exactly one member, so checks name elements instead of positions that shift between saves and versions. - `value_equals` takes an optional numeric `tolerance`, comparing numbers and numeric strings. - Changes inside zip views are listed by selector, and the task draft suggests name-addressed checks. FreeCAD's `.FCStd` is the first case: a hole cut into a plate is verified from the zipped `Document.xml` on files from both FreeCAD 0.21 and 1.0. Co-Authored-By: Claude Opus 5.5 * fix(desktop): record the desktop entry's package and app profiles in any case - A launch from a desktop entry records the package that owns the entry, so the export installs the app the entry starts (Writer, not only libreoffice-common); the program's own package is kept as executable_package when it differs. - State folders match the app's names ignoring letter case, as direct children of the XDG folders and the home, so GIMP's ~/.config/GIMP profile is seeded and reset; roles follow the same rule. Co-Authored-By: Claude Opus 5.5 * feat(desktop): read namespaced XML alike in REA and the exported verifier The exported Python reader now names XML elements and attributes as the document spells them (svg, text:p, sodipodi:docname, xml:space), with namespace declarations kept as attributes, which is what REA's view already produced. Export therefore accepts checks into Inkscape SVGs, LibreOffice content.xml and namespaced XML inside zips, and the draft suggests them. expat replaces ElementTree: one pass with namespace processing refuses what REA's parser refuses, and a streaming pass builds the view within the same node and depth limits. Text splits at CDATA, comments and processing instructions as REA's DOM splits it. Fixtures are real Inkscape 1.2.2 and LibreOffice 24.2 saves. Co-Authored-By: Claude Opus 5.5 * fix(desktop): install the program's own package and keep unknown owners unknown - When the desktop entry's package does not own the program it runs, the export installs the program's package too. The capture records why it cannot (executable_package_reason), and the export then refuses. - The export owns a state folder in the letter case the app wrote it, as the state roles already did, so data/get and data/reset cover a profile folder the app created during the capture. - A dpkg-query that fails or exits 2 or higher leaves the package unknown instead of reading as "no owner". Co-Authored-By: Claude Opus 5.5 * fix(desktop): keep selector checks undecided on partial views and pair members one to one - A selector check is undecided while an array it searched may be missing members: a cut zip listing, or a truncated XML or entry view. Both evaluators agree. - Diffs pair the members of two versions one to one, so two new elements can no longer both match the same old one and hide an addition. - The task draft descends into JSON values and readable entries added whole, and never suggests an element by its position, last step included. - The export's namespace refusal also checks XML entries inside the value a pointer names, such as the whole listing. - The exported reader keeps REA's 500-level JSON depth limit, so one deep entry no longer hides the rest of the archive, and treats an entry whose data runs past its declared size as damaged. Co-Authored-By: Claude Opus 5.5 * fix(desktop): check approved packages against the repositories the export keeps The capture approved library packages and the program's package against every repository with a verified key, but the export can still leave a repository out: its key dropped by the content budget, an architecture restriction, or a conflicting key. Each repository now records the packages only it supplies (`supplies`). The export drops stranded library packages with a limitation, and refuses when the program's own package is stranded. Captures without `supplies` behave as before. The install limitations move to appExportInstallLimitations.ts to keep appExportInstall.ts under the line limit. Co-Authored-By: Claude Opus 5.5 * fix(desktop): compare type changes in place, keep empty values and unread entries honest - When an array becomes another type, its members are compared with what their pointers name there, so values that are still present no longer look removed. - The draft checks an added empty array or object by equality. - The exported reader's JSON depth check keeps one iterator per level and copies no wide list, back to the memory peak before the check. - An entry whose view could not be read is no `null` value: the draft suggests no check on it, and a check into its view fails in both evaluators with the read error. Co-Authored-By: Claude Opus 5.5 * fix(desktop): decide entry view checks only on entries with a view, and walk JSON depth by level - A check into an archive entry's view needs an entry read in a format with a view. A binary or unread entry's `view` of `null` no longer passes a `null` or absence check, in either evaluator, and the draft suggests no absence inside an entry that ends without a view. - REA's JSON depth check keeps one iterator per level instead of queuing a pair per member, so 800,000 nulls need 18 MiB instead of more than 64. The helper came with the state view; its Python twin was fixed here. Co-Authored-By: Claude Opus 5.5 * fix(desktop): refuse an export whose desktop entry runs a program no package owns When a package owns the desktop entry but no package owns the program it runs, such as one in ~/.local/bin first on PATH, the capture keeps the entry's package and records why the program cannot be installed. Export refuses instead of installing a package that does not bring that program. Co-Authored-By: Claude Opus 5.5 * fix(desktop): bound zip entry reads, export metadata checks, and diff by the evaluator's pointer rules - The exported reader inflates a zip entry at most 64 KiB at a time and stops a byte past its declared size, so a forged small size no longer holds the whole stream in memory (77.5 MiB to 0.1 MiB on a 32 KiB bomb). - The namespace refusal applies only to checks that depend on an entry's XML, so checks on an entry's name, size or digest, or on its absence, export. - Diffs find a member's counterpart by the evaluator's pointer rules, so a selector step across a type change no longer reports a present value removed. Co-Authored-By: Claude Opus 5.5 * fix(desktop): read at most 1,000 zip directory records, and index arrays once per diff - Both zip readers now read the central directory a record at a time, keep the first 1,000 file records and count the rest, then hand the library an archive whose directory holds only those. Directories are told apart as zip.js does, so the Python reader stops listing attribute-marked directories as files. 600,000 entries now peak at 1.3 MiB in Python (was 293.6 MiB) and need 24 MiB of heap in REA. - The exported Python zip reader moves to appExportPythonZip.ts. - Diffs look members up through an index built once per compared array, so 20,000 selector keys against 20,000 members take 31 ms, not 5.4 s. Co-Authored-By: Claude Opus 5.5 * feat(desktop): pick seed and end by checkpoint, and keep the capture host out of bundles - Export, task export and the task draft take --seed-checkpoint and --end-checkpoint, so one capture can make the seed in the app and then the edit; the defaults stay the baseline and the last version. - Capture reads apt's lists folder and trusted keys from apt-config, so a user-space apt-get update under APT_CONFIG is found, and starts the app without AGENTENV_* variables. - Capture records the host name and user. Export rewrites user@host, the host name as a whole name and the captured home in text, UTF-16 and dconf seeds, ships SEED_HOME=/home/user instead of the capture user's home, lists scrubbed and unscrubbable seeds, and refuses checks that name the host. Co-Authored-By: Claude Opus 5.5 * fix(desktop): check tasks against the shipped seed and scrub JSON and XML by value - Task validation reads the seed as the bundle ships it, after the scrub, so a task whose end value the scrub writes into the seed is refused. - JSON strings and XML text, attribute values, comments and CDATA are scrubbed as their decoded values and rewritten only when they change, so syntax stays valid and escaped identities are found. - One leftover search covers every shipped file's path and contents, generated module and dconf keys included; the strict whole-name rule is kept for replacing only. - A host named localhost is never replaced, but user@localhost is. Co-Authored-By: Claude Opus 5.5 * fix(desktop): resolve a record's selector keys together, and follow Zip64 locators without sentinels - The diff resolves all of a record's selector-shaped keys in one lookup: conditions are normalized per field, selectors grouped by field set, and only the values asked for are kept, so retained memory grows with the keys, not members times field sets (68 MB to 5.5 MB, 76 MB to 4.9 MB on the reported cases). - Both zip readers follow a Zip64 locator just before the end record whatever the classic record holds, as zipfile does, so valid Zip64 archives with ordinary classic values read again. The exported module's synthetic tail carries a real Zip64 end record, so bytes that only look like a locator no longer make zipfile refuse it. Co-Authored-By: Claude Opus 5.5 * fix(desktop): scrub JSON values only, with a scanner whose stack does not grow with string length JSON seeds are walked one string at a time, finding each closing quote with indexOf and a backslash count instead of a backtracking regex, so a 16,000,000-character string no longer overflows the stack. Member names are never rewritten, so two keys cannot collide; a name holding the host leaves the file in unscrubbed_files. Co-Authored-By: Claude Opus 5.5 * fix(desktop): scrub dconf and UTF-16 seeds by format, search check values for the host, and gate checks on the selected ends - dconf: the host scrub ran over the whole key file, so on a host named `true` `wrap=true` became an invalid `wrap=localhost`. Only decoded GVariant string values are scrubbed now; keys, groups, bytestrings, dictionary keys and object paths ship as captured and are reported. - Task identity: the host search read the serialized check, whose field names (`file`, `kind`) and default ids matched hosts of those names. It now reads the check's string values only. - Value checks: the text-file refusal read every recorded version; it now reads only the shipped seed and the end. - UTF-16 seeds: the plain scrubber renamed JSON keys and XML names; the decoded text's format now picks the scrubber, keeping the byte order mark. Co-Authored-By: Claude Opus 5.5 * fix(desktop): pair members by identity first, build selectors lazily, and refuse oversized and deep text before building a view - Pairing: an element with an identifier (name, id, xid, *:id) pairs only by it, so an unchanged id still names an edited element instead of a removal and an addition, and two objects of one type are never paired as a rename. Selectors keep the tag apart from each attribute condition and spell only the chosen one, so a long tag with many attributes no longer multiplies. - Size: REA's detection and the exported reader decide JSON with a scanner that builds no value, and refuse a view over the parsed-view limit before building it, in files and zip entries alike. - Deep text: the exported reader confirms the whole text is JSON before reporting a nesting depth error, as REA reads it. Co-Authored-By: Claude Opus 5.5 * fix(desktop): count a selector's matches over every sibling while identified elements choose only their identifiers Identity-first pairing also dropped an identified sibling's other attributes from the counts that prove a selector names one member, so a shared attribute such as `color` or `fill` could be chosen though it matched two siblings: the diff reported present elements removed and drafts lost their checks. Counting now covers every attribute of every sibling; only the choice is limited to an identified element's tag and identifiers. Co-Authored-By: Claude Opus 5.5 * fix(desktop): tell a UTF-16 seed's format without building it, and scrub XML without one piece per token Choosing the scrubber for a decoded UTF-16 seed ran the full state detection, which built the whole XML document (or JSON value), so a large seed within the capture limit could exhaust memory at export. A syntax pass now decides the format (the JSON token scan, and the XML parser's events keeping only the open elements), and the XML scrubber copies out only the spans it changes. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../references/environments.md | 8 +- docs/desktop-application-capture.md | 72 ++- docs/desktop-application-export.md | 158 +++++- docs/desktop-application-state.md | 77 ++- docs/desktop-application-tasks.md | 151 +++++- docs/specs/desktop-app-environments/README.md | 9 + .../capture-for-building/plan.md | 23 + .../capture-for-building/spec.md | 74 +++ .../capture-for-building/verification.md | 186 +++++++ .../desktop-app-environments/capture/spec.md | 22 +- .../desktop-app-environments/capture/tasks.md | 2 + .../capture/verification.md | 70 +++ .../document-views/plan.md | 25 + .../document-views/spec.md | 106 ++++ .../document-views/verification.md | 220 ++++++++ .../desktop-app-environments/export/spec.md | 13 +- .../state-view/spec.md | 10 +- .../desktop-app-environments/tasks/spec.md | 11 +- .../tasks/verification.md | 5 +- .../desktop/DesktopApplicationCapture.test.ts | 5 + .../desktop/DesktopApplicationCapture.ts | 18 +- .../desktop/DesktopApplicationExport.test.ts | 77 +++ .../desktop/DesktopApplicationExport.ts | 359 +++++++------ .../desktop/DesktopApplicationState.ts | 40 +- .../desktop/DesktopArchiveTask.test.ts | 273 ++++++++++ .../desktop/DesktopCheckpointEnds.test.ts | 251 +++++++++ src/application/desktop/DesktopExportFiles.ts | 182 +++++++ .../desktop/DesktopExportIdentity.test.ts | 348 ++++++++++++ .../desktop/DesktopSigningKeys.test.ts | 77 ++- .../desktop/DesktopStateVersions.ts | 62 ++- src/application/desktop/DesktopTaskDraft.ts | 35 +- src/application/desktop/DesktopTaskExport.ts | 92 ++-- src/application/desktop/DesktopZipView.ts | 131 +++++ .../desktop/desktopCapture.fixture.ts | 59 +- src/artifacts/ZipArtifactReader.ts | 30 +- src/cli/desktopCaptureCommands.ts | 25 +- src/desktop/ApplicationStateDirectories.ts | 106 ++++ src/desktop/AptRepositoryInventory.test.ts | 42 ++ src/desktop/AptRepositoryInventory.ts | 39 ++ src/desktop/DebianPackageInventory.test.ts | 303 +++++++++++ src/desktop/DebianPackageInventory.ts | 239 ++++++--- src/desktop/DesktopApplicationSession.test.ts | 29 +- src/desktop/DesktopApplicationSession.ts | 16 +- src/desktop/LibraryPackageInventory.ts | 38 +- src/desktop/LinuxApplicationInventory.test.ts | 34 ++ src/desktop/LinuxApplicationInventory.ts | 95 +--- src/domain/desktop/appExport.test.ts | 16 +- src/domain/desktop/appExport.ts | 70 ++- src/domain/desktop/appExportInstall.test.ts | 161 +++++- src/domain/desktop/appExportInstall.ts | 153 +++--- .../desktop/appExportInstallLimitations.ts | 81 +++ src/domain/desktop/appExportPython.ts | 211 +++++--- src/domain/desktop/appExportPythonXml.ts | 101 ++++ src/domain/desktop/appExportPythonZip.ts | 237 +++++++++ src/domain/desktop/appTask.ts | 12 +- src/domain/desktop/appTaskChecks.fixture.ts | 416 +++++++++++++++ src/domain/desktop/appTaskChecks.test.ts | 158 ++++++ src/domain/desktop/appTaskChecks.ts | 124 +++-- src/domain/desktop/appTaskDraft.test.ts | 503 ++++++++++++++++++ src/domain/desktop/appTaskDraft.ts | 153 +++++- src/domain/desktop/appTaskPython.ts | 167 +++++- src/domain/desktop/aptRepositories.ts | 27 + src/domain/desktop/desktopCapture.ts | 10 + src/domain/desktop/desktopState.ts | 14 + src/domain/desktop/hostIdentity.test.ts | 110 ++++ src/domain/desktop/hostIdentity.ts | 106 ++++ src/domain/desktop/libraryPackages.test.ts | 1 + src/domain/desktop/libraryPackages.ts | 30 +- src/domain/desktop/seedScrub.test.ts | 333 ++++++++++++ src/domain/desktop/seedScrub.ts | 418 +++++++++++++++ src/domain/desktop/stateChanges.test.ts | 428 +++++++++++++++ src/domain/desktop/stateChanges.ts | 109 +++- src/domain/desktop/stateFormats.test.ts | 9 + src/domain/desktop/stateFormats.ts | 230 +++++++- src/domain/desktop/stateRoles.test.ts | 25 + src/domain/desktop/stateRoles.ts | 23 +- src/domain/desktop/viewPointer.test.ts | 240 +++++++++ src/domain/desktop/viewPointer.ts | 400 ++++++++++++++ src/domain/desktop/zipArchive.fixture.ts | 172 ++++++ src/domain/desktop/zipDirectory.ts | 207 +++++++ src/domain/desktop/zipView.ts | 200 +++++++ src/domain/xmlDocument.ts | 37 +- tests/boundary/cli/desktopCaptureCli.test.ts | 55 ++ .../providers/desktopExportPython.test.ts | 164 +++++- .../providers/desktopStateHeap.test.ts | 142 +++++ .../providers/desktopXmlPython.test.ts | 198 +++++++ .../boundary/providers/desktopZipHeap.test.ts | 50 ++ .../providers/desktopZipPython.test.ts | 477 +++++++++++++++++ .../fixtures/freecad/0.21/after/Document.xml | 222 ++++++++ .../fixtures/freecad/0.21/before/Document.xml | 119 +++++ tests/fixtures/freecad/1.0/after/Document.xml | 272 ++++++++++ .../fixtures/freecad/1.0/before/Document.xml | 151 ++++++ tests/fixtures/freecad/README.md | 17 + tests/fixtures/inkscape/README.md | 13 + tests/fixtures/inkscape/after/poster.svg | 57 ++ tests/fixtures/inkscape/before/poster.svg | 57 ++ tests/fixtures/libreoffice/README.md | 13 + tests/fixtures/libreoffice/after/content.xml | 2 + tests/fixtures/libreoffice/before/content.xml | 2 + tests/support/desktopExportPython.ts | 149 ++++++ 100 files changed, 11304 insertions(+), 795 deletions(-) create mode 100644 docs/specs/desktop-app-environments/capture-for-building/plan.md create mode 100644 docs/specs/desktop-app-environments/capture-for-building/spec.md create mode 100644 docs/specs/desktop-app-environments/capture-for-building/verification.md create mode 100644 docs/specs/desktop-app-environments/document-views/plan.md create mode 100644 docs/specs/desktop-app-environments/document-views/spec.md create mode 100644 docs/specs/desktop-app-environments/document-views/verification.md create mode 100644 src/application/desktop/DesktopArchiveTask.test.ts create mode 100644 src/application/desktop/DesktopCheckpointEnds.test.ts create mode 100644 src/application/desktop/DesktopExportFiles.ts create mode 100644 src/application/desktop/DesktopExportIdentity.test.ts create mode 100644 src/application/desktop/DesktopZipView.ts create mode 100644 src/desktop/ApplicationStateDirectories.ts create mode 100644 src/desktop/AptRepositoryInventory.test.ts create mode 100644 src/domain/desktop/appExportInstallLimitations.ts create mode 100644 src/domain/desktop/appExportPythonXml.ts create mode 100644 src/domain/desktop/appExportPythonZip.ts create mode 100644 src/domain/desktop/hostIdentity.test.ts create mode 100644 src/domain/desktop/hostIdentity.ts create mode 100644 src/domain/desktop/seedScrub.test.ts create mode 100644 src/domain/desktop/seedScrub.ts create mode 100644 src/domain/desktop/viewPointer.test.ts create mode 100644 src/domain/desktop/viewPointer.ts create mode 100644 src/domain/desktop/zipArchive.fixture.ts create mode 100644 src/domain/desktop/zipDirectory.ts create mode 100644 src/domain/desktop/zipView.ts create mode 100644 tests/boundary/providers/desktopStateHeap.test.ts create mode 100644 tests/boundary/providers/desktopXmlPython.test.ts create mode 100644 tests/boundary/providers/desktopZipHeap.test.ts create mode 100644 tests/boundary/providers/desktopZipPython.test.ts create mode 100644 tests/fixtures/freecad/0.21/after/Document.xml create mode 100644 tests/fixtures/freecad/0.21/before/Document.xml create mode 100644 tests/fixtures/freecad/1.0/after/Document.xml create mode 100644 tests/fixtures/freecad/1.0/before/Document.xml create mode 100644 tests/fixtures/freecad/README.md create mode 100644 tests/fixtures/inkscape/README.md create mode 100644 tests/fixtures/inkscape/after/poster.svg create mode 100644 tests/fixtures/inkscape/before/poster.svg create mode 100644 tests/fixtures/libreoffice/README.md create mode 100644 tests/fixtures/libreoffice/after/content.xml create mode 100644 tests/fixtures/libreoffice/before/content.xml create mode 100644 tests/support/desktopExportPython.ts diff --git a/.agents/skills/reverse-engineer-anything/references/environments.md b/.agents/skills/reverse-engineer-anything/references/environments.md index 3157c81c99..331c0e9e69 100644 --- a/.agents/skills/reverse-engineer-anything/references/environments.md +++ b/.agents/skills/reverse-engineer-anything/references/environments.md @@ -337,7 +337,13 @@ Kinds: `file_present` and `file_absent`; `lines_present` and `lines_absent` `at_most`); `value_equals` and `value_absent` (a JSON `pointer` into the view, such as `/groups/GROUP/KEY` for a key file); `row_present` and `row_absent` (`table`, `where` by column) and `row_count`. Each names its `file` as a path -under the home. The +under the home. A document saved as a zip archive, such as FreeCAD's +`.FCStd`, reads as its entries: point into an entry's view, and name array +members by selector rather than position, as +`/entries/[name=Document.xml]/view/root/children/[name=Object][@name=Hole]`, +so the check survives an app version that writes them in another order. Give +`value_equals` a `tolerance` to compare a number the app writes as text, such +as `"5.0000000000000000"`, with a number. The [desktop application tasks](https://github.com/morluto/rea/blob/main/docs/desktop-application-tasks.md) guide has the exact rules. diff --git a/docs/desktop-application-capture.md b/docs/desktop-application-capture.md index b9f56a3db9..f759fb8532 100644 --- a/docs/desktop-application-capture.md +++ b/docs/desktop-application-capture.md @@ -52,7 +52,10 @@ application is already open, or if the window list cannot be read. A launch beside a running instance can hand off to it, and that instance's unsaved state would be missing from the baseline. It then records the starting state, launches the application in its own process group with a run token in its -environment, and reads commands from stdin, one per line: +environment, and reads commands from stdin, one per line. The application gets +REA's environment without the `AGENTENV_*` variables an agent environment sets, +such as its control token: an app the exported computer launches never has +them, and an app captured inside a computer must not hold a credential to it. | Line | Effect | | ------------------------------------------- | ----------------------------------------------------------- | @@ -65,6 +68,15 @@ session. A name that repeats an earlier checkpoint, or is one of the capture's own names (`baseline`, `stop`, `time-limit`, `exit`), is refused with a progress line on stderr; no checkpoint is taken and the session continues. +Name the checkpoints a later step reads by what they mark. The +[export](desktop-application-export.md) seeds the bundle from the baseline +unless `--seed-checkpoint` names another, and a +[task](desktop-application-tasks.md) is checked against the last checkpoint +unless `--end-checkpoint` names another. So one capture can build a seed and +then the change: launch the app, create and save the document, send +`checkpoint seed`, make the edit and save it, send `checkpoint end`, then +`stop`. + The session also finishes when the launched process has exited and the application's processes and windows are all gone in two consecutive samples, or when `--max-duration-seconds` passes. Before deciding the processes are @@ -122,7 +134,13 @@ command makes it private (mode 0700). It holds: distinct content. The Evidence operation is `capture_desktop_application` and its predicate type -is `rea.desktop-application-capture`. Its result has four parts. +is `rea.desktop-application-capture`. Its `host` names the platform, +architecture, display and home directory, and the host's name (`hostname`) +and the user's (`user`), which captured state can hold, as in a mail +template's `ada@e81232be457d`; the export removes them from the bundle. A +capture written before they were recorded has neither, and one is left out +when the system cannot say, as for a user with no passwd entry. The rest of +the result has four parts. **Application.** The desktop entry (id, path, name, `Exec`, icon, `StartupWMClass`, categories, MIME types), the launch command and working @@ -132,7 +150,39 @@ architecture and the apt sources that list the installed version, with the origin (`o=`) apt reports for each source in `source_origins`, such as `Ubuntu` for the Ubuntu archive and its mirrors or `LP-PPA-` for a PPA; an empty source list means the version came from a local `.deb` or the package lists -are missing, which `package_lists_present` tells apart. +are missing, which `package_lists_present` tells apart. apt's lists folder, +and the trusted keys below, are where `apt-config` resolves +`Dir::State::Lists`, `Dir::Etc::TrustedParts` and `Dir::Etc::Trusted` under +`Dir`, so an `APT_CONFIG` file that moves them is followed: on a computer +without lists, an ordinary user can run `apt-get update` with an `APT_CONFIG` +naming writable `Dir::State::Lists` and `Dir::Cache` folders and capture with +the same `APT_CONFIG`. Without `apt-config`, or when it fails, they are +`/var/lib/apt/lists`, `/etc/apt/trusted.gpg.d` and `/etc/apt/trusted.gpg`. + +The Debian package is the one `dpkg-query -S` names as the owner of the +desktop entry the capture launched, or else of the executable, since the +entry's package is what installs the application: LibreOffice Writer's entry +belongs to `libreoffice-writer`, while the `/usr/bin/libreoffice` it runs +belongs to `libreoffice-common`, which does not bring Writer with it. When the +two differ, `executable_package` names the executable's package, which the +export installs beside the entry's, since the entry's package need not depend +on it (`vim-common` owns Vim's entry but does not depend on `vim`). +`executable_package_reason` says why the computer could not install it by +name, by the rule for library packages below: neither the Ubuntu 24.04 archive +nor a vendor repository the export declares offers it, or `apt-cache policy` +did not say. When a package owns the entry but none owns its program, such as +a `~/.local/bin/` found first on `PATH`, the entry's package is recorded +without `executable_package`, and `executable_package_reason` says no package +owns the program, so the export refuses rather than install a package that +does not bring it. A program link that dpkg does not own counts as owned when +dpkg owns the file it resolves to, as an alternatives link does. An +`--executable` launch, or an entry no package owns, such as +one in `~/.local/share/applications`, records the executable's package. When +`dpkg-query` does not finish or exits with an error for the executable or the +entry, the package is `unknown` and the limitations say which path, since a +failed lookup is not proof that no package owns it. The sources, vendor +repositories and `--package-file` match below are the recorded package's; the +library walk still reads the executable. Each source whose origin is not `Ubuntu` is also recorded in `apt_repositories`. `apt-get indextargets` names the sources file entry apt @@ -148,8 +198,8 @@ and in `apt-get indextargets` and `apt-cache policy` output alike, so entries match whether or not apt prints it, and sets `uri_credentials_removed` on a repository whose URI held one (a capture written before this was recorded omits the field). The key is found by verification, not by name: the candidates are the -entry's `signed-by` keyrings, or else every `.gpg` and `.asc` key in -`/etc/apt/trusted.gpg.d` and `/etc/apt/trusted.gpg`, and each is checked with +entry's `signed-by` keyrings, or else every `.gpg` and `.asc` key in apt's trusted +parts folder and its trusted keyring, and each is checked with `gpgv` against the release's `InRelease` (or `Release` with `Release.gpg`) beside its index in apt's lists folder. A key counts when gpgv reports a valid signature by it and no bad, expired, revoked or unverifiable signature @@ -187,7 +237,12 @@ listed, and neither is the application's own package. One batched recorded vendor repository with a verified key offers, in any component its sources entry lists, since the export declares them all; the rest, such as packages from a PPA or a local `.deb`, are listed in -`uninstallable_library_packages`. Sonames found nowhere are listed in +`uninstallable_library_packages`. The same query checks `executable_package`. +Each recorded repository's `supplies` lists the kept packages, and the +executable's package, that the archive does not offer and its package files +list, so the export can leave out those only a repository it does not declare +supplies, such as one whose key the content budget later dropped. Sonames +found nowhere are listed in `missing_libraries`, and libraries no package owns in `unpackaged_libraries`. `library_packages_reason` says why the lists are incomplete: the executable is not an ELF image (a launcher script, for example), `ldconfig`, `dpkg-query` or @@ -223,7 +278,10 @@ the fuse sentinel in their binary or a `resources/app.asar` beside it. State directories are the XDG config, data, state and cache folders and the dot-folder named after the package, the executable, the desktop entry's full id and its last part, and the entry's name, with whether each existed at the -start. Dotfiles in the home named after the application (`.rc`, or +start. A folder in those places whose name is one of these in another letter +case joins them, such as GIMP's `~/.config/GIMP` for the `gimp` package; only +the whole name is compared, so `~/.config/GIMPshop` does not. Dotfiles in the +home named after the application (`.rc`, or `.` followed by `-`, `.` or `_`, for a name of three or more characters) that exist at the start join them, since an application may only read such a file during the capture. A `flatpak run` launch is named after its application diff --git a/docs/desktop-application-export.md b/docs/desktop-application-export.md index ab687e849a..207abfac95 100644 --- a/docs/desktop-application-export.md +++ b/docs/desktop-application-export.md @@ -22,10 +22,16 @@ the capture, once REA has validated it against the capture; see [desktop application tasks](desktop-application-tasks.md). `--package-url` says where the computer downloads the package of an application installed from a `.deb` file; see [Applications from a .deb file](#applications-from-a-deb-file). +`--seed-checkpoint` names the checkpoint whose state seeds the bundle, the +baseline by default, and `--end-checkpoint` the one a task must pass on, the +capture's last by default; see [The seed checkpoint](#the-seed-checkpoint). The command returns Evidence with operation `export_desktop_application` and predicate type `rea.desktop-application-export`, listing the environment name, -owned paths, seed files and written files. A failed export, cancellation +the seed and end `checkpoints`, owned paths, seed files, the seeds it +rewrote to remove the capture host's name and user (`scrubbed_files`), the +bundle files that still hold them (`unscrubbed_files`), and written files, +the last three by their path in the bundle. A failed export, cancellation included, removes the bundle it had not yet published, so no partial copy of the user's data remains. If the bundle was published but its output handles could not be closed, the command reports a cleanup error that carries the export @@ -38,7 +44,7 @@ Evidence; the bundle itself is complete. | `envs//env.toml` | `type = "computer_app"`, the module and class, `computer = "ubuntu-24.04"`, `driver = "reference"` | | `envs//.py` | The `ComputerAppEnvironment` class | | `envs//SKILL.md` | What the app is, its window class and where its files live | -| `envs//seed/` | The application's files from before the capture, by home-relative path | +| `envs//seed/` | The application's files at the seed checkpoint, by home-relative path | | `envs//apt-keys/` | The signing key of each vendor apt repository the app installs from | | `envs//install-package.sh` | With `--package-url`: the run script that installs the downloaded package with dpkg | | `tasks/smoke.json` | A task that deploys the app | @@ -63,8 +69,18 @@ The class declares: leaves out dependencies on the libraries it loads, as Typora's Electron package does, still starts; library packages the capture found neither the Ubuntu 24.04 archive nor a declared repository offers are left out, with a - limitation. An archive package's declared dependencies are trusted as they - are. The package must come from the Ubuntu 24.04 archive or a mirror of it, + limitation, and so are those only repositories the export leaves out supply + (a repository's `supplies`), such as one whose key the capture could not + retain. An archive package's declared dependencies are trusted as they + are. When the desktop entry's package is not the one that owns the program + it runs, as `vim-common`'s entry runs `vim`'s program, the program's package + (`executable_package`) follows the app's by name, since the entry's package + need not depend on it; a capture that found no archive or declared + repository offering it records why, and the export refuses it with that + reason, as it does when only repositories it leaves out supply it, or when + no package owns the program at all (`executable_package_reason` without + `executable_package`). The package must come from the Ubuntu 24.04 archive + or a mirror of it, or from a vendor apt repository whose signing key the capture verified and retained. A source counts as the archive when apt reports its origin as `Ubuntu` for a noble suite; a capture made before origins were recorded @@ -111,7 +127,11 @@ The class declares: computer's container does not grant, and Electron exits without them. The README and the result's limitations say the sandbox is off. - `paths`: the state directories holding the application's state files and the - folders holding its documents, nested folders collapsed. The home itself and + folders holding its documents, nested folders collapsed. A folder beside a + state directory whose name differs from it only in letter case is owned + whole in the files' spelling, so a `~/.config/GIMP` profile GIMP created + during the capture beside the candidate `~/.config/gimp` is owned as + `.config/GIMP`, not as the folder of its first file. The home itself and the shared `.config`, `.local`, `.local/share`, `.local/state` and `.cache` folders are never owned whole: a file directly in one is owned as itself. Files the capture recorded under a symlinked state directory's physical @@ -127,18 +147,14 @@ The class declares: with a typed JSON view of each, read in a worker thread. An application with no observed state declares no paths and no hooks. -Seeded text files (text, JSON, key files and XML in the state view) have the -captured home path respelled to the computer's home wherever it is followed by -`/`, the end of the text, or a character that cannot continue a file name. A -UTF-16 file that starts with a byte-order mark and holds the captured home is -respelled the same way, keeping its mark; other files are copied as captured. A file under the owned paths whose starting -bytes the capture did not retain is not seeded: one in the state view without -retained baseline content, or one the capture omitted (for example too large -or past the content budget). The result lists them in `unseeded_files` and the -README names them. When the application held files open in folders whose -starting content the capture did not retain, a limitation names those folders. +A file under the owned paths whose bytes at the seed the capture did not +retain is not seeded: one in the state view without retained content at that +checkpoint, or one the capture omitted (for example too large or past the +content budget). The result lists them in `unseeded_files` and the README +names them. When the application held files open in folders whose starting +content the capture did not retain, a limitation names those folders. -The application's dconf keys at the baseline, if any, are written once when the +The application's dconf keys at the seed, if any, are written once when the app first starts, also for an application that owns no files, and the class adds `dconf-cli` to its apt packages for that. The server starts before the desktop's D-Bus session, so the class does not use `dconf load`: it dumps any @@ -148,6 +164,85 @@ win, and replaces `~/.config/dconf/user`. If that fails, the server prints a one-line warning and the app starts without the captured keys. `data/reset` does not restore them. +## The seed checkpoint + +By default the seed is the state before the capture, its `baseline`, and a +task's end state is the capture's last checkpoint. `--seed-checkpoint` and +`--end-checkpoint` name others, so one capture can make a seed and then a +change: the user creates and saves a document and takes `checkpoint seed`, +then edits and saves it and takes `checkpoint end`. Exported with +`--seed-checkpoint seed --end-checkpoint end`, the bundle seeds the document +as it was saved at `seed`, owned paths, dconf keys and unseeded files +included, and a task must fail on that seed and pass on the state at `end`. +A file's state at a checkpoint is its last version recorded at or before it. +The export refuses a name the capture does not hold exactly once, and an end +checkpoint taken before the seed checkpoint; `inspect-desktop-application-state` +lists the names in `capture.checkpoints`. + +## Host name and user + +A seed can hold the capture host's name and the user's: Geany saved its mail +template as `pref_template_mail=ada@e81232be457d`, the user `ada` on a +container named `e81232be457d`, and many files hold the home path +`/home/ada`. The capture records `host.hostname` and `host.user`, and the +export removes them from what the bundle ships: + +- In seeded text files (text, JSON, key files and XML in the state view), in + UTF-16 files that start with a byte-order mark (keeping the mark and byte + order), and in the dconf keys, `user@host` becomes `user@localhost`, the + host name standing alone becomes `localhost`, and the captured home becomes + `/home/user` wherever it is followed by `/`, the end of the text, or a + character that cannot continue a file name. The host name counts only as a + whole name: not inside a longer word, before a `-`, or as a label of a + domain such as `e81232be457d.local`, so a host named `ubuntu` leaves + `ports.ubuntu.com/ubuntu-ports` alone. A host named `localhost` is left + alone, but its `user@localhost` still takes the computer's user. The user + name alone is never replaced, since a short one such as `ada` or `user` is + also ordinary text. A rewritten seed is written with its own SHA-256, and + `scrubbed_files` and the README list it. +- JSON and XML keep their syntax. In JSON only string values change, each + read as its decoded value, so `\u0065` escapes cannot hide the host, and is + written back as JSON only when it changed; numbers and `true`, `false` and + `null` stay as they are even for a host named `true`. Member names are never + rewritten, since `{"ubuntu": 1, "localhost": 2}` on a host named `ubuntu` + would get two `localhost` keys and lose a setting; a file whose names hold + the host is listed in `unscrubbed_files` instead. In XML only character + data, attribute values, comments and CDATA change, character references + decoded the same way; element and attribute names and processing + instructions stay. A UTF-16 file is scrubbed by the format its text would + have in UTF-8, so JSON or XML in UTF-16 follows these rules too. +- In the dconf keys only GVariant strings change, each read as its decoded + value and written back in its own quotes only when it changed. Group names, + keys, dictionary keys, bytestrings and everything outside quotes stay, so a + host named `true` leaves `wrap=true` alone. A value whose string does not + parse, or that holds an object path or signature, ships as captured. +- `SEED_HOME` in the class is `/home/user`, the home the seed spells, which + the class respells to the computer's own home when it seeds the home at + boot, as it did the captured home before. The replacements read as the + computer's own: its user is `user`, with its home in `/home/user`, and + `localhost` is the name every host has for itself. +- Binary files, SQLite databases among them, and text files over 256 MiB are + copied as captured. Then every file the bundle ships, the class module with + its dconf keys and the README included, is searched by a looser rule than + the replacement's: the host name and `user@host` anywhere, as in + `e81232be457d.local` or a seed named `e81232be457d-notes.txt`, and the + captured home as a whole path, in its path and its content, JSON and dconf + strings and XML references decoded and the replacements themselves set + aside. Each one found is listed in `unscrubbed_files`, and in the README + unless it is the README, to check before sharing the bundle. Bytes that are not text are + searched as they are, so a binary file that holds the host in another + encoding is not listed. +- A task check whose values name `user@host` or the host name anywhere, by + that rule, is refused, as one naming the captured home is: the bundle does + not hold them. Its field names, kind and id are not values, so a host named + `file` or `kind` refuses no check. A task's checks must fail on the seed as + the bundle ships it, scrubbed, so a check the scrub itself satisfies, such + as a mail setting the user changed from `ada@e81232be457d` to + `user@localhost`, does not count as failing there. + +A capture written before the host's name and user were recorded has only its +home removed, with a limitation saying so; capture again to remove the rest. + ## Applications from a .deb file A closed-source application you installed from a `.deb` file you downloaded, @@ -199,24 +294,35 @@ shapes: SQLite tables (at most 64 ordinary tables, `tables_truncated` past that, and per table `columns`, `primary_key`, at most 500 `rows` and `truncated`, with blobs, unsafe integers and text that is not UTF-8 tagged as REA tags them), JSON, XML as `{root, truncated}` with DOCTYPEs refused and REA's -node, depth and whitespace rules, key file groups, and text as -`{lines, line_count, truncated}` split on `\n` and `\r\n`. A JSON file +node, depth and whitespace rules, key file groups, text as +`{lines, line_count, truncated}` split on `\n` and `\r\n`, and zip archives +as their entries, read with Python's `zipfile` from the file in place, with +REA's entry, size, compression ratio and archive limits and its wording for an +entry it does not read. The module reads the central directory itself, as REA +does, and shows `zipfile` only the records it lists, reading each entry in +bounded chunks no further than a byte past its declared size. A JSON file holding a number JavaScript cannot read exactly, such as an integer above 2^53, -gets an `error` instead of a view, as in REA, while text that is not one JSON +or nested more than 500 levels deep, gets an `error` instead of a view, as in +REA, while text that is not one JSON document, such as JSON Lines, stays text. Format detection follows REA's rules. Files larger than 64 MiB get an `error` instead of a view -and are not read. Known differences remain: +and are not read. XML is read with Python's `expat` twice: with namespace +processing, which refuses what REA's parser refuses, such as an undeclared +prefix, then without it, so element and attribute names keep the prefixes the +document spells and namespace declarations stay attributes, as in REA's view; +text splits into nodes where REA's splits it, at CDATA sections, comments and +processing instructions. Known differences remain: -- XML names: element and attribute names are Python's `{namespace}local` - spelling rather than REA's prefixed names, namespace declarations are not - listed as attributes, and CDATA sections merge with the text around them, so - they count as one node. -- dconf databases are reported as binary, since REA reads them with `dconf`. +- dconf databases are reported as binary, since REA reads them with `dconf`; + inside an archive, one is `dconf` with an error, as in REA. +- Zip entry names: a name in an Info-ZIP Unicode Path extra field is used only + where Python's `zipfile` reads it (3.12 and later), and an entry whose local + header names it differently from the central directory is damaged rather + than read. - SQLite databases are read in place, read-only, rather than from a staged copy; a write lock the running app holds is waited out for a quarter of a second and then gives the file an `error`, and so does a table or column name that is not UTF-8. -- JSON nested deeper than Python's recursion limit gives the file an `error`. The seed is the user's real data. Keep the bundle local and do not publish it. The class was checked against agentenv-framework 0.9.1431 with diff --git a/docs/desktop-application-state.md b/docs/desktop-application-state.md index 75c2423e8f..605d43c728 100644 --- a/docs/desktop-application-state.md +++ b/docs/desktop-application-state.md @@ -30,6 +30,11 @@ changed later. Each path records the evidence the capture holds about it: | `dconf_application_keys` | It is a dconf database holding groups whose path names the application | | `desktop_service` | It lies where desktop services and toolkits keep shared files, or caches | +A folder beside a state directory whose name differs from it only in letter +case counts as that state directory, so a profile the application first +creates during the capture, such as GIMP's `~/.config/GIMP` beside the +candidate `~/.config/gimp`, is its state too. + The role follows from the reasons, in this order: `application_state` for application keys in dconf, or a state directory outside shared desktop locations; `document` for a file named in a window title or selected by the @@ -48,13 +53,20 @@ would claim other applications' groups. Each path lists its versions: the baseline, then its state after each checkpoint where it, or a database's `-wal` or `-journal` file, changed. A -checkpoint that leaves an absent file absent adds no version. A version records +checkpoint that leaves an absent file absent adds no version. A file's state +at any checkpoint is its last version recorded at or before it, which is how +the [export](desktop-application-export.md#the-seed-checkpoint) and the +[task draft](desktop-application-tasks.md) read it at the seed and end +checkpoints they are given; `capture.checkpoints` lists the names in order. +Views hold the captured bytes as they are, the capture host's name and user +included; the export removes those from the bundle. A version records whether the file was present, the SHA-256 of its retained bytes, and a view: | Format | Detected by | View | | --------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | `sqlite` | The `SQLite format 3` header | Every ordinary table's columns, primary key and up to 500 rows | | `dconf` | The `GVariant` header | Groups whose path names the application, plus the names of the other groups | +| `zip` | A zip local file header, `PK\x03\x04`, whatever the name | Its file entries in archive order, each with its own format and view | | `json` | A `.json` name or a leading `{` or `[` that parses | The parsed value, from files up to 4 MiB nested up to 500 levels | | `xml` | An XML name or declaration that parses | An element tree (`name`, `attributes`, `children`); text kept exactly, DOCTYPEs refused | | `keyfile` | Only `[group]`, `key=value` and comment lines, with at least one group | Groups of `key` → string value, from files up to 4 MiB | @@ -64,6 +76,16 @@ whether the file was present, the SHA-256 of its retained bytes, and a view: A JSON value holding a number JavaScript would round, such as an integer above 2^53, is not read rather than shown rounded. +An XML view names elements and attributes as the document spells them, prefix +included: a saved Inkscape drawing's root is `svg`, in its default namespace, +with `sodipodi:docname` and the declarations `xmlns` and `xmlns:sodipodi` +among its attributes, and a Writer paragraph is `text:p`. An element's +`children` hold its elements and text in order, text split where a CDATA +section, comment or processing instruction falls; whitespace-only text in an +element that holds elements is dropped unless `xml:space="preserve"` applies. +The view keeps up to 5,000 nodes, comments and processing instructions +counted, nested up to 64 levels, and sets `truncated` when it leaves some out. + SQLite databases are read through REA's SQLite worker from a private copy, with any retained `-wal` or `-journal` file staged beside it; the worker accepts an inactive rollback journal and refuses a hot one. A version is not @@ -84,6 +106,52 @@ A version has a `view_error` when its bytes were not retained, are over the 64 MiB read limit, are missing or no longer match their digest, exceed a format's limit above, or its reader failed. +## Archives + +Many applications save a document as a zip archive of XML and other files: +FreeCAD's `.FCStd` and `.FCBak`, OpenDocument (`.odt`, `.ods`, whose +`content.xml` is the document), Office Open XML, EPUB and JAR. Such a file is +`zip`, and its view lists its file entries in archive order, leaving out +directories: an entry whose name ends in `/`, or whose attributes mark a Unix +directory or, from an MS-DOS host, the directory attribute: + +```json +{ + "entries": [ + { + "name": "Document.xml", + "size": 11923, + "sha256": "…", + "format": "xml", + "view": { + "root": { "name": "Document", "attributes": {}, "children": [] }, + "truncated": false + }, + "view_error": null + } + ], + "entry_count": 15, + "truncated": false +} +``` + +Each entry's bytes are read from the archive's verified bytes in memory, +checked against its CRC-32 and declared size, and given a `format` and `view` +by the rules and limits above, with the entry's name in place of a file name. +Nothing is extracted to disk. An entry has a `view_error`, with `sha256`, +`format` and `view` null, when it is encrypted, compressed other than stored or +deflated, larger than 16 MiB, more than 100 times its compressed size, past the +64 MiB read from one archive, or its data is damaged. A SQLite or dconf +database, or an archive, inside an archive keeps its format and `sha256` and +has a `view_error` instead of a view: it is not read. The view lists at most +1,000 entries; `entry_count` counts them all, and `truncated` is true when some +are not listed. REA reads the central directory itself, a record at a time, +through the Zip64 end record whenever a Zip64 locator precedes the end record, +as Python's `zipfile` does, and hands the zip reader only the records it lists, +so an archive of hundreds of thousands of entries costs no more to read +than one of a thousand. An archive whose central directory cannot be read has a +`view_error` of its own. + ## Kinds of change Between consecutive versions a change is `created`, `deleted`, or a @@ -94,6 +162,13 @@ each, with counts): - `values` for JSON and XML: JSON pointers to values, containers included, so an array that becomes an object with the same members is changed. A pointer longer than 4096 characters is counted but not listed. +- `values` for zip archives too, with array members named by + [selectors](desktop-application-tasks.md#pointers-and-selectors) rather than + positions wherever a selector names one member in both versions: entries by + name, as `/entries/[name=Document.xml]`, and XML elements by tag and an + attribute, as `/view/root/children/[name=Object][@name=Hole]`, so an object + inserted before another does not shift it. A comparison is incomplete when + either view leaves out entries, or an entry's view is missing or truncated. - `keys` for key files and dconf: `group/key`, with `~` and `/` in the key written `~0` and `~1` as in a JSON pointer, so `[a/b]` `c` and `[a]` `b/c` stay apart. diff --git a/docs/desktop-application-tasks.md b/docs/desktop-application-tasks.md index 100a34a07a..11480c5ba8 100644 --- a/docs/desktop-application-tasks.md +++ b/docs/desktop-application-tasks.md @@ -27,9 +27,15 @@ operation `describe_desktop_application_task` and predicate type window class. - `owned_paths`: the paths the export owns, the only ones whose files the app's `data/get` serves and checks can read. -- `files`: each file under them whose state differs between the capture's - first and last version, with its home path, role, both versions and the net - change between them, from the state view's own diff (`lines`, `values`, +- `checkpoints`: the two checkpoints compared, `baseline` for the seed and + `final` for the end. They are the capture's first and last unless + `--seed-checkpoint` and `--end-checkpoint` name others, as for the + [export](desktop-application-export.md#the-seed-checkpoint), which takes + the same options; a name the capture does not hold, or an end before the + seed, is refused. +- `files`: each file under them whose state differs between those + checkpoints, with its home path, role, its versions at both (`baseline` and + `final`, the last recorded at or before each) and the net change between them, from the state view's own diff (`lines`, `values`, `keys` or `rows` with what was added, removed and changed, or `created`, `deleted`, `format`, `content`, `unreadable`; `content` also when the views hold only part of a file and match while the file changed). A @@ -46,8 +52,13 @@ operation `describe_desktop_application_task` and predicate type lines it lost (when either is at most ten), and `line_count` for each line holding a letter whose count changed; JSON, XML and key files gain `value_equals` and `value_absent` by pointer; SQLite tables gain rows added, - changed and removed, by primary key, and their new row count. Values that - look like a time the app stamps are left out. + changed and removed, by primary key, and their new row count. A zip archive + gains checks inside the JSON, key file and XML views of its entries, by + [selectors](#pointers-and-selectors) and never through an element by + position: an element added whole that it exists, one removed whole that it + is absent, then values changed, added and removed, an entry or a JSON value + added whole through its members, and an empty array or object as it is. + Values that look like a time the app stamps are left out. The suggestions are a starting point. Keep the ones that say what the user did; drop the ones that only follow from how the app saves its file. A LibreCAD @@ -86,7 +97,7 @@ file, in the shapes of the state view. | `lines_present` | `lines` | Every listed line is a whole line of the text view | | `lines_absent` | `lines` | None of the listed lines is | | `line_count` | `exact` or `contains`; `equals`, `at_least` or `at_most` | The number of lines equal to `exact`, or holding `contains`, compares so | -| `value_equals` | `pointer`, `value` | The JSON pointer names a value equal to `value` | +| `value_equals` | `pointer`, `value`; optional `tolerance` | The pointer names a value equal to `value`, or within `tolerance` of it | | `value_absent` | `pointer` | The pointer names no value | | `row_present` | `table`, `where` | A row of the SQLite table has every column in `where` equal | | `row_absent` | `table`, `where` | No row does | @@ -102,9 +113,16 @@ The rules both evaluators follow: - Equality is JSON equality: numbers by value, never a boolean for a number, objects by their keys whatever their order, arrays in order. - Pointers follow RFC 6901 (`~1` for `/`, `~0` for `~`) and must start with - `/`. Key files read as `{"groups": {"GROUP": {"KEY": "VALUE"}}}`, so a - setting is `/groups/GROUP/KEY`; XML as - `{"root": {"name", "attributes", "children"}}`; SQLite as `{"tables": {"TABLE": {"columns", "rows"}}}`. + `/`; on an array a segment may be a selector, below. Key files read as + `{"groups": {"GROUP": {"KEY": "VALUE"}}}`, so a setting is + `/groups/GROUP/KEY`; XML as `{"root": {"name", "attributes", "children"}}`; + SQLite as `{"tables": {"TABLE": {"columns", "rows"}}}`; a zip archive as + `{"entries": [{"name", "format", "view", …}]}`. +- With `tolerance`, a non-negative number, `value` must be a number, and the + value found passes when it is a number, or a string that is a decimal + numeral such as `"5.0000000000000000"`, at most `tolerance` from `value`. + `tolerance: 0` compares numerically, so `"1.2500"` equals `1.25`. Anything + else fails as not a number. - `contains` matches a line that holds it anywhere, case and all. Checks take no regular expressions: a pattern run on what the agent wrote could stall the verifier, and JavaScript and Python read some patterns differently. @@ -114,17 +132,118 @@ The rules both evaluators follow: is decided on it. The export refuses, naming the check, a file outside the owned paths, a file -spelled with a leading slash, `.` or `..`, a pointer into a text file or into -XML that uses namespaces (the verifier's reader names them differently), the -empty pointer (the whole file), a value that names the captured home, and a -value that looks like an ISO 8601 date and time. Other volatile content, such -as a counter the app bumps on every save, is for the author to leave out. +spelled with a leading slash, `.` or `..`, a pointer into a file that is text +at the seed, as the bundle ships it, or at the end checkpoint, the empty +pointer (the whole file), a value that names the captured home, or the +capture host's name or `user@host` anywhere (which the +[export removes from the seed](desktop-application-export.md#host-name-and-user)), a +`tolerance` with a value that is not a number, and a value that looks like an +ISO 8601 date and time. Other volatile content, such as a counter the app +bumps on every save, is for the author to leave out. + +## Pointers and selectors + +An app that rewrites a document moves things: FreeCAD 1.0 records more +properties than 0.21, so a hole's radius sits at another position among its +siblings, and a plate's entry moves once a hole is added before it. A pointer +segment applied to an array may therefore name a member by what it holds +rather than by its position: + +- `[KEY=VALUE]` names the member whose string field `KEY` is `VALUE`, such as + a zip entry by `[name=Document.xml]` or an XML element by its tag, + `[name=Object]`. +- `[@KEY=VALUE]` names the member whose `attributes` hold `KEY="VALUE"`, as + the XML view keeps an element's attributes. +- Conditions can follow one another, `[name=Object][@name=Hole]`, and all must + hold. They compare strings only, the selector is escaped like any pointer + segment (`~1` for `/`), and a value cannot hold `][`. + +A selector must name exactly one member. When several match, or none does +before the last segment, the check fails and says which selector matched what. +When the last segment's selector matches none, the pointer names no value: +`value_absent` passes, which is how a check says an element was removed, and +`value_equals` fails. On an object, a bracketed segment is an ordinary key. + +A FreeCAD task that checks the user cut a 10 mm hole through the plate, in +FreeCAD 0.21 and 1.0 alike: + +```json +{ + "name": "cut-hole", + "prompt": "Cut a 10 mm hole through the middle of the plate and save it.", + "checks": [ + { + "id": "cut", + "kind": "value_equals", + "file": "Documents/plate.FCStd", + "pointer": "/entries/[name=Document.xml]/view/root/children/[name=Objects]/children/[name=Object][@type=Part::Cut]/attributes/type", + "value": "Part::Cut" + }, + { + "id": "radius", + "kind": "value_equals", + "file": "Documents/plate.FCStd", + "pointer": "/entries/[name=Document.xml]/view/root/children/[name=ObjectData]/children/[name=Object][@name=Hole]/children/[name=Properties]/children/[name=Property][@name=Radius]/children/[name=Float]/attributes/value", + "value": 5, + "tolerance": 0.01 + } + ] +} +``` + +Before the edit the archive has no `Part::Cut` and no `Hole`, so both checks +fail on the seed; after it, `Radius` is `"5.0000000000000000"` and both pass. +A check decided by what a view leaves out depends, in an archive, on the entry +its pointer goes into: it fails when that entry's view is missing or +truncated, or when the entry is not listed and the listing is truncated. A +selector must see every member to know it names one, so a check whose pointer +searches an array the view may hold only part of fails as undecided: any array +of a truncated XML view, of a truncated entry's view, or the listing when it +is cut. A position is decided on what the view holds. Like a file, an entry +that has no view (binary, not read, or a database or archive inside the +archive) fails every check into its `view`; its `null` there is no value, while +an entry holding the JSON document `null` reads as `null`. Checks on its `name`, +`size`, `sha256` and `format` still read. + +XML that uses namespaces is named as the document spells it, in both +evaluators: an element or attribute by its prefixed name, such as `text:p` +or `inkscape:label`, an element in a default namespace by its bare name, such +as `svg`, and namespace declarations as the attributes `xmlns` and +`xmlns:PREFIX`. Text is a string among an element's `children`. These checks +hold a poster's headline in an Inkscape SVG and a date in a LibreOffice Writer +document: + +```json +[ + { + "id": "headline", + "kind": "value_equals", + "file": "Pictures/poster.svg", + "pointer": "/root/children/[name=g][@inkscape:label=Layer 1]/children/[name=text][@id=headline]/children/[name=tspan]/children/0", + "value": "Summer Bake Sale" + }, + { + "id": "date", + "kind": "value_equals", + "file": "Documents/flyer.odt", + "pointer": "/entries/[name=content.xml]/view/root/children/[name=office:body]/children/[name=office:text]/children/[name=text:p][@text:style-name=Subtitle]/children/0", + "value": "Monday 24 November" + } +] +``` + +Inkscape writes a line of text as a `tspan` inside its `text` element, and +Writer keeps a paragraph's style in `text:style-name`. A prefix is the one the +document uses, so a check names the prefixes the app writes. ## Validation and export `rea export-desktop-application --task ` -evaluates the checks on the capture's state view: on the first version of -each file, which is the seed, and on its last version, the captured end state. +evaluates the checks on the capture's state view: on each file as it was at +the seed checkpoint, which is the seed, as the bundle ships it with the +capture host's name and user removed, and at the end checkpoint, the +captured end state. Those are the capture's first and last checkpoints unless +`--seed-checkpoint` and `--end-checkpoint` name others. It refuses a task whose every check passes on the seed, and a check that fails on the end state, with that check's reason. A capture yields one task, so the option is taken once. The result's `task` names the task, its number of checks diff --git a/docs/specs/desktop-app-environments/README.md b/docs/specs/desktop-app-environments/README.md index f778c90e26..2da35c2485 100644 --- a/docs/specs/desktop-app-environments/README.md +++ b/docs/specs/desktop-app-environments/README.md @@ -30,6 +30,15 @@ The work lands as one PR per step: installed from a downloaded `.deb` file, which the computer downloads from an operator's URL and checks by its captured SHA-256, starting with VariCAD. +10. [Document views](document-views/spec.md): documents saved as zip + archives (FreeCAD, OpenDocument, Office Open XML, EPUB) read as their + entries, checked by name rather than position, namespaced XML included, + and numbers compared within a tolerance, starting with FreeCAD. +11. [Capture for building](capture-for-building/spec.md): one capture makes + the seed and then the change, at checkpoints the export and the task + draft are told; apt lists from a user-space `apt-get update` are found; + the application does not inherit the computer's control token; and the + bundle holds neither the capture host's name nor its user's. Version 1 covers Linux with X11, applications whose state lives in files or SQLite under the home directory, and real applications from public packages. diff --git a/docs/specs/desktop-app-environments/capture-for-building/plan.md b/docs/specs/desktop-app-environments/capture-for-building/plan.md new file mode 100644 index 0000000000..b05887eb01 --- /dev/null +++ b/docs/specs/desktop-app-environments/capture-for-building/plan.md @@ -0,0 +1,23 @@ +# Plan + +1. Checkpoints: `versionAt` and `StateEnds` beside the state view's schema + in `src/domain/desktop/desktopState.ts`, since the draft is domain code; + `selectStateEnds` in `DesktopStateVersions.ts` turns names into positions + and refuses under the calling operation. The export's plan, its task + validation and the draft read every file through `versionAt`. +2. apt folders: `parseAptConfigShell` in `aptRepositories.ts` and + `resolveAptLocations` in `AptRepositoryInventory.ts`, called once per + Debian package with the host's folders as the fallback. +3. Launch: `applicationEnvironment` in `DesktopApplicationSession.ts` filters + the environment the owned process is spawned with. +4. Identity: `host.hostname` and `host.user` in the capture schema and + record; `src/domain/desktop/hostIdentity.ts` owns the matching rules, the + replacements and the per-seed decision, used by the export and by the task + refusal. The bundle files move to `DesktopExportFiles.ts`, which rewrites + seeds as it copies them and writes the README last, listing them. +5. CLI options, the guides, this spec and its verification. +6. Tests first: domain rules, the seed and end scenario through the export + and the draft, the export's scrub with a dconf database and a binary seed, + apt folders from a stub and from real `apt-config`, the launch environment + and the recorded host on Linux, and the CLI options; then Mousepad in the + Ubuntu 24.04 container. diff --git a/docs/specs/desktop-app-environments/capture-for-building/spec.md b/docs/specs/desktop-app-environments/capture-for-building/spec.md new file mode 100644 index 0000000000..acf5dcaa71 --- /dev/null +++ b/docs/specs/desktop-app-environments/capture-for-building/spec.md @@ -0,0 +1,74 @@ +# Capture for building + +## Outcome + +REA's capture, state view, task draft and export fit a build in which an +agent drives the application inside an agentenv computer, makes the seed and +then the change in one capture, and exports a bundle that can be shared. The +seed is the state at a checkpoint the agent names rather than the state before +launch; the capture finds the package's sources from lists an ordinary user +downloaded; the application does not inherit the computer's control token; +and the bundle holds neither the capture host's name nor its user's. + +## Requirements + +- Checkpoints: the export takes `--seed-checkpoint` and `--end-checkpoint`, + and the task draft the same two options. The seed checkpoint chooses the + seed files, their text formats, the unseeded files and the dconf keys; the + end checkpoint is where a task's checks must all pass, the seed where at + least one must fail; the draft compares the two. A file's state at a + checkpoint is its last version recorded at or before it. Without the + options they are the baseline and the capture's last checkpoint, so earlier + captures export and draft as before. A name the capture does not hold + exactly once, and an end before the seed, are refused naming the option. + The export reports both names in `checkpoints`, and the draft in its own + `checkpoints` as `baseline` and `final`. +- apt folders: the lists folder, the trusted parts folder and the trusted + keyring are where `apt-config shell` resolves `Dir::State::Lists/d`, + `Dir::Etc::TrustedParts/d` and `Dir::Etc::Trusted/f`, so an `APT_CONFIG` + file that moves them is followed; each is apt's default when `apt-config` is + missing, fails or names no absolute path. +- Launch environment: the application gets REA's environment without any + variable whose name starts with `AGENTENV_`, plus the run token. +- Host identity: the capture records `host.hostname` and `host.user`, each + optional, so captures written before parse; one is left out when the system + cannot say. +- Scrub: the export replaces `user@host` with `user@localhost`, the host name + as a whole name with `localhost`, and the captured home where a whole path + segment ends after it with `/home/user`, in seeded text files read as + UTF-8, in UTF-16 seeds with a byte-order mark, and in the dconf keys. A + host named `localhost` keeps that name, but its captured `user@localhost` + is still replaced. The user name alone is never replaced. JSON is scrubbed + by its string values, decoded, never its member names, and XML by its + decoded character data and attribute values, plus comments and CDATA, so + neither's syntax or names change and escapes hide nothing; a UTF-16 seed is + scrubbed by the format its text would have in UTF-8. dconf keys are + scrubbed by their decoded GVariant strings, never group names, keys, + dictionary keys or other values, and a value that does not parse or holds + an object path or signature ships as captured. A rewritten seed is a + generated file with its own SHA-256. The class's `SEED_HOME` is `/home/user`, which it respells + to the computer's home at boot as before. A capture whose home is already + `/home/user`, as inside a computer, has no home to replace. A capture + without the host's name and user has only its home replaced, with a + limitation. +- Leftovers: every shipped file, generated ones included, is searched in its + path and content, escapes decoded, for the host name and `user@host` + anywhere and the captured home as a whole path, with the replacements set + aside. `scrubbed_files` lists the rewritten seeds and `unscrubbed_files` + every file found, by bundle path; the README lists both. +- Tasks: checks whose values name `user@host` or the host name by that rule + are refused, like those that name the captured home, but not for their + field names, kind or id; and the checks that must fail on the seed are + evaluated on the seed as the bundle ships it. A value check is refused as + pointing into text only when its file is text at the seed or the end + checkpoint, so versions before or after them do not count. + +## Non-goals + +- No MCP tools, no build computer, no control channel with acknowledgements + and no witness; later steps own those. +- No change to captured Evidence or the state view's views: they keep the + captured bytes, and only the bundle is scrubbed. +- No rewriting of binary files, SQLite databases or file names, and no + replacement of the user name standing alone. +- No change to the draft's field names, `baseline` and `final`. diff --git a/docs/specs/desktop-app-environments/capture-for-building/verification.md b/docs/specs/desktop-app-environments/capture-for-building/verification.md new file mode 100644 index 0000000000..0adce414ee --- /dev/null +++ b/docs/specs/desktop-app-environments/capture-for-building/verification.md @@ -0,0 +1,186 @@ +# Verification + +Run on 2026-11-05 on top of the namespaced XML and capture fixes branches: +gates and source tests on macOS 26.5 with Node v22.23.1 and Python 3.11.14; +Linux tests and the end-to-end capture in an Ubuntu 24.04.5 arm64 container +with Node v24.22.1, Python 3.12.3, apt 2.8.3, Xvfb, xfwm4 and Mousepad +0.6.1-1build2, run as the ordinary user `ada` on a container whose host name +is its id. + +## Gates + +- `npm run typecheck`, `format:check`, `build:cached`, `knip`, `jscpd`, + `docs:check`, `docs:build`, `verify:test-discovery` and + `verify:architecture-guards`: passed. `lint` and `lint:typed` fail only on + main's own `tests/acceptance/webSourceLocationCli.test.ts`, with no + diagnostics in the touched files. Of the two clones jscpd lists in touched + files, one is a new test's nine-line import block and the other a pair of + cancellation tests already in `DesktopApplicationCapture.test.ts`. +- `npx vitest run src/domain/desktop src/application/desktop src/desktop` + with `tests/boundary/providers/desktop*.test.ts` and the desktop CLI + boundary tests `cliStartupImports`, `desktopCaptureCli`, + `desktopApplicationCli`, `cliInputSchemaRequired` and `cliToolCoverage`: + 468 passed and 20 skipped on macOS (the Linux-only cases); 485 passed and 3 + skipped in the container. + +## Tests + +Each new test fails on the code before this change, run from that code with +only the new tests and fixtures added: on macOS for the source and CLI tests, +in the container for the three Linux-only ones. + +- The scenario: a capture where the app saves a new note before `seed`, edits + it before `end`, and writes its settings as it exits. Exported with + `--seed-checkpoint seed --end-checkpoint end`, the bundle seeds the note as + saved at `seed` and the settings as they were then, and a task whose check + of the edit fails at `seed` and passes at `end` is kept. A check that holds + only at `exit` is refused with the end at `end` and kept with the end left + to default. Without the options the seed is the baseline, without the note. + The draft between the two lists only the note, with `eggs` added. An + unknown name and an end before the seed are refused naming the option, + under the export's and the draft's own operations, and write no bundle. + The same options through the public CLI, with a refusal's structured + issue. +- The scrub's rules: Geany's `pref_template_mail=ada@e81232be457d` becomes + `user@localhost` while `pref_template_developer=ada` stays; the host name + alone becomes `localhost` but not inside `xe81232be457d`, before `-2` or in + `e81232be457d.local`, and a host named `ubuntu` leaves + `ports.ubuntu.com/ubuntu-ports` alone; the home where a path segment ends, + not `/home/adam` or `/home/ada.bak`; only the home for a capture without + the host's name and user; a UTF-8 byte-order mark kept; UTF-16 seeds in + both byte orders; leftovers found in a binary database and in + `e81232be457d.local`; nothing to replace in a capture whose home is + `/home/user`. +- Through the export: a capture whose settings file, SQLite database and + dconf keys name the host and user. No bundle file but the database holds + the host name, `ada@` or `/home/ada`; the settings seed is rewritten, the + class holds `SEED_HOME = "/home/user"` and the scrubbed dconf keys, and the + result and README list the rewritten settings and the database. Checks + naming `ada@e81232be457d` or the host name are refused; a capture written + before the host was recorded still parses and has its home replaced. +- apt folders: lists in the folder a stubbed `apt-config` names, with a + quoted `'` in its name, are found, and the default folder is used when + `apt-config` fails or is missing (adapter test); on Linux, real + `apt-config` under an `APT_CONFIG` file resolves `Dir::State::Lists` + relative to `Dir::State`, and a missing `apt-config` gives the defaults. +- Linux: a launched shell's environment has no `AGENTENV_*` variable while + it keeps the others and the run token; a capture of the fake application + records the host's name and user as Node reads them. + +## Mousepad end to end + +As `ada`, with no apt lists in `/var/lib/apt/lists`, an `apt-get update` +under an `APT_CONFIG` naming `/tmp/apt-user/lists` and `/tmp/apt-user/cache` +wrote 15 Packages lists there. With that `APT_CONFIG` and +`AGENTENV_CONTROL_TOKEN` set, `capture-desktop-application --desktop-entry +org.xfce.mousepad` ran while `xdotool` typed `milk` and +`from ada@`, saved the file as `~/Documents/list.txt`, sent +`checkpoint seed`, typed `eggs`, saved, sent `checkpoint end` and then +`stop`. + +- Mousepad's environment held the run token and no `AGENTENV_*` variable. +- The package is `mousepad` 0.6.1-1build2 from + `http://ports.ubuntu.com/ubuntu-ports noble/universe arm64 Packages`, origin + `Ubuntu`, with `package_lists_present` true and no apt limitation. +- `host` records `/home/ada`, the container's host name and `ada`; the + checkpoints are `baseline`, `seed`, `end`, `stop` and `exit`. +- The draft between `seed` and `end` lists `Documents/list.txt` with `eggs` + added and suggests `lines_present` of `eggs`. +- The export with `--seed-checkpoint seed --end-checkpoint end` and that check + kept the task (`failing_on_seed: ["eggs"]`), seeded `list.txt` as + `milk\nfrom user@localhost\n` and listed it in `scrubbed_files`; + `unscrubbed_files` is empty and no bundle file holds the host name, `ada@` + or `/home/ada`. The dconf keys' recent file is + `file:///home/user/Documents/list.txt`. A task checking the line + `from ada@` was refused, and the export without options seeded + nothing, since the note did not exist at the baseline. +- The exported module's own `_seed_home` wrote `list.txt` unchanged into + another home and respelled the dconf keys' path to it. + `python -m agentenv_protocol.computer._declare` of agentenv-framework + 0.9.1431 declared the class, and `agent-env run --task smoke --dry-run` + planned the deploy. + +## Review fixes + +Run on 2026-11-05 on the same toolchains, after the first review. Each new +test failed on the reviewed head, with only the new tests added, and passes +now. + +- A task's seed checks read the seed as the bundle ships it. A mail setting + changed from `ada@e81232be457d` to `user@localhost` already reads + `user@localhost` in the scrubbed seed, so its check is no longer counted as + failing there: `failing_on_seed` went from `mail` and `note` to `note`. +- JSON is scrubbed by its strings' decoded values, so a host named `true` + leaves `{"enabled": true}` valid and a `\u0065` escape no longer hides the + host; an untouched string keeps its escapes. XML is scrubbed in its decoded + character data and attribute values, comments and CDATA, so a host named + `item` leaves `` alone and `e` references hide nothing; the + output is still read as XML. +- One search for what is left covers every shipped file, in its path and its + content: a clean seed named `e81232be457d-notes.txt`, the class module + whose dconf keys hold `ada@e81232be457d.local` and the README that lists + them are listed in `unscrubbed_files`, which now names bundle paths and + equals an independent search of the bundle. A task check holding + `e81232be457d.local` is refused by the same rule. The replacements are set + aside, so a host named `host` is not found in `localhost`. +- A host named `localhost` keeps that name, but `ada@localhost` becomes + `user@localhost`, and a binary file holding it is reported. +- A second review found member names rewritten too: on a host named + `ubuntu`, `{"ubuntu": 1, "localhost": 2}` got two `localhost` keys. Names + are now kept, an escaped one before a colon on the next line included, and + a seed whose JSON key holds the host ships as captured and is listed in + `unscrubbed_files`. +- The same review found that the regular expression that found JSON strings + threw `RangeError: Maximum call stack size exceeded` on one string of + 16,000,000 characters, which a 64 MiB capture can retain, so such a seed + stopped the export. The scrub now walks the document one string at a + time, and that seed is scrubbed in about 20 ms. The XML expressions, run on + a comment, CDATA section, processing instruction, attribute value and text + of the same length, finished within 6 ms each. + +The gates passed as before, `lint` and `lint:typed` again failing only on +main's `webSourceLocationCli.test.ts` with no diagnostics in this change's +files; the desktop tests ran 483 passed and 20 skipped on macOS, and 500 +passed and 3 skipped in the container, where the Mousepad run above gave the +same result, its rewritten seed now listed by bundle path. + +A third review found four more. Each new test failed on the reviewed head and +passes now; `src/domain/desktop`, `src/application/desktop` and +`tests/boundary/providers/desktop` ran 404 passed and 11 skipped on macOS. + +- dconf keys were scrubbed as one text, so on a host named `true` the setting + `wrap=true` became `wrap=localhost`, which `dconf compile` refuses, and + keys and group names could be renamed. Only decoded GVariant strings change + now; a value that does not parse ships as captured and the class module is + listed in `unscrubbed_files`. +- A task's check was searched as serialized JSON, so on a host named `file` + or `kind` every check was refused for its field names. Only its values are + searched now, not its field names, kind or id. +- A value check was refused when its file was text at any recorded version; + an empty `.json` before `seed` or after `end` refused a task whose file is + JSON at both. Only the seed, as shipped, and the end are read now. +- UTF-16 JSON and XML were scrubbed as plain text, so on a host named + `ubuntu` `{"ubuntu": 1, "localhost": 2}` got two `localhost` keys. They are + scrubbed by the format their text would have in UTF-8, in their own byte + order. + +## UTF-16 format detection + +- A decoded UTF-16 seed's scrubber is chosen by a syntax pass that builds + no value or document (`textSyntaxFormat`: the JSON token scan, and the + XML parser's events with only the open elements kept), and the XML + scrubber copies out only changed spans. A UTF-16 XML seed of a million + elements is scrubbed in a 24 MiB heap, where the previous code needed + more than 768 MiB; `desktopStateHeap.test.ts` runs it in 32 MiB and failed + on the previous code. On 200,000 random XML-like inputs and 80 UTF-16 + cases the output is byte-identical to the previous code's. + +## Limits of this verification + +- The bundle was not run in an agentenv computer, and REA was not run inside + one; the build computer is a later step. +- Geany's leak is covered by a fixture with its line, not by capturing + Geany. No real application was captured with a UTF-16 seed or a binary + file holding the host name. +- The apt-config fallback for a missing command and a failing one is covered + by the adapter test, and the real command only on Ubuntu 24.04. diff --git a/docs/specs/desktop-app-environments/capture/spec.md b/docs/specs/desktop-app-environments/capture/spec.md index 7e20044794..586b3abffe 100644 --- a/docs/specs/desktop-app-environments/capture/spec.md +++ b/docs/specs/desktop-app-environments/capture/spec.md @@ -44,12 +44,28 @@ folder to build the environment; this step builds nothing. folders also name the state directories), and identifies the package: snap, flatpak and AppImage from paths (and the AppImage magic, and a `flatpak run` launch), otherwise - the dpkg owner with its version, architecture, the apt sources listing the - installed version and the `o=` origin apt reports for each source. It marks + the dpkg owner of the launched desktop entry, or else of the executable, + with its version, architecture, the apt sources listing the + installed version and the `o=` origin apt reports for each source. The + entry's package is the one that installs the application: LibreOffice + Writer's entry belongs to `libreoffice-writer`, while the `libreoffice` it + runs belongs to `libreoffice-common`, which does not depend on Writer. When + the two differ, `executable_package` names the executable's, with + `executable_package_reason` when neither the Ubuntu 24.04 archive nor a + declared vendor repository offers it or apt could not say; each vendor + repository records in `supplies` the library packages and executable's + package only vendor repositories offer that its package files list. When + a package owns the entry but none owns the program, nor the file it resolves + to, `executable_package_reason` says so without `executable_package`. A + `dpkg-query` lookup that does not finish or exits with an error leaves the + package + `unknown`, with a limitation naming the path. It marks Electron applications by the fuse sentinel or `resources/app.asar`, and lists candidate state directories under the XDG folders and dot-folders named after the package, executable, desktop entry's full id and last part, - and name; a `flatpak run` launch uses the application id (its first operand + and name, and every folder present there whose name is one of those in + another letter case, such as GIMP's `~/.config/GIMP`; a `flatpak run` + launch uses the application id (its first operand after options and their values) instead of the launcher's name and adds `~/.var/app/`. An `--executable` runs from the caller's directory, which also resolves a relative path to it. diff --git a/docs/specs/desktop-app-environments/capture/tasks.md b/docs/specs/desktop-app-environments/capture/tasks.md index a4ac5e4747..05b57f9db5 100644 --- a/docs/specs/desktop-app-environments/capture/tasks.md +++ b/docs/specs/desktop-app-environments/capture/tasks.md @@ -10,3 +10,5 @@ - [x] Add unit, adapter, service and Linux capture tests. - [x] Capture Mousepad end to end in a container. - [x] Run the gates and record verification. +- [x] Take a Debian package from the launched desktop entry's owner (LibreOffice Writer). +- [x] Find state folders named after the application in another letter case (GIMP). diff --git a/docs/specs/desktop-app-environments/capture/verification.md b/docs/specs/desktop-app-environments/capture/verification.md index a4530430f2..1a1aea1553 100644 --- a/docs/specs/desktop-app-environments/capture/verification.md +++ b/docs/specs/desktop-app-environments/capture/verification.md @@ -58,6 +58,76 @@ nothing to the output folder. Before that refusal existed, the same setup showed Mousepad handing the launch to the running instance, which opened a new tab in its existing window while the launched process exited at once. +## Fixes after the five-application test + +Run on 2026-11-05 on macOS 26.5 with Node v22.23.1. A test that captured and +exported five applications found two inventory gaps: + +- LibreOffice Writer, captured with `--desktop-entry libreoffice-writer`, + recorded `libreoffice-common`, the owner of the `/usr/bin/libreoffice` its + entry runs, which does not depend on Writer. The exported computer installed + only that package and failed its readiness check with + `no window with class 'libreoffice-writer'`. The package is now the entry's + owner, with the executable's in `executable_package`. Adapter tests on a + fake dpkg host whose entry and program have different owners cover an entry + launch, an `--executable` launch, an entry no package owns and a + `--package-file` matched against the entry's package. +- GIMP 2.10 keeps its profile in `~/.config/GIMP/2.10`, which no candidate + name (`gimp`) matched, so it was neither seeded nor reset. A folder in the + XDG folders or the home whose name is one of the candidate names in another + letter case is now a state directory, and the state view counts a path in a + folder beside a state directory that differs from it only in case as in it. + An adapter test with GIMP's layout and a domain test cover both, and leave + out `GIMPshop`, `MyGIMP` and a nested `Other/GIMP`. + +Review of those fixes found three more gaps, each now covered by a test that +failed on the first fix: + +- The entry's package need not depend on the program's (`vim-common` owns + Vim's entry, `vim` its program), so the export now installs + `executable_package` by name beside the app's package, for archive, vendor + repository and package-file installs alike. The capture checks it by the + library packages' rule and records `executable_package_reason` when neither + the archive nor a declared repository offers it, or apt could not say; the + export then refuses. Adapter, domain and export service tests cover the + record, the three install paths, the refusal and the rendered + `Packages(apt=[...])`. +- A profile folder created during the capture in another letter case than its + candidate was application state but owned as the folder of its first file + (`.config/GIMP/2.10`), so later files elsewhere in it escaped `data/get` and + `data/reset`. Owned paths now use the same match as roles and own + `.config/GIMP` in the files' spelling; a domain test and an export service + test of a created profile cover it. +- A `dpkg-query -S` that timed out or exited with an error counted as "no + owner", so a failed entry lookup quietly recorded the program's package. It + now leaves the package `unknown` with a limitation naming the path, for the + entry and the executable, after a timeout or an exit status of 2. + +A later review found that both the program's package and library packages +were approved at capture against every repository with a verified key, while +the content budget can then drop a repository's key and the export leaves that +repository out. Repositories now record what they `supply`, and the export +leaves out library packages, and refuses a program's package, that only +repositories it leaves out supply. A capture adapter test, two export domain +tests and a key-retention service test that drops one of two keys under the +per-file limit cover it. + +A third review found that an entry owned by a package whose program no +package owns, such as a `~/.local/bin` program found first on `PATH`, was +recorded as the entry's package alone, which the export then installed though +it does not bring that program. The capture now records that no package owns +the program, and the export refuses it. An adapter test, an export domain test +and an export service test cover it, and an adapter test keeps a program link +whose resolved file dpkg owns counted as owned. + +Each new test failed on the code before its fix, run in a copy of that commit +with only the new tests added. `npm run typecheck`, `lint`, `lint:typed` +(no diagnostics in the touched files), `format:check`, `build:cached`, +`knip`, `jscpd`, `docs:check`, `docs:build`, `verify:test-discovery` and +`verify:architecture-guards` passed, and +`npx vitest run src/domain/desktop src/application/desktop src/desktop tests/boundary/cli/cliStartupImports.test.ts` +ran 351 passed and 18 skipped. Neither application was captured again. + ## Limits of this verification - Only Mousepad was captured end to end, on arm64 in a container. GnuCash, diff --git a/docs/specs/desktop-app-environments/document-views/plan.md b/docs/specs/desktop-app-environments/document-views/plan.md new file mode 100644 index 0000000000..73e31d6bf9 --- /dev/null +++ b/docs/specs/desktop-app-environments/document-views/plan.md @@ -0,0 +1,25 @@ +# Plan + +1. Format and reader: `zip` detected by its local file header; the text, JSON, + key file and XML readers shared as `textualView`, so a file and an entry + read alike; a domain module for the entry limits, the entry view and the + archive view, and an application reader that walks the verified bytes with + zip.js under the options and name decoding `ZipArtifactReader` uses. +2. Selectors: `locatePointer` in `src/domain/desktop/viewPointer.ts`, used by + REA's evaluator and the draft, and `_locate` in the exported verifier, with + the same messages; `tolerance` in the task schema and both evaluators. +3. Changes and draft: member keys chosen per array from the candidate + selectors of both versions, a keyed walk for zip views in `compareTrees`, + and a walk that notes a member added or removed whole once for the draft's + archive suggestions. +4. Exported module: `_zip` and `_entry_view` beside `_view`, with + `zipfile`, `zlib` and `hashlib` imported at the top. +5. Docs and tests: real FreeCAD 0.21 and 1.0 `Document.xml` files zipped into + `.FCStd` archives and checked by both evaluators, an `.odt` built in the + test, archives crafted for each limit, and the shared check cases run + through both evaluators. +6. Namespaced XML: the exported module reads XML with `expat` rather than + `ElementTree`, one pass with namespace processing to check the document and + one without to build the view as REA's DOM does, names and text nodes + alike; the export's namespace refusal and the draft's namespace gate go. + Parity tests read real Inkscape and LibreOffice Writer saves. diff --git a/docs/specs/desktop-app-environments/document-views/spec.md b/docs/specs/desktop-app-environments/document-views/spec.md new file mode 100644 index 0000000000..9b06107c1d --- /dev/null +++ b/docs/specs/desktop-app-environments/document-views/spec.md @@ -0,0 +1,106 @@ +# Documents saved as zip archives + +## Outcome + +A document an application saves as a zip archive, first FreeCAD's `.FCStd`, +is read into a view of its entries, so a task can check what the user did in +it. Checks name the XML elements they read by tag and attribute rather than by +position, so the same check holds across application versions that write the +document in another order, and compare numbers written as text within a +tolerance. XML that uses namespaces, as OpenDocument's `content.xml` and +Inkscape's SVG do, is named by the prefixes the document spells. Both +evaluators, REA's and the exported verifier's, agree. + +## Requirements + +- Format: a file starting with a zip local file header (`PK\x03\x04`) is + `zip`, whatever its name. Its view is + `{entries, entry_count, truncated}`: the file entries in archive order, + directories left out, each `{name, size, sha256, format, view, view_error}`. +- Reading: from the retained bytes already checked against their digest, in + memory, with REA's zip reader options (CRC-32 and overlap checks, CP437 or + UTF-8 names); nothing is extracted. Each entry's format and view come from + the state view's own readers and limits, with the entry name in place of the + file name. A SQLite or dconf database, or an archive, inside an archive is + not read and says so. +- Limits: at most 1,000 entries listed (`entry_count` counts all); an entry is + read only when it is unencrypted, stored or deflated, at most 16 MiB + uncompressed and at most 100 times its compressed size, and while the + archive has read at most 64 MiB. An entry over a limit, or whose data fails + its checks, has `sha256`, `format` and `view` null and a `view_error`. +- Exported module: `data/get` gives the same view, read with Python's + `zipfile` from the file in place with the same limits, order of checks and + wording; parity tests compare the two readers on the same archives. +- XML names: both readers name elements and attributes as the document spells + them, prefix included (`text:p`, `sodipodi:docname`), an element in a + default namespace by its bare name (`svg`), and keep namespace declarations + as the attributes `xmlns` and `xmlns:PREFIX`, in document order. The + exported module reads XML with `expat` twice: with namespace processing, to + refuse what REA's parser refuses, then without, for the names. Text splits + into nodes as REA's DOM splits it: at CDATA sections, comments and + processing instructions, an empty CDATA section joining the text around it. +- Parsed views: a JSON or key file entry or file over the 4 MiB limit is + refused before its value is built, in both readers. Its format is told + apart from text by a token scan that builds no value and holds a byte per + open container, which the exported reader also uses when Python's parser + reaches its recursion limit, so deeply nested text is JSON only when all of + it is. +- Selectors: on an array, a pointer segment `[KEY=VALUE]` names the member + whose string field `KEY` is `VALUE`, `[@KEY=VALUE]` the member whose + `attributes` hold it, and conditions can follow one another. A selector must + match exactly one member; several, or none before the last segment, fail the + check with a message naming the selector. None at the last segment names no + value, so `value_absent` passes and `value_equals` fails. +- Tolerance: `value_equals` takes an optional non-negative `tolerance`, with a + number `value`, and passes when the value found is a number, or a decimal + numeral string, at most `tolerance` from it. +- Truncation: a check decided by what a zip view leaves out depends on the + entry its pointer goes into: that entry's view missing or truncated, or the + listing truncated when the entry is not listed. A pointer whose selector + searches an array the view may hold only part of (any array of a truncated + XML view or entry view, or a cut listing) fails as undecided in both + evaluators. A check into the view of an entry without one (binary, or with a + `view_error`) fails in both evaluators, while checks on its metadata read; + the draft checks only entries with a view, and an absence only where the + entry still has one. +- Changes: `values` between zip versions pair array members one to one, by + the first selector naming a member alone in its version and one unpaired + member of the other (entries by name, XML elements by tag and attribute), + even when a selector before it names nothing there, then by position; a + member left unpaired is keyed by a selector naming it alone and nothing in + the other version. Identity comes first: an element with identifying + attributes (`name`, `id`, `xid`, or a prefixed `id` such as `xml:id`) is + keyed by its tag and those alone, so one whose identifiers name nothing in + the other version is added or removed; its other attributes (`type`, + `color`) key only elements without one. A selector is used only when it + matches one sibling, counted over every attribute of every sibling, + identified ones included. Selectors are spelled only once chosen, so a + long tag is not copied per attribute. A comparison is incomplete when + either view leaves something out. +- Draft: for a zip document, checks inside its entries' JSON, key file and + XML views, through no object or array by position and no array a view may + cut: an element added whole exists, one removed whole is absent, then values + changed, added and removed, with an entry or a JSON value added whole checked + through its members. +- Export refusals: a `tolerance` whose value is not a number. A pointer into + XML that uses namespaces is accepted, in a file or an archive entry. +- Reading: the exported reader reads an entry in bounded chunks, at most one + byte past its declared size, so a forged small size cannot make it expand + the rest of the stream. +- Directory: both readers read the central directory themselves, a record at a + time through the Zip64 end record whenever a Zip64 locator precedes the end + record, whatever the end record holds, count the file entries (directories told + apart as zip.js does: a final slash, a Unix directory mode, or the MS-DOS + attribute), and give zip.js or `zipfile` only the first 1,000 records, so + memory does not grow with the number of entries. + +## Non-goals + +- No reading of an archive inside an archive, or of a database inside one. +- No names by namespace URI: a check names the prefix the document uses, so + one written for a document that binds another prefix to the same namespace + does not match it. +- No other compression methods (bzip2, LZMA, Zstandard) and no encrypted + entries. +- No selectors over non-string values, nested fields other than `attributes`, + or values holding `][`. diff --git a/docs/specs/desktop-app-environments/document-views/verification.md b/docs/specs/desktop-app-environments/document-views/verification.md new file mode 100644 index 0000000000..1e4a4bb2f8 --- /dev/null +++ b/docs/specs/desktop-app-environments/document-views/verification.md @@ -0,0 +1,220 @@ +# Verification + +Run on 2026-11-05 on top of the vendor .deb files branch, on macOS with Node +v22.23.1 and Python 3.11.14. + +## Gates + +- `npm run typecheck`, `lint`, `lint:typed`, `format:check`, `knip` (after + `build:cached`), `jscpd`, `docs:check`, `docs:build`, + `verify:test-discovery` and `verify:architecture-guards`: passed, with no + lint or typed-lint diagnostics in the touched files. The one clone jscpd + lists in a touched file, `DesktopApplicationState.ts` against + `GpgvKeyCheck.ts`, was there before. After main was merged in, `lint` and + `lint:typed` fail only on main's own `tests/acceptance/webSourceLocationCli.test.ts`. +- `npx vitest run src/domain/desktop src/application/desktop src/desktop` with + `tests/boundary/providers/desktopExportPython.test.ts`, + `desktopTaskPython.test.ts` and `tests/boundary/cli/cliStartupImports.test.ts`: + 377 passed and 18 skipped (Linux-only cases). The new + `tests/boundary/providers/desktopZipPython.test.ts`: 13 passed, under + Python 3.9.6, 3.10, 3.11, 3.12, 3.13 and 3.14 alike; + `desktopStateHeap.test.ts`: 2 passed, and `desktopZipHeap.test.ts`: 1. The desktop + CLI boundary tests `desktopApplicationCli`, `cliInputSchemaRequired` and + `cliToolCoverage`: 28 passed. +- Namespaced XML, on the same day and toolchain: the same gates passed, with + no lint, typed-lint or jscpd findings in the touched files, and the desktop + tests with the four provider parity files, `desktopXmlPython.test.ts` among + them: 370 passed and 18 skipped. + +## Tests + +Each new test fails on the code before this change, run from a copy of that +commit with only the new tests and fixtures added; the tests already in the +touched files still pass there. + +- Parity of the two zip readers, REA's and the exported module's, on the same + archives: entries in order with JSON, key file, XML, text, UTF-8-named and + binary entries, a directory left out, and an archive, a SQLite database and + an inexact JSON number inside, each not read for its reason; an encrypted + entry, bzip2, a declared 17 MiB entry, a 943-times expansion, a bad CRC-32 + and a short declared size; a damaged archive; 1,002 entries; and the 64 MiB + archive budget through both readers' refusal functions. +- FreeCAD: the real `Document.xml` of FreeCAD 0.21 and 1.0, before and after + the hole, zipped into `.FCStd` archives. Both readers give the same views, + and both evaluators give the same results and descriptions: a `Part::Cut` + exists, the `Hole` radius is 5 ± 0.01 and the cut's tool links `Hole` all + fail before the edit and pass after it, in both versions, by the same + pointers. +- OpenDocument: an `.odt` built in the test reads as `mimetype` text and + `content.xml` and `META-INF/manifest.xml` as XML, with the paragraph's text + in the view; the readers give the same view, namespaced names included, and + the export keeps a check of the paragraph by `[name=text:p]`. +- Namespaced XML, in `desktopXmlPython.test.ts`: the real saves of an + Inkscape 1.2.2 poster and a LibreOffice 24.2 Writer flyer, before and after + one edit, read alike by both readers, the SVG as a file and `content.xml` + in an `.odt` built in the test. Both evaluators give the same results and + descriptions: the headline `[name=text][@id=headline]` under the layer + `[@inkscape:label=Layer 1]` and the `[@text:style-name=Subtitle]` date fail + before the edit and pass after it, and `inkscape:document-units` passes in + both. Built documents cover `xmlns` declarations in document order, an + undeclared default namespace, `xml:` attributes, CDATA, comments and + processing instructions, what both parsers refuse (an undeclared prefix, + one attribute twice under two prefixes, a prefix bound to `""`, two colons + in a name, a colon in a processing instruction's target, a DOCTYPE), + and the node and depth limits on prefixed names. +- Selectors and tolerance in the shared check cases both evaluators run: one + member matched, several, none before or at the last segment, a malformed + selector, an escaped `/` in an entry name, an entry by index, a selector + over strings in an array, per-entry truncation, an entry the listing left + out; numbers and numeral strings within a tolerance, outside it, a string + and a boolean that are not numbers, and `tolerance: 0`. +- `locatePointer` and the member keys: the most general selector unique among + siblings, passed over when it names several members of the other version, + and values that hold `][` or look like a time left out. +- The keyed zip diff: an object inserted before another shifts nothing, and a + comparison is incomplete when an entry is unread, truncated or not listed. +- The draft: an archive's added and removed elements and changed values by + selector, inside namespaced XML too, where a paragraph is named by + `[name=text:p][@text:style-name=Subtitle]`; and through the application, a + capture with a FreeCAD document and an `.odt` whose draft, with a check of + the list's paragraph, the export then accepts. + +## Review fixes + +Each fix has a regression test that fails on the reviewed commit and passes +after it: + +- A selector over an array a view may hold only part of is undecided in both + evaluators: a truncated XML view, a cut listing searched by entry name, and + a truncated entry's XML, beside positions into the same views that stay + decided. +- The zip diff pairs members one to one: an element whose attributes split + across two new siblings leaves one paired and the other added. +- The draft checks an entry or a JSON value added whole through its members, + and suggests nothing that reaches an element by position, the last step + included. +- The export accepts a check on the whole listing whose value holds + namespaced XML, since both readers name it alike. +- The exported reader keeps a JSON entry nested 1,100 deep on that entry, + with its siblings read, and refuses JSON nested more than 500 levels as REA + does, 501 levels read and 502 refused, in a file or an entry. +- The exported reader reports an entry damaged when its declared size is + short and its checksum is the prefix's, deflated or stored, as REA does. +- An array that becomes an object with the same keys, or the reverse, is a + changed value, with nothing in it added or removed, so the draft suggests no + absence the end state contradicts. +- The draft checks an empty array or object added to a JSON entry as it is. +- An entry REA could not read, whose `view` is therefore `null`, gets no + drafted check, and both evaluators fail `value_equals null` into its view, + while a JSON entry holding `null` still passes it. +- The exported reader's depth check holds one iterator per level: on a + 4,000,001-byte array of 800,000 nulls, `_text_view` peaks at 6.7 MB of + traced allocations, as before the check, against 58.2 MB when it queued + every member. +- A binary entry fails `value_equals null` and `value_absent` into its view in + both evaluators, its `size` still checked, and the draft suggests no absence + in an entry that ends binary. +- REA's own depth check (`deeperThan`, from the state view) also holds one + iterator per level: on 800,000 nulls it grows the heap by 7.3 MB of + short-lived iterator results, against 72.7 MB when it queued a pair per + member, and runs under an 18 MiB old-space limit where the queue needed more + than 64 MiB. A test runs it under a 32 MiB limit. +- The exported reader reads an entry in 64 KiB chunks up to a byte past its + declared size: a 32,731-byte archive holding 32 MiB of zeros declared as one + byte peaks at 0.1 MiB of traced allocations, against 77.5 MiB when it read + the stream whole before clipping it, both reporting the entry damaged as REA + does. An 8.7 MiB text entry peaks at 48.5 MiB against 47.6 MiB. REA's zip.js + reader already stops at the first chunk past the declared size: reading that + archive grows the peak resident size by 0.8 MiB, as a one-byte archive does. +- A check of a namespaced entry's `size`, of its absence, or comparing the + entry whole exports, since both readers name namespaced XML alike. +- The diff finds a counterpart by the evaluator's own step: a bracketed key + against an array is a selector, so `{"[name=x]": 1}` becoming + `[{"name": "x"}]` reports nothing removed, in an archive or a JSON file, and + the draft suggests no absence of a value the selector still names. +- Both readers bound the central directory: an archive of 600,000 empty + entries (52.4 MiB) reads in a 24 MiB V8 old space against more than 48 MiB + when zip.js parsed every record, and grows peak RSS by 37 MiB against + 184 MiB; the exported reader peaks at 1.3 MiB of traced allocations against + 293.6 MiB, and at 1.3 MiB against 25.1 MiB for 50,000 entries. Directories + are told apart as zip.js tells them in both. +- Both readers follow a Zip64 locator whether or not the classic end record + holds sentinels, as `zipfile` does: an archive whose classic record keeps its + own counts, size and offset, which `zipfile` and zip.js both read, was + unreadable to both readers and now lists its entries in both. A last name + ending in bytes that only look like a locator keeps the classic values in + both, and the exported module gives `zipfile` real Zip64 end records for the + directory it shows it, so `zipfile` no longer refuses such an archive; the + parity tests pass under Python 3.9 to 3.14. +- A diff comparing an object of selector-shaped keys with an array indexes the + array once per set of selector fields: 2,000 keys read its members 4,000 + times against 4,002,000, and 8,000 and 20,000 keys diff in 19 and 31 ms + against 980 ms and 5.4 s. +- The keys are resolved together, their conditions sorted and de-duplicated, + holding only the values they ask for rather than an index of the array per + set of fields. 127 field combinations over 5,000 members grow the heap by + 5.5 MB against 68.0 MB, in 138 ms against 174 ms; 160 spellings of one + selector over 800 members by 4.9 MB against 75.9 MB, in 4 ms against + 388 ms, reading the members 1,600 times against 128,800. Both diff in a + 16 MiB V8 old space where they needed more than 48 MiB; a test runs them + under a 32 MiB limit. +- Member pairing keeps an element's tag apart from its attribute conditions + and spells a selector only once it is chosen: an element with a + 38,588-character tag and 2,000 attributes pairs with itself in 4 ms, + growing the heap by 1.1 MiB, against 7.2 s and 148 MiB when the tag was + copied into a selector per attribute. A test pairs two such elements under + a 32 MiB V8 old space. +- An element pairs by its identifiers first, and by a later selector both + versions share before one that names nothing in the other version keys it + as added: `` becoming `` beside another `item` is a changed `color` under + `[name=item][@id=1]`, in the diff and the draft, rather than one element + removed and another added. A FreeCAD `Hole` deleted and a `Hole001` added, + both `Part::Cylinder`, stay one removal and one addition rather than a + rename found by their shared `type`. An identified element's other + attributes still count against its siblings' selectors: `[@color=blue]` is + not reported removed when it moves to the identified `item`, and + `[@role=body]`, `[@role=second]` and an Inkscape group's + `[@inkscape:label=note]` key their elements rather than a `color`, `class` + or `fill` an identified sibling shares, which would match both. The real + FreeCAD 0.21 + and 1.0 documents, and the Inkscape, LibreOffice and AbiWord ones of the + branches built on this one, zipped as entries, give the same diffs, drafts + and member keys as before. +- Both readers refuse a JSON or key file over 4 MiB before building its value, + telling JSON from text by a token scan. REA's reader detects a + 4,200,004-byte array of empty objects in 58 ms, growing the heap by 4.2 MiB + (the decoded text), against 129 ms and 85.5 MiB when detection parsed it, + and a test reads it under a 32 MiB V8 old space. The exported reader's + entry peaks at 8.4 MB of traced allocations against 110.0 MB, a 16 MiB one + at 35.2 MB against 442.5 MB, and a 4.9 MB key file at 10.4 MB against + 86.7 MB; its scan takes 0.9 s and 3.5 s for those entries against 0.7 s and + 2.7 s for the parse. The exported reader now applies the limit to files as + REA does, where it read them whole before. +- The exported reader takes text nested past Python's recursion limit as JSON + only when the token scan finds all of it JSON, as REA does: 1,100 opening + brackets and 1,099 closing ones are text in both readers, where the export + called them JSON nested too deeply, and 1,100 of each are still refused for + their depth in both. + +## FreeCAD captures + +The real `.FCStd` files the FreeCAD investigation captured, from FreeCAD 0.21 +(Ubuntu 24.04 with the FreeCAD PPA) and 1.0 (Ubuntu 25.10), read by the built +CLI: + +- `inspect-desktop-application-state`: `plate.FCStd` is `zip` in every + version, 2 entries before and 15 or 16 after, with a complete `values` + change listing `/entries/[name=Document.xml]/view/root/children/[name=Objects]/children/[name=Object][@name=Hole]` + among the added values; the `.FCBak` backups are `zip` too. +- REA's evaluator and the exported module's, each on its own view of the + retained bytes: identical views, and the hole task's two checks fail before + and pass after in both versions. +- `export-desktop-application --task` on the 0.21 capture keeps the task with + both checks failing on the seed and writes the selectors and the tolerance + into `verify.py`. The 1.0 capture is not exported, as before this change: + its package installs only on Ubuntu 25.10. +- `describe-desktop-application-task` on the 0.21 capture drafts ten checks on + `plate.FCStd`, all by selector (the six objects and dependencies FreeCAD + added, then counts and statuses), and the export keeps that draft with all + ten failing on the seed. diff --git a/docs/specs/desktop-app-environments/export/spec.md b/docs/specs/desktop-app-environments/export/spec.md index fc6109a041..4b6e2722b8 100644 --- a/docs/specs/desktop-app-environments/export/spec.md +++ b/docs/specs/desktop-app-environments/export/spec.md @@ -16,11 +16,18 @@ state through `data/add`, `data/get` and `data/reset`. shared safe output tree. - It refuses applications not installed from a Debian package archive, captures without an application window, and environment names that are not portable. -- The class declares the apt package, the captured launch command with +- The class declares the apt package, and the package of the program the + desktop entry runs when another one owns it (refused when the capture found + no source the computer can install that package from, or only repositories + the export leaves out supply it, as library packages so supplied are left + out; a capture whose entry runs a program no package owns is refused), the + captured launch + command with arguments under the captured home respelled under `$HOME`, an exact window class match with a window ready check, `paths` from the state view's roles - (state directories of application state files and folders of documents, - nested ones collapsed) and the skill file. + (state directories of application state files, or a folder beside one that + differs only in letter case in the files' spelling, and folders of + documents, nested ones collapsed) and the skill file. - With paths, it declares all three data hooks and calls agentenv's helpers: seed the home once per boot then `keep_initial`, `add`, `reset`, and `get` plus a data part holding a typed JSON view of each file. Without paths it diff --git a/docs/specs/desktop-app-environments/state-view/spec.md b/docs/specs/desktop-app-environments/state-view/spec.md index c335a05d75..5a9ccb5846 100644 --- a/docs/specs/desktop-app-environments/state-view/spec.md +++ b/docs/specs/desktop-app-environments/state-view/spec.md @@ -15,6 +15,9 @@ generation reads the kinds of change. valid result; anything else is refused as invalid input. - Retained bytes are read without following links and used only when their SHA-256 matches the name they are stored under. +- A path in a folder beside a state directory whose name differs from it only + in letter case, such as GIMP's `~/.config/GIMP` beside `~/.config/gimp`, + lies in that state directory. - Each path gets the reasons the capture supports (state directory, open file, window title, selected path, dconf application keys, shared desktop location) and one role derived from them. @@ -28,14 +31,17 @@ generation reads the kinds of change. `dconf dump /` on a private file profile, keeping only groups whose path names the application; JSON up to 4 MiB and 500 levels with exact numbers; XML through the shared strict parser with DOCTYPEs - refused; key files up to 4 MiB whose every line parses; text; binary. + refused; key files up to 4 MiB whose every line parses; zip archives by + their local file header, as their entries with each entry's own format and + view ([document views](../document-views/spec.md)); text; binary. - Changes between versions are created, deleted, or lines, values, keys or rows with added, removed and changed lists (100 each, with counts); content for binary files and unreadable when a view is missing. Values are JSON pointers to every value, containers included, compared on one shared path so memory follows depth, and a pointer over 4096 characters is counted but not listed; keys are `group/key` with the key escaped as a JSON pointer - segment, so distinct entries never share a name. + segment, so distinct entries never share a name. Zip values name array + members by selector where one names a member in both versions. - The Evidence has operation `inspect_desktop_application_state`, predicate type `rea.desktop-application-state` and confidence `inferred`. diff --git a/docs/specs/desktop-app-environments/tasks/spec.md b/docs/specs/desktop-app-environments/tasks/spec.md index 1dbe40b2cf..f6d5a5f248 100644 --- a/docs/specs/desktop-app-environments/tasks/spec.md +++ b/docs/specs/desktop-app-environments/tasks/spec.md @@ -26,7 +26,8 @@ never runs code the agent wrote. and 1 to 32 `checks`, parsed by a zod schema in `src/domain/desktop/`. Check kinds: `file_present`, `file_absent`, `lines_present`, `lines_absent`, `line_count` (`exact` or `contains`; `equals`, `at_least` or `at_most`), - `value_equals` and `value_absent` (RFC 6901 pointer), `row_present`, + `value_equals` (optional numeric `tolerance`) and `value_absent` (RFC 6901 + pointer, with selectors on arrays), `row_present`, `row_absent` and `row_count` (SQLite table, `where` by column). Ids default to `check-N` and must be unique. - Semantics: an absent file reads as empty; a file without a view fails every @@ -37,10 +38,10 @@ never runs code the agent wrote. regular expressions: a pattern run on agent-written text can stall the verifier, and the two evaluators would read some differently. - Refusals, each naming the check: a file outside the owned paths or not - spelled as a home path; the empty pointer; a pointer not starting with `/`, - into text, or into XML using namespaces; a repeated line; a value that names the - captured home or looks like an ISO 8601 date and time; the draft's - placeholder prompt. + spelled as a home path; the empty pointer; a pointer not starting with `/` + or into text; a repeated line; a `tolerance` whose value is not a number; a + value that names the captured home or looks like an ISO 8601 date and time; + the draft's placeholder prompt. - `export-desktop-application --task FILE` evaluates the checks on the state view's first versions (the seed) and last versions (the end), refuses a task whose every check passes on the seed and a check that fails at the end, diff --git a/docs/specs/desktop-app-environments/tasks/verification.md b/docs/specs/desktop-app-environments/tasks/verification.md index e2c9777cbb..a5f229a02a 100644 --- a/docs/specs/desktop-app-environments/tasks/verification.md +++ b/docs/specs/desktop-app-environments/tasks/verification.md @@ -127,5 +127,6 @@ end to end, not how hard a task can be. - SQLite and dconf readers were not exercised on the host captures, which hold text files; SQLite checks are covered by synthetic views. -- The XML namespace refusal and the CDATA difference the export guide lists - are covered by code review only. +- Namespaced XML and CDATA, which the export once refused or read + differently, are covered by the document views' parity tests on real + Inkscape and LibreOffice saves, not by a host capture. diff --git a/src/application/desktop/DesktopApplicationCapture.test.ts b/src/application/desktop/DesktopApplicationCapture.test.ts index 5e7aee96b8..daa87bbbeb 100644 --- a/src/application/desktop/DesktopApplicationCapture.test.ts +++ b/src/application/desktop/DesktopApplicationCapture.test.ts @@ -8,6 +8,7 @@ import { writeFile, type FileHandle, } from "node:fs/promises"; +import { hostname, userInfo } from "node:os"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; @@ -256,6 +257,10 @@ describe.skipIf(process.platform !== "linux")( window_match: { class: "FakeApp", instance: "fakeapp" }, }); expect(capture.session.first_window_ms).not.toBeNull(); + expect(capture.host).toMatchObject({ + hostname: hostname(), + user: userInfo().username, + }); expect( capture.state.checkpoints.map((checkpoint) => checkpoint.name), ).toEqual(["baseline", "written", "stop", "exit"]); diff --git a/src/application/desktop/DesktopApplicationCapture.ts b/src/application/desktop/DesktopApplicationCapture.ts index 3e5edae98f..43876b9dcc 100644 --- a/src/application/desktop/DesktopApplicationCapture.ts +++ b/src/application/desktop/DesktopApplicationCapture.ts @@ -1,4 +1,4 @@ -import { arch } from "node:os"; +import { arch, hostname, userInfo } from "node:os"; import { dirname } from "node:path"; import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; @@ -84,6 +84,21 @@ export interface DesktopCaptureErrorOutput extends AnalysisErrorProjection { const unique = (paths: readonly string[]) => [...new Set(paths)]; +/** The host's name and the user's, each left out when the system cannot say, as for a user with no passwd entry. */ +const hostIdentity = (): { hostname?: string; user?: string } => { + const name = hostname(); + let user: string | undefined; + try { + user = userInfo().username; + } catch { + user = undefined; + } + return { + ...(name.length === 0 ? {} : { hostname: name }), + ...(user === undefined || user.length === 0 ? {} : { user }), + }; +}; + const limitationsFor = ( session: DesktopSession, recorder: CheckpointRecorder, @@ -273,6 +288,7 @@ const recordCapture = async ( architecture: arch(), display: admitted.display, home, + ...hostIdentity(), }, application, session, diff --git a/src/application/desktop/DesktopApplicationExport.test.ts b/src/application/desktop/DesktopApplicationExport.test.ts index 2e7dd68f24..bc12ee8c1f 100644 --- a/src/application/desktop/DesktopApplicationExport.test.ts +++ b/src/application/desktop/DesktopApplicationExport.test.ts @@ -306,6 +306,70 @@ const exportVendorCapture = async (local: DesktopApplicationCapture) => { return { result, directory }; }; +describe("exportDesktopApplication of a program in another package", () => { + it("installs the package of the program the desktop entry runs beside the entry's, or refuses when it cannot", async () => { + const local = capture(); + const withProgram = (reason?: string) => + exportCapture({ + ...local, + application: { + ...local.application, + package: { + ...local.application.package, + executable_package: "notes-bin", + ...(reason === undefined + ? {} + : { executable_package_reason: reason }), + }, + }, + }); + + const { result, directory } = await withProgram(); + const refused = await withProgram( + "apt-cache policy did not say where notes-bin comes from", + ); + + if (result instanceof Error) throw result; + expect( + await readFile(join(directory, "envs/notes/notes_app.py"), "utf8"), + ).toContain('packages=Packages(apt=["notes", "notes-bin"])'); + expect(await readFile(join(directory, "README.md"), "utf8")).toContain( + "`notes-bin`, which owns the program the desktop entry runs, is installed with it.", + ); + expect(refused.result).toMatchObject({ + _tag: "AnalysisInputError", + issues: [expect.objectContaining({ path: ["capture"] })], + }); + expect(JSON.stringify(refused.result)).toContain( + "the program it runs belongs to notes-bin, which the computer cannot install: apt-cache policy did not say where notes-bin comes from", + ); + expect(await exists(refused.directory)).toBe(false); + }); + + it("refuses a capture whose desktop entry runs a program no package owns, and writes nothing", async () => { + const local = capture(); + const { result, directory } = await exportCapture({ + ...local, + application: { + ...local.application, + package: { + ...local.application.package, + executable_package_reason: `no package owns ${HOME}/.local/bin/notes`, + }, + }, + }); + + expect(result).toMatchObject({ + _tag: "AnalysisInputError", + issues: [expect.objectContaining({ path: ["capture"] })], + }); + expect(JSON.stringify(result)).toContain( + `the computer cannot install the program it runs: no package owns ${HOME}/.local/bin/notes`, + ); + expect(await exists(directory)).toBe(false); + }); +}); + describe("exportDesktopApplication from a vendor repository", () => { it("copies the verified key, declares the repository and library packages, and turns the sandbox off", async () => { const { result, directory } = await exportVendorCapture( @@ -499,6 +563,19 @@ describe("exportDesktopApplication home paths", () => { }); }); + it("owns a profile folder the application created beside a state directory in another letter case", async () => { + const { result } = await exportWalked({ + stateDirectories: [`${HOME}/.config/notes`, `${HOME}/.local/share/notes`], + baseline: [], + created: [[`${HOME}/.config/Notes/2.10/sessionrc`, "[ui]\nwrap=true\n"]], + }); + + if (result instanceof Error) throw result; + expect(result.normalized_result).toMatchObject({ + paths: [".config/Notes"], + }); + }); + it("keeps the file present before the capture when two map to one home path and names the other", async () => { const later = `${HOME}/.config/notes/settings.ini`; const { result, directory } = await exportWalked({ diff --git a/src/application/desktop/DesktopApplicationExport.ts b/src/application/desktop/DesktopApplicationExport.ts index d93167c20a..97e450fd74 100644 --- a/src/application/desktop/DesktopApplicationExport.ts +++ b/src/application/desktop/DesktopApplicationExport.ts @@ -1,7 +1,5 @@ -import { createHash } from "node:crypto"; import { mkdir } from "node:fs/promises"; import { dirname } from "node:path"; -import { Readable } from "node:stream"; import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js"; import { @@ -25,18 +23,23 @@ import { renderSmokeTask, type DesktopAppExportPlan, type HomePathCollision, + type SeedScrubReport, } from "../../domain/desktop/appExport.js"; import { INSTALL_SCRIPT, electronLaunchArguments, - installLimitations, } from "../../domain/desktop/appExportInstall.js"; +import { installLimitations } from "../../domain/desktop/appExportInstallLimitations.js"; import type { DesktopApplicationCapture } from "../../domain/desktop/desktopCapture.js"; import { desktopApplicationStateSchema, + versionAt, type DesktopApplicationState, } from "../../domain/desktop/desktopState.js"; import { isWithinAny } from "../../domain/desktop/desktopStateDiff.js"; +import type { HostIdentity } from "../../domain/desktop/hostIdentity.js"; +import { scrubDconf } from "../../domain/desktop/seedScrub.js"; +import { isTextualFormat } from "../../domain/desktop/stateFormats.js"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import type { JsonValue } from "../../domain/jsonValue.js"; @@ -52,18 +55,20 @@ import { import { admitApplication, exportEnvironmentName, - refuse, } from "./DesktopExportInstall.js"; import { - withRetainedContent, - type RetainedContent, -} from "./DesktopRetainedContent.js"; + retainedCopies, + shippedSeedViews, + writeFiles, + type SeedVersion, +} from "./DesktopExportFiles.js"; +import { withRetainedContent } from "./DesktopRetainedContent.js"; +import { selectStateEnds } from "./DesktopStateVersions.js"; import { admitDesktopTask, type AdmittedTask } from "./DesktopTaskExport.js"; const OPERATION = "export_desktop_application"; /** Largest seed file the export copies; the state view reads only 64 MiB. */ const SEED_MAX_BYTES = 1024 * 1024 * 1024; -const TEXT_FORMATS = new Set(["text", "json", "keyfile", "xml"]); /** What the caller asked to export, after CLI parsing. */ export interface DesktopExportRequest { @@ -74,6 +79,10 @@ export interface DesktopExportRequest { readonly tasks: readonly string[]; /** Where the computer downloads the captured package file from, for an application installed from one. */ readonly packageUrl?: string | undefined; + /** The checkpoint the seed is taken at; the baseline when omitted. */ + readonly seedCheckpoint?: string | undefined; + /** The checkpoint a task's end state is read at; the last when omitted. */ + readonly endCheckpoint?: string | undefined; } /** What the export wrote. */ @@ -81,9 +90,14 @@ export interface DesktopExportResult { readonly directory: string; readonly environment: string; readonly framework_version: string; + readonly checkpoints: { readonly seed: string; readonly end: string }; readonly paths: readonly string[]; readonly seed_files: readonly string[]; readonly unseeded_files: readonly string[]; + /** Seed files rewritten to remove the capture host's name and user. */ + readonly scrubbed_files: readonly string[]; + /** Seed files that still hold them, in bytes the export does not rewrite or in their name. */ + readonly unscrubbed_files: readonly string[]; readonly dconf_groups: number; readonly files: readonly string[]; readonly limitations: readonly string[]; @@ -95,29 +109,22 @@ const isRecord = ( ): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); -/** The application's dconf groups at the baseline, as a dconf key file. */ -const baselineDconf = ( +/** The application's dconf groups at the seed, as a dconf key file with the identity scrubbed from its strings. */ +const seedDconf = ( state: DesktopApplicationState, -): { text: string; groups: number } => { + seed: number, + identity: HostIdentity, +): { text: string; groups: number; holdsIdentity: boolean } => { const database = state.paths.find( (path) => path.format === "dconf" && path.role === "application_state", ); - const view = database?.versions.find((version) => version.present)?.view; - if ( - database?.versions[0]?.present !== true || - !isRecord(view) || - !isRecord(view.groups) - ) - return { text: "", groups: 0 }; - const lines: string[] = []; - for (const [group, keys] of Object.entries(view.groups)) { - if (!isRecord(keys)) continue; - lines.push(`[${group}]`); - for (const [key, value] of Object.entries(keys)) - if (typeof value === "string") lines.push(`${key}=${value}`); - lines.push(""); - } - return { text: lines.join("\n"), groups: Object.keys(view.groups).length }; + const version = + database === undefined ? undefined : versionAt(database, seed); + const view = version?.view; + if (version?.present !== true || !isRecord(view) || !isRecord(view.groups)) + return { text: "", groups: 0, holdsIdentity: false }; + const groups = Object.keys(view.groups).length; + return { ...scrubDconf(view.groups, identity), groups }; }; type StatePath = DesktopApplicationState["paths"][number]; @@ -126,20 +133,22 @@ type FailedRelease = Extract< { readonly kind: "failed" } >; -/** Files under the owned paths that existed before the capture without retained bytes, in the state view or omitted. */ +/** Files under the owned paths that existed at the seed without retained bytes, in the state view or omitted. */ const unseededFiles = ( capture: DesktopApplicationCapture, homePath: (path: string) => string | null, files: ReadonlyMap, - paths: readonly string[], + { paths, seed }: { readonly paths: readonly string[]; readonly seed: number }, ): readonly string[] => { const listed = [...files] - .filter( - ([home, path]) => + .filter(([home, path]) => { + const version = versionAt(path, seed); + return ( isWithinAny(home, paths) && - path.versions[0]?.present === true && - path.versions[0].sha256 === null, - ) + version?.present === true && + version.sha256 === null + ); + }) .map(([home]) => home); const omitted = capture.state.omitted_content .map(({ path }) => homePath(path)) @@ -150,18 +159,23 @@ const unseededFiles = ( return [...new Set([...listed, ...omitted])].sort(); }; -/** Decide the bundle from the capture and its state view, with each seed's digest. */ +/** Decide the bundle from the capture and its state view at the seed, with each seed's digest. */ const planExport = ( request: DesktopExportRequest, capture: DesktopApplicationCapture, state: DesktopApplicationState, - evidenceId: string, + context: { + readonly evidenceId: string; + readonly seed: number; + readonly identity: HostIdentity; + }, ): { readonly plan: DesktopAppExportPlan; - readonly seeds: ReadonlyMap; + readonly seeds: ReadonlyMap; readonly limitations: readonly string[]; readonly collisions: readonly HomePathCollision[]; readonly files: ReadonlyMap; + readonly dconf: { readonly groups: number; readonly holdsIdentity: boolean }; } => { const { application, host } = capture; const source = exportEnvironmentName(capture, request.name); @@ -175,14 +189,19 @@ const planExport = ( capture, state, ); - const seeds = new Map(); + const { text: dconf, ...dconfScrub } = seedDconf( + state, + context.seed, + context.identity, + ); + const seeds = new Map(); const textSeeds: string[] = []; for (const [home, path] of files) { - const baseline = path.versions[0]; - if (!isWithinAny(home, paths) || baseline?.present !== true) continue; - if (baseline.sha256 === null) continue; - seeds.set(home, baseline.sha256); - if (TEXT_FORMATS.has(baseline.format ?? "")) textSeeds.push(home); + const version = versionAt(path, context.seed); + if (!isWithinAny(home, paths) || version?.present !== true) continue; + if (version.sha256 === null) continue; + seeds.set(home, { sha256: version.sha256, format: version.format }); + if (isTextualFormat(version.format)) textSeeds.push(home); } const plan: DesktopAppExportPlan = { name: source, @@ -195,6 +214,9 @@ const planExport = ( aptSources: install.sources, architectures: install.architectures, packageFile: install.packageFile, + ...(install.executablePackage === undefined + ? {} + : { executablePackage: install.executablePackage }), electron, command: launchCommand( electronLaunchArguments(application.argv, electron), @@ -204,23 +226,27 @@ const planExport = ( windowClass, paths, capturedHome: host.home, + seedCheckpoint: state.capture.checkpoints[context.seed] ?? "baseline", seedFiles: [...seeds.keys()].sort(), textSeeds: textSeeds.sort(), - unseededFiles: unseededFiles(capture, homePath, files, paths), - dconf: baselineDconf(state).text, - captureEvidenceId: evidenceId, + unseededFiles: unseededFiles(capture, homePath, files, { + paths, + seed: context.seed, + }), + dconf, + captureEvidenceId: context.evidenceId, }; const limitations = installLimitations( install, application.package, electron, ); - return { plan, seeds, limitations, collisions, files }; + return { plan, seeds, limitations, collisions, files, dconf: dconfScrub }; }; const EXPORT_LIMITATIONS = [ - "The seed is the user's real state before the capture; keep the bundle local.", - "Binary state files are seeded as captured; only text files, UTF-8 or UTF-16 with a byte-order mark, have the captured home path respelled.", + "The seed is the user's real state at the seed checkpoint; keep the bundle local.", + "Binary state files are seeded as captured; only text files, UTF-8 or UTF-16 with a byte-order mark, have the capture host's name, user@host and home path replaced.", ]; /** @@ -275,78 +301,76 @@ const writeBundle = async ( return undefined; }; -/** A bundle file copied from retained content: a seed or an apt key. */ -interface RetainedCopy { - readonly path: string; - readonly digest: string; - readonly name: string; -} - -/** The generated files and the retained files copied after them. */ -interface ExportBundle { - readonly generated: readonly { - readonly path: string; - readonly bytes: Buffer; - }[]; - readonly copies: readonly RetainedCopy[]; -} - -const seedPath = (plan: DesktopAppExportPlan, seed: string) => - `envs/${plan.name}/seed/${seed}`; - -/** Each seed by its home path, then each distinct apt key in the environment folder. */ -const retainedCopies = ( +/** The bundle's generated files, the class module flagged when its dconf keys hold the identity, and its README, made once the seeds' scrub is known. */ +const bundleText = ( plan: DesktopAppExportPlan, - seeds: ReadonlyMap, -): readonly RetainedCopy[] => [ - ...[...seeds].map(([seed, digest]) => ({ - path: seedPath(plan, seed), - digest, - name: `~/${seed}`, - })), - ...[ - ...new Map( - plan.aptSources.map((source) => [source.keyFile, source.keySha256]), - ), - ].map(([keyFile, digest]) => ({ - path: `envs/${plan.name}/${keyFile}`, - digest, - name: `the apt key ${keyFile}`, - })), -]; + admitted: AdmittedTask | undefined, + dconfHoldsIdentity: boolean, +) => { + const environment = `envs/${plan.name}`; + const text = (path: string, content: string, holdsIdentity = false) => ({ + path, + bytes: Buffer.from(content), + holdsIdentity, + }); + return { + readme: (scrub: SeedScrubReport) => + text("README.md", renderReadme(plan, admitted?.readme, scrub)), + generated: [ + text(`${environment}/env.toml`, renderEnvToml(plan)), + text( + `${environment}/${appModuleFile(plan.name)}`, + renderAppClass(plan), + dconfHoldsIdentity, + ), + text(`${environment}/SKILL.md`, renderSkill(plan)), + ...(plan.packageFile === null + ? [] + : [ + text( + `${environment}/${INSTALL_SCRIPT}`, + renderInstallScript(plan.packageFile), + ), + ]), + text("tasks/smoke.json", renderSmokeTask(plan)), + ...(admitted?.files ?? []).map((file) => text(file.path, file.content)), + ], + }; +}; -/** Write the generated files, then each retained file as it is read, so one at a time is in memory. */ -const writeFiles = async ( - tree: SafeOutputTree, - content: RetainedContent, - bundle: ExportBundle, - signal: AbortSignal | undefined, -): Promise => { - for (const file of bundle.generated) { - const sha256 = createHash("sha256").update(file.bytes).digest("hex"); - await tree.write( - file.path, - Readable.from([file.bytes]), - { bytes: file.bytes.length, sha256 }, - signal, - ); - } - for (const copy of bundle.copies) { - signal?.throwIfAborted(); - const bytes = await content.read(copy.digest, signal); - if (!bytes.ok) - throw refuse( - `The retained content of ${copy.name} ${bytes.error}.`, - "capture", - "Capture the application again, leaving a seed file out of its state paths if it cannot be retained.", - ); - await tree.write( - copy.path, - Readable.from([bytes.value]), - { bytes: bytes.value.length, sha256: copy.digest }, - signal, - ); - } +const exportLimitations = ( + capture: DesktopApplicationCapture, + plan: DesktopAppExportPlan, + facts: { + readonly install: readonly string[]; + readonly collisions: readonly HomePathCollision[]; + readonly identity: HostIdentity; + }, +): string[] => { + const opened = capture.state.unretained_opened_directories; + return [ + ...EXPORT_LIMITATIONS, + ...facts.install, + ...(plan.unseededFiles.length === 0 + ? [] + : [ + `${String(plan.unseededFiles.length)} file(s) under the owned paths existed at the seed but were not retained, so they are not seeded.`, + ]), + ...(facts.identity.hostname === null || facts.identity.user === null + ? [ + "The capture does not record the host's name and user, so only the captured home is removed from the seed; capture again to remove them too.", + ] + : []), + ...(opened.length === 0 + ? [] + : [ + `The application held files open in ${opened.join(", ")}, whose starting content the capture did not retain, so nothing there is seeded.`, + ]), + ...facts.collisions.map( + ({ home, kept, dropped }) => + `${dropped} and ${kept} both map to ~/${home}; the export uses ${kept} and leaves out ${dropped}.`, + ), + ]; }; const runExport = async ( @@ -367,77 +391,71 @@ const runExport = async ( const state = desktopApplicationStateSchema.parse( stateEvidence.normalized_result, ); - const { plan, seeds, collisions, files, ...install } = planExport( + const { checkpoints } = state.capture; + const ends = selectStateEnds( + checkpoints, + { seed: request.seedCheckpoint, end: request.endCheckpoint }, + OPERATION, + ); + const { home, hostname, user } = capture.host; + const identity = { home, hostname: hostname ?? null, user: user ?? null }; + const { plan, seeds, collisions, files, dconf, ...install } = planExport( request, capture, state, - evidence.evidence_id, - ); - const admitted = await admitDesktopTask( - request.tasks, - { ...plan, files }, - signal, + { evidenceId: evidence.evidence_id, seed: ends.seed, identity }, ); const copies = retainedCopies(plan, seeds); - const environment = `envs/${plan.name}`; - const text = (path: string, content: string) => ({ - path, - bytes: Buffer.from(content), - }); - const generated = [ - text("README.md", renderReadme(plan, admitted?.readme)), - text(`${environment}/env.toml`, renderEnvToml(plan)), - text(`${environment}/${appModuleFile(plan.name)}`, renderAppClass(plan)), - text(`${environment}/SKILL.md`, renderSkill(plan)), - ...(plan.packageFile === null - ? [] - : [ - text( - `${environment}/${INSTALL_SCRIPT}`, - renderInstallScript(plan.packageFile), - ), - ]), - text("tasks/smoke.json", renderSmokeTask(plan)), - ...(admitted?.files ?? []).map((file) => text(file.path, file.content)), - ]; + // A task's seed checks read the seeds as the bundle ships them, scrubbed. + const seedViews = + request.tasks.length === 0 + ? new Map() + : await withRetainedContent( + request.capture, + (content) => shippedSeedViews(content, copies, identity, signal), + SEED_MAX_BYTES, + ); + const admitted = await admitDesktopTask( + request.tasks, + { ...plan, files, identity, ends, seedViews }, + signal, + ); + const { generated, readme } = bundleText(plan, admitted, dconf.holdsIdentity); + let scrub: SeedScrubReport = { scrubbed: [], unscrubbed: [] }; const unclosed = await withRetainedContent( request.capture, (content) => writeBundle( request.directory, - (tree) => writeFiles(tree, content, { generated, copies }, signal), + async (tree) => { + const bundle = { generated, copies, identity, readme }; + scrub = await writeFiles(tree, content, bundle, signal); + }, signal, ), SEED_MAX_BYTES, ); - const opened = capture.state.unretained_opened_directories; - const limitations = [ - ...EXPORT_LIMITATIONS, - ...install.limitations, - ...(plan.unseededFiles.length === 0 - ? [] - : [ - `${String(plan.unseededFiles.length)} file(s) under the owned paths existed before the capture but were not retained, so they are not seeded.`, - ]), - ...(opened.length === 0 - ? [] - : [ - `The application held files open in ${opened.join(", ")}, whose starting content the capture did not retain, so nothing there is seeded.`, - ]), - ...collisions.map( - ({ home, kept, dropped }) => - `${dropped} and ${kept} both map to ~/${home}; the export uses ${kept} and leaves out ${dropped}.`, - ), - ]; + const limitations = exportLimitations(capture, plan, { + install: install.limitations, + collisions, + identity, + }); const result: DesktopExportResult = { directory: request.directory, environment: plan.name, framework_version: DESKTOP_EXPORT_FRAMEWORK_VERSION, + checkpoints: { + seed: checkpoints[ends.seed] ?? "baseline", + end: checkpoints[ends.end] ?? "baseline", + }, paths: plan.paths, seed_files: plan.seedFiles, unseeded_files: plan.unseededFiles, - dconf_groups: baselineDconf(state).groups, + scrubbed_files: [...scrub.scrubbed].sort(), + unscrubbed_files: [...scrub.unscrubbed].sort(), + dconf_groups: dconf.groups, files: [ + "README.md", ...generated.map((file) => file.path), ...copies.map((copy) => copy.path), ], @@ -489,12 +507,17 @@ export const exportDesktopApplication = async ( named: request.name !== undefined, tasks: request.tasks.length, package_url: request.packageUrl !== undefined, + seed_checkpoint: request.seedCheckpoint ?? null, + end_checkpoint: request.endCheckpoint ?? null, }, result: { ...result, + checkpoints: { ...result.checkpoints }, paths: [...result.paths], seed_files: [...result.seed_files], unseeded_files: [...result.unseeded_files], + scrubbed_files: [...result.scrubbed_files], + unscrubbed_files: [...result.unscrubbed_files], files: [...result.files], limitations: [...result.limitations], }, diff --git a/src/application/desktop/DesktopApplicationState.ts b/src/application/desktop/DesktopApplicationState.ts index cd211e26bb..103026eaaf 100644 --- a/src/application/desktop/DesktopApplicationState.ts +++ b/src/application/desktop/DesktopApplicationState.ts @@ -14,14 +14,11 @@ import type { } from "../../domain/desktop/desktopState.js"; import { diffStateViews } from "../../domain/desktop/stateChanges.js"; import { - decodeStateText, detectStateFormat, - jsonView, keyfileView, plainRecord, SQLITE_SIDECARS, - textView, - xmlView, + textualView, } from "../../domain/desktop/stateFormats.js"; import { applicationNames, @@ -37,6 +34,7 @@ import { err, ok, type Result } from "../../domain/result.js"; import { describeValidationFailure } from "../../domain/validationFailure.js"; import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js"; import { readJsonFile } from "../JsonFiles.js"; +import { readZipView } from "./DesktopZipView.js"; import { withRetainedContent, type RetainedContent, @@ -257,9 +255,9 @@ const dconfView = async ( : failed(dump.error); }); -const MIB = 1024 * 1024; -/** Largest JSON or key file read into a view, whose whole value enters Evidence. */ -const MAX_PARSED_VIEW_BYTES = 4 * MIB; +/** A reader's result as a version's view or view_error. */ +const outcomeOf = (read: Result): ViewOutcome => + read.ok ? { view: read.value, error: null } : failed(read.error); /** Read one retained version into JSON for its own format. */ const viewOf = async ( @@ -267,31 +265,14 @@ const viewOf = async ( context: ViewContext, ): Promise => { const format = input.version.format ?? "binary"; - if ( - (format === "json" || format === "keyfile") && - input.bytes.length > MAX_PARSED_VIEW_BYTES - ) - return failed( - `larger than the ${String(MAX_PARSED_VIEW_BYTES / MIB)} MiB limit for JSON and key file views`, - ); - const text = decodeStateText(input.bytes) ?? ""; switch (format) { case "text": - return { view: textView(text), error: null }; - case "json": { - const parsed = jsonView(text); - return parsed.ok - ? { view: parsed.value, error: null } - : failed(parsed.error); - } + case "json": case "keyfile": - return { view: { groups: keyfileView(text) }, error: null }; - case "xml": { - const tree = xmlView(text); - return tree === undefined - ? failed("not well-formed XML, or it declares a DOCTYPE") - : { view: tree, error: null }; - } + case "xml": + return outcomeOf(textualView(format, input.bytes)); + case "zip": + return outcomeOf(await readZipView(input.bytes, context.signal)); case "dconf": return dconfView(input, context); case "sqlite": @@ -416,6 +397,7 @@ const STATE_LIMITATIONS = [ "SQLite views list at most 500 rows of at most 64 ordinary tables, text views at most 5,000 lines and XML views at most 5,000 nodes; changes between truncated views, or views with an unread table, are marked incomplete.", "JSON and key file views read files of at most 4 MiB, JSON nested at most 500 levels and only numbers JavaScript holds exactly; other files of those formats have a view_error.", "dconf views keep only groups whose path names the application.", + "Zip views list at most 1,000 entries and read an entry only when it is unencrypted, stored or deflated, at most 16 MiB and at most 100 times its compressed size, up to 64 MiB per archive; databases and archives inside an archive are not read.", ]; /** Read a capture folder into the application's state view. */ diff --git a/src/application/desktop/DesktopArchiveTask.test.ts b/src/application/desktop/DesktopArchiveTask.test.ts new file mode 100644 index 0000000000..057f757f2d --- /dev/null +++ b/src/application/desktop/DesktopArchiveTask.test.ts @@ -0,0 +1,273 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import type { DesktopApplicationCapture } from "../../domain/desktop/desktopCapture.js"; +import { desktopApplicationStateSchema } from "../../domain/desktop/desktopState.js"; +import { + odtArchive, + zipArchive, +} from "../../domain/desktop/zipArchive.fixture.js"; +import { exportDesktopApplication } from "./DesktopApplicationExport.js"; +import { inspectDesktopApplicationState } from "./DesktopApplicationState.js"; +import { DESKTOP_OPERATION } from "./DesktopCaptureRefusals.js"; +import { draftDesktopApplicationTask } from "./DesktopTaskDraft.js"; +import { + HOME, + capture, + change, + readers, + retained, + writeCaptureFolder, +} from "./desktopCapture.fixture.js"; + +const FREECAD = join( + import.meta.dirname, + "../../../tests/fixtures/freecad/1.0", +); +const PLATE = "Documents/plate.FCStd"; +const LIST = "Documents/list.odt"; +/** The list's paragraphs, by their prefixed element names. */ +const LIST_TEXT = + "/entries/[name=content.xml]/view/root/children/[name=office:body]/children/[name=office:text]/children"; + +/** FreeCAD's save of the plate, before and after the user cut the hole. */ +const plate = async (end: "before" | "after") => + zipArchive([ + { + name: "Document.xml", + data: await readFile(join(FREECAD, end, "Document.xml")), + }, + { name: "thumbnails/Thumbnail.png", data: Buffer.from([0x89, 0x50, 0]) }, + ]); + +/** The notes capture with a FreeCAD document and an OpenDocument list the user also edited, both named in window titles. */ +const archiveCapture = async ( + list: readonly [Buffer, Buffer] = [ + odtArchive("Buy bread"), + odtArchive("Buy milk"), + ], +) => { + const base = capture(); + const [first, second] = base.state.checkpoints; + if (first === undefined || second === undefined) throw new Error("fixture"); + const files = { + [`${HOME}/${PLATE}`]: [await plate("before"), await plate("after")], + [`${HOME}/${LIST}`]: list, + } as const; + const captured: DesktopApplicationCapture = { + ...base, + session: { + ...base.session, + windows: base.session.windows.map((window) => ({ + ...window, + titles: [...window.titles, "plate.FCStd - Notes", "list.odt - Notes"], + })), + }, + state: { + ...base.state, + checkpoints: [ + { + ...first, + retained: [ + ...first.retained, + ...Object.entries(files).map(([path, [before]]) => + retained(path, before), + ), + ], + }, + { + ...second, + changes: [ + ...second.changes, + ...Object.entries(files).map(([path, [before, after]]) => + change(path, { before, after }), + ), + ], + retained: [ + ...second.retained, + ...Object.entries(files).map(([path, [, after]]) => + retained(path, after), + ), + ], + }, + ], + }, + }; + return writeCaptureFolder( + captured, + DESKTOP_OPERATION, + Object.values(files).flat(), + ); +}; + +const HOLE = { + kind: "value_equals", + file: PLATE, + pointer: + "/entries/[name=Document.xml]/view/root/children/[name=ObjectData]/children/[name=Object][@name=Hole]/children/[name=Properties]/children/[name=Property][@name=Radius]/children/[name=Float]/attributes/value", + value: 5, + tolerance: 0.01, +}; + +/** The entries of the list document's last version in the capture's state view. */ +const endEntries = async (folder: string) => { + const evidence = await inspectDesktopApplicationState(folder, readers); + if (!("normalized_result" in evidence)) throw evidence; + const state = desktopApplicationStateSchema.parse(evidence.normalized_result); + const end = state.paths + .find((entry) => entry.home_path === LIST) + ?.versions.at(-1)?.view; + return ( + (end as { entries: Record[] } | undefined)?.entries ?? [] + ); +}; + +const exportTask = async (folder: string, checks: unknown[]) => { + const scratch = await createTestTempDirectory("desktop-archive-task-"); + const path = join(scratch, "task.json"); + await writeFile( + path, + JSON.stringify({ + name: "cut-hole", + prompt: "Cut a 10 mm hole through the plate and save it.", + checks, + }), + ); + const directory = join(scratch, "bundle"); + const result = await exportDesktopApplication( + { capture: folder, directory, name: undefined, tasks: [path] }, + readers, + ); + return { result, directory }; +}; + +describe("documents saved as zip archives", () => { + it("reads each version into its entries and lists the change by entry and element name", async () => { + const evidence = await inspectDesktopApplicationState( + await archiveCapture(), + readers, + ); + if (!("normalized_result" in evidence)) throw evidence; + const state = desktopApplicationStateSchema.parse( + evidence.normalized_result, + ); + const path = state.paths.find((entry) => entry.home_path === PLATE); + + expect(path).toMatchObject({ + role: "document", + format: "zip", + versions: [ + { format: "zip", view: { entry_count: 2, truncated: false } }, + { format: "zip", view_error: null }, + ], + changes: [{ kind: "values", complete: true }], + }); + expect(path?.changes[0]?.added).toContain( + "/entries/[name=Document.xml]/view/root/children/[name=Objects]/children/[name=Object][@name=Hole]", + ); + }); + + it("exports a task that checks the hole and the list's namespaced XML by name", async () => { + const folder = await archiveCapture(); + const milk = { + kind: "value_equals", + file: LIST, + pointer: `${LIST_TEXT}/[name=text:p]/children/0`, + value: "Buy milk", + }; + + const { result, directory } = await exportTask(folder, [HOLE, milk]); + if (result instanceof Error) throw result; + const verifier = await readFile( + join(directory, "artifacts/cut-hole-check/verify.py"), + "utf8", + ); + expect(verifier).toContain(`"tolerance": 0.01`); + expect(verifier).toContain(JSON.stringify(milk.pointer)); + }); + + it("accepts a check on the whole listing, whose value holds namespaced XML", async () => { + const folder = await archiveCapture(); + const entries = await endEntries(folder); + expect(entries).toHaveLength(3); + + const { result } = await exportTask(folder, [ + { kind: "value_equals", file: LIST, pointer: "/entries", value: entries }, + ]); + + if (result instanceof Error) throw result; + }); + + it("exports checks of a namespaced entry's metadata, its absence, and the entry compared whole", async () => { + const comments = { + name: "comments.xml", + data: '', + }; + const folder = await archiveCapture([ + odtArchive("Buy bread", [comments]), + odtArchive("Buy milk"), + ]); + const content = (await endEntries(folder)).find( + (entry) => entry.name === "content.xml", + ); + + const { result } = await exportTask(folder, [ + { + kind: "value_equals", + file: LIST, + pointer: "/entries/[name=content.xml]/size", + value: content?.size, + }, + { + kind: "value_absent", + file: LIST, + pointer: "/entries/[name=comments.xml]", + }, + ]); + expect( + result instanceof Error + ? (result as Error & { userMessage: string }).userMessage + : undefined, + ).toBeUndefined(); + + const whole = await exportTask(folder, [ + { + kind: "value_equals", + file: LIST, + pointer: "/entries/[name=content.xml]", + value: content, + }, + ]); + if (whole.result instanceof Error) throw whole.result; + }); + + it("drafts name-addressed checks of the archive that the export then accepts", async () => { + const folder = await archiveCapture(); + + const drafted = await draftDesktopApplicationTask(folder, readers); + if (drafted instanceof Error) throw drafted; + const task = ( + drafted.normalized_result as { + task: { checks: { file: string; pointer?: string }[] }; + } + ).task; + + const archived = task.checks.filter(({ file }) => file === PLATE); + expect(archived.length).toBeGreaterThan(0); + expect( + archived.every(({ pointer }) => pointer?.includes("[name=") === true), + ).toBe(true); + expect(task.checks.filter(({ file }) => file === LIST)).toEqual([ + expect.objectContaining({ + kind: "value_equals", + pointer: `${LIST_TEXT}/[name=text:p]/children/0`, + value: "Buy milk", + }), + ]); + const { result } = await exportTask(folder, task.checks); + expect(result).not.toBeInstanceOf(Error); + }); +}); diff --git a/src/application/desktop/DesktopCheckpointEnds.test.ts b/src/application/desktop/DesktopCheckpointEnds.test.ts new file mode 100644 index 0000000000..c5102faa8d --- /dev/null +++ b/src/application/desktop/DesktopCheckpointEnds.test.ts @@ -0,0 +1,251 @@ +import { access, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import type { DesktopApplicationCapture } from "../../domain/desktop/desktopCapture.js"; +import { exportDesktopApplication } from "./DesktopApplicationExport.js"; +import { draftDesktopApplicationTask } from "./DesktopTaskDraft.js"; +import { + EDITED_NOTE, + HOME, + SAVED_NOTE, + change, + readers, + retained, + seedEndCapture, + writeCaptureFolder, +} from "./desktopCapture.fixture.js"; + +const NOTE = "Documents/today.txt"; + +const folder = () => + writeCaptureFolder(seedEndCapture(), undefined, [EDITED_NOTE]); + +const STATE = `${HOME}/.config/notes/state.json`; +const UNSET = '{"eggs": false}'; +const SET = '{"eggs": true}'; +/** The state file's versions after the baseline, where it is empty, which reads as text. */ +const STATE_VERSIONS: Readonly< + Record +> = { + seed: { before: "", after: UNSET }, + end: { before: UNSET, after: SET }, + exit: { before: SET, after: "" }, +}; + +/** The capture with a JSON state file that is empty before `seed` and again after `end`. */ +const emptiedStateFolder = () => { + const base = seedEndCapture(); + const emptied: DesktopApplicationCapture = { + ...base, + state: { + ...base.state, + checkpoints: base.state.checkpoints.map((point) => { + if (point.name === "baseline") + return { + ...point, + retained: [...point.retained, retained(STATE, "")], + }; + const version = STATE_VERSIONS[point.name]; + return version === undefined + ? point + : { + ...point, + changes: [ + ...point.changes, + change(STATE, version, { state: true }), + ], + retained: [...point.retained, retained(STATE, version.after)], + }; + }), + }, + }; + return writeCaptureFolder(emptied, undefined, [EDITED_NOTE, "", UNSET, SET]); +}; + +const eggs = { id: "eggs", kind: "lines_present", file: NOTE, lines: ["eggs"] }; +const wrapped = { + id: "wrap", + kind: "value_equals", + file: ".config/notes/settings.ini", + pointer: "/groups/ui/wrap", + value: "true", +}; + +/** Export the capture between the named checkpoints, with a task holding these checks when any are given. */ +const exportBetween = async ( + ends: { seedCheckpoint?: string; endCheckpoint?: string }, + checks: readonly unknown[] = [], + captured = folder, +) => { + const scratch = await createTestTempDirectory("desktop-ends-"); + const task = join(scratch, "task.json"); + await writeFile( + task, + JSON.stringify({ + name: "add-eggs", + prompt: "Add eggs to my list.", + checks, + }), + ); + const directory = join(scratch, "bundle"); + const result = await exportDesktopApplication( + { + capture: await captured(), + directory, + name: undefined, + tasks: checks.length === 0 ? [] : [task], + ...ends, + }, + readers, + ); + return { result, directory }; +}; + +const exists = (path: string) => + access(path).then( + () => true, + () => false, + ); + +describe("exportDesktopApplication between named checkpoints", () => { + it("seeds the files as they were at the seed checkpoint and keeps a task that fails there and passes at the end", async () => { + const { result, directory } = await exportBetween( + { seedCheckpoint: "seed", endCheckpoint: "end" }, + [eggs], + ); + + if (result instanceof Error) throw result; + expect(result.normalized_result).toMatchObject({ + checkpoints: { seed: "seed", end: "end" }, + seed_files: [".config/notes/settings.ini", NOTE], + task: { name: "add-eggs", failing_on_seed: ["eggs"] }, + }); + const seed = join(directory, "envs/notes/seed"); + expect(await readFile(join(seed, NOTE), "utf8")).toBe(SAVED_NOTE); + expect( + await readFile(join(seed, ".config/notes/settings.ini"), "utf8"), + ).toBe("[ui]\nwrap=false\n"); + }); + + it("keeps the baseline and the last checkpoint when none is named", async () => { + const { result } = await exportBetween({}); + + if (result instanceof Error) throw result; + expect(result.normalized_result).toMatchObject({ + checkpoints: { seed: "baseline", end: "exit" }, + seed_files: [".config/notes/settings.ini"], + }); + }); + + it("reads the end state at the end checkpoint, not at the capture's last", async () => { + const atEnd = await exportBetween( + { seedCheckpoint: "seed", endCheckpoint: "end" }, + [eggs, wrapped], + ); + const atExit = await exportBetween({ seedCheckpoint: "seed" }, [ + eggs, + wrapped, + ]); + + expect(atEnd.result).toMatchObject({ issues: [{ path: ["task"] }] }); + expect((atEnd.result as { userMessage: string }).userMessage).toContain( + '"wrap"', + ); + if (atExit.result instanceof Error) throw atExit.result; + expect(atExit.result.normalized_result).toMatchObject({ + checkpoints: { seed: "seed", end: "exit" }, + task: { failing_on_seed: ["eggs", "wrap"] }, + }); + }); + + it("reads a value check's format at the seed and end only, so a version read as text before or after them does not refuse it", async () => { + const eggsSet = { + id: "eggs", + kind: "value_equals", + file: ".config/notes/state.json", + pointer: "/eggs", + value: true, + }; + + const between = await exportBetween( + { seedCheckpoint: "seed", endCheckpoint: "end" }, + [eggsSet], + emptiedStateFolder, + ); + const fromBaseline = await exportBetween( + { endCheckpoint: "end" }, + [eggsSet], + emptiedStateFolder, + ); + + if (between.result instanceof Error) throw between.result; + expect(between.result.normalized_result).toMatchObject({ + checkpoints: { seed: "seed", end: "end" }, + task: { failing_on_seed: ["eggs"] }, + }); + expect( + (fromBaseline.result as { userMessage: string }).userMessage, + ).toContain("points into .config/notes/state.json, which is text"); + }); + + it.each([ + [{ seedCheckpoint: "saved" }, "seed_checkpoint"], + [{ endCheckpoint: "Exit" }, "end_checkpoint"], + [{ seedCheckpoint: "end", endCheckpoint: "seed" }, "end_checkpoint"], + ])( + "refuses %j, naming the option, and writes no bundle", + async (ends, field) => { + const { result, directory } = await exportBetween(ends); + + expect(result).toMatchObject({ + operation: "export_desktop_application", + issues: [{ path: [field], reason: "invalid_value" }], + }); + expect(await exists(directory)).toBe(false); + }, + ); +}); + +describe("draftDesktopApplicationTask between named checkpoints", () => { + it("diffs the seed checkpoint against the end checkpoint", async () => { + const result = await draftDesktopApplicationTask( + await folder(), + readers, + undefined, + { seed: "seed", end: "end" }, + ); + + if (result instanceof Error) throw result; + expect(result.normalized_result).toMatchObject({ + checkpoints: { baseline: "seed", final: "end" }, + files: [ + { + file: NOTE, + baseline: { checkpoint: "seed" }, + final: { checkpoint: "end" }, + change: { kind: "lines", added: ["eggs"], removed: [] }, + }, + ], + task: { + checks: [{ kind: "lines_present", file: NOTE, lines: ["eggs"] }], + }, + }); + }); + + it("refuses an unknown checkpoint under its own operation", async () => { + const result = await draftDesktopApplicationTask( + await folder(), + readers, + undefined, + { seed: "saved" }, + ); + + expect(result).toMatchObject({ + operation: "describe_desktop_application_task", + issues: [{ path: ["seed_checkpoint"], reason: "invalid_value" }], + }); + }); +}); diff --git a/src/application/desktop/DesktopExportFiles.ts b/src/application/desktop/DesktopExportFiles.ts new file mode 100644 index 0000000000..6479d34908 --- /dev/null +++ b/src/application/desktop/DesktopExportFiles.ts @@ -0,0 +1,182 @@ +import { createHash } from "node:crypto"; +import { Readable } from "node:stream"; + +import type { SafeOutputTree } from "../../artifacts/SafeOutputTree.js"; +import type { + DesktopAppExportPlan, + SeedScrubReport, +} from "../../domain/desktop/appExport.js"; +import type { TaskFileView } from "../../domain/desktop/appTaskChecks.js"; +import { + identityLeftIn, + type HostIdentity, +} from "../../domain/desktop/hostIdentity.js"; +import { scrubSeed } from "../../domain/desktop/seedScrub.js"; +import { + detectStateFormat, + isTextualFormat, + textualView, +} from "../../domain/desktop/stateFormats.js"; +import { refuse } from "./DesktopExportInstall.js"; +import type { RetainedContent } from "./DesktopRetainedContent.js"; + +/** A file the export writes from bytes it made. */ +export interface GeneratedFile { + readonly path: string; + readonly bytes: Buffer; + /** Whether what it was made from holds the identity in a spelling its bytes do not show, such as a decoded escape. */ + readonly holdsIdentity?: boolean; +} + +/** A seed file's retained digest and the format the state view read it as. */ +export interface SeedVersion { + readonly sha256: string; + readonly format: string | null; +} + +/** A bundle file copied from retained content: a seed, with its home path and format, or an apt key. */ +export interface RetainedCopy { + readonly path: string; + readonly digest: string; + readonly name: string; + readonly seed?: { readonly home: string; readonly format: string | null }; +} + +/** The files a bundle holds; the README is made last, from what the seeds' scrub found. */ +export interface ExportBundle { + readonly generated: readonly GeneratedFile[]; + readonly copies: readonly RetainedCopy[]; + readonly identity: HostIdentity; + readonly readme: (scrub: SeedScrubReport) => GeneratedFile; +} + +/** Each seed by its home path, then each distinct apt key in the environment folder. */ +export const retainedCopies = ( + plan: DesktopAppExportPlan, + seeds: ReadonlyMap, +): readonly RetainedCopy[] => [ + ...[...seeds].map(([seed, { sha256, format }]) => ({ + path: `envs/${plan.name}/seed/${seed}`, + digest: sha256, + name: `~/${seed}`, + seed: { home: seed, format }, + })), + ...[ + ...new Map( + plan.aptSources.map((source) => [source.keyFile, source.keySha256]), + ), + ].map(([keyFile, digest]) => ({ + path: `envs/${plan.name}/${keyFile}`, + digest, + name: `the apt key ${keyFile}`, + })), +]; + +/** A retained file's bytes, refused when they cannot be read. */ +const retainedBytes = async ( + content: RetainedContent, + copy: RetainedCopy, + signal: AbortSignal | undefined, +): Promise => { + signal?.throwIfAborted(); + const bytes = await content.read(copy.digest, signal); + if (!bytes.ok) + throw refuse( + `The retained content of ${copy.name} ${bytes.error}.`, + "capture", + "Capture the application again, leaving a seed file out of its state paths if it cannot be retained.", + ); + return bytes.value; +}; + +/** + * The view of each text seed the scrub rewrites, as the bundle ships it, for + * a task's checks on the seed: the computer starts from these bytes. + */ +export const shippedSeedViews = async ( + content: RetainedContent, + copies: readonly RetainedCopy[], + identity: HostIdentity, + signal: AbortSignal | undefined, +): Promise> => { + const views = new Map(); + for (const copy of copies) { + const seed = copy.seed; + if (seed === undefined || !isTextualFormat(seed.format)) continue; + const bytes = await retainedBytes(content, copy, signal); + const shipped = scrubSeed(bytes, seed, identity).bytes; + if (shipped === undefined) continue; + const format = detectStateFormat(seed.home, shipped); + const view = isTextualFormat(format) + ? textualView(format, shipped) + : undefined; + views.set( + seed.home, + view === undefined || view.ok + ? { format, view: view?.value ?? null } + : { format, view: null, error: view.error }, + ); + } + return views; +}; + +/** + * Write the generated files, then each retained file as it is read, so one at + * a time is in memory: a seed whose text names the capture host becomes a + * generated file with its own digest. The README follows, listing them. Every + * file is listed as unscrubbed whose path or content still holds the identity. + */ +export const writeFiles = async ( + tree: SafeOutputTree, + content: RetainedContent, + bundle: ExportBundle, + signal: AbortSignal | undefined, +): Promise => { + const scrubbed: string[] = []; + const unscrubbed: string[] = []; + const write = async ( + path: string, + bytes: Buffer, + shipped: { readonly sha256?: string; readonly holdsIdentity: boolean }, + ) => { + await tree.write( + path, + Readable.from([bytes]), + { + bytes: bytes.length, + sha256: + shipped.sha256 ?? createHash("sha256").update(bytes).digest("hex"), + }, + signal, + ); + if ( + shipped.holdsIdentity || + identityLeftIn(path, bundle.identity) !== undefined + ) + unscrubbed.push(path); + }; + const generated = (file: GeneratedFile) => + write(file.path, file.bytes, { + holdsIdentity: + file.holdsIdentity === true || + identityLeftIn(file.bytes.toString("utf8"), bundle.identity) !== + undefined, + }); + for (const file of bundle.generated) await generated(file); + for (const copy of bundle.copies) { + const bytes = await retainedBytes(content, copy, signal); + const scrub = + copy.seed === undefined + ? undefined + : scrubSeed(bytes, copy.seed, bundle.identity); + const holdsIdentity = scrub?.holdsIdentity === true; + if (scrub?.bytes === undefined) + await write(copy.path, bytes, { sha256: copy.digest, holdsIdentity }); + else { + scrubbed.push(copy.path); + await write(copy.path, scrub.bytes, { holdsIdentity }); + } + } + await generated(bundle.readme({ scrubbed, unscrubbed: [...unscrubbed] })); + return { scrubbed, unscrubbed }; +}; diff --git a/src/application/desktop/DesktopExportIdentity.test.ts b/src/application/desktop/DesktopExportIdentity.test.ts new file mode 100644 index 0000000000..f1e26513b7 --- /dev/null +++ b/src/application/desktop/DesktopExportIdentity.test.ts @@ -0,0 +1,348 @@ +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { join, relative } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import type { DesktopApplicationCapture } from "../../domain/desktop/desktopCapture.js"; +import { exportDesktopApplication } from "./DesktopApplicationExport.js"; +import { + DATABASE, + HOME, + NOTE, + SETTINGS, + capture, + change, + checkpoint, + readers, + retained, + writeCaptureFolder, +} from "./desktopCapture.fixture.js"; + +const HOSTNAME = "e81232be457d"; +/** Geany's leak: its mail template took the capture user and the container's host name. */ +const CONFIG = `[ui]\npref_template_mail=ada@${HOSTNAME}\npref_template_developer=ada\nlast=${HOME}/Documents/today.txt\n`; +const DB = `SQLite format 3\0rows from ${HOSTNAME}`; +const DCONF_DB = "GVariant database"; +const DCONF = `[org/notes]\nmail='ada@${HOSTNAME}'\nrecent='${HOME}/x'\n`; +/** A mail setting the user changed from the captured user@host to the value the scrub writes. */ +const MAIL = { + before: `[ui]\nmail=ada@${HOSTNAME}\n`, + after: "[ui]\nmail=user@localhost\n", +}; +const NAMED = `${HOME}/.config/notes/${HOSTNAME}-notes.txt`; +/** A JSON setting keyed by the host name, which the export must not rename. */ +const KEYED = `{"${HOSTNAME}": {"open": true}, "localhost": {"open": false}}`; +const EXTRA_CONTENT = [MAIL.before, MAIL.after, "clean\n", KEYED]; + +/** A capture whose seed names the host and user in a text file, a database and dconf keys. */ +const identityCapture = ( + host: Partial = { + hostname: HOSTNAME, + user: "ada", + }, +): DesktopApplicationCapture => { + const base = capture(); + return { + ...base, + host: { ...base.host, ...host }, + state: { + ...base.state, + checkpoints: [ + checkpoint( + "baseline", + "baseline", + [], + [ + retained(SETTINGS, CONFIG), + retained(DATABASE, DB), + retained(`${HOME}/.config/dconf/user`, DCONF_DB), + ], + ), + ...base.state.checkpoints.slice(1), + ], + }, + }; +}; + +/** Export the capture, with a task holding these checks when any are given, and these dconf keys. */ +const exportIdentity = async ( + local: DesktopApplicationCapture, + checks: readonly unknown[] = [], + dconf = DCONF, +) => { + const scratch = await createTestTempDirectory("desktop-identity-"); + const task = join(scratch, "task.json"); + await writeFile( + task, + JSON.stringify({ name: "mail", prompt: "Set my mail.", checks }), + ); + const directory = join(scratch, "bundle"); + const result = await exportDesktopApplication( + { + capture: await writeCaptureFolder(local, undefined, [ + CONFIG, + DB, + DCONF_DB, + ...EXTRA_CONTENT, + ]), + directory, + name: undefined, + tasks: checks.length === 0 ? [] : [task], + }, + { ...readers, dconf: async () => ({ ok: true as const, text: dconf }) }, + ); + return { result, directory }; +}; + +/** Every file in a bundle by its path, as text. */ +const bundleFiles = async (directory: string) => { + const entries = await readdir(directory, { + recursive: true, + withFileTypes: true, + }); + const files = new Map(); + for (const entry of entries.filter((candidate) => candidate.isFile())) { + const path = join(entry.parentPath, entry.name); + files.set(relative(directory, path), await readFile(path, "latin1")); + } + return files; +}; + +/** The bundle files whose path or content holds the host name, `ada@` or the captured home, sorted. */ +const leaking = (files: ReadonlyMap) => + [...files] + .filter(([path, text]) => + [HOSTNAME, "ada@", HOME].some( + (spelling) => path.includes(spelling) || text.includes(spelling), + ), + ) + .map(([path]) => path) + .sort(); + +describe("exportDesktopApplication of a capture that names its host", () => { + it("ships no host name, user@host or captured home outside the seeds it cannot rewrite, and lists both", async () => { + const { result, directory } = await exportIdentity(identityCapture()); + + if (result instanceof Error) throw result; + expect(result.normalized_result).toMatchObject({ + scrubbed_files: ["envs/notes/seed/.config/notes/settings.ini"], + unscrubbed_files: ["envs/notes/seed/.local/share/notes/notes.db"], + }); + const files = await bundleFiles(directory); + const seed = "envs/notes/seed/.config/notes/settings.ini"; + expect(files.get(seed)).toBe( + "[ui]\npref_template_mail=user@localhost\npref_template_developer=ada\nlast=/home/user/Documents/today.txt\n", + ); + expect(leaking(files)).toEqual([ + "envs/notes/seed/.local/share/notes/notes.db", + ]); + const module = files.get("envs/notes/notes_app.py") ?? ""; + expect(module).toContain('SEED_HOME = "/home/user"'); + expect(module).toContain("mail='user@localhost'"); + const readme = files.get("README.md") ?? ""; + expect(readme).toContain("`envs/notes/seed/.config/notes/settings.ini`"); + expect(readme).toContain("`envs/notes/seed/.local/share/notes/notes.db`"); + }); + + it("lists every shipped file that still holds the host name, by its name, a JSON key or content, generated ones included", async () => { + const base = identityCapture(); + const [baseline, ...later] = base.state.checkpoints; + const named = { + ...base, + state: { + ...base.state, + checkpoints: [ + { + ...checkpoint("baseline", "baseline", [], []), + retained: [ + ...(baseline?.retained ?? []), + retained(NAMED, "clean\n"), + retained(`${HOME}/.config/notes/hosts.json`, KEYED), + ], + }, + ...later, + ], + }, + }; + + const { result, directory } = await exportIdentity( + named, + [], + `[org/notes]\nmail='ada@${HOSTNAME}.local'\n`, + ); + + if (result instanceof Error) throw result; + const files = await bundleFiles(directory); + const shipped = leaking(files); + expect(shipped).toEqual([ + "README.md", + "envs/notes/notes_app.py", + `envs/notes/seed/.config/notes/${HOSTNAME}-notes.txt`, + "envs/notes/seed/.config/notes/hosts.json", + "envs/notes/seed/.local/share/notes/notes.db", + ]); + expect(files.get("envs/notes/seed/.config/notes/hosts.json")).toBe(KEYED); + expect(result.normalized_result).toMatchObject({ + unscrubbed_files: shipped, + }); + }); + + it("checks a task's seed as the bundle ships it, so a check the scrub satisfies does not count as failing there", async () => { + const base = capture(); + const mailCapture = { + ...identityCapture(), + state: { + ...base.state, + checkpoints: [ + checkpoint( + "baseline", + "baseline", + [], + [retained(SETTINGS, MAIL.before)], + ), + checkpoint( + "edited", + "request", + [ + change(SETTINGS, MAIL, { state: true }), + change(NOTE, { after: "milk\n" }), + ], + [retained(SETTINGS, MAIL.after), retained(NOTE, "milk\n")], + ), + ], + }, + }; + + const { result } = await exportIdentity(mailCapture, [ + { + id: "mail", + kind: "value_equals", + file: ".config/notes/settings.ini", + pointer: "/groups/ui/mail", + value: "user@localhost", + }, + { + id: "note", + kind: "lines_present", + file: "Documents/today.txt", + lines: ["milk"], + }, + ]); + + if (result instanceof Error) throw result; + expect(result.normalized_result).toMatchObject({ + task: { failing_on_seed: ["note"] }, + }); + }); + + it.each([ + [`pref_template_mail=ada@${HOSTNAME}`, `ada@${HOSTNAME}`], + [HOSTNAME, HOSTNAME], + [`url=http://${HOSTNAME}.local/`, HOSTNAME], + ])("refuses a check whose value names %s", async (line, named) => { + const { result } = await exportIdentity(identityCapture(), [ + { + id: "mail", + kind: "lines_absent", + file: ".config/notes/settings.ini", + lines: [line], + }, + ]); + + expect(result).toMatchObject({ issues: [{ path: ["task"] }] }); + expect((result as { userMessage: string }).userMessage).toContain(named); + }); + + it("still parses and respells the home of a capture written before it recorded the host", async () => { + const { + hostname: _hostname, + user: _user, + ...older + } = identityCapture().host; + + const { result, directory } = await exportIdentity({ + ...identityCapture(), + host: older, + }); + + if (result instanceof Error) throw result; + expect(result.normalized_result).toMatchObject({ + scrubbed_files: ["envs/notes/seed/.config/notes/settings.ini"], + unscrubbed_files: [], + }); + expect( + await readFile( + join(directory, "envs/notes/seed/.config/notes/settings.ini"), + "utf8", + ), + ).toBe( + `[ui]\npref_template_mail=ada@${HOSTNAME}\npref_template_developer=ada\nlast=/home/user/Documents/today.txt\n`, + ); + }); +}); + +describe("exportDesktopApplication where names, syntax or an unparsed value spell the host", () => { + it.each(["file", "kind", "check"])( + "keeps a task on a host named %s, which only a check's field names, kind and id spell", + async (hostname) => { + const { result } = await exportIdentity( + identityCapture({ hostname, user: "ada" }), + [ + { + kind: "lines_present", + file: "Documents/today.txt", + lines: ["milk"], + }, + ], + ); + + if (result instanceof Error) throw result; + expect(result.normalized_result).toMatchObject({ + task: { failing_on_seed: ["check-1"] }, + }); + }, + ); + + it("scrubs dconf strings by their decoded values, so a host named true keeps every key, group and literal", async () => { + const dconf = String.raw`[org/notes/true] +wrap=true +true=uint32 1 +mail='ada@true' +hosts={'true': <'true'>, 'b': <"it's true">} +blob=b'true' +`; + + const { result, directory } = await exportIdentity( + identityCapture({ hostname: "true", user: "ada" }), + [], + dconf, + ); + + if (result instanceof Error) throw result; + const module = await readFile(join(directory, "envs/notes/notes_app.py")); + expect(module.toString("utf8")).toContain( + `DCONF = ${JSON.stringify(`[org/notes/true]\nwrap=true\ntrue=uint32 1\nmail='user@localhost'\nhosts={'true': <'localhost'>, 'b': <"it's localhost">}\nblob=b'true'\n`)}`, + ); + }); + + it("keeps a dconf value whose string does not parse as captured, and lists the module holding it", async () => { + const { result, directory } = await exportIdentity( + identityCapture(), + [], + `[org/notes]\nmail='ada@${HOSTNAME}\nrecent='${HOME}/x'\n`, + ); + + if (result instanceof Error) throw result; + const module = await readFile(join(directory, "envs/notes/notes_app.py")); + expect(module.toString("utf8")).toContain( + `DCONF = ${JSON.stringify(`[org/notes]\nmail='ada@${HOSTNAME}\nrecent='/home/user/x'\n`)}`, + ); + expect(result.normalized_result).toMatchObject({ + unscrubbed_files: [ + "envs/notes/notes_app.py", + "envs/notes/seed/.local/share/notes/notes.db", + ], + }); + }); +}); diff --git a/src/application/desktop/DesktopSigningKeys.test.ts b/src/application/desktop/DesktopSigningKeys.test.ts index 32825e9aae..c177739e0b 100644 --- a/src/application/desktop/DesktopSigningKeys.test.ts +++ b/src/application/desktop/DesktopSigningKeys.test.ts @@ -5,7 +5,11 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; -import type { DesktopApplicationInventory } from "../../domain/desktop/desktopCapture.js"; +import { exportInstall } from "../../domain/desktop/appExportInstall.js"; +import type { + AptRepository, + DesktopApplicationInventory, +} from "../../domain/desktop/desktopCapture.js"; import { DesktopContentStore } from "../../desktop/DesktopContentStore.js"; import { capture } from "./desktopCapture.fixture.js"; import { retainSigningKeys } from "./DesktopSigningKeys.js"; @@ -98,3 +102,74 @@ describe("retainSigningKeys", () => { }, ); }); + +describe("retainSigningKeys before an export", () => { + it("leaves out the program's package when only the repository whose key was dropped supplied it", async () => { + const large = Buffer.alloc(64, 1); + const small = Buffer.alloc(16, 2); + const digest = (bytes: Buffer) => + createHash("sha256").update(bytes).digest("hex"); + const signed = ( + host: string, + bytes: Buffer, + supplies: readonly string[], + ): AptRepository => ({ + source: `https://${host}/linux ./ Packages`, + sources_file: `/etc/apt/sources.list.d/${host}.list`, + uri: `https://${host}/linux`, + suite: "./", + components: [], + architectures: [], + release: "stable", + signing_key: { + path: `/etc/apt/trusted.gpg.d/${host}.gpg`, + sha256: digest(bytes), + format: "binary", + fingerprint: host, + }, + supplies: [...supplies], + }); + const { application } = capture(); + const inventory: DesktopApplicationInventory = { + ...application, + package: { + kind: "deb", + name: "typora", + executable_package: "typora-bin", + version: "1.14.9-1", + architecture: "arm64", + sources: ["https://a.example/linux ./ Packages"], + source_origins: ["typora.io"], + package_lists_present: true, + apt_repositories: [ + signed("a.example", large, ["typora-bin"]), + signed("b.example", small, []), + ], + library_packages: [], + }, + }; + const { content } = await store(1024, 32); + + const retained = await retainSigningKeys( + inventory, + new Map([ + [digest(large), large], + [digest(small), small], + ]), + content, + ); + + expect(retained.package.apt_repositories?.[0]).toMatchObject({ + signing_key: null, + supplies: ["typora-bin"], + }); + expect(exportInstall(retained.package, "typora", "arm64")).toMatchObject({ + ok: false, + error: { + reason: expect.stringContaining( + "typora-bin, which the computer cannot install: only apt repositories the export leaves out offer it", + ), + }, + }); + }); +}); diff --git a/src/application/desktop/DesktopStateVersions.ts b/src/application/desktop/DesktopStateVersions.ts index 649d906cfd..5db5854209 100644 --- a/src/application/desktop/DesktopStateVersions.ts +++ b/src/application/desktop/DesktopStateVersions.ts @@ -1,13 +1,73 @@ +import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import type { DesktopApplicationCapture, DesktopStateChange, } from "../../domain/desktop/desktopCapture.js"; -import type { DesktopStatePath } from "../../domain/desktop/desktopState.js"; +import type { + DesktopStatePath, + StateEnds, +} from "../../domain/desktop/desktopState.js"; import { isWithinAny } from "../../domain/desktop/desktopStateDiff.js"; import { SQLITE_SIDECARS } from "../../domain/desktop/stateFormats.js"; type Version = DesktopStatePath["versions"][number]; +/** The checkpoints a caller named for the seed and the end; unnamed ones default. */ +export interface CheckpointSelection { + readonly seed?: string | undefined; + readonly end?: string | undefined; +} + +/** + * The seed and end among a capture's checkpoint names, by position: the + * baseline and the last checkpoint unless named. Refuses a name the capture + * does not hold exactly once, and an end before the seed. + */ +export const selectStateEnds = ( + checkpoints: readonly string[], + selection: CheckpointSelection, + operation: string, +): StateEnds => { + const refuse = (field: string, userMessage: string) => + new AnalysisInputError( + operation, + { + userMessage, + remediationAction: + "Name a checkpoint inspect-desktop-application-state lists, with the end at or after the seed.", + conformsToInputContract: true, + }, + [{ path: [field], reason: "invalid_value", message: userMessage }], + ); + const position = ( + field: string, + name: string | undefined, + fallback: number, + ) => { + if (name === undefined) return fallback; + const found = checkpoints.flatMap((candidate, index) => + candidate === name ? [index] : [], + ); + if (found.length === 1) return found[0] ?? fallback; + const count = + found.length === 0 + ? "no checkpoint" + : `${String(found.length)} checkpoints`; + throw refuse( + field, + `The capture has ${count} named ${JSON.stringify(name)}; its checkpoints are ${checkpoints.join(", ")}.`, + ); + }; + const seed = position("seed_checkpoint", selection.seed, 0); + const end = position("end_checkpoint", selection.end, checkpoints.length - 1); + if (end < seed) + throw refuse( + "end_checkpoint", + `The end checkpoint ${checkpoints[end] ?? ""} was taken before the seed checkpoint ${checkpoints[seed] ?? ""}.`, + ); + return { seed, end }; +}; + /** The changes of each path in checkpoint order. */ type ChangeIndex = ReadonlyMap< string, diff --git a/src/application/desktop/DesktopTaskDraft.ts b/src/application/desktop/DesktopTaskDraft.ts index 1d50a56d62..349b4cd258 100644 --- a/src/application/desktop/DesktopTaskDraft.ts +++ b/src/application/desktop/DesktopTaskDraft.ts @@ -16,11 +16,15 @@ import { desktopOperationFailure, } from "./DesktopCaptureRefusals.js"; import { exportEnvironmentName } from "./DesktopExportInstall.js"; +import { + selectStateEnds, + type CheckpointSelection, +} from "./DesktopStateVersions.js"; const OPERATION = "describe_desktop_application_task"; const DRAFT_LIMITATIONS = [ - "Files are those under the paths the export owns, whose state differs between the capture's first and last version; data/get serves nothing else.", + "Files are those under the paths the export owns, whose state differs between the seed and end checkpoints, by default the capture's first and last; data/get serves nothing else.", "Suggested checks and views cover documents, or the app's own state when no document changed; each check is derived from the net change and is a starting point, not a task.", "Views longer than 200 items or strings longer than 2,000 characters are cut here (view_cut); inspect-desktop-application-state shows every view whole.", ]; @@ -29,6 +33,7 @@ const draft = async ( folder: string, readers: DesktopStateReaders, signal: AbortSignal | undefined, + selection: CheckpointSelection, ): Promise => { const { evidence, capture } = await readDesktopCapture(folder, signal); const stateEvidence = await inspectDesktopApplicationState( @@ -40,10 +45,15 @@ const draft = async ( const state = desktopApplicationStateSchema.parse( stateEvidence.normalized_result, ); + const { checkpoints } = state.capture; + const ends = selectStateEnds(checkpoints, selection, OPERATION); const { files, paths } = exportedStateFiles(capture, state); - const drafted = draftTaskFiles(files, (home) => isWithinAny(home, paths)); + const drafted = draftTaskFiles( + files, + (home) => isWithinAny(home, paths), + ends, + ); const environment = exportEnvironmentName(capture, undefined); - const checkpoints = state.capture.checkpoints; const result = { application: { environment, @@ -51,7 +61,10 @@ const draft = async ( window_class: capture.session.window_match?.class ?? null, }, owned_paths: [...paths], - checkpoints: { baseline: checkpoints[0], final: checkpoints.at(-1) }, + checkpoints: { + baseline: checkpoints[ends.seed], + final: checkpoints[ends.end], + }, files: drafted.map(({ file, role, baseline, final, change }) => ({ file, role, @@ -74,7 +87,11 @@ const draft = async ( return createEvidence(undefined, DESKTOP_PROVIDER, { predicateType: "rea.desktop-application-task-draft", operation: OPERATION, - parameters: { capture_evidence_id: evidence.evidence_id }, + parameters: { + capture_evidence_id: evidence.evidence_id, + seed_checkpoint: selection.seed ?? null, + end_checkpoint: selection.end ?? null, + }, result, confidence: "inferred", authority: "analyst-inference", @@ -82,14 +99,18 @@ const draft = async ( }); }; -/** Describe how a capture's files changed, with a task draft an agent completes and passes to the export. */ +/** + * Describe how a capture's files changed between the seed and end + * checkpoints, with a task draft an agent completes and passes to the export. + */ export const draftDesktopApplicationTask = async ( folder: string, readers: DesktopStateReaders, signal?: AbortSignal, + selection: CheckpointSelection = {}, ): Promise => { try { - return await draft(folder, readers, signal); + return await draft(folder, readers, signal, selection); } catch (cause: unknown) { return desktopOperationFailure( cause, diff --git a/src/application/desktop/DesktopTaskExport.ts b/src/application/desktop/DesktopTaskExport.ts index a3657f461d..fb4b9c4e9d 100644 --- a/src/application/desktop/DesktopTaskExport.ts +++ b/src/application/desktop/DesktopTaskExport.ts @@ -6,7 +6,6 @@ import { } from "../../domain/desktop/appTask.js"; import { evaluateTaskChecks, - namesXmlNamespace, versionView, type TaskFileView, } from "../../domain/desktop/appTaskChecks.js"; @@ -17,8 +16,17 @@ import { taskVerifierArtifact, } from "../../domain/desktop/appTaskPython.js"; import type { DesktopAppExportPlan } from "../../domain/desktop/appExport.js"; -import type { DesktopStatePath } from "../../domain/desktop/desktopState.js"; +import { + versionAt, + type DesktopStatePath, + type StateEnds, +} from "../../domain/desktop/desktopState.js"; +import { + identityLeftIn, + type HostIdentity, +} from "../../domain/desktop/hostIdentity.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { JsonValue } from "../../domain/jsonValue.js"; import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; import { EvidenceFileError } from "../../domain/evidenceErrors.js"; @@ -33,12 +41,16 @@ import { refuse } from "./DesktopExportInstall.js"; const OPERATION = "export_desktop_application"; const REWRITE = "Fix the task file and export again."; -/** What the export knows of the app: its name, owned paths, captured home and state files by home path. */ +/** What the export knows of the app: its name, owned paths, captured home, host identity, state files by home path and the checkpoints it reads. */ type TaskTarget = Pick< DesktopAppExportPlan, "name" | "paths" | "capturedHome" > & { readonly files: ReadonlyMap; + readonly identity: HostIdentity; + readonly ends: StateEnds; + /** Views of the seeds the export rewrites, as it ships them. */ + readonly seedViews: ReadonlyMap; }; /** A task the capture validated: the files it adds to the bundle, its README section and its summary. */ @@ -101,44 +113,63 @@ const readTaskFile = async ( throw primary ?? typed(loaded.error); }; +/** The task's views of every file at its seed, as the bundle ships it, and at its end. */ +interface TaskViews { + readonly seed: ReadonlyMap; + readonly end: ReadonlyMap; +} + +/** Every string in a value, member names included. */ +const valueStrings = (value: JsonValue | undefined): string[] => { + if (typeof value === "string") return [value]; + if (Array.isArray(value)) return value.flatMap(valueStrings); + if (typeof value !== "object" || value === null) return []; + return Object.entries(value).flatMap(([name, member]) => [ + name, + ...valueStrings(member), + ]); +}; + /** Why a check cannot be validated against this app, or undefined. */ const targetProblem = ( check: DesktopTaskCheck, target: TaskTarget, + views: TaskViews, ): string | undefined => { if (!isWithinAny(check.file, target.paths)) return `names ${check.file}, which is not under the app's owned paths (${target.paths.join(", ") || "none"}); data/get serves only those`; - if (JSON.stringify(check).includes(target.capturedHome)) - return `names the captured home ${target.capturedHome}, which the computer's home replaces`; + // What the check compares and names; its field names, kind and id label it. + const { id: _id, kind: _kind, ...fields } = check; + for (const value of Object.values(fields).flatMap(valueStrings)) { + if (value.includes(target.capturedHome)) + return `names the captured home ${target.capturedHome}, which the computer's home replaces`; + const named = identityLeftIn(value, target.identity); + if (named !== undefined) + return `names ${named}, the capture host's name or user, which the export removes from the seed`; + } if (check.kind !== "value_equals" && check.kind !== "value_absent") return undefined; - const versions = target.files.get(check.file)?.versions ?? []; - if (versions.some((version) => version.format === "text")) - return `points into ${check.file}, which is text; use lines_present, lines_absent or line_count`; - return versions.some( - (version) => version.format === "xml" && namesXmlNamespace(version.view), + return [views.seed, views.end].some( + (at) => at.get(check.file)?.format === "text", ) - ? `points into ${check.file}, whose XML uses namespaces the verifier names differently` + ? `points into ${check.file}, which is text; use lines_present, lines_absent or line_count` : undefined; }; -/** Views of every file at one end of the capture: its first or its last version. */ +/** Views of every file at the end checkpoint, or at the seed as the bundle ships it. */ const viewsAt = ( - files: ReadonlyMap, - end: "baseline" | "final", + target: TaskTarget, + end: keyof StateEnds, ): ReadonlyMap => { const views = new Map(); - for (const [home, path] of files) { - const version = - end === "baseline" ? path.versions[0] : path.versions.at(-1); - // A file not retained at the baseline is not seeded, so the computer starts without it. - if ( - end === "baseline" && - version?.present === true && - version.sha256 === null - ) + for (const [home, path] of target.files) { + const version = versionAt(path, target.ends[end]); + // A file not retained at the seed is not seeded, so the computer starts without it. + if (end === "seed" && version?.present === true && version.sha256 === null) continue; - const view = version === undefined ? undefined : versionView(version); + const shipped = end === "seed" ? target.seedViews.get(home) : undefined; + const view = + shipped ?? (version === undefined ? undefined : versionView(version)); if (view !== undefined) views.set(home, view); } return views; @@ -198,8 +229,12 @@ export const admitDesktopTask = async ( REWRITE, ); const task = parsed.value; + const views: TaskViews = { + seed: viewsAt(target, "seed"), + end: viewsAt(target, "end"), + }; for (const check of task.checks) { - const problem = targetProblem(check, target); + const problem = targetProblem(check, target, views); if (problem !== undefined) throw refuse( `Check ${JSON.stringify(check.id)} ${problem}.`, @@ -207,7 +242,7 @@ export const admitDesktopTask = async ( REWRITE, ); } - const end = evaluateTaskChecks(task.checks, viewsAt(target.files, "final")); + const end = evaluateTaskChecks(task.checks, views.end); const failing = end.find((outcome) => !outcome.passed); if (failing !== undefined) throw refuse( @@ -215,10 +250,7 @@ export const admitDesktopTask = async ( "task", "Write checks that the captured end state passes; describe-desktop-application-task shows its views.", ); - const failingOnSeed = evaluateTaskChecks( - task.checks, - viewsAt(target.files, "baseline"), - ) + const failingOnSeed = evaluateTaskChecks(task.checks, views.seed) .filter((outcome) => !outcome.passed) .map((outcome) => outcome.id); if (failingOnSeed.length === 0) diff --git a/src/application/desktop/DesktopZipView.ts b/src/application/desktop/DesktopZipView.ts new file mode 100644 index 0000000000..d83bb70bc1 --- /dev/null +++ b/src/application/desktop/DesktopZipView.ts @@ -0,0 +1,131 @@ +import { + Reader, + Uint8ArrayWriter, + ZipReader, + type FileEntry, +} from "@zip.js/zip.js"; + +import { + decodeZipEntryText, + ZIP_READER_OPTIONS, + zipEntryPath, +} from "../../artifacts/ZipArtifactReader.js"; +import { + DAMAGED_ZIP_ENTRY, + MAX_ZIP_ENTRIES, + UNREADABLE_ZIP, + unreadZipEntry, + zipEntry, + zipEntryRefusal, + zipView, + type ZipEntryHeader, +} from "../../domain/desktop/zipView.js"; +import { + boundedDirectoryTail, + scanZipDirectory, +} from "../../domain/desktop/zipDirectory.js"; +import type { JsonValue } from "../../domain/jsonValue.js"; +import { err, ok, type Result } from "../../domain/result.js"; + +/** + * An archive as zip.js reads it: its entries in place, then a directory of the + * kept records alone, so zip.js parses no more records than the view lists. + * Reads are copies, as zip.js's own byte array reader returns. + */ +class BoundedArchiveReader extends Reader { + readonly #entries: Uint8Array; + readonly #tail: Uint8Array; + + constructor(entries: Uint8Array, tail: Uint8Array) { + super(entries); + this.#entries = entries; + this.#tail = tail; + this.size = entries.length + tail.length; + } + + override readUint8Array(index: number, length: number): Promise { + const end = Math.min(index + length, this.size); + const split = this.#entries.length; + const read = new Uint8Array(Math.max(0, end - index)); + if (index < split) + read.set(this.#entries.subarray(index, Math.min(end, split))); + if (end > split) + read.set( + this.#tail.subarray(Math.max(0, index - split), end - split), + Math.max(0, split - index), + ); + return Promise.resolve(read); + } +} + +/** An entry's view, its bytes checked against its CRC-32 and declared size, which bounds them. */ +const readEntry = async ( + entry: FileEntry, + header: ZipEntryHeader, + signal: AbortSignal | undefined, +): Promise => { + let bytes: Uint8Array; + try { + bytes = await entry.getData(new Uint8ArrayWriter(), { + checkSignature: true, + ...(signal === undefined ? {} : { signal }), + }); + } catch { + signal?.throwIfAborted(); + return unreadZipEntry(header, DAMAGED_ZIP_ENTRY); + } + return zipEntry(header, bytes); +}; + +/** + * A zip archive's view, read from the digest-checked bytes in memory rather + * than reopened from disk: file entries in archive order up to the listing + * limit, each read by the state view's own readers within the entry and + * archive budgets. Nothing is extracted and no archive inside is opened. + */ +export const readZipView = async ( + bytes: Uint8Array, + signal?: AbortSignal, +): Promise> => { + // The directory is scanned first, so zip.js sees only the records the view lists. + const directory = scanZipDirectory(bytes, MAX_ZIP_ENTRIES); + if (directory === undefined) return err(UNREADABLE_ZIP); + const reader = new ZipReader( + new BoundedArchiveReader( + bytes.subarray(0, directory.start), + boundedDirectoryTail(directory), + ), + ZIP_READER_OPTIONS, + ); + const entries: JsonValue[] = []; + let spent = 0; + try { + for await (const entry of reader.getEntriesGenerator({ + decodeText: decodeZipEntryText, + })) { + signal?.throwIfAborted(); + if (entry.directory) continue; + const name = zipEntryPath(entry); + const header: ZipEntryHeader = { + name, + size: entry.uncompressedSize, + compressedSize: entry.compressedSize, + method: entry.compressionMethod, + encrypted: entry.encrypted, + }; + const refusal = zipEntryRefusal(header, spent); + if (refusal === undefined) spent += header.size; + entries.push( + refusal === undefined + ? await readEntry(entry, header, signal) + : unreadZipEntry(header, refusal), + ); + } + } catch { + signal?.throwIfAborted(); + return err(UNREADABLE_ZIP); + } finally { + await reader.close(); + } + return ok(zipView(entries, directory.count)); +}; diff --git a/src/application/desktop/desktopCapture.fixture.ts b/src/application/desktop/desktopCapture.fixture.ts index 87716917a8..0edada60ac 100644 --- a/src/application/desktop/desktopCapture.fixture.ts +++ b/src/application/desktop/desktopCapture.fixture.ts @@ -15,9 +15,11 @@ export { change, checkpoint, retained }; /** A synthetic capture of a notes app: a settings key file, a document and a SQLite database. */ export const HOME = "/home/ada"; -const sha256 = (text: string) => +/** A retained file's content: text, or bytes such as a zip archive's. */ +type Content = string | Buffer; +const sha256 = (text: Content) => createHash("sha256").update(text).digest("hex"); -const facts = (text: string) => ({ +const facts = (text: Content) => ({ type: "file" as const, size: text.length, mode: 0o644, @@ -26,7 +28,7 @@ const facts = (text: string) => ({ }); const change = ( path: string, - versions: { readonly before?: string; readonly after: string }, + versions: { readonly before?: Content; readonly after: Content }, marks: { readonly state?: boolean } = {}, ) => ({ path, @@ -39,7 +41,7 @@ const change = ( in_state_directory: marks.state === true, opened_by_application: false, }); -const retained = (path: string, text: string) => ({ +const retained = (path: string, text: Content) => ({ path, sha256: sha256(text), size: text.length, @@ -165,10 +167,57 @@ export const capture = (): DesktopApplicationCapture => ({ limitations: [], }); +/** The note as saved before checkpoint `seed`, then as edited and saved before `end`. */ +export const SAVED_NOTE = "milk\n"; +export const EDITED_NOTE = "milk\neggs\n"; + +/** + * The app launches, the user saves a new note and takes `seed`, edits and + * saves it and takes `end`, then stops; the app writes its settings as it + * exits. Write it with `EDITED_NOTE` as extra content. + */ +export const seedEndCapture = (): DesktopApplicationCapture => { + const base = capture(); + const wrap = { before: "[ui]\nwrap=false\n", after: "[ui]\nwrap=true\n" }; + return { + ...base, + state: { + ...base.state, + checkpoints: [ + checkpoint( + "baseline", + "baseline", + [], + [retained(SETTINGS, wrap.before)], + ), + checkpoint( + "seed", + "request", + [change(NOTE, { after: SAVED_NOTE })], + [retained(NOTE, SAVED_NOTE)], + ), + checkpoint( + "end", + "request", + [change(NOTE, { before: SAVED_NOTE, after: EDITED_NOTE })], + [retained(NOTE, EDITED_NOTE)], + ), + { ...checkpoint("stop", "request", [], []), trigger: "stop" }, + checkpoint( + "exit", + "exit", + [change(SETTINGS, wrap, { state: true })], + [retained(SETTINGS, wrap.after)], + ), + ], + }, + }; +}; + export const writeCaptureFolder = async ( result: DesktopApplicationCapture, operation = DESKTOP_OPERATION, - extraContent: readonly string[] = [], + extraContent: readonly Content[] = [], ) => { const folder = await createTestTempDirectory("desktop-state-"); await mkdir(join(folder, "content")); diff --git a/src/artifacts/ZipArtifactReader.ts b/src/artifacts/ZipArtifactReader.ts index 152c22a116..e1b4e2c2ca 100644 --- a/src/artifacts/ZipArtifactReader.ts +++ b/src/artifacts/ZipArtifactReader.ts @@ -28,6 +28,7 @@ import { type DecompressionStreamOptions, type Entry, type FileEntry, + type ZipReaderConstructorOptions, } from "@zip.js/zip.js"; import { @@ -78,6 +79,18 @@ registerCodec({ DecompressionStream: ZipZstandardDecompressionStream, }); +/** zip.js reader options: CRC and overlap checks, and names decoded as REA reads them. */ +export const ZIP_READER_OPTIONS = { + checkSignature: true, + checkOverlappingEntry: true, + // Unflagged names use the ZIP legacy encoding (CP437); the UTF-8 flag + // and validated Unicode Path extra fields remain authoritative. + filenameEncoding: "cp437", + // REA applies its own provider-neutral path validation in scanReader and + // must preserve that typed failure instead of zip.js rejecting first. + filenameValidation: "tolerant", +} as const satisfies ZipReaderConstructorOptions; + class NodeFileReader extends Reader { #handle: FileHandle | undefined; readonly #source: ArtifactFileSource; @@ -169,16 +182,7 @@ export class ZipArtifactReader implements ArtifactReader { ) { this.format = format; this.#source = new NodeFileReader(path, maximumMetadataReadBytes, admitted); - this.#reader = new ZipReader(this.#source, { - checkSignature: true, - checkOverlappingEntry: true, - // Unflagged names use the ZIP legacy encoding (CP437); the UTF-8 flag - // and validated Unicode Path extra fields remain authoritative. - filenameEncoding: "cp437", - // REA applies its own provider-neutral path validation in scanReader and - // must preserve that typed failure instead of zip.js rejecting first. - filenameValidation: "tolerant", - }); + this.#reader = new ZipReader(this.#source, ZIP_READER_OPTIONS); } async *entries(signal?: AbortSignal): AsyncIterable { @@ -368,7 +372,8 @@ const legacyNameLimitations = (entry: Entry): readonly string[] => { ]; }; -const decodeZipEntryText = ( +/** Decode an entry name as zip.js reports it: strict UTF-8 when flagged, CP437 otherwise. */ +export const decodeZipEntryText = ( bytes: Uint8Array, encoding: string, type: "filename" | "comment", @@ -396,7 +401,8 @@ const decodeCp437 = (bytes: Uint8Array): string => : CP437_HIGH_HALF.charAt(byte - 0x80), ).join(""); -const zipEntryPath = (entry: Entry): string => { +/** An entry's name, from a valid Unicode Path extra field when it has one. */ +export const zipEntryPath = (entry: Entry): string => { const field = entry.extraFieldUnicodePath; if (field?.valid !== true) return entry.filename; return decodeStrictZipUtf8( diff --git a/src/cli/desktopCaptureCommands.ts b/src/cli/desktopCaptureCommands.ts index 9addce69ae..cc84695f9e 100644 --- a/src/cli/desktopCaptureCommands.ts +++ b/src/cli/desktopCaptureCommands.ts @@ -74,6 +74,22 @@ const captureOptions = z.object({ ), }); +/** The checkpoints a bundle's seed and a task's end state are read at. */ +const checkpointOptions = { + seedCheckpoint: z + .string() + .optional() + .describe( + "Checkpoint whose state seeds the bundle; the baseline by default", + ), + endCheckpoint: z + .string() + .optional() + .describe( + "Checkpoint whose state a task must pass on; the capture's last by default", + ), +}; + /** * Run a desktop operation with SQLite and dconf readers, finishing through * the shared command owner so the result, cancellation and the SQLite @@ -147,6 +163,7 @@ const registerDesktopExportCommand = ( .describe( "http or https URL the computer downloads the captured --package-file from when the bundle is put, for an application installed from a .deb file", ), + ...checkpointOptions, }), run: ({ args, options }) => withCommandCancellation((signal) => @@ -170,6 +187,8 @@ const registerDesktopExportCommand = ( absoluteCallerPath(path), ), packageUrl: options.packageUrl, + seedCheckpoint: options.seedCheckpoint, + endCheckpoint: options.endCheckpoint, }, readers, signal, @@ -189,13 +208,14 @@ const registerDesktopTaskCommand = ( ): void => { cli.command(CLI_COMMANDS.describeDesktopApplicationTask, { description: - "Describe how a desktop application capture's files changed from the baseline to the end, with suggested checks and a task draft to complete for export-desktop-application --task", + "Describe how a desktop application capture's files changed from the seed checkpoint to the end, with suggested checks and a task draft to complete for export-desktop-application --task", args: z.object({ capture: z .string() .describe("Folder written by capture-desktop-application"), }), - run: ({ args }) => + options: z.object(checkpointOptions), + run: ({ args, options }) => withCommandCancellation((signal) => logCliCommand( logger, @@ -212,6 +232,7 @@ const registerDesktopTaskCommand = ( absoluteCallerPath(args.capture), readers, signal, + { seed: options.seedCheckpoint, end: options.endCheckpoint }, ), ); }, diff --git a/src/desktop/ApplicationStateDirectories.ts b/src/desktop/ApplicationStateDirectories.ts new file mode 100644 index 0000000000..8c81e887ed --- /dev/null +++ b/src/desktop/ApplicationStateDirectories.ts @@ -0,0 +1,106 @@ +import { lstat, readdir } from "node:fs/promises"; +import { basename, join } from "node:path"; + +import type { DesktopApplicationInventory } from "../domain/desktop/desktopCapture.js"; + +/** The environment whose XDG folders, and the home whose dot-folders, may hold the application's state. */ +interface StateLocations { + readonly environment: Readonly>; + readonly home: string; +} + +/** + * Names the application's state folders may carry, including the entry's + * full reverse-DNS id and its last part. The executable is null for a + * `flatpak run` launch, whose `flatpak` launcher names flatpak's own folders. + */ +export const stateNames = ( + package_: DesktopApplicationInventory["package"], + executable: string | null, + entry: DesktopApplicationInventory["desktop_entry"], +): readonly (string | null)[] => [ + package_.kind === "deb" + ? (package_.name?.split(":")[0] ?? null) + : package_.name, + executable === null ? null : basename(executable), + entry?.id ?? null, + entry?.id?.split(".").at(-1) ?? null, + entry?.startup_wm_class ?? null, + entry?.name ?? null, +]; + +/** Whether a home entry is a dotfile named after the application, such as `.varicad-cfg.ini` or `.notesrc`. */ +const appNamedDotfile = (entry: string, names: ReadonlySet): boolean => + [...names].some((name) => { + // Short names would match unrelated dotfiles. + if (name.length < 3 || !entry.startsWith(`.${name}`)) return false; + const rest = entry.slice(name.length + 1); + return rest === "rc" || /^[-._]./u.test(rest); + }); + +/** A folder's entries that are the prefix and then one of the lowercase names in any letter case, such as `GIMP` for `gimp`. */ +const recasedEntries = async ( + folder: string, + prefix: string, + names: ReadonlySet, +): Promise => + (await readdir(folder).catch(() => [] as string[])) + .filter( + (entry) => + entry.startsWith(prefix) && + names.has(entry.slice(prefix.length).toLowerCase()), + ) + .map((entry) => join(folder, entry)); + +/** + * Folder names under XDG and dot-directories where the application may keep + * state, and the dotfiles in the home named after it, which an application + * may only read during a capture. + */ +export const stateDirectories = async ( + names: readonly (string | null)[], + { environment, home }: StateLocations, + extra: readonly string[], +): Promise => { + const bases = [ + environment.XDG_CONFIG_HOME || join(home, ".config"), + environment.XDG_DATA_HOME || join(home, ".local/share"), + environment.XDG_STATE_HOME || join(home, ".local/state"), + environment.XDG_CACHE_HOME || join(home, ".cache"), + ]; + const variants = new Set( + names + .filter( + (name): name is string => + name !== null && + /^[^/\0]+$/u.test(name) && + name !== "." && + name !== "..", + ) + .flatMap((name) => [name, name.toLowerCase()]), + ); + const lowered = new Set([...variants].map((name) => name.toLowerCase())); + const dotfiles = (await readdir(home).catch(() => [] as string[])) + .filter((entry) => appNamedDotfile(entry, variants)) + .map((entry) => join(home, entry)); + // A folder present in another letter case than any name, such as GIMP's `~/.config/GIMP`, is the application's too. + const recased = await Promise.all([ + ...bases.map((base) => recasedEntries(base, "", lowered)), + recasedEntries(home, ".", lowered), + ]); + const paths = new Set([ + ...[...variants].flatMap((name) => [ + ...bases.map((base) => join(base, name)), + join(home, `.${name}`), + ]), + ...recased.flat(), + ...dotfiles, + ...extra, + ]); + return Promise.all( + [...paths].map(async (path) => ({ + path, + exists_at_start: (await lstat(path).catch(() => undefined)) !== undefined, + })), + ); +}; diff --git a/src/desktop/AptRepositoryInventory.test.ts b/src/desktop/AptRepositoryInventory.test.ts new file mode 100644 index 0000000000..9d11b52837 --- /dev/null +++ b/src/desktop/AptRepositoryInventory.test.ts @@ -0,0 +1,42 @@ +import { existsSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { + APT_LOCATIONS, + resolveAptLocations, +} from "./AptRepositoryInventory.js"; +import { runHostQuery, type QueryRunner } from "./HostQuery.js"; + +describe.skipIf( + process.platform !== "linux" || !existsSync("/usr/bin/apt-config"), +)("resolveAptLocations with apt-config", () => { + it("reads the lists folder an APT_CONFIG file names, resolved under Dir::State", async () => { + const root = await createTestTempDirectory("apt-config-"); + const config = join(root, "apt.conf"); + await writeFile( + config, + `Dir::State "${root}/state";\nDir::State::Lists "user lists";\n`, + ); + const configured: QueryRunner = (command, argv, signal) => + runHostQuery("env", [`APT_CONFIG=${config}`, command, ...argv], signal); + + const locations = await resolveAptLocations(configured, undefined); + + expect(locations).toEqual({ + lists: `${root}/state/user lists/`, + trustedParts: "/etc/apt/trusted.gpg.d/", + trusted: "/etc/apt/trusted.gpg", + }); + expect( + await resolveAptLocations( + (_command, argv, signal) => + runHostQuery("/missing/apt-config", argv, signal), + undefined, + ), + ).toEqual(APT_LOCATIONS); + }); +}); diff --git a/src/desktop/AptRepositoryInventory.ts b/src/desktop/AptRepositoryInventory.ts index 7b782f16dd..efbdaf3715 100644 --- a/src/desktop/AptRepositoryInventory.ts +++ b/src/desktop/AptRepositoryInventory.ts @@ -5,6 +5,7 @@ import { aptListFileName, entryForTarget, isUbuntuProRepository, + parseAptConfigShell, parseAptIndexTargets, parseDeb822Sources, parseOneLineSources, @@ -30,6 +31,44 @@ export const APT_LOCATIONS: AptLocations = { lists: "/var/lib/apt/lists", }; +/** + * apt's folders as `apt-config` resolves them under `Dir`, which an + * `APT_CONFIG` file can move, as a user-space `apt-get update` does with its + * lists. Each is the fallback's when apt-config is missing, fails or does not + * name it. + */ +export const resolveAptLocations = async ( + runQuery: QueryRunner, + signal: AbortSignal | undefined, + fallback: AptLocations = APT_LOCATIONS, +): Promise => { + const output = succeeded( + await runQuery( + "apt-config", + [ + "shell", + "LISTS", + "Dir::State::Lists/d", + "PARTS", + "Dir::Etc::TrustedParts/d", + "TRUSTED", + "Dir::Etc::Trusted/f", + ], + signal, + ), + ); + const values = parseAptConfigShell(output ?? ""); + const value = (name: string) => { + const found = values.get(name); + return found?.startsWith("/") === true ? found : undefined; + }; + return { + lists: value("LISTS") ?? fallback.lists, + trustedParts: value("PARTS") ?? fallback.trustedParts, + trusted: value("TRUSTED") ?? fallback.trusted, + }; +}; + /** The host commands and apt folders the repository check reads. */ export interface AptRepositoryHost { readonly runQuery: QueryRunner; diff --git a/src/desktop/DebianPackageInventory.test.ts b/src/desktop/DebianPackageInventory.test.ts index 4f9b0a089a..932ae3d6a7 100644 --- a/src/desktop/DebianPackageInventory.test.ts +++ b/src/desktop/DebianPackageInventory.test.ts @@ -5,6 +5,9 @@ import { mkdir, readFile, realpath, + rename, + rm, + symlink, writeFile, } from "node:fs/promises"; import { join } from "node:path"; @@ -553,6 +556,8 @@ describe.skipIf(process.platform === "win32")("library packages", () => { signing_key: expect.objectContaining({ fingerprint: VENDOR_A.fingerprint, }), + // libnss3 comes from the archive, which does not depend on the repository. + supplies: ["libnspr4"], }, ], library_packages: ["libnspr4", "libnss3"], @@ -610,3 +615,301 @@ describe.skipIf(process.platform === "win32")( }); }, ); + +const WRITER_VERSION = "4:24.2.7-0ubuntu0.24.04.4"; + +/** LibreOffice Writer from the Ubuntu archive: libreoffice-writer owns the entry, libreoffice-common the `libreoffice` it runs. */ +const writerFixture = async () => { + const root = await createTestTempDirectory("desktop-entry-package-"); + for (const folder of [ + "home/.local/share/applications", + "bin", + "share/applications", + "lists", + ]) + await mkdir(join(root, folder), { recursive: true }); + await writeFile(join(root, "bin/libreoffice"), "#!/bin/sh\nexit 0\n"); + await chmod(join(root, "bin/libreoffice"), 0o755); + const entry = + "[Desktop Entry]\nType=Application\nName=LibreOffice Writer\nExec=libreoffice --writer %U\n"; + await writeFile( + join(root, "share/applications/libreoffice-writer.desktop"), + entry, + ); + // A copy the user made, which no package owns. + await writeFile( + join(root, "home/.local/share/applications/my-writer.desktop"), + entry, + ); + await writeFile(join(root, "lists/noble_main_binary-arm64_Packages"), ""); + const runQuery: QueryRunner = async (command, argv) => { + const reply = (stdout: string, exitCode = 0): QueryOutcome => ({ + stdout, + exitCode, + }); + if (command === "apt-cache") + return reply( + argv.length === 1 + ? `Package files:\n 500 ${NOBLE}\n release v=24.04,o=Ubuntu,a=noble,n=noble,l=Ubuntu,c=main,b=arm64\nPinned packages:\n` + : `${argv[1]}:\n Version table:\n *** ${WRITER_VERSION} 500\n 500 ${NOBLE}\n 100 /var/lib/dpkg/status\n`, + ); + if (command === "dpkg-deb") + return reply( + `Package: libreoffice-writer\nVersion: ${WRITER_VERSION}\nArchitecture: arm64\nDepends: libreoffice-core\n`, + ); + if (command !== "dpkg-query") return "missing"; + if (argv[0] === "-W") + return reply(`${argv.at(-1)}\t${WRITER_VERSION}\tarm64\tinstalled\n`); + const path = argv[1] ?? ""; + const owner = path.endsWith("/bin/libreoffice") + ? "libreoffice-common" + : path.endsWith("/share/applications/libreoffice-writer.desktop") + ? "libreoffice-writer" + : undefined; + return owner === undefined ? reply("", 1) : reply(`${owner}: ${path}`); + }; + const inspect = async ( + launch: { desktopEntry: string } | { executable: string }, + options: { packageFile?: string; run?: QueryRunner } = {}, + ) => { + const result = await inspectLinuxApplication( + { + desktopEntry: undefined, + executable: undefined, + ...launch, + arguments: [], + environment: { + PATH: join(root, "bin"), + XDG_DATA_HOME: join(root, "home/.local/share"), + XDG_DATA_DIRS: join(root, "share"), + }, + home: join(root, "home"), + callerDirectory: join(root, "home"), + packageFile: options.packageFile, + }, + undefined, + { + runQuery: options.run ?? runQuery, + apt: { + trustedParts: join(root, "apt/trusted.gpg.d"), + trusted: join(root, "apt/trusted.gpg"), + lists: join(root, "lists"), + }, + }, + ); + if (!result.ok) throw new Error(result.error.reason); + return { + installed: result.value.inventory.package, + limitations: result.value.limitations, + }; + }; + return { root, runQuery, inspect }; +}; + +describe.skipIf(process.platform === "win32")("desktop entry package", () => { + it("records the package that owns the launched entry, and the program's when it differs", async () => { + const { inspect } = await writerFixture(); + + const { installed } = await inspect({ desktopEntry: "libreoffice-writer" }); + + expect(installed).toMatchObject({ + kind: "deb", + name: "libreoffice-writer", + executable_package: "libreoffice-common", + version: WRITER_VERSION, + architecture: "arm64", + sources: [NOBLE], + source_origins: ["Ubuntu"], + package_lists_present: true, + }); + expect(installed).not.toHaveProperty("executable_package_reason"); + }); + + it("finds apt's package lists in the folder apt-config names, or the default when it cannot say", async () => { + const { root, runQuery, inspect } = await writerFixture(); + // A user-space apt-get update under APT_CONFIG keeps its lists elsewhere. + const userLists = join(root, "user's lists"); + await rename(join(root, "lists"), userLists); + await mkdir(join(root, "lists")); + const answering = + (outcome: QueryOutcome): QueryRunner => + async (command, argv, signal) => + command === "apt-config" ? outcome : runQuery(command, argv, signal); + const quoted = `LISTS='${userLists.replaceAll("'", "'\\''")}/'\n`; + + const present = await Promise.all( + [ + { stdout: quoted, exitCode: 0 }, + { stdout: "", exitCode: 100 }, + "missing" as const, + ].map(async (outcome) => { + const { installed } = await inspect( + { desktopEntry: "libreoffice-writer" }, + { run: answering(outcome) }, + ); + return installed.package_lists_present; + }), + ); + + expect(present).toEqual([true, false, false]); + }); + + it("records why the computer cannot install the program's package when no source it can use offers it", async () => { + const { runQuery, inspect } = await writerFixture(); + const local: QueryRunner = async (command, argv, signal) => + command === "apt-cache" && argv[1] === "libreoffice-common" + ? { + stdout: `libreoffice-common:\n Version table:\n *** ${WRITER_VERSION} 100\n 100 /var/lib/dpkg/status\n`, + exitCode: 0, + } + : runQuery(command, argv, signal); + const unasked: QueryRunner = async (command, argv, signal) => + command === "apt-cache" && argv[1] === "libreoffice-common" + ? "failed" + : runQuery(command, argv, signal); + + const fromLocal = await inspect( + { desktopEntry: "libreoffice-writer" }, + { run: local }, + ); + const fromUnasked = await inspect( + { desktopEntry: "libreoffice-writer" }, + { run: unasked }, + ); + + expect(fromLocal.installed).toMatchObject({ + name: "libreoffice-writer", + executable_package: "libreoffice-common", + executable_package_reason: + "neither the Ubuntu 24.04 archive nor a vendor repository the export declares offers libreoffice-common", + }); + expect(fromUnasked.installed).toMatchObject({ + executable_package: "libreoffice-common", + executable_package_reason: + "apt-cache policy did not say where libreoffice-common comes from", + }); + }); + + it.each([ + ["the desktop entry", "libreoffice-writer.desktop"], + ["the executable", "/bin/libreoffice"], + ])( + "leaves the package unknown when dpkg-query cannot say which package owns %s", + async (owned, suffix) => { + const { runQuery, inspect } = await writerFixture(); + const failing = + (outcome: QueryOutcome): QueryRunner => + async (command, argv, signal) => + command === "dpkg-query" && argv[1]?.endsWith(suffix) === true + ? outcome + : runQuery(command, argv, signal); + + for (const outcome of ["failed", { stdout: "", exitCode: 2 }] as const) { + const { installed, limitations } = await inspect( + { desktopEntry: "libreoffice-writer" }, + { run: failing(outcome) }, + ); + + expect(installed).toMatchObject({ kind: "unknown", name: null }); + expect(limitations.join("\n")).toMatch( + /^dpkg-query could not say which package owns \//mu, + ); + expect(limitations.join("\n")).toContain( + `${suffix}, so the package that owns ${owned}`, + ); + } + }, + ); + + it("keeps the program's package for an executable launch and an entry no package owns", async () => { + const { inspect } = await writerFixture(); + + for (const launch of [ + { executable: "libreoffice" }, + { desktopEntry: "my-writer" }, + ]) { + const { installed } = await inspect(launch); + expect(installed).toMatchObject({ + kind: "deb", + name: "libreoffice-common", + }); + expect(installed).not.toHaveProperty("executable_package"); + } + }); + + it("matches a package file against the entry's package", async () => { + const { root, inspect } = await writerFixture(); + const deb = join(root, "libreoffice-writer.deb"); + await writeFile(deb, "package bytes"); + + const { installed } = await inspect( + { desktopEntry: "libreoffice-writer" }, + { packageFile: deb }, + ); + + expect(installed).not.toHaveProperty("package_file_reason"); + expect(installed.package_file).toMatchObject({ + package: "libreoffice-writer", + version: WRITER_VERSION, + depends: ["libreoffice-core"], + }); + }); +}); + +describe.skipIf(process.platform === "win32")( + "desktop entry program ownership", + () => { + it("keeps the entry's package and records that no package owns the program it runs", async () => { + const { runQuery, inspect } = await writerFixture(); + // A program of the user's own, such as ~/.local/bin/libreoffice, found first on PATH. + const unpackaged: QueryRunner = async (command, argv, signal) => + command === "dpkg-query" && + argv[1]?.endsWith("/bin/libreoffice") === true + ? { stdout: "", exitCode: 1 } + : runQuery(command, argv, signal); + + const { installed } = await inspect( + { desktopEntry: "libreoffice-writer" }, + { run: unpackaged }, + ); + + expect(installed).toMatchObject({ + kind: "deb", + name: "libreoffice-writer", + executable_package_reason: expect.stringMatching( + /^no package owns \/\S+\/bin\/libreoffice$/u, + ), + }); + expect(installed).not.toHaveProperty("executable_package"); + }); + + it("counts the program as owned when dpkg owns the file its path resolves to, as for an alternatives link", async () => { + const { root, runQuery, inspect } = await writerFixture(); + const soffice = join(root, "lib/libreoffice/program/soffice"); + await mkdir(join(root, "lib/libreoffice/program"), { recursive: true }); + await writeFile(soffice, "#!/bin/sh\nexit 0\n"); + await chmod(soffice, 0o755); + await rm(join(root, "bin/libreoffice")); + await symlink(soffice, join(root, "bin/libreoffice")); + const resolvedOnly: QueryRunner = async (command, argv, signal) => { + const path = argv[1] ?? ""; + if (command === "dpkg-query" && path.endsWith("/bin/libreoffice")) + return { stdout: "", exitCode: 1 }; + if (command === "dpkg-query" && path.endsWith("/program/soffice")) + return { stdout: `libreoffice-common: ${path}`, exitCode: 0 }; + return runQuery(command, argv, signal); + }; + + const { installed } = await inspect( + { desktopEntry: "libreoffice-writer" }, + { run: resolvedOnly }, + ); + + expect(installed).toMatchObject({ + name: "libreoffice-writer", + executable_package: "libreoffice-common", + }); + expect(installed).not.toHaveProperty("executable_package_reason"); + }); + }, +); diff --git a/src/desktop/DebianPackageInventory.ts b/src/desktop/DebianPackageInventory.ts index e1f8ddcadf..9fd7b1ca5f 100644 --- a/src/desktop/DebianPackageInventory.ts +++ b/src/desktop/DebianPackageInventory.ts @@ -8,8 +8,14 @@ import { fromUbuntuNobleArchive, repositoryRefusal, } from "../domain/desktop/appExportInstall.js"; -import { declaredPackageFiles } from "../domain/desktop/aptRepositories.js"; -import type { DesktopApplicationInventory } from "../domain/desktop/desktopCapture.js"; +import { + declaredPackageFiles, + repositorySupplies, +} from "../domain/desktop/aptRepositories.js"; +import type { + AptRepository, + DesktopApplicationInventory, +} from "../domain/desktop/desktopCapture.js"; import { DPKG_STATUS_FORMAT, ownershipCandidates, @@ -20,12 +26,14 @@ import { } from "../domain/desktop/linuxPackages.js"; import { inspectAptRepositories, + resolveAptLocations, type AptRepositoryHost, } from "./AptRepositoryInventory.js"; import { succeeded } from "./HostQuery.js"; import { inspectLibraryPackages, installableLibraries, + installablePackages, } from "./LibraryPackageInventory.js"; /** The host commands and apt folders Debian package identification reads. */ @@ -108,7 +116,10 @@ const settled = async ( * the repository check. */ const installFacts = async ( - installed: Pick, + installed: Pick< + Package, + "name" | "sources" | "source_origins" | "executable_package" + >, executable: string, context: InventoryContext, origins: ReadonlyMap | undefined, @@ -132,33 +143,99 @@ const installFacts = async ( context.limitations.push(...vendor.limitations); for (const [sha256, bytes] of vendor.keys) context.signingKeys.set(sha256, bytes); + const usable = (repository: AptRepository) => + repositoryRefusal(repository) === undefined; const declared = declaredPackageFiles( - vendor.repositories.filter( - (repository) => repositoryRefusal(repository) === undefined, - ), + vendor.repositories.filter(usable), vendor.indexTargets, ); + const program = installed.executable_package; + const split = await installablePackages( + [ + ...(libraries?.library_packages ?? []), + ...(program === undefined ? [] : [program]), + ], + { origins, declared }, + runQuery, + context.signal, + ); + // The export checks these against the repositories it still declares. + const supplies = (repository: AptRepository) => + split === undefined || !usable(repository) + ? [] + : repositorySupplies(repository, split.supplied, vendor.indexTargets); return { - apt_repositories: [...vendor.repositories], - ...(libraries === undefined - ? {} - : await installableLibraries( - libraries, - { origins, declared }, - runQuery, - context.signal, - )), + apt_repositories: vendor.repositories.map((repository) => ({ + ...repository, + supplies: [...supplies(repository)], + })), + ...(libraries === undefined ? {} : installableLibraries(libraries, split)), + ...(program === undefined ? {} : executablePackageFacts(program, split)), }; }; +/** Why the computer cannot install the executable's package by name beside the entry's, by the library packages' rule; nothing when it can. */ +const executablePackageFacts = ( + name: string, + split: Awaited>, +): Pick => { + if (split?.installable.includes(name) === true) return {}; + return { + executable_package_reason: + split === undefined + ? `apt-cache policy did not say where ${name} comes from` + : `neither the Ubuntu 24.04 archive nor a vendor repository the export declares offers ${name}`, + }; +}; + +/** Which package dpkg says owns the paths, or why it could not say. */ +type Ownership = + | { readonly kind: "owned"; readonly name: string } + | { readonly kind: "unowned" } + | { readonly kind: "unknown"; readonly reason: string }; + +/** The owner of the first path dpkg owns; unknown once a query fails, since that path's owner would have come first. */ +const dpkgOwner = async ( + paths: readonly string[], + { host, signal }: InventoryContext, +): Promise => { + for (const path of ownershipCandidates(paths)) { + const outcome = await host.runQuery("dpkg-query", ["-S", path], signal); + if (outcome === "missing") + return { kind: "unknown", reason: "dpkg-query is not installed" }; + // dpkg-query exits 1 when no package owns the path and 2 when it could not tell. + if (outcome === "failed" || outcome.exitCode > 1) + return { + kind: "unknown", + reason: `dpkg-query could not say which package owns ${path}`, + }; + const [name] = parseDpkgOwners(outcome.stdout, path); + if (name !== undefined) return { kind: "owned", name }; + } + return { kind: "unowned" }; +}; + +/** A package name without the `:` dpkg may qualify it with. */ +const unqualified = (name: string): string => name.split(":")[0] ?? name; + +/** The files a Debian package is identified from. */ +export interface DebianPackagePaths { + /** The program as launched and as resolved; dpkg may own either. */ + readonly program: readonly string[]; + /** The resolved program, whose libraries are walked. */ + readonly executable: string; + /** The launched desktop entry and its resolved file; empty for an executable launch. */ + readonly entry: readonly string[]; +} + /** - * The Debian package that owns the executable, with the apt sources of its - * installed version, the vendor repositories among them and the packages of - * the libraries the executable loads; `unknown` when dpkg owns no candidate. + * The Debian package that owns the desktop entry, or else the executable, + * with the apt sources of its installed version, the vendor repositories + * among them and the packages of the libraries the executable loads; + * `unknown` when dpkg owns neither. */ export const debianPackage = async ( - paths: readonly string[], - executable: string, + { program, executable, entry }: DebianPackagePaths, context: InventoryContext, ): Promise => { const { host, signal, limitations } = context; @@ -170,57 +247,77 @@ export const debianPackage = async ( sources: [], package_lists_present: null, }; - for (const path of ownershipCandidates(paths)) { - const owners = await host.runQuery("dpkg-query", ["-S", path], signal); - if (owners === "missing") { - limitations.push( - "dpkg-query is not installed, so the package that owns the executable is unknown.", - ); - return unknown; - } - const [name] = parseDpkgOwners(succeeded(owners) ?? "", path); - if (name === undefined) continue; - const status = succeeded( - await host.runQuery( - "dpkg-query", - ["-W", `-f=${DPKG_STATUS_FORMAT}`, name], - signal, - ), + const executableOwner = await dpkgOwner(program, context); + if (executableOwner.kind === "unknown") { + limitations.push( + `${executableOwner.reason}, so the package that owns the executable is unknown.`, ); - const parsed = status === undefined ? undefined : parseDpkgStatus(status); - const policy = succeeded( - await host.runQuery("apt-cache", ["policy", name], signal), - ); - const listsPresent = ( - await readdir(host.apt.lists).catch(() => [] as string[]) - ).some((list) => /_Packages(?:\.[a-z0-9]+)?$/u.test(list)); - if (!listsPresent) - limitations.push( - "No apt package lists are present, so whether the installed version comes from a package archive is unknown.", - ); - const sources = - policy === undefined ? [] : [...parseAptPolicySources(policy)]; - // A local install's library packages are matched to the archive by origin too. - const origins = await policyOrigins(context); - const installed = { - kind: "deb" as const, - name: parsed?.name ?? name, - version: parsed?.version ?? null, - architecture: parsed?.architecture ?? null, - sources, - ...(origins === undefined - ? {} - : { - source_origins: sources.map( - (source) => origins.get(source) ?? null, - ), - }), - package_lists_present: listsPresent, - }; - return { - ...installed, - ...(await installFacts(installed, executable, context, origins)), - }; + return unknown; } - return unknown; + // The entry's package installs the application, whose program can come from another package. + const entryOwner = + entry.length === 0 + ? ({ kind: "unowned" } as const) + : await dpkgOwner(entry, context); + if (entryOwner.kind === "unknown") { + limitations.push( + `${entryOwner.reason}, so the package that owns the desktop entry, which installs the application, is unknown.`, + ); + return unknown; + } + const owner = executableOwner.kind === "owned" ? executableOwner.name : null; + const name = entryOwner.kind === "owned" ? entryOwner.name : owner; + if (name === null) return unknown; + const status = succeeded( + await host.runQuery( + "dpkg-query", + ["-W", `-f=${DPKG_STATUS_FORMAT}`, name], + signal, + ), + ); + const parsed = status === undefined ? undefined : parseDpkgStatus(status); + const policy = succeeded( + await host.runQuery("apt-cache", ["policy", name], signal), + ); + const apt = await resolveAptLocations(host.runQuery, signal, host.apt); + const listsPresent = ( + await readdir(apt.lists).catch(() => [] as string[]) + ).some((list) => /_Packages(?:\.[a-z0-9]+)?$/u.test(list)); + if (!listsPresent) + limitations.push( + "No apt package lists are present, so whether the installed version comes from a package archive is unknown.", + ); + const sources = + policy === undefined ? [] : [...parseAptPolicySources(policy)]; + // A local install's library packages are matched to the archive by origin too. + const origins = await policyOrigins(context); + const installed = { + kind: "deb" as const, + name: parsed?.name ?? name, + ...(owner === null || unqualified(owner) === unqualified(name) + ? {} + : { executable_package: unqualified(owner) }), + // Installing the entry's package does not bring a program no package owns, such as ~/.local/bin/. + ...(owner === null + ? { executable_package_reason: `no package owns ${executable}` } + : {}), + version: parsed?.version ?? null, + architecture: parsed?.architecture ?? null, + sources, + ...(origins === undefined + ? {} + : { + source_origins: sources.map((source) => origins.get(source) ?? null), + }), + package_lists_present: listsPresent, + }; + return { + ...installed, + ...(await installFacts( + installed, + executable, + { ...context, host: { ...host, apt } }, + origins, + )), + }; }; diff --git a/src/desktop/DesktopApplicationSession.test.ts b/src/desktop/DesktopApplicationSession.test.ts index bfefc47361..c330c4b306 100644 --- a/src/desktop/DesktopApplicationSession.test.ts +++ b/src/desktop/DesktopApplicationSession.test.ts @@ -1,4 +1,4 @@ -import { readdir } from "node:fs/promises"; +import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; @@ -64,6 +64,33 @@ describe.skipIf(process.platform !== "linux")("runDesktopSession", () => { expect(checkpoints).toEqual(["stop", "exit"]); expect(vi.mocked(terminateTokenOwnedProcesses)).toHaveBeenCalledTimes(1); }, 30_000); + + it("launches the application without the agent environment's variables", async () => { + const root = await createTestTempDirectory("desktop-session-env-"); + const plan = shellPlan(root, `env > "${join(root, "env")}"`); + + await runDesktopSession({ + ...plan, + environment: { + ...plan.environment, + AGENTENV_CONTROL_TOKEN: "control", + AGENTENV_URL: "http://127.0.0.1:1", + NOT_AGENTENV_KEPT: "kept", + }, + }); + + const names = (await readFile(join(root, "env"), "utf8")) + .split("\n") + .map((line) => line.split("=")[0]); + expect(names.filter((name) => name?.startsWith("AGENTENV_"))).toEqual([]); + expect(names).toEqual( + expect.arrayContaining([ + "NOT_AGENTENV_KEPT", + "PATH", + "REA_PROCESS_RUN_ID", + ]), + ); + }, 30_000); }); describe.skipIf(process.platform !== "linux")( diff --git a/src/desktop/DesktopApplicationSession.ts b/src/desktop/DesktopApplicationSession.ts index 98fc36df42..5908853753 100644 --- a/src/desktop/DesktopApplicationSession.ts +++ b/src/desktop/DesktopApplicationSession.ts @@ -264,6 +264,20 @@ const unreleased = ( ], }; +/** + * The caller's environment without the agent environment's own variables, + * such as its control token: agentenv launches the exported app without + * them, and the app must not hold a credential to the computer it runs on. + */ +const applicationEnvironment = ( + environment: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv => + Object.fromEntries( + Object.entries(environment).filter( + ([name]) => !name.startsWith("AGENTENV_"), + ), + ); + const launch = async ( plan: DesktopSessionPlan, runId: string, @@ -277,7 +291,7 @@ const launch = async ( // Desktop launchers often exec through env, a shell or a wrapper script. expectedCommand: null, cwd: plan.workingDirectory, - hostEnvironment: plan.environment, + hostEnvironment: applicationEnvironment(plan.environment), sweepTokenOwnedProcesses: true, signal, }); diff --git a/src/desktop/LibraryPackageInventory.ts b/src/desktop/LibraryPackageInventory.ts index 9b91d34171..e7f003a89b 100644 --- a/src/desktop/LibraryPackageInventory.ts +++ b/src/desktop/LibraryPackageInventory.ts @@ -205,26 +205,37 @@ const packageSources = async ( return sources; }; -/** - * Keep the library packages the computer can install: those some version of - * which the Ubuntu 24.04 archive or a declared vendor source offers, as - * `apt-cache policy` lists them; the rest are recorded as uninstallable. - */ -export const installableLibraries = async ( - libraries: LibraryPackages, +/** The packages the computer can install by name, and the rest, as `installableLibraryPackages` decides from `apt-cache policy`; undefined when apt could not be asked. */ +export const installablePackages = async ( + names: readonly string[], available: { readonly origins: ReadonlyMap | undefined; readonly declared: ReadonlySet; }, runQuery: QueryRunner, signal: AbortSignal | undefined, -): Promise => { - const names = libraries.library_packages; +): Promise | undefined> => { const sources = names.length === 0 ? new Map() : await packageSources(names, runQuery, signal); - if (sources === undefined) + return sources === undefined + ? undefined + : installableLibraryPackages(names, { ...available, sources }); +}; + +/** + * Keep the library packages the computer can install: those some version of + * which the Ubuntu 24.04 archive or a declared vendor source offers, as + * `apt-cache policy` listed them for `installablePackages`; the rest are + * recorded as uninstallable. + */ +export const installableLibraries = ( + libraries: LibraryPackages, + split: Awaited>, +): LibraryPackages => { + const names = libraries.library_packages; + if (split === undefined && names.length > 0) return { ...libraries, library_packages: [], @@ -236,10 +247,11 @@ export const installableLibraries = async ( .filter((reason) => reason !== undefined) .join("; "), }; - const split = installableLibraryPackages(names, { ...available, sources }); + const installable = (name: string) => + split?.installable.includes(name) === true; return { ...libraries, - library_packages: split.installable, - uninstallable_library_packages: split.uninstallable, + library_packages: names.filter(installable), + uninstallable_library_packages: names.filter((name) => !installable(name)), }; }; diff --git a/src/desktop/LinuxApplicationInventory.test.ts b/src/desktop/LinuxApplicationInventory.test.ts index 6f12851e1d..e530e79eb5 100644 --- a/src/desktop/LinuxApplicationInventory.test.ts +++ b/src/desktop/LinuxApplicationInventory.test.ts @@ -302,6 +302,40 @@ describe.skipIf(process.platform === "win32")( }); }); + it("adds a state folder named after the application in another letter case, such as GIMP's", async () => { + const installed = await fixture(); + const { home, bin } = installed; + await writeFile(join(bin, "gimp-2.10"), "#!/bin/sh\nexit 0\n"); + await chmod(join(bin, "gimp-2.10"), 0o755); + await mkdir(join(home, ".local/share/applications"), { recursive: true }); + await writeFile( + join(home, ".local/share/applications/gimp.desktop"), + "[Desktop Entry]\nType=Application\nName=GNU Image Manipulation Program\nExec=gimp-2.10 %U\n", + ); + await mkdir(join(home, ".config/GIMP/2.10"), { recursive: true }); + await writeFile(join(home, ".config/GIMP/2.10/sessionrc"), "x\n"); + await mkdir(join(home, ".Gimp")); + const unrelated = [ + ".config/GIMPshop", + ".config/MyGIMP", + ".config/Other/GIMP", + ]; + for (const folder of unrelated) + await mkdir(join(home, folder), { recursive: true }); + + const inventory = await inventoryOf("gimp", installed); + + expect(inventory.state_directories).toEqual( + expect.arrayContaining([ + { path: join(home, ".config/GIMP"), exists_at_start: true }, + { path: join(home, ".Gimp"), exists_at_start: true }, + ]), + ); + const paths = inventory.state_directories.map((entry) => entry.path); + for (const folder of unrelated) + expect(paths).not.toContain(join(home, folder)); + }); + it("adds the dotfiles in the home named after the application, which it may only read", async () => { const installed = await fixture(); const { home } = installed; diff --git a/src/desktop/LinuxApplicationInventory.ts b/src/desktop/LinuxApplicationInventory.ts index af12178661..a72261ec41 100644 --- a/src/desktop/LinuxApplicationInventory.ts +++ b/src/desktop/LinuxApplicationInventory.ts @@ -1,4 +1,4 @@ -import { lstat, readdir, realpath, stat } from "node:fs/promises"; +import { lstat, realpath, stat } from "node:fs/promises"; import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import type { DesktopApplicationInventory } from "../domain/desktop/desktopCapture.js"; @@ -21,6 +21,7 @@ import { readStableFileBytes, readStableFilePrefix, } from "../filesystem/readStableFileBytes.js"; +import { stateDirectories, stateNames } from "./ApplicationStateDirectories.js"; import { APT_LOCATIONS } from "./AptRepositoryInventory.js"; import { inspectPackageFile } from "./DebianPackageFile.js"; import { @@ -176,10 +177,12 @@ interface ProgramLocation { readonly program: string; readonly resolved: string | null; readonly flatpak: string | null; + /** The launched desktop entry and its resolved file; empty for an executable launch. */ + readonly entry: readonly string[]; } const identifyPackage = async ( - { program, resolved, flatpak }: ProgramLocation, + { program, resolved, flatpak, entry }: ProgramLocation, context: InventoryContext, ): Promise => { const base = { @@ -201,8 +204,11 @@ const identifyPackage = async ( ) return { kind: "appimage", name: basename(resolved ?? program), ...base }; return debianPackage( - [program, ...(resolved === null ? [] : [resolved])], - resolved ?? program, + { + program: [program, ...(resolved === null ? [] : [resolved])], + executable: resolved ?? program, + entry, + }, context, ); }; @@ -231,61 +237,6 @@ const electronMarkers = async ( return markers; }; -/** Whether a home entry is a dotfile named after the application, such as `.varicad-cfg.ini` or `.notesrc`. */ -const appNamedDotfile = (entry: string, names: ReadonlySet): boolean => - [...names].some((name) => { - // Short names would match unrelated dotfiles. - if (name.length < 3 || !entry.startsWith(`.${name}`)) return false; - const rest = entry.slice(name.length + 1); - return rest === "rc" || /^[-._]./u.test(rest); - }); - -/** - * Folder names under XDG and dot-directories where the application may keep - * state, and the dotfiles in the home named after it, which an application - * may only read during a capture. - */ -const stateDirectories = async ( - names: readonly (string | null)[], - { environment, home }: Pick, - extra: readonly string[], -): Promise => { - const bases = [ - environment.XDG_CONFIG_HOME || join(home, ".config"), - environment.XDG_DATA_HOME || join(home, ".local/share"), - environment.XDG_STATE_HOME || join(home, ".local/state"), - environment.XDG_CACHE_HOME || join(home, ".cache"), - ]; - const variants = new Set( - names - .filter( - (name): name is string => - name !== null && - /^[^/\0]+$/u.test(name) && - name !== "." && - name !== "..", - ) - .flatMap((name) => [name, name.toLowerCase()]), - ); - const dotfiles = (await readdir(home).catch(() => [] as string[])) - .filter((entry) => appNamedDotfile(entry, variants)) - .map((entry) => join(home, entry)); - const paths = new Set([ - ...[...variants].flatMap((name) => [ - ...bases.map((base) => join(base, name)), - join(home, `.${name}`), - ]), - ...dotfiles, - ...extra, - ]); - return Promise.all( - [...paths].map(async (path) => ({ - path, - exists_at_start: (await lstat(path).catch(() => undefined)) !== undefined, - })), - ); -}; - const launchCommand = async ( request: LinuxApplicationRequest, signal: AbortSignal | undefined, @@ -348,26 +299,6 @@ const launchCommand = async ( }); }; -/** - * Names the application's state folders may carry, including the entry's - * full reverse-DNS id and its last part. The executable is null for a - * `flatpak run` launch, whose `flatpak` launcher names flatpak's own folders. - */ -const stateNames = ( - package_: Package, - executable: string | null, - entry: DesktopApplicationInventory["desktop_entry"], -): readonly (string | null)[] => [ - package_.kind === "deb" - ? (package_.name?.split(":")[0] ?? null) - : package_.name, - executable === null ? null : basename(executable), - entry?.id ?? null, - entry?.id?.split(".").at(-1) ?? null, - entry?.startup_wm_class ?? null, - entry?.name ?? null, -]; - /** Resolve what the request launches, where it came from and where it may keep state. */ export const inspectLinuxApplication = async ( request: LinuxApplicationRequest, @@ -404,8 +335,12 @@ export const inspectLinuxApplication = async ( }; const { limitations, signingKeys } = context; const flatpak = wrapper.kind === "flatpak" ? wrapper.application_id : null; + const entryFiles = + entry === null + ? [] + : [entry.path, await realpath(entry.path).catch(() => entry.path)]; const identified = await identifyPackage( - { program: path, resolved, flatpak }, + { program: path, resolved, flatpak, entry: entryFiles }, context, ); const package_ = diff --git a/src/domain/desktop/appExport.test.ts b/src/domain/desktop/appExport.test.ts index 9c5ed33a05..9019a4bcdf 100644 --- a/src/domain/desktop/appExport.test.ts +++ b/src/domain/desktop/appExport.test.ts @@ -29,6 +29,7 @@ const plan: DesktopAppExportPlan = { windowClass: "Mousepad", paths: [".config/Mousepad", "Documents"], capturedHome: "/home/ada", + seedCheckpoint: "baseline", seedFiles: ["Documents/todo.txt"], textSeeds: ["Documents/todo.txt"], unseededFiles: [], @@ -58,7 +59,7 @@ describe("names", () => { it("keeps the class off the module's own names and the module off other modules", () => { expect(className("path")).toBe("PathApp"); expect(className("data-part")).toBe("DataPartApp"); - expect(className("element-tree")).toBe("ElementTreeApp"); + expect(className("run-script")).toBe("RunScriptApp"); expect(className("p-a-t-h-s")).toBe("PATHSApp"); expect(appModuleFile("calendar")).toBe("calendar_app.py"); expect(appModuleFile("gnu-cash")).toBe("gnu_cash_app.py"); @@ -76,6 +77,19 @@ describe("ownedPaths", () => { ).toEqual([".config/app", "Documents"]); }); + it("owns a folder beside a state directory, differing only in letter case, in the file's spelling", () => { + expect( + ownedPaths( + [".config/GIMP/2.10/sessionrc", ".Gimp/x"], + [], + [".config/gimp", ".gimp"], + ), + ).toEqual([".Gimp", ".config/GIMP"]); + expect( + ownedPaths([".config/GIMPshop/2.10/sessionrc"], [], [".config/gimp"]), + ).toEqual([".config/GIMPshop/2.10"]); + }); + it("owns a file directly in the home or a shared folder as itself", () => { expect(ownedPaths(["books.gnucash"], [], [])).toEqual(["books.gnucash"]); expect( diff --git a/src/domain/desktop/appExport.ts b/src/domain/desktop/appExport.ts index b9d67e7bd0..22d9b66bfb 100644 --- a/src/domain/desktop/appExport.ts +++ b/src/domain/desktop/appExport.ts @@ -13,6 +13,8 @@ import { import type { DesktopApplicationCapture } from "./desktopCapture.js"; import type { DesktopApplicationState } from "./desktopState.js"; import { isWithinAny } from "./desktopStateDiff.js"; +import { SEED_HOME } from "./hostIdentity.js"; +import { stateDirectoryOf } from "./stateRoles.js"; /** agentenv-framework release the exported class was checked against. */ export const DESKTOP_EXPORT_FRAMEWORK_VERSION = "0.9.1431"; @@ -35,6 +37,8 @@ export interface DesktopAppExportPlan { readonly architectures: readonly string[]; /** The package file downloaded and installed in place of the app's apt package; null when apt installs it. */ readonly packageFile: ExportPackageFile | null; + /** The package of the program the desktop entry runs, installed by name after the app's; absent when the app's package owns it. */ + readonly executablePackage?: string; /** Whether the app is Electron, whose launch then runs without Chromium's sandbox. */ readonly electron: boolean; readonly command: readonly string[]; @@ -42,13 +46,15 @@ export interface DesktopAppExportPlan { /** Home-relative paths the app owns for data add, get and reset. */ readonly paths: readonly string[]; readonly capturedHome: string; + /** The checkpoint whose state the seed holds. */ + readonly seedCheckpoint: string; /** Home-relative files copied into the computer's home before the first launch. */ readonly seedFiles: readonly string[]; - /** Seed files read as text, which get the captured home path respelled. */ + /** Seed files read as text, which get the seed's home path respelled. */ readonly textSeeds: readonly string[]; /** Files under the owned paths that existed before the capture but whose bytes were not retained. */ readonly unseededFiles: readonly string[]; - /** `dconf dump` key file of the application's groups at the baseline, or empty. */ + /** `dconf dump` key file of the application's groups at the seed, or empty. */ readonly dconf: string; readonly captureEvidenceId: string; } @@ -101,9 +107,10 @@ const SHARED_FOLDERS = new Set([ ]); /** - * The paths the app owns: the state directory holding each state file, or - * a document's folder; a file directly in the home or a shared folder is - * owned as itself. Nested paths collapse into the outermost one. + * The paths the app owns: the state directory holding each state file, in + * the file's letter case, or a document's folder; a file directly in the home + * or a shared folder is owned as itself. Nested paths collapse into the + * outermost one. */ export const ownedPaths = ( stateFiles: readonly string[], @@ -114,12 +121,10 @@ export const ownedPaths = ( const owner = (file: string, folder: string) => SHARED_FOLDERS.has(folder) ? file : folder; const folderOf = (file: string) => file.split("/").slice(0, -1).join("/"); - for (const file of stateFiles) { - const directory = stateDirectories.find((candidate) => - isWithinAny(file, [candidate]), + for (const file of stateFiles) + folders.add( + owner(file, stateDirectoryOf(file, stateDirectories) ?? folderOf(file)), ); - folders.add(owner(file, directory ?? folderOf(file))); - } for (const file of documents) folders.add(owner(file, folderOf(file))); const sorted = [...folders].filter((folder) => folder.length > 0).sort(); return sorted.filter( @@ -319,10 +324,11 @@ const installName = (plan: DesktopAppExportPlan): string => ? plan.aptPackage : `${plan.aptPackage}/${plan.aptRelease}`; -/** The app's package, or a package file's dependencies, then its library packages and dconf-cli when settings are loaded. */ +/** The app's package, or a package file's dependencies, then its program's package, its library packages and dconf-cli when settings are loaded. */ const aptPackages = (plan: DesktopAppExportPlan): readonly string[] => [ ...new Set([ ...(plan.packageFile?.dependencies ?? [installName(plan)]), + ...(plan.executablePackage === undefined ? [] : [plan.executablePackage]), ...plan.libraryPackages, ...(plan.dconf.length === 0 ? [] : ["dconf-cli"]), ]), @@ -360,12 +366,12 @@ export const renderAppClass = (plan: DesktopAppExportPlan): string => { return `"""${docText(plan.title)} as an agentenv computer app, exported by REA from a desktop application capture. Package: ${plan.aptPackage}${plan.packageVersion === null ? "" : ` (captured at ${plan.packageVersion})`}. Capture Evidence: ${plan.captureEvidenceId}. -The seed is the user's state before the capture: keep this bundle local. +The seed is the user's state at checkpoint ${docText(plan.seedCheckpoint)}: keep this bundle local. """ ${PYTHON_IMPORTS} HERE = Path(__file__).resolve().parent -CAPTURED_HOME = ${py(plan.capturedHome)} +SEED_HOME = ${py(SEED_HOME)} PATHS = ${pyList(plan.paths)} DCONF = ${py(plan.dconf)} TEXT_SEEDS = frozenset(${pyList(plan.textSeeds)}) @@ -430,6 +436,11 @@ const packageFileNotes = ({ packageFile: file }: DesktopAppExportPlan) => const installNotes = (plan: DesktopAppExportPlan): string => [ ...packageFileNotes(plan), + ...(plan.executablePackage === undefined + ? [] + : [ + `- \`${plan.executablePackage}\`, which owns the program the desktop entry runs, is installed with it.`, + ]), ...(plan.libraryPackages.length === 0 ? [] : [ @@ -445,10 +456,36 @@ const installNotes = (plan: DesktopAppExportPlan): string => .map((line) => `\n${line}`) .join(""); +/** Bundle files: seeds the export rewrote to remove the capture host's identity, and files that still hold it. */ +export interface SeedScrubReport { + readonly scrubbed: readonly string[]; + readonly unscrubbed: readonly string[]; +} + +const homeList = (paths: readonly string[]) => + paths.map((path) => `\`~/${path}\``).join(", "); + +const bundleList = (paths: readonly string[]) => + paths.map((path) => `\`${path}\``).join(", "); + +const scrubNotes = ({ scrubbed, unscrubbed }: SeedScrubReport): string => + [ + ...(scrubbed.length === 0 + ? [] + : [`\n Rewritten: ${bundleList(scrubbed)}.`]), + ...(unscrubbed.length === 0 + ? [] + : [ + `\n- Still holding the capture host's name, user@host or home, in their name or in content REA does not rewrite: + ${bundleList(unscrubbed)}. Read them before sharing the bundle.`, + ]), + ].join(""); + /** The bundle README: what was exported, how to run it, and what to keep in mind; a task's section follows. */ export const renderReadme = ( plan: DesktopAppExportPlan, taskSection = "", + scrub: SeedScrubReport = { scrubbed: [], unscrubbed: [] }, ): string => `# ${plan.title} computer app @@ -461,12 +498,13 @@ ${DESKTOP_EXPORT_FRAMEWORK_VERSION}. when a window of class \`${plan.windowClass}\` appears.${installNotes(plan)} - Owned paths: ${plan.paths.length === 0 ? "none" : plan.paths.map((path) => `\`~/${path}\``).join(", ")}. \`data/get\` returns their files and a typed JSON view of each; \`data/reset\` restores the seed. -- Seed: ${plan.seedFiles.length} file(s) from the user's home before the capture, with the captured home path - respelled in text files${plan.dconf.length === 0 ? "" : ", and the application's dconf keys"}.${ +- Seed: ${plan.seedFiles.length} file(s) from the user's home at checkpoint \`${plan.seedCheckpoint}\`${plan.dconf.length === 0 ? "" : ", and the application's dconf keys"}. + Text files and dconf keys spell the capture host's name as \`localhost\`, \`user@host\` as \`user@localhost\` + and the home as \`${SEED_HOME}\`, which the computer respells to its own at boot.${scrubNotes(scrub)}${ plan.unseededFiles.length === 0 ? "" : ` -- Not seeded, because the capture did not retain their starting bytes: ${plan.unseededFiles.map((path) => `\`~/${path}\``).join(", ")}. +- Not seeded, because the capture did not retain their bytes at the seed: ${homeList(plan.unseededFiles)}. Capture again with \`--state-path\` naming them, or a larger \`--max-file-bytes\`, to include them.` } - \`tasks/smoke.json\` deploys the app. diff --git a/src/domain/desktop/appExportInstall.test.ts b/src/domain/desktop/appExportInstall.test.ts index ff4479a0b6..c238aaafb1 100644 --- a/src/domain/desktop/appExportInstall.test.ts +++ b/src/domain/desktop/appExportInstall.test.ts @@ -4,9 +4,9 @@ import { electronLaunchArguments, exportInstall, fromUbuntuNobleArchive, - installLimitations, repositoryRefusal, } from "./appExportInstall.js"; +import { installLimitations } from "./appExportInstallLimitations.js"; import { VARICAD_FILE, VARICAD_URL, @@ -214,6 +214,165 @@ describe("exportInstall", () => { }); }); +describe("exportInstall with the program in another package", () => { + const archive: DesktopPackage = { + ...typora(undefined), + name: "vim-common", + executable_package: "vim", + sources: ["http://archive.ubuntu.com/ubuntu noble/main amd64 Packages"], + source_origins: ["Ubuntu"], + }; + + it("installs the program's package beside the entry's from the archive, a vendor repository or a package file", () => { + const fromVendor = exportInstall( + { ...typora([repository()]), executable_package: "typora-bin" }, + "typora", + "arm64", + ); + const fromFile = exportInstall( + varicadPackage({ executable_package: "varicad-bin" }), + "varicad", + "x64", + VARICAD_URL, + ); + + expect(exportInstall(archive, "vim", "arm64")).toMatchObject({ + ok: true, + value: { aptPackage: "vim-common", executablePackage: "vim" }, + }); + expect(fromVendor).toMatchObject({ + ok: true, + value: { aptPackage: "typora", executablePackage: "typora-bin" }, + }); + expect(fromFile).toMatchObject({ + ok: true, + value: { + packageFile: { url: VARICAD_URL }, + executablePackage: "varicad-bin", + }, + }); + }); + + it("refuses when no package owns the program the entry runs", () => { + const { executable_package: _program, ...entryOnly } = archive; + + expect( + exportInstall( + { + ...entryOnly, + executable_package_reason: "no package owns /home/ada/.local/bin/vim", + }, + "vim", + "arm64", + ), + ).toEqual({ + ok: false, + error: { + field: "capture", + reason: + "The desktop entry belongs to vim-common, but the computer cannot install the program it runs: no package owns /home/ada/.local/bin/vim.", + remediation: + "Capture again with the packaged program first on PATH, or with --executable naming it.", + }, + }); + }); + + it("refuses when the computer cannot install the program's package", () => { + const reason = + "neither the Ubuntu 24.04 archive nor a vendor repository the export declares offers vim"; + + expect( + exportInstall( + { ...archive, executable_package_reason: reason }, + "vim", + "arm64", + ), + ).toEqual({ + ok: false, + error: expect.objectContaining({ + field: "capture", + reason: `The desktop entry belongs to vim-common, but the program it runs belongs to vim, which the computer cannot install: ${reason}.`, + }), + }); + }); +}); + +describe("exportInstall with repositories the export leaves out", () => { + const mirror = (supplies: readonly string[]) => + repository({ + source: "https://mirror.typora.example/linux ./ Packages", + uri: "https://mirror.typora.example/linux", + signing_key: { ...KEY, sha256: "c".repeat(64) }, + supplies: [...supplies], + }); + // The capture could not retain this repository's key, so the export leaves it out. + const dropped = (supplies: readonly string[]) => + repository({ + signing_key: null, + signing_key_reason: + "the verified key /etc/apt/trusted.gpg.d/typora.gpg did not fit the capture's content budget, so it was not retained", + supplies: [...supplies], + }); + const installed = ( + repositories: readonly AptRepository[], + extra: Partial = {}, + ): DesktopPackage => ({ + ...typora(repositories), + library_packages: ["libgbm1", "libvendor1", "libshared1"], + ...extra, + }); + + it("refuses the program's package when only a repository it leaves out supplies it", () => { + const refused = exportInstall( + installed([dropped(["typora-bin"]), mirror([])], { + executable_package: "typora-bin", + }), + "typora", + "arm64", + ); + + expect(refused).toEqual({ + ok: false, + error: expect.objectContaining({ + reason: + "The desktop entry belongs to typora, but the program it runs belongs to typora-bin, which the computer cannot install: only apt repositories the export leaves out offer it.", + }), + }); + }); + + it("installs only the library packages a declared repository or the archive still offers, and says which it left out", () => { + const vendor = exportInstall( + installed([ + dropped(["libshared1", "libvendor1"]), + mirror(["libshared1"]), + ]), + "typora", + "arm64", + ); + const fromFile = exportInstall( + varicadPackage({ apt_repositories: [dropped(["libxt6t64"])] }), + "varicad", + "x64", + VARICAD_URL, + ); + + if (!vendor.ok) throw new Error(vendor.error.reason); + expect(vendor.value).toMatchObject({ + libraryPackages: ["libgbm1", "libshared1"], + strandedPackages: ["libvendor1"], + }); + expect( + installLimitations(vendor.value, installed([]), false).join("\n"), + ).toContain( + "in packages only the apt repositories left out offer: libvendor1", + ); + expect(fromFile).toMatchObject({ + ok: true, + value: { libraryPackages: ["libgl1"], strandedPackages: ["libxt6t64"] }, + }); + }); +}); + describe("exportInstall sources", () => { const amd64 = "https://apt.example.com/repo stable/main amd64 Packages"; const i386 = "https://apt.example.com/repo stable/main i386 Packages"; diff --git a/src/domain/desktop/appExportInstall.ts b/src/domain/desktop/appExportInstall.ts index 849026cd41..ce0902af23 100644 --- a/src/domain/desktop/appExportInstall.ts +++ b/src/domain/desktop/appExportInstall.ts @@ -69,6 +69,10 @@ export interface ExportInstall { readonly architectures: readonly string[]; /** The package file installed in place of an apt package; null when apt installs the package. */ readonly packageFile: ExportPackageFile | null; + /** The package of the program the desktop entry runs, installed by name beside the app's; absent when the app's package owns it. */ + readonly executablePackage?: string; + /** Packages the capture found only in repositories the export leaves out, which the computer cannot install. */ + readonly strandedPackages?: readonly string[]; } /** Why the computer cannot install the captured package: the input to change, and how. */ @@ -146,7 +150,7 @@ const declaredSources = ( const declared = new Map(); const keyFiles = new Map(); const refused: string[] = []; - let first: AptRepository | undefined; + const kept: AptRepository[] = []; for (const repository of repositories) { const { uri, suite, components, signing_key: key } = repository; const reason = @@ -182,12 +186,43 @@ const declaredSources = ( keyFile, keySha256: key.sha256, }); - first ??= repository; + kept.push(repository); } return { sources: [...declared.values()], refused, - release: first?.release ?? null, + release: kept[0]?.release ?? null, + kept, + }; +}; + +/** + * The library packages the computer installs given the repositories the + * export declares, and the packages it cannot: those only repositories it + * leaves out supply, such as one whose key the capture could not retain. + */ +const suppliedLibraries = ( + installed: DesktopPackage, + kept: readonly AptRepository[], +): Pick => { + const repositories = installed.apt_repositories ?? []; + const supplied = new Set( + kept.flatMap((repository) => repository.supplies ?? []), + ); + const stranded = [ + ...new Set( + repositories + .filter((repository) => !kept.includes(repository)) + .flatMap((repository) => repository.supplies ?? []), + ), + ] + .filter((name) => !supplied.has(name)) + .sort(); + return { + libraryPackages: (installed.library_packages ?? []).filter( + (name) => !stranded.includes(name), + ), + ...(stranded.length === 0 ? {} : { strandedPackages: stranded }), }; }; @@ -242,7 +277,7 @@ const vendorInstall = ( ), skipped: [], }); - const { sources, refused, release } = declaredSources( + const { sources, refused, release, kept } = declaredSources( repositories, environment, architecture, @@ -257,7 +292,7 @@ const vendorInstall = ( return ok({ aptPackage, release, - libraryPackages: installed.library_packages ?? [], + ...suppliedLibraries(installed, kept), sources, skipped: refused, architectures: [architecture], @@ -328,7 +363,7 @@ const packageFileInstall = ( return ok({ ...named, release: null, - libraryPackages: installed.library_packages ?? [], + ...suppliedLibraries(installed, []), sources: [], architectures: [architecture], packageFile: { @@ -350,7 +385,7 @@ const packageFileInstall = ( * `--package-url`, is downloaded from it and installed from its captured * package file; anything else is refused with the reason. */ -export const exportInstall = ( +const packageInstall = ( installed: DesktopPackage, environment: string, hostArchitecture: string, @@ -371,6 +406,7 @@ export const exportInstall = ( ? ok({ aptPackage, release: null, + ...suppliedLibraries(installed, []), libraryPackages: [], sources: [], skipped: [], @@ -397,6 +433,38 @@ export const exportInstall = ( ); }; +/** How the computer installs the captured package and, when another package owns the program its desktop entry runs, that package too. */ +export const exportInstall = ( + ...request: Parameters +): Result => { + const install = packageInstall(...request); + const [{ executable_package: program, executable_package_reason: found }] = + request; + if (!install.ok) return install; + if (program === undefined) + return found === undefined + ? install + : err( + refusal( + `The desktop entry belongs to ${install.value.aptPackage}, but the computer cannot install the program it runs: ${found}.`, + "capture", + "Capture again with the packaged program first on PATH, or with --executable naming it.", + ), + ); + const reason = + found ?? + (install.value.strandedPackages?.includes(program) === true + ? "only apt repositories the export leaves out offer it" + : undefined); + return reason === undefined + ? ok({ ...install.value, executablePackage: program }) + : err( + refusal( + `The desktop entry belongs to ${install.value.aptPackage}, but the program it runs belongs to ${program}, which the computer cannot install: ${reason}.`, + ), + ); +}; + /** Chromium's sandbox needs user namespaces a container does not grant, so an Electron launch runs with `--no-sandbox` right after the program. */ export const electronLaunchArguments = ( argv: readonly string[], @@ -407,74 +475,3 @@ export const electronLaunchArguments = ( const at = wrapper.kind === "direct" ? wrapper.index + 1 : 1; return [...argv.slice(0, at), "--no-sandbox", ...argv.slice(at)]; }; - -const unmappedLibraries = (installed: DesktopPackage): readonly string[] => [ - ...(installed.library_packages_reason === undefined - ? [] - : [installed.library_packages_reason]), - ...(installed.missing_libraries?.length - ? [ - `not found on the capture host: ${installed.missing_libraries.join(", ")}`, - ] - : []), - ...(installed.unpackaged_libraries?.length - ? [`owned by no package: ${installed.unpackaged_libraries.join(", ")}`] - : []), - ...(installed.uninstallable_library_packages?.length - ? [ - `in packages neither the Ubuntu 24.04 archive nor a declared repository offers: ${installed.uninstallable_library_packages.join(", ")}`, - ] - : []), -]; - -/** What the export says about a package file the computer downloads and installs. */ -const packageFileLimitations = ( - file: ExportPackageFile, - installed: DesktopPackage, -): readonly string[] => [ - `The computer downloads the package from ${file.url} once, when the bundle is put, and refuses the download unless its SHA-256 is ${file.sha256}, the captured file's; the bundle does not contain the package. It installs exactly that file's version, ${installed.version ?? "unknown"}, with dpkg after the apt packages it depends on, and the bundle supports only its architecture, ${file.architecture}.`, - "Vendor sites may refuse automated downloads, so the URL must point at a copy of the file the operator may use, and the package's licence, such as a trial's terms, is the operator's to check.", - ...(file.unreadDependencies.length === 0 - ? [] - : [ - `REA could not read a package name from these dependencies, so the computer does not install them: ${file.unreadDependencies.join(", ")}.`, - ]), -]; - -/** What the export says about how the computer installs and starts the application. */ -export const installLimitations = ( - install: ExportInstall, - installed: DesktopPackage, - electron: boolean, -): readonly string[] => { - const unmapped = unmappedLibraries(installed); - // Library packages matter for any package the archive does not install by name. - const vendor = install.sources.length > 0 || install.packageFile !== null; - return [ - ...(install.packageFile !== null - ? packageFileLimitations(install.packageFile, installed) - : [ - install.sources.length > 0 - ? `The application is installed as ${install.aptPackage}${install.release === null ? "" : `/${install.release}`} from ${install.sources.map((source) => source.uri).join(", ")}, trusting the key that verified it on the capture host, so the computer installs the version that repository offers then, which may differ from the one captured; the bundle supports only the capture host's architecture, ${install.architectures.join(", ")}.` - : "The application is installed from the distribution archive by name, so the computer may install a different version than the one captured.", - ]), - ...install.skipped.map( - (repository) => `The apt repository ${repository} was left out.`, - ), - ...(vendor && installed.library_packages === undefined - ? [ - "The capture did not record the executable's library packages, so the computer installs only what the package depends on.", - ] - : []), - ...(!vendor || unmapped.length === 0 - ? [] - : [ - `Not every library the executable loads is installed with it (${unmapped.join("; ")}), so the computer may lack some.`, - ]), - ...(electron - ? [ - "Chromium's sandbox is off inside the computer (--no-sandbox): it needs user namespaces the computer's container does not grant, and Electron exits without them.", - ] - : []), - ]; -}; diff --git a/src/domain/desktop/appExportInstallLimitations.ts b/src/domain/desktop/appExportInstallLimitations.ts new file mode 100644 index 0000000000..c3384d3195 --- /dev/null +++ b/src/domain/desktop/appExportInstallLimitations.ts @@ -0,0 +1,81 @@ +import type { ExportInstall, ExportPackageFile } from "./appExportInstall.js"; +import type { DesktopPackage } from "./desktopCapture.js"; + +const unmappedLibraries = ( + installed: DesktopPackage, + stranded: readonly string[], +): readonly string[] => [ + ...(installed.library_packages_reason === undefined + ? [] + : [installed.library_packages_reason]), + ...(installed.missing_libraries?.length + ? [ + `not found on the capture host: ${installed.missing_libraries.join(", ")}`, + ] + : []), + ...(installed.unpackaged_libraries?.length + ? [`owned by no package: ${installed.unpackaged_libraries.join(", ")}`] + : []), + ...(installed.uninstallable_library_packages?.length + ? [ + `in packages neither the Ubuntu 24.04 archive nor a declared repository offers: ${installed.uninstallable_library_packages.join(", ")}`, + ] + : []), + ...(stranded.length > 0 + ? [ + `in packages only the apt repositories left out offer: ${stranded.join(", ")}`, + ] + : []), +]; + +/** What the export says about a package file the computer downloads and installs. */ +const packageFileLimitations = ( + file: ExportPackageFile, + installed: DesktopPackage, +): readonly string[] => [ + `The computer downloads the package from ${file.url} once, when the bundle is put, and refuses the download unless its SHA-256 is ${file.sha256}, the captured file's; the bundle does not contain the package. It installs exactly that file's version, ${installed.version ?? "unknown"}, with dpkg after the apt packages it depends on, and the bundle supports only its architecture, ${file.architecture}.`, + "Vendor sites may refuse automated downloads, so the URL must point at a copy of the file the operator may use, and the package's licence, such as a trial's terms, is the operator's to check.", + ...(file.unreadDependencies.length === 0 + ? [] + : [ + `REA could not read a package name from these dependencies, so the computer does not install them: ${file.unreadDependencies.join(", ")}.`, + ]), +]; + +/** What the export says about how the computer installs and starts the application. */ +export const installLimitations = ( + install: ExportInstall, + installed: DesktopPackage, + electron: boolean, +): readonly string[] => { + const unmapped = unmappedLibraries(installed, install.strandedPackages ?? []); + // Library packages matter for any package the archive does not install by name. + const vendor = install.sources.length > 0 || install.packageFile !== null; + return [ + ...(install.packageFile !== null + ? packageFileLimitations(install.packageFile, installed) + : [ + install.sources.length > 0 + ? `The application is installed as ${install.aptPackage}${install.release === null ? "" : `/${install.release}`} from ${install.sources.map((source) => source.uri).join(", ")}, trusting the key that verified it on the capture host, so the computer installs the version that repository offers then, which may differ from the one captured; the bundle supports only the capture host's architecture, ${install.architectures.join(", ")}.` + : "The application is installed from the distribution archive by name, so the computer may install a different version than the one captured.", + ]), + ...install.skipped.map( + (repository) => `The apt repository ${repository} was left out.`, + ), + ...(vendor && installed.library_packages === undefined + ? [ + "The capture did not record the executable's library packages, so the computer installs only what the package depends on.", + ] + : []), + ...(!vendor || unmapped.length === 0 + ? [] + : [ + `Not every library the executable loads is installed with it (${unmapped.join("; ")}), so the computer may lack some.`, + ]), + ...(electron + ? [ + "Chromium's sandbox is off inside the computer (--no-sandbox): it needs user namespaces the computer's container does not grant, and Electron exits without them.", + ] + : []), + ]; +}; diff --git a/src/domain/desktop/appExportPython.ts b/src/domain/desktop/appExportPython.ts index a44fec8ad3..844df74526 100644 --- a/src/domain/desktop/appExportPython.ts +++ b/src/domain/desktop/appExportPython.ts @@ -1,3 +1,6 @@ +import { PYTHON_XML_READER } from "./appExportPythonXml.js"; +import { PYTHON_ZIP_HELPERS } from "./appExportPythonZip.js"; + /** The generated module's imports. */ /** The class method that holds readiness until the app's own window shows, not a splash screen with its class. */ export const PYTHON_READY_CHECK = ` @@ -10,16 +13,22 @@ export const PYTHON_READY_CHECK = ` export const PYTHON_IMPORTS = `from __future__ import annotations import contextlib +import copy +import hashlib +import io import json import math import os import re import sqlite3 +import struct import subprocess import sys import tempfile -import xml.etree.ElementTree as ElementTree +import zipfile +import zlib from pathlib import Path +from xml.parsers import expat from agentenv_protocol import add_data, get_data, reset_data from agentenv_protocol.computer import AptSource, ComputerAppEnvironment, Download, Launch, Packages, Ready, RunScript, computer_app, linux, ready_check @@ -33,7 +42,9 @@ XML_SUFFIXES = (".xml", ".xbel", ".plist", ".svg", ".ui") GROUP_HEADER = re.compile(r"^\[([^\]]+)\]$") LINE_BREAK = re.compile(r"\r?\n") SQLITE_HEADER = b"SQLite format 3\x00" -XML_SPACE = "{http://www.w3.org/XML/1998/namespace}space" +GVDB_HEADER = b"GVariant" +ZIP_HEADER = b"PK\x03\x04" +XML_SPACE = "xml:space" MAX_VIEW_BYTES = 64 * 1024 * 1024 MAX_SAFE_INTEGER = 2**53 - 1 MAX_TABLES = 64 @@ -42,11 +53,20 @@ MAX_LINES = 5000 MAX_XML_NODES = 5000 MAX_XML_DEPTH = 64 SQLITE_BUSY_S = 0.25 +MAX_PARSED_BYTES = 4 * 1024 * 1024 +PARSED_TOO_LARGE = "larger than the 4 MiB limit for JSON and key file views" +MAX_JSON_VIEW_DEPTH = 500 +JSON_TOO_DEEP = f"nested deeper than the {MAX_JSON_VIEW_DEPTH} levels a JSON view keeps" +#: A JSON token after any whitespace, by group: an opening, a closing, a comma, a colon, a string's quote, or a number +#: or literal. Strings are read by json's scanner, since a pattern repeating per escape holds memory for each. +JSON_TOKEN = re.compile(r'[ \t\n\r]*(?:([\[{])|([\]}])|(,)|(:)|(")|(-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?' + r"|true|false|null))") +JSON_SPACE = re.compile(r"[ \t\n\r]*") def _respelled(text: str, home: Path) -> str: - """The captured home path respelled to this computer's home, only where a whole path segment ends after it.""" - return re.sub(re.escape(CAPTURED_HOME) + r"(?![\w.@+~-])", lambda _: str(home), text) + """The seed's home path respelled to this computer's home, only where a whole path segment ends after it.""" + return re.sub(re.escape(SEED_HOME) + r"(?![\w.@+~-])", lambda _: str(home), text) def _respelled_utf16(data: bytes, home: Path) -> bytes: @@ -57,7 +77,7 @@ def _respelled_utf16(data: bytes, home: Path) -> bytes: text = data[len(mark):].decode(codec) except UnicodeDecodeError: return data - return mark + _respelled(text, home).encode(codec) if CAPTURED_HOME in text else data + return mark + _respelled(text, home).encode(codec) if SEED_HOME in text else data return data @@ -81,7 +101,7 @@ def _taskbar_window(window_class: str) -> bool: def _seed_home(home: Path) -> None: - """Copy the captured files into this computer's home; text files, UTF-16 ones too, have the captured home path + """Copy the captured files into this computer's home; text files, UTF-16 ones too, have the seed's home path respelled.""" seed = HERE / "seed" for source in sorted(seed.rglob("*")) if seed.is_dir() else []: @@ -189,10 +209,25 @@ def _keyfile_line(raw: str) -> tuple: return ("entry", key.strip(), value.strip()) if equals and key else ("other",) +def _each_line(text: str): + """Each line of text as REA splits them, one at a time.""" + start = 0 + for line_break in LINE_BREAK.finditer(text): + yield text[start:line_break.start()] + start = line_break.end() + yield text[start:] + + def _is_keyfile(text: str) -> bool: - """REA's rule: at least one group, and nothing a key file view would drop.""" - kinds = {_keyfile_line(line)[0] for line in LINE_BREAK.split(text)} - return "group" in kinds and "other" not in kinds + """REA's rule: at least one group, and nothing a key file view would drop. Lines are read one at a time, stopping + at the first it would drop.""" + group = False + for line in _each_line(text): + kind = _keyfile_line(line)[0] + if kind == "other": + return False + group = group or kind == "group" + return group def _keyfile(text: str) -> dict: @@ -206,56 +241,7 @@ def _keyfile(text: str) -> dict: return groups -class _XmlTree(ElementTree.TreeBuilder): - """Keeps comments and processing instructions, which REA's node limit counts, and refuses DOCTYPEs as REA does.""" - - def __init__(self) -> None: - super().__init__(insert_comments=True, insert_pis=True) - - def doctype(self, name, pubid, system) -> None: - raise ValueError("the document declares a DOCTYPE") - - -def _xml_children(element) -> list: - nodes = [element.text] if element.text else [] - for child in element: - nodes.append(child) - if child.tail: - nodes.append(child.tail) - return nodes - - -def _xml(node, depth: int, budget: dict, scope: tuple): - """A node as REA's XML view keeps it: an element with its attributes and children, or its text; None when it is - formatting whitespace, a comment, or past REA's node and depth limits.""" - if budget["remaining"] <= 0 or depth > MAX_XML_DEPTH: - budget["truncated"] = True - return None - budget["remaining"] -= 1 - preserve, element_content = scope - if isinstance(node, str): - return None if not node.strip() and element_content and not preserve else node - if not isinstance(node.tag, str): - return None - space = node.get(XML_SPACE) - nodes = _xml_children(node) - inner = (space == "preserve" if space else preserve, - any(not isinstance(child, str) and isinstance(child.tag, str) for child in nodes)) - children = [_xml(child, depth + 1, budget, inner) for child in nodes] - return {"name": node.tag, "attributes": dict(node.attrib), "children": [c for c in children if c is not None]} - - -def _xml_view(text: str): - parser = ElementTree.XMLParser(target=_XmlTree()) - try: - parser.feed(text) - root = parser.close() - except (ElementTree.ParseError, ValueError): - return None - budget = {"remaining": MAX_XML_NODES, "truncated": False} - tree = _xml(root, 0, budget, (False, False)) - return None if tree is None else {"root": tree, "truncated": budget["truncated"]} - +${PYTHON_XML_READER} def _not_json(constant: str): raise ValueError(f"{constant} is not JSON") @@ -296,7 +282,7 @@ def _exact_number(text: str): _INEXACT = object() -_INEXACT_END = object() +_LEVEL_END = object() def _number_or_mark(spelling: str): @@ -312,8 +298,8 @@ def _holds_inexact(value) -> bool: iterator per nesting level, so no child list is copied.""" levels = [iter((value,))] while levels: - item = next(levels[-1], _INEXACT_END) - if item is _INEXACT_END: + item = next(levels[-1], _LEVEL_END) + if item is _LEVEL_END: levels.pop() elif item is _INEXACT: return True @@ -324,25 +310,92 @@ def _holds_inexact(value) -> bool: return False -def _text_view(name: str, text: str) -> dict: - """The format REA's state view detects for decoded text, and its view.""" +def _deeper_than(root, limit: int) -> bool: + """Whether a parsed JSON value nests deeper than a limit, as REA checks it: a value's depth is the number of + iterators open above it, one per nesting level, so no child list is copied.""" + levels = [iter((root,))] + while levels: + item = next(levels[-1], _LEVEL_END) + if item is _LEVEL_END: + levels.pop() + elif len(levels) - 1 > limit: + return True + elif isinstance(item, dict): + levels.append(iter(item.values())) + elif isinstance(item, list): + levels.append(iter(item)) + return False + + +def _is_json(text: str) -> bool: + """Whether text is one JSON value as REA's state view reads it, checked a token at a time without building it or + recursing: it holds a byte per open container and one string at a time, so text too deep for Python's parser or + too large to parse into a view is still told apart from other text.""" + containers, expect, empty, at = bytearray(), "value", False, 0 + while True: + token = JSON_TOKEN.match(text, at) + if token is None: + break + kind, at = token.lastindex, token.end() + if kind == 5: + try: + at = json.decoder.scanstring(text, at)[1] + except ValueError: + return False + if kind == 1 and expect == "value": + containers.append(ord(text[at - 1])) + expect, empty = ("value" if text[at - 1] == "[" else "key"), True + # A closing bracket's code is its opening's plus two. + elif kind == 2 and (expect == "after" or empty) and containers and containers[-1] == ord(text[at - 1]) - 2: + containers.pop() + expect, empty = "after", False + elif kind == 3 and expect == "after" and containers: + expect = "value" if containers[-1] == ord("[") else "key" + elif kind == 4 and expect == "colon": + expect = "value" + elif kind >= 5 and (expect == "value" or (kind == 5 and expect == "key")): + expect, empty = ("colon" if expect == "key" else "after"), False + else: + return False + return expect == "after" and not containers and JSON_SPACE.fullmatch(text, at) is not None + + +def _json_view(text: str, size: int): + """A JSON text's format and view, or why it has none, as REA reads it; None when it is not JSON. Text over the + parsed-view limit, or nested past Python's recursion limit, is told apart without building its value.""" + if size > MAX_PARSED_BYTES: + return {"format": "json", "view": None, "error": PARSED_TOO_LARGE} if _is_json(text) else None + # The whole text must parse, and duplicate keys settle, before an inexact number makes it JSON with an error. + try: + view = json.loads(text, parse_constant=_not_json, parse_float=_number_or_mark, parse_int=_number_or_mark) + except RecursionError: + # Nesting too deep for Python to parse is far deeper than REA's view keeps, once the rest of it is JSON too. + return {"format": "json", "view": None, "error": JSON_TOO_DEEP} if _is_json(text) else None + except ValueError: + return None + if _holds_inexact(view): + return {"format": "json", "view": None, + "error": "a number in it cannot be read exactly, such as an integer above 2^53"} + if _deeper_than(view, MAX_JSON_VIEW_DEPTH): + return {"format": "json", "view": None, "error": JSON_TOO_DEEP} + return {"format": "json", "view": view, "truncated": False} + + +def _text_view(name: str, text: str, size: int) -> dict: + """The format REA's state view detects for decoded text of size bytes, and its view; a JSON or key file over the + parsed-view limit is refused before its value is built.""" trimmed = re.sub(r"^[\s]+", "", text) if name.endswith(".json") or trimmed[:1] in ("[", "{"): - # The whole text must parse, and duplicate keys settle, before an inexact number makes it JSON with an error. - try: - view = json.loads(text, parse_constant=_not_json, parse_float=_number_or_mark, parse_int=_number_or_mark) - except ValueError: - pass - else: - if _holds_inexact(view): - return {"format": "json", "view": None, - "error": "a number in it cannot be read exactly, such as an integer above 2^53"} - return {"format": "json", "view": view, "truncated": False} + view = _json_view(text, size) + if view is not None: + return view lowered = name.lower() if lowered.endswith(XML_SUFFIXES) or trimmed.startswith(" MAX_PARSED_BYTES: + return {"format": "keyfile", "view": None, "error": PARSED_TOO_LARGE} return {"format": "keyfile", "view": {"groups": _keyfile(text)}, "truncated": False} return _text(text) @@ -358,13 +411,17 @@ def _view(path: Path) -> dict: data += file.read(MAX_VIEW_BYTES + 1 - len(data)) if whole else b"" if data.startswith(SQLITE_HEADER): return _sqlite(path) + if data.startswith(ZIP_HEADER): + return _zip(path) if len(data) > MAX_VIEW_BYTES: return too_large try: text = data.decode("utf-8-sig") if whole else None except UnicodeDecodeError: text = None - return {"format": "binary", "view": None, "truncated": False} if text is None else _text_view(path.name, text) + if text is None: + return {"format": "binary", "view": None, "truncated": False} + return _text_view(path.name, text, len(data)) def _views(home: Path, paths: list) -> dict: @@ -381,12 +438,12 @@ def _views(home: Path, paths: list) -> dict: except (OSError, ValueError, sqlite3.Error, RecursionError) as error: files[key] = {"format": None, "view": None, "error": str(error)} return files -`; +`.concat(PYTHON_ZIP_HELPERS); /** Constants the class module assigns before the helpers. */ const PYTHON_CONSTANTS = [ "HERE", - "CAPTURED_HOME", + "SEED_HOME", "PATHS", "DCONF", "TEXT_SEEDS", diff --git a/src/domain/desktop/appExportPythonXml.ts b/src/domain/desktop/appExportPythonXml.ts new file mode 100644 index 0000000000..fadd5d40d3 --- /dev/null +++ b/src/domain/desktop/appExportPythonXml.ts @@ -0,0 +1,101 @@ +/** The generated module's XML reader: REA's XML view of a document, built with expat. */ +export const PYTHON_XML_READER = String.raw`def _expat(namespaces: bool): + """An expat parser that refuses DOCTYPEs as REA does; with namespaces it also enforces their rules, as REA's + parser does, and without them it reports names as the document spells them.""" + parser = expat.ParserCreate(namespace_separator=" " if namespaces else None) + + def doctype(*_) -> None: + raise ValueError("the document declares a DOCTYPE") + + parser.StartDoctypeDeclHandler = doctype + return parser + + +class _XmlReader: + """REA's XML view built as expat reads: names as spelled, declarations as attributes, text split where REA's DOM + splits it (an empty CDATA section joins the text around it), and each node counted against REA's limits.""" + + def __init__(self) -> None: + self.remaining, self.truncated, self.root = MAX_XML_NODES, False, None + #: Per open element: its view or None when left out, whether xml:space="preserve" applies inside it, and + #: whether it holds an element, which makes whitespace-only text in it formatting. + self.open = [] + self.text, self.cdata = [], None + + def read(self, text: str) -> None: + parser = _expat(False) + parser.buffer_text = True + parser.StartElementHandler, parser.EndElementHandler = self._start, self._end + parser.CharacterDataHandler = self._data + parser.StartCdataSectionHandler, parser.EndCdataSectionHandler = self._cdata_start, self._cdata_end + parser.CommentHandler = parser.ProcessingInstructionHandler = self._other + parser.Parse(text, True) + + def _admit(self) -> bool: + """Whether the next node inside a kept element is kept, within the node and depth limits; one past them + marks the view truncated.""" + if self.open and self.open[-1][0] is None: + return False + if self.remaining <= 0 or len(self.open) > MAX_XML_DEPTH: + self.truncated = True + return False + self.remaining -= 1 + return True + + def _text_node(self, text: str) -> None: + if self._admit(): + self.open[-1][0]["children"].append(text) + + def _flush(self) -> None: + if self.text: + text, self.text = "".join(self.text), [] + self._text_node(text) + + def _data(self, text: str) -> None: + (self.text if self.cdata is None else self.cdata).append(text) + + def _cdata_start(self) -> None: + self.cdata = [] + + def _cdata_end(self) -> None: + text, self.cdata = "".join(self.cdata), None + if text: + self._flush() + self._text_node(text) + + def _other(self, *_) -> None: + """A comment or processing instruction, counted like any node and never kept.""" + self._flush() + if self.open: + self._admit() + + def _start(self, name: str, attributes: dict) -> None: + self._flush() + parent = self.open[-1] if self.open else [None, False, False] + parent[2] = True + view = {"name": name, "attributes": attributes, "children": []} if self._admit() else None + if view is not None and parent[0] is not None: + parent[0]["children"].append(view) + space = attributes.get(XML_SPACE) + self.open.append([view, space == "preserve" if space else parent[1], False]) + + def _end(self, _name: str) -> None: + self._flush() + view, preserve, holds_element = self.open.pop() + if view is not None and holds_element and not preserve: + view["children"] = [child for child in view["children"] if not isinstance(child, str) or child.strip()] + if not self.open: + self.root = view + + +def _xml_view(text: str): + """REA's XML view, or None for a document REA's parser refuses: a pass with namespaces checks the document as REA's + parser does, then a pass without them reads names as spelled, as REA's view keeps them.""" + reader = _XmlReader() + try: + _expat(True).Parse(text, True) + reader.read(text) + except (expat.ExpatError, ValueError): + return None + return {"root": reader.root, "truncated": reader.truncated} +`; diff --git a/src/domain/desktop/appExportPythonZip.ts b/src/domain/desktop/appExportPythonZip.ts new file mode 100644 index 0000000000..c20d197dd1 --- /dev/null +++ b/src/domain/desktop/appExportPythonZip.ts @@ -0,0 +1,237 @@ +/** + * The exported module's zip reader: the central directory read a record at a + * time, entries read in bounded chunks, and each entry's view by the rules a + * file's follow, with REA's limits and wording. Part of the module's helpers. + */ +export const PYTHON_ZIP_HELPERS = String.raw` +MAX_ZIP_ENTRIES = 1000 +MAX_ZIP_ENTRY_BYTES = 16 * 1024 * 1024 +MAX_ZIP_BYTES = 64 * 1024 * 1024 +MAX_ZIP_RATIO = 100 +ZIP_READ_CHUNK = 64 * 1024 +ZIP_END = b"PK\x05\x06" +ZIP_CENTRAL = b"PK\x01\x02" +ZIP64_LOCATOR = b"PK\x06\x07" +ZIP64_END = b"PK\x06\x06" +#: Formats an archive entry may have whose reader needs a tool, a worker or another archive opened. +NOT_READ_INSIDE = ((SQLITE_HEADER, "sqlite", "a SQLite database"), (GVDB_HEADER, "dconf", "a dconf database"), + (ZIP_HEADER, "zip", "an archive")) + + +def _entry_view(name: str, data: bytes) -> dict: + """An archive entry's format and view by the rules a file's follow; a database or an archive inside is not + read.""" + for header, form, inside in NOT_READ_INSIDE: + if data.startswith(header): + return {"format": form, "view": None, "error": f"{inside} inside an archive is not read"} + try: + text = data.decode("utf-8-sig") if b"\x00" not in data[:8192] else None + except UnicodeDecodeError: + text = None + if text is None: + return {"format": "binary", "view": None} + return _text_view(name, text, len(data)) + + +def _zip_refusal(info, spent: int): + """Why an entry is not read, checked in REA's order, or None; spent is the archive's bytes read so far.""" + if info.flag_bits & 0x1: + return "encrypted, so it is not read" + if info.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED): + return f"compressed with method {info.compress_type}, which the view does not read" + if info.file_size > MAX_ZIP_ENTRY_BYTES: + return "larger than the 16 MiB an entry's view reads" + if info.file_size > MAX_ZIP_RATIO * info.compress_size: + return f"expands more than {MAX_ZIP_RATIO} times, so it is not read" + if spent + info.file_size > MAX_ZIP_BYTES: + return "past the 64 MiB read from one archive" + return None + + +def _entry_bytes(archive, info): + """An entry's uncompressed bytes, or None when they fail its CRC-32 or are not exactly its declared size. Reading + stops one byte past that size, which data past a short declared size fills, and asks for a bounded chunk at a + time, which bounds what the decompressor expands per call, so a forged small size holds no more than that.""" + probe = copy.copy(info) + probe.file_size = info.file_size + 1 + data = bytearray() + try: + with archive.open(probe) as member: + while len(data) <= info.file_size: + chunk = member.read(min(probe.file_size - len(data), ZIP_READ_CHUNK)) + if not chunk: + break + data += chunk + except (zipfile.BadZipFile, zlib.error, EOFError, OSError, ValueError): + return None + return data if len(data) == info.file_size else None + + +def _zip_entry(archive, info, refusal): + """One entry of a zip view: its name, size and digest, and its own format and view, or why it was not read.""" + entry = {"name": info.filename, "size": info.file_size, "sha256": None, "format": None, "view": None, + "view_error": refusal} + if refusal is not None: + return entry + data = _entry_bytes(archive, info) + if data is None: + return {**entry, "view_error": "its compressed data is damaged"} + view = _entry_view(info.filename, data) + return {**entry, "sha256": hashlib.sha256(data).hexdigest(), "format": view["format"], + "view": view.get("view"), "view_error": view.get("error")} + + +def _zip_end(file, size: int): + """Where an archive's central directory ends, how long it is and the offset it declares, from the last end + record whose comment reaches the end of the file and, when a Zip64 locator precedes it whatever that record + holds, the Zip64 one just before the locator; None without an end record, or when sentinels defer to a + Zip64 record that is missing.""" + window = min(size, 22 + 0xFFFF) + file.seek(size - window) + tail = file.read(window) + at = tail.rfind(ZIP_END, 0, len(tail) - 18) + while at >= 0 and at + 22 + int.from_bytes(tail[at + 20:at + 22], "little") != len(tail): + at = tail.rfind(ZIP_END, 0, at + 3) + if at < 0: + return None + total, length, offset = struct.unpack_from("= 5 and len(data) == length and data[0] == 1 and not flags & 0x800 + and int.from_bytes(data[1:5], "little") == zlib.crc32(raw)) + return data[5:] if usable else None + at += 4 + length + return None + + +def _zip_ends_in_slash(raw: bytes, flags: int, extra: bytes): + """Whether an entry's name, as REA decodes it, ends in a slash; None when it must be UTF-8 and is not.""" + utf8 = _zip_unicode_path(raw, flags, extra) + if utf8 is None and flags & 0x800: + utf8 = raw + if utf8 is None: + return raw.endswith(b"/") + try: + return utf8.decode("utf-8").endswith("/") + except UnicodeDecodeError: + return None + + +def _zip_directory(file, size: int, keep: int): + """An archive's central directory read a record at a time, keeping the first file entries' records, as many as keep allows, and + counting the rest, as REA reads it: (start, declared offset, records, count), or None when it cannot be read. + Directories are told apart as zip.js tells them: a final slash, a Unix directory mode, or the MS-DOS attribute.""" + located = _zip_end(file, size) + if located is None: + return None + end, length, declared = located + start = end - length + if start < 0: + return None + file.seek(start) + records, count, at = [], 0, start + while at < end: + fixed = file.read(46) + if at + 46 > end or len(fixed) < 46 or fixed[:4] != ZIP_CENTRAL: + return None + made_by, flags = struct.unpack_from(" end: + return None + slash = _zip_ends_in_slash(rest[:name_length], flags, rest[name_length:name_length + extra_length]) + if slash is None: + return None + if not (slash or (external >> 16) & 0o170000 == 0o040000 or (made_by >> 8 == 0 and external & 0x10)): + count += 1 + if len(records) < keep: + records.append(fixed + rest) + return start, declared, records, count + + +class _BoundedArchive(io.RawIOBase): + """An archive as zipfile reads it: its entries in place, then a directory of the kept records alone and end + records for them, so zipfile parses no more records than the view lists.""" + + def __init__(self, file, start: int, tail: bytes) -> None: + super().__init__() + self._file, self._start, self._tail, self._at = file, start, tail, 0 + + def readable(self) -> bool: + return True + + def seekable(self) -> bool: + return True + + def tell(self) -> int: + return self._at + + def seek(self, offset: int, whence: int = 0) -> int: + self._at = offset + (0, self._at, self._start + len(self._tail))[whence] + return self._at + + def readinto(self, buffer) -> int: + view = memoryview(buffer) + if self._at < self._start: + self._file.seek(self._at) + count = self._file.readinto(view[:self._start - self._at]) + else: + chunk = self._tail[self._at - self._start:self._at - self._start + len(view)] + count = len(chunk) + view[:count] = chunk + self._at += count + return count + + +def _zip(path: Path) -> dict: + """A zip archive's file entries in archive order, up to REA's entry, size, ratio and archive limits; nothing is + extracted to disk, no archive inside is opened, and zipfile is shown only the directory records listed.""" + entries, spent = [], 0 + try: + with path.open("rb") as file: + directory = _zip_directory(file, os.fstat(file.fileno()).st_size, MAX_ZIP_ENTRIES) + if directory is None: + raise zipfile.BadZipFile("no readable central directory") + start, declared, records, count = directory + kept = b"".join(records) + offset = declared if declared <= 0xFFFFFFFF else start + # zipfile follows a Zip64 locator signature wherever one sits before the end record, and the kept records + # may end in those bytes, so a real Zip64 end record and locator for them come first. + tail = (kept + + struct.pack("<4sQ2H2I4Q", ZIP64_END, 44, 45, 45, 0, 0, len(records), len(records), len(kept), offset) + + struct.pack("<4sIQI", ZIP64_LOCATOR, 0, offset + len(kept), 1) + + struct.pack("<4s4H2IH", ZIP_END, 0, 0, len(records), len(records), len(kept), offset, 0)) + with zipfile.ZipFile(_BoundedArchive(file, start, tail)) as archive: + for info in archive.infolist(): + refusal = _zip_refusal(info, spent) + spent += info.file_size if refusal is None else 0 + entries.append(_zip_entry(archive, info, refusal)) + except (zipfile.BadZipFile, zlib.error, EOFError, NotImplementedError, OSError, ValueError, struct.error): + return {"format": "zip", "view": None, "error": "not a readable zip archive"} + view = {"entries": entries, "entry_count": count, "truncated": count > len(entries)} + return {"format": "zip", "view": view, "truncated": view["truncated"]} +`; diff --git a/src/domain/desktop/appTask.ts b/src/domain/desktop/appTask.ts index 4272eefa15..8cc0566ace 100644 --- a/src/domain/desktop/appTask.ts +++ b/src/domain/desktop/appTask.ts @@ -48,7 +48,11 @@ const checkSchema = z.discriminatedUnion("kind", [ contains: line.optional(), ...comparison, }), - kind("value_equals", { pointer: z.string(), value: jsonValueSchema }), + kind("value_equals", { + pointer: z.string(), + value: jsonValueSchema, + tolerance: z.number().min(0).optional(), + }), kind("value_absent", { pointer: z.string() }), kind("row_present", { table, where }), kind("row_absent", { table, where }), @@ -143,6 +147,12 @@ const checkProblem = (check: DesktopTaskCheck): string | undefined => { new Set(check.lines).size !== check.lines.length ) return "it lists a line twice"; + if ( + check.kind === "value_equals" && + check.tolerance !== undefined && + typeof check.value !== "number" + ) + return "tolerance compares numbers, so value must be a number"; const stamped = expectedValues(check).find(isTimestamp); return stamped === undefined ? undefined diff --git a/src/domain/desktop/appTaskChecks.fixture.ts b/src/domain/desktop/appTaskChecks.fixture.ts index 5e6c8ffa43..702ed50830 100644 --- a/src/domain/desktop/appTaskChecks.fixture.ts +++ b/src/domain/desktop/appTaskChecks.fixture.ts @@ -1,3 +1,4 @@ +import type { JsonValue } from "../jsonValue.js"; import type { DesktopTaskCheckInput } from "./appTask.js"; import type { TaskFileView } from "./appTaskChecks.js"; @@ -24,6 +25,7 @@ export const CHECK_VIEWS: Readonly> = { zero: 0, tags: ["a", "b"], "a/b": { "~k": null }, + scale: "1.2500", }, }, ".config/notes/app.ini": { @@ -60,6 +62,110 @@ export const CHECK_VIEWS: Readonly> = { format: "sqlite", view: { tables: {}, tables_truncated: true }, }, + "Documents/plate.FCStd": { + format: "zip", + view: { + entries: [ + { + name: "Document.xml", + size: 300, + sha256: "a".repeat(64), + format: "xml", + view: { + root: { + name: "Document", + attributes: {}, + children: [ + element("Object", { name: "Plate", type: "Part::Box" }), + element("Object", { name: "Hole", type: "Part::Cylinder" }, [ + element("Property", { name: "Radius" }, [ + element("Float", { value: "5.0000000000000000" }), + ]), + ]), + element("Object", { name: "Pad", type: "Part::Box" }), + "saved by hand", + ], + }, + truncated: false, + }, + view_error: null, + }, + { + name: "thumbnails/Thumbnail.png", + size: 3, + sha256: "b".repeat(64), + format: "binary", + view: null, + view_error: null, + }, + { + name: "shape.brp", + size: 9000, + sha256: "c".repeat(64), + format: "text", + view: { lines: ["a"], line_count: 6000, truncated: true }, + view_error: null, + }, + { + name: "huge.brp", + size: 17_000_000, + sha256: null, + format: null, + view: null, + view_error: "larger than the 16 MiB an entry's view reads", + }, + { + name: "empty.json", + size: 4, + sha256: "f".repeat(64), + format: "json", + view: null, + view_error: null, + }, + ], + entry_count: 5, + truncated: false, + }, + }, + "Documents/many.zip": { + format: "zip", + view: { entries: [], entry_count: 1200, truncated: true }, + }, + "Documents/cut.xml": { + format: "xml", + view: { + root: element("doc", {}, [element("p", { id: "1" }), "kept"]), + truncated: true, + }, + }, + "Documents/listing.zip": { + format: "zip", + view: { + entries: [ + { + name: "a.json", + size: 7, + sha256: "d".repeat(64), + format: "json", + view: { x: 1 }, + view_error: null, + }, + { + name: "page.xml", + size: 9000, + sha256: "e".repeat(64), + format: "xml", + view: { + root: element("page", {}, [element("p", { id: "1" })]), + truncated: true, + }, + view_error: null, + }, + ], + entry_count: 1500, + truncated: true, + }, + }, "Documents/photo.png": { format: "binary", view: null }, "Documents/huge.txt": { format: null, @@ -68,10 +174,318 @@ export const CHECK_VIEWS: Readonly> = { }, }; +/** An element of REA's XML view. */ +function element( + name: string, + attributes: Record, + children: JsonValue[] = [], +): JsonValue { + return { name, attributes, children }; +} + const todo = "Documents/todo.txt"; const settings = ".config/notes/settings.json"; const db = ".local/share/notes/notes.db"; const absent = "Documents/absent.txt"; +const plate = "Documents/plate.FCStd"; +const objects = "/entries/[name=Document.xml]/view/root/children"; +const radius = `${objects}/[name=Object][@name=Hole]/children/[name=Property][@name=Radius]/children/[name=Float]/attributes/value`; + +/** Selectors over arrays a view may hold only part of stay undecided; positions stay decided on what it holds. */ +const PARTIAL_SELECTOR_CASES: readonly (readonly [ + DesktopTaskCheckInput, + boolean, +])[] = [ + [ + { + kind: "value_equals", + file: "Documents/cut.xml", + pointer: "/root/children/[name=p]/attributes/id", + value: "1", + }, + false, + ], + [ + { + kind: "value_equals", + file: "Documents/cut.xml", + pointer: "/root/children/0/attributes/id", + value: "1", + }, + true, + ], + [ + { + kind: "value_equals", + file: "Documents/listing.zip", + pointer: "/entries/[name=a.json]/view/x", + value: 1, + }, + false, + ], + [ + { + kind: "value_equals", + file: "Documents/listing.zip", + pointer: "/entries/0/view/x", + value: 1, + }, + true, + ], + [ + { + kind: "value_equals", + file: "Documents/listing.zip", + pointer: "/entries/1/view/root/children/[name=p]/attributes/id", + value: "1", + }, + false, + ], +]; + +/** Selectors: one member matched, none, several, or a malformed one, through a zip view. */ +const SELECTOR_CASES: readonly (readonly [DesktopTaskCheckInput, boolean])[] = [ + [ + { + kind: "value_equals", + file: plate, + pointer: `${objects}/[name=Object][@name=Hole]/attributes/type`, + value: "Part::Cylinder", + }, + true, + ], + [ + { + kind: "value_equals", + file: plate, + pointer: `${objects}/[name=Object][@type=Part::Box]/attributes/name`, + value: "Plate", + }, + false, + ], + [ + { + kind: "value_equals", + file: plate, + pointer: `${objects}/[name=Object][@name=Gone]/attributes/name`, + value: "Gone", + }, + false, + ], + [ + { + kind: "value_equals", + file: plate, + pointer: `${objects}/[name=Object][@name=Gone]`, + value: "Gone", + }, + false, + ], + [ + { + kind: "value_absent", + file: plate, + pointer: `${objects}/[name=Object][@name=Gone]`, + }, + true, + ], + [ + { + kind: "value_absent", + file: plate, + pointer: `${objects}/[name=Object][@name=Gone]/attributes/type`, + }, + false, + ], + [ + { + kind: "value_absent", + file: plate, + pointer: `${objects}/[name=Object][@type=Part::Box]`, + }, + false, + ], + [ + { kind: "value_equals", file: settings, pointer: "/tags/[x]", value: "a" }, + false, + ], + [{ kind: "value_absent", file: settings, pointer: "/tags/[name=a]" }, true], + [ + { + kind: "value_equals", + file: plate, + pointer: "/entries/[name=thumbnails~1Thumbnail.png]/format", + value: "binary", + }, + true, + ], + [ + { + kind: "value_equals", + file: plate, + pointer: "/entries/0/name", + value: "Document.xml", + }, + true, + ], + [ + { + kind: "value_absent", + file: plate, + pointer: "/entries/[name=Document.xml]/view/root/attributes/x", + }, + true, + ], + [ + { + kind: "value_absent", + file: plate, + pointer: "/entries/[name=shape.brp]/view/lines/5", + }, + false, + ], + [ + { + kind: "value_absent", + file: plate, + pointer: "/entries/[name=huge.brp]/view/lines/0", + }, + false, + ], + [ + { kind: "value_absent", file: plate, pointer: "/entries/[name=gone.txt]" }, + true, + ], + [ + { + kind: "value_absent", + file: "Documents/many.zip", + pointer: "/entries/[name=gone.txt]", + }, + false, + ], + [{ kind: "lines_present", file: plate, lines: ["a"] }, false], + [ + { + kind: "value_equals", + file: plate, + pointer: "/entries/[name=huge.brp]/view", + value: null, + }, + false, + ], + [ + { + kind: "value_equals", + file: plate, + pointer: "/entries/[name=empty.json]/view", + value: null, + }, + true, + ], + [ + { + kind: "value_equals", + file: plate, + pointer: "/entries/[name=thumbnails~1Thumbnail.png]/view", + value: null, + }, + false, + ], + [ + { + kind: "value_absent", + file: plate, + pointer: "/entries/[name=thumbnails~1Thumbnail.png]/view/x", + }, + false, + ], + [ + { + kind: "value_equals", + file: plate, + pointer: "/entries/[name=thumbnails~1Thumbnail.png]/size", + value: 3, + }, + true, + ], + ...PARTIAL_SELECTOR_CASES, +]; + +/** Numbers within a tolerance, numeral strings read as numbers, anything else refused. */ +const TOLERANCE_CASES: readonly (readonly [DesktopTaskCheckInput, boolean])[] = + [ + [ + { + kind: "value_equals", + file: plate, + pointer: radius, + value: 5, + tolerance: 0.01, + }, + true, + ], + [ + { + kind: "value_equals", + file: plate, + pointer: radius, + value: 5.1, + tolerance: 0.01, + }, + false, + ], + [ + { + kind: "value_equals", + file: plate, + pointer: `${objects}/[name=Object][@name=Hole]/attributes/type`, + value: 5, + tolerance: 1, + }, + false, + ], + [ + { + kind: "value_equals", + file: settings, + pointer: "/ratio", + value: 0.49, + tolerance: 0.02, + }, + true, + ], + [ + { + kind: "value_equals", + file: settings, + pointer: "/wrap", + value: 1, + tolerance: 0, + }, + false, + ], + [ + { + kind: "value_equals", + file: settings, + pointer: "/scale", + value: 1.25, + tolerance: 0, + }, + true, + ], + [ + { + kind: "value_equals", + file: settings, + pointer: "/missing", + value: 1, + tolerance: 0, + }, + false, + ], + ]; /** Every kind of check against those views, each with the outcome both evaluators must give. */ export const CHECK_CASES: readonly (readonly [ @@ -319,4 +733,6 @@ export const CHECK_CASES: readonly (readonly [ [{ kind: "row_count", file: db, table: "notes", at_least: 3 }, false], [{ kind: "row_count", file: db, table: "big", at_least: 1 }, false], [{ kind: "row_count", file: absent, table: "notes", equals: 0 }, true], + ...SELECTOR_CASES, + ...TOLERANCE_CASES, ]; diff --git a/src/domain/desktop/appTaskChecks.test.ts b/src/domain/desktop/appTaskChecks.test.ts index be86236d4c..a050a5148a 100644 --- a/src/domain/desktop/appTaskChecks.test.ts +++ b/src/domain/desktop/appTaskChecks.test.ts @@ -118,6 +118,34 @@ const REFUSALS: readonly (readonly [Record, string])[] = [ { checks: [{ kind: "row_present", file: "a", table: "t", where: {} }] }, "name at least one column", ], + [ + { + checks: [ + { + kind: "value_equals", + file: "a", + pointer: "/x", + value: "5", + tolerance: 0.1, + }, + ], + }, + "tolerance compares numbers, so value must be a number", + ], + [ + { + checks: [ + { + kind: "value_equals", + file: "a", + pointer: "/x", + value: 5, + tolerance: -1, + }, + ], + }, + "at checks.0.tolerance", + ], ]; describe("evaluateTaskChecks", () => { @@ -152,6 +180,136 @@ describe("evaluateTaskChecks", () => { }); }); +describe("evaluateTaskChecks selectors and tolerance", () => { + const plate = "Documents/plate.FCStd"; + const objects = "/entries/[name=Document.xml]/view/root/children"; + const outcome = (check: Record) => + evaluateTaskChecks( + [{ id: "c", file: plate, ...check } as never], + new Map(Object.entries(CHECK_VIEWS)), + )[0]; + + it("leaves a selector undecided where the array it searches may be cut short, and a position decided", () => { + const cut = + "the view of Documents/listing.zip is truncated, so what it leaves out is unknown"; + + expect( + outcome({ + kind: "value_equals", + file: "Documents/listing.zip", + pointer: "/entries/[name=a.json]/view/x", + value: 1, + }), + ).toEqual({ id: "c", passed: false, description: cut }); + expect( + outcome({ + kind: "value_equals", + file: "Documents/listing.zip", + pointer: "/entries/1/view/root/children/[name=p]/attributes/id", + value: "1", + }), + ).toEqual({ id: "c", passed: false, description: cut }); + expect( + outcome({ + kind: "value_equals", + file: "Documents/listing.zip", + pointer: "/entries/0/view/x", + value: 1, + })?.passed, + ).toBe(true); + }); + + it("fails a check into an entry's view that could not be read, where JSON null passes", () => { + expect( + outcome({ + kind: "value_equals", + pointer: "/entries/[name=huge.brp]/view", + value: null, + }), + ).toEqual({ + id: "c", + passed: false, + description: + "Documents/plate.FCStd entry huge.brp has no view: larger than the 16 MiB an entry's view reads", + }); + expect( + outcome({ + kind: "value_equals", + pointer: "/entries/[name=empty.json]/view", + value: null, + })?.passed, + ).toBe(true); + expect( + outcome({ + kind: "value_equals", + pointer: "/entries/[name=thumbnails~1Thumbnail.png]/view", + value: null, + }), + ).toEqual({ + id: "c", + passed: false, + description: + "Documents/plate.FCStd entry thumbnails/Thumbnail.png is binary and has no view", + }); + }); + + it("fails a selector that matches several members or, on the way, none, and says which", () => { + expect( + outcome({ + kind: "value_equals", + pointer: `${objects}/[name=Object][@type=Part::Box]/attributes/name`, + value: "Plate", + }), + ).toMatchObject({ + passed: false, + description: `${plate} at ${objects}/[name=Object][@type=Part::Box]/attributes/name: [name=Object][@type=Part::Box] matches 2 members, not one`, + }); + expect( + outcome({ + kind: "value_absent", + pointer: `${objects}/[name=Object][@name=Gone]/attributes/type`, + })?.description, + ).toMatch(/: \[name=Object\]\[@name=Gone\] matches no member$/u); + expect( + outcome({ + kind: "value_equals", + pointer: `${objects}/[name=Object][@name=Gone]`, + value: 1, + })?.description, + ).toMatch(/matches no member$/u); + expect( + outcome({ + kind: "value_equals", + pointer: `${objects}/[Object]`, + value: 1, + })?.description, + ).toMatch(/: \[Object\] is not a selector of the form \[KEY=VALUE\]$/u); + }); + + it("compares numbers and numeral strings within a tolerance, and nothing else", () => { + const radius = `${objects}/[name=Object][@name=Hole]/children/[name=Property][@name=Radius]/children/[name=Float]/attributes/value`; + + expect( + outcome({ + kind: "value_equals", + pointer: radius, + value: 5.1, + tolerance: 0.01, + })?.description, + ).toBe( + `${plate} at ${radius} is "5.0000000000000000", not within 0.01 of 5.1`, + ); + expect( + outcome({ + kind: "value_equals", + pointer: `${objects}/[name=Object][@name=Hole]/attributes/type`, + value: 5, + tolerance: 1, + })?.description, + ).toMatch(/is "Part::Cylinder", not a number$/u); + }); +}); + describe("parseDesktopTask", () => { it("settles ids and keeps the prompt trimmed", () => { const parsed = parseDesktopTask( diff --git a/src/domain/desktop/appTaskChecks.ts b/src/domain/desktop/appTaskChecks.ts index 96fcee4794..bed05c4dc1 100644 --- a/src/domain/desktop/appTaskChecks.ts +++ b/src/domain/desktop/appTaskChecks.ts @@ -1,6 +1,12 @@ import type { JsonValue } from "../jsonValue.js"; import { countComparison, type DesktopTaskCheck } from "./appTask.js"; import type { DesktopStatePath } from "./desktopState.js"; +import { locatePointer, type Located } from "./viewPointer.js"; +import { + viewlessEntryAt, + zipArrayLeavesOut, + zipLeavesOutAt, +} from "./zipView.js"; /** One file as the app's data/get reports it: its view, or why it has none. An absent file has no entry. */ export interface TaskFileView { @@ -62,24 +68,15 @@ export const sameJson = ( return actual === expected; }; -/** The value a JSON pointer names, or undefined when it names none. */ -export const resolvePointer = ( - view: JsonValue | null, - pointer: string, -): JsonValue | undefined => { - let current: JsonValue | null = view; - for (const raw of pointer.slice(1).split("/")) { - const token = raw.replaceAll("~1", "/").replaceAll("~0", "~"); - if (Array.isArray(current)) { - if (!/^(?:0|[1-9][0-9]*)$/u.test(token)) return undefined; - const index = Number(token); - if (index >= current.length) return undefined; - current = current[index] ?? null; - } else if (isRecord(current) && Object.hasOwn(current, token)) - current = current[token] ?? null; - else return undefined; - } - return current ?? null; +/** A decimal numeral as FreeCAD and other apps write numbers in text, such as `5.0000000000000000`. */ +const NUMERAL = /^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/u; + +/** A number, or a numeral string read as one; undefined for anything else or a numeral past the doubles. */ +const numberOf = (value: JsonValue): number | undefined => { + if (typeof value === "number") return value; + if (typeof value !== "string" || !NUMERAL.test(value)) return undefined; + const number = Number(value); + return Number.isFinite(number) ? number : undefined; }; /** A file's version as the app's data/get serves it, or undefined when it is absent. */ @@ -92,19 +89,6 @@ export const versionView = ( ? { format: version.format, view: version.view } : { format: version.format, view: null, error: version.view_error }; -/** Whether an XML view names a namespace, whose names the verifier's reader spells differently. */ -export const namesXmlNamespace = (node: JsonValue | null): boolean => { - if (Array.isArray(node)) return node.some(namesXmlNamespace); - if (!isRecord(node)) return false; - const named = (name: JsonValue | undefined) => - typeof name === "string" && (name.includes(":") || name === "xmlns"); - return ( - named(node.name) || - (isRecord(node.attributes) && Object.keys(node.attributes).some(named)) || - Object.values(node).some(namesXmlNamespace) - ); -}; - /** Whether a view says it holds only part of its file, as REA's own readers flag it. */ const truncatedView = (format: string | null, view: JsonValue | null) => { if (!isRecord(view)) return false; @@ -196,33 +180,85 @@ const lineVerdict = ( ); }; +/** A value compared within a tolerance: numbers, and numeral strings, by their difference. */ +const nearVerdict = ( + at: string, + found: JsonValue, + expected: number, + tolerance: number, +): Verdict => { + const number = numberOf(found); + const within = `within ${shown(tolerance)} of ${shown(expected)}`; + if (number === undefined) + return fail(`${at} is ${shown(found)}, not a number`); + return Math.abs(number - expected) <= tolerance + ? pass(`${at} is ${shown(found)}, ${within}`) + : fail(`${at} is ${shown(found)}, not ${within}`); +}; + +/** + * Why a value check cannot be decided on what the view holds: the entry whose + * view its pointer goes into has none, or a selector searched an array the view + * may hold only part of. Undefined when it can be. + */ +const unreadOrPartial = ( + check: Check<"value_equals" | "value_absent">, + format: string | null | undefined, + read: { readonly view: JsonValue | null; readonly truncated: boolean }, + located: Located, +): Verdict | undefined => { + const zip = format === "zip"; + const viewless = zip ? viewlessEntryAt(read.view, check.pointer) : undefined; + if (viewless !== undefined) + return fail(`${check.file} entry ${viewless.name} ${viewless.reason}`); + // A selector tells one member from several only in an array the view holds whole. + const partial = (array: string) => + zip ? zipArrayLeavesOut(read.view, array) : read.truncated; + return located.searched.some(partial) ? undecided(check.file) : undefined; +}; + const valueVerdict = ( check: Check<"value_equals" | "value_absent">, entry: TaskFileView | undefined, ): Verdict => { const read = viewOf(check, entry, undefined); if ("passed" in read) return read; - const found = - entry === undefined ? undefined : resolvePointer(read.view, check.pointer); + const located: Located = + entry === undefined + ? { kind: "none", searched: [], byPosition: [] } + : locatePointer(read.view, check.pointer); const at = `${check.file} at ${check.pointer}`; + const unknown = unreadOrPartial(check, entry?.format, read, located); + if (unknown !== undefined) return unknown; + if (located.kind === "unresolved") return fail(`${at}: ${located.problem}`); + // A zip view's truncation that matters is the entry the pointer goes into. + const truncated = + entry?.format === "zip" + ? zipLeavesOutAt(read.view, check.pointer) + : read.truncated; // A whole object or array equals the expected one only if nothing of it was left out. const whole = check.kind === "value_equals" && typeof check.value === "object" && check.value !== null; - if (whole && read.truncated) return undecided(check.file); - if (check.kind === "value_equals") - return found !== undefined && sameJson(found, check.value) + if (whole && truncated) return undecided(check.file); + if (check.kind === "value_equals") { + if (located.kind === "none") + return fail( + located.reason === undefined + ? `${at} has no value` + : `${at}: ${located.reason}`, + ); + if (check.tolerance !== undefined && typeof check.value === "number") + return nearVerdict(at, located.value, check.value, check.tolerance); + return sameJson(located.value, check.value) ? pass(`${at} is ${shown(check.value)}`) - : fail( - found === undefined - ? `${at} has no value` - : `${at} is ${shown(found)}, not ${shown(check.value)}`, - ); - if (read.truncated) return undecided(check.file); - return found === undefined + : fail(`${at} is ${shown(located.value)}, not ${shown(check.value)}`); + } + if (truncated) return undecided(check.file); + return located.kind === "none" ? pass(`${at} has no value`) - : fail(`${at} is ${shown(found)}`); + : fail(`${at} is ${shown(located.value)}`); }; /** A table's columns and rows; a table the view lacks has none, unless the view left tables out. */ diff --git a/src/domain/desktop/appTaskDraft.test.ts b/src/domain/desktop/appTaskDraft.test.ts index 3f3fe6b246..c44d5b4918 100644 --- a/src/domain/desktop/appTaskDraft.test.ts +++ b/src/domain/desktop/appTaskDraft.test.ts @@ -46,6 +46,7 @@ const draft = (files: Record) => draftTaskFiles( new Map(Object.entries(files)), (home) => home !== "elsewhere", + { seed: 0, end: 1 }, ); describe("draftTaskFiles", () => { @@ -269,6 +270,46 @@ describe("draftTaskFiles for structured views", () => { }); }); +describe("draftTaskFiles for XML that uses namespaces", () => { + it("suggests values inside an XML file by pointer, as for any XML", () => { + const poster = (headline: string): JsonValue => ({ + root: { + name: "svg", + attributes: { + xmlns: "http://www.w3.org/2000/svg", + "sodipodi:docname": "poster.svg", + }, + children: [ + { + name: "text", + attributes: { id: "headline" }, + children: [headline], + }, + ], + }, + truncated: false, + }); + + const [document] = draft({ + "Pictures/poster.svg": path( + "document", + "xml", + poster("Spring Bake Sale"), + poster("Summer Bake Sale"), + ), + }); + + expect(document?.suggested).toEqual([ + { + kind: "value_equals", + file: "Pictures/poster.svg", + pointer: "/root/children/0/children/0", + value: "Summer Bake Sale", + }, + ]); + }); +}); + describe("draftTaskFiles with partial evidence", () => { const notes = (...rows: JsonValue[][]) => ({ tables: { @@ -349,3 +390,465 @@ describe("draftTaskFiles with partial evidence", () => { }); }); }); + +/** An element of REA's XML view. */ +const xmlElement = ( + name: string, + attributes: Record = {}, + children: JsonValue[] = [], +): JsonValue => ({ name, attributes, children }); + +/** A zip view's entry, read, under a name. */ +const zipEntry = ( + name: string, + format: string, + view: JsonValue, +): JsonValue => ({ + name, + size: 1, + sha256: name, + format, + view, + view_error: null, +}); + +const listing = (entries: JsonValue[]): JsonValue => ({ + entries, + entry_count: entries.length, + truncated: false, +}); + +describe("draftTaskFiles archives added to whole", () => { + it("suggests checks inside a JSON value or a readable entry added whole", () => { + const [file] = draft({ + "Documents/a.zip": path( + "document", + "zip", + listing([zipEntry("a.json", "json", { a: 1 })]), + listing([ + zipEntry("a.json", "json", { a: 1, added: { x: 1 } }), + zipEntry("new.json", "json", { y: [2] }), + zipEntry("new.xml", "xml", { + root: xmlElement("doc"), + truncated: false, + }), + zipEntry("new.png", "binary", null), + ]), + ), + }); + + expect(file?.suggested).toEqual([ + { + kind: "value_equals", + file: "Documents/a.zip", + pointer: "/entries/[name=new.xml]/view/root/name", + value: "doc", + }, + { + kind: "value_equals", + file: "Documents/a.zip", + pointer: "/entries/[name=a.json]/view/added/x", + value: 1, + }, + { + kind: "value_equals", + file: "Documents/a.zip", + pointer: "/entries/[name=new.json]/view/y/0", + value: 2, + }, + ]); + }); + + it("checks an empty array or object added as a value, and nothing absent that a type change keeps", () => { + const [file] = draft({ + "Documents/a.zip": path( + "document", + "zip", + listing([zipEntry("a.json", "json", { a: 1, kept: [1] })]), + listing([ + zipEntry("a.json", "json", { + a: 1, + kept: { "0": 1 }, + items: [], + meta: {}, + }), + ]), + ), + }); + + expect(file?.suggested).toEqual([ + { + kind: "value_equals", + file: "Documents/a.zip", + pointer: "/entries/[name=a.json]/view/items", + value: [], + }, + { + kind: "value_equals", + file: "Documents/a.zip", + pointer: "/entries/[name=a.json]/view/meta", + value: {}, + }, + ]); + }); + + it("suggests no element by its position, even at the last step", () => { + const page = (paragraphs: string[]) => + listing([ + zipEntry("page.xml", "xml", { + root: xmlElement( + "page", + {}, + paragraphs.map((text) => xmlElement("p", {}, [text])), + ), + truncated: false, + }), + ]); + + const [file] = draft({ + "Documents/page.zip": path( + "document", + "zip", + page(["a"]), + page(["a", "b"]), + ), + }); + + expect(file).toMatchObject({ change: { kind: "values" }, suggested: [] }); + }); +}); + +describe("draftTaskFiles archive elements by identity", () => { + it("checks an element replaced by another of its type as one added and one gone, not renamed", () => { + const document = (name: string, id: string) => + listing([ + zipEntry("Document.xml", "xml", { + root: xmlElement("Objects", {}, [ + xmlElement("Object", { type: "Part::Box", name: "Plate" }), + xmlElement("Object", { type: "Part::Cylinder", name, id }), + ]), + truncated: false, + }), + ]); + + const [file] = draft({ + "Documents/plate.zip": path( + "document", + "zip", + document("Hole", "2"), + document("Hole001", "3"), + ), + }); + + const objects = "/entries/[name=Document.xml]/view/root/children"; + expect(file?.suggested).toEqual([ + { + kind: "value_equals", + file: "Documents/plate.zip", + pointer: `${objects}/[name=Object][@name=Hole001]/name`, + value: "Object", + }, + { + kind: "value_absent", + file: "Documents/plate.zip", + pointer: `${objects}/[name=Object][@name=Hole]`, + }, + ]); + }); + + it("checks an edit to an element its identifier still names, not a replacement", () => { + const list = (color: string) => + listing([ + zipEntry("list.xml", "xml", { + root: xmlElement("list", {}, [ + xmlElement("item", { color, id: "1" }), + xmlElement("item", { id: "2" }), + ]), + truncated: false, + }), + ]); + + const [file] = draft({ + "Documents/list.zip": path("document", "zip", list("red"), list("green")), + }); + + expect(file?.suggested).toEqual([ + { + kind: "value_equals", + file: "Documents/list.zip", + pointer: + "/entries/[name=list.xml]/view/root/children/[name=item][@id=1]/attributes/color", + value: "green", + }, + ]); + }); +}); + +/** The checks drafted for `list.xml` in an archive whose `item` elements change. */ +const itemChecks = (before: JsonValue[], after: JsonValue[]) => { + const list = (items: JsonValue[]) => + listing([ + zipEntry("list.xml", "xml", { + root: xmlElement("list", {}, items), + truncated: false, + }), + ]); + const [file] = draft({ + "Documents/list.zip": path("document", "zip", list(before), list(after)), + }); + return file?.suggested; +}; + +const ITEMS = "/entries/[name=list.xml]/view/root/children"; + +describe("draftTaskFiles archive elements beside identified ones", () => { + it("counts an identified element's other attributes, so a selector it shares is not taken for absent", () => { + expect( + itemChecks( + [ + xmlElement("item", { id: "1", color: "red" }), + xmlElement("item", { color: "blue" }), + ], + [ + xmlElement("item", { id: "1", color: "blue" }), + xmlElement("item", { color: "red" }), + ], + ), + ).toEqual([ + { + kind: "value_equals", + file: "Documents/list.zip", + pointer: `${ITEMS}/[name=item][@id=1]/attributes/color`, + value: "blue", + }, + ]); + }); + + it("keys an element by a selector naming it alone, not one an identified sibling also matches", () => { + const body = (text: string) => + xmlElement("item", { color: "red", role: "body" }, [text]); + const id = xmlElement("item", { id: "1", color: "red" }); + + expect(itemChecks([id, body("old")], [id, body("new")])).toEqual([ + { + kind: "value_equals", + file: "Documents/list.zip", + pointer: `${ITEMS}/[name=item][@role=body]/children/0`, + value: "new", + }, + ]); + }); + + it("passes over a selector an identified sibling shares for one naming the element alone", () => { + const second = (color: string) => + xmlElement("item", { class: "common", role: "second", color }); + const id = xmlElement("item", { id: "1", class: "common" }); + + expect(itemChecks([id, second("red")], [id, second("blue")])).toEqual([ + { + kind: "value_equals", + file: "Documents/list.zip", + pointer: `${ITEMS}/[name=item][@role=second]/attributes/color`, + value: "blue", + }, + ]); + }); +}); + +describe("draftTaskFiles archive entries without a view, and type changes", () => { + it("checks no entry whose view could not be read, though JSON null is a value", () => { + const [file] = draft({ + "Documents/a.zip": path( + "document", + "zip", + listing([]), + listing([ + { + name: "big.json", + size: 1, + sha256: "big.json", + format: "json", + view: null, + view_error: + "a number in it cannot be read exactly, such as an integer above 2^53", + }, + zipEntry("null.json", "json", null), + ]), + ), + }); + + expect(file?.suggested).toEqual([ + { + kind: "value_equals", + file: "Documents/a.zip", + pointer: "/entries/[name=null.json]/view", + value: null, + }, + ]); + }); + + it("suggests no absence inside an entry that ends with no view, such as one now binary", () => { + const [file] = draft({ + "Documents/a.zip": path( + "document", + "zip", + listing([zipEntry("a.json", "json", { a: 1 })]), + listing([zipEntry("a.json", "binary", null)]), + ), + }); + + expect(file).toMatchObject({ change: { kind: "values" }, suggested: [] }); + }); + + it("suggests no absence of a value a selector still names after a type change", () => { + const [file] = draft({ + "Documents/a.zip": path( + "document", + "zip", + listing([zipEntry("a.json", "json", { items: { "[name=x]": 1 } })]), + listing([zipEntry("a.json", "json", { items: [{ name: "x" }] })]), + ), + }); + + expect(file?.suggested).toEqual([ + { + kind: "value_equals", + file: "Documents/a.zip", + pointer: "/entries/[name=a.json]/view/items/[name=x]/name", + value: "x", + }, + ]); + }); +}); + +describe("draftTaskFiles archives", () => { + it("suggests checks inside an archive's XML by name: elements added or removed whole, then values", () => { + const element = ( + name: string, + attributes: Record, + children: JsonValue[] = [], + ): JsonValue => ({ name, attributes, children }); + const archive = (objects: JsonValue[], saved: string): JsonValue => ({ + entries: [ + { + name: "Document.xml", + size: 1, + sha256: saved, + format: "xml", + view: { + root: element("Document", { saved }, [ + element("Objects", {}, objects), + ]), + truncated: false, + }, + view_error: null, + }, + { + name: "thumb.png", + size: 1, + sha256: saved, + format: "binary", + view: null, + view_error: null, + }, + ], + entry_count: 2, + truncated: false, + }); + const plate = { name: "Plate", type: "Part::Box" }; + const box = element("Object", { ...plate, count: "1" }); + const sketch = element("Object", { name: "Sketch", type: "Sketcher" }); + const hole = element("Object", { name: "Hole", type: "Part::Cylinder" }, [ + element("Property", { name: "Radius" }, [ + element("Float", { value: "5.0" }), + ]), + ]); + + const [document] = draft({ + "Documents/plate.FCStd": path( + "document", + "zip", + archive([box, sketch], "2026-11-04T10:12:00Z"), + archive( + [element("Object", { ...plate, count: "2" }), hole], + "2026-11-04T10:13:00Z", + ), + ), + }); + + const objects = + "/entries/[name=Document.xml]/view/root/children/[name=Objects]/children"; + expect(document?.suggested).toEqual([ + { + kind: "value_equals", + file: "Documents/plate.FCStd", + pointer: `${objects}/[name=Object][@name=Hole]/name`, + value: "Object", + }, + { + kind: "value_absent", + file: "Documents/plate.FCStd", + pointer: `${objects}/[name=Object][@name=Sketch]`, + }, + { + kind: "value_equals", + file: "Documents/plate.FCStd", + pointer: `${objects}/[name=Object][@name=Plate]/attributes/count`, + value: "2", + }, + ]); + }); + + it("suggests checks inside XML that uses namespaces, by its prefixed names", () => { + const paragraph = (style: string, text: string): JsonValue => ({ + name: "text:p", + attributes: { "text:style-name": style }, + children: [text], + }); + const odt = (date: string): JsonValue => ({ + entries: [ + { + name: "content.xml", + size: 1, + sha256: date, + format: "xml", + view: { + root: { + name: "office:text", + attributes: { + "xmlns:text": "urn:oasis:names:tc:opendocument:xmlns:text:1.0", + }, + children: [ + paragraph("Title", "Summer Bake Sale"), + paragraph("Subtitle", date), + ], + }, + truncated: false, + }, + view_error: null, + }, + ], + entry_count: 1, + truncated: false, + }); + + const [flyer] = draft({ + "Documents/flyer.odt": path( + "document", + "zip", + odt("Monday 17 November"), + odt("Monday 24 November"), + ), + }); + + expect(flyer?.suggested).toEqual([ + { + kind: "value_equals", + file: "Documents/flyer.odt", + pointer: + "/entries/[name=content.xml]/view/root/children/[name=text:p][@text:style-name=Subtitle]/children/0", + value: "Monday 24 November", + }, + ]); + }); +}); diff --git a/src/domain/desktop/appTaskDraft.ts b/src/domain/desktop/appTaskDraft.ts index 1bab5cf508..d0c3b1c9b9 100644 --- a/src/domain/desktop/appTaskDraft.ts +++ b/src/domain/desktop/appTaskDraft.ts @@ -1,8 +1,13 @@ import type { JsonValue } from "../jsonValue.js"; import { isTimestamp, type DesktopTaskCheckInput } from "./appTask.js"; -import { namesXmlNamespace, resolvePointer } from "./appTaskChecks.js"; -import type { DesktopStatePath } from "./desktopState.js"; -import { diffStateViews } from "./stateChanges.js"; +import { + versionAt, + type DesktopStatePath, + type StateEnds, +} from "./desktopState.js"; +import { diffStateViews, diffZipMembers } from "./stateChanges.js"; +import { locatePointer, pairMembers, resolvePointer } from "./viewPointer.js"; +import { entryWithoutView, zipArrayLeavesOut, zipEntryAt } from "./zipView.js"; type Version = DesktopStatePath["versions"][number]; type Suggestion = DesktopTaskCheckInput; @@ -124,15 +129,142 @@ const innermost = (pointers: readonly string[]): readonly string[] => (pointer) => !pointers.some((other) => other.startsWith(`${pointer}/`)), ); +/** + * The entry a pointer into a zip view goes into, when it has a view and both + * evaluators read it alike: JSON, a key file or XML. A binary or unread + * entry also holds a `null` view, which is no JSON `null`. + */ +const readableEntry = (view: JsonValue | null, pointer: string) => { + const entry = zipEntryAt(view, pointer); + return typeof entry === "object" && + entryWithoutView(entry) === undefined && + (entry.format === "json" || + entry.format === "keyfile" || + entry.format === "xml") + ? entry + : undefined; +}; + +/** + * Where checks for a pointer the diff lists start: the view of an entry it + * names whole, or its root element for XML; the pointer itself inside that; + * undefined for an entry neither evaluator reads alike, or the entry's fields. + */ +const checkRoot = ( + view: JsonValue | null, + pointer: string, +): string | undefined => { + const entry = readableEntry(view, pointer); + const [entryPointer] = /^\/entries\/[^/]+/u.exec(pointer) ?? []; + if (entry === undefined || entryPointer === undefined) return undefined; + const inner = pointer.slice(entryPointer.length).split("/").slice(1); + const root = entry.format === "xml" ? ["view", "root"] : ["view"]; + const prefix = (short: readonly string[], long: readonly string[]) => + short.length <= long.length && + short.every((part, index) => long[index] === part); + if (prefix(inner, root)) return `${entryPointer}/${root.join("/")}`; + return prefix(root, inner) ? pointer : undefined; +}; + +/** Whether a check at a pointer is decided by name: no object or array reached by position, no array searched that the view may cut. */ +const byName = (view: JsonValue | null, pointer: string) => { + const located = locatePointer(view, pointer); + return ( + located.kind !== "unresolved" && + located.byPosition.length === 0 && + !located.searched.some((array) => zipArrayLeavesOut(view, array)) + ); +}; + +/** An element of REA's XML view, by its tag name, or undefined for any other value. */ +const elementName = (value: JsonValue | undefined): string | undefined => + isRecord(value) && + typeof value.name === "string" && + isRecord(value.attributes) && + Array.isArray(value.children) + ? value.name + : undefined; + +/** + * Checks on the entries of an archive, by the selectors its diff names + * members with: elements added or removed whole first, as the likeliest + * record of what the user did, then values changed, added and removed. A + * JSON value or an entry added whole is checked through its members. + */ +const zipSuggestions = ( + file: string, + baseline: Version, + final: Version, +): Suggestion[] => { + const change = diffZipMembers(baseline.view, final.view); + const elements: Suggestion[] = []; + const values: Suggestion[] = []; + const equals = (pointer: string, value: JsonValue | undefined) => { + if ( + values.length < MAX_SUGGESTIONS && + value !== undefined && + !(isRecord(value) && Object.keys(value).length > 0) && + !isTimestamp(value) && + byName(final.view, pointer) + ) + values.push({ kind: "value_equals", file, pointer, value }); + }; + const added = (pointer: string, value: JsonValue | undefined): void => { + if (elements.length >= MAX_SUGGESTIONS && values.length >= MAX_SUGGESTIONS) + return; + const name = elementName(value); + if (name !== undefined) { + if ( + elements.length < MAX_SUGGESTIONS && + byName(final.view, `${pointer}/name`) + ) + elements.push({ + kind: "value_equals", + file, + pointer: `${pointer}/name`, + value: name, + }); + } else if (Array.isArray(value) && value.length > 0) { + const { keys } = pairMembers(value, []).newer; + value.forEach((item, index) => + added(`${pointer}/${escapeToken(keys[index] ?? String(index))}`, item), + ); + } else if (isRecord(value) && Object.keys(value).length > 0) + for (const [key, item] of Object.entries(value)) + added(`${pointer}/${escapeToken(key)}`, item); + // A scalar, or an empty array or object, is checked as it is. + else equals(pointer, value); + }; + for (const pointer of change.added) { + const root = checkRoot(final.view, pointer); + if (root !== undefined) added(root, resolvePointer(final.view, root)); + } + for (const pointer of innermost(change.changed)) + if (checkRoot(final.view, pointer) === pointer) + equals(pointer, resolvePointer(final.view, pointer)); + // An absence is checked only where the entry still has a view to lack it in. + for (const pointer of change.removed) + if ( + checkRoot(baseline.view, pointer) === pointer && + checkRoot(final.view, pointer) === pointer && + byName(baseline.view, pointer) && + byName(final.view, pointer) + ) + (elementName(resolvePointer(baseline.view, pointer)) === undefined + ? values + : elements + ).push({ kind: "value_absent", file, pointer }); + return [...elements, ...values]; +}; + const valueSuggestions = ( file: string, baseline: Version, final: Version, ): Suggestion[] => { const format = final.format; + if (format === "zip") return zipSuggestions(file, baseline, final); if (format !== "json" && format !== "xml" && format !== "keyfile") return []; - if (format === "xml" && [baseline.view, final.view].some(namesXmlNamespace)) - return []; const change = diffStateViews(format, baseline.view, final.view); const pointers = keyPointers([baseline.view, final.view]); const pointer = (entry: string) => @@ -350,19 +482,20 @@ export interface TaskDraftFile { } /** - * Each file under the owned paths whose state differs between the baseline and - * the final version, with suggested checks for the files the user worked on: - * documents, or the app's own state when no document changed. + * Each file under the owned paths whose state differs between its versions at + * the seed and end checkpoints, with suggested checks for the files the user + * worked on: documents, or the app's own state when no document changed. */ export const draftTaskFiles = ( files: ReadonlyMap, served: (home: string) => boolean, + ends: StateEnds, ): readonly TaskDraftFile[] => { const changed = [...files] .filter(([home]) => served(home)) .flatMap(([home, path]) => { - const baseline = path.versions[0]; - const final = path.versions.at(-1); + const baseline = versionAt(path, ends.seed); + const final = versionAt(path, ends.end); const change = baseline === undefined || final === undefined ? undefined diff --git a/src/domain/desktop/appTaskPython.ts b/src/domain/desktop/appTaskPython.ts index 73a8228655..50d0fbd68e 100644 --- a/src/domain/desktop/appTaskPython.ts +++ b/src/domain/desktop/appTaskPython.ts @@ -4,6 +4,9 @@ import type { DesktopTask } from "./appTask.js"; /** The verifier's evaluator: the checks' semantics, kept equal to `evaluateTaskChecks`. */ export const PYTHON_TASK_EVALUATOR = String.raw` MISSING = object() +INDEX = re.compile(r"0|[1-9][0-9]*") +VIEW_FORMATS = ("text", "json", "keyfile", "xml") +NUMERAL = re.compile(r"[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?") def _shown(value) -> str: @@ -26,19 +29,130 @@ def _same(actual, expected) -> bool: return type(actual) is type(expected) and actual == expected -def _resolve(view, pointer: str): - current = view - for raw in pointer[1:].split("/"): - token = raw.replace("~1", "/").replace("~0", "~") +def _bracketed(token: str) -> bool: + return len(token) >= 2 and token.startswith("[") and token.endswith("]") + + +def _selector(token: str): + """A selector's conditions, [KEY=VALUE] or [@KEY=VALUE] one or more times, or None when the token is not one.""" + if not _bracketed(token): + return None + conditions = [] + for part in token[1:-1].split("]["): + key, equals, value = part.partition("=") + name = key[1:] if key.startswith("@") else key + if not equals or not name: + return None + conditions.append((key.startswith("@"), name, value)) + return conditions + + +def _selects(member, conditions) -> bool: + """Whether an array member meets every condition; selectors compare strings only.""" + if not isinstance(member, dict): + return False + for attribute, key, value in conditions: + fields = member.get("attributes") if attribute else member + if not isinstance(fields, dict) or not isinstance(fields.get(key), str) or fields[key] != value: + return False + return True + + +def _step(items: list, token: str, last: bool): + """One step into an array, by index or by a selector that must match one member, or none when it is the last.""" + if INDEX.fullmatch(token): + return ("member", items[int(token)]) if int(token) < len(items) else ("none", None) + conditions = _selector(token) + if conditions is None: + return ("unresolved", f"{token} is not a selector of the form [KEY=VALUE]") if _bracketed(token) else ( + "none", None) + matched = [item for item in items if _selects(item, conditions)] + if len(matched) == 1: + return "member", matched[0] + if len(matched) > 1: + return "unresolved", f"{token} matches {len(matched)} members, not one" + return ("none" if last else "unresolved"), f"{token} matches no member" + + +def _locate(view, pointer: str): + """Where a pointer leads: ("value", value), ("none", why or None) or ("unresolved", problem), with the pointers + to the arrays a selector searched on the way.""" + current, searched = view, [] + segments = pointer[1:].split("/") + for position, segment in enumerate(segments): + token = segment.replace("~1", "/").replace("~0", "~") if isinstance(current, list): - if not re.fullmatch(r"0|[1-9][0-9]*", token) or int(token) >= len(current): - return MISSING - current = current[int(token)] + if not INDEX.fullmatch(token) and _selector(token) is not None: + searched.append("".join("/" + part for part in segments[:position])) + kind, found = _step(current, token, position == len(segments) - 1) + if kind != "member": + return kind, found, searched + current = found elif isinstance(current, dict) and token in current: current = current[token] else: - return MISSING - return current + return "none", None, searched + return "value", current, searched + + +def _number(value): + """A number, or a numeral string read as one; None for anything else, a boolean, or a numeral past the doubles.""" + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return value + if not isinstance(value, str) or not NUMERAL.fullmatch(value): + return None + number = float(value) + return number if math.isfinite(number) else None + + +def _entry_leaves_out(entry) -> bool: + return isinstance(entry, dict) and (isinstance(entry.get("view_error"), str) or ( + entry.get("format") in ("text", "xml") and isinstance(entry.get("view"), dict) + and entry["view"].get("truncated") is True)) + + +def _viewless_entry(view, pointer: str): + """The name of the entry whose view a pointer goes into when it has none, so its null is no JSON value, and + why: binary, unread, or of a format without a view; None when it has one.""" + parts = pointer[1:].split("/") + if len(parts) < 3 or parts[0] != "entries" or parts[2] != "view": + return None + kind, item, _ = _locate(view, "/entries/" + parts[1]) + if kind != "value" or not isinstance(item, dict): + return None + if isinstance(item.get("view_error"), str): + reason = f"has no view: {item['view_error']}" + elif item.get("format") == "binary": + reason = "is binary and has no view" + elif item.get("format") not in VIEW_FORMATS: + reason = "has no view" + else: + return None + return (item["name"] if isinstance(item.get("name"), str) else ""), reason + + +def _array_leaves_out(form, view, array: str) -> bool: + """Whether an array a selector searched may lack members: in a zip the listing or its entry's view, else the + view's own truncation.""" + if form != "zip": + return _truncated(form, view) + if array == "/entries": + return isinstance(view, dict) and view.get("truncated") is True + return _zip_leaves_out(view, array) + + +def _zip_leaves_out(view, pointer: str) -> bool: + """Whether a zip view leaves out what a pointer reaches: for a pointer into one listed entry, only that entry.""" + if not isinstance(view, dict): + return False + parts = pointer[1:].split("/") + if parts[0] == "entries" and len(parts) > 1: + kind, entry, _ = _locate(view, "/entries/" + parts[1]) + return _entry_leaves_out(entry) if kind == "value" else view.get("truncated") is True + entries = view.get("entries") if isinstance(view.get("entries"), list) else [] + return view.get("truncated") is True or any(_entry_leaves_out(entry) for entry in entries) def _truncated(form, view) -> bool: @@ -102,21 +216,47 @@ def _lines(check, entry): return _compared(check, n, f"line(s) of {file} match {_shown(check['exact'])}") +def _near(at: str, found, expected, tolerance): + """A value compared within a tolerance: numbers, and numeral strings, by their difference.""" + number = _number(found) + within = f"within {_shown(tolerance)} of {_shown(expected)}" + if number is None: + return False, f"{at} is {_shown(found)}, not a number" + if abs(number - expected) <= tolerance: + return True, f"{at} is {_shown(found)}, {within}" + return False, f"{at} is {_shown(found)}, not {within}" + + def _value(check, entry): view, truncated, problem = _view_of(check, entry, None) if problem is not None: return False, problem - found = MISSING if entry is None else _resolve(view, check["pointer"]) + kind, found, searched = ("none", None, []) if entry is None else _locate(view, check["pointer"]) at = f"{check['file']} at {check['pointer']}" + viewless = _viewless_entry(view, check["pointer"]) if entry is not None and entry.get("format") == "zip" else None + if viewless is not None: + return False, f"{check['file']} entry {viewless[0]} {viewless[1]}" + # A selector tells one member from several only in an array the view holds whole. + if any(_array_leaves_out(entry.get("format"), view, array) for array in searched): + return _undecided(check) + if kind == "unresolved": + return False, f"{at}: {found}" + if entry is not None and entry.get("format") == "zip": + truncated = _zip_leaves_out(view, check["pointer"]) if check["kind"] == "value_equals" and isinstance(check["value"], (dict, list)) and truncated: return _undecided(check) if check["kind"] == "value_equals": - if found is not MISSING and _same(found, check["value"]): + if kind == "none": + return False, f"{at} has no value" if found is None else f"{at}: {found}" + expected = check["value"] + if check.get("tolerance") is not None and isinstance(expected, (int, float)) and not isinstance(expected, bool): + return _near(at, found, expected, check["tolerance"]) + if _same(found, check["value"]): return True, f"{at} is {_shown(check['value'])}" - return False, f"{at} has no value" if found is MISSING else f"{at} is {_shown(found)}, not {_shown(check['value'])}" + return False, f"{at} is {_shown(found)}, not {_shown(check['value'])}" if truncated: return _undecided(check) - return (True, f"{at} has no value") if found is MISSING else (False, f"{at} is {_shown(found)}") + return (True, f"{at} has no value") if kind == "none" else (False, f"{at} is {_shown(found)}") def _rows(check, entry): @@ -194,6 +334,7 @@ It reads the app's data/get through the computer, takes the typed views of its f model, no code from the task. A failure to reach the app raises, so the run fails rather than scoring the agent. """ import json +import math import re from agentenv_protocol import client as protocol diff --git a/src/domain/desktop/aptRepositories.ts b/src/domain/desktop/aptRepositories.ts index 7a1164be80..9ebfe7c50d 100644 --- a/src/domain/desktop/aptRepositories.ts +++ b/src/domain/desktop/aptRepositories.ts @@ -291,6 +291,33 @@ export const declaredPackageFiles = ( ); }; +/** The packages of `supplied` whose declared sources include one of the repository's package files, sorted. */ +export const repositorySupplies = ( + repository: Pick, + supplied: ReadonlyMap, + targets: readonly AptIndexTarget[], +): readonly string[] => { + const files = declaredPackageFiles([repository], targets); + return [...supplied] + .filter(([, sources]) => sources.some((source) => files.has(source))) + .map(([name]) => name) + .sort(); +}; + +/** The `NAME='value'` lines `apt-config shell` prints, by name; apt writes a quote in a value as `'\''`. */ +export const parseAptConfigShell = ( + output: string, +): ReadonlyMap => + new Map( + output.split("\n").flatMap((line) => { + const match = /^(?[A-Za-z_]\w*)='(?.*)'$/u.exec(line); + const { name, value } = match?.groups ?? {}; + return name === undefined || value === undefined + ? [] + : [[name, value.replaceAll(`'\\''`, "'")] as const]; + }), + ); + const QUOTED = new Set('\\|{}[]<>"^~_=!@#$%^&*'); /** apt's file name for a URI in its lists directory: no scheme or credentials, special characters quoted, `/` as `_`. */ diff --git a/src/domain/desktop/desktopCapture.ts b/src/domain/desktop/desktopCapture.ts index 484e8c089e..07f251b41e 100644 --- a/src/domain/desktop/desktopCapture.ts +++ b/src/domain/desktop/desktopCapture.ts @@ -90,6 +90,8 @@ const aptRepositorySchema = z.strictObject({ signing_key: aptSigningKeySchema.nullable(), /** Why no signing key was verified, or why the repository was not checked. */ signing_key_reason: z.string().optional(), + /** Installable library packages, and the executable's package, that the Ubuntu 24.04 archive does not offer and this repository's package files list; absent from captures written before it was recorded. */ + supplies: z.array(z.string()).optional(), }); export type AptRepository = z.infer; @@ -110,7 +112,12 @@ export type DesktopPackageFile = z.infer; const packageSchema = z.strictObject({ kind: z.enum(["deb", "snap", "flatpak", "appimage", "unknown"]), + /** A Debian package is the launched desktop entry's owner, or else the executable's. */ name: z.string().nullable(), + /** The executable's Debian package when another package owns the desktop entry; absent otherwise. */ + executable_package: z.string().optional(), + /** Why the computer cannot install the program the desktop entry runs: `executable_package` is not offered, or, without it, no package owns the program; absent when it can. */ + executable_package_reason: z.string().optional(), version: z.string().nullable(), architecture: z.string().nullable(), /** Package sources listing the installed version; empty for local installs. */ @@ -224,6 +231,9 @@ export const desktopApplicationCaptureSchema = z.strictObject({ architecture: z.string().min(1), display: z.string().min(1), home: z.string().min(1), + /** The host's name and the user's, which captured state can hold; absent from captures written before they were recorded, or when the system could not say. */ + hostname: z.string().min(1).optional(), + user: z.string().min(1).optional(), }), application: desktopApplicationInventorySchema, session: desktopSessionSchema, diff --git a/src/domain/desktop/desktopState.ts b/src/domain/desktop/desktopState.ts index d43a2070d4..11b7ebd97a 100644 --- a/src/domain/desktop/desktopState.ts +++ b/src/domain/desktop/desktopState.ts @@ -11,6 +11,7 @@ const format = z.enum([ "xml", "sqlite", "dconf", + "zip", "binary", ]); const index = z.int().min(0); @@ -86,6 +87,19 @@ export const desktopStatePathSchema = z.strictObject({ }); export type DesktopStatePath = z.infer; +/** The checkpoints, by position, a bundle's seed is taken at and a task's end state is read at. */ +export interface StateEnds { + readonly seed: number; + readonly end: number; +} + +/** A path's version as it stood at a checkpoint: the last one recorded at or before that position. */ +export const versionAt = ( + path: DesktopStatePath, + checkpoint: number, +): DesktopStatePath["versions"][number] | undefined => + path.versions.findLast((version) => version.checkpoint_index <= checkpoint); + /** The normalized result of `inspect_desktop_application_state`. */ export const desktopApplicationStateSchema = z.strictObject({ capture: z.strictObject({ diff --git a/src/domain/desktop/hostIdentity.test.ts b/src/domain/desktop/hostIdentity.test.ts new file mode 100644 index 0000000000..43d7ea458b --- /dev/null +++ b/src/domain/desktop/hostIdentity.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; + +import { + SEED_HOME, + identityLeftIn, + scrubHostIdentity, + type HostIdentity, +} from "./hostIdentity.js"; + +const GEANY: HostIdentity = { + home: "/home/ada", + hostname: "e81232be457d", + user: "ada", +}; + +/** A capture whose host is named localhost, so only user@host gives it away. */ +const LOCAL: HostIdentity = { ...GEANY, hostname: "localhost" }; + +describe("scrubHostIdentity", () => { + it("replaces user@host, keeping a short user name standing alone", () => { + expect( + scrubHostIdentity( + "pref_template_mail=ada@e81232be457d\npref_template_developer=ada\nby ada@example.com\n", + GEANY, + ), + ).toBe( + "pref_template_mail=user@localhost\npref_template_developer=ada\nby ada@example.com\n", + ); + }); + + it("replaces the host name only as a whole name, not inside a word or a domain", () => { + expect( + scrubHostIdentity( + "host=e81232be457d\non e81232be457d.\nhttp://e81232be457d.local/\nxe81232be457d e81232be457d-2\n", + GEANY, + ), + ).toBe( + "host=localhost\non localhost.\nhttp://e81232be457d.local/\nxe81232be457d e81232be457d-2\n", + ); + const ubuntu = { home: "/home/ubuntu", hostname: "ubuntu", user: "user" }; + expect( + scrubHostIdentity( + "deb http://ports.ubuntu.com/ubuntu-ports noble\nuser settings\n", + ubuntu, + ), + ).toBe("deb http://ports.ubuntu.com/ubuntu-ports noble\nuser settings\n"); + }); + + it("replaces the home path where a whole path segment ends after it", () => { + expect( + scrubHostIdentity( + "a=/home/ada/Documents/x\nb=file:///home/ada\nc=/home/adam/x\nd=/home/ada.bak\n", + GEANY, + ), + ).toBe( + `a=${SEED_HOME}/Documents/x\nb=file://${SEED_HOME}\nc=/home/adam/x\nd=/home/ada.bak\n`, + ); + }); + + it("replaces only the home when the capture recorded no host name or user", () => { + expect( + scrubHostIdentity("/home/ada/x ada@e81232be457d e81232be457d", { + home: "/home/ada", + hostname: null, + user: null, + }), + ).toBe(`${SEED_HOME}/x ada@e81232be457d e81232be457d`); + }); +}); + +describe("scrubHostIdentity on a host named localhost", () => { + it("replaces the captured user@localhost and leaves localhost alone", () => { + expect( + scrubHostIdentity("mail=ada@localhost\nhost=localhost\n", LOCAL), + ).toBe("mail=user@localhost\nhost=localhost\n"); + }); +}); + +describe("identityLeftIn", () => { + it("finds the host name and user@host anywhere, the home as a whole path, and not the user name alone", () => { + const texts = [ + '["mail=ada@e81232be457d"]', + "e81232be457d-notes.txt", + "http://e81232be457d.local/", + "x/home/ada/y", + "/home/adam/x", + '["author=ada"]', + ]; + + expect(texts.map((text) => identityLeftIn(text, GEANY))).toEqual([ + "ada@e81232be457d", + "e81232be457d", + "e81232be457d", + "/home/ada", + undefined, + undefined, + ]); + }); + + it("does not take the replacements for what they replaced", () => { + const host = { home: "/home", hostname: "host", user: "user" }; + + expect( + identityLeftIn(`mail=user@localhost on localhost in ${SEED_HOME}`, host), + ).toBeUndefined(); + expect(identityLeftIn("ghost /home/ada", host)).toBe("/home"); + expect(identityLeftIn("mail=ada@localhost", LOCAL)).toBe("ada@localhost"); + expect(identityLeftIn("mail=user@localhost", LOCAL)).toBeUndefined(); + }); +}); diff --git a/src/domain/desktop/hostIdentity.ts b/src/domain/desktop/hostIdentity.ts new file mode 100644 index 0000000000..c51971e26b --- /dev/null +++ b/src/domain/desktop/hostIdentity.ts @@ -0,0 +1,106 @@ +/** The capture host's identity, which captured state can hold and a shared bundle must not. */ +export interface HostIdentity { + readonly home: string; + /** Null when the capture did not record it. */ + readonly hostname: string | null; + readonly user: string | null; +} + +/** The home the seed spells, which the computer respells to its own at boot; the computer's user is `user`. */ +export const SEED_HOME = "/home/user"; +const SEED_HOST = "localhost"; +const SEED_LOGIN = `user@${SEED_HOST}`; +/** What the scrub writes, which a search for what is left must not take for what it replaced. */ +const REPLACEMENTS = [SEED_LOGIN, SEED_HOME, SEED_HOST]; + +const WORD = String.raw`\p{L}\p{N}_`; +/** A home path ends where no file name character follows, as the computer's boot respelling reads it. */ +const HOME_END = String.raw`(?![${WORD}.@+~-])`; +/** A host name ends where no name character or `.name` follows, so `host.example.com` is left whole. */ +const HOST_END = String.raw`(?![${WORD}-]|\.[${WORD}])`; + +const escaped = (text: string): string => + text.replace(/[\\^$.*+?()[\]{}|/]/gu, "\\$&"); + +/** The captured home, unless it is the root or the seed's own home. */ +const homeOf = ({ home }: HostIdentity): string | undefined => + home.length > 1 && home !== SEED_HOME ? home : undefined; + +/** The captured `user@host`, unless it is already what the scrub writes. */ +const loginOf = ({ hostname, user }: HostIdentity): string | undefined => + hostname === null || user === null || `${user}@${hostname}` === SEED_LOGIN + ? undefined + : `${user}@${hostname}`; + +/** The host name, unless it is `localhost`, which every host calls itself. */ +const hostOf = ({ hostname }: HostIdentity): string | undefined => + hostname === null || hostname === SEED_HOST ? undefined : hostname; + +/** The spellings a bundle must not hold: the captured home, `user@host` and the host name. */ +export const identitySpellings = (identity: HostIdentity): readonly string[] => + [homeOf(identity), loginOf(identity), hostOf(identity)].filter( + (spelling): spelling is string => spelling !== undefined, + ); + +/** + * The identity's spellings the scrub replaces: the home path where a whole + * path segment ends after it, as the computer respells it at boot; + * `user@host`; and the host name as a whole name. The user name alone is + * never matched, since a short one such as `ada` is also ordinary text. + */ +const identityPattern = (identity: HostIdentity): RegExp | undefined => { + const home = homeOf(identity); + const login = loginOf(identity); + const host = hostOf(identity); + const alternatives = [ + ...(home === undefined ? [] : [`(?${escaped(home)})${HOME_END}`]), + ...(login === undefined + ? [] + : [`(?${escaped(login)})${HOST_END}`]), + ...(host === undefined + ? [] + : [`(?${escaped(host)})${HOST_END}`]), + ]; + return alternatives.length === 0 + ? undefined + : new RegExp(alternatives.join("|"), "gu"); +}; + +/** The text with the identity replaced: `/home/user` for the home, `user@localhost` and `localhost` for the host. */ +export const scrubHostIdentity = ( + text: string, + identity: HostIdentity, +): string => { + const pattern = identityPattern(identity); + return pattern === undefined + ? text + : text.replace(pattern, (...match: unknown[]) => { + const groups = match.at(-1) as Record; + if (groups.home !== undefined) return SEED_HOME; + return groups.login === undefined ? SEED_HOST : SEED_LOGIN; + }); +}; + +/** + * The first of the identity's spellings a text still holds, by the rule for + * what a bundle may not ship and a check may not name: the captured home where + * a whole path segment ends after it, and `user@host` and the host name + * anywhere, as in `host.local` or `host-notes.txt`. The scrub's own + * replacements are set aside first, so a host named `host` is not found in + * `localhost`. + */ +export const identityLeftIn = ( + text: string, + identity: HostIdentity, +): string | undefined => { + const home = homeOf(identity); + return identitySpellings(identity).find((spelling) => { + const rest = REPLACEMENTS.filter((own) => own.includes(spelling)).reduce( + (remaining, own) => remaining.replaceAll(own, "\u0000"), + text, + ); + return spelling === home + ? new RegExp(`${escaped(home)}${HOME_END}`, "u").test(rest) + : rest.includes(spelling); + }); +}; diff --git a/src/domain/desktop/libraryPackages.test.ts b/src/domain/desktop/libraryPackages.test.ts index 2b410c1c19..d185899a8b 100644 --- a/src/domain/desktop/libraryPackages.test.ts +++ b/src/domain/desktop/libraryPackages.test.ts @@ -209,6 +209,7 @@ describe("installableLibraryPackages", () => { ).toEqual({ installable: ["libnss3", "typora-helper"], uninstallable: ["libfoo", "libgone"], + supplied: new Map([["typora-helper", [TYPORA]]]), }); }); }); diff --git a/src/domain/desktop/libraryPackages.ts b/src/domain/desktop/libraryPackages.ts index 548d50c9d0..17d0858dd0 100644 --- a/src/domain/desktop/libraryPackages.ts +++ b/src/domain/desktop/libraryPackages.ts @@ -182,7 +182,8 @@ export const libraryPackageNames = ( /** * Library packages the computer can install, and the rest: a package is * installable when some version of it comes from the Ubuntu 24.04 archive - * or from one of the vendor sources the export declares. + * or from one of the vendor sources the export declares. `supplied` names the + * declared sources of each installable package the archive does not offer. */ export const installableLibraryPackages = ( names: readonly string[], @@ -193,21 +194,26 @@ export const installableLibraryPackages = ( readonly origins: ReadonlyMap | undefined; readonly declared: ReadonlySet; }, -): { readonly installable: string[]; readonly uninstallable: string[] } => { +): { + readonly installable: string[]; + readonly uninstallable: string[]; + readonly supplied: ReadonlyMap; +} => { const installable: string[] = []; const uninstallable: string[] = []; + const supplied = new Map(); for (const name of names) { const sources = available.sources.get(name) ?? []; - const offered = - sources.some((source) => available.declared.has(source)) || - fromUbuntuNobleArchive( - sources, - available.origins === undefined - ? undefined - : sources.map((source) => available.origins?.get(source) ?? null), - ); - if (offered) installable.push(name); + const declared = sources.filter((source) => available.declared.has(source)); + const archive = fromUbuntuNobleArchive( + sources, + available.origins === undefined + ? undefined + : sources.map((source) => available.origins?.get(source) ?? null), + ); + if (archive || declared.length > 0) installable.push(name); else uninstallable.push(name); + if (!archive && declared.length > 0) supplied.set(name, declared); } - return { installable, uninstallable }; + return { installable, uninstallable, supplied }; }; diff --git a/src/domain/desktop/seedScrub.test.ts b/src/domain/desktop/seedScrub.test.ts new file mode 100644 index 0000000000..a283e715ba --- /dev/null +++ b/src/domain/desktop/seedScrub.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, it } from "vitest"; + +import { SEED_HOME, type HostIdentity } from "./hostIdentity.js"; +import { scrubDconf, scrubSeed } from "./seedScrub.js"; +import { detectStateFormat } from "./stateFormats.js"; + +const GEANY: HostIdentity = { + home: "/home/ada", + hostname: "e81232be457d", + user: "ada", +}; + +/** Text as UTF-16 after a byte-order mark, in either byte order. */ +const utf16 = (text: string, order: "le" | "be") => { + const bytes = Buffer.from(`\uFEFF${text}`, "utf16le"); + return order === "be" ? bytes.swap16() : bytes; +}; + +/** A seed at a home path that says nothing of its format. */ +const seed = (format: string) => ({ home: "seed", format }); + +const scrubbed = (text: string, format: string, identity = GEANY) => { + const outcome = scrubSeed(Buffer.from(text), seed(format), identity); + return { + text: outcome.bytes?.toString("utf8"), + holdsIdentity: outcome.holdsIdentity, + }; +}; + +describe("scrubSeed on text", () => { + it("rewrites a UTF-8 text seed, keeping its byte-order mark, and leaves a clean one as it is", () => { + expect(scrubbed("\uFEFFmail=ada@e81232be457d\n", "keyfile")).toEqual({ + text: "\uFEFFmail=user@localhost\n", + holdsIdentity: false, + }); + expect(scrubbed("wrap=true\n", "text")).toEqual({ + text: undefined, + holdsIdentity: false, + }); + }); + + it.each(["le", "be"] as const)( + "rewrites a UTF-16 %s seed with a byte-order mark in its own order", + (order) => { + const outcome = scrubSeed( + utf16("20260216;/home/ada/.varicad;ada@e81232be457d\n", order), + seed("binary"), + GEANY, + ); + + expect(outcome.bytes?.toString("hex")).toBe( + utf16( + `20260216;${SEED_HOME}/.varicad;user@localhost\n`, + order, + ).toString("hex"), + ); + }, + ); + + it("replaces the captured user@localhost of a host named localhost and finds it in bytes it cannot rewrite", () => { + const local = { ...GEANY, hostname: "localhost" }; + + expect(scrubbed("mail=ada@localhost\n", "keyfile", local)).toEqual({ + text: "mail=user@localhost\n", + holdsIdentity: false, + }); + expect(scrubbed("SQLite format 3\0ada@localhost", "sqlite", local)).toEqual( + { text: undefined, holdsIdentity: true }, + ); + }); +}); + +describe("scrubSeed on JSON and XML", () => { + it("rewrites JSON strings only, so a host named like a literal leaves the syntax valid", () => { + const named = { ...GEANY, hostname: "true" }; + const outcome = scrubbed( + '{"enabled": true, "host": "true", "list": [true, "a true"]}', + "json", + named, + ); + + expect(outcome.text).toBe( + '{"enabled": true, "host": "localhost", "list": [true, "a localhost"]}', + ); + expect(JSON.parse(outcome.text ?? "")).toEqual({ + enabled: true, + host: "localhost", + list: [true, "a localhost"], + }); + }); + + it("keeps member names, reporting one that holds the host rather than rewriting it into another", () => { + const named = { ...GEANY, hostname: "ubuntu" }; + const text = String.raw`{"ubuntu": 1, "localhost": 2, "\u0075buntu-dock" + : "ubuntu", "list": ["ubuntu"]}`; + + const outcome = scrubbed(text, "json", named); + + expect(outcome).toEqual({ + text: String.raw`{"ubuntu": 1, "localhost": 2, "\u0075buntu-dock" + : "localhost", "list": ["localhost"]}`, + holdsIdentity: true, + }); + expect(Object.keys(JSON.parse(outcome.text ?? "") as object)).toEqual([ + "ubuntu", + "localhost", + "ubuntu-dock", + "list", + ]); + }); + + it("walks a JSON string of 16,000,000 characters, which the capture can retain, without running out of stack", () => { + const note = "x".repeat(16_000_000); + const text = `{"note": "${note}", "mail": "ada@e81232be457d"}`; + + const outcome = scrubSeed(Buffer.from(text), seed("json"), GEANY); + + expect(outcome.holdsIdentity).toBe(false); + expect(outcome.bytes?.length).toBe(text.length - 2); + expect(outcome.bytes?.subarray(-29).toString("utf8")).toBe( + `x", "mail": "user@localhost"}`, + ); + }); + + it("decodes JSON escapes, rewriting an escaped identity and finding one left inside a domain", () => { + expect( + scrubbed( + String.raw`{"mail": "ada@\u006581232be457d", "kept": "\u00e9"}`, + "json", + ), + ).toEqual({ + text: String.raw`{"mail": "user@localhost", "kept": "\u00e9"}`, + holdsIdentity: false, + }); + expect( + scrubbed(String.raw`{"url": "http://\u006581232be457d.local/"}`, "json"), + ).toEqual({ text: undefined, holdsIdentity: true }); + }); + + it("rewrites XML text, attribute values, comments and CDATA but not names, decoding references", () => { + const xml = `\nada@e81232be457d`; + const named = { ...GEANY, hostname: "item" }; + + const outcome = scrubbed(xml, "xml"); + const items = scrubbed('item', "xml", named); + + expect(outcome).toEqual({ + text: `\nuser@localhost`, + holdsIdentity: false, + }); + expect(detectStateFormat("m.xml", Buffer.from(outcome.text ?? ""))).toBe( + "xml", + ); + expect(items.text).toBe('localhost'); + }); + + it.each(["le", "be"] as const)( + "scrubs UTF-16 %s JSON and XML by value, as their UTF-8 forms are, in their own byte order", + (order) => { + const named = { ...GEANY, hostname: "ubuntu" }; + const json = '{"ubuntu": 1, "localhost": 2, "dock": "ubuntu"}'; + const xml = `\nubuntu`; + + const outcomes = [ + { text: json, home: ".config/dock.json" }, + { text: xml, home: ".config/dock.xml" }, + ].map(({ text, home }) => + scrubSeed(utf16(text, order), { home, format: "binary" }, named), + ); + + expect( + outcomes.map(({ bytes, holdsIdentity }) => [ + bytes?.toString("hex"), + holdsIdentity, + ]), + ).toEqual([ + [ + utf16( + '{"ubuntu": 1, "localhost": 2, "dock": "localhost"}', + order, + ).toString("hex"), + true, + ], + [ + utf16( + `\nlocalhost`, + order, + ).toString("hex"), + true, + ], + ]); + }, + ); + + it("finds an identity XML references hide inside a domain, which it does not rewrite", () => { + expect(scrubbed("http://e81232be457d.local/", "xml")).toEqual({ + text: undefined, + holdsIdentity: true, + }); + }); + + it("scrubs around a reference past the last code point instead of throwing", () => { + const outcome = scrubbed( + '� ada@e81232be457d', + "xml", + ); + expect(outcome.holdsIdentity).toBe(false); + expect(outcome.text).toContain("user@localhost"); + expect(outcome.text).not.toContain("e81232be457d"); + }); +}); + +describe("scrubSeed on bytes it does not rewrite", () => { + it("reports a seed whose shipped bytes still hold the host name or the captured home", () => { + const database = Buffer.concat([ + Buffer.from("SQLite format 3\0"), + Buffer.from([0, 1, 2]), + Buffer.from("e81232be457d"), + ]); + const outcomes = [ + scrubSeed(database, seed("sqlite"), GEANY), + scrubSeed( + Buffer.from("url=http://e81232be457d.local/\n"), + seed("text"), + GEANY, + ), + scrubSeed(Buffer.from("SQLite format 3\0ada"), seed("sqlite"), GEANY), + scrubSeed(Buffer.from([0xff, 0xfe, 0x41]), seed("binary"), GEANY), + ]; + + expect( + outcomes.map(({ bytes, holdsIdentity }) => [bytes, holdsIdentity]), + ).toEqual([ + [undefined, true], + [undefined, true], + [undefined, false], + [undefined, false], + ]); + }); + + it("leaves the seed's own home alone, as a capture inside the computer spells it", () => { + const inside = { home: SEED_HOME, hostname: "4f0c1d2e3a4b", user: "user" }; + + expect( + scrubSeed( + Buffer.from(`SQLite format 3\0${SEED_HOME}/x`), + seed("sqlite"), + inside, + ), + ).toEqual({ bytes: undefined, holdsIdentity: false }); + expect( + scrubbed(`${SEED_HOME}/x user@4f0c1d2e3a4b`, "text", inside), + ).toEqual({ text: `${SEED_HOME}/x user@localhost`, holdsIdentity: false }); + }); +}); + +describe("scrubDconf", () => { + const named = { ...GEANY, hostname: "true" }; + + it("rewrites GVariant strings by their decoded values, keeping groups, keys, dictionary keys, literals and bytestrings", () => { + const outcome = scrubDconf( + { + "org/true": { + true: "true", + mail: String.raw`'ada@\u0074rue'`, + quoted: `"it's true"`, + list: "['true', \"a 'true'\", 'x']", + hosts: "{'true': <'true'>}", + blob: "b'true'", + count: "uint32 7", + }, + }, + named, + ); + + expect(outcome.text).toBe( + [ + "[org/true]", + "true=true", + "mail='user@localhost'", + `quoted="it's localhost"`, + "list=['localhost', \"a 'localhost'\", 'x']", + "hosts={'true': <'localhost'>}", + "blob=b'true'", + "count=uint32 7", + "", + ].join("\n"), + ); + expect(outcome.holdsIdentity).toBe(true); + }); + + it("escapes what a rewritten string holds that would end it or its line", () => { + const outcome = scrubDconf( + { "org/notes": { note: String.raw`'true\nit\'s \\ true'` } }, + named, + ); + + expect(outcome.text).toBe( + String.raw`[org/notes] +note='localhost\u000ait\'s \\ localhost' +`, + ); + }); + + it.each([ + ["an unterminated string", "'ada@e81232be457d"], + ["an escape naming no character", String.raw`'ada@e81232be457d\u0000'`], + ["an object path", "[objectpath '/e81232be457d', '/x']"], + ["a signature", "@mg 'e81232be457d'"], + ])( + "keeps a value holding %s as captured and finds the host in it", + (_name, value) => { + expect(scrubDconf({ "org/notes": { key: value } }, GEANY)).toEqual({ + text: `[org/notes]\nkey=${value}\n`, + holdsIdentity: true, + }); + }, + ); + + it("finds a host its escapes spell where the scrub does not replace it", () => { + expect( + scrubDconf( + { "org/notes": { url: String.raw`'http://\u006581232be457d.local/'` } }, + GEANY, + ), + ).toEqual({ + text: String.raw`[org/notes] +url='http://\u006581232be457d.local/' +`, + holdsIdentity: true, + }); + }); +}); diff --git a/src/domain/desktop/seedScrub.ts b/src/domain/desktop/seedScrub.ts new file mode 100644 index 0000000000..1860ceb4e9 --- /dev/null +++ b/src/domain/desktop/seedScrub.ts @@ -0,0 +1,418 @@ +import type { JsonValue } from "../jsonValue.js"; +import { + identityLeftIn, + identitySpellings, + scrubHostIdentity, + type HostIdentity, +} from "./hostIdentity.js"; +import { isTextualFormat, textSyntaxFormat } from "./stateFormats.js"; + +/** What the export ships for one seed. */ +export interface SeedScrub { + /** The rewritten bytes, or undefined when the seed ships as captured. */ + readonly bytes: Buffer | undefined; + /** Whether what it ships still holds the captured home, `user@host` or the host name, its escapes decoded. */ + readonly holdsIdentity: boolean; +} + +/** Seeds up to this size are decoded, well inside a JavaScript string; a larger one is only searched byte for byte. */ +const MAX_SCRUBBED_BYTES = 256 * 1024 * 1024; + +/** A text with the identity scrubbed, and the text a search for what is left reads, its escapes decoded. */ +interface TextScrub { + readonly text: string; + readonly decoded: string; +} + +/** Whitespace JSON allows between tokens. */ +const JSON_SPACE = new Set([" ", "\t", "\n", "\r"]); + +/** Where the string literal opening at `start` ends, just past its closing quote: the first of its quotes no odd run of backslashes escapes, or -1. */ +const stringEnd = (text: string, start: number): number => { + const mark = text[start] ?? ""; + for (let quote = text.indexOf(mark, start + 1); quote !== -1;) { + let slashes = 0; + while (text[quote - 1 - slashes] === "\\") slashes += 1; + if (slashes % 2 === 0) return quote + 1; + quote = text.indexOf(mark, quote + 1); + } + return -1; +}; + +/** Whether the token after a string literal is a colon, which makes it a member name or dictionary key. */ +const isMemberName = (text: string, end: number): boolean => { + let next = end; + while (JSON_SPACE.has(text[next] ?? "")) next += 1; + return text[next] === ":"; +}; + +/** + * JSON with each string value scrubbed as its decoded value and rewritten + * only when that changed, so names, literals, numbers and other strings keep + * their spelling. A name is never rewritten, since two could then collide; + * one holding the identity is left for the search for what is left. The + * document, already read as JSON, is walked one string at a time, so a long + * string costs no more stack than a short one. + */ +const scrubJson = (text: string, identity: HostIdentity): TextScrub => { + const parts: string[] = []; + const strings: string[] = []; + let copied = 0; + for (let start = text.indexOf('"'); start !== -1;) { + const end = stringEnd(text, start); + if (end === -1) break; + const value = JSON.parse(text.slice(start, end)) as string; + const clean = isMemberName(text, end) + ? value + : scrubHostIdentity(value, identity); + strings.push(clean); + if (clean !== value) { + parts.push(text.slice(copied, start), JSON.stringify(clean)); + copied = end; + } + start = text.indexOf('"', end); + } + parts.push(text.slice(copied)); + const scrubbed = parts.join(""); + return { text: scrubbed, decoded: [scrubbed, ...strings].join("\n") }; +}; + +/** Comments, CDATA sections, processing instructions and tags of a document already read as XML; the rest is character data. */ +const XML_MARKUP = + /||<\?[\s\S]*?\?>|<[^>"']*(?:(?:"[^"]*"|'[^']*')[^>"']*)*>/gu; +const XML_VALUE = /"([^"]*)"|'([^']*)'/gu; +const XML_REFERENCE = + /&(?:#x([0-9a-fA-F]+)|#([0-9]+)|(lt|gt|amp|quot|apos));/gu; +const XML_ENTITIES: Readonly> = { + lt: "<", + gt: ">", + amp: "&", + quot: '"', + apos: "'", +}; + +/** A character reference's character, or the reference as written when it names no Unicode code point. */ +const referenced = (whole: string, point: number): string => + point <= 0x10ffff ? String.fromCodePoint(point) : whole; + +const decodeXml = (text: string): string => + text.replace( + XML_REFERENCE, + (whole, hex?: string, decimal?: string, name?: string) => + hex !== undefined + ? referenced(whole, Number.parseInt(hex, 16)) + : decimal !== undefined + ? referenced(whole, Number(decimal)) + : (XML_ENTITIES[name ?? ""] ?? whole), + ); + +/** Character data, or an attribute value in the given quote, escaped so XML reads it back as written. */ +const encodeXml = (text: string, quote: string | undefined): string => + text.replace(/[&<>\r\t\n"']/gu, (character) => { + if (character === "&") return "&"; + if (character === "<") return "<"; + if (character === ">") return ">"; + if (character === "\r") return " "; + if (quote === undefined || (character !== quote && character.trim() !== "")) + return character; + return `&#${String(character.codePointAt(0))};`; + }); + +/** + * XML with character data, attribute values, comments and CDATA scrubbed, + * character data and attribute values as their decoded text and rewritten + * only when that changed; names and processing instructions are kept. Only + * changed spans are copied out, so however many elements the document has, + * the scrub holds no more than its changes. + */ +const scrubXml = (text: string, identity: HostIdentity): TextScrub => { + const data = (raw: string, quote?: string) => { + const value = decodeXml(raw); + const clean = scrubHostIdentity(value, identity); + return clean === value ? raw : encodeXml(clean, quote); + }; + const parts: string[] = []; + let copied = 0; + /** Replace the span at `start` written `raw` with `clean` when they differ. */ + const replace = (start: number, raw: string, clean: string) => { + if (clean === raw) return; + parts.push(text.slice(copied, start), clean); + copied = start + raw.length; + }; + let end = 0; + for (const match of text.matchAll(XML_MARKUP)) { + const [markup, comment, cdata] = match; + const characters = text.slice(end, match.index); + replace(end, characters, data(characters)); + end = match.index + markup.length; + if (comment !== undefined) + replace( + match.index, + markup, + ``, + ); + else if (cdata !== undefined) + replace( + match.index, + markup, + ``, + ); + else if (!markup.startsWith(" + double === undefined + ? `'${data(single ?? "", "'")}'` + : `"${data(double, '"')}"`, + ), + ); + } + const rest = text.slice(end); + replace(end, rest, data(rest)); + parts.push(text.slice(copied)); + const scrubbed = parts.join(""); + return { text: scrubbed, decoded: decodeXml(scrubbed) }; +}; + +/** GVariant escapes that stand for one character, as `g_variant_parse` reads them; a backslash before a line break joins lines. */ +const GVARIANT_ESCAPES: Readonly> = { + a: "\u0007", + b: "\b", + f: "\f", + n: "\n", + r: "\r", + t: "\t", + v: "\v", + "\n": "", +}; +const GVARIANT_ESCAPE = /\\(?:u([0-9a-fA-F]{4})|U([0-9a-fA-F]{8})|([\s\S]))/gu; +/** What, outside quotes, types a GVariant literal as an object path or signature rather than a string. */ +const GVARIANT_PATH_OR_SIGNATURE = + /\b(?:objectpath|signature)\b|@[a-z(){}]*[og]/u; + +/** Whether a code point is one a GVariant string can hold: not NUL, a surrogate or past Unicode. */ +const isCharacter = (point: number): boolean => + point > 0 && point <= 0x10ffff && (point < 0xd800 || point >= 0xe000); + +/** A GVariant string literal's body decoded, or undefined where `g_variant_parse` refuses it. */ +const decodeGVariant = (body: string): string | undefined => { + let refused = body.includes("\0"); + const value = body.replace( + GVARIANT_ESCAPE, + (_escape, short?: string, long?: string, other?: string) => { + if (other !== undefined) { + refused ||= other === "u" || other === "U"; + return GVARIANT_ESCAPES[other] ?? other; + } + const point = Number.parseInt(short ?? long ?? "0", 16); + if (isCharacter(point)) return String.fromCodePoint(point); + refused = true; + return ""; + }, + ); + return refused ? undefined : value; +}; + +/** A GVariant string literal in the given quote, escaping what would end it or break its key file line. */ +const encodeGVariant = (value: string, quote: string): string => + `${quote}${value.replace(/[\\'"\p{Cc}]/gu, (character) => { + if (character === "\\" || character === quote) return `\\${character}`; + if (character === "'" || character === '"') return character; + return `\\u${(character.codePointAt(0) ?? 0).toString(16).padStart(4, "0")}`; + })}${quote}`; + +/** Whether the literal opening at `start` is a bytestring, its `b` starting a token. */ +const isBytestring = (text: string, start: number): boolean => + text[start - 1] === "b" && !/[A-Za-z0-9]/u.test(text[start - 2] ?? ""); + +/** + * A dconf value, GVariant text as `dconf dump` prints it, with each string + * scrubbed as its decoded value and rewritten only when that changed, as JSON + * strings are: dictionary keys, bytestrings and everything outside quotes + * keep their spelling. Undefined, so the value ships as captured, when a + * literal does not parse or the value holds an object path or signature, + * whose quoted literals are not strings. + */ +const scrubGVariant = ( + text: string, + identity: HostIdentity, +): TextScrub | undefined => { + const parts: string[] = []; + const strings: string[] = []; + const quotes = /['"]/gu; + let outside = ""; + let last = 0; + let copied = 0; + for ( + let match = quotes.exec(text); + match !== null; + match = quotes.exec(text) + ) { + const start = match.index; + const end = stringEnd(text, start); + if (end === -1) return undefined; + outside += ` ${text.slice(last, start)}`; + last = end; + quotes.lastIndex = end; + if (isBytestring(text, start)) continue; + const value = decodeGVariant(text.slice(start + 1, end - 1)); + if (value === undefined) return undefined; + const clean = isMemberName(text, end) + ? value + : scrubHostIdentity(value, identity); + strings.push(clean); + if (clean !== value) { + parts.push(text.slice(copied, start), encodeGVariant(clean, match[0])); + copied = end; + } + } + if (GVARIANT_PATH_OR_SIGNATURE.test(`${outside} ${text.slice(last)}`)) + return undefined; + parts.push(text.slice(copied)); + const scrubbed = parts.join(""); + return { text: scrubbed, decoded: [scrubbed, ...strings].join("\n") }; +}; + +const isRecord = ( + value: JsonValue | undefined, +): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * A dconf view's groups as the key file the computer compiles, each value's + * strings scrubbed, and whether it still holds the identity, strings decoded. + * Group names and keys are kept, so no two can collide; a value that does + * not parse ships as captured. + */ +export const scrubDconf = ( + groups: Readonly>, + identity: HostIdentity, +): { readonly text: string; readonly holdsIdentity: boolean } => { + const lines: string[] = []; + const decoded: string[] = []; + for (const [group, keys] of Object.entries(groups)) { + if (!isRecord(keys)) continue; + lines.push(`[${group}]`); + for (const [key, value] of Object.entries(keys)) { + if (typeof value !== "string") continue; + const scrub = scrubGVariant(value, identity); + lines.push(`${key}=${scrub?.text ?? value}`); + if (scrub !== undefined) decoded.push(scrub.decoded); + } + lines.push(""); + } + const text = lines.join("\n"); + return { + text, + holdsIdentity: + identityLeftIn([text, ...decoded].join("\n"), identity) !== undefined, + }; +}; + +/** Text and key files, which hold the identity as written. */ +const plainScrub = (text: string, identity: HostIdentity): TextScrub => { + const scrubbed = scrubHostIdentity(text, identity); + return { text: scrubbed, decoded: scrubbed }; +}; + +/** UTF-16 text after a byte-order mark, decoded and encoded as the computer's boot respelling does; undefined otherwise. */ +const utf16Text = (bytes: Buffer) => { + const order = [ + { mark: [0xff, 0xfe], swap: false }, + { mark: [0xfe, 0xff], swap: true }, + ].find(({ mark }) => mark.every((byte, index) => bytes[index] === byte)); + if (order === undefined) return undefined; + const little = Buffer.from(bytes.subarray(2)); + if (order.swap && little.length % 2 === 0) little.swap16(); + try { + return { + text: new TextDecoder("utf-16le", { fatal: true }).decode(little), + encode: (scrubbed: string) => { + const encoded = Buffer.from(scrubbed, "utf16le"); + if (order.swap) encoded.swap16(); + return Buffer.concat([Buffer.from(order.mark), encoded]); + }, + }; + } catch { + return undefined; + } +}; + +/** A seed by its home path and the format the state view read it as. */ +interface Seed { + readonly home: string; + readonly format: string | null; +} + +/** + * The seed's text, the format it is scrubbed as, and how to encode it again: + * UTF-8 for a text format, or UTF-16 after a byte-order mark, JSON or XML as + * its text would be in UTF-8, told by syntax alone so no view is built. + */ +const seedText = (bytes: Buffer, { home, format }: Seed) => { + if (bytes.length > MAX_SCRUBBED_BYTES) return undefined; + if (!isTextualFormat(format)) { + const utf16 = utf16Text(bytes); + return utf16 === undefined + ? undefined + : { ...utf16, format: textSyntaxFormat(home, utf16.text) }; + } + try { + return { + // The byte-order mark is kept, as Python's utf-8 codec keeps it. + text: new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + bytes, + ), + encode: (scrubbed: string) => Buffer.from(scrubbed, "utf8"), + format, + }; + } catch { + return undefined; + } +}; + +/** Whether bytes it does not rewrite hold the identity: read as Latin-1, so each byte is one character, or searched byte for byte past the size it decodes. */ +const holdsIdentity = (bytes: Buffer, identity: HostIdentity): boolean => { + if (bytes.length > MAX_SCRUBBED_BYTES) + return identitySpellings(identity).some((spelling) => + bytes.includes(spelling), + ); + const latin1 = (value: string | null) => + value === null ? null : Buffer.from(value).toString("latin1"); + return ( + identityLeftIn(bytes.toString("latin1"), { + home: latin1(identity.home) ?? "", + hostname: latin1(identity.hostname), + user: latin1(identity.user), + }) !== undefined + ); +}; + +/** + * Rewrite a seed as the computer reads it at boot: a text format as UTF-8, + * and another file as UTF-16 when it starts with a byte-order mark, keeping + * the mark and byte order. JSON is scrubbed by its strings and XML by its + * character data and attribute values, in either encoding, so neither's + * syntax changes. Other bytes ship as captured. Either way what ships is + * searched, its escapes decoded, for what is left. + */ +export const scrubSeed = ( + bytes: Buffer, + seed: Seed, + identity: HostIdentity, +): SeedScrub => { + const decoded = seedText(bytes, seed); + if (decoded === undefined) + return { bytes: undefined, holdsIdentity: holdsIdentity(bytes, identity) }; + const scrub = + decoded.format === "json" + ? scrubJson(decoded.text, identity) + : decoded.format === "xml" + ? scrubXml(decoded.text, identity) + : plainScrub(decoded.text, identity); + return { + bytes: scrub.text === decoded.text ? undefined : decoded.encode(scrub.text), + holdsIdentity: identityLeftIn(scrub.decoded, identity) !== undefined, + }; +}; diff --git a/src/domain/desktop/stateChanges.test.ts b/src/domain/desktop/stateChanges.test.ts index f2d4e76d15..8dbc97343c 100644 --- a/src/domain/desktop/stateChanges.test.ts +++ b/src/domain/desktop/stateChanges.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { JsonValue } from "../jsonValue.js"; import { diffStateViews } from "./stateChanges.js"; +import { resolvePointer } from "./viewPointer.js"; describe("diffStateViews", () => { it("reports added and removed lines as a multiset", () => { @@ -193,3 +194,430 @@ describe("diffStateViews fidelity", () => { ).toBe(true); }); }); + +/** An archive holding one JSON entry, `a.json`, whose view is `{items}`. */ +const jsonArchive = (items: JsonValue): JsonValue => ({ + entries: [ + { + name: "a.json", + size: 1, + sha256: "a", + format: "json", + view: { items }, + view_error: null, + }, + ], + entry_count: 1, + truncated: false, +}); + +/** + * Diff an object of selector keys, each naming one of `size` members named + * n0, n1 and on, against them, and expect a few reads of each member, not a + * pass over the array per key. + */ +const expectFewReads = (keyed: Record, size: number) => { + let reads = 0; + const listed = new Proxy( + Array.from({ length: size }, (_, index) => ({ + name: `n${String(index)}`, + })), + { + get: (target, key, receiver) => { + if (typeof key === "string" && /^[0-9]+$/u.test(key)) reads += 1; + return Reflect.get(target, key, receiver) as unknown; + }, + }, + ); + + const difference = diffStateViews( + "json", + { items: keyed }, + { items: listed as JsonValue }, + ); + + expect(difference).toMatchObject({ removed: [], removed_count: 0 }); + expect(reads).toBeLessThan(10 * size); +}; + +describe("diffStateViews selector lookups", () => { + it("reads every spelling of a selector as the evaluator does", () => { + const listed = [ + { name: "n0", kind: "a" }, + { name: "n1", kind: "a" }, + ]; + const keyed = { + "[kind=a][name=n0]": 1, + "[name=n1][kind=a][name=n1]": 2, + "[name=n0][name=n1]": 3, + "[kind=a]": 4, + }; + + const difference = diffStateViews( + "json", + { items: keyed }, + { items: listed }, + ); + + expect(difference.removed).toEqual([ + "/items/[name=n0][name=n1]", + "/items/[kind=a]", + ]); + for (const key of Object.keys(keyed)) + expect( + resolvePointer({ items: listed }, `/items/${key}`) === undefined, + ).toBe(difference.removed.includes(`/items/${key}`)); + }); + + it("resolves selectors that repeat a condition as one, in one pass over the array", () => { + // An index per spelling of the same selector read the 800 members once for each of the 160. + expectFewReads( + Object.fromEntries( + Array.from({ length: 160 }, (_, index) => [ + "[name=n0]".repeat(index + 1), + index, + ]), + ), + 800, + ); + }); + + it("compares a wide object of selector keys with an array in one pass over the array, not one per key", () => { + const size = 2000; + + // A scan of the array per key read its members about size * size times. + expectFewReads( + Object.fromEntries( + Array.from({ length: size }, (_, index) => [ + `[name=n${String(index)}]`, + index, + ]), + ), + size, + ); + }); +}); + +describe("diffStateViews zip member pairing", () => { + it("compares counterparts by the evaluator's pointer rules, a bracketed key against an array as a selector", () => { + const keyed = { "[name=x]": 1 }; + const listed = [{ name: "x" }]; + const items = "/entries/[name=a.json]/view/items"; + + expect( + diffStateViews("zip", jsonArchive(keyed), jsonArchive(listed)), + ).toMatchObject({ + added: [`${items}/[name=x]/name`], + removed: [], + changed: [items, `${items}/[name=x]`], + }); + expect( + diffStateViews("zip", jsonArchive(listed), jsonArchive(keyed)), + ).toMatchObject({ + added: [], + removed: [`${items}/[name=x]/name`], + changed: [items, `${items}/[name=x]`], + }); + expect( + diffStateViews("json", { items: keyed }, { items: listed }), + ).toMatchObject({ + added: ["/items/0", "/items/0/name"], + removed: [], + changed: ["/items"], + }); + }); + + it("compares an array's members with what their pointers name once it becomes an object", () => { + const unchanged = { + added: [], + removed: [], + changed: ["/entries/[name=a.json]/view/items"], + complete: true, + }; + + expect( + diffStateViews("zip", jsonArchive([1]), jsonArchive({ "0": 1 })), + ).toMatchObject(unchanged); + expect( + diffStateViews("zip", jsonArchive({ "0": 1 }), jsonArchive([1])), + ).toMatchObject(unchanged); + }); + + it("pairs each element with at most one in the other version, so one split in two is an addition", () => { + const item = (attributes: Record) => ({ + name: "item", + attributes, + children: [], + }); + const archive = (items: JsonValue[]): JsonValue => ({ + entries: [ + { + name: "list.xml", + size: 1, + sha256: String(items.length), + format: "xml", + view: { + root: { name: "list", attributes: {}, children: items }, + truncated: false, + }, + view_error: null, + }, + ], + entry_count: 1, + truncated: false, + }); + + const difference = diffStateViews( + "zip", + archive([item({ a: "x", b: "y" })]), + archive([item({ b: "y" }), item({ a: "x" })]), + ); + + const items = "/entries/[name=list.xml]/view/root/children"; + expect(difference).toMatchObject({ + added: [ + `${items}/1`, + `${items}/1/name`, + `${items}/1/attributes`, + `${items}/1/attributes/a`, + `${items}/1/children`, + ], + removed: [`${items}/[name=item][@b=y]/attributes/a`], + changed: ["/entries/[name=list.xml]/sha256"], + complete: true, + }); + }); +}); + +describe("diffStateViews zip member pairing by identity", () => { + it("pairs an element by an attribute both versions keep, so another attribute's new value is a change", () => { + const item = (attributes: Record) => ({ + name: "item", + attributes, + children: [], + }); + const archive = (color: string): JsonValue => ({ + entries: [ + { + name: "list.xml", + size: 1, + sha256: color, + format: "xml", + view: { + root: { + name: "list", + attributes: {}, + children: [item({ color, id: "1" }), item({ id: "2" })], + }, + truncated: false, + }, + view_error: null, + }, + ], + entry_count: 1, + truncated: false, + }); + + expect( + diffStateViews("zip", archive("red"), archive("green")), + ).toMatchObject({ + added: [], + removed: [], + changed: [ + "/entries/[name=list.xml]/sha256", + "/entries/[name=list.xml]/view/root/children/[name=item][@id=1]/attributes/color", + ], + complete: true, + }); + }); + + it("counts every attribute of an identified element, so a selector it shares names nothing removed", () => { + const archive = (first: string, second: string): JsonValue => ({ + entries: [ + { + name: "list.xml", + size: 1, + sha256: first, + format: "xml", + view: { + root: { + name: "list", + attributes: {}, + children: [ + { name: "item", attributes: { id: "1", color: first } }, + { name: "item", attributes: { color: second } }, + ], + }, + truncated: false, + }, + view_error: null, + }, + ], + entry_count: 1, + truncated: false, + }); + + expect( + diffStateViews("zip", archive("red", "blue"), archive("blue", "red")), + ).toMatchObject({ + added: [], + removed: [], + changed: [ + "/entries/[name=list.xml]/sha256", + "/entries/[name=list.xml]/view/root/children/[name=item][@id=1]/attributes/color", + "/entries/[name=list.xml]/view/root/children/1/attributes/color", + ], + complete: true, + }); + }); + + it("keys an Inkscape group by its label when an identified group shares its fill", () => { + const archive = (transform: string): JsonValue => ({ + entries: [ + { + name: "poster.svg", + size: 1, + sha256: transform, + format: "xml", + view: { + root: { + name: "svg", + attributes: {}, + children: [ + { + name: "g", + attributes: { + fill: "red", + "inkscape:label": "note", + transform, + }, + }, + { name: "g", attributes: { fill: "red", id: "layer" } }, + ], + }, + truncated: false, + }, + view_error: null, + }, + ], + entry_count: 1, + truncated: false, + }); + + expect( + diffStateViews("zip", archive("scale(1)"), archive("scale(2)")), + ).toMatchObject({ + added: [], + removed: [], + changed: [ + "/entries/[name=poster.svg]/sha256", + "/entries/[name=poster.svg]/view/root/children/[name=g][@inkscape:label=note]/attributes/transform", + ], + complete: true, + }); + }); +}); + +describe("diffStateViews zip archives", () => { + it("matches zip entries and XML elements by name, so an inserted one shifts nothing", () => { + const element = (name: string, attributes: Record) => ({ + name, + attributes, + children: [], + }); + const archive = (objects: JsonValue[], radius: string): JsonValue => ({ + entries: [ + { + name: "thumb.png", + size: 3, + sha256: "a", + format: "binary", + view: null, + }, + { + name: "Document.xml", + size: 9, + sha256: radius, + format: "xml", + view: { + root: { name: "Document", attributes: {}, children: objects }, + truncated: false, + }, + view_error: null, + }, + ], + entry_count: 2, + truncated: false, + }); + const plate = element("Object", { name: "Plate", type: "Part::Box" }); + const hole = element("Object", { name: "Hole", type: "Part::Cylinder" }); + + const difference = diffStateViews( + "zip", + archive([plate], "1"), + archive( + [hole, { ...plate, attributes: { name: "Plate", type: "Part::Cut" } }], + "2", + ), + ); + + const objects = "/entries/[name=Document.xml]/view/root/children"; + expect(difference).toMatchObject({ + kind: "values", + added: [ + `${objects}/[name=Object][@name=Hole]`, + `${objects}/[name=Object][@name=Hole]/name`, + `${objects}/[name=Object][@name=Hole]/attributes`, + `${objects}/[name=Object][@name=Hole]/attributes/name`, + `${objects}/[name=Object][@name=Hole]/attributes/type`, + `${objects}/[name=Object][@name=Hole]/children`, + ], + removed: [], + changed: [ + "/entries/[name=Document.xml]/sha256", + `${objects}/[name=Object][@name=Plate]/attributes/type`, + ], + complete: true, + }); + }); + + it("marks a zip comparison incomplete when an entry was left unread or its view cut", () => { + const listing = (entry: JsonValue): JsonValue => ({ + entries: [entry], + entry_count: 1, + truncated: false, + }); + const read = { + name: "a.txt", + size: 1, + format: "text", + view: { lines: [] }, + view_error: null, + }; + + expect( + diffStateViews( + "zip", + listing(read), + listing({ ...read, view_error: "encrypted, so it is not read" }), + ).complete, + ).toBe(false); + expect( + diffStateViews( + "zip", + listing(read), + listing({ ...read, view: { lines: [], truncated: true } }), + ).complete, + ).toBe(false); + expect( + diffStateViews("zip", listing(read), { + entries: [], + entry_count: 1001, + truncated: true, + }).complete, + ).toBe(false); + expect(diffStateViews("zip", listing(read), listing(read)).complete).toBe( + true, + ); + }); +}); diff --git a/src/domain/desktop/stateChanges.ts b/src/domain/desktop/stateChanges.ts index 7912d37295..c7e78a7e27 100644 --- a/src/domain/desktop/stateChanges.ts +++ b/src/domain/desktop/stateChanges.ts @@ -1,5 +1,7 @@ import type { JsonValue } from "../jsonValue.js"; import type { DesktopStateFormat } from "./stateFormats.js"; +import { memberLookup, pairMembers } from "./viewPointer.js"; +import { zipLeavesOut } from "./zipView.js"; const MAX_LISTED = 100; @@ -57,54 +59,98 @@ const ownEntry = (value: JsonValue): string => { return isRecord(value) ? "{}" : JSON.stringify(value); }; -/** The member a pointer segment names, the way JSON pointers resolve it. */ -const memberOf = (value: JsonValue, key: string): JsonValue | undefined => { - if (Array.isArray(value)) - return String(Number(key)) === key ? value[Number(key)] : undefined; - return isRecord(value) && Object.hasOwn(value, key) ? value[key] : undefined; +/** How a walk matches array members: by selector rather than position, and whether it notes a missing value's members too. */ +interface WalkOptions { + readonly keyed?: boolean; + readonly outermost?: boolean; +} + +/** What a walk notes, and how; `older` when it walks the earlier version against the later one. */ +interface Noting extends WalkOptions { + readonly missing: Found; + readonly changed?: Found; + readonly older?: boolean; +} + +/** Each member's key and the other version's member it pairs with, one to one, both walks pairing alike. */ +const keyedMembers = ( + from: readonly JsonValue[], + other: JsonValue | undefined, + older: boolean, +) => { + const others = Array.isArray(other) ? other : []; + const pairing = older ? pairMembers(others, from) : pairMembers(from, others); + const side = older ? pairing.older : pairing.newer; + const lookup = memberLookup(other); + return side.keys.map((key, index) => { + const partner = side.partners[index]; + // Once the array became another type, a member's counterpart is what its pointer names there. + if (!Array.isArray(other)) return { key, counterpart: lookup(key) }; + return { + key, + counterpart: partner === undefined ? undefined : others[partner], + }; + }); }; /** * Walk `from` in document order against `other`, noting each value `other` * lacks in `missing` and, when given, each whose own entry differs in - * `changed`. One path is shared, so memory follows depth, not the document. + * `changed`. One path is shared, so memory follows depth, not the document; + * a keyed walk also holds the member keys of the arrays along it. */ const walk = ( from: JsonValue, other: JsonValue | undefined, path: PointerPath, - into: { readonly missing: Found; readonly changed?: Found }, + into: Noting, ): void => { - if (other === undefined) note(into.missing, path); - else if (into.changed !== undefined && ownEntry(from) !== ownEntry(other)) + if (other === undefined) { + note(into.missing, path); + if (into.outermost === true) return; + } else if (into.changed !== undefined && ownEntry(from) !== ownEntry(other)) note(into.changed, path); - const visit = (key: string, item: JsonValue) => { + const visit = (key: string, item: JsonValue, counterpart?: JsonValue) => { const segment = escapePointer(key); path.segments.push(segment); path.length += segment.length + 1; - walk( - item, - other === undefined ? undefined : memberOf(other, key), - path, - into, - ); + walk(item, counterpart, path, into); path.segments.pop(); path.length -= segment.length + 1; }; - if (Array.isArray(from)) - from.forEach((item, index) => visit(String(index), item)); - else if (isRecord(from)) - for (const [key, item] of Object.entries(from)) visit(key, item); + if (Array.isArray(from) && into.keyed === true) { + const members = keyedMembers(from, other, into.older === true); + from.forEach((item, index) => { + const member = members[index]; + if (member !== undefined) visit(member.key, item, member.counterpart); + }); + } else if (Array.isArray(from)) { + const lookup = memberLookup(other); + from.forEach((item, index) => + visit(String(index), item, lookup(String(index))), + ); + } else if (isRecord(from)) { + // The keys go to the lookup together, so an array compared with an object is searched once per field set. + const lookup = memberLookup(other, Object.keys(from)); + for (const [key, item] of Object.entries(from)) + visit(key, item, lookup(key)); + } }; -/** Values added, removed and changed between two JSON documents, by JSON pointer. */ +/** + * Values added, removed and changed between two JSON documents, by JSON + * pointer; keyed, array members are paired one to one by the selectors naming + * them, so an inserted member does not shift the ones after it. + */ const compareTrees = ( before: JsonValue, after: JsonValue, + options: WalkOptions = {}, ): DesktopStateDifference => { const [added, removed, changed] = [found(), found(), found()]; - walk(after, before, { segments: [], length: 0 }, { missing: added, changed }); - walk(before, after, { segments: [], length: 0 }, { missing: removed }); + const start = () => ({ segments: [], length: 0 }); + walk(after, before, start(), { ...options, missing: added, changed }); + walk(before, after, start(), { ...options, missing: removed, older: true }); return { kind: "values", added: added.listed, @@ -257,9 +303,11 @@ export const diffStateViews = ( const difference = compareViews(format, before, after); // Only REA's own readers write coverage flags; JSON and key files are data. const flagged = format === "text" || format === "xml" || format === "sqlite"; - return flagged && (truncatedView(before) || truncatedView(after)) - ? { ...difference, complete: false } - : difference; + const partial = + format === "zip" + ? zipLeavesOut(before) || zipLeavesOut(after) + : flagged && (truncatedView(before) || truncatedView(after)); + return partial ? { ...difference, complete: false } : difference; }; const compareViews = ( @@ -273,6 +321,8 @@ const compareViews = ( case "json": case "xml": return compareTrees(before, after); + case "zip": + return compareTrees(before, after, { keyed: true }); case "keyfile": case "dconf": return compareMaps("keys", keyEntries(before), keyEntries(after)); @@ -288,3 +338,10 @@ const compareViews = ( return compareMaps("content", new Map(), new Map()); } }; + +/** How two zip views differ by name-addressed pointer, a member added or removed whole noted once, without its contents. */ +export const diffZipMembers = ( + before: JsonValue, + after: JsonValue, +): DesktopStateDifference => + compareTrees(before, after, { keyed: true, outermost: true }); diff --git a/src/domain/desktop/stateFormats.test.ts b/src/domain/desktop/stateFormats.test.ts index 9ca5a81252..c2558f6119 100644 --- a/src/domain/desktop/stateFormats.test.ts +++ b/src/domain/desktop/stateFormats.test.ts @@ -21,6 +21,15 @@ describe("detectStateFormat", () => { expect(detectStateFormat("/h/blob", Uint8Array.of(1, 0, 2))).toBe("binary"); }); + it("recognises a zip archive by its local file header, whatever its name", () => { + const header = Uint8Array.of(0x50, 0x4b, 3, 4, 20, 0, 0, 0); + expect(detectStateFormat("/h/plate.FCStd", header)).toBe("zip"); + expect(detectStateFormat("/h/plate.20261105.FCBak", header)).toBe("zip"); + expect( + detectStateFormat("/h/empty.odt", Uint8Array.of(0x50, 0x4b, 5, 6, 0, 0)), + ).toBe("binary"); + }); + it("tells JSON, XML, key files and plain text apart", () => { expect(detectStateFormat("/h/a.json", bytes('{"a":1}'))).toBe("json"); expect(detectStateFormat("/h/notes", bytes("{not json"))).toBe("text"); diff --git a/src/domain/desktop/stateFormats.ts b/src/domain/desktop/stateFormats.ts index 11e2f79f11..e4eed2e211 100644 --- a/src/domain/desktop/stateFormats.ts +++ b/src/domain/desktop/stateFormats.ts @@ -4,7 +4,7 @@ import { isExactJsonNumber } from "../exactJsonNumber.js"; import { MAX_JSON_DEPTH, type JsonValue } from "../jsonValue.js"; import { err, ok, type Result } from "../result.js"; import { safeParseJson } from "../safeJson.js"; -import { parseXmlDocument } from "../xmlDocument.js"; +import { isWellFormedXml, parseXmlDocument } from "../xmlDocument.js"; /** Formats the state view reads into JSON. */ export type DesktopStateFormat = @@ -14,6 +14,7 @@ export type DesktopStateFormat = | "xml" | "sqlite" | "dconf" + | "zip" | "binary"; const SQLITE_HEADER = "SQLite format 3\0"; @@ -23,12 +24,17 @@ export const SQLITE_SIDECARS = [ { suffix: "-journal", label: "rollback journal" }, ] as const; const GVDB_HEADER = "GVariant"; +/** A zip archive's local file header, which FreeCAD, ODF, OOXML, EPUB and JAR files begin with. */ +const ZIP_HEADER = "PK\u0003\u0004"; const XML_EXTENSIONS = /\.(?:xml|xbel|plist|svg|ui)$/iu; const MAX_TEXT_LINES = 5000; const MAX_XML_NODES = 5000; const MAX_XML_DEPTH = 64; /** Half of Evidence's nesting limit, leaving room for the result around a view. */ const MAX_JSON_VIEW_DEPTH = MAX_JSON_DEPTH / 2; +const MIB = 1024 * 1024; +/** Largest JSON or key file read into a view, whose whole value enters Evidence. */ +const MAX_PARSED_VIEW_BYTES = 4 * MIB; type KeyfileLine = | { readonly kind: "skip" } @@ -80,6 +86,149 @@ export const decodeStateText = (bytes: Uint8Array): string | undefined => { } }; +const JSON_NUMBER = /-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/uy; +const JSON_HEX4 = /[0-9a-fA-F]{4}/uy; +const JSON_ESCAPES = new Set(['"', "\\", "/", "b", "f", "n", "r", "t"]); + +/** Where the JSON string opening at `start` ends, past its closing quote, or -1 when it is malformed. */ +const jsonStringEnd = (text: string, start: number): number => { + for (let at = start + 1; at < text.length; at += 1) { + const code = text.charCodeAt(at); + if (code === 0x22) return at + 1; + if (code < 0x20) return -1; + if (code === 0x5c) { + const escape = text.charAt(at + 1); + JSON_HEX4.lastIndex = at + 2; + if (escape === "u" && JSON_HEX4.test(text)) at += 5; + else if (JSON_ESCAPES.has(escape)) at += 1; + else return -1; + } + } + return -1; +}; + +/** Where the JSON string, number or literal starting at `start` ends, or -1 when none does. */ +const jsonScalarEnd = (text: string, start: number): number => { + if (text.charCodeAt(start) === 0x22) return jsonStringEnd(text, start); + for (const literal of ["true", "false", "null"]) + if (text.startsWith(literal, start)) return start + literal.length; + JSON_NUMBER.lastIndex = start; + return JSON_NUMBER.test(text) ? JSON_NUMBER.lastIndex : -1; +}; + +/** How far a JSON text has been read: what may come next, and the containers still open. */ +interface JsonScan { + expect: "value" | "key" | "colon" | "after"; + /** The innermost container was just opened, so it may close at once. */ + empty: boolean; + /** Each open container's opening bracket, innermost at `depth - 1`. */ + open: Uint8Array; + depth: number; +} + +/** Open a container, growing the brackets held as it nests deeper. */ +const openJson = (scan: JsonScan, bracket: number): void => { + if (scan.depth === scan.open.length) { + const grown = new Uint8Array(scan.open.length * 2); + grown.set(scan.open); + scan.open = grown; + } + scan.open[scan.depth] = bracket; + scan.depth += 1; + scan.expect = bracket === 0x5b ? "value" : "key"; + scan.empty = true; +}; + +/** Read one bracket, comma or colon into the scan; false when the text cannot hold it there. */ +const jsonPunctuation = (scan: JsonScan, char: string): boolean => { + const code = char.charCodeAt(0); + const inner = scan.depth > 0 ? scan.open[scan.depth - 1] : undefined; + switch (char) { + case "[": + case "{": + if (scan.expect !== "value") return false; + openJson(scan, code); + return true; + case "]": + case "}": + // A closing bracket's code is its opening's plus two. + if ((scan.expect !== "after" && !scan.empty) || inner !== code - 2) + return false; + scan.depth -= 1; + break; + case ",": + if (scan.expect !== "after" || inner === undefined) return false; + scan.expect = inner === 0x5b ? "value" : "key"; + return true; + default: + if (scan.expect !== "colon") return false; + scan.expect = "value"; + return true; + } + scan.expect = "after"; + scan.empty = false; + return true; +}; + +/** Read a string, number or literal into the scan; false when the text cannot hold it there. */ +const jsonScalar = (scan: JsonScan, string: boolean): boolean => { + if (scan.expect === "value") scan.expect = "after"; + else if (string && scan.expect === "key") scan.expect = "colon"; + else return false; + scan.empty = false; + return true; +}; + +/** + * Whether text is one JSON value as `JSON.parse` reads it, checked a token at + * a time without building the value: it holds a byte per open container, so + * text too large to parse into a view is still told apart from other text. + */ +export const isJsonText = (text: string): boolean => { + const scan: JsonScan = { + expect: "value", + empty: false, + open: new Uint8Array(64), + depth: 0, + }; + for (let at = 0; at < text.length;) { + const char = text.charAt(at); + if (" \t\n\r".includes(char)) at += 1; + else if ("[]{},:".includes(char)) { + if (!jsonPunctuation(scan, char)) return false; + at += 1; + } else { + at = jsonScalarEnd(text, at); + if (at < 0 || !jsonScalar(scan, char === '"')) return false; + } + } + return scan.expect === "after" && scan.depth === 0; +}; + +/** Whether a file's name or leading text marks it as JSON, which it is if it parses. */ +const namedJson = (path: string, trimmed: string): boolean => + path.endsWith(".json") || /^[[{]/u.test(trimmed); + +/** Whether a file's name or XML declaration marks it as XML, which it is if it parses. */ +const namedXml = (path: string, trimmed: string): boolean => + XML_EXTENSIONS.test(path) || trimmed.startsWith(" { + const trimmed = text.trimStart(); + if (namedJson(path, trimmed) && isJsonText(text)) return "json"; + if (namedXml(path, trimmed) && isWellFormedXml(text, { doctype: "reject" })) + return "xml"; + return "text"; +}; + /** Pick the reader for one retained file from its leading bytes and name. */ export const detectStateFormat = ( path: string, @@ -88,15 +237,19 @@ export const detectStateFormat = ( const header = new TextDecoder("latin1").decode(bytes.subarray(0, 16)); if (header === SQLITE_HEADER) return "sqlite"; if (header.startsWith(GVDB_HEADER)) return "dconf"; + if (header.startsWith(ZIP_HEADER)) return "zip"; const text = decodeStateText(bytes); if (text === undefined) return "binary"; const trimmed = text.trimStart(); if ( - (path.endsWith(".json") || /^[[{]/u.test(trimmed)) && - safeParseJson(text).ok + namedJson(path, trimmed) && + // JSON too large for a view is told apart without building its value. + (bytes.length > MAX_PARSED_VIEW_BYTES + ? isJsonText(text) + : safeParseJson(text).ok) ) return "json"; - if (XML_EXTENSIONS.test(path) || trimmed.startsWith(" { - const pending: [JsonValue, number][] = [[root, 0]]; - for (let next = pending.pop(); next !== undefined; next = pending.pop()) { - const [value, depth] = next; - if (depth > limit) return true; - if (typeof value === "object" && value !== null) - for (const item of Object.values(value)) pending.push([item, depth + 1]); +/** An object's own values one at a time, without copying them into an array. */ +function* ownValues(record: Record): Generator { + for (const key in record) + if (Object.hasOwn(record, key)) yield record[key] ?? null; +} + +/** + * Whether a parsed JSON value nests deeper than a limit, checked without + * recursion: a value's depth is the number of levels open above it, each + * walked by one iterator, so no container's members are copied or queued. + */ +export const deeperThan = (root: JsonValue, limit: number): boolean => { + const levels: Iterator[] = [[root].values()]; + for (let level = levels.at(-1); level !== undefined; level = levels.at(-1)) { + const next = level.next(); + if (next.done === true) levels.pop(); + else if (levels.length - 1 > limit) return true; + else if (Array.isArray(next.value)) levels.push(next.value.values()); + else if (typeof next.value === "object" && next.value !== null) + levels.push(ownValues(next.value)); } return false; }; @@ -256,3 +421,44 @@ export const xmlView = (text: string): JsonValue | undefined => { ? undefined : { root: tree, truncated: budget.truncated }; }; + +/** Formats read from a file's bytes alone, without a tool or worker. */ +export type TextualStateFormat = "text" | "json" | "keyfile" | "xml"; + +/** Whether a format is one of the text formats, read as UTF-8. */ +export const isTextualFormat = ( + format: string | null, +): format is TextualStateFormat => + format === "text" || + format === "json" || + format === "keyfile" || + format === "xml"; + +/** A text, JSON, key file or XML file's view, or why it has none. */ +export const textualView = ( + format: TextualStateFormat, + bytes: Uint8Array, +): Result => { + if ( + (format === "json" || format === "keyfile") && + bytes.length > MAX_PARSED_VIEW_BYTES + ) + return err( + `larger than the ${String(MAX_PARSED_VIEW_BYTES / MIB)} MiB limit for JSON and key file views`, + ); + const text = decodeStateText(bytes) ?? ""; + switch (format) { + case "text": + return ok(textView(text)); + case "json": + return jsonView(text); + case "keyfile": + return ok({ groups: keyfileView(text) }); + case "xml": { + const tree = xmlView(text); + return tree === undefined + ? err("not well-formed XML, or it declares a DOCTYPE") + : ok(tree); + } + } +}; diff --git a/src/domain/desktop/stateRoles.test.ts b/src/domain/desktop/stateRoles.test.ts index 466219dd53..a76c1424ee 100644 --- a/src/domain/desktop/stateRoles.test.ts +++ b/src/domain/desktop/stateRoles.test.ts @@ -58,6 +58,31 @@ describe("stateReasons", () => { }); }); +describe("stateReasons for state directories", () => { + it("counts a folder beside a state directory whose name differs only in letter case, as GIMP's ~/.config/GIMP does", () => { + const facts: DesktopRoleFacts = { + home: "/home/ada", + stateDirectories: ["/data/ada/.config/gimp"], + stateTails: [".config/gimp"], + selectedRoots: [], + titles: [], + openedFiles: new Set(), + }; + const reasons = (path: string) => stateReasons(facts, path, [], false); + + expect(reasons("/data/ada/.config/GIMP/2.10/sessionrc")).toEqual([ + "state_directory", + ]); + expect(reasons("/home/ada/.config/GIMP/2.10/sessionrc")).toEqual([ + "state_directory", + ]); + expect(reasons("/home/ada/.config/gimp")).toEqual(["state_directory"]); + expect(reasons("/home/ada/.config/GIMPshop/sessionrc")).toEqual([]); + expect(reasons("/home/ada/.config/other/GIMP/sessionrc")).toEqual([]); + expect(reasons("/home/ada/.Config/gimp/sessionrc")).toEqual([]); + }); +}); + describe("isApplicationDconfGroup", () => { it("matches a whole path segment, not a substring", () => { const names = new Set(["mousepad"]); diff --git a/src/domain/desktop/stateRoles.ts b/src/domain/desktop/stateRoles.ts index f25084a087..6b92aded99 100644 --- a/src/domain/desktop/stateRoles.ts +++ b/src/domain/desktop/stateRoles.ts @@ -85,6 +85,24 @@ const hiddenTail = (path: string): string | null => { return index < 0 ? null : path.slice(index + 1); }; +/** The state directory a path lies in, or the folder beside one whose name differs only in letter case that the application may create during the capture, as the path spells it. */ +export const stateDirectoryOf = ( + path: string, + directories: readonly string[], +): string | undefined => { + for (const directory of directories) { + const parent = directory.slice(0, directory.lastIndexOf("/") + 1); + if (!path.startsWith(parent)) continue; + const folder = path.slice(parent.length).split("/")[0] ?? ""; + if ( + folder.length > 0 && + folder.toLowerCase() === directory.slice(parent.length).toLowerCase() + ) + return `${parent}${folder}`; + } + return undefined; +}; + /** Facts about the whole capture that every path's reasons draw on. */ export interface DesktopRoleFacts { readonly home: string; @@ -170,8 +188,9 @@ export const stateReasons = ( const reasons: DesktopStateReason[] = []; if ( changes.some((change) => change.in_state_directory) || - isWithinAny(path, facts.stateDirectories) || - (relative !== null && isWithinAny(relative, facts.stateTails)) + stateDirectoryOf(path, facts.stateDirectories) !== undefined || + (relative !== null && + stateDirectoryOf(relative, facts.stateTails) !== undefined) ) reasons.push("state_directory"); if ( diff --git a/src/domain/desktop/viewPointer.test.ts b/src/domain/desktop/viewPointer.test.ts new file mode 100644 index 0000000000..e0e390083d --- /dev/null +++ b/src/domain/desktop/viewPointer.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from "vitest"; + +import type { JsonValue } from "../jsonValue.js"; +import { locatePointer, pairMembers } from "./viewPointer.js"; + +const element = ( + name: string, + attributes: Record = {}, + children: JsonValue[] = [], +): JsonValue => ({ name, attributes, children }); + +const VIEW: JsonValue = { + root: element("Objects", {}, [ + "text between", + element("Object", { name: "Plate", type: "Part::Box" }), + element("Object", { name: "Hole", type: "Part::Cylinder" }), + element("Object", { name: "Pad", type: "Part::Box" }), + element("Note", { "odd]key": "x" }), + ]), + entries: [ + { name: "a/b.xml", size: 1 }, + { name: "x][y", size: 2 }, + { name: 3, size: 3 }, + ], +}; + +describe("locatePointer", () => { + it("selects the one member a selector names, by field, by attribute, or both", () => { + expect( + locatePointer( + VIEW, + "/root/children/[name=Object][@name=Hole]/attributes/type", + ), + ).toMatchObject({ kind: "value", value: "Part::Cylinder" }); + expect( + locatePointer(VIEW, "/root/children/[@name=Pad]/name"), + ).toMatchObject({ + kind: "value", + value: "Object", + }); + expect(locatePointer(VIEW, "/entries/[name=a~1b.xml]/size")).toMatchObject({ + kind: "value", + value: 1, + }); + expect(locatePointer(VIEW, "/root/children/0")).toMatchObject({ + kind: "value", + value: "text between", + }); + }); + + it("reports the arrays a selector searched, and those it entered by position at an object", () => { + expect( + locatePointer( + VIEW, + "/root/children/[name=Object][@name=Hole]/attributes/type", + ), + ).toMatchObject({ searched: ["/root/children"], byPosition: [] }); + expect(locatePointer(VIEW, "/root/children/1/name")).toMatchObject({ + searched: [], + byPosition: ["/root/children"], + }); + expect(locatePointer(VIEW, "/root/children/0")).toMatchObject({ + byPosition: [], + }); + expect( + locatePointer(VIEW, "/root/children/[@type=Part::Box]"), + ).toMatchObject({ kind: "unresolved", searched: ["/root/children"] }); + }); + + it("names no value when the last selector matches nothing, and is unresolved otherwise", () => { + expect( + locatePointer(VIEW, "/root/children/[name=Object][@name=Gone]"), + ).toMatchObject({ + kind: "none", + reason: "[name=Object][@name=Gone] matches no member", + }); + expect( + locatePointer(VIEW, "/root/children/[name=Object][@name=Gone]/name"), + ).toMatchObject({ + kind: "unresolved", + problem: "[name=Object][@name=Gone] matches no member", + }); + expect( + locatePointer(VIEW, "/root/children/[@type=Part::Box]"), + ).toMatchObject({ + kind: "unresolved", + problem: "[@type=Part::Box] matches 2 members, not one", + }); + expect(locatePointer(VIEW, "/root/children/[Object]")).toMatchObject({ + kind: "unresolved", + problem: "[Object] is not a selector of the form [KEY=VALUE]", + }); + expect(locatePointer(VIEW, "/root/children/[=x]")).toMatchObject({ + kind: "unresolved", + }); + }); + + it("compares strings only and keeps an ordinary key on an object", () => { + expect(locatePointer(VIEW, "/entries/[name=3]")).toMatchObject({ + kind: "none", + reason: "[name=3] matches no member", + }); + expect(locatePointer({ "[a=b]": 1 }, "/[a=b]")).toMatchObject({ + kind: "value", + value: 1, + }); + expect(locatePointer(VIEW, "/root/children/01")).toMatchObject({ + kind: "none", + }); + expect(locatePointer(null, "/x")).toMatchObject({ kind: "none" }); + }); +}); + +describe("pairMembers", () => { + it("keys each member by the most general selector naming it alone, else by position", () => { + const children = (VIEW as { root: { children: JsonValue[] } }).root + .children; + const entries = (VIEW as { entries: JsonValue[] }).entries; + + expect(pairMembers(children, []).newer.keys).toEqual([ + "0", + "[name=Object][@name=Plate]", + "[name=Object][@name=Hole]", + "[name=Object][@name=Pad]", + "[name=Note]", + ]); + expect(pairMembers(entries, []).newer.keys).toEqual([ + "[name=a/b.xml]", + "1", + "2", + ]); + }); + + it("pairs one to one, so a member another selector also names is not paired twice", () => { + const item = (attributes: Record) => + element("item", attributes); + + expect( + pairMembers( + [item({ b: "y" }), item({ a: "x" })], + [item({ a: "x", b: "y" })], + ), + ).toEqual({ + newer: { keys: ["[name=item][@b=y]", "1"], partners: [0, undefined] }, + older: { keys: ["[name=item][@b=y]"], partners: [0] }, + }); + }); + + it("passes over a selector that names several members of the other version", () => { + const pairing = pairMembers( + [ + element("Object", { name: "Plate" }), + element("Object", { name: "Hole" }), + ], + [element("Object", { name: "Plate" })], + ); + + expect(pairing).toEqual({ + newer: { + keys: ["[name=Object][@name=Plate]", "[name=Object][@name=Hole]"], + partners: [0, undefined], + }, + older: { keys: ["[name=Object][@name=Plate]"], partners: [0] }, + }); + }); + + it("pairs an element by its identifier, so another attribute that changed is an edit", () => { + const pairing = pairMembers( + [ + element("item", { color: "green", id: "1" }), + element("item", { id: "2" }), + ], + [ + element("item", { color: "red", id: "1" }), + element("item", { id: "2" }), + ], + ); + + const keys = ["[name=item][@id=1]", "[name=item][@id=2]"]; + expect(pairing).toEqual({ + newer: { keys, partners: [0, 1] }, + older: { keys, partners: [0, 1] }, + }); + }); + + it("counts an identified sibling's other attributes, keying a group by its label rather than a fill both share", () => { + const note = (transform: string) => + element("g", { fill: "red", "inkscape:label": "note", transform }); + const layer = element("g", { fill: "red", id: "layer" }); + + const keys = ["[name=g][@inkscape:label=note]", "[name=g][@id=layer]"]; + expect( + pairMembers([note("scale(2)"), layer], [note("scale(1)"), layer]), + ).toEqual({ + newer: { keys, partners: [0, 1] }, + older: { keys, partners: [0, 1] }, + }); + }); + + it("pairs an element with identifiers by those alone, so one replaced by another of its type is removed and added", () => { + const object = (name: string, id: string) => + element("Object", { type: "Part::Cylinder", name, id }); + const plate = element("Object", { type: "Part::Box", name: "Plate" }); + + expect( + pairMembers( + [plate, object("Hole001", "3")], + [plate, object("Hole", "2")], + ), + ).toEqual({ + newer: { + keys: ["[name=Object][@name=Plate]", "[name=Object][@name=Hole001]"], + partners: [0, undefined], + }, + older: { + keys: ["[name=Object][@name=Plate]", "[name=Object][@name=Hole]"], + partners: [0, undefined], + }, + }); + }); + + it("pairs members no selector names by position", () => { + expect(pairMembers(["a", "b", "c"], ["a", "x"])).toEqual({ + newer: { keys: ["0", "1", "2"], partners: [0, 1, undefined] }, + older: { keys: ["0", "1"], partners: [0, 1] }, + }); + }); + + it("leaves out values a selector cannot carry and times an app stamps", () => { + expect( + pairMembers( + [ + element("Save", { at: "2026-11-04T10:12:00Z", id: "1" }), + element("Save", { at: "2026-11-04T10:13:00Z", id: "2" }), + ], + [], + ).newer.keys, + ).toEqual(["[name=Save][@id=1]", "[name=Save][@id=2]"]); + }); +}); diff --git a/src/domain/desktop/viewPointer.ts b/src/domain/desktop/viewPointer.ts new file mode 100644 index 0000000000..2f5b5b735d --- /dev/null +++ b/src/domain/desktop/viewPointer.ts @@ -0,0 +1,400 @@ +import type { JsonValue } from "../jsonValue.js"; +import { isTimestamp } from "./appTask.js"; + +/** One condition of a selector: a member's string field, or with `@` one of its attributes, equal to a value. */ +interface Condition { + readonly attribute: boolean; + readonly key: string; + readonly value: string; +} + +/** The arrays a pointer stepped into, each as the pointer to it. */ +interface ArraySteps { + /** Arrays a selector searched, whose every member it had to see. */ + readonly searched: readonly string[]; + /** Arrays it entered by position at a member that is an object or array. */ + readonly byPosition: readonly string[]; +} + +/** Where a pointer leads in a view, and the arrays it stepped into on the way. */ +export type Located = ArraySteps & + ( + | { readonly kind: "value"; readonly value: JsonValue } + /** It names no value; `reason` says which last selector matched nothing, when one did. */ + | { readonly kind: "none"; readonly reason?: string } + /** A selector before the last segment matched no member, or any matched several, or one is malformed. */ + | { readonly kind: "unresolved"; readonly problem: string } + ); + +/** Where a step into an array leads, before the steps are attached. */ +type Stepped = + | { readonly kind: "none"; readonly reason?: string } + | { readonly kind: "unresolved"; readonly problem: string }; + +const INDEX = /^(?:0|[1-9][0-9]*)$/u; + +const isRecord = ( + value: JsonValue | null | undefined, +): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const own = (record: Record, key: string) => + Object.hasOwn(record, key) ? record[key] : undefined; + +const bracketed = (segment: string) => + segment.length >= 2 && segment.startsWith("[") && segment.endsWith("]"); + +/** A segment's conditions when it is a selector; otherwise undefined. A value cannot hold `][`. */ +const selectorOf = (segment: string): readonly Condition[] | undefined => { + if (!bracketed(segment)) return undefined; + const conditions: Condition[] = []; + for (const part of segment.slice(1, -1).split("][")) { + const equals = part.indexOf("="); + const key = equals < 0 ? "" : part.slice(0, equals); + const attribute = key.startsWith("@"); + const name = attribute ? key.slice(1) : key; + if (name === "") return undefined; + conditions.push({ attribute, key: name, value: part.slice(equals + 1) }); + } + return conditions; +}; + +/** Whether an array member meets every condition; selectors compare strings only. */ +const selects = (member: JsonValue, conditions: readonly Condition[]) => + isRecord(member) && + conditions.every(({ attribute, key, value }) => { + const fields = attribute ? own(member, "attributes") : member; + return isRecord(fields) && own(fields, key) === value; + }); + +/** One step into an array: by index, or by a selector that must match one member, or none when it is the last. */ +const arrayStep = ( + items: readonly JsonValue[], + token: string, + last: boolean, +): { readonly member: JsonValue } | Stepped => { + if (INDEX.test(token)) { + const index = Number(token); + return index < items.length + ? { member: items[index] ?? null } + : { kind: "none" }; + } + const conditions = selectorOf(token); + if (conditions === undefined) + return bracketed(token) + ? { + kind: "unresolved", + problem: `${token} is not a selector of the form [KEY=VALUE]`, + } + : { kind: "none" }; + const matched = items.filter((item) => selects(item, conditions)); + if (matched.length === 1) return { member: matched[0] ?? null }; + if (matched.length > 1) + return { + kind: "unresolved", + problem: `${token} matches ${String(matched.length)} members, not one`, + }; + const reason = `${token} matches no member`; + return last + ? { kind: "none", reason } + : { kind: "unresolved", problem: reason }; +}; + +/** + * Follow an RFC 6901 pointer through a view, where a segment applied to an + * array may also be a selector: `[KEY=VALUE]` one or more times, naming the one + * member whose string field KEY equals VALUE, or with `@KEY` whose `attributes` + * hold it, as REA's XML view keeps them. So `/root/children/[name=Object][@name=Hole]` + * is the `Object` element named `Hole`, wherever it sits among its siblings. + */ +export const locatePointer = ( + view: JsonValue | null, + pointer: string, +): Located => { + let current: JsonValue = view; + const segments = pointer.slice(1).split("/"); + const steps = { searched: [] as string[], byPosition: [] as string[] }; + for (const [position, segment] of segments.entries()) { + const token = segment.replaceAll("~1", "/").replaceAll("~0", "~"); + if (Array.isArray(current)) { + const array = segments + .slice(0, position) + .map((part) => `/${part}`) + .join(""); + const indexed = INDEX.test(token); + if (!indexed && selectorOf(token) !== undefined) + steps.searched.push(array); + const step = arrayStep(current, token, position === segments.length - 1); + if ("kind" in step) return { ...step, ...steps }; + if (indexed && typeof step.member === "object" && step.member !== null) + steps.byPosition.push(array); + current = step.member; + } else if (isRecord(current) && Object.hasOwn(current, token)) + current = current[token] ?? null; + else return { kind: "none", ...steps }; + } + return { kind: "value", value: current, ...steps }; +}; + +/** The field a condition reads from a member, when it is a string. */ +const fieldOf = (member: JsonValue, { attribute, key }: Condition) => { + if (!isRecord(member)) return undefined; + const fields = attribute ? own(member, "attributes") : member; + const value = isRecord(fields) ? own(fields, key) : undefined; + return typeof value === "string" ? value : undefined; +}; + +/** Marks selector values several members share, which therefore name none. */ +const SHARED = Symbol("shared"); + +/** + * A selector's conditions once per field, in field order, so every spelling of + * one selector reads alike; undefined when it is malformed or two conditions on + * one field differ, which no member meets. + */ +const normalSelector = (token: string): readonly Condition[] | undefined => { + const conditions = selectorOf(token); + if (conditions === undefined) return undefined; + const byField = new Map(); + for (const condition of conditions) { + const field = `${condition.attribute ? "@" : ""}${condition.key}`; + const seen = byField.get(field); + if (seen !== undefined && seen.value !== condition.value) return undefined; + byField.set(field, condition); + } + return [...byField.keys()] + .sort() + .flatMap((field) => byField.get(field) ?? []); +}; + +/** Selectors grouped by the fields they read, each wanted value list with the tokens spelling it. */ +const selectorGroups = (tokens: Iterable) => { + const groups = new Map< + string, + { conditions: readonly Condition[]; wanted: Map } + >(); + for (const token of tokens) { + if (INDEX.test(token)) continue; + const conditions = normalSelector(token); + if (conditions === undefined) continue; + const fields = JSON.stringify( + conditions.map(({ attribute, key }) => [attribute, key]), + ); + const values = JSON.stringify(conditions.map(({ value }) => value)); + const group = groups.get(fields) ?? { conditions, wanted: new Map() }; + groups.set(fields, group); + const spellings = group.wanted.get(values); + if (spellings === undefined) group.wanted.set(values, [token]); + else spellings.push(token); + } + return groups.values(); +}; + +/** + * The member each selector among `tokens` names alone, found in one pass over + * the array per set of fields they read, holding only the values they ask for. + */ +const selectedMembers = ( + items: readonly JsonValue[], + tokens: Iterable, +): ReadonlyMap => { + const selected = new Map(); + for (const { conditions, wanted } of selectorGroups(tokens)) { + const found = new Map(); + for (const item of items) { + const values = conditions.map((condition) => fieldOf(item, condition)); + if (values.some((value) => value === undefined)) continue; + const key = JSON.stringify(values); + if (wanted.has(key)) found.set(key, found.has(key) ? SHARED : item); + } + for (const [key, member] of found) + if (member !== SHARED) + for (const token of wanted.get(key) ?? []) selected.set(token, member); + } + return selected; +}; + +/** + * What pointer tokens name in one value by the evaluator's rules: an own key, + * an index, or the one member a selector among `tokens` matches. The selectors + * are resolved together up front, so many cost one pass per set of fields they + * read, and what is kept grows with the tokens, not with the array. + */ +export const memberLookup = ( + value: JsonValue | undefined, + tokens: Iterable = [], +): ((token: string) => JsonValue | undefined) => { + if (!Array.isArray(value)) + return (token) => + isRecord(value) && Object.hasOwn(value, token) ? value[token] : undefined; + const selected = selectedMembers(value, tokens); + return (token) => + INDEX.test(token) ? value[Number(token)] : selected.get(token); +}; + +/** The value a pointer names, or undefined when it names none or a selector on its way is unresolved. */ +export const resolvePointer = ( + view: JsonValue | null, + pointer: string, +): JsonValue | undefined => { + const located = locatePointer(view, pointer); + return located.kind === "value" ? located.value : undefined; +}; + +/** A value a selector can carry and that stays the same between saves. */ +const selectable = (value: string) => + !value.includes("][") && !isTimestamp(value); + +/** An attribute name a selector can carry. */ +const attributeName = (key: string) => key !== "" && !/[=[\]]/u.test(key); + +/** + * A member's selectors as its name and the conditions after it, the ids they + * are counted by, and how many of them, from the first, may key it. + */ +interface Candidates { + readonly name: string; + readonly suffixes: readonly string[]; + readonly ids: readonly string[]; + readonly choosable: number; +} + +/** An attribute that identifies its element: `name`, `id`, `xid`, or a prefixed `id` such as `xml:id`. */ +const identifying = (key: string) => /^(?:name|id|xid|.+:id)$/iu.test(key); + +/** + * The selectors that could name a member, most general first: `[name=N]`, then + * `[name=N][@A=V]` for each attribute, a `name` attribute before the others. + * All are counted, so a selector several siblings match is never taken for one + * naming a member alone; but an element with identifying attributes is keyed + * by those alone, so one whose identifiers are new is another element, + * whatever else it shares. The ids number the name among those seen, so a + * name is spelled into a selector only once one is chosen, not per attribute. + */ +const candidatesOf = ( + member: JsonValue, + names: Map, +): Candidates | undefined => { + if (!isRecord(member)) return undefined; + const name = own(member, "name"); + if (typeof name !== "string" || !selectable(name)) return undefined; + const attributes = own(member, "attributes"); + const usable = ( + isRecord(attributes) ? Object.entries(attributes) : [] + ).flatMap(([key, value]) => + typeof value === "string" && attributeName(key) && selectable(value) + ? [{ key, value }] + : [], + ); + const identities = usable.filter(({ key }) => identifying(key)); + const named = (key: string) => key.toLowerCase() === "name"; + const suffixes = [ + "", + ...[ + ...identities.filter(({ key }) => named(key)), + ...identities.filter(({ key }) => !named(key)), + ...usable.filter(({ key }) => !identifying(key)), + ].map(({ key, value }) => `[@${key}=${value}]`), + ]; + const number = names.get(name) ?? names.size; + names.set(name, number); + // A suffix is empty or opens with `[`, so the number before it cannot run into it. + const ids = suffixes.map((suffix) => `${String(number)}${suffix}`); + const choosable = + identities.length > 0 ? identities.length + 1 : suffixes.length; + return { name, suffixes, ids, choosable }; +}; + +/** The selector a member's candidate at `index` spells. */ +const selectorAt = ({ name, suffixes }: Candidates, index: number) => + `[name=${name}]${suffixes[index] ?? ""}`; + +const countIds = (members: readonly (Candidates | undefined)[]) => { + const counts = new Map(); + for (const candidates of members) + for (const id of candidates?.ids ?? []) + counts.set(id, (counts.get(id) ?? 0) + 1); + return counts; +}; + +/** One version's side of a pairing: each member's key, and the other version's member it pairs with. */ +interface PairedSide { + readonly keys: readonly string[]; + readonly partners: readonly (number | undefined)[]; +} + +/** + * Pair the members of two versions of an array one to one. A member pairs by + * its first candidate selector naming it alone in its version and one unpaired + * member of the other, whichever before it name nothing there; one with no + * partner keeps its first selector naming it alone and nothing in the other + * version. Members left without a selector are keyed and paired by position. + */ +export const pairMembers = ( + newer: readonly JsonValue[], + older: readonly JsonValue[], +): { readonly newer: PairedSide; readonly older: PairedSide } => { + const names = new Map(); + const newCandidates = newer.map((member) => candidatesOf(member, names)); + const oldCandidates = older.map((member) => candidatesOf(member, names)); + const newCounts = countIds(newCandidates); + const oldCounts = countIds(oldCandidates); + const count = (counts: ReadonlyMap, id: string) => + counts.get(id) ?? 0; + const oldById = new Map(); + oldCandidates.forEach((candidates, index) => { + for (const id of candidates?.ids.slice(0, candidates.choosable) ?? []) + if (count(oldCounts, id) === 1) oldById.set(id, index); + }); + const newKeys: (string | undefined)[] = newer.map(() => undefined); + const oldKeys: (string | undefined)[] = older.map(() => undefined); + const newPartners: (number | undefined)[] = newer.map(() => undefined); + const oldPartners: (number | undefined)[] = older.map(() => undefined); + newCandidates.forEach((candidates, index) => { + if (candidates === undefined) return; + for (const [at, id] of candidates.ids + .slice(0, candidates.choosable) + .entries()) { + const match = count(newCounts, id) === 1 ? oldById.get(id) : undefined; + if (match === undefined || oldPartners[match] !== undefined) continue; + newKeys[index] = oldKeys[match] = selectorAt(candidates, at); + newPartners[index] = match; + oldPartners[match] = index; + return; + } + }); + /** The first selector naming a member alone in its version and nothing in the other. */ + const unmatched = ( + candidates: Candidates | undefined, + here: ReadonlyMap, + there: ReadonlyMap, + ) => { + if (candidates === undefined) return undefined; + const at = candidates.ids + .slice(0, candidates.choosable) + .findIndex((id) => count(here, id) === 1 && count(there, id) === 0); + return at < 0 ? undefined : selectorAt(candidates, at); + }; + newCandidates.forEach((candidates, index) => { + newKeys[index] ??= unmatched(candidates, newCounts, oldCounts); + }); + oldCandidates.forEach((candidates, index) => { + oldKeys[index] ??= unmatched(candidates, oldCounts, newCounts); + }); + for (let index = 0; index < Math.min(newer.length, older.length); index += 1) + if (newKeys[index] === undefined && oldKeys[index] === undefined) { + newPartners[index] = index; + oldPartners[index] = index; + } + const side = ( + keys: readonly (string | undefined)[], + partners: readonly (number | undefined)[], + ): PairedSide => ({ + keys: keys.map((key, index) => key ?? String(index)), + partners, + }); + return { + newer: side(newKeys, newPartners), + older: side(oldKeys, oldPartners), + }; +}; diff --git a/src/domain/desktop/zipArchive.fixture.ts b/src/domain/desktop/zipArchive.fixture.ts new file mode 100644 index 0000000000..6659c3c46a --- /dev/null +++ b/src/domain/desktop/zipArchive.fixture.ts @@ -0,0 +1,172 @@ +import { crc32, deflateRawSync } from "node:zlib"; + +/** One archive member; the overrides let a test frame what a real writer would not. */ +export interface ZipMember { + readonly name: string; + readonly data?: string | Uint8Array; + /** 0 stores, 8 deflates; another method stores the data under that number. */ + readonly method?: number; + readonly declaredSize?: number; + readonly crc?: number; + /** General purpose flags; bit 0 marks the member encrypted. */ + readonly flags?: number; + /** "Version made by": its high byte names the host, 0 for MS-DOS and 3 for Unix. */ + readonly madeBy?: number; + /** External attributes; a slash-ended name gets the MS-DOS directory bit. */ + readonly external?: number; +} + +const UTF8_NAME = 0x800; + +/** A zip archive of the members in order, framed from the PKWARE APPNOTE records. */ +export const zipArchive = (members: readonly ZipMember[]): Buffer => { + const locals: Buffer[] = []; + const centrals: Buffer[] = []; + let offset = 0; + for (const member of members) { + const content = Buffer.from(member.data ?? ""); + const method = member.method ?? 8; + const stored = method === 8 ? deflateRawSync(content) : content; + const name = Buffer.from(member.name); + const flags = + (member.flags ?? 0) | + (/^[\x20-\x7e]*$/u.test(member.name) ? 0 : UTF8_NAME); + const fields = { + method, + flags, + crc: member.crc ?? crc32(content), + compressed: stored.length, + size: member.declaredSize ?? content.length, + }; + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(fields.flags, 6); + local.writeUInt16LE(fields.method, 8); + local.writeUInt16LE(0x5021, 12); // 2020-01-01, DOS date. + local.writeUInt32LE(fields.crc, 14); + local.writeUInt32LE(fields.compressed, 18); + local.writeUInt32LE(fields.size, 22); + local.writeUInt16LE(name.length, 26); + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(member.madeBy ?? 20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(fields.flags, 8); + central.writeUInt16LE(fields.method, 10); + central.writeUInt16LE(0x5021, 14); + central.writeUInt32LE(fields.crc, 16); + central.writeUInt32LE(fields.compressed, 20); + central.writeUInt32LE(fields.size, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt32LE( + member.external ?? (member.name.endsWith("/") ? 0x10 : 0), + 38, + ); + central.writeUInt32LE(offset, 42); + locals.push(local, name, stored); + centrals.push(central, name); + offset += local.length + name.length + stored.length; + } + const directory = Buffer.concat(centrals); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + // Past 65,535 members the 16-bit counts wrap, as writers without Zip64 leave them. + end.writeUInt16LE(members.length & 0xffff, 8); + end.writeUInt16LE(members.length & 0xffff, 10); + end.writeUInt32LE(directory.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, directory, end]); +}; + +/** A minimal OpenDocument text: the stored `mimetype` first, then its content and manifest, then any other members. */ +export const odtArchive = ( + paragraph: string, + others: readonly ZipMember[] = [], +): Buffer => + zipArchive([ + { + name: "mimetype", + data: "application/vnd.oasis.opendocument.text", + method: 0, + }, + { + name: "content.xml", + data: ` +${paragraph} +`, + }, + { + name: "META-INF/manifest.xml", + data: ` + +`, + }, + ...others, + ]); + +/** An archive of `count` empty stored entries named `e/0`, `e/1` and so on, written straight into one buffer. */ +export const emptyEntriesArchive = (count: number): Buffer => { + const names = Array.from({ length: count }, (_, index) => + Buffer.from(`e/${String(index)}`), + ); + const named = names.reduce((total, name) => total + name.length, 0); + const directory = 30 * count + named; + const archive = Buffer.alloc(directory + 46 * count + named + 22); + let local = 0; + let central = directory; + for (const name of names) { + archive.writeUInt32LE(0x04034b50, local); + archive.writeUInt16LE(20, local + 4); + archive.writeUInt16LE(name.length, local + 26); + name.copy(archive, local + 30); + archive.writeUInt32LE(0x02014b50, central); + archive.writeUInt16LE(20, central + 4); + archive.writeUInt16LE(20, central + 6); + archive.writeUInt16LE(name.length, central + 28); + archive.writeUInt32LE(local, central + 42); + name.copy(archive, central + 46); + local += 30 + name.length; + central += 46 + name.length; + } + archive.writeUInt32LE(0x06054b50, central); + // Past 65,535 entries the 16-bit counts wrap, as writers without Zip64 leave them. + archive.writeUInt16LE(count & 0xffff, central + 8); + archive.writeUInt16LE(count & 0xffff, central + 10); + archive.writeUInt32LE(central - directory, central + 12); + archive.writeUInt32LE(directory, central + 16); + return archive; +}; + +/** + * The same archive ended by Zip64 records: a Zip64 end record and its locator, + * and a classic one that defers to them, or with `sentinels` false keeps its + * own counts, size and offset, as a writer may when they fit. + */ +export const withZip64End = (archive: Buffer, sentinels = true): Buffer => { + const end = archive.length - 22; + const entries = archive.readUInt16LE(end + 10); + const size = archive.readUInt32LE(end + 12); + const offset = archive.readUInt32LE(end + 16); + const record = Buffer.alloc(56); + record.writeUInt32LE(0x06064b50, 0); + record.writeBigUInt64LE(44n, 4); + record.writeUInt16LE(45, 12); + record.writeUInt16LE(45, 14); + record.writeBigUInt64LE(BigInt(entries), 24); + record.writeBigUInt64LE(BigInt(entries), 32); + record.writeBigUInt64LE(BigInt(size), 40); + record.writeBigUInt64LE(BigInt(offset), 48); + const locator = Buffer.alloc(20); + locator.writeUInt32LE(0x07064b50, 0); + locator.writeBigUInt64LE(BigInt(end), 8); + locator.writeUInt32LE(1, 16); + const classic = Buffer.from(archive.subarray(end)); + if (sentinels) { + classic.writeUInt16LE(0xffff, 8); + classic.writeUInt16LE(0xffff, 10); + classic.writeUInt32LE(0xffffffff, 12); + classic.writeUInt32LE(0xffffffff, 16); + } + return Buffer.concat([archive.subarray(0, end), record, locator, classic]); +}; diff --git a/src/domain/desktop/zipDirectory.ts b/src/domain/desktop/zipDirectory.ts new file mode 100644 index 0000000000..0e5a959e49 --- /dev/null +++ b/src/domain/desktop/zipDirectory.ts @@ -0,0 +1,207 @@ +import { crc32 } from "node:zlib"; + +import { + ZIP_EOCD_BYTES, + ZIP_MAX_COMMENT_BYTES, + zipEndOfCentralDirectoryCandidates, +} from "../zipEndOfCentralDirectory.js"; + +const CENTRAL_HEADER = 0x02014b50; +const CENTRAL_HEADER_BYTES = 46; +const ZIP64_LOCATOR = 0x07064b50; +const ZIP64_LOCATOR_BYTES = 20; +const ZIP64_END = 0x06064b50; +const ZIP64_END_BYTES = 56; +const UTF8_NAME = 0x800; +const UNICODE_PATH = 0x7075; +const UNIX_TYPE = 0o170000; +const UNIX_DIRECTORY = 0o040000; +const MSDOS_DIRECTORY = 0x10; +const STRICT_UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + +/** An archive's central directory read a record at a time, bounded to the records it keeps. */ +export interface ZipDirectory { + /** Where the directory begins; the bytes before it hold the entries. */ + readonly start: number; + /** The directory offset the end record declares, which readers compare with `start` to allow for prepended data. */ + readonly declaredOffset: number; + /** The first file entries' records, as stored, in directory order. */ + readonly records: readonly Uint8Array[]; + /** File entries the directory lists, directories left out. */ + readonly count: number; +} + +/** Where the directory ends, how long it is and the offset it declares, from the end record and any Zip64 one. */ +const directoryEnd = (view: DataView) => { + const window = Math.max( + 0, + view.byteLength - ZIP_EOCD_BYTES - ZIP_MAX_COMMENT_BYTES, + ); + const found = zipEndOfCentralDirectoryCandidates( + new Uint8Array( + view.buffer, + view.byteOffset + window, + view.byteLength - window, + ), + ).next(); + if (found.done === true) return undefined; + const end = window + found.value.offset; + const { totalEntries, directorySize, directoryOffset } = found.value; + const classic = { end, size: directorySize, declaredOffset: directoryOffset }; + const record = end - ZIP64_LOCATOR_BYTES - ZIP64_END_BYTES; + // Like Python's zipfile, a Zip64 locator is followed whether or not the classic record defers to it. + if ( + record < 0 || + view.getUint32(end - ZIP64_LOCATOR_BYTES, true) !== ZIP64_LOCATOR + ) + return classic; + // Read just before its locator, where zipfile and zip.js look when the locator's own offset holds no Zip64 record. + if (view.getUint32(record, true) === ZIP64_END) + return { + end: record, + size: Number(view.getBigUint64(record + 40, true)), + declaredOffset: Number(view.getBigUint64(record + 48, true)), + }; + // Without one the classic values stand, as zip.js reads them, unless they are sentinels deferring to it. + const deferred = + totalEntries === 0xffff || + directorySize === 0xffffffff || + directoryOffset === 0xffffffff; + return deferred ? undefined : classic; +}; + +/** Where one record's name and extra field lie in the archive, and its flags. */ +interface RecordName { + readonly name: number; + readonly extra: number; + readonly comment: number; + readonly flags: number; +} + +/** An Info-ZIP Unicode Path when zip.js uses it: version 1, no UTF-8 flag, and the CRC-32 of the stored name. */ +const unicodePath = ( + bytes: Uint8Array, + view: DataView, + { name, extra, comment, flags }: RecordName, +): Uint8Array | undefined => { + for (let at = extra; at + 4 <= comment;) { + const length = view.getUint16(at + 2, true); + if (view.getUint16(at, true) === UNICODE_PATH) + return length >= 5 && + at + 4 + length <= comment && + bytes[at + 4] === 1 && + (flags & UTF8_NAME) === 0 && + view.getUint32(at + 5, true) === crc32(bytes.subarray(name, extra)) + ? bytes.subarray(at + 9, at + 4 + length) + : undefined; + at += 4 + length; + } + return undefined; +}; + +/** + * Whether an entry's name, as REA decodes it, ends in a slash; undefined when + * it must be UTF-8 and is not. A plain CP437 name is judged by its last byte, + * so most records are read without allocating. + */ +const endsInSlash = ( + bytes: Uint8Array, + view: DataView, + record: RecordName, +): boolean | undefined => { + const unicode = + record.comment > record.extra + ? unicodePath(bytes, view, record) + : undefined; + const utf8 = + unicode ?? + ((record.flags & UTF8_NAME) === 0 + ? undefined + : bytes.subarray(record.name, record.extra)); + if (utf8 === undefined) + return record.extra > record.name && bytes[record.extra - 1] === 0x2f; + try { + return STRICT_UTF8.decode(utf8).endsWith("/"); + } catch { + return undefined; + } +}; + +/** + * Read an archive's central directory a record at a time, keeping the first + * `keep` file entries' records and counting the rest, so no more than those is + * ever parsed into entries. Directories are told apart as zip.js tells them: a + * final slash, a Unix directory mode, or the MS-DOS attribute. Undefined when + * the directory cannot be found or a record in it is malformed. + */ +export const scanZipDirectory = ( + bytes: Uint8Array, + keep: number, +): ZipDirectory | undefined => { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const located = directoryEnd(view); + if (located === undefined) return undefined; + const start = located.end - located.size; + if (start < 0) return undefined; + const records: Uint8Array[] = []; + let count = 0; + for (let at = start; at < located.end;) { + if ( + at + CENTRAL_HEADER_BYTES > located.end || + view.getUint32(at, true) !== CENTRAL_HEADER + ) + return undefined; + const name = at + CENTRAL_HEADER_BYTES; + const extra = name + view.getUint16(at + 28, true); + const comment = extra + view.getUint16(at + 30, true); + const next = comment + view.getUint16(at + 32, true); + if (next > located.end) return undefined; + const slash = endsInSlash(bytes, view, { + name, + extra, + comment, + flags: view.getUint16(at + 8, true), + }); + if (slash === undefined) return undefined; + const external = view.getUint32(at + 38, true); + const directory = + slash || + ((external >>> 16) & UNIX_TYPE) === UNIX_DIRECTORY || + (view.getUint16(at + 4, true) >> 8 === 0 && + (external & MSDOS_DIRECTORY) !== 0); + if (!directory) { + count += 1; + if (records.length < keep) records.push(bytes.subarray(at, next)); + } + at = next; + } + return { start, declaredOffset: located.declaredOffset, records, count }; +}; + +/** The kept records and an end record for them alone, which follow the entries in the archive a reader is given. */ +export const boundedDirectoryTail = (directory: ZipDirectory): Uint8Array => { + const size = directory.records.reduce( + (total, record) => total + record.length, + 0, + ); + const tail = new Uint8Array(size + ZIP_EOCD_BYTES); + let at = 0; + for (const record of directory.records) { + tail.set(record, at); + at += record.length; + } + const end = new DataView(tail.buffer, size); + end.setUint32(0, 0x06054b50, true); + end.setUint16(8, directory.records.length, true); + end.setUint16(10, directory.records.length, true); + end.setUint32(12, size, true); + // A declared offset past 32 bits cannot be right for a view-sized archive; the start stands in for it. + end.setUint32( + 16, + directory.declaredOffset <= 0xffffffff + ? directory.declaredOffset + : directory.start, + true, + ); + return tail; +}; diff --git a/src/domain/desktop/zipView.ts b/src/domain/desktop/zipView.ts new file mode 100644 index 0000000000..e76a46c21f --- /dev/null +++ b/src/domain/desktop/zipView.ts @@ -0,0 +1,200 @@ +import { createHash } from "node:crypto"; + +import type { JsonValue } from "../jsonValue.js"; +import { err, ok, type Result } from "../result.js"; +import { + detectStateFormat, + textualView, + type DesktopStateFormat, +} from "./stateFormats.js"; +import { locatePointer } from "./viewPointer.js"; + +const MIB = 1024 * 1024; +/** Entries a zip view lists; past them it counts entries and is truncated. */ +export const MAX_ZIP_ENTRIES = 1000; +/** Largest entry read, uncompressed. */ +const MAX_ZIP_ENTRY_BYTES = 16 * MIB; +/** Uncompressed bytes read from one archive, the same as the largest file the state view reads. */ +const MAX_ZIP_BYTES = 64 * MIB; +/** Most an entry may expand over its compressed size; a zip bomb expands about a thousand times. */ +const MAX_ZIP_RATIO = 100; + +/** Why an archive has no view; the exported reader words it the same. */ +export const UNREADABLE_ZIP = "not a readable zip archive"; +/** Why an entry whose data fails its checks has no view. */ +export const DAMAGED_ZIP_ENTRY = "its compressed data is damaged"; + +/** What the central directory says of one entry, before it is read. */ +export interface ZipEntryHeader { + readonly name: string; + readonly size: number; + readonly compressedSize: number; + readonly method: number; + readonly encrypted: boolean; +} + +/** Why an entry is not read, checked in this order by both readers, or undefined; `spent` is the archive's bytes read so far. */ +export const zipEntryRefusal = ( + header: ZipEntryHeader, + spent: number, +): string | undefined => { + if (header.encrypted) return "encrypted, so it is not read"; + if (header.method !== 0 && header.method !== 8) + return `compressed with method ${String(header.method)}, which the view does not read`; + if (header.size > MAX_ZIP_ENTRY_BYTES) + return `larger than the ${String(MAX_ZIP_ENTRY_BYTES / MIB)} MiB an entry's view reads`; + if (header.size > MAX_ZIP_RATIO * header.compressedSize) + return `expands more than ${String(MAX_ZIP_RATIO)} times, so it is not read`; + if (spent + header.size > MAX_ZIP_BYTES) + return `past the ${String(MAX_ZIP_BYTES / MIB)} MiB read from one archive`; + return undefined; +}; + +/** An entry that was not read: its name and size, and why. */ +export const unreadZipEntry = ( + header: ZipEntryHeader, + error: string, +): JsonValue => ({ + name: header.name, + size: header.size, + sha256: null, + format: null, + view: null, + view_error: error, +}); + +/** An entry's view for its format; one whose reader needs a tool, a worker or another archive opened is not read. */ +const entryView = ( + format: DesktopStateFormat, + bytes: Uint8Array, +): Result => { + switch (format) { + case "text": + case "json": + case "keyfile": + case "xml": + return textualView(format, bytes); + case "sqlite": + return err("a SQLite database inside an archive is not read"); + case "dconf": + return err("a dconf database inside an archive is not read"); + case "zip": + return err("an archive inside an archive is not read"); + case "binary": + return ok(null); + } +}; + +/** An entry read from its uncompressed bytes by the readers and limits a file's view uses. */ +export const zipEntry = ( + header: ZipEntryHeader, + bytes: Uint8Array, +): JsonValue => { + const format = detectStateFormat(header.name, bytes); + const read = entryView(format, bytes); + return { + name: header.name, + size: header.size, + sha256: createHash("sha256").update(bytes).digest("hex"), + format, + view: read.ok ? read.value : null, + view_error: read.ok ? null : read.error, + }; +}; + +/** A zip archive's view: its file entries in archive order, up to the listing limit, and how many it has. */ +export const zipView = ( + entries: readonly JsonValue[], + count: number, +): JsonValue => ({ + entries: [...entries], + entry_count: count, + truncated: count > entries.length, +}); + +const isRecord = ( + value: JsonValue | null | undefined, +): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** Whether an entry's view is missing or holds only part of it. */ +const entryLeavesOut = (entry: JsonValue): boolean => + isRecord(entry) && + (typeof entry.view_error === "string" || + ((entry.format === "text" || entry.format === "xml") && + isRecord(entry.view) && + entry.view.truncated === true)); + +/** Whether a zip view leaves anything out: entries past its listing, or what an entry's view lacks. */ +export const zipLeavesOut = (view: JsonValue | null): boolean => + isRecord(view) && + (view.truncated === true || + (Array.isArray(view.entries) && view.entries.some(entryLeavesOut))); + +/** The entry a pointer into a zip view goes into, `none` when it names an entry the view lacks, or undefined when it names none. */ +export const zipEntryAt = ( + view: JsonValue | null, + pointer: string, +): Record | "none" | undefined => { + const [first, second] = pointer.slice(1).split("/"); + if (first !== "entries" || second === undefined) return undefined; + const entry = locatePointer(view, `/entries/${second}`); + if (entry.kind !== "value") return "none"; + return isRecord(entry.value) ? entry.value : undefined; +}; + +/** Formats an entry's view is read in; any other entry's `view` is `null` for want of one. */ +const VIEW_FORMATS: ReadonlySet = new Set([ + "text", + "json", + "keyfile", + "xml", +]); + +/** Why an entry has no view a check can read: binary, unread, or of a format with none; undefined when it has one. */ +export const entryWithoutView = ( + entry: Record, +): string | undefined => { + if (typeof entry.view_error === "string") + return `has no view: ${entry.view_error}`; + if (entry.format === "binary") return "is binary and has no view"; + return VIEW_FORMATS.has(entry.format ?? null) ? undefined : "has no view"; +}; + +/** The entry whose view a pointer goes into when it has none, so its `view` of `null` is no JSON value, and why. */ +export const viewlessEntryAt = ( + view: JsonValue | null, + pointer: string, +): { readonly name: string; readonly reason: string } | undefined => { + const entry = zipEntryAt(view, pointer); + if (typeof entry !== "object" || pointer.split("/")[3] !== "view") + return undefined; + const reason = entryWithoutView(entry); + return reason === undefined + ? undefined + : { name: typeof entry.name === "string" ? entry.name : "", reason }; +}; + +/** + * Whether a zip view leaves out what a pointer reaches: for a pointer into one + * listed entry, only that entry; for an entry not listed, the listing; else anything. + */ +export const zipLeavesOutAt = ( + view: JsonValue | null, + pointer: string, +): boolean => { + const entry = zipEntryAt(view, pointer); + if (entry === undefined) return zipLeavesOut(view); + return entry === "none" + ? isRecord(view) && view.truncated === true + : entryLeavesOut(entry); +}; + +/** Whether an array in a zip view may lack members: the listing when it is cut, or an array in an entry whose view is. */ +export const zipArrayLeavesOut = ( + view: JsonValue | null, + array: string, +): boolean => + array === "/entries" + ? isRecord(view) && view.truncated === true + : zipLeavesOutAt(view, array); diff --git a/src/domain/xmlDocument.ts b/src/domain/xmlDocument.ts index 300f88136e..532abce214 100644 --- a/src/domain/xmlDocument.ts +++ b/src/domain/xmlDocument.ts @@ -20,6 +20,32 @@ export class XmlDocumentParseError extends Error { } } +/** Read XML syntax as a stream, throwing at the first error; it holds only the open elements. */ +const checkXmlSyntax = (source: string, doctype: "allow" | "reject"): void => { + const syntax = new SaxesParser({ + xmlns: true, + defaultXMLVersion: "1.0", + forceXMLVersion: true, + }); + syntax.on("doctype", () => { + if (doctype === "reject") throw new XmlDocumentParseError("doctype"); + }); + syntax.write(source).close(); +}; + +/** Whether XML passes the syntax check `parseXmlDocument` makes, without building a document. */ +export const isWellFormedXml = ( + source: string, + policy: { readonly doctype: "allow" | "reject" }, +): boolean => { + try { + checkXmlSyntax(source, policy.doctype); + return true; + } catch { + return false; + } +}; + /** One boundary owns strict XML syntax and the source-located DOM projection. */ export const parseXmlDocument = ( source: string, @@ -29,16 +55,7 @@ export const parseXmlDocument = ( }, ): Result => { try { - const syntax = new SaxesParser({ - xmlns: true, - defaultXMLVersion: "1.0", - forceXMLVersion: true, - }); - syntax.on("doctype", () => { - if (policy.doctype === "reject") - throw new XmlDocumentParseError("doctype"); - }); - syntax.write(source).close(); + checkXmlSyntax(source, policy.doctype); // Saxes owns well-formedness outside DTDs. The DOM parser additionally // checks DTD syntax and builds source-located nodes; its advisory warnings // (including literal U+FFFD) are not byte-decoding or syntax failures. diff --git a/tests/boundary/cli/desktopCaptureCli.test.ts b/tests/boundary/cli/desktopCaptureCli.test.ts index e79ae1191c..22b232eae3 100644 --- a/tests/boundary/cli/desktopCaptureCli.test.ts +++ b/tests/boundary/cli/desktopCaptureCli.test.ts @@ -1,7 +1,62 @@ +import { join } from "node:path"; + import { expect, it } from "vitest"; +import { + EDITED_NOTE, + seedEndCapture, + writeCaptureFolder, +} from "../../../src/application/desktop/desktopCapture.fixture.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { serveCli as serve } from "./cliServeHarness.js"; +it("exports and drafts between the checkpoints the command names", async () => { + const capture = await writeCaptureFolder(seedEndCapture(), undefined, [ + EDITED_NOTE, + ]); + const ends = ["--seed-checkpoint", "seed", "--end-checkpoint", "end"]; + const bundle = join(await createTestTempDirectory("desktop-cli-"), "bundle"); + + const exported = await serve([ + "export-desktop-application", + capture, + bundle, + ...ends, + "--json", + ]); + const drafted = await serve([ + "describe-desktop-application-task", + capture, + ...ends, + "--json", + ]); + const refused = await serve([ + "describe-desktop-application-task", + capture, + "--end-checkpoint", + "saved", + "--json", + ]); + + expect( + parseEvidence(JSON.parse(exported.stdout)).normalized_result, + ).toMatchObject({ + checkpoints: { seed: "seed", end: "end" }, + seed_files: [".config/notes/settings.ini", "Documents/today.txt"], + }); + expect( + parseEvidence(JSON.parse(drafted.stdout)).normalized_result, + ).toMatchObject({ checkpoints: { baseline: "seed", final: "end" } }); + expect(JSON.parse(refused.stdout)).toMatchObject({ + code: "invalid_request", + details: { + operation: "describe_desktop_application_task", + issues: [{ path: ["end_checkpoint"] }], + }, + }); +}); + it.each([ [["--exclude", ""], "exclude"], [["--state-path", ""], "statePath"], diff --git a/tests/boundary/providers/desktopExportPython.test.ts b/tests/boundary/providers/desktopExportPython.test.ts index 72cd6a9b84..57ec3b6621 100644 --- a/tests/boundary/providers/desktopExportPython.test.ts +++ b/tests/boundary/providers/desktopExportPython.test.ts @@ -10,8 +10,11 @@ import { } from "../../../src/domain/desktop/appExportPython.js"; import { detectStateFormat, + isJsonText, jsonView, + textualView, } from "../../../src/domain/desktop/stateFormats.js"; +import { safeParseJson } from "../../../src/domain/safeJson.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; /** The exported module's view format for each file, run with only its standard library imports. */ @@ -21,7 +24,7 @@ const pythonFormats = (files: readonly (readonly [string, string])[]) => { .join("\n"); const program = `${imports}${PYTHON_HELPERS} for name, text in json.load(sys.stdin): - print(_text_view(name, text)["format"]) + print(_text_view(name, text, len(text.encode()))["format"]) `; return execFileSync("python3", ["-I", "-B", "-c", program], { input: JSON.stringify(files), @@ -75,7 +78,7 @@ const pythonUtf16Respelled = (files: readonly string[]) => { .filter((line) => !line.includes("agentenv_protocol")) .join("\n"); const program = `${imports} -CAPTURED_HOME = "/home/ada" +SEED_HOME = "/home/ada" ${PYTHON_HELPERS} for data in json.load(sys.stdin): print(_respelled_utf16(bytes.fromhex(data), Path("/home/user")).hex()) @@ -145,7 +148,7 @@ const taskbarWindow = (bin: string, windowClass: string): string => { "-I", "-B", "-c", - `${imports}\nCAPTURED_HOME = "/home/ada"\n${PYTHON_HELPERS}\nprint(_taskbar_window(sys.argv[1]))`, + `${imports}\nSEED_HOME = "/home/ada"\n${PYTHON_HELPERS}\nprint(_taskbar_window(sys.argv[1]))`, windowClass, ], { @@ -189,7 +192,7 @@ const pythonJson = (texts: readonly string[]) => { .join("\n"); const program = `${imports}${PYTHON_HELPERS} for text in json.load(sys.stdin): - print(json.dumps(_text_view("a.json", text))) + print(json.dumps(_text_view("a.json", text, len(text.encode())))) `; return execFileSync("python3", ["-I", "-B", "-c", program], { input: JSON.stringify(texts), @@ -224,6 +227,159 @@ it("refuses the JSON numbers REA's state view refuses, as JSON with an error rat }); }); +it("refuses JSON nested deeper than the 500 levels REA's view keeps, reading the boundary itself", () => { + const nested = (levels: number) => + `${"[".repeat(levels)}${"]".repeat(levels)}`; + const texts = [nested(501), nested(502), nested(1100)]; + + const python = pythonJson(texts); + + expect(texts.map((text) => jsonView(text).ok)).toEqual([true, false, false]); + texts.forEach((text, index) => { + const typescript = jsonView(text); + expect(python[index]).toEqual( + typescript.ok + ? { format: "json", view: typescript.value, truncated: false } + : { format: "json", view: null, error: typescript.error }, + ); + }); +}); + +/** The exported module's format, view and error for each named text, and REA's for the same bytes. */ +const textViews = (files: readonly (readonly [string, string])[]) => { + const imports = PYTHON_IMPORTS.split("\n") + .filter((line) => !line.includes("agentenv_protocol")) + .join("\n"); + const program = `${imports}${PYTHON_HELPERS} +for name, text in json.load(sys.stdin): + view = _text_view(name, text, len(text.encode())) + print(json.dumps({"format": view["format"], "view": view["view"], "error": view.get("error")})) +`; + const python = execFileSync("python3", ["-I", "-B", "-c", program], { + input: JSON.stringify(files), + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as unknown); + const rea = files.map(([name, text]) => { + const bytes = new TextEncoder().encode(text); + const format = detectStateFormat(name, bytes); + if (format !== "text" && format !== "json" && format !== "keyfile") + throw new Error(`${name} reads as ${format}`); + const read = textualView(format, bytes); + return read.ok + ? { format, view: read.value, error: null } + : { format, view: null, error: read.error }; + }); + return { python, rea }; +}; + +it("reads text nested past Python's recursion limit as JSON only when all of it is, as REA does", () => { + const brackets = (open: number, close: number) => + `${"[".repeat(open)}${"]".repeat(close)}`; + const files = [ + ["deep.txt", brackets(1100, 1099)], + ["deep.json", brackets(1100, 1099)], + ["deep.txt", brackets(1100, 1100)], + ["deep.json", `${brackets(1100, 1100)} x`], + ] as const; + + const { python, rea } = textViews(files); + + expect(python).toEqual(rea); + expect(rea.map(({ format, error }) => [format, error])).toEqual([ + ["text", null], + ["text", null], + ["json", "nested deeper than the 500 levels a JSON view keeps"], + ["text", null], + ]); +}); + +it("refuses JSON and key files over 4 MiB as REA does, telling JSON apart from text without parsing it", () => { + const objects = `[{}${",{}".repeat(1_400_000)}]`; + const files = [ + ["objects.json", objects], + ["objects", `${objects.slice(0, -1)}}`], + ["settings", `[ui]\n${"wrap=false\n".repeat(400_000)}`], + ["log", `[0] ${"x".repeat(4_200_000)}`], + ] as const; + + const { python, rea } = textViews(files); + + expect(python).toEqual(rea); + const refused = "larger than the 4 MiB limit for JSON and key file views"; + expect(rea.map(({ format, error }) => [format, error])).toEqual([ + ["json", refused], + ["text", null], + ["keyfile", refused], + ["text", null], + ]); +}); + +it("checks text for JSON the way REA does, token by token, without parsing it", () => { + const [bom, nbsp, lineSeparator] = [0xfeff, 0xa0, 0x2028].map((code) => + String.fromCharCode(code), + ); + const texts = [ + ["-0", "01", "1.", "1E+5", "-", "[1.5e+]", "truex", "[NaN]"], + ['"\\u00e9"', '"\\u00g9"', '"\\x"', '"\t"', '"a', '"\\uD800"'], + ["", " [1] ", "[1] x", "{}{}", `${bom}[1]`, `[1]${nbsp}`], + [`["${lineSeparator}"]`, "[]", "[1,]", "[,1]", "[1 2]", "[1]]", "[[1]"], + ["[}", "{}", '{"a":1}', '{"a" 1}', "{a:1}", '{"a":1,}', '{"a"::1}'], + ["{1:2}", '{"a":[1,{"b":null}],"c":-1.5e-3}'], + ].flat(); + const imports = PYTHON_IMPORTS.split("\n") + .filter((line) => !line.includes("agentenv_protocol")) + .join("\n"); + + const python = JSON.parse( + execFileSync( + "python3", + [ + "-I", + "-B", + "-c", + `${imports}${PYTHON_HELPERS}\nprint(json.dumps([_is_json(text) for text in json.load(sys.stdin)]))`, + ], + { input: JSON.stringify(texts), encoding: "utf8" }, + ), + ) as boolean[]; + + const parsed = texts.map((text) => safeParseJson(text).ok); + expect(texts.map((text) => isJsonText(text))).toEqual(parsed); + expect(python).toEqual(parsed); + expect(parsed.filter(Boolean)).toHaveLength(10); +}); + +it("checks JSON depth with one iterator per level, copying no wide list", () => { + const imports = PYTHON_IMPORTS.split("\n") + .filter((line) => !line.includes("agentenv_protocol")) + .join("\n"); + const program = `${imports} +import tracemalloc +${PYTHON_HELPERS} +wide = [None] * 800_000 +tracemalloc.start() +deep = _deeper_than(wide, 500) +peak = tracemalloc.get_traced_memory()[1] +tracemalloc.stop() +nested = [] +for _ in range(501): + nested = [nested] +print(json.dumps({"deep": deep, "peak": peak, "edge": _deeper_than(nested, 501), "over": _deeper_than(nested, 500)})) +`; + + const result = JSON.parse( + execFileSync("python3", ["-I", "-B", "-c", program], { encoding: "utf8" }), + ) as { deep: boolean; peak: number; edge: boolean; over: boolean }; + + expect(result).toMatchObject({ deep: false, edge: false, over: true }); + // Queuing each member of the 800,000-item list traced about 51 MB. + expect(result.peak).toBeLessThan(1024 * 1024); +}); + it("spells infinite and negative-zero SQLite reals as REA's SQLite reader does", () => { const imports = PYTHON_IMPORTS.split("\n") .filter((line) => !line.includes("agentenv_protocol")) diff --git a/tests/boundary/providers/desktopStateHeap.test.ts b/tests/boundary/providers/desktopStateHeap.test.ts new file mode 100644 index 0000000000..2f3431aa98 --- /dev/null +++ b/tests/boundary/providers/desktopStateHeap.test.ts @@ -0,0 +1,142 @@ +import { execFileSync } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const ROOT = join(import.meta.dirname, "../../.."); + +/** What a script using one export of a desktop state module prints, run in a 32 MiB V8 old space. */ +const inSmallHeap = async (module: string, name: string, body: string) => { + const folder = await createTestTempDirectory("desktop-state-heap-"); + const script = join(folder, "run.mjs"); + const url = pathToFileURL(join(ROOT, "src/domain/desktop", module)).href; + await writeFile( + script, + `import { ${name} } from ${JSON.stringify(url)};\n${body}`, + ); + const output = execFileSync( + process.execPath, + [ + "--max-old-space-size=32", + "--max-semi-space-size=1", + "--import", + "tsx", + script, + ], + { cwd: ROOT, encoding: "utf8", timeout: 120_000 }, + ); + return JSON.parse(output) as unknown; +}; + +it("checks JSON depth with one iterator per level, so a wide array fits a small heap", async () => { + // Queuing a pair per member needed more than 64 MiB here; one iterator per level runs in 18 MiB. + const depths = await inSmallHeap( + "stateFormats.ts", + "deeperThan", + `const wide = new Array(800_000).fill(null); +let nested = []; +for (let level = 0; level < 501; level += 1) nested = [nested]; +console.log(JSON.stringify({ wide: deeperThan(wide, 500), edge: deeperThan(nested, 501), over: deeperThan(nested, 500) })); +`, + ); + + expect(depths).toEqual({ wide: false, edge: false, over: true }); +}, 180_000); + +it("pairs elements with a long tag and many attributes in a small heap, spelling a selector only once chosen", async () => { + // Spelling the tag into a selector per attribute needed more than 140 MiB here. + const pairing = await inSmallHeap( + "viewPointer.ts", + "pairMembers", + `const tag = "t".repeat(40_000); +const element = (id) => { + const attributes = { id }; + for (let index = 0; index < 2000; index += 1) attributes["a" + index] = String(index); + return { name: tag, attributes, children: [] }; +}; +const members = [element("1"), element("2")]; +const { newer, older } = pairMembers(members, members); +console.log(JSON.stringify({ keys: newer.keys.map((key) => key.replace(tag, "T")), partners: newer.partners, same: older.keys.every((key, index) => key === newer.keys[index]) })); +`, + ); + + expect(pairing).toEqual({ + keys: ["[name=T][@id=1]", "[name=T][@id=2]"], + partners: [0, 1], + same: true, + }); +}, 180_000); + +it("refuses JSON over the parsed-view limit in a small heap, telling it apart from text without building it", async () => { + // Parsing the entry to detect its format needed more than 80 MiB here. + const entries = await inSmallHeap( + "zipView.ts", + "zipEntry", + `const read = (name, text) => { + const bytes = new TextEncoder().encode(text); + const entry = zipEntry({ name, size: bytes.length, compressedSize: bytes.length, method: 0, encrypted: false }, bytes); + return [entry.format, entry.view_error ?? entry.view.line_count]; +}; +console.log(JSON.stringify([ + read("objects.json", "[{}" + ",{}".repeat(1_400_000) + "]"), + read("objects.txt", "[{}" + ",{}".repeat(1_400_000) + "}"), +])); +`, + ); + + expect(entries).toEqual([ + ["json", "larger than the 4 MiB limit for JSON and key file views"], + ["text", 1], + ]); +}, 180_000); + +it("scrubs UTF-16 XML and JSON seeds of a million elements in a small heap, telling their format without building them", async () => { + // Building the XML document to choose the scrubber needed more than 768 MiB here, and parsing the JSON more than 64 MiB; the syntax checks and copying only changes run in 24 MiB. + const outcomes = await inSmallHeap( + "seedScrub.ts", + "scrubSeed", + `const scrub = (home, text, tail, clean) => { + const seed = Buffer.from("\\uFEFF" + text + tail, "utf16le"); + const scrubbed = scrubSeed(seed, { home, format: "binary" }, { home: "/home/ada", hostname: "e81232be457d", user: "ada" }); + const expected = Buffer.concat([seed.subarray(0, -2 * tail.length), Buffer.from(clean, "utf16le")]); + return { exact: scrubbed.bytes?.equals(expected), holdsIdentity: scrubbed.holdsIdentity }; +}; +console.log(JSON.stringify([ + scrub(".config/wide.xml", "\\n" + "".repeat(1_000_000), "ada@e81232be457d", "user@localhost"), + scrub(".config/wide.json", "[{}" + ",{}".repeat(1_000_000), ',"ada@e81232be457d"]', ',"user@localhost"]'), +])); +`, + ); + + expect(outcomes).toEqual([ + { exact: true, holdsIdentity: false }, + { exact: true, holdsIdentity: false }, + ]); +}, 180_000); + +it("compares objects of selector keys with arrays in a small heap, however many field sets or spellings they use", async () => { + // An index per field set or spelling needed more than 48 MiB for either comparison here. + const removed = await inSmallHeap( + "stateChanges.ts", + "diffStateViews", + `const fields = ["f0", "f1", "f2", "f3", "f4", "f5", "f6"]; +const members = Array.from({ length: 5000 }, (_, i) => Object.fromEntries(fields.map((f) => [f, f + "-" + i]))); +const combinations = {}; +for (let mask = 1; mask < 128; mask += 1) + combinations[fields.filter((_, bit) => mask & (1 << bit)).map((f) => "[" + f + "=" + f + "-0]").join("")] = mask; +const named = Array.from({ length: 800 }, (_, i) => ({ name: "n" + i })); +const repeated = {}; +for (let count = 1; count <= 160; count += 1) repeated["[name=n0]".repeat(count)] = count; +console.log(JSON.stringify([ + diffStateViews("json", { items: combinations }, { items: members }), + diffStateViews("json", { items: repeated }, { items: named }), +].map((difference) => difference.removed_count))); +`, + ); + + expect(removed).toEqual([0, 0]); +}, 180_000); diff --git a/tests/boundary/providers/desktopXmlPython.test.ts b/tests/boundary/providers/desktopXmlPython.test.ts new file mode 100644 index 0000000000..b3c699e483 --- /dev/null +++ b/tests/boundary/providers/desktopXmlPython.test.ts @@ -0,0 +1,198 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import type { DesktopTaskCheck } from "../../../src/domain/desktop/appTask.js"; +import { xmlView } from "../../../src/domain/desktop/stateFormats.js"; +import { zipArchive } from "../../../src/domain/desktop/zipArchive.fixture.js"; +import { + expectSameCheckResults, + expectSameFileViews, + pythonXmlViews, + typescriptFileView, +} from "../../support/desktopExportPython.js"; + +const FIXTURES = join(import.meta.dirname, "../../fixtures"); +const ENDS = ["before", "after"] as const; + +/** Inkscape's saves of the poster, before and after the headline was changed. */ +const posters = () => + Promise.all( + ENDS.map(async (end) => ({ + name: "poster.svg", + bytes: await readFile(join(FIXTURES, "inkscape", end, "poster.svg")), + })), + ); + +/** LibreOffice Writer's saves of the flyer, before and after the date was changed, zipped with their mimetype. */ +const flyers = () => + Promise.all( + ENDS.map(async (end) => ({ + name: "flyer.odt", + bytes: zipArchive([ + { + name: "mimetype", + data: "application/vnd.oasis.opendocument.text", + method: 0, + }, + { + name: "content.xml", + data: await readFile( + join(FIXTURES, "libreoffice", end, "content.xml"), + ), + }, + ]), + })), + ); + +const POSTER = "Pictures/poster.svg"; +const FLYER = "Documents/flyer.odt"; + +/** The poster's checks: its headline's text, then a setting Inkscape keeps in a namespaced attribute. */ +const POSTER_CHECKS: DesktopTaskCheck[] = [ + { + id: "headline", + kind: "value_equals", + file: POSTER, + pointer: + "/root/children/[name=g][@inkscape:label=Layer 1]/children/[name=text][@id=headline]/children/[name=tspan]/children/0", + value: "Summer Bake Sale", + }, + { + id: "units", + kind: "value_equals", + file: POSTER, + pointer: + "/root/children/[name=sodipodi:namedview]/attributes/inkscape:document-units", + value: "mm", + }, +]; + +const DATE: DesktopTaskCheck = { + id: "date", + kind: "value_equals", + file: FLYER, + pointer: + "/entries/[name=content.xml]/view/root/children/[name=office:body]/children/[name=office:text]/children/[name=text:p][@text:style-name=Subtitle]/children/0", + value: "Monday 24 November", +}; + +const passes = (outcomes: readonly { passed: boolean }[] | undefined) => + outcomes?.map(({ passed }) => passed); + +describe("namespaced XML in the exported module", () => { + it("reads an Inkscape SVG as REA does: prefixed names, the default namespace and its declarations", async () => { + const files = await posters(); + await expectSameFileViews(files); + + const [, after] = files; + if (after === undefined) throw new Error("fixture"); + const { view } = await typescriptFileView(after); + expect(view).toMatchObject({ + root: { + name: "svg", + attributes: { + xmlns: "http://www.w3.org/2000/svg", + "xmlns:svg": "http://www.w3.org/2000/svg", + "xmlns:sodipodi": + "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd", + "sodipodi:docname": "poster.svg", + }, + children: [{ name: "defs" }, { name: "sodipodi:namedview" }, {}], + }, + truncated: false, + }); + }); + + it("checks the poster's headline by name: failing before the edit and passing after, in both evaluators", async () => { + const [before, after] = await expectSameCheckResults( + POSTER, + POSTER_CHECKS, + await posters(), + ); + + expect(passes(before)).toEqual([false, true]); + expect(before?.[0]?.description).toContain( + 'is "Spring Bake Sale", not "Summer Bake Sale"', + ); + expect(passes(after)).toEqual([true, true]); + }); + + it("reads a Writer document's content.xml as REA does, in its archive", async () => { + const files = await flyers(); + await expectSameFileViews(files); + + const [, after] = files; + if (after === undefined) throw new Error("fixture"); + const { view } = await typescriptFileView(after); + expect(view).toMatchObject({ + entries: [ + { name: "mimetype", format: "text" }, + { + name: "content.xml", + format: "xml", + view: { + root: { + name: "office:document-content", + attributes: { + "xmlns:text": "urn:oasis:names:tc:opendocument:xmlns:text:1.0", + "office:version": "1.3", + }, + }, + }, + }, + ], + }); + }); + + it("checks the flyer's date by name: failing before the edit and passing after, in both evaluators", async () => { + const [before, after] = await expectSameCheckResults( + FLYER, + [DATE], + await flyers(), + ); + + expect(passes(before)).toEqual([false]); + expect(before?.[0]?.description).toContain( + 'is "Monday 17 November", not "Monday 24 November"', + ); + expect(passes(after)).toEqual([true]); + }); + + it("splits text, CDATA and comments, spells xml: and xmlns names, and refuses documents, as REA does", () => { + const texts = [ + '', + "xzwv", + ' ', + "", + "", + '', + '', + '', + "", + "", + ]; + + const ours = texts.map((text) => xmlView(text) ?? null); + + expect(pythonXmlViews(texts)).toEqual(ours); + expect(ours.slice(4)).toEqual([null, null, null, null, null, null]); + expect(ours[1]).toMatchObject({ + root: { children: ["x", "y", "zw", "v", "q", "r"] }, + }); + }); + + it("keeps REA's node and depth limits on prefixed names", () => { + const declared = 'xmlns:p="urn:u"'; + const texts = [ + `${"".repeat(5000)}`, + `${"".repeat(70)}${"".repeat(70)}`, + ]; + + const ours = texts.map((text) => xmlView(text) ?? null); + + expect(pythonXmlViews(texts)).toEqual(ours); + expect(ours).toMatchObject([{ truncated: true }, { truncated: true }]); + }); +}); diff --git a/tests/boundary/providers/desktopZipHeap.test.ts b/tests/boundary/providers/desktopZipHeap.test.ts new file mode 100644 index 0000000000..498735ca4e --- /dev/null +++ b/tests/boundary/providers/desktopZipHeap.test.ts @@ -0,0 +1,50 @@ +import { execFileSync } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { expect, it } from "vitest"; + +import { emptyEntriesArchive } from "../../../src/domain/desktop/zipArchive.fixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const ROOT = join(import.meta.dirname, "../../.."); + +it("reads a 600,000-entry archive's view in a small heap, parsing no more of its directory than it lists", async () => { + const folder = await createTestTempDirectory("desktop-zip-heap-"); + const archive = join(folder, "wide.zip"); + await writeFile(archive, emptyEntriesArchive(600_000)); + const script = join(folder, "read.mjs"); + const reader = pathToFileURL( + join(ROOT, "src/application/desktop/DesktopZipView.ts"), + ).href; + await writeFile( + script, + `import { readFileSync } from "node:fs"; +import { readZipView } from ${JSON.stringify(reader)}; +const read = await readZipView(readFileSync(process.argv[2])); +if (!read.ok) throw new Error(read.error); +console.log(JSON.stringify({ listed: read.value.entries.length, count: read.value.entry_count, truncated: read.value.truncated })); +`, + ); + + // zip.js parsing every record needed between 48 and 64 MiB here; the bounded directory reads in 24 MiB. + const output = execFileSync( + process.execPath, + [ + "--max-old-space-size=36", + "--max-semi-space-size=1", + "--import", + "tsx", + script, + archive, + ], + { cwd: ROOT, encoding: "utf8", timeout: 120_000 }, + ); + + expect(JSON.parse(output)).toEqual({ + listed: 1000, + count: 600_000, + truncated: true, + }); +}, 180_000); diff --git a/tests/boundary/providers/desktopZipPython.test.ts b/tests/boundary/providers/desktopZipPython.test.ts new file mode 100644 index 0000000000..deaf1d0ae1 --- /dev/null +++ b/tests/boundary/providers/desktopZipPython.test.ts @@ -0,0 +1,477 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { crc32 } from "node:zlib"; + +import { describe, expect, it } from "vitest"; + +import { readZipView } from "../../../src/application/desktop/DesktopZipView.js"; +import type { DesktopTaskCheck } from "../../../src/domain/desktop/appTask.js"; +import type { JsonValue } from "../../../src/domain/jsonValue.js"; +import { detectStateFormat } from "../../../src/domain/desktop/stateFormats.js"; +import { + emptyEntriesArchive, + odtArchive, + withZip64End, + zipArchive, + type ZipMember, +} from "../../../src/domain/desktop/zipArchive.fixture.js"; +import { zipEntryRefusal } from "../../../src/domain/desktop/zipView.js"; +import { + expectSameCheckResults, + expectSameFileViews, + exportedPython, + pythonFileViews, + writeFiles, +} from "../../support/desktopExportPython.js"; + +const MIB = 1024 * 1024; +const FIXTURES = join(import.meta.dirname, "../../fixtures/freecad"); + +/** Archives as FreeCAD documents under the home. */ +const saved = (archives: readonly Buffer[]) => + archives.map((bytes) => ({ name: "plate.FCStd", bytes })); + +const expectSameViews = (archives: readonly Buffer[]) => + expectSameFileViews(saved(archives)); + +/** REA's view of an archive, once the exported module's is shown to be the same. */ +const sameView = async (archive: Buffer) => { + await expectSameViews([archive]); + const read = await readZipView(archive); + if (!read.ok) throw new Error(read.error); + return read.value; +}; + +/** The exported module's zip view of an archive file, and the most memory reading it traced. */ +const pythonPeak = async (archive: Buffer) => + JSON.parse( + exportedPython( + `import tracemalloc +tracemalloc.start() +view = _view(Path(json.load(sys.stdin)[0]))["view"] +peak = tracemalloc.get_traced_memory()[1] +tracemalloc.stop() +print(json.dumps({"view": view, "peak": peak}))`, + await writeFiles(saved([archive])), + ), + ) as { + view: { entries: Record[]; entry_count: number }; + peak: number; + }; + +const sqliteHeader = Buffer.concat([ + Buffer.from("SQLite format 3\0"), + Buffer.alloc(84), +]); + +describe("zip views in the exported module", () => { + it("lists entries in archive order with the formats and views REA reads", async () => { + const members: ZipMember[] = [ + { name: "settings.json", data: '{"zoom": 1.5, "tags": ["a"]}' }, + { name: "app.ini", data: "[ui]\nwrap=false\n" }, + { name: "doc/", data: "" }, + { name: "doc/page.xml", data: 'Hello' }, + { name: "notes.txt", data: "one\ntwo\n", method: 0 }, + { name: "café.txt", data: "naïve\n" }, + { name: "image.png", data: Buffer.from([0x89, 0x50, 0, 1, 2]) }, + { name: "inner.zip", data: zipArchive([{ name: "x", data: "y" }]) }, + { name: "store.db", data: sqliteHeader }, + { name: "big.json", data: '{"id": 9007199254740993}' }, + ]; + + await expectSameViews([zipArchive(members)]); + const read = await readZipView(zipArchive(members)); + expect(read).toMatchObject({ + ok: true, + value: { entry_count: 9, truncated: false }, + }); + if (!read.ok) return; + expect(read.value).toMatchObject({ + entries: [ + { name: "settings.json", format: "json", view: { zoom: 1.5 } }, + { name: "app.ini", format: "keyfile" }, + { name: "doc/page.xml", format: "xml", view_error: null }, + { name: "notes.txt", format: "text", view: { lines: ["one", "two"] } }, + { name: "café.txt", format: "text" }, + { name: "image.png", format: "binary", view: null, view_error: null }, + { + name: "inner.zip", + format: "zip", + view_error: "an archive inside an archive is not read", + }, + { + name: "store.db", + format: "sqlite", + view_error: "a SQLite database inside an archive is not read", + }, + { + name: "big.json", + format: "json", + view_error: + "a number in it cannot be read exactly, such as an integer above 2^53", + }, + ], + }); + }); + + it("refuses the same entries for the same reasons, and an archive it cannot read", async () => { + // A short declared size with the checksum of that prefix still leaves data the entry does not declare. + const helCrc = crc32(Buffer.from("hel")); + const archive = zipArchive([ + { name: "secret.txt", data: "x", flags: 1 }, + { name: "bzip2.txt", data: "x", method: 12 }, + { name: "huge.txt", data: "x", method: 0, declaredSize: 17 * MIB }, + { name: "bomb.txt", data: "a".repeat(200_000) }, + { name: "crc.txt", data: "hello", crc: 1 }, + { name: "short.txt", data: "hello", declaredSize: 3 }, + { name: "prefix.txt", data: "hello", declaredSize: 3, crc: helCrc }, + { + name: "prefix-stored.txt", + data: "hello", + method: 0, + declaredSize: 3, + crc: helCrc, + }, + { name: "fine.txt", data: "ok\n" }, + ]); + const damaged = Buffer.concat([ + Buffer.from("PK\u0003\u0004"), + Buffer.alloc(60, 7), + ]); + + await expectSameViews([archive, damaged]); + const read = await readZipView(archive); + expect(read.ok && read.value).toMatchObject({ + entries: [ + { view_error: "encrypted, so it is not read", sha256: null }, + { + view_error: "compressed with method 12, which the view does not read", + }, + { view_error: "larger than the 16 MiB an entry's view reads" }, + { view_error: "expands more than 100 times, so it is not read" }, + { view_error: "its compressed data is damaged" }, + { view_error: "its compressed data is damaged" }, + { name: "prefix.txt", view_error: "its compressed data is damaged" }, + { + name: "prefix-stored.txt", + view_error: "its compressed data is damaged", + }, + { format: "text", view_error: null }, + ], + }); + expect(await readZipView(damaged)).toEqual({ + ok: false, + error: "not a readable zip archive", + }); + }); + + it("lists at most 1,000 entries and counts the rest", async () => { + const archive = zipArchive( + Array.from({ length: 1002 }, (_, index) => ({ + name: `n/${String(index)}.txt`, + data: String(index), + method: 0, + })), + ); + + await expectSameViews([archive]); + const read = await readZipView(archive); + expect(read.ok && read.value).toMatchObject({ + entry_count: 1002, + truncated: true, + }); + expect( + read.ok && (read.value as { entries: unknown[] }).entries, + ).toHaveLength(1000); + }); + + it("stops reading an archive at 64 MiB of entries, as the exported reader does", () => { + const headers = [ + [{ size: 10, compressedSize: 10 }, 64 * MIB - 10], + [{ size: 11, compressedSize: 11 }, 64 * MIB - 10], + [{ size: 16 * MIB, compressedSize: 1 * MIB }, 0], + [{ size: 0, compressedSize: 0 }, 64 * MIB], + ] as const; + + const ours = headers.map( + ([sizes, spent]) => + zipEntryRefusal( + { name: "a", method: 8, encrypted: false, ...sizes }, + spent, + ) ?? null, + ); + const theirs = JSON.parse( + exportedPython( + `from types import SimpleNamespace +print(json.dumps([_zip_refusal(SimpleNamespace(flag_bits=0, compress_type=8, file_size=size, compress_size=packed), spent) + for (size, packed, spent) in json.load(sys.stdin)]))`, + headers.map(([sizes, spent]) => [ + sizes.size, + sizes.compressedSize, + spent, + ]), + ), + ) as unknown; + + expect(ours).toEqual([ + null, + "past the 64 MiB read from one archive", + null, + null, + ]); + expect(theirs).toEqual(ours); + }); +}); + +describe("zip directories in the exported module", () => { + it("tells directories apart as zip.js does: by a final slash, a Unix mode or the MS-DOS attribute", async () => { + const archive = zipArchive([ + { name: "a.txt", data: "a\n" }, + { name: "slash/", data: "" }, + { name: "unix", data: "", madeBy: 0x0314, external: 0o040755 * 0x10000 }, + { name: "dos", data: "", madeBy: 20, external: 0x10 }, + { + name: "unix-file", + data: "u\n", + madeBy: 0x0314, + external: 0o100644 * 0x10000, + }, + ]); + + expect(await sameView(archive)).toMatchObject({ + entries: [{ name: "a.txt" }, { name: "unix-file" }], + entry_count: 2, + truncated: false, + }); + }); + + it("finds the directory through a Zip64 locator whether or not the classic end record defers to it, as zipfile does", async () => { + const members = [ + { name: "a.txt", data: "a\n" }, + { name: "d/" }, + { name: "b.json", data: '{"b": 1}' }, + ]; + + // A classic record keeping its own values once left both readers short of the directory. + for (const sentinels of [true, false]) + expect( + await sameView(withZip64End(zipArchive(members), sentinels)), + ).toMatchObject({ + entries: [{ name: "a.txt" }, { name: "b.json", view: { b: 1 } }], + entry_count: 2, + }); + }); + + it("keeps the classic values when bytes before the end record only look like a Zip64 locator", async () => { + // The last name ends in a locator signature and 16 more bytes, which sit just before the end record. + const name = `c-PK\u0006\u0007${"0".repeat(16)}`; + const archive = zipArchive([ + { name: "a.txt", data: "a\n" }, + { name, data: "c", flags: 0x800 }, + ]); + + expect(await sameView(archive)).toMatchObject({ + entries: [{ name: "a.txt" }, { name }], + entry_count: 2, + }); + }); + + it("lists the first 1,000 of 50,000 entries holding no more of the directory than those", async () => { + const archive = emptyEntriesArchive(50_000); + await expectSameViews([archive]); + + const { view, peak } = await pythonPeak(archive); + + expect(view.entries).toHaveLength(1000); + expect(view.entry_count).toBe(50_000); + // Parsing every record before the listing limit applied traced about 25 MiB here. + expect(peak).toBeLessThan(4 * MIB); + }); +}); + +describe("zip entries the exported module cannot fully read", () => { + it("reads an entry at most a byte past its declared size, so a forged small size holds no memory", async () => { + // 32 MiB of zeros deflate to about 32 KiB; the entry declares one byte. + const archive = zipArchive([ + { + name: "bomb.txt", + data: Buffer.alloc(32 * MIB), + declaredSize: 1, + crc: 0, + }, + ]); + await expectSameViews([archive]); + + const { view, peak } = await pythonPeak(archive); + + expect(view.entries[0]?.view_error).toBe("its compressed data is damaged"); + // Reading the stream whole before clipping it traced about 77 MiB here. + expect(peak).toBeLessThan(4 * MIB); + }); + + it("refuses JSON and key file entries over 4 MiB before building their values, as REA does", async () => { + const objects = `[{}${",{}".repeat(1_400_000)}]`; + const archive = zipArchive([ + { name: "objects.json", data: objects, method: 0 }, + { name: "objects.txt", data: `${objects.slice(0, -1)}}`, method: 0 }, + { + name: "settings.ini", + data: `[ui]\n${"wrap=false\n".repeat(400_000)}`, + method: 0, + }, + ]); + const refused = "larger than the 4 MiB limit for JSON and key file views"; + + expect(await sameView(archive)).toMatchObject({ + entries: [ + { format: "json", view: null, view_error: refused }, + { format: "text", view: { line_count: 1 }, view_error: null }, + { format: "keyfile", view: null, view_error: refused }, + ], + }); + const { peak } = await pythonPeak( + zipArchive([{ name: "objects.json", data: objects, method: 0 }]), + ); + // Parsing the 4,200,004-byte entry before refusing it traced about 110 MB here. + expect(peak).toBeLessThan(4 * objects.length); + }, 60_000); + + it("keeps a JSON entry nested too deeply on that entry and reads its siblings, as REA does", async () => { + const nested = (levels: number) => + `${"[".repeat(levels)}${"]".repeat(levels)}`; + const archive = zipArchive([ + { name: "Document.xml", data: "" }, + { name: "deep.json", data: nested(1100) }, + { name: "edge.json", data: nested(501) }, + ]); + + expect(await sameView(archive)).toMatchObject({ + entries: [ + { name: "Document.xml", format: "xml", view_error: null }, + { + name: "deep.json", + format: "json", + view: null, + view_error: "nested deeper than the 500 levels a JSON view keeps", + }, + { name: "edge.json", format: "json", view_error: null }, + ], + }); + }); +}); + +/** A FreeCAD document zipped as FreeCAD saves it, with a shape and a thumbnail beside it. */ +const fcstd = async (version: string, end: "before" | "after") => + zipArchive([ + { + name: "Document.xml", + data: await readFile(join(FIXTURES, version, end, "Document.xml")), + }, + { + name: "PartShape.brp", + data: "CASCADE Topology V1, (c) Matra-Datavision\n", + }, + { + name: "thumbnails/Thumbnail.png", + data: Buffer.from([0x89, 0x50, 0]), + method: 0, + }, + ]); + +const DOCUMENT = "/entries/[name=Document.xml]/view/root/children"; +const FILE = "Documents/plate.FCStd"; + +/** The FreeCAD task's checks, by name: they hold in both versions' files. */ +const HOLE_CHECKS: DesktopTaskCheck[] = [ + { + id: "cut", + kind: "value_equals", + file: FILE, + pointer: `${DOCUMENT}/[name=Objects]/children/[name=Object][@type=Part::Cut]/attributes/type`, + value: "Part::Cut", + }, + { + id: "radius", + kind: "value_equals", + file: FILE, + pointer: `${DOCUMENT}/[name=ObjectData]/children/[name=Object][@name=Hole]/children/[name=Properties]/children/[name=Property][@name=Radius]/children/[name=Float]/attributes/value`, + value: 5, + tolerance: 0.01, + }, + { + id: "tool", + kind: "value_equals", + file: FILE, + pointer: `${DOCUMENT}/[name=ObjectData]/children/[name=Object][@name=PlateWithHole]/children/[name=Properties]/children/[name=Property][@name=Tool]/children/[name=Link]/attributes/value`, + value: "Hole", + }, +]; + +describe("FreeCAD documents", () => { + it.each(["0.21", "1.0"])( + "checks the hole in FreeCAD %s by name: failing before the edit and passing after, in both evaluators", + async (version) => { + const archives = saved([ + await fcstd(version, "before"), + await fcstd(version, "after"), + ]); + await expectSameFileViews(archives); + + const [before, after] = await expectSameCheckResults( + FILE, + HOLE_CHECKS, + archives, + ); + expect(before?.map(({ passed }) => passed)).toEqual([ + false, + false, + false, + ]); + expect(after?.map(({ passed }) => passed)).toEqual([true, true, true]); + expect(after?.[1]?.description).toContain( + 'is "5.0000000000000000", within 0.01 of 5', + ); + }, + ); +}); + +describe("OpenDocument text", () => { + it("reads content.xml from an .odt as REA does, its namespaced names included", async () => { + const odt = odtArchive("Buy milk"); + const read = await readZipView(odt); + const [python] = await pythonFileViews([{ name: "list.odt", bytes: odt }]); + + expect(detectStateFormat("list.odt", odt)).toBe("zip"); + expect(read.ok && read.value).toMatchObject({ + entries: [ + { + name: "mimetype", + format: "text", + view: { lines: ["application/vnd.oasis.opendocument.text"] }, + }, + { + name: "content.xml", + format: "xml", + view: { + root: { + name: "office:document-content", + children: [ + { + name: "office:body", + children: [ + { + name: "office:text", + children: [{ name: "text:p", children: ["Buy milk"] }], + }, + ], + }, + ], + }, + }, + }, + { name: "META-INF/manifest.xml", format: "xml" }, + ], + }); + expect(python?.view).toEqual(read.ok ? read.value : null); + }); +}); diff --git a/tests/fixtures/freecad/0.21/after/Document.xml b/tests/fixtures/freecad/0.21/after/Document.xml new file mode 100644 index 0000000000..b869fbc412 --- /dev/null +++ b/tests/fixtures/freecad/0.21/after/Document.xml @@ -0,0 +1,222 @@ + + + + + <_Property name="FileName" type="App::PropertyString" status="50331649"/> + <_Property name="Tip" type="App::PropertyLink" status="33554433"/> + <_Property name="TransientDir" type="App::PropertyString" status="50331649"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_Property name="History" type="Part::PropertyShapeHistory" status="234881024"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/freecad/0.21/before/Document.xml b/tests/fixtures/freecad/0.21/before/Document.xml new file mode 100644 index 0000000000..36a24b3a62 --- /dev/null +++ b/tests/fixtures/freecad/0.21/before/Document.xml @@ -0,0 +1,119 @@ + + + + + <_Property name="FileName" type="App::PropertyString" status="50331649"/> + <_Property name="Tip" type="App::PropertyLink" status="33554433"/> + <_Property name="TransientDir" type="App::PropertyString" status="50331649"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/freecad/1.0/after/Document.xml b/tests/fixtures/freecad/1.0/after/Document.xml new file mode 100644 index 0000000000..bd310155dd --- /dev/null +++ b/tests/fixtures/freecad/1.0/after/Document.xml @@ -0,0 +1,272 @@ + + + + + + <_Property name="FileName" type="App::PropertyString" status="50331649"/> + <_Property name="Tip" type="App::PropertyLink" status="33554433"/> + <_Property name="TransientDir" type="App::PropertyString" status="50331649"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_Property name="History" type="Part::PropertyShapeHistory" status="234881024"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/freecad/1.0/before/Document.xml b/tests/fixtures/freecad/1.0/before/Document.xml new file mode 100644 index 0000000000..11346edf6a --- /dev/null +++ b/tests/fixtures/freecad/1.0/before/Document.xml @@ -0,0 +1,151 @@ + + + + + + <_Property name="FileName" type="App::PropertyString" status="50331649"/> + <_Property name="Tip" type="App::PropertyLink" status="33554433"/> + <_Property name="TransientDir" type="App::PropertyString" status="50331649"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/freecad/README.md b/tests/fixtures/freecad/README.md new file mode 100644 index 0000000000..df68be4d8d --- /dev/null +++ b/tests/fixtures/freecad/README.md @@ -0,0 +1,17 @@ +# FreeCAD documents + +Each `Document.xml` is unchanged from a `.FCStd` file that a real FreeCAD saved +during a desktop capture: `0.21` from FreeCAD 0.21 (`0.21R33771`) and `1.0` from +FreeCAD 1.0 (`1.0R`), both on Ubuntu. A `.FCStd` file is a zip archive, and +`Document.xml` is the entry that records the document's objects. + +`before` holds one `Part::Box` named `Plate`, 160 × 100 × 5 mm. `after` is the +same document once the user cut a 10 mm hole through it and saved: it adds a +`Part::Cylinder` named `Hole` with `Radius` `5.0000000000000000`, and a +`Part::Cut` named `PlateWithHole` whose `Base` links `Plate` and whose `Tool` +links `Hole`. + +The two versions write the same objects in different places: FreeCAD 1.0 +records more properties, so the hole's `Radius` sits at another position among +its siblings. Tests zip these files into `.FCStd` archives and check them by +name rather than by position. diff --git a/tests/fixtures/inkscape/README.md b/tests/fixtures/inkscape/README.md new file mode 100644 index 0000000000..4f7ec711b9 --- /dev/null +++ b/tests/fixtures/inkscape/README.md @@ -0,0 +1,13 @@ +# Inkscape drawings + +Each `poster.svg` is unchanged from a file that a real Inkscape saved as +Inkscape SVG: Inkscape 1.2.2 on Ubuntu 24.04, from +`inkscape poster.svg --export-type=svg`. The saved file declares the default +SVG namespace, `svg`, `inkscape` and `sodipodi`, names the document with +`sodipodi:docname`, adds a `sodipodi:namedview` whose settings are `inkscape:` +attributes, and keeps `xml:space="preserve"` on its text. + +`before` is an A4 poster with a layer `Layer 1` holding a banner and two +lines of text; the `text` with `id="headline"` reads `Spring Bake Sale`. +`after` is the same poster once the headline was changed to +`Summer Bake Sale`, and nothing else. diff --git a/tests/fixtures/inkscape/after/poster.svg b/tests/fixtures/inkscape/after/poster.svg new file mode 100644 index 0000000000..7731967be9 --- /dev/null +++ b/tests/fixtures/inkscape/after/poster.svg @@ -0,0 +1,57 @@ + + + + + + + Summer Bake Sale + Saturday at the school hall + + diff --git a/tests/fixtures/inkscape/before/poster.svg b/tests/fixtures/inkscape/before/poster.svg new file mode 100644 index 0000000000..8066cfecee --- /dev/null +++ b/tests/fixtures/inkscape/before/poster.svg @@ -0,0 +1,57 @@ + + + + + + + Spring Bake Sale + Saturday at the school hall + + diff --git a/tests/fixtures/libreoffice/README.md b/tests/fixtures/libreoffice/README.md new file mode 100644 index 0000000000..d4239c5296 --- /dev/null +++ b/tests/fixtures/libreoffice/README.md @@ -0,0 +1,13 @@ +# LibreOffice Writer documents + +Each `content.xml` is unchanged from an `.odt` file that a real LibreOffice +Writer saved: LibreOffice 24.2.7.2 on Ubuntu 24.04, from +`soffice --headless --convert-to odt`. An `.odt` file is a zip archive, and +`content.xml` is the entry that holds the document's text; it declares 35 +namespace prefixes on its root and names every element and attribute with one, +as `text:p` and `text:style-name`. + +`before` is a flyer with a `Title` paragraph `Summer Bake Sale`, a `Subtitle` +paragraph `Monday 17 November` and a body paragraph. `after` is the same flyer +once the date was changed to `Monday 24 November`, and nothing else. Tests zip +each file into an `.odt` archive with its `mimetype`. diff --git a/tests/fixtures/libreoffice/after/content.xml b/tests/fixtures/libreoffice/after/content.xml new file mode 100644 index 0000000000..ff7e91e178 --- /dev/null +++ b/tests/fixtures/libreoffice/after/content.xml @@ -0,0 +1,2 @@ + +Summer Bake SaleMonday 24 NovemberCakes, biscuits and bread from 10 o'clock in the school hall. \ No newline at end of file diff --git a/tests/fixtures/libreoffice/before/content.xml b/tests/fixtures/libreoffice/before/content.xml new file mode 100644 index 0000000000..8af1d0ab45 --- /dev/null +++ b/tests/fixtures/libreoffice/before/content.xml @@ -0,0 +1,2 @@ + +Summer Bake SaleMonday 17 NovemberCakes, biscuits and bread from 10 o'clock in the school hall. \ No newline at end of file diff --git a/tests/support/desktopExportPython.ts b/tests/support/desktopExportPython.ts new file mode 100644 index 0000000000..f4c1f421a5 --- /dev/null +++ b/tests/support/desktopExportPython.ts @@ -0,0 +1,149 @@ +import { execFileSync } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { readZipView } from "../../src/application/desktop/DesktopZipView.js"; +import type { DesktopTaskCheck } from "../../src/domain/desktop/appTask.js"; +import { + evaluateTaskChecks, + type TaskCheckOutcome, + type TaskFileView, +} from "../../src/domain/desktop/appTaskChecks.js"; +import { + PYTHON_HELPERS, + PYTHON_IMPORTS, +} from "../../src/domain/desktop/appExportPython.js"; +import { PYTHON_TASK_EVALUATOR } from "../../src/domain/desktop/appTaskPython.js"; +import { + detectStateFormat, + textualView, +} from "../../src/domain/desktop/stateFormats.js"; +import type { JsonValue } from "../../src/domain/jsonValue.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +/** A file as an app saved it: its name under the home and its bytes. */ +export interface SavedFile { + readonly name: string; + readonly bytes: Uint8Array; +} + +/** The exported module's standard library imports, its helpers and the verifier's evaluator, as one program. */ +const PYTHON = `${PYTHON_IMPORTS.split("\n") + .filter((line) => !line.includes("agentenv_protocol")) + .join("\n")}${PYTHON_HELPERS}${PYTHON_TASK_EVALUATOR}`; + +/** Run a driver after the exported module's helpers and evaluator, with JSON on its stdin; its stdout. */ +export const exportedPython = (driver: string, input: unknown): string => + execFileSync("python3", ["-I", "-B", "-c", `${PYTHON}\n${driver}`], { + input: JSON.stringify(input), + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + +/** One JSON value per line the driver printed. */ +const printed = (output: string): Value[] => + output + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Value); + +/** Write each file into a fresh folder, as data/get finds it under the home, keeping its suffix; their paths. */ +export const writeFiles = async (files: readonly SavedFile[]) => { + const folder = await createTestTempDirectory("desktop-export-python-"); + return Promise.all( + files.map(async ({ name, bytes }, index) => { + const path = join(folder, `${String(index)}-${name}`); + await writeFile(path, bytes); + return path; + }), + ); +}; + +/** The exported module's data/get entry for each file: its format and view, or its error. */ +export const pythonFileViews = async (files: readonly SavedFile[]) => + printed>( + exportedPython( + `for path in json.load(sys.stdin): + print(json.dumps(_view(Path(path))))`, + await writeFiles(files), + ), + ); + +/** The verifier's results for each file, read by the exported module as the home path `home`. */ +const pythonCheckResults = async ( + home: string, + checks: readonly DesktopTaskCheck[], + files: readonly SavedFile[], +) => + printed<{ id: string; result: boolean; description: string }[]>( + exportedPython( + `request = json.load(sys.stdin) +for path in request["paths"]: + print(json.dumps(evaluate(request["checks"], {request["home"]: _view(Path(path))})))`, + { home, checks, paths: await writeFiles(files) }, + ), + ); + +/** The exported module's XML view of each text, or null where it refuses one. */ +export const pythonXmlViews = (texts: readonly string[]) => + printed( + exportedPython( + `for text in json.load(sys.stdin): + print(json.dumps(_xml_view(text)))`, + texts, + ), + ); + +/** REA's state view of a zip, text, JSON, key file or XML file, in the shape data/get serves it. */ +export const typescriptFileView = async ({ + name, + bytes, +}: SavedFile): Promise => { + const format = detectStateFormat(name, bytes); + if (format === "sqlite" || format === "dconf" || format === "binary") + throw new Error(`${name} is ${format}, which these tests do not read`); + const read = + format === "zip" ? await readZipView(bytes) : textualView(format, bytes); + return read.ok + ? { format, view: read.value } + : { format, view: null, error: read.error }; +}; + +/** Both readers give each file the same format and view, or the same error. */ +export const expectSameFileViews = async (files: readonly SavedFile[]) => { + const fromPython = await pythonFileViews(files); + for (const [index, file] of files.entries()) { + const ours = await typescriptFileView(file); + const theirs = fromPython[index] ?? {}; + expect(theirs.format).toBe(ours.format); + expect(theirs.view).toEqual(ours.view); + expect(theirs.error).toBe(ours.error); + } +}; + +/** Both evaluators give the same results and descriptions on each file read as the home path `home`; REA's. */ +export const expectSameCheckResults = async ( + home: string, + checks: readonly DesktopTaskCheck[], + files: readonly SavedFile[], +): Promise<(readonly TaskCheckOutcome[])[]> => { + const fromPython = await pythonCheckResults(home, checks, files); + return Promise.all( + files.map(async (file, index) => { + const ours = evaluateTaskChecks( + checks, + new Map([[home, await typescriptFileView(file)]]), + ); + expect(fromPython[index]).toEqual( + ours.map(({ id, passed, description }) => ({ + id, + result: passed, + description, + })), + ); + return ours; + }), + ); +}; From b28b1e0ae21706dfa51ca6286479cd524af38d25 Mon Sep 17 00:00:00 2001 From: Dragolone <160818051+Dragolone@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:11:24 +1100 Subject: [PATCH 004/151] fix(adb): list devices adb reports with bare no-permissions text (#8905) adb prints the no-permissions state as "no permissions[ (reason)]; see [URL]" and omits the reason when it finds no udev problem to name, for example on a Linux host without a plugdev group. The state matcher required whitespace after "no permissions", so that line counted as unrecognized and list_adb_devices refused the whole listing, hiding every other attached device. Accept ";" after this one state. Other states still need a whitespace or end-of-line boundary. --- src/adb/AdbDeviceOutput.test.ts | 41 +++++++++++++++++++++++++++++++++ src/adb/AdbDeviceOutput.ts | 9 +++++++- src/adb/AdbProvider.test.ts | 30 ++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/adb/AdbDeviceOutput.test.ts b/src/adb/AdbDeviceOutput.test.ts index d2a32a8550..bd93598a99 100644 --- a/src/adb/AdbDeviceOutput.test.ts +++ b/src/adb/AdbDeviceOutput.test.ts @@ -168,6 +168,47 @@ describe("ADB device states", () => { }); }); + it.each([ + [ + "without a reason", + "no permissions; see [http://developer.android.com/tools/device.html]", + ], + [ + "with a reason", + "no permissions (user alice is not in the plugdev group); see [http://developer.android.com/tools/device.html]", + ], + ])("parses adb's no-permissions help text %s", (_name, stateText) => { + const { devices, unparsedLines } = parseAdbDevicesOutput( + [ + "List of devices attached", + `07ef9c2a ${stateText} usb:1-1 transport_id:4`, + "emulator-5554 device product:sdk model:sdk device:emu transport_id:1", + "", + ].join("\n"), + ); + expect(unparsedLines).toEqual({ sample: [], total: 0 }); + expect(devices.map(({ serial, state }) => [serial, state])).toEqual([ + ["07ef9c2a", "no permissions"], + ["emulator-5554", "device"], + ]); + expect(devices[0]).toMatchObject({ + transport: "usb", + product: null, + model: null, + device: null, + transport_id: 4, + kind: "physical", + }); + }); + + it("rejects a semicolon after any state other than no permissions", () => { + const { devices, unparsedLines } = parseAdbDevicesOutput( + "serial-bad-device device; see [url] transport_id:1\n", + ); + expect(devices).toEqual([]); + expect(unparsedLines.total).toBe(1); + }); + it("returns unrecognized lines instead of dropping them", () => { const output = [ "List of devices attached", diff --git a/src/adb/AdbDeviceOutput.ts b/src/adb/AdbDeviceOutput.ts index 4892e2860f..2dfda6e0b3 100644 --- a/src/adb/AdbDeviceOutput.ts +++ b/src/adb/AdbDeviceOutput.ts @@ -97,7 +97,14 @@ export const parseAdbDevicesOutput = ( if (remainder === undefined || !remainder.startsWith(candidate)) return false; const next = remainder[candidate.length]; - return next === undefined || /\s/u.test(next); + // adb prints no-permissions as `no permissions[ (reason)]; see [URL]` and + // omits the reason on a host where it finds no udev problem to name, + // such as one without a plugdev group, so `;` can follow it directly. + return ( + next === undefined || + /\s/u.test(next) || + (candidate === "no permissions" && next === ";") + ); }); if (match === null || state === undefined) { if (rawLine.trim() !== "") unparsed.add(rawLine); diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts index 4c4aed2f87..40bc2951fc 100644 --- a/src/adb/AdbProvider.test.ts +++ b/src/adb/AdbProvider.test.ts @@ -239,6 +239,36 @@ fi`, expect(result.error.message).toContain(malformedLine); }); +it("lists a USB device adb has no permission to open alongside online devices", async () => { + const { binary } = await writeAdbStub( + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$1" = "devices" ]; then + printf 'List of devices attached\\n' + printf '07ef9c2a no permissions; see [http://developer.android.com/tools/device.html] usb:1-1 transport_id:4\\n' + printf 'emulator-5554 device transport_id:1\\n' + exit 0 +fi`, + ); + const result = await evidenceResult(providerOver(binary), { + operation: "list_adb_devices", + input: {}, + }); + expect(result.devices).toEqual([ + { + serial: "07ef9c2a", + state: "no permissions", + transport: "usb", + product: null, + model: null, + device: null, + transport_id: 4, + kind: "physical", + kind_basis: "usb_transport", + }, + expect.objectContaining({ serial: "emulator-5554", state: "device" }), + ]); +}); + it("lists rescue and detached transports alongside online devices", async () => { const { binary } = await writeAdbStub( () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi From b153ba8ddd5bf13cbc6ba74009eca290686adbca Mon Sep 17 00:00:00 2001 From: Afloat Date: Thu, 5 Nov 2026 21:11:56 -0500 Subject: [PATCH 005/151] fix(process): honor cancellation at bounded file read completion (#8908) --- src/process/BoundedFileBytes.ts | 3 ++ .../filesystem/boundedFileBytes.test.ts | 44 ++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/process/BoundedFileBytes.ts b/src/process/BoundedFileBytes.ts index a33d17c052..d42725b0ee 100644 --- a/src/process/BoundedFileBytes.ts +++ b/src/process/BoundedFileBytes.ts @@ -41,6 +41,7 @@ export const readBoundedFileBytes = async ( expectedSize > maxBytes) ) throw new RangeError("Expected file size must fit the byte limit"); + assertActive(); if (expectedSize === undefined) { const chunks: Buffer[] = []; const buffer = Buffer.allocUnsafe(Math.min(64 * 1_024, maxBytes + 1)); @@ -52,6 +53,7 @@ export const readBoundedFileBytes = async ( 0, Math.min(buffer.length, maxBytes - total + 1), ); + assertActive(); if (bytesRead === 0) return Buffer.concat(chunks, total); total += bytesRead; if (total > maxBytes) return undefined; @@ -64,6 +66,7 @@ export const readBoundedFileBytes = async ( for (;;) { assertActive(); const { bytesRead } = await read(buffer, total, expectedSize - total); + assertActive(); if (bytesRead === 0) return buffer.subarray(0, total); total += bytesRead; if (total > maxBytes) return undefined; diff --git a/tests/boundary/filesystem/boundedFileBytes.test.ts b/tests/boundary/filesystem/boundedFileBytes.test.ts index 8a753eb2b2..f2813adf19 100644 --- a/tests/boundary/filesystem/boundedFileBytes.test.ts +++ b/tests/boundary/filesystem/boundedFileBytes.test.ts @@ -26,7 +26,7 @@ describe("bounded file byte reads", () => { }, ); - it.each([undefined, 5])( + it.each([undefined, 0, 5])( "preserves raw reasons or caller-owned cancellation with expected size %s", async (expectedSize) => { const root = await createTestTempDirectory("rea-bounded-cancel-"); @@ -61,6 +61,48 @@ describe("bounded file byte reads", () => { }, ); + it.each([ + { contents: "", maxBytes: 0, expectedSize: undefined, outcome: "EOF" }, + { contents: "", maxBytes: 5, expectedSize: 5, outcome: "early EOF" }, + { + contents: "hello", + maxBytes: 5, + expectedSize: 5, + outcome: "expected size", + }, + { + contents: "hello", + maxBytes: 4, + expectedSize: undefined, + outcome: "overflow", + }, + ])( + "honors cancellation while the $outcome read is in flight", + async ({ contents, maxBytes, expectedSize }) => { + const root = await createTestTempDirectory("rea-bounded-cancel-read-"); + const path = join(root, "input"); + await writeFile(path, contents); + const handle = await open(path, "r"); + const controller = new AbortController(); + const reason = new Error("cancelled during read"); + const typed = new Error("typed cancellation", { cause: reason }); + try { + const result = readBoundedFileBytes(handle, maxBytes, { + signal: controller.signal, + expectedSize, + cancellationFailure: (observed) => { + expect(observed).toBe(reason); + return typed; + }, + }); + controller.abort(reason); + await expect(result).rejects.toBe(typed); + } finally { + await handle.close(); + } + }, + ); + it("stops after one overflow byte when a file grows beyond its admitted size", async () => { const root = await createTestTempDirectory("rea-growing-bytes-"); const path = join(root, "input"); From a3621e5e1f73755d927dd97861ab4216cabf4787 Mon Sep 17 00:00:00 2001 From: Afloat Date: Thu, 5 Nov 2026 21:12:43 -0500 Subject: [PATCH 006/151] fix(process): resume buffered NDJSON frames on partial chunks (#8909) --- src/hopper/HopperResponseStream.test.ts | 8 ++++++-- src/process/DecodedNdjsonLineBuffer.test.ts | 10 ++++++++++ src/process/DecodedNdjsonLineBuffer.ts | 15 +++++++++------ 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/hopper/HopperResponseStream.test.ts b/src/hopper/HopperResponseStream.test.ts index 0a9de6f041..a08c1d9c13 100644 --- a/src/hopper/HopperResponseStream.test.ts +++ b/src/hopper/HopperResponseStream.test.ts @@ -20,7 +20,9 @@ describe("Hopper response stream", () => { expect(accepted).toEqual([]); expect(abort).toHaveBeenCalledOnce(); - stream.push('{"id":3,"result":null}\n'); + stream.push('{"id":3'); + expect(accepted).toEqual([2]); + stream.push(',"result":null}\n'); expect(accepted).toEqual([2, 3]); expect(abort).toHaveBeenCalledOnce(); }); @@ -45,7 +47,9 @@ describe("Hopper response stream", () => { "Hopper returned an unknown response id", ); - stream.push('{"id":2,"result":null}\n'); + stream.push('{"id":2'); + expect(accepted).toEqual([1]); + stream.push(',"result":null}\n'); expect(accepted).toEqual([1, 2]); expect(abort).toHaveBeenCalledOnce(); }); diff --git a/src/process/DecodedNdjsonLineBuffer.test.ts b/src/process/DecodedNdjsonLineBuffer.test.ts index 088b00bd55..73151122b4 100644 --- a/src/process/DecodedNdjsonLineBuffer.test.ts +++ b/src/process/DecodedNdjsonLineBuffer.test.ts @@ -3,6 +3,16 @@ import { describe, expect, it } from "vitest"; import { DecodedNdjsonLineBuffer } from "./DecodedNdjsonLineBuffer.js"; describe("decoded NDJSON line buffer", () => { + it("drains complete buffered lines on an empty push after iteration stops", () => { + const lines = new DecodedNdjsonLineBuffer(); + const iterator = lines.push("first\nsecond\nthird\npartial"); + expect(iterator.next()).toEqual({ value: "first", done: false }); + iterator.return(undefined); + + expect([...lines.push("")]).toEqual(["second", "third"]); + expect([...lines.push(" line\n")]).toEqual(["partial line"]); + }); + it("joins fragments and yields trimmed nonblank complete lines", () => { const lines = new DecodedNdjsonLineBuffer(); diff --git a/src/process/DecodedNdjsonLineBuffer.ts b/src/process/DecodedNdjsonLineBuffer.ts index 4e601deebd..55837ca330 100644 --- a/src/process/DecodedNdjsonLineBuffer.ts +++ b/src/process/DecodedNdjsonLineBuffer.ts @@ -2,6 +2,7 @@ export class DecodedNdjsonLineBuffer { #buffer = ""; #fragments: string[] = []; + #nextNewline = -1; /** * Consume a chunk and yield each complete, trimmed, nonblank line. @@ -9,17 +10,18 @@ export class DecodedNdjsonLineBuffer { */ *push(chunk: string): Generator { this.#fragments.push(chunk); - if (!chunk.includes("\n")) return; + if (!chunk.includes("\n") && this.#nextNewline < 0) return; this.#buffer += this.#fragments.join(""); this.#fragments = []; - let newline = this.#buffer.indexOf("\n"); - while (newline >= 0) { - const encodedLine = this.#buffer.slice(0, newline); - this.#buffer = this.#buffer.slice(newline + 1); + if (this.#nextNewline < 0) this.#nextNewline = this.#buffer.indexOf("\n"); + while (this.#nextNewline >= 0) { + const encodedLine = this.#buffer.slice(0, this.#nextNewline); + this.#buffer = this.#buffer.slice(this.#nextNewline + 1); + // Update before yielding: a consumer may stop with complete lines left. + this.#nextNewline = this.#buffer.indexOf("\n"); const line = encodedLine.trim(); if (line.length > 0) yield line; - newline = this.#buffer.indexOf("\n"); } } @@ -27,5 +29,6 @@ export class DecodedNdjsonLineBuffer { reset(): void { this.#buffer = ""; this.#fragments = []; + this.#nextNewline = -1; } } From 3eff8065e757c6e3bab96d164abfceafd340e6d1 Mon Sep 17 00:00:00 2001 From: Afloat Date: Thu, 5 Nov 2026 21:13:14 -0500 Subject: [PATCH 007/151] fix(process): preserve UTF-8 at diagnostic truncation boundaries (#8907) * fix(process): preserve UTF-8 at diagnostic truncation boundaries * fix(process): preserve malformed UTF-8 excerpt evidence --- src/process/BoundedOutputExcerpt.test.ts | 96 +++++++++++++++++++++++ src/process/BoundedOutputExcerpt.ts | 47 ++++++++++- src/process/ExecFileBoundedOutput.test.ts | 16 ++++ 3 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 src/process/BoundedOutputExcerpt.test.ts diff --git a/src/process/BoundedOutputExcerpt.test.ts b/src/process/BoundedOutputExcerpt.test.ts new file mode 100644 index 0000000000..d33315e738 --- /dev/null +++ b/src/process/BoundedOutputExcerpt.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import { BoundedOutputExcerpt } from "./BoundedOutputExcerpt.js"; + +describe("bounded output excerpts", () => { + it.each([ + { text: `A🙂${"x".repeat(30)}🙂Z`, limit: 8, head: "A", tail: "Z" }, + { + text: `é中🙂${"x".repeat(30)}🙂中é`, + limit: 16, + head: "é中", + tail: "中é", + }, + { text: `é${"x".repeat(30)}中`, limit: 1, head: "", tail: "" }, + ])( + "omits only cut UTF-8 code points with a $limit byte budget", + ({ text, limit, head, tail }) => { + const bytes = Buffer.from(text); + for (let split = 0; split <= bytes.length; split += 1) { + const excerpt = new BoundedOutputExcerpt(limit); + excerpt.append(bytes.subarray(0, split)); + excerpt.append(bytes.subarray(split)); + expect(excerpt.truncated).toBe(true); + expect(excerpt.text).toBe(`${head} … [output omitted] … ${tail}`); + } + }, + ); + + it("rejoins multibyte characters across the head/tail boundary without truncation", () => { + const bytes = Buffer.from("A🙂"); + for (let split = 0; split <= bytes.length; split += 1) { + const excerpt = new BoundedOutputExcerpt(bytes.length); + excerpt.append(bytes.subarray(0, split)); + excerpt.append(bytes.subarray(split)); + expect(excerpt.truncated).toBe(false); + expect(excerpt.text).toBe("A🙂"); + } + const bytewise = new BoundedOutputExcerpt(bytes.length); + for (const byte of bytes) bytewise.append(Buffer.from([byte])); + expect(bytewise.truncated).toBe(false); + expect(bytewise.text).toBe("A🙂"); + }); + + it.each([ + { suffix: [0xc0], decoded: "�" }, + { suffix: [0xc1], decoded: "�" }, + { suffix: [0xf5], decoded: "�" }, + { suffix: [0xff], decoded: "�" }, + { suffix: [0x80], decoded: "�" }, + { suffix: [0xe0, 0x9f], decoded: "��" }, + { suffix: [0xed, 0xa0], decoded: "��" }, + { suffix: [0xf0, 0x8f], decoded: "��" }, + { suffix: [0xf4, 0x90], decoded: "��" }, + { suffix: [0xf0, 0x8f, 0x80], decoded: "���" }, + { suffix: [0xf4, 0x90, 0x80], decoded: "���" }, + { suffix: [0xc2], decoded: "" }, + { suffix: [0xe0], decoded: "" }, + { suffix: [0xe0, 0xa0], decoded: "" }, + { suffix: [0xed, 0x9f], decoded: "" }, + { suffix: [0xf0], decoded: "" }, + { suffix: [0xf0, 0x90], decoded: "" }, + { suffix: [0xf0, 0x90, 0x80], decoded: "" }, + { suffix: [0xf4, 0x8f], decoded: "" }, + { suffix: [0xf4, 0x8f, 0xbf], decoded: "" }, + ])( + "omits only valid incomplete UTF-8 head suffixes: $suffix", + ({ suffix, decoded }) => { + const head = Buffer.from([0x41, ...suffix]); + const tail = `${"B".repeat(head.length - 1)}Z`; + const bytes = Buffer.concat([ + head, + Buffer.from("x".repeat(30)), + Buffer.from(tail), + ]); + for (let split = 0; split <= bytes.length; split += 1) { + const excerpt = new BoundedOutputExcerpt(head.length * 2); + excerpt.append(bytes.subarray(0, split)); + excerpt.append(bytes.subarray(split)); + expect(excerpt.truncated).toBe(true); + expect(excerpt.text).toBe(`A${decoded} … [output omitted] … ${tail}`); + } + }, + ); + + it("preserves malformed output inside the retained head and tail", () => { + const excerpt = new BoundedOutputExcerpt(4); + excerpt.append( + Buffer.concat([ + Buffer.from([0xff, 0x41]), + Buffer.from("x".repeat(30)), + Buffer.from([0x42, 0xff]), + ]), + ); + expect(excerpt.text).toBe("�A … [output omitted] … B�"); + }); +}); diff --git a/src/process/BoundedOutputExcerpt.ts b/src/process/BoundedOutputExcerpt.ts index 439df43cb2..d65d96ba7b 100644 --- a/src/process/BoundedOutputExcerpt.ts +++ b/src/process/BoundedOutputExcerpt.ts @@ -39,8 +39,49 @@ export class BoundedOutputExcerpt { } get text(): string { - return this.truncated - ? `${this.#head.toString("utf8")} … [output omitted] … ${this.#tail.toString("utf8")}` - : Buffer.concat([this.#head, this.#tail]).toString("utf8"); + if (!this.truncated) + return Buffer.concat([this.#head, this.#tail]).toString("utf8"); + + const head = decodeTruncatedHead(this.#head); + let tailStart = 0; + while ( + tailStart < this.#tail.length && + (this.#tail[tailStart]! & 0xc0) === 0x80 + ) + tailStart += 1; + // A valid cut can leave at most three continuation bytes. Longer runs + // are malformed output, so retain their decoding evidence. + if (tailStart > 3) tailStart = 0; + return `${head} … [output omitted] … ${this.#tail.subarray(tailStart).toString("utf8")}`; } } + +/** Omit only a suffix that could still form a valid UTF-8 code point. */ +const decodeTruncatedHead = (bytes: Buffer): string => { + let start = bytes.length - 1; + while ( + start >= 0 && + bytes.length - start < 4 && + (bytes[start]! & 0xc0) === 0x80 + ) + start -= 1; + const first = bytes[start]; + if (first === undefined) return bytes.toString("utf8"); + const width = + first >= 0xc2 && first <= 0xdf + ? 2 + : first >= 0xe0 && first <= 0xef + ? 3 + : first >= 0xf0 && first <= 0xf4 + ? 4 + : 0; + const second = bytes[start + 1]; + // Exclude overlong encodings, surrogates and values beyond U+10FFFF. + const minimumSecond = first === 0xe0 ? 0xa0 : first === 0xf0 ? 0x90 : 0x80; + const maximumSecond = first === 0xed ? 0x9f : first === 0xf4 ? 0x8f : 0xbf; + const incomplete = + bytes.length - start < width && + (second === undefined || + (second >= minimumSecond && second <= maximumSecond)); + return bytes.toString("utf8", 0, incomplete ? start : bytes.length); +}; diff --git a/src/process/ExecFileBoundedOutput.test.ts b/src/process/ExecFileBoundedOutput.test.ts index 7c603bf241..50a22bb17b 100644 --- a/src/process/ExecFileBoundedOutput.test.ts +++ b/src/process/ExecFileBoundedOutput.test.ts @@ -3,6 +3,22 @@ import { describe, expect, it } from "vitest"; import { execFileBoundedOutput } from "./ExecFileBoundedOutput.js"; describe("execFileBoundedOutput", () => { + it("retains complete UTF-8 characters at both sides of truncated command output", async () => { + const text = `A🙂${"x".repeat(30)}🙂Z`; + const result = await execFileBoundedOutput( + process.execPath, + [ + "-e", + `process.stdout.write(${JSON.stringify(text)}); process.stderr.write(${JSON.stringify(text)});`, + ], + { env: {}, limit: 8 }, + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("A … [output omitted] … Z"); + expect(result.stderr).toBe("A … [output omitted] … Z"); + expect(result.outputTruncated).toBe(true); + }); + it("lets a command finish after its output exceeds the capture limit", async () => { const result = await execFileBoundedOutput( process.execPath, From f1212b9e4e5d01a26131c6001193d9858b8b6bbc Mon Sep 17 00:00:00 2001 From: RRFZ <247457381+Plastic-time@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:13:45 -0500 Subject: [PATCH 008/151] fix(cli): reject non-decimal native UI and command deadline options (#8920) --- src/cli/utilityCommands.ts | 35 ++--- .../cli/utilityIntegerOptions.test.ts | 120 ++++++++++++++++++ 2 files changed, 140 insertions(+), 15 deletions(-) create mode 100644 tests/boundary/cli/utilityIntegerOptions.test.ts diff --git a/src/cli/utilityCommands.ts b/src/cli/utilityCommands.ts index f565dbf9fc..5dd39f7079 100644 --- a/src/cli/utilityCommands.ts +++ b/src/cli/utilityCommands.ts @@ -43,13 +43,22 @@ export const registerUtilityCommands = ( registerReferenceSourceCommand(cli, logger, environment); }; +/** + * A native deadline option keeps the shared field's bounds but admits only + * decimal argv text; Incur's Number() coercion would accept "0x10" or "1e3". + */ +const cliNativeDeadline = (field: typeof nativeUiDeadlineField) => + cliIntegerArgument(1) + .pipe(field.unwrap()) + .optional() + .describe(field.description ?? ""); + +// Decimal tokens only, for the same reason as cliNativeDeadline. const nativeUiScopeOptions = { - pid: z.number().int().positive().describe("Existing application PID"), - windowId: z - .number() - .int() - .positive() - .describe("Exact selected application window ID"), + pid: cliIntegerArgument(1).describe("Existing application PID"), + windowId: cliIntegerArgument(1).describe( + "Exact selected application window ID", + ), screenshot: z .boolean() .default(true) @@ -58,14 +67,10 @@ const nativeUiScopeOptions = { .boolean() .default(true) .describe("Read the selected window accessibility tree"), - maxNodes: z - .number() - .int() - .positive() - .safe() + maxNodes: cliIntegerArgument(1) .default(500) .describe("Maximum accessibility nodes per capture"), - nativeUiDeadlineMs: nativeUiDeadlineField, + nativeUiDeadlineMs: cliNativeDeadline(nativeUiDeadlineField), }; const nativeUiScope = (options: { @@ -203,7 +208,7 @@ const registerNativeCommands = ( path: localPathStringSchema.describe("Mach-O or app path"), }), options: z.object({ - nativeCommandTimeoutMs: nativeCommandTimeoutField, + nativeCommandTimeoutMs: cliNativeDeadline(nativeCommandTimeoutField), }), run: ({ args, options }) => logCliCommand(logger, command, () => @@ -230,7 +235,7 @@ const registerNativeCommands = ( .describe( "Plist path relative to the app root, or an absolute path (default: the bundle's Info.plist)", ), - nativeCommandTimeoutMs: nativeCommandTimeoutField, + nativeCommandTimeoutMs: cliNativeDeadline(nativeCommandTimeoutField), }), run: ({ args, options }) => logCliCommand(logger, "inspect-plist", () => @@ -258,7 +263,7 @@ const registerNativeCommands = ( symbols: swiftSymbolsSchema.describe("Swift mangled symbols to demangle"), }), options: z.object({ - nativeCommandTimeoutMs: nativeCommandTimeoutField, + nativeCommandTimeoutMs: cliNativeDeadline(nativeCommandTimeoutField), }), run: ({ args, options }) => logCliCommand(logger, "demangle-swift", () => diff --git a/tests/boundary/cli/utilityIntegerOptions.test.ts b/tests/boundary/cli/utilityIntegerOptions.test.ts new file mode 100644 index 0000000000..a0f4e79a6f --- /dev/null +++ b/tests/boundary/cli/utilityIntegerOptions.test.ts @@ -0,0 +1,120 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest, type CliResult } from "../../support/cli/cliFixture.js"; + +// Numeric coercion reads each of these as 16 (or 10 for "1e1"). +const NON_DECIMAL_TEXT = ["0x10", "0b10000", "1e1", "+16", "16.0", " 16"]; + +const nativeUi = (...flags: string[]): string[] => [ + "observe-native-ui", + "Demo.app", + "--pid=16", + "--window-id=7", + ...flags, +]; + +/** A present plist, so only option parsing can refuse at validation. */ +const plist = async (): Promise => { + const root = await createTestTempDirectory("rea-utility-options-"); + const path = join(root, "Info.plist"); + await writeFile( + path, + '\n\n', + ); + return path; +}; + +const expectFieldRefusal = (result: CliResult, field: string): void => { + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: expect.arrayContaining([ + expect.objectContaining({ path: field }), + ]), + }); +}; + +const expectPastValidation = (result: CliResult): void => { + expect(JSON.parse(result.stdout)).not.toMatchObject({ + code: "VALIDATION_ERROR", + }); +}; + +describe("utility command integer options", () => { + // The options share one parser, so one takes every malformed spelling. + for (const [flag, field, values] of [ + ["--pid", "pid", NON_DECIMAL_TEXT], + ["--window-id", "windowId", ["0x10"]], + ["--max-nodes", "maxNodes", ["0x10"]], + ["--native-ui-deadline-ms", "nativeUiDeadlineMs", ["0x10"]], + ] as const) { + cliTest.for(values)( + `rejects %j as a native UI ${flag} value`, + async (value, { cli }) => { + // A later flag replaces the decimal --pid/--window-id defaults. + const result = await cli.run({ + arguments: [...nativeUi(`${flag}=${value}`), "--json"], + }); + + expectFieldRefusal(result, field); + }, + ); + } + + cliTest('rejects "0x10" as a native command timeout', async ({ cli }) => { + const result = await cli.run({ + arguments: [ + "inspect-plist", + await plist(), + "--native-command-timeout-ms=0x10", + "--json", + ], + }); + + expectFieldRefusal(result, "nativeCommandTimeoutMs"); + }); + + cliTest( + "keeps the native deadline bounds for decimal text", + async ({ cli }) => { + for (const value of ["0", "2147483648"]) { + const result = await cli.run({ + arguments: [ + ...nativeUi(`--native-ui-deadline-ms=${value}`), + "--json", + ], + }); + expectFieldRefusal(result, "nativeUiDeadlineMs"); + } + expectFieldRefusal( + await cli.run({ arguments: [...nativeUi("--pid=0"), "--json"] }), + "pid", + ); + }, + ); + + cliTest("admits decimal utility integer options", async ({ cli }) => { + expectPastValidation( + await cli.run({ + arguments: [ + ...nativeUi("--max-nodes=500", "--native-ui-deadline-ms=2147483647"), + "--json", + ], + }), + ); + expectPastValidation( + await cli.run({ + arguments: [ + "inspect-plist", + await plist(), + "--native-command-timeout-ms=1000", + "--json", + ], + }), + ); + }); +}); From 4b9392e41cb98f647832e33f0d223bdc541ed898 Mon Sep 17 00:00:00 2001 From: RRFZ <247457381+Plastic-time@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:14:15 -0500 Subject: [PATCH 009/151] fix(pe): reject certificate versions and fields RFC 5280 does not allow (#8921) --- src/native/pe/PeCertificateAsn1.ts | 27 ++++++++++---- src/native/pe/PeCertificateParser.test.ts | 44 +++++++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/native/pe/PeCertificateAsn1.ts b/src/native/pe/PeCertificateAsn1.ts index 0b8f760798..6d3d23eb2a 100644 --- a/src/native/pe/PeCertificateAsn1.ts +++ b/src/native/pe/PeCertificateAsn1.ts @@ -390,14 +390,18 @@ const requireSubjectPublicKeyInfo = (node: Asn1BerNode): void => { }; // issuerUniqueID [1], subjectUniqueID [2], and extensions [3] are optional -// and ordered. Anything else is not a TBSCertificate field. +// and ordered. Anything else is not a TBSCertificate field. RFC 5280 +// 4.1.2.8-4.1.2.9: unique identifiers need v2 or v3; extensions need v3. const finishTbsCertificate = ( children: readonly Asn1BerNode[], start: number, + version: number, ): void => { let cursor = start; const issuerUniqueId = children[cursor]; if (issuerUniqueId?.tag === 0x81) { + if (version < 1) + return fail("TBSCertificate issuerUniqueID requires version 2 or 3."); requireBitStringContent( issuerUniqueId.content, "TBSCertificate issuerUniqueID", @@ -406,6 +410,8 @@ const finishTbsCertificate = ( } const subjectUniqueId = children[cursor]; if (subjectUniqueId?.tag === 0x82) { + if (version < 1) + return fail("TBSCertificate subjectUniqueID requires version 2 or 3."); requireBitStringContent( subjectUniqueId.content, "TBSCertificate subjectUniqueID", @@ -414,6 +420,8 @@ const finishTbsCertificate = ( } const extensions = children[cursor]; if (extensions?.tag === 0xa3) { + if (version !== 2) + return fail("TBSCertificate extensions require version 3."); const body = extensions.children[0]; if ( extensions.children.length !== 1 || @@ -454,15 +462,22 @@ export const parseCertificate = ( requireBitString(signatureValue, "Certificate signatureValue"); const tbsChildren = sequence(tbs, "TBSCertificate"); let index = 0; - const version = tbsChildren[0]; - if (version?.tag === 0xa0) { - const versionNumber = version.children[0]; + // An omitted version is the DEFAULT v1, encoded as 0. + let version = 0; + const versionField = tbsChildren[0]; + if (versionField?.tag === 0xa0) { + const versionNumber = versionField.children[0]; if ( - version.children.length !== 1 || + versionField.children.length !== 1 || versionNumber === undefined || versionNumber.tag !== 0x02 ) return fail("TBSCertificate version is not an INTEGER."); + // RFC 5280 4.1.2.1: v1(0), v2(1) or v3(2), one minimal octet each. + const [value] = versionNumber.content; + if (versionNumber.content.length !== 1 || value === undefined || value > 2) + return fail("TBSCertificate version is not v1, v2 or v3."); + version = value; index = 1; } const serial = tbsChildren[index]; @@ -494,7 +509,7 @@ export const parseCertificate = ( "Certificate signatureAlgorithm differs from the TBSCertificate signature.", ); requireSubjectPublicKeyInfo(subjectPublicKeyInfo); - finishTbsCertificate(tbsChildren, index + 6); + finishTbsCertificate(tbsChildren, index + 6, version); const validityChildren = sequence(validity, "Validity"); const notBefore = validityChildren[0]; const notAfter = validityChildren[1]; diff --git a/src/native/pe/PeCertificateParser.test.ts b/src/native/pe/PeCertificateParser.test.ts index 66bb62bb26..91bc1ea4ca 100644 --- a/src/native/pe/PeCertificateParser.test.ts +++ b/src/native/pe/PeCertificateParser.test.ts @@ -678,6 +678,8 @@ it.each(["signature", "public-key", "issuer-id", "subject-id"] as const)( readAsn1( derSeq( derSeq( + // Unique identifiers need v2 or v3. + der(0xa0, derInt(2)), derInt(1), SHA256_ALG, name, @@ -766,6 +768,48 @@ it("rejects a certificate missing subjectPublicKeyInfo or signatureValue", () => ).toThrow(/trailing fields/u); }); +it("accepts only RFC 5280 certificate versions and the fields each allows", () => { + const name = directoryName(der(0x0c, Buffer.from("Fixture"))); + const version = (value: number) => der(0xa0, derInt(value)); + const uniqueId = der(0x81, Buffer.from([0])); + const extensions = der(0xa3, derSeq()); + const certificate = (head: Buffer[], tail: Buffer[]) => + readAsn1( + derSeq( + derSeq( + ...head, + derInt(1), + SHA256_ALG, + name, + validity, + name, + spki, + ...tail, + ), + SHA256_ALG, + der(0x03, Buffer.from([0])), + ), + ); + for (const [head, tail, message] of [ + [[version(3)], [], /version is not v1, v2 or v3/u], + [[version(0xff)], [], /version is not v1, v2 or v3/u], + [[], [uniqueId], /issuerUniqueID requires version 2 or 3/u], + [[], [extensions], /extensions require version 3/u], + [[version(1)], [extensions], /extensions require version 3/u], + ] as const) + expect(() => parseCertificate(certificate([...head], [...tail]))).toThrow( + message, + ); + for (const [head, tail] of [ + [[], []], + [[version(1)], [uniqueId]], + [[version(2)], [uniqueId, extensions]], + ] as const) + expect( + parseCertificate(certificate([...head], [...tail])).subject.text, + ).toBe("commonName=Fixture"); +}); + it("rejects a UTF8String name that is not valid UTF-8", () => { const certificate = readAsn1( namedCertificate( From b2672d872711054528d077c067fa8bd367a3e001 Mon Sep 17 00:00:00 2001 From: RRFZ <247457381+Plastic-time@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:14:35 -0500 Subject: [PATCH 010/151] fix(pe): reject a SignerInfo version that does not match its sid (#8924) --- src/native/pe/PeCertificateAsn1.ts | 10 ++++++ src/native/pe/PeCertificateParser.test.ts | 38 +++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/native/pe/PeCertificateAsn1.ts b/src/native/pe/PeCertificateAsn1.ts index 6d3d23eb2a..34211c104d 100644 --- a/src/native/pe/PeCertificateAsn1.ts +++ b/src/native/pe/PeCertificateAsn1.ts @@ -604,6 +604,16 @@ const parseSigner = ( sid, names, ); + // RFC 5652 5.3: issuerAndSerialNumber requires version 1 and + // subjectKeyIdentifier requires version 3. + const [expected, identifier] = + sid.tag === 0x80 + ? [3, "subjectKeyIdentifier"] + : [1, "issuerAndSerialNumber"]; + if (version.content.length !== 1 || version.content[0] !== expected) + return fail( + `SignerInfo version must be ${String(expected)} with ${identifier}.`, + ); let cursor = 3; const authenticated = children[cursor]?.tag === 0xa0; if (authenticated) { diff --git a/src/native/pe/PeCertificateParser.test.ts b/src/native/pe/PeCertificateParser.test.ts index 91bc1ea4ca..b8633bd807 100644 --- a/src/native/pe/PeCertificateParser.test.ts +++ b/src/native/pe/PeCertificateParser.test.ts @@ -515,6 +515,44 @@ it("keeps subjectKeyIdentifier bytes on the signer", () => { ).toThrow(/decoded budget/u); }); +it("rejects a SignerInfo version that does not match its sid", () => { + const payload = (version: number, sid: Buffer) => + contentInfo( + derSeq( + derInt(1), + derSet(SHA256_ALG), + derSeq(DATA_OID), + derSet( + derSeq( + derInt(version), + sid, + SHA256_ALG, + SHA256_ALG, + der(0x04, Buffer.from([1])), + ), + ), + ), + ); + const issuerAndSerial = derSeq(derSeq(), derInt(1)); + const ski = der(0x80, Buffer.from([0xab])); + for (const [version, sid] of [ + [3, issuerAndSerial], + [2, issuerAndSerial], + [1, ski], + ] as const) + expect(() => + parse(peCertificateFixture(false, payload(version, sid))), + ).toThrow(/SignerInfo version must be [13] with/u); + for (const [version, sid] of [ + [1, issuerAndSerial], + [3, ski], + ] as const) + expect( + parse(peCertificateFixture(false, payload(version, sid))).entries[0] + ?.pkcs7?.signers, + ).toHaveLength(1); +}); + // X.690 8.3.1 and 8.3.2 apply under BER as well as DER. const malformedSerials = [ [], From de8b2f359269b7821c5bb8fa018070f6f06109b5 Mon Sep 17 00:00:00 2001 From: RRFZ <247457381+Plastic-time@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:15:05 -0500 Subject: [PATCH 011/151] fix(pe): reject malformed and repeated certificate extensions (#8928) RFC 5280 4.1 defines an Extension as extnID, an optional critical BOOLEAN and an extnValue OCTET STRING. Section 4.2 says a certificate MUST NOT include more than one instance of an extension. The decoder only checked that extensions were a SEQUENCE. Any entry, and any repeat, was accepted. --- src/native/pe/PeCertificateAsn1.ts | 26 +++++++++++ src/native/pe/PeCertificateParser.test.ts | 56 +++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/src/native/pe/PeCertificateAsn1.ts b/src/native/pe/PeCertificateAsn1.ts index 34211c104d..25839dd00c 100644 --- a/src/native/pe/PeCertificateAsn1.ts +++ b/src/native/pe/PeCertificateAsn1.ts @@ -389,6 +389,31 @@ const requireSubjectPublicKeyInfo = (node: Asn1BerNode): void => { requireBitString(key, "SubjectPublicKeyInfo subjectPublicKey"); }; +// RFC 5280 4.1 and 4.2: an Extension is extnID, an optional critical BOOLEAN +// and an extnValue OCTET STRING. A certificate MUST NOT repeat an extnID. +const requireExtensions = (extensions: readonly Asn1BerNode[]): void => { + const seen = new Set(); + for (const extension of extensions) { + const fields = sequence(extension, "TBSCertificate extension"); + const extnId = fields[0]; + const critical = fields.length === 3 ? fields[1] : undefined; + const extnValue = fields[fields.length - 1]; + if ( + (fields.length !== 2 && fields.length !== 3) || + extnId?.tag !== 0x06 || + (critical !== undefined && + (critical.tag !== 0x01 || critical.content.length !== 1)) || + extnValue?.tag !== 0x04 + ) + return fail( + "TBSCertificate extension is not an OID, optional BOOLEAN, and OCTET STRING.", + ); + const oid = decodeOid(extnId.content); + if (seen.has(oid)) return fail(`TBSCertificate repeats extension ${oid}.`); + seen.add(oid); + } +}; + // issuerUniqueID [1], subjectUniqueID [2], and extensions [3] are optional // and ordered. Anything else is not a TBSCertificate field. RFC 5280 // 4.1.2.8-4.1.2.9: unique identifiers need v2 or v3; extensions need v3. @@ -429,6 +454,7 @@ const finishTbsCertificate = ( body.tag !== 0x30 ) return fail("TBSCertificate extensions are not a SEQUENCE."); + requireExtensions(body.children); cursor += 1; } if (cursor !== children.length) diff --git a/src/native/pe/PeCertificateParser.test.ts b/src/native/pe/PeCertificateParser.test.ts index b8633bd807..a7bde68945 100644 --- a/src/native/pe/PeCertificateParser.test.ts +++ b/src/native/pe/PeCertificateParser.test.ts @@ -1142,3 +1142,59 @@ it("rejects a certificate whose signatureAlgorithm differs from its TBS signatur parseCertificate(certificate(SHA256_ALG)).signature_algorithm_oid, ).toBe("2.16.840.1.101.3.4.2.1"); }); + +it("rejects malformed and repeated certificate extensions", () => { + const name = directoryName(der(0x0c, Buffer.from("Fixture"))); + const certificate = (...extensions: Buffer[]) => + readAsn1( + derSeq( + derSeq( + der(0xa0, derInt(2)), + derInt(1), + SHA256_ALG, + name, + validity, + name, + spki, + der(0xa3, derSeq(...extensions)), + ), + SHA256_ALG, + der(0x03, Buffer.from([0])), + ), + ); + const ski = der(0x06, Buffer.from([0x55, 0x1d, 0x0e])); + const skiValue = der(0x04, Buffer.from([0x04, 0x01, 0xaa])); + const basicConstraints = der(0x06, Buffer.from([0x55, 0x1d, 0x13])); + const basicConstraintsValue = der(0x04, Buffer.from([0x30, 0x00])); + const critical = (...content: number[]) => der(0x01, Buffer.from(content)); + for (const extension of [ + derInt(1), + derSeq(ski), + derSeq(skiValue), + derSeq(ski, derInt(1)), + derSeq(ski, derInt(1), skiValue), + derSeq(ski, critical(0xff, 0xff), skiValue), + derSeq(ski, skiValue, critical(0xff)), + derSeq(ski, skiValue, der(0x05, Buffer.alloc(0))), + ]) + expect(() => parseCertificate(certificate(extension))).toThrow( + /TBSCertificate extension/u, + ); + expect(() => + parseCertificate( + certificate( + derSeq(ski, skiValue), + derSeq(basicConstraints, basicConstraintsValue), + derSeq(ski, skiValue), + ), + ), + ).toThrow(/repeats extension 2\.5\.29\.14/u); + expect( + parseCertificate( + certificate( + derSeq(ski, skiValue), + derSeq(basicConstraints, critical(0xff), basicConstraintsValue), + ), + ).subject.text, + ).toBe("commonName=Fixture"); +}); From fc4c381dd7232cf0e73f7f0ff8b55248e33dc12c Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 5 Nov 2026 19:01:11 -0800 Subject: [PATCH 012/151] feat(desktop): keep an agentenv computer up and drive it through REA's MCP tools (#9041) * feat(desktop): keep an agentenv computer up and drive it through REA's MCP tools - A Python helper keeps one agentenv computer up per build with run_bundle(keep=True) and reports its address, MCP URL, control token and sandbox id; it tears the computer down when stdin closes. - AgentEnvComputer owns the helper, waits for readiness, holds one MCP session to the computer's computer_* tools, records through the computer-recording routes, and removes the sandbox's containers by label when agentenv cannot confirm teardown. The token never leaves it. - start_build_computer starts a computer from a request (archive package, apt repository or .deb, opened on demand with REA in /opt/rea) or from a bundle folder; act_on_build_computer forwards screenshot, click, drag, scroll, type, key and wait and returns the driver's images unchanged; close_build_computer tears it down. Co-Authored-By: Claude Opus 5.5 * fix(desktop): remove a build computer's container by name when agentenv cannot confirm teardown agentenv's local provider runs an ordinary computer as agent- without the agentenv.sandbox label, so the label-only fallback found nothing and reported a running computer as removed. The fallback now lists containers by exact name and by label. The real test holds its recording open long enough for a first frame, checks the container by name, and runs rea inside the computer; the guide and tool descriptions say the app opens from the Applications menu and that a screenshot after opening a menu should come from a wait. Co-Authored-By: Claude Opus 5.5 * fix(desktop): own failed build-computer starts, keep ids past oversized screenshots, bound recordings - A start is owned from the helper's spawn. One that fails and whose teardown is unconfirmed stays held under a computer_id close can retry; closeAll refuses new starts, cancels and joins starts in progress, then closes every held computer. - When the first screenshot is too large, the error carries REA's result with the computer_id, and start_build_computer takes screenshot region, format and quality. - Recordings are refused before reading when the manifest's total exceeds the allowance, then fetched one segment at a time, each read bounded and checked against its size and sha256. - The helper keeps the tail of its output (a new tail retention mode in ProviderProcessSupervisor), so a build's own error is not crowded out. - A helper that reported no computer exits on its own before SIGTERM, so no phantom owner is kept. Co-Authored-By: Claude Opus 5.5 * fix(desktop): bound a build computer's action log Every action kept its full arguments for the computer's whole life, so a long session grew REA's memory without limit. The log now keeps the latest 256 actions within 256 KiB, cuts each string to 1 KiB and each list to 64 items (naming what it cut), and keeps the total and the last applied index of each kind of action, so rules over the whole session hold after entries drop. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- bridge/agentenv/computer.py | 83 ++++ docs/.vitepress/config.ts | 4 + docs/desktop-build-computer.md | 158 +++++++ docs/index.md | 1 + docs/specs/desktop-app-environments/README.md | 3 + .../build-computer/plan.md | 21 + .../build-computer/spec.md | 74 +++ .../build-computer/tasks.md | 10 + .../build-computer/verification.md | 77 ++++ package.json | 1 + .../desktop/DesktopBuildActionLog.test.ts | 51 +++ .../desktop/DesktopBuildActionLog.ts | 155 +++++++ src/application/desktop/DesktopBuildBundle.ts | 110 +++++ .../desktop/DesktopBuildComputers.ts | 415 +++++++++++++++++ src/composition/capabilityDeclarations.ts | 1 + src/composition/capabilityServices.ts | 5 + src/contracts/desktopBuildToolContracts.ts | 114 +++++ src/contracts/toolContracts.ts | 2 + src/contracts/toolEffects.test.ts | 2 + src/contracts/toolEffects.ts | 24 + src/contracts/toolFamilies.ts | 3 + src/desktop/AgentEnvComputer.ts | 365 +++++++++++++++ src/desktop/AgentEnvComputerHelper.ts | 351 ++++++++++++++ src/domain/desktop/appBuild.test.ts | 189 ++++++++ src/domain/desktop/appBuild.ts | 321 +++++++++++++ src/domain/desktop/appExport.ts | 17 +- src/domain/desktop/computerActions.test.ts | 70 +++ src/domain/desktop/computerActions.ts | 197 ++++++++ src/process/ProviderProcess.ts | 86 +++- src/process/ProviderProcessOutput.test.ts | 49 ++ src/server/ToolResultDelivery.ts | 50 ++ src/server/createServer.ts | 3 + src/server/registerDesktopBuildTools.ts | 99 ++++ tests/boundary/cli/cliToolCoverage.test.ts | 4 + tests/boundary/mcp/buildComputerMcp.test.ts | 431 ++++++++++++++++++ .../providers/buildComputerLifecycle.test.ts | 72 +++ .../providers/buildComputerReal.test.ts | 84 ++++ .../providers/buildComputerRecording.test.ts | 139 ++++++ tests/fixtures/fakeAgentEnvComputer.ts | 426 +++++++++++++++++ 39 files changed, 4250 insertions(+), 17 deletions(-) create mode 100644 bridge/agentenv/computer.py create mode 100644 docs/desktop-build-computer.md create mode 100644 docs/specs/desktop-app-environments/build-computer/plan.md create mode 100644 docs/specs/desktop-app-environments/build-computer/spec.md create mode 100644 docs/specs/desktop-app-environments/build-computer/tasks.md create mode 100644 docs/specs/desktop-app-environments/build-computer/verification.md create mode 100644 src/application/desktop/DesktopBuildActionLog.test.ts create mode 100644 src/application/desktop/DesktopBuildActionLog.ts create mode 100644 src/application/desktop/DesktopBuildBundle.ts create mode 100644 src/application/desktop/DesktopBuildComputers.ts create mode 100644 src/contracts/desktopBuildToolContracts.ts create mode 100644 src/desktop/AgentEnvComputer.ts create mode 100644 src/desktop/AgentEnvComputerHelper.ts create mode 100644 src/domain/desktop/appBuild.test.ts create mode 100644 src/domain/desktop/appBuild.ts create mode 100644 src/domain/desktop/computerActions.test.ts create mode 100644 src/domain/desktop/computerActions.ts create mode 100644 src/server/registerDesktopBuildTools.ts create mode 100644 tests/boundary/mcp/buildComputerMcp.test.ts create mode 100644 tests/boundary/providers/buildComputerLifecycle.test.ts create mode 100644 tests/boundary/providers/buildComputerReal.test.ts create mode 100644 tests/boundary/providers/buildComputerRecording.test.ts create mode 100644 tests/fixtures/fakeAgentEnvComputer.ts diff --git a/bridge/agentenv/computer.py b/bridge/agentenv/computer.py new file mode 100644 index 0000000000..fc1540e29b --- /dev/null +++ b/bridge/agentenv/computer.py @@ -0,0 +1,83 @@ +"""Keep one agentenv computer up for REA until stdin closes or SIGTERM arrives. + +REA runs ``computer.py [task]`` with the agentenv runner's own interpreter, so ``agent_env`` +imports. It deploys the bundle's task, ``smoke`` by default, on this machine with +``run_bundle(keep=True, emulate=False)``, prints one JSON line naming the computer's address, MCP URL, control +token and sandbox id, or ``{"error": ...}``, and tears the computer down once its stdin closes or it gets SIGTERM +or SIGINT. Everything else it or agentenv prints, a build's output included, goes to stderr. It exits 0 once +the teardown left nothing up, 1 when it did, and 2 when no computer came up. +""" + +from __future__ import annotations + +import json +import os +import signal +import sys +import threading + + +def _protocol(): + """A private copy of stdout for the JSON line; fd 1 then points at stderr, as do children that inherit it.""" + protocol = os.fdopen(os.dup(1), "w", encoding="utf-8") + os.dup2(2, 1) + sys.stdout = sys.stderr + return protocol + + +def _say(protocol, value: dict) -> None: + protocol.write(json.dumps(value) + "\n") + protocol.flush() + + +def _computer(result): + """The run's deployed computer, or why there is none.""" + run = result.runs[0] if result.runs else None + if run is None: + return None, "the bundle ran no task" + if run.error is not None or run.failed_steps: + return None, f"the task failed: {run.error or run.failed_steps}" + for env in run.context.deployed_envs: + if env.environment_url and env.mcp_url and getattr(env, "sandbox_id", None): + return env, None + return None, "the task deployed no computer" + + +def main(argv: list[str]) -> int: + protocol = _protocol() + bundle, task = argv[1], argv[2] if len(argv) > 2 else "smoke" + try: + from agent_env.bundle import run_bundle + except ImportError as error: + _say(protocol, {"error": f"agent_env does not import with {sys.executable}: {error}"}) + return 2 + try: + result = run_bundle(bundle, tasks=[task], keep=True, emulate=False, sandbox="local", + on_progress=lambda line: print(line, file=sys.stderr, flush=True)) + except BaseException as error: # noqa: BLE001 - a signal's RunInterrupted too; run_bundle tore its runs down + _say(protocol, {"error": f"{type(error).__name__}: {error}"}) + return 2 + stop = threading.Event() + for signum in (signal.SIGTERM, signal.SIGINT): + signal.signal(signum, lambda *_: stop.set()) + env, problem = _computer(result) + try: + if env is None: + _say(protocol, {"error": problem}) + else: + _say(protocol, {"base_url": env.environment_url, "mcp_url": env.mcp_url, + "control_token": env.control_token, "sandbox_id": env.sandbox_id}) + threading.Thread(target=lambda: (sys.stdin.buffer.read(), stop.set()), daemon=True).start() + while not stop.wait(0.5): + pass + finally: + torn_down = result.teardown() + still_up = [sandbox for run in torn_down.runs if run.torn_down is not None for sandbox in run.torn_down.still_up] + if still_up: + print(f"still up after the teardown: {still_up}", file=sys.stderr, flush=True) + return 1 + return 0 if env is not None else 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index a438e9257d..304218e341 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -78,6 +78,10 @@ export default defineConfig({ text: "Desktop application tasks", link: "/desktop-application-tasks", }, + { + text: "Desktop build computer", + link: "/desktop-build-computer", + }, { text: ".NET assemblies", link: "/managed-code-analysis" }, { text: "Python packaged applications", diff --git a/docs/desktop-build-computer.md b/docs/desktop-build-computer.md new file mode 100644 index 0000000000..e5be5c362c --- /dev/null +++ b/docs/desktop-build-computer.md @@ -0,0 +1,158 @@ +# Desktop build computer + +A build computer is a local agentenv computer that an agent drives while it +builds a desktop app environment. Claude Code cannot attach a new MCP server +in the middle of a session, so REA keeps the computer up and forwards the +agent's computer-use actions to it through three MCP tools: + +| Tool | What it does | +| ----------------------- | ------------------------------------------------------------------------------------------ | +| `start_build_computer` | Starts a computer from a request or a bundle folder; returns its id and a first screenshot | +| `act_on_build_computer` | Sends one action to the computer's driver and returns the driver's answer, images included | +| `close_build_computer` | Tears the computer down, by agentenv's teardown or by removing its sandbox's containers | + +The tools are MCP-only, like the debugger sessions: the computer lives as long +as the REA MCP server that started it, or until it is closed. + +## Requirements + +- Docker, and an agentenv-framework install whose `agent-env` program is named + by the absolute `REA_AGENT_ENV_COMMAND`. Bundles target agentenv + 0.9.1431. REA runs its helper, `bridge/agentenv/computer.py`, with that + runner's Python: the interpreter on the program's `#!` line, or the + `python3` beside it in its virtual environment. +- `REA_DOCKER_COMMAND`, the absolute path of the Docker CLI, lets REA remove a + computer whose agentenv teardown was not confirmed. Without it, REA names the + command to run instead. +- For a request, `REA_BUILD_COMPUTER_REA_ARCHIVE`: the absolute path of a tar + archive of a runnable REA, with its production dependencies, whose one top + folder holds `scripts/rea.mjs`. The computer runs REA from it. + +Set these where the MCP server starts; a shell export does not reach a server +that is already running. + +## Start a computer + +From a request, REA renders a bundle in a private temporary folder: a computer +app on the `ubuntu-24.04` base with the `reference` driver that installs the +app, opens it on demand with `gtk-launch ` and keeps no seed and +no data hooks. The app is not open when the computer starts: agentenv lists it +in the desktop's Applications menu under Other by the environment's name, such +as `mousepad`, and that entry launches it through the app's server. + +```json +{ + "source": { + "kind": "request", + "desktop_entry": "org.xfce.mousepad", + "window_class": "Mousepad", + "install": { "kind": "archive", "package": "mousepad" } + } +} +``` + +`install` takes one of: + +| `kind` | Installs | +| ---------------- | ---------------------------------------------------------------------------------------------------------- | +| `archive` | `package` from the Ubuntu 24.04 archive, on amd64 and arm64 | +| `apt_repository` | `package` from the repository at `uri` with `suite` and `components`, trusting the key file `key_file` | +| `package_file` | The `.deb` at `url`, checked against `sha256`, for one `architecture`, after its apt `dependencies` are in | + +Build steps run with the network off, so a `.deb`'s dependencies must be +listed. The bundle adds REA in a build-only layer: Node.js +24.22.1, downloaded per architecture and checked against its release checksum, +and REA from `REA_BUILD_COMPUTER_REA_ARCHIVE`, both unpacked into `/opt/rea` by +a root build step, with `node` and `rea` on `PATH`. An exported bundle carries +none of it. + +From a bundle folder, such as one `export-desktop-application` wrote, REA +starts that bundle's `tasks/smoke.json` as it is: + +```json +{ "source": { "kind": "bundle", "path": "/home/ada/mousepad-bundle" } } +``` + +REA runs the deploy with `run_bundle(keep=True, emulate=False)` on the local +sandbox provider, so only the host's own architecture is built. The first start +of a bundle builds its image and can take minutes; later starts reuse it. REA +then waits until the computer's readiness checks pass, opens one MCP session +to its driver and takes a screenshot, with the `region`, `format` and `quality` +given in `screenshot`. The result holds `computer_id`, agentenv's `sandbox_id`, +the environment name or bundle folder, and the screenshot's frame as structured +content beside its image. + +A failed start returns the last 16 KiB of the helper's stderr, where a failing +build prints its error, and its exit status; a cancelled one stops the helper, +which tears down whatever agentenv brought up. When that teardown is not +confirmed, the error's `computer_id` names what REA keeps for +`close_build_computer` to retry. Closing the server cancels starts still in +progress and waits for them before closing every computer. + +## Act + +`act_on_build_computer` takes `computer_id`, an `action` and the driver's own +arguments for `computer_`, which REA passes through: + +| `action` | Arguments | +| ------------ | --------------------------------------------------------------- | +| `screenshot` | `region`, `format` (`png` or `jpeg`), `quality` | +| `click` | `x`, `y`, `button`, `count` (2 double-clicks), `modifiers` | +| `drag` | `path` of `[x, y]` points, `button`, `duration_ms`, `modifiers` | +| `scroll` | `direction`, `x`, `y`, `amount`, `modifiers` | +| `type` | `text`, where `\n` presses Enter and `\t` Tab | +| `key` | `keys`, one chord pressed in order, and `repeat` | +| `wait` | `ms` | + +Pointer actions also take `frame_id`, `space` and `norm_scale`, and every +action but `screenshot` takes `screenshot: true` for an image afterwards. That +image is taken at once, so after an action that opens a menu or a window, ask +for it with a `wait` of a few hundred milliseconds instead. +Points are pixels of the latest frame, so take a screenshot first; with +`space: "norm"` they are on a `0..norm_scale` grid over it. Keys are W3C names +or one character; REA translates model spellings such as `ctrl+s`, `Return`, +`esc` and `cmd` and leaves other names for the driver to judge. An argument the +action does not take is refused before anything is sent. + +The result carries the driver's content unchanged, its text and images, after +REA's own JSON: `action_index` and the driver's structured result. A refusal, +such as a stale frame, is an error carrying the computer's message. A response +larger than the MCP budget is refused with how to ask for a smaller image: a +`region`, or `jpeg` with a lower `quality`. That error keeps REA's own result, +`computer_id` included, as `details.result`, since the action or start has +happened. + +Every action is logged on the computer in order with the arguments sent, its +start and end and whether it was applied, refused or failed. The log keeps the +latest 256 actions within 256 KiB of JSON, dropping the oldest first. Each +string argument, such as `type`'s `text`, is kept to its first 1 KiB and each +list, such as `drag`'s `path`, to its first 64 items; an entry names the +arguments it cut in `truncated_arguments`, and the driver gets them whole. The +log counts every action, so it says how many it dropped, and keeps the index of +the last applied action of each kind for rules over the whole session. The log +and the computer's recording hooks (`computer-recording/v1` start and end under +REA's own request id, and the segments fetched through the driver's `data/get` +one at a time, each read within its manifest size and checked against it) are +kept for later build stages; they are not tools. A recording larger than a +quarter of the V8 heap is refused before any segment is read. + +## Close + +`close_build_computer` ends REA's MCP session and closes the helper's stdin, +on which the helper runs agentenv's teardown. When agentenv confirms nothing is +left, the result says `teardown: "agentenv"`. Otherwise REA removes the +containers of that exact sandbox: agentenv runs a computer as +`agent-` without a label and labels its other containers +`agentenv.sandbox=`, so REA lists both with `docker ps -aq +--filter`, removes them with `docker rm -f`, and says +`teardown: "container_label"`. When neither confirms it, the error names what +is left and the computer stays listed, so closing it again retries. Closing the +MCP server closes every computer the same way, and a helper whose REA server +exits sees its stdin close and tears down. + +The control token that guards the computer's framework-only routes stays in +REA: no result, error or log carries it. Code running in the computer as its +user can still read it, which is acceptable for a local build. + +See the [specification](specs/desktop-app-environments/build-computer/spec.md) +for scope and verification. diff --git a/docs/index.md b/docs/index.md index 88c4ec2ec1..caabe1e59d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -63,6 +63,7 @@ For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md). | Read a captured application's state | [Desktop application state](desktop-application-state.md) | | Export a captured application as an environment | [Desktop application export](desktop-application-export.md) | | Write and validate a task for an exported app | [Desktop application tasks](desktop-application-tasks.md) | +| Drive a computer while building an environment | [Desktop build computer](desktop-build-computer.md) | | Use CLI/MCP and retain evidence | [CLI](cli.md), [MCP contracts](mcp-contracts.md), [agent prompts](mcp-prompts.md), [obligation ledgers](reconstruction-obligation-ledgers.md) | For development, use [testing](testing.md), [client verification](client-verification.md), diff --git a/docs/specs/desktop-app-environments/README.md b/docs/specs/desktop-app-environments/README.md index 2da35c2485..befba07af7 100644 --- a/docs/specs/desktop-app-environments/README.md +++ b/docs/specs/desktop-app-environments/README.md @@ -39,6 +39,9 @@ The work lands as one PR per step: draft are told; apt lists from a user-space `apt-get update` are found; the application does not inherit the computer's control token; and the bundle holds neither the capture host's name nor its user's. +12. [Build computer](build-computer/spec.md): an agentenv computer REA keeps + up from a request or a bundle, with REA installed inside for building, + driven through REA's MCP tools while an agent builds the environment. Version 1 covers Linux with X11, applications whose state lives in files or SQLite under the home directory, and real applications from public packages. diff --git a/docs/specs/desktop-app-environments/build-computer/plan.md b/docs/specs/desktop-app-environments/build-computer/plan.md new file mode 100644 index 0000000000..35e1f6a7d1 --- /dev/null +++ b/docs/specs/desktop-app-environments/build-computer/plan.md @@ -0,0 +1,21 @@ +# Plan + +1. Domain: the build request schema and the bundle renderer beside the + export's renderers, reusing their env.toml, apt source, install script, + smoke task and naming helpers; the action schema, per-action argument + admission and key translation. +2. Helper: `bridge/agentenv/computer.py`, shipped in the package, with its + exit statuses (0 torn down, 1 left up, 2 nothing came up). +3. Adapter: `src/desktop/AgentEnvComputer.ts` finds the runner's Python, + spawns and supervises the helper, reads the handshake, waits for readiness, + holds the driver MCP session and the token, serves the recording hooks and + releases the computer, removing its containers by name and label when needed. +4. Application: `DesktopBuildComputers` renders request bundles into a private + folder, starts and holds computers, forwards actions with their log, and + closes them, keeping unconfirmed ones for retry. +5. MCP: contracts, effects, the `application` family, the capability service, + registration, image pass-through in `ToolResultDelivery`, and the MCP-only + list for CLI parity. +6. Docs and tests: the guide; renderer and key tests; boundary tests with a + fake driver and a fake `agent_env` behind the real helper; an opt-in real + test with agentenv and Docker. diff --git a/docs/specs/desktop-app-environments/build-computer/spec.md b/docs/specs/desktop-app-environments/build-computer/spec.md new file mode 100644 index 0000000000..6c0854061f --- /dev/null +++ b/docs/specs/desktop-app-environments/build-computer/spec.md @@ -0,0 +1,74 @@ +# Build computer + +## Outcome + +An agent that builds a desktop app environment, such as Claude Code, drives a +real agentenv computer through REA's own MCP tools: REA keeps the computer up, +forwards each computer-use action to its driver and returns the driver's answer, +screenshots included, then tears the computer down. The computer starts from a +request (the app's install source and desktop entry), with REA installed inside +it for building, or from an existing bundle folder. + +## Requirements + +- MCP-only session tools in the `application` family: `start_build_computer`, + `act_on_build_computer` and `close_build_computer`, held by one owner per + server and closed with it. +- Lifecycle: one helper process per computer, `bridge/agentenv/computer.py`, + spawned with REA's owned-process supervision and run with the agentenv + runner's Python, found from the absolute `REA_AGENT_ENV_COMMAND`. It calls + `run_bundle(, tasks=["smoke"], keep=True, emulate=False, + sandbox="local")`, prints one JSON line (base URL, MCP URL, control token, + sandbox id) on a private copy of stdout, sends everything else to stderr, + and tears down on stdin EOF, SIGTERM or SIGINT. REA waits for + `GET /agentenv/ext/computer-ready/status` with the token, then holds one MCP + session to the driver's `/mcp`. +- Teardown: closing stdin, then SIGTERM, then the process group. When the helper + does not confirm the teardown, REA removes the containers of the exact + sandbox id it reported, the one named `agent-` and any labelled + `agentenv.sandbox=`, through the absolute `REA_DOCKER_COMMAND`. An + unconfirmed teardown keeps the computer listed for a retried close. + Cancelling a start stops the helper, whose deploy tears down what it began; + a helper that reported its failure finishes its own teardown first. The owner + holds each start from its helper's spawn: a failed start whose teardown is + unconfirmed stays held under the `computer_id` its error names, and shutdown + cancels and joins starts in progress before closing every computer. +- Diagnostics: the helper's stderr is kept as its last 16 KiB, through the + shared supervisor's tail retention, so a failed build reports its error + rather than its first package downloads. +- The control token never appears in a result, an error or a log; the helper's + stderr is redacted of it. +- Build bundle from a request: the app from the Ubuntu archive, an apt + repository with its key file, or a `.deb` URL with its SHA-256, dependencies + and architecture, rendered with the export's install shapes and Python + helpers; `Launch(at="demand")` through `gtk-launch`; no seed and no data + hooks; REA in a build-only layer, with Node.js 24.22.1 downloaded per + architecture and checked, and REA's payload a parameter: a local archive + shipped in the environment folder (`REA_BUILD_COMPUTER_REA_ARCHIVE`) or, + later, a released download. +- Actions: one tool with an `action` (screenshot, click, drag, scroll, type, + key, wait) and the driver's own arguments, passed through, frame or `norm` + coordinates included. Common key spellings become W3C names. An argument the + action does not take is refused before sending. Results carry the driver's + content blocks unchanged after REA's JSON, which `ToolResultDelivery` measures + against the response budget as a whole; a response over it is refused with + REA's own result, `computer_id` included, kept in the error. The first + screenshot takes `region`, `format` and `quality`. Each action is logged per + computer within a fixed allowance: the latest 256 actions in 256 KiB of JSON, + oldest dropped first, each string argument cut to 1 KiB and each list to 64 + items with the cut arguments named, beside a count of every action and the + index of the last applied action of each kind. +- Recording hooks, internal: start and end `computer-recording/v1` under REA's + request id, and fetch the segments through the driver's `data/get` one per + request, each answer read within its segment's base64 size and the segment + checked against the manifest's size and SHA-256, before teardown. A + recording over the caller's allowance, a quarter of the V8 heap by default, + is refused before any read. + +## Non-goals + +- Capture checkpoints, the apt lists folder, the capture's launch environment + and the host scrub, which a sibling change owns. +- The witness and its record, which read the action log and recordings. +- Remote sandbox providers, emulated architectures, VNC, and reaping orphans + left by an earlier REA process. diff --git a/docs/specs/desktop-app-environments/build-computer/tasks.md b/docs/specs/desktop-app-environments/build-computer/tasks.md new file mode 100644 index 0000000000..43c330665c --- /dev/null +++ b/docs/specs/desktop-app-environments/build-computer/tasks.md @@ -0,0 +1,10 @@ +# Tasks + +- [x] Record the outcome, requirements and non-goals. +- [x] Render the build bundle from a request and admit actions. +- [x] Keep a computer up with the helper and tear it down, by name and label when needed. +- [x] Forward actions with images and log them; add the recording hooks. +- [x] Register the MCP tools and their catalog entries. +- [x] Document the tools. +- [x] Add boundary tests with a fake driver and an opt-in real test. +- [x] Run the opt-in real test on an amd64 host and record it here. diff --git a/docs/specs/desktop-app-environments/build-computer/verification.md b/docs/specs/desktop-app-environments/build-computer/verification.md new file mode 100644 index 0000000000..0d7579bdf6 --- /dev/null +++ b/docs/specs/desktop-app-environments/build-computer/verification.md @@ -0,0 +1,77 @@ +# Verification + +Run on 2026-11-05 on macOS 26.5 with Node v24.22.1 and Python 3.11. Docker and +agentenv were not used by the gates or the boundary tests. + +## Tests + +- `src/domain/desktop/appBuild.test.ts`: the bundle for an archive package, an + apt repository with its key and a `.deb` with a downloaded REA; the Node.js + pin matches REA's; refused desktop entries, credentialed or non-http URLs and + relative bundle paths. +- `src/domain/desktop/computerActions.test.ts`: key translation, argument + pass-through and refusal of an argument the action does not take. +- `tests/boundary/mcp/buildComputerMcp.test.ts`, through the public MCP tools + with REA's real helper, a fake `agent_env` package on `PYTHONPATH` and a fake + driver serving readiness, `computer_*` tools over streamable HTTP, + recording and `data/get`: start with readiness polling, the image pass-through, + argument pass-through and key translation, a driver refusal, a refused + argument, close and a later unknown id; a request bundle with REA's payload, + compiled by the fake runner; the missing payload and runner settings; a + runner failure with its output; removal by name and label; an unconfirmed teardown + retried after Docker recovers; cancellation of a start stopping the helper; + a long build's failure reported from the end of its output; a first + screenshot too large for the response that keeps `computer_id` for act and + close; the first screenshot's options. No result carries the control token. +- `tests/boundary/providers/buildComputerRecording.test.ts`: recording start, + end and one fetch per segment under REA's request id, the action log, a + long session's log kept within its allowance with every action counted and + the driver still getting each argument whole, a segment that differs from + its manifest, a recording over the allowance refused before any read, and an + answer outgrowing its segment's size. +- `src/application/desktop/DesktopBuildActionLog.test.ts`: the log names only + the arguments it cut, cuts text without splitting a character, and records + the outcome of an action dropped while it was pending. +- `tests/boundary/providers/buildComputerLifecycle.test.ts`: shutdown cancels + and joins a start in progress and refuses later starts; a failed start whose + teardown is unconfirmed stays held under the id its error names, keeps its + primary failure, and is released by a retried close and by shutdown. +- `src/process/ProviderProcessOutput.test.ts`: the supervisor's tail retention + keeps each stream's latest bytes, reports checkpoints over dropped bytes as + incomplete, and drops a UTF-8 character the cut would split. +- `tests/boundary/providers/buildComputerReal.test.ts`: opt-in with + `REA_BUILD_COMPUTER_REAL=1`, `REA_AGENT_ENV_COMMAND`, `REA_DOCKER_COMMAND` and + `REA_BUILD_COMPUTER_REA_ARCHIVE`; skipped here. + +## Checks against agentenv + +- `python -m agentenv_protocol.computer._declare` from agentenv-framework + 0.9.1431 declared both a Mousepad archive bundle and a `.deb` bundle with a + downloaded REA: packages, downloads per architecture, both run scripts and + the demand launch. +- `install-rea.sh` ran as root with the network off in a local Ubuntu 24.04 + container against a Node.js-shaped and an REA-shaped archive: it unpacked + both into `/opt/rea`, put `node` and `rea` on `PATH` and removed the + archives. + +## Real computer + +Run on 2026-11-05 on Ubuntu 24.04 amd64 with Docker, agentenv-framework +0.9.1431 and Node v24.22.1. REA's archive was packed from this tree with +`npm pack`, then `npm ci --omit=dev --omit=optional` in its `rea-prod` folder, +18.5 MB as `.tar.xz`. + +- `tests/boundary/providers/buildComputerReal.test.ts` passed in 25 s with the + image built; the first run, which builds it, took 109 s. +- Through `rea mcp` over stdio: `start_build_computer` from the Mousepad + request came up in 31 s with its first screenshot as a PNG. Mousepad opened + from Applications, Other, `mousepad`; a typed line was saved with `ctrl+s` + through the Save As dialog; actions took 6 to 60 ms beside the driver's own + typing and waits. `rea --version` answered 6.8.0 in the computer for its user + and root. `close_build_computer` took 0.5 s with `teardown: "agentenv"` and + left no container. The exported Inkscape bundle started cold in 59 s and + closed the same way; a start cancelled at 60 s left nothing up. +- With the helper killed, close removed the computer's container by its name, + `agent-`: agentenv labels only its VM and closed-network + containers. +- No result or log held the control token. diff --git a/package.json b/package.json index 6635d5ef71..a2849ebf63 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "bridge/ghidra/ReaGhidraPrepareDol.java", "bridge/android/ReaJadxBridge.java", "bridge/mitmproxy/capture.py", + "bridge/agentenv/computer.py", "scripts/rea.mjs", "scripts/lib/cli-process-environment.mjs", "scripts/rea-android-workspace-guardian.mjs", diff --git a/src/application/desktop/DesktopBuildActionLog.test.ts b/src/application/desktop/DesktopBuildActionLog.test.ts new file mode 100644 index 0000000000..23934ba0ca --- /dev/null +++ b/src/application/desktop/DesktopBuildActionLog.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { + BuildComputerActionLog, + LOGGED_ACTIONS, + LOGGED_LIST_ITEMS, + LOGGED_TEXT_BYTES, +} from "./DesktopBuildActionLog.js"; + +describe("build computer action log", () => { + it("cuts each list and string argument, and names only what it cut", () => { + const log = new BuildComputerActionLog(); + const path = Array.from({ length: 3 * LOGGED_LIST_ITEMS }, (_, at) => [ + at, + at, + ]); + log.begin("drag", "computer_drag", { path, button: "left" }); + // Two-byte characters, so the cut must not split one. + log.begin("type", "computer_type", { text: "é".repeat(LOGGED_TEXT_BYTES) }); + log.begin("key", "computer_key", { keys: ["Control", "x".repeat(2000)] }); + log.begin("click", "computer_click", { x: 1, y: 2 }); + expect(log.entries).toEqual([ + expect.objectContaining({ + arguments: { path: path.slice(0, LOGGED_LIST_ITEMS), button: "left" }, + truncated_arguments: ["path"], + outcome: "pending", + }), + expect.objectContaining({ + arguments: { text: "é".repeat(LOGGED_TEXT_BYTES / 2) }, + truncated_arguments: ["text"], + }), + expect.objectContaining({ + arguments: { keys: ["Control", "x".repeat(LOGGED_TEXT_BYTES)] }, + truncated_arguments: ["keys"], + }), + expect.not.objectContaining({ truncated_arguments: expect.anything() }), + ]); + }); + + it("records the outcome of an action dropped while it was pending", () => { + const log = new BuildComputerActionLog(); + const slow = log.begin("key", "computer_key", { keys: ["Control", "s"] }); + for (let index = 0; index < LOGGED_ACTIONS; index += 1) + log.begin("wait", "computer_wait", { ms: 1 }).settle("applied"); + expect(log.dropped).toBe(1); + slow.settle("applied"); + expect(log.lastApplied).toEqual({ key: 0, wait: LOGGED_ACTIONS }); + expect(log.entries).toHaveLength(LOGGED_ACTIONS); + expect(log.entries.every((entry) => entry.action === "wait")).toBe(true); + }); +}); diff --git a/src/application/desktop/DesktopBuildActionLog.ts b/src/application/desktop/DesktopBuildActionLog.ts new file mode 100644 index 0000000000..aa5e112753 --- /dev/null +++ b/src/application/desktop/DesktopBuildActionLog.ts @@ -0,0 +1,155 @@ +import type { ComputerAction } from "../../domain/desktop/computerActions.js"; +import type { JsonValue } from "../../domain/jsonValue.js"; +import { boundedUtf8Text } from "../../process/boundedUtf8Text.js"; + +/** Bytes the log keeps of each string in an action's arguments, such as type's text. */ +export const LOGGED_TEXT_BYTES = 1024; +/** Items the log keeps of each list in an action's arguments, such as drag's path. */ +export const LOGGED_LIST_ITEMS = 64; +/** The latest actions the log keeps, and the JSON bytes they may take together. */ +export const LOGGED_ACTIONS = 256; +export const LOGGED_BYTES = 256 * 1024; + +type Outcome = "pending" | "applied" | "refused" | "failed"; + +/** One forwarded action, in the order REA sent it, as the witness reads it. */ +export interface BuildComputerAction { + readonly index: number; + readonly action: ComputerAction; + readonly tool: string; + /** The driver's arguments as sent, keys translated, each string and list cut to the log's allowance. */ + readonly arguments: Readonly>; + /** The arguments cut in this entry; the driver received them whole. */ + readonly truncated_arguments?: readonly string[]; + readonly started_at: string; + readonly ended_at: string | null; + readonly outcome: Outcome; +} + +/** A value within the log's allowance, and whether it was cut. */ +const bounded = ( + value: JsonValue, +): { readonly value: JsonValue; readonly cut: boolean } => { + if (typeof value === "string") { + const { text, truncated } = boundedUtf8Text(value, LOGGED_TEXT_BYTES); + return { value: text, cut: truncated }; + } + if (Array.isArray(value)) { + const items = value.slice(0, LOGGED_LIST_ITEMS).map(bounded); + return { + value: items.map((item) => item.value), + cut: value.length > items.length || items.some((item) => item.cut), + }; + } + if (value === null || typeof value !== "object") return { value, cut: false }; + const fields = Object.entries(value).map( + ([name, field]) => [name, bounded(field)] as const, + ); + return { + value: Object.fromEntries( + fields.map(([name, field]) => [name, field.value]), + ), + cut: fields.some(([, field]) => field.cut), + }; +}; + +const jsonBytes = (entry: BuildComputerAction): number => + Buffer.byteLength(JSON.stringify(entry)); + +/** + * A build computer's action log. It counts every action and keeps the index + * of the last applied action of each kind, so rules over a whole session hold + * however long it runs, and keeps the latest actions themselves within + * `LOGGED_ACTIONS` and `LOGGED_BYTES`, dropping the oldest first. + */ +export class BuildComputerActionLog { + readonly #kept: { entry: BuildComputerAction; bytes: number }[] = []; + readonly #lastApplied: Partial> = {}; + #total = 0; + #bytes = 0; + + /** Every action logged, kept or not; the next one's index. */ + get total(): number { + return this.#total; + } + + /** How many of the earliest actions are no longer kept. */ + get dropped(): number { + return this.#total - this.#kept.length; + } + + /** The kept actions, oldest first, indexed on from `dropped`. */ + get entries(): readonly BuildComputerAction[] { + return this.#kept.map(({ entry }) => entry); + } + + /** The index of the last applied action of each kind, kept or not. */ + get lastApplied(): Readonly>> { + return { ...this.#lastApplied }; + } + + /** Log an action as it is sent, pending, and how to record how it ended. */ + begin( + action: ComputerAction, + tool: string, + args: Readonly>, + ): { + readonly index: number; + readonly settle: (outcome: Exclude) => void; + } { + const index = this.#total; + const fields = Object.entries(args).map( + ([name, value]) => [name, bounded(value)] as const, + ); + const truncated = fields.flatMap(([name, field]) => + field.cut ? [name] : [], + ); + const entry: BuildComputerAction = { + index, + action, + tool, + arguments: Object.fromEntries( + fields.map(([name, field]) => [name, field.value]), + ), + ...(truncated.length === 0 ? {} : { truncated_arguments: truncated }), + started_at: new Date().toISOString(), + ended_at: null, + outcome: "pending", + }; + this.#total += 1; + this.#keep(this.#kept.length, entry); + return { + index, + settle: (outcome) => { + if (outcome === "applied") + this.#lastApplied[action] = Math.max( + index, + this.#lastApplied[action] ?? index, + ); + // An action dropped while pending leaves only its outcome above. + const slot = index - this.dropped; + if (this.#kept[slot] !== undefined) + this.#keep(slot, { + ...entry, + outcome, + ended_at: new Date().toISOString(), + }); + }, + }; + } + + /** Put an entry in its slot, then drop the oldest until the rest fit the allowance. */ + #keep(slot: number, entry: BuildComputerAction): void { + const bytes = jsonBytes(entry); + this.#bytes += bytes - (this.#kept[slot]?.bytes ?? 0); + this.#kept[slot] = { entry, bytes }; + while (this.#kept.length > LOGGED_ACTIONS || this.#bytes > LOGGED_BYTES) + this.#bytes -= this.#kept.shift()?.bytes ?? 0; + } +} + +/** What later stages read of a computer's action log. */ +export type ReadonlyActionLog = Pick< + BuildComputerActionLog, + "total" | "dropped" | "entries" | "lastApplied" +>; diff --git a/src/application/desktop/DesktopBuildBundle.ts b/src/application/desktop/DesktopBuildBundle.ts new file mode 100644 index 0000000000..014ad26e06 --- /dev/null +++ b/src/application/desktop/DesktopBuildBundle.ts @@ -0,0 +1,110 @@ +import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join } from "node:path"; + +import { + renderBuildBundle, + type BuildRequest, + type ReaPayload, +} from "../../domain/desktop/appBuild.js"; +import { + AnalysisCapabilityUnavailableError, + AnalysisInputError, +} from "../../domain/analysisErrorCore.js"; +import { AGENTENV_PROVIDER } from "../../desktop/AgentEnvComputerHelper.js"; +import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js"; + +const START = "start_build_computer"; +const MAX_KEY_BYTES = 1024 * 1024; + +/** The archive the build layer unpacks REA from, as the operator configured it. */ +const reaPayload = async ( + environment: Readonly, +): Promise => { + const path = environment.REA_BUILD_COMPUTER_REA_ARCHIVE; + const reason = + "A build computer from a request needs REA_BUILD_COMPUTER_REA_ARCHIVE, the absolute path of a tar archive of a runnable REA whose one top folder holds scripts/rea.mjs."; + if ( + path === undefined || + !isAbsolute(path) || + !(await stat(path).then( + (entry) => entry.isFile(), + () => false, + )) + ) + throw new AnalysisCapabilityUnavailableError( + AGENTENV_PROVIDER, + START, + reason, + { + userMessage: reason, + remediationAction: + "Pack a built REA with its production dependencies, set REA_BUILD_COMPUTER_REA_ARCHIVE to the archive and restart REA, or start from an exported bundle folder.", + }, + ); + return { kind: "file", path }; +}; + +/** An apt repository's key file, read whole once it is known to be a small regular file. */ +const readAptKey = async (path: string): Promise => { + const refuse = (reason: string, cause?: unknown) => + new AnalysisInputError(START, { + userMessage: `The apt key file ${path} ${reason}.`, + conformsToInputContract: true, + ...(cause === undefined ? {} : { cause }), + }); + const entry = await stat(path).catch((cause: unknown) => { + throw refuse("cannot be read", cause); + }); + if (!entry.isFile() || entry.size > MAX_KEY_BYTES) + throw refuse( + `is not a regular file of at most ${String(MAX_KEY_BYTES)} bytes, as a signing key is`, + ); + return readFile(path).catch((cause: unknown) => { + throw refuse("cannot be read", cause); + }); +}; + +/** Write the request's build bundle into a private folder, which the computer's owner removes on close. */ +const writeBuildBundle = async ( + request: BuildRequest, + payload: ReaPayload, +): Promise<{ + readonly workspace: PrivateRuntimeRoot; + readonly bundle: string; + readonly name: string; +}> => { + const key = + request.install.kind === "apt_repository" + ? await readAptKey(request.install.key_file) + : undefined; + const { name, files } = renderBuildBundle(request, payload, key); + const workspace = await PrivateRuntimeRoot.create({ + prefix: "rea-build-computer-", + }); + try { + const bundle = join(workspace.path, "bundle"); + for (const file of files) { + const target = join(bundle, file.path); + await mkdir(dirname(target), { recursive: true }); + if ( + typeof file.content === "string" || + file.content instanceof Uint8Array + ) + await writeFile(target, file.content); + else await copyFile(file.content.copyFrom, target); + } + return { workspace, bundle, name }; + } catch (cause: unknown) { + await workspace.close(); + throw cause; + } +}; + +/** + * Render a request's build bundle, with REA's payload from the operator's + * configuration, into a private folder the caller owns and removes. + */ +export const writeRequestBundle = async ( + request: BuildRequest, + environment: Readonly, +) => writeBuildBundle(request, await reaPayload(environment)); diff --git a/src/application/desktop/DesktopBuildComputers.ts b/src/application/desktop/DesktopBuildComputers.ts new file mode 100644 index 0000000000..a6b8577571 --- /dev/null +++ b/src/application/desktop/DesktopBuildComputers.ts @@ -0,0 +1,415 @@ +import { randomUUID } from "node:crypto"; + +import type { BuildSource } from "../../domain/desktop/appBuild.js"; +import { + driverCall, + type ComputerAction, + type ComputerActionInput, + type ScreenshotOptions, +} from "../../domain/desktop/computerActions.js"; +import { + AnalysisError, + type AnalysisCapturedOutput, +} from "../../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisInputError, +} from "../../domain/analysisErrorCore.js"; +import type { JsonValue } from "../../domain/jsonValue.js"; +import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { + AgentEnvComputer, + ComputerStartFailure, + type DriverResult, +} from "../../desktop/AgentEnvComputer.js"; +import { + AGENTENV_PROVIDER, + ComputerHelper, + type ComputerRelease, +} from "../../desktop/AgentEnvComputerHelper.js"; +import type { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js"; +import { + BuildComputerActionLog, + type ReadonlyActionLog, +} from "./DesktopBuildActionLog.js"; +import { writeRequestBundle } from "./DesktopBuildBundle.js"; + +const START = "start_build_computer"; +const ACT = "act_on_build_computer"; +const CLOSE = "close_build_computer"; + +/** A result with the driver's content blocks, delivered after REA's own. */ +export interface ForwardedResult { + readonly value: Value; + readonly content: DriverResult["content"]; +} + +/** What later stages read of a live build computer: its action log and recording hooks. */ +export interface BuildComputerSession { + readonly sandboxId: string; + readonly actions: ReadonlyActionLog; + readonly startRecording: AgentEnvComputer["startRecording"]; + readonly endRecording: AgentEnvComputer["endRecording"]; + readonly recordingSegments: AgentEnvComputer["recordingSegments"]; +} + +/** A computer REA owns: a live one, or a failed start whose cleanup is still unconfirmed. */ +interface HeldComputer { + readonly computer: AgentEnvComputer | undefined; + /** Brings the computer down; absent when no helper was spawned. */ + readonly release: (() => Promise) | undefined; + readonly workspace: PrivateRuntimeRoot | undefined; + readonly actions: BuildComputerActionLog; + closing: boolean; +} + +/** A start still in progress, which shutdown cancels and joins. */ +interface PendingStart { + readonly settled: Promise; + readonly stop: AbortController; +} + +const unknownComputer = (operation: string) => + new AnalysisInputError(operation, { + userMessage: + "No build computer has this id in this REA server; start one with start_build_computer.", + conformsToInputContract: true, + }); + +const failure = ( + operation: string, + cause: unknown, + signal: AbortSignal, +): AnalysisError => { + if (cause instanceof AnalysisError) return cause; + if (signal.aborted) + return new AnalysisCancelledError(operation, { cause: signal.reason }); + return new ProviderAdapterError(AGENTENV_PROVIDER, operation, { + userMessage: cause instanceof Error ? cause.message : String(cause), + cause, + }); +}; + +/** + * Why a start failed, with the helper's output, and, when its teardown is + * unconfirmed, the id REA keeps that teardown under for close to retry. + */ +const startFailure = ( + cause: unknown, + signal: AbortSignal, + output: AnalysisCapturedOutput | undefined, + kept: { readonly id: string; readonly release: ComputerRelease }, +): AnalysisError => { + if (cause instanceof AnalysisError && kept.release.verified) return cause; + const options = { + cause, + ...(output === undefined ? {} : { capturedOutput: output }), + ...(kept.release.verified + ? {} + : { + cleanup: { + reason: kept.release.reason, + resources: kept.release.resources, + }, + diagnostics: { computer_id: kept.id }, + remediationAction: `REA keeps this computer's teardown as ${kept.id}; once the reported problem is fixed, call close_build_computer with that computer_id.`, + }), + }; + if (signal.aborted) + return new AnalysisCancelledError(START, { + ...options, + cause: signal.reason, + }); + return new ProviderAdapterError(AGENTENV_PROVIDER, START, { + ...(cause instanceof ComputerStartFailure && + cause.remediation !== undefined && + kept.release.verified + ? { remediationAction: cause.remediation } + : {}), + userMessage: cause instanceof Error ? cause.message : String(cause), + ...options, + }); +}; + +/** + * Owns the build computers one REA server keeps up: starts each from a + * request or a bundle, forwards actions to its driver with an action log, + * and brings each down on close or when the server closes. A start in + * progress is owned from its helper's spawn; one that fails without a + * confirmed teardown stays held so close and shutdown can retry it. + */ +export class DesktopBuildComputers { + readonly #computers = new Map(); + readonly #starts = new Set(); + #closed = false; + + constructor(private readonly environment: Readonly) {} + + async start( + input: { + readonly source: BuildSource; + readonly screenshot?: ScreenshotOptions | undefined; + }, + signal: AbortSignal, + ): Promise< + Result< + ForwardedResult<{ + readonly computer_id: string; + readonly sandbox_id: string; + readonly environment: string; + readonly screenshot: Readonly> | null; + }>, + AnalysisError + > + > { + if (this.#closed) + return err( + new AnalysisCancelledError(START, { + cause: new Error("REA is closing its build computers"), + }), + ); + const stop = new AbortController(); + const settled = this.#start(input, AbortSignal.any([signal, stop.signal])); + const pending = { settled, stop }; + this.#starts.add(pending); + try { + return await settled; + } finally { + this.#starts.delete(pending); + } + } + + async #start( + input: Parameters[0], + signal: AbortSignal, + ): ReturnType { + const { source } = input; + let target: { + readonly bundle: string; + readonly name: string; + readonly workspace?: PrivateRuntimeRoot; + }; + try { + target = + source.kind === "bundle" + ? { bundle: source.path, name: source.path } + : await writeRequestBundle(source, this.environment); + } catch (cause: unknown) { + return err(failure(START, cause, signal)); + } + const id = randomUUID(); + let helper: ComputerHelper | undefined; + let computer: AgentEnvComputer | undefined; + try { + helper = await ComputerHelper.spawn({ + bundle: target.bundle, + environment: this.environment, + signal, + operation: START, + }); + computer = await AgentEnvComputer.connect(helper, signal); + const shot = driverCall("screenshot", input.screenshot ?? {}); + if (!shot.ok) throw new TypeError("screenshot options are its own"); + const taken = await computer.callTool( + shot.value.tool, + shot.value.arguments, + signal, + ); + const live = computer; + this.#computers.set(id, { + computer: live, + release: () => live.close(), + workspace: target.workspace, + actions: new BuildComputerActionLog(), + closing: false, + }); + return ok({ + value: { + computer_id: id, + sandbox_id: live.sandboxId, + environment: target.name, + screenshot: taken.isError ? null : taken.structured, + }, + content: taken.content, + }); + } catch (cause: unknown) { + const owner = helper; + const connected = computer; + this.#computers.set(id, { + computer: undefined, + release: + connected !== undefined + ? () => connected.close() + : owner === undefined + ? undefined + : () => owner.release(), + workspace: target.workspace, + actions: new BuildComputerActionLog(), + closing: true, + }); + const release = await this.#retire(id); + return err(startFailure(cause, signal, owner?.output(), { id, release })); + } + } + + /** Forward one action to the computer's driver and log it; a driver refusal is an error carrying its message. */ + async act( + input: ComputerActionInput, + signal: AbortSignal, + ): Promise< + Result< + ForwardedResult<{ + readonly computer_id: string; + readonly action: ComputerAction; + readonly action_index: number; + readonly result: Readonly> | null; + }>, + AnalysisError + > + > { + const { computer_id: id, action, ...fields } = input; + const held = this.#computers.get(id); + const computer = held?.computer; + if (held === undefined || computer === undefined || held.closing) + return err(unknownComputer(ACT)); + const call = driverCall(action, fields); + if (!call.ok) + return err( + new AnalysisInputError( + ACT, + { + userMessage: `${action} does not take ${call.error.field}; it takes ${call.error.accepted.join(", ")}.`, + }, + [ + { + path: [call.error.field], + reason: "unknown_argument", + expected: [...call.error.accepted], + }, + ], + ), + ); + const logged = held.actions.begin( + action, + call.value.tool, + call.value.arguments, + ); + let result: DriverResult; + try { + result = await computer.callTool( + call.value.tool, + call.value.arguments, + signal, + ); + } catch (cause: unknown) { + logged.settle("failed"); + return err(failure(ACT, cause, signal)); + } + logged.settle(result.isError ? "refused" : "applied"); + if (result.isError) + return err( + new AnalysisInputError(ACT, { + userMessage: `The computer refused the ${action}: ${result.content + .flatMap((block) => + typeof block.text === "string" ? [block.text] : [], + ) + .join(" ")}`, + remediationAction: + "Follow the computer's message; actions on frame coordinates need a screenshot first.", + conformsToInputContract: true, + }), + ); + return ok({ + value: { + computer_id: id, + action, + action_index: logged.index, + result: result.structured, + }, + content: result.content, + }); + } + + /** Bring a computer down; one whose teardown is not confirmed stays held so close can be retried. */ + async close(id: string): Promise< + Result< + { + readonly computer_id: string; + readonly teardown: "agentenv" | "container_label"; + }, + AnalysisError + > + > { + if (!this.#computers.has(id)) return err(unknownComputer(CLOSE)); + const release = await this.#retire(id); + return release.verified + ? ok({ computer_id: id, teardown: release.teardown }) + : err( + new ProviderCleanupError( + AGENTENV_PROVIDER, + release.resources, + { reason: release.reason }, + { operation: CLOSE, remediationAction: release.reason }, + ), + ); + } + + /** Release a held computer and its bundle folder; it stays held while either is unconfirmed. */ + async #retire(id: string): Promise { + const held = this.#computers.get(id); + if (held === undefined) return { verified: true, teardown: "agentenv" }; + held.closing = true; + const release = (await held.release?.()) ?? { + verified: true, + teardown: "agentenv", + }; + if (!release.verified) return release; + try { + await held.workspace?.close(); + } catch (cause: unknown) { + return { + verified: false, + reason: `the build bundle folder could not be removed: ${cause instanceof Error ? cause.message : String(cause)}`, + resources: [held.workspace?.path ?? id], + }; + } + this.#computers.delete(id); + return release; + } + + /** Cancel and join the starts in progress, then bring every held computer down, as the server closes. */ + async closeAll(): Promise { + this.#closed = true; + const starts = [...this.#starts]; + for (const start of starts) + start.stop.abort(new Error("REA is closing its build computers")); + await Promise.allSettled(starts.map((start) => start.settled)); + const results = await Promise.all( + [...this.#computers.keys()].map((id) => this.close(id)), + ); + const failures = results.flatMap((result) => + result.ok ? [] : [result.error], + ); + if (failures.length > 0) + throw new AggregateError( + failures, + "Build computer teardown is incomplete", + ); + } + + /** A live computer's action log and recording hooks, for the witness. */ + session(id: string): BuildComputerSession | undefined { + const held = this.#computers.get(id); + const computer = held?.computer; + if (held === undefined || computer === undefined) return undefined; + return { + sandboxId: computer.sandboxId, + actions: held.actions, + startRecording: (...request) => computer.startRecording(...request), + endRecording: (...request) => computer.endRecording(...request), + recordingSegments: (...request) => computer.recordingSegments(...request), + }; + } +} diff --git a/src/composition/capabilityDeclarations.ts b/src/composition/capabilityDeclarations.ts index df2073a19d..e801eb65c1 100644 --- a/src/composition/capabilityDeclarations.ts +++ b/src/composition/capabilityDeclarations.ts @@ -227,6 +227,7 @@ export const CAPABILITY_DECLARATIONS = { }), ], }, + desktopBuildComputers: { providers: [] }, devicetree: { providers: [providerBinding(DEVICETREE_PROVIDER, ["inspect_devicetree"])], }, diff --git a/src/composition/capabilityServices.ts b/src/composition/capabilityServices.ts index 15fdc65471..0b238bf2d5 100644 --- a/src/composition/capabilityServices.ts +++ b/src/composition/capabilityServices.ts @@ -26,6 +26,7 @@ import { } from "./evm.js"; import { createSqliteDatabaseService } from "./sqlite.js"; import { createDesktopApplicationService } from "./desktop.js"; +import { DesktopBuildComputers } from "../application/desktop/DesktopBuildComputers.js"; import { createGoBinaryService } from "./go.js"; import { createRustBinaryService } from "./rust.js"; import { createBeamService } from "./erlang.js"; @@ -318,6 +319,10 @@ const modules = { }), ), desktopApplication: service(() => createDesktopApplicationService()), + desktopBuildComputers: capability( + ({ providerEnvironment }) => new DesktopBuildComputers(providerEnvironment), + (value) => value.closeAll(), + ), goBinary: service( ({ options }) => options.goBinary ?? createGoBinaryService(), ), diff --git a/src/contracts/desktopBuildToolContracts.ts b/src/contracts/desktopBuildToolContracts.ts new file mode 100644 index 0000000000..b50e3609d1 --- /dev/null +++ b/src/contracts/desktopBuildToolContracts.ts @@ -0,0 +1,114 @@ +import { z } from "zod"; + +import { buildSourceSchema } from "../domain/desktop/appBuild.js"; +import { + COMPUTER_ACTIONS, + computerActionInputSchema, + screenshotOptionsSchema, +} from "../domain/desktop/computerActions.js"; +import { jsonObjectSchema } from "../domain/jsonValue.js"; +import type { ToolContract } from "./toolContractTypes.js"; +import { toolContractMetadata } from "./toolEffects.js"; + +const computerId = z.uuid().describe("The id start_build_computer returned."); +const driverResult = jsonObjectSchema + .nullable() + .describe( + "The driver's structured result, such as frame_id, width and height; null when it returned none.", + ); +const EXAMPLE_ID = "00000000-0000-4000-8000-000000000001"; + +/** Session tools that keep an agentenv computer up and forward computer-use actions to it while an environment is built. */ +export const DESKTOP_BUILD_TOOL_CONTRACTS = [ + { + name: "start_build_computer", + ...toolContractMetadata("start_build_computer"), + kind: "application", + description: + "Start a local agentenv computer to build a desktop app environment on, and return its id with a first screenshot as an image. From a request, REA renders a bundle that installs the app from the Ubuntu archive, an apt repository or a .deb URL, opens it on demand from its desktop entry (it is not open at first: open it from the desktop's Applications menu, under Other, by the environment's name), and installs REA itself in a build-only layer from REA_BUILD_COMPUTER_REA_ARCHIVE; from a bundle folder, such as an exported one, it starts that bundle's smoke task as it is. Needs Docker and the agentenv runner named by REA_AGENT_ENV_COMMAND; the first start of a bundle builds its image, which downloads packages and can take minutes. screenshot sets the first screenshot's region, format and quality. The computer stays up, reachable on this host, until close_build_computer or the server closes; when a start fails before its teardown is confirmed, the error's computer_id names what close_build_computer retries.", + inputSchema: z.strictObject({ + source: buildSourceSchema, + screenshot: screenshotOptionsSchema.optional(), + }), + outputSchema: z.strictObject({ + computer_id: z.uuid(), + sandbox_id: z.string().describe("agentenv's sandbox id."), + environment: z + .string() + .describe("The rendered environment's name, or the bundle folder."), + screenshot: driverResult, + }), + examples: [ + { + title: "Build Mousepad from the Ubuntu archive", + input: { + source: { + kind: "request", + desktop_entry: "org.xfce.mousepad", + install: { kind: "archive", package: "mousepad" }, + }, + }, + }, + { + title: "Start an exported bundle", + input: { source: { kind: "bundle", path: "/tmp/mousepad-bundle" } }, + }, + ], + }, + { + name: "act_on_build_computer", + ...toolContractMetadata("act_on_build_computer"), + kind: "application", + description: `Drive the app on a build computer: one action, ${COMPUTER_ACTIONS.join(", ")}, sent to the computer's computer_ tool with the fields given, which are the driver's own arguments. Points are pixels of the latest frame, so take a screenshot first, or space norm on a 0..norm_scale grid; count 2 double-clicks; keys names one chord in W3C names, and spellings such as ctrl+s are translated. Returns the driver's content unchanged, its images included, and its structured result; an action's screenshot is taken at once, so after one that opens a menu or a window, take it with a wait instead; a refusal, such as a stale frame, is an error with the computer's message. Each action is logged on the computer.`, + inputSchema: computerActionInputSchema, + outputSchema: z.strictObject({ + computer_id: z.uuid(), + action: z.enum(COMPUTER_ACTIONS), + action_index: z + .int() + .nonnegative() + .describe("The action's place in the computer's action log."), + result: driverResult, + }), + examples: [ + { + title: "Look at the screen", + input: { computer_id: EXAMPLE_ID, action: "screenshot" }, + }, + { + title: "Double-click a point of the latest frame", + input: { + computer_id: EXAMPLE_ID, + action: "click", + x: 640, + y: 400, + count: 2, + }, + }, + { + title: "Save", + input: { computer_id: EXAMPLE_ID, action: "key", keys: ["ctrl+s"] }, + }, + ], + }, + { + name: "close_build_computer", + ...toolContractMetadata("close_build_computer"), + kind: "application", + description: + "Tear a build computer down and discard its state. agentenv's teardown is tried first; if it leaves the computer up, REA removes that exact sandbox's containers, the one named agent- and any labelled agentenv.sandbox=, with REA_DOCKER_COMMAND. When neither confirms it, the error names what is left and the computer stays listed, so closing again retries.", + inputSchema: z.strictObject({ computer_id: computerId }), + outputSchema: z.strictObject({ + computer_id: z.uuid(), + teardown: z + .enum(["agentenv", "container_label"]) + .describe("What confirmed the computer is gone."), + }), + examples: [ + { + title: "Close a build computer", + input: { computer_id: EXAMPLE_ID }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index 4d0ebcba60..00e213b942 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -6,6 +6,7 @@ import { WASM_TOOL_CONTRACTS } from "./wasm/wasmToolContracts.js"; import { APK_SIGNATURE_TOOL_CONTRACTS } from "./apkSignature/apkSignatureToolContracts.js"; import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js"; import { DESKTOP_APPLICATION_TOOL_CONTRACTS } from "./desktopApplicationToolContracts.js"; +import { DESKTOP_BUILD_TOOL_CONTRACTS } from "./desktopBuildToolContracts.js"; import { GO_TOOL_CONTRACTS } from "./go/goToolContracts.js"; import { MODEL_TOOL_CONTRACTS } from "./model/modelToolContracts.js"; import { IL2CPP_TOOL_CONTRACTS } from "./il2cpp/il2cppToolContracts.js"; @@ -84,6 +85,7 @@ export const TOOL_CONTRACTS = [ ...WEB_RUNTIME_TOOL_CONTRACTS, ...WEB_NETWORK_CAPTURE_TOOL_CONTRACTS, ...ENVIRONMENT_TOOL_CONTRACTS, + ...DESKTOP_BUILD_TOOL_CONTRACTS, ...JAVASCRIPT_RECOVERY_TOOL_CONTRACTS, ...SESSION_TOOL_CONTRACTS, ...CUTTER_TOOL_CONTRACTS, diff --git a/src/contracts/toolEffects.test.ts b/src/contracts/toolEffects.test.ts index b138e74c0f..480044292b 100644 --- a/src/contracts/toolEffects.test.ts +++ b/src/contracts/toolEffects.test.ts @@ -176,6 +176,7 @@ describe("tool effects", () => { ({ effects }) => effects.executesForeignCode || effects.mutatesDevice, ); expect(destructive.map(({ name }) => name).sort()).toEqual([ + "act_on_build_computer", "capture_browser_scenario", "capture_electron_scenario", "capture_native_ui_scenario", @@ -195,6 +196,7 @@ describe("tool effects", () => { "rizin_debug_command", "start_adb_activity", "start_adb_app", + "start_build_computer", "start_rizin_debug_session", "stop_adb_app", "uninstall_adb_package", diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 23cd952687..4076200999 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -735,6 +735,30 @@ const TOOL_EFFECT_AUDIT: Readonly> = { writesFilesystem: true, idempotent: false, }), + // Builds and boots a container that installs and runs the requested app. + start_build_computer: effects({ + mutatesSession: true, + writesFilesystem: true, + launchesProcess: true, + accessesNetwork: true, + executesForeignCode: true, + idempotent: false, + }), + // Drives the app through the computer's driver on a loopback port. + act_on_build_computer: effects({ + mutatesSession: true, + accessesNetwork: true, + changesUiState: true, + executesForeignCode: true, + idempotent: false, + }), + close_build_computer: effects({ + mutatesSession: true, + launchesProcess: true, + accessesNetwork: true, + mayDiscardData: true, + idempotent: false, + }), reconcile_javascript_runtime: evidence, recover_javascript_sources: effects({ mutatesSession: true, diff --git a/src/contracts/toolFamilies.ts b/src/contracts/toolFamilies.ts index b89851d2a0..034d0919c6 100644 --- a/src/contracts/toolFamilies.ts +++ b/src/contracts/toolFamilies.ts @@ -20,6 +20,7 @@ import { APK_SIGNATURE_TOOL_CONTRACTS } from "./apkSignature/apkSignatureToolCon import { ERLANG_BEAM_TOOL_CONTRACTS } from "./erlang/beamToolContracts.js"; import { PYTHON_PYC_TOOL_CONTRACTS } from "./python/pythonPycToolContracts.js"; import { DESKTOP_APPLICATION_TOOL_CONTRACTS } from "./desktopApplicationToolContracts.js"; +import { DESKTOP_BUILD_TOOL_CONTRACTS } from "./desktopBuildToolContracts.js"; import { IL2CPP_TOOL_CONTRACTS } from "./il2cpp/il2cppToolContracts.js"; import { MODEL_TOOL_CONTRACTS } from "./model/modelToolContracts.js"; import { MANAGED_TOOL_CONTRACTS } from "./managed/managedToolContracts.js"; @@ -65,6 +66,7 @@ export const TOOL_CONTRACT_GROUPS = { CAPTURE_TOOL_CONTRACTS, CUTTER_TOOL_CONTRACTS, DESKTOP_APPLICATION_TOOL_CONTRACTS, + DESKTOP_BUILD_TOOL_CONTRACTS, ELECTRON_TOOL_CONTRACTS, ENHANCED_TOOL_CONTRACTS, ENVIRONMENT_TOOL_CONTRACTS, @@ -232,6 +234,7 @@ export const TOOL_FAMILY_DEFINITIONS = [ ...TOOL_CONTRACT_GROUPS.APPLICATION_TOOL_CONTRACTS, ...TOOL_CONTRACT_GROUPS.ANALYSIS_SETTINGS_TOOL_CONTRACTS, ...TOOL_CONTRACT_GROUPS.ENVIRONMENT_TOOL_CONTRACTS, + ...TOOL_CONTRACT_GROUPS.DESKTOP_BUILD_TOOL_CONTRACTS, ...TOOL_CONTRACT_GROUPS.ANALYSIS_VIEW_TOOL_CONTRACTS, ...TOOL_CONTRACT_GROUPS.WEB_SCRIPT_TOOL_CONTRACTS, ...TOOL_CONTRACT_GROUPS.JAVASCRIPT_RECOVERY_TOOL_CONTRACTS, diff --git a/src/desktop/AgentEnvComputer.ts b/src/desktop/AgentEnvComputer.ts new file mode 100644 index 0000000000..4436273cd3 --- /dev/null +++ b/src/desktop/AgentEnvComputer.ts @@ -0,0 +1,365 @@ +import { createHash } from "node:crypto"; +import { setTimeout as delay } from "node:timers/promises"; +import { getHeapStatistics } from "node:v8"; + +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; +import { z } from "zod"; + +import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js"; +import { StructuredContentMcpClient } from "../providers/StructuredContentMcpClient.js"; +import { validateMcpHttpResponse } from "../providers/Utf8McpResponse.js"; +import type { + ComputerHelper, + ComputerRelease, +} from "./AgentEnvComputerHelper.js"; + +const READY_TIMEOUT_MS = 180_000; +const REQUEST_TIMEOUT_MS = 120_000; +const RECORDINGS = ".agentenv-recordings"; +/** A control route's answer: readiness, or a recording's manifest, near 1.5 MB for a day of 15 s segments. */ +const CONTROL_RESPONSE_BYTES = 4 * 1024 * 1024; +/** What a data/get answer holds around one file's base64 bytes. */ +const ENVELOPE_BYTES = 64 * 1024; + +const readinessSchema = z.object({ + ready: z.boolean(), + checks: z + .array( + z.object({ + name: z.string(), + ok: z.boolean(), + detail: z.string().nullish(), + final: z.boolean().optional(), + }), + ) + .default([]), +}); +const recordingId = z.string().regex(/^rec-[0-9a-f]+$/u); +const recordingStartSchema = z.looseObject({ + recording_id: recordingId, + started_at: z.string(), +}); +const recordingManifestSchema = z.looseObject({ + recording_id: recordingId, + segments: z.array( + z.looseObject({ + index: z.int().nonnegative(), + path: z.string(), + bytes: z.int().nonnegative(), + sha256: z.string().regex(/^[0-9a-f]{64}$/u), + }), + ), +}); +/** An ended recording's manifest, as `computer-recording/v1` returns it. */ +export type RecordingManifest = z.infer; +const dataGetSchema = z.union([ + z.object({ + result: z.object({ + parts: z.array( + z.looseObject({ + file: z.looseObject({ bytes: z.string().optional() }).optional(), + metadata: z + .record(z.string(), z.looseObject({ path: z.string().optional() })) + .optional(), + }), + ), + }), + }), + z.object({ error: z.looseObject({ message: z.string() }) }), +]); + +/** A driver tool's answer as REA forwards it: its content blocks unchanged and its structured result. */ +export interface DriverResult { + readonly isError: boolean; + readonly content: readonly Readonly>[]; + readonly structured: Readonly> | null; +} + +/** A response body read only while it stays within `maxBytes`. */ +const readBounded = async ( + response: Response, + maxBytes: number, + what: string, +): Promise => { + const chunks: Uint8Array[] = []; + let bytes = 0; + if (response.body !== null) + for await (const chunk of response.body) { + bytes += chunk.byteLength; + if (bytes > maxBytes) + throw new Error(`${what} is more than ${String(maxBytes)} bytes.`); + chunks.push(chunk); + } + return Buffer.concat(chunks, bytes).toString("utf8"); +}; + +/** Why a computer did not come up, with what the caller can do about it. */ +export class ComputerStartFailure extends Error { + constructor( + message: string, + readonly remediation: string | undefined, + options?: ErrorOptions, + ) { + super(message, options); + this.name = "ComputerStartFailure"; + } +} + +const connectDriver = async ( + mcpUrl: string, + signal: AbortSignal, +): Promise => { + const client = new StructuredContentMcpClient({ + name: "rea-build-computer", + version: "1", + }); + const transport: StreamableHTTPClientTransport = + new StreamableHTTPClientTransport(new URL(mcpUrl), { + requestInit: { redirect: "error" }, + fetch: async (input, init) => + validateMcpHttpResponse(await fetch(input, init), () => { + void transport.close().catch(() => undefined); + }), + }); + await client.connect(transport, { timeout: REQUEST_TIMEOUT_MS, signal }); + return client; +}; + +/** + * One agentenv computer kept up for REA: the helper that holds it, one MCP + * session to its driver, and its control token, which never leaves this + * object. + */ +export class AgentEnvComputer { + readonly sandboxId: string; + readonly #baseUrl: string; + readonly #token: string | null; + readonly #helper: ComputerHelper; + readonly #client: StructuredContentMcpClient; + + private constructor(parts: { + readonly sandboxId: string; + readonly baseUrl: string; + readonly token: string | null; + readonly helper: ComputerHelper; + readonly client: StructuredContentMcpClient; + }) { + this.sandboxId = parts.sandboxId; + this.#baseUrl = parts.baseUrl; + this.#token = parts.token; + this.#helper = parts.helper; + this.#client = parts.client; + } + + /** + * Connect to the computer a helper keeps up: wait for its handshake and + * readiness and open an MCP session to its driver. A failure throws a + * `ComputerStartFailure`, or the signal's reason when cancelled; the + * helper's owner releases it either way. + */ + static async connect( + helper: ComputerHelper, + signal: AbortSignal, + ): Promise { + const handshake = await helper.handshake(signal); + if (handshake === undefined || "error" in handshake) + throw new ComputerStartFailure( + `agentenv did not bring the computer up: ${handshake?.error ?? "its helper ended without a handshake"}.`, + "Read the helper's output; `agent-env run --task smoke --no-emulate` reproduces the deploy.", + ); + let client: StructuredContentMcpClient | undefined; + try { + client = await connectDriver(handshake.mcp_url, signal); + const computer = new AgentEnvComputer({ + sandboxId: handshake.sandbox_id, + baseUrl: handshake.base_url.replace(/\/+$/u, ""), + token: handshake.control_token, + helper, + client, + }); + await computer.#waitReady(signal); + return computer; + } catch (cause: unknown) { + await client?.close().catch(() => undefined); + if (signal.aborted) throw signal.reason; + throw new ComputerStartFailure( + `The computer came up but REA could not use it: ${cause instanceof Error ? cause.message : String(cause)}`, + undefined, + { cause }, + ); + } + } + + /** Call one of the driver's `computer_*` tools on REA's MCP session. */ + async callTool( + name: string, + args: Readonly>, + signal: AbortSignal, + ): Promise { + const result = await this.#client.callTool( + { name, arguments: args }, + { timeout: REQUEST_TIMEOUT_MS, signal }, + ); + const structured = jsonObjectSchema.safeParse(result.structuredContent); + return { + isError: result.isError === true, + content: result.content.map((block) => jsonObjectSchema.parse(block)), + structured: structured.success ? structured.data : null, + }; + } + + /** Start a recording under the caller's request id; repeating the id answers the same recording. */ + async startRecording(requestId: string, signal?: AbortSignal) { + return recordingStartSchema.parse( + await this.#request( + "/agentenv/ext/computer-recording/start", + { request_id: requestId }, + signal, + ), + ); + } + + /** End a recording; its manifest lists the segments the computer keeps until teardown. */ + async endRecording( + id: string, + signal?: AbortSignal, + ): Promise { + return recordingManifestSchema.parse( + await this.#request( + "/agentenv/ext/computer-recording/end", + { recording_id: id }, + signal, + ), + ); + } + + /** + * Fetch an ended recording's segments through the driver's data plane, + * one request per segment, each read within its manifest size and checked + * against it. A recording larger than `maxBytes`, by default a quarter of + * the V8 heap as for retained Evidence, is refused before any read. + */ + async recordingSegments( + manifest: RecordingManifest, + signal?: AbortSignal, + maxBytes = Math.floor(getHeapStatistics().heap_size_limit / 4), + ): Promise { + const total = manifest.segments.reduce( + (sum, segment) => sum + segment.bytes, + 0, + ); + if (total > maxBytes) + throw new Error( + `Recording ${manifest.recording_id} holds ${String(total)} bytes, more than the ${String(maxBytes)} REA reads into memory for one recording; end recordings sooner so each stays smaller.`, + ); + const segments: { readonly path: string; readonly bytes: Buffer }[] = []; + for (const segment of manifest.segments) + segments.push({ + path: segment.path, + bytes: await this.#segment(segment, signal), + }); + return segments; + } + + async #segment( + segment: RecordingManifest["segments"][number], + signal: AbortSignal | undefined, + ): Promise { + const path = `${RECORDINGS}/${segment.path}`; + const answer = dataGetSchema.parse( + await this.#request( + "/agentenv", + { + jsonrpc: "2.0", + id: segment.index + 1, + method: "data/get", + params: { paths: [path] }, + }, + signal, + { + // Base64 plus the JSON-RPC envelope and the part's metadata. + bytes: Math.ceil(segment.bytes / 3) * 4 + ENVELOPE_BYTES, + what: `The computer's answer for recording segment ${segment.path}`, + }, + ), + ); + if ("error" in answer) + throw new Error( + `data/get of recording segment ${segment.path} failed: ${answer.error.message}`, + ); + const encoded = answer.result.parts.find( + (part) => Object.values(part.metadata ?? {})[0]?.path === path, + )?.file?.bytes; + const bytes = + encoded === undefined ? undefined : Buffer.from(encoded, "base64"); + if ( + bytes?.length !== segment.bytes || + createHash("sha256").update(bytes).digest("hex") !== segment.sha256 + ) + throw new Error( + `Recording segment ${segment.path} is missing or differs from its manifest.`, + ); + return bytes; + } + + /** End REA's MCP session and bring the computer down; a failed release can be retried. */ + async close(): Promise { + await this.#client.close().catch(() => undefined); + return this.#helper.release(); + } + + async #waitReady(signal: AbortSignal): Promise { + const deadline = Date.now() + READY_TIMEOUT_MS; + for (;;) { + const status = readinessSchema.parse( + await this.#request( + "/agentenv/ext/computer-ready/status", + undefined, + signal, + ), + ); + if (status.ready) return; + const waiting = status.checks.filter((check) => !check.ok); + const failed = waiting.find((check) => check.final === true); + if (failed !== undefined || Date.now() > deadline) + throw new Error( + failed === undefined + ? `The computer was not ready within ${String(READY_TIMEOUT_MS / 1000)} s; waiting on ${waiting.map((check) => check.name).join(", ")}.` + : `The computer's ${failed.name} check failed: ${failed.detail ?? "no detail"}.`, + ); + await delay(500, undefined, { signal }); + } + } + + /** A framework-only route of the computer, with its control token; its answer is read within `limit`. */ + async #request( + path: string, + body: unknown, + signal: AbortSignal | undefined, + limit = { + bytes: CONTROL_RESPONSE_BYTES, + what: `The computer's answer to ${path}`, + }, + ): Promise { + const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + const response = await fetch(`${this.#baseUrl}${path}`, { + method: body === undefined ? "GET" : "POST", + headers: { + ...(this.#token === null + ? {} + : { authorization: `Bearer ${this.#token}` }), + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + redirect: "error", + signal: + signal === undefined ? timeout : AbortSignal.any([signal, timeout]), + }); + const text = await readBounded(response, limit.bytes, limit.what); + if (!response.ok) + throw new Error( + `The computer answered ${path} with HTTP ${String(response.status)}: ${text.slice(0, 512)}`, + ); + return JSON.parse(text) as unknown; + } +} diff --git a/src/desktop/AgentEnvComputerHelper.ts b/src/desktop/AgentEnvComputerHelper.ts new file mode 100644 index 0000000000..4548cdf871 --- /dev/null +++ b/src/desktop/AgentEnvComputerHelper.ts @@ -0,0 +1,351 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { access, open, realpath } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { z } from "zod"; + +import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; +import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js"; +import { readBoundedControlLines } from "../process/BoundedControlLines.js"; +import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js"; +import { + ProviderProcessSupervisor, + spawnOwnedProviderProcess, + type SpawnedOwnedProviderProcess, +} from "../process/ProviderProcess.js"; + +const execFileAsync = promisify(execFile); + +export const AGENTENV_PROVIDER = "agentenv"; +const HELPER = fileURLToPath( + new URL("../../bridge/agentenv/computer.py", import.meta.url), +); +const HANDSHAKE_BYTES = 64 * 1024; +const STDERR_BYTES = 16 * 1024; +const TEARDOWN_MS = 120_000; +const RUNNER_REMEDIATION = + "Set REA_AGENT_ENV_COMMAND to the absolute path of the agent-env program of an agentenv-framework install (a venv, uv tool or pipx), then restart REA."; + +const handshakeSchema = z.union([ + z.strictObject({ + base_url: z.url({ protocol: /^https?$/u }), + mcp_url: z.url({ protocol: /^https?$/u }), + control_token: z.string().min(1).nullable(), + sandbox_id: z.string().min(1), + }), + z.strictObject({ error: z.string() }), +]); +/** The helper's one JSON line: where the kept computer is, or why there is none. */ +export type Handshake = z.infer; + +/** How a computer's teardown ended: confirmed by agentenv or by removing its containers, or not confirmed. */ +export type ComputerRelease = + | { + readonly verified: true; + readonly teardown: "agentenv" | "container_label"; + } + | { + readonly verified: false; + readonly reason: string; + readonly resources: readonly string[]; + }; + +const unavailable = (operation: string, reason: string, cause?: unknown) => + new AnalysisCapabilityUnavailableError(AGENTENV_PROVIDER, operation, reason, { + userMessage: reason, + remediationAction: RUNNER_REMEDIATION, + ...(cause === undefined ? {} : { cause }), + }); + +const executable = async (path: string): Promise => + access(path, constants.X_OK).then( + () => true, + () => false, + ); + +const readHead = async (path: string): Promise => { + const file = await open(path, "r"); + try { + const { buffer, bytesRead } = await file.read(Buffer.alloc(512), 0, 512, 0); + return buffer.subarray(0, bytesRead).toString("utf8"); + } finally { + await file.close(); + } +}; + +/** + * The Python the agentenv runner runs with, so the helper can import + * `agent_env`: the interpreter its console script names on its `#!` line, + * or the `python3` beside the resolved script in its venv. + */ +export const agentEnvInterpreter = async ( + environment: Readonly, + operation: string, +): Promise => { + const command = environment.REA_AGENT_ENV_COMMAND; + if (command === undefined || !isAbsolute(command)) + throw unavailable( + operation, + "Build computers need REA_AGENT_ENV_COMMAND, the absolute path of agentenv's agent-env program; REA runs its helper with that runner's Python.", + ); + let program: string; + let head: string; + try { + program = await realpath(command); + head = await readHead(program); + } catch (cause: unknown) { + throw unavailable( + operation, + `REA_AGENT_ENV_COMMAND ${command} cannot be read.`, + cause, + ); + } + const named = /^#!(\/\S+)[ \t]*\r?\n/u.exec(head)?.[1]; + for (const candidate of [ + ...(named !== undefined && basename(named).startsWith("python") + ? [named] + : []), + join(dirname(program), "python3"), + ]) + if (await executable(candidate)) return candidate; + throw unavailable( + operation, + `REA_AGENT_ENV_COMMAND ${command} names no Python: its first line names no python interpreter and no python3 sits beside it.`, + ); +}; + +/** What a Docker container name may hold, so a sandbox id outside it names no container. */ +const CONTAINER_NAME = /^[A-Za-z0-9_.-]+$/u; + +/** + * The `docker ps` filters that find one sandbox's containers: agentenv's + * local provider runs a computer as `agent-` without a label, and + * labels only the containers and relays of its VM and closed-network modes. + */ +const sandboxFilters = (sandbox: string): readonly string[] => [ + ...(CONTAINER_NAME.test(sandbox) + ? [`name=^agent-${sandbox.replaceAll(".", "\\.")}$`] + : []), + `label=agentenv.sandbox=${sandbox}`, +]; + +/** Remove the containers of one sandbox REA's helper named, by their exact name and label; undefined once removed. */ +const removeSandbox = async ( + environment: Readonly, + sandbox: string, +): Promise => { + const filters = sandboxFilters(sandbox); + const manual = `Run docker rm -f ${filters.map((filter) => `$(docker ps -aq --filter '${filter}')`).join(" ")}.`; + const docker = environment.REA_DOCKER_COMMAND; + if (docker === undefined || !isAbsolute(docker)) + return `agentenv's teardown did not confirm sandbox ${sandbox} is gone, and REA_DOCKER_COMMAND is not an absolute path to remove its containers. ${manual}`; + const options = { env: environment, timeout: TEARDOWN_MS }; + try { + const containers = new Set(); + for (const filter of filters) { + const listed = await execFileAsync( + docker, + ["ps", "-aq", "--filter", filter], + options, + ); + for (const id of listed.stdout.split(/\s+/u).filter(Boolean)) + containers.add(id); + } + if (containers.size > 0) + await execFileAsync(docker, ["rm", "-f", ...containers], options); + return undefined; + } catch (cause: unknown) { + return `Removing sandbox ${sandbox}'s containers failed: ${cause instanceof Error ? cause.message : String(cause)}. ${manual}`; + } +}; + +const parseHandshake = (line: string): Handshake => { + try { + const parsed = handshakeSchema.safeParse(JSON.parse(line)); + if (parsed.success) return parsed.data; + } catch { + // Reported below as a line that is not a handshake. + } + return { error: "the helper wrote something other than its handshake" }; +}; + +/** + * REA's helper process holding one computer up: an owned, supervised child + * run with the agentenv runner's Python. It reads the first JSON line and + * keeps draining stdout, so a handshake arriving after a cancelled wait still + * names the sandbox teardown must reach. + */ +export class ComputerHelper { + readonly #supervisor: ProviderProcessSupervisor; + readonly #handshake: Promise; + #sandboxId: string | undefined; + /** The helper said no computer came up, and exits on its own after its teardown. */ + #failed = false; + #token: string | null = null; + #stopped: + | Promise<{ + readonly exit: number | null | undefined; + readonly stopped?: string; + }> + | undefined; + + private constructor( + private readonly spawned: SpawnedOwnedProviderProcess, + private readonly environment: Readonly, + ) { + this.#supervisor = new ProviderProcessSupervisor( + { + ...spawned, + ownsProcessLifetime: true, + cleanup: + spawned.cleanup ?? + (() => cleanupOwnedProcessGroup(spawned.ownership)), + }, + { + captureStdout: false, + // A failed build's error comes last, after its package installs. + maxDiagnosticBytes: { stdout: 0, stderr: STDERR_BYTES }, + diagnosticRetention: "tail", + }, + ); + // A helper that already exited must not crash REA when its stdin closes. + spawned.process.stdin?.on("error", () => undefined); + this.#handshake = new Promise((resolve) => { + const ended = () => resolve(undefined); + void this.#drain(resolve).then(ended, ended); + }); + } + + /** Spawn the helper for a bundle folder; it deploys the bundle's smoke task. */ + static async spawn(options: { + readonly bundle: string; + readonly environment: Readonly; + readonly signal: AbortSignal; + readonly operation: string; + }): Promise { + const { environment, signal } = options; + const interpreter = await agentEnvInterpreter( + environment, + options.operation, + ); + const spawned = await spawnOwnedProviderProcess({ + command: interpreter, + arguments: [HELPER, options.bundle, "smoke"], + runId: randomUUID(), + expectedCommand: null, + hostEnvironment: environment, + stdin: "pipe", + signal, + }); + return new ComputerHelper(spawned, environment); + } + + async #drain(resolve: (handshake: Handshake) => void): Promise { + const stdout = this.spawned.process.stdout; + if (stdout === null) return; + let first: Handshake | undefined; + for await (const line of readBoundedControlLines(stdout, HANDSHAKE_BYTES)) { + if (first !== undefined) continue; + first = line.ok + ? parseHandshake(line.value) + : { error: "the helper's handshake line is too long" }; + if ("sandbox_id" in first) { + this.#sandboxId = first.sandbox_id; + this.#token = first.control_token; + } else this.#failed = true; + resolve(first); + } + } + + /** The handshake, or undefined when the helper ended without one; cancellation rejects with its reason. */ + handshake(signal: AbortSignal): Promise { + signal.throwIfAborted(); + const aborted = new Promise((_, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + }); + return Promise.race([this.#handshake, aborted]); + } + + /** The helper's stderr tail with the control token removed, and how it exited. */ + output(): AnalysisCapturedOutput { + const snapshot = this.#supervisor.snapshot(); + const token = this.#token; + return { + stdout: "", + stderr: + token === null + ? snapshot.stderr.text + : snapshot.stderr.text.replaceAll(token, "[control token]"), + truncated: + snapshot.diagnosticTruncated === true || + snapshot.stderr.observedBytes > snapshot.stderr.bytes, + exit_code: snapshot.exitCode ?? null, + signal: snapshot.signal ?? null, + }; + } + + /** + * Close stdin once the helper holds a computer; let one that reported its + * failure finish its own teardown; else SIGTERM. SIGTERM after a grace, + * then clear its process group. + */ + async #stop() { + const child = this.spawned.process; + const holding = this.#sandboxId !== undefined; + const waiting = holding || this.#failed; + if (holding) child.stdin?.end(); + else if (!this.#failed) child.kill("SIGTERM"); + if (!(await this.#supervisor.waitForExit(TEARDOWN_MS)) && waiting) { + child.kill("SIGTERM"); + await this.#supervisor.waitForExit(TEARDOWN_MS); + } + const exit = this.#supervisor.observedExit()?.code; + const result = await this.#supervisor.stop(); + return result.status === "incomplete" + ? { exit, stopped: result.reason } + : { exit }; + } + + /** + * Bring the computer down. The helper's exit 0 or 2 means agentenv tore + * down everything it kept; otherwise the containers of the sandbox it + * named are removed by their `agentenv.sandbox` label. A failed release + * can be retried. + */ + async release(): Promise { + const { exit, stopped } = await (this.#stopped ??= this.#stop()); + if (stopped !== undefined) { + this.#stopped = undefined; + return { + verified: false, + reason: `REA's agentenv helper did not stop: ${stopped}`, + resources: [ + `process group ${String(this.spawned.ownership.processGroupId)}`, + ], + }; + } + if (exit === 0 || exit === 2) + return { verified: true, teardown: "agentenv" }; + const sandbox = this.#sandboxId; + if (sandbox === undefined) + return { + verified: false, + reason: `REA's agentenv helper ended (exit ${String(exit)}) before naming its sandbox; look for its container with docker ps -a --filter name=agent- and docker ps -a --filter label=agentenv.sandbox.`, + resources: ["an unnamed agentenv sandbox"], + }; + const failure = await removeSandbox(this.environment, sandbox); + return failure === undefined + ? { verified: true, teardown: "container_label" } + : { + verified: false, + reason: failure, + resources: [`containers labelled agentenv.sandbox=${sandbox}`], + }; + } +} diff --git a/src/domain/desktop/appBuild.test.ts b/src/domain/desktop/appBuild.test.ts new file mode 100644 index 0000000000..2db90e7cd1 --- /dev/null +++ b/src/domain/desktop/appBuild.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; + +import { PACKAGE_METADATA } from "../../generatedPackageMetadata.js"; +import { + BUILD_NODE_VERSION, + buildSourceSchema, + renderBuildBundle, + type BuildBundleFile, + type BuildRequest, +} from "./appBuild.js"; + +const request = (install: BuildRequest["install"]): BuildRequest => ({ + kind: "request", + desktop_entry: "org.xfce.mousepad.desktop", + install, +}); +const localPayload = { kind: "file", path: "/builds/rea-prod.tar.xz" } as const; +const text = (files: readonly BuildBundleFile[], path: string): string => { + const content = files.find((file) => file.path === path)?.content; + if (typeof content !== "string") throw new Error(`no text file ${path}`); + return content; +}; + +describe("build bundle renderer", () => { + it("installs an archive package, opens it on demand and adds REA in a build-only layer", () => { + const bundle = renderBuildBundle( + request({ kind: "archive", package: "mousepad" }), + localPayload, + ); + expect(bundle.name).toBe("mousepad"); + expect(bundle.files.map((file) => file.path).sort()).toEqual([ + "envs/mousepad/env.toml", + "envs/mousepad/install-rea.sh", + "envs/mousepad/mousepad_app.py", + "envs/mousepad/rea-payload.tar", + "tasks/smoke.json", + ]); + expect( + bundle.files.find((file) => file.path.endsWith("rea-payload.tar")) + ?.content, + ).toEqual({ copyFrom: "/builds/rea-prod.tar.xz" }); + const module = text(bundle.files, "envs/mousepad/mousepad_app.py"); + expect(module).toContain( + 'packages=Packages(apt=["mousepad", "libgtk-3-bin"])', + ); + expect(module).toContain( + 'launch=Launch(at="demand", command=["gtk-launch", "org.xfce.mousepad"]),', + ); + expect(module).toContain('"arch": ["amd64", "arm64"]'); + expect(module).toContain( + `Download(url="https://nodejs.org/dist/v${BUILD_NODE_VERSION}/node-v${BUILD_NODE_VERSION}-linux-x64.tar.xz", sha256="6c172a9ab6718f7b801fb5a7bd486af2ee5e32758086a04b84fcce2aa77095e6", to="node-amd64.tar.xz", arch="amd64")`, + ); + expect(module).toContain( + 'sha256="dbabf7a1cbf947e6059ffad2c27b80041db97dea1e553b62afdee7f13fca336e", to="node-arm64.tar.xz", arch="arm64"', + ); + expect(module).toContain('run=[RunScript(script="install-rea.sh")]'); + // Building needs no seed and no data hooks. + expect(module).not.toMatch(/paths=|@add_data|@get_data|@reset_data/u); + expect(text(bundle.files, "envs/mousepad/env.toml")).toContain( + 'module = "mousepad_app.py:Mousepad"', + ); + expect(JSON.parse(text(bundle.files, "tasks/smoke.json"))).toEqual([ + { id: "env", type: "deploy_env", env_id: "mousepad" }, + ]); + const script = text(bundle.files, "envs/mousepad/install-rea.sh"); + expect(script).toContain("/opt/rea/payload/*/scripts/rea.mjs"); + expect(script).toContain( + `/opt/rea/node-v${BUILD_NODE_VERSION}-linux-*/bin/node`, + ); + }); + + it("pins the Node.js release REA itself pins", () => { + expect(BUILD_NODE_VERSION).toBe(PACKAGE_METADATA.pinnedNodeVersion); + }); +}); + +describe("build bundle install sources", () => { + it("declares a vendor apt repository with its key file in the environment folder", () => { + const key = Buffer.from( + "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nAAAA\n-----END PGP PUBLIC KEY BLOCK-----\n", + ); + const bundle = renderBuildBundle( + { + ...request({ + kind: "apt_repository", + package: "geany", + uri: "https://ppa.launchpadcontent.net/geany-dev/ppa/ubuntu", + suite: "noble", + components: ["main"], + key_file: "/keys/geany.asc", + }), + desktop_entry: "geany", + window_class: "Geany", + }, + localPayload, + key, + ); + const module = text(bundle.files, "envs/geany/geany_app.py"); + expect(module).toContain( + 'sources=[AptSource(apt="https://ppa.launchpadcontent.net/geany-dev/ppa/ubuntu", suite="noble", components=["main"], key="apt-keys/geany.asc")],', + ); + expect(module).toContain( + 'window={"class": "Geany"}, ready=Ready(window=True)', + ); + const copied = bundle.files.find( + (file) => file.path === "envs/geany/apt-keys/geany.asc", + )?.content; + expect(copied instanceof Uint8Array && key.equals(copied)).toBe(true); + }); + + it("downloads a .deb for its architecture and installs it after its dependencies", () => { + const sha256 = "a".repeat(64); + const bundle = renderBuildBundle( + request({ + kind: "package_file", + url: "https://downloads.example.com/varicad.deb", + sha256, + architecture: "amd64", + dependencies: ["libgl1"], + }), + { + kind: "download", + url: "https://releases.example.com/rea.tar.xz", + sha256: "b".repeat(64), + }, + ); + const module = text(bundle.files, "envs/mousepad/mousepad_app.py"); + expect(module).toContain( + 'packages=Packages(apt=["libgl1", "libgtk-3-bin"])', + ); + expect(module).toContain( + `Download(url="https://downloads.example.com/varicad.deb", sha256="${sha256}", to="vendor/package.deb", arch="amd64")`, + ); + expect(module).toContain( + `Download(url="https://releases.example.com/rea.tar.xz", sha256="${"b".repeat(64)}", to="rea-payload.tar")`, + ); + expect(module).toContain('"arch": ["amd64"]'); + expect(module).not.toContain('to="node-arm64.tar.xz"'); + expect(module).toContain( + 'run=[RunScript(script="install-package.sh"), RunScript(script="install-rea.sh")]', + ); + expect(text(bundle.files, "envs/mousepad/install-package.sh")).toContain( + "dpkg -i 'vendor/package.deb'", + ); + expect( + bundle.files.some((file) => file.path.endsWith("rea-payload.tar")), + ).toBe(false); + }); + + it("admits only what agentenv can install from", () => { + const parse = (value: unknown) => buildSourceSchema.safeParse(value); + expect( + parse({ + kind: "request", + desktop_entry: "../evil", + install: { kind: "archive", package: "mousepad" }, + }).success, + ).toBe(false); + expect( + parse({ + kind: "request", + desktop_entry: "mousepad", + install: { + kind: "package_file", + url: "https://user@example.com/a.deb", + sha256: "a".repeat(64), + architecture: "amd64", + }, + }).success, + ).toBe(false); + expect( + parse({ + kind: "request", + desktop_entry: "mousepad", + install: { + kind: "apt_repository", + package: "geany", + uri: "ftp://example.com/ubuntu", + suite: "noble", + key_file: "/keys/geany.gpg", + }, + }).success, + ).toBe(false); + expect(parse({ kind: "bundle", path: "relative/bundle" }).success).toBe( + false, + ); + expect(parse({ kind: "bundle", path: "/tmp/bundle" }).success).toBe(true); + }); +}); diff --git a/src/domain/desktop/appBuild.ts b/src/domain/desktop/appBuild.ts new file mode 100644 index 0000000000..b2b94f28a2 --- /dev/null +++ b/src/domain/desktop/appBuild.ts @@ -0,0 +1,321 @@ +import { createHash } from "node:crypto"; + +import { z } from "zod"; + +import { absoluteLocalPathStringSchema } from "../localPath.js"; +import { + appModuleFile, + aptSources, + className, + environmentName, + py, + pyList, + renderEnvToml, + renderInstallScript, + renderSmokeTask, +} from "./appExport.js"; +import { INSTALL_SCRIPT, type ExportPackageFile } from "./appExportInstall.js"; +import { isArmoredKey } from "./aptRepositories.js"; + +/** The Node.js release the build layer runs REA with: the one REA pins. */ +export const BUILD_NODE_VERSION = "24.22.1"; +/** SHA-256 of each architecture's Node.js tarball, from the release's SHASUMS256.txt. */ +const NODE_TARBALLS = [ + { + arch: "amd64", + platform: "linux-x64", + sha256: "6c172a9ab6718f7b801fb5a7bd486af2ee5e32758086a04b84fcce2aa77095e6", + }, + { + arch: "arm64", + platform: "linux-arm64", + sha256: "dbabf7a1cbf947e6059ffad2c27b80041db97dea1e553b62afdee7f13fca336e", + }, +] as const; +const COMPUTER_ARCHITECTURES = ["amd64", "arm64"] as const; +/** The package of `gtk-launch`, which opens the app from its desktop entry. */ +const LAUNCHER_PACKAGE = "libgtk-3-bin"; +const REA_INSTALL_SCRIPT = "install-rea.sh"; +const REA_PAYLOAD = "rea-payload.tar"; + +/** A URL agentenv downloads from: http or https in lowercase, without a user name or password. */ +const downloadUrlSchema = z + .url({ protocol: /^https?$/u }) + .regex(/^https?:\/\//u) + .refine( + (url) => { + const parsed = new URL(url); + return parsed.username === "" && parsed.password === ""; + }, + { message: "The URL must not hold a user name or password." }, + ); +const aptPackageSchema = z + .string() + .regex(/^[a-z0-9][a-z0-9+.-]+$/u) + .describe("A Debian package name."); +const sha256Schema = z.string().regex(/^[0-9a-f]{64}$/u); + +/** Where the computer installs the app from, chosen before anything is captured. */ +export const buildInstallSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("archive"), + package: aptPackageSchema, + }), + z.strictObject({ + kind: z.literal("apt_repository"), + package: aptPackageSchema, + uri: downloadUrlSchema, + suite: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._/-]*$/u), + components: z.array(z.string().regex(/^[A-Za-z0-9._-]+$/u)).default([]), + key_file: absoluteLocalPathStringSchema.describe( + "The repository's signing key on this host, armored or binary.", + ), + }), + z.strictObject({ + kind: z.literal("package_file"), + url: downloadUrlSchema, + sha256: sha256Schema, + architecture: z.enum(COMPUTER_ARCHITECTURES), + dependencies: z + .array(aptPackageSchema) + .default([]) + .describe( + "Packages apt installs first; dpkg installs the file offline, so its dependencies must be listed.", + ), + }), +]); + +/** A request for a build computer, or an existing bundle folder to keep up as it is. */ +export const buildSourceSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("request"), + desktop_entry: z + .string() + .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u) + .max(255) + .describe( + "The desktop entry id the package installs, such as org.xfce.mousepad; the menu opens it with gtk-launch.", + ), + window_class: z + .string() + .min(1) + .max(255) + .optional() + .describe("The app window's WM_CLASS class, when known."), + install: buildInstallSchema, + }), + z.strictObject({ + kind: z.literal("bundle"), + path: absoluteLocalPathStringSchema.describe( + "An agentenv bundle folder whose tasks/smoke.json deploys one computer, such as an exported one.", + ), + }), +]); +export type BuildSource = z.output; +export type BuildRequest = Extract; + +/** Where the build layer gets REA from: a tar archive with one top folder holding `scripts/rea.mjs`. */ +export type ReaPayload = + | { readonly kind: "file"; readonly path: string } + | { + readonly kind: "download"; + readonly url: string; + readonly sha256: string; + }; + +/** One file of the bundle: its text or bytes, or a host file copied as it is. */ +export interface BuildBundleFile { + readonly path: string; + readonly content: string | Uint8Array | { readonly copyFrom: string }; +} + +const download = ( + url: string, + sha256: string, + to: string, + arch?: string, +): string => + `Download(url=${py(url)}, sha256=${py(sha256)}, to=${py(to)}${arch === undefined ? "" : `, arch=${py(arch)}`})`; + +/** The build-only step: unpack Node.js and REA into /opt/rea, as root and offline, and put both on PATH. */ +const renderReaInstallScript = (): string => `#!/bin/sh +# Build-only layer, run as root with the network off: unpack Node.js and REA into /opt/rea and put both on PATH. +set -eu +mkdir -p /opt/rea/payload +python3 - <<'PY' +import glob +import tarfile + +for name, into in [(path, "/opt/rea") for path in glob.glob("node-*.tar.xz")] + [("${REA_PAYLOAD}", "/opt/rea/payload")]: + with tarfile.open(name) as archive: + archive.extractall(into, filter="tar") +PY +set -- /opt/rea/payload/*/scripts/rea.mjs +if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then + echo "The REA archive must hold one top folder with scripts/rea.mjs." >&2 + exit 1 +fi +ln -sf /opt/rea/node-v${BUILD_NODE_VERSION}-linux-*/bin/node /usr/local/bin/node +printf '#!/bin/sh\\nexec /usr/local/bin/node %s "$@"\\n' "$1" > /usr/local/bin/rea +chmod 755 /usr/local/bin/rea +rm -f node-*.tar.xz ${REA_PAYLOAD} +`; + +/** What the computer installs for a request: packages, sources, downloads and run scripts. */ +const buildInstall = ( + request: BuildRequest, + name: string, + payload: ReaPayload, + aptKey: Uint8Array | undefined, +) => { + const { install } = request; + const architectures = + install.kind === "package_file" + ? [install.architecture] + : [...COMPUTER_ARCHITECTURES]; + if (install.kind === "apt_repository" && aptKey === undefined) + throw new TypeError("An apt repository needs its key file's bytes."); + const key = + install.kind === "apt_repository" && aptKey !== undefined + ? { + bytes: aptKey, + source: { + uri: install.uri, + suite: install.suite, + components: install.components, + keyFile: `apt-keys/${name}.${isArmoredKey(aptKey) ? "asc" : "gpg"}`, + keySha256: createHash("sha256").update(aptKey).digest("hex"), + }, + } + : undefined; + const packageFile: ExportPackageFile | null = + install.kind === "package_file" + ? { + url: install.url, + sha256: install.sha256, + path: "vendor/package.deb", + architecture: install.architecture, + dependencies: install.dependencies, + unreadDependencies: [], + } + : null; + const downloads = [ + ...NODE_TARBALLS.filter(({ arch }) => architectures.includes(arch)).map( + ({ arch, platform, sha256 }) => + download( + `https://nodejs.org/dist/v${BUILD_NODE_VERSION}/node-v${BUILD_NODE_VERSION}-${platform}.tar.xz`, + sha256, + `node-${arch}.tar.xz`, + arch, + ), + ), + ...(packageFile === null + ? [] + : [ + download( + packageFile.url, + packageFile.sha256, + packageFile.path, + packageFile.architecture, + ), + ]), + ...(payload.kind === "download" + ? [download(payload.url, payload.sha256, REA_PAYLOAD)] + : []), + ]; + return { + architectures, + packages: [ + ...(install.kind === "package_file" + ? install.dependencies + : [install.package]), + LAUNCHER_PACKAGE, + ], + key, + packageFile, + downloads, + scripts: [ + ...(packageFile === null ? [] : [INSTALL_SCRIPT]), + REA_INSTALL_SCRIPT, + ], + }; +}; + +/** The class module: the decorators that install and launch the app, and a class with no hooks. */ +const renderBuildModule = ( + request: BuildRequest, + entry: string, + plan: { readonly className: string }, + install: ReturnType, +): string => { + const window = + request.window_class === undefined + ? "" + : `, window={"class": ${py(request.window_class)}}, ready=Ready(window=True)`; + const sources = aptSources({ + aptSources: install.key === undefined ? [] : [install.key.source], + }); + return `"""${entry} as an agentenv computer app for building an environment with REA. + +Rendered by REA's start_build_computer: the app installed from the request, opened on demand, and REA with Node.js +${BUILD_NODE_VERSION} in /opt/rea. It is for building only; keep it local and never publish it. +""" + +from agentenv_protocol.computer import AptSource, ComputerAppEnvironment, Download, Launch, Packages, Ready, RunScript, computer_app, linux + + +@computer_app(supports=[{"os": {"type": "linux", "display": ["x11"]}, "arch": ${pyList(install.architectures)}}]) +@linux( + packages=Packages(apt=${pyList(install.packages)}),${sources} + downloads=[${install.downloads.join(", ")}], + run=[${install.scripts.map((script) => `RunScript(script=${py(script)})`).join(", ")}], + launch=Launch(at="demand", command=${pyList(["gtk-launch", entry])}${window}), +) +class ${plan.className}(ComputerAppEnvironment): + """${entry}, opened on demand for building: it keeps no seed and serves no data operations.""" +`; +}; + +/** + * The bundle a build computer starts from: the requested app installed and + * opened on demand, without a seed or data hooks, plus REA and Node.js in a + * build-only layer. Node.js is downloaded per architecture at put; REA is + * downloaded too, or shipped in the environment folder from a local archive. + * An apt repository's key file is copied into the environment folder. + */ +export const renderBuildBundle = ( + request: BuildRequest, + payload: ReaPayload, + aptKey?: Uint8Array, +): { readonly name: string; readonly files: readonly BuildBundleFile[] } => { + const entry = request.desktop_entry.replace(/\.desktop$/u, ""); + const name = environmentName(entry, (candidate) => candidate !== "app"); + const plan = { name, className: className(name) }; + const install = buildInstall(request, name, payload, aptKey); + const env = `envs/${name}`; + const file = (path: string, content: BuildBundleFile["content"]) => ({ + path: `${env}/${path}`, + content, + }); + return { + name, + files: [ + file("env.toml", renderEnvToml(plan)), + file( + appModuleFile(name), + renderBuildModule(request, entry, plan, install), + ), + file(REA_INSTALL_SCRIPT, renderReaInstallScript()), + ...(install.packageFile === null + ? [] + : [file(INSTALL_SCRIPT, renderInstallScript(install.packageFile))]), + ...(install.key === undefined + ? [] + : [file(install.key.source.keyFile, install.key.bytes)]), + ...(payload.kind === "file" + ? [file(REA_PAYLOAD, { copyFrom: payload.path })] + : []), + { path: "tasks/smoke.json", content: renderSmokeTask(plan) }, + ], + }; +}; diff --git a/src/domain/desktop/appExport.ts b/src/domain/desktop/appExport.ts index 22d9b66bfb..a58f5f8b62 100644 --- a/src/domain/desktop/appExport.ts +++ b/src/domain/desktop/appExport.ts @@ -276,12 +276,14 @@ export const launchCommand = ( }; /** A Python string literal; JSON escapes are valid Python escapes. */ -const py = (value: string): string => JSON.stringify(value); -const pyList = (values: readonly string[]): string => +export const py = (value: string): string => JSON.stringify(value); +export const pyList = (values: readonly string[]): string => `[${values.map(py).join(", ")}]`; /** The bundle's env.toml: a computer app deployed alone on the X11 base. */ -export const renderEnvToml = (plan: DesktopAppExportPlan): string => +export const renderEnvToml = ( + plan: Pick, +): string => [ `type = "computer_app"`, `module = ${py(`${appModuleFile(plan.name)}:${plan.className}`)}`, @@ -334,7 +336,10 @@ const aptPackages = (plan: DesktopAppExportPlan): readonly string[] => [ ]), ]; -const aptSources = (plan: DesktopAppExportPlan): string => +/** The decorator's `sources=` line for vendor repositories, or nothing for the archive. */ +export const aptSources = ( + plan: Pick, +): string => plan.aptSources.length === 0 ? "" : `\n sources=[${plan.aptSources @@ -515,5 +520,7 @@ The seed is the user's real data. Keep this bundle local; do not publish it. ${plan.dconf.length === 0 ? "" : "dconf keys are compiled into the user's dconf database, with `dconf-cli`, once when the app first starts; `data/reset` does not restore them.\n"}${taskSection}`; /** A smoke task that deploys the app. */ -export const renderSmokeTask = (plan: DesktopAppExportPlan): string => +export const renderSmokeTask = ( + plan: Pick, +): string => `${JSON.stringify([{ id: "env", type: "deploy_env", env_id: plan.name }], null, 2)}\n`; diff --git a/src/domain/desktop/computerActions.test.ts b/src/domain/desktop/computerActions.test.ts new file mode 100644 index 0000000000..3d298ee812 --- /dev/null +++ b/src/domain/desktop/computerActions.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { driverCall, w3cKeys } from "./computerActions.js"; + +describe("computer actions", () => { + it("translates model key spellings to W3C names and keeps W3C names and characters", () => { + expect(w3cKeys(["ctrl+s"])).toEqual(["Control", "s"]); + expect(w3cKeys(["cmd+Shift+z"])).toEqual(["Meta", "Shift", "z"]); + expect(w3cKeys(["Return", "esc", "pgdn", "f5", "space"])).toEqual([ + "Enter", + "Escape", + "PageDown", + "F5", + " ", + ]); + expect(w3cKeys(["Control", "ArrowUp", "+", "ctrl++"])).toEqual([ + "Control", + "ArrowUp", + "+", + "Control", + "+", + ]); + // An unknown name passes through for the driver to refuse with its hint. + expect(w3cKeys(["Hyper"])).toEqual(["Hyper"]); + }); + + it("passes the driver's arguments through and translates key fields", () => { + expect( + driverCall("click", { x: 500, y: 250, count: 2, space: "norm" }), + ).toEqual({ + ok: true, + value: { + tool: "computer_click", + arguments: { x: 500, y: 250, count: 2, space: "norm" }, + }, + }); + expect(driverCall("key", { keys: ["ctrl+s"] })).toEqual({ + ok: true, + value: { tool: "computer_key", arguments: { keys: ["Control", "s"] } }, + }); + expect( + driverCall("drag", { + path: [ + [1, 2], + [3, 4], + ], + modifiers: ["shift"], + }), + ).toEqual({ + ok: true, + value: { + tool: "computer_drag", + arguments: { + path: [ + [1, 2], + [3, 4], + ], + modifiers: ["Shift"], + }, + }, + }); + }); + + it("refuses a field the action does not take", () => { + expect(driverCall("type", { text: "hi", x: 3 })).toEqual({ + ok: false, + error: { field: "x", accepted: ["text", "screenshot"] }, + }); + }); +}); diff --git a/src/domain/desktop/computerActions.ts b/src/domain/desktop/computerActions.ts new file mode 100644 index 0000000000..04a7a80e16 --- /dev/null +++ b/src/domain/desktop/computerActions.ts @@ -0,0 +1,197 @@ +import { z } from "zod"; + +import { fixedTupleSchema } from "../fixedTupleSchema.js"; +import type { JsonValue } from "../jsonValue.js"; +import { err, ok, type Result } from "../result.js"; + +/** Actions REA forwards to an agentenv computer's driver, each as its `computer_` tool. */ +export const COMPUTER_ACTIONS = [ + "screenshot", + "click", + "drag", + "scroll", + "type", + "key", + "wait", +] as const; +export type ComputerAction = (typeof COMPUTER_ACTIONS)[number]; + +const POINTER = ["frame_id", "space", "norm_scale"] as const; + +/** The driver's argument names each action takes, from its `computer_*` tool schemas. */ +const ACTION_FIELDS: Readonly> = { + screenshot: ["region", "format", "quality", ...POINTER], + click: ["x", "y", "button", "count", "modifiers", "screenshot", ...POINTER], + drag: [ + "path", + "button", + "duration_ms", + "modifiers", + "screenshot", + ...POINTER, + ], + scroll: [ + "direction", + "x", + "y", + "amount", + "modifiers", + "screenshot", + ...POINTER, + ], + type: ["text", "screenshot"], + key: ["keys", "repeat", "screenshot"], + wait: ["ms", "screenshot"], +}; + +const coordinate = z + .number() + .describe("Frame pixels, or 0..norm_scale with space norm."); + +/** The driver's screenshot arguments that size its image. */ +const SCREENSHOT_FIELDS = { + region: fixedTupleSchema([z.number(), z.number(), z.number(), z.number()]) + .optional() + .describe("screenshot: [x0, y0, x1, y1] to crop to."), + format: z.enum(["png", "jpeg"]).optional(), + quality: z.int().min(1).max(100).optional().describe("jpeg quality."), +}; + +/** How a build computer's first screenshot is taken; smaller images cost fewer tokens. */ +export const screenshotOptionsSchema = z.strictObject(SCREENSHOT_FIELDS); +export type ScreenshotOptions = z.output; + +/** One action on a build computer: the driver's own argument names, passed through as given. */ +export const computerActionInputSchema = z.strictObject({ + computer_id: z.uuid().describe("The id start_build_computer returned."), + action: z + .enum(COMPUTER_ACTIONS) + .describe("The driver's computer_ tool to call."), + x: coordinate.optional(), + y: coordinate.optional(), + frame_id: z + .string() + .min(1) + .optional() + .describe("The frame the point is on; the latest by default."), + space: z + .enum(["frame", "norm"]) + .optional() + .describe( + "frame: the frame's pixels, the default; norm: a 0..norm_scale grid over it.", + ), + norm_scale: z.number().positive().optional(), + button: z + .string() + .min(1) + .optional() + .describe("left, the default, right or middle."), + count: z.int().positive().optional().describe("click: 2 double-clicks."), + modifiers: z + .array(z.string().min(1)) + .optional() + .describe("Keys held during a click, drag or scroll."), + path: z + .array(fixedTupleSchema([z.number(), z.number()])) + .min(2) + .optional() + .describe("drag: [x, y] points from press to release."), + duration_ms: z.int().nonnegative().optional(), + direction: z.enum(["up", "down", "left", "right"]).optional(), + amount: z.int().positive().optional().describe("scroll: notches."), + text: z.string().optional().describe("type: \\n presses Enter, \\t Tab."), + keys: z + .array(z.string().min(1)) + .min(1) + .optional() + .describe( + 'key: one chord pressed in order, as ["Control", "s"] or ["ctrl+s"].', + ), + repeat: z.int().positive().optional(), + ms: z.int().nonnegative().optional().describe("wait: milliseconds."), + ...SCREENSHOT_FIELDS, + screenshot: z + .boolean() + .optional() + .describe("Return a screenshot taken after the action."), +}); +export type ComputerActionInput = z.output; + +/** Lowercase model and X11 spellings of keys the driver names in W3C terms. */ +const KEY_NAMES: ReadonlyMap = new Map([ + ["ctrl", "Control"], + ["control", "Control"], + ["alt", "Alt"], + ["option", "Alt"], + ["shift", "Shift"], + ["cmd", "Meta"], + ["command", "Meta"], + ["meta", "Meta"], + ["super", "Meta"], + ["win", "Meta"], + ["enter", "Enter"], + ["return", "Enter"], + ["esc", "Escape"], + ["escape", "Escape"], + ["tab", "Tab"], + ["space", " "], + ["backspace", "Backspace"], + ["delete", "Delete"], + ["del", "Delete"], + ["insert", "Insert"], + ["home", "Home"], + ["end", "End"], + ["pageup", "PageUp"], + ["page_up", "PageUp"], + ["pgup", "PageUp"], + ["pagedown", "PageDown"], + ["page_down", "PageDown"], + ["pgdn", "PageDown"], + ["up", "ArrowUp"], + ["down", "ArrowDown"], + ["left", "ArrowLeft"], + ["right", "ArrowRight"], +]); + +const ONE_CHARACTER = /^.$/su; + +/** One key's W3C name: a character as given, a known spelling translated, any other name left for the driver to judge. */ +const w3cKey = (spelling: string): string => { + if (ONE_CHARACTER.test(spelling)) return spelling; + const lower = spelling.toLowerCase(); + return ( + KEY_NAMES.get(lower) ?? + (/^f(?:[1-9]|1\d|2[0-4])$/u.test(lower) ? lower.toUpperCase() : spelling) + ); +}; + +/** W3C key names from W3C names or model spellings such as `ctrl+s`, which name a chord in order. */ +export const w3cKeys = (keys: readonly string[]): string[] => + keys.flatMap((key) => + ONE_CHARACTER.test(key) ? [key] : key.split(/\+(?!$)/u).map(w3cKey), + ); + +/** The driver tool and arguments for an action, or the first field the action does not take. */ +export const driverCall = ( + action: ComputerAction, + fields: Readonly>, +): Result< + { readonly tool: string; readonly arguments: Record }, + { readonly field: string; readonly accepted: readonly string[] } +> => { + const accepted = ACTION_FIELDS[action]; + const given = Object.entries(fields).flatMap(([name, value]) => + value === undefined ? [] : [[name, value] as const], + ); + const refused = given.find(([name]) => !accepted.includes(name))?.[0]; + if (refused !== undefined) return err({ field: refused, accepted }); + const translated = Object.fromEntries( + given.map(([name, value]) => [ + name, + (name === "keys" || name === "modifiers") && Array.isArray(value) + ? w3cKeys(value.filter((key) => typeof key === "string")) + : value, + ]), + ); + return ok({ tool: `computer_${action}`, arguments: translated }); +}; diff --git a/src/process/ProviderProcess.ts b/src/process/ProviderProcess.ts index 83946af8f2..10ce701278 100644 --- a/src/process/ProviderProcess.ts +++ b/src/process/ProviderProcess.ts @@ -206,6 +206,11 @@ export interface ProviderProcessSupervisorOptions { readonly captureStdout?: boolean; /** Retained output budget; overflow is drained and reported, not retained. */ readonly maxDiagnosticBytes?: ProcessDiagnosticBudget; + /** + * Which bytes the budget keeps: the first ones (the default), or each + * stream's latest, where a failing command's last words are. + */ + readonly diagnosticRetention?: "head" | "tail"; /** * Single-line token ending at a non-ASCII-word byte or EOF, located before retention * applies, so its latest occurrence survives an exhausted budget. @@ -833,13 +838,24 @@ export class ProviderProcessSupervisor { streamLimit === undefined ? bytes.byteLength : Math.max(0, streamLimit - capture.bytes); - const retained = Math.min( - bytes.byteLength, - aggregateRemaining, - streamRemaining, - ); - if (retained < bytes.byteLength) this.#diagnosticTruncated = true; - if (retained > 0) capture.append(bytes.subarray(0, retained)); + let truncated: boolean; + if (this.#options.diagnosticRetention === "tail") { + // The stream may give up its own earlier bytes to keep these. + const limit = Math.min( + capture.bytes + aggregateRemaining, + streamLimit ?? Number.MAX_SAFE_INTEGER, + ); + truncated = capture.appendTail(bytes, limit) > 0; + } else { + const retained = Math.min( + bytes.byteLength, + aggregateRemaining, + streamRemaining, + ); + if (retained > 0) capture.append(bytes.subarray(0, retained)); + truncated = retained < bytes.byteLength; + } + if (truncated) this.#diagnosticTruncated = true; this.#options.onDiagnostic?.({ type: "output", stream, @@ -848,7 +864,7 @@ export class ProviderProcessSupervisor { stream === "stdout" ? this.#stdoutObservedBytes : this.#stderrObservedBytes, - ...(retained < bytes.byteLength ? { truncated: true } : {}), + ...(truncated ? { truncated: true } : {}), }); } @@ -868,6 +884,8 @@ export class ProviderProcessSupervisor { class ProcessOutputCapture { readonly #chunks: Buffer[] = []; #bytes = 0; + /** Observed bytes before the first retained one, which tail retention dropped. */ + #start = 0; get bytes(): number { return this.#bytes; @@ -876,6 +894,7 @@ class ProcessOutputCapture { reset(): void { this.#chunks.length = 0; this.#bytes = 0; + this.#start = 0; } append(chunk: Buffer): void { @@ -883,6 +902,45 @@ class ProcessOutputCapture { this.#chunks.push(Buffer.from(chunk)); } + /** + * Keep the latest `limit` bytes; returns how many observed bytes this + * dropped. A UTF-8 character the cut splits is dropped whole. + */ + appendTail(chunk: Buffer, limit: number): number { + const before = this.#start; + const kept = chunk.subarray(Math.max(0, chunk.byteLength - limit)); + this.append(kept); + this.#start += chunk.byteLength - kept.byteLength; + const cut = kept.byteLength < chunk.byteLength || this.#bytes > limit; + this.#dropFront(this.#bytes - limit, cut); + return this.#start - before; + } + + /** Drop the first `count` retained bytes and, after a cut, the rest of a character it split. */ + #dropFront(count: number, cut: boolean): void { + let remaining = Math.max(0, count); + for ( + let first = this.#chunks[0]; + first !== undefined; + first = this.#chunks[0] + ) { + let drop = Math.min(remaining, first.byteLength); + // UTF-8 continuation bytes are 0b10xxxxxx. + while ( + cut && + drop < first.byteLength && + ((first[drop] ?? 0) & 0xc0) === 0x80 + ) + drop += 1; + if (drop === first.byteLength) this.#chunks.shift(); + else if (drop > 0) this.#chunks[0] = Buffer.from(first.subarray(drop)); + this.#bytes -= drop; + this.#start += drop; + remaining -= Math.min(remaining, drop); + if (drop < first.byteLength) return; + } + } + snapshot(observedBytes: number): ProviderProcessSnapshot["stdout"] { return this.#snapshotBuffer( Buffer.concat(this.#chunks, this.#bytes), @@ -894,10 +952,12 @@ class ProcessOutputCapture { start: number, end: number, ): { stream: ProviderProcessSnapshot["stdout"]; complete: boolean } { - const availableEnd = Math.min(end, this.#bytes); - const availableStart = Math.min(start, availableEnd); + // Positions are observed bytes; the retained ones start at #start. + const retainedEnd = this.#start + this.#bytes; + const availableEnd = Math.max(this.#start, Math.min(end, retainedEnd)); + const availableStart = Math.min(Math.max(start, this.#start), availableEnd); const chunks: Buffer[] = []; - let offset = 0; + let offset = this.#start; for (const chunk of this.#chunks) { const chunkEnd = offset + chunk.byteLength; const from = Math.max(availableStart, offset); @@ -909,7 +969,9 @@ class ProcessOutputCapture { const buffer = Buffer.concat(chunks, availableEnd - availableStart); return { stream: this.#snapshotBuffer(buffer, Math.max(0, end - start)), - complete: start === end || (start <= this.#bytes && end <= this.#bytes), + complete: + start === end || + (start >= this.#start && start <= retainedEnd && end <= retainedEnd), }; } diff --git a/src/process/ProviderProcessOutput.test.ts b/src/process/ProviderProcessOutput.test.ts index d8ce4e7acb..12cd80260a 100644 --- a/src/process/ProviderProcessOutput.test.ts +++ b/src/process/ProviderProcessOutput.test.ts @@ -894,3 +894,52 @@ it("marks a stop the owner requested before any signal it sends", async () => { stopRequested: true, }); }); + +it("retains a stream's latest bytes in tail retention, with checkpoints over what remains", () => { + const producer = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + exitCode: null, + signalCode: null, + kill: () => false, + }); + const supervisor = new ProviderProcessSupervisor( + { process: producer, ownsProcessLifetime: false }, + { + maxDiagnosticBytes: { stdout: 0, stderr: 6 }, + diagnosticRetention: "tail", + }, + ); + try { + const first = supervisor.checkpointOutput(); + producer.stderr.write("abcd"); + producer.stderr.write("efgh"); + producer.stderr.write("ij"); + expect(supervisor.snapshot()).toMatchObject({ + diagnosticTruncated: true, + stderr: { text: "efghij", bytes: 6, observedBytes: 10 }, + }); + // Bytes before the retained tail are gone, so that range is incomplete. + expect(supervisor.snapshotOutputSince(first)).toMatchObject({ + complete: false, + snapshot: { stderr: { text: "efghij" } }, + }); + const later = supervisor.checkpointOutput(); + producer.stderr.write("kl"); + expect(supervisor.snapshotOutputSince(later)).toMatchObject({ + complete: true, + snapshot: { stderr: { text: "kl", bytes: 2 } }, + }); + // A character the cut would split is dropped whole, not decoded as U+FFFD. + producer.stderr.write(Buffer.from("é12345")); + expect(supervisor.snapshot().stderr).toEqual({ + text: "12345", + bytes: 5, + observedBytes: 19, + }); + } finally { + supervisor.dispose(); + producer.stdout.destroy(); + producer.stderr.destroy(); + } +}); diff --git a/src/server/ToolResultDelivery.ts b/src/server/ToolResultDelivery.ts index 59bc2c7aef..ecabe3ae61 100644 --- a/src/server/ToolResultDelivery.ts +++ b/src/server/ToolResultDelivery.ts @@ -27,6 +27,7 @@ import { } from "../config/mcpResponseBudget.js"; import { encodeToolResult, + measureMcpResultWithinBudget, MCP_RESULT_STRING_LIMIT, NODE_MAX_STRING_LENGTH, TARGET_CONTENT_RESULT_META, @@ -137,6 +138,55 @@ export class ToolResultDelivery { : this.toErrorToolResult(result.error, contract.returnsTargetContent); } + /** + * A plain result followed by the content blocks a forwarded tool returned, + * such as a computer's screenshot, unchanged. When the whole response does + * not fit the budget, the error keeps the tool's own result, whose ids name + * what it started, and says how to ask for a smaller image. + */ + toForwardedToolResult( + result: Result, + contract: ToolContract, + forwarded: readonly Readonly>[], + ): CallToolResult { + const delivered = this.toCallToolResult(result, contract); + if (delivered.isError === true || forwarded.length === 0) return delivered; + // The blocks are the upstream MCP result's own, validated by its client. + const content = [ + ...delivered.content, + ...(forwarded as CallToolResult["content"]), + ]; + const combined = { ...delivered, content }; + const measured = measureMcpResultWithinBudget( + combined, + this.resultBudgetBytes, + ); + if (measured.ok) return combined; + const projected = mcpResponseOverflowProjection({ + completed: true, + constraint: "receive-buffer", + effectiveResponseBytes: this.maximumResponseBytes, + nextStep: + "The tool's own result, without the forwarded content, is details.result; use its ids, and ask for a smaller image: a screenshot region, or format jpeg with a lower quality.", + }); + return this.toErrorToolResult( + new AnalysisResourceConstraintError( + contract.name, + "transport", + projected.reason, + { + boundary: "mcp-response", + result_budget_bytes: this.resultBudgetBytes, + response_bytes_at_least: measured.bytesAtLeast, + constraint: measured.constraint, + }, + { remediationAction: projected.remediationAction }, + ), + contract.returnsTargetContent, + { result: result.ok ? result.value : null }, + ); + } + /** Deliver Evidence using the producer's explicit recording acknowledgment. */ toEvidenceToolResult( evidence: Evidence, diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 2eb4ef63b2..0171c42e42 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -31,6 +31,7 @@ import { registerEvmTools } from "./registerEvmTools.js"; import { registerSqliteTools } from "./registerSqliteTools.js"; import { registerRecordedCrashTools } from "./registerRecordedCrashTools.js"; import { registerDesktopApplicationTool } from "./registerDesktopApplicationTool.js"; +import { registerDesktopBuildTools } from "./registerDesktopBuildTools.js"; import { registerModelArtifactTools } from "./registerModelArtifactTools.js"; import { registerGoTools } from "./registerGoTools.js"; import { registerIl2cppTools } from "./registerIl2cppTools.js"; @@ -204,6 +205,7 @@ export const createServer = ( webSourceLocation, webModuleTrace, desktopApplication, + desktopBuildComputers, goBinary, rustBinary, erlangBeam, @@ -800,6 +802,7 @@ export const createServer = ( recordEvidenceWithUnknown, withAdmittedAnalysis: analysisAdmission, }); + registerDesktopBuildTools(server, desktopBuildComputers, toolLogger); }, session: () => { if (session === undefined) return; diff --git a/src/server/registerDesktopBuildTools.ts b/src/server/registerDesktopBuildTools.ts new file mode 100644 index 0000000000..d9f6adb117 --- /dev/null +++ b/src/server/registerDesktopBuildTools.ts @@ -0,0 +1,99 @@ +import type { + CallToolResult, + ServerContext, +} from "@modelcontextprotocol/server"; +import type { Logger } from "pino"; +import { z } from "zod"; + +import type { + DesktopBuildComputers, + ForwardedResult, +} from "../application/desktop/DesktopBuildComputers.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import type { ToolContract } from "../contracts/toolContractTypes.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; +import type { JsonValue } from "../domain/jsonValue.js"; +import { err, ok, type Result } from "../domain/result.js"; +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import { logToolExecution } from "./toolLogging.js"; +import { applicationToolRegistrationOptions } from "./toolRegistrationOptions.js"; + +/** Parse a tool's own input so a refusal names the field. */ +const admit = ( + contract: { readonly name: string; readonly inputSchema: Schema }, + raw: unknown, +): Result, AnalysisError> => { + const parsed = z.safeParse(contract.inputSchema, raw); + return parsed.success + ? ok(parsed.data) + : err( + analysisInputErrorFromIssues(contract.name, parsed.error.issues, raw, { + cause: parsed.error, + }), + ); +}; + +/** Register the build computer session tools on the REA MCP server. */ +export const registerDesktopBuildTools = ( + server: EvidenceMcpServer, + computers: Pick, + logger: Logger, +): void => { + const forward = ( + contract: ToolContract, + result: Result, AnalysisError>, + ): CallToolResult => + result.ok + ? server.delivery.toForwardedToolResult( + ok(result.value.value), + contract, + result.value.content, + ) + : server.delivery.toCallToolResult(result, contract); + const start = toolContract("start_build_computer"); + server.registerTool( + start.name, + applicationToolRegistrationOptions(start), + async (raw: unknown, context: ServerContext) => { + const input = admit(start, raw); + if (!input.ok) return server.delivery.toCallToolResult(input, start); + return forward( + start, + await logToolExecution(logger, start.name, () => + computers.start(input.value, context.mcpReq.signal), + ), + ); + }, + ); + const act = toolContract("act_on_build_computer"); + server.registerTool( + act.name, + applicationToolRegistrationOptions(act), + async (raw: unknown, context: ServerContext) => { + const input = admit(act, raw); + if (!input.ok) return server.delivery.toCallToolResult(input, act); + return forward( + act, + await logToolExecution(logger, act.name, () => + computers.act(input.value, context.mcpReq.signal), + ), + ); + }, + ); + const close = toolContract("close_build_computer"); + server.registerTool( + close.name, + applicationToolRegistrationOptions(close), + async (raw: unknown) => { + const input = admit(close, raw); + if (!input.ok) return server.delivery.toCallToolResult(input, close); + return server.delivery.toCallToolResult( + await logToolExecution(logger, close.name, () => + computers.close(input.value.computer_id), + ), + close, + ); + }, + ); +}; diff --git a/tests/boundary/cli/cliToolCoverage.test.ts b/tests/boundary/cli/cliToolCoverage.test.ts index 138b558323..832ea81457 100644 --- a/tests/boundary/cli/cliToolCoverage.test.ts +++ b/tests/boundary/cli/cliToolCoverage.test.ts @@ -96,6 +96,10 @@ const MCP_TOOLS_WITHOUT_DEDICATED_CLI = Object.freeze([ "unload_frida_script", "frida_session_status", "close_frida_session", + // Build computers are held by the long-lived MCP server between actions. + "start_build_computer", + "act_on_build_computer", + "close_build_computer", ]); describe("CLI canonical tool coverage", () => { diff --git a/tests/boundary/mcp/buildComputerMcp.test.ts b/tests/boundary/mcp/buildComputerMcp.test.ts new file mode 100644 index 0000000000..ef60a51ed3 --- /dev/null +++ b/tests/boundary/mcp/buildComputerMcp.test.ts @@ -0,0 +1,431 @@ +import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/server"; +import { expect, it, onTestFinished } from "vitest"; +import { z } from "zod"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { + FAKE_FRAME_PNG, + fakeAgentEnvRunner, + startFakeComputer, + type FakeComputer, + type FakeComputerOptions, +} from "../../fixtures/fakeAgentEnvComputer.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +/** The host's environment without the settings the opt-in real test exports, which would reach past these fakes. */ +const hostEnvironment = Object.fromEntries( + Object.entries(process.env).filter( + ([name]) => + ![ + "REA_AGENT_ENV_COMMAND", + "REA_DOCKER_COMMAND", + "REA_BUILD_COMPUTER_REA_ARCHIVE", + ].includes(name), + ), +); + +const connect = async (environment: Readonly>) => { + const session = createTestBinarySession(() => { + throw new Error("build computers must not start a deep provider"); + }); + const server = createServer( + { kind: "session", session }, + { + environment: { + ...hostEnvironment, + REA_MCP_INPUT_SCHEMA_PROFILE: "full", + ...environment, + }, + }, + ); + const client = new Client({ name: "build-computer-mcp", version: "1" }); + onTestFinished(async () => { + await client.close(); + await server.close(); + await session.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + return client; +}; + +const fakes = async (options: FakeComputerOptions = {}) => { + const computer = await startFakeComputer(options); + onTestFinished(() => computer.close()); + const runner = await fakeAgentEnvRunner(computer); + return { computer, runner }; +}; + +const bundleFolder = async (): Promise => { + const bundle = await createTestTempDirectory("exported-bundle-"); + await mkdir(join(bundle, "tasks")); + await writeFile(join(bundle, "tasks", "smoke.json"), "[]\n"); + return bundle; +}; + +const images = (result: CallToolResult) => + result.content.filter((block) => block.type === "image"); + +/** No delivered text, success or failure, may hold the control token. */ +const expectNoToken = (computer: FakeComputer, result: CallToolResult) => + expect(JSON.stringify(result)).not.toContain(computer.token); + +const startFromBundle = async (client: Client, computer: FakeComputer) => { + const started = await client.callTool({ + name: "start_build_computer", + arguments: { source: { kind: "bundle", path: await bundleFolder() } }, + }); + expect(started.isError, JSON.stringify(started)).not.toBe(true); + expectNoToken(computer, started); + return { + started, + value: toolContract("start_build_computer").outputSchema.parse( + started.structuredContent, + ), + }; +}; + +it("keeps a computer up, forwards actions with their images and tears it down on close", async () => { + const { computer, runner } = await fakes({ notReadyPolls: 2 }); + const client = await connect(runner.environment); + const { started, value } = await startFromBundle(client, computer); + expect(value.sandbox_id).toBe("sbx-fake"); + expect(images(started)).toEqual([ + { type: "image", data: FAKE_FRAME_PNG, mimeType: "image/png" }, + ]); + expect(await runner.notes("runs.jsonl")).toMatchObject([ + { tasks: ["smoke"], keep: true, emulate: false, sandbox: "local" }, + ]); + // Readiness was polled with the token until it passed; /mcp needs none. + expect(computer.unauthorized).toEqual([]); + + const act = (action: string, fields: Record = {}) => + client.callTool({ + name: "act_on_build_computer", + arguments: { computer_id: value.computer_id, action, ...fields }, + }); + const screenshot = await act("screenshot", { + region: [0, 0, 500, 500], + format: "jpeg", + space: "norm", + }); + expect(screenshot.isError).not.toBe(true); + expect(images(screenshot)).toHaveLength(1); + expect(screenshot.content).toContainEqual({ + type: "text", + text: "computer_screenshot done", + }); + const clicked = await act("click", { x: 640, y: 400, count: 2 }); + expect(images(clicked)).toEqual([]); + expect( + toolContract("act_on_build_computer").outputSchema.parse( + clicked.structuredContent, + ), + ).toMatchObject({ + action: "click", + action_index: 1, + result: { applied: "yes" }, + }); + await act("key", { keys: ["ctrl+s"], screenshot: true }); + expect(computer.calls.slice(1)).toEqual([ + { + name: "computer_screenshot", + arguments: { region: [0, 0, 500, 500], format: "jpeg", space: "norm" }, + }, + { name: "computer_click", arguments: { x: 640, y: 400, count: 2 } }, + { + name: "computer_key", + arguments: { keys: ["Control", "s"], screenshot: true }, + }, + ]); + + computer.refuseNext( + "computer_drag", + "stale_frame: there is no frame yet: take a screenshot first", + ); + const refused = await act("drag", { + path: [ + [1, 1], + [9, 9], + ], + }); + expectNoToken(computer, refused); + expect(parseMcpToolError(refused).error.message).toContain( + "stale_frame: there is no frame yet", + ); + const misplaced = await act("type", { text: "hi", x: 3 }); + expect(parseMcpToolError(misplaced).error.code).toBe("invalid_request"); + expect(computer.calls.map(({ name }) => name).at(-1)).toBe("computer_drag"); + + const closed = await client.callTool({ + name: "close_build_computer", + arguments: { computer_id: value.computer_id }, + }); + expect(closed.structuredContent).toEqual({ + computer_id: value.computer_id, + teardown: "agentenv", + }); + expect(await runner.notes("teardowns.jsonl")).toHaveLength(1); + const after = await act("screenshot"); + expect(parseMcpToolError(after).error.message).toContain( + "start_build_computer", + ); +}); + +it("renders a build bundle from a request with REA's payload and starts it", async () => { + const { computer, runner } = await fakes(); + const payload = join(runner.state, "rea-prod.tar.xz"); + await writeFile(payload, "a packed REA build"); + const client = await connect({ + ...runner.environment, + REA_BUILD_COMPUTER_REA_ARCHIVE: payload, + }); + const started = await client.callTool({ + name: "start_build_computer", + arguments: { + source: { + kind: "request", + desktop_entry: "org.xfce.mousepad", + install: { kind: "archive", package: "mousepad" }, + }, + }, + }); + expect(started.isError, JSON.stringify(started)).not.toBe(true); + expect(started.structuredContent).toMatchObject({ environment: "mousepad" }); + const env = join(runner.state, "bundle", "envs", "mousepad"); + expect(await readFile(join(env, "rea-payload.tar"), "utf8")).toBe( + "a packed REA build", + ); + expect(await readFile(join(env, "mousepad_app.py"), "utf8")).toContain( + 'Launch(at="demand"', + ); + const { computer_id: computerId } = toolContract( + "start_build_computer", + ).outputSchema.parse(started.structuredContent); + await client.callTool({ + name: "close_build_computer", + arguments: { computer_id: computerId }, + }); + expect(computer.calls.map(({ name }) => name)).toEqual([ + "computer_screenshot", + ]); +}); + +it("names what a request needs when REA's payload is not configured", async () => { + const { runner } = await fakes(); + const client = await connect(runner.environment); + const refused = await client.callTool({ + name: "start_build_computer", + arguments: { + source: { + kind: "request", + desktop_entry: "mousepad", + install: { kind: "archive", package: "mousepad" }, + }, + }, + }); + expect(JSON.stringify(parseMcpToolError(refused))).toContain( + "REA_BUILD_COMPUTER_REA_ARCHIVE", + ); + expect(await runner.notes("runs.jsonl")).toEqual([]); +}); + +it("reports the runner's failure with its output and leaves no helper behind", async () => { + const { runner } = await fakes(); + const client = await connect({ + ...runner.environment, + FAKE_FAIL: "compose failed: no space left on device", + }); + const failed = await client.callTool({ + name: "start_build_computer", + arguments: { source: { kind: "bundle", path: await bundleFolder() } }, + }); + const { error } = parseMcpToolError(failed); + expect(JSON.stringify(error)).toContain( + "compose failed: no space left on device", + ); + // The runner's own stdout went to the diagnostics, not the handshake. + expect(JSON.stringify(error)).toContain("agentenv writing to stdout"); +}); + +it("keeps the end of a long build's output, where its failure is", async () => { + const { runner } = await fakes(); + const client = await connect({ + ...runner.environment, + FAKE_CHATTER_BYTES: String(64 * 1024), + FAKE_FAIL: "Unable to locate package nosuchpkg", + }); + const failed = await client.callTool({ + name: "start_build_computer", + arguments: { source: { kind: "bundle", path: await bundleFolder() } }, + }); + const projected = JSON.stringify(parseMcpToolError(failed)); + expect(projected).toContain("E: Unable to locate package nosuchpkg"); + // The package-install chatter before it is what the bound drops. + expect(projected).not.toContain("chatter 00000:"); +}); + +it("keeps the computer's id when its first screenshot does not fit the response", async () => { + const { computer, runner } = await fakes({ + frameCharacters: 11 * 1024 * 1024, + }); + const client = await connect({ + ...runner.environment, + REA_MCP_MAX_RESPONSE_BYTES: String(10 * 1024 * 1024), + }); + const refused = parseMcpToolError( + await client.callTool({ + name: "start_build_computer", + arguments: { source: { kind: "bundle", path: await bundleFolder() } }, + }), + ); + const { computer_id: computerId } = z + .object({ computer_id: z.uuid() }) + .parse( + z.object({ result: z.unknown() }).parse(refused.error.details).result, + ); + // The id names the running computer: it can be driven and closed. + const shot = await client.callTool({ + name: "act_on_build_computer", + arguments: { computer_id: computerId, action: "wait", ms: 10 }, + }); + expect(shot.isError).not.toBe(true); + const closed = await client.callTool({ + name: "close_build_computer", + arguments: { computer_id: computerId }, + }); + expect(closed.structuredContent).toMatchObject({ teardown: "agentenv" }); + expect(computer.calls[0]?.name).toBe("computer_screenshot"); +}); + +it("takes the first screenshot with the caller's options", async () => { + const { computer, runner } = await fakes(); + const client = await connect(runner.environment); + const started = await client.callTool({ + name: "start_build_computer", + arguments: { + source: { kind: "bundle", path: await bundleFolder() }, + screenshot: { region: [0, 0, 640, 400], format: "jpeg", quality: 40 }, + }, + }); + expect(started.isError, JSON.stringify(started)).not.toBe(true); + expect(computer.calls[0]).toEqual({ + name: "computer_screenshot", + arguments: { region: [0, 0, 640, 400], format: "jpeg", quality: 40 }, + }); +}); + +it("refuses to start without an absolute agentenv runner", async () => { + const client = await connect({ REA_AGENT_ENV_COMMAND: "agent-env" }); + const refused = await client.callTool({ + name: "start_build_computer", + arguments: { source: { kind: "bundle", path: await bundleFolder() } }, + }); + const { error } = parseMcpToolError(refused); + expect(error.code).toBe("capability_unavailable"); + expect(JSON.stringify(error)).toContain("REA_AGENT_ENV_COMMAND"); +}); + +it("removes the sandbox's containers by name and label when agentenv's teardown leaves it up", async () => { + const { computer, runner } = await fakes(); + const docker = join(runner.state, "docker"); + // agentenv runs a computer as agent- without a label; only its other modes label containers. + await writeFile( + docker, + `#!/bin/sh\necho "$*" >> '${join(runner.state, "docker.log")}'\ncase "$4" in name=*) echo c0ffee ;; label=*) printf 'c0ffee\\nbead00\\n' ;; esac\nexit 0\n`, + ); + await chmod(docker, 0o755); + const client = await connect({ + ...runner.environment, + FAKE_TEARDOWN_FAILS: "1", + REA_DOCKER_COMMAND: docker, + }); + const { value } = await startFromBundle(client, computer); + const closed = await client.callTool({ + name: "close_build_computer", + arguments: { computer_id: value.computer_id }, + }); + expect(closed.structuredContent).toEqual({ + computer_id: value.computer_id, + teardown: "container_label", + }); + expect( + (await readFile(join(runner.state, "docker.log"), "utf8")).split("\n"), + ).toEqual([ + "ps -aq --filter name=^agent-sbx-fake$", + "ps -aq --filter label=agentenv.sandbox=sbx-fake", + "rm -f c0ffee bead00", + "", + ]); +}); + +it("keeps a computer whose teardown is unconfirmed so close can be retried", async () => { + const { computer, runner } = await fakes(); + const down = join(runner.state, "docker-down"); + const docker = join(runner.state, "docker"); + await writeFile(down, ""); + await writeFile( + docker, + `#!/bin/sh\n[ -e '${down}' ] && { echo "Cannot connect to the Docker daemon" >&2; exit 1; }\nexit 0\n`, + ); + await chmod(docker, 0o755); + const client = await connect({ + ...runner.environment, + FAKE_TEARDOWN_FAILS: "1", + REA_DOCKER_COMMAND: docker, + }); + const { value } = await startFromBundle(client, computer); + const close = () => + client.callTool({ + name: "close_build_computer", + arguments: { computer_id: value.computer_id }, + }); + const first = parseMcpToolError(await close()); + expect(first.error.code).toBe("cleanup_incomplete"); + expect(JSON.stringify(first)).toContain( + "Cannot connect to the Docker daemon", + ); + expect(JSON.stringify(first)).toContain("label=agentenv.sandbox=sbx-fake"); + expect(JSON.stringify(first)).toContain("name=^agent-sbx-fake$"); + // The record stays, so a retry reaches the same sandbox once Docker answers. + await rm(down); + expect((await close()).structuredContent).toEqual({ + computer_id: value.computer_id, + teardown: "container_label", + }); + expect(await runner.notes("teardowns.jsonl")).toHaveLength(1); +}); + +it("stops the helper when the caller cancels a start", async () => { + const { runner } = await fakes(); + const client = await connect({ + ...runner.environment, + FAKE_DEPLOY_SECONDS: "30", + }); + const controller = new AbortController(); + const call = client.callTool( + { + name: "start_build_computer", + arguments: { source: { kind: "bundle", path: await bundleFolder() } }, + }, + { signal: controller.signal }, + ); + while ((await runner.notes("runs.jsonl")).length === 0) await delay(50); + controller.abort(); + await expect(call).rejects.toThrow(); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((await runner.notes("interrupted.jsonl")).length > 0) break; + await delay(50); + } + expect(await runner.notes("interrupted.jsonl")).toHaveLength(1); +}); diff --git a/tests/boundary/providers/buildComputerLifecycle.test.ts b/tests/boundary/providers/buildComputerLifecycle.test.ts new file mode 100644 index 0000000000..c0f4ef6100 --- /dev/null +++ b/tests/boundary/providers/buildComputerLifecycle.test.ts @@ -0,0 +1,72 @@ +import { chmod, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +import { expect, it } from "vitest"; + +import { fakeBuildComputers as setUp } from "../../fixtures/fakeAgentEnvComputer.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it("joins a start still in progress when the owner closes, and refuses later starts", async () => { + const { computers, runner, start } = await setUp( + {}, + { FAKE_DEPLOY_SECONDS: "30" }, + ); + const order: string[] = []; + const pending = start().then((result) => { + order.push("start settled"); + return result; + }); + while ((await runner.notes("runs.jsonl")).length === 0) await delay(50); + await computers.closeAll(); + order.push("closeAll returned"); + expect(order).toEqual(["start settled", "closeAll returned"]); + const started = await pending; + expect(started.ok).toBe(false); + if (!started.ok) expect(started.error._tag).toBe("AnalysisCancelledError"); + // Shutdown cancelled the deploy, whose own interruption tore it down. + expect(await runner.notes("interrupted.jsonl")).toHaveLength(1); + const later = await start(); + expect(later.ok).toBe(false); + expect(await runner.notes("runs.jsonl")).toHaveLength(1); +}); + +it("keeps a failed start whose teardown is unconfirmed, so close and shutdown can retry it", async () => { + const state = await createTestTempDirectory("fake-docker-"); + const down = join(state, "docker-down"); + const docker = join(state, "docker"); + await writeFile(down, ""); + await writeFile( + docker, + `#!/bin/sh\n[ -e '${down}' ] && { echo "Cannot connect to the Docker daemon" >&2; exit 1; }\nexit 0\n`, + ); + await chmod(docker, 0o755); + const { computers, runner, start } = await setUp( + { readinessFails: true }, + { FAKE_TEARDOWN_FAILS: "1", REA_DOCKER_COMMAND: docker }, + ); + const started = await start(); + if (started.ok) throw new Error("readiness failed, so the start must fail"); + // The primary failure stays the error; the unconfirmed teardown rides on it. + expect(started.error.userMessage).toContain("Xvfb exited"); + expect(started.error.cleanupIncomplete).toBe(true); + expect(started.error.cleanup?.reason).toContain( + "Cannot connect to the Docker daemon", + ); + const id = started.error.diagnostics?.computer_id; + if (typeof id !== "string") throw new Error("the failure names its owner"); + // The retained owner retries the same sandbox; it cannot be driven. + expect((await computers.close(id)).ok).toBe(false); + expect( + ( + await computers.act( + { computer_id: id, action: "wait" }, + new AbortController().signal, + ) + ).ok, + ).toBe(false); + await rm(down); + await computers.closeAll(); + expect(await computers.close(id)).toMatchObject({ ok: false }); + expect(await runner.notes("teardowns.jsonl")).toHaveLength(1); +}); diff --git a/tests/boundary/providers/buildComputerReal.test.ts b/tests/boundary/providers/buildComputerReal.test.ts new file mode 100644 index 0000000000..656eb8aa71 --- /dev/null +++ b/tests/boundary/providers/buildComputerReal.test.ts @@ -0,0 +1,84 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import { describe, expect, it } from "vitest"; + +import { DesktopBuildComputers } from "../../../src/application/desktop/DesktopBuildComputers.js"; + +const execute = promisify(execFile); +const docker = process.env.REA_DOCKER_COMMAND ?? ""; + +/** + * Opt-in: starts a real computer with agentenv and Docker. Set + * REA_BUILD_COMPUTER_REAL=1 with absolute REA_AGENT_ENV_COMMAND and + * REA_DOCKER_COMMAND, and REA_BUILD_COMPUTER_REA_ARCHIVE naming a packed REA + * build; run on a native amd64 or arm64 Linux host. + */ +describe.skipIf(process.env.REA_BUILD_COMPUTER_REAL !== "1")( + "real agentenv build computer", + () => { + it("builds Mousepad with REA inside, drives it, records it and leaves no container", async () => { + const computers = new DesktopBuildComputers(process.env); + const signal = new AbortController().signal; + const started = await computers.start( + { + source: { + kind: "request", + desktop_entry: "org.xfce.mousepad", + window_class: "Mousepad", + install: { kind: "archive", package: "mousepad" }, + }, + }, + signal, + ); + if (!started.ok) throw started.error; + const { computer_id: id, sandbox_id: sandbox } = started.value.value; + // agentenv runs a computer as agent- without a label; its other modes label their containers. + const containers = async () => + ( + await Promise.all( + [ + `name=^agent-${sandbox}$`, + `label=agentenv.sandbox=${sandbox}`, + ].map((filter) => + execute(docker, ["ps", "-aq", "--filter", filter]), + ), + ) + ).flatMap(({ stdout }) => stdout.split(/\s+/u).filter(Boolean)); + try { + expect(started.value.content).toContainEqual( + expect.objectContaining({ type: "image", mimeType: "image/png" }), + ); + expect(await containers()).not.toEqual([]); + const { stdout: version } = await execute(docker, [ + "exec", + `agent-${sandbox}`, + "rea", + "--version", + ]); + expect(version.trim()).toMatch(/^\d+\.\d+\.\d+/u); + const session = computers.session(id); + if (session === undefined) throw new Error("no session"); + const recording = await session.startRecording(`rea-real:${id}`); + const typed = await computers.act( + { computer_id: id, action: "key", keys: ["ctrl+Escape"] }, + signal, + ); + expect(typed.ok, JSON.stringify(typed)).toBe(true); + // The driver's stream delivers its first frame a moment after the recording starts. + const waited = await computers.act( + { computer_id: id, action: "wait", ms: 2000 }, + signal, + ); + expect(waited.ok, JSON.stringify(waited)).toBe(true); + const manifest = await session.endRecording(recording.recording_id); + const segments = await session.recordingSegments(manifest); + expect(segments.length).toBeGreaterThan(0); + } finally { + const closed = await computers.close(id); + expect(closed.ok, JSON.stringify(closed)).toBe(true); + } + expect(await containers()).toEqual([]); + }, 1_800_000); + }, +); diff --git a/tests/boundary/providers/buildComputerRecording.test.ts b/tests/boundary/providers/buildComputerRecording.test.ts new file mode 100644 index 0000000000..331b93e12c --- /dev/null +++ b/tests/boundary/providers/buildComputerRecording.test.ts @@ -0,0 +1,139 @@ +import { expect, it, onTestFinished } from "vitest"; + +import { + LOGGED_ACTIONS, + LOGGED_BYTES, + LOGGED_LIST_ITEMS, + LOGGED_TEXT_BYTES, +} from "../../../src/application/desktop/DesktopBuildActionLog.js"; +import { + FAKE_SEGMENTS, + type FakeComputerOptions, + fakeBuildComputers, +} from "../../fixtures/fakeAgentEnvComputer.js"; + +/** A build computer held up by REA's real helper over a fake runner and driver. */ +const startComputer = async (options: FakeComputerOptions = {}) => { + const { computer, computers, start } = await fakeBuildComputers(options); + onTestFinished(() => computers.closeAll()); + const started = await start(); + if (!started.ok) throw started.error; + const id = started.value.value.computer_id; + const session = computers.session(id); + if (session === undefined) throw new Error("the started computer is held"); + return { computer, computers, id, session }; +}; + +it("records a build session under REA's request id and fetches its segments before teardown", async () => { + const { computer, computers, id, session } = await startComputer(); + const recording = await session.startRecording("rea-build:1"); + const acted = await computers.act( + { computer_id: id, action: "type", text: "buy milk\n" }, + new AbortController().signal, + ); + expect(acted.ok).toBe(true); + const manifest = await session.endRecording(recording.recording_id); + expect(await session.recordingSegments(manifest)).toEqual([ + { path: "rec-000000000001/0000.mp4", bytes: FAKE_SEGMENTS[0] }, + { path: "rec-000000000001/0001.mp4", bytes: FAKE_SEGMENTS[1] }, + ]); + // One segment per request, so only one segment's body is held at a time. + expect(computer.dataRequests).toEqual([ + [".agentenv-recordings/rec-000000000001/0000.mp4"], + [".agentenv-recordings/rec-000000000001/0001.mp4"], + ]); + expect(computer.recordingRequests).toEqual([ + { + path: "/agentenv/ext/computer-recording/start", + body: { request_id: "rea-build:1" }, + }, + { + path: "/agentenv/ext/computer-recording/end", + body: { recording_id: "rec-000000000001" }, + }, + ]); + // The witness reads what was done, in order, with the driver's arguments. + expect(session.actions.entries).toMatchObject([ + { + index: 0, + action: "type", + tool: "computer_type", + arguments: { text: "buy milk\n" }, + outcome: "applied", + }, + ]); + expect(computer.unauthorized).toEqual([]); + expect((await computers.close(id)).ok).toBe(true); +}); + +it("keeps a long session's action log within its allowance while the driver gets each action whole", async () => { + const { computer, computers, id, session } = await startComputer(); + const signal = new AbortController().signal; + const path = Array.from( + { length: 2 * LOGGED_LIST_ITEMS }, + (_, at): [number, number] => [at, at], + ); + expect( + (await computers.act({ computer_id: id, action: "drag", path }, signal)).ok, + ).toBe(true); + const text = "buy milk ".repeat(LOGGED_TEXT_BYTES); + const typed = LOGGED_ACTIONS; + for (let index = 0; index < typed; index += 1) + expect( + (await computers.act({ computer_id: id, action: "type", text }, signal)) + .ok, + ).toBe(true); + expect(computer.calls.at(1)?.arguments).toEqual({ path }); + expect(computer.calls.at(-1)?.arguments).toEqual({ text }); + const { entries } = session.actions; + expect( + entries.reduce( + (bytes, entry) => bytes + Buffer.byteLength(JSON.stringify(entry)), + 0, + ), + ).toBeLessThanOrEqual(LOGGED_BYTES); + expect(session.actions.total).toBe(typed + 1); + expect(session.actions.dropped).toBe(typed + 1 - entries.length); + expect(session.actions.dropped).toBeGreaterThan(0); + expect(entries.at(-1)).toMatchObject({ + index: typed, + action: "type", + arguments: { text: text.slice(0, LOGGED_TEXT_BYTES) }, + truncated_arguments: ["text"], + outcome: "applied", + }); + // The drag is no longer kept, but that it was applied is. + expect(session.actions.lastApplied).toEqual({ drag: 0, type: typed }); +}); + +it("refuses a segment whose bytes differ from the recording's manifest", async () => { + const { session } = await startComputer(); + const manifest = await session.endRecording("rec-000000000001"); + await expect( + session.recordingSegments({ + ...manifest, + segments: manifest.segments.map((segment) => ({ + ...segment, + sha256: "0".repeat(64), + })), + }), + ).rejects.toThrow(/rec-000000000001\/0000\.mp4/u); +}); + +it("refuses a recording larger than the allowance before reading any segment", async () => { + const { computer, session } = await startComputer(); + const manifest = await session.endRecording("rec-000000000001"); + const total = FAKE_SEGMENTS.reduce((sum, bytes) => sum + bytes.length, 0); + await expect( + session.recordingSegments(manifest, undefined, total - 1), + ).rejects.toThrow(String(total)); + expect(computer.dataRequests).toEqual([]); +}); + +it("stops reading a segment whose answer outgrows its manifest size", async () => { + const { session } = await startComputer({ segmentPadding: 1024 * 1024 }); + const manifest = await session.endRecording("rec-000000000001"); + await expect(session.recordingSegments(manifest)).rejects.toThrow( + /rec-000000000001\/0000\.mp4.*more than/u, + ); +}); diff --git a/tests/fixtures/fakeAgentEnvComputer.ts b/tests/fixtures/fakeAgentEnvComputer.ts new file mode 100644 index 0000000000..1d3de303e5 --- /dev/null +++ b/tests/fixtures/fakeAgentEnvComputer.ts @@ -0,0 +1,426 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { access, chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; +import type { AddressInfo } from "node:net"; +import { join } from "node:path"; + +import { + McpServer, + WebStandardStreamableHTTPServerTransport, +} from "@modelcontextprotocol/server"; +import { z } from "zod"; + +import { onTestFinished } from "vitest"; + +import { DesktopBuildComputers } from "../../src/application/desktop/DesktopBuildComputers.js"; +import { createTestTempDirectory } from "./temporaryDirectory.js"; + +/** A 1x1 PNG, the fake driver's every frame. */ +export const FAKE_FRAME_PNG = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; +/** The fake recording's segments, in order, as the driver keeps them under `rec-000000000001/`. */ +export const FAKE_SEGMENTS = [ + Buffer.from("not really an mp4 segment"), + Buffer.from("nor is this second one"), +]; +const segmentPath = (index: number) => + `rec-000000000001/${String(index).padStart(4, "0")}.mp4`; + +/** How the fake driver misbehaves, when a test asks it to. */ +export interface FakeComputerOptions { + /** Polls answered not ready before readiness passes. */ + readonly notReadyPolls?: number; + /** Readiness reports a final failed check instead. */ + readonly readinessFails?: boolean; + /** Base64 characters in each screenshot's image, in place of a 1x1 PNG. */ + readonly frameCharacters?: number; + /** Bytes data/get appends to each segment beyond its manifest size. */ + readonly segmentPadding?: number; +} + +const COMPUTER_TOOLS = [ + "computer_screenshot", + "computer_click", + "computer_drag", + "computer_scroll", + "computer_type", + "computer_key", + "computer_wait", +] as const; + +/** A local stand-in for an agentenv computer's driver: readiness, `computer_*` MCP tools, recording and data/get. */ +export interface FakeComputer { + readonly url: string; + readonly token: string; + /** Every `computer_*` call the driver received, with its arguments as sent. */ + readonly calls: { name: string; arguments: Record }[]; + /** Paths of framework-only routes called without the control token. */ + readonly unauthorized: string[]; + readonly recordingRequests: { path: string; body: unknown }[]; + /** The paths each data/get asked for, one entry per request. */ + readonly dataRequests: unknown[]; + /** Refuse the next call of a tool as the driver refuses one, with its message. */ + refuseNext(name: string, message: string): void; + close(): Promise; +} + +const readBody = async (request: IncomingMessage): Promise => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(chunk as Buffer); + return Buffer.concat(chunks); +}; + +const sendJson = (response: ServerResponse, status: number, value: unknown) => { + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify(value)); +}; + +/** The driver's `computer_*` tools over streamable HTTP, recording each call and refusing on request. */ +const startFakeDriver = async ( + calls: FakeComputer["calls"], + refusals: Map, + options: FakeComputerOptions, +) => { + const frameData = + options.frameCharacters === undefined + ? FAKE_FRAME_PNG + : "A".repeat(options.frameCharacters); + const mcp = new McpServer({ name: "fake-reference-driver", version: "1" }); + for (const name of COMPUTER_TOOLS) + mcp.registerTool( + name, + { description: name, inputSchema: z.looseObject({}) }, + (args: Record) => { + calls.push({ name, arguments: args }); + const refusal = refusals.get(name); + if (refusal !== undefined) { + refusals.delete(name); + return { content: [{ type: "text", text: refusal }], isError: true }; + } + const frame = { frame_id: `f${String(calls.length)}`, width: 1280 }; + const image = + name === "computer_screenshot" || args.screenshot === true + ? [ + { + type: "image" as const, + data: frameData, + mimeType: "image/png", + }, + ] + : []; + return { + content: [{ type: "text" as const, text: `${name} done` }, ...image], + structuredContent: + name === "computer_screenshot" + ? frame + : { applied: "yes", ...(image.length === 0 ? {} : { frame }) }, + }; + }, + ); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => "fake-session", + enableJsonResponse: true, + }); + await mcp.connect(transport); + return { mcp, transport }; +}; + +interface FakeState { + readonly options: FakeComputerOptions; + readonly recordingRequests: FakeComputer["recordingRequests"]; + readonly dataRequests: unknown[]; + polls: number; +} + +const segments = FAKE_SEGMENTS.map((bytes, index) => ({ + index, + path: segmentPath(index), + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), +})); + +/** The framework-only routes the token guards: readiness, recording and the driver's data/get. */ +const frameworkRoute = ( + state: FakeState, + path: string, + parsed: unknown, +): unknown => { + if (path === "/agentenv/ext/computer-ready/status") { + state.polls += 1; + const ready = + state.options.readinessFails !== true && + state.polls > (state.options.notReadyPolls ?? 0); + return { + ready, + checks: [ + { + name: "display", + ok: ready, + detail: state.options.readinessFails === true ? "Xvfb exited" : "", + ...(state.options.readinessFails === true ? { final: true } : {}), + }, + ], + }; + } + if (path.startsWith("/agentenv/ext/computer-recording/")) { + state.recordingRequests.push({ path, body: parsed }); + return path.endsWith("/start") + ? { + recording_id: "rec-000000000001", + started_at: "2026-11-05T00:00:00Z", + format: "video/mp4; codecs=avc1", + } + : { + recording_id: "rec-000000000001", + fps: 10, + started_at: "2026-11-05T00:00:00Z", + ended_at: "2026-11-05T00:00:15Z", + ended_by: "request", + segments, + }; + } + const { id, params } = parsed as { id: number; params: { paths: string[] } }; + state.dataRequests.push(params.paths); + const padding = Buffer.alloc(state.options.segmentPadding ?? 0, 0x20); + return { + jsonrpc: "2.0", + id, + result: { + parts: FAKE_SEGMENTS.flatMap((bytes, index) => { + const home = `.agentenv-recordings/${segmentPath(index)}`; + return params.paths.some( + (requested) => home === requested || home.startsWith(`${requested}/`), + ) + ? [ + { + kind: "file", + file: { + bytes: Buffer.concat([bytes, padding]).toString("base64"), + name: home.split("/").at(-1), + }, + metadata: { "urn:agentenv:data-objects/v1": { path: home } }, + }, + ] + : []; + }), + }, + }; +}; + +/** Start the fake driver, with the misbehaviour a test asks for. */ +export const startFakeComputer = async ( + options: FakeComputerOptions = {}, +): Promise => { + const token = "fake-control-token-0123456789abcdef"; + const calls: FakeComputer["calls"] = []; + const unauthorized: string[] = []; + const state: FakeState = { + options, + recordingRequests: [], + dataRequests: [], + polls: 0, + }; + const refusals = new Map(); + const { mcp, transport } = await startFakeDriver(calls, refusals, options); + const server = createServer((request, response) => { + void (async () => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + const body = await readBody(request); + // REA's client needs no server-initiated stream; a 405 says so. + if (url.pathname === "/mcp" && request.method === "GET") { + sendJson(response, 405, {}); + return; + } + if (url.pathname === "/mcp") { + const headers = new Headers(); + for (const [key, value] of Object.entries(request.headers)) + if (typeof value === "string") headers.set(key, value); + const answer = await transport.handleRequest( + new Request(url, { + method: request.method ?? "GET", + headers, + ...(body.length === 0 ? {} : { body }), + }), + ); + response.writeHead(answer.status, Object.fromEntries(answer.headers)); + response.end(Buffer.from(await answer.arrayBuffer())); + return; + } + if (request.headers.authorization !== `Bearer ${token}`) { + unauthorized.push(url.pathname); + sendJson(response, 401, { ok: false }); + return; + } + const parsed: unknown = body.length === 0 ? {} : JSON.parse(String(body)); + sendJson(response, 200, frameworkRoute(state, url.pathname, parsed)); + })().catch((cause: unknown) => { + response.destroy(cause instanceof Error ? cause : undefined); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${String(port)}`, + token, + calls, + unauthorized, + recordingRequests: state.recordingRequests, + dataRequests: state.dataRequests, + refuseNext: (name, message) => refusals.set(name, message), + close: async () => { + await mcp.close(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }, + }; +}; + +/** + * A stand-in `agent_env.bundle.run_bundle`, which REA's real helper imports: + * it notes each call, checks the bundle's Python compiles, copies the bundle + * and writes to stdout as a build would, then hands out the fake computer. + */ +const FAKE_BUNDLE_MODULE = String.raw` +import glob +import json +import os +import shutil +import signal +import sys +import time +import types + +STATE = os.environ["FAKE_STATE"] + + +def _note(name, value): + with open(os.path.join(STATE, name), "a") as handle: + handle.write(json.dumps(value) + "\n") + + +def _interrupted(*_): + _note("interrupted.jsonl", {"pid": os.getpid()}) + raise KeyboardInterrupt("cancelled") + + +class _Result: + def __init__(self, run): + self.runs = (run,) + + def teardown(self): + _note("teardowns.jsonl", {"pid": os.getpid()}) + still_up = ("sbx-fake",) if os.environ.get("FAKE_TEARDOWN_FAILS") else () + report = types.SimpleNamespace(still_up=still_up) + return types.SimpleNamespace(runs=(types.SimpleNamespace(torn_down=report),)) + + +def run_bundle(root, *, tasks=(), keep=False, emulate=True, sandbox=None, on_progress=None): + previous = signal.signal(signal.SIGTERM, _interrupted) + _note("runs.jsonl", {"root": str(root), "tasks": list(tasks), "keep": keep, "emulate": emulate, + "sandbox": sandbox}) + for path in glob.glob(os.path.join(root, "envs", "*", "*.py")): + with open(path) as handle: + compile(handle.read(), path, "exec") + shutil.copytree(root, os.path.join(STATE, "bundle"), dirs_exist_ok=True) + os.system("echo a build writing to stdout") + print("agentenv writing to stdout") + on_progress("deploying the computer") + time.sleep(float(os.environ.get("FAKE_DEPLOY_SECONDS", "0"))) + signal.signal(signal.SIGTERM, previous) + chatter = int(os.environ.get("FAKE_CHATTER_BYTES", "0")) + for line in range(chatter // 80): + print(f"chatter {line:05d}: Get:1 http://ports.ubuntu.com/ubuntu-ports noble/main arm64 libfoo", file=sys.stderr) + if os.environ.get("FAKE_FAIL"): + print(f"E: {os.environ['FAKE_FAIL']}", file=sys.stderr, flush=True) + raise RuntimeError(os.environ["FAKE_FAIL"]) + url = os.environ["FAKE_COMPUTER_URL"] + env = types.SimpleNamespace(environment_url=url, mcp_url=url + "/mcp", sandbox_id="sbx-fake", + control_token=os.environ["FAKE_COMPUTER_TOKEN"]) + run = types.SimpleNamespace(context=types.SimpleNamespace(deployed_envs=[env]), error=None, failed_steps=[]) + return _Result(run) +`; + +/** A fake runner: an `agent-env` script naming the host's python3 and a fake `agent_env` package on PYTHONPATH. */ +export interface FakeAgentEnvRunner { + readonly state: string; + readonly environment: Readonly>; + /** Lines one JSON-lines note file holds. */ + notes(name: string): Promise; +} + +export const fakeAgentEnvRunner = async ( + computer: Pick, +): Promise => { + const state = await createTestTempDirectory("fake-agentenv-"); + const python = execFileSync( + "python3", + ["-c", "import sys; print(sys.executable)"], + { encoding: "utf8" }, + ).trim(); + await mkdir(join(state, "site", "agent_env"), { recursive: true }); + const runner = join(state, "agent-env"); + await writeFile( + runner, + `#!${python}\n# Stand-in runner: REA reads only its interpreter.\n`, + ); + await chmod(runner, 0o755); + await writeFile(join(state, "site", "agent_env", "__init__.py"), ""); + await writeFile( + join(state, "site", "agent_env", "bundle.py"), + FAKE_BUNDLE_MODULE, + ); + return { + state, + environment: { + REA_AGENT_ENV_COMMAND: runner, + PYTHONPATH: join(state, "site"), + FAKE_STATE: state, + FAKE_COMPUTER_URL: computer.url, + FAKE_COMPUTER_TOKEN: computer.token, + }, + notes: async (name) => { + const path = join(state, name); + try { + await access(path); + } catch { + return []; + } + return (await readFile(path, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as unknown); + }, + }; +}; + +/** + * A build computer owner over REA's real helper, a fake runner and a fake + * driver, and a bundle-folder start; the test closes what it starts. + */ +export const fakeBuildComputers = async ( + options: FakeComputerOptions = {}, + environment: Readonly> = {}, +) => { + const computer = await startFakeComputer(options); + onTestFinished(() => computer.close()); + const runner = await fakeAgentEnvRunner(computer); + const computers = new DesktopBuildComputers({ + ...process.env, + ...runner.environment, + ...environment, + }); + const bundle = await createTestTempDirectory("exported-bundle-"); + await mkdir(join(bundle, "tasks")); + await writeFile(join(bundle, "tasks", "smoke.json"), "[]\n"); + const start = () => + computers.start( + { source: { kind: "bundle", path: bundle } }, + new AbortController().signal, + ); + return { computer, runner, computers, start }; +}; From 3708538e85775f1c5566ff54b9c9affe125a8875 Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:34:26 +0800 Subject: [PATCH 013/151] feat(javascript): compare static endpoints with HAR captures (#9090) * feat(javascript): compare static endpoints with HAR captures * test(javascript): preserve original HAR fixture bytes during formatting * test(javascript): load the byte-preserved HAR fixture --- docs/mcp-contracts.md | 1 + docs/web-network-captures.md | 103 +++++ ...cObservedEndpointComparisonService.test.ts | 64 +++ ...StaticObservedEndpointComparisonService.ts | 244 +++++++++++ src/cli/applicationCommands.ts | 2 + src/cli/staticObservedEndpointsCommand.ts | 129 ++++++ src/cliCommandNames.ts | 1 + src/contracts/applicationToolContracts.ts | 26 ++ .../applicationWorkflowInputContracts.ts | 27 ++ .../staticObservedEndpointExample.ts | 32 ++ src/contracts/toolEffects.ts | 1 + .../WebNetworkApiSurfaceAccumulator.ts | 76 +++- .../WebNetworkProjectionAccumulator.ts | 20 +- src/domain/analysisView/analysisView.ts | 9 + .../analysisView/analysisViewRequest.ts | 2 + src/domain/analysisView/analysisViewResult.ts | 22 + .../analysisView/endpointComparisonView.ts | 236 ++++++++++ .../staticObservedEndpointSchemas.ts | 103 +++++ .../staticObservedEndpoints.test.ts | 296 +++++++++++++ .../javascript/staticObservedEndpoints.ts | 355 +++++++++++++++ src/server/registerApplicationTools.ts | 2 + .../compareEndpoints.ts | 60 +++ .../staticObservedEndpoints.test.ts | 409 ++++++++++++++++++ .../javascript-endpoint-capture/README.md | 21 + .../javascript-endpoint-capture/app.js.txt | 6 + .../index.html.txt | 1 + .../recording.har.txt | 1 + 27 files changed, 2231 insertions(+), 18 deletions(-) create mode 100644 src/application/javascript/StaticObservedEndpointComparisonService.test.ts create mode 100644 src/application/javascript/StaticObservedEndpointComparisonService.ts create mode 100644 src/cli/staticObservedEndpointsCommand.ts create mode 100644 src/contracts/javascript/staticObservedEndpointExample.ts create mode 100644 src/domain/analysisView/endpointComparisonView.ts create mode 100644 src/domain/javascript/staticObservedEndpointSchemas.ts create mode 100644 src/domain/javascript/staticObservedEndpoints.test.ts create mode 100644 src/domain/javascript/staticObservedEndpoints.ts create mode 100644 src/server/registerApplicationTools/compareEndpoints.ts create mode 100644 tests/acceptance/applications/staticObservedEndpoints.test.ts create mode 100644 tests/fixtures/javascript-endpoint-capture/README.md create mode 100644 tests/fixtures/javascript-endpoint-capture/app.js.txt create mode 100644 tests/fixtures/javascript-endpoint-capture/index.html.txt create mode 100644 tests/fixtures/javascript-endpoint-capture/recording.har.txt diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 58e4b6b651..07e1afd8f7 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -817,6 +817,7 @@ arrays and nested sources declared by the tool's input schema: | `match_native_functions` | `left`, `right`, `left_observations[]`, `right_observations[]` | | `trace_application_feature`, `trace_javascript_semantics`, `compare_source_to_bundle` | `application`; feature tracing also accepts `native_observations[]` | | `compare_application_versions`, `compare_javascript_export_shapes` | `left`, `right`; version comparison also accepts both native-observation arrays | +| `compare_static_and_observed_endpoints` | `application`, `capture`; summary retains the complete comparison for `endpoint-comparison` pages | | `reconcile_javascript_runtime` | `static_layers[].analysis`, `runtime_observations[]` | | `compare_managed_members`, `verify_managed_native_boundaries`, `import_managed_reconstruction` | Comparison `left`/`right`; verification `managed_boundaries`/`native_observations[]`; reconstruction `static_members` | | `project_managed_application_graph` | `managed_artifact`, `managed_members`, `managed_native_boundaries` | diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index ada3ab10fd..f45f0bc71a 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -475,6 +475,109 @@ parent envelope, capture and examined records are deduplicated onto the view. Portable captures must retain unique ordinals within their declared total; caller-selected subsets can preserve any requested ordinal order. +## Compare static JavaScript endpoints with HAR + +`compare_static_and_observed_endpoints` answers which supplied static network +references correspond to captured request groups. Supply `application` from +`analyze_javascript_application` and `capture` from `inspect_web_network_capture` +with HAR format. Both accept inline Evidence or exact retained references. +For normal MCP investigations, request producer summaries, then pass their +`parent_evidence_id` values; large inline graphs can exceed transport admission. +Existing Evidence bundles can instead be loaded with `import_evidence_bundle`. + +```json +{ + "application": { + "kind": "retained-evidence", + "evidence_id": "ev_" + }, + "capture": { + "kind": "retained-evidence", + "evidence_id": "ev_" + }, + "origin": "https://api.example.test" +} +``` + +MCP defaults to a summary with source identities, counts, source coverage and +the complete comparison's `parent_evidence_id`. Select `detail: "complete"` for +all facts. An optional `view` can return a selected `endpoint-comparison` page +in this initial call and takes precedence over `detail`; it uses the same +projector as later retained queries. This answers a selected question without +an extra call or requiring the client's generic view-output schema preparation. +To read another page, call `inspect_analysis_view` on the retained parent: + +```json +{ + "source": { + "kind": "retained-evidence", + "evidence_id": "ev_" + }, + "view": { + "kind": "endpoint-comparison", + "collection": "static-endpoints", + "statuses": ["unknown"], + "fields": ["path", "status", "reason"], + "offset": 0, + "limit": 32 + } +} +``` + +Collections are `static-endpoints`, `observed-groups` and `matches`. Exact +`endpoint_ids` or `group_ids` select batches where applicable. Fields are +collection-specific; row identities always remain. Empty filters select none. +Pages classify the complete source pair before filtering, and page exhaustion +never upgrades source coverage. Observed-group pages omit potentially large +`record_ordinals` by default; explicitly select that field when needed. + +Each static network **observation** is matched, not observed in this capture, +or unknown. Each unmatched observed group is reported separately. Match rows +link stable endpoint/group identities, identify `exact` or inferred +`template-compatible` basis, and name one `supporting_record_ordinal`. An exact +witness identifies a real whole path within the group; other group members can +have different literal paths. Segment-value combinations are never evidence +that a whole path occurred. Group templates use the existing API-surface owner. + +`origin` is a caller assertion for root-relative targets, never a captured base +URL. Without it those targets remain unknown. Candidate values, unconfirmed +static observations, unresolved +expressions, non-URL GraphQL/tRPC operation names, non-HTTP targets and spellings +requiring URL normalization remain unknown. Known method spellings and the +EventSource/sendBeacon API definitions constrain matching; unavailable methods +stay null and multiple possible methods remain ambiguous. Fetch options and +XHR method arguments are not recovered by this comparison. +If multiple observed methods prevent pairing, those groups also stay unknown +unless another static observation establishes a match; they do not count as +observed-without-static-match. + +Not-observed requires an exercised origin, every parent record retained, and +no excluded request lines. It never means hidden, unused or inaccessible. +Source-specific limitations remain accessible on the linked source Evidence; +the comparison reports their counts without copying unrelated source text. +Query strings, fragments, headers and bodies are not projected. Sensitive-parent +refusals are the same as API-surface. Sources, grouping, whole-path witnesses +and derived rows share its 96 MiB representation allowance. No URL is contacted, +and this workflow does not reopen targets or rerun static analysis. + +CLI uses the same comparison and standard Evidence publication. Put +`application`, `capture` and optional `origin` in the input JSON; delivery uses +flags. Complete detail is the CLI default. A summary requires a bundle so all +facts and their source closure survive process exit: + +```bash +rea compare-static-and-observed-endpoints /evidence/compare-input.json \ + --detail summary --evidence_output /evidence/comparison.json --json +``` + +Add `--view '{"kind":"endpoint-comparison","collection":"static-endpoints","statuses":["not-observed-in-capture"],"fields":["path","status","reason"]}'` +to return that selection immediately. CLI views require summary detail and a +bundle, preserving complete Evidence after exit. + +CLI input can use retained IDs with `--evidence-bundle`. Later +`inspect-analysis-view` calls can use an `evidence-file` source pointing at the +saved bundle and the summary's complete parent ID. + ## Environment manifest Pass retained HAR Evidence to `inspect_analysis_view` with diff --git a/src/application/javascript/StaticObservedEndpointComparisonService.test.ts b/src/application/javascript/StaticObservedEndpointComparisonService.test.ts new file mode 100644 index 0000000000..3170351bdb --- /dev/null +++ b/src/application/javascript/StaticObservedEndpointComparisonService.test.ts @@ -0,0 +1,64 @@ +import { expect, it } from "vitest"; +import { compareStaticAndObservedEndpointsEvidence } from "./StaticObservedEndpointComparisonService.js"; +import { STATIC_OBSERVED_ENDPOINT_COMPARISON_EXAMPLE } from "../../contracts/javascript/staticObservedEndpointExample.js"; +import { createEvidence } from "../../domain/evidence.js"; + +it("supports cancellation at a cooperative boundary before publishing a result", async () => { + const controller = new AbortController(); + const pending = compareStaticAndObservedEndpointsEvidence( + STATIC_OBSERVED_ENDPOINT_COMPARISON_EXAMPLE, + undefined, + { signal: controller.signal }, + ); + controller.abort("stop comparison"); + expect(await pending).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); +}); + +it("admits the combined representation before source authentication can clone it", async () => { + const result = await compareStaticAndObservedEndpointsEvidence({ + ...STATIC_OBSERVED_ENDPOINT_COMPARISON_EXAMPLE, + application: { + ...STATIC_OBSERVED_ENDPOINT_COMPARISON_EXAMPLE.application, + raw_result: "x".repeat(8 * 1024 * 1024), + }, + }); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisResourceConstraintError", + operation: "compare_static_and_observed_endpoints", + }, + }); +}); + +it("locates malformed retained capture facts at the named consumer input", async () => { + const capture = createEvidence( + undefined, + STATIC_OBSERVED_ENDPOINT_COMPARISON_EXAMPLE.capture.provider, + { + operation: "inspect_web_network_capture", + parameters: { sensitive_value_count: 0 }, + result: {}, + confidence: "observed", + }, + ); + const result = await compareStaticAndObservedEndpointsEvidence({ + ...STATIC_OBSERVED_ENDPOINT_COMPARISON_EXAMPLE, + capture, + }); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisInputError", + operation: "compare_static_and_observed_endpoints", + issues: expect.arrayContaining([ + expect.objectContaining({ + path: ["capture", "normalized_result", "format"], + }), + ]), + }, + }); +}); diff --git a/src/application/javascript/StaticObservedEndpointComparisonService.ts b/src/application/javascript/StaticObservedEndpointComparisonService.ts new file mode 100644 index 0000000000..a91d85d96b --- /dev/null +++ b/src/application/javascript/StaticObservedEndpointComparisonService.ts @@ -0,0 +1,244 @@ +import type { WebNetworkCapture } from "../../domain/webNetworkCapture.js"; +import { setImmediate } from "node:timers/promises"; +import { compareStaticObservedEndpointsRequestSchema } from "../../contracts/javascript/applicationWorkflowInputContracts.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { readImmutableEvidence } from "../../domain/evidenceValidation.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { EvidenceWorkflowExecution } from "../investigation/EvidenceWorkflowExecution.js"; +import { + AnalysisCancelledError, + AnalysisInputError, +} from "../../domain/analysisErrorCore.js"; +import { digestCanonicalValue } from "../../domain/canonicalDigest.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER } from "../../domain/investigationProviders.js"; +import { WebNetworkApiSurfaceAccumulator } from "../../domain/analysisView/WebNetworkApiSurfaceAccumulator.js"; +import { admitHarCaptureParent } from "../../domain/analysisView/WebNetworkProjectionAccumulator.js"; +import { compareStaticObservedEndpoints } from "../../domain/javascript/staticObservedEndpoints.js"; +import type { StaticObservedEndpointComparison } from "../../domain/javascript/staticObservedEndpointSchemas.js"; +import { + resolveEvidenceRequest, + type EvidenceLookup, +} from "../EvidenceInputResolver.js"; +import { + applicationGraphEvidenceInputError, + parseApplicationGraphEvidence, + type ApplicationGraphEvidenceSource, +} from "./JavaScriptApplicationEvidenceGraph.js"; + +const OPERATION = "compare_static_and_observed_endpoints"; +interface EndpointComparisonExecution extends EvidenceWorkflowExecution { + readonly result: StaticObservedEndpointComparison; +} + +interface ComparisonSources { + readonly application: ApplicationGraphEvidenceSource; + readonly capture: WebNetworkCapture; + readonly captureEvidence: Evidence; + readonly origin: string | undefined; +} +type ComparisonOptions = { readonly signal?: AbortSignal }; +type ComparisonFacts = Pick< + StaticObservedEndpointComparison, + "static_endpoints" | "observed_groups" | "matches" | "summary" +>; + +const readSources = ( + rawInput: unknown, + lookup: EvidenceLookup | undefined, + accumulator: WebNetworkApiSurfaceAccumulator, + options: ComparisonOptions, +): Result => { + const request = resolveEvidenceRequest( + OPERATION, + rawInput, + compareStaticObservedEndpointsRequestSchema, + lookup, + { + ...options, + readEvidence: (value) => { + const admitted = accumulator.admitInlineEvidence(value); + return admitted.ok ? readImmutableEvidence(value) : admitted; + }, + }, + ); + if (!request.ok) return request; + const input = request.value.input; + const source = parseApplicationGraphEvidence(input.application, [ + "application", + ]); + if (!source.ok) + return err(applicationGraphEvidenceInputError(OPERATION, source.error)); + if (source.value.kind !== "static-application") + return err( + new AnalysisInputError(OPERATION, undefined, [ + { + path: ["application"], + reason: "invalid_value", + message: + "Use analyze_javascript_application Evidence; reconciled, managed and web-bundle graphs are outside this comparison's static scope.", + }, + ]), + ); + const captureEvidence = input.capture; + const admitted = admitHarCaptureParent( + { + evidenceId: captureEvidence.evidence_id, + operation: captureEvidence.operation, + predicateType: captureEvidence.predicate_type, + normalizedResult: captureEvidence.normalized_result, + subject: captureEvidence.subject, + parameters: captureEvidence.parameters, + limitations: captureEvidence.limitations, + }, + "api-surface", + accumulator, + { operation: OPERATION, sourceField: "capture" }, + ); + if (!admitted.ok) return admitted; + return ok({ + application: source.value, + capture: admitted.value, + captureEvidence, + origin: input.origin, + }); +}; + +const sourceCoverage = ( + { application, capture, captureEvidence }: ComparisonSources, + excluded: number, +): StaticObservedEndpointComparison["source_coverage"] => ({ + application: { + status: application.graph.coverage.status, + truncated: application.graph.coverage.truncated, + omitted_count: application.graph.coverage.omitted_count, + limit_count: application.graph.coverage.limits.length, + limitation_count: new Set([ + ...application.evidence.limitations, + ...application.graph.limitations, + ]).size, + }, + capture_limitation_count: new Set([ + ...captureEvidence.limitations, + ...capture.limitations, + ]).size, + observed_records_examined: capture.records.length, + observed_records_total: capture.total_records, + observed_records_excluded: excluded, +}); + +const createComparison = ( + sources: ComparisonSources, + comparison: ComparisonFacts, + accumulator: WebNetworkApiSurfaceAccumulator, +): Result => { + const { application, capture, captureEvidence, origin } = sources; + const evidenceLinks = [ + application.evidence.evidence_id, + captureEvidence.evidence_id, + ].sort(); + const limitations = [ + "Source-specific limitation details remain on the two source Evidence records; source_coverage reports their counts without copying unrelated capture or graph text.", + "Not-observed-in-capture refers only to this capture; it does not establish unused, hidden, inaccessible or absent server behavior.", + "Template-compatible pairs are inferred format matches, not handler identity or runtime execution proof. Unknown static methods are never filled from observed methods.", + "Observed-without-static-match means no confirmed correspondence in the supplied static observations; incomplete static discovery can miss code references.", + ...(origin === undefined + ? [] + : [ + "The origin for root-relative static targets is a caller assertion, not an observed application base URL.", + ]), + ].sort(); + const admittedLimitations = accumulator.addLimitations(limitations); + if (!admittedLimitations.ok) return admittedLimitations; + const parameters = { + application_evidence_id: application.evidence.evidence_id, + capture_evidence_id: captureEvidence.evidence_id, + origin: origin ?? null, + }; + const result: StaticObservedEndpointComparison = { + comparison_id: `endpoint_comparison_${digestCanonicalValue(parameters)}`, + application: { + evidence_id: application.evidence.evidence_id, + graph_id: application.graph.graph_id, + }, + capture: { + evidence_id: captureEvidence.evidence_id, + artifact: { + path: capture.artifact.path, + sha256: capture.artifact.sha256, + }, + }, + declared_origin: origin ?? null, + ...comparison, + source_coverage: sourceCoverage( + sources, + accumulator.comparisonCoverage().excluded_records, + ), + evidence_links: evidenceLinks, + limitations, + }; + const evidence: Evidence = createEvidence( + undefined, + JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, + { + predicateType: "rea.static-observed-endpoint-comparison", + operation: OPERATION, + parameters, + result, + rawResult: null, + confidence: "inferred", + authority: "analyst-inference", + environment: null, + limitations, + evidenceLinks, + }, + ); + return ok({ + evidence, + result, + sources: [application.evidence, captureEvidence], + }); +}; + +/** Authenticate sources once, group cooperatively, then publish the completed comparison. */ +export const compareStaticAndObservedEndpointsEvidence = async ( + rawInput: unknown, + lookup?: EvidenceLookup, + options: ComparisonOptions = {}, +): Promise> => { + const accumulator = new WebNetworkApiSurfaceAccumulator(undefined, false, { + operation: OPERATION, + }); + const admitted = readSources(rawInput, lookup, accumulator, options); + if (!admitted.ok) return admitted; + const sources = admitted.value; + const { capture, captureEvidence } = sources; + for (const [index, record] of capture.records.entries()) { + if (index % 256 === 0) { + await setImmediate(); + if (options.signal?.aborted) + return err( + new AnalysisCancelledError(OPERATION, { + cause: options.signal.reason, + }), + ); + } + const added = accumulator.add(record); + if (!added.ok) return added; + } + const compared = compareStaticObservedEndpoints({ + applicationEvidenceId: sources.application.evidence.evidence_id, + captureEvidenceId: captureEvidence.evidence_id, + graph: sources.application.graph, + accumulator, + ...(sources.origin === undefined ? {} : { origin: sources.origin }), + captureComplete: capture.records.length === capture.total_records, + }); + if (!compared.ok) return compared; + await setImmediate(); + if (options.signal?.aborted) + return err( + new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), + ); + return createComparison(sources, compared.value, accumulator); +}; diff --git a/src/cli/applicationCommands.ts b/src/cli/applicationCommands.ts index 6a88982ae4..fe14a4f0f3 100644 --- a/src/cli/applicationCommands.ts +++ b/src/cli/applicationCommands.ts @@ -8,6 +8,7 @@ import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { Logger } from "pino"; import { registerSourceToBundleCommand } from "./sourceToBundleCommand.js"; +import { registerStaticObservedEndpointsCommand } from "./staticObservedEndpointsCommand.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; import type { Evidence } from "../domain/evidence.js"; @@ -72,6 +73,7 @@ export const registerApplicationCommands = ( }, }); registerSourceToBundleCommand(cli, logger); + registerStaticObservedEndpointsCommand(cli, logger); registerEvidenceJsonCommand({ cli, logger, diff --git a/src/cli/staticObservedEndpointsCommand.ts b/src/cli/staticObservedEndpointsCommand.ts new file mode 100644 index 0000000000..849d0bb501 --- /dev/null +++ b/src/cli/staticObservedEndpointsCommand.ts @@ -0,0 +1,129 @@ +import { z } from "incur"; +import type { Logger } from "pino"; +import type { Evidence } from "../domain/evidence.js"; +import type { CliInstance } from "./types.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { parseCliJsonInput } from "../cliJsonInput.js"; +import { logCliCommand } from "../cliLogging.js"; +import { compareStaticObservedEndpointsFactsSchema } from "../contracts/javascript/applicationWorkflowInputContracts.js"; +import { getEvidenceInputContract } from "../domain/evidenceInputContract.js"; +import { endpointComparisonViewRequestSchema } from "../domain/analysisView/endpointComparisonView.js"; +import { parseAnalysisInput } from "../application/AnalysisInput.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER } from "../domain/investigationProviders.js"; +import { withCommandCancellation } from "./commandCancellation.js"; +import { + evidenceBundleOption, + runEvidenceInputCommand, +} from "./evidenceInputCommand.js"; +import { + analysisEvidenceDeliveryOptions, + parseCliAnalysisEvidenceDelivery, + publishCliAnalysisEvidence, +} from "./analysisEvidenceDelivery.js"; + +const operation = "compare_static_and_observed_endpoints"; +const deliverySchema = z + .object({ + detail: analysisEvidenceDeliveryOptions.detail, + view: endpointComparisonViewRequestSchema.optional(), + }) + .superRefine((value, context) => { + if (value.view !== undefined && value.detail !== "summary") + context.addIssue({ + code: "custom", + path: ["view"], + message: + "A selected page requires --detail summary and --evidence_output.", + }); + }); +const commandOptions = z.object({ + evidenceBundle: evidenceBundleOption, + ...analysisEvidenceDeliveryOptions, + view: z + .string() + .optional() + .describe( + "JSON endpoint-comparison page; requires --detail summary and --evidence_output", + ), +}); + +const runComparison = async ( + inputJson: string, + options: z.output, + signal: AbortSignal, +) => { + const selected = parseCliAnalysisEvidenceDelivery( + operation, + options, + deliverySchema, + ); + if (!selected.ok) return projectAnalysisError(selected.error); + const delivery = { + providerId: JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER.id, + operation, + signal, + detail: selected.value.detail, + evidenceOutput: selected.value.evidenceOutput, + ...(selected.value.view === undefined ? {} : { view: selected.value.view }), + }; + const raw = await parseCliJsonInput(inputJson, operation, signal); + if (!raw.ok) return raw.error; + // The frame validates transport fields without cloning either Evidence before budget admission. + const input = parseAnalysisInput( + operation, + raw.value, + getEvidenceInputContract(compareStaticObservedEndpointsFactsSchema) + .frameSchema, + signal, + ); + if (!input.ok) return projectAnalysisError(input.error); + let sources: readonly Evidence[] = []; + const result = await runEvidenceInputCommand( + operation, + options.evidenceBundle, + signal, + async (lookup) => { + const { compareStaticAndObservedEndpointsEvidence } = + await import("../application/javascript/StaticObservedEndpointComparisonService.js"); + const compared = await compareStaticAndObservedEndpointsEvidence( + { ...input.value, detail: options.detail }, + lookup, + { signal }, + ); + if (!compared.ok) return compared; + sources = compared.value.sources; + return { ok: true, value: compared.value.evidence }; + }, + ); + return publishCliAnalysisEvidence(result, { + ...delivery, + sourceEvidence: sources, + }); +}; + +/** Reuse standard CLI publication so summaries keep their complete source closure on disk. */ +export const registerStaticObservedEndpointsCommand = ( + cli: CliInstance, + logger: Logger, +): void => { + const name = CLI_COMMANDS.compareStaticAndObservedEndpoints; + cli.command(name, { + description: + "Compare static JavaScript endpoints and HAR Evidence JSON offline; --detail summary with --evidence_output saves full facts for selected inspect-analysis-view pages", + args: z.object({ + inputJson: z + .string() + .describe( + "Inline JSON or file containing application, capture and optional origin", + ), + }), + options: commandOptions, + run: ({ args, options }) => + withCommandCancellation((signal) => + logCliCommand(logger, name, () => + runComparison(args.inputJson, options, signal), + ), + ), + }); +}; diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 4c7e12ec6f..60d2be5ad6 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -179,6 +179,7 @@ export const CLI_COMMANDS = Object.freeze({ traceApplicationFeature: "trace-application-feature", traceJavaScriptSemantics: "trace-javascript-semantics", compareApplicationVersions: "compare-application-versions", + compareStaticAndObservedEndpoints: "compare-static-and-observed-endpoints", compareSourceToBundle: "compare-source-to-bundle", compareJavaScriptExportShapes: "compare-javascript-export-shapes", buildReconstructionObligationLedger: "build-reconstruction-obligation-ledger", diff --git a/src/contracts/applicationToolContracts.ts b/src/contracts/applicationToolContracts.ts index fe47b7f5d7..09b0e18959 100644 --- a/src/contracts/applicationToolContracts.ts +++ b/src/contracts/applicationToolContracts.ts @@ -5,7 +5,11 @@ import { featureTraceViewResultSchema, sourceToBundleSummaryViewResultSchema, sourceToBundlePageViewResultSchema, + endpointComparisonSummaryViewResultSchema, + endpointComparisonPageViewResultSchema, } from "../domain/analysisView/analysisView.js"; +import { staticObservedEndpointComparisonSchema } from "../domain/javascript/staticObservedEndpointSchemas.js"; +import { STATIC_OBSERVED_ENDPOINT_COMPARISON_EXAMPLE } from "./javascript/staticObservedEndpointExample.js"; import { applicationVersionComparisonResultSchema } from "../domain/javascript/javascriptApplicationVersionComparisonSchemas.js"; import { applicationFeatureTraceResultSchema } from "../domain/javascript/javascriptFeatureTraceSchemas.js"; import { javaScriptSemanticTraceResultSchema } from "../domain/javascript/javascriptSemanticTraceSchemas.js"; @@ -13,6 +17,7 @@ import { javaScriptExportShapeComparisonResultSchema } from "../domain/javascrip import { sourceToBundleComparisonResultSchema } from "../domain/javascript/sourceToBundleComparisonSchemas.js"; import { compareApplicationVersionsRequestSchema, + compareStaticObservedEndpointsRequestSchema, compareJavaScriptExportShapesRequestSchema, compareSourceToBundleRequestSchema, traceApplicationFeatureToolRequestSchema, @@ -207,6 +212,27 @@ export const APPLICATION_TOOL_CONTRACTS = [ }, ], }, + { + name: "compare_static_and_observed_endpoints", + ...toolContractMetadata("compare_static_and_observed_endpoints"), + description: + "Compare authenticated analyze_javascript_application and HAR Evidence, inline or by exact retained reference. No files, targets or URLs are acquired. Default summary retains the full comparison; optional view returns an endpoint-comparison page in this call (takes precedence over detail). inspect_analysis_view kind endpoint-comparison pages static-endpoints, observed-groups or matches with filters and fields. detail complete returns all facts. Pairings identify exact or inferred template-compatible basis; candidate/partial targets and ambiguous methods remain unknown. Root-relative targets need a caller-declared origin. Not-observed requires an exercised origin, every parent record retained and no excluded records; it never means hidden, unused or inaccessible. Copies no query strings, fragments, headers or bodies. Sensitive-declaration parents are refused; the shared 96 MiB representation budget includes source admission and derived state.", + kind: "application", + inputSchema: compareStaticObservedEndpointsRequestSchema, + outputSchema: evidenceResultOf( + z.union([ + staticObservedEndpointComparisonSchema, + endpointComparisonSummaryViewResultSchema, + endpointComparisonPageViewResultSchema, + ]), + ), + examples: [ + { + title: "Compare retained static endpoints with an offline HAR", + input: STATIC_OBSERVED_ENDPOINT_COMPARISON_EXAMPLE, + }, + ], + }, { name: "compare_source_to_bundle", ...toolContractMetadata("compare_source_to_bundle"), diff --git a/src/contracts/javascript/applicationWorkflowInputContracts.ts b/src/contracts/javascript/applicationWorkflowInputContracts.ts index 08a4294584..c5448d1bc9 100644 --- a/src/contracts/javascript/applicationWorkflowInputContracts.ts +++ b/src/contracts/javascript/applicationWorkflowInputContracts.ts @@ -5,6 +5,8 @@ import { import { sourceToBundleViewRequestSchema } from "../../domain/analysisView/sourceToBundleView.js"; import { featureTraceViewRequestSchema } from "../../domain/analysisView/featureTraceView.js"; import { z } from "zod"; +import { recordedBrowserOriginSchema } from "../../domain/browserObservation.js"; +import { endpointComparisonViewRequestSchema } from "../../domain/analysisView/endpointComparisonView.js"; import { evidenceInputSchema } from "../../domain/evidenceReference.js"; import { compareJavaScriptExportShapesInputSchema } from "../../domain/javascript/javascriptExportShapeComparisonSchemas.js"; @@ -51,6 +53,31 @@ const compareApplicationVersionsFacts = { right_native_observations: z.array(evidenceInputSchema).default([]), } as const; +/** Compare existing static Application Graph and HAR Evidence without acquiring targets. */ +export const compareStaticObservedEndpointsFactsSchema = z.strictObject({ + application: evidenceInputSchema, + capture: evidenceInputSchema, + origin: recordedBrowserOriginSchema + .optional() + .describe( + "Caller-declared canonical HTTP(S) origin for root-relative static targets; omit to keep their origin unknown. This never contacts the origin.", + ), +}); +export const compareStaticObservedEndpointsRequestSchema = + compareStaticObservedEndpointsFactsSchema.extend({ + view: endpointComparisonViewRequestSchema + .optional() + .describe( + "Return this selected page in the initial call, retaining the complete comparison. An explicit view takes precedence over detail.", + ), + detail: z + .enum(["summary", "complete"]) + .default("summary") + .describe( + "Summary retains the complete comparison and returns counts and source coverage. Use inspect_analysis_view kind endpoint-comparison for selected rows; complete returns all facts inline.", + ), + }); + /** Application comparison request accepting inline or same-session application Evidence. */ export const compareApplicationVersionsRequestSchema = z.strictObject({ ...compareApplicationVersionsFacts, diff --git a/src/contracts/javascript/staticObservedEndpointExample.ts b/src/contracts/javascript/staticObservedEndpointExample.ts new file mode 100644 index 0000000000..68107fb587 --- /dev/null +++ b/src/contracts/javascript/staticObservedEndpointExample.ts @@ -0,0 +1,32 @@ +import { createEvidence } from "../../domain/evidence.js"; +import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "./javascriptRuntimeReconciliationExample.js"; + +/** Portable source records demonstrate admission, without claiming a real capture. */ +export const STATIC_OBSERVED_ENDPOINT_COMPARISON_EXAMPLE = { + application: JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE, + capture: createEvidence( + undefined, + { id: "rea-example-har", name: "Example HAR", version: "1" }, + { + operation: "inspect_web_network_capture", + parameters: { sensitive_value_count: 0 }, + result: { + artifact: { path: "/capture.har", sha256: null, bytes: 0 }, + format: "har", + decoder: { id: "rea-example-har", name: "Example HAR", version: "1" }, + container: { + reported: {}, + numeric_literals: [], + redactions: [], + records_pointer: "/log/entries", + }, + total_records: 0, + records: [], + runtime_attribution: "unknown", + limitations: ["Empty illustrative capture, not a runtime measurement."], + }, + confidence: "observed", + }, + ), + origin: "https://example.test", +}; diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 4076200999..4e98ab96a7 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -769,6 +769,7 @@ const TOOL_EFFECT_AUDIT: Readonly> = { trace_application_feature: evidence, trace_javascript_semantics: evidence, compare_application_versions: evidence, + compare_static_and_observed_endpoints: evidence, search_guidance: effects(), read_guidance_section: effects(), guided_workflows: effects(), diff --git a/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.ts b/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.ts index fbbf65426d..41aeb8824d 100644 --- a/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.ts +++ b/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.ts @@ -22,6 +22,7 @@ import { textBytes, type RouteRule, type RouteTemplate, + type RecordedRequestLine, } from "./webNetworkRouteTemplates.js"; type ApiSurfaceFields = { @@ -35,6 +36,8 @@ interface GroupState { readonly method: string; readonly origin: string; readonly path_template: string; + readonly rules: string; + readonly paths?: Map; readonly segments: readonly { readonly segment_index: number; readonly rule: RouteRule; @@ -47,6 +50,20 @@ interface GroupState { } type Buckets = Map>>>; +/** Borrowed group facts; exact membership retains whole paths, never segment products. */ +export interface ApiSurfaceComparisonGroup { + readonly method: string; + readonly origin: string; + readonly path_template: string; + readonly rules: string; + readonly templated_segments: readonly { + readonly segment_index: number; + readonly rule: RouteRule; + }[]; + readonly record_ordinals: readonly number[]; + readonly exactPathOrdinal: (path: string) => number | undefined; +} + /** Own group identity, deduplication and admission for the entire projection. */ export class WebNetworkApiSurfaceAccumulator extends WebNetworkProjectionAccumulator { readonly #buckets: Buckets = new Map(); @@ -57,8 +74,11 @@ export class WebNetworkApiSurfaceAccumulator extends WebNetworkProjectionAccumul constructor( maximumBytes = WEB_NETWORK_CAPTURE_LIMITS.outputBytes, readonly includeQueryParameterNames = false, + private readonly comparison?: { + readonly operation: string; + }, ) { - super(maximumBytes, "API-surface"); + super(maximumBytes, "API-surface", comparison?.operation); } #exclude( @@ -87,15 +107,7 @@ export class WebNetworkApiSurfaceAccumulator extends WebNetworkProjectionAccumul if (route === null) return this.overBudget(); const queryNames = readQueryParameterNames(line.query, this.scratch); if (queryNames === null) return this.overBudget(); - return this.#append( - record.ordinal, - line.method, - line.origin, - route, - line.status, - mediaTypeEssence(line.rawMime), - queryNames, - ); + return this.#append(record.ordinal, line, route, queryNames); } finally { this.releaseTemporary(); } @@ -103,19 +115,20 @@ export class WebNetworkApiSurfaceAccumulator extends WebNetworkProjectionAccumul #append( ordinal: number, - method: string, - origin: string, + line: Extract, route: RouteTemplate, - status: number | null, - mime: string | null, queryNames: ReadonlySet | undefined, ): Result { + const { method, origin, path, status } = line; + const mime = mediaTypeEssence(line.rawMime); let group = this.#buckets .get(method) ?.get(origin) ?.get(route.template) ?.get(route.rules); let bytes = ENTRY_BYTES; // One retained record ordinal, including page output. + if (this.comparison !== undefined && !group?.paths?.has(path)) + bytes += ENTRY_BYTES + textBytes(path.length); if (group === undefined) { bytes += GROUP_BYTES + @@ -150,6 +163,10 @@ export class WebNetworkApiSurfaceAccumulator extends WebNetworkProjectionAccumul method, origin, path_template: route.template, + rules: route.rules, + ...(this.comparison === undefined + ? {} + : { paths: new Map() }), segments: route.segments.map((segment) => ({ segment_index: segment.segment_index, rule: segment.rule, @@ -173,6 +190,8 @@ export class WebNetworkApiSurfaceAccumulator extends WebNetworkProjectionAccumul group.segments[index]!.values.add(segment.value); } group.ordinals.push(ordinal); + if (group.paths !== undefined && !group.paths.has(path)) + group.paths.set(path, ordinal); if (status === null || mime === null) this.#unavailableResponse = true; if (status !== null) group.statuses.add(status); if (mime !== null) group.contentTypes.add(mime); @@ -186,6 +205,35 @@ export class WebNetworkApiSurfaceAccumulator extends WebNetworkProjectionAccumul return ok(undefined); } + /** Comparison-only inventory, borrowing admitted ordinals without page serialization. */ + *comparisonGroups(): IterableIterator { + if (this.comparison === undefined) + throw new Error( + "Exact paths must be retained when admitting comparison records", + ); + for (const group of this.#groups) + yield { + method: group.method, + origin: group.origin, + path_template: group.path_template, + rules: group.rules, + templated_segments: group.segments.map(({ segment_index, rule }) => ({ + segment_index, + rule, + })), + record_ordinals: group.ordinals, + exactPathOrdinal: (path) => group.paths!.get(path), + }; + } + + /** Counts do not allocate the existing view's complete page or exclusion list. */ + comparisonCoverage() { + return { + groups: this.#groups.length, + excluded_records: this.#excluded.length, + }; + } + page(offset: number, limit: number) { this.#groups.sort( (a, b) => diff --git a/src/domain/analysisView/WebNetworkProjectionAccumulator.ts b/src/domain/analysisView/WebNetworkProjectionAccumulator.ts index f57364d9c1..9080df2b2b 100644 --- a/src/domain/analysisView/WebNetworkProjectionAccumulator.ts +++ b/src/domain/analysisView/WebNetworkProjectionAccumulator.ts @@ -13,7 +13,7 @@ import { } from "../webNetworkCapture.js"; import { admitAnalysisViewData } from "./analysisViewDataAdmission.js"; import { analysisViewInputError } from "./analysisViewErrors.js"; -import { analysisViewResultInputError } from "./analysisViewParent.js"; +import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; import type { AnalysisViewParent } from "./analysisViewParent.js"; import { ENTRY_BYTES, @@ -32,6 +32,7 @@ export abstract class WebNetworkProjectionAccumulator { protected constructor( readonly maximumBytes: number, private readonly budgetSubject: string, + private readonly operation = "inspect_analysis_view", ) {} protected retain(bytes: number): boolean { @@ -72,7 +73,7 @@ export abstract class WebNetworkProjectionAccumulator { protected overBudget(): Result { return err( new AnalysisResourceConstraintError( - "inspect_analysis_view", + this.operation, "memory", `${this.budgetSubject} retained state and temporary work exceed the projection representation budget.`, { output_bytes: this.maximumBytes }, @@ -123,11 +124,14 @@ export const admitHarCaptureParent = ( parent: AnalysisViewParent, viewKind: "api-surface" | "environment-manifest" | "environment-state", accumulator: WebNetworkProjectionAccumulator, + context: { readonly operation?: string; readonly sourceField?: string } = {}, ): Result => { + const operation = context.operation ?? "inspect_analysis_view"; + const sourceField = context.sourceField ?? "source"; const unsupported = (reason: string, remediationAction?: string) => err( new AnalysisUnsupportedTargetError( - "inspect_analysis_view", + operation, parent.evidenceId, reason, remediationAction === undefined ? undefined : { remediationAction }, @@ -142,7 +146,15 @@ export const admitHarCaptureParent = ( const parsed = safeParseOwnedWebNetworkCapture(parent.normalizedResult); if (!parsed.success) return err( - analysisViewResultInputError(parent.normalizedResult, parsed.error), + analysisInputErrorFromIssues( + operation, + parsed.error.issues.map((issue) => ({ + ...issue, + path: [sourceField, "normalized_result", ...issue.path], + })), + { [sourceField]: { normalized_result: parent.normalizedResult } }, + { cause: parsed.error }, + ), ); if (parsed.data.format !== "har") return unsupported( diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index 94026bfcae..cf2d23698c 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -19,6 +19,7 @@ import { import { projectJavaScriptRoutesView } from "./javascriptRoutesView.js"; import { projectWebBundleView } from "./webBundleView.js"; import { projectSourceToBundleView } from "./sourceToBundleView.js"; +import { projectEndpointComparisonView } from "./endpointComparisonView.js"; import { projectFeatureTraceView } from "./featureTraceView.js"; import { projectNativeFeatureTraceView } from "./nativeFeatureTraceView.js"; import { projectBatchDecompileView } from "./batchDecompileView.js"; @@ -155,6 +156,8 @@ import { nativeFunctionSummaryViewResultSchema, sourceToBundleSummaryViewResultSchema, sourceToBundlePageViewResultSchema, + endpointComparisonSummaryViewResultSchema, + endpointComparisonPageViewResultSchema, featureTraceSummaryViewResultSchema, batchDecompileSummaryViewResultSchema, featureTraceViewResultSchema, @@ -224,6 +227,8 @@ export { nativeFunctionSummaryViewResultSchema, sourceToBundleSummaryViewResultSchema, sourceToBundlePageViewResultSchema, + endpointComparisonSummaryViewResultSchema, + endpointComparisonPageViewResultSchema, featureTraceSummaryViewResultSchema, batchDecompileSummaryViewResultSchema, featureTraceViewResultSchema, @@ -351,6 +356,10 @@ const retainedViewProjectors = new Map([ retain("inspect_gpu_code_objects", projectGpuCodeObjectsView), retain("match_native_functions", projectNativeFunctionMatchingView), retain("compare_source_to_bundle", projectSourceToBundleView), + retain( + "compare_static_and_observed_endpoints", + projectEndpointComparisonView, + ), retain("trace_application_feature", projectFeatureTraceView), retain("batch_decompile", projectBatchDecompileView), retain("inspect_artifact", projectArtifactInventoryView), diff --git a/src/domain/analysisView/analysisViewRequest.ts b/src/domain/analysisView/analysisViewRequest.ts index 58426500bb..716562a1b4 100644 --- a/src/domain/analysisView/analysisViewRequest.ts +++ b/src/domain/analysisView/analysisViewRequest.ts @@ -11,6 +11,7 @@ import { nativeExportsViewRequestSchema } from "./nativeExportsView.js"; import { javaScriptRoutesViewRequestSchema } from "./javascriptRoutesView.js"; import { webBundleViewRequestSchema } from "./webBundleView.js"; import { sourceToBundleViewRequestSchema } from "./sourceToBundleView.js"; +import { endpointComparisonViewRequestSchema } from "./endpointComparisonView.js"; import { featureTraceViewRequestSchema } from "./featureTraceView.js"; import { nativeFeatureTraceViewRequestSchema } from "./nativeFeatureTraceView.js"; import { batchDecompileViewRequestSchema } from "./batchDecompileView.js"; @@ -72,6 +73,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ webBundleViewRequestSchema, nativeFunctionMatchingViewRequestSchema, sourceToBundleViewRequestSchema, + endpointComparisonViewRequestSchema, featureTraceViewRequestSchema, nativeFeatureTraceViewRequestSchema, batchDecompileViewRequestSchema, diff --git a/src/domain/analysisView/analysisViewResult.ts b/src/domain/analysisView/analysisViewResult.ts index edea69c003..32acb45c50 100644 --- a/src/domain/analysisView/analysisViewResult.ts +++ b/src/domain/analysisView/analysisViewResult.ts @@ -73,6 +73,11 @@ import { sourceToBundleViewItemsSchema, sourceToBundleViewContextSchema, } from "./sourceToBundleView.js"; +import { + endpointComparisonViewContextSchema, + endpointComparisonViewItemsSchema, + endpointComparisonViewRequestSchema, +} from "./endpointComparisonView.js"; import { featureTraceSummarySchema, featureTraceViewCollections, @@ -1017,6 +1022,21 @@ export const sourceToBundleSummaryViewResultSchema = z.strictObject({ parent_operation: z.literal("compare_source_to_bundle"), summary: sourceToBundleViewContextSchema, }); +export const endpointComparisonSummaryViewResultSchema = z.strictObject({ + ...summaryViewResultBase, + artifact: z.null(), + parent_operation: z.literal("compare_static_and_observed_endpoints"), + summary: endpointComparisonViewContextSchema, +}); +export const endpointComparisonPageViewResultSchema = z.strictObject({ + ...viewResultBase, + ...endpointComparisonViewContextSchema.shape, + kind: z.literal("endpoint-comparison"), + artifact: z.null(), + parent_operation: z.literal("compare_static_and_observed_endpoints"), + view: endpointComparisonViewRequestSchema, + items: endpointComparisonViewItemsSchema, +}); export const sourceToBundlePageViewResultSchema = z.strictObject({ ...viewResultBase, ...sourceToBundleViewContextSchema.shape, @@ -1146,6 +1166,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ javaScriptRoutesViewResultSchema, webBundlePageViewResultSchema, sourceToBundlePageViewResultSchema, + endpointComparisonPageViewResultSchema, featureTraceViewResultSchema, batchDecompileViewResultSchema, z.strictObject({ @@ -1241,6 +1262,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ nativeFunctionMatchingSummaryViewResultSchema, nativeFunctionSummaryViewResultSchema, sourceToBundleSummaryViewResultSchema, + endpointComparisonSummaryViewResultSchema, featureTraceSummaryViewResultSchema, batchDecompileSummaryViewResultSchema, webBundleSummaryViewResultSchema, diff --git a/src/domain/analysisView/endpointComparisonView.ts b/src/domain/analysisView/endpointComparisonView.ts new file mode 100644 index 0000000000..c2266ea365 --- /dev/null +++ b/src/domain/analysisView/endpointComparisonView.ts @@ -0,0 +1,236 @@ +import { z } from "zod"; +import type { StaticObservedEndpointComparison } from "../javascript/staticObservedEndpointSchemas.js"; +import type { JsonValue } from "../jsonValue.js"; +import { + comparedStaticEndpointSchema, + comparedObservedGroupSchema, + endpointMatchSchema, + staticObservedEndpointComparisonSchema, +} from "../javascript/staticObservedEndpointSchemas.js"; +import { + createAnalysisViewResultReader, + type AnalysisViewParent, +} from "./analysisViewParent.js"; +import { + ownedView, + type AdmittedView, + type SummaryAnalysisView, +} from "./analysisViewShape.js"; +import { + boundedViewMessages, + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; +import { selectViewRows } from "./selectViewRows.js"; +import { analysisViewInputError } from "./analysisViewErrors.js"; +import { err, ok } from "../result.js"; + +const page = { + kind: z.literal("endpoint-comparison"), + offset: z.int().nonnegative().default(0), + limit: z.int().positive().default(64), +}; +export const endpointComparisonViewRequestSchema = z.discriminatedUnion( + "collection", + [ + z.strictObject({ + ...page, + collection: z.literal("static-endpoints"), + endpoint_ids: z + .array(comparedStaticEndpointSchema.shape.endpoint_id) + .optional(), + statuses: z.array(comparedStaticEndpointSchema.shape.status).optional(), + fields: z.array(comparedStaticEndpointSchema.keyof()).optional(), + }), + z.strictObject({ + ...page, + collection: z.literal("observed-groups"), + group_ids: z.array(comparedObservedGroupSchema.shape.group_id).optional(), + statuses: z.array(comparedObservedGroupSchema.shape.status).optional(), + fields: z.array(comparedObservedGroupSchema.keyof()).optional(), + }), + z.strictObject({ + ...page, + collection: z.literal("matches"), + endpoint_ids: z.array(endpointMatchSchema.shape.endpoint_id).optional(), + group_ids: z.array(endpointMatchSchema.shape.group_id).optional(), + fields: z.array(endpointMatchSchema.keyof()).optional(), + }), + ], +); +export const endpointComparisonViewItemsSchema = z.union([ + z.array( + comparedStaticEndpointSchema + .partial() + .required({ endpoint_id: true, node_id: true, observation_id: true }), + ), + z.array(comparedObservedGroupSchema.partial().required({ group_id: true })), + z.array( + endpointMatchSchema + .partial() + .required({ match_id: true, endpoint_id: true, group_id: true }), + ), +]); +export const endpointComparisonViewContextSchema = + staticObservedEndpointComparisonSchema.omit({ + static_endpoints: true, + observed_groups: true, + matches: true, + limitations: true, + }); +const readReport = createAnalysisViewResultReader( + staticObservedEndpointComparisonSchema, +); +const set = (values: readonly string[] | undefined) => + values === undefined ? undefined : new Set(values); +const fieldsOf = (row: object, fields: readonly string[]) => { + const source = row as Record; + return Object.fromEntries(fields.map((field) => [field, source[field]!])); +}; + +const selectComparisonRows = ( + report: StaticObservedEndpointComparison, + view: z.output, +) => { + const { static_endpoints, observed_groups, matches } = report; + let selected: { + rows: z.output; + total: number; + }; + if (view.collection === "static-endpoints") { + const ids = set(view.endpoint_ids), + statuses = set(view.statuses); + selected = selectViewRows( + static_endpoints, + view, + (row) => + (ids === undefined || ids.has(row.endpoint_id)) && + (statuses === undefined || statuses.has(row.status)), + (row) => ({ + ...fieldsOf( + row, + view.fields ?? [ + "method", + "method_basis", + "origin", + "origin_basis", + "path", + "status", + "reason", + ], + ), + endpoint_id: row.endpoint_id, + node_id: row.node_id, + observation_id: row.observation_id, + }), + ); + } else if (view.collection === "observed-groups") { + const ids = set(view.group_ids), + statuses = set(view.statuses); + selected = selectViewRows( + observed_groups, + view, + (row) => + (ids === undefined || ids.has(row.group_id)) && + (statuses === undefined || statuses.has(row.status)), + (row) => ({ + ...fieldsOf( + row, + view.fields ?? [ + "method", + "origin", + "path_template", + "status", + "reason", + ], + ), + group_id: row.group_id, + }), + ); + } else { + const endpoints = set(view.endpoint_ids), + groups = set(view.group_ids); + selected = selectViewRows( + matches, + view, + (row) => + (endpoints === undefined || endpoints.has(row.endpoint_id)) && + (groups === undefined || groups.has(row.group_id)), + (row) => ({ + ...fieldsOf(row, view.fields ?? ["basis", "supporting_record_ordinal"]), + match_id: row.match_id, + endpoint_id: row.endpoint_id, + group_id: row.group_id, + }), + ); + } + return selected; +}; + +/** Page completed classifications; filters never reclassify a subset or upgrade source coverage. */ +export const projectEndpointComparisonView = ( + parent: AnalysisViewParent, + admitted: AdmittedView, +) => { + const view = ownedView< + SummaryAnalysisView | z.output + >(admitted, "summary", "endpoint-comparison"); + if (view === undefined) + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: + "Endpoint comparisons support summary and endpoint-comparison pages", + }, + ]), + ); + const parsed = readReport(parent); + if (!parsed.ok) return parsed; + const { + static_endpoints: _static, + observed_groups: _observed, + matches: _matches, + limitations, + ...context + } = parsed.value; + const shared = { + parent_evidence_id: parent.evidenceId, + parent_operation: "compare_static_and_observed_endpoints" as const, + parent_digest: parent.evidenceId.slice(3), + artifact: null, + limitations: boundedViewMessages( + [...parent.limitations, ...limitations], + parent.evidenceId, + "limitations", + ), + unknowns: + parsed.value.summary.unknown > 0 + ? [ + "Some static endpoint observations cannot be classified; page static-endpoints with status unknown for their reasons.", + ] + : [], + }; + if (view.kind === "summary") + return ok({ + ...shared, + kind: "summary" as const, + view, + summary: context, + coverage: completeWithinViewCoverage(1, 1), + }); + const selected = selectComparisonRows(parsed.value, view); + return ok({ + ...shared, + ...context, + kind: "endpoint-comparison" as const, + view, + items: selected.rows, + coverage: pageViewCoverage( + view.offset, + selected.rows.length, + selected.total, + ), + }); +}; diff --git a/src/domain/javascript/staticObservedEndpointSchemas.ts b/src/domain/javascript/staticObservedEndpointSchemas.ts new file mode 100644 index 0000000000..f2cebd59f6 --- /dev/null +++ b/src/domain/javascript/staticObservedEndpointSchemas.ts @@ -0,0 +1,103 @@ +import { z } from "zod"; +import { digestSchema, prefixedDigestSchema } from "../digests.js"; +import { ROUTE_RULES } from "../analysisView/webNetworkRouteTemplates.js"; + +export const comparedStaticEndpointSchema = z.strictObject({ + endpoint_id: prefixedDigestSchema("static_endpoint"), + node_id: prefixedDigestSchema("jag_node"), + observation_id: prefixedDigestSchema("jag_observation"), + method: z.string().nullable(), + method_basis: z.enum(["api-definition", "callee-spelling", "unknown"]), + origin: z.string().nullable(), + origin_basis: z.enum(["literal-url", "caller-origin", "unknown"]), + path: z.string().nullable(), + status: z.enum(["matched", "not-observed-in-capture", "unknown"]), + reason: z + .enum([ + "candidate-endpoint", + "unconfirmed-static-observation", + "partial-target", + "non-http-target", + "unresolved-target", + "unparseable-target", + "normalization-required", + "origin-required", + "ambiguous-method", + "capture-incomplete", + "origin-not-exercised", + "no-observed-match", + ]) + .nullable(), +}); + +export const comparedObservedGroupSchema = z.strictObject({ + group_id: prefixedDigestSchema("observed_endpoint"), + method: z.string(), + origin: z.string(), + path_template: z.string(), + templated_segments: z.array( + z.strictObject({ + segment_index: z.int().nonnegative(), + rule: z.enum(ROUTE_RULES), + }), + ), + record_ordinals: z.array(z.int().nonnegative()), + status: z.enum(["matched", "unknown", "observed-without-static-match"]), + reason: z.literal("ambiguous-static-method").nullable(), +}); + +export const endpointMatchSchema = z.strictObject({ + match_id: prefixedDigestSchema("endpoint_match"), + endpoint_id: comparedStaticEndpointSchema.shape.endpoint_id, + group_id: comparedObservedGroupSchema.shape.group_id, + basis: z.enum(["exact", "template-compatible"]), + supporting_record_ordinal: z.int().nonnegative(), +}); + +export const staticObservedEndpointComparisonSchema = z.strictObject({ + comparison_id: prefixedDigestSchema("endpoint_comparison"), + application: z.strictObject({ + evidence_id: prefixedDigestSchema("ev"), + graph_id: prefixedDigestSchema("jag"), + }), + capture: z.strictObject({ + evidence_id: prefixedDigestSchema("ev"), + artifact: z.strictObject({ + path: z.string(), + sha256: digestSchema.nullable(), + }), + }), + declared_origin: z.string().nullable(), + summary: z.strictObject({ + static_network_endpoint_observations: z.int().nonnegative(), + matched: z.int().nonnegative(), + not_observed_in_capture: z.int().nonnegative(), + unknown: z.int().nonnegative(), + observed_groups: z.int().nonnegative(), + observed_without_static_match: z.int().nonnegative(), + observed_unknown: z.int().nonnegative(), + matches: z.int().nonnegative(), + }), + source_coverage: z.strictObject({ + application: z.strictObject({ + status: z.enum(["complete", "partial", "unknown", "unavailable"]), + truncated: z.boolean(), + omitted_count: z.int().nonnegative().nullable(), + limit_count: z.int().nonnegative(), + limitation_count: z.int().nonnegative(), + }), + capture_limitation_count: z.int().nonnegative(), + observed_records_examined: z.int().nonnegative(), + observed_records_total: z.int().nonnegative(), + observed_records_excluded: z.int().nonnegative(), + }), + static_endpoints: z.array(comparedStaticEndpointSchema), + observed_groups: z.array(comparedObservedGroupSchema), + matches: z.array(endpointMatchSchema), + evidence_links: z.array(prefixedDigestSchema("ev")), + limitations: z.array(z.string()), +}); + +export type StaticObservedEndpointComparison = z.output< + typeof staticObservedEndpointComparisonSchema +>; diff --git a/src/domain/javascript/staticObservedEndpoints.test.ts b/src/domain/javascript/staticObservedEndpoints.test.ts new file mode 100644 index 0000000000..03e218a690 --- /dev/null +++ b/src/domain/javascript/staticObservedEndpoints.test.ts @@ -0,0 +1,296 @@ +import { expect, it } from "vitest"; +import { + createJavaScriptApplicationGraph, + createJavaScriptApplicationNode, +} from "./javascriptApplicationGraph.js"; +import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js"; +import { javascriptApplicationAnalysisResultSchema } from "./javascriptApplicationAnalysis.js"; +import { analysisViewCaptureFixture } from "../../../tests/fixtures/analysisView.js"; +import { WebNetworkApiSurfaceAccumulator } from "../analysisView/WebNetworkApiSurfaceAccumulator.js"; +import { compareStaticObservedEndpoints } from "./staticObservedEndpoints.js"; +import { staticObservedEndpointComparisonSchema } from "./staticObservedEndpointSchemas.js"; + +const original = javascriptApplicationAnalysisResultSchema.parse( + JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE.normalized_result, +).graph; +const evidence = original.nodes[0]!.observations[0]!.evidence; +if (!evidence.artifact.available) + throw new Error("Static fixture must identify its source bytes"); +const artifactSha256 = evidence.artifact.sha256; +const graphFor = ( + targets: readonly { + value: string; + mechanism?: string; + interpretation?: string; + resolution?: string; + }[], + sourceState: "observed" | "unknown" = "observed", +) => + createJavaScriptApplicationGraph({ + schema: original.schema, + root_node_ids: original.root_node_ids, + coverage: original.coverage, + limitations: original.limitations, + nodes: [ + ...original.nodes, + ...targets.map((target, index) => + createJavaScriptApplicationNode({ + kind: "endpoint", + identity: { + strategy: "artifact-local-key", + stability: "artifact-version", + artifact_sha256: artifactSha256, + namespace: "network-endpoint", + key: String(index), + }, + observations: [ + { + label: null, + properties: { + endpoint_kind: "network", + mechanism: "call:fetch", + ...target, + }, + evidence: + sourceState === "observed" + ? evidence + : { + ...evidence, + state: sourceState, + confidence: "unknown", + limitations: [ + "Fixture explicitly leaves network interpretation unknown.", + ], + }, + }, + ], + }), + ), + ], + edges: [], + }); +const run = ( + targets: Parameters[0], + requests: readonly { path: string; method?: string; origin?: string }[], + options: { + complete?: boolean; + origin?: string | null; + budget?: number; + sourceState?: "observed" | "unknown"; + } = {}, +) => { + const accumulator = new WebNetworkApiSurfaceAccumulator( + options.budget, + false, + { operation: "compare_static_and_observed_endpoints" }, + ); + for (const [ordinal, request] of requests.entries()) { + const added = accumulator.add({ + ...analysisViewCaptureFixture().records[0]!, + ordinal, + limitations: [], + reported: { + request: { + method: request.method ?? "GET", + url: `${request.origin ?? "https://api.test"}${request.path}`, + }, + response: { status: 200, content: { mimeType: "application/json" } }, + }, + }); + if (!added.ok) return added; + } + return compareStaticObservedEndpoints({ + applicationEvidenceId: `ev_${"a".repeat(64)}`, + captureEvidenceId: `ev_${"b".repeat(64)}`, + graph: graphFor(targets, options.sourceState), + accumulator, + captureComplete: options.complete ?? true, + ...(options.origin === null + ? {} + : { origin: options.origin ?? "https://api.test" }), + }); +}; + +it("reports all four outcomes, exact/template basis and unknown method without copying URL details", () => { + const result = run( + [ + { value: "/matched?source-query=ordinary#fragment" }, + { value: "/users/789" }, + { value: "/missing" }, + { value: "KEY", interpretation: "candidate" }, + ], + [ + { path: "/matched?capture-query=ordinary" }, + { path: "/users/123" }, + { path: "/observed-only" }, + ], + ); + if (!result.ok) throw result.error; + expect(result.value.summary).toMatchObject({ + matched: 2, + not_observed_in_capture: 1, + unknown: 1, + observed_without_static_match: 1, + }); + expect(result.value.matches.map(({ basis }) => basis).sort()).toEqual([ + "exact", + "template-compatible", + ]); + expect( + result.value.static_endpoints.find(({ path }) => path === "/matched"), + ).toMatchObject({ + method: null, + method_basis: "unknown", + origin_basis: "caller-origin", + }); + expect(JSON.stringify(result.value)).not.toMatch( + /source-query|capture-query|fragment|ordinary/, + ); + expect( + staticObservedEndpointComparisonSchema.shape.static_endpoints.safeParse( + result.value.static_endpoints, + ).success, + ).toBe(true); +}); + +it("does not promote an explicitly unknown static observation to a match", () => { + const result = run([{ value: "/matched" }], [{ path: "/matched" }], { + sourceState: "unknown", + }); + if (!result.ok) throw result.error; + expect(result.value.static_endpoints[0]).toMatchObject({ + status: "unknown", + reason: "unconfirmed-static-observation", + }); + expect(result.value.matches).toEqual([]); +}); + +it("does not manufacture an exact whole path from independent templated segment values", () => { + const result = run( + [{ value: "/a/1/b/2" }], + [{ path: "/a/1/b/3" }, { path: "/a/4/b/2" }], + ); + if (!result.ok) throw result.error; + expect(result.value.matches[0]?.basis).toBe("template-compatible"); + expect(result.value.observed_groups[0]?.record_ordinals).toEqual([0, 1]); +}); + +it("names a concrete exact witness within a group containing different literal paths", () => { + const result = run( + [{ value: "/users/123" }], + [{ path: "/users/456" }, { path: "/users/123" }], + ); + if (!result.ok) throw result.error; + expect(result.value.matches[0]).toMatchObject({ + basis: "exact", + supporting_record_ordinal: 1, + }); + expect(result.value.observed_groups[0]?.record_ordinals).toEqual([0, 1]); +}); + +it.each([{ complete: false }, { origin: null }])( + "keeps missing endpoints unknown with incomplete capture or undeclared origin: %j", + (options) => { + const result = run([{ value: "/missing" }], [{ path: "/other" }], options); + if (!result.ok) throw result.error; + expect(result.value.static_endpoints[0]?.status).toBe("unknown"); + }, +); + +it("requires an exercised origin, even for an exact absolute URL", () => { + const result = run( + [{ value: "https://unseen.test/missing" }], + [{ path: "/other" }], + ); + if (!result.ok) throw result.error; + expect(result.value.static_endpoints[0]).toMatchObject({ + status: "unknown", + reason: "origin-not-exercised", + }); +}); + +it("keeps normalization, operation names and partial expressions unknown", () => { + const result = run( + [ + { value: "/a/%2e%2e/admin" }, + { value: "GetViewer" }, + { value: "trpc.user.byId" }, + { value: "fetch(prefix + path)", resolution: "partial" }, + ], + [{ path: "/admin" }], + ); + if (!result.ok) throw result.error; + expect(result.value.summary.unknown).toBe(4); + expect(result.value.matches).toEqual([]); +}); + +it.each([false, true])( + "keeps ambiguity on both sides and preserves confirmed method matches (known peer: %s)", + (knownPeer) => { + const result = run( + [ + { value: "/ambiguous" }, + ...(knownPeer + ? [{ value: "/ambiguous", mechanism: "new:EventSource" }] + : []), + { value: "/events", mechanism: "new:EventSource" }, + ], + [ + { path: "/ambiguous", method: "GET" }, + { path: "/ambiguous", method: "POST" }, + { path: "/events", method: "POST" }, + ], + ); + if (!result.ok) throw result.error; + expect( + result.value.static_endpoints.find( + ({ path, method }) => path === "/ambiguous" && method === null, + ), + ).toMatchObject({ + status: "unknown", + reason: "ambiguous-method", + method: null, + }); + expect( + result.value.static_endpoints.find(({ path }) => path === "/events"), + ).toMatchObject({ status: "not-observed-in-capture", method: "GET" }); + expect(result.value.summary.observed_unknown).toBe(knownPeer ? 1 : 2); + expect( + result.value.observed_groups.find( + ({ path_template, method }) => + path_template === "/ambiguous" && method === "POST", + ), + ).toMatchObject({ status: "unknown", reason: "ambiguous-static-method" }); + expect( + result.value.observed_groups.find( + ({ path_template, method }) => + path_template === "/ambiguous" && method === "GET", + ), + ).toMatchObject({ + status: knownPeer ? "matched" : "unknown", + reason: knownPeer ? null : "ambiguous-static-method", + }); + }, +); + +it("does not upgrade absence when even one recorded request is excluded", () => { + const result = run( + [{ value: "/missing" }], + [{ path: "/known" }, { path: "/omitted", method: "" }], + ); + if (!result.ok) throw result.error; + expect(result.value.static_endpoints[0]).toMatchObject({ + status: "unknown", + reason: "capture-incomplete", + }); +}); + +it("charges comparison state against the inherited representation allowance", () => { + const result = run([{ value: "/large".repeat(500) }], [{ path: "/known" }], { + budget: 4096, + }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisResourceConstraintError" }, + }); +}); diff --git a/src/domain/javascript/staticObservedEndpoints.ts b/src/domain/javascript/staticObservedEndpoints.ts new file mode 100644 index 0000000000..ab8fa0810b --- /dev/null +++ b/src/domain/javascript/staticObservedEndpoints.ts @@ -0,0 +1,355 @@ +import { digestCanonicalValue } from "../canonicalDigest.js"; +import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js"; +import { parseRecordedTransportUrl } from "../recordedTransportUrl.js"; +import { err, ok, type Result } from "../result.js"; +import type { AnalysisError } from "../analysisErrorBase.js"; +import type { JavaScriptApplicationGraph } from "./javascriptApplicationGraph.js"; +import { + WebNetworkApiSurfaceAccumulator, + type ApiSurfaceComparisonGroup, +} from "../analysisView/WebNetworkApiSurfaceAccumulator.js"; +import { + ENTRY_BYTES, + GROUP_BYTES, + scanRouteTemplate, + textBytes, +} from "../analysisView/webNetworkRouteTemplates.js"; +import type { StaticObservedEndpointComparison } from "./staticObservedEndpointSchemas.js"; + +type Endpoint = StaticObservedEndpointComparison["static_endpoints"][number]; +type Group = StaticObservedEndpointComparison["observed_groups"][number]; +type Match = StaticObservedEndpointComparison["matches"][number]; +const identity = (prefix: string, facts: unknown) => + `${prefix}_${digestCanonicalValue(facts)}`; +const routeKey = (origin: string, path: string, rules: string) => + JSON.stringify([origin, path, rules]); + +/** Interpret only method-bearing API spellings; fetch options and XHR methods stay unknown. */ +const methodFor = ( + mechanism: unknown, +): Pick => { + if (typeof mechanism !== "string") + return { method: null, method_basis: "unknown" }; + if (/(?:^|[.:])EventSource$/u.test(mechanism)) + return { method: "GET", method_basis: "api-definition" }; + if (/(?:^|[.:])sendBeacon$/u.test(mechanism)) + return { method: "POST", method_basis: "api-definition" }; + const method = /(?:^|[.:])(get|post|put|patch|delete|head|options)$/u.exec( + mechanism, + )?.[1]; + return method === undefined + ? { method: null, method_basis: "unknown" } + : { method: method.toUpperCase(), method_basis: "callee-spelling" }; +}; + +/** Keep recorded spelling; targets requiring URL normalization stay unknown instead of acquiring a different identity. */ +const targetFor = ( + value: unknown, + origin: string | undefined, +): Pick => { + const unknown = (reason: Endpoint["reason"]) => ({ + origin: null, + origin_basis: "unknown" as const, + path: null, + reason, + }); + if (typeof value !== "string") return unknown("unparseable-target"); + if (value.startsWith("/") && !value.startsWith("//")) { + const path = value.split(/[?#]/u, 1)[0]!; + if (origin === undefined) return { ...unknown("origin-required"), path }; + // Use the same recorded URL owner as HAR, rather than URL.pathname's normalization. + const parsed = parseRecordedTransportUrl(`${origin}${path}`); + if ( + parsed.path !== path || + parsed.origin !== origin || + parsed.credentials !== null + ) + return unknown("unparseable-target"); + if (URL.parse(`${origin}${path}`)?.pathname !== path) + return unknown("normalization-required"); + return { origin, origin_basis: "caller-origin", path, reason: null }; + } + if (!/^https?:\/\//iu.test(value)) + return unknown( + /^[A-Za-z][A-Za-z0-9+.-]*:/u.test(value) + ? "non-http-target" + : "unresolved-target", + ); + const parsed = parseRecordedTransportUrl(value); + if ( + parsed.path === null || + parsed.origin === null || + parsed.credentials !== null + ) + return unknown("unparseable-target"); + if (URL.parse(value)?.pathname !== parsed.path) + return unknown("normalization-required"); + return { + origin: parsed.origin, + origin_basis: "literal-url", + path: parsed.path, + reason: null, + }; +}; + +interface ComparisonInput { + readonly applicationEvidenceId: string; + readonly captureEvidenceId: string; + readonly graph: JavaScriptApplicationGraph; + readonly accumulator: WebNetworkApiSurfaceAccumulator; + readonly origin?: string; + readonly captureComplete: boolean; +} +interface IndexedGroup { + readonly source: ApiSurfaceComparisonGroup; + readonly row: Group; +} +interface ObservedIndex { + readonly rows: Group[]; + readonly routes: Map< + string, + { methods: Map; ambiguous: boolean } + >; + readonly origins: Set; + readonly complete: boolean; +} +type Node = JavaScriptApplicationGraph["nodes"][number]; + +/** Each route owns its methods and ambiguity; a group is never guessed from display text. */ +const indexObservedGroups = ( + input: ComparisonInput, +): Result => { + const { accumulator } = input; + const index: ObservedIndex = { + rows: [], + routes: new Map(), + origins: new Set(), + complete: + input.captureComplete && + accumulator.comparisonCoverage().excluded_records === 0, + }; + for (const source of accumulator.comparisonGroups()) { + const admitted = accumulator.admitDerivedBytes( + GROUP_BYTES + ENTRY_BYTES * source.record_ordinals.length, + ); + if (!admitted.ok) return admitted; + const row: Group = { + group_id: identity("observed_endpoint", [ + input.captureEvidenceId, + source.method, + source.origin, + source.path_template, + source.templated_segments, + ]), + method: source.method, + origin: source.origin, + path_template: source.path_template, + templated_segments: [...source.templated_segments], + record_ordinals: [...source.record_ordinals], + status: "observed-without-static-match", + reason: null, + }; + index.rows.push(row); + index.origins.add(source.origin); + const key = routeKey(source.origin, source.path_template, source.rules); + const bucket = index.routes.get(key); + if (bucket === undefined) + index.routes.set(key, { + methods: new Map([[source.method, { source, row }]]), + ambiguous: false, + }); + else bucket.methods.set(source.method, { source, row }); + } + return ok(index); +}; + +const readStaticEndpoint = ( + input: ComparisonInput, + node: Node, + observation: Node["observations"][number], +): Result => { + const properties = observation.properties; + if (properties.endpoint_kind !== "network") return ok(undefined); + const admitted = input.accumulator.admitDerivedBytes( + GROUP_BYTES + + (typeof properties.value === "string" + ? textBytes(properties.value.length) + : 0), + ); + if (!admitted.ok) return admitted; + const candidate = properties.interpretation === "candidate"; + const partial = properties.resolution === "partial"; + let reason: Endpoint["reason"] = null; + if (candidate) reason = "candidate-endpoint"; + else if (observation.evidence.state !== "observed") + reason = "unconfirmed-static-observation"; + else if (partial) reason = "partial-target"; + return ok({ + endpoint_id: identity("static_endpoint", [ + input.applicationEvidenceId, + node.node_id, + observation.observation_id, + ]), + node_id: node.node_id, + observation_id: observation.observation_id, + ...methodFor(reason === null ? properties.mechanism : undefined), + ...(reason !== null + ? { + origin: null, + origin_basis: "unknown" as const, + path: null, + reason, + } + : targetFor(properties.value, input.origin)), + status: "unknown", + }); +}; + +const matchEndpoint = ( + endpoint: Endpoint, + index: ObservedIndex, + budget: WebNetworkApiSurfaceAccumulator, +): Result => { + if ( + endpoint.reason !== null || + endpoint.path === null || + endpoint.origin === null + ) + return ok(undefined); + let failure: AnalysisError | undefined; + const route = scanRouteTemplate(endpoint.path, (bytes) => { + const admitted = budget.admitDerivedBytes(bytes); + if (!admitted.ok) failure = admitted.error; + return admitted.ok; + }); + if (route === null) { + if (failure === undefined) + throw new Error("Route reservation failed without its owned error"); + return err(failure); + } + const bucket = index.routes.get( + routeKey(endpoint.origin, route.template, route.rules), + ); + if ( + endpoint.method === null && + bucket !== undefined && + bucket.methods.size > 1 + ) { + endpoint.reason = "ambiguous-method"; + bucket.ambiguous = true; + return ok(undefined); + } + const selected = + endpoint.method === null + ? bucket?.methods.values().next().value + : bucket?.methods.get(endpoint.method); + if (selected !== undefined) { + const admitted = budget.admitDerivedBytes(GROUP_BYTES); + if (!admitted.ok) return admitted; + endpoint.status = "matched"; + selected.row.status = "matched"; + const exactOrdinal = selected.source.exactPathOrdinal(endpoint.path); + const basis = + exactOrdinal !== undefined + ? ("exact" as const) + : ("template-compatible" as const); + return ok({ + match_id: identity("endpoint_match", [ + endpoint.endpoint_id, + selected.row.group_id, + basis, + ]), + endpoint_id: endpoint.endpoint_id, + group_id: selected.row.group_id, + basis, + supporting_record_ordinal: + exactOrdinal ?? selected.source.record_ordinals[0]!, + }); + } + if (!index.complete) endpoint.reason = "capture-incomplete"; + else if (!index.origins.has(endpoint.origin)) + endpoint.reason = "origin-not-exercised"; + else { + endpoint.status = "not-observed-in-capture"; + endpoint.reason = "no-observed-match"; + } + return ok(undefined); +}; + +const finalizeObservedGroups = (index: ObservedIndex): void => { + for (const bucket of index.routes.values()) { + if (!bucket.ambiguous) continue; + for (const { row } of bucket.methods.values()) { + if (row.status === "matched") continue; + row.status = "unknown"; + row.reason = "ambiguous-static-method"; + } + } + index.rows.sort((a, b) => compare(a.group_id, b.group_id)); +}; + +const summarize = ( + endpoints: readonly Endpoint[], + groups: readonly Group[], + matchCount: number, +): StaticObservedEndpointComparison["summary"] => { + const summary = { + static_network_endpoint_observations: endpoints.length, + matched: 0, + not_observed_in_capture: 0, + unknown: 0, + observed_groups: groups.length, + observed_without_static_match: 0, + observed_unknown: 0, + matches: matchCount, + }; + for (const endpoint of endpoints) { + if (endpoint.status === "matched") summary.matched++; + else if (endpoint.status === "unknown") summary.unknown++; + else summary.not_observed_in_capture++; + } + for (const group of groups) { + if (group.status === "observed-without-static-match") + summary.observed_without_static_match++; + else if (group.status === "unknown") summary.observed_unknown++; + } + return summary; +}; + +/** Join admitted observations in indexed work; retain one concrete witness per pairing. */ +export const compareStaticObservedEndpoints = ( + input: ComparisonInput, +): Result< + Pick< + StaticObservedEndpointComparison, + "static_endpoints" | "observed_groups" | "matches" | "summary" + >, + AnalysisError +> => { + const indexed = indexObservedGroups(input); + if (!indexed.ok) return indexed; + const index = indexed.value; + const endpoints: Endpoint[] = []; + const matches: Match[] = []; + for (const node of input.graph.nodes) { + if (node.kind !== "endpoint") continue; + for (const observation of node.observations) { + const endpoint = readStaticEndpoint(input, node, observation); + if (!endpoint.ok) return endpoint; + if (endpoint.value === undefined) continue; + endpoints.push(endpoint.value); + const match = matchEndpoint(endpoint.value, index, input.accumulator); + if (!match.ok) return match; + if (match.value !== undefined) matches.push(match.value); + } + } + finalizeObservedGroups(index); + endpoints.sort((a, b) => compare(a.endpoint_id, b.endpoint_id)); + matches.sort((a, b) => compare(a.match_id, b.match_id)); + return ok({ + static_endpoints: endpoints, + observed_groups: index.rows, + matches, + summary: summarize(endpoints, index.rows, matches.length), + }); +}; diff --git a/src/server/registerApplicationTools.ts b/src/server/registerApplicationTools.ts index a68f218579..ae392dc1ca 100644 --- a/src/server/registerApplicationTools.ts +++ b/src/server/registerApplicationTools.ts @@ -3,6 +3,7 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import { registerTraceFeatureTool } from "./registerApplicationTools/traceFeature.js"; import { registerTraceJavaScriptSemanticsTool } from "./registerApplicationTools/traceSemantics.js"; import { registerCompareApplicationVersionsTool } from "./registerApplicationTools/compareVersions.js"; +import { registerCompareEndpointsTool } from "./registerApplicationTools/compareEndpoints.js"; import { registerCompareSourceToBundleTool } from "./registerApplicationTools/compareSourceToBundle.js"; import { registerCompareJavaScriptExportShapesTool } from "./registerApplicationTools/compareExportShapes.js"; import { registerCoverageTools } from "./registerApplicationTools/coverage.js"; @@ -23,6 +24,7 @@ export const registerApplicationTools = ( registerTraceFeatureTool(server, options); registerTraceJavaScriptSemanticsTool(server, options); registerCompareApplicationVersionsTool(server, options); + registerCompareEndpointsTool(server, options); registerCompareSourceToBundleTool(server, options); registerCompareJavaScriptExportShapesTool(server, options); registerReconstructionObligationLedgerTool(server, options); diff --git a/src/server/registerApplicationTools/compareEndpoints.ts b/src/server/registerApplicationTools/compareEndpoints.ts new file mode 100644 index 0000000000..69b4b156de --- /dev/null +++ b/src/server/registerApplicationTools/compareEndpoints.ts @@ -0,0 +1,60 @@ +import type { EvidenceMcpServer } from "../EvidenceMcpServer.js"; +import { runAdmittedToolOperation } from "../admittedToolOperation.js"; +import { requireAnalysisViewRetention } from "../../application/analysisView/AnalysisViewService.js"; +import { compareStaticAndObservedEndpointsEvidence } from "../../application/javascript/StaticObservedEndpointComparisonService.js"; +import { toolContract } from "../../contracts/toolContracts.js"; +import { toolRegistrationOptions } from "../toolRegistrationOptions.js"; +import { logToolExecution } from "../toolLogging.js"; +import { deliverApplicationEvidence } from "./helpers.js"; +import type { ApplicationToolRegistration } from "./types.js"; + +const contract = toolContract("compare_static_and_observed_endpoints"); + +/** Both adapters execute the same authenticated, provider-free comparison. */ +export const registerCompareEndpointsTool = ( + server: EvidenceMcpServer, + options: ApplicationToolRegistration, +): void => { + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + async (input, context) => + runAdmittedToolOperation( + server, + options.withAdmittedAnalysis, + contract.name, + context.mcpReq.signal, + async () => { + if (input.detail === "summary" || input.view !== undefined) { + const retention = requireAnalysisViewRetention( + contract.name, + options, + ); + if (!retention.ok) + return server.delivery.toCallToolResult(retention, contract); + } + const result = await logToolExecution( + options.logger, + contract.name, + () => + compareStaticAndObservedEndpointsEvidence( + input, + options.evidenceById, + { signal: context.mcpReq.signal }, + ), + ); + if (!result.ok) + return server.delivery.toCallToolResult(result, contract); + return deliverApplicationEvidence( + server, + options, + contract, + result.value, + input.view ?? + (input.detail === "summary" ? { kind: "summary" } : undefined), + context.mcpReq.signal, + ); + }, + ), + ); +}; diff --git a/tests/acceptance/applications/staticObservedEndpoints.test.ts b/tests/acceptance/applications/staticObservedEndpoints.test.ts new file mode 100644 index 0000000000..83b458b2e5 --- /dev/null +++ b/tests/acceptance/applications/staticObservedEndpoints.test.ts @@ -0,0 +1,409 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; +import { expect } from "vitest"; +import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { + createEvidence, + parseEvidence, + type Evidence, +} from "../../../src/domain/evidence.js"; +import { + createEvidenceBundle, + evidenceBundleSchema, +} from "../../../src/domain/evidenceBundle.js"; +import { + staticObservedEndpointComparisonSchema, + type StaticObservedEndpointComparison, +} from "../../../src/domain/javascript/staticObservedEndpointSchemas.js"; +import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; +import { webNetworkCaptureSchema } from "../../../src/domain/webNetworkCapture.js"; + +const prepareInputs = async (cli: TestCli) => { + const root = await createTestTempDirectory("rea-static-observed-endpoints-"); + const appPath = join(root, "app"); + await mkdir(appPath); + const fixture = new URL( + "../../fixtures/javascript-endpoint-capture/", + import.meta.url, + ); + const sourcePath = join(appPath, "app.js"); + const source = await readFile(new URL("app.js.txt", fixture), "utf8"); + await writeFile(sourcePath, source); + await writeFile( + join(appPath, "index.html"), + await readFile(new URL("index.html.txt", fixture)), + ); + const harPath = join(root, "capture.har"); + const harBytes = await readFile( + new URL("recording.har.txt", fixture), + "utf8", + ); + expect(createHash("sha256").update(harBytes).digest("hex")).toBe( + "f0e7d2c869c811d4272a7b2916c6531c8c6cc7938336a35575e2a6b7b6346714", + ); + await writeFile(harPath, harBytes); + const run = cli.sharedCliRun?.bind(cli) ?? cli.run.bind(cli); + const analyzed = await run({ + arguments: [ + "analyze-javascript-application", + appPath, + "--artifact-format", + "directory", + "--json", + ], + }); + expect(analyzed.exitCode, analyzed.stderr).toBe(0); + const application = parseEvidence(analyzed.json); + const inspected = await run({ + arguments: ["inspect-web-network-capture", harPath, "har", "--json"], + }); + expect(inspected.exitCode, inspected.stderr).toBe(0); + const capture = parseEvidence(inspected.json); + const input = { application, capture, origin: "http://127.0.0.1:42845" }; + const inputPath = join(root, "comparison-input.json"); + await writeFile(inputPath, JSON.stringify(input)); + + return { + root, + sourcePath, + source, + harPath, + harBytes, + run, + application, + capture, + input, + inputPath, + }; +}; + +type McpCall = Awaited>["call"]; +const assertPagedComparison = async ( + call: McpCall, + report: StaticObservedEndpointComparison, + complete: Evidence, + input: Parameters[1], +) => { + const pageRequest = { + source: { kind: "retained-evidence", evidence_id: complete.evidence_id }, + view: { + kind: "endpoint-comparison", + collection: "static-endpoints", + fields: ["path", "status", "reason"], + offset: 0, + limit: 2, + }, + }; + const first = await call("inspect_analysis_view", pageRequest); + const firstView = analysisViewResultSchema.parse( + parseEvidence(first.structuredContent).normalized_result, + ); + expect(firstView).toMatchObject({ + source_coverage: report.source_coverage, + summary: report.summary, + coverage: { examined: 2, total: 7, exhausted: false, next_offset: 2 }, + }); + if (firstView.kind !== "endpoint-comparison") + throw new Error("Expected endpoint page"); + const inline = await call("compare_static_and_observed_endpoints", { + ...input, + detail: "complete", + view: pageRequest.view, + }); + expect(inline.isError, JSON.stringify(inline.content)).not.toBe(true); + expect(parseEvidence(inline.structuredContent).normalized_result).toEqual( + firstView, + ); + expect(firstView.items).toEqual( + report.static_endpoints + .slice(0, 2) + .map( + ({ endpoint_id, node_id, observation_id, path, status, reason }) => ({ + endpoint_id, + node_id, + observation_id, + path, + status, + reason, + }), + ), + ); + const empty = await call("inspect_analysis_view", { + ...pageRequest, + view: { ...pageRequest.view, statuses: [], fields: [] }, + }); + expect( + parseEvidence(empty.structuredContent).normalized_result, + ).toMatchObject({ + items: [], + coverage: { total: 0, exhausted: true }, + summary: report.summary, + }); + const matchPage = await call("inspect_analysis_view", { + source: pageRequest.source, + view: { kind: "endpoint-comparison", collection: "matches", fields: [] }, + }); + expect( + parseEvidence(matchPage.structuredContent).normalized_result, + ).toMatchObject({ + items: report.matches.map(({ match_id, endpoint_id, group_id }) => ({ + match_id, + endpoint_id, + group_id, + })), + }); + + return { firstView, pageRequest }; +}; + +const assertCliPublication = async ( + context: Awaited>, + comparison: { + readonly complete: Evidence; + readonly summary: Awaited>; + readonly views: Awaited>; + }, +) => { + const { root, run, inputPath, application, capture } = context; + const { + complete, + summary, + views: { firstView, pageRequest }, + } = comparison; + const bundlePath = join(root, "comparison.bundle.json"); + const cliSummary = await run({ + arguments: [ + "compare-static-and-observed-endpoints", + inputPath, + "--detail", + "summary", + "--evidence_output", + bundlePath, + "--json", + ], + }); + expect(cliSummary.exitCode, cliSummary.stderr).toBe(0); + expect(cliSummary.json).toMatchObject({ + summary: summary.structuredContent, + }); + const bundle = evidenceBundleSchema.parse( + JSON.parse(await readFile(bundlePath, "utf8")), + ); + expect(new Set(bundle.records.map(({ evidence_id }) => evidence_id))).toEqual( + new Set([ + application.evidence_id, + capture.evidence_id, + complete.evidence_id, + ]), + ); + const fileViewPath = join(root, "page-input.json"); + await writeFile( + fileViewPath, + JSON.stringify({ + ...pageRequest, + source: { + kind: "evidence-file", + path: bundlePath, + evidence_id: complete.evidence_id, + }, + }), + ); + const cliPage = await run({ + arguments: ["inspect-analysis-view", fileViewPath, "--json"], + }); + expect(cliPage.exitCode, cliPage.stderr).toBe(0); + expect(parseEvidence(cliPage.json).normalized_result).toEqual(firstView); + const inlineCli = await run({ + arguments: [ + "compare-static-and-observed-endpoints", + inputPath, + "--detail", + "summary", + "--view", + JSON.stringify(pageRequest.view), + "--evidence_output", + join(root, "selected.bundle.json"), + "--json", + ], + }); + expect(inlineCli.exitCode, inlineCli.stderr).toBe(0); + expect(inlineCli.json).toMatchObject({ + summary: { normalized_result: firstView }, + }); + const invalidView = await run({ + arguments: [ + "compare-static-and-observed-endpoints", + inputPath, + "--view", + JSON.stringify(pageRequest.view), + "--json", + ], + }); + expect(invalidView.exitCode).not.toBe(0); + expect(invalidView.json).toMatchObject({ code: "invalid_request" }); + const missingSink = await run({ + arguments: [ + "compare-static-and-observed-endpoints", + inputPath, + "--detail", + "summary", + "--json", + ], + }); + expect(missingSink.exitCode).not.toBe(0); + expect(missingSink.json).toMatchObject({ code: "invalid_request" }); +}; + +cliTest( + "compares real source and decoded HAR through CLI/MCP, retains complete facts and pages stable identities", + async ({ cli }) => { + const context = await prepareInputs(cli); + const { + root, + sourcePath, + source, + harPath, + harBytes, + run, + application, + capture, + input, + inputPath, + } = context; + const compared = await run({ + arguments: ["compare-static-and-observed-endpoints", inputPath, "--json"], + }); + expect(compared.exitCode, compared.stderr).toBe(0); + const complete = parseEvidence(compared.json); + const report = staticObservedEndpointComparisonSchema.parse( + complete.normalized_result, + ); + expect(report.summary).toMatchObject({ + static_network_endpoint_observations: 7, + matched: 3, + not_observed_in_capture: 1, + unknown: 3, + observed_without_static_match: 3, + }); + expect(new Set(report.matches.map(({ basis }) => basis))).toEqual( + new Set(["exact", "template-compatible"]), + ); + expect(complete.evidence_links).toEqual( + [application.evidence_id, capture.evidence_id].sort(), + ); + expect(JSON.stringify(report)).not.toMatch(/kept-source-only|fragment|KEY/); + const { client, call } = await connectLocalToolsMcp({ + REA_MCP_INPUT_SCHEMA_PROFILE: "full", + REA_MCP_TOOL_FAMILIES: "application,session", + }); + const schemas = await advertisedMcpToolSchemas( + client, + "compare_static_and_observed_endpoints", + ); + expect(schemas.ajv.validate(schemas.inputSchema, input)).toBe(true); + expect( + schemas.ajv.validate(schemas.inputSchema, { ...input, detail: "wrong" }), + ).toBe(false); + const retained = { + application: { + kind: "retained-evidence", + evidence_id: application.evidence_id, + }, + capture: { kind: "retained-evidence", evidence_id: capture.evidence_id }, + origin: input.origin, + }; + let bundleNumber = 0; + const importRecords = async ( + records: Parameters[0], + ) => { + const path = join(root, `sources-${bundleNumber++}.bundle.json`); + await writeFile(path, JSON.stringify(createEvidenceBundle(records))); + const response = await call("import_evidence_bundle", { path }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + }; + await importRecords([application, capture]); + const fullMcp = await call("compare_static_and_observed_endpoints", { + ...retained, + detail: "complete", + }); + expect(fullMcp.isError, JSON.stringify(fullMcp.content)).not.toBe(true); + expect(parseEvidence(fullMcp.structuredContent)).toEqual(complete); + const summary = await call( + "compare_static_and_observed_endpoints", + retained, + ); + expect(summary.isError).not.toBe(true); + const summaryView = analysisViewResultSchema.parse( + parseEvidence(summary.structuredContent).normalized_result, + ); + expect(summaryView).toMatchObject({ + kind: "summary", + parent_evidence_id: complete.evidence_id, + summary: { + summary: report.summary, + source_coverage: report.source_coverage, + }, + }); + expect(summaryView).not.toHaveProperty("static_endpoints"); + + const views = await assertPagedComparison(call, report, complete, retained); + await assertCliPublication(context, { complete, summary, views }); + const tampered = await call("compare_static_and_observed_endpoints", { + ...retained, + capture: { ...capture, normalized_result: {} }, + }); + expect(parseMcpToolError(tampered).error).toMatchObject({ + code: "evidence_integrity_mismatch", + }); + const sourceCapture = webNetworkCaptureSchema.parse( + capture.normalized_result, + ); + const replacement = (parameters: object, result: unknown) => + createEvidence(undefined, capture.provider, { + operation: capture.operation, + parameters, + result, + confidence: "observed", + }); + const declared = replacement( + { ...capture.parameters, sensitive_value_count: 1 }, + sourceCapture, + ); + await importRecords([declared]); + const refused = await call("compare_static_and_observed_endpoints", { + ...retained, + capture: { kind: "retained-evidence", evidence_id: declared.evidence_id }, + }); + expect(parseMcpToolError(refused).error).toMatchObject({ + code: "unsupported_target", + }); + expect(JSON.stringify(refused)).toContain("sensitive_values_unretained"); + const subset = replacement(capture.parameters, { + ...sourceCapture, + records: sourceCapture.records.slice(0, 1), + }); + await importRecords([subset]); + const limited = await call("compare_static_and_observed_endpoints", { + ...retained, + capture: { kind: "retained-evidence", evidence_id: subset.evidence_id }, + detail: "complete", + }); + expect(limited.isError).not.toBe(true); + expect( + staticObservedEndpointComparisonSchema + .parse(parseEvidence(limited.structuredContent).normalized_result) + .static_endpoints.find(({ path }) => path === "/api/admin/export"), + ).toMatchObject({ status: "unknown", reason: "capture-incomplete" }); + expect(await readFile(sourcePath, "utf8")).toBe(source); + expect( + createHash("sha256") + .update(await readFile(harPath)) + .digest("hex"), + ).toBe(createHash("sha256").update(harBytes).digest("hex")); + }, +); diff --git a/tests/fixtures/javascript-endpoint-capture/README.md b/tests/fixtures/javascript-endpoint-capture/README.md new file mode 100644 index 0000000000..e68ab14d85 --- /dev/null +++ b/tests/fixtures/javascript-endpoint-capture/README.md @@ -0,0 +1,21 @@ +# Static endpoint and capture fixture + +This source-owned loopback application was recorded on Linux with Playwright +1.64.1 and Chromium headless shell 153.0.8010.12. `recording.har.txt` is the original +HAR 1.2 from `browser.newContext({recordHar: {mode: "full", content: "embed"}})`; +its SHA-256 is `f0e7d2c869c811d4272a7b2916c6531c8c6cc7938336a35575e2a6b7b6346714`. +The `.txt` suffix preserves the recorder bytes without JSON formatting; the test +copies it to `capture.har` before production decoding. +The recorded origin is `http://127.0.0.1:42845`. Do not reserialize the HAR. + +The server returned the two adjacent text files as `/app.js` and `/`, and JSON +for the API paths. It streamed each response in two chunks 40 ms apart. The +browser awaited all three fetch response bodies before context close. The five +recorded requests are `/`, `/app.js`, `/api/users?ordinary=kept-source-only`, +`/users/123` and `/observed-only`. The script's unused function bodies were +never called. Transport authentication headers and cookies are absent. + +The public acceptance test copies these sources into a temporary application +and runs the production static analyzer, HAR decoder, comparison and retained +views through CLI/MCP. It replays this recording offline and needs no browser +or network. The recording proves only this small application's selected run. diff --git a/tests/fixtures/javascript-endpoint-capture/app.js.txt b/tests/fixtures/javascript-endpoint-capture/app.js.txt new file mode 100644 index 0000000000..69d455e030 --- /dev/null +++ b/tests/fixtures/javascript-endpoint-capture/app.js.txt @@ -0,0 +1,6 @@ + +function unusedAdmin() { return fetch('/api/admin/export'); } +function templateCandidate() { return fetch('/users/789'); } +function neverContacted() { return fetch('https://never-contact.invalid/route'); } +function translatedValue() { return translate.get('KEY'); } +Promise.all([fetch('/api/users?ordinary=kept-source-only#fragment'), fetch('/users/123'), fetch(window.dynamicTarget)].map(request => request.then(response => response.text()))).then(() => window.done = true); diff --git a/tests/fixtures/javascript-endpoint-capture/index.html.txt b/tests/fixtures/javascript-endpoint-capture/index.html.txt new file mode 100644 index 0000000000..d1f5160a22 --- /dev/null +++ b/tests/fixtures/javascript-endpoint-capture/index.html.txt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/fixtures/javascript-endpoint-capture/recording.har.txt b/tests/fixtures/javascript-endpoint-capture/recording.har.txt new file mode 100644 index 0000000000..9d64ce498d --- /dev/null +++ b/tests/fixtures/javascript-endpoint-capture/recording.har.txt @@ -0,0 +1 @@ +{"log":{"version":"1.2","creator":{"name":"Playwright","version":"1.64.1"},"browser":{"name":"chromium","version":"153.0.8010.12"},"pages":[{"startedDateTime":"2026-11-06T03:22:56.796Z","id":"page@07d055c0281abfb821900dbff3f19b1f","title":"","pageTimings":{"onContentLoad":98,"onLoad":98}}],"entries":[{"pageref":"page@07d055c0281abfb821900dbff3f19b1f","startedDateTime":"2026-11-06T03:22:56.799Z","time":48.571000000000005,"request":{"method":"GET","url":"http://127.0.0.1:42845/","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Accept","value":"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"},{"name":"Accept-Encoding","value":"gzip, deflate, br, zstd"},{"name":"Connection","value":"keep-alive"},{"name":"Host","value":"127.0.0.1:42845"},{"name":"Sec-Fetch-Dest","value":"document"},{"name":"Sec-Fetch-Mode","value":"navigate"},{"name":"Sec-Fetch-Site","value":"none"},{"name":"Sec-Fetch-User","value":"?1"},{"name":"Upgrade-Insecure-Requests","value":"1"},{"name":"User-Agent","value":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36"},{"name":"sec-ch-ua","value":"\"HeadlessChrome\";v=\"153\", \"Not_A Brand\";v=\"8\", \"Chromium\";v=\"153\""},{"name":"sec-ch-ua-mobile","value":"?0"},{"name":"sec-ch-ua-platform","value":"\"Linux\""}],"queryString":[],"headersSize":630,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Connection","value":"keep-alive"},{"name":"Content-Type","value":"text/html"},{"name":"Date","value":"Fri, 06 Nov 2026 03:22:56 GMT"},{"name":"Keep-Alive","value":"timeout=5"},{"name":"Transfer-Encoding","value":"chunked"}],"content":{"size":85,"mimeType":"text/html","compression":0,"text":""},"headersSize":156,"bodySize":102,"redirectURL":"","_transferSize":258},"cache":{},"timings":{"dns":0.063,"connect":0.148,"ssl":1.43,"send":0,"wait":6.175,"receive":40.755},"_frameref":"frame@2eea7869e2d6761b00a3030963155215","_monotonicTime":589.594,"_resourceType":"document","serverIPAddress":"127.0.0.1","_serverPort":42845,"_securityDetails":{}},{"pageref":"page@07d055c0281abfb821900dbff3f19b1f","startedDateTime":"2026-11-06T03:22:56.848Z","time":44.629999999999995,"request":{"method":"GET","url":"http://127.0.0.1:42845/app.js","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Accept","value":"*/*"},{"name":"Accept-Encoding","value":"gzip, deflate, br, zstd"},{"name":"Connection","value":"keep-alive"},{"name":"Host","value":"127.0.0.1:42845"},{"name":"Referer","value":"http://127.0.0.1:42845/"},{"name":"Sec-Fetch-Dest","value":"script"},{"name":"Sec-Fetch-Mode","value":"no-cors"},{"name":"Sec-Fetch-Site","value":"same-origin"},{"name":"User-Agent","value":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36"},{"name":"sec-ch-ua","value":"\"HeadlessChrome\";v=\"153\", \"Not_A Brand\";v=\"8\", \"Chromium\";v=\"153\""},{"name":"sec-ch-ua-mobile","value":"?0"},{"name":"sec-ch-ua-platform","value":"\"Linux\""}],"queryString":[],"headersSize":492,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Connection","value":"keep-alive"},{"name":"Content-Type","value":"text/javascript"},{"name":"Date","value":"Fri, 06 Nov 2026 03:22:56 GMT"},{"name":"Keep-Alive","value":"timeout=5"},{"name":"Transfer-Encoding","value":"chunked"}],"content":{"size":477,"mimeType":"text/javascript","compression":0,"text":"\nfunction unusedAdmin() { return fetch('/api/admin/export'); }\nfunction templateCandidate() { return fetch('/users/789'); }\nfunction neverContacted() { return fetch('https://never-contact.invalid/route'); }\nfunction translatedValue() { return translate.get('KEY'); }\nPromise.all([fetch('/api/users?ordinary=kept-source-only#fragment'), fetch('/users/123'), fetch(window.dynamicTarget)].map(request => request.then(response => response.text()))).then(() => window.done = true);\n"},"headersSize":162,"bodySize":494,"redirectURL":"","_transferSize":656},"cache":{},"timings":{"dns":-1,"connect":-1,"ssl":-1,"send":0,"wait":0.937,"receive":43.693},"_frameref":"frame@2eea7869e2d6761b00a3030963155215","_monotonicTime":638.947,"_resourceType":"script","serverIPAddress":"127.0.0.1","_serverPort":42845,"_securityDetails":{}},{"pageref":"page@07d055c0281abfb821900dbff3f19b1f","startedDateTime":"2026-11-06T03:22:56.894Z","time":44.669000000000004,"request":{"method":"GET","url":"http://127.0.0.1:42845/api/users?ordinary=kept-source-only","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Accept","value":"*/*"},{"name":"Accept-Encoding","value":"gzip, deflate, br, zstd"},{"name":"Connection","value":"keep-alive"},{"name":"Host","value":"127.0.0.1:42845"},{"name":"Referer","value":"http://127.0.0.1:42845/"},{"name":"Sec-Fetch-Dest","value":"empty"},{"name":"Sec-Fetch-Mode","value":"cors"},{"name":"Sec-Fetch-Site","value":"same-origin"},{"name":"User-Agent","value":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36"},{"name":"sec-ch-ua","value":"\"HeadlessChrome\";v=\"153\", \"Not_A Brand\";v=\"8\", \"Chromium\";v=\"153\""},{"name":"sec-ch-ua-mobile","value":"?0"},{"name":"sec-ch-ua-platform","value":"\"Linux\""}],"queryString":[{"name":"ordinary","value":"kept-source-only"}],"headersSize":491,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Connection","value":"keep-alive"},{"name":"Content-Type","value":"application/json"},{"name":"Date","value":"Fri, 06 Nov 2026 03:22:56 GMT"},{"name":"Keep-Alive","value":"timeout=5"},{"name":"Transfer-Encoding","value":"chunked"}],"content":{"size":11,"mimeType":"application/json","compression":0,"text":"{\"ok\":true}"},"headersSize":163,"bodySize":26,"redirectURL":"","_transferSize":189},"cache":{},"timings":{"dns":-1,"connect":-1,"ssl":-1,"send":0,"wait":3.066,"receive":41.603},"_frameref":"frame@2eea7869e2d6761b00a3030963155215","_monotonicTime":680.638,"_resourceType":"fetch","serverIPAddress":"127.0.0.1","_serverPort":42845,"_securityDetails":{}},{"pageref":"page@07d055c0281abfb821900dbff3f19b1f","startedDateTime":"2026-11-06T03:22:56.894Z","time":48.574,"request":{"method":"GET","url":"http://127.0.0.1:42845/users/123","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Accept","value":"*/*"},{"name":"Accept-Encoding","value":"gzip, deflate, br, zstd"},{"name":"Connection","value":"keep-alive"},{"name":"Host","value":"127.0.0.1:42845"},{"name":"Referer","value":"http://127.0.0.1:42845/"},{"name":"Sec-Fetch-Dest","value":"empty"},{"name":"Sec-Fetch-Mode","value":"cors"},{"name":"Sec-Fetch-Site","value":"same-origin"},{"name":"User-Agent","value":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36"},{"name":"sec-ch-ua","value":"\"HeadlessChrome\";v=\"153\", \"Not_A Brand\";v=\"8\", \"Chromium\";v=\"153\""},{"name":"sec-ch-ua-mobile","value":"?0"},{"name":"sec-ch-ua-platform","value":"\"Linux\""}],"queryString":[],"headersSize":491,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Connection","value":"keep-alive"},{"name":"Content-Type","value":"application/json"},{"name":"Date","value":"Fri, 06 Nov 2026 03:22:56 GMT"},{"name":"Keep-Alive","value":"timeout=5"},{"name":"Transfer-Encoding","value":"chunked"}],"content":{"size":11,"mimeType":"application/json","compression":0,"text":"{\"ok\":true}"},"headersSize":163,"bodySize":26,"redirectURL":"","_transferSize":189},"cache":{},"timings":{"dns":0.007,"connect":0.494,"ssl":1.603,"send":0,"wait":4.798,"receive":41.672},"_frameref":"frame@2eea7869e2d6761b00a3030963155215","_monotonicTime":682.181,"_resourceType":"fetch","serverIPAddress":"127.0.0.1","_serverPort":42845,"_securityDetails":{}},{"pageref":"page@07d055c0281abfb821900dbff3f19b1f","startedDateTime":"2026-11-06T03:22:56.894Z","time":51.590999999999994,"request":{"method":"GET","url":"http://127.0.0.1:42845/observed-only","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Accept","value":"*/*"},{"name":"Accept-Encoding","value":"gzip, deflate, br, zstd"},{"name":"Connection","value":"keep-alive"},{"name":"Host","value":"127.0.0.1:42845"},{"name":"Referer","value":"http://127.0.0.1:42845/"},{"name":"Sec-Fetch-Dest","value":"empty"},{"name":"Sec-Fetch-Mode","value":"cors"},{"name":"Sec-Fetch-Site","value":"same-origin"},{"name":"User-Agent","value":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36"},{"name":"sec-ch-ua","value":"\"HeadlessChrome\";v=\"153\", \"Not_A Brand\";v=\"8\", \"Chromium\";v=\"153\""},{"name":"sec-ch-ua-mobile","value":"?0"},{"name":"sec-ch-ua-platform","value":"\"Linux\""}],"queryString":[],"headersSize":495,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Connection","value":"keep-alive"},{"name":"Content-Type","value":"application/json"},{"name":"Date","value":"Fri, 06 Nov 2026 03:22:56 GMT"},{"name":"Keep-Alive","value":"timeout=5"},{"name":"Transfer-Encoding","value":"chunked"}],"content":{"size":11,"mimeType":"application/json","compression":0,"text":"{\"ok\":true}"},"headersSize":163,"bodySize":26,"redirectURL":"","_transferSize":189},"cache":{},"timings":{"dns":0.004,"connect":0.202,"ssl":1.286,"send":0,"wait":7.949,"receive":42.15},"_frameref":"frame@2eea7869e2d6761b00a3030963155215","_monotonicTime":682.414,"_resourceType":"fetch","serverIPAddress":"127.0.0.1","_serverPort":42845,"_securityDetails":{}}]}} \ No newline at end of file From c467cf868583e378d8d8ce9dfffe25bce0f25376 Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:48:06 +0800 Subject: [PATCH 014/151] fix(mcp): clarify discovery bounds and isolate catalog evals (#9101) State the existing discovery page and exact-name bounds using the validation constants. Isolate account apps and plugins in catalog trials by default, with an explicit opt-in and one shared feature policy for config and receipts. --- docs/mcp-catalog-evaluation.md | 6 ++++++ src/catalogDiscovery.ts | 16 ++++++++++++---- .../support/analysis-efficiency/agent-trial.py | 17 ++++++++++++----- .../analysis-efficiency/test_codex_context.py | 18 ++++++++++++++---- 4 files changed, 44 insertions(+), 13 deletions(-) diff --git a/docs/mcp-catalog-evaluation.md b/docs/mcp-catalog-evaluation.md index eff14206d8..1e2713a8f1 100644 --- a/docs/mcp-catalog-evaluation.md +++ b/docs/mcp-catalog-evaluation.md @@ -39,6 +39,12 @@ and a 192-task ceiling. Runs are sequential. Unsupported clients, resource stops failed startup, missing usage, and unverified cleanup remain failed measurements. They do not become zero-cost successes. +Trials disable account apps and plugins explicitly. A fresh `CODEX_HOME` alone +does not prevent account-connected tools from appearing in the client executor +catalog. For a deliberate mixed-catalog investigation, run `agent-trial.py` with +`--include-account-tools`; keep those results separate from REA-only trials. +The settings receipt records the same feature policy written to Codex config. + Each repetition compares no server, all REA tools, and a client-selected subset (`binary_session` by default), in the supported client-code host mode. Alternate repetitions reverse ordering. All variants receive the same developer instructions diff --git a/src/catalogDiscovery.ts b/src/catalogDiscovery.ts index 1b4b31a64e..6a5724f4ca 100644 --- a/src/catalogDiscovery.ts +++ b/src/catalogDiscovery.ts @@ -12,7 +12,13 @@ export const HOT_TOOL_NAMES: ReadonlySet = new Set([ // One page activates at most 20 schemas: callers can page a broad search instead // of inadvertently expanding the entire catalog into their client's context. -const pageSize = z.int().min(1).max(20).default(3); +const MAX_DISCOVERY_TOOLS = 20; +const DEFAULT_DISCOVERY_PAGE_SIZE = 3; +const pageSize = z + .int() + .min(1) + .max(MAX_DISCOVERY_TOOLS) + .default(DEFAULT_DISCOVERY_PAGE_SIZE); const inputSchema = z .strictObject({ query: z @@ -25,12 +31,14 @@ const inputSchema = z names: z .array(z.string().min(1)) .min(1) - .max(20) + .max(MAX_DISCOVERY_TOOLS) .optional() - .describe("Exact tool names to load instead of searching."), + .describe( + `At most ${MAX_DISCOVERY_TOOLS} exact tool names to load instead of searching.`, + ), offset: z.int().min(0).default(0), limit: pageSize.describe( - "Search page size; does not apply to exact names.", + `Search page size 1..${MAX_DISCOVERY_TOOLS} (default ${DEFAULT_DISCOVERY_PAGE_SIZE}); does not apply to exact names.`, ), }) .refine( diff --git a/tests/support/analysis-efficiency/agent-trial.py b/tests/support/analysis-efficiency/agent-trial.py index f5db80f9b9..8308c95c93 100644 --- a/tests/support/analysis-efficiency/agent-trial.py +++ b/tests/support/analysis-efficiency/agent-trial.py @@ -37,6 +37,8 @@ def main(): parser.add_argument('--profile-parent', type=Path, help='External disposable-profile parent (default: cache beside the source Codex home)') parser.add_argument('--authority', choices=['static', 'browser'], default='static') + parser.add_argument('--include-account-tools', action='store_true', + help='Allow account apps/plugins; default isolates REA from their catalogs') parser.add_argument('--capture-tool-context', action='store_true', help='Retain owned tool/usage items; excludes account/ambient state') parser.add_argument('--guide-enabled-tools', action='store_true', @@ -159,14 +161,20 @@ def main(): 'or delegate. Reply with exactly the text requested by the user.' ) quote = json.dumps + # A disposable CODEX_HOME does not disable account-connected apps. Keep + # the requested feature policy identical in the config and its receipt. + features = { + 'multi_agent': False, 'shell_snapshot': False, 'shell_tool': False, + 'unified_exec': False, 'code_mode_host': args.mode == 'client-code', + 'apps': args.include_account_tools, 'plugins': args.include_account_tools, + } config = '\n'.join([ 'approval_policy = "never"', 'sandbox_mode = "read-only"', 'web_search = "disabled"', 'model = ' + quote(args.model), 'model_reasoning_effort = ' + quote(args.reasoning), 'developer_instructions = ' + quote(instruction), - '[features]', 'multi_agent = false', 'shell_snapshot = false', - 'shell_tool = false', 'unified_exec = false', - 'code_mode_host = ' + ('true' if args.mode == 'client-code' else 'false'), + '[features]', + *[name + ' = ' + str(enabled).lower() for name, enabled in features.items()], '[history]', 'persistence = "none"', ]) + '\n' if not args.no_mcp: @@ -253,8 +261,7 @@ def main(): provider_env.get('PATH', os.environ.get('PATH', '')).encode()).hexdigest(), 'provider_environment_policy_sha256': hashlib.sha256( provider_policy_text.encode()).hexdigest(), - 'features': {'shell_tool': False, 'unified_exec': False, - 'multi_agent': False, 'code_mode_host': args.mode == 'client-code'}, + 'features': features, }, indent=2) + '\n') invocation = [args.codex, '--no-daemon', 'exec', '--strict-config', *([] if args.capture_tool_context else ['--ephemeral']), diff --git a/tests/support/analysis-efficiency/test_codex_context.py b/tests/support/analysis-efficiency/test_codex_context.py index 6b5c1350e7..2ebc6fb2a2 100644 --- a/tests/support/analysis-efficiency/test_codex_context.py +++ b/tests/support/analysis-efficiency/test_codex_context.py @@ -681,9 +681,12 @@ class ContextCaptureTests(unittest.TestCase): spec = importlib.util.spec_from_file_location('agent_trial', Path(__file__).with_name('agent-trial.py')) agent = importlib.util.module_from_spec(spec) spec.loader.exec_module(agent) - for control, drift, mode in [(False, False, 'direct'), (True, False, 'direct'), - (False, True, 'direct'), (False, False, 'client-code')]: - with self.subTest(control=control, drift=drift, mode=mode), tempfile.TemporaryDirectory() as directory: + for control, drift, mode, account_tools in [ + (False, False, 'direct', False), (True, False, 'direct', False), + (False, True, 'direct', False), (False, False, 'client-code', False), + (False, False, 'client-code', True), (True, False, 'client-code', True), + ]: + with self.subTest(control=control, drift=drift, mode=mode, account_tools=account_tools), tempfile.TemporaryDirectory() as directory: root = Path(directory) runtime = root / 'runtime' (runtime / 'scripts').mkdir(parents=True) @@ -696,7 +699,8 @@ class ContextCaptureTests(unittest.TestCase): output = runtime / '.cache/trial' arguments = ['agent-trial.py', '--runtime-root', str(runtime), '--revision', 'fixture', '--prompt', str(question), '--output', str(output), '--catalog-load', '--mode', mode, - *(['--no-mcp'] if control else [])] + *(['--no-mcp'] if control else []), + *(['--include-account-tools'] if account_tools else [])] observed = {} def launch(_invocation, **options): @@ -723,6 +727,12 @@ class ContextCaptureTests(unittest.TestCase): self.assertEqual('required = true' in observed['config'], not control) self.assertNotIn('Use the registered REA MCP server', observed['config']) self.assertIn('code_mode_host = ' + ('true' if mode == 'client-code' else 'false'), observed['config']) + settings = json.loads((output / 'trial-settings.json').read_text()) + for feature in ['apps', 'plugins']: + self.assertIn(feature + ' = ' + str(account_tools).lower(), observed['config']) + self.assertIs(settings['features'][feature], account_tools) + for feature, enabled in settings['features'].items(): + self.assertIn(feature + ' = ' + str(enabled).lower(), observed['config']) self.assertNotIn('code_mode =', observed['config']) self.assertNotIn('code_mode_only =', observed['config']) self.assertTrue(json.loads((output / 'trial-settings.json').read_text())['workspace_isolated_from_runtime']) From 8580f1430c76ad62e8e3c5a368e5d7ef26d50ffd Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:07:01 +0800 Subject: [PATCH 015/151] fix(desktop): launch spaced REA archives and Electron apps (#9085) * fix(desktop): launch spaced REA archives and Electron apps Link the unpacked REA entrypoint to a fixed path so a top folder with spaces is not split by the generated rea launcher. When a build request marks the app as Electron, rewrite its installed desktop command with the export path's launch policy before gtk-launch. * fix(desktop): escape rewritten Exec values for desktop files Command quoting alone left literal backslashes one escape short of the desktop-entry value layer, so gio rejected the rewritten entry. Escape the command with the same rules parseDesktopEntry undoes before writing the Exec line. --------- Co-authored-by: Cursor Agent --- docs/desktop-build-computer.md | 7 + .../build-computer/spec.md | 5 +- src/contracts/desktopBuildToolContracts.ts | 2 +- src/domain/desktop/appBuild.test.ts | 153 ++++++++++++++++++ src/domain/desktop/appBuild.ts | 64 +++++++- src/domain/desktop/appExportInstall.test.ts | 70 ++++++++ src/domain/desktop/appExportInstall.ts | 36 ++++- src/domain/desktop/desktopEntry.test.ts | 50 ++++++ src/domain/desktop/desktopEntry.ts | 89 ++++++++++ 9 files changed, 472 insertions(+), 4 deletions(-) diff --git a/docs/desktop-build-computer.md b/docs/desktop-build-computer.md index e5be5c362c..8096dc163f 100644 --- a/docs/desktop-build-computer.md +++ b/docs/desktop-build-computer.md @@ -40,6 +40,13 @@ no data hooks. The app is not open when the computer starts: agentenv lists it in the desktop's Applications menu under Other by the environment's name, such as `mousepad`, and that entry launches it through the app's server. +When the app is Electron, set `electron` to true. The install rewrites that +desktop entry's command with the same policy an export uses: `--no-sandbox` +immediately after the program, unless the command already has it. The +computer's container does not grant the user namespaces Chromium's sandbox +needs, and Electron exits without the flag. With the rewritten command, the +Applications menu entry can open the app. + ```json { "source": { diff --git a/docs/specs/desktop-app-environments/build-computer/spec.md b/docs/specs/desktop-app-environments/build-computer/spec.md index 6c0854061f..6e1f74db4e 100644 --- a/docs/specs/desktop-app-environments/build-computer/spec.md +++ b/docs/specs/desktop-app-environments/build-computer/spec.md @@ -42,7 +42,10 @@ it for building, or from an existing bundle folder. repository with its key file, or a `.deb` URL with its SHA-256, dependencies and architecture, rendered with the export's install shapes and Python helpers; `Launch(at="demand")` through `gtk-launch`; no seed and no data - hooks; REA in a build-only layer, with Node.js 24.22.1 downloaded per + hooks. When the request sets `electron`, the install rewrites that desktop + entry's command with the export's launch policy (`--no-sandbox` right after + the program, unless the command already has it) before `gtk-launch`; REA in + a build-only layer, with Node.js 24.22.1 downloaded per architecture and checked, and REA's payload a parameter: a local archive shipped in the environment folder (`REA_BUILD_COMPUTER_REA_ARCHIVE`) or, later, a released download. diff --git a/src/contracts/desktopBuildToolContracts.ts b/src/contracts/desktopBuildToolContracts.ts index b50e3609d1..eec9665ae8 100644 --- a/src/contracts/desktopBuildToolContracts.ts +++ b/src/contracts/desktopBuildToolContracts.ts @@ -25,7 +25,7 @@ export const DESKTOP_BUILD_TOOL_CONTRACTS = [ ...toolContractMetadata("start_build_computer"), kind: "application", description: - "Start a local agentenv computer to build a desktop app environment on, and return its id with a first screenshot as an image. From a request, REA renders a bundle that installs the app from the Ubuntu archive, an apt repository or a .deb URL, opens it on demand from its desktop entry (it is not open at first: open it from the desktop's Applications menu, under Other, by the environment's name), and installs REA itself in a build-only layer from REA_BUILD_COMPUTER_REA_ARCHIVE; from a bundle folder, such as an exported one, it starts that bundle's smoke task as it is. Needs Docker and the agentenv runner named by REA_AGENT_ENV_COMMAND; the first start of a bundle builds its image, which downloads packages and can take minutes. screenshot sets the first screenshot's region, format and quality. The computer stays up, reachable on this host, until close_build_computer or the server closes; when a start fails before its teardown is confirmed, the error's computer_id names what close_build_computer retries.", + "Start a local agentenv computer to build a desktop app environment on, and return its id with a first screenshot as an image. From a request, REA renders a bundle that installs the app from the Ubuntu archive, an apt repository or a .deb URL, opens it on demand from its desktop entry (it is not open at first: open it from the desktop's Applications menu, under Other, by the environment's name), and installs REA itself in a build-only layer from REA_BUILD_COMPUTER_REA_ARCHIVE. Set source.electron for an Electron app: the install rewrites that desktop entry's command with the same launch policy an export uses, adding --no-sandbox right after the program unless the command already has it, because the container does not grant the user namespaces Chromium needs and Electron exits without the flag. From a bundle folder, such as an exported one, it starts that bundle's smoke task as it is. Needs Docker and the agentenv runner named by REA_AGENT_ENV_COMMAND; the first start of a bundle builds its image, which downloads packages and can take minutes. screenshot sets the first screenshot's region, format and quality. The computer stays up, reachable on this host, until close_build_computer or the server closes; when a start fails before its teardown is confirmed, the error's computer_id names what close_build_computer retries.", inputSchema: z.strictObject({ source: buildSourceSchema, screenshot: screenshotOptionsSchema.optional(), diff --git a/src/domain/desktop/appBuild.test.ts b/src/domain/desktop/appBuild.test.ts index 2db90e7cd1..7c002b22cd 100644 --- a/src/domain/desktop/appBuild.test.ts +++ b/src/domain/desktop/appBuild.test.ts @@ -1,5 +1,12 @@ +import { execFile } from "node:child_process"; +import { chmod, mkdir, readFile, readlink, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + import { describe, expect, it } from "vitest"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import { PACKAGE_METADATA } from "../../generatedPackageMetadata.js"; import { BUILD_NODE_VERSION, @@ -9,6 +16,8 @@ import { type BuildRequest, } from "./appBuild.js"; +const execute = promisify(execFile); + const request = (install: BuildRequest["install"]): BuildRequest => ({ kind: "request", desktop_entry: "org.xfce.mousepad.desktop", @@ -67,6 +76,7 @@ describe("build bundle renderer", () => { expect(script).toContain( `/opt/rea/node-v${BUILD_NODE_VERSION}-linux-*/bin/node`, ); + expect(script).toContain("exec /usr/local/bin/node /opt/rea/rea.mjs"); }); it("pins the Node.js release REA itself pins", () => { @@ -187,3 +197,146 @@ describe("build bundle install sources", () => { expect(parse({ kind: "bundle", path: "/tmp/bundle" }).success).toBe(true); }); }); + +const shellQuote = (value: string): string => + `'${value.replaceAll("'", `'"'"'`)}'`; + +describe("installed launch paths", () => { + it("keeps an archive folder with spaces as the rea entrypoint", async () => { + const root = await createTestTempDirectory("rea-launcher-spaces-"); + const nodeDir = join( + root, + "node-src", + `node-v${BUILD_NODE_VERSION}-linux-x64`, + "bin", + ); + const payloadDir = join(root, "payload-src", "REA Build", "scripts"); + await mkdir(nodeDir, { recursive: true }); + await mkdir(payloadDir, { recursive: true }); + const marker = join(root, "node-args"); + const entrypoint = "export const marker = 'rea-entrypoint';\n"; + await writeFile( + join(nodeDir, "node"), + `#!/bin/sh\nprintf '%s\\n' "$@" > ${shellQuote(marker)}\n`, + ); + await chmod(join(nodeDir, "node"), 0o755); + await writeFile(join(payloadDir, "rea.mjs"), entrypoint); + await execute("tar", [ + "-cJf", + join(root, "node-amd64.tar.xz"), + "-C", + join(root, "node-src"), + `node-v${BUILD_NODE_VERSION}-linux-x64`, + ]); + await execute("tar", [ + "-cf", + join(root, "rea-payload.tar"), + "-C", + join(root, "payload-src"), + "REA Build", + ]); + const bundle = renderBuildBundle( + request({ kind: "archive", package: "mousepad" }), + localPayload, + ); + const script = text(bundle.files, "envs/mousepad/install-rea.sh") + .replaceAll("/opt/rea", join(root, "opt/rea")) + .replaceAll("/usr/local/bin", join(root, "bin")); + await mkdir(join(root, "bin"), { recursive: true }); + const scriptPath = join(root, "install-rea.sh"); + await writeFile(scriptPath, script); + await chmod(scriptPath, 0o755); + await execute("sh", [scriptPath], { cwd: root }); + + const linked = join(root, "opt/rea/rea.mjs"); + expect(await readlink(linked)).toBe( + join(root, "opt/rea/payload/REA Build/scripts/rea.mjs"), + ); + expect(await readFile(linked, "utf8")).toBe(entrypoint); + await execute(join(root, "bin/rea"), ["--version"]); + expect(await readFile(marker, "utf8")).toBe(`${linked}\n--version\n`); + }); + + it("rewrites an Electron desktop command with the shared launch policy", async () => { + const bundle = renderBuildBundle( + { + ...request({ kind: "archive", package: "typora" }), + desktop_entry: "typora.desktop", + electron: true, + }, + localPayload, + ); + expect(bundle.files.map((file) => file.path).sort()).toContain( + "envs/typora/prepare-electron-launch.sh", + ); + const module = text(bundle.files, "envs/typora/typora_app.py"); + expect(module).toContain( + 'launch=Launch(at="demand", command=["gtk-launch", "typora"]),', + ); + expect(module).toContain( + 'run=[RunScript(script="install-rea.sh"), RunScript(script="prepare-electron-launch.sh")]', + ); + const plain = renderBuildBundle( + { + ...request({ kind: "archive", package: "typora" }), + desktop_entry: "typora", + electron: false, + }, + localPayload, + ); + expect( + plain.files.some((file) => + file.path.endsWith("prepare-electron-launch.sh"), + ), + ).toBe(false); + + const root = await createTestTempDirectory("rea-electron-launch-"); + const policy = join( + root, + "opt/rea/payload/rea/dist/domain/desktop/appExportInstall.js", + ); + await mkdir(dirname(policy), { recursive: true }); + const source = fileURLToPath( + new URL("./appExportInstall.ts", import.meta.url), + ); + await writeFile( + policy, + `export { adjustInstalledDesktopText } from ${JSON.stringify(source)};\n`, + ); + const applications = join(root, "share/applications"); + await mkdir(applications, { recursive: true }); + const desktop = join(applications, "typora.desktop"); + const original = [ + "[Desktop Entry]", + "Type=Application", + "Name=Typora", + "Exec=typora %U", + "[Desktop Action New]", + "Name=New Window", + "Exec=typora --new-window", + "", + ].join("\n"); + await writeFile(desktop, original); + const bin = join(root, "bin"); + await mkdir(bin, { recursive: true }); + await writeFile( + join(bin, "node"), + `#!/bin/sh\nexec ${shellQuote(process.execPath)} --import tsx "$@"\n`, + ); + await chmod(join(bin, "node"), 0o755); + const script = text(bundle.files, "envs/typora/prepare-electron-launch.sh") + .replaceAll("/opt/rea", join(root, "opt/rea")) + .replaceAll("/usr/local/bin", bin); + const scriptPath = join(root, "prepare-electron-launch.sh"); + await writeFile(scriptPath, script); + await chmod(scriptPath, 0o755); + await execute("sh", [scriptPath], { + cwd: process.cwd(), + env: { ...process.env, XDG_DATA_DIRS: join(root, "share") }, + }); + + expect(await readFile(desktop, "utf8")).toBe( + original.replace("Exec=typora %U", "Exec=typora --no-sandbox %U"), + ); + }); +}); diff --git a/src/domain/desktop/appBuild.ts b/src/domain/desktop/appBuild.ts index b2b94f28a2..1e58312748 100644 --- a/src/domain/desktop/appBuild.ts +++ b/src/domain/desktop/appBuild.ts @@ -36,6 +36,7 @@ const COMPUTER_ARCHITECTURES = ["amd64", "arm64"] as const; /** The package of `gtk-launch`, which opens the app from its desktop entry. */ const LAUNCHER_PACKAGE = "libgtk-3-bin"; const REA_INSTALL_SCRIPT = "install-rea.sh"; +const ELECTRON_LAUNCH_SCRIPT = "prepare-electron-launch.sh"; const REA_PAYLOAD = "rea-payload.tar"; /** A URL agentenv downloads from: http or https in lowercase, without a user name or password. */ @@ -96,6 +97,12 @@ export const buildSourceSchema = z.discriminatedUnion("kind", [ .describe( "The desktop entry id the package installs, such as org.xfce.mousepad; the menu opens it with gtk-launch.", ), + electron: z + .boolean() + .optional() + .describe( + "True when the app is Electron. The install rewrites its desktop entry's command with the same launch policy an export uses, adding --no-sandbox right after the program unless the command already has it. The computer's container does not grant the user namespaces Chromium needs, and Electron exits without that flag.", + ), window_class: z .string() .min(1) @@ -156,7 +163,10 @@ if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then exit 1 fi ln -sf /opt/rea/node-v${BUILD_NODE_VERSION}-linux-*/bin/node /usr/local/bin/node -printf '#!/bin/sh\\nexec /usr/local/bin/node %s "$@"\\n' "$1" > /usr/local/bin/rea +# The archive's top folder is caller-chosen and may contain spaces. The launcher +# runs a fixed path so the shell does not split that folder before Node opens it. +ln -sfn "$1" /opt/rea/rea.mjs +printf '#!/bin/sh\\nexec /usr/local/bin/node /opt/rea/rea.mjs "$@"\\n' > /usr/local/bin/rea chmod 755 /usr/local/bin/rea rm -f node-*.tar.xz ${REA_PAYLOAD} `; @@ -237,10 +247,59 @@ const buildInstall = ( scripts: [ ...(packageFile === null ? [] : [INSTALL_SCRIPT]), REA_INSTALL_SCRIPT, + ...(request.electron === true ? [ELECTRON_LAUNCH_SCRIPT] : []), ], }; }; +/** + * The build step that rewrites an Electron app's installed desktop command + * with REA's launch policy. It runs after Node.js and the REA payload are + * unpacked, and before the Applications menu opens the entry with gtk-launch. + */ +const renderElectronLaunchScript = (entry: string): string => `#!/bin/sh +# Applications menu -> gtk-launch of this entry. Chromium's sandbox needs user +# namespaces the container does not grant, so adjust the installed command first. +set -eu +entry='${entry}' +desktop= +ifs=$IFS +IFS=: +for dir in \${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do + if [ -n "$dir" ] && [ -f "$dir/applications/$entry.desktop" ]; then + desktop="$dir/applications/$entry.desktop" + break + fi +done +IFS=$ifs +if [ -z "$desktop" ]; then + echo "No installed desktop entry $entry.desktop was found, so its command cannot be adjusted for Electron." >&2 + exit 1 +fi +set -- /opt/rea/payload/*/dist/domain/desktop/appExportInstall.js +if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then + echo "The REA archive must include dist/domain/desktop/appExportInstall.js so an Electron app can use REA's launch policy." >&2 + exit 1 +fi +/usr/local/bin/node --input-type=module - "$1" "$desktop" <<'JS' +import { readFileSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const policy = await import(pathToFileURL(process.argv[2]).href); +if (typeof policy.adjustInstalledDesktopText !== "function") { + console.error("REA's desktop launch policy did not load from the archive."); + process.exit(1); +} +const desktop = process.argv[3]; +const adjusted = policy.adjustInstalledDesktopText(readFileSync(desktop, "utf8"), true); +if (!adjusted.ok) { + console.error(\`The desktop entry \${desktop} could not be adjusted for Electron: \${adjusted.error}.\`); + process.exit(1); +} +writeFileSync(desktop, adjusted.value); +JS +`; + /** The class module: the decorators that install and launch the app, and a class with no hooks. */ const renderBuildModule = ( request: BuildRequest, @@ -306,6 +365,9 @@ export const renderBuildBundle = ( renderBuildModule(request, entry, plan, install), ), file(REA_INSTALL_SCRIPT, renderReaInstallScript()), + ...(request.electron === true + ? [file(ELECTRON_LAUNCH_SCRIPT, renderElectronLaunchScript(entry))] + : []), ...(install.packageFile === null ? [] : [file(INSTALL_SCRIPT, renderInstallScript(install.packageFile))]), diff --git a/src/domain/desktop/appExportInstall.test.ts b/src/domain/desktop/appExportInstall.test.ts index c238aaafb1..d558223162 100644 --- a/src/domain/desktop/appExportInstall.test.ts +++ b/src/domain/desktop/appExportInstall.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from "vitest"; import { + adjustInstalledDesktopText, electronLaunchArguments, exportInstall, fromUbuntuNobleArchive, repositoryRefusal, } from "./appExportInstall.js"; +import { parseDesktopEntry, tokenizeDesktopExec } from "./desktopEntry.js"; import { installLimitations } from "./appExportInstallLimitations.js"; import { VARICAD_FILE, @@ -558,6 +560,74 @@ describe("electronLaunchArguments", () => { }); }); +describe("adjustInstalledDesktopText", () => { + const entry = (exec: string) => + `[Desktop Entry]\nType=Application\nName=Typora\nExec=${exec}\n[Desktop Action New]\nExec=typora --new-window\n`; + + it("adds the shared Electron launch policy after the program", () => { + const text = entry("typora %U"); + expect(adjustInstalledDesktopText(text, true)).toEqual({ + ok: true, + value: entry("typora --no-sandbox %U"), + }); + expect(adjustInstalledDesktopText(text, false)).toEqual({ + ok: true, + value: text, + }); + }); + + it("puts the flag after the program an env wrapper launches and keeps a path with spaces", () => { + expect( + adjustInstalledDesktopText( + entry("env APP_MODE=desktop /opt/app/app %U"), + true, + ), + ).toEqual({ + ok: true, + value: entry("env APP_MODE=desktop /opt/app/app --no-sandbox %U"), + }); + expect( + adjustInstalledDesktopText(entry('"/opt/REA App/typora" %U'), true), + ).toEqual({ + ok: true, + value: entry('"/opt/REA App/typora" --no-sandbox %U'), + }); + }); + + it("keeps a quoted trailing backslash when the launch policy rewrites Exec", () => { + const text = String.raw`[Desktop Entry] +Type=Application +Name=App +Exec=/usr/bin/true "foo\\\\" +`; + const rewritten = adjustInstalledDesktopText(text, true); + if (!rewritten.ok) throw new Error(rewritten.error); + const parsed = parseDesktopEntry(rewritten.value); + if (!parsed.ok) throw new Error(parsed.error); + expect(tokenizeDesktopExec(parsed.value.exec ?? "")).toEqual({ + ok: true, + value: ["/usr/bin/true", "--no-sandbox", "foo\\"], + }); + expect(rewritten.value).toContain( + String.raw`Exec=/usr/bin/true --no-sandbox "foo\\\\"`, + ); + }); + + it("leaves a command that already passes the flag, and reports an unusable Exec", () => { + const text = entry("typora --no-sandbox %U"); + expect(adjustInstalledDesktopText(text, true)).toEqual({ + ok: true, + value: text, + }); + expect( + adjustInstalledDesktopText("[Desktop Entry]\nName=Typora\n", true), + ).toEqual({ ok: false, error: "missing_exec" }); + expect( + adjustInstalledDesktopText('[Desktop Entry]\nExec="typora\n', true), + ).toEqual({ ok: false, error: "unterminated_quote" }); + }); +}); + describe("installLimitations", () => { it("says where the package comes from, which libraries are unmapped and that the sandbox is off", () => { const installed: DesktopPackage = { diff --git a/src/domain/desktop/appExportInstall.ts b/src/domain/desktop/appExportInstall.ts index ce0902af23..3b43544028 100644 --- a/src/domain/desktop/appExportInstall.ts +++ b/src/domain/desktop/appExportInstall.ts @@ -2,7 +2,13 @@ import { err, ok, type Result } from "../result.js"; import { withoutCredentials, withoutCredentialsIn } from "./aptRepositories.js"; import { dependencyPackages } from "./debianPackageFile.js"; import type { AptRepository, DesktopPackage } from "./desktopCapture.js"; -import { unwrapDesktopCommand } from "./desktopEntry.js"; +import { + formatDesktopExec, + parseDesktopEntry, + replaceDesktopExec, + tokenizeDesktopExec, + unwrapDesktopCommand, +} from "./desktopEntry.js"; const NOBLE_SUITE = / noble(?:-updates|-security|-backports)?\/(?:main|restricted|universe|multiverse) /u; @@ -475,3 +481,31 @@ export const electronLaunchArguments = ( const at = wrapper.kind === "direct" ? wrapper.index + 1 : 1; return [...argv.slice(0, at), "--no-sandbox", ...argv.slice(at)]; }; + +/** + * The desktop entry after its main `Exec` command has been prepared with + * `electronLaunchArguments`. A non-Electron entry, and an Electron command + * that already passes `--no-sandbox`, keep their original text. + */ +export const adjustInstalledDesktopText = ( + text: string, + electron: boolean, +): Result< + string, + | "missing_desktop_entry_group" + | "missing_exec" + | "missing_exec_line" + | "unterminated_quote" + | "empty_command" +> => { + if (!electron) return ok(text); + const parsed = parseDesktopEntry(text); + if (!parsed.ok) return parsed; + const exec = parsed.value.exec; + if (exec === null) return err("missing_exec"); + const tokens = tokenizeDesktopExec(exec); + if (!tokens.ok) return tokens; + const adjusted = electronLaunchArguments(tokens.value, true); + if (adjusted === tokens.value) return ok(text); + return replaceDesktopExec(text, formatDesktopExec(adjusted)); +}; diff --git a/src/domain/desktop/desktopEntry.test.ts b/src/domain/desktop/desktopEntry.test.ts index 0ce77b6a77..1c5539b84e 100644 --- a/src/domain/desktop/desktopEntry.test.ts +++ b/src/domain/desktop/desktopEntry.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from "vitest"; import { + escapeDesktopEntryValue, expandDesktopExec, + formatDesktopExec, parseDesktopEntry, + replaceDesktopExec, tokenizeDesktopExec, unwrapDesktopCommand, } from "./desktopEntry.js"; @@ -81,6 +84,53 @@ describe("tokenizeDesktopExec", () => { }); }); +describe("formatDesktopExec", () => { + it("round-trips arguments that contain spaces and quotes", () => { + const exec = '"/opt/My App/run" --title "say \\"hi\\"" %F'; + const tokens = tokenizeDesktopExec(exec); + if (!tokens.ok) throw new Error(tokens.error); + expect(tokenizeDesktopExec(formatDesktopExec(tokens.value))).toEqual( + tokens, + ); + }); + + it("escapes the desktop-entry value so a trailing backslash survives a second parse", () => { + const program = "/opt/app\\dir\\"; + const quoted = 'say "hi"\\'; + const argv = [program, "--title", quoted, "%U"]; + // Command quoting doubles each backslash; the value layer doubles them + // again, which is the form gio accepts. One fewer level leaves a trailing + // backslash that escapes the closing quote. + const escaped = String.raw`Exec="/opt/app\\\\dir\\\\" --title "say \\"hi\\"\\\\" %U`; + const text = `[Desktop Entry]\nType=Application\nName=App\n${escaped}\n`; + + const parsed = parseDesktopEntry(text); + if (!parsed.ok) throw new Error(parsed.error); + expect(tokenizeDesktopExec(parsed.value.exec ?? "")).toEqual({ + ok: true, + value: argv, + }); + expect(escapeDesktopEntryValue(formatDesktopExec(argv))).toBe( + escaped.slice("Exec=".length), + ); + + const rewritten = replaceDesktopExec( + text, + formatDesktopExec([program, "--no-sandbox", "--title", quoted, "%U"]), + ); + if (!rewritten.ok) throw new Error(rewritten.error); + const again = parseDesktopEntry(rewritten.value); + if (!again.ok) throw new Error(again.error); + expect(tokenizeDesktopExec(again.value.exec ?? "")).toEqual({ + ok: true, + value: [program, "--no-sandbox", "--title", quoted, "%U"], + }); + expect(rewritten.value).toContain( + String.raw`Exec="/opt/app\\\\dir\\\\" --no-sandbox --title "say \\"hi\\"\\\\" %U`, + ); + }); +}); + describe("expandDesktopExec", () => { const context = { icon: "editor", name: "Editor", location: "/e.desktop" }; diff --git a/src/domain/desktop/desktopEntry.ts b/src/domain/desktop/desktopEntry.ts index d4b8ce4c35..29a3464cc6 100644 --- a/src/domain/desktop/desktopEntry.ts +++ b/src/domain/desktop/desktopEntry.ts @@ -32,6 +32,43 @@ const unescapeValue = (value: string): string => (match, next: string) => VALUE_ESCAPES[next] ?? match, ); +/** + * Escape a string for a desktop-entry value. This is the inverse of the + * escapes `parseDesktopEntry` removes, and the same rules GLib uses when it + * writes a string: `\\`, `\n`, `\r`, a leading `\s` or `\t`. A backslash that + * is only command-quoted would otherwise be eaten before `Exec` is tokenized, + * and `gio launch` rejects the resulting entry. + */ +export const escapeDesktopEntryValue = (value: string): string => { + let escaped = ""; + let leading = true; + for (const character of value) { + switch (character) { + case " ": + escaped += leading ? "\\s" : " "; + break; + case "\t": + escaped += leading ? "\\t" : "\t"; + break; + case "\n": + escaped += "\\n"; + break; + case "\r": + escaped += "\\r"; + break; + case "\\": + escaped += "\\\\"; + leading = false; + break; + default: + escaped += character; + leading = false; + break; + } + } + return escaped; +}; + const listValue = (value: string | undefined): readonly string[] => value === undefined ? [] @@ -109,6 +146,58 @@ export const tokenizeDesktopExec = ( return tokens.length === 0 ? err("empty_command") : ok(tokens); }; +/** Reserved characters an unquoted `Exec` argument must not contain. */ +const EXEC_RESERVED = /[\t\n\r "'\\><~|&;$*?#()`]/u; + +/** + * Quote `Exec` arguments so `tokenizeDesktopExec` reads them back. The + * desktop-entry value escapes around that command are applied when the line + * is written. + */ +export const formatDesktopExec = (argv: readonly string[]): string => + argv + .map((argument) => + argument.length > 0 && !EXEC_RESERVED.test(argument) + ? argument + : `"${argument.replace(/[\\"`$]/gu, (character) => `\\${character}`)}"`, + ) + .join(" "); + +const EXEC_LINE = /^Exec\s*=/u; + +/** + * Replace the first `Exec` line of the main group, leaving other groups + * alone. `exec` is the command `tokenizeDesktopExec` reads; it is escaped for + * the desktop-entry value layer before it is written. + */ +export const replaceDesktopExec = ( + text: string, + exec: string, +): Result => { + const newline = text.includes("\r\n") ? "\r\n" : "\n"; + let group: string | undefined; + let replaced = false; + const lines = text.split(/\r?\n/u).map((line) => { + const trimmed = line.trim(); + const header = GROUP.exec(trimmed); + if (header?.groups?.name !== undefined) { + group = header.groups.name; + return line; + } + if ( + replaced || + group !== "Desktop Entry" || + trimmed.length === 0 || + trimmed.startsWith("#") || + !EXEC_LINE.test(trimmed) + ) + return line; + replaced = true; + return `Exec=${escapeDesktopEntryValue(exec)}`; + }); + return replaced ? ok(lines.join(newline)) : err("missing_exec_line"); +}; + /** Facts substituted for the `%i`, `%c` and `%k` field codes. */ export interface DesktopExecContext { readonly icon: string | null; From 15fad0efa3ad51db7933a1bd091d7f425cbe500e Mon Sep 17 00:00:00 2001 From: highmachs Date: Fri, 6 Nov 2026 11:55:26 +0530 Subject: [PATCH 016/151] fix(desktop): normalize XML attribute whitespace and line endings during seed scrubbing (#9099) --- src/domain/desktop/seedScrub.test.ts | 38 ++++++++++++++++++++++++++++ src/domain/desktop/seedScrub.ts | 11 +++++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/domain/desktop/seedScrub.test.ts b/src/domain/desktop/seedScrub.test.ts index a283e715ba..2d9d720c8b 100644 --- a/src/domain/desktop/seedScrub.test.ts +++ b/src/domain/desktop/seedScrub.test.ts @@ -210,6 +210,44 @@ describe("scrubSeed on JSON and XML", () => { }); }); +describe("scrubSeed XML whitespace normalization", () => { + it("normalizes literal whitespace in rewritten XML attributes without inventing character references", () => { + const xml = ''; + const outcome = scrubbed(xml, "xml"); + + expect(outcome.text).toBe( + ``, + ); + expect(outcome.holdsIdentity).toBe(false); + }); + + it("normalizes literal line endings in rewritten XML character data without inventing CR references", () => { + const crlf = `/home/ada/a\r\n b`; + const cr = `/home/ada/a\r b`; + + expect(scrubbed(crlf, "xml").text).toBe(`${SEED_HOME}/a\n b`); + expect(scrubbed(cr, "xml").text).toBe(`${SEED_HOME}/a\n b`); + }); + + it("preserves explicit character references in XML attributes and character data", () => { + const xml = `/home/ada/a b`; + const outcome = scrubbed(xml, "xml"); + + expect(outcome.text).toBe( + `${SEED_HOME}/a b`, + ); + expect(outcome.holdsIdentity).toBe(false); + }); + + it("leaves XML with literal line endings or whitespace byte-for-byte unchanged when no identity matches", () => { + const xml = '\r\nclean\r\n'; + const outcome = scrubSeed(Buffer.from(xml), seed("xml"), GEANY); + + expect(outcome.bytes).toBeUndefined(); + expect(outcome.holdsIdentity).toBe(false); + }); +}); + describe("scrubSeed on bytes it does not rewrite", () => { it("reports a seed whose shipped bytes still hold the host name or the captured home", () => { const database = Buffer.concat([ diff --git a/src/domain/desktop/seedScrub.ts b/src/domain/desktop/seedScrub.ts index 1860ceb4e9..881114a982 100644 --- a/src/domain/desktop/seedScrub.ts +++ b/src/domain/desktop/seedScrub.ts @@ -95,8 +95,12 @@ const XML_ENTITIES: Readonly> = { const referenced = (whole: string, point: number): string => point <= 0x10ffff ? String.fromCodePoint(point) : whole; -const decodeXml = (text: string): string => - text.replace( +const decodeXml = (text: string, quote?: string): string => { + const normalized = + quote !== undefined + ? text.replace(/\r\n|[\r\n\t]/gu, " ") + : text.replace(/\r\n|\r/gu, "\n"); + return normalized.replace( XML_REFERENCE, (whole, hex?: string, decimal?: string, name?: string) => hex !== undefined @@ -105,6 +109,7 @@ const decodeXml = (text: string): string => ? referenced(whole, Number(decimal)) : (XML_ENTITIES[name ?? ""] ?? whole), ); +}; /** Character data, or an attribute value in the given quote, escaped so XML reads it back as written. */ const encodeXml = (text: string, quote: string | undefined): string => @@ -127,7 +132,7 @@ const encodeXml = (text: string, quote: string | undefined): string => */ const scrubXml = (text: string, identity: HostIdentity): TextScrub => { const data = (raw: string, quote?: string) => { - const value = decodeXml(raw); + const value = decodeXml(raw, quote); const clean = scrubHostIdentity(value, identity); return clean === value ? raw : encodeXml(clean, quote); }; From 711b374d1c6054d543922328d972f668dce22e84 Mon Sep 17 00:00:00 2001 From: highmachs Date: Fri, 6 Nov 2026 11:56:00 +0530 Subject: [PATCH 017/151] fix(native): retain defined global and weak ELF symbols with GNU version index 0 (#9100) --- src/native/exports/NativeExportParser.test.ts | 83 ++++++++++++++----- src/native/exports/NativeExportParser.ts | 1 - 2 files changed, 63 insertions(+), 21 deletions(-) diff --git a/src/native/exports/NativeExportParser.test.ts b/src/native/exports/NativeExportParser.test.ts index 99a6608938..3b070e805e 100644 --- a/src/native/exports/NativeExportParser.test.ts +++ b/src/native/exports/NativeExportParser.test.ts @@ -358,7 +358,8 @@ describe("ELF dynamic symbol exports", () => { { name: "hidden_fn", visibility: 2 }, { name: "undefined_fn", section: 0 }, { name: "local_fn", binding: 0 }, - { name: "local_version_fn", version: 0 }, + { name: "local_version_fn", binding: 0, version: 0 }, + { name: "unversioned_fn", version: 0 }, ]; it("lists defined default and protected globals with their versions", () => { @@ -374,7 +375,7 @@ describe("ELF dynamic symbol exports", () => { bits: 64, byte_order: "little", architecture: "amd64", - coverage: { status: "complete", examined_entries: 10, exports: 6 }, + coverage: { status: "complete", examined_entries: 11, exports: 7 }, issues: [], }); expect( @@ -441,9 +442,69 @@ describe("ELF dynamic symbol exports", () => { visibility: "protected", version: null, }, + { + name: "unversioned_fn", + type: "function", + binding: "global", + visibility: "default", + version: null, + }, ]); }); + it("reads a big-endian ELF32 table", () => { + const result = parse( + elfExportImage({ + bits: 32, + little: false, + machine: 20, + symbols: [{ name: "be_fn", value: 0x10000n, size: 8n }], + }), + ); + + expect(result).toMatchObject({ + bits: 32, + byte_order: "big", + architecture: "ppc", + exports: [{ name: "be_fn", value: "0x10000", size: "8" }], + }); + }); +}); + +describe("ELF version index 0 unversioned exports", () => { + it("retains defined global and weak symbols with GNU version index 0 as unversioned exports", () => { + const bytes = elfExportImage({ + symbols: [ + { name: "global_symbol", version: 0 }, + { name: "weak_symbol", binding: 2, version: 0 }, + { name: "plain_symbol", version: 1 }, + { name: "hidden_symbol", visibility: 2, version: 0 }, + { name: "undefined_symbol", section: 0, version: 0 }, + { name: "local_symbol", binding: 0, version: 0 }, + ], + }); + const result = parse(bytes); + expect(result).toMatchObject({ + format: "elf", + coverage: { status: "complete", examined_entries: 6, exports: 3 }, + issues: [], + }); + expect( + result.format === "elf" && + result.exports.map(({ name, binding, version }) => ({ + name, + binding, + version, + })), + ).toEqual([ + { name: "global_symbol", binding: "global", version: null }, + { name: "weak_symbol", binding: "weak", version: null }, + { name: "plain_symbol", binding: "global", version: null }, + ]); + }); +}); + +describe("historical ELF export evidence", () => { it("normalizes historical ELF export evidence lacking visibility to unknown", () => { const historical = { artifact, @@ -477,24 +538,6 @@ describe("ELF dynamic symbol exports", () => { if (parsed.format !== "elf") throw new Error("Expected ELF result"); expect(parsed.exports[0]?.visibility).toBe("unknown"); }); - - it("reads a big-endian ELF32 table", () => { - const result = parse( - elfExportImage({ - bits: 32, - little: false, - machine: 20, - symbols: [{ name: "be_fn", value: 0x10000n, size: 8n }], - }), - ); - - expect(result).toMatchObject({ - bits: 32, - byte_order: "big", - architecture: "ppc", - exports: [{ name: "be_fn", value: "0x10000", size: "8" }], - }); - }); }); describe("ELF export version and name coverage", () => { diff --git a/src/native/exports/NativeExportParser.ts b/src/native/exports/NativeExportParser.ts index fc32975e76..93fbdea8ad 100644 --- a/src/native/exports/NativeExportParser.ts +++ b/src/native/exports/NativeExportParser.ts @@ -694,7 +694,6 @@ function* parseElfExports( else if (versions !== null) { const raw = reader.u16(versions + index * 2); const versionIndex = raw & 0x7fff; - if (versionIndex === 0) continue; if (versionIndex >= 2) { const decodedName = versionNames.get(versionIndex) ?? null; if (decodedName === null && !unresolvedVersions.has(versionIndex)) { From 8766bcef5987b04d3a19c2a739b990b47391893a Mon Sep 17 00:00:00 2001 From: hhang <17309584214@163.com> Date: Fri, 6 Nov 2026 14:26:28 +0800 Subject: [PATCH 018/151] fix(desktop): retain UTF-16 seed content after the signature (#9098) Co-authored-by: hhang <169027046+xu91102@users.noreply.github.com> --- docs/desktop-application-export.md | 5 +- .../desktop/DesktopExportUtf16Values.test.ts | 59 +++++++++++++++++++ src/domain/desktop/seedScrub.test.ts | 19 ++++++ src/domain/desktop/seedScrub.ts | 6 +- 4 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 src/application/desktop/DesktopExportUtf16Values.test.ts diff --git a/docs/desktop-application-export.md b/docs/desktop-application-export.md index 207abfac95..7db04e0701 100644 --- a/docs/desktop-application-export.md +++ b/docs/desktop-application-export.md @@ -188,8 +188,9 @@ container named `e81232be457d`, and many files hold the home path export removes them from what the bundle ships: - In seeded text files (text, JSON, key files and XML in the state view), in - UTF-16 files that start with a byte-order mark (keeping the mark and byte - order), and in the dconf keys, `user@host` becomes `user@localhost`, the + UTF-16 files that start with a byte-order mark (keeping the mark, byte order + and a following U+FEFF content character), and in the dconf keys, + `user@host` becomes `user@localhost`, the host name standing alone becomes `localhost`, and the captured home becomes `/home/user` wherever it is followed by `/`, the end of the text, or a character that cannot continue a file name. The host name counts only as a diff --git a/src/application/desktop/DesktopExportUtf16Values.test.ts b/src/application/desktop/DesktopExportUtf16Values.test.ts new file mode 100644 index 0000000000..e3ba42db7e --- /dev/null +++ b/src/application/desktop/DesktopExportUtf16Values.test.ts @@ -0,0 +1,59 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import { exportDesktopApplication } from "./DesktopApplicationExport.js"; +import { + HOME, + capture, + checkpoint, + readers, + retained, + writeCaptureFolder, +} from "./desktopCapture.fixture.js"; + +it.each(["le", "be"] as const)( + "exports UTF-16 %s content without consuming its first character as another signature", + async (order) => { + const encode = (text: string) => { + const bytes = Buffer.from(`\uFEFF${text}`, "utf16le"); + return order === "be" ? bytes.swap16() : bytes; + }; + const input = encode(`\uFEFFtitle\n${HOME}/file\n`); + const path = `${HOME}/.config/notes/notes.txt`; + const base = capture(); + const captured = { + ...base, + state: { + ...base.state, + checkpoints: [ + checkpoint("baseline", "baseline", [], [retained(path, input)]), + ], + }, + }; + const folder = await writeCaptureFolder(captured, undefined, [input]); + const scratch = await createTestTempDirectory("desktop-utf16-values-"); + const directory = join(scratch, "bundle"); + + const result = await exportDesktopApplication( + { capture: folder, directory, name: "notes", tasks: [] }, + readers, + ); + + if (result instanceof Error) throw result; + const seed = "envs/notes/seed/.config/notes/notes.txt"; + expect(result.normalized_result).toMatchObject({ + scrubbed_files: [seed], + unscrubbed_files: [], + }); + const output = await readFile(join(directory, seed)); + expect(output.equals(encode("\uFEFFtitle\n/home/user/file\n"))).toBe(true); + const source = captured.state.checkpoints[0]?.retained[0]; + expect(source).toBeDefined(); + expect( + (await readFile(join(folder, "content", source!.sha256))).equals(input), + ).toBe(true); + }, +); diff --git a/src/domain/desktop/seedScrub.test.ts b/src/domain/desktop/seedScrub.test.ts index 2d9d720c8b..714d6a226a 100644 --- a/src/domain/desktop/seedScrub.test.ts +++ b/src/domain/desktop/seedScrub.test.ts @@ -28,6 +28,25 @@ const scrubbed = (text: string, format: string, identity = GEANY) => { }; describe("scrubSeed on text", () => { + it.each(["le", "be"] as const)( + "keeps a leading content U+FEFF after the UTF-16 %s signature", + (order) => { + const input = utf16("\uFEFFtitle\n/home/ada/file\n", order); + const outcome = scrubSeed(input, seed("binary"), GEANY); + expect( + outcome.bytes?.equals(utf16(`\uFEFFtitle\n${SEED_HOME}/file\n`, order)), + ).toBe(true); + expect(outcome.holdsIdentity).toBe(false); + expect( + scrubSeed( + utf16("\uFEFFtitle\n/different/home/file\n", order), + seed("binary"), + GEANY, + ), + ).toEqual({ bytes: undefined, holdsIdentity: false }); + }, + ); + it("rewrites a UTF-8 text seed, keeping its byte-order mark, and leaves a clean one as it is", () => { expect(scrubbed("\uFEFFmail=ada@e81232be457d\n", "keyfile")).toEqual({ text: "\uFEFFmail=user@localhost\n", diff --git a/src/domain/desktop/seedScrub.ts b/src/domain/desktop/seedScrub.ts index 881114a982..02e8626cfd 100644 --- a/src/domain/desktop/seedScrub.ts +++ b/src/domain/desktop/seedScrub.ts @@ -332,7 +332,11 @@ const utf16Text = (bytes: Buffer) => { if (order.swap && little.length % 2 === 0) little.swap16(); try { return { - text: new TextDecoder("utf-16le", { fatal: true }).decode(little), + // The signature is already removed; a leading U+FEFF here is content. + text: new TextDecoder("utf-16le", { + fatal: true, + ignoreBOM: true, + }).decode(little), encode: (scrubbed: string) => { const encoded = Buffer.from(scrubbed, "utf16le"); if (order.swap) encoded.swap16(); From c7b0e0dec6547829541b39011cebdce486640206 Mon Sep 17 00:00:00 2001 From: Ace <1160684883@qq.com> Date: Fri, 6 Nov 2026 14:26:41 +0800 Subject: [PATCH 019/151] feat(harmony): disclose the unread pkgContextInfo.json declaration (#9096) * feat(harmony): disclose the unread pkgContextInfo.json declaration A package that ships pkgContextInfo.json shows the entry in unclassified_components, yet nothing said its isSO declarations were skipped: bridge_candidates came only from the libs//lib.so path rule. State that gap in limitations when the entry is inventoried, so a path-convention hypothesis is not read as a package declaration. The declaration-parsing half of #4489 needs a decision about who owns reading inventoried file content; this projection receives identity and digests only, and the shared inventory carrier guard rejects other Evidence. No content is parsed here. * test(harmony): assert the disclosure by its names, not its sentence docs/testing.md requires asserting structured facts instead of freezing an explanatory sentence, and says a replaced prose assertion must still pass on a harmless wording change while failing when the fact is lost. The first version of this test compared the whole limitation string verbatim, so any rewording of the disclosure would have failed CI. The assertions now require exactly one limitation naming pkgContextInfo.json, carrying bridge_candidates, isSO, and the not-read/not-narrowed fact, and keep the structural claim that the same package without the declaration file yields identical bridge candidates and no such limitation. --- docs/harmony-analysis.md | 6 ++ src/domain/harmony/harmonyApplication.ts | 10 ++++ .../filesystem/harmony/application.test.ts | 60 ++++++++++++++++++- 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/docs/harmony-analysis.md b/docs/harmony-analysis.md index 72fbeec5c1..8eb15215bb 100644 --- a/docs/harmony-analysis.md +++ b/docs/harmony-analysis.md @@ -82,6 +82,12 @@ boundaries are out of scope for this increment and stay in the projection's `limitations` array until a dedicated HarmonyOS provider exists. REA fails closed on packages whose inventory cannot be authenticated. +`pkgContextInfo.json` is inventoried and listed as an unclassified component but +never read, so its `isSO` declarations do not narrow `bridge_candidates`. When +that entry is present the projection adds a limitation naming the unread +declaration, so a path-convention hypothesis is not mistaken for a package +declaration. + ## Verification lane `npm run fixtures:harmony` downloads the SHA-256-pinned 1.0.1 HAP release diff --git a/src/domain/harmony/harmonyApplication.ts b/src/domain/harmony/harmonyApplication.ts index 1e52571bb1..8632ad0d58 100644 --- a/src/domain/harmony/harmonyApplication.ts +++ b/src/domain/harmony/harmonyApplication.ts @@ -71,6 +71,15 @@ export type HarmonyApplicationProjectionResult = z.infer< >; type Component = z.infer; +const PKG_CONTEXT_NOT_READ = + "pkgContextInfo.json is inventoried but not read, so bridge_candidates are not narrowed by its isSO declarations; narrowing requires parsed file content that this projection does not receive."; + +/** Whether the package carries the build-output declaration this projection cannot read. */ +const pkgContextDeclared = ( + files: readonly ProjectedInventoryEntry[], +): boolean => + files.some(({ path }) => /(?:^|\/)pkgContextInfo\.json$/iu.test(path)); + /** Project exact HarmonyOS package paths and hashes without decoding or executing target code. */ export const projectHarmonyInventory = ( source: ParsedInventorySet, @@ -122,6 +131,7 @@ export const projectHarmonyInventory = ( "Runtime families are inferred from inventory formats and paths; filename suffixes do not establish valid Ark bytecode.", "The signing component lists META-INF certificate-entry suffixes only; signatures applied by the packaging tool live in the ZIP signing block rather than archive entries, so an empty signing list does not mean the package is unsigned.", "Bridge candidates are path-based hypotheses, not decoded N-API declarations or observed runtime calls.", + ...(pkgContextDeclared(fileEntries) ? [PKG_CONTEXT_NOT_READ] : []), ...(rootFormat === "app-pack" ? [ "App Pack child packages are listed by inventory path only; their contents are not recursively inventoried by this projection.", diff --git a/tests/boundary/filesystem/harmony/application.test.ts b/tests/boundary/filesystem/harmony/application.test.ts index 1406bcb14f..01c6a7b938 100644 --- a/tests/boundary/filesystem/harmony/application.test.ts +++ b/tests/boundary/filesystem/harmony/application.test.ts @@ -17,7 +17,10 @@ import { import { projectHarmonyApplicationEvidence } from "../../../../src/application/harmony/HarmonyApplicationService.js"; import { createDirectAnalysis } from "../../../../src/composition/directAnalysis.js"; -import { harmonyApplicationProjectionResultSchema } from "../../../../src/domain/harmony/harmonyApplication.js"; +import { + harmonyApplicationProjectionResultSchema, + type HarmonyApplicationProjectionResult, +} from "../../../../src/domain/harmony/harmonyApplication.js"; import { artifactInventoryResultSchema } from "../../../../src/domain/artifactGraph.js"; import { createEvidence } from "../../../../src/domain/evidence.js"; import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; @@ -39,6 +42,61 @@ const stageHapEntries = [ { path: "META-INF/FIXTURE.p7b", content: "opaque signing" }, ] as const; +const PKG_CONTEXT_NAME = "pkgContextInfo.json"; + +const unreadDeclarationLimitations = ( + limitations: readonly string[], +): string[] => limitations.filter((text) => text.includes(PKG_CONTEXT_NAME)); + +const projectStageHap = async ( + label: string, + entries: readonly { path: string; content: string | Uint8Array }[], +): Promise => { + const path = await writeApplicationZip(label, "Fixture.hap", entries); + return requireDeterministicProjection( + projectHarmonyApplicationEvidence, + await inventoryApplicationPackage(path), + harmonyApplicationProjectionResultSchema, + ); +}; + +const stageHapWithDeclaration = stageHapEntries.map(({ path, content }) => ({ + path, + content, +})); + +describe("HarmonyOS N-API declaration disclosure", () => { + it("discloses the unread declaration by its names, not its wording", async () => { + const projection = await projectStageHap("rea-harmony-pkg-context-", [ + ...stageHapWithDeclaration, + { + path: PKG_CONTEXT_NAME, + content: '{"libentry.so":{"isSO":true}}', + }, + ]); + const control = await projectStageHap( + "rea-harmony-pkg-context-control-", + stageHapWithDeclaration, + ); + + const disclosed = unreadDeclarationLimitations(projection.limitations); + expect(disclosed).toHaveLength(1); + expect(disclosed[0]).toContain("bridge_candidates"); + expect(disclosed[0]).toMatch(/\bisSO\b/); + expect(disclosed[0]).toMatch(/not read|not narrowed/iu); + expect(projection.bridge_candidates).toEqual(control.bridge_candidates); + expect(unreadDeclarationLimitations(control.limitations)).toEqual([]); + }); + + it("does not disclose a declaration the package does not carry", async () => { + const projection = await projectStageHap( + "rea-harmony-no-pkg-context-", + stageHapWithDeclaration, + ); + expect(unreadDeclarationLimitations(projection.limitations)).toEqual([]); + }); +}); + describe("HarmonyOS unclassified inventory", () => { it("retains distinct unclassified paths sharing content with a classified file", async () => { const path = await writeApplicationZip( From c92cbcaf281bea28e16146c3555d58a2d62edf03 Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Fri, 6 Nov 2026 14:27:41 +0800 Subject: [PATCH 020/151] fix(desktop): preserve significant keyfile value whitespace (#9077) * fix(desktop): preserve significant keyfile value whitespace * fix(desktop): preserve keyfile whitespace in exported verifier * test(process): align symlink fixture with filesystem checkpoints * test(frida): assert the CLI spawn Evidence contract * test(mcp): use an unsupported analysis-view parent * test(browser): separate source-location line bounds from parity --- src/domain/desktop/appExportPython.ts | 6 ++-- src/domain/desktop/stateChanges.test.ts | 28 +++++++++++++++++++ src/domain/desktop/stateFormats.ts | 11 +++++--- .../providers/desktopExportPython.test.ts | 22 +++++++++++++++ 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/domain/desktop/appExportPython.ts b/src/domain/desktop/appExportPython.ts index 844df74526..9d1d871dc2 100644 --- a/src/domain/desktop/appExportPython.ts +++ b/src/domain/desktop/appExportPython.ts @@ -205,8 +205,10 @@ def _keyfile_line(raw: str) -> tuple: header = GROUP_HEADER.match(line) if header: return ("group", header.group(1)) - key, equals, value = line.partition("=") - return ("entry", key.strip(), value.strip()) if equals and key else ("other",) + key, equals, value = raw.partition("=") + key = key.strip() + # Only ASCII space and tab immediately after '=' are padding. + return ("entry", key, value.lstrip(" \t")) if equals and key else ("other",) def _each_line(text: str): diff --git a/src/domain/desktop/stateChanges.test.ts b/src/domain/desktop/stateChanges.test.ts index 8dbc97343c..1321c3b28c 100644 --- a/src/domain/desktop/stateChanges.test.ts +++ b/src/domain/desktop/stateChanges.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { JsonValue } from "../jsonValue.js"; import { diffStateViews } from "./stateChanges.js"; +import { keyfileView } from "./stateFormats.js"; import { resolvePointer } from "./viewPointer.js"; describe("diffStateViews", () => { @@ -84,6 +85,33 @@ describe("diffStateViews", () => { }); describe("diffStateViews fidelity", () => { + it("preserves significant keyfile whitespace in views and differences", () => { + const view = (suffix: string, unicode: string, padding: string) => + keyfileView( + `[ui]\r\nsuffix=${suffix}\r\nunicode=${unicode}\r\npadded=${padding}\r\n`, + ); + const before = view("hello ", "\u00a0hello\u00a0", " hello"); + const after = view("hello ", "\u00a0hello\u00a0\u00a0", "\thello"); + expect(before.ui).toEqual({ + suffix: "hello ", + unicode: "\u00a0hello\u00a0", + padded: "hello", + }); + expect(after.ui).toEqual({ + suffix: "hello ", + unicode: "\u00a0hello\u00a0\u00a0", + padded: "hello", + }); + expect( + diffStateViews("keyfile", { groups: before }, { groups: after }), + ).toMatchObject({ + kind: "keys", + changed: ["ui/suffix", "ui/unicode"], + changed_count: 2, + complete: true, + }); + }); + it("keeps empty containers as values", () => { expect(diffStateViews("json", {}, { items: [] })).toMatchObject({ added: ["/items"], diff --git a/src/domain/desktop/stateFormats.ts b/src/domain/desktop/stateFormats.ts index e4eed2e211..2e868563da 100644 --- a/src/domain/desktop/stateFormats.ts +++ b/src/domain/desktop/stateFormats.ts @@ -49,13 +49,16 @@ const keyfileLine = (raw: string): KeyfileLine => { return { kind: "skip" }; const name = /^\[(?[^\]]+)\]$/u.exec(line)?.groups?.name; if (name !== undefined) return { kind: "group", name }; - const equals = line.indexOf("="); - return equals <= 0 + const equals = raw.indexOf("="); + if (equals < 0) return { kind: "other" }; + const key = raw.slice(0, equals).trim(); + return key.length === 0 ? { kind: "other" } : { kind: "entry", - key: line.slice(0, equals).trim(), - value: line.slice(equals + 1).trim(), + key, + // Only ASCII space and tab immediately after '=' are padding. + value: raw.slice(equals + 1).replace(/^[ \t]+/u, ""), }; }; diff --git a/tests/boundary/providers/desktopExportPython.test.ts b/tests/boundary/providers/desktopExportPython.test.ts index 57ec3b6621..f7938aa320 100644 --- a/tests/boundary/providers/desktopExportPython.test.ts +++ b/tests/boundary/providers/desktopExportPython.test.ts @@ -10,6 +10,7 @@ import { } from "../../../src/domain/desktop/appExportPython.js"; import { detectStateFormat, + keyfileView, isJsonText, jsonView, textualView, @@ -397,3 +398,24 @@ it("spells infinite and negative-zero SQLite reals as REA's SQLite reader does", expect(JSON.parse(cells)).toEqual(["Infinity", "-Infinity", "-0", 0, 1.5]); }); + +it("keeps the same keyfile whitespace as the retained state view", async () => { + const inputs = [ + "[ui]\nsuffix=hello \nunicode=\u00a0hello\u00a0\npadded= hello\n", + "[ui]\r\nsuffix=hello \r\nunicode=\u00a0hello\u00a0\u00a0\r\npadded=\thello\r\n", + ]; + const imports = PYTHON_IMPORTS.split("\n") + .filter((line) => !line.includes("agentenv_protocol")) + .join("\n"); + const program = `${imports}${PYTHON_HELPERS} +print(json.dumps([_text_view("settings.ini", text, len(text.encode()))["view"]["groups"] for text in json.load(sys.stdin)])) +`; + const root = await createTestTempDirectory("rea-keyfile-python-"); + const script = join(root, "fixture.py"); + await writeFile(script, program, "utf8"); + const output = execFileSync("python3", ["-I", "-B", "-X", "utf8", script], { + input: JSON.stringify(inputs), + encoding: "utf8", + }); + expect(JSON.parse(output)).toEqual(inputs.map(keyfileView)); +}); From edd32e5af1826abf4f6698dd1dd7005de662c27c Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Fri, 6 Nov 2026 14:33:03 +0800 Subject: [PATCH 021/151] test(paths): isolate POSIX cwd fixture from the host platform (#9092) --- tests/boundary/filesystem/callerPath.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/boundary/filesystem/callerPath.test.ts b/tests/boundary/filesystem/callerPath.test.ts index 25cc5eb35e..38e755ef69 100644 --- a/tests/boundary/filesystem/callerPath.test.ts +++ b/tests/boundary/filesystem/callerPath.test.ts @@ -43,9 +43,14 @@ it("removes only POSIX spellings that cannot change the named file", () => { }); it("makes a relative POSIX cwd absolute before appending the path", () => { - expect( - absoluteCallerPath("sample", { cwd: "fixtures/../x", platform: "linux" }), - ).toBe(`${process.cwd()}/fixtures/../x/sample`); + const cwd = vi.spyOn(process, "cwd").mockReturnValue("/work"); + try { + expect( + absoluteCallerPath("sample", { cwd: "fixtures/../x", platform: "linux" }), + ).toBe("/work/fixtures/../x/sample"); + } finally { + cwd.mockRestore(); + } }); it("uses lexical resolution on Windows, where it is exact", () => { From 34b954ec8a97d5e250c5f1291edd64688f40baca Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Fri, 6 Nov 2026 14:33:21 +0800 Subject: [PATCH 022/151] test(cutter): synchronize peer descriptor close before cancellation (#9082) * test(cutter): synchronize peer descriptor close before cancellation * test(process): align symlink fixture with filesystem checkpoints * test(frida): assert the CLI spawn Evidence contract * test(mcp): use an unsupported analysis-view parent * test(browser): separate source-location line bounds from parity --- .../process-global/cutterDescriptorLifetime.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/process-global/cutterDescriptorLifetime.test.ts b/tests/process-global/cutterDescriptorLifetime.test.ts index 463f699abc..f0e6044626 100644 --- a/tests/process-global/cutterDescriptorLifetime.test.ts +++ b/tests/process-global/cutterDescriptorLifetime.test.ts @@ -236,8 +236,15 @@ it("does not assign a peer's later cancellation to a descriptor that already fai const gate = new Promise((resolve) => { release = resolve; }); + let firstClosing!: () => void; + const firstCloseStarted = new Promise((resolve) => { + firstClosing = resolve; + }); fault.onClose = async (handle) => { - if (handle === fault.handles[0]) return; + if (handle === fault.handles[0]) { + firstClosing(); + return; + } entered(); await gate; }; @@ -247,10 +254,11 @@ it("does not assign a peer's later cancellation to a descriptor that already fai .catch((cause: unknown) => cause); try { await awaitAcknowledgement( - waiting, + Promise.all([waiting, firstCloseStarted]).then(() => undefined), reading, "peer descriptor finalization", ); + // Let the first close rejection propagate while its peer remains gated. await new Promise((resolve) => setImmediate(resolve)); controller.abort(reason); release(); From 415ddb74c0d41c77e070c7adfee834be0fef4028 Mon Sep 17 00:00:00 2001 From: RRFZ <247457381+Plastic-time@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:33:56 -0500 Subject: [PATCH 023/151] fix(cli): publish the GPU inventory digest format in the CLI schema (#9062) inspect-gpu-code-objects declared --expected_sha256 as any string. The CLI schema accepted an upper-case digest, as PowerShell's Get-FileHash prints it, and the command then refused it as invalid_request. Reuse the shared lowercase SHA-256 field so argument parsing refuses it with the CLI field name. --- src/cli/gpuCommands.ts | 8 +++--- tests/acceptance/gpuCodeObjectsCli.test.ts | 30 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/cli/gpuCommands.ts b/src/cli/gpuCommands.ts index 186c35cf4c..c6270beec4 100644 --- a/src/cli/gpuCommands.ts +++ b/src/cli/gpuCommands.ts @@ -39,10 +39,10 @@ export const registerGpuCommands = (cli: CliInstance, logger: Logger): void => { .enum(["inventory", "kernels"]) .default("inventory") .describe("Inventory GPU targets or decode selected kernel metadata"), - expected_sha256: z - .string() - .optional() - .describe("Inventory SHA-256; required with --object_ids"), + expected_sha256: + inspectGpuCodeObjectsInputSchema.shape.expected_sha256.describe( + "Inventory SHA-256 as 64 lowercase hex digits; required with --object_ids", + ), object_ids: z .string() .optional() diff --git a/tests/acceptance/gpuCodeObjectsCli.test.ts b/tests/acceptance/gpuCodeObjectsCli.test.ts index 09ca561735..a5f952775c 100644 --- a/tests/acceptance/gpuCodeObjectsCli.test.ts +++ b/tests/acceptance/gpuCodeObjectsCli.test.ts @@ -880,6 +880,36 @@ cliTest( }, ); +cliTest( + "publishes and enforces the lowercase --expected_sha256 digest", + async ({ cli }) => { + const schema = await cli.run({ + arguments: ["inspect-gpu-code-objects", "--schema", "--json"], + }); + expect(schema.exitCode, schema.stderr).toBe(0); + expect(schema.json).toMatchObject({ + options: { + properties: { expected_sha256: { pattern: "^[a-f0-9]{64}$" } }, + }, + }); + // PowerShell's Get-FileHash prints the digest in upper case. + const response = await runFileCli(cli, { + arguments: [ + "inspect-gpu-code-objects", + "/not-opened-for-invalid-selectors", + "--expected_sha256", + "AB".repeat(32), + "--json", + ], + }); + expect(response.exitCode).toBe(1); + expect(response.json).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: [expect.objectContaining({ path: "expected_sha256" })], + }); + }, +); + it("preserves incomplete decoder coverage through MCP target and object pages", async () => { const root = await createTestTempDirectory("rea-gpu-partial-"), { call } = await connectLocalToolsMcp(); From a74ce4512c9cdb6a3dd33c1cf8ddcae41be2e0f6 Mon Sep 17 00:00:00 2001 From: RRFZ <247457381+Plastic-time@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:34:07 -0500 Subject: [PATCH 024/151] fix(cli): reject non-decimal desktop application limits (#9061) inspect-desktop-application took --max_entries and --max_probe_bytes through Number(), so 0x1 examined one entry and 1e1 ten. Admit decimal text only and keep the shared fields' bounds and defaults. --- src/cli/desktopApplicationCommands.ts | 19 ++++---- .../cli/desktopApplicationCli.test.ts | 48 +++++++++++++++++++ 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/cli/desktopApplicationCommands.ts b/src/cli/desktopApplicationCommands.ts index cc0fd32456..7a90547237 100644 --- a/src/cli/desktopApplicationCommands.ts +++ b/src/cli/desktopApplicationCommands.ts @@ -12,6 +12,7 @@ import { absoluteCallerPath } from "../filesystem/callerPath.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; import { withCommandCancellation } from "./commandCancellation.js"; +import { cliDecimalField } from "./options.js"; /** One-shot complete Evidence can be saved and queried with inspect-analysis-view. */ export const registerDesktopApplicationCommands = ( @@ -25,15 +26,17 @@ export const registerDesktopApplicationCommands = ( path: localPathStringSchema.describe("Local installation directory"), }), options: z.object({ - max_entries: - inspectDesktopApplicationInputSchema.shape.max_entries.describe( - "Maximum directory entries to examine; exhaustion leaves partial coverage", - ), + // Decimal tokens only: numeric coercion would read "0x1" as 1 and + // "1e1" as 10. The shared fields keep their bounds and defaults. + max_entries: cliDecimalField( + inspectDesktopApplicationInputSchema.shape.max_entries, + ).describe( + "Maximum directory entries to examine; exhaustion leaves partial coverage", + ), ...analysisEvidenceDeliveryOptions, - max_probe_bytes: - inspectDesktopApplicationInputSchema.shape.max_probe_bytes.describe( - "Maximum total bytes read from selected file ranges", - ), + max_probe_bytes: cliDecimalField( + inspectDesktopApplicationInputSchema.shape.max_probe_bytes, + ).describe("Maximum total bytes read from selected file ranges"), }), run: ({ args, options }) => withCommandCancellation((signal) => diff --git a/tests/boundary/cli/desktopApplicationCli.test.ts b/tests/boundary/cli/desktopApplicationCli.test.ts index 92020943af..1fd42fa3ea 100644 --- a/tests/boundary/cli/desktopApplicationCli.test.ts +++ b/tests/boundary/cli/desktopApplicationCli.test.ts @@ -193,3 +193,51 @@ cliTest.skipIf(process.platform === "win32" || process.getuid?.() === 0)( } }, ); + +// Numeric coercion admits each spelling within the field's bounds: +// "0x1" as 1 entry, "1e1" as 10 entries and "0x1000" as 4096 bytes. +cliTest.for([ + ["max_entries", "0x1"], + ["max_entries", "1e1"], + ["max_probe_bytes", "0x1000"], +] as const)( + "rejects %s %s as a non-decimal desktop limit", + async ([field, value], { cli }) => { + const root = await createTestTempDirectory("rea-desktop-limit-"); + const result = await cli.run({ + arguments: [ + "inspect-desktop-application", + root, + `--${field}=${value}`, + "--json", + ], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: [expect.objectContaining({ path: field })], + }); + }, +); + +cliTest("keeps a decimal desktop entry limit", async ({ cli }) => { + const root = await createTestTempDirectory("rea-desktop-limit-"); + await writeFile(join(root, "a.txt"), "a"); + await writeFile(join(root, "b.txt"), "b"); + const result = await cli.run({ + arguments: [ + "inspect-desktop-application", + root, + "--max_entries=01", + "--json", + ], + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseEvidence(result.json).normalized_result).toMatchObject({ + coverage: { + status: "partial", + entries_examined: 1, + limits: { max_entries: 1 }, + }, + }); +}); From 6993e4ac7361af5477450b0abc4f108a8d9b6878 Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Fri, 6 Nov 2026 14:37:16 +0800 Subject: [PATCH 025/151] fix(managed): explain single-type member capacity exhaustion (#9065) * test(process): align symlink fixture with filesystem checkpoints * fix(managed): explain single-type member capacity exhaustion * test(frida): assert the CLI spawn Evidence contract * test(mcp): use an unsupported analysis-view parent * test(browser): separate source-location line bounds from parity * fix(managed): count selected types in capacity guidance --- src/dotnet/ManagedMemberRecovery.ts | 4 +- src/dotnet/ManagedMemberTypeInventory.test.ts | 45 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/dotnet/ManagedMemberRecovery.ts b/src/dotnet/ManagedMemberRecovery.ts index 00ad4a9a5e..155d007040 100644 --- a/src/dotnet/ManagedMemberRecovery.ts +++ b/src/dotnet/ManagedMemberRecovery.ts @@ -51,7 +51,9 @@ export const prepareManagedMemberRecovery = ( limitations: [ ...inventory.limitations, "Member decoding exceeded capacity after the type inventory was admitted; fields, methods, member references, call edges, field accesses, exported types and user strings are not examined to completion. Empty member collections do not establish absence.", - "Use the returned TypeDef full_name values with type_names (CLI --type) to inspect a smaller selection; a single selected type can still exceed capacity.", + inventory.selection !== null && inventory.types.length === 1 + ? "This single-type selection already reached the member-decoding capacity. Member references and exported types are assembly-wide and still count toward the shared budget." + : "Use the returned TypeDef full_name values with type_names (CLI --type) to inspect a smaller selection; a single selected type can still exceed capacity.", "Metadata tokens are build-local coordinates and require the reported artifact SHA-256 and MVID. Type names and list ranges retain their original metadata and nesting uncertainty.", ], }; diff --git a/src/dotnet/ManagedMemberTypeInventory.test.ts b/src/dotnet/ManagedMemberTypeInventory.test.ts index 6d12a77f00..5a66314462 100644 --- a/src/dotnet/ManagedMemberTypeInventory.test.ts +++ b/src/dotnet/ManagedMemberTypeInventory.test.ts @@ -1,8 +1,11 @@ +import { ManagedDecodeBudget } from "./ManagedDecodeBudget.js"; +import { prepareManagedMemberRecovery } from "./ManagedMemberRecovery.js"; import { expect, it } from "vitest"; import { inspectManagedMembersBytes } from "./ManagedMemberInspector.js"; import { buildManagedPeFixture, + buildManagedNestedTypeFixture, managedPeFixtureTarget, } from "./ManagedPe.fixture.js"; import { buildManagedMemberCapacityFixture } from "../../tests/fixtures/managedMemberCapacity.js"; @@ -97,6 +100,25 @@ it("preserves explicit type selection when its selected body exhausts capacity", }); }); +it("explains capacity exhaustion after a single-type selection", () => { + const bytes = buildManagedMemberCapacityFixture(); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + { + typeNames: ["Fixture.Program"], + }, + ); + expect(result.coverage.state).toBe("partial"); + expect(result.coverage.issues).toContainEqual( + expect.objectContaining({ code: "resource-limit" }), + ); + const guidance = result.limitations.join(" "); + expect(guidance).toMatch(/single.type selection/i); + expect(guidance).toMatch(/assembly.wide/i); + expect(guidance).not.toContain("inspect a smaller selection"); +}); + it("retains types when a field signature exceeds the decoding bound", () => { const bytes = buildManagedPeFixture({ fieldSignature: Buffer.alloc(270_001, 0x06), @@ -376,3 +398,26 @@ it("reads inventory names on demand without admitting TypeDef member lists or Ex ]), ); }); + +it("keeps narrowing advice when one bare name selects multiple nested types", () => { + const bytes = buildManagedNestedTypeFixture(); + const inventory = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + { + typeNames: ["Inner"], + }, + ); + expect(inventory.types).toHaveLength(2); + const recovery = prepareManagedMemberRecovery( + inventory, + [], + new ManagedDecodeBudget(), + )(0); + expect(recovery.limitations.join(" ")).toContain( + "inspect a smaller selection", + ); + expect(recovery.limitations.join(" ")).not.toContain( + "This single-type selection", + ); +}); From d3a4fc87cd2842146f4fb46262799651306b54f7 Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Fri, 6 Nov 2026 14:37:29 +0800 Subject: [PATCH 026/151] fix(cli): stop completion after the option terminator (#9064) * test(process): align symlink fixture with filesystem checkpoints * test(frida): assert the CLI spawn Evidence contract * test(mcp): use an unsupported analysis-view parent * test(browser): separate source-location line bounds from parity * fix(cli): stop completion after the option terminator --- src/cli/completions.ts | 1 + tests/boundary/cli/completions.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/cli/completions.ts b/src/cli/completions.ts index 15ae1c51f9..353ed37c6a 100644 --- a/src/cli/completions.ts +++ b/src/cli/completions.ts @@ -142,6 +142,7 @@ export const createCliCompletions = ( const before = splitIncurBuiltinArguments( normalizeCliArguments(words.slice(1, index)), ).command; + if (before.includes("--")) return true; const input = [cli.name, ...before, words[index] ?? ""]; const doctor = before[0] === "mcp" && before[1] === "doctor"; const candidates = Completions.complete( diff --git a/tests/boundary/cli/completions.test.ts b/tests/boundary/cli/completions.test.ts index cc3f96fb4a..62cbdb868f 100644 --- a/tests/boundary/cli/completions.test.ts +++ b/tests/boundary/cli/completions.test.ts @@ -49,6 +49,7 @@ it("resolves enabled builtin scopes in the same tree as registered commands", () }); it("uses canonical global flag metadata and retains command options and enum hints", () => { + expect(complete(["rea", "instructions", "--", "--he"])).toEqual([]); expect(complete(["rea", "instructions", "--he"])).toEqual(["--help"]); expect(complete(["rea", "instructions", "--for"])).toEqual(["--format"]); expect(complete(["rea", "instructions", "--tok"])).toEqual( From f624c3b8e3bb2afbcd176244707bdf0d2fde1f97 Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Fri, 6 Nov 2026 14:41:44 +0800 Subject: [PATCH 027/151] test(desktop): make Python output fixtures portable (#9066) * test(process): align symlink fixture with filesystem checkpoints * test(frida): assert the CLI spawn Evidence contract * test(mcp): use an unsupported analysis-view parent * test(browser): separate source-location line bounds from parity * test(desktop): make Python output fixtures portable --- .../providers/desktopExportPython.test.ts | 65 ++++++++++++++----- 1 file changed, 48 insertions(+), 17 deletions(-) diff --git a/tests/boundary/providers/desktopExportPython.test.ts b/tests/boundary/providers/desktopExportPython.test.ts index f7938aa320..73a01001bd 100644 --- a/tests/boundary/providers/desktopExportPython.test.ts +++ b/tests/boundary/providers/desktopExportPython.test.ts @@ -1,4 +1,9 @@ -import { execFileSync } from "node:child_process"; +import { + execFileSync, + type ExecFileSyncOptionsWithStringEncoding, +} from "node:child_process"; +import { mkdtempSync, writeFileSync, unlinkSync, rmdirSync } from "node:fs"; +import { tmpdir } from "node:os"; import { chmod, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -18,6 +23,33 @@ import { import { safeParseJson } from "../../../src/domain/safeJson.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +/** Keep the exported Python helpers out of the host command-line size limit. */ +const execPython = ( + args: string[], + options: ExecFileSyncOptionsWithStringEncoding, +): string => { + const directory = mkdtempSync(join(tmpdir(), "rea-export-python-")); + const script = join(directory, "fixture.py"); + const codeIndex = args.indexOf("-c"); + try { + writeFileSync(script, args[codeIndex + 1]!, "utf8"); + return execFileSync( + "python3", + [ + "-X", + "utf8", + ...args.slice(0, codeIndex), + script, + ...args.slice(codeIndex + 2), + ], + options, + ); + } finally { + unlinkSync(script); + rmdirSync(directory); + } +}; + /** The exported module's view format for each file, run with only its standard library imports. */ const pythonFormats = (files: readonly (readonly [string, string])[]) => { const imports = PYTHON_IMPORTS.split("\n") @@ -27,12 +59,12 @@ const pythonFormats = (files: readonly (readonly [string, string])[]) => { for name, text in json.load(sys.stdin): print(_text_view(name, text, len(text.encode()))["format"]) `; - return execFileSync("python3", ["-I", "-B", "-c", program], { + return execPython(["-I", "-B", "-c", program], { input: JSON.stringify(files), encoding: "utf8", }) .trim() - .split("\n"); + .split(/\r?\n/u); }; it("detects key files by the state view's rule rather than a name or any group header", () => { @@ -79,17 +111,18 @@ const pythonUtf16Respelled = (files: readonly string[]) => { .filter((line) => !line.includes("agentenv_protocol")) .join("\n"); const program = `${imports} +from pathlib import PurePosixPath SEED_HOME = "/home/ada" ${PYTHON_HELPERS} for data in json.load(sys.stdin): - print(_respelled_utf16(bytes.fromhex(data), Path("/home/user")).hex()) + print(_respelled_utf16(bytes.fromhex(data), PurePosixPath("/home/user")).hex()) `; - return execFileSync("python3", ["-I", "-B", "-c", program], { + return execPython(["-I", "-B", "-c", program], { input: JSON.stringify(files), encoding: "utf8", }) .trim() - .split("\n"); + .split(/\r?\n/u); }; const utf16 = (text: string, order: "le" | "be") => { @@ -143,13 +176,13 @@ const taskbarWindow = (bin: string, windowClass: string): string => { const imports = PYTHON_IMPORTS.split("\n") .filter((line) => !line.includes("agentenv_protocol")) .join("\n"); - return execFileSync( - "python3", + return execPython( [ "-I", "-B", "-c", - `${imports}\nSEED_HOME = "/home/ada"\n${PYTHON_HELPERS}\nprint(_taskbar_window(sys.argv[1]))`, + `${imports}\nfrom pathlib import PurePosixPath +SEED_HOME = "/home/ada"\n${PYTHON_HELPERS}\nprint(_taskbar_window(sys.argv[1]))`, windowClass, ], { @@ -195,12 +228,12 @@ const pythonJson = (texts: readonly string[]) => { for text in json.load(sys.stdin): print(json.dumps(_text_view("a.json", text, len(text.encode())))) `; - return execFileSync("python3", ["-I", "-B", "-c", program], { + return execPython(["-I", "-B", "-c", program], { input: JSON.stringify(texts), encoding: "utf8", }) .trim() - .split("\n") + .split(/\r?\n/u) .map((line) => JSON.parse(line) as { view: unknown; error?: string }); }; @@ -256,7 +289,7 @@ for name, text in json.load(sys.stdin): view = _text_view(name, text, len(text.encode())) print(json.dumps({"format": view["format"], "view": view["view"], "error": view.get("error")})) `; - const python = execFileSync("python3", ["-I", "-B", "-c", program], { + const python = execPython(["-I", "-B", "-c", program], { input: JSON.stringify(files), encoding: "utf8", maxBuffer: 64 * 1024 * 1024, @@ -336,8 +369,7 @@ it("checks text for JSON the way REA does, token by token, without parsing it", .join("\n"); const python = JSON.parse( - execFileSync( - "python3", + execPython( [ "-I", "-B", @@ -373,7 +405,7 @@ print(json.dumps({"deep": deep, "peak": peak, "edge": _deeper_than(nested, 501), `; const result = JSON.parse( - execFileSync("python3", ["-I", "-B", "-c", program], { encoding: "utf8" }), + execPython(["-I", "-B", "-c", program], { encoding: "utf8" }), ) as { deep: boolean; peak: number; edge: boolean; over: boolean }; expect(result).toMatchObject({ deep: false, edge: false, over: true }); @@ -385,8 +417,7 @@ it("spells infinite and negative-zero SQLite reals as REA's SQLite reader does", const imports = PYTHON_IMPORTS.split("\n") .filter((line) => !line.includes("agentenv_protocol")) .join("\n"); - const cells = execFileSync( - "python3", + const cells = execPython( [ "-I", "-B", From 6571be631adccb6399d727acf23dc6d049ce8f1f Mon Sep 17 00:00:00 2001 From: Hasan Zayni <36798269+dark13b@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:51:47 +0300 Subject: [PATCH 028/151] fix(decompile): keep the output failure streak across external functions (#7924) * fix(decompile): keep the output failure streak across external functions An external function writes no file, but the streak counter treated its outcome as a success and reset to zero. With external functions interleaved between unwritable local functions, decompile_all never reached the documented five-failure stop. The streak now ignores external outcomes. Fixes #7374 * style(decompile): restore the repository formatter output oxfmt --check rejected decompileAll.ts after a local prettier pass reflowed an unrelated type alias. Restore it so the diff is only the streak change. * ci: retrigger after artifact-download ECONNRESET Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Sonnet 5.5 --- .../decompileDirectory/decompileAll.test.ts | 32 +++++++++++++++++++ .../decompileDirectory/decompileAll.ts | 11 ++++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/application/decompileDirectory/decompileAll.test.ts b/src/application/decompileDirectory/decompileAll.test.ts index c4623c57d7..3f9be556a2 100644 --- a/src/application/decompileDirectory/decompileAll.test.ts +++ b/src/application/decompileDirectory/decompileAll.test.ts @@ -1817,6 +1817,38 @@ describe("decompile_all repeated output failures", () => { ).toBe("written"); }); + it("keeps counting output_io failures across external functions", async () => { + const mixed = Array.from({ length: 11 }, (_, index) => ({ + address: `0x${(0x1000 + index * 0x10).toString(16)}`, + name: `f${index.toString()}`, + })); + const at = (parity: number) => + mixed.filter((_, index) => index % 2 === parity); + expect( + (await run(harness({ procedures: mixed }).tools, directory())).ok, + ).toBe(true); + // Locals cannot be read back; externals have no file to resume from. + await blockFiles(at(0).map(({ address }) => address)); + for (const { address } of at(1)) + await rm(join(directory(), `functions/${address}.c`)); + const stopped = await run( + harness({ + procedures: mixed, + external: at(1).map(({ address }) => address), + }).tools, + directory(), + { resume: true }, + ); + expect(stopped.ok).toBe(false); + if (stopped.ok) return; + expect(stopped.error).toBeInstanceOf(AnalysisOutputError); + expect(stopped.error.diagnostics).toMatchObject({ + failed: 5, + external: 4, + consecutive_output_io: 5, + }); + }); + it("does not stop when a verified file interrupts the failures", async () => { expect((await run(harness({ procedures }).tools, directory())).ok).toBe( true, diff --git a/src/application/decompileDirectory/decompileAll.ts b/src/application/decompileDirectory/decompileAll.ts index cdf5f57857..e7a937857e 100644 --- a/src/application/decompileDirectory/decompileAll.ts +++ b/src/application/decompileDirectory/decompileAll.ts @@ -410,13 +410,16 @@ const publishSelected = async ( continue; input.state.attempted += 1; input.state.lastFailureCode = undefined; + const externalBefore = input.state.external; const outcome = await attemptOne(input, procedure, existing, bodySupport); bodySupport = outcome.bodySupport; if (!outcome.result.ok) return outcome.result; - input.state.outputIoStreak = - input.state.lastFailureCode === "output_io" - ? input.state.outputIoStreak + 1 - : 0; + // An external function writes no file, so it says nothing about the directory. + if (input.state.external === externalBefore) + input.state.outputIoStreak = + input.state.lastFailureCode === "output_io" + ? input.state.outputIoStreak + 1 + : 0; // Repeated write failures mean the directory has stopped accepting files, // for example a full disk. Stop instead of decompiling every remaining // function only to fail its write. Committed files and records are kept. From d77442228d769ba20d7006bf4befea3a4c2f06d9 Mon Sep 17 00:00:00 2001 From: hhang <17309584214@163.com> Date: Fri, 6 Nov 2026 14:52:32 +0800 Subject: [PATCH 029/151] fix(desktop): align exported XML formatting whitespace (#9020) * fix(desktop): align exported XML formatting whitespace * test(process): align symlink comparison snapshots * test(boundary): align inherited CLI and MCP fixtures --------- Co-authored-by: hhang <169027046+xu91102@users.noreply.github.com> --- docs/desktop-application-state.md | 3 + src/domain/desktop/appExportPythonXml.ts | 5 +- .../desktopXmlWhitespacePython.test.ts | 59 +++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 tests/boundary/providers/desktopXmlWhitespacePython.test.ts diff --git a/docs/desktop-application-state.md b/docs/desktop-application-state.md index 605d43c728..7ab24697a0 100644 --- a/docs/desktop-application-state.md +++ b/docs/desktop-application-state.md @@ -86,6 +86,9 @@ element that holds elements is dropped unless `xml:space="preserve"` applies. The view keeps up to 5,000 nodes, comments and processing instructions counted, nested up to 64 levels, and sets `truncated` when it leaves some out. +Exported XML views use REA's same formatting-whitespace classification, so +retained XML text and the task checks that address it stay aligned. + SQLite databases are read through REA's SQLite worker from a private copy, with any retained `-wal` or `-journal` file staged beside it; the worker accepts an inactive rollback journal and refuses a hot one. A version is not diff --git a/src/domain/desktop/appExportPythonXml.ts b/src/domain/desktop/appExportPythonXml.ts index fadd5d40d3..30c7571c48 100644 --- a/src/domain/desktop/appExportPythonXml.ts +++ b/src/domain/desktop/appExportPythonXml.ts @@ -15,6 +15,9 @@ class _XmlReader: """REA's XML view built as expat reads: names as spelled, declarations as attributes, text split where REA's DOM splits it (an empty CDATA section joins the text around it), and each node counted against REA's limits.""" + #: Match REA String.trim(); default strip also drops U+0085 and keeps U+FEFF. + FORMATTING_SPACE = "\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff" + def __init__(self) -> None: self.remaining, self.truncated, self.root = MAX_XML_NODES, False, None #: Per open element: its view or None when left out, whether xml:space="preserve" applies inside it, and @@ -83,7 +86,7 @@ class _XmlReader: self._flush() view, preserve, holds_element = self.open.pop() if view is not None and holds_element and not preserve: - view["children"] = [child for child in view["children"] if not isinstance(child, str) or child.strip()] + view["children"] = [child for child in view["children"] if not isinstance(child, str) or child.strip(self.FORMATTING_SPACE)] if not self.open: self.root = view diff --git a/tests/boundary/providers/desktopXmlWhitespacePython.test.ts b/tests/boundary/providers/desktopXmlWhitespacePython.test.ts new file mode 100644 index 0000000000..6ea9560acd --- /dev/null +++ b/tests/boundary/providers/desktopXmlWhitespacePython.test.ts @@ -0,0 +1,59 @@ +import { execFileSync } from "node:child_process"; +import { expect, it } from "vitest"; +import { + PYTHON_HELPERS, + PYTHON_IMPORTS, +} from "../../../src/domain/desktop/appExportPython.js"; +import { xmlView } from "../../../src/domain/desktop/stateFormats.js"; + +/** Execute the unmodified generated XML reader with its standard-library imports. */ +const pythonViews = (texts: readonly string[]) => { + const imports = PYTHON_IMPORTS.split("\n") + .filter((line) => !line.includes("agentenv_protocol")) + .join("\n"); + return JSON.parse( + execFileSync( + "python3", + [ + "-I", + "-B", + "-c", + `${imports}${PYTHON_HELPERS}\nprint(json.dumps([_xml_view(text) for text in json.load(sys.stdin)]))`, + ], + { input: JSON.stringify(texts), encoding: "utf8" }, + ), + ); +}; + +it.each(["\u0085", "\ufeff"])( + "matches REA's formatting decision for %j between elements", + (text) => { + const xml = `${text}`; + expect(pythonViews([xml])).toEqual([xmlView(xml)]); + }, +); + +it("matches the ECMAScript whitespace set for XML-valid characters and preserves other text", () => { + const characters = + "\t\n\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff\u0085\u180e\u200b"; + const texts = [...characters].map((text) => `${text}`); + expect(pythonViews(texts)).toEqual(texts.map(xmlView)); +}); + +it("keeps leaf text and xml:space-preserved text, with the original parsing refusals", () => { + const texts = [ + "\ufeff", + "\u0085", + '\ufeff', + '\u0085', + ]; + const actual = pythonViews(texts) as { root: { children: unknown[] } }[]; + const expected = texts.map(xmlView) as { root: { children: unknown[] } }[]; + expect(actual.map((view) => view.root.children)).toEqual( + expected.map((view) => view.root.children), + ); + expect(pythonViews(["", ""])).toEqual([ + null, + null, + ]); +}); From c9797c5dd5b8316715bdbb92481ebd9f24c0fd64 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Fri, 6 Nov 2026 06:52:42 +0000 Subject: [PATCH 030/151] fix(native): keep inspect_macho usable when dyld_info refuses the slice (#9034) * fix(native): keep inspect_macho usable when dyld_info refuses the slice dyld_info exits 1 with 'unknown platform' for Mach-O files without a platform load command and with 'old arm64e bind opcodes not supported' for pre-ABI arm64e binaries. inspect_macho then failed outright with the generic execution_failure remediation although nm, otool and the load table still describe the slice. Treat these two refusals like the missing-UUID refusal: imports are reported unavailable, exports keep the nm observations, and the dyld_info diagnostic is recorded as a limitation. * test(native): cover dyld refusal fallback and limitations --- src/native/NativeMachoCommandDiagnostics.ts | 26 +++++++-- src/native/NativeMachoInspection.ts | 53 ++++++++++--------- .../nativeMachoUuidlessInspection.test.ts | 44 +++++++++++++++ 3 files changed, 95 insertions(+), 28 deletions(-) diff --git a/src/native/NativeMachoCommandDiagnostics.ts b/src/native/NativeMachoCommandDiagnostics.ts index 0a688060f3..536949a6dc 100644 --- a/src/native/NativeMachoCommandDiagnostics.ts +++ b/src/native/NativeMachoCommandDiagnostics.ts @@ -33,6 +33,12 @@ export const isVtoolContainerRefusal = ( const MISSING_UUID_DIAGNOSTIC = "missing LC_UUID load command"; +/** dyld_info refusals of a slice that nm and the load table still describe. */ +const UNSUPPORTED_SLICE_DIAGNOSTICS: ReadonlySet = new Set([ + "unknown platform", + "old arm64e bind opcodes not supported", +]); + const dyldFailureDiagnostics = ( capture: NativeCommandCapture, outputs: readonly string[], @@ -67,6 +73,17 @@ export const isMissingUuidDyldFailure = ( capture.stderr.trim().length === 0 && dyldFailureDiagnostic(capture) === MISSING_UUID_DIAGNOSTIC; +/** The sole diagnostic when dyld_info refuses the slice itself; nm and the load table still describe it. */ +export const dyldSliceRefusal = ( + capture: NativeCommandCapture, +): string | null => { + if (isMissingUuidDyldFailure(capture)) return MISSING_UUID_DIAGNOSTIC; + const diagnostic = dyldFailureDiagnostic(capture); + return diagnostic !== null && UNSUPPORTED_SLICE_DIAGNOSTICS.has(diagnostic) + ? diagnostic + : null; +}; + /** Admit the observed dSYM refusal only when no other diagnostic is present. */ export const isUnbackedTextDyldFailure = ( capture: NativeCommandCapture, @@ -106,10 +123,13 @@ export const nativeMachoCapturedOutput = ( }); /** Keep the exact target diagnostic beside the incomplete collection. */ -export const missingUuidLimitation = (mode: "imports" | "exports"): string => +export const dyldRefusalLimitation = ( + mode: "imports" | "exports", + diagnostic: string, +): string => mode === "imports" - ? "dyld_info -imports reported 'missing LC_UUID load command'; import observations are unavailable." - : "dyld_info -exports reported 'missing LC_UUID load command'; exports include nm symbol-table observations only."; + ? `dyld_info -imports reported '${diagnostic}'; import observations are unavailable.` + : `dyld_info -exports reported '${diagnostic}'; exports include nm symbol-table observations only.`; /** Preserve unrecognized nonzero command output as an inspection failure. */ export const unusableNativeCommandOutput = ( diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index 9f0b29e7ad..55f363b0ab 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -45,7 +45,8 @@ import { isVtoolContainerRefusal, nativeCommandOperand, nativeMachoCapturedOutput, - missingUuidLimitation, + dyldRefusalLimitation, + dyldSliceRefusal, parseDyldFileTypeRefusal, unusableNativeCommandOutput, } from "./NativeMachoCommandDiagnostics.js"; @@ -164,7 +165,7 @@ export const inspectNativeMacho = async ( context.signal, tool === "dyld_info" ? (capture) => - isMissingUuidDyldFailure(capture) || + dyldSliceRefusal(capture) !== null || isUnbackedTextDyldFailure(capture) || parseDyldFileTypeRefusal(capture) !== null : tool === "vtool" && format === "fat" @@ -188,7 +189,7 @@ export const inspectNativeMacho = async ( if (!captured.ok) return err(captured.error); if ( (captured.value.exitCode !== 0 || captured.value.signal !== null) && - !isMissingUuidDyldFailure(captured.value) && + dyldSliceRefusal(captured.value) === null && !isUnbackedTextDyldFailure(captured.value) && parseDyldFileTypeRefusal(captured.value) === null ) @@ -300,7 +301,7 @@ export const inspectNativeMacho = async ( // Only then does llvm-nm's dyld-info pass add names, and its exact names // disambiguate dyld_info rows. Refusals above are not export rows. if ( - !isMissingUuidDyldFailure(dyldExports) && + dyldSliceRefusal(dyldExports) === null && !namesEveryExport(captures[nmIndex]!.stdout, dyldExports) ) { const withDyldInfo = await runSliceCommand(DYLD_INFO_EXPORTS); @@ -320,10 +321,7 @@ export const inspectNativeMacho = async ( sliceOutput(dyldImports), hasChainedFixups, ); - if ( - !isMissingUuidDyldFailure(dyldImports) && - parsedImports.needsNameInventory - ) { + if (parsedImports.needsNameInventory) { const importNames = await runSliceCommand(SYMBOL_TABLE_IMPORTS); if (!importNames.ok) return err(importNames.error); captures.push(importNames.value); @@ -444,9 +442,10 @@ const normalizeMacho = ( const byTool = captureLookup(captures); const dyldImportsCapture = byTool("dyld_info", 0); const dyldExportsCapture = byTool("dyld_info", 1); - const missingImportsUuid = isMissingUuidDyldFailure(dyldImportsCapture); - const missingExportsUuid = isMissingUuidDyldFailure(dyldExportsCapture); - const imports = missingImportsUuid ? [] : parsedImports.map(withRuntimeName); + const importsRefusal = dyldSliceRefusal(dyldImportsCapture); + const exportsRefusal = dyldSliceRefusal(dyldExportsCapture); + const imports = + importsRefusal === null ? parsedImports.map(withRuntimeName) : []; const imageBase = load.segments.find( (segment) => @@ -456,13 +455,11 @@ const normalizeMacho = ( segment.file_size <= sliceSize, )?.vm_address ?? null; const nmExports = parseNmExports(exportNames.stdout); - const dyldExports = missingExportsUuid - ? [] - : parseDyldExports( - sliceOutput(dyldExportsCapture), - imageBase, - new Set(nmExports.map(({ name }) => name)), - ); + const dyldExports = parseDyldExports( + sliceOutput(dyldExportsCapture), + imageBase, + new Set(nmExports.map(({ name }) => name)), + ); const exports = uniqueSymbols([...dyldExports, ...nmExports]).map( withRuntimeName, ); @@ -493,8 +490,12 @@ const normalizeMacho = ( ] : []), ...additionalLimitations, - ...(missingImportsUuid ? [missingUuidLimitation("imports")] : []), - ...(missingExportsUuid ? [missingUuidLimitation("exports")] : []), + ...(importsRefusal === null + ? [] + : [dyldRefusalLimitation("imports", importsRefusal)]), + ...(exportsRefusal === null + ? [] + : [dyldRefusalLimitation("exports", exportsRefusal)]), ...uuid.limitations, ...(imageBase === null && /^\s*offset\s+symbol\s*$/mu.test(sliceOutput(byTool("dyld_info", 1))) @@ -532,7 +533,9 @@ const normalizeMacho = ( ), imports: covered(imports, false, [ "dyld_info textual imports may omit chained or toolchain-unsupported metadata.", - ...(missingImportsUuid ? [missingUuidLimitation("imports")] : []), + ...(importsRefusal === null + ? [] + : [dyldRefusalLimitation("imports", importsRefusal)]), ...(imports.some(({ name_status }) => name_status === "ambiguous") ? [ "Import text contains name, flags, library, or addend boundaries whose split is ambiguous.", @@ -540,11 +543,11 @@ const normalizeMacho = ( : []), ]), exports: covered(exports, false, [ - ...(missingExportsUuid - ? [missingUuidLimitation("exports")] - : [ + ...(exportsRefusal === null + ? [ "Merged nm/dyld_info results may be incomplete for stripped binaries.", - ]), + ] + : [dyldRefusalLimitation("exports", exportsRefusal)]), ...(dyldExports.some(({ sourceSymbol }) => sourceSymbol !== undefined) ? [ "Renamed re-exports are listed under their exported name; the source library's symbol name is not reported.", diff --git a/tests/boundary/providers/native/nativeMachoUuidlessInspection.test.ts b/tests/boundary/providers/native/nativeMachoUuidlessInspection.test.ts index 87d2c21443..0fe9ecb241 100644 --- a/tests/boundary/providers/native/nativeMachoUuidlessInspection.test.ts +++ b/tests/boundary/providers/native/nativeMachoUuidlessInspection.test.ts @@ -325,3 +325,47 @@ it("delivers partial UUID-less inspection Evidence through production MCP", asyn }, }); }); + +it.each(["unknown platform", "old arm64e bind opcodes not supported"])( + "keeps nm exports and the load table when dyld_info refuses the slice with %s", + async (diagnostic) => { + const target = await makeTarget("refused-slice", 2, true); + const runner = new UuidlessDyldRunner(diagnostic); + const result = await new NativeMacOSProvider({}, runner, "darwin") + .createClient(target) + .execute("inspect_macho", {}); + + expect(result.ok, JSON.stringify(result.ok ? null : result.error)).toBe( + true, + ); + if (!result.ok) return; + const importsLimitation = `dyld_info -imports reported '${diagnostic}'; import observations are unavailable.`; + const exportsLimitation = `dyld_info -exports reported '${diagnostic}'; exports include nm symbol-table observations only.`; + expect(result.value.result).toMatchObject({ + load_commands: { exhaustive: true }, + imports: { + items: [], + total: null, + exhaustive: false, + limitations: expect.arrayContaining([importsLimitation]), + }, + exports: { + items: expect.arrayContaining([ + expect.objectContaining({ name: "_main", source: "nm" }), + ]), + total: null, + exhaustive: false, + limitations: expect.arrayContaining([exportsLimitation]), + }, + }); + expect(result.value.limitations).toEqual( + expect.arrayContaining([importsLimitation, exportsLimitation]), + ); + expect(runner.commands.filter(({ tool }) => tool === "nm")).toEqual([ + { + tool: "nm", + args: ["-gjU", "--no-dyldinfo", "-arch", "arm64", target.path], + }, + ]); + }, +); From 6430f395a8c4d3784bc03d9cf050a70e9e9e318a Mon Sep 17 00:00:00 2001 From: RRFZ <247457381+Plastic-time@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:52:53 -0500 Subject: [PATCH 031/151] fix(cli): reject non-decimal pyc and BEAM limit options (#9060) inspect-python-pyc and inspect-erlang-beam took their max-* limits through Number(), so 0x10 meant 16 and 1e2 meant 100. Admit decimal text only through cliDecimalField, which now keeps the shared field's description for help. --- src/cli/erlangCommands.ts | 10 ++-- src/cli/options.ts | 8 +++- src/cli/pythonPycCommands.ts | 17 +++++-- .../boundary/cli/cliIntegerArguments.test.ts | 46 +++++++++++++++++++ 4 files changed, 72 insertions(+), 9 deletions(-) diff --git a/src/cli/erlangCommands.ts b/src/cli/erlangCommands.ts index 9ec3113c8b..096dc9eac4 100644 --- a/src/cli/erlangCommands.ts +++ b/src/cli/erlangCommands.ts @@ -15,6 +15,7 @@ import { } from "./analysisEvidenceDelivery.js"; import { erlangBeamDeliverySchema } from "../domain/analysisView/erlangBeamView.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { cliDecimalField } from "./options.js"; /** Inspect operator-selected BEAM files through the same shared workflow as MCP. */ export const registerErlangCommands = ( @@ -28,9 +29,12 @@ export const registerErlangCommands = ( path: localPathStringSchema.describe("Local BEAM file"), }), options: z.object({ - maxFileBytes: inspectErlangBeamInputSchema.shape.max_file_bytes, - maxChunks: inspectErlangBeamInputSchema.shape.max_chunks, - maxAtoms: inspectErlangBeamInputSchema.shape.max_atoms, + // Decimal tokens only, as for inspect-python-pyc. + maxFileBytes: cliDecimalField( + inspectErlangBeamInputSchema.shape.max_file_bytes, + ), + maxChunks: cliDecimalField(inspectErlangBeamInputSchema.shape.max_chunks), + maxAtoms: cliDecimalField(inspectErlangBeamInputSchema.shape.max_atoms), ...analysisEvidenceDeliveryOptions, view: z .string() diff --git a/src/cli/options.ts b/src/cli/options.ts index 4431b8cb5f..071bdc23c3 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -20,7 +20,7 @@ export const cliIntegerArgument = (min: number) => .regex(/^[0-9]+$/u, "Expected a decimal integer") .pipe(z.coerce.number().safe().min(min)); -/** Parse a decimal token, then let the canonical numeric field own bounds and defaults. */ +/** Parse a decimal token, then let the canonical numeric field own bounds, defaults and help. */ export function cliDecimalField( field: z.ZodDefault, ): z.ZodType; @@ -36,7 +36,11 @@ export function cliDecimalField( .pipe(z.coerce.number()) .pipe(field.unwrap()); const omitted = field.parse(undefined); - return omitted === undefined ? parsed.optional() : parsed.default(omitted); + const admitted = + omitted === undefined ? parsed.optional() : parsed.default(omitted); + return field.description === undefined + ? admitted + : admitted.describe(field.description); } export const providerSelectionOption = analysisProviderSelectorSchema diff --git a/src/cli/pythonPycCommands.ts b/src/cli/pythonPycCommands.ts index 4caaabd6b8..d9c4a7659a 100644 --- a/src/cli/pythonPycCommands.ts +++ b/src/cli/pythonPycCommands.ts @@ -15,6 +15,7 @@ import { } from "./analysisEvidenceDelivery.js"; import { pythonPycDeliverySchema } from "../domain/analysisView/pythonPycView.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { cliDecimalField } from "./options.js"; /** Inspect operator-selected pyc files through the same shared workflow as MCP. */ export const registerPythonPycCommands = ( @@ -28,10 +29,18 @@ export const registerPythonPycCommands = ( path: localPathStringSchema.describe("Local CPython .pyc file"), }), options: z.object({ - maxFileBytes: inspectPythonPycInputSchema.shape.max_file_bytes, - maxObjects: inspectPythonPycInputSchema.shape.max_objects, - maxDepth: inspectPythonPycInputSchema.shape.max_depth, - maxStringBytes: inspectPythonPycInputSchema.shape.max_string_bytes, + // Decimal tokens only: numeric coercion would read "0x10" as 16 and + // "1e2" as 100. The shared fields keep their bounds and defaults. + maxFileBytes: cliDecimalField( + inspectPythonPycInputSchema.shape.max_file_bytes, + ), + maxObjects: cliDecimalField( + inspectPythonPycInputSchema.shape.max_objects, + ), + maxDepth: cliDecimalField(inspectPythonPycInputSchema.shape.max_depth), + maxStringBytes: cliDecimalField( + inspectPythonPycInputSchema.shape.max_string_bytes, + ), ...analysisEvidenceDeliveryOptions, view: z .string() diff --git a/tests/boundary/cli/cliIntegerArguments.test.ts b/tests/boundary/cli/cliIntegerArguments.test.ts index 9d15641520..d489ae71d1 100644 --- a/tests/boundary/cli/cliIntegerArguments.test.ts +++ b/tests/boundary/cli/cliIntegerArguments.test.ts @@ -250,6 +250,52 @@ describe("byte-entropy window options", () => { ); }); +describe("pyc and BEAM limit options", () => { + // One option takes every malformed spelling. Each sibling takes one + // spelling that numeric coercion admits within the field's bounds. + for (const [command, flag, field, values] of [ + ["inspect-python-pyc", "--max-depth", "maxDepth", NON_INTEGER_OPTION_TEXT], + ["inspect-python-pyc", "--max-file-bytes", "maxFileBytes", ["0x10"]], + ["inspect-python-pyc", "--max-objects", "maxObjects", ["1e2"]], + ["inspect-python-pyc", "--max-string-bytes", "maxStringBytes", ["0x1"]], + ["inspect-erlang-beam", "--max-file-bytes", "maxFileBytes", ["0x10"]], + ["inspect-erlang-beam", "--max-chunks", "maxChunks", ["1e1"]], + ["inspect-erlang-beam", "--max-atoms", "maxAtoms", ["0b1"]], + ] as const) { + cliTest.for(values)( + `rejects %j as ${command} ${flag}`, + async (value, { cli }) => { + const result = await runFileCli(cli, { + arguments: [ + command, + "/nonexistent/missing-artifact", + `${flag}=${value}`, + "--json", + ], + }); + + expectCliValidationError(result, field); + }, + ); + } + + cliTest.for([ + ["inspect-python-pyc", "--max-depth=016"], + ["inspect-erlang-beam", "--max-chunks=016"], + ] as const)("keeps a decimal %s limit", async ([command, flag], { cli }) => { + const result = await runFileCli(cli, { + arguments: [command, "/nonexistent/missing-artifact", flag, "--json"], + }); + + // The limit is admitted; the missing file is refused afterwards. + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ + code: "invalid_request", + details: { issues: [expect.objectContaining({ path: ["path"] })] }, + }); + }); +}); + describe("artifact decimal options", () => { // Same admission as byte-entropy windows. One option takes every malformed // spelling; siblings in artifactCommands.ts take one coercion each. From 08bc52104a275c0e2693ac6f952337a54e30f496 Mon Sep 17 00:00:00 2001 From: gongfpp <1360187968@qq.com> Date: Fri, 6 Nov 2026 14:53:03 +0800 Subject: [PATCH 032/151] fix(cli): preserve Unicode edge whitespace in Markdown tables (#8915) * fix(cli): preserve Unicode edge whitespace in Markdown tables * fix(cli): bound Markdown whitespace encoding to linear time --------- Co-authored-by: gongfpp <22683417+gongfpp@users.noreply.github.com> --- src/cli/cliOutputFormatter.ts | 23 ++++++-- tests/boundary/cli/cliOutputFormatter.test.ts | 26 ++++++++- .../cli/markdownArgumentErrors.test.ts | 5 +- .../boundary/cli/markdownLiteralCells.test.ts | 55 +++++++++++++++++++ 4 files changed, 101 insertions(+), 8 deletions(-) diff --git a/src/cli/cliOutputFormatter.ts b/src/cli/cliOutputFormatter.ts index 9553ec6901..7da8b79496 100644 --- a/src/cli/cliOutputFormatter.ts +++ b/src/cli/cliOutputFormatter.ts @@ -344,7 +344,7 @@ const escapeTableCell = (value: string): string => .replace(/\r\n|\r|\n/g, "
"); const encodeMarkdownWhitespace = (whitespace: string): string => - whitespace.replace(/ /g, " ").replace(/\t/g, " "); + whitespace.replace(/\s/g, (character) => `&#${character.charCodeAt(0)};`); /** Heading and paragraph facts are literal data, including block syntax. */ const escapeMarkdownText = (value: string): string => @@ -357,11 +357,24 @@ const escapeMarkdownText = (value: string): string => const markdownHeading = (path: readonly string[]): string => `## ${escapeMarkdownText(path.join("."))}\n\n`; -/** Encode literal text for a Markdown table cell, including edge whitespace. */ -export const encodeMarkdownTableCell = (value: string): string => - escapeTableCell(value).replace(/^[ \t]+|[ \t]+$/g, (whitespace) => - encodeMarkdownWhitespace(whitespace), +const markdownEdgeWhitespace = /[\t \p{Z}\ufeff]/u; + +/** Encode edge spacing that Markdown can represent as character references. */ +export const encodeMarkdownTableCell = (value: string): string => { + const escaped = escapeTableCell(value); + let start = 0; + let end = escaped.length; + // Scan each edge once instead of retrying a trailing match at every suffix. + while (start < end && markdownEdgeWhitespace.test(escaped[start]!)) + start += 1; + while (end > start && markdownEdgeWhitespace.test(escaped[end - 1]!)) + end -= 1; + return ( + encodeMarkdownWhitespace(escaped.slice(0, start)) + + escaped.slice(start, end) + + encodeMarkdownWhitespace(escaped.slice(end)) ); +}; const renderTable = ( headers: readonly string[], diff --git a/tests/boundary/cli/cliOutputFormatter.test.ts b/tests/boundary/cli/cliOutputFormatter.test.ts index ba584ae286..7aba1e58ab 100644 --- a/tests/boundary/cli/cliOutputFormatter.test.ts +++ b/tests/boundary/cli/cliOutputFormatter.test.ts @@ -1,6 +1,30 @@ import { expect, it } from "vitest"; -import { formatDelimitedRows } from "../../../src/cli/cliOutputFormatter.js"; +import { + encodeMarkdownTableCell, + formatDelimitedRows, +} from "../../../src/cli/cliOutputFormatter.js"; + +it.each([ + ["", ""], + ["plain", "plain"], + ["a\u00a0b", "a\u00a0b"], + [ + " \t\u00a0\u202f\u2003\u2028\u2029\ufeff", + "    

", + ], + ["\u00a0🙂\ufeff", " 🙂"], + [" \t name\u00a0", " &\\#160;name "], + ["\u00a0\u0000\u202f", " \\\\u0000 "], + ["\u00a0\r\n\u202f", " 
 "], + ["\v \u00a0x\u202f \f", "\v \u00a0x\u202f \f"], + [" \v\u00a0x\u202f\f ", " \v\u00a0x\u202f\f "], +])( + "encodes only representable Markdown table edge spacing in %j", + (value, expected) => { + expect(encodeMarkdownTableCell(value)).toBe(expected); + }, +); const parseDelimited = (source: string, delimiter: string): string[][] => { const records: string[][] = []; diff --git a/tests/boundary/cli/markdownArgumentErrors.test.ts b/tests/boundary/cli/markdownArgumentErrors.test.ts index e7cc862641..351df7e179 100644 --- a/tests/boundary/cli/markdownArgumentErrors.test.ts +++ b/tests/boundary/cli/markdownArgumentErrors.test.ts @@ -212,7 +212,7 @@ it("redacts Markdown punctuation in validation fields before presentation", asyn }); it("retains upstream Markdown literal-cell protections after redaction", async () => { - const value = "\t:smile: secret|token # {x}\t"; + const value = "\u00a0\t:smile: secret|token # {x}\t\u202f"; const secret = "secret|token"; const cli = Cli.create("literal-validation-fixture", { sync: false }); cli.command("inspect", { @@ -265,7 +265,8 @@ it("retains upstream Markdown literal-cell protections after redaction", async ( const redactedMd = await run("md", true); const json = JSON.parse(await run("json", false)); const redactedJson = JSON.parse(await run("json", true)); - expect(md).toContain(" "); + expect(md).toContain("  "); + expect(md).toContain("  "); expect(md).toContain(":smile:"); expect(md).toContain("secret\\|token"); expect(redactedMd).not.toContain("secret\\|token"); diff --git a/tests/boundary/cli/markdownLiteralCells.test.ts b/tests/boundary/cli/markdownLiteralCells.test.ts index 9c3a8f2758..9fb142afee 100644 --- a/tests/boundary/cli/markdownLiteralCells.test.ts +++ b/tests/boundary/cli/markdownLiteralCells.test.ts @@ -97,6 +97,18 @@ cliTest( "trailing ": "trailing ", "\tleading tab": "trailing tab\t", " mixed\t ": " mixed\t ", + "\u00a0name": "\u00a0value\u00a0", + name: "value", + "\u202fname": "\u202fvalue\u202f", + "\u2003name": "\u2003value\u2003", + "\u2028name": "\u2028value\u2028", + "\u2029name": "\u2029value\u2029", + "\ufeffname": "\ufeffvalue\ufeff", + "\u00a0": "\u00a0", + "\u202f\u2003": "\u202f\u2003", + " \t\u00a0mixed\u2003\t ": " \t\u00a0mixed\u2003\t ", + "internal\u00a0name": "internal\u00a0value", + " name": " value", " ": " ", " ": " ", "": "", @@ -172,6 +184,49 @@ cliTest( }, ); +cliTest( + "renders long internal Unicode whitespace within the CLI deadline", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-markdown-long-whitespace-"); + const path = join(root, "bundle.json"); + const literal = `left${"\u00a0\u202f\u2003".repeat(80_000)}right`; + const record = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "ordinary-json", + parameters: {}, + result: { rows: [{ [literal]: literal }] }, + }, + ); + const saved = JSON.stringify(createEvidenceBundle([record])); + await writeFile(path, saved); + // The parent enforces this deadline even if the CLI is stuck in a regexp. + const markdown = await cli.run({ + arguments: [ + "inspect-evidence-bundle", + JSON.stringify({ path, detail: "complete" }), + "--filter-output", + "records.0.normalized_result", + "--format", + "md", + "--full-output", + ], + timeoutMs: 20_000, + }); + expect(markdown.exitCode, markdown.stderr).toBe(0); + const renderer = await createMarkdownRenderer(root, { linkify: false }); + // Full output also includes metadata tables; check the Evidence column. + const column = tableColumnByHeader( + renderer.render(markdown.stdout), + literal, + ); + expect(column).toHaveLength(1); + expect(column[0] === literal).toBe(true); + expect(await readFile(path, "utf8")).toBe(saved); + }, +); + cliTest( "preserves capability provider identities in rendered Markdown", async ({ cli }) => { From e69f27fba08b730e9f6d6df1b108a0a15dbc9877 Mon Sep 17 00:00:00 2001 From: Dragolone <160818051+Dragolone@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:53:14 +1100 Subject: [PATCH 033/151] fix(web-capture): bound the mitmproxy headline facet index (#2876) * fix(web-capture): keep mitmproxy request targets and status codes in previews Capture previews selected HAR/SAZ headline names (url, status) only, so a native mitmproxy record lost its request target (scheme, host, port, authority, path) and its status_code while keeping the reason phrase, and the matching numeric sidecars were filtered out. Select those mitmproxy headline fields too; headers and payloads stay omitted. * fix(comparison): recheck historical calls-only changes (#2223) * fix(comparison): preserve incomplete function call evidence Keep missing, unknown, partial and unresolved call relationships unknown in function comparisons and reconstruction claims. Aggregate IDA caller and callee coverage without overwriting uncertainty. Validate with 144 focused tests, typecheck/lint, docs checks and test discovery. Real providers were not verified; the full headless IDA session suite requires unavailable Windows native controls. * fix(comparison): recheck historical calls-only changes --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> * fix(ci): isolate Linux Hopper process fixture inventory * fix(android): make DEX descriptor pattern portable * style: format comparison and IDA sources * fix(web-capture): preview mitmproxy targets only as safe scalars Select the mitmproxy target and status fields only for mitmproxy records, only when they are scalars, and the path only in origin form. An absolute, network-path or otherwise ambiguous path spelling can carry userinfo that the native bridge's redaction does not recognise, and a nested value could copy an arbitrary record subtree into the preview; both stay in complete record pages. Other formats keep their existing headline fields. * fix(web-capture): keep the asterisk-form OPTIONS target in mitmproxy previews * fix(web-capture): exclude ambiguous and oversized mitmproxy targets from previews Origin-form checks alone accepted slash-backslash and slash-control spellings such as /\user:password@host/a, which WHATWG resolves as an authority with credentials and the recorded transport-URL interpretation already treats as ambiguous credentials. Preview a mitmproxy path only when parseRecordedTransportUrl finds no credentials, and omit mitmproxy headline strings longer than 8 KiB so one record cannot exceed the reply budget; both stay in complete record pages. * fix(web-capture): keep userinfo-bearing mitmproxy target fields out of previews Native redaction covers only request path and authority, so a malformed state such as host "user:password@example.test" passed through the preview. Omit every non-path mitmproxy headline string that contains a userinfo marker (@); valid schemes, hosts and ports never do, and the value stays in complete record pages. * fix(web-capture): admit mitmproxy headline values only in their well-formed shapes A per-string length cap still let a malformed capture fill every target slot with escape-heavy text, so one default preview page could expand to about 18 MiB of JSON. Preview each mitmproxy field only in its own shape: a short scheme token, host/authority without controls, whitespace or userinfo markers, integer port and status code, and a path of at most 2 KiB without control characters. That bounds every record's headline to about 4 KiB serialized, even for an escape-heavy path. * fix(web-capture): bind mitmproxy headlines to their message side and integer type Select mitmproxy target fields only from the request and status_code only from the response, so an unknown scalar extension such as response.path or request.status_code is not promoted to a headline. Admit port and status_code only when the record's numeric sidecar reports the producer's integer type; a native float such as 443.0 is otherwise indistinguishable from 443 after JSON parsing. * fix(web-capture): require one canonical integer sidecar for mitmproxy numeric headlines An inline parent could pair request.port 443 with an integer sidecar whose literal is unbounded or does not represent the value, or with duplicate sidecars, and the selected-pointer filter would copy them into the preview. Admit port and status_code only with exactly one integer sidecar whose literal is the value's canonical decimal, which also bounds the copied literal to a few characters. * fix(web-capture): add mitmproxy headlines only for HTTP flows The mitmproxy container format does not make every retained flow HTTP; the bridge keeps non-HTTP and unknown flow states with their extensions. Add the mitmproxy target and status candidates only when the record's own type is "http". * fix(web-capture): require consistent numeric sidecars for every mitmproxy headline Selecting a field also selects its numeric sidecars, so a string target such as request.path with an unrelated multi-megabyte sidecar copied that literal into the preview. Admit integer slots only with exactly one canonical integer sidecar and string slots only with no sidecar. * fix(web-capture): fail closed on any userinfo marker in previewed mitmproxy paths URL parsing can fail before recognising userinfo: /\user:password@example.test:65536/a has an invalid port, so the parser reported no credentials and the path was previewed with them. Every userinfo needs "@", so preview a path only when it contains none and does not start with // or /\; such paths stay on complete pages. The parser check is no longer needed. * fix(web-capture): never promote a redacted pointer to a mitmproxy headline Inline or imported Evidence can carry a value at a pointer that its own redactions mark as sensitive. Skip any mitmproxy headline whose pointer has a redaction entry, so a preview never re-emits a value the record says was redacted. * fix(web-capture): check integer headline types before comparing sidecar literals The sidecar comparison called String(value) before the numeric shape check, so an inline object such as {"toString": null} at request.port threw a TypeError out of the view instead of being omitted. Require a number before canonicalizing it. * fix(web-capture): honor ancestor value redactions before selecting mitmproxy headlines A value redaction at /request or the record root excludes its whole subtree, but only exact-pointer matches were honored, so contradictory inline Evidence could still preview /request/host or /request/path. Treat a value redaction as covering every descendant pointer; a property-name redaction still only identifies the parent of an omitted key and hides no sibling values. * fix(web-capture): treat redacted byte descriptors as exclusions for mitmproxy headlines Producers mark excluded bytes with a redacted binary descriptor as well as a redaction entry, and the record schema accepts either facet alone. Skip a mitmproxy headline when a redacted descriptor covers its pointer, so contradictory Evidence that keeps the value cannot preview it. * fix(web-capture): refuse a redacted flow type as the mitmproxy HTTP discriminator Contradictory Evidence can keep type "http" while marking /type redacted. Apply the same redaction check to /type before treating the flow as HTTP, so no mitmproxy headline is added when the flow kind is unavailable. * fix(web-capture): refuse mitmproxy headlines under contradictory numeric sidecars A numeric sidecar records that the producer held a number at that pointer. Treat the flow as HTTP only when /type has no sidecar of its own, and promote a mitmproxy headline only when no ancestor (the record root, /request or /response) carries one. * fix(web-capture): require a textual flow type before adding mitmproxy headlines mitmproxy's flow state carries type as a string. When the native value was bytes, the bridge shows the UTF-8 display text "http" next to a retained binary descriptor, so the producer did not supply the discriminator mitmproxy defines. Treat the flow as HTTP only when /type has no numeric sidecar and no binary descriptor at or above it. * fix(web-capture): refuse mitmproxy headlines under byte-valued ancestors The mitmproxy bridge emits either a byte descriptor for a value or children for a dictionary, never both. Treat a byte descriptor on an ancestor like a numeric sidecar, and refuse a byte descriptor on an integer headline, while keeping the exact descriptor of byte-backed string fields such as the request path. * fix(web-capture): check child facets and byte provenance of mitmproxy headlines A facet beneath a scalar coordinate says the producer held a container there, so refuse the flow type or a headline field with a numeric sidecar, byte descriptor or redaction below it. A string headline may carry at most one byte descriptor, and only retained mitmproxy producer bytes whose content is the value's UTF-8; HAR content bytes or mismatched content keep it out. * fix(web-capture): verify the byte count and digest of byte-backed headlines The record schema checks a descriptor's length and digest shapes, not their relation to its content. Require a byte-backed string headline's descriptor to match the value's UTF-8 in content, byte count and SHA-256 before promoting it. * fix(web-capture): reject byte-backed headlines without a UTF-8 form Buffer.from replaces an unpaired surrogate with U+FFFD bytes, so a descriptor for those bytes matched a string no producer could have decoded. Require the value to round-trip through UTF-8 before comparing it with its descriptor, as CaptureRedaction already does. * fix(web-capture): index mitmproxy facets once and refuse non-UTF-8 targets Reject host, authority and path text containing an unpaired surrogate whether or not a byte descriptor backs it, since no producer could have reported such text. Build a per-record index of facet pointers (sidecars and descriptors by pointer, scalar pointers, container ancestors, withheld values and omitted names) once, only for mitmproxy HTTP records, so each headline check is a few set lookups instead of several passes over every facet array. * fix(web-capture): bound the mitmproxy facet index to headline coordinates Facet pointers have no length or depth bound, and recording every ancestor kept one set entry per prefix (a million for a 1 MB pointer). Record only the fixed headline coordinates (/type and the request/response target and status fields) that have a facet beneath them, examining prefixes no longer than those coordinates. * fix(web-capture): index only facets at headline coordinates and their ancestors Grouping every numeric sidecar and byte descriptor allocated an entry per facet even though only /type, the request/response headline fields and their ancestors are queried. Keep facets only at those pointers (at most two per pointer, which already decides every check), and let other facets merely mark the headline coordinates they lie beneath, using prefix comparisons that derive no strings. * test(web-capture): pin headline omissions and controls beside many unrelated facets The bounded facet index must not change what a mitmproxy preview withholds or keeps. Beside 20,000 unrelated sidecars, a redacted host and a path with a facet beneath it stay withheld while the scheme, port and status remain, with only their own sidecars retained. The test passes against both the current main projection and this change. * test(web-capture): count long set additions in the million-character pointer tests The two million-character pointer tests had no time or size bound, so on Node 22 and 24 they passed against the old ancestor index too, and only Node 26 caught a regression by running out of memory. Count the strings longer than 1,000 characters the preview adds to sets instead: only the pointer and strings cut from it are that long, the old index added 999,000 and 499,507 of them, and the new index adds none. --------- Co-authored-by: Lucien <164857476+dakjdakd@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- .../webNetworkCaptureView.test.ts | 96 ++++++++++++++++++- .../analysisView/webNetworkCaptureView.ts | 84 +++++++++------- 2 files changed, 144 insertions(+), 36 deletions(-) diff --git a/src/domain/analysisView/webNetworkCaptureView.test.ts b/src/domain/analysisView/webNetworkCaptureView.test.ts index 4aaecad8c6..af31680954 100644 --- a/src/domain/analysisView/webNetworkCaptureView.test.ts +++ b/src/domain/analysisView/webNetworkCaptureView.test.ts @@ -1,4 +1,4 @@ -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; import { createHash } from "node:crypto"; import { createEvidence } from "../evidence.js"; import { freezeJsonSnapshot } from "../immutableJson.js"; @@ -849,6 +849,100 @@ it.each([ }, ); +it.each([ + ["an unrelated", "/".repeat(1_000_000), { method: "GET", path: "/a" }], + [ + "a path-descendant", + `/request/path${"/0".repeat(500_000)}`, + { method: "GET" }, + ], +])( + "indexes %s million-character facet pointer by its headline prefix only", + (_label, pointer, request) => { + // Count set additions, not elapsed time or heap, so every Node version + // catches a regression. Only the pointer and strings cut from it are this + // long here; indexing every ancestor added one per "/" it held. + let longAdditions = 0; + const add = Set.prototype.add; + const addSpy = vi.spyOn(Set.prototype, "add").mockImplementation(function ( + this: Set, + value: unknown, + ) { + if (typeof value === "string" && value.length > 1_000) longAdditions++; + return add.call(this, value); + }); + let reported: unknown; + try { + reported = nativePreview( + "mitmproxy", + { method: "GET", path: "/a" }, + { + response: { http_version: "HTTP/1.1", reason: "OK" }, + sidecars: [{ pointer, producer_type: "integer", literal: "1" }], + }, + ).items[0]?.reported; + } finally { + addSpy.mockRestore(); + } + expect(longAdditions).toBe(0); + expect(reported).toEqual({ + type: "http", + request, + response: { http_version: "HTTP/1.1", reason: "OK" }, + }); + }, +); + +// Indexing only headline coordinates must not change what the preview +// withholds or keeps beside many facets the headline checks never consult. +it("keeps headline omissions and controls beside many unrelated facets", () => { + const unrelated = Array.from({ length: 20_000 }, (_, index) => ({ + pointer: `/extension/values/${index}`, + producer_type: "integer", + literal: String(index), + })); + const [item] = nativePreview( + "mitmproxy", + { + method: "GET", + scheme: "https", + host: "secret.test", + port: 443, + path: "/a", + }, + { + response: { http_version: "HTTP/1.1", reason: "OK", status_code: 200 }, + sidecars: [ + ...unrelated, + { pointer: "/request/port", producer_type: "integer", literal: "443" }, + { + pointer: "/response/status_code", + producer_type: "integer", + literal: "200", + }, + // A facet beneath the path says the producer held a container there. + { pointer: "/request/path/0", producer_type: "integer", literal: "1" }, + ], + redactions: [ + { pointer: "/request/host", reason: "explicit-sensitive-value" }, + ], + }, + ).items; + expect(item?.reported).toEqual({ + type: "http", + request: { method: "GET", scheme: "https", port: 443 }, + response: { http_version: "HTTP/1.1", reason: "OK", status_code: 200 }, + }); + expect(item?.numeric_literals).toEqual([ + { pointer: "/request/port", producer_type: "integer", literal: "443" }, + { + pointer: "/response/status_code", + producer_type: "integer", + literal: "200", + }, + ]); +}); + it("keeps an unpaired-surrogate path out despite replacement-character bytes", () => { const reported = nativePreview( "mitmproxy", diff --git a/src/domain/analysisView/webNetworkCaptureView.ts b/src/domain/analysisView/webNetworkCaptureView.ts index 83c737ea9b..b5d4e948a2 100644 --- a/src/domain/analysisView/webNetworkCaptureView.ts +++ b/src/domain/analysisView/webNetworkCaptureView.ts @@ -247,37 +247,43 @@ const ancestorsOf = (pointer: string): string[] => { ancestors.push(pointer.slice(0, at)); return ancestors; }; -/** Add a pointer's strict ancestors, stopping at one already present (its own ancestors are too). */ -const addAncestors = (pointer: string, into: Set): void => { - for ( - let at = pointer.lastIndexOf("/"); - at !== -1; - at = at === 0 ? -1 : pointer.lastIndexOf("/", at - 1) - ) { - const ancestor = pointer.slice(0, at); - if (into.has(ancestor)) return; - into.add(ancestor); - } +/** The only coordinates a mitmproxy headline check asks about. */ +const HEADLINE_POINTERS: ReadonlySet = new Set([ + "/type", + ...Object.entries(MITMPROXY_HEADLINE_FIELDS).flatMap(([side, fields]) => + fields.map((field) => `/${side}/${field}`), + ), +]); +const HEADLINE_CHILD_PREFIXES = [...HEADLINE_POINTERS].map( + (pointer) => [pointer, `${pointer}/`] as const, +); +/** Headline coordinates and their ancestors: the only pointers whose own facets matter. */ +const INDEXED_POINTERS: ReadonlySet = new Set( + [...HEADLINE_POINTERS].flatMap((pointer) => [ + pointer, + ...ancestorsOf(pointer), + ]), +); +/** Mark the headline coordinates a facet lies beneath, without deriving strings from its pointer. */ +const markContainers = (pointer: string, into: Set): void => { + for (const [coordinate, prefix] of HEADLINE_CHILD_PREFIXES) + if (pointer.startsWith(prefix)) into.add(coordinate); }; -const groupByPointer = ( - facets: readonly T[], -): Map => { - const groups = new Map(); - for (const facet of facets) { - const group = groups.get(facet.pointer); - if (group === undefined) groups.set(facet.pointer, [facet]); - else group.push(facet); - } - return groups; +/** Two entries already decide every representation check, so keep no more. */ +const keep = (groups: Map, pointer: string, facet: T): void => { + const group = groups.get(pointer); + if (group === undefined) groups.set(pointer, [facet]); + else if (group.length < 2) group.push(facet); }; -/** A record's facet coordinates, indexed once for every headline check. */ +/** A record's facets at headline coordinates and their ancestors, indexed once. */ type FacetIndex = { + /** At most two facets per pointer; a second already fails every check. */ readonly sidecars: ReadonlyMap; readonly bytes: ReadonlyMap; /** The producer held a number or bytes here. */ readonly scalars: ReadonlySet; - /** A facet lies beneath: the producer held a container here. */ + /** Headline coordinates with a facet beneath: the producer held a container there. */ readonly containers: ReadonlySet; /** * Withheld by a value redaction or a redacted byte descriptor; either facet @@ -287,22 +293,30 @@ type FacetIndex = { /** The parent of an omitted property name; it hides no sibling values. */ readonly omittedNames: ReadonlySet; }; +/** + * Facets elsewhere in the record only mark containers, so the index holds a + * fixed number of entries however many facets an admitted record carries. + */ const indexFacets = (record: WebNetworkCaptureRecord): FacetIndex => { + const sidecars = new Map(); + const bytes = new Map(); const containers = new Set(); const withheld = new Set(); const omittedNames = new Set(); - for (const { pointer } of record.numeric_literals) - addAncestors(pointer, containers); - for (const { pointer, state } of record.binary_fields) { - addAncestors(pointer, containers); - if (state === "redacted") withheld.add(pointer); - } - for (const redaction of record.redactions) { - addAncestors(redaction.pointer, containers); - ("scope" in redaction ? omittedNames : withheld).add(redaction.pointer); - } - const sidecars = groupByPointer(record.numeric_literals); - const bytes = groupByPointer(record.binary_fields); + const indexed = (pointer: string): boolean => { + markContainers(pointer, containers); + return INDEXED_POINTERS.has(pointer); + }; + for (const sidecar of record.numeric_literals) + if (indexed(sidecar.pointer)) keep(sidecars, sidecar.pointer, sidecar); + for (const field of record.binary_fields) + if (indexed(field.pointer)) { + keep(bytes, field.pointer, field); + if (field.state === "redacted") withheld.add(field.pointer); + } + for (const redaction of record.redactions) + if (indexed(redaction.pointer)) + ("scope" in redaction ? omittedNames : withheld).add(redaction.pointer); return { sidecars, bytes, From 1c0a02d4a41aa2205b84132b100acaae8c8481e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E4=BD=B3=E6=B5=A9Alben?= <35291372+XiaoBinGan@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:53:25 +0800 Subject: [PATCH 034/151] fix(evidence): bound malformed file field diagnostics (#8557) * fix(evidence): bound malformed file field diagnostics `readEvidenceFileSource` projected every validator issue, and the generic error delivery applied the MCP response budget afterwards. A file with many invalid fields therefore produced a transport `resource_constraint` that replaced the typed file error, so the caller lost the selected path entirely. Issue #8453 reports exactly that: 20,000 invalid values still returned a direct `invalid_request` with the path and every field issue, while 125,000 returned `resource_constraint` with neither. `projectInputIssuesWithinBudget` already existed for this but had no production caller. The reader now projects within a 64 KiB allowance and, when the whole list does not fit, keeps the largest leading group that does and appends one explicit omission issue naming how many of how many were dropped. The typed file error and its `path` therefore survive the response boundary, and the diagnostics stay actionable and explicitly incomplete instead of hiding the real failure. Two regressions cover the bound and the fits-in-budget case. With the unbounded projection restored, the bound test and its supporting fixtures fail. * chore: retrigger title and readiness checks after the title was corrected * fix(evidence): bound file diagnostics in one pass and cover the MCP boundary Review findings on the first version. **The prefix was not maximal.** `boundedContentIssues` halved the list and returned the first attempt that fit, so a file just over the allowance lost roughly half its diagnostics. Prefix selection now lives in the shared projector (`projectInputIssuesBounded`), which walks the issues once and stops exactly at the allowance, keeping the largest fitting prefix. **A compound issue could still lose its location.** One `unrecognized_keys` issue naming hundreds of keys expands into hundreds of field diagnostics and can exceed the allowance on its own. The projector now retains a diagnostic at that issue's path instead of returning only the omission notice. **The regression never crossed the MCP boundary.** The issue is about the delivered MCP error, so `evidenceFileSourceMcp.test.ts` sends the 125,000-value fixture through the production `inspect_analysis_view` tool and asserts the delivered error stays `invalid_request`, keeps `details.path`, keeps bounded `content_issues`, and fits the response budget. Both regressions fail without the bound. * test(evidence): move the MCP delivery regression to the adapters project Review finding. `scripts/lib/test-projects.mjs` assigns `src/application/**` to the `services` project, so the delivery regression sat in the wrong project even though it exercises the production MCP tool registration. Moving it to `src/server/` puts it in `adapters`, which runs the other MCP boundary tests and plans the same way. --- .../analysisView/evidenceFileSource.test.ts | 60 ++++++++++++++++ .../analysisView/evidenceFileSource.ts | 33 ++++++++- src/domain/inputIssueProjection.ts | 56 +++++++++++++++ src/server/EvidenceFileDiagnosticsMcp.test.ts | 71 +++++++++++++++++++ 4 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 src/server/EvidenceFileDiagnosticsMcp.test.ts diff --git a/src/application/analysisView/evidenceFileSource.test.ts b/src/application/analysisView/evidenceFileSource.test.ts index 3e251d868e..3ee02f3138 100644 --- a/src/application/analysisView/evidenceFileSource.test.ts +++ b/src/application/analysisView/evidenceFileSource.test.ts @@ -19,6 +19,7 @@ import { isImmutableJsonSnapshot } from "../../domain/immutableJson.js"; import { ok } from "../../domain/result.js"; import { createEvidenceBundle } from "../../domain/evidenceBundle.js"; import { readEvidenceFileSource } from "./evidenceFileSource.js"; +import type { Evidence } from "../../domain/evidence.js"; describe("evidence-file source reads", () => { it("owns immutable authentication proofs for selected records and related bundle parents", async () => { @@ -332,3 +333,62 @@ it("keeps bundle-selection errors useful when operation labels exceed the reply ); expect(encoded).not.toContain(operation); }); + +describe("malformed Evidence file diagnostics (#8453)", () => { + /** + * A malformed file can carry tens of thousands of field issues. Projecting all + * of them expanded the error past the MCP response budget, which replaced the + * typed file error and its path with a transport `resource_constraint`. The + * reader now bounds the projected issues and says how many were omitted. + */ + const manyIssues = (count: number): Evidence => { + const source = structuredClone(analysisViewCaptureEvidence()); + return { + ...source, + limitations: Array.from({ length: count }, (_value, index) => index), + } as unknown as Evidence; + }; + + it("keeps the typed file error and path with bounded diagnostics", async () => { + const root = await createTestTempDirectory("rea-evidence-many-issues-"); + const path = join(root, "many.json"); + await writeFile(path, JSON.stringify(manyIssues(125_000))); + const result = await readEvidenceFileSource(path); + if (result.ok) throw new Error("expected a rejected file"); + expect(result.error).toMatchObject({ + _tag: "EvidenceFileError", + reason: "wrong-kind", + path, + }); + const issues = (result.error as { contentIssues?: readonly unknown[] }) + .contentIssues; + expect(issues).toBeDefined(); + // Bounded: far below the 125,000 source issues, and small enough for the + // default MCP response budget. + expect(issues?.length ?? 0).toBeLessThan(1_000); + expect(Buffer.byteLength(JSON.stringify(issues), "utf8")).toBeLessThan( + 1 << 20, + ); + expect(issues).toContainEqual( + expect.objectContaining({ + path: [], + message: expect.stringContaining("validator issues were omitted"), + }), + ); + }); + + it("projects every issue when they fit the budget", async () => { + const root = await createTestTempDirectory("rea-evidence-few-issues-"); + const path = join(root, "few.json"); + await writeFile(path, JSON.stringify(manyIssues(3))); + const result = await readEvidenceFileSource(path); + if (result.ok) throw new Error("expected a rejected file"); + const issues = ( + result.error as { contentIssues?: readonly { message?: string }[] } + ).contentIssues; + expect(issues).toHaveLength(3); + expect(issues?.some(({ message }) => message?.includes("omitted"))).toBe( + false, + ); + }); +}); diff --git a/src/application/analysisView/evidenceFileSource.ts b/src/application/analysisView/evidenceFileSource.ts index 2057b14507..d4186af946 100644 --- a/src/application/analysisView/evidenceFileSource.ts +++ b/src/application/analysisView/evidenceFileSource.ts @@ -1,3 +1,4 @@ +import type { z } from "zod"; import { requireAuthenticationHeadroom } from "../JsonAuthenticationHeadroom.js"; import { authenticateEvidenceBundleValue } from "../EvidenceBundleFiles.js"; import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; @@ -5,13 +6,14 @@ import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisCancelledError, AnalysisInputError, + type AnalysisInputIssue, } from "../../domain/analysisErrorCore.js"; import { INSPECT_ANALYSIS_VIEW_OPERATION } from "../../domain/analysisView/analysisViewErrors.js"; import { err, ok, type Result } from "../../domain/result.js"; import type { Evidence } from "../../domain/evidence.js"; import { safeParseImmutableEvidence } from "../../domain/evidenceValidation.js"; import { EvidenceFileError } from "../../domain/evidenceErrors.js"; -import { projectInputIssues } from "../../domain/inputIssueProjection.js"; +import { projectInputIssuesBounded } from "../../domain/inputIssueProjection.js"; import { readJsonFile } from "../JsonFiles.js"; /** The request cancellation and the scope retaining descriptors whose close failed. */ @@ -118,6 +120,33 @@ const listRecordIds = (records: readonly Evidence[]): string => { return omitted > 0 ? `${listed} and ${String(omitted)} more` : listed; }; +/** + * Budget for projected field-level diagnostics. A malformed file can carry tens + * of thousands of issues, and projecting all of them pushes the error past the + * MCP response budget, which then hides the typed file error and its path. Keep + * the leading issues that fit and say how many were omitted. + */ +const EVIDENCE_CONTENT_ISSUE_BUDGET_BYTES = 65_536; + +const boundedContentIssues = ( + issues: readonly z.core.$ZodIssue[], + input: unknown, +): readonly AnalysisInputIssue[] => { + const bounded = projectInputIssuesBounded( + issues, + input, + EVIDENCE_CONTENT_ISSUE_BUDGET_BYTES, + ); + if (bounded.omittedIssueCount === 0) return bounded.issues; + return [ + ...bounded.issues, + { + path: [], + reason: "invalid_value", + message: `${String(bounded.omittedIssueCount)} of ${String(issues.length)} validator issues were omitted because they do not fit the response budget; repair the reported fields and retry.`, + }, + ]; +}; /** Read one Evidence record or select from an authenticated bundle without rereading the file. */ export const readEvidenceFileSource = async ( path: string, @@ -171,7 +200,7 @@ export const readEvidenceFileSource = async ( new EvidenceFileError("read", "wrong-kind", { path, cause: parsed.error, - contentIssues: projectInputIssues(parsed.error.issues, value), + contentIssues: boundedContentIssues(parsed.error.issues, value), userMessage: "The selected file does not contain valid Evidence. Repair or recreate the file and try again.", }), diff --git a/src/domain/inputIssueProjection.ts b/src/domain/inputIssueProjection.ts index afdf8a4f7b..657b256e73 100644 --- a/src/domain/inputIssueProjection.ts +++ b/src/domain/inputIssueProjection.ts @@ -67,6 +67,62 @@ export const projectInputIssuesWithinBudget = ( )?.flat(); }; +/** Field diagnostics that fit a byte allowance, plus what was dropped. */ +export interface BoundedInputIssues { + readonly issues: readonly AnalysisInputIssue[]; + /** Validator issues whose field diagnostics did not fit the allowance. */ + readonly omittedIssueCount: number; +} + +/** + * Project as many validator issues as fit, in one forward pass, and count the + * rest. A single pass keeps the returned group maximal: it stops exactly at the + * allowance instead of at a coarse fraction of the list, and a compound issue + * such as `unrecognized_keys` still charges every field diagnostic it expands + * into because the recursion goes through `projectIssue` per nested issue. + */ +export const projectInputIssuesBounded = ( + issues: readonly z.core.$ZodIssue[], + input: unknown, + budgetBytes: number, +): BoundedInputIssues => { + const budget = { remaining: budgetBytes }; + const output: AnalysisInputIssue[] = []; + let index = 0; + try { + for (; index < issues.length; index += 1) { + const issue = issues[index]; + if (issue === undefined) continue; + output.push(...projectIssue(issue, input, budget)); + } + } catch (error) { + if (error !== projectionBudgetExceeded) throw error; + // The issue that crossed the allowance and every later one contribute at + // least one omitted field diagnostic each. + const omittedIssueCount = issues.length - index; + if (output.length > 0) return { issues: output, omittedIssueCount }; + // One compound issue, such as `unrecognized_keys` naming hundreds of keys, + // can exceed the allowance on its own. Retain a diagnostic at its path so the + // caller keeps the location instead of only an omission notice. + const first = issues[0]; + if (first === undefined) return { issues: output, omittedIssueCount }; + const path = first.path.flatMap((part) => + typeof part === "string" || typeof part === "number" ? [part] : [], + ); + return { + issues: [ + { + path, + reason: "invalid_value", + message: `${String(omittedIssueCount)} field diagnostics at this location were omitted because they do not fit the response budget.`, + }, + ], + omittedIssueCount, + }; + } + return { issues: output, omittedIssueCount: 0 }; +}; + /** Retain each projected issue's source through bounded SDK normalization. */ export const projectInputIssueGroupsWithinBudget = ( issues: readonly z.core.$ZodIssue[], diff --git a/src/server/EvidenceFileDiagnosticsMcp.test.ts b/src/server/EvidenceFileDiagnosticsMcp.test.ts new file mode 100644 index 0000000000..dd00ddb10f --- /dev/null +++ b/src/server/EvidenceFileDiagnosticsMcp.test.ts @@ -0,0 +1,71 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { analysisViewCaptureEvidence } from "../../tests/fixtures/analysisView.js"; +import { createApplicationMcpHarness } from "../../tests/fixtures/applicationMcpHarness.js"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import type { Evidence } from "../domain/evidence.js"; + +/** + * Issue #8453 is about the delivered MCP error, not only the reader's result. On + * unmodified `main`, a malformed Evidence file with 125,000 invalid `limitations` + * values returned `resource_constraint` at the MCP response boundary with an + * original-error reference and no file path, because the reader projected every + * validator issue before the response budget applied. + */ +it("delivers a bounded file validation error through inspect_analysis_view", async () => { + const root = await createTestTempDirectory("rea-mcp-bounded-issues-"); + const evidencePath = join(root, "many.json"); + const source = structuredClone(analysisViewCaptureEvidence()); + const document = { + ...source, + limitations: Array.from({ length: 125_000 }, (_value, index) => index), + } as unknown as Evidence; + await writeFile(evidencePath, JSON.stringify(document)); + + const { client } = await createApplicationMcpHarness(); + const result = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { kind: "evidence-file", path: evidencePath }, + view: { kind: "summary" }, + }, + }); + + expect(result.isError).toBe(true); + const text = (result.content as readonly { type: string; text?: string }[]) + .flatMap(({ type, text: value }) => + type === "text" && value !== undefined ? [value] : [], + ) + .join(""); + // The typed file error and the selected path survive the response boundary. + const delivered = JSON.parse(text) as { + error?: { + code?: string; + details?: { + path?: string; + content_issues?: readonly { + path?: readonly (string | number)[]; + reason?: string; + message?: string; + }[]; + }; + }; + }; + expect(delivered.error?.code).toBe("invalid_request"); + expect(delivered.error?.details?.path).toBe(evidencePath); + const issues = delivered.error?.details?.content_issues ?? []; + // Bounded rather than all 125,000, and explicitly incomplete. + expect(issues.length).toBeGreaterThan(0); + expect(issues.length).toBeLessThan(1_000); + expect(issues).toContainEqual( + expect.objectContaining({ + path: [], + message: expect.stringContaining("validator issues were omitted"), + }), + ); + // The whole delivered response stays well inside the default MCP budget. + expect(Buffer.byteLength(text, "utf8")).toBeLessThan(1 << 20); +}); From 3959b68d910c8abf6d07bd0ce22173505f74c11e Mon Sep 17 00:00:00 2001 From: Luis Araya <35828647+Totopo27@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:13:24 -0600 Subject: [PATCH 035/151] test(rust): assert non-retryable compiler-hash capacity in CLI and MCP (#5504) (#7520) --- tests/acceptance/rustBinaryCli.test.ts | 28 ++++++++++++++++++++++++ tests/boundary/mcp/rustBinaryMcp.test.ts | 6 +++++ 2 files changed, 34 insertions(+) diff --git a/tests/acceptance/rustBinaryCli.test.ts b/tests/acceptance/rustBinaryCli.test.ts index 9d461b36bb..02f3cc9164 100644 --- a/tests/acceptance/rustBinaryCli.test.ts +++ b/tests/acceptance/rustBinaryCli.test.ts @@ -10,6 +10,7 @@ import { RUST_RESOURCE_GUARD_LIMITATION } from "../../src/rust/RustBinaryProvide import { machoImageWithUnknownCommand } from "../../src/artifacts/apple/MachoImage.fixture.js"; import { compressRustElfSectionNames, + createRustHashOverflowImage, createRustImage, createRustImageWithGoSection, RUSTC_HASH, @@ -685,3 +686,30 @@ cliTest( }, 30000, ); + +cliTest( + "refuses distinct rustc paths above capacity with non-retryable guidance", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-rust-cli-overflow-"); + const path = join(root, "overflow.elf"); + await writeFile(path, createRustHashOverflowImage()); + const result = await cli.run({ + arguments: ["inspect-rust-binary", path, "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "resource_constraint", + category: "resource_constraint", + retryable: false, + remediation: { + action: + "The rustc commit-path inventory is a fixed count. Waiting for an active analysis to finish does not raise it. Repeating the unchanged request fails the same way.", + }, + details: { + operation: "inspect_rust_binary", + resource: "capacity", + reported_limits: { maximum_rustc_hashes: 64 }, + }, + }); + }, +); diff --git a/tests/boundary/mcp/rustBinaryMcp.test.ts b/tests/boundary/mcp/rustBinaryMcp.test.ts index 4f7b3452c0..02b806b8c4 100644 --- a/tests/boundary/mcp/rustBinaryMcp.test.ts +++ b/tests/boundary/mcp/rustBinaryMcp.test.ts @@ -113,6 +113,12 @@ mcpTest( expect(response.isError).toBe(true); expect(parseMcpToolError(response).error).toMatchObject({ code: "resource_constraint", + category: "resource_constraint", + retryable: false, + remediation: { + action: + "The rustc commit-path inventory is a fixed count. Waiting for an active analysis to finish does not raise it. Repeating the unchanged request fails the same way.", + }, details: { operation: "inspect_rust_binary", resource: "capacity", From 42aaef8bdb458105b7d7260c60fa6f5612dde51b Mon Sep 17 00:00:00 2001 From: RRFZ <247457381+Plastic-time@users.noreply.github.com> Date: Fri, 6 Nov 2026 02:13:43 -0500 Subject: [PATCH 036/151] fix(cli): require absolute ADB device paths for file pull and push (#9048) pull-adb-file and push-adb-file declared device-path as any non-empty string. The CLI schema advertised a relative path as valid, and the service then refused it under the MCP field name. Reuse the shared device_path fields, and keep the regex constraint in CLI field errors as list-adb-directory does. --- src/cli/adbCommands.ts | 4 +- src/cli/argumentErrors.ts | 2 + .../adbPackageInventoryCoverage.test.ts | 48 ++++++++++++++++--- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/cli/adbCommands.ts b/src/cli/adbCommands.ts index d86da63dd1..f908935da0 100644 --- a/src/cli/adbCommands.ts +++ b/src/cli/adbCommands.ts @@ -145,7 +145,7 @@ export const registerAdbCommands = ( description: "Pull one device file to a local copy with a SHA-256 digest", args: z.object({ serial, - "device-path": z.string().min(1).describe("Absolute device path to pull"), + "device-path": adbInputSchemas.pull_adb_file.shape.device_path, }), options: z.object({ "output-directory": z @@ -166,7 +166,7 @@ export const registerAdbCommands = ( args: z.object({ serial, "local-path": z.string().min(1).describe("Existing local file"), - "device-path": z.string().min(1).describe("Absolute device destination"), + "device-path": adbInputSchemas.push_adb_file.shape.device_path, }), options: z.object({ overwrite: z diff --git a/src/cli/argumentErrors.ts b/src/cli/argumentErrors.ts index 0becf11a73..f213b861a8 100644 --- a/src/cli/argumentErrors.ts +++ b/src/cli/argumentErrors.ts @@ -22,6 +22,8 @@ const isAdbSelector = (command: string, field: string): boolean => (adbPackageCommands.has(command) && field === "package") || (command === "list-adb-directory" && field === "device-path") || (command === "resolve-adb-packages" && field === "query") || + (["pull-adb-file", "push-adb-file"].includes(command) && + field === "device-path") || (command === "start-adb-activity" && ["action", "component", "data-uri"].includes(field)); diff --git a/tests/acceptance/applications/adbPackageInventoryCoverage.test.ts b/tests/acceptance/applications/adbPackageInventoryCoverage.test.ts index 06651fedb7..78179e780f 100644 --- a/tests/acceptance/applications/adbPackageInventoryCoverage.test.ts +++ b/tests/acceptance/applications/adbPackageInventoryCoverage.test.ts @@ -12,7 +12,18 @@ import { cliTest, runFileCli } from "../../support/cli/cliFixture.js"; const PACKAGE_LINE = "package:/data/app/example/base.apk=com.example.app"; -cliTest.for([ +interface AdbSelectorCase { + readonly command: string; + readonly field: string; + readonly valid: string; + readonly invalid: string; + readonly constraint: Readonly>; + /** Positional arguments between the serial and the checked field. */ + readonly between?: Readonly>; + readonly options?: readonly string[]; +} + +cliTest.for([ { command: "list-adb-directory", field: "device-path", @@ -20,6 +31,22 @@ cliTest.for([ invalid: "sdcard", constraint: { expected: "regex" }, }, + { + command: "pull-adb-file", + field: "device-path", + valid: "/sdcard/notes.txt", + invalid: "sdcard/notes.txt", + constraint: { expected: "regex" }, + options: ["--output-directory", "pulled"], + }, + { + command: "push-adb-file", + field: "device-path", + valid: "/data/local/tmp/payload.bin", + invalid: "data/local/tmp/payload.bin", + constraint: { expected: "regex" }, + between: { "local-path": "payload.bin" }, + }, { command: "resolve-adb-packages", field: "query", @@ -29,18 +56,27 @@ cliTest.for([ }, ])( "publishes the enforced ADB selector constraint for $command", - async ({ command, field, valid, invalid, constraint }, { cli }) => { + async ( + { command, field, valid, invalid, constraint, between = {}, options = [] }, + { cli }, + ) => { const schema = await cli.run({ arguments: [command, "--schema", "--json"], }); expect(schema.exitCode, schema.stderr).toBe(0); const output = schema.json as { args: Parameters[0] }; const validate = new Ajv2020({ strict: false }).compile(output.args); - const serial = "emulator-5554"; - expect(validate({ serial, [field]: valid })).toBe(true); - expect(validate({ serial, [field]: invalid })).toBe(false); + const leading = { serial: "emulator-5554", ...between }; + expect(validate({ ...leading, [field]: valid })).toBe(true); + expect(validate({ ...leading, [field]: invalid })).toBe(false); const result = await cli.run({ - arguments: [command, serial, invalid, "--json"], + arguments: [ + command, + ...Object.values(leading), + invalid, + ...options, + "--json", + ], }); expect(result.exitCode).toBe(1); expect(result.json).toMatchObject({ From 91d73a113e557aea6e23f454f4adca7579405391 Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:13:51 +0800 Subject: [PATCH 037/151] feat(contracts): inventory offline OpenAPI operation declarations (#9104) * feat(contracts): inventory offline OpenAPI operation declarations * docs(contracts): link fixture provenance outside the docs site * fix(contracts): describe the shared API document byte allowance * fix(api-contract): preserve safe diagnostics and completed source facts --- docs/api-contracts.md | 102 ++++ docs/mcp-contracts.md | 12 + src/application/Utf8JsonInput.ts | 12 +- .../apiContract/ApiContractRead.ts | 133 +++++ .../apiContract/ApiContractService.test.ts | 321 ++++++++++++ .../apiContract/ApiContractService.ts | 178 +++++++ src/cli/apiContractCommand.ts | 108 +++++ src/cli/applicationCommands.ts | 2 + src/cliCommandNames.ts | 1 + src/contracts/apiContractToolContracts.ts | 44 ++ src/contracts/applicationToolContracts.ts | 2 + src/contracts/toolEffects.ts | 1 + src/domain/analysisView/analysisView.ts | 6 + .../analysisView/analysisViewRequest.ts | 2 + src/domain/analysisView/analysisViewResult.ts | 22 + src/domain/analysisView/apiContractView.ts | 211 ++++++++ src/domain/apiContract/apiContract.ts | 95 ++++ src/domain/apiContract/apiContractQuery.ts | 69 +++ .../apiContract/parseApiContract.test.ts | 314 ++++++++++++ src/domain/apiContract/parseApiContract.ts | 452 +++++++++++++++++ src/domain/artifactOperationError.ts | 1 + src/server/registerApplicationTools.ts | 2 + .../registerApplicationTools/apiContract.ts | 58 +++ .../applications/apiContract.test.ts | 457 ++++++++++++++++++ .../boundary/filesystem/cliJsonInput.test.ts | 14 + tests/fixtures/openapi/LICENSE.petstore.txt | 201 ++++++++ tests/fixtures/openapi/README.md | 19 + tests/fixtures/openapi/petstore.json.txt | 1 + 28 files changed, 2839 insertions(+), 1 deletion(-) create mode 100644 docs/api-contracts.md create mode 100644 src/application/apiContract/ApiContractRead.ts create mode 100644 src/application/apiContract/ApiContractService.test.ts create mode 100644 src/application/apiContract/ApiContractService.ts create mode 100644 src/cli/apiContractCommand.ts create mode 100644 src/contracts/apiContractToolContracts.ts create mode 100644 src/domain/analysisView/apiContractView.ts create mode 100644 src/domain/apiContract/apiContract.ts create mode 100644 src/domain/apiContract/apiContractQuery.ts create mode 100644 src/domain/apiContract/parseApiContract.test.ts create mode 100644 src/domain/apiContract/parseApiContract.ts create mode 100644 src/server/registerApplicationTools/apiContract.ts create mode 100644 tests/acceptance/applications/apiContract.test.ts create mode 100644 tests/fixtures/openapi/LICENSE.petstore.txt create mode 100644 tests/fixtures/openapi/README.md create mode 100644 tests/fixtures/openapi/petstore.json.txt diff --git a/docs/api-contracts.md b/docs/api-contracts.md new file mode 100644 index 0000000000..76cf4b9cde --- /dev/null +++ b/docs/api-contracts.md @@ -0,0 +1,102 @@ +# Offline API contract inventory + +`inspect_api_contract` / `rea inspect-api-contract` reads one local OpenAPI +3.0, 3.1 or 3.2 JSON document. It inventories inline operation slots under +`paths`; the facts describe declarations, not implemented, reachable or +authorized runtime behavior. YAML, AsyncAPI, callbacks/webhooks, schema graphs, +contract diffs and traffic correlation are outside this version. + +The importer never resolves references, opens sibling documents, evaluates +examples or contacts declared servers. OpenAPI 3.2 `query` and +`additionalOperations` follow the [Path Item contract](https://spec.openapis.org/oas/v3.2.1.html#path-item-object), +including case-sensitive additional HTTP method spelling. + +## Select a useful answer + +MCP defaults to a summary retaining the complete inventory. An optional view +returns selected facts immediately, taking precedence over `detail`: + +```json +{ + "path": "/artifacts/openapi.json", + "view": { + "kind": "api-contract", + "collection": "operations", + "methods": ["GET"], + "limit": 20 + } +} +``` + +`operations` supports exact `operation_ids`, `operation_keys`, `methods`, +`paths`, `declared_ids` and `states` filters. Filters combine by intersection; +empty arrays select nothing. `fields` chooses columns; identity, source pointer, +state and unknown reason remain. The other collections are +`path-item-references` (optional `reference_ids`) and `diagnostics` (optional +`codes`), also with fields and offset/limit. The default page contains 64 rows. + +Query a retained result with `inspect_analysis_view`, using the same view and +`source: {"kind":"retained-evidence","evidence_id":"..."}`. Copy the complete +parent ID from `normalized_result.parent_evidence_id`, not the summary's own ID. +These pages use immutable retained facts without rereading the specification. +Complete Evidence remains available through `get_evidence_bundle`. + +CLI defaults to complete Evidence. For a selected answer, save that complete +Evidence in an absent bundle path: + +```sh +rea inspect-api-contract ./openapi.json --detail summary \ + --view '{"kind":"api-contract","collection":"operations","methods":["GET"],"limit":20}' \ + --evidence_output ./contract.evidence.json --json +``` + +Later `rea inspect-analysis-view` can read that bundle through an exact +`evidence-file` source selection, even after the original specification is +unavailable. Publication never overwrites an existing file. CLI preserves its +original path spelling in `caller_input_path`; analysis locations and MCP inputs +use absolute filesystem paths. + +## Identity, provenance and coverage + +Each operation has an artifact-scoped `operation_id`, an exact method/path +`operation_key`, an optional `declared_id` from `operationId`, and its original +JSON Pointer. Keys can correlate exact declarations in a caller-selected pair +of revisions; they do not establish equivalence across unrelated APIs, renamed +path templates or runtime handlers. Declared IDs are not assumed unique. +The artifact includes the verified byte length, SHA-256 and selected path. + +Malformed siblings retain useful declarations plus diagnostics. A Path Item +`$ref`, including a local fragment, remains unresolved; adjacent inline slots +are unknown because referenced fields may overlap. Missing or malformed paths +are explicit, and missing required OpenAPI 3.0 paths does not become a complete +empty inventory. `source_coverage` is limited to inline paths and records that +reference resolution and full schema validation were not performed. Page +exhaustion describes the selected rows and never upgrades source coverage. + +Structured transport userinfo credentials are excluded from reference URIs; +`credential_redacted` reports that change, and an ambiguous credential span leaves +the displayed reference unknown. Ordinary reference query/fragment text and +names are retained. Original bytes remain in the caller's artifact; complete +inventory Evidence does not embed the entire source JSON. Duplicate JSON members +use the shared parser's last-value-wins behavior, recorded in source coverage. + +If decoding or later validation fails after a verified read, the typed error +retains a compact `file-snapshot-hash` observation with the exact path, digest +and byte length. Syntax diagnostics do not quote source JSON. Cancellation +does not replace a parser failure that has already completed. + +## Resource bounds and verification + +The file-byte allowance and conservative expanded-facts allowance are each +32 MiB resource policies, not OpenAPI format limits. `max_file_bytes` can lower +the input allowance. Native JSON parsing additionally checks available heap +against the shared representation-expansion estimate before decoding. The +shared JSON nesting limit is 1,000, including uninterpreted schema regions. +Capacity refusals are typed; reply paging does not increase parser allowances. + +The committed [Petstore fixture](https://github.com/morluto/rea/blob/b7c8caedd4166e6032c49024cdc6fb1154f1a2df/tests/fixtures/openapi/README.md) is an +original document from the published application, replayed offline through +public CLI/MCP tests. These check advertised schemas, complete/selected parity, +retained paging, partial references, zero requests to a loopback reference +server and unchanged source bytes. They do not establish runtime endpoint +behavior or full OpenAPI conformance. diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 07e1afd8f7..93e9be79e3 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -572,6 +572,18 @@ that the response budget accounts for. It does not alter `content` or such as requiring approval for process-launching or device-mutating tools after target content was read. +## Offline API contract declarations + +`inspect_api_contract` reads a local OpenAPI 3.0/3.1/3.2 JSON document without +an active target or any requests to declared servers or references. Default +summary delivery retains complete declaration Evidence; an optional +`api-contract` view selects operations, unresolved Path Items or diagnostics in +this call. Exact filters, field selection and paging share the retained +`inspect_analysis_view` projector. Coverage describes inline paths only and +never establishes runtime implementation or full specification conformance. +See [offline API contracts](api-contracts.md) for identities, source pointers, +resource bounds and CLI publication. + ## Retained application Evidence inputs `analyze_javascript_application`, `inspect_binary_layout`, `inspect_artifact`, diff --git a/src/application/Utf8JsonInput.ts b/src/application/Utf8JsonInput.ts index 3844fd6454..6981a3146e 100644 --- a/src/application/Utf8JsonInput.ts +++ b/src/application/Utf8JsonInput.ts @@ -92,5 +92,15 @@ export const parseUtf8Json = ( cause: undefined, userMessage: JSON_BYTE_ORDER_MARK_MESSAGE, }; - return safeParseJson(text); + const parsed = safeParseJson(text); + // Native syntax errors can quote source snippets containing transport + // credentials. Keep the original exception internal, never in public text. + return parsed.ok + ? parsed + : { + ok: false, + error: + "JSON input is not valid JSON. Check syntax in the selected file.", + cause: parsed.cause, + }; }; diff --git a/src/application/apiContract/ApiContractRead.ts b/src/application/apiContract/ApiContractRead.ts new file mode 100644 index 0000000000..1382042a1e --- /dev/null +++ b/src/application/apiContract/ApiContractRead.ts @@ -0,0 +1,133 @@ +import { + artifactFileCancellationCause, + artifactFilePrimaryFacts, +} from "../../artifacts/ArtifactFileOperation.js"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + projectStableArtifactFailure, + stableArtifactInputRecovery, +} from "../../artifacts/StableArtifactFailure.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, +} from "../../domain/analysisErrorCore.js"; +import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; +import { + API_CONTRACT_OPERATION as operation, + API_CONTRACT_PROVIDER, +} from "../../domain/apiContract/apiContract.js"; +import type { InspectApiContractInput } from "../../domain/apiContract/apiContractQuery.js"; +import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; +import { err, ok } from "../../domain/result.js"; + +const inputFailure = (path: string, cause: unknown, message: string) => + new AnalysisInputError( + operation, + { + cause, + ...(cause instanceof ArtifactReaderFailure + ? stableArtifactInputRecovery(cause) + : {}), + }, + [ + { + path: ["path"], + reason: "invalid_format", + message: `${message} (${path})`, + }, + ], + ); + +const readFailure = ( + failure: unknown, + input: InspectApiContractInput, +): AnalysisError => { + const { cause, origin } = artifactFilePrimaryFacts(failure); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure + ) { + if (cause.reason === "cancelled") + return new AnalysisCancelledError(operation, { + cause: artifactFileCancellationCause(cause), + }); + if (cause.reason === "integrity") + return new AnalysisArtifactChangedError( + operation, + input.path, + cause.message, + { cause }, + ); + if (cause.reason === "limit") + return new AnalysisResourceConstraintError( + operation, + cause.boundary ?? "file-size", + cause.message, + { max_file_bytes: input.max_file_bytes }, + { + cause, + retryable: false, + remediationAction: + "Select a complete document within max_file_bytes; increasing reply limits or paging does not change this file admission.", + }, + ); + if (cause.reason === "path" || cause.reason === "format") + return inputFailure(input.path, cause, cause.message); + } + if (cause instanceof Error && "code" in cause) { + if (cause.code === "EACCES" || cause.code === "EPERM") + return new AnalysisAccessDeniedError(operation, input.path, cause.code, { + cause, + }); + if (isSelectedRegularFileError(cause)) + return inputFailure( + input.path, + cause, + "Selected API document could not be read", + ); + } + return new ArtifactOperationError( + operation, + "io", + undefined, + "API document read failed.", + { cause }, + ); +}; + +/** The existing owner verifies exactly the bytes whose SHA-256 the inventory cites. */ +export const readApiContract = async ( + input: InspectApiContractInput, + resources: ArtifactResourceScope, + signal?: AbortSignal, +) => { + try { + return ok( + await resources.run(() => + readStableArtifact( + input.path, + input.max_file_bytes, + signal, + undefined, + resources, + ), + ), + ); + } catch (cause: unknown) { + return err( + projectStableArtifactFailure( + cause, + readFailure(cause, input), + input.path, + API_CONTRACT_PROVIDER.id, + operation, + ), + ); + } +}; diff --git a/src/application/apiContract/ApiContractService.test.ts b/src/application/apiContract/ApiContractService.test.ts new file mode 100644 index 0000000000..dee612195d --- /dev/null +++ b/src/application/apiContract/ApiContractService.test.ts @@ -0,0 +1,321 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { apiContractSchema } from "../../domain/apiContract/apiContract.js"; +import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import { inspectApiContractEvidence } from "./ApiContractService.js"; +import * as readOwner from "./ApiContractRead.js"; +import * as parser from "../../domain/apiContract/parseApiContract.js"; + +const fixture = new URL( + "../../../tests/fixtures/openapi/petstore.json.txt", + import.meta.url, +); +const inputFile = async (bytes: Uint8Array | string) => { + const root = await createTestTempDirectory("rea-api-contract-"); + const path = join(root, "contract.json"); + await writeFile(path, bytes); + return { root, path }; +}; + +it("binds the published source bytes to complete Evidence independently of reply selection", async () => { + const bytes = await readFile(fixture); + const { path } = await inputFile(bytes); + const resources = new ArtifactResourceScope(); + try { + const results = await Promise.all([ + inspectApiContractEvidence({ path }, resources), + inspectApiContractEvidence({ path, detail: "complete" }, resources), + inspectApiContractEvidence( + { + path, + view: { + kind: "api-contract", + collection: "operations", + fields: [], + limit: 1, + }, + }, + resources, + ), + ]); + for (const result of results) expect(result.ok).toBe(true); + const [summary, complete, page] = results; + if (!summary?.ok || !complete?.ok || !page?.ok) + throw new Error("Expected verified imports"); + expect(summary.value.evidence_id).toBe(complete.value.evidence_id); + expect(page.value.evidence_id).toBe(complete.value.evidence_id); + const value = apiContractSchema.parse(complete.value.normalized_result); + expect(value.artifact).toEqual({ + path, + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + }); + expect(value.summary.declared).toBe(19); + expect(complete.value.operation).toBe("inspect_api_contract"); + expect(complete.value.parameters).not.toHaveProperty("detail"); + expect(complete.value.parameters).not.toHaveProperty("view"); + expect((await readFile(path)).equals(bytes)).toBe(true); + } finally { + await resources.close(); + } +}); + +it.each([ + [Buffer.from([255]), "not valid UTF-8"], + [Buffer.from("{broken"), "JSON"], + [Buffer.from("\uFEFF{}"), "byte-order mark"], +])( + "reports malformed UTF-8/JSON without rewriting source %#", + async (bytes, reason) => { + const { path } = await inputFile(bytes); + const resources = new ArtifactResourceScope(); + try { + const result = await inspectApiContractEvidence({ path }, resources); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisInputError"); + expect(JSON.stringify(result.error)).toContain(reason); + expect(result.error.partialObservation).toEqual({ + kind: "file-snapshot-hash", + path, + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + }); + } + expect((await readFile(path)).equals(bytes)).toBe(true); + } finally { + await resources.close(); + } + }, +); + +it("rejects the selected physical byte budget before parsing", async () => { + const { path } = await inputFile("{bad json}"); + const resources = new ArtifactResourceScope(); + try { + const result = await inspectApiContractEvidence( + { path, max_file_bytes: 4 }, + resources, + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisResourceConstraintError"); + if (result.error instanceof AnalysisResourceConstraintError) + expect(result.error.operation).toBe("inspect_api_contract"); + } + } finally { + await resources.close(); + } +}); + +it("bounds nesting even in schema regions omitted from the inventory", async () => { + let nested: unknown = {}; + for (let depth = 0; depth < 1001; depth++) nested = { child: nested }; + const { path } = await inputFile( + JSON.stringify({ + openapi: "3.1.2", + info: { title: "Nested", version: "1" }, + paths: {}, + components: nested, + }), + ); + const resources = new ArtifactResourceScope(); + try { + const result = await inspectApiContractEvidence({ path }, resources); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisInputError"); + expect(JSON.stringify(result.error)).toContain("maximum nesting depth"); + expect(result.error.partialObservation).toMatchObject({ + kind: "file-snapshot-hash", + path, + }); + } + } finally { + await resources.close(); + } +}); + +it("rejects relative, missing and non-regular selections at the input owner", async () => { + const { root } = await inputFile("{}"); + const directory = join(root, "directory"); + await mkdir(directory); + const resources = new ArtifactResourceScope(); + try { + for (const path of [ + "relative.json", + join(root, "missing.json"), + directory, + ]) { + const result = await inspectApiContractEvidence({ path }, resources); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("AnalysisInputError"); + } + } finally { + await resources.close(); + } +}); + +it.skipIf(process.platform === "win32")( + "preserves an actionable regular-file retry for a selected symlink", + async () => { + const { root, path } = await inputFile("{}"); + const link = join(root, "linked.json"); + await symlink(path, link); + const resources = new ArtifactResourceScope(); + try { + const result = await inspectApiContractEvidence( + { path: link }, + resources, + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisInputError"); + expect(JSON.stringify(result.error)).toContain(path); + } + } finally { + await resources.close(); + } + }, +); + +it("honors pre-cancellation with an opaque caller reason", async () => { + const controller = new AbortController(); + const reason = { _tag: "ArtifactReaderFailure", reason: "integrity" }; + controller.abort(reason); + const resources = new ArtifactResourceScope(); + try { + const result = await inspectApiContractEvidence( + { path: "/unused.json" }, + resources, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe(reason); + expect(result.error.partialObservation).toBeUndefined(); + } + } finally { + await resources.close(); + } +}); + +it("keeps native JSON source excerpts internal while preserving the failure cause", async () => { + const bytes = '{"$ref":"https://u:p@h","":NaN}'; + const { path } = await inputFile(bytes); + const resources = new ArtifactResourceScope(); + try { + const result = await inspectApiContractEvidence({ path }, resources); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected a syntax refusal"); + expect(result.error.cause).toBeInstanceOf(SyntaxError); + expect(JSON.stringify(result.error)).not.toContain("u:p@h"); + expect(result.error.partialObservation).toEqual({ + kind: "file-snapshot-hash", + path, + bytes: Buffer.byteLength(bytes), + sha256: createHash("sha256").update(bytes).digest("hex"), + }); + } finally { + await resources.close(); + } +}); + +it.each([ + [{ openapi: "2.0" }, "AnalysisUnsupportedTargetError"], + [ + { + openapi: "3.1.2", + info: { title: "a".repeat(3 * 1024 * 1024), version: "1" }, + }, + "AnalysisResourceConstraintError", + ], +])( + "keeps a completed parser failure ahead of queued cancellation %#", + async (document, tag) => { + const bytes = JSON.stringify(document); + const { path } = await inputFile(bytes); + const resources = new ArtifactResourceScope(); + const controller = new AbortController(); + const realParse = parser.parseApiContractSteps; + const scheduled = vi + .spyOn(parser, "parseApiContractSteps") + .mockImplementationOnce(function* (...args) { + const result = yield* realParse(...args); + expect(result.ok).toBe(false); + setImmediate(() => controller.abort("after-parser-failure")); + return result; + }); + try { + const result = await inspectApiContractEvidence({ path }, resources, { + signal: controller.signal, + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(controller.signal.aborted).toBe(true); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected the parser failure"); + expect(result.error._tag).toBe(tag); + expect(result.error.partialObservation).toEqual({ + kind: "file-snapshot-hash", + path, + bytes: Buffer.byteLength(bytes), + sha256: createHash("sha256").update(bytes).digest("hex"), + }); + } finally { + scheduled.mockRestore(); + await resources.close(); + } + }, +); + +it("cancels a cooperative document traversal after the actual verified file read", async () => { + const paths = Object.fromEntries( + Array.from({ length: 3000 }, (_, index) => [ + `/items/${index}`, + { get: { operationId: String(index) } }, + ]), + ); + const bytes = JSON.stringify({ + openapi: "3.1.2", + info: { title: "Cancellation", version: "1" }, + paths, + }); + const { path } = await inputFile(bytes); + const controller = new AbortController(); + const reason = { stage: "after-source-read" }; + const realRead = readOwner.readApiContract; + const scheduled = vi + .spyOn(readOwner, "readApiContract") + .mockImplementationOnce(async (...args) => { + const loaded = await realRead(...args); + expect(loaded.ok).toBe(true); + setImmediate(() => controller.abort(reason)); + return loaded; + }); + const resources = new ArtifactResourceScope(); + try { + const result = await inspectApiContractEvidence({ path }, resources, { + signal: controller.signal, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe(reason); + expect(result.error.partialObservation).toEqual({ + kind: "file-snapshot-hash", + path, + bytes: Buffer.byteLength(bytes), + sha256: createHash("sha256").update(bytes).digest("hex"), + }); + } + expect(await readFile(path, "utf8")).toBe(bytes); + } finally { + scheduled.mockRestore(); + await resources.close(); + } +}); diff --git a/src/application/apiContract/ApiContractService.ts b/src/application/apiContract/ApiContractService.ts new file mode 100644 index 0000000000..3802f225cc --- /dev/null +++ b/src/application/apiContract/ApiContractService.ts @@ -0,0 +1,178 @@ +import { isAbsolute } from "node:path"; +import { setImmediate } from "node:timers/promises"; +import { getHeapStatistics } from "node:v8"; +import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, +} from "../../domain/analysisErrorCore.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { jsonValueValidationSteps } from "../../domain/jsonValue.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { + API_CONTRACT_OPERATION as operation, + API_CONTRACT_PROVIDER, + type ApiContractArtifact, +} from "../../domain/apiContract/apiContract.js"; +import { + inspectApiContractInputSchema, + type InspectApiContractInput, +} from "../../domain/apiContract/apiContractQuery.js"; +import { parseApiContractSteps } from "../../domain/apiContract/parseApiContract.js"; +import { parseAnalysisInput } from "../AnalysisInput.js"; +import { nativeJsonParseRequiredBytes } from "../JsonInputFile.js"; +import { parseUtf8Json } from "../Utf8JsonInput.js"; +import { readApiContract } from "./ApiContractRead.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; + +const inputFailure = (message: string, cause?: unknown) => + new AnalysisInputError(operation, { cause }, [ + { path: ["path"], reason: "invalid_format", message }, + ]); + +/** Cooperative traversals release their provisional state on cancellation. */ +const completeSteps = async ( + steps: Generator>, + signal?: AbortSignal, +): Promise> => { + let completed = false; + try { + for (;;) { + if (signal?.aborted) + return err( + new AnalysisCancelledError(operation, { cause: signal.reason }), + ); + const next = steps.next(); + if (next.done) { + completed = true; + if (!next.value.ok) return next.value; + // Include cancellation queued during the final synchronous projection. + await setImmediate(); + return signal?.aborted + ? err(new AnalysisCancelledError(operation, { cause: signal.reason })) + : next.value; + } + await setImmediate(); + } + } finally { + if (!completed) steps.return(undefined as never); + } +}; + +const decodeDocument = ( + bytes: Buffer, + path: string, +): Result => { + const required = nativeJsonParseRequiredBytes(bytes.length); + const available = getHeapStatistics().total_available_size; + if (required > available) + return err( + new AnalysisResourceConstraintError( + operation, + "memory", + "Insufficient parser headroom for the admitted JSON representation expansion.", + { + input_bytes: bytes.length, + required_bytes: required, + available_heap_bytes: available, + }, + { + remediationAction: + "Select a smaller complete document or free parser memory; paging a reply does not reduce JSON parsing work.", + }, + ), + ); + try { + const parsed = parseUtf8Json(bytes, operation, path); + return parsed.ok + ? ok(parsed.value) + : err(inputFailure(parsed.error, parsed.cause)); + } catch (cause: unknown) { + if (cause instanceof AnalysisError) return err(cause); + throw cause; + } +}; + +function* documentSteps(document: unknown, artifact: ApiContractArtifact) { + const issue = yield* jsonValueValidationSteps(document); + if (issue !== undefined) return err(inputFailure(issue)); + return yield* parseApiContractSteps(document, artifact); +} + +const importDocument = async ( + input: InspectApiContractInput, + bytes: Buffer, + sha256: string, + signal?: AbortSignal, +): Promise> => { + const document = decodeDocument(bytes, input.path); + if (!document.ok) return document; + const parsed = await completeSteps( + documentSteps(document.value, { + path: input.path, + sha256, + bytes: bytes.length, + }), + signal, + ); + if (!parsed.ok) return parsed; + const result = parsed.value; + return completeProviderEvidence( + createEvidence( + { path: input.path, format: "file", sha256 }, + API_CONTRACT_PROVIDER, + { + operation, + predicateType: "rea.api-contract-inventory", + parameters: { + path: input.path, + format: input.format, + max_file_bytes: input.max_file_bytes, + }, + result, + confidence: "observed", + limitations: result.limitations, + locations: [{ kind: "artifact-path", path: input.path }], + }, + ), + signal, + ); +}; + +/** Shared CLI/MCP import: one verified file read, no references, network or target execution. */ +export const inspectApiContractEvidence = async ( + rawInput: unknown, + resources: ArtifactResourceScope, + options: { readonly signal?: AbortSignal } = {}, +): Promise> => { + const admitted = parseAnalysisInput( + operation, + rawInput, + inspectApiContractInputSchema, + options.signal, + ); + if (!admitted.ok) return admitted; + const input = admitted.value; + if (!isAbsolute(input.path)) + return err( + inputFailure("Expected an absolute filesystem path on this host."), + ); + const source = await readApiContract(input, resources, options.signal); + if (!source.ok) return source; + const result = await importDocument( + input, + source.value.bytes, + source.value.sha256, + options.signal, + ); + if (!result.ok) + result.error.retainPartialObservation({ + kind: "file-snapshot-hash", + path: input.path, + sha256: source.value.sha256, + bytes: source.value.bytes.length, + }); + return result; +}; diff --git a/src/cli/apiContractCommand.ts b/src/cli/apiContractCommand.ts new file mode 100644 index 0000000000..46628a3014 --- /dev/null +++ b/src/cli/apiContractCommand.ts @@ -0,0 +1,108 @@ +import { z } from "incur"; +import type { Logger } from "pino"; +import type { CliInstance } from "./types.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { logCliCommand } from "../cliLogging.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { projectArtifactCloseFailure } from "../artifacts/StableArtifactFailure.js"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; +import { localPathStringSchema } from "../domain/localPath.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { + API_CONTRACT_OPERATION as operation, + API_CONTRACT_PROVIDER, +} from "../domain/apiContract/apiContract.js"; +import { + apiContractViewRequestSchema, + inspectApiContractInputSchema, +} from "../domain/apiContract/apiContractQuery.js"; +import { withCommandCancellation } from "./commandCancellation.js"; +import { + analysisEvidenceDeliveryOptions, + parseCliAnalysisEvidenceDelivery, + deliverCliAnalysisEvidence, +} from "./analysisEvidenceDelivery.js"; + +const deliverySchema = z + .object({ + detail: analysisEvidenceDeliveryOptions.detail, + view: apiContractViewRequestSchema.optional(), + }) + .superRefine((value, context) => { + if (value.view !== undefined && value.detail !== "summary") + context.addIssue({ + code: "custom", + path: ["view"], + message: + "A selected page requires --detail summary and --evidence_output.", + }); + }); + +/** Standard one-shot publication keeps complete inventory Evidence for later offline pages. */ +export const registerApiContractCommand = ( + cli: CliInstance, + logger: Logger, +): void => { + cli.command(CLI_COMMANDS.inspectApiContract, { + description: + "Inventory local OpenAPI 3.0/3.1/3.2 JSON declarations offline without resolving references", + args: z.object({ + path: localPathStringSchema.describe("Local OpenAPI JSON path"), + }), + options: z.object({ + ...analysisEvidenceDeliveryOptions, + maxFileBytes: inspectApiContractInputSchema.shape.max_file_bytes, + view: z + .string() + .optional() + .describe( + "JSON api-contract page; requires --detail summary and --evidence_output", + ), + }), + run: ({ args, options }) => + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.inspectApiContract, async () => { + const selected = parseCliAnalysisEvidenceDelivery( + operation, + options, + deliverySchema, + ); + if (!selected.ok) return projectAnalysisError(selected.error); + const resources = new ArtifactResourceScope(); + const { inspectApiContractEvidence } = + await import("../application/apiContract/ApiContractService.js"); + return deliverCliAnalysisEvidence({ + callerInputPath: args.path, + run: () => + inspectApiContractEvidence( + { + path: absoluteCallerPath(args.path), + max_file_bytes: options.maxFileBytes, + detail: "complete", + }, + resources, + { signal }, + ), + service: { + close: () => + resources.close().catch((cause: unknown) => { + throw projectArtifactCloseFailure( + cause, + API_CONTRACT_PROVIDER.id, + operation, + ); + }), + }, + providerId: API_CONTRACT_PROVIDER.id, + operation, + signal, + detail: selected.value.detail, + evidenceOutput: selected.value.evidenceOutput, + ...(selected.value.view === undefined + ? {} + : { view: selected.value.view }), + }); + }), + ), + }); +}; diff --git a/src/cli/applicationCommands.ts b/src/cli/applicationCommands.ts index fe14a4f0f3..fea2774ae0 100644 --- a/src/cli/applicationCommands.ts +++ b/src/cli/applicationCommands.ts @@ -1,3 +1,4 @@ +import { registerApiContractCommand } from "./apiContractCommand.js"; import { Cli, z } from "incur"; import { CLI_COMMANDS } from "../cliCommandNames.js"; @@ -74,6 +75,7 @@ export const registerApplicationCommands = ( }); registerSourceToBundleCommand(cli, logger); registerStaticObservedEndpointsCommand(cli, logger); + registerApiContractCommand(cli, logger); registerEvidenceJsonCommand({ cli, logger, diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 60d2be5ad6..626570fbcc 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -68,6 +68,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectGoBinary: "inspect-go-binary", inspectIl2cppMetadata: "inspect-il2cpp-metadata", inspectModelArtifact: "inspect-model-artifact", + inspectApiContract: "inspect-api-contract", inspectGpuCodeObjects: "inspect-gpu-code-objects", inspectRustBinary: "inspect-rust-binary", inspectPythonPyc: "inspect-python-pyc", diff --git a/src/contracts/apiContractToolContracts.ts b/src/contracts/apiContractToolContracts.ts new file mode 100644 index 0000000000..833eab1cc1 --- /dev/null +++ b/src/contracts/apiContractToolContracts.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; +import { apiContractSchema } from "../domain/apiContract/apiContract.js"; +import { inspectApiContractInputSchema } from "../domain/apiContract/apiContractQuery.js"; +import { + apiContractSummaryViewResultSchema, + apiContractPageViewResultSchema, +} from "../domain/analysisView/analysisView.js"; +import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js"; +import { toolContractMetadata } from "./toolEffects.js"; +import type { ToolContract } from "./toolContractTypes.js"; + +/** Named, provider-free contract for declarations in one local specification. */ +export const API_CONTRACT_TOOL_CONTRACTS = [ + { + name: "inspect_api_contract", + ...toolContractMetadata("inspect_api_contract"), + kind: "application", + description: + "Inventory declared operations in a local OpenAPI 3.0/3.1/3.2 JSON document offline. No target session, network, endpoint requests or reference resolution. Returns exact method/path, source JSON Pointers, source-scoped IDs and separate exact method/path keys. Candidate runtime behavior is never inferred from a contract. Default summary retains complete Evidence; optional view returns selected operations, unresolved Path Items or diagnostics immediately and takes precedence over detail. Retained inspect_analysis_view kind api-contract supports exact batch ID/method/path selection, fields and paging. Coverage describes inline paths only; callbacks/webhooks, schemas, servers, parameters and security remain unanalyzed. Malformed siblings and unresolved references remain explicit; this is not full OpenAPI validation. File and expanded-facts allowances are each 32 MiB, with JSON parsing additionally admitted against available heap and the shared nesting limit.", + inputSchema: inspectApiContractInputSchema, + outputSchema: evidenceResultOf( + z.union([ + apiContractSchema, + apiContractSummaryViewResultSchema, + apiContractPageViewResultSchema, + ]), + ), + examples: [ + { + title: + "Read selected declared HTTP operations without contacting servers", + input: { + path: "/artifacts/openapi.json", + view: { + kind: "api-contract", + collection: "operations", + methods: ["GET"], + limit: 20, + }, + }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/applicationToolContracts.ts b/src/contracts/applicationToolContracts.ts index 09b0e18959..fb48b71608 100644 --- a/src/contracts/applicationToolContracts.ts +++ b/src/contracts/applicationToolContracts.ts @@ -1,3 +1,4 @@ +import { API_CONTRACT_TOOL_CONTRACTS } from "./apiContractToolContracts.js"; import { applicationInventoryProjectionInputSchema } from "../domain/mobileApplicationGraphProjection.js"; import { z } from "zod"; import { @@ -92,6 +93,7 @@ const retained = (evidence_id: string) => ({ /** Provider-neutral graph workflow contracts shared by MCP and CLI adapters. */ export const APPLICATION_TOOL_CONTRACTS = [ + ...API_CONTRACT_TOOL_CONTRACTS, { name: "search_guidance", ...toolContractMetadata("search_guidance"), diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 4e98ab96a7..9793726836 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -158,6 +158,7 @@ const TOOL_EFFECT_AUDIT: Readonly> = { inspect_go_binary: evidence, inspect_il2cpp_metadata: evidence, inspect_model_artifact: evidence, + inspect_api_contract: evidence, inspect_gpu_code_objects: evidence, inspect_rust_binary: evidence, inspect_apk_signature: evidence, diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index cf2d23698c..e675d686de 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -1,3 +1,4 @@ +import { projectApiContractView } from "./apiContractView.js"; import { isNativeObservationOperation, NATIVE_OBSERVATION_OPERATIONS, @@ -157,6 +158,8 @@ import { sourceToBundleSummaryViewResultSchema, sourceToBundlePageViewResultSchema, endpointComparisonSummaryViewResultSchema, + apiContractSummaryViewResultSchema, + apiContractPageViewResultSchema, endpointComparisonPageViewResultSchema, featureTraceSummaryViewResultSchema, batchDecompileSummaryViewResultSchema, @@ -228,6 +231,8 @@ export { sourceToBundleSummaryViewResultSchema, sourceToBundlePageViewResultSchema, endpointComparisonSummaryViewResultSchema, + apiContractSummaryViewResultSchema, + apiContractPageViewResultSchema, endpointComparisonPageViewResultSchema, featureTraceSummaryViewResultSchema, batchDecompileSummaryViewResultSchema, @@ -356,6 +361,7 @@ const retainedViewProjectors = new Map([ retain("inspect_gpu_code_objects", projectGpuCodeObjectsView), retain("match_native_functions", projectNativeFunctionMatchingView), retain("compare_source_to_bundle", projectSourceToBundleView), + retain("inspect_api_contract", projectApiContractView), retain( "compare_static_and_observed_endpoints", projectEndpointComparisonView, diff --git a/src/domain/analysisView/analysisViewRequest.ts b/src/domain/analysisView/analysisViewRequest.ts index 716562a1b4..93e1f17fde 100644 --- a/src/domain/analysisView/analysisViewRequest.ts +++ b/src/domain/analysisView/analysisViewRequest.ts @@ -1,3 +1,4 @@ +import { apiContractViewRequestSchema } from "../apiContract/apiContractQuery.js"; import { nativeObservationViewRequestSchema, nativeContextViewRequestSchema, @@ -74,6 +75,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ nativeFunctionMatchingViewRequestSchema, sourceToBundleViewRequestSchema, endpointComparisonViewRequestSchema, + apiContractViewRequestSchema, featureTraceViewRequestSchema, nativeFeatureTraceViewRequestSchema, batchDecompileViewRequestSchema, diff --git a/src/domain/analysisView/analysisViewResult.ts b/src/domain/analysisView/analysisViewResult.ts index 32acb45c50..b8f9a29851 100644 --- a/src/domain/analysisView/analysisViewResult.ts +++ b/src/domain/analysisView/analysisViewResult.ts @@ -1,3 +1,9 @@ +import { apiContractViewRequestSchema } from "../apiContract/apiContractQuery.js"; +import { apiContractArtifactSchema } from "../apiContract/apiContract.js"; +import { + apiContractViewContextSchema, + apiContractViewItemsSchema, +} from "./apiContractView.js"; import { analysisViewCoverageSchema, nullableCollectionViewCoverageSchema, @@ -1022,6 +1028,20 @@ export const sourceToBundleSummaryViewResultSchema = z.strictObject({ parent_operation: z.literal("compare_source_to_bundle"), summary: sourceToBundleViewContextSchema, }); +export const apiContractSummaryViewResultSchema = z.strictObject({ + ...summaryViewResultBase, + parent_operation: z.literal("inspect_api_contract"), + summary: apiContractViewContextSchema, +}); +export const apiContractPageViewResultSchema = z.strictObject({ + ...viewResultBase, + ...apiContractViewContextSchema.shape, + kind: z.literal("api-contract"), + artifact: apiContractArtifactSchema, + parent_operation: z.literal("inspect_api_contract"), + view: apiContractViewRequestSchema, + items: apiContractViewItemsSchema, +}); export const endpointComparisonSummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, artifact: z.null(), @@ -1167,6 +1187,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ webBundlePageViewResultSchema, sourceToBundlePageViewResultSchema, endpointComparisonPageViewResultSchema, + apiContractPageViewResultSchema, featureTraceViewResultSchema, batchDecompileViewResultSchema, z.strictObject({ @@ -1263,6 +1284,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ nativeFunctionSummaryViewResultSchema, sourceToBundleSummaryViewResultSchema, endpointComparisonSummaryViewResultSchema, + apiContractSummaryViewResultSchema, featureTraceSummaryViewResultSchema, batchDecompileSummaryViewResultSchema, webBundleSummaryViewResultSchema, diff --git a/src/domain/analysisView/apiContractView.ts b/src/domain/analysisView/apiContractView.ts new file mode 100644 index 0000000000..6040ac5caf --- /dev/null +++ b/src/domain/analysisView/apiContractView.ts @@ -0,0 +1,211 @@ +import { z } from "zod"; +import { + apiContractSchema, + apiContractOperationSchema, + apiContractPathReferenceSchema, + apiContractDiagnosticSchema, + API_CONTRACT_OPERATION, + type ApiContract, +} from "../apiContract/apiContract.js"; +import { apiContractViewRequestSchema } from "../apiContract/apiContractQuery.js"; +import type { JsonValue } from "../jsonValue.js"; +import { err, ok } from "../result.js"; +import { + createAnalysisViewResultReader, + type AnalysisViewParent, +} from "./analysisViewParent.js"; +import { + ownedView, + type AdmittedView, + type SummaryAnalysisView, +} from "./analysisViewShape.js"; +import { + boundedViewMessages, + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; +import { selectViewRows } from "./selectViewRows.js"; +import { analysisViewInputError } from "./analysisViewErrors.js"; + +export const apiContractViewContextSchema = apiContractSchema.omit({ + operations: true, + path_item_references: true, + diagnostics: true, + limitations: true, +}); +export const apiContractViewItemsSchema = z.union([ + z.array( + apiContractOperationSchema.partial().required({ + operation_id: true, + operation_key: true, + source_pointer: true, + state: true, + reason: true, + }), + ), + z.array( + apiContractPathReferenceSchema.partial().required({ + reference_id: true, + source_pointer: true, + state: true, + credential_redacted: true, + }), + ), + z.array( + apiContractDiagnosticSchema + .partial() + .required({ diagnostic_id: true, source_pointer: true, code: true }), + ), +]); +const readReport = createAnalysisViewResultReader(apiContractSchema); +const values = (selected: readonly string[] | undefined) => + selected === undefined ? undefined : new Set(selected); +const admits = (selection: ReadonlySet | undefined, value: string) => + selection === undefined || selection.has(value); +const fieldsOf = ( + row: object, + fields: readonly string[], +): Record => { + const source = row as Record; + return Object.fromEntries(fields.map((field) => [field, source[field]!])); +}; + +const selectOperations = ( + report: ApiContract, + view: Extract< + z.output, + { collection: "operations" } + >, +) => { + const ids = values(view.operation_ids), + keys = values(view.operation_keys), + methods = values(view.methods), + paths = values(view.paths), + declared = values(view.declared_ids), + states = values(view.states); + return selectViewRows( + report.operations, + view, + (row) => + admits(ids, row.operation_id) && + admits(keys, row.operation_key) && + admits(methods, row.method) && + admits(paths, row.path) && + admits(states, row.state) && + (declared === undefined || + (row.declared_id !== null && declared.has(row.declared_id))), + (row) => ({ + ...fieldsOf(row, view.fields ?? ["method", "path", "declared_id"]), + operation_id: row.operation_id, + operation_key: row.operation_key, + source_pointer: row.source_pointer, + state: row.state, + reason: row.reason, + }), + ); +}; + +const selectRows = ( + report: ApiContract, + view: z.output, +) => { + if (view.collection === "operations") return selectOperations(report, view); + if (view.collection === "path-item-references") { + const ids = values(view.reference_ids); + return selectViewRows( + report.path_item_references, + view, + (row) => admits(ids, row.reference_id), + (row) => ({ + ...fieldsOf(row, view.fields ?? ["path", "reference"]), + reference_id: row.reference_id, + source_pointer: row.source_pointer, + state: row.state, + credential_redacted: row.credential_redacted, + }), + ); + } + const codes = values(view.codes); + return selectViewRows( + report.diagnostics, + view, + (row) => admits(codes, row.code), + (row) => ({ + ...fieldsOf(row, view.fields ?? ["message"]), + diagnostic_id: row.diagnostic_id, + source_pointer: row.source_pointer, + code: row.code, + }), + ); +}; + +/** Queries borrow completed declarations; an exhausted page never upgrades source completeness. */ +export const projectApiContractView = ( + parent: AnalysisViewParent, + admitted: AdmittedView, +) => { + const view = ownedView< + SummaryAnalysisView | z.output + >(admitted, "summary", "api-contract"); + if (view === undefined) + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: + "API contract inventories support summary and api-contract pages.", + }, + ]), + ); + const parsed = readReport(parent); + if (!parsed.ok) return parsed; + const { + operations: _operations, + path_item_references: _references, + diagnostics: _diagnostics, + limitations, + ...context + } = parsed.value; + const shared = { + parent_evidence_id: parent.evidenceId, + parent_operation: API_CONTRACT_OPERATION, + parent_digest: parent.evidenceId.slice(3), + limitations: boundedViewMessages( + [...parent.limitations, ...limitations], + parent.evidenceId, + "limitations", + ), + unknowns: + context.source_coverage.status === "partial" + ? [ + "Unresolved or malformed declarations limit this inventory; query path-item-references and diagnostics for exact source pointers.", + ] + : [], + }; + if (view.kind === "summary") + return ok({ + ...shared, + kind: "summary" as const, + view, + artifact: { + path: context.artifact.path, + sha256: context.artifact.sha256, + }, + summary: context, + coverage: completeWithinViewCoverage(1, 1), + }); + const selected = selectRows(parsed.value, view); + return ok({ + ...shared, + ...context, + kind: "api-contract" as const, + view, + items: selected.rows, + coverage: pageViewCoverage( + view.offset, + selected.rows.length, + selected.total, + ), + }); +}; diff --git a/src/domain/apiContract/apiContract.ts b/src/domain/apiContract/apiContract.ts new file mode 100644 index 0000000000..024c08e107 --- /dev/null +++ b/src/domain/apiContract/apiContract.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; +import { digestSchema, prefixedDigestSchema } from "../digests.js"; +import { localPathStringSchema } from "../localPath.js"; + +/** Resource allowances, not OpenAPI format limits. */ +export const API_CONTRACT_MAX_FILE_BYTES = 32 * 1024 * 1024; +export const API_CONTRACT_FACT_BYTES = 32 * 1024 * 1024; +export const API_CONTRACT_OPERATION = "inspect_api_contract" as const; +export const API_CONTRACT_PROVIDER = { + id: "rea-api-contract", + name: "REA offline API contract inventory", + version: "1", +} as const; + +export const apiContractArtifactSchema = z.strictObject({ + path: localPathStringSchema, + sha256: digestSchema, + bytes: z.int().nonnegative(), +}); + +/** Identity is source-scoped; operation_key separately survives source revisions. */ +export const apiContractOperationSchema = z.strictObject({ + operation_id: prefixedDigestSchema("api_operation"), + operation_key: prefixedDigestSchema("api_operation_key"), + method: z.string(), + path: z.string(), + declared_id: z.string().nullable(), + source_pointer: z.string(), + state: z.enum(["declared", "unknown"]), + reason: z + .enum(["unresolved-path-item-reference", "malformed-operation"]) + .nullable(), +}); +export const apiContractPathReferenceSchema = z.strictObject({ + reference_id: prefixedDigestSchema("api_path_reference"), + path: z.string(), + source_pointer: z.string(), + reference: z.string().nullable(), + credential_redacted: z.boolean(), + state: z.enum(["unresolved", "malformed"]), +}); +export const apiContractDiagnosticSchema = z.strictObject({ + diagnostic_id: prefixedDigestSchema("api_contract_diagnostic"), + source_pointer: z.string(), + code: z.enum([ + "invalid-info", + "invalid-paths", + "invalid-path-key", + "invalid-path-item", + "invalid-path-reference", + "invalid-operation", + "invalid-operation-id", + "unknown-path-item-field", + "invalid-additional-operations", + "invalid-additional-method", + ]), + message: z.string(), +}); + +export const apiContractSchema = z.strictObject({ + contract_id: prefixedDigestSchema("api_contract"), + format: z.literal("openapi-json"), + openapi: z.string(), + artifact: apiContractArtifactSchema, + info: z.strictObject({ + title: z.string().nullable(), + version: z.string().nullable(), + }), + operations: z.array(apiContractOperationSchema), + path_item_references: z.array(apiContractPathReferenceSchema), + diagnostics: z.array(apiContractDiagnosticSchema), + summary: z.strictObject({ + operation_slots: z.int().nonnegative(), + declared: z.int().nonnegative(), + unknown: z.int().nonnegative(), + unresolved_path_items: z.int().nonnegative(), + diagnostics: z.int().nonnegative(), + }), + source_coverage: z.strictObject({ + scope: z.literal("inline-path-operations"), + status: z.enum(["complete-within-scope", "partial"]), + paths_state: z.enum(["object", "absent", "malformed"]), + path_entries_examined: z.int().nonnegative(), + reference_resolution: z.literal("not-attempted"), + schema_validation: z.literal("not-performed"), + duplicate_json_members: z.literal("last-value-wins"), + }), + limitations: z.array(z.string()), +}); +export type ApiContract = z.output; +export type ApiContractOperation = z.output; +export type ApiContractDiagnostic = z.output< + typeof apiContractDiagnosticSchema +>; +export type ApiContractArtifact = z.output; diff --git a/src/domain/apiContract/apiContractQuery.ts b/src/domain/apiContract/apiContractQuery.ts new file mode 100644 index 0000000000..07af27e9db --- /dev/null +++ b/src/domain/apiContract/apiContractQuery.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; +import { localPathStringSchema } from "../localPath.js"; +import { + API_CONTRACT_MAX_FILE_BYTES, + apiContractDiagnosticSchema, + apiContractOperationSchema, + apiContractPathReferenceSchema, +} from "./apiContract.js"; + +const page = { + kind: z.literal("api-contract"), + offset: z.int().nonnegative().default(0), + limit: z.int().positive().default(64), +}; +export const apiContractViewRequestSchema = z.discriminatedUnion("collection", [ + z.strictObject({ + ...page, + collection: z.literal("operations"), + operation_ids: z + .array(apiContractOperationSchema.shape.operation_id) + .optional(), + operation_keys: z + .array(apiContractOperationSchema.shape.operation_key) + .optional(), + methods: z.array(z.string()).optional(), + paths: z.array(z.string()).optional(), + declared_ids: z.array(z.string()).optional(), + states: z.array(apiContractOperationSchema.shape.state).optional(), + fields: z.array(apiContractOperationSchema.keyof()).optional(), + }), + z.strictObject({ + ...page, + collection: z.literal("path-item-references"), + reference_ids: z + .array(apiContractPathReferenceSchema.shape.reference_id) + .optional(), + fields: z.array(apiContractPathReferenceSchema.keyof()).optional(), + }), + z.strictObject({ + ...page, + collection: z.literal("diagnostics"), + codes: z.array(apiContractDiagnosticSchema.shape.code).optional(), + fields: z.array(apiContractDiagnosticSchema.keyof()).optional(), + }), +]); + +export const inspectApiContractInputSchema = z.strictObject({ + path: localPathStringSchema.describe( + "Absolute path to one local OpenAPI JSON document", + ), + format: z.literal("openapi-json").default("openapi-json"), + max_file_bytes: z + .int() + .positive() + .max(API_CONTRACT_MAX_FILE_BYTES) + .default(API_CONTRACT_MAX_FILE_BYTES) + .describe( + "Maximum input document bytes (up to 32 MiB); reply paging does not increase parser allowances", + ), + detail: z.enum(["summary", "complete"]).default("summary"), + view: apiContractViewRequestSchema + .optional() + .describe( + "Return this selected page immediately; complete Evidence is retained and view takes precedence over detail", + ), +}); +export type InspectApiContractInput = z.output< + typeof inspectApiContractInputSchema +>; diff --git a/src/domain/apiContract/parseApiContract.test.ts b/src/domain/apiContract/parseApiContract.test.ts new file mode 100644 index 0000000000..7bad1c079c --- /dev/null +++ b/src/domain/apiContract/parseApiContract.test.ts @@ -0,0 +1,314 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { expect, it } from "vitest"; +import { apiContractSchema, type ApiContractArtifact } from "./apiContract.js"; +import { parseApiContractSteps } from "./parseApiContract.js"; + +const artifact: ApiContractArtifact = { + path: "/contract.json", + sha256: "a".repeat(64), + bytes: 1, +}; +const document = (paths: unknown, openapi = "3.0.4") => ({ + openapi, + info: { title: "Owned API", version: "1" }, + paths, +}); +const parse = (value: unknown, source = artifact) => { + const steps = parseApiContractSteps(value, source); + for (;;) { + const step = steps.next(); + if (step.done) return step.value; + } +}; +const report = (value: unknown, source = artifact) => { + const result = parse(value, source); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + return apiContractSchema.parse(result.value); +}; + +it("reads a real published Petstore document without embedding its schemas", () => { + const bytes = readFileSync( + new URL( + "../../../tests/fixtures/openapi/petstore.json.txt", + import.meta.url, + ), + ); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + expect(sha256).toBe( + "00441c05add60f28da55eb4563d3fb6bbda364ba8eb7b381e2e2c98613005c82", + ); + const value = report(JSON.parse(bytes.toString("utf8")), { + ...artifact, + sha256, + bytes: bytes.length, + }); + expect(value.openapi).toBe("3.0.4"); + expect(value.info).toEqual({ + title: "Swagger Petstore - OpenAPI 3.0", + version: "1.0.27", + }); + expect(value.summary).toEqual({ + operation_slots: 19, + declared: 19, + unknown: 0, + unresolved_path_items: 0, + diagnostics: 0, + }); + expect(value.source_coverage).toMatchObject({ + status: "complete-within-scope", + path_entries_examined: 13, + reference_resolution: "not-attempted", + schema_validation: "not-performed", + }); + expect( + value.operations.find((row) => row.declared_id === "getPetById"), + ).toMatchObject({ + method: "GET", + path: "/pet/{petId}", + source_pointer: "/paths/~1pet~1{petId}/get", + state: "declared", + }); + expect(value).not.toHaveProperty("components"); + expect(value).not.toHaveProperty("servers"); +}); + +it.each(["3.0.4", "3.1.2", "3.2.1"])( + "accepts the explicit %s minor family", + (version) => { + expect( + report(document({ "/x": { get: {} } }, version)).summary.declared, + ).toBe(1); + }, +); + +it.each(["2.0", "3.3.0", "3.2", "3.01.0", "3.0.01", "3.2.0-beta.1"])( + "refuses an unsupported version %s explicitly", + (version) => { + const value = parse(document({}, version)); + expect(value.ok).toBe(false); + if (!value.ok) + expect(value.error._tag).toBe("AnalysisUnsupportedTargetError"); + }, +); + +it.each([null, [], {}, { openapi: 3 }])( + "rejects a malformed root %#", + (value) => { + const parsed = parse(value); + expect(parsed.ok).toBe(false); + if (!parsed.ok) expect(parsed.error._tag).toBe("AnalysisInputError"); + }, +); + +it("keeps source-scoped identities separate from exact method/path keys and declared IDs", () => { + const input = document({ + "/a/~b": { get: { operationId: "same" } }, + "/b": { get: { operationId: "same" } }, + }); + const first = report(input); + const copied = report(input, { ...artifact, path: "/copy.json" }); + const revised = report(input, { ...artifact, sha256: "b".repeat(64) }); + expect(first.operations[0]?.source_pointer).toBe("/paths/~1a~1~0b/get"); + expect(copied.operations).toEqual(first.operations); + expect(revised.operations.map((row) => row.operation_key)).toEqual( + first.operations.map((row) => row.operation_key), + ); + expect(revised.operations[0]?.operation_id).not.toBe( + first.operations[0]?.operation_id, + ); + expect(new Set(first.operations.map((row) => row.operation_id)).size).toBe(2); + expect(first.operations.every((row) => row.declared_id === "same")).toBe( + true, + ); +}); + +it("preserves unresolved local, cyclic and remote Path Items without following them", () => { + const input = document({ + "/inline": { $ref: "#/paths/~1inline", get: { operationId: "inline" } }, + "/local": { $ref: "./sibling.json#/paths/~1other" }, + "/remote": { $ref: "http://127.0.0.1:9/reference.json" }, + }); + const value = report(input); + expect(value.path_item_references.map((row) => row.reference)).toEqual([ + "#/paths/~1inline", + "./sibling.json#/paths/~1other", + "http://127.0.0.1:9/reference.json", + ]); + expect(value.operations).toHaveLength(1); + expect(value.operations[0]).toMatchObject({ + state: "unknown", + reason: "unresolved-path-item-reference", + }); + expect(value.source_coverage.status).toBe("partial"); + expect(value.summary).toMatchObject({ + declared: 0, + unknown: 1, + unresolved_path_items: 3, + }); +}); + +it("keeps usable siblings, malformed slots and missing values distinct", () => { + const input = document({ + "/ok": { get: { operationId: "" }, post: { operationId: 0 } }, + "/broken": { get: null }, + "/bad-ref": { $ref: false, get: {} }, + "/bad-item": 0, + invalid: { get: {} }, + "x-doc": { get: {} }, + }); + const value = report(input); + expect(value.summary).toEqual({ + operation_slots: 4, + declared: 2, + unknown: 2, + unresolved_path_items: 1, + diagnostics: 5, + }); + expect(value.operations[0]?.declared_id).toBe(""); + expect(value.operations[1]).toMatchObject({ + declared_id: null, + state: "declared", + }); + expect(value.operations[2]).toMatchObject({ + state: "unknown", + reason: "malformed-operation", + }); + expect(value.path_item_references[0]).toMatchObject({ + state: "malformed", + reference: null, + }); + expect(value.source_coverage.status).toBe("partial"); + expect(value.diagnostics.map((row) => row.code)).toEqual([ + "invalid-operation-id", + "invalid-operation", + "invalid-path-reference", + "invalid-path-item", + "invalid-path-key", + ]); +}); + +it("excludes structured transport userinfo while preserving ordinary reference text", () => { + const value = report( + document({ + "/absolute": { + $ref: "https://name:credential@example.test/schema.json?token=caller-text#part", + }, + "/relative": { + $ref: "//name:credential@example.test/schema.json?ordinary=kept", + }, + "/file": { $ref: "./ordinary@name.json#/components/schemas/password" }, + }), + ); + expect( + value.path_item_references.map(({ reference, credential_redacted }) => ({ + reference, + credential_redacted, + })), + ).toEqual([ + { + reference: "https://example.test/schema.json?token=caller-text#part", + credential_redacted: true, + }, + { + reference: "//example.test/schema.json?ordinary=kept", + credential_redacted: true, + }, + { + reference: "./ordinary@name.json#/components/schemas/password", + credential_redacted: false, + }, + ]); + expect(JSON.stringify(value)).not.toContain("name:credential"); +}); + +it("keeps an ambiguous credential spelling unknown without normalizing its reference identity", () => { + const value = report( + document({ + "/ambiguous": { + $ref: "https:\\\\name:credential@example.test/schema.json", + }, + }), + ); + expect(value.path_item_references[0]).toMatchObject({ + reference: null, + credential_redacted: true, + state: "unresolved", + }); + expect(value.source_coverage.status).toBe("partial"); +}); + +it("reports absent and malformed paths without calling them a complete empty API", () => { + const value = document(undefined); + delete (value as { paths?: unknown }).paths; + expect(report(value).source_coverage).toMatchObject({ + status: "partial", + paths_state: "absent", + }); + expect( + report({ ...value, openapi: "3.1.2", webhooks: { name: { post: {} } } }) + .source_coverage, + ).toMatchObject({ + status: "complete-within-scope", + paths_state: "absent", + scope: "inline-path-operations", + }); + expect(report(document(null)).source_coverage).toMatchObject({ + status: "partial", + paths_state: "malformed", + }); +}); + +it("handles 3.2 QUERY and case-sensitive additional methods, diagnosing invalid fixed-method duplicates", () => { + const value = report( + document( + { + "/x": { + query: {}, + post: {}, + additionalOperations: { + COPY: {}, + post: {}, + POST: {}, + "bad method": {}, + }, + }, + }, + "3.2.1", + ), + ); + expect(value.operations.map((row) => row.method)).toEqual([ + "QUERY", + "POST", + "COPY", + "post", + ]); + expect(value.operations[2]?.source_pointer).toBe( + "/paths/~1x/additionalOperations/COPY", + ); + expect(value.diagnostics.map((row) => row.code)).toEqual([ + "invalid-additional-method", + "invalid-additional-method", + ]); + expect(value.source_coverage.status).toBe("partial"); + const older = report( + document( + { "/x": { query: {}, additionalOperations: { COPY: {} }, GET: {} } }, + "3.1.2", + ), + ); + expect(older.operations).toEqual([]); + expect(older.diagnostics.map((row) => row.code)).toEqual( + Array(3).fill("unknown-path-item-field"), + ); +}); + +it("bounds expanded identities and source pointers before building a giant row", () => { + const result = parse( + document({ [`/${"a".repeat(1_100_000)}`]: { get: {} } }), + ); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error._tag).toBe("AnalysisResourceConstraintError"); +}); diff --git a/src/domain/apiContract/parseApiContract.ts b/src/domain/apiContract/parseApiContract.ts new file mode 100644 index 0000000000..22950d14e1 --- /dev/null +++ b/src/domain/apiContract/parseApiContract.ts @@ -0,0 +1,452 @@ +import { + AnalysisInputError, + AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, +} from "../analysisErrorCore.js"; +import type { AnalysisError } from "../analysisErrorBase.js"; +import { digestCanonicalValue } from "../canonicalDigest.js"; +import { err, ok, type Result } from "../result.js"; +import { parseRecordedTransportUrl } from "../recordedTransportUrl.js"; +import { + API_CONTRACT_FACT_BYTES, + API_CONTRACT_OPERATION, + type ApiContract, + type ApiContractArtifact, + type ApiContractDiagnostic, + type ApiContractOperation, +} from "./apiContract.js"; + +const methods = [ + "get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", +] as const; +const modernMethods = [...methods, "query"]; +const reservedAdditionalMethods = new Set( + modernMethods.map((method) => method.toUpperCase()), +); +const pathMetadata = new Set([ + "$ref", + "summary", + "description", + "servers", + "parameters", +]); +// HTTP methods are case-sensitive tokens; additionalOperations preserves that spelling. +const httpToken = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/u; +const record = (value: unknown): Record | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +const pointerPart = (value: string): string => + value.replaceAll("~", "~0").replaceAll("/", "~1"); +const identity = (prefix: string, facts: unknown) => + `${prefix}_${digestCanonicalValue(facts)}`; + +interface OperationSlot { + readonly path: string; + readonly method: string; + readonly pointer: string; + readonly hasReference: boolean; +} + +/** Own compact declarations and account for repeated path/pointer serialization before allocation. */ +class ContractInventory { + readonly operations: ApiContract["operations"] = []; + readonly references: ApiContract["path_item_references"] = []; + readonly diagnostics: ApiContractDiagnostic[] = []; + #retainedBytes = 0; + + constructor(readonly artifact: ApiContractArtifact) {} + + reserve(bytes: number): Result { + // Includes object/index overhead and repeated escaped Evidence delivery, not just source bytes. + this.#retainedBytes += bytes; + return this.#retainedBytes <= API_CONTRACT_FACT_BYTES + ? ok(undefined) + : err( + new AnalysisResourceConstraintError( + API_CONTRACT_OPERATION, + "memory", + "API contract declarations exceed the retained-facts representation allowance.", + { representation_bytes: API_CONTRACT_FACT_BYTES }, + { + retryable: false, + remediationAction: + "Select a smaller complete API document; paging the reply does not increase the parser's retained-facts allowance.", + }, + ), + ); + } + + diagnostic( + code: ApiContractDiagnostic["code"], + pointer: string, + message: string, + ): Result { + const admitted = this.reserve(1024 + pointer.length * 12); + if (!admitted.ok) return admitted; + this.diagnostics.push({ + diagnostic_id: identity("api_contract_diagnostic", [ + this.artifact.sha256, + pointer, + code, + ]), + source_pointer: pointer, + code, + message, + }); + return ok(undefined); + } + + reference( + path: string, + pointer: string, + value: unknown, + ): Result { + const admitted = this.reserve( + 1024 + + path.length * 12 + + (typeof value === "string" ? value.length * 12 : 0), + ); + if (!admitted.ok) return admitted; + const credentials = + typeof value === "string" + ? parseRecordedTransportUrl(value).credentials + : null; + const reference = + typeof value !== "string" || credentials === "ambiguous" + ? null + : credentials === null + ? value + : value.slice(0, credentials.start) + value.slice(credentials.end); + this.references.push({ + reference_id: identity("api_path_reference", [ + this.artifact.sha256, + pointer, + ]), + path, + source_pointer: pointer, + reference, + credential_redacted: credentials !== null, + state: typeof value === "string" ? "unresolved" : "malformed", + }); + return typeof value === "string" + ? ok(undefined) + : this.diagnostic( + "invalid-path-reference", + pointer, + "Path Item $ref must be a string; reference resolution was not attempted.", + ); + } + + operation(value: unknown, slot: OperationSlot): Result { + const object = record(value); + const declaredId = + typeof object?.operationId === "string" ? object.operationId : null; + const admitted = this.reserve( + 1536 + + slot.path.length * 32 + + slot.method.length * 24 + + (declaredId?.length ?? 0) * 12, + ); + if (!admitted.ok) return admitted; + const reason: ApiContractOperation["reason"] = + object === undefined + ? "malformed-operation" + : slot.hasReference + ? "unresolved-path-item-reference" + : null; + this.operations.push({ + operation_id: identity("api_operation", [ + this.artifact.sha256, + slot.pointer, + ]), + operation_key: identity("api_operation_key", [slot.path, slot.method]), + method: slot.method, + path: slot.path, + declared_id: declaredId, + source_pointer: slot.pointer, + state: reason === null ? "declared" : "unknown", + reason, + }); + if (object === undefined) + return this.diagnostic( + "invalid-operation", + slot.pointer, + "The method's value is not an Operation Object.", + ); + if (Object.hasOwn(object, "operationId") && declaredId === null) + return this.diagnostic( + "invalid-operation-id", + `${slot.pointer}/operationId`, + "operationId is present but is not a string; its identity remains unavailable.", + ); + return ok(undefined); + } +} + +const infoFacts = ( + root: Record, + inventory: ContractInventory, +): Result => { + const info = record(root.info); + const title = typeof info?.title === "string" ? info.title : null; + const version = typeof info?.version === "string" ? info.version : null; + const admitted = inventory.reserve( + 4096 + + inventory.artifact.path.length * 12 + + (title?.length ?? 0) * 12 + + (version?.length ?? 0) * 12, + ); + if (!admitted.ok) return admitted; + if (info === undefined || title === null || version === null) { + const diagnosed = inventory.diagnostic( + "invalid-info", + "/info", + "Info title/version are not both present strings; usable metadata and operation slots are retained without claiming a valid OpenAPI document.", + ); + if (!diagnosed.ok) return diagnosed; + } + return ok({ title, version }); +}; + +function* additionalOperations( + value: unknown, + context: { + inventory: ContractInventory; + path: string; + pointer: string; + hasReference: boolean; + }, +): Generator> { + const { inventory, path, pointer, hasReference } = context; + const operations = record(value); + if (operations === undefined) + return inventory.diagnostic( + "invalid-additional-operations", + pointer, + "additionalOperations must be an object mapping HTTP method tokens to Operation Objects.", + ); + let examined = 0; + for (const method in operations) { + if (!Object.hasOwn(operations, method)) continue; + if (examined++ % 128 === 0) yield; + const admitted = inventory.reserve(512 + method.length * 24); + if (!admitted.ok) return admitted; + const source = `${pointer}/${pointerPart(method)}`; + const reserved = reservedAdditionalMethods.has(method); + const added = + !httpToken.test(method) || reserved + ? inventory.diagnostic( + "invalid-additional-method", + source, + "The additional HTTP method is not a token or duplicates a fixed method; it is not treated as a valid operation.", + ) + : inventory.operation(operations[method], { + path, + method, + pointer: source, + hasReference, + }); + if (!added.ok) return added; + } + return ok(undefined); +} + +function* pathItem( + value: unknown, + context: { + inventory: ContractInventory; + path: string; + pointer: string; + modern: boolean; + }, +): Generator> { + const { inventory, path, pointer, modern } = context; + const item = record(value); + if (item === undefined) + return inventory.diagnostic( + "invalid-path-item", + pointer, + "Path Item must be an object; its operations are unknown.", + ); + const hasReference = Object.hasOwn(item, "$ref"); + if (hasReference) { + const added = inventory.reference(path, `${pointer}/$ref`, item.$ref); + if (!added.ok) return added; + } + const fixedMethods: readonly string[] = modern ? modernMethods : methods; + let examined = 0; + for (const key in item) { + if (!Object.hasOwn(item, key)) continue; + if (examined++ % 128 === 0) yield; + if (fixedMethods.includes(key)) { + const added = inventory.operation(item[key], { + path, + method: key.toUpperCase(), + pointer: `${pointer}/${key}`, + hasReference, + }); + if (!added.ok) return added; + } else if (key === "additionalOperations" && modern) { + const added = yield* additionalOperations(item[key], { + inventory, + path, + pointer: `${pointer}/${key}`, + hasReference, + }); + if (!added.ok) return added; + } else if (!pathMetadata.has(key) && !key.startsWith("x-")) { + const admitted = inventory.reserve(512 + key.length * 24); + if (!admitted.ok) return admitted; + const diagnosed = inventory.diagnostic( + "unknown-path-item-field", + `${pointer}/${pointerPart(key)}`, + "This field is not an operation or a recognized Path Item field in the selected OpenAPI minor version.", + ); + if (!diagnosed.ok) return diagnosed; + } + } + return ok(undefined); +} + +const completedInventory = ( + inventory: ContractInventory, + context: { + openapi: string; + info: ApiContract["info"]; + pathsState: ApiContract["source_coverage"]["paths_state"]; + examined: number; + }, +): ApiContract => { + const artifact = inventory.artifact; + let declared = 0; + for (const operation of inventory.operations) + if (operation.state === "declared") declared++; + return { + contract_id: `api_contract_${artifact.sha256}`, + format: "openapi-json", + openapi: context.openapi, + artifact, + info: context.info, + operations: inventory.operations, + path_item_references: inventory.references, + diagnostics: inventory.diagnostics, + summary: { + operation_slots: inventory.operations.length, + declared, + unknown: inventory.operations.length - declared, + unresolved_path_items: inventory.references.length, + diagnostics: inventory.diagnostics.length, + }, + source_coverage: { + scope: "inline-path-operations", + status: + inventory.references.length > 0 || inventory.diagnostics.length > 0 + ? "partial" + : "complete-within-scope", + paths_state: context.pathsState, + path_entries_examined: context.examined, + reference_resolution: "not-attempted", + schema_validation: "not-performed", + duplicate_json_members: "last-value-wins", + }, + limitations: [ + "Operations are document declarations, not evidence of runtime implementation, reachability or authorization.", + "Only inline operations under paths are inventoried; callbacks, webhooks, components, servers, parameters, schemas, request/response contracts and security are not analyzed.", + "No reference is resolved, including local fragments. A Path Item reference makes its effective operations unknown; adjacent inline slots are preserved without merging referenced definitions.", + "Transport userinfo credentials are excluded from reference URIs. credential_redacted marks the exclusion; an ambiguous credential span leaves the displayed reference unknown. Other reference text is preserved.", + "This is a structural inventory, not full OpenAPI schema validation. operationId values are preserved without assuming uniqueness; operation_key matches exact method/path spelling, not semantic equivalence across APIs.", + "Source JSON members use last-value-wins parsing. Original bytes remain in the caller-selected artifact, identified by SHA-256; this Evidence does not embed the complete source document.", + ], + }; +}; + +/** Enumerate declarations only; never resolve references, servers, callbacks or runtime expressions. */ +export function* parseApiContractSteps( + value: unknown, + artifact: ApiContractArtifact, +): Generator> { + const root = record(value); + if (root === undefined || typeof root.openapi !== "string") + return err( + new AnalysisInputError(API_CONTRACT_OPERATION, undefined, [ + { + path: ["path"], + reason: "invalid_format", + message: + "Expected an OpenAPI JSON object with an openapi version string.", + }, + ]), + ); + const version = /^3\.([012])\.(?:0|[1-9][0-9]*)$/u.exec(root.openapi); + if (version === null) + return err( + new AnalysisUnsupportedTargetError( + API_CONTRACT_OPERATION, + artifact.path, + "Only OpenAPI 3.0, 3.1 and 3.2 JSON documents are supported.", + ), + ); + const inventory = new ContractInventory(artifact); + const versionAdmitted = inventory.reserve(root.openapi.length * 12); + if (!versionAdmitted.ok) return versionAdmitted; + const info = infoFacts(root, inventory); + if (!info.ok) return info; + const paths = record(root.paths); + const pathsState = + paths !== undefined + ? "object" + : Object.hasOwn(root, "paths") + ? "malformed" + : "absent"; + if ( + pathsState === "malformed" || + (pathsState === "absent" && version[1] === "0") + ) { + const diagnosed = inventory.diagnostic( + "invalid-paths", + "/paths", + "paths must be an object and is required for OpenAPI 3.0; a missing or malformed inventory is not proof of zero operations.", + ); + if (!diagnosed.ok) return diagnosed; + } + let examined = 0; + const sourcePaths = paths ?? {}; + for (const path in sourcePaths) { + if (!Object.hasOwn(sourcePaths, path) || path.startsWith("x-")) continue; + yield; + examined++; + // Reserve expanded pointer/path strings before escaping or building a row. + const admitted = inventory.reserve(512 + path.length * 16); + if (!admitted.ok) return admitted; + const pointer = `/paths/${pointerPart(path)}`; + const read = path.startsWith("/") + ? yield* pathItem(sourcePaths[path], { + inventory, + path, + pointer, + modern: version[1] === "2", + }) + : inventory.diagnostic( + "invalid-path-key", + pointer, + "A Paths entry must start with /; it was not interpreted as an endpoint.", + ); + if (!read.ok) return read; + } + return ok( + completedInventory(inventory, { + openapi: root.openapi, + info: info.value, + pathsState, + examined, + }), + ); +} diff --git a/src/domain/artifactOperationError.ts b/src/domain/artifactOperationError.ts index 07ea24176c..60e3e6965c 100644 --- a/src/domain/artifactOperationError.ts +++ b/src/domain/artifactOperationError.ts @@ -34,6 +34,7 @@ export class ArtifactOperationError extends AnalysisError { | "inspect_pe_certificates" | "inspect_elf_notes" | "inspect_devicetree" + | "inspect_api_contract" | "inspect_kernel_module" | "inspect_erlang_beam" | "extract_artifact" diff --git a/src/server/registerApplicationTools.ts b/src/server/registerApplicationTools.ts index ae392dc1ca..dcdfa96465 100644 --- a/src/server/registerApplicationTools.ts +++ b/src/server/registerApplicationTools.ts @@ -1,3 +1,4 @@ +import { registerApiContractTool } from "./registerApplicationTools/apiContract.js"; import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import { registerTraceFeatureTool } from "./registerApplicationTools/traceFeature.js"; @@ -25,6 +26,7 @@ export const registerApplicationTools = ( registerTraceJavaScriptSemanticsTool(server, options); registerCompareApplicationVersionsTool(server, options); registerCompareEndpointsTool(server, options); + registerApiContractTool(server, options); registerCompareSourceToBundleTool(server, options); registerCompareJavaScriptExportShapesTool(server, options); registerReconstructionObligationLedgerTool(server, options); diff --git a/src/server/registerApplicationTools/apiContract.ts b/src/server/registerApplicationTools/apiContract.ts new file mode 100644 index 0000000000..2b77c017c1 --- /dev/null +++ b/src/server/registerApplicationTools/apiContract.ts @@ -0,0 +1,58 @@ +import type { EvidenceMcpServer } from "../EvidenceMcpServer.js"; +import { runAdmittedToolOperation } from "../admittedToolOperation.js"; +import { requireAnalysisViewRetention } from "../../application/analysisView/AnalysisViewService.js"; +import { inspectApiContractEvidence } from "../../application/apiContract/ApiContractService.js"; +import { toolContract } from "../../contracts/toolContracts.js"; +import { toolRegistrationOptions } from "../toolRegistrationOptions.js"; +import { logToolExecution } from "../toolLogging.js"; +import { deliverApplicationEvidence } from "./helpers.js"; +import type { ApplicationToolRegistration } from "./types.js"; + +const contract = toolContract("inspect_api_contract"); + +/** Reads use the server's existing artifact resource owner and Evidence session. */ +export const registerApiContractTool = ( + server: EvidenceMcpServer, + options: ApplicationToolRegistration, +): void => { + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + async (input, context) => + runAdmittedToolOperation( + server, + options.withAdmittedAnalysis, + contract.name, + context.mcpReq.signal, + async () => { + if (input.detail === "summary" || input.view !== undefined) { + const retention = requireAnalysisViewRetention( + contract.name, + options, + ); + if (!retention.ok) + return server.delivery.toCallToolResult(retention, contract); + } + const result = await logToolExecution( + options.logger, + contract.name, + () => + inspectApiContractEvidence(input, options.artifactResources, { + signal: context.mcpReq.signal, + }), + ); + if (!result.ok) + return server.delivery.toCallToolResult(result, contract); + return deliverApplicationEvidence( + server, + options, + contract, + { evidence: result.value, sources: [] }, + input.view ?? + (input.detail === "summary" ? { kind: "summary" } : undefined), + context.mcpReq.signal, + ); + }, + ), + ); +}; diff --git a/tests/acceptance/applications/apiContract.test.ts b/tests/acceptance/applications/apiContract.test.ts new file mode 100644 index 0000000000..3f67f1c393 --- /dev/null +++ b/tests/acceptance/applications/apiContract.test.ts @@ -0,0 +1,457 @@ +import { createHash } from "node:crypto"; +import { createServer } from "node:http"; +import { readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, onTestFinished } from "vitest"; +import { z } from "zod"; +import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { parseEvidence, type Evidence } from "../../../src/domain/evidence.js"; +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; +import { apiContractSchema } from "../../../src/domain/apiContract/apiContract.js"; +import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; + +type McpCall = Awaited>["call"]; +const fixture = new URL( + "../../fixtures/openapi/petstore.json.txt", + import.meta.url, +); +const asView = (response: Awaited>) => { + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + return analysisViewResultSchema.parse( + parseEvidence(response.structuredContent).normalized_result, + ); +}; +const retained = (evidence: Evidence) => ({ + kind: "retained-evidence", + evidence_id: evidence.evidence_id, +}); + +const checkRetainedPages = async (call: McpCall, parent: Evidence) => { + const report = apiContractSchema.parse(parent.normalized_result); + const rows = []; + for (let offset = 0; offset < 19; offset += 5) { + const page = asView( + await call("inspect_analysis_view", { + source: retained(parent), + view: { + kind: "api-contract", + collection: "operations", + offset, + limit: 5, + }, + }), + ); + expect(page.kind).toBe("api-contract"); + if (page.kind !== "api-contract") + throw new Error("Expected operation page"); + rows.push(...page.items); + expect(page.source_coverage).toEqual(report.source_coverage); + expect(page.coverage.exhausted).toBe(offset + 5 >= 19); + } + expect(rows).toEqual(report.operations); + const selected = asView( + await call("inspect_analysis_view", { + source: retained(parent), + view: { + kind: "api-contract", + collection: "operations", + paths: ["/pet/{petId}"], + methods: ["GET"], + fields: [], + }, + }), + ); + expect(selected.kind).toBe("api-contract"); + if (selected.kind !== "api-contract") + throw new Error("Expected selected page"); + expect(selected.items).toHaveLength(1); + expect(Object.keys(selected.items[0]!).sort()).toEqual([ + "operation_id", + "operation_key", + "reason", + "source_pointer", + "state", + ]); + const wanted = report.operations.find( + (row) => row.declared_id === "getPetById", + )!; + expect(selected.items[0]).toMatchObject({ + operation_id: wanted.operation_id, + operation_key: wanted.operation_key, + state: "declared", + }); + for (const filter of [ + { operation_ids: [] }, + { methods: [] }, + { paths: [] }, + { declared_ids: [] }, + ]) { + const empty = asView( + await call("inspect_analysis_view", { + source: retained(parent), + view: { kind: "api-contract", collection: "operations", ...filter }, + }), + ); + expect(empty).toMatchObject({ + items: [], + coverage: { total: 0, exhausted: true }, + source_coverage: report.source_coverage, + }); + } + const batch = asView( + await call("inspect_analysis_view", { + source: retained(parent), + view: { + kind: "api-contract", + collection: "operations", + operation_keys: report.operations + .slice(0, 2) + .map((row) => row.operation_key), + }, + }), + ); + expect(batch).toMatchObject({ + items: report.operations.slice(0, 2), + coverage: { total: 2 }, + }); +}; + +const checkCliPublication = async ( + cli: TestCli, + context: { + root: string; + path: string; + parent: Evidence; + summary: unknown; + selected: unknown; + }, +) => { + const { root, path, parent, summary, selected } = context; + const run = cli.sharedCliRun?.bind(cli) ?? cli.run.bind(cli); + const complete = await run({ + arguments: ["inspect-api-contract", path, "--json"], + }); + expect(complete.exitCode, complete.stderr).toBe(0); + expect(parseEvidence(complete.json)).toEqual({ + ...parent, + caller_input_path: path, + }); + const bundlePath = join(root, "contract.bundle.json"); + const receipt = await run({ + arguments: [ + "inspect-api-contract", + path, + "--detail", + "summary", + "--evidence_output", + bundlePath, + "--json", + ], + }); + expect(receipt.exitCode, receipt.stderr).toBe(0); + expect(receipt.json).toMatchObject({ + summary: { ...parseEvidence(summary), caller_input_path: path }, + }); + const bundle = evidenceBundleSchema.parse( + JSON.parse(await readFile(bundlePath, "utf8")), + ); + expect(bundle.records).toEqual([{ ...parent, caller_input_path: path }]); + const request = { + kind: "api-contract", + collection: "operations", + methods: ["GET"], + limit: 2, + }; + const inline = await run({ + arguments: [ + "inspect-api-contract", + path, + "--detail", + "summary", + "--view", + JSON.stringify(request), + "--evidence_output", + join(root, "selected.bundle.json"), + "--json", + ], + }); + expect(inline.exitCode, inline.stderr).toBe(0); + expect(inline.json).toMatchObject({ + summary: { ...parseEvidence(selected), caller_input_path: path }, + }); + const viewInput = join(root, "retained-page.json"); + await writeFile( + viewInput, + JSON.stringify({ + source: { + kind: "evidence-file", + path: bundlePath, + evidence_id: parent.evidence_id, + }, + view: request, + }), + ); + await rm(path); + const fromBundle = await run({ + arguments: ["inspect-analysis-view", viewInput, "--json"], + }); + expect(fromBundle.exitCode, fromBundle.stderr).toBe(0); + expect(parseEvidence(fromBundle.json)).toEqual(parseEvidence(selected)); + const missingSink = await run({ + arguments: ["inspect-api-contract", path, "--detail", "summary", "--json"], + }); + expect(missingSink.json).toMatchObject({ code: "invalid_request" }); + const invalidView = await run({ + arguments: [ + "inspect-api-contract", + path, + "--view", + JSON.stringify(request), + "--json", + ], + }); + expect(invalidView.json).toMatchObject({ code: "invalid_request" }); +}; + +cliTest( + "imports the published OpenAPI document through public CLI/MCP with retained, selected and complete parity", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-public-api-contract-"); + const path = join(root, "openapi.json"); + const bytes = await readFile(fixture); + expect(createHash("sha256").update(bytes).digest("hex")).toBe( + "00441c05add60f28da55eb4563d3fb6bbda364ba8eb7b381e2e2c98613005c82", + ); + await writeFile(path, bytes); + const { client, call } = await connectLocalToolsMcp({ + REA_MCP_INPUT_SCHEMA_PROFILE: "full", + REA_MCP_TOOL_FAMILIES: "application,session", + }); + const schemas = await advertisedMcpToolSchemas( + client, + "inspect_api_contract", + ); + expect(schemas.ajv.validate(schemas.inputSchema, { path })).toBe(true); + expect( + schemas.ajv.validate(schemas.inputSchema, { + path, + view: { + kind: "api-contract", + collection: "operations", + fields: ["not-a-field"], + }, + }), + ).toBe(false); + const summary = await call("inspect_api_contract", { path }); + const summaryView = asView(summary); + expect(summaryView).toMatchObject({ + kind: "summary", + summary: { summary: { operation_slots: 19, declared: 19, unknown: 0 } }, + }); + expect(JSON.stringify(summary.structuredContent)).not.toContain( + "getPetById", + ); + const bundle = await call("get_evidence_bundle", { detail: "complete" }); + expect(bundle.isError).not.toBe(true); + const records = z + .object({ result: evidenceBundleSchema }) + .parse(bundle.structuredContent).result.records; + const parent = records.find( + (record) => record.evidence_id === summaryView.parent_evidence_id, + )!; + expect(parent).toBeDefined(); + expect( + apiContractSchema.parse(parent.normalized_result).summary.declared, + ).toBe(19); + const full = await call("inspect_api_contract", { + path, + detail: "complete", + }); + expect(parseEvidence(full.structuredContent)).toEqual(parent); + const selected = await call("inspect_api_contract", { + path, + detail: "complete", + view: { + kind: "api-contract", + collection: "operations", + methods: ["GET"], + limit: 2, + }, + }); + expect(asView(selected)).toMatchObject({ + kind: "api-contract", + items: [{ method: "GET" }, { method: "GET" }], + }); + await rm(path); + await checkRetainedPages(call, parent); + await writeFile(path, bytes); + await checkCliPublication(cli, { + root, + path, + parent, + summary: summary.structuredContent, + selected: selected.structuredContent, + }); + expect((await readFile(fixture)).equals(bytes)).toBe(true); + }, +); + +cliTest( + "keeps unresolved references, malformed siblings and partial coverage offline", + async ({ cli }) => { + let requests = 0; + const server = createServer((_request, response) => { + requests++; + response.end("{}"); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + onTestFinished( + () => + new Promise((resolve, reject) => + server.close((error) => + error === undefined ? resolve() : reject(error), + ), + ), + ); + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("Expected loopback listener"); + const reference = `http://127.0.0.1:${address.port}/never-requested.json`; + const root = await createTestTempDirectory("rea-partial-api-contract-"); + const path = join(root, "partial.json"); + const value = JSON.parse((await readFile(fixture)).toString("utf8")); + value.paths["/remote"] = { $ref: reference }; + value.paths["/pet"].$ref = "#/paths/~1pet"; + value.paths["/broken"] = { get: null }; + value.servers = [{ url: reference }]; + const bytes = JSON.stringify(value); + await writeFile(path, bytes); + const { call } = await connectLocalToolsMcp({ + REA_MCP_TOOL_FAMILIES: "application,session", + }); + const complete = await call("inspect_api_contract", { + path, + detail: "complete", + }); + expect(complete.isError).not.toBe(true); + const parent = parseEvidence(complete.structuredContent); + const report = apiContractSchema.parse(parent.normalized_result); + expect(report.summary).toEqual({ + operation_slots: 20, + declared: 17, + unknown: 3, + unresolved_path_items: 2, + diagnostics: 1, + }); + const source = retained(parent); + const refs = asView( + await call("inspect_analysis_view", { + source, + view: { + kind: "api-contract", + collection: "path-item-references", + limit: 64, + }, + }), + ); + expect(refs).toMatchObject({ + items: [{ reference: "#/paths/~1pet" }, { reference }], + coverage: { exhausted: true }, + source_coverage: { status: "partial" }, + }); + const diagnostics = asView( + await call("inspect_analysis_view", { + source, + view: { + kind: "api-contract", + collection: "diagnostics", + codes: ["invalid-operation"], + }, + }), + ); + expect(diagnostics).toMatchObject({ + items: [ + { code: "invalid-operation", source_pointer: "/paths/~1broken/get" }, + ], + source_coverage: { status: "partial" }, + }); + const unknown = asView( + await call("inspect_api_contract", { + path, + view: { + kind: "api-contract", + collection: "operations", + states: ["unknown"], + }, + }), + ); + expect(unknown).toMatchObject({ + coverage: { total: 3 }, + source_coverage: { status: "partial" }, + }); + const run = cli.sharedCliRun?.bind(cli) ?? cli.run.bind(cli); + const cliResult = await run({ + arguments: ["inspect-api-contract", path, "--json"], + }); + expect(cliResult.exitCode, cliResult.stderr).toBe(0); + expect(parseEvidence(cliResult.json)).toEqual({ + ...parent, + caller_input_path: path, + }); + const unsupported = join(root, "unsupported.json"); + await writeFile(unsupported, JSON.stringify({ ...value, openapi: "2.0" })); + const refused = parseMcpToolError( + await call("inspect_api_contract", { path: unsupported }), + ); + expect(refused.error.code).toBe("unsupported_target"); + expect(refused.error.details?.partial_observation).toMatchObject({ + kind: "file-snapshot-hash", + path: unsupported, + }); + expect(requests).toBe(0); + expect(await readFile(path, "utf8")).toBe(bytes); + }, +); + +cliTest( + "preserves verified failure identity without exposing malformed JSON source through CLI/MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-invalid-api-contract-"); + const path = join(root, "malformed.json"); + const bytes = '{"$ref":"https://u:p@h","":NaN}'; + await writeFile(path, bytes); + const { call } = await connectLocalToolsMcp({ + REA_MCP_TOOL_FAMILIES: "application", + }); + const response = await call("inspect_api_contract", { path }); + const error = parseMcpToolError(response); + const snapshot = { + kind: "file-snapshot-hash", + path, + bytes: Buffer.byteLength(bytes), + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + expect(error.error).toMatchObject({ + code: "invalid_request", + details: { partial_observation: snapshot }, + }); + expect(JSON.stringify(response)).not.toContain("u:p@h"); + const run = cli.sharedCliRun?.bind(cli) ?? cli.run.bind(cli); + const result = await run({ + arguments: ["inspect-api-contract", path, "--json"], + }); + expect(result.exitCode).not.toBe(0); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { partial_observation: snapshot }, + }); + expect(result.stdout + result.stderr).not.toContain("u:p@h"); + expect(await readFile(path, "utf8")).toBe(bytes); + }, +); diff --git a/tests/boundary/filesystem/cliJsonInput.test.ts b/tests/boundary/filesystem/cliJsonInput.test.ts index 443bcca113..855d79e546 100644 --- a/tests/boundary/filesystem/cliJsonInput.test.ts +++ b/tests/boundary/filesystem/cliJsonInput.test.ts @@ -164,6 +164,20 @@ describe("CLI JSON streamed input", () => { } }); + it("keeps malformed source excerpts out of native and streamed file diagnostics", async () => { + const root = await createTestTempDirectory("rea-json-input-excerpt-"); + const path = join(root, "input.json"); + for (const prefix of ["", " ".repeat(9 * 1024 * 1024)]) { + await writeFile(path, `${prefix}{"$ref":"https://u:p@h","":NaN}`); + const result = await parseCliJsonInput(path, "test-input"); + expect(result).toMatchObject({ + ok: false, + error: { input_reason: "invalid-json" }, + }); + expect(JSON.stringify(result)).not.toContain("u:p@h"); + } + }); + it("names a leading byte-order mark in small and streamed files", async () => { const root = await createTestTempDirectory("rea-json-input-bom-"); const path = join(root, "input.json"); diff --git a/tests/fixtures/openapi/LICENSE.petstore.txt b/tests/fixtures/openapi/LICENSE.petstore.txt new file mode 100644 index 0000000000..8dada3edaf --- /dev/null +++ b/tests/fixtures/openapi/LICENSE.petstore.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tests/fixtures/openapi/README.md b/tests/fixtures/openapi/README.md new file mode 100644 index 0000000000..fa597b45b5 --- /dev/null +++ b/tests/fixtures/openapi/README.md @@ -0,0 +1,19 @@ +# Published OpenAPI fixture + +`petstore.json.txt` is the original UTF-8 JSON published by the running +[Swagger Petstore 3 application](https://petstore3.swagger.io/api/v3/openapi.json), +retrieved on 2026-11-06. Its API version is `1.0.27`, OpenAPI version is `3.0.4`, +and SHA-256 is `00441c05add60f28da55eb4563d3fb6bbda364ba8eb7b381e2e2c98613005c82`. +It declares 13 paths and 19 inline operations. This is a published application +contract, not a recording proving that any endpoint was exercised or reachable. + +The [producer repository](https://github.com/swagger-api/swagger-petstore) +licenses its definition under Apache-2.0; its license is retained in +`LICENSE.petstore.txt`. No authentication credentials were used to retrieve the +public definition. The document contains declared security schemes and example +values, which are source content rather than captured transport credentials. + +The `.txt` suffix keeps formatting tools from rewriting the original source. +Tests copy it to a temporary JSON file and inspect it offline. They neither +contact Petstore nor resolve the document's references. CI needs no network to +acquire this fixture and does not rewrite it. diff --git a/tests/fixtures/openapi/petstore.json.txt b/tests/fixtures/openapi/petstore.json.txt new file mode 100644 index 0000000000..a8781928cf --- /dev/null +++ b/tests/fixtures/openapi/petstore.json.txt @@ -0,0 +1 @@ +{"openapi":"3.0.4","info":{"title":"Swagger Petstore - OpenAPI 3.0","description":"This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about\nSwagger at [https://swagger.io](https://swagger.io). In the third iteration of the pet store, we've switched to the design first approach!\nYou can now help us improve the API whether it's by making changes to the definition itself or to the code.\nThat way, with time, we can improve the API in general, and expose some of the new features in OAS3.\n\nSome useful links:\n- [The Pet Store repository](https://github.com/swagger-api/swagger-petstore)\n- [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)","termsOfService":"https://swagger.io/terms/","contact":{"email":"apiteam@swagger.io"},"license":{"name":"Apache 2.0","url":"https://www.apache.org/licenses/LICENSE-2.0.html"},"version":"1.0.27"},"externalDocs":{"description":"Find out more about Swagger","url":"https://swagger.io"},"servers":[{"url":"/api/v3"}],"tags":[{"name":"pet","description":"Everything about your Pets","externalDocs":{"description":"Find out more","url":"https://swagger.io"}},{"name":"store","description":"Access to Petstore orders","externalDocs":{"description":"Find out more about our store","url":"https://swagger.io"}},{"name":"user","description":"Operations about user"}],"paths":{"/pet":{"put":{"tags":["pet"],"summary":"Update an existing pet.","description":"Update an existing pet by Id.","operationId":"updatePet","requestBody":{"description":"Update an existent pet in the store","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Pet"}},"application/xml":{"schema":{"$ref":"#/components/schemas/Pet"}},"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/Pet"}}},"required":true},"responses":{"200":{"description":"Successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Pet"}},"application/xml":{"schema":{"$ref":"#/components/schemas/Pet"}}}},"400":{"description":"Invalid ID supplied"},"404":{"description":"Pet not found"},"422":{"description":"Validation exception"},"default":{"description":"Unexpected error"}},"security":[{"petstore_auth":["write:pets","read:pets"]}]},"post":{"tags":["pet"],"summary":"Add a new pet to the store.","description":"Add a new pet to the store.","operationId":"addPet","requestBody":{"description":"Create a new pet in the store","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Pet"}},"application/xml":{"schema":{"$ref":"#/components/schemas/Pet"}},"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/Pet"}}},"required":true},"responses":{"200":{"description":"Successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Pet"}},"application/xml":{"schema":{"$ref":"#/components/schemas/Pet"}}}},"400":{"description":"Invalid input"},"422":{"description":"Validation exception"},"default":{"description":"Unexpected error"}},"security":[{"petstore_auth":["write:pets","read:pets"]}]}},"/pet/findByStatus":{"get":{"tags":["pet"],"summary":"Finds Pets by status.","description":"Multiple status values can be provided with comma separated strings.","operationId":"findPetsByStatus","parameters":[{"name":"status","in":"query","description":"Status values that need to be considered for filter","required":true,"explode":true,"schema":{"type":"string","default":"available","enum":["available","pending","sold"]}}],"responses":{"200":{"description":"successful operation","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Pet"}}},"application/xml":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Pet"}}}}},"400":{"description":"Invalid status value"},"default":{"description":"Unexpected error"}},"security":[{"petstore_auth":["write:pets","read:pets"]}]}},"/pet/findByTags":{"get":{"tags":["pet"],"summary":"Finds Pets by tags.","description":"Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.","operationId":"findPetsByTags","parameters":[{"name":"tags","in":"query","description":"Tags to filter by","required":true,"explode":true,"schema":{"type":"array","items":{"type":"string"}}}],"responses":{"200":{"description":"successful operation","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Pet"}}},"application/xml":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Pet"}}}}},"400":{"description":"Invalid tag value"},"default":{"description":"Unexpected error"}},"security":[{"petstore_auth":["write:pets","read:pets"]}]}},"/pet/{petId}":{"get":{"tags":["pet"],"summary":"Find pet by ID.","description":"Returns a single pet.","operationId":"getPetById","parameters":[{"name":"petId","in":"path","description":"ID of pet to return","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Pet"}},"application/xml":{"schema":{"$ref":"#/components/schemas/Pet"}}}},"400":{"description":"Invalid ID supplied"},"404":{"description":"Pet not found"},"default":{"description":"Unexpected error"}},"security":[{"api_key":[]},{"petstore_auth":["write:pets","read:pets"]}]},"post":{"tags":["pet"],"summary":"Updates a pet in the store with form data.","description":"Updates a pet resource based on the form data.","operationId":"updatePetWithForm","parameters":[{"name":"petId","in":"path","description":"ID of pet that needs to be updated","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"name","in":"query","description":"Name of pet that needs to be updated","schema":{"type":"string"}},{"name":"status","in":"query","description":"Status of pet that needs to be updated","schema":{"type":"string"}}],"responses":{"200":{"description":"successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Pet"}},"application/xml":{"schema":{"$ref":"#/components/schemas/Pet"}}}},"400":{"description":"Invalid input"},"default":{"description":"Unexpected error"}},"security":[{"petstore_auth":["write:pets","read:pets"]}]},"delete":{"tags":["pet"],"summary":"Deletes a pet.","description":"Delete a pet.","operationId":"deletePet","parameters":[{"name":"api_key","in":"header","description":"","required":false,"schema":{"type":"string"}},{"name":"petId","in":"path","description":"Pet id to delete","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Pet deleted"},"400":{"description":"Invalid pet value"},"default":{"description":"Unexpected error"}},"security":[{"petstore_auth":["write:pets","read:pets"]}]}},"/pet/{petId}/uploadImage":{"post":{"tags":["pet"],"summary":"Uploads an image.","description":"Upload image of the pet.","operationId":"uploadFile","parameters":[{"name":"petId","in":"path","description":"ID of pet to update","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"additionalMetadata","in":"query","description":"Additional Metadata","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"responses":{"200":{"description":"successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponse"}}}},"400":{"description":"No file uploaded"},"404":{"description":"Pet not found"},"default":{"description":"Unexpected error"}},"security":[{"petstore_auth":["write:pets","read:pets"]}]}},"/store/inventory":{"get":{"tags":["store"],"summary":"Returns pet inventories by status.","description":"Returns a map of status codes to quantities.","operationId":"getInventory","responses":{"200":{"description":"successful operation","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"integer","format":"int32"}}}}},"default":{"description":"Unexpected error"}},"security":[{"api_key":[]}]}},"/store/order":{"post":{"tags":["store"],"summary":"Place an order for a pet.","description":"Place a new order in the store.","operationId":"placeOrder","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Order"}},"application/xml":{"schema":{"$ref":"#/components/schemas/Order"}},"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/Order"}}}},"responses":{"200":{"description":"successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Order"}}}},"400":{"description":"Invalid input"},"422":{"description":"Validation exception"},"default":{"description":"Unexpected error"}}}},"/store/order/{orderId}":{"get":{"tags":["store"],"summary":"Find purchase order by ID.","description":"For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.","operationId":"getOrderById","parameters":[{"name":"orderId","in":"path","description":"ID of order that needs to be fetched","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Order"}},"application/xml":{"schema":{"$ref":"#/components/schemas/Order"}}}},"400":{"description":"Invalid ID supplied"},"404":{"description":"Order not found"},"default":{"description":"Unexpected error"}}},"delete":{"tags":["store"],"summary":"Delete purchase order by identifier.","description":"For valid response try integer IDs with value < 1000. Anything above 1000 or non-integers will generate API errors.","operationId":"deleteOrder","parameters":[{"name":"orderId","in":"path","description":"ID of the order that needs to be deleted","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"order deleted"},"400":{"description":"Invalid ID supplied"},"404":{"description":"Order not found"},"default":{"description":"Unexpected error"}}}},"/user":{"post":{"tags":["user"],"summary":"Create user.","description":"This can only be done by the logged in user.","operationId":"createUser","requestBody":{"description":"Created user object","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}},"application/xml":{"schema":{"$ref":"#/components/schemas/User"}},"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/User"}}}},"responses":{"200":{"description":"successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}},"application/xml":{"schema":{"$ref":"#/components/schemas/User"}}}},"default":{"description":"Unexpected error"}}}},"/user/createWithList":{"post":{"tags":["user"],"summary":"Creates list of users with given input array.","description":"Creates list of users with given input array.","operationId":"createUsersWithListInput","requestBody":{"content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/User"}}}}},"responses":{"200":{"description":"Successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}},"application/xml":{"schema":{"$ref":"#/components/schemas/User"}}}},"default":{"description":"Unexpected error"}}}},"/user/login":{"get":{"tags":["user"],"summary":"Logs user into the system.","description":"Log into the system.","operationId":"loginUser","parameters":[{"name":"username","in":"query","description":"The user name for login","required":false,"schema":{"type":"string"}},{"name":"password","in":"query","description":"The password for login in clear text","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"successful operation","headers":{"X-Rate-Limit":{"description":"calls per hour allowed by the user","schema":{"type":"integer","format":"int32"}},"X-Expires-After":{"description":"date in UTC when token expires","schema":{"type":"string","format":"date-time"}}},"content":{"application/xml":{"schema":{"type":"string"}},"application/json":{"schema":{"type":"string"}}}},"400":{"description":"Invalid username/password supplied"},"default":{"description":"Unexpected error"}}}},"/user/logout":{"get":{"tags":["user"],"summary":"Logs out current logged in user session.","description":"Log user out of the system.","operationId":"logoutUser","parameters":[],"responses":{"200":{"description":"successful operation"},"default":{"description":"Unexpected error"}}}},"/user/{username}":{"get":{"tags":["user"],"summary":"Get user by user name.","description":"Get user detail based on username.","operationId":"getUserByName","parameters":[{"name":"username","in":"path","description":"The name that needs to be fetched. Use user1 for testing","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}},"application/xml":{"schema":{"$ref":"#/components/schemas/User"}}}},"400":{"description":"Invalid username supplied"},"404":{"description":"User not found"},"default":{"description":"Unexpected error"}}},"put":{"tags":["user"],"summary":"Update user resource.","description":"This can only be done by the logged in user.","operationId":"updateUser","parameters":[{"name":"username","in":"path","description":"name that need to be deleted","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"Update an existent user in the store","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}},"application/xml":{"schema":{"$ref":"#/components/schemas/User"}},"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/User"}}}},"responses":{"200":{"description":"successful operation"},"400":{"description":"bad request"},"404":{"description":"user not found"},"default":{"description":"Unexpected error"}}},"delete":{"tags":["user"],"summary":"Delete user resource.","description":"This can only be done by the logged in user.","operationId":"deleteUser","parameters":[{"name":"username","in":"path","description":"The name that needs to be deleted","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"User deleted"},"400":{"description":"Invalid username supplied"},"404":{"description":"User not found"},"default":{"description":"Unexpected error"}}}}},"components":{"schemas":{"Order":{"type":"object","properties":{"id":{"type":"integer","format":"int64","example":10},"petId":{"type":"integer","format":"int64","example":198772},"quantity":{"type":"integer","format":"int32","example":7},"shipDate":{"type":"string","format":"date-time"},"status":{"type":"string","description":"Order Status","example":"approved","enum":["placed","approved","delivered"]},"complete":{"type":"boolean"}},"xml":{"name":"order"}},"Category":{"type":"object","properties":{"id":{"type":"integer","format":"int64","example":1},"name":{"type":"string","example":"Dogs"}},"xml":{"name":"category"}},"User":{"type":"object","properties":{"id":{"type":"integer","format":"int64","example":10},"username":{"type":"string","example":"theUser"},"firstName":{"type":"string","example":"John"},"lastName":{"type":"string","example":"James"},"email":{"type":"string","example":"john@email.com"},"password":{"type":"string","example":"12345"},"phone":{"type":"string","example":"12345"},"userStatus":{"type":"integer","description":"User Status","format":"int32","example":1}},"xml":{"name":"user"}},"Tag":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"tag"}},"Pet":{"required":["name","photoUrls"],"type":"object","properties":{"id":{"type":"integer","format":"int64","example":10},"name":{"type":"string","example":"doggie"},"category":{"$ref":"#/components/schemas/Category"},"photoUrls":{"type":"array","xml":{"wrapped":true},"items":{"type":"string","xml":{"name":"photoUrl"}}},"tags":{"type":"array","xml":{"wrapped":true},"items":{"$ref":"#/components/schemas/Tag"}},"status":{"type":"string","description":"pet status in the store","enum":["available","pending","sold"]}},"xml":{"name":"pet"}},"ApiResponse":{"type":"object","properties":{"code":{"type":"integer","format":"int32"},"type":{"type":"string"},"message":{"type":"string"}},"xml":{"name":"##default"}}},"requestBodies":{"Pet":{"description":"Pet object that needs to be added to the store","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Pet"}},"application/xml":{"schema":{"$ref":"#/components/schemas/Pet"}}}},"UserArray":{"description":"List of user object","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/User"}}}}}},"securitySchemes":{"petstore_auth":{"type":"oauth2","flows":{"implicit":{"authorizationUrl":"https://petstore3.swagger.io/oauth/authorize","scopes":{"write:pets":"modify pets in your account","read:pets":"read your pets"}}}},"api_key":{"type":"apiKey","name":"api_key","in":"header"}}}} \ No newline at end of file From 172f248884cead39657ff3491899dd51c0ef27ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E4=BD=B3=E6=B5=A9Alben?= <35291372+XiaoBinGan@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:14:03 +0800 Subject: [PATCH 038/151] fix(javascript): emit no fs acquisition for a rejected literal mode (#8624) * fix(javascript): emit no fs acquisition for a rejected literal mode `fs.open`, `fs.openSync` and `fs/promises.open` validate the `mode` in argument 2 before touching the filesystem, so a mode Node rejects throws synchronously and no descriptor exists. The guard read only the flag, so a rejected mode kept its acquisition and any linked release. Measured on Node 24.22.1: fs.openSync(p, "r", -1) -> THREW ERR_OUT_OF_RANGE fs.openSync(p, "r", 1.5) -> THREW ERR_OUT_OF_RANGE fs.openSync(p, "r", NaN) -> THREW ERR_OUT_OF_RANGE fs.openSync(p, "r", 1e21) -> THREW ERR_OUT_OF_RANGE fs.openSync(p, "r", true) -> THREW ERR_INVALID_ARG_TYPE fs.openSync(p, "r", 0n) -> THREW ERR_INVALID_ARG_TYPE fs.openSync(p, "r", {}) -> THREW ERR_INVALID_ARG_TYPE fs.openSync(p, "r", "8") -> THREW ERR_INVALID_ARG_VALUE fs.openSync(p, "r", "") -> THREW ERR_INVALID_ARG_VALUE fs.openSync(p, "r", "0o644") -> THREW ERR_INVALID_ARG_VALUE --- accepted --- fs.openSync(p, "r", 0o644) -> ok fs.openSync(p, "r", 2147483647) -> ok fs.openSync(p, "r", -0) -> ok fs.openSync(p, "r", "0644") -> ok fs.openSync(p, "r", null) -> ok fs.openSync(p, "r", void 0) -> ok The guard decides only exactly-readable literals. A numeric mode is validated as an unsigned 32-bit integer; a string mode must be a canonical octal digit string, padded leading zeros allowed, and its value must stay inside uint32; `null`, `undefined` and `void ` default the mode and are kept, as is a computed value. Three boundaries are deliberate: - Values in `[2^31, 2^32-1]` **abort the Node process** rather than throwing (`Assertion failed: args[2]->IsInt32()`), so they stay undecided instead of being claimed as a synchronous throw. - A spread before the mode slot shifts the runtime position. - A unary chain is resolved through its innermost readable operand, and a nested boolean or string result is left undecided rather than applied as a number. Regressions cover both directions. Full suite here: 5466 passed | 7 skipped. * fix(javascript): remove the superseded string mode check Review finding. `rejectsOpenModeLiteral` builds `octal` from a string literal or a no-expression template and returns inside that branch for every value it matched. A `StringLiteral` therefore never reaches the later `isStringLiteral` check, which cannot run and is a second copy of the canonical-octal rule to maintain. * test(javascript): cover the spread and boundary paths of the mode guard Review feedback from @greptile-apps: the new mode tests skipped both paths the guard deliberately leaves undecided. Two spread cases are added: - `openSync(...args, "r", "0600")`, where a spread in the flag slot shifts the runtime position of the mode; - `openSync(p, "r", ...modes)`, where a spread occupies the mode slot itself. Both must keep the acquisition, because neither establishes the mode value. The boundary cases were already present: `2147483647` and `2147483648` are keep cases (`2147483648` aborts the process rather than throwing) and `4294967296` is the reject case, alongside the octal `37777777777` keep and `40000000000` reject pair. 127 cases pass in this file. --- ...SemanticAnalysis.open_flag_literal.test.ts | 300 +++++++++++++++++- .../javascript/javascriptSemanticResources.ts | 202 ++++++++++-- 2 files changed, 474 insertions(+), 28 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.open_flag_literal.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.open_flag_literal.test.ts index cd1106b3a0..b48b4836ba 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.open_flag_literal.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.open_flag_literal.test.ts @@ -88,6 +88,13 @@ it.each([ ["a spread before the flag", `openSync(...args, "0600")`], ["a spread supplying every argument", `openSync(...args)`], ["a spread in the flag slot", `openSync(p, ...flags)`], + // A spread before the mode slot shifts the runtime position, and a spread in + // the mode slot hides the value, so the guard must keep the acquisition. + [ + "a spread in the flag that shifts the mode slot", + `openSync(...args, "r", "0600")`, + ], + ["a spread in the mode slot", `openSync(p, "r", ...modes)`], ] as const)("keeps the acquisition for %s", (_name, call) => { expect(acquisitionsFor(call)).toEqual([ { kind: "acquire", method: "openSync" }, @@ -112,22 +119,299 @@ it("keeps a read stream whose flags option is invalid", () => { }); /** - * Review finding: a no-expression template literal is exactly readable, so it is the - * same flag string Node receives. + * Node validates the `mode` in slot 2 before touching the filesystem. A numeric + * mode must be an unsigned 32-bit integer and a string mode must be a canonical + * octal digit string; anything else throws synchronously and no descriptor + * exists. `null`, `undefined` and `void ` default the mode, and a computed + * value may still be valid, so those are kept. * - * fs.openSync(p, `rb`) -> THREW ERR_INVALID_ARG_VALUE - * fs.openSync(p, `r`) -> ok + * fs.openSync(p, "r", -1) -> THREW ERR_OUT_OF_RANGE + * fs.openSync(p, "r", 1.5) -> THREW ERR_OUT_OF_RANGE + * fs.openSync(p, "r", true) -> THREW ERR_INVALID_ARG_TYPE + * fs.openSync(p, "r", "8") -> THREW ERR_INVALID_ARG_VALUE + * fs.openSync(p, "r", "") -> THREW ERR_INVALID_ARG_VALUE + * fs.openSync(p, "r", 0o644) -> ok + * fs.openSync(p, "r", "0644") -> ok + * fs.openSync(p, "r", null) -> ok */ it.each([ - ["a rejected template flag", 'openSync("x", `rb`)'], - ["another rejected template flag", 'openSync("x", `bad`)'], + ["a negative mode", `openSync("x", "r", -1)`], + ["a fractional mode", `openSync("x", "r", 1.5)`], + ["a NaN mode", `openSync("x", "r", NaN)`], + ["an Infinity mode", `openSync("x", "r", Infinity)`], + ["an overflowing mode", `openSync("x", "r", 1e21)`], + ["a boolean mode", `openSync("x", "r", true)`], + ["a BigInt mode", `openSync("x", "r", 0n)`], + ["an object mode", `openSync("x", "r", {})`], + ["an array mode", `openSync("x", "r", [])`], + ["a non-octal mode string", `openSync("x", "r", "8")`], + ["an empty mode string", `openSync("x", "r", "")`], + ["a prefixed mode string", `openSync("x", "r", "0o644")`], + ["a padded mode string", `openSync("x", "r", " 644 ")`], + ["a rejected fs/promises mode", `openPromise("x", "r", -1)`], ] as const)("emits no fs acquisition for %s", (_name, call) => { expect(acquisitionsFor(call)).toEqual([]); }); it.each([ - ["an accepted template flag", 'openSync("x", `r`)'], - ["an interpolated template flag", 'openSync("x", `${flag}`)'], + ["a zero mode", `openSync("x", "r", 0)`, "openSync"], + ["an octal numeric mode", `openSync("x", "r", 0o644)`, "openSync"], + ["the largest usable mode", `openSync("x", "r", 2147483647)`, "openSync"], + ["a negated zero mode", `openSync("x", "r", -0)`, "openSync"], + ["an octal mode string", `openSync("x", "r", "644")`, "openSync"], + ["a zero-padded mode string", `openSync("x", "r", "0644")`, "openSync"], + ["a null mode", `openSync("x", "r", null)`, "openSync"], + ["an undefined mode", `openSync("x", "r", undefined)`, "openSync"], + ["a void mode", `openSync("x", "r", void 0)`, "openSync"], + ["an omitted mode", `openSync("x", "r")`, "openSync"], + ["a computed mode", `openSync("x", "r", mode)`, "openSync"], +] as const)("keeps the fs acquisition for %s", (_name, call, method) => { + expect(acquisitionsFor(call)).toEqual([ + { kind: "acquire", method: method ?? "openSync" }, + ]); +}); + +/** + * Review findings on the mode guard. + * + * A no-expression template literal is exactly readable, so it is the same string + * Node receives: + * + * fs.openSync(p, `bad`) -> THREW ERR_INVALID_ARG_VALUE + * fs.openSync(p, `r`) -> ok + * fs.openSync(p, "r", `644`) -> ok + * fs.openSync(p, "r", `8`) -> THREW ERR_INVALID_ARG_VALUE + * + * An octal mode string above the uint32 maximum throws ERR_OUT_OF_RANGE, while the + * maximum itself belongs to the range that aborts the process and so stays + * undecided: + * + * fs.openSync(p, "r", "37777777777") -> 4294967295, abort, kept + * fs.openSync(p, "r", "40000000000") -> 4294967296, THREW ERR_OUT_OF_RANGE + * fs.openSync(p, "r", "0644") -> ok + */ +it.each([ + ["a rejected template flag", `openSync("x", ` + "`bad`" + `)`], + ["a rejected template mode", `openSync("x", "r", ` + "`8`" + `)`], + ["an octal mode above uint32", `openSync("x", "r", "40000000000")`], + ["an octal mode far above uint32", `openSync("x", "r", "777777777777")`], + ["a mode above uint32", `openSync("x", "r", 4294967296)`], +] as const)("emits no fs acquisition for %s", (_name, call) => { + expect(acquisitionsFor(call)).toEqual([]); +}); + +it.each([ + ["an accepted template flag", `openSync("x", ` + "`r`" + `)`, "openSync"], + [ + "an interpolated template flag", + `openSync("x", ` + "`${flag}`" + `)`, + "openSync", + ], + [ + "an accepted template mode", + `openSync("x", "r", ` + "`644`" + `)`, + "openSync", + ], + [ + "an interpolated template mode", + `openSync("x", "r", ` + "`${mode}`" + `)`, + "openSync", + ], + ["the uint32 mode boundary", `openSync("x", "r", 2147483647)`, "openSync"], + [ + "the process-abort mode boundary", + `openSync("x", "r", 2147483648)`, + "openSync", + ], + [ + "the uint32 octal boundary", + `openSync("x", "r", "37777777777")`, + "openSync", + ], +] as const)("keeps the fs acquisition for %s", (_name, call, method) => { + expect(acquisitionsFor(call)).toEqual([{ kind: "acquire", method }]); +}); + +/** + * Two further review findings. + * + * Leading zeros pad an octal mode without changing its value, so the range bound + * must read the significant digits: + * + * fs.openSync(p, "r", "00000000000000644") -> ok (420) + * fs.openSync(p, "r", "0000040000000000") -> THREW ERR_OUT_OF_RANGE (2^32) + * + * A unary operator over a BigInt literal is still a BigInt, which Node rejects in + * both slots: + * + * fs.openSync(p, -1n) -> THREW ERR_INVALID_ARG_TYPE + * fs.openSync(p, "r", -1n) -> THREW ERR_INVALID_ARG_TYPE + */ +it.each([ + [ + "a zero-padded octal mode above uint32", + `openSync("x", "r", "0000040000000000")`, + ], + ["a negated BigInt flag", `openSync("x", -1n)`], + ["a unary-plus BigInt flag", `openSync("x", +1n)`], + ["a negated BigInt mode", `openSync("x", "r", -1n)`], + ["a unary-plus BigInt mode", `openSync("x", "r", +1n)`], +] as const)("emits no fs acquisition for %s", (_name, call) => { + expect(acquisitionsFor(call)).toEqual([]); +}); + +it.each([ + [ + "a zero-padded octal mode", + `openSync("x", "r", "00000000000000644")`, + "openSync", + ], + [ + "a shorter zero-padded octal mode", + `openSync("x", "r", "00000000644")`, + "openSync", + ], +] as const)("keeps the fs acquisition for %s", (_name, call, method) => { + expect(acquisitionsFor(call)).toEqual([{ kind: "acquire", method }]); +}); + +/** + * Two review findings. + * + * A regex literal is an object, which Node rejects in both slots, and a unary + * operator applied to an unshadowed `NaN` or `Infinity` is the same non-finite + * number the bare global is: + * + * fs.openSync(p, /r/) -> THREW ERR_INVALID_ARG_TYPE + * fs.openSync(p, -NaN) -> THREW ERR_OUT_OF_RANGE + * fs.openSync(p, "r", -Infinity) -> THREW ERR_OUT_OF_RANGE + * --- accepted --- + * fs.openSync(p, void 0) -> ok + * fs.openSync(p, -0) -> ok + */ +it.each([ + ["a regex flag", `openSync("x", /r/)`], + ["a regex mode", `openSync("x", "r", /644/)`], + ["a negated NaN flag", `openSync("x", -NaN)`], + ["a unary-plus Infinity flag", `openSync("x", +Infinity)`], + ["a negated Infinity mode", `openSync("x", "r", -Infinity)`], +] as const)("emits no fs acquisition for %s", (_name, call) => { + expect(acquisitionsFor(call)).toEqual([]); +}); + +it("keeps the fs acquisition for a shadowed NaN flag", () => { + expect(acquisitionsFor(`const NaN = 0o644; openSync("x", NaN)`)).toEqual([ + { kind: "acquire", method: "openSync" }, + ]); +}); + +/** + * Review finding: a unary operator over an exact non-numeric primitive is coerced + * by JavaScript, so the result is decidable rather than unknown: + * + * fs.openSync(p, "r", -true) -> mode -1, THREW ERR_OUT_OF_RANGE + * fs.openSync(p, "r", +"bad") -> mode NaN, THREW ERR_OUT_OF_RANGE + * fs.openSync(p, +"bad") -> flag NaN, THREW ERR_INVALID_ARG_VALUE + */ +it.each([ + ["a negated boolean mode", `openSync("x", "r", -true)`], + ["a unary-plus bad string mode", `openSync("x", "r", +"bad")`], + ["a bitwise-not null mode", `openSync("x", "r", ~null)`], + ["a negated octal-looking string mode", `openSync("x", "r", -"644")`], + ["a unary-plus bad string flag", `openSync("x", +"bad")`], +] as const)("emits no fs acquisition for %s", (_name, call) => { + expect(acquisitionsFor(call)).toEqual([]); +}); + +/** + * A nested unary chain resolves through its innermost readable operand, so the + * conversion branch is actually exercised. Measured on Node 24.22.1: + * + * ~-1 = 0 -> ok, `0` is the O_RDONLY bitmask + * ~~NaN = 0 -> ok + * ~NaN = -1 -> in-range negative, left undecided (some signed bitmasks and + * `~NaN` itself return a descriptor on Linux) + * -(-1.5) = 1.5 -> THREW ERR_OUT_OF_RANGE as a mode + * -(~NaN) = 1 -> ok as a mode + * ~(-1.5) = -1 -> in-range negative, left undecided + */ +it.each([ + ["a negated fractional mode", `openSync("x", "r", -(-1.5))`], + ["a double-negated fractional flag", `openSync("x", -(-1.5))`], +] as const)("emits no fs acquisition for %s", (_name, call) => { + expect(acquisitionsFor(call)).toEqual([]); +}); + +it.each([ + ["a nested bitwise-not flag", `openSync("x", ~-1)`], + ["a doubly nested bitwise-not flag", `openSync("x", ~~NaN)`], + ["a coerced valid mode", `openSync("x", "r", -(~NaN))`], +] as const)("keeps the fs acquisition for %s", (_name, call) => { + expect(acquisitionsFor(call)).toEqual([ + { kind: "acquire", method: "openSync" }, + ]); +}); + +it("leaves a bitwise-not NaN flag undecided", () => { + // `~NaN` is `-1`: in range, and a negative bitmask is accepted on Linux, so the + // guard must not claim it throws. + expect(acquisitionsFor(`openSync("x", ~NaN)`)).toEqual([ + { kind: "acquire", method: "openSync" }, + ]); +}); + +/** + * Review finding: `staticFlagBits` reports "not a bitmask" for `!`, `typeof` and + * `delete` as `Number.NaN`. Recursing into that marker treated it as an operand value, + * so `openSync(p, "r", +!0)` was rejected although it receives mode `1`. + * + * A nested boolean or string result is left undecided instead: `+!0` is `1` at + * runtime, but deciding it needs the operand's truthiness rather than a bitmask read. + */ +it.each([ + ["a unary-plus boolean mode", `openSync("x", "r", +!0)`], + ["a unary-plus false mode", `openSync("x", "r", +!1)`], + ["a unary-plus boolean flag", `openSync("x", +!1)`], +] as const)("keeps the fs acquisition for %s", (_name, call) => { + expect(acquisitionsFor(call)).toEqual([ + { kind: "acquire", method: "openSync" }, + ]); +}); + +it("leaves a negated boolean mode undecided", () => { + // `-!0` is `-1`, which Node rejects, but the outer operator cannot be decided from + // a boolean marker, so the guard must not claim a throw. + expect(acquisitionsFor(`openSync("x", "r", -!0)`)).toEqual([ + { kind: "acquire", method: "openSync" }, + ]); +}); + +/** + * Review finding: a real `NaN` result and the "not a bitmask" answer must not share a + * return slot. `staticFlagBits` reports a boolean or string operand as `Number.NaN`, + * and `unaryOperandNumber` used `Object.is(nested, NaN)` to exclude it — which also + * discarded an actual numeric `NaN`, so `-(-NaN)` kept a rejected acquisition. + * + * Measured on Node 24.22.1: + * + * -(-NaN) = NaN -> THREW ERR_OUT_OF_RANGE + * +(~NaN) = -1 -> THREW ERR_OUT_OF_RANGE + * ~~NaN = 0 -> ok + */ +it.each([ + ["a double-negated NaN mode", `openSync("x", "r", -(-NaN))`], + ["a unary-plus bitwise-not NaN mode", `openSync("x", "r", +(~NaN))`], + ["a negated fractional mode", `openSync("x", "r", -(-1.5))`], + ["a negated Infinity mode", `openSync("x", "r", -Infinity)`], +] as const)("emits no fs acquisition for %s", (_name, call) => { + expect(acquisitionsFor(call)).toEqual([]); +}); + +it.each([ + ["a doubly nested bitwise-not mode", `openSync("x", "r", ~~NaN)`], + ["a unary-plus boolean mode", `openSync("x", "r", +!0)`], + ["a negated boolean mode", `openSync("x", "r", -!0)`], + ["a bitwise-not NaN flag", `openSync("x", ~NaN)`], ] as const)("keeps the fs acquisition for %s", (_name, call) => { expect(acquisitionsFor(call)).toEqual([ { kind: "acquire", method: "openSync" }, diff --git a/src/domain/javascript/javascriptSemanticResources.ts b/src/domain/javascript/javascriptSemanticResources.ts index 1e5534d40a..062136d20e 100644 --- a/src/domain/javascript/javascriptSemanticResources.ts +++ b/src/domain/javascript/javascriptSemanticResources.ts @@ -105,25 +105,25 @@ const rejectsOpenFlagLiteral = ( // A non-integer or out-of-int32 value is not a usable bitmask. The sign alone // does not decide: Node accepts some signed bitmasks such as `-2147483648` and // rejects others such as `-1`, so an in-range negative is left undecided. - const bits = staticFlagBits(value); + const bits = staticFlagBits(value, context); if (bits !== undefined) return ( bits !== null && (!Number.isInteger(bits) || bits > 2_147_483_647 || bits < -2_147_483_648) ); - // `NaN` and `Infinity` are unshadowed globals, and neither is a bitmask. - if ( - t.isIdentifier(value) && - (value.name === "NaN" || value.name === "Infinity") && - isUnmodifiedGlobalReference(value, context.state, value.name) - ) - return true; - // BigInt, boolean, object and array literals all fail Node's flag check. + // `NaN` and `Infinity` are unshadowed globals, and neither is a bitmask; a unary + // operator over one is the same non-finite number. + if (isNonFiniteNumber(value, context)) return true; + // BigInt, boolean, object and array literals all fail Node's flag check. A + // unary operator over a BigInt literal is a BigInt too. return ( t.isBigIntLiteral(value) || t.isBooleanLiteral(value) || + t.isRegExpLiteral(value) || t.isObjectExpression(value) || - t.isArrayExpression(value) + t.isArrayExpression(value) || + (t.isUnaryExpression(value) && + t.isBigIntLiteral(unwrapJavaScriptExpression(value.argument).node)) ); }; @@ -135,24 +135,187 @@ const rejectsOpenFlagLiteral = ( * it must never be judged by its operand. `!`, `typeof` and `delete` yield a * boolean or a string rather than a bitmask. */ -const staticFlagBits = (value: t.Node): number | null | undefined => { +const staticFlagBits = ( + value: t.Node, + context: DataEffectTraversalContext, + depth = 0, +): number | null | undefined => { if (t.isNumericLiteral(value)) return value.value; if (!t.isUnaryExpression(value)) return undefined; if (value.operator === "void") return null; + // `!` yields a boolean and `typeof` a string. Neither is a bitmask, so a caller + // that reads the flag or mode rejects it. This arm is only reachable from the + // top-level entry: `unaryOperandNumber` never returns one of these as a number. if ( value.operator === "!" || value.operator === "typeof" || value.operator === "delete" ) return Number.NaN; + // A unary chain resolves through its innermost readable operand, so `~-1` is `0` + // and `-(-1.5)` is `1.5`. The depth bound keeps a pathological chain from + // recursing without limit. + if (depth > 8) return undefined; const operand = unwrapJavaScriptExpression(value.argument).node; - if (!t.isNumericLiteral(operand)) return undefined; - if (value.operator === "-") return -operand.value; - if (value.operator === "+") return operand.value; - if (value.operator === "~") return ~operand.value; + const number = unaryOperandNumber(operand, context, depth + 1); + if (number === undefined) return undefined; + if (value.operator === "-") return -number; + if (value.operator === "+") return number; + if (value.operator === "~") return ~number; return undefined; }; +/** + * The number an operand of a unary operator evaluates to, or `undefined` when it is + * not exactly readable. + * + * This is separate from `staticFlagBits` because that function answers two different + * questions: a numeric bitmask, and "this is a boolean or a string rather than a + * bitmask". The second answer has no number, and a real `NaN` is a number, so they + * cannot share a return slot. A nested `!`, `typeof` or `delete` therefore stays + * undecided here: `+!0` is `1` at runtime, but deciding it needs the operand's + * truthiness rather than a bitmask reading. + */ +const unaryOperandNumber = ( + value: t.Node, + context: DataEffectTraversalContext, + depth: number, +): number | undefined => { + if (depth > 8) return undefined; + if (t.isNumericLiteral(value)) return value.value; + if (t.isUnaryExpression(value)) { + if ( + value.operator === "!" || + value.operator === "typeof" || + value.operator === "delete" || + value.operator === "void" + ) + return undefined; + const inner = unaryOperandNumber( + unwrapJavaScriptExpression(value.argument).node, + context, + depth + 1, + ); + if (inner === undefined) return undefined; + if (value.operator === "-") return -inner; + if (value.operator === "+") return inner; + if (value.operator === "~") return ~inner; + return undefined; + } + return primitiveToNumber(value) ?? nonFiniteGlobalNumber(value, context); +}; + +/** + * The number an unshadowed `NaN` or `Infinity` evaluates to, or `undefined` when the + * operand names neither. A `~` over one is a finite int32, which is why the bitwise + * result must be computed rather than treated as non-finite. + */ +const nonFiniteGlobalNumber = ( + value: t.Node, + context: DataEffectTraversalContext, +): number | undefined => { + if ( + !t.isIdentifier(value) || + (value.name !== "NaN" && value.name !== "Infinity") || + !isUnmodifiedGlobalReference(value, context.state, value.name) + ) + return undefined; + return value.name === "NaN" ? Number.NaN : Number.POSITIVE_INFINITY; +}; + +/** + * The number JavaScript's unary coercion produces for an exactly readable + * non-numeric primitive, or `undefined` when the operand is not one. `-true` is + * `-1` and `+"bad"` is `NaN`, so neither is a socket port or an fs flag. + */ +const primitiveToNumber = (value: t.Node): number | undefined => { + if (t.isStringLiteral(value)) return Number(value.value); + if (t.isBooleanLiteral(value)) return Number(value.value); + if (t.isNullLiteral(value)) return 0; + if (t.isTemplateLiteral(value) && value.expressions.length === 0) { + const cooked = value.quasis[0]?.value.cooked; + return cooked === undefined || cooked === null ? undefined : Number(cooked); + } + return undefined; +}; + +/** + * Whether an exactly readable expression is a non-finite number: an unshadowed + * `NaN` or `Infinity`, or a unary operator applied to one. `void` is excluded + * because it yields `undefined`, which both slots accept as the default. + */ +const isNonFiniteNumber = ( + value: t.Node, + context: DataEffectTraversalContext, +): boolean => { + const unshadowed = (node: t.Node): boolean => + t.isIdentifier(node) && + (node.name === "NaN" || node.name === "Infinity") && + isUnmodifiedGlobalReference(node, context.state, node.name); + if (unshadowed(value)) return true; + return ( + t.isUnaryExpression(value) && + value.operator !== "void" && + unshadowed(unwrapJavaScriptExpression(value.argument).node) + ); +}; + +/** `fs.open`, `fs.openSync` and `fs/promises.open` take the mode in slot 2. */ +const rejectsOpenModeLiteral = ( + node: t.CallExpression | t.OptionalCallExpression, + context: DataEffectTraversalContext, +): boolean => { + // A spread before the mode slot shifts the runtime position. + if ( + t.isSpreadElement(node.arguments[0]) || + t.isSpreadElement(node.arguments[1]) + ) + return false; + const mode = node.arguments[2]; + if (mode === undefined || t.isSpreadElement(mode)) return false; + const value = unwrapJavaScriptExpression(mode).node; + // `null`, `undefined` and `void ` all default the mode. + if (t.isNullLiteral(value)) return false; + // A no-expression template literal is exactly readable, so it is the same + // string Node would receive. + const octal = t.isStringLiteral(value) + ? value.value + : t.isTemplateLiteral(value) && value.expressions.length === 0 + ? value.quasis[0]?.value.cooked + : undefined; + if (octal !== undefined && octal !== null) { + if (!/^[0-7]+$/u.test(octal)) return true; + // Leading zeros pad the text without changing the value, so the bound reads the + // significant digits. Eleven octal digits cover the whole uint32 range, so a + // longer significant run cannot fit and an eleven-digit one is exact. + const digits = octal.replace(/^0+/u, ""); + return digits.length > 11 || Number.parseInt(digits, 8) > 4_294_967_295; + } + // A numeric mode is validated as an unsigned 32-bit integer. + const bits = staticFlagBits(value, context); + if (bits !== undefined) { + if (bits === null) return false; + // Values in `[2^31, 2^32-1]` abort the process rather than throwing, so + // they stay undecided instead of being claimed as a synchronous throw. + return !Number.isInteger(bits) || bits < 0 || bits > 4_294_967_295; + } + // `NaN` and `Infinity` are unshadowed globals, and neither is a mode; a unary + // operator over one is the same non-finite number. + if (isNonFiniteNumber(value, context)) return true; + // BigInt, boolean, object and array literals all fail Node's mode check. A + // unary operator over a BigInt literal is a BigInt too, and the range helpers + // above only price numeric operands. + return ( + t.isBigIntLiteral(value) || + t.isBooleanLiteral(value) || + t.isRegExpLiteral(value) || + t.isObjectExpression(value) || + t.isArrayExpression(value) || + (t.isUnaryExpression(value) && + t.isBigIntLiteral(unwrapJavaScriptExpression(value.argument).node)) + ); +}; + /** `fs.open`, `fs.openSync` and `fs/promises.open` take the flag in slot 1. */ const openFlagInFirstArgument = (reference: AcquisitionReference): boolean => reference.method === "open" || reference.method === "openSync" @@ -168,11 +331,10 @@ const collectAcquisitions = ( if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return; const reference = acquisitionMethod(node.callee, context.state); if (reference === null) return; - if ( - openFlagInFirstArgument(reference) && - rejectsOpenFlagLiteral(node, context) - ) - return; + if (openFlagInFirstArgument(reference)) { + if (rejectsOpenFlagLiteral(node, context)) return; + if (rejectsOpenModeLiteral(node, context)) return; + } output.push({ node, specifier: reference.specifier, From dc9f32976498aa128e433f7d435172ce5237c1cd Mon Sep 17 00:00:00 2001 From: Jay-Alexander Elliot <10952625+jaelliot@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:14:24 -0700 Subject: [PATCH 039/151] test(ghidra): verify PSP VFPU prefix and vector decoding (#8614) * test(psp): add source-owned VFPU prefix and vadd sequence * test(psp): assert VFPU prefix and consumer decode facts * test(psp): verify VFPU prefixes and consumers through public Ghidra MCP * docs(psp): delimit VFPU prefix/consumer conformance coverage * test(psp): preserve unknown when Ghidra omits high-pcode data * style(psp): apply pinned oxfmt output to verify-real-ghidra-mips.mjs * style(psp): apply pinned oxfmt output to psp-fixture.mjs --- docs/ghidra-psp.md | 11 +++++- scripts/verify-real-ghidra-mips.mjs | 47 ++++++++++++++++++++++++ tests/conformance/ghidra/psp-fixture.mjs | 31 ++++++++++++++++ tests/conformance/psp/probe.S | 26 +++++++++++++ 4 files changed, 113 insertions(+), 2 deletions(-) diff --git a/docs/ghidra-psp.md b/docs/ghidra-psp.md index 9f74e69174..a2684703da 100644 --- a/docs/ghidra-psp.md +++ b/docs/ghidra-psp.md @@ -93,8 +93,15 @@ The maintained PSP fixture builds from `tests/conformance/psp/`, never executes, and is independent of commercial game data. PSP GNU tools supply ELF/ABI, raw ABI-record, symbol and encoding observations; REA's parsed target and saved snapshot must agree with the independent ABI declaration. Public CLI/MCP must -recover known functions, a direct -call, the immediate `0x1234`, BITREV, a global word and a marker. The verifier +recover known functions, a direct call, the immediate `0x1234`, BITREV, a +global word and a marker. The additional source-owned VFPU function contains +`vpfxs`, `vpfxt`, `vpfxd` and three `vadd.q` instructions. +The PSP lane checks their fixed encoded bytes and Ghidra's decoded mnemonics +through `inspect_native_instruction`; an analyzed dossier reports available +high-p-code opaque-operation and truncation observations. Neither decoded +instructions nor `CALLOTHER` counts establish prefix consumption, lane-level +arithmetic or emulator/hardware equivalence. +The verifier checks observed loader/language, original bytes, Evidence, snapshot reopening, and an A-B-A target switch with different global contents at the same address. A snapshot for A must not open B. Cleanup must finish before success is reported. diff --git a/scripts/verify-real-ghidra-mips.mjs b/scripts/verify-real-ghidra-mips.mjs index 14e794ba3e..a4c04ce8d6 100644 --- a/scripts/verify-real-ghidra-mips.mjs +++ b/scripts/verify-real-ghidra-mips.mjs @@ -38,6 +38,8 @@ import { buildPspFixture, assertPspLoadedImage, assertPspProbe, + PSP_VFPU_DECODE_CASES, + assertPspVfpuProbe, } from "../tests/conformance/ghidra/psp-fixture.mjs"; import { requireMcpToolError, @@ -541,6 +543,47 @@ try { }); if (psp) assertPspProbe(move, branch); else assertMipsProbe(move, branch, byteOrder, probe); + let vfpuCoverage; + if (psp) { + const vfpuAddress = BigInt(independent.symbols.rea_psp_vfpu_probe); + const vfpuInstructions = []; + for (const { index } of PSP_VFPU_DECODE_CASES) { + vfpuInstructions.push( + await call("inspect_native_instruction", { + address: `0x${(vfpuAddress + 4n * BigInt(index)).toString(16)}`, + }), + ); + } + assertPspVfpuProbe(vfpuInstructions); + const vfpuDossier = functionDossierSchema.parse( + await call("analyze_function", { + procedure: independent.symbols.rea_psp_vfpu_probe, + detail: "complete", + }), + ); + assert.equal(BigInt(vfpuDossier.procedure.address), vfpuAddress); + const flow = vfpuDossier.native_value_flow; + const hasHighPcode = flow?.available === true; + vfpuCoverage = { + decoded_instructions: vfpuInstructions.length, + prefix_sites: 4, + vadd_sites: 3, + high_pcode: hasHighPcode ? "observed" : "unavailable", + opaque_high_pcode_operations: hasHighPcode + ? flow.operations.filter(({ opcode }) => opcode === "CALLOTHER") + .length + : null, + truncated: hasHighPcode ? flow.truncated : null, + unavailable_reason: + flow?.available === false + ? flow.reason + : hasHighPcode + ? null + : "Ghidra did not report a HighFunction value flow", + pseudocode_nonempty: vfpuDossier.pseudocode.trim().length > 0, + numeric_vector_semantics: "not_verified", + }; + } const global = await call("read_bytes", { address: independent.symbols[`${prefix}_global`], length: 4, @@ -807,6 +850,9 @@ try { "independent ELF/ABI and symbols", "loaded Ghidra identity", "fixed instruction bytes and immediate", + ...(psp + ? ["VFPU prefix/vadd opcode decoding (not arithmetic equivalence)"] + : []), psp ? "Allegrex BITREV decoding and A-B-A target identity" : "conditional branch destination", @@ -816,6 +862,7 @@ try { ? ["soft-float o32 parameter/return storage and caller constants"] : []), ], + ...(vfpuCoverage === undefined ? {} : { vfpu_decode: vfpuCoverage }), ...(softFloat === undefined ? {} : { soft_float: softFloat }), procedures: procedures.length, carved: carved ?? { diff --git a/tests/conformance/ghidra/psp-fixture.mjs b/tests/conformance/ghidra/psp-fixture.mjs index c468621de3..5e50a65820 100644 --- a/tests/conformance/ghidra/psp-fixture.mjs +++ b/tests/conformance/ghidra/psp-fixture.mjs @@ -68,6 +68,7 @@ export async function buildPspFixture(directory, globalValue = 7) { ["rea_psp_entry", "FUNC"], ["rea_psp_leaf", "FUNC"], ["rea_psp_probe", "FUNC"], + ["rea_psp_vfpu_probe", "FUNC"], ["rea_psp_global", "OBJECT"], ["rea_psp_marker", "OBJECT"], ]) { @@ -231,6 +232,36 @@ export function assertPspLoadedImage(image, targetSha256) { assert.equal(image.source_files[0].modified_sha256, targetSha256); } +/** Static decode coverage only: neither opaque CALLOTHER nor generated C proves vector math. */ +export const PSP_VFPU_DECODE_CASES = [ + { index: 2, word: 0xdc0000e4, mnemonic: "vpfxs" }, + { index: 3, word: 0xdd0000e4, mnemonic: "vpfxt" }, + { index: 4, word: 0xde000000, mnemonic: "vpfxd" }, + { index: 5, word: 0x60048090, mnemonic: "vadd.q" }, + { index: 7, word: 0xdc0000c6, mnemonic: "vpfxs" }, + { index: 8, word: 0x60048090, mnemonic: "vadd.q" }, + { index: 10, word: 0x60048090, mnemonic: "vadd.q" }, +]; + +/** Compare actual Ghidra listing instructions against source-owned PSP words. */ +export function assertPspVfpuProbe(observations) { + assert.equal(observations.length, PSP_VFPU_DECODE_CASES.length); + for (let i = 0; i < observations.length; i++) { + const expected = PSP_VFPU_DECODE_CASES[i]; + const observed = observations[i]; + const word = Buffer.alloc(4); + word.writeUInt32LE(expected.word); + assert.equal( + observed.status, + "decoded", + `VFPU instruction ${expected.index} did not decode`, + ); + assert.equal(observed.length, 4); + assert.equal(observed.bytes.toLowerCase(), word.toString("hex")); + assert.equal(observed.mnemonic.toLowerCase(), expected.mnemonic); + } +} + /** Fixed source encodings and independently chosen immediate, not pseudocode. */ export function assertPspProbe(move, bitrev) { assert.equal(move.status, "decoded"); diff --git a/tests/conformance/psp/probe.S b/tests/conformance/psp/probe.S index 43da2c17ab..62f1c2947e 100644 --- a/tests/conformance/psp/probe.S +++ b/tests/conformance/psp/probe.S @@ -12,3 +12,29 @@ rea_psp_probe: nop .end rea_psp_probe .size rea_psp_probe, .-rea_psp_probe + +/* Source-owned VFPU prefix/consumer sequence. Never executed. + * Encodings match the independent Allegrex headless conformance fixture; + * this tests static decoding, not numerical lane or prefix correctness. + */ +.align 2 +.globl rea_psp_vfpu_probe +.type rea_psp_vfpu_probe, @function +.ent rea_psp_vfpu_probe +rea_psp_vfpu_probe: + .word 0xd8800000 /* lv.q */ + .word 0xd8a40000 /* lv.q */ + .word 0xdc0000e4 /* vpfxs, identity source */ + .word 0xdd0000e4 /* vpfxt, identity target */ + .word 0xde000000 /* vpfxd */ + .word 0x60048090 /* vadd.q */ + .word 0xf8d00000 /* sv.q */ + .word 0xdc0000c6 /* vpfxs, swizzled source */ + .word 0x60048090 /* vadd.q */ + .word 0xf8d00010 /* sv.q */ + .word 0x60048090 /* vadd.q with no new prefix */ + .word 0xf8d00020 /* sv.q */ + jr $ra + nop +.end rea_psp_vfpu_probe +.size rea_psp_vfpu_probe, .-rea_psp_vfpu_probe From 1d4e9f31c344d9dde1fdbfed0fbae61a3d48a3ca Mon Sep 17 00:00:00 2001 From: Can <159528514+eco-null@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:14:43 +0300 Subject: [PATCH 040/151] test(cli): cover production doctor output option ordering (#6683) * fix(cli): retain production doctor output routing * test(reference): return byte-preserving entry names * fix(javascript): coalesce projected source facts * test: repair isolation and format fixtures * fix(evidence): retain rebound snapshot proof Carry immutable member proof across caller and target path rebinding so low-headroom error projection reuses completed Evidence without cloning its payload. --------- Co-authored-by: eco-null --- tests/boundary/cli/mcpDoctorHelpCli.test.ts | 39 +++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/boundary/cli/mcpDoctorHelpCli.test.ts b/tests/boundary/cli/mcpDoctorHelpCli.test.ts index abf2e40c31..60a561274f 100644 --- a/tests/boundary/cli/mcpDoctorHelpCli.test.ts +++ b/tests/boundary/cli/mcpDoctorHelpCli.test.ts @@ -5,6 +5,45 @@ import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js"; const CLI_INTEGRATION_TIMEOUT_MS = 60_000; describe("MCP doctor help boundary", () => { + workspaceCliTest( + "keeps output options from selecting the CLI-derived MCP doctor", + async ({ cli }) => { + for (const arguments_ of [ + ["mcp", "doctor", "--json"], + ["--json", "mcp", "doctor"], + ["mcp", "--json", "doctor"], + ["--format", "json", "mcp", "doctor"], + ["mcp", "--format", "json", "doctor"], + ["--format=json", "mcp", "doctor"], + ["mcp", "--format=json", "doctor"], + ["--full-output", "mcp", "--json", "doctor"], + ]) { + const result = await cli.run({ + arguments: arguments_, + environment: { REA_MCP_DOCTOR_DEADLINE_MS: "0" }, + }); + expect(result.exitCode, arguments_.join(" ")).toBe(1); + const report = { + healthy: false, + adapter: "production-stdio", + checks: [ + expect.objectContaining({ + name: "REA_MCP_DOCTOR_DEADLINE_MS", + ok: false, + }), + ], + }; + expect(result.json).toMatchObject( + arguments_.includes("--full-output") + ? { ok: false, error: report } + : report, + ); + expect(result.stderr).toBe(""); + } + }, + CLI_INTEGRATION_TIMEOUT_MS, + ); + workspaceCliTest( "answers the advertised help option instead of validating it", async ({ cli }) => { From 7ad3d7928bb6d50b498939875e216043fc9d9ed1 Mon Sep 17 00:00:00 2001 From: Can <159528514+eco-null@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:15:03 +0300 Subject: [PATCH 041/151] test(search): cover Unicode literal syntax and astral case folding (#6685) * fix(search): fold Unicode literal matches * test(ida): require Unicode literal case folding * test(reference): return byte-preserving entry names * fix(javascript): coalesce projected source facts * test: repair isolation and format fixtures * fix(evidence): retain rebound snapshot proof Carry immutable member proof across caller and target path rebinding so low-headroom error projection reuses completed Evidence without cloning its payload. --------- Co-authored-by: eco-null --- src/domain/analysisSearch.test.ts | 34 +++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/domain/analysisSearch.test.ts b/src/domain/analysisSearch.test.ts index 5411a693af..0bd142f762 100644 --- a/src/domain/analysisSearch.test.ts +++ b/src/domain/analysisSearch.test.ts @@ -51,3 +51,37 @@ it.each([ ).toBe(true); }, ); + +it.each([ + ["[\u03a3]+", "prefix [\u03c2]+ suffix", true], + ["a+b", "AAAB", false], + ["a+b", "prefix A+B suffix", true], + ["\\", "path\\file", true], + ["", "anything", true], + ["𐐀", "prefix 𐐨 suffix", true], +] as const)( + "keeps insensitive literal syntax for %s in %s", + (pattern, value, expected) => { + const common = { pattern, document: undefined, mode: "literal" as const }; + expect( + analysisSearchPredicate({ ...common, case_sensitive: false })(value), + ).toBe(expected); + expect( + analysisSearchPredicate({ ...common, case_sensitive: true })(value), + ).toBe(value.includes(pattern)); + }, +); + +it("keeps explicit regex semantics and invalid-pattern input errors", () => { + const common = { + document: undefined, + mode: "regex" as const, + case_sensitive: false, + }; + expect( + analysisSearchPredicate({ ...common, pattern: "^\u03c3$" })("\u03c2"), + ).toBe(true); + expect(() => analysisSearchPredicate({ ...common, pattern: "[" })).toThrow( + "search", + ); +}); From 85775b635b81b938b7a244f615efe93f384f4571 Mon Sep 17 00:00:00 2001 From: Can <159528514+eco-null@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:15:23 +0300 Subject: [PATCH 042/151] test(ghidra): verify installation metadata budget through doctor (#6690) * fix(ghidra): name installation metadata limit * test(reference): return byte-preserving entry names * fix(javascript): coalesce projected source facts * test: repair isolation and format fixtures * fix(evidence): retain rebound snapshot proof Carry immutable member proof across caller and target path rebinding so low-headroom error projection reuses completed Evidence without cloning its payload. --------- Co-authored-by: eco-null --- .../cli/ghidraInstallationProperties.test.ts | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/tests/boundary/cli/ghidraInstallationProperties.test.ts b/tests/boundary/cli/ghidraInstallationProperties.test.ts index 5f68cb0b17..968c52b67e 100644 --- a/tests/boundary/cli/ghidraInstallationProperties.test.ts +++ b/tests/boundary/cli/ghidraInstallationProperties.test.ts @@ -7,9 +7,13 @@ import { MAX_GHIDRA_INSTALLATION_PROPERTIES_BYTES } from "../../../src/ghidra/Gh import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; -cliTest.skipIf(process.platform === "win32").for(["pipe", "capacity"] as const)( +cliTest.for(["pipe", "capacity"] as const)( "doctor reports selected properties %s without launching Java", - async (kind, { cli }) => { + async (kind, { cli, skip }) => { + if (kind === "pipe" && process.platform === "win32") { + skip(); + return; + } const root = await createTestTempDirectory("rea-doctor-properties-"); const install = join(root, "ghidra"); const path = join(install, "Ghidra/application.properties"); @@ -37,5 +41,25 @@ cliTest.skipIf(process.platform === "win32").for(["pipe", "capacity"] as const)( diagnostics: { path, stage: "installation_properties" }, }, }); + if (kind === "capacity") { + expect(result.json).toMatchObject({ + retryable: false, + details: { + reported_limits: { + maximum_bytes: MAX_GHIDRA_INSTALLATION_PROPERTIES_BYTES, + observed_bytes: String( + MAX_GHIDRA_INSTALLATION_PROPERTIES_BYTES + 1, + ), + }, + }, + remediation: { + action: expect.stringContaining("installation-properties"), + }, + }); + expect(JSON.stringify(result.json)).toContain("no runtime override"); + expect(JSON.stringify(result.json)).not.toContain( + "Retry with a sufficient file-size allowance", + ); + } }, ); From 99bcd3e4e092ada9e12795dcda7c489d81cb5ca7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E4=BD=B3=E6=B5=A9Alben?= <35291372+XiaoBinGan@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:15:43 +0800 Subject: [PATCH 043/151] fix(javascript): report empty Electron coverage instead of silent zeroes (#8332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(javascript): report empty Electron coverage instead of silent zeroes `analyze_javascript_application` reported `browser_windows: 0` and `ipc.operations: 0` with only generic partial/unknown flags, so a bundle that imports `electron` but whose Electron facts were not recovered read exactly like an application with no IPC at all. Issue #8201 is that false negative on a real Rolldown bundle. Two additions, both scoped to "Electron is referenced but nothing was recovered": **A machine-readable indicator.** `electron_summary` gains `electron_semantics: "recovered" | "empty"`. It is absent when no analyzed source links an Electron module, so its presence distinguishes "not an Electron application" from "Electron coverage is empty". It follows the existing `protocol_registrations.exactOptional()` precedent, so previously captured evidence stays valid. **A graph limitation.** The limitation list already carried a note when at least one Electron fact was recovered but said nothing when zero were. It now states that zero IPC operations and zero BrowserWindows mean empty coverage rather than absence. `electronModuleReferenced` is the single owner of the module test. It reuses `ELECTRON_MODULE`, which matches exactly the supported entry points (`electron`, `electron/common`, `/main`, `/renderer`, `/utility`), so `electron-helper` does not count, and it ignores type-only links because importing Electron types promises no boundary fact. Five tests cover recovered, empty, not-referenced, type-only, and supported subpath versus unrelated package. The empty, recovered and type-only cases fail on unmodified `main`. * chore: retrigger title and readiness checks after the title was corrected * fix(javascript): report Electron coverage from retained facts Four review findings on the coverage indicator. **A rejected application projection hid the reference.** A file whose expanded relationships exceed the result budget is replaced by a placeholder with no module links before the summary runs, while `graphLimitations` reads the raw analysis. The same response could then warn in prose and omit the field. The summary now takes the retained analysis for coverage only, and both outputs read one `electronSemanticsState`, so they cannot disagree. **A skipped semantic analysis hid the reference too.** `electronModuleReferenced` read only `semantic.ir.moduleLinks`, which is absent when semantic analysis is skipped for a budget and drops `import("electron")` or `module.require("electron")` forms. It now falls back to the retained `javascript.references`, which the static producer records for those inputs, while still excluding `type-import` links when the richer owner is available. `electronBoundaryFactCount` is now the single owner of the fact total, replacing a duplicate inline reduce in the graph builder. **Omission no longer claims absence.** The schema comment said a missing `electron_semantics` means the application does not use Electron. The predicate only establishes that no retained fact names one, and unread sources, failed analysis, and dynamic module names can hide its use, so the contract now says unknown. Two regressions cover a rejected projection and a skipped semantic analysis; both fail on the previous commit. * fix(javascript): count a dynamic Electron import as coverage Review finding: `electronModuleReferenced` returned as soon as module links existed, so a literal dynamic import was ignored. Verified against the two fact owners: import("electron") -> references: [dynamic-import electron] moduleLinks: [] require("electron") -> references: [require electron] moduleLinks: [require electron] An application that only loads Electron through `import("electron")` therefore got neither `electron_semantics: "empty"` nor the empty-coverage warning, and the zero counts stayed unexplained. The two owners now answer their own half instead of one short-circuiting the other. Module links answer a runtime static import or require and keep excluding `type-import`; the retained reference is read for every kind except `static-import`, which is exactly the type-only risk. A dynamic specifier is still never counted by either owner, so this remains an observed-reference claim rather than an absence claim. The regression uses `import("electron")` and fails on the previous commit. * fix(javascript): read only module-loading Electron references Two review findings, both verified against the static reference producer. **A retained runtime static import was skipped.** The excluded kind was meant to protect the type-only distinction, but the static analyzer records a whole `import type { IpcMain } from "electron"` declaration as no reference at all: import type { IpcMain } from "electron" -> references: [] import { app } from "electron" -> references: [static-import electron] require("electron") -> references: [require electron] import("electron") -> references: [dynamic-import electron] new Worker(new URL("electron", m)) -> references: [worker electron] When semantic analysis is skipped for a budget the module links are absent, so that exclusion dropped a real runtime ESM import and left the zero counts unexplained. **A worker URL path is not an Electron module.** `new Worker(new URL("electron", import.meta.url))` records a `worker` reference whose specifier is a path relative to the importing source, so counting it reported an ordinary worker file as `electron_semantics: "empty"`. `ELECTRON_REFERENCE_KINDS` is now an explicit allowlist of the kinds that load a module by package specifier — `static-import`, `dynamic-import` and `require` — so `worker` and `service-worker` URL paths are excluded. Module links are still read first for the type-only distinction. Three regressions cover a skipped-semantics ESM import, a skipped-semantics whole type-only import, and a worker URL path; two of them fail on the previous commit. * fix(javascript): own Electron applicability in the static producer Review finding. A `worker` reference records a URL relative to the importing source, so `new Worker(new URL("electron", import.meta.url))` names a local file and is not an Electron module. The boundary owner already read only module-loading reference kinds, but `finalizeStaticAnalysis` still treated every matching static reference as `analysis_coverage.applicability.electron: "observed"`, which `javascriptStaticSourceCoverageSummary` publishes. One response could therefore say Electron was observed in its coverage view while the Electron summary omitted `electron_semantics`. `ELECTRON_REFERENCE_KINDS` now lives next to `ELECTRON_MODULE` in the domain owner and is consumed by both the static producer and the Electron boundary owner, so the two outputs cannot disagree. A worker URL path is `unknown` for both, while a `require("electron")` is `observed` for both. The regression asserts the domain applicability fact and the boundary summary agree for a worker URL path; it fails when the producer stops filtering by kind. * fix(javascript): require a loaded Electron module for applicability Review finding. A CommonJS `require.resolve("electron")` records a `require` reference with `operation: "resolve"`: it locates the module without loading it, so no Electron boundary fact is expected. Both predicates dropped the operation, so such a source reported `electron_semantics: "empty"` and the missing-coverage warning even though Electron was never loaded. Measured on the static producer: require.resolve("electron") -> references: [require resolve electron] applicability.electron: unknown require("electron") -> references: [require load electron] applicability.electron: observed import("electron") -> references: [dynamic-import load electron] applicability.electron: observed `electronModuleReferenced` and `finalizeStaticAnalysis` now both require `operation === "load"`, so the boundary summary and the coverage view stay aligned and reachability-only references no longer claim missing coverage. The regression asserts a resolve-only source produces neither the field nor the warning, and it fails when the operation check is removed. * fix(javascript): classify Electron loads in the static fact owner Review finding, and the correct home for the previous two rounds of the same complaint. A Webpack/Rspack factory may name a local module `"electron"`, and the static producer then records a loaded `require` reference whose specifier is that bundle module key. Treating the key as a package specifier reported `electron_semantics: "empty"` plus the missing-coverage warning for a bundle that never loads Electron. Three changes, all in the domain owner, so no consumer re-derives the question: - `JavaScriptModuleRangeIndex.hasKey` answers whether a specifier is a recovered bundle module key. A `require` whose specifier is such a key loads a module inside the artifact. - `electronObserved` in `finalizeStaticAnalysis` now requires a loaded module-load reference whose specifier is not a bundle key. The published `analysis_coverage.applicability.electron` therefore owns every exclusion: `type-import`, `require.resolve`, `worker`/`service-worker` URL paths, and bundle-local keys. - `electronModuleReferenced` consumes that owned fact instead of re-deriving it, keeping the boundary summary and the coverage view from disagreeing. Historical evidence without the field still falls back to the retained references. Measured: bundle-local module ID "electron" -> applicability unknown require("electron") -> applicability observed webpack external n(45840) -> applicability observed The regression covers both the local key and the real external in the same factory shape; it fails without the bundle-key exclusion. * fix(javascript): identify a bundle module by its loader, not its key Both review findings were right, and they show why the previous key-set approach was wrong: bundle-key membership is a property of the file, but whether a specifier names a module inside the artifact is a property of the reference. `JavaScriptStaticReference` now records the loader that produced it, and `electronObserved` excludes `factory-loader` references instead of every specifier that happens to match a recovered module key. Measured on Node 24.22.1: local module keyed "electron" -> unknown (factory-loader) external 45840 = require("electron") -> observed (commonjs-loader) factory keyed "electron" that loads the pkg -> observed (commonjs-loader) The third case is the regression: a bundle whose factory is *named* `"electron"` but whose body does `e.exports = require("electron")` is a real Electron load, and the old key-set check suppressed it. The real load keeps its `commonjs-loader` identity even when the same specifier also names a factory, so one response can rely on the loader while the other relies on the key without either lying. `JavaScriptModuleRangeIndex.hasKey` is removed; it answered the wrong question. One regression covers all three shapes and fails when the `factory-loader` exclusion is dropped. * fix(javascript): carry the reference loader across the worker boundary The worker schema is strict, so the new `loader` field broke the worker handshake: `unrecognized_keys` on every `references` entry. That surfaced in CI as both a worker-schema test failure and a package-baseline failure reporting `worker_failure_reason: "io"` for a one-file Electron app. `referenceShape` now accepts the same three loader identities as the domain type, so the fact survives the worker boundary instead of collapsing every analysis to an execution failure. Verified: `JavaScriptAnalysisWorkerSchemas.test.ts` 10 passed, and the full domain/application/javascript suite is 5484 passed | 10 skipped. The remaining `SourceMapCommand` and `DarwinHelperCache` findings are present on unmodified `main` and are not from this change. * fix(javascript): remove the superseded key set and forward a deferred loader Two review findings. **The `keys` set is dead.** `JavaScriptModuleRangeIndex.keys` is left over from the abandoned key-membership approach: the loader-identity refactor removed `hasKey` and the write in `add`, so nothing reads it. Removed. **The deferred require path dropped the loader identity.** `requireCallSpecifier` returns a `loader` for `commonjs-loader`, `factory-loader` and `create-require`, and the direct `addReference` call forwards it, but `finishModuleRequireCalls` did not. A `createRequire` loader saved under a member literally named `require` therefore lost its identity: import { createRequire } from "node:module"; const holder = { require: createRequire(import.meta.url) }; holder.require("x"); before: require "x" with loader undefined after: require "x" with loader "create-require" Three regressions cover it; one fails without the change. I did **not** change the inline-type-only import case. `isTypeOnlyImportDeclaration` deliberately keeps a declaration whose bindings are all inline type-only, because `hasUncertainTypeOnlyModuleSideEffect` reports that decision as `type-only-module-side-effect` uncertainty: whether TypeScript emits the import depends on the consumer's compiler options. Narrowing it to erased broke `javascriptStaticCoverage.test.ts` and `javascriptSemanticAnalysis.require_links.test.ts`, which assert exactly that contract. If Electron applicability should ignore that reference, the decision belongs where applicability is derived, not in the shared declaration predicate — say the word and I will do it there instead. * fix(javascript): keep a commonjs-loader reference out of the bundle module index Review finding. `finalizeLocatedFindings` attaches `module_runtime` to every finding that sits inside a factory module range, whoever produced it. `resolveReference` then sent every reference carrying `module_runtime` through the bundle-module lookup, so a real CommonJS load inside a factory body resolved to the local module key that shared its name: "electron": (e, t, n) => { e.exports = require("electron") } fact: specifier "electron", loader "commonjs-loader", module_key "electron" before: resolved through the bundle module index to `#module:electron` after: resolves as an external package specifier Only a `factory-loader` specifier names a recovered bundle module key, which is what `loader` already records after the loader-identity change. The module lookup is now gated on it, and an absent `loader` keeps the previous behaviour for older findings. --- .../javascript/ElectronBoundaryAnalysis.ts | 95 +++++- .../JavaScriptArtifactGraphBuilder.ts | 33 +- ...criptArtifactGraphElectronCoverage.test.ts | 318 ++++++++++++++++++ .../JavaScriptArtifactGraphFindings.ts | 8 +- .../javascriptApplicationAnalysis.ts | 11 + .../javascriptElectronMemberWrites.ts | 12 + .../javascript/javascriptStaticAnalysis.ts | 16 +- .../javascriptStaticAnalysisCalls.ts | 16 + ...scriptStaticAnalysisDeferredLoader.test.ts | 55 +++ .../javascriptStaticAnalysisState.ts | 1 + .../javascriptStaticAnalysisTypes.ts | 6 + .../JavaScriptAnalysisWorkerSchemas.ts | 5 + 12 files changed, 551 insertions(+), 25 deletions(-) create mode 100644 src/application/javascript/JavaScriptArtifactGraphElectronCoverage.test.ts create mode 100644 src/domain/javascript/javascriptStaticAnalysisDeferredLoader.test.ts diff --git a/src/application/javascript/ElectronBoundaryAnalysis.ts b/src/application/javascript/ElectronBoundaryAnalysis.ts index 8b1b3c404f..5d1bc43ca1 100644 --- a/src/application/javascript/ElectronBoundaryAnalysis.ts +++ b/src/application/javascript/ElectronBoundaryAnalysis.ts @@ -1,6 +1,10 @@ import type { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js"; import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; import type { ElectronIpcFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js"; +import { + ELECTRON_MODULE, + ELECTRON_REFERENCE_KINDS, +} from "../../domain/javascript/javascriptElectronMemberWrites.js"; import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js"; import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; @@ -117,11 +121,89 @@ export const createElectronIpcContext = ( return { records, owners, states, pairings }; }; +/** + * Whether any analyzed source loads an Electron module. + * + * `analysis_coverage.applicability.electron` is the domain owner of this + * question: it already excludes a `type-import`, a `require.resolve` that only + * locates the module, a `worker` URL path, and a specifier that is a recovered + * bundle module key. Reading the owned fact here keeps the boundary summary and + * the published coverage view from disagreeing. + * + * Historical evidence may predate that field, so the retained references answer + * the same question for those files. + */ +export const electronModuleReferenced = ( + analysis: JavaScriptArtifactAnalysis, +): boolean => + analysis.files.some(({ javascript, semantic }) => { + const applicability = javascript?.analysis_coverage?.applicability.electron; + if (applicability !== undefined) return applicability === "observed"; + const links = semantic?.ir.moduleLinks; + if ( + links !== undefined && + links.some( + ({ specifier, kind }) => + kind !== "type-import" && + specifier !== null && + ELECTRON_MODULE.test(specifier), + ) + ) + return true; + return (javascript?.references ?? []).some( + ({ specifier, kind, operation }) => + operation === "load" && + ELECTRON_REFERENCE_KINDS.has(kind) && + specifier !== null && + ELECTRON_MODULE.test(specifier), + ); + }); + +/** Every Electron boundary fact family the graph reports. */ +export const electronBoundaryFactCount = ( + analysis: JavaScriptArtifactAnalysis, +): number => + analysis.files.reduce( + (count, { javascript }) => + count + + (javascript === null + ? 0 + : javascript.electron.browser_windows.length + + javascript.electron.context_bridge_apis.length + + javascript.electron.ipc.length + + javascript.electron.sender_validations.length + + javascript.electron.utility_processes.length + + javascript.electron.native_addon_bindings.length + + javascript.electron.protocols.length), + 0, + ); + +/** + * Whether the Electron boundary summary came back empty. `undefined` means no + * analyzed source names Electron, which is unknown rather than absent: unread + * sources and dynamic module names can hide its use. + */ +export const electronSemanticsState = ( + analysis: JavaScriptArtifactAnalysis, +): "recovered" | "empty" | undefined => + electronModuleReferenced(analysis) === false + ? undefined + : electronBoundaryFactCount(analysis) === 0 + ? "empty" + : "recovered"; + /** Summarize static Electron findings without treating gaps as absence. */ export const summarizeElectronBoundaries = ( analysis: JavaScriptArtifactAnalysis, ipcContext: ElectronIpcContext, directoryPaths?: ReadonlySet, + /** + * Analysis view used only to describe coverage. A rejected application + * projection replaces a file with a placeholder in `analysis`, which would + * hide a retained Electron reference, so the caller passes the raw analysis + * and both outputs then share one coverage result. + */ + coverageAnalysis: JavaScriptArtifactAnalysis = analysis, ): ElectronBoundarySummary => { const files = new Map(analysis.files.map(({ file }) => [file.path, file])); const javascript = analysis.files.flatMap(({ javascript: value }) => @@ -155,7 +237,15 @@ export const summarizeElectronBoundaries = ( finding.channel === null ? [] : [finding.channel], ), ); + const protocolCount = javascript.reduce( + (count, { electron }) => count + electron.protocols.length, + 0, + ); + // The coverage state is read from the retained analysis so that a rejected + // projection cannot hide an Electron reference the graph still reports. + const semantics = electronSemanticsState(coverageAnalysis); return { + ...(semantics === undefined ? {} : { electron_semantics: semantics }), browser_windows: windows.length, explicit_web_preferences: windows.reduce( (count, window) => count + window.web_preferences.length, @@ -220,10 +310,7 @@ export const summarizeElectronBoundaries = ( moduleKind: finding.module_kind, }), ).length, - protocol_registrations: javascript.reduce( - (count, { electron }) => count + electron.protocols.length, - 0, - ), + protocol_registrations: protocolCount, }; }; diff --git a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts index e59e673224..f5d6a42466 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts @@ -1,4 +1,5 @@ import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js"; +import { electronSemanticsState } from "./ElectronBoundaryAnalysis.js"; import { ELECTRON_IDENTITY_LIMITATION } from "../../domain/javascript/javascriptElectronMemberWrites.js"; import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; import { @@ -240,6 +241,7 @@ function* buildJavaScriptArtifactGraphInputSteps( projectionContext.analysis, ipc, context.directoryPaths, + context.analysis, ), graph: { schema: "JavaScriptApplicationGraph", @@ -356,20 +358,9 @@ const graphLimitations = ( ): string[] => { const ipc = ipcContext.records; const pairings = ipcContext.states; - const electronFindings = context.analysis.files.reduce( - (count, { javascript }) => - count + - (javascript === null - ? 0 - : javascript.electron.browser_windows.length + - javascript.electron.context_bridge_apis.length + - javascript.electron.ipc.length + - javascript.electron.sender_validations.length + - javascript.electron.utility_processes.length + - javascript.electron.native_addon_bindings.length + - javascript.electron.protocols.length), - 0, - ); + // Shared with the Electron summary so both outputs describe one coverage + // result, including when a rejected projection drops a file from the graph. + const electronSemantics = electronSemanticsState(context.analysis); return [ ...context.analysis.limitations, ...((context.fileSet.source_selection?.unselected_files ?? 0) === 0 @@ -395,11 +386,15 @@ const graphLimitations = ( ) ? [ELECTRON_IDENTITY_LIMITATION] : []), - ...(electronFindings === 0 - ? [] - : [ - "Electron relationships are derived from inert syntax; runtime registration, reachability, defaults, and enforcement remain unproven.", - ]), + ...(electronSemantics === "empty" + ? [ + "Electron modules are referenced but no Electron boundary fact was recovered from inert syntax. Zero IPC operations and zero BrowserWindows here mean empty coverage, not evidence that the application has none.", + ] + : electronSemantics === "recovered" + ? [ + "Electron relationships are derived from inert syntax; runtime registration, reachability, defaults, and enforcement remain unproven.", + ] + : []), ...(ipc.some(({ finding }) => finding.channel === null) ? [ "Dynamic IPC channel expressions remain unresolved and are never paired by textual similarity.", diff --git a/src/application/javascript/JavaScriptArtifactGraphElectronCoverage.test.ts b/src/application/javascript/JavaScriptArtifactGraphElectronCoverage.test.ts new file mode 100644 index 0000000000..3aaac46202 --- /dev/null +++ b/src/application/javascript/JavaScriptArtifactGraphElectronCoverage.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, it } from "vitest"; + +import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; +import { projectJavaScriptModuleSemantics } from "../../domain/javascript/javascriptModuleSemanticIr.js"; +import { moduleResolutionModeForJavaScriptReference } from "../../domain/javascript/javascriptModuleResolutionMode.js"; +import { analyzeParsedJavaScriptSemanticsSteps } from "../../domain/javascript/javascriptSemanticAnalysis.js"; +import { parseJavaScriptSource } from "../../domain/javascript/javascriptSourceParser.js"; +import { analyzeParsedJavaScriptStaticSourceSteps } from "../../domain/javascript/javascriptStaticAnalysis.js"; +import type { + AnalyzedJavaScriptArtifactFile, + JavaScriptArtifactAnalysis, +} from "./JavaScriptArtifactAnalysisTypes.js"; +import { completeJavaScriptAnalysisSteps } from "./JavaScriptAnalysisControl.js"; +import { buildImmutableJavaScriptArtifactGraphSteps as buildGraph } from "./JavaScriptArtifactGraphBuilder.js"; +import { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js"; +import { electronModuleReferenced } from "./ElectronBoundaryAnalysis.js"; + +const SHA256 = "a".repeat(64); +const PATH = "main.js"; + +const EMPTY_COVERAGE = + "Electron modules are referenced but no Electron boundary fact was recovered from inert syntax. Zero IPC operations and zero BrowserWindows here mean empty coverage, not evidence that the application has none."; + +const fileFor = ( + source: string, + path: string = PATH, +): JavaScriptArtifactFile => ({ + path, + container_sha256: SHA256, + sha256: SHA256, + bytes: source.length, + inventory_artifact_id: `art_${SHA256}`, + kind: "javascript", + unpacked: false, + text: { included: true, value: source }, +}); + +const analyzedEntry = async ( + source: string, + path: string = PATH, +): Promise => { + const file = fileFor(source, path); + const parsed = parseJavaScriptSource(source, path); + if (parsed === null) throw new Error("expected a parsed source"); + const facts = await completeJavaScriptAnalysisSteps( + analyzeParsedJavaScriptStaticSourceSteps(source, parsed), + ); + const ir = await completeJavaScriptAnalysisSteps( + analyzeParsedJavaScriptSemanticsSteps( + parsed, + moduleResolutionModeForJavaScriptReference(path, "static-import"), + 100_000, + ), + ); + return { + file, + javascript: facts, + semantic: { ir: projectJavaScriptModuleSemantics(ir) }, + }; +}; + +const snapshot = { + manifest: { + root_artifact_id: `art_${SHA256}`, + root_sha256: SHA256, + root_format: "directory", + manifest_id: `agm_${SHA256}`, + graph_sha256: SHA256, + }, + nodes: [{ artifact_id: `art_${SHA256}`, sha256: SHA256, size: 1 }], + occurrences: [], + limitations: [], + integrity_contradictions: [], + coverage: { scope: "root", status: "complete" }, +} as unknown as ArtifactInventorySnapshot; + +const graphFor = async ( + source: string, + mutate?: ( + entry: AnalyzedJavaScriptArtifactFile, + ) => AnalyzedJavaScriptArtifactFile, + path: string = PATH, +): Promise<{ + limitations: readonly string[]; + electronSemantics: string | undefined; +}> => { + const base = await analyzedEntry(source, path); + const entry = mutate === undefined ? base : mutate(base); + const analysis: JavaScriptArtifactAnalysis = { + jsonDocuments: new JavaScriptJsonDocuments(), + files: [entry], + packages: [], + json_modules: [], + html_scripts: [], + html_inline_scripts: [], + source_maps: [], + visited_ast_nodes: 0, + findings: 0, + modules: 0, + parse_failures: 0, + limitations: [], + }; + const fileSet = { + files: [entry.file], + unavailable_relevant_files: [], + containers: [], + text_bytes_read: entry.file.bytes, + invalid_utf8_files: 0, + }; + const steps = buildGraph(snapshot, fileSet, analysis); + let next = steps.next(); + while (next.done !== true) next = steps.next(); + return { + limitations: next.value.graph.limitations, + electronSemantics: next.value.electronSummary.electron_semantics, + }; +}; + +describe("Electron semantic coverage", () => { + it("reports empty Electron coverage when the module is referenced but nothing is recovered", async () => { + const { limitations, electronSemantics } = await graphFor( + `const { app } = require("electron");\napp.whenReady();\n`, + ); + expect(limitations).toContain(EMPTY_COVERAGE); + expect(electronSemantics).toBe("empty"); + }); + + it("reports idle-coverage limits, not empty coverage, once a boundary fact is recovered", async () => { + const { limitations, electronSemantics } = await graphFor( + `const { ipcMain } = require("electron");\nipcMain.handle("ping", () => 1);\n`, + ); + expect(limitations).not.toContain(EMPTY_COVERAGE); + expect(limitations).toContain( + "Electron relationships are derived from inert syntax; runtime registration, reachability, defaults, and enforcement remain unproven.", + ); + expect(electronSemantics).toBe("recovered"); + }); + + it("omits the coverage field when the source never references Electron", async () => { + const { limitations, electronSemantics } = await graphFor( + `export const value = 1;\n`, + ); + expect(limitations).not.toContain(EMPTY_COVERAGE); + expect(electronSemantics).toBeUndefined(); + }); + + it("does not count a type-only Electron import as boundary coverage", () => { + // A `.js` fixture cannot carry `import type`, so the module predicate is + // exercised directly here. + const analysisWith = ( + kind: "require" | "type-import", + ): JavaScriptArtifactAnalysis => + ({ + files: [ + { + file: fileFor(""), + javascript: null, + semantic: { + ir: { moduleLinks: [{ kind, specifier: "electron" }] }, + }, + }, + ], + }) as unknown as JavaScriptArtifactAnalysis; + expect(electronModuleReferenced(analysisWith("require"))).toBe(true); + expect(electronModuleReferenced(analysisWith("type-import"))).toBe(false); + }); + + it("matches a supported Electron subpath and ignores an unrelated package", async () => { + const subpath = await graphFor( + `const { app } = require("electron/main");\napp.whenReady();\n`, + ); + expect(subpath.electronSemantics).toBe("empty"); + const unrelated = await graphFor( + `const helper = require("electron-helper");\nhelper.run();\n`, + ); + expect(unrelated.electronSemantics).toBeUndefined(); + }); + + it("keeps the coverage state when a rejected projection drops the file", async () => { + // A file whose expanded relationships exceed the result budget is replaced + // by a placeholder before the Electron summary runs, so the coverage state + // must come from the retained analysis rather than the projected view. + const { limitations, electronSemantics } = await graphFor( + `const { app } = require("electron");\napp.whenReady();\n`, + (entry) => ({ + ...entry, + application_projection_failure: { + reason: "result budget exhausted", + error: { code: "result_budget_exhausted" }, + limits: null, + }, + }), + ); + expect(electronSemantics).toBe("empty"); + expect(limitations).toContain(EMPTY_COVERAGE); + }); + + it("counts a retained static reference when semantic analysis was skipped", async () => { + // Semantic analysis can be skipped for a budget, which leaves the module + // links absent while the static reference is still retained. + const { electronSemantics } = await graphFor( + `const { app } = require("electron");\napp.whenReady();\n`, + (entry) => ({ + ...entry, + semantic: null, + }), + ); + expect(electronSemantics).toBe("empty"); + }); +}); + +it("counts a dynamic Electron import that module links do not retain", async () => { + // The semantic collector keeps no module link for `import("electron")`, so + // only the retained static reference names Electron here. + const { limitations, electronSemantics } = await graphFor( + `const electron = import("electron");\nelectron.then(() => {});\n`, + ); + expect(electronSemantics).toBe("empty"); + expect(limitations).toContain(EMPTY_COVERAGE); +}); + +it("counts a retained runtime static import when semantics are skipped", async () => { + // The static analyzer records a whole `import type` declaration as no + // reference, so a retained `static-import` is a runtime load. + const { electronSemantics } = await graphFor( + `import { app } from "electron";\napp.whenReady();\n`, + (entry) => ({ ...entry, semantic: null }), + ); + expect(electronSemantics).toBe("empty"); +}); + +it("does not count a whole type-only import when semantics are skipped", async () => { + // `import type` needs a TypeScript source, and the static analyzer records no + // reference for a whole type-only declaration. + const { electronSemantics } = await graphFor( + `import type { IpcMain } from "electron";\nexport const value = 1;\n`, + (entry) => ({ ...entry, semantic: null }), + "main.mts", + ); + expect(electronSemantics).toBeUndefined(); +}); + +it("does not treat a worker URL path as an Electron module", async () => { + // `new Worker(new URL("electron", import.meta.url))` records a `worker` + // reference whose specifier is a path relative to the importing source. + const { electronSemantics } = await graphFor( + `const worker = new Worker(new URL("electron", import.meta.url));\n`, + (entry) => ({ ...entry, semantic: null }), + ); + expect(electronSemantics).toBeUndefined(); +}); + +it("reports the same Electron applicability as the retained static fact", async () => { + // `analysis_coverage.applicability.electron` is the domain owner of the same + // question, so a worker URL path must be unknown for both outputs. + + const entry = await analyzedEntry( + `const worker = new Worker(new URL("electron", import.meta.url));\n`, + ); + expect(entry.javascript?.analysis_coverage?.applicability.electron).toBe( + "unknown", + ); + const graph = await graphFor( + `const worker = new Worker(new URL("electron", import.meta.url));\n`, + ); + expect(graph.electronSemantics).toBeUndefined(); +}); + +it("does not count a resolve-only Electron reference as coverage", async () => { + // `require.resolve("electron")` locates the module without loading it, so no + // boundary fact is expected and neither output may claim empty coverage. + const { limitations, electronSemantics } = await graphFor( + `const entry = require.resolve("electron");\n`, + ); + expect(electronSemantics).toBeUndefined(); + expect(limitations).not.toContain(EMPTY_COVERAGE); +}); + +it("classifies a bundle module by its loader, not by its name", async () => { + // A Webpack/Rspack factory can name a local module `"electron"`. A + // `factory-loader` reference names that module key, while a real external load + // keeps its `commonjs-loader` identity even in the same file. + const shell = (modules: string, consumer: string) => + `(()=>{var m={${modules}},c={};` + + `function n(t){var r=c[t];if(void 0!==r)return r.exports;var o=c[t]={exports:{}};` + + `return m[t](o,o.exports,n),o.exports}${consumer}})();`; + + const local = await graphFor( + shell( + `"electron":(e,t,n)=>{"use strict";e.exports={local:1}},` + + `100:(e,t,n)=>{"use strict";const s=n("electron");s.local}`, + `n(100);`, + ), + ); + expect(local.electronSemantics).toBeUndefined(); + expect(local.limitations).not.toContain(EMPTY_COVERAGE); + + const external = await graphFor( + shell( + `45840:e=>{"use strict";e.exports=require("electron")},` + + `100:(e,t,n)=>{"use strict";const s=n(45840);s.ipcMain.handle("x",()=>1)}`, + `n(100);`, + ), + ); + expect(external.electronSemantics).toBe("recovered"); + + // A factory keyed `"electron"` whose body loads the real package is still a + // real load, so the name alone must not suppress it. + const externalUnderKey = await graphFor( + shell( + `"electron":(e,t,n)=>{"use strict";e.exports=require("electron")},` + + `100:(e,t,n)=>{"use strict";const s=n("electron");s.ipcMain.handle("x",()=>1)}`, + `n(100);`, + ), + ); + expect(externalUnderKey.electronSemantics).toBe("recovered"); +}); diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index 9e0b3c7036..49968898b4 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -678,8 +678,14 @@ const resolveReference = ( ? null : resolveBundlerChunkReference(context, sourcePath, reference); if (chunk !== null) return chunk; + // Only a recovered factory loader names a bundle module key. A real CommonJS + // `require("electron")` inside a factory body records `commonjs-loader`, so its + // specifier is an external package name even though the finding sits inside a + // module range and carries that range's runtime. const module = - isWorker || reference.module_runtime === undefined + isWorker || + reference.module_runtime === undefined || + reference.loader === "commonjs-loader" ? undefined : context.moduleNodes.get( moduleLookupKey(sourcePath, reference.module_runtime, specifier), diff --git a/src/domain/javascript/javascriptApplicationAnalysis.ts b/src/domain/javascript/javascriptApplicationAnalysis.ts index d99ff3f5d4..294d1d31e6 100644 --- a/src/domain/javascript/javascriptApplicationAnalysis.ts +++ b/src/domain/javascript/javascriptApplicationAnalysis.ts @@ -58,6 +58,17 @@ const electronBoundarySummarySchema = z.strictObject({ resolved_native_addon_bindings: countSchema, /** Absent in evidence produced before protocol registrations were reported. */ protocol_registrations: countSchema.exactOptional(), + /** + * Whether the Electron boundary surface was actually recovered. `empty` means + * an analyzed source names Electron but no boundary fact was recovered, so the + * zeroes above are missing coverage and not evidence of absence. + * + * Absent when no retained fact names an Electron module, or in evidence + * produced before this field existed. Absence is unknown rather than proof + * that the application has no Electron surface: unread sources, failed + * analysis, and dynamic module names can hide one. + */ + electron_semantics: z.enum(["recovered", "empty"]).exactOptional(), }); const reconstructionStatisticsSchema = z.strictObject({ diff --git a/src/domain/javascript/javascriptElectronMemberWrites.ts b/src/domain/javascript/javascriptElectronMemberWrites.ts index 63f8374464..99510650c4 100644 --- a/src/domain/javascript/javascriptElectronMemberWrites.ts +++ b/src/domain/javascript/javascriptElectronMemberWrites.ts @@ -8,6 +8,18 @@ import { } from "./javascriptAstValues.js"; import { traverseJavaScriptValueAstSteps } from "./javascriptSemanticTraversal.js"; +/** + * The reference kinds that load a module by package specifier. A `worker` or + * `service-worker` reference records a URL relative to the importing source, so + * `new Worker(new URL("electron", import.meta.url))` names a local file and is + * not an Electron module. + */ +export const ELECTRON_REFERENCE_KINDS: ReadonlySet = new Set([ + "static-import", + "dynamic-import", + "require", +]); + /** Match Electron's supported module entry points. */ export const ELECTRON_MODULE = /^electron(?:\/(?:common|main|renderer|utility))?$/u; diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 770d3f4905..f8c25f0022 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -8,6 +8,7 @@ import { collectElectronWrappedCallPathsSteps } from "./electronWrappedCallPaths import { ELECTRON_IDENTITY_LIMITATION, ELECTRON_MODULE, + ELECTRON_REFERENCE_KINDS, } from "./javascriptElectronMemberWrites.js"; import { @@ -199,9 +200,22 @@ const finalizeStaticAnalysis = ( ): JavaScriptStaticAnalysis => { const parserErrors = file.errors.length; const typeOnlySideEffect = hasUncertainTypeOnlyModuleSideEffect(file.program); + // A `worker` or `service-worker` reference records a URL relative to the + // importing source, so `new Worker(new URL("electron", import.meta.url))` is + // not an Electron module. Read the same kinds the Electron boundary owner uses. + // Only a loaded module establishes Electron applicability: `require.resolve` + // locates the module without loading it, so no boundary fact is expected. const electronObserved = accumulator.references.some( ({ value }) => - value.specifier !== null && ELECTRON_MODULE.test(value.specifier), + value.specifier !== null && + value.operation === "load" && + ELECTRON_REFERENCE_KINDS.has(value.kind) && + ELECTRON_MODULE.test(value.specifier) && + // A `factory-loader` reference names a recovered bundle module, so its + // specifier is a module key inside the artifact rather than a package. A + // real external load in the same file keeps its `commonjs-loader` + // identity, so it is still counted. + value.loader !== "factory-loader", ); const resourceLimits = [...bindings.valueResourceLimits].sort(); const coverage: JavaScriptStaticCoverage = { diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index e71986ad51..c9cf1c628c 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -103,6 +103,9 @@ export const inspectCall = ( ...(requireCall.resolutionBasePath === undefined ? {} : { resolutionBasePath: requireCall.resolutionBasePath }), + ...(requireCall.loader === undefined + ? {} + : { loader: requireCall.loader }), }); const chunkLoad = chunkLoadSpecifier(node, bindings, context); if (chunkLoad !== null) @@ -281,6 +284,7 @@ const requireCallSpecifier = ( readonly specifier: string | undefined; readonly operation: "load" | "resolve"; readonly resolutionBasePath?: string; + readonly loader?: "commonjs-loader" | "factory-loader" | "create-require"; } | null => { const call = semanticRequireCallAt( node, @@ -305,6 +309,13 @@ const requireCallSpecifier = ( ...(call.sourceRelativePath === undefined ? {} : { resolutionBasePath: call.sourceRelativePath }), + // `factory-loader` names a bundle factory require, so its specifier is a + // recovered module key rather than a package specifier. + ...(call.loader === "commonjs-loader" || + call.loader === "factory-loader" || + call.loader === "create-require" + ? { loader: call.loader } + : {}), }; }; @@ -340,6 +351,10 @@ export const finishModuleRequireCalls = ( ...(call.resolutionBasePath === undefined ? {} : { resolutionBasePath: call.resolutionBasePath }), + // A factory or `createRequire` loader saved under a member named `require` + // still names a bundle module key, not a package. Dropping the loader here + // would read that key as an external specifier. + ...(call.loader === undefined ? {} : { loader: call.loader }), }); } context.accumulator.pendingModuleRequireCalls.length = 0; @@ -981,6 +996,7 @@ export const addReference = ( ...(input.resolutionBasePath === undefined ? {} : { resolution_base_path: input.resolutionBasePath }), + ...(input.loader === undefined ? {} : { loader: input.loader }), module_key: null, location: range(input.node), }, diff --git a/src/domain/javascript/javascriptStaticAnalysisDeferredLoader.test.ts b/src/domain/javascript/javascriptStaticAnalysisDeferredLoader.test.ts new file mode 100644 index 0000000000..89f3ef69e3 --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisDeferredLoader.test.ts @@ -0,0 +1,55 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +/** + * A `createRequire` loader saved under a member literally named `require` is still + * the same loader. The deferred path used to drop the loader identity, so the + * specifier of a factory or `create-require` module key read as an external package. + * + * Measured shape: + * + * import { createRequire } from "node:module"; + * const holder = { require: createRequire(import.meta.url) }; + * holder.require("x"); -> a `create-require` load of "x" + * + * A plain `module.require` member is the CommonJS loader, so it stays unqualified. + */ +const referencesFor = (source: string) => + (analyzeJavaScriptStaticSource(source).references ?? []).map( + ({ kind, specifier, loader }) => ({ kind, specifier, loader }), + ); + +it("forwards createRequire through a member named require", () => { + expect( + referencesFor( + `import { createRequire } from "node:module"; +const holder = { require: createRequire(import.meta.url) }; +holder.require("x");`, + ), + ).toEqual([ + { kind: "static-import", specifier: "node:module", loader: undefined }, + { kind: "require", specifier: "x", loader: "create-require" }, + ]); +}); + +it("forwards a destructured require member alias", () => { + expect( + referencesFor( + `import { createRequire } from "node:module"; +const { require: load } = { require: createRequire(import.meta.url) }; +load("y");`, + ), + ).toEqual([ + { kind: "static-import", specifier: "node:module", loader: undefined }, + { kind: "require", specifier: "y", loader: "create-require" }, + ]); +}); + +it("keeps a module.require member unqualified", () => { + // A bare snippet has no `module` binding, so the specifier stays unresolved; what + // this pins is that the deferred path does not invent a loader identity for it. + expect(referencesFor(`const load = module.require; load("z");`)).toEqual([ + { kind: "require", specifier: null, loader: undefined }, + ]); +}); diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index cd6b6e4425..8628fcb931 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -138,6 +138,7 @@ export interface JavaScriptReferenceInput { readonly kind: JavaScriptStaticReference["kind"]; readonly specifier: string | undefined; readonly resolutionBasePath?: string; + readonly loader?: JavaScriptStaticReference["loader"]; } /** Candidate route or network endpoint literal. */ diff --git a/src/domain/javascript/javascriptStaticAnalysisTypes.ts b/src/domain/javascript/javascriptStaticAnalysisTypes.ts index 0ad2c5eba5..489d08dfdc 100644 --- a/src/domain/javascript/javascriptStaticAnalysisTypes.ts +++ b/src/domain/javascript/javascriptStaticAnalysisTypes.ts @@ -44,6 +44,12 @@ interface JavaScriptStaticReferenceState { readonly module_runtime?: string; /** Source-relative filename selected by a proven createRequire factory. */ readonly resolution_base_path?: string; + /** + * Which loader produced this reference. A `factory-loader` specifier is a + * recovered bundle module key, so it names a module inside the artifact even + * when it reads like a package name. + */ + readonly loader?: "commonjs-loader" | "factory-loader" | "create-require"; readonly location: JavaScriptSourceRange; } diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index 0c17677dde..a7a6e7d0e3 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -327,6 +327,11 @@ const referenceShape = { ...findingContext, operation: z.enum(["load", "resolve"]), resolution_base_path: z.string().exactOptional(), + // A `factory-loader` specifier is a recovered bundle module key rather than a + // package specifier, so the loader identity travels with the reference. + loader: z + .enum(["commonjs-loader", "factory-loader", "create-require"]) + .exactOptional(), kind: z.enum([ "static-import", "dynamic-import", From fe9797b48734e0742dc703893a16615809af6092 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E4=BD=B3=E6=B5=A9Alben?= <35291372+XiaoBinGan@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:36:22 +0800 Subject: [PATCH 044/151] feat(python): qualify a recognized 3.14/3.15 pyc header (#8927) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(python): qualify a recognized 3.14/3.15 pyc header Python 3.15 is the default `python3` on current Homebrew, but its pyc was rejected with "Pyc magic 3666 is outside the pinned CPython 3.7–3.13 table" — a message that says nothing about the file and reads as if the version were unknown. Issue #8206. Two facts were wrong. **3.15 was not in the identity table at all,** so `pythonBytecodeIdentity` reported it as unknown even though the table already pins 3.14 (3627). 3666 is now pinned as 3.15. **A known identity without a pinned code layout shared one refusal with an unknown magic.** The two are different outcomes: every 3.7+ pyc carries the same packed 16-byte header, so a recognized newer magic can still be qualified even when no marshal layout is pinned for it. The parser now separates them: magic 3666 (3.15) -> CPython 3.15 header is recognized (magic 3666, 16-byte header, flags 0x0, timestamp-based), but no marshal code layout is pinned for that version, so its code objects cannot be decoded. Standalone marshal decoding is pinned for CPython 3.7–3.13. magic 1 -> Pyc magic 1 is not a pinned CPython identity, so its header cannot be qualified; ... The recognized case reports the observed version, the header shape, the PEP 552 flags and whether the pyc is hash-based or timestamp-based, which is what an agent needs to decide that a `.jsc`-style text-recovery attempt is pointless and that the file is a 3.15 pyc rather than a corrupt one. It stays a typed `unsupported` failure: no pinned layout means no decoded code object, so there is no partial success. I did not guess a 3.14/3.15 marshal layout. The 3.11+ `TYPE_CODE` order is pinned through 3.13; asserting the same order holds for 3.14 or 3.15 without a real interpreter to verify against would be an unsupported claim, which is why this change qualifies the header instead of decoding it. Docs and the tool description now say marshal decoding is pinned for 3.7–3.13 while the identity table also recognizes 3.14 and 3.15. Three regressions fail on the base commit. * chore: retrigger title and readiness checks after the title was corrected * test(python): pin the qualified header facts to the packed fields Review finding. Every new case used `pythonPycHeader(magic)` with its default zero flags and never asserted the reported flags or header mode, so a refusal that hardcoded `flags 0x0, timestamp-based` would have passed all of them. The cases now build the header from explicit fields and assert the facts that field produces: `flags 0x0/timestamp-based` and `flags 0x1` or `0x3` with a PEP 552 source hash for hash-based. A separate case parses the same magic in both modes and requires each refusal to name its own mode and not the other. Verified by hardcoding `flags 0x0` in the refusal: the two hash-based cases fail. --- docs/cli.md | 3 +- docs/python-pyc.md | 17 +++- .../python/pythonPycToolContracts.ts | 2 +- src/domain/python/pythonBytecodeIdentity.ts | 2 + src/domain/python/pythonPyc.test.ts | 5 + src/python/PythonPycParser.test.ts | 91 ++++++++++++++++++- src/python/PythonPycParser.ts | 15 ++- 7 files changed, 123 insertions(+), 12 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index ba49b0e3a9..3c743cd68a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -473,7 +473,8 @@ applicable. gzip, xz, and zstd containers are unsupported and name utility, then inspect the decompressed file. See [Linux kernel modules](kernel-modules.md). -Inspect a CPython 3.7–3.13 `.pyc` header and nested marshal code objects +Inspect a CPython 3.7–3.13 `.pyc` header and nested marshal code objects (3.14 and +3.15 headers are qualified but their marshal layout is not pinned) without executing bytecode or installing Python: ```bash diff --git a/docs/python-pyc.md b/docs/python-pyc.md index be09af8cd3..c5b69976ee 100644 --- a/docs/python-pyc.md +++ b/docs/python-pyc.md @@ -81,9 +81,14 @@ The inline Evidence result contains the artifact path, SHA-256, and byte length. - `magic`, the 16-bit little-endian field; - `python_version` and `code_layout`, mapped from the pinned CPython 3.7–3.13 - table (`py37`, `py38`, or `py311`); 3.11 alpha magics before 3494 used + marshal table (`py37`, `py38`, or `py311`); 3.11 alpha magics before 3494 used other code layouts and are unsupported. The 3.7.0a1/a2 magics 3390/3391 - used 12-byte headers and are outside this reader's supported format; + used 12-byte headers and are outside this reader's supported format. The + identity table also recognizes 3.14 (3627) and 3.15 (3666): those magics + carry the same packed 16-byte header, so the typed refusal names the observed + version and flags, and says that no marshal code layout is pinned for it. + Without a pinned layout the code objects cannot be decoded, so a recognized + version is still not a successful decode; - `flags`, `hash_based`, and `check_source` from PEP 552; - `source_hash_hex` for hash-based pycs, or `source_mtime` and `source_size` for timestamp pycs. @@ -180,9 +185,11 @@ name-table entry (`co_names`, varnames, freevars, cellvars, and Copied constant tables and each reported nested code relationship count against that same object budget. Integer digits and expanded set or dictionary keys, including a shared tuple repeated at each level, count -against the string budget. Magics outside the pinned 3.7–3.13 table, -truncated headers, trailing marshal bytes, and a non-code top-level object are -typed failures. +against the string budget. Magics outside the pinned identity table, truncated +headers, trailing marshal bytes, and a non-code top-level object are typed +failures. A recognized 3.14/3.15 magic is a typed unsupported failure too: the +header is qualified from its packed 16-byte fields, but no marshal code layout is +pinned, so no code object is reported and the result is never partial success. A decoded header does not prove the matching `.py` is present. `python_version` is the magic table, not a running interpreter. This workflow does not recover diff --git a/src/contracts/python/pythonPycToolContracts.ts b/src/contracts/python/pythonPycToolContracts.ts index 858f7db18e..eb2f065a8a 100644 --- a/src/contracts/python/pythonPycToolContracts.ts +++ b/src/contracts/python/pythonPycToolContracts.ts @@ -31,7 +31,7 @@ export const PYTHON_PYC_TOOL_CONTRACTS = [ kind: "artifact-provider", requiresActiveTarget: true, description: - "Inspect one explicit local CPython 3.7–3.13 .pyc file with a supported 16-byte header without executing bytecode, invoking CPython or recovering source. Returns the header (magic, pinned python_version, PEP 552 flags, source hash or mtime/size), the top-level code object with nested functions, names, const summaries, and bytecode length/SHA-256. Complete output is the default. detail summary retains the complete Evidence and returns header/coverage. Include an optional python-pyc code_objects view to return selected names/indices/fields in the first call; inspect_analysis_view pages the same retained source later. Every matching display name survives under its parent-scoped source_index; nested_code represents code-constant references, not runtime calls. Marshal is walked with object, depth and string budgets; unsupported magics/layouts, truncated streams and exhausted budgets return no partial success. A decoded header is not proof the matching .py is present. Portable on supported Node hosts; no Python installation required.", + "Inspect one explicit local CPython 3.7–3.13 .pyc file with a supported 16-byte header without executing bytecode, invoking CPython or recovering source. Returns the header (magic, pinned python_version, PEP 552 flags, source hash or mtime/size), the top-level code object with nested functions, names, const summaries, and bytecode length/SHA-256. Complete output is the default. detail summary retains the complete Evidence and returns header/coverage. Include an optional python-pyc code_objects view to return selected names/indices/fields in the first call; inspect_analysis_view pages the same retained source later. Every matching display name survives under its parent-scoped source_index; nested_code represents code-constant references, not runtime calls. Marshal is walked with object, depth and string budgets; unsupported magics/layouts, truncated streams and exhausted budgets return no partial success. A recognized 3.14 (3627) or 3.15 (3666) magic is qualified from its packed 16-byte header — the refusal names the observed version and flags — but no marshal code layout is pinned for those versions, so no code object is reported. A decoded header is not proof the matching .py is present. Portable on supported Node hosts; no Python installation required.", inputSchema: inspectPythonPycRequestSchema, outputSchema: analysisDetailOutputSchemaOf( pythonPycSchema, diff --git a/src/domain/python/pythonBytecodeIdentity.ts b/src/domain/python/pythonBytecodeIdentity.ts index f41e6b0b51..dd97920cb9 100644 --- a/src/domain/python/pythonBytecodeIdentity.ts +++ b/src/domain/python/pythonBytecodeIdentity.ts @@ -26,6 +26,8 @@ const PYTHON_BYTECODE_IDENTITIES: ReadonlyMap = ...identities(range(3500, 3531), "3.12"), ...identities(range(3550, 3571), "3.13"), ...identities([3627], "3.14"), + // Reported against Python 3.15, the default `python3` on current Homebrew. + ...identities([3666], "3.15"), ]); export const pythonBytecodeIdentity = ( diff --git a/src/domain/python/pythonPyc.test.ts b/src/domain/python/pythonPyc.test.ts index 5d75c654c1..69b9d0485c 100644 --- a/src/domain/python/pythonPyc.test.ts +++ b/src/domain/python/pythonPyc.test.ts @@ -41,7 +41,12 @@ it("keeps pinned CPython identity separate from standalone marshal coverage", () pythonVersion: "3.14", headerBytes: 16, }); + expect(pythonBytecodeIdentity(3666)).toEqual({ + pythonVersion: "3.15", + headerBytes: 16, + }); expect(pythonPycMarshalProfile(3627)).toBeNull(); + expect(pythonPycMarshalProfile(3666)).toBeNull(); expect(pythonPycMarshalProfile(3450)).toBeNull(); expect(pythonPycMarshalProfile(62171)).toBeNull(); expect(PYTHON_PYC_MARSHAL_PROFILES.size).toBeGreaterThan(80); diff --git a/src/python/PythonPycParser.test.ts b/src/python/PythonPycParser.test.ts index 72a67a0f7e..fb1642ce45 100644 --- a/src/python/PythonPycParser.test.ts +++ b/src/python/PythonPycParser.test.ts @@ -157,12 +157,97 @@ it.each([ }, ); -it("keeps a known newer CPython identity outside standalone decoder coverage", () => { - expect(() => parse(pythonPycHeader(3627))).toThrow( +it.each([ + { magic: 3627, version: "3.14", flags: 0x0, mode: "timestamp-based" }, + { magic: 3666, version: "3.15", flags: 0x0, mode: "timestamp-based" }, + // A non-zero flag word and a PEP 552 hash-based header, so a refusal that hardcoded + // `flags 0x0, timestamp-based` could not pass. + { magic: 3666, version: "3.15", flags: 0x3, mode: "hash-based" }, + { magic: 3627, version: "3.14", flags: 0x1, mode: "hash-based" }, +] as const)( + "qualifies the recognized CPython $version header without a pinned code layout", + ({ magic, version, flags, mode }) => { + // The header is still a packed 16-byte 3.7+ header, so its identity and flags are + // reported even though no marshal layout is pinned for this version. + const header = pythonPycHeader(magic, { + flags, + ...(flags === 0 ? {} : { sourceHash: Buffer.alloc(8, 0xab) }), + }); + expect(() => parse(header)).toThrow( + expect.objectContaining({ + kind: "unsupported", + message: expect.stringContaining( + `CPython ${version} header is recognized`, + ), + }), + ); + expect(() => parse(header)).toThrow( + expect.objectContaining({ + message: expect.stringContaining(`magic ${magic}, 16-byte header`), + }), + ); + // The reported facts are the ones packed into this header. + expect(() => parse(header)).toThrow( + expect.objectContaining({ + message: expect.stringContaining(`flags 0x${flags.toString(16)}`), + }), + ); + expect(() => parse(header)).toThrow( + expect.objectContaining({ + message: expect.stringContaining(mode), + }), + ); + expect(() => parse(header)).toThrow( + expect.objectContaining({ + message: expect.stringContaining( + "no marshal code layout is pinned for that version", + ), + }), + ); + expect(() => parse(header)).toThrow( + expect.objectContaining({ + message: expect.stringContaining( + "marshal decoding is pinned for CPython 3.7", + ), + }), + ); + }, +); + +it("reports timestamp-based for a pyc without the hash flag and hash-based with it", () => { + // Same magic, opposite header mode: the two refusals must differ only in that fact. + const timestamp = () => + parse(pythonPycHeader(3666, { mtime: 7, sourceSize: 9 })); + const hash = () => + parse( + pythonPycHeader(3666, { flags: 0x1, sourceHash: Buffer.alloc(8, 1) }), + ); + expect(timestamp).toThrow( + expect.objectContaining({ + message: expect.stringContaining("timestamp-based"), + }), + ); + expect(timestamp).toThrow( + expect.not.objectContaining({ + message: expect.stringContaining("hash-based"), + }), + ); + expect(hash).toThrow( + expect.objectContaining({ message: expect.stringContaining("hash-based") }), + ); + expect(hash).toThrow( + expect.not.objectContaining({ + message: expect.stringContaining("timestamp-based"), + }), + ); +}); + +it("does not qualify a magic outside the pinned identity table", () => { + expect(() => parse(pythonPycHeader(1))).toThrow( expect.objectContaining({ kind: "unsupported", message: expect.stringContaining( - "outside the pinned CPython 3.7–3.13 table", + "is not a pinned CPython identity, so its header cannot be qualified", ), }), ); diff --git a/src/python/PythonPycParser.ts b/src/python/PythonPycParser.ts index 4d39a2544e..0931ff47bc 100644 --- a/src/python/PythonPycParser.ts +++ b/src/python/PythonPycParser.ts @@ -223,10 +223,21 @@ export const parsePythonPyc = ( malformed( "Selected file is shorter than a 16-byte CPython 3.7+ pyc header.", ); - if (mapped === null || identity === null) + if (mapped === null || identity === null) { + // A known identity without a pinned code layout is a different outcome from an + // unrecognized magic: the header below is still qualified from the same packed + // fields every 3.7+ pyc uses, so report it instead of a bare rejection. + const flags = bytes.readUInt32LE(4); + const hashBased = (flags & PYTHON_PYC_HASH_BASED) !== 0; + const headerFacts = `16-byte header, flags 0x${flags.toString(16)}, ${ + hashBased ? "hash-based" : "timestamp-based" + }`; return unsupported( - `Pyc magic ${magic} is outside the pinned CPython 3.7–3.13 table.`, + identity === null + ? `Pyc magic ${magic} is not a pinned CPython identity, so its header cannot be qualified; standalone marshal decoding is pinned for CPython 3.7–3.13.` + : `CPython ${identity.pythonVersion} header is recognized (magic ${magic}, ${headerFacts}), but no marshal code layout is pinned for that version, so its code objects cannot be decoded. Standalone marshal decoding is pinned for CPython 3.7–3.13.`, ); + } const flags = bytes.readUInt32LE(4); const hashBased = (flags & PYTHON_PYC_HASH_BASED) !== 0; const header = { From 1902fcb13d1b8d6770bc93274f9731d0152f7937 Mon Sep 17 00:00:00 2001 From: Jay-Alexander Elliot <10952625+jaelliot@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:38:30 -0700 Subject: [PATCH 045/151] fix(cli): load Evidence record pairs from JSON files (#5129) * fix(cli): read explicit Evidence record pairs from JSON files * test(cli): isolate record-pair file regression * style(cli): format record-pair file regression * test(cli): reuse comparison fixture for record-pair file input * style(cli): format reused record-pair test invocation * test(cli): keep paired-input coverage within lint function limit --- docs/cli.md | 10 ++-- src/cliEvidenceCommands.ts | 26 ++++------- .../boundary/cli/bundleComparisonCli.test.ts | 46 ++++++++++--------- 3 files changed, 38 insertions(+), 44 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 3c743cd68a..d7414f514e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -744,10 +744,12 @@ not read artifact contents: the inventories of two builds are unresolved records, or one opaque change when you pair them. Use `compare-artifacts`, described at the end of this section, to compare their contents. -`--record_pairs` supplies an explicit JSON array of observation pairs, matching -MCP `compare_bundles.record_pairs`. Pair IDs must exist on their named sides and -be one-to-one. Without pairs, one-sided membership remains unknown; REA does -not infer correspondence from position or display names. +`--record_pairs` accepts an inline JSON array or the path to a local JSON +file containing that array, matching MCP `compare_bundles.record_pairs`. For a +file, use `rea compare left.json right.json --record_pairs pairs.json --json`. +Pair IDs must exist on their named sides and be one-to-one. Without pairs, +one-sided membership remains unknown; REA does not infer correspondence from +position or display names. Exports preserve an existing destination unless `--overwrite` is explicit. diff --git a/src/cliEvidenceCommands.ts b/src/cliEvidenceCommands.ts index 1442ddd5ed..93ead9c8e5 100644 --- a/src/cliEvidenceCommands.ts +++ b/src/cliEvidenceCommands.ts @@ -1,6 +1,5 @@ import { withCommandCancellation } from "./cli/commandCancellation.js"; -import { safeParseJson } from "./domain/safeJson.js"; -import { AnalysisInputError } from "./domain/analysisErrorCore.js"; +import { parseCliJsonInput } from "./cliJsonInput.js"; import { analysisInputErrorFromIssues } from "./domain/inputIssueProjection.js"; import { bundleComparisonInputSchema } from "./domain/bundleComparison.js"; import { Cli, z } from "incur"; @@ -72,7 +71,7 @@ export const registerEvidenceCommands = ( .string() .optional() .describe( - "JSON array of explicit left_evidence_id/right_evidence_id observation pairs", + "Inline JSON array or local JSON file of left_evidence_id/right_evidence_id pairs", ), }), run: ({ args, options }) => @@ -81,21 +80,12 @@ export const registerEvidenceCommands = ( const decoded = options.record_pairs === undefined ? { ok: true as const, value: [] } - : safeParseJson(options.record_pairs); - if (!decoded.ok) - return cliError( - new AnalysisInputError( - "compare_bundles", - { cause: decoded.cause }, - [ - { - path: ["record_pairs"], - reason: "invalid_format", - expected: "JSON array", - }, - ], - ), - ); + : await parseCliJsonInput( + options.record_pairs, + "compare_bundles", + signal, + ); + if (!decoded.ok) return decoded.error; const input = { record_pairs: decoded.value }; const selected = bundleComparisonInputSchema .pick({ record_pairs: true }) diff --git a/tests/boundary/cli/bundleComparisonCli.test.ts b/tests/boundary/cli/bundleComparisonCli.test.ts index fcf164b79b..d09f466eff 100644 --- a/tests/boundary/cli/bundleComparisonCli.test.ts +++ b/tests/boundary/cli/bundleComparisonCli.test.ts @@ -123,16 +123,10 @@ cliTest( right_evidence_id: different.records[0]!.evidence_id, }, ]; - const paired = await cli.run({ - arguments: [ - "compare", - leftPath, - changedPath, - "--record_pairs", - JSON.stringify(pairs), - "--json", - ], - }); + const pairArgs = ["compare", leftPath, changedPath, "--record_pairs"]; + const compareWithPairs = (value: string) => + cli.run({ arguments: [...pairArgs, value, "--json"] }); + const paired = await compareWithPairs(JSON.stringify(pairs)); expect(paired.exitCode).toBe(0); const pairedResult = bundleComparisonResultSchema.parse(paired.json); expect(pairedResult).toMatchObject({ @@ -142,6 +136,21 @@ cliTest( { classification: "changed", conclusion_kind: "observed_change" }, ], }); + const pairFile = join(root, "pairs.json"); + await writeFile(pairFile, JSON.stringify(pairs)); + const fromFile = await compareWithPairs(pairFile); + expect(fromFile.exitCode).toBe(0); + expect(bundleComparisonResultSchema.parse(fromFile.json)).toEqual( + pairedResult, + ); + const missingFile = join(root, "missing-pairs.json"); + const absentFile = await compareWithPairs(missingFile); + expect(absentFile.exitCode).toBe(1); + expect(absentFile.json).toMatchObject({ + code: "invalid_request", + input_path: missingFile, + input_reason: "read-failed", + }); const call = await cli.sharedMcpCall?.(); if (call === undefined) throw new Error("Expected shared MCP session"); const mcp = await call("compare_bundles", { @@ -155,18 +164,11 @@ cliTest( parseEvidence(mcp.structuredContent).normalized_result, ), ).toEqual(pairedResult); - const absentPair = await cli.run({ - arguments: [ - "compare", - leftPath, - changedPath, - "--record_pairs", - JSON.stringify([ - { ...pairs[0], left_evidence_id: `ev_${"f".repeat(64)}` }, - ]), - "--json", - ], - }); + const absentPair = await compareWithPairs( + JSON.stringify([ + { ...pairs[0], left_evidence_id: `ev_${"f".repeat(64)}` }, + ]), + ); expect(absentPair.exitCode).toBe(1); expect(absentPair.json).toMatchObject({ code: "invalid_request", From fb841a9596f5195f52f1946a2ee42e109c30cdaf Mon Sep 17 00:00:00 2001 From: Xiangfang Chen <565499699@qq.com> Date: Fri, 6 Nov 2026 15:38:47 +0800 Subject: [PATCH 046/151] feat(setup): support CodeBuddy as a setup client (#2492) Register rea in the global ~/.codebuddy/mcp.json through a new codebuddy entry in SUPPORTED_CLIENT_DEFINITIONS (json dialect, ~/.codebuddy marker, ~/.codebuddy/skills skill directory). Document the client and cover registration and uninstall with boundary tests. Loading, the bundled skill, and real REA tool calls were verified in the CodeBuddy client. --- docs/client-configuration.md | 12 ++ docs/installation.md | 1 + src/application/SupportedClients.ts | 8 + .../filesystem/clientLifecycle.test.ts | 1 + .../codebuddyClientConfiguration.test.ts | 148 ++++++++++++++++++ 5 files changed, 170 insertions(+) create mode 100644 tests/boundary/filesystem/codebuddyClientConfiguration.test.ts diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 998719288e..16417ef4d0 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -663,6 +663,18 @@ The user-level MCP paths follow the editor's VS Code layout. Trae's official documentation describes the raw JSON editor without specifying those paths; loading this registration in Trae remains unverified. +## CodeBuddy + +For CodeBuddy, setup writes `mcpServers.rea` into the global +`~/.codebuddy/mcp.json` (a standard `mcpServers` JSON file at the user home +root on every platform). Entries carry `command`, `args`, and `env`; stdio is +the default transport. CodeBuddy discovers personal skills from +`~/.codebuddy/skills`, so setup installs its skill copy there. + +The global MCP path is a plain JSON file; setup preserves every other server +and top-level key when it upserts `rea`. Loading this registration, the +bundled skill, and real REA tool calls through CodeBuddy are verified. + ## WorkBuddy For [WorkBuddy](https://www.workbuddy.cn/docs/workbuddy/From-Beginner-to-Expert-Guide/Function-Description/MCP-Guide), setup writes `mcpServers.rea` into the diff --git a/docs/installation.md b/docs/installation.md index 7144221c29..97d2fcfd8f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -636,6 +636,7 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s | Claude Desktop | `claude_desktop` | | Codex | `codex` | | Cursor | `cursor` | +| CodeBuddy | `codebuddy` | | Gemini CLI | `gemini_cli` | | Windsurf | `windsurf` | | Devin | `devin` | diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 09b44b67af..6bf7e8dbf4 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -588,6 +588,14 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ markerPath: [".cursor"], format: "json", }, + { + name: "codebuddy", + displayName: "CodeBuddy", + skillPath: [".codebuddy", "skills"], + configPath: [".codebuddy", "mcp.json"], + markerPath: [".codebuddy"], + format: "json", + }, { name: "gemini_cli", displayName: "Gemini CLI", diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index 474bf5be61..2a4700e200 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -50,6 +50,7 @@ describe("client configuration filesystem lifecycle", () => { "claude_desktop", "codex", "cursor", + "codebuddy", "gemini_cli", "windsurf", "devin", diff --git a/tests/boundary/filesystem/codebuddyClientConfiguration.test.ts b/tests/boundary/filesystem/codebuddyClientConfiguration.test.ts new file mode 100644 index 0000000000..68a5934371 --- /dev/null +++ b/tests/boundary/filesystem/codebuddyClientConfiguration.test.ts @@ -0,0 +1,148 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; +import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; +import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; +import { skillDestinations } from "../../../src/application/SetupSkill.js"; +import { supportedClients } from "../../../src/application/SupportedClients.js"; +import { systemUninstallHost } from "../../../src/application/Uninstall.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; + +const command = npxRegistrationCommand(process.platform); + +beforeEach(clearClientLocationEnvironment); +afterEach(() => vi.unstubAllEnvs()); + +const codebuddyPath = ( + home: string, + platform: NodeJS.Platform = process.platform, + env: Parameters[2] = {}, +) => + supportedClients(home, platform, env).find( + ({ name }) => name === "codebuddy", + ); + +describe("CodeBuddy configuration paths", () => { + it("resolves the global mcp.json under the home root", () => { + expect(codebuddyPath("/home/a")?.configPath).toBe( + join("/home/a", ".codebuddy", "mcp.json"), + ); + expect(codebuddyPath("/Users/a")?.markerPath).toBe( + join("/Users/a", ".codebuddy"), + ); + expect(codebuddyPath("C:\\Users\\a", "win32")?.configPath).toBe( + join("C:\\Users\\a", ".codebuddy", "mcp.json"), + ); + }); + + it("resolves the registration format as the plain mcpServers JSON dialect", () => { + expect(codebuddyPath("/home/a")?.format).toBe("json"); + }); + + it("resolves the personal skills directory under the home root", () => { + expect( + skillDestinations("/home/a", ["codebuddy"]).map(({ path }) => path), + ).toEqual([ + join("/home/a", ".codebuddy", "skills", "reverse-engineer-anything"), + ]); + }); +}); + +describe("CodeBuddy JSON registration", () => { + const codebuddyClient = async () => { + const home = await createTestTempDirectory("rea-codebuddy-"); + const client = codebuddyPath(home, process.platform); + if (client?.markerPath === undefined) + throw new Error("missing CodeBuddy client"); + await mkdir(client.markerPath, { recursive: true }); + return { home, client }; + }; + + it("upserts rea into mcpServers while preserving unrelated settings", async () => { + const { home, client } = await codebuddyClient(); + await writeFile( + client.configPath, + `{ + "mcpServers": { + "other": { "command": "other-server" } + } +}`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcpServers.other).toEqual({ command: "other-server" }); + expect(parsed.mcpServers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client }) => client === "codebuddy"), + ).toMatchObject({ state: "aligned", command }); + }); + + it("creates the mcpServers table in an existing mcp.json without servers", async () => { + const { client } = await codebuddyClient(); + await writeFile( + client.configPath, + JSON.stringify({ unrelated: { setting: true } }, null, 2), + ); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.unrelated).toEqual({ setting: true }); + expect(parsed.mcpServers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + }); + + it("registers into an empty mcp.json", async () => { + const { client } = await codebuddyClient(); + await writeFile(client.configPath, ""); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcpServers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + }); + + it("removes only the rea entry on uninstall", async () => { + const { home, client } = await codebuddyClient(); + await writeFile( + client.configPath, + JSON.stringify( + { mcpServers: { other: { command: "other-server" } } }, + null, + 2, + ), + ); + await configureClientConfiguration(client, {}, command); + + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcpServers.other).toEqual({ command: "other-server" }); + expect(parsed.mcpServers.rea).toBeUndefined(); + }); +}); From 1cc979c1046947f41be817803e73846ff5c244ab Mon Sep 17 00:00:00 2001 From: Hasan Zayni <36798269+dark13b@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:39:03 +0300 Subject: [PATCH 047/151] feat(ghidra): admit DOS MZ targets on Windows P0 (#7144) * feat(ghidra): admit DOS MZ targets on Windows P0 Windows Ghidra P0 refused dos-mz although the MzLoader import, 16-bit real-mode language attestation and load-image evidence are host-neutral. Admit dos-mz in inspectWindowsP0TargetSupport and run verify:ghidra:dos on Windows: inherit the system environment there, expect annotation and naming edits to be refused (P0 has no mutation authority), and skip verifier lineage like the 6502 lane. Explicit COM stays refused on Windows; its verifier is built around annotation edits. Refs #7121 Co-Authored-By: Claude Opus 5.5 * test(ghidra): check naming refusal in the read-only DOS lane With mutable: false the address-boundary helper skipped every naming request, so the Windows report overstated its coverage. Assert that set_address_name and set_addresses_names are refused with capability_unavailable and leave the entry name unchanged, and declare the optional mutable argument in the .d.mts. Co-Authored-By: Claude Opus 5.5 * test(ghidra): unwrap failed profile Result before asserting Co-Authored-By: Claude Sonnet 5.5 * fix(ghidra): expect an unchanged DOS entry on Windows P0 verification Windows P0 refuses edits, so verifyGhidraAddressBoundaries skips the rename-and-restore and the entry keeps its imported symbol source. The post-check still expected user_defined, which only follows a rename. Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/ghidra-dos.md | 5 +- docs/installation.md | 3 +- docs/native-investigation.md | 3 +- docs/testing-lanes.md | 7 +- docs/windows-ghidra-p0.md | 7 +- .../lib/real-ghidra-address-boundaries.d.mts | 6 +- .../lib/real-ghidra-address-boundaries.mjs | 99 ++++++++++++++----- scripts/verify-real-ghidra-dos.mjs | 37 ++++--- src/ghidra/GhidraProvider.behavior.test.ts | 38 ++++++- src/ghidra/GhidraProvider.ts | 7 +- src/ghidra/GhidraProviderCapabilities.ts | 2 +- .../android/androidJniLibraries.test.ts | 2 +- 12 files changed, 162 insertions(+), 54 deletions(-) diff --git a/docs/ghidra-dos.md b/docs/ghidra-dos.md index 460584b752..f81658d5c3 100644 --- a/docs/ghidra-dos.md +++ b/docs/ghidra-dos.md @@ -175,8 +175,9 @@ are separate from the initialized MZ module. Headerless COM requires the from the filename or unrecognized bytes. DOS extenders without an admitted MZ real-mode entry remain outside this import boundary. -DOS MZ is available through Ghidra, with no Hopper support claim. It is outside -the Windows native PE P0 boundary. macOS x64 DOS verification remains unverified. +DOS MZ is available through Ghidra, with no Hopper support claim. Windows Ghidra +P0 admits DOS MZ read-only: function annotations and naming edits are refused +there, and explicit COM is not admitted. macOS x64 DOS verification remains unverified. ## Real verification diff --git a/docs/installation.md b/docs/installation.md index 97d2fcfd8f..ee7d8a9d7e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1143,7 +1143,8 @@ and `set_addresses_names` name a function entry or any other mapped address session metadata edits leave executable bytes unchanged and are discarded on close. GUI controls require Hopper; Windows P0 remains read-only. -Windows P0 admits native x86, x86-64, ARMNT (Thumb-2) and ARM64 PE applications and DLLs, plus explicitly +Windows P0 admits native x86, x86-64, ARMNT (Thumb-2) and ARM64 PE applications and DLLs, +[DOS MZ executables](ghidra-dos.md), plus explicitly selected [raw NMOS 6502 images](raw-binary.md#nmos-6502) with a caller-supplied entry at file offset 0, on fixed local NTFS volumes. Other raw CPU families remain unsupported on Windows. diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 8afa1d44e4..b3847b4ca8 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -1211,7 +1211,8 @@ is present. A denied trace reports the `debugger-attach-denied` reason. Install a Ghidra 12.x (>=12.1) release and the 64-bit full JDK it declares, then configure REA to use them. Ghidra analysis supports Linux x64/arm64 and macOS x64/arm64; each installation requires the matching native decompiler. Experimental Windows x64 P0 admits native -x86, x86-64, ARMNT (Thumb-2) and ARM64 PE applications and DLLs, plus explicitly selected +x86, x86-64, ARMNT (Thumb-2) and ARM64 PE applications and DLLs, [DOS MZ +executables](ghidra-dos.md), plus explicitly selected [raw NMOS 6502 images](raw-binary.md#nmos-6502) with a caller-supplied load address, on fixed local NTFS volumes. Other raw CPU families remain unsupported on Windows. The boundary uses bundled Job Object ownership, protected runtime DACLs, and diff --git a/docs/testing-lanes.md b/docs/testing-lanes.md index e19786ff9f..17985ebddc 100644 --- a/docs/testing-lanes.md +++ b/docs/testing-lanes.md @@ -467,13 +467,14 @@ references, timeout recovery, and cleanup. Linux, other hosts, and the larger ## DOS Ghidra analysis `npm run verify:ghidra:dos` requires the supported Ghidra and JDK installation -on Linux x64 or macOS x64/arm64. It generates a source-owned MZ fixture without +on Linux x64, macOS x64/arm64, or Windows x64 P0. It generates a source-owned MZ fixture without a DOS emulator or compiler, then checks real 16-bit decoding, segment relocation, near/far calls, decompilation, disjoint function body ranges, stable CLI/MCP observations, unchanged source bytes, and owned process/project cleanup. Raw p-code address-space selector tokens are reported separately from -the stable observation comparison. Linux x64 and macOS arm64 are verified; -macOS x64 remains unverified. This lane is separate from host-native and optional cross-format +the stable observation comparison. Linux x64, macOS arm64 and Windows x64 are +verified; macOS x64 remains unverified. On Windows P0 the lane checks that +annotation edits are refused instead of exercising them. This lane is separate from host-native and optional cross-format verification. See [DOS analysis](ghidra-dos.md). `npm run verify:ghidra:com` uses a generated headerless fixture with no compiler, diff --git a/docs/windows-ghidra-p0.md b/docs/windows-ghidra-p0.md index 7822de053f..7151379999 100644 --- a/docs/windows-ghidra-p0.md +++ b/docs/windows-ghidra-p0.md @@ -17,7 +17,8 @@ additional permission flag or degraded mode is required. - Node.js 22.x (>=22.23.4), 24.x (>=24.22.1), or 26.12.1+; - an operator-installed Ghidra 12.x (>=12.1) distribution (verified with 12.1.4); - a 64-bit full JDK inside that installation's declared Java range (JDK 21 or newer, with no maximum, for current 12.1 releases); -- an explicit native, non-managed x86, x86-64, ARMNT (Thumb-2) or ARM64 PE application/DLL, or a +- an explicit native, non-managed x86, x86-64, ARMNT (Thumb-2) or ARM64 PE application/DLL, a + [DOS MZ executable](ghidra-dos.md), or a [raw NMOS 6502 image](raw-binary.md#nmos-6502) with a caller-supplied entry; and - the 27 read-only Ghidra inventory, memory, and function-analysis operations. @@ -26,8 +27,8 @@ PE load-image inspection returns an explicit unsupported attestation status. Independent verification covers admitted DOS MZ, explicit COM, explicit [raw-binary imports](raw-binary.md), and explicit [GameCube DOL images](ghidra-dol.md) on their supported hosts, including the -admitted raw NMOS 6502 mapping on Windows. Windows P0 admission remains limited -to native PE and raw NMOS 6502 images. For x64 PE +admitted DOS MZ and raw NMOS 6502 mappings on Windows. Windows P0 admission remains limited +to native PE, DOS MZ, and raw NMOS 6502 images; explicit COM is not admitted. For x64 PE NativeAOT RTR 9.1, the read-only snapshot parser can report metadata through `inspect_native_load_image` and `inspect_native_data_type` without a JAR or database mutation: MethodTable relationships, virtual slots and frozen strings. diff --git a/scripts/lib/real-ghidra-address-boundaries.d.mts b/scripts/lib/real-ghidra-address-boundaries.d.mts index 1376e2d14e..f54d1899f6 100644 --- a/scripts/lib/real-ghidra-address-boundaries.d.mts +++ b/scripts/lib/real-ghidra-address-boundaries.d.mts @@ -1,8 +1,12 @@ import type { Client } from "@modelcontextprotocol/client"; -/** Verify segmented aliases and restore the entry's observed public name. */ +/** + * Verify segmented aliases and restore the entry's observed public name. + * With `mutable: false` (Windows P0), annotation and naming edits must be refused. + */ export function verifyGhidraAddressBoundaries( client: Pick, entry: string, cli: (command: string, address: string, flags: string[]) => Promise, + options?: { readonly mutable?: boolean }, ): Promise; diff --git a/scripts/lib/real-ghidra-address-boundaries.mjs b/scripts/lib/real-ghidra-address-boundaries.mjs index e896b7d148..ccda2b1075 100644 --- a/scripts/lib/real-ghidra-address-boundaries.mjs +++ b/scripts/lib/real-ghidra-address-boundaries.mjs @@ -1,8 +1,16 @@ import { requireMcpToolError } from "./mcp-verifier-results.mjs"; import assert from "node:assert/strict"; -/** Exercise segmented-address truncation through real CLI/MCP reads and edits. */ -export async function verifyGhidraAddressBoundaries(client, entry, cli) { +/** + * Exercise segmented-address truncation through real CLI/MCP reads and edits. + * A read-only provider (Windows P0) must refuse edits before parsing them. + */ +export async function verifyGhidraAddressBoundaries( + client, + entry, + cli, + { mutable = true } = {}, +) { const query = async (name, args) => { const reply = await client.callTool( { name, arguments: args }, @@ -45,6 +53,14 @@ export async function verifyGhidraAddressBoundaries(client, entry, cli) { ); assert.equal(reply.isError, true, `${name} accepted ${address}`); const error = requireMcpToolError(reply); + if (!mutable && name === "annotate_native_function") { + assert.equal( + error?.code, + "capability_unavailable", + JSON.stringify(reply), + ); + continue; + } assert.equal(error?.code, "invalid_request", JSON.stringify(reply)); assert.equal(error.details.operation, name); const issues = JSON.stringify(error.details.issues); @@ -65,6 +81,63 @@ export async function verifyGhidraAddressBoundaries(client, entry, cli) { await query("read_bytes", { address: padded, length: 1 }), await query("read_bytes", { address: entry, length: 1 }), ); + if (mutable) await verifyAliasNaming(query, entry, padded); + else await verifyNamingRefused(client, query, entry, padded); + for (const [command, flags, operation] of [ + ["read-bytes", ["--length", "1"], "read_bytes"], + [ + "annotate-native-function", + ["--name", "MUST_NOT_ALIAS"], + "annotate_native_function", + ], + ]) { + let failure; + try { + await cli(command, representations[0], flags); + } catch (error) { + assert.equal(error.code, 1); + failure = JSON.parse(error.stdout); + } + if (!mutable && operation === "annotate_native_function") { + assert.equal(failure?.code, "capability_unavailable"); + continue; + } + assert.equal(failure?.code, "invalid_request"); + assert.equal(failure.details.operation, operation); + assert.match( + JSON.stringify(failure.details.issues), + /cannot be represented without truncation/u, + ); + } +} + +/** A read-only provider refuses both naming operations and keeps the entry name. */ +async function verifyNamingRefused(client, query, entry, padded) { + const originalName = await query("address_name", { address: entry }); + for (const [name, args] of [ + ["set_address_name", { address: entry, name: "MUST_NOT_RENAME" }], + [ + "set_addresses_names", + { names: { [entry]: "MUST_NOT_RENAME", [padded]: "MUST_NOT_RENAME" } }, + ], + ]) { + const reply = await client.callTool( + { name, arguments: args }, + { timeout: 240000 }, + ); + assert.equal(reply.isError, true, `${name} was not refused`); + const error = requireMcpToolError(reply); + assert.equal(error?.code, "capability_unavailable", JSON.stringify(reply)); + } + assert.equal( + await query("address_name", { address: entry }), + originalName, + "A refused naming edit changed the entry name", + ); +} + +/** Equivalent spellings of one address share or reject one naming edit together. */ +async function verifyAliasNaming(query, entry, padded) { const originalName = await query("address_name", { address: entry }); assert.equal( typeof originalName, @@ -127,26 +200,4 @@ export async function verifyGhidraAddressBoundaries(client, entry, cli) { throw cleanupCause; } } - for (const [command, flags, operation] of [ - ["read-bytes", ["--length", "1"], "read_bytes"], - [ - "annotate-native-function", - ["--name", "MUST_NOT_ALIAS"], - "annotate_native_function", - ], - ]) { - let failure; - try { - await cli(command, representations[0], flags); - } catch (error) { - assert.equal(error.code, 1); - failure = JSON.parse(error.stdout); - } - assert.equal(failure?.code, "invalid_request"); - assert.equal(failure.details.operation, operation); - assert.match( - JSON.stringify(failure.details.issues), - /cannot be represented without truncation/u, - ); - } } diff --git a/scripts/verify-real-ghidra-dos.mjs b/scripts/verify-real-ghidra-dos.mjs index 7bad32a135..fdfe11fa02 100644 --- a/scripts/verify-real-ghidra-dos.mjs +++ b/scripts/verify-real-ghidra-dos.mjs @@ -35,10 +35,10 @@ import { verifyGhidraAddressBoundaries } from "./lib/real-ghidra-address-boundar // This lane intentionally has no game paths, binary seeds or compiler dependency. if (process.argv.length !== 2) throw new Error("Usage: node scripts/verify-real-ghidra-dos.mjs"); -if (!["linux", "darwin"].includes(process.platform)) - throw new Error( - "verify:ghidra:dos supports Linux/macOS hosts only; Windows P0 is PE x86-64 only.", - ); +if (!["linux", "darwin", "win32"].includes(process.platform)) + throw new Error("verify:ghidra:dos supports Linux, macOS and Windows hosts."); +// Windows P0 is read-only: annotation and naming edits must be refused. +const windows = process.platform === "win32"; if (process.env.GHIDRA_INSTALL_DIR === undefined) throw new Error( "verify:ghidra:dos prerequisite missing: GHIDRA_INSTALL_DIR (bring your own Ghidra).", @@ -72,6 +72,8 @@ await writeFile(targetPath, fixture.bytes, { mode: 0o600 }); const sha256 = digest(fixture.bytes); const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url)); const env = { + // Windows children need the system environment (SystemRoot, profile paths). + ...(windows ? { ...process.env, TMP: runtime, TEMP: runtime } : {}), PATH: process.env.PATH ?? "/usr/bin:/bin", TMPDIR: runtime, REA_LOG_LEVEL: "silent", @@ -396,21 +398,25 @@ try { client, identities.entry.address, cliEvidence, + { mutable: !windows }, ); const entrySymbol = entryWindow.procedure.symbol; assert.ok(entrySymbol != null, "The DOS entry needs observed symbol facts"); // Restoring the name is itself a user edit, so Ghidra records user provenance. + // Windows P0 refuses edits and never renames, so its entry stays unchanged. assert.deepEqual( await call("read_function_instructions", { procedure: identities.entry.address, }), - { - ...entryWindow, - procedure: { - ...entryWindow.procedure, - symbol: { ...entrySymbol, source: "user_defined" }, - }, - }, + windows + ? entryWindow + : { + ...entryWindow, + procedure: { + ...entryWindow.procedure, + symbol: { ...entrySymbol, source: "user_defined" }, + }, + }, "Restoring the entry name changed more than its symbol provenance", ); assert.equal( @@ -474,7 +480,11 @@ try { "The complete body is Ghidra's observed AddressSet, not independently proven historical compiler ownership.", "Language/compiler identity is bound by the production authenticated bridge handshake; profile loader is the declared import policy.", "Native high-pcode flow is excluded from cross-runtime parity: LOAD/STORE address-space selector constants are process-specific in current Ghidra output. Both dossiers are still strictly parsed.", - "Windows P0 remains unsupported for DOS MZ.", + ...(windows + ? [ + "Windows P0 is read-only: annotation and naming edits were checked as refused, not exercised.", + ] + : []), ], }; } catch (error) { @@ -510,7 +520,8 @@ try { } assert.ok(report, "No DOS proof report was produced"); const verifierRun = await completeVerifierRun(run); -assertVerifierCleanupComplete(verifierRun.process_lineage); +// Windows provider jobs are verified by their native owners, as in the 6502 lane. +if (!windows) assertVerifierCleanupComplete(verifierRun.process_lineage); process.stdout.write( `${JSON.stringify({ verifier_run: verifierRun, ...report, cleanup: "complete" })}\n`, ); diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index 2de2110101..dca0ea3d3a 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -334,8 +334,6 @@ describe("Ghidra provider", () => { }, }, }); - const windows = provider(installationHost("win32")); - expect(windows.inspectTargetSupport(target).status).toBe("unsupported"); }); it("admits parsed Hexagon ELF targets through Ghidra profile selection and declines them on Windows", async () => { const parsed = parseExecutableHeader(elf(1, 1, 164), "x64"); @@ -761,6 +759,42 @@ describe("Ghidra platform support", () => { }); }); +describe("Ghidra Windows DOS admission", () => { + it("admits DOS MZ through the POSIX real-mode import but not explicit COM (#7121)", async () => { + const windows = provider(installationHost("win32")); + const target: BinaryTarget = { + path: "C:\\fixtures\\legacy.exe", + sha256: "a".repeat(64), + kind: "executable", + format: "dos-mz", + architecture: "x86", + availableArchitectures: ["x86"], + }; + expect(windows.inspectTargetSupport(target)).toMatchObject({ + status: "supported", + diagnostics: { host_platform: "win32", target_format: "dos-mz" }, + }); + expect( + windows.inspectTargetSupport({ ...target, format: "dos-com" }), + ).toMatchObject({ + status: "unsupported", + code: "target_format_unsupported", + }); + const resolved = await windows.resolveAnalysisProfile(target); + if (!resolved.ok) throw resolved.error; + expect(resolved.value).toMatchObject({ + profile: { + parameters: { + loader: "MzLoader", + load_image_evidence: "independent-mz-mapping-relocations-v2", + language_id: "x86:LE:16:Real Mode", + load_segment: "0x1000", + }, + }, + }); + }); +}); + describe("Ghidra Windows platform support", () => { it("keeps Windows P0 unavailable until native isolation authority exists", async () => { const reason = "Authored native artifact unavailable diagnostic"; diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index 040b7330a4..7716de4eee 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -694,7 +694,10 @@ const inspectWindowsP0TargetSupport = ( // The raw 6502 lane uses the same immutable snapshot and native ownership boundary. if (target.format === "raw-binary" && target.architecture === "6502") return { status: "supported", code: null, reason: null, diagnostics }; - // DOL is verified on POSIX hosts only; Windows P0 does not admit it, like the other non-PE lanes. + // DOS MZ reuses the host-neutral MzLoader import and its attested load image. + if (target.format === "dos-mz") + return { status: "supported", code: null, reason: null, diagnostics }; + // DOL is verified on POSIX hosts only; Windows P0 does not admit it. if (target.format === "gamecube-dol") return { status: "unsupported", @@ -708,7 +711,7 @@ const inspectWindowsP0TargetSupport = ( status: "unsupported", code: "target_format_unsupported", reason: - "Windows Ghidra P0 accepts native PE and explicitly selected raw 6502 targets only.", + "Windows Ghidra P0 accepts native PE, DOS MZ, and explicitly selected raw 6502 targets only.", diagnostics, }; if (!WINDOWS_P0_NATIVE_PE_ARCHITECTURES.includes(target.architecture)) diff --git a/src/ghidra/GhidraProviderCapabilities.ts b/src/ghidra/GhidraProviderCapabilities.ts index 25d5ec9f34..19d5c23bfc 100644 --- a/src/ghidra/GhidraProviderCapabilities.ts +++ b/src/ghidra/GhidraProviderCapabilities.ts @@ -28,7 +28,7 @@ export const healthLimitations = Object.freeze([ /** Additional limitations applied to the experimental Windows x64 P0 boundary. */ export const windowsP0Limitations = Object.freeze([ - "Windows Ghidra P0 accepts native x86, x86-64, ARMNT (Thumb-2) and ARM64 PE applications and DLLs, and explicitly selected raw 6502 images; managed, hostile, sensitive, and mutable-path targets are unsupported.", + "Windows Ghidra P0 accepts native x86, x86-64, ARMNT (Thumb-2) and ARM64 PE applications and DLLs, DOS MZ images, and explicitly selected raw 6502 images; managed, hostile, sensitive, and mutable-path targets are unsupported.", "The Windows bridge uses authenticated IPv4 loopback because Node path-based IPC does not expose Java AF_UNIX sockets; the endpoint file contains no bearer token.", "Windows sessions require the matching packaged Windows x64 native addon, local NTFS targets and runtimes, and Windows 10 or later. Native handles enforce path admission, private DACLs, and Job Object ownership automatically.", ]); diff --git a/tests/boundary/android/androidJniLibraries.test.ts b/tests/boundary/android/androidJniLibraries.test.ts index 734d141ef8..e292c734fb 100644 --- a/tests/boundary/android/androidJniLibraries.test.ts +++ b/tests/boundary/android/androidJniLibraries.test.ts @@ -17,8 +17,8 @@ import { import { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; -import type { AndroidJniSelection } from "../../../src/domain/android/androidJniBindings.js"; import { + type AndroidJniSelection, androidJniLibraryCheckpointSchema, type AndroidJniLibraryCheckpoint, } from "../../../src/domain/android/androidJniBindings.js"; From 3e0599a9499bffc9dbbb68aa3025e8983058902b Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:59:03 +0800 Subject: [PATCH 048/151] fix(javascript): preserve loader identity across terminal origins (#9107) Exclude only proven separate values from Webpack loader alias walks; preserve unsupported, projected and unresolved origins as unknown and retain the real alias-state bound. Refs #7253. The full Bitwarden precision regression remains open. Validation: 336 focused tests and check:fast passed; real Bitwarden preload identity retained; full main/preload completed with disclosed partial coverage. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) --- .../electronBundledRequireIdentity.test.ts | 123 ++++++++++++++++++ .../javascriptBundledRequireOrigin.ts | 37 ++++-- .../javascriptBundlerRuntimeScopes.test.ts | 46 +++++++ 3 files changed, 193 insertions(+), 13 deletions(-) diff --git a/src/domain/javascript/electronBundledRequireIdentity.test.ts b/src/domain/javascript/electronBundledRequireIdentity.test.ts index d2f10ef81f..c30a34a2c1 100644 --- a/src/domain/javascript/electronBundledRequireIdentity.test.ts +++ b/src/domain/javascript/electronBundledRequireIdentity.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { runInNewContext } from "node:vm"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; @@ -181,6 +182,128 @@ describe("Electron identity through Webpack externals", () => { }); }); +describe("Webpack loader alias bounds", () => { + const getterSource = (initializers: readonly string[], selection = "") => + bootstrap( + EXTERNAL, + 'const s=n.n(n(45840));new(s().BrowserWindow)({});s().ipcMain.handle("ready",()=>{});', + `let box={};${initializers.map((value) => `if(flag)box=${value};`).join("")}${selection}box.n=()=>0;n(100);`, + ); + + it.each([false, true])( + "preserves only proven Electron identity beside a large object union (loader=%s)", + (includesLoader) => { + const source = getterSource([ + ...Array(520).fill("{}"), + ...(includesLoader ? ["n"] : []), + ]); + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.browser_windows).toHaveLength( + includesLoader ? 0 : 1, + ); + expect(analysis.electron.ipc).toHaveLength(includesLoader ? 0 : 1); + if (!includesLoader) + expect(analysis.analysis_coverage).toMatchObject({ + examination: { + semantic_resource_limits: expect.not.arrayContaining([ + "alias-selections", + ]), + }, + }); + }, + ); + + it.each( + [1, 520].flatMap((count) => + [ + "flag?n:{}", + "flag&&n", + "flag||n", + "(0,n)", + "getLoader()", + "new GetLoader()", + "holder.loader", + "unresolvedLoader", + ].map((value) => ({ count, value })), + ), + )( + "keeps $count possible loader values through $value unknown", + ({ count, value }) => { + const analysis = analyzeJavaScriptStaticSource( + getterSource(Array(count).fill(value)), + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.browser_windows).toEqual([]); + expect(analysis.electron.ipc).toEqual([]); + }, + ); + + it.each(["[]", "()=>0", "class {}", "0", "'text'"])( + "retains the getter when terminal origins are %s", + (value) => { + const analysis = analyzeJavaScriptStaticSource( + getterSource(Array(520).fill(value)), + ); + expect(analysis.electron.browser_windows).toHaveLength(1); + expect(analysis.electron.ipc).toHaveLength(1); + }, + ); + + it.each([1, 520])("keeps %s projected loader writes unknown", (count) => { + const analysis = analyzeJavaScriptStaticSource( + getterSource([], "if(flag)({slot:box}={slot:n});".repeat(count)), + ); + expect(analysis.electron.browser_windows).toEqual([]); + expect(analysis.electron.ipc).toEqual([]); + }); + + it.each(["conditional", "projected"])( + "observes no runtime window after a %s alias replaces the getter", + (kind) => { + let windows = 0; + const source = + kind === "conditional" + ? getterSource(Array(520).fill("flag?n:{}")) + : getterSource([], "if(flag)({slot:box}={slot:n});"); + expect(() => + runInNewContext(source, { + flag: true, + require: () => ({ + BrowserWindow: class { + constructor() { + windows += 1; + } + }, + ipcMain: { handle() {} }, + }), + }), + ).toThrow(/is not a function/u); + expect(windows).toBe(0); + }, + ); + + it("keeps a limited loader-alias walk unknown", () => { + const aliases = Array.from( + { length: 520 }, + (_, index) => `const box${index + 1}=box${index};`, + ).join(""); + const analysis = analyzeJavaScriptStaticSource( + bootstrap( + EXTERNAL, + "const s=n.n(n(45840));new(s().BrowserWindow)({});", + `const box0={};${aliases}box520.n=()=>0;n(100);`, + ), + ); + expect(analysis.electron.browser_windows).toEqual([]); + expect(analysis.analysis_coverage).toMatchObject({ + examination: { + semantic_resource_limits: expect.arrayContaining(["alias-selections"]), + }, + }); + }); +}); + describe("Webpack externals with a tampered runtime", () => { it.each([ [ diff --git a/src/domain/javascript/javascriptBundledRequireOrigin.ts b/src/domain/javascript/javascriptBundledRequireOrigin.ts index 0e7db5e856..9a69f44dfb 100644 --- a/src/domain/javascript/javascriptBundledRequireOrigin.ts +++ b/src/domain/javascript/javascriptBundledRequireOrigin.ts @@ -6,6 +6,7 @@ import { proveJavaScriptBootstrapIntegrity } from "./javascriptBootstrapIntegrit import { semanticMemberChain, semanticStaticPropertyKey, + readExactJavaScriptLiteral, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import { collectJavaScriptIdentityWritesSteps } from "./javascriptElectronMemberWrites.js"; @@ -159,32 +160,42 @@ const canonicalWebpackInteropBindings = ( // The shared identity-write owner covers assignment, delete, destructuring, // and Object/Reflect mutators; any write that may select `n` counts. const writes = new Set(); + const interop = createSemanticModuleInterop(state); + const excludesLoader = (node: t.Node): boolean => + interop.isFreshObject(node) || readExactJavaScriptLiteral(node).found; const loaderMayOwnReference = (root: t.Node): boolean => { - const pending = [root]; - const seen = new Set(); + const first = unwrapJavaScriptExpression(root).node; + if (!t.isIdentifier(first)) return !excludesLoader(first); + // Only proven separate values are terminals. An unsupported expression + // might return the loader, so preserve that uncertainty instead of dropping it. + const pending = [first]; + const seen = new Set(); while (pending.length > 0) { - const reference = pending.pop(); - if (reference === undefined || seen.has(reference)) continue; - seen.add(reference); + const node = pending.pop(); + if (node === undefined || seen.has(node)) continue; + seen.add(node); if (seen.size > SEMANTIC_ALIAS_SELECTION_LIMIT) { state.valueResourceLimits.add("alias-selections"); return true; } - const node = unwrapJavaScriptExpression(reference).node; - if (!t.isIdentifier(node)) continue; const binding = resolveSemanticBindingState(state, node, node.name); - if (binding === undefined) continue; + if (binding === undefined) return true; if (loaderBindingIds.has(binding.bindingId)) return true; const selected = semanticBindingAtRead(binding, node, state).binding; - for (const source of [ - ...selected.initializers, - ...selected.referenceInitializers, + for (const initializers of [ + selected.initializers, + selected.referenceInitializers, ]) - if (source.projection.length === 0) pending.push(source.node); + for (const source of initializers) { + // A fresh container does not establish the selected property's value. + if (source.projection.length !== 0) return true; + const next = unwrapJavaScriptExpression(source.node).node; + if (t.isIdentifier(next)) pending.push(next); + else if (!excludesLoader(next)) return true; + } } return false; }; - const interop = createSemanticModuleInterop(state); completeSemanticSteps( collectJavaScriptIdentityWritesSteps( state.program, diff --git a/tests/boundary/cli/javascriptBundlerRuntimeScopes.test.ts b/tests/boundary/cli/javascriptBundlerRuntimeScopes.test.ts index 2ad1529870..0bc0547d3a 100644 --- a/tests/boundary/cli/javascriptBundlerRuntimeScopes.test.ts +++ b/tests/boundary/cli/javascriptBundlerRuntimeScopes.test.ts @@ -26,6 +26,52 @@ const runtimeSource = ( const appA = runtimeSource("webpackChunkAppA", "a"); const appB = runtimeSource("webpackChunkAppB", "b"); +for (const includesLoader of [false, true]) { + cliTest( + `preserves only proven Webpack Electron facts (conditional loader=${includesLoader})`, + async ({ cli }) => { + const root = await createTestTempDirectory("rea-webpack-loader-union-"); + const assignments = Array.from( + { length: 520 }, + () => `if(flag)box=${includesLoader ? "flag?load:{}" : "{}"};`, + ).join(""); + await writeFixtureFiles(root, { + "main.cjs": `(()=>{ + var modules={ + 1:module=>{module.exports=require("electron")}, + 2:(module,exports,load)=>{ + const electron=load.n(load(1)); + new(electron().BrowserWindow)({}); + electron().ipcMain.handle("ready",()=>{}); + } + },cache={}; + function load(id){ + var found=cache[id];if(found!==undefined)return found.exports; + var module=cache[id]={exports:{}}; + return modules[id](module,module.exports,load),module.exports; + } + load.n=value=>value&&value.__esModule?()=>value.default:()=>value; + let box={};${assignments}box.n=()=>0;load(2); + })();`, + }); + const output = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(output.exitCode).toBe(0); + const result = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(output.json).normalized_result, + ); + expect(result.summary).toMatchObject({ + browser_windows: includesLoader ? 0 : 1, + ipc: { + operations: includesLoader ? 0 : 1, + main_handlers: includesLoader ? 0 : 1, + }, + }); + }, + ); +} + cliTest( "keeps source-derived runtime, module and chunk tuples collision-free", async ({ cli }) => { From 39827ec7171a311cd5258ffb1ef953068aa84051 Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:22:26 +0800 Subject: [PATCH 049/151] docs(mcp): document verified FastMCP catalog recovery (#9113) Document existing compact output advertisements for full catalogs and explicit hot/cold refresh under the FastMCP1MiB SSE limit. Preserve canonical server validation and spell out the remaining Unsloth and large-result verification gaps. Refs #9093; related #5861,#7155. Validation: four real FastMCP HTTP profile trials; docs:check and formatting. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) --- docs/client-configuration.md | 39 ++++++++++++++++++++++++++++++++++-- docs/mcp-contracts.md | 11 +++++++--- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 16417ef4d0..be1d0c53b1 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -126,8 +126,43 @@ REA_MCP_TOOL_CATALOG_PROFILE = "hot-cold" The server setting alone does not enable client refresh. If a successful discovery call is followed by a missing tool, check the client's refresh feature and tool allow/deny lists. Clients without refresh should use the default -`REA_MCP_TOOL_CATALOG_PROFILE=full`; reconnect after changing it. Other clients' -hot/cold behavior has not been verified. +`REA_MCP_TOOL_CATALOG_PROFILE=full`; reconnect after changing it. The +[FastMCP HTTP trial](#fastmcp-and-unsloth-sse-catalog-limits) below covers +explicit refreshes; other clients' automatic refresh remains unverified. + +## FastMCP and Unsloth SSE catalog limits + +If an HTTP client reports `Server-sent event exceeded the 1048576 byte limit` +during `tools/list`, merge these settings into the **REA server process's** +environment, then reconnect: + +```text +REA_MCP_INPUT_SCHEMA_PROFILE=compact +REA_MCP_OUTPUT_SCHEMA_PROFILE=compact +REA_MCP_TOOL_CATALOG_PROFILE=full +``` + +This advertises all tools without requiring catalog refresh. Compact output +schemas widen client-side validation; REA still validates complete results +against the canonical contracts. See [output schemas](mcp-contracts.md#output-schemas). + +On main `3e0599a94`, a real FastMCP 4.1.6 / Python MCP 2.3.0 HTTP client with +the default 1 MiB SSE event limit rejected the 4,108,975-byte full catalog. +The settings above delivered all 240 tools in 611,305 bytes and a +`binary_session` call succeeded. These are measured SSE response sizes for +that catalog, not a size guarantee for future catalogs or other profiles. + +For a client that refreshes tools, combine `hot-cold` with the compact schema +settings to reduce initial context further: the same trial delivered six +initial tools in 33,903 bytes, then explicitly refreshed through 34 loaded +tools. Hot/cold alone later exceeded the SSE limit during refresh. Follow +the [refresh requirements](#hotcold-catalogs). + +The Unsloth report [#9093](https://github.com/morluto/rea/issues/9093) fails at +catalog delivery; the Unsloth application itself was not exercised in this +trial. Schema compaction does not bound analysis results. Continue to use +summary/detail selection and tool-specific paging for larger results; client +receive limits and full Evidence access still apply. ## Fiddler Everywhere alongside REA diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 93e9be79e3..765f8fc06f 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -2013,15 +2013,20 @@ profile and the advertised form that profile produces. `full`, `compact`, and bounded schemas clients receive; `off` keeps the canonical output contracts in the digest while advertising nothing. -Some stdio hosts reject a complete `tools/list` frame before they load any -tools. The opt-in `REA_MCP_OUTPUT_SCHEMA_PROFILE=compact` setting bounds every -advertised output schema to 4,096 bytes; `off` omits `outputSchema` entirely +Some clients reject a complete `tools/list` frame or HTTP SSE event before +they load any tools. The opt-in `REA_MCP_OUTPUT_SCHEMA_PROFILE=compact` +setting bounds every advertised output schema to 4,096 bytes; `off` omits `outputSchema` entirely (the measured pi-web-ui failure and catalog breakdown are in [#2512](https://github.com/morluto/rea/issues/2512)). The default `full` profile is unchanged. Both settings keep the same named canonical tool contract for server-side result validation, and both still return complete `structuredContent` and text results. +For the 1 MiB FastMCP SSE limit reported by Unsloth in +[#9093](https://github.com/morluto/rea/issues/9093), see the +[tested catalog configuration](client-configuration.md#fastmcp-and-unsloth-sse-catalog-limits). +Hot/cold selection alone does not bound later catalog refreshes. + `compact` keeps the complete validation structure when it fits the budget. Otherwise it advertises the root property shape, first-level reference-resolved types, first-level guidance, and the root's `required`/`additionalProperties` From afc9cfe9ff63c5ccef2d0a6f6c71d1d26281d431 Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:09:46 +0800 Subject: [PATCH 050/151] perf(tests): reuse bounded CLI workers in mobile archive checks (#9116) Co-authored-by: gensokyo-dev --- .../mobileDirectoryComponents.test.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/acceptance/applications/mobileDirectoryComponents.test.ts b/tests/acceptance/applications/mobileDirectoryComponents.test.ts index ed39edbfe1..eb68d8ebb3 100644 --- a/tests/acceptance/applications/mobileDirectoryComponents.test.ts +++ b/tests/acceptance/applications/mobileDirectoryComponents.test.ts @@ -19,7 +19,7 @@ import { parseEvidence } from "../../../src/domain/evidence.js"; import { harmonyApplicationProjectionResultSchema } from "../../../src/domain/harmony/harmonyApplication.js"; import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { cliTest } from "../../support/cli/cliFixture.js"; +import { cliTest, runFileCli } from "../../support/cli/cliFixture.js"; const harmony = { family: "HarmonyOS", @@ -146,6 +146,11 @@ cliTest.for(fixtures)( const environment = { HOME: root, USERPROFILE: root }; const { call } = await connectLocalToolsMcp(environment); for (const mode of ["directories", "files", "nested"] as const) { + // Keep one fresh dispatcher check per family; reuse later commands. + const run = + mode === "directories" + ? cli.run.bind(cli) + : runFileCli.bind(undefined, cli); const writer = new ZipWriter(new Uint8ArrayWriter()); for (const path of fixture.paths) { if (mode !== "files") @@ -168,7 +173,7 @@ cliTest.for(fixtures)( const path = join(root, `${mode}.${fixture.extension}`); await writeFile(path, bytes); - const inspected = await cli.run({ + const inspected = await run({ arguments: ["inspect-artifact", path, "--json"], environment, }); @@ -194,7 +199,7 @@ cliTest.for(fixtures)( const input = { inventory_evidence: [inventoryEvidence] }; const originalInput = JSON.stringify(input); - const projected = await cli.run({ + const projected = await run({ arguments: [fixture.command, originalInput, "--json"], environment, }); @@ -321,6 +326,9 @@ cliTest.for([ const environment = { HOME: root, USERPROFILE: root }; const { call } = await connectLocalToolsMcp(environment); for (const encrypted of [false, true]) { + const run = encrypted + ? runFileCli.bind(undefined, cli) + : cli.run.bind(cli); const writer = new ZipWriter(new Uint8ArrayWriter()); for (const member of fixture.paths) await writer.add( @@ -331,14 +339,14 @@ cliTest.for([ const bytes = await writer.close(); const path = join(root, `${encrypted}.${fixture.extension}`); await writeFile(path, bytes); - const inspected = await cli.run({ + const inspected = await run({ arguments: ["inspect-artifact", path, "--json"], environment, }); expect(inspected.exitCode, inspected.stderr).toBe(0); const inventory = parseEvidence(inspected.json); const input = { inventory_evidence: [inventory] }; - const projected = await cli.run({ + const projected = await run({ arguments: [fixture.command, JSON.stringify(input), "--json"], environment, }); From f1bfa1cba206e42d9381436fe7bc2e52241faa54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A4=80=ED=98=B8?= <101695482+chordpli@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:20:56 +0900 Subject: [PATCH 051/151] fix(cli): honor the selected format for unknown mcp subcommands (#7802) * fix(cli): honor the selected format for unknown mcp subcommands Incur 0.6.1 renders its unknown mcp subcommand rejection as TOON regardless of --format or --json. Report the same COMMAND_NOT_FOUND envelope in the explicitly selected format before dispatching to Incur, share the pinned mcp subcommand table with completions, and redact explicitly declared sensitive values from the diagnostic. Fixes #7691 * fix(cli): correct only the subcommand in the mcp suggestion The suggested command replaced every token equal to the unknown subcommand, including option values such as --command. Record each command token's argument position in the shared builtin split and replace only the subcommand position. * fix(cli): leave earlier Incur builtins and caller output flags intact Incur serves --llms, --llms-full and its update check before the mcp subcommand branch, so defer those invocations to Incur as well. Build the suggested command from the caller's arguments rather than the arguments adapted for Incur, so a delimited format and its columns remain in the suggestion. * fix(cli): ignore output values spelled like earlier Incur builtins The earlier-builtin check matched raw tokens, so an output flag value such as --columns --llms or --filter-output --llms deferred the rejection to Incur's TOON path. Check the arguments Incur receives and skip positions its builtin pass consumes as output values. * fix(cli): omit mcp suggestions that would not replay caller input Joining the corrected tokens with spaces re-tokenizes a value with whitespace or shell metacharacters when the suggestion is copied, and quoting differs between POSIX shells, cmd and PowerShell. Offer the corrected command only when every token is a plain shell word and no declared sensitive value was redacted; the message still names the suggested subcommand. * fix(cli): preserve safe MCP diagnostic output * fix(cli): omit mcp suggestions with words zsh expands zsh expands a word that starts with = to an executable path by default, so a copied suggestion such as --command =node would replace the caller's literal value. Treat =-leading words as non-replayable. * fix(cli): render mcp diagnostics through the document renderer Markdown output went through Incur's formatter, so caller text such as *oops* rendered as emphasis instead of literal text. Use REA's document renderer for every format, and redact declared sensitive literals from the fixed call-to-action strings as well as the message. * test(cli): keep the mcp correction on the subcommand after timing globals --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- src/cli.ts | 5 +- src/cli/completions.ts | 20 +-- src/cli/mcpBuiltinCommands.ts | 132 ++++++++++++++++++++ src/cliBuiltinArguments.ts | 20 ++- tests/boundary/cli/cliOutput.test.ts | 178 +++++++++++++++++++++++++++ 5 files changed, 332 insertions(+), 23 deletions(-) create mode 100644 src/cli/mcpBuiltinCommands.ts diff --git a/src/cli.ts b/src/cli.ts index 4217fed009..e9050e7682 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -87,6 +87,7 @@ import { presentCliArgumentErrors, } from "./cli/argumentErrors.js"; import { completionHelpNote, createCliCompletions } from "./cli/completions.js"; +import { reportUnknownMcpSubcommand } from "./cli/mcpBuiltinCommands.js"; import { closeCliGuidanceResources, registerGuidanceCommands, @@ -601,7 +602,9 @@ export const createCli = ( } const result = serveStrict(normalized, options ?? {}, (tokens, selected) => serveDeclared(tokens, selected, (declared, declaredOptions) => - serve(adaptCliOutputArgumentsForIncur(declared), declaredOptions), + reportUnknownMcpSubcommand(cli.name, declared, declaredOptions) + ? Promise.resolve() + : serve(adaptCliOutputArgumentsForIncur(declared), declaredOptions), ), ); const note = completionHelpNote(normalized); diff --git a/src/cli/completions.ts b/src/cli/completions.ts index 353ed37c6a..0660b52e73 100644 --- a/src/cli/completions.ts +++ b/src/cli/completions.ts @@ -11,6 +11,7 @@ import { } from "../cliBuiltinArguments.js"; import { CLI_OUTPUT_FORMATS, parseCliOutputArguments } from "../cliOutput.js"; import { bashCompletionScript } from "./bashCompletionRegistration.js"; +import { MCP_BUILTIN_SUBCOMMANDS } from "./mcpBuiltinCommands.js"; import { MCP_DOCTOR_ALIAS, MCP_DOCTOR_OPTIONS } from "../mcpDoctorOptions.js"; type Commands = Parameters[0]; @@ -85,24 +86,7 @@ const completionCommands = (cli: CompletionCli): Commands => { commands.set("mcp", { _group: true, description: "Register as MCP server", - commands: new Map([ - [ - "add", - { - description: "Register as MCP server", - alias: { command: "c" }, - options: z.object({ - agent: z.string().optional(), - command: z.string().optional(), - noGlobal: z.boolean().optional(), - }), - }, - ], - [ - "doctor", - { description: "Validate MCP server startup and tool listing" }, - ], - ]), + commands: MCP_BUILTIN_SUBCOMMANDS, }); commands.set("completions", { _group: true, diff --git a/src/cli/mcpBuiltinCommands.ts b/src/cli/mcpBuiltinCommands.ts new file mode 100644 index 0000000000..809ec0396a --- /dev/null +++ b/src/cli/mcpBuiltinCommands.ts @@ -0,0 +1,132 @@ +import { z } from "incur"; + +import { + adaptCliOutputArgumentsForIncur, + declaredSensitiveValues, + splitIncurBuiltinArguments, +} from "../cliBuiltinArguments.js"; +import { + parseCliOutputArguments, + renderCliDocument, + type CliOutputFormat, +} from "../cliOutput.js"; +import { redactExplicitText } from "../domain/explicitSensitiveValues.js"; +import type { CliInstance } from "./types.js"; + +type ServeOptions = NonNullable[1]>; + +/** Pinned Incur 0.6.1 `mcp` builtin subcommands; Incur does not export them. */ +export const MCP_BUILTIN_SUBCOMMANDS = new Map([ + [ + "add", + { + description: "Register as MCP server", + alias: { command: "c" }, + options: z.object({ + agent: z.string().optional(), + command: z.string().optional(), + noGlobal: z.boolean().optional(), + }), + }, + ], + ["doctor", { description: "Validate MCP server startup and tool listing" }], +]); + +/** Incur 0.6.1 builtins that it serves before its `mcp` subcommand branch. */ +const PRECEDING_BUILTIN_FLAGS: ReadonlySet = new Set([ + "--incur-update-check", + "--update", + "--llms", + "--llms-full", + "--mcp", +]); + +// zsh expands a word that starts with `=` to an executable path. +const PLAIN_SHELL_WORD = /^(?!=)[\w./+=:,-]+$/u; + +/** + * Incur 0.6.1 rejects an unknown `mcp` subcommand in TOON regardless of the + * selected format. Report the same envelope in the explicitly selected format; + * without one, Incur's TOON or human rendering is already the selected output. + * The arguments are the caller's normalized ones, before REA adapts output + * flags for Incur, so the suggested command keeps the caller's selection. + */ +export const reportUnknownMcpSubcommand = ( + name: string, + arguments_: readonly string[], + options: ServeOptions, +): boolean => { + const { command, commandIndices } = splitIncurBuiltinArguments(arguments_); + const [entry, subcommand] = command; + // Incur receives JSON for REA's delimited formats and no `--columns`. + const incurArguments = adaptCliOutputArgumentsForIncur(arguments_); + const incurOutputValues = new Set( + splitIncurBuiltinArguments(incurArguments).outputValues.map( + ({ index }) => index, + ), + ); + if ( + entry !== "mcp" || + // Incur shows group help for an empty token and owns flag-shaped globals. + !subcommand || + subcommand.startsWith("-") || + MCP_BUILTIN_SUBCOMMANDS.has(subcommand) || + // Incur serves these builtins before its `mcp` subcommand branch. An + // output flag's value is data, even when it is spelled like a builtin. + incurArguments.some( + (token, index) => + !incurOutputValues.has(index) && PRECEDING_BUILTIN_FLAGS.has(token), + ) + ) + return false; + const output = parseCliOutputArguments(incurArguments); + // Invalid output controls keep Incur's own parse diagnostic. + if (!output.formatExplicit || !output.validation.ok) return false; + const sensitive = declaredSensitiveValues(command); + const text = (value: string): string => redactExplicitText(value, sensitive); + const suggestion = [...MCP_BUILTIN_SUBCOMMANDS.keys()].find((candidate) => + candidate.startsWith(subcommand.toLowerCase()), + ); + const corrected = arguments_.map((token, index) => + index === commandIndices[1] ? suggestion : token, + ); + // Joined tokens replay only when each one is a plain word in every shell; + // quoting is shell-specific, and a redacted value cannot be replayed. + const replayable = + suggestion !== undefined && + corrected.every( + (token) => + token !== undefined && + PLAIN_SHELL_WORD.test(token) && + text(token) === token, + ); + const commands = [ + ...(replayable ? [{ command: `${name} ${corrected.join(" ")}` }] : []), + { + command: text(`${name} mcp --help`), + description: text("see all available commands"), + }, + ]; + const document = { + code: "COMMAND_NOT_FOUND", + message: text( + `'${subcommand}' is not a command for '${name} mcp'.${suggestion === undefined ? "" : ` Did you mean '${suggestion}'?`}`, + ), + cta: { + description: text( + commands.length === 1 ? "Suggested command:" : "Suggested commands:", + ), + commands, + }, + }; + (options.stdout ?? ((value: string) => process.stdout.write(value)))( + // REA's document renderer owns JSON layout and Markdown escaping. + renderCliDocument( + document, + output.format as CliOutputFormat, + process.stdout.isTTY === true, + ), + ); + (options.exit ?? ((code: number) => process.exit(code)))(1); + return true; +}; diff --git a/src/cliBuiltinArguments.ts b/src/cliBuiltinArguments.ts index e441d3b48e..54bcd0d37c 100644 --- a/src/cliBuiltinArguments.ts +++ b/src/cliBuiltinArguments.ts @@ -67,6 +67,8 @@ export const splitCliTimingArguments = ( export interface IncurBuiltinArgumentSplit { /** Tokens left for command resolution and command option parsing. */ readonly command: readonly string[]; + /** Argument position of each `command` token. */ + readonly commandIndices: readonly number[]; /** Valueless global flags extracted by Incur before command dispatch. */ readonly flags: ReadonlySet; /** Positions of nonempty values consumed by valued output flags. */ @@ -85,6 +87,7 @@ export const splitIncurBuiltinArguments = ( arguments_: readonly string[], ): IncurBuiltinArgumentSplit => { const command: string[] = []; + const commandIndices: number[] = []; const flags = new Set(); const outputValues: { readonly flag: string; readonly index: number }[] = []; for (let index = 0; index < arguments_.length; index += 1) { @@ -104,20 +107,29 @@ export const splitIncurBuiltinArguments = ( continue; } command.push(argument); + commandIndices.push(index); } // 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) => { + const remaining: string[] = []; + const remainingIndices: number[] = []; + command.forEach((token, position) => { if (token === "--") globalsEnded = true; if (!globalsEnded && cliTimingFlagValue(token) !== undefined) { flags.add(token); - return false; + return; } - return true; + remaining.push(token); + remainingIndices.push(commandIndices[position]!); }); - return { command: remaining, flags, outputValues }; + return { + command: remaining, + commandIndices: remainingIndices, + flags, + outputValues, + }; }; /** diff --git a/tests/boundary/cli/cliOutput.test.ts b/tests/boundary/cli/cliOutput.test.ts index 79d6279bf8..4b8356f65d 100644 --- a/tests/boundary/cli/cliOutput.test.ts +++ b/tests/boundary/cli/cliOutput.test.ts @@ -883,3 +883,181 @@ describe("version report rendering", () => { ).toBeUndefined(); }); }); + +describe("unknown MCP subcommand correction", () => { + workspaceCliTest( + "keeps the correction on the subcommand after timing globals", + async ({ cli }) => { + for (const timing of ["--timings", "--no-timings", "--timings=false"]) { + const timed = await cli.run({ + arguments: [timing, "mcp", timing, "ad", "--format", "json"], + }); + expect(timed.json).toMatchObject({ + cta: { + commands: [ + { command: `rea ${timing} mcp ${timing} add --format json` }, + {}, + ], + }, + }); + } + }, + ); +}); + +describe("unknown MCP subcommand output", () => { + workspaceCliTest( + "reports the rejection in the explicitly selected format", + async ({ cli }) => { + const expected = { + code: "COMMAND_NOT_FOUND", + message: "'bogus' is not a command for 'rea mcp'.", + cta: { + commands: [ + { + command: "rea mcp --help", + description: "see all available commands", + }, + ], + }, + }; + for (const selection of [["--format", "json"], ["--json"]]) { + const result = await cli.run({ + arguments: ["mcp", "bogus", ...selection], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject(expected); + expect(result.stdout).toBe(`${JSON.stringify(result.json)}\n`); + } + const yaml = await cli.run({ + arguments: ["mcp", "bogus", "--format", "yaml"], + }); + expect(yaml.exitCode).toBe(1); + expect(yaml.stdout).toContain(" - command: rea mcp --help\n"); + const markdown = await cli.run({ + arguments: ["mcp", "bogus", "--format", "md"], + }); + expect(markdown.exitCode).toBe(1); + expect(markdown.stdout).toMatch(/^## code\n\nCOMMAND_NOT_FOUND\n/u); + const jsonLines = await cli.run({ + arguments: ["mcp", "bogus", "--format", "jsonl"], + }); + expect(jsonLines.exitCode).toBe(1); + expect(JSON.parse(jsonLines.stdout)).toMatchObject({ + type: "error", + ok: false, + result: expected, + }); + const suggested = await cli.run({ + arguments: ["mcp", "ad", "--format", "json"], + }); + expect(suggested.json).toMatchObject({ + message: "'ad' is not a command for 'rea mcp'. Did you mean 'add'?", + cta: { commands: [{ command: "rea mcp add --format json" }, {}] }, + }); + // Only the subcommand is corrected; an equal option value is caller input. + const optionValue = await cli.run({ + arguments: ["mcp", "ad", "--command", "ad", "--json"], + }); + expect(optionValue.json).toMatchObject({ + cta: { commands: [{ command: "rea mcp add --command ad --json" }, {}] }, + }); + // Builtins Incur serves before its mcp branch keep their own output. + const manifest = await cli.run({ + arguments: ["mcp", "bogus", "--llms", "--json"], + }); + expect(manifest.exitCode).toBe(0); + expect(manifest.stdout).not.toContain("COMMAND_NOT_FOUND"); + // An output value spelled like a builtin is data, not that builtin. + for (const arguments_ of [ + [ + "mcp", + "ad", + "--format", + "csv", + "--filter-output", + "cta.commands", + "--columns", + "--llms", + ], + ["mcp", "ad", "--json", "--filter-output", "--llms"], + ]) { + const builtinValue = await cli.run({ arguments: arguments_ }); + expect(builtinValue.exitCode).toBe(1); + expect(JSON.parse(builtinValue.stdout)).toMatchObject({ + code: "COMMAND_NOT_FOUND", + }); + } + // The suggestion keeps the caller's delimited output selection. + const delimited = await cli.run({ + arguments: [ + "mcp", + "ad", + "--format", + "csv", + "--filter-output", + "cta.commands", + "--columns", + "command", + ], + }); + expect(delimited.exitCode).toBe(1); + expect(delimited.stdout).toContain( + "rea mcp add --format csv --filter-output cta.commands --columns command", + ); + // A suggestion that would not replay the caller's tokens is omitted. + const spaced = await cli.run({ + arguments: ["mcp", "ad", "--command", "/path with spaces", "--json"], + }); + expect(spaced.json).toMatchObject({ + message: "'ad' is not a command for 'rea mcp'. Did you mean 'add'?", + cta: { + commands: [ + { + command: "rea mcp --help", + description: "see all available commands", + }, + ], + }, + }); + expect(spaced.stdout).not.toContain("rea mcp add"); + for (const shellActive of ["@args", "%PATH%", "=node"]) { + const result = await cli.run({ + arguments: ["mcp", "ad", "--command", shellActive, "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.stdout).not.toContain("rea mcp add"); + expect(result.json).toMatchObject({ + cta: { commands: [{ command: "rea mcp --help" }] }, + }); + } + // Markdown output escapes caller text through REA's document renderer. + const emphasis = await cli.run({ + arguments: ["mcp", "*oops*", "--format", "md"], + }); + expect(emphasis.exitCode).toBe(1); + expect(emphasis.stdout).toContain("'\\*oops\\*' is not a command"); + // A declared literal is redacted from fixed call-to-action text too. + const fixedText = await cli.run({ + arguments: ["mcp", "bogus", "--sensitive-value", "help", "--json"], + }); + expect(fixedText.exitCode).toBe(1); + expect(fixedText.stdout).not.toContain("help"); + const redacted = await cli.run({ + arguments: ["mcp", "s3cret", "--sensitive-value", "s3cret", "--json"], + }); + expect(redacted.exitCode).toBe(1); + expect(redacted.stdout).not.toContain("s3cret"); + expect(redacted.json).toMatchObject({ code: "COMMAND_NOT_FOUND" }); + // An empty token keeps Incur's group help. + const help = await cli.run({ arguments: ["mcp", "", "--json"] }); + expect(help.exitCode).toBe(0); + expect(help.stdout).toContain("rea mcp"); + // Without a selected format, Incur's default TOON remains the output. + const toon = await cli.run({ arguments: ["mcp", "bogus"] }); + expect(toon.exitCode).toBe(1); + expect(toon.stdout).toMatch(/^code: COMMAND_NOT_FOUND\n/u); + }, + CLI_INTEGRATION_TIMEOUT_MS, + ); +}); From 996977edf2bf2055b49c6cb047239ce189929e49 Mon Sep 17 00:00:00 2001 From: Yunzhou Li <154221037+YunzhouLi-hub@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:21:28 +0800 Subject: [PATCH 052/151] feat(cli): expose caller-selected reference-source path exclusions (#2630) * feat(cli): expose caller-selected reference-source path exclusions Let import-reference-source accept a JSON array of Git-ignore rules and pass them to the existing importer before the source tree is opened. Fixes #2443 * docs(cli): use an effective reference exclusion negation * test(cli): expect entry_kind on reference-source exclusions Main records entry_kind on every historical source exclusion. The caller exclusion assertions still compared objects without that field, so the pruning test failed after merging main. Co-authored-by: Yunzhou Li * test(cli): record caller *.tmp over project ignore Caller exclusions are mandatory and override project rules, so project.tmp matched by *.tmp is caller-excluded. Align the pruning assertion with that result from the merged importer. --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: REA Maintenance Co-authored-by: Cursor Agent Co-authored-by: Yunzhou Li --- docs/cli.md | 1 + src/cli/utilityCommands.ts | 78 ++++- .../cli/referenceSourceExclusions.test.ts | 286 ++++++++++++++++++ 3 files changed, 364 insertions(+), 1 deletion(-) diff --git a/docs/cli.md b/docs/cli.md index d7414f514e..efc0db8e68 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -834,6 +834,7 @@ Import an older source tree to compare with the current artifact: ```bash rea import-reference-source /absolute/path/to/source rea import-reference-source /absolute/path/to/source --detail summary --outline-depth 2 --json +rea import-reference-source /absolute/path/to/source --exclude-paths '["vendor/","*.generated","!keep.generated"]' ``` The default (`--detail full`) returns the complete `HistoricalSourceGraph`. diff --git a/src/cli/utilityCommands.ts b/src/cli/utilityCommands.ts index 5dd39f7079..b94da62f08 100644 --- a/src/cli/utilityCommands.ts +++ b/src/cli/utilityCommands.ts @@ -12,7 +12,10 @@ import { projectReferenceSourceImportError } from "../application/ReferenceSourc import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js"; import { parseConfig } from "../config/parseConfig.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; -import { AnalysisInputError } from "../domain/analysisErrorCore.js"; +import { + AnalysisInputError, + type AnalysisInputIssue, +} from "../domain/analysisErrorCore.js"; import { projectInputIssues } from "../domain/inputIssueProjection.js"; import { safeParseJson } from "../domain/safeJson.js"; import { nativeUiScenarioInputSchema } from "../domain/native/nativeUiObservation.js"; @@ -282,6 +285,66 @@ const registerNativeCommands = ( }); }; +const referenceSourceExcludePathsSchema = z.object({ + excludePaths: z.array(z.string().min(1)), +}); + +const EXCLUDE_PATHS_INPUT_MESSAGE = + "--exclude-paths must be a JSON array of nonempty Git-ignore rules. Each rule uses Git-ignore syntax, including wildcards, directory suffixes, and negations, rather than a literal path match."; + +const invalidReferenceSourceExcludePaths = ( + issues: readonly AnalysisInputIssue[], +) => ({ + error: "Import failed" as const, + ...projectAnalysisError( + new AnalysisInputError( + CLI_COMMANDS.importReferenceSource, + { userMessage: EXCLUDE_PATHS_INPUT_MESSAGE }, + issues, + ), + ), +}); + +/** Parse caller exclusions before the import opens the selected tree. */ +const parseReferenceSourceExcludePaths = ( + encoded: string | undefined, +): + | { + readonly ok: true; + readonly rules: readonly string[] | undefined; + } + | { + readonly ok: false; + readonly failure: ReturnType; + } => { + if (encoded === undefined) return { ok: true, rules: undefined }; + const decoded = safeParseJson(encoded); + if (!decoded.ok) + return { + ok: false, + failure: invalidReferenceSourceExcludePaths([ + { + path: ["excludePaths"], + reason: "invalid_format", + expected: "JSON", + }, + ]), + }; + const parsed = referenceSourceExcludePathsSchema.safeParse({ + excludePaths: decoded.value, + }); + if (!parsed.success) + return { + ok: false, + failure: invalidReferenceSourceExcludePaths( + projectInputIssues(parsed.error.issues, { + excludePaths: decoded.value, + }), + ), + }; + return { ok: true, rules: parsed.data.excludePaths }; +}; + const registerReferenceSourceCommand = ( cli: CliInstance, logger: Logger, @@ -295,6 +358,12 @@ const registerReferenceSourceCommand = ( .describe("Local source directory to import as historical reference"), }), options: z.object({ + excludePaths: z + .string() + .optional() + .describe( + "JSON array of Git-ignore rules to exclude. Rules use Git-ignore syntax, including wildcards, directory suffixes, and negations; they are not literal path matches. An empty array adds no caller exclusions.", + ), detail: z .enum(["full", "summary"]) .default("full") @@ -312,6 +381,10 @@ const registerReferenceSourceCommand = ( logger, "import-reference-source", async () => { + const excludePaths = parseReferenceSourceExcludePaths( + options.excludePaths, + ); + if (!excludePaths.ok) return excludePaths.failure; if ( options.outlineDepth !== undefined && options.detail !== "summary" @@ -341,6 +414,7 @@ const registerReferenceSourceCommand = ( return runReferenceSourceImport( args.root, config.value.referenceSourcePolicy, + excludePaths.rules, options.detail === "summary" ? (options.outlineDepth ?? 2) : undefined, @@ -354,6 +428,7 @@ const registerReferenceSourceCommand = ( const runReferenceSourceImport = async ( root: string, policy: ReferenceSourcePolicy, + excludePaths: readonly string[] | undefined, outlineDepth: number | undefined, ): Promise => { const resources = new ArtifactResourceScope(); @@ -373,6 +448,7 @@ const runReferenceSourceImport = async ( policy, importer: PRODUCT_IDENTITY.packageName, importerVersion: PRODUCT_IDENTITY.packageVersion, + ...(excludePaths === undefined ? {} : { excludePaths }), }, resources, ), diff --git a/tests/boundary/cli/referenceSourceExclusions.test.ts b/tests/boundary/cli/referenceSourceExclusions.test.ts index 87b8541166..77495d25d4 100644 --- a/tests/boundary/cli/referenceSourceExclusions.test.ts +++ b/tests/boundary/cli/referenceSourceExclusions.test.ts @@ -12,6 +12,31 @@ import { describe, expect } from "vitest"; import { historicalSourceGraphSchema } from "../../../src/domain/referenceSourceGraph.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +import type { TestCli } from "../../support/cli/cliFixture.js"; + +const isolatedEnvironment = (root: string) => ({ + HOME: root, + USERPROFILE: root, + XDG_CONFIG_HOME: root, + XDG_CACHE_HOME: root, + REA_REFERENCE_SECRET_PATTERNS_JSON: '["private.txt"]', +}); + +const importSource = ( + cli: TestCli, + root: string, + excludePaths: string | undefined, +) => + cli.run({ + arguments: [ + "import-reference-source", + root, + "--json", + ...(excludePaths === undefined ? [] : ["--exclude-paths", excludePaths]), + ], + cwd: root, + environment: isolatedEnvironment(root), + }); describe.skipIf(process.platform === "win32")( "compiled reference-source exclusion provenance", @@ -123,3 +148,264 @@ describe.skipIf(process.platform === "win32")( ); }, ); + +describe.skipIf(process.platform === "win32")( + "compiled reference-source caller exclusions", + () => { + cliTest( + "prunes caller-excluded directories and applies Git-ignore rules", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-reference-caller-exclusions-", + ); + const logs = join(root, "logs"); + const skipped = join(root, "skip-me", "nested"); + await mkdir(logs); + await mkdir(skipped, { recursive: true }); + await Promise.all([ + writeFile(join(root, ".gitignore"), "project.tmp\n"), + writeFile(join(root, "project.tmp"), "project policy\n"), + writeFile(join(logs, "application.log"), "default policy\n"), + writeFile(join(root, "keep.log"), "negated default rule\n"), + writeFile(join(root, "private.txt"), "sensitive local file\n"), + writeFile(join(root, "notes.tmp"), "caller wildcard\n"), + writeFile(join(skipped, "hidden.ts"), "must not be traversed\n"), + writeFile(join(root, "source.ts"), "export const value = 1;\n"), + ]); + const callerRules = '["skip-me/","*.tmp","!keep.log","private.txt"]'; + + const omitted = await importSource(cli, root, undefined); + const empty = await importSource(cli, root, "[]"); + const selected = await importSource(cli, root, callerRules); + + expect(omitted.exitCode).toBe(0); + expect(empty.exitCode).toBe(0); + const omittedGraph = historicalSourceGraphSchema.parse(omitted.json); + const emptyGraph = historicalSourceGraphSchema.parse(empty.json); + expect(emptyGraph.exclusions).toEqual(omittedGraph.exclusions); + expect(emptyGraph.entries.map((entry) => entry.path)).toEqual( + omittedGraph.entries.map((entry) => entry.path), + ); + expect(omittedGraph.exclusions).not.toContainEqual( + expect.objectContaining({ reason: "caller-excluded" }), + ); + expect(omittedGraph.entries).toContainEqual( + expect.objectContaining({ path: "notes.tmp", kind: "file" }), + ); + expect(omittedGraph.entries).toContainEqual( + expect.objectContaining({ + path: "skip-me/nested/hidden.ts", + kind: "file", + }), + ); + expect(omittedGraph.exclusions).toContainEqual({ + path: "keep.log", + entry_kind: "file", + reason: "default-ignored", + pattern: "*.log", + }); + + expect(selected.exitCode).toBe(0); + const selectedGraph = historicalSourceGraphSchema.parse(selected.json); + expect(selectedGraph.exclusions).toEqual([ + { + path: "logs/application.log", + entry_kind: "file", + reason: "default-ignored", + pattern: "*.log", + }, + { + path: "notes.tmp", + entry_kind: "file", + reason: "caller-excluded", + pattern: "*.tmp", + }, + { + path: "private.txt", + entry_kind: "file", + reason: "configured-secret", + pattern: "private.txt", + }, + { + path: "project.tmp", + entry_kind: "file", + reason: "caller-excluded", + pattern: "*.tmp", + }, + { + path: "skip-me", + entry_kind: "directory", + reason: "caller-excluded", + pattern: "skip-me/", + }, + ]); + expect(selectedGraph.entries).toContainEqual( + expect.objectContaining({ path: "source.ts", kind: "file" }), + ); + expect(selectedGraph.entries).toContainEqual( + expect.objectContaining({ path: "keep.log", kind: "file" }), + ); + for (const path of [ + "skip-me", + "skip-me/nested", + "skip-me/nested/hidden.ts", + "notes.tmp", + ]) + expect( + selectedGraph.entries.some((entry) => entry.path === path), + path, + ).toBe(false); + expect( + selectedGraph.exclusions.some((exclusion) => + exclusion.path.startsWith("skip-me/"), + ), + ).toBe(false); + }, + ); + }, +); + +describe("compiled reference-source caller exclusion input", () => { + for (const scenario of [ + { + label: "malformed JSON", + value: "[", + issue: { + path: ["excludePaths"], + reason: "invalid_format", + expected: "JSON", + }, + }, + { + label: "a non-array JSON value", + value: "{}", + issue: { + path: ["excludePaths"], + reason: "invalid_type", + expected: "array", + }, + }, + { + label: "a numeric rule", + value: "[1]", + issue: { + path: ["excludePaths", 0], + reason: "invalid_type", + expected: "string", + }, + }, + { + label: "a null rule", + value: "[null]", + issue: { + path: ["excludePaths", 0], + reason: "invalid_type", + expected: "string", + }, + }, + { + label: "an object rule", + value: "[{}]", + issue: { + path: ["excludePaths", 0], + reason: "invalid_type", + expected: "string", + }, + }, + { + label: "a boolean rule", + value: "[true]", + issue: { + path: ["excludePaths", 0], + reason: "invalid_type", + expected: "string", + }, + }, + { + label: "an empty-string rule", + value: '[""]', + issue: { + path: ["excludePaths", 0], + reason: "out_of_range", + minimum: 1, + }, + }, + ]) { + cliTest( + `rejects ${scenario.label} before an import starts`, + async ({ cli }) => { + const directory = await createTestTempDirectory( + "rea-reference-exclude-input-", + ); + const root = join(directory, "missing-source"); + const result = await cli.run({ + arguments: [ + "import-reference-source", + root, + "--exclude-paths", + scenario.value, + "--json", + ], + cwd: directory, + environment: { + HOME: directory, + USERPROFILE: directory, + XDG_CONFIG_HOME: directory, + XDG_CACHE_HOME: directory, + }, + }); + + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + error: "Import failed", + code: "invalid_request", + category: "invalid_input", + message: expect.stringContaining("Git-ignore"), + details: { + operation: "import-reference-source", + issues: [scenario.issue], + }, + }); + expect(JSON.stringify(result.json)).not.toContain( + "could not be resolved", + ); + expect(JSON.stringify(result.json)).not.toContain(root); + }, + ); + } + + cliTest( + "starts an import after an empty exclusion array", + async ({ cli }) => { + const directory = await createTestTempDirectory( + "rea-reference-exclude-empty-", + ); + const root = join(directory, "missing-source"); + const result = await cli.run({ + arguments: [ + "import-reference-source", + root, + "--exclude-paths", + "[]", + "--json", + ], + cwd: directory, + environment: { + HOME: directory, + USERPROFILE: directory, + XDG_CONFIG_HOME: directory, + XDG_CACHE_HOME: directory, + }, + }); + + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + error: "Import failed", + category: "invalid_input", + message: expect.stringContaining( + "Reference source root could not be resolved", + ), + }); + }, + ); +}); From 9044b6fb36b8f8c839d95e191ece5bf28ac84c08 Mon Sep 17 00:00:00 2001 From: Rishikesh Gowda K K Date: Fri, 6 Nov 2026 14:52:02 +0530 Subject: [PATCH 053/151] fix(kuna): mark project-dependent operations unavailable on export failure (#8748) * fix(kuna): mark project-dependent operations unavailable on export failure * fix(providers): preserve sibling operations in capability error copies * fix(kuna): propagate sibling unavailability on export fallback * fix(binary): preserve observed sibling recovery instructions across fallback observations * fix(binary): build sibling unavailability from structured reasons without rewriting display text * test(kuna): synchronize fallback test on settled export workspace retirement * test(kuna): release and join export synchronization barrier across exit paths * test(kuna): synchronize fallback test on observed export process exit * test(kuna): gate fallback completion on settled export cache build * test(kuna): preserve build promise chain and restore spy after teardown --- src/application/AnalysisProvider.ts | 10 + .../binary/SessionRuntimeAvailability.ts | 58 ++++- src/domain/analysisErrorCore.ts | 6 + src/domain/explicitSensitiveFailure.ts | 13 + .../javascript/javascriptSemanticAnalysis.ts | 12 +- src/kuna/KunaSessionClient.ts | 59 ++++- src/providers/AnalysisClientFailure.test.ts | 38 +++ src/providers/AnalysisClientFailure.ts | 9 + .../mcp/sessionAvailabilityRecovery.test.ts | 240 ++++++++++++++++++ .../kuna/kunaSingleFunctionRuns.test.ts | 71 +++++- 10 files changed, 498 insertions(+), 18 deletions(-) diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index 4277072e16..b43002b7a1 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -9,6 +9,7 @@ import type { NativeToolName } from "../contracts/native/nativeToolContracts.js" import type { ArtifactAnalysisOperation } from "../contracts/artifactToolContracts.js"; import type { ManagedToolName } from "../contracts/managed/managedToolContracts.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import type { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { Result } from "../domain/result.js"; @@ -81,6 +82,11 @@ export interface AnalysisExecution extends AnalysisObservation { * output. */ readonly referencesTruncated?: boolean; + /** + * Sibling operations known to be unavailable at the provider level when this + * operation answered from a standalone or fallback path. + */ + readonly siblingUnavailability?: AnalysisCapabilityUnavailableError; } interface CreateAnalysisExecutionOptions { @@ -91,6 +97,7 @@ interface CreateAnalysisExecutionOptions { readonly limitations?: readonly string[]; readonly relationshipCoverage?: RelationshipCoverage; readonly referencesTruncated?: boolean; + readonly siblingUnavailability?: AnalysisCapabilityUnavailableError; readonly locations?: readonly EvidenceLocation[]; readonly sourceEvidence?: readonly Evidence[]; readonly subject?: EvidenceSubjectTarget | null; @@ -149,6 +156,9 @@ export function createAnalysisExecution( ...(options.referencesTruncated === undefined ? {} : { referencesTruncated: options.referencesTruncated }), + ...(options.siblingUnavailability === undefined + ? {} + : { siblingUnavailability: options.siblingUnavailability }), locations: [...(options.locations ?? [])], ...(options.sourceEvidence === undefined ? {} diff --git a/src/application/binary/SessionRuntimeAvailability.ts b/src/application/binary/SessionRuntimeAvailability.ts index 748c65a5e9..4b6089a454 100644 --- a/src/application/binary/SessionRuntimeAvailability.ts +++ b/src/application/binary/SessionRuntimeAvailability.ts @@ -1,4 +1,5 @@ import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js"; import type { Result } from "../../domain/result.js"; import type { CapabilityDescriptor } from "../AnalysisProvider.js"; @@ -96,12 +97,37 @@ export class SessionRuntimeAvailability { if (result.ok) { operation.succeeded = Math.max(operation.succeeded, dispatch); provider.succeeded = Math.max(provider.succeeded, dispatch); - // A success can only remove unavailability. - if (this.#projected.size === 0) return false; + const siblingUnavailability = + typeof result.value === "object" && + result.value !== null && + "siblingUnavailability" in result.value && + result.value.siblingUnavailability instanceof + AnalysisCapabilityUnavailableError + ? result.value.siblingUnavailability + : undefined; + if (siblingUnavailability !== undefined) { + this.#observeSiblingUnavailability( + dispatch, + capability.operation, + siblingUnavailability, + ); + } else if (this.#projected.size === 0) { + return false; + } } else { const reason = runtimeFailureReason(result.error); if (reason === undefined) return false; operation.failure = newer(operation.failure, { dispatch, reason }); + if ( + result.error instanceof AnalysisCapabilityUnavailableError && + result.error.siblingOperations !== undefined + ) { + this.#observeSiblingUnavailability( + dispatch, + capability.operation, + result.error, + ); + } if (PROVIDER_WIDE_FAILURES.has(result.error._tag)) provider.failure = newer(provider.failure, { dispatch, @@ -112,6 +138,34 @@ export class SessionRuntimeAvailability { return this.#project(route); } + #observeSiblingUnavailability( + dispatch: number, + currentOperation: string, + error: AnalysisCapabilityUnavailableError, + ): void { + if (error.siblingOperations === undefined) return; + for (const sibling of error.siblingOperations) { + if (sibling !== currentOperation) { + const siblingOutcomes = outcomes(this.#operations, sibling); + const activeFailure = + siblingOutcomes.failure !== undefined && + siblingOutcomes.failure.dispatch > siblingOutcomes.succeeded + ? siblingOutcomes.failure + : undefined; + const reason = + activeFailure?.reason ?? + error.siblingReasons?.[sibling] ?? + (error.remediationAction !== undefined + ? `Provider ${error.providerId} cannot execute ${sibling}: ${error.reason}. ${error.remediationAction}` + : `Provider ${error.providerId} cannot execute ${sibling}: ${error.reason}`); + siblingOutcomes.failure = newer(siblingOutcomes.failure, { + dispatch, + reason, + }); + } + } + } + /** Forget runtime outcomes when the active provider client is replaced. */ clear(): boolean { this.#operations.clear(); diff --git a/src/domain/analysisErrorCore.ts b/src/domain/analysisErrorCore.ts index cb689c448e..4246904ee8 100644 --- a/src/domain/analysisErrorCore.ts +++ b/src/domain/analysisErrorCore.ts @@ -220,6 +220,8 @@ export class AnalysisCapabilityUnavailableError extends AnalysisError { readonly _tag = "AnalysisCapabilityUnavailableError"; override readonly userMessage: string | undefined; readonly remediationAction: string | undefined; + readonly siblingOperations: readonly string[] | undefined; + readonly siblingReasons: Readonly> | undefined; /** Incomplete cleanup must settle before the operation can be retried. */ readonly retryable: boolean; @@ -230,6 +232,8 @@ export class AnalysisCapabilityUnavailableError extends AnalysisError { options?: AnalysisErrorOptions & { readonly userMessage?: string; readonly remediationAction?: string; + readonly siblingOperations?: readonly string[]; + readonly siblingReasons?: Readonly>; readonly retryable?: boolean; }, ) { @@ -239,6 +243,8 @@ export class AnalysisCapabilityUnavailableError extends AnalysisError { ); this.userMessage = options?.userMessage; this.remediationAction = options?.remediationAction; + this.siblingOperations = options?.siblingOperations; + this.siblingReasons = options?.siblingReasons; this.retryable = options?.retryable === true && !this.cleanupIncomplete; } } diff --git a/src/domain/explicitSensitiveFailure.ts b/src/domain/explicitSensitiveFailure.ts index a7e4e5f226..bbd0e43140 100644 --- a/src/domain/explicitSensitiveFailure.ts +++ b/src/domain/explicitSensitiveFailure.ts @@ -286,6 +286,19 @@ const redactFailure = ( ...(error.userMessage === undefined ? {} : { userMessage: text(error.userMessage) }), + ...(error.siblingOperations === undefined + ? {} + : { siblingOperations: error.siblingOperations }), + ...(error.siblingReasons === undefined + ? {} + : { + siblingReasons: Object.fromEntries( + Object.entries(error.siblingReasons).map(([op, msg]) => [ + op, + text(msg), + ]), + ), + }), ...(error.retryable ? { retryable: true } : {}), }, ); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 1e98d51d1d..fd5e60af1f 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -40,14 +40,12 @@ import { semanticWriteSiteTarget, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; -import type { - JavaScriptSemanticAnalysisState, - JavaScriptSemanticBindingState, - JavaScriptSemanticScopeState, - JavaScriptBindingIndex, - JavaScriptSemanticReferenceEffectsAtSite, -} from "./javascriptSemanticState.js"; import { + type JavaScriptSemanticAnalysisState, + type JavaScriptSemanticBindingState, + type JavaScriptSemanticScopeState, + type JavaScriptBindingIndex, + type JavaScriptSemanticReferenceEffectsAtSite, currentSemanticScope, semanticPrototypeOwners, type SemanticPrototypeOwner, diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index df5b0fe4d4..cf50340eb8 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -180,6 +180,7 @@ interface Answer { readonly referenceSource?: JsonValue; readonly stringSource?: JsonValue; readonly relationshipCoverage?: RelationshipCoverage; + readonly siblingUnavailability?: AnalysisCapabilityUnavailableError; } /** @@ -242,6 +243,23 @@ const descriptorCleanupError = ( const FAILED_PROJECT_RECORDS_GUIDANCE = "REA does not expose per-function records from a failed project export."; +/** + * Operations that unconditionally require Kuna's shared project export and + * cannot answer from the quick function inventory, reference walk, or a + * standalone single-function decompile run. + */ +const KUNA_PROJECT_DEPENDENT_OPERATIONS = Object.freeze([ + "procedure_assembly", + "analyze_function", +] as const satisfies readonly string[]); + +const exportUnavailableMessage = ( + operation: string, + reason: string, + next: string, +): string => + `Kuna's project export failed, so ${operation} is unavailable in this session: ${reason.replace(/\.?$/u, ".")} ${FAILED_PROJECT_RECORDS_GUIDANCE} Inspect any captured diagnostics. ${next}`; + /** * How a request that needs the export reports the export's failure. A session * never rebuilds a failed export, so repeating the request cannot succeed; the @@ -267,12 +285,24 @@ const exportUnavailable = ( ? // A fixed buffer (`memory`) is no setting, so a restart cannot help. `${failure.remediationAction.replace(/\.?$/u, ".")}${failure.resource === "memory" ? "" : ` ${restart}`}` : "To retry, resolve the reported failure, then reopen the target."; + const userMessage = exportUnavailableMessage(operation, reason, next); + const siblingReasons = Object.freeze( + Object.fromEntries( + KUNA_PROJECT_DEPENDENT_OPERATIONS.map((sibling) => [ + sibling, + exportUnavailableMessage(sibling, reason, next), + ]), + ), + ); return new AnalysisCapabilityUnavailableError( KUNA_PROVIDER_ID, operation, reason, { cause: failure, + siblingOperations: KUNA_PROJECT_DEPENDENT_OPERATIONS, + siblingReasons, + remediationAction: next, // Callers see only the top-level projection, so the export failure's // own projected code and details (its limits, target path, captured // output and diagnostics) travel whole rather than field by field. @@ -284,7 +314,7 @@ const exportUnavailable = ( ? {} : { export_details: exported.details }), }, - userMessage: `Kuna's project export failed, so ${operation} is unavailable in this session: ${reason.replace(/\.?$/u, ".")} ${FAILED_PROJECT_RECORDS_GUIDANCE} Inspect any captured diagnostics. ${next}`, + userMessage, }, ); }; @@ -695,6 +725,9 @@ export class KunaSessionClient implements AnalysisClient { ...(answer.relationshipCoverage === undefined ? {} : { relationshipCoverage: answer.relationshipCoverage }), + ...(answer.siblingUnavailability === undefined + ? {} + : { siblingUnavailability: answer.siblingUnavailability }), locations: [ { kind: "artifact-path", path: this.#target.path }, ...answer.locations, @@ -1270,19 +1303,22 @@ export class KunaSessionClient implements AnalysisClient { ); return outcome; } - const exportFailure = await this.#settledExportFailure(); + const exportUnavailable = await this.#settledExportUnavailable(operation); return ok({ result: outcome.value, source: { kind: "single-function" }, limitations: [ KUNA_SINGLE_FUNCTION_LIMITATION, - ...(exportFailure === undefined + ...(exportUnavailable === undefined ? [] : [ - `The project export failed, so later requests also use single-function runs: ${exportFailure}`, + `The project export failed, so later requests also use single-function runs: ${exportUnavailable.reason}`, FAILED_PROJECT_RECORDS_GUIDANCE, ]), ], + ...(exportUnavailable === undefined + ? {} + : { siblingUnavailability: exportUnavailable }), }); } } @@ -2058,8 +2094,14 @@ export class KunaSessionClient implements AnalysisClient { return error; } - /** The export's failure once it has settled; undefined while it builds or after success. */ - async #settledExportFailure(): Promise { + /** + * The export's structured unavailability once it has settled as failed; + * undefined while it builds, after success, or when the failure was + * cancellation or incomplete cleanup. + */ + async #settledExportUnavailable( + operation: string, + ): Promise { const settled = await Promise.race([ this.#project, Promise.resolve(undefined), @@ -2070,7 +2112,10 @@ export class KunaSessionClient implements AnalysisClient { settled.error instanceof KunaPublishedProjectCleanupError ) return undefined; - return settled.error.userMessage ?? settled.error.message; + const unavailable = exportUnavailable(operation, settled.error); + return unavailable instanceof AnalysisCapabilityUnavailableError + ? unavailable + : undefined; } /** Wait for the export, reporting progress, without cancelling it for other callers. */ diff --git a/src/providers/AnalysisClientFailure.test.ts b/src/providers/AnalysisClientFailure.test.ts index b03c27a764..07d546b31c 100644 --- a/src/providers/AnalysisClientFailure.test.ts +++ b/src/providers/AnalysisClientFailure.test.ts @@ -1,4 +1,5 @@ import { expect, it } from "vitest"; +import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import type { FileOffsetPartialObservation } from "../domain/native/fileOffsetPartialObservation.js"; import { analysisClientFailureForOperation } from "./AnalysisClientFailure.js"; @@ -91,3 +92,40 @@ it("keeps a regex deadline's continuing-session guidance when attributing it to environment: "REA_REGEX_MATCHING_DEADLINE_MS", }); }); + +it("preserves sibling operations, sibling reasons, and remediation action when rebuilding capability unavailable errors", () => { + const source = new AnalysisCapabilityUnavailableError( + "kuna", + "procedure_assembly", + "Kuna project export failed", + { + userMessage: "Export failed", + remediationAction: "Retry without project requirement", + siblingOperations: ["procedure_assembly", "analyze_function"], + siblingReasons: { + analyze_function: "analyze_function export failed", + }, + retryable: true, + }, + ); + const attributed = analysisClientFailureForOperation( + source, + "kuna", + "procedure_assembly", + ); + expect(attributed).toBeInstanceOf(AnalysisCapabilityUnavailableError); + if (!(attributed instanceof AnalysisCapabilityUnavailableError)) + throw new Error("Expected AnalysisCapabilityUnavailableError"); + expect(attributed.siblingOperations).toEqual([ + "procedure_assembly", + "analyze_function", + ]); + expect(attributed.siblingReasons).toEqual({ + analyze_function: "analyze_function export failed", + }); + expect(attributed.remediationAction).toBe( + "Retry without project requirement", + ); + expect(attributed.userMessage).toBe("Export failed"); + expect(attributed.retryable).toBe(true); +}); diff --git a/src/providers/AnalysisClientFailure.ts b/src/providers/AnalysisClientFailure.ts index ef985844c2..4110017c6e 100644 --- a/src/providers/AnalysisClientFailure.ts +++ b/src/providers/AnalysisClientFailure.ts @@ -228,6 +228,15 @@ export const analysisClientFailureForOperation = ( ...(cause.userMessage === undefined ? {} : { userMessage: cause.userMessage }), + ...(cause.remediationAction === undefined + ? {} + : { remediationAction: cause.remediationAction }), + ...(cause.siblingOperations === undefined + ? {} + : { siblingOperations: cause.siblingOperations }), + ...(cause.siblingReasons === undefined + ? {} + : { siblingReasons: cause.siblingReasons }), retryable: cause.retryable, }, ); diff --git a/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts b/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts index d0343707a0..6e639680b1 100644 --- a/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts +++ b/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts @@ -630,6 +630,246 @@ it("returns provider-inferred siblings to declared availability after one succes }); }); +it("marks declared sibling operations unavailable when a shared prerequisite fails", async () => { + const respond = { + current: (operation: string): Promise => { + if (operation === "procedure_assembly") + return Promise.resolve( + err( + new AnalysisCapabilityUnavailableError( + FIXTURE.id, + operation, + "project export failed", + { siblingOperations: ["procedure_assembly", "procedure_info"] }, + ), + ), + ); + return Promise.resolve(observed("fixture-text")); + }, + }; + const { call, availability } = await connectScripted({ + operations: DIRECT_OPERATIONS, + respond, + }); + expect(await call("procedure_assembly", PROCEDURE)).toBe("error"); + // Independent operations remain available. + expect(await call("address_name", ADDRESS)).toBe("ok"); + expect( + await availability([ + "procedure_assembly", + "procedure_info", + "address_name", + "list_procedures", + ]), + ).toEqual({ + procedure_assembly: refusal("procedure_assembly", "project export failed"), + procedure_info: refusal("procedure_info", "project export failed"), + address_name: "available", + list_procedures: "available", + }); +}); + +it("marks declared sibling operations unavailable when fallback reports export failure", async () => { + const respond = { + current: (operation: string): Promise => { + if (operation === "procedure_pseudo_code") + return Promise.resolve( + ok( + createAnalysisExecution("int main() { return 0; }", FIXTURE, { + siblingUnavailability: new AnalysisCapabilityUnavailableError( + FIXTURE.id, + operation, + "project export failed", + { + siblingOperations: ["procedure_assembly", "procedure_info"], + }, + ), + }), + ), + ); + return Promise.resolve(observed("fixture-text")); + }, + }; + const { call, availability } = await connectScripted({ + operations: [...DIRECT_OPERATIONS, "procedure_pseudo_code"], + respond, + }); + expect(await call("procedure_pseudo_code", PROCEDURE)).toBe("ok"); + // Independent operations remain available. + expect(await call("address_name", ADDRESS)).toBe("ok"); + expect( + await availability([ + "procedure_pseudo_code", + "procedure_assembly", + "procedure_info", + "address_name", + "list_procedures", + ]), + ).toEqual({ + procedure_pseudo_code: "available", + procedure_assembly: refusal("procedure_assembly", "project export failed"), + procedure_info: refusal("procedure_info", "project export failed"), + address_name: "available", + list_procedures: "available", + }); +}); + +it("preserves observed sibling recovery instructions when a later fallback reports sibling unavailability", async () => { + const recoveryMessage = + "Project export failed. Set REA_FIXTURE_TIMEOUT_MS and restart REA."; + const respond = { + current: (operation: string): Promise => { + if (operation === "procedure_assembly") + return Promise.resolve( + err( + new AnalysisCapabilityUnavailableError( + FIXTURE.id, + operation, + "project export failed", + { + userMessage: recoveryMessage, + siblingOperations: ["procedure_assembly", "procedure_info"], + }, + ), + ), + ); + if (operation === "procedure_pseudo_code") + return Promise.resolve( + ok( + createAnalysisExecution("int main() { return 0; }", FIXTURE, { + siblingUnavailability: new AnalysisCapabilityUnavailableError( + FIXTURE.id, + operation, + "project export failed", + { + siblingOperations: ["procedure_assembly", "procedure_info"], + }, + ), + }), + ), + ); + return Promise.resolve(observed("fixture-text")); + }, + }; + const { call, availability } = await connectScripted({ + operations: [...DIRECT_OPERATIONS, "procedure_pseudo_code"], + respond, + }); + expect(await call("procedure_assembly", PROCEDURE)).toBe("error"); + expect(await availability(["procedure_assembly"])).toEqual({ + procedure_assembly: recoveryMessage, + }); + + // A later successful fallback must not overwrite the full recovery explanation. + expect(await call("procedure_pseudo_code", PROCEDURE)).toBe("ok"); + expect(await availability(["procedure_assembly"])).toEqual({ + procedure_assembly: recoveryMessage, + }); +}); + +it("replaces inactive past sibling failures with the current export failure when fallback reports sibling unavailability", async () => { + let assemblyFails = true; + const oldRefusal = "Import slot 0x4000 cannot be disassembled."; + const exportFailureMessage = + "Kuna's project export failed, so procedure_assembly is unavailable in this session."; + const respond = { + current: (operation: string): Promise => { + if (operation === "procedure_assembly") { + if (assemblyFails) + return Promise.resolve( + err( + new AnalysisCapabilityUnavailableError( + FIXTURE.id, + operation, + oldRefusal, + { userMessage: oldRefusal }, + ), + ), + ); + return Promise.resolve(observed("mov eax, 1")); + } + if (operation === "procedure_pseudo_code") + return Promise.resolve( + ok( + createAnalysisExecution("int main() { return 0; }", FIXTURE, { + siblingUnavailability: new AnalysisCapabilityUnavailableError( + FIXTURE.id, + operation, + "project export failed", + { + siblingOperations: ["procedure_assembly"], + siblingReasons: { + procedure_assembly: exportFailureMessage, + }, + }, + ), + }), + ), + ); + return Promise.resolve(observed("fixture-text")); + }, + }; + const { call, availability } = await connectScripted({ + operations: [...DIRECT_OPERATIONS, "procedure_pseudo_code"], + respond, + }); + // 1. Initial refusal on procedure_assembly + expect(await call("procedure_assembly", PROCEDURE)).toBe("error"); + expect(await availability(["procedure_assembly"])).toEqual({ + procedure_assembly: oldRefusal, + }); + + // 2. procedure_assembly succeeds later, resolving the active failure + assemblyFails = false; + expect(await call("procedure_assembly", PROCEDURE)).toBe("ok"); + expect(await availability(["procedure_assembly"])).toEqual({ + procedure_assembly: "available", + }); + + // 3. Fallback observes export failure: must show the new export failure, not resurrect oldRefusal + expect(await call("procedure_pseudo_code", PROCEDURE)).toBe("ok"); + expect(await availability(["procedure_assembly"])).toEqual({ + procedure_assembly: exportFailureMessage, + }); +}); + +it("preserves source paths and diagnostics unchanged in structured sibling unavailability", async () => { + const pathWithOpName = "/var/log/procedure_pseudo_code/target.c"; + const siblingMessage = `Kuna's project export failed, so procedure_assembly is unavailable in this session: Error compiling ${pathWithOpName}.`; + const respond = { + current: (operation: string): Promise => { + if (operation === "procedure_pseudo_code") + return Promise.resolve( + ok( + createAnalysisExecution("int main() { return 0; }", FIXTURE, { + siblingUnavailability: new AnalysisCapabilityUnavailableError( + FIXTURE.id, + operation, + `export failed for ${pathWithOpName}`, + { + siblingOperations: ["procedure_assembly"], + siblingReasons: { + procedure_assembly: siblingMessage, + }, + }, + ), + }), + ), + ); + return Promise.resolve(observed("fixture-text")); + }, + }; + const { call, availability } = await connectScripted({ + operations: [...DIRECT_OPERATIONS, "procedure_pseudo_code"], + respond, + }); + expect(await call("procedure_pseudo_code", PROCEDURE)).toBe("ok"); + // The path containing "procedure_pseudo_code" must remain completely intact + expect(await availability(["procedure_assembly"])).toEqual({ + procedure_assembly: siblingMessage, + }); +}); + it("keeps each provider's failures and ignores cancelled requests", async () => { let fixtureBroken = false; let auxiliaryBroken = true; diff --git a/tests/boundary/providers/kuna/kunaSingleFunctionRuns.test.ts b/tests/boundary/providers/kuna/kunaSingleFunctionRuns.test.ts index 468dd9329f..d82b3a0702 100644 --- a/tests/boundary/providers/kuna/kunaSingleFunctionRuns.test.ts +++ b/tests/boundary/providers/kuna/kunaSingleFunctionRuns.test.ts @@ -1,7 +1,12 @@ -import { expect, it as test } from "vitest"; +import { expect, it as test, vi } from "vitest"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; -import { kunaTimeoutRecovery } from "../../../../src/kuna/KunaCommand.js"; +import { + kunaTimeoutRecovery, + type KunaProcessLauncher, +} from "../../../../src/kuna/KunaCommand.js"; +import { KunaProjectCache } from "../../../../src/kuna/KunaProjectCache.js"; +import { spawnOwnedProviderProcess } from "../../../../src/process/ProviderProcess.js"; import { fakeKunaCalls, @@ -235,6 +240,10 @@ it("keeps answering repeats from the completed run after the export fails", asyn expect(answer.limitations).toContainEqual( expect.stringContaining("The project export failed"), ); + expect(answer.siblingUnavailability?.siblingOperations).toEqual([ + "procedure_assembly", + "analyze_function", + ]); } expect(await decompilesOf(kuna)).toHaveLength(1); } finally { @@ -242,6 +251,64 @@ it("keeps answering repeats from the completed run after the export fails", asyn } }); +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +it("reports sibling unavailability when fallback observes settled export failure without prior assembly call", async () => { + const exportSettled = deferred(); + exportSettled.promise.catch(() => undefined); + const originalBuild = KunaProjectCache.prototype.build; + const exportBuild = vi + .spyOn(KunaProjectCache.prototype, "build") + .mockImplementation(function (this: KunaProjectCache, ...args) { + const promise = originalBuild.apply(this, args); + return promise.finally(() => exportSettled.resolve()); + }); + const launcher: KunaProcessLauncher = async (options) => { + if (options.arguments[0] === "decompile") { + await exportSettled.promise; + } + return spawnOwnedProviderProcess(options); + }; + const { kuna, open } = await session( + "rea-kuna-single-fallback-unavail-", + { project: "exit-failure" }, + {}, + launcher, + ); + const client = open(); + try { + const answer = await run(client, "procedure_pseudo_code", { + procedure: "main", + }); + expect(answer.rawResult).toMatchObject({ + source: { kind: "single-function" }, + }); + expect(answer.siblingUnavailability).toBeDefined(); + expect(answer.siblingUnavailability?.siblingOperations).toEqual([ + "procedure_assembly", + "analyze_function", + ]); + expect(answer.siblingUnavailability?.providerId).toBe("kuna"); + expect(await decompilesOf(kuna)).toHaveLength(1); + await exportSettled.promise; + } finally { + exportSettled.resolve(); + try { + expect(await client.close()).toEqual({ ok: true, value: null }); + } finally { + exportBuild.mockRestore(); + } + } +}); + it("does not keep a failed run, so a repeat runs again", async () => { const { kuna, open } = await session( "rea-kuna-single-failed-not-kept-", From 985b9eae962e561c2e02c01fbe8cbfea9478c100 Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:52:09 +0800 Subject: [PATCH 054/151] feat(browser): import saved Playwright v10 trace evidence (#9127) * feat(browser): import saved Playwright v10 trace evidence * fix(browser): preserve original Playwright ZIP member names --- docs/mcp-contracts.md | 9 + docs/playwright-traces.md | 125 +++++ .../playwrightTrace/PlaywrightTracePort.ts | 11 + .../playwrightTrace/PlaywrightTraceService.ts | 84 ++++ src/browser/history/CaptureJson.ts | 73 +-- src/browser/history/HarCapture.ts | 8 +- .../PlaywrightTraceDecoder.test.ts | 454 ++++++++++++++++++ .../playwrightTrace/PlaywrightTraceDecoder.ts | 254 ++++++++++ .../PlaywrightTraceLifecycle.test.ts | 264 ++++++++++ .../playwrightTrace/TraceEventProjection.ts | 181 +++++++ .../history/playwrightTrace/TraceFailure.ts | 78 +++ .../history/playwrightTrace/TraceInventory.ts | 177 +++++++ .../history/playwrightTrace/TraceLines.ts | 78 +++ .../history/playwrightTrace/TraceStream.ts | 278 +++++++++++ src/cli/applicationCommands.ts | 2 + src/cli/playwrightTraceCommand.ts | 107 +++++ src/cliCommandNames.ts | 1 + src/contracts/applicationToolContracts.ts | 2 + src/contracts/playwrightTraceToolContracts.ts | 42 ++ src/contracts/toolEffects.ts | 1 + src/domain/analysisErrorBase.ts | 2 + src/domain/analysisView/analysisView.ts | 6 + .../analysisView/analysisViewRequest.ts | 2 + src/domain/analysisView/analysisViewResult.ts | 20 + .../analysisView/playwrightTraceView.ts | 351 ++++++++++++++ src/domain/artifactOperationError.ts | 1 + src/domain/playwrightTrace/playwrightTrace.ts | 133 +++++ .../playwrightTrace/playwrightTraceQuery.ts | 68 +++ src/server/registerApplicationTools.ts | 2 + .../playwrightTrace.ts | 59 +++ .../applications/playwrightTrace.test.ts | 433 +++++++++++++++++ tests/fixtures/playwright-trace/README.md | 44 ++ .../playwright-trace/trace.network.txt | 3 + .../fixtures/playwright-trace/trace.trace.txt | 51 ++ tests/fixtures/playwrightTrace.ts | 93 ++++ 35 files changed, 3469 insertions(+), 28 deletions(-) create mode 100644 docs/playwright-traces.md create mode 100644 src/application/playwrightTrace/PlaywrightTracePort.ts create mode 100644 src/application/playwrightTrace/PlaywrightTraceService.ts create mode 100644 src/browser/history/playwrightTrace/PlaywrightTraceDecoder.test.ts create mode 100644 src/browser/history/playwrightTrace/PlaywrightTraceDecoder.ts create mode 100644 src/browser/history/playwrightTrace/PlaywrightTraceLifecycle.test.ts create mode 100644 src/browser/history/playwrightTrace/TraceEventProjection.ts create mode 100644 src/browser/history/playwrightTrace/TraceFailure.ts create mode 100644 src/browser/history/playwrightTrace/TraceInventory.ts create mode 100644 src/browser/history/playwrightTrace/TraceLines.ts create mode 100644 src/browser/history/playwrightTrace/TraceStream.ts create mode 100644 src/cli/playwrightTraceCommand.ts create mode 100644 src/contracts/playwrightTraceToolContracts.ts create mode 100644 src/domain/analysisView/playwrightTraceView.ts create mode 100644 src/domain/playwrightTrace/playwrightTrace.ts create mode 100644 src/domain/playwrightTrace/playwrightTraceQuery.ts create mode 100644 src/server/registerApplicationTools/playwrightTrace.ts create mode 100644 tests/acceptance/applications/playwrightTrace.test.ts create mode 100644 tests/fixtures/playwright-trace/README.md create mode 100644 tests/fixtures/playwright-trace/trace.network.txt create mode 100644 tests/fixtures/playwright-trace/trace.trace.txt create mode 100644 tests/fixtures/playwrightTrace.ts diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 765f8fc06f..bb0e7dc7f6 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -2138,3 +2138,12 @@ skill and portable conformance projections in the `generated-docs` artifact. These outputs describe the exact source revision being built; they are not checked-in snapshots. For a running server, `binary_session` remains the authoritative source of catalog identity and tool availability. + +## Offline Playwright traces + +`inspect_playwright_trace` reads a local v10 trace ZIP and defaults to a retained +summary. `playwright-trace` views select source-located action boundaries, console +and network facts, resource references and diagnostics; long text has bounded +previews and character pages. Import does not contact recorded URLs or use a +browser. See [offline Playwright traces](./playwright-traces.md) for coverage, +resource policies and the shared CLI workflow. diff --git a/docs/playwright-traces.md b/docs/playwright-traces.md new file mode 100644 index 0000000000..4a5ac6e357 --- /dev/null +++ b/docs/playwright-traces.md @@ -0,0 +1,125 @@ +# Offline Playwright traces + +Use `inspect_playwright_trace` or `inspect-playwright-trace` with an existing +local Playwright **trace v10** ZIP. Import is offline: recorded URLs are not +requested, and no browser, application or trace viewer is started. + +```sh +rea inspect-playwright-trace /absolute/trace.zip --json +rea inspect-playwright-trace /absolute/trace.zip --detail summary \ + --evidence_output /absolute/new-evidence.bundle.json --json +``` + +MCP defaults to a compact summary and retains complete supported Evidence. +CLI defaults to complete delivery; summary and selected pages require an absent +`--evidence_output` destination, so later queries can read the original parent. + +```json +{ + "name": "inspect_playwright_trace", + "arguments": { + "path": "/captures/trace.zip", + "view": { + "kind": "playwright-trace", + "collection": "events", + "kinds": ["network"], + "fields": ["method", "url", "status"], + "limit": 10 + } + } +} +``` + +`view` takes precedence over `detail`. The retained parent can be queried with +`inspect_analysis_view`; CLI can use the same request against an exported +Evidence bundle with `inspect-analysis-view`. + +## Facts, selection and text + +The `playwright-trace` view offers: + +| Collection | Available selection | +| ------------- | ---------------------------------------------------------------------- | +| `events` | Batch event/stream/call IDs, event kinds, fields, row offset and limit | +| `streams` | Batch stream IDs, fields and row pages | +| `members` | Batch member IDs, exact archive paths, fields and row pages | +| `diagnostics` | Codes, fields and row pages | +| `event-text` | One event ID, UTF-16 character offset and limit | + +An omitted filter selects all retained rows; an explicit empty filter selects +none. Minimal event rows keep event/stream IDs, source member/line, producer +ordinal, kind and credential-redaction status. Page exhaustion describes only +the selected facts and never establishes complete recording coverage. + +Default event pages return at most 256 UTF-16 code units in `text_preview`, +with explicit text coverage. Continue from that preview's `next_offset` using +`collection: "event-text"`, its `event_id`, `offset`, and a character `limit` +(default 2,048). Character offsets do not split surrogate pairs. Explicit +`fields: ["text"]` returns the complete interpreted text value; `reported` +returns the retained supported payload and sidecars remain selectable. +Complete Evidence remains accessible through ordinary bundle operations. + +## Source and coverage + +Archive SHA-256 and directory occurrence bind stable member identity. Each event +retains its member ID, physical one-based NDJSON line and zero-based physical +line ordinal. Headline times retain producer monotonic milliseconds, without +aligning stream clocks. Counts derive from the retained supported records. Call IDs are producer values; they do not establish unique actions +across streams or imply a successfully completed before/after pair. There is no +inferred total ordering or clock alignment between `.trace` and `.network`. + +Member `path` preserves the reader's original ZIP name; `logical_path` separately +records its normalized interpretation. Resource references, exact `paths` filters +and trace/network profile pairing use original names, so `resources/x` and +`./resources/x` remain distinct. Reader limitations remain attached to each member +and keep source interpretation partial when present. + +`decoder_profile` names the selected interpretation policy, not a verified source version. +Trace members require an initial numeric v10 context header. Other declared +versions are refused. Missing, malformed, repeated or ambiguous profiles remain +unknown; network members require one unambiguous paired trace profile. Valid +siblings of malformed records stay useful, with explicit diagnostics. Unsafe +numeric values are not rounded: exact literals stay in sidecars. + +Interpreted facts cover action boundaries (class/method, title, call/page IDs, +selected navigation URL/selector, recorded times and available error text), +console text/location, network metadata and screencast references. Other action +arguments, results, context options, DOM, stacks, unsupported event types and +resource contents are outside this version's interpretation. These omissions +are scope limitations, not assertions that omitted values are secrets or absent. +Original archive bytes remain the complete raw source. + +Known HAR transport credentials and structured network, navigation and console-location +URL userinfo are excluded using the existing capture projection. Ordinary names, query values, console text and caller data +are preserved. Network numeric/redaction pointers are relative to `reported`; +combine them with the event source's `record_pointer` (`/snapshot`) to locate the +original NDJSON value. Resource-reference pointers already address the physical +record. + +Resource references distinguish `present-unread`, `missing`, `ambiguous` and +`unavailable`. Presence means directory metadata only: screenshots and bodies are +not read or validated. Member `declared_bytes` and `declared_compressed_bytes` +come from the ZIP directory; they are not measurements of unread payloads. +Only decoded stream members have verified ZIP CRC and +size. Duplicate names keep separate occurrence IDs; no filename silently picks +a resource. Recording completeness always remains unknown, including after all +retained pages have been read. + +## Bounds and verification + +Each decoder admits one archive import at a time; concurrent imports receive a +retryable capacity error. Retained queries do not require another import. +Resource policies admit a 256-MiB archive, 32 MiB of decoded streams, a +conservative 32-MiB retained-facts representation, 4-MiB metadata reads and +4-MiB physical lines. JSON parsing additionally checks heap headroom, a +131,072-node record allowance and the shared 64-level capture nesting budget. +These are resource policies, not Playwright format limits. Paging or field +selection does not increase parsing allowances; choose a smaller complete +recorder chunk when admission fails. + +The owned fixtures in `tests/fixtures/playwright-trace/` preserve actual +Playwright 1.64.1 v10 recorder text. Public CLI/MCP tests exercise advertised +schemas, retained and bundle queries without the source file, partial facts, +credential exclusion, bounded Unicode previews, and a loopback listener that +must receive no import-time requests. Recorder/version/platform compatibility +outside this verified profile is unverified. diff --git a/src/application/playwrightTrace/PlaywrightTracePort.ts b/src/application/playwrightTrace/PlaywrightTracePort.ts new file mode 100644 index 0000000000..cd98e530ba --- /dev/null +++ b/src/application/playwrightTrace/PlaywrightTracePort.ts @@ -0,0 +1,11 @@ +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { Result } from "../../domain/result.js"; +import type { PlaywrightTrace } from "../../domain/playwrightTrace/playwrightTrace.js"; + +/** Producer parsing and archive ownership stay with the offline format adapter. */ +export interface PlaywrightTracePort { + inspect( + path: string, + signal?: AbortSignal, + ): Promise>; +} diff --git a/src/application/playwrightTrace/PlaywrightTraceService.ts b/src/application/playwrightTrace/PlaywrightTraceService.ts new file mode 100644 index 0000000000..8968bfb222 --- /dev/null +++ b/src/application/playwrightTrace/PlaywrightTraceService.ts @@ -0,0 +1,84 @@ +import { + primaryAnalysisError, + type AnalysisError, +} from "../../domain/analysisErrorBase.js"; +import { isAbsolute } from "node:path"; +import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; +import { createEvidence } from "../../domain/evidence.js"; +import { err } from "../../domain/result.js"; +import { inspectPlaywrightTraceInputSchema } from "../../domain/playwrightTrace/playwrightTraceQuery.js"; +import { + PLAYWRIGHT_TRACE_OPERATION as operation, + PLAYWRIGHT_TRACE_PROVIDER, + playwrightTraceSchema, + type PlaywrightTrace, +} from "../../domain/playwrightTrace/playwrightTrace.js"; +import { parseAnalysisInput } from "../AnalysisInput.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; +import type { PlaywrightTracePort } from "./PlaywrightTracePort.js"; + +const evidence = (report: PlaywrightTrace) => + createEvidence( + { + path: report.artifact.path, + format: "file", + sha256: report.artifact.sha256, + }, + PLAYWRIGHT_TRACE_PROVIDER, + { + operation, + predicateType: "rea.playwright-trace-inventory", + parameters: { + path: report.artifact.path, + decoder_profile: "playwright-trace-v10", + }, + result: report, + confidence: "observed", + limitations: report.limitations, + locations: [{ kind: "artifact-path", path: report.artifact.path }], + }, + ); +/** One Evidence workflow serves CLI and MCP; delivery selection never changes source identity. */ +export class PlaywrightTraceService { + constructor(readonly port: PlaywrightTracePort) {} + async inspect(raw: unknown, signal?: AbortSignal) { + const parsed = parseAnalysisInput( + operation, + raw, + inspectPlaywrightTraceInputSchema, + signal, + ); + if (!parsed.ok) return parsed; + if (!isAbsolute(parsed.value.path)) + return err( + new AnalysisInputError(operation, undefined, [ + { + path: ["path"], + reason: "invalid_format", + message: "Expected an absolute filesystem trace archive path.", + }, + ]), + ); + const loaded = await this.port.inspect(parsed.value.path, signal); + if (!loaded.ok) { + const source = + loaded.error.partialObservation ?? + primaryAnalysisError(loaded.error).partialObservation; + const partial = playwrightTraceSchema.safeParse(source); + if (partial.success) { + const projected = evidence(partial.data); + let current: AnalysisError | undefined = loaded.error; + while (current !== undefined) { + if ( + current.partialObservation === source || + current.partialObservation === undefined + ) + current.partialObservation = projected; + current = current.cleanupPrimary; + } + } + return loaded; + } + return completeProviderEvidence(evidence(loaded.value), signal); + } +} diff --git a/src/browser/history/CaptureJson.ts b/src/browser/history/CaptureJson.ts index a1b5ca17af..a73a934f45 100644 --- a/src/browser/history/CaptureJson.ts +++ b/src/browser/history/CaptureJson.ts @@ -13,12 +13,43 @@ type Frame = } | { kind: "array"; pointer: string; items: unknown[] }; +/** Admit each retained parser node before its object or numeric wrapper is allocated. */ +const captureNodeAdmission = (maximumNodes: number | undefined) => { + let nodes = 0; + return (): void => { + nodes++; + if (maximumNodes !== undefined && nodes > maximumNodes) + throw new CaptureFormatError( + "input-limit", + "JSON exceeds its parser node representation allowance.", + ); + }; +}; + +const selectCaptureObjectProperty = ( + frames: Frame[], + key: string, + format: string, +): void => { + const frame = frames.at(-1); + if (frame?.kind !== "object") + throw new TypeError("JSON visitor emitted a property outside an object."); + if (frame.members.has(key)) + throw new CaptureFormatError( + "format", + `${format} JSON contains duplicate object members.`, + ); + frame.pendingKey = key; +}; + /** Materialize upstream JSON events without duplicate folding, prototype setters or numeric rounding. */ export const parseCaptureJson = ( text: string, - format: "HAR" | "qlog", + format: "HAR" | "qlog" | "Playwright trace", + maximumNodes?: number, ): unknown => { const frames: Frame[] = []; + const admitNode = captureNodeAdmission(maximumNodes); let result: unknown; const valuePointer = (): string => { const parent = frames.at(-1); @@ -33,6 +64,7 @@ export const parseCaptureJson = ( // its own level stays within the budget; values inside an admitted // container add no level, so empty and scalar-bearing containers agree. const admitContainer = (): void => { + admitNode(); if (frames.length >= WEB_NETWORK_CAPTURE_LIMITS.depth) throw new CaptureFormatError( "input-limit", @@ -53,6 +85,14 @@ export const parseCaptureJson = ( delete parent.pendingKey; } }; + const endContainer = (kind: "object" | "array"): void => { + const frame = frames.pop(); + if (frame?.kind !== kind) + throw new TypeError(`JSON visitor ended an unexpected ${kind}.`); + append( + frame.kind === "object" ? Object.fromEntries(frame.members) : frame.items, + ); + }; visit( text, { @@ -65,38 +105,17 @@ export const parseCaptureJson = ( }); }, onObjectProperty: (key) => { - // Prototype-named members are kept as ordinary own properties: the - // members map keys them without prototype setters, and - // Object.fromEntries materializes them as own data properties. - const frame = frames.at(-1); - if (frame?.kind !== "object") - throw new TypeError( - "JSON visitor emitted a property outside an object.", - ); - if (frame.members.has(key)) - throw new CaptureFormatError( - "format", - `${format} JSON contains duplicate object members.`, - ); - frame.pendingKey = key; - }, - onObjectEnd: () => { - const frame = frames.pop(); - if (frame?.kind !== "object") - throw new TypeError("JSON visitor ended an unexpected object."); - append(Object.fromEntries(frame.members)); + admitNode(); + selectCaptureObjectProperty(frames, key, format); }, + onObjectEnd: () => endContainer("object"), onArrayBegin: () => { admitContainer(); frames.push({ kind: "array", pointer: valuePointer(), items: [] }); }, - onArrayEnd: () => { - const frame = frames.pop(); - if (frame?.kind !== "array") - throw new TypeError("JSON visitor ended an unexpected array."); - append(frame.items); - }, + onArrayEnd: () => endContainer("array"), onLiteralValue: (value: unknown, offset, length) => { + admitNode(); if (typeof value === "number") append(new LosslessNumber(text.slice(offset, offset + length))); else if ( diff --git a/src/browser/history/HarCapture.ts b/src/browser/history/HarCapture.ts index 4353e9b6de..5abc0c9d4b 100644 --- a/src/browser/history/HarCapture.ts +++ b/src/browser/history/HarCapture.ts @@ -64,7 +64,7 @@ export const decodeHarCapture = ( ); const records = entries.map((entry, ordinal): WebNetworkCaptureRecord => { // Validate each original record even when a structural ancestor excludes it. - const projected = projectHar(entry, sensitiveValues, "entry"); + const projected = projectRecordedHarEntry(entry, sensitiveValues); const projection: Projection = recordsExcluded ? { reported: null, @@ -103,6 +103,12 @@ export const decodeHarCapture = ( }; }; +/** Project producer-owned HAR entry facts without pretending another format is a HAR document. */ +export const projectRecordedHarEntry = ( + value: unknown, + sensitiveValues: readonly string[], +): Projection => projectHar(value, sensitiveValues, "entry"); + /** Relative HTTP paths belong only to entries, never similarly named container extensions. */ const projectHar = ( value: unknown, diff --git a/src/browser/history/playwrightTrace/PlaywrightTraceDecoder.test.ts b/src/browser/history/playwrightTrace/PlaywrightTraceDecoder.test.ts new file mode 100644 index 0000000000..ab69545992 --- /dev/null +++ b/src/browser/history/playwrightTrace/PlaywrightTraceDecoder.test.ts @@ -0,0 +1,454 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile, open } from "node:fs/promises"; +import { expect, it } from "vitest"; +import { ArtifactResourceScope } from "../../../artifacts/ArtifactResourceScope.js"; +import { PlaywrightTraceDecoder } from "./PlaywrightTraceDecoder.js"; +import { playwrightTraceSchema } from "../../../domain/playwrightTrace/playwrightTrace.js"; +import { + playwrightTraceFixture, + playwrightTracePathFixture, +} from "../../../../tests/fixtures/playwrightTrace.js"; + +it("imports real v10 recorder text with per-stream identity and resource presence, preserving source bytes", async () => { + const fixture = await playwrightTraceFixture({ resources: true }); + const bytes = await readFile(fixture.path); + const resources = new ArtifactResourceScope(); + try { + const result = await new PlaywrightTraceDecoder(resources).inspect( + fixture.path, + ); + if (!result.ok) throw result.error; + const report = playwrightTraceSchema.parse(result.value); + expect(report.summary).toEqual({ + events: 23, + action_before: 6, + action_after: 6, + console: 2, + network: 3, + screenshots: 6, + unavailable_references: 0, + }); + expect(report.artifact).toEqual({ + path: fixture.path, + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, + }); + expect(new Set(report.events.map((row) => row.event_id)).size).toBe(23); + expect(report.streams).toHaveLength(2); + expect(report.streams[0]?.producer?.playwright_version).toBe("1.64.1"); + expect(report.source_coverage).toEqual({ + recording_completeness: "unknown", + interpretation: "scoped", + resource_contents: "not-decoded", + }); + const serialized = JSON.stringify(report); + expect(serialized).not.toContain("FAKE_TRACE_CREDENTIAL"); + expect(serialized).not.toContain("FAKE_COOKIE"); + expect(serialized).toContain("ordinary caller text"); + expect(report.events.find((row) => row.kind === "console")?.text).toBe( + "fixture loaded", + ); + expect( + report.events.find((row) => row.method === "click")?.selector, + ).toContain("Load value"); + expect( + report.events + .flatMap((row) => row.resource_references) + .every((row) => row.state === "present-unread"), + ).toBe(true); + expect((await readFile(fixture.path)).equals(bytes)).toBe(true); + } finally { + await resources.close(); + } +}); + +it("preserves exact ZIP names for resources and paired stream profiles", async () => { + const fixture = await playwrightTracePathFixture(); + const resources = new ArtifactResourceScope(); + try { + const result = await new PlaywrightTraceDecoder(resources).inspect( + fixture.path, + ); + if (!result.ok) throw result.error; + const report = result.value; + expect(report.members.map((member) => member.path)).toContain( + "./resources/x", + ); + expect( + report.members.find((member) => member.path === "./resources/x"), + ).toMatchObject({ + logical_path: "resources/x", + limitations: [expect.stringContaining("leading current-directory")], + }); + expect( + report.members.find((member) => member.path === "resources/x"), + ).toMatchObject({ logical_path: "resources/x", limitations: [] }); + expect(report.events).toHaveLength(4); + expect(report.streams.map((stream) => stream.state)).toEqual([ + "supported", + "supported", + "supported", + "supported", + ]); + const members = new Map( + report.members.map((member) => [member.member_id, member]), + ); + for (const event of report.events) { + const source = members.get(event.source.member_id)!; + const expected = source.path.startsWith("./") + ? "./resources/x" + : "resources/x"; + expect(event.resource_references).toEqual([ + expect.objectContaining({ + path: expected, + state: "present-unread", + member_id: report.members.find((row) => row.path === expected) + ?.member_id, + matching_members: 1, + }), + ]); + } + expect( + report.events + .filter((event) => event.kind === "network") + .map((event) => event.status), + ).toEqual([200, 201]); + } finally { + await resources.close(); + } +}); + +it("keeps missing resource references explicit without turning page exhaustion into capture completeness", async () => { + const fixture = await playwrightTraceFixture(); + const resources = new ArtifactResourceScope(); + try { + const result = await new PlaywrightTraceDecoder(resources).inspect( + fixture.path, + ); + if (!result.ok) throw result.error; + expect(result.value.summary.unavailable_references).toBe(8); + expect(result.value.source_coverage.interpretation).toBe("partial"); + expect( + result.value.events + .flatMap((row) => row.resource_references) + .every((row) => row.state === "missing" && row.matching_members === 0), + ).toBe(true); + } finally { + await resources.close(); + } +}); + +const header = + JSON.stringify({ type: "context-options", version: 10, origin: "library" }) + + "\n"; +const before = + JSON.stringify({ + type: "before", + callId: "call-a", + class: "Frame", + method: "goto", + startTime: 1, + params: { + url: "https://u:p@example.test/path?token=ordinary", + selector: "ordinary", + }, + }) + "\n"; + +it("retains valid siblings of malformed records and exposes exact producer lines", async () => { + const fixture = await playwrightTraceFixture({ + trace: header + '{"type":"console","text":NaN}\n' + before, + network: "", + }); + const resources = new ArtifactResourceScope(); + try { + const result = await new PlaywrightTraceDecoder(resources).inspect( + fixture.path, + ); + if (!result.ok) throw result.error; + expect(result.value.events).toHaveLength(1); + expect(result.value.events[0]).toMatchObject({ + source: { line: 3 }, + url: "https://example.test/path?token=ordinary", + credential_redacted: true, + }); + expect(result.value.diagnostics).toContainEqual( + expect.objectContaining({ line: 2, code: "malformed-record" }), + ); + expect(result.value.source_coverage.interpretation).toBe("partial"); + expect(JSON.stringify(result.value)).not.toContain("u:p@"); + } finally { + await resources.close(); + } +}); + +it.each([ + ["", 0], + [before, 0], + [header + before + header + before, 1], +])( + "never guesses a missing or repeated context version %#", + async (trace, events) => { + const fixture = await playwrightTraceFixture({ trace, network: "" }); + const resources = new ArtifactResourceScope(); + try { + const result = await new PlaywrightTraceDecoder(resources).inspect( + fixture.path, + ); + if (!result.ok) throw result.error; + expect(result.value.events).toHaveLength(events); + expect(result.value.source_coverage.interpretation).toBe("partial"); + expect( + result.value.streams.every((stream) => stream.state === "unknown"), + ).toBe(true); + } finally { + await resources.close(); + } + }, +); + +it("refuses other declared trace versions with verified archive identity", async () => { + const fixture = await playwrightTraceFixture({ + trace: header.replace('"version":10', '"version":11'), + network: "", + }); + const bytes = await readFile(fixture.path); + const resources = new ArtifactResourceScope(); + try { + const result = await new PlaywrightTraceDecoder(resources).inspect( + fixture.path, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected version refusal"); + expect(result.error._tag).toBe("AnalysisUnsupportedTargetError"); + expect( + playwrightTraceSchema.parse(result.error.partialObservation).artifact + .sha256, + ).toBe(createHash("sha256").update(bytes).digest("hex")); + } finally { + await resources.close(); + } +}); + +it("preserves opaque pre-cancellation without reading or claiming a source snapshot", async () => { + const resources = new ArtifactResourceScope(); + const controller = new AbortController(); + const reason = { reason: "owned caller" }; + controller.abort(reason); + try { + const result = await new PlaywrightTraceDecoder(resources).inspect( + "/unused.zip", + controller.signal, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe(reason); + expect(result.error.partialObservation).toBeUndefined(); + } finally { + await resources.close(); + } +}); + +it("keeps unsafe numeric literals exact instead of rounding recorded times", async () => { + const trace = + header + + '{"type":"before","callId":"large","class":"Frame","method":"goto","startTime":9007199254740993,"params":{"url":"https://example.test/"}}\n'; + const fixture = await playwrightTraceFixture({ trace, network: "" }); + const resources = new ArtifactResourceScope(); + try { + const result = await new PlaywrightTraceDecoder(resources).inspect( + fixture.path, + ); + if (!result.ok) throw result.error; + expect(result.value.events[0]?.time).toBeNull(); + expect(result.value.events[0]?.numeric_literals).toContainEqual({ + pointer: "/startTime", + literal: "9007199254740993", + }); + } finally { + await resources.close(); + } +}); + +it.each([ + ['{"type":"context-options","version":10,"version":11}\n', "unknown"], + [header + '{"type":"resource-snapshot","snapshot":{}}\n', "supported"], +])( + "reports duplicate keys and incomplete known events explicitly %#", + async (trace, state) => { + const fixture = await playwrightTraceFixture({ trace, network: "" }); + const resources = new ArtifactResourceScope(); + try { + const result = await new PlaywrightTraceDecoder(resources).inspect( + fixture.path, + ); + if (!result.ok) throw result.error; + expect(result.value.streams[0]?.state).toBe(state); + expect(result.value.source_coverage.interpretation).toBe("partial"); + expect(result.value.diagnostics.length).toBeGreaterThan(0); + } finally { + await resources.close(); + } + }, +); + +it("refuses oversized physical lines before parsing their representation", async () => { + const trace = + header + + JSON.stringify({ + type: "console", + text: "x".repeat(4 * 1024 * 1024), + time: 1, + }) + + "\n"; + const fixture = await playwrightTraceFixture({ trace, network: "" }); + const resources = new ArtifactResourceScope(); + try { + const result = await new PlaywrightTraceDecoder(resources).inspect( + fixture.path, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected line capacity refusal"); + expect(result.error._tag).toBe("AnalysisResourceConstraintError"); + expect(resources.hasPending).toBe(false); + } finally { + await resources.close(); + } +}); + +it("never promotes facts from a stream whose ZIP CRC fails", async () => { + const fixture = await playwrightTraceFixture({ level: 0 }); + const bytes = await readFile(fixture.path); + const needle = Buffer.from('"method":"GET"'); + const offset = bytes.indexOf(needle); + expect(offset).toBeGreaterThan(0); + bytes[offset + 10] = 80; + await writeFile(fixture.path, bytes); + const resources = new ArtifactResourceScope(); + try { + const result = await new PlaywrightTraceDecoder(resources).inspect( + fixture.path, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected CRC refusal"); + expect(result.error._tag).toBe("AnalysisInputError"); + const partial = playwrightTraceSchema.parse( + result.error.partialObservation, + ); + expect(partial.summary.network).toBe(0); + expect(partial.summary.events).toBe(20); + expect(partial.source_coverage.interpretation).toBe("partial"); + expect(resources.hasPending).toBe(false); + } finally { + await resources.close(); + } +}); + +it("rejects a sparse over-budget archive before hashing or expansion", async () => { + const fixture = await playwrightTraceFixture(); + const file = await open(fixture.path, "r+"); + await file.truncate(256 * 1024 * 1024 + 1); + await file.close(); + const resources = new ArtifactResourceScope(); + try { + const result = await new PlaywrightTraceDecoder(resources).inspect( + fixture.path, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected archive admission refusal"); + expect(result.error._tag).toBe("AnalysisResourceConstraintError"); + expect(result.error.partialObservation).toBeUndefined(); + expect(resources.hasPending).toBe(false); + } finally { + await resources.close(); + } +}); + +it("bounds JSON node expansion even when many tokens fit inside a small physical line", async () => { + const trace = + header + + JSON.stringify({ + type: "frame-snapshot", + snapshot: Array.from({ length: 132000 }, () => 0), + }) + + "\n"; + const fixture = await playwrightTraceFixture({ trace, network: "" }); + const resources = new ArtifactResourceScope(); + try { + const result = await new PlaywrightTraceDecoder(resources).inspect( + fixture.path, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected node admission refusal"); + expect(result.error._tag).toBe("AnalysisResourceConstraintError"); + expect(resources.hasPending).toBe(false); + } finally { + await resources.close(); + } +}); + +it("keeps duplicate resource names ambiguous instead of selecting one ZIP occurrence", async () => { + const fixture = await playwrightTraceFixture({ resources: true, level: 0 }); + const frames = fixture.trace + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)) + .filter((row) => row.type === "screencast-frame"); + const first = frames[0].file as string, + second = frames[1].file as string; + expect(first.length).toBe(second.length); + const bytes = await readFile(fixture.path); + const eocd = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])); + expect(eocd).toBeGreaterThan(0); + let cursor = bytes.readUInt32LE(eocd + 16); + let changed = 0; + for (let index = 0; index < bytes.readUInt16LE(eocd + 10); index++) { + expect(bytes.readUInt32LE(cursor)).toBe(0x02014b50); + const length = bytes.readUInt16LE(cursor + 28); + if ( + bytes.subarray(cursor + 46, cursor + 46 + length).toString("utf8") === + second + ) { + const local = bytes.readUInt32LE(cursor + 42); + expect(bytes.readUInt32LE(local)).toBe(0x04034b50); + expect(bytes.readUInt16LE(local + 26)).toBe(length); + // Both headers agree; CRC covers the unchanged data, not member names. + Buffer.from(first).copy(bytes, cursor + 46); + Buffer.from(first).copy(bytes, local + 30); + changed++; + } + cursor += + 46 + + length + + bytes.readUInt16LE(cursor + 30) + + bytes.readUInt16LE(cursor + 32); + } + expect(changed).toBe(1); + await writeFile(fixture.path, bytes); + const resources = new ArtifactResourceScope(); + try { + const result = await new PlaywrightTraceDecoder(resources).inspect( + fixture.path, + ); + if (!result.ok) throw result.error; + expect( + result.value.members.filter((member) => member.path === first), + ).toHaveLength(2); + expect( + new Set(result.value.members.map((member) => member.member_id)).size, + ).toBe(result.value.members.length); + expect( + result.value.events.flatMap((row) => row.resource_references), + ).toContainEqual( + expect.objectContaining({ + path: first, + state: "ambiguous", + member_id: null, + matching_members: 2, + }), + ); + expect(result.value.source_coverage.interpretation).toBe("partial"); + } finally { + await resources.close(); + } +}); diff --git a/src/browser/history/playwrightTrace/PlaywrightTraceDecoder.ts b/src/browser/history/playwrightTrace/PlaywrightTraceDecoder.ts new file mode 100644 index 0000000000..69940c21b6 --- /dev/null +++ b/src/browser/history/playwrightTrace/PlaywrightTraceDecoder.ts @@ -0,0 +1,254 @@ +import type { StableRegularFileDescriptor } from "../../../filesystem/RegularFile.js"; +import type { PlaywrightTracePort } from "../../../application/playwrightTrace/PlaywrightTracePort.js"; +import { ArtifactResourceScope } from "../../../artifacts/ArtifactResourceScope.js"; +import type { ArtifactEntry } from "../../../artifacts/ArtifactReader.js"; +import { ZipArtifactReader } from "../../../artifacts/ZipArtifactReader.js"; +import { + artifactFilePrimaryFacts, + assertArtifactReadActive, + withArtifactRegularFile, +} from "../../../artifacts/ArtifactFileOperation.js"; +import { projectStableArtifactFailure } from "../../../artifacts/StableArtifactFailure.js"; +import { assertArtifactFileSelectedSource } from "../../../artifacts/ArtifactFileSource.js"; +import { hashStableRootArtifactHandle } from "../../../artifacts/inventory/hashStableRootArtifact.js"; +import { + AnalysisResourceConstraintError, + AnalysisCancelledError, + AnalysisUnsupportedTargetError, +} from "../../../domain/analysisErrorCore.js"; +import { err, ok } from "../../../domain/result.js"; +import { + PLAYWRIGHT_TRACE_LIMITS, + PLAYWRIGHT_TRACE_OPERATION as operation, + PLAYWRIGHT_TRACE_PROVIDER, + type PlaywrightTrace, + type TraceMember, + type TraceStream, +} from "../../../domain/playwrightTrace/playwrightTrace.js"; +import type { FileSnapshotPartialObservation } from "../../../domain/fileSnapshotObservation.js"; +import { TraceInventory } from "./TraceInventory.js"; +import { readTraceStream } from "./TraceStream.js"; +import { traceFailure } from "./TraceFailure.js"; + +type SelectedMember = { entry: ArtifactEntry; member: TraceMember }; + +const readStreams = async ( + inventory: TraceInventory, + reader: ZipArtifactReader, + selected: SelectedMember[], + signal?: AbortSignal, +) => { + const budget = { decodedBytes: 0 }; + const profiles = new Map(); + for (const { entry, member } of selected.filter((row) => + row.member.path.endsWith(".trace"), + )) { + const stream = await readTraceStream({ + inventory, + reader, + entry, + member, + budget, + profileKnown: false, + ...(signal === undefined ? {} : { signal }), + }); + inventory.streams.push(stream); + const previous = profiles.get(member.path) ?? []; + previous.push(stream); + profiles.set(member.path, previous); + } + for (const { entry, member } of selected.filter((row) => + row.member.path.endsWith(".network"), + )) { + const expected = member.path.slice(0, -".network".length) + ".trace"; + const paired = profiles.get(expected) ?? []; + const known = + paired.length === 1 && + paired[0]?.version === 10 && + paired[0].state === "supported"; + inventory.streams.push( + await readTraceStream({ + inventory, + reader, + entry, + member, + budget, + profileKnown: known, + ...(signal === undefined ? {} : { signal }), + }), + ); + } +}; + +/** One invocation owns its verified identity and completed facts across finalization. */ +class TraceArchiveRead { + inventory: TraceInventory | undefined; + identity: FileSnapshotPartialObservation | undefined; + primary: unknown; + complete: PlaywrightTrace | undefined; + constructor( + readonly resources: ArtifactResourceScope, + readonly path: string, + readonly signal?: AbortSignal, + ) {} + + async run() { + try { + const report = await this.resources.run(() => + withArtifactRegularFile( + this.path, + this.resources, + this.signal, + (source) => this.readSource(source), + ), + ); + assertArtifactReadActive(this.signal); + return ok(report); + } catch (cause) { + const error = traceFailure( + this.primary ?? artifactFilePrimaryFacts(cause).cause, + this.path, + ); + const partial = + this.complete ?? + (this.inventory?.members.length + ? this.inventory.finish(true) + : this.identity); + if (partial !== undefined) error.retainPartialObservation(partial); + return err( + projectStableArtifactFailure( + cause, + error, + this.path, + PLAYWRIGHT_TRACE_PROVIDER.id, + operation, + ), + ); + } + } + + async readSource(source: StableRegularFileDescriptor) { + if (source.initial.size > PLAYWRIGHT_TRACE_LIMITS.archiveBytes) + throw new AnalysisResourceConstraintError( + operation, + "file-size", + "Trace archive exceeds the 256 MiB input allowance.", + { + input_bytes: source.initial.size, + max_archive_bytes: PLAYWRIGHT_TRACE_LIMITS.archiveBytes, + }, + ); + const hash = await hashStableRootArtifactHandle( + this.path, + source.handle, + source.initial, + this.signal, + ); + this.identity = { + kind: "file-snapshot-hash", + path: this.path, + sha256: hash.sha256, + bytes: hash.bytes, + }; + const facts = new TraceInventory({ + path: this.path, + sha256: hash.sha256, + bytes: hash.bytes, + }); + this.inventory = facts; + const reader = new ZipArtifactReader( + this.path, + "zip", + PLAYWRIGHT_TRACE_LIMITS.metadataBytes, + source, + ); + let outcome: + | { kind: "completed"; value: PlaywrightTrace } + | { kind: "failed"; cause: unknown }; + try { + const selected: SelectedMember[] = []; + for await (const entry of reader.entries(this.signal)) { + const member = facts.member(entry); + if (member.path.endsWith(".trace") || member.path.endsWith(".network")) + selected.push({ entry, member }); + } + if (selected.length === 0) + throw new AnalysisUnsupportedTargetError( + operation, + this.path, + "Archive contains no Playwright trace or network stream members.", + ); + if (!selected.some((row) => row.member.path.endsWith(".network"))) + facts.diagnostic( + null, + null, + "missing-network-member", + "No network stream was supplied; this does not establish absent traffic.", + ); + await readStreams(facts, reader, selected, this.signal); + await assertArtifactFileSelectedSource( + this.path, + source, + "Playwright trace", + ); + this.complete = facts.finish(); + outcome = { kind: "completed", value: this.complete }; + } catch (cause) { + this.primary = cause; + outcome = { kind: "failed", cause }; + } + const released = await this.resources.release({ + kind: "reader", + resource: `Playwright ZIP reader for ${this.path}`, + reader, + }); + if (outcome.kind === "completed" && this.signal?.aborted) { + const cancellation = new AnalysisCancelledError(operation, { + cause: this.signal.reason, + partialObservation: this.complete ?? this.identity, + }); + this.primary = cancellation; + outcome = { kind: "failed", cause: cancellation }; + } + if (released.kind === "failed") + throw this.resources.cleanupFailure( + outcome, + [released], + this.complete ?? this.identity, + ); + if (outcome.kind === "failed") throw outcome.cause; + return outcome.value; + } +} + +/** Offline decoder; failed cleanup stays with the caller's shared scope. */ +export class PlaywrightTraceDecoder implements PlaywrightTracePort { + #running = false; + constructor(readonly resources: ArtifactResourceScope) {} + async inspect(path: string, signal?: AbortSignal) { + if (signal?.aborted) + return err( + new AnalysisCancelledError(operation, { cause: signal.reason }), + ); + if (this.#running) + return err( + new AnalysisResourceConstraintError( + operation, + "capacity", + "One trace archive import is already active in this decoder.", + { maximum_concurrent_trace_imports: 1 }, + { + retryable: true, + remediationAction: + "Wait for the active import to finish, then retry. Queries against retained Evidence do not need another import.", + }, + ), + ); + this.#running = true; + try { + return await new TraceArchiveRead(this.resources, path, signal).run(); + } finally { + this.#running = false; + } + } +} diff --git a/src/browser/history/playwrightTrace/PlaywrightTraceLifecycle.test.ts b/src/browser/history/playwrightTrace/PlaywrightTraceLifecycle.test.ts new file mode 100644 index 0000000000..3ad648df98 --- /dev/null +++ b/src/browser/history/playwrightTrace/PlaywrightTraceLifecycle.test.ts @@ -0,0 +1,264 @@ +import { createDeferred } from "../../../../tests/fixtures/binarySession.js"; +import { readFile, rename, copyFile } from "node:fs/promises"; +import { expect, it, vi } from "vitest"; +import { ArtifactResourceScope } from "../../../artifacts/ArtifactResourceScope.js"; +import { ZipArtifactReader } from "../../../artifacts/ZipArtifactReader.js"; +import { PlaywrightTraceDecoder } from "./PlaywrightTraceDecoder.js"; +import { PlaywrightTraceService } from "../../../application/playwrightTrace/PlaywrightTraceService.js"; +import { parseEvidence } from "../../../domain/evidence.js"; +import { playwrightTraceSchema } from "../../../domain/playwrightTrace/playwrightTrace.js"; +import { playwrightTraceFixture } from "../../../../tests/fixtures/playwrightTrace.js"; +import { OwnedFileHandle } from "../../../filesystem/OwnedFileHandle.js"; + +it("preserves only fully verified streams when cancellation arrives before the network member", async () => { + const fixture = await playwrightTraceFixture(); + const resources = new ArtifactResourceScope(); + const controller = new AbortController(); + const reason = { stage: "network-member" }; + const realOpen = ZipArtifactReader.prototype.open; + const opened = vi + .spyOn(ZipArtifactReader.prototype, "open") + .mockImplementation(function (this: ZipArtifactReader, entry, signal) { + if (entry.path.endsWith(".network")) controller.abort(reason); + return realOpen.call(this, entry, signal); + }); + try { + const result = await new PlaywrightTraceService( + new PlaywrightTraceDecoder(resources), + ).inspect({ path: fixture.path }, controller.signal); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe(reason); + const partial = playwrightTraceSchema.parse( + parseEvidence(result.error.partialObservation).normalized_result, + ); + expect(partial.summary.events).toBe(20); + expect(partial.summary.network).toBe(0); + expect(partial.source_coverage.interpretation).toBe("partial"); + expect(resources.hasPending).toBe(false); + } finally { + opened.mockRestore(); + await resources.close(); + } +}); + +it("retains complete Evidence and the exact failed reader owner when abort arrives during final cleanup", async () => { + const fixture = await playwrightTraceFixture(); + const bytes = await readFile(fixture.path); + const resources = new ArtifactResourceScope(); + const controller = new AbortController(); + const reason = { stage: "final-reader-cleanup" }; + const realClose = ZipArtifactReader.prototype.close; + const closed = vi + .spyOn(ZipArtifactReader.prototype, "close") + .mockImplementationOnce(async function (this: ZipArtifactReader) { + await realClose.call(this); + controller.abort(reason); + throw new Error("Injected reader close acknowledgement failure"); + }); + try { + const result = await new PlaywrightTraceService( + new PlaywrightTraceDecoder(resources), + ).inspect({ path: fixture.path }, controller.signal); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected composed failure"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupPrimary?._tag).toBe("AnalysisCancelledError"); + expect(result.error.cleanupPrimary?.cause).toBe(reason); + expect( + playwrightTraceSchema.parse( + parseEvidence(result.error.partialObservation).normalized_result, + ).summary.events, + ).toBe(23); + expect(resources.hasPending).toBe(true); + await resources.retryCleanup(); + expect(resources.hasPending).toBe(false); + expect(result.error.cleanupIncomplete).toBe(false); + expect( + parseEvidence(result.error.cleanupPrimary?.partialObservation).operation, + ).toBe("inspect_playwright_trace"); + expect((await readFile(fixture.path)).equals(bytes)).toBe(true); + } finally { + closed.mockRestore(); + await resources.close(); + } +}); + +it("keeps completed Evidence on cancellation during the outer descriptor close", async () => { + const fixture = await playwrightTraceFixture(); + const resources = new ArtifactResourceScope(); + const controller = new AbortController(); + const reason = { stage: "outer-descriptor-close" }; + const realClose = OwnedFileHandle.prototype.close; + const closed = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementationOnce(async function (this: OwnedFileHandle) { + await realClose.call(this); + controller.abort(reason); + }); + try { + const result = await new PlaywrightTraceService( + new PlaywrightTraceDecoder(resources), + ).inspect({ path: fixture.path }, controller.signal); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe(reason); + expect( + playwrightTraceSchema.parse( + parseEvidence(result.error.partialObservation).normalized_result, + ).summary.events, + ).toBe(23); + expect(resources.hasPending).toBe(false); + } finally { + closed.mockRestore(); + await resources.close(); + } +}); + +it("keeps an existing unsupported-version failure ahead of cancellation during cleanup", async () => { + const fixture = await playwrightTraceFixture({ + trace: '{"type":"context-options","version":11}\n', + network: "", + }); + const resources = new ArtifactResourceScope(); + const controller = new AbortController(); + const realClose = ZipArtifactReader.prototype.close; + const closed = vi + .spyOn(ZipArtifactReader.prototype, "close") + .mockImplementationOnce(async function (this: ZipArtifactReader) { + await realClose.call(this); + controller.abort("late cleanup abort"); + }); + try { + const result = await new PlaywrightTraceDecoder(resources).inspect( + fixture.path, + controller.signal, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected version refusal"); + expect(controller.signal.aborted).toBe(true); + expect(result.error._tag).toBe("AnalysisUnsupportedTargetError"); + expect(resources.hasPending).toBe(false); + } finally { + closed.mockRestore(); + await resources.close(); + } +}); + +it("retains independent reader and file owners until both cleanup retries complete", async () => { + const fixture = await playwrightTraceFixture(); + const resources = new ArtifactResourceScope(); + const realClose = ZipArtifactReader.prototype.close; + const reader = vi + .spyOn(ZipArtifactReader.prototype, "close") + .mockImplementationOnce(async function (this: ZipArtifactReader) { + await realClose.call(this); + throw new Error("Injected reader acknowledgement failure"); + }); + const descriptor = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementationOnce(async function (this: OwnedFileHandle) { + throw new Error("Injected descriptor close failure"); + }); + try { + const result = await new PlaywrightTraceService( + new PlaywrightTraceDecoder(resources), + ).inspect({ path: fixture.path }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected cleanup failure"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(resources.cleanupResources).toHaveLength(2); + expect( + playwrightTraceSchema.parse( + parseEvidence(result.error.partialObservation).normalized_result, + ).summary.events, + ).toBe(23); + await resources.retryCleanup(); + expect(resources.hasPending).toBe(false); + expect(result.error.cleanupIncomplete).toBe(false); + } finally { + reader.mockRestore(); + descriptor.mockRestore(); + await resources.close(); + } +}); + +it.skipIf(process.platform === "win32")( + "refuses source replacement without rebinding the archive identity", + async () => { + const fixture = await playwrightTraceFixture(); + const replacement = await playwrightTraceFixture({ + trace: '{"type":"context-options","version":10}\n', + network: "", + }); + const original = await readFile(fixture.path); + const resources = new ArtifactResourceScope(); + const held = fixture.path + ".held"; + const realOpen = ZipArtifactReader.prototype.open; + let replaced = false; + const opened = vi + .spyOn(ZipArtifactReader.prototype, "open") + .mockImplementation(async function ( + this: ZipArtifactReader, + entry, + signal, + ) { + if (entry.path.endsWith(".network") && !replaced) { + replaced = true; + await rename(fixture.path, held); + await copyFile(replacement.path, fixture.path); + } + return realOpen.call(this, entry, signal); + }); + try { + const result = await new PlaywrightTraceDecoder(resources).inspect( + fixture.path, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected source identity refusal"); + expect(result.error._tag).toBe("AnalysisArtifactChangedError"); + expect((await readFile(held)).equals(original)).toBe(true); + expect(resources.hasPending).toBe(false); + } finally { + opened.mockRestore(); + await resources.close(); + } + }, +); + +it("bounds active imports without an unbounded queue and releases admission after completion", async () => { + const fixture = await playwrightTraceFixture(); + const resources = new ArtifactResourceScope(); + const port = new PlaywrightTraceDecoder(resources); + const reached = createDeferred(), + release = createDeferred(); + const realRun = resources.run.bind(resources); + const running = vi + .spyOn(resources, "run") + .mockImplementationOnce( + async (operation: () => Promise): Promise => { + reached.resolve(); + await release.promise; + return realRun(operation); + }, + ); + const first = port.inspect(fixture.path); + try { + await reached.promise; + const second = await port.inspect(fixture.path); + expect(second.ok).toBe(false); + if (second.ok) throw new Error("Expected capacity refusal"); + expect(second.error._tag).toBe("AnalysisResourceConstraintError"); + release.resolve(); + expect((await first).ok).toBe(true); + expect((await port.inspect(fixture.path)).ok).toBe(true); + expect(resources.hasPending).toBe(false); + } finally { + release.resolve(); + await first; + running.mockRestore(); + await resources.close(); + } +}); diff --git a/src/browser/history/playwrightTrace/TraceEventProjection.ts b/src/browser/history/playwrightTrace/TraceEventProjection.ts new file mode 100644 index 0000000000..c0b2014293 --- /dev/null +++ b/src/browser/history/playwrightTrace/TraceEventProjection.ts @@ -0,0 +1,181 @@ +import type { JsonValue } from "../../../domain/jsonValue.js"; +import type { TraceEvent } from "../../../domain/playwrightTrace/playwrightTrace.js"; +import { CaptureRedaction, captureObject } from "../CaptureRedaction.js"; +import { projectRecordedHarEntry } from "../HarCapture.js"; + +const string = (value: unknown): string | null => + typeof value === "string" ? value : null; +const number = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; +const object = (value: unknown) => (captureObject(value) ? value : {}); + +/** Action payload scope is explicit; omitted arguments/results are not alleged secrets. */ +const actionFacts = ( + raw: Record, + redactor: CaptureRedaction, +) => { + const facts: Record = {}; + for (const key of [ + "type", + "callId", + "parentId", + "pageId", + "startTime", + "endTime", + "class", + "method", + "title", + ]) + if (Object.hasOwn(raw, key)) facts[key] = raw[key]; + const params = object(raw.params); + const selected: Record = {}; + if (Object.hasOwn(params, "selector")) selected.selector = params.selector; + if ( + (raw.class === "Frame" || raw.class === "Page") && + raw.method === "goto" && + typeof params.url === "string" + ) + selected.url = redactor.text(params.url, "/params/url", {}); + if (Object.keys(selected).length > 0) facts.params = selected; + if (Object.hasOwn(raw, "error")) { + const error = object(raw.error); + facts.error = captureObject(raw.error) + ? Object.fromEntries( + ["name", "message"] + .filter((key) => Object.hasOwn(error, key)) + .map((key) => [key, error[key]]), + ) + : raw.error; + } + return facts; +}; + +const selectedEvent = ( + raw: Record, + redactor: CaptureRedaction, +) => { + let kind: TraceEvent["kind"]; + let selected: unknown; + if (raw.type === "before" || raw.type === "after") { + if (typeof raw.callId !== "string") return undefined; + kind = raw.type === "before" ? "action-before" : "action-after"; + selected = actionFacts(raw, redactor); + } else if (raw.type === "console") { + kind = "console"; + const consoleFacts = Object.fromEntries( + ["type", "messageType", "text", "time", "pageId"] + .filter((key) => Object.hasOwn(raw, key)) + .map((key) => [key, raw[key]]), + ); + const location = object(raw.location); + if (captureObject(raw.location)) + Object.assign(consoleFacts, { + location: { + ...location, + ...(typeof location.url === "string" + ? { url: redactor.text(location.url, "/location/url", {}) } + : {}), + }, + }); + selected = consoleFacts; + } else if (raw.type === "resource-snapshot") { + kind = "network"; + if (!captureObject(raw.snapshot)) return undefined; + selected = raw.snapshot; + } else if (raw.type === "screencast-frame") { + kind = "screenshot"; + selected = Object.fromEntries( + [ + "type", + "pageId", + "file", + "width", + "height", + "timestamp", + "frameSwapWallTime", + ] + .filter((key) => Object.hasOwn(raw, key)) + .map((key) => [key, raw[key]]), + ); + } else return undefined; + return { kind, selected }; +}; + +/** Recognize only pinned v10 event meanings; no live browser attribution or clock alignment. */ +export const projectTraceEvent = ( + raw: Record, + identity: Pick, +): TraceEvent | undefined => { + const redactor = new CaptureRedaction([]); + const selected = selectedEvent(raw, redactor); + if (selected === undefined) return undefined; + const { kind } = selected; + const projected = projectRecordedHarEntry(selected.selected, []); + const facts = object(projected.reported); + const params = object(facts.params), + request = object(facts.request), + response = object(facts.response); + const error = object(facts.error); + const redactions = [...projected.redactions, ...redactor.redactions].map( + ({ pointer, reason }) => ({ pointer, reason }), + ); + const references: TraceEvent["resource_references"] = []; + const reference = (value: unknown, pointer: string) => { + if (typeof value === "string") + references.push({ + path: value, + source_pointer: pointer, + state: "missing", + member_id: null, + matching_members: 0, + }); + }; + if (kind === "screenshot") reference(facts.file, "/file"); + if (kind === "network") { + reference( + object(request.postData)._file, + "/snapshot/request/postData/_file", + ); + reference( + object(response.content)._file, + "/snapshot/response/content/_file", + ); + } + return { + ...identity, + kind, + call_id: string(facts.callId), + page_id: string(kind === "network" ? facts.pageref : facts.pageId), + time: number( + kind === "network" + ? facts._monotonicTime + : kind === "screenshot" + ? facts.timestamp + : kind === "action-before" + ? facts.startTime + : kind === "action-after" + ? facts.endTime + : facts.time, + ), + method: string(kind === "network" ? request.method : facts.method), + url: string(kind === "network" ? request.url : params.url), + status: number(response.status), + text: string( + kind === "console" + ? facts.text + : kind === "action-after" + ? error.message + : facts.title, + ), + selector: string(params.selector), + resource_references: references, + credential_redacted: redactions.some( + (row) => row.reason === "transport-credential", + ), + reported: projected.reported as JsonValue, + numeric_literals: projected.numeric_literals.map( + ({ pointer, literal }) => ({ pointer, literal }), + ), + redactions, + }; +}; diff --git a/src/browser/history/playwrightTrace/TraceFailure.ts b/src/browser/history/playwrightTrace/TraceFailure.ts new file mode 100644 index 0000000000..0cd225ca5e --- /dev/null +++ b/src/browser/history/playwrightTrace/TraceFailure.ts @@ -0,0 +1,78 @@ +import { AnalysisError } from "../../../domain/analysisErrorBase.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, +} from "../../../domain/analysisErrorCore.js"; +import { ArtifactOperationError } from "../../../domain/artifactOperationError.js"; +import { PLAYWRIGHT_TRACE_OPERATION as operation } from "../../../domain/playwrightTrace/playwrightTrace.js"; +import { ArtifactReaderFailure } from "../../../artifacts/ArtifactReader.js"; +import { artifactFileCancellationCause } from "../../../artifacts/ArtifactFileOperation.js"; +import { isSelectedRegularFileError } from "../../../filesystem/RegularFile.js"; +import { stableArtifactInputRecovery } from "../../../artifacts/StableArtifactFailure.js"; + +/** Classify the archive owner's facts without interpreting opaque cancellation reasons. */ +export const traceFailure = (cause: unknown, path: string): AnalysisError => { + if (cause instanceof AnalysisError) return cause; + if (cause instanceof ArtifactReaderFailure) { + if (cause.reason === "cancelled") + return new AnalysisCancelledError(operation, { + cause: artifactFileCancellationCause(cause), + }); + if (cause.reason === "integrity") + return new AnalysisArtifactChangedError(operation, path, cause.message, { + cause, + }); + if (cause.reason === "limit") + return new AnalysisResourceConstraintError( + operation, + cause.boundary ?? "memory", + cause.message, + null, + { + cause, + remediationAction: + "Select a smaller complete recorder chunk. Reply selection does not increase archive parsing allowances.", + }, + ); + if (cause.reason === "format" || cause.reason === "path") + return new AnalysisInputError( + operation, + { cause, ...stableArtifactInputRecovery(cause) }, + [{ path: ["path"], reason: "invalid_format", message: cause.message }], + ); + return new ArtifactOperationError( + operation, + cause.reason, + undefined, + cause.message, + { cause }, + ); + } + if ( + cause instanceof Error && + "code" in cause && + (cause.code === "EACCES" || cause.code === "EPERM") + ) + return new AnalysisAccessDeniedError(operation, path, cause.code, { + cause, + }); + if (cause instanceof Error && isSelectedRegularFileError(cause)) + return new AnalysisInputError(operation, { cause }, [ + { + path: ["path"], + reason: "invalid_format", + message: + "Expected a readable regular trace archive without a symbolic link.", + }, + ]); + return new ArtifactOperationError( + operation, + "io", + undefined, + "Trace archive could not be read.", + { cause }, + ); +}; diff --git a/src/browser/history/playwrightTrace/TraceInventory.ts b/src/browser/history/playwrightTrace/TraceInventory.ts new file mode 100644 index 0000000000..1f23b612c7 --- /dev/null +++ b/src/browser/history/playwrightTrace/TraceInventory.ts @@ -0,0 +1,177 @@ +import { digestCanonicalValue } from "../../../domain/canonicalDigest.js"; +import { AnalysisResourceConstraintError } from "../../../domain/analysisErrorCore.js"; +import type { ArtifactEntry } from "../../../artifacts/ArtifactReader.js"; +import { + PLAYWRIGHT_TRACE_LIMITS, + PLAYWRIGHT_TRACE_OPERATION, + type PlaywrightTrace, + type TraceEvent, + type TraceMember, + type TraceStream, +} from "../../../domain/playwrightTrace/playwrightTrace.js"; + +export const traceIdentity = (prefix: string, value: unknown) => + `${prefix}_${digestCanonicalValue(value)}`; + +/** Retain only bounded producer facts; ZIP directory occurrences own member identity. */ +export class TraceInventory { + readonly members: TraceMember[] = []; + readonly streams: TraceStream[] = []; + readonly events: TraceEvent[] = []; + readonly diagnostics: PlaywrightTrace["diagnostics"] = []; + readonly #paths = new Map(); + #bytes = 0; + constructor(readonly artifact: PlaywrightTrace["artifact"]) {} + reserve(bytes: number): void { + this.#bytes += bytes; + if (this.#bytes > PLAYWRIGHT_TRACE_LIMITS.factsBytes) + throw new AnalysisResourceConstraintError( + PLAYWRIGHT_TRACE_OPERATION, + "memory", + "Trace facts exceed the 32 MiB retained representation allowance.", + { retained_fact_allowance: PLAYWRIGHT_TRACE_LIMITS.factsBytes }, + { + remediationAction: + "Select a smaller complete recorder chunk; fields and reply pagination do not increase parser allowances.", + }, + ); + } + retain(value: unknown): void { + this.reserve(512 + Buffer.byteLength(JSON.stringify(value)) * 4); + } + member(entry: ArtifactEntry): TraceMember { + const path = entry.sourcePath ?? entry.path; + this.reserve( + 1024 + + (path.length + + entry.path.length + + entry.limitations.reduce((sum, value) => sum + value.length, 0)) * + 12, + ); + const row: TraceMember = { + member_id: traceIdentity("trace_member", [ + this.artifact.sha256, + entry.adapterKey, + ]), + path, + logical_path: entry.path, + kind: entry.kind, + declared_bytes: entry.declaredSize, + declared_compressed_bytes: entry.compressedSize, + encrypted: entry.encrypted, + decoded: false, + limitations: [...entry.limitations], + }; + this.members.push(row); + const matches = this.#paths.get(row.path) ?? []; + if (matches.length > 0) + this.diagnostic( + row, + null, + "duplicate-member-path", + "Archive member names are ambiguous; directory occurrences keep distinct identity.", + ); + matches.push(row); + this.#paths.set(row.path, matches); + return row; + } + diagnostic( + member: TraceMember | null, + line: number | null, + code: string, + message: string, + ): void { + const row = { + diagnostic_id: traceIdentity("trace_diagnostic", [ + this.artifact.sha256, + member?.member_id ?? null, + line, + code, + this.diagnostics.length, + ]), + member_id: member?.member_id ?? null, + line, + code, + message, + }; + this.retain(row); + this.diagnostics.push(row); + } + /** References establish directory presence only; payload decoding and image validity stay unknown. */ + finish(incomplete = false): PlaywrightTrace { + const events = this.events.map((event) => ({ + ...event, + resource_references: event.resource_references.map((reference) => { + const matches = this.#paths.get(reference.path) ?? []; + const member = matches.length === 1 ? matches[0] : undefined; + const state: TraceEvent["resource_references"][number]["state"] = + matches.length === 0 + ? "missing" + : matches.length > 1 + ? "ambiguous" + : member?.kind !== "file" || member.encrypted + ? "unavailable" + : "present-unread"; + return { + ...reference, + state, + member_id: member?.member_id ?? null, + matching_members: matches.length, + }; + }), + })); + const unavailable = events.reduce( + (sum, event) => + sum + + event.resource_references.filter( + (reference) => reference.state !== "present-unread", + ).length, + 0, + ); + return { + kind: "playwright-trace", + trace_id: traceIdentity("playwright_trace", [this.artifact.sha256, 10]), + decoder_profile: "playwright-trace-v10", + artifact: this.artifact, + ordering: "per-stream-source-order-only", + members: this.members, + streams: this.streams, + events, + diagnostics: this.diagnostics, + summary: { + events: events.length, + action_before: events.filter((row) => row.kind === "action-before") + .length, + action_after: events.filter((row) => row.kind === "action-after") + .length, + console: events.filter((row) => row.kind === "console").length, + network: events.filter((row) => row.kind === "network").length, + screenshots: events.filter((row) => row.kind === "screenshot").length, + unavailable_references: unavailable, + }, + source_coverage: { + recording_completeness: "unknown", + interpretation: + incomplete || + this.diagnostics.length > 0 || + this.members.some((member) => member.limitations.length > 0) || + unavailable > 0 || + this.streams.some((row) => row.state !== "supported") + ? "partial" + : "scoped", + resource_contents: "not-decoded", + }, + limitations: [ + ...(incomplete + ? [ + "Import stopped before all selected members completed; only fully verified stream facts are included.", + ] + : []), + "Recorded producer declarations are not authenticated. The archive was read offline without browser launch, replay or requests to recorded URLs.", + "Order is preserved within each source member only; no total order or clock alignment is inferred across trace and network streams.", + "Only v10 action boundaries, console text/location, network metadata and screencast references are interpreted. Other event types, DOM, context options, most action arguments, results, stacks and resource contents are not analyzed. Original archive bytes remain the complete raw source.", + "Only decoded stream members have checked decompression/CRC. Resource reference presence does not establish readable content, valid images, observed runtime behavior or complete capture coverage.", + ], + }; + } +} diff --git a/src/browser/history/playwrightTrace/TraceLines.ts b/src/browser/history/playwrightTrace/TraceLines.ts new file mode 100644 index 0000000000..614beba8d3 --- /dev/null +++ b/src/browser/history/playwrightTrace/TraceLines.ts @@ -0,0 +1,78 @@ +import { setImmediate } from "node:timers/promises"; +import type { Readable } from "node:stream"; +import { assertArtifactReadActive } from "../../../artifacts/ArtifactFileOperation.js"; +import { AnalysisResourceConstraintError } from "../../../domain/analysisErrorCore.js"; +import { + PLAYWRIGHT_TRACE_LIMITS, + PLAYWRIGHT_TRACE_OPERATION, +} from "../../../domain/playwrightTrace/playwrightTrace.js"; + +export interface TraceLineBudget { + decodedBytes: number; +} +/** Buffer one physical line; do not decode a partial UTF-8 sequence or split records by characters. */ +export async function* traceLines( + stream: Readable, + budget: TraceLineBudget, + signal?: AbortSignal, +): AsyncGenerator<{ line: number; bytes: Buffer }> { + let fragments: Buffer[] = []; + let pendingBytes = 0; + let line = 0; + const append = (part: Buffer) => { + pendingBytes += part.length; + if (pendingBytes > PLAYWRIGHT_TRACE_LIMITS.lineBytes) + throw new AnalysisResourceConstraintError( + PLAYWRIGHT_TRACE_OPERATION, + "memory", + "A trace line exceeds the 4 MiB parser representation allowance.", + { max_line_bytes: PLAYWRIGHT_TRACE_LIMITS.lineBytes }, + { + remediationAction: + "Select a smaller recorder chunk; changing reply pagination does not change parsing allowances.", + }, + ); + fragments.push(part); + }; + for await (const chunk of stream) { + assertArtifactReadActive(signal); + if (!Buffer.isBuffer(chunk)) + throw new TypeError("ZIP trace stream returned non-byte data"); + budget.decodedBytes += chunk.length; + if (budget.decodedBytes > PLAYWRIGHT_TRACE_LIMITS.decodedBytes) + throw new AnalysisResourceConstraintError( + PLAYWRIGHT_TRACE_OPERATION, + "memory", + "Decoded trace streams exceed the 32 MiB analysis allowance.", + { max_decoded_bytes: PLAYWRIGHT_TRACE_LIMITS.decodedBytes }, + { + remediationAction: + "Select a smaller complete recorder chunk; binary resource payloads are not decoded by this tool.", + }, + ); + let start = 0; + for (;;) { + const end = chunk.indexOf(10, start); + if (end === -1) { + if (start < chunk.length) append(chunk.subarray(start)); + break; + } + append(chunk.subarray(start, end)); + const bytes = + fragments.length === 1 + ? fragments[0]! + : Buffer.concat(fragments, pendingBytes); + line++; + yield { line, bytes }; + fragments = []; + pendingBytes = 0; + start = end + 1; + if (line % 128 === 0) { + await setImmediate(); + assertArtifactReadActive(signal); + } + } + } + if (pendingBytes > 0) + yield { line: line + 1, bytes: Buffer.concat(fragments, pendingBytes) }; +} diff --git a/src/browser/history/playwrightTrace/TraceStream.ts b/src/browser/history/playwrightTrace/TraceStream.ts new file mode 100644 index 0000000000..ba5b5905a5 --- /dev/null +++ b/src/browser/history/playwrightTrace/TraceStream.ts @@ -0,0 +1,278 @@ +import { getHeapStatistics } from "node:v8"; +import { LosslessNumber, isSafeNumber } from "lossless-json"; +import type { + ArtifactEntry, + ArtifactReader, +} from "../../../artifacts/ArtifactReader.js"; +import { + AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, +} from "../../../domain/analysisErrorCore.js"; +import { + PLAYWRIGHT_TRACE_OPERATION, + PLAYWRIGHT_TRACE_LIMITS, + type TraceMember, + type TraceStream, + type TraceEvent, +} from "../../../domain/playwrightTrace/playwrightTrace.js"; +import { captureObject } from "../CaptureRedaction.js"; +import { CaptureFormatError } from "../CaptureFormatError.js"; +import { parseCaptureJson } from "../CaptureJson.js"; +import { TraceInventory, traceIdentity } from "./TraceInventory.js"; +import { traceLines, type TraceLineBudget } from "./TraceLines.js"; +import { projectTraceEvent } from "./TraceEventProjection.js"; + +const text = (value: unknown) => (typeof value === "string" ? value : null); +const number = (value: unknown) => + value instanceof LosslessNumber && isSafeNumber(value.value) + ? Number(value.value) + : null; + +const readRecord = (bytes: Buffer): unknown => { + const required = 32 * 1024 * 1024 + bytes.length * 64; + if (required > getHeapStatistics().total_available_size) + throw new AnalysisResourceConstraintError( + PLAYWRIGHT_TRACE_OPERATION, + "memory", + "Insufficient heap headroom for trace record expansion.", + { input_bytes: bytes.length, required_headroom_bytes: required }, + ); + let value: string; + try { + value = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + bytes, + ); + } catch (cause) { + throw new CaptureFormatError( + "format", + "Trace line is not valid UTF-8.", + "", + cause, + ); + } + return parseCaptureJson( + value, + "Playwright trace", + PLAYWRIGHT_TRACE_LIMITS.factsBytes / 256, + ); +}; + +const hasPartialHeadline = (event: TraceEvent): boolean => + (event.kind === "network" && + (event.method === null || event.url === null || event.status === null)) || + (event.kind === "console" && event.text === null) || + (event.kind === "action-before" && event.method === null) || + (event.kind === "screenshot" && event.resource_references.length === 0); + +interface TraceStreamInput { + readonly inventory: TraceInventory; + readonly reader: ArtifactReader; + readonly entry: ArtifactEntry; + readonly member: TraceMember; + readonly budget: TraceLineBudget; + readonly profileKnown: boolean; + readonly signal?: AbortSignal; +} + +/** A member's facts remain provisional until its ZIP stream verifies CRC and size. */ +class TraceStreamRead { + readonly stream: TraceStream; + readonly pending: TraceEvent[] = []; + readonly diagnostics: { line: number; code: string; message: string }[] = []; + readonly omitted = new Map(); + #headerSeen = false; + constructor(readonly input: TraceStreamInput) { + const { member, profileKnown } = input; + this.stream = { + stream_id: traceIdentity("trace_stream", member.member_id), + member_id: member.member_id, + role: member.path.endsWith(".network") ? "network" : "trace", + version: profileKnown ? 10 : null, + state: profileKnown ? "supported" : "unknown", + lines: 0, + supported_events: 0, + omitted_event_types: [], + producer: null, + }; + } + diagnostic(line: number, code: string, message: string): void { + this.input.inventory.reserve(1024); + this.diagnostics.push({ line, code, message }); + } + header(raw: unknown, line: number): void { + const { inventory } = this.input; + this.#headerSeen = true; + if (!captureObject(raw) || raw.type !== "context-options") { + this.diagnostic( + line, + "missing-context-header", + "A trace member has no initial context-options record; its event version stays unknown.", + ); + return; + } + const version = number(raw.version); + if (version !== null && version !== 10) + throw new AnalysisUnsupportedTargetError( + PLAYWRIGHT_TRACE_OPERATION, + inventory.artifact.path, + "Only Playwright trace version 10 is supported.", + ); + if (version !== 10) { + this.diagnostic( + line, + "unknown-version", + "The context header does not declare numeric trace version 10.", + ); + return; + } + this.stream.version = 10; + this.stream.state = "supported"; + this.stream.producer = { + playwright_version: text(raw.playwrightVersion), + browser_name: text(raw.browserName), + origin: text(raw.origin), + }; + inventory.retain(this.stream.producer); + } + record(line: { line: number; bytes: Buffer }): void { + const { inventory, member } = this.input; + let raw: unknown; + try { + raw = readRecord(line.bytes); + } catch (cause) { + if (!(cause instanceof CaptureFormatError)) throw cause; + if (cause.reason === "input-limit") + throw new AnalysisResourceConstraintError( + PLAYWRIGHT_TRACE_OPERATION, + "memory", + cause.message, + { parser_nodes: PLAYWRIGHT_TRACE_LIMITS.factsBytes / 256 }, + { cause }, + ); + this.diagnostic(line.line, "malformed-record", cause.message); + this.#headerSeen = true; + return; + } + if (this.stream.role === "trace" && !this.#headerSeen) { + this.header(raw, line.line); + return; + } + if (!captureObject(raw) || typeof raw.type !== "string") { + this.diagnostic( + line.line, + "malformed-record", + "An event must be an object with a type string.", + ); + return; + } + if (raw.type === "context-options") { + this.stream.state = "unknown"; + this.diagnostic( + line.line, + "duplicate-context-header", + "Repeated context headers make the remaining stream version unknown; later events are not interpreted.", + ); + return; + } + let event: TraceEvent | undefined; + try { + event = projectTraceEvent(raw, { + event_id: traceIdentity("trace_event", [member.member_id, line.line]), + stream_id: this.stream.stream_id, + ordinal: line.line - 1, + source: { + member_id: member.member_id, + line: line.line, + record_pointer: raw.type === "resource-snapshot" ? "/snapshot" : "", + }, + }); + } catch (cause) { + if (!(cause instanceof CaptureFormatError)) throw cause; + this.diagnostic(line.line, "malformed-record", cause.message); + return; + } + if (event !== undefined) { + inventory.reserve(event.resource_references.length * 512); + inventory.retain(event); + this.pending.push(event); + const partial = hasPartialHeadline(event); + if (partial) + this.diagnostic( + line.line, + "partial-event", + "Selected headline fields are unavailable; known facts remain retained without assuming absent values.", + ); + return; + } + if (!this.omitted.has(raw.type)) + inventory.reserve(512 + raw.type.length * 12); + this.omitted.set(raw.type, (this.omitted.get(raw.type) ?? 0) + 1); + if ( + [ + "before", + "after", + "console", + "resource-snapshot", + "screencast-frame", + ].includes(raw.type) + ) + this.diagnostic( + line.line, + "malformed-event", + "A supported event is missing its required payload identity.", + ); + } + async read(): Promise { + const { inventory, reader, entry, member, budget, signal } = this.input; + if (entry.kind !== "file" || entry.encrypted) { + this.stream.state = "unavailable"; + inventory.diagnostic( + member, + null, + "unavailable-stream", + "A selected stream is encrypted or is not a regular archive member.", + ); + return this.stream; + } + const content = await reader.open(entry, signal); + for await (const line of traceLines(content, budget, signal)) { + this.stream.lines = line.line; + if (line.bytes.length === 0) continue; + if (this.stream.role === "network" && this.stream.state !== "supported") + continue; + if ( + this.stream.role === "trace" && + this.#headerSeen && + this.stream.state !== "supported" + ) + continue; + this.record(line); + } + member.decoded = true; + if (this.stream.version === null) + inventory.diagnostic( + member, + null, + "unknown-stream-version", + "This stream has no unambiguous v10 trace profile; its events were not interpreted.", + ); + for (const diagnostic of this.diagnostics) + inventory.diagnostic( + member, + diagnostic.line, + diagnostic.code, + diagnostic.message, + ); + this.stream.supported_events = this.pending.length; + this.stream.omitted_event_types = Array.from( + this.omitted, + ([type, count]) => ({ type, count }), + ); + inventory.retain(this.stream); + for (const event of this.pending) inventory.events.push(event); + return this.stream; + } +} + +export const readTraceStream = (input: TraceStreamInput) => + new TraceStreamRead(input).read(); diff --git a/src/cli/applicationCommands.ts b/src/cli/applicationCommands.ts index fea2774ae0..5db46d0593 100644 --- a/src/cli/applicationCommands.ts +++ b/src/cli/applicationCommands.ts @@ -1,3 +1,4 @@ +import { registerPlaywrightTraceCommand } from "./playwrightTraceCommand.js"; import { registerApiContractCommand } from "./apiContractCommand.js"; import { Cli, z } from "incur"; @@ -76,6 +77,7 @@ export const registerApplicationCommands = ( registerSourceToBundleCommand(cli, logger); registerStaticObservedEndpointsCommand(cli, logger); registerApiContractCommand(cli, logger); + registerPlaywrightTraceCommand(cli, logger); registerEvidenceJsonCommand({ cli, logger, diff --git a/src/cli/playwrightTraceCommand.ts b/src/cli/playwrightTraceCommand.ts new file mode 100644 index 0000000000..6bf6aae62f --- /dev/null +++ b/src/cli/playwrightTraceCommand.ts @@ -0,0 +1,107 @@ +import { z } from "incur"; +import type { Logger } from "pino"; +import type { CliInstance } from "./types.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { logCliCommand } from "../cliLogging.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { projectArtifactCloseFailure } from "../artifacts/StableArtifactFailure.js"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; +import { localPathStringSchema } from "../domain/localPath.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { + PLAYWRIGHT_TRACE_OPERATION as operation, + PLAYWRIGHT_TRACE_PROVIDER, +} from "../domain/playwrightTrace/playwrightTrace.js"; +import { playwrightTraceViewRequestSchema } from "../domain/playwrightTrace/playwrightTraceQuery.js"; +import { withCommandCancellation } from "./commandCancellation.js"; +import { + analysisEvidenceDeliveryOptions, + parseCliAnalysisEvidenceDelivery, + deliverCliAnalysisEvidence, +} from "./analysisEvidenceDelivery.js"; + +const deliverySchema = z + .object({ + detail: analysisEvidenceDeliveryOptions.detail, + view: playwrightTraceViewRequestSchema.optional(), + }) + .superRefine((value, context) => { + if (value.view !== undefined && value.detail !== "summary") + context.addIssue({ + code: "custom", + path: ["view"], + message: + "A selected page requires --detail summary and --evidence_output.", + }); + }); + +/** Standard one-shot publication keeps complete inventory Evidence for later offline pages. */ +export const registerPlaywrightTraceCommand = ( + cli: CliInstance, + logger: Logger, +): void => { + cli.command(CLI_COMMANDS.inspectPlaywrightTrace, { + description: + "Inventory local saved Playwright v10 trace ZIP events offline without launching a browser", + args: z.object({ + path: localPathStringSchema.describe("Local Playwright trace ZIP path"), + }), + options: z.object({ + ...analysisEvidenceDeliveryOptions, + view: z + .string() + .optional() + .describe( + "JSON playwright-trace page; requires --detail summary and --evidence_output", + ), + }), + run: ({ args, options }) => + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.inspectPlaywrightTrace, async () => { + const selected = parseCliAnalysisEvidenceDelivery( + operation, + options, + deliverySchema, + ); + if (!selected.ok) return projectAnalysisError(selected.error); + const resources = new ArtifactResourceScope(); + const { PlaywrightTraceService } = + await import("../application/playwrightTrace/PlaywrightTraceService.js"); + const { PlaywrightTraceDecoder } = + await import("../browser/history/playwrightTrace/PlaywrightTraceDecoder.js"); + const service = new PlaywrightTraceService( + new PlaywrightTraceDecoder(resources), + ); + return deliverCliAnalysisEvidence({ + callerInputPath: args.path, + run: () => + service.inspect( + { + path: absoluteCallerPath(args.path), + detail: "complete", + }, + signal, + ), + service: { + close: () => + resources.close().catch((cause: unknown) => { + throw projectArtifactCloseFailure( + cause, + PLAYWRIGHT_TRACE_PROVIDER.id, + operation, + ); + }), + }, + providerId: PLAYWRIGHT_TRACE_PROVIDER.id, + operation, + signal, + detail: selected.value.detail, + evidenceOutput: selected.value.evidenceOutput, + ...(selected.value.view === undefined + ? {} + : { view: selected.value.view }), + }); + }), + ), + }); +}; diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 626570fbcc..6f172de2a0 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -69,6 +69,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectIl2cppMetadata: "inspect-il2cpp-metadata", inspectModelArtifact: "inspect-model-artifact", inspectApiContract: "inspect-api-contract", + inspectPlaywrightTrace: "inspect-playwright-trace", inspectGpuCodeObjects: "inspect-gpu-code-objects", inspectRustBinary: "inspect-rust-binary", inspectPythonPyc: "inspect-python-pyc", diff --git a/src/contracts/applicationToolContracts.ts b/src/contracts/applicationToolContracts.ts index fb48b71608..70be78ccca 100644 --- a/src/contracts/applicationToolContracts.ts +++ b/src/contracts/applicationToolContracts.ts @@ -1,3 +1,4 @@ +import { PLAYWRIGHT_TRACE_TOOL_CONTRACTS } from "./playwrightTraceToolContracts.js"; import { API_CONTRACT_TOOL_CONTRACTS } from "./apiContractToolContracts.js"; import { applicationInventoryProjectionInputSchema } from "../domain/mobileApplicationGraphProjection.js"; import { z } from "zod"; @@ -94,6 +95,7 @@ const retained = (evidence_id: string) => ({ /** Provider-neutral graph workflow contracts shared by MCP and CLI adapters. */ export const APPLICATION_TOOL_CONTRACTS = [ ...API_CONTRACT_TOOL_CONTRACTS, + ...PLAYWRIGHT_TRACE_TOOL_CONTRACTS, { name: "search_guidance", ...toolContractMetadata("search_guidance"), diff --git a/src/contracts/playwrightTraceToolContracts.ts b/src/contracts/playwrightTraceToolContracts.ts new file mode 100644 index 0000000000..f0aa1cd472 --- /dev/null +++ b/src/contracts/playwrightTraceToolContracts.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; +import { playwrightTraceSchema } from "../domain/playwrightTrace/playwrightTrace.js"; +import { inspectPlaywrightTraceInputSchema } from "../domain/playwrightTrace/playwrightTraceQuery.js"; +import { + playwrightTraceSummaryViewResultSchema, + playwrightTracePageViewResultSchema, +} from "../domain/analysisView/analysisView.js"; +import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js"; +import { toolContractMetadata } from "./toolEffects.js"; +import type { ToolContract } from "./toolContractTypes.js"; + +export const PLAYWRIGHT_TRACE_TOOL_CONTRACTS = [ + { + name: "inspect_playwright_trace", + ...toolContractMetadata("inspect_playwright_trace"), + kind: "application", + description: + "Inspect a caller-selected Playwright v10 trace ZIP offline. No browser, replay or recorded-URL requests. Retains action boundaries, console text, network facts and screenshot/body references with archive/member/line identity. No cross-stream total order or complete-capture claim. Credentials use existing HAR/URL projection; context options, most action arguments/results, DOM and resource bytes are not analyzed. Default summary retains complete supported Evidence; view selects events/streams/members/diagnostics with batch IDs, fields and paging. Default text previews cover 256 UTF-16 units; event-text reads further exact text chunks and fields text returns the complete value. Archive/decoded-stream/fact allowances are 256/32/32 MiB; individual lines 4 MiB with bounded JSON node admission. One archive import is admitted at a time per decoder; retained views remain independent. Paging does not increase parsing allowances.", + inputSchema: inspectPlaywrightTraceInputSchema, + outputSchema: evidenceResultOf( + z.union([ + playwrightTraceSchema, + playwrightTraceSummaryViewResultSchema, + playwrightTracePageViewResultSchema, + ]), + ), + examples: [ + { + title: "Read recorded network headlines without contacting endpoints", + input: { + path: "/captures/trace.zip", + view: { + kind: "playwright-trace", + collection: "events", + kinds: ["network"], + limit: 10, + }, + }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 9793726836..da51658922 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -159,6 +159,7 @@ const TOOL_EFFECT_AUDIT: Readonly> = { inspect_il2cpp_metadata: evidence, inspect_model_artifact: evidence, inspect_api_contract: evidence, + inspect_playwright_trace: evidence, inspect_gpu_code_objects: evidence, inspect_rust_binary: evidence, inspect_apk_signature: evidence, diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index 34b9c26f9c..b504c09fc8 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -1,3 +1,4 @@ +import type { PlaywrightTrace } from "./playwrightTrace/playwrightTrace.js"; import type { SavedAnalysisSnapshotPartialObservation } from "./savedAnalysisSnapshot.js"; import type { AnalysisSettingLimit } from "./analysisSettings.js"; import type { JsonValue } from "./jsonValue.js"; @@ -400,6 +401,7 @@ import type { WebArtifactAcquisition } from "./webArtifactAcquisition.js"; /** Provider-neutral evidence collected before a typed analysis failure. */ export type AnalysisPartialObservation = + | PlaywrightTrace | SavedAnalysisSnapshotPartialObservation | WebArtifactAcquisition | WebTraceObservation diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index e675d686de..f8e59610e7 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -1,3 +1,4 @@ +import { projectPlaywrightTraceView } from "./playwrightTraceView.js"; import { projectApiContractView } from "./apiContractView.js"; import { isNativeObservationOperation, @@ -159,6 +160,8 @@ import { sourceToBundlePageViewResultSchema, endpointComparisonSummaryViewResultSchema, apiContractSummaryViewResultSchema, + playwrightTraceSummaryViewResultSchema, + playwrightTracePageViewResultSchema, apiContractPageViewResultSchema, endpointComparisonPageViewResultSchema, featureTraceSummaryViewResultSchema, @@ -232,6 +235,8 @@ export { sourceToBundlePageViewResultSchema, endpointComparisonSummaryViewResultSchema, apiContractSummaryViewResultSchema, + playwrightTraceSummaryViewResultSchema, + playwrightTracePageViewResultSchema, apiContractPageViewResultSchema, endpointComparisonPageViewResultSchema, featureTraceSummaryViewResultSchema, @@ -362,6 +367,7 @@ const retainedViewProjectors = new Map([ retain("match_native_functions", projectNativeFunctionMatchingView), retain("compare_source_to_bundle", projectSourceToBundleView), retain("inspect_api_contract", projectApiContractView), + retain("inspect_playwright_trace", projectPlaywrightTraceView), retain( "compare_static_and_observed_endpoints", projectEndpointComparisonView, diff --git a/src/domain/analysisView/analysisViewRequest.ts b/src/domain/analysisView/analysisViewRequest.ts index 93e1f17fde..b9ef686368 100644 --- a/src/domain/analysisView/analysisViewRequest.ts +++ b/src/domain/analysisView/analysisViewRequest.ts @@ -1,3 +1,4 @@ +import { playwrightTraceViewRequestSchema } from "../playwrightTrace/playwrightTraceQuery.js"; import { apiContractViewRequestSchema } from "../apiContract/apiContractQuery.js"; import { nativeObservationViewRequestSchema, @@ -76,6 +77,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ sourceToBundleViewRequestSchema, endpointComparisonViewRequestSchema, apiContractViewRequestSchema, + playwrightTraceViewRequestSchema, featureTraceViewRequestSchema, nativeFeatureTraceViewRequestSchema, batchDecompileViewRequestSchema, diff --git a/src/domain/analysisView/analysisViewResult.ts b/src/domain/analysisView/analysisViewResult.ts index b8f9a29851..eadb67db30 100644 --- a/src/domain/analysisView/analysisViewResult.ts +++ b/src/domain/analysisView/analysisViewResult.ts @@ -1,3 +1,8 @@ +import { playwrightTraceViewRequestSchema } from "../playwrightTrace/playwrightTraceQuery.js"; +import { + playwrightTraceViewContextSchema, + playwrightTraceViewItemsSchema, +} from "./playwrightTraceView.js"; import { apiContractViewRequestSchema } from "../apiContract/apiContractQuery.js"; import { apiContractArtifactSchema } from "../apiContract/apiContract.js"; import { @@ -1028,6 +1033,19 @@ export const sourceToBundleSummaryViewResultSchema = z.strictObject({ parent_operation: z.literal("compare_source_to_bundle"), summary: sourceToBundleViewContextSchema, }); +export const playwrightTraceSummaryViewResultSchema = z.strictObject({ + ...summaryViewResultBase, + parent_operation: z.literal("inspect_playwright_trace"), + summary: playwrightTraceViewContextSchema, +}); +export const playwrightTracePageViewResultSchema = z.strictObject({ + ...viewResultBase, + ...playwrightTraceViewContextSchema.shape, + kind: z.literal("playwright-trace"), + parent_operation: z.literal("inspect_playwright_trace"), + view: playwrightTraceViewRequestSchema, + items: playwrightTraceViewItemsSchema, +}); export const apiContractSummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, parent_operation: z.literal("inspect_api_contract"), @@ -1169,6 +1187,7 @@ const machoPageViewResultSchema = z /** Projected facts for one selected view of retained analysis Evidence. */ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ + playwrightTracePageViewResultSchema, z.strictObject({ ...viewResultBase, ...nativeViewSourceContext, @@ -1285,6 +1304,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ sourceToBundleSummaryViewResultSchema, endpointComparisonSummaryViewResultSchema, apiContractSummaryViewResultSchema, + playwrightTraceSummaryViewResultSchema, featureTraceSummaryViewResultSchema, batchDecompileSummaryViewResultSchema, webBundleSummaryViewResultSchema, diff --git a/src/domain/analysisView/playwrightTraceView.ts b/src/domain/analysisView/playwrightTraceView.ts new file mode 100644 index 0000000000..b762c717c8 --- /dev/null +++ b/src/domain/analysisView/playwrightTraceView.ts @@ -0,0 +1,351 @@ +import { + analysisTextPage, + analysisTextPageItemSchema, +} from "./analysisViewText.js"; +import { z } from "zod"; +import { + playwrightTraceSchema, + traceEventSchema, + traceSourceSchema, + traceMemberSchema, + traceStreamSchema, + traceDiagnosticSchema, + PLAYWRIGHT_TRACE_OPERATION, + type PlaywrightTrace, +} from "../playwrightTrace/playwrightTrace.js"; +import { playwrightTraceViewRequestSchema } from "../playwrightTrace/playwrightTraceQuery.js"; +import type { JsonValue } from "../jsonValue.js"; +import { err, ok } from "../result.js"; +import { + createAnalysisViewResultReader, + type AnalysisViewParent, +} from "./analysisViewParent.js"; +import { + ownedView, + type AdmittedView, + type SummaryAnalysisView, +} from "./analysisViewShape.js"; +import { + analysisViewCoverageSchema, + boundedViewMessages, + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; +import { selectViewRows } from "./selectViewRows.js"; +import { analysisViewInputError } from "./analysisViewErrors.js"; + +export const playwrightTraceViewContextSchema = playwrightTraceSchema + .omit({ + streams: true, + members: true, + events: true, + diagnostics: true, + limitations: true, + }) + .extend({ + counts: z.strictObject({ + streams: z.int().nonnegative(), + members: z.int().nonnegative(), + diagnostics: z.int().nonnegative(), + }), + }); +export const playwrightTraceViewItemsSchema = z.union([ + z.array( + analysisTextPageItemSchema.extend({ + event_id: traceEventSchema.shape.event_id, + source: traceSourceSchema, + }), + ), + z.array( + traceEventSchema + .partial() + .required({ + event_id: true, + stream_id: true, + ordinal: true, + source: true, + kind: true, + credential_redacted: true, + }) + .extend({ + text_preview: analysisTextPageItemSchema + .extend({ coverage: analysisViewCoverageSchema }) + .nullable() + .optional(), + }), + ), + z.array( + traceMemberSchema.partial().required({ + member_id: true, + path: true, + logical_path: true, + kind: true, + encrypted: true, + decoded: true, + limitations: true, + }), + ), + z.array( + traceStreamSchema.partial().required({ + stream_id: true, + member_id: true, + role: true, + version: true, + state: true, + }), + ), + z.array( + traceDiagnosticSchema.partial().required({ + diagnostic_id: true, + member_id: true, + line: true, + code: true, + }), + ), +]); +const readReport = createAnalysisViewResultReader(playwrightTraceSchema); +const values = (selected: readonly string[] | undefined) => + selected === undefined ? undefined : new Set(selected); +const allows = ( + selection: ReadonlySet | undefined, + value: string | null, +) => selection === undefined || (value !== null && selection.has(value)); +const fields = (row: object, selected: readonly string[]) => + Object.fromEntries( + selected.map((key) => [key, (row as Record)[key]!]), + ); +type View = z.output; +const eventFields = ( + row: PlaywrightTrace["events"][number], + view: Extract, +) => { + const selected = view.fields?.filter((field) => field !== "text_preview") ?? [ + "call_id", + "page_id", + "time", + "method", + "url", + "status", + "selector", + "resource_references", + ]; + const preview = + view.fields === undefined || view.fields.includes("text_preview"); + let text_preview: + | (z.output & { + coverage: z.output; + }) + | null = null; + if (preview && row.text !== null) { + const page = analysisTextPage(row.text, 0, 256, "Recorded event text"); + if (!page.ok) throw page.error; + text_preview = { ...page.value.item, coverage: page.value.coverage }; + } + return { + ...fields(row, selected), + ...(preview ? { text_preview } : {}), + event_id: row.event_id, + stream_id: row.stream_id, + ordinal: row.ordinal, + source: row.source, + kind: row.kind, + credential_redacted: row.credential_redacted, + }; +}; + +const selectRows = ( + report: PlaywrightTrace, + view: Exclude, +) => { + if (view.collection === "events") { + const ids = values(view.event_ids), + streams = values(view.stream_ids), + kinds = values(view.kinds), + calls = values(view.call_ids); + return selectViewRows( + report.events, + view, + (row) => + allows(ids, row.event_id) && + allows(streams, row.stream_id) && + allows(kinds, row.kind) && + allows(calls, row.call_id), + (row) => eventFields(row, view), + ); + } + if (view.collection === "members") { + const ids = values(view.member_ids), + paths = values(view.paths); + return selectViewRows( + report.members, + view, + (row) => allows(ids, row.member_id) && allows(paths, row.path), + (row) => ({ + ...fields( + row, + view.fields ?? ["declared_bytes", "declared_compressed_bytes"], + ), + member_id: row.member_id, + path: row.path, + logical_path: row.logical_path, + kind: row.kind, + encrypted: row.encrypted, + decoded: row.decoded, + limitations: row.limitations, + }), + ); + } + if (view.collection === "streams") { + const ids = values(view.stream_ids); + return selectViewRows( + report.streams, + view, + (row) => allows(ids, row.stream_id), + (row) => ({ + ...fields( + row, + view.fields ?? ["producer", "lines", "supported_events"], + ), + stream_id: row.stream_id, + member_id: row.member_id, + role: row.role, + version: row.version, + state: row.state, + }), + ); + } + const codes = values(view.codes); + return selectViewRows( + report.diagnostics, + view, + (row) => allows(codes, row.code), + (row) => ({ + ...fields(row, view.fields ?? ["message"]), + diagnostic_id: row.diagnostic_id, + member_id: row.member_id, + line: row.line, + code: row.code, + }), + ); +}; +const selectEventText = ( + report: PlaywrightTrace, + view: Extract, +) => { + const event = report.events.find((row) => row.event_id === view.event_id); + if (event === undefined || event.text === null) + return err( + analysisViewInputError([ + { + path: ["view", "event_id"], + reason: "invalid_value", + message: + "Choose a retained event with interpreted text; events without a text value remain unknown.", + }, + ]), + ); + const page = analysisTextPage( + event.text, + view.offset, + view.limit, + "Recorded event text", + ); + if (!page.ok) return page; + return ok({ + items: [ + { ...page.value.item, event_id: event.event_id, source: event.source }, + ], + coverage: page.value.coverage, + }); +}; + +/** Counts do not materialize stream/member/event arrays into the initial agent response. */ +export const projectPlaywrightTraceView = ( + parent: AnalysisViewParent, + admitted: AdmittedView, +) => { + const view = ownedView( + admitted, + "summary", + "playwright-trace", + ); + if (view === undefined) + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: + "Playwright trace inventories support summary and playwright-trace pages.", + }, + ]), + ); + const parsed = readReport(parent); + if (!parsed.ok) return parsed; + const { + streams, + members, + events: _events, + diagnostics, + limitations, + ...metadata + } = parsed.value; + const context = { + ...metadata, + counts: { + streams: streams.length, + members: members.length, + diagnostics: diagnostics.length, + }, + }; + const shared = { + parent_evidence_id: parent.evidenceId, + parent_operation: PLAYWRIGHT_TRACE_OPERATION, + parent_digest: parent.evidenceId.slice(3), + limitations: boundedViewMessages( + [...parent.limitations, ...limitations], + parent.evidenceId, + "limitations", + ), + unknowns: [ + "Recording completeness and cross-stream ordering remain unknown. Resource contents are not decoded.", + ], + }; + if (view.kind === "summary") + return ok({ + ...shared, + kind: "summary" as const, + view, + artifact: { + path: context.artifact.path, + sha256: context.artifact.sha256, + }, + summary: context, + coverage: completeWithinViewCoverage(1, 1), + }); + if (view.collection === "event-text") { + const text = selectEventText(parsed.value, view); + return text.ok + ? ok({ + ...shared, + ...context, + kind: "playwright-trace" as const, + view, + ...text.value, + }) + : text; + } + const selected = selectRows(parsed.value, view); + return ok({ + ...shared, + ...context, + kind: "playwright-trace" as const, + view, + items: selected.rows, + coverage: pageViewCoverage( + view.offset, + selected.rows.length, + selected.total, + ), + }); +}; diff --git a/src/domain/artifactOperationError.ts b/src/domain/artifactOperationError.ts index 60e3e6965c..85b548d9b2 100644 --- a/src/domain/artifactOperationError.ts +++ b/src/domain/artifactOperationError.ts @@ -35,6 +35,7 @@ export class ArtifactOperationError extends AnalysisError { | "inspect_elf_notes" | "inspect_devicetree" | "inspect_api_contract" + | "inspect_playwright_trace" | "inspect_kernel_module" | "inspect_erlang_beam" | "extract_artifact" diff --git a/src/domain/playwrightTrace/playwrightTrace.ts b/src/domain/playwrightTrace/playwrightTrace.ts new file mode 100644 index 0000000000..12da1bf366 --- /dev/null +++ b/src/domain/playwrightTrace/playwrightTrace.ts @@ -0,0 +1,133 @@ +import { z } from "zod"; +import { digestSchema, prefixedDigestSchema } from "../digests.js"; +import { localPathStringSchema } from "../localPath.js"; +import { jsonValueSchema } from "../jsonValue.js"; + +export const PLAYWRIGHT_TRACE_OPERATION = "inspect_playwright_trace" as const; +export const PLAYWRIGHT_TRACE_PROVIDER = { + id: "rea-playwright-trace", + name: "REA offline Playwright trace inventory", + version: "1", +} as const; +/** Resource policies; these are not Playwright format limits. */ +export const PLAYWRIGHT_TRACE_LIMITS = { + archiveBytes: 256 * 1024 * 1024, + decodedBytes: 32 * 1024 * 1024, + factsBytes: 32 * 1024 * 1024, + metadataBytes: 4 * 1024 * 1024, + lineBytes: 4 * 1024 * 1024, +} as const; + +export const traceMemberSchema = z.strictObject({ + member_id: prefixedDigestSchema("trace_member"), + path: z.string().describe("Original ZIP member name reported by the reader"), + logical_path: z.string().describe("Separate reader-normalized logical path"), + kind: z.enum(["file", "directory", "symlink", "slice", "metadata"]), + declared_bytes: z.int().nonnegative().nullable(), + declared_compressed_bytes: z.int().nonnegative().nullable(), + encrypted: z.boolean(), + decoded: z.boolean(), + limitations: z.array(z.string()), +}); +export const traceSourceSchema = z.strictObject({ + member_id: prefixedDigestSchema("trace_member"), + line: z.int().positive(), + record_pointer: z.string(), +}); +export const traceResourceReferenceSchema = z.strictObject({ + path: z.string(), + source_pointer: z.string(), + state: z.enum(["present-unread", "missing", "ambiguous", "unavailable"]), + member_id: traceMemberSchema.shape.member_id.nullable(), + matching_members: z.int().nonnegative(), +}); +export const traceEventSchema = z.strictObject({ + event_id: prefixedDigestSchema("trace_event"), + stream_id: prefixedDigestSchema("trace_stream"), + ordinal: z.int().nonnegative(), + source: traceSourceSchema, + kind: z.enum([ + "action-before", + "action-after", + "console", + "network", + "screenshot", + ]), + call_id: z.string().nullable(), + page_id: z.string().nullable(), + time: z.number().finite().nullable(), + method: z.string().nullable(), + url: z.string().nullable(), + status: z.number().finite().nullable(), + text: z.string().nullable(), + selector: z.string().nullable(), + resource_references: z.array(traceResourceReferenceSchema), + credential_redacted: z.boolean(), + reported: jsonValueSchema, + numeric_literals: z.array( + z.strictObject({ pointer: z.string(), literal: z.string() }), + ), + redactions: z.array( + z.strictObject({ pointer: z.string(), reason: z.string() }), + ), +}); +export const traceDiagnosticSchema = z.strictObject({ + diagnostic_id: prefixedDigestSchema("trace_diagnostic"), + member_id: traceMemberSchema.shape.member_id.nullable(), + line: z.int().positive().nullable(), + code: z.string(), + message: z.string(), +}); +export const traceStreamSchema = z.strictObject({ + stream_id: traceEventSchema.shape.stream_id, + member_id: traceMemberSchema.shape.member_id, + role: z.enum(["trace", "network"]), + version: z.literal(10).nullable(), + state: z.enum(["supported", "unknown", "unavailable"]), + lines: z.int().nonnegative(), + supported_events: z.int().nonnegative(), + omitted_event_types: z.array( + z.strictObject({ type: z.string(), count: z.int().nonnegative() }), + ), + producer: z + .strictObject({ + playwright_version: z.string().nullable(), + browser_name: z.string().nullable(), + origin: z.string().nullable(), + }) + .nullable(), +}); +export const playwrightTraceSchema = z.strictObject({ + kind: z.literal("playwright-trace"), + trace_id: prefixedDigestSchema("playwright_trace"), + decoder_profile: z.literal("playwright-trace-v10"), + artifact: z.strictObject({ + path: localPathStringSchema, + sha256: digestSchema, + bytes: z.int().nonnegative(), + }), + ordering: z.literal("per-stream-source-order-only"), + streams: z.array(traceStreamSchema), + members: z.array(traceMemberSchema), + events: z.array(traceEventSchema), + diagnostics: z.array(traceDiagnosticSchema), + summary: z.strictObject({ + events: z.int().nonnegative(), + action_before: z.int().nonnegative(), + action_after: z.int().nonnegative(), + console: z.int().nonnegative(), + network: z.int().nonnegative(), + screenshots: z.int().nonnegative(), + unavailable_references: z.int().nonnegative(), + }), + source_coverage: z.strictObject({ + recording_completeness: z.literal("unknown"), + interpretation: z.enum(["scoped", "partial"]), + resource_contents: z.literal("not-decoded"), + }), + limitations: z.array(z.string()), +}); +export type PlaywrightTrace = z.output; +export type TraceEvent = z.output; +export type TraceMember = z.output; +export type TraceStream = z.output; diff --git a/src/domain/playwrightTrace/playwrightTraceQuery.ts b/src/domain/playwrightTrace/playwrightTraceQuery.ts new file mode 100644 index 0000000000..2f67b4a908 --- /dev/null +++ b/src/domain/playwrightTrace/playwrightTraceQuery.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; +import { localPathStringSchema } from "../localPath.js"; +import { + traceDiagnosticSchema, + traceEventSchema, + traceMemberSchema, + traceStreamSchema, +} from "./playwrightTrace.js"; +const page = { + kind: z.literal("playwright-trace"), + offset: z.int().nonnegative().default(0), + limit: z.int().positive().default(32), +}; +export const playwrightTraceViewRequestSchema = z.discriminatedUnion( + "collection", + [ + z.strictObject({ + ...page, + collection: z.literal("event-text"), + event_id: traceEventSchema.shape.event_id, + limit: z.int().positive().default(2048), + }), + z.strictObject({ + ...page, + collection: z.literal("streams"), + stream_ids: z.array(traceStreamSchema.shape.stream_id).optional(), + fields: z.array(traceStreamSchema.keyof()).optional(), + }), + z.strictObject({ + ...page, + collection: z.literal("events"), + event_ids: z.array(traceEventSchema.shape.event_id).optional(), + stream_ids: z.array(traceEventSchema.shape.stream_id).optional(), + kinds: z.array(traceEventSchema.shape.kind).optional(), + call_ids: z.array(z.string()).optional(), + fields: z + .array(z.enum([...traceEventSchema.keyof().options, "text_preview"])) + .optional(), + }), + z.strictObject({ + ...page, + collection: z.literal("members"), + member_ids: z.array(traceMemberSchema.shape.member_id).optional(), + paths: z + .array(z.string()) + .optional() + .describe("Exact original ZIP member names; logical_path is separate"), + fields: z.array(traceMemberSchema.keyof()).optional(), + }), + z.strictObject({ + ...page, + collection: z.literal("diagnostics"), + codes: z.array(z.string()).optional(), + fields: z.array(traceDiagnosticSchema.keyof()).optional(), + }), + ], +); +export const inspectPlaywrightTraceInputSchema = z.strictObject({ + path: localPathStringSchema.describe( + "Absolute path to an offline Playwright v10 trace ZIP", + ), + detail: z.enum(["summary", "complete"]).default("summary"), + view: playwrightTraceViewRequestSchema + .optional() + .describe( + "Return this selected page immediately; complete supported Evidence is retained", + ), +}); diff --git a/src/server/registerApplicationTools.ts b/src/server/registerApplicationTools.ts index dcdfa96465..4f7770c893 100644 --- a/src/server/registerApplicationTools.ts +++ b/src/server/registerApplicationTools.ts @@ -1,3 +1,4 @@ +import { registerPlaywrightTraceTool } from "./registerApplicationTools/playwrightTrace.js"; import { registerApiContractTool } from "./registerApplicationTools/apiContract.js"; import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; @@ -27,6 +28,7 @@ export const registerApplicationTools = ( registerCompareApplicationVersionsTool(server, options); registerCompareEndpointsTool(server, options); registerApiContractTool(server, options); + registerPlaywrightTraceTool(server, options); registerCompareSourceToBundleTool(server, options); registerCompareJavaScriptExportShapesTool(server, options); registerReconstructionObligationLedgerTool(server, options); diff --git a/src/server/registerApplicationTools/playwrightTrace.ts b/src/server/registerApplicationTools/playwrightTrace.ts new file mode 100644 index 0000000000..279ff9cd71 --- /dev/null +++ b/src/server/registerApplicationTools/playwrightTrace.ts @@ -0,0 +1,59 @@ +import { PlaywrightTraceDecoder } from "../../browser/history/playwrightTrace/PlaywrightTraceDecoder.js"; +import type { EvidenceMcpServer } from "../EvidenceMcpServer.js"; +import { runAdmittedToolOperation } from "../admittedToolOperation.js"; +import { requireAnalysisViewRetention } from "../../application/analysisView/AnalysisViewService.js"; +import { PlaywrightTraceService } from "../../application/playwrightTrace/PlaywrightTraceService.js"; +import { toolContract } from "../../contracts/toolContracts.js"; +import { toolRegistrationOptions } from "../toolRegistrationOptions.js"; +import { logToolExecution } from "../toolLogging.js"; +import { deliverApplicationEvidence } from "./helpers.js"; +import type { ApplicationToolRegistration } from "./types.js"; + +const contract = toolContract("inspect_playwright_trace"); + +/** Reads use the server's existing artifact resource owner and Evidence session. */ +export const registerPlaywrightTraceTool = ( + server: EvidenceMcpServer, + options: ApplicationToolRegistration, +): void => { + const service = new PlaywrightTraceService( + new PlaywrightTraceDecoder(options.artifactResources), + ); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + async (input, context) => + runAdmittedToolOperation( + server, + options.withAdmittedAnalysis, + contract.name, + context.mcpReq.signal, + async () => { + if (input.detail === "summary" || input.view !== undefined) { + const retention = requireAnalysisViewRetention( + contract.name, + options, + ); + if (!retention.ok) + return server.delivery.toCallToolResult(retention, contract); + } + const result = await logToolExecution( + options.logger, + contract.name, + () => service.inspect(input, context.mcpReq.signal), + ); + if (!result.ok) + return server.delivery.toCallToolResult(result, contract); + return deliverApplicationEvidence( + server, + options, + contract, + { evidence: result.value, sources: [] }, + input.view ?? + (input.detail === "summary" ? { kind: "summary" } : undefined), + context.mcpReq.signal, + ); + }, + ), + ); +}; diff --git a/tests/acceptance/applications/playwrightTrace.test.ts b/tests/acceptance/applications/playwrightTrace.test.ts new file mode 100644 index 0000000000..1e045cbba6 --- /dev/null +++ b/tests/acceptance/applications/playwrightTrace.test.ts @@ -0,0 +1,433 @@ +import { readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, onTestFinished } from "vitest"; +import { createServer } from "node:http"; +import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; +import { + playwrightTraceFixture, + playwrightTracePathFixture, +} from "../../fixtures/playwrightTrace.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js"; +import { parseEvidence, type Evidence } from "../../../src/domain/evidence.js"; +import { playwrightTraceSchema } from "../../../src/domain/playwrightTrace/playwrightTrace.js"; +import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; + +type McpCall = Awaited>["call"]; + +cliTest( + "preserves original ZIP paths across CLI/MCP, resource lookup and exact member filters", + async ({ cli }) => { + const fixture = await playwrightTracePathFixture(); + const original = await readFile(fixture.path); + const { call } = await connectLocalToolsMcp({ + REA_MCP_TOOL_FAMILIES: "application,session", + PLAYWRIGHT_BROWSERS_PATH: "/rea-no-browser-required", + }); + const parent = parseEvidence( + ( + await call("inspect_playwright_trace", { + path: fixture.path, + detail: "complete", + }) + ).structuredContent, + ); + const report = playwrightTraceSchema.parse(parent.normalized_result); + expect(report.events).toHaveLength(4); + expect(report.summary.unavailable_references).toBe(0); + expect(report.streams.every((stream) => stream.state === "supported")).toBe( + true, + ); + const view = { + kind: "playwright-trace", + collection: "members", + paths: ["./resources/x"], + fields: [], + }; + const selected = parseEvidence( + ( + await call("inspect_analysis_view", { + source: { + kind: "retained-evidence", + evidence_id: parent.evidence_id, + }, + view, + }) + ).structuredContent, + ); + expect(selected.normalized_result).toMatchObject({ + kind: "playwright-trace", + items: [ + { + path: "./resources/x", + logical_path: "resources/x", + limitations: [expect.stringContaining("leading current-directory")], + }, + ], + coverage: { total: 1, exhausted: true }, + source_coverage: { interpretation: "partial" }, + }); + expect( + (await call("inspect_playwright_trace", { path: fixture.path, view })) + .structuredContent, + ).toEqual(selected); + const run = cli.sharedCliRun?.bind(cli) ?? cli.run.bind(cli); + const result = await run({ + arguments: ["inspect-playwright-trace", fixture.path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseEvidence(result.json)).toEqual({ + ...parent, + caller_input_path: fixture.path, + }); + expect((await readFile(fixture.path)).equals(original)).toBe(true); + }, +); + +const checkCliPublication = async ({ + cli, + fixture, + parent, + selected, + view, + call, +}: { + cli: TestCli; + fixture: Awaited>; + parent: Evidence; + selected: Evidence; + view: Record; + call: McpCall; +}) => { + const source = { kind: "retained-evidence", evidence_id: parent.evidence_id }; + const run = cli.sharedCliRun?.bind(cli) ?? cli.run.bind(cli); + const fullCli = await run({ + arguments: ["inspect-playwright-trace", fixture.path, "--json"], + }); + expect(fullCli.exitCode, fullCli.stderr).toBe(0); + expect(parseEvidence(fullCli.json)).toEqual({ + ...parent, + caller_input_path: fixture.path, + }); + const bundlePath = join(fixture.root, "retained.bundle.json"); + const receipt = await run({ + arguments: [ + "inspect-playwright-trace", + fixture.path, + "--detail", + "summary", + "--view", + JSON.stringify(view), + "--evidence_output", + bundlePath, + "--json", + ], + }); + expect(receipt.exitCode, receipt.stderr).toBe(0); + expect(receipt.json).toMatchObject({ + summary: { + ...selected, + caller_input_path: fixture.path, + }, + }); + expect( + evidenceBundleSchema.parse(JSON.parse(await readFile(bundlePath, "utf8"))) + .records, + ).toEqual([{ ...parent, caller_input_path: fixture.path }]); + await rm(fixture.path); + const retained = await call("inspect_analysis_view", { source, view }); + expect(parseEvidence(retained.structuredContent)).toEqual(selected); + const request = join(fixture.root, "page.json"); + await writeFile( + request, + JSON.stringify({ + source: { + kind: "evidence-file", + path: bundlePath, + evidence_id: parent.evidence_id, + }, + view, + }), + ); + const fromBundle = await run({ + arguments: ["inspect-analysis-view", request, "--json"], + }); + expect(fromBundle.exitCode, fromBundle.stderr).toBe(0); + expect(parseEvidence(fromBundle.json)).toEqual(selected); +}; + +cliTest( + "imports recorder v10 through actual CLI/MCP schemas with retained pages and publication parity", + async ({ cli }) => { + const fixture = await playwrightTraceFixture({ resources: true }); + const { client, call } = await connectLocalToolsMcp({ + REA_MCP_TOOL_FAMILIES: "application,session", + REA_MCP_INPUT_SCHEMA_PROFILE: "full", + PLAYWRIGHT_BROWSERS_PATH: "/rea-no-browser-required", + }); + const schemas = await advertisedMcpToolSchemas( + client, + "inspect_playwright_trace", + ); + expect( + schemas.ajv.validate(schemas.inputSchema, { path: fixture.path }), + ).toBe(true); + expect( + schemas.ajv.validate(schemas.inputSchema, { + path: fixture.path, + view: { + kind: "playwright-trace", + collection: "events", + fields: ["unknown"], + }, + }), + ).toBe(false); + const first = await call("inspect_playwright_trace", { + path: fixture.path, + }); + expect(first.isError, JSON.stringify(first.content)).not.toBe(true); + const summary = analysisViewResultSchema.parse( + parseEvidence(first.structuredContent).normalized_result, + ); + expect(summary).toMatchObject({ + kind: "summary", + summary: { summary: { events: 23 }, counts: { streams: 2 } }, + }); + expect(JSON.stringify(first.structuredContent)).not.toContain( + "fixture loaded", + ); + const complete = await call("inspect_playwright_trace", { + path: fixture.path, + detail: "complete", + }); + expect(complete.isError).not.toBe(true); + const parent = parseEvidence(complete.structuredContent); + const report = playwrightTraceSchema.parse(parent.normalized_result); + expect(summary.parent_evidence_id).toBe(parent.evidence_id); + const source = { + kind: "retained-evidence", + evidence_id: parent.evidence_id, + }; + const rows = []; + for (let offset = 0; offset < 23; offset += 5) { + const page = await call("inspect_analysis_view", { + source, + view: { + kind: "playwright-trace", + collection: "events", + offset, + limit: 5, + fields: Object.keys(report.events[0]!), + }, + }); + expect(page.isError).not.toBe(true); + const view = analysisViewResultSchema.parse( + parseEvidence(page.structuredContent).normalized_result, + ); + if (view.kind !== "playwright-trace") + throw new Error("Expected trace page"); + rows.push(...view.items); + expect(view.source_coverage).toEqual(report.source_coverage); + } + expect(rows).toEqual(report.events); + const view = { + kind: "playwright-trace", + collection: "events", + kinds: ["network"], + fields: [], + limit: 1, + }; + const selected = await call("inspect_playwright_trace", { + path: fixture.path, + view, + }); + expect(selected.isError).not.toBe(true); + const selectedView = analysisViewResultSchema.parse( + parseEvidence(selected.structuredContent).normalized_result, + ); + expect(selectedView).toMatchObject({ + kind: "playwright-trace", + items: [{ kind: "network", source: { record_pointer: "/snapshot" } }], + }); + if (selectedView.kind !== "playwright-trace") + throw new Error("Expected trace selection"); + expect(Object.keys(selectedView.items[0]!).sort()).toEqual([ + "credential_redacted", + "event_id", + "kind", + "ordinal", + "source", + "stream_id", + ]); + for (const filter of [ + { event_ids: [] }, + { stream_ids: [] }, + { kinds: [] }, + { call_ids: [] }, + ]) { + const response = await call("inspect_analysis_view", { + source, + view: { kind: "playwright-trace", collection: "events", ...filter }, + }); + expect(response.isError).not.toBe(true); + expect( + parseEvidence(response.structuredContent).normalized_result, + ).toMatchObject({ items: [], coverage: { total: 0, exhausted: true } }); + } + await checkCliPublication({ + cli, + fixture, + parent, + selected: parseEvidence(selected.structuredContent), + view, + call, + }); + }, +); + +cliTest( + "keeps console previews bounded and malformed siblings offline through public CLI/MCP", + async ({ cli }) => { + let requests = 0; + const server = createServer((_request, response) => { + requests++; + response.end("must not be fetched"); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + onTestFinished( + () => + new Promise((resolve, reject) => + server.close((error) => + error === undefined ? resolve() : reject(error), + ), + ), + ); + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("Expected loopback listener"); + const base = await playwrightTraceFixture(); + const message = "雪🦊".repeat(2000); + const recordedOrigin = new URL( + JSON.parse(base.network.split("\n")[0]!).snapshot.request.url, + ).origin; + const trace = + base.trace.replaceAll( + recordedOrigin, + `http://127.0.0.1:${address.port}`, + ) + + JSON.stringify({ + type: "console", + messageType: "log", + text: message, + time: 123, + }) + + '\n{"type":"console","text":NaN}\n'; + const network = base.network.replaceAll( + recordedOrigin, + `http://127.0.0.1:${address.port}`, + ); + const fixture = await playwrightTraceFixture({ trace, network }); + const { call } = await connectLocalToolsMcp({ + REA_MCP_TOOL_FAMILIES: "application,session", + PLAYWRIGHT_BROWSERS_PATH: "/rea-no-browser-required", + }); + const valid = await playwrightTraceFixture({ + trace: base.trace.replaceAll( + recordedOrigin, + `http://127.0.0.1:${address.port}`, + ), + network, + resources: true, + }); + const control = await call("inspect_playwright_trace", { + path: valid.path, + detail: "complete", + }); + expect(control.isError, JSON.stringify(control.content)).not.toBe(true); + expect( + playwrightTraceSchema.parse( + parseEvidence(control.structuredContent).normalized_result, + ).source_coverage.interpretation, + ).toBe("scoped"); + const response = await call("inspect_playwright_trace", { + path: fixture.path, + view: { + kind: "playwright-trace", + collection: "events", + kinds: ["console"], + offset: 2, + limit: 1, + }, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + const view = analysisViewResultSchema.parse( + parseEvidence(response.structuredContent).normalized_result, + ); + expect(view).toMatchObject({ + kind: "playwright-trace", + source_coverage: { interpretation: "partial" }, + items: [ + { + text_preview: { + unit: "utf16-code-units", + coverage: { total: message.length, exhausted: false }, + }, + }, + ], + }); + expect(JSON.stringify(response)).not.toContain(message); + if (view.kind !== "playwright-trace") + throw new Error("Expected text preview"); + const item = view.items[0]; + if (item === undefined || !("event_id" in item)) + throw new Error("Expected event identity"); + const chunk = await call("inspect_analysis_view", { + source: { + kind: "retained-evidence", + evidence_id: view.parent_evidence_id, + }, + view: { + kind: "playwright-trace", + collection: "event-text", + event_id: item.event_id, + offset: 256, + limit: 2048, + }, + }); + expect(chunk.isError, JSON.stringify(chunk.content)).not.toBe(true); + expect( + parseEvidence(chunk.structuredContent).normalized_result, + ).toMatchObject({ + items: [{ text: message.slice(256, 2304), unit: "utf16-code-units" }], + coverage: { next_offset: 2304, exhausted: false }, + }); + + const full = await call("inspect_playwright_trace", { + path: fixture.path, + view: { + kind: "playwright-trace", + collection: "events", + kinds: ["console"], + offset: 2, + limit: 1, + fields: ["text"], + }, + }); + expect( + parseEvidence(full.structuredContent).normalized_result, + ).toMatchObject({ items: [{ text: message }] }); + const run = cli.sharedCliRun?.bind(cli) ?? cli.run.bind(cli); + const result = await run({ + arguments: ["inspect-playwright-trace", fixture.path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + expect( + playwrightTraceSchema.parse(parseEvidence(result.json).normalized_result) + .summary.network, + ).toBe(3); + expect(requests).toBe(0); + }, +); diff --git a/tests/fixtures/playwright-trace/README.md b/tests/fixtures/playwright-trace/README.md new file mode 100644 index 0000000000..4c764c240d --- /dev/null +++ b/tests/fixtures/playwright-trace/README.md @@ -0,0 +1,44 @@ +# Owned Playwright v10 recorder fixture + +These two files contain unchanged NDJSON bytes from an actual local Playwright +1.64.1 recording on Linux, Node 24.22.1 and Chromium 156.0.8078.4. They are +stored with `.txt` suffixes to preserve producer JSON spelling and framing. + +The owned loopback application redirects to a page, logs `fixture loaded`, then +loads JSON after a button click and logs its count. Requests intentionally use +`Authorization: Bearer FAKE_TRACE_CREDENTIAL`; the response sets the deliberately +fake `trace_session=FAKE_COOKIE` cookie. These are regression data, not real +credentials. The ordinary `X-Trace-Note` value must remain available. + +Recording used `context.tracing.start({screenshots:true,snapshots:true,sources:false})`, +a new page, navigation through the redirect, one button click, a wait for the +result text, a screenshot, a 100-ms wait, and `context.tracing.stop({path})`. +The archive's SHA-256 was +`7a7e0210f0c815225b7b65e93a37f350e2e44061cd8cf6d8170479b79c0f0b87`; +it was 10,643 bytes with 11 members. The binary archive and image payloads are +not committed. + +- `trace.trace.txt`: 51 physical lines; 6 before/6 after action boundaries, + 2 console messages, 6 screencast references, 11 frame snapshots, 17 logs, + one input event, one browser event, and one context header. +- `trace.network.txt`: 3 resource-snapshot records, including the redirect and + fetched JSON. The body references use v10 `_file` members. + +`tests/fixtures/playwrightTrace.ts` reconstructs ZIPs from these original text +streams. Optional resource members are **opaque text markers**: they test +presence and ambiguity semantics and do not establish valid screenshots, +response bodies or recorder compatibility beyond the supplied stream facts. +Separate manual verification uses the original complete recorder archive. +Tests also create clearly identified malformed and partial variants. +The path-identity scenario constructs two v10 stream pairs and resource names +with and without `./`; it is controlled relationship data, not another recorder capture. + +The application and recording were created for this repository; no external +application code, credentials or screenshot binaries are included. + +Exact text identities: + +| File | Bytes | SHA-256 | +| ------------------- | -----: | ------------------------------------------------------------------ | +| `trace.trace.txt` | 10,654 | `27ecb5b6fd8582fce16fd47f68f6d12a582d9c45b9c02e4a1d69f933d2c228b3` | +| `trace.network.txt` | 5,981 | `cd7d35df7ea586f675c5e1818cc654d74722d8bb9f656843fa761e6e59406a20` | diff --git a/tests/fixtures/playwright-trace/trace.network.txt b/tests/fixtures/playwright-trace/trace.network.txt new file mode 100644 index 0000000000..83df091f23 --- /dev/null +++ b/tests/fixtures/playwright-trace/trace.network.txt @@ -0,0 +1,3 @@ +{"type":"resource-snapshot","snapshot":{"pageref":"page@0dcd3fc52027f9b8ab1fe8ed50847967","startedDateTime":"2026-11-06T07:18:16.514Z","time":16.483,"request":{"method":"GET","url":"http://127.0.0.1:43883/redirect","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Accept","value":"text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"},{"name":"Accept-Encoding","value":"gzip, deflate, br, zstd"},{"name":"Connection","value":"keep-alive"},{"name":"Host","value":"127.0.0.1:43883"},{"name":"Sec-Fetch-Dest","value":"document"},{"name":"Sec-Fetch-Mode","value":"navigate"},{"name":"Sec-Fetch-Site","value":"none"},{"name":"Sec-Fetch-User","value":"?1"},{"name":"Upgrade-Insecure-Requests","value":"1"},{"name":"User-Agent","value":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36"},{"name":"sec-ch-ua","value":"\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"HeadlessChrome\";v=\"156\""},{"name":"sec-ch-ua-mobile","value":"?0"},{"name":"sec-ch-ua-platform","value":"\"Linux\""}],"queryString":[],"headersSize":-1,"bodySize":0},"response":{"status":302,"statusText":"Found","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Connection","value":"keep-alive"},{"name":"Date","value":"Fri, 06 Nov 2026 07:18:16 GMT"},{"name":"Keep-Alive","value":"timeout=5"},{"name":"Transfer-Encoding","value":"chunked"},{"name":"location","value":"/"}],"content":{"size":-1,"mimeType":"x-unknown"},"headersSize":-1,"bodySize":-1,"redirectURL":"http://127.0.0.1:43883/","_transferSize":-1},"cache":{},"timings":{"dns":0.064,"connect":1.003,"ssl":4.839,"send":0,"wait":2.171,"receive":8.406},"_frameref":"frame@cd1850d4c52e8087c375b939d7372467","_monotonicTime":988.341,"_resourceType":"document","serverIPAddress":"127.0.0.1","_serverPort":43883,"_securityDetails":{}}} +{"type":"resource-snapshot","snapshot":{"pageref":"page@0dcd3fc52027f9b8ab1fe8ed50847967","startedDateTime":"2026-11-06T07:18:16.529Z","time":3.093,"request":{"method":"GET","url":"http://127.0.0.1:43883/","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Accept","value":"text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"},{"name":"Accept-Encoding","value":"gzip, deflate, br, zstd"},{"name":"Connection","value":"keep-alive"},{"name":"Host","value":"127.0.0.1:43883"},{"name":"Sec-Fetch-Dest","value":"document"},{"name":"Sec-Fetch-Mode","value":"navigate"},{"name":"Sec-Fetch-Site","value":"none"},{"name":"Sec-Fetch-User","value":"?1"},{"name":"Upgrade-Insecure-Requests","value":"1"},{"name":"User-Agent","value":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36"},{"name":"sec-ch-ua","value":"\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"HeadlessChrome\";v=\"156\""},{"name":"sec-ch-ua-mobile","value":"?0"},{"name":"sec-ch-ua-platform","value":"\"Linux\""}],"queryString":[],"headersSize":639,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Connection","value":"keep-alive"},{"name":"Date","value":"Fri, 06 Nov 2026 07:18:16 GMT"},{"name":"Keep-Alive","value":"timeout=5"},{"name":"Transfer-Encoding","value":"chunked"},{"name":"content-type","value":"text/html"}],"content":{"size":484,"mimeType":"text/html","compression":0,"_file":"resources/7f55d0c7c0c54857e59ff1bf742dbede400cb502.html"},"headersSize":156,"bodySize":496,"redirectURL":"","_transferSize":652},"cache":{},"timings":{"dns":-1,"connect":-1,"ssl":-1,"send":0,"wait":1.948,"receive":1.145},"_frameref":"frame@cd1850d4c52e8087c375b939d7372467","_monotonicTime":1001.778,"_resourceType":"document","serverIPAddress":"127.0.0.1","_serverPort":43883,"_securityDetails":{}}} +{"type":"resource-snapshot","snapshot":{"pageref":"page@0dcd3fc52027f9b8ab1fe8ed50847967","startedDateTime":"2026-11-06T07:18:16.668Z","time":2.638,"request":{"method":"GET","url":"http://127.0.0.1:43883/api/value","httpVersion":"HTTP/1.1","cookies":[],"headers":[{"name":"Accept","value":"*/*"},{"name":"Accept-Encoding","value":"gzip, deflate, br, zstd"},{"name":"Authorization","value":"Bearer FAKE_TRACE_CREDENTIAL"},{"name":"Connection","value":"keep-alive"},{"name":"Host","value":"127.0.0.1:43883"},{"name":"Referer","value":"http://127.0.0.1:43883/"},{"name":"Sec-Fetch-Dest","value":"empty"},{"name":"Sec-Fetch-Mode","value":"cors"},{"name":"Sec-Fetch-Site","value":"same-origin"},{"name":"User-Agent","value":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36"},{"name":"X-Trace-Note","value":"ordinary caller text"},{"name":"sec-ch-ua","value":"\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"HeadlessChrome\";v=\"156\""},{"name":"sec-ch-ua-mobile","value":"?0"},{"name":"sec-ch-ua-platform","value":"\"Linux\""}],"queryString":[],"headersSize":571,"bodySize":0},"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","cookies":[{"name":"trace_session","value":"FAKE_COOKIE","path":"/"}],"headers":[{"name":"Connection","value":"keep-alive"},{"name":"Date","value":"Fri, 06 Nov 2026 07:18:16 GMT"},{"name":"Keep-Alive","value":"timeout=5"},{"name":"Transfer-Encoding","value":"chunked"},{"name":"content-type","value":"application/json"},{"name":"set-cookie","value":"trace_session=FAKE_COOKIE; Path=/"}],"content":{"size":35,"mimeType":"application/json","compression":0,"_file":"resources/4dbd8adef8cfcd7b3f50fc99f2aa379f4d8423de.json"},"headersSize":210,"bodySize":46,"redirectURL":"","_transferSize":256},"cache":{},"timings":{"dns":-1,"connect":-1,"ssl":-1,"send":0,"wait":0.55,"receive":2.088},"_frameref":"frame@cd1850d4c52e8087c375b939d7372467","_monotonicTime":1142.967,"_resourceType":"fetch","serverIPAddress":"127.0.0.1","_serverPort":43883,"_securityDetails":{}}} diff --git a/tests/fixtures/playwright-trace/trace.trace.txt b/tests/fixtures/playwright-trace/trace.trace.txt new file mode 100644 index 0000000000..83b8338e45 --- /dev/null +++ b/tests/fixtures/playwright-trace/trace.trace.txt @@ -0,0 +1,51 @@ +{"version":10,"type":"context-options","origin":"library","browserName":"chromium","playwrightVersion":"1.64.1","options":{"noDefaultViewport":false,"selectorEngines":[],"acceptDownloads":"accept","viewport":{"width":1280,"height":720}},"platform":"linux","wallTime":1793949496297,"monotonicTime":767.965,"sdkLanguage":"javascript","testIdAttributeName":"data-testid"} +{"type":"before","callId":"fnpx@6","startTime":770.631,"class":"BrowserContext","method":"newPage","params":{}} +{"type":"event","time":962.87,"class":"BrowserContext","method":"page","params":{"pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967"}} +{"type":"after","callId":"fnpx@6","endTime":962.959,"result":{"page":""}} +{"type":"before","callId":"fnpx@8","startTime":965.977,"class":"Frame","method":"goto","params":{"url":"http://127.0.0.1:43883/redirect","waitUntil":"load","timeout":30000}} +{"type":"frame-snapshot","snapshot":{"callId":"fnpx@8","phase":"before","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","frameId":"frame@cd1850d4c52e8087c375b939d7372467","frameUrl":"about:blank","html":["HTML",{},["HEAD",{},["BASE",{"href":"about:blank"}]],["BODY"]],"viewport":{"width":1280,"height":720},"timestamp":975.83,"wallTime":1793949496503,"collectionTime":6.199999988079071,"resourceOverrides":[],"isMainFrame":true}} +{"type":"log","callId":"fnpx@8","time":977.163,"message":"navigating to \"http://127.0.0.1:43883/redirect\", waiting until \"load\""} +{"type":"screencast-frame","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","file":"screencast/page@0dcd3fc52027f9b8ab1fe8ed50847967-1793949496522.jpeg","width":1280,"height":720,"timestamp":993.356,"frameSwapWallTime":1793949496494.3298} +{"type":"screencast-frame","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","file":"screencast/page@0dcd3fc52027f9b8ab1fe8ed50847967-1793949496524.jpeg","width":1280,"height":720,"timestamp":994.803,"frameSwapWallTime":1793949496515.41} +{"type":"console","messageType":"log","text":"fixture loaded","args":[{"preview":"fixture loaded","value":"fixture loaded"}],"location":{"url":"http://127.0.0.1:43883/","lineNumber":0,"columnNumber":157},"time":1025.079,"pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967"} +{"type":"after","callId":"fnpx@8","endTime":1040.379,"result":{"response":""}} +{"type":"frame-snapshot","snapshot":{"callId":"fnpx@8","phase":"after","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","frameId":"frame@cd1850d4c52e8087c375b939d7372467","frameUrl":"http://127.0.0.1:43883/","doctype":"html","html":["HTML",{},["HEAD",{},["BASE",{"href":"http://127.0.0.1:43883/"}],["META",{"charset":"utf-8"}],["TITLE",{},"REA owned trace fixture"]],["BODY",{},["BUTTON",{"id":"load"},"Load value"],["OUTPUT",{"id":"result"}]]],"viewport":{"width":1280,"height":720},"timestamp":1048.948,"wallTime":1793949496578,"collectionTime":3.899999976158142,"resourceOverrides":[],"isMainFrame":true}} +{"type":"before","callId":"fnpx@10","startTime":1050.33,"class":"Frame","method":"click","params":{"selector":"internal:role=button[name=\"Load value\"i]","strict":true,"timeout":30000}} +{"type":"screencast-frame","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","file":"screencast/page@0dcd3fc52027f9b8ab1fe8ed50847967-1793949496583.jpeg","width":1280,"height":720,"timestamp":1054.155,"frameSwapWallTime":1793949496582.074} +{"type":"frame-snapshot","snapshot":{"callId":"fnpx@10","phase":"before","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","frameId":"frame@cd1850d4c52e8087c375b939d7372467","frameUrl":"http://127.0.0.1:43883/","doctype":"html","html":[[1,9]],"viewport":{"width":1280,"height":720},"timestamp":1056.269,"wallTime":1793949496585,"collectionTime":0.20000004768371582,"resourceOverrides":[],"isMainFrame":true}} +{"type":"log","callId":"fnpx@10","time":1058.209,"message":"waiting for getByRole('button', { name: 'Load value' })"} +{"type":"log","callId":"fnpx@10","time":1085.066,"message":" locator resolved to "} +{"type":"log","callId":"fnpx@10","time":1086.221,"message":"attempting click action"} +{"type":"log","callId":"fnpx@10","time":1086.649,"message":" waiting for element to be visible, enabled and stable"} +{"type":"log","callId":"fnpx@10","time":1121.065,"message":" element is visible, enabled and stable"} +{"type":"log","callId":"fnpx@10","time":1121.088,"message":" scrolling into view if needed"} +{"type":"log","callId":"fnpx@10","time":1122.588,"message":" done scrolling"} +{"type":"input","callId":"fnpx@10","point":{"x":48.6,"y":18.5},"box":{"x":8,"y":8,"width":81.21875,"height":21}} +{"type":"frame-snapshot","snapshot":{"callId":"fnpx@10","phase":"action","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","frameId":"frame@cd1850d4c52e8087c375b939d7372467","frameUrl":"http://127.0.0.1:43883/","doctype":"html","html":["HTML",{},[[2,4]],["BODY",{},["BUTTON",{"__playwright_target__":"","id":"load"},[[2,5]]],[[2,7]]]],"viewport":{"width":1280,"height":720},"timestamp":1125.868,"wallTime":1793949496654,"collectionTime":0.19999998807907104,"resourceOverrides":[],"isMainFrame":true}} +{"type":"log","callId":"fnpx@10","time":1131.318,"message":" performing click action"} +{"type":"log","callId":"fnpx@10","time":1143.761,"message":" click action done"} +{"type":"log","callId":"fnpx@10","time":1143.782,"message":" waiting for scheduled navigations to finish"} +{"type":"console","messageType":"info","text":"loaded value 3","args":[{"preview":"loaded value","value":"loaded value"},{"preview":"3","value":3}],"location":{"url":"http://127.0.0.1:43883/","lineNumber":0,"columnNumber":445},"time":1152.774,"pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967"} +{"type":"log","callId":"fnpx@10","time":1153.049,"message":" navigations have finished"} +{"type":"after","callId":"fnpx@10","endTime":1153.154} +{"type":"screencast-frame","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","file":"screencast/page@0dcd3fc52027f9b8ab1fe8ed50847967-1793949496682.jpeg","width":1280,"height":720,"timestamp":1153.487,"frameSwapWallTime":1793949496682.071} +{"type":"frame-snapshot","snapshot":{"callId":"fnpx@10","phase":"after","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","frameId":"frame@cd1850d4c52e8087c375b939d7372467","frameUrl":"http://127.0.0.1:43883/","doctype":"html","html":["HTML",{},[[3,4]],["BODY",{},[[1,0]],["OUTPUT",{"id":"result"},"café ready"]]],"viewport":{"width":1280,"height":720},"timestamp":1155.716,"wallTime":1793949496683,"collectionTime":0.09999996423721313,"resourceOverrides":[],"isMainFrame":true}} +{"type":"before","callId":"fnpx@12","startTime":1156.385,"class":"Frame","method":"waitForSelector","params":{"selector":"internal:text=\"café ready\"s","strict":true,"omitReturnValue":true,"timeout":30000}} +{"type":"frame-snapshot","snapshot":{"callId":"fnpx@12","phase":"before","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","frameId":"frame@cd1850d4c52e8087c375b939d7372467","frameUrl":"http://127.0.0.1:43883/","doctype":"html","html":["HTML",{},[[4,4]],["BODY",{},["BUTTON",{"id":"load"},[[4,5]]],[[1,1]]]],"viewport":{"width":1280,"height":720},"timestamp":1161.717,"wallTime":1793949496690,"collectionTime":0.19999998807907104,"resourceOverrides":[],"isMainFrame":true}} +{"type":"log","callId":"fnpx@12","time":1162.482,"message":"waiting for getByText('café ready', { exact: true }) to be visible"} +{"type":"screencast-frame","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","file":"screencast/page@0dcd3fc52027f9b8ab1fe8ed50847967-1793949496692.jpeg","width":1280,"height":720,"timestamp":1163.201,"frameSwapWallTime":1793949496698.7368} +{"type":"log","callId":"fnpx@12","time":1165.406,"message":" locator resolved to visible café ready"} +{"type":"after","callId":"fnpx@12","endTime":1165.481,"result":{}} +{"type":"frame-snapshot","snapshot":{"callId":"fnpx@12","phase":"after","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","frameId":"frame@cd1850d4c52e8087c375b939d7372467","frameUrl":"http://127.0.0.1:43883/","doctype":"html","html":[[1,2]],"viewport":{"width":1280,"height":720},"timestamp":1166.097,"wallTime":1793949496695,"collectionTime":0.19999998807907104,"resourceOverrides":[],"isMainFrame":true}} +{"type":"before","callId":"fnpx@14","startTime":1166.805,"class":"Page","method":"screenshot","params":{"timeout":30000}} +{"type":"frame-snapshot","snapshot":{"callId":"fnpx@14","phase":"before","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","frameId":"frame@cd1850d4c52e8087c375b939d7372467","frameUrl":"http://127.0.0.1:43883/","doctype":"html","html":[[2,2]],"viewport":{"width":1280,"height":720},"timestamp":1167.437,"wallTime":1793949496696,"collectionTime":0.10000002384185791,"resourceOverrides":[],"isMainFrame":true}} +{"type":"log","callId":"fnpx@14","time":1167.847,"message":"taking page screenshot"} +{"type":"log","callId":"fnpx@14","time":1168.964,"message":"waiting for fonts to load..."} +{"type":"log","callId":"fnpx@14","time":1170.096,"message":"fonts loaded"} +{"type":"screencast-frame","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","file":"screencast/page@0dcd3fc52027f9b8ab1fe8ed50847967-1793949496721.jpeg","width":1280,"height":720,"timestamp":1192.358,"frameSwapWallTime":1793949496715.4019} +{"type":"after","callId":"fnpx@14","endTime":1209.436,"result":{"binary":""}} +{"type":"frame-snapshot","snapshot":{"callId":"fnpx@14","phase":"after","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","frameId":"frame@cd1850d4c52e8087c375b939d7372467","frameUrl":"http://127.0.0.1:43883/","doctype":"html","html":[[3,2]],"viewport":{"width":1280,"height":720},"timestamp":1210.204,"wallTime":1793949496739,"collectionTime":0.09999996423721313,"resourceOverrides":[],"isMainFrame":true}} +{"type":"before","callId":"fnpx@16","startTime":1210.951,"class":"Frame","method":"waitForTimeout","params":{"waitTimeout":100}} +{"type":"frame-snapshot","snapshot":{"callId":"fnpx@16","phase":"before","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","frameId":"frame@cd1850d4c52e8087c375b939d7372467","frameUrl":"http://127.0.0.1:43883/","doctype":"html","html":[[4,2]],"viewport":{"width":1280,"height":720},"timestamp":1211.611,"wallTime":1793949496740,"collectionTime":0.20000004768371582,"resourceOverrides":[],"isMainFrame":true}} +{"type":"after","callId":"fnpx@16","endTime":1312.319} +{"type":"frame-snapshot","snapshot":{"callId":"fnpx@16","phase":"after","pageId":"page@0dcd3fc52027f9b8ab1fe8ed50847967","frameId":"frame@cd1850d4c52e8087c375b939d7372467","frameUrl":"http://127.0.0.1:43883/","doctype":"html","html":[[5,2]],"viewport":{"width":1280,"height":720},"timestamp":1313.434,"wallTime":1793949496842,"collectionTime":0.19999998807907104,"resourceOverrides":[],"isMainFrame":true}} diff --git a/tests/fixtures/playwrightTrace.ts b/tests/fixtures/playwrightTrace.ts new file mode 100644 index 0000000000..f28b9ae7df --- /dev/null +++ b/tests/fixtures/playwrightTrace.ts @@ -0,0 +1,93 @@ +import { ZipWriter, Uint8ArrayWriter, TextReader } from "@zip.js/zip.js"; +import { writeFile, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createTestTempDirectory } from "./temporaryDirectory.js"; + +/** Assemble actual recorder text; opaque resource markers deliberately do not claim image validity. */ +export const playwrightTraceFixture = async ( + options: { + resources?: boolean; + trace?: string; + network?: string; + level?: 0 | 6; + extraMembers?: readonly { path: string; contents: string }[]; + } = {}, +) => { + const trace = + options.trace ?? + (await readFile( + new URL("./playwright-trace/trace.trace.txt", import.meta.url), + "utf8", + )); + const network = + options.network ?? + (await readFile( + new URL("./playwright-trace/trace.network.txt", import.meta.url), + "utf8", + )); + const root = await createTestTempDirectory("rea-playwright-trace-"); + const path = join(root, "trace.zip"); + const writer = new ZipWriter(new Uint8ArrayWriter(), { + useWebWorkers: false, + }); + const compression = + options.level === undefined ? {} : { level: options.level }; + await writer.add("trace.trace", new TextReader(trace), compression); + await writer.add("trace.network", new TextReader(network), compression); + if (options.resources) { + const paths = new Set(); + for (const line of trace.split("\n").filter(Boolean)) { + const row = JSON.parse(line); + if (row.type === "screencast-frame" && typeof row.file === "string") + paths.add(row.file); + } + for (const line of network.split("\n").filter(Boolean)) { + const row = JSON.parse(line); + for (const field of [ + row.snapshot?.request?.postData, + row.snapshot?.response?.content, + ]) + if (typeof field?._file === "string") paths.add(field._file); + } + for (const name of paths) + await writer.add( + name, + new TextReader( + "Opaque fixture resource: bytes are not interpreted as an image or body.", + ), + ); + } + for (const member of options.extraMembers ?? []) + await writer.add(member.path, new TextReader(member.contents)); + await writeFile(path, await writer.close()); + return { root, path, trace, network }; +}; + +/** Controlled v10 relationships with distinct source names sharing logical paths. */ +export const playwrightTracePathFixture = () => { + const trace = (resource: string) => + [ + { type: "context-options", version: 10 }, + { type: "screencast-frame", file: resource, timestamp: 1 }, + ] + .map((row) => JSON.stringify(row)) + .join("\n") + "\n"; + const network = (resource: string, status: number) => + JSON.stringify({ + type: "resource-snapshot", + snapshot: { + request: { method: "GET", url: "http://captured.invalid/value" }, + response: { status, content: { _file: resource } }, + }, + }) + "\n"; + return playwrightTraceFixture({ + resources: true, + trace: trace("resources/x"), + network: network("resources/x", 200), + extraMembers: [ + { path: "./trace.trace", contents: trace("./resources/x") }, + { path: "./trace.network", contents: network("./resources/x", 201) }, + { path: "./resources/x", contents: "Alternate unread resource bytes" }, + ], + }); +}; From aefd903adb96bd2967388a73f79a66f03bd35c87 Mon Sep 17 00:00:00 2001 From: xin zhuang <65798732+1052326311@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:54:08 +0800 Subject: [PATCH 055/151] fix(desktop): preserve nullable SQLite row identity (#8999) Use whole-row multiset identity for incomplete/NULL declared keys and share it with task drafting. Count actual projected predicates across all rows so timestamp omission and duplicate multiplicities yield satisfiable checks. Fixes #8997. Validation:106 focused tests; nativeSQLite public CLI before/after cases and generatedPython evaluator agreement. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) --- .../desktop/DesktopApplicationState.test.ts | 82 ++++++- src/domain/desktop/appTaskChecks.ts | 32 ++- src/domain/desktop/appTaskDraft.test.ts | 203 ++++++++++++++++++ src/domain/desktop/appTaskDraft.ts | 87 ++++++-- src/domain/desktop/stateChanges.test.ts | 43 ++++ src/domain/desktop/stateChanges.ts | 31 ++- 6 files changed, 438 insertions(+), 40 deletions(-) diff --git a/src/application/desktop/DesktopApplicationState.test.ts b/src/application/desktop/DesktopApplicationState.test.ts index 33c4474d58..c652dcb2ec 100644 --- a/src/application/desktop/DesktopApplicationState.test.ts +++ b/src/application/desktop/DesktopApplicationState.test.ts @@ -8,7 +8,11 @@ import type { DesktopCheckpoint, } from "../../domain/desktop/desktopCapture.js"; import { desktopApplicationStateSchema } from "../../domain/desktop/desktopState.js"; -import { inspectDesktopApplicationState } from "./DesktopApplicationState.js"; +import { + inspectDesktopApplicationState, + type DesktopStateReaders, +} from "./DesktopApplicationState.js"; +import { draftDesktopApplicationTask } from "./DesktopTaskDraft.js"; import { DATABASE, HOME, @@ -215,6 +219,82 @@ describe("inspectDesktopApplicationState", () => { }); }); +describe("nullable SQLite row identity through desktop capture consumers", () => { + it("carries the changed row into the task draft without losing its check", async () => { + const source = capture(); + const folder = await writeCaptureFolder({ + ...source, + state: { + ...source.state, + checkpoints: source.state.checkpoints.map((entry) => ({ + ...entry, + changes: entry.changes.filter((change) => change.path === DATABASE), + retained: entry.retained.filter((file) => file.path === DATABASE), + })), + }, + }); + const sqlite: DesktopStateReaders["sqlite"] = async (database) => ({ + ok: true, + view: { + tables: { + items: { + columns: ["id", "body"], + primary_key: ["id"], + rows: (await readFile(database, "latin1")).endsWith("v1") + ? [ + [null, "alpha"], + [null, "beta"], + ] + : [ + [null, "gamma"], + [null, "beta"], + ], + truncated: false, + }, + }, + tables_truncated: false, + }, + }); + const using = { ...readers, sqlite }; + const state = await inspectDesktopApplicationState(folder, using); + if (!("normalized_result" in state)) throw state; + expect( + desktopApplicationStateSchema.parse(state.normalized_result).paths, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + home_path: ".local/share/notes/notes.db", + changes: [ + expect.objectContaining({ + kind: "rows", + added_count: 1, + removed_count: 1, + }), + ], + }), + ]), + ); + const task = await draftDesktopApplicationTask(folder, using); + if (!("normalized_result" in task)) throw task; + expect(task.normalized_result).toMatchObject({ + task: { + checks: [ + { + kind: "row_present", + table: "items", + where: { id: null, body: "gamma" }, + }, + { + kind: "row_absent", + table: "items", + where: { id: null, body: "alpha" }, + }, + ], + }, + }); + }); +}); + describe("inspectDesktopApplicationState database sidecars", () => { it("refuses a database whose write-ahead log was left out at the baseline", async () => { const base = capture(); diff --git a/src/domain/desktop/appTaskChecks.ts b/src/domain/desktop/appTaskChecks.ts index bed05c4dc1..c423923a0d 100644 --- a/src/domain/desktop/appTaskChecks.ts +++ b/src/domain/desktop/appTaskChecks.ts @@ -287,6 +287,26 @@ const tableOf = ( }; }; +/** Count every row satisfying a task predicate, including overlapping predicates. */ +export const countTaskRows = ( + columns: readonly JsonValue[], + rows: readonly (readonly JsonValue[])[], + where: Readonly>, +): number => { + const conditions = Object.entries(where).map( + ([column, value]) => [columns.indexOf(column), value] as const, + ); + let count = 0; + for (const row of rows) + if ( + conditions.every( + ([index, value]) => index >= 0 && sameJson(row[index], value), + ) + ) + count += 1; + return count; +}; + const rowVerdict = ( check: Check<"row_present" | "row_absent" | "row_count">, entry: TaskFileView | undefined, @@ -297,13 +317,11 @@ const rowVerdict = ( if ("passed" in table) return table; const { columns, rows } = table; const name = `${check.file} table ${check.table}`; - const matches = (row: JsonValue) => - Array.isArray(row) && - Object.entries(check.where ?? {}).every(([column, value]) => { - const index = columns.indexOf(column); - return index >= 0 && sameJson(row[index], value); - }); - const n = rows.filter(matches).length; + const n = countTaskRows( + columns, + rows.filter(Array.isArray), + check.where ?? {}, + ); if (check.kind === "row_present") return n > 0 ? pass(`${name} has a row with ${shown(check.where)}`) diff --git a/src/domain/desktop/appTaskDraft.test.ts b/src/domain/desktop/appTaskDraft.test.ts index c44d5b4918..56d461129c 100644 --- a/src/domain/desktop/appTaskDraft.test.ts +++ b/src/domain/desktop/appTaskDraft.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { JsonValue } from "../jsonValue.js"; import { draftTaskFiles } from "./appTaskDraft.js"; +import { evaluateTaskChecks } from "./appTaskChecks.js"; import type { DesktopStatePath } from "./desktopState.js"; type Format = DesktopStatePath["format"]; @@ -310,6 +311,208 @@ describe("draftTaskFiles for XML that uses namespaces", () => { }); }); +describe("draftTaskFiles with nullable SQLite keys", () => { + it("suggests checks for distinct NULL-key rows and ignores a reorder", () => { + const file = ".local/share/app/items.db"; + const table = (rows: (string | null)[][]) => ({ + tables: { + items: { + columns: ["id", "body"], + primary_key: ["id"], + rows, + truncated: false, + }, + }, + tables_truncated: false, + }); + expect( + draft({ + [file]: path( + "application_state", + "sqlite", + table([ + [null, "alpha"], + [null, "beta"], + ]), + table([ + [null, "beta"], + [null, "alpha"], + ]), + ), + }), + ).toEqual([]); + const [database] = draft({ + [file]: path( + "application_state", + "sqlite", + table([ + [null, "alpha"], + [null, "beta"], + ]), + table([ + [null, "gamma"], + [null, "beta"], + ]), + ), + }); + expect(database?.suggested).toEqual([ + { + kind: "row_present", + file, + table: "items", + where: { id: null, body: "gamma" }, + }, + { + kind: "row_absent", + file, + table: "items", + where: { id: null, body: "alpha" }, + }, + ]); + }); +}); + +describe("draftTaskFiles with unkeyed row conditions", () => { + const file = "Documents/items.db"; + const table = (rows: JsonValue[][]) => ({ + tables: { + items: { + columns: ["id", "body", "at"], + primary_key: ["id"], + rows, + truncated: false, + }, + }, + tables_truncated: false, + }); + + it("does not contradict itself when only a NULL-key row timestamp changes", () => { + const [database] = draft({ + [file]: path( + "document", + "sqlite", + table([[null, "alpha", "2026-11-04 09:00:00"]]), + table([[null, "alpha", "2026-11-04 10:00:00"]]), + ), + }); + expect(database?.suggested).toEqual([]); + }); + + it("counts repeated row conditions even when the total row count stays equal", () => { + const before = table([ + [null, "alpha", null], + [null, "alpha", null], + [null, "beta", null], + ]); + const after = table([ + [null, "alpha", null], + [null, "beta", null], + [null, "beta", null], + ]); + const [database] = draft({ + [file]: path("document", "sqlite", before, after), + }); + expect(database?.suggested).toEqual([ + { + kind: "row_count", + file, + table: "items", + where: { id: null, body: "alpha", at: null }, + equals: 1, + }, + { + kind: "row_count", + file, + table: "items", + where: { id: null, body: "beta", at: null }, + equals: 2, + }, + ]); + const checks = (database?.suggested ?? []).map((check, index) => ({ + ...check, + id: String(index), + })); + expect( + evaluateTaskChecks( + checks, + new Map([[file, { format: "sqlite", view: after }]]), + ).map((outcome) => outcome.passed), + ).toEqual([true, true]); + expect( + evaluateTaskChecks( + checks, + new Map([[file, { format: "sqlite", view: before }]]), + ).map((outcome) => outcome.passed), + ).toEqual([false, false]); + }); + + it.each([ + { + name: "counts all rows matching an overlapping predicate", + before: [ + [null, "alpha", "2026-11-04 09:00:00"], + [null, "alpha", null], + ], + after: [ + [null, "alpha", "2026-11-04 09:00:00"], + [null, "alpha", null], + [null, "alpha", "2026-11-04 10:00:00"], + ], + expected: [ + { kind: "row_count", where: { id: null, body: "alpha" }, equals: 3 }, + { kind: "row_count", equals: 3 }, + ], + }, + { + name: "does not suggest absence when a broader predicate still matches", + before: [ + [null, "alpha", "2026-11-04 09:00:00"], + [null, "alpha", null], + ], + after: [[null, "alpha", null]], + expected: [ + { kind: "row_count", where: { id: null, body: "alpha" }, equals: 1 }, + { kind: "row_count", equals: 1 }, + ], + }, + { + name: "keeps the surviving broad predicate when a timestamp becomes NULL", + before: [[null, "alpha", "2026-11-04 09:00:00"]], + after: [[null, "alpha", null]], + expected: [ + { kind: "row_present", where: { id: null, body: "alpha", at: null } }, + ], + }, + ])("$name", ({ before, after, expected }) => { + for (const key of [["id"], []]) { + const baseline = table(before); + const final = table(after); + baseline.tables.items.primary_key = key; + final.tables.items.primary_key = key; + const [database] = draft({ + [file]: path("document", "sqlite", baseline, final), + }); + expect(database?.suggested).toEqual( + expected.map((check) => ({ ...check, file, table: "items" })), + ); + const checks = (database?.suggested ?? []).map((check, index) => ({ + ...check, + id: String(index), + })); + for (const [view, passed] of [ + [final, true], + [baseline, false], + ] as const) + expect( + evaluateTaskChecks( + checks, + new Map([[file, { format: "sqlite", view }]]), + ).map((outcome) => outcome.passed), + ).toEqual(checks.map(() => passed)); + } + }); +}); + describe("draftTaskFiles with partial evidence", () => { const notes = (...rows: JsonValue[][]) => ({ tables: { diff --git a/src/domain/desktop/appTaskDraft.ts b/src/domain/desktop/appTaskDraft.ts index d0c3b1c9b9..9c5ec865fa 100644 --- a/src/domain/desktop/appTaskDraft.ts +++ b/src/domain/desktop/appTaskDraft.ts @@ -5,7 +5,12 @@ import { type DesktopStatePath, type StateEnds, } from "./desktopState.js"; -import { diffStateViews, diffZipMembers } from "./stateChanges.js"; +import { countTaskRows } from "./appTaskChecks.js"; +import { + diffStateViews, + diffZipMembers, + sqliteRowIdentity, +} from "./stateChanges.js"; import { locatePointer, pairMembers, resolvePointer } from "./viewPointer.js"; import { entryWithoutView, zipArrayLeavesOut, zipEntryAt } from "./zipView.js"; @@ -330,6 +335,58 @@ const rowWhere = ( }), ); +/** Match uniquely keyed rows separately from multiset row conditions. */ +const keyedRows = (table: TableView) => + new Map( + table.rows.flatMap((row) => { + const identity = sqliteRowIdentity(table.columns, table.key, row); + return identity.keyed ? [[identity.value, row] as const] : []; + }), + ); + +/** The predicates a task can express after volatile values are omitted. */ +const rowConditions = (table: TableView) => { + const conditions = new Map>(); + for (const row of table.rows) { + if (sqliteRowIdentity(table.columns, table.key, row).keyed) continue; + const where = rowWhere(table, row, () => true); + if (Object.keys(where).length === 0) continue; + conditions.set(JSON.stringify(where), where); + } + return conditions; +}; + +const unkeyedRowSuggestions = ( + file: string, + table: string, + baseline: TableView, + final: TableView, +): Suggestion[] => { + const conditions = new Map([ + ...rowConditions(final), + ...rowConditions(baseline), + ]); + const suggestions: Suggestion[] = []; + for (const where of conditions.values()) { + const previous = countTaskRows(baseline.columns, baseline.rows, where); + const count = countTaskRows(final.columns, final.rows, where); + if (previous === count) continue; + if (count === 0) + suggestions.push({ kind: "row_absent", file, table, where }); + else if (previous === 0) + suggestions.push({ kind: "row_present", file, table, where }); + else + suggestions.push({ + kind: "row_count", + file, + table, + where, + equals: count, + }); + } + return suggestions; +}; + const rowSuggestions = ( file: string, baseline: Version, @@ -347,28 +404,15 @@ const rowSuggestions = ( }; for (const [name, table] of tableViews(final)) { const old = before.get(name) ?? { ...table, rows: [] }; - const identity = (view: TableView, row: readonly JsonValue[]) => - JSON.stringify( - view.key.length === 0 - ? row - : view.key.map((column) => row[view.columns.indexOf(column)] ?? null), - ); - const oldRows = new Map(old.rows.map((row) => [identity(old, row), row])); - const newRows = new Map( - table.rows.map((row) => [identity(table, row), row]), - ); - const keyed = (column: string) => table.key.includes(column); + const oldRows = keyedRows(old); + const newRows = keyedRows(table); for (const [id, row] of newRows) { const previous = oldRows.get(id); if (previous === undefined) push( "row_present", name, - rowWhere( - table, - row, - (column) => table.key.length === 0 || !keyed(column), - ), + rowWhere(table, row, (column) => !table.key.includes(column)), ); else if (JSON.stringify(previous) !== JSON.stringify(row)) push( @@ -378,7 +422,7 @@ const rowSuggestions = ( table, row, (column) => - keyed(column) || + table.key.includes(column) || JSON.stringify(row[table.columns.indexOf(column)]) !== JSON.stringify(previous[old.columns.indexOf(column)]), ), @@ -389,12 +433,9 @@ const rowSuggestions = ( push( "row_absent", name, - rowWhere( - old, - row, - (column) => old.key.length === 0 || old.key.includes(column), - ), + rowWhere(old, row, (column) => old.key.includes(column)), ); + suggestions.push(...unkeyedRowSuggestions(file, name, old, table)); if (old.rows.length !== table.rows.length) suggestions.push({ kind: "row_count", diff --git a/src/domain/desktop/stateChanges.test.ts b/src/domain/desktop/stateChanges.test.ts index 1321c3b28c..822db56f16 100644 --- a/src/domain/desktop/stateChanges.test.ts +++ b/src/domain/desktop/stateChanges.test.ts @@ -82,6 +82,49 @@ describe("diffStateViews", () => { changed: ["notes:[1]"], }); }); + + it("compares NULL primary-key rows as a multiset while retaining keyed matches", () => { + const table = (rows: (string | null)[][]) => ({ + tables: { + items: { columns: ["id", "body"], primary_key: ["id"], rows }, + }, + }); + const before = table([ + [null, "alpha"], + [null, "beta"], + ["fixed", "old"], + ]); + expect( + diffStateViews( + "sqlite", + before, + table([ + ["fixed", "old"], + [null, "beta"], + [null, "alpha"], + ]), + ), + ).toMatchObject({ + added_count: 0, + removed_count: 0, + changed_count: 0, + }); + expect( + diffStateViews( + "sqlite", + before, + table([ + [null, "gamma"], + [null, "beta"], + ["fixed", "new"], + ]), + ), + ).toMatchObject({ + added: ['items:[null,"gamma"]'], + removed: ['items:[null,"alpha"]'], + changed: ['items:["fixed"]'], + }); + }); }); describe("diffStateViews fidelity", () => { diff --git a/src/domain/desktop/stateChanges.ts b/src/domain/desktop/stateChanges.ts index c7e78a7e27..7885d70abe 100644 --- a/src/domain/desktop/stateChanges.ts +++ b/src/domain/desktop/stateChanges.ts @@ -245,6 +245,24 @@ const unreadTables = (view: JsonValue): string[] => .filter(([, table]) => isRecord(table) && typeof table.error === "string") .map(([name]) => name); +/** A nullable SQLite primary key cannot identify a row on its own. */ +export const sqliteRowIdentity = ( + columns: readonly JsonValue[], + key: readonly JsonValue[], + row: readonly JsonValue[], +): { value: string; keyed: boolean } => { + const indexes = key.map((column) => columns.indexOf(column)); + const keyed = + indexes.length > 0 && + indexes.every( + (index) => index >= 0 && row[index] !== null && row[index] !== undefined, + ); + return { + value: JSON.stringify(keyed ? indexes.map((index) => row[index]) : row), + keyed, + }; +}; + /** * `table:key` → row for SQLite views, keyed by primary key when there is one. * Tables unread in either version are skipped, so their rows are not misreported. @@ -258,18 +276,13 @@ const rowEntries = ( if (skipped.has(name) || !isRecord(table) || !Array.isArray(table.rows)) continue; const columns = Array.isArray(table.columns) ? table.columns : []; - const keyColumns = ( - Array.isArray(table.primary_key) ? table.primary_key : [] - ) - .map((column) => columns.indexOf(column)) - .filter((index) => index >= 0); + const keyColumns = Array.isArray(table.primary_key) + ? table.primary_key + : []; const seen = new Map(); for (const row of table.rows) { if (!Array.isArray(row)) continue; - const identity = - keyColumns.length > 0 - ? JSON.stringify(keyColumns.map((index) => row[index] ?? null)) - : JSON.stringify(row); + const identity = sqliteRowIdentity(columns, keyColumns, row).value; // Rows without a key are compared as a multiset of whole rows. const occurrence = seen.get(identity) ?? 0; seen.set(identity, occurrence + 1); From fe27f96720856cd07b5f1c9daf70ddd5b0d865ff Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:15:25 +0800 Subject: [PATCH 056/151] test(ci): restore client registration and doctor checks (#9128) Apply the two-file repair proposed in #9109 to unblock main CI. Preserve every original test assertion and use the current registration command owner. Co-authored-by: gensokyo-dev --- tests/boundary/cli/mcpDoctorHelpCli.test.ts | 4 +++- .../filesystem/codebuddyClientConfiguration.test.ts | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/boundary/cli/mcpDoctorHelpCli.test.ts b/tests/boundary/cli/mcpDoctorHelpCli.test.ts index 60a561274f..50b5ad2ba5 100644 --- a/tests/boundary/cli/mcpDoctorHelpCli.test.ts +++ b/tests/boundary/cli/mcpDoctorHelpCli.test.ts @@ -4,7 +4,7 @@ import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js"; const CLI_INTEGRATION_TIMEOUT_MS = 60_000; -describe("MCP doctor help boundary", () => { +describe("MCP doctor output routing boundary", () => { workspaceCliTest( "keeps output options from selecting the CLI-derived MCP doctor", async ({ cli }) => { @@ -43,7 +43,9 @@ describe("MCP doctor help boundary", () => { }, CLI_INTEGRATION_TIMEOUT_MS, ); +}); +describe("MCP doctor help boundary", () => { workspaceCliTest( "answers the advertised help option instead of validating it", async ({ cli }) => { diff --git a/tests/boundary/filesystem/codebuddyClientConfiguration.test.ts b/tests/boundary/filesystem/codebuddyClientConfiguration.test.ts index 68a5934371..4ff4a49852 100644 --- a/tests/boundary/filesystem/codebuddyClientConfiguration.test.ts +++ b/tests/boundary/filesystem/codebuddyClientConfiguration.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; -import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; import { skillDestinations } from "../../../src/application/SetupSkill.js"; import { supportedClients } from "../../../src/application/SupportedClients.js"; @@ -12,7 +12,9 @@ import { systemUninstallHost } from "../../../src/application/Uninstall.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; -const command = npxRegistrationCommand(process.platform); +const command = currentClientRegistrationCommand({ + platform: process.platform, +}); beforeEach(clearClientLocationEnvironment); afterEach(() => vi.unstubAllEnvs()); From f3d6f45315b854406a9066cf9ce3ccfdf1ad390d Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Fri, 6 Nov 2026 18:50:12 +0800 Subject: [PATCH 057/151] fix(desktop): preserve exported SQLite text encoding (#9123) Preserve original SQLite TEXT bytes and decode them using the database encoding in exported Python readers. Extend the real encoding matrix to leading FEFF and quoted column names. 45 focused tests, native public CLI and full export/helper parity checks, formatting and check:fast pass. Retains the original correction by ShuaixinHuang from #9074. Fixes #9004. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) --- src/domain/desktop/appExportPython.ts | 31 ++++--- .../providers/desktopExportPython.test.ts | 2 +- .../desktopExportSqliteEncoding.test.ts | 85 +++++++++++++++++++ 3 files changed, 106 insertions(+), 12 deletions(-) create mode 100644 tests/boundary/providers/desktopExportSqliteEncoding.test.ts diff --git a/src/domain/desktop/appExportPython.ts b/src/domain/desktop/appExportPython.ts index 9d1d871dc2..40753258bf 100644 --- a/src/domain/desktop/appExportPython.ts +++ b/src/domain/desktop/appExportPython.ts @@ -149,14 +149,15 @@ def _load_dconf(home: Path) -> None: file=sys.stderr) -def _sqlite_text(data: bytes): - text = data.decode("utf-8", "replace") +def _sqlite_text(data: bytes, encoding: str): + text = data.decode(encoding, "replace") return {"text": text, "bytes_hex": data.hex()} if "�" in text else text -def _cell(value): - if isinstance(value, bytes): - return {"blob_hex": value.hex()} +def _cell(value, encoding: str): + if isinstance(value, str): + data = bytes.fromhex(value[1:]) + return _sqlite_text(data, encoding) if value[0] == "t" else {"blob_hex": data.hex()} if isinstance(value, int) and abs(value) > MAX_SAFE_INTEGER: return {"integer": str(value)} # JSON has no infinities or negative zero; REA's SQLite reader spells them as strings. @@ -165,11 +166,18 @@ def _cell(value): return value -def _table(db, name: str, primary_key: list) -> dict: +def _table(db, name: str, primary_key: list, encoding: str) -> dict: quoted = '"' + name.replace('"', '""') + '"' - cursor = db.execute(f"select * from main.{quoted} not indexed limit {MAX_ROWS + 1}") - rows = [[_cell(value) for value in row] for row in cursor] - return {"columns": [column[0] for column in cursor.description], "primary_key": primary_key, + columns = [column[0] for column in db.execute(f"select * from main.{quoted} limit 0").description] + # SQLite owns TEXT-to-BLOB conversion, so hex retains the database's original encoding. + projected = [] + for name in columns: + column = '"' + name.replace('"', '""') + '"' + projected.append(f"CASE typeof({column}) WHEN 'text' THEN 't' || hex(CAST({column} AS BLOB)) " + f"WHEN 'blob' THEN 'b' || hex({column}) ELSE {column} END") + cursor = db.execute(f"select {', '.join(projected)} from main.{quoted} not indexed limit {MAX_ROWS + 1}") + rows = [[_cell(value, encoding) for value in row] for row in cursor] + return {"columns": columns, "primary_key": primary_key, "rows": rows[:MAX_ROWS], "truncated": len(rows) > MAX_ROWS} @@ -182,8 +190,9 @@ def _sqlite(path: Path) -> dict: keys = {name: [key for (key,) in db.execute( "select name from pragma_table_info(?, 'main') where pk > 0 order by pk", (name,))] for name in names[:MAX_TABLES]} - db.text_factory = _sqlite_text - tables = {name: _table(db, name, keys[name]) for name in names[:MAX_TABLES]} + encoding = {"UTF-8": "utf-8", "UTF-16le": "utf-16-le", "UTF-16be": "utf-16-be"}[ + db.execute("PRAGMA encoding").fetchone()[0]] + tables = {name: _table(db, name, keys[name], encoding) for name in names[:MAX_TABLES]} cut = len(names) > MAX_TABLES return {"format": "sqlite", "view": {"tables": tables, "tables_truncated": cut}, "truncated": cut or any(table["truncated"] for table in tables.values())} diff --git a/tests/boundary/providers/desktopExportPython.test.ts b/tests/boundary/providers/desktopExportPython.test.ts index 73a01001bd..7783989ea2 100644 --- a/tests/boundary/providers/desktopExportPython.test.ts +++ b/tests/boundary/providers/desktopExportPython.test.ts @@ -422,7 +422,7 @@ it("spells infinite and negative-zero SQLite reals as REA's SQLite reader does", "-I", "-B", "-c", - `${imports}${PYTHON_HELPERS}\nprint(json.dumps([_cell(v) for v in (math.inf, -math.inf, -0.0, 0.0, 1.5)]))`, + `${imports}${PYTHON_HELPERS}\nprint(json.dumps([_cell(v, "utf-8") for v in (math.inf, -math.inf, -0.0, 0.0, 1.5)]))`, ], { encoding: "utf8" }, ); diff --git a/tests/boundary/providers/desktopExportSqliteEncoding.test.ts b/tests/boundary/providers/desktopExportSqliteEncoding.test.ts new file mode 100644 index 0000000000..07dfa7a7e9 --- /dev/null +++ b/tests/boundary/providers/desktopExportSqliteEncoding.test.ts @@ -0,0 +1,85 @@ +import { writeFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { + PYTHON_HELPERS, + PYTHON_IMPORTS, +} from "../../../src/domain/desktop/appExportPython.js"; +import { PYTHON_TASK_EVALUATOR } from "../../../src/domain/desktop/appTaskPython.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it.each([ + ["UTF-8", "efbfbd", "80"], + ["UTF-16le", "fdff", "00d8"], + ["UTF-16be", "fffd", "d800"], +] as const)( + "retains %s text bytes in exported SQLite views and task checks", + async (encoding, hex, malformed) => { + const root = await createTestTempDirectory("rea-export-sqlite-encoding-"); + const imports = PYTHON_IMPORTS.split("\n") + .filter((line) => !line.includes("agentenv_protocol")) + .join("\n"); + const program = `${imports}${PYTHON_HELPERS}${PYTHON_TASK_EVALUATOR} +path = Path(sys.argv[1]) +with sqlite3.connect(path) as db: + db.execute("PRAGMA encoding=" + repr(sys.argv[2])) + db.execute('CREATE TABLE items(id INTEGER PRIMARY KEY, "body" TEXT, normal TEXT, raw BLOB, amount INTEGER, fraction REAL, missing, "tag""value" TEXT)') + db.execute('INSERT INTO items VALUES(1,?,?,?,?,?,NULL,?)', ("\ufffd", "\ufeffemoji 😀\ufeff", b"t00", 9007199254740993, 2.5, "value\t ")) + db.execute("INSERT INTO items(id, body) VALUES(2,CAST(X'" + sys.argv[3] + "' AS TEXT))") + native = db.execute('SELECT hex(CAST(body AS BLOB)) FROM items ORDER BY id').fetchall() +before = path.read_bytes() +entry = _sqlite(path) +checks = [{"id": "body-matches", "kind": "row_present", "file": "items.db", "table": "items", "where": {"id": 1, "body": {"text": "\ufffd", "bytes_hex": native[0][0].lower()}}}] +print(json.dumps({"entry": entry, "native": native, "verdicts": evaluate(checks, {"items.db": entry}), "unchanged": before == path.read_bytes()})) +`; + const script = join(root, "fixture.py"); + await writeFile(script, program, "utf8"); + const result = JSON.parse( + execFileSync( + "python3", + [ + "-I", + "-B", + "-X", + "utf8", + script, + join(root, "items.db"), + encoding, + malformed, + ], + { encoding: "utf8" }, + ), + ); + expect(result.native).toEqual([ + [hex.toUpperCase()], + [malformed.toUpperCase()], + ]); + expect(result.entry.view.tables.items.rows).toEqual([ + [ + 1, + { text: "\ufffd", bytes_hex: hex }, + "\ufeffemoji \u{1f600}\ufeff", + { blob_hex: "743030" }, + { integer: "9007199254740993" }, + 2.5, + null, + "value\t ", + ], + [ + 2, + { text: "\ufffd", bytes_hex: malformed }, + null, + null, + null, + null, + null, + null, + ], + ]); + expect(result.verdicts).toMatchObject([ + { id: "body-matches", result: true }, + ]); + expect(result.unchanged).toBe(true); + }, +); From abdbaa544ad3e63444a58bebd1708cc51b136a8b Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Fri, 6 Nov 2026 11:20:43 +0000 Subject: [PATCH 058/151] fix(rizin): report the output cap instead of a provider signal (#9033) Attribute REA capture-limit failures at the shared Rizin/objdump execution boundary. Preserve raw signals, output prefixes, unknown completion and incomplete-cleanup facts; report a termination request without claiming confirmed exit. 134 focused tests, check:fast and actual Rizin CLI controls over an owned ELF and /usr/bin/ls pass. Original proposal by cpruijsen. Fixes #9031. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) --- .../reverse/ReverseEngineeringService.ts | 18 ++++-- tests/composition/reverseEngineering.test.ts | 11 +++- .../reverseEngineeringEdgeCases.test.ts | 62 ++++++++++++++++++- 3 files changed, 82 insertions(+), 9 deletions(-) diff --git a/src/application/reverse/ReverseEngineeringService.ts b/src/application/reverse/ReverseEngineeringService.ts index 02fe3b53c3..f56c362e15 100644 --- a/src/application/reverse/ReverseEngineeringService.ts +++ b/src/application/reverse/ReverseEngineeringService.ts @@ -54,6 +54,7 @@ export type ReverseEngineeringOperation = | "execute_rizin_command"; const MAX_COMBINED_OUTPUT_BYTES = 16 * 1024 * 1024; +export const REVERSE_COMMAND_OUTPUT_LIMITATION = `REA stopped collecting output at its ${String(MAX_COMBINED_OUTPUT_BYTES)}-byte cap and requested termination of the provider process; the captured stdout and stderr are a prefix and command completion is unknown. Narrow the command or page its output.`; // Eight concurrent captures bound retained provider output to 128 MiB. const MAX_CONCURRENT_REVERSE_ENGINEERING_OPERATIONS = 8; @@ -453,6 +454,7 @@ export class ReverseEngineeringService { let cleanupIncomplete = false; let cleanupOnlyFailure = false; let completionStatus: "complete" | "unknown" = "complete"; + let outputLimitReached = false; let failure: AnalysisError | undefined; let cleanupOwner: ProviderProcessSupervisor | undefined; let earlyFailure: AnalysisError | undefined; @@ -507,6 +509,8 @@ export class ReverseEngineeringService { ); const capture = execFileOutputFailure(cause); const code = capture?.code ?? commandFailureCode(cause); + if (code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") + outputLimitReached = true; if (earlyFailure === undefined && code === "E2BIG") earlyFailure = new AnalysisResourceConstraintError( operation, @@ -742,12 +746,14 @@ export class ReverseEngineeringService { )} bytes contain invalid or incomplete UTF-8; replacement characters in the text capture do not preserve the original bytes.`, ] : []), - ...(outputTruncated - ? [ - "Captured provider output is partial because bytes were omitted; process completion is reported separately.", - ] - : []), - ...(signalName === null + ...(outputLimitReached + ? [REVERSE_COMMAND_OUTPUT_LIMITATION] + : outputTruncated + ? [ + "Captured provider output is partial because bytes were omitted; process completion is reported separately.", + ] + : []), + ...(signalName === null || outputLimitReached ? [] : [ `The provider process terminated by signal ${signalName}; captured output may be partial and command completion is unknown.`, diff --git a/tests/composition/reverseEngineering.test.ts b/tests/composition/reverseEngineering.test.ts index 4c353e2818..6242584ace 100644 --- a/tests/composition/reverseEngineering.test.ts +++ b/tests/composition/reverseEngineering.test.ts @@ -7,7 +7,10 @@ import { PassThrough } from "node:stream"; import { describe, expect, it } from "vitest"; -import { digestFile } from "../../src/application/reverse/ReverseEngineeringService.js"; +import { + digestFile, + REVERSE_COMMAND_OUTPUT_LIMITATION, +} from "../../src/application/reverse/ReverseEngineeringService.js"; import { TestReverseEngineeringService as ReverseEngineeringService } from "../support/reverseEngineeringService.js"; import { providerSnapshot } from "../support/reverseEngineeringCompositionFixtures.js"; import { createReverseEngineeringService } from "../../src/composition/reverseEngineering.js"; @@ -166,13 +169,17 @@ describe("reverse engineering command services", () => { command: "wx 00 @ 0", }); expect(result.ok).toBe(true); - if (result.ok) + if (result.ok) { expect(result.value.raw_result).toMatchObject({ stdout: "captured partial output", exit_code: null, output_truncated: true, completion_status: "unknown", }); + expect(result.value.limitations).toContain( + REVERSE_COMMAND_OUTPUT_LIMITATION, + ); + } } finally { await rm(directory, { recursive: true, force: true }); } diff --git a/tests/composition/reverseEngineeringEdgeCases.test.ts b/tests/composition/reverseEngineeringEdgeCases.test.ts index b268c90b29..401c9fc0c7 100644 --- a/tests/composition/reverseEngineeringEdgeCases.test.ts +++ b/tests/composition/reverseEngineeringEdgeCases.test.ts @@ -14,7 +14,10 @@ import { AnalysisCapabilityUnavailableError, AnalysisResourceConstraintError, } from "../../src/domain/analysisErrorCore.js"; -import { ReverseEngineeringService as CoreReverseEngineeringService } from "../../src/application/reverse/ReverseEngineeringService.js"; +import { + ReverseEngineeringService as CoreReverseEngineeringService, + REVERSE_COMMAND_OUTPUT_LIMITATION, +} from "../../src/application/reverse/ReverseEngineeringService.js"; import { createReverseEngineeringCommandPort } from "../../src/composition/reverseEngineering.js"; import { objdumpCommand } from "../../src/objdump/ObjdumpCommand.js"; import { @@ -29,6 +32,63 @@ import { withVerifierCleanup } from "../../scripts/lib/verifier-cleanup.mjs"; registerSharedReverseEngineeringEdgeCaseTests(); registerOwnedRizinCommandEvidenceTests({ sweepTokenOwnedProcesses: true }); +it.each(["objdump", "rizin"] as const)( + "retains %s output-limit Evidence when owned cleanup remains incomplete", + async (provider) => { + const { directory, path } = await createArtifact(); + const service = new ReverseEngineeringService({ + runOwned: async (_spawn, limits) => { + expect(limits.diagnosticBytes).toBe(16 * 1024 * 1024); + throw new OwnedCommandFailure( + "output-limit", + "diagnostic output exceeded its budget", + providerSnapshot({ + exitCode: null, + signal: "SIGTERM", + diagnosticTruncated: true, + }), + "owned descendant remains", + undefined, + ["run-id", "pid:42"], + ); + }, + }); + try { + const result = + provider === "objdump" + ? await service.inspectWithObjdump({ path, operation: "symbols" }) + : await service.executeRizinCommand({ path, command: "iI" }); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisOutputError", + cleanupIncomplete: true, + cleanup: { + reason: "owned descendant remains", + resources: ["run-id", "pid:42"], + }, + partialObservation: { + raw_result: { + stdout: "captured owned output", + exit_code: null, + signal: "SIGTERM", + output_truncated: true, + completion_status: "unknown", + artifact_sha256_after: null, + artifact_changed: null, + }, + limitations: expect.arrayContaining([ + REVERSE_COMMAND_OUTPUT_LIMITATION, + ]), + }, + }, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, +); + it("records pathless objdump architecture support as provider capability evidence", async () => { const { directory, path } = await createArtifact(); expect(objdumpCommand({ operation: "architectures" })).toEqual(["-i"]); From 14d2c51d851425364929c701a1fcd8b4e1b90098 Mon Sep 17 00:00:00 2001 From: Saim Shafique Date: Fri, 6 Nov 2026 17:05:09 +0500 Subject: [PATCH 059/151] fix(managed): decode only selected types in member inspection (#9044) Apply declaring-type selection before member-row admission, recovering only referenced unselected names. Reuse known cores and skip recovery for unscoped inspection. Add valid/malformed selected and unselected generic call regressions. 251 focused tests and check:fast pass. Public CLI scale reproduction and independent metadata oracle confirm complete selected members; public CoreLib/Roslyn/WPF facts remain identical. Real stdio MCP summary and paging retain complete source coverage and provenance. Original proposal by sx4im. The maintainer explicitly authorized integration and waived the human-declaration gate; unchecked author declarations are preserved without attesting human review. Fixes #8980. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) --- src/dotnet/ManagedMemberInspector.test.ts | 88 ++++++++++++++ src/dotnet/ManagedMemberInspector.ts | 53 +++++++- src/dotnet/ManagedMemberRows.ts | 96 ++++++++++++++- src/dotnet/ManagedMemberTypeInventory.test.ts | 77 ++++++++++++ src/dotnet/ManagedMethodSpecs.ts | 50 ++++++-- src/dotnet/ManagedPe.fixture.ts | 113 +++++++++++++----- 6 files changed, 431 insertions(+), 46 deletions(-) diff --git a/src/dotnet/ManagedMemberInspector.test.ts b/src/dotnet/ManagedMemberInspector.test.ts index 43807fc523..ca7a9a9f17 100644 --- a/src/dotnet/ManagedMemberInspector.test.ts +++ b/src/dotnet/ManagedMemberInspector.test.ts @@ -3042,6 +3042,94 @@ it("retains names of unselected fields without admitting their malformed signatu ).toEqual([]); }); +it("keeps names on call edges into unselected types", () => { + const bytes = buildManagedNestedTypeFixture({ topLevelTypes: true }); + const pe = readManagedPeLayout(bytes); + if (pe.cli === null) throw new Error("Expected CLI fixture"); + const { rva, size } = pe.cli.metadata; + const layout = readManagedMetadataLayout( + bytes, + pe.rvaToOffset(rva, size, "cli.metadata"), + size, + ); + // The selected method calls the MethodDef row owned by the unselected + // Inner type: skipping that row must not lose the edge target's name. + const method = metadataRowOffset(layout, 6, 2); + const methodRva = bytes.readUInt32LE(method); + const body = tokenBody([[call, 0x06000001]]); + bytes.set(body, pe.rvaToOffset(methodRva, body.length, "fixture CIL")); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + { + typeNames: ["OtherInner"], + }, + ); + expect(result.methods).toMatchObject([{ token: "0x06000002", name: "M" }]); + expect(result.call_edges).toMatchObject([ + { + target_token: "0x06000001", + target_name: "M", + target_declaring_type: "Inner", + }, + ]); + expect(result.coverage).toEqual({ state: "complete", issues: [] }); +}); + +it.each([false, true])( + "keeps selected and unselected generic call targets named (malformed=%s)", + (malformed) => { + const bytes = buildManagedPeFixture({ + additionalEmptyTypes: 1, + methodRowCount: 2, + firstTypeMethodRowCount: 1, + distinctMemberNames: true, + methodSignature: genericDef, + methodSpecMethods: [2 << 1, 1 << 1], + ...(malformed + ? { methodSpecInstantiation: Buffer.from([0x0a, 0x00]) } + : {}), + ilBody: tokenBody([ + [call, 0x2b00_0001], + [call, 0x2b00_0002], + ]), + }); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + { typeNames: ["Fixture.Empty0"] }, + ); + expect(result.methods).toMatchObject([ + { token: "0x06000001", name: "Method0" }, + ]); + expect(result.call_edges).toMatchObject([ + { + target_token: "0x2b000001", + target_kind: "method-spec", + target_name: "Method1", + target_declaring_type: "Fixture.Program", + }, + { + target_token: "0x2b000002", + target_kind: "method-spec", + target_name: "Method0", + target_declaring_type: "Fixture.Empty0", + }, + ]); + expect(result.coverage.state).toBe(malformed ? "partial" : "complete"); + expect( + result.coverage.issues.map(({ code, scope }) => [code, scope]), + ).toEqual( + malformed + ? [ + ["invalid-blob", "metadata.MethodSpec:0x2b000001"], + ["invalid-blob", "metadata.MethodSpec:0x2b000002"], + ] + : [], + ); + }, +); + it.each([false, true])( "keeps ordinary TypeSpec operands distinct from GENERICINST heads (generic=%s)", (generic) => { diff --git a/src/dotnet/ManagedMemberInspector.ts b/src/dotnet/ManagedMemberInspector.ts index 493084fa82..49926d1bcd 100644 --- a/src/dotnet/ManagedMemberInspector.ts +++ b/src/dotnet/ManagedMemberInspector.ts @@ -24,6 +24,7 @@ import { readManagedMetadataLayout } from "./ManagedMetadataLayout.js"; import { appendManagedParseIssues, ManagedReaderFailure, + readManagedValue, uniqueManagedParseIssues, managedMetadataFailureStatus, type ManagedMetadataHeaderFacts, @@ -33,7 +34,7 @@ import { readTypeDefinitions, type DecodedTypeDefinition, } from "./ManagedMemberInspectorCore.js"; -import { edgeTargets } from "./ManagedMethodSpecs.js"; +import { edgeTargets, methodSpecMethod } from "./ManagedMethodSpecs.js"; import { managedSignatureTypeNames } from "./ManagedSignatureTypeNames.js"; import { typeNameLookup } from "./ManagedTypeNames.js"; import { managedTypeReferences } from "./ManagedTypeReferences.js"; @@ -46,8 +47,10 @@ import { parseFields, parseMemberRefs, parseMethods, + readReferencedMemberCores, type SelectedTypeTokens, } from "./ManagedMemberRows.js"; +import type { MetadataTokenFact } from "./ManagedMetadataHeaps.js"; const unavailable = ( target: BinaryTarget, @@ -325,6 +328,52 @@ export const inspectManagedMembersBytes = ( methodLimit, }); appendManagedParseIssues(issues, fields.issues, methods.issues); + // Names for edge targets outside the selection come only from the rows + // selected method bodies actually reference. Unselected rows were + // skipped without reading them, so those names are recovered here + // instead of spending the budget on the whole assembly. + const referenced = new Map< + string, + Pick + >(); + const needsCore = ( + reference: Pick, + ): boolean => + reference.table === 4 + ? !fields.core.has(reference.token) + : reference.table === 6 && !methods.core.has(reference.token); + // Unscoped inspection already read every core. Selected members also + // retain their cores; only missing edge targets need the recovery pass. + if (selected !== null) + for (const { tokenInstructions } of methods.methods) + for (const { reference } of tokenInstructions) { + if (needsCore(reference)) referenced.set(reference.token, reference); + else if ( + reference.table === 0x2b && + reference.row >= 1 && + reference.row <= (layout.table(0x2b)?.rowCount ?? 0) + ) { + const spec = readManagedValue( + () => methodSpecMethod(bytes, layout, reference.row), + issues, + ); + const generic = spec?.method; + if ( + generic !== undefined && + generic.kind === "valid" && + needsCore(generic) + ) + referenced.set(generic.token, generic); + } + } + const referencedCores = readReferencedMemberCores( + bytes, + layout, + ranges, + memberLists, + referenced.values(), + ); + appendManagedParseIssues(issues, referencedCores.issues); const resolvedMethods = methods.methods.map(({ method, catchTokens }) => ({ ...method, body: { @@ -368,7 +417,7 @@ export const inspectManagedMembersBytes = ( bytes, layout, methods.methods, - [fields.core, memberRefs.core, methods.core], + [referencedCores.core, fields.core, memberRefs.core, methods.core], signatureTypeName, ); appendManagedParseIssues(issues, targets.issues); diff --git a/src/dotnet/ManagedMemberRows.ts b/src/dotnet/ManagedMemberRows.ts index 1cb1e4c4f4..cbc229af46 100644 --- a/src/dotnet/ManagedMemberRows.ts +++ b/src/dotnet/ManagedMemberRows.ts @@ -91,6 +91,21 @@ export const parseFields = ( issues, ); for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { + // The owner comes from the shared range lookup, not the row itself: a + // row belonging to no selected type is skipped without a cursor or name + // charge. A selected method that references such a row keeps its name + // through readReferencedMemberCores instead. + const owner = + ownerRow === undefined + ? undefined + : readManagedValue(() => ownerRow(row), issues); + const declared = owner === undefined ? undefined : ranges[owner - 1]; + if ( + owner !== undefined && + selected !== null && + !isSelected(selected, declared) + ) + continue; readManagedValue(() => { const { token, rowOffset, flags, nameIndex, signatureIndex } = readFieldDefinitionRow(bytes, layout, row); @@ -100,11 +115,7 @@ export const parseFields = ( nameIndex, layout.strings.size, ); - const owner = - ownerRow === undefined - ? 0 - : (readManagedValue(() => ownerRow(row), issues) ?? 0); - const declared = ranges[owner - 1]; + const owned = owner ?? 0; core.set(token, { token, name, @@ -133,7 +144,7 @@ export const parseFields = ( kind: "field", identitySha256: memberTargetIdentity( name, - declared === undefined ? null : { table: 2, row: owner }, + declared === undefined ? null : { table: 2, row: owned }, decoded, declarationTypeName, ), @@ -371,6 +382,18 @@ export const parseMethods = ({ isSelected(selected, declared) || // A type whose name is unreadable may be one the caller requested. declared?.fullName === null); + // As in parseFields: a row belonging to no selected type is skipped + // without reading it. The unscanned count keeps its existing meaning: + // a skipped row hides a selected body only when its owner is unknown + // or unreadably named, which is exactly when mayBeSelected holds. + if ( + owner !== undefined && + selected !== null && + !isSelected(selected, declared) + ) { + if (mayBeSelected) unscannedMethods += 1; + continue; + } const produced = methods.length; readManagedValue(() => { const { @@ -460,6 +483,67 @@ export const parseMethods = ({ return { methods, core, issues, unscannedMethods }; }; +/** + * Core entries for Field and MethodDef rows a selected method's body + * references. Edge targets outside the selection keep their names, but + * reading every unselected row to name them spends the representation + * budget on the whole assembly; only referenced rows are read here. + * MemberRef tokens need no fill: member references decode in full. + */ +export const readReferencedMemberCores = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + ranges: readonly TypeRange[], + memberLists: TypeMemberListFacts, + references: Iterable>, +): { + readonly core: ReadonlyMap; + readonly issues: readonly ManagedParseIssue[]; +} => { + const core = new Map(); + const issues: ManagedParseIssue[] = []; + const ownerRows = new Map number) | undefined>(); + const ownerOf = (table: 4 | 6, row: number): number => { + if (!ownerRows.has(table)) + ownerRows.set( + table, + readManagedValue( + () => memberDeclaringTypeRowLookup(bytes, layout, table, memberLists), + issues, + ), + ); + return ownerRows.get(table)?.(row) ?? 0; + }; + for (const { table, row, token } of references) { + if (table !== 4 && table !== 6) continue; + if (core.has(token)) continue; + // A body can name a row its table does not contain: the edge stays + // unnamed, as before, instead of reporting a row that was never read. + // A row that exists but cannot be read still reports its issue below. + if (row < 1 || row > (layout.table(table)?.rowCount ?? 0)) continue; + readManagedValue(() => { + const declared = ranges[ownerOf(table, row) - 1]; + const { nameIndex } = + table === 4 + ? readFieldDefinitionRow(bytes, layout, row) + : readMethodDefinitionRow(bytes, layout, row); + const name = readMetadataString( + bytes, + layout, + nameIndex, + layout.strings.size, + ); + core.set(token, { + token, + name, + declaringType: declared?.fullName ?? null, + kind: table === 4 ? "field" : "method", + }); + }, issues); + } + return { core, issues }; +}; + /** * Member kinds each operand kind may name. A MemberRef whose signature does * not decode keeps its name on calls, as before, but is never named a field. diff --git a/src/dotnet/ManagedMemberTypeInventory.test.ts b/src/dotnet/ManagedMemberTypeInventory.test.ts index 5a66314462..c05469b3fd 100644 --- a/src/dotnet/ManagedMemberTypeInventory.test.ts +++ b/src/dotnet/ManagedMemberTypeInventory.test.ts @@ -81,6 +81,83 @@ it("lets a caller select an inventoried empty type without decoding the oversize expect(result.methods).toEqual([]); }); +it("decodes a single selected type to completion on a game-scale assembly", () => { + // Slay the Spire 2 sts2.dll scale from #8980: 9,409 TypeDefs, 49,738 + // MethodDefs, 40,037 Fields, 11,442 MemberRefs, 58,698 CustomAttributes. + // A single-type selection must not spend the representation budget on the + // whole assembly before the selection is applied. The selected type owns + // one field and one method while the rest of the member inventory stays + // on the last type, so this also proves the selected members decode + // instead of merely proving that skipping unselected rows saves budget. + const bytes = buildManagedPeFixture({ + additionalEmptyTypes: 9400, + fieldRowCount: 40037, + methodRowCount: 49738, + memberRefRowCount: 11442, + customAttributeRowCount: 58698, + distinctMemberNames: true, + firstTypeFieldRowCount: 1, + firstTypeMethodRowCount: 1, + }); + const target = managedPeFixtureTarget(bytes); + const inventory = inspectManagedMembersBytes(bytes, target); + const selected = inventory.types.find( + (type) => type.full_name === "Fixture.Empty0", + ); + expect(selected).toMatchObject({ + token: "0x02000001", + field_list: { count: 1 }, + method_list: { count: 1 }, + }); + const result = inspectManagedMembersBytes(bytes, target, { + typeNames: ["Fixture.Empty0"], + }); + expect(result.coverage).toEqual({ state: "complete", issues: [] }); + expect(result.selection).toEqual({ + type_names: ["Fixture.Empty0"], + unmatched_type_names: [], + }); + expect(result.types).toHaveLength(1); + expect(result.types[0]?.token).toBe(selected?.token); + expect(result.fields).toHaveLength(1); + expect(result.fields[0]).toMatchObject({ + name: "Field0", + declaring_type: "Fixture.Empty0", + declaring_type_token: "0x02000001", + }); + expect(result.methods).toHaveLength(1); + expect(result.methods[0]).toMatchObject({ + name: "Method0", + declaring_type: "Fixture.Empty0", + declaring_type_token: "0x02000001", + }); + expect(result.methods[0]?.body.status).toBe("present"); + expect(result.methods[0]?.body.instruction_count).toBe(4); + expect(result.methods[0]?.body.decoded_instruction_count).toBe(4); + // The selected body references the selected field and a member reference; + // both resolve to their tokens instead of staying anonymous. + expect(result.methods[0]?.body.anchors).toContainEqual( + expect.objectContaining({ + opcode: "ldfld", + operand_kind: "field", + operand: "0x04000001", + }), + ); + expect(result.methods[0]?.body.anchors).toContainEqual( + expect.objectContaining({ + opcode: "call", + operand_kind: "method", + operand: "0x0a000001", + }), + ); + // Member references stay assembly-wide: the selection narrows declaring + // types, not the reference inventory behind call edges. + expect(result.member_refs).toHaveLength(11442); + expect( + result.member_refs.find((ref) => ref.token === "0x0a000001"), + ).toMatchObject({ name: "Member0" }); +}); + it("preserves explicit type selection when its selected body exhausts capacity", () => { const bytes = buildManagedMemberCapacityFixture(); const result = inspectManagedMembersBytes( diff --git a/src/dotnet/ManagedMethodSpecs.ts b/src/dotnet/ManagedMethodSpecs.ts index 8b8705bba3..fa3db52623 100644 --- a/src/dotnet/ManagedMethodSpecs.ts +++ b/src/dotnet/ManagedMethodSpecs.ts @@ -21,6 +21,38 @@ import { } from "./ManagedMetadataHeaps.js"; import { managedFailure, readManagedValue } from "./ManagedReaderFailure.js"; +/** + * A MethodSpec row's Method column and instantiation blob: the generic + * method it instantiates and the row's byte offset. Shared by the edge + * naming below and by callers that resolve a referenced spec before the + * edge pass, so both use the same coded-index interpretation. + */ +export const methodSpecMethod = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + row: number, +): { + readonly method: ReturnType; + readonly methodRaw: number; + readonly instantiation: number; + readonly offset: number; +} => { + const cursor = metadataRowCursor(bytes, layout, 0x2b, row); + const methodRaw = cursor.readIndex(layout.codedIndexSize("MethodDefOrRef")); + const instantiation = cursor.readIndex(layout.blobIndexSize); + return { + method: decodeMetadataCodedIndex( + methodRaw, + MANAGED_CODED_INDEXES.MethodDefOrRef.bits, + MANAGED_CODED_INDEXES.MethodDefOrRef.tables, + layout.rowCounts, + ), + methodRaw, + instantiation, + offset: cursor.start, + }; +}; + /** * Index fields, member references, and methods by token, then name each * MethodSpec an inspected body calls after the generic method it instantiates, @@ -82,20 +114,16 @@ const readMethodSpec = ( ): void => { const { token, row } = reference; const scope = `metadata.MethodSpec:${token}`; - const cursor = metadataRowCursor(bytes, layout, 0x2b, row); - const methodRaw = cursor.readIndex(layout.codedIndexSize("MethodDefOrRef")); - const instantiation = cursor.readIndex(layout.blobIndexSize); - const method = decodeMetadataCodedIndex( - methodRaw, - MANAGED_CODED_INDEXES.MethodDefOrRef.bits, - MANAGED_CODED_INDEXES.MethodDefOrRef.tables, - layout.rowCounts, + const { method, methodRaw, instantiation, offset } = methodSpecMethod( + bytes, + layout, + row, ); if (method.kind !== "valid") issues.push({ code: "invalid-row", scope, - offset: cursor.start, + offset, detail: method.kind === "null" ? "MethodSpec method coded index 0x0 is null, but the Method column must reference a row" @@ -111,7 +139,7 @@ const readMethodSpec = ( const instantiationFacts = specSignatureFacts( layout, readMetadataBlob(bytes, layout, instantiation, layout.blob?.size ?? 0), - { form: "instantiation", scope, offset: cursor.start }, + { form: "instantiation", scope, offset }, signatureTypeName, ); const { types } = instantiationFacts; @@ -138,7 +166,7 @@ const readMethodSpec = ( arity === null ? "MethodSpec method does not have a decodable method signature" : `MethodSpec supplies ${String(types.length)} type argument(s), but its method declares ${String(arity)} generic parameter(s)`, - cursor.start, + offset, ); if ( target !== undefined && diff --git a/src/dotnet/ManagedPe.fixture.ts b/src/dotnet/ManagedPe.fixture.ts index 564a506f69..64949cbff0 100644 --- a/src/dotnet/ManagedPe.fixture.ts +++ b/src/dotnet/ManagedPe.fixture.ts @@ -18,12 +18,27 @@ interface ManagedPeFixtureOptions { }[]; readonly fieldName?: string; readonly fieldSignature?: Buffer; + /** Field rows; the last TypeDef owns them all, as with MethodDef rows. */ + readonly fieldRowCount?: number; + /** + * Field rows owned by the first TypeDef; the last TypeDef owns the rest. + * Lets a test select a type with a few members while the bulk of the + * member inventory stays on another type. + */ + readonly firstTypeFieldRowCount?: number; + /** + * Give every Field, MethodDef and MemberRef row its own name, like a real + * assembly, instead of sharing one name across all rows. + */ + readonly distinctMemberNames?: boolean; readonly ilBody?: Buffer; readonly malformedAssemblyReferenceRows?: readonly number[]; readonly malformedCustomAttributeRows?: readonly number[]; readonly metadataExtraData?: number; readonly metadataValidMaskExtra?: bigint; readonly methodRowCount?: number; + /** MethodDef rows owned by the first TypeDef; the last TypeDef owns the rest. */ + readonly firstTypeMethodRowCount?: number; readonly methodName?: string; readonly methodFlags?: number; readonly methodSignature?: Buffer; @@ -603,7 +618,10 @@ interface FixtureMetadataRowContext { readonly additionalTypeNames: readonly number[]; readonly typeNamespace: number; readonly fieldName: number; + readonly fieldNames: readonly number[]; readonly methodName: number; + readonly methodNames: readonly number[]; + readonly memberRefNames: readonly number[]; readonly pinvokeModuleName: number | null; readonly pinvokeImportName: number | null; readonly resourceName: number; @@ -651,6 +669,13 @@ const repeatedRows = ( createRow: () => Buffer, ): readonly Buffer[] => Array.from({ length: count }, createRow); +/** One name per row when distinct names were generated, else the shared name. */ +const cycleName = (names: readonly number[], index: number): number => { + const name = names[index % names.length]; + if (name === undefined) throw new Error("Fixture name list is empty"); + return name; +}; + const customAttributeRows = ( { options, @@ -791,7 +816,7 @@ const fixtureIndexWidths = ({ [0, options.moduleRowCount ?? 1], [1, indexes.typeReferences.length + 1], [2, indexes.additionalTypeNames.length + 1], - [4, 1], + [4, options.fieldRowCount ?? 1], [6, options.methodRowCount ?? 1], [8, 0], [10, options.memberRefRowCount ?? 1], @@ -836,6 +861,32 @@ const fixtureIndexWidths = ({ }; }; +const typeDefTable = ( + options: ManagedPeFixtureOptions, + indexes: FixtureMetadataRowContext["indexes"], + heapSizes: FixtureMetadataRowContext["heapSizes"], + widths: FixtureIndexWidths, + nestedRows: ReadonlySet, +): readonly [number, readonly Buffer[]] => [ + 2, + [...indexes.additionalTypeNames, indexes.typeName].map((name, index) => + typeDefRow({ + name, + flags: nestedRows.has(index + 1) ? 0x0010_0002 : 0x0010_0001, + namespace: indexes.typeNamespace, + fieldList: index === 0 ? 1 : 1 + (options.firstTypeFieldRowCount ?? 0), + methodList: index === 0 ? 1 : 1 + (options.firstTypeMethodRowCount ?? 0), + stringSize: heapSizes.strings, + typeDefOrRefSize: widths.typeDefOrRef, + fieldListSize: widths.table(4), + methodListSize: widths.table(6), + ...(options.extendsRaw === undefined + ? {} + : { extendsRaw: options.extendsRaw }), + }), + ), +]; + const fixtureMetadataRows = ({ options, methodRva, @@ -861,41 +912,23 @@ const fixtureMetadataRows = ({ ), ], typeRefTable(options, indexes, heapSizes, widths), - [ - 2, - [...indexes.additionalTypeNames, indexes.typeName].map((name, index) => - typeDefRow({ - name, - flags: nestedRows.has(index + 1) ? 0x0010_0002 : 0x0010_0001, - namespace: indexes.typeNamespace, - fieldList: 1, - methodList: 1, - stringSize: heapSizes.strings, - typeDefOrRefSize: widths.typeDefOrRef, - fieldListSize: widths.table(4), - methodListSize: widths.table(6), - ...(options.extendsRaw === undefined - ? {} - : { extendsRaw: options.extendsRaw }), - }), - ), - ], + typeDefTable(options, indexes, heapSizes, widths, nestedRows), [ 4, - [ + Array.from({ length: options.fieldRowCount ?? 1 }, (_, index) => fieldRow( - indexes.fieldName, + cycleName(indexes.fieldNames, index), indexes.fieldSignature, heapSizes.strings, heapSizes.blobs, ), - ], + ), ], [ 6, - repeatedRows(options.methodRowCount ?? 1, () => + Array.from({ length: options.methodRowCount ?? 1 }, (_, index) => methodDefRow({ - name: indexes.methodName, + name: cycleName(indexes.methodNames, index), signature: indexes.methodSignature, stringSize: heapSizes.strings, blobSize: heapSizes.blobs, @@ -909,9 +942,9 @@ const fixtureMetadataRows = ({ ], [ 10, - repeatedRows(options.memberRefRowCount ?? 1, () => + Array.from({ length: options.memberRefRowCount ?? 1 }, (_, index) => memberRefRow({ - name: indexes.constructorName, + name: cycleName(indexes.memberRefNames, index), signature: indexes.constructorSignature, stringSize: heapSizes.strings, blobSize: heapSizes.blobs, @@ -1029,6 +1062,29 @@ const buildManagedFixtureMetadata = ( const typeNamespace = strings.add(options.typeNamespace ?? "Fixture"); const fieldName = strings.add(options.fieldName ?? "counter"); const methodName = strings.add(options.methodName ?? "Main"); + const distinctNames = ( + count: number, + single: T, + distinct: (index: number) => T, + ): readonly T[] => + options.distinctMemberNames === true + ? Array.from({ length: count }, (_, index) => distinct(index)) + : [single]; + const fieldNames = distinctNames( + options.fieldRowCount ?? 1, + fieldName, + (index) => strings.add(`Field${String(index)}`), + ); + const methodNames = distinctNames( + options.methodRowCount ?? 1, + methodName, + (index) => strings.add(`Method${String(index)}`), + ); + const memberRefNames = distinctNames( + options.memberRefRowCount ?? 1, + constructorName, + (index) => strings.add(`Member${String(index)}`), + ); const pinvokeModuleName = options.pinvoke === undefined ? null @@ -1091,7 +1147,10 @@ const buildManagedFixtureMetadata = ( additionalTypeNames, typeNamespace, fieldName, + fieldNames, methodName, + methodNames, + memberRefNames, pinvokeModuleName, pinvokeImportName, resourceName, From 5303199163049131aa2d9b59b23b57e179984fc4 Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:16:31 +0800 Subject: [PATCH 060/151] feat(api-contract): inspect local GraphQL SDL and field references (#9140) * feat(api-contract): inspect local GraphQL SDL and field references * fix(api-contract): retain caller metadata and bound selection work --- docs/api-contracts.md | 80 +- docs/mcp-contracts.md | 21 +- licenses/npm-dependencies.txt | 7 +- licenses/npm-runtime-notices.txt | 32 +- package-lock.json | 10 + package.json | 1 + src/application/EvidenceBundleCommands.ts | 6 +- .../apiContract/ApiContractRead.ts | 22 +- .../apiContract/ApiContractService.ts | 43 +- .../apiContract/ApiContractTraversal.ts | 44 + .../apiContract/GraphqlContractImport.ts | 207 +++ .../GraphqlContractService.test.ts | 304 +++++ .../investigation/EvidenceLedger.test.ts | 46 + .../investigation/EvidenceLedger.ts | 13 +- src/cli/analysisEvidenceDelivery.ts | 14 +- src/cli/apiContractCommand.ts | 41 +- src/cli/cliEvidenceCallerPath.ts | 8 +- src/contracts/analysisViewToolContracts.ts | 2 +- src/contracts/apiContractToolContracts.ts | 19 +- src/contracts/toolEffects.test.ts | 2 + src/domain/analysisErrorBase.ts | 2 + src/domain/analysisView/analysisView.ts | 2 + .../analysisView/analysisViewRequest.ts | 2 + src/domain/analysisView/analysisViewResult.ts | 18 +- src/domain/analysisView/apiContractView.ts | 12 +- .../analysisView/graphqlContractView.ts | 293 +++++ src/domain/apiContract/GraphqlDocuments.ts | 88 ++ src/domain/apiContract/GraphqlInventory.ts | 180 +++ .../apiContract/GraphqlOperationFacts.ts | 210 +++ src/domain/apiContract/GraphqlSchemaFacts.ts | 106 ++ src/domain/apiContract/apiContractQuery.ts | 60 +- src/domain/apiContract/graphqlContract.ts | 142 ++ .../apiContract/graphqlContractQuery.ts | 74 ++ .../apiContract/parseGraphqlContract.test.ts | 340 +++++ .../apiContract/parseGraphqlContract.ts | 105 ++ src/domain/evidence.test.ts | 39 + src/domain/evidence.ts | 30 +- .../registerApplicationTools/apiContract.ts | 6 +- .../applications/graphqlContract.test.ts | 415 ++++++ .../cli/cliEvidenceCallerPath.test.ts | 38 +- .../filesystem/evidenceBundleFiles.test.ts | 8 +- tests/fixtures/cliMcpEvidenceParity.ts | 9 +- tests/fixtures/graphql/LICENSE | 21 + tests/fixtures/graphql/README.md | 15 + tests/fixtures/graphql/swapi.graphql.txt | 1166 +++++++++++++++++ 45 files changed, 4204 insertions(+), 99 deletions(-) create mode 100644 src/application/apiContract/ApiContractTraversal.ts create mode 100644 src/application/apiContract/GraphqlContractImport.ts create mode 100644 src/application/apiContract/GraphqlContractService.test.ts create mode 100644 src/domain/analysisView/graphqlContractView.ts create mode 100644 src/domain/apiContract/GraphqlDocuments.ts create mode 100644 src/domain/apiContract/GraphqlInventory.ts create mode 100644 src/domain/apiContract/GraphqlOperationFacts.ts create mode 100644 src/domain/apiContract/GraphqlSchemaFacts.ts create mode 100644 src/domain/apiContract/graphqlContract.ts create mode 100644 src/domain/apiContract/graphqlContractQuery.ts create mode 100644 src/domain/apiContract/parseGraphqlContract.test.ts create mode 100644 src/domain/apiContract/parseGraphqlContract.ts create mode 100644 tests/acceptance/applications/graphqlContract.test.ts create mode 100644 tests/fixtures/graphql/LICENSE create mode 100644 tests/fixtures/graphql/README.md create mode 100644 tests/fixtures/graphql/swapi.graphql.txt diff --git a/docs/api-contracts.md b/docs/api-contracts.md index 76cf4b9cde..bc9e752db8 100644 --- a/docs/api-contracts.md +++ b/docs/api-contracts.md @@ -1,12 +1,17 @@ # Offline API contract inventory -`inspect_api_contract` / `rea inspect-api-contract` reads one local OpenAPI +`inspect_api_contract` / `rea inspect-api-contract` supports local OpenAPI JSON +and GraphQL SDL (`format: "graphql-sdl"`). Neither profile contacts endpoints. + +## OpenAPI JSON + +The default profile reads one local OpenAPI 3.0, 3.1 or 3.2 JSON document. It inventories inline operation slots under `paths`; the facts describe declarations, not implemented, reachable or authorized runtime behavior. YAML, AsyncAPI, callbacks/webhooks, schema graphs, contract diffs and traffic correlation are outside this version. -The importer never resolves references, opens sibling documents, evaluates +The OpenAPI importer never resolves references, opens sibling documents, evaluates examples or contacts declared servers. OpenAPI 3.2 `query` and `additionalOperations` follow the [Path Item contract](https://spec.openapis.org/oas/v3.2.1.html#path-item-object), including case-sensitive additional HTTP method spelling. @@ -100,3 +105,74 @@ public CLI/MCP tests. These check advertised schemas, complete/selected parity, retained paging, partial references, zero requests to a loopback reference server and unchanged source bytes. They do not establish runtime endpoint behavior or full OpenAPI conformance. + +## GraphQL SDL and operation documents + +Select `format: "graphql-sdl"` for SDL. Optionally supply `operations_path` to +one local document containing operations and fragments. Both MCP paths are +absolute; CLI accepts relative spellings: + +```sh +rea inspect-api-contract ./schema.graphql --contractFormat graphql-sdl \ + --operationsPath ./queries.graphql --detail summary \ + --view '{"kind":"graphql-contract","collection":"references","limit":20}' \ + --evidence_output ./graphql.evidence.json --json +``` + +Collections are `types`, `fields`, `definitions`, `references`, `fragment-spreads` +and `diagnostics`. Each supports `fields`, `offset` and `limit` (default 64). +Exact batch filters include type/field/definition/reference IDs, type names, +`field_keys` such as `Query.viewer`, owning type names, binding/spread states +and diagnostic codes. Filters intersect; an empty array selects nothing. +Identity, source and applicable uncertainty fields always remain. Retained pages +and bundle-based queries use the same `graphql-contract` views without rereading +SDL or rebuilding its schema model. + +Types and fields retain individual declaration occurrences, including extensions, +list/non-null wrappers, implemented interfaces and union member names. Operation +and fragment definitions own field references with aliases and parent-reference +IDs. Each reference links to a supplied SDL field occurrence, remains `model-only` +for a library-provided field such as `__typename`, or records an exact unknown +reason. Rejected schema construction keeps declarations but cannot supply trusted +bindings. Missing or duplicate fragment names remain explicit. Fragments are +visited once in their declared type scope and linked by spread edges; cycles +are never recursively expanded. + +The versioned `graphql-sdl-field-bindings-v1` profile records the GraphQL library +version. It performs syntax parsing and schema construction for field resolution; +it does not establish whole-schema/operation validity, spread applicability, +reachability, authorization or endpoint behavior. Arguments, defaults, +descriptions, directive behavior, enum value inventory, introspection JSON, +federation composition and version comparison remain outside this profile. + +Every fact has a document role, SHA-256, UTF-16 start/end offsets and parser token +line/column. Offsets address decoded UTF-8 text, including an optional BOM, rather +than byte positions. The two files are independently verified snapshots, not an +atomic pair. Fact IDs identify source occurrences; `field_key` separately supports +exact name-based joins and does not assert equivalence across revisions. CLI +retains its auxiliary path spelling in `caller_input_paths.operations_path`, +outside semantic identity, as well as its primary `caller_input_path`. + +Malformed operation text retains the SDL inventory with partial coverage and a +source-located diagnostic. A primary parser refusal or later resource/cancellation +failure retains known source identities and available interpreted Evidence; +syntax diagnostics do not quote source text. Complete Evidence contains facts, +not original document bytes. An exhausted page never upgrades source coverage. + +Each file has the same configurable maximum of 32 MiB. Conservatively charged +facts share a 32 MiB allowance; text/AST allocation is admitted against available +heap. The parser admits at most 262,144 lexical tokens and 65,536 comments per +document; subsequent iterative AST admission limits traversal depth to 128. +These are REA resource policies, not GraphQL language limits. Very deep input can +be refused by the parser before AST admission. Selected replies reduce transport +and retained queries reuse immutable facts; they do not avoid the initial parse. + +The committed SWAPI SDL is an unchanged public application fixture with provenance +in `tests/fixtures/graphql/README.md`. It supplies 53 types and 243 fields; +verification does not execute its API. + +Large complete inventories can exceed the default MCP response budget even when +selected pages fit. Use `export_evidence_bundle` with a caller-selected local +path for complete retained Evidence, or CLI complete JSON/bundle publication. +A transport refusal does not erase a completed retained analysis. Narrow pages +and source coverage remain available on the same continuing connection. diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index bb0e7dc7f6..1d50599359 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -574,15 +574,18 @@ after target content was read. ## Offline API contract declarations -`inspect_api_contract` reads a local OpenAPI 3.0/3.1/3.2 JSON document without -an active target or any requests to declared servers or references. Default -summary delivery retains complete declaration Evidence; an optional -`api-contract` view selects operations, unresolved Path Items or diagnostics in -this call. Exact filters, field selection and paging share the retained -`inspect_analysis_view` projector. Coverage describes inline paths only and -never establishes runtime implementation or full specification conformance. -See [offline API contracts](api-contracts.md) for identities, source pointers, -resource bounds and CLI publication. +`inspect_api_contract` reads local OpenAPI 3.0/3.1/3.2 JSON (default) or +GraphQL SDL (`format: "graphql-sdl"`) without an active target or network requests. +OpenAPI inventories inline path operations and unresolved Path Items. GraphQL +inventories type/field declarations; optional `operations_path` adds operation +owners, aliases, SDL field bindings and explicit fragment-spread edges without +expanding fragments. Unresolved bindings and unavailable models stay explicit. +Default summaries retain complete Evidence; `api-contract` or `graphql-contract` +views select facts immediately with exact filters, fields and paging. Retained +`inspect_analysis_view` uses the same projector without reopening inputs. +Coverage describes examined declarations and does not establish runtime behavior +or full specification/operation validity. See [offline API contracts](api-contracts.md) +for source identities, parser allowances, supported scope and CLI publication. ## Retained application Evidence inputs diff --git a/licenses/npm-dependencies.txt b/licenses/npm-dependencies.txt index 1a32572080..c6ba7868d5 100644 --- a/licenses/npm-dependencies.txt +++ b/licenses/npm-dependencies.txt @@ -2,7 +2,7 @@ REA npm dependency inventory Prepared from the root package-lock.json; refresh this inventory when locked dependencies change. License identifiers are upstream lockfile declarations, not a legal assessment. Runtime includes transitive and optional platform packages; development includes build, test and website tooling. -Locked package entries: 963; runtime: 180. +Locked package entries: 964; runtime: 181. Full runtime copyright/license/NOTICE texts: npm-runtime-notices.txt. @algolia/abtesting@1.25.0 | development | MIT @@ -2825,6 +2825,11 @@ gopd@1.2.0 | runtime | MIT Package: https://www.npmjs.com/package/gopd/v/1.2.0 Archive: https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz Integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== +graphql@17.1.0 | runtime | MIT + Lockfile path: node_modules/graphql + Package: https://www.npmjs.com/package/graphql/v/17.1.0 + Archive: https://registry.npmjs.org/graphql/-/graphql-17.1.0.tgz + Integrity: sha512-JKCOa7rSQNrket/JFhuua86hljUO+IIpyUHfKxlKD6FEfbrLX8jc1FDrA0KPKI+s8OspBCF/5npYccAICHXPpQ== handlebars@4.7.10 | development | MIT Lockfile path: node_modules/handlebars Package: https://www.npmjs.com/package/handlebars/v/4.7.10 diff --git a/licenses/npm-runtime-notices.txt b/licenses/npm-runtime-notices.txt index 2c443c2e9f..a05826ff50 100644 --- a/licenses/npm-runtime-notices.txt +++ b/licenses/npm-runtime-notices.txt @@ -1,7 +1,7 @@ REA third-party npm runtime copyright, license and NOTICE texts Collected from integrity-verified locked npm archives. Release-specific supplements and upstream notice gaps are identified in their entries. External engines are separately obtained; see THIRD_PARTY_NOTICES.md. -Runtime dependency fingerprint (SHA-256): 09f8570cf168ddd3ad7c877dbcdd1ef5013de3af884a37fd27e6da3b99b49664 +Runtime dependency fingerprint (SHA-256): 13e2e43ee348b0cc210699dff53278d47e9e94c65934796c82eabc912f7c3938 ===== @babel/helper-string-parser@8.0.6 ===== Declared license: MIT @@ -4249,6 +4249,36 @@ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +===== graphql@17.1.0 ===== +Declared license: MIT +Archive: https://registry.npmjs.org/graphql/-/graphql-17.1.0.tgz +Integrity: sha512-JKCOa7rSQNrket/JFhuua86hljUO+IIpyUHfKxlKD6FEfbrLX8jc1FDrA0KPKI+s8OspBCF/5npYccAICHXPpQ== +Scope: npm runtime dependency, including optional platform packages. + +--- package/LICENSE --- + +MIT License + +Copyright (c) GraphQL Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ===== has-property-descriptors@1.0.2 ===== Declared license: MIT Archive: https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz diff --git a/package-lock.json b/package-lock.json index bf410ac47d..e71a1b96d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "cross-spawn": "7.0.6", "diff": "9.0.0", "evmole": "0.9.3", + "graphql": "17.1.0", "har-schema": "2.0.0", "ignore": "7.0.6", "incur": "0.6.1", @@ -8108,6 +8109,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graphql": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-17.1.0.tgz", + "integrity": "sha512-JKCOa7rSQNrket/JFhuua86hljUO+IIpyUHfKxlKD6FEfbrLX8jc1FDrA0KPKI+s8OspBCF/5npYccAICHXPpQ==", + "license": "MIT", + "engines": { + "node": "^22.0.0 || ^24.0.0 || >=26.0.0" + } + }, "node_modules/handlebars": { "version": "4.7.10", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz", diff --git a/package.json b/package.json index a2849ebf63..d9ac233a28 100644 --- a/package.json +++ b/package.json @@ -372,6 +372,7 @@ "cross-spawn": "7.0.6", "diff": "9.0.0", "evmole": "0.9.3", + "graphql": "17.1.0", "har-schema": "2.0.0", "ignore": "7.0.6", "incur": "0.6.1", diff --git a/src/application/EvidenceBundleCommands.ts b/src/application/EvidenceBundleCommands.ts index 26bf80e437..37413f48e2 100644 --- a/src/application/EvidenceBundleCommands.ts +++ b/src/application/EvidenceBundleCommands.ts @@ -139,7 +139,11 @@ export const deliverAnalysisEvidence = async ( // inherit the parent's selected input path. const selected = detail === "summary" && evidence.caller_input_path !== undefined - ? withEvidenceCallerInputPath(delivered.value, evidence.caller_input_path) + ? withEvidenceCallerInputPath( + delivered.value, + evidence.caller_input_path, + evidence.caller_input_paths, + ) : delivered.value; const published = await publishEvidenceDelivery({ bundle, diff --git a/src/application/apiContract/ApiContractRead.ts b/src/application/apiContract/ApiContractRead.ts index 1382042a1e..d44cf43193 100644 --- a/src/application/apiContract/ApiContractRead.ts +++ b/src/application/apiContract/ApiContractRead.ts @@ -22,11 +22,20 @@ import { API_CONTRACT_OPERATION as operation, API_CONTRACT_PROVIDER, } from "../../domain/apiContract/apiContract.js"; -import type { InspectApiContractInput } from "../../domain/apiContract/apiContractQuery.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { err, ok } from "../../domain/result.js"; -const inputFailure = (path: string, cause: unknown, message: string) => +interface ContractDocumentInput { + readonly path: string; + readonly max_file_bytes: number; + readonly parameter?: "path" | "operations_path"; +} +const inputFailure = ( + path: string, + cause: unknown, + message: string, + parameter = "path", +) => new AnalysisInputError( operation, { @@ -37,7 +46,7 @@ const inputFailure = (path: string, cause: unknown, message: string) => }, [ { - path: ["path"], + path: [parameter], reason: "invalid_format", message: `${message} (${path})`, }, @@ -46,7 +55,7 @@ const inputFailure = (path: string, cause: unknown, message: string) => const readFailure = ( failure: unknown, - input: InspectApiContractInput, + input: ContractDocumentInput, ): AnalysisError => { const { cause, origin } = artifactFilePrimaryFacts(failure); if ( @@ -78,7 +87,7 @@ const readFailure = ( }, ); if (cause.reason === "path" || cause.reason === "format") - return inputFailure(input.path, cause, cause.message); + return inputFailure(input.path, cause, cause.message, input.parameter); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") @@ -90,6 +99,7 @@ const readFailure = ( input.path, cause, "Selected API document could not be read", + input.parameter, ); } return new ArtifactOperationError( @@ -103,7 +113,7 @@ const readFailure = ( /** The existing owner verifies exactly the bytes whose SHA-256 the inventory cites. */ export const readApiContract = async ( - input: InspectApiContractInput, + input: ContractDocumentInput, resources: ArtifactResourceScope, signal?: AbortSignal, ) => { diff --git a/src/application/apiContract/ApiContractService.ts b/src/application/apiContract/ApiContractService.ts index 3802f225cc..a7c1f63705 100644 --- a/src/application/apiContract/ApiContractService.ts +++ b/src/application/apiContract/ApiContractService.ts @@ -1,10 +1,8 @@ import { isAbsolute } from "node:path"; -import { setImmediate } from "node:timers/promises"; import { getHeapStatistics } from "node:v8"; import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { - AnalysisCancelledError, AnalysisInputError, AnalysisResourceConstraintError, } from "../../domain/analysisErrorCore.js"; @@ -25,6 +23,7 @@ import { parseAnalysisInput } from "../AnalysisInput.js"; import { nativeJsonParseRequiredBytes } from "../JsonInputFile.js"; import { parseUtf8Json } from "../Utf8JsonInput.js"; import { readApiContract } from "./ApiContractRead.js"; +import { completeApiContractSteps } from "./ApiContractTraversal.js"; import { completeProviderEvidence } from "../ProviderOperationExecution.js"; const inputFailure = (message: string, cause?: unknown) => @@ -32,35 +31,6 @@ const inputFailure = (message: string, cause?: unknown) => { path: ["path"], reason: "invalid_format", message }, ]); -/** Cooperative traversals release their provisional state on cancellation. */ -const completeSteps = async ( - steps: Generator>, - signal?: AbortSignal, -): Promise> => { - let completed = false; - try { - for (;;) { - if (signal?.aborted) - return err( - new AnalysisCancelledError(operation, { cause: signal.reason }), - ); - const next = steps.next(); - if (next.done) { - completed = true; - if (!next.value.ok) return next.value; - // Include cancellation queued during the final synchronous projection. - await setImmediate(); - return signal?.aborted - ? err(new AnalysisCancelledError(operation, { cause: signal.reason })) - : next.value; - } - await setImmediate(); - } - } finally { - if (!completed) steps.return(undefined as never); - } -}; - const decodeDocument = ( bytes: Buffer, path: string, @@ -102,14 +72,14 @@ function* documentSteps(document: unknown, artifact: ApiContractArtifact) { } const importDocument = async ( - input: InspectApiContractInput, + input: Extract, bytes: Buffer, sha256: string, signal?: AbortSignal, ): Promise> => { const document = decodeDocument(bytes, input.path); if (!document.ok) return document; - const parsed = await completeSteps( + const parsed = await completeApiContractSteps( documentSteps(document.value, { path: input.path, sha256, @@ -141,7 +111,7 @@ const importDocument = async ( ); }; -/** Shared CLI/MCP import: one verified file read, no references, network or target execution. */ +/** Shared CLI/MCP entrypoint; format owners read selected files without network or execution. */ export const inspectApiContractEvidence = async ( rawInput: unknown, resources: ArtifactResourceScope, @@ -159,6 +129,11 @@ export const inspectApiContractEvidence = async ( return err( inputFailure("Expected an absolute filesystem path on this host."), ); + if (input.format === "graphql-sdl") { + const { importGraphqlContract } = + await import("./GraphqlContractImport.js"); + return importGraphqlContract(input, resources, options.signal); + } const source = await readApiContract(input, resources, options.signal); if (!source.ok) return source; const result = await importDocument( diff --git a/src/application/apiContract/ApiContractTraversal.ts b/src/application/apiContract/ApiContractTraversal.ts new file mode 100644 index 0000000000..fe984b6ff1 --- /dev/null +++ b/src/application/apiContract/ApiContractTraversal.ts @@ -0,0 +1,44 @@ +import { setImmediate } from "node:timers/promises"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; +import { err, type Result } from "../../domain/result.js"; +import { API_CONTRACT_OPERATION as operation } from "../../domain/apiContract/apiContract.js"; + +/** Let a producer retain already interpreted facts before releasing its provisional state. */ +const cancelSteps = ( + steps: Generator>, + reason: unknown, +): Result => { + const cancellation = new AnalysisCancelledError(operation, { cause: reason }); + try { + const stopped = steps.throw(cancellation); + if (stopped.done && !stopped.value.ok) return stopped.value; + } catch (cause) { + if (cause !== cancellation) throw cause; + } + return err(cancellation); +}; + +/** Cooperative traversals release their provisional state on cancellation. */ +export const completeApiContractSteps = async ( + steps: Generator>, + signal?: AbortSignal, +): Promise> => { + let completed = false; + try { + for (;;) { + if (signal?.aborted) return cancelSteps(steps, signal.reason); + const next = steps.next(); + if (next.done) { + completed = true; + if (!next.value.ok) return next.value; + // The Evidence owner classifies queued late cancellation with completed facts. + await setImmediate(); + return next.value; + } + await setImmediate(); + } + } finally { + if (!completed) steps.return(undefined as never); + } +}; diff --git a/src/application/apiContract/GraphqlContractImport.ts b/src/application/apiContract/GraphqlContractImport.ts new file mode 100644 index 0000000000..fe763cfbfd --- /dev/null +++ b/src/application/apiContract/GraphqlContractImport.ts @@ -0,0 +1,207 @@ +import { isAbsolute } from "node:path"; +import { getHeapStatistics } from "node:v8"; +import { version } from "graphql"; +import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { + primaryAnalysisError, + type AnalysisError, +} from "../../domain/analysisErrorBase.js"; +import { + AnalysisInputError, + AnalysisResourceConstraintError, +} from "../../domain/analysisErrorCore.js"; +import { createEvidence } from "../../domain/evidence.js"; +import { fileSnapshotPartialObservationSchema } from "../../domain/fileSnapshotObservation.js"; +import { + API_CONTRACT_OPERATION as operation, + API_CONTRACT_PROVIDER, + type ApiContractArtifact, +} from "../../domain/apiContract/apiContract.js"; +import type { InspectApiContractInput } from "../../domain/apiContract/apiContractQuery.js"; +import { + graphqlContractSchema, + type GraphqlContract, +} from "../../domain/apiContract/graphqlContract.js"; +import { GraphqlInventory } from "../../domain/apiContract/GraphqlInventory.js"; +import { parseGraphqlContractSteps } from "../../domain/apiContract/parseGraphqlContract.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { nativeJsonParseRequiredBytes } from "../JsonInputFile.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; +import { completeApiContractSteps } from "./ApiContractTraversal.js"; +import { readApiContract } from "./ApiContractRead.js"; + +type GraphqlInput = Extract; +const evidence = (input: GraphqlInput, report: GraphqlContract) => + createEvidence( + { path: input.path, format: "file", sha256: report.artifact.sha256 }, + API_CONTRACT_PROVIDER, + { + operation, + predicateType: "rea.api-contract-inventory", + parameters: { + path: input.path, + format: input.format, + max_file_bytes: input.max_file_bytes, + ...(input.operations_path === undefined + ? {} + : { operations_path: input.operations_path }), + }, + result: report, + confidence: "observed", + limitations: report.limitations, + locations: [ + { kind: "artifact-path", path: input.path }, + ...(input.operations_path === undefined + ? [] + : [{ kind: "artifact-path" as const, path: input.operations_path }]), + ], + }, + ); +const inputFailure = (parameter: string, message: string, cause?: unknown) => + new AnalysisInputError(operation, { cause }, [ + { path: [parameter], reason: "invalid_format", message }, + ]); + +const decode = ( + bytes: Buffer, + parameter: string, +): Result => { + // Reuse the existing conservative document-expansion admission before text/AST allocation. + const required = nativeJsonParseRequiredBytes(bytes.length); + const available = getHeapStatistics().total_available_size; + if (required > available) + return err( + new AnalysisResourceConstraintError( + operation, + "memory", + "Insufficient parser headroom for GraphQL text and AST expansion.", + { + input_bytes: bytes.length, + required_bytes: required, + available_heap_bytes: available, + }, + { + remediationAction: + "Select a smaller complete document or free parser memory; reply paging does not reduce parsing work.", + }, + ), + ); + try { + return ok( + new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes), + ); + } catch (cause) { + if (!(cause instanceof TypeError)) throw cause; + return err( + inputFailure( + parameter, + "The selected GraphQL document is not valid UTF-8.", + cause, + ), + ); + } +}; + +/** Partial facts and independently verified identities survive parsing and descriptor cleanup. */ +const retainFailure = ( + failure: AnalysisError, + input: GraphqlInput, + artifact: ApiContractArtifact, + operationArtifact: ApiContractArtifact | null, +) => { + const prior = + failure.partialObservation ?? + primaryAnalysisError(failure).partialObservation; + const parsed = graphqlContractSchema.safeParse(prior); + const snapshot = fileSnapshotPartialObservationSchema.safeParse(prior); + const secondary = + snapshot.success && + snapshot.data.kind === "file-snapshot-hash" && + snapshot.data.sha256 !== null && + snapshot.data.path !== null && + snapshot.data.path === input.operations_path + ? { + path: snapshot.data.path, + sha256: snapshot.data.sha256, + bytes: snapshot.data.bytes, + } + : operationArtifact; + const report = parsed.success + ? parsed.data + : new GraphqlInventory( + artifact, + secondary, + version, + input.operations_path !== undefined, + ).finish(true); + const projected = evidence(input, report); + let current: AnalysisError | undefined = failure; + while (current !== undefined) { + if ( + current.partialObservation === prior || + current.partialObservation === undefined + ) + current.partialObservation = projected; + current = current.cleanupPrimary; + } + return err(failure); +}; + +/** Loaded only for SDL; both adapters use this verified local-file interpretation. */ +export const importGraphqlContract = async ( + input: GraphqlInput, + resources: ArtifactResourceScope, + signal?: AbortSignal, +) => { + if (input.operations_path !== undefined && !isAbsolute(input.operations_path)) + return err( + inputFailure( + "operations_path", + "Expected an absolute filesystem path on this host.", + ), + ); + const loaded = await readApiContract(input, resources, signal); + if (!loaded.ok) return loaded; + const artifact = { + path: input.path, + sha256: loaded.value.sha256, + bytes: loaded.value.bytes.length, + }; + const schemaText = decode(loaded.value.bytes, "path"); + if (!schemaText.ok) + return retainFailure(schemaText.error, input, artifact, null); + let operations: { artifact: ApiContractArtifact; text: string } | undefined; + if (input.operations_path !== undefined) { + const second = await readApiContract( + { + path: input.operations_path, + max_file_bytes: input.max_file_bytes, + parameter: "operations_path", + }, + resources, + signal, + ); + if (!second.ok) return retainFailure(second.error, input, artifact, null); + const secondArtifact = { + path: input.operations_path, + sha256: second.value.sha256, + bytes: second.value.bytes.length, + }; + const text = decode(second.value.bytes, "operations_path"); + if (!text.ok) + return retainFailure(text.error, input, artifact, secondArtifact); + operations = { artifact: secondArtifact, text: text.value }; + } + const parsed = await completeApiContractSteps( + parseGraphqlContractSteps({ artifact, text: schemaText.value }, operations), + signal, + ); + if (!parsed.ok) + return retainFailure( + parsed.error, + input, + artifact, + operations?.artifact ?? null, + ); + return completeProviderEvidence(evidence(input, parsed.value), signal); +}; diff --git a/src/application/apiContract/GraphqlContractService.test.ts b/src/application/apiContract/GraphqlContractService.test.ts new file mode 100644 index 0000000000..2ab245c3e9 --- /dev/null +++ b/src/application/apiContract/GraphqlContractService.test.ts @@ -0,0 +1,304 @@ +import * as operationParser from "../../domain/apiContract/GraphqlOperationFacts.js"; +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { parseEvidence } from "../../domain/evidence.js"; +import { graphqlContractSchema } from "../../domain/apiContract/graphqlContract.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import { inspectApiContractEvidence } from "./ApiContractService.js"; +import * as parser from "../../domain/apiContract/parseGraphqlContract.js"; +import * as reader from "./ApiContractRead.js"; + +const files = async ( + schema: string | Uint8Array, + operations?: string | Uint8Array, +) => { + const root = await createTestTempDirectory("rea-graphql-contract-"); + const path = join(root, "SDL 雪.graphql"), + operations_path = join(root, "operations.graphql"); + await writeFile(path, schema); + if (operations !== undefined) await writeFile(operations_path, operations); + return { + path, + format: "graphql-sdl" as const, + ...(operations === undefined ? {} : { operations_path }), + }; +}; +const withScope = async ( + run: (resources: ArtifactResourceScope) => Promise, +) => { + const resources = new ArtifactResourceScope(); + try { + return await run(resources); + } finally { + await resources.close(); + } +}; +const reportOf = (value: unknown) => + graphqlContractSchema.parse(parseEvidence(value).normalized_result); + +it("binds both independently verified byte snapshots and preserves optional-BOM positions", async () => { + const schema = "\uFEFFtype Query { greeting: String }", + operations = "query Q { renamed: greeting }"; + const input = await files(schema, operations); + await withScope(async (resources) => { + const result = await inspectApiContractEvidence(input, resources); + if (!result.ok) throw result.error; + const report = reportOf(result.value); + expect(result.value.parameters).toEqual({ + ...input, + max_file_bytes: 33554432, + }); + expect(report.artifact.sha256).toBe( + createHash("sha256").update(schema).digest("hex"), + ); + expect(report.operations_artifact?.sha256).toBe( + createHash("sha256").update(operations).digest("hex"), + ); + expect( + schema.slice( + report.fields[0]!.source.start_utf16, + report.fields[0]!.source.end_utf16, + ), + ).toBe("greeting: String"); + expect(report.references[0]).toMatchObject({ + response_key: "renamed", + schema_field_id: report.fields[0]!.field_id, + state: "resolved", + }); + expect(await readFile(input.path, "utf8")).toBe(schema); + }); +}); + +it.each(["path", "operations_path"] as const)( + "preserves known source identities on invalid UTF-8 in %s", + async (parameter) => { + const input = await files( + parameter === "path" + ? Buffer.from([255]) + : "type Query { greeting: String }", + parameter === "operations_path" ? Buffer.from([255]) : "{ greeting }", + ); + await withScope(async (resources) => { + const result = await inspectApiContractEvidence(input, resources); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected invalid UTF-8"); + expect(result.error._tag).toBe("AnalysisInputError"); + expect(projectAnalysisError(result.error)).toMatchObject({ + details: { issues: [{ path: [parameter] }] }, + }); + const partial = reportOf(result.error.partialObservation); + expect(partial.artifact.path).toBe(input.path); + expect(partial.source_coverage).toMatchObject({ + status: "partial", + schema_declarations: "unavailable", + }); + expect(partial.operations_artifact !== null).toBe( + parameter === "operations_path", + ); + }); + }, +); + +it("retains SDL facts when the operation syntax is malformed", async () => { + const input = await files( + "type Query { greeting: String }", + "query Q { greeting(", + ); + await withScope(async (resources) => { + const result = await inspectApiContractEvidence(input, resources); + if (!result.ok) throw result.error; + const report = reportOf(result.value); + expect(report.fields).toHaveLength(1); + expect(report.source_coverage).toMatchObject({ + status: "partial", + schema_declarations: "complete", + operations: "unavailable", + }); + expect(report.diagnostics[0]).toMatchObject({ + code: "operations-parse-refused", + source: { role: "operations" }, + }); + }); +}); + +it("rejects primary SDL syntax with queryable partial Evidence and neutral diagnostics", async () => { + const input = await files( + 'type Query { field: "https://user:credential@host/path" }', + ); + await withScope(async (resources) => { + const result = await inspectApiContractEvidence(input, resources); + if (result.ok) throw new Error("Expected a syntax refusal"); + const report = reportOf(result.error.partialObservation); + expect(report.source_coverage.schema_declarations).toBe("unavailable"); + expect(report.diagnostics[0]?.source?.role).toBe("schema"); + expect(JSON.stringify(projectAnalysisError(result.error))).not.toContain( + "user:credential", + ); + }); +}); + +it("attributes auxiliary file admission failures to operations_path and retains the primary identity", async () => { + const input = await files("type Query { field: String }"); + await withScope(async (resources) => { + const missing = await inspectApiContractEvidence( + { ...input, operations_path: join(input.path, "missing") }, + resources, + ); + expect(missing.ok).toBe(false); + if (missing.ok) throw new Error("Expected missing operation file"); + expect(projectAnalysisError(missing.error)).toMatchObject({ + details: { issues: [{ path: ["operations_path"] }] }, + }); + expect(reportOf(missing.error.partialObservation).artifact.path).toBe( + input.path, + ); + const relative = await inspectApiContractEvidence( + { ...input, operations_path: "relative.graphql" }, + resources, + ); + expect(relative.ok).toBe(false); + if (!relative.ok) expect(relative.error.partialObservation).toBeUndefined(); + const wrongFormat = await inspectApiContractEvidence( + { path: input.path, operations_path: input.path }, + resources, + ); + expect(wrongFormat.ok).toBe(false); + }); +}); + +it("keeps real SDL facts on cooperative cancellation rather than dropping them with provisional AST state", async () => { + const input = await files( + "type Query { " + + Array.from({ length: 1000 }, (_, i) => `f${i}: String`).join(" ") + + " }", + ); + const controller = new AbortController(); + const real = reader.readApiContract; + const scheduled = vi + .spyOn(reader, "readApiContract") + .mockImplementationOnce(async (...args) => { + const result = await real(...args); + setImmediate(() => controller.abort("after-read")); + return result; + }); + try { + await withScope(async (resources) => { + const result = await inspectApiContractEvidence(input, resources, { + signal: controller.signal, + }); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe("after-read"); + const report = reportOf(result.error.partialObservation); + expect(report.fields.length).toBeGreaterThan(0); + expect(report.fields.length).toBeLessThan(1000); + expect(report.source_coverage).toMatchObject({ + status: "partial", + schema_declarations: "partial", + }); + }); + } finally { + scheduled.mockRestore(); + } +}); + +it("retains completed facts on cancellation queued after the last projection", async () => { + const input = await files("type Query { field: String }", "{ field }"); + const controller = new AbortController(); + const real = parser.parseGraphqlContractSteps; + const scheduled = vi + .spyOn(parser, "parseGraphqlContractSteps") + .mockImplementationOnce(function* (...args) { + const result = yield* real(...args); + setImmediate(() => controller.abort("after-projection")); + return result; + }); + try { + await withScope(async (resources) => { + const result = await inspectApiContractEvidence(input, resources, { + signal: controller.signal, + }); + if (result.ok) throw new Error("Expected late cancellation"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect( + reportOf(result.error.partialObservation).source_coverage.status, + ).toBe("complete-within-scope"); + }); + } finally { + scheduled.mockRestore(); + } +}); + +it("applies the selected file allowance to the auxiliary document and retains its known read coverage", async () => { + const input = await files("type Query { f: String }", "#".repeat(128)); + await withScope(async (resources) => { + const result = await inspectApiContractEvidence( + { ...input, max_file_bytes: 32 }, + resources, + ); + if (result.ok) throw new Error("Expected auxiliary file capacity refusal"); + expect(result.error._tag).toBe("AnalysisResourceConstraintError"); + const partial = reportOf(result.error.partialObservation); + expect(partial.artifact.path).toBe(input.path); + expect(partial.operations_artifact).toBeNull(); + expect(partial.source_coverage.operations).toBe("unavailable"); + }); +}); + +it.each(["query Q", "fragment Q on Query"])( + "cancels within one wide definition after a bounded field-binding chunk: %s", + async (definition) => { + const count = 4096; + const input = await files( + "type Query { value: String }", + definition + + " { " + + Array.from({ length: count }, (_, index) => `a${index}: value`).join( + " ", + ) + + " }", + ); + const controller = new AbortController(); + const real = operationParser.collectGraphqlOperationFacts; + const observed = vi + .spyOn(operationParser, "collectGraphqlOperationFacts") + .mockImplementationOnce(function* (...args) { + const steps = real(...args); + let scheduled = false; + try { + for (;;) { + const next = steps.next(); + if (next.done) return next.value; + if (!scheduled && args[2].references.length > 0) { + scheduled = true; + setImmediate(() => controller.abort("within-wide-operation")); + } + yield; + } + } finally { + steps.return(undefined); + } + }); + try { + await withScope(async (resources) => { + const result = await inspectApiContractEvidence(input, resources, { + signal: controller.signal, + }); + if (result.ok) + throw new Error("Expected cancellation within selections"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe("within-wide-operation"); + const report = reportOf(result.error.partialObservation); + expect(report.references.length).toBeGreaterThan(0); + expect(report.references.length).toBeLessThan(count); + expect(report.source_coverage.operations).toBe("partial"); + }); + } finally { + observed.mockRestore(); + } + }, +); diff --git a/src/application/investigation/EvidenceLedger.test.ts b/src/application/investigation/EvidenceLedger.test.ts index 85a68e7c49..0678df1aa5 100644 --- a/src/application/investigation/EvidenceLedger.test.ts +++ b/src/application/investigation/EvidenceLedger.test.ts @@ -46,6 +46,52 @@ it("accepts and replaces caller path metadata for the same semantic record", () ); }); +it("replaces auxiliary caller spelling on duplicate import and ignores record key order", () => { + const original = createEvidence(TARGET, PROVIDER, { + operation: "health", + parameters: {}, + result: true, + }); + const first = withEvidenceCallerInputPath(original, "./same-schema", { + operations_path: "./queries", + other: "./unchanged", + }); + const second = withEvidenceCallerInputPath(original, "./same-schema", { + other: "./unchanged", + operations_path: "./alias/../queries", + }); + const ledger = new EvidenceLedger(); + expect(ledger.record(first).ok).toBe(true); + expect(second.evidence_id).toBe(first.evidence_id); + const imported = ledger.import(createEvidenceBundle([second])); + expect(imported).toMatchObject({ + ok: true, + value: { + recordsAdded: 0, + changed: true, + replacements: [{ kind: "metadata" }], + }, + }); + expect(ledger.get(first.evidence_id)?.caller_input_paths).toEqual( + second.caller_input_paths, + ); + expect(first.caller_input_paths?.operations_path).toBe("./queries"); + const reordered = withEvidenceCallerInputPath(original, "./same-schema", { + operations_path: "./alias/../queries", + other: "./unchanged", + }); + expect(ledger.import(createEvidenceBundle([reordered]))).toMatchObject({ + ok: true, + value: { changed: false, replacements: [] }, + }); + const unannotated = withEvidenceCallerInputPath(original, "./same-schema"); + expect(ledger.import(createEvidenceBundle([unannotated]))).toMatchObject({ + ok: true, + value: { changed: true }, + }); + expect(ledger.get(first.evidence_id)?.caller_input_paths).toBeUndefined(); +}); + describe("evidence ledger recording", () => { it("deduplicates and atomically imports bundles", () => { const evidence = createEvidence(TARGET, PROVIDER, { diff --git a/src/application/investigation/EvidenceLedger.ts b/src/application/investigation/EvidenceLedger.ts index 403f346a4f..7d9372284d 100644 --- a/src/application/investigation/EvidenceLedger.ts +++ b/src/application/investigation/EvidenceLedger.ts @@ -12,7 +12,11 @@ import { import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; import { UnknownRegistryError } from "../../domain/unknownRegistryError.js"; -import { isImmutableEvidence, type Evidence } from "../../domain/evidence.js"; +import { + isImmutableEvidence, + evidenceDisplayMetadataAgree, + type Evidence, +} from "../../domain/evidence.js"; import { safeParseImmutableEvidence } from "../../domain/evidenceValidation.js"; import { createResidualUnknown, @@ -154,7 +158,7 @@ export class EvidenceLedger { continue; } if (existing === evidence) continue; - if (!displayMetadataAgree(existing, evidence)) { + if (!evidenceDisplayMetadataAgree(existing, evidence)) { recordChanges.set(evidence.evidence_id, evidence); replacements.push({ kind: "metadata", @@ -693,8 +697,3 @@ const readLedgerEvidence = ( const unknownRevisionKey = (unknown: ResidualUnknown): string => `${unknown.unknown_id}:${String(unknown.revision)}`; - -const displayMetadataAgree = (left: Evidence, right: Evidence): boolean => - left.subject?.name === right.subject?.name && - left.subject?.local_path === right.subject?.local_path && - left.caller_input_path === right.caller_input_path; diff --git a/src/cli/analysisEvidenceDelivery.ts b/src/cli/analysisEvidenceDelivery.ts index 0517c8acbc..bf89dcde49 100644 --- a/src/cli/analysisEvidenceDelivery.ts +++ b/src/cli/analysisEvidenceDelivery.ts @@ -61,6 +61,8 @@ interface CliEvidenceDelivery { readonly evidenceOutput: string | undefined; /** Exact one-shot CLI target spelling, when this invocation selects a file. */ readonly callerInputPath?: string; + /** Verbatim named auxiliary path choices for the same direct invocation. */ + readonly callerInputPaths?: Readonly>; /** Derived workflows publish their selected source closure with the result. */ readonly sourceEvidence?: readonly Evidence[]; /** Invocation exclusions cover final cleanup and publication paths/diagnostics. */ @@ -197,7 +199,11 @@ export const deliverCliAnalysisEvidence = async ( const complete = await finishAnalysisCommand( input.callerInputPath === undefined ? run - : bindCliEvidenceCallerPath(input.callerInputPath, run), + : bindCliEvidenceCallerPath( + input.callerInputPath, + run, + input.callerInputPaths, + ), service, providerId, operation, @@ -290,7 +296,11 @@ export const publishCliAnalysisEvidence = async ( // inspect_analysis_view calls own their own input and do not inherit it. const selected = detail === "summary" && complete.caller_input_path !== undefined - ? withEvidenceCallerInputPath(delivered.value, complete.caller_input_path) + ? withEvidenceCallerInputPath( + delivered.value, + complete.caller_input_path, + complete.caller_input_paths, + ) : delivered.value; const inspectionSources = analysisEvidenceSources(complete); if (!inspectionSources.ok) return undelivered(inspectionSources.error); diff --git a/src/cli/apiContractCommand.ts b/src/cli/apiContractCommand.ts index 46628a3014..56465d2e89 100644 --- a/src/cli/apiContractCommand.ts +++ b/src/cli/apiContractCommand.ts @@ -13,8 +13,9 @@ import { API_CONTRACT_PROVIDER, } from "../domain/apiContract/apiContract.js"; import { - apiContractViewRequestSchema, - inspectApiContractInputSchema, + apiContractViewSelectionSchema, + apiContractFormatSchema, + apiContractFileBytesSchema, } from "../domain/apiContract/apiContractQuery.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { @@ -26,7 +27,7 @@ import { const deliverySchema = z .object({ detail: analysisEvidenceDeliveryOptions.detail, - view: apiContractViewRequestSchema.optional(), + view: apiContractViewSelectionSchema.optional(), }) .superRefine((value, context) => { if (value.view !== undefined && value.detail !== "summary") @@ -45,18 +46,28 @@ export const registerApiContractCommand = ( ): void => { cli.command(CLI_COMMANDS.inspectApiContract, { description: - "Inventory local OpenAPI 3.0/3.1/3.2 JSON declarations offline without resolving references", + "Inspect local OpenAPI JSON or GraphQL SDL declarations and field bindings offline", args: z.object({ - path: localPathStringSchema.describe("Local OpenAPI JSON path"), + path: localPathStringSchema.describe( + "Local OpenAPI JSON or GraphQL SDL path", + ), }), options: z.object({ ...analysisEvidenceDeliveryOptions, - maxFileBytes: inspectApiContractInputSchema.shape.max_file_bytes, + maxFileBytes: apiContractFileBytesSchema, + contractFormat: apiContractFormatSchema + .default("openapi-json") + .describe("Input API contract syntax: OpenAPI JSON or GraphQL SDL"), + operationsPath: localPathStringSchema + .optional() + .describe( + "Local operation/fragment document, only with --contractFormat graphql-sdl", + ), view: z .string() .optional() .describe( - "JSON api-contract page; requires --detail summary and --evidence_output", + "JSON api-contract or graphql-contract page; requires --detail summary and --evidence_output", ), }), run: ({ args, options }) => @@ -73,12 +84,28 @@ export const registerApiContractCommand = ( await import("../application/apiContract/ApiContractService.js"); return deliverCliAnalysisEvidence({ callerInputPath: args.path, + ...(options.operationsPath === undefined + ? {} + : { + callerInputPaths: { operations_path: options.operationsPath }, + }), run: () => inspectApiContractEvidence( { path: absoluteCallerPath(args.path), + format: options.contractFormat, + ...(options.operationsPath === undefined + ? {} + : { + operations_path: absoluteCallerPath( + options.operationsPath, + ), + }), max_file_bytes: options.maxFileBytes, detail: "complete", + ...(selected.value.view === undefined + ? {} + : { view: selected.value.view }), }, resources, { signal }, diff --git a/src/cli/cliEvidenceCallerPath.ts b/src/cli/cliEvidenceCallerPath.ts index 800459d9fc..01d7516c8e 100644 --- a/src/cli/cliEvidenceCallerPath.ts +++ b/src/cli/cliEvidenceCallerPath.ts @@ -13,19 +13,25 @@ export const bindCliEvidenceCallerPath = ( callerPath: string, run: () => Promise>, + additionalPaths?: Readonly>, ): (() => Promise>) => async () => { const result = await run(); if (result.ok) return { ok: true, - value: withEvidenceCallerInputPath(result.value, callerPath), + value: withEvidenceCallerInputPath( + result.value, + callerPath, + additionalPaths, + ), }; if (result.error.partialObservation !== undefined) { try { result.error.partialObservation = withEvidenceCallerInputPath( parseEvidence(result.error.partialObservation), callerPath, + additionalPaths, ); } catch (cause) { // Raw or invalid partial observations must retain their original form. diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index 18a5edb9d9..46baf75bfc 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_analysis_view"), kind: "application", description: - 'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Use web-bundle collections endpoints or inferences to page endpoint observations and inferences from retained analyze_web_bundle Evidence; summary returns target URL, origin, and counts. For retained trace_feature Evidence, kind native-feature-trace pages matches, references or unknowns; matches filter exact address, references filter exact source_address and target_address, source_index identifies each original row, and list_unknowns pages durable question heads. Inspect selected views of supported completed Evidence without repeating analysis. For procedure_pseudo_code Evidence, summary reports text availability and the original selection; kind native with facet pseudocode pages the original UTF-16 text. Other native facets require analyze_function. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent also supports summary and browser-scenario steps/events/transactions/text pages, preserving step_indices, event_kinds, transaction_ids and selected artifacts. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state (a list also learns the filters, search, sort and paging its recordings verify, and answers new values of them from the state), and every other read tool is a recorded view with a reason. It also reports a write plan per state-changing tool (create, update, delete or document, built from its first successful call) and write_clock; a plan is served only when replaying the capture shows a later read confirming its effect and none contradicting it. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. The environment-coverage view, view {kind: "environment-coverage", template_rules: [...]}, summarizes that state without its entities: reads.state and reads.recorded count read tools answered from the state and replayed (recorded_reasons per view reason), writes.served lists the served write tools, and writes.withheld lists each other write tool with its reason, plan record, contradicting read and a hint about what to record next; it refuses what the environment-state view refuses. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. The file may also be an Evidence bundle written by CLI --evidence_output or export_evidence_bundle; select evidence_id when it contains more than one record. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings. Exact names and declaring_types filters intersect before paging where supported; fields selects columns (distinct from collection: "fields"). Identity/source_index, module MVID, source coverage and unresolved filter counts remain; omit body to avoid IL anchors. Omitted fields preserves full rows. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. APK signing views use kind apk-signature and collection signers/digests/lineage, with ids, signer_ids, schemes, fields and paging. Identity links remain when fields is empty. Declared identities and digest matches do not establish signature verification or authenticity. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types/generic_method_instances/generic_method_issues. Select indices, fields and pages; sections/images/types/methods/fields/properties/events/parameters/return_types/field_types/parameter_types/generic_method_instances accept exact names (native type names match retained rendered values, preserving same-name rows; null names never match), types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses and generic_method_instances accept addresses/states (generic_method_instances also method_indices), preserving every folded alternative; generic_method_issues accept rows. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native load-image Evidence uses kind native-load-image with collection source_files/mappings/relocations/entry_points/entry_context/checks/sections, original indices, collection-specific fields and offset/limit paging. Null collections remain unrecorded or inapplicable; pages preserve unsupported verification. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence accepts kind summary for identity, availability and facet counts, or kind native for selected facts: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native_api and value-flow observations. For parameter type paths when value flow is recorded, use facet: "value_flow_parameters"; its decompiler-observed path may be passed to inspect_native_data_type, but exact database lookup can be unavailable and does not by itself establish type identity. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Retained batch_decompile Evidence accepts kind summary for counts and sizes, or view: {kind: "batch-decompile", collection: "items", where: {statuses: ["unavailable", "error"]}} for body-free rows with exact indices, addresses, procedure_addresses or statuses (include_pseudocode adds bodies), or collection "pseudocode" with an index to page one body. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', + 'For inspect_api_contract parents, graphql-contract pages retained SDL types/fields, operation definitions, field references, fragment-spreads or diagnostics with exact filters and selected columns. Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Use web-bundle collections endpoints or inferences to page endpoint observations and inferences from retained analyze_web_bundle Evidence; summary returns target URL, origin, and counts. For retained trace_feature Evidence, kind native-feature-trace pages matches, references or unknowns; matches filter exact address, references filter exact source_address and target_address, source_index identifies each original row, and list_unknowns pages durable question heads. Inspect selected views of supported completed Evidence without repeating analysis. For procedure_pseudo_code Evidence, summary reports text availability and the original selection; kind native with facet pseudocode pages the original UTF-16 text. Other native facets require analyze_function. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent also supports summary and browser-scenario steps/events/transactions/text pages, preserving step_indices, event_kinds, transaction_ids and selected artifacts. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state (a list also learns the filters, search, sort and paging its recordings verify, and answers new values of them from the state), and every other read tool is a recorded view with a reason. It also reports a write plan per state-changing tool (create, update, delete or document, built from its first successful call) and write_clock; a plan is served only when replaying the capture shows a later read confirming its effect and none contradicting it. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. The environment-coverage view, view {kind: "environment-coverage", template_rules: [...]}, summarizes that state without its entities: reads.state and reads.recorded count read tools answered from the state and replayed (recorded_reasons per view reason), writes.served lists the served write tools, and writes.withheld lists each other write tool with its reason, plan record, contradicting read and a hint about what to record next; it refuses what the environment-state view refuses. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. The file may also be an Evidence bundle written by CLI --evidence_output or export_evidence_bundle; select evidence_id when it contains more than one record. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings. Exact names and declaring_types filters intersect before paging where supported; fields selects columns (distinct from collection: "fields"). Identity/source_index, module MVID, source coverage and unresolved filter counts remain; omit body to avoid IL anchors. Omitted fields preserves full rows. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. APK signing views use kind apk-signature and collection signers/digests/lineage, with ids, signer_ids, schemes, fields and paging. Identity links remain when fields is empty. Declared identities and digest matches do not establish signature verification or authenticity. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types/generic_method_instances/generic_method_issues. Select indices, fields and pages; sections/images/types/methods/fields/properties/events/parameters/return_types/field_types/parameter_types/generic_method_instances accept exact names (native type names match retained rendered values, preserving same-name rows; null names never match), types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses and generic_method_instances accept addresses/states (generic_method_instances also method_indices), preserving every folded alternative; generic_method_issues accept rows. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native load-image Evidence uses kind native-load-image with collection source_files/mappings/relocations/entry_points/entry_context/checks/sections, original indices, collection-specific fields and offset/limit paging. Null collections remain unrecorded or inapplicable; pages preserve unsupported verification. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence accepts kind summary for identity, availability and facet counts, or kind native for selected facts: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native_api and value-flow observations. For parameter type paths when value flow is recorded, use facet: "value_flow_parameters"; its decompiler-observed path may be passed to inspect_native_data_type, but exact database lookup can be unavailable and does not by itself establish type identity. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Retained batch_decompile Evidence accepts kind summary for counts and sizes, or view: {kind: "batch-decompile", collection: "items", where: {statuses: ["unavailable", "error"]}} for body-free rows with exact indices, addresses, procedure_addresses or statuses (include_pseudocode adds bodies), or collection "pseudocode" with an index to page one body. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ diff --git a/src/contracts/apiContractToolContracts.ts b/src/contracts/apiContractToolContracts.ts index 833eab1cc1..60e94729b5 100644 --- a/src/contracts/apiContractToolContracts.ts +++ b/src/contracts/apiContractToolContracts.ts @@ -1,9 +1,11 @@ +import { graphqlContractSchema } from "../domain/apiContract/graphqlContract.js"; import { z } from "zod"; import { apiContractSchema } from "../domain/apiContract/apiContract.js"; import { inspectApiContractInputSchema } from "../domain/apiContract/apiContractQuery.js"; import { apiContractSummaryViewResultSchema, apiContractPageViewResultSchema, + graphqlContractPageViewResultSchema, } from "../domain/analysisView/analysisView.js"; import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js"; import { toolContractMetadata } from "./toolEffects.js"; @@ -16,16 +18,31 @@ export const API_CONTRACT_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_api_contract"), kind: "application", description: - "Inventory declared operations in a local OpenAPI 3.0/3.1/3.2 JSON document offline. No target session, network, endpoint requests or reference resolution. Returns exact method/path, source JSON Pointers, source-scoped IDs and separate exact method/path keys. Candidate runtime behavior is never inferred from a contract. Default summary retains complete Evidence; optional view returns selected operations, unresolved Path Items or diagnostics immediately and takes precedence over detail. Retained inspect_analysis_view kind api-contract supports exact batch ID/method/path selection, fields and paging. Coverage describes inline paths only; callbacks/webhooks, schemas, servers, parameters and security remain unanalyzed. Malformed siblings and unresolved references remain explicit; this is not full OpenAPI validation. File and expanded-facts allowances are each 32 MiB, with JSON parsing additionally admitted against available heap and the shared nesting limit.", + "Inspect local OpenAPI JSON (default) or GraphQL SDL offline; no session, network, endpoint requests or target execution. OpenAPI 3.0/3.1/3.2 inventories inline path operations with exact method/path keys, source JSON Pointers and source-scoped IDs; references stay unresolved and malformed siblings explicit. GraphQL format graphql-sdl inventories type/field declarations and optional operations_path field bindings, operation/fragment owners and explicit spread edges without expanding fragments. It preserves aliases, wrappers, source digests/UTF-16 ranges and unknown/model-only bindings; field resolution is not full operation validation. Introspection, contract diff and runtime behavior are outside scope. Default summary retains complete Evidence; view selects facts immediately, overriding detail. Retained inspect_analysis_view supports kind api-contract or graphql-contract with exact batch filters, fields and paging. Coverage describes examined declarations, independently of page exhaustion. Each file and expanded facts have 32 MiB allowances plus heap admission. GraphQL additionally bounds lexical tokens (262144), comments (65536) and subsequent AST traversal depth (128). Reply paging does not increase parser allowances.", inputSchema: inspectApiContractInputSchema, outputSchema: evidenceResultOf( z.union([ apiContractSchema, + graphqlContractSchema, apiContractSummaryViewResultSchema, apiContractPageViewResultSchema, + graphqlContractPageViewResultSchema, ]), ), examples: [ + { + title: "Bind local GraphQL selections to supplied SDL fields", + input: { + path: "/artifacts/schema.graphql", + format: "graphql-sdl", + operations_path: "/artifacts/queries.graphql", + view: { + kind: "graphql-contract", + collection: "references", + limit: 20, + }, + }, + }, { title: "Read selected declared HTTP operations without contacting servers", diff --git a/src/contracts/toolEffects.test.ts b/src/contracts/toolEffects.test.ts index 480044292b..aba192568e 100644 --- a/src/contracts/toolEffects.test.ts +++ b/src/contracts/toolEffects.test.ts @@ -88,6 +88,8 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly> = { inspect_adb_client: "inspects the operator-selected adb client; never starts its server", list_adb_devices: "may start the local adb server, which REA does not own", + inspect_api_contract: + "states there is no target execution during offline contract parsing", inspect_firmware_regions: "states there is no target execution", inspect_gpu_code_objects: "reads static declarations without executing GPU code", diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index b504c09fc8..1b609680b8 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -1,4 +1,5 @@ import type { PlaywrightTrace } from "./playwrightTrace/playwrightTrace.js"; +import type { GraphqlContract } from "./apiContract/graphqlContract.js"; import type { SavedAnalysisSnapshotPartialObservation } from "./savedAnalysisSnapshot.js"; import type { AnalysisSettingLimit } from "./analysisSettings.js"; import type { JsonValue } from "./jsonValue.js"; @@ -401,6 +402,7 @@ import type { WebArtifactAcquisition } from "./webArtifactAcquisition.js"; /** Provider-neutral evidence collected before a typed analysis failure. */ export type AnalysisPartialObservation = + | GraphqlContract | PlaywrightTrace | SavedAnalysisSnapshotPartialObservation | WebArtifactAcquisition diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index f8e59610e7..acda9f113b 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -163,6 +163,7 @@ import { playwrightTraceSummaryViewResultSchema, playwrightTracePageViewResultSchema, apiContractPageViewResultSchema, + graphqlContractPageViewResultSchema, endpointComparisonPageViewResultSchema, featureTraceSummaryViewResultSchema, batchDecompileSummaryViewResultSchema, @@ -238,6 +239,7 @@ export { playwrightTraceSummaryViewResultSchema, playwrightTracePageViewResultSchema, apiContractPageViewResultSchema, + graphqlContractPageViewResultSchema, endpointComparisonPageViewResultSchema, featureTraceSummaryViewResultSchema, batchDecompileSummaryViewResultSchema, diff --git a/src/domain/analysisView/analysisViewRequest.ts b/src/domain/analysisView/analysisViewRequest.ts index b9ef686368..a15c12f1d1 100644 --- a/src/domain/analysisView/analysisViewRequest.ts +++ b/src/domain/analysisView/analysisViewRequest.ts @@ -1,3 +1,4 @@ +import { graphqlContractViewRequestSchema } from "../apiContract/graphqlContractQuery.js"; import { playwrightTraceViewRequestSchema } from "../playwrightTrace/playwrightTraceQuery.js"; import { apiContractViewRequestSchema } from "../apiContract/apiContractQuery.js"; import { @@ -77,6 +78,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ sourceToBundleViewRequestSchema, endpointComparisonViewRequestSchema, apiContractViewRequestSchema, + graphqlContractViewRequestSchema, playwrightTraceViewRequestSchema, featureTraceViewRequestSchema, nativeFeatureTraceViewRequestSchema, diff --git a/src/domain/analysisView/analysisViewResult.ts b/src/domain/analysisView/analysisViewResult.ts index eadb67db30..301e68cbeb 100644 --- a/src/domain/analysisView/analysisViewResult.ts +++ b/src/domain/analysisView/analysisViewResult.ts @@ -1,3 +1,8 @@ +import { graphqlContractViewRequestSchema } from "../apiContract/graphqlContractQuery.js"; +import { + graphqlContractViewContextSchema, + graphqlContractViewItemsSchema, +} from "./graphqlContractView.js"; import { playwrightTraceViewRequestSchema } from "../playwrightTrace/playwrightTraceQuery.js"; import { playwrightTraceViewContextSchema, @@ -1049,7 +1054,10 @@ export const playwrightTracePageViewResultSchema = z.strictObject({ export const apiContractSummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, parent_operation: z.literal("inspect_api_contract"), - summary: apiContractViewContextSchema, + summary: z.union([ + apiContractViewContextSchema, + graphqlContractViewContextSchema, + ]), }); export const apiContractPageViewResultSchema = z.strictObject({ ...viewResultBase, @@ -1060,6 +1068,13 @@ export const apiContractPageViewResultSchema = z.strictObject({ view: apiContractViewRequestSchema, items: apiContractViewItemsSchema, }); +export const graphqlContractPageViewResultSchema = z.strictObject({ + ...viewResultBase, + ...graphqlContractViewContextSchema.shape, + parent_operation: z.literal("inspect_api_contract"), + view: graphqlContractViewRequestSchema, + items: graphqlContractViewItemsSchema, +}); export const endpointComparisonSummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, artifact: z.null(), @@ -1207,6 +1222,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ sourceToBundlePageViewResultSchema, endpointComparisonPageViewResultSchema, apiContractPageViewResultSchema, + graphqlContractPageViewResultSchema, featureTraceViewResultSchema, batchDecompileViewResultSchema, z.strictObject({ diff --git a/src/domain/analysisView/apiContractView.ts b/src/domain/analysisView/apiContractView.ts index 6040ac5caf..957a149771 100644 --- a/src/domain/analysisView/apiContractView.ts +++ b/src/domain/analysisView/apiContractView.ts @@ -1,3 +1,5 @@ +import { graphqlContractSchema } from "../apiContract/graphqlContract.js"; +import { projectGraphqlContractView } from "./graphqlContractView.js"; import { z } from "zod"; import { apiContractSchema, @@ -57,7 +59,9 @@ export const apiContractViewItemsSchema = z.union([ .required({ diagnostic_id: true, source_pointer: true, code: true }), ), ]); -const readReport = createAnalysisViewResultReader(apiContractSchema); +const readReport = createAnalysisViewResultReader( + z.union([apiContractSchema, graphqlContractSchema]), +); const values = (selected: readonly string[] | undefined) => selected === undefined ? undefined : new Set(selected); const admits = (selection: ReadonlySet | undefined, value: string) => @@ -144,6 +148,10 @@ export const projectApiContractView = ( parent: AnalysisViewParent, admitted: AdmittedView, ) => { + const parsed = readReport(parent); + if (!parsed.ok) return parsed; + if (parsed.value.format === "graphql-sdl") + return projectGraphqlContractView(parent, admitted, parsed.value); const view = ownedView< SummaryAnalysisView | z.output >(admitted, "summary", "api-contract"); @@ -158,8 +166,6 @@ export const projectApiContractView = ( }, ]), ); - const parsed = readReport(parent); - if (!parsed.ok) return parsed; const { operations: _operations, path_item_references: _references, diff --git a/src/domain/analysisView/graphqlContractView.ts b/src/domain/analysisView/graphqlContractView.ts new file mode 100644 index 0000000000..e2f1e7d4a8 --- /dev/null +++ b/src/domain/analysisView/graphqlContractView.ts @@ -0,0 +1,293 @@ +import { z } from "zod"; +import { + graphqlContractSchema, + graphqlTypeSchema, + graphqlFieldSchema, + graphqlDefinitionSchema, + graphqlReferenceSchema, + graphqlSpreadSchema, + graphqlDiagnosticSchema, + type GraphqlContract, +} from "../apiContract/graphqlContract.js"; +import { graphqlContractViewRequestSchema } from "../apiContract/graphqlContractQuery.js"; +import type { JsonValue } from "../jsonValue.js"; +import { err, ok } from "../result.js"; +import type { AnalysisViewParent } from "./analysisViewParent.js"; +import { + ownedView, + type AdmittedView, + type SummaryAnalysisView, +} from "./analysisViewShape.js"; +import { + boundedViewMessages, + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; +import { analysisViewInputError } from "./analysisViewErrors.js"; +import { selectViewRows } from "./selectViewRows.js"; + +export const graphqlContractViewContextSchema = graphqlContractSchema.omit({ + types: true, + fields: true, + definitions: true, + references: true, + fragment_spreads: true, + diagnostics: true, + limitations: true, +}); +export const graphqlContractViewItemsSchema = z.union([ + z.array( + graphqlTypeSchema.partial().required({ type_id: true, source: true }), + ), + z.array( + graphqlFieldSchema + .partial() + .required({ field_id: true, owner_type_id: true, source: true }), + ), + z.array( + graphqlDefinitionSchema + .partial() + .required({ definition_id: true, source: true }), + ), + z.array( + graphqlReferenceSchema.partial().required({ + reference_id: true, + definition_id: true, + source: true, + state: true, + reason: true, + }), + ), + z.array( + graphqlSpreadSchema.partial().required({ + spread_id: true, + definition_id: true, + source: true, + state: true, + }), + ), + z.array( + graphqlDiagnosticSchema.partial().required({ + diagnostic_id: true, + source: true, + source_role: true, + code: true, + }), + ), +]); +type View = z.output; +const values = (selected: readonly string[] | undefined) => + selected === undefined ? undefined : new Set(selected); + +/** Scan retained facts once; allocate projections only for the selected page. */ +const select = ( + rows: readonly Row[], + view: View, + filters: Partial | undefined>>, + columns: { + readonly defaults: readonly string[]; + readonly required: readonly Key[]; + }, +) => { + const keys = [ + ...new Set([ + ...(view.fields ?? columns.defaults), + ...columns.required, + ]), + ]; + return selectViewRows( + rows, + view, + (row) => { + for (const key in filters) { + const selected = filters[key]; + if ( + selected !== undefined && + (typeof row[key] !== "string" || !selected.has(row[key] as string)) + ) + return false; + } + return true; + }, + (row) => { + const source = row as Record; + return Object.fromEntries( + keys.map((key) => [key, source[key as keyof Row]]), + ) as Partial & Pick; + }, + ); +}; +const referenceColumns = { + defaults: [ + "field_name", + "response_key", + "parent_type_name", + "schema_field_id", + "result_type", + "parent_reference_id", + ], + required: ["reference_id", "definition_id", "source", "state", "reason"], +} as const; + +const selectRows = (report: GraphqlContract, view: View) => { + switch (view.collection) { + case "types": + return select( + report.types, + view, + { + type_id: values(view.type_ids), + name: values(view.names), + }, + { + defaults: ["name", "kind", "extension"], + required: ["type_id", "source"], + }, + ); + case "fields": + return select( + report.fields, + view, + { + field_id: values(view.field_ids), + field_key: values(view.field_keys), + owner_type_name: values(view.owner_type_names), + }, + { + defaults: ["field_key", "kind", "type_ref"], + required: ["field_id", "owner_type_id", "source"], + }, + ); + case "definitions": + return select( + report.definitions, + view, + { + definition_id: values(view.definition_ids), + name: values(view.names), + kind: values(view.kinds), + }, + { + defaults: ["name", "kind", "type_condition"], + required: ["definition_id", "source"], + }, + ); + case "references": + return select( + report.references, + view, + { + reference_id: values(view.reference_ids), + definition_id: values(view.definition_ids), + schema_field_id: values(view.schema_field_ids), + state: values(view.states), + }, + referenceColumns, + ); + case "fragment-spreads": + return select( + report.fragment_spreads, + view, + { + definition_id: values(view.definition_ids), + name: values(view.names), + state: values(view.states), + }, + { + defaults: [ + "name", + "target_definition_id", + "matching_definitions", + "parent_reference_id", + ], + required: ["spread_id", "definition_id", "source", "state"], + }, + ); + case "diagnostics": + return select( + report.diagnostics, + view, + { + code: values(view.codes), + }, + { + defaults: ["message"], + required: ["diagnostic_id", "source", "source_role", "code"], + }, + ); + } +}; + +/** Source coverage and unknown bindings remain independent of page exhaustion. */ +export const projectGraphqlContractView = ( + parent: AnalysisViewParent, + admitted: AdmittedView, + report: GraphqlContract, +) => { + const view = ownedView( + admitted, + "summary", + "graphql-contract", + ); + if (view === undefined) + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: + "GraphQL inventories support summary and graphql-contract pages.", + }, + ]), + ); + const { + types: _types, + fields: _fields, + definitions: _definitions, + references: _references, + fragment_spreads: _spreads, + diagnostics: _diagnostics, + limitations, + ...context + } = report; + const shared = { + parent_evidence_id: parent.evidenceId, + parent_operation: "inspect_api_contract" as const, + parent_digest: parent.evidenceId.slice(3), + limitations: boundedViewMessages( + [...parent.limitations, ...limitations], + parent.evidenceId, + "limitations", + ), + unknowns: + context.source_coverage.status === "partial" + ? [ + "Unresolved bindings, fragment edges or unavailable parsing/model stages limit this inventory; inspect references, fragment-spreads and diagnostics.", + ] + : [], + }; + if (view.kind === "summary") + return ok({ + ...shared, + kind: "summary" as const, + view, + artifact: { + path: context.artifact.path, + sha256: context.artifact.sha256, + }, + summary: context, + coverage: completeWithinViewCoverage(1, 1), + }); + const selected = selectRows(report, view); + return ok({ + ...shared, + ...context, + view, + items: selected.rows, + coverage: pageViewCoverage( + view.offset, + selected.rows.length, + selected.total, + ), + }); +}; diff --git a/src/domain/apiContract/GraphqlDocuments.ts b/src/domain/apiContract/GraphqlDocuments.ts new file mode 100644 index 0000000000..564123b622 --- /dev/null +++ b/src/domain/apiContract/GraphqlDocuments.ts @@ -0,0 +1,88 @@ +import { GraphQLError, Source, parse, visit, type DocumentNode } from "graphql"; +import { AnalysisResourceConstraintError } from "../analysisErrorCore.js"; +import { + API_CONTRACT_OPERATION, + API_CONTRACT_MAX_FILE_BYTES, + type ApiContractArtifact, +} from "./apiContract.js"; +import { + GRAPHQL_CONTRACT_LIMITS, + type GraphqlSource, +} from "./graphqlContract.js"; + +export interface GraphqlSourceDocument { + readonly artifact: ApiContractArtifact; + readonly text: string; +} + +/** The library does not distinguish syntax and parser-policy errors with a stable code. */ +export class GraphqlDocumentRefusal extends Error { + readonly source: GraphqlSource | null; + constructor( + cause: GraphQLError, + document: GraphqlSourceDocument, + role: GraphqlSource["role"], + ) { + super("The GraphQL parser refused this document.", { cause }); + const offset = cause.positions?.[0], + location = cause.locations?.[0]; + this.source = + offset === undefined || location === undefined + ? null + : { + role, + sha256: document.artifact.sha256, + start_utf16: offset, + end_utf16: offset, + line: location.line, + column: location.column, + }; + } +} + +/** Public parser allowances bound allocation; iterative AST admission bounds subsequent traversal. */ +export const parseGraphqlDocument = ( + document: GraphqlSourceDocument, + role: GraphqlSource["role"], +): DocumentNode => { + try { + const parsed = parse(new Source(document.text, document.artifact.path), { + maxLexicalTokens: GRAPHQL_CONTRACT_LIMITS.lexicalTokens, + maxComments: GRAPHQL_CONTRACT_LIMITS.comments, + maxSourceLength: API_CONTRACT_MAX_FILE_BYTES, + }); + let depth = 0; + visit(parsed, { + enter() { + depth++; + if (depth > GRAPHQL_CONTRACT_LIMITS.astDepth) + throw new AnalysisResourceConstraintError( + API_CONTRACT_OPERATION, + "memory", + "GraphQL AST nesting exceeds the 128-level traversal allowance.", + { max_ast_depth: GRAPHQL_CONTRACT_LIMITS.astDepth }, + ); + }, + leave() { + depth--; + }, + }); + return parsed; + } catch (cause) { + if (cause instanceof GraphQLError) + throw new GraphqlDocumentRefusal(cause, document, role); + if (cause instanceof RangeError) + throw new AnalysisResourceConstraintError( + API_CONTRACT_OPERATION, + "memory", + "GraphQL parsing exceeded a runtime representation allowance.", + { parser: "graphql-js" }, + { + cause, + remediationAction: + "Use a smaller, less deeply nested complete document.", + }, + ); + throw cause; + } +}; diff --git a/src/domain/apiContract/GraphqlInventory.ts b/src/domain/apiContract/GraphqlInventory.ts new file mode 100644 index 0000000000..3279986929 --- /dev/null +++ b/src/domain/apiContract/GraphqlInventory.ts @@ -0,0 +1,180 @@ +import type { ASTNode } from "graphql"; +import { digestCanonicalValue } from "../canonicalDigest.js"; +import { AnalysisResourceConstraintError } from "../analysisErrorCore.js"; +import { + API_CONTRACT_FACT_BYTES, + API_CONTRACT_OPERATION, + type ApiContractArtifact, +} from "./apiContract.js"; +import { + GRAPHQL_CONTRACT_PROFILE, + type GraphqlContract, + type GraphqlSource, +} from "./graphqlContract.js"; + +export const graphqlIdentity = (prefix: string, value: unknown): string => + prefix + "_" + digestCanonicalValue(value); + +/** Positions come from the parser's tokens; do not rescan the source per fact. */ +export const graphqlSource = ( + artifact: ApiContractArtifact, + role: GraphqlSource["role"], + node: ASTNode, +): GraphqlSource => { + const location = node.loc; + if (location === undefined) + throw new Error("The GraphQL parser omitted a required source location."); + return { + role, + sha256: artifact.sha256, + start_utf16: location.start, + end_utf16: location.end, + line: location.startToken.line, + column: location.startToken.column, + }; +}; + +/** One read owns compact declarations, bindings, indices and their resource charge. */ +export class GraphqlInventory { + readonly types: GraphqlContract["types"] = []; + readonly fields: GraphqlContract["fields"] = []; + readonly definitions: GraphqlContract["definitions"] = []; + readonly references: GraphqlContract["references"] = []; + readonly spreads: GraphqlContract["fragment_spreads"] = []; + readonly diagnostics: GraphqlContract["diagnostics"] = []; + readonly fieldIdsByStart = new Map(); + schemaDeclarations: GraphqlContract["source_coverage"]["schema_declarations"] = + "unavailable"; + schemaModel: GraphqlContract["source_coverage"]["schema_model"] = + "unavailable"; + operations: GraphqlContract["source_coverage"]["operations"]; + roots: GraphqlContract["model_roots"] = { + query: null, + mutation: null, + subscription: null, + }; + #bytes = 0; + constructor( + readonly artifact: ApiContractArtifact, + readonly operationArtifact: ApiContractArtifact | null, + readonly interpreterVersion: string, + requestedOperations = operationArtifact !== null, + ) { + this.operations = requestedOperations ? "unavailable" : "not-supplied"; + } + + retain(value: unknown): void { + this.#bytes += 512 + Buffer.byteLength(JSON.stringify(value)) * 4; + if (this.#bytes > API_CONTRACT_FACT_BYTES) + throw new AnalysisResourceConstraintError( + API_CONTRACT_OPERATION, + "memory", + "GraphQL facts exceed the 32 MiB retained representation allowance.", + { retained_fact_allowance: API_CONTRACT_FACT_BYTES }, + { + remediationAction: + "Select smaller complete schema/operation documents; reply paging does not raise parser allowances.", + }, + ); + } + + diagnostic( + code: string, + message: string, + role: GraphqlSource["role"], + source: GraphqlSource | null = null, + ): void { + const row = { + diagnostic_id: graphqlIdentity("graphql_diagnostic", [ + this.artifact.sha256, + this.operationArtifact?.sha256 ?? null, + source, + role, + code, + this.diagnostics.length, + ]), + code, + message, + source_role: role, + source, + }; + this.retain(row); + this.diagnostics.push(row); + } + + finish(incomplete = false): GraphqlContract { + let unknownReferences = 0, + modelOnly = 0, + unresolvedSpreads = 0; + for (const row of this.references) { + if (row.state === "unknown") unknownReferences++; + if (row.state === "model-only") modelOnly++; + } + for (const row of this.spreads) + if (row.state !== "resolved") unresolvedSpreads++; + const partial = + incomplete || + this.schemaDeclarations !== "complete" || + this.schemaModel !== "constructed" || + (this.operations !== "complete" && this.operations !== "not-supplied") || + unknownReferences > 0 || + unresolvedSpreads > 0 || + this.diagnostics.length > 0; + return { + kind: "graphql-contract", + format: "graphql-sdl", + contract_id: graphqlIdentity("graphql_contract", [ + this.artifact.sha256, + this.operationArtifact?.sha256 ?? null, + GRAPHQL_CONTRACT_PROFILE, + ]), + decoder_profile: GRAPHQL_CONTRACT_PROFILE, + interpreter: { id: "graphql-js", version: this.interpreterVersion }, + artifact: this.artifact, + operations_artifact: this.operationArtifact, + offset_units: "utf16-code-units", + model_roots: this.roots, + types: this.types, + fields: this.fields, + definitions: this.definitions, + references: this.references, + fragment_spreads: this.spreads, + diagnostics: this.diagnostics, + summary: { + types: this.types.length, + fields: this.fields.length, + definitions: this.definitions.length, + references: this.references.length, + unknown_references: unknownReferences, + model_only_references: modelOnly, + fragment_spreads: this.spreads.length, + unresolved_spreads: unresolvedSpreads, + diagnostics: this.diagnostics.length, + }, + source_coverage: { + status: partial ? "partial" : "complete-within-scope", + schema_declarations: this.schemaDeclarations, + schema_model: this.schemaModel, + operations: + incomplete && this.operations === "complete" + ? "partial" + : this.operations, + operation_validation: "field-resolution-only", + endpoint_behavior: "unknown", + }, + limitations: [ + ...(incomplete + ? [ + "Import stopped before the requested interpretation completed; retained rows are partial observations.", + ] + : []), + "Facts describe supplied SDL and operation text. Schema field bindings are derivations from that SDL and the identified GraphQL library model; reachability, execution and authorization remain unknown.", + "Only type/field declarations, operation/fragment owners, field bindings and explicit fragment-spread edges are interpreted. Arguments, defaults, descriptions, directive behavior, enum values, full operation validation, introspection JSON and version comparison are outside this profile.", + "Fragment definitions are visited once in their declared type scope; spreads are retained as edges without expansion. Resolved field bindings do not establish that a whole operation is valid or that a fragment can execute in every spread context.", + "Model-only bindings have no matching declaration in the supplied SDL. Missing or ambiguous declarations and unavailable models remain explicit.", + "Each supplied file is an independently verified byte snapshot; the pair is not an atomic filesystem observation. UTF-16 offsets and parser token line/column values address decoded UTF-8 text, including an optional BOM.", + "Original document bytes are identified by SHA-256 rather than embedded. Complete interpreted Evidence remains available separately from selected replies.", + ], + }; + } +} diff --git a/src/domain/apiContract/GraphqlOperationFacts.ts b/src/domain/apiContract/GraphqlOperationFacts.ts new file mode 100644 index 0000000000..90caa21d1c --- /dev/null +++ b/src/domain/apiContract/GraphqlOperationFacts.ts @@ -0,0 +1,210 @@ +import { + Kind, + TypeInfo, + isExecutableDefinitionNode, + type DocumentNode, + type GraphQLSchema, + type FieldNode, + type FragmentSpreadNode, + type SelectionSetNode, +} from "graphql"; +import type { + GraphqlDefinition, + GraphqlReference, + GraphqlSpread, +} from "./graphqlContract.js"; +import type { GraphqlSourceDocument } from "./GraphqlDocuments.js"; +import { + GraphqlInventory, + graphqlIdentity, + graphqlSource, +} from "./GraphqlInventory.js"; + +interface DefinitionContext { + readonly progress: { examined: number }; + readonly definitionId: string; + readonly input: GraphqlSourceDocument; + readonly inventory: GraphqlInventory; + readonly info: TypeInfo | undefined; + readonly fragments: ReadonlyMap; +} + +const reference = ( + node: FieldNode, + parentId: string | null, + { definitionId, input, inventory, info }: DefinitionContext, +): GraphqlReference => { + const field = info?.getFieldDef(), + parent = info?.getParentType(); + const position = field?.astNode?.loc?.start; + const fieldId = + position === undefined + ? undefined + : inventory.fieldIdsByStart.get(position); + const source = graphqlSource(input.artifact, "operations", node); + const state = + fieldId !== undefined + ? "resolved" + : field != null + ? "model-only" + : "unknown"; + const reason = + fieldId !== undefined + ? null + : field != null + ? "no-sdl-source" + : info === undefined + ? "schema-model-unavailable" + : parent == null + ? "parent-type-unavailable" + : "unknown-field"; + return { + reference_id: graphqlIdentity("graphql_reference", source), + definition_id: definitionId, + parent_reference_id: parentId, + field_name: node.name.value, + response_key: node.alias?.value ?? node.name.value, + parent_type_name: parent?.name ?? null, + schema_field_id: fieldId ?? null, + result_type: field == null ? null : String(field.type), + state, + reason, + source, + }; +}; + +const spread = ( + node: FragmentSpreadNode, + parentId: string | null, + { definitionId, input, inventory, fragments }: DefinitionContext, +) => { + const candidates = fragments.get(node.name.value) ?? []; + const source = graphqlSource(input.artifact, "operations", node); + const row: GraphqlSpread = { + spread_id: graphqlIdentity("graphql_spread", source), + definition_id: definitionId, + parent_reference_id: parentId, + name: node.name.value, + target_definition_id: + candidates.length === 1 ? candidates[0]!.definition_id : null, + matching_definitions: candidates.length, + state: + candidates.length === 1 + ? "resolved" + : candidates.length === 0 + ? "missing" + : "ambiguous", + source, + }; + inventory.retain(row); + inventory.spreads.push(row); +}; + +/** Only output selections affect field binding; input values/directives remain outside this profile. */ +function* collectSelections( + set: SelectionSetNode, + context: DefinitionContext, + parentId: string | null, +): Generator { + const { info, inventory } = context; + info?.enter(set); + try { + for (const node of set.selections) { + info?.enter(node); + try { + let childParent = parentId; + if (node.kind === Kind.FIELD) { + const row = reference(node, parentId, context); + inventory.retain(row); + inventory.references.push(row); + childParent = row.reference_id; + } else if (node.kind === Kind.FRAGMENT_SPREAD) { + spread(node, parentId, context); + } + // Bound hashing/binding work within one wide query or fragment, too. + if (++context.progress.examined % 128 === 0) yield; + if ( + node.kind !== Kind.FRAGMENT_SPREAD && + node.selectionSet !== undefined + ) + yield* collectSelections(node.selectionSet, context, childParent); + } finally { + info?.leave(node); + } + } + } finally { + info?.leave(set); + } +} + +/** Record each definition once; fragment edges never recursively expand its body. */ +export function* collectGraphqlOperationFacts( + document: DocumentNode, + input: GraphqlSourceDocument, + inventory: GraphqlInventory, + model: GraphQLSchema | undefined, +): Generator { + inventory.operations = "partial"; + const fragments = new Map(); + const owners = new Map(); + for (const definition of document.definitions) { + if (!isExecutableDefinitionNode(definition)) { + inventory.diagnostic( + "non-operation-definition", + "This operation input contains a definition outside operation/fragment interpretation.", + "operations", + graphqlSource(input.artifact, "operations", definition), + ); + continue; + } + const source = graphqlSource(input.artifact, "operations", definition); + const row: GraphqlDefinition = { + definition_id: graphqlIdentity("graphql_definition", source), + kind: + definition.kind === Kind.FRAGMENT_DEFINITION + ? "fragment" + : definition.operation, + name: definition.name?.value ?? null, + type_condition: + definition.kind === Kind.FRAGMENT_DEFINITION + ? definition.typeCondition.name.value + : null, + source, + }; + inventory.retain(row); + inventory.definitions.push(row); + owners.set(source.start_utf16, row); + if (row.kind === "fragment" && row.name !== null) { + const candidates = fragments.get(row.name) ?? []; + candidates.push(row); + fragments.set(row.name, candidates); + } + yield; + } + const progress = { examined: 0 }; + for (const definition of document.definitions) { + if (!isExecutableDefinitionNode(definition)) continue; + const owner = + definition.loc === undefined + ? undefined + : owners.get(definition.loc.start); + if (owner === undefined) continue; + const info = model === undefined ? undefined : new TypeInfo(model); + const context = { + progress, + definitionId: owner.definition_id, + input, + inventory, + info, + fragments, + }; + info?.enter(definition); + try { + yield* collectSelections(definition.selectionSet, context, null); + } finally { + info?.leave(definition); + } + yield; + } + inventory.operations = "complete"; +} diff --git a/src/domain/apiContract/GraphqlSchemaFacts.ts b/src/domain/apiContract/GraphqlSchemaFacts.ts new file mode 100644 index 0000000000..21415cd28d --- /dev/null +++ b/src/domain/apiContract/GraphqlSchemaFacts.ts @@ -0,0 +1,106 @@ +import { + Kind, + isTypeDefinitionNode, + isTypeExtensionNode, + print, + type DocumentNode, + type TypeNode, +} from "graphql"; +import type { GraphqlType, GraphqlField } from "./graphqlContract.js"; +import type { GraphqlSourceDocument } from "./GraphqlDocuments.js"; +import { + GraphqlInventory, + graphqlIdentity, + graphqlSource, +} from "./GraphqlInventory.js"; + +const typeKinds: Partial> = { + [Kind.OBJECT_TYPE_DEFINITION]: "object", + [Kind.OBJECT_TYPE_EXTENSION]: "object", + [Kind.INTERFACE_TYPE_DEFINITION]: "interface", + [Kind.INTERFACE_TYPE_EXTENSION]: "interface", + [Kind.INPUT_OBJECT_TYPE_DEFINITION]: "input-object", + [Kind.INPUT_OBJECT_TYPE_EXTENSION]: "input-object", + [Kind.SCALAR_TYPE_DEFINITION]: "scalar", + [Kind.SCALAR_TYPE_EXTENSION]: "scalar", + [Kind.ENUM_TYPE_DEFINITION]: "enum", + [Kind.ENUM_TYPE_EXTENSION]: "enum", + [Kind.UNION_TYPE_DEFINITION]: "union", + [Kind.UNION_TYPE_EXTENSION]: "union", +}; +const nonTypeSDL = new Set([ + Kind.SCHEMA_DEFINITION, + Kind.SCHEMA_EXTENSION, + Kind.DIRECTIVE_DEFINITION, + Kind.DIRECTIVE_EXTENSION, +]); +const namedType = (input: TypeNode): string => { + let node = input; + while (node.kind !== Kind.NAMED_TYPE) node = node.type; + return node.name.value; +}; + +/** Declaration occurrences remain separate even if SDL model construction rejects duplicates. */ +export function* collectGraphqlSchemaFacts( + document: DocumentNode, + input: GraphqlSourceDocument, + inventory: GraphqlInventory, +): Generator { + inventory.schemaDeclarations = "partial"; + let examined = 0; + for (const definition of document.definitions) { + if (!isTypeDefinitionNode(definition) && !isTypeExtensionNode(definition)) { + if (!nonTypeSDL.has(definition.kind)) + inventory.diagnostic( + "non-schema-definition", + "This schema input contains a definition outside SDL type/field interpretation.", + "schema", + graphqlSource(input.artifact, "schema", definition), + ); + continue; + } + const kind = typeKinds[definition.kind]; + if (kind === undefined) + throw new Error("Missing GraphQL declaration kind."); + const source = graphqlSource(input.artifact, "schema", definition); + const row: GraphqlType = { + type_id: graphqlIdentity("graphql_type", source), + name: definition.name.value, + kind, + extension: isTypeExtensionNode(definition), + interfaces: + "interfaces" in definition + ? (definition.interfaces ?? []).map((node) => node.name.value) + : [], + members: + "types" in definition + ? (definition.types ?? []).map((node) => node.name.value) + : [], + source, + }; + inventory.retain(row); + inventory.types.push(row); + if ("fields" in definition) { + for (const field of definition.fields ?? []) { + const location = graphqlSource(input.artifact, "schema", field); + const fact: GraphqlField = { + field_id: graphqlIdentity("graphql_field", location), + field_key: row.name + "." + field.name.value, + owner_type_id: row.type_id, + owner_type_name: row.name, + kind: kind === "input-object" ? "input" : "output", + name: field.name.value, + type_ref: print(field.type), + named_type: namedType(field.type), + source: location, + }; + inventory.retain(fact); + inventory.fields.push(fact); + inventory.fieldIdsByStart.set(location.start_utf16, fact.field_id); + if (++examined % 128 === 0) yield; + } + } + if (++examined % 128 === 0) yield; + } + inventory.schemaDeclarations = "complete"; +} diff --git a/src/domain/apiContract/apiContractQuery.ts b/src/domain/apiContract/apiContractQuery.ts index 07af27e9db..08a3942946 100644 --- a/src/domain/apiContract/apiContractQuery.ts +++ b/src/domain/apiContract/apiContractQuery.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { localPathStringSchema } from "../localPath.js"; +import { graphqlContractViewRequestSchema } from "./graphqlContractQuery.js"; import { API_CONTRACT_MAX_FILE_BYTES, apiContractDiagnosticSchema, @@ -44,26 +45,51 @@ export const apiContractViewRequestSchema = z.discriminatedUnion("collection", [ }), ]); -export const inspectApiContractInputSchema = z.strictObject({ +export const apiContractFormatSchema = z.enum(["openapi-json", "graphql-sdl"]); +export const apiContractFileBytesSchema = z + .int() + .positive() + .max(API_CONTRACT_MAX_FILE_BYTES) + .default(API_CONTRACT_MAX_FILE_BYTES) + .describe( + "Maximum bytes per input document (up to 32 MiB); reply paging does not increase parser allowances", + ); +export const apiContractViewSelectionSchema = z.discriminatedUnion("kind", [ + apiContractViewRequestSchema, + graphqlContractViewRequestSchema, +]); +const commonInput = { path: localPathStringSchema.describe( - "Absolute path to one local OpenAPI JSON document", + "Absolute path to a local API contract document", ), - format: z.literal("openapi-json").default("openapi-json"), - max_file_bytes: z - .int() - .positive() - .max(API_CONTRACT_MAX_FILE_BYTES) - .default(API_CONTRACT_MAX_FILE_BYTES) - .describe( - "Maximum input document bytes (up to 32 MiB); reply paging does not increase parser allowances", - ), + max_file_bytes: apiContractFileBytesSchema, detail: z.enum(["summary", "complete"]).default("summary"), - view: apiContractViewRequestSchema - .optional() - .describe( - "Return this selected page immediately; complete Evidence is retained and view takes precedence over detail", - ), -}); +}; +const selectionDescription = + "Return this selected page immediately; complete Evidence is retained and view takes precedence over detail"; +export const inspectApiContractInputSchema = z + .union([ + z.strictObject({ + ...commonInput, + format: z.literal("openapi-json").default("openapi-json"), + view: apiContractViewRequestSchema + .optional() + .describe(selectionDescription), + }), + z.strictObject({ + ...commonInput, + format: z.literal("graphql-sdl"), + operations_path: localPathStringSchema + .optional() + .describe( + "Optional absolute path to one local GraphQL operation/fragment document; parsed without execution", + ), + view: graphqlContractViewRequestSchema + .optional() + .describe(selectionDescription), + }), + ]) + .meta({ "x-rea-root-discriminator": "format" }); export type InspectApiContractInput = z.output< typeof inspectApiContractInputSchema >; diff --git a/src/domain/apiContract/graphqlContract.ts b/src/domain/apiContract/graphqlContract.ts new file mode 100644 index 0000000000..22af042689 --- /dev/null +++ b/src/domain/apiContract/graphqlContract.ts @@ -0,0 +1,142 @@ +import { z } from "zod"; +import { digestSchema, prefixedDigestSchema } from "../digests.js"; +import { apiContractArtifactSchema } from "./apiContract.js"; + +/** Parser representation policies, not GraphQL language limits. */ +export const GRAPHQL_CONTRACT_LIMITS = { + lexicalTokens: 262144, + comments: 65536, + astDepth: 128, +} as const; +export const GRAPHQL_CONTRACT_PROFILE = "graphql-sdl-field-bindings-v1"; + +export const graphqlSourceSchema = z.strictObject({ + role: z.enum(["schema", "operations"]), + sha256: digestSchema, + start_utf16: z.int().nonnegative(), + end_utf16: z.int().nonnegative(), + line: z.int().positive(), + column: z.int().positive(), +}); +export const graphqlTypeSchema = z.strictObject({ + type_id: prefixedDigestSchema("graphql_type"), + name: z.string(), + kind: z.enum([ + "object", + "interface", + "input-object", + "scalar", + "enum", + "union", + ]), + extension: z.boolean(), + interfaces: z.array(z.string()), + members: z.array(z.string()), + source: graphqlSourceSchema, +}); +export const graphqlFieldSchema = z.strictObject({ + field_id: prefixedDigestSchema("graphql_field"), + field_key: z.string(), + owner_type_id: graphqlTypeSchema.shape.type_id, + owner_type_name: z.string(), + kind: z.enum(["output", "input"]), + name: z.string(), + type_ref: z.string(), + named_type: z.string(), + source: graphqlSourceSchema, +}); +export const graphqlDefinitionSchema = z.strictObject({ + definition_id: prefixedDigestSchema("graphql_definition"), + kind: z.enum(["query", "mutation", "subscription", "fragment"]), + name: z.string().nullable(), + type_condition: z.string().nullable(), + source: graphqlSourceSchema, +}); +export const graphqlReferenceSchema = z.strictObject({ + reference_id: prefixedDigestSchema("graphql_reference"), + definition_id: graphqlDefinitionSchema.shape.definition_id, + parent_reference_id: prefixedDigestSchema("graphql_reference").nullable(), + field_name: z.string(), + response_key: z.string(), + parent_type_name: z.string().nullable(), + schema_field_id: graphqlFieldSchema.shape.field_id.nullable(), + result_type: z.string().nullable(), + state: z.enum(["resolved", "model-only", "unknown"]), + reason: z + .enum([ + "schema-model-unavailable", + "parent-type-unavailable", + "unknown-field", + "no-sdl-source", + ]) + .nullable(), + source: graphqlSourceSchema, +}); +export const graphqlSpreadSchema = z.strictObject({ + spread_id: prefixedDigestSchema("graphql_spread"), + definition_id: graphqlDefinitionSchema.shape.definition_id, + parent_reference_id: graphqlReferenceSchema.shape.reference_id.nullable(), + name: z.string(), + target_definition_id: graphqlDefinitionSchema.shape.definition_id.nullable(), + matching_definitions: z.int().nonnegative(), + state: z.enum(["resolved", "missing", "ambiguous"]), + source: graphqlSourceSchema, +}); +export const graphqlDiagnosticSchema = z.strictObject({ + diagnostic_id: prefixedDigestSchema("graphql_diagnostic"), + code: z.string(), + message: z.string(), + source_role: graphqlSourceSchema.shape.role, + source: graphqlSourceSchema.nullable(), +}); +export const graphqlContractSchema = z.strictObject({ + kind: z.literal("graphql-contract"), + format: z.literal("graphql-sdl"), + contract_id: prefixedDigestSchema("graphql_contract"), + decoder_profile: z.literal(GRAPHQL_CONTRACT_PROFILE), + interpreter: z.strictObject({ + id: z.literal("graphql-js"), + version: z.string(), + }), + artifact: apiContractArtifactSchema, + operations_artifact: apiContractArtifactSchema.nullable(), + offset_units: z.literal("utf16-code-units"), + model_roots: z.strictObject({ + query: z.string().nullable(), + mutation: z.string().nullable(), + subscription: z.string().nullable(), + }), + types: z.array(graphqlTypeSchema), + fields: z.array(graphqlFieldSchema), + definitions: z.array(graphqlDefinitionSchema), + references: z.array(graphqlReferenceSchema), + fragment_spreads: z.array(graphqlSpreadSchema), + diagnostics: z.array(graphqlDiagnosticSchema), + summary: z.strictObject({ + types: z.int().nonnegative(), + fields: z.int().nonnegative(), + definitions: z.int().nonnegative(), + references: z.int().nonnegative(), + unknown_references: z.int().nonnegative(), + model_only_references: z.int().nonnegative(), + fragment_spreads: z.int().nonnegative(), + unresolved_spreads: z.int().nonnegative(), + diagnostics: z.int().nonnegative(), + }), + source_coverage: z.strictObject({ + status: z.enum(["complete-within-scope", "partial"]), + schema_declarations: z.enum(["complete", "partial", "unavailable"]), + schema_model: z.enum(["constructed", "unavailable"]), + operations: z.enum(["complete", "partial", "unavailable", "not-supplied"]), + operation_validation: z.literal("field-resolution-only"), + endpoint_behavior: z.literal("unknown"), + }), + limitations: z.array(z.string()), +}); +export type GraphqlContract = z.output; +export type GraphqlSource = z.output; +export type GraphqlType = z.output; +export type GraphqlField = z.output; +export type GraphqlDefinition = z.output; +export type GraphqlReference = z.output; +export type GraphqlSpread = z.output; diff --git a/src/domain/apiContract/graphqlContractQuery.ts b/src/domain/apiContract/graphqlContractQuery.ts new file mode 100644 index 0000000000..55f2b17b5b --- /dev/null +++ b/src/domain/apiContract/graphqlContractQuery.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; +import { + graphqlTypeSchema, + graphqlFieldSchema, + graphqlDefinitionSchema, + graphqlReferenceSchema, + graphqlSpreadSchema, + graphqlDiagnosticSchema, +} from "./graphqlContract.js"; + +const page = { + kind: z.literal("graphql-contract"), + offset: z.int().nonnegative().default(0), + limit: z.int().positive().default(64), +}; +export const graphqlContractViewRequestSchema = z.discriminatedUnion( + "collection", + [ + z.strictObject({ + ...page, + collection: z.literal("types"), + type_ids: z.array(graphqlTypeSchema.shape.type_id).optional(), + names: z.array(z.string()).optional(), + fields: z.array(graphqlTypeSchema.keyof()).optional(), + }), + z.strictObject({ + ...page, + collection: z.literal("fields"), + field_ids: z.array(graphqlFieldSchema.shape.field_id).optional(), + field_keys: z.array(z.string()).optional(), + owner_type_names: z.array(z.string()).optional(), + fields: z.array(graphqlFieldSchema.keyof()).optional(), + }), + z.strictObject({ + ...page, + collection: z.literal("definitions"), + definition_ids: z + .array(graphqlDefinitionSchema.shape.definition_id) + .optional(), + names: z.array(z.string()).optional(), + kinds: z.array(graphqlDefinitionSchema.shape.kind).optional(), + fields: z.array(graphqlDefinitionSchema.keyof()).optional(), + }), + z.strictObject({ + ...page, + collection: z.literal("references"), + reference_ids: z + .array(graphqlReferenceSchema.shape.reference_id) + .optional(), + definition_ids: z + .array(graphqlDefinitionSchema.shape.definition_id) + .optional(), + schema_field_ids: z.array(graphqlFieldSchema.shape.field_id).optional(), + states: z.array(graphqlReferenceSchema.shape.state).optional(), + fields: z.array(graphqlReferenceSchema.keyof()).optional(), + }), + z.strictObject({ + ...page, + collection: z.literal("fragment-spreads"), + definition_ids: z + .array(graphqlDefinitionSchema.shape.definition_id) + .optional(), + names: z.array(z.string()).optional(), + states: z.array(graphqlSpreadSchema.shape.state).optional(), + fields: z.array(graphqlSpreadSchema.keyof()).optional(), + }), + z.strictObject({ + ...page, + collection: z.literal("diagnostics"), + codes: z.array(z.string()).optional(), + fields: z.array(graphqlDiagnosticSchema.keyof()).optional(), + }), + ], +); diff --git a/src/domain/apiContract/parseGraphqlContract.test.ts b/src/domain/apiContract/parseGraphqlContract.test.ts new file mode 100644 index 0000000000..b4c997e7d5 --- /dev/null +++ b/src/domain/apiContract/parseGraphqlContract.test.ts @@ -0,0 +1,340 @@ +import { + buildASTSchema, + parse as parseNative, + TypeInfo, + visit, + visitWithTypeInfo, +} from "graphql"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { expect, it } from "vitest"; +import { parseGraphqlContractSteps } from "./parseGraphqlContract.js"; +import { graphqlContractSchema } from "./graphqlContract.js"; +import { projectAnalysisError } from "../analysisErrorProjection.js"; +import type { GraphqlSourceDocument } from "./GraphqlDocuments.js"; + +const source = (text: string, name: string): GraphqlSourceDocument => ({ + text, + artifact: { + path: "/fixtures/" + name + ".graphql", + sha256: createHash("sha256").update(text).digest("hex"), + bytes: Buffer.byteLength(text), + }, +}); +const parse = (schema: string, operations?: string) => { + const steps = parseGraphqlContractSteps( + source(schema, "schema"), + operations === undefined ? undefined : source(operations, "operations"), + ); + for (;;) { + const step = steps.next(); + if (step.done) return step.value; + } +}; +const successful = (schema: string, operations?: string) => { + const result = parse(schema, operations); + if (!result.ok) throw result.error; + return graphqlContractSchema.parse(result.value); +}; + +it("binds aliases and extension fields to exact SDL occurrences with list/null wrappers", () => { + const schema = + "type Query { people: [Person!]! } type Person { id: ID! } extend type Person { name: String }"; + const operations = + "query People { rows: people { ...Identity name } } fragment Identity on Person { id }"; + const report = successful(schema, operations); + expect(report.types.map((row) => [row.name, row.extension])).toEqual([ + ["Query", false], + ["Person", false], + ["Person", true], + ]); + expect( + report.fields.map((row) => [row.field_key, row.type_ref, row.named_type]), + ).toEqual([ + ["Query.people", "[Person!]!", "Person"], + ["Person.id", "ID!", "ID"], + ["Person.name", "String", "String"], + ]); + expect( + report.references.map((row) => [ + row.field_name, + row.response_key, + row.parent_type_name, + row.state, + ]), + ).toEqual([ + ["people", "rows", "Query", "resolved"], + ["name", "name", "Person", "resolved"], + ["id", "id", "Person", "resolved"], + ]); + for (const reference of report.references) { + const field = report.fields.find( + (row) => row.field_id === reference.schema_field_id, + )!; + expect(field.field_key).toBe( + reference.parent_type_name + "." + reference.field_name, + ); + expect( + schema.slice(field.source.start_utf16, field.source.end_utf16), + ).toContain(field.name); + expect( + operations.slice( + reference.source.start_utf16, + reference.source.end_utf16, + ), + ).toContain(reference.field_name); + } + expect(report.references[1]?.parent_reference_id).toBe( + report.references[0]?.reference_id, + ); + expect(report.references[2]?.parent_reference_id).toBeNull(); + expect(report.fragment_spreads[0]?.target_definition_id).toBe( + report.definitions[1]?.definition_id, + ); + expect(report.source_coverage).toMatchObject({ + status: "complete-within-scope", + endpoint_behavior: "unknown", + }); +}); + +it("uses declared roots and interface/inline-fragment type scopes", () => { + const report = successful( + "schema { query: Root } interface Node { id: ID! } type User implements Node { id: ID! name: String } type Root { node: Node }", + "{ node { id ... on User { name } } }", + ); + expect(report.model_roots.query).toBe("Root"); + expect(report.references.map((row) => row.parent_type_name)).toEqual([ + "Root", + "Node", + "User", + ]); + expect(report.types.find((row) => row.name === "User")?.interfaces).toEqual([ + "Node", + ]); + expect(report.references.every((row) => row.state === "resolved")).toBe(true); +}); + +it("retains fragment cycles as finite edges without expanding selections", () => { + const report = successful( + "type Query { value: String }", + "query Q { ...A } fragment A on Query { value ...A }", + ); + expect(report.definitions).toHaveLength(2); + expect(report.references).toHaveLength(1); + expect(report.fragment_spreads).toHaveLength(2); + expect(report.fragment_spreads.every((row) => row.state === "resolved")).toBe( + true, + ); + expect(report.source_coverage.operation_validation).toBe( + "field-resolution-only", + ); +}); + +it("keeps duplicate fragment names ambiguous and missing fragments explicit", () => { + const report = successful( + "type Query { value: String }", + "query Q { ...A ...Missing } fragment A on Query { value } fragment A on Query { value }", + ); + expect( + report.fragment_spreads.map((row) => [ + row.state, + row.matching_definitions, + row.target_definition_id, + ]), + ).toEqual([ + ["ambiguous", 2, null], + ["missing", 0, null], + ]); + expect(report.summary.unresolved_spreads).toBe(2); + expect(report.source_coverage.status).toBe("partial"); +}); + +it("preserves duplicate SDL field occurrences without selecting a model winner", () => { + const report = successful( + "type Query { value: String value: Int }", + "{ value }", + ); + expect(report.fields.map((row) => row.field_key)).toEqual([ + "Query.value", + "Query.value", + ]); + expect(new Set(report.fields.map((row) => row.field_id)).size).toBe(2); + expect(report.source_coverage.schema_model).toBe("unavailable"); + expect(report.references[0]).toMatchObject({ + state: "unknown", + reason: "schema-model-unavailable", + schema_field_id: null, + }); +}); + +it("retains unknown field and unavailable parent scopes instead of assuming absence", () => { + const report = successful( + "type Query { value: String }", + "{ missing { child } }", + ); + expect(report.references.map((row) => row.reason)).toEqual([ + "unknown-field", + "parent-type-unavailable", + ]); + expect(report.summary.unknown_references).toBe(2); + expect(report.source_coverage.status).toBe("partial"); +}); + +it("distinguishes library model fields from supplied SDL declarations", () => { + const report = successful("type Query { value: String }", "{ __typename }"); + expect(report.references[0]).toMatchObject({ + state: "model-only", + reason: "no-sdl-source", + schema_field_id: null, + result_type: "String!", + }); + expect(report.summary.model_only_references).toBe(1); +}); + +it("preserves input fields, union members and ordinary credential-like names", () => { + const report = successful( + "input Input { access_token: String } type User { authorization: String } union Result = User type Query { result(input: Input): Result }", + "{ result { ... on User { authorization } } }", + ); + expect( + report.fields.find((row) => row.field_key === "Input.access_token")?.kind, + ).toBe("input"); + expect(report.types.find((row) => row.name === "Result")?.members).toEqual([ + "User", + ]); + expect(report.references.at(-1)?.field_name).toBe("authorization"); +}); + +it("keeps verified SDL facts when the operation document cannot be parsed", () => { + const report = successful( + "type Query { value: String }", + '{ value( url: "https://name:FAKE_GQL_SECRET@example.invalid" ) ', + ); + expect(report.fields).toHaveLength(1); + expect(report.operations_artifact).not.toBeNull(); + expect(report.source_coverage.operations).toBe("unavailable"); + expect(report.diagnostics[0]).toMatchObject({ + code: "operations-parse-refused", + source_role: "operations", + source: { line: 1 }, + }); + expect(JSON.stringify(report)).not.toContain("FAKE_GQL_SECRET"); +}); + +it("retains partial source metadata and source-located neutral diagnostics on schema refusal", () => { + const result = parse( + 'type Query { field: "https://name:FAKE_GQL_SECRET@example.invalid" }', + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected syntax refusal"); + expect(result.error._tag).toBe("AnalysisInputError"); + const partial = graphqlContractSchema.parse(result.error.partialObservation); + expect(partial.source_coverage.schema_declarations).toBe("unavailable"); + expect(partial.diagnostics[0]).toMatchObject({ + code: "schema-parse-refused", + source: { line: 1 }, + }); + expect(JSON.stringify(projectAnalysisError(result.error))).not.toContain( + "FAKE_GQL_SECRET", + ); +}); + +it("preserves UTF-16/BOM positions and stable content identities across repeated imports", () => { + const schema = "\uFEFF\n# 雪🦊\ntype Query { value: String }"; + const one = successful(schema, "{ value }"), + two = successful(schema, "{ value }"); + expect(one).toEqual(two); + const field = one.fields[0]!; + expect(field.source).toMatchObject({ role: "schema", line: 3, column: 14 }); + expect(schema.slice(field.source.start_utf16, field.source.end_utf16)).toBe( + "value: String", + ); +}); + +it("refuses deep ASTs through a typed resource result with retained schema facts", () => { + const result = parse( + "type Query { child: Query }", + "{ child ".repeat(90) + "}".repeat(90), + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected nesting admission refusal"); + expect(result.error._tag).toBe("AnalysisResourceConstraintError"); + expect( + graphqlContractSchema.parse(result.error.partialObservation).fields, + ).toHaveLength(1); +}); + +it("imports the unmodified published SWAPI SDL with independently counted declarations", async () => { + const bytes = await readFile( + new URL( + "../../../tests/fixtures/graphql/swapi.graphql.txt", + import.meta.url, + ), + ); + expect(bytes.length).toBe(35868); + expect(createHash("sha256").update(bytes).digest("hex")).toBe( + "ebc4d09b21e2aec2735d6f0d3c812fc017cd49a44900dd3ae34f49f6f8c25133", + ); + const report = successful( + bytes.toString("utf8"), + "query Films { allFilms(first: 2) { films { ...FilmIdentity } } } fragment FilmIdentity on Film { id title }", + ); + expect(report.summary).toMatchObject({ + types: 53, + fields: 243, + definitions: 2, + references: 4, + unknown_references: 0, + }); + expect(report.model_roots.query).toBe("Root"); + expect(report.source_coverage.status).toBe("complete-within-scope"); +}); + +it("retains a bounded prefix when expanded field facts exceed the shared allowance", () => { + const result = parse( + "type Query { " + + Array.from({ length: 18000 }, (_, i) => `field${i}: String`).join(" ") + + " }", + ); + if (result.ok) throw new Error("Expected fact capacity refusal"); + expect(result.error._tag).toBe("AnalysisResourceConstraintError"); + const report = graphqlContractSchema.parse(result.error.partialObservation); + expect(report.fields.length).toBeGreaterThan(1000); + expect(report.fields.length).toBeLessThan(18000); + expect(report.source_coverage).toMatchObject({ + status: "partial", + schema_declarations: "partial", + }); +}); + +it("matches full SDK field scope while pruning variable defaults, arguments and directive payloads", () => { + const schema = + "directive @choice(values: [Int!]) on FIELD | QUERY input Filter { name: String } type Query { user(filter: Filter): User } type User { id: ID! name: String }"; + const operations = + 'query Q($filter: Filter = {name:"default"}) @choice(values:[1,2]) { first: user(filter:$filter) @choice(values:[3]) { id } user(filter:{name:"x"}) { id ... on User { name } ...Names } } fragment Names on User { name }'; + const info = new TypeInfo(buildASTSchema(parseNative(schema))); + const expected: unknown[] = []; + visit( + parseNative(operations), + visitWithTypeInfo(info, { + Field(node) { + expected.push([ + node.name.value, + node.alias?.value ?? node.name.value, + info.getParentType()?.name ?? null, + String(info.getType()), + ]); + }, + }), + ); + const report = successful(schema, operations); + expect( + report.references.map((row) => [ + row.field_name, + row.response_key, + row.parent_type_name, + row.result_type, + ]), + ).toEqual(expected); + expect(report.references.every((row) => row.state === "resolved")).toBe(true); +}); diff --git a/src/domain/apiContract/parseGraphqlContract.ts b/src/domain/apiContract/parseGraphqlContract.ts new file mode 100644 index 0000000000..3e6af663ff --- /dev/null +++ b/src/domain/apiContract/parseGraphqlContract.ts @@ -0,0 +1,105 @@ +import { buildASTSchema, version, type GraphQLSchema } from "graphql"; +import { AnalysisError } from "../analysisErrorBase.js"; +import { + AnalysisInputError, + AnalysisResourceConstraintError, +} from "../analysisErrorCore.js"; +import { err, ok, type Result } from "../result.js"; +import type { GraphqlContract } from "./graphqlContract.js"; +import { API_CONTRACT_OPERATION } from "./apiContract.js"; +import { GraphqlInventory } from "./GraphqlInventory.js"; +import { + GraphqlDocumentRefusal, + parseGraphqlDocument, + type GraphqlSourceDocument, +} from "./GraphqlDocuments.js"; +import { collectGraphqlSchemaFacts } from "./GraphqlSchemaFacts.js"; +import { collectGraphqlOperationFacts } from "./GraphqlOperationFacts.js"; + +/** One syntax/model interpretation owns all declarations and field bindings. */ +export function* parseGraphqlContractSteps( + schema: GraphqlSourceDocument, + operations?: GraphqlSourceDocument, +): Generator> { + const inventory = new GraphqlInventory( + schema.artifact, + operations?.artifact ?? null, + version, + ); + try { + const document = parseGraphqlDocument(schema, "schema"); + yield* collectGraphqlSchemaFacts(document, schema, inventory); + let model: GraphQLSchema | undefined; + try { + const constructed = buildASTSchema(document); + const roots = { + query: constructed.getQueryType()?.name ?? null, + mutation: constructed.getMutationType()?.name ?? null, + subscription: constructed.getSubscriptionType()?.name ?? null, + }; + inventory.schemaModel = "constructed"; + inventory.roots = roots; + model = constructed; + } catch (cause) { + if (cause instanceof RangeError) + throw new AnalysisResourceConstraintError( + API_CONTRACT_OPERATION, + "memory", + "GraphQL schema construction exceeded a runtime representation allowance.", + {}, + { cause }, + ); + if (!(cause instanceof Error)) throw cause; + inventory.diagnostic( + "schema-model-unavailable", + "The supplied SDL could not construct a schema model. Declaration occurrences remain available; bindings stay unknown. Full schema validation diagnostics are outside this profile.", + "schema", + ); + } + yield; + if (operations !== undefined) { + try { + const operationDocument = parseGraphqlDocument( + operations, + "operations", + ); + yield* collectGraphqlOperationFacts( + operationDocument, + operations, + inventory, + model, + ); + } catch (cause) { + if (!(cause instanceof GraphqlDocumentRefusal)) throw cause; + inventory.diagnostic( + "operations-parse-refused", + "The operation document was refused by the GraphQL syntax/parser allowance checks; inspect this source location and the documented parser allowances.", + "operations", + cause.source, + ); + } + } + return ok(inventory.finish()); + } catch (cause) { + let error: AnalysisError; + if (cause instanceof GraphqlDocumentRefusal) { + inventory.diagnostic( + "schema-parse-refused", + "The schema document was refused by GraphQL syntax/parser allowance checks; inspect this source location and the documented allowances.", + "schema", + cause.source, + ); + error = new AnalysisInputError(API_CONTRACT_OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_format", + message: + "The schema document was refused by the GraphQL syntax/parser allowance checks. Inspect its source location and documented allowances.", + }, + ]); + } else if (cause instanceof AnalysisError) error = cause; + else throw cause; + error.retainPartialObservation(inventory.finish(true)); + return err(error); + } +} diff --git a/src/domain/evidence.test.ts b/src/domain/evidence.test.ts index cd044e5278..12e1f5d639 100644 --- a/src/domain/evidence.test.ts +++ b/src/domain/evidence.test.ts @@ -322,6 +322,45 @@ it("retains verbatim caller input without changing semantic identity or copying expect(() => withEvidenceCallerInputPath(second, "invalid\0path")).toThrow(); }); +it("retains named auxiliary path choices without changing identity, history or shared payloads", () => { + const original = immutableEvidence( + createEvidence(TARGET, PROVIDER, { + operation: "inspect", + parameters: {}, + result: { rows: ["source facts"] }, + }), + ); + const paths = { operations_path: "./queries 雪.graphql" }; + const rebound = withEvidenceCallerInputPath( + original, + "./schema.graphql", + paths, + ); + paths.operations_path = "later unrelated choice"; + expect(rebound.caller_input_paths).toEqual({ + operations_path: "./queries 雪.graphql", + }); + expect(original.caller_input_paths).toBeUndefined(); + expect(rebound.evidence_id).toBe(original.evidence_id); + expect(rebound.normalized_result).toBe(original.normalized_result); + expect(Object.isFrozen(rebound.caller_input_paths)).toBe(true); + expect(parseEvidence(JSON.parse(JSON.stringify(rebound)))).toEqual(rebound); + expect( + withEvidenceCallerInputPath(rebound, "./schema.graphql", { + operations_path: "./queries 雪.graphql", + }), + ).toBe(rebound); + expect( + evidenceSchema.safeParse({ ...original, caller_input_paths: undefined }) + .success, + ).toBe(false); + expect(() => + withEvidenceCallerInputPath(original, "./schema.graphql", { + operations_path: "invalid\0path", + }), + ).toThrow(); +}); + it.each(["caller selection", "subject display"])( "preserves sealed JSON ownership when rebinding %s metadata", (metadata) => { diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index 9439563979..9b26620951 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -1,3 +1,4 @@ +import { isDeepStrictEqual } from "node:util"; import { basename } from "node:path"; import { z } from "zod"; @@ -83,6 +84,10 @@ const executionEnvironmentSchema = z.strictObject({ architecture: z.string().min(1), isolation: z.enum(["none", "process", "container", "virtual-machine"]), }); +const callerInputPathsSchema = z.record( + nulFreeStringSchema, + nulFreeStringSchema, +); const evidenceBaseSchema = z .object({ @@ -97,6 +102,11 @@ const evidenceBaseSchema = z caller_input_path: nulFreeStringSchema .exactOptional() .describe("Verbatim direct-request path; not part of semantic identity."), + caller_input_paths: callerInputPathsSchema + .exactOptional() + .describe( + "Verbatim auxiliary input paths keyed by argument name; caller assertions outside semantic identity.", + ), raw_result: jsonValueSchema.nullable(), normalized_result: jsonValueSchema, confidence: z.enum(["observed", "derived", "inferred"]), @@ -277,6 +287,16 @@ export const parseEvidence = (input: unknown): Evidence => { return authenticatedEvidenceSchema.parse(input); }; +/** Evidence owns all presentation metadata excluded from its semantic identity. */ +export const evidenceDisplayMetadataAgree = ( + left: Evidence, + right: Evidence, +): boolean => + left.subject?.name === right.subject?.name && + left.subject?.local_path === right.subject?.local_path && + left.caller_input_path === right.caller_input_path && + isDeepStrictEqual(left.caller_input_paths, right.caller_input_paths); + /** Recognize authenticated Evidence in a union of partial observations. */ export const tryParseEvidence = (input: unknown): Evidence | undefined => { if (typeof input !== "object" || input === null || !("evidence_id" in input)) @@ -332,15 +352,23 @@ export const rebindEvidenceToTargetPath = ( export const withEvidenceCallerInputPath = ( evidence: Evidence, rawPath: string, + additionalPaths?: Readonly>, ): Evidence => { const callerInputPath = nulFreeStringSchema.parse(rawPath); const owned = immutableEvidence(evidence); - if (owned.caller_input_path === callerInputPath) return owned; + const paths = + additionalPaths === undefined + ? undefined + : callerInputPathsSchema.parse(additionalPaths); + const samePaths = + paths === undefined || isDeepStrictEqual(owned.caller_input_paths, paths); + if (owned.caller_input_path === callerInputPath && samePaths) return owned; // The authenticated source owns its immutable payloads. Only this small // envelope changes, so replay does not copy a document-sized result. const rebound = { ...owned, caller_input_path: callerInputPath, + ...(paths === undefined ? {} : { caller_input_paths: paths }), }; return rememberReboundEvidence(owned, rebound); }; diff --git a/src/server/registerApplicationTools/apiContract.ts b/src/server/registerApplicationTools/apiContract.ts index 2b77c017c1..dbbe07245e 100644 --- a/src/server/registerApplicationTools/apiContract.ts +++ b/src/server/registerApplicationTools/apiContract.ts @@ -42,7 +42,11 @@ export const registerApiContractTool = ( }), ); if (!result.ok) - return server.delivery.toCallToolResult(result, contract); + return server.delivery.toPartialErrorToolResult( + result.error, + contract.returnsTargetContent, + options.recordEvidence, + ); return deliverApplicationEvidence( server, options, diff --git a/tests/acceptance/applications/graphqlContract.test.ts b/tests/acceptance/applications/graphqlContract.test.ts new file mode 100644 index 0000000000..c3c26063d5 --- /dev/null +++ b/tests/acceptance/applications/graphqlContract.test.ts @@ -0,0 +1,415 @@ +import { expectCliMcpEvidenceParity } from "../../fixtures/cliMcpEvidenceParity.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createHash } from "node:crypto"; +import { readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js"; +import { parseEvidence, type Evidence } from "../../../src/domain/evidence.js"; +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; +import { graphqlContractSchema } from "../../../src/domain/apiContract/graphqlContract.js"; +import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; + +const fixture = new URL( + "../../fixtures/graphql/swapi.graphql.txt", + import.meta.url, +); +const operations = + "query Films { renamed: allFilms(first: 2) { films { ...Names director } } } fragment Names on Film { title }"; +const asView = (value: unknown) => + analysisViewResultSchema.parse(parseEvidence(value).normalized_result); +const retained = (parent: Evidence) => ({ + kind: "retained-evidence", + evidence_id: parent.evidence_id, +}); + +const checkCliPublication = async ( + cli: TestCli, + context: { + root: string; + parent: Evidence; + selected: ReturnType; + }, +) => { + const { root, parent, selected } = context; + const run = cli.sharedCliRun?.bind(cli) ?? cli.run.bind(cli); + const args = [ + "inspect-api-contract", + "./SDL 雪.graphql", + "--contractFormat", + "graphql-sdl", + "--operationsPath", + "./queries file.graphql", + ]; + const cliComplete = await run({ + cwd: root, + arguments: [...args, "--json"], + }); + expect(cliComplete.exitCode, cliComplete.stderr).toBe(0); + expectCliMcpEvidenceParity(cliComplete.json, parent, "./SDL 雪.graphql", { + operations_path: "./queries file.graphql", + }); + const bundlePath = join(root, "graphql.evidence.json"); + const cliSelected = await run({ + cwd: root, + arguments: [ + ...args, + "--detail", + "summary", + "--evidence_output", + bundlePath, + "--view", + JSON.stringify({ + kind: "graphql-contract", + collection: "fields", + field_keys: ["Film.title", "Film.director"], + fields: [], + limit: 1, + }), + "--json", + ], + }); + expect(cliSelected.exitCode, cliSelected.stderr).toBe(0); + const receipt = cliSelected.json as { summary: unknown }; + expect(parseEvidence(receipt.summary)).toMatchObject({ + caller_input_paths: { operations_path: "./queries file.graphql" }, + }); + expect(asView(receipt.summary)).toEqual(selected); + const bundle = evidenceBundleSchema.parse( + JSON.parse(await readFile(bundlePath, "utf8")), + ); + expect(bundle.records[0]?.evidence_id).toBe(parent.evidence_id); + return bundlePath; +}; + +type McpCall = Awaited>["call"]; +const checkRetainedPages = async (call: McpCall, parent: Evidence) => { + const report = graphqlContractSchema.parse(parent.normalized_result); + const paged = []; + for (const offset of [0, 100, 200]) { + const response = await call("inspect_analysis_view", { + source: retained(parent), + view: { + kind: "graphql-contract", + collection: "fields", + offset, + limit: 100, + fields: [ + "field_id", + "field_key", + "owner_type_id", + "owner_type_name", + "kind", + "name", + "type_ref", + "named_type", + "source", + ], + }, + }); + const page = asView(response.structuredContent); + if (page.kind !== "graphql-contract") + throw new Error("Expected retained GraphQL page"); + expect(page.source_coverage).toEqual(report.source_coverage); + paged.push(...page.items); + } + expect(paged).toEqual(report.fields); +}; + +const checkAdvertisedSchema = async ( + client: Awaited>["client"], + input: { path: string; format: string; operations_path: string }, +) => { + const schemas = await advertisedMcpToolSchemas( + client, + "inspect_api_contract", + ); + expect(schemas.ajv.validate(schemas.inputSchema, input)).toBe(true); + for (const invalid of [ + { ...input, format: "openapi-json" }, + { ...input, view: { kind: "api-contract", collection: "operations" } }, + { + ...input, + view: { + kind: "graphql-contract", + collection: "fields", + fields: ["unknown-column"], + }, + }, + ]) + expect(schemas.ajv.validate(schemas.inputSchema, invalid)).toBe(false); +}; + +cliTest( + "imports unchanged SWAPI SDL through public CLI/MCP with exact bindings, selected replies and source-free retained pages", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-public-graphql-"); + const path = join(root, "SDL 雪.graphql"), + operationsPath = join(root, "queries file.graphql"); + const bytes = await readFile(fixture); + expect(createHash("sha256").update(bytes).digest("hex")).toBe( + "ebc4d09b21e2aec2735d6f0d3c812fc017cd49a44900dd3ae34f49f6f8c25133", + ); + await writeFile(path, bytes); + await writeFile(operationsPath, operations); + const { client, call } = await connectLocalToolsMcp({ + REA_MCP_INPUT_SCHEMA_PROFILE: "full", + REA_MCP_TOOL_FAMILIES: "application,session", + }); + const input = { + path, + format: "graphql-sdl", + operations_path: operationsPath, + }; + await checkAdvertisedSchema(client, input); + const summaryResponse = await call("inspect_api_contract", input); + expect(summaryResponse.isError).not.toBe(true); + const summary = asView(summaryResponse.structuredContent); + expect(summary).toMatchObject({ + kind: "summary", + summary: { + model_roots: { query: "Root" }, + summary: { + types: 53, + fields: 243, + definitions: 2, + references: 4, + fragment_spreads: 1, + }, + }, + }); + expect(JSON.stringify(summaryResponse.structuredContent)).not.toContain( + "Root.allFilms", + ); + const complete = await call("inspect_api_contract", { + ...input, + detail: "complete", + }); + const parent = parseEvidence(complete.structuredContent); + const report = graphqlContractSchema.parse(parent.normalized_result); + expect(summary.parent_evidence_id).toBe(parent.evidence_id); + expect( + report.references.map((row) => [ + row.field_name, + row.parent_type_name, + row.state, + ]), + ).toEqual([ + ["allFilms", "Root", "resolved"], + ["films", "FilmsConnection", "resolved"], + ["director", "Film", "resolved"], + ["title", "Film", "resolved"], + ]); + for (const reference of report.references) { + const field = report.fields.find( + (row) => row.field_id === reference.schema_field_id, + )!; + expect(field.field_key).toBe( + reference.parent_type_name + "." + reference.field_name, + ); + expect( + bytes + .toString("utf8") + .slice(field.source.start_utf16, field.source.end_utf16), + ).toContain(reference.field_name); + expect(reference.source.sha256).toBe(report.operations_artifact?.sha256); + } + const selectedResponse = await call("inspect_api_contract", { + ...input, + detail: "complete", + view: { + kind: "graphql-contract", + collection: "fields", + field_keys: ["Film.title", "Film.director"], + fields: [], + limit: 1, + }, + }); + const selected = asView(selectedResponse.structuredContent); + expect(selected).toMatchObject({ + kind: "graphql-contract", + coverage: { total: 2, next_offset: 1 }, + items: [ + { + field_id: report.fields.find((row) => row.field_key === "Film.title")! + .field_id, + }, + ], + }); + if (selected.kind !== "graphql-contract") + throw new Error("Expected selected field page"); + expect(Object.keys(selected.items[0]!).sort()).toEqual([ + "field_id", + "owner_type_id", + "source", + ]); + const bundlePath = await checkCliPublication(cli, { + root, + parent, + selected, + }); + await rm(path); + await rm(operationsPath); + await checkRetainedPages(call, parent); + const queryPath = join(root, "offline-query.json"); + await writeFile( + queryPath, + JSON.stringify({ + source: { + kind: "evidence-file", + path: bundlePath, + evidence_id: parent.evidence_id, + }, + view: { + kind: "graphql-contract", + collection: "fields", + field_keys: [], + fields: [], + }, + }), + ); + const run = cli.sharedCliRun?.bind(cli) ?? cli.run.bind(cli); + const offline = await run({ + arguments: ["inspect-analysis-view", queryPath, "--json"], + }); + expect(offline.exitCode, offline.stderr).toBe(0); + expect(asView(offline.json)).toMatchObject({ + items: [], + coverage: { total: 0, exhausted: true }, + source_coverage: report.source_coverage, + }); + }, +); + +cliTest( + "keeps duplicate and malformed GraphQL declarations explicit through advertised public replies", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-partial-graphql-"); + const path = join(root, "schema.graphql"), + operations_path = join(root, "queries.graphql"); + await writeFile(path, "type Query { value: String value: Int }"); + await writeFile(operations_path, "query Q { value ...Missing }"); + const { call } = await connectLocalToolsMcp({ + REA_MCP_TOOL_FAMILIES: "application,session", + }); + const result = await call("inspect_api_contract", { + path, + format: "graphql-sdl", + operations_path, + detail: "complete", + }); + const parent = parseEvidence(result.structuredContent); + const report = graphqlContractSchema.parse(parent.normalized_result); + expect(report.fields).toHaveLength(2); + expect(report.references[0]).toMatchObject({ + state: "unknown", + reason: "schema-model-unavailable", + schema_field_id: null, + }); + expect(report.fragment_spreads[0]?.state).toBe("missing"); + expect(report.source_coverage.status).toBe("partial"); + for (const collection of [ + "types", + "definitions", + "references", + "fragment-spreads", + "diagnostics", + ]) { + const response = await call("inspect_analysis_view", { + source: retained(parent), + view: { kind: "graphql-contract", collection, fields: [], limit: 1 }, + }); + expect(response.isError).not.toBe(true); + expect(asView(response.structuredContent)).toMatchObject({ + source_coverage: report.source_coverage, + }); + } + await writeFile(operations_path, "query Q { value("); + const malformed = await call("inspect_api_contract", { + path, + format: "graphql-sdl", + operations_path, + }); + expect(malformed.isError).not.toBe(true); + expect(asView(malformed.structuredContent)).toMatchObject({ + summary: { + source_coverage: { status: "partial", operations: "unavailable" }, + }, + }); + const run = cli.sharedCliRun?.bind(cli) ?? cli.run.bind(cli); + const cliResult = await run({ + arguments: [ + "inspect-api-contract", + path, + "--contractFormat", + "graphql-sdl", + "--operationsPath", + operations_path, + "--json", + ], + }); + expect(cliResult.exitCode, cliResult.stderr).toBe(0); + expect( + graphqlContractSchema.parse( + parseEvidence(cliResult.json).normalized_result, + ).fields, + ).toEqual(report.fields); + }, +); + +cliTest( + "retains a primary GraphQL parser refusal for later queries on the same connection", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-invalid-graphql-"); + const path = join(root, "malformed.graphql"); + const text = 'type Query { field: "https://u:p@host/path" }'; + await writeFile(path, text); + const { call } = await connectLocalToolsMcp({ + REA_MCP_TOOL_FAMILIES: "application,session", + }); + const response = await call("inspect_api_contract", { + path, + format: "graphql-sdl", + }); + const error = parseMcpToolError(response); + const source = error.error.details?.partial_observation; + expect(source).toMatchObject({ kind: "retained-evidence" }); + expect(JSON.stringify(response)).not.toContain("u:p@host"); + await rm(path); + const page = await call("inspect_analysis_view", { + source, + view: { kind: "graphql-contract", collection: "diagnostics" }, + }); + expect(page.isError).not.toBe(true); + expect(asView(page.structuredContent)).toMatchObject({ + source_coverage: { + status: "partial", + schema_declarations: "unavailable", + }, + items: [{ code: "schema-parse-refused", source: { role: "schema" } }], + }); + await writeFile(path, text); + const run = cli.sharedCliRun?.bind(cli) ?? cli.run.bind(cli); + const result = await run({ + arguments: [ + "inspect-api-contract", + path, + "--contractFormat", + "graphql-sdl", + "--json", + ], + }); + expect(result.exitCode).not.toBe(0); + expect(result.json).toMatchObject({ + details: { + partial_observation: { + caller_input_path: path, + operation: "inspect_api_contract", + }, + }, + }); + }, +); diff --git a/tests/boundary/cli/cliEvidenceCallerPath.test.ts b/tests/boundary/cli/cliEvidenceCallerPath.test.ts index e2c1d08ad3..f9bde75805 100644 --- a/tests/boundary/cli/cliEvidenceCallerPath.test.ts +++ b/tests/boundary/cli/cliEvidenceCallerPath.test.ts @@ -3,7 +3,11 @@ import { expect, it } from "vitest"; import { createEvidence } from "../../../src/domain/evidence.js"; import { err, ok } from "../../../src/domain/result.js"; import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; -import { finishCliEvidenceCommand } from "../../../src/cli/cliEvidenceCallerPath.js"; +import { finishAnalysisCommand } from "../../../src/cli/finishAnalysisCommand.js"; +import { + bindCliEvidenceCallerPath, + finishCliEvidenceCommand, +} from "../../../src/cli/cliEvidenceCallerPath.js"; it("retains the original file selection when final cleanup fails", async () => { const evidence = createEvidence( @@ -83,3 +87,35 @@ it("binds authenticated partial Evidence but preserves raw partial observations" expect(result.error.details?.partial_observation).toEqual(expected); } }); + +it("preserves both direct input spellings when final cleanup fails", async () => { + const evidence = createEvidence( + { path: "/tmp/schema", sha256: "a".repeat(64), format: "file" }, + { id: "test", name: "Test", version: "1" }, + { operation: "inspect", parameters: {}, result: null }, + ); + const result = await finishAnalysisCommand( + bindCliEvidenceCallerPath("./schema", async () => ok(evidence), { + operations_path: "./queries 雪.graphql", + }), + { + close: async () => { + throw new Error("cleanup failed"); + }, + }, + "test", + "inspect", + new AbortController().signal, + ); + if (result.ok) throw new Error("Expected cleanup failure"); + expect(result.error).toMatchObject({ + code: "cleanup_incomplete", + details: { + partial_observation: { + evidence_id: evidence.evidence_id, + caller_input_path: "./schema", + caller_input_paths: { operations_path: "./queries 雪.graphql" }, + }, + }, + }); +}); diff --git a/tests/boundary/filesystem/evidenceBundleFiles.test.ts b/tests/boundary/filesystem/evidenceBundleFiles.test.ts index 86b955dc05..3fe76795b4 100644 --- a/tests/boundary/filesystem/evidenceBundleFiles.test.ts +++ b/tests/boundary/filesystem/evidenceBundleFiles.test.ts @@ -312,10 +312,15 @@ it.each([undefined, "../selected-link/./artifact"])( const parent = callerInputPath === undefined ? original - : withEvidenceCallerInputPath(original, callerInputPath); + : withEvidenceCallerInputPath(original, callerInputPath, { + operations_path: "./query 雪.graphql", + }); const delivered = await deliverAnalysisEvidence(parent, path, "summary"); if (!delivered.ok) throw delivered.error; expect(delivered.value.caller_input_path).toBe(callerInputPath); + expect(delivered.value.caller_input_paths).toEqual( + parent.caller_input_paths, + ); expect(delivered.value.evidence_links).toContain(parent.evidence_id); expect(original.caller_input_path).toBeUndefined(); const saved = await readEvidenceBundle(path); @@ -324,6 +329,7 @@ it.each([undefined, "../selected-link/./artifact"])( (record) => record.evidence_id === parent.evidence_id, ); expect(savedParent?.caller_input_path).toBe(callerInputPath); + expect(savedParent?.caller_input_paths).toEqual(parent.caller_input_paths); }, ); diff --git a/tests/fixtures/cliMcpEvidenceParity.ts b/tests/fixtures/cliMcpEvidenceParity.ts index 9efe03c832..fb771ed1c6 100644 --- a/tests/fixtures/cliMcpEvidenceParity.ts +++ b/tests/fixtures/cliMcpEvidenceParity.ts @@ -6,9 +6,16 @@ export const expectCliMcpEvidenceParity = ( cli: unknown, mcp: unknown, callerPath: string, + auxiliaryPaths?: Readonly>, ): void => { - const { caller_input_path: callerInputPath, ...shared } = parseEvidence(cli); + const { + caller_input_path: callerInputPath, + caller_input_paths: callerInputPaths, + ...shared + } = parseEvidence(cli); expect(callerInputPath).toBe(callerPath); + expect(callerInputPaths).toEqual(auxiliaryPaths); expect(mcp).not.toHaveProperty("caller_input_path"); + expect(mcp).not.toHaveProperty("caller_input_paths"); expect(mcp).toEqual(shared); }; diff --git a/tests/fixtures/graphql/LICENSE b/tests/fixtures/graphql/LICENSE new file mode 100644 index 0000000000..7bbf892a04 --- /dev/null +++ b/tests/fixtures/graphql/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) GraphQL Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tests/fixtures/graphql/README.md b/tests/fixtures/graphql/README.md new file mode 100644 index 0000000000..3ce8f9d458 --- /dev/null +++ b/tests/fixtures/graphql/README.md @@ -0,0 +1,15 @@ +# GraphQL contract fixture + +`swapi.graphql.txt` is the unmodified SDL published by the +[SWAPI GraphQL application](https://github.com/graphql/swapi-graphql/blob/8c0bf5868f11fe84942548180751fceeb36bd606/schema.graphql), +at revision `8c0bf5868f11fe84942548180751fceeb36bd606`, retrieved 2026-11-06. +Its MIT license is preserved in `LICENSE`. + +- Size: 35,868 bytes. +- SHA-256: `ebc4d09b21e2aec2735d6f0d3c812fc017cd49a44900dd3ae34f49f6f8c25133`. +- The `.txt` suffix protects the original bytes from source formatters. + +This is a published application contract, not captured runtime traffic. +Owned operation documents and malformed variants are separately constructed +by tests. Parsing this fixture does not establish endpoint reachability, +authorization, operation execution or complete server behavior. diff --git a/tests/fixtures/graphql/swapi.graphql.txt b/tests/fixtures/graphql/swapi.graphql.txt new file mode 100644 index 0000000000..3e739807d7 --- /dev/null +++ b/tests/fixtures/graphql/swapi.graphql.txt @@ -0,0 +1,1166 @@ +schema { + query: Root +} + +"""A single film.""" +type Film implements Node { + """The title of this film.""" + title: String + + """The episode number of this film.""" + episodeID: Int + + """The opening paragraphs at the beginning of this film.""" + openingCrawl: String + + """The name of the director of this film.""" + director: String + + """The name(s) of the producer(s) of this film.""" + producers: [String] + + """The ISO 8601 date format of film release at original creator country.""" + releaseDate: String + speciesConnection(after: String, first: Int, before: String, last: Int): FilmSpeciesConnection + starshipConnection(after: String, first: Int, before: String, last: Int): FilmStarshipsConnection + vehicleConnection(after: String, first: Int, before: String, last: Int): FilmVehiclesConnection + characterConnection(after: String, first: Int, before: String, last: Int): FilmCharactersConnection + planetConnection(after: String, first: Int, before: String, last: Int): FilmPlanetsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! +} + +"""A connection to a list of items.""" +type FilmCharactersConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmCharactersEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + characters: [Person] +} + +"""An edge in a connection.""" +type FilmCharactersEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type FilmPlanetsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmPlanetsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + planets: [Planet] +} + +"""An edge in a connection.""" +type FilmPlanetsEdge { + """The item at the end of the edge""" + node: Planet + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type FilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +"""An edge in a connection.""" +type FilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type FilmSpeciesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmSpeciesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + species: [Species] +} + +"""An edge in a connection.""" +type FilmSpeciesEdge { + """The item at the end of the edge""" + node: Species + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type FilmStarshipsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmStarshipsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + starships: [Starship] +} + +"""An edge in a connection.""" +type FilmStarshipsEdge { + """The item at the end of the edge""" + node: Starship + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type FilmVehiclesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmVehiclesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + vehicles: [Vehicle] +} + +"""An edge in a connection.""" +type FilmVehiclesEdge { + """The item at the end of the edge""" + node: Vehicle + + """A cursor for use in pagination""" + cursor: String! +} + +"""An object with an ID""" +interface Node { + """The id of the object.""" + id: ID! +} + +"""Information about pagination in a connection.""" +type PageInfo { + """When paginating forwards, are there more items?""" + hasNextPage: Boolean! + + """When paginating backwards, are there more items?""" + hasPreviousPage: Boolean! + + """When paginating backwards, the cursor to continue.""" + startCursor: String + + """When paginating forwards, the cursor to continue.""" + endCursor: String +} + +"""A connection to a list of items.""" +type PeopleConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PeopleEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + people: [Person] +} + +"""An edge in a connection.""" +type PeopleEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! +} + +"""An individual person or character within the Star Wars universe.""" +type Person implements Node { + """The name of this person.""" + name: String + + """ + The birth year of the person, using the in-universe standard of BBY or ABY - + Before the Battle of Yavin or After the Battle of Yavin. The Battle of Yavin is + a battle that occurs at the end of Star Wars episode IV: A New Hope. + """ + birthYear: String + + """ + The eye color of this person. Will be "unknown" if not known or "n/a" if the + person does not have an eye. + """ + eyeColor: String + + """ + The gender of this person. Either "Male", "Female" or "unknown", + "n/a" if the person does not have a gender. + """ + gender: String + + """ + The hair color of this person. Will be "unknown" if not known or "n/a" if the + person does not have hair. + """ + hairColor: String + + """The height of the person in centimeters.""" + height: Int + + """The mass of the person in kilograms.""" + mass: Float + + """The skin color of this person.""" + skinColor: String + + """A planet that this person was born on or inhabits.""" + homeworld: Planet + filmConnection(after: String, first: Int, before: String, last: Int): PersonFilmsConnection + + """The species that this person belongs to, or null if unknown.""" + species: Species + starshipConnection(after: String, first: Int, before: String, last: Int): PersonStarshipsConnection + vehicleConnection(after: String, first: Int, before: String, last: Int): PersonVehiclesConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! +} + +"""A connection to a list of items.""" +type PersonFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PersonFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +"""An edge in a connection.""" +type PersonFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type PersonStarshipsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PersonStarshipsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + starships: [Starship] +} + +"""An edge in a connection.""" +type PersonStarshipsEdge { + """The item at the end of the edge""" + node: Starship + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type PersonVehiclesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PersonVehiclesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + vehicles: [Vehicle] +} + +"""An edge in a connection.""" +type PersonVehiclesEdge { + """The item at the end of the edge""" + node: Vehicle + + """A cursor for use in pagination""" + cursor: String! +} + +""" +A large mass, planet or planetoid in the Star Wars Universe, at the time of +0 ABY. +""" +type Planet implements Node { + """The name of this planet.""" + name: String + + """The diameter of this planet in kilometers.""" + diameter: Int + + """ + The number of standard hours it takes for this planet to complete a single + rotation on its axis. + """ + rotationPeriod: Int + + """ + The number of standard days it takes for this planet to complete a single orbit + of its local star. + """ + orbitalPeriod: Int + + """ + A number denoting the gravity of this planet, where "1" is normal or 1 standard + G. "2" is twice or 2 standard Gs. "0.5" is half or 0.5 standard Gs. + """ + gravity: String + + """The average population of sentient beings inhabiting this planet.""" + population: Float + + """The climates of this planet.""" + climates: [String] + + """The terrains of this planet.""" + terrains: [String] + + """ + The percentage of the planet surface that is naturally occurring water or bodies + of water. + """ + surfaceWater: Float + residentConnection(after: String, first: Int, before: String, last: Int): PlanetResidentsConnection + filmConnection(after: String, first: Int, before: String, last: Int): PlanetFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! +} + +"""A connection to a list of items.""" +type PlanetFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PlanetFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +"""An edge in a connection.""" +type PlanetFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type PlanetResidentsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PlanetResidentsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + residents: [Person] +} + +"""An edge in a connection.""" +type PlanetResidentsEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type PlanetsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PlanetsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + planets: [Planet] +} + +"""An edge in a connection.""" +type PlanetsEdge { + """The item at the end of the edge""" + node: Planet + + """A cursor for use in pagination""" + cursor: String! +} + +type Root { + allFilms(after: String, first: Int, before: String, last: Int): FilmsConnection + film(id: ID, filmID: ID): Film + allPeople(after: String, first: Int, before: String, last: Int): PeopleConnection + person(id: ID, personID: ID): Person + allPlanets(after: String, first: Int, before: String, last: Int): PlanetsConnection + planet(id: ID, planetID: ID): Planet + allSpecies(after: String, first: Int, before: String, last: Int): SpeciesConnection + species(id: ID, speciesID: ID): Species + allStarships(after: String, first: Int, before: String, last: Int): StarshipsConnection + starship(id: ID, starshipID: ID): Starship + allVehicles(after: String, first: Int, before: String, last: Int): VehiclesConnection + vehicle(id: ID, vehicleID: ID): Vehicle + + """Fetches an object given its ID""" + node( + """The ID of an object""" + id: ID! + ): Node +} + +"""A type of person or character within the Star Wars Universe.""" +type Species implements Node { + """The name of this species.""" + name: String + + """The classification of this species, such as "mammal" or "reptile".""" + classification: String + + """The designation of this species, such as "sentient".""" + designation: String + + """The average height of this species in centimeters.""" + averageHeight: Float + + """The average lifespan of this species in years, null if unknown.""" + averageLifespan: Int + + """ + Common eye colors for this species, null if this species does not typically + have eyes. + """ + eyeColors: [String] + + """ + Common hair colors for this species, null if this species does not typically + have hair. + """ + hairColors: [String] + + """ + Common skin colors for this species, null if this species does not typically + have skin. + """ + skinColors: [String] + + """The language commonly spoken by this species.""" + language: String + + """A planet that this species originates from.""" + homeworld: Planet + personConnection(after: String, first: Int, before: String, last: Int): SpeciesPeopleConnection + filmConnection(after: String, first: Int, before: String, last: Int): SpeciesFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! +} + +"""A connection to a list of items.""" +type SpeciesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [SpeciesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + species: [Species] +} + +"""An edge in a connection.""" +type SpeciesEdge { + """The item at the end of the edge""" + node: Species + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type SpeciesFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [SpeciesFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +"""An edge in a connection.""" +type SpeciesFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type SpeciesPeopleConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [SpeciesPeopleEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + people: [Person] +} + +"""An edge in a connection.""" +type SpeciesPeopleEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! +} + +"""A single transport craft that has hyperdrive capability.""" +type Starship implements Node { + """The name of this starship. The common name, such as "Death Star".""" + name: String + + """ + The model or official name of this starship. Such as "T-65 X-wing" or "DS-1 + Orbital Battle Station". + """ + model: String + + """ + The class of this starship, such as "Starfighter" or "Deep Space Mobile + Battlestation" + """ + starshipClass: String + + """The manufacturers of this starship.""" + manufacturers: [String] + + """The cost of this starship new, in galactic credits.""" + costInCredits: Float + + """The length of this starship in meters.""" + length: Float + + """The number of personnel needed to run or pilot this starship.""" + crew: String + + """The number of non-essential people this starship can transport.""" + passengers: String + + """ + The maximum speed of this starship in atmosphere. null if this starship is + incapable of atmosphering flight. + """ + maxAtmospheringSpeed: Int + + """The class of this starships hyperdrive.""" + hyperdriveRating: Float + + """ + The Maximum number of Megalights this starship can travel in a standard hour. + A "Megalight" is a standard unit of distance and has never been defined before + within the Star Wars universe. This figure is only really useful for measuring + the difference in speed of starships. We can assume it is similar to AU, the + distance between our Sun (Sol) and Earth. + """ + MGLT: Int + + """The maximum number of kilograms that this starship can transport.""" + cargoCapacity: Float + + """ + The maximum length of time that this starship can provide consumables for its + entire crew without having to resupply. + """ + consumables: String + pilotConnection(after: String, first: Int, before: String, last: Int): StarshipPilotsConnection + filmConnection(after: String, first: Int, before: String, last: Int): StarshipFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! +} + +"""A connection to a list of items.""" +type StarshipFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [StarshipFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +"""An edge in a connection.""" +type StarshipFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type StarshipPilotsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [StarshipPilotsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + pilots: [Person] +} + +"""An edge in a connection.""" +type StarshipPilotsEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type StarshipsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [StarshipsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + starships: [Starship] +} + +"""An edge in a connection.""" +type StarshipsEdge { + """The item at the end of the edge""" + node: Starship + + """A cursor for use in pagination""" + cursor: String! +} + +"""A single transport craft that does not have hyperdrive capability""" +type Vehicle implements Node { + """ + The name of this vehicle. The common name, such as "Sand Crawler" or "Speeder + bike". + """ + name: String + + """ + The model or official name of this vehicle. Such as "All-Terrain Attack + Transport". + """ + model: String + + """The class of this vehicle, such as "Wheeled" or "Repulsorcraft".""" + vehicleClass: String + + """The manufacturers of this vehicle.""" + manufacturers: [String] + + """The cost of this vehicle new, in Galactic Credits.""" + costInCredits: Float + + """The length of this vehicle in meters.""" + length: Float + + """The number of personnel needed to run or pilot this vehicle.""" + crew: String + + """The number of non-essential people this vehicle can transport.""" + passengers: String + + """The maximum speed of this vehicle in atmosphere.""" + maxAtmospheringSpeed: Int + + """The maximum number of kilograms that this vehicle can transport.""" + cargoCapacity: Float + + """ + The maximum length of time that this vehicle can provide consumables for its + entire crew without having to resupply. + """ + consumables: String + pilotConnection(after: String, first: Int, before: String, last: Int): VehiclePilotsConnection + filmConnection(after: String, first: Int, before: String, last: Int): VehicleFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! +} + +"""A connection to a list of items.""" +type VehicleFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [VehicleFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +"""An edge in a connection.""" +type VehicleFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type VehiclePilotsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [VehiclePilotsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + pilots: [Person] +} + +"""An edge in a connection.""" +type VehiclePilotsEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type VehiclesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [VehiclesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + vehicles: [Vehicle] +} + +"""An edge in a connection.""" +type VehiclesEdge { + """The item at the end of the edge""" + node: Vehicle + + """A cursor for use in pagination""" + cursor: String! +} From ee2f5b965a7b5cb0036444330d89fa32302f7845 Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Fri, 6 Nov 2026 20:48:17 +0800 Subject: [PATCH 061/151] fix(desktop): bound exported task array-index conversion (#9139) --- src/domain/desktop/appTaskPython.ts | 6 ++- .../providers/desktopTaskArrayIndex.test.ts | 52 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 tests/boundary/providers/desktopTaskArrayIndex.test.ts diff --git a/src/domain/desktop/appTaskPython.ts b/src/domain/desktop/appTaskPython.ts index 50d0fbd68e..456e8f6060 100644 --- a/src/domain/desktop/appTaskPython.ts +++ b/src/domain/desktop/appTaskPython.ts @@ -61,7 +61,11 @@ def _selects(member, conditions) -> bool: def _step(items: list, token: str, last: bool): """One step into an array, by index or by a selector that must match one member, or none when it is the last.""" if INDEX.fullmatch(token): - return ("member", items[int(token)]) if int(token) < len(items) else ("none", None) + # A canonical decimal longer than the array length cannot name a member. + if len(token) > len(str(len(items))): + return "none", None + index = int(token) + return ("member", items[index]) if index < len(items) else ("none", None) conditions = _selector(token) if conditions is None: return ("unresolved", f"{token} is not a selector of the form [KEY=VALUE]") if _bracketed(token) else ( diff --git a/tests/boundary/providers/desktopTaskArrayIndex.test.ts b/tests/boundary/providers/desktopTaskArrayIndex.test.ts new file mode 100644 index 0000000000..80f1ada014 --- /dev/null +++ b/tests/boundary/providers/desktopTaskArrayIndex.test.ts @@ -0,0 +1,52 @@ +import { execFileSync } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { PYTHON_TASK_EVALUATOR } from "../../../src/domain/desktop/appTaskPython.js"; +import { locatePointer } from "../../../src/domain/desktop/viewPointer.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it("keeps oversized array indices absent without crashing exported task checks", async () => { + const huge = "9".repeat(5000); + const cases = [ + { items: [1], pointer: `/${huge}` }, + { items: [], pointer: `/${huge}` }, + { items: [1], pointer: "/0" }, + { items: [1], pointer: "/1" }, + { items: Array.from({ length: 12 }, (_, index) => index), pointer: "/11" }, + { items: Array.from({ length: 12 }, (_, index) => index), pointer: "/12" }, + { items: [1], pointer: "/01" }, + ]; + const folder = await createTestTempDirectory("task-array-index-"); + const script = join(folder, "fixture.py"); + await writeFile( + script, + `import json, re, math, sys +${PYTHON_TASK_EVALUATOR} +results = [] +for case in json.load(sys.stdin): + items, pointer = case["items"], case["pointer"] + located = _locate(items, pointer) + check = {"id": "absent", "kind": "value_absent", "file": "state.json", "pointer": pointer} + verdict = evaluate([check], {"state.json": {"format": "json", "view": items, "truncated": False}})[0] + results.append([located[0], located[1], verdict["result"]]) +print(json.dumps(results)) +`, + ); + const results = JSON.parse( + execFileSync("python3", ["-I", "-B", script], { + input: JSON.stringify(cases), + encoding: "utf8", + }), + ); + expect(results).toEqual( + cases.map(({ items, pointer }) => { + const located = locatePointer(items, pointer); + return [ + located.kind, + located.kind === "value" ? located.value : null, + located.kind === "none", + ]; + }), + ); +}); From 7e9cf83fcabf942921824658fb68da839a798d7e Mon Sep 17 00:00:00 2001 From: Harsh Asalkar Date: Fri, 6 Nov 2026 18:18:37 +0530 Subject: [PATCH 062/151] feat(mcp): guide agents from argument-name mistakes in procedure and search tools (#2146) (#8649) * feat(domain): guide agents from neighboring-tool argument mistakes (#2146) * feat(mcp): guide agents from argument-name mistakes in MCP tools (#2146) * fix(domain): suppress argument replacement when expected field is present (#2146) Suppress argument replacement guidance when the expected field is already present in the call input, preventing invalid suggestions such as advising procedure when procedure was already provided. Defrost explanatory prose assertions in tests and bind test cases to production toolContract schemas. * test(domain): use named contract families and decouple guidance prose in tests (#2146) Import named contract families (OFFICIAL_TOOL_CONTRACTS, APPLICATION_TOOL_CONTRACTS, ENHANCED_TOOL_CONTRACTS) instead of the full tool catalog per docs/testing.md. Replace hardcoded explanatory message and remediation strings with dynamic references to the owning guidance constants. * test(domain): restore independent replacement field assertions (#2146) Keep exact checks for expected replacement field names ('procedure', 'pattern', 'address', 'query') independent of the owning guidance helper per docs/testing.md, while retaining dynamic validation for message and remediation prose. --- docs/mcp-contracts.md | 6 +- src/domain/analysisErrorPresentation.ts | 16 +- src/domain/inputArgumentGuidance.ts | 125 +++++++++++ src/domain/inputIssueProjection.test.ts | 282 ++++++++++++++++++++++++ src/domain/inputIssueProjection.ts | 89 ++++++-- src/ida/IdaInput.ts | 9 +- src/server/EvidenceMcpServer.test.ts | 151 +++++++++++++ src/server/EvidenceMcpServerCore.ts | 7 +- 8 files changed, 658 insertions(+), 27 deletions(-) create mode 100644 src/domain/inputArgumentGuidance.ts diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 1d50599359..fccae5cb79 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -188,7 +188,11 @@ The complete field/type diagnostic is preserved when normalization fits the response budget, including request-specific rejected fields and reasons. Zod input issues retain their field paths and available type and range constraints. SDK diagnostic parts may be distributed between field issues and the main -message so each retained explanation appears once. +message so each retained explanation appears once. Known neighboring-tool +argument-name mistakes (such as `address` instead of `procedure`, or `query` +instead of `pattern`) provide targeted replacement guidance and the expected +canonical property name without accepting unauthorized aliases or changing +canonical Evidence parameters. If their conservative projection allowance is exhausted, the response states that field-level detail was omitted and retains the original SDK diagnostic. Oversized SDK diagnostics keep the existing retained-Evidence recovery path. An unknown diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 2285303699..6378be7615 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -1,4 +1,5 @@ import { describeResidualUnknownGraphConstraint } from "./residualUnknownGraphError.js"; +import { knownArgumentMistakeRemediation } from "./inputArgumentGuidance.js"; import { AnalysisAccessDeniedError, AnalysisArtifactChangedError, @@ -214,11 +215,18 @@ export const analysisErrorRemediationAction = ( ? `Correct ${[...new Set(error.settings.map(({ setting }) => setting))].join(", ")} in the environment that starts REA (your shell or the MCP client's registration), then rerun.` : analysisErrorUserMessage(error)) ); - if (error instanceof AnalysisInputError) - return ( - error.remediationAction ?? - `Read the declared input schema or CLI usage for ${error.operation}; if using MCP and the catalog may be stale, call tools/list. Correct the listed fields, then retry. Do not repeat the unchanged call.` + if (error instanceof AnalysisInputError) { + const mistakeRemediation = knownArgumentMistakeRemediation( + error.operation, + error.issues, ); + const baseAction = + error.remediationAction ?? + `Read the declared input schema or CLI usage for ${error.operation}; if using MCP and the catalog may be stale, call tools/list. Correct the listed fields, then retry. Do not repeat the unchanged call.`; + return mistakeRemediation === undefined + ? baseAction + : `${mistakeRemediation} ${baseAction}`; + } if ( error instanceof AnalysisOutputError && error.remediationAction !== undefined diff --git a/src/domain/inputArgumentGuidance.ts b/src/domain/inputArgumentGuidance.ts new file mode 100644 index 0000000000..770c0b5d06 --- /dev/null +++ b/src/domain/inputArgumentGuidance.ts @@ -0,0 +1,125 @@ +import type { AnalysisInputIssue } from "./analysisErrorCore.js"; + +/** Deterministic correction metadata for a known neighboring-tool argument name mistake. */ +export interface ArgumentMistakeGuidance { + readonly expected: string; + readonly message: string; + readonly remediation: string; +} + +const PROCEDURE_OPERATIONS: ReadonlySet = new Set([ + "analyze_function", + "annotate_native_function", + "inspect_native_api", + "procedure_address", + "procedure_assembly", + "procedure_callees", + "procedure_callers", + "procedure_info", + "procedure_pseudo_code", + "procedure_references", + "read_function_instructions", + "trace_native_values", +]); + +const ADDRESS_OPERATIONS: ReadonlySet = new Set([ + "address_name", + "address_to_file_offset", + "comment", + "get_call_graph", + "goto_address", + "inline_comment", + "inspect_address_context", + "inspect_native_instruction", + "next_address", + "prev_address", + "read_bytes", + "resolve_containing_procedure", + "resolve_native_call_targets", + "set_address_name", + "set_bookmark", + "set_comment", + "set_inline_comment", + "unset_bookmark", + "xrefs", +]); + +const PATTERN_OPERATIONS: ReadonlySet = new Set([ + "analyze_swift_types", + "get_objc_classes", + "search_bytes", + "search_procedures", + "search_strings", +]); + +const QUERY_OPERATIONS: ReadonlySet = new Set([ + "search_guidance", + "trace_feature", +]); + +const PROCEDURE_INSTEAD_OF_ADDRESS: ArgumentMistakeGuidance = { + expected: "procedure", + message: "Unrecognized argument 'address'; did you mean 'procedure'?", + remediation: + "Use 'procedure' (a procedure name or address) instead of 'address'.", +}; + +const ADDRESS_INSTEAD_OF_PROCEDURE: ArgumentMistakeGuidance = { + expected: "address", + message: "Unrecognized argument 'procedure'; did you mean 'address'?", + remediation: "Use 'address' instead of 'procedure'.", +}; + +const PATTERN_INSTEAD_OF_QUERY: ArgumentMistakeGuidance = { + expected: "pattern", + message: "Unrecognized argument 'query'; did you mean 'pattern'?", + remediation: "Use 'pattern' instead of 'query'.", +}; + +const QUERY_INSTEAD_OF_PATTERN: ArgumentMistakeGuidance = { + expected: "query", + message: "Unrecognized argument 'pattern'; did you mean 'query'?", + remediation: "Use 'query' instead of 'pattern'.", +}; + +/** Lookup known neighboring-tool argument-name confusion for one top-level key. */ +export const knownArgumentMistakeGuidance = ( + operation: string | undefined, + argumentName: string, +): ArgumentMistakeGuidance | undefined => { + if (operation === undefined) return undefined; + if (argumentName === "address" && PROCEDURE_OPERATIONS.has(operation)) + return PROCEDURE_INSTEAD_OF_ADDRESS; + if (argumentName === "procedure" && ADDRESS_OPERATIONS.has(operation)) + return ADDRESS_INSTEAD_OF_PROCEDURE; + if (argumentName === "query" && PATTERN_OPERATIONS.has(operation)) + return PATTERN_INSTEAD_OF_QUERY; + if (argumentName === "pattern" && QUERY_OPERATIONS.has(operation)) + return QUERY_INSTEAD_OF_PATTERN; + return undefined; +}; + +/** Combine distinct argument-name replacement hints for an operation's rejected issues. */ +export const knownArgumentMistakeRemediation = ( + operation: string, + issues: readonly AnalysisInputIssue[], +): string | undefined => { + const hints: string[] = []; + const seen = new Set(); + for (const issue of issues) { + if (issue.reason !== "unknown_argument" || issue.path.length !== 1) + continue; + const key = issue.path[0]; + if (typeof key !== "string") continue; + const guidance = knownArgumentMistakeGuidance(operation, key); + if ( + guidance !== undefined && + issue.expected === guidance.expected && + !seen.has(guidance.remediation) + ) { + seen.add(guidance.remediation); + hints.push(guidance.remediation); + } + } + return hints.length === 0 ? undefined : hints.join(" "); +}; diff --git a/src/domain/inputIssueProjection.test.ts b/src/domain/inputIssueProjection.test.ts index 3ba9fe8aaf..5e2385cc4d 100644 --- a/src/domain/inputIssueProjection.test.ts +++ b/src/domain/inputIssueProjection.test.ts @@ -6,7 +6,39 @@ import { projectInputIssues, projectInputIssuesWithinBudget, } from "./inputIssueProjection.js"; +import { projectAnalysisError } from "./analysisErrorProjection.js"; import { processScenarioSchema } from "./process/processScenario.js"; +import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/officialToolContracts.js"; +import { APPLICATION_TOOL_CONTRACTS } from "../contracts/applicationToolContracts.js"; +import { ENHANCED_TOOL_CONTRACTS } from "../contracts/enhancedToolContracts.js"; +import { knownArgumentMistakeGuidance } from "./inputArgumentGuidance.js"; + +const officialContract = ( + name: (typeof OFFICIAL_TOOL_CONTRACTS)[number]["name"], +) => { + const contract = OFFICIAL_TOOL_CONTRACTS.find((c) => c.name === name); + if (contract === undefined) + throw new Error(`Missing official contract: ${name}`); + return contract; +}; + +const applicationContract = ( + name: (typeof APPLICATION_TOOL_CONTRACTS)[number]["name"], +) => { + const contract = APPLICATION_TOOL_CONTRACTS.find((c) => c.name === name); + if (contract === undefined) + throw new Error(`Missing application contract: ${name}`); + return contract; +}; + +const enhancedContract = ( + name: (typeof ENHANCED_TOOL_CONTRACTS)[number]["name"], +) => { + const contract = ENHANCED_TOOL_CONTRACTS.find((c) => c.name === name); + if (contract === undefined) + throw new Error(`Missing enhanced contract: ${name}`); + return contract; +}; describe("input issue projection", () => { it("builds application input errors with projected and semantic issues", () => { @@ -119,6 +151,214 @@ describe("input issue projection", () => { }); }); +describe("input issue argument guidance", () => { + it("guides callers from neighboring-tool argument names while keeping unknown keys strict", () => { + const input = { address: "0x401000", count: 10 }; + for (const op of [ + "read_function_instructions", + "procedure_pseudo_code", + "procedure_assembly", + ] as const) { + const guidance = knownArgumentMistakeGuidance(op, "address"); + expect(guidance).toBeDefined(); + const parsed = officialContract(op).inputSchema.safeParse(input); + if (parsed.success) throw new Error("expected invalid input"); + const error = analysisInputErrorFromIssues( + op, + parsed.error.issues, + input, + ); + expect(error.issues).toEqual([ + { path: ["procedure"], reason: "missing_argument", expected: "string" }, + { + path: ["address"], + reason: "unknown_argument", + expected: "procedure", + message: guidance!.message, + }, + { path: ["count"], reason: "unknown_argument" }, + ]); + const action = projectAnalysisError(error).remediation.action; + expect(action.startsWith(guidance!.remediation)).toBe(true); + expect(action).toContain(op); + } + + const searchInput = { query: "flag" }; + for (const op of ["search_strings", "search_procedures"] as const) { + const guidance = knownArgumentMistakeGuidance(op, "query"); + expect(guidance).toBeDefined(); + const parsed = officialContract(op).inputSchema.safeParse(searchInput); + if (parsed.success) throw new Error("expected invalid input"); + const searchError = analysisInputErrorFromIssues( + op, + parsed.error.issues, + searchInput, + ); + expect(searchError.issues).toEqual([ + { path: ["pattern"], reason: "missing_argument", expected: "string" }, + { + path: ["query"], + reason: "unknown_argument", + expected: "pattern", + message: guidance!.message, + }, + ]); + const action = projectAnalysisError(searchError).remediation.action; + expect(action.startsWith(guidance!.remediation)).toBe(true); + expect(action).toContain(op); + } + }); + + it("guides callers for symmetric neighboring-tool argument names", () => { + const addressInput = { procedure: "main" }; + for (const op of ["resolve_containing_procedure", "read_bytes"] as const) { + const guidance = knownArgumentMistakeGuidance(op, "procedure"); + expect(guidance).toBeDefined(); + const parsed = officialContract(op).inputSchema.safeParse(addressInput); + if (parsed.success) throw new Error("expected invalid input"); + const error = analysisInputErrorFromIssues( + op, + parsed.error.issues, + addressInput, + ); + expect(error.issues).toEqual([ + { path: ["address"], reason: "missing_argument", expected: "string" }, + { + path: ["procedure"], + reason: "unknown_argument", + expected: "address", + message: guidance!.message, + }, + ]); + const action = projectAnalysisError(error).remediation.action; + expect(action.startsWith(guidance!.remediation)).toBe(true); + expect(action).toContain(op); + } + + const xrefsGuidance = knownArgumentMistakeGuidance("xrefs", "procedure"); + expect(xrefsGuidance).toBeDefined(); + const xrefsParsed = + officialContract("xrefs").inputSchema.safeParse(addressInput); + if (xrefsParsed.success) throw new Error("expected invalid input"); + const xrefsError = analysisInputErrorFromIssues( + "xrefs", + xrefsParsed.error.issues, + addressInput, + ); + expect(xrefsError.issues).toEqual([ + { + path: ["procedure"], + reason: "unknown_argument", + expected: "address", + message: xrefsGuidance!.message, + }, + ]); + const xrefsAction = projectAnalysisError(xrefsError).remediation.action; + expect(xrefsAction.startsWith(xrefsGuidance!.remediation)).toBe(true); + expect(xrefsAction).toContain("xrefs"); + + const queryInput = { pattern: "flag" }; + const queryCases = [ + { + op: "search_guidance" as const, + contract: applicationContract("search_guidance"), + }, + { + op: "trace_feature" as const, + contract: enhancedContract("trace_feature"), + }, + ]; + for (const { op, contract } of queryCases) { + const guidance = knownArgumentMistakeGuidance(op, "pattern"); + expect(guidance).toBeDefined(); + const parsed = contract.inputSchema.safeParse(queryInput); + if (parsed.success) throw new Error("expected invalid input"); + const error = analysisInputErrorFromIssues( + op, + parsed.error.issues, + queryInput, + ); + expect(error.issues).toEqual([ + { path: ["query"], reason: "missing_argument", expected: "string" }, + { + path: ["pattern"], + reason: "unknown_argument", + expected: "query", + message: guidance!.message, + }, + ]); + const action = projectAnalysisError(error).remediation.action; + expect(action.startsWith(guidance!.remediation)).toBe(true); + expect(action).toContain(op); + } + }); +}); + +describe("input issue argument guidance suppression", () => { + it("suppresses replacement guidance when expected argument is already present", () => { + const inputWithBoth = { + procedure: "main", + address: "0x401000", + comment: "entry comment", + }; + const parsed = officialContract( + "annotate_native_function", + ).inputSchema.safeParse(inputWithBoth); + if (parsed.success) throw new Error("expected invalid input"); + const error = analysisInputErrorFromIssues( + "annotate_native_function", + parsed.error.issues, + inputWithBoth, + ); + expect(error.issues).toEqual([ + { path: ["address"], reason: "unknown_argument" }, + ]); + const action = projectAnalysisError(error).remediation.action; + const suppressedGuidance = knownArgumentMistakeGuidance( + "annotate_native_function", + "address", + ); + expect(suppressedGuidance).toBeDefined(); + expect(action).not.toContain(suppressedGuidance!.remediation); + expect(action).toContain("annotate_native_function"); + + const budgeted = projectInputIssuesWithinBudget( + parsed.error.issues, + inputWithBoth, + 1024, + "annotate_native_function", + ); + expect(budgeted).toEqual(error.issues); + + const inputMissingExpected = { + address: "0x401000", + comment: "entry comment", + }; + const parsedMissing = officialContract( + "annotate_native_function", + ).inputSchema.safeParse(inputMissingExpected); + if (parsedMissing.success) throw new Error("expected invalid input"); + const errorMissing = analysisInputErrorFromIssues( + "annotate_native_function", + parsedMissing.error.issues, + inputMissingExpected, + ); + const missingGuidance = knownArgumentMistakeGuidance( + "annotate_native_function", + "address", + ); + expect(missingGuidance).toBeDefined(); + expect(errorMissing.issues).toContainEqual({ + path: ["address"], + reason: "unknown_argument", + expected: "procedure", + message: missingGuidance!.message, + }); + const missingAction = projectAnalysisError(errorMissing).remediation.action; + expect(missingAction.startsWith(missingGuidance!.remediation)).toBe(true); + }); +}); + describe("bounded input issue projection", () => { it("bounds union expansion before constructing field diagnostics", () => { const schema = z.object({ @@ -189,4 +429,46 @@ describe("bounded input issue projection", () => { 10, ); }); + + it("bounds pre-allocation budget accounting for guided unrecognized keys", () => { + const issue: z.core.$ZodIssue = { + code: "unrecognized_keys", + path: [], + keys: ["address"], + message: "Unrecognized keys: address", + }; + expect(projectInputIssuesWithinBudget([issue], {}, 800)).toEqual([ + { path: ["address"], reason: "unknown_argument" }, + ]); + + expect( + projectInputIssuesWithinBudget( + [issue], + {}, + 800, + "read_function_instructions", + ), + ).toBeUndefined(); + + const guidance = knownArgumentMistakeGuidance( + "read_function_instructions", + "address", + ); + expect(guidance).toBeDefined(); + expect( + projectInputIssuesWithinBudget( + [issue], + {}, + 2000, + "read_function_instructions", + ), + ).toEqual([ + { + path: ["address"], + reason: "unknown_argument", + expected: "procedure", + message: guidance!.message, + }, + ]); + }); }); diff --git a/src/domain/inputIssueProjection.ts b/src/domain/inputIssueProjection.ts index 657b256e73..f7aed8b34e 100644 --- a/src/domain/inputIssueProjection.ts +++ b/src/domain/inputIssueProjection.ts @@ -3,6 +3,10 @@ import { AnalysisInputError, type AnalysisInputIssue, } from "./analysisErrorCore.js"; +import { knownArgumentMistakeGuidance } from "./inputArgumentGuidance.js"; + +const isRootArgument = (path: readonly unknown[]): boolean => + path.length === 0 || (path.length === 1 && path[0] === "input"); /** Build the standard application input error while retaining schema causes and extra semantic issues. */ export const analysisInputErrorFromIssues = ( @@ -23,7 +27,10 @@ export const analysisInputErrorFromIssues = ( ? {} : { userMessage: options.userMessage }), }, - [...projectInputIssues(issues, input), ...(options.additionalIssues ?? [])], + [ + ...projectInputIssues(issues, input, operation), + ...(options.additionalIssues ?? []), + ], ); /** Remove only the internal operation/input envelope from caller correction paths. */ @@ -36,7 +43,7 @@ export const analysisOperationInputErrorFromIssues = ( new AnalysisInputError( operation, { cause }, - projectInputIssues(issues, { operation, input }).map((issue) => + projectInputIssues(issues, { operation, input }, operation).map((issue) => issue.path[0] === "input" ? { ...issue, path: issue.path.slice(1) } : issue, @@ -47,8 +54,9 @@ export const analysisOperationInputErrorFromIssues = ( export const projectInputIssues = ( issues: readonly z.core.$ZodIssue[], input: unknown, + operation?: string, ): readonly AnalysisInputIssue[] => - issues.flatMap((issue) => projectIssue(issue, input)); + issues.flatMap((issue) => projectIssue(issue, input, undefined, operation)); const projectionBudgetExceeded = Symbol( "input issue projection budget exceeded", @@ -59,11 +67,13 @@ export const projectInputIssuesWithinBudget = ( issues: readonly z.core.$ZodIssue[], input: unknown, budgetBytes: number, + operation?: string, ): readonly AnalysisInputIssue[] | undefined => { return projectInputIssueGroupsWithinBudget( issues, input, budgetBytes, + operation, )?.flat(); }; @@ -128,11 +138,13 @@ export const projectInputIssueGroupsWithinBudget = ( issues: readonly z.core.$ZodIssue[], input: unknown, budgetBytes: number, + operation?: string, ): readonly (readonly AnalysisInputIssue[])[] | undefined => { const budget = { remaining: budgetBytes }; try { const groups: (readonly AnalysisInputIssue[])[] = []; - for (const issue of issues) groups.push(projectIssue(issue, input, budget)); + for (const issue of issues) + groups.push(projectIssue(issue, input, budget, operation)); return groups; } catch (error) { if (error === projectionBudgetExceeded) return undefined; @@ -143,6 +155,8 @@ export const projectInputIssueGroupsWithinBudget = ( const chargeProjection = ( issue: z.core.$ZodIssue, budget: { remaining: number }, + operation?: string, + input?: unknown, ): void => { // Six bytes per UTF-16 code unit covers JSON escaping. The fixed allowance // covers the projected object, property names, scalar values and delimiters. @@ -166,7 +180,19 @@ const chargeProjection = ( } if (issue.code === "invalid_key") charge(32 * issue.issues.length); if (issue.code === "unrecognized_keys") - for (const key of issue.keys) charge(256 + pathCost + 6 * key.length); + for (const key of issue.keys) { + charge(256 + pathCost + 6 * key.length); + if (isRootArgument(issue.path)) { + const guidance = knownArgumentMistakeGuidance(operation, key); + const expectedAlreadyPresent = + guidance !== undefined && + valueAtPath(input, [...issue.path, guidance.expected]) !== undefined; + if (guidance !== undefined && !expectedAlreadyPresent) + charge( + 6 * guidance.expected.length + 6 * guidance.message.length + 32, + ); + } + } if (issue.code === "invalid_value") for (const value of issue.values) charge(typeof value === "string" ? 6 * value.length + 32 : 32); @@ -186,12 +212,39 @@ const nestedIssue = ( return { ...child, path: [...parent.path, ...child.path] }; }; +const projectUnrecognizedKeyIssues = ( + keys: readonly string[], + path: readonly (string | number)[], + input: unknown, + operation?: string, +): readonly AnalysisInputIssue[] => + keys.map((key) => { + const guidance = isRootArgument(path) + ? knownArgumentMistakeGuidance(operation, key) + : undefined; + const expectedAlreadyPresent = + guidance !== undefined && + valueAtPath(input, [...path, guidance.expected]) !== undefined; + const effectiveGuidance = expectedAlreadyPresent ? undefined : guidance; + return { + path: [...path, key], + reason: "unknown_argument" as const, + ...(effectiveGuidance === undefined + ? {} + : { + expected: effectiveGuidance.expected, + message: effectiveGuidance.message, + }), + }; + }); + const projectIssue = ( issue: z.core.$ZodIssue, input: unknown, budget?: { remaining: number }, + operation?: string, ): readonly AnalysisInputIssue[] => { - if (budget !== undefined) chargeProjection(issue, budget); + if (budget !== undefined) chargeProjection(issue, budget, operation, input); const path = issue.path.flatMap((part) => typeof part === "string" || typeof part === "number" ? [part] : [], ); @@ -216,20 +269,27 @@ const projectIssue = ( return uniqueIssues( issue.errors.flatMap((branch) => branch.flatMap((branchIssue) => - projectIssue(nestedIssue(issue, branchIssue, budget), input, budget), + projectIssue( + nestedIssue(issue, branchIssue, budget), + input, + budget, + operation, + ), ), ), ); } if (issue.code === "invalid_key") return issue.issues.flatMap((keyIssue) => - projectIssue(nestedIssue(issue, keyIssue, budget), input, budget), + projectIssue( + nestedIssue(issue, keyIssue, budget), + input, + budget, + operation, + ), ); if (issue.code === "unrecognized_keys") - return issue.keys.map((key) => ({ - path: [...path, key], - reason: "unknown_argument" as const, - })); + return projectUnrecognizedKeyIssues(issue.keys, path, input, operation); if (issue.code === "invalid_type") return [ { @@ -279,10 +339,7 @@ const uniqueIssues = ( ...new Map(issues.map((issue) => [JSON.stringify(issue), issue])).values(), ]; -const valueAtPath = ( - input: unknown, - path: readonly (string | number)[], -): unknown => { +const valueAtPath = (input: unknown, path: readonly PropertyKey[]): unknown => { let current = input; for (const part of path) { if (typeof current !== "object" || current === null) return undefined; diff --git a/src/ida/IdaInput.ts b/src/ida/IdaInput.ts index 994d744c9a..832b80c9cc 100644 --- a/src/ida/IdaInput.ts +++ b/src/ida/IdaInput.ts @@ -3,7 +3,7 @@ import { AnalysisInputError } from "../domain/analysisErrorCore.js"; import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js"; import { err, ok } from "../domain/result.js"; import { idaAddressSchema } from "./IdaProtocolValues.js"; -import { projectInputIssues } from "../domain/inputIssueProjection.js"; +import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; /** Apply the named public contract and IDA's explicit-address boundary before startup. */ export const parseIdaInput = ( @@ -14,12 +14,11 @@ export const parseIdaInput = ( const parsed = contract?.inputSchema.safeParse(parameters); if (parsed?.success !== true) return err( - new AnalysisInputError( + analysisInputErrorFromIssues( operation, + parsed?.error.issues ?? [], + parameters, { cause: parsed?.error }, - parsed?.error === undefined - ? [] - : projectInputIssues(parsed.error.issues, parameters), ), ); if (operation === "xrefs" && typeof parameters.address !== "string") diff --git a/src/server/EvidenceMcpServer.test.ts b/src/server/EvidenceMcpServer.test.ts index ae8f130946..eaae922d1d 100644 --- a/src/server/EvidenceMcpServer.test.ts +++ b/src/server/EvidenceMcpServer.test.ts @@ -13,6 +13,8 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio"; import { z } from "zod"; import { InvestigationRecords } from "../application/investigation/InvestigationRecords.js"; +import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/officialToolContracts.js"; +import { knownArgumentMistakeGuidance } from "../domain/inputArgumentGuidance.js"; import { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import { forgetPendingMcpRequest } from "./EvidenceMcpServerCore.js"; import { createToolResultDelivery } from "./toolResult.js"; @@ -368,3 +370,152 @@ describe("SDK validation error delivery", () => { }, ); }); + +const createGuidanceClientServer = async ( + toolName: string, + inputSchema: z.ZodTypeAny, +) => { + const delivery = createToolResultDelivery(4096); + const records = new InvestigationRecords(); + const server = new EvidenceMcpServer( + { name: "sdk-argument-guidance-test", version: "1" }, + { capabilities: {} }, + (evidence) => records.recordEvidence(evidence), + delivery, + ); + server.registerTool(toolName, { inputSchema }, async () => ({ + content: [{ type: "text", text: "valid call" }], + })); + const client = new Client( + { name: "sdk-argument-guidance-client", version: "1" }, + { versionNegotiation: { mode: { pin: "2026-07-28" } } }, + ); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const serving = serveStdio(() => server, { transport: serverTransport }); + await client.connect(clientTransport); + return { + client, + close: () => Promise.all([client.close(), serving.close()]), + }; +}; + +const officialContract = ( + name: (typeof OFFICIAL_TOOL_CONTRACTS)[number]["name"], +) => { + const contract = OFFICIAL_TOOL_CONTRACTS.find((c) => c.name === name); + if (contract === undefined) + throw new Error(`Missing official contract: ${name}`); + return contract; +}; + +describe("SDK argument name guidance", () => { + it("guides procedure_pseudo_code caller from address to procedure without duplicating diagnostic", async () => { + const guidance = knownArgumentMistakeGuidance( + "procedure_pseudo_code", + "address", + ); + expect(guidance).toBeDefined(); + const session = await createGuidanceClientServer( + "procedure_pseudo_code", + officialContract("procedure_pseudo_code").inputSchema, + ); + try { + const result = await session.client.callTool({ + name: "procedure_pseudo_code", + arguments: { address: "0x401000" }, + }); + expect(result.isError).toBe(true); + const payload = JSON.parse(firstText(result) ?? "null"); + expect(payload.error.code).toBe("invalid_request"); + expect(payload.error.details.issues).toContainEqual( + expect.objectContaining({ + path: ["address"], + reason: "unknown_argument", + expected: "procedure", + message: guidance!.message, + }), + ); + expect( + payload.error.remediation.action.startsWith(guidance!.remediation), + ).toBe(true); + expect(payload.error.message).not.toContain("address"); + } finally { + await session.close(); + } + }); + + it("guides search_strings caller from query to pattern", async () => { + const guidance = knownArgumentMistakeGuidance("search_strings", "query"); + expect(guidance).toBeDefined(); + const session = await createGuidanceClientServer( + "search_strings", + officialContract("search_strings").inputSchema, + ); + try { + const result = await session.client.callTool({ + name: "search_strings", + arguments: { query: "admin" }, + }); + expect(result.isError).toBe(true); + const payload = JSON.parse(firstText(result) ?? "null"); + expect(payload.error.code).toBe("invalid_request"); + expect(payload.error.details.issues).toContainEqual( + expect.objectContaining({ + path: ["query"], + reason: "unknown_argument", + expected: "pattern", + message: guidance!.message, + }), + ); + expect( + payload.error.remediation.action.startsWith(guidance!.remediation), + ).toBe(true); + } finally { + await session.close(); + } + }); + + it("rejects address with procedure guidance and retains unexplained key count in diagnostics", async () => { + const guidance = knownArgumentMistakeGuidance( + "read_function_instructions", + "address", + ); + expect(guidance).toBeDefined(); + const session = await createGuidanceClientServer( + "read_function_instructions", + officialContract("read_function_instructions").inputSchema, + ); + try { + const result = await session.client.callTool({ + name: "read_function_instructions", + arguments: { address: "0x401000", count: 10 }, + }); + expect(result.isError).toBe(true); + const payload = JSON.parse(firstText(result) ?? "null"); + expect(payload.error.code).toBe("invalid_request"); + expect(payload.error.details.issues).toContainEqual( + expect.objectContaining({ + path: ["address"], + reason: "unknown_argument", + expected: "procedure", + message: guidance!.message, + }), + ); + expect(payload.error.details.issues).toContainEqual( + expect.objectContaining({ + path: ["count"], + reason: "unknown_argument", + }), + ); + const countIssue = payload.error.details.issues.find( + (issue: { path: string[] }) => issue.path?.[0] === "count", + ); + expect(countIssue?.expected).toBeUndefined(); + expect(countIssue?.message).toBeUndefined(); + expect(payload.error.message).toContain("count"); + } finally { + await session.close(); + } + }); +}); diff --git a/src/server/EvidenceMcpServerCore.ts b/src/server/EvidenceMcpServerCore.ts index 2d451eb78b..40bacd7c6b 100644 --- a/src/server/EvidenceMcpServerCore.ts +++ b/src/server/EvidenceMcpServerCore.ts @@ -194,7 +194,11 @@ const partitionSdkDiagnostic = ( ); } else { for (const issue of group) mapped.push(issue); - remaining.push(diagnostic.slice(start, offset)); + const explainedByIssues = + raw.code === "unrecognized_keys" && + group.length > 0 && + group.every((item) => item.message !== undefined); + if (!explainedByIssues) remaining.push(diagnostic.slice(start, offset)); } } if (offset !== diagnostic.length) return undefined; @@ -1048,6 +1052,7 @@ class ErrorEvidenceTransport implements Transport { validation.issues, validation.input, this.delivery.resultBudgetBytes, + tool, ); const projectionLimited = validation !== undefined && groups === undefined; const summary = `The arguments for MCP tool ${tool} do not match its advertised input schema.`; From 81f9f71f6d7d564456381bf7c7d278f0335ea31b Mon Sep 17 00:00:00 2001 From: Entrpi <133157614+Entrpi@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:48:59 +1100 Subject: [PATCH 063/151] feat(rizin): select the decoder and map address for one-shot commands (#8991) * feat(rizin): select the decoder and map address for one-shot commands execute_rizin_command and rea execute-rizin-command accept optional asm_arch, asm_bits, asm_cpu and base_address (CLI --asm-arch, --asm-bits, --asm-cpu and --base-address). REA passes them to Rizin before the artifact path as -a, -b, -e asm.cpu= and -m. Rizin 0.9.1 refuses -e asm.arch= and -e asm.bits= at startup, while -a and -b work in 0.7.4 and 0.9.1. Rizin applies -e settings after -a and -b. base_address is the address of file offset 0, as in REA's raw-binary inputs. REA checks only the spelling, because Rizin owns the arch and CPU vocabulary and its builds differ. The Homebrew build of Rizin 0.9.1 has no xtensa or riscv plugin; the official static Linux build of 0.7.4 has both. Evidence records each input and the complete argument list. When any input is set, Evidence also states that REA does not confirm the decoder or map that Rizin used: Rizin uses another value for one it cannot apply and exits 0. Rizin 0.9.1 keeps its default and reports the value on stderr, and Rizin 0.7.4 turned -b 8 for arm into 16 without a message. Calls without these inputs keep their argument list and Evidence. The real-provider gate tests/boundary/providers/RizinDecoderInputs.test.ts checks the effect of each flag through MCP with Rizin's own e asm.*, omlj and byte reads. It skips when no Rizin is on PATH. It passes with Rizin 0.9.1 (Homebrew, macOS arm64) and 0.7.4 (official static build, Linux x86_64). * docs(rizin): map multi-segment ESP32 images with om An ESP32-family app or bootloader image holds several segments, each loaded at its own address, so one base_address cannot map it. The recipe removes the default map with om-* and adds one om map per segment, using the segment offsets from the image format or from esptool image-info. It does not depend on an ESP image reader in REA. The offsets were checked against esptool 4.12.0 image_info on a synthetic two-segment ESP32 image. Rizin 0.9.1 and 0.7.4 mapped its segments at their load addresses. Rizin 0.7.4, whose static Linux build has the xtensa plugin, decoded the IRAM segment as nop.n, nop.n, ret.n and nop.n. * fix(rizin): read the file at base_address when a loader would claim it On Rizin 0.9.1, base_address could read 0xff at the mapped address with exit 0. -m maps the file, but Rizin still runs its format loaders, and a loose one can claim a raw image. Rizin 0.9.1's AVR loader matches when bytes 1 and 3 look like rjmp and turns io.va off, so reads use file offsets. The BIOS loader, in 0.7.4 and 0.9.1, matches a 64 KiB file with a jmp 16 bytes from its end and maps it at another address. An acceptance run on a raw ESP32-S3 segment hit the first case. With base_address, REA now passes -n together with -m. Rizin then skips the loaders and maps the whole file at the address: rizin.c calls rz_io_map_new at the -m address and leaves io.va at its default. The input therefore means a raw image, and Rizin loads no sections, symbols or entry point, even from an ELF file. Evidence states this in its own limitation, and the decoder limitation no longer covers the map. The real-provider gate adds an AVR-like and a BIOS-like image and reads below the base, at it, at the end of the image and past it. Without -n it fails on Rizin 0.9.1. It passes with Rizin 0.9.1 (Homebrew, macOS arm64) and 0.7.4 (official static build, Linux x86_64). The ESP om recipe now starts with e io.va=true, because reads use the maps only while io.va is on. * fix(rizin): advertise name patterns that compile in v mode The asm_arch and asm_cpu pattern ended its character class with an unescaped '-', which JavaScript's v (unicodeSets) mode rejects, so clients compiling advertised patterns in that mode could not use execute_rizin_command's schema. Escaping it keeps the same names valid in every mode, as main's own patterns write it. * fix(rizin): state what the decoder and map inputs leave unconfirmed The base_address limitation reported a map REA never observes and left out that -n keeps Rizin's default arch, bits and byte order. The asm_* limitation implied Rizin replaces a bad CPU, but e asm.cpu echoes any value. Compact schemas drop property descriptions, so the tool description now carries the raw-loading warning. The real-provider gate checks that asm_cpu changes a decode, and a base_address-only call. * docs(rizin): record the gate's full run on Rizin 0.7.4 * test(rizin): follow the test guide in the decoder input tests Focused tests resolve execute_rizin_command from its contract family, the harness owns the temporary directory before setup can fail, and guidance is compared with its owner, not with sentences from it. --- docs/reverse-engineering-tools.md | 114 ++++++++ docs/testing-lanes.md | 15 + scripts/ci/scopes.mjs | 1 + .../reverse/ReverseEngineeringCommandPort.ts | 2 + .../reverse/ReverseEngineeringService.ts | 1 + src/cli/reverseEngineeringCommands.ts | 17 ++ src/composition/reverseEngineering.ts | 6 +- .../reverseEngineeringToolContracts.ts | 2 +- src/domain/rawBinary.ts | 3 +- src/domain/reverseEngineering.ts | 39 +++ src/rizin/RizinCommand.ts | 27 ++ .../cli/reverseEngineeringTargetInput.test.ts | 69 +++++ .../reverseEngineeringEnvironmentMcp.test.ts | 93 +++++++ .../providers/RizinDecoderInputs.test.ts | 259 ++++++++++++++++++ tests/composition/reverseEngineering.test.ts | 148 ++++++++++ tsconfig.reverse-tools-mutation.json | 1 + vitest.reverse-tools.config.ts | 1 + 17 files changed, 795 insertions(+), 3 deletions(-) create mode 100644 tests/boundary/providers/RizinDecoderInputs.test.ts diff --git a/docs/reverse-engineering-tools.md b/docs/reverse-engineering-tools.md index 6136a5f9f3..3fb605acc9 100644 --- a/docs/reverse-engineering-tools.md +++ b/docs/reverse-engineering-tools.md @@ -124,6 +124,120 @@ rea execute-rizin-command ./program iI Captured stdout and stderr are retained up to a 16 MiB combined limit; `output_truncated` marks output beyond that limit. One-shot commands use the `command_timeout_ms` budget described above. That 16 MiB cap has no runtime override. +### Decoder and map inputs + +Four optional inputs select how Rizin decodes the artifact and where it maps +it. REA passes each one to Rizin before the artifact path: + +| Input | CLI option | Rizin argument | Example | +| -------------- | ---------------- | -------------- | -------------------- | +| `asm_arch` | `--asm-arch` | `-a` | `arm`, `x86`, `6502` | +| `asm_bits` | `--asm-bits` | `-b` | `16` for Thumb | +| `asm_cpu` | `--asm-cpu` | `-e asm.cpu=` | `cortexm` | +| `base_address` | `--base-address` | `-n -m` | `0x08000000` | + +Rizin 0.9.1 refuses `-e asm.arch=` and `-e asm.bits=` at startup, and Rizin +0.7.4 accepts them, so REA uses `-a` and `-b`, which both accept. Rizin applies +`-e` settings after `-a` and `-b`, so `asm_cpu` applies to the selected arch. + +`base_address` is the address of file offset 0, as in REA's raw-binary inputs. +REA passes it as `-m` together with `-n`, so Rizin maps the whole file there as +a raw image and loads no format information, such as sections, symbols or an +entry point, even from an ELF file. Without `-n`, a Rizin format loader can +claim a raw image and give wrong reads with no error. In Rizin 0.9.1 the AVR +loader turns `io.va` off, so every read at the mapped address returns `0xff`. +In Rizin 0.7.4 and 0.9.1 the BIOS loader maps the file at another address. +A loader can also claim a raw image that you open with only `asm_*` inputs, so +pass `base_address`, `0x0` if the image has no known address. Evidence records +each input and the complete argument list. + +With `-n`, Rizin has no headers to read, so arch, bits and byte order stay at +its defaults unless the call selects them. A Thumb image opened with only +`base_address` decodes as plausible AArch64 on an arm64 host. Pass `asm_arch` +and `asm_bits` with `base_address`, and for a big-endian image start the command +with `e cfg.bigendian=true`. + +REA checks only the spelling. Arch and CPU names contain letters, digits, `_`, +`.`, `+` and `-`, and start with a letter or a digit. `asm_bits` is an integer +from 1 to 64. `base_address` is `0x` followed by 1 to 16 hexadecimal digits. +Rizin owns the names, and its builds differ: for example, the Homebrew build of +Rizin 0.9.1 has no `xtensa` or `riscv` plugin, while the official static Linux +build of Rizin 0.7.4 has both. The commands `e asm.arch=?` and `e asm.cpu=?` +list what the installed Rizin supports. To use another build, set +`REA_RIZIN_COMMAND`. When Rizin cannot apply an arch or bits value, it uses +another value and exits with status 0. Rizin 0.9.1 keeps its default and +reports the value on stderr. Rizin 0.7.4 reports an unknown arch on stderr, but +for `arm` it used 16 bits instead of 8 without a message. Both accept any +`asm.cpu` value, and `e asm.cpu` then echoes it, so compare it with +`e asm.cpu=?`, which lists the CPUs of the selected arch. Evidence therefore +states that REA does not confirm the decoder that Rizin used. To check the +decoder and the map, start the command with +`e asm.arch; e asm.bits; e asm.cpu; e cfg.bigendian; oml`. + +This call reads a Cortex-M flash dump at its address, in Thumb state: + +```sh +rea execute-rizin-command ./flash.bin "e asm.arch; e asm.bits; oml; pxw 8 @ 0x08000000" --asm-arch arm --asm-bits 16 --asm-cpu cortexm --base-address 0x08000000 +``` + +Through MCP, the same call is: + +```json +{ + "path": "/firmware/flash.bin", + "command": "e asm.arch; e asm.bits; oml; pxw 8 @ 0x08000000", + "asm_arch": "arm", + "asm_bits": 16, + "asm_cpu": "cortexm", + "base_address": "0x08000000" +} +``` + +`pxw 8` prints the initial stack pointer and the Reset vector. The Reset vector +is a Thumb address with its low bit set. Disassemble the handler at the even +address, for example `pd 16 @ 0x08000108` for a Reset vector of `0x08000109`. + +### Multi-segment ESP images + +An app or bootloader image of the ESP32 family (the ESP32, ESP32-S, ESP32-C, +ESP32-H and ESP32-P series) starts with the magic byte `0xE9` and holds several +segments. ESP8266 images use a different header, and this recipe does not +cover them. Each segment loads at its own address, so one `base_address` cannot +map the image. Instead, remove the default map and add one `om` map for each +segment, in the same command: + +1. Find the load address and the length of each segment, and the file offset + of its data. The image header is 24 bytes. Each segment starts with an + 8-byte header that holds the load address and the data length, both 32-bit + little-endian, and its data follows. So the data of the first segment + starts at file offset `0x20`, and each later segment header follows the + data of the segment before it. `esptool image-info` lists the same + segments; esptool v4 spells it `image_info --version 2`. Its `File offs` + column is the offset of the 8-byte segment header, so the data starts 8 + bytes later. A padding segment carries no code or data, and you can skip + it. +2. Start the command with `e io.va=true`. Reads use the maps only while + `io.va` is on, and a Rizin format loader can turn it off. +3. Rizin opens the image as file descriptor 3, which `ol` shows. For each + segment, add this map, with `r-x` for code and `r--` for data: + `om 3 `. A map + cannot have more permissions than the opened file, which is read-only. +4. Select the decoder with `asm_arch` and set `asm_bits` to 32. ESP32, ESP32-S2 + and ESP32-S3 use Xtensa. The ESP32-C and ESP32-H series and ESP32-P4 use + RISC-V. Use the plugin name that `e asm.arch=?` lists for the installed + Rizin. + +For example, for an image with an IRAM segment of 8 bytes at `0x40080000` and +a DRAM segment of 16 bytes at `0x3ffb0000`: + +```sh +rea execute-rizin-command ./app.bin "e io.va=true; om-*; om 3 0x40080000 0x8 0x20 r-x iram0; om 3 0x3ffb0000 0x10 0x30 r-- dram0; oml; pd 4 @ 0x40080000" --asm-arch xtensa --asm-bits 32 +``` + +`om-*` removes the maps that Rizin made when it opened the file, such as the +default map of the whole file at address 0. The entry address is the 32-bit +little-endian value at file offset 4. + ## Cutter bridge Cutter commands use the local REA Python bridge and authenticated loopback messages. diff --git a/docs/testing-lanes.md b/docs/testing-lanes.md index 17985ebddc..63b600c2a4 100644 --- a/docs/testing-lanes.md +++ b/docs/testing-lanes.md @@ -15,6 +15,21 @@ typecheck selections; its real-provider assertions skip when Rizin 0.7.4 is unavailable. Run the focused command above on a host with that provider for the explicit real-provider verification gate. +The decoder and map inputs of `execute_rizin_command` have their own +real-provider gate. Run +`npm run test:focused -- tests/boundary/providers/RizinDecoderInputs.test.ts`. +It calls the tool through MCP with the Rizin on `PATH` and checks the effect of +`-a`, `-b`, `-e asm.cpu=` and `-n -m` on raw images with Rizin's own `e asm.*` +and `omlj` output, and that `asm_cpu` changes how an M-profile instruction +decodes. It also reads bytes below the base address, at it, at the end of the +image and past it, and checks a call with only `base_address`. Two of the images are ones that Rizin's AVR and +BIOS loaders claim without `-n`. It also selects an arch that no host uses by +default, an arch plugin that Rizin does not have, and a bits value that the +arch lacks. The test names the Rizin version it ran. It skips when no Rizin is +on `PATH`; to check another build, put its directory first on `PATH`. It has +passed with Rizin 0.9.1 (Homebrew, macOS arm64) and Rizin 0.7.4 (official static +build, Linux x86_64). + Each real-toolchain command must require only the host tools needed to prove its stated claim. Use a host-native fixture for host/provider acceptance, and place optional cross-target formats or platform-specific runners in separate diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index 03bcf518d3..c50f0c19d2 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -102,6 +102,7 @@ const PATH_SCOPES = { "tests/boundary/providers/RizinTimeoutEvidence.test.ts", "tests/boundary/providers/RizinDebugSession*.test.ts", "tests/boundary/providers/RizinInferiorTty.test.ts", + "tests/boundary/providers/RizinDecoderInputs.test.ts", "tests/boundary/providers/RizinTestInferiorTty.ts", "tests/boundary/process/ownedDebuggerSession.test.ts", "tests/boundary/cli/debuggerOneShotExit.test.ts", diff --git a/src/application/reverse/ReverseEngineeringCommandPort.ts b/src/application/reverse/ReverseEngineeringCommandPort.ts index 92a014e83f..7228526c86 100644 --- a/src/application/reverse/ReverseEngineeringCommandPort.ts +++ b/src/application/reverse/ReverseEngineeringCommandPort.ts @@ -9,6 +9,8 @@ export interface ReverseEngineeringProviderCommand { readonly executable: string; readonly arguments: readonly string[]; readonly identity: EvidenceProvider; + /** What the provider cannot attest about the arguments it built. */ + readonly limitations?: readonly string[]; readonly environmentOverrides?: Readonly; } diff --git a/src/application/reverse/ReverseEngineeringService.ts b/src/application/reverse/ReverseEngineeringService.ts index f56c362e15..1185f028cc 100644 --- a/src/application/reverse/ReverseEngineeringService.ts +++ b/src/application/reverse/ReverseEngineeringService.ts @@ -736,6 +736,7 @@ export class ReverseEngineeringService { "Rizin commands and retained plugins may read files or access network resources outside the selected artifact; those external inputs are not bound to Evidence.", ] : []), + ...(profile.limitations ?? []), ...(stdoutInvalidUtf8 || stderrInvalidUtf8 ? [ `Captured ${[ diff --git a/src/cli/reverseEngineeringCommands.ts b/src/cli/reverseEngineeringCommands.ts index 43a2fe81b6..eaa2470586 100644 --- a/src/cli/reverseEngineeringCommands.ts +++ b/src/cli/reverseEngineeringCommands.ts @@ -17,6 +17,7 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { OBJDUMP_ARTIFACT_OPERATIONS, reverseCommandTimeoutSchema, + rizinInputSchema, } from "../domain/reverseEngineering.js"; import { logCliCommand } from "../cliLogging.js"; import type { CliInstance } from "./types.js"; @@ -309,6 +310,10 @@ export const registerReverseEngineeringCommands = ( command: z.string().min(1).describe("One caller-selected Rizin command."), }), options: z.object({ + asmArch: rizinInputSchema.shape.asm_arch, + asmBits: rizinInputSchema.shape.asm_bits, + asmCpu: rizinInputSchema.shape.asm_cpu, + baseAddress: rizinInputSchema.shape.base_address, commandTimeoutMs: reverseCommandTimeoutSchema, }), run: ({ args, options }) => @@ -318,6 +323,18 @@ export const registerReverseEngineeringCommands = ( service.executeRizinCommand( { ...args, + ...(options.asmArch === undefined + ? {} + : { asm_arch: options.asmArch }), + ...(options.asmBits === undefined + ? {} + : { asm_bits: options.asmBits }), + ...(options.asmCpu === undefined + ? {} + : { asm_cpu: options.asmCpu }), + ...(options.baseAddress === undefined + ? {} + : { base_address: options.baseAddress }), ...(options.commandTimeoutMs === undefined ? {} : { command_timeout_ms: options.commandTimeoutMs }), diff --git a/src/composition/reverseEngineering.ts b/src/composition/reverseEngineering.ts index e41561c8cb..6394dc1550 100644 --- a/src/composition/reverseEngineering.ts +++ b/src/composition/reverseEngineering.ts @@ -3,7 +3,10 @@ import type { ReverseEngineeringCommandPort } from "../application/reverse/Rever import { ReverseEngineeringService } from "../application/reverse/ReverseEngineeringService.js"; import { objdumpCommand } from "../objdump/ObjdumpCommand.js"; import { OBJDUMP_PROVIDER_IDENTITY } from "../objdump/ObjdumpProviderIdentity.js"; -import { rizinCommand } from "../rizin/RizinCommand.js"; +import { + rizinCommand, + rizinCommandLimitations, +} from "../rizin/RizinCommand.js"; import { RIZIN_PROVIDER_IDENTITY } from "../rizin/RizinProviderIdentity.js"; import { resolveWindowsProviderExecutable } from "./resolveWindowsProviderExecutable.js"; @@ -28,6 +31,7 @@ export const createReverseEngineeringCommandPort = ( platform, ), arguments: rizinCommand(input), + limitations: rizinCommandLimitations(input), identity: RIZIN_PROVIDER_IDENTITY, }), }); diff --git a/src/contracts/reverseEngineeringToolContracts.ts b/src/contracts/reverseEngineeringToolContracts.ts index d2aacf238b..b0098803e1 100644 --- a/src/contracts/reverseEngineeringToolContracts.ts +++ b/src/contracts/reverseEngineeringToolContracts.ts @@ -39,7 +39,7 @@ export const REVERSE_ENGINEERING_TOOL_CONTRACTS = [ ...toolContractMetadata("execute_rizin_command"), kind: "native-provider", description: - "Execute one caller-selected Rizin command against a local artifact using the Rizin CLI profile that suppresses user settings and scripts while retaining plugins. Preserves decoded stdout and stderr inline with SHA-256 Evidence, up to a shared 16 MiB combined limit; invalid UTF-8 bytes are represented by replacement characters and flagged in Evidence limitations. output_truncated reports output beyond that limit and completion_status is unknown if buffer enforcement terminated the process. Rizin commands and retained plugins can execute foreign code, mutate files, access the network, and read files outside the selected artifact; identities and contents of those external inputs are not bound to Evidence. Command effects depend on the Rizin command and loaded plugins; JSON is command-specific and raw output remains authoritative. In a shared MCP server, this admitted command closes any active binary-analysis session; after its effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first. Optional command_timeout_ms is the per-phase wall clock in milliseconds (default 120000, range 1..2147483647); omitted means 120000 and the value applies on the next call. It does not raise the 16 MiB output cap or a Rizin debugger session deadline.", + "Execute one caller-selected Rizin command against a local artifact using the Rizin CLI profile that suppresses user settings and scripts while retaining plugins. Preserves decoded stdout and stderr inline with SHA-256 Evidence, up to a shared 16 MiB combined limit; invalid UTF-8 bytes are represented by replacement characters and flagged in Evidence limitations. output_truncated reports output beyond that limit and completion_status is unknown if buffer enforcement terminated the process. Rizin commands and retained plugins can execute foreign code, mutate files, access the network, and read files outside the selected artifact; identities and contents of those external inputs are not bound to Evidence. Command effects depend on the Rizin command and loaded plugins; JSON is command-specific and raw output remains authoritative. For a raw image, asm_arch, asm_bits and asm_cpu select the decoder and base_address maps file offset 0 there; base_address also passes -n, which skips format loaders even for an ELF, and REA confirms neither. In a shared MCP server, this admitted command closes any active binary-analysis session; after its effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first. Optional command_timeout_ms is the per-phase wall clock in milliseconds (default 120000, range 1..2147483647); omitted means 120000 and the value applies on the next call. It does not raise the 16 MiB output cap or a Rizin debugger session deadline.", inputSchema: rizinInputSchema, outputSchema: evidenceResultOf(commandObservationSchema), examples: [ diff --git a/src/domain/rawBinary.ts b/src/domain/rawBinary.ts index deb9f81069..8a23541747 100644 --- a/src/domain/rawBinary.ts +++ b/src/domain/rawBinary.ts @@ -21,7 +21,8 @@ export const BINARY_ARCHITECTURES = [ /** Provider-neutral CPU family for a caller-supplied raw-binary load. */ export const binaryArchitectureSchema = z.enum(BINARY_ARCHITECTURES); -const RAW_BINARY_ENTRY_POINT = /^0x[0-9A-Fa-f]{1,16}$/u; +/** Caller-supplied hexadecimal address of at most 64 bits, such as 0x08000000. */ +export const RAW_BINARY_ENTRY_POINT = /^0x[0-9A-Fa-f]{1,16}$/u; /** Hexadecimal load and entry address; file offset 0 is mapped here. */ export const rawBinaryEntryPointSchema = z diff --git a/src/domain/reverseEngineering.ts b/src/domain/reverseEngineering.ts index 03601ae2c9..55926af60b 100644 --- a/src/domain/reverseEngineering.ts +++ b/src/domain/reverseEngineering.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { commandEvidenceDetailSchema } from "./commandObservation.js"; import { localPathStringSchema } from "./localPath.js"; +import { RAW_BINARY_ENTRY_POINT } from "./rawBinary.js"; /** Omitted one-shot objdump/rizin wall clock. Preflight shares it; the command gets it again. */ export const DEFAULT_REVERSE_COMMAND_TIMEOUT_MS = 120_000; @@ -72,6 +73,15 @@ export const objdumpInputSchema = z.discriminatedUnion("operation", [ }), ]); +// Rizin owns its arch and CPU vocabulary, and the plugins differ between +// builds, so REA checks only the spelling. Rizin sets no length limit. +const rizinNameSchema = z + .string() + .regex( + /^[A-Za-z0-9][A-Za-z0-9_.+\-]*$/u, + "must be a Rizin name: letters, digits, '_', '.', '+' or '-', starting with a letter or digit", + ); + /** Caller-selected Rizin command against one local artifact. */ export const rizinInputSchema = z .object({ @@ -83,6 +93,35 @@ export const rizinInputSchema = z (value) => !/[\r\n\u0000]/u.test(value), "A single command line is required", ), + asm_arch: rizinNameSchema + .exactOptional() + .describe( + "Rizin arch plugin, passed as -a, such as arm. Plugins differ between builds; e asm.arch=? lists them.", + ), + // Rizin's widest decoders are 64-bit. + asm_bits: z + .number() + .int() + .min(1) + .max(64) + .exactOptional() + .describe( + "Rizin asm.bits, passed as -b, such as 16 for Thumb. Omitted, Rizin's default applies.", + ), + asm_cpu: rizinNameSchema + .exactOptional() + .describe( + "Rizin asm.cpu, passed as -e asm.cpu=, such as cortexm; e asm.cpu=? lists those of the selected arch.", + ), + base_address: z + .string() + .regex(RAW_BINARY_ENTRY_POINT, { + message: "base_address must be a hexadecimal address of the form 0x…", + }) + .exactOptional() + .describe( + "Address of file offset 0. REA passes -n and -m, so Rizin maps the file there as a raw image, reads no format headers, and keeps its default decoder unless asm_arch and asm_bits select one.", + ), ...commandTimeoutMsField, }) .strict(); diff --git a/src/rizin/RizinCommand.ts b/src/rizin/RizinCommand.ts index c571a29c2f..7ed8566054 100644 --- a/src/rizin/RizinCommand.ts +++ b/src/rizin/RizinCommand.ts @@ -7,7 +7,34 @@ export const rizinCommand = (input: RizinInput): readonly string[] => [ "-q", "-e", "scr.color=0", + // Rizin 0.9.1 refuses -e asm.arch= and -e asm.bits= at startup. Once the + // file is open it applies -a and -b, then the -e settings. + ...(input.asm_arch === undefined ? [] : ["-a", input.asm_arch]), + ...(input.asm_bits === undefined ? [] : ["-b", String(input.asm_bits)]), + ...(input.asm_cpu === undefined ? [] : ["-e", `asm.cpu=${input.asm_cpu}`]), + // A format loader can claim a raw image. Rizin 0.9.1's AVR loader turns + // io.va off, so reads at the -m map return 0xff, and the BIOS loader moves + // the map. -n skips the loaders, and Rizin maps file offset 0 at the base. + ...(input.base_address === undefined ? [] : ["-n", "-m", input.base_address]), "-c", input.command, absoluteCallerPath(input.path), ]; + +/** What REA cannot attest about the decoder and map inputs it passed to Rizin. */ +export const rizinCommandLimitations = ( + input: RizinInput, +): readonly string[] => [ + ...(input.asm_arch === undefined && + input.asm_bits === undefined && + input.asm_cpu === undefined + ? [] + : [ + "REA does not confirm that Rizin applied the selected asm_* values. Rizin uses another arch or bits value when it cannot apply one, at most reporting it on stderr, and e asm.cpu echoes any value, so compare it with e asm.cpu=?.", + ]), + ...(input.base_address === undefined + ? [] + : [ + "With base_address, REA passed -n and -m so that Rizin maps file offset 0 at that address as a raw image; REA does not confirm the map, which oml shows. -n skips Rizin's format loaders, so expect no sections, symbols or entry point, and arch, bits and byte order stay at Rizin's defaults unless the call selects them.", + ]), +]; diff --git a/tests/boundary/cli/reverseEngineeringTargetInput.test.ts b/tests/boundary/cli/reverseEngineeringTargetInput.test.ts index 5a65b2247a..edb2c5abb8 100644 --- a/tests/boundary/cli/reverseEngineeringTargetInput.test.ts +++ b/tests/boundary/cli/reverseEngineeringTargetInput.test.ts @@ -1,8 +1,10 @@ +import { chmod, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; import { analysisErrorProjectionSchema } from "../../../src/contracts/errorSchemas.js"; +import { REVERSE_ENGINEERING_TOOL_CONTRACTS } from "../../../src/contracts/reverseEngineeringToolContracts.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; @@ -51,6 +53,73 @@ cliTest( }, ); +cliTest( + "passes Rizin decoder and map flags before the artifact", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-rizin-load-flags-"); + const provider = join(root, "argv-rizin"); + const artifact = join(root, "flash.bin"); + await writeFile(artifact, "flash"); + await writeFile( + provider, + `#!${process.execPath}\nprocess.stdout.write(JSON.stringify(process.argv.slice(2)));\n`, + ); + await chmod(provider, 0o755); + const result = await cli.run({ + arguments: [ + "execute-rizin-command", + artifact, + "oml", + "--asm-arch", + "riscv", + "--asm-bits", + "32", + "--asm-cpu", + "rv32imc", + "--base-address", + "0x42000000", + "--format", + "json", + ], + cwd: root, + environment: { REA_RIZIN_COMMAND: provider }, + }); + expect(result.exitCode, result.stderr).toBe(0); + const rizinContract = REVERSE_ENGINEERING_TOOL_CONTRACTS.find( + (contract) => contract.name === "execute_rizin_command", + ); + if (rizinContract === undefined) + throw new Error("execute_rizin_command contract is missing"); + const evidence = rizinContract.outputSchema.parse(result.json); + const argv = [ + "-N", + "-q", + "-e", + "scr.color=0", + "-a", + "riscv", + "-b", + "32", + "-e", + "asm.cpu=rv32imc", + "-n", + "-m", + "0x42000000", + "-c", + "oml", + artifact, + ]; + expect(JSON.parse(evidence.normalized_result.stdout)).toEqual(argv); + expect(evidence.parameters).toMatchObject({ + asm_arch: "riscv", + asm_bits: 32, + asm_cpu: "rv32imc", + base_address: "0x42000000", + command_arguments: argv, + }); + }, +); + cliTest( "reports the rejected Rizin command before inspecting the target", async ({ cli }) => { diff --git a/tests/boundary/mcp/reverseEngineeringEnvironmentMcp.test.ts b/tests/boundary/mcp/reverseEngineeringEnvironmentMcp.test.ts index ff47db600f..1742f56562 100644 --- a/tests/boundary/mcp/reverseEngineeringEnvironmentMcp.test.ts +++ b/tests/boundary/mcp/reverseEngineeringEnvironmentMcp.test.ts @@ -3,8 +3,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Client } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/server"; import { expect, it } from "vitest"; +import { REVERSE_ENGINEERING_TOOL_CONTRACTS } from "../../../src/contracts/reverseEngineeringToolContracts.js"; +import { rizinCommandLimitations } from "../../../src/rizin/RizinCommand.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { createProviderEnvironmentMcpHarness } from "../../support/reverseDebuggerMcpTestHelpers.js"; const writeProvider = async (path: string, output: string): Promise => { @@ -119,3 +123,92 @@ it("snapshots an explicit provider environment when the server is created", asyn '"captured"', ); }); + +it("passes Rizin decoder and map inputs as flags before the artifact", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-rizin-load-mcp-")); + const provider = join(directory, "argv-rizin"); + const artifact = join(directory, "flash.bin"); + await writeFile(artifact, "flash"); + await writeFile( + provider, + `#!${process.execPath}\nprocess.stdout.write(JSON.stringify(process.argv.slice(2)));\n`, + ); + await chmod(provider, 0o755); + const { client } = await connectProviderClient({ + directory, + clientName: "reverse-rizin-load-inputs", + configuredProvider: provider, + }); + const input = { + path: artifact, + command: "e asm.arch; e asm.bits; e asm.cpu; oml", + asm_arch: "arm", + asm_bits: 16, + asm_cpu: "cortexm", + base_address: "0x08000000", + }; + + const response = await client.callTool({ + name: "execute_rizin_command", + arguments: input, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + const rizinContract = REVERSE_ENGINEERING_TOOL_CONTRACTS.find( + (contract) => contract.name === "execute_rizin_command", + ); + if (rizinContract === undefined) + throw new Error("execute_rizin_command contract is missing"); + const evidence = rizinContract.outputSchema.parse(response.structuredContent); + const argv = [ + "-N", + "-q", + "-e", + "scr.color=0", + "-a", + "arm", + "-b", + "16", + "-e", + "asm.cpu=cortexm", + "-n", + "-m", + "0x08000000", + "-c", + input.command, + artifact, + ]; + expect(JSON.parse(evidence.normalized_result.stdout)).toEqual(argv); + expect(evidence.parameters).toMatchObject({ + ...input, + command_arguments: argv, + }); + expect(evidence.limitations).toEqual( + expect.arrayContaining([...rizinCommandLimitations(input)]), + ); + + // Compact schemas drop property descriptions, so the tool description names + // the input that changes how Rizin loads the file. + const { tools } = await client.listTools(); + const listed = tools.find((tool) => tool.name === rizinContract.name); + expect(listed?.description).toBe(rizinContract.description); + expect(listed?.description).toContain("base_address"); + + const refused = await client.callTool({ + name: "execute_rizin_command", + arguments: { ...input, asm_cpu: "cortexm; q" }, + }); + expect(refused.isError).toBe(true); + expect(parseMcpToolError(refused as CallToolResult).error).toMatchObject({ + code: "invalid_request", + details: { + operation: "execute_rizin_command", + issues: [ + { + path: ["asm_cpu"], + reason: "invalid_format", + message: expect.stringMatching(/^must be a Rizin name: /u), + }, + ], + }, + }); +}); diff --git a/tests/boundary/providers/RizinDecoderInputs.test.ts b/tests/boundary/providers/RizinDecoderInputs.test.ts new file mode 100644 index 0000000000..468e42039b --- /dev/null +++ b/tests/boundary/providers/RizinDecoderInputs.test.ts @@ -0,0 +1,259 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { REVERSE_ENGINEERING_TOOL_CONTRACTS } from "../../../src/contracts/reverseEngineeringToolContracts.js"; +import { rizinCommandLimitations } from "../../../src/rizin/RizinCommand.js"; +import { createProviderEnvironmentMcpHarness } from "../../support/reverseDebuggerMcpTestHelpers.js"; + +// Real-provider gate for execute_rizin_command's decoder and map inputs. It +// runs the Rizin found on PATH and skips without one; put another Rizin's +// bin directory first on PATH to check that version. +const rizin = findRizin(); +const banner = rizin === undefined ? "no Rizin on PATH" : versionBanner(rizin); + +// Cortex-M vector table head (SP 0x20002000, Reset 0x08000109), then the +// Thumb instructions `bx lr` and `nop`. +const CORTEX_M_IMAGE = Buffer.from([ + 0x00, 0x20, 0x00, 0x20, 0x09, 0x01, 0x00, 0x08, 0x70, 0x47, 0x00, 0xbf, +]); +// Thumb `msr primask, r0`, which only an M-profile decoder knows. +const MSR_PRIMASK = Buffer.from([0x80, 0xf3, 0x10, 0x88]); +// 6502 `nop; rts`; no host decodes 6502 by default. +const MOS_6502_IMAGE = Buffer.from([0xea, 0x60]); +// Raw images that Rizin's loaders claim unless REA passes -n. Rizin 0.9.1's +// AVR loader (bytes 1 and 3 look like rjmp) turns io.va off, so reads at the +// -m map return 0xff. The BIOS loader (a jmp 16 bytes from the end of a 64 KiB +// file) maps the file at another address in 0.7.4 and 0.9.1. +const AVR_LIKE_IMAGE = Buffer.from( + Array.from({ length: 64 }, (_, index) => + index === 1 || index === 3 ? 0xc0 : index, + ), +); +const BIOS_LIKE_IMAGE = Buffer.alloc(0x10000); +BIOS_LIKE_IMAGE.set([0x11, 0x22, 0x33, 0x44], 0); +BIOS_LIKE_IMAGE[0xfff0] = 0xea; +BIOS_LIKE_IMAGE[0xffff] = 0x5a; + +const rizinContract = REVERSE_ENGINEERING_TOOL_CONTRACTS.find( + (contract) => contract.name === "execute_rizin_command", +); +if (rizinContract === undefined) + throw new Error("execute_rizin_command contract is missing"); + +const hex = (value: number): string => `0x${value.toString(16)}`; + +const runRizin = async ( + input: Readonly>, + image: Buffer, +) => { + const directory = await mkdtemp(join(tmpdir(), "rea-rizin-decoder-")); + // The harness owns the directory, so it must exist before setup can fail. + const { client } = await createProviderEnvironmentMcpHarness({ + directory, + clientName: "real-rizin-decoder-inputs", + serverOptions: { environment: { REA_RIZIN_COMMAND: rizin! } }, + }); + const path = join(directory, "image.bin"); + await writeFile(path, image); + const toolArguments = { path, ...input }; + const response = await client.callTool({ + name: "execute_rizin_command", + arguments: toolArguments, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + const evidence = rizinContract.outputSchema.parse(response.structuredContent); + expect(evidence.normalized_result.exit_code).toBe(0); + expect(evidence.limitations).toEqual( + expect.arrayContaining([ + ...rizinCommandLimitations({ + ...toolArguments, + command: String(input.command), + }), + ]), + ); + return { + lines: evidence.normalized_result.stdout.trimEnd().split("\n"), + stderr: evidence.normalized_result.stderr, + }; +}; + +const mapsOf = (line: string | undefined) => + JSON.parse(line ?? "null") as readonly { + readonly fd: number; + readonly delta: number; + readonly from: number; + }[]; + +it.skipIf(rizin === undefined)( + `applies -a, -b, -e asm.cpu= and -m to a raw Cortex-M image (${banner})`, + async () => { + const { lines } = await runRizin( + { + command: + "e asm.arch; e asm.bits; e asm.cpu; omlj; p8 1 @ 0x07ffffff; p8 12 @ 0x08000000; p8 1 @ 0x0800000c; pi 1 @ 0x08000008", + asm_arch: "arm", + asm_bits: 16, + asm_cpu: "cortexm", + base_address: "0x08000000", + }, + CORTEX_M_IMAGE, + ); + expect(lines.slice(0, 3)).toEqual(["arm", "16", "cortexm"]); + // One map: file offset 0 at the base address, and nothing mapped below + // it or past its end. + expect(mapsOf(lines[3])).toEqual([ + expect.objectContaining({ delta: 0, from: 0x08000000 }), + ]); + expect(lines.slice(4)).toEqual([ + "ff", + CORTEX_M_IMAGE.toString("hex"), + "ff", + "bx lr", + ]); + }, +); + +it.skipIf(rizin === undefined)( + `applies asm_cpu to the decoder, and echoes one Rizin lacks (${banner})`, + async () => { + const thumb = { asm_arch: "arm", asm_bits: 16, base_address: "0x0" }; + const plain = await runRizin({ command: "pi 1", ...thumb }, MSR_PRIMASK); + expect(plain.lines).toEqual(["invalid"]); + + const selected = await runRizin( + { command: "pi 1", ...thumb, asm_cpu: "cortexm" }, + MSR_PRIMASK, + ); + expect(selected.lines).toEqual(["msr primask, r0"]); + + const unknown = await runRizin( + { command: "e asm.cpu; pi 1", ...thumb, asm_cpu: "rea-no-such-cpu" }, + MSR_PRIMASK, + ); + expect(unknown.lines).toEqual(["rea-no-such-cpu", "invalid"]); + }, +); + +it.skipIf(rizin === undefined)( + `states what a call with only base_address leaves unconfirmed (${banner})`, + async () => { + const { lines } = await runRizin( + { command: "omlj; om-*; omlj", base_address: "0x8000" }, + CORTEX_M_IMAGE, + ); + expect(mapsOf(lines[0])).toEqual([ + expect.objectContaining({ delta: 0, from: 0x8000 }), + ]); + // The command removed the map, so the map is not a fact of the result. + expect(mapsOf(lines[1])).toEqual([]); + }, +); + +it.skipIf(rizin === undefined)( + `reads the file at base_address when a Rizin loader would claim it (${banner})`, + async () => { + const base = 0x08000000; + for (const image of [AVR_LIKE_IMAGE, BIOS_LIKE_IMAGE]) { + const last = base + image.length - 1; + const { lines } = await runRizin( + { + command: `e io.va; omlj; p8 1 @ ${hex(base - 1)}; p8 4 @ ${hex(base)}; p8 1 @ ${hex(last)}; p8 1 @ ${hex(last + 1)}`, + asm_arch: "arm", + asm_bits: 16, + base_address: hex(base), + }, + image, + ); + expect(lines[0]).toBe("true"); + expect(mapsOf(lines[1])).toEqual([ + expect.objectContaining({ delta: 0, from: base }), + ]); + expect(lines.slice(2)).toEqual([ + "ff", + image.subarray(0, 4).toString("hex"), + image.subarray(-1).toString("hex"), + "ff", + ]); + } + }, +); + +it.skipIf(rizin === undefined)( + `selects an arch that differs from the host default (${banner})`, + async () => { + const plain = await runRizin( + { command: "e asm.arch; e asm.bits; omlj" }, + MOS_6502_IMAGE, + ); + expect(plain.lines[0]).not.toBe("6502"); + expect(mapsOf(plain.lines[2])).toEqual([ + expect.objectContaining({ delta: 0, from: 0 }), + ]); + + const selected = await runRizin( + { + command: "e asm.arch; e asm.bits; omlj; pi 2 @ 0x8000", + asm_arch: "6502", + asm_bits: 8, + base_address: "0x8000", + }, + MOS_6502_IMAGE, + ); + expect(selected.lines.slice(0, 2)).toEqual(["6502", "8"]); + expect(mapsOf(selected.lines[2])).toEqual([ + expect.objectContaining({ delta: 0, from: 0x8000 }), + ]); + expect(selected.lines.slice(3)).toEqual(["nop", "rts"]); + }, +); + +it.skipIf(rizin === undefined)( + `does not apply an arch or bits value that Rizin lacks (${banner})`, + async () => { + const plain = await runRizin({ command: "e asm.arch" }, MOS_6502_IMAGE); + const unknown = await runRizin( + { + command: "e asm.arch; e asm.bits", + asm_arch: "rea-no-such-arch", + asm_bits: 16, + }, + MOS_6502_IMAGE, + ); + // Rizin exits 0, decodes with its default arch and names the value only + // on stderr. Evidence carries the limitation for this case. + expect(unknown.lines[0]).toBe(plain.lines[0]); + expect(unknown.stderr).toContain("rea-no-such-arch"); + + // ARM has no 8-bit mode. Rizin 0.9.1 keeps its default and reports the + // value on stderr; Rizin 0.7.4 uses 16 without a message. + const unsupported = await runRizin( + { command: "e asm.bits", asm_arch: "arm", asm_bits: 8 }, + CORTEX_M_IMAGE, + ); + expect(unsupported.lines[0]).not.toBe("8"); + }, +); + +function findRizin(): string | undefined { + try { + const path = execFileSync("which", ["rizin"], { encoding: "utf8" }).trim(); + return path === "" ? undefined : path; + } catch { + return undefined; + } +} + +function versionBanner(binary: string): string { + try { + return ( + execFileSync(binary, ["-v"], { encoding: "utf8" }).split("\n")[0] ?? + "unknown Rizin" + ).trim(); + } catch { + return "unknown Rizin"; + } +} diff --git a/tests/composition/reverseEngineering.test.ts b/tests/composition/reverseEngineering.test.ts index 6242584ace..101fc3d3e5 100644 --- a/tests/composition/reverseEngineering.test.ts +++ b/tests/composition/reverseEngineering.test.ts @@ -17,6 +17,7 @@ import { createReverseEngineeringService } from "../../src/composition/reverseEn import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; import { AnalysisCancelledError } from "../../src/domain/analysisErrorCore.js"; import { REVERSE_ENGINEERING_TOOL_CONTRACTS } from "../../src/contracts/reverseEngineeringToolContracts.js"; +import { rizinCommandLimitations } from "../../src/rizin/RizinCommand.js"; it("documents the shared combined capture limit for both command tools", () => { expect(REVERSE_ENGINEERING_TOOL_CONTRACTS.map(({ name }) => name)).toEqual( @@ -494,6 +495,153 @@ describe("Rizin command service", () => { }); }); +it("passes decoder and map inputs to Rizin before the path and records them", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-rizin-load-")); + const path = join(directory, "flash.bin"); + await writeFile(path, Buffer.from([0x70, 0x47])); + const calls: (readonly string[])[] = []; + const service = new ReverseEngineeringService({ + environment: { REA_RIZIN_COMMAND: "/tools/rizin" }, + run: async (_command, args) => { + calls.push(args); + return { stdout: "bx lr\n", stderr: "" }; + }, + }); + const input = { + path, + command: "pi 1", + asm_arch: "arm", + asm_bits: 16, + asm_cpu: "cortexm", + base_address: "0x08000000", + }; + + try { + const result = await service.executeRizinCommand(input); + if (!result.ok) throw result.error; + const argv = [ + "-N", + "-q", + "-e", + "scr.color=0", + "-a", + "arm", + "-b", + "16", + "-e", + "asm.cpu=cortexm", + "-n", + "-m", + "0x08000000", + "-c", + "pi 1", + path, + ]; + // The command is the last launch; a provider probe may precede it. + expect(calls.at(-1)).toEqual(argv); + expect(result.value.parameters).toMatchObject({ + ...input, + command_arguments: argv, + }); + expect(result.value.limitations).toEqual( + expect.arrayContaining([...rizinCommandLimitations(input)]), + ); + // One statement for the decoder inputs and one for the raw map. + expect(rizinCommandLimitations(input)).toHaveLength(2); + expect( + rizinCommandLimitations({ path, command: "pi 1", asm_bits: 16 }), + ).toHaveLength(1); + expect( + rizinCommandLimitations({ path, command: "pi 1", base_address: "0x0" }), + ).toHaveLength(1); + expect(rizinCommandLimitations({ path, command: "pi 1" })).toEqual([]); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("checks only the spelling and range of decoder and map inputs", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-rizin-load-input-")); + const path = join(directory, "image.bin"); + await writeFile(path, "image"); + const calls: (readonly string[])[] = []; + const service = new ReverseEngineeringService({ + run: async (_command, args) => { + calls.push(args); + return { stdout: "", stderr: "" }; + }, + }); + + try { + // Rizin's own names: digits first, dotted plugins, '+', '-' and case. + for (const [selection, flags] of [ + [{ asm_arch: "6502", asm_bits: 8 }, ["-a", "6502", "-b", "8"]], + [{ asm_arch: "x86.nasm", asm_bits: 64 }, ["-a", "x86.nasm", "-b", "64"]], + [{ asm_cpu: "c55x+" }, ["-e", "asm.cpu=c55x+"]], + [{ asm_cpu: "c166-generic" }, ["-e", "asm.cpu=c166-generic"]], + [{ asm_cpu: "ATxmega128a4u" }, ["-e", "asm.cpu=ATxmega128a4u"]], + [{ asm_bits: 1 }, ["-b", "1"]], + [{ base_address: "0x0" }, ["-n", "-m", "0x0"]], + [ + { base_address: "0xFFFFFFFFFFFFFFFF" }, + ["-n", "-m", "0xFFFFFFFFFFFFFFFF"], + ], + ] as const) { + const result = await service.executeRizinCommand({ + path, + command: "e asm.arch", + ...selection, + }); + if (!result.ok) throw result.error; + expect(result.value.parameters.command_arguments).toEqual([ + "-N", + "-q", + "-e", + "scr.color=0", + ...flags, + "-c", + "e asm.arch", + path, + ]); + } + + calls.length = 0; + for (const [field, value] of [ + ["asm_arch", ""], + ["asm_arch", "arm;q"], + ["asm_arch", "arm x"], + ["asm_arch", "-q"], + ["asm_cpu", "?"], + ["asm_cpu", "cortex=m"], + ["asm_bits", 0], + ["asm_bits", 65], + ["asm_bits", 16.5], + ["asm_bits", "16"], + ["base_address", "0x"], + ["base_address", "08000000"], + ["base_address", "0X08000000"], + ["base_address", "0x10000000000000000"], + ] as const) { + const result = await service.executeRizinCommand({ + path, + command: "e asm.arch", + [field]: value, + }); + if (result.ok) throw new Error(`${field}=${String(value)} was accepted`); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "invalid_request", + details: { + operation: "execute_rizin_command", + issues: [expect.objectContaining({ path: [field] })], + }, + }); + } + expect(calls).toEqual([]); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + it("retains partial Evidence when a provider command times out", async () => { const directory = await mkdtemp(join(tmpdir(), "rea-rizin-timeout-partial-")); const path = join(directory, "fixture.bin"); diff --git a/tsconfig.reverse-tools-mutation.json b/tsconfig.reverse-tools-mutation.json index 4fd7937fad..9942cb5fc7 100644 --- a/tsconfig.reverse-tools-mutation.json +++ b/tsconfig.reverse-tools-mutation.json @@ -18,6 +18,7 @@ "tests/boundary/providers/RizinTimeoutEvidence.test.ts", "tests/boundary/mcp/rizinPendingOutput.test.ts", "tests/boundary/providers/RizinInferiorTty.test.ts", + "tests/boundary/providers/RizinDecoderInputs.test.ts", "tests/boundary/providers/RizinTestInferiorTty.ts", "tests/boundary/providers/RizinCommandTimeout.test.ts", "tests/boundary/providers/CutterBridgeClient.test.ts", diff --git a/vitest.reverse-tools.config.ts b/vitest.reverse-tools.config.ts index 82468f43df..3fab3790d0 100644 --- a/vitest.reverse-tools.config.ts +++ b/vitest.reverse-tools.config.ts @@ -24,6 +24,7 @@ export default defineConfig({ "tests/boundary/providers/GdbSessionManagers.test.ts", "tests/boundary/providers/RizinDebugSessionManagers.test.ts", "tests/boundary/providers/RizinInferiorTty.test.ts", + "tests/boundary/providers/RizinDecoderInputs.test.ts", "tests/boundary/providers/GdbSessionManagerCoverage.test.ts", "tests/boundary/providers/GdbCommandQueue.test.ts", "tests/boundary/providers/GdbSessionReviewRegressions.test.ts", From 6c08320654818fb2f83969d63819d143d0532d90 Mon Sep 17 00:00:00 2001 From: 2SinceJuly Date: Fri, 6 Nov 2026 20:49:20 +0800 Subject: [PATCH 064/151] feat(process): add a non-executing dry run to capture_process_scenario (#8469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(process): add a non-executing dry run to capture_process_scenario Preparation prompts asked an agent to prepare a scenario without running it but could only suggest the tool that launches the target. Add dry_run: true so the CLI and MCP resolve the executable, working directory, observation paths and admission checks, then return planned Evidence under the rea.process-scenario-plan predicate. Planned Evidence is not capture Evidence and comparison tools refuse it as a capture. The preparation prompt now suggests the dry-run call, and the scenario identity commitments exclude the operation-only field. Fixes #8398 * fix(process): redact planned sensitive input and share admission Address review findings on the dry-run contract: - Redact sensitive input events in planned Evidence and preserve only their byte length. - Recheck cancellation after the last awaited admission step. - Run the sensitive-matcher and filesystem-root admission checks that a capture performs before launch. - Assert the prompt advertises the dry_run field without freezing explanatory wording. * test(process): hold the final admission step for cancellation Add a preparation host seam so the regression can cancel while the last filesystem admission call is pending, then assert the returned AnalysisCancelledError retains the caller's reason. * refactor(process): remove the unused plan alias Keep the plan schema as the single exported contract and satisfy the unused-export check. * fix(process): report admission-time aborts as planning cancellation `prepareProcessScenarioPlan` returned the admission error verbatim, so an abort that landed while `admitFilesystemObservationRoots` awaited its `lstat` surfaced as a cancelled `ProcessCaptureError` with `operation: process_capture` even though no process ran. Convert that result through the same `cancelled()` projection the neighbouring checks use, and pin it with a test. * fix(contracts): keep the process result invariants on the dry-run union The output schema for `capture_process_scenario` became a union of the capture and plan shapes, and the union no longer carried the description that advertised the cross-field invariants to MCP clients — the boundary test reads `normalized_result.description`. Attach a description to the union that names both variants and keeps the capture invariants verbatim. * test(process): exercise the post-admission cancellation check The previous regression aborted immediately after starting preparation and released the held admission afterwards. Preparation awaits runtime path resolution first, so that abort was always observed by the pre-admission check; deleting the post-admission "if (aborted()) return cancelled();" still left the test green. Signal from the admitRoots seam when admission is actually entered, abort only after that point, then let admission resolve ok. Preparation now has to reach the check between a successful admission and the ok result, and the assertion pins both the AnalysisCancelledError tag and the caller's reason as its cause. Verified red-green: removing only the post-admission check fails this test (expected true to be false at prepared.ok) while the other two pass, and restoring it passes all three. * test(process): gate the admission seam on acknowledgement Race the admission acknowledgement against planning's own settlement and a bounded deadline, and release and join planning in `finally`, so a planning result that arrives before admission fails the test instead of stalling it until the suite timeout. * fix(contracts): hold plans to the scenario refinements and redact inputs `processScenarioPlanSchema` described the resolved dry-run response but dropped every cross-field check the input contract applies, so a plan could advertise out-of-order events, an event past `timeout_ms`, or a terminal beyond the renderer cell budget while claiming it had been validated. Extract those checks into `scenarioRefinements` and refine the plan's scenario with them. The plan's input event also carried `sensitive` and an optional `input_bytes`, which accepted a sensitive event that still shipped its data. Model the public and sensitive forms as separate exact variants: public input forbids `input_bytes`, sensitive input requires an empty `data` plus its byte length. --------- Co-authored-by: 2SinceJuly <202121119+2SinceJuly@users.noreply.github.com> --- docs/process-capture.md | 10 + src/application/process/ProcessCli.ts | 13 ++ src/application/process/ProcessEvidence.ts | 37 +++- .../ProcessScenarioPreparation.test.ts | 126 ++++++++++++ .../process/ProcessScenarioPreparation.ts | 78 +++++++ src/contracts/promptContracts.ts | 2 +- src/contracts/sessionToolContracts.ts | 2 +- src/contracts/toolOutputSchemaGroups.ts | 9 +- src/domain/process/processScenario.test.ts | 86 ++++++++ src/domain/process/processScenario.ts | 193 +++++++++++++----- src/process/capture/FilesystemSnapshot.ts | 39 +++- src/server/registerSessionTools.ts | 39 +++- .../tools/localToolContracts.test.ts | 42 ++-- tests/boundary/mcp/promptMcp.test.ts | 1 + .../boundary/process/ProcessEvidence.test.ts | 25 ++- .../process/processCaptureProgress.test.ts | 23 ++- tests/boundary/process/processCli.test.ts | 32 ++- .../process/processSessionMcp.test.ts | 65 +++++- 18 files changed, 735 insertions(+), 87 deletions(-) create mode 100644 src/application/process/ProcessScenarioPreparation.test.ts create mode 100644 src/application/process/ProcessScenarioPreparation.ts diff --git a/docs/process-capture.md b/docs/process-capture.md index 73a1e825cd..edea6870d9 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -74,6 +74,16 @@ Read the result's terminal observations, exit/settlement status, and cleanup outcome. [Scenario and result details](#scenario-and-result-details) describe limits, normalization, filesystem coverage, and incomplete observations. +## Prepare without launching + +Add `"dry_run": true` to the scenario, or pass `dry_run: true` to the MCP tool, +to resolve the executable, working directory, observation paths and admission +checks without starting a process. The result is planned Evidence with the +`rea.process-scenario-plan` predicate, not capture Evidence; comparison tools +refuse it as a capture. Use it to review a scenario before separately requesting +execution. A planned result does not establish runtime behavior, exit status, +cleanup or the inherited ambient environment. + ## Compare two captures Compare saved capture Evidence with: diff --git a/src/application/process/ProcessCli.ts b/src/application/process/ProcessCli.ts index 737c4c0f2d..cf7d35f0ee 100644 --- a/src/application/process/ProcessCli.ts +++ b/src/application/process/ProcessCli.ts @@ -26,8 +26,10 @@ import { ProcessCaptureResourceScope } from "../../process/capture/ProcessCaptur import { ProcessCaptureError } from "../../process/capture/ProcessCaptureError.js"; import { createProcessCaptureEvidence, + createProcessScenarioPlanEvidence, type ProcessCaptureEvidenceFailure, } from "./ProcessEvidence.js"; +import { prepareProcessScenarioPlan } from "./ProcessScenarioPreparation.js"; import { readJsonFile } from "../JsonFiles.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; @@ -58,6 +60,17 @@ export const captureProcessScenarioFile = async ( input, { cause: parsed.error }, ); + if (parsed.data.dry_run) { + const prepared = await prepareProcessScenarioPlan( + parsed.data, + environment, + process.platform, + signal, + ); + return prepared.ok + ? createProcessScenarioPlanEvidence(prepared.value, process.platform) + : cliAnalysisError(prepared.error); + } const progress = createProgressReporter( async (update) => { process.stderr.write(`${JSON.stringify({ rea_progress: update })}\n`); diff --git a/src/application/process/ProcessEvidence.ts b/src/application/process/ProcessEvidence.ts index 4fd233088a..bd21493d42 100644 --- a/src/application/process/ProcessEvidence.ts +++ b/src/application/process/ProcessEvidence.ts @@ -15,7 +15,11 @@ import { type ProcessCapture, } from "../../domain/process/processCaptureParsing.js"; import { safeParseEvidence } from "../../domain/evidenceValidation.js"; -import type { ProcessScenario } from "../../domain/process/processScenario.js"; +import { + processScenarioPlanProjection, + processScenarioPlanSchema, + type ProcessScenario, +} from "../../domain/process/processScenario.js"; import type { ParsedProcessCaptureEvidence } from "../../domain/process/processComparisonEvidence.js"; import { err, ok, type Result } from "../../domain/result.js"; @@ -24,6 +28,11 @@ const PROCESS_CAPTURE_EVIDENCE = { predicate: "rea.process-capture", } as const; +const PROCESS_SCENARIO_PLAN_EVIDENCE = { + operation: "capture_process_scenario", + predicate: "rea.process-scenario-plan", +} as const; + /** Semantic rejection facts; CLI and MCP retain their own error projections. */ export type ProcessCaptureEvidenceFailure = | { readonly kind: "invalid-evidence"; readonly cause: z.ZodError } @@ -68,7 +77,7 @@ export const parseProcessCaptureEvidence = ( /** Project one process scenario into secret-free Evidence parameters. */ const processEvidenceParameters = ( - scenario: ProcessScenario, + scenario: Omit, platform: ProcessCapture["manifest"]["platform"], ): Readonly> => ({ executable_name: (platform === "win32" ? win32 : posix).basename( @@ -102,3 +111,27 @@ export const createProcessCaptureEvidence = ( limitations: capture.limitations, }); }; + +/** Create one canonical planned-scenario Evidence record without observing a process. */ +export const createProcessScenarioPlanEvidence = ( + scenario: Omit, + platform: ProcessCapture["manifest"]["platform"] = process.platform, +) => { + const plan = processScenarioPlanSchema.parse({ + status: "planned", + scenario: processScenarioPlanProjection(scenario), + ambient_environment: "inherited-at-run", + }); + return createEvidence(undefined, PROCESS_PROVIDER, { + predicateType: PROCESS_SCENARIO_PLAN_EVIDENCE.predicate, + operation: PROCESS_SCENARIO_PLAN_EVIDENCE.operation, + parameters: processEvidenceParameters(scenario, platform), + result: plan, + confidence: "derived", + authority: "controlled-replay", + environment: null, + limitations: [ + "Planned only; no process was launched. Runtime behavior, exit, cleanup and the inherited ambient environment remain unobserved.", + ], + }); +}; diff --git a/src/application/process/ProcessScenarioPreparation.test.ts b/src/application/process/ProcessScenarioPreparation.test.ts new file mode 100644 index 0000000000..697ca28f5f --- /dev/null +++ b/src/application/process/ProcessScenarioPreparation.test.ts @@ -0,0 +1,126 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; + +import { awaitAcknowledgement } from "../../../tests/fixtures/lifecycleAcknowledgement.js"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { processScenarioSchema } from "../../domain/process/processScenario.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { ProcessCaptureError } from "../../process/capture/ProcessCaptureError.js"; +import { prepareProcessScenarioPlan } from "./ProcessScenarioPreparation.js"; + +it("rejects observation roots that capture admission rejects", async () => { + const root = await createTestTempDirectory("rea-process-plan-"); + const file = join(root, "selected"); + await writeFile(file, "not a directory"); + const scenario = processScenarioSchema.parse({ + executable: process.execPath, + working_directory: root, + filesystem_observation_paths: [join(file, "child")], + }); + + const prepared = await prepareProcessScenarioPlan( + scenario, + {}, + process.platform, + ); + + expect(prepared.ok).toBe(false); + if (prepared.ok) return; + expect(prepared.error).toMatchObject({ + _tag: "ProcessCaptureError", + userMessage: "filesystem observation admission failed", + }); +}); + +it("reports cancellation normalized inside admission as planning cancellation", async () => { + const scenario = processScenarioSchema.parse({ + executable: process.execPath, + working_directory: ".", + }); + const controller = new AbortController(); + const prepared = await prepareProcessScenarioPlan( + scenario, + {}, + process.platform, + controller.signal, + { + admitRoots: () => { + controller.abort("caller stopped during admission"); + return Promise.resolve( + err( + new ProcessCaptureError("filesystem observation admission failed", { + cause: controller.signal.reason, + }), + ), + ); + }, + }, + ); + + expect(prepared.ok).toBe(false); + if (prepared.ok) return; + expect(prepared.error._tag).toBe("AnalysisCancelledError"); + expect(prepared.error).toMatchObject({ + cause: "caller stopped during admission", + }); +}); + +it("reports cancellation after the last awaited admission step", async () => { + const scenario = processScenarioSchema.parse({ + executable: process.execPath, + working_directory: ".", + }); + const controller = new AbortController(); + let admissionEntered!: () => void; + const entered = new Promise((resolve) => { + admissionEntered = resolve; + }); + let release!: () => void; + const pendingAdmission = new Promise>( + (resolve) => { + release = () => resolve(ok(undefined)); + }, + ); + let admissionCalls = 0; + + const planning = prepareProcessScenarioPlan( + scenario, + {}, + process.platform, + controller.signal, + { + admitRoots: () => { + admissionCalls += 1; + admissionEntered(); + return pendingAdmission; + }, + }, + ); + + // Abort only once admission is actually running. Aborting earlier would be + // observed by the pre-admission check and would leave the post-admission + // check untested. + try { + await awaitAcknowledgement(entered, planning, "root admission"); + expect(admissionCalls).toBe(1); + expect(controller.signal.aborted).toBe(false); + controller.abort("caller stopped after admission"); + + // Admission still succeeds after the abort, so only the check that runs + // after it can turn the planning result into a cancellation. + } finally { + release(); + await planning; + } + + const prepared = await planning; + expect(admissionCalls).toBe(1); + expect(prepared.ok).toBe(false); + if (prepared.ok) return; + expect(prepared.error._tag).toBe("AnalysisCancelledError"); + expect(prepared.error).toMatchObject({ + cause: "caller stopped after admission", + }); +}); diff --git a/src/application/process/ProcessScenarioPreparation.ts b/src/application/process/ProcessScenarioPreparation.ts new file mode 100644 index 0000000000..5103f2ece2 --- /dev/null +++ b/src/application/process/ProcessScenarioPreparation.ts @@ -0,0 +1,78 @@ +import { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, + AnalysisInputError, +} from "../../domain/analysisErrorCore.js"; +import type { ProcessScenario } from "../../domain/process/processScenario.js"; +import { admitFilesystemObservationRoots } from "../../process/capture/FilesystemSnapshot.js"; +import { processCaptureOwnershipUnavailableReason } from "../../process/capture/ProcessCaptureCapability.js"; +import { createProcessSensitiveOutput } from "../../process/capture/ProcessSensitiveOutput.js"; +import { resolveProcessScenarioRuntimePaths } from "../../process/capture/ProcessScenarioRuntimeValidation.js"; +import { err, ok, type Result } from "../../domain/result.js"; + +/** A scenario whose executable and observation paths are resolved for execution. */ +export type ResolvedProcessScenario = Omit; + +/** Test seam for holding the final admission step while cancellation arrives. */ +export interface ProcessScenarioPreparationHost { + readonly admitRoots?: typeof admitFilesystemObservationRoots; +} + +/** + * Resolve a scenario for a dry run. This applies the same executable, working + * directory, path and platform admission checks as a capture, then stops + * before any process is launched. + */ +export const prepareProcessScenarioPlan = async ( + scenario: ProcessScenario, + environment: Readonly> = process.env, + platform: NodeJS.Platform = process.platform, + signal?: AbortSignal, + host: ProcessScenarioPreparationHost = {}, +): Promise> => { + if (signal?.aborted === true) + return err( + new AnalysisCancelledError("capture_process_scenario", { + cause: signal.reason, + }), + ); + const ownershipReason = processCaptureOwnershipUnavailableReason(platform); + if (ownershipReason !== undefined) + return err( + new AnalysisCapabilityUnavailableError( + "rea-process", + "capture_process_scenario", + ownershipReason, + { userMessage: ownershipReason }, + ), + ); + const cancelled = () => + err( + new AnalysisCancelledError("capture_process_scenario", { + cause: signal?.reason, + }), + ); + const aborted = () => signal?.aborted === true; + try { + const { dry_run: _dryRun, ...resolved } = + await resolveProcessScenarioRuntimePaths(scenario, environment, platform); + if (aborted()) return cancelled(); + const privacy = createProcessSensitiveOutput(scenario); + if (!privacy.ok) return err(privacy.error); + const admitted = await (host.admitRoots ?? admitFilesystemObservationRoots)( + resolved, + signal, + ); + if (!admitted.ok) return aborted() ? cancelled() : admitted; + if (aborted()) return cancelled(); + return ok(resolved); + } catch (cause: unknown) { + if (aborted()) return cancelled(); + return err( + cause instanceof AnalysisError + ? cause + : new AnalysisInputError("capture_process_scenario", { cause }, []), + ); + } +}; diff --git a/src/contracts/promptContracts.ts b/src/contracts/promptContracts.ts index 080e48cb45..1878ab63f2 100644 --- a/src/contracts/promptContracts.ts +++ b/src/contracts/promptContracts.ts @@ -478,7 +478,7 @@ export const PROMPT_CONTRACTS = [ { tools: ["capture_process_scenario"], instruction: - "Prepare the scenario without running it. Specify the selected command, inputs, effective working directory, expected observations, timeout, and cleanup. Explain command lookup, inherited environment, optional filesystem observations, and that a later capture runs with the current user's permissions. A selected executable and working directory do not authorize execution.", + "Prepare the scenario without running it. Specify the selected command, inputs, effective working directory, expected observations, timeout, and cleanup. Explain command lookup, inherited environment, optional filesystem observations, and that a later capture runs with the current user's permissions. A selected executable and working directory do not authorize execution. Call capture_process_scenario with dry_run: true to validate and return the resolved plan without launching it.", }, { tools: ["compare_process_captures"], diff --git a/src/contracts/sessionToolContracts.ts b/src/contracts/sessionToolContracts.ts index 09bea4145e..0778c29316 100644 --- a/src/contracts/sessionToolContracts.ts +++ b/src/contracts/sessionToolContracts.ts @@ -103,7 +103,7 @@ export const SESSION_TOOL_CONTRACTS = [ ), session( "capture_process_scenario", - "Run one caller-selected command under a PTY and return process capture Evidence with residual unknowns. The renderer admits at most 1,000,000 combined terminal cells, computed as columns × (rows + scrollback); every resize uses the selected scrollback and must fit the same budget. Supply a smaller terminal shape when that product is too large; dimensions are never clamped. This release has no runtime override for that cell budget. A command name resolves through the inherited PATH; the working directory defaults to the caller's current directory; host environment variables are inherited with scenario overrides. Environment keys cannot contain '=' or NUL, and REA_PROCESS_RUN_ID is reserved for process ownership. Filesystem snapshots are opt-in through filesystem_observation_paths. A positive finalization_ms makes a timeout or idle deadline send SIGTERM to the captured root through its start identity, keep observing, and send SIGKILL once after the interval; exit.finalization records each attempt with its delivery result and the observed exit, and cancellation still ends the run at once. The target runs with the current user's permissions; this is not a security sandbox.", + "Run one caller-selected command under a PTY and return process capture Evidence with residual unknowns. Set dry_run: true to validate and return the resolved scenario without launching it; planned Evidence uses the rea.process-scenario-plan predicate and is not a capture. The renderer admits at most 1,000,000 combined terminal cells, computed as columns × (rows + scrollback); every resize uses the selected scrollback and must fit the same budget. Supply a smaller terminal shape when that product is too large; dimensions are never clamped. This release has no runtime override for that cell budget. A command name resolves through the inherited PATH; the working directory defaults to the caller's current directory; host environment variables are inherited with scenario overrides. Environment keys cannot contain '=' or NUL, and REA_PROCESS_RUN_ID is reserved for process ownership. Filesystem snapshots are opt-in through filesystem_observation_paths. A positive finalization_ms makes a timeout or idle deadline send SIGTERM to the captured root through its start identity, keep observing, and send SIGKILL once after the interval; exit.finalization records each attempt with its delivery result and the observed exit, and cancellation still ends the run at once. The target runs with the current user's permissions; this is not a security sandbox.", processScenarioSchema, ), session( diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 119b67e225..7ee0a3d098 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -120,6 +120,7 @@ import { } from "./toolOutputSchemaPrimitives.js"; import { processCaptureSchema } from "../domain/process/processCapture.js"; +import { processScenarioPlanSchema } from "../domain/process/processScenario.js"; import { electronFuseReportSchema } from "../domain/electron/electronFuses.js"; const contextFacetSchema = z.discriminatedUnion("state", [ z.object({ state: z.literal("available"), value: jsonValueSchema }), @@ -558,7 +559,13 @@ export const sessionOutputSchemas = { total: z.int().min(0), }), ), - capture_process_scenario: evidenceResultOf(processCaptureSchema), + capture_process_scenario: evidenceResultOf( + z + .union([processCaptureSchema, processScenarioPlanSchema]) + .describe( + `A capture, or the plan a dry run produced without launching a process. ${processCaptureSchema.description ?? ""}`, + ), + ), compare_process_captures: evidenceResultOf(processCaptureComparisonSchema), compare_artifacts: evidenceResultOf(artifactComparisonResultSchema), compare_functions: evidenceResultOf(functionComparisonResultSchema), diff --git a/src/domain/process/processScenario.test.ts b/src/domain/process/processScenario.test.ts index 73355d06c1..965bf23347 100644 --- a/src/domain/process/processScenario.test.ts +++ b/src/domain/process/processScenario.test.ts @@ -10,6 +10,7 @@ import { PROCESS_TERMINAL_CELL_BUDGET, processComparisonContract, processScenarioCommitment, + processScenarioPlanSchema, processScenarioSchema, } from "./processScenario.js"; @@ -48,6 +49,26 @@ it("defaults the finalization interval to zero without changing committed identi ).toBe(false); }); +it("keeps the dry-run operation out of capture identity", () => { + const capture = processScenarioSchema.parse(baseScenario); + const planned = processScenarioSchema.parse({ + ...baseScenario, + dry_run: true, + }); + + expect(capture.dry_run).toBe(false); + expect(planned.dry_run).toBe(true); + expect(Object.hasOwn(processScenarioCommitment(planned), "dry_run")).toBe( + false, + ); + expect(processScenarioCommitment(planned)).toEqual( + processScenarioCommitment(capture), + ); + expect(processComparisonContract(planned)).toEqual( + processComparisonContract(capture), + ); +}); + it("accepts an explicit zero finalization interval", () => { expect( processScenarioSchema.parse({ ...baseScenario, finalization_ms: 0 }), @@ -343,3 +364,68 @@ it("keeps native and PTY environment admission distinct through Standard Schema expect(ajv.compile(nativePublished)(environment)).toBe(true); expect(ajv.compile(ptyPublished)(environment)).toBe(false); }); + +const planWith = (events: unknown[]) => { + // `dry_run` selects the operation, not the scenario, so a plan omits it. + const { dry_run: _dryRun, ...scenario } = processScenarioSchema.parse({ + ...baseScenario, + events: [{ at_ms: 1, type: "signal", signal: "SIGINT" }], + }); + return { + status: "planned", + ambient_environment: "inherited-at-run", + scenario: { ...scenario, events }, + }; +}; + +it("rejects a plan whose events are out of order, as the input contract does", () => { + // A plan says the scenario was validated, so it cannot accept an ordering + // the input contract refuses (codex review on rea#8469). + expect( + processScenarioPlanSchema.safeParse( + planWith([ + { at_ms: 20, type: "signal", signal: "SIGINT" }, + { at_ms: 10, type: "signal", signal: "SIGTERM" }, + ]), + ).success, + ).toBe(false); +}); + +it("rejects a plan whose event lands after the scenario timeout", () => { + const plan = planWith([{ at_ms: 900, type: "signal", signal: "SIGINT" }]); + plan.scenario.timeout_ms = 100; + expect(processScenarioPlanSchema.safeParse(plan).success).toBe(false); +}); + +it("rejects a plan whose terminal exceeds the renderer cell budget", () => { + const plan = planWith([]); + plan.scenario.terminal = { columns: 65_535, rows: 65_535, scrollback: 1_000 }; + expect(processScenarioPlanSchema.safeParse(plan).success).toBe(false); +}); + +it("rejects a sensitive input event that still carries its data", () => { + expect( + processScenarioPlanSchema.safeParse( + planWith([{ at_ms: 1, type: "input", data: "hunter2", sensitive: true }]), + ).success, + ).toBe(false); +}); + +it("requires a sensitive input event to report its byte length", () => { + expect( + processScenarioPlanSchema.safeParse( + planWith([{ at_ms: 1, type: "input", data: "", sensitive: true }]), + ).success, + ).toBe(false); +}); + +it("accepts the projection the producer actually emits", () => { + expect( + processScenarioPlanSchema.safeParse( + planWith([ + { at_ms: 1, type: "input", data: "", sensitive: true, input_bytes: 7 }, + { at_ms: 2, type: "input", data: "plain", sensitive: false }, + ]), + ).success, + ).toBe(true); +}); diff --git a/src/domain/process/processScenario.ts b/src/domain/process/processScenario.ts index 68c0bb17c9..f1ee910fcc 100644 --- a/src/domain/process/processScenario.ts +++ b/src/domain/process/processScenario.ts @@ -122,9 +122,12 @@ export const processScenarioCommitment = ( scenario: ProcessScenario, executableSha256?: string, ): Readonly> => { - // A zero interval is today's immediate kill; leaving it out keeps the - // committed identity of every scenario that predates the field unchanged. - const { finalization_ms: finalizationMs, ...committed } = scenario; + // dry_run selects a preparation operation, not a different scenario. + const { + dry_run: _dryRun, + finalization_ms: finalizationMs, + ...committed + } = scenario; return { ...committed, ...(finalizationMs > 0 ? { finalization_ms: finalizationMs } : {}), @@ -181,6 +184,12 @@ export const processScenarioSchema = z .strictObject({ executable: childProcessString.min(1), arguments: z.array(childProcessString).default([]), + dry_run: z + .boolean() + .default(false) + .describe( + "Validate and return the resolved scenario without launching it. A planned result is not capture Evidence and records no process observation.", + ), working_directory: childProcessString.default("."), environment: processEnvironmentSchema.default({}), filesystem_observation_paths: z @@ -278,64 +287,81 @@ export const processScenarioSchema = z }), }) .superRefine((scenario, context) => { - // Each budget is a safe integer, but their sum is not checked anywhere - // else. BigInt keeps the comparison exact beyond the representable range. - const lifecycleBudget = - BigInt(scenario.timeout_ms) + - BigInt(scenario.finalization_ms) + - BigInt(scenario.settle_ms); - if (lifecycleBudget > BigInt(Number.MAX_SAFE_INTEGER)) + // Shared with the plan schema below: a resolved plan claims it was + // validated, so it must reject the same impossible scenarios this + // contract does. + scenarioRefinements(scenario, context); + }); + +/** The fields the refinements read; present on both the input and plan shapes. */ +type ScenarioRefinementTarget = Pick< + ProcessScenario, + "timeout_ms" | "finalization_ms" | "settle_ms" | "terminal" | "events" +>; + +/** The constraints every scenario shape must satisfy, input or resolved. */ +const scenarioRefinements = ( + scenario: ScenarioRefinementTarget, + context: z.RefinementCtx, +): void => { + // Each budget is a safe integer, but their sum is not checked anywhere + // else. BigInt keeps the comparison exact beyond the representable range. + const lifecycleBudget = + BigInt(scenario.timeout_ms) + + BigInt(scenario.finalization_ms) + + BigInt(scenario.settle_ms); + if (lifecycleBudget > BigInt(Number.MAX_SAFE_INTEGER)) + context.addIssue({ + code: "custom", + message: `timeout_ms + finalization_ms + settle_ms must stay at or below ${String(Number.MAX_SAFE_INTEGER)} ms, the largest total exactly representable as a JavaScript number`, + path: ["timeout_ms"], + }); + if ( + !fitsProcessTerminalCellBudget( + scenario.terminal.columns, + scenario.terminal.rows, + scenario.terminal.scrollback, + ) + ) + context.addIssue({ + code: "custom", + path: ["terminal"], + message: `terminal columns × (rows + scrollback) must not exceed ${String(PROCESS_TERMINAL_CELL_BUDGET)} renderer cells. This release has no runtime override.`, + }); + for (let index = 1; index < scenario.events.length; index += 1) { + const event = scenario.events[index]; + const previous = scenario.events[index - 1]; + if (eventsOutOfOrder(event, previous)) { context.addIssue({ code: "custom", - message: `timeout_ms + finalization_ms + settle_ms must stay at or below ${String(Number.MAX_SAFE_INTEGER)} ms, the largest total exactly representable as a JavaScript number`, - path: ["timeout_ms"], + message: "events must be ordered by at_ms", + path: ["events", index], }); + } + } + for (const [index, event] of scenario.events.entries()) { if ( + event.type === "resize" && !fitsProcessTerminalCellBudget( - scenario.terminal.columns, - scenario.terminal.rows, + event.columns, + event.rows, scenario.terminal.scrollback, ) ) context.addIssue({ code: "custom", - path: ["terminal"], - message: `terminal columns × (rows + scrollback) must not exceed ${String(PROCESS_TERMINAL_CELL_BUDGET)} renderer cells. This release has no runtime override.`, + path: ["events", index], + message: `resize event at ${String(event.at_ms)} ms exceeds the ${String(PROCESS_TERMINAL_CELL_BUDGET)}-cell renderer budget with the selected scrollback. This release has no runtime override.`, + }); + if (event.at_ms > scenario.timeout_ms) { + context.addIssue({ + code: "custom", + message: `event at ${String(event.at_ms)} ms occurs after the ${String(scenario.timeout_ms)} ms scenario timeout`, + path: ["events", index], }); - for (let index = 1; index < scenario.events.length; index += 1) { - const event = scenario.events[index]; - const previous = scenario.events[index - 1]; - if (eventsOutOfOrder(event, previous)) { - context.addIssue({ - code: "custom", - message: "events must be ordered by at_ms", - path: ["events", index], - }); - } } - for (const [index, event] of scenario.events.entries()) { - if ( - event.type === "resize" && - !fitsProcessTerminalCellBudget( - event.columns, - event.rows, - scenario.terminal.scrollback, - ) - ) - context.addIssue({ - code: "custom", - path: ["events", index], - message: `resize event at ${String(event.at_ms)} ms exceeds the ${String(PROCESS_TERMINAL_CELL_BUDGET)}-cell renderer budget with the selected scrollback. This release has no runtime override.`, - }); - if (event.at_ms > scenario.timeout_ms) { - context.addIssue({ - code: "custom", - message: `event at ${String(event.at_ms)} ms occurs after the ${String(scenario.timeout_ms)} ms scenario timeout`, - path: ["events", index], - }); - } - } - }); + } +}; const eventsOutOfOrder = ( event: { readonly at_ms: number } | undefined, @@ -343,5 +369,74 @@ const eventsOutOfOrder = ( ): boolean => event !== undefined && previous !== undefined && event.at_ms < previous.at_ms; +/** Project a resolved scenario for planning without exposing sensitive input bytes. */ +export const processScenarioPlanProjection = ( + scenario: ProcessScenario | Omit, +) => { + const { dry_run: _dryRun, ...rest } = scenario as ProcessScenario; + return { + ...rest, + events: committedScenarioEvents(rest.events, true), + }; +}; + /** Parsed instructions and resource bounds for one process experiment. */ export type ProcessScenario = z.infer; + +// A plain union, not a discriminated one: the two `input` variants split on +// `sensitive`, which a `type`-only discriminator cannot distinguish. +const processScenarioPlanEventSchema = z.union([ + // Public input keeps its bytes; a sensitive input is projected to an empty + // string plus its length. Two exact variants, so the named output contract + // rejects a response that marks an event sensitive while shipping its data + // (codex review on rea#8469). + z.strictObject({ + ...timedEventBase, + type: z.literal("input"), + data: z.string(), + sensitive: z.literal(false).default(false), + input_bytes: z.never().optional(), + }), + z.strictObject({ + ...timedEventBase, + type: z.literal("input"), + data: z.literal(""), + sensitive: z.literal(true), + input_bytes: z.int().safe().nonnegative(), + }), + z.strictObject({ + ...timedEventBase, + type: z.literal("resize"), + columns: z.int().min(PROCESS_TERMINAL_MIN_COLUMNS).max(65_535), + rows: z.int().min(1).max(65_535), + }), + z.strictObject({ + ...timedEventBase, + type: z.literal("signal"), + signal: z.enum(["SIGINT", "SIGTERM", "SIGKILL"]), + }), +]); + +// The plan's `events` differ from the input contract (a sensitive input is +// projected), but every other field is the same contract, and the scenario +// bounds hold for a resolved plan just as they do for an input scenario. +const { + dry_run: _dryRun, + events: _events, + ...processScenarioPlanShape +} = processScenarioSchema.shape; + +/** Resolved, non-executing scenario returned by a dry run. */ +export const processScenarioPlanSchema = z.strictObject({ + status: z.literal("planned"), + scenario: z + .strictObject({ + ...processScenarioPlanShape, + events: z.array(processScenarioPlanEventSchema).default([]), + }) + // A plan claims the scenario was validated, so it must reject the + // impossible ones the input contract rejects: unordered events, an event + // past timeout_ms, or a resize/terminal beyond the renderer budget. + .superRefine(scenarioRefinements), + ambient_environment: z.literal("inherited-at-run"), +}); diff --git a/src/process/capture/FilesystemSnapshot.ts b/src/process/capture/FilesystemSnapshot.ts index 06366fa376..49509d25c0 100644 --- a/src/process/capture/FilesystemSnapshot.ts +++ b/src/process/capture/FilesystemSnapshot.ts @@ -31,7 +31,7 @@ import { withProcessCaptureFile, type ProcessCaptureCleanupRetainer, } from "./ProcessCaptureRead.js"; -import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; const hasSameIdentity = ( @@ -150,6 +150,43 @@ const lookupPath = async ( return { state: "present", stats }; }; +/** Admit selected observation roots without allocating or walking a snapshot. */ +export const admitFilesystemObservationRoots = async ( + scenario: Pick, + signal?: AbortSignal, +): Promise> => { + try { + for (const root of scenario.filesystem_observation_paths) { + const lookup = await lookupPath(root, signal); + if (lookup.state === "present") continue; + if (lookup.system_code === "ENOENT") continue; + if (lookup.system_code === "EACCES" || lookup.system_code === "EPERM") + return err( + new AnalysisAccessDeniedError( + "capture_process_scenario", + root, + lookup.system_code, + { cause: lookup.cause }, + ), + ); + throw lookup.cause; + } + return ok(undefined); + } catch (cause: unknown) { + const normalized = + cause instanceof AnalysisError + ? cause + : normalizeCaptureFailure(cause, signal); + return err( + normalized instanceof AnalysisError + ? normalized + : new ProcessCaptureError("filesystem observation admission failed", { + cause, + }), + ); + } +}; + /** Reports a complete digest or an explicit per-file reason hashing was omitted. */ export type FileHashOutcome = | { readonly state: "hashed"; readonly sha256: string } diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index e306ca85d2..086ab57572 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -10,7 +10,11 @@ import { inspectAddressContext, } from "../application/AnalysisContextQueries.js"; import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js"; -import { createProcessCaptureEvidence } from "../application/process/ProcessEvidence.js"; +import { + createProcessCaptureEvidence, + createProcessScenarioPlanEvidence, +} from "../application/process/ProcessEvidence.js"; +import { prepareProcessScenarioPlan } from "../application/process/ProcessScenarioPreparation.js"; import { captureProcessScenario } from "../process/capture/ProcessHarness.js"; import { ProcessCaptureResourceScope } from "../process/capture/ProcessCaptureLifecycle.js"; import { ProcessCaptureError } from "../process/capture/ProcessCaptureError.js"; @@ -110,6 +114,39 @@ const registerProcessTools = ({ context.mcpReq.signal, async () => { const progress = mcpProgressReporter(context); + if (input.dry_run) { + const prepared = await logToolExecution( + logger, + captureContract.name, + () => + prepareProcessScenarioPlan( + input, + environment, + process.platform, + context.mcpReq.signal, + ), + ); + if (!prepared.ok) + return server.delivery.toCallToolResult( + prepared, + captureContract, + ); + const evidence = createProcessScenarioPlanEvidence( + prepared.value, + process.platform, + ); + const recorded = session.recordEvidence(evidence); + if (!recorded.ok) + return server.delivery.toCallToolResult( + recorded, + captureContract, + ); + return server.delivery.toEvidenceToolResult( + evidence, + captureContract, + recorded, + ); + } const captured = await logToolExecution( logger, captureContract.name, diff --git a/tests/acceptance/tools/localToolContracts.test.ts b/tests/acceptance/tools/localToolContracts.test.ts index d3c1be400c..5389ab81ea 100644 --- a/tests/acceptance/tools/localToolContracts.test.ts +++ b/tests/acceptance/tools/localToolContracts.test.ts @@ -8,6 +8,7 @@ import { expect, it } from "vitest"; import { toolContract } from "../../../src/contracts/toolContracts.js"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/processCaptureExample.js"; +import type { ProcessCapture } from "../../../src/domain/process/processCaptureParsing.js"; import { PROCESS_PROVIDER } from "../../../src/domain/process/processEvidenceProvider.js"; import { itWithCaptureCapability as captureTest } from "../../boundary/process/processCaptureCapability.js"; import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; @@ -23,6 +24,15 @@ const electron = { application_path: "/tmp/main.js", }; +const captureResult = (value: unknown): ProcessCapture => { + const normalized = toolContract( + "capture_process_scenario", + ).outputSchema.parse(value).normalized_result; + if ("status" in normalized && normalized.status === "planned") + throw new Error("expected process capture output"); + return normalized as ProcessCapture; +}; + it("keeps overall replay verification unknown when capture divergence is unknown", async () => { const { call } = await connectLocalToolsMcp(); const captureEvidence = ( @@ -531,15 +541,15 @@ captureTest.each(["untruncated", "truncated"] as const)( limits: captureState === "truncated" ? { output_bytes: 128 } : {}, }); expect(captured.isError, JSON.stringify(captured)).not.toBe(true); - const capture = toolContract("capture_process_scenario").outputSchema.parse( + const source = toolContract("capture_process_scenario").outputSchema.parse( captured.structuredContent, ); - const source = capture; + const capture = captureResult(source); if (captureState === "untruncated") - expect( - capture.normalized_result.frames.map((frame) => frame.data).join(""), - ).toContain("value ordinary-evidence caller-value"); - else expect(capture.normalized_result.truncated).toBe(true); + expect(capture.frames.map((frame) => frame.data).join("")).toContain( + "value ordinary-evidence caller-value", + ); + else expect(capture.truncated).toBe(true); const environment = await call("capture_process_scenario", { executable: "/usr/bin/printenv", arguments: ["1"], @@ -548,15 +558,15 @@ captureTest.each(["untruncated", "truncated"] as const)( const environmentEnvelope = toolContract( "capture_process_scenario", ).outputSchema.parse(environment.structuredContent); - const received = environmentEnvelope.normalized_result.frames - .map((frame) => frame.data) + const received = captureResult(environmentEnvelope) + .frames.map((frame) => frame.data) .join(""); expect(received).toBe("numbered\r\n"); expect(await readFile(join(root, "state"), "utf8")).toBe("selected"); - expect(capture.normalized_result.filesystem_effects).toEqual( + expect(capture.filesystem_effects).toEqual( expect.arrayContaining([expect.objectContaining({ status: "created" })]), ); - expect(capture.normalized_result.cleanup).toMatchObject({ + expect(capture.cleanup).toMatchObject({ owned_process_group: "verified", temporary_root: "removed", }); @@ -662,18 +672,16 @@ captureTest( events: [{ type: "input", at_ms: 1000, data: "\0" }], }); expect(response.isError, JSON.stringify(response)).not.toBe(true); - const capture = toolContract("capture_process_scenario").outputSchema.parse( - response.structuredContent, + const capture = captureResult(response.structuredContent); + expect(capture.frames.map((frame) => frame.data).join("")).toContain( + "stdin-byte:0", ); - expect( - capture.normalized_result.frames.map((frame) => frame.data).join(""), - ).toContain("stdin-byte:0"); - expect(capture.normalized_result.manifest.scenario).toMatchObject({ + expect(capture.manifest.scenario).toMatchObject({ environment: {}, filesystem_observation_paths: [], terminal: { columns: 80, rows: 24, scrollback: 1000 }, }); - expect(capture.normalized_result.interaction_events).toEqual( + expect(capture.interaction_events).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "input", diff --git a/tests/boundary/mcp/promptMcp.test.ts b/tests/boundary/mcp/promptMcp.test.ts index ee76be8dd5..652db8cfbf 100644 --- a/tests/boundary/mcp/promptMcp.test.ts +++ b/tests/boundary/mcp/promptMcp.test.ts @@ -258,6 +258,7 @@ function registerPromptRenderingContracts(): void { "expected observations, timeout, and cleanup", ); expect(content.text).toContain("do not authorize execution"); + expect(content.text).toContain("dry_run"); expect(content.text).toContain("not a required sequence"); expect(content.text).toContain("never as authorization"); expect(content.text).not.toContain("Run the caller-provided scenario"); diff --git a/tests/boundary/process/ProcessEvidence.test.ts b/tests/boundary/process/ProcessEvidence.test.ts index 914106e351..8ffec42355 100644 --- a/tests/boundary/process/ProcessEvidence.test.ts +++ b/tests/boundary/process/ProcessEvidence.test.ts @@ -1,6 +1,9 @@ import { expect, it } from "vitest"; -import { createProcessCaptureEvidence } from "../../../src/application/process/ProcessEvidence.js"; +import { + createProcessCaptureEvidence, + createProcessScenarioPlanEvidence, +} from "../../../src/application/process/ProcessEvidence.js"; import { createRunManifest, observeLaunchedExecutable, @@ -14,6 +17,7 @@ import { compareProcessCaptures } from "../../../src/domain/process/processCompa import { parseProcessCapture } from "../../../src/domain/process/processCaptureParsing.js"; import { digestProcessCommitment, + processScenarioPlanSchema, processScenarioSchema, processComparisonContract, processScenarioCommitment, @@ -112,6 +116,25 @@ it("does not persist sensitive scripted input in process capture Evidence", () = expect(serialized).not.toContain(secret); }); +it("does not persist sensitive scripted input in planned Evidence", () => { + const secret = "top-secret-planned-input"; + const scenario = processScenarioSchema.parse({ + executable: "/bin/echo", + working_directory: "/tmp", + events: [{ type: "input", at_ms: 0, data: secret, sensitive: true }], + }); + const evidence = createProcessScenarioPlanEvidence(scenario, "darwin"); + + expect(JSON.stringify(evidence)).not.toContain(secret); + const plan = processScenarioPlanSchema.parse(evidence.normalized_result); + expect(plan.scenario.events[0]).toMatchObject({ + type: "input", + data: "", + sensitive: true, + input_bytes: Buffer.byteLength(secret), + }); +}); + it("does not treat captures with different redacted inputs as equivalent", () => { const left = captureEvidenceForSensitiveInput("secret-left"); const right = captureEvidenceForSensitiveInput("secret-right"); diff --git a/tests/boundary/process/processCaptureProgress.test.ts b/tests/boundary/process/processCaptureProgress.test.ts index 79a898e9b0..e380f6b961 100644 --- a/tests/boundary/process/processCaptureProgress.test.ts +++ b/tests/boundary/process/processCaptureProgress.test.ts @@ -10,6 +10,7 @@ import { analysisCliErrorEnvelopeSchema } from "../../../src/contracts/errorSche import { toolContract } from "../../../src/contracts/toolContracts.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { parseProcessCapture } from "../../../src/domain/process/processCaptureParsing.js"; +import type { ProcessCapture } from "../../../src/domain/process/processCaptureParsing.js"; import { silentLogger } from "../../../src/logger.js"; import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; @@ -18,6 +19,15 @@ import { itWithCaptureCapability } from "./processCaptureCapability.js"; const PROGRESS_INTERVAL_MS = 100; +const captureResult = (value: unknown): ProcessCapture => { + const normalized = toolContract( + "capture_process_scenario", + ).outputSchema.parse(value).normalized_result; + if ("status" in normalized && normalized.status === "planned") + throw new Error("expected process capture output"); + return normalized as ProcessCapture; +}; + const progressLineSchema = z.object({ rea_progress: z.object({ phase: z.string(), @@ -462,7 +472,9 @@ itWithCaptureCapability( "capture_process_scenario", ).outputSchema.parse(silent.structuredContent); expect( - evidence.normalized_result.frames.map((frame) => frame.data).join(""), + captureResult(evidence) + .frames.map((frame) => frame.data) + .join(""), ).toContain("SILENT_CHILD_OK"); const progress: Array<{ @@ -518,12 +530,11 @@ itWithCaptureCapability( const tokenEvidence = toolContract( "capture_process_scenario", ).outputSchema.parse(captured.structuredContent); - const output = tokenEvidence.normalized_result.frames - .map((frame) => frame.data) - .join(""); + const tokenCapture = captureResult(tokenEvidence); + const output = tokenCapture.frames.map((frame) => frame.data).join(""); expect(output).toContain("TOKEN_CHILD_STARTED"); expect(output).toContain("TOKEN_CHILD_DONE"); - expect(tokenEvidence.normalized_result.exit).toMatchObject({ + expect(tokenCapture.exit).toMatchObject({ reason: "exited", code: 0, }); @@ -562,7 +573,7 @@ itWithCaptureCapability( const timedOutEvidence = toolContract( "capture_process_scenario", ).outputSchema.parse(timedOut.structuredContent); - expect(timedOutEvidence.normalized_result.exit.reason).toBe("timeout"); + expect(captureResult(timedOutEvidence).exit.reason).toBe("timeout"); expect(timeoutProgress.every(({ total }) => total === undefined)).toBe( true, ); diff --git a/tests/boundary/process/processCli.test.ts b/tests/boundary/process/processCli.test.ts index 618c009914..7b6dc3871b 100644 --- a/tests/boundary/process/processCli.test.ts +++ b/tests/boundary/process/processCli.test.ts @@ -1,4 +1,4 @@ -import { readFile, readdir, writeFile } from "node:fs/promises"; +import { access, readFile, readdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -379,6 +379,36 @@ describe("process CLI evidence validation", () => { ).toContain("minimal-capture"); }); + it("plans the minimal scenario without launching it", async () => { + const root = await createTestTempDirectory("rea-process-cli-"); + const scenario = join(root, "scenario.json"); + const marker = join(root, "launched"); + await writeFile( + scenario, + JSON.stringify({ + executable: process.execPath, + arguments: [ + "-e", + "require('node:fs').writeFileSync(process.argv[1], 'launched')", + marker, + ], + dry_run: true, + }), + ); + + const evidence = await captureProcessScenarioFile(scenario); + expect(evidence).toMatchObject({ + predicate_type: "rea.process-scenario-plan", + operation: "capture_process_scenario", + normalized_result: { + status: "planned", + ambient_environment: "inherited-at-run", + scenario: { executable: process.execPath }, + }, + }); + await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("rejects unrelated capture evidence", async () => { const root = await createTestTempDirectory("rea-process-cli-"); const invalidCapture = join(root, "invalid-capture.json"); diff --git a/tests/boundary/process/processSessionMcp.test.ts b/tests/boundary/process/processSessionMcp.test.ts index 094757bd90..8205469017 100644 --- a/tests/boundary/process/processSessionMcp.test.ts +++ b/tests/boundary/process/processSessionMcp.test.ts @@ -14,6 +14,7 @@ import { } from "../../fixtures/binarySession.js"; import { createServer } from "../../../src/server/createServer.js"; import { silentLogger } from "../../../src/logger.js"; +import type { ProcessCapture } from "../../../src/domain/process/processCaptureParsing.js"; import { itWithCaptureCapability } from "./processCaptureCapability.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -22,6 +23,15 @@ const processFixture = fileURLToPath( ); const resources: Array<{ close(): Promise }> = []; +const captureOutput = (value: unknown): ProcessCapture => { + const normalized = toolContract( + "capture_process_scenario", + ).outputSchema.parse(value).normalized_result; + if ("status" in normalized && normalized.status === "planned") + throw new Error("expected process capture output"); + return normalized as ProcessCapture; +}; + afterEach(async () => { await Promise.all(resources.splice(0).map((resource) => resource.close())); }); @@ -70,6 +80,53 @@ it("rejects oversized initial and resize terminal buffers through MCP before lau await client.ping(); }); +itWithCaptureCapability( + "returns a planned scenario without launching the target", + async () => { + const root = await createTestTempDirectory("rea-process-plan-"); + const marker = join(root, "launched"); + const session = createTestBinarySession(() => { + throw new Error("A dry run must not launch a binary provider"); + }); + const server = createServer( + { kind: "session", session }, + { logger: silentLogger }, + ); + const client = new Client({ name: "process-plan", version: "1" }); + resources.push(client, server, session); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const result = await client.callTool({ + name: "capture_process_scenario", + arguments: { + executable: process.execPath, + working_directory: root, + arguments: [ + "-e", + "require('node:fs').writeFileSync(process.argv[1], 'launched')", + marker, + ], + dry_run: true, + }, + }); + + expect(result.isError, JSON.stringify(result.content)).not.toBe(true); + await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" }); + const parsed = toolContract("capture_process_scenario").outputSchema.parse( + result.structuredContent, + ); + expect(parsed.predicate_type).toBe("rea.process-scenario-plan"); + expect(parsed.normalized_result).toMatchObject({ + status: "planned", + ambient_environment: "inherited-at-run", + scenario: { executable: process.execPath }, + }); + }, +); + itWithCaptureCapability( "bounds normalized PTY text and preserves equal-size output through MCP", async () => { @@ -109,9 +166,7 @@ itWithCaptureCapability( }, }); expect(result.isError, JSON.stringify(result.content)).not.toBe(true); - return toolContract("capture_process_scenario").outputSchema.parse( - result.structuredContent, - ).normalized_result; + return captureOutput(result.structuredContent); }; const expanded = await capture("x".repeat(500)); @@ -286,9 +341,7 @@ itWithCaptureCapability( }, }); expect(result.isError, JSON.stringify(result.content)).not.toBe(true); - const capture = toolContract("capture_process_scenario").outputSchema.parse( - result.structuredContent, - ).normalized_result; + const capture = captureOutput(result.structuredContent); expect(capture.frames.map(({ data }) => data).join("")).toContain( "injected-value|scenario-value", ); From 55ee36809d2fc15c25fd25b342757348413206dd Mon Sep 17 00:00:00 2001 From: RRFZ <247457381+Plastic-time@users.noreply.github.com> Date: Fri, 6 Nov 2026 07:49:41 -0500 Subject: [PATCH 065/151] fix(javascript): apply package type scope to application sources (#7883) * fix(javascript): apply package type scope to application sources Application analysis parsed .js sources without their package scope, so an unbound require in a type-module package became a resolved CommonJS dependency. The parent now observes each source's nearest same-container package.json, stopping at node_modules, and the worker parses with that package type as reference imports already do. Fixes #7323 * fix(javascript): keep package type unknown behind unavailable manifests The package-type lookup indexed only readable files, so a nearer package.json whose bytes were unavailable was skipped and an ancestor's "type" applied to sources it does not own. Index unavailable manifests by container and directory too, and stop the walk with "unknown" when one is the nearest owner, as the Next app-context lookup already does. * fix(javascript): keep Electron preloads outside module package scope Electron's preload loader ignores a package's "type": "module" and gives a .js preload `require`; ESM preloads need the .mjs suffix. Applying the package type to every .js source marked such a preload as an ES module, so its `require("electron")` edges and IPC pairing disappeared. A source that requires an Electron module now keeps the context REA inferred before package scope was applied. Node-loaded sources in a module scope still drop unbound `require`. * fix(javascript): admit Electron script loaders only on renderer evidence The previous commit dropped a module package type whenever a source required an Electron module. A Node-loaded ES module that requires Electron, or one whose Electron call goes through a local `require`, then regained the false require edges the package scope removes. The parser applies the package type unconditionally again. The worker re-reads a package-module script that requires an Electron module once outside the package scope. The source keeps that context only when the static pass there directly observes a contextBridge exposure or a renderer IPC operation, which only Electron's renderer-side loaders provide. * fix(javascript): check Electron renderer evidence without a second static pass b54845a6b ran the full static pass on an unscoped parse of every package-module script that requires Electron, then discarded it when no renderer operation turned up, so such main-process sources paid for two full passes. A candidate must now also name contextBridge or ipcRenderer, which any renderer evidence needs. The evidence check runs only the static pass's binding and Electron identity preamble with its exposure and IPC descriptors. The full static pass runs once, on the chosen parse. * fix(javascript): confirm Electron renderer evidence with the static pass The focused evidence check from 4a86bca9b also counted renderer calls in bundler factory defaults that the bundler convention never runs, which the static pass skips. Such a default switched a type-module file to script context, so its unrelated unbound requires became imports again. The focused check now only screens. It follows the static pass's own traversal, so a negative result still rules evidence out. A positive result is confirmed by the static pass's direct findings on the unscoped parse, and those findings become the file's facts when they hold. * fix(javascript): confirm renderer evidence before wrapper substitution f71236400 confirmed a screened source from the static pass's final contextBridge and IPC findings, without wrapper substitutions. The wrapper pass replaces a directly observed exposure inside a helper with a substituted finding, so such a preload lost its imports again. Before its wrapper pass, the static pass now records whether its source traversal directly observed a contextBridge exposure or a renderer IPC operation, and observedElectronRendererCall exposes that. The traversal still skips bundler factory defaults that never run, and the reported findings are unchanged. * fix(javascript): resolve declared preload loaders before source analysis * fix(javascript): leave preload discovery failures to source analysis A source that failed during preload discovery threw out of the whole application analysis, so one oversized source or a per-source timeout in a module package returned no Evidence. Skip a source that exceeds its worker limits and end discovery on any other failure except cancellation; source analysis then meets the same source, records its failure and keeps partial Evidence as it does without discovery. * fix(javascript): report preload discovery gaps in Evidence limitations When preload discovery skipped a source or stopped early, source analysis could still read that source, so the result looked complete while a .js preload declared there kept its package scope. Return each discovery gap with the discovered paths and add it to the analysis limitations, which both complete and partial Evidence carry. --- docs/javascript-artifact-reconstruction.md | 17 ++ ...ScriptArtifactAnalysis.packageType.test.ts | 83 +++++++ .../javascript/JavaScriptArtifactAnalysis.ts | 69 +++++- .../JavaScriptArtifactReconstruction.ts | 30 ++- .../javascript/JavaScriptJsonDocuments.ts | 1 + .../javascript/JavaScriptPreloadDiscovery.ts | 135 ++++++++++++ .../javascriptAnalysisWorkerProcess.ts | 24 +- .../javascript/javascriptSourceAnalysis.ts | 5 + .../javascript/javascriptSourceParser.ts | 66 ++++-- .../analysis/JavaScriptAnalysisWorker.ts | 5 + .../JavaScriptAnalysisWorkerProtocol.ts | 3 + .../JavaScriptAnalysisWorkerServer.test.ts | 2 + .../JavaScriptAnalysisWorkerServer.ts | 5 + .../javascriptAnalysisLimits.test.ts | 35 +++ .../applications/unboundRequires.test.ts | 188 ++++++++++++++++ .../javascriptPreloadDiscovery.test.ts | 208 ++++++++++++++++++ 16 files changed, 844 insertions(+), 32 deletions(-) create mode 100644 src/application/javascript/JavaScriptArtifactAnalysis.packageType.test.ts create mode 100644 src/application/javascript/JavaScriptPreloadDiscovery.ts create mode 100644 tests/boundary/javascript/javascriptPreloadDiscovery.test.ts diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 3381815cc5..2cd393c378 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -347,6 +347,23 @@ counterparts while resolving to inventoried source files. This substitution requires import or require semantics. Literal Electron preload, renderer, and utility-process runtime paths retain the runtime filename selected by the caller. +Application `.js` and `.ts` sources use the nearest readable, same-container +`package.json` type; explicit module/CommonJS suffixes keep their own meaning. +An unavailable nearer manifest leaves the package type unknown. When a module +package contains `.js` sources, a preliminary pass through the bounded worker +collects BrowserWindow preload declarations. Exact same-container `.js` +preloads use the script-loader context even when their only operation is a +helper `require`. Renderer API names alone never select that context. Dynamic, +unresolved, excluded-source, and cross-container declarations do not establish +a preload identity; this static association is not proof that Electron loaded +the file. Discovery performs another static pass, while semantic projection +runs once and existing heap, deadline, cancellation, and cleanup controls remain. +A source that exceeds its worker limits during discovery contributes no +declarations, and any other discovery failure ends discovery; source analysis +then records that failure and retains partial Evidence as usual. Either gap is +also listed in the Evidence limitations, because source analysis may still read +that source after a declared preload has kept its package scope. + Selected bounded text is then parsed as inert data to recover: - `package.json` metadata and declared main or renderer entrypoints; diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.packageType.test.ts b/src/application/javascript/JavaScriptArtifactAnalysis.packageType.test.ts new file mode 100644 index 0000000000..920769b4e6 --- /dev/null +++ b/src/application/javascript/JavaScriptArtifactAnalysis.packageType.test.ts @@ -0,0 +1,83 @@ +import { expect, it } from "vitest"; +import { analyzeAndAdoptJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js"; +import { createJavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js"; +import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js"; +import type { + JavaScriptArtifactFile, + JavaScriptUnanalyzedArtifactFile, +} from "../../domain/javascript/javascriptArtifactFiles.js"; +import type { JavaScriptPackageType } from "../../domain/javascript/javascriptSourceParser.js"; +import { ok } from "../../domain/result.js"; + +const container = "a".repeat(64); + +const included = ( + path: string, + kind: JavaScriptArtifactFile["kind"], + text: string, +): JavaScriptArtifactFile => ({ + path, + container_sha256: container, + sha256: "b".repeat(64), + bytes: Buffer.byteLength(text), + inventory_artifact_id: path, + kind, + unpacked: false, + text: { included: true, value: text }, +}); + +it("leaves the package type unknown behind a nearer unavailable manifest", async () => { + const files = [ + included("package.json", "package-json", '{"type":"module"}'), + included("main.js", "javascript", 'require("./dep.cjs");'), + included("nested/main.js", "javascript", 'require("../dep.cjs");'), + included( + "nested/inner/package.json", + "package-json", + '{"type":"commonjs"}', + ), + included("nested/inner/main.js", "javascript", 'require("../../dep.cjs");'), + ]; + const unavailable: JavaScriptUnanalyzedArtifactFile = { + path: "nested/package.json", + container_sha256: container, + sha256: null, + bytes: null, + inventory_artifact_id: null, + kind: "package-json", + limitations: ["Inventoried package bytes are unavailable."], + }; + const observed = new Map(); + const javascript = analyzeJavaScriptStaticSource(""); + const worker = { + analyze: async ( + file: JavaScriptArtifactFile, + _capacity: unknown, + _signal?: AbortSignal, + _onPhase?: unknown, + packageType?: JavaScriptPackageType, + ) => { + observed.set(file.path, packageType); + return ok({ javascript, module: null, projection: null }); + }, + }; + await analyzeAndAdoptJavaScriptArtifactFiles( + { + files, + unavailable_relevant_files: [unavailable], + containers: [], + text_bytes_read: 0, + invalid_utf8_files: 0, + }, + worker, + createJavaScriptSemanticGraphProjection(), + async () => {}, + ); + // The nearest package.json owns a source's scope even when its bytes are + // unavailable, so the root type must not reach nested/main.js. + expect(Object.fromEntries(observed)).toEqual({ + "main.js": "module", + "nested/main.js": "unknown", + "nested/inner/main.js": "commonjs", + }); +}); diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index 52f7858c4e..9c863211aa 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -1,7 +1,10 @@ import { createHash } from "node:crypto"; +import { posix } from "node:path"; import { setImmediate } from "node:timers/promises"; +import type { JavaScriptPackageType } from "../../domain/javascript/javascriptSourceParser.js"; + import { resolveJavaScriptSourceMapReferences } from "../../domain/javascript/javascriptSourceMapPaths.js"; import { hasValidSourceMapContents } from "../../domain/sourceMapContents.js"; import { @@ -77,12 +80,19 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async ( ) => Promise, signal?: AbortSignal, onPhase?: (file: JavaScriptArtifactFile, phase: string) => Promise, + preloadPaths: ReadonlySet = new Set(), + /** Discovery gaps that may leave a declared preload in its package scope. */ + preloadLimitations: readonly string[] = [], ): Promise<{ readonly analysis: JavaScriptArtifactAnalysis; readonly interruption: AnalysisError | null; }> => { const state = emptyArtifactAnalysis(); const sourceIndices: number[] = []; + const packageTypeFor = createJavaScriptPackageTypeLookup( + fileSet, + state.jsonDocuments, + ); let interruption: AnalysisError | null = null; const selectedFileCount = fileSet.files.length - (fileSet.source_selection?.unselected_files ?? 0); @@ -118,6 +128,10 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async ( projection.remainingFileCapacity(), signal, onPhase === undefined ? undefined : (phase) => onPhase(file, phase), + preloadPaths.has(`${file.container_sha256}:${file.path}`) && + /\.js$/iu.test(file.path) + ? "commonjs" + : packageTypeFor(file), ); const facts = result.ok ? result.value : result.error; for (const revoked of facts.revokedStaticProjections ?? []) { @@ -294,11 +308,15 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async ( ); } } - return { analysis: finalizeArtifactAnalysis(state), interruption }; + return { + analysis: finalizeArtifactAnalysis(state, preloadLimitations), + interruption, + }; }; const finalizeArtifactAnalysis = ( state: MutableArtifactAnalysis, + preloadLimitations: readonly string[], ): JavaScriptArtifactAnalysis => { return { jsonDocuments: state.jsonDocuments, @@ -328,6 +346,7 @@ const finalizeArtifactAnalysis = ( (failure) => (failure === undefined ? [] : [failure.reason]), ), ), + ...preloadLimitations, ], }; }; @@ -386,6 +405,54 @@ const addStructuredObservations = ( if (json.status === "invalid") state.parseFailures += 1; }; +/** + * Node's package scope for a source is its nearest package.json; the lookup + * stops at a `node_modules` directory (Node's package scope resolution). Only + * a same-container, readable manifest object establishes the type; any other + * nearest manifest, including one whose bytes are unavailable, or none in the + * inventory, leaves the scope unknown. + */ +export const createJavaScriptPackageTypeLookup = ( + fileSet: JavaScriptArtifactFileSet, + jsonDocuments: JavaScriptJsonDocuments, +): ((file: JavaScriptArtifactFile) => JavaScriptPackageType) => { + const manifests = new Map(); + for (const file of fileSet.files) + if (posix.basename(file.path) === "package.json") + manifests.set( + `${file.container_sha256}:${posix.dirname(file.path)}`, + file, + ); + const unavailableManifests = new Set( + fileSet.unavailable_relevant_files + .filter(({ path }) => posix.basename(path) === "package.json") + .map( + ({ path, container_sha256 }) => + `${container_sha256}:${posix.dirname(path)}`, + ), + ); + return (file) => { + for ( + let directory = posix.dirname(file.path); + posix.basename(directory) !== "node_modules"; + directory = posix.dirname(directory) + ) { + const key = `${file.container_sha256}:${directory}`; + if (unavailableManifests.has(key)) return "unknown"; + const manifest = manifests.get(key); + if (manifest !== undefined) { + const document = jsonDocuments.read(manifest); + if (document.status !== "included" || document.packageFields === null) + return "unknown"; + const type = document.packageFields.type; + return type === "module" || type === "commonjs" ? type : "unspecified"; + } + if (directory === "." || directory === "" || directory === "/") break; + } + return "unknown"; + }; +}; + const parsePackage = ( file: JavaScriptArtifactFile, document: JavaScriptJsonDocumentFacts, diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index b035acfea6..11e993d88e 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -1,3 +1,4 @@ +import { discoverJavaScriptPreloadPaths } from "./JavaScriptPreloadDiscovery.js"; import { artifactNameForClassification } from "../../domain/artifactNameSelection.js"; import type { AnalysisSettingValues } from "../../domain/analysisSettings.js"; import { absoluteCallerPath } from "../../filesystem/callerPath.js"; @@ -180,17 +181,24 @@ export const reconstructJavaScriptArtifact = async ( ? {} : { fileNodes: input.max_semantic_nodes_per_file }), }); + const sourceLimits = { + heapMb: + input.max_heap_mb ?? + overrides?.["javascript.max_heap_mb"] ?? + JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.heapMb, + timeoutMs: + input.analysis_timeout_ms ?? + overrides?.["javascript.analysis_timeout_ms"] ?? + JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.timeoutMs, + }; + const preloads = await discoverJavaScriptPreloadPaths( + files, + sourceLimits, + scope, + signal, + ); const worker = await createJavaScriptSourceAnalysis( - { - heapMb: - input.max_heap_mb ?? - overrides?.["javascript.max_heap_mb"] ?? - JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.heapMb, - timeoutMs: - input.analysis_timeout_ms ?? - overrides?.["javascript.analysis_timeout_ms"] ?? - JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.timeoutMs, - }, + sourceLimits, files.files.filter( (file) => file.kind === "javascript" && file.text.included, ).length, @@ -229,6 +237,8 @@ export const reconstructJavaScriptArtifact = async ( }); abortIfNeeded(signal); }, + preloads.paths, + preloads.limitations, ); } finally { try { diff --git a/src/application/javascript/JavaScriptJsonDocuments.ts b/src/application/javascript/JavaScriptJsonDocuments.ts index 3700b29004..a4f96d1d45 100644 --- a/src/application/javascript/JavaScriptJsonDocuments.ts +++ b/src/application/javascript/JavaScriptJsonDocuments.ts @@ -54,6 +54,7 @@ const PACKAGE_PROPERTIES = [ "browser", "exports", "imports", + "type", ] as const; /** diff --git a/src/application/javascript/JavaScriptPreloadDiscovery.ts b/src/application/javascript/JavaScriptPreloadDiscovery.ts new file mode 100644 index 0000000000..ab2ab67425 --- /dev/null +++ b/src/application/javascript/JavaScriptPreloadDiscovery.ts @@ -0,0 +1,135 @@ +import { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; +import { + AnalysisCancelledError, + AnalysisResourceConstraintError, +} from "../../domain/analysisErrorCore.js"; +import { translateArtifactFailure } from "../../artifacts/ArtifactOperationFailure.js"; +import type { JavaScriptArtifactFileSet } from "../../domain/javascript/javascriptArtifactFiles.js"; +import type { JavaScriptSourceAnalysisLimits } from "../../domain/javascript/javascriptSourceAnalysis.js"; +import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { createJavaScriptSourceAnalysis } from "../../artifacts/javascript/JavaScriptSourceAnalysis.js"; +import { createJavaScriptPackageTypeLookup } from "./JavaScriptArtifactAnalysis.js"; +import { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js"; +import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js"; + +/** Declared preload identities, with the discovery gaps that may hide others. */ +export interface JavaScriptPreloadDiscovery { + readonly paths: ReadonlySet; + readonly limitations: readonly string[]; +} + +/** Resolve declared preload identities before choosing any .js file's loader. + * Discovery uses the same bounded worker and path owner as normal analysis. + * It never infers a loader from renderer API text in the selected file. + */ +export const discoverJavaScriptPreloadPaths = async ( + fileSet: JavaScriptArtifactFileSet, + limits: JavaScriptSourceAnalysisLimits, + scope: ArtifactResourceScope, + signal?: AbortSignal, +): Promise => { + const paths = new Set(); + const limitations: string[] = []; + const jsonDocuments = new JavaScriptJsonDocuments(); + const packageTypeFor = createJavaScriptPackageTypeLookup( + fileSet, + jsonDocuments, + ); + if ( + !fileSet.files.some( + (file) => + file.kind === "javascript" && + file.text.included && + /\.js$/iu.test(file.path) && + packageTypeFor(file) === "module", + ) + ) + return { paths, limitations }; + const sources = fileSet.files.filter( + (file) => file.kind === "javascript" && file.text.included, + ); + const files = new Map(fileSet.files.map((file) => [file.path, file])); + const worker = await createJavaScriptSourceAnalysis(limits, sources.length, { + scope, + }); + let outcome: { kind: "completed" } | { kind: "failed"; cause: unknown } = { + kind: "completed", + }; + try { + for (const [index, file] of sources.entries()) { + const result = await worker.analyze( + file, + { nodes: 0, unknowns: 0 }, + signal, + undefined, + packageTypeFor(file), + "preload-discovery", + ); + if (!result.ok) { + const { error } = result.error; + if (signal?.aborted === true || error instanceof AnalysisCancelledError) + throw error; + // Normal analysis meets the same source and owns its failure and the + // partial Evidence. A per-source limit only loses this source's + // declarations; any other failure would stop that pass here too. The + // gap stays reported even if normal analysis then reads the source. + const skipped = + error instanceof AnalysisResourceConstraintError && + error.reportedLimits?.parent_result_budget_exhausted !== true; + const later = sources.length - index - 1; + limitations.push( + skipped || later === 0 + ? `Preload discovery could not read ${file.path} (${error.message}); a .js preload declared only there keeps its package scope.` + : `Preload discovery stopped at ${file.path} (${error.message}); a .js preload declared only there or in ${String(later)} later sources keeps its package scope.`, + ); + if (skipped) continue; + break; + } + for (const window of result.value.javascript.electron.browser_windows) { + if (window.preload_path === null) continue; + const resolution = resolveArtifactPathByContext({ + declaredPath: window.preload_path, + sourcePath: file.path, + context: window.preload_resolution_context, + files, + jsonDocuments, + unanalyzedFiles: fileSet.unanalyzed_files, + runtimePathKind: "exact-file", + }); + if ( + resolution.resolution_status !== "resolved" || + resolution.resolved_path === null + ) + continue; + const target = files.get(resolution.resolved_path); + if (target?.container_sha256 === file.container_sha256) + paths.add(`${target.container_sha256}:${target.path}`); + } + } + } catch (cause: unknown) { + outcome = { kind: "failed", cause }; + throw cause; + } finally { + // The scoped port parks a healthy process; the following checkout resets + // its per-analysis accounting, and scope cleanup still owns failed closes. + const cleanup = await scope.release({ + kind: "javascript-source-analysis", + analysis: worker, + resource: "JavaScript preload discovery", + }); + if (cleanup.kind === "failed") { + if (outcome.kind === "failed" && outcome.cause instanceof AnalysisError) + throw analysisErrorWithCleanupFailure( + outcome.cause, + translateArtifactFailure( + "analyze_javascript_application", + scope.cleanupFailure({ kind: "completed" }, [cleanup]), + ), + "analyze_javascript_application", + ); + throw scope.cleanupFailure(outcome, [cleanup]); + } + } + return { paths, limitations }; +}; diff --git a/src/composition/javascriptAnalysisWorkerProcess.ts b/src/composition/javascriptAnalysisWorkerProcess.ts index 540cbadd6b..8155d37eb1 100644 --- a/src/composition/javascriptAnalysisWorkerProcess.ts +++ b/src/composition/javascriptAnalysisWorkerProcess.ts @@ -20,11 +20,13 @@ const analyze: JavaScriptSourceWorkerAnalyzer = async ( file, capacity, callbacks, + packageType, + purpose, ) => { if (!file.text.included) throw new TypeError("JavaScript worker requires decoded source text"); await callbacks.phase("parse"); - let parsed = parseJavaScriptSource(file.text.value, file.path); + let parsed = parseJavaScriptSource(file.text.value, file.path, packageType); if (parsed === null) { await callbacks.checkpoint(failedJavaScriptStaticAnalysis()); return null; @@ -33,6 +35,26 @@ const analyze: JavaScriptSourceWorkerAnalyzer = async ( const facts = await completeJavaScriptAnalysisSteps( analyzeParsedJavaScriptStaticSourceSteps(file.text.value, parsed), ); + if (purpose === "preload-discovery") { + // Only declarations cross this preliminary pass. The normal pass owns + // full static/semantic facts and their result reservations. + const empty = failedJavaScriptStaticAnalysis(); + await callbacks.checkpoint({ + ...empty, + parse_status: facts.parse_status, + parse_error_count: facts.parse_error_count, + ...(facts.analysis_coverage === undefined + ? {} + : { analysis_coverage: facts.analysis_coverage }), + visited_ast_nodes: facts.visited_ast_nodes, + limitations: facts.limitations, + electron: { + ...empty.electron, + browser_windows: facts.electron.browser_windows, + }, + }); + return null; + } const resultBudgetBytes = await callbacks.checkpoint(facts); if (facts.parse_status === "failed") return null; if (resultBudgetBytes === 0) diff --git a/src/domain/javascript/javascriptSourceAnalysis.ts b/src/domain/javascript/javascriptSourceAnalysis.ts index 9a588c7f32..f7e02df35b 100644 --- a/src/domain/javascript/javascriptSourceAnalysis.ts +++ b/src/domain/javascript/javascriptSourceAnalysis.ts @@ -8,6 +8,7 @@ import type { Result } from "../result.js"; import type { JavaScriptArtifactFile } from "./javascriptArtifactFiles.js"; import type { JavaScriptStaticAnalysis } from "./javascriptStaticAnalysisTypes.js"; import type { JavaScriptModuleSemanticIr } from "./javascriptModuleSemanticIr.js"; +import type { JavaScriptPackageType } from "./javascriptSourceParser.js"; /** An earlier static projection displaced to retain later static observations. */ export interface JavaScriptRevokedStaticProjection { @@ -48,6 +49,10 @@ export interface JavaScriptSourceAnalysisPort { capacity: JavaScriptSemanticProjectionCapacity, signal?: AbortSignal, onPhase?: (phase: string) => Promise, + /** Effective package/loader context; declared .js preloads use commonjs. */ + packageType?: JavaScriptPackageType, + /** Discover loader declarations without retaining semantic projections. */ + purpose?: "analysis" | "preload-discovery", ): Promise>; close(): Promise; } diff --git a/src/domain/javascript/javascriptSourceParser.ts b/src/domain/javascript/javascriptSourceParser.ts index 9fc1d9a1b9..43c3be3ba5 100644 --- a/src/domain/javascript/javascriptSourceParser.ts +++ b/src/domain/javascript/javascriptSourceParser.ts @@ -154,6 +154,19 @@ const runtimeSourceTypeForPath = ( ? "module" : "unambiguous"; +/** + * Node package facts apply to .js/.ts sources, not explicit suffixes, + * declaration wrappers, or an unknown JSX compilation pipeline. + */ +const packageContextForPath = ( + sourcePath: string | undefined, + observedPackageType: JavaScriptPackageType | undefined, +): JavaScriptPackageType | undefined => + /\.(?:js|ts)$/iu.test(sourcePath ?? "") && + !/\.d\.ts$/iu.test(sourcePath ?? "") + ? observedPackageType + : undefined; + export interface RecoveredJavaScriptParse { readonly file: ParsedJavaScriptSource; /** Errors that apply only if a source of unknown mode is an ES module. */ @@ -173,13 +186,7 @@ export const parseRecoveredJavaScript = ( observedPackageType?: JavaScriptPackageType, ): RecoveredJavaScriptParse => { const filenameType = runtimeSourceTypeForPath(sourcePath); - // Node package facts apply to .js/.ts sources, not explicit suffixes, - // declaration wrappers, or an unknown JSX compilation pipeline. - const packageContext = - /\.(?:js|ts)$/iu.test(sourcePath ?? "") && - !/\.d\.ts$/iu.test(sourcePath ?? "") - ? observedPackageType - : undefined; + const packageContext = packageContextForPath(sourcePath, observedPackageType); // Retain TypeScript import syntax for later CommonJS compilation. const sourceType = filenameType !== "unambiguous" @@ -246,36 +253,55 @@ export const parseRecoveredJavaScript = ( }; }; -/** Parse inert JavaScript or TypeScript facts without attaching comments to AST nodes. */ +/** + * Parse inert JavaScript or TypeScript facts without attaching comments to AST + * nodes. An observed package type selects the runtime context of .js/.ts + * sources as it does for reference imports; syntax recovery is unchanged. + */ export const parseJavaScriptSource = ( source: string, sourcePath?: string, + observedPackageType?: JavaScriptPackageType, ): ParsedJavaScriptSource | null => { try { const runtimeSourceType = runtimeSourceTypeForPath(sourcePath); const commonjs = runtimeSourceType === "commonjs"; + const packageContext = packageContextForPath( + sourcePath, + observedPackageType, + ); const options = { errorRecovery: true, attachComment: false, plugins: parserPluginsForPath(sourcePath), }; - const file = parseWithRuntimeContext(source, sourcePath, { - ...options, - // Inert artifact analysis retains module declarations even in .cjs assets. - // Infer their syntax here; reference imports keep filename-mode diagnostics. - sourceType: commonjs ? "unambiguous" : runtimeSourceType, - allowReturnOutsideFunction: commonjs, - allowNewTargetOutsideFunction: commonjs, - }); + const file = parseWithRuntimeContext( + source, + sourcePath, + { + ...options, + // Inert artifact analysis retains module declarations even in .cjs assets. + // Infer their syntax here; reference imports keep filename-mode diagnostics. + sourceType: commonjs ? "unambiguous" : runtimeSourceType, + allowReturnOutsideFunction: commonjs, + allowNewTargetOutsideFunction: commonjs, + }, + packageContext, + ); // Unambiguous mode retains module-only diagnostics after choosing a script. // The .cjs extension establishes wrapper mode, so obtain its own diagnostics. return commonjs && file.program.sourceType === "script" && file.errors.length > 0 - ? parseWithRuntimeContext(source, sourcePath, { - ...options, - sourceType: "commonjs", - }) + ? parseWithRuntimeContext( + source, + sourcePath, + { + ...options, + sourceType: "commonjs", + }, + packageContext, + ) : file; } catch (cause: unknown) { // Unparseable source is represented by the null return. diff --git a/src/javascript/analysis/JavaScriptAnalysisWorker.ts b/src/javascript/analysis/JavaScriptAnalysisWorker.ts index 72af9e850e..ca7f2daea4 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorker.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorker.ts @@ -24,6 +24,7 @@ import type { JavaScriptSourceAnalysisLimits, JavaScriptRevokedStaticProjection, } from "../../domain/javascript/javascriptSourceAnalysis.js"; +import type { JavaScriptPackageType } from "../../domain/javascript/javascriptSourceParser.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisCancelledError, @@ -292,6 +293,8 @@ export class JavaScriptAnalysisWorker { capacity: JavaScriptSemanticProjectionCapacity, signal?: AbortSignal, onPhase?: (phase: string) => Promise, + packageType: JavaScriptPackageType = "unknown", + purpose: "analysis" | "preload-discovery" = "analysis", ): Promise< Result > { @@ -347,6 +350,8 @@ export class JavaScriptAnalysisWorker { unknown_budget: capacity.unknowns, text_sha256: createHash("sha256").update(file.text.value).digest("hex"), file: metadata, + package_type: packageType, + purpose, }); const frame = `${JSON.stringify(request)}\n`; if (Buffer.byteLength(frame) > DEFAULTS.protocolBytes) diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.ts index ffcfb873ee..4af0dacfce 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.ts @@ -37,6 +37,9 @@ export const javaScriptWorkerRequestSchema = z.strictObject({ kind: z.literal("javascript"), unpacked: z.boolean(), }), + /** Type of the source's inventoried package scope, observed by the parent. */ + package_type: z.enum(["module", "commonjs", "unspecified", "unknown"]), + purpose: z.enum(["analysis", "preload-discovery"]), }); /** Parent admission after validated static observations have been retained. */ diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerServer.test.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerServer.test.ts index 7834a47c2a..5e9d968ff4 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerServer.test.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerServer.test.ts @@ -81,6 +81,8 @@ const startServer = async () => { kind: "javascript", unpacked: false, }, + package_type: "unknown", + purpose: "analysis", }); }, close: async () => { diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerServer.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerServer.ts index ae219d4012..ae46284020 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerServer.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerServer.ts @@ -16,6 +16,7 @@ import { import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js"; import { javaScriptRuntime } from "../../domain/runtimeVersion.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; +import type { JavaScriptPackageType } from "../../domain/javascript/javascriptSourceParser.js"; import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; import type { JavaScriptModuleSemanticIr } from "../../domain/javascript/javascriptModuleSemanticIr.js"; import { @@ -43,6 +44,8 @@ export interface JavaScriptSourceWorkerAnalyzer { ) => Promise; readonly checkpoint: (facts: JavaScriptStaticAnalysis) => Promise; }, + packageType: JavaScriptPackageType, + purpose: "analysis" | "preload-discovery", ): Promise<{ readonly module: JavaScriptModuleSemanticIr; readonly projection: JavaScriptSemanticFileProjection; @@ -186,6 +189,8 @@ const analyzeJob = async ( return resultBudgetBytes; }, }, + request.package_type, + request.purpose, ); await send({ kind: "phase", id: request.id, phase: "transfer" }); const descriptor = diff --git a/tests/acceptance/applications/javascriptAnalysisLimits.test.ts b/tests/acceptance/applications/javascriptAnalysisLimits.test.ts index ccebeb6db5..b7a9215604 100644 --- a/tests/acceptance/applications/javascriptAnalysisLimits.test.ts +++ b/tests/acceptance/applications/javascriptAnalysisLimits.test.ts @@ -259,6 +259,41 @@ cliTest( 60_000, ); +cliTest( + "keeps partial Evidence when a module package source times out", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-js-limit-module-timeout-"); + await Promise.all([ + writeFile(join(root, "package.json"), '{"type":"module"}'), + writeFile(join(root, "a.js"), "export const observed = 7;\n"), + ]); + // A module package with .js sources runs preload discovery first, which + // must leave the timeout to the source pass that retains partial facts. + const result = await cli.run({ + arguments: [ + "analyze-javascript-application", + root, + "--analysis-timeout-ms", + "1", + "--format", + "json", + ], + }); + expect(result.exitCode).toBe(1); + const failure = z + .object({ + code: z.literal("provider_timeout"), + details: z.object({ partial_observation: z.unknown() }), + }) + .parse(result.json); + const partial = parseEvidence(failure.details.partial_observation); + expect(partial.operation).toBe("analyze_javascript_application"); + expect(partial.limitations).toContainEqual( + expect.stringMatching(/^Preload discovery could not read a\.js \(/u), + ); + }, +); + async function getBundle(call: SharedMcpCall) { const response = await call("get_evidence_bundle", { detail: "complete" }); expect(response.isError).not.toBe(true); diff --git a/tests/acceptance/applications/unboundRequires.test.ts b/tests/acceptance/applications/unboundRequires.test.ts index 0e85f0a9a7..b5425ba886 100644 --- a/tests/acceptance/applications/unboundRequires.test.ts +++ b/tests/acceptance/applications/unboundRequires.test.ts @@ -325,6 +325,194 @@ const expectImportEqualsRelationships = ( ).toHaveLength(0); }; +/** Source-module facts and semantic require edges of one analyzed source. */ +const sourceRequireFacts = ( + graph: ReturnType< + typeof javascriptApplicationAnalysisResultSchema.parse + >["graph"], + path: string, +) => { + const module = graph.nodes + .flatMap(({ observations }) => observations) + .find( + ({ properties }) => + properties.semantic_role === "source-module" && + properties.path === path, + )?.properties; + const requires = graph.edges.filter( + ({ relation, properties, evidence }) => + relation === "imports" && + properties.module_link_kind === "require" && + evidence.location.available && + evidence.location.value.kind === "source-range" && + evidence.location.value.source === path, + ); + return { + module_format: module?.module_format, + import_relationships: module?.import_relationships, + require_targets: requires.map(({ properties }) => [ + properties.specifier, + properties.resolved_path, + properties.resolution_status, + ]), + }; +}; + +cliTest( + "follows the nearest package type for an unbound require in .js sources", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-package-type-requires-"); + const source = (dependency: string) => + `try { console.log(require(${JSON.stringify(dependency)})); } catch (error) { console.log(error.name); }\n`; + // The nearest package.json owns each .js file's scope (Node packages.md, + // "Determining module system"). Requiring Electron, directly, through a + // local loader, or in a bundler factory default that never runs, does not + // give a Node-loaded module a require. + await writeFixtureFiles(root, { + "package.json": JSON.stringify({ type: "module" }), + "main.js": source("./dep.cjs"), + "electron-main.js": + 'try { require("electron"); console.log(require("./dep.cjs")); } catch (error) { console.log(error.name); }\n', + "renderer-looking-main.js": + 'try { require("electron").ipcRenderer.send("ready"); require("./dep.cjs"); } catch (error) { console.log(error.name); }', + "shadowed.js": `function load(require) { return require("electron").contextBridge; }\n${source("./dep.cjs")}`, + "dead-default.js": [ + source("./dep.cjs"), + "globalThis.webpackChunkApp = [];", + "globalThis.webpackChunkApp.push([[1], {", + ' 7(module = require("electron").ipcRenderer.send("unused"), exports, load) {}', + "}]);", + ].join("\n"), + "dep.cjs": "module.exports = 1;\n", + "legacy/package.json": JSON.stringify({ type: "commonjs" }), + "legacy/main.js": source("../dep.cjs"), + "untyped/package.json": JSON.stringify({}), + "untyped/main.js": source("../dep.cjs"), + }); + for (const [path, expected] of [ + ["main.js", "ReferenceError"], + ["electron-main.js", "ReferenceError"], + ["renderer-looking-main.js", "ReferenceError"], + ["shadowed.js", "ReferenceError"], + ["dead-default.js", "ReferenceError"], + ["legacy/main.js", "1"], + ["untyped/main.js", "1"], + ] as const) { + const control = await execute(process.execPath, [join(root, path)], { + cwd: root, + timeout: 5_000, + }); + expect(control.stdout.trim(), path).toBe(expected); + } + + const response = await cli.run({ + arguments: ["analyze-javascript-application", root, "--format", "json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + + expect(response.exitCode, response.stderr).toBe(0); + const { graph } = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(response.json).normalized_result, + ); + for (const path of [ + "main.js", + "electron-main.js", + "renderer-looking-main.js", + "shadowed.js", + "dead-default.js", + ]) + expect(sourceRequireFacts(graph, path), path).toEqual({ + module_format: "unknown", + import_relationships: 0, + require_targets: [], + }); + for (const path of ["legacy/main.js", "untyped/main.js"]) + expect(sourceRequireFacts(graph, path), path).toEqual({ + module_format: "commonjs", + import_relationships: 1, + require_targets: [["../dep.cjs", "dep.cjs", "resolved"]], + }); + }, +); + +cliTest( + "keeps Electron's script loader for a .js preload in a module package scope", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-package-type-preload-"); + // Electron's preload loader ignores "type": "module" ("ES Modules (ESM) + // in Electron"); main.mjs is an ES module by its suffix. + await writeFixtureFiles(root, { + "package.json": JSON.stringify({ type: "module", main: "main.mjs" }), + "main.mjs": [ + 'import { app, BrowserWindow, ipcMain } from "electron";', + 'import { fileURLToPath } from "node:url";', + 'ipcMain.handle("ping", () => "pong");', + "app.whenReady().then(() => {", + " const window = new BrowserWindow({", + ' webPreferences: { sandbox: false, preload: fileURLToPath(new URL("./preload.js", import.meta.url)) },', + " });", + ' window.loadFile("index.html");', + "});", + ].join("\n"), + "preload.js": [ + 'const { contextBridge, ipcRenderer } = require("electron");', + 'function expose() { contextBridge.exposeInMainWorld("api", { ping: () => ipcRenderer.invoke("ping") }); } expose();', + ].join("\n"), + "index.html": "app\n", + }); + + const response = await cli.run({ + arguments: ["analyze-javascript-application", root, "--format", "json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + + expect(response.exitCode, response.stderr).toBe(0); + const { graph, summary } = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(response.json).normalized_result, + ); + expect(sourceRequireFacts(graph, "preload.js")).toEqual({ + module_format: "commonjs", + import_relationships: 2, + require_targets: [ + ["electron", null, "external"], + ["electron", null, "external"], + ], + }); + expect(summary.ipc).toMatchObject({ + main_handlers: 1, + renderer_transmissions: 1, + paired_renderer_transmissions: 1, + }); + }, +); + +cliTest( + "keeps a declared preload's helper require without renderer API text", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-helper-only-preload-"); + await writeFixtureFiles(root, { + "package.json": JSON.stringify({ type: "module", main: "main.mjs" }), + "main.mjs": + 'import { BrowserWindow } from "electron"; import { fileURLToPath } from "node:url"; new BrowserWindow({webPreferences:{sandbox:false,preload:fileURLToPath(new URL("./aaa-preload.js",import.meta.url))}});', + "aaa-preload.js": 'require("./helper.cjs");', + "helper.cjs": "module.exports = 1;", + }); + const response = await cli.run({ + arguments: ["analyze-javascript-application", root, "--format", "json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(response.exitCode, response.stderr).toBe(0); + const { graph } = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(response.json).normalized_result, + ); + expect(sourceRequireFacts(graph, "aaa-preload.js")).toEqual({ + module_format: "commonjs", + import_relationships: 1, + require_targets: [["./helper.cjs", "helper.cjs", "resolved"]], + }); + }, +); + cliTest.for(["CLI", "stdio MCP"] as const)( "retains unbound CommonJS semantic relationships through %s", async (surface, { cli }) => { diff --git a/tests/boundary/javascript/javascriptPreloadDiscovery.test.ts b/tests/boundary/javascript/javascriptPreloadDiscovery.test.ts new file mode 100644 index 0000000000..2947dab782 --- /dev/null +++ b/tests/boundary/javascript/javascriptPreloadDiscovery.test.ts @@ -0,0 +1,208 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js"; +import { + AnalysisCancelledError, + AnalysisResourceConstraintError, +} from "../../../src/domain/analysisErrorCore.js"; +import type { + JavaScriptArtifactFile, + JavaScriptArtifactFileSet, +} from "../../../src/domain/javascript/javascriptArtifactFiles.js"; +import { analyzeParsedJavaScriptStaticSource } from "../../../src/domain/javascript/javascriptStaticAnalysis.js"; +import { parseJavaScriptSource } from "../../../src/domain/javascript/javascriptSourceParser.js"; +import { ok, err } from "../../../src/domain/result.js"; +import { discoverJavaScriptPreloadPaths } from "../../../src/application/javascript/JavaScriptPreloadDiscovery.js"; +const acquire = vi.hoisted(() => vi.fn()); +vi.mock( + "../../../src/artifacts/javascript/JavaScriptSourceAnalysis.js", + () => ({ + createJavaScriptSourceAnalysis: acquire, + }), +); +const file = ( + path: string, + text: string, + container = "a".repeat(64), +): JavaScriptArtifactFile => ({ + path, + container_sha256: container, + sha256: "b".repeat(64), + bytes: text.length, + inventory_artifact_id: path, + kind: path.endsWith("json") ? "package-json" : "javascript", + unpacked: false, + text: { included: true, value: text }, +}); +const declaration = + 'import { BrowserWindow } from "electron"; import { fileURLToPath } from "node:url"; new BrowserWindow({webPreferences:{sandbox:false,preload:fileURLToPath(new URL("./preload.js",import.meta.url))}});'; +const fixture = (): JavaScriptArtifactFileSet => ({ + files: [ + file("package.json", '{"type":"module"}'), + file("preload.js", 'require("./helper.cjs");'), + file("main.mjs", declaration), + ], + unavailable_relevant_files: [], + containers: [], + text_bytes_read: 0, + invalid_utf8_files: 0, +}); +const limits = { heapMb: 128, timeoutMs: 5000 }; +const close = vi.fn(async () => {}); +const analyze = vi.fn( + async ( + source: JavaScriptArtifactFile, + _capacity: unknown, + _signal: AbortSignal | undefined, + _phase: unknown, + packageType: "module" | "commonjs" | "unknown" | "unspecified", + ) => { + const parsed = parseJavaScriptSource( + source.text.included ? source.text.value : "", + source.path, + packageType, + )!; + return ok({ + javascript: analyzeParsedJavaScriptStaticSource( + source.text.included ? source.text.value : "", + parsed, + ), + module: null, + projection: null, + }); + }, +); +beforeEach(() => { + close.mockReset(); + analyze.mockClear(); + acquire.mockReset(); + acquire.mockResolvedValue({ analyze, close }); +}); +it("resolves an exact preload before its declaring source regardless of inventory order", async () => { + const result = await discoverJavaScriptPreloadPaths( + fixture(), + limits, + new ArtifactResourceScope(), + ); + expect(result).toEqual({ + paths: new Set(["a".repeat(64) + ":preload.js"]), + limitations: [], + }); + expect(close).toHaveBeenCalledOnce(); + expect(analyze.mock.calls.every((call) => call[2] === undefined)).toBe(true); +}); +it("keeps discovering after one source exceeds its worker limit", async () => { + const files = fixture(); + const limited = vi.fn( + async (source: JavaScriptArtifactFile, ...rest: unknown[]) => + source.path === "big.js" + ? err({ + error: new AnalysisResourceConstraintError( + "analyze_javascript_application", + "memory", + "big.js exhausted the worker heap", + { max_heap_mb: 128 }, + ), + javascript: null, + module: null, + projection: null, + }) + : analyze(source, ...(rest as [unknown, undefined, unknown, "module"])), + ); + acquire.mockResolvedValue({ analyze: limited, close }); + const result = await discoverJavaScriptPreloadPaths( + { ...files, files: [file("big.js", ""), ...files.files] }, + limits, + new ArtifactResourceScope(), + ); + // The gap stays reported even if source analysis later reads big.js. + expect(result).toEqual({ + paths: new Set(["a".repeat(64) + ":preload.js"]), + limitations: [ + expect.stringMatching( + /^Preload discovery could not read big\.js \(.*big\.js exhausted the worker heap/u, + ), + ], + }); + expect(limited.mock.calls.map(([source]) => source.path)).toEqual([ + "big.js", + "preload.js", + "main.mjs", + ]); +}); +it("does not turn an invalid ESM require declaration into a preload identity", async () => { + const files = fixture(); + const source = file( + "main.js", + 'const { BrowserWindow } = require("electron"); new BrowserWindow({webPreferences:{preload:"preload.js"}});', + ); + expect( + ( + await discoverJavaScriptPreloadPaths( + { ...files, files: [...files.files.slice(0, 2), source] }, + limits, + new ArtifactResourceScope(), + ) + ).paths, + ).toEqual(new Set()); +}); +it("does not transfer preload identity across containers", async () => { + const files = fixture(); + expect( + ( + await discoverJavaScriptPreloadPaths( + { + ...files, + files: files.files.map((source) => + source.path === "preload.js" + ? { ...source, container_sha256: "c".repeat(64) } + : source, + ), + }, + limits, + new ArtifactResourceScope(), + ) + ).paths, + ).toEqual(new Set()); +}); +it("skips discovery when package scope cannot change a .js loader", async () => { + const files = fixture(); + await discoverJavaScriptPreloadPaths( + { + ...files, + files: [ + file("package.json", '{"type":"commonjs"}'), + ...files.files.slice(1), + ], + }, + limits, + new ArtifactResourceScope(), + ); + expect(acquire).not.toHaveBeenCalled(); +}); +it("retains a failed close and the original cancellation until scope cleanup retries", async () => { + const controller = new AbortController(); + const reason = new Error("caller cancelled"); + controller.abort(reason); + const failure = new AnalysisCancelledError("analyze_javascript_application", { + cause: reason, + }); + const failingAnalyze = vi.fn(async () => + err({ error: failure, javascript: null, module: null, projection: null }), + ); + close.mockRejectedValueOnce(new Error("close denied")); + acquire.mockResolvedValue({ analyze: failingAnalyze, close }); + const scope = new ArtifactResourceScope(); + await expect( + discoverJavaScriptPreloadPaths(fixture(), limits, scope, controller.signal), + ).rejects.toMatchObject({ + cleanupIncomplete: true, + cause: expect.objectContaining({ + errors: expect.arrayContaining([failure]), + }), + }); + expect(scope.hasPending).toBe(true); + expect(failingAnalyze.mock.calls).toHaveLength(1); + await scope.retryCleanup(); + expect(scope.hasPending).toBe(false); + expect(close).toHaveBeenCalledTimes(2); +}); From 1480a7c72e601f4444e21ecd50118c1afa774209 Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:15:58 +0800 Subject: [PATCH 066/151] docs(firmware): qualify component comparison workflows (#9143) --- docs/firmware-analysis.md | 13 ++ docs/fit-images.md | 129 ++++++++++++- src/cli/artifactComparisonCommand.ts | 2 +- src/contracts/artifactToolContracts.ts | 2 +- src/contracts/sessionToolContracts.ts | 2 +- .../applications/firmwareComparison.test.ts | 176 ++++++++++++++++++ 6 files changed, 317 insertions(+), 7 deletions(-) create mode 100644 tests/acceptance/applications/firmwareComparison.test.ts diff --git a/docs/firmware-analysis.md b/docs/firmware-analysis.md index 4ff0899e93..b435c79511 100644 --- a/docs/firmware-analysis.md +++ b/docs/firmware-analysis.md @@ -13,6 +13,19 @@ On Linux, Binwalk and Unblob run as REA-owned processes and require a procps-compatible `ps` on REA's `PATH`; see the shared [Linux process prerequisite](installation.md#linux-owned-process-prerequisite). +## Compare stored firmware components + +For supported FIT and legacy uImage containers, use the built-in +`inspect-artifact` / `inspect_artifact` inventory and shared +`compare-artifacts` / `compare_artifacts` workflow. This route needs no Binwalk, +Unblob or extraction: exact logical paths, stored-byte hashes and declared +metadata establish component changes, with source Evidence and explicit unknowns. +The outer container at `.` is separate from its children. See the +[FIT version comparison recipe](fit-images.md#comparison) for CLI, +MCP, summary delivery and selected supporting facts. Its real-data verification +covers a pair of OpenWrt FIT releases; it does not establish comparison of +decoded root filesystems or arbitrary firmware formats. + ## Caller-supplied tools Provide existing tools using absolute executable paths, scoped to your shell: diff --git a/docs/fit-images.md b/docs/fit-images.md index 372db3abe2..316d7d04c6 100644 --- a/docs/fit-images.md +++ b/docs/fit-images.md @@ -149,10 +149,116 @@ After extraction: ## Comparison -Artifact comparison reports changes to an image's payload interpretation, -including load and entry declarations. Equivalent external offset forms and -inventory positions leave those declarations unchanged. Unavailable -interpretations remain unknown, while independently known changes survive. +Use the shared `compare-artifacts` / `compare_artifacts` workflow for exact +component paths and stored content identities. Neither extraction nor an external +firmware engine is required. This compares the bytes exposed by this FIT reader; +it does not inspect files inside a compressed root filesystem, match renamed +components, or establish runtime equivalence. + +### CLI + +Retain each complete inspection while returning a summary: + +```sh +rea inspect-artifact /absolute/baseline.itb --detail summary \ + --evidence_output /absolute/baseline-evidence.json --json +rea inspect-artifact /absolute/candidate.itb --detail summary \ + --evidence_output /absolute/candidate-evidence.json --json +``` + +The CLI returns a `{summary, evidence_bundle}` receipt. Use each response's +`summary.normalized_result.parent_evidence_id` as the corresponding +`evidence_id` below. Save this request as `comparison.json`, replacing the paths +and IDs with the returned values, then run `rea compare-artifacts comparison.json +--json`. Evidence-file paths must be absolute. Naming the exact complete parent +avoids ambiguity when a bundle contains several inspection or inventory records. + +```json +{ + "left": { + "kind": "evidence-file", + "path": "/absolute/baseline-evidence.json", + "evidence_id": "COMPLETE_BASELINE_EVIDENCE_ID" + }, + "right": { + "kind": "evidence-file", + "path": "/absolute/candidate-evidence.json", + "evidence_id": "COMPLETE_CANDIDATE_EVIDENCE_ID" + } +} +``` + +### MCP + +For each file, call `open_binary` with its path, then `inspect_artifact` with +`{"detail":"summary"}`. Opening the second file retains the first inspection's +Evidence. Compare the two complete parent IDs without resending their graphs: + +```json +{ + "name": "compare_artifacts", + "arguments": { + "left": { + "kind": "retained-evidence", + "evidence_id": "COMPLETE_BASELINE_EVIDENCE_ID" + }, + "right": { + "kind": "retained-evidence", + "evidence_id": "COMPLETE_CANDIDATE_EVIDENCE_ID" + } + } +} +``` + +The result counts unchanged, added, removed, changed, unknown and contradictory +paths, returning every delta inline with source Evidence links. The root `.` is +the outer FIT and is counted separately from `images/.bin`. A changed +outer hash therefore does not imply every component changed. Additions and +removals require complete container-entry coverage; ambiguous duplicate paths or +missing observations cannot prove absence or equivalence. + +Artifact comparison includes an image's payload interpretation, including load +and entry declarations. Equivalent external offset forms and inventory positions +leave those declarations unchanged. Unavailable interpretations remain unknown, +while independently known changes survive; a changed row with an `availability` +dimension does not establish the unavailable field's value. + +For supporting facts, batch selected paths rather than requesting the entire +inspection again: + +```json +{ + "name": "inspect_analysis_view", + "arguments": { + "source": { + "kind": "retained-evidence", + "evidence_id": "COMPLETE_BASELINE_EVIDENCE_ID" + }, + "view": { + "kind": "inventory", + "collection": "occurrences", + "logical_paths": ["images/kernel-1.bin", "images/fdt-1.bin"], + "fields": [ + "artifact_id", + "logical_path", + "hash_status", + "source_location", + "source_path" + ], + "offset": 0, + "limit": 64 + } + } +} +``` + +Read `collection: "nodes"` with those `artifact_ids` and `fields: ["sha256", +"size"]` to obtain content facts. Join by `artifact_id`, not array position. +Continue using the returned page metadata when necessary. Source locations are +ranges in the original FIT, not decoded filesystem offsets. Complete Evidence +remains available through the retained parent and `export_evidence_bundle`. +Summary and field selection reduce delivery only; they do not skip inventory +hashing or change coverage. ## Verification @@ -170,3 +276,18 @@ No image was executed. For small containers, complete delivery avoids an additional view request. Choose summary and selected views when the omitted facts justify that extra request; summary delivery does not reduce the underlying inventory work. + +The component comparison workflow was additionally checked on the official +[OpenWrt 24.10.0](https://downloads.openwrt.org/releases/24.10.0/targets/mediatek/filogic/) +and [24.10.1](https://downloads.openwrt.org/releases/24.10.1/targets/mediatek/filogic/) +`abt_asr3000-initramfs-recovery.itb` images (8,519,680 and 8,585,216 bytes). +Their SHA-256 values are +`083b174e81ed373f2d2f2ea5366f9291767d95375383528fd6b9955ad815b85a` and +`41129cce68ad6269e5c8c48ef110f97682d6c4202ff2198bfd0d300c3dac132a`. +An independent FDT byte-range/hash oracle found changed kernel and initrd bytes +and an identical DTB; REA reported those two changed components, the changed +outer `.` and one unchanged component. A controlled copy changing only the root +timestamp reported only `.` changed and all three components unchanged. Public +CLI comparison of the exported source Evidence matched the MCP Evidence exactly; +summary and selected inventory views matched the independent ranges and hashes. +These checks did not decompress either initrd or execute the firmware. diff --git a/src/cli/artifactComparisonCommand.ts b/src/cli/artifactComparisonCommand.ts index 804a338525..bdbb47b994 100644 --- a/src/cli/artifactComparisonCommand.ts +++ b/src/cli/artifactComparisonCommand.ts @@ -24,7 +24,7 @@ export const registerArtifactComparisonCommand = ( const name = CLI_COMMANDS.compareArtifacts; cli.command(name, { description: - 'Compare two artifact inventories named as {left, right}: complete Evidence or {kind:"evidence-file",path,evidence_id?}. A one-shot command retains no session Evidence for retained-evidence references.', + 'Compare two artifact inventories, including FIT and legacy uImage firmware components, named as {left, right}: complete Evidence or {kind:"evidence-file",path,evidence_id?}. A one-shot command retains no session Evidence for retained-evidence references.', args: z.object({ inputJson: z.string().describe("Inline workflow JSON or JSON file path"), }), diff --git a/src/contracts/artifactToolContracts.ts b/src/contracts/artifactToolContracts.ts index 431974c14b..20286a652d 100644 --- a/src/contracts/artifactToolContracts.ts +++ b/src/contracts/artifactToolContracts.ts @@ -102,7 +102,7 @@ const artifact = < export const ARTIFACT_TOOL_CONTRACTS = [ artifact( "inspect_artifact", - "Inspect an active archive or application package, including Node.js SEA carriers, in one call. By default returns the complete content-addressed artifact graph once, as the typed nested inventory Evidence, with root and integrity observations, integrity contradictions, hypotheses, unexplored branches, limitations, and next probes inline. Bun standalone inventory describes stored module, sourcemap and opaque bytecode regions. For PCK, Bun and large containers prefer detail summary: it retains the complete inspection and returns its derived summary view; read exact paths, batch occurrence/content IDs and selected fields with inspect_analysis_view using parent_evidence_id. This changes delivery, not inventory coverage. Classic iNES includes header facts, PRG/CHR file-region hashes and byte-exact reconstruction evidence; mapper execution and CPU bank mappings remain unobserved. Read-only DMG mounting is used automatically on supported macOS hosts. Integrity mismatches fail by default; record-and-continue keeps mismatches explicitly untrusted. It does not extract files; use extract_artifact to materialize regular contents of supported formats. DMG extraction is unavailable; use a caller-managed read-only mount on macOS to access its files.", + "Inspect an active archive, application package, firmware container (including FIT and legacy uImage), or Node.js SEA carrier in one call. By default returns the complete content-addressed artifact graph once, as the typed nested inventory Evidence, with root and integrity observations, integrity contradictions, hypotheses, unexplored branches, limitations, and next probes inline. Bun standalone inventory describes stored module, sourcemap and opaque bytecode regions. For PCK, Bun and large containers prefer detail summary: it retains the complete inspection and returns its derived summary view; read exact paths, batch occurrence/content IDs and selected fields with inspect_analysis_view using parent_evidence_id. This changes delivery, not inventory coverage. Classic iNES includes header facts, PRG/CHR file-region hashes and byte-exact reconstruction evidence; mapper execution and CPU bank mappings remain unobserved. Read-only DMG mounting is used automatically on supported macOS hosts. Integrity mismatches fail by default; record-and-continue keeps mismatches explicitly untrusted. It does not extract files; use extract_artifact to materialize regular contents of supported formats. DMG extraction is unavailable; use a caller-managed read-only mount on macOS to access its files.", artifactInspectionInputSchema, { evidenceParameters: "preserve-detail" }, ), diff --git a/src/contracts/sessionToolContracts.ts b/src/contracts/sessionToolContracts.ts index 0778c29316..12401abc00 100644 --- a/src/contracts/sessionToolContracts.ts +++ b/src/contracts/sessionToolContracts.ts @@ -113,7 +113,7 @@ export const SESSION_TOOL_CONTRACTS = [ ), session( "compare_artifacts", - 'Compare complete artifact inventories by logical occurrence path, content identity, metadata, and graph relations. Name each side once without resending Evidence: {kind: "retained-evidence", evidence_id} selects an inspect_artifact result, its nested inventory, or its inspect_analysis_view summary retained in this session; {kind: "evidence-file", path, evidence_id?} reads one record from a local Evidence file or bundle at an absolute path. Complete inline Evidence is also accepted. Summary facts are not treated as a complete inventory; comparison resolves the exact complete parent from the same source bundle or the retained session. Every delta cites both complete inputs, and gaps yield truncated or unknown, never equivalence. Returns every change inline.', + 'Compare complete artifact inventories, including FIT and legacy uImage firmware components, by logical occurrence path, content identity, metadata, and graph relations. The root path . describes the outer container separately from its components. Name each side once without resending Evidence: {kind: "retained-evidence", evidence_id} selects an inspect_artifact result, its nested inventory, or its inspect_analysis_view summary retained in this session; {kind: "evidence-file", path, evidence_id?} reads one record from a local Evidence file or bundle at an absolute path. Complete inline Evidence is also accepted. Summary facts are not treated as a complete inventory; comparison resolves the exact complete parent from the same source bundle or the retained session. Every delta cites both complete inputs, and gaps yield truncated or unknown, never equivalence. Returns every change inline.', artifactComparisonInputSchema, ), session( diff --git a/tests/acceptance/applications/firmwareComparison.test.ts b/tests/acceptance/applications/firmwareComparison.test.ts new file mode 100644 index 0000000000..6eaab8ea01 --- /dev/null +++ b/tests/acceptance/applications/firmwareComparison.test.ts @@ -0,0 +1,176 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { z } from "zod"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; +import { artifactInventorySummaryViewResultSchema } from "../../../src/domain/analysisView/analysisViewResult.js"; +import { parseEvidence, type Evidence } from "../../../src/domain/evidence.js"; +import { artifactComparisonResultSchema } from "../../../src/domain/artifactComparison.js"; +import { parseArtifactInventoryEvidence } from "../../../src/domain/artifactInventoryEvidence.js"; +import { + fitFixture, + type FitFixtureImage, +} from "../../../src/artifacts/fit/FitImage.fixture.js"; +import { dtString } from "../../../src/firmware/devicetree/Devicetree.fixture.js"; + +type McpCall = Awaited>["call"]; +const image = (name: string, content: string): FitFixtureImage => ({ + name, + data: Buffer.from(content), + hashes: ["sha256"], + properties: { + type: dtString("firmware"), + arch: dtString("arm64"), + compression: dtString("none"), + }, +}); +const inspect = async (call: McpCall, path: string) => { + expect((await call("open_binary", { path })).isError).not.toBe(true); + const result = await call("inspect_artifact", { detail: "summary" }); + expect(result.isError).not.toBe(true); + const parent = artifactInventorySummaryViewResultSchema.parse( + parseEvidence(result.structuredContent).normalized_result, + ); + return { kind: "retained-evidence", evidence_id: parent.parent_evidence_id }; +}; +const compare = async (call: McpCall, left: object, right: object) => { + const result = await call("compare_artifacts", { left, right }); + expect(result.isError, JSON.stringify(result.content)).not.toBe(true); + const evidence = parseEvidence(result.structuredContent); + return { + evidence, + comparison: artifactComparisonResultSchema.parse( + evidence.normalized_result, + ), + }; +}; +const inspectRecord = (records: readonly Evidence[], id: string) => { + const record = records.find((record) => record.evidence_id === id); + if (record === undefined) + throw new Error("Expected complete inventory owner"); + return record; +}; + +cliTest( + "compares FIT component changes through retained summaries and exact CLI Evidence sources", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-fit-version-compare-"); + const leftPath = join(root, "baseline.itb"), + rightPath = join(root, "candidate.itb"); + const same = image("same", "identical stored bytes"); + const leftBytes = fitFixture({ + images: [ + same, + image("changed", "old bytes"), + image("removed", "removed bytes"), + ], + }); + const rightBytes = fitFixture({ + images: [ + image("added", "new component"), + image("changed", "new bytes"), + same, + ], + }); + await writeFile(leftPath, leftBytes); + await writeFile(rightPath, rightBytes); + const { call } = await connectLocalToolsMcp({ + REA_MCP_TOOL_FAMILIES: "session,artifact,application", + REA_MCP_INPUT_SCHEMA_PROFILE: "full", + }); + const left = await inspect(call, leftPath), + right = await inspect(call, rightPath); + const { evidence, comparison } = await compare(call, left, right); + expect(comparison).toMatchObject({ + status: "changed", + summary: { unchanged: 1, added: 1, removed: 1, changed: 2, unknown: 0 }, + }); + expect( + comparison.changes.map((row) => [row.logical_path, row.classification]), + ).toEqual([ + [".", "changed"], + ["images/added.bin", "added"], + ["images/changed.bin", "changed"], + ["images/removed.bin", "removed"], + ]); + expect(evidence.evidence_links).toEqual([ + left.evidence_id, + right.evidence_id, + ]); + const bundlePath = join(root, "retained.json"); + expect( + (await call("export_evidence_bundle", { path: bundlePath })).isError, + ).not.toBe(true); + const records = evidenceBundleSchema.parse( + JSON.parse(await readFile(bundlePath, "utf8")), + ).records; + const inventory = parseArtifactInventoryEvidence( + inspectRecord(records, left.evidence_id), + ).inventory; + const sameOccurrence = inventory.occurrences.find( + (row) => row.logical_path === "images/same.bin", + )!; + const sameNode = inventory.nodes.find( + (row) => row.artifact_id === sameOccurrence.artifact_id, + )!; + expect(sameNode.sha256).toBe( + createHash("sha256").update(same.data).digest("hex"), + ); + expect(sameOccurrence.hash_status).toBe("verified"); + const requestPath = join(root, "compare.json"); + await writeFile( + requestPath, + JSON.stringify({ + left: { + kind: "evidence-file", + path: bundlePath, + evidence_id: left.evidence_id, + }, + right: { + kind: "evidence-file", + path: bundlePath, + evidence_id: right.evidence_id, + }, + }), + ); + const run = cli.sharedCliRun?.bind(cli) ?? cli.run.bind(cli); + const result = await run({ + arguments: ["compare-artifacts", requestPath, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseEvidence(result.json)).toEqual(evidence); + const cliBundlePath = join(root, "cli-inspection.json"); + const cliInspection = await run({ + arguments: [ + "inspect-artifact", + leftPath, + "--detail", + "summary", + "--evidence_output", + cliBundlePath, + "--json", + ], + }); + expect(cliInspection.exitCode, cliInspection.stderr).toBe(0); + const receipt = z + .object({ summary: z.unknown() }) + .parse(cliInspection.json); + const cliSummary = artifactInventorySummaryViewResultSchema.parse( + parseEvidence(receipt.summary).normalized_result, + ); + const cliRecords = evidenceBundleSchema.parse( + JSON.parse(await readFile(cliBundlePath, "utf8")), + ).records; + const cliParent = inspectRecord(cliRecords, cliSummary.parent_evidence_id); + expect(cliParent.caller_input_path).toBe(leftPath); + expect(parseArtifactInventoryEvidence(cliParent).inventory).toEqual( + inventory, + ); + expect((await readFile(leftPath)).equals(leftBytes)).toBe(true); + expect((await readFile(rightPath)).equals(rightBytes)).toBe(true); + }, +); From fbe7532fabb78de46bd00db845c1147b5325ad22 Mon Sep 17 00:00:00 2001 From: Beqa <91750923+BeqaKs@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:23:13 +0400 Subject: [PATCH 067/151] fix(managed): surface capacity limits in member comparison Preserve per-side capacity diagnostics in shared managed member comparisons and apply repeatable CLI type selection to both source inspections. Keep partial and historical unknown semantics intact, with one typed diagnostic projection and side schema. Validated with 159 focused tests, final-main CLI/MCP boundary checks, schema validation and official public WPF 8.0.32/8.0.33 assemblies. Full source Evidence remains available through the retained-record MCP workflow. No target execution or budget increase. Fixes #9021 Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) --- docs/cli.md | 3 +- docs/managed-code-analysis.md | 15 ++ .../managed/ManagedMemberComparisonService.ts | 21 ++- src/cli/managedCommands.ts | 10 +- src/domain/managed/managedMemberComparison.ts | 41 +++--- .../managedMemberComparisonCoverage.ts | 31 ++++ src/dotnet/ManagedMemberTypeInventory.test.ts | 16 +-- .../cli/managedMemberComparisonCli.test.ts | 132 ++++++++++++++++++ .../managed/managedMemberComparison.test.ts | 50 ++++++- tests/fixtures/managedMemberCapacity.ts | 15 ++ 10 files changed, 296 insertions(+), 38 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index efc0db8e68..e0b8d0e106 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -796,7 +796,8 @@ from the bundle for every supported view kind. Bundles exported from MCP work for these commands. Its inline source form cannot also select a bundle. A missing ID is refused with `details.reason: "missing"`; supplying a bundle does not make incompatible Evidence valid for a workflow. -`compare-managed-members` keeps its two assembly paths, and +`compare-managed-members` keeps its two assembly paths and an optional +repeatable `--type` selection applied to both sides, and `compare-process-captures` keeps its two capture Evidence JSON paths. The bundle option above selects an input; producer options that write a new bundle, such as `inspect-managed-members --evidence-bundle`, retain their existing meaning. diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index 36e64bf205..f4a2adf065 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -280,6 +280,21 @@ inspects its two selected paths sequentially, retaining each inspection rather than both full byte snapshots. The limit therefore remains a per-file resource admission rule, not an aggregate comparison limit. Comparison Evidence keeps the caller-selected paths as its parameters and artifact locations. +When either side's member inspection exceeds its representation budget, the +comparison limitations name that side and its `resource-limit` diagnostic; its +empty member collections do not establish added or removed members. Each side's +`resource_limits` preserves the reported diagnostic's code, scope, detail and +nullable offset. An empty array means no resource limit was reported; historical +comparisons omit this field, leaving those diagnostics unrecorded. Always read +the comparison coverage alongside member counts. To compare +a smaller selection, pass the same repeatable `--type` full names that +`inspect-managed-members --type` accepts to `compare-managed-members`, which +applies them to both sides and records them as `type_names`; over MCP, compare +two `inspect_managed_members` Evidence records selected with the same +`type_names`. A single selected type can still exceed the budget. +The two-record MCP workflow keeps full source Evidence available for +`inspect_analysis_view` field selection and paging; path-based CLI comparison +records the selected paths, type names and per-side diagnostics. Input overflow returns a resource constraint. Decode or projection overflow reports `resource-limit` with unavailable or partial coverage; omitted facts do diff --git a/src/application/managed/ManagedMemberComparisonService.ts b/src/application/managed/ManagedMemberComparisonService.ts index a324518702..d3ac5bad64 100644 --- a/src/application/managed/ManagedMemberComparisonService.ts +++ b/src/application/managed/ManagedMemberComparisonService.ts @@ -102,6 +102,8 @@ export const compareManagedMemberPaths = async ( input: { readonly leftPath: string; readonly rightPath: string; + /** Applied to both sides with inspect_managed_members selection semantics. */ + readonly typeNames?: readonly string[]; }, dependencies: Partial = {}, resources?: ArtifactResourceScope, @@ -159,6 +161,9 @@ export const compareManagedMemberPaths = async ( try { inspection = inspectManagedMembersBytes(snapshot.bytes, target, { userStrings: false, + ...(input.typeNames === undefined + ? {} + : { typeNames: input.typeNames }), }); } catch (cause: unknown) { throw ( @@ -172,7 +177,10 @@ export const compareManagedMemberPaths = async ( } const evidence = createEvidence(target, MANAGED_STATIC_PROVIDER, { operation: "inspect_managed_members", - parameters: {}, + parameters: + input.typeNames === undefined + ? {} + : { type_names: [...input.typeNames] }, result: inspection, rawResult: null, limitations: inspection.limitations, @@ -198,7 +206,7 @@ export const compareManagedMemberPaths = async ( left.evidence, right.evidence, result, - { leftPath: input.leftPath, rightPath: input.rightPath }, + input, ), ); } catch (cause: unknown) { @@ -215,7 +223,11 @@ const createManagedMemberComparisonEvidence = ( left: Evidence, right: Evidence, result: ManagedMemberComparisonResult, - sourcePaths?: { readonly leftPath: string; readonly rightPath: string }, + sourcePaths?: { + readonly leftPath: string; + readonly rightPath: string; + readonly typeNames?: readonly string[]; + }, ): Evidence => createEvidence(undefined, MANAGED_WORKFLOW_PROVIDER, { predicateType: "rea.managed-member-comparison", @@ -229,6 +241,9 @@ const createManagedMemberComparisonEvidence = ( left_path: sourcePaths.leftPath, right_path: sourcePaths.rightPath, }), + ...(sourcePaths?.typeNames === undefined + ? {} + : { type_names: [...sourcePaths.typeNames] }), }, result, rawResult: null, diff --git a/src/cli/managedCommands.ts b/src/cli/managedCommands.ts index 3c588d609b..06470529ff 100644 --- a/src/cli/managedCommands.ts +++ b/src/cli/managedCommands.ts @@ -152,7 +152,12 @@ const registerCompareManagedMembers = ( description: "Compare managed PE/CLI member inventories by exact declared type, name, and signature; names alone never form a match", args: compareManagedMemberArgs, - run: ({ args }) => + options: z.object({ + type: managedMembersInputSchema.shape.type_names.describe( + "Compare only members declared by this exact type full name on both sides, as selected by inspect-managed-members --type; use it when a whole-assembly comparison exceeds capacity (repeatable)", + ), + }), + run: ({ args, options }) => withCommandCancellation((signal) => logCliCommand(logger, "compare-managed-members", async () => { const resources = new ArtifactResourceScope(); @@ -162,6 +167,9 @@ const registerCompareManagedMembers = ( { leftPath: args.leftPath, rightPath: args.rightPath, + ...(options.type === undefined + ? {} + : { typeNames: options.type }), }, {}, resources, diff --git a/src/domain/managed/managedMemberComparison.ts b/src/domain/managed/managedMemberComparison.ts index ed4f45d8fc..4717898c21 100644 --- a/src/domain/managed/managedMemberComparison.ts +++ b/src/domain/managed/managedMemberComparison.ts @@ -7,6 +7,7 @@ import { evidenceInputSchema } from "../evidenceReference.js"; import { cliMetadataGuidSchema, managedMetadataTokenSchema, + managedMemberInspectionSchema, type ManagedMemberInspection, } from "./managedArtifact.js"; import { @@ -173,6 +174,26 @@ const fieldComparisonItemSchema = comparisonItemSchema( memberIdentitySchema.omit({ normalized_il_sha256: true }), ); +const comparisonSideSchema = z.strictObject({ + evidence_id: evidenceIdSchema, + artifact_sha256: digestSchema, + mvid: cliMetadataGuidSchema.nullable(), + module_name: z.string().nullable(), + metadata_status: z.enum(["absent", "complete", "partial", "malformed"]), + methods_total: z.int().min(0), + fields_total: z.int().min(0), + resource_limits: z + .array( + managedMemberInspectionSchema.shape.coverage.shape.issues.element.extend({ + code: z.literal("resource-limit"), + }), + ) + .optional() + .describe( + "Resource-limit diagnostics reported by this source; an empty array means none were reported. Historical comparisons omit this field; omission does not establish that no resource limit occurred.", + ), +}); + /** Obfuscation-resistant, execution-free managed member comparison. */ export const managedMemberComparisonResultSchema = z.strictObject({ comparison_id: prefixedDigestSchema("mmc"), @@ -181,24 +202,8 @@ export const managedMemberComparisonResultSchema = z.strictObject({ token_identity: z.literal("build-local"), name_matching: z.literal("exact-signature-fallback"), }), - left: z.strictObject({ - evidence_id: evidenceIdSchema, - artifact_sha256: digestSchema, - mvid: cliMetadataGuidSchema.nullable(), - module_name: z.string().nullable(), - metadata_status: z.enum(["absent", "complete", "partial", "malformed"]), - methods_total: z.int().min(0), - fields_total: z.int().min(0), - }), - right: z.strictObject({ - evidence_id: evidenceIdSchema, - artifact_sha256: digestSchema, - mvid: cliMetadataGuidSchema.nullable(), - module_name: z.string().nullable(), - metadata_status: z.enum(["absent", "complete", "partial", "malformed"]), - methods_total: z.int().min(0), - fields_total: z.int().min(0), - }), + left: comparisonSideSchema, + right: comparisonSideSchema, summary: z.strictObject({ unchanged: z.int().min(0), changed: z.int().min(0), diff --git a/src/domain/managed/managedMemberComparisonCoverage.ts b/src/domain/managed/managedMemberComparisonCoverage.ts index f697848d65..ce4b6cb54c 100644 --- a/src/domain/managed/managedMemberComparisonCoverage.ts +++ b/src/domain/managed/managedMemberComparisonCoverage.ts @@ -131,6 +131,14 @@ const sideCoverageStatus = ( ? "complete" : "partial"; +/** Copy the source's typed capacity facts without retaining unrelated issue inventories. */ +const resourceLimits = ( + result: ManagedMemberInspection, +): NonNullable => + result.coverage.issues.flatMap(({ code, scope, detail, offset }) => + code === "resource-limit" ? [{ code, scope, detail, offset }] : [], + ); + export const sideManifest = ( side: ManagedMemberComparisonSide, ): ManagedMemberComparisonResult["left"] => ({ @@ -141,6 +149,7 @@ export const sideManifest = ( metadata_status: side.result.metadata.status, methods_total: side.result.methods.length, fields_total: side.result.fields.length, + resource_limits: resourceLimits(side.result), }); export const buildComparisonSummary = ( @@ -180,6 +189,27 @@ export const buildComparisonMatching = ( ).length, }); +/** Carry each side's representation-budget diagnostic and its recovery. */ +const capacityLimitations = ( + left: ManagedMemberInspection, + right: ManagedMemberInspection, +): string[] => { + const exceeded = new Set( + (["left", "right"] as const).flatMap((side) => + resourceLimits(side === "left" ? left : right).map( + ({ scope, detail }) => + `The ${side} managed member observation exceeded capacity at ${scope}: ${detail}. Its member collections are not examined to completion, so empty or missing members do not establish absence, addition or removal.`, + ), + ), + ); + return exceeded.size === 0 + ? [] + : [ + ...exceeded, + "Compare a smaller selection by inspecting both sides with the same type_names, or by passing --type to CLI compare-managed-members; a single selected type can still exceed capacity.", + ]; +}; + export const comparisonLimitations = ( left: ManagedMemberInspection, right: ManagedMemberInspection, @@ -220,6 +250,7 @@ export const comparisonLimitations = ( ); if (left.coverage.state !== "complete" || right.coverage.state !== "complete") limitations.push("At least one managed member observation is partial."); + limitations.push(...capacityLimitations(left, right)); if (selectionAgreement === "different") limitations.push( "The observations select different declaring types; a member outside the other side's selection is unknown, not added or removed.", diff --git a/src/dotnet/ManagedMemberTypeInventory.test.ts b/src/dotnet/ManagedMemberTypeInventory.test.ts index c05469b3fd..032c665258 100644 --- a/src/dotnet/ManagedMemberTypeInventory.test.ts +++ b/src/dotnet/ManagedMemberTypeInventory.test.ts @@ -8,7 +8,10 @@ import { buildManagedNestedTypeFixture, managedPeFixtureTarget, } from "./ManagedPe.fixture.js"; -import { buildManagedMemberCapacityFixture } from "../../tests/fixtures/managedMemberCapacity.js"; +import { + buildManagedMemberCapacityFixture, + buildManagedMemberSelectionCapacityFixture, +} from "../../tests/fixtures/managedMemberCapacity.js"; import { readManagedPeLayout } from "./ManagedPeReader.js"; import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js"; import { @@ -89,16 +92,7 @@ it("decodes a single selected type to completion on a game-scale assembly", () = // one field and one method while the rest of the member inventory stays // on the last type, so this also proves the selected members decode // instead of merely proving that skipping unselected rows saves budget. - const bytes = buildManagedPeFixture({ - additionalEmptyTypes: 9400, - fieldRowCount: 40037, - methodRowCount: 49738, - memberRefRowCount: 11442, - customAttributeRowCount: 58698, - distinctMemberNames: true, - firstTypeFieldRowCount: 1, - firstTypeMethodRowCount: 1, - }); + const bytes = buildManagedMemberSelectionCapacityFixture(); const target = managedPeFixtureTarget(bytes); const inventory = inspectManagedMembersBytes(bytes, target); const selected = inventory.types.find( diff --git a/tests/boundary/cli/managedMemberComparisonCli.test.ts b/tests/boundary/cli/managedMemberComparisonCli.test.ts index b52c3dff4e..f47842bf22 100644 --- a/tests/boundary/cli/managedMemberComparisonCli.test.ts +++ b/tests/boundary/cli/managedMemberComparisonCli.test.ts @@ -16,6 +16,8 @@ import { parseEvidence } from "../../../src/domain/evidence.js"; import { managedMemberInspectionSchema } from "../../../src/domain/managed/managedArtifact.js"; import { managedMemberComparisonResultSchema } from "../../../src/domain/managed/managedMemberComparison.js"; import { buildManagedPeFixture } from "../../../src/dotnet/ManagedPe.fixture.js"; +import { comparisonLimitations } from "../../../src/domain/managed/managedMemberComparisonCoverage.js"; +import { buildManagedMemberSelectionCapacityFixture } from "../../fixtures/managedMemberCapacity.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; @@ -193,6 +195,134 @@ cliTest( }, ); +cliTest( + "surfaces one side's member capacity limit in the comparison and recovers with a shared --type selection", + async ({ cli }) => { + const directory = await createTestTempDirectory("rea-managed-capacity-"); + const left = join(directory, "left.dll"); + const right = join(directory, "right.dll"); + await writeFile( + left, + buildManagedPeFixture({ + additionalEmptyTypes: 1, + fieldRowCount: 2, + methodRowCount: 2, + firstTypeFieldRowCount: 1, + firstTypeMethodRowCount: 1, + distinctMemberNames: true, + }), + ); + await writeFile(right, buildManagedMemberSelectionCapacityFixture(1)); + const call = await cli.sharedMcpCall!(); + const sources = []; + for (const path of [left, right]) { + const reply = await call("inspect_managed_members", { path }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + const evidence = parseEvidence(reply.structuredContent); + sources.push({ + evidence, + report: managedMemberInspectionSchema.parse(evidence.normalized_result), + }); + } + const [leftSource, rightSource] = sources; + if (leftSource === undefined || rightSource === undefined) + throw new Error("Missing source inspection"); + const limits = rightSource.report.coverage.issues.filter( + ({ code }) => code === "resource-limit", + ); + expect(limits).toEqual([ + expect.objectContaining({ + code: "resource-limit", + scope: "metadata.representation", + offset: null, + }), + ]); + + const whole = await cli.run({ + arguments: ["compare-managed-members", left, right, "--json"], + }); + expect(whole.exitCode, whole.stderr).toBe(0); + const wholeEvidence = parseEvidence(whole.json); + const wholeReport = managedMemberComparisonResultSchema.parse( + wholeEvidence.normalized_result, + ); + expect(wholeReport.coverage).toEqual({ + status: "partial", + left_status: "complete", + right_status: "partial", + }); + expect(wholeReport.right.methods_total).toBe(0); + expect(wholeReport.left.resource_limits).toEqual([]); + expect(wholeReport.right.resource_limits).toEqual(limits); + expect(wholeReport.limitations).toEqual( + comparisonLimitations(leftSource.report, rightSource.report), + ); + const mcpCompared = await call("compare_managed_members", { + left: leftSource.evidence, + right: rightSource.evidence, + }); + expect(mcpCompared.isError, JSON.stringify(mcpCompared)).not.toBe(true); + expect( + parseEvidence(mcpCompared.structuredContent).normalized_result, + ).toMatchObject({ + left: { resource_limits: [] }, + right: { resource_limits: limits }, + coverage: wholeReport.coverage, + }); + expect(wholeEvidence.limitations).toEqual( + expect.arrayContaining(wholeReport.limitations), + ); + + const selected = await cli.run({ + arguments: [ + "compare-managed-members", + left, + right, + "--type", + "Fixture.Empty0", + "--json", + ], + }); + expect(selected.exitCode, selected.stderr).toBe(0); + const selectedEvidence = parseEvidence(selected.json); + expect(selectedEvidence.parameters).toMatchObject({ + left_path: left, + right_path: right, + type_names: ["Fixture.Empty0"], + }); + const selectedReport = managedMemberComparisonResultSchema.parse( + selectedEvidence.normalized_result, + ); + expect(selectedReport.coverage).toMatchObject({ + left_status: "complete", + right_status: "complete", + }); + expect(selectedReport.left.resource_limits).toEqual([]); + expect(selectedReport.right.resource_limits).toEqual([]); + expect(selectedReport.summary).toEqual({ + unchanged: 2, + changed: 0, + added: 0, + removed: 0, + unknown: 0, + }); + expect(selectedReport.fields).toMatchObject([ + { + status: "unchanged", + left: { name: "Field0" }, + right: { name: "Field0" }, + }, + ]); + expect(selectedReport.methods).toMatchObject([ + { + status: "unchanged", + left: { name: "Method0" }, + right: { name: "Method0" }, + }, + ]); + }, +); + const managedComparisonEvidence = async () => { const directory = await createTestTempDirectory("rea-managed-finalization-"); const path = join(directory, "comparison.dll"); @@ -248,6 +378,7 @@ it.each([true, false])( try { const result = await registeredComparisonCommand().run({ args: { leftPath: "left.dll", rightPath: "right.dll" }, + options: {}, }); expect(result).toMatchObject({ error: "Managed member comparison failed", @@ -296,6 +427,7 @@ it("retains completed comparison Evidence when interrupted during final cleanup" try { const result = await registeredComparisonCommand().run({ args: { leftPath: "left.dll", rightPath: "right.dll" }, + options: {}, }); expect(result).toMatchObject({ error: "Managed member comparison failed", diff --git a/tests/boundary/providers/managed/managedMemberComparison.test.ts b/tests/boundary/providers/managed/managedMemberComparison.test.ts index c8d613d874..dcf442a063 100644 --- a/tests/boundary/providers/managed/managedMemberComparison.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparison.test.ts @@ -17,6 +17,7 @@ import { compareManagedMembersInputSchema, } from "../../../../src/domain/managed/managedMemberComparison.js"; import { readManagedMemberEvidence } from "../../../../src/domain/managed/managedMemberComparisonMatch.js"; +import { comparisonLimitations } from "../../../../src/domain/managed/managedMemberComparisonCoverage.js"; import { createEvidence } from "../../../../src/domain/evidence.js"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; @@ -94,11 +95,27 @@ it("keeps missing members unknown when the opposite inspection retains only type ); const completePath = join(directory, "complete.dll"); const limitedPath = join(directory, "limited.dll"); - await writeFile( - completePath, - buildManagedPeFixture({ additionalEmptyTypes: 2 }), + const completeBytes = buildManagedPeFixture({ additionalEmptyTypes: 2 }); + const limitedBytes = buildManagedMemberCapacityFixture(); + await writeFile(completePath, completeBytes); + await writeFile(limitedPath, limitedBytes); + const complete = inspectManagedMembersBytes( + completeBytes, + managedPeFixtureTarget(completeBytes, completePath), + { userStrings: false }, + ); + const limited = inspectManagedMembersBytes( + limitedBytes, + managedPeFixtureTarget(limitedBytes, limitedPath), + { userStrings: false }, + ); + expect(limited.coverage.issues).toContainEqual( + expect.objectContaining({ + code: "resource-limit", + scope: "metadata.representation", + offset: expect.any(Number), + }), ); - await writeFile(limitedPath, buildManagedMemberCapacityFixture()); for (const [leftPath, rightPath] of [ [completePath, limitedPath], [limitedPath, completePath], @@ -116,6 +133,18 @@ it("keeps missing members unknown when the opposite inspection retains only type expect(report.fields).toHaveLength(1); expect(report.methods[0]?.status).toBe("unknown"); expect(report.fields[0]?.status).toBe("unknown"); + const sources = + leftPath === completePath + ? ([complete, limited] as const) + : ([limited, complete] as const); + for (const [side, source] of [ + [report.left, sources[0]], + [report.right, sources[1]], + ] as const) + expect(side.resource_limits).toEqual( + source.coverage.issues.filter(({ code }) => code === "resource-limit"), + ); + expect(report.limitations).toEqual(comparisonLimitations(...sources)); } }); @@ -278,6 +307,19 @@ const pairs = (comparison: ReturnType) => { const original = nestedInspection(); const swapped = nestedInspection({ swapEnclosing: true }); +it("preserves unrecorded resource-limit facts in historical comparison manifests", () => { + const current = compare(original, swapped); + expect(current.left.resource_limits).toEqual([]); + expect(current.right.resource_limits).toEqual([]); + const historical = structuredClone(current); + delete historical.left.resource_limits; + delete historical.right.resource_limits; + const parsed = managedMemberComparisonResultSchema.parse(historical); + expect(parsed.left).not.toHaveProperty("resource_limits"); + expect(parsed.right).not.toHaveProperty("resource_limits"); + expect(parsed.summary).toEqual(current.summary); +}); + it("does not equate missing nested parents with genuinely top-level types", () => { const missing = nestedInspection({ nestedClassRows: [] }); const topLevel = nestedInspection({ topLevelTypes: true }); diff --git a/tests/fixtures/managedMemberCapacity.ts b/tests/fixtures/managedMemberCapacity.ts index 8512d02c80..ab9fd809c9 100644 --- a/tests/fixtures/managedMemberCapacity.ts +++ b/tests/fixtures/managedMemberCapacity.ts @@ -1,5 +1,20 @@ import { buildManagedPeFixture } from "../../src/dotnet/ManagedPe.fixture.js"; +/** Large member inventories with one small, nonempty type available for selection. */ +export const buildManagedMemberSelectionCapacityFixture = ( + additionalEmptyTypes = 9400, +): Buffer => + buildManagedPeFixture({ + additionalEmptyTypes, + fieldRowCount: 40037, + methodRowCount: 49738, + memberRefRowCount: 11442, + customAttributeRowCount: 58698, + distinctMemberNames: true, + firstTypeFieldRowCount: 1, + firstTypeMethodRowCount: 1, + }); + /** Valid dense CIL exceeds the unchanged decoding bound, after TypeDef admission. */ export const buildManagedMemberCapacityFixture = ( options: Parameters[0] = {}, From 772d6098f0228675a4cb35e2c5bbe98adcfa1c32 Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Fri, 6 Nov 2026 22:17:31 +0800 Subject: [PATCH 068/151] fix(environment): validate JSON pointer array indexes (#9147) --- .../analysisView/environmentStateWriteApply.test.ts | 2 ++ src/domain/analysisView/environmentStateWriteApply.ts | 11 +++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/domain/analysisView/environmentStateWriteApply.test.ts b/src/domain/analysisView/environmentStateWriteApply.test.ts index a4fc8133f1..33f0937519 100644 --- a/src/domain/analysisView/environmentStateWriteApply.test.ts +++ b/src/domain/analysisView/environmentStateWriteApply.test.ts @@ -142,6 +142,8 @@ it.each<[string, JsonValue | undefined]>([ ["/x~1y/m~0n", true], ["/", "blank"], ["/a/2/b", undefined], + ["/a/01/b", undefined], + ["/a/999999999999999999999999/b", undefined], ["/a/first", undefined], ["/a/0/b/c", undefined], ["/missing", undefined], diff --git a/src/domain/analysisView/environmentStateWriteApply.ts b/src/domain/analysisView/environmentStateWriteApply.ts index 255dd25d89..3386f1a3d0 100644 --- a/src/domain/analysisView/environmentStateWriteApply.ts +++ b/src/domain/analysisView/environmentStateWriteApply.ts @@ -180,8 +180,15 @@ export const valueAt = ( ): JsonValue | undefined => { let value: JsonValue | undefined = body; for (const token of pointerTokens(pointer)) { - if (Array.isArray(value)) value = value[Number(token)]; - else if (isObject(value) && Object.hasOwn(value, token)) + if (Array.isArray(value)) { + // JSON Pointer array indexes are canonical decimal tokens; coercing + // arbitrary text would make /01 alias /1 and can turn huge indexes into + // Infinity. + if (!/^(?:0|[1-9]\d*)$/u.test(token)) return undefined; + const index = Number(token); + if (!Number.isSafeInteger(index)) return undefined; + value = value[index]; + } else if (isObject(value) && Object.hasOwn(value, token)) value = value[token]; else return undefined; } From cfa7ebb0206aaf3cf5aeaf583f9b9336d2f9fea4 Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Fri, 6 Nov 2026 22:18:25 +0800 Subject: [PATCH 069/151] fix(environment): ignore unsafe paging bounds (#9145) --- .../analysisView/environmentStatePaging.test.ts | 11 ++++++++++- src/domain/analysisView/environmentStatePaging.ts | 6 ++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/domain/analysisView/environmentStatePaging.test.ts b/src/domain/analysisView/environmentStatePaging.test.ts index e679086ba7..bb472e4ecc 100644 --- a/src/domain/analysisView/environmentStatePaging.test.ts +++ b/src/domain/analysisView/environmentStatePaging.test.ts @@ -1,5 +1,5 @@ import { expect, it } from "vitest"; -import { startsAtBeginning } from "./environmentStatePaging.js"; +import { pageBound, startsAtBeginning } from "./environmentStatePaging.js"; it("requires every recorded position to establish the beginning, including repeated names", () => { expect(startsAtBeginning(undefined)).toBe(true); @@ -16,3 +16,12 @@ it("requires every recorded position to establish the beginning, including repea ]) expect(startsAtBeginning(query), query).toBe(false); }); + +it("does not infer a page bound from unsafe numeric query values", () => { + for (const query of [ + "?_start=999999999999999999999999&_end=100", + "?limit=9007199254740992", + "?_start=9007199254740993&_end=9007199254740995", + ]) + expect(pageBound(query, 5), query).toBeUndefined(); +}); diff --git a/src/domain/analysisView/environmentStatePaging.ts b/src/domain/analysisView/environmentStatePaging.ts index ea2f984cff..91cdfdc96c 100644 --- a/src/domain/analysisView/environmentStatePaging.ts +++ b/src/domain/analysisView/environmentStatePaging.ts @@ -64,15 +64,21 @@ const pageLimit = ( ): number | undefined => { let size = Infinity; const range = { start: 0, end: Infinity }; + let unsafeNumber = false; for (const [name, value] of iterateUrlQueryParameters(query ?? "")) { const key = name.toLowerCase(); if (!/^\d+$/u.test(value)) continue; const number = Number(value); + if (!Number.isSafeInteger(number)) { + unsafeNumber = true; + continue; + } if (PAGE_SIZE_PARAMETERS.has(key)) { if (number > 0 || !unboundedZero) size = Math.min(size, number); } else if (key === "_start") range.start = number; else if (key === "_end") range.end = number; } + if (unsafeNumber) return undefined; const width = Math.min(size, Math.max(range.end - range.start, 0)); return Number.isFinite(width) ? width : undefined; }; From bacc1e38464ce7eb87ce1fb6fb552c6b1854ac15 Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:44:56 +0800 Subject: [PATCH 070/151] perf(mcp)!: defer retained views in progressive catalogs (#9151) Keep full schemas and canonical analysis unchanged while loading the cross-domain view through existing named discovery. Qualify real recent-tool workflows with token ledgers, latency, CPU, family RSS and retained-follow-up controls. BREAKING CHANGE: The opt-in hot-cold catalog no longer preloads inspect_analysis_view. Discover it before calling it, batch with its producer when needed, or use the unchanged default full profile. --- docs/client-configuration.md | 11 + docs/mcp-contracts.md | 8 +- ...-tools-progressive-catalog-2026-11-06.json | 1491 +++++++++++++++++ ...nt-tools-progressive-catalog-2026-11-06.md | 66 + src/catalogDiscovery.ts | 3 +- .../progressiveAnalysisViews.test.ts | 98 ++ tests/boundary/mcp/deferredRuntime.test.ts | 12 +- tests/boundary/mcp/mcpDoctor.test.ts | 9 +- tests/boundary/mcp/streamableHttpMcp.test.ts | 9 +- 9 files changed, 1695 insertions(+), 12 deletions(-) create mode 100644 docs/verification/recent-tools-progressive-catalog-2026-11-06.json create mode 100644 docs/verification/recent-tools-progressive-catalog-2026-11-06.md create mode 100644 tests/acceptance/applications/progressiveAnalysisViews.test.ts diff --git a/docs/client-configuration.md b/docs/client-configuration.md index be1d0c53b1..64fca91f79 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -111,6 +111,17 @@ named tools through `discover_tools`. Setup preserves this explicit setting. See the [discovery contract](mcp-contracts.md#hotcold-catalog-discovery) for pagination, identity and family-scope semantics. +Analysis tools, including `inspect_analysis_view`, load on demand. For example, +load `inspect_api_contract` and supply its `view` to answer an initial OpenAPI +or GraphQL question directly. When later questions need retained facts, load +`inspect_analysis_view` and use the complete `parent_evidence_id`; source files +need not be reopened. If both tools are needed from the start, request both +names in one `discover_tools` batch to avoid another catalog refresh. The same +pattern applies to selected Playwright trace and endpoint-comparison results. +This defers unused schema delivery and client validator preparation; it does +not reduce parsing work, alter Evidence or shorten requested facts. Clients +without catalog refresh should keep the default `full` profile. + Codex CLI 0.165.0 requires its experimental `mcp_tool_list_changed` feature for this workflow. Merge these entries into the existing `~/.codex/config.toml` without replacing other feature or REA settings: diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index fccae5cb79..780f4b4846 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -1901,7 +1901,13 @@ The default, `full`, retains the complete upfront advertisement. Restart the server to change profiles. See [client configuration](client-configuration.md#hotcold-catalogs). Hot/cold initially advertises `binary_session`, `open_binary`, `close_binary`, -`get_evidence_bundle`, `inspect_analysis_view`, and the adapter's `discover_tools`. +`get_evidence_bundle`, and the adapter's `discover_tools`. +`inspect_analysis_view` is loaded on demand like other analysis tools. When a +follow-up needs retained facts, include it in `discover_tools.names`; if the +producer and follow-up are known together, load both in one batch. Producers +that accept `view` can answer a selected first question without loading the +cross-domain view contract. Complete output schemas and canonical result +validation are preserved when each tool loads. An explicit tool-family scope can remove any of those domain tools. `discover_tools` searches canonical names and descriptions using case-insensitive words, or accepts exact `names`. It loads the matching page and returns names diff --git a/docs/verification/recent-tools-progressive-catalog-2026-11-06.json b/docs/verification/recent-tools-progressive-catalog-2026-11-06.json new file mode 100644 index 0000000000..d335933aec --- /dev/null +++ b/docs/verification/recent-tools-progressive-catalog-2026-11-06.json @@ -0,0 +1,1491 @@ +{ + "tokenizer": "tiktoken==0.14.0/o200k_base", + "actual_model_usage": null, + "scope": "Estimates count a fixed analyst question, recorded server instructions once, every catalog snapshot or each unique tool input definition, all call arguments and all tool text replies. The SDK-wire proxy additionally counts output schemas and structuredContent duplication. No inference/model requests were made. Model rendering, catalog replay/caching, protocol framing and reasoning tokens are unmeasured.", + "runtime": { + "node": "24.22.1", + "sdk_client": "2.3.1", + "sdk_server": "2.3.1", + "client_flags": ["--max-old-space-size=1024"], + "server_flags": ["--max-old-space-size=1024"], + "baseline": "1480a7c72e601f4444e21ecd50118c1afa774209" + }, + "conditions": [ + "Three alternating repetitions of the fixed six-case suite, sequential fresh MCP servers and Client/AJV instances; the Node harness and SDK code are reused across its six cases. Filesystem caches were not flushed; cold-disk behavior is unmeasured. All advertised output schemas remain full; stock SDK caching and normal JIT retained.", + "Two alternating cold-client batched retained controls, each in its own Node harness. Parser fixtures and oracles were acquired before profiling; oracle consumption and assertions are inside the measured harness.", + "All runs use two CPU affinity, a 4 GiB sampled process-family RSS guard at 200 ms, and 1 GiB Node heaps. Cleanup is awaited. All compared attempts completed without a guard stop.", + "Initial failed harness attempt used an incorrect GraphQL line-start oracle; descriptions start before field declaration lines. That failed attempt is retained but excluded. The corrected oracle requires declaration lines within source ranges, matching source digests.", + "The full default catalog is exactly equal across before/after at 241 tools and 4,257,925 bytes. Only opt-in hot-cold initial visibility changes." + ], + "comparisons": [ + { + "name": "openapi", + "fact_digest": "8f03d4e3175e240be48845f3fdd571fb80dd6cf8c49d6ab8e57393e90f318b3e", + "before_repetitions": 3, + "after_repetitions": 3, + "milliseconds": { + "before_median": 13800.087245, + "before_range": [11737.716133, 13912.537394], + "after_median": 6464.514327, + "after_range": [5967.3796219999995, 6844.161873], + "change_percent": -53.155989435195785 + }, + "compile_milliseconds": { + "before_median": 7878.876172999997, + "before_range": [7050.097135, 7998.953358999999], + "after_median": 1380.081345999999, + "after_range": [1220.1001699999988, 1402.5490140000002], + "change_percent": -82.48377921296213 + }, + "catalog_wire_bytes": { + "before_median": 1602805, + "before_range": [1602805, 1602805], + "after_median": 220127, + "after_range": [220127, 220127], + "change_percent": -86.26613967388423 + }, + "snapshot_text_task_tokens": { + "before_median": 14764, + "before_range": [14764, 14764], + "after_median": 8098, + "after_range": [8098, 8098], + "change_percent": -45.15036575453807 + }, + "incremental_text_task_tokens": { + "before_median": 9669, + "before_range": [9669, 9669], + "after_median": 6336, + "after_range": [6336, 6336], + "change_percent": -34.470989761092156 + }, + "sdk_wire_tokens": { + "before_median": 423689, + "before_range": [423689, 423689], + "after_median": 65267, + "after_range": [65267, 65267], + "change_percent": -84.5955405969945 + }, + "catalog_count": { + "before_median": 2, + "before_range": [2, 2], + "after_median": 2, + "after_range": [2, 2], + "change_percent": 0.0 + }, + "call_count": { + "before_median": 3, + "before_range": [3, 3], + "after_median": 3, + "after_range": [3, 3], + "change_percent": 0.0 + } + }, + { + "name": "graphql", + "fact_digest": "4c3ab8e43f6fe8a0394333008edcd2b4c48d2f70c8eaf00bac09ad4ee18d3f2e", + "before_repetitions": 3, + "after_repetitions": 3, + "milliseconds": { + "before_median": 13415.565038, + "before_range": [12957.091652, 14744.741817], + "after_median": 7562.523673, + "after_range": [7395.386477999999, 8303.394408], + "change_percent": -43.62873534153113 + }, + "compile_milliseconds": { + "before_median": 6476.677151, + "before_range": [6433.232380999998, 7606.6485280000015], + "after_median": 1005.382477000001, + "after_range": [873.3461099999986, 1030.825600000002], + "change_percent": -84.47687828866428 + }, + "catalog_wire_bytes": { + "before_median": 1602805, + "before_range": [1602805, 1602805], + "after_median": 220127, + "after_range": [220127, 220127], + "change_percent": -86.26613967388423 + }, + "snapshot_text_task_tokens": { + "before_median": 16993, + "before_range": [16993, 16993], + "after_median": 10327, + "after_range": [10327, 10327], + "change_percent": -39.22791737774378 + }, + "incremental_text_task_tokens": { + "before_median": 11898, + "before_range": [11898, 11898], + "after_median": 8565, + "after_range": [8565, 8565], + "change_percent": -28.013111447302062 + }, + "sdk_wire_tokens": { + "before_median": 428170, + "before_range": [428170, 428170], + "after_median": 69748, + "after_range": [69748, 69748], + "change_percent": -83.71020856201976 + }, + "catalog_count": { + "before_median": 2, + "before_range": [2, 2], + "after_median": 2, + "after_range": [2, 2], + "change_percent": 0.0 + }, + "call_count": { + "before_median": 3, + "before_range": [3, 3], + "after_median": 3, + "after_range": [3, 3], + "change_percent": 0.0 + } + }, + { + "name": "trace", + "fact_digest": "ca36e480a4f4f0f13aaf1fd60415f571b53f630dba1aebcb901ad64d9c623cb4", + "before_repetitions": 3, + "after_repetitions": 3, + "milliseconds": { + "before_median": 12145.078334000002, + "before_range": [10803.033836000002, 12906.171000999999], + "after_median": 6746.290509999999, + "after_range": [6226.009792999999, 6969.350793999998], + "change_percent": -44.45247429064464 + }, + "compile_milliseconds": { + "before_median": 6087.562135000015, + "before_range": [6071.050374000002, 6374.3004250000085], + "after_median": 796.2965660000009, + "after_range": [696.1192190000002, 846.5835069999957], + "change_percent": -86.9192864345195 + }, + "catalog_wire_bytes": { + "before_median": 1594474, + "before_range": [1594474, 1594474], + "after_median": 211796, + "after_range": [211796, 211796], + "change_percent": -86.71687340151047 + }, + "snapshot_text_task_tokens": { + "before_median": 17041, + "before_range": [17041, 17041], + "after_median": 10375, + "after_range": [10375, 10375], + "change_percent": -39.11742268646206 + }, + "incremental_text_task_tokens": { + "before_median": 11946, + "before_range": [11946, 11946], + "after_median": 8613, + "after_range": [8613, 8613], + "change_percent": -27.900552486187845 + }, + "sdk_wire_tokens": { + "before_median": 424803, + "before_range": [424803, 424803], + "after_median": 66381, + "after_range": [66381, 66381], + "change_percent": -84.37369792586212 + }, + "catalog_count": { + "before_median": 2, + "before_range": [2, 2], + "after_median": 2, + "after_range": [2, 2], + "change_percent": 0.0 + }, + "call_count": { + "before_median": 3, + "before_range": [3, 3], + "after_median": 3, + "after_range": [3, 3], + "change_percent": 0.0 + } + }, + { + "name": "endpoints", + "fact_digest": "93cc0cd8829807f531e3b66d6c547e11ea8cf52d1927660e91aac2af46e04593", + "before_repetitions": 3, + "after_repetitions": 3, + "milliseconds": { + "before_median": 10606.162208000002, + "before_range": [9403.442415999998, 10791.434284999996], + "after_median": 5307.667614000002, + "after_range": [4955.987430000001, 5536.538309], + "change_percent": -49.956756177116155 + }, + "compile_milliseconds": { + "before_median": 6023.578876000014, + "before_range": [5398.957807999999, 6469.031580999996], + "after_median": 784.4235569999983, + "after_range": [762.5041139999994, 915.2510410000032], + "change_percent": -86.97745023103444 + }, + "catalog_wire_bytes": { + "before_median": 1588821, + "before_range": [1588821, 1588821], + "after_median": 206143, + "after_range": [206143, 206143], + "change_percent": -87.02541066614803 + }, + "snapshot_text_task_tokens": { + "before_median": 13391, + "before_range": [13391, 13391], + "after_median": 6725, + "after_range": [6725, 6725], + "change_percent": -49.77970278545292 + }, + "incremental_text_task_tokens": { + "before_median": 8296, + "before_range": [8296, 8296], + "after_median": 4963, + "after_range": [4963, 4963], + "change_percent": -40.17598842815815 + }, + "sdk_wire_tokens": { + "before_median": 416728, + "before_range": [416728, 416728], + "after_median": 58306, + "after_range": [58306, 58306], + "change_percent": -86.00861953120501 + }, + "catalog_count": { + "before_median": 2, + "before_range": [2, 2], + "after_median": 2, + "after_range": [2, 2], + "change_percent": 0.0 + }, + "call_count": { + "before_median": 3, + "before_range": [3, 3], + "after_median": 3, + "after_range": [3, 3], + "change_percent": 0.0 + } + }, + { + "name": "firmware", + "fact_digest": "262d18c202420e14fa43ab62a326edda0c7ebff44dd5a994bb4be1681e8b01ea", + "before_repetitions": 3, + "after_repetitions": 3, + "milliseconds": { + "before_median": 10206.227484000003, + "before_range": [10065.749725000001, 12356.191089], + "after_median": 6277.109490999999, + "after_range": [6096.664005999999, 6372.874781000002], + "change_percent": -38.49726061034367 + }, + "compile_milliseconds": { + "before_median": 5330.633561000017, + "before_range": [5208.604807999982, 6741.363641000011], + "after_median": 960.8874660000001, + "after_range": [950.9055049999988, 963.4927039999966], + "change_percent": -81.9742352385644 + }, + "catalog_wire_bytes": { + "before_median": 1643377, + "before_range": [1643377, 1643377], + "after_median": 260699, + "after_range": [260699, 260699], + "change_percent": -84.13638501694986 + }, + "snapshot_text_task_tokens": { + "before_median": 16563, + "before_range": [16563, 16563], + "after_median": 9897, + "after_range": [9897, 9897], + "change_percent": -40.24633218619815 + }, + "incremental_text_task_tokens": { + "before_median": 11468, + "before_range": [11468, 11468], + "after_median": 8135, + "after_range": [8135, 8135], + "change_percent": -29.06348099058249 + }, + "sdk_wire_tokens": { + "before_median": 437975, + "before_range": [437975, 437975], + "after_median": 79553, + "after_range": [79553, 79553], + "change_percent": -81.83617786403333 + }, + "catalog_count": { + "before_median": 2, + "before_range": [2, 2], + "after_median": 2, + "after_range": [2, 2], + "change_percent": 0.0 + }, + "call_count": { + "before_median": 6, + "before_range": [6, 6], + "after_median": 6, + "after_range": [6, 6], + "change_percent": 0.0 + } + }, + { + "name": "retained", + "fact_digest": "898ece83e260d8dad0f32fb5fe5f34958474e3114481c255d510b516c16d46fc", + "before_repetitions": 3, + "after_repetitions": 3, + "milliseconds": { + "before_median": 10778.632697999994, + "before_range": [9488.293762000008, 11417.009704000004], + "after_median": 12789.786540000001, + "after_range": [11933.108044, 13520.854069], + "change_percent": 18.658710231151886 + }, + "compile_milliseconds": { + "before_median": 5363.864044000024, + "before_range": [5019.943965000013, 5759.699594000005], + "after_median": 7155.112841999995, + "after_range": [6501.408383000002, 7514.264855000001], + "change_percent": 33.394746460877364 + }, + "catalog_wire_bytes": { + "before_median": 1602805, + "before_range": [1602805, 1602805], + "after_median": 1041219, + "after_range": [1041219, 1041219], + "change_percent": -35.0376995330062 + }, + "snapshot_text_task_tokens": { + "before_median": 19468, + "before_range": [19468, 19468], + "after_median": 18509, + "after_range": [18509, 18509], + "change_percent": -4.926032463529895 + }, + "incremental_text_task_tokens": { + "before_median": 14373, + "before_range": [14373, 14373], + "after_median": 14583, + "after_range": [14583, 14583], + "change_percent": 1.4610728449175436 + }, + "sdk_wire_tokens": { + "before_median": 433098, + "before_range": [433098, 433098], + "after_median": 288340, + "after_range": [288340, 288340], + "change_percent": -33.423844026063385 + }, + "catalog_count": { + "before_median": 2, + "before_range": [2, 2], + "after_median": 3, + "after_range": [3, 3], + "change_percent": 50.0 + }, + "call_count": { + "before_median": 4, + "before_range": [4, 4], + "after_median": 5, + "after_range": [5, 5], + "change_percent": 25.0 + } + }, + { + "name": "retained-batch", + "fact_digest": "898ece83e260d8dad0f32fb5fe5f34958474e3114481c255d510b516c16d46fc", + "before_repetitions": 2, + "after_repetitions": 2, + "milliseconds": { + "before_median": 12960.7983505, + "before_range": [12874.578895999999, 13047.017805], + "after_median": 13170.496023, + "after_range": [12574.366872999999, 13766.625173], + "change_percent": 1.6179379296639596 + }, + "compile_milliseconds": { + "before_median": 7359.362569500003, + "before_range": [7333.903944000005, 7384.8211949999995], + "after_median": 7463.924536999997, + "after_range": [7261.214694999997, 7666.634378999997], + "change_percent": 1.4208019582203857 + }, + "catalog_wire_bytes": { + "before_median": 1602805.0, + "before_range": [1602805, 1602805], + "after_median": 911466.0, + "after_range": [911466, 911466], + "change_percent": -43.133069836942106 + }, + "snapshot_text_task_tokens": { + "before_median": 19470.0, + "before_range": [19470, 19470], + "after_median": 16265.0, + "after_range": [16265, 16265], + "change_percent": -16.46122239342578 + }, + "incremental_text_task_tokens": { + "before_median": 14375.0, + "before_range": [14375, 14375], + "after_median": 14503.0, + "after_range": [14503, 14503], + "change_percent": 0.8904347826087067 + }, + "sdk_wire_tokens": { + "before_median": 433098.0, + "before_range": [433098, 433098], + "after_median": 254147.0, + "after_range": [254147, 254147], + "change_percent": -41.318823915141614 + }, + "catalog_count": { + "before_median": 2.0, + "before_range": [2, 2], + "after_median": 2.0, + "after_range": [2, 2], + "change_percent": 0.0 + }, + "call_count": { + "before_median": 4.0, + "before_range": [4, 4], + "after_median": 4.0, + "after_range": [4, 4], + "change_percent": 0.0 + } + } + ], + "records": [ + { + "cohort": "before", + "repetition": 2, + "resources": { + "label": "tools-opt-before-2-workflows", + "exit_code": 0, + "elapsed_seconds": 67.346, + "cpu_user_seconds": 67.885165, + "cpu_system_seconds": 4.171404, + "cpu_initial_user_seconds": 0.017537999999999998, + "cpu_initial_system_seconds": 0.003929, + "sampled_peak_family_rss_bytes": 1195388928, + "sample_interval_seconds": 0.2, + "stop": null + }, + "cases": [ + { + "name": "openapi", + "milliseconds": 11737.716133, + "compile_milliseconds": 7050.097135, + "catalog_count": 2, + "call_count": 3, + "question_tokens": 26, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 10590, + "catalog_input_incremental_tokens": 5495, + "argument_tokens": 165, + "text_reply_tokens": 3411, + "snapshot_text_task_tokens": 14764, + "incremental_text_task_tokens": 9669, + "sdk_wire_tokens": 423689, + "catalog_wire_bytes": 1602805, + "fact_digest": "8f03d4e3175e240be48845f3fdd571fb80dd6cf8c49d6ab8e57393e90f318b3e", + "receipt_sha256": "d7e451171cf8aabde46d01802e5ce352362689d24e38c61df7082fb9f0fd8a0d" + }, + { + "name": "graphql", + "milliseconds": 13415.565038, + "compile_milliseconds": 6433.232380999998, + "catalog_count": 2, + "call_count": 3, + "question_tokens": 26, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 10590, + "catalog_input_incremental_tokens": 5495, + "argument_tokens": 144, + "text_reply_tokens": 5661, + "snapshot_text_task_tokens": 16993, + "incremental_text_task_tokens": 11898, + "sdk_wire_tokens": 428170, + "catalog_wire_bytes": 1602805, + "fact_digest": "4c3ab8e43f6fe8a0394333008edcd2b4c48d2f70c8eaf00bac09ad4ee18d3f2e", + "receipt_sha256": "fb687cd5409bcae27999581a5647a61feea4e8646fb240c6105ba3fa429a7821" + }, + { + "name": "trace", + "milliseconds": 12145.078334000002, + "compile_milliseconds": 6087.562135000015, + "catalog_count": 2, + "call_count": 3, + "question_tokens": 31, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 11255, + "catalog_input_incremental_tokens": 6160, + "argument_tokens": 138, + "text_reply_tokens": 5045, + "snapshot_text_task_tokens": 17041, + "incremental_text_task_tokens": 11946, + "sdk_wire_tokens": 424803, + "catalog_wire_bytes": 1594474, + "fact_digest": "ca36e480a4f4f0f13aaf1fd60415f571b53f630dba1aebcb901ad64d9c623cb4", + "receipt_sha256": "d8bc6285b3ccf4c5eb9f18a871253436a59415eeb5958c0d85f157bc3d316c6f" + }, + { + "name": "endpoints", + "milliseconds": 9403.442415999998, + "compile_milliseconds": 5398.957807999999, + "catalog_count": 2, + "call_count": 3, + "question_tokens": 20, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 10733, + "catalog_input_incremental_tokens": 5638, + "argument_tokens": 223, + "text_reply_tokens": 1843, + "snapshot_text_task_tokens": 13391, + "incremental_text_task_tokens": 8296, + "sdk_wire_tokens": 416728, + "catalog_wire_bytes": 1588821, + "fact_digest": "93cc0cd8829807f531e3b66d6c547e11ea8cf52d1927660e91aac2af46e04593", + "receipt_sha256": "6482bced05b8c7bcc3e85ac615742e0b2538416906667fc9e71eb1ab95e8381d" + }, + { + "name": "firmware", + "milliseconds": 10065.749725000001, + "compile_milliseconds": 5330.633561000017, + "catalog_count": 2, + "call_count": 6, + "question_tokens": 23, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 10815, + "catalog_input_incremental_tokens": 5720, + "argument_tokens": 218, + "text_reply_tokens": 4935, + "snapshot_text_task_tokens": 16563, + "incremental_text_task_tokens": 11468, + "sdk_wire_tokens": 437975, + "catalog_wire_bytes": 1643377, + "fact_digest": "262d18c202420e14fa43ab62a326edda0c7ebff44dd5a994bb4be1681e8b01ea", + "receipt_sha256": "37e36f899bd1e03f43a5d38cb932c446528d614732fc4de2baedf29a9f10af2a" + }, + { + "name": "retained", + "milliseconds": 9488.293762000008, + "compile_milliseconds": 5019.943965000013, + "catalog_count": 2, + "call_count": 4, + "question_tokens": 21, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 10590, + "catalog_input_incremental_tokens": 5495, + "argument_tokens": 268, + "text_reply_tokens": 8017, + "snapshot_text_task_tokens": 19468, + "incremental_text_task_tokens": 14373, + "sdk_wire_tokens": 433098, + "catalog_wire_bytes": 1602805, + "fact_digest": "898ece83e260d8dad0f32fb5fe5f34958474e3114481c255d510b516c16d46fc", + "receipt_sha256": "03de58677cf04dcdc287295bfcc0e7062a7cb369a007eb3b4312dda75d978d9d" + } + ] + }, + { + "cohort": "before", + "repetition": 3, + "resources": { + "label": "tools-opt-before-3-workflows", + "exit_code": 0, + "elapsed_seconds": 70.618, + "cpu_user_seconds": 71.266879, + "cpu_system_seconds": 4.048731, + "cpu_initial_user_seconds": 0.0, + "cpu_initial_system_seconds": 0.0, + "sampled_peak_family_rss_bytes": 1224159232, + "sample_interval_seconds": 0.2, + "stop": null + }, + "cases": [ + { + "name": "openapi", + "milliseconds": 13912.537394, + "compile_milliseconds": 7878.876172999997, + "catalog_count": 2, + "call_count": 3, + "question_tokens": 26, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 10590, + "catalog_input_incremental_tokens": 5495, + "argument_tokens": 165, + "text_reply_tokens": 3411, + "snapshot_text_task_tokens": 14764, + "incremental_text_task_tokens": 9669, + "sdk_wire_tokens": 423689, + "catalog_wire_bytes": 1602805, + "fact_digest": "8f03d4e3175e240be48845f3fdd571fb80dd6cf8c49d6ab8e57393e90f318b3e", + "receipt_sha256": "f76ad42b75ce6e660c8d484ae1f27fbd171cfe00033b5c2b7b3f4102ad81c011" + }, + { + "name": "graphql", + "milliseconds": 12957.091652, + "compile_milliseconds": 6476.677151, + "catalog_count": 2, + "call_count": 3, + "question_tokens": 26, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 10590, + "catalog_input_incremental_tokens": 5495, + "argument_tokens": 144, + "text_reply_tokens": 5661, + "snapshot_text_task_tokens": 16993, + "incremental_text_task_tokens": 11898, + "sdk_wire_tokens": 428170, + "catalog_wire_bytes": 1602805, + "fact_digest": "4c3ab8e43f6fe8a0394333008edcd2b4c48d2f70c8eaf00bac09ad4ee18d3f2e", + "receipt_sha256": "3b884a5b3b19357e6ff6854dbe31166b343a1d5b96225f9e51f5c563d058712c" + }, + { + "name": "trace", + "milliseconds": 10803.033836000002, + "compile_milliseconds": 6071.050374000002, + "catalog_count": 2, + "call_count": 3, + "question_tokens": 31, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 11255, + "catalog_input_incremental_tokens": 6160, + "argument_tokens": 138, + "text_reply_tokens": 5045, + "snapshot_text_task_tokens": 17041, + "incremental_text_task_tokens": 11946, + "sdk_wire_tokens": 424803, + "catalog_wire_bytes": 1594474, + "fact_digest": "ca36e480a4f4f0f13aaf1fd60415f571b53f630dba1aebcb901ad64d9c623cb4", + "receipt_sha256": "614c4104bc0572c4c4058d2bb97166f717ea4a3639af00f855c637eca8ba47b0" + }, + { + "name": "endpoints", + "milliseconds": 10791.434284999996, + "compile_milliseconds": 6469.031580999996, + "catalog_count": 2, + "call_count": 3, + "question_tokens": 20, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 10733, + "catalog_input_incremental_tokens": 5638, + "argument_tokens": 223, + "text_reply_tokens": 1843, + "snapshot_text_task_tokens": 13391, + "incremental_text_task_tokens": 8296, + "sdk_wire_tokens": 416728, + "catalog_wire_bytes": 1588821, + "fact_digest": "93cc0cd8829807f531e3b66d6c547e11ea8cf52d1927660e91aac2af46e04593", + "receipt_sha256": "1618e05456bd77c193be80d49eb78fa5fb485cca4776e72293112db0b0a59e0f" + }, + { + "name": "firmware", + "milliseconds": 10206.227484000003, + "compile_milliseconds": 5208.604807999982, + "catalog_count": 2, + "call_count": 6, + "question_tokens": 23, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 10815, + "catalog_input_incremental_tokens": 5720, + "argument_tokens": 218, + "text_reply_tokens": 4935, + "snapshot_text_task_tokens": 16563, + "incremental_text_task_tokens": 11468, + "sdk_wire_tokens": 437975, + "catalog_wire_bytes": 1643377, + "fact_digest": "262d18c202420e14fa43ab62a326edda0c7ebff44dd5a994bb4be1681e8b01ea", + "receipt_sha256": "a2bfac082071d00f53c0f9afe435f7d55460be20f7e81f94be673f08d171815a" + }, + { + "name": "retained", + "milliseconds": 10778.632697999994, + "compile_milliseconds": 5363.864044000024, + "catalog_count": 2, + "call_count": 4, + "question_tokens": 21, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 10590, + "catalog_input_incremental_tokens": 5495, + "argument_tokens": 268, + "text_reply_tokens": 8017, + "snapshot_text_task_tokens": 19468, + "incremental_text_task_tokens": 14373, + "sdk_wire_tokens": 433098, + "catalog_wire_bytes": 1602805, + "fact_digest": "898ece83e260d8dad0f32fb5fe5f34958474e3114481c255d510b516c16d46fc", + "receipt_sha256": "c8c6ee11f6d4bf1ff896471f912d41281992657cf6d643e2239b43db7f460a61" + } + ] + }, + { + "cohort": "before", + "repetition": 4, + "resources": { + "label": "tools-opt-before-4-workflows", + "exit_code": 0, + "elapsed_seconds": 76.951, + "cpu_user_seconds": 77.627496, + "cpu_system_seconds": 4.267546, + "cpu_initial_user_seconds": 0.0, + "cpu_initial_system_seconds": 0.0, + "sampled_peak_family_rss_bytes": 1168723968, + "sample_interval_seconds": 0.2, + "stop": null + }, + "cases": [ + { + "name": "openapi", + "milliseconds": 13800.087245, + "compile_milliseconds": 7998.953358999999, + "catalog_count": 2, + "call_count": 3, + "question_tokens": 26, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 10590, + "catalog_input_incremental_tokens": 5495, + "argument_tokens": 165, + "text_reply_tokens": 3411, + "snapshot_text_task_tokens": 14764, + "incremental_text_task_tokens": 9669, + "sdk_wire_tokens": 423689, + "catalog_wire_bytes": 1602805, + "fact_digest": "8f03d4e3175e240be48845f3fdd571fb80dd6cf8c49d6ab8e57393e90f318b3e", + "receipt_sha256": "bcafb72b9d7648df086c1eedd1a115b43ac378049f1ccac6b70fb214b5b32708" + }, + { + "name": "graphql", + "milliseconds": 14744.741817, + "compile_milliseconds": 7606.6485280000015, + "catalog_count": 2, + "call_count": 3, + "question_tokens": 26, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 10590, + "catalog_input_incremental_tokens": 5495, + "argument_tokens": 144, + "text_reply_tokens": 5661, + "snapshot_text_task_tokens": 16993, + "incremental_text_task_tokens": 11898, + "sdk_wire_tokens": 428170, + "catalog_wire_bytes": 1602805, + "fact_digest": "4c3ab8e43f6fe8a0394333008edcd2b4c48d2f70c8eaf00bac09ad4ee18d3f2e", + "receipt_sha256": "5b8ed8a8929ee5df6f07a43334973f586295c52386634fd3c8b4ac521c335f9f" + }, + { + "name": "trace", + "milliseconds": 12906.171000999999, + "compile_milliseconds": 6374.3004250000085, + "catalog_count": 2, + "call_count": 3, + "question_tokens": 31, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 11255, + "catalog_input_incremental_tokens": 6160, + "argument_tokens": 138, + "text_reply_tokens": 5045, + "snapshot_text_task_tokens": 17041, + "incremental_text_task_tokens": 11946, + "sdk_wire_tokens": 424803, + "catalog_wire_bytes": 1594474, + "fact_digest": "ca36e480a4f4f0f13aaf1fd60415f571b53f630dba1aebcb901ad64d9c623cb4", + "receipt_sha256": "30cd015eb69a1e75bb652f17fec68bd137046fe0b844796a69854abf945c2ad8" + }, + { + "name": "endpoints", + "milliseconds": 10606.162208000002, + "compile_milliseconds": 6023.578876000014, + "catalog_count": 2, + "call_count": 3, + "question_tokens": 20, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 10733, + "catalog_input_incremental_tokens": 5638, + "argument_tokens": 223, + "text_reply_tokens": 1843, + "snapshot_text_task_tokens": 13391, + "incremental_text_task_tokens": 8296, + "sdk_wire_tokens": 416728, + "catalog_wire_bytes": 1588821, + "fact_digest": "93cc0cd8829807f531e3b66d6c547e11ea8cf52d1927660e91aac2af46e04593", + "receipt_sha256": "e37f8d277cff0106e9fffd6f7b75fcba07b4584841da9ae849fa479ed6036dbc" + }, + { + "name": "firmware", + "milliseconds": 12356.191089, + "compile_milliseconds": 6741.363641000011, + "catalog_count": 2, + "call_count": 6, + "question_tokens": 23, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 10815, + "catalog_input_incremental_tokens": 5720, + "argument_tokens": 218, + "text_reply_tokens": 4935, + "snapshot_text_task_tokens": 16563, + "incremental_text_task_tokens": 11468, + "sdk_wire_tokens": 437975, + "catalog_wire_bytes": 1643377, + "fact_digest": "262d18c202420e14fa43ab62a326edda0c7ebff44dd5a994bb4be1681e8b01ea", + "receipt_sha256": "9335cd9eaba386a1c577b54b937063ec13e37c2563043cd78112b33a45a2f1b9" + }, + { + "name": "retained", + "milliseconds": 11417.009704000004, + "compile_milliseconds": 5759.699594000005, + "catalog_count": 2, + "call_count": 4, + "question_tokens": 21, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 10590, + "catalog_input_incremental_tokens": 5495, + "argument_tokens": 268, + "text_reply_tokens": 8017, + "snapshot_text_task_tokens": 19468, + "incremental_text_task_tokens": 14373, + "sdk_wire_tokens": 433098, + "catalog_wire_bytes": 1602805, + "fact_digest": "898ece83e260d8dad0f32fb5fe5f34958474e3114481c255d510b516c16d46fc", + "receipt_sha256": "c2be9075161f0fe31502616e5cb88e4c97f2d3815fe761c6c136c3dfbf37391d" + } + ] + }, + { + "cohort": "after", + "repetition": 1, + "resources": { + "label": "tools-opt-after-1-workflows", + "exit_code": 0, + "elapsed_seconds": 46.181, + "cpu_user_seconds": 45.013929, + "cpu_system_seconds": 2.726881, + "cpu_initial_user_seconds": 0.0, + "cpu_initial_system_seconds": 0.0, + "sampled_peak_family_rss_bytes": 1051422720, + "sample_interval_seconds": 0.2, + "stop": null + }, + "cases": [ + { + "name": "openapi", + "milliseconds": 5967.3796219999995, + "compile_milliseconds": 1220.1001699999988, + "catalog_count": 2, + "call_count": 3, + "question_tokens": 26, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 3924, + "catalog_input_incremental_tokens": 2162, + "argument_tokens": 165, + "text_reply_tokens": 3411, + "snapshot_text_task_tokens": 8098, + "incremental_text_task_tokens": 6336, + "sdk_wire_tokens": 65267, + "catalog_wire_bytes": 220127, + "fact_digest": "8f03d4e3175e240be48845f3fdd571fb80dd6cf8c49d6ab8e57393e90f318b3e", + "receipt_sha256": "fedbafc903f87d1f8c40c02f2bde5709f6ece8981549fc05344eafa253ed79bd" + }, + { + "name": "graphql", + "milliseconds": 7395.386477999999, + "compile_milliseconds": 1005.382477000001, + "catalog_count": 2, + "call_count": 3, + "question_tokens": 26, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 3924, + "catalog_input_incremental_tokens": 2162, + "argument_tokens": 144, + "text_reply_tokens": 5661, + "snapshot_text_task_tokens": 10327, + "incremental_text_task_tokens": 8565, + "sdk_wire_tokens": 69748, + "catalog_wire_bytes": 220127, + "fact_digest": "4c3ab8e43f6fe8a0394333008edcd2b4c48d2f70c8eaf00bac09ad4ee18d3f2e", + "receipt_sha256": "6d7d8f0d5a817d8499797a7b998df956f769c3648feeaced3aedc8342819a2e8" + }, + { + "name": "trace", + "milliseconds": 6969.350793999998, + "compile_milliseconds": 846.5835069999957, + "catalog_count": 2, + "call_count": 3, + "question_tokens": 31, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 4589, + "catalog_input_incremental_tokens": 2827, + "argument_tokens": 138, + "text_reply_tokens": 5045, + "snapshot_text_task_tokens": 10375, + "incremental_text_task_tokens": 8613, + "sdk_wire_tokens": 66381, + "catalog_wire_bytes": 211796, + "fact_digest": "ca36e480a4f4f0f13aaf1fd60415f571b53f630dba1aebcb901ad64d9c623cb4", + "receipt_sha256": "719d7641b288ff9867d800a4249ea26c046130e706f7b90128485e912230ea3f" + }, + { + "name": "endpoints", + "milliseconds": 5536.538309, + "compile_milliseconds": 915.2510410000032, + "catalog_count": 2, + "call_count": 3, + "question_tokens": 20, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 4067, + "catalog_input_incremental_tokens": 2305, + "argument_tokens": 223, + "text_reply_tokens": 1843, + "snapshot_text_task_tokens": 6725, + "incremental_text_task_tokens": 4963, + "sdk_wire_tokens": 58306, + "catalog_wire_bytes": 206143, + "fact_digest": "93cc0cd8829807f531e3b66d6c547e11ea8cf52d1927660e91aac2af46e04593", + "receipt_sha256": "9ad69d0e327df3cb16a88b37217c578f53a90e5a0e2ea7a13bf7b24f37f6fafe" + }, + { + "name": "firmware", + "milliseconds": 6372.874781000002, + "compile_milliseconds": 960.8874660000001, + "catalog_count": 2, + "call_count": 6, + "question_tokens": 23, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 4149, + "catalog_input_incremental_tokens": 2387, + "argument_tokens": 218, + "text_reply_tokens": 4935, + "snapshot_text_task_tokens": 9897, + "incremental_text_task_tokens": 8135, + "sdk_wire_tokens": 79553, + "catalog_wire_bytes": 260699, + "fact_digest": "262d18c202420e14fa43ab62a326edda0c7ebff44dd5a994bb4be1681e8b01ea", + "receipt_sha256": "b12cf259423301aa506a6651b0c283525689ba42a5bd751df5747291c1612cb3" + }, + { + "name": "retained", + "milliseconds": 12789.786540000001, + "compile_milliseconds": 7155.112841999995, + "catalog_count": 3, + "call_count": 5, + "question_tokens": 21, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 9421, + "catalog_input_incremental_tokens": 5495, + "argument_tokens": 283, + "text_reply_tokens": 8212, + "snapshot_text_task_tokens": 18509, + "incremental_text_task_tokens": 14583, + "sdk_wire_tokens": 288340, + "catalog_wire_bytes": 1041219, + "fact_digest": "898ece83e260d8dad0f32fb5fe5f34958474e3114481c255d510b516c16d46fc", + "receipt_sha256": "0c4412cd4cbb4f3794d2ef6760516f37cbf2748b4123c5d6af31459c7b1331c7" + } + ] + }, + { + "cohort": "after", + "repetition": 2, + "resources": { + "label": "tools-opt-after-2-workflows", + "exit_code": 0, + "elapsed_seconds": 46.814, + "cpu_user_seconds": 45.982852, + "cpu_system_seconds": 2.926909, + "cpu_initial_user_seconds": 0.0, + "cpu_initial_system_seconds": 0.0, + "sampled_peak_family_rss_bytes": 1028833280, + "sample_interval_seconds": 0.2, + "stop": null + }, + "cases": [ + { + "name": "openapi", + "milliseconds": 6464.514327, + "compile_milliseconds": 1380.081345999999, + "catalog_count": 2, + "call_count": 3, + "question_tokens": 26, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 3924, + "catalog_input_incremental_tokens": 2162, + "argument_tokens": 165, + "text_reply_tokens": 3411, + "snapshot_text_task_tokens": 8098, + "incremental_text_task_tokens": 6336, + "sdk_wire_tokens": 65267, + "catalog_wire_bytes": 220127, + "fact_digest": "8f03d4e3175e240be48845f3fdd571fb80dd6cf8c49d6ab8e57393e90f318b3e", + "receipt_sha256": "46553079481bb3593d16f53329f7675419ba483cc0450d41d2409578a4177cc6" + }, + { + "name": "graphql", + "milliseconds": 7562.523673, + "compile_milliseconds": 873.3461099999986, + "catalog_count": 2, + "call_count": 3, + "question_tokens": 26, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 3924, + "catalog_input_incremental_tokens": 2162, + "argument_tokens": 144, + "text_reply_tokens": 5661, + "snapshot_text_task_tokens": 10327, + "incremental_text_task_tokens": 8565, + "sdk_wire_tokens": 69748, + "catalog_wire_bytes": 220127, + "fact_digest": "4c3ab8e43f6fe8a0394333008edcd2b4c48d2f70c8eaf00bac09ad4ee18d3f2e", + "receipt_sha256": "0938ae580a7de9eca03a63bff61c99c05e272efd17c3499206b44f9f2a8114eb" + }, + { + "name": "trace", + "milliseconds": 6746.290509999999, + "compile_milliseconds": 796.2965660000009, + "catalog_count": 2, + "call_count": 3, + "question_tokens": 31, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 4589, + "catalog_input_incremental_tokens": 2827, + "argument_tokens": 138, + "text_reply_tokens": 5045, + "snapshot_text_task_tokens": 10375, + "incremental_text_task_tokens": 8613, + "sdk_wire_tokens": 66381, + "catalog_wire_bytes": 211796, + "fact_digest": "ca36e480a4f4f0f13aaf1fd60415f571b53f630dba1aebcb901ad64d9c623cb4", + "receipt_sha256": "4dbb012a16b0d2427ef413a12baf0bdf4c19e9dedafd33cfd2690813be6ad6c1" + }, + { + "name": "endpoints", + "milliseconds": 5307.667614000002, + "compile_milliseconds": 784.4235569999983, + "catalog_count": 2, + "call_count": 3, + "question_tokens": 20, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 4067, + "catalog_input_incremental_tokens": 2305, + "argument_tokens": 223, + "text_reply_tokens": 1843, + "snapshot_text_task_tokens": 6725, + "incremental_text_task_tokens": 4963, + "sdk_wire_tokens": 58306, + "catalog_wire_bytes": 206143, + "fact_digest": "93cc0cd8829807f531e3b66d6c547e11ea8cf52d1927660e91aac2af46e04593", + "receipt_sha256": "d651d5a5f17352d1619d9be3d1b43d5189f65fe9552f898fabd4d2fe2cdfbd30" + }, + { + "name": "firmware", + "milliseconds": 6277.109490999999, + "compile_milliseconds": 963.4927039999966, + "catalog_count": 2, + "call_count": 6, + "question_tokens": 23, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 4149, + "catalog_input_incremental_tokens": 2387, + "argument_tokens": 218, + "text_reply_tokens": 4935, + "snapshot_text_task_tokens": 9897, + "incremental_text_task_tokens": 8135, + "sdk_wire_tokens": 79553, + "catalog_wire_bytes": 260699, + "fact_digest": "262d18c202420e14fa43ab62a326edda0c7ebff44dd5a994bb4be1681e8b01ea", + "receipt_sha256": "7ba0fbe19d0e26234f175e9736f681fd1f532f4f43bcff036c890b5a8051a77a" + }, + { + "name": "retained", + "milliseconds": 13520.854069, + "compile_milliseconds": 7514.264855000001, + "catalog_count": 3, + "call_count": 5, + "question_tokens": 21, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 9421, + "catalog_input_incremental_tokens": 5495, + "argument_tokens": 283, + "text_reply_tokens": 8212, + "snapshot_text_task_tokens": 18509, + "incremental_text_task_tokens": 14583, + "sdk_wire_tokens": 288340, + "catalog_wire_bytes": 1041219, + "fact_digest": "898ece83e260d8dad0f32fb5fe5f34958474e3114481c255d510b516c16d46fc", + "receipt_sha256": "e99d6ef9f230105a7f022ae116a1fc5b5dd0b9dc226c2d31cd4d53e2c5310af3" + } + ] + }, + { + "cohort": "after", + "repetition": 3, + "resources": { + "label": "tools-opt-after-3-workflows", + "exit_code": 0, + "elapsed_seconds": 45.414, + "cpu_user_seconds": 44.322896000000014, + "cpu_system_seconds": 2.9074579999999983, + "cpu_initial_user_seconds": 265.20459, + "cpu_initial_system_seconds": 33.979246, + "sampled_peak_family_rss_bytes": 1046691840, + "sample_interval_seconds": 0.2, + "stop": null + }, + "cases": [ + { + "name": "openapi", + "milliseconds": 6844.161873, + "compile_milliseconds": 1402.5490140000002, + "catalog_count": 2, + "call_count": 3, + "question_tokens": 26, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 3924, + "catalog_input_incremental_tokens": 2162, + "argument_tokens": 165, + "text_reply_tokens": 3411, + "snapshot_text_task_tokens": 8098, + "incremental_text_task_tokens": 6336, + "sdk_wire_tokens": 65267, + "catalog_wire_bytes": 220127, + "fact_digest": "8f03d4e3175e240be48845f3fdd571fb80dd6cf8c49d6ab8e57393e90f318b3e", + "receipt_sha256": "a6bacdd8805a6ccdcb24b14f750851ccd427d56d2c2730e10b7ac5a9c9cc254b" + }, + { + "name": "graphql", + "milliseconds": 8303.394408, + "compile_milliseconds": 1030.825600000002, + "catalog_count": 2, + "call_count": 3, + "question_tokens": 26, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 3924, + "catalog_input_incremental_tokens": 2162, + "argument_tokens": 144, + "text_reply_tokens": 5661, + "snapshot_text_task_tokens": 10327, + "incremental_text_task_tokens": 8565, + "sdk_wire_tokens": 69748, + "catalog_wire_bytes": 220127, + "fact_digest": "4c3ab8e43f6fe8a0394333008edcd2b4c48d2f70c8eaf00bac09ad4ee18d3f2e", + "receipt_sha256": "6ca5f9910a67f66e1ed2585a8ec1842463fc57f46df2531e999b9967d7ee5e06" + }, + { + "name": "trace", + "milliseconds": 6226.009792999999, + "compile_milliseconds": 696.1192190000002, + "catalog_count": 2, + "call_count": 3, + "question_tokens": 31, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 4589, + "catalog_input_incremental_tokens": 2827, + "argument_tokens": 138, + "text_reply_tokens": 5045, + "snapshot_text_task_tokens": 10375, + "incremental_text_task_tokens": 8613, + "sdk_wire_tokens": 66381, + "catalog_wire_bytes": 211796, + "fact_digest": "ca36e480a4f4f0f13aaf1fd60415f571b53f630dba1aebcb901ad64d9c623cb4", + "receipt_sha256": "7bdb887b8253bc6bb6769470af0fd4a117640dafc32f8e05dcd36cc17b75f24e" + }, + { + "name": "endpoints", + "milliseconds": 4955.987430000001, + "compile_milliseconds": 762.5041139999994, + "catalog_count": 2, + "call_count": 3, + "question_tokens": 20, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 4067, + "catalog_input_incremental_tokens": 2305, + "argument_tokens": 223, + "text_reply_tokens": 1843, + "snapshot_text_task_tokens": 6725, + "incremental_text_task_tokens": 4963, + "sdk_wire_tokens": 58306, + "catalog_wire_bytes": 206143, + "fact_digest": "93cc0cd8829807f531e3b66d6c547e11ea8cf52d1927660e91aac2af46e04593", + "receipt_sha256": "74ed65f0f40b05c7a0ae294f8c67cebc85f7bfe38b80376150e3a55f1f28012b" + }, + { + "name": "firmware", + "milliseconds": 6096.664005999999, + "compile_milliseconds": 950.9055049999988, + "catalog_count": 2, + "call_count": 6, + "question_tokens": 23, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 4149, + "catalog_input_incremental_tokens": 2387, + "argument_tokens": 218, + "text_reply_tokens": 4935, + "snapshot_text_task_tokens": 9897, + "incremental_text_task_tokens": 8135, + "sdk_wire_tokens": 79553, + "catalog_wire_bytes": 260699, + "fact_digest": "262d18c202420e14fa43ab62a326edda0c7ebff44dd5a994bb4be1681e8b01ea", + "receipt_sha256": "45f3e4e715b7a1147328dd948906fd9bdccc72b481cc60daba2659006a0b1e29" + }, + { + "name": "retained", + "milliseconds": 11933.108044, + "compile_milliseconds": 6501.408383000002, + "catalog_count": 3, + "call_count": 5, + "question_tokens": 21, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 9421, + "catalog_input_incremental_tokens": 5495, + "argument_tokens": 283, + "text_reply_tokens": 8212, + "snapshot_text_task_tokens": 18509, + "incremental_text_task_tokens": 14583, + "sdk_wire_tokens": 288340, + "catalog_wire_bytes": 1041219, + "fact_digest": "898ece83e260d8dad0f32fb5fe5f34958474e3114481c255d510b516c16d46fc", + "receipt_sha256": "1e3d5a55042339f573de7cc74f6522ebbf8581e14dc1b9f493e8935c8ff0b2e7" + } + ] + }, + { + "cohort": "before-batch", + "repetition": 1, + "resources": { + "label": "tools-opt-before-batch-1", + "exit_code": 0, + "elapsed_seconds": 13.612, + "cpu_user_seconds": 13.502546, + "cpu_system_seconds": 1.09891, + "cpu_initial_user_seconds": 0.0, + "cpu_initial_system_seconds": 0.0, + "sampled_peak_family_rss_bytes": 975155200, + "sample_interval_seconds": 0.2, + "stop": null + }, + "cases": [ + { + "name": "retained-batch", + "milliseconds": 13047.017805, + "compile_milliseconds": 7384.8211949999995, + "catalog_count": 2, + "call_count": 4, + "question_tokens": 23, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 10590, + "catalog_input_incremental_tokens": 5495, + "argument_tokens": 268, + "text_reply_tokens": 8017, + "snapshot_text_task_tokens": 19470, + "incremental_text_task_tokens": 14375, + "sdk_wire_tokens": 433098, + "catalog_wire_bytes": 1602805, + "fact_digest": "898ece83e260d8dad0f32fb5fe5f34958474e3114481c255d510b516c16d46fc", + "receipt_sha256": "8111ae1b4d5e4ca96a3a4777b4ed163a5df69f35cb45fe143ed737e47c510881" + } + ] + }, + { + "cohort": "before-batch", + "repetition": 2, + "resources": { + "label": "tools-opt-before-batch-2", + "exit_code": 0, + "elapsed_seconds": 13.391, + "cpu_user_seconds": 12.692829, + "cpu_system_seconds": 1.044382, + "cpu_initial_user_seconds": 0.0, + "cpu_initial_system_seconds": 0.0, + "sampled_peak_family_rss_bytes": 971472896, + "sample_interval_seconds": 0.2, + "stop": null + }, + "cases": [ + { + "name": "retained-batch", + "milliseconds": 12874.578895999999, + "compile_milliseconds": 7333.903944000005, + "catalog_count": 2, + "call_count": 4, + "question_tokens": 23, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 10590, + "catalog_input_incremental_tokens": 5495, + "argument_tokens": 268, + "text_reply_tokens": 8017, + "snapshot_text_task_tokens": 19470, + "incremental_text_task_tokens": 14375, + "sdk_wire_tokens": 433098, + "catalog_wire_bytes": 1602805, + "fact_digest": "898ece83e260d8dad0f32fb5fe5f34958474e3114481c255d510b516c16d46fc", + "receipt_sha256": "9c26e3e9c54a2733f97eb3e0869260061eb356c9ef4b7d051d5b6c6d3d7e8644" + } + ] + }, + { + "cohort": "after-batch", + "repetition": 1, + "resources": { + "label": "tools-opt-after-batch-1", + "exit_code": 0, + "elapsed_seconds": 13.059, + "cpu_user_seconds": 13.387999, + "cpu_system_seconds": 1.02984, + "cpu_initial_user_seconds": 0.0, + "cpu_initial_system_seconds": 0.0, + "sampled_peak_family_rss_bytes": 981540864, + "sample_interval_seconds": 0.2, + "stop": null + }, + "cases": [ + { + "name": "retained-batch", + "milliseconds": 12574.366872999999, + "compile_milliseconds": 7261.214694999997, + "catalog_count": 2, + "call_count": 4, + "question_tokens": 23, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 7257, + "catalog_input_incremental_tokens": 5495, + "argument_tokens": 272, + "text_reply_tokens": 8141, + "snapshot_text_task_tokens": 16265, + "incremental_text_task_tokens": 14503, + "sdk_wire_tokens": 254147, + "catalog_wire_bytes": 911466, + "fact_digest": "898ece83e260d8dad0f32fb5fe5f34958474e3114481c255d510b516c16d46fc", + "receipt_sha256": "9428e22be462fd1575c823dfaf3c2d9b4cef2346f73c3e72e8ca67f2cace5433" + } + ] + }, + { + "cohort": "after-batch", + "repetition": 2, + "resources": { + "label": "tools-opt-after-batch-2", + "exit_code": 0, + "elapsed_seconds": 14.441, + "cpu_user_seconds": 13.627651, + "cpu_system_seconds": 1.1311650000000002, + "cpu_initial_user_seconds": 42.72465, + "cpu_initial_system_seconds": 5.985825, + "sampled_peak_family_rss_bytes": 969101312, + "sample_interval_seconds": 0.2, + "stop": null + }, + "cases": [ + { + "name": "retained-batch", + "milliseconds": 13766.625173, + "compile_milliseconds": 7666.634378999997, + "catalog_count": 2, + "call_count": 4, + "question_tokens": 23, + "instructions_tokens": 572, + "catalog_input_snapshot_tokens": 7257, + "catalog_input_incremental_tokens": 5495, + "argument_tokens": 272, + "text_reply_tokens": 8141, + "snapshot_text_task_tokens": 16265, + "incremental_text_task_tokens": 14503, + "sdk_wire_tokens": 254147, + "catalog_wire_bytes": 911466, + "fact_digest": "898ece83e260d8dad0f32fb5fe5f34958474e3114481c255d510b516c16d46fc", + "receipt_sha256": "953edaf7a37f6fbb16e02738b621eab71d7ef264e5ce6c631637bb1d58aa594f" + } + ] + } + ], + "inputs": [ + { + "name": "petstore", + "bytes": 17106, + "sha256": "00441c05add60f28da55eb4563d3fb6bbda364ba8eb7b381e2e2c98613005c82" + }, + { + "name": "github-openapi", + "bytes": 14151332, + "sha256": "02e621ca680e6b45acfb0088d9b7e1648b7c332c6778d18dee8762bb5ccbeb9f" + }, + { + "name": "swapi", + "bytes": 35868, + "sha256": "ebc4d09b21e2aec2735d6f0d3c812fc017cd49a44900dd3ae34f49f6f8c25133" + }, + { + "name": "github-sdl", + "bytes": 1562841, + "sha256": "0d71ca68a4e951599b2ea541a81cc259a843608fc22b8eb1713a1d03551b149e" + }, + { + "name": "trace-small", + "bytes": 10643, + "sha256": "7a7e0210f0c815225b7b65e93a37f350e2e44061cd8cf6d8170479b79c0f0b87" + }, + { + "name": "trace-large", + "bytes": 652278, + "sha256": "d9f2234e8338aab777c3dfb58c507d856947f9815b31653f0e5b34a97bdef3b6" + }, + { + "name": "javascript-evidence", + "bytes": 173722, + "sha256": "fa35b8bab470284650fab41685228cc34a2d04f046e9323d196c178f03a1f539" + }, + { + "name": "har-evidence", + "bytes": 24241, + "sha256": "767ac5e1b6d11dfb82548c73200efae9acc5a9d90313eab176c3debccdf5355b" + }, + { + "name": "openwrt-24.10.0", + "bytes": 8519680, + "sha256": "083b174e81ed373f2d2f2ea5366f9291767d95375383528fd6b9955ad815b85a" + }, + { + "name": "openwrt-24.10.1", + "bytes": 8585216, + "sha256": "41129cce68ad6269e5c8c48ef110f97682d6c4202ff2198bfd0d300c3dac132a" + } + ], + "independent_trace_network_oracle": "Every returned source line, method, URL and status agrees with raw original ZIP trace.network JSON, checked after profiling.", + "candidate_source_sha256": "3bacf5d50f60dc008a09e1b3deca8e4955b89f5a2d2eafa89c08734b7ca45a6c", + "policy_patch_sha256": "d9e0dd4dc5bd5227acb3ae00d444ff61c75621758fa17bb6179a5f3277fa73df", + "local_program_sha256": { + "workflows.mjs": "6db5797a3284e67f978cc5d238b819bdfd92905ca3a0fb13a044c1826e89dd8c", + "batched-workflow.mjs": "64c8d2542e129c729b2278abfa850bf10450f70f029dac50e87bf6488b44e518", + "count-costs.py": "34781f1da818d7127acb4aff481493ad8e80112d0e80ddae086fcd5c6c37296e" + }, + "full_default_catalog": { + "tools": 241, + "bytes": 4257925, + "sha256": "3c2b50a047ad033b02335d03fc7315e8d8bcf16613abd13d402b1b1bc05ef915", + "exact_before_after_equality": true + }, + "case_timing_scope": "Per-case milliseconds run through verified answer, excluding its final close. Suite resource receipts include every client/server close, all six completed tasks and receipt writes.", + "artifact_availability": "Raw harnesses, catalogs, calls, original local trace/HAR captures, profiler logs and receipts remain outside Git. This summary records their digests; exact timing reproduction requires the matching external fixtures. Public stdio regression is self-contained.", + "integration_verification": { + "base": "fbe7532fabb78de46bd00db845c1147b5325ad22", + "runtime_source_commit": "20e41b5ca03b2595a4a6fb39bf0816b3b29d5546", + "default_catalog": { + "tools": 241, + "bytes": 4257977, + "same_base_exact_equality": true, + "sha256": "d2b6232db43fb9d120fd2d99bd82dac010df83827d34fa331a0d39f8f22848de" + }, + "public_workflow": "passed", + "real_workflows": "all six analysis Evidence digests match the pinned qualification", + "resources": { + "label": "tools-opt-rebased-workflows", + "exit_code": 0, + "elapsed_seconds": 48.151, + "cpu_user_seconds": 47.956187, + "cpu_system_seconds": 3.336528, + "cpu_initial_user_seconds": 0.0, + "cpu_initial_system_seconds": 0.0, + "sampled_peak_family_rss_bytes": 1024729088, + "sample_interval_seconds": 0.2, + "stop": null + }, + "note": "The intervening main change modifies a managed comparison output schema. Default-catalog equality was checked against that same new base, separately from the pinned performance cohort." + } +} diff --git a/docs/verification/recent-tools-progressive-catalog-2026-11-06.md b/docs/verification/recent-tools-progressive-catalog-2026-11-06.md new file mode 100644 index 0000000000..ad20412256 --- /dev/null +++ b/docs/verification/recent-tools-progressive-catalog-2026-11-06.md @@ -0,0 +1,66 @@ +# Progressive catalog costs for recent analysis tools + +## Decision and compatibility + +Load `inspect_analysis_view` on demand in the opt-in `hot-cold` profile. Its cross-domain output contract is unused when a producer's inline `view` answers the question. The existing catalog owner already handles exact-name discovery, visibility, notifications, scope and identity; no new cache, analysis pipeline or protocol is added. + +This changes initial hot/cold visibility: clients that assumed the view was always loaded must discover it before calling it. When follow-ups are already known, discover the producer and view in one batch. On the pinned measurement baseline, the default `full` catalog is exactly unchanged: 241 tools, 4,257,925 serialized bytes, identical names, schemas, annotations and descriptions. Complete schemas, canonical validation, Evidence IDs, provenance, unknowns and requested facts remain intact. + +## Pinned verification + +Baseline: `1480a7c72e601f4444e21ecd50118c1afa774209`; candidate is that checkout with the `src/catalogDiscovery.ts` policy change recorded in the [JSON receipt](recent-tools-progressive-catalog-2026-11-06.json). Node 24.22.1 and MCP client/server 2.3.1, Linux x64. No target execution or browser launch occurs during these offline tasks. + +Three alternating before/after suite repetitions use sequential fresh MCP servers, Client and AJV instances for six cases. The Node harness/SDK code is shared across the six cases in a repetition, so later cases have warm code. Filesystem caches were not flushed; these are repeat measurements, not a cold-disk qualification. Capture acquisition, fixture generation and builds preceded profiling. Two separate alternating batched controls use a fresh Node harness per trial. All Node processes use normal JIT and 1 GiB heaps. Stock SDK output validation/caching is retained without per-call tool-definition overrides. + +The SDK 2.3.1 cache compiles validators for the complete discovered list on first use. With `application,session,artifact` families and full output schemas, the initial hot/cold catalog drops from 781,713 to 90,374 bytes. The five inline-only tasks reduce cumulative validator preparation from roughly 5–8 seconds to 0.7–1.4 seconds. This is a client/catalog cost reduction, not faster artifact parsing. + +## Answer latency and token estimates + +Per-case times below include discovery, analysis and oracle assertions through the verified answer; final close is accounted for in the suite totals below. Values are medians of three repetitions, except the two-repetition batched control. + +Token estimates use `tiktoken==0.14.0`, `o200k_base`. The conservative column counts each distinct visible tool input definition once, recorded server instructions, a fixed question, every tool argument and every text reply. It includes discovery calls. It is a proxy, not model billing: actual hosts may prune tools, replay catalogs or render structured results differently. No model requests were made. + +| Completed question | Answer seconds before → after | Incremental input/text token proxy before → after | +| ---------------------------------------------- | ----------------------------: | ------------------------------------------------: | +| OpenAPI: Petstore + GitHub GET declarations | 13.80 → 6.46 | 9,669 → 6,336 | +| GraphQL: SWAPI Film + GitHub Repository fields | 13.42 → 7.56 | 11,898 → 8,565 | +| Playwright: small + large network events | 12.15 → 6.75 | 11,946 → 8,613 | +| Static JS / HAR: unobserved endpoint | 10.61 → 5.31 | 8,296 → 4,963 | +| OpenWrt: two FIT component inventories | 10.21 → 6.28 | 11,468 → 8,135 | +| Later-discovered retained follow-ups | 10.78 → 12.79 | 14,373 → 14,583 | +| Batched producer + retained follow-ups | 12.96 → 13.17 | 14,375 → 14,503 | + +The five inline-only tasks improve in this cohort; the conservative token proxy falls 28–40%. The retained cases eventually need the same definitions and **do not establish a token reduction** under incremental loading. Discovering the view later adds one tool call and one catalog refresh; the shared-harness cohort also shows slower answer latency. Batching avoids that extra discovery/refresh, but the cold-client timing ranges overlap: no retained-workflow speedup is established. + +If each refreshed catalog is instead counted once in full, the snapshot/text proxy falls further, including in the retained cases. The SDK-wire proxy additionally includes output schemas and duplicated `structuredContent`; these are transport costs and must not be presented as model input. Both ledgers and all ranges are in the JSON receipt. + +## CPU, memory and cleanup + +Each entire six-case suite completes all server/client closes and is measured with two CPU affinity, a 4 GiB sampled process-family RSS guard, 200 ms samples and a 20-minute deadline. No compared run hit a guard. These are suite totals, not per-case attribution. + +| Six-case suite, median | Before | After | +| ------------------------------- | -------: | -------: | +| Elapsed | 70.62 s | 46.18 s | +| User + system CPU | 75.32 s | 47.74 s | +| Sampled process-family peak RSS | 1.11 GiB | 0.97 GiB | + +The RSS statistic sums process RSS and can count shared pages more than once. Host-wide pressure, inference CPU/memory, cold-disk performance and sustained large-page generic-validator JIT behavior are unmeasured. No universal provider, model-token or memory improvement is claimed. + +## Correctness oracle and limits + +- OpenAPI selections agree with the original JSON path/method declarations in small Petstore and large GitHub specifications. +- GraphQL names and declaration lines come from an independent source-line oracle for SWAPI and GitHub SDL; declarations must lie inside returned source ranges, including preceding SDL descriptions, with matching source digests. +- Every selected trace network line, method, URL and status agrees with raw original ZIP `trace.network` JSON. Both original small and large Playwright recordings are used. +- Endpoint comparison reuses authenticated static JavaScript/browser-HAR Evidence from #9090 and identifies `/api/admin/export` as not observed in that capture. It does not establish endpoint absence outside the capture. +- OpenWrt 24.10.0/24.10.1 ABT ASR3000 recovery FITs retain the previously independently checked kernel/initrd changes, identical DTB and separately changed outer `.` from #9143. No decompression, device execution or native provider analysis. +- Analysis Evidence digests match across every paired run. The public stdio regression also removes its owned source file after import, discovers the view, verifies the complete advertised output schema, queries retained facts and exports the complete source Evidence. Repeat discovery emits no extra notification. + +Input sizes/digests and profiler/receipt digests are recorded in JSON. Original qualification sources and scope are in #9090, #9104, #9127, #9140 and #9143. Raw harnesses and captures remain local outside Git; exact real-data timing reproduction requires those matching external fixtures. The public regression is self-contained. + +An initial harness attempt used an incorrect assertion that a field declaration line must equal its source range start. SDL descriptions precede some declarations. That attempt failed, is retained separately and is excluded; the corrected oracle checks containment. The intended public regression fails on the base because the view is initially loaded, then passes after the policy change. + +No default catalog change, weakened output validation, shortened IDs, omitted Evidence, guessed absence, additional parsed-result cache or parser speedup is part of this change. + +## Integration verification + +After rebasing onto `fbe7532fabb78de46bd00db845c1147b5325ad22`, the public workflow passed again and all six real-workflow Evidence digests matched the qualification above. An unrelated upstream managed schema change makes the default catalog 4,257,977 bytes; a separate same-base comparison confirms exact equality before and after this policy change. The repeated performance cohort remains pinned to its original baseline; the additional integration run is recorded separately in JSON. diff --git a/src/catalogDiscovery.ts b/src/catalogDiscovery.ts index 6a5724f4ca..91f89aa430 100644 --- a/src/catalogDiscovery.ts +++ b/src/catalogDiscovery.ts @@ -2,12 +2,13 @@ import { createHash } from "node:crypto"; import { z } from "zod"; import type { createCatalogIdentity } from "./catalogIdentity.js"; +// Retained views are loaded like producers: their full cross-domain output +// contract is unnecessary until a task actually queries retained analysis. export const HOT_TOOL_NAMES: ReadonlySet = new Set([ "binary_session", "open_binary", "close_binary", "get_evidence_bundle", - "inspect_analysis_view", ]); // One page activates at most 20 schemas: callers can page a broad search instead diff --git a/tests/acceptance/applications/progressiveAnalysisViews.test.ts b/tests/acceptance/applications/progressiveAnalysisViews.test.ts new file mode 100644 index 0000000000..a6c84d772c --- /dev/null +++ b/tests/acceptance/applications/progressiveAnalysisViews.test.ts @@ -0,0 +1,98 @@ +import { readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; +import { apiContractSchema } from "../../../src/domain/apiContract/apiContract.js"; +import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { toolOutputSchemaWithMetadata } from "../../../src/contracts/toolSchemaMetadata.js"; + +it("loads retained analysis views only when needed, preserving complete schemas, source facts and Evidence", async () => { + const root = await createTestTempDirectory("rea-progressive-api-"); + const path = join(root, "api.json"); + await writeFile( + path, + JSON.stringify({ + openapi: "3.1.0", + info: { title: "Fixture", version: "1" }, + paths: { "/items": { get: { operationId: "listItems" } } }, + }), + ); + const { client } = await connectLocalToolsMcp({ + REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold", + REA_MCP_TOOL_FAMILIES: "application,session", + }); + const names = async () => + (await client.listTools()).tools.map((tool) => tool.name); + expect(await names()).not.toContain("inspect_analysis_view"); + const changed = vi.fn(); + client.setNotificationHandler("notifications/tools/list_changed", changed); + const call = async (name: string, args: Record) => { + const result = await client.callTool({ name, arguments: args }); + expect(result.isError, JSON.stringify(result.content)).not.toBe(true); + return result; + }; + await call("discover_tools", { names: ["inspect_api_contract"] }); + expect(await names()).toContain("inspect_api_contract"); + const view = { + kind: "api-contract", + collection: "operations", + paths: ["/items"], + fields: ["method", "path"], + }; + const first = parseEvidence( + (await call("inspect_api_contract", { path, view })).structuredContent, + ); + const selected = analysisViewResultSchema.parse(first.normalized_result); + expect(selected).toMatchObject({ + kind: "api-contract", + items: [{ method: "GET", path: "/items", state: "declared" }], + }); + expect(await names()).not.toContain("inspect_analysis_view"); + expect(changed).toHaveBeenCalledTimes(1); + await rm(path); + await call("discover_tools", { + names: ["inspect_analysis_view", "export_evidence_bundle"], + }); + const loaded = (await client.listTools()).tools.find( + (tool) => tool.name === "inspect_analysis_view", + ); + expect(loaded?.outputSchema).toEqual( + toolOutputSchemaWithMetadata(toolContract("inspect_analysis_view"))[ + "~standard" + ].jsonSchema.output({ target: "draft-2020-12" }), + ); + expect(changed).toHaveBeenCalledTimes(2); + const source = { + kind: "retained-evidence", + evidence_id: selected.parent_evidence_id, + }; + const follow = await call("inspect_analysis_view", { source, view }); + expect(parseEvidence(follow.structuredContent)).toEqual(first); + const bundlePath = join(root, "complete.json"); + await call("export_evidence_bundle", { path: bundlePath }); + const bundle = evidenceBundleSchema.parse( + JSON.parse(await readFile(bundlePath, "utf8")), + ); + const parent = bundle.records.find( + (record) => record.evidence_id === selected.parent_evidence_id, + ); + expect(parent).toBeDefined(); + const report = apiContractSchema.parse(parent?.normalized_result); + expect(report.operations).toMatchObject([ + { + method: "GET", + path: "/items", + declared_id: "listItems", + source_pointer: "/paths/~1items/get", + }, + ]); + expect(follow.structuredContent).toHaveProperty("evidence_links", [ + selected.parent_evidence_id, + ]); + await call("discover_tools", { names: ["inspect_analysis_view"] }); + expect(changed).toHaveBeenCalledTimes(2); +}); diff --git a/tests/boundary/mcp/deferredRuntime.test.ts b/tests/boundary/mcp/deferredRuntime.test.ts index 6e64ee1d61..454d3a1776 100644 --- a/tests/boundary/mcp/deferredRuntime.test.ts +++ b/tests/boundary/mcp/deferredRuntime.test.ts @@ -123,13 +123,13 @@ it.each([ names: ["list_unknowns", "list_unknowns"], }); expect(loaded.structuredContent).toMatchObject({ - loaded_count: 7, + loaded_count: initial.tools.length + 1, total_matches: 1, tools: [{ name: "list_unknowns" }], }); expect((await call("list_unknowns", {})).isError).not.toBe(true); const materialized = await client.listTools(); - expect(materialized.tools).toHaveLength(7); + expect(materialized.tools).toHaveLength(initial.tools.length + 1); expect( materialized.tools.find(({ name }) => name === "inspect_sqlite_database"), ).toBeUndefined(); @@ -153,12 +153,16 @@ it.each([ expect(result.isError).not.toBe(true); expect(JSON.stringify(result.structuredContent)).toContain("pelican"); expect(JSON.stringify(result.structuredContent)).toContain("evidence_id"); - expect((await client.listTools()).tools).toHaveLength(8); + expect((await client.listTools()).tools).toHaveLength( + initial.tools.length + 2, + ); if ("REA_MCP_TOOL_FAMILIES" in environment) { expect( (await call("discover_tools", { names: ["current_address"] })).isError, ).toBe(true); - expect((await client.listTools()).tools).toHaveLength(8); + expect((await client.listTools()).tools).toHaveLength( + initial.tools.length + 2, + ); } expect(stderr()).toBe(""); }, diff --git a/tests/boundary/mcp/mcpDoctor.test.ts b/tests/boundary/mcp/mcpDoctor.test.ts index c45846280d..88c96ddf07 100644 --- a/tests/boundary/mcp/mcpDoctor.test.ts +++ b/tests/boundary/mcp/mcpDoctor.test.ts @@ -1,5 +1,8 @@ import { contractsForToolFamilies } from "../../../src/contracts/toolFamilies.js"; -import { progressiveCatalogIdentity } from "../../../src/catalogDiscovery.js"; +import { + HOT_TOOL_NAMES, + progressiveCatalogIdentity, +} from "../../../src/catalogDiscovery.js"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { execFile, spawn } from "node:child_process"; @@ -206,8 +209,8 @@ it("recognizes a hot/cold initial catalog and its inventory identity", async () }); expect(result.healthy).toBe(true); expect(result.inventory?.tools).toMatchObject({ - expected: 6, - observed: 6, + expected: HOT_TOOL_NAMES.size + 1, + observed: HOT_TOOL_NAMES.size + 1, missing: [], unexpected: [], }); diff --git a/tests/boundary/mcp/streamableHttpMcp.test.ts b/tests/boundary/mcp/streamableHttpMcp.test.ts index 38c28d65a1..0feae65669 100644 --- a/tests/boundary/mcp/streamableHttpMcp.test.ts +++ b/tests/boundary/mcp/streamableHttpMcp.test.ts @@ -56,7 +56,11 @@ describe("rea mcp --http", () => { { REA_MCP_TOOL_CATALOG_PROFILE: "hot-cold" }, ); const first = await openSession(started.url, "hot-cold-first"); - expect((await first.client.listTools()).tools).toHaveLength(6); + const initialNames = (await first.client.listTools()).tools.map( + ({ name }) => name, + ); + expect(initialNames).not.toContain("inspect_analysis_view"); + expect(initialNames).not.toContain("list_unknowns"); let changed = 0; first.client.setNotificationHandler( "notifications/tools/list_changed", @@ -87,8 +91,7 @@ describe("rea mcp --http", () => { const second = await openSession(started.url, "hot-cold-second"); expect( (await second.client.listTools()).tools.map(({ name }) => name), - ).not.toContain("list_unknowns"); - expect((await second.client.listTools()).tools).toHaveLength(6); + ).toEqual(initialNames); await expectIdentity(second); await second.transport.terminateSession(); }, From 98c589e42019b4cc2c3c6aee1f6d2031e2de2441 Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:08:27 +0800 Subject: [PATCH 071/151] perf(tests): reuse bounded CLI workers for native matching errors (#9163) Co-authored-by: gensokyo-dev --- .../tools/nativeFunctionMatching.test.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/acceptance/tools/nativeFunctionMatching.test.ts b/tests/acceptance/tools/nativeFunctionMatching.test.ts index c8cd0a6f44..fc2bf53fbe 100644 --- a/tests/acceptance/tools/nativeFunctionMatching.test.ts +++ b/tests/acceptance/tools/nativeFunctionMatching.test.ts @@ -1,7 +1,11 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; -import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; +import { + cliTest, + runFileCli, + type TestCli, +} from "../../support/cli/cliFixture.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; @@ -470,6 +474,9 @@ cliTest( "left_observations", "right_observations", ] as const) { + // Keep fresh-process coverage for both source-admission routes on left. + const run = + field === "left" ? cli.run.bind(cli) : runFileCli.bind(undefined, cli); const observation = field.endsWith("observations"); const request = { left, @@ -494,7 +501,7 @@ cliTest( const requestPath = join(root, "request.json"); await writeFile(requestPath, JSON.stringify(request)); for (const sourceArguments of [[], ["--source_bundle", bundlePath]]) { - const rejected = await cli.run({ + const rejected = await run({ arguments: [ "match-native-functions", requestPath, @@ -571,7 +578,7 @@ cliTest( const corruptRequest = { left: badCoordinate, right, detail: "complete" }; await writeFile(requestPath, JSON.stringify(corruptRequest)); - const corruptInline = await cli.run({ + const corruptInline = await runFileCli(cli, { arguments: ["match-native-functions", requestPath, "--json"], }); const corruptPortableRequest = { @@ -583,7 +590,7 @@ cliTest( detail: "complete", }; await writeFile(requestPath, JSON.stringify(corruptPortableRequest)); - const corruptPortable = await cli.run({ + const corruptPortable = await runFileCli(cli, { arguments: [ "match-native-functions", requestPath, @@ -601,7 +608,7 @@ cliTest( const malformedBundlePath = join(root, "malformed-outer-frame.json"); await writeFile(malformedBundlePath, JSON.stringify({ records: [left] })); - const malformedBundle = await cli.run({ + const malformedBundle = await runFileCli(cli, { arguments: [ "match-native-functions", requestPath, From 93ce13e76f0054dbe7a03e17ae188b37415232e3 Mon Sep 17 00:00:00 2001 From: Nianze Wu Date: Sat, 7 Nov 2026 00:21:02 +0800 Subject: [PATCH 072/151] fix(desktop): read SQLite state despite metadata name collisions Resolve exported SQLite metadata through built-in PRAGMAs on the private connection. Preserve application tables/views, source encoding, nullable rows and the existing resource limits. Validated with 93 focused tests, current-main RED controls, and actual CLI exports of the public Chinook dataset with native/shipped-reader parity and unchanged source hashes. No live GUI, container or installed agentenv qualification is claimed. Full technical CI 42247722923 (all eight shards and final aggregate) passed; Greptile review scored 5/5 on the merged head. The original human readiness declarations remain unfilled; the explicitly authorized maintainer readiness exception applies without adding a human-review attestation. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) --- src/domain/desktop/appExportPython.ts | 4 +- .../desktopExportSqliteMetadata.test.ts | 125 ++++++++++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 tests/boundary/providers/desktopExportSqliteMetadata.test.ts diff --git a/src/domain/desktop/appExportPython.ts b/src/domain/desktop/appExportPython.ts index 40753258bf..f0b7a6e699 100644 --- a/src/domain/desktop/appExportPython.ts +++ b/src/domain/desktop/appExportPython.ts @@ -184,11 +184,11 @@ def _table(db, name: str, primary_key: list, encoding: str) -> dict: def _sqlite(path: Path) -> dict: """The database's ordinary tables, in schema order, up to REA's table and row limits.""" with contextlib.closing(sqlite3.connect(path.resolve().as_uri() + "?mode=ro", uri=True, timeout=SQLITE_BUSY_S)) as db: - kinds = dict(db.execute("select name, type from pragma_table_list where schema = 'main'")) + kinds = dict(db.execute("select name, type from temp.pragma_table_list where schema = 'main'")) names = [name for (name,) in db.execute("select name from sqlite_schema where type = 'table'") if kinds.get(name) == "table"] keys = {name: [key for (key,) in db.execute( - "select name from pragma_table_info(?, 'main') where pk > 0 order by pk", (name,))] + "select name from temp.pragma_table_info(?, 'main') where pk > 0 order by pk", (name,))] for name in names[:MAX_TABLES]} encoding = {"UTF-8": "utf-8", "UTF-16le": "utf-16-le", "UTF-16be": "utf-16-be"}[ db.execute("PRAGMA encoding").fetchone()[0]] diff --git a/tests/boundary/providers/desktopExportSqliteMetadata.test.ts b/tests/boundary/providers/desktopExportSqliteMetadata.test.ts new file mode 100644 index 0000000000..191c63ac94 --- /dev/null +++ b/tests/boundary/providers/desktopExportSqliteMetadata.test.ts @@ -0,0 +1,125 @@ +import { execFileSync } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +import { expect, it } from "vitest"; + +import { + PYTHON_HELPERS, + PYTHON_IMPORTS, +} from "../../../src/domain/desktop/appExportPython.js"; +import { PYTHON_TASK_EVALUATOR } from "../../../src/domain/desktop/appTaskPython.js"; +import { inspectSqliteDatabaseSnapshot } from "../../../src/sqlite/SqliteDatabaseInspection.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const collisions = [ + ["table", "pragma_table_list"], + ["table", "pragma_table_info"], + ["view", "pragma_table_list"], + ["view", "pragma_table_info"], +] as const; + +it.each( + (["UTF-8", "UTF-16le", "UTF-16be"] as const).flatMap((encoding) => + collisions.map(([kind, name]) => [encoding, kind, name] as const), + ), +)( + "reads exported %s SQLite state when a %s shadows %s", + async (encoding, kind, name) => { + const root = await createTestTempDirectory("desktop-sqlite-metadata-"); + const path = join(root, "state.db"); + const db = new DatabaseSync(path); + try { + db.exec(` + PRAGMA encoding = '${encoding}'; + CREATE TABLE notes(id INTEGER, part TEXT, "body "" text" TEXT, PRIMARY KEY(part, id)); + INSERT INTO notes VALUES(7, 'a', 'retained'); + ${ + kind === "table" + ? `CREATE TABLE ${name}(name TEXT, type TEXT, schema TEXT); + INSERT INTO ${name} VALUES('notes', 'view', 'main');` + : `CREATE VIEW ${name} AS SELECT missing_function() AS name, 'table' AS type, 'main' AS schema;` + } + `); + db.prepare("INSERT INTO notes VALUES(NULL, ?, ?)").run( + "a", + "\ufeffretained 😀\ufeff", + ); + } finally { + db.close(); + } + const before = await readFile(path); + const imports = PYTHON_IMPORTS.split("\n") + .filter((line) => !line.includes("agentenv_protocol")) + .join("\n"); + const program = join(root, "read.py"); + await writeFile( + program, + `${imports}${PYTHON_HELPERS}${PYTHON_TASK_EVALUATOR} +views = _views(Path(sys.argv[1]), ["state.db"]) +checks = [{"id": "row", "kind": "row_present", "file": "state.db", "table": "notes", "where": {"id": 7}}] +missing = [{**checks[0], "where": {"id": 8}}] +print(json.dumps({"views": views, "results": evaluate(checks + missing, views)})) +`, + ); + const output = JSON.parse( + execFileSync( + process.platform === "win32" ? "python" : "python3", + ["-I", "-B", program, root], + { encoding: "utf8" }, + ), + ) as { + views: Record; + results: { result: boolean }[]; + }; + + expect(output.views["state.db"]).toMatchObject({ + format: "sqlite", + truncated: false, + view: { + tables_truncated: false, + }, + }); + expect(output.results.map(({ result }) => result)).toEqual([true, false]); + expect(output.views["state.db"]?.view.tables).toEqual({ + notes: { + columns: ["id", "part", 'body " text'], + primary_key: ["part", "id"], + rows: [ + [7, "a", "retained"], + [null, "a", "\ufeffretained 😀\ufeff"], + ], + truncated: false, + }, + ...(kind === "table" + ? { + [name]: { + columns: ["name", "type", "schema"], + primary_key: [], + rows: [["notes", "view", "main"]], + truncated: false, + }, + } + : {}), + }); + expect( + inspectSqliteDatabaseSnapshot(path, { path, table: "notes" }).rows, + ).toMatchObject({ + columns: ["id", "part", 'body " text'], + values: [ + [ + { type: "integer", value: "7" }, + { type: "text", value: "a" }, + { type: "text", value: "retained" }, + ], + [ + { type: "null" }, + { type: "text", value: "a" }, + { type: "text", value: "\ufeffretained 😀\ufeff" }, + ], + ], + }); + expect((await readFile(path)).equals(before)).toBe(true); + }, +); From 39bd0cc625be27da810d4ec4444770236aa30647 Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:58:52 +0800 Subject: [PATCH 073/151] test(cli): select shutdown budgets for worker lifecycle scenarios (#9170) Co-authored-by: gensokyo-dev --- .../cli/sharedJavaScriptCliSession.test.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/tests/boundary/cli/sharedJavaScriptCliSession.test.ts b/tests/boundary/cli/sharedJavaScriptCliSession.test.ts index 2b23dfd24a..420d019622 100644 --- a/tests/boundary/cli/sharedJavaScriptCliSession.test.ts +++ b/tests/boundary/cli/sharedJavaScriptCliSession.test.ts @@ -21,6 +21,9 @@ import { import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; import { writeFixtureFiles } from "../../support/workspace/writeFixtureFiles.js"; +// Inert process models exercise escalation without waiting for real I/O. +const MOCK_SHUTDOWN_GRACE_MS = 1; + const requestSchema = z.object({ id: z.number(), arguments: z.array(z.string()), @@ -118,6 +121,7 @@ test("reads a CLI session frame delivered one byte at a time", () => { test("stops an unready shared CLI process when the session closes", async () => { let spawned: FakeCliProcess | undefined; const session = createCliCommandSession({ + shutdownGraceMs: MOCK_SHUTDOWN_GRACE_MS, spawnWorker: () => { spawned = fakeProcess(41); return asCliProcess(spawned); @@ -147,6 +151,7 @@ test("stops an in-flight shared CLI command when the session closes", async () = let received = false; let spawned: FakeCliProcess | undefined; const session = createCliCommandSession({ + shutdownGraceMs: MOCK_SHUTDOWN_GRACE_MS, spawnWorker: () => { const child = fakeProcess(42); spawned = child; @@ -172,6 +177,7 @@ test("stops an in-flight shared CLI command when the session closes", async () = test("reuses one process and restarts it when the environment changes", async () => { let spawns = 0; const session = createCliCommandSession({ + shutdownGraceMs: MOCK_SHUTDOWN_GRACE_MS, spawnWorker: (environment) => { spawns += 1; const child = fakeProcess(50 + spawns); @@ -624,6 +630,7 @@ test("shares one shared CLI cleanup attempt across concurrent close calls", asyn test("starts another process after the shared CLI process exits", async () => { const children: FakeCliProcess[] = []; const session = createCliCommandSession({ + shutdownGraceMs: MOCK_SHUTDOWN_GRACE_MS, spawnWorker: () => { const child = fakeProcess(70 + children.length); children.push(child); @@ -667,6 +674,7 @@ test("starts another process after the shared CLI process exits", async () => { test("retires a fatal worker before starting the next command", async () => { const events: string[] = []; const session = createCliCommandSession({ + shutdownGraceMs: MOCK_SHUTDOWN_GRACE_MS, spawnWorker: () => { const generation = events.filter((event) => event.startsWith("spawn"), @@ -709,6 +717,7 @@ test("retires a fatal worker before starting the next command", async () => { test("retires a transport failure before the next command reuses the worker", async () => { const events: string[] = []; const session = createCliCommandSession({ + shutdownGraceMs: MOCK_SHUTDOWN_GRACE_MS, spawnWorker: () => { const generation = events.filter((event) => event.startsWith("spawn"), @@ -753,6 +762,7 @@ test("retires a transport failure before the next command reuses the worker", as test("restarts the shared CLI process after the command limit", async () => { const children: FakeCliProcess[] = []; const session = createCliCommandSession({ + shutdownGraceMs: MOCK_SHUTDOWN_GRACE_MS, commandLimit: 2, spawnWorker: () => { const child = respondingProcess( @@ -783,6 +793,7 @@ test("restarts the shared CLI process after the command limit", async () => { test("restarts the shared CLI process after the resident-set limit", async () => { const children: FakeCliProcess[] = []; const session = createCliCommandSession({ + shutdownGraceMs: MOCK_SHUTDOWN_GRACE_MS, commandLimit: 100, residentLimitBytes: 100, spawnWorker: () => { @@ -811,6 +822,7 @@ test("restarts the shared CLI process after the resident-set limit", async () => test("starts a new process after the real worker reports a fatal failure", async () => { const session = createCliCommandSession(); + onTestFinished(() => session.close()); const failed = await session.run({ arguments: ["--version"], cwd: "/workspace/this-directory-does-not-exist", @@ -830,6 +842,7 @@ test("starts a new process after the real worker reports a fatal failure", async }); test("keeps a malformed JSON command's failure status and serves the next command", async () => { + const root = await createTestTempDirectory("rea-warm-cli-status-"); const session = createCliCommandSession(); onTestFinished(() => session.close()); const environment = { @@ -843,7 +856,6 @@ test("keeps a malformed JSON command's failure status and serves the next comman expect(failed.stdout).toContain("invalid_request"); const worker = session.processId(); expect(worker).toEqual(expect.any(Number)); - const root = await createTestTempDirectory("rea-warm-cli-status-"); await writeFixtureFiles(root, { "app.mjs": "export function current() { return 1; }\n", }); @@ -859,8 +871,12 @@ test("keeps a malformed JSON command's failure status and serves the next comman }); test("closes the real JavaScript CLI worker through its resource cleanup", async () => { + const root = await createTestTempDirectory("rea-warm-cli-graceful-"); let worker: ChildProcessWithoutNullStreams | undefined; const session = createCliCommandSession({ + // Verify real EOF cleanup, whose process-ownership checks depend on host I/O, + // independently of the default one-second force-escalation policy. + shutdownGraceMs: 5_000, spawnWorker: (environment) => { worker = spawn( process.execPath, @@ -882,7 +898,6 @@ test("closes the real JavaScript CLI worker through its resource cleanup", async }, }); onTestFinished(() => session.close()); - const root = await createTestTempDirectory("rea-warm-cli-graceful-"); await writeFixtureFiles(root, { "app.mjs": "export function current() { return 1; }\n", }); From 0fc35a098a36c42a566211187462bc6754cf4411 Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Sat, 7 Nov 2026 01:27:27 +0800 Subject: [PATCH 074/151] test(javascript): normalize module resolution oracle paths (#9159) * test(javascript): normalize module resolution oracle paths * test(process): align symlink fixture with filesystem checkpoints * test(frida): assert the CLI spawn Evidence contract * test(mcp): use an unsupported analysis-view parent * test(browser): separate source-location line bounds from parity --- .../applications/javascriptModuleResolutionSemantics.test.ts | 4 ++-- tests/boundary/cli/mcpDoctorHelpCli.test.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/acceptance/applications/javascriptModuleResolutionSemantics.test.ts b/tests/acceptance/applications/javascriptModuleResolutionSemantics.test.ts index 1404f6cf7d..9091e23e73 100644 --- a/tests/acceptance/applications/javascriptModuleResolutionSemantics.test.ts +++ b/tests/acceptance/applications/javascriptModuleResolutionSemantics.test.ts @@ -78,13 +78,13 @@ cliTest.for(["CLI", "stdio MCP"] as const)( "node_modules/mapped/x%2Ey.js": 'throw new Error("must never load");', "oracle.mjs": ` import { fileURLToPath } from "node:url"; -import { relative } from "node:path"; +import { relative, sep } from "node:path"; import { existsSync } from "node:fs"; const valid = ${JSON.stringify(Object.keys(valid))}; const rejected = ${JSON.stringify(Object.keys(rejected))}; const resolveFile = request => { const path = fileURLToPath(import.meta.resolve(request)); - return { path: relative(process.cwd(), path), exists: existsSync(path) }; + return { path: relative(process.cwd(), path).split(sep).join("/"), exists: existsSync(path) }; }; console.log(JSON.stringify({ valid: valid.map(resolveFile), diff --git a/tests/boundary/cli/mcpDoctorHelpCli.test.ts b/tests/boundary/cli/mcpDoctorHelpCli.test.ts index 50b5ad2ba5..8f630f7eef 100644 --- a/tests/boundary/cli/mcpDoctorHelpCli.test.ts +++ b/tests/boundary/cli/mcpDoctorHelpCli.test.ts @@ -158,7 +158,9 @@ describe("MCP doctor help boundary", () => { }, CLI_INTEGRATION_TIMEOUT_MS, ); +}); +describe("MCP doctor timing boundary", () => { workspaceCliTest( "reports doctor timing on stderr while preserving JSON output", async ({ cli }) => { From 128f7c330f1df9d2b542a9e631e4218a701330b0 Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Sat, 7 Nov 2026 01:27:49 +0800 Subject: [PATCH 075/151] fix(browser): clarify shared source-map output budget (#9154) * fix(browser): clarify shared source-map output budget * test(browser): assert source-map budget facts independently * test(process): align symlink fixture with filesystem checkpoints * test(frida): assert the CLI spawn Evidence contract * test(mcp): use an unsupported analysis-view parent * test(browser): separate source-location line bounds from parity --- src/browser/WebSourceMapFetcher.ts | 2 +- tests/boundary/browser/webSourceMapResponseIdentity.test.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/browser/WebSourceMapFetcher.ts b/src/browser/WebSourceMapFetcher.ts index f934953109..bade5de12f 100644 --- a/src/browser/WebSourceMapFetcher.ts +++ b/src/browser/WebSourceMapFetcher.ts @@ -666,7 +666,7 @@ const normalizeSourceMap = ( return emptySourceMapItem( request, "fetch_failed", - "Source-map response text exceeds the 32 MiB source-map output budget; JSON parsing and source-map decoding were not attempted.", + "JSON-escaped response text and map metadata would exceed the remaining space in the shared 32 MiB source-map output budget; JSON parsing and source-map decoding were not attempted.", fetchedUrl, responseIdentity, ); diff --git a/tests/boundary/browser/webSourceMapResponseIdentity.test.ts b/tests/boundary/browser/webSourceMapResponseIdentity.test.ts index 68fc32fe44..33e2a79a2a 100644 --- a/tests/boundary/browser/webSourceMapResponseIdentity.test.ts +++ b/tests/boundary/browser/webSourceMapResponseIdentity.test.ts @@ -193,6 +193,11 @@ it("refuses oversized text before JSON interpretation while retaining body ident { status: "included", artifact: { text: valid }, mappings: [{}] }, ], }); + const limitation = result.value.items[0]?.limitation; + expect(limitation).toMatch(/JSON[- ]escap/i); + expect(limitation).toMatch(/metadata/i); + expect(limitation).toMatch(/shared/i); + expect(limitation).toMatch(/32\s*MiB/i); expect(Buffer.byteLength(JSON.stringify(result.value))).toBeLessThanOrEqual( 32 * 1024 * 1024, ); From 9cdcdf3dff3118f13f667f04ffab9e3eefe7c3a7 Mon Sep 17 00:00:00 2001 From: RRFZ <247457381+Plastic-time@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:28:12 -0500 Subject: [PATCH 076/151] fix(cli): reject non-decimal guidance search limits (#9165) --- src/cli/guidanceCommands.ts | 8 +++++--- tests/boundary/cli/guidanceCli.test.ts | 7 +++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/cli/guidanceCommands.ts b/src/cli/guidanceCommands.ts index 34486f309b..6758d93bf7 100644 --- a/src/cli/guidanceCommands.ts +++ b/src/cli/guidanceCommands.ts @@ -14,6 +14,7 @@ import { logCliCommand } from "../cliLogging.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; +import { cliDecimalField } from "./options.js"; /** Guidance descriptors owned by one CLI host. */ export class CliGuidanceResources { @@ -64,9 +65,10 @@ export const registerGuidanceCommands = ( locator: readGuidanceSectionInputSchema.shape.locator .optional() .describe("Required with --action read; locator returned by search"), - limit: searchGuidanceLimitSchema - .optional() - .describe("Maximum search matches; search only, defaults to 8"), + // Decimal tokens only: numeric coercion would read "0x2" as 2. + limit: cliDecimalField(searchGuidanceLimitSchema.optional()).describe( + "Maximum search matches; search only, defaults to 8", + ), cursor: searchGuidanceInputSchema.shape.cursor .optional() .describe("Continue a search page; search only"), diff --git a/tests/boundary/cli/guidanceCli.test.ts b/tests/boundary/cli/guidanceCli.test.ts index f169bd17eb..bb51ca6002 100644 --- a/tests/boundary/cli/guidanceCli.test.ts +++ b/tests/boundary/cli/guidanceCli.test.ts @@ -75,6 +75,13 @@ it("reports no matches and rejects invalid CLI inputs", async () => { }); const empty = await run(["guidance", "--query", " "]); expect(empty.exitCode).not.toBe(0); + // Numeric coercion would read "0x2" as a two-match page. + const hexLimit = await run(["guidance", "--query", "module", "--limit=0x2"]); + expect(hexLimit.exitCode).not.toBe(0); + expect(JSON.parse(hexLimit.stdout)).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: [expect.objectContaining({ path: "limit" })], + }); const malformed = await run([ "guidance", "--action", From fdd4aefd18d491b3259592de3e8ab3a4ddeea47c Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Sat, 7 Nov 2026 01:28:34 +0800 Subject: [PATCH 077/151] fix(cli): preserve literal Bash completion candidates (#9153) * test(process): align symlink fixture with filesystem checkpoints * test(frida): assert the CLI spawn Evidence contract * test(mcp): use an unsupported analysis-view parent * test(browser): separate source-location line bounds from parity * fix(cli): preserve literal Bash completion candidates --- src/cli/bashCompletionRegistration.ts | 9 +++++---- tests/boundary/cli/bashCompletionRegistration.test.ts | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/cli/bashCompletionRegistration.ts b/src/cli/bashCompletionRegistration.ts index 4bb8361bdc..327b82226f 100644 --- a/src/cli/bashCompletionRegistration.ts +++ b/src/cli/bashCompletionRegistration.ts @@ -5,16 +5,17 @@ export const bashCompletionScript = (name: string): string => { return `_incur_complete_${id}() { local IFS=$'\\013' local _COMPLETE_INDEX=\${COMP_CWORD} - local _completions - _completions=( $( + local _completions _output + if ! _output=$( export COMPLETE="bash" export _COMPLETE_INDEX="$_COMPLETE_INDEX" ${command} -- "\${COMP_WORDS[@]}" - ) ) - if [[ $? != 0 ]]; then + ); then unset COMPREPLY return fi + # Split the protocol records without expanding pathname patterns. + read -r -d '' -a _completions < <(printf '%s\\0' "$_output") local _nospace=false local _nospace_marker=$'\\001' local _c diff --git a/tests/boundary/cli/bashCompletionRegistration.test.ts b/tests/boundary/cli/bashCompletionRegistration.test.ts index 94255d53aa..3d7596b7a5 100644 --- a/tests/boundary/cli/bashCompletionRegistration.test.ts +++ b/tests/boundary/cli/bashCompletionRegistration.test.ts @@ -21,7 +21,7 @@ complete -p rea || exit rea() { [[ "$COMPLETE" == bash && "$_COMPLETE_INDEX" == 1 ]] || return 1 [[ "$1" == -- && "$2" == rea && "$3" == ana ]] || return 1 - printf 'analyze\\013analyze-web-bundle\\013' + printf 'analyze\\013analyze-web-bundle\\013*.json\\013' } COMP_WORDS=(rea ana) COMP_CWORD=1 @@ -33,7 +33,7 @@ printf '%s\\n' "\${COMPREPLY[@]}" ); expect(stderr).toBe(""); expect(stdout).toContain("-F _incur_complete_rea rea"); - expect(stdout).toContain("\nanalyze\nanalyze-web-bundle\n"); + expect(stdout).toContain("\nanalyze\nanalyze-web-bundle\n*.json\n"); }, ); From 2cf9c42df61d05e1cc7c6db3e7f06fa9883b9f08 Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Sat, 7 Nov 2026 01:28:59 +0800 Subject: [PATCH 078/151] fix(desktop): recognise JSON scalar files with mixed-case suffixes (#9152) * fix(desktop): recognise JSON scalar files with mixed-case suffixes * test(setup): use the current registration command in CodeBuddy fixture * test(cli): split doctor help and timing boundary groups * style(test): format split MCP doctor boundary groups --- src/domain/desktop/appExportPython.ts | 2 +- src/domain/desktop/stateFormats.test.ts | 11 +++++++++++ src/domain/desktop/stateFormats.ts | 2 +- tests/boundary/providers/desktopExportPython.test.ts | 3 ++- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/domain/desktop/appExportPython.ts b/src/domain/desktop/appExportPython.ts index f0b7a6e699..185932d6f4 100644 --- a/src/domain/desktop/appExportPython.ts +++ b/src/domain/desktop/appExportPython.ts @@ -396,7 +396,7 @@ def _text_view(name: str, text: str, size: int) -> dict: """The format REA's state view detects for decoded text of size bytes, and its view; a JSON or key file over the parsed-view limit is refused before its value is built.""" trimmed = re.sub(r"^[\s]+", "", text) - if name.endswith(".json") or trimmed[:1] in ("[", "{"): + if name.lower().endswith(".json") or trimmed[:1] in ("[", "{"): view = _json_view(text, size) if view is not None: return view diff --git a/src/domain/desktop/stateFormats.test.ts b/src/domain/desktop/stateFormats.test.ts index c2558f6119..60377217b1 100644 --- a/src/domain/desktop/stateFormats.test.ts +++ b/src/domain/desktop/stateFormats.test.ts @@ -5,12 +5,23 @@ import { jsonView, keyfileView, textView, + textSyntaxFormat, xmlView, } from "./stateFormats.js"; const bytes = (text: string) => new TextEncoder().encode(text); describe("detectStateFormat", () => { + it("recognises JSON scalars with mixed-case suffixes and still validates their syntax", () => { + for (const text of ["42", "true", "null", '"value"']) { + expect(detectStateFormat("/h/a.JsOn", bytes(text))).toBe("json"); + expect(textSyntaxFormat("/h/a.JSON", text)).toBe("json"); + expect(detectStateFormat("/h/a.txt", bytes(text))).toBe("text"); + } + expect(detectStateFormat("/h/a.JSON", bytes("42 trailing"))).toBe("text"); + expect(textSyntaxFormat("/h/a.JSON", "42 trailing")).toBe("text"); + }); + it("recognises SQLite and dconf headers before decoding text", () => { expect(detectStateFormat("/h/a.db", bytes("SQLite format 3\0rest"))).toBe( "sqlite", diff --git a/src/domain/desktop/stateFormats.ts b/src/domain/desktop/stateFormats.ts index 2e868563da..d4ee0a3855 100644 --- a/src/domain/desktop/stateFormats.ts +++ b/src/domain/desktop/stateFormats.ts @@ -210,7 +210,7 @@ export const isJsonText = (text: string): boolean => { /** Whether a file's name or leading text marks it as JSON, which it is if it parses. */ const namedJson = (path: string, trimmed: string): boolean => - path.endsWith(".json") || /^[[{]/u.test(trimmed); + path.toLowerCase().endsWith(".json") || /^[[{]/u.test(trimmed); /** Whether a file's name or XML declaration marks it as XML, which it is if it parses. */ const namedXml = (path: string, trimmed: string): boolean => diff --git a/tests/boundary/providers/desktopExportPython.test.ts b/tests/boundary/providers/desktopExportPython.test.ts index 7783989ea2..4cbaa9f5f9 100644 --- a/tests/boundary/providers/desktopExportPython.test.ts +++ b/tests/boundary/providers/desktopExportPython.test.ts @@ -91,13 +91,14 @@ it("keeps JSON Lines holding a number it cannot read exactly as text, as REA doe const files = [ ["events.jsonl", lines], ["events.json", lines], + ["events.JSON", "42"], ["big.json", '{"id":9007199254740993}'], ["long.json", `{"id":${"9".repeat(5000)}}`], ] as const; const formats = pythonFormats(files); - expect(formats).toEqual(["text", "text", "json", "json"]); + expect(formats).toEqual(["text", "text", "json", "json", "json"]); expect(formats).toEqual( files.map(([name, text]) => detectStateFormat(name, new TextEncoder().encode(text)), From 10407fb62409d8fc84e6e33c865bb644b2b072da Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Sat, 7 Nov 2026 01:29:22 +0800 Subject: [PATCH 079/151] fix(dotnet): reject invalid UTF-8 metadata version strings (#9156) * test(process): align symlink fixture with filesystem checkpoints * test(frida): assert the CLI spawn Evidence contract * test(mcp): use an unsupported analysis-view parent * test(browser): separate source-location line bounds from parity * fix(dotnet): reject invalid UTF-8 metadata version strings * test(dotnet): preserve valid multibyte metadata version text --- src/dotnet/ManagedMetadataHeaps.test.ts | 18 ++++++++++++++++++ src/dotnet/ManagedMetadataLayout.ts | 14 +++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/dotnet/ManagedMetadataHeaps.test.ts b/src/dotnet/ManagedMetadataHeaps.test.ts index 469f547b34..5ad03227f0 100644 --- a/src/dotnet/ManagedMetadataHeaps.test.ts +++ b/src/dotnet/ManagedMetadataHeaps.test.ts @@ -39,6 +39,24 @@ describe("managed metadata heaps", () => { const layout = readManagedMetadataLayout(bytes, 0, bytes.length); expect(layout.streamNames).toEqual(["#GUID", "#Strings", "#~"]); expect(layout.blob).toBeNull(); + expect(layout.version).toBe("v4.0.30319"); + + bytes.fill(0, 16, 16 + version.length); + Buffer.from("v😀é", "utf8").copy(bytes, 16); + expect(readManagedMetadataLayout(bytes, 0, bytes.length).version).toBe( + "v😀é", + ); + + bytes[16] = 0xff; + expect(() => readManagedMetadataLayout(bytes, 0, bytes.length)).toThrow( + expect.objectContaining({ + issue: expect.objectContaining({ + code: "invalid-metadata-root", + scope: "metadata.version", + offset: 16, + }), + }), + ); }); it("accepts the empty blob entry when an empty #Blob stream is omitted", () => { diff --git a/src/dotnet/ManagedMetadataLayout.ts b/src/dotnet/ManagedMetadataLayout.ts index cf4025b6f4..d114e6cd77 100644 --- a/src/dotnet/ManagedMetadataLayout.ts +++ b/src/dotnet/ManagedMetadataLayout.ts @@ -1,3 +1,5 @@ +import { isUtf8 } from "node:buffer"; + import { attachManagedDecodeBudget, ManagedDecodeBudget, @@ -241,9 +243,15 @@ const readMetadataStreams = ( const zero = versionBytes.indexOf(0); const decodedVersionLength = zero < 0 ? versionBytes.length : zero; budget.reserveString(decodedVersionLength, rootOffset + 16); - const version = versionBytes - .subarray(0, decodedVersionLength) - .toString("utf8"); + const versionText = versionBytes.subarray(0, decodedVersionLength); + if (!isUtf8(versionText)) + throw managedFailure( + "invalid-metadata-root", + "metadata.version", + "Metadata version is not valid UTF-8", + rootOffset + 16, + ); + const version = versionText.toString("utf8"); let cursor = align4(16 + versionLength); requireRange(bytes, rootOffset, cursor, 4, "metadata.stream-count"); const streamCount = bytes.readUInt16LE(cursor + 2); From d1103beb8dc17bb6540ba211b333fa97bc0907c8 Mon Sep 17 00:00:00 2001 From: RRFZ <247457381+Plastic-time@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:29:44 -0500 Subject: [PATCH 080/151] fix(cli): reject non-decimal web capture deadlines (#9167) --- src/cli/environmentCommands.ts | 5 +++- src/cli/webNetworkCaptureCommands.ts | 6 +++-- .../boundary/cli/environmentBuildCli.test.ts | 24 ++++++++++++++++++- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/cli/environmentCommands.ts b/src/cli/environmentCommands.ts index fa3eab11bb..238ea53c46 100644 --- a/src/cli/environmentCommands.ts +++ b/src/cli/environmentCommands.ts @@ -38,6 +38,7 @@ import { inspectWebNetworkCaptureInputSchema } from "../domain/webNetworkCapture import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; import type { CliInstance } from "./types.js"; +import { cliDecimalField } from "./options.js"; /** Same envelope label as the other JSON-input commands and `parseCliJsonInput`. */ const cliError = (error: AnalysisError): JsonValue => ({ @@ -512,8 +513,10 @@ const registerBuildCommand = ( .boolean() .default(false) .describe("Run every stage and report, but write nothing"), - captureTimeoutMs: + // Decimal tokens only: numeric coercion would read "0x1" as 1 ms. + captureTimeoutMs: cliDecimalField( inspectWebNetworkCaptureInputSchema.shape.capture_timeout_ms, + ), }), run: ({ args, options }) => withCommandCancellation((signal) => diff --git a/src/cli/webNetworkCaptureCommands.ts b/src/cli/webNetworkCaptureCommands.ts index b31296d907..9cffd44753 100644 --- a/src/cli/webNetworkCaptureCommands.ts +++ b/src/cli/webNetworkCaptureCommands.ts @@ -8,7 +8,7 @@ import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; -import { cliIntegerArgument } from "./options.js"; +import { cliDecimalField, cliIntegerArgument } from "./options.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { analysisEvidenceDeliveryOptions, @@ -55,8 +55,10 @@ export const registerWebNetworkCaptureCommands = ( .optional() .describe("Zero-based producer ordinals; repeat to select several"), ...explicitSensitiveValueOptions, - captureTimeoutMs: + // Decimal tokens only: numeric coercion would read "0x1" as 1 ms. + captureTimeoutMs: cliDecimalField( inspectWebNetworkCaptureInputSchema.shape.capture_timeout_ms, + ), }), run: ({ args, options }) => withCommandCancellation((signal) => diff --git a/tests/boundary/cli/environmentBuildCli.test.ts b/tests/boundary/cli/environmentBuildCli.test.ts index 712b49edc6..8f2377923c 100644 --- a/tests/boundary/cli/environmentBuildCli.test.ts +++ b/tests/boundary/cli/environmentBuildCli.test.ts @@ -152,7 +152,13 @@ cliTest( const har = join(root, "grafana.har"); await writeFile(har, JSON.stringify(environmentManifestHar())); const planned = await cli.run({ - arguments: ["environment-build", har, "--dry-run", "--json"], + arguments: [ + "environment-build", + har, + "--dry-run", + "--capture-timeout-ms=030000", + "--json", + ], timeoutMs: 60_000, }); expect(planned.exitCode, planned.stderr).toBe(0); @@ -188,6 +194,22 @@ cliTest( expect(conflicting.exitCode).toBe(1); expect(conflicting.json).toMatchObject({ code: "invalid_request" }); + // Both capture readers share the field. Numeric coercion would read + // "0x1" as a 1 ms capture deadline. + for (const command of [ + ["environment-build", har, "--dry-run"], + ["inspect-web-network-capture", har, "har"], + ]) { + const hexDeadline = await cli.run({ + arguments: [...command, "--capture-timeout-ms=0x1", "--json"], + }); + expect(hexDeadline.exitCode).toBe(1); + expect(hexDeadline.json).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: [expect.objectContaining({ path: "captureTimeoutMs" })], + }); + } + const noDirectory = await cli.run({ arguments: ["environment-build", har, "--json"], timeoutMs: 60_000, From 91d537098a1cd7a77f310b1a357e6d786bfa2e7b Mon Sep 17 00:00:00 2001 From: Beqa <91750923+BeqaKs@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:30:06 +0400 Subject: [PATCH 081/151] fix(setup): spell IDA and JADX in interactive completion (#9149) --- src/cli/interactiveSetup.ts | 9 +++++++- tests/boundary/cli/cliSetup.test.ts | 32 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/cli/interactiveSetup.ts b/src/cli/interactiveSetup.ts index 9e73025e69..6a4af58753 100644 --- a/src/cli/interactiveSetup.ts +++ b/src/cli/interactiveSetup.ts @@ -275,8 +275,15 @@ const readyAgentClients = ( const readyProviders = (result: SetupResult): readonly string[] => result.doctor.availableProviders.map(displayProviderId); +/** Product spellings that title case would distort. Lookup ids stay lowercase. */ +const PROVIDER_DISPLAY_NAMES: Readonly> = { + ida: "IDA", + jadx: "JADX", +}; + const displayProviderId = (id: string): string => - id.length === 0 ? id : `${id[0]?.toUpperCase() ?? ""}${id.slice(1)}`; + PROVIDER_DISPLAY_NAMES[id] ?? + (id.length === 0 ? id : `${id[0]?.toUpperCase() ?? ""}${id.slice(1)}`); const humanList = (values: readonly string[]): string => { if (values.length < 2) return values[0] ?? ""; diff --git a/tests/boundary/cli/cliSetup.test.ts b/tests/boundary/cli/cliSetup.test.ts index 7574b4e26d..cd4ee046d1 100644 --- a/tests/boundary/cli/cliSetup.test.ts +++ b/tests/boundary/cli/cliSetup.test.ts @@ -493,6 +493,38 @@ describe("interactive setup completion", () => { ); }, ); + + it("prints IDA and JADX with their product spellings", async () => { + const { stderr } = await execute( + process.execPath, + [ + "--input-type=module", + "--eval", + [ + 'import { renderInteractiveSetupResult } from "./dist/cli/interactiveSetup.js";', + "renderInteractiveSetupResult({", + ' status: "ready",', + " plannedActions: [],", + " appliedActions: [],", + " clients: {},", + " doctor: {", + " healthy: true,", + ' availableProviders: ["hopper", "ghidra", "ida", "jadx", "kuna"],', + " checks: [],", + " },", + "}, {});", + ].join("\n"), + ], + { cwd: process.cwd(), env: { ...process.env, NO_COLOR: "1" } }, + ); + + expect(stderr).toContain( + "Deep analysis: Hopper, Ghidra, IDA, JADX, and Kuna", + ); + expect(stderr).not.toContain("Ida"); + expect(stderr).not.toContain("Jadx"); + }); + it("prints retained cleanup resources without relying on paths in remediation", async () => { const resources = ["/fixture/owned backup", "/fixture/临时目录"]; const result: SetupResult = { From f8356f3d713d707b4a6d9eb5ab3941787f546ccc Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Fri, 6 Nov 2026 09:49:11 -0800 Subject: [PATCH 082/151] fix(desktop): read the PPA key add-apt-repository writes into Signed-By (#8982) * fix(desktop): read the PPA key add-apt-repository writes into Signed-By On Ubuntu 24.04, add-apt-repository puts the PPA's armored key inside the deb822 Signed-By field, and REA refused such repositories, so PPAs added the standard way could not be exported. REA now reads that key by deb822 rules, verifies the release with it through the same private gpgv keyring as key files, as the entry's only candidate (as apt does), retains it as armored and exports it as apt-keys/.asc. A held key that does not verify is refused with a reason that names no URL. Co-Authored-By: Claude Opus 5.5 * test(desktop): check the held-key refusal's facts, not its sentence The PPA refusal tests now assert that the reason names the sources file and the release file and no URL, and that export carries that reason, instead of comparing whole sentences. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/desktop-application-capture.md | 26 +++-- docs/desktop-application-export.md | 8 +- .../vendor-packages/spec.md | 27 +++-- src/desktop/AptRepositoryInventory.ts | 31 +++-- src/desktop/DebianPackageInventory.test.ts | 106 ++++++++++++++++++ src/desktop/GpgvKeyCheck.test.ts | 45 ++++++++ src/desktop/GpgvKeyCheck.ts | 73 ++++++++---- src/domain/desktop/aptKeys.fixture.ts | 34 ++++++ src/domain/desktop/aptRepositories.test.ts | 54 ++++++++- src/domain/desktop/aptRepositories.ts | 24 +++- 10 files changed, 366 insertions(+), 62 deletions(-) diff --git a/docs/desktop-application-capture.md b/docs/desktop-application-capture.md index f759fb8532..6a84127eda 100644 --- a/docs/desktop-application-capture.md +++ b/docs/desktop-application-capture.md @@ -201,21 +201,27 @@ found by verification, not by name: the candidates are the entry's `signed-by` keyrings, or else every `.gpg` and `.asc` key in apt's trusted parts folder and its trusted keyring, and each is checked with `gpgv` against the release's `InRelease` (or `Release` with `Release.gpg`) -beside its index in apt's lists folder. A key counts when gpgv reports a -valid signature by it and no bad, expired, revoked or unverifiable signature -by it, whatever gpgv's exit status: a release also signed by a key the host -lacks makes gpgv fail. Each candidate is copied, an armored key decoded to a -binary keyring, into a private temporary folder that is removed afterwards; a -folder REA cannot remove fails the capture with the folder as its cleanup -resource. The key that verifies is recorded in `signing_key` with its path, +beside its index in apt's lists folder. A deb822 entry whose `Signed-By` field +holds the armored key itself, as `add-apt-repository ppa:/` +writes it on Ubuntu 24.04, has that key as its only candidate, as apt trusts +no other for it: REA reads the field's continuation lines as deb822 writes +them, a `.` line standing for an empty one, from the key block's `BEGIN` line +to its last `END` line, and records the key's path as the entry's sources +file. A key counts when gpgv reports a valid signature by it and no bad, +expired, revoked or unverifiable signature by it, whatever gpgv's exit status: +a release also signed by a key the host lacks makes gpgv fail. Each candidate +is copied, an armored key decoded to a binary keyring, into a private +temporary folder that is removed afterwards; a folder REA cannot remove +fails the capture with the folder as its cleanup resource. The key that +verifies is recorded in `signing_key` with its path, SHA-256, format (`binary` or `armored`) and primary fingerprint, and its bytes are retained under `content/`. A key file holding one key is retained as it is; from one holding several, such as a legacy `/etc/apt/trusted.gpg`, only the transferable key with the verifying fingerprint is retained, in binary, so the bundle trusts no other key. When no candidate verifies, `gpgv` is -missing, apt holds no signed release, no sources entry matches, the entry's -`Signed-By` holds the key itself, or the key is larger than -`--max-file-bytes` or the content budget, `signing_key` is null and +missing, apt holds no signed release, no sources entry matches, the key the +entry's `Signed-By` holds does not verify the release, or the key is larger +than `--max-file-bytes` or the content budget, `signing_key` is null and `signing_key_reason` says why. Ubuntu Pro's ESM repositories (origins such as `UbuntuESMApps`, host `esm.ubuntu.com`) are not checked: apt reaches them only with the host's Ubuntu Pro credentials, which a computer does not have. diff --git a/docs/desktop-application-export.md b/docs/desktop-application-export.md index 7db04e0701..005a18d7eb 100644 --- a/docs/desktop-application-export.md +++ b/docs/desktop-application-export.md @@ -88,9 +88,11 @@ The class declares: - For a vendor repository, `sources=[AptSource(apt=..., suite=..., components=[...], key="apt-keys/.gpg")]`, with the retained key copied to that path (`.asc` when it is armored, which agentenv tells by its - content). apt takes one key per URI and suite, so sources that share them, - such as one repository listed for two architectures, are declared once, with - their components merged; one with a different key is left out. A repository + content). A PPA added with `add-apt-repository`, whose sources entry holds its + key in `Signed-By`, exports that key as `apt-keys/.asc`. apt takes one + key per URI and suite, so sources that share them, such as one repository + listed for two architectures, are declared once, with their components + merged; one with a different key is left out. A repository whose key the capture did not verify, an Ubuntu Pro (ESM) repository, one whose sources entry's URI held a user name or password (which the capture did not record and the export never writes; the reason names neither them diff --git a/docs/specs/desktop-app-environments/vendor-packages/spec.md b/docs/specs/desktop-app-environments/vendor-packages/spec.md index 7d10b3cea0..d4d05fa533 100644 --- a/docs/specs/desktop-app-environments/vendor-packages/spec.md +++ b/docs/specs/desktop-app-environments/vendor-packages/spec.md @@ -36,11 +36,16 @@ that key, with those libraries, and starts it. A VALIDSIG counts whatever gpgv's exit status, unless a BADSIG, EXPSIG, EXPKEYSIG, REVKEYSIG or ERRSIG names the same key. Candidates are the entry's Signed-By keyrings, or every `.gpg` and `.asc` key in - `/etc/apt/trusted.gpg.d` and `/etc/apt/trusted.gpg`. Each is written into a - private temporary folder, an armored key decoded to binary in REA, and the - folder is removed; a failed removal fails the capture with the folder's - owner and cleanup facts, as does a sources file, key or library whose - handle cannot be closed. When the repository check and the library walk + `/etc/apt/trusted.gpg.d` and `/etc/apt/trusted.gpg`. A deb822 Signed-By that + holds an armored key block, as `add-apt-repository ppa:…` writes it, is the + entry's only candidate: its continuation lines are stripped, a `.` line read + as an empty one, from the `BEGIN` line to the last `END` line, and the key's + path is the entry's sources file; a held key that does not verify is refused + with a reason naming that file and the release file, and no URL. Each is + written into a private temporary folder, an armored key decoded to binary in + REA, and the folder is removed; a failed removal fails the capture with the + folder's owner and cleanup facts, as does a sources file, key or library + whose handle cannot be closed. When the repository check and the library walk both fail, the failure keeps both causes and every owner. The verified key's path, SHA-256, format and primary fingerprint are recorded and its bytes retained in the capture's content store, within `--max-file-bytes` @@ -70,9 +75,10 @@ that key, with those libraries, and starts it. - Export: a package from the Ubuntu 24.04 archive installs by name as before, for amd64 and arm64. Otherwise each usable vendor repository is declared as `AptSource(apt, suite, components, key="apt-keys/.gpg|.asc")` with the - retained key copied into the environment folder, one source per URI and - suite; the package is named `/` from the first declared - repository, so its release wins over the archive, and the bundle supports + retained key copied into the environment folder (`.asc` for an armored key, + such as one a PPA's Signed-By held), one source per URI and suite; the + package is named `/` from the first declared repository, + so its release wins over the archive, and the bundle supports only the capture host's architecture. A repository is usable when its key was verified, its sources entry's URI held no user name or password (`uri_credentials_removed`; a capture written before that field whose @@ -94,9 +100,8 @@ that key, with those libraries, and starts it. ## Non-goals -- Keys held inline in a deb822 `Signed-By` field, Signed-By fingerprints - matched against the signing key, and repositories reached over anything but - http or https. +- Signed-By fingerprints matched against the signing key, and repositories + reached over anything but http or https. - Pinning the captured version, carrying the host's apt pinning over, or checking that the capture host's suite suits Ubuntu 24.04. - Ubuntu Pro repositories, which need the host's credentials, and other diff --git a/src/desktop/AptRepositoryInventory.ts b/src/desktop/AptRepositoryInventory.ts index efbdaf3715..5452d5c9a9 100644 --- a/src/desktop/AptRepositoryInventory.ts +++ b/src/desktop/AptRepositoryInventory.ts @@ -14,7 +14,11 @@ import { type AptSourceStanza, } from "../domain/desktop/aptRepositories.js"; import type { AptRepository } from "../domain/desktop/desktopCapture.js"; -import { withGpgvKeyCheck, type VerifiedKey } from "./GpgvKeyCheck.js"; +import { + withGpgvKeyCheck, + type HeldKey, + type VerifiedKey, +} from "./GpgvKeyCheck.js"; import { isHostFile, readHostFile } from "./HostFileRead.js"; import { succeeded, type QueryRunner } from "./HostQuery.js"; @@ -159,12 +163,15 @@ const signedRelease = async ( : undefined; }; +/** The keys gpgv checks a release with: candidate key files, or the one key the sources entry holds. */ +type KeyCandidates = readonly string[] | HeldKey; + interface RepositoryContext { readonly apt: AptLocations; readonly targets: ReadonlyMap | undefined; readonly sources: (file: string) => Promise; readonly verify: ( - candidates: readonly string[], + candidates: KeyCandidates, release: readonly string[], ) => Promise; } @@ -178,7 +185,7 @@ const keyCheck = async ( | { readonly reason: string } | { readonly release: readonly string[]; - readonly candidates: readonly string[]; + readonly candidates: KeyCandidates; } > => { if (target === undefined) @@ -192,16 +199,17 @@ const keyCheck = async ( return { reason: `no sources entry at ${target.sourcesFile}:${String(target.position)} configures it`, }; - if (entry.inlineKey) - return { - reason: - "its Signed-By field holds the key itself, which REA does not read", - }; const release = await signedRelease(target); if (release === undefined) return { reason: `apt holds no signed release file for it in ${dirname(target.filename)}`, }; + // apt trusts only the key an entry holds, never the keys it trusts for all repositories. + if (entry.inlineKey !== null) + return { + release, + candidates: { path: entry.file, bytes: Buffer.from(entry.inlineKey) }, + }; const candidates = await candidateKeys(entry, context.apt); return candidates.length > MAX_CANDIDATE_KEYS ? { reason: `more than ${String(MAX_CANDIDATE_KEYS)} candidate keys` } @@ -269,7 +277,8 @@ const repositoryFacts = async ( * The apt repositories other than Ubuntu's that list the installed version. * `apt-get indextargets` names each source's sources entry, release and * signed release file; the key is the one whose signature over that release - * gpgv verified: the entry's Signed-By keyrings, or every key apt trusts. + * gpgv verified: the key the entry's Signed-By holds, its Signed-By keyrings, + * or every key apt trusts. */ export const inspectAptRepositories = async ( installed: { @@ -298,7 +307,9 @@ export const inspectAptRepositories = async ( targets, sources: files.read, verify: (candidates, release) => - check.firstVerifying(candidates, release), + "bytes" in candidates + ? check.heldVerifying(candidates, release) + : check.firstVerifying(candidates, release), }; const keys = new Map(); const repositories: AptRepository[] = []; diff --git a/src/desktop/DebianPackageInventory.test.ts b/src/desktop/DebianPackageInventory.test.ts index 932ae3d6a7..a86bcc10c3 100644 --- a/src/desktop/DebianPackageInventory.test.ts +++ b/src/desktop/DebianPackageInventory.test.ts @@ -17,10 +17,14 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { + A_ARMORED, + B_ARMORED, GPGV_A_ONLY, + inlineSignedBy, VENDOR_A, VENDOR_B, } from "../domain/desktop/aptKeys.fixture.js"; +import { exportInstall } from "../domain/desktop/appExportInstall.js"; import { elfImage } from "../domain/desktop/elfImage.fixture.js"; import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import type { QueryOutcome, QueryRunner } from "./HostQuery.js"; @@ -137,6 +141,35 @@ const PRIVATE: Repository = { releaseFile: "apt.vendor.example_repo_dists_stable_", }; +const PPA_URI = "https://ppa.launchpadcontent.net/vendor/typora/ubuntu"; + +/** A PPA as `add-apt-repository ppa:vendor/typora` adds it on Ubuntu 24.04, with the key it holds in Signed-By. */ +const heldKeyPpa = (armored: string): Repository => ({ + list: "vendor-ubuntu-typora-noble.sources", + line: () => + [ + "Types: deb", + `URIs: ${PPA_URI}/`, + "Suites: noble", + "Components: main", + inlineSignedBy(armored), + "", + ].join("\n"), + source: `${PPA_URI} noble/main arm64 Packages`, + release: + "v=24.04,o=LP-PPA-vendor-typora,a=noble,n=noble,l=Typora,c=main,b=arm64", + indexTarget: [ + "Codename: noble", + "Origin: LP-PPA-vendor-typora", + "Suite: noble", + `Base-URI: ${PPA_URI}/dists/noble/`, + "Component: main", + "Release: noble", + `Repo-URI: ${PPA_URI}/`, + ].join("\n"), + releaseFile: "ppa.launchpadcontent.net_vendor_typora_ubuntu_dists_noble_", +}); + interface FixtureOptions { readonly repository?: Repository; /** The bytes of trusted.gpg.d/typora.gpg (or .asc when armored). */ @@ -489,6 +522,79 @@ describe.skipIf(process.platform === "win32")("vendor repositories", () => { }); }); +describe.skipIf(process.platform === "win32")( + "keys a PPA's Signed-By holds", + () => { + it("verifies the key a PPA's Signed-By holds in a private keyring, retains it armored, and exports it as an .asc key", async () => { + const armored = Buffer.from(A_ARMORED); + const repository = heldKeyPpa(A_ARMORED); + const { + root, + keyrings, + signingKeys, + package: installed, + } = await inspected({ repository }); + + expect(installed.apt_repositories).toEqual([ + expect.objectContaining({ + source: repository.source, + uri: PPA_URI, + suite: "noble", + components: ["main"], + signing_key: { + path: join(root, "apt/sources.list.d", repository.list), + sha256: sha256(armored), + format: "armored", + fingerprint: VENDOR_A.fingerprint, + }, + }), + ]); + expect(signingKeys.get(sha256(armored))?.equals(armored)).toBe(true); + expect(keyrings).toHaveLength(1); + expect(keyrings[0]?.startsWith(root)).toBe(false); + await expect(access(keyrings[0] ?? "")).rejects.toThrow(); + const install = exportInstall(installed, "typora", "arm64"); + expect(install.ok && install.value.sources).toEqual([ + { + uri: PPA_URI, + suite: "noble", + components: ["main"], + keyFile: "apt-keys/typora.asc", + keySha256: sha256(armored), + }, + ]); + }); + + it("refuses a PPA whose held key does not verify its release, without trying the keys apt trusts for all repositories or naming a URL", async () => { + const repository = heldKeyPpa(B_ARMORED); + const { + root, + keyrings, + signingKeys, + package: installed, + } = await inspected({ repository }); + + const refused = installed.apt_repositories?.[0]; + const reason = refused?.signing_key_reason; + if (reason === undefined) throw new Error("expected a refusal reason"); + expect(refused?.signing_key).toBeNull(); + expect(reason).toContain( + join(root, "apt/sources.list.d", repository.list), + ); + expect(reason).toContain( + join(root, "lists", `${repository.releaseFile}InRelease`), + ); + expect(reason).not.toMatch(/[a-z][a-z0-9+.-]*:\/\//iu); + expect(keyrings).toHaveLength(1); + expect(signingKeys.size).toBe(0); + expect(exportInstall(installed, "typora", "arm64")).toMatchObject({ + ok: false, + error: { field: "capture", reason: expect.stringContaining(reason) }, + }); + }); + }, +); + describe.skipIf(process.platform === "win32")("library packages", () => { it("keeps the library packages the Ubuntu archive offers and sets aside the rest", async () => { const vendor = await inspected(); diff --git a/src/desktop/GpgvKeyCheck.test.ts b/src/desktop/GpgvKeyCheck.test.ts index d6505cb243..6cd53855fc 100644 --- a/src/desktop/GpgvKeyCheck.test.ts +++ b/src/desktop/GpgvKeyCheck.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { + A_ARMORED, SIGNED_BY_BOTH, VENDOR_A, VENDOR_B, @@ -62,6 +63,25 @@ describe.skipIf(!hasGpgv)("withGpgvKeyCheck with gpgv", () => { expect(verified.fingerprint).toBe(VENDOR_A.fingerprint); expect(verified.bytes.equals(VENDOR_A.key)).toBe(true); }); + + it("verifies the armored key a sources entry holds and retains it as it is, named by the entry's file", async () => { + const { release } = await files({}); + const sources = "/etc/apt/sources.list.d/a-ubuntu-b-noble.sources"; + + const verified = await withGpgvKeyCheck(runHostQuery, undefined, (check) => + check.heldVerifying({ path: sources, bytes: Buffer.from(A_ARMORED) }, [ + release, + ]), + ); + + if ("reason" in verified) throw new Error(verified.reason); + expect(verified).toMatchObject({ + path: sources, + format: "armored", + fingerprint: VENDOR_A.fingerprint, + }); + expect(verified.bytes.toString()).toBe(A_ARMORED); + }); }); describe("withGpgvKeyCheck cleanup", () => { @@ -94,4 +114,29 @@ describe("withGpgvKeyCheck cleanup", () => { ); await expect(access(owned.path)).rejects.toMatchObject({ code: "ENOENT" }); }); + + it("writes a held key's keyring into the private folder and removes it, saying why the key does not verify", async () => { + const { release } = await files({}); + const keyrings: string[] = []; + const runQuery: QueryRunner = async (_command, argv) => { + keyrings.push(argv[argv.indexOf("--keyring") + 1] ?? ""); + return { stdout: "[GNUPG:] NEWSIG\n", exitCode: 2 }; + }; + const sources = "/etc/apt/sources.list.d/a-ubuntu-b-noble.sources"; + + const verified = await withGpgvKeyCheck(runQuery, undefined, (check) => + check.heldVerifying({ path: sources, bytes: Buffer.from(A_ARMORED) }, [ + release, + ]), + ); + + if (!("reason" in verified)) throw new Error("expected a refusal"); + expect(verified.reason).toContain(sources); + expect(verified.reason).toContain(release); + expect(verified.reason).not.toMatch(/[a-z][a-z0-9+.-]*:\/\//iu); + expect(keyrings).toHaveLength(1); + await expect(access(keyrings[0] ?? "")).rejects.toMatchObject({ + code: "ENOENT", + }); + }); }); diff --git a/src/desktop/GpgvKeyCheck.ts b/src/desktop/GpgvKeyCheck.ts index 6758a9e5b3..e234703caf 100644 --- a/src/desktop/GpgvKeyCheck.ts +++ b/src/desktop/GpgvKeyCheck.ts @@ -16,7 +16,10 @@ import type { QueryRunner } from "./HostQuery.js"; const FILE_MAX_BYTES = 1024 * 1024; -/** A candidate key file that verified a release, and the bytes of it to retain. */ +const released = (release: readonly string[]): string => + release[0] ?? "its release file"; + +/** A candidate key file, or a key a sources entry holds, that verified a release, and the bytes of it to retain. */ export interface VerifiedKey { readonly path: string; readonly bytes: Buffer; @@ -26,6 +29,12 @@ export interface VerifiedKey { readonly fingerprint: string; } +/** A key a deb822 sources entry holds in its Signed-By field, named by the entry's sources file. */ +export interface HeldKey { + readonly path: string; + readonly bytes: Buffer; +} + /** Runs gpgv on one candidate keyring at a time, written into a private folder it owns. */ class GpgvKeyCheck { #root: PrivateRuntimeRoot | undefined; @@ -55,31 +64,53 @@ class GpgvKeyCheck { candidates: readonly string[], release: readonly string[], ): Promise { - const released = release[0] ?? "its release file"; const unreadable: string[] = []; for (const path of candidates) { const bytes = await this.#read(path); if (bytes === "unreadable") unreadable.push(path); if (typeof bytes === "string") continue; - const signature = await this.#verify(bytes, release); - if (signature === "missing") - return { - reason: - "gpgv is not installed, so no key could be checked against the repository's signature", - }; - if (signature === undefined) continue; - const kept = verifyingKey(bytes, signature.primary); - if (!kept.ok) - return { reason: `${path} verifies ${released}, but ${kept.error}` }; - return { - path, - ...kept.value, - sha256: createHash("sha256").update(kept.value.bytes).digest("hex"), - fingerprint: signature.primary, - }; + const verified = await this.#verifying({ path, bytes }, release); + if (verified !== undefined) return verified; } return { - reason: `no candidate key (${candidates.join(", ")}) verifies ${released}${unreadable.length === 0 ? "" : `; unreadable: ${unreadable.join(", ")}`}`, + reason: `no candidate key (${candidates.join(", ")}) verifies ${released(release)}${unreadable.length === 0 ? "" : `; unreadable: ${unreadable.join(", ")}`}`, + }; + } + + /** The key a sources entry holds when it verifies the release, or why not. */ + async heldVerifying( + key: HeldKey, + release: readonly string[], + ): Promise { + return ( + (await this.#verifying(key, release)) ?? { + reason: `the key the Signed-By field of ${key.path} holds does not verify ${released(release)}`, + } + ); + } + + /** The key with the bytes to retain when it verifies the release, why it cannot count, or undefined when it does not verify. */ + async #verifying( + key: { readonly path: string; readonly bytes: Buffer }, + release: readonly string[], + ): Promise { + const signature = await this.#verify(key.bytes, release); + if (signature === "missing") + return { + reason: + "gpgv is not installed, so no key could be checked against the repository's signature", + }; + if (signature === undefined) return undefined; + const kept = verifyingKey(key.bytes, signature.primary); + if (!kept.ok) + return { + reason: `${key.path} verifies ${released(release)}, but ${kept.error}`, + }; + return { + path: key.path, + ...kept.value, + sha256: createHash("sha256").update(kept.value.bytes).digest("hex"), + fingerprint: signature.primary, }; } @@ -118,7 +149,9 @@ class GpgvKeyCheck { export const withGpgvKeyCheck = async ( runQuery: QueryRunner, signal: AbortSignal | undefined, - use: (check: Pick) => Promise, + use: ( + check: Pick, + ) => Promise, ): Promise => { const check = new GpgvKeyCheck(runQuery, signal); let outcome: diff --git a/src/domain/desktop/aptKeys.fixture.ts b/src/domain/desktop/aptKeys.fixture.ts index 7f868abd1e..c5bb11abc6 100644 --- a/src/domain/desktop/aptKeys.fixture.ts +++ b/src/domain/desktop/aptKeys.fixture.ts @@ -23,6 +23,40 @@ export const VENDOR_B = { ), }; +/** `gpg --armor --export A`, GnuPG 2.4.9. */ +export const A_ARMORED = `-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEauuPaBYJKwYBBAHaRw8BAQdAU6pi/2EhXePFnrHKRH29KuHZHwAQElOjI9pN +c4b8HbG0GFZlbmRvciBBIDxhQGV4YW1wbGUuY29tPoiZBBMWCgBBFiEEbBcWys4/ +mLGBw840ZwHP1biTA2wFAmrrj2gCGwMFCQXw8dgFCwkIBwICIgIGFQoJCAsCBBYC +AwECHgcCF4AACgkQZwHP1biTA2x38AD/TyC+wrSKRFUaH3E/VTwobbbg5wZbGYMk +n1ytPPD61xkA/j+dC/mvcEkT5YfMLx7KFPrlRnp+SAAWZBzW9ywLNo4A +=Em8s +-----END PGP PUBLIC KEY BLOCK----- +`; + +/** `gpg --armor --export B`, GnuPG 2.4.9. */ +export const B_ARMORED = `-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEauuPaBYJKwYBBAHaRw8BAQdA7npYbfFfwg0YB91jv2Ion9nZATrqRjMxfyoM +zYbuIKW0GFZlbmRvciBCIDxiQGV4YW1wbGUuY29tPoiZBBMWCgBBFiEEwo1/MzfH +muvX3Ul2+uUZIWP8omgFAmrrj2gCGwMFCQXw8dgFCwkIBwICIgIGFQoJCAsCBBYC +AwECHgcCF4AACgkQ+uUZIWP8omi6wwEAwdUiyGmAnfqDDEbm5jx1LyKa7NtHEEcH +FNiRbBi99DQBAJTsAHBzv2QhQ4t3tCelAnVI/u7T47wfKi3NgIp1MBkC +=OjLx +-----END PGP PUBLIC KEY BLOCK----- +`; + +/** The Signed-By field `add-apt-repository ppa:…` writes for a key on Ubuntu 24.04: each line indented, an empty one as ` .`. */ +export const inlineSignedBy = (armored: string): string => + [ + "Signed-By: ", + ...armored + .trimEnd() + .split("\n") + .map((line) => (line === "" ? " ." : ` ${line}`)), + ].join("\n"); + /** `gpg --armor --export A B`: both keys in one armor block. */ export const BOTH_ARMORED = `-----BEGIN PGP PUBLIC KEY BLOCK----- diff --git a/src/domain/desktop/aptRepositories.test.ts b/src/domain/desktop/aptRepositories.test.ts index 1a363c7927..98dff7f95c 100644 --- a/src/domain/desktop/aptRepositories.test.ts +++ b/src/domain/desktop/aptRepositories.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { GPGV_A_ONLY, VENDOR_A } from "./aptKeys.fixture.js"; +import { + A_ARMORED, + GPGV_A_ONLY, + inlineSignedBy, + VENDOR_A, +} from "./aptKeys.fixture.js"; import { aptListFileName, dearmorPublicKeys, @@ -107,7 +112,7 @@ describe("parseOneLineSources", () => { components: [], architectures: [], signedBy: [], - inlineKey: false, + inlineKey: null, }, ]); }); @@ -150,7 +155,7 @@ describe("parseDeb822Sources", () => { }); }); - it("skips disabled stanzas yet counts them, as apt does, and marks a Signed-By that holds the key itself", () => { + it("skips disabled stanzas yet counts them, as apt does, and reads a Signed-By that holds the key itself", () => { const stanzas = parseDeb822Sources( [ "# a comment-only paragraph apt does not count", @@ -180,11 +185,52 @@ describe("parseDeb822Sources", () => { uris: ["https://ppa.launchpadcontent.net/a/b/ubuntu"], architectures: ["arm64"], signedBy: [], - inlineKey: true, + inlineKey: + "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBGdummy\n-----END PGP PUBLIC KEY BLOCK-----\n", }), ]); }); + it("reads the key a PPA's Signed-By holds as add-apt-repository writes it, so it dearmors to the key", () => { + const [stanza] = parseDeb822Sources( + [ + "Types: deb", + "URIs: https://ppa.launchpadcontent.net/freecad-maintainers/freecad-stable/ubuntu/", + "Suites: noble", + "Components: main", + inlineSignedBy(A_ARMORED), + "", + ].join("\n"), + "/etc/apt/sources.list.d/freecad-maintainers-ubuntu-freecad-stable-noble.sources", + ); + + expect(stanza).toMatchObject({ signedBy: [], inlineKey: A_ARMORED }); + expect( + dearmorPublicKeys(Buffer.from(stanza?.inlineKey ?? ""))?.equals( + VENDOR_A.key, + ), + ).toBe(true); + }); + + it("reads a key that starts on the Signed-By line, with tab-indented lines and a field after it", () => { + const [stanza] = parseDeb822Sources( + [ + "Types: deb", + "URIs: https://ppa.launchpadcontent.net/a/b/ubuntu/", + "Suites: noble", + `Signed-By: ${A_ARMORED.trimEnd().replaceAll("\n\n", "\n.\n").replaceAll("\n", "\n\t ")}`, + "# a comment between fields", + "Components: main", + ].join("\n"), + "a-ubuntu-b-noble.sources", + ); + + expect(stanza).toMatchObject({ + components: ["main"], + inlineKey: A_ARMORED, + }); + }); + it("keeps a stanza's URIs and suites as lists and resolves a target's pair from them, without expanding every pair", () => { const uris = Array.from( { length: 500 }, diff --git a/src/domain/desktop/aptRepositories.ts b/src/domain/desktop/aptRepositories.ts index 9ebfe7c50d..4f710844df 100644 --- a/src/domain/desktop/aptRepositories.ts +++ b/src/domain/desktop/aptRepositories.ts @@ -18,8 +18,8 @@ export interface AptSourceStanza { readonly architectures: readonly string[]; /** Keyring paths and key fingerprints the entry is signed by. */ readonly signedBy: readonly string[]; - /** Whether Signed-By holds a key block rather than paths or fingerprints. */ - readonly inlineKey: boolean; + /** The armored key block Signed-By holds in place of paths or fingerprints; null when it holds none. */ + readonly inlineKey: string | null; } /** One `deb` entry of an apt sources file for one URI and suite. */ @@ -55,6 +55,22 @@ export const withoutCredentials = (uri: string): string => export const withoutCredentialsIn = (text: string): string => text.replace(/(?[a-z][a-z0-9+.-]*:\/\/)[^\s/]*@/giu, "$"); +/** + * The key block a deb822 Signed-By field holds, as apt writes it to a key + * file: each line stripped and a `.` line made empty, from its BEGIN line to + * its last END line; null when the field holds no key block. + */ +const inlineKeyBlock = (value: string): string | null => { + if (!value.includes(ARMOR_BEGIN)) return null; + const lines = value.split("\n").map((line) => { + const stripped = line.trim(); + return stripped === "." ? "" : stripped; + }); + const begin = Math.max(lines.indexOf(ARMOR_BEGIN), 0); + const end = lines.lastIndexOf(ARMOR_END); + return `${lines.slice(begin, end < begin ? lines.length : end + 1).join("\n")}\n`; +}; + /** Whether a suite as an entry writes it is a target's release, which apt leaves empty for the flat suite `/`. */ const isRelease = (suite: string, release: string): boolean => suite === release || (suite === "/" && release === ""); @@ -69,7 +85,7 @@ const stanza = ( }, ): AptSourceStanza => { const signedBy = fields.options.get("signed-by") ?? ""; - const inlineKey = signedBy.includes(ARMOR_BEGIN); + const inlineKey = inlineKeyBlock(signedBy); const uris = fields.uris.map(withoutCredentials); return { ...at, @@ -80,7 +96,7 @@ const stanza = ( suites: fields.suites, components: fields.components, architectures: listValues(fields.options.get("arch")), - signedBy: inlineKey ? [] : listValues(signedBy), + signedBy: inlineKey === null ? listValues(signedBy) : [], inlineKey, }; }; From 81ada025c02be1afb98661817c5784179f54280c Mon Sep 17 00:00:00 2001 From: Sahinur <68515168+SahinurDEV@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:09:18 +0600 Subject: [PATCH 083/151] fix(native): preserve Objective-C records with unreadable protocol lists Keep independent Objective-C class, metaclass, category and protocol records when an adopted-protocol list is unreadable. The shared list reader retains readable prefixes through tagged Result failures; consumers preserve partial/unknown semantics with unchanged limits. Advance the source-owned reader commitment to v3 and reject stale v2 snapshot bindings. Validated with 232 focused tests, current-main and original-proposal RED checks, named provider-result/schema/identity controls, and a verified public GLFW Mach-O paired with independent LLVM/Python byte evidence. Ordinary binary results remain identical; a deliberately mutated count preserves ten rows instead of eight. The final head passes the real macOS CI lane, with twelve CLI tests including both new protocol-count cases over CLI and stdio MCP. Snapshot binding and same-version replay checks pass. The original unchecked human declarations are preserved. The explicit maintainer readiness exception applies after technical CI and automated review, without adding a human-review attestation. Fixes #8945 Full technical CI 42263548929 passes, including all eight shards and required aggregates. The Author readiness-only exception is explicitly authorized; no human-review attestation is added. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) --- docs/native-investigation.md | 7 + src/native/AppleDispatchMetadata.test.ts | 141 ++++++++++++++++++ src/native/AppleObjcCategories.ts | 24 ++- src/native/AppleObjcDispatchFacets.ts | 12 +- src/native/AppleObjcProtocols.ts | 63 +++++--- src/native/NativeMacOSProviderMetadata.ts | 4 +- .../nativeDispatchPropertyAttributes.test.ts | 67 ++++++++- .../directAnalysisSnapshotReplay.test.ts | 7 +- .../nativeDispatchFormatFailures.test.ts | 43 +++++- tests/fixtures/objcRecordBudgetMacho.ts | 8 + 10 files changed, 344 insertions(+), 32 deletions(-) diff --git a/docs/native-investigation.md b/docs/native-investigation.md index b3847b4ca8..9e92f074f6 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -927,6 +927,13 @@ complete observation. section lengths and unsupported non-PowerPC big-endian CPU declarations return `unsupported_target` with the selected path and precise reason. Recovery selects a well-formed or supported artifact and reopens that target. + An unreadable adopted-protocol sublist leaves its class, category or + `objc_protocol_records` entry available with partial decode status and + `protocol_list_unreadable`. Independently decoded metadata and any readable + protocol-name prefix remain available; an empty partial list does not + establish that its owner adopts no protocols. Existing list and record + limits remain in effect. Reader v3 uses a new analysis profile; snapshots + bound to reader v2 are incompatible and must be regenerated for this recovery. The byte reader holds the whole selected file in memory, so it admits files up to 64 MiB / 67108864 bytes by default. Select a larger budget with `max_input_bytes` (CLI `--max-input-bytes`), at most 1 GiB / 1073741824 diff --git a/src/native/AppleDispatchMetadata.test.ts b/src/native/AppleDispatchMetadata.test.ts index 9ac17f938c..4e357503d8 100644 --- a/src/native/AppleDispatchMetadata.test.ts +++ b/src/native/AppleDispatchMetadata.test.ts @@ -2478,3 +2478,144 @@ it.each([false, true])( ).toBe(unreadParent ? "partial" : "complete"); }, ); + +describe("Objective-C adopted protocol-list count limit", () => { + // Root class and metaclass that adopt the fixture protocol through the + // class_ro_t base protocol list at 0x700. + const rootClass = (protocolCount: bigint, category?: "partial-method") => { + const bytes = fixture(false, category); + bytes.writeUInt32LE(2, 12); + bytes.writeUInt32LE(5, 88); + bytes.writeUInt32LE(2, 0x300); + bytes.writeUInt32LE(3, 0x380); + bytes.writeBigUInt64LE(protocolCount, 0x700); + return bytes; + }; + const classCoverage = ( + result: ReturnType, + ) => + result.coverage.find( + ({ facet }) => facet === "objc_class_method_ivar_metadata", + ); + + it("retains the class and metaclass rows when the list exceeds its count limit", () => { + const result = decodeAppleDispatchMetadata( + rootClass(20001n), + 100, + provenance, + ); + expect( + result.objc_classes.map(({ name, is_meta_class, protocols, decode }) => ({ + name, + is_meta_class, + protocols, + decode, + })), + ).toEqual([ + { + name: "Fixture", + is_meta_class: false, + protocols: [], + decode: { status: "partial", reason: "protocol_list_unreadable" }, + }, + // The metaclass has no adopted-protocol list of its own. + { + name: "Fixture", + is_meta_class: true, + protocols: [], + decode: { status: "decoded", reason: null }, + }, + ]); + expect(result.objc_classes[0]?.properties).toHaveLength(3); + expect(result.objc_ivars).toHaveLength(1); + expect(result.objc_dispatch_implementations).toMatchObject([ + { class_name: "Fixture", selector: "performAction:" }, + ]); + expect(classCoverage(result)).toMatchObject({ + status: "partial", + // The class row is retained as partial, so it is examined but not + // counted as fully decoded; the metaclass is fully decoded. + examined: 2, + decoded: 1, + }); + }); + + it("retains the readable protocol prefix when a later list slot is unmapped", () => { + const bytes = rootClass(2n); + bytes.writeBigUInt64LE(base + 0x5000n, 0x710); + const result = decodeAppleDispatchMetadata(bytes, 100, provenance); + expect(result.objc_classes).toMatchObject([ + { + name: "Fixture", + protocols: ["FixtureProtocol"], + decode: { status: "partial", reason: "protocol_list_unreadable" }, + }, + { name: "Fixture", is_meta_class: true }, + ]); + expect(result.objc_protocol_records).toMatchObject([ + { name: "FixtureProtocol", decode: { status: "decoded" } }, + ]); + }); + + it("retains a category's own methods and properties after a protocol-list refusal", () => { + const bytes = rootClass(0n, "partial-method"); + bytes.writeBigUInt64LE(base + 0x700n, 0xb98); + bytes.writeBigUInt64LE(base + 0x600n, 0xb68); + bytes.writeBigUInt64LE(base + 0xa00n, 0xb60); + bytes.writeBigUInt64LE(20001n, 0xa00); + const result = decodeAppleDispatchMetadata(bytes, 100, provenance); + expect(result.objc_categories).toMatchObject([ + { + name: "FixtureCategory", + class_name: "Fixture", + instance_methods: ["performAction:"], + protocols: [], + decode: { status: "partial", reason: "protocol_list_unreadable" }, + }, + ]); + expect(result.objc_categories[0]?.properties).toHaveLength(3); + expect(classCoverage(result)?.status).toBe("complete"); + expect( + result.coverage.find( + ({ facet }) => facet === "objc_properties_categories", + ), + ).toMatchObject({ status: "partial" }); + }); + + it("retains an adopting protocol's own method declarations after a list refusal", () => { + const bytes = rootClass(1n); + bytes.writeBigUInt64LE(base + 0xa00n, 0x750); + bytes.writeBigUInt64LE(20001n, 0xa00); + const result = decodeAppleDispatchMetadata(bytes, 100, provenance); + expect(result.objc_protocol_records).toMatchObject([ + { + name: "FixtureProtocol", + adopted_protocols: [], + methods: [{ selector: "requiredAction:" }], + optional_methods: [{ selector: "optionalAction:" }], + decode: { status: "partial", reason: "protocol_list_unreadable" }, + }, + ]); + expect(result.objc_protocols).toMatchObject([ + { name: "FixtureProtocol", methods: [{ selector: "requiredAction:" }] }, + ]); + expect(result.objc_classes).toHaveLength(2); + expect(classCoverage(result)?.status).toBe("partial"); + }); + + it.each([0n, 1n])( + "keeps complete class coverage for an in-limit count of %s", + (count) => { + const bytes = rootClass(count); + const result = decodeAppleDispatchMetadata(bytes, 100, provenance); + const reason = classCoverage(result)?.reason ?? ""; + expect(reason).not.toContain("Protocol list of Fixture"); + expect(result.objc_classes).toHaveLength(2); + for (const row of result.objc_classes) + expect(row.decode?.reason ?? "").not.toContain( + "protocol_list_unreadable", + ); + expect(classCoverage(result)?.status).toBe("complete"); + }, + ); +}); diff --git a/src/native/AppleObjcCategories.ts b/src/native/AppleObjcCategories.ts index ae4b5dd329..d82de3c0f0 100644 --- a/src/native/AppleObjcCategories.ts +++ b/src/native/AppleObjcCategories.ts @@ -4,6 +4,7 @@ import type { ObjcDispatchReaders } from "./AppleObjcDispatchFacets.js"; import { readObjcPropertiesOf } from "./AppleObjcProperties.js"; import { issue, type DecodeIssue } from "./AppleDispatchDecodeFacts.js"; import { BinaryFormatFailure } from "./metadata/BinaryContainer.js"; +import type { ObjcProtocolListResult } from "./AppleObjcProtocols.js"; const CLASS_SYMBOL = /^_OBJC_(?:META)?CLASS_\$_(.+)$/u; @@ -50,7 +51,11 @@ export const decodeObjcCategories = (input: { category: string, onTruncated?: () => void, ) => string[]; - readonly protocols: (list: bigint, onTruncated?: () => void) => string[]; + /** Read the protocol-list pointer field, retaining known names on failure. */ + readonly protocolList: ( + field: bigint, + onTruncated?: () => void, + ) => ObjcProtocolListResult; /** Name of a class defined in this image, from its class_ro_t. */ readonly localClassName: (address: bigint) => string; }): number => { @@ -119,10 +124,16 @@ export const decodeObjcCategories = (input: { name, onTruncated, ); - const protocols = input.protocols( - read.pointer(category + 32n), - onTruncated, - ); + const protocols = input.protocolList(category + 32n, onTruncated); + if (!protocols.ok) + input.issues.push( + issue( + "objc_properties_categories", + "protocol_list_unreadable", + `Category ${name} adopted protocol list: ${protocols.error.reason}`, + `0x${(category + 32n).toString(16)}`, + ), + ); const properties = readObjcPropertiesOf( read, read.pointer(category + 40n), @@ -136,6 +147,7 @@ export const decodeObjcCategories = (input: { ); const decodeReasons = [ ...(className === null ? ["category_class_unresolved"] : []), + ...(protocols.ok ? [] : ["protocol_list_unreadable"]), ...(budgetTruncated ? ["max_records_reached"] : []), ]; result.objc_categories.push({ @@ -149,7 +161,7 @@ export const decodeObjcCategories = (input: { : "local", instance_methods: instanceMethods, class_methods: classMethods, - protocols, + protocols: protocols.ok ? protocols.value : protocols.error.names, properties, location: read.location(category), decode: diff --git a/src/native/AppleObjcDispatchFacets.ts b/src/native/AppleObjcDispatchFacets.ts index fd46e5eab8..09896ab331 100644 --- a/src/native/AppleObjcDispatchFacets.ts +++ b/src/native/AppleObjcDispatchFacets.ts @@ -361,12 +361,20 @@ export const decodeObjcDispatchFacets = (input: { location: hex(read.pointer(ro + 64n)), onTruncated, }); - const protocols = protocolReader.list(read.pointer(ro + 40n), onTruncated); + const adoptedProtocols = protocolReader.readList(ro + 40n, onTruncated); + if (!adoptedProtocols.ok) + failures.push( + `Protocol list of ${name}: ${adoptedProtocols.error.reason}`, + ); + const protocols = adoptedProtocols.ok + ? adoptedProtocols.value + : adoptedProtocols.error.names; const classDecodeReasons = [ ...(superclass.name === null && !root ? ["superclass_binding_unresolved"] : []), ...(classListTruncated ? ["max_records_reached"] : []), + ...(adoptedProtocols.ok ? [] : ["protocol_list_unreadable"]), ]; result.objc_classes.push({ name, @@ -449,7 +457,7 @@ export const decodeObjcDispatchFacets = (input: { issues: categoryIssues, admit: () => budget.admit(), methods, - protocols: (list, onTruncated) => protocolReader.list(list, onTruncated), + protocolList: protocolReader.readList, localClassName: (address) => readLocalClass(read, address).name, }); return { diff --git a/src/native/AppleObjcProtocols.ts b/src/native/AppleObjcProtocols.ts index 97a548d221..60335bbd43 100644 --- a/src/native/AppleObjcProtocols.ts +++ b/src/native/AppleObjcProtocols.ts @@ -1,6 +1,17 @@ import { readObjcPropertyList } from "./AppleObjcProperties.js"; import type { ObjcSwiftMetadata } from "../domain/native/objcSwiftMetadata.js"; import type { SwiftNominalReaders } from "./AppleSwiftNominalTypes.js"; +import { err, ok, type Result } from "../domain/result.js"; + +/** A failed list still owns the names read before its unreadable suffix. */ +export type ObjcProtocolListResult = Result< + string[], + { + readonly _tag: "ProtocolListUnreadable"; + readonly names: string[]; + readonly reason: string; + } +>; /** Read protocol lists and absolute protocol method declarations with cycle-safe identities. */ export const createObjcProtocolReader = (input: { @@ -19,23 +30,36 @@ export const createObjcProtocolReader = (input: { const { readers: read, result, failures } = input; const visited = new Set(); const name = (address: bigint) => read.string(read.pointer(address + 8n)); - const list = (address: bigint, onTruncated?: () => void): string[] => { - if (address === 0n) return []; - // `count` is plain uintptr_t data, not a pointer fixup. - const count = read.u64(address); - if (count > 20000n) - throw new RangeError("Protocol list exceeds 20000 entries"); + /** Own the list pointer field as well as the list's count and entries. */ + const readList = ( + field: bigint, + onTruncated?: () => void, + ): ObjcProtocolListResult => { const names: string[] = []; - for (let index = 0n; index < count; index++) { - if (!read.admit()) { - onTruncated?.(); - break; + try { + const address = read.pointer(field); + if (address === 0n) return ok(names); + // `count` is plain uintptr_t data, not a pointer fixup. + const count = read.u64(address); + if (count > 20000n) + throw new RangeError("Protocol list exceeds 20000 entries"); + for (let index = 0n; index < count; index++) { + if (!read.admit()) { + onTruncated?.(); + break; + } + const protocol = read.pointer(address + 8n + index * 8n); + names.push(name(protocol)); + record(protocol); } - const protocol = read.pointer(address + 8n + index * 8n); - names.push(name(protocol)); - record(protocol); + return ok(names); + } catch (cause: unknown) { + return err({ + _tag: "ProtocolListUnreadable", + names, + reason: cause instanceof Error ? cause.message : String(cause), + }); } - return names; }; const methods = ( address: bigint, @@ -97,7 +121,11 @@ export const createObjcProtocolReader = (input: { const onTruncated = () => { budgetTruncated = true; }; - const adopted = list(read.pointer(address + 16n), onTruncated); + const adopted = readList(address + 16n, onTruncated); + if (!adopted.ok) + failures.push( + `Protocol 0x${address.toString(16)} adopted protocol list: ${adopted.error.reason}`, + ); if (protocolName.length === 0) throw new TypeError("Objective-C protocol has an empty name"); const skipped: string[] = []; @@ -165,6 +193,7 @@ export const createObjcProtocolReader = (input: { ); const decodeReasons = [ ...propertyReasons, + ...(adopted.ok ? [] : ["protocol_list_unreadable"]), ...(skipped.length === 0 ? [] : ["protocol_method_selector_empty"]), ...(budgetTruncated ? ["max_records_reached"] : []), ]; @@ -176,7 +205,7 @@ export const createObjcProtocolReader = (input: { }); result.objc_protocol_records.push({ name: protocolName, - adopted_protocols: adopted, + adopted_protocols: adopted.ok ? adopted.value : adopted.error.names, methods: required, optional_methods: optional, location: read.location(address), @@ -195,5 +224,5 @@ export const createObjcProtocolReader = (input: { ); } }; - return { list, record }; + return { readList, record }; }; diff --git a/src/native/NativeMacOSProviderMetadata.ts b/src/native/NativeMacOSProviderMetadata.ts index 931d1ca5b6..db058e0b17 100644 --- a/src/native/NativeMacOSProviderMetadata.ts +++ b/src/native/NativeMacOSProviderMetadata.ts @@ -9,12 +9,12 @@ export const APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE = createAnalysisProfile( { id: NATIVE_MACOS_PROVIDER_IDENTITY.id, name: NATIVE_MACOS_PROVIDER_IDENTITY.name, - version: "apple-metadata-reader-2", + version: "apple-metadata-reader-3", }, { operation: "inspect_native_dispatch_metadata", reader: "apple-dispatch-metadata", - revision: 2, + revision: 3, }, ); /** Operations whose target process can show UI, use the network and write files. */ diff --git a/tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts b/tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts index a254028d66..b233b6561b 100644 --- a/tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts +++ b/tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts @@ -1,4 +1,5 @@ -import { writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; import { join } from "node:path"; import { describe, expect } from "vitest"; @@ -6,6 +7,70 @@ import { describe, expect } from "vitest"; import { machoWithPropertyAttributes } from "../../fixtures/objcPropertyMacho.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +import { objcAdoptedProtocolCountMacho } from "../../fixtures/objcRecordBudgetMacho.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { nativeDispatchMetadataResultSchema } from "../../../src/domain/native/objcSwiftMetadata.js"; + +describe.skipIf(process.platform !== "darwin")( + "native adopted-protocol list recovery", + () => { + cliTest.for([1n, 20001n])( + "preserves class facts and partial-list semantics over CLI and stdio MCP for count %s", + async (count, { cli }) => { + const root = await createTestTempDirectory("rea-protocol-list-"); + const path = join(root, "fixture.macho"); + const bytes = objcAdoptedProtocolCountMacho(count); + await writeFile(path, bytes); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const cliReply = await cli.run({ + arguments: ["inspect-native-dispatch-metadata", path, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(cliReply.exitCode, cliReply.stderr).toBe(0); + const cliResult = nativeDispatchMetadataResultSchema.parse( + parseEvidence(cliReply.json).normalized_result, + ); + const call = await cli.sharedMcpCall!(); + const opened = await call("open_binary", { path }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + try { + const reply = await call("inspect_native_dispatch_metadata", {}); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + const mcpResult = nativeDispatchMetadataResultSchema.parse( + parseEvidence(reply.structuredContent).normalized_result, + ); + for (const result of [cliResult, mcpResult]) { + expect(result.target_sha256).toBe(sha256); + expect(result.input_admission?.retained_bytes).toBe(bytes.length); + expect(result.result.objc_classes).toMatchObject([ + { + name: "FixtureClass", + methods: [{ selector: "classMethod" }], + protocols: count === 1n ? ["FixtureProtocol"] : [], + decode: + count === 1n + ? { status: "decoded", reason: null } + : { status: "partial", reason: "protocol_list_unreadable" }, + }, + ]); + expect( + result.result.coverage.find( + ({ facet }) => facet === "objc_class_method_ivar_metadata", + )?.status, + ).toBe(count === 1n ? "complete" : "partial"); + } + expect(mcpResult.result.objc_classes).toEqual( + cliResult.result.objc_classes, + ); + } finally { + const closed = await call("close_binary", {}); + expect(closed.isError).not.toBe(true); + } + expect((await readFile(path)).equals(bytes)).toBe(true); + }, + ); + }, +); describe.skipIf(process.platform !== "darwin")( "native dispatch property attributes", diff --git a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts index 2921a6a445..748fe72662 100644 --- a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts +++ b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts @@ -1572,7 +1572,7 @@ const expectReaderBindingMismatch = ( expect(projectAnalysisError(error).details).toMatchObject({ binding_mismatch: { provider: { - snapshot: { version: "apple-metadata-reader-older" }, + snapshot: { version: "apple-metadata-reader-2" }, selected: { version: selectedVersion }, }, profile_digest: { snapshot: snapshotDigest }, @@ -1610,12 +1610,13 @@ const expectProfilelessWorkflowSnapshotRejections = async ( if (!opened.ok) throw opened.error; const readerIdentity = APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE.provider; const wrongReader = structuredClone(snapshot); + // Reader v2 could discard owners when their adopted-protocol list was unreadable. const otherReaderProfile = createAnalysisProfile( - { ...readerIdentity, version: "apple-metadata-reader-older" }, + { ...readerIdentity, version: "apple-metadata-reader-2" }, { operation: "inspect_native_dispatch_metadata", reader: "apple-dispatch-metadata", - revision: 0, + revision: 2, }, ); wrongReader.binding = { diff --git a/tests/boundary/providers/native/nativeDispatchFormatFailures.test.ts b/tests/boundary/providers/native/nativeDispatchFormatFailures.test.ts index b9def76798..9f3300fd8f 100644 --- a/tests/boundary/providers/native/nativeDispatchFormatFailures.test.ts +++ b/tests/boundary/providers/native/nativeDispatchFormatFailures.test.ts @@ -1,14 +1,19 @@ import { writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; import { join } from "node:path"; import { expect, it } from "vitest"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js"; +import { nativeDispatchMetadataResultSchema } from "../../../../src/domain/native/objcSwiftMetadata.js"; import { NativeFixtureRunner, nativeMachoTargetForFile, } from "../../../fixtures/nativeCommands.js"; -import { objcRecordBudgetMacho } from "../../../fixtures/objcRecordBudgetMacho.js"; +import { + objcAdoptedProtocolCountMacho, + objcRecordBudgetMacho, +} from "../../../fixtures/objcRecordBudgetMacho.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; const sectionCases = [ @@ -101,3 +106,39 @@ it("continues decoding the well-formed eight-byte class-list control", async () }, }); }); + +it.each([1n, 20001n])( + "retains class facts and source identity through provider delivery for protocol count %s", + async (count) => { + const bytes = objcAdoptedProtocolCountMacho(count); + const { result } = await inspect(bytes); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + const report = nativeDispatchMetadataResultSchema.parse( + result.value.result, + ); + expect(report.target_sha256).toBe( + createHash("sha256").update(bytes).digest("hex"), + ); + expect(report.input_admission?.retained_bytes).toBe(bytes.length); + expect(report.result.objc_classes).toMatchObject([ + { + name: "FixtureClass", + methods: [{ selector: "classMethod", method_type: "instance" }], + protocols: count === 1n ? ["FixtureProtocol"] : [], + decode: + count === 1n + ? { status: "decoded", reason: null } + : { status: "partial", reason: "protocol_list_unreadable" }, + }, + ]); + expect(report.result.objc_dispatch_implementations).toMatchObject([ + { class_name: "FixtureClass", selector: "classMethod" }, + ]); + expect( + report.result.coverage.find( + ({ facet }) => facet === "objc_class_method_ivar_metadata", + )?.status, + ).toBe(count === 1n ? "complete" : "partial"); + }, +); diff --git a/tests/fixtures/objcRecordBudgetMacho.ts b/tests/fixtures/objcRecordBudgetMacho.ts index 747fac20f2..892a16266b 100644 --- a/tests/fixtures/objcRecordBudgetMacho.ts +++ b/tests/fixtures/objcRecordBudgetMacho.ts @@ -76,3 +76,11 @@ export const objcRecordBudgetMacho = ( bytes.write("protocolMethod\0", 0x9a0); return bytes; }; + +/** A class adopts the fixture protocol through a separately counted list. */ +export const objcAdoptedProtocolCountMacho = (count: bigint): Buffer => { + const bytes = objcRecordBudgetMacho("class"); + bytes.writeBigUInt64LE(base + 0x6b8n, 0x328); + bytes.writeBigUInt64LE(count, 0x6b8); + return bytes; +}; From fdc4e3908b70d7cf6b03f357fb9c42a133bb5577 Mon Sep 17 00:00:00 2001 From: Sahinur <68515168+SahinurDEV@users.noreply.github.com> Date: Sat, 7 Nov 2026 02:12:31 +0600 Subject: [PATCH 084/151] fix(artifacts): report ZIP Zstd window refusals as resource limits Classify a native method-93 decoder window refusal once at the ZIP owner as limit/memory, retaining the fixed 128 MiB cap, native cause and existing corruption/CRC/size/cancellation handling. Derive recovery labels from the cap and share one independent frame fixture. Validated with 43 reader/translationtests,three actual CLI/stdio MCP cases, a pinned public GLFW payload rewrapped in independently verified ZIP controls, six equivalent CLI calls before/after, static checks and documentation checks. All input bytes remain unchanged, normal extraction matches exactly, and failed outputs are absent. ELF/GPU component refusal-policy gaps remain separate unverified full-format workflows. The original three unchecked human declarations are preserved. The explicitly authorized readiness-only exception applies after technical CI and review; no human-review attestation is added. Fixes #8623 Full technical CI 42279406899 passes, including all eight shards and required aggregates. Greptile reviews this head at 5/5; Codex cannot complete because the repository review quota is exhausted. The Author readiness-only exception is explicitly authorized; no human-review attestation is added. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) --- docs/cli.md | 5 +- src/artifacts/ZipArtifactReader.test.ts | 112 +++++++++++----- src/artifacts/ZipArtifactReader.ts | 21 ++- .../cli/artifactZstandardWindow.test.ts | 120 ++++++++++++++++++ tests/fixtures/zipZstandard.ts | 11 ++ 5 files changed, 235 insertions(+), 34 deletions(-) create mode 100644 tests/boundary/cli/artifactZstandardWindow.test.ts diff --git a/docs/cli.md b/docs/cli.md index e0b8d0e106..6e5c556d1c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -349,7 +349,10 @@ unknown names, resource limits and retained views. Compressed bytes stay stored. Zstandard (standard method 93). They retain the shared ZIP CRC, declared-size, overlap and archive expansion checks. The native streaming decoder keeps the existing 128 MiB Zstandard window cap; frames requiring a larger window are -refused. Deprecated method 20 and other unregistered ZIP codecs remain unsupported. +reported as a resource limit (`reason: limit`, `boundary: memory`), rather than +as archive corruption. The cap cannot be raised at runtime: recompress with a +window of at most 128 MiB, or decompress externally before retrying. +Deprecated method 20 and other unregistered ZIP codecs remain unsupported. Inventory and extract UF2 or DfuSe regions: diff --git a/src/artifacts/ZipArtifactReader.test.ts b/src/artifacts/ZipArtifactReader.test.ts index a117ea9ba3..f629a0a390 100644 --- a/src/artifacts/ZipArtifactReader.test.ts +++ b/src/artifacts/ZipArtifactReader.test.ts @@ -1,6 +1,9 @@ import { writeOrderedZip } from "../../tests/fixtures/artifactEntryOrder.js"; import { zipSymlinkMetadataFixture } from "../../tests/fixtures/zipSymlinkMetadata.js"; -import { zstandardZipFixture } from "../../tests/fixtures/zipZstandard.js"; +import { + zstandardZipFixture, + zstandardWindowFrameFixture, +} from "../../tests/fixtures/zipZstandard.js"; import { ArtifactReaderFailure, type ArtifactEntry } from "./ArtifactReader.js"; import { createDeferred } from "../../tests/fixtures/binarySession.js"; import { once } from "node:events"; @@ -27,6 +30,7 @@ import { MINIMUM_ARTIFACT_EXPANDED_BYTES } from "../domain/artifactExpansionBudg import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { hashReadable } from "./ArtifactHash.js"; import { translateArtifactFailure } from "./ArtifactOperationFailure.js"; +import { analysisErrorUserMessage } from "../domain/analysisErrorPresentation.js"; /** Small real ZIP carriers with forged sizes; metadata-only controls, never decompressed. */ const writeZipWithDeclaredSize = async ( @@ -115,35 +119,38 @@ const admittedZip = async () => { return { path, content, handle, positional, initial }; }; -describe("ZIP Zstandard method 93", () => { - const read = async (archive: Buffer): Promise => { - const root = await createTestTempDirectory("rea-zip-zstd-"); - const path = join(root, "method93.zip"); - await writeFile(path, archive); - const reader = new ZipArtifactReader(path, "zip"); - try { - const entries = []; - for await (const entry of reader.entries()) entries.push(entry); - const entry = entries[0]; - if (entry === undefined) throw new Error("Missing ZIP member"); - const chunks: Buffer[] = []; - for await (const chunk of await reader.open(entry)) - chunks.push(Buffer.from(chunk)); - return Buffer.concat(chunks); - } finally { - await reader.close(); - } - }; +/** Read the first member of one method-93 ZIP fixture. */ +const readFirstZstandardMember = async (archive: Buffer): Promise => { + const root = await createTestTempDirectory("rea-zip-zstd-"); + const path = join(root, "method93.zip"); + await writeFile(path, archive); + const reader = new ZipArtifactReader(path, "zip"); + try { + const entries = []; + for await (const entry of reader.entries()) entries.push(entry); + const entry = entries[0]; + if (entry === undefined) throw new Error("Missing ZIP member"); + const chunks: Buffer[] = []; + for await (const chunk of await reader.open(entry)) + chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks); + } finally { + await reader.close(); + } +}; +describe("ZIP Zstandard method 93", () => { it.each([ Buffer.alloc(0), Buffer.from("Zstandard ZIP evidence: café\n".repeat(7)), ])( "streams exact bytes from a standard method-93 ZIP: %j", async (content) => { - expect((await read(zstandardZipFixture(content))).equals(content)).toBe( - true, - ); + expect( + (await readFirstZstandardMember(zstandardZipFixture(content))).equals( + content, + ), + ).toBe(true); }, ); @@ -154,7 +161,9 @@ describe("ZIP Zstandard method 93", () => { "retains %s validation for Zstandard entries", async (_label, options, message) => { await expect( - read(zstandardZipFixture(Buffer.from("member bytes"), options)), + readFirstZstandardMember( + zstandardZipFixture(Buffer.from("member bytes"), options), + ), ).rejects.toMatchObject({ name: "ArtifactReaderFailure", reason: "format", @@ -167,7 +176,9 @@ describe("ZIP Zstandard method 93", () => { "keeps unsupported ZIP method %s unavailable", async (method) => { await expect( - read(zstandardZipFixture(Buffer.from("member bytes"), { method })), + readFirstZstandardMember( + zstandardZipFixture(Buffer.from("member bytes"), { method }), + ), ).rejects.toMatchObject({ reason: "unavailable", message: expect.stringContaining("Compression method not supported"), @@ -178,10 +189,11 @@ describe("ZIP Zstandard method 93", () => { it.each([ ["bad frame", Buffer.from("not a Zstandard frame")], ["truncated frame", Buffer.from("28b52ffd", "hex")], - ["excessive decoder window", Buffer.from("28b52ffd0090010000", "hex")], ] as const)("reports %s as an archive fault", async (_label, compressed) => { await expect( - read(zstandardZipFixture(Buffer.alloc(0), { compressed })), + readFirstZstandardMember( + zstandardZipFixture(Buffer.alloc(0), { compressed }), + ), ).rejects.toMatchObject({ name: "ArtifactReaderFailure", reason: "format", @@ -196,7 +208,7 @@ describe("ZIP Zstandard method 93", () => { }); compressed[compressed.length - 1]! ^= 1; await expect( - read(zstandardZipFixture(content, { compressed })), + readFirstZstandardMember(zstandardZipFixture(content, { compressed })), ).rejects.toMatchObject({ reason: "format", message: expect.stringContaining("Invalid compressed data"), @@ -206,7 +218,7 @@ describe("ZIP Zstandard method 93", () => { it("reports a required Zstandard dictionary as unavailable", async () => { const compressed = Buffer.from("28b52ffd210100010000", "hex"); - const failure = await read( + const failure = await readFirstZstandardMember( zstandardZipFixture(Buffer.alloc(0), { compressed }), ).catch((cause: unknown) => cause); expect(failure).toMatchObject({ @@ -243,7 +255,7 @@ describe("ZIP Zstandard method 93", () => { "member bytes member bytes member bytes member bytes", ); await expect( - read( + readFirstZstandardMember( zstandardZipFixture(content, { compressed: Buffer.from(hex, "hex") }), ), ).rejects.toMatchObject({ @@ -256,7 +268,7 @@ describe("ZIP Zstandard method 93", () => { it("retains the archive expansion budget for method-93 declarations", async () => { await expect( - read( + readFirstZstandardMember( zstandardZipFixture(Buffer.from("small"), { declaredSize: MINIMUM_ARTIFACT_EXPANDED_BYTES + 1, }), @@ -267,6 +279,44 @@ describe("ZIP Zstandard method 93", () => { }); }); +describe("ZIP Zstandard decoder window", () => { + it("reports a valid frame above the decoder window as a resource limit", async () => { + // Window descriptor 0x90 declares 256 MiB; the single raw block is valid. + const { content, compressed: largeWindow } = + zstandardWindowFrameFixture(0x90); + const failure = await readFirstZstandardMember( + zstandardZipFixture(content, { compressed: largeWindow }), + ).catch((cause: unknown) => cause); + expect(failure).toMatchObject({ + name: "ArtifactReaderFailure", + reason: "limit", + boundary: "memory", + message: expect.stringContaining("fixed 128 MiB allowance"), + cause: { code: "ZSTD_error_frameParameter_windowTooLarge" }, + }); + const translated = translateArtifactFailure("extract_artifact", failure); + expect(translated).toMatchObject({ + reason: "limit", + boundary: "memory", + detail: expect.stringContaining("fixed 128 MiB allowance"), + }); + const userMessage = analysisErrorUserMessage(translated); + expect(userMessage).toContain("fixed 128 MiB allowance"); + expect(userMessage).not.toContain("fresh copy"); + + // Control: the same frame with a 1 MiB window descriptor decodes exactly. + const smallWindow = Buffer.from(largeWindow); + smallWindow[5] = 0x50; + expect( + ( + await readFirstZstandardMember( + zstandardZipFixture(content, { compressed: smallWindow }), + ) + ).equals(content), + ).toBe(true); + }); +}); + describe("ZIP Zstandard member streams", () => { it.each(["abort", "consumer-close"] as const)( "settles a streaming Zstandard read after %s and allows another read", diff --git a/src/artifacts/ZipArtifactReader.ts b/src/artifacts/ZipArtifactReader.ts index e1b4e2c2ca..e8d0a61137 100644 --- a/src/artifacts/ZipArtifactReader.ts +++ b/src/artifacts/ZipArtifactReader.ts @@ -53,6 +53,14 @@ const ZIP_UTF8_DECODER = new TextDecoder("utf-8", { ignoreBOM: true, }); +/** Fixed method-93 decoder window: 2^27 bytes, matching the ELF/GPU decoders. */ +const ZIP_ZSTANDARD_WINDOW_LOG_MAX = 27; + +const ZIP_ZSTANDARD_WINDOW_ALLOWANCE = `${String(2 ** (ZIP_ZSTANDARD_WINDOW_LOG_MAX - 20))} MiB`; + +/** A frame above the window cap is a deliberate resource refusal, not damage. */ +const ZIP_ZSTANDARD_WINDOW_LIMIT = `ZIP Zstandard member requires a decoder window above REA's fixed ${ZIP_ZSTANDARD_WINDOW_ALLOWANCE} allowance; the archive is not necessarily corrupt, and this limit cannot be raised at runtime. Recompress the member with a window of at most ${ZIP_ZSTANDARD_WINDOW_ALLOWANCE}, or decompress it with a tool that admits larger windows, then retry.`; + /** Keep zip.js's framing, CRC, size checks and backpressure for method 93. */ class ZipZstandardDecompressionStream { readonly readable; @@ -63,7 +71,9 @@ class ZipZstandardDecompressionStream { createZstdDecompress({ chunkSize: options?.chunkSize, // Match the existing ELF/GPU Zstandard decoder's 128 MiB window cap. - params: { [constants.ZSTD_d_windowLogMax]: 27 }, + params: { + [constants.ZSTD_d_windowLogMax]: ZIP_ZSTANDARD_WINDOW_LOG_MAX, + }, }), ); this.readable = stream.readable; @@ -525,6 +535,14 @@ const typedZipFailure = ( "ZIP archive uses an unsupported Zstandard dictionary", { cause }, ); + if ( + compressionMethod === 93 && + code === "ZSTD_error_frameParameter_windowTooLarge" + ) + return new ArtifactReaderFailure("limit", ZIP_ZSTANDARD_WINDOW_LIMIT, { + cause, + boundary: "memory", + }); const invalidZstandard = compressionMethod === 93 && (code === "ZSTD_error_prefix_unknown" || @@ -532,7 +550,6 @@ const typedZipFailure = ( code === "ZSTD_error_corruption_detected" || code === "ZSTD_error_dictionary_corrupted" || code === "ZSTD_error_frameParameter_unsupported" || - code === "ZSTD_error_frameParameter_windowTooLarge" || code === "Z_BUF_ERROR"); const reason = ZIP_FAILURE_REASONS.get(cause.message) ?? diff --git a/tests/boundary/cli/artifactZstandardWindow.test.ts b/tests/boundary/cli/artifactZstandardWindow.test.ts new file mode 100644 index 0000000000..ed2433c0d9 --- /dev/null +++ b/tests/boundary/cli/artifactZstandardWindow.test.ts @@ -0,0 +1,120 @@ +import { createHash } from "node:crypto"; +import { access, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + zstandardWindowFrameFixture, + zstandardZipFixture, +} from "../../fixtures/zipZstandard.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const expectRefusal = (diagnostic: unknown, large: boolean): void => { + expect(diagnostic).toMatchObject({ + code: large ? "truncated" : "artifact_operation_failed", + details: { + reason: large ? "limit" : "format", + ...(large ? { boundary: "memory", truncated: true } : {}), + }, + }); + if (large) { + expect(diagnostic).toMatchObject({ + message: expect.stringContaining("fixed 128 MiB allowance"), + remediation: { + action: expect.stringContaining("cannot be raised at runtime"), + }, + }); + expect(JSON.stringify(diagnostic)).not.toContain("fresh copy"); + } +}; + +cliTest.for(["small", "large", "corrupt"] as const)( + "preserves ZIP window classification over CLI and stdio MCP: %s", + async (kind, { cli }) => { + const root = await createTestTempDirectory("rea-zstd-window-"); + const path = join(root, "fixture.zip"); + const { content, compressed } = zstandardWindowFrameFixture( + kind === "large" ? 0x90 : 0x50, + ); + if (kind === "corrupt") compressed[0] = 0; + const bytes = zstandardZipFixture(content, { compressed }); + await writeFile(path, bytes); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const success = kind === "small"; + const evidencePath = join(root, "inventory.json"); + for (const operation of ["inspect-artifact", "extract-artifact"] as const) { + const outputRoot = join(root, `cli-${operation}`); + const reply = await cli.run({ + arguments: [ + operation, + path, + "--json", + ...(operation === "inspect-artifact" + ? ["--detail", "summary", "--evidence_output", evidencePath] + : ["--output-root", outputRoot]), + ], + }); + expect(reply.exitCode, reply.stderr).toBe(success ? 0 : 1); + if (!success) expectRefusal(reply.json, kind === "large"); + else if (operation === "extract-artifact") + expect( + (await readFile(join(outputRoot, "member.txt"))).equals(content), + ).toBe(true); + else { + const bundle = parseEvidenceBundle( + JSON.parse(await readFile(evidencePath, "utf8")), + ); + expect(bundle.records).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + subject: expect.objectContaining({ + digest: expect.objectContaining({ sha256 }), + }), + }), + ]), + ); + } + if (!success && operation === "extract-artifact") + await expect(access(outputRoot)).rejects.toMatchObject({ + code: "ENOENT", + }); + } + + const call = await cli.sharedMcpCall!(); + const opened = await call("open_binary", { path }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + try { + for (const operation of [ + "inspect_artifact", + "extract_artifact", + ] as const) { + const outputRoot = join(root, `mcp-${operation}`); + const reply = await call( + operation, + operation === "inspect_artifact" + ? { detail: "summary" } + : { output_root: outputRoot }, + ); + if (!success) { + expectRefusal(parseMcpToolError(reply).error, kind === "large"); + if (operation === "extract_artifact") + await expect(access(outputRoot)).rejects.toMatchObject({ + code: "ENOENT", + }); + } else { + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + if (operation === "extract_artifact") + expect( + (await readFile(join(outputRoot, "member.txt"))).equals(content), + ).toBe(true); + } + } + } finally { + expect((await call("close_binary", {})).isError).not.toBe(true); + } + expect((await readFile(path)).equals(bytes)).toBe(true); + }, +); diff --git a/tests/fixtures/zipZstandard.ts b/tests/fixtures/zipZstandard.ts index 4499586616..ee7c149871 100644 --- a/tests/fixtures/zipZstandard.ts +++ b/tests/fixtures/zipZstandard.ts @@ -1,5 +1,16 @@ import { crc32, zstdCompressSync } from "node:zlib"; +/** A valid independent raw-block frame; its window is unrelated to its tiny output. */ +export const zstandardWindowFrameFixture = (windowDescriptor: number) => { + const content = Buffer.from("REA independent frame control\n"); + const compressed = Buffer.from( + "28b52ffd0090f1000052454120696e646570656e64656e74206672616d6520636f6e74726f6c0a", + "hex", + ); + compressed[5] = windowDescriptor; + return { content, compressed }; +}; + /** One non-Zip64 member, independently framed from the PKWARE APPNOTE records. */ export const zstandardZipFixture = ( content: Buffer, From a93eb39698e6aa2cec86e26e7e9a4b74d3d7a6c2 Mon Sep 17 00:00:00 2001 From: Nianze Wu Date: Sat, 7 Nov 2026 05:21:56 +0800 Subject: [PATCH 085/151] fix(managed): preserve unknown member ownership across invalid rows Compare the preceding valid TypeDef coordinate independently for FieldList and MethodList, retaining readable members while leaving affected ownership unknown. Keep the scalar producer and shared owner lookup authoritative across member inspection, metadata inventory and native boundary workflows. Validated with 577 managed/workflow tests, seven malformed/working ordering vectors, and independent dnfile/pefile raw metadata from a pinned public SystemEvents assembly. Four deliberately changed metadata bytes reproduce both decreases across an invalid row. Six CLI calls and ten stdio MCP tool calls before/after preserve all 458 fields and 195 methods and raw signatures/CIL hashes, keep ordinary results identical across three operations, and retain unresolved filter candidates rather than false absence. Alternative #8868 passes the expanded 206 inspector/ownership controls; converge on this generic implementation. The original three unchecked human declarations are preserved. The explicitly authorized readiness-only exception applies after technical CI and review; no human-review attestation is added. Fixes #8861 Full technical CI 42293217445 passes, including all eight shards and required aggregates. Greptile reviews this head at 5/5; Codex cannot complete because the repository review quota is exhausted. The Author readiness-only exception is explicitly authorized; no human-review attestation is added. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) --- docs/managed-code-analysis.md | 4 + src/dotnet/ManagedMemberInspector.test.ts | 122 ++++++++++++++++++ src/dotnet/ManagedTypeMemberLists.ts | 30 +++-- .../cli/managedMemberListOrdering.test.ts | 120 +++++++++++++++++ tests/fixtures/managedMemberListStarts.ts | 30 +++++ 5 files changed, 294 insertions(+), 12 deletions(-) create mode 100644 tests/boundary/cli/managedMemberListOrdering.test.ts create mode 100644 tests/fixtures/managedMemberListStarts.ts diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index f4a2adf065..c340d38ebc 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -739,6 +739,10 @@ refer to the pointer table when present rather than necessarily contiguous definition rows. Invalid or nonmonotonic list coordinates and malformed or ambiguous pointers leave affected owners unknown with partial coverage while preserving readable member and attribute facts. +For each list column, ordering checks compare consecutive valid coordinates +even across invalid rows. A provable decrease leaves the affected ownership +unknown. Retained declaring-type filters count these candidates in +`unresolved_filter_rows`; omit that filter to retrieve their known-name rows. The retained inverse indexes are admitted against the existing representation budget before allocation. diff --git a/src/dotnet/ManagedMemberInspector.test.ts b/src/dotnet/ManagedMemberInspector.test.ts index ca7a9a9f17..5801de7ee5 100644 --- a/src/dotnet/ManagedMemberInspector.test.ts +++ b/src/dotnet/ManagedMemberInspector.test.ts @@ -12,6 +12,7 @@ import { type ManagedMetadataLayout, } from "./ManagedMetadataLayout.js"; import { readManagedPeLayout } from "./ManagedPeReader.js"; +import { managedMemberListStartsFixture } from "../../tests/fixtures/managedMemberListStarts.js"; import { memberListIndexSize } from "./ManagedMemberPointers.js"; import { edges } from "./ManagedMemberRows.js"; import { parseUserStrings } from "./ManagedUserStrings.js"; @@ -2212,6 +2213,127 @@ describe("managed TypeDef list ownership bounds", () => { }); }); +describe("managed TypeDef ordering across invalid coordinates", () => { + it.each([ + { + label: "both lists decrease", + fields: [2, 0, 1], + methods: [2, 0, 1], + fieldOwner: null, + methodOwner: null, + issues: 4, + decreases: [ + ["FieldList", 3], + ["MethodList", 3], + ], + }, + { + label: "out-of-range nonzero coordinates are skipped", + fields: [2, 65535, 1], + methods: [2, 65535, 1], + fieldOwner: null, + methodOwner: null, + issues: 4, + decreases: [ + ["FieldList", 3], + ["MethodList", 3], + ], + }, + { + label: "known coordinates stay ordered", + fields: [1, 0, 1], + methods: [1, 0, 1], + fieldOwner: "0x02000003", + methodOwner: "0x02000003", + issues: 2, + decreases: [], + }, + { + label: "only fields decrease", + fields: [2, 0, 1], + methods: [1, 1, 1], + fieldOwner: null, + methodOwner: "0x02000003", + issues: 2, + decreases: [["FieldList", 3]], + }, + { + label: "only methods decrease", + fields: [1, 1, 1], + methods: [2, 0, 1], + fieldOwner: "0x02000003", + methodOwner: null, + issues: 2, + decreases: [["MethodList", 3]], + }, + { + label: "multiple invalid rows", + fields: [2, 0, 0, 1], + methods: [2, 0, 0, 1], + fieldOwner: null, + methodOwner: null, + issues: 6, + decreases: [ + ["FieldList", 4], + ["MethodList", 4], + ], + }, + { + label: "one invalid column does not hide another column or a valid tail", + fields: [1, 0, 1], + methods: [2, 1, 1], + fieldOwner: "0x02000003", + methodOwner: "0x02000003", + issues: 2, + decreases: [["MethodList", 2]], + }, + ] as const)( + "checks independent ordering across invalid starts: $label", + (test) => { + const bytes = managedMemberListStartsFixture(test.fields, test.methods); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + expect(result.fields).toMatchObject([ + { + token: "0x04000001", + declaring_type: test.fieldOwner === null ? null : "Fixture.Program", + declaring_type_token: test.fieldOwner, + signature: { parse_status: "decoded" }, + }, + ]); + expect(result.methods).toMatchObject([ + { + token: "0x06000001", + declaring_type: test.methodOwner === null ? null : "Fixture.Program", + declaring_type_token: test.methodOwner, + signature: { parse_status: "decoded" }, + }, + ]); + expect(result.types.at(-1)).toMatchObject({ + field_list: { count: test.fieldOwner === null ? 0 : 1 }, + method_list: { count: test.methodOwner === null ? 0 : 1 }, + }); + expect(result.coverage.state).toBe("partial"); + expect(result.coverage.issues).toHaveLength(test.issues); + expect( + result.coverage.issues.filter(({ detail }) => + detail.includes("decreases"), + ), + ).toMatchObject( + test.decreases.map(([list, row]) => ({ + code: "invalid-row", + scope: `metadata.TypeDef:0x0200000${String(row)}`, + detail: expect.stringContaining( + `${list} start 1 decreases from preceding start 2`, + ), + })), + ); + }, + ); +}); + describe("managed TypeDef list ordering", () => { it("leaves adjacent ownership unknown when valid list starts decrease", () => { const bytes = buildManagedPeFixture({ diff --git a/src/dotnet/ManagedTypeMemberLists.ts b/src/dotnet/ManagedTypeMemberLists.ts index e66ea37a2f..977c97c3cd 100644 --- a/src/dotnet/ManagedTypeMemberLists.ts +++ b/src/dotnet/ManagedTypeMemberLists.ts @@ -94,29 +94,35 @@ export const readTypeMemberListFacts = ( } const nonmonotonicFields = new Set(); const nonmonotonicMethods = new Set(); - for (let index = 0; index + 1 < rows.length; index += 1) { - const current = rows[index]; - const next = rows[index + 1]; - if (current === undefined || next === undefined) continue; - for (const [name, before, after, affected] of [ - ["FieldList", current.field, next.field, nonmonotonicFields], - ["MethodList", current.method, next.method, nonmonotonicMethods], - ] as const) + const previousKnown = { field: -1, method: -1 }; + for (const [index, entry] of rows.entries()) { + for (const [name, member, affected] of [ + ["FieldList", "field", nonmonotonicFields], + ["MethodList", "method", nonmonotonicMethods], + ] as const) { + const after = entry[member]; + // An invalid coordinate cannot establish a range endpoint, but it + // cannot hide a decrease between the surrounding valid coordinates. + if (after.value === null) continue; + const preceding = previousKnown[member]; + const before = rows[preceding]?.[member]; + previousKnown[member] = index; if ( + before !== undefined && before.value !== null && - after.value !== null && after.value < before.value ) { managedDecodeBudget(layout).reserve(512, after.offset); + affected.add(preceding); affected.add(index); - affected.add(index + 1); issues.push({ code: "invalid-row", - scope: `metadata.TypeDef:${metadataToken(2, index + 2)}`, + scope: `metadata.TypeDef:${metadataToken(2, index + 1)}`, offset: after.offset, - detail: `TypeDef ${name} start ${String(after.raw)} decreases from preceding start ${String(before.raw)}; adjacent member ranges are unknown`, + detail: `TypeDef ${name} start ${String(after.raw)} decreases from preceding start ${String(before.raw)}; affected member ranges are unknown`, }); } + } } const entries: TypeMemberListEntry[] = rows.map((entry, index) => { const next = rows[index + 1]; diff --git a/tests/boundary/cli/managedMemberListOrdering.test.ts b/tests/boundary/cli/managedMemberListOrdering.test.ts new file mode 100644 index 0000000000..6c731d003e --- /dev/null +++ b/tests/boundary/cli/managedMemberListOrdering.test.ts @@ -0,0 +1,120 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisViewResult.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { managedMemberInspectionSchema } from "../../../src/domain/managed/managedArtifact.js"; +import { managedMemberListStartsFixture } from "../../fixtures/managedMemberListStarts.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for(["decreasing", "ordered"] as const)( + "preserves managed ownership and unknown filter rows over CLI/MCP: %s", + async (kind, { cli }) => { + const root = await createTestTempDirectory("rea-managed-list-order-"); + const path = join(root, "fixture.dll"); + const starts = kind === "decreasing" ? [2, 0, 1] : [1, 0, 1]; + const bytes = managedMemberListStartsFixture(starts, starts); + await writeFile(path, bytes); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const destination = join(root, "members.json"); + const cliReply = await cli.run({ + arguments: [ + "inspect-managed-members", + path, + "--detail", + "summary", + "--evidence-bundle", + destination, + "--json", + ], + }); + expect(cliReply.exitCode, cliReply.stderr).toBe(0); + const bundle = parseEvidenceBundle( + JSON.parse(await readFile(destination, "utf8")), + ); + const parent = bundle.records.find( + ({ operation }) => operation === "inspect_managed_members", + ); + if (parent === undefined) + throw new Error("Missing complete member Evidence"); + expect(parent.subject?.digest.sha256).toBe(sha256); + const report = managedMemberInspectionSchema.parse( + parent.normalized_result, + ); + expect(report.coverage.state).toBe("partial"); + expect( + report.coverage.issues.filter(({ detail }) => + detail.includes("decreases"), + ), + ).toHaveLength(kind === "decreasing" ? 2 : 0); + const owner = kind === "decreasing" ? null : "0x02000003"; + const field = report.fields.find(({ token }) => token === "0x04000001"); + const method = report.methods.find(({ token }) => token === "0x06000001"); + if (field?.name == null || method?.name == null) + throw new Error("Readable fixture members were lost"); + for (const row of [field, method]) { + expect(row.declaring_type_token).toBe(owner); + expect(row.signature.parse_status).toBe("decoded"); + } + + const call = await cli.sharedMcpCall!(); + const reply = await call("inspect_managed_members", { + path, + detail: "summary", + }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + const summary = analysisViewResultSchema.parse( + parseEvidence(reply.structuredContent).normalized_result, + ); + if (summary.kind !== "summary") throw new Error("Expected member summary"); + for (const [collection, member] of [ + ["fields", field], + ["methods", method], + ] as const) { + for (const typed of [false, true]) { + const result = await call("inspect_analysis_view", { + source: { + kind: "retained-evidence", + evidence_id: summary.parent_evidence_id, + }, + view: { + kind: "managed-members", + collection, + names: [member.name], + fields: ["name", "declaring_type"], + offset: 0, + limit: 8, + ...(typed ? { declaring_types: ["Fixture.Program"] } : {}), + }, + }); + expect(result.isError, JSON.stringify(result)).not.toBe(true); + const view = analysisViewResultSchema.parse( + parseEvidence(result.structuredContent).normalized_result, + ); + if (view.kind !== "managed-members") + throw new Error("Expected retained members"); + expect(view.page.collection).toBe(collection); + expect(view.source_coverage.state).toBe("partial"); + if (typed && kind === "decreasing") { + expect(view.page.rows).toEqual([]); + expect(view.page.unresolved_filter_rows).toBe(1); + } else { + expect(view.page.rows).toMatchObject([ + { + token: member.token, + name: member.name, + declaring_type_token: owner, + }, + ]); + expect(view.page.rows[0]).not.toHaveProperty("body"); + expect(view.page.rows[0]).not.toHaveProperty("signature"); + } + } + } + expect((await readFile(path)).equals(bytes)).toBe(true); + }, +); diff --git a/tests/fixtures/managedMemberListStarts.ts b/tests/fixtures/managedMemberListStarts.ts new file mode 100644 index 0000000000..465e407e18 --- /dev/null +++ b/tests/fixtures/managedMemberListStarts.ts @@ -0,0 +1,30 @@ +import { readManagedMetadataLayout } from "../../src/dotnet/ManagedMetadataLayout.js"; +import { buildManagedPeFixture } from "../../src/dotnet/ManagedPe.fixture.js"; +import { readManagedPeLayout } from "../../src/dotnet/ManagedPeReader.js"; +import { readTypeDefinitionRowFacts } from "../../src/dotnet/ManagedTypeDefinitionRows.js"; + +/** Select TypeDef list coordinates while retaining the fixture's readable member rows. */ +export const managedMemberListStartsFixture = ( + fields: readonly number[], + methods: readonly number[], +): Buffer => { + if (fields.length === 0 || fields.length !== methods.length) + throw new Error("Each fixture TypeDef needs both list coordinates"); + const bytes = buildManagedPeFixture({ + additionalEmptyTypes: fields.length - 1, + }); + const pe = readManagedPeLayout(bytes); + if (pe.cli === null) throw new Error("Fixture omitted CLI metadata"); + const { rva, size } = pe.cli.metadata; + const layout = readManagedMetadataLayout( + bytes, + pe.rvaToOffset(rva, size, "cli.metadata"), + size, + ); + for (const [index, field] of fields.entries()) { + const row = readTypeDefinitionRowFacts(bytes, layout, index + 1); + bytes.writeUInt16LE(field, row.fieldListOffset); + bytes.writeUInt16LE(methods[index]!, row.methodListOffset); + } + return bytes; +}; From e9ea9d6f50b155a44af4b3ff4afbb5597c9d55f9 Mon Sep 17 00:00:00 2001 From: 2SinceJuly Date: Sat, 7 Nov 2026 05:56:35 +0800 Subject: [PATCH 086/151] fix(cli): validate MCP registration before side effects (#9057) Fixes #7375. Fixes #7608. Share argument consumption and agent facts between admission and scope checks; reject invalid registrations before settings, backups or runner startup. Preserve missing-value priority, explicit redaction, discovery and valid registration controls. Validated 33 focused tests (one platform skip), current-main RED and independent compiled-CLI before/after workflows; check:fast passes. Full technical CI 42297438912 passes, including all eight shards and required aggregates. Greptile reviews this head at 5/5; no completed Codex review is available and the bot reports exhausted quota. The original author readiness declarations remain verbatim; no new human-review attestation is added. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) --- docs/cli.md | 4 + src/cli.ts | 49 +++---- src/cliBuiltinArguments.ts | 72 +++++++--- .../boundary/cli/cliBuiltinArguments.test.ts | 10 +- .../cli/defaultAmpRegistration.test.ts | 34 +++++ .../cli/mcpAddOptionValidation.test.ts | 125 +++++++++++++++++- 6 files changed, 243 insertions(+), 51 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 6e5c556d1c..bb5fe36030 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1200,6 +1200,10 @@ such as `UNSUPPORTED_OUTPUT_COMBINATION` for `--json` with `--token-limit`. Use Commands reject surplus positional arguments before execution, including commands that accept no positionals. Use `rea --help` for the declared argument shape; a final variadic argument continues to accept multiple values. +`rea mcp add` accepts options only and rejects unknown options before reading or +backing up client settings or starting registration. Its `--agent` and +`--command`/`-c` options accept spaced and equals forms. Values must be nonempty; a following flag does not satisfy a spaced option's +missing value. `rea mcp doctor --json` checks production MCP startup and reports child logs in `diagnostics`. It drains stderr while retaining at most its first 64 KiB of raw diff --git a/src/cli.ts b/src/cli.ts index e9050e7682..9f2520948d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -25,11 +25,13 @@ import { registerBinaryDiagnosticsCommands } from "./cli/binaryDiagnosticsComman import { registerFridaCommands } from "./cli/fridaCommands.js"; import type { FridaInstrumentationPort } from "./application/frida/FridaInstrumentationPort.js"; import { Cli, z } from "incur"; +import { redactExplicitText } from "./domain/explicitSensitiveValues.js"; import { adaptCliOutputArgumentsForIncur, classifyMcpAddDiscovery, expandMcpAddEqualsArguments, - mcpAddAgents, + declaredSensitiveValues, + readMcpAddArguments, normalizeCliArguments, splitIncurBuiltinArguments, } from "./cliBuiltinArguments.js"; @@ -311,37 +313,21 @@ export const createCli = ( )(1); return Promise.resolve(); } - // Incur's `mcp add` silently skips a valued option with no value and - // swallows the next flag as the value: the registration succeeds without - // the requested agent or command. Reject a trailing --agent/--command/-c, - // a value position occupied by another flag, and an empty `--flag=` form - // up front. - const valued = ["--agent", "--command", "-c"]; - let missingValue: string | undefined; - for (let index = 0; index < command.length; index += 1) { - const next = command[index + 1]; - if ( - valued.includes(command[index]!) && - (next === undefined || next.startsWith("-")) - ) { - missingValue = command[index]; - break; - } - } - const emptyValue = command.find( - (token) => - (token.startsWith("--agent=") || - token.startsWith("--command=") || - token.startsWith("-c=")) && - token.length === token.indexOf("=") + 1, + // Validate before reading settings, creating a backup or invoking Incur's + // writer. The same owner supplies the agent selection used below. + const parsedRegistration = readMcpAddArguments( + command.slice(builtin + 2), ); - if (missingValue !== undefined || emptyValue !== undefined) { - const flag = missingValue ?? emptyValue!; - const message = `Missing value for flag: ${flag.replace(/=.*$/, "")}`; + if (!parsedRegistration.validation.ok) { + const failure = parsedRegistration.validation.error; + const message = redactExplicitText( + failure.kind === "missing_value" + ? `Missing value for flag: ${failure.flag}` + : `Unexpected argument: ${failure.token}`, + declaredSensitiveValues(command), + ); const field = - flag.includes("command") || flag === "-c" || flag.startsWith("-c=") - ? "command" - : "agent"; + failure.kind === "missing_value" ? failure.field : "arguments"; const output = parseCliOutputArguments(normalized); if (!output.validation.ok) write(renderCliOutputArgumentError(output.validation)); @@ -390,8 +376,7 @@ export const createCli = ( // 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 = mcpAddAgents(rest); + const { agents } = parsedRegistration; const ampRequested = ![ "--help", diff --git a/src/cliBuiltinArguments.ts b/src/cliBuiltinArguments.ts index 54bcd0d37c..6b21088995 100644 --- a/src/cliBuiltinArguments.ts +++ b/src/cliBuiltinArguments.ts @@ -1,3 +1,5 @@ +import { err, ok, type Result } from "./domain/result.js"; + /** Global output flags that take a value. */ export const VALUED_OUTPUT_FLAGS: ReadonlySet = new Set([ "--format", @@ -214,7 +216,11 @@ export const expandMcpAddEqualsArguments = ( arguments_: readonly string[], ): string[] => arguments_.flatMap((token) => { - if (token.startsWith("--agent=") || token.startsWith("--command=")) { + if ( + token.startsWith("--agent=") || + token.startsWith("--command=") || + token.startsWith("-c=") + ) { const separator = token.indexOf("="); const value = token.slice(separator + 1); return value === "" ? [token] : [token.slice(0, separator), value]; @@ -222,26 +228,55 @@ export const expandMcpAddEqualsArguments = ( return [token]; }); +type McpAddArgumentError = + | { + readonly kind: "missing_value"; + readonly flag: string; + readonly field: "agent" | "command"; + } + | { readonly kind: "unexpected_argument"; readonly token: string }; + /** - * Agent selection for an `mcp add` command, after equals-form expansion. - * Single owner: the project-scope guard and the `mcp add` dispatch in - * `cli.ts` both consume this. Incur consumes command values before agent - * flags, so a `--command` value is never read as an agent. + * Own token consumption for the pinned registration builtin. Agent selection + * remains observable even for invalid input so the launcher's scope refusal + * and dispatch cannot interpret a command value as an agent selector. */ -export const mcpAddAgents = ( +export const readMcpAddArguments = ( command: IncurBuiltinArgumentSplit["command"], -): string[] => { +): { + readonly agents: readonly string[]; + readonly validation: Result; +} => { const agents: string[] = []; + let missing: McpAddArgumentError | undefined; + let unexpected: McpAddArgumentError | undefined; for (let index = 0; index < command.length; index += 1) { - if ( - (command[index] === "--command" || command[index] === "-c") && - command[index + 1] - ) - index += 1; - else if (command[index] === "--agent" && command[index + 1]) - agents.push(command[++index]!); + const token = command[index]!; + const flag = token.split("=", 1)[0]!; + if (flag === "--agent" || flag === "--command" || flag === "-c") { + const equals = token.indexOf("="); + const value = equals < 0 ? command[++index] : token.slice(equals + 1); + // Spaced flags cannot satisfy another option's missing value. Equals + // values retain Incur's existing expansion/discovery behavior. + if ( + value === undefined || + value === "" || + (equals < 0 && value.startsWith("-")) + ) + missing ??= { + kind: "missing_value", + flag, + field: flag === "--agent" ? "agent" : "command", + }; + if (flag === "--agent" && value) agents.push(value); + } else if (token !== "--no-global") + unexpected ??= { kind: "unexpected_argument", token }; } - return agents; + const failure = missing ?? unexpected; + return { + agents, + validation: failure === undefined ? ok(undefined) : err(failure), + }; }; /** @@ -278,7 +313,10 @@ export const classifyMcpAddDiscovery = ( flags.has("--help") || flags.has("-h") || flags.has("--llms") || - flags.has("--llms-full") + flags.has("--llms-full") || + flags.has("--mcp") || + flags.has("--update") || + flags.has("--incur-update-check") ) return "bypass"; if (flags.has("--version")) return "version"; @@ -296,7 +334,7 @@ export const isProjectScopedAmpRegistration = ( if (classifyMcpAddDiscovery(flags) !== undefined) return false; const rest = command.slice(2); if (!rest.includes("--no-global")) return false; - const agents = mcpAddAgents(rest); + const { agents } = readMcpAddArguments(rest); return agents.length === 0 || agents.includes("amp"); }; diff --git a/tests/boundary/cli/cliBuiltinArguments.test.ts b/tests/boundary/cli/cliBuiltinArguments.test.ts index 598b86e6c5..1562be6d57 100644 --- a/tests/boundary/cli/cliBuiltinArguments.test.ts +++ b/tests/boundary/cli/cliBuiltinArguments.test.ts @@ -13,7 +13,15 @@ import { describe("mcp add discovery classification", () => { it("bypasses help and listing flags", () => { - for (const flag of ["--help", "-h", "--llms", "--llms-full"]) { + for (const flag of [ + "--help", + "-h", + "--llms", + "--llms-full", + "--mcp", + "--update", + "--incur-update-check", + ]) { expect(classifyMcpAddDiscovery(new Set([flag]))).toBe("bypass"); } }); diff --git a/tests/boundary/cli/defaultAmpRegistration.test.ts b/tests/boundary/cli/defaultAmpRegistration.test.ts index e52271e21a..65e2cdc86b 100644 --- a/tests/boundary/cli/defaultAmpRegistration.test.ts +++ b/tests/boundary/cli/defaultAmpRegistration.test.ts @@ -375,3 +375,37 @@ cliTest( } }, ); + +cliTest( + "invalid registration never starts a package runner or changes settings", + async ({ cli }) => { + const f = await fixture(original); + const before = await stat(f.settings); + for (const options of [ + ["--bogus"], + ["--agents", "claude-code"], + ["--unknown=declared-literal", "--sensitive-value", "declared-literal"], + ["--unknown=API_SECRET_ordinary-evidence"], + ]) { + const result = await cli.run({ + arguments: ["mcp", "add", ...options, "--json"], + cwd: f.home, + environment: f.environment, + timeoutMs: 20_000, + }); + expect(result.exitCode, result.stderr).toBe(1); + expect(result.json).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: [expect.objectContaining({ path: "arguments" })], + }); + if (options.includes("--sensitive-value")) { + expect(result.stdout + result.stderr).not.toContain("declared-literal"); + expect(result.stdout).toContain("[REDACTED]"); + } else expect(result.stdout).toContain(options[0]); + expect(await readFile(f.settings, "utf8")).toBe(original); + expect((await stat(f.settings)).mtimeMs).toBe(before.mtimeMs); + expect(await readdir(f.directory)).toEqual(["settings.json"]); + expect(await readFile(f.log, "utf8")).toBe(""); + } + }, +); diff --git a/tests/boundary/cli/mcpAddOptionValidation.test.ts b/tests/boundary/cli/mcpAddOptionValidation.test.ts index f9ef555588..336be78001 100644 --- a/tests/boundary/cli/mcpAddOptionValidation.test.ts +++ b/tests/boundary/cli/mcpAddOptionValidation.test.ts @@ -1,4 +1,11 @@ -import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { + access, + mkdir, + readFile, + readdir, + stat, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; @@ -89,3 +96,119 @@ cliTest("mcp add rejects valued options without a value", async ({ cli }) => { command: expect.any(String), }); }); + +cliTest( + "mcp add rejects an argument that is neither an option nor a value", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-mcp-add-argument-"); + const home = join(root, "home"); + const settingsPath = join(home, ".config", "amp", "settings.json"); + const run = (arguments_: readonly string[]) => + cli.run({ + arguments: [...arguments_, "--json"], + cwd: root, + environment: { + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: join(home, ".config"), + }, + timeoutMs: 20_000, + }); + + // Incur skips a stray word, so the registration used to succeed for a request + // REA cannot honor and wrote the settings file anyway. + for (const arguments_ of [ + ["mcp", "add", "extra", "--agent", "amp"], + ["mcp", "add", "--agent", "amp", "extra"], + ["mcp", "add", "--agent=amp", "extra"], + ["mcp", "add", "--bogus", "--agent", "amp"], + ] as const) { + const result = await run(arguments_); + expect(result.exitCode, result.stderr).toBe(1); + expect(result.json).toMatchObject({ + code: "VALIDATION_ERROR", + message: expect.stringMatching(/Unexpected argument: (extra|--bogus)/), + fieldErrors: expect.arrayContaining([ + expect.objectContaining({ + code: "invalid_value", + missing: false, + path: expect.stringMatching(/arguments|command/), + }), + ]), + }); + await expect(access(settingsPath)).rejects.toMatchObject({ + code: "ENOENT", + }); + } + + const original = + '{"amp.mcpServers":{"rea":{"command":"old","env":{"KEEP":"1"}}},"theme":"dark"}\n'; + await mkdir(join(home, ".config", "amp"), { recursive: true }); + await writeFile(settingsPath, original); + const before = await stat(settingsPath); + const rejected = await run(["mcp", "add", "extra", "--agent=amp"]); + expect(rejected.exitCode, rejected.stderr).toBe(1); + expect(await readFile(settingsPath, "utf8")).toBe(original); + expect((await stat(settingsPath)).mtimeMs).toBe(before.mtimeMs); + expect(await readdir(join(home, ".config", "amp"))).toEqual([ + "settings.json", + ]); + + // Verify the actual stored command, rather than an already-existing file. + for (const options of [ + ["--agent", "amp", "--command", "node --version"], + ["--agent=amp", "--command=node --version"], + ["--agent=amp", "-c=node --version"], + ]) { + const registered = await run(["mcp", "add", ...options]); + expect(registered.exitCode, registered.stderr).toBe(0); + expect(JSON.parse(await readFile(settingsPath, "utf8"))).toEqual({ + "amp.mcpServers": { + rea: { command: "node", args: ["--version"], env: { KEEP: "1" } }, + }, + theme: "dark", + }); + } + expect(await readFile(`${settingsPath}.rea.backup`, "utf8")).toBe(original); + }, +); + +cliTest( + "mcp add names the option the reported error is about", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-mcp-add-field-"); + const home = join(root, "home"); + const run = (arguments_: readonly string[]) => + cli.run({ + arguments: [...arguments_, "--json"], + cwd: root, + environment: { + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: join(home, ".config"), + }, + timeoutMs: 20_000, + }); + + // A stray word alongside a missing value: the message describes the missing + // flag, so `path` must name that option rather than the stray argument. + for (const [arguments_, field] of [ + [["mcp", "add", "extra", "--command"], "command"], + [["mcp", "add", "extra", "-c"], "command"], + [["mcp", "add", "extra", "--agent"], "agent"], + ] as const) { + const result = await run(arguments_); + expect(result.exitCode, result.stderr).toBe(1); + expect(result.json).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: [ + expect.objectContaining({ code: "invalid_value", path: field }), + ], + }); + expect( + (result.json as { message?: string }).message, + "the message and the field must describe the same error", + ).toContain("Missing value for flag"); + } + }, +); From 48f5f30fd5536cb783c74c1a2df7c2003689dbd0 Mon Sep 17 00:00:00 2001 From: Soukyu Date: Sat, 7 Nov 2026 05:30:39 +0700 Subject: [PATCH 087/151] fix(cli): reject malformed dry-run literals before changes (#7870) Fixes #7540. Retain every raw dryRun literal at the shared inert token binder before Incur boolean coercion; preserve parser-owned values, original execution, defaults and variadics. Return an actionable typed PARSE_ERROR before setup or environment publication. Validated 40 focused tests, independent nine-call compiled-CLI before/after configuration controls, check:fast and docs:check. Full technical CI 42302162266 passes, including all eight shards and required aggregates. Greptile reviews this head at 5/5; no completed Codex review of this head is available. The Author readiness-only exception is explicitly authorized; no human-review attestation is added. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) --- docs/cli.md | 6 +- src/cli.ts | 4 +- src/cli/strictPositionals.ts | 83 +++++++---- tests/boundary/cli/cliSetup.test.ts | 132 +++++++++++++++++- .../boundary/cli/environmentBuildCli.test.ts | 42 ++++++ tests/boundary/cli/strictPositionals.test.ts | 101 +++++++++++++- 6 files changed, 337 insertions(+), 31 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index bb5fe36030..162bae2f49 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1392,7 +1392,11 @@ status with the analysis failure status, so it exits `1`. Setup has its own cont structured result and process status as separate signals when scripting cancellation. -`setup --dry-run` returns `planned` and exits `0`; a cancelled setup also exits +`setup --dry-run` returns `planned` and exits `0`. For `setup` and +`environment-build`, explicit `--dry-run=true` and `--dry-run=false` are +accepted; empty or other values fail with `PARSE_ERROR` before any changes. +Every supplied value is checked even when a later occurrence overrides it. +A cancelled setup also exits `0`. Setup returns `1` for `needs_confirmation` or `needs_human`. `doctor` returns `1` when required checks in its selected readiness scope fail; unavailable optional providers remain informational for unrelated tasks. diff --git a/src/cli.ts b/src/cli.ts index 9f2520948d..20ad639049 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -83,7 +83,7 @@ import { import { registerReverseEngineeringCommands } from "./cli/reverseEngineeringCommands.js"; import { registerCutterCommands } from "./cli/cutterCommands.js"; import { excludeDeclaredSensitiveArguments } from "./cli/explicitSensitiveArguments.js"; -import { rejectSurplusCliArguments } from "./cli/strictPositionals.js"; +import { validateCliArguments } from "./cli/strictPositionals.js"; import { encodeCliArgumentErrors, presentCliArgumentErrors, @@ -253,7 +253,7 @@ export const createCli = ( }); presentOmittableDefaults(cli); if (resultOutput !== undefined) streamCliCommandResults(cli, resultOutput); - const serveStrict = rejectSurplusCliArguments(cli); + const serveStrict = validateCliArguments(cli); presentCliArgumentErrors(cli); const serveDeclared = excludeDeclaredSensitiveArguments(cli); encodeCliArgumentErrors(cli); diff --git a/src/cli/strictPositionals.ts b/src/cli/strictPositionals.ts index e897276925..9467ddac9f 100644 --- a/src/cli/strictPositionals.ts +++ b/src/cli/strictPositionals.ts @@ -20,15 +20,18 @@ const tokenType = (field: z.ZodType): z.ZodType => { }; /** Preserve flag consumption without evaluating command defaults or transforms. */ -const optionTokenShape = (schema: z.ZodObject): z.ZodObject => +const optionTokenShape = ( + schema: z.ZodObject, + strictDryRun: boolean, +): z.ZodObject => z.object( Object.fromEntries( Object.entries(schema.shape).map(([name, field]) => { const inner = tokenType(field); const token = - inner instanceof z.ZodBoolean + inner instanceof z.ZodBoolean && !(strictDryRun && name === "dryRun") ? z.boolean().optional() - : inner instanceof z.ZodArray + : inner instanceof z.ZodArray || (strictDryRun && name === "dryRun") ? z.array(z.unknown()).optional() : z.unknown().optional(); return [ @@ -40,12 +43,12 @@ const optionTokenShape = (schema: z.ZodObject): z.ZodObject => ); /** - * Incur 0.6.1 drops unbound positionals. Its public parser owns token binding, - * but provides neither a strict-count option nor raw argv in middleware. - * Capture its unbound tail with inert schemas, before the real command parse. - * The original schemas still own validation, transforms, defaults and catalog. + * Incur 0.6.1 drops unbound positionals and coerces arbitrary boolean text to + * false. Its public parser owns token binding; inert schemas retain the + * unbound tail and every dryRun literal before this lossy conversion. Original + * schemas still own validation, transforms, defaults and actual execution. */ -export const rejectSurplusCliArguments = ( +export const validateCliArguments = ( cli: CliInstance, ): (( arguments_: readonly string[], @@ -57,13 +60,19 @@ export const rejectSurplusCliArguments = ( const fields = command.args instanceof z.ZodObject ? command.args.shape : {}; const entries = Object.entries(fields); - // A declared final array already consumes every remaining positional. - const final = entries.at(-1)?.[1]; - if (final !== undefined && tokenType(final) instanceof z.ZodArray) return; - let tail = "__rea_unbound_positionals"; - while (Object.hasOwn(fields, tail)) tail += "_"; const optionsSchema = command.options instanceof z.ZodObject ? command.options : undefined; + const dryRunField = optionsSchema?.shape["dryRun"]; + const strictDryRun = + dryRunField !== undefined && + tokenType(dryRunField) instanceof z.ZodBoolean; + const final = entries.at(-1)?.[1]; + const variadic = + final !== undefined && tokenType(final) instanceof z.ZodArray; + // A final array consumes the tail, but must still admit dry-run literals. + if (variadic && !strictDryRun) return; + let tail = "__rea_unbound_positionals"; + while (Object.hasOwn(fields, tail)) tail += "_"; command.middleware = [ async (context, next) => { const tokens = invocation.getStore(); @@ -73,24 +82,50 @@ export const rejectSurplusCliArguments = ( // registered command for each one-shot invocation. const args = z.object({ ...Object.fromEntries( - entries.map(([name]) => [name, z.unknown().optional()]), + entries.map(([name, field]) => [ + name, + tokenType(field) instanceof z.ZodArray + ? z.array(z.unknown()).optional() + : z.unknown().optional(), + ]), ), - [tail]: z.array(z.string()).optional(), + ...(variadic ? {} : { [tail]: z.array(z.string()).optional() }), }); const options = optionsSchema === undefined ? undefined - : optionTokenShape(optionsSchema); + : optionTokenShape(optionsSchema, strictDryRun); // Incur supplies the invoked path, including aliases, after resolving // groups. No second router decides which command receives the tokens. - const parsed = Parser.parse( - tokens.slice(context.command.split(" ").length), - { - args, - options, - alias: command.alias as Record | undefined, - }, - ); + const rest = tokens.slice(context.command.split(" ").length); + // Only this preflight copy expands bare booleans. Array binding keeps + // all explicit literals without coercion, including overridden ones. + // Flag-shaped string data still belongs to its consuming option. + const admitted = strictDryRun + ? rest.map((token) => + token === "--dry-run" || token === "--dryRun" + ? "--dry-run=true" + : token === "--no-dry-run" || token === "--no-dryRun" + ? "--dry-run=false" + : token, + ) + : rest; + const parsed = Parser.parse(admitted, { + args, + options, + alias: command.alias as Record | undefined, + }); + const dryRunValues: unknown = + "dryRun" in parsed.options ? parsed.options.dryRun : undefined; + if ( + strictDryRun && + Array.isArray(dryRunValues) && + dryRunValues.some((value) => value !== "true" && value !== "false") + ) + throw new Errors.IncurError({ + code: "PARSE_ERROR", + message: "Invalid --dry-run value; use true or false.", + }); const surplus: unknown = parsed.args[tail]; if (Array.isArray(surplus) && surplus.length > 0) throw new Errors.ParseError({ diff --git a/tests/boundary/cli/cliSetup.test.ts b/tests/boundary/cli/cliSetup.test.ts index cd4ee046d1..c1592e063b 100644 --- a/tests/boundary/cli/cliSetup.test.ts +++ b/tests/boundary/cli/cliSetup.test.ts @@ -11,7 +11,6 @@ import { } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; - import { spawn } from "@lydell/node-pty"; import { describe, expect, it } from "vitest"; import { z } from "zod"; @@ -19,10 +18,33 @@ import { parse as parseToml } from "smol-toml"; import type { SetupResult } from "../../../src/application/SetupTypes.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { cliTest } from "../../support/cli/cliFixture.js"; +import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; const execute = promisify(execFile); const decisionMarker = "REA_SETUP_DECISION:"; +const isolatedSetupEnvironment = (home: string) => ({ + HOME: home, + USERPROFILE: home, + CODEX_HOME: join(home, ".codex"), + XDG_CONFIG_HOME: join(home, ".config"), + XDG_DATA_HOME: join(home, ".local", "share"), + XDG_CACHE_HOME: join(home, ".cache"), +}); +const runSetup = (cli: TestCli, home: string, dryRun: string) => + cli.run({ + arguments: [ + "setup", + "--yes", + "--client", + "codex", + "--skill=false", + dryRun, + "--format", + "json", + ], + cwd: home, + environment: isolatedSetupEnvironment(home), + }); const decisionSchema = z.object({ approved: z.boolean(), selectedActionIds: z.array(z.string()), @@ -86,6 +108,112 @@ const clientStates = [ const agentActions = actions.filter(({ kind }) => kind !== "install_hopper"); +cliTest.for([ + "--dry-run=abc", + "--dry-run=", + "--dryRun=abc", + "--dry-run=ab\ncd", +] as const)( + "rejects malformed setup dry-run value %s before approved changes are written", + async (dryRun, { cli }) => { + const home = await createTestTempDirectory("rea-cli-invalid-dry-run-"); + const config = join(home, ".codex", "config.toml"); + const result = await runSetup(cli, home, dryRun); + + await expect(access(config)).rejects.toMatchObject({ code: "ENOENT" }); + expect(result.exitCode).not.toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("--dry-run"); + }, +); + +cliTest.for(["--dry-run=true", "--dry-run=false"] as const)( + "accepts explicit setup dry-run value %s", + async (dryRun, { cli }) => { + const home = await createTestTempDirectory("rea-cli-explicit-dry-run-"); + const config = join(home, ".codex", "config.toml"); + const result = await runSetup(cli, home, dryRun); + + expect(result.exitCode, result.stderr).toBe(0); + if (dryRun === "--dry-run=true") { + expect(result.json).toMatchObject({ status: "planned" }); + await expect(access(config)).rejects.toMatchObject({ code: "ENOENT" }); + } else { + expect(result.json).toMatchObject({ status: "ready" }); + await expect(access(config)).resolves.toBeUndefined(); + } + }, +); + +cliTest( + "reports invalid dry-run through the public CLI without a stack trace", + async ({ cli }) => { + const home = await createTestTempDirectory( + "rea-cli-public-invalid-dry-run-", + ); + const config = join(home, ".codex", "config.toml"); + const result = await runSetup(cli, home, "--dry-run=abc"); + + expect(result.exitCode).not.toBe(0); + expect(result.json).toMatchObject({ code: "PARSE_ERROR" }); + expect(result.stdout).toContain("--dry-run"); + expect(`${result.stdout}\n${result.stderr}`).not.toContain( + "Incur.ParseError", + ); + await expect(access(config)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +cliTest( + "shows setup help even when a malformed dry-run value is present", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-cli-help-invalid-dry-run-"); + const config = join(home, ".codex", "config.toml"); + const result = await cli.run({ + arguments: ["setup", "--dry-run=abc", "--help"], + cwd: home, + environment: isolatedSetupEnvironment(home), + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Usage: rea setup"); + expect(result.stdout).not.toContain("Invalid --dry-run value"); + await expect(access(config)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +cliTest( + "rejects an overridden malformed dry-run before changing existing Codex configuration", + async ({ cli }) => { + const selectedHome = await createTestTempDirectory( + "rea-cli-existing-dry-run-", + ); + const config = join(selectedHome, ".codex", "config.toml"); + const original = 'model = "retained-evidence"\n'; + await mkdir(dirname(config), { recursive: true }); + await writeFile(config, original); + const before = await lstat(config); + const result = await cli.run({ + arguments: [ + "setup", + "--yes", + "--client", + "codex", + "--skill=false", + "--dry-run=abc", + "--dryRun=false", + "--json", + ], + cwd: selectedHome, + environment: isolatedSetupEnvironment(selectedHome), + }); + expect(result.exitCode, result.stderr).toBe(1); + expect(result.json).toMatchObject({ code: "PARSE_ERROR" }); + expect(await readFile(config, "utf8")).toBe(original); + expect((await lstat(config)).mtimeMs).toBe(before.mtimeMs); + expect(await readdir(dirname(config))).toEqual(["config.toml"]); + }, +); + const runSetupDryRunInTty = async ( home: string, arguments_: readonly string[], diff --git a/tests/boundary/cli/environmentBuildCli.test.ts b/tests/boundary/cli/environmentBuildCli.test.ts index 8f2377923c..57271fcd22 100644 --- a/tests/boundary/cli/environmentBuildCli.test.ts +++ b/tests/boundary/cli/environmentBuildCli.test.ts @@ -234,3 +234,45 @@ cliTest( ]); }, ); + +cliTest.for(["--dry-run=abc", "--dry-run="] as const)( + "rejects malformed environment-build dry-run %s before publication", + async (dryRun, { cli }) => { + const root = await createTestTempDirectory( + "rea-environment-invalid-dry-run-", + ); + const har = join(root, "grafana.har"); + const output = join(root, "bundle"); + await writeFile(har, JSON.stringify(environmentManifestHar())); + const result = await cli.run({ + arguments: ["environment-build", har, output, dryRun, "--json"], + timeoutMs: 60_000, + }); + expect(result.exitCode).not.toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("--dry-run"); + expect(await readdir(root)).toEqual(["grafana.har"]); + }, +); + +cliTest.for(["--dry-run=true", "--dry-run=false"] as const)( + "preserves explicit environment-build dry-run value %s", + async (dryRun, { cli }) => { + const root = await createTestTempDirectory( + "rea-environment-explicit-dry-run-", + ); + const har = join(root, "grafana.har"); + const output = join(root, "bundle"); + await writeFile(har, JSON.stringify(environmentManifestHar())); + const result = await cli.run({ + arguments: ["environment-build", har, output, dryRun, "--json"], + timeoutMs: 60_000, + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.json).toMatchObject({ + status: dryRun === "--dry-run=true" ? "planned" : "built", + }); + expect((await readdir(root)).includes("bundle")).toBe( + dryRun === "--dry-run=false", + ); + }, +); diff --git a/tests/boundary/cli/strictPositionals.test.ts b/tests/boundary/cli/strictPositionals.test.ts index 48d4e0608e..2a85bf19c9 100644 --- a/tests/boundary/cli/strictPositionals.test.ts +++ b/tests/boundary/cli/strictPositionals.test.ts @@ -4,7 +4,7 @@ import { Cli, z } from "incur"; import { expect, it } from "vitest"; import { createCli } from "../../../src/cli.js"; -import { rejectSurplusCliArguments } from "../../../src/cli/strictPositionals.js"; +import { validateCliArguments } from "../../../src/cli/strictPositionals.js"; import { excludeDeclaredSensitiveArguments, explicitSensitiveValueOptions, @@ -27,7 +27,7 @@ const serve = async (cli: CliInstance, argv: string[]) => { return { stdout, exitCode }; }; const installAdmission = (cli: CliInstance) => { - const strict = rejectSurplusCliArguments(cli); + const strict = validateCliArguments(cli); const declared = excludeDeclaredSensitiveArguments(cli); const original = cli.serve.bind(cli); cli.serve = (argv = [], options = {}) => @@ -209,3 +209,100 @@ it("preserves counted aliases and negated booleans while checking the positional expect(surplus.stdout).toContain("Unexpected positional arguments"); expect(runs).toBe(1); }); + +it("validates every dry-run literal before lossy coercion while retaining bare and negated controls", async () => { + let runs = 0; + let defaults = 0; + const cli = Cli.create("dry-run-fixture", { sync: false }); + cli.command("setup", { + aliases: ["plan"], + options: z.object({ + dryRun: z.boolean().default(false), + selected: z.string().default(() => { + defaults++; + return "default"; + }), + }), + run: ({ options }) => { + runs++; + return options; + }, + }); + installAdmission(cli); + for (const literal of ["", "abc", "1", "True", "ab\ncd"]) { + const result = await serve(cli, [ + "plan", + `--dry-run=${literal}`, + "--dryRun=false", + ]); + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ code: "PARSE_ERROR" }); + } + expect({ runs, defaults }).toEqual({ runs: 0, defaults: 0 }); + for (const [tokens, expected] of [ + [["--dry-run"], true], + [["--dryRun=true"], true], + [["--dry-run=false"], false], + [["--no-dry-run"], false], + [["--no-dryRun", "--dry-run"], true], + ] as const) { + const result = await serve(cli, ["setup", ...tokens]); + expect(result.exitCode, result.stdout).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ dryRun: expected }); + } + expect({ runs, defaults }).toEqual({ runs: 5, defaults: 5 }); +}); + +it("keeps flag-shaped option data untouched while validating only bound dry-run values", async () => { + const cli = Cli.create("dry-run-data", { sync: false }); + cli.command("setup", { + options: z.object({ + dryRun: z.boolean().default(false), + selected: z.string(), + }), + alias: { selected: "s" }, + run: ({ options }) => options, + }); + cli.command("text", { + options: z.object({ dryRun: z.string() }), + run: ({ options }) => options, + }); + installAdmission(cli); + for (const value of ["--dry-run=abc", "--dry-run", "--no-dryRun"]) { + const result = await serve(cli, ["setup", "-s", value, "--dry-run=true"]); + expect(result.exitCode, result.stdout).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + dryRun: true, + selected: value, + }); + } + const text = await serve(cli, ["text", "--dry-run=abc"]); + expect(text.exitCode).toBe(0); + expect(JSON.parse(text.stdout)).toEqual({ dryRun: "abc" }); +}); + +it("validates dry-run on variadic commands without losing their declared tail", async () => { + let runs = 0; + const cli = Cli.create("dry-run-tail", { sync: false }); + cli.command("many", { + args: z.object({ + values: z.array(z.string()), + }), + options: z.object({ dryRun: z.boolean().default(false) }), + run: ({ args, options }) => { + runs++; + return { args, options }; + }, + }); + installAdmission(cli); + const rejected = await serve(cli, ["many", "one", "two", "--dry-run=abc"]); + expect(rejected.exitCode).toBe(1); + expect(runs).toBe(0); + const accepted = await serve(cli, ["many", "one", "two", "--no-dry-run"]); + expect(accepted.exitCode, accepted.stdout).toBe(0); + expect(JSON.parse(accepted.stdout)).toMatchObject({ + args: { values: ["one", "two"] }, + options: { dryRun: false }, + }); + expect(runs).toBe(1); +}); From abfccc0683e73ee396087309ab98c78c9f6dde4b Mon Sep 17 00:00:00 2001 From: Saurabh Sungh Date: Sat, 7 Nov 2026 04:43:15 +0530 Subject: [PATCH 088/151] fix(javascript): bound symbolic property and member queries (#8876) Fixes #8838. Admit all symbolic query entry points through the existing resolver budget, preserve cache-hit depth and restore entered frames in finally. Retain independent facts with partial source coverage instead of losing entire scripts to recursive spreads. Validated 273 focused tests, check:fast/docs:check, real CLI/stdio-MCP projections and complete parent reads, independent public-source oracle, and unchanged full results for the official Electron control and Bitwarden selected preload. Full technical CI 42308068679 passes, including all eight shards and required aggregates. Greptile reviews this head at 5/5; no completed Codex review of this head is available. The Author readiness-only exception is explicitly authorized; no human-review attestation is added. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) --- docs/javascript-application-graph.md | 7 + .../javascriptSymbolicBindings.test.ts | 77 ++++++++ .../javascript/javascriptSymbolicBindings.ts | 178 +++++++++++------- .../javascriptApplicationService.test.ts | 53 ++++++ 4 files changed, 244 insertions(+), 71 deletions(-) diff --git a/docs/javascript-application-graph.md b/docs/javascript-application-graph.md index b232d86b86..3b45dd0841 100644 --- a/docs/javascript-application-graph.md +++ b/docs/javascript-application-graph.md @@ -171,6 +171,13 @@ resource truncation. JavaScript summary `limitation_count` counts the full, deduplicated union of parent, application and semantic caveats, including messages whose delivery is omitted by the view's byte budget. +Symbolic property reads and member enumeration share the expression resolver's +existing resource budget, including recursive return spreads with fresh arguments. +A bounded source retains independent window, preload and endpoint facts while +reporting `parse_status: partial` and limited examination in `analysis_coverage`. +Unresolved recursive values remain unknown; selected views retain source coverage +separately from the completeness of the returned page. + Unavailable values are represented explicitly rather than as missing fields, so absence of evidence cannot silently become evidence of absence. diff --git a/src/domain/javascript/javascriptSymbolicBindings.test.ts b/src/domain/javascript/javascriptSymbolicBindings.test.ts index 170a6cf8fd..1c9df55055 100644 --- a/src/domain/javascript/javascriptSymbolicBindings.test.ts +++ b/src/domain/javascript/javascriptSymbolicBindings.test.ts @@ -954,3 +954,80 @@ it("keeps a possibly repeated factory's native descriptor value unknown", () => })();`); expect(value.kind).toBe("unknown"); }); + +it("bounds recursion when spreading a self-recursive call result and querying properties", () => { + const { resolver, values } = inspectFixture( + `function f(o) { const stay = f(o); return { ...stay, c: stay.sc.startsWith("k") }; } + inspect(f({}));`, + (bindings, node) => { + const obj = bindings.evaluate(node.arguments[0]); + const sc = bindings.property(obj, "sc"); + const c = bindings.property(obj, "c"); + const members = bindings.members(obj); + const unknownMembers = bindings.unknownMembers(obj); + expect(sc.kind).toBe("unknown"); + expect(c.kind).toBe("unknown"); + expect(members).toContain("c"); + expect(unknownMembers).toBeGreaterThan(0); + return obj; + }, + ); + expect(values).toHaveLength(1); + expect(resolver.truncated).toBe(true); +}); + +it("evaluates finite recursion across returned closures and object arguments to literal results", () => { + const closureResult = inspectOne(` + function make(flag) { return function step() { return flag ? { next: make(false)() } : { value: "end" }; }; } + inspect(make(true)().next.value); + `); + expect(closureResult).toMatchObject({ kind: "literal", value: "end" }); + + const stepResult = inspectOne(` + function f(step) { return step ? { next: f(step.next) } : { value: "end" }; } + inspect(f({ next: { next: null } }).next.next.value); + `); + expect(stepResult).toMatchObject({ kind: "literal", value: "end" }); +}); + +it("bounds recursion with fresh arguments while preserving own stable members", () => { + const { resolver, values } = inspectFixture( + `function grow(value) { return { ...grow({ value }), stable: 1 }; } + inspect(grow(null));`, + (bindings, node) => { + const obj = bindings.evaluate(node.arguments[0]); + const stable = bindings.property(obj, "stable"); + const members = bindings.members(obj); + const unknownMembers = bindings.unknownMembers(obj); + expect(stable).toMatchObject({ kind: "literal", value: 1 }); + expect(members).toContain("stable"); + expect(unknownMembers).toBeGreaterThan(0); + // A bounded recursive read must restore depth for a later independent read. + expect(bindings.property(obj, "stable")).toMatchObject({ + kind: "literal", + value: 1, + }); + return obj; + }, + ); + expect(values).toHaveLength(1); + expect(resolver.truncated).toBe(true); +}); + +it("keeps repeated cached queries within an independent depth budget", () => { + const { resolver, values } = inspectFixture( + 'const shared = { value: "end" }; inspect(shared);', + (bindings, node) => { + for (let index = 0; index < 256; index += 1) { + const value = bindings.property( + bindings.evaluate(node.arguments[0]), + "value", + ); + expect(value).toMatchObject({ kind: "literal", value: "end" }); + } + return bindings.evaluate(node.arguments[0]); + }, + ); + expect(values).toHaveLength(1); + expect(resolver.truncated).toBe(false); +}); diff --git a/src/domain/javascript/javascriptSymbolicBindings.ts b/src/domain/javascript/javascriptSymbolicBindings.ts index 01e6614485..52eb90f5aa 100644 --- a/src/domain/javascript/javascriptSymbolicBindings.ts +++ b/src/domain/javascript/javascriptSymbolicBindings.ts @@ -65,6 +65,8 @@ interface SymbolicReadBoundary { } type SymbolicNamespace = Extract; +const SYMBOLIC_DEPTH_LIMIT = 128; + const emptyEnvironment: SymbolicEnvironment = { values: new Map() }; const containsOwnedReturn = (root: t.Node): boolean => { @@ -210,15 +212,25 @@ export class JavaScriptSymbolicBindings { return emptyEnvironment; } + /** Shared admission; cache hits keep their existing depth-free fast path. */ + #admitEvaluation(): boolean { + if ( + --this.#remainingEvaluations < 0 || + this.#depth >= SYMBOLIC_DEPTH_LIMIT + ) { + this.truncated = true; + return false; + } + return true; + } + evaluate( input: t.Node | null | undefined, environment?: SymbolicEnvironment, ): SymbolicValue { if (!input) return { kind: "literal", value: undefined }; - if (--this.#remainingEvaluations < 0 || this.#depth >= 128) { - this.truncated = true; + if (!this.#admitEvaluation()) return symbolicUnknown("symbolic resource limit"); - } const node = unwrapJavaScriptExpression(input).node; const env = environment ?? this.environmentAt(node); const cache = this.#cache.get(env) ?? new WeakMap(); @@ -278,14 +290,21 @@ export class JavaScriptSymbolicBindings { key: string, read?: SymbolicPropertyRead, ): SymbolicValue { - let result: SymbolicValue; - if (value.kind === "union") - result = symbolicUnion([ - ...this.#commonProperty(value.values, key, read), - ...value.values.map((variant) => this.property(variant, key, read)), - ]); - else result = this.#property(value, key, read); - return this.#withConditions(result, value); + if (!this.#admitEvaluation()) + return symbolicUnknown("symbolic resource limit"); + this.#depth += 1; + try { + let result: SymbolicValue; + if (value.kind === "union") + result = symbolicUnion([ + ...this.#commonProperty(value.values, key, read), + ...value.values.map((variant) => this.property(variant, key, read)), + ]); + else result = this.#property(value, key, read); + return this.#withConditions(result, value); + } finally { + this.#depth -= 1; + } } #commonProperty( @@ -465,40 +484,51 @@ export class JavaScriptSymbolicBindings { active = new Set(), ): readonly string[] { if (active.has(value)) return []; + if (!this.#admitEvaluation()) return []; active.add(value); - if (value.kind === "union") - return [...new Set(value.values.flatMap((v) => this.members(v, active)))]; - if (value.kind === "record") return [...value.values.keys()]; - if (value.kind === "namespace") { - const available = this.#namespaceWrites(value); - if (available === undefined) return []; - const replacement = available.findLast( - (write) => write.path.length === 0, - ); - return replacement - ? this.members( - this.evaluate(replacement.value, value.environment), - active, - ) - : [ - ...new Set( - available.flatMap((w) => (w.path.length === 1 ? w.path : [])), - ), - ]; - } - if (value.kind !== "object") return []; - return [ - ...new Set( - value.node.properties.flatMap((p) => - t.isSpreadElement(p) - ? this.members(this.evaluate(p.argument, value.environment), active) - : [semanticStaticPropertyKey(p.key, p.computed)].filter( - (k): k is string => - k !== null && !isObjectPrototypeSetter(p, k), + this.#depth += 1; + try { + if (value.kind === "union") + return [ + ...new Set(value.values.flatMap((v) => this.members(v, active))), + ]; + if (value.kind === "record") return [...value.values.keys()]; + if (value.kind === "namespace") { + const available = this.#namespaceWrites(value); + if (available === undefined) return []; + const replacement = available.findLast( + (write) => write.path.length === 0, + ); + return replacement + ? this.members( + this.evaluate(replacement.value, value.environment), + active, + ) + : [ + ...new Set( + available.flatMap((w) => (w.path.length === 1 ? w.path : [])), ), + ]; + } + if (value.kind !== "object") return []; + return [ + ...new Set( + value.node.properties.flatMap((p) => + t.isSpreadElement(p) + ? this.members( + this.evaluate(p.argument, value.environment), + active, + ) + : [semanticStaticPropertyKey(p.key, p.computed)].filter( + (k): k is string => + k !== null && !isObjectPrototypeSetter(p, k), + ), + ), ), - ), - ]; + ]; + } finally { + this.#depth -= 1; + } } unknownMembers( @@ -506,39 +536,45 @@ export class JavaScriptSymbolicBindings { active = new Set(), ): number { if (active.has(value)) return 1; + if (!this.#admitEvaluation()) return 1; active.add(value); - if (value.kind === "union") - return value.values.reduce( - (sum, v) => sum + this.unknownMembers(v, active), + this.#depth += 1; + try { + if (value.kind === "union") + return value.values.reduce( + (sum, v) => sum + this.unknownMembers(v, active), + 0, + ); + if (value.kind === "record") return 0; + if (value.kind === "namespace") { + if (this.#mutated.has(value.bindingId)) return 1; + const available = this.#namespaceWrites(value); + if (available === undefined) return 1; + const replacement = available.findLast( + (write) => write.path.length === 0, + ); + return replacement + ? this.unknownMembers( + this.evaluate(replacement.value, value.environment), + active, + ) + : 0; + } + if (value.kind !== "object") return 1; + return value.node.properties.reduce( + (sum, p) => + sum + + (t.isSpreadElement(p) + ? this.unknownMembers( + this.evaluate(p.argument, value.environment), + new Set(active), + ) + : Number(semanticStaticPropertyKey(p.key, p.computed) === null)), 0, ); - if (value.kind === "record") return 0; - if (value.kind === "namespace") { - if (this.#mutated.has(value.bindingId)) return 1; - const available = this.#namespaceWrites(value); - if (available === undefined) return 1; - const replacement = available.findLast( - (write) => write.path.length === 0, - ); - return replacement - ? this.unknownMembers( - this.evaluate(replacement.value, value.environment), - active, - ) - : 0; + } finally { + this.#depth -= 1; } - if (value.kind !== "object") return 1; - return value.node.properties.reduce( - (sum, p) => - sum + - (t.isSpreadElement(p) - ? this.unknownMembers( - this.evaluate(p.argument, value.environment), - new Set(active), - ) - : Number(semanticStaticPropertyKey(p.key, p.computed) === null)), - 0, - ); } /** Calling a generator creates an iterator; advancing it is not modeled. */ diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationService.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationService.test.ts index 9bff3d5f09..677ec2631f 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptApplicationService.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptApplicationService.test.ts @@ -555,3 +555,56 @@ describe("JavaScript application cleanup diagnostics", () => { }); }); }); + +describe("JavaScript application symbolic coverage", () => { + it("analyzes sources spreading recursive call results while preserving coverage limits and independent endpoints", async () => { + const root = await createTestTempDirectory("rea-js-recursive-spread-"); + await writeFile( + join(root, "self.js"), + 'function f(o) { const stay = f(o); return { ...stay, c: stay.sc.startsWith("k") }; }\nfetch("/kept-self");\n', + ); + await writeFile( + join(root, "mutual.js"), + 'function a(o) { const stay = b(o); return { ...stay, c: stay.sc.startsWith("k") }; }\nfunction b(o) { const stay = a(o); return { ...stay, c: stay.sc.startsWith("k") }; }\nfetch("/kept-mutual");\n', + ); + const result = await analyzeJavaScriptApplication({ + input_path: root, + format: "directory", + source_paths: ["self.js", "mutual.js"], + }); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + const normalized = javascriptApplicationAnalysisResultSchema.parse( + result.value.normalized_result, + ); + expect(normalized.statistics).toMatchObject({ + parsed_javascript_files: 2, + parse_failures: 0, + }); + const endpoints = normalized.graph.nodes + .filter(({ kind }) => kind === "endpoint") + .flatMap(({ observations }) => + observations.map(({ properties }) => properties.value), + ) + .sort(); + expect(endpoints).toEqual(["/kept-mutual", "/kept-self"]); + + const assetObservations = normalized.graph.nodes + .filter(({ kind }) => kind === "javascript-asset") + .flatMap(({ observations }) => observations); + for (const path of ["self.js", "mutual.js"]) { + const observation = assetObservations.find( + ({ properties }) => properties.path === path, + ); + expect(observation?.properties).toMatchObject({ + parse_status: "partial", + analysis_coverage: { + examination: { + status: "limited", + electron_call_paths_limited: true, + }, + }, + }); + } + }); +}); From 57fc2d4617c303dc527cd1bc69ba428972d9baa6 Mon Sep 17 00:00:00 2001 From: lostviolinist <50481227+lostviolinist@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:12:43 -0800 Subject: [PATCH 089/151] fix(test): wait for valid debugger fixture PIDs (#9208) * fix(test): wait for valid debugger fixture PIDs * test(process): cover delayed and invalid PID publication * test(process): share validated PID file readiness --- tests/boundary/process/pidFile.test.ts | 45 +++++++++++++++++++ .../GdbSessionReviewRegressions.test.ts | 31 +++++-------- .../providers/RizinInferiorTty.test.ts | 29 ++---------- tests/support/process/pidFile.ts | 25 +++++++++++ 4 files changed, 85 insertions(+), 45 deletions(-) create mode 100644 tests/boundary/process/pidFile.test.ts create mode 100644 tests/support/process/pidFile.ts diff --git a/tests/boundary/process/pidFile.test.ts b/tests/boundary/process/pidFile.test.ts new file mode 100644 index 0000000000..e30459fe2b --- /dev/null +++ b/tests/boundary/process/pidFile.test.ts @@ -0,0 +1,45 @@ +import { open, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { waitForTestPidFile } from "../../support/process/pidFile.js"; + +it("waits through a missing file and an empty file before accepting its PID", async () => { + const directory = await createTestTempDirectory("rea-pid-file-"); + const path = join(directory, "pid"); + let settled = false; + const reading = waitForTestPidFile(path, 1_000).finally(() => { + settled = true; + }); + const observed = reading.catch(() => undefined); + try { + await delay(30); + expect(settled).toBe(false); + const file = await open(path, "w"); + try { + await delay(30); + expect(settled).toBe(false); + await file.writeFile(String(process.pid)); + await expect(reading).resolves.toBe(process.pid); + } finally { + await file.close(); + } + } finally { + await observed; + } +}); + +it.each(["", "0", "-1", "1e3", "12x", "9007199254740992"])( + "refuses a PID file containing %j when its deadline expires", + async (text) => { + const directory = await createTestTempDirectory("rea-invalid-pid-file-"); + const path = join(directory, "pid"); + await writeFile(path, text); + await expect(waitForTestPidFile(path, 30)).rejects.toThrow( + "did not publish a valid PID", + ); + }, +); diff --git a/tests/boundary/providers/GdbSessionReviewRegressions.test.ts b/tests/boundary/providers/GdbSessionReviewRegressions.test.ts index 0992920881..eb0b00634b 100644 --- a/tests/boundary/providers/GdbSessionReviewRegressions.test.ts +++ b/tests/boundary/providers/GdbSessionReviewRegressions.test.ts @@ -10,6 +10,7 @@ import { evidenceSchema } from "../../../src/domain/evidence.js"; import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js"; import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; import { stopOwnedDebuggerProcess } from "../../../src/process/ownedDebuggerSession.js"; +import { waitForTestPidFile } from "../../support/process/pidFile.js"; // These tests launch controlled fake providers; Linux admission is injected so // lifecycle semantics remain testable on hosts that correctly reject GDB. @@ -21,9 +22,9 @@ const createGdbManagerForInjectedPlatform = ( let directory: string | undefined; const managers: Array<{ closeAll(): Promise }> = []; -const expectOwnedProcessExited = async (pidText: string): Promise => { - const pid = Number(pidText); - expect(Number.isInteger(pid)).toBe(true); +const expectOwnedProcessExited = async (pid: number): Promise => { + expect(Number.isSafeInteger(pid)).toBe(true); + expect(pid).toBeGreaterThan(0); await expect .poll(() => { try { @@ -44,6 +45,10 @@ const expectOwnedProcessExited = async (pidText: string): Promise => { .toBe(false); }; +it("rejects an empty PID before observing process exit", async () => { + await expect(expectOwnedProcessExited(Number(""))).rejects.toThrow(); +}); + it("keeps persistent GDB sessions unavailable on Windows", async () => { const gdb = await createGdbManagerForInjectedPlatform({ environment: { REA_GDB_COMMAND: "/missing/gdb" }, @@ -448,19 +453,7 @@ process.stdin.resume(); managers.push(manager); const starting = manager.start(); - let pidText: string | undefined; - for ( - let attempt = 0; - attempt < 100 && pidText === undefined; - attempt += 1 - ) { - try { - pidText = await readFile(pidPath, "utf8"); - } catch { - await new Promise((resolve) => setTimeout(resolve, 10)); - } - } - if (pidText === undefined) throw new Error("fake GDB did not start"); + const pid = await waitForTestPidFile(pidPath, 1_000); let closeSettled = false; const closing = manager.closeAll().then(() => { @@ -481,7 +474,7 @@ process.stdin.resume(); if (started.ok) throw new Error("GDB unexpectedly started"); expect(started.error._tag).toBe("AnalysisCancelledError"); expect(await readFile(signalPath, "utf8")).toBe("SIGTERM"); - await expectOwnedProcessExited(pidText); + await expectOwnedProcessExited(pid); expect(manager.status("unknown-session")).toBeUndefined(); await expect(manager.start()).resolves.toMatchObject({ ok: false, @@ -589,7 +582,7 @@ setTimeout(() => process.exit(0), 250).unref(); expect(result.error.cleanupIncomplete).toBe(true); } await expectOwnedProcessExited( - await readFile(join(directory, "pid"), "utf8"), + await waitForTestPidFile(join(directory, "pid"), 1_000), ); expect(manager.status(started.value.session_id)?.state).toBe("closed"); }, @@ -1033,7 +1026,7 @@ setInterval(() => {}, 1000); child.kill("SIGKILL"); } await ownedProcessExit; - await expectOwnedProcessExited(String(ownedLaunch.process.pid)); + await expectOwnedProcessExited(ownedLaunch.process.pid!); } const retried = await manager.retryCleanupFailures(); expect(retried.ok).toBe(false); diff --git a/tests/boundary/providers/RizinInferiorTty.test.ts b/tests/boundary/providers/RizinInferiorTty.test.ts index a79aaadea9..9fa965d9ce 100644 --- a/tests/boundary/providers/RizinInferiorTty.test.ts +++ b/tests/boundary/providers/RizinInferiorTty.test.ts @@ -25,6 +25,7 @@ import { rizinTestInferiorTty, } from "./RizinTestInferiorTty.js"; import { createInjectedProcess } from "./RizinDebugSessionCoverage.fixtures.js"; +import { waitForTestPidFile } from "../../support/process/pidFile.js"; let terminal: ReturnType | undefined; let manager: RizinDebugSessionManager | undefined; @@ -484,18 +485,7 @@ it.skipIf( ); const closed = await closing; expect(closed.ok, closed.ok ? "" : closed.error.message).toBe(true); - const deadline = Date.now() + 5_000; - let pidText = ""; - while (Date.now() < deadline) { - try { - pidText = await readFile(pidPath, "utf8"); - break; - } catch { - await new Promise((resolveDelay) => setTimeout(resolveDelay, 50)); - } - } - inferiorPid = Number(pidText); - expect(Number.isSafeInteger(inferiorPid) && inferiorPid > 0).toBe(true); + inferiorPid = await waitForTestPidFile(pidPath, 5_000); expect(() => process.kill(inferiorPid!, 0)).not.toThrow(); await writeFile(releasePath, "release after close"); const outputDeadline = Date.now() + 3_000; @@ -636,20 +626,7 @@ process.stdin.on("data", (chunk) => { ).toBe(true); if (!started.ok) throw started.error; if (launchTarget) { - let pidText: string | undefined; - const deadline = Date.now() + 2_000; - while (pidText === undefined && Date.now() < deadline) { - try { - pidText = await readFile(pidPath, "utf8"); - } catch { - await new Promise((resolveDelay) => setTimeout(resolveDelay, 10)); - } - } - const parsedInferiorPid = - pidText === undefined ? Number.NaN : Number(pidText); - if (!Number.isSafeInteger(parsedInferiorPid) || parsedInferiorPid <= 0) - throw new Error("The test inferior did not publish a valid PID."); - inferiorPid = parsedInferiorPid; + inferiorPid = await waitForTestPidFile(pidPath, 2_000); } return started.value.session_id; } diff --git a/tests/support/process/pidFile.ts b/tests/support/process/pidFile.ts new file mode 100644 index 0000000000..1f350c0d89 --- /dev/null +++ b/tests/support/process/pidFile.ts @@ -0,0 +1,25 @@ +import { readFile } from "node:fs/promises"; +import { setTimeout as delay } from "node:timers/promises"; + +/** Wait for a fixture's PID bytes, not merely the creation of its file. */ +export const waitForTestPidFile = async ( + path: string, + timeoutMs: number, +): Promise => { + const deadline = performance.now() + timeoutMs; + do { + let text = ""; + try { + text = await readFile(path, "utf8"); + } catch (cause) { + if ( + !(cause instanceof Error && "code" in cause && cause.code === "ENOENT") + ) + throw cause; + } + const pid = Number(text); + if (/^[1-9][0-9]*$/.test(text) && Number.isSafeInteger(pid)) return pid; + await delay(10); + } while (performance.now() < deadline); + throw new Error(`Test process did not publish a valid PID in ${path}`); +}; From ac23dc8077e429bd61030c5d443580e432ae619b Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Fri, 6 Nov 2026 19:20:12 -0800 Subject: [PATCH 090/151] feat(desktop): read XML with a plain DOCTYPE, and check an element's whole text (#9050) * feat(desktop): read XML with a plain DOCTYPE, and check an element's whole text - Both readers accept a DOCTYPE with only a name and an optional PUBLIC or SYSTEM identifier, record it as the view's doctype, and never fetch the DTD or expand entities beyond XML's five; an internal subset, an external entity and a parameter entity stay refused alike. - text_equals and text_contains compare the concatenated text of the element (or text node or attribute) a pointer reaches, as XPath string() does, with optional normalize_whitespace; both evaluators agree, and a text check on a truncated element is undecided. - Whitespace-only text between elements is dropped only when the element holds no other text, so text split into runs keeps its spaces. - The draft suggests text_equals for an element whose text changed. So an AbiWord .abw reads as XML, and its paragraph's text is checked whole however AbiWord splits it into runs, as Writer's spans are. Co-Authored-By: Claude Opus 5.5 * fix(desktop): draft whole-text checks on the paragraph, read only text as text, and skip asctime stamps - A changed value is read at both ends, so text that became a run still marks its paragraph, and the drafted text_equals moves up out of mixed content to the nearest ancestor named by an attribute, such as AbiWord's p by its xid. - Text checks accept only a text node or an attribute value; element names and doctype fields fail, in both evaluators. - isTimestamp also recognises asctime dates (Www Mmm dd hh:mm:ss yyyy). - XML files are drafted with the keyed diff the archive entries use, so no suggestion reaches an element by position; the shared logic moves to appTaskDraftNamed.ts. - Text-check tests assert facts, not whole sentences. Co-Authored-By: Claude Opus 5.5 * fix(desktop): keep checks on a changed attribute inside a paragraph whose text check replaces its runs' A drafted whole-text check on a paragraph hid every change inside it, so a link whose label and href both changed drafted only the paragraph's text and the new destination went unchecked. The text check now covers text and runs added or removed whole; a changed name or attribute of an element both versions hold keeps its own check. Co-Authored-By: Claude Opus 5.5 * fix(desktop): read an attribute called name or attributes as an attribute when the draft keeps its check The owner of `.../attributes/name` was taken as `.../attributes`, so a changed `name` attribute inside a paragraph with a text check was dropped again. The owner is now read by segment, attributes first. Co-Authored-By: Claude Opus 5.5 * fix(desktop): draft text checks from text in runs added whole, and name a text element by its own id before its container Text typed into a run the diff lists whole was never read, so the draft kept only a presence check on the run, which wrong text also passed. Such text now marks the nearest element both ends hold, or, when none can take the check, the added element's own text. The climb also names an element by an identifier of its own (id, xml:id, xid) before a larger container, so a headline under a long layer keeps a whole-text check that survives re-split runs. Co-Authored-By: Claude Opus 5.5 * test(desktop): expect the whole-text check where #8988's pairing test meets text checks On this branch a changed element's text is drafted as `text_equals` on the element, so the identity pairing test now expects that check, under the same `[@role=body]` key. Co-Authored-By: Claude Opus 5.5 * fix(desktop): keep checks for elements without text beside a text edit, and normalize DOCTYPE system id line endings A review found two gaps. - A paragraph's whole-text check covered every element added or removed inside it, including ones holding no text: an image frame, a line break. A solver who typed the text but never inserted the image passed. Such an element now keeps its own presence or absence check. - The Python reader kept a DOCTYPE system literal's CRLF where TypeScript normalizes it to LF. Both now report LF. Co-Authored-By: Claude Opus 5.5 * test(desktop): import textSyntaxFormat once in the state format tests main's mixed-case JSON suffix fix and this branch both added the import, and the merge kept both. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../references/environments.md | 21 +- docs/desktop-application-export.md | 12 +- docs/desktop-application-state.md | 56 +- docs/desktop-application-tasks.md | 86 +- docs/specs/desktop-app-environments/README.md | 3 + .../document-views/verification.md | 3 +- .../state-view/spec.md | 6 +- .../desktop-app-environments/tasks/spec.md | 9 +- .../xml-text-checks/plan.md | 29 + .../xml-text-checks/spec.md | 118 +++ .../xml-text-checks/verification.md | 167 ++++ .../desktop/DesktopArchiveTask.test.ts | 59 +- .../desktop/DesktopTaskExport.test.ts | 11 + src/application/desktop/DesktopTaskExport.ts | 4 +- src/domain/desktop/appExportPythonXml.ts | 87 ++- src/domain/desktop/appTask.ts | 71 +- src/domain/desktop/appTaskChecks.fixture.ts | 310 ++++++++ src/domain/desktop/appTaskChecks.test.ts | 211 +++++ src/domain/desktop/appTaskChecks.ts | 148 +++- src/domain/desktop/appTaskDraft.test.ts | 732 +++++++++++++++++- src/domain/desktop/appTaskDraft.ts | 171 +--- src/domain/desktop/appTaskDraftNamed.ts | 228 ++++++ src/domain/desktop/appTaskDraftText.ts | 336 ++++++++ src/domain/desktop/appTaskPython.ts | 101 +++ src/domain/desktop/stateChanges.ts | 7 +- src/domain/desktop/stateFormats.test.ts | 107 ++- src/domain/desktop/stateFormats.ts | 91 ++- src/domain/xmlDocument.test.ts | 99 ++- src/domain/xmlDocument.ts | 95 ++- .../providers/desktopXmlPython.test.ts | 54 +- .../providers/desktopXmlTextPython.test.ts | 212 +++++ .../desktopXmlWhitespacePython.test.ts | 4 +- tests/fixtures/abiword/README.md | 15 + tests/fixtures/abiword/after/meeting.abw | 31 + tests/fixtures/abiword/before/meeting.abw | 30 + tests/fixtures/libreoffice-runs/README.md | 16 + .../libreoffice-runs/after/content.xml | 2 + .../libreoffice-runs/before/content.xml | 2 + tests/support/desktopExportPython.ts | 34 +- 39 files changed, 3443 insertions(+), 335 deletions(-) create mode 100644 docs/specs/desktop-app-environments/xml-text-checks/plan.md create mode 100644 docs/specs/desktop-app-environments/xml-text-checks/spec.md create mode 100644 docs/specs/desktop-app-environments/xml-text-checks/verification.md create mode 100644 src/domain/desktop/appTaskDraftNamed.ts create mode 100644 src/domain/desktop/appTaskDraftText.ts create mode 100644 tests/boundary/providers/desktopXmlTextPython.test.ts create mode 100644 tests/fixtures/abiword/README.md create mode 100644 tests/fixtures/abiword/after/meeting.abw create mode 100644 tests/fixtures/abiword/before/meeting.abw create mode 100644 tests/fixtures/libreoffice-runs/README.md create mode 100644 tests/fixtures/libreoffice-runs/after/content.xml create mode 100644 tests/fixtures/libreoffice-runs/before/content.xml diff --git a/.agents/skills/reverse-engineer-anything/references/environments.md b/.agents/skills/reverse-engineer-anything/references/environments.md index 331c0e9e69..327ceab171 100644 --- a/.agents/skills/reverse-engineer-anything/references/environments.md +++ b/.agents/skills/reverse-engineer-anything/references/environments.md @@ -327,7 +327,8 @@ Checks must express only the observed change: the draft suggests counts of `SECTION`, `ENDSEC` and `CIRCLE`; only `CIRCLE` equals 9 is the user's change. - Never check unchanged content, whole files, or values the app stamps: - times, ids it assigns, window sizes. REA refuses ISO 8601 date-times; the + times, ids it assigns, window sizes. REA refuses ISO 8601 and `asctime` + date-times (`Thu Nov 5 09:20:31 2026`); the rest is your judgment. - Prefer a few robust checks: whole lines over positions, rows by meaningful columns over generated ids, `at_least` when the user may add more. @@ -335,15 +336,21 @@ Checks must express only the observed change: Kinds: `file_present` and `file_absent`; `lines_present` and `lines_absent` (`lines`); `line_count` (`exact` or `contains`, and `equals`, `at_least` or `at_most`); `value_equals` and `value_absent` (a JSON `pointer` into the view, -such as `/groups/GROUP/KEY` for a key file); `row_present` and `row_absent` -(`table`, `where` by column) and `row_count`. Each names its `file` as a path -under the home. A document saved as a zip archive, such as FreeCAD's -`.FCStd`, reads as its entries: point into an entry's view, and name array -members by selector rather than position, as +such as `/groups/GROUP/KEY` for a key file); `text_equals` and +`text_contains` (an XML element's whole text at a `pointer`, optional +`normalize_whitespace`); `row_present` and `row_absent` (`table`, `where` by +column) and `row_count`. Each names its `file` as a path under the home. A +document saved as a zip archive, such as FreeCAD's `.FCStd`, reads as its +entries: point into an entry's view, and name array members by selector +rather than position, as `/entries/[name=Document.xml]/view/root/children/[name=Object][@name=Hole]`, so the check survives an app version that writes them in another order. Give `value_equals` a `tolerance` to compare a number the app writes as text, such -as `"5.0000000000000000"`, with a number. The +as `"5.0000000000000000"`, with a number. Check what the user wrote in a +document with `text_equals` on its paragraph element, not `value_equals` on a +text node: apps such as AbiWord and Writer split a paragraph into runs as they +save, so its first text node holds only part of it; use `text_contains` for a +value within a sentence, such as a time. The [desktop application tasks](https://github.com/morluto/rea/blob/main/docs/desktop-application-tasks.md) guide has the exact rules. diff --git a/docs/desktop-application-export.md b/docs/desktop-application-export.md index 005a18d7eb..0d641842df 100644 --- a/docs/desktop-application-export.md +++ b/docs/desktop-application-export.md @@ -296,8 +296,9 @@ The views follow the [state view](desktop-application-state.md)'s formats and shapes: SQLite tables (at most 64 ordinary tables, `tables_truncated` past that, and per table `columns`, `primary_key`, at most 500 `rows` and `truncated`, with blobs, unsafe integers and text that is not UTF-8 tagged as -REA tags them), JSON, XML as `{root, truncated}` with DOCTYPEs refused and REA's -node, depth and whitespace rules, key file groups, text as +REA tags them), JSON, XML as `{root, truncated}` with REA's node, depth and +whitespace rules and its `doctype` when the document declares one, key file +groups, text as `{lines, line_count, truncated}` split on `\n` and `\r\n`, and zip archives as their entries, read with Python's `zipfile` from the file in place, with REA's entry, size, compression ratio and archive limits and its wording for an @@ -314,7 +315,12 @@ processing, which refuses what REA's parser refuses, such as an undeclared prefix, then without it, so element and attribute names keep the prefixes the document spells and namespace declarations stay attributes, as in REA's view; text splits into nodes where REA's splits it, at CDATA sections, comments and -processing instructions. Known differences remain: +processing instructions. Both passes read a DOCTYPE as REA does: with +parameter entity parsing off, so the DTD it names is never read, and handlers +that refuse an internal subset, an external entity and an entity expat would +skip; the first pass also checks each start tag for a reference to an +undeclared entity, which expat drops from an attribute value without a word +when a DOCTYPE names a DTD. Known differences remain: - dconf databases are reported as binary, since REA reads them with `dconf`; inside an archive, one is `dconf` with an error, as in REA. diff --git a/docs/desktop-application-state.md b/docs/desktop-application-state.md index 7ab24697a0..b89747b165 100644 --- a/docs/desktop-application-state.md +++ b/docs/desktop-application-state.md @@ -62,16 +62,16 @@ Views hold the captured bytes as they are, the capture host's name and user included; the export removes those from the bundle. A version records whether the file was present, the SHA-256 of its retained bytes, and a view: -| Format | Detected by | View | -| --------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `sqlite` | The `SQLite format 3` header | Every ordinary table's columns, primary key and up to 500 rows | -| `dconf` | The `GVariant` header | Groups whose path names the application, plus the names of the other groups | -| `zip` | A zip local file header, `PK\x03\x04`, whatever the name | Its file entries in archive order, each with its own format and view | -| `json` | A `.json` name or a leading `{` or `[` that parses | The parsed value, from files up to 4 MiB nested up to 500 levels | -| `xml` | An XML name or declaration that parses | An element tree (`name`, `attributes`, `children`); text kept exactly, DOCTYPEs refused | -| `keyfile` | Only `[group]`, `key=value` and comment lines, with at least one group | Groups of `key` → string value, from files up to 4 MiB | -| `text` | Other UTF-8 text | Lines, up to 5,000 | -| `binary` | NUL bytes or invalid UTF-8 | None | +| Format | Detected by | View | +| --------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `sqlite` | The `SQLite format 3` header | Every ordinary table's columns, primary key and up to 500 rows | +| `dconf` | The `GVariant` header | Groups whose path names the application, plus the names of the other groups | +| `zip` | A zip local file header, `PK\x03\x04`, whatever the name | Its file entries in archive order, each with its own format and view | +| `json` | A `.json` name or a leading `{` or `[` that parses | The parsed value, from files up to 4 MiB nested up to 500 levels | +| `xml` | An XML name or declaration that parses | An element tree (`name`, `attributes`, `children`); text kept exactly, DTDs never read | +| `keyfile` | Only `[group]`, `key=value` and comment lines, with at least one group | Groups of `key` → string value, from files up to 4 MiB | +| `text` | Other UTF-8 text | Lines, up to 5,000 | +| `binary` | NUL bytes or invalid UTF-8 | None | A JSON value holding a number JavaScript would round, such as an integer above 2^53, is not read rather than shown rounded. @@ -81,10 +81,38 @@ included: a saved Inkscape drawing's root is `svg`, in its default namespace, with `sodipodi:docname` and the declarations `xmlns` and `xmlns:sodipodi` among its attributes, and a Writer paragraph is `text:p`. An element's `children` hold its elements and text in order, text split where a CDATA -section, comment or processing instruction falls; whitespace-only text in an -element that holds elements is dropped unless `xml:space="preserve"` applies. -The view keeps up to 5,000 nodes, comments and processing instructions -counted, nested up to 64 levels, and sets `truncated` when it leaves some out. +section, comment or processing instruction falls. Whitespace-only text between +child elements is formatting, and dropped, in an element that holds no other +text, unless `xml:space="preserve"` applies; in mixed content, an element with +text of its own beside its children such as a paragraph split into runs, it is +kept, so `

Meet on Sunday evening

` keeps the space between +the runs. Whitespace here is XML's: space, tab, carriage return and line feed; +a no-break space is text. The view keeps up to 5,000 nodes, comments and +processing instructions counted, nested up to 64 levels, and sets `truncated` +when it leaves some out. + +A document that begins with a DOCTYPE naming its DTD, as AbiWord's `.abw` +does, reads as the same element tree, and the view records the declaration +beside `root`, its public identifier normalized as XML matches it: + +```json +{ + "doctype": { + "name": "abiword", + "public_id": "-//ABISOURCE//DTD AWML 1.0 Strict//EN", + "system_id": "http://www.abisource.com/awml.dtd" + }, + "root": { "name": "abiword", "attributes": {}, "children": [] }, + "truncated": false +} +``` + +An identifier the DOCTYPE leaves out is `null`, and a document without one +has no `doctype`. The DTD is never fetched or read, so it adds no default +attributes and declares no entities. A DOCTYPE with an internal subset +(``), which could declare entities, is not read as XML, nor is +a document that refers to any entity but XML's five (`&`, `<`, `>`, +`'`, `"`); such a file is text. Exported XML views use REA's same formatting-whitespace classification, so retained XML text and the task checks that address it stay aligned. diff --git a/docs/desktop-application-tasks.md b/docs/desktop-application-tasks.md index 11480c5ba8..cfc78769d3 100644 --- a/docs/desktop-application-tasks.md +++ b/docs/desktop-application-tasks.md @@ -50,15 +50,24 @@ operation `describe_desktop_application_task` and predicate type role `document`, or of the app's own state files when no document changed. A text file gains `lines_present` for its new lines and `lines_absent` for lines it lost (when either is at most ten), and `line_count` for each line - holding a letter whose count changed; JSON, XML and key files gain + holding a letter whose count changed; JSON and key files gain `value_equals` and `value_absent` by pointer; SQLite tables gain rows added, - changed and removed, by primary key, and their new row count. A zip archive - gains checks inside the JSON, key file and XML views of its entries, by - [selectors](#pointers-and-selectors) and never through an element by - position: an element added whole that it exists, one removed whole that it - is absent, then values changed, added and removed, an entry or a JSON value - added whole through its members, and an empty array or object as it is. - Values that look like a time the app stamps are left out. + changed and removed, by primary key, and their new row count. An XML file, + and the JSON, key file and XML views of a zip archive's entries, gain checks + by [selectors](#pointers-and-selectors) and never through an element by + position: in XML, first [`text_equals`](#whole-text) for each element whose + text changed, then an element added whole that it exists, one removed whole + that it is absent, then values changed, added and removed, an entry or a + JSON value added whole through its members, and an empty array or object as + it is. The text check names the paragraph rather than a run inside it: it + goes up from an element sitting in its parent's text, and from one named by + its tag or position alone to the nearest ancestor named by an attribute, as + AbiWord's `p` by its `xid`; the text nodes and runs inside it get no other + check, but an element holding no text that is added or removed there, such + as an image or a line break, keeps its own, since the text check never + reads it. Whitespace an app lays out between elements is not checked, and + values that look like a time the app stamps, in ISO 8601 or as C's + `asctime` writes it (`Thu Nov 5 09:20:31 2026`), are left out. The suggestions are a starting point. Keep the ones that say what the user did; drop the ones that only follow from how the app saves its file. A LibreCAD @@ -99,6 +108,8 @@ file, in the shapes of the state view. | `line_count` | `exact` or `contains`; `equals`, `at_least` or `at_most` | The number of lines equal to `exact`, or holding `contains`, compares so | | `value_equals` | `pointer`, `value`; optional `tolerance` | The pointer names a value equal to `value`, or within `tolerance` of it | | `value_absent` | `pointer` | The pointer names no value | +| `text_equals` | `pointer`, `value`; optional `normalize_whitespace` | The XML element's [whole text](#whole-text) equals `value` | +| `text_contains` | `pointer`, `value`; optional `normalize_whitespace` | The XML element's whole text holds `value` | | `row_present` | `table`, `where` | A row of the SQLite table has every column in `where` equal | | `row_absent` | `table`, `where` | No row does | | `row_count` | `table`; optional `where`; `equals`, `at_least` or `at_most` | The number of rows, or of rows matching `where`, compares so | @@ -109,7 +120,8 @@ The rules both evaluators follow: `value_absent` and `row_absent` pass on it and a count is 0. - A file that has no view (binary, over the read limit, or unreadable) fails every check but `file_present` and `file_absent`. Line checks need a text - view and row checks a SQLite view; another format fails. + view, row checks a SQLite view and text checks an XML view, in a file or an + archive entry; another format fails. - Equality is JSON equality: numbers by value, never a boolean for a number, objects by their keys whatever their order, arrays in order. - Pointers follow RFC 6901 (`~1` for `/`, `~0` for `~`) and must start with @@ -127,9 +139,9 @@ The rules both evaluators follow: no regular expressions: a pattern run on what the agent wrote could stall the verifier, and JavaScript and Python read some patterns differently. - A check decided by what a view leaves out (`lines_absent`, `value_absent`, - `row_absent`, the counts, and `value_equals` of a whole object or array) - fails when that view is truncated; a check satisfied by what the view holds - is decided on it. + `row_absent`, the counts, `value_equals` of a whole object or array, and + the text checks of an element) fails when that view is truncated; a check + satisfied by what the view holds is decided on it. The export refuses, naming the check, a file outside the owned paths, a file spelled with a leading slash, `.` or `..`, a pointer into a file that is text @@ -137,9 +149,11 @@ at the seed, as the bundle ships it, or at the end checkpoint, the empty pointer (the whole file), a value that names the captured home, or the capture host's name or `user@host` anywhere (which the [export removes from the seed](desktop-application-export.md#host-name-and-user)), a -`tolerance` with a value that is not a number, and a value that looks like an -ISO 8601 date and time. Other volatile content, such as a counter the app -bumps on every save, is for the author to leave out. +`tolerance` with a value that is not a number, an empty `text_contains` +value, or one of only whitespace with `normalize_whitespace`, and a value that +looks like a date and time, in ISO 8601 or `asctime`'s form. Other volatile +content, such as a +counter the app bumps on every save, is for the author to leave out. ## Pointers and selectors @@ -236,6 +250,48 @@ Inkscape writes a line of text as a `tspan` inside its `text` element, and Writer keeps a paragraph's style in `text:style-name`. A prefix is the one the document uses, so a check names the prefixes the app writes. +## Whole text + +A text node is only the stretch of text between two pieces of markup, and +apps split a paragraph into runs as they save it. AbiWord wraps text typed +into a reopened document in `c` elements, so changing a time to `3:30` saves +`

The team meeting starts at 3:30 in room 4.

`; Writer, +which records revision IDs by default, writes text typed in one session as +`text:span` runs. A `value_equals` on the paragraph's `children/0` then reads +`"The team meeting starts at "` and fails. `text_equals` and `text_contains` +read the element's whole text instead: + +```json +{ + "id": "time", + "kind": "text_equals", + "file": "Documents/meeting.abw", + "pointer": "/root/children/[name=section]/children/[name=p][@xid=3]", + "value": "The team meeting starts at 3:30 in room 4." +} +``` + +- The pointer names an element of an XML view, in a file or in an archive + entry's `view`, or a text node or attribute value in one, whose text is its + value. Another format, a pointer into an archive outside an XML entry's + view, or any other value fails, an element's `name` and the `doctype`'s + fields included: they are strings of the view, not text of the document. +- An element's text is XPath's `string()` of what the view keeps: every text + node in it and in its descendants, CDATA included, in document order, with + nothing between them. The view drops whitespace-only text between child + elements only in an element that holds no other text, outside + `xml:space="preserve"`, so the space between two runs of a paragraph is + part of its text. Elements that stand for characters, such as Writer's + `text:s`, `text:tab` and `text:line-break`, add none. +- `text_equals` passes when the text equals `value` exactly, case and all; + `text_contains` when it holds `value`, as a time within a sentence, so the + check holds whatever the rest of the sentence says. +- `normalize_whitespace: true` collapses each run of XML whitespace (space, + tab, carriage return, line feed) in both the text and `value` to one space + and trims both ends before comparing, as XPath's `normalize-space()` does, + for an app that reflows text or a value written across lines. A no-break + space is not whitespace there. + ## Validation and export `rea export-desktop-application --task ` diff --git a/docs/specs/desktop-app-environments/README.md b/docs/specs/desktop-app-environments/README.md index befba07af7..304c67ba5a 100644 --- a/docs/specs/desktop-app-environments/README.md +++ b/docs/specs/desktop-app-environments/README.md @@ -42,6 +42,9 @@ The work lands as one PR per step: 12. [Build computer](build-computer/spec.md): an agentenv computer REA keeps up from a request or a bundle, with REA installed inside for building, driven through REA's MCP tools while an agent builds the environment. +13. [XML text checks](xml-text-checks/spec.md): XML whose DOCTYPE only names + its DTD read as XML, starting with AbiWord, and checks of an element's + whole text across the runs an app splits a paragraph into. Version 1 covers Linux with X11, applications whose state lives in files or SQLite under the home directory, and real applications from public packages. diff --git a/docs/specs/desktop-app-environments/document-views/verification.md b/docs/specs/desktop-app-environments/document-views/verification.md index 1e4a4bb2f8..6410047e54 100644 --- a/docs/specs/desktop-app-environments/document-views/verification.md +++ b/docs/specs/desktop-app-environments/document-views/verification.md @@ -60,7 +60,8 @@ touched files still pass there. undeclared default namespace, `xml:` attributes, CDATA, comments and processing instructions, what both parsers refuse (an undeclared prefix, one attribute twice under two prefixes, a prefix bound to `""`, two colons - in a name, a colon in a processing instruction's target, a DOCTYPE), + in a name, a colon in a processing instruction's target, a DOCTYPE with an + internal subset), and the node and depth limits on prefixed names. - Selectors and tolerance in the shared check cases both evaluators run: one member matched, several, none before or at the last segment, a malformed diff --git a/docs/specs/desktop-app-environments/state-view/spec.md b/docs/specs/desktop-app-environments/state-view/spec.md index 5a9ccb5846..c7b934fcd6 100644 --- a/docs/specs/desktop-app-environments/state-view/spec.md +++ b/docs/specs/desktop-app-environments/state-view/spec.md @@ -30,8 +30,10 @@ generation reads the kinds of change. unreadable directory or unchecked path, outside recorded exclusions); dconf through `dconf dump /` on a private file profile, keeping only groups whose path names the application; JSON up to 4 MiB and 500 levels - with exact numbers; XML through the shared strict parser with DOCTYPEs - refused; key files up to 4 MiB whose every line parses; zip archives by + with exact numbers; XML through the shared strict parser, a DOCTYPE read + only when it has no internal subset and its DTD never read + ([XML text checks](../xml-text-checks/spec.md)); key files up to 4 MiB + whose every line parses; zip archives by their local file header, as their entries with each entry's own format and view ([document views](../document-views/spec.md)); text; binary. - Changes between versions are created, deleted, or lines, values, keys or diff --git a/docs/specs/desktop-app-environments/tasks/spec.md b/docs/specs/desktop-app-environments/tasks/spec.md index f6d5a5f248..b8715b73ea 100644 --- a/docs/specs/desktop-app-environments/tasks/spec.md +++ b/docs/specs/desktop-app-environments/tasks/spec.md @@ -27,7 +27,9 @@ never runs code the agent wrote. kinds: `file_present`, `file_absent`, `lines_present`, `lines_absent`, `line_count` (`exact` or `contains`; `equals`, `at_least` or `at_most`), `value_equals` (optional numeric `tolerance`) and `value_absent` (RFC 6901 - pointer, with selectors on arrays), `row_present`, + pointer, with selectors on arrays), `text_equals` and `text_contains` (an + XML element's whole text at a pointer, optional `normalize_whitespace`; see + [XML text checks](../xml-text-checks/spec.md)), `row_present`, `row_absent` and `row_count` (SQLite table, `where` by column). Ids default to `check-N` and must be unique. - Semantics: an absent file reads as empty; a file without a view fails every @@ -40,7 +42,8 @@ never runs code the agent wrote. - Refusals, each naming the check: a file outside the owned paths or not spelled as a home path; the empty pointer; a pointer not starting with `/` or into text; a repeated line; a `tolerance` whose value is not a number; a - value that names the captured home or looks like an ISO 8601 date and time; + value that names the captured home or looks like a date and time (ISO + 8601, or C's `asctime` form); the draft's placeholder prompt. - `export-desktop-application --task FILE` evaluates the checks on the state view's first versions (the seed) and last versions (the end), refuses a @@ -63,6 +66,6 @@ never runs code the agent wrote. gives the `agent-env run` recipe. - No checks over dconf keys, which `data/get` serves as binary, or over files outside the owned paths. -- No timestamp detection beyond ISO 8601 date-times; other volatile values +- No timestamp detection beyond ISO 8601 and `asctime` date-times; other volatile values are left to the author. - No more than one task per capture. diff --git a/docs/specs/desktop-app-environments/xml-text-checks/plan.md b/docs/specs/desktop-app-environments/xml-text-checks/plan.md new file mode 100644 index 0000000000..55f48fac32 --- /dev/null +++ b/docs/specs/desktop-app-environments/xml-text-checks/plan.md @@ -0,0 +1,29 @@ +# Plan + +1. Parser: `parseXmlDocument` in `src/domain/xmlDocument.ts` gains the + DOCTYPE policy `external`, which refuses a DOCTYPE whose text holds a `[` + outside its quoted literals with the reason `internal-subset`, and + `declaredDocumentType` reads the DOM's name and identifiers, unquoted, the + public one normalized. saxes and xmldom read no DTD and already refuse any + entity but XML's five. +2. State view: `detectStateFormat` and `xmlView` in `stateFormats.ts` use the + `external` policy, and the view adds `doctype` when one is declared. One + predicate, XML whitespace, decides both whether text is formatting and + whether an element is mixed content. +3. Exported module: `_expat` in `appExportPythonXml.ts` turns parameter entity + parsing off, refuses an internal subset, an external entity and a skipped + entity, and passes the declaration to the reader; the namespace pass checks + start tags through a default handler; `_XmlReader` tracks per element + whether it holds text other than XML whitespace. +4. Checks: `text_equals` and `text_contains` in the schema, `textVerdict` + with `isXmlElement` and `elementText` in `appTaskChecks.ts`, and + `_whole_text` in the verifier's evaluator, in the same order of failures; + the export's refusal of a pointer into text covers them. +5. Draft: `wholeTextSuggestions` in `appTaskDraftText.ts`, and the archive's + suggestions in `appTaskDraft.ts` generalized to a check tree, an archive's + entries or an XML file's root, over the keyed diff `diffNamedMembers`; + `isTimestamp` also reads `asctime`'s form. +6. Docs and tests: a synthetic AbiWord save and a Writer `content.xml` split + into runs, read alike by both readers and checked alike by both + evaluators; the DOCTYPE cases each parser accepts and refuses; the shared + check cases. diff --git a/docs/specs/desktop-app-environments/xml-text-checks/spec.md b/docs/specs/desktop-app-environments/xml-text-checks/spec.md new file mode 100644 index 0000000000..dfc9aa9e64 --- /dev/null +++ b/docs/specs/desktop-app-environments/xml-text-checks/spec.md @@ -0,0 +1,118 @@ +# XML with a DOCTYPE, and whole-text checks + +## Outcome + +A document saved as XML that begins with a DOCTYPE naming its DTD, first +AbiWord's `.abw`, is read as XML rather than text, so a task can check what +the user wrote in it. A check can compare an element's whole text, so it holds +however an app splits a paragraph into runs as it saves: AbiWord's `c` +elements, Writer's `text:span`. Both evaluators, REA's and the exported +verifier's, agree. + +## Requirements + +- DOCTYPE: one with a name and, optionally, a `PUBLIC` or `SYSTEM` external + identifier is read as XML by both readers. The DTD it names is never fetched + or read. One with an internal subset (`[ … ]`), even an empty one, is + refused, so a document can declare no entity, and a reference to any entity + but XML's five (`amp`, `lt`, `gt`, `apos`, `quot`) is refused, in content + or in an attribute value, though the DTD a DOCTYPE names might declare it. + A refused document is no XML view, so the file reads as text, as a malformed + one does, in both readers. +- View: the element tree is the one the document has without its DOCTYPE, + within REA's node and depth limits; the declaration is no node. The view + records it beside `root` as + `doctype: {name, public_id, system_id}`, an identifier `null` when the + DOCTYPE gives none, the public one normalized as XML matches it: each run + of spaces and line breaks one space, the ends trimmed. The system one keeps + its spaces, each line ending a line feed as XML reads the document, which + the exported module does itself since `expat` passes the literal as + written. A document without a DOCTYPE has no `doctype`. +- Exported module: `expat` with parameter entity parsing off, and handlers + that refuse an internal subset, an external entity and a skipped entity. The + pass with namespace processing also checks each start tag for a reference + to an undeclared entity, which `expat` drops from an attribute value without + calling a handler when a DOCTYPE names a DTD. +- Whitespace in the view: whitespace-only text between child elements is + formatting, and dropped, only in an element that holds no other text, + outside `xml:space="preserve"`; in mixed content, an element holding text + beside its children, it is kept, so the space between two runs is part of + the text. Whitespace is XML's: space, tab, carriage return and line feed. A + no-break space and other Unicode spaces are text, in both readers alike. +- Checks: `text_equals` and `text_contains`, + `{id, kind, file, pointer, value, normalize_whitespace?}`. The pointer, with + selectors as for `value_equals`, names an element of an XML view, in a file + or an archive entry's `view`, or a text node or attribute value in one. An + element's text is XPath's `string()` of what the view keeps: every text node + in it and its descendants, CDATA included, in document order; a text node's + or an attribute's is its value. `text_equals` passes when that text equals + `value`, `text_contains` when it holds `value`, exactly, case and all. + `normalize_whitespace: true` collapses each run of XML whitespace in both + the text and `value` to one space and trims their ends before comparing, as + `normalize-space()` does. +- Failures, worded alike in both evaluators: an absent file; a file of another + format; a pointer into an archive outside an entry's `view`, or into an + entry of another format; a value that is neither an element, a text node + (a string among an element's `children`) nor an attribute's value (one among + its `attributes`), so an element's `name` and the `doctype`'s fields fail; + and, as undecided, an element in a view that leaves something out, or a + selector searching an array such a view may cut. A text node is whole, so + it is decided on a truncated view. +- Refusals: the task parser refuses the empty pointer, a pointer not starting + with `/`, an empty `text_contains` value or one that is only whitespace + with `normalize_whitespace`, and a value that looks like a date and time, + in ISO 8601 or C's `asctime` form (`Www Mmm dd hh:mm:ss yyyy`, as AbiWord + stamps a save); the export refuses a text check into a file read as text. +- Draft: an XML file is drafted as an archive's entries are, from the keyed + diff that names members by selector: never through an element by position, + an element added or removed whole checked once, and no check of + whitespace-only text between elements. In XML, each element holding a text + node that the diff lists with more than whitespace in it, at either end of a + changed value, and the nearest ancestor both ends hold of each element the + diff lists added or removed whole with such text anywhere in it, gets + `text_equals` with its final text, ahead of the other checks, so a run + added or removed with its words marks its paragraph. That ancestor is not + the document's root unless the root holds text of its own. The check goes + up from an element in mixed content (its parent holds text beside it, the + root included) to the nearest that is not, then from one named by its tag + or position alone to the nearest ancestor below the root named by an + attribute, as AbiWord's `p` by `xid`, so it names a paragraph rather than a + run a later save may split or merge. An element passed on the way that an + identifier of its own (`id`, `xml:id`, `xid` or one ending in `:id`) names + goes ahead of that ancestor, the outermost first, as an SVG `text` by its + `id` in a layer named by its label, unless an identifier names the ancestor + too, as the `text` above a `tspan` with an `id` of its own; one that only + another attribute names, such as `xml:space`, goes after it, for when the + ancestor cannot take the check, as when its text is too long. Either + attribute holds the same value at both ends and names the element alone + among its siblings at each. The check is kept when that element is the + outermost such, at both ends, named without positions, held whole by the + final view, its text changed, at most 2,000 characters and no timestamp. + Where none can take it, an element added whole, named without positions, + gets `text_equals` with its own text instead of a check that it is there, + as a new paragraph in a section too long to check. The text nodes and runs + inside a checked element, added and removed ones included, get no other + check, but a changed name or attribute of an element inside it that both + ends hold, such as a link's `href`, keeps its own, and so does an element + added or removed there whose text is only whitespace, such as an image or a + line break, which the text check never reads. + +## Non-goals + +- No DTD processing: no default attributes, entities, validation or catalog + lookup, from an internal subset or an external DTD. +- No characters from elements that stand for them, such as Writer's `text:s`, + `text:tab` and `text:line-break`, which are elements rather than text. +- No space between runs in an element that holds no text of its own outside + `xml:space="preserve"`: a paragraph made only of runs separated by a bare + space reads without it, since nothing tells that space from formatting. +- No regular expressions, case folding or Unicode normalization in text + checks. +- No rule for save bookkeeping other than timestamps: a counter an app bumps + on save, such as AbiWord's `history` `version`, `edit-time` and + `last-saved`, is still suggested, by name, for the author to drop, since a + number that went up is as often the user's edit. +- No paragraph for the changed text of a run named by an attribute of its + own, such as a run among others of different formatting, in a paragraph + holding no text of its own: its check names that run. A run added or + removed whole is checked at its paragraph. diff --git a/docs/specs/desktop-app-environments/xml-text-checks/verification.md b/docs/specs/desktop-app-environments/xml-text-checks/verification.md new file mode 100644 index 0000000000..4dd78eadcf --- /dev/null +++ b/docs/specs/desktop-app-environments/xml-text-checks/verification.md @@ -0,0 +1,167 @@ +# Verification + +Run on 2026-11-05 on top of the namespaced XML views branch, on macOS with +Node v22.23.1 and Python 3.11.14 (expat 2.7.4). + +## Gates + +- `npm run typecheck`, `format:check`, `build:cached` then `knip`, `jscpd`, + `docs:check`, `docs:build`, `verify:test-discovery`, + `verify:architecture-guards` and `verify:module-boundaries`: passed. + `lint` and `lint:typed` on the touched source and test files: no + diagnostics; jscpd lists no clone in a touched file. +- `npx vitest run src/domain/desktop src/application/desktop src/desktop + src/domain/xmlDocument.test.ts` with the eight `tests/boundary/providers/desktop*` + files and the CLI boundary tests `cliStartupImports`, + `desktopApplicationCli`, `cliInputSchemaRequired` and `cliToolCoverage`: + 492 passed and 18 skipped (Linux-only cases). +- The parity files `desktopXmlTextPython`, `desktopXmlPython`, + `desktopTaskPython`, `desktopZipPython` and `desktopExportPython`: 34 passed + under each of Python 3.9.6 (expat 2.2.8), 3.10, 3.11, 3.12, 3.13 and 3.14. + +## Tests + +Each new test fails on the code before this change, run from an export of +that commit with only the new tests, fixtures and test support added: 31 +failed and the 112 tests already there passed. + +- AbiWord, in `desktopXmlTextPython.test.ts`: a synthetic `.abw` in the form + AbiWord 3.0 saves, with its AWML DOCTYPE, before and after the meeting time + changed to `3:30`, which the edit split into `c` runs. Both readers give the + same `xml` view with the same `doctype`; before this change it was text. + Both evaluators give the same results and descriptions: `text_equals` and + `text_contains` on `[name=p][@xid=3]` fail before the edit and pass after + it, while `value_equals` on its `children/0` fails after it too. +- Writer runs: a Writer `content.xml` derived from the real 24.2 save, its body + paragraph split into `T1` spans with a bare space between two of them, + zipped into an `.odt`. Both readers give the same view, the space kept as + mixed content; `text_equals`, `text_contains` with `normalize_whitespace` + and a reflowed `text_equals` fail before and pass after in both evaluators, + and `children/0` does not pass. +- DOCTYPE: built documents both readers read alike (a name only, `SYSTEM` + with `[` in its literal, `PUBLIC` with whitespace to normalize, empty + literals, a prefixed name, a name that differs from the root's) and refuse + alike (an empty internal subset, entity declarations with and without a + reference, an external general entity, a parameter entity, an undeclared + entity in content and in an attribute value with an external DTD, a + `PUBLIC` without a system literal, an illegal public identifier character, + a DOCTYPE after the root, two DOCTYPEs). A DTD written to disk and named by + its `file:` URL is not read: its entity stays undeclared and its default + attribute absent, in both readers. +- Whitespace: mixed content keeps the space between runs and element content + drops formatting, in both readers; a no-break space, U+0085 and U+FEFF + between elements are kept as text in both. Before, both dropped the + no-break space, REA also U+FEFF and the exported module U+0085, so the two + disagreed on those. +- Shared check cases both evaluators run: the whole text of a split + paragraph, with nested elements, empty, a text node, an attribute value, + exact and collapsed, equal and contained; an absent file, JSON and text + files, a non-element value, a selector matching nothing, an element of a + truncated view and a selector over it (undecided) beside a text node there + (decided), an archive's XML entry, its JSON entry, its binary entry, a + pointer to an entry's name, and a truncated entry. +- Parser: the `external` policy reads a DOCTYPE without an internal subset and + refuses one with it, `]` in a literal included, and `declaredDocumentType` + reports the identifiers unquoted, the public one normalized. +- Task file: the empty pointer, a pointer without `/`, an empty or + whitespace-only `text_contains` value, a time-like value and a non-boolean + `normalize_whitespace` are refused; the export refuses a text check into a + text file. +- Draft: an element's whole text by selector in an SVG, in an archive's + namespaced XML and in the AbiWord note, with nothing suggested inside the + paragraph, and no text check where only whitespace text changed; through + the application, a capture whose `.odt` paragraph an edit split into runs + drafts `text_equals` on it, and the export keeps a task with it, failing on + the seed, while `value_equals` on its first run fails at the end. + +## Review fixes + +Run on 2026-11-05 with the same toolchain. Each fix has tests that fail on the +reviewed commit, run from an export of it with only the changed tests and +fixtures added (8 failed, 64 passed), and pass after it. The gates above pass +again: lint and typed lint report nothing in the touched files, and the +desktop set gives 497 passed and 18 skipped, the parity files 34 under each +of Python 3.9 to 3.14. + +- A drafted text check names the paragraph, not a run. A changed value is read + at both ends, so text an app wraps in a run (`

old

` becoming + `

new

`) still marks its paragraph; the check goes up from an + element in mixed content, and from one named by its tag or position alone + to the nearest ancestor named by an attribute, as AbiWord's `p` by `xid`. Edits + that replace text with a run, change the text of a paragraph's only run, or + of a run among the paragraph's own text all draft `text_equals` on + `[name=p][@xid=6]`, which passes on the edited save and on one splitting the + text into other runs; in an archive, text turned into two spans drafts the + `text:p`. A link whose label and `href` both change drafts the paragraph's + text and keeps `value_equals` on the new `href`. +- Text checks read only an element, a text node or an attribute's value: an + element's `name` and the `doctype`'s fields fail in both evaluators, in a + file or an archive entry, while an attribute's value in an entry passes. +- The tests of text checks assert outcomes and the facts in a description (the + pointer, the text found, the value compared) rather than its wording. + Rewording the descriptions in both evaluators passes them; showing the value + uncollapsed fails them. +- The draft leaves out times in C's `asctime` form, as AbiWord stamps + `abiword.date_last_changed`, which the task parser also refuses, and drafts + an XML file as an archive's entries are: by selector, never through an + element by position, and without whitespace text between elements. For the + AbiWord note it suggests the paragraph's text first and no save time; + `history`'s `version`, `edit-time` and `last-saved` remain, named. + +## Second review fixes + +Run on 2026-11-05 with the same toolchain. The new draft tests fail on the +reviewed commit, run with only the changed test file (6 failed, 23 passed), +and pass after it. `npm run typecheck`, `docs:check`, and lint and format on +the touched files pass; the desktop set (`src/domain/desktop`, +`src/domain/xmlDocument.test.ts` and the `tests/boundary/providers/desktop*` +files) gives 329 passed, and `src/application/desktop` with `src/desktop` +141 passed and 18 skipped. The drafts for the Inkscape, AbiWord, Writer and +FreeCAD fixtures are the same as on the reviewed commit. + +- A run added or removed whole marks its paragraph, the nearest ancestor both + ends hold, by the text anywhere in it. `Meeting at ` gaining + `3:30` drafts `text_equals` `Meeting at 3:30` on `[name=p][@xid=6]` + and no check that the run is there: a save splitting the text into other + runs passes it and `13:30` in the run fails it. So does a bold run added to + a paragraph made only of runs, though its `props` name it. Removing a run + drafts the paragraph's remaining text instead of the run's absence. + `

Hello

` gaining ` world`, or ` world` in a `b` inside + the span, drafts `Hello world` on the paragraph, in a body and as the + document's root, which `WRONG` in the span fails. +- A new paragraph `p[@xid=7]` drafts its section's text where the section is + short enough to check, and its own text where it is not, instead of a check + that it is there; other words in it and the seed fail both. +- An element the diff names by its tag alone, and an identifier of its own + names, takes the check ahead of the ancestor named by another attribute: an + SVG headline, the only `text` in a layer, drafts `text_equals` on + `[name=text][@id=headline]` rather than the layer, the `text`'s `id` chosen + over its `tspan`'s, and Inkscape's own save keeps its check on the + `text`, which the diff already names by its `id`. Another attribute names + it only where the layer cannot take the check: with only `xml:space`, the + check stays on a short layer, and on the `text` by `xml:space` past 2,000 + characters, where it had fallen back to `value_equals` on the `tspan`'s + first text node. A save splitting the headline across two `tspan`s passes + it and the old headline fails it. When the edit also changed the `id`, no + text check is drafted. + +## Third review fixes + +Run on 2026-11-06 with the same toolchain. Each new case fails on the +reviewed commit and passes after it. The desktop set gives 873 passed, with +only `desktopExportSqliteMetadata.test.ts` failing, as it does on `main` in +this environment; the XML parity files give 16 passed under each of Python 3.9 +to 3.14. The drafts for the Inkscape, AbiWord, Writer and FreeCAD fixtures, +as files and as archive entries, are the same as on the reviewed commit. + +- A paragraph's text check no longer stands in for an element holding no text + that an edit adds or removes in it. In an archive, `Hello` becoming + `Hello there` with a `draw:frame` holding an image drafts the paragraph's + text and the frame's presence, and the frame removed drafts its absence; in + an AbiWord paragraph, an `image` added or a `br` removed beside the text + edit does the same. A save with the new text alone fails the presence or + absence check, and a run holding text added in the same edit still gets no + check of its own. +- The exported module reports a DOCTYPE's system identifier with each line + ending a line feed, as REA's parser does: `"x\r\ny\rz\n"` reads as + `x\ny\nz\n` in both, where `expat` had passed it as written. diff --git a/src/application/desktop/DesktopArchiveTask.test.ts b/src/application/desktop/DesktopArchiveTask.test.ts index 057f757f2d..28a0824664 100644 --- a/src/application/desktop/DesktopArchiveTask.test.ts +++ b/src/application/desktop/DesktopArchiveTask.test.ts @@ -262,8 +262,8 @@ describe("documents saved as zip archives", () => { ).toBe(true); expect(task.checks.filter(({ file }) => file === LIST)).toEqual([ expect.objectContaining({ - kind: "value_equals", - pointer: `${LIST_TEXT}/[name=text:p]/children/0`, + kind: "text_equals", + pointer: `${LIST_TEXT}/[name=text:p]`, value: "Buy milk", }), ]); @@ -271,3 +271,58 @@ describe("documents saved as zip archives", () => { expect(result).not.toBeInstanceOf(Error); }); }); + +describe("paragraphs split into runs", () => { + it("drafts and exports a paragraph's whole text once an edit split it into runs, which its first run does not hold", async () => { + const run = (text: string) => + `${text}`; + const folder = await archiveCapture([ + odtArchive("Buy bread"), + odtArchive(`Buy ${run("oat")} ${run("milk")}`), + ]); + const paragraph = `${LIST_TEXT}/[name=text:p]`; + + const drafted = await draftDesktopApplicationTask(folder, readers); + if (drafted instanceof Error) throw drafted; + const { checks } = ( + drafted.normalized_result as { task: { checks: { file: string }[] } } + ).task; + expect(checks.filter(({ file }) => file === LIST)).toEqual([ + expect.objectContaining({ + kind: "text_equals", + pointer: paragraph, + value: "Buy oat milk", + }), + ]); + + const whole = await exportTask(folder, [ + HOLE, + { + kind: "text_equals", + file: LIST, + pointer: paragraph, + value: "Buy oat milk", + }, + ]); + if (whole.result instanceof Error) throw whole.result; + expect(whole.result.normalized_result).toMatchObject({ + task: { failing_on_seed: ["check-1", "check-2"] }, + }); + + const first = await exportTask(folder, [ + HOLE, + { + kind: "value_equals", + file: LIST, + pointer: `${paragraph}/children/0`, + value: "Buy oat milk", + }, + ]); + expect(first.result).toBeInstanceOf(Error); + // The first run holds only the text before the first span. + for (const fact of ['"check-2"', JSON.stringify("Buy ")]) + expect( + (first.result as Error & { userMessage: string }).userMessage, + ).toContain(fact); + }); +}); diff --git a/src/application/desktop/DesktopTaskExport.test.ts b/src/application/desktop/DesktopTaskExport.test.ts index ad4b839135..d0822a991e 100644 --- a/src/application/desktop/DesktopTaskExport.test.ts +++ b/src/application/desktop/DesktopTaskExport.test.ts @@ -197,6 +197,17 @@ describe("exportDesktopApplication with a task", () => { ]), "which is text; use lines_present", ], + [ + task([ + { + kind: "text_contains", + file: NOTE, + pointer: "/lines/0", + value: "milk", + }, + ]), + "which is text; use lines_present", + ], [ task([ { id: "eggs", kind: "lines_present", file: NOTE, lines: ["eggs"] }, diff --git a/src/application/desktop/DesktopTaskExport.ts b/src/application/desktop/DesktopTaskExport.ts index fb4b9c4e9d..ac04fcc47f 100644 --- a/src/application/desktop/DesktopTaskExport.ts +++ b/src/application/desktop/DesktopTaskExport.ts @@ -1,5 +1,6 @@ import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { + isPointerCheck, parseDesktopTask, type DesktopTask, type DesktopTaskCheck, @@ -147,8 +148,7 @@ const targetProblem = ( if (named !== undefined) return `names ${named}, the capture host's name or user, which the export removes from the seed`; } - if (check.kind !== "value_equals" && check.kind !== "value_absent") - return undefined; + if (!isPointerCheck(check)) return undefined; return [views.seed, views.end].some( (at) => at.get(check.file)?.format === "text", ) diff --git a/src/domain/desktop/appExportPythonXml.ts b/src/domain/desktop/appExportPythonXml.ts index 30c7571c48..c912593771 100644 --- a/src/domain/desktop/appExportPythonXml.ts +++ b/src/domain/desktop/appExportPythonXml.ts @@ -1,32 +1,68 @@ /** The generated module's XML reader: REA's XML view of a document, built with expat. */ -export const PYTHON_XML_READER = String.raw`def _expat(namespaces: bool): - """An expat parser that refuses DOCTYPEs as REA does; with namespaces it also enforces their rules, as REA's - parser does, and without them it reports names as the document spells them.""" +export const PYTHON_XML_READER = String.raw`#: XML's whitespace, which alone can be formatting between elements; Unicode's wider set is text. +XML_WHITESPACE = " \t\r\n" +PREDEFINED_ENTITIES = ("amp", "lt", "gt", "apos", "quot") +ENTITY_REFERENCE = re.compile(r"&([^#;][^;]*);") +#: A line ending as XML normalizes it before parsing; expat leaves a DOCTYPE's system literal as written. +XML_LINE_ENDING = re.compile(r"\r\n?") + + +def _expat(namespaces: bool, doctype=None): + """An expat parser that reads a DOCTYPE as REA does: one with an internal subset, which could declare entities, + is refused, the DTD it names is never read, and a reference to an entity other than XML's five is refused where + expat would skip it. With namespaces it also enforces their rules, as REA's parser does, and without them it + reports names as the document spells them. doctype, when given, receives the declared name and identifiers.""" parser = expat.ParserCreate(namespace_separator=" " if namespaces else None) + parser.SetParamEntityParsing(expat.XML_PARAM_ENTITY_PARSING_NEVER) - def doctype(*_) -> None: - raise ValueError("the document declares a DOCTYPE") + def declared(name: str, system_id, public_id, internal_subset: bool) -> None: + if internal_subset: + raise ValueError("the document's DOCTYPE has an internal subset") + if doctype is not None: + if system_id is not None: + system_id = XML_LINE_ENDING.sub("\n", system_id) + doctype({"name": name, "public_id": public_id, "system_id": system_id}) - parser.StartDoctypeDeclHandler = doctype + def undeclared(name: str, _parameter: bool) -> None: + raise ValueError(f"the document refers to the undeclared entity {name}") + + def external(*_) -> None: + raise ValueError("the document refers to an external entity") + + parser.StartDoctypeDeclHandler = declared + parser.SkippedEntityHandler = undeclared + parser.ExternalEntityRefHandler = external return parser +def _check_tag(markup: str) -> None: + """expat leaves an undeclared entity out of an attribute value without a word when a DOCTYPE names a DTD; REA's + parser refuses it. With text taken by its own handler, a start tag is the only markup expat passes here that + starts with < and a name, and it passes it whole.""" + if markup[:1] == "<" and markup[1:2] not in ("!", "?", "/"): + for name in ENTITY_REFERENCE.findall(markup): + if name not in PREDEFINED_ENTITIES: + raise ValueError(f"the document refers to the undeclared entity {name}") + + +def _ignore_text(_text: str) -> None: + pass + + class _XmlReader: """REA's XML view built as expat reads: names as spelled, declarations as attributes, text split where REA's DOM splits it (an empty CDATA section joins the text around it), and each node counted against REA's limits.""" - #: Match REA String.trim(); default strip also drops U+0085 and keeps U+FEFF. - FORMATTING_SPACE = "\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff" - def __init__(self) -> None: - self.remaining, self.truncated, self.root = MAX_XML_NODES, False, None - #: Per open element: its view or None when left out, whether xml:space="preserve" applies inside it, and - #: whether it holds an element, which makes whitespace-only text in it formatting. + self.remaining, self.truncated, self.root, self.doctype = MAX_XML_NODES, False, None, None + #: Per open element: its view or None when left out, whether xml:space="preserve" applies inside it, whether + #: it holds an element, and whether it holds text other than whitespace: whitespace-only text is formatting + #: only in an element that holds elements and no such text. self.open = [] self.text, self.cdata = [], None def read(self, text: str) -> None: - parser = _expat(False) + parser = _expat(False, self._doctype) parser.buffer_text = True parser.StartElementHandler, parser.EndElementHandler = self._start, self._end parser.CharacterDataHandler = self._data @@ -34,6 +70,9 @@ class _XmlReader: parser.CommentHandler = parser.ProcessingInstructionHandler = self._other parser.Parse(text, True) + def _doctype(self, declared: dict) -> None: + self.doctype = declared + def _admit(self) -> bool: """Whether the next node inside a kept element is kept, within the node and depth limits; one past them marks the view truncated.""" @@ -46,6 +85,8 @@ class _XmlReader: return True def _text_node(self, text: str) -> None: + if self.open and text.strip(XML_WHITESPACE): + self.open[-1][3] = True if self._admit(): self.open[-1][0]["children"].append(text) @@ -74,31 +115,35 @@ class _XmlReader: def _start(self, name: str, attributes: dict) -> None: self._flush() - parent = self.open[-1] if self.open else [None, False, False] + parent = self.open[-1] if self.open else [None, False, False, False] parent[2] = True view = {"name": name, "attributes": attributes, "children": []} if self._admit() else None if view is not None and parent[0] is not None: parent[0]["children"].append(view) space = attributes.get(XML_SPACE) - self.open.append([view, space == "preserve" if space else parent[1], False]) + self.open.append([view, space == "preserve" if space else parent[1], False, False]) def _end(self, _name: str) -> None: self._flush() - view, preserve, holds_element = self.open.pop() - if view is not None and holds_element and not preserve: - view["children"] = [child for child in view["children"] if not isinstance(child, str) or child.strip(self.FORMATTING_SPACE)] + view, preserve, holds_element, holds_text = self.open.pop() + if view is not None and holds_element and not holds_text and not preserve: + view["children"] = [child for child in view["children"] + if not isinstance(child, str) or child.strip(XML_WHITESPACE)] if not self.open: self.root = view def _xml_view(text: str): """REA's XML view, or None for a document REA's parser refuses: a pass with namespaces checks the document as REA's - parser does, then a pass without them reads names as spelled, as REA's view keeps them.""" + parser does, then a pass without them reads names as spelled, as REA's view keeps them, and the DOCTYPE.""" reader = _XmlReader() try: - _expat(True).Parse(text, True) + check = _expat(True) + check.CharacterDataHandler, check.DefaultHandler = _ignore_text, _check_tag + check.Parse(text, True) reader.read(text) except (expat.ExpatError, ValueError): return None - return {"root": reader.root, "truncated": reader.truncated} + view = {"root": reader.root, "truncated": reader.truncated} + return view if reader.doctype is None else {"doctype": reader.doctype, **view} `; diff --git a/src/domain/desktop/appTask.ts b/src/domain/desktop/appTask.ts index 8cc0566ace..71954d3d2e 100644 --- a/src/domain/desktop/appTask.ts +++ b/src/domain/desktop/appTask.ts @@ -25,6 +25,15 @@ const comparison = { at_most: count.optional(), }; const table = z.string().min(1); +const wholeText = { + pointer: z.string(), + value: z.string(), + normalize_whitespace: z.boolean().optional(), +}; + +/** Text with each run of XML whitespace (space, tab, CR, LF) made one space and none at either end, as XPath's normalize-space(). */ +export const collapseWhitespace = (text: string): string => + text.replace(/[ \t\r\n]+/gu, " ").replace(/^ | $/gu, ""); /** One kind of check: its id, kind and file, then its own fields. */ const kind = ( @@ -54,6 +63,8 @@ const checkSchema = z.discriminatedUnion("kind", [ tolerance: z.number().min(0).optional(), }), kind("value_absent", { pointer: z.string() }), + kind("text_equals", wholeText), + kind("text_contains", { ...wholeText, value: z.string().min(1) }), kind("row_present", { table, where }), kind("row_absent", { table, where }), kind("row_count", { table, where: where.optional(), ...comparison }), @@ -91,23 +102,46 @@ export const countComparison = ( return given.length === 1 ? given[0] : undefined; }; +/** Checks that read a view at a JSON pointer. */ +export type PointerCheck = Extract< + DesktopTaskCheck, + { kind: "value_equals" | "value_absent" | "text_equals" | "text_contains" } +>; + +export const isPointerCheck = ( + check: DesktopTaskCheck, +): check is PointerCheck => + check.kind === "value_equals" || + check.kind === "value_absent" || + check.kind === "text_equals" || + check.kind === "text_contains"; + /** A home-relative path in its one spelling: no leading slash, empty, `.` or `..` segment. */ const isHomePath = (path: string): boolean => path .split("/") .every((segment) => segment !== "" && segment !== "." && segment !== ".."); -/** Whether a value looks like an ISO 8601 date and time, which the app stamps rather than the user writes. */ -export const isTimestamp = (value: JsonValue): boolean => - typeof value === "string" && - /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:[.,]\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/u.test( - value.trim(), - ); +/** An ISO 8601 date and time. */ +const ISO_TIMESTAMP = + /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:[.,]\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/u; +/** C's `asctime` and `ctime` form, `Www Mmm dd hh:mm:ss yyyy`, the day padded with a space, as AbiWord stamps a save. */ +const ASCTIME_TIMESTAMP = + /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [ 0-3]?\d \d{2}:\d{2}:\d{2} \d{4}$/u; + +/** Whether a value looks like a date and time the app stamps rather than the user writes: ISO 8601, or `asctime`'s form. */ +export const isTimestamp = (value: JsonValue): boolean => { + if (typeof value !== "string") return false; + const trimmed = value.trim(); + return ISO_TIMESTAMP.test(trimmed) || ASCTIME_TIMESTAMP.test(trimmed); +}; /** Expected values a check compares, for the timestamp guard. */ const expectedValues = (check: DesktopTaskCheck): readonly JsonValue[] => { switch (check.kind) { case "value_equals": + case "text_equals": + case "text_contains": return [check.value]; case "row_present": case "row_absent": @@ -118,6 +152,19 @@ const expectedValues = (check: DesktopTaskCheck): readonly JsonValue[] => { } }; +/** What is wrong with a pointer check's pointer, or with what a text check searches for, or undefined. */ +const pointerProblem = (check: PointerCheck): string | undefined => { + if (check.pointer === "") + return "the empty pointer names the whole file; point at the value that changed"; + if (!check.pointer.startsWith("/")) + return `pointer ${JSON.stringify(check.pointer)} must start with /`; + return check.kind === "text_contains" && + check.normalize_whitespace === true && + collapseWhitespace(check.value) === "" + ? "text_contains needs a value with more than whitespace in it" + : undefined; +}; + /** What is wrong with one check beyond its shape, or undefined. */ const checkProblem = (check: DesktopTaskCheck): string | undefined => { if (!isHomePath(check.file)) @@ -132,16 +179,8 @@ const checkProblem = (check: DesktopTaskCheck): string | undefined => { countComparison(check) === undefined ) return `${check.kind} takes exactly one of equals, at_least and at_most`; - if ( - (check.kind === "value_equals" || check.kind === "value_absent") && - check.pointer === "" - ) - return "the empty pointer names the whole file; point at the value that changed"; - if ( - (check.kind === "value_equals" || check.kind === "value_absent") && - !check.pointer.startsWith("/") - ) - return `pointer ${JSON.stringify(check.pointer)} must start with /`; + const pointed = isPointerCheck(check) ? pointerProblem(check) : undefined; + if (pointed !== undefined) return pointed; if ( (check.kind === "lines_present" || check.kind === "lines_absent") && new Set(check.lines).size !== check.lines.length diff --git a/src/domain/desktop/appTaskChecks.fixture.ts b/src/domain/desktop/appTaskChecks.fixture.ts index 702ed50830..b89e5a1210 100644 --- a/src/domain/desktop/appTaskChecks.fixture.ts +++ b/src/domain/desktop/appTaskChecks.fixture.ts @@ -166,6 +166,32 @@ export const CHECK_VIEWS: Readonly> = { truncated: true, }, }, + "Documents/meeting.xml": { + format: "xml", + view: { + doctype: { name: "doc", public_id: null, system_id: "meeting.dtd" }, + root: element("doc", { lang: "en" }, [ + element("p", { id: "time" }, [ + "Starts at ", + element("c", {}, ["3"]), + ":", + element("c", {}, ["30"]), + " today.", + ]), + element("p", { id: "spaced" }, [ + "\n Doors\topen ", + element("b", {}, [" early "]), + "\n", + ]), + element("p", { id: "empty" }), + element("p", { id: "deep" }, [ + element("q", {}, [element("r", {}, ["x"]), "y"]), + "z", + ]), + ]), + truncated: false, + }, + }, "Documents/photo.png": { format: "binary", view: null }, "Documents/huge.txt": { format: null, @@ -487,6 +513,289 @@ const TOLERANCE_CASES: readonly (readonly [DesktopTaskCheckInput, boolean])[] = ], ]; +const meeting = "Documents/meeting.xml"; +const paragraph = (id: string) => `/root/children/[name=p][@id=${id}]`; + +/** + * Strings of an XML view that are no text of the document: a tag name, the + * DOCTYPE's fields. Each holds the value checked, and each fails; an + * attribute's value in an archive entry is text and passes. + */ +const METADATA_CASES: readonly (readonly [DesktopTaskCheckInput, boolean])[] = [ + [ + { kind: "text_equals", file: meeting, pointer: "/root/name", value: "doc" }, + false, + ], + [ + { + kind: "text_contains", + file: meeting, + pointer: `${paragraph("time")}/name`, + value: "p", + }, + false, + ], + [ + { + kind: "text_equals", + file: meeting, + pointer: "/doctype/system_id", + value: "meeting.dtd", + }, + false, + ], + [ + { + kind: "text_equals", + file: meeting, + pointer: "/doctype/name", + value: "doc", + }, + false, + ], + [ + { + kind: "text_equals", + file: plate, + pointer: "/entries/[name=Document.xml]/view/root/name", + value: "Document", + }, + false, + ], + [ + { + kind: "text_equals", + file: plate, + pointer: `${objects}/[name=Object][@name=Hole]/attributes/type`, + value: "Part::Cylinder", + }, + true, + ], +]; + +/** An element's whole text, its runs and descendants included, compared or searched, exactly or with whitespace collapsed. */ +const TEXT_CASES: readonly (readonly [DesktopTaskCheckInput, boolean])[] = [ + [ + { + kind: "text_equals", + file: meeting, + pointer: paragraph("time"), + value: "Starts at 3:30 today.", + }, + true, + ], + [ + { + kind: "text_equals", + file: meeting, + pointer: paragraph("time"), + value: "Starts at 3:30", + }, + false, + ], + [ + { + kind: "text_contains", + file: meeting, + pointer: paragraph("time"), + value: "3:30", + }, + true, + ], + [ + { + kind: "text_contains", + file: meeting, + pointer: paragraph("time"), + value: "3:45", + }, + false, + ], + [ + { + kind: "text_equals", + file: meeting, + pointer: paragraph("spaced"), + value: "Doors open early", + normalize_whitespace: true, + }, + true, + ], + [ + { + kind: "text_equals", + file: meeting, + pointer: paragraph("spaced"), + value: "Doors open early", + }, + false, + ], + [ + { + kind: "text_contains", + file: meeting, + pointer: paragraph("spaced"), + value: "open\n early", + normalize_whitespace: true, + }, + true, + ], + [ + { + kind: "text_contains", + file: meeting, + pointer: paragraph("spaced"), + value: "open early", + }, + false, + ], + [ + { + kind: "text_equals", + file: meeting, + pointer: paragraph("empty"), + value: "", + }, + true, + ], + [ + { + kind: "text_equals", + file: meeting, + pointer: paragraph("deep"), + value: "xyz", + }, + true, + ], + [ + { + kind: "text_equals", + file: meeting, + pointer: `${paragraph("time")}/children/0`, + value: "Starts at ", + }, + true, + ], + [ + { + kind: "text_equals", + file: meeting, + pointer: "/root/attributes/lang", + value: "en", + }, + true, + ], + [ + { + kind: "text_equals", + file: meeting, + pointer: "/root/attributes", + value: "en", + }, + false, + ], + ...METADATA_CASES, + [ + { + kind: "text_equals", + file: meeting, + pointer: paragraph("gone"), + value: "x", + }, + false, + ], + [{ kind: "text_equals", file: absent, pointer: "/root", value: "" }, false], + [ + { kind: "text_equals", file: settings, pointer: "/tags/0", value: "a" }, + false, + ], + [ + { kind: "text_contains", file: todo, pointer: "/lines/0", value: "b" }, + false, + ], + [ + { + kind: "text_equals", + file: "Documents/cut.xml", + pointer: "/root/children/0", + value: "", + }, + false, + ], + [ + { + kind: "text_equals", + file: "Documents/cut.xml", + pointer: "/root/children/1", + value: "kept", + }, + true, + ], + [ + { + kind: "text_contains", + file: "Documents/cut.xml", + pointer: "/root/children/[name=p]", + value: "x", + }, + false, + ], + [ + { + kind: "text_equals", + file: plate, + pointer: "/entries/[name=Document.xml]/view/root", + value: "saved by hand", + }, + true, + ], + [ + { + kind: "text_equals", + file: plate, + pointer: "/entries/[name=empty.json]/view/x", + value: "x", + }, + false, + ], + [ + { + kind: "text_equals", + file: plate, + pointer: "/entries/0/name", + value: "Document.xml", + }, + false, + ], + [ + { + kind: "text_equals", + file: plate, + pointer: "/entries/[name=thumbnails~1Thumbnail.png]/view", + value: "", + }, + false, + ], + [ + { + kind: "text_equals", + file: "Documents/listing.zip", + pointer: "/entries/1/view/root", + value: "", + }, + false, + ], + [ + { + kind: "text_equals", + file: "Documents/photo.png", + pointer: "/root", + value: "", + }, + false, + ], +]; + /** Every kind of check against those views, each with the outcome both evaluators must give. */ export const CHECK_CASES: readonly (readonly [ DesktopTaskCheckInput, @@ -735,4 +1044,5 @@ export const CHECK_CASES: readonly (readonly [ [{ kind: "row_count", file: absent, table: "notes", equals: 0 }, true], ...SELECTOR_CASES, ...TOLERANCE_CASES, + ...TEXT_CASES, ]; diff --git a/src/domain/desktop/appTaskChecks.test.ts b/src/domain/desktop/appTaskChecks.test.ts index a050a5148a..fd6ed0a448 100644 --- a/src/domain/desktop/appTaskChecks.test.ts +++ b/src/domain/desktop/appTaskChecks.test.ts @@ -101,6 +101,32 @@ const REFUSALS: readonly (readonly [Record, string])[] = [ }, "looks like a time the app stamps", ], + [ + { + checks: [ + { + kind: "text_equals", + file: "a", + pointer: "/m", + value: "Thu Nov 5 22:03:14 2026\n", + }, + ], + }, + "looks like a time the app stamps", + ], + [ + { + checks: [ + { + kind: "row_present", + file: "a", + table: "t", + where: { saved: "Mon Jan 12 07:00:00 2026" }, + }, + ], + }, + "looks like a time the app stamps", + ], [ { checks: [ @@ -146,6 +172,63 @@ const REFUSALS: readonly (readonly [Record, string])[] = [ }, "at checks.0.tolerance", ], + [ + { checks: [{ kind: "text_equals", file: "a", pointer: "", value: "x" }] }, + "the empty pointer names the whole file", + ], + [ + { + checks: [{ kind: "text_contains", file: "a", pointer: "p", value: "x" }], + }, + "must start with /", + ], + [ + { + checks: [{ kind: "text_contains", file: "a", pointer: "/p", value: "" }], + }, + "at checks.0.value", + ], + [ + { + checks: [ + { + kind: "text_contains", + file: "a", + pointer: "/p", + value: " \n", + normalize_whitespace: true, + }, + ], + }, + "text_contains", + ], + [ + { + checks: [ + { + kind: "text_equals", + file: "a", + pointer: "/p", + value: "2026-11-04T10:12:00Z", + }, + ], + }, + "looks like a time the app stamps", + ], + [ + { + checks: [ + { + kind: "text_equals", + file: "a", + pointer: "/p", + value: "x", + normalize_whitespace: "yes", + }, + ], + }, + "at checks.0.normalize_whitespace", + ], ]; describe("evaluateTaskChecks", () => { @@ -310,6 +393,134 @@ describe("evaluateTaskChecks selectors and tolerance", () => { }); }); +describe("evaluateTaskChecks whole text", () => { + const outcome = (check: Record) => + evaluateTaskChecks( + [{ id: "c", ...check } as never], + new Map(Object.entries(CHECK_VIEWS)), + )[0]; + const meeting = "Documents/meeting.xml"; + const plate = "Documents/plate.FCStd"; + const time = "/root/children/[name=p][@id=time]"; + /** A value as a description shows it: caller data and what the view holds, as JSON. */ + const quoted = (value: unknown) => JSON.stringify(value); + const expectFacts = ( + found: { passed: boolean; description: string } | undefined, + passed: boolean, + facts: readonly string[], + ) => { + expect(found?.passed).toBe(passed); + for (const fact of facts) expect(found?.description).toContain(fact); + }; + + it("reports the text an element has, its runs joined, beside the value it was compared with", () => { + expectFacts( + outcome({ + kind: "text_equals", + file: meeting, + pointer: time, + value: "Starts at 3:45 today.", + }), + false, + [ + meeting, + time, + quoted("Starts at 3:30 today."), + quoted("Starts at 3:45 today."), + ], + ); + expectFacts( + outcome({ + kind: "text_contains", + file: meeting, + pointer: time, + value: "3:45", + }), + false, + [quoted("Starts at 3:30 today."), quoted("3:45")], + ); + const collapsed = outcome({ + kind: "text_contains", + file: meeting, + pointer: "/root/children/[name=p][@id=spaced]", + value: "Doors open", + normalize_whitespace: true, + }); + expectFacts(collapsed, true, [quoted("Doors open")]); + expect(collapsed?.description).not.toContain(quoted("Doors open")); + }); + + it("reads only an XML element, a text node or an attribute value, and names what it reached instead", () => { + const refused: readonly (readonly [Record, string[]])[] = [ + [ + { file: ".config/notes/settings.json", pointer: "/tags/0", value: "a" }, + [".config/notes/settings.json", "json"], + ], + [ + { file: meeting, pointer: "/root/attributes", value: "en" }, + ["/root/attributes", quoted({ lang: "en" })], + ], + [ + { file: meeting, pointer: "/root/name", value: "doc" }, + ["/root/name", quoted("doc")], + ], + [ + { file: meeting, pointer: "/doctype/system_id", value: "meeting.dtd" }, + ["/doctype/system_id", quoted("meeting.dtd")], + ], + [ + { + file: plate, + pointer: "/entries/[name=empty.json]/view/x", + value: "x", + }, + ["empty.json", "json"], + ], + [ + { file: plate, pointer: "/entries/0/name", value: "Document.xml" }, + ["/entries/0/name"], + ], + ]; + + for (const [check, facts] of refused) + expectFacts(outcome({ kind: "text_equals", ...check }), false, facts); + }); + + it("leaves an element of a truncated view undecided as a value check is, while a text node there is decided", () => { + const cut = "Documents/cut.xml"; + + expect( + outcome({ + kind: "text_equals", + file: cut, + pointer: "/root/children/0", + value: "", + }), + ).toEqual( + outcome({ + kind: "value_absent", + file: cut, + pointer: "/root/attributes/x", + }), + ); + expect( + outcome({ + kind: "value_absent", + file: cut, + pointer: "/root/attributes/x", + })?.passed, + ).toBe(false); + expect( + outcome({ + kind: "text_equals", + file: cut, + pointer: "/root/children/1", + value: "kept", + })?.passed, + ).toBe(true); + }); +}); + describe("parseDesktopTask", () => { it("settles ids and keeps the prompt trimmed", () => { const parsed = parseDesktopTask( diff --git a/src/domain/desktop/appTaskChecks.ts b/src/domain/desktop/appTaskChecks.ts index c423923a0d..4e4c84a715 100644 --- a/src/domain/desktop/appTaskChecks.ts +++ b/src/domain/desktop/appTaskChecks.ts @@ -1,10 +1,16 @@ import type { JsonValue } from "../jsonValue.js"; -import { countComparison, type DesktopTaskCheck } from "./appTask.js"; +import { + collapseWhitespace, + countComparison, + type DesktopTaskCheck, + type PointerCheck, +} from "./appTask.js"; import type { DesktopStatePath } from "./desktopState.js"; import { locatePointer, type Located } from "./viewPointer.js"; import { viewlessEntryAt, zipArrayLeavesOut, + zipEntryAt, zipLeavesOutAt, } from "./zipView.js"; @@ -197,12 +203,12 @@ const nearVerdict = ( }; /** - * Why a value check cannot be decided on what the view holds: the entry whose + * Why a pointer check cannot be decided on what the view holds: the entry whose * view its pointer goes into has none, or a selector searched an array the view * may hold only part of. Undefined when it can be. */ const unreadOrPartial = ( - check: Check<"value_equals" | "value_absent">, + check: PointerCheck, format: string | null | undefined, read: { readonly view: JsonValue | null; readonly truncated: boolean }, located: Located, @@ -217,16 +223,24 @@ const unreadOrPartial = ( return located.searched.some(partial) ? undecided(check.file) : undefined; }; +const NOTHING: Located = { kind: "none", searched: [], byPosition: [] }; + +/** A value found where its pointer leads, or why it is none. */ +const noValue = (at: string, located: Located & { kind: "none" }) => + fail( + located.reason === undefined + ? `${at} has no value` + : `${at}: ${located.reason}`, + ); + const valueVerdict = ( check: Check<"value_equals" | "value_absent">, entry: TaskFileView | undefined, ): Verdict => { const read = viewOf(check, entry, undefined); if ("passed" in read) return read; - const located: Located = - entry === undefined - ? { kind: "none", searched: [], byPosition: [] } - : locatePointer(read.view, check.pointer); + const located = + entry === undefined ? NOTHING : locatePointer(read.view, check.pointer); const at = `${check.file} at ${check.pointer}`; const unknown = unreadOrPartial(check, entry?.format, read, located); if (unknown !== undefined) return unknown; @@ -243,12 +257,7 @@ const valueVerdict = ( check.value !== null; if (whole && truncated) return undecided(check.file); if (check.kind === "value_equals") { - if (located.kind === "none") - return fail( - located.reason === undefined - ? `${at} has no value` - : `${at}: ${located.reason}`, - ); + if (located.kind === "none") return noValue(at, located); if (check.tolerance !== undefined && typeof check.value === "number") return nearVerdict(at, located.value, check.value, check.tolerance); return sameJson(located.value, check.value) @@ -261,6 +270,116 @@ const valueVerdict = ( : fail(`${at} is ${shown(located.value)}`); }; +/** An element of REA's XML view: its tag name, attributes and children. */ +export const isXmlElement = ( + value: JsonValue | null | undefined, +): value is Record & { + name: string; + children: JsonValue[]; +} => + isRecord(value) && + typeof value.name === "string" && + isRecord(value.attributes) && + Array.isArray(value.children); + +/** + * An element's text as XPath's `string()` reads it from what the view keeps: + * every text node among its children and its descendants', in document order. + */ +export const elementText = (element: JsonValue): string => { + const parts: string[] = []; + const pending: JsonValue[] = [element]; + for (let node = pending.pop(); node !== undefined; node = pending.pop()) + if (typeof node === "string") parts.push(node); + else if (isXmlElement(node)) + for (let index = node.children.length - 1; index >= 0; index -= 1) + pending.push(node.children[index] ?? null); + return parts.join(""); +}; + +/** + * Whether a string a pointer reaches is text of the document: a member of an + * element's `children` (a text node) or `attributes` (an attribute's value), + * rather than an element's name or a field of the DOCTYPE. + */ +const isTextAt = (view: JsonValue | null, pointer: string): boolean => { + const segments = pointer.split("/"); + const field = segments.at(-2); + if (field !== "children" && field !== "attributes") return false; + const owner = locatePointer(view, segments.slice(0, -2).join("/")); + return owner.kind === "value" && isXmlElement(owner.value); +}; + +/** Why a text check's pointer into an archive goes into no XML entry's view, or undefined when it does or names an entry not listed. */ +const notXmlEntry = ( + check: Check<"text_equals" | "text_contains">, + view: JsonValue | null, +): string | undefined => { + if (check.pointer.split("/")[3] !== "view") + return `${check.file} at ${check.pointer} does not point into an entry's view`; + const entry = zipEntryAt(view, check.pointer); + if (typeof entry !== "object" || entry.format === "xml") return undefined; + // An entry without a view failed before this, so its format is one with a view. + const text = (value: JsonValue | undefined) => + typeof value === "string" ? value : ""; + return `${check.file} entry ${text(entry.name)} is read as ${text(entry.format)}, not xml`; +}; + +/** A text found at `at` compared with the check's value or searched for it, both collapsed when it asks. */ +const comparedText = ( + check: Check<"text_equals" | "text_contains">, + at: string, + found: string, +): Verdict => { + const collapse = check.normalize_whitespace === true; + const text = collapse ? collapseWhitespace(found) : found; + const value = collapse ? collapseWhitespace(check.value) : check.value; + const how = collapse ? " with whitespace collapsed" : ""; + if (check.kind === "text_equals") + return text === value + ? pass(`${at} has the text ${shown(value)}${how}`) + : fail(`${at} has the text ${shown(text)}${how}, not ${shown(value)}`); + return text.includes(value) + ? pass(`${at} has text containing ${shown(value)}${how}`) + : fail(`${at} has the text ${shown(text)}${how}, without ${shown(value)}`); +}; + +/** An element's whole text, or a text node's, compared with `value` or searched for it. */ +const textVerdict = ( + check: Check<"text_equals" | "text_contains">, + entry: TaskFileView | undefined, +): Verdict => { + const read = viewOf(check, entry, undefined); + if ("passed" in read) return read; + const at = `${check.file} at ${check.pointer}`; + if (entry === undefined) return fail(`${at} has no value`); + const zip = entry.format === "zip"; + if (!zip && entry.format !== "xml") + return fail(`${check.file} is read as ${String(entry.format)}, not xml`); + const located = locatePointer(read.view, check.pointer); + const unknown = unreadOrPartial(check, entry.format, read, located); + if (unknown !== undefined) return unknown; + const problem = zip ? notXmlEntry(check, read.view) : undefined; + if (problem !== undefined) return fail(problem); + if (located.kind === "unresolved") return fail(`${at}: ${located.problem}`); + if (located.kind === "none") return noValue(at, located); + const found = located.value; + if ( + typeof found === "string" + ? !isTextAt(read.view, check.pointer) + : !isXmlElement(found) + ) + return fail( + `${at} is ${shown(found)}, not an element, a text node or an attribute value`, + ); + // A text node is whole; an element's text is whole only if its view left nothing out. + const truncated = zip + ? zipLeavesOutAt(read.view, check.pointer) + : read.truncated; + if (typeof found !== "string" && truncated) return undecided(check.file); + return comparedText(check, at, elementText(found)); +}; + /** A table's columns and rows; a table the view lacks has none, unless the view left tables out. */ const tableOf = ( check: Check<"row_present" | "row_absent" | "row_count">, @@ -358,6 +477,9 @@ const verdict = ( case "value_equals": case "value_absent": return valueVerdict(check, entry); + case "text_equals": + case "text_contains": + return textVerdict(check, entry); case "row_present": case "row_absent": case "row_count": diff --git a/src/domain/desktop/appTaskDraft.test.ts b/src/domain/desktop/appTaskDraft.test.ts index 56d461129c..061fb4e478 100644 --- a/src/domain/desktop/appTaskDraft.test.ts +++ b/src/domain/desktop/appTaskDraft.test.ts @@ -1,9 +1,15 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + import { describe, expect, it } from "vitest"; import type { JsonValue } from "../jsonValue.js"; -import { draftTaskFiles } from "./appTaskDraft.js"; +import type { DesktopTaskCheck } from "./appTask.js"; import { evaluateTaskChecks } from "./appTaskChecks.js"; +import { draftTaskFiles, type TaskDraftFile } from "./appTaskDraft.js"; import type { DesktopStatePath } from "./desktopState.js"; +import { xmlView } from "./stateFormats.js"; +import { locatePointer } from "./viewPointer.js"; type Format = DesktopStatePath["format"]; @@ -272,7 +278,7 @@ describe("draftTaskFiles for structured views", () => { }); describe("draftTaskFiles for XML that uses namespaces", () => { - it("suggests values inside an XML file by pointer, as for any XML", () => { + it("suggests an element's whole text where its text changed, named by selector", () => { const poster = (headline: string): JsonValue => ({ root: { name: "svg", @@ -302,15 +308,725 @@ describe("draftTaskFiles for XML that uses namespaces", () => { expect(document?.suggested).toEqual([ { - kind: "value_equals", + kind: "text_equals", file: "Pictures/poster.svg", - pointer: "/root/children/0/children/0", + pointer: "/root/children/[name=text]", value: "Summer Bake Sale", }, ]); }); }); +describe("draftTaskFiles for an AbiWord document", () => { + const FIXTURES = join(import.meta.dirname, "../../../tests/fixtures"); + const PARAGRAPH = "/root/children/[name=section]/children/[name=p][@xid=3]"; + + it("suggests an AbiWord paragraph's whole text first, no save time, and nothing by position", async () => { + const [before, after] = await Promise.all( + ["before", "after"].map(async (end) => + xmlView( + await readFile(join(FIXTURES, "abiword", end, "meeting.abw"), "utf8"), + ), + ), + ); + if (before === undefined || after === undefined) throw new Error("fixture"); + + const [document] = draft({ + "Documents/meeting.abw": path("document", "xml", before, after), + }); + const suggested = document?.suggested ?? []; + + expect(suggested[0]).toEqual({ + kind: "text_equals", + file: "Documents/meeting.abw", + pointer: PARAGRAPH, + value: "The team meeting starts at 3:30 in room 4.", + }); + const pointers = suggested.flatMap((check) => + "pointer" in check ? [check.pointer] : [], + ); + expect(pointers).toHaveLength(suggested.length); + // AbiWord stamps its save time as asctime does: Thu Nov 5 09:20:31 2026. + expect( + suggested.filter( + (check) => + "value" in check && + typeof check.value === "string" && + check.value.includes("09:20:31"), + ), + ).toEqual([]); + // Every check names the elements it goes through, so a reordered save keeps it. + for (const pointer of pointers) { + const located = locatePointer(after, pointer); + expect([pointer, located.byPosition]).toEqual([pointer, []]); + } + expect( + pointers.filter((pointer) => pointer.startsWith(`${PARAGRAPH}/`)), + ).toEqual([]); + }); +}); + +describe("draftTaskFiles for text split into runs", () => { + it("suggests a paragraph's text, not a run's, wherever an edit leaves it, and the check holds however a later save splits it", () => { + const run = (text: string) => + xmlElement("c", { props: "lang:en-US" }, [text]); + const note = (sixth: JsonValue[]): JsonValue => ({ + root: xmlElement("abiword", {}, [ + xmlElement("section", { xid: "1" }, [ + xmlElement("p", { style: "Normal", xid: "5" }, ["Agenda"]), + xmlElement("p", { style: "Normal", xid: "6" }, sixth), + ]), + ]), + truncated: false, + }); + const sixth = "/root/children/[name=section]/children/[name=p][@xid=6]"; + const edits: readonly (readonly [JsonValue[], JsonValue[], string])[] = [ + // Text replaced by a run, as AbiWord types into a reopened document. + [["Meeting at 2:00"], [run("Meeting at 3:30")], "Meeting at 3:30"], + // Text edited inside the paragraph's only run. + [[run("Meeting at 2:00")], [run("Meeting at 3:30")], "Meeting at 3:30"], + // Text edited inside a run among the paragraph's own text. + [ + ["Meeting at ", run("2:00")], + ["Meeting at ", run("3:30")], + "Meeting at 3:30", + ], + ]; + + for (const [old, edited, text] of edits) { + const [file] = draft({ + "Documents/note.abw": path("document", "xml", note(old), note(edited)), + }); + + expect(file?.suggested).toEqual([ + { + kind: "text_equals", + file: "Documents/note.abw", + pointer: sixth, + value: text, + }, + ]); + const checks = (file?.suggested ?? []).map((check, index) => ({ + ...check, + id: `c${String(index)}`, + })) as DesktopTaskCheck[]; + for (const saved of [ + edited, + [run("Meeting at "), run("3"), ":", run("30")], + ]) + expect( + evaluateTaskChecks( + checks, + new Map([ + ["Documents/note.abw", { format: "xml", view: note(saved) }], + ]), + ).map(({ passed }) => passed), + ).toEqual([true]); + } + }); + + it("suggests the paragraph's text in an archive when an edit turned its text into runs", () => { + const run = (text: string) => + xmlElement("text:span", { "text:style-name": "T1" }, [text]); + const list = (paragraph: JsonValue[]) => + listing([ + zipEntry("content.xml", "xml", { + root: xmlElement("office:text", {}, [ + xmlElement("text:p", { "text:style-name": "Body" }, paragraph), + ]), + truncated: false, + }), + ]); + + const [file] = draft({ + "Documents/list.odt": path( + "document", + "zip", + list(["Buy bread"]), + list([run("Buy "), run("oat milk")]), + ), + }); + + expect(file?.suggested).toEqual([ + { + kind: "text_equals", + file: "Documents/list.odt", + pointer: "/entries/[name=content.xml]/view/root/children/[name=text:p]", + value: "Buy oat milk", + }, + ]); + }); + + it("suggests the text of a paragraph in an archive and none of its runs, and nothing where only whitespace changed", () => { + const run = (text: string) => + xmlElement("text:span", { "text:style-name": "T1" }, [text]); + const list = (paragraph: JsonValue[], formatting: JsonValue[]) => + listing([ + zipEntry("content.xml", "xml", { + root: xmlElement("office:text", {}, [ + xmlElement("text:p", { "text:style-name": "Body" }, paragraph), + xmlElement("text:list", {}, formatting), + ]), + truncated: false, + }), + ]); + + const [file] = draft({ + "Documents/list.odt": path( + "document", + "zip", + list(["Buy bread"], [xmlElement("text:list-item"), "\n"]), + list( + ["Buy ", run("oat"), " ", run("milk")], + [xmlElement("text:list-item"), "\n", "\n"], + ), + ), + }); + + const paragraph = + "/entries/[name=content.xml]/view/root/children/[name=text:p]"; + expect( + file?.suggested.filter(({ kind }) => kind === "text_equals"), + ).toEqual([ + { + kind: "text_equals", + file: "Documents/list.odt", + pointer: paragraph, + value: "Buy oat milk", + }, + ]); + expect( + file?.suggested.filter( + (check) => + "pointer" in check && check.pointer.startsWith(`${paragraph}/`), + ), + ).toEqual([]); + }); +}); + +describe("draftTaskFiles for runs with attributes", () => { + it("keeps a check on a changed attribute of a run both versions have when the paragraph's text check replaces the run's, whatever the attribute is called", () => { + for (const key of ["xlink:href", "name", "attributes"]) { + const note = (target: string, label: string): JsonValue => ({ + root: xmlElement("abiword", {}, [ + xmlElement("p", { xid: "6" }, [ + "Visit ", + xmlElement("a", { [key]: target }, [label]), + ]), + ]), + truncated: false, + }); + + const [file] = draft({ + "Documents/note.abw": path( + "document", + "xml", + note("old-target", "Old"), + note("new-target", "New"), + ), + }); + + const paragraph = "/root/children/[name=p]"; + expect(file?.suggested).toEqual([ + expect.objectContaining({ + kind: "text_equals", + pointer: paragraph, + value: "Visit New", + }), + expect.objectContaining({ + kind: "value_equals", + pointer: `${paragraph}/children/[name=a]/attributes/${key}`, + value: "new-target", + }), + ]); + } + }); +}); + +/** Whether each check a draft suggests passes on a view of its file, XML unless another format is given. */ +const passing = ( + file: string, + suggested: TaskDraftFile["suggested"] | undefined, + view: JsonValue, + format: Format = "xml", +) => + evaluateTaskChecks( + (suggested ?? []).map((check, index) => ({ + ...check, + id: `c${String(index)}`, + })) as DesktopTaskCheck[], + new Map([[file, { format, view }]]), + ).map(({ passed }) => passed); + +describe("draftTaskFiles for runs added or removed whole", () => { + it("suggests the paragraph's text where an edit adds or removes a run holding text, which other text in the run fails", () => { + const run = (text: string) => + xmlElement("c", { props: "lang:en-US" }, [text]); + const note = (sixth: JsonValue[]): JsonValue => ({ + root: xmlElement("abiword", {}, [ + xmlElement("section", { xid: "1" }, [ + xmlElement("p", { style: "Normal", xid: "5" }, ["Agenda"]), + xmlElement("p", { style: "Normal", xid: "6" }, sixth), + ]), + ]), + truncated: false, + }); + const edits = [ + { + old: ["Meeting at "], + edited: ["Meeting at ", run("3:30")], + text: "Meeting at 3:30", + split: [run("Meeting at 3"), ":30"], + wrong: ["Meeting at ", run("13:30")], + }, + { + old: ["Meeting at ", run("3:30")], + edited: ["Meeting at "], + text: "Meeting at ", + split: [run("Meeting "), "at "], + wrong: ["Meeting at ", run("3:30")], + }, + ]; + + for (const { old, edited, text, split, wrong } of edits) { + const [file] = draft({ + "Documents/note.abw": path("document", "xml", note(old), note(edited)), + }); + + expect(file?.suggested).toEqual([ + { + kind: "text_equals", + file: "Documents/note.abw", + pointer: "/root/children/[name=section]/children/[name=p][@xid=6]", + value: text, + }, + ]); + expect( + [edited, split, wrong].map((saved) => + passing("Documents/note.abw", file?.suggested, note(saved)), + ), + ).toEqual([[true], [true], [false]]); + } + }); + + it("suggests the paragraph's text where an edit adds a run, however deep in it the text sits, in the document's root too", () => { + const inBody = (paragraph: JsonValue[]): JsonValue => ({ + root: xmlElement("body", {}, [xmlElement("p", {}, paragraph)]), + truncated: false, + }); + const asRoot = (paragraph: JsonValue[]): JsonValue => ({ + root: xmlElement("p", {}, paragraph), + truncated: false, + }); + const shapes = [ + [inBody, "/root/children/[name=p]"], + [asRoot, "/root"], + ] as const; + const runs = [ + xmlElement("span", {}, [" world"]), + xmlElement("span", {}, [xmlElement("b", {}, [" world"])]), + ]; + + for (const [document, pointer] of shapes) + for (const added of runs) { + const [file] = draft({ + "Documents/hello.xml": path( + "document", + "xml", + document(["Hello"]), + document(["Hello", added]), + ), + }); + + expect(file?.suggested).toEqual([ + { + kind: "text_equals", + file: "Documents/hello.xml", + pointer, + value: "Hello world", + }, + ]); + expect( + [ + ["Hello", added], + ["Hello", xmlElement("span", {}, ["WRONG"])], + ["Hello"], + ].map((paragraph) => + passing( + "Documents/hello.xml", + file?.suggested, + document(paragraph), + ), + ), + ).toEqual([[true], [false], [false]]); + } + }); +}); + +describe("draftTaskFiles for text in an element its tag names alone", () => { + const LAYER = "/root/children/[name=g][@inkscape:label=Layer 1]"; + // Body text that makes the layer's whole text longer than a draft compares. + const BODY = "The bake sale opens at nine in the school hall. ".repeat(50); + const poster = ( + attributes: Record, + headline: JsonValue[], + body = BODY, + ): JsonValue => ({ + root: xmlElement("svg", {}, [ + xmlElement("g", { "inkscape:label": "Layer 1" }, [ + xmlElement("text", attributes, headline), + xmlElement("g", { id: "details" }, [ + xmlElement("text", { id: "body" }, [xmlElement("tspan", {}, [body])]), + ]), + ]), + xmlElement("g", { "inkscape:label": "Layer 2" }), + ]), + truncated: false, + }); + const tspan = (text: string, attributes: Record = {}) => + xmlElement("tspan", attributes, [text]); + + it("names the text by its own unchanged attribute, an identifier first, where the layer is too long to check, so a save splitting it into runs passes", () => { + const headline = { "xml:space": "preserve", id: "headline" }; + const [file] = draft({ + "Pictures/poster.svg": path( + "document", + "xml", + poster(headline, [tspan("Spring Bake Sale")]), + poster(headline, [tspan("Summer Bake Sale")]), + ), + }); + + expect(file?.suggested).toEqual([ + { + kind: "text_equals", + file: "Pictures/poster.svg", + pointer: `${LAYER}/children/[name=text][@id=headline]`, + value: "Summer Bake Sale", + }, + ]); + expect( + [ + [tspan("Summer Bake Sale")], + [tspan("Summer "), tspan("Bake Sale")], + [tspan("Spring Bake Sale")], + ].map((saved) => + passing( + "Pictures/poster.svg", + file?.suggested, + poster(headline, saved), + ), + ), + ).toEqual([[true], [true], [false]]); + + // An attribute the edit changed names nothing, so no text check is drafted. + const [renamed] = draft({ + "Pictures/poster.svg": path( + "document", + "xml", + poster({ id: "headline" }, [tspan("Spring Bake Sale")]), + poster({ id: "title" }, [tspan("Summer Bake Sale")]), + ), + }); + expect( + renamed?.suggested.filter(({ kind }) => kind === "text_equals"), + ).toEqual([]); + }); + + it("names the text by its own identifier ahead of a short layer, the outermost such first, and only by another attribute where the layer cannot take the check", async () => { + const check = (pointer: string, value: string) => [ + { kind: "text_equals", file: "Pictures/poster.svg", pointer, value }, + ]; + const headline = { "xml:space": "preserve", id: "headline" }; + const short = (text: string) => + poster(headline, [tspan(text, { id: "tspan1" })], "Saturday"); + const [identified] = draft({ + "Pictures/poster.svg": path( + "document", + "xml", + short("Spring Bake Sale"), + short("Summer Bake Sale"), + ), + }); + expect(identified?.suggested).toEqual( + check(`${LAYER}/children/[name=text][@id=headline]`, "Summer Bake Sale"), + ); + + const spaced = (body: string) => (text: string) => + poster({ "xml:space": "preserve" }, [tspan(text)], body); + const [shortLayer, longLayer] = ["Saturday", BODY].map((body) => + draft({ + "Pictures/poster.svg": path( + "document", + "xml", + spaced(body)("Spring Bake Sale"), + spaced(body)("Summer Bake Sale"), + ), + }), + ); + expect(shortLayer?.[0]?.suggested).toEqual( + check(LAYER, "Summer Bake SaleSaturday"), + ); + expect(longLayer?.[0]?.suggested).toEqual( + check( + `${LAYER}/children/[name=text][@xml:space=preserve]`, + "Summer Bake Sale", + ), + ); + + // Inkscape's own save names its headline by the id among two texts, ahead of its tspan's id. + const [before, after] = await Promise.all( + ["before", "after"].map(async (end) => + xmlView( + await readFile( + join( + import.meta.dirname, + "../../../tests/fixtures/inkscape", + end, + "poster.svg", + ), + "utf8", + ), + ), + ), + ); + if (before === undefined || after === undefined) throw new Error("fixture"); + const [saved] = draft({ + "Pictures/poster.svg": path("document", "xml", before, after), + }); + expect(saved?.suggested).toEqual( + check( + "/root/children/[name=g]/children/[name=text][@id=headline]", + "Summer Bake Sale", + ), + ); + }); +}); + +describe("draftTaskFiles for elements added whole holding text", () => { + const section = (paragraphs: JsonValue[]): JsonValue => ({ + root: xmlElement("abiword", {}, [ + xmlElement("section", { xid: "1" }, paragraphs), + ]), + truncated: false, + }); + + it("checks a run added to a paragraph made only of runs at the paragraph, though an attribute of its own names the run", () => { + const run = (props: string, text: string) => + xmlElement("c", { props }, [text]); + const note = (runs: JsonValue[]) => + section([ + xmlElement("p", { xid: "5" }, ["Agenda"]), + xmlElement("p", { xid: "6" }, runs), + ]); + const meeting = run("lang:en-US", "Meeting at "); + + const [file] = draft({ + "Documents/note.abw": path( + "document", + "xml", + note([meeting]), + note([meeting, run("font-weight:bold", "3:30")]), + ), + }); + + expect(file?.suggested).toEqual([ + { + kind: "text_equals", + file: "Documents/note.abw", + pointer: "/root/children/[name=section]/children/[name=p][@xid=6]", + value: "Meeting at 3:30", + }, + ]); + expect( + [ + [meeting, run("font-weight:bold", "3:30")], + [run("lang:en-US", "Meeting at 3:30")], + [meeting, run("font-weight:bold", "13:30")], + ].map((runs) => + passing("Documents/note.abw", file?.suggested, note(runs)), + ), + ).toEqual([[true], [true], [false]]); + }); + + it("checks a new paragraph's text at a section short enough to check, and otherwise its own, rather than that it is there", () => { + const sections = [ + ["Agenda", "/root/children/[name=section]", "AgendaBring the minutes"], + [ + "Agenda. ".repeat(300), + "/root/children/[name=section]/children/[name=p][@xid=7]", + "Bring the minutes", + ], + ] as const; + + for (const [agenda, pointer, value] of sections) { + const note = (added: JsonValue[]) => + section([xmlElement("p", { xid: "5" }, [agenda]), ...added]); + const minutes = (text: string) => + xmlElement("p", { xid: "7" }, [xmlElement("c", {}, [text])]); + + const [file] = draft({ + "Documents/note.abw": path( + "document", + "xml", + note([]), + note([minutes("Bring the minutes")]), + ), + }); + + expect(file?.suggested).toEqual([ + { kind: "text_equals", file: "Documents/note.abw", pointer, value }, + ]); + expect( + [[minutes("Bring the minutes")], [minutes("Bring the agenda")], []].map( + (added) => + passing("Documents/note.abw", file?.suggested, note(added)), + ), + ).toEqual([[true], [false], [false]]); + } + }); +}); + +describe("draftTaskFiles for elements without text beside a text edit", () => { + it("checks an image added or removed in an archive's paragraph whose text changed, which its text check never reads", () => { + const file = "Documents/notes.odt"; + const content = (paragraph: JsonValue[]) => + listing([ + zipEntry("content.xml", "xml", { + root: xmlElement("office:text", {}, [ + xmlElement("text:p", { n: "a" }, paragraph), + xmlElement("text:p", { n: "b" }, ["Agenda"]), + ]), + truncated: false, + }), + ]); + const image = xmlElement("draw:frame", { "draw:name": "Image1" }, [ + xmlElement("draw:image", { "xlink:href": "Pictures/1.png" }), + ]); + const paragraph = + "/entries/[name=content.xml]/view/root/children/[name=text:p][@n=a]"; + const edits = [ + { + old: ["Hello"], + edited: ["Hello there", image], + imageCheck: { + kind: "value_equals", + pointer: `${paragraph}/children/[name=draw:frame]/name`, + value: "draw:frame", + }, + textOnly: ["Hello there"], + }, + { + old: ["Hello", image], + edited: ["Hello there"], + imageCheck: { + kind: "value_absent", + pointer: `${paragraph}/children/[name=draw:frame]`, + }, + textOnly: ["Hello there", image], + }, + ]; + + for (const { old, edited, imageCheck, textOnly } of edits) { + const [draftFile] = draft({ + [file]: path("document", "zip", content(old), content(edited)), + }); + + expect(draftFile?.suggested).toEqual([ + { + kind: "text_equals", + file, + pointer: paragraph, + value: "Hello there", + }, + { ...imageCheck, file }, + ]); + expect( + [edited, textOnly].map((saved) => + passing(file, draftFile?.suggested, content(saved), "zip"), + ), + ).toEqual([ + [true, true], + [true, false], + ]); + } + }); + + it("checks an image or break added or removed in an XML paragraph whose text changed, while a run holding text stays covered", () => { + const file = "Documents/note.abw"; + const note = (sixth: JsonValue[]): JsonValue => ({ + root: xmlElement("abiword", {}, [ + xmlElement("section", { xid: "1" }, [ + xmlElement("p", { xid: "5" }, ["Agenda"]), + xmlElement("p", { xid: "6" }, sixth), + ]), + ]), + truncated: false, + }); + const sixth = "/root/children/[name=section]/children/[name=p][@xid=6]"; + const image = xmlElement("image", { dataid: "image_1" }); + const brk = xmlElement("br"); + const edits = [ + { + old: ["Meeting at 2:00"], + edited: ["Meeting at 3:30", image], + others: [ + { + kind: "value_equals", + pointer: `${sixth}/children/[name=image]/name`, + value: "image", + }, + ], + textOnly: ["Meeting at 3:30"], + }, + { + old: ["Meeting at 2:00", brk], + edited: ["Meeting at 3:30"], + others: [ + { kind: "value_absent", pointer: `${sixth}/children/[name=br]` }, + ], + textOnly: ["Meeting at 3:30", brk], + }, + { + old: ["Meeting at 2:00"], + edited: ["Meeting at ", xmlElement("c", {}, ["3:30"]), brk], + others: [ + { + kind: "value_equals", + pointer: `${sixth}/children/[name=br]/name`, + value: "br", + }, + ], + textOnly: ["Meeting at 3:30"], + }, + ]; + + for (const { old, edited, others, textOnly } of edits) { + const [draftFile] = draft({ + [file]: path("document", "xml", note(old), note(edited)), + }); + + expect(draftFile?.suggested).toEqual([ + { + kind: "text_equals", + file, + pointer: sixth, + value: "Meeting at 3:30", + }, + ...others.map((check) => ({ ...check, file })), + ]); + expect( + [edited, textOnly].map((saved) => + passing(file, draftFile?.suggested, note(saved)), + ), + ).toEqual([ + [true, true], + [true, false], + ]); + } + }); +}); + describe("draftTaskFiles with nullable SQLite keys", () => { it("suggests checks for distinct NULL-key rows and ignores a reorder", () => { const file = ".local/share/app/items.db"; @@ -834,9 +1550,9 @@ describe("draftTaskFiles archive elements beside identified ones", () => { expect(itemChecks([id, body("old")], [id, body("new")])).toEqual([ { - kind: "value_equals", + kind: "text_equals", file: "Documents/list.zip", - pointer: `${ITEMS}/[name=item][@role=body]/children/0`, + pointer: `${ITEMS}/[name=item][@role=body]`, value: "new", }, ]); @@ -1046,10 +1762,10 @@ describe("draftTaskFiles archives", () => { expect(flyer?.suggested).toEqual([ { - kind: "value_equals", + kind: "text_equals", file: "Documents/flyer.odt", pointer: - "/entries/[name=content.xml]/view/root/children/[name=text:p][@text:style-name=Subtitle]/children/0", + "/entries/[name=content.xml]/view/root/children/[name=text:p][@text:style-name=Subtitle]", value: "Monday 24 November", }, ]); diff --git a/src/domain/desktop/appTaskDraft.ts b/src/domain/desktop/appTaskDraft.ts index 9c5ec865fa..06109b843e 100644 --- a/src/domain/desktop/appTaskDraft.ts +++ b/src/domain/desktop/appTaskDraft.ts @@ -1,18 +1,20 @@ import type { JsonValue } from "../jsonValue.js"; import { isTimestamp, type DesktopTaskCheckInput } from "./appTask.js"; +import { + ARCHIVE, + escapeToken, + innermost, + namedSuggestions, + XML_FILE, +} from "./appTaskDraftNamed.js"; import { versionAt, type DesktopStatePath, type StateEnds, } from "./desktopState.js"; import { countTaskRows } from "./appTaskChecks.js"; -import { - diffStateViews, - diffZipMembers, - sqliteRowIdentity, -} from "./stateChanges.js"; -import { locatePointer, pairMembers, resolvePointer } from "./viewPointer.js"; -import { entryWithoutView, zipArrayLeavesOut, zipEntryAt } from "./zipView.js"; +import { diffStateViews, sqliteRowIdentity } from "./stateChanges.js"; +import { resolvePointer } from "./viewPointer.js"; type Version = DesktopStatePath["versions"][number]; type Suggestion = DesktopTaskCheckInput; @@ -21,6 +23,14 @@ type Suggestion = DesktopTaskCheckInput; const MAX_SUGGESTIONS = 10; const MAX_ITEMS = 200; const MAX_CHARS = 2000; +/** The most text checks a file gets, and the longest text one compares. */ +const LIMIT = { suggestions: MAX_SUGGESTIONS, chars: MAX_CHARS }; + +/** The views at a file's two ends. */ +const versions = (baseline: Version, final: Version) => ({ + baseline: baseline.view, + final: final.view, +}); const isRecord = ( value: JsonValue | null | undefined, @@ -108,9 +118,6 @@ const textSuggestions = ( return suggestions; }; -const escapeToken = (token: string) => - token.replaceAll("~", "~0").replaceAll("/", "~1"); - /** `group/key` entries of key file views as JSON pointers into the view. */ const keyPointers = ( views: readonly (JsonValue | null)[], @@ -128,148 +135,20 @@ const keyPointers = ( ), ); -/** Pointers without a listed descendant: a container listed with its members is suggested through them. */ -const innermost = (pointers: readonly string[]): readonly string[] => - pointers.filter( - (pointer) => !pointers.some((other) => other.startsWith(`${pointer}/`)), - ); - -/** - * The entry a pointer into a zip view goes into, when it has a view and both - * evaluators read it alike: JSON, a key file or XML. A binary or unread - * entry also holds a `null` view, which is no JSON `null`. - */ -const readableEntry = (view: JsonValue | null, pointer: string) => { - const entry = zipEntryAt(view, pointer); - return typeof entry === "object" && - entryWithoutView(entry) === undefined && - (entry.format === "json" || - entry.format === "keyfile" || - entry.format === "xml") - ? entry - : undefined; -}; - -/** - * Where checks for a pointer the diff lists start: the view of an entry it - * names whole, or its root element for XML; the pointer itself inside that; - * undefined for an entry neither evaluator reads alike, or the entry's fields. - */ -const checkRoot = ( - view: JsonValue | null, - pointer: string, -): string | undefined => { - const entry = readableEntry(view, pointer); - const [entryPointer] = /^\/entries\/[^/]+/u.exec(pointer) ?? []; - if (entry === undefined || entryPointer === undefined) return undefined; - const inner = pointer.slice(entryPointer.length).split("/").slice(1); - const root = entry.format === "xml" ? ["view", "root"] : ["view"]; - const prefix = (short: readonly string[], long: readonly string[]) => - short.length <= long.length && - short.every((part, index) => long[index] === part); - if (prefix(inner, root)) return `${entryPointer}/${root.join("/")}`; - return prefix(root, inner) ? pointer : undefined; -}; - -/** Whether a check at a pointer is decided by name: no object or array reached by position, no array searched that the view may cut. */ -const byName = (view: JsonValue | null, pointer: string) => { - const located = locatePointer(view, pointer); - return ( - located.kind !== "unresolved" && - located.byPosition.length === 0 && - !located.searched.some((array) => zipArrayLeavesOut(view, array)) - ); -}; - -/** An element of REA's XML view, by its tag name, or undefined for any other value. */ -const elementName = (value: JsonValue | undefined): string | undefined => - isRecord(value) && - typeof value.name === "string" && - isRecord(value.attributes) && - Array.isArray(value.children) - ? value.name - : undefined; - -/** - * Checks on the entries of an archive, by the selectors its diff names - * members with: elements added or removed whole first, as the likeliest - * record of what the user did, then values changed, added and removed. A - * JSON value or an entry added whole is checked through its members. - */ -const zipSuggestions = ( - file: string, - baseline: Version, - final: Version, -): Suggestion[] => { - const change = diffZipMembers(baseline.view, final.view); - const elements: Suggestion[] = []; - const values: Suggestion[] = []; - const equals = (pointer: string, value: JsonValue | undefined) => { - if ( - values.length < MAX_SUGGESTIONS && - value !== undefined && - !(isRecord(value) && Object.keys(value).length > 0) && - !isTimestamp(value) && - byName(final.view, pointer) - ) - values.push({ kind: "value_equals", file, pointer, value }); - }; - const added = (pointer: string, value: JsonValue | undefined): void => { - if (elements.length >= MAX_SUGGESTIONS && values.length >= MAX_SUGGESTIONS) - return; - const name = elementName(value); - if (name !== undefined) { - if ( - elements.length < MAX_SUGGESTIONS && - byName(final.view, `${pointer}/name`) - ) - elements.push({ - kind: "value_equals", - file, - pointer: `${pointer}/name`, - value: name, - }); - } else if (Array.isArray(value) && value.length > 0) { - const { keys } = pairMembers(value, []).newer; - value.forEach((item, index) => - added(`${pointer}/${escapeToken(keys[index] ?? String(index))}`, item), - ); - } else if (isRecord(value) && Object.keys(value).length > 0) - for (const [key, item] of Object.entries(value)) - added(`${pointer}/${escapeToken(key)}`, item); - // A scalar, or an empty array or object, is checked as it is. - else equals(pointer, value); - }; - for (const pointer of change.added) { - const root = checkRoot(final.view, pointer); - if (root !== undefined) added(root, resolvePointer(final.view, root)); - } - for (const pointer of innermost(change.changed)) - if (checkRoot(final.view, pointer) === pointer) - equals(pointer, resolvePointer(final.view, pointer)); - // An absence is checked only where the entry still has a view to lack it in. - for (const pointer of change.removed) - if ( - checkRoot(baseline.view, pointer) === pointer && - checkRoot(final.view, pointer) === pointer && - byName(baseline.view, pointer) && - byName(final.view, pointer) - ) - (elementName(resolvePointer(baseline.view, pointer)) === undefined - ? values - : elements - ).push({ kind: "value_absent", file, pointer }); - return [...elements, ...values]; -}; - const valueSuggestions = ( file: string, baseline: Version, final: Version, ): Suggestion[] => { const format = final.format; - if (format === "zip") return zipSuggestions(file, baseline, final); - if (format !== "json" && format !== "xml" && format !== "keyfile") return []; + if (format === "zip" || format === "xml") + return namedSuggestions( + file, + versions(baseline, final), + format === "zip" ? ARCHIVE : XML_FILE, + LIMIT, + ); + if (format !== "json" && format !== "keyfile") return []; const change = diffStateViews(format, baseline.view, final.view); const pointers = keyPointers([baseline.view, final.view]); const pointer = (entry: string) => diff --git a/src/domain/desktop/appTaskDraftNamed.ts b/src/domain/desktop/appTaskDraftNamed.ts new file mode 100644 index 0000000000..3497b10a2b --- /dev/null +++ b/src/domain/desktop/appTaskDraftNamed.ts @@ -0,0 +1,228 @@ +import type { JsonValue } from "../jsonValue.js"; +import { isTimestamp, type DesktopTaskCheckInput } from "./appTask.js"; +import { isXmlElement } from "./appTaskChecks.js"; +import { wholeTextSuggestions } from "./appTaskDraftText.js"; +import { diffNamedMembers } from "./stateChanges.js"; +import { locatePointer, pairMembers, resolvePointer } from "./viewPointer.js"; +import { + entryWithoutView, + zipArrayLeavesOut, + zipEntryAt, + zipLeavesOutAt, +} from "./zipView.js"; + +type Suggestion = DesktopTaskCheckInput; + +const isRecord = ( + value: JsonValue | null | undefined, +): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** A pointer segment for a key: `~` and `/` escaped as RFC 6901 writes them. */ +export const escapeToken = (token: string) => + token.replaceAll("~", "~0").replaceAll("/", "~1"); + +/** Pointers without a listed descendant: a container listed with its members is suggested through them. */ +export const innermost = (pointers: readonly string[]): readonly string[] => + pointers.filter( + (pointer) => !pointers.some((other) => other.startsWith(`${pointer}/`)), + ); + +/** + * The entry a pointer into a zip view goes into, when it has a view and both + * evaluators read it alike: JSON, a key file or XML. A binary or unread + * entry also holds a `null` view, which is no JSON `null`. + */ +const readableEntry = (view: JsonValue | null, pointer: string) => { + const entry = zipEntryAt(view, pointer); + return typeof entry === "object" && + entryWithoutView(entry) === undefined && + (entry.format === "json" || + entry.format === "keyfile" || + entry.format === "xml") + ? entry + : undefined; +}; + +/** + * Where checks for a pointer the diff lists start: the view of an entry it + * names whole, or its root element for XML; the pointer itself inside that; + * undefined for an entry neither evaluator reads alike, or the entry's fields. + */ +const checkRoot = ( + view: JsonValue | null, + pointer: string, +): string | undefined => { + const entry = readableEntry(view, pointer); + const [entryPointer] = /^\/entries\/[^/]+/u.exec(pointer) ?? []; + if (entry === undefined || entryPointer === undefined) return undefined; + const inner = pointer.slice(entryPointer.length).split("/").slice(1); + const root = entry.format === "xml" ? ["view", "root"] : ["view"]; + const prefix = (short: readonly string[], long: readonly string[]) => + short.length <= long.length && + short.every((part, index) => long[index] === part); + if (prefix(inner, root)) return `${entryPointer}/${root.join("/")}`; + return prefix(root, inner) ? pointer : undefined; +}; + +/** + * Where checks into a view can go: an archive's entries, or an XML file's root + * element. `root` is where checks for a pointer the diff lists start, undefined + * where the evaluators do not read alike; `xml` whether a pointer goes into an + * XML view; `leavesOut` whether the view may lack part of what a pointer + * reaches, and `arrayLeavesOut` members of the array a pointer names. + */ +interface CheckTree { + readonly root: ( + view: JsonValue | null, + pointer: string, + ) => string | undefined; + readonly xml: (view: JsonValue | null, pointer: string) => boolean; + readonly leavesOut: (view: JsonValue | null, pointer: string) => boolean; + readonly arrayLeavesOut: (view: JsonValue | null, array: string) => boolean; +} + +export const ARCHIVE: CheckTree = { + root: checkRoot, + xml: (view, pointer) => readableEntry(view, pointer)?.format === "xml", + leavesOut: zipLeavesOutAt, + arrayLeavesOut: zipArrayLeavesOut, +}; + +/** An XML file's view leaves out what its `truncated` says, anywhere in the tree. */ +const truncatedXml = (view: JsonValue | null) => + isRecord(view) && view.truncated === true; + +export const XML_FILE: CheckTree = { + root: (_view, pointer) => + pointer === "/root" || pointer.startsWith("/root/") ? pointer : undefined, + xml: () => true, + leavesOut: truncatedXml, + arrayLeavesOut: truncatedXml, +}; + +/** Whether a check at a pointer is decided by name: no object or array reached by position, no array searched that the view may cut. */ +const byName = (view: JsonValue | null, pointer: string, tree: CheckTree) => { + const located = locatePointer(view, pointer); + return ( + located.kind !== "unresolved" && + located.byPosition.length === 0 && + !located.searched.some((array) => tree.arrayLeavesOut(view, array)) + ); +}; + +/** An element of REA's XML view, by its tag name, or undefined for any other value. */ +const elementName = (value: JsonValue | undefined): string | undefined => + isXmlElement(value) ? value.name : undefined; + +/** A text node of an XML view holding only whitespace, which an app writes as it lays out the file. */ +const isLayoutText = ( + tree: CheckTree, + view: JsonValue | null, + pointer: string, + value: JsonValue | undefined, +) => + typeof value === "string" && + /^[ \t\r\n]*$/u.test(value) && + /\/children\/[^/]+$/u.test(pointer) && + tree.xml(view, pointer); + +/** + * Checks by the selectors a keyed diff names members with, in an archive's + * entries or an XML file: the whole text of each element whose text changed + * first, then elements added or removed whole, then values changed, added and + * removed. A JSON value or an entry added whole is checked through its + * members. Nothing goes through an object or array by position. + */ +export const namedSuggestions = ( + file: string, + ends: { + readonly baseline: JsonValue | null; + readonly final: JsonValue | null; + }, + tree: CheckTree, + limit: { readonly suggestions: number; readonly chars: number }, +): Suggestion[] => { + const change = diffNamedMembers(ends.baseline, ends.final); + const texts = wholeTextSuggestions(file, change, ends, { + limit, + inTree: (pointer) => + tree.xml(ends.final, pointer) && + tree.root(ends.final, pointer) === pointer, + name: (pointer) => + byName(ends.final, pointer, tree) && !tree.leavesOut(ends.final, pointer) + ? pointer + : undefined, + }); + const elements: Suggestion[] = []; + const values: Suggestion[] = []; + const equals = (pointer: string, value: JsonValue | undefined) => { + if ( + values.length < limit.suggestions && + !texts.covers(pointer) && + value !== undefined && + !(isRecord(value) && Object.keys(value).length > 0) && + !isTimestamp(value) && + !isLayoutText(tree, ends.final, pointer, value) && + byName(ends.final, pointer, tree) + ) + values.push({ kind: "value_equals", file, pointer, value }); + }; + const added = (pointer: string, value: JsonValue | undefined): void => { + if ( + (elements.length >= limit.suggestions && + values.length >= limit.suggestions) || + texts.covers(pointer) + ) + return; + const name = elementName(value); + if (name !== undefined) { + if ( + elements.length < limit.suggestions && + byName(ends.final, `${pointer}/name`, tree) + ) + elements.push({ + kind: "value_equals", + file, + pointer: `${pointer}/name`, + value: name, + }); + } else if (Array.isArray(value) && value.length > 0) { + const { keys } = pairMembers(value, []).newer; + value.forEach((item, index) => + added(`${pointer}/${escapeToken(keys[index] ?? String(index))}`, item), + ); + } else if (isRecord(value) && Object.keys(value).length > 0) + for (const [key, item] of Object.entries(value)) + added(`${pointer}/${escapeToken(key)}`, item); + // A scalar, or an empty array or object, is checked as it is. + else equals(pointer, value); + }; + for (const pointer of change.added) { + const root = tree.root(ends.final, pointer); + if (root !== undefined) added(root, resolvePointer(ends.final, root)); + } + for (const pointer of innermost(change.changed)) + if (tree.root(ends.final, pointer) === pointer) + equals(pointer, resolvePointer(ends.final, pointer)); + // An absence is checked only where the entry still has a view to lack it in. + for (const pointer of change.removed) + if ( + !texts.covers(pointer) && + !isLayoutText( + tree, + ends.baseline, + pointer, + resolvePointer(ends.baseline, pointer), + ) && + tree.root(ends.baseline, pointer) === pointer && + tree.root(ends.final, pointer) === pointer && + byName(ends.baseline, pointer, tree) && + byName(ends.final, pointer, tree) + ) + (elementName(resolvePointer(ends.baseline, pointer)) === undefined + ? values + : elements + ).push({ kind: "value_absent", file, pointer }); + return [...texts.suggestions, ...elements, ...values]; +}; diff --git a/src/domain/desktop/appTaskDraftText.ts b/src/domain/desktop/appTaskDraftText.ts new file mode 100644 index 0000000000..97a8434fc1 --- /dev/null +++ b/src/domain/desktop/appTaskDraftText.ts @@ -0,0 +1,336 @@ +import type { JsonValue } from "../jsonValue.js"; +import { isTimestamp, type DesktopTaskCheckInput } from "./appTask.js"; +import { elementText, isXmlElement } from "./appTaskChecks.js"; +import type { DesktopStateDifference } from "./stateChanges.js"; +import { resolvePointer } from "./viewPointer.js"; + +/** The pointers a diff lists, of every kind. */ +type Listed = Pick; + +/** A file's views at its two ends. */ +interface Ends { + readonly baseline: JsonValue | null; + readonly final: JsonValue | null; +} + +/** Where a format's text checks go, and how many and how long a draft keeps. */ +export interface TextPlacement { + /** Whether a pointer is an element of an XML tree both evaluators read alike. */ + readonly inTree: (pointer: string) => boolean; + /** The pointer a check names an element by, or undefined when the final view cannot decide a check there. */ + readonly name: (pointer: string) => string | undefined; + readonly limit: { readonly suggestions: number; readonly chars: number }; +} + +/** Text checks for the elements whose text changed, and the pointers those checks make redundant: text and whole runs holding text in them, or an element added whole checked itself, not an attribute or name of an element both ends hold, nor an element holding no text, as an image or a break. */ +export interface WholeTextSuggestions { + readonly suggestions: readonly DesktopTaskCheckInput[]; + readonly covers: (pointer: string) => boolean; +} + +/** The element a member of `children` sits in: the pointer up to its last `/children/N`; none for the root. */ +const parentOf = (pointer: string): string | undefined => + /^(?.+)\/children\/[^/]+$/u.exec(pointer)?.groups?.parent; + +const isWhitespace = (text: string) => /^[ \t\r\n]*$/u.test(text); + +/** The element whose name or attribute a pointer reaches; none for text or a whole element. */ +const ownerOf = (pointer: string): string | undefined => { + const segments = pointer.split("/"); + // An attribute's own key may be `name` or `attributes`, so `attributes` is read one up first. + if (segments.at(-2) === "attributes") return segments.slice(0, -2).join("/"); + const last = segments.at(-1); + return last === "attributes" || last === "name" + ? segments.slice(0, -1).join("/") + : undefined; +}; + +/** Whether an element holds text of its own beside its children, as a paragraph holds the text between its runs. */ +const holdsText = (value: JsonValue | undefined): boolean => + isXmlElement(value) && + value.children.some( + (child) => typeof child === "string" && !isWhitespace(child), + ); + +/** An element's parent below the document's root, whose text a check could read instead; none for a child of the root. */ +const parentBelowRoot = (pointer: string): string | undefined => { + const parent = parentOf(pointer); + return parent === undefined || parentOf(parent) === undefined + ? undefined + : parent; +}; + +/** Whether the diff names an element by one of its attributes, as `[name=p][@xid=6]`, rather than by its tag alone or its position. */ +const namedByAttribute = (pointer: string) => + (pointer.split("/").at(-1) ?? "").includes("[@"); + +const isRecord = ( + value: JsonValue | undefined, +): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** An element's attributes; none for any other value. */ +const attributesOf = (value: JsonValue | undefined) => + isXmlElement(value) && isRecord(value.attributes) ? value.attributes : {}; + +/** Whether an attribute identifies its element, as `id`, `xml:id` or AbiWord's `xid` do, rather than formatting it. */ +const isIdentifier = (key: string) => /^(?:[^:]*:)?x?id$/iu.test(key); + +/** Whether the diff names an element by an identifier, as `[name=p][@xid=6]`. */ +const namedByIdentifier = (pointer: string) => { + const key = /\]\[@(?[^=]+)=/u.exec(pointer.split("/").at(-1) ?? "") + ?.groups?.key; + return key !== undefined && isIdentifier(key); +}; + +/** Whether an end holds an element at a pointer whose text a check reads as nothing, as an image or a break. */ +const textlessAt = (pointer: string, ends: Ends) => + [ends.baseline, ends.final].some((view) => { + const value = resolvePointer(view, pointer); + return isXmlElement(value) && isWhitespace(elementText(value)); + }); + +/** Whether both ends hold an element at a pointer. */ +const keptAt = (element: string, ends: Ends) => + [ends.baseline, ends.final].every((view) => + isXmlElement(resolvePointer(view, element)), + ); + +/** Whether an element holds text of its own, at either end. */ +const mixedAt = (element: string, ends: Ends) => + [ends.baseline, ends.final].some((view) => + holdsText(resolvePointer(view, element)), + ); + +/** Where a text check may go: an element as the diff names it, the pointer the check names it by, and whether an identifier is in that pointer. */ +interface Candidate { + readonly element: string; + readonly pointer: string; + readonly identifier: boolean; +} + +/** + * An element named by its tag and one of its own attributes, an identifier + * first: one with the same value at both ends that names the element alone + * among its siblings at each; none when no attribute does. + */ +const byOwnAttribute = (element: string, ends: Ends): Candidate | undefined => { + const parent = parentOf(element); + const before = resolvePointer(ends.baseline, element); + const after = resolvePointer(ends.final, element); + if (parent === undefined || !isXmlElement(before) || !isXmlElement(after)) + return undefined; + const attributes = attributesOf(after); + const keys = Object.keys(attributes); + for (const key of [ + ...keys.filter(isIdentifier), + ...keys.filter((key) => !isIdentifier(key)), + ]) { + const value = attributes[key]; + if ( + typeof value !== "string" || + attributesOf(before)[key] !== value || + /[=[\]]/u.test(key) || + value.includes("][") || + isTimestamp(value) + ) + continue; + const selector = `[name=${after.name}][@${key}=${value}]`; + const pointer = `${parent}/children/${selector.replaceAll("~", "~0").replaceAll("/", "~1")}`; + if ( + resolvePointer(ends.baseline, pointer) === before && + resolvePointer(ends.final, pointer) === after + ) + return { element, pointer, identifier: isIdentifier(key) }; + } + return undefined; +}; + +/** + * Where a text check goes for an element whose text changed, best first, so + * the check names the paragraph, which a later save keeps, rather than a run + * it may split or merge: up from an element in mixed content (its parent + * holds text beside it, at either end) to the nearest that is not, then, from + * one named by tag or position alone, to the nearest ancestor named by an + * attribute, as AbiWord's `p` by its `xid`, where one is below the document's + * root. An element passed on the way that an identifier of its own names, the + * outermost first, goes ahead of that ancestor unless an identifier names the + * ancestor too; one that only another attribute names goes after it. + */ +const outsideRuns = (pointer: string, ends: Ends): readonly Candidate[] => { + let current = pointer; + // A paragraph may be the document's root. + for ( + let parent = parentOf(current); + parent !== undefined && mixedAt(parent, ends); + parent = parentOf(current) + ) + current = parent; + const passed: string[] = []; + let named = current; + while (!namedByAttribute(named)) { + const parent = parentBelowRoot(named); + if (parent === undefined) + return [{ element: current, pointer: current, identifier: false }]; + passed.push(named); + named = parent; + } + const ranked = [ + { element: named, pointer: named, identifier: namedByIdentifier(named) }, + ...passed + .toReversed() + .flatMap((element) => byOwnAttribute(element, ends) ?? []), + ]; + return [ + ...ranked.filter(({ identifier }) => identifier), + ...ranked.filter(({ identifier }) => !identifier), + ]; +}; + +/** + * The nearest ancestor both ends hold of an element added or removed whole, + * where its text is checked; none when that is the document's root holding no + * text of its own, whose whole text a check would read instead. + */ +const keptAncestor = (pointer: string, ends: Ends): string | undefined => { + let parent = parentOf(pointer); + while (parent !== undefined && !keptAt(parent, ends)) + parent = parentOf(parent); + return parent === undefined || + (parentOf(parent) === undefined && !mixedAt(parent, ends)) + ? undefined + : parent; +}; + +/** A text check, and the element it reads as the diff names it. */ +interface Placed { + readonly element: string; + readonly check: DesktopTaskCheckInput; +} + +/** What text checks read: the file, its two ends, and where checks go. */ +interface TextContext { + readonly file: string; + readonly ends: Ends; + readonly place: TextPlacement; +} + +/** + * The text check a candidate takes: an element the final view holds whole at + * the pointer `name` gives it, its text at most the limit and no timestamp, + * and changed from the baseline's or, for an element `added` whole, absent + * there. + */ +const textCheck = ( + { file, ends, place }: TextContext, + { element, pointer: named }: Candidate, + added: boolean, +): Placed | undefined => { + const pointer = place.name(named); + if (pointer === undefined) return undefined; + const now = resolvePointer(ends.final, pointer); + const before = resolvePointer(ends.baseline, pointer); + if (!isXmlElement(now) || (!added && !isXmlElement(before))) return undefined; + const value = elementText(now); + return value !== (isXmlElement(before) ? elementText(before) : undefined) && + value.length <= place.limit.chars && + !isTimestamp(value) + ? { element, check: { kind: "text_equals", file, pointer, value } } + : undefined; +}; + +/** + * Checks of an element's whole text where an edit changed it, for an app that + * splits a paragraph into runs as it saves: the element holding each text node + * the diff lists with more than whitespace in it, in either version of a + * changed value, and the nearest ancestor both ends hold of each element it + * lists whole holding such text, taken out of the runs it sits in, the + * outermost where one holds another, an element at both ends at the pointer + * `name` gives it, whose text changed and that the final view holds whole. An + * element added whole whose ancestors cannot take the check takes it itself. + */ +export const wholeTextSuggestions = ( + file: string, + change: Listed, + ends: Ends, + place: TextPlacement, +): WholeTextSuggestions => { + const holders = new Set(); + /** Elements added whole holding text, each with the ancestor checked for it, if any. */ + const added = new Map(); + const note = (pointer: string, view: JsonValue | null, whole = false) => { + const value = resolvePointer(view, pointer); + const parent = parentOf(pointer); + if (typeof value === "string") { + if (parent !== undefined && !isWhitespace(value) && place.inTree(parent)) + holders.add(parent); + } else if ( + isXmlElement(value) && + !isWhitespace(elementText(value)) && + place.inTree(pointer) + ) { + const holder = keptAncestor(pointer, ends); + if (holder !== undefined) holders.add(holder); + if (whole) added.set(pointer, holder); + } + }; + // A value that changed may be text at one end only, as text an app wraps in a run. + for (const pointer of change.changed) { + note(pointer, ends.final); + note(pointer, ends.baseline); + } + for (const pointer of change.added) note(pointer, ends.final, true); + for (const pointer of change.removed) note(pointer, ends.baseline); + const context = { file, ends, place }; + const checks = new Map(); + const check = (candidate: Candidate) => { + if (!checks.has(candidate.element)) + checks.set(candidate.element, textCheck(context, candidate, false)); + return checks.get(candidate.element); + }; + const climbs = new Map(); + const climb = (holder: string) => { + if (!climbs.has(holder)) { + const found = outsideRuns(holder, ends).find( + (candidate) => check(candidate) !== undefined, + ); + climbs.set(holder, found === undefined ? undefined : check(found)); + } + return climbs.get(holder); + }; + const placed: Placed[] = []; + const keep = (found: Placed | undefined) => { + if (found !== undefined && !placed.includes(found)) placed.push(found); + }; + for (const holder of holders) keep(climb(holder)); + for (const [element, holder] of added) + if (holder === undefined || climb(holder) === undefined) + keep( + textCheck( + context, + { element, pointer: element, identifier: false }, + true, + ), + ); + const suggested = placed + .filter( + ({ element }) => + !placed.some((other) => element.startsWith(`${other.element}/`)), + ) + .slice(0, place.limit.suggestions); + return { + suggestions: suggested.map(({ check }) => check), + covers: (pointer) => { + if ( + !suggested.some( + ({ element }) => + pointer === element || pointer.startsWith(`${element}/children/`), + ) + ) + return false; + if (textlessAt(pointer, ends)) return false; + const owner = ownerOf(pointer); + return owner === undefined || !keptAt(owner, ends); + }, + }; +}; diff --git a/src/domain/desktop/appTaskPython.ts b/src/domain/desktop/appTaskPython.ts index 456e8f6060..c15d3bd8b4 100644 --- a/src/domain/desktop/appTaskPython.ts +++ b/src/domain/desktop/appTaskPython.ts @@ -7,6 +7,7 @@ MISSING = object() INDEX = re.compile(r"0|[1-9][0-9]*") VIEW_FORMATS = ("text", "json", "keyfile", "xml") NUMERAL = re.compile(r"[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?") +WHITESPACE_RUN = re.compile(r"[ \t\r\n]+") def _shown(value) -> str: @@ -263,6 +264,104 @@ def _value(check, entry): return (True, f"{at} has no value") if kind == "none" else (False, f"{at} is {_shown(found)}") +def _is_element(value) -> bool: + return (isinstance(value, dict) and isinstance(value.get("name"), str) and isinstance(value.get("attributes"), dict) + and isinstance(value.get("children"), list)) + + +def _element_text(element) -> str: + """An element's text as XPath's string() reads it from what the view keeps: every text node among its children + and its descendants', in document order.""" + parts, pending = [], [element] + while pending: + node = pending.pop() + if isinstance(node, str): + parts.append(node) + elif _is_element(node): + pending.extend(reversed(node["children"])) + return "".join(parts) + + +def _collapse_whitespace(text: str) -> str: + """Each run of XML whitespace (space, tab, CR, LF) made one space, none at either end, as normalize-space().""" + return WHITESPACE_RUN.sub(" ", text).strip(" ") + + +def _text_at(view, pointer: str) -> bool: + """Whether a string a pointer reaches is text of the document: a member of an element's children (a text node) + or attributes (an attribute's value), rather than an element's name or a field of the DOCTYPE.""" + segments = pointer.split("/") + if len(segments) < 3 or segments[-2] not in ("children", "attributes"): + return False + kind, owner, _ = _locate(view, "/".join(segments[:-2])) + return kind == "value" and _is_element(owner) + + +def _not_xml_entry(check, view): + """Why a text check's pointer into an archive goes into no XML entry's view, or None when it does or names an + entry not listed.""" + parts = check["pointer"].split("/") + if len(parts) < 4 or parts[3] != "view": + return f"{check['file']} at {check['pointer']} does not point into an entry's view" + if parts[1] != "entries": + return None + kind, item, _ = _locate(view, "/entries/" + parts[2]) + if kind != "value" or not isinstance(item, dict) or item.get("format") == "xml": + return None + # An entry without a view failed before this, so its format is one with a view. + name, form = (value if isinstance(value, str) else "" for value in (item.get("name"), item.get("format"))) + return f"{check['file']} entry {name} is read as {form}, not xml" + + +def _whole_text(check, entry): + """An element's whole text, or a text node's, compared with the value or searched for it.""" + view, truncated, problem = _view_of(check, entry, None) + if problem is not None: + return False, problem + at = f"{check['file']} at {check['pointer']}" + if entry is None: + return False, f"{at} has no value" + form = entry.get("format") + if form not in ("xml", "zip"): + return False, f"{check['file']} is read as {form}, not xml" + kind, found, searched = _locate(view, check["pointer"]) + viewless = _viewless_entry(view, check["pointer"]) if form == "zip" else None + if viewless is not None: + return False, f"{check['file']} entry {viewless[0]} {viewless[1]}" + if any(_array_leaves_out(form, view, array) for array in searched): + return _undecided(check) + problem = _not_xml_entry(check, view) if form == "zip" else None + if problem is not None: + return False, problem + if kind == "unresolved": + return False, f"{at}: {found}" + if kind == "none": + return False, f"{at} has no value" if found is None else f"{at}: {found}" + if not (_text_at(view, check["pointer"]) if isinstance(found, str) else _is_element(found)): + return False, f"{at} is {_shown(found)}, not an element, a text node or an attribute value" + # A text node is whole; an element's text is whole only if its view left nothing out. + if form == "zip": + truncated = _zip_leaves_out(view, check["pointer"]) + if not isinstance(found, str) and truncated: + return _undecided(check) + return _compared_text(check, at, _element_text(found)) + + +def _compared_text(check, at: str, found: str): + """A text found at a pointer compared with the check's value or searched for it, both collapsed when it asks.""" + collapse = check.get("normalize_whitespace") is True + text = _collapse_whitespace(found) if collapse else found + value = _collapse_whitespace(check["value"]) if collapse else check["value"] + how = " with whitespace collapsed" if collapse else "" + if check["kind"] == "text_equals": + if text == value: + return True, f"{at} has the text {_shown(value)}{how}" + return False, f"{at} has the text {_shown(text)}{how}, not {_shown(value)}" + if value in text: + return True, f"{at} has text containing {_shown(value)}{how}" + return False, f"{at} has the text {_shown(text)}{how}, without {_shown(value)}" + + def _rows(check, entry): view, _, problem = _view_of(check, entry, "sqlite") if problem is not None: @@ -306,6 +405,8 @@ def _verdict(check, entry): return _lines(check, entry) if kind in ("value_equals", "value_absent"): return _value(check, entry) + if kind in ("text_equals", "text_contains"): + return _whole_text(check, entry) return _rows(check, entry) diff --git a/src/domain/desktop/stateChanges.ts b/src/domain/desktop/stateChanges.ts index 7885d70abe..2183536a7e 100644 --- a/src/domain/desktop/stateChanges.ts +++ b/src/domain/desktop/stateChanges.ts @@ -352,8 +352,11 @@ const compareViews = ( } }; -/** How two zip views differ by name-addressed pointer, a member added or removed whole noted once, without its contents. */ -export const diffZipMembers = ( +/** + * How two views differ by name-addressed pointer, a member added or removed + * whole noted once, without its contents: an archive's entries, or an XML tree. + */ +export const diffNamedMembers = ( before: JsonValue, after: JsonValue, ): DesktopStateDifference => diff --git a/src/domain/desktop/stateFormats.test.ts b/src/domain/desktop/stateFormats.test.ts index 60377217b1..7c270a2fb1 100644 --- a/src/domain/desktop/stateFormats.test.ts +++ b/src/domain/desktop/stateFormats.test.ts @@ -4,8 +4,8 @@ import { detectStateFormat, jsonView, keyfileView, - textView, textSyntaxFormat, + textView, xmlView, } from "./stateFormats.js"; @@ -111,7 +111,7 @@ describe("views", () => { }); }); - it("reads XML into an element tree and refuses a DOCTYPE", () => { + it("reads XML into an element tree and refuses a DOCTYPE with an internal subset", () => { expect(xmlView('text')).toEqual({ root: { name: "a", @@ -121,7 +121,63 @@ describe("views", () => { truncated: false, }); expect(xmlView(']>&e;')).toBeUndefined(); + expect(xmlView(']>')).toBeUndefined(); + expect(xmlView("")).toBeUndefined(); }); + + it("reads a document whose DOCTYPE only names its DTD as the same tree, recording the declaration", () => { + const tree = 'text & more'; + const declared = `\n\n${tree}`; + + expect(xmlView(declared)).toEqual({ + doctype: { + name: "a", + public_id: "-//X//DTD A 1.0//EN", + system_id: "http://example.test/a.dtd", + }, + ...(xmlView(tree) as object), + }); + expect(xmlView(`${tree}`)).toMatchObject({ + doctype: { name: "a", public_id: null, system_id: null }, + }); + }); + + it("refuses a reference to an entity the document cannot declare, even with an external DTD", () => { + expect(xmlView('&e;')).toBeUndefined(); + expect(xmlView('')).toBeUndefined(); + }); +}); + +describe("detectStateFormat with a DOCTYPE", () => { + it("reads an AbiWord document, which names its DTD, as XML, and one declaring entities as text", () => { + const abiword = (subset: string) => + bytes( + `\n\n

Hello

\n`, + ); + + expect(detectStateFormat("/h/Documents/note.abw", abiword(""))).toBe("xml"); + expect( + detectStateFormat( + "/h/Documents/note.abw", + abiword(' []'), + ), + ).toBe("text"); + }); + + it.each([ + '', + ']>&lol;', + '&lol;', + '', + ])( + "tells XML by syntax alone as it does when it builds the document: %j", + (body) => { + const text = `\n${body}\n`; + expect(textSyntaxFormat("/h/Documents/note.abw", text)).toBe( + detectStateFormat("/h/Documents/note.abw", bytes(text)), + ); + }, + ); }); describe("captured data safety and fidelity", () => { @@ -155,6 +211,53 @@ describe("captured data safety and fidelity", () => { }); }); + it("keeps whitespace between the runs of mixed content, which holds text of its own", () => { + expect( + xmlView("

Meet on Sunday evening.\n \n

"), + ).toEqual({ + root: { + name: "p", + attributes: {}, + children: [ + "Meet on ", + { name: "s", attributes: {}, children: ["Sunday"] }, + " ", + { name: "s", attributes: {}, children: ["evening"] }, + ".", + { + name: "q", + attributes: {}, + children: [{ name: "s", attributes: {}, children: [] }], + }, + ], + }, + truncated: false, + }); + }); + + it("drops only XML whitespace as formatting: a no-break space between elements is text", () => { + const b = { name: "b", attributes: {}, children: [] }; + const between = (text: string) => `
${text}`; + + expect( + xmlView(`${[" ", "\u0085", "", "\t\r\n "].map(between).join("")}`), + ).toEqual({ + root: { + name: "r", + attributes: {}, + children: [ + ...[" ", "\u0085", ""].map((text) => ({ + name: "a", + attributes: {}, + children: [b, text, b], + })), + { name: "a", attributes: {}, children: [b, b] }, + ], + }, + truncated: false, + }); + }); + it("keeps whitespace runs a leaf's comments separate", () => { expect(xmlView(" ")).toEqual({ root: { name: "a", attributes: {}, children: [" ", " ", " "] }, diff --git a/src/domain/desktop/stateFormats.ts b/src/domain/desktop/stateFormats.ts index d4ee0a3855..9294e6b52a 100644 --- a/src/domain/desktop/stateFormats.ts +++ b/src/domain/desktop/stateFormats.ts @@ -4,7 +4,11 @@ import { isExactJsonNumber } from "../exactJsonNumber.js"; import { MAX_JSON_DEPTH, type JsonValue } from "../jsonValue.js"; import { err, ok, type Result } from "../result.js"; import { safeParseJson } from "../safeJson.js"; -import { isWellFormedXml, parseXmlDocument } from "../xmlDocument.js"; +import { + declaredDocumentType, + isWellFormedXml, + parseXmlDocument, +} from "../xmlDocument.js"; /** Formats the state view reads into JSON. */ export type DesktopStateFormat = @@ -27,6 +31,8 @@ const GVDB_HEADER = "GVariant"; /** A zip archive's local file header, which FreeCAD, ODF, OOXML, EPUB and JAR files begin with. */ const ZIP_HEADER = "PK\u0003\u0004"; const XML_EXTENSIONS = /\.(?:xml|xbel|plist|svg|ui)$/iu; +/** A DOCTYPE that only names its DTD reads as XML; one with an internal subset, which can declare entities, does not. */ +const XML_POLICY = { doctype: "external", lineEndings: "normalize" } as const; const MAX_TEXT_LINES = 5000; const MAX_XML_NODES = 5000; const MAX_XML_DEPTH = 64; @@ -227,7 +233,7 @@ export const textSyntaxFormat = ( ): "json" | "xml" | "text" => { const trimmed = text.trimStart(); if (namedJson(path, trimmed) && isJsonText(text)) return "json"; - if (namedXml(path, trimmed) && isWellFormedXml(text, { doctype: "reject" })) + if (namedXml(path, trimmed) && isWellFormedXml(text, XML_POLICY)) return "xml"; return "text"; }; @@ -253,12 +259,7 @@ export const detectStateFormat = ( ) return "json"; if (namedXml(path, trimmed)) - return parseXmlDocument(text, { - doctype: "reject", - lineEndings: "normalize", - }).ok - ? "xml" - : "text"; + return parseXmlDocument(text, XML_POLICY).ok ? "xml" : "text"; return isKeyfile(text) ? "keyfile" : "text"; }; @@ -351,10 +352,33 @@ interface XmlBudget { interface XmlScope { /** `xml:space="preserve"` applies. */ readonly preserve: boolean; - /** The parent holds child elements, so whitespace-only text between them is formatting. */ + /** + * The parent holds child elements and no text but whitespace, so whitespace-only + * text between them is formatting; in mixed content, text beside elements, it is text. + */ readonly elementContent: boolean; } +/** XML's whitespace: space, tab, carriage return and line feed, not Unicode's wider set. */ +const XML_WHITESPACE = /^[ \t\r\n]*$/u; + +const isTextNode = (node: Node): boolean => + node.nodeType === node.TEXT_NODE || node.nodeType === node.CDATA_SECTION_NODE; + +/** Whether an element holds child elements and no text but whitespace beside them. */ +const holdsElementContent = (element: Element): boolean => { + let elements = false; + for ( + let child = element.firstChild; + child !== null; + child = child.nextSibling + ) + if (child.nodeType === child.ELEMENT_NODE) elements = true; + else if (isTextNode(child) && !XML_WHITESPACE.test(child.nodeValue ?? "")) + return false; + return elements; +}; + const xmlNode = ( node: Node, depth: number, @@ -366,12 +390,9 @@ const xmlNode = ( return undefined; } budget.remaining -= 1; - if ( - node.nodeType === node.TEXT_NODE || - node.nodeType === node.CDATA_SECTION_NODE - ) { + if (isTextNode(node)) { const text = node.nodeValue ?? ""; - return text.trim().length === 0 && scope.elementContent && !scope.preserve + return XML_WHITESPACE.test(text) && scope.elementContent && !scope.preserve ? undefined : text; } @@ -383,17 +404,10 @@ const xmlNode = ( if (attribute !== null) attributes[attribute.name] = attribute.value; } const space = element.getAttribute("xml:space"); - let elementContent = false; - for ( - let child = element.firstChild; - child !== null && !elementContent; - child = child.nextSibling - ) - elementContent = child.nodeType === child.ELEMENT_NODE; const inner: XmlScope = { preserve: space === null || space === "" ? scope.preserve : space === "preserve", - elementContent, + elementContent: holdsElementContent(element), }; const children: JsonValue[] = []; for ( @@ -407,22 +421,35 @@ const xmlNode = ( return { name: element.tagName, attributes, children }; }; -/** An XML document as a bounded element tree; DOCTYPEs are refused. */ +/** + * An XML document as a bounded element tree. A DOCTYPE that only names its DTD + * is recorded as `doctype`, its DTD never read; one with an internal subset is + * refused, as is a reference to any entity but XML's five. + */ export const xmlView = (text: string): JsonValue | undefined => { - const parsed = parseXmlDocument(text, { - doctype: "reject", - lineEndings: "normalize", - }); + const parsed = parseXmlDocument(text, XML_POLICY); const root = parsed.ok ? parsed.value.documentElement : null; - if (root === null) return undefined; + if (!parsed.ok || root === null) return undefined; const budget: XmlBudget = { remaining: MAX_XML_NODES, truncated: false }; const tree = xmlNode(root, 0, budget, { preserve: false, elementContent: false, }); - return tree === undefined - ? undefined - : { root: tree, truncated: budget.truncated }; + if (tree === undefined) return undefined; + const doctype = declaredDocumentType(parsed.value); + return { + ...(doctype === undefined + ? {} + : { + doctype: { + name: doctype.name, + public_id: doctype.publicId, + system_id: doctype.systemId, + }, + }), + root: tree, + truncated: budget.truncated, + }; }; /** Formats read from a file's bytes alone, without a tool or worker. */ @@ -460,7 +487,7 @@ export const textualView = ( case "xml": { const tree = xmlView(text); return tree === undefined - ? err("not well-formed XML, or it declares a DOCTYPE") + ? err("not well-formed XML, or its DOCTYPE has an internal subset") : ok(tree); } } diff --git a/src/domain/xmlDocument.test.ts b/src/domain/xmlDocument.test.ts index 3b62828ccb..ff6c26380c 100644 --- a/src/domain/xmlDocument.test.ts +++ b/src/domain/xmlDocument.test.ts @@ -1,5 +1,9 @@ import { expect, it } from "vitest"; -import { parseXmlDocument } from "./xmlDocument.js"; +import { + declaredDocumentType, + isWellFormedXml, + parseXmlDocument, +} from "./xmlDocument.js"; it.each([ '😀 � & �', @@ -95,6 +99,99 @@ it("applies the consumer's explicit DTD policy", () => { expect(parsed.error.reason).toBe("doctype"); }); +it.each([ + "", + '', + "", +])( + "reads a DOCTYPE without an internal subset when only that is allowed: %j", + (source) => { + const parsed = parseXmlDocument(source, { + doctype: "external", + lineEndings: "normalize", + }); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.documentElement?.tagName).toBe("root"); + }, +); + +it.each([ + "", + ']>', + '', +])( + "refuses an internal subset when only an external DTD is allowed: %j", + (source) => { + expect( + parseXmlDocument(source, { + doctype: "external", + lineEndings: "normalize", + }), + ).toMatchObject({ ok: false, error: { reason: "internal-subset" } }); + }, +); + +it.each([ + ['', true], + ['', true], + ["", true], + ["", false], + ['&e;', false], + ['', false], + ['', false], + ["", false], + ['', false], + ["", false], +])( + "tells XML by the syntax pass alone as the DOM parse does under the external policy: %j", + (source, xml) => { + expect(isWellFormedXml(source, { doctype: "external" })).toBe(xml); + expect( + parseXmlDocument(source, { + doctype: "external", + lineEndings: "normalize", + }).ok, + ).toBe(xml); + }, +); + +it("reports a document type's name and identifiers as declared, the public one normalized", () => { + const read = (source: string) => { + const parsed = parseXmlDocument(source, { + doctype: "external", + lineEndings: "normalize", + }); + if (!parsed.ok) throw parsed.error; + return declaredDocumentType(parsed.value); + }; + + expect( + read( + '', + ), + ).toEqual({ + name: "abiword", + publicId: "-//ABISOURCE//DTD AWML 1.0 Strict//EN", + systemId: "http://www.abisource.com/awml.dtd", + }); + expect(read("")).toEqual({ + name: "a", + publicId: null, + systemId: "x y", + }); + expect(read('')).toEqual({ + name: "a", + publicId: "", + systemId: "", + }); + expect(read("")).toEqual({ + name: "a", + publicId: null, + systemId: null, + }); + expect(read("")).toBeUndefined(); +}); + it("applies XML line-ending normalization only when the consumer selects it", () => { const parsed = parseXmlDocument('\r\nx', { doctype: "allow", diff --git a/src/domain/xmlDocument.ts b/src/domain/xmlDocument.ts index 532abce214..e5a188d2a5 100644 --- a/src/domain/xmlDocument.ts +++ b/src/domain/xmlDocument.ts @@ -8,35 +8,80 @@ export const XML_LINE_ENDING = /\r\n?|\n/gu; export class XmlDocumentParseError extends Error { readonly _tag = "XmlDocumentParseError"; constructor( - readonly reason: "malformed" | "doctype", + readonly reason: "malformed" | "doctype" | "internal-subset", cause?: unknown, ) { super( reason === "doctype" ? "XML document types are unsupported" - : `XML is not well-formed${cause instanceof Error ? `: ${cause.message}` : ""}`, + : reason === "internal-subset" + ? "XML document type internal subsets are unsupported" + : `XML is not well-formed${cause instanceof Error ? `: ${cause.message}` : ""}`, { cause }, ); } } -/** Read XML syntax as a stream, throwing at the first error; it holds only the open elements. */ -const checkXmlSyntax = (source: string, doctype: "allow" | "reject"): void => { +/** Whether a DOCTYPE's text, as saxes reports it, opens an internal subset: a `[` outside its quoted literals. */ +const declaresInternalSubset = (declaration: string): boolean => + declaration.replace(/"[^"]*"|'[^']*'/gu, "").includes("["); + +/** XML 1.0's `NameStartChar`, and the further characters `NameChar` allows. */ +const NAME_START = String.raw`:A-Z_a-z\u{C0}-\u{D6}\u{D8}-\u{F6}\u{F8}-\u{2FF}\u{370}-\u{37D}\u{37F}-\u{1FFF}\u{200C}\u{200D}\u{2070}-\u{218F}\u{2C00}-\u{2FEF}\u{3001}-\u{D7FF}\u{F900}-\u{FDCF}\u{FDF0}-\u{FFFD}\u{10000}-\u{EFFFF}`; +const NAME_MORE = String.raw`\-.0-9\u{B7}\u{300}-\u{36F}\u{203F}\u{2040}`; +/** XML 1.0's `PubidChar` but the apostrophe, which only a double-quoted literal may hold. */ +const PUBID = String.raw`\n\r a-zA-Z0-9\-()+,./:=?;!*#@$_%`; +const SPACE = String.raw`[ \t\r\n]`; + +/** + * A DOCTYPE without an internal subset, as XML 1.0's `doctypedecl` spells it + * between ``: a name, then optionally `SYSTEM` and a literal, + * or `PUBLIC` and a public identifier and a literal. + */ +const EXTERNAL_DOCTYPE = new RegExp( + `^${SPACE}+[${NAME_START}][${NAME_START}${NAME_MORE}]*` + + `(?:${SPACE}+(?:SYSTEM|PUBLIC${SPACE}+(?:"[${PUBID}']*"|'[${PUBID}]*'))` + + `${SPACE}+(?:"[^"]*"|'[^']*'))?${SPACE}*$`, + "u", +); + +/** + * Which DOCTYPE a document may have: any, none, or with `external` one + * without an internal subset, so the document declares no entity. Neither + * parser reads an external DTD. + */ +type DoctypePolicy = "allow" | "reject" | "external"; + +/** + * Read XML syntax as a stream, throwing at the first error; it holds only the + * open elements. Saxes reads a DOCTYPE without checking it, so the policy + * checks one it admits without an internal subset against XML's grammar. + */ +const checkXmlSyntax = (source: string, doctype: DoctypePolicy): void => { const syntax = new SaxesParser({ xmlns: true, defaultXMLVersion: "1.0", forceXMLVersion: true, }); - syntax.on("doctype", () => { + syntax.on("doctype", (declaration) => { if (doctype === "reject") throw new XmlDocumentParseError("doctype"); + if (doctype !== "external") return; + if (declaresInternalSubset(declaration)) + throw new XmlDocumentParseError("internal-subset"); + if (!EXTERNAL_DOCTYPE.test(declaration)) + throw new XmlDocumentParseError("malformed"); }); syntax.write(source).close(); }; -/** Whether XML passes the syntax check `parseXmlDocument` makes, without building a document. */ +/** + * Whether XML passes the syntax check `parseXmlDocument` makes, without + * building a document. An internal subset's syntax needs the DOM parser, so + * the policy must refuse one. + */ export const isWellFormedXml = ( source: string, - policy: { readonly doctype: "allow" | "reject" }, + policy: { readonly doctype: Exclude }, ): boolean => { try { checkXmlSyntax(source, policy.doctype); @@ -46,11 +91,11 @@ export const isWellFormedXml = ( } }; -/** One boundary owns strict XML syntax and the source-located DOM projection. */ +/** One boundary owns strict XML syntax, under a DOCTYPE policy, and the source-located DOM projection. */ export const parseXmlDocument = ( source: string, policy: { - readonly doctype: "allow" | "reject"; + readonly doctype: DoctypePolicy; readonly lineEndings: "preserve" | "normalize"; }, ): Result => { @@ -76,3 +121,35 @@ export const parseXmlDocument = ( ); } }; + +/** A DOCTYPE's name and external identifiers, null where it gives none. */ +export interface DeclaredDocumentType { + readonly name: string; + readonly publicId: string | null; + readonly systemId: string | null; +} + +/** A quoted literal's value; the DOM keeps the quotes, and holds "" where there is no literal. */ +const literalValue = (literal: string): string | null => + literal === "" ? null : literal.slice(1, -1); + +/** + * The document's DOCTYPE as declared, or undefined without one. The public + * identifier is normalized as XML matches it, and as expat reports it: each + * run of spaces and line breaks becomes one space, and the ends are trimmed. + */ +export const declaredDocumentType = ( + document: Document, +): DeclaredDocumentType | undefined => { + const doctype = document.doctype; + if (doctype === null) return undefined; + const publicId = literalValue(doctype.publicId); + return { + name: doctype.name, + publicId: + publicId === null + ? null + : publicId.replace(/[ \r\n]+/gu, " ").replace(/^ | $/gu, ""), + systemId: literalValue(doctype.systemId), + }; +}; diff --git a/tests/boundary/providers/desktopXmlPython.test.ts b/tests/boundary/providers/desktopXmlPython.test.ts index b3c699e483..ee549c1cd8 100644 --- a/tests/boundary/providers/desktopXmlPython.test.ts +++ b/tests/boundary/providers/desktopXmlPython.test.ts @@ -1,50 +1,22 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; - import { describe, expect, it } from "vitest"; import type { DesktopTaskCheck } from "../../../src/domain/desktop/appTask.js"; import { xmlView } from "../../../src/domain/desktop/stateFormats.js"; -import { zipArchive } from "../../../src/domain/desktop/zipArchive.fixture.js"; import { expectSameCheckResults, expectSameFileViews, + odtOf, pythonXmlViews, + savedEnds, typescriptFileView, } from "../../support/desktopExportPython.js"; -const FIXTURES = join(import.meta.dirname, "../../fixtures"); -const ENDS = ["before", "after"] as const; - /** Inkscape's saves of the poster, before and after the headline was changed. */ -const posters = () => - Promise.all( - ENDS.map(async (end) => ({ - name: "poster.svg", - bytes: await readFile(join(FIXTURES, "inkscape", end, "poster.svg")), - })), - ); +const posters = () => savedEnds("inkscape", "poster.svg"); /** LibreOffice Writer's saves of the flyer, before and after the date was changed, zipped with their mimetype. */ const flyers = () => - Promise.all( - ENDS.map(async (end) => ({ - name: "flyer.odt", - bytes: zipArchive([ - { - name: "mimetype", - data: "application/vnd.oasis.opendocument.text", - method: 0, - }, - { - name: "content.xml", - data: await readFile( - join(FIXTURES, "libreoffice", end, "content.xml"), - ), - }, - ]), - })), - ); + savedEnds("libreoffice", "content.xml", "flyer.odt", odtOf); const POSTER = "Pictures/poster.svg"; const FLYER = "Documents/flyer.odt"; @@ -171,7 +143,7 @@ describe("namespaced XML in the exported module", () => { '', '', "", - "", + ']>', ]; const ours = texts.map((text) => xmlView(text) ?? null); @@ -183,6 +155,22 @@ describe("namespaced XML in the exported module", () => { }); }); + it("keeps whitespace between runs of mixed content, and drops only XML whitespace, as REA does", () => { + const texts = [ + "

Meet on Sunday evening.\n \n

", + "

Sunday evening

", + "
x", + " \u0085\t\r\n ", + ]; + + const ours = texts.map((text) => xmlView(text) ?? null); + + expect(pythonXmlViews(texts)).toEqual(ours); + expect(ours[1]).toMatchObject({ + root: { children: [{ name: "s" }, { name: "s" }] }, + }); + }); + it("keeps REA's node and depth limits on prefixed names", () => { const declared = 'xmlns:p="urn:u"'; const texts = [ diff --git a/tests/boundary/providers/desktopXmlTextPython.test.ts b/tests/boundary/providers/desktopXmlTextPython.test.ts new file mode 100644 index 0000000000..a63a4bcd4b --- /dev/null +++ b/tests/boundary/providers/desktopXmlTextPython.test.ts @@ -0,0 +1,212 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import type { DesktopTaskCheck } from "../../../src/domain/desktop/appTask.js"; +import { xmlView } from "../../../src/domain/desktop/stateFormats.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + expectSameCheckResults, + expectSameFileViews, + odtOf, + pythonXmlViews, + savedEnds, + typescriptFileView, +} from "../../support/desktopExportPython.js"; + +/** The meeting note as AbiWord saves it, before and after its time was changed. */ +const notes = () => savedEnds("abiword", "meeting.abw"); + +/** Writer's flyer before and after an edit split its body paragraph into runs, zipped with its mimetype. */ +const flyers = () => + savedEnds("libreoffice-runs", "content.xml", "flyer.odt", odtOf); + +const NOTE = "Documents/meeting.abw"; +const PARAGRAPH = "/root/children/[name=section]/children/[name=p][@xid=3]"; +const MEETING = "The team meeting starts at 3:30 in room 4."; + +/** The paragraph's whole text, a time within it, and its first text node, which the edit split. */ +const NOTE_CHECKS: DesktopTaskCheck[] = [ + { + id: "time", + kind: "text_equals", + file: NOTE, + pointer: PARAGRAPH, + value: MEETING, + }, + { + id: "within", + kind: "text_contains", + file: NOTE, + pointer: PARAGRAPH, + value: "at 3:30 in", + }, + { + id: "first-node", + kind: "value_equals", + file: NOTE, + pointer: `${PARAGRAPH}/children/0`, + value: MEETING, + }, +]; + +const FLYER = "Documents/flyer.odt"; +const BODY = + "/entries/[name=content.xml]/view/root/children/[name=office:body]/children/[name=office:text]/children/[name=text:p][@text:style-name=Standard]"; +const CAKES = "Cakes, biscuits and bread from 9:30 in the village green."; + +const FLYER_CHECKS: DesktopTaskCheck[] = [ + { id: "body", kind: "text_equals", file: FLYER, pointer: BODY, value: CAKES }, + { + id: "time", + kind: "text_contains", + file: FLYER, + pointer: BODY, + value: " 9:30\n", + normalize_whitespace: true, + }, + { + id: "reflowed", + kind: "text_equals", + file: FLYER, + pointer: BODY, + value: " Cakes, biscuits and bread\n from 9:30 in the village green. ", + normalize_whitespace: true, + }, + { + id: "first-run", + kind: "value_equals", + file: FLYER, + pointer: `${BODY}/children/0`, + value: CAKES, + }, +]; + +const passes = (outcomes: readonly { passed: boolean }[] | undefined) => + outcomes?.map(({ passed }) => passed); + +describe("XML with a DOCTYPE in the exported module", () => { + it("reads an AbiWord document, which names its DTD, as REA does: the element tree and the declaration", async () => { + const files = await notes(); + await expectSameFileViews(files); + + const [, after] = files; + if (after === undefined) throw new Error("fixture"); + const { format, view } = await typescriptFileView(after); + expect(format).toBe("xml"); + expect(view).toMatchObject({ + doctype: { + name: "abiword", + public_id: "-//ABISOURCE//DTD AWML 1.0 Strict//EN", + system_id: "http://www.abisource.com/awml.dtd", + }, + root: { name: "abiword", attributes: { "xml:space": "preserve" } }, + truncated: false, + }); + }); + + it("reads a DOCTYPE that only names a DTD alike, and refuses an internal subset or an undeclared entity in both", () => { + const accepted = [ + "", + 't', + "", + '&"'>', + '', + '', + ]; + const refused = [ + "", + ']>', + ']>&e;', + ']>&e;', + ' %p;]>', + '&e;', + '', + '', + '', + "", + "", + ]; + const texts = [...accepted, ...refused]; + + const ours = texts.map((text) => xmlView(text) ?? null); + + expect(pythonXmlViews(texts)).toEqual(ours); + expect(ours.slice(0, accepted.length)).not.toContain(null); + expect(ours.slice(accepted.length)).toEqual(refused.map(() => null)); + expect(ours[2]).toMatchObject({ + doctype: { name: "a", public_id: "-//A B//EN", system_id: "s y" }, + }); + expect(ours[5]).toMatchObject({ + doctype: { name: "a", public_id: null, system_id: "x\ny\nz\n" }, + }); + }); + + it("never reads the DTD a DOCTYPE names: neither its entities nor its default attributes apply", async () => { + const folder = await createTestTempDirectory("desktop-xml-dtd-"); + const dtd = join(folder, "a.dtd"); + await writeFile( + dtd, + '', + ); + const system = pathToFileURL(dtd).href; + const texts = [ + ``, + `&e;`, + ``, + ]; + + const ours = texts.map((text) => xmlView(text) ?? null); + + expect(pythonXmlViews(texts)).toEqual(ours); + expect(ours).toEqual([ + { + doctype: { name: "a", public_id: null, system_id: system }, + root: { name: "a", attributes: {}, children: [] }, + truncated: false, + }, + null, + null, + ]); + }); +}); + +describe("whole-text checks in both evaluators", () => { + it("checks the meeting time across AbiWord's runs: the whole text fails before and passes after, its first node does not", async () => { + const [before, after] = await expectSameCheckResults( + NOTE, + NOTE_CHECKS, + await notes(), + ); + + expect(passes(before)).toEqual([false, false, false]); + for (const fact of [ + PARAGRAPH, + JSON.stringify("The team meeting starts at 2:00 in room 4."), + ]) + expect(before?.[0]?.description).toContain(fact); + expect(passes(after)).toEqual([true, true, false]); + // The first text node holds only the text before the first run. + expect(after?.[2]?.description).toContain( + JSON.stringify("The team meeting starts at "), + ); + }); + + it("reads Writer's runs in its archive alike, the space between two runs included", async () => { + const files = await flyers(); + await expectSameFileViews(files); + + const [before, after] = await expectSameCheckResults( + FLYER, + FLYER_CHECKS, + files, + ); + + expect(passes(before)).toEqual([false, false, false, false]); + expect(passes(after)).toEqual([true, true, true, false]); + // The value searched for is the collapsed one, " 9:30\n" read as "9:30". + expect(after?.[1]?.description).toContain(JSON.stringify("9:30")); + }); +}); diff --git a/tests/boundary/providers/desktopXmlWhitespacePython.test.ts b/tests/boundary/providers/desktopXmlWhitespacePython.test.ts index 6ea9560acd..2a79e439d6 100644 --- a/tests/boundary/providers/desktopXmlWhitespacePython.test.ts +++ b/tests/boundary/providers/desktopXmlWhitespacePython.test.ts @@ -40,7 +40,7 @@ it("matches the ECMAScript whitespace set for XML-valid characters and preserves expect(pythonViews(texts)).toEqual(texts.map(xmlView)); }); -it("keeps leaf text and xml:space-preserved text, with the original parsing refusals", () => { +it("keeps leaf text and xml:space-preserved text, reads a plain DOCTYPE as REA does, and refuses an unbound prefix", () => { const texts = [ "\ufeff", "\u0085", @@ -53,7 +53,7 @@ it("keeps leaf text and xml:space-preserved text, with the original parsing refu expected.map((view) => view.root.children), ); expect(pythonViews(["", ""])).toEqual([ - null, + xmlView(""), null, ]); }); diff --git a/tests/fixtures/abiword/README.md b/tests/fixtures/abiword/README.md new file mode 100644 index 0000000000..ad78da1e97 --- /dev/null +++ b/tests/fixtures/abiword/README.md @@ -0,0 +1,15 @@ +# AbiWord documents + +Each `meeting.abw` is synthetic: written by hand in the form AbiWord 3.0 +saves its native format, AWML 1.1, not saved by a real AbiWord. Like a real +save it is XML that starts with a `DOCTYPE` naming the AWML DTD by public and +system identifier, with no internal subset, keeps `xml:space="preserve"` on its +`abiword` root, and writes a paragraph as a `p` element numbered by `xid`. + +`before` holds a planning note whose paragraph `xid="3"` reads +`The team meeting starts at 2:00 in room 4.` `after` is the same note once the +time was changed to `3:30` after the document was reopened: AbiWord wraps text +typed into a reopened document in `c` elements carrying the typing language, +so the paragraph's text is split across its children as +`The team meeting starts at 3:30 in room 4.` The save also +updates the metadata date and adds a `history` version. diff --git a/tests/fixtures/abiword/after/meeting.abw b/tests/fixtures/abiword/after/meeting.abw new file mode 100644 index 0000000000..b9972db48c --- /dev/null +++ b/tests/fixtures/abiword/after/meeting.abw @@ -0,0 +1,31 @@ + + + + + + + + + + + +Thu Nov 5 09:20:31 2026 + +AbiWord +ada +application/x-abiword + + + + + + + + + +
+

Weekly planning

+

The team meeting starts at 3:30 in room 4.

+

+
+
diff --git a/tests/fixtures/abiword/before/meeting.abw b/tests/fixtures/abiword/before/meeting.abw new file mode 100644 index 0000000000..d509c5b7d0 --- /dev/null +++ b/tests/fixtures/abiword/before/meeting.abw @@ -0,0 +1,30 @@ + + + + + + + + + + + +Thu Nov 5 09:12:44 2026 + +AbiWord +ada +application/x-abiword + + + + + + + + +
+

Weekly planning

+

The team meeting starts at 2:00 in room 4.

+

+
+
diff --git a/tests/fixtures/libreoffice-runs/README.md b/tests/fixtures/libreoffice-runs/README.md new file mode 100644 index 0000000000..a04598c324 --- /dev/null +++ b/tests/fixtures/libreoffice-runs/README.md @@ -0,0 +1,16 @@ +# LibreOffice Writer paragraphs split into runs + +Each `content.xml` is synthetic: the real LibreOffice 24.2 save in +[`../libreoffice/after`](../libreoffice/README.md), with its body paragraph +rewritten by hand in the form Writer saves text edited while it records +revision IDs (its default). Writer gives text typed in one editing session an +automatic text style, here `T1`, carrying that session's `officeooo:rsid`, and +writes each stretch of it as a `text:span`, so one paragraph's text is split +across several children. + +`before` has the body paragraph `Cakes, biscuits and bread from 10:00 in the +school hall.` with no automatic styles. `after` is the flyer once the time was +changed to `9:30` and the place to `village green`, each word typed over in +the same session: `9`, `30`, `village` and `green` are spans of `T1`, and the +`:` and the space between `village` and `green` stay plain text of the +paragraph. diff --git a/tests/fixtures/libreoffice-runs/after/content.xml b/tests/fixtures/libreoffice-runs/after/content.xml new file mode 100644 index 0000000000..598a499526 --- /dev/null +++ b/tests/fixtures/libreoffice-runs/after/content.xml @@ -0,0 +1,2 @@ + +Summer Bake SaleMonday 24 NovemberCakes, biscuits and bread from 9:30 in the village green. \ No newline at end of file diff --git a/tests/fixtures/libreoffice-runs/before/content.xml b/tests/fixtures/libreoffice-runs/before/content.xml new file mode 100644 index 0000000000..5245efc41a --- /dev/null +++ b/tests/fixtures/libreoffice-runs/before/content.xml @@ -0,0 +1,2 @@ + +Summer Bake SaleMonday 24 NovemberCakes, biscuits and bread from 10:00 in the school hall. \ No newline at end of file diff --git a/tests/support/desktopExportPython.ts b/tests/support/desktopExportPython.ts index f4c1f421a5..8c4af73edd 100644 --- a/tests/support/desktopExportPython.ts +++ b/tests/support/desktopExportPython.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; @@ -20,6 +20,7 @@ import { detectStateFormat, textualView, } from "../../src/domain/desktop/stateFormats.js"; +import { zipArchive } from "../../src/domain/desktop/zipArchive.fixture.js"; import type { JsonValue } from "../../src/domain/jsonValue.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; @@ -29,6 +30,37 @@ export interface SavedFile { readonly bytes: Uint8Array; } +const FIXTURES = join(import.meta.dirname, "../fixtures"); + +/** + * A fixture's two saves, before and after its edit, read from + * `tests/fixtures/FOLDER/{before,after}/FILE` as the file `name`, each + * wrapped, such as a Writer `content.xml` zipped into an `.odt`. + */ +export const savedEnds = ( + folder: string, + file: string, + name: string = file, + wrap: (bytes: Uint8Array) => Uint8Array = (bytes) => bytes, +): Promise => + Promise.all( + (["before", "after"] as const).map(async (end) => ({ + name, + bytes: wrap(await readFile(join(FIXTURES, folder, end, file))), + })), + ); + +/** A Writer `content.xml` zipped into an `.odt` after its stored `mimetype`, as Writer saves one. */ +export const odtOf = (content: Uint8Array): Uint8Array => + zipArchive([ + { + name: "mimetype", + data: "application/vnd.oasis.opendocument.text", + method: 0, + }, + { name: "content.xml", data: content }, + ]); + /** The exported module's standard library imports, its helpers and the verifier's evaluator, as one program. */ const PYTHON = `${PYTHON_IMPORTS.split("\n") .filter((line) => !line.includes("agentenv_protocol")) From 99db80a13e369885e7de99417475c25380446081 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:52:39 +0800 Subject: [PATCH 091/151] fix(cli): reject non-decimal integer tokens on every numeric option (#9178) --- src/cli/adbCommands.ts | 20 +-- src/cli/apiContractCommand.ts | 3 +- src/cli/apktoolCommands.ts | 4 +- src/cli/binaryDiagnosticsCommands.ts | 103 +++++++---- src/cli/browserAdvancedCommands.ts | 20 +-- src/cli/browserCommands.ts | 13 +- src/cli/captureCommands.ts | 35 ++-- src/cli/coreNativeCommands.ts | 134 +++++--------- src/cli/decompileDirectoryCommand.ts | 15 +- src/cli/desktopCaptureCommands.ts | 4 +- src/cli/evmCommands.ts | 5 +- src/cli/firmwareCommands.ts | 33 ++-- src/cli/fridaCommands.ts | 33 ++-- src/cli/javascriptRuntimeCommands.ts | 10 +- src/cli/jebCommands.ts | 5 +- src/cli/options.ts | 161 ++++++++++++++--- src/cli/reverseEngineeringCommands.ts | 9 +- src/cli/signalCommands.ts | 39 ++-- src/cli/sqliteCommands.ts | 16 +- src/cli/utilityCommands.ts | 12 +- src/cli/wasmCommands.ts | 5 +- src/cli/webRuntimeCommands.ts | 15 +- src/cli/webSourceLocationCommands.ts | 6 +- src/cliObservationOptions.test.ts | 13 +- src/cliObservationOptions.ts | 55 +++--- src/cliOutput.ts | 4 +- .../browser/observationDurationCli.test.ts | 2 +- tests/acceptance/webRuntimeCli.test.ts | 11 +- .../boundary/cli/cliDecimalArguments.test.ts | 169 ++++++++++++++++++ .../cli/cliInputSchemaRequired.test.ts | 4 +- tests/boundary/cli/cliOutput.test.ts | 5 +- 31 files changed, 633 insertions(+), 330 deletions(-) create mode 100644 tests/boundary/cli/cliDecimalArguments.test.ts diff --git a/src/cli/adbCommands.ts b/src/cli/adbCommands.ts index f908935da0..1773750901 100644 --- a/src/cli/adbCommands.ts +++ b/src/cli/adbCommands.ts @@ -12,6 +12,7 @@ import { type AdbOperation, } from "../domain/adb/adbDeviceAnalysis.js"; import type { Logger } from "pino"; +import { cliDecimalField } from "./options.js"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; @@ -105,23 +106,16 @@ export const registerAdbCommands = ( description: "Read a bounded dump of the device log for one buffer", args: z.object({ serial }), options: z.object({ - count: z - .number() - .int() - .min(1) - .max(10_000) - .default(500) - .describe("Number of most recent lines to read"), + count: cliDecimalField( + adbInputSchemas.read_adb_logcat.shape.count, + ).describe("Number of most recent lines to read"), buffer: z .enum(["main", "system", "radio", "events", "crash"]) .default("main") .describe("Log buffer to read"), - pid: z - .number() - .int() - .positive() - .optional() - .describe("Restrict to one process id"), + pid: cliDecimalField(adbInputSchemas.read_adb_logcat.shape.pid).describe( + "Restrict to one process id", + ), }), run: ({ args, options }) => execute(CLI_COMMANDS.readAdbLogcat, "read_adb_logcat", { diff --git a/src/cli/apiContractCommand.ts b/src/cli/apiContractCommand.ts index 56465d2e89..8ef87dc934 100644 --- a/src/cli/apiContractCommand.ts +++ b/src/cli/apiContractCommand.ts @@ -1,6 +1,7 @@ import { z } from "incur"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; +import { cliDecimalField } from "./options.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; @@ -54,7 +55,7 @@ export const registerApiContractCommand = ( }), options: z.object({ ...analysisEvidenceDeliveryOptions, - maxFileBytes: apiContractFileBytesSchema, + maxFileBytes: cliDecimalField(apiContractFileBytesSchema), contractFormat: apiContractFormatSchema .default("openapi-json") .describe("Input API contract syntax: OpenAPI JSON or GraphQL SDL"), diff --git a/src/cli/apktoolCommands.ts b/src/cli/apktoolCommands.ts index 817496eeb7..d4689938a4 100644 --- a/src/cli/apktoolCommands.ts +++ b/src/cli/apktoolCommands.ts @@ -10,6 +10,7 @@ import { type ApktoolOperation, } from "../domain/apktool/apktoolResourceAnalysis.js"; import type { Logger } from "pino"; +import { cliDecimalField } from "./options.js"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; @@ -72,8 +73,9 @@ export const registerApktoolCommands = ( apktoolInputSchemas.decode_android_resources.shape.locales.describe( "Repeat to project multiple localized string tables, e.g. --locales de --locales pt-BR", ), - decodeTimeoutMs: + decodeTimeoutMs: cliDecimalField( apktoolInputSchemas.decode_android_resources.shape.decode_timeout_ms, + ), strings: z .boolean() .default( diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index db4286666c..3e8962bdcb 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -57,8 +57,10 @@ export const registerBinaryDiagnosticsCommands = ( path: localPathStringSchema.describe("Local ELF32/ELF64 image"), }), options: z.object({ - maxFileBytes: inspectElfNotesInputSchema.shape.max_file_bytes, - maxEntries: inspectElfNotesInputSchema.shape.max_entries, + maxFileBytes: cliDecimalField( + inspectElfNotesInputSchema.shape.max_file_bytes, + ), + maxEntries: cliDecimalField(inspectElfNotesInputSchema.shape.max_entries), }), run: ({ args, options }) => withCommandCancellation(async (signal) => { @@ -93,13 +95,18 @@ export const registerBinaryDiagnosticsCommands = ( path: localPathStringSchema.describe("Local Apple .ips crash report"), }), options: z.object({ - maxFileBytes: inspectAppleIpsInputSchema.shape.max_file_bytes, - maxThreads: inspectAppleIpsInputSchema.shape.max_threads, - maxFrames: inspectAppleIpsInputSchema.shape.max_frames, - maxImages: inspectAppleIpsInputSchema.shape.max_images, - maxRegisters: inspectAppleIpsInputSchema.shape.max_registers, - maxIntegerTextBytes: + maxFileBytes: cliDecimalField( + inspectAppleIpsInputSchema.shape.max_file_bytes, + ), + maxThreads: cliDecimalField(inspectAppleIpsInputSchema.shape.max_threads), + maxFrames: cliDecimalField(inspectAppleIpsInputSchema.shape.max_frames), + maxImages: cliDecimalField(inspectAppleIpsInputSchema.shape.max_images), + maxRegisters: cliDecimalField( + inspectAppleIpsInputSchema.shape.max_registers, + ), + maxIntegerTextBytes: cliDecimalField( inspectAppleIpsInputSchema.shape.max_integer_text_bytes, + ), }), run: ({ args, options }) => withCommandCancellation(async (signal) => { @@ -140,8 +147,12 @@ export const registerBinaryDiagnosticsCommands = ( ), }), options: z.object({ - maxFileBytes: inspectKernelModuleInputSchema.shape.max_file_bytes, - maxEntries: inspectKernelModuleInputSchema.shape.max_entries, + maxFileBytes: cliDecimalField( + inspectKernelModuleInputSchema.shape.max_file_bytes, + ), + maxEntries: cliDecimalField( + inspectKernelModuleInputSchema.shape.max_entries, + ), }), run: ({ args, options }) => withCommandCancellation(async (signal) => { @@ -176,8 +187,12 @@ export const registerBinaryDiagnosticsCommands = ( path: localPathStringSchema.describe("Local PE32/PE32+ image"), }), options: z.object({ - maxFileBytes: inspectPeLoadConfigInputSchema.shape.max_file_bytes, - maxCallbacks: inspectPeLoadConfigInputSchema.shape.max_callbacks, + maxFileBytes: cliDecimalField( + inspectPeLoadConfigInputSchema.shape.max_file_bytes, + ), + maxCallbacks: cliDecimalField( + inspectPeLoadConfigInputSchema.shape.max_callbacks, + ), }), run: ({ args, options }) => withCommandCancellation(async (signal) => { @@ -212,7 +227,9 @@ export const registerBinaryDiagnosticsCommands = ( path: localPathStringSchema.describe("Local ELF, PE, or Mach-O image"), }), options: z.object({ - maxFileBytes: inspectBinaryHardeningInputSchema.shape.max_file_bytes, + maxFileBytes: cliDecimalField( + inspectBinaryHardeningInputSchema.shape.max_file_bytes, + ), }), run: ({ args, options }) => withCommandCancellation(async (signal) => { @@ -289,8 +306,12 @@ export const registerBinaryDiagnosticsCommands = ( path: localPathStringSchema.describe("Local PE32/PE32+ image"), }), options: z.object({ - maxFileBytes: inspectPeCertificatesInputSchema.shape.max_file_bytes, - maxEntries: inspectPeCertificatesInputSchema.shape.max_entries, + maxFileBytes: cliDecimalField( + inspectPeCertificatesInputSchema.shape.max_file_bytes, + ), + maxEntries: cliDecimalField( + inspectPeCertificatesInputSchema.shape.max_entries, + ), }), run: ({ args, options }) => withCommandCancellation(async (signal) => { @@ -325,9 +346,13 @@ export const registerBinaryDiagnosticsCommands = ( path: localPathStringSchema.describe("Local PE32/PE32+ image"), }), options: z.object({ - maxFileBytes: inspectPeImportsInputSchema.shape.max_file_bytes, - maxDlls: inspectPeImportsInputSchema.shape.max_dlls, - maxFunctions: inspectPeImportsInputSchema.shape.max_functions, + maxFileBytes: cliDecimalField( + inspectPeImportsInputSchema.shape.max_file_bytes, + ), + maxDlls: cliDecimalField(inspectPeImportsInputSchema.shape.max_dlls), + maxFunctions: cliDecimalField( + inspectPeImportsInputSchema.shape.max_functions, + ), }), run: ({ args, options }) => withCommandCancellation(async (signal) => { @@ -363,8 +388,12 @@ export const registerBinaryDiagnosticsCommands = ( path: localPathStringSchema.describe("Local PE32/PE32+ image"), }), options: z.object({ - maxFileBytes: inspectPeResourcesInputSchema.shape.max_file_bytes, - maxEntries: inspectPeResourcesInputSchema.shape.max_entries, + maxFileBytes: cliDecimalField( + inspectPeResourcesInputSchema.shape.max_file_bytes, + ), + maxEntries: cliDecimalField( + inspectPeResourcesInputSchema.shape.max_entries, + ), }), run: ({ args, options }) => withCommandCancellation(async (signal) => { @@ -398,18 +427,18 @@ export const registerBinaryDiagnosticsCommands = ( path: localPathStringSchema.describe("Local flattened devicetree image"), }), options: z.object({ - maxFileBytes: inspectDevicetreeInputSchema.shape.max_file_bytes.describe( - "Maximum FDT file size in bytes (default 4194304)", - ), - maxNodes: inspectDevicetreeInputSchema.shape.max_nodes.describe( - "Maximum number of device-tree nodes (default 8192)", - ), - maxProperties: inspectDevicetreeInputSchema.shape.max_properties.describe( - "Maximum number of device-tree properties (default 32768)", - ), - maxDepth: inspectDevicetreeInputSchema.shape.max_depth.describe( - "Maximum device-tree nesting depth (default 64)", - ), + maxFileBytes: cliDecimalField( + inspectDevicetreeInputSchema.shape.max_file_bytes, + ).describe("Maximum FDT file size in bytes (default 4194304)"), + maxNodes: cliDecimalField( + inspectDevicetreeInputSchema.shape.max_nodes, + ).describe("Maximum number of device-tree nodes (default 8192)"), + maxProperties: cliDecimalField( + inspectDevicetreeInputSchema.shape.max_properties, + ).describe("Maximum number of device-tree properties (default 32768)"), + maxDepth: cliDecimalField( + inspectDevicetreeInputSchema.shape.max_depth, + ).describe("Maximum device-tree nesting depth (default 64)"), ...analysisEvidenceDeliveryOptions, }), run: ({ args, options }) => @@ -449,7 +478,9 @@ export const registerBinaryDiagnosticsCommands = ( path: localPathStringSchema.describe("Local PE or ELF image"), }), options: z.object({ - maxFileBytes: inspectNativeExportsInputSchema.shape.max_file_bytes, + maxFileBytes: cliDecimalField( + inspectNativeExportsInputSchema.shape.max_file_bytes, + ), ...analysisEvidenceDeliveryOptions, }), run: ({ args, options }) => @@ -489,8 +520,9 @@ export const registerBinaryDiagnosticsCommands = ( ), }), options: z.object({ - pwntoolsTimeoutMs: + pwntoolsTimeoutMs: cliDecimalField( inspectBinaryLayoutInputSchema.shape.pwntools_timeout_ms, + ), }), run: ({ args, options }) => withCommandCancellation((signal) => @@ -524,8 +556,9 @@ export const registerBinaryDiagnosticsCommands = ( .boolean() .default(false) .describe("Add BYO core-only GDB/pwndbg mapping candidates"), - pwntoolsTimeoutMs: + pwntoolsTimeoutMs: cliDecimalField( inspectRecordedCrashInputSchema.shape.pwntools_timeout_ms, + ), }), run: ({ args, options }) => withCommandCancellation((signal) => diff --git a/src/cli/browserAdvancedCommands.ts b/src/cli/browserAdvancedCommands.ts index 227822f270..3bce3bd49a 100644 --- a/src/cli/browserAdvancedCommands.ts +++ b/src/cli/browserAdvancedCommands.ts @@ -34,6 +34,7 @@ import { import type { JsonValue } from "../domain/jsonValue.js"; import { parseCliJsonInput } from "../cliJsonInput.js"; import type { Logger } from "pino"; +import { cliDecimalField } from "./options.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; @@ -63,12 +64,9 @@ const registerWebMcp = ( }), options: z.object({ ...browserScopeOptions, - observationMs: z - .number() - .int() - .min(0) - .default(100) - .describe("Observation duration in milliseconds"), + observationMs: cliDecimalField( + discoverWebMcpToolsInputSchema.shape.observation_ms, + ).describe("Observation duration in milliseconds"), }), run: ({ args, options }) => withCommandCancellation((signal) => @@ -229,13 +227,9 @@ const registerScreenshotDiff = ( .describe("Later screenshot artifact JSON or JSON file path"), }), options: z.object({ - channelThreshold: z - .number() - .int() - .min(0) - .max(255) - .default(0) - .describe("Per-channel difference threshold for changed pixels"), + channelThreshold: cliDecimalField( + compareWebScreenshotsInputSchema.shape.channel_threshold, + ).describe("Per-channel difference threshold for changed pixels"), }), run: ({ args, options }) => logCliCommand(logger, "compare-web-screenshots", async () => { diff --git a/src/cli/browserCommands.ts b/src/cli/browserCommands.ts index 23652bf53a..32ba64a6ab 100644 --- a/src/cli/browserCommands.ts +++ b/src/cli/browserCommands.ts @@ -25,6 +25,7 @@ import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js"; import { observeWebSessionInputSchema } from "../domain/browserSession.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; import type { Logger } from "pino"; +import { cliDecimalField } from "./options.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { browserPageInspectionOptions, @@ -172,12 +173,16 @@ const registerBundleAnalysis = ( .boolean() .default(false) .describe("Fetch source maps referenced by captured scripts"), - maxHeapMb: analyzeWebBundleInputSchema.shape.max_heap_mb, - analysisTimeoutMs: analyzeWebBundleInputSchema.shape.analysis_timeout_ms, - totalAnalysisTimeoutMs: + maxHeapMb: cliDecimalField(analyzeWebBundleInputSchema.shape.max_heap_mb), + analysisTimeoutMs: cliDecimalField( + analyzeWebBundleInputSchema.shape.analysis_timeout_ms, + ), + totalAnalysisTimeoutMs: cliDecimalField( analyzeWebBundleInputSchema.shape.total_analysis_timeout_ms, - sourceMapFetchTimeoutMs: + ), + sourceMapFetchTimeoutMs: cliDecimalField( analyzeWebBundleInputSchema.shape.source_map_fetch_timeout_ms, + ), }), run: ({ args, options }) => withCommandCancellation((signal) => diff --git a/src/cli/captureCommands.ts b/src/cli/captureCommands.ts index d5854bb499..a7a15c1d56 100644 --- a/src/cli/captureCommands.ts +++ b/src/cli/captureCommands.ts @@ -15,6 +15,7 @@ import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; import { finishCliEvidenceCommand } from "./cliEvidenceCallerPath.js"; +import { cliDecimalField, cliDecimalShape } from "./options.js"; import { localPathStringSchema } from "../domain/localPath.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; export const registerCaptureCommands = ( @@ -30,14 +31,16 @@ export const registerCaptureCommands = ( description: "Search installed TShark protocol and field definitions", options: z .object({ - ...inspectPacketFieldsInputSchema.shape, - limit: z - .number() - .int() - .positive() - .max(Number.MAX_SAFE_INTEGER) - .optional() - .describe("Maximum matching records to return; defaults to 50"), + ...cliDecimalShape(inspectPacketFieldsInputSchema.shape), + limit: cliDecimalField( + z + .number() + .int() + .positive() + .max(Number.MAX_SAFE_INTEGER) + .optional() + .describe("Maximum matching records to return; defaults to 50"), + ), all: z .boolean() .default(false) @@ -76,13 +79,15 @@ export const registerCaptureCommands = ( }), options: z .object({ - ...packetCaptureOptionsSchema.shape, - match_limit: z - .number() - .int() - .positive() - .optional() - .describe("Returned matching frames per page; default 50"), + ...cliDecimalShape(packetCaptureOptionsSchema.shape), + match_limit: cliDecimalField( + z + .number() + .int() + .positive() + .optional() + .describe("Returned matching frames per page; default 50"), + ), all_matches: z .boolean() .default(false) diff --git a/src/cli/coreNativeCommands.ts b/src/cli/coreNativeCommands.ts index a9449dd4bf..a3ebd76d27 100644 --- a/src/cli/coreNativeCommands.ts +++ b/src/cli/coreNativeCommands.ts @@ -1,5 +1,6 @@ import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { nativeDispatchMetadataInputSchema } from "../domain/native/objcSwiftMetadata.js"; +import { nativeInvestigationTraceLimitsSchema } from "../domain/native/nativeInvestigationGraph.js"; import { nativeValueTraceInputSchema } from "../domain/native/nativeValueTrace.js"; import { z } from "incur"; import { @@ -21,6 +22,7 @@ import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; import type { Logger } from "pino"; import { + cliDecimalField, directAnalysisOptionsFromCli, nativeOpenOptionFields, snapshotSelectionOption, @@ -62,13 +64,15 @@ export const registerCoreNativeCommands = ( .string() .optional() .describe("Continuation from the same query's next_cursor"), - limit: z - .number() - .int() - .min(1) - .max(MAX_BYTE_SEARCH_MATCHES) - .default(100) - .describe("Maximum matches on this page"), + limit: cliDecimalField( + z + .number() + .int() + .min(1) + .max(MAX_BYTE_SEARCH_MATCHES) + .default(100) + .describe("Maximum matches on this page"), + ), ...nativeOpenOptionFields, }), run: ({ args, options }) => @@ -144,12 +148,9 @@ export const registerCoreNativeCommands = ( address: z.string().describe("Exact provider memory address"), }), options: z.object({ - length: z - .number() - .int() - .min(1) - .default(256) - .describe("Requested byte count"), + length: cliDecimalField( + z.number().int().min(1).default(256).describe("Requested byte count"), + ), ...nativeOpenOptionFields, }), run: ({ args, options }) => @@ -193,54 +194,27 @@ export const registerCoreNativeCommands = ( ), }), options: z.object({ - maxDepth: z - .number() - .int() - .min(0) - .max(16) - .default(3) - .describe("Maximum call traversal depth"), - maxFunctions: z - .number() - .int() - .min(1) - .max(64) - .default(16) - .describe("Maximum function decompilations"), - maxCallSites: z - .number() - .int() - .min(1) - .max(4096) - .default(256) - .describe("Maximum static call-site resolutions"), - maxNodes: z - .number() - .int() - .min(1) - .max(20000) - .default(5000) - .describe("Maximum retained graph nodes"), - maxEdges: z - .number() - .int() - .min(1) - .max(40000) - .default(10000) - .describe("Maximum retained graph edges"), - offset: z - .number() - .int() - .min(0) - .default(0) - .describe("First graph node index"), - limit: z - .number() - .int() - .min(1) - .max(5000) - .default(1000) - .describe("Maximum nodes on this page"), + maxDepth: cliDecimalField( + nativeValueTraceInputSchema.shape.max_depth, + ).describe("Maximum call traversal depth"), + maxFunctions: cliDecimalField( + nativeValueTraceInputSchema.shape.max_functions, + ).describe("Maximum function decompilations"), + maxCallSites: cliDecimalField( + nativeValueTraceInputSchema.shape.max_call_sites, + ).describe("Maximum static call-site resolutions"), + maxNodes: cliDecimalField( + nativeValueTraceInputSchema.shape.max_nodes, + ).describe("Maximum retained graph nodes"), + maxEdges: cliDecimalField( + nativeValueTraceInputSchema.shape.max_edges, + ).describe("Maximum retained graph edges"), + offset: cliDecimalField( + nativeValueTraceInputSchema.shape.offset, + ).describe("First graph node index"), + limit: cliDecimalField(nativeValueTraceInputSchema.shape.limit).describe( + "Maximum nodes on this page", + ), ...analysisEvidenceDeliveryOptions, ...nativeOpenOptionFields, }), @@ -378,10 +352,12 @@ const registerNativeDispatchMetadataCommand = ( ), }), options: z.object({ - maxRecords: nativeDispatchMetadataInputSchema.shape.max_records.describe( - "Maximum symbol records to inspect", + maxRecords: cliDecimalField( + nativeDispatchMetadataInputSchema.shape.max_records, + ).describe("Maximum symbol records to inspect"), + maxInputBytes: cliDecimalField( + nativeDispatchMetadataInputSchema.shape.max_input_bytes, ), - maxInputBytes: nativeDispatchMetadataInputSchema.shape.max_input_bytes, snapshot: snapshotSelectionOption.describe( "Load or update the local analysis snapshot", ), @@ -424,27 +400,15 @@ const registerNativeUiActionCommand = ( ), }), options: z.object({ - maxDepth: z - .number() - .int() - .min(0) - .max(32) - .default(8) - .describe("Maximum relationship depth"), - maxNodes: z - .number() - .int() - .min(1) - .max(2_000) - .default(250) - .describe("Maximum returned graph nodes"), - maxEdges: z - .number() - .int() - .min(1) - .max(5_000) - .default(500) - .describe("Maximum returned graph edges"), + maxDepth: cliDecimalField( + nativeInvestigationTraceLimitsSchema.shape.max_depth, + ).describe("Maximum relationship depth"), + maxNodes: cliDecimalField( + nativeInvestigationTraceLimitsSchema.shape.max_nodes, + ).describe("Maximum returned graph nodes"), + maxEdges: cliDecimalField( + nativeInvestigationTraceLimitsSchema.shape.max_edges, + ).describe("Maximum returned graph edges"), ...nativeOpenOptionFields, }), run: async ({ args, options }) => diff --git a/src/cli/decompileDirectoryCommand.ts b/src/cli/decompileDirectoryCommand.ts index 378f7f56e1..c584cd1cd9 100644 --- a/src/cli/decompileDirectoryCommand.ts +++ b/src/cli/decompileDirectoryCommand.ts @@ -6,9 +6,11 @@ import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; import type { Logger } from "pino"; import { + cliDecimalField, directAnalysisOptionsFromCli, nativeOpenOptionFields, } from "./options.js"; +import { decompileDirectoryInputSchema } from "../contracts/decompileDirectory.js"; import type { CliInstance } from "./types.js"; import { localPathStringSchema } from "../domain/localPath.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; @@ -64,14 +66,11 @@ export const registerDecompileDirectoryCommand = ( .min(1) .optional() .describe("Inclusive upper bound, 0xoffset or space:0xoffset"), - limit: z - .number() - .int() - .min(1) - .optional() - .describe( - "Maximum functions to attempt after skipping verified files and recorded external functions", - ), + limit: cliDecimalField( + decompileDirectoryInputSchema.shape.limit, + ).describe( + "Maximum functions to attempt after skipping verified files and recorded external functions", + ), snapshot: z .string() .min(1) diff --git a/src/cli/desktopCaptureCommands.ts b/src/cli/desktopCaptureCommands.ts index cc84695f9e..a156741efa 100644 --- a/src/cli/desktopCaptureCommands.ts +++ b/src/cli/desktopCaptureCommands.ts @@ -11,6 +11,7 @@ import { err, ok } from "../domain/result.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; +import { cliDecimalField } from "./options.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; import { DESKTOP_CAPTURE_DEFAULTS } from "../domain/desktop/desktopCapture.js"; @@ -18,7 +19,8 @@ import { SessionRequestQueue } from "../desktop/DesktopSessionRequests.js"; import { withCommandCancellation } from "./commandCancellation.js"; import type { CliInstance } from "./types.js"; -const positive = (fallback: number) => z.int().positive().default(fallback); +const positive = (fallback: number) => + cliDecimalField(z.int().positive().default(fallback)); /** Options of `capture-desktop-application`. */ const captureOptions = z.object({ diff --git a/src/cli/evmCommands.ts b/src/cli/evmCommands.ts index d7def8345e..836993d581 100644 --- a/src/cli/evmCommands.ts +++ b/src/cli/evmCommands.ts @@ -8,6 +8,7 @@ import { withCommandCancellation } from "./commandCancellation.js"; import { localPathStringSchema } from "../domain/localPath.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; import { finishCliEvidenceCommand } from "./cliEvidenceCallerPath.js"; +import { cliDecimalField } from "./options.js"; import { inspectEvmInterfaceInputSchema } from "../domain/evm/evmInterface.js"; /** Resolve operator paths and preserve explicit encoding through the shared workflow. */ @@ -26,7 +27,9 @@ export const registerEvmCommands = ( .describe("Selected carrier representation"), }), options: z.object({ - timeoutMs: inspectEvmInterfaceInputSchema.shape.timeout_ms, + timeoutMs: cliDecimalField( + inspectEvmInterfaceInputSchema.shape.timeout_ms, + ), }), run: ({ args, options }) => withCommandCancellation((signal) => diff --git a/src/cli/firmwareCommands.ts b/src/cli/firmwareCommands.ts index 564b563c3e..839a750d52 100644 --- a/src/cli/firmwareCommands.ts +++ b/src/cli/firmwareCommands.ts @@ -6,6 +6,7 @@ import { createFirmwareAnalysisProvider } from "../composition/firmware.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; import { firmwareInputSchemas } from "../domain/firmware/firmwareAnalysis.js"; +import { cliDecimalField } from "./options.js"; import { localPathStringSchema } from "../domain/localPath.js"; import type { FirmwareOperation } from "../domain/firmware/firmwareAnalysis.js"; import type { Logger } from "pino"; @@ -50,7 +51,9 @@ export const registerFirmwareCommands = ( description: "Inspect firmware signature candidates without extracting them", args: z.object({ path }), - options: z.object({ analysisTimeoutMs: extractInput.analysis_timeout_ms }), + options: z.object({ + analysisTimeoutMs: cliDecimalField(extractInput.analysis_timeout_ms), + }), run: ({ args, options }) => execute( CLI_COMMANDS.inspectFirmwareRegions, @@ -75,17 +78,23 @@ export const registerFirmwareCommands = ( ), }), options: z.object({ - offset: range.offset - .optional() - .describe("Selected byte offset; requires length"), - length: range.length - .optional() - .describe("Selected byte length; requires offset"), - maxDepth: extractInput.max_depth, - maxOutputBytes: extractInput.max_output_bytes, - maxOutputFiles: extractInput.max_output_files, - publicationTimeoutMs: extractInput.publication_timeout_ms, - analysisTimeoutMs: extractInput.analysis_timeout_ms, + offset: cliDecimalField( + range.offset + .optional() + .describe("Selected byte offset; requires length"), + ), + length: cliDecimalField( + range.length + .optional() + .describe("Selected byte length; requires offset"), + ), + maxDepth: cliDecimalField(extractInput.max_depth), + maxOutputBytes: cliDecimalField(extractInput.max_output_bytes), + maxOutputFiles: cliDecimalField(extractInput.max_output_files), + publicationTimeoutMs: cliDecimalField( + extractInput.publication_timeout_ms, + ), + analysisTimeoutMs: cliDecimalField(extractInput.analysis_timeout_ms), }), run: ({ args, options }) => execute( diff --git a/src/cli/fridaCommands.ts b/src/cli/fridaCommands.ts index 485f28c9e5..098ccbd01f 100644 --- a/src/cli/fridaCommands.ts +++ b/src/cli/fridaCommands.ts @@ -15,6 +15,7 @@ import { ok } from "../domain/result.js"; import { FRIDA_KEEPALIVE_INTERVAL_DESCRIPTION } from "../contracts/fridaToolContracts.js"; import { isValidFridaRemoteAddress } from "../domain/fridaRemoteAddress.js"; import type { Logger } from "pino"; +import { cliDecimalField } from "./options.js"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; @@ -48,12 +49,14 @@ export const registerFridaCommands = ( .optional() .describe("Remote TLS certificate"), origin: z.string().min(1).optional().describe("Remote connection origin"), - keepaliveInterval: z - .number() - .int() - .positive() - .optional() - .describe(FRIDA_KEEPALIVE_INTERVAL_DESCRIPTION), + keepaliveInterval: cliDecimalField( + z + .number() + .int() + .positive() + .optional() + .describe(FRIDA_KEEPALIVE_INTERVAL_DESCRIPTION), + ), }); const remoteFrom = ( options: z.infer, @@ -173,13 +176,15 @@ export const registerFridaCommands = ( .array(z.string()) .optional() .describe("Arguments passed to a spawned target"), - durationMs: z - .number() - .int() - .min(0) - .max(60_000) - .default(1_000) - .describe("Instrumentation duration in milliseconds"), + durationMs: cliDecimalField( + z + .number() + .int() + .min(0) + .max(60_000) + .default(1_000) + .describe("Instrumentation duration in milliseconds"), + ), }) .refine( (options) => @@ -233,7 +238,7 @@ export const registerFridaCommands = ( : { remote }; const targetInput = args.mode === "attach" - ? /^[1-9][0-9]*$/u.test(args.target) && + ? /^[0-9]+$/u.test(args.target) && Number.isSafeInteger(Number(args.target)) && Number(args.target) > 0 ? { diff --git a/src/cli/javascriptRuntimeCommands.ts b/src/cli/javascriptRuntimeCommands.ts index a27a36c850..7b3e949f93 100644 --- a/src/cli/javascriptRuntimeCommands.ts +++ b/src/cli/javascriptRuntimeCommands.ts @@ -7,6 +7,7 @@ import { } from "../application/javascript/JavaScriptRuntimeObservationService.js"; import { createJavaScriptRuntimeObservationProvider } from "../composition/javascriptRuntimeObservation.js"; import { V8InspectorAdmission } from "../inspector/V8InspectorProvider.js"; +import { cliDecimalField } from "./options.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; import { @@ -27,12 +28,9 @@ const observeOptionsSchema = z.object({ .describe( "Declared target role; Inspector cannot authenticate the Electron role", ), - observationMs: z - .number() - .int() - .min(0) - .default(100) - .describe("Observation window in milliseconds"), + observationMs: cliDecimalField( + observeJavaScriptRuntimeInputSchema.shape.observation_ms, + ).describe("Observation window in milliseconds"), }); /** Register CLI equivalents of passive V8 Inspector tools. */ diff --git a/src/cli/jebCommands.ts b/src/cli/jebCommands.ts index e3e65bb69e..da35bdf7ef 100644 --- a/src/cli/jebCommands.ts +++ b/src/cli/jebCommands.ts @@ -7,6 +7,7 @@ import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; import type { JebOperation } from "../domain/jeb/jebAnalysis.js"; import type { Logger } from "pino"; +import { cliDecimalField } from "./options.js"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; @@ -63,8 +64,8 @@ export const registerJebCommands = ( options: z.object({ filter: jebInputSchemas.list_jeb_units.shape.filter, "parent-unit-path": jebInputSchemas.list_jeb_units.shape.parent_unit_path, - index: jebInputSchemas.list_jeb_units.shape.index, - count: jebInputSchemas.list_jeb_units.shape.count, + index: cliDecimalField(jebInputSchemas.list_jeb_units.shape.index), + count: cliDecimalField(jebInputSchemas.list_jeb_units.shape.count), }), run: ({ options }) => execute(CLI_COMMANDS.listJebUnits, "list_jeb_units", { diff --git a/src/cli/options.ts b/src/cli/options.ts index 071bdc23c3..6bf24bf36b 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -13,35 +13,154 @@ import { analysisProviderSelectorSchema } from "../domain/providerSelection.js"; import type { AnalysisProviderSelector } from "../domain/providerSelection.js"; import type { Logger } from "pino"; +/** Decimal integer argv text. Leading zeros stay decimal; `0x` and exponent spellings do not. */ +const DECIMAL_INTEGER_TOKEN = /^[0-9]+$/u; + +type DecimalWrapper = + | { readonly kind: "default"; readonly value: number | null } + | { readonly kind: "prefault"; readonly value: number | null } + | { + readonly kind: "optional" | "exactOptional" | "nullable" | "readonly"; + }; + /** Parse decimal integer tokens before Number coercion can erase their syntax. */ export const cliIntegerArgument = (min: number) => z .string() - .regex(/^[0-9]+$/u, "Expected a decimal integer") + .regex(DECIMAL_INTEGER_TOKEN, "Expected a decimal integer") .pipe(z.coerce.number().safe().min(min)); -/** Parse a decimal token, then let the canonical numeric field own bounds, defaults and help. */ -export function cliDecimalField( - field: z.ZodDefault, -): z.ZodType; -export function cliDecimalField( - field: z.ZodOptional, -): z.ZodType; -export function cliDecimalField( - field: z.ZodDefault | z.ZodOptional, -): z.ZodType { - const parsed = z +const decimalToken = (domain: z.ZodNumber) => + z .string() - .regex(/^[0-9]+$/u, "Expected a decimal integer") + .regex(DECIMAL_INTEGER_TOKEN, "Expected a decimal integer") .pipe(z.coerce.number()) - .pipe(field.unwrap()); - const omitted = field.parse(undefined); - const admitted = - omitted === undefined ? parsed.optional() : parsed.default(omitted); - return field.description === undefined - ? admitted - : admitted.describe(field.description); -} + .pipe(domain); + +const unwrapSchema = (field: { unwrap: () => unknown }): z.ZodType => + field.unwrap() as z.ZodType; + +/** + * Incur advertises Zod's output view. Publish the input view so the decimal + * token guard survives `--schema` and other SDK conversions. + */ +const publishDecimalInput = (field: z.ZodType): z.ZodType => { + const { $schema: _dialect, ...inputSchema } = z.toJSONSchema(field, { + io: "input", + }); + return field.meta(inputSchema); +}; + +const numericStack = ( + field: z.ZodType, +): + | { + readonly core: z.ZodNumber; + readonly wrappers: readonly DecimalWrapper[]; + readonly description: string | undefined; + } + | undefined => { + const wrappers: DecimalWrapper[] = []; + let description: string | undefined; + let current = field; + for (;;) { + if (description === undefined && current.description !== undefined) + description = current.description; + if (current instanceof z.ZodDefault) { + wrappers.push({ + kind: "default", + value: current.parse(undefined) as number | null, + }); + current = unwrapSchema(current); + continue; + } + if (current instanceof z.ZodPrefault) { + wrappers.push({ + kind: "prefault", + value: current.parse(undefined) as number | null, + }); + current = unwrapSchema(current); + continue; + } + if (current instanceof z.ZodOptional) { + wrappers.push({ kind: "optional" }); + current = unwrapSchema(current); + continue; + } + if (current instanceof z.ZodExactOptional) { + wrappers.push({ kind: "exactOptional" }); + current = unwrapSchema(current); + continue; + } + if (current instanceof z.ZodNullable) { + wrappers.push({ kind: "nullable" }); + current = unwrapSchema(current); + continue; + } + if (current instanceof z.ZodReadonly) { + wrappers.push({ kind: "readonly" }); + current = unwrapSchema(current); + continue; + } + break; + } + return current instanceof z.ZodNumber + ? { core: current, wrappers, description } + : undefined; +}; + +const applyDecimalWrappers = ( + field: z.ZodType, + wrappers: readonly DecimalWrapper[], +): z.ZodType => { + let admitted = field; + for (const wrapper of wrappers.toReversed()) { + if (wrapper.kind === "nullable") admitted = admitted.nullable(); + else if (wrapper.kind === "optional") admitted = admitted.optional(); + else if (wrapper.kind === "exactOptional") + admitted = admitted.exactOptional(); + else if (wrapper.kind === "readonly") admitted = admitted.readonly(); + else if (wrapper.kind === "prefault") + admitted = admitted.prefault(wrapper.value as never); + else if (wrapper.kind === "default") + admitted = admitted.default(wrapper.value as never); + } + return admitted; +}; + +/** + * Parse a decimal token, then let the domain field own bounds, defaults, + * nullability and help. JSON numbers remain valid for schema consumers. + */ +export const cliDecimalField = ( + field: Field, +): z.ZodType> => { + const numeric = numericStack(field); + if (numeric === undefined) + throw new Error( + `cliDecimalField expected a numeric domain field, received ${field.constructor.name}`, + ); + const admitted = applyDecimalWrappers( + z.union([numeric.core, decimalToken(numeric.core)]), + numeric.wrappers, + ); + const described = + numeric.description === undefined + ? admitted + : admitted.describe(numeric.description); + return publishDecimalInput(described) as z.ZodType>; +}; + +/** Adapt each numeric property of a shared shape; other properties stay unchanged. */ +export const cliDecimalShape = >( + shape: Shape, +): Shape => + Object.fromEntries( + Object.entries(shape).map(([key, field]) => [ + key, + numericStack(field) === undefined ? field : cliDecimalField(field), + ]), + ) as Shape; export const providerSelectionOption = analysisProviderSelectorSchema .optional() diff --git a/src/cli/reverseEngineeringCommands.ts b/src/cli/reverseEngineeringCommands.ts index eaa2470586..9ab992b10b 100644 --- a/src/cli/reverseEngineeringCommands.ts +++ b/src/cli/reverseEngineeringCommands.ts @@ -20,6 +20,7 @@ import { rizinInputSchema, } from "../domain/reverseEngineering.js"; import { logCliCommand } from "../cliLogging.js"; +import { cliDecimalField } from "./options.js"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; @@ -218,7 +219,7 @@ export const registerReverseEngineeringCommands = ( .describe( "Follow local DWARF debug links. External debug-file identity is not bound to Evidence.", ), - commandTimeoutMs: reverseCommandTimeoutSchema, + commandTimeoutMs: cliDecimalField(reverseCommandTimeoutSchema), }), run: ({ args, options }) => withCommandCancellation((signal) => @@ -261,7 +262,7 @@ export const registerReverseEngineeringCommands = ( args: z.object({}), options: z.object({ ...analysisEvidenceDeliveryOptions, - commandTimeoutMs: reverseCommandTimeoutSchema, + commandTimeoutMs: cliDecimalField(reverseCommandTimeoutSchema), }), run: ({ options }) => withCommandCancellation((signal) => @@ -311,10 +312,10 @@ export const registerReverseEngineeringCommands = ( }), options: z.object({ asmArch: rizinInputSchema.shape.asm_arch, - asmBits: rizinInputSchema.shape.asm_bits, + asmBits: cliDecimalField(rizinInputSchema.shape.asm_bits), asmCpu: rizinInputSchema.shape.asm_cpu, baseAddress: rizinInputSchema.shape.base_address, - commandTimeoutMs: reverseCommandTimeoutSchema, + commandTimeoutMs: cliDecimalField(reverseCommandTimeoutSchema), }), run: ({ args, options }) => withCommandCancellation((signal) => diff --git a/src/cli/signalCommands.ts b/src/cli/signalCommands.ts index 471e1ab681..fc25e02158 100644 --- a/src/cli/signalCommands.ts +++ b/src/cli/signalCommands.ts @@ -15,6 +15,7 @@ import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; import { finishCliEvidenceCommand } from "./cliEvidenceCallerPath.js"; +import { cliDecimalField, cliDecimalShape } from "./options.js"; import { localPathStringSchema } from "../domain/localPath.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; export const registerSignalCommands = ( @@ -30,16 +31,18 @@ export const registerSignalCommands = ( description: "Search installed sigrok decoders or selected decoder help", options: z .object({ - ...inspectSignalDecodersInputSchema.shape, - limit: z - .number() - .int() - .positive() - .max(Number.MAX_SAFE_INTEGER) - .optional() - .describe( - "Maximum matching decoders or help lines to return; defaults to 50", - ), + ...cliDecimalShape(inspectSignalDecodersInputSchema.shape), + limit: cliDecimalField( + z + .number() + .int() + .positive() + .max(Number.MAX_SAFE_INTEGER) + .optional() + .describe( + "Maximum matching decoders or help lines to return; defaults to 50", + ), + ), all: z .boolean() .default(false) @@ -78,13 +81,15 @@ export const registerSignalCommands = ( }), options: z .object({ - ...signalRecordingOptionsSchema.shape, - line_limit: z - .number() - .int() - .positive() - .optional() - .describe("Returned output lines per page; default 50"), + ...cliDecimalShape(signalRecordingOptionsSchema.shape), + line_limit: cliDecimalField( + z + .number() + .int() + .positive() + .optional() + .describe("Returned output lines per page; default 50"), + ), all_lines: z .boolean() .default(false) diff --git a/src/cli/sqliteCommands.ts b/src/cli/sqliteCommands.ts index 4288a162f3..26fae79cd4 100644 --- a/src/cli/sqliteCommands.ts +++ b/src/cli/sqliteCommands.ts @@ -9,6 +9,7 @@ import { withCommandCancellation } from "./commandCancellation.js"; import { localPathStringSchema } from "../domain/localPath.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; import { finishCliEvidenceCommand } from "./cliEvidenceCallerPath.js"; +import { cliDecimalField } from "./options.js"; import { inspectSqliteDatabaseInputSchema } from "../domain/sqlite/sqliteDatabase.js"; /** Resolve an operator-selected SQLite snapshot through the shared inspection workflow. */ @@ -25,7 +26,9 @@ export const registerSqliteCommands = ( path: localPathStringSchema.describe("Local SQLite database snapshot"), }), options: z.object({ - workerTimeoutMs: inspectSqliteDatabaseInputSchema.shape.worker_timeout_ms, + workerTimeoutMs: cliDecimalField( + inspectSqliteDatabaseInputSchema.shape.worker_timeout_ms, + ), table: z .string() .optional() @@ -42,14 +45,9 @@ export const registerSqliteCommands = ( .describe( "Compact inventory by default, or selected-table details when table is supplied; database requests full schema", ), - rowLimit: z - .number() - .int() - .min(1) - .optional() - .describe( - "Positive safe integer row count; default 20; requires table", - ), + rowLimit: cliDecimalField( + inspectSqliteDatabaseInputSchema.shape.row_limit, + ).describe("Positive safe integer row count; default 20; requires table"), }), run: ({ args, options }) => withCommandCancellation((signal) => diff --git a/src/cli/utilityCommands.ts b/src/cli/utilityCommands.ts index b94da62f08..6ffd4ca9bd 100644 --- a/src/cli/utilityCommands.ts +++ b/src/cli/utilityCommands.ts @@ -1,6 +1,6 @@ import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { z } from "incur"; -import { cliIntegerArgument } from "./options.js"; +import { cliDecimalField, cliIntegerArgument } from "./options.js"; import type { DirectAnalysis } from "../composition/directAnalysis.js"; import type { ReferenceSourcePolicy } from "../domain/referenceSourcePolicy.js"; @@ -46,15 +46,9 @@ export const registerUtilityCommands = ( registerReferenceSourceCommand(cli, logger, environment); }; -/** - * A native deadline option keeps the shared field's bounds but admits only - * decimal argv text; Incur's Number() coercion would accept "0x10" or "1e3". - */ +/** Decimal argv text; the shared deadline field owns the bounds and help. */ const cliNativeDeadline = (field: typeof nativeUiDeadlineField) => - cliIntegerArgument(1) - .pipe(field.unwrap()) - .optional() - .describe(field.description ?? ""); + cliDecimalField(field); // Decimal tokens only, for the same reason as cliNativeDeadline. const nativeUiScopeOptions = { diff --git a/src/cli/wasmCommands.ts b/src/cli/wasmCommands.ts index 69a8b2e001..f76c5540e0 100644 --- a/src/cli/wasmCommands.ts +++ b/src/cli/wasmCommands.ts @@ -7,6 +7,7 @@ import { z } from "incur"; import { wasmArtifactModule } from "../composition/wasm.js"; import { logCliCommand } from "../cliLogging.js"; import type { Logger } from "pino"; +import { cliDecimalField } from "./options.js"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; @@ -71,7 +72,9 @@ export const registerWasmCommands = ( path: localPathStringSchema.describe("Local WASM artifact"), }), options: z.object({ - commandTimeoutMs: inspectWasmArtifactInputSchema.shape.command_timeout_ms, + commandTimeoutMs: cliDecimalField( + inspectWasmArtifactInputSchema.shape.command_timeout_ms, + ), glue_paths: wasmArtifactPathsSchema(localPathStringSchema) .optional() .describe("Selected local JavaScript glue paths"), diff --git a/src/cli/webRuntimeCommands.ts b/src/cli/webRuntimeCommands.ts index 10592be4bb..b9278dd5ec 100644 --- a/src/cli/webRuntimeCommands.ts +++ b/src/cli/webRuntimeCommands.ts @@ -8,7 +8,8 @@ import { } from "../cliObservationOptions.js"; import { logCliCommand } from "../cliLogging.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; -import { MAX_BROWSER_DEADLINE_MS } from "../domain/browserDeadline.js"; +import { cliDecimalField } from "./options.js"; +import { observeWebExecutionInputSchema } from "../domain/webExecution.js"; import type { Logger } from "pino"; import type { ProgressReporter } from "../application/ProgressReporter.js"; import { withCommandCancellation } from "./commandCancellation.js"; @@ -37,15 +38,9 @@ export const registerWebRuntimeCommands = ( }), options: z.object({ ...browserScopeOptions, - // incur only coerces ZodNumber argv fields; domain z.int() constructs - // ZodNumberFormat. Preserve the canonical bounds in this CLI adapter. - observationMs: z - .number() - .int() - .min(1) - .max(MAX_BROWSER_DEADLINE_MS) - .default(10_000) - .describe("Observation duration in milliseconds"), + observationMs: cliDecimalField( + observeWebExecutionInputSchema.shape.observation_ms, + ).describe("Observation duration in milliseconds"), }), run: ({ args, options }) => withCommandCancellation((signal) => diff --git a/src/cli/webSourceLocationCommands.ts b/src/cli/webSourceLocationCommands.ts index 136083fb53..f63ec35bf7 100644 --- a/src/cli/webSourceLocationCommands.ts +++ b/src/cli/webSourceLocationCommands.ts @@ -6,7 +6,7 @@ import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; -import { cliIntegerArgument } from "./options.js"; +import { cliDecimalField, cliIntegerArgument } from "./options.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; @@ -37,7 +37,9 @@ export const registerWebSourceLocationCommands = ( ), }), options: z.object({ - decodeTimeoutMs: webSourceLocationInputSchema.shape.decode_timeout_ms, + decodeTimeoutMs: cliDecimalField( + webSourceLocationInputSchema.shape.decode_timeout_ms, + ), }), run: ({ args, options }) => withCommandCancellation((signal) => diff --git a/src/cliObservationOptions.test.ts b/src/cliObservationOptions.test.ts index 84615698b3..01c8f73586 100644 --- a/src/cliObservationOptions.test.ts +++ b/src/cliObservationOptions.test.ts @@ -4,8 +4,8 @@ import { observationDuration } from "./cliObservationOptions.js"; const options = z.object({ observationMs: observationDuration(500) }); -it.each(["", " ", "\t"])( - "rejects an empty selected duration before SDK numeric coercion (%j)", +it.each(["", " ", "\t", "0x10", "1e3", " 16 ", "+16", "16.0"])( + "rejects a non-decimal observation duration before numeric coercion (%j)", (value) => { for (const argv of [ [`--observation-ms=${value}`], @@ -18,9 +18,8 @@ it.each(["", " ", "\t"])( it.each([ { argv: [], expected: 500 }, { argv: ["--observation-ms=0"], expected: 0 }, - { argv: ["--observation-ms", "0x10"], expected: 16 }, - { argv: ["--observation-ms", "1e3"], expected: 1000 }, - { argv: ["--observation-ms", " 16 "], expected: 16 }, + { argv: ["--observation-ms", "010"], expected: 10 }, + { argv: ["--observation-ms", "16"], expected: 16 }, ])("preserves caller selection and omission ($argv)", ({ argv, expected }) => { expect(Parser.parse(argv, { options }).options.observationMs).toBe(expected); }); @@ -34,14 +33,14 @@ it.each(["-1", "0.5", "NaN", "Infinity"])( }, ); -it("advertises numeric bounds, a nonblank text branch and a valid numeric default", () => { +it("advertises the decimal token guard and the domain numeric bounds", () => { expect(Schema.toJsonSchema(options)).toMatchObject({ properties: { observationMs: { default: 500, anyOf: [ { type: "integer", minimum: 0 }, - { type: "string", pattern: "\\S" }, + { type: "string", pattern: "^[0-9]+$" }, ], }, }, diff --git a/src/cliObservationOptions.ts b/src/cliObservationOptions.ts index c4e2df57d4..8fce3a8f0f 100644 --- a/src/cliObservationOptions.ts +++ b/src/cliObservationOptions.ts @@ -1,32 +1,20 @@ import { javascriptRouteDeliveryOptions } from "./cli/javascriptRouteDelivery.js"; import { z } from "incur"; +import { cliDecimalField } from "./cli/options.js"; import { webRuntimeCommandTimeoutSchema } from "./domain/browserDeadline.js"; import { javaScriptAnalysisResourceControlsSchema } from "./domain/javascript/javascriptAnalysisResourceControls.js"; import { javaScriptSourcePathsSchema } from "./domain/javascript/javascriptSourceSelection.js"; /** Accept an agent-selected browser observation duration without an artificial ceiling. */ -export const observationDuration = (fallback: number, minimum = 0) => { - const duration = z.number().int().min(minimum); - // Keep argv text until its presence is checked. Incur eagerly converts a - // ZodNumber field with Number(), which turns empty/blank selections into 0. - const selected = z - .union([ - duration, - z - .string() - .regex(/\S/u, "Expected a nonempty observation duration") - .pipe(z.coerce.number()) - .pipe(duration), - ]) - .default(fallback) - .describe("Observation duration in milliseconds"); - // Incur advertises Zod's output view for CLI inputs. Publish this field's - // input view so the lexical guard survives the SDK conversion as well. - const { $schema: _dialect, ...inputSchema } = z.toJSONSchema(selected, { - io: "input", - }); - return selected.meta(inputSchema); -}; +export const observationDuration = (fallback: number, minimum = 0) => + cliDecimalField( + z + .number() + .int() + .min(minimum) + .default(fallback) + .describe("Observation duration in milliseconds"), + ); /** Shared passive browser origin options. */ export const browserScopeOptions = { @@ -34,7 +22,7 @@ export const browserScopeOptions = { .array(z.string().min(1)) .optional() .describe("Optional exact-origin filter"), - commandTimeoutMs: webRuntimeCommandTimeoutSchema, + commandTimeoutMs: cliDecimalField(webRuntimeCommandTimeoutSchema), }; /** Keep an omitted command deadline absent so Evidence records the caller selection only. */ @@ -84,20 +72,24 @@ export const electronPageInspectionOptions = z.object({ .boolean() .default(false) .describe("Include JavaScript source text"), - commandTimeoutMs: webRuntimeCommandTimeoutSchema, + commandTimeoutMs: cliDecimalField(webRuntimeCommandTimeoutSchema), }); export const javascriptApplicationOptions = z.object({ ...javascriptRouteDeliveryOptions, - maxSemanticNodes: + maxSemanticNodes: cliDecimalField( javaScriptAnalysisResourceControlsSchema.shape.max_semantic_nodes, - maxSemanticNodesPerFile: + ), + maxSemanticNodesPerFile: cliDecimalField( javaScriptAnalysisResourceControlsSchema.shape.max_semantic_nodes_per_file, - maxSemanticUnknowns: + ), + maxSemanticUnknowns: cliDecimalField( javaScriptAnalysisResourceControlsSchema.shape.max_semantic_unknowns, - maxSemanticUnknownsPerFile: + ), + maxSemanticUnknownsPerFile: cliDecimalField( javaScriptAnalysisResourceControlsSchema.shape .max_semantic_unknowns_per_file, + ), sourcePath: javaScriptSourcePathsSchema .optional() .describe( @@ -110,9 +102,12 @@ export const javascriptApplicationOptions = z.object({ .describe( "Inline JSON array or JSON file path of exact inventoried file paths relative to the application root, selected after any --source-path values. Use a file for selections that exceed the host command-line limit.", ), - maxHeapMb: javaScriptAnalysisResourceControlsSchema.shape.max_heap_mb, - analysisTimeoutMs: + maxHeapMb: cliDecimalField( + javaScriptAnalysisResourceControlsSchema.shape.max_heap_mb, + ), + analysisTimeoutMs: cliDecimalField( javaScriptAnalysisResourceControlsSchema.shape.analysis_timeout_ms, + ), artifactFormat: z .enum(["auto", "asar", "directory"]) .default("auto") diff --git a/src/cliOutput.ts b/src/cliOutput.ts index 9d41cf77b5..045e9cbc22 100644 --- a/src/cliOutput.ts +++ b/src/cliOutput.ts @@ -73,9 +73,7 @@ export const cliOutputValueRejection = ( } if ( (flag === "--token-limit" || flag === "--token-offset") && - (!Number.isSafeInteger(Number(value)) || - Number(value) < 0 || - value.trim() === "") + (!/^[0-9]+$/u.test(value) || !Number.isSafeInteger(Number(value))) ) return `Invalid value for ${flag}: "${value}". Expected a nonnegative safe integer.`; return undefined; diff --git a/tests/acceptance/browser/observationDurationCli.test.ts b/tests/acceptance/browser/observationDurationCli.test.ts index bd534ac26c..9e32fedf06 100644 --- a/tests/acceptance/browser/observationDurationCli.test.ts +++ b/tests/acceptance/browser/observationDurationCli.test.ts @@ -69,7 +69,7 @@ cliTest( default: 500, anyOf: [ { type: "integer", minimum: 0 }, - { type: "string", pattern: "\\S" }, + { type: "string", pattern: "^[0-9]+$" }, ], }, }, diff --git a/tests/acceptance/webRuntimeCli.test.ts b/tests/acceptance/webRuntimeCli.test.ts index c28d9b4513..f4252aecf9 100644 --- a/tests/acceptance/webRuntimeCli.test.ts +++ b/tests/acceptance/webRuntimeCli.test.ts @@ -68,10 +68,15 @@ it("advertises the canonical observation duration bounds through the compiled CL options: { properties: { observationMs: { - type: "integer", - minimum: 1, - maximum: 2_147_483_647, default: 10_000, + anyOf: [ + { + type: "integer", + minimum: 1, + maximum: 2_147_483_647, + }, + { type: "string", pattern: "^[0-9]+$" }, + ], }, }, }, diff --git a/tests/boundary/cli/cliDecimalArguments.test.ts b/tests/boundary/cli/cliDecimalArguments.test.ts new file mode 100644 index 0000000000..6d1e8f46cd --- /dev/null +++ b/tests/boundary/cli/cliDecimalArguments.test.ts @@ -0,0 +1,169 @@ +import { Ajv2020 } from "ajv/dist/2020.js"; +import { Parser, Schema, z } from "incur"; +import { describe, expect, it } from "vitest"; + +import { createCli } from "../../../src/cli.js"; +import { visitRegisteredCommands } from "../../../src/cli/visitRegisteredCommands.js"; + +const NON_DECIMAL = ["0x10", "1e3"] as const; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const numericBranches = (schema: unknown): boolean => { + if (!isRecord(schema)) return false; + if (schema.type === "integer" || schema.type === "number") return true; + for (const key of ["anyOf", "oneOf", "allOf"]) { + const branches = schema[key]; + if (Array.isArray(branches) && branches.some(numericBranches)) return true; + } + return false; +}; + +const bounds = (schema: unknown): Array<{ min: number; max: number }> => { + if (!isRecord(schema)) return []; + const nested = ["anyOf", "oneOf", "allOf"].flatMap((key) => + Array.isArray(schema[key]) ? schema[key].flatMap(bounds) : [], + ); + if (schema.type !== "integer" && schema.type !== "number") return nested; + const min = + typeof schema.exclusiveMinimum === "number" + ? schema.exclusiveMinimum + 1 + : typeof schema.minimum === "number" + ? schema.minimum + : 0; + const max = + typeof schema.exclusiveMaximum === "number" + ? schema.exclusiveMaximum - 1 + : typeof schema.maximum === "number" + ? schema.maximum + : Number.MAX_SAFE_INTEGER; + const preferred = + typeof schema.default === "number" && + schema.default >= min && + schema.default <= max + ? schema.default + : min; + return Number.isSafeInteger(preferred) && preferred >= min && preferred <= max + ? [...nested, { min: preferred, max }] + : nested; +}; + +const flagName = (name: string): string => + `--${name.replace(/[A-Z]/gu, (character) => `-${character.toLowerCase()}`)}`; + +/** Fields whose decimal admission this catalog must actually exercise. */ +const REQUIRED_NUMERIC_FIELDS = [ + "read-bytes options.length", + "search-bytes options.limit", + "read-adb-logcat options.count", + "read-adb-logcat options.pid", + "inspect-sqlite-database options.rowLimit", + "decompile-all options.limit", + "instrument-with-frida options.durationMs", + "compare-web-screenshots options.channelThreshold", + "inspect-byte-entropy options.blockSize", + "inspect-elf-notes options.maxFileBytes", + "inspect-web-page options.observationMs", + "observe-web-execution options.observationMs", + "extract-firmware options.maxDepth", +] as const; + +describe("decimal integer CLI admission", () => { + it("rejects 0x10 and 1e3 for every numeric input and keeps an in-range decimal", () => { + const cli = createCli({}); + const ajv = new Ajv2020({ strict: false, allErrors: true }); + const failures: string[] = []; + const checked: string[] = []; + visitRegisteredCommands(cli, (command, path) => { + for (const group of ["args", "options"] as const) { + const parser = command[group]; + if (!(parser instanceof z.ZodObject)) continue; + const advertised = Schema.toJsonSchema(parser); + if (!isRecord(advertised) || !isRecord(advertised.properties)) continue; + for (const [name, property] of Object.entries(advertised.properties)) { + if (!numericBranches(property)) continue; + const field = parser.shape[name]; + if (field === undefined) { + failures.push( + `${path} ${group}.${name}: advertised without parser`, + ); + continue; + } + const label = `${path} ${group}.${name}`; + let validate: ((value: unknown) => boolean) | undefined; + try { + validate = ajv.compile( + property as Parameters[0], + ); + } catch (error) { + failures.push( + `${label}: Ajv could not compile the advertised schema (${error instanceof Error ? error.message : String(error)})`, + ); + continue; + } + for (const token of NON_DECIMAL) { + if (validate(token)) + failures.push(`${label}: advertised schema accepts ${token}`); + } + const isolated = z.object({ [name]: field }); + const read = (token: string): unknown => { + if (group === "options") { + return Parser.parse([flagName(name), token], { + options: isolated, + }).options[name]; + } + return Parser.parse([token], { args: isolated }).args[name]; + }; + for (const token of NON_DECIMAL) { + try { + read(token); + failures.push(`${label}: Parser.parse accepts ${token}`); + } catch { + // Decimal tokens are the only numeric spelling. + } + } + const candidate = bounds(property)[0]?.min; + if (candidate === undefined) { + failures.push(`${label}: no in-range decimal candidate`); + continue; + } + const decimal = String(candidate); + try { + expect(read(decimal)).toBe(candidate); + } catch (error) { + failures.push( + `${label}: decimal ${decimal} did not parse (${error instanceof Error ? error.message : String(error)})`, + ); + continue; + } + const admitsLeadingZeros = + JSON.stringify(property).includes("^[0-9]+$"); + try { + const parsed = read(`0${decimal}`); + if (parsed !== candidate) + failures.push( + `${label}: leading-zero decimal parsed as ${String(parsed)}, not ${String(candidate)}`, + ); + } catch (error) { + if (admitsLeadingZeros) + failures.push( + `${label}: leading-zero decimal was rejected (${error instanceof Error ? error.message : String(error)})`, + ); + } + if (!(validate(decimal) || validate(candidate))) + failures.push( + `${label}: advertised schema rejects in-range ${decimal}`, + ); + checked.push(label); + } + } + }); + expect(checked).toEqual( + expect.arrayContaining([...REQUIRED_NUMERIC_FIELDS]), + ); + expect(checked.length).toBeGreaterThan(REQUIRED_NUMERIC_FIELDS.length); + expect(new Set(checked).size).toBe(checked.length); + expect(failures).toEqual([]); + }); +}); diff --git a/tests/boundary/cli/cliInputSchemaRequired.test.ts b/tests/boundary/cli/cliInputSchemaRequired.test.ts index 37d3a0d407..747bbc2780 100644 --- a/tests/boundary/cli/cliInputSchemaRequired.test.ts +++ b/tests/boundary/cli/cliInputSchemaRequired.test.ts @@ -223,7 +223,9 @@ describe("CLI advertised input schemas", () => { }); expect(advertised.options?.properties?.["limit"]).toMatchObject({ default: 20_000, - minimum: 1, + anyOf: expect.arrayContaining([ + expect.objectContaining({ type: "integer", minimum: 1 }), + ]), }); expect( parserSchema(cli, "inspect-keyed-archive", "args")?.parse({ path: "x" }), diff --git a/tests/boundary/cli/cliOutput.test.ts b/tests/boundary/cli/cliOutput.test.ts index 4b8356f65d..6b3c970009 100644 --- a/tests/boundary/cli/cliOutput.test.ts +++ b/tests/boundary/cli/cliOutput.test.ts @@ -200,6 +200,9 @@ describe("CLI output value admission", () => { "NaN", "Infinity", "9007199254740992", + "0x10", + "1e3", + " 16 ", ]) { const validation = parseCliOutputArguments([ "providers", @@ -220,7 +223,7 @@ describe("CLI output value admission", () => { error: { code: "INVALID_OUTPUT_VALUE" }, }); } - for (const value of ["0", "1", "9007199254740991"]) + for (const value of ["0", "1", "010", "9007199254740991"]) expect( parseCliOutputArguments(["providers", flag, value]).validation, ).toEqual({ ok: true }); From c07ab4b994b017170b27b07b2c0361aeb44fc53f Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:52:39 +0800 Subject: [PATCH 092/151] feat(ghidra): checkpoint owned Programs across sessions Add opt-in REA_GHIDRA_CHECKPOINT_DIR for owned Ghidra Program persistence through the shared CLI/MCP provider. Save completed initial analysis and metadata changes on normal close; verify and restore the last successful packed Program without auto-analysis. Preserve generation identity, exclusive ownership, typed cancellation and cleanup retry. Refresh checkpoint-dependent profiles after retirement, including failed-switch restoration, while retaining caller selection provenance. Keep source executables and caller projects unchanged. Validated with 720 focused tests (3 platform skips), 13 real Ghidra CLI/MCP scenarios, static/docs checks and CI. Review findings addressed. No version bump or release publication. Refs #8165. --- bridge/ghidra/ReaGhidraBridge.java | 30 + docs/ghidra-provider.md | 11 +- docs/installation.md | 61 +++ package.json | 1 + scripts/verify-real-ghidra-checkpoints.mjs | 489 +++++++++++++++++ src/application/AnalysisProvider.ts | 2 + .../SetupRegistrationEnvironment.ts | 1 + .../binary/AnalysisProviderEvaluation.ts | 4 + .../binary/AnalysisProviderRegistry.ts | 4 + src/application/binary/BinarySession.ts | 39 +- .../binary/BinarySessionRestoration.test.ts | 80 +++ .../binary/SessionProviderRouter.ts | 35 ++ src/config.test.ts | 36 ++ src/config/environment.ts | 29 + src/config/parseConfig.ts | 3 + src/config/types.ts | 2 + src/ghidra/GhidraCheckpoint.test.ts | 514 ++++++++++++++++++ src/ghidra/GhidraCheckpoint.ts | 434 +++++++++++++++ src/ghidra/GhidraCheckpointRecovery.test.ts | 39 ++ src/ghidra/GhidraClient.ts | 227 +++++++- src/ghidra/GhidraClientTypes.ts | 2 + src/ghidra/GhidraLauncher.ts | 38 +- src/ghidra/GhidraProvider.behavior.test.ts | 55 +- src/ghidra/GhidraProvider.ts | 102 +++- src/ghidra/GhidraProviderCapabilities.ts | 38 +- src/ghidra/GhidraProviderClient.ts | 141 +++-- src/ghidra/GhidraSessionError.ts | 1 + .../filesystem/ghidraPspExtension.test.ts | 28 + .../ghidraDecompileDeadlineLifetime.test.ts | 4 +- .../ghidra/ghidraCheckpointLifecycle.test.ts | 285 ++++++++++ .../providers/ghidra/ghidraClient.fixture.ts | 17 + tests/fixtures/fakeGhidra.mjs | 70 +++ 32 files changed, 2711 insertions(+), 111 deletions(-) create mode 100644 scripts/verify-real-ghidra-checkpoints.mjs create mode 100644 src/ghidra/GhidraCheckpoint.test.ts create mode 100644 src/ghidra/GhidraCheckpoint.ts create mode 100644 src/ghidra/GhidraCheckpointRecovery.test.ts create mode 100644 tests/boundary/providers/ghidra/ghidraCheckpointLifecycle.test.ts diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 05cbb01781..fff0ef1e61 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -187,6 +187,8 @@ public final class ReaGhidraBridge extends HeadlessScript { private static final int MAX_DECOMPILE_TIMEOUT_SECONDS = Integer.MAX_VALUE / 1000; private final ReaGhidraExtensions analysisExtensions = new ReaGhidraExtensions(); private static AddressSpace sessionDefaultAddressSpace; + private Path checkpointDirectory; + private long checkpointModification; @Override public void run() throws Exception { @@ -195,6 +197,7 @@ public final class ReaGhidraBridge extends HeadlessScript { throw new IllegalArgumentException("REA bridge requires one session descriptor"); } Path descriptorPath = Path.of(arguments[0]); + checkpointDirectory = descriptorPath.toAbsolutePath().normalize().getParent(); SessionDescriptor descriptor = readDescriptor(descriptorPath); decompileTimeoutSeconds = descriptor.decompileTimeoutSeconds(); // Windows retains a native immutable bearer descriptor lease until @@ -247,6 +250,7 @@ public final class ReaGhidraBridge extends HeadlessScript { throw new IOException("Windows Ghidra P0 does not admit metadata recovery or database mutation."); analysisExtensions.analyze(descriptor.analysisExtensions, currentProgram, monitor); initializeDecompiler(); + checkpointModification = currentProgram.getModificationNumber(); serve(descriptor); } finally { @@ -451,6 +455,7 @@ public final class ReaGhidraBridge extends HeadlessScript { if (descriptor.existingProject && isMutatingOperation(request.method)) throw new RequestFailure("method_unavailable", "Existing Ghidra project sessions are read-only"); return switch (request.method) { + case "checkpoint_program" -> checkpointProgram(request.params, descriptor); case "read_binary_inventory_summary" -> binaryInventorySummary(request.params); case "inspect_native_load_image" -> inspectNativeLoadImage(request.params); case "read_bytes" -> readMemoryBytes(request.params); @@ -506,6 +511,31 @@ public final class ReaGhidraBridge extends HeadlessScript { return method.equals("annotate_native_function") || method.equals("set_address_name") || method.equals("set_addresses_names"); } + /** Internal lifecycle request: the destination is inside this owned runtime. */ + private JsonObject checkpointProgram(JsonObject params, SessionDescriptor descriptor) throws Exception { + requireKeys(params, Set.of("path", "force")); + if (descriptor.existingProject || !descriptor.transport.equals("unix-socket")) + throw new RequestFailure("method_unavailable", "This Program has no checkpoint authority"); + Path output = Path.of(requireString(params, "path")).toAbsolutePath().normalize(); + if (!output.getParent().equals(checkpointDirectory) || + !output.getFileName().toString().matches("checkpoint-[a-f0-9-]{36}\\.gzf")) + throw new RequestFailure("invalid_request", "Checkpoint destination must belong to this runtime"); + JsonElement forced = params.get("force"); + if (!forced.isJsonPrimitive() || !forced.getAsJsonPrimitive().isBoolean()) + throw new RequestFailure("invalid_request", "Checkpoint force must be a boolean"); + boolean force = forced.getAsBoolean(); + JsonObject result = new JsonObject(); + boolean changed = currentProgram.getModificationNumber() != checkpointModification; + result.addProperty("saved", force || changed); + if (force || changed) { + if (Files.exists(output)) throw new RequestFailure("invalid_request", "Checkpoint destination already exists"); + currentProgram.saveToPackedFile(output.toFile(), monitor); + checkpointModification = currentProgram.getModificationNumber(); + result.addProperty("path", output.toString()); + } + return result; + } + private JsonObject sessionInfo(SessionDescriptor descriptor) throws Exception { boolean timedOut = analysisTimeoutOccurred(); JsonObject provider = new JsonObject(); diff --git a/docs/ghidra-provider.md b/docs/ghidra-provider.md index cc0b48a899..b6bfb52dc4 100644 --- a/docs/ghidra-provider.md +++ b/docs/ghidra-provider.md @@ -2,9 +2,18 @@ REA imports one target into an ephemeral Ghidra database. Linux/macOS support read-only analysis and atomic function-name/entry-comment edits; Windows P0 is -read-only. GUI controls and persistent database mutation are unavailable. +read-only. GUI controls and writes to caller-selected source databases are unavailable. Executable bytes remain unchanged. +An opt-in [owned Program checkpoint](installation.md#automatically-checkpoint-an-owned-ghidra-program) +retains analysis across Linux/macOS processes. It saves initial completed analysis +and normal-close metadata changes to `REA_GHIDRA_CHECKPOINT_DIR`; cancellation +and process failure recover only the last successfully saved state. The restored +packed Program's digest is reported with restored observations. Initial analysis +keeps one stable generation through its first save; later saved states bind their +content digest in profile identity. Restoration does not turn prior +database state into a fresh discovery. The default remains ephemeral. + Use [installation](installation.md#ghidra) for supported hosts, prerequisites, configuration, startup deadlines, resource controls, and cleanup recovery. [Linux Ghidra launches](installation.md#linux-owned-process-prerequisite) also diff --git a/docs/installation.md b/docs/installation.md index ee7d8a9d7e..9977f76450 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1427,6 +1427,67 @@ not needed for the make step, only for the Ghidra provider. If your toolchain rejects the inherited `-mmacosx-version-min=10.6` flag, retry the make with `ADDITIONAL_FLAGS="-w"`. +### Automatically checkpoint an owned Ghidra Program + +On Linux/macOS, opt in with an absolute storage directory: + +```bash +export REA_GHIDRA_CHECKPOINT_DIR=/absolute/rea-checkpoints +rea decompile /absolute/target/app --procedure entry --provider ghidra --json +``` + +Use the same setting in the MCP server environment. CLI and MCP share the +provider-owned implementation. The first successful analysis saves the complete +Program as a packed `.gzf`. Later processes restore its names, comments, types +and analysis into a private temporary project with `-noanalysis`. Normal close +saves metadata changes; unchanged queries do not rewrite the checkpoint. +The target executable is never executed by this workflow. + +Storage is separated by target SHA-256 and analysis-profile identity, including +the supported Ghidra version. Identical executable bytes at another path can +reuse the Program. One live REA session holds the checkpoint's socket lease; +a competing session reports that owner and must wait until it closes. Only +REA-owned Programs are checkpointed. Caller-selected existing projects retain +their separate read-only workflow below. + +The directory retains the current manifest and packed Program. Publication +writes and syncs a new packed file before atomically replacing the manifest; +superseded files are removed after publication. A failed save preserves the +previous checkpoint and the live session for retry. Correct the reported storage +problem, then call `close_binary` again. Incomplete cleanup retains its owner. +Corrupt, malformed or changed checkpoints fail explicitly instead of falling +back to a fresh import. Preserve that directory and repair it or explicitly +select a new directory to start fresh. + +Recovery reaches **the last successfully saved checkpoint**. Cancellation, +process failure or a killed CLI can lose later edits; unfinished first-time +auto-analysis cannot resume midway. This does not restore a whole REA +investigation, and does not promise host-crash durability. The working project +remains ephemeral. Packed Program import, checksums, and saving consume disk and +time proportional to Program size; saving runs once after first analysis and +again only when the Program changes. + +This initial mode supports stock native ELF, PE and Mach-O imports on POSIX. +Windows, special pinned-loader profiles, caller-selected `.gpr` projects, +language/analyzer overrides, analysis seeds and extra analysis extensions are +not admitted with this setting; unset it to use their existing workflows. +The checkpoint directory must be private to the current user. + +REA analysis snapshots remain separate immutable query results. Initial analysis +has a stable generation bound to the target and analysis profile: publishing its +first checkpoint neither replaces a live MCP session nor invalidates its initial +snapshot. Later saved states bind their content digest, so an edited checkpoint +can require reopening without an older `--snapshot`. Snapshot import and save +after live metadata mutation remain unavailable as before. Complete Evidence +retains the checkpoint directory and generation in its analysis profile; restored +reads also report the packed file's path and verified digest. Returned caveats +distinguish restored database state from fresh analysis. + +Run `npm run verify:ghidra:checkpoints` with your existing Ghidra/JDK installation +to verify fresh CLI/MCP processes, name/comment/type preservation, competing +sessions, corruption refusal and explicit recovery. Lifecycle fixtures cover +save failure/retry and cancellation during checkpoint finalization separately. + ### Reuse an existing Ghidra project For repeated investigations, select an expanded local project marker with diff --git a/package.json b/package.json index d9ac233a28..1eb1186205 100644 --- a/package.json +++ b/package.json @@ -205,6 +205,7 @@ "verify:flutter": "npm run build:cached && node scripts/verify-flutter.mjs", "verify:adb": "npm run build:cached && node scripts/verify-adb.mjs", "verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs", + "verify:ghidra:checkpoints": "npm run build:cached && node scripts/verify-real-ghidra-checkpoints.mjs", "verify:ghidra:seeds": "npm run build:cached && node scripts/verify/ghidra/seeds.mjs", "verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.mjs", "verify:ghidra:ppc": "npm run build:cached && node scripts/verify-real-ghidra-ppc.mjs", diff --git a/scripts/verify-real-ghidra-checkpoints.mjs b/scripts/verify-real-ghidra-checkpoints.mjs new file mode 100644 index 0000000000..395ebe85a7 --- /dev/null +++ b/scripts/verify-real-ghidra-checkpoints.mjs @@ -0,0 +1,489 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + copyFile, + mkdtemp, + mkdir, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { + requireMcpOperationResult, + requireMcpToolError, +} from "./lib/mcp-verifier-results.mjs"; +import { + createVerifierRun, + completeVerifierRun, + assertVerifierCleanupComplete, +} from "./lib/verifier-run.mjs"; + +if (process.platform === "win32") + throw new Error( + "Owned Ghidra checkpoints currently support Linux/macOS only.", + ); +const run = createVerifierRun(), + exec = promisify(execFile); +const root = await mkdtemp(join(tmpdir(), "rea-ghidra-checkpoints-")); +const runtime = join(root, "runtime"), + checkpointDirectory = join(root, "checkpoints"); +const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url)); +const sourcePath = join(root, "fixture.c"), + target = join(root, "checkpoint-fixture"); +const env = { + ...process.env, + HOME: root, + XDG_CONFIG_HOME: join(root, "config"), + XDG_CACHE_HOME: join(root, "cache"), + XDG_DATA_HOME: join(root, "data"), + TMPDIR: runtime, + JDK_JAVA_OPTIONS: `-Duser.home=${root}`, + REA_ANALYSIS_PROVIDER: "ghidra", + REA_GHIDRA_CHECKPOINT_DIR: checkpointDirectory, +}; +for (const name of [ + "REA_GHIDRA_PROJECT_PATH", + "REA_GHIDRA_PROGRAM_NAME", + "REA_GHIDRA_SEED_FILE", + "REA_GHIDRA_NATIVEAOT_JAR", + "REA_GHIDRA_LANGUAGE_ID", + "REA_GHIDRA_COMPILER_SPEC_ID", + "REA_GHIDRA_DISABLED_ANALYZERS", +]) + delete env[name]; +const report = { + verifier: "ghidra-owned-checkpoints", + phases: [], + target: null, +}; +const sha = async (path) => + createHash("sha256") + .update(await readFile(path)) + .digest("hex"); +async function phase(name, operation) { + const start = performance.now(); + try { + const facts = await operation(); + report.phases.push({ + name, + status: "verified", + elapsed_ms: Math.round(performance.now() - start), + ...facts, + }); + } catch (cause) { + report.phases.push({ + name, + status: "failed", + elapsed_ms: Math.round(performance.now() - start), + }); + throw cause; + } +} +async function cli(procedure, flags = [], path = target) { + const { stdout } = await exec( + process.execPath, + [ + entrypoint, + "decompile", + path, + procedure, + "--provider", + "ghidra", + "--json", + ...flags, + ], + { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, + ); + return JSON.parse(stdout); +} +async function refuseCli(procedure, flags, expected) { + let refusal; + try { + await cli(procedure, flags); + } catch (cause) { + assert.equal(cause.code, 1, cause.stderr); + refusal = JSON.parse(cause.stdout); + } + assert.ok(refusal, "CLI unexpectedly accepted unavailable checkpoint state"); + assert.equal(refusal.code, expected, JSON.stringify(refusal)); + return refusal; +} +async function connect(environment = {}) { + const client = new Client({ + name: "rea-ghidra-checkpoint-verifier", + version: "1", + }); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env: { ...env, ...environment }, + stderr: "pipe", + }); + transport.stderr?.on("data", () => {}); + try { + await client.connect(transport); + } catch (cause) { + await client.close(); + await transport.close(); + throw cause; + } + const raw = (name, args = {}) => + client.callTool({ name, arguments: args }, { timeout: 240000 }); + return { + raw, + call: async (name, args = {}) => + requireMcpOperationResult(await raw(name, args), name), + close: async () => { + await client.close(); + await transport.close(); + }, + }; +} +let manifestPath; +const manifest = async () => JSON.parse(await readFile(manifestPath, "utf8")); +try { + await mkdir(runtime); + await writeFile( + sourcePath, + `typedef struct CheckpointRecord { int value; int flag; } CheckpointRecord; +__attribute__((noinline)) int rea_checkpoint_leaf(CheckpointRecord *record) { return record->value * 7 + record->flag; } +int main(void) { CheckpointRecord record = { 3, 11 }; return rea_checkpoint_leaf(&record); } +`, + ); + await exec("cc", [ + "-O0", + "-g", + "-gdwarf-4", + "-fno-inline", + sourcePath, + "-o", + target, + ]); + const targetDigest = await sha(target); + report.target = { + sha256: targetDigest, + source: "self-built C struct/function fixture; never executed", + host: process.platform, + architecture: process.arch, + }; + let original, initialManifest; + const snapshotPath = join(root, "analysis-snapshot.json"); + await phase( + "a cold MCP session keeps its live Program across initial save and repeated open", + async () => { + const cold = await connect({ + REA_GHIDRA_CHECKPOINT_DIR: join(root, "cold-session-checkpoints"), + }); + try { + await cold.call("open_binary", { path: target, provider_id: "ghidra" }); + await cold.call("procedure_pseudo_code", { + procedure: "rea_checkpoint_leaf", + }); + const id = (await cold.call("binary_session")).analysis_run.run_id; + await cold.call("annotate_native_function", { + procedure: "rea_checkpoint_leaf", + comment: "cold-session unsaved comment", + }); + await cold.call("open_binary", { path: target, provider_id: "ghidra" }); + assert.equal( + (await cold.call("binary_session")).analysis_run.run_id, + id, + ); + const current = await cold.call("procedure_pseudo_code", { + procedure: "rea_checkpoint_leaf", + }); + assert.match(JSON.stringify(current), /cold-session unsaved comment/); + await cold.call("close_binary"); + return { same_live_run: true, unsaved_metadata_retained: true }; + } finally { + await cold.close(); + } + }, + ); + await phase("first analysis creates a checkpoint", async () => { + original = await cli("rea_checkpoint_leaf", ["--snapshot", snapshotPath]); + assert.match(JSON.stringify(original.normalized_result), /value/); + const keys = await readdir(checkpointDirectory); + assert.equal(keys.length, 1); + manifestPath = join(checkpointDirectory, keys[0], "current.json"); + initialManifest = await manifest(); + assert.equal(initialManifest.target_sha256, targetDigest); + assert.deepEqual( + original.analysis_profile.parameters.owned_checkpoint.state, + { kind: "initial-analysis" }, + ); + return { + provider_version: original.provider.version, + response_bytes: Buffer.byteLength(JSON.stringify(original)), + }; + }); + await phase( + "the first checkpoint publication preserves initial immutable snapshot identity", + async () => { + const cached = await cli("rea_checkpoint_leaf", [ + "--snapshot", + snapshotPath, + ]); + assert.deepEqual(cached, original); + assert.deepEqual(await manifest(), initialManifest); + return {}; + }, + ); + await phase( + "fresh CLI restores the Program and does not rewrite it", + async () => { + const restored = await cli("rea_checkpoint_leaf"); + assert.deepEqual(restored.normalized_result, original.normalized_result); + assert.equal( + restored.analysis_profile.digest, + original.analysis_profile.digest, + ); + assert.ok( + restored.limitations.some((value) => + value.includes(initialManifest.source.sha256), + ), + ); + assert.deepEqual(await manifest(), initialManifest); + return { + checkpoint_sha256: initialManifest.source.sha256, + response_bytes: Buffer.byteLength(JSON.stringify(restored)), + }; + }, + ); + await phase( + "another path with identical target bytes reuses the same Program", + async () => { + const alias = join(root, "renamed-target"); + await copyFile(target, alias); + const restored = await cli("rea_checkpoint_leaf", [], alias); + assert.deepEqual(restored.normalized_result, original.normalized_result); + assert.equal(restored.caller_input_path, alias); + assert.deepEqual(await manifest(), initialManifest); + return {}; + }, + ); + await phase( + "MCP preserves a live run, refuses a concurrent writer, and saves edits", + async () => { + const active = await connect(); + try { + await active.call("open_binary", { + path: target, + provider_id: "ghidra", + }); + await active.call("procedure_pseudo_code", { + procedure: "rea_checkpoint_leaf", + }); + const id = (await active.call("binary_session")).analysis_run.run_id; + const other = await connect(); + try { + await other.call("open_binary", { + path: target, + provider_id: "ghidra", + }); + const error = requireMcpToolError(await other.raw("list_documents")); + assert.equal(error.code, "configuration_invalid"); + assert.match(error.message, /another REA session/); + await other.call("close_binary"); + } finally { + await other.close(); + } + await active.call("open_binary", { + path: target, + provider_id: "ghidra", + }); + assert.equal( + (await active.call("binary_session")).analysis_run.run_id, + id, + ); + await active.call("annotate_native_function", { + procedure: "rea_checkpoint_leaf", + name: "rea_checkpoint_retained", + comment: "REA checkpoint first comment", + }); + await active.call("close_binary"); + assert.notDeepEqual(await manifest(), initialManifest); + return { same_live_run: true, concurrent_owner_refused: true }; + } finally { + await active.close(); + } + }, + ); + await phase("new MCP restores edits and remains editable", async () => { + const resumed = await connect(); + try { + await resumed.call("open_binary", { + path: target, + provider_id: "ghidra", + }); + const analysis = await resumed.call("analyze_function", { + procedure: "rea_checkpoint_retained", + detail: "complete", + }); + assert.equal(analysis.procedure.name, "rea_checkpoint_retained"); + assert.match(JSON.stringify(analysis), /REA checkpoint first comment/); + assert.match(analysis.pseudocode, /value/); + await resumed.call("annotate_native_function", { + procedure: "rea_checkpoint_retained", + comment: "REA checkpoint second comment", + }); + await resumed.call("close_binary"); + return { structure_field_preserved: true }; + } finally { + await resumed.close(); + } + }); + await phase("CLI sees the second MCP edit", async () => { + const result = await cli("rea_checkpoint_retained"); + assert.match( + JSON.stringify(result.normalized_result), + /REA checkpoint second comment/, + ); + return {}; + }); + await phase( + "an older immutable REA snapshot refuses the changed checkpoint", + async () => { + const refusal = await refuseCli( + "rea_checkpoint_leaf", + ["--snapshot", snapshotPath], + "evidence_integrity_mismatch", + ); + assert.ok(refusal.details.binding_mismatch.profile_digest); + return {}; + }, + ); + await phase( + "an edited live Program switches to identical bytes at another path", + async () => { + const active = await connect(); + const alias = join(root, "live-alias"); + await copyFile(target, alias); + try { + await active.call("open_binary", { + path: target, + provider_id: "ghidra", + }); + await active.call("annotate_native_function", { + procedure: "rea_checkpoint_retained", + comment: "REA checkpoint second comment; saved during alias switch", + }); + await active.call("open_binary", { + path: alias, + provider_id: "ghidra", + }); + const result = await active.call("procedure_pseudo_code", { + procedure: "rea_checkpoint_retained", + }); + assert.match(JSON.stringify(result), /saved during alias switch/); + await active.call("close_binary"); + return { alias_switch_preserved_edits: true }; + } finally { + await active.close(); + } + }, + ); + const current = await manifest(), + bytes = await readFile(manifestPath); + await phase( + "target/profile mismatch is refused without replacing the saved state", + async () => { + try { + await writeFile( + manifestPath, + JSON.stringify({ ...current, target_sha256: "0".repeat(64) }), + ); + await refuseCli("rea_checkpoint_retained", [], "configuration_invalid"); + } finally { + await writeFile(manifestPath, bytes); + } + return {}; + }, + ); + await phase( + "malformed manifest is refused without a fresh import", + async () => { + try { + await writeFile(manifestPath, "{"); + await refuseCli("rea_checkpoint_retained", [], "configuration_invalid"); + } finally { + await writeFile(manifestPath, bytes); + } + return {}; + }, + ); + await phase( + "corrupt packed data is refused and explicit restoration recovers", + async () => { + const packed = join( + checkpointDirectory, + (await readdir(checkpointDirectory))[0], + current.source.file, + ), + backup = join(root, "good.gzf"); + await copyFile(packed, backup); + try { + await writeFile(packed, "corrupted packed data"); + await refuseCli("rea_checkpoint_retained", [], "configuration_invalid"); + } finally { + await copyFile(backup, packed); + } + const result = await cli("rea_checkpoint_retained"); + assert.match( + JSON.stringify(result.normalized_result), + /REA checkpoint second comment/, + ); + return {}; + }, + ); + assert.equal(await sha(target), targetDigest); + assert.deepEqual(await readdir(runtime), []); + const checkpointFiles = await readdir( + join(checkpointDirectory, (await readdir(checkpointDirectory))[0]), + ); + assert.equal( + checkpointFiles.length, + 2, + "Only the current manifest and packed Program should remain", + ); + report.status = "verified"; +} catch (cause) { + report.status = "failed"; + report.error = cause instanceof Error ? cause.stack : String(cause); + if (typeof cause?.stdout === "string") + report.failure_stdout = cause.stdout.slice(-12000); + if (typeof cause?.stderr === "string") + report.failure_stderr = cause.stderr.slice(-4000); + process.exitCode = 1; +} finally { + report.run = await completeVerifierRun(run); + let cleanupVerified = false; + try { + assertVerifierCleanupComplete(report.run.process_lineage); + cleanupVerified = true; + } catch (cause) { + report.cleanup_error = String(cause); + report.retained_workspace = root; + process.exitCode = 1; + } + if (cleanupVerified) { + try { + await rm(root, { recursive: true, force: true }); + } catch (cause) { + report.cleanup_error = String(cause); + report.retained_workspace = root; + process.exitCode = 1; + } + } + process.stdout.write(JSON.stringify(report, null, 2) + "\n"); +} diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index b43002b7a1..0eff579021 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -249,6 +249,8 @@ export interface ProviderIdentity { /** Typed analysis profile resolved before provider startup; null means unresolved. */ export interface AnalysisProfileResolution { readonly profile: AnalysisProfileCommitment | null; + /** Closing a client can publish state that invalidates a previewed profile. */ + readonly refreshAfterRetirement?: true; /** Keep provider-private facts bound to this selection without Evidence serialization. */ readonly createClient?: AnalysisProviderBoundClientFactory; } diff --git a/src/application/SetupRegistrationEnvironment.ts b/src/application/SetupRegistrationEnvironment.ts index 54e2d4183e..dc4c9948f7 100644 --- a/src/application/SetupRegistrationEnvironment.ts +++ b/src/application/SetupRegistrationEnvironment.ts @@ -63,6 +63,7 @@ const SELECTED_REGISTRATION_SETTINGS = [ "REA_GHIDRA_DISABLED_ANALYZERS", "REA_GHIDRA_SEED_FILE", "REA_GHIDRA_PROJECT_PATH", + "REA_GHIDRA_CHECKPOINT_DIR", "REA_GHIDRA_PROGRAM_NAME", "REA_GHIDRA_NATIVEAOT_JAR", "REA_HOPPER_CALLER_SCAN_BUDGET_MS", diff --git a/src/application/binary/AnalysisProviderEvaluation.ts b/src/application/binary/AnalysisProviderEvaluation.ts index 0d69c0d597..513b3905e4 100644 --- a/src/application/binary/AnalysisProviderEvaluation.ts +++ b/src/application/binary/AnalysisProviderEvaluation.ts @@ -24,6 +24,7 @@ export interface AnalysisProviderCandidateEvaluation { readonly candidate: AnalysisProviderCandidate; readonly status: AnalysisProviderCandidateStatus; readonly profile?: AnalysisProfileCommitment; + readonly refreshAfterRetirement?: true; readonly createClient?: AnalysisProviderBoundClientFactory; readonly profileError?: AnalysisError; } @@ -118,6 +119,9 @@ export const evaluateAnalysisProviderCandidate = async ( candidate, status, profile, + ...(resolved.value.refreshAfterRetirement === undefined + ? {} + : { refreshAfterRetirement: resolved.value.refreshAfterRetirement }), ...(resolved.value.createClient === undefined ? {} : { createClient: resolved.value.createClient }), diff --git a/src/application/binary/AnalysisProviderRegistry.ts b/src/application/binary/AnalysisProviderRegistry.ts index ef6cdb8012..0b153a31aa 100644 --- a/src/application/binary/AnalysisProviderRegistry.ts +++ b/src/application/binary/AnalysisProviderRegistry.ts @@ -80,6 +80,7 @@ export interface AnalysisProviderBinding { readonly identity: ProviderIdentity & { readonly version: string }; readonly selectionSource: "request" | "environment" | "auto-single-candidate"; readonly profile: AnalysisProfileCommitment; + readonly refreshAfterRetirement?: true; readonly createClient?: AnalysisProviderBoundClientFactory; /** The provider's capabilities, attributed to its resolved identity. */ readonly capabilities: readonly CapabilityDescriptor[]; @@ -831,6 +832,9 @@ const selectedResult = ( identity: structuredClone(identity), selectionSource: source, profile: structuredClone(evaluated.profile), + ...(evaluated.refreshAfterRetirement === undefined + ? {} + : { refreshAfterRetirement: evaluated.refreshAfterRetirement }), ...(evaluated.createClient === undefined ? {} : { createClient: evaluated.createClient }), diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index d69be1cb18..75daea3ecf 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -600,8 +600,9 @@ export class BinarySession ); const resolved = await resolve(); if (!resolved.ok) return resolved; - const { target, route, sameTarget } = resolved.value; - const { profile } = route; + const { target, sameTarget } = resolved.value; + let { route } = resolved.value; + let { profile } = route; if (isAborted(options.signal)) return err( new AnalysisCancelledError("open_binary", { @@ -671,6 +672,29 @@ export class BinarySession }), ), ); + if (previous !== undefined) { + const refreshed = await this.#providerRouter.refreshAfterRetirement( + route, + target, + options.signal, + ); + const admitted = refreshed.ok + ? validateResolvedSessionTarget({ + target, + route: refreshed.value, + current: previous, + options, + stagedSnapshotMismatch: (target, profile) => + this.snapshotMismatch(target, profile), + }) + : refreshed; + if (!admitted.ok) + return err( + await this.#restoreAfterFailedOpen(previous, admitted.error), + ); + route = admitted.value.route; + profile = route.profile; + } const runId = randomUUID(); const client = route.createClient(target, { runId, @@ -1648,15 +1672,22 @@ export class BinarySession previous: SessionBinding | undefined, ): Promise> { if (previous === undefined) return ok(null); + const refreshed = await this.#providerRouter.refreshAfterRetirement( + previous.route, + previous.target, + ); + if (!refreshed.ok) return refreshed; + const route = refreshed.value; const runId = randomUUID(); - const client = previous.route.createClient(previous.target, { + const client = route.createClient(previous.target, { runId, }); - const run: SessionRun = { client, route: previous.route, runId }; + const run: SessionRun = { client, route, runId }; const started = await client.execute("health", {}); if (started.ok) { this.#active = { ...previous, + profile: route.profile, ...run, nativeSourceAdmitted: false, }; diff --git a/src/application/binary/BinarySessionRestoration.test.ts b/src/application/binary/BinarySessionRestoration.test.ts index f4ef5eb695..900bdc6da0 100644 --- a/src/application/binary/BinarySessionRestoration.test.ts +++ b/src/application/binary/BinarySessionRestoration.test.ts @@ -1,4 +1,9 @@ import { expect, it } from "vitest"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { elf } from "../../domain/binaryTarget.fixture.js"; +import { createTestBinarySession } from "../../../tests/fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import { AnalysisCancelledError, AnalysisOutputError, @@ -16,6 +21,81 @@ import { type SessionProviderRoute, } from "./SessionProviderRouter.js"; +it.each([false, true])( + "refreshes state published by close on alias switch (incoming fails: %s)", + async (failIncoming) => { + const directory = await createTestTempDirectory("rea-published-profile-"); + const first = join(directory, "first.elf"), + alias = join(directory, "alias.elf"); + await writeFile(first, elf(2, 1, 62)); + await writeFile(alias, elf(2, 1, 62)); + const identity = { + id: "fixture", + name: "Fixture analysis provider", + version: "1", + }; + let generation = 0, + dirty = false; + const starts: number[] = []; + const failure = new AnalysisOutputError( + "health", + "Incoming target refused", + ); + const session = createTestBinarySession( + (_target, profile) => ({ + execute: async () => { + const selected = profile?.parameters.generation; + starts.push(Number(selected)); + if (selected !== generation) + return err( + new AnalysisOutputError("health", "Stale published profile"), + ); + return ok(createAnalysisExecution(null, identity)); + }, + close: async () => { + if (dirty) { + generation++; + dirty = false; + } + return ok(null); + }, + }), + { + resolveAnalysisProfile: async (target) => + failIncoming && generation > 0 && target.path === alias + ? err(failure) + : ok({ + profile: createAnalysisProfile(identity, { generation }), + refreshAfterRetirement: true, + }), + }, + ); + try { + expect((await session.open(first)).ok).toBe(true); + expect((await session.execute("list_strings", {})).ok).toBe(true); + dirty = true; + const switched = await session.open(alias); + if (failIncoming) { + expect(switched.ok).toBe(false); + expect(session.activeTarget()?.path).toBe(first); + } else { + expect(switched.ok).toBe(true); + expect(session.activeTarget()?.path).toBe(alias); + } + expect(session.analysisProfile()?.parameters.generation).toBe(1); + expect(await session.status()).toMatchObject({ + analysis_provider_binding: { + selection_source: "auto-single-candidate", + }, + }); + expect((await session.execute("list_strings", {})).ok).toBe(true); + expect(starts).toEqual([0, 1]); + } finally { + expect((await session.close()).ok).toBe(true); + } + }, +); + it("reports the previous selection after a failed switch restores its provider", async () => { const session = new BinarySession( SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []), diff --git a/src/application/binary/SessionProviderRouter.ts b/src/application/binary/SessionProviderRouter.ts index 5e454bd54c..3eb47cf518 100644 --- a/src/application/binary/SessionProviderRouter.ts +++ b/src/application/binary/SessionProviderRouter.ts @@ -25,6 +25,7 @@ export interface SessionProviderRoute { readonly identity: ProviderIdentity; readonly capabilities: ReadonlyMap; readonly profile: AnalysisProfileCommitment | null; + readonly refreshAfterRetirement?: true; readonly binding: AnalysisProviderBinding | null; readonly selection: AnalysisProviderSelection | undefined; createClient( @@ -97,6 +98,37 @@ export class SessionProviderRouter { : selected; } + /** Refresh only provider selections whose state can be published by close. */ + async refreshAfterRetirement( + route: SessionProviderRoute, + target: BinaryTarget, + signal?: AbortSignal, + ): Promise> { + if (route.refreshAfterRetirement !== true || route.binding === null) + return ok(route); + const refreshed = await this.registry.select( + target, + route.binding.identity.id, + signal === undefined ? {} : { signal }, + ); + if (!refreshed.ok) return refreshed; + return ok( + selectableRoute(this.auxiliaryProviders, { + ...refreshed.value, + requestedProviderId: + route.selection?.requestedProviderId ?? + refreshed.value.requestedProviderId, + binding: + refreshed.value.binding === null + ? null + : { + ...refreshed.value.binding, + selectionSource: route.binding.selectionSource, + }, + }), + ); + } + /** Candidate status authoritative for the active or target-free route. */ async candidateStatuses( route: SessionProviderRoute, @@ -156,6 +188,9 @@ const selectableRoute = ( identity, capabilities, profile: binding?.profile ?? null, + ...(binding?.refreshAfterRetirement === undefined + ? {} + : { refreshAfterRetirement: binding.refreshAfterRetirement }), binding, selection, createClient: (target, context) => diff --git a/src/config.test.ts b/src/config.test.ts index 5b6264bd97..a9e3ea9661 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -11,6 +11,42 @@ import { } from "./config/environment.js"; import { projectAnalysisError } from "./domain/analysisErrorProjection.js"; +it("keeps the caller's opt-in Ghidra checkpoint directory and leaves it disabled by default", () => { + const selected = parseConfig({ + REA_GHIDRA_CHECKPOINT_DIR: "/tmp/checkpoints with spaces", + }); + expect(selected.ok && selected.value.ghidraCheckpointDirectory).toBe( + "/tmp/checkpoints with spaces", + ); + const defaults = parseConfig({}); + expect( + defaults.ok && defaults.value.ghidraCheckpointDirectory, + ).toBeUndefined(); + expect(parseConfig({ REA_GHIDRA_CHECKPOINT_DIR: "relative" }).ok).toBe(false); +}); + +it.each([ + ["REA_GHIDRA_PROJECT_PATH", "/tmp/selected.gpr"], + ["REA_GHIDRA_SEED_FILE", "/tmp/seeds.json"], + ["REA_GHIDRA_LANGUAGE_ID", "x86:LE:64:default"], + ["REA_GHIDRA_NATIVEAOT_JAR", "/tmp/extension.jar"], + ["REA_GHIDRA_DISABLED_ANALYZERS", "Stack"], +])( + "refuses %s with checkpoints rather than silently dropping the selected input", + (setting, value) => { + const result = parseConfig({ + REA_GHIDRA_CHECKPOINT_DIR: "/tmp/checkpoints", + [setting]: value, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("conflicting inputs accepted"); + expect(result.error.message).toContain("checkpoints"); + expect(result.error).toMatchObject({ + settings: [expect.objectContaining({ setting })], + }); + }, +); + it("reports a configuration failure for a NUL in Hopper loader arguments", () => { const result = parseConfig({ HOPPER_LOADER_ARGS_JSON: JSON.stringify(["--option", "value\0suffix"]), diff --git a/src/config/environment.ts b/src/config/environment.ts index a54073e5b2..ada3ed8636 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -123,6 +123,14 @@ const environmentSchema = z.object({ ), REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS: ghidraDecompileTimeoutSchema.optional(), REA_GHIDRA_PROJECT_PATH: ghidraProjectPathSchema.optional(), + REA_GHIDRA_CHECKPOINT_DIR: z + .string() + .min(1) + .refine( + (value) => value.startsWith("/") && !value.includes("\0"), + "must be an absolute POSIX directory path", + ) + .optional(), REA_GHIDRA_PROGRAM_NAME: ghidraProgramNameSchema.optional(), REA_GHIDRA_LANGUAGE_ID: ghidraLanguageIdSchema.optional(), REA_GHIDRA_COMPILER_SPEC_ID: ghidraCompilerSpecIdSchema.optional(), @@ -243,6 +251,27 @@ export const parseEnvironment = ( const parsed = parseEnvironmentValues(environment, unresolvedSettings); if (!parsed.ok) return parsed; const env = parsed.value; + if (env.REA_GHIDRA_CHECKPOINT_DIR !== undefined) { + const conflicts = [ + "REA_GHIDRA_PROJECT_PATH", + "REA_GHIDRA_SEED_FILE", + "REA_GHIDRA_NATIVEAOT_JAR", + "REA_GHIDRA_LANGUAGE_ID", + "REA_GHIDRA_DISABLED_ANALYZERS", + ].filter((key) => Reflect.get(env, key) !== undefined); + if (conflicts.length > 0) + return err( + new ConfigurationError( + "Ghidra checkpoints currently support stock fresh-import analysis only", + { + settings: conflicts.map((setting) => ({ + setting, + constraint: "cannot be combined with REA_GHIDRA_CHECKPOINT_DIR", + })), + }, + ), + ); + } const project = env.REA_GHIDRA_PROJECT_PATH; const incompatible = project === undefined diff --git a/src/config/parseConfig.ts b/src/config/parseConfig.ts index 5fe9781fdb..b0e1cb6aa6 100644 --- a/src/config/parseConfig.ts +++ b/src/config/parseConfig.ts @@ -137,6 +137,9 @@ export const parseConfig = ( }, }), ghidraInstallDir: env.GHIDRA_INSTALL_DIR, + ...(env.REA_GHIDRA_CHECKPOINT_DIR === undefined + ? {} + : { ghidraCheckpointDirectory: env.REA_GHIDRA_CHECKPOINT_DIR }), ghidraJavaHome: env.JAVA_HOME, ghidraStartupTimeoutMs: env.REA_GHIDRA_STARTUP_TIMEOUT_MS, ...(env.REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS === undefined diff --git a/src/config/types.ts b/src/config/types.ts index b2e3b724cf..455d1160ca 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -31,6 +31,8 @@ export interface AppConfig { readonly ghidraDisabledAnalyzers?: readonly string[]; readonly ghidraSeedFile?: string; readonly ghidraExistingProject?: GhidraProjectSelection; + /** Opt-in storage for checkpoints of REA-owned Ghidra Programs. */ + readonly ghidraCheckpointDirectory?: string; readonly ghidraNativeAotJar?: string; readonly ilspyCmdPath: string | undefined; readonly hopperLauncherPath: string; diff --git a/src/ghidra/GhidraCheckpoint.test.ts b/src/ghidra/GhidraCheckpoint.test.ts new file mode 100644 index 0000000000..f10c731f59 --- /dev/null +++ b/src/ghidra/GhidraCheckpoint.test.ts @@ -0,0 +1,514 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { + chmod, + access, + mkdtemp, + mkdir, + readFile, + writeFile, + rm, + rename, + symlink, + type FileHandle, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { createAnalysisProfile } from "../domain/analysisProfile.js"; +import { acquireOwnedLease } from "../process/OwnedLease.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { + GhidraCheckpoint, + resolveGhidraCheckpoint, +} from "./GhidraCheckpoint.js"; + +const controls = vi.hoisted(() => ({ + file: undefined as ((handle: FileHandle, path: string) => void) | undefined, +})); +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + rename: vi.fn(actual.rename), + rm: vi.fn(actual.rm), + open: async (...args: Parameters) => { + const handle = await actual.open(...args); + controls.file?.(handle, String(args[0])); + return handle; + }, + }; +}); + +vi.mock("../process/OwnedLease.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, acquireOwnedLease: vi.fn(actual.acquireOwnedLease) }; +}); + +const roots: string[] = []; +const owners: GhidraCheckpoint[] = []; +afterEach(async () => { + controls.file = undefined; + vi.restoreAllMocks(); + const leases = await vi.importActual< + typeof import("../process/OwnedLease.js") + >("../process/OwnedLease.js"); + vi.mocked(acquireOwnedLease) + .mockReset() + .mockImplementation(leases.acquireOwnedLease); + const actual = + await vi.importActual( + "node:fs/promises", + ); + vi.mocked(rm).mockReset().mockImplementation(actual.rm); + vi.mocked(rename).mockReset().mockImplementation(actual.rename); + for (const owner of owners.splice(0)) await owner.close(); + for (const root of roots.splice(0)) + await rm(root, { recursive: true, force: true }); +}); + +it.skipIf(process.platform === "win32")( + "retains lease identity after release fails and releases it on retry", + async () => { + const f = await fixture(); + const actual = await vi.importActual< + typeof import("../process/OwnedLease.js") + >("../process/OwnedLease.js"); + vi.mocked(acquireOwnedLease).mockImplementationOnce(async (input) => { + const result = await actual.acquireOwnedLease(input); + if (!result.acquired) return result; + let failed = false; + return { + acquired: true, + lease: { + ...result.lease, + release: async () => { + if (!failed) { + failed = true; + throw new Error("controlled lease release failure"); + } + await result.lease.release(); + }, + }, + }; + }); + await f.owner.acquire("original"); + const retained = [...f.owner.cleanupResources]; + expect(retained).toHaveLength(1); + await expect(f.owner.close()).rejects.toThrow( + "controlled lease release failure", + ); + expect(f.owner.cleanupResources).toEqual(retained); + expect(f.owner.hasResources).toBe(true); + await f.owner.close(); + expect(f.owner.cleanupResources).toEqual([]); + const peer = new GhidraCheckpoint(f.selection, f.resources); + owners.push(peer); + expect(await peer.acquire("replacement")).toBeUndefined(); + }, +); +const profile = createAnalysisProfile( + { id: "ghidra", name: "Ghidra", version: "12.1.4" }, + { import_mode: "test" }, +); +const target = "1".repeat(64); +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "rea-checkpoint-test-")); + roots.push(root); + const resources = new ArtifactResourceScope(); + const selection = await resolveGhidraCheckpoint( + root, + target, + profile, + resources, + ); + const owner = new GhidraCheckpoint(selection, resources); + owners.push(owner); + const packed = join(root, "fixture.gzf"); + await writeFile(packed, "controlled packed bytes"); + return { root, resources, selection, owner, packed }; +} + +it.skipIf(process.platform === "win32").each( + ["manifest", "packed"].flatMap((stage) => [ + { stage, cancel: true, closeFails: false }, + { stage, cancel: true, closeFails: true }, + { stage, cancel: false, closeFails: true }, + ]), +)( + "preserves $stage cancellation=$cancel and descriptor cleanup=$closeFails", + async ({ stage, cancel, closeFails }) => { + const f = await fixture(); + await f.owner.acquire("writer"); + await f.owner.publish(f.packed); + await f.owner.close(); + const controller = new AbortController(); + const reason = { caller: "stopped hashing" }; + controls.file = (handle, path) => { + if (!path.endsWith(stage === "manifest" ? "current.json" : ".gzf")) + return; + const read = handle.read.bind(handle); + vi.spyOn(handle, "read").mockImplementation( + async (...args: Parameters) => { + const result = await read(...args); + if (cancel) controller.abort(reason); + return result; + }, + ); + if (closeFails) + vi.spyOn(handle, "close").mockRejectedValueOnce( + new Error("controlled checkpoint close failure"), + ); + }; + try { + const error = await resolveGhidraCheckpoint( + f.root, + target, + profile, + f.resources, + controller.signal, + ).catch((cause: unknown) => cause); + if (cancel) { + expect(error).toBeInstanceOf(AnalysisCancelledError); + expect((error as AnalysisCancelledError).cause).toBe(reason); + } else expect(error).toMatchObject({ _tag: "ProviderAdapterError" }); + expect(error).toMatchObject({ cleanupIncomplete: closeFails }); + expect(f.resources.hasPending).toBe(closeFails); + await f.resources.retryCleanup(); + expect(error).toMatchObject({ cleanupIncomplete: false }); + expect(f.resources.hasPending).toBe(false); + } finally { + controls.file = undefined; + await f.resources.retryCleanup(); + } + }, +); + +it.skipIf(process.platform === "win32")( + "publishes a verified state, reopens it, and separates target and analysis identities", + async () => { + const f = await fixture(); + expect(f.selection.state).toEqual({ kind: "initial-analysis" }); + expect(await f.owner.acquire("first")).toBeUndefined(); + await f.owner.publish(f.packed); + await f.owner.close(); + const loaded = await resolveGhidraCheckpoint( + f.root, + target, + profile, + f.resources, + ); + expect(loaded.state).toEqual(f.selection.state); + const saved = f.owner.observation.source!; + const resumed = new GhidraCheckpoint(loaded, f.resources); + owners.push(resumed); + expect(await resumed.acquire("second")).toEqual({ + path: join(loaded.directory, saved.file), + sha256: saved.sha256, + }); + expect( + ( + await resolveGhidraCheckpoint( + f.root, + "2".repeat(64), + profile, + f.resources, + ) + ).key, + ).not.toBe(f.selection.key); + expect( + ( + await resolveGhidraCheckpoint( + f.root, + target, + createAnalysisProfile(profile.provider, { import_mode: "other" }), + f.resources, + ) + ).key, + ).not.toBe(f.selection.key); + }, +); + +it.skipIf(process.platform === "win32")( + "refuses a concurrent owner and admits it after the original owner closes", + async () => { + const f = await fixture(); + await f.owner.acquire("first"); + const other = new GhidraCheckpoint(f.selection, f.resources); + owners.push(other); + await expect(other.acquire("second")).rejects.toThrow( + /another REA session.*first/, + ); + await f.owner.close(); + expect(await other.acquire("second")).toBeUndefined(); + }, +); + +it.skipIf(process.platform === "win32")( + "refuses state changed after profile resolution without replacing the current manifest", + async () => { + const f = await fixture(); + await f.owner.acquire("first"); + await f.owner.publish(f.packed); + const edited = join(f.root, "edited.gzf"); + await writeFile(edited, "edited checkpoint"); + await f.owner.publish(edited); + await f.owner.close(); + const stale = new GhidraCheckpoint(f.selection, f.resources); + owners.push(stale); + const bytes = await readFile( + join(f.selection.directory, "current.json"), + "utf8", + ); + await expect(stale.acquire("stale")).rejects.toThrow( + /changed after profile selection/, + ); + await expect(stale.publish(f.packed)).rejects.toThrow( + /no admitted session lease/, + ); + expect( + await readFile(join(f.selection.directory, "current.json"), "utf8"), + ).toEqual(bytes); + }, +); + +it.skipIf(process.platform === "win32")( + "keeps initial identity stable and retains legacy data without guessing its origin", + async () => { + const f = await fixture(); + await f.owner.acquire("initial"); + await f.owner.publish(f.packed); + await f.owner.close(); + const initial = await resolveGhidraCheckpoint( + f.root, + target, + profile, + f.resources, + ); + expect(initial).toEqual(f.selection); + const path = join(initial.directory, "current.json"); + const record = JSON.parse(await readFile(path, "utf8")); + expect(record.version).toBe(2); + expect(record.source.kind).toBe("initial-analysis"); + const legacy = { + ...record, + version: 1, + source: { file: record.source.file, sha256: record.source.sha256 }, + }; + await writeFile(path, JSON.stringify(legacy)); + const selected = await resolveGhidraCheckpoint( + f.root, + target, + profile, + f.resources, + ); + expect(selected.state).toEqual({ + kind: "retained", + sha256: record.source.sha256, + }); + const resumed = new GhidraCheckpoint(selected, f.resources); + owners.push(resumed); + expect((await resumed.acquire("legacy"))?.sha256).toBe( + record.source.sha256, + ); + await resumed.close(); + expect(JSON.parse(await readFile(path, "utf8"))).toEqual(legacy); + }, +); + +it.skipIf(process.platform === "win32")( + "rejects corruption, malformed manifests, and traversal rather than silently importing fresh", + async () => { + const f = await fixture(); + await f.owner.acquire("first"); + await f.owner.publish(f.packed); + await f.owner.close(); + const manifestPath = join(f.selection.directory, "current.json"); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + await writeFile( + join(f.selection.directory, manifest.source.file), + "corrupt", + ); + await expect( + resolveGhidraCheckpoint(f.root, target, profile, f.resources), + ).rejects.toThrow(/checksum/); + await writeFile( + manifestPath, + JSON.stringify({ + ...manifest, + source: { ...manifest.source, file: "../../foreign.gzf" }, + }), + ); + await expect( + resolveGhidraCheckpoint(f.root, target, profile, f.resources), + ).rejects.toThrow(); + await writeFile(manifestPath, "{"); + await expect( + resolveGhidraCheckpoint(f.root, target, profile, f.resources), + ).rejects.toThrow(); + }, +); + +it.skipIf(process.platform === "win32")( + "keeps the prior checkpoint after a failed publication and succeeds on retry", + async () => { + const f = await fixture(); + await f.owner.acquire("first"); + await f.owner.publish(f.packed); + const path = join(f.selection.directory, "current.json"), + before = await readFile(path, "utf8"); + const actual = + await vi.importActual( + "node:fs/promises", + ); + vi.mocked(rename).mockRejectedValueOnce( + new Error("controlled publish failure"), + ); + const next = join(f.root, "next.gzf"); + await writeFile(next, "new packed bytes"); + await expect(f.owner.publish(next)).rejects.toMatchObject({ + _tag: "ProviderAdapterError", + diagnostics: { reason: "controlled publish failure" }, + }); + expect(await readFile(path, "utf8")).toEqual(before); + vi.mocked(rename).mockImplementation(actual.rename); + await f.owner.publish(next); + expect(await readFile(path, "utf8")).not.toEqual(before); + }, +); + +it.skipIf(process.platform === "win32")( + "retains publication and its old-file cleanup when cleanup fails, then retries without saving twice", + async () => { + const f = await fixture(); + await f.owner.acquire("first"); + await f.owner.publish(f.packed); + const old = f.owner.observation.source!; + const next = join(f.root, "next.gzf"); + await writeFile(next, "changed"); + const actual = + await vi.importActual( + "node:fs/promises", + ); + vi.mocked(rm).mockImplementation(async (path, options) => { + if (path === join(f.selection.directory, old.file)) + throw new Error("controlled cleanup failure"); + await actual.rm(path, options); + }); + await expect(f.owner.publish(next)).rejects.toMatchObject({ + cause: expect.objectContaining({ + message: "controlled cleanup failure", + }), + }); + const committed = await readFile( + join(f.selection.directory, "current.json"), + "utf8", + ); + expect(f.owner.hasResources).toBe(true); + await expect(f.owner.close()).rejects.toMatchObject({ + cause: expect.objectContaining({ + message: "controlled cleanup failure", + }), + }); + vi.mocked(rm).mockImplementation(actual.rm); + await f.owner.publish(next); + expect( + await readFile(join(f.selection.directory, "current.json"), "utf8"), + ).toEqual(committed); + await f.owner.close(); + expect(f.owner.hasResources).toBe(false); + }, +); + +it.skipIf(process.platform === "win32")( + "preserves a published checkpoint when cancellation is observed after the manifest commit", + async () => { + const f = await fixture(); + await f.owner.acquire("first"); + const controller = new AbortController(); + const actual = + await vi.importActual( + "node:fs/promises", + ); + vi.mocked(rename).mockImplementationOnce(async (...args) => { + await actual.rename(...args); + controller.abort("after publication"); + }); + await expect( + f.owner.publish(f.packed, controller.signal), + ).rejects.toThrow(); + expect(f.owner.observation.source).not.toBeNull(); + expect( + (await resolveGhidraCheckpoint(f.root, target, profile, f.resources)) + .state, + ).toEqual({ kind: "initial-analysis" }); + }, +); + +it.skipIf(process.platform === "win32")( + "does not adopt a public checkpoint directory", + async () => { + const f = await fixture(); + await mkdir(f.selection.directory, { mode: 0o755 }); + await chmod(f.selection.directory, 0o755); + await expect(f.owner.acquire("first")).rejects.toThrow(/private directory/); + }, +); + +it.skipIf(process.platform === "win32")( + "keeps its lease socket short when checkpoint and inherited temporary paths are long", + async () => { + const f = await fixture(); + const selection = await resolveGhidraCheckpoint( + join(f.root, "long-".repeat(24)), + target, + profile, + f.resources, + ); + const owner = new GhidraCheckpoint(selection, f.resources); + owners.push(owner); + expect(await owner.acquire("long-directory")).toBeUndefined(); + }, +); + +it.skipIf(process.platform === "win32")( + "preserves symlink/parent traversal and shares its lease with the physical directory", + async () => { + const f = await fixture(); + await mkdir(join(f.root, "physical", "child"), { recursive: true }); + await symlink(join(f.root, "physical", "child"), join(f.root, "link")); + const selected = `${f.root}/link/../selected`; + const selection = await resolveGhidraCheckpoint( + selected, + target, + profile, + f.resources, + ); + expect(selection.directory.startsWith(selected)).toBe(true); + const owner = new GhidraCheckpoint(selection, f.resources); + owners.push(owner); + await owner.acquire("selected"); + await owner.publish(f.packed); + await access( + join(f.root, "physical", "selected", selection.key, "current.json"), + ); + await expect( + access(join(f.root, "selected", selection.key, "current.json")), + ).rejects.toThrow(); + const alias = await resolveGhidraCheckpoint( + join(f.root, "physical", "selected"), + target, + profile, + f.resources, + ); + const peer = new GhidraCheckpoint(alias, f.resources); + owners.push(peer); + await expect(peer.acquire("physical")).rejects.toThrow( + /another REA session/, + ); + await owner.close(); + expect((await peer.acquire("physical"))?.sha256).toBe( + owner.observation.source?.sha256, + ); + }, +); diff --git a/src/ghidra/GhidraCheckpoint.ts b/src/ghidra/GhidraCheckpoint.ts new file mode 100644 index 0000000000..9b9d85d8ed --- /dev/null +++ b/src/ghidra/GhidraCheckpoint.ts @@ -0,0 +1,434 @@ +import { appendCallerPath } from "../filesystem/callerPath.js"; +import { createHash, randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { + copyFile, + lstat, + mkdir, + open, + realpath, + rename, + rm, +} from "node:fs/promises"; +import { join } from "node:path"; +import { z } from "zod"; +import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; +import { ConfigurationError } from "../domain/configurationErrors.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { + withArtifactFileHandle, + withArtifactRegularFile, + assertArtifactReadActive, + artifactFilePrimaryFacts, + mapArtifactFileFailure, +} from "../artifacts/ArtifactFileOperation.js"; +import { + sameRegularFileState, + RegularFileChangedError, +} from "../filesystem/RegularFile.js"; +import { acquireOwnedLease, type OwnedLease } from "../process/OwnedLease.js"; +import { + ghidraFileFailure, + readGhidraProfileArtifact, +} from "./GhidraFileArtifacts.js"; + +const digest = z.string().regex(/^[a-f0-9]{64}$/); +const sourceIdentitySchema = z + .object({ + file: z.string().regex(/^program-[a-f0-9-]{36}\.gzf$/), + sha256: digest, + }) + .strict(); +const sourceSchema = sourceIdentitySchema.extend({ + kind: z.enum(["initial-analysis", "retained"]), +}); +type CheckpointSource = z.infer; +const manifestIdentitySchema = z.object({ + target_sha256: digest, + base_profile_digest: digest, +}); +const manifestSchema = z.union([ + manifestIdentitySchema + .extend({ version: z.literal(2), source: sourceSchema }) + .strict(), + // Earlier local checkpoints have no initial-generation proof. Retain their + // data, binding their identity to the verified content rather than guessing. + manifestIdentitySchema + .extend({ version: z.literal(1), source: sourceIdentitySchema }) + .strict() + .transform((value) => ({ + ...value, + version: 2 as const, + source: { ...value.source, kind: "retained" as const }, + })), +]); +const stateSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("initial-analysis") }).strict(), + z.object({ kind: z.literal("retained"), sha256: digest }).strict(), +]); +const checkpointState = ( + source: CheckpointSource | null, +): z.infer => + source === null || source.kind === "initial-analysis" + ? { kind: "initial-analysis" } + : { kind: "retained", sha256: source.sha256 }; + +/** Semantic generation is stable through the first save; edited states bind their digest. */ +export const ghidraCheckpointSelectionSchema = z + .object({ + directory: z.string().min(1), + key: digest, + target_sha256: digest, + base_profile_digest: digest, + state: stateSchema, + }) + .strict(); +export type GhidraCheckpointSelection = z.infer< + typeof ghidraCheckpointSelectionSchema +>; +export interface GhidraPackedProgram { + readonly path: string; + readonly sha256: string; +} + +const missing = (cause: unknown): boolean => { + const primary = artifactFilePrimaryFacts(cause).cause; + return ( + primary instanceof Error && "code" in primary && primary.code === "ENOENT" + ); +}; +const failure = (directory: string, reason: string, cause?: unknown) => + new ConfigurationError( + `Ghidra checkpoint at ${JSON.stringify(directory)}: ${reason}. Preserve this directory; correct its contents or select a new REA_GHIDRA_CHECKPOINT_DIR, then reopen the target.`, + { + cause, + settings: [{ setting: "REA_GHIDRA_CHECKPOINT_DIR", constraint: reason }], + }, + ); + +/** Hash large packed Programs with one 64 KiB buffer and stable descriptor identity. */ +export const hashGhidraPackedProgram = ( + path: string, + resources: ArtifactResourceScope, + signal?: AbortSignal, +): Promise => + withArtifactRegularFile( + path, + resources, + signal, + async ({ handle, initial }) => { + const hash = createHash("sha256"), + buffer = Buffer.allocUnsafe(64 * 1024); + for (let offset = 0; offset < initial.size;) { + assertArtifactReadActive(signal); + const { bytesRead } = await handle.read( + buffer, + 0, + Math.min(buffer.length, initial.size - offset), + offset, + ); + if (bytesRead === 0) throw new RegularFileChangedError(path); + hash.update(buffer.subarray(0, bytesRead)); + offset += bytesRead; + } + if (!sameRegularFileState(initial, await handle.stat())) + throw new RegularFileChangedError(path); + return hash.digest("hex"); + }, + ); + +async function readSource( + selection: Omit, + resources: ArtifactResourceScope, + signal?: AbortSignal, +) { + let manifest: z.infer; + try { + manifest = await readGhidraProfileArtifact( + appendCallerPath(selection.directory, "current.json"), + 64 * 1024, + resources, + signal, + (bytes) => manifestSchema.parse(JSON.parse(bytes.toString("utf8"))), + ); + } catch (cause: unknown) { + if (missing(cause) && !resources.hasPending) return null; + throw ghidraFileFailure( + mapArtifactFileFailure(cause, (primary) => + primary instanceof SyntaxError || primary instanceof z.ZodError + ? failure(selection.directory, "the manifest is malformed", primary) + : primary, + ), + "resolve_analysis_profile", + { path: appendCallerPath(selection.directory, "current.json") }, + ); + } + if ( + manifest.target_sha256 !== selection.target_sha256 || + manifest.base_profile_digest !== selection.base_profile_digest + ) + throw failure( + selection.directory, + "the manifest belongs to a different target or analysis profile", + ); + const path = appendCallerPath(selection.directory, manifest.source.file); + let actual: string; + try { + actual = await hashGhidraPackedProgram(path, resources, signal); + } catch (cause: unknown) { + throw ghidraFileFailure(cause, "resolve_analysis_profile", { path }); + } + if (actual !== manifest.source.sha256) + throw failure( + selection.directory, + "the packed Program checksum differs from its manifest", + ); + return manifest.source; +} + +/** Resolve storage and content identity once for the shared CLI/MCP analysis profile. */ +export async function resolveGhidraCheckpoint( + root: string, + targetSha256: string, + profile: AnalysisProfileCommitment, + resources: ArtifactResourceScope, + signal?: AbortSignal, +): Promise { + const key = createHash("sha256") + .update(JSON.stringify([targetSha256, profile.digest])) + .digest("hex"); + const selection = { + directory: appendCallerPath(root, key), + key, + target_sha256: targetSha256, + base_profile_digest: profile.digest, + }; + return { + ...selection, + state: checkpointState(await readSource(selection, resources, signal)), + }; +} + +/** One session owns publication, the socket lease, and any failed staging cleanup. */ +export class GhidraCheckpoint { + #lease: OwnedLease | undefined; + #source: CheckpointSource | null | undefined; + #state: GhidraCheckpointSelection["state"]; + readonly #pendingRemoval = new Set(); + readonly #cleanupFailures = new Set(); + #publishedInput: string | undefined; + constructor( + readonly selection: GhidraCheckpointSelection, + private readonly resources: ArtifactResourceScope, + ) { + this.#state = selection.state; + } + get hasResources(): boolean { + return this.#lease !== undefined || this.#pendingRemoval.size > 0; + } + get cleanupResources(): readonly string[] { + return [ + ...(this.#lease === undefined + ? [] + : [this.#lease.socketPath ?? this.selection.directory]), + ...this.#pendingRemoval, + ]; + } + get observation() { + return { + directory: this.selection.directory, + source: this.#source ?? null, + source_verified: this.#source !== undefined, + state: this.#state, + pending_cleanup: [...this.#pendingRemoval], + }; + } + + async acquire( + runId: string, + signal?: AbortSignal, + ): Promise { + assertArtifactReadActive(signal); + await mkdir(this.selection.directory, { recursive: true, mode: 0o700 }); + const state = await lstat(this.selection.directory); + if ( + !state.isDirectory() || + (state.mode & 0o077) !== 0 || + (process.getuid !== undefined && state.uid !== process.getuid()) + ) + throw failure( + this.selection.directory, + "the checkpoint directory must be a private directory owned by this user", + ); + const directory = await realpath(this.selection.directory); + const acquired = await acquireOwnedLease({ + identity: { kind: "ghidra-checkpoint-v1", directory }, + runId, + directory: join( + "/tmp", + `rea-ghidra-checkpoint-leases-${process.getuid?.() ?? "user"}`, + ), + resources: this.resources, + failure: (reason, cause) => + failure( + directory, + `lease ${reason.kind}${cause instanceof Error ? `: ${cause.message}` : ""}`, + cause, + ), + }); + if (!acquired.acquired) + throw failure( + directory, + `another REA session owns this checkpoint (run ${acquired.owner.runId}, process ${acquired.owner.processId}); close that session before retrying`, + ); + this.#lease = acquired.lease; + this.#source = undefined; + const current = await readSource(this.selection, this.resources, signal); + const generation = checkpointState(current); + const aligned = + generation.kind === "initial-analysis" + ? this.#state.kind === "initial-analysis" + : this.#state.kind === "retained" && + this.#state.sha256 === generation.sha256; + if (!aligned) + throw failure( + directory, + "the checkpoint changed after profile selection; reopen the target", + ); + assertArtifactReadActive(signal); + this.#source = current; + return current === null + ? undefined + : { path: join(directory, current.file), sha256: current.sha256 }; + } + + /** Publish a new immutable blob, then atomically replace its manifest. */ + async publish(path: string, signal?: AbortSignal): Promise { + if (this.#lease === undefined || this.#source === undefined) + throw failure( + this.selection.directory, + "publication has no admitted session lease", + ); + const previous = this.#source; + await this.retryCleanup(); + if (this.#publishedInput === path) return; + const file = `program-${randomUUID()}.gzf`; + const destination = appendCallerPath(this.selection.directory, file); + const temporary = appendCallerPath( + this.selection.directory, + `.current-${randomUUID()}.json`, + ); + this.#pendingRemoval.add(destination); + this.#pendingRemoval.add(temporary); + let primary: unknown; + let failed = false; + try { + assertArtifactReadActive(signal); + await copyFile(path, destination, constants.COPYFILE_EXCL); + const sha256 = await hashGhidraPackedProgram( + destination, + this.resources, + signal, + ); + const source: CheckpointSource = { + file, + sha256, + kind: previous === null ? "initial-analysis" : "retained", + }; + for (const [output, bytes] of [ + [destination, undefined], + [ + temporary, + JSON.stringify({ + version: 2, + target_sha256: this.selection.target_sha256, + base_profile_digest: this.selection.base_profile_digest, + source, + }), + ], + ] as const) { + const handle = await open( + output, + bytes === undefined ? "r+" : "wx", + 0o600, + ); + await withArtifactFileHandle( + output, + handle, + this.resources, + signal, + async (handle) => { + if (bytes !== undefined) await handle.writeFile(bytes); + await handle.sync(); + }, + ); + } + assertArtifactReadActive(signal); + await rename( + temporary, + appendCallerPath(this.selection.directory, "current.json"), + ); + this.#source = source; + this.#state = checkpointState(source); + this.#publishedInput = path; + this.#pendingRemoval.delete(destination); + this.#pendingRemoval.delete(temporary); + if (previous !== null) + this.#pendingRemoval.add( + appendCallerPath(this.selection.directory, previous.file), + ); + } catch (cause: unknown) { + primary = cause; + failed = true; + } + try { + await this.retryCleanup(); + } catch (cause: unknown) { + if (failed) + throw analysisErrorWithCleanupFailure( + ghidraFileFailure(primary, "checkpoint_program", { + path: this.selection.directory, + }), + ghidraFileFailure(cause, "checkpoint_program", { + path: this.selection.directory, + }), + ); + throw cause; + } + if (failed) + throw ghidraFileFailure(primary, "checkpoint_program", { + path: this.selection.directory, + }); + assertArtifactReadActive(signal); + } + + async retryCleanup(): Promise { + for (const path of this.#pendingRemoval) { + try { + await rm(path, { force: true }); + this.#pendingRemoval.delete(path); + } catch (cause: unknown) { + const error = new ProviderCleanupError( + "ghidra", + [...this.#pendingRemoval], + { + reason: `Checkpoint file cleanup failed: ${cause instanceof Error ? cause.message : String(cause)}`, + checkpoint: this.observation, + }, + { cause }, + ); + this.#cleanupFailures.add(error); + throw error; + } + } + for (const failure of this.#cleanupFailures) failure.resolveCleanup(); + this.#cleanupFailures.clear(); + } + async close(): Promise { + await this.retryCleanup(); + await this.#lease?.release(); + this.#lease = undefined; + } +} diff --git a/src/ghidra/GhidraCheckpointRecovery.test.ts b/src/ghidra/GhidraCheckpointRecovery.test.ts new file mode 100644 index 0000000000..d38d42f7a1 --- /dev/null +++ b/src/ghidra/GhidraCheckpointRecovery.test.ts @@ -0,0 +1,39 @@ +import { expect, it } from "vitest"; +import { + ghidraDecompileTimeoutRecovery, + ghidraStartupTimeoutGuidance, +} from "./GhidraProviderClient.js"; + +it("names the saved Program when a finished invocation must restart after a decompile deadline", () => { + const recovery = ghidraDecompileTimeoutRecovery(10, "owned-checkpoint"); + expect(recovery.remediationAction).toContain( + "restores the last saved Program from REA_GHIDRA_CHECKPOINT_DIR", + ); + expect(recovery.remediationAction).toContain( + "if initial analysis never completed", + ); + expect(recovery.remediationAction).not.toContain( + "repeats import and analysis", + ); + expect(recovery.continuingRemediationAction).toContain( + "update_analysis_settings", + ); +}); + +it("keeps initial analysis and later restore explicit in checkpoint startup recovery", () => { + const guidance = ghidraStartupTimeoutGuidance(250, "owned-checkpoint"); + expect(guidance.userMessage).toContain( + "checkpoint restore or initial analysis and saving", + ); + for (const action of [ + guidance.remediationAction, + guidance.continuingCallerGuidance.remediationAction, + ]) { + expect(action).toContain("Keep REA_GHIDRA_CHECKPOINT_DIR"); + expect(action).toContain( + "initial analysis and its first checkpoint must complete once", + ); + expect(action).not.toContain("repeats import from scratch"); + expect(action).not.toContain("REA_GHIDRA_PROJECT_PATH"); + } +}); diff --git a/src/ghidra/GhidraClient.ts b/src/ghidra/GhidraClient.ts index 6b7c4407c5..7e32230cae 100644 --- a/src/ghidra/GhidraClient.ts +++ b/src/ghidra/GhidraClient.ts @@ -4,6 +4,9 @@ import { type FileSnapshotPartialObservation, } from "../domain/fileSnapshotObservation.js"; import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { z } from "zod"; import type { Stats } from "node:fs"; import type { FileHandle } from "node:fs/promises"; import type { Socket } from "node:net"; @@ -110,6 +113,10 @@ import { import { GhidraWire } from "./GhidraClientWire.js"; import { completeGhidraStartupHandshake } from "./GhidraClientStartup.js"; import { ghidraSessionRoot } from "./GhidraSessionRoot.js"; +import { + GhidraCheckpoint, + type GhidraPackedProgram, +} from "./GhidraCheckpoint.js"; const SHUTDOWN_TIMEOUT_MS = 10_000; const SESSION_ROOT = ghidraSessionRoot(); @@ -142,6 +149,11 @@ export class GhidraClient { #snapshotPath: string | undefined; #memoryCgroupBaseline: LinuxCgroupMemoryState | undefined; readonly #fileResources = new ArtifactResourceScope(); + readonly #checkpoint: GhidraCheckpoint | undefined; + #checkpointStaging: + | { readonly path: string; readonly disposition: "publish" | "discard" } + | undefined; + #checkpointRestore: GhidraPackedProgram | undefined; readonly #cleanupFailures = new Set(); #targetAdmission: JsonValue | undefined; #token: string | undefined; @@ -187,6 +199,10 @@ export class GhidraClient { this.#failAll(this.#failure("process", "Ghidra bridge socket closed")); }; constructor(options: GhidraClientOptions) { + this.#checkpoint = + options.checkpoint === undefined + ? undefined + : new GhidraCheckpoint(options.checkpoint, this.#fileResources); this.#options = { ...options, startupTimeoutMs: @@ -723,6 +739,11 @@ export class GhidraClient { } } + /** Actual restore source, including a restart within the same REA session. */ + checkpointRestore(): GhidraPackedProgram | undefined { + return this.#checkpointRestore; + } + /** Read completion from the current attempt; no independent readiness flag. */ #startupSucceeded(): boolean { const startup = this.#startupAttempt?.settlement; @@ -751,6 +772,7 @@ export class GhidraClient { ? (cause.cleanup?.resources ?? []) : []), ...this.#fileResources.cleanupResources, + ...(this.#checkpoint?.cleanupResources ?? []), ...[this.#runtimeRoot, this.#socketRoot].flatMap((root) => root === undefined ? [] : [root.path], ), @@ -827,6 +849,7 @@ export class GhidraClient { return [ ...(client.#process === undefined ? [] : ["ghidra-process"]), ...client.#fileResources.cleanupResources, + ...(client.#checkpoint?.cleanupResources ?? []), ...[client.#runtimeRoot, client.#socketRoot].flatMap((root) => root === undefined ? [] : [root.path], ), @@ -906,13 +929,35 @@ export class GhidraClient { this.#socket !== undefined || this.#runtimeRoot !== undefined || this.#socketRoot !== undefined || - this.#fileResources.hasPending + this.#fileResources.hasPending || + this.#checkpoint?.hasResources === true ); } async #startWithin( deadline: ProviderStartupDeadline, ): Promise { + let packedProgram: GhidraPackedProgram | undefined; + try { + packedProgram = await this.#checkpoint?.acquire( + this.#options.runId ?? randomUUID(), + deadline.signal, + ); + this.#checkpointRestore = packedProgram; + } catch (cause: unknown) { + return this.#startupFailure( + deadline.signal.aborted + ? this.#interruptionFailure(deadline, cause) + : this.#failure( + "checkpoint", + cause instanceof Error + ? cause.message + : "Ghidra checkpoint admission failed", + cause, + ), + deadline, + ); + } try { this.#runtimeRoot = await PrivateRuntimeRoot.create({ parent: SESSION_ROOT, @@ -920,8 +965,9 @@ export class GhidraClient { platform: this.#options.platform, }); } catch (cause: unknown) { - return err( + return this.#startupFailure( this.#failure("start", "Ghidra runtime root creation failed", cause), + deadline, ); } if (deadline.signal.aborted) return this.#startupInterrupted(deadline); @@ -990,6 +1036,7 @@ export class GhidraClient { targetSha256: this.#options.targetSha256, providerVersion: this.#options.providerVersion, profileDigest: this.#options.profileDigest, + ...(packedProgram === undefined ? {} : { packedProgram }), }, { signal: deadline.signal, resourceScope: this.#fileResources }, ) @@ -1048,11 +1095,107 @@ export class GhidraClient { ); } if (deadline.signal.aborted) return this.#startupInterrupted(deadline); + if (this.#checkpoint !== undefined && packedProgram === undefined) { + try { + await this.#saveCheckpoint(true, deadline.signal); + } catch (cause: unknown) { + return this.#startupFailure( + deadline.signal.aborted + ? this.#interruptionFailure(deadline, cause) + : this.#failure( + "checkpoint", + "Initial Ghidra checkpoint could not be saved; check REA_GHIDRA_CHECKPOINT_DIR and reopen the target", + cause, + ), + deadline, + ); + } + } this.#startupOutputCheckpoint = undefined; this.#requestQueue.reopen(); return completed; } + /** Save once at startup and after changes, retaining a failed publication for retry. */ + async #saveCheckpoint(force: boolean, signal?: AbortSignal): Promise { + const owner = this.#checkpoint; + const runtime = this.#runtimeRoot; + if (owner === undefined || runtime === undefined) return; + await this.#finishCheckpointStaging(signal); + const path = join(runtime.path, `checkpoint-${randomUUID()}.gzf`); + const saved = await this.#wire.request( + "checkpoint_program", + { path, force }, + signal === undefined ? {} : { signal }, + ); + if (!saved.ok) { + if (saved.error.kind === "remote") + return this.#discardCheckpoint(path, saved.error, signal); + throw saved.error; + } + const parsed = z + .discriminatedUnion("saved", [ + z.object({ saved: z.literal(false) }).strict(), + z.object({ saved: z.literal(true), path: z.literal(path) }).strict(), + ]) + .safeParse(saved.value); + if (!parsed.success) + return this.#discardCheckpoint( + path, + this.#failure( + "protocol", + "Ghidra checkpoint acknowledgement is invalid", + parsed.error, + ), + signal, + ); + if (!parsed.data.saved) return; + this.#checkpointStaging = { path, disposition: "publish" }; + await this.#finishCheckpointStaging(signal); + } + + /** An acknowledged failed export is owned but is not a valid checkpoint. */ + async #discardCheckpoint( + path: string, + failure: GhidraSessionError, + signal?: AbortSignal, + ): Promise { + this.#checkpointStaging = { path, disposition: "discard" }; + try { + await this.#finishCheckpointStaging(signal); + } catch (cause: unknown) { + const cleanup = this.#retainCleanupFailure( + new ProviderCleanupError( + "ghidra", + [path], + { + reason: + "A failed Ghidra checkpoint export left a partial file; close the target to retry its cleanup before another save.", + diagnostics: this.#diagnostics(), + }, + { cause }, + ), + ); + throw failure.withCleanupFailure(cleanup); + } + throw failure; + } + + async #finishCheckpointStaging(signal?: AbortSignal): Promise { + const staged = this.#checkpointStaging; + if (staged === undefined) return; + if (staged.disposition === "publish") { + if (this.#checkpoint === undefined) + throw this.#failure( + "protocol", + "Checkpoint publication owner is missing", + ); + await this.#checkpoint.publish(staged.path, signal); + } + await rm(staged.path, { force: true }); + this.#checkpointStaging = undefined; + } + async #connectAndHandshake( endpoint: GhidraEndpoint, deadline: ProviderStartupDeadline, @@ -1153,7 +1296,11 @@ export class GhidraClient { await starting?.promise.catch(() => undefined); await this.#cleanup(forceStop); } finally { - if (this.#startupAttempt === starting) this.#startupAttempt = undefined; + if ( + this.#startupAttempt === starting && + (forceStop || this.#socket === undefined || this.#socket.destroyed) + ) + this.#startupAttempt = undefined; this.#closePromise = undefined; } } @@ -1165,6 +1312,20 @@ export class GhidraClient { this.#requestQueue.failQueued( this.#failure("process", "Ghidra session closed"), ); + if ( + !forceStop && + this.#startupSucceeded() && + this.#checkpoint !== undefined && + this.#socket !== undefined && + !this.#socket.destroyed + ) { + try { + await this.#saveCheckpoint(false); + } catch (cause: unknown) { + this.#requestQueue.reopen(); + throw cause; + } + } const socket = this.#socket; if (!forceStop && socket !== undefined && !socket.destroyed) { const shutdown = await this.#wire @@ -1219,6 +1380,7 @@ export class GhidraClient { this.#lastDiagnostics = this.#diagnostics(); this.#process = undefined; this.#launch = undefined; + await this.#checkpoint?.close(); await this.#fileResources.retryCleanup(); try { await this.#removeRuntimeRoots(); @@ -1227,6 +1389,8 @@ export class GhidraClient { else throw cause; } this.#snapshotPath = undefined; + this.#checkpointStaging = undefined; + this.#checkpointRestore = undefined; this.#endpointPath = undefined; this.#token = undefined; this.#runId = undefined; @@ -1401,32 +1565,39 @@ export class GhidraClient { scoped === undefined ? undefined : ghidraFailureOutput(scoped, this.#token, this.#outputDrainIncomplete); - return createGhidraDiagnostics({ - scope, - targetPath: this.#options.targetPath, - targetSha256: this.#options.targetSha256, - ...(this.#targetAdmission === undefined + return { + ...createGhidraDiagnostics({ + scope, + targetPath: this.#options.targetPath, + targetSha256: this.#options.targetSha256, + ...(this.#targetAdmission === undefined + ? {} + : { targetAdmission: this.#targetAdmission }), + transport: this.#options.transport, + providerVersion: this.#options.providerVersion, + profileDigest: this.#options.profileDigest, + ...(this.#runId === undefined ? {} : { runId: this.#runId }), + ...(this.#runtimeRoot === undefined + ? {} + : { runtimeRoot: this.#runtimeRoot.path }), + ...(this.#socketRoot === undefined + ? {} + : { socketRoot: this.#socketRoot.path }), + ...(this.#endpointPath === undefined + ? {} + : { endpointPath: this.#endpointPath }), + ...(this.#launch === undefined ? {} : { launch: this.#launch }), + ...(snapshot === undefined ? {} : { snapshot }), + ...(this.#token === undefined ? {} : { token: this.#token }), + previous: this.#lastDiagnostics, + ...(failureOutput === undefined + ? {} + : { failure_output: failureOutput }), + }), + ...(this.#checkpoint === undefined ? {} - : { targetAdmission: this.#targetAdmission }), - transport: this.#options.transport, - providerVersion: this.#options.providerVersion, - profileDigest: this.#options.profileDigest, - ...(this.#runId === undefined ? {} : { runId: this.#runId }), - ...(this.#runtimeRoot === undefined - ? {} - : { runtimeRoot: this.#runtimeRoot.path }), - ...(this.#socketRoot === undefined - ? {} - : { socketRoot: this.#socketRoot.path }), - ...(this.#endpointPath === undefined - ? {} - : { endpointPath: this.#endpointPath }), - ...(this.#launch === undefined ? {} : { launch: this.#launch }), - ...(snapshot === undefined ? {} : { snapshot }), - ...(this.#token === undefined ? {} : { token: this.#token }), - previous: this.#lastDiagnostics, - ...(failureOutput === undefined ? {} : { failure_output: failureOutput }), - }); + : { checkpoint: this.#checkpoint.observation }), + }; } #refreshProcessFailure( diff --git a/src/ghidra/GhidraClientTypes.ts b/src/ghidra/GhidraClientTypes.ts index 7ac2bdb27e..79b814b117 100644 --- a/src/ghidra/GhidraClientTypes.ts +++ b/src/ghidra/GhidraClientTypes.ts @@ -1,4 +1,5 @@ import type { Logger } from "pino"; +import type { GhidraCheckpointSelection } from "./GhidraCheckpoint.js"; import type { BridgeMaxRequestBytes } from "../config/bridgeMaxRequestBytes.js"; import type { Result } from "../domain/result.js"; import type { LinuxCgroupMemoryState } from "../process/LinuxCgroupMemory.js"; @@ -40,6 +41,7 @@ export interface GhidraClientOptions { readonly targetPath: string; readonly targetSha256: string; readonly existingProject?: boolean; + readonly checkpoint?: GhidraCheckpointSelection; /** Installation host shared by runtime allocation and snapshot admission. */ readonly platform?: NodeJS.Platform; readonly transport?: GhidraTransportKind; diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index f916b276e1..2a15f6c546 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -1,5 +1,7 @@ import { GhidraAdmissionError } from "./GhidraAdmissionError.js"; import { copyExistingGhidraProject } from "./GhidraProjectSnapshot.js"; +import { hashGhidraPackedProgram } from "./GhidraCheckpoint.js"; +import { constants as fsConstants } from "node:fs"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { GHIDRA_BRIDGE_SOURCES } from "./GhidraBridgeSources.js"; import { @@ -9,7 +11,7 @@ import { import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js"; import type { GhidraRiscvCallingConvention } from "./GhidraRiscvProfile.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; -import { mkdir, readFile, realpath } from "node:fs/promises"; +import { copyFile, mkdir, readFile, realpath } from "node:fs/promises"; import { basename, dirname, join, posix, win32 } from "node:path"; import { writeGhidraRuntimeFile } from "./GhidraRuntimeFile.js"; @@ -69,6 +71,7 @@ export interface GhidraLaunchSession { readonly targetSha256: string; readonly providerVersion: string; readonly profileDigest: string; + readonly packedProgram?: import("./GhidraCheckpoint.js").GhidraPackedProgram; } /** Process plus private log coordinates returned by a Ghidra launcher. */ @@ -335,6 +338,29 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { platform, this.options.configurationDirectoryName, ); + let packedProgramPath: string | undefined; + if (session.packedProgram !== undefined) { + if (options.resourceScope === undefined) + throw new GhidraLaunchError( + "Checkpoint restore requires its owning resource scope.", + ); + packedProgramPath = join(session.runtimeRoot, "restored-program.gzf"); + await copyFile( + session.packedProgram.path, + packedProgramPath, + fsConstants.COPYFILE_EXCL, + ); + if ( + (await hashGhidraPackedProgram( + packedProgramPath, + options.resourceScope, + options.signal, + )) !== session.packedProgram.sha256 + ) + throw new GhidraLaunchError( + "Ghidra checkpoint changed before its private import; reopen the target.", + ); + } // Copy-first: open the caller's project only through the owned copy so // Ghidra never touches the original project files. if (existingProject !== undefined) @@ -426,6 +452,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { projectRoot: paths.projectRoot, targetPath: session.targetPath, ...(existingProject === undefined ? {} : { existingProject }), + ...(packedProgramPath === undefined ? {} : { packedProgramPath }), bridgeScriptPath: this.options.bridgeScriptPath, descriptorPath: paths.descriptorPath, ghidraLogPath: paths.ghidraLogPath, @@ -764,6 +791,7 @@ export interface GhidraHeadlessArgumentOptions { readonly platform?: NodeJS.Platform; readonly projectRoot: string; readonly targetPath: string; + readonly packedProgramPath?: string; /** Open this pre-imported program with -process instead of importing. */ readonly existingProject?: Pick< GhidraExistingProject, @@ -916,7 +944,7 @@ export const ghidraHeadlessArguments = ( ...(existing === undefined ? [ "-import", - options.targetPath, + options.packedProgramPath ?? options.targetPath, ...(gamecubeDol !== undefined ? [ // One BinaryLoader block at file offset 0; the pre-script rebuilds the DOL section map. @@ -984,7 +1012,11 @@ export const ghidraHeadlessArguments = ( // An existing project opens with -noanalysis: its auto-analysis was paid // when the project was imported, outside this session's startup deadline. ...(existing === undefined - ? ["-readOnly", "-deleteProject"] + ? [ + ...(options.packedProgramPath === undefined ? [] : ["-noanalysis"]), + "-readOnly", + "-deleteProject", + ] : ["-noanalysis", "-readOnly"]), "-log", options.ghidraLogPath, diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index dca0ea3d3a..df3f3a129c 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -3,6 +3,7 @@ import { EXISTING_PROJECT_WINDOWS_REMEDIATION, EXISTING_PROJECT_WINDOWS_UNAVAILABLE, existingProjectCapabilities, + checkpointCapabilities, ghidraEmptyIncomingReferencesLimitation, ghidraEmptyDossierIncomingLimitation, ghidraEmptyExternalOutgoingLimitation, @@ -109,6 +110,48 @@ import { createEvidence } from "../domain/evidence.js"; import { createEvidenceBundle } from "../domain/evidenceBundle.js"; const INSTALL = "/opt/ghidra_12.1.4_PUBLIC"; +it.skipIf(process.platform === "win32")( + "health describes checkpoint recovery without ephemeral reimport advice", + async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-checkpoint-health-")); + const ghidra = provider( + installationHost(), + () => ({ + start: async () => ok(sessionInfo()), + callTool: async () => ok([]), + close: async () => ok(null), + checkpointRestore: () => ({ + path: join(directory, "verified.gzf"), + sha256: "a".repeat(64), + }), + }), + { REA_GHIDRA_CHECKPOINT_DIR: directory }, + ); + const client = await createElfClient(ghidra); + try { + const health = await client.execute("health", {}); + if (!health.ok) throw health.error; + expect(health.value.limitations.join("\n")).toContain( + "last successfully saved owned Program", + ); + expect(health.value.limitations.join("\n")).toContain( + "auto-analysis was not rerun", + ); + expect(health.value.limitations.join("\n")).not.toMatch( + /imports the original artifact again|discards session annotations/, + ); + for (const capability of checkpointCapabilities()) { + expect(capability.limitations.join("\n")).not.toMatch( + /discarded on close|imports the original artifact again/, + ); + } + } finally { + await client.close(); + await ghidra.closeOwnedResources(); + await rm(directory, { recursive: true, force: true }); + } + }, +); afterEach(() => vi.restoreAllMocks()); const installationHost = ( platform: NodeJS.Platform = "linux", @@ -1840,7 +1883,10 @@ describe("Ghidra decompile deadline for a finished invocation", () => { seconds, existingProject, ); - const recovery = ghidraDecompileTimeoutRecovery(seconds, existingProject); + const recovery = ghidraDecompileTimeoutRecovery( + seconds, + existingProject ? "existing-project" : "fresh-import", + ); for (const projected of [continuing, invocation]) expect(projected).toMatchObject({ code: "provider_timeout", @@ -4284,7 +4330,10 @@ describe("Ghidra startup deadline for a finished invocation", () => { "invocation", ); const continuing = await ghidraTimedOut("start", elapsed, environment); - const guidance = ghidraStartupTimeoutGuidance(250, existingProject); + const guidance = ghidraStartupTimeoutGuidance( + 250, + existingProject ? "existing-project" : "fresh-import", + ); expect(invocation).toMatchObject({ code: "provider_timeout", remediation: { action: guidance.remediationAction }, @@ -4332,7 +4381,7 @@ describe("Ghidra startup deadline for a finished invocation", () => { })); const result = await (await createElfClient(ghidra)).execute("health", {}); if (result.ok) throw new Error("Expected the composed startup failure"); - const guidance = ghidraStartupTimeoutGuidance(250, false); + const guidance = ghidraStartupTimeoutGuidance(250, "fresh-import"); const child = (lifetime: AnalysisCallerLifetime) => projectAnalysisError(result.error, lifetime).details?.diagnostics; expect(projectAnalysisError(result.error, "invocation")).toMatchObject({ diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index 7716de4eee..f5934b2fd5 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -1,3 +1,13 @@ +import { resolveGhidraCheckpoint } from "./GhidraCheckpoint.js"; +import { + checkpointCapabilities, + CAPABILITIES, + existingProjectCapabilities, + EXISTING_PROJECT_WINDOWS_REMEDIATION, + EXISTING_PROJECT_WINDOWS_UNAVAILABLE, + windowsP0Capabilities, + WINDOWS_P0_NATIVE_PE_ARCHITECTURES, +} from "./GhidraProviderCapabilities.js"; import { existingGhidraAnalysisProfile, ghidraPinnedLanguage, @@ -70,14 +80,6 @@ import { isGhidraPspTarget, } from "./GhidraPspProfile.js"; import { resolveGhidraExtensions } from "./extensions/GhidraExtensions.js"; -import { - CAPABILITIES, - existingProjectCapabilities, - EXISTING_PROJECT_WINDOWS_REMEDIATION, - EXISTING_PROJECT_WINDOWS_UNAVAILABLE, - windowsP0Capabilities, - WINDOWS_P0_NATIVE_PE_ARCHITECTURES, -} from "./GhidraProviderCapabilities.js"; import { GHIDRA_PROVIDER_IDENTITY } from "./GhidraProviderMetadata.js"; import { createGhidraProviderClient, @@ -163,7 +165,9 @@ export class GhidraProvider implements AnalysisProviderCandidate { ? existingProjectCapabilities(platform) : platform === "win32" ? windowsP0Capabilities() - : CAPABILITIES; + : this.config.ghidraCheckpointDirectory === undefined + ? CAPABILITIES + : checkpointCapabilities(); } async inspectAvailability( @@ -206,7 +210,9 @@ export class GhidraProvider implements AnalysisProviderCandidate { this.config.ghidraExistingProject !== undefined ? existingProjectCapabilities(installation.platform) : readiness === undefined - ? CAPABILITIES + ? this.config.ghidraCheckpointDirectory === undefined + ? CAPABILITIES + : checkpointCapabilities() : windowsP0Capabilities(readiness), }; } @@ -501,19 +507,75 @@ export class GhidraProvider implements AnalysisProviderCandidate { options?.signal, ); if (!seeds.ok) return seeds; - if (extensions.value.length === 0 && seeds.value === undefined) + if ( + extensions.value.length === 0 && + seeds.value === undefined && + this.config.ghidraCheckpointDirectory === undefined + ) return resolved; + const baseProfile = createAnalysisProfile(resolved.value.profile.provider, { + ...resolved.value.profile.parameters, + ...(extensions.value.length === 0 + ? {} + : { analysis_extensions: jsonValueSchema.parse(extensions.value) }), + ...(seeds.value === undefined + ? {} + : { analysis_seeds: jsonValueSchema.parse(seeds.value) }), + }); + const checkpointDirectory = this.config.ghidraCheckpointDirectory; + if ( + checkpointDirectory !== undefined && + (installation.platform === "win32" || + target.kind !== "executable" || + !( + target.format === "elf" || + target.format === "pe" || + isMachOFormat(target.format) + ) || + ghidraPinnedLanguage(target) !== undefined || + extensions.value.length !== 0 || + baseProfile.parameters.loader !== "auto-from-header" || + baseProfile.parameters.psp_extension !== undefined || + seeds.value !== undefined || + this.config.ghidraLanguageOverride !== undefined || + this.config.ghidraDisabledAnalyzers !== undefined) + ) + return err( + new ConfigurationError( + "Ghidra checkpoints currently support stock ELF/PE/Mach-O imports on Linux/macOS", + { + settings: [ + { + setting: "REA_GHIDRA_CHECKPOINT_DIR", + constraint: + "Select a stock native import on Linux/macOS, or unset this setting for the existing ephemeral workflow.", + }, + ], + }, + ), + ); + const checkpoint = + checkpointDirectory === undefined + ? undefined + : await resolveGhidraCheckpoint( + checkpointDirectory, + target.sha256, + baseProfile, + this.#inspectionResources, + options?.signal, + ); return ok({ ...resolved.value, - profile: createAnalysisProfile(resolved.value.profile.provider, { - ...resolved.value.profile.parameters, - ...(extensions.value.length === 0 - ? {} - : { analysis_extensions: jsonValueSchema.parse(extensions.value) }), - ...(seeds.value === undefined - ? {} - : { analysis_seeds: jsonValueSchema.parse(seeds.value) }), - }), + ...(checkpoint === undefined + ? {} + : { refreshAfterRetirement: true as const }), + profile: + checkpoint === undefined + ? baseProfile + : createAnalysisProfile(baseProfile.provider, { + ...baseProfile.parameters, + owned_checkpoint: jsonValueSchema.parse(checkpoint), + }), }); } diff --git a/src/ghidra/GhidraProviderCapabilities.ts b/src/ghidra/GhidraProviderCapabilities.ts index 19d5c23bfc..ba89597ab4 100644 --- a/src/ghidra/GhidraProviderCapabilities.ts +++ b/src/ghidra/GhidraProviderCapabilities.ts @@ -33,17 +33,28 @@ export const windowsP0Limitations = Object.freeze([ "Windows sessions require the matching packaged Windows x64 native addon, local NTFS targets and runtimes, and Windows 10 or later. Native handles enforce path admission, private DACLs, and Job Object ownership automatically.", ]); +export type GhidraSessionSource = + | "fresh-import" + | "existing-project" + | "owned-checkpoint"; + /** Existing databases have their own unknown analysis history; reopening does not complete it. */ export const ghidraHealthLimitations = ( - existingProject = false, + source: GhidraSessionSource = "fresh-import", ): readonly string[] => - existingProject + source === "existing-project" ? [ "This session opens a caller-selected existing Ghidra project through a private copy, without import, auto-analysis or repair scripts. Original analysis completeness and settings are unknown.", "The original project is never opened by Ghidra or modified. The private copy is read-only to bridge operations and deleted on close; original executable bytes are never written.", "The analysis profile records the selected project content fingerprint and program name. The imported program SHA-256 must match the admitted target; the project may contain caller annotations or earlier edits, which are not fresh REA discoveries.", ] - : healthLimitations; + : source === "owned-checkpoint" + ? [ + "Opt-in Ghidra checkpoints retain the last successfully saved owned Program, including its names, comments and types. Restoring a checkpoint skips auto-analysis; its analysis is retained state, not a fresh discovery.", + "REA saves initial completed analysis and saves metadata changes on normal close. A failed save retains the live session for retry. Cancellation or process failure can lose changes after the last successful save; unfinished initial auto-analysis is not resumable.", + "The live project is a temporary working copy. Checkpoints persist in REA_GHIDRA_CHECKPOINT_DIR; source executables and caller-selected source projects are not modified.", + ] + : healthLimitations; const stringInventoryLimitation = "Only Ghidra-defined string Data is observed; packed or undefined string bytes may be absent. Completeness applies to that inventory, and an empty search does not establish that text is absent from the artifact. If an address is known, use read_bytes and xrefs to inspect bytes and references."; @@ -82,10 +93,10 @@ export const ghidraEmptyInstructionOutgoingLimitation = /** Limitation text for one admitted operation, including the common base. */ export const limitationsFor = ( operation: string, - existingProject = false, + source: GhidraSessionSource = "fresh-import", ): readonly string[] => { const common = [ - ...ghidraHealthLimitations(existingProject), + ...ghidraHealthLimitations(source), "Default-space addresses use lowercase 0x-prefixed hexadecimal; other address spaces use :0x.", ]; switch (operation) { @@ -101,7 +112,9 @@ export const limitationsFor = ( ...common, "Names use Ghidra USER_DEFINED source. Name writes accept a leaf name or a fully qualified name within the existing namespace; readback uses the fully qualified name and can be reused without adding namespace prefixes. Edits retain the current namespace; other namespace-like text remains literal leaf-name text. Regular comments map to PRE and inline comments to EOL at the exact function entry. Changes commit together after readback and refreshed analysis; failure rolls them all back.", "Annotation text rejects NUL and unpaired Unicode surrogates before mutation, with the field and UTF-16 index in the error. Supported Unicode and line endings are preserved.", - "Metadata edits invalidate immutable analysis snapshots and are discarded on close. CLI returns the updated dossier before session cleanup; this is not a saved Ghidra project.", + source === "owned-checkpoint" + ? "Metadata edits invalidate immutable analysis snapshots. Normal close saves edits to the owned checkpoint; cancellation or process failure can lose edits after the last successful save. CLI returns the updated dossier before cleanup." + : "Metadata edits invalidate immutable analysis snapshots and are discarded on close. CLI returns the updated dossier before session cleanup; this is not a saved Ghidra project.", ]; case "set_address_name": case "set_addresses_names": @@ -111,7 +124,9 @@ export const limitationsFor = ( operation === "set_address_name" ? "A name held by another address in the destination namespace is not moved; a false result reports its reason in raw_result.failures." : "set_addresses_names decides every address from the names held before the batch: addresses requesting one destination name, or a name another selected address holds, are rejected rather than ordered. Others apply in their own transactions. raw_result.failures gives each failure's reason; raw_result.conflicts lists each contested name once with its requesting and holding addresses.", - "Metadata edits invalidate immutable analysis snapshots and are discarded on close; this is not a saved Ghidra project.", + source === "owned-checkpoint" + ? "Metadata edits invalidate immutable analysis snapshots. Normal close saves edits to the owned checkpoint; cancellation or process failure can lose edits after the last successful save." + : "Metadata edits invalidate immutable analysis snapshots and are discarded on close; this is not a saved Ghidra project.", ]; case "inspect_native_load_image": return [...common, ghidraLoadImageVerificationNote()]; @@ -241,6 +256,13 @@ export const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze( }), ); +/** Preserve operation-specific caveats while describing the opted-in lifecycle. */ +export const checkpointCapabilities = (): readonly CapabilityDescriptor[] => + CAPABILITIES.map((capability) => ({ + ...capability, + limitations: limitationsFor(capability.operation, "owned-checkpoint"), + })); + /** The selected database mode is not part of Windows P0 authority. */ export const EXISTING_PROJECT_WINDOWS_UNAVAILABLE = "An existing Ghidra project cannot be opened on the Windows launch path."; @@ -258,7 +280,7 @@ export const existingProjectCapabilities = ( capability.operation === "binary_overview" ? "read_binary_inventory_summary" : capability.operation, - true, + "existing-project", ); if (platform === "win32") return { diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 9e40866423..e21c901510 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -1,10 +1,12 @@ import { filterGhidraLiteralCandidates } from "./GhidraLiteralSearch.js"; +import { ghidraCheckpointSelectionSchema } from "./GhidraCheckpoint.js"; import { ghidraProjectHistoryEvidence } from "./GhidraProjectHistory.js"; import type { Evidence, EvidenceLocation } from "../domain/evidence.js"; import { ghidraProjectCommitmentSchema } from "./GhidraProjectSnapshot.js"; import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js"; import { MAX_GHIDRA_DECOMPILE_TIMEOUT_SECONDS } from "../domain/analysisResourceLimits.js"; import { z } from "zod"; +import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { artifactFilePrimaryFacts } from "../artifacts/ArtifactFileOperation.js"; import { fileURLToPath } from "node:url"; @@ -62,7 +64,7 @@ import { AnalysisTimeoutError, resourceConstraintRecovery, } from "../domain/analysisErrorCore.js"; -import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; import type { RelationshipCoverage } from "../domain/relationshipCoverage.js"; import { providerRetryAction, @@ -109,6 +111,7 @@ import type { NativeAotPeResult } from "../domain/native/nativeAotPe.js"; import { GhidraNativeAotSnapshotOwner } from "./GhidraNativeAotSnapshotOwner.js"; import { ghidraHealthLimitations, + type GhidraSessionSource, ghidraEmptyXrefsLimitation, ghidraEmptyIncomingReferencesLimitation, ghidraEmptyDossierIncomingLimitation, @@ -147,6 +150,7 @@ export type GhidraProviderClientFactory = ( Pick< GhidraClient, | "callToolIfReady" + | "checkpointRestore" | "operationHealthSnapshot" | "runtimeLineage" | "readTargetSnapshot" @@ -288,6 +292,22 @@ export const createGhidraProviderClient = (input: { const prerequisites = ghidraClientPrerequisites(profile, installation); if (!prerequisites.ok) return err(prerequisites.error); const committedProfile = prerequisites.value.profile; + const checkpoint = ghidraCheckpointSelectionSchema + .optional() + .safeParse(committedProfile.parameters.owned_checkpoint); + if ( + !checkpoint.success || + (config.ghidraCheckpointDirectory !== undefined) !== + (checkpoint.data !== undefined) + ) + return err( + new ProviderAdapterError("ghidra", "open_binary", { + diagnostics: { + reason: + "Resolve REA_GHIDRA_CHECKPOINT_DIR into the analysis profile before opening the session.", + }, + }), + ); const psp = isGhidraPspTarget(target); const pspExtension = psp ? ghidraPspExtensionSchema.safeParse( @@ -378,6 +398,12 @@ export const createGhidraProviderClient = (input: { const projection: GhidraProjection = { limits: config, programName: project?.program_name, + source: + project !== undefined + ? "existing-project" + : checkpoint.data !== undefined + ? "owned-checkpoint" + : "fresh-import", }; const seedProfile = ghidraSeedCommitmentSchema .optional() @@ -556,6 +582,7 @@ export const createGhidraProviderClient = (input: { }), ); const client = clientFactory({ + ...(checkpoint.data === undefined ? {} : { checkpoint: checkpoint.data }), startupTimeoutMs: config.ghidraStartupTimeoutMs, readStartupTimeoutMs: () => config.ghidraStartupTimeoutMs, bridgeMaxRequestBytes: config.bridgeMaxRequestBytes, @@ -789,18 +816,26 @@ export const createGhidraProviderClient = (input: { const releaseLimitation = unverifiedGhidraBuildLimitation( prerequisites.value.providerVersion, ); - const sessionLimitations = (): readonly string[] => [ - ...providerLimitations, - ...targetLimitations, - ...(psp ? GHIDRA_PSP_LIMITATIONS : []), - ...softFloatLimitations, - ...riscvCallingConventionLimitations, - ...ghidraExtensionLimitations(extensions), - ...seedLimitations, - ...disabledAnalyzerLimitations, - ...pdbLimitations, - ...(releaseLimitation === undefined ? [] : [releaseLimitation]), - ]; + const sessionLimitations = (): readonly string[] => { + const restored = client.checkpointRestore?.(); + return [ + ...(restored === undefined + ? [] + : [ + `Restored owned Program ${restored.path} (SHA-256 ${restored.sha256}); auto-analysis was not rerun.`, + ]), + ...providerLimitations, + ...targetLimitations, + ...(psp ? GHIDRA_PSP_LIMITATIONS : []), + ...softFloatLimitations, + ...riscvCallingConventionLimitations, + ...ghidraExtensionLimitations(extensions), + ...seedLimitations, + ...disabledAnalyzerLimitations, + ...pdbLimitations, + ...(releaseLimitation === undefined ? [] : [releaseLimitation]), + ]; + }; const nativeAotOwner = target.format === "pe" ? new GhidraNativeAotSnapshotOwner({ @@ -828,7 +863,7 @@ export const createGhidraProviderClient = (input: { }), analysisProfile: committedProfile, limitations: [ - ...limitationsFor(operation, project !== undefined), + ...limitationsFor(operation, projection.source), ...(observation.limitations ?? []), ...sessionLimitations(), ], @@ -925,7 +960,7 @@ export const createGhidraProviderClient = (input: { createAnalysisExecution(started.value, committedProfile.provider, { analysisProfile: committedProfile, limitations: [ - ...ghidraHealthLimitations(project !== undefined), + ...ghidraHealthLimitations(projection.source), ...sessionLimitations(), ], }), @@ -1165,6 +1200,7 @@ type GhidraProjectionLimits = Pick< interface GhidraProjection { readonly limits: GhidraProjectionLimits; readonly programName: string | undefined; + readonly source: GhidraSessionSource; } const projectSessionError = ( @@ -1193,7 +1229,7 @@ const projectSessionError = ( const projectPrimarySessionError = ( operation: AnalysisOperation, failure: GhidraSessionError, - { limits, programName }: GhidraProjection, + { limits, programName, source }: GhidraProjection, health?: ProviderOperationHealth, request?: Readonly>, ): AnalysisError => { @@ -1205,6 +1241,22 @@ const projectPrimarySessionError = ( ? {} : { partialObservation: failure.partialObservation }), }; + if (failure.kind === "checkpoint") { + if (failure.cause instanceof AnalysisError) + return analysisClientFailureForOperation( + failure.cause, + "ghidra", + operation, + ); + return new ConfigurationError(failure.message, { + ...context, + settings: [ + { setting: "REA_GHIDRA_CHECKPOINT_DIR", constraint: failure.message }, + ], + remediationAction: + "Preserve the checkpoint directory. Correct the reported file, permission or competing-session problem, then reopen the target. Select a new checkpoint directory only if you explicitly want fresh analysis.", + }); + } // Launch-time copies, such as the verified existing-project copy, report a // changed source as the launcher's primary failure; read it through the // launch wrapper so the refusal keeps its typed projection. @@ -1260,7 +1312,7 @@ const projectPrimarySessionError = ( const remediationAction = unavailable ? "Select a Ghidra program whose imported identity can be verified, or investigate the missing identity in Ghidra. Configure a different project or program when starting REA. REA did not fall back to a fresh import." : "Select a target matching the configured program and retry, or configure a matching project or program when starting REA. Resolve any reported cleanup before reopening. REA did not fall back to a fresh import."; - if (limits.ghidraExistingProject !== undefined) + if (source === "existing-project") return new ConfigurationError(constraint, { ...context, settings: [{ setting: "REA_GHIDRA_PROJECT_PATH", constraint }], @@ -1319,7 +1371,7 @@ const projectPrimarySessionError = ( default: recovery = "Use list_procedures or search_procedures (CLI: `rea search --kind procedures --provider ghidra --json`) to inspect the current Ghidra inventory and choose an observed name or address. An absent entry does not prove the function is absent from the artifact." + - (limits.ghidraExistingProject !== undefined + (source === "existing-project" ? " The selected project's previous analysis completeness is unknown. To explicitly request a separate fresh import, start a new REA process without REA_GHIDRA_PROJECT_PATH and REA_GHIDRA_PROGRAM_NAME; that analysis will not include the selected project's annotations or other edits." : ""); } @@ -1361,7 +1413,7 @@ const projectPrimarySessionError = ( failure, "Only the decompiler failed for this procedure.", GHIDRA_SESSION_RETAINED, - limits.ghidraExistingProject !== undefined, + source, ), remediationAction: "Call analyze_function (CLI: `rea function`) for the same procedure. It returns assembly, callers, callees, references and basic blocks from the Ghidra listing and records this decompiler failure under facet_availability.pseudocode. Repeating the unchanged decompile request is not expected to succeed.", @@ -1378,11 +1430,11 @@ const projectPrimarySessionError = ( failure, "Only this procedure's decompilation was stopped.", GHIDRA_SESSION_RETAINED, - limits.ghidraExistingProject !== undefined, + source, ); const recovery = ghidraDecompileTimeoutRecovery( limits.ghidraDecompileTimeoutSeconds, - limits.ghidraExistingProject !== undefined, + source, ); return new AnalysisTimeoutError( operation, @@ -1417,7 +1469,7 @@ const projectPrimarySessionError = ( failure, undefined, GHIDRA_ANNOTATIONS_RETAINED, - limits.ghidraExistingProject !== undefined, + source, ); return new AnalysisResourceConstraintError( operation, @@ -1503,7 +1555,7 @@ const projectPrimarySessionError = ( remediationAction: GHIDRA_RESTRICTED_TEMP_REMEDIATION, }); const killed = projectSigkill(operation, failure, context, { - restart: exitedProviderMessage(operation, failure, health), + restart: exitedProviderMessage(operation, failure, health, source), uncertainty, }); if (killed !== undefined) return killed; @@ -1511,10 +1563,7 @@ const projectPrimarySessionError = ( const timeoutMs = failure.timeoutMs ?? startupTimeoutMs; const guidance = failure.message === GHIDRA_STARTUP_DEADLINE_ELAPSED - ? ghidraStartupTimeoutGuidance( - timeoutMs, - limits.ghidraExistingProject !== undefined, - ) + ? ghidraStartupTimeoutGuidance(timeoutMs, source) : undefined; return new AnalysisTimeoutError(operation, timeoutMs, { ...context, @@ -1529,7 +1578,7 @@ const projectPrimarySessionError = ( }), }); } - const exited = exitedProviderMessage(operation, failure, health); + const exited = exitedProviderMessage(operation, failure, health, source); if (exited !== undefined) return new ProviderAdapterError("ghidra", operation, { ...context, @@ -1708,10 +1757,12 @@ export const GHIDRA_ANNOTATIONS_RETAINED = "The active analysis session and annotations remain available."; /** What a new Ghidra session repeats in the configured startup mode. */ -const ghidraSessionRestart = (existingProject: boolean): string => - existingProject +const ghidraSessionRestart = (source: GhidraSessionSource): string => + source === "existing-project" ? "copies and opens the selected REA_GHIDRA_PROJECT_PATH project again, without import or auto-analysis" - : "repeats import and analysis"; + : source === "owned-checkpoint" + ? "restores the last saved Program from REA_GHIDRA_CHECKPOINT_DIR; if initial analysis never completed, it must finish once before a checkpoint exists" + : "repeats import and analysis"; /** * Decompile-deadline recovery for each caller lifetime. A finished invocation @@ -1720,7 +1771,7 @@ const ghidraSessionRestart = (existingProject: boolean): string => */ export const ghidraDecompileTimeoutRecovery = ( seconds: number, - existingProject: boolean, + source: GhidraSessionSource, ): { readonly remediationAction: string; readonly continuingRemediationAction: string; @@ -1729,7 +1780,7 @@ export const ghidraDecompileTimeoutRecovery = ( seconds < MAX_GHIDRA_DECOMPILE_TIMEOUT_SECONDS ? `Set REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS above ${String(seconds)} seconds (maximum ${String(MAX_GHIDRA_DECOMPILE_TIMEOUT_SECONDS)}), or unset it to leave decompilation unbounded,` : `REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS is already at its maximum of ${String(seconds)} seconds; unset it to leave decompilation unbounded` - } in the environment of a new REA invocation, then rerun the command. That invocation starts a new Ghidra session and ${ghidraSessionRestart(existingProject)}. Use \`rea function\` for this procedure's listing facts without pseudocode.`, + } in the environment of a new REA invocation, then rerun the command. That invocation starts a new Ghidra session and ${ghidraSessionRestart(source)}. Use \`rea function\` for this procedure's listing facts without pseudocode.`, continuingRemediationAction: "Use analyze_function (CLI: `rea function`) for listing facts. Call update_analysis_settings with setting ghidra.decompile_timeout_seconds and action set to raise the per-function deadline for the next operation on this Program. For CLI or future REA processes, set REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS. Set the MCP tool-call deadline to cover startup and the full call; earlier cancellation closes the session. `rea setup` estimates deadlines for new Codex/Kimi registrations and preserves existing positive values; edit existing deadlines yourself.", }); @@ -1743,7 +1794,7 @@ const ghidraRequestOutcome = ( failure: GhidraSessionError, requestScope: string | undefined, retainedSession: string, - existingProject: boolean, + source: GhidraSessionSource, ): { readonly userMessage: string; readonly continuingCallerGuidance?: { readonly userMessage: string }; @@ -1758,7 +1809,7 @@ const ghidraRequestOutcome = ( return { userMessage: `${detail} ${ failure.cleanupFailure === undefined - ? `The request was also cancelled before the failure settled, so REA closed the Ghidra session; the next request starts a new session and ${ghidraSessionRestart(existingProject)}.` + ? `The request was also cancelled before the failure settled, so REA closed the Ghidra session; the next request starts a new session and ${ghidraSessionRestart(source)}.` : "The request was also cancelled, and Ghidra session shutdown left cleanup unconfirmed. Close the target to retry retained cleanup before starting another Ghidra session." }`, }; @@ -1839,7 +1890,7 @@ const projectSigkill = ( /** Name the settings that change Ghidra's startup deadline for the configured startup mode. */ export const ghidraStartupTimeoutGuidance = ( timeoutMs: number, - existingProject: boolean, + source: GhidraSessionSource, ) => { const remediation = (continuing: boolean): string => { const raise = continuing @@ -1852,9 +1903,11 @@ export const ghidraStartupTimeoutGuidance = ( ? " The MCP client's tool-call deadline must also allow startup and the query." : ""; const reopen = continuing ? "Reopening" : "Each invocation"; - return existingProject + return source === "existing-project" ? `Large projects can need longer: ${raise}. Existing-project mode does not run auto-analysis, so analyzer settings do not shorten this startup. ${retry}${client}` - : `Large binaries can need longer: ${raise}; REA_GHIDRA_DISABLED_ANALYZERS can skip selected expensive analyzers with reduced coverage. ${retry} ${reopen} repeats import from scratch; for repeated work on the same native ELF, PE or Mach-O binary on a POSIX host, analyze it once into a Ghidra project and set REA_GHIDRA_PROJECT_PATH to reuse that analysis.${client}`; + : source === "owned-checkpoint" + ? `Checkpoint startup can need longer: ${raise}. Restoring a saved Program skips auto-analysis; initial analysis and its first checkpoint must complete once. ${retry} Keep REA_GHIDRA_CHECKPOINT_DIR to recover the last successful save. Analyzer overrides require explicitly disabling checkpoint mode.${client}` + : `Large binaries can need longer: ${raise}; REA_GHIDRA_DISABLED_ANALYZERS can skip selected expensive analyzers with reduced coverage. ${retry} ${reopen} repeats import from scratch; for repeated work on the same native ELF, PE or Mach-O binary on a POSIX host, analyze it once into a Ghidra project and set REA_GHIDRA_PROJECT_PATH to reuse that analysis.${client}`; }; return { limit: analysisSettingLimit( @@ -1862,9 +1915,12 @@ export const ghidraStartupTimeoutGuidance = ( timeoutMs, undefined, ), - userMessage: existingProject - ? `Ghidra did not finish copying and opening the selected project within its configured ${String(timeoutMs)} ms startup deadline (REA_GHIDRA_STARTUP_TIMEOUT_MS).` - : `Ghidra did not finish importing and analyzing the target within its configured ${String(timeoutMs)} ms startup deadline (REA_GHIDRA_STARTUP_TIMEOUT_MS).`, + userMessage: + source === "existing-project" + ? `Ghidra did not finish copying and opening the selected project within its configured ${String(timeoutMs)} ms startup deadline (REA_GHIDRA_STARTUP_TIMEOUT_MS).` + : source === "owned-checkpoint" + ? `Ghidra did not finish checkpoint restore or initial analysis and saving within its configured ${String(timeoutMs)} ms startup deadline (REA_GHIDRA_STARTUP_TIMEOUT_MS).` + : `Ghidra did not finish importing and analyzing the target within its configured ${String(timeoutMs)} ms startup deadline (REA_GHIDRA_STARTUP_TIMEOUT_MS).`, remediationAction: remediation(false), continuingCallerGuidance: { remediationAction: remediation(true) }, }; @@ -1875,6 +1931,7 @@ const exitedProviderMessage = ( operation: AnalysisOperation, failure: GhidraSessionError, health: ProviderOperationHealth | undefined, + source: GhidraSessionSource, ): string | undefined => { if (failure.kind !== "process" && failure.kind !== "protocol") return undefined; @@ -1887,5 +1944,5 @@ const exitedProviderMessage = ( health.state === "exited" ? `exited${health.exitCode === null ? "" : ` with code ${String(health.exitCode)}`}` : "became unreachable"; - return `The Ghidra provider for this session ${observed} before ${operation} could complete. Call open_binary for the same target to start a new Ghidra session, then retry; session-only annotations from the previous provider are not retained.`; + return `The Ghidra provider for this session ${observed} before ${operation} could complete. Call open_binary for the same target to start a new Ghidra session, then retry; ${source === "owned-checkpoint" ? "the last successful checkpoint is restored, and later unsaved annotations are not retained" : "session-only annotations from the previous provider are not retained"}.`; }; diff --git a/src/ghidra/GhidraSessionError.ts b/src/ghidra/GhidraSessionError.ts index eda2c72666..a6226c652b 100644 --- a/src/ghidra/GhidraSessionError.ts +++ b/src/ghidra/GhidraSessionError.ts @@ -29,6 +29,7 @@ export type GhidraSessionFailureKind = | "start" | "process" | "protocol" + | "checkpoint" | "remote" | "analysis_timeout"; diff --git a/tests/boundary/filesystem/ghidraPspExtension.test.ts b/tests/boundary/filesystem/ghidraPspExtension.test.ts index dd01e2ba86..4428fb7f1f 100644 --- a/tests/boundary/filesystem/ghidraPspExtension.test.ts +++ b/tests/boundary/filesystem/ghidraPspExtension.test.ts @@ -138,6 +138,34 @@ async function installation() { describe.skipIf(process.platform === "win32")( "PSP installed-extension and profile boundary", () => { + it("refuses an otherwise supported custom PSP loader in stock checkpoint mode", async () => { + const f = await installation(); + const checkpoints = new GhidraProvider( + { + ...f.config, + ghidraCheckpointDirectory: join(f.directory, "checkpoints"), + }, + silentLogger, + {}, + host, + ); + try { + const supported = await f.provider.resolveAnalysisProfile(target); + if (!supported.ok) throw supported.error; + expect(supported.value.profile?.parameters.loader).toBe("PspElfLoader"); + const refused = await checkpoints.resolveAnalysisProfile(target); + expect(refused).toMatchObject({ + ok: false, + error: { + _tag: "ConfigurationError", + settings: [{ setting: "REA_GHIDRA_CHECKPOINT_DIR" }], + }, + }); + } finally { + await checkpoints.closeOwnedResources(); + await f.provider.closeOwnedResources(); + } + }); it("binds source interpretation and installed content, not mtimes or discovery order", async () => { const { provider, root } = await installation(); expect(provider.inspectTargetSupport(target).status).toBe("supported"); diff --git a/tests/boundary/mcp/ghidraDecompileDeadlineLifetime.test.ts b/tests/boundary/mcp/ghidraDecompileDeadlineLifetime.test.ts index 78a23ca334..c3fefcd010 100644 --- a/tests/boundary/mcp/ghidraDecompileDeadlineLifetime.test.ts +++ b/tests/boundary/mcp/ghidraDecompileDeadlineLifetime.test.ts @@ -110,7 +110,7 @@ it("tells a finished CLI invocation to rerun with the launch setting, not to reu ); // The invocation has already released the Program the guidance describes. await expectReleased(launcher); - const recovery = ghidraDecompileTimeoutRecovery(1, false); + const recovery = ghidraDecompileTimeoutRecovery(1, "fresh-import"); expect(projected).toMatchObject({ error: "Analysis failed", code: "provider_timeout", @@ -152,7 +152,7 @@ it("keeps same-session and runtime-update guidance for a continuing MCP connecti retryable: true, recovery: { kind: "inspect_timeout_state", timeout_ms: 1000 }, remediation: { - action: ghidraDecompileTimeoutRecovery(1, false) + action: ghidraDecompileTimeoutRecovery(1, "fresh-import") .continuingRemediationAction, }, details: { diff --git a/tests/boundary/providers/ghidra/ghidraCheckpointLifecycle.test.ts b/tests/boundary/providers/ghidra/ghidraCheckpointLifecycle.test.ts new file mode 100644 index 0000000000..a650bdb775 --- /dev/null +++ b/tests/boundary/providers/ghidra/ghidraCheckpointLifecycle.test.ts @@ -0,0 +1,285 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { createHash } from "node:crypto"; +import { + access, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js"; +import { createAnalysisProfile } from "../../../../src/domain/analysisProfile.js"; +import { GhidraClient } from "../../../../src/ghidra/GhidraClient.js"; +import { resolveGhidraCheckpoint } from "../../../../src/ghidra/GhidraCheckpoint.js"; +import { GhidraFixtureLauncher } from "./ghidraClient.fixture.js"; +import { awaitAcknowledgement } from "../../../fixtures/lifecycleAcknowledgement.js"; + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, rm: vi.fn(actual.rm) }; +}); + +const roots: string[] = [], + clients: GhidraClient[] = [], + launchers: GhidraFixtureLauncher[] = []; +afterEach(async () => { + const actual = + await vi.importActual( + "node:fs/promises", + ); + vi.mocked(rm).mockReset().mockImplementation(actual.rm); + for (const launcher of launchers.splice(0)) + for (const child of launcher.processes) + if (child.connected) child.send("release_checkpoint"); + for (const client of clients.splice(0)) await client.close(); + for (const root of roots.splice(0)) + await rm(root, { recursive: true, force: true }); +}); + +it.skipIf(process.platform === "win32")( + "reopens after startup publication succeeds but staging removal fails", + async () => { + const f = await fixture(); + const actual = + await vi.importActual( + "node:fs/promises", + ); + let failed = false; + vi.mocked(rm).mockImplementation(async (path, options) => { + if ( + !failed && + typeof path === "string" && + /checkpoint-[a-f0-9-]+\.gzf$/.test(path) + ) { + failed = true; + throw new Error("controlled staging removal failure"); + } + await actual.rm(path, options); + }); + expect((await f.client.start()).ok).toBe(false); + await access(join(f.selection.directory, "current.json")); + expect((await f.client.start()).ok).toBe(true); + expect((await f.client.close()).ok).toBe(true); + for (const root of f.launcher.runtimeRoots) + await expect(access(root)).rejects.toThrow(); + }, +); +const profile = createAnalysisProfile( + { id: "ghidra", name: "Ghidra", version: "12.1.4" }, + { checkpoint_test: true }, +); +async function fixture( + mode: ConstructorParameters[0] = "checkpoint", +) { + const root = await mkdtemp(join(tmpdir(), "rea-checkpoint-lifecycle-")); + roots.push(root); + const targetPath = join(root, "target"); + await writeFile(targetPath, "source target bytes"); + const sha256 = createHash("sha256") + .update("source target bytes") + .digest("hex"); + const selection = await resolveGhidraCheckpoint( + root, + sha256, + profile, + new ArtifactResourceScope(), + ); + const launcher = new GhidraFixtureLauncher(mode); + launchers.push(launcher); + const client = new GhidraClient({ + launcher, + targetPath, + targetSha256: sha256, + providerVersion: "12.1.4", + profileDigest: profile.digest, + checkpoint: selection, + startupTimeoutMs: 10_000, + }); + clients.push(client); + return { root, selection, launcher, client, targetPath, sha256 }; +} + +it.skipIf(process.platform === "win32")( + "saves completed initial analysis, leaves an unchanged checkpoint alone, and releases its lease", + async () => { + const f = await fixture(); + expect((await f.client.start()).ok).toBe(true); + const path = join(f.selection.directory, "current.json"), + before = await readFile(path, "utf8"); + expect((await f.client.close()).ok).toBe(true); + expect(await readFile(path, "utf8")).toEqual(before); + expect(f.launcher.requests).toContain("checkpoint_program"); + const selection = await resolveGhidraCheckpoint( + f.root, + f.sha256, + profile, + new ArtifactResourceScope(), + ); + const resumed = new GhidraClient({ + launcher: f.launcher, + targetPath: f.targetPath, + targetSha256: f.sha256, + providerVersion: "12.1.4", + profileDigest: profile.digest, + checkpoint: selection, + startupTimeoutMs: 10_000, + }); + clients.push(resumed); + expect((await resumed.start()).ok).toBe(true); + expect(f.launcher.packedPrograms.at(-1)?.sha256).toBe( + JSON.parse(await readFile(path, "utf8")).source.sha256, + ); + }, +); + +it.skipIf(process.platform === "win32")( + "keeps a failed normal-close save editable and retries without deleting the previous checkpoint", + async () => { + const f = await fixture("checkpoint_fail_close"); + expect((await f.client.start()).ok).toBe(true); + const path = join(f.selection.directory, "current.json"), + before = await readFile(path, "utf8"); + expect( + ( + await f.client.callTool("set_address_name", { + address: "0x1000", + name: "changed", + }) + ).ok, + ).toBe(true); + expect((await f.client.close()).ok).toBe(false); + expect( + (await readdir(f.launcher.runtimeRoots[0]!)).filter((name) => + name.startsWith("checkpoint-"), + ), + ).toEqual([]); + expect(f.client.isReady()).toBe(true); + expect(await readFile(path, "utf8")).toEqual(before); + expect((await f.client.callTool("list_documents", {})).ok).toBe(true); + expect((await f.client.close()).ok).toBe(true); + expect(await readFile(path, "utf8")).not.toEqual(before); + }, +); + +it.skipIf(process.platform === "win32")( + "does not release a Program or its lease before its final checkpoint acknowledges", + async () => { + const f = await fixture("checkpoint_hold_close"); + expect((await f.client.start()).ok).toBe(true); + await f.client.callTool("set_address_name", { + address: "0x1000", + name: "changed", + }); + const closing = f.client.close(); + await awaitAcknowledgement( + f.launcher.checkpointHeld, + closing, + "final checkpoint", + ); + expect(f.launcher.processes[0]!.exitCode).toBeNull(); + const selection = await resolveGhidraCheckpoint( + f.root, + f.sha256, + profile, + new ArtifactResourceScope(), + ); + const other = new GhidraClient({ + launcher: f.launcher, + targetPath: f.targetPath, + targetSha256: f.sha256, + providerVersion: "12.1.4", + profileDigest: profile.digest, + checkpoint: selection, + }); + clients.push(other); + expect((await other.start()).ok).toBe(false); + f.launcher.processes[0]!.send("release_checkpoint"); + expect((await closing).ok).toBe(true); + }, +); + +it.skipIf(process.platform === "win32")( + "retains a failed partial-file cleanup and retries it before exporting again", + async () => { + const f = await fixture("checkpoint_fail_close"); + expect((await f.client.start()).ok).toBe(true); + const manifestPath = join(f.selection.directory, "current.json"); + const before = await readFile(manifestPath, "utf8"); + await f.client.callTool("set_address_name", { + address: "0x1000", + name: "changed", + }); + const actual = + await vi.importActual( + "node:fs/promises", + ); + vi.mocked(rm).mockImplementation(async (path, options) => { + if (typeof path === "string" && /checkpoint-[a-f0-9-]+\.gzf$/.test(path)) + throw new Error("controlled partial cleanup failure"); + await actual.rm(path, options); + }); + const first = await f.client.close(); + expect(first.ok).toBe(false); + expect((await f.client.close()).ok).toBe(false); + expect(await readFile(manifestPath, "utf8")).toBe(before); + expect( + (await readdir(f.launcher.runtimeRoots[0]!)).filter((name) => + name.startsWith("checkpoint-"), + ), + ).toHaveLength(1); + if (first.ok) throw new Error("partial cleanup unexpectedly succeeded"); + expect(first.error.cleanupResources).toContain(f.launcher.runtimeRoots[0]); + expect(f.client.isReady()).toBe(true); + vi.mocked(rm).mockImplementation(actual.rm); + expect((await f.client.close()).ok).toBe(true); + expect(first.error.cleanupResources).toEqual([]); + }, +); + +it.skipIf(process.platform === "win32")( + "lets a startup peer finish after another waiter cancels during initial checkpointing", + async () => { + const f = await fixture("checkpoint_hold_startup"), + abort = new AbortController(); + const first = f.client.start(abort.signal), + peer = f.client.start(); + const child = await f.launcher.started; + await awaitAcknowledgement( + f.launcher.checkpointHeld, + peer, + "initial checkpoint", + ); + abort.abort("only this waiter"); + expect((await first).ok).toBe(false); + child.send("release_checkpoint"); + expect((await peer).ok).toBe(true); + expect(f.client.isReady()).toBe(true); + }, +); + +it.skipIf(process.platform === "win32")( + "cancels the last startup waiter without publishing a partial checkpoint and removes its runtime", + async () => { + const f = await fixture("checkpoint_hold_startup"), + abort = new AbortController(); + const starting = f.client.start(abort.signal), + child = await f.launcher.started; + await awaitAcknowledgement( + f.launcher.checkpointHeld, + starting, + "initial checkpoint", + ); + abort.abort("last waiter"); + expect((await starting).ok).toBe(false); + expect((await f.client.close()).ok).toBe(true); + await expect( + access(join(f.selection.directory, "current.json")), + ).rejects.toThrow(); + for (const root of f.launcher.runtimeRoots) + await expect(access(root)).rejects.toThrow(); + expect(child.exitCode !== null || child.signalCode !== null).toBe(true); + }, +); diff --git a/tests/boundary/providers/ghidra/ghidraClient.fixture.ts b/tests/boundary/providers/ghidra/ghidraClient.fixture.ts index 91e9f5bbe9..2a3d9bbc52 100644 --- a/tests/boundary/providers/ghidra/ghidraClient.fixture.ts +++ b/tests/boundary/providers/ghidra/ghidraClient.fixture.ts @@ -15,6 +15,10 @@ export const ghidraFixturePath = fileURLToPath( ); type FixtureMode = + | "checkpoint" + | "checkpoint_fail_close" + | "checkpoint_hold_startup" + | "checkpoint_hold_close" | "unicode_search" | "reject_first_connection" | "reject_all_connections" @@ -60,10 +64,16 @@ export class GhidraFixtureLauncher implements GhidraLauncher { readonly tokens: string[] = []; readonly processes: ChildProcess[] = []; readonly requests: string[] = []; + readonly packedPrograms: GhidraLaunchSession["packedProgram"][] = []; + #notifyCheckpointHeld: (() => void) | undefined; + readonly checkpointHeld = new Promise((resolve) => { + this.#notifyCheckpointHeld = resolve; + }); constructor(readonly mode: FixtureMode = "success") {} async launch(session: GhidraLaunchSession) { + this.packedPrograms.push(session.packedProgram); this.runtimeRoots.push(session.runtimeRoot); this.endpointPaths.push(session.endpointPath); this.tokens.push(session.token); @@ -86,6 +96,13 @@ export class GhidraFixtureLauncher implements GhidraLauncher { }, }); process_.on("message", (value: unknown) => { + if ( + typeof value === "object" && + value !== null && + "event" in value && + value.event === "checkpoint-held" + ) + this.#notifyCheckpointHeld?.(); if ( typeof value === "object" && value !== null && diff --git a/tests/fixtures/fakeGhidra.mjs b/tests/fixtures/fakeGhidra.mjs index 22b8336b23..59c1f048be 100644 --- a/tests/fixtures/fakeGhidra.mjs +++ b/tests/fixtures/fakeGhidra.mjs @@ -178,6 +178,19 @@ const handleOutputFailure = (socket, state) => { }; const handleRequest = (socket, server, request, state) => { + if (state.mode.startsWith("checkpoint")) { + if (request.method === "set_address_name") { + state.checkpointDirty = true; + socket.write( + JSON.stringify({ id: request.id, ok: true, result: "renamed" }) + "\n", + ); + return; + } + if (request.method === "checkpoint_program") { + void handleCheckpoint(socket, request, state); + return; + } + } if (request.method === "ping") { handlePing(socket, request, state); return; @@ -374,6 +387,63 @@ const handleRequest = (socket, server, request, state) => { } }; +async function handleCheckpoint(socket, request, state) { + const { path, force } = request.params; + if (!force && !state.checkpointDirty) { + socket.write( + JSON.stringify({ id: request.id, ok: true, result: { saved: false } }) + + "\n", + ); + return; + } + if ( + !force && + state.mode === "checkpoint_fail_close" && + !state.failedCheckpoint + ) { + state.failedCheckpoint = true; + await writeFile(path, "controlled incomplete packed Program"); + socket.write( + JSON.stringify({ + id: request.id, + ok: false, + error: { + code: "checkpoint_failed", + message: "Controlled checkpoint failure", + }, + }) + "\n", + ); + return; + } + await writeFile( + path, + `fixture checkpoint ${state.checkpointDirty ? "edited" : "initial"}`, + ); + if ( + (force && state.mode === "checkpoint_hold_startup") || + (!force && state.mode === "checkpoint_hold_close") + ) { + await new Promise((resolve) => { + const release = (message) => { + if (message === "release_checkpoint") { + process.off("message", release); + resolve(); + } + }; + process.on("message", release); + process.send?.({ event: "checkpoint-held" }); + }); + } + state.checkpointDirty = false; + socket.write( + JSON.stringify({ + id: request.id, + ok: true, + result: { saved: true, path }, + }) + "\n", + ); +} + const handlePing = (socket, request, state) => { if ( ["import_identity_unavailable", "import_identity_mismatch"].includes( From 36dcd985e1649deab079903ee82831709af05bf2 Mon Sep 17 00:00:00 2001 From: Shivam Dwivedi <12083769+panditshivam@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:46:02 +0530 Subject: [PATCH 093/151] docs(clients): document llama.cpp MCP setup (#4878) * docs(clients): document llama.cpp MCP setup * docs(clients): clarify persistent installs and llama.cpp deadlines * docs(clients): escape Windows paths in llama.cpp config --- docs/client-configuration.md | 97 ++++++++++++++++++++++++++++++++++++ docs/client-verification.md | 24 +++++++++ docs/installation.md | 6 +++ 3 files changed, 127 insertions(+) diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 64fca91f79..b2a8856ac9 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -839,6 +839,103 @@ Harness integration, with live DeepSeek API compatibility remaining unverified. See the [Harness MCP guide](https://github.com/deepseek-ai/deepseek-harness/blob/639ed015397290b3745d163aafe02ffee4aa3f84/packages/mcp/mcp-client/README.md) for its native configuration contract. +
+ +## llama.cpp + +Use REA from llama.cpp's local Web UI through `llama-server`'s native stdio +MCP support. REA supplies the analysis tools; llama.cpp runs your chosen model. +It is not a replacement for a native analysis provider such as Ghidra or Hopper. + +Install REA in a persistent location so the configuration below can use a stable +entrypoint. For a project-local installation, run this in your chosen directory: + +```bash +npm install --save-exact rea-agents +``` + +Or install it globally: + +```bash +npm install --global rea-agents +``` + +See [package runners and global installation](installation.md#package-runners-and-global-installation) +for runtime requirements and installation options. Running `npx rea-agents setup` +alone does not create either installation; do not use its temporary package-cache path. +Use a llama.cpp build whose `llama-server --help` lists +`--mcp-servers-config`; older builds without that option cannot use this route. +The configuration below uses an already installed REA package, so tool discovery +does not depend on downloading an npm package during server startup. + +Save a JSON file, for example `rea-mcp.json`: + +```json +{ + "mcpServers": { + "rea": { + "command": "/absolute/path/to/node", + "args": ["/absolute/path/to/rea-agents/scripts/rea.mjs", "mcp"], + "timeout_ms": 450000 + } + } +} +``` + +Replace both paths with your installation's actual paths. For a project-local npm +installation, run `npm root` in that project; the script is under +`$(npm root)/rea-agents/scripts/rea.mjs`. For a global installation, use +`$(npm root -g)/rea-agents/scripts/rea.mjs` instead. Write the resulting absolute +paths as JSON strings; llama-server does not expand shell expressions there. +On Windows, use forward slashes, such as `"C:/Program Files/nodejs/node.exe"`, +or double each backslash, as in `"C:\\Program Files\\nodejs\\node.exe"`. +Apply this to both `command` and the script path in `args`. Single backslashes +can make the JSON invalid or change the path through escape sequences. +`node -p "JSON.stringify(process.execPath)"` prints your current Node.js executable +as a quoted, escaped JSON string that can be used as the `command` value. +Use the version supported by REA. Preserve any other +servers if you are adding REA to an existing configuration. `timeout_ms` is +llama.cpp's per-tool-call timeout, including cold engine startup. The example +allows REA's default 330-second Ghidra startup budget plus 120 seconds for the +operation. Increase it if you configure a longer provider allowance or need more +operation time; see [provider deadlines](mcp-contracts.md#ghidra-first-query-deadlines-and-recovery). + +Start the server with your own tool-capable GGUF model: + +```bash +llama-server -m /absolute/path/to/model.gguf \ + --host 127.0.0.1 --port 8080 --parallel 1 --ctx-size 16384 \ + --mcp-servers-config /absolute/path/to/rea-mcp.json +``` + +Open `http://127.0.0.1:8080`. REA tools appear with a `rea_` prefix, such as +`rea_analyze_javascript_application`. Select only the tools needed for the +conversation in the Web UI's tool settings: discovery of REA's complete catalog +does not mean that its schemas and results fit the selected model's context. +Choose a context size your model and machine support. For a first check, enable +`rea_analyze_javascript_application` and ask it to inspect a small JavaScript +directory you own. This task needs no native analysis engine. Inspect the +returned Evidence and limitations rather than relying only on the model's summary. + +If the model prints a proposed call as ordinary text, REA has not been called. +Check the selected model and its chat template against llama.cpp's function-calling +guidance. A working MCP connection does not establish reliable tool selection by +every model. + +`--tools all` is not needed; it enables llama.cpp's separate built-in tools. +`--ui-mcp-proxy` is also not needed for this server-side stdio connection. +REA's setup wizard does not register llama.cpp automatically; pass the JSON +file to llama-server explicitly and restart it after changes. + +The MCP child runs with llama-server's permissions. Loopback binding does not +sandbox it or make every REA operation read-only. Enable only trusted commands, +keep the server local, and select targets and operations you are authorized to use. + +See llama.cpp's [MCP server configuration](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md#mcp-servers) +and [function-calling guidance](https://github.com/ggml-org/llama.cpp/blob/master/docs/function-calling.md). +The [verification record](client-verification.md#llamacpp) distinguishes connection +checks from model behavior and lists the tested versions. + ## Other client settings For OpenCode, setup writes the V1 `mcp.rea` entry, which OpenCode V1 and V2 diff --git a/docs/client-verification.md b/docs/client-verification.md index 86b387cfcc..76f0d45b09 100644 --- a/docs/client-verification.md +++ b/docs/client-verification.md @@ -43,6 +43,30 @@ does not establish broader Pi compatibility. See the pinned upstream [`declarations.ts`](https://github.com/earendil-works/pi/blob/b45e0ebcc5ae2fc42a6f2f23104d10e656e6bedb/packages/codemode/src/declarations.ts) and [renderer usage notes](https://github.com/earendil-works/pi/blob/b45e0ebcc5ae2fc42a6f2f23104d10e656e6bedb/packages/codemode/README.md#declarations-for-the-model). + + +## llama.cpp + +The [manual stdio connection](client-configuration.md#llamacpp) was checked on +macOS arm64 with Node.js 24.18.0, llama-server b11715 (`9d1878a43`), and the +published `rea-agents@6.5.0` package. `GET /tools` exposed 207 REA tools without +enabling llama.cpp's built-in tools. With the source checkout at `9e372c6`, all +207 forwarded names, descriptions and input schemas matched a direct MCP client. + +Calls through `POST /tools` returned session metadata and JavaScript analysis +Evidence for a fixture at a Unicode path. A missing target returned REA's input +error, and a subsequent session call still succeeded. The owned llama-server +process exited successfully after the checks. No native analysis engine or +Windows host was exercised. + +A local Qwen3-1.7B Q8_0 run selected the JavaScript analysis tool and received its +Evidence, but its follow-up answer reached the output-token limit. Other small +Qwen3 attempts returned proposed calls as ordinary JSON text rather than tool +calls. These runs do not establish dependable model-driven investigations. +A request with the full catalog and task history exceeded a 16,384-token context; +select tools for the task instead of assuming that discovery implies model fit. +The checks exercised HTTP and stdio, not browser interaction with the Web UI. + ## End-to-end, integration and golden evidence The optional `verify:qwen-client`, `verify:pi-client`, and `verify:hermes-client` diff --git a/docs/installation.md b/docs/installation.md index 9977f76450..f3639edf59 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -674,6 +674,12 @@ configuration paths, profiles, trust policies, and schema-limit workarounds. See [the Cordis patch and launcher setup](client-configuration.md#deepseek-harness). +### llama.cpp + +llama-server can launch REA through its native stdio MCP configuration. This is +a manual connection, not an automatic setup client ID. See the +[local model setup](client-configuration.md#llamacpp). + ### Other client settings See [OpenCode, Grok Build, and Grok Bot settings](client-configuration.md#other-client-settings). From 923e6f77e37a63c7bdf3864db00e4c5cd73be09a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E4=BD=B3=E6=B5=A9Alben?= <35291372+XiaoBinGan@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:17:47 +0800 Subject: [PATCH 094/151] fix(javascript): keep timer cancellation within the Node handle family (#7712) * fix(javascript): keep timer cancellation within Node's handle family Node keeps timeouts and immediates in disjoint handle families. clearTimeout and clearInterval are aliases that act only on a Timeout from setTimeout or setInterval, while clearImmediate acts only on an Immediate from setImmediate. Crossing the families returns without doing anything, so the scheduled callback still runs. REA linked a cancellation whenever the cleared binding traced back to any timer schedule, so clearImmediate(setTimeout(work, 10)) reported a complete cancellation and the graph emitted a resolved cancels-timer relation. It also flagged no uncertainty for it. Require the cancel and the schedule to share a handle family before linking them. A cross-family clear now keeps linkedTimerId null and resolution unresolved, and the graph reports the cancellation as a candidate with an ambiguous-target unknown instead of a resolved fact. Verified against Node v24.22.1 over all nine schedule/cancel pairs: setTimeout and setInterval with clearTimeout or clearInterval cancel, and setImmediate with clearImmediate cancels; the other five combinations leave the callback running. Node's own lib/timers.js shows why: clearTimeout requires timer?._onTimeout, clearImmediate requires immediate?._onImmediate and returns early otherwise, and clearInterval delegates to clearTimeout. * chore: retrigger title and readiness checks after the title was corrected * fix(javascript): say cross-family timer clearing is runtime-dependent Review feedback from @morluto: the previous wording claimed clearing across the timer handle families "returns without doing anything, so the timer keeps running". That does not hold on every supported Node version. Measured on Node 24.22.1, all three cross-family pairs fire: | scheduled with | cancelled with | Node 24.22.1 | | --- | --- | --- | | `setTimeout` | `clearImmediate` | fires | | `setImmediate` | `clearTimeout` | fires | | `setImmediate` | `clearInterval` | fires | `clearImmediate` cancelling a `Timeout` on Node 22 is the case the old claim missed. Within a family the clear always cancels, which is why the analysis links only same-family pairs; across families the outcome depends on the runtime, which is exactly why the analysis reports `unresolved` rather than `complete` in either direction. The behavior and the tests are unchanged. The `timerHandleFamily` doc comment, the linked-origin comment, and the test comment now state the runtime dependence and why it justifies an unresolved result. --- .../javascriptSemanticAnalysis.timers.test.ts | 34 +++++++++++++++++++ .../javascriptSemanticAsyncEffects.ts | 28 ++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.timers.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.timers.test.ts index 0df828281b..7590ebf588 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.timers.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.timers.test.ts @@ -87,3 +87,37 @@ it("keeps cancellation uncertain across a mutable closure capture", () => { resolution: "partial", }); }); + +it("keeps cancellation within the handle family that Node clears", () => { + // Node keeps timeouts and immediates in separate handle families: + // clearTimeout/clearInterval act only on a Timeout, clearImmediate only on an + // Immediate. Clearing within a family always cancels; clearing across them is + // runtime-dependent (setImmediate then clearTimeout never cancels, but + // setTimeout then clearImmediate does on some supported Node versions), so a + // cross-family clear is retained as unresolved rather than as proven either way. + const pairs = [ + ["setTimeout", "clearTimeout", true], + ["setTimeout", "clearInterval", true], + ["setTimeout", "clearImmediate", false], + ["setInterval", "clearTimeout", true], + ["setInterval", "clearInterval", true], + ["setInterval", "clearImmediate", false], + ["setImmediate", "clearTimeout", false], + ["setImmediate", "clearInterval", false], + ["setImmediate", "clearImmediate", true], + ] as const; + + for (const [schedule, cancel, linked] of pairs) { + const operations = timerOperations(` + const handle = ${schedule}(work, 10); + ${cancel}(handle); + `); + + expect(operations[1], `${schedule} then ${cancel}`).toMatchObject({ + kind: "cancel", + method: cancel, + linkedTimerId: linked ? operations[0]?.timerId : null, + resolution: linked ? "complete" : "unresolved", + }); + } +}); diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index e88832eaa4..1033f6e38f 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -68,6 +68,24 @@ const TIMER_CANCEL_METHODS = [ "clearImmediate", ] as const; +/** + * Node keeps two timer handle families. `clearTimeout` and `clearInterval` are + * aliases that act only on a `Timeout` from `setTimeout` or `setInterval`, and + * `clearImmediate` acts only on an `Immediate` from `setImmediate`. Clearing + * within a family always cancels. Clearing across the families is + * runtime-dependent: `setImmediate` then `clearTimeout`/`clearInterval` never + * cancels, but `setTimeout` then `clearImmediate` reports as cancelled on some + * supported Node versions and fires on others. The analysis therefore treats a + * cross-family clear as unresolved rather than as proof that the timer kept + * running. + */ +const timerHandleFamily = ( + method: JavaScriptSemanticTimerOperation["method"], +): "timeout" | "immediate" => + method === "setImmediate" || method === "clearImmediate" + ? "immediate" + : "timeout"; + interface AsyncEffectAnalysis { readonly eventOperations: readonly JavaScriptSemanticEventOperation[]; readonly timerOperations: readonly JavaScriptSemanticTimerOperation[]; @@ -435,7 +453,15 @@ const resolveTimers = ( const linked = candidate.handleRead?.origins.flatMap((origin) => { const schedule = schedules.get(origin); - return schedule === undefined ? [] : [schedule]; + // A handle always clears its own family. Clearing across families is + // runtime-dependent, so it establishes no link in either direction. + if ( + schedule === undefined || + timerHandleFamily(schedule.method) !== + timerHandleFamily(candidate.method) + ) + return []; + return [schedule]; }) ?? []; const incompleteOrigins = candidate.handleRead !== null && From 562779bd71458fc53d60247d026730d52cd9b66e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:49:50 +0800 Subject: [PATCH 095/151] fix(mcp): advertise absolute paths for tools that reject relative inputs (#9179) * fix(mcp): advertise absolute paths for tools that reject relative inputs The domain schemas accepted relative paths, then handlers rejected them with isAbsolute. Those inputs now use absoluteLocalPathStringSchema, and the duplicate handler checks are gone. * fix(javascript): own recovery path admission in the schema Relative recovery paths now fail in absoluteLocalPathStringSchema, including the additional_paths index. Drop the duplicate handler check, and make the CLI, MCP, and tools/list tests assert that schema fact. * test: assert absolute-path facts instead of error prose Recovery and web-script tests keep the field, format code, and regex expectation. They no longer freeze the schema's explanatory sentence. --------- Co-authored-by: Cursor Agent --- src/application/WebScriptExportService.ts | 16 --- .../binaryDiagnostics/AppleIpsService.ts | 16 +-- .../binaryDiagnostics/ByteEntropyService.ts | 5 - .../binaryDiagnostics/ElfNotesService.ts | 16 +-- .../binaryDiagnostics/KernelModuleService.ts | 16 +-- .../JavaScriptRecoveryService.test.ts | 8 +- .../javascript/JavaScriptRecoveryService.ts | 27 +--- src/cli/webScriptCommands.ts | 11 +- src/domain/javascript/javascriptRecovery.ts | 8 +- src/domain/native/appleIps.ts | 4 +- src/domain/native/byteEntropy.ts | 6 +- src/domain/native/elfNotes.ts | 4 +- src/domain/native/kernelModule.ts | 4 +- src/domain/webScriptExport.ts | 6 +- src/javascript/recovery/RecoveryFiles.ts | 5 - tests/acceptance/nonBinaryComposition.test.ts | 30 ++++- .../cli/cliInputSchemaRequired.test.ts | 6 +- .../filesystem/webScriptExport.test.ts | 6 +- .../mcp/absoluteLocalPathTools.test.ts | 117 ++++++++++++++++++ 19 files changed, 180 insertions(+), 131 deletions(-) create mode 100644 tests/boundary/mcp/absoluteLocalPathTools.test.ts diff --git a/src/application/WebScriptExportService.ts b/src/application/WebScriptExportService.ts index d0cd45ea31..24bcaab43e 100644 --- a/src/application/WebScriptExportService.ts +++ b/src/application/WebScriptExportService.ts @@ -1,5 +1,4 @@ import { createHash } from "node:crypto"; -import { isAbsolute } from "node:path"; import { z } from "zod"; @@ -85,21 +84,6 @@ export const exportWebScriptsValidated = async ( resources: ArtifactResourceScope, options: ExecutionOptions = {}, ): Promise> => { - const invalidPaths = (["capture_path", "output_directory"] as const).filter( - (field) => !isAbsolute(input[field]), - ); - if (invalidPaths.length > 0) - return err( - new AnalysisInputError( - OPERATION, - undefined, - invalidPaths.map((field) => ({ - path: [field], - reason: "invalid_format" as const, - message: `${field} must be an absolute filesystem path on this host.`, - })), - ), - ); try { if (options.signal?.aborted) return err( diff --git a/src/application/binaryDiagnostics/AppleIpsService.ts b/src/application/binaryDiagnostics/AppleIpsService.ts index 29a15cac4a..25539b3353 100644 --- a/src/application/binaryDiagnostics/AppleIpsService.ts +++ b/src/application/binaryDiagnostics/AppleIpsService.ts @@ -1,11 +1,7 @@ -import { isAbsolute } from "node:path"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { AppleIpsPort } from "./AppleIpsPort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { - AnalysisInputError, - AnalysisOutputError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { appleIpsSchema, @@ -38,16 +34,6 @@ export class AppleIpsService { options?.signal, ); if (!input.ok) return input; - if (!isAbsolute(input.value.path)) - return err( - new AnalysisInputError(OPERATION, undefined, [ - { - path: ["path"], - reason: "invalid_format", - message: "Expected an absolute filesystem path on this host.", - }, - ]), - ); const result = await this.provider.inspect(input.value, options); if (!result.ok) return result; const report = appleIpsSchema.safeParse(result.value); diff --git a/src/application/binaryDiagnostics/ByteEntropyService.ts b/src/application/binaryDiagnostics/ByteEntropyService.ts index 2e5776d587..a845e45b58 100644 --- a/src/application/binaryDiagnostics/ByteEntropyService.ts +++ b/src/application/binaryDiagnostics/ByteEntropyService.ts @@ -1,7 +1,6 @@ import { createHash } from "node:crypto"; import { constants } from "node:fs"; import { open, stat } from "node:fs/promises"; -import { isAbsolute } from "node:path"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import { BYTE_ENTROPY_PROVIDER } from "../../domain/investigationProviders.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; @@ -105,10 +104,6 @@ export class ByteEntropyService { ); if (!parsed.ok) return parsed; const input = parsed.value; - if (!isAbsolute(input.path)) - return err( - invalid("path", "Expected an absolute filesystem path on this host."), - ); const evidenceFromReport = (report: ByteEntropy): Evidence => createEvidence( { path: input.path, format: "file", sha256: report.artifact.sha256 }, diff --git a/src/application/binaryDiagnostics/ElfNotesService.ts b/src/application/binaryDiagnostics/ElfNotesService.ts index 47c9468af2..fae180099f 100644 --- a/src/application/binaryDiagnostics/ElfNotesService.ts +++ b/src/application/binaryDiagnostics/ElfNotesService.ts @@ -1,11 +1,7 @@ -import { isAbsolute } from "node:path"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { ElfNotesPort } from "./ElfNotesPort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { - AnalysisInputError, - AnalysisOutputError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { inspectElfNotesInputSchema, @@ -39,16 +35,6 @@ export class ElfNotesService { options?.signal, ); if (!input.ok) return input; - if (!isAbsolute(input.value.path)) - return err( - new AnalysisInputError(OPERATION, undefined, [ - { - path: ["path"], - reason: "invalid_format", - message: "Expected an absolute filesystem path on this host.", - }, - ]), - ); const result = await this.provider.inspect(input.value, options); if (!result.ok) return result; const report = elfNotesSchema.safeParse(result.value); diff --git a/src/application/binaryDiagnostics/KernelModuleService.ts b/src/application/binaryDiagnostics/KernelModuleService.ts index 8f8fbe1dd7..0300327657 100644 --- a/src/application/binaryDiagnostics/KernelModuleService.ts +++ b/src/application/binaryDiagnostics/KernelModuleService.ts @@ -1,11 +1,7 @@ -import { isAbsolute } from "node:path"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { KernelModulePort } from "./KernelModulePort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { - AnalysisInputError, - AnalysisOutputError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { elfObjectTypeName } from "../../domain/native/elfNotes.js"; import { @@ -47,16 +43,6 @@ export class KernelModuleService { options?.signal, ); if (!input.ok) return input; - if (!isAbsolute(input.value.path)) - return err( - new AnalysisInputError(OPERATION, undefined, [ - { - path: ["path"], - reason: "invalid_format", - message: "Expected an absolute filesystem path on this host.", - }, - ]), - ); const result = await this.provider.inspect(input.value, options); if (!result.ok) return result; const report = kernelModuleSchema.safeParse(result.value); diff --git a/src/application/javascript/JavaScriptRecoveryService.test.ts b/src/application/javascript/JavaScriptRecoveryService.test.ts index 1977b6031b..41282994c0 100644 --- a/src/application/javascript/JavaScriptRecoveryService.test.ts +++ b/src/application/javascript/JavaScriptRecoveryService.test.ts @@ -154,7 +154,13 @@ describe("JavaScript recovery workflow", () => { expect(invoked).toBe(false); if (result.ok) throw new Error("expected invalid path"); expect(result.error).toMatchObject({ - issues: [{ path: ["additional_paths"], reason: "invalid_format" }], + issues: [ + { + path: ["additional_paths", 0], + reason: "invalid_format", + expected: "regex", + }, + ], }); }); }); diff --git a/src/application/javascript/JavaScriptRecoveryService.ts b/src/application/javascript/JavaScriptRecoveryService.ts index 852b5a3a6c..d57c1a9abc 100644 --- a/src/application/javascript/JavaScriptRecoveryService.ts +++ b/src/application/javascript/JavaScriptRecoveryService.ts @@ -1,10 +1,6 @@ import { parseAnalysisInput } from "../AnalysisInput.js"; -import { isAbsolute } from "node:path"; -import { - AnalysisInputError, - AnalysisOutputError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { @@ -38,27 +34,6 @@ export class JavaScriptRecoveryService { options?.signal, ); if (!parsed.ok) return parsed; - const invalidPaths = (["path", "output_directory"] as const).filter( - (field) => !isAbsolute(parsed.value[field]), - ); - const invalidAdditional = (parsed.value.additional_paths ?? []).filter( - (additional) => !isAbsolute(additional), - ); - if (invalidPaths.length > 0 || invalidAdditional.length > 0) - return err( - new AnalysisInputError(OPERATION, undefined, [ - ...invalidPaths.map((field) => ({ - path: [field], - reason: "invalid_format" as const, - message: `${field} must be an absolute filesystem path on this host.`, - })), - ...invalidAdditional.map((additional) => ({ - path: ["additional_paths"], - reason: "invalid_format" as const, - message: `additional_paths entries must be absolute filesystem paths on this host: ${additional}`, - })), - ]), - ); const execution = await this.provider.recover(parsed.value, options); if (!execution.ok) return execution; const result = javascriptRecoveryResultSchema.safeParse( diff --git a/src/cli/webScriptCommands.ts b/src/cli/webScriptCommands.ts index cade5933a7..3f09e07b2a 100644 --- a/src/cli/webScriptCommands.ts +++ b/src/cli/webScriptCommands.ts @@ -1,6 +1,7 @@ import { Cli, z } from "incur"; import { exportWebScripts } from "../application/WebScriptExportService.js"; +import { exportWebScriptsInputSchema } from "../domain/webScriptExport.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; @@ -18,14 +19,8 @@ export const registerWebScriptCommands = ( description: "Export retained website scripts into an absent directory for static JavaScript analysis", args: z.object({ - capturePath: z - .string() - .describe("Absolute path to saved page or scenario capture JSON"), - outputDirectory: z - .string() - .describe( - "Absolute absent directory for verified scripts and manifest", - ), + capturePath: exportWebScriptsInputSchema.shape.capture_path, + outputDirectory: exportWebScriptsInputSchema.shape.output_directory, }), run: ({ args }) => withCommandCancellation((signal) => diff --git a/src/domain/javascript/javascriptRecovery.ts b/src/domain/javascript/javascriptRecovery.ts index 07f34699a6..7310cd9922 100644 --- a/src/domain/javascript/javascriptRecovery.ts +++ b/src/domain/javascript/javascriptRecovery.ts @@ -1,21 +1,21 @@ import { z } from "zod"; import { digestSchema } from "../digests.js"; import { analyzeJavaScriptApplicationInputSchema } from "./javascriptApplicationAnalysis.js"; -import { localPathStringSchema } from "../localPath.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; /** Recover derived readable modules from explicitly selected local scripts. */ export const javascriptRecoveryInputSchema = z.strictObject({ - path: localPathStringSchema.describe( + path: absoluteLocalPathStringSchema.describe( "Absolute path to a UTF-8 JavaScript file; never executed", ), additional_paths: z - .array(localPathStringSchema) + .array(absoluteLocalPathStringSchema) .max(16) .optional() .describe( "Additional absolute UTF-8 JavaScript files recovered together with path; each is snapshotted separately so imports can resolve across related chunks. All inputs share a 64 MiB total budget", ), - output_directory: localPathStringSchema.describe( + output_directory: absoluteLocalPathStringSchema.describe( "Absolute absent output directory; parent must exist", ), extraction_mode: z diff --git a/src/domain/native/appleIps.ts b/src/domain/native/appleIps.ts index b390b8de5a..b9d3a9fff2 100644 --- a/src/domain/native/appleIps.ts +++ b/src/domain/native/appleIps.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { digestSchema } from "../digests.js"; -import { localPathStringSchema } from "../localPath.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; const uint = z.number().int().min(0).max(0xffffffff); const recordedIntegerSchema = z.strictObject({ @@ -52,7 +52,7 @@ export const APPLE_IPS_IMAGE_SOURCE_MEANING: Readonly< /** Complete bounded inspection of one Apple .ips crash report. */ export const inspectAppleIpsInputSchema = z.strictObject({ - path: localPathStringSchema.describe( + path: absoluteLocalPathStringSchema.describe( "Absolute filesystem path to an Apple .ips crash report", ), max_file_bytes: z diff --git a/src/domain/native/byteEntropy.ts b/src/domain/native/byteEntropy.ts index c4f04082ec..6c21e3d94a 100644 --- a/src/domain/native/byteEntropy.ts +++ b/src/domain/native/byteEntropy.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { localPathStringSchema } from "../localPath.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; const offsetSchema = z .number() @@ -10,7 +10,9 @@ const entropySchema = z.number().min(0).max(8); /** Select blocks relative to an explicit byte window; identity remains file-wide. */ export const inspectByteEntropyInputSchema = z.strictObject({ - path: localPathStringSchema, + path: absoluteLocalPathStringSchema.describe( + "Absolute filesystem path to the file", + ), block_size: z .number() .int() diff --git a/src/domain/native/elfNotes.ts b/src/domain/native/elfNotes.ts index 915b009332..0c67c43dc9 100644 --- a/src/domain/native/elfNotes.ts +++ b/src/domain/native/elfNotes.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { digestSchema } from "../digests.js"; -import { localPathStringSchema } from "../localPath.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; const uint = z.number().int().min(0).max(0xffffffff); const range = z.strictObject({ offset: uint, bytes: uint }); @@ -86,7 +86,7 @@ export const coreNoteTypeName = (type: number): ElfNoteTypeName => { /** Complete bounded inspection of ELF PT_NOTE and SHT_NOTE tables. */ export const inspectElfNotesInputSchema = z.strictObject({ - path: localPathStringSchema.describe( + path: absoluteLocalPathStringSchema.describe( "Absolute filesystem path to an ELF image", ), max_file_bytes: z diff --git a/src/domain/native/kernelModule.ts b/src/domain/native/kernelModule.ts index cb984edb14..944fb55eaa 100644 --- a/src/domain/native/kernelModule.ts +++ b/src/domain/native/kernelModule.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { digestSchema } from "../digests.js"; import { fixedTupleSchema } from "../fixedTupleSchema.js"; -import { localPathStringSchema } from "../localPath.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; import { ELF_OBJECT_TYPE_NAMES } from "./elfNotes.js"; const uint = z.number().int().min(0).max(0xffffffff); @@ -197,7 +197,7 @@ const signatureSchema = z.strictObject({ /** Complete bounded inspection of one relocatable Linux kernel module. */ export const inspectKernelModuleInputSchema = z.strictObject({ - path: localPathStringSchema.describe( + path: absoluteLocalPathStringSchema.describe( "Absolute filesystem path to an ELF kernel module or a compressed .ko container", ), max_file_bytes: z diff --git a/src/domain/webScriptExport.ts b/src/domain/webScriptExport.ts index 9e1e914620..331c06c7ac 100644 --- a/src/domain/webScriptExport.ts +++ b/src/domain/webScriptExport.ts @@ -3,14 +3,14 @@ import { z } from "zod"; import { browserCompletenessSchema } from "./browserCompleteness.js"; import { browserScenarioCompletenessSchema } from "./browserScenarioCaptureValues.js"; import { digestSchema, prefixedDigestSchema } from "./digests.js"; -import { localPathStringSchema } from "./localPath.js"; +import { absoluteLocalPathStringSchema } from "./localPath.js"; /** Export already captured scripts into one absent, caller-selected directory. */ export const exportWebScriptsInputSchema = z.strictObject({ - capture_path: localPathStringSchema.describe( + capture_path: absoluteLocalPathStringSchema.describe( "Absolute filesystem path to saved inspect_web_page or capture_browser_scenario JSON, normalized result or complete Evidence", ), - output_directory: localPathStringSchema.describe( + output_directory: absoluteLocalPathStringSchema.describe( "Absolute absent filesystem directory for verified scripts and manifest; parent directory must exist", ), }); diff --git a/src/javascript/recovery/RecoveryFiles.ts b/src/javascript/recovery/RecoveryFiles.ts index d17937520c..e4830ee3e3 100644 --- a/src/javascript/recovery/RecoveryFiles.ts +++ b/src/javascript/recovery/RecoveryFiles.ts @@ -261,11 +261,6 @@ const recoveryInputLayoutPath = async ( path: string, signal?: AbortSignal, ): Promise => { - if (!isAbsolute(path)) - throw recoveryInputFailure( - "path", - "Select an absolute JavaScript file path", - ); try { checkRecoveryCancellation(signal); const parent = dirname(path); diff --git a/tests/acceptance/nonBinaryComposition.test.ts b/tests/acceptance/nonBinaryComposition.test.ts index 386d0fe71a..5f69868106 100644 --- a/tests/acceptance/nonBinaryComposition.test.ts +++ b/tests/acceptance/nonBinaryComposition.test.ts @@ -241,14 +241,34 @@ cliTest( }); expect(result.exitCode).toBe(1); expect(response.isError).toBe(true); - expect(parseMcpToolError(response)).toEqual({ error: result.json }); expect(result.json).toMatchObject({ - code: "invalid_request", - details: { - operation: "recover_javascript_sources", - issues: [{ path: [field], reason: "invalid_format" }], + code: "VALIDATION_ERROR", + fieldErrors: [ + expect.objectContaining({ + path: field === "path" ? "path" : "outputDirectory", + code: "invalid_format", + }), + ], + }); + expect(parseMcpToolError(response)).toMatchObject({ + error: { + code: "invalid_request", + details: { + operation: "recover_javascript_sources", + issues: [ + { + path: [field], + reason: "invalid_format", + expected: "regex", + }, + ], + }, }, }); + expect(JSON.stringify(result.json)).not.toContain("REA_WAKARU_COMMAND"); + expect(JSON.stringify(parseMcpToolError(response))).not.toContain( + "REA_WAKARU_COMMAND", + ); } }, ); diff --git a/tests/boundary/cli/cliInputSchemaRequired.test.ts b/tests/boundary/cli/cliInputSchemaRequired.test.ts index 747bbc2780..5a9f7a617e 100644 --- a/tests/boundary/cli/cliInputSchemaRequired.test.ts +++ b/tests/boundary/cli/cliInputSchemaRequired.test.ts @@ -158,7 +158,11 @@ describe("CLI advertised input schemas", () => { const validate = ajv.compile(schema.options ?? {}); for (const count of [16, 17]) { const input = { - [field]: Array.from({ length: count }, () => "./selected"), + [field]: Array.from({ length: count }, () => + command === "recover-javascript-sources" + ? "/selected" + : "./selected", + ), }; expect(validate(input)).toBe(count === 16); expect( diff --git a/tests/boundary/filesystem/webScriptExport.test.ts b/tests/boundary/filesystem/webScriptExport.test.ts index 9cbbccf650..a4bb2e544b 100644 --- a/tests/boundary/filesystem/webScriptExport.test.ts +++ b/tests/boundary/filesystem/webScriptExport.test.ts @@ -795,8 +795,7 @@ describe("captured script path validation", () => { { path: ["capture_path"], reason: "invalid_format", - message: - "capture_path must be an absolute filesystem path on this host.", + expected: "regex", }, ], }); @@ -815,8 +814,7 @@ describe("captured script path validation", () => { { path: ["output_directory"], reason: "invalid_format", - message: - "output_directory must be an absolute filesystem path on this host.", + expected: "regex", }, ], }); diff --git a/tests/boundary/mcp/absoluteLocalPathTools.test.ts b/tests/boundary/mcp/absoluteLocalPathTools.test.ts new file mode 100644 index 0000000000..e71e707e6a --- /dev/null +++ b/tests/boundary/mcp/absoluteLocalPathTools.test.ts @@ -0,0 +1,117 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import type { AnySchema } from "ajv"; +import { expect, it } from "vitest"; + +import { JAVASCRIPT_RECOVERY_TOOL_CONTRACTS } from "../../../src/contracts/javascript/javascriptRecoveryToolContracts.js"; +import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "../../../src/contracts/native/binaryDiagnosticsToolContracts.js"; +import { WEB_SCRIPT_TOOL_CONTRACTS } from "../../../src/contracts/webScriptToolContracts.js"; +import { RequestTrackingMcpServer } from "../../../src/server/RequestTrackingMcpServer.js"; +import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js"; + +const CONTRACTS = [ + ...BINARY_DIAGNOSTICS_TOOL_CONTRACTS, + ...WEB_SCRIPT_TOOL_CONTRACTS, + ...JAVASCRIPT_RECOVERY_TOOL_CONTRACTS, +]; + +const PATH_FIELDS = { + inspect_apple_ips: ["path"], + inspect_elf_notes: ["path"], + inspect_kernel_module: ["path"], + inspect_byte_entropy: ["path"], + export_web_scripts: ["capture_path", "output_directory"], + recover_javascript_sources: ["path", "output_directory", "additional_paths"], +} as const; + +const absoluteFile = + process.platform === "win32" ? "C:\\sample.bin" : "/tmp/sample.bin"; +const absoluteDirectory = process.platform === "win32" ? "C:\\out" : "/tmp/out"; + +const contractFor = (name: keyof typeof PATH_FIELDS) => { + const contract = CONTRACTS.find((candidate) => candidate.name === name); + if (contract === undefined) throw new Error(`Missing contract ${name}`); + return contract; +}; + +const validInput = ( + name: keyof typeof PATH_FIELDS, +): Record => { + if (name === "export_web_scripts") + return { + capture_path: absoluteFile, + output_directory: absoluteDirectory, + }; + if (name === "recover_javascript_sources") + return { + path: absoluteFile, + output_directory: absoluteDirectory, + additional_paths: [absoluteFile], + }; + return { path: absoluteFile }; +}; + +const withRelativeField = ( + name: keyof typeof PATH_FIELDS, + field: string, +): Record => ({ + ...validInput(name), + [field]: field === "additional_paths" ? ["sample.bin"] : "sample.bin", +}); + +it("rejects one relative path field at a time on tools/list", async () => { + const server = new RequestTrackingMcpServer({ + name: "absolute-path-schema", + version: "1", + }); + for (const name of Object.keys(PATH_FIELDS) as (keyof typeof PATH_FIELDS)[]) { + const contract = contractFor(name); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + () => ({ content: [] }), + ); + } + const client = new Client({ name: "absolute-path-client", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const checked: string[] = []; + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const listed = await client.listTools(); + for (const name of Object.keys( + PATH_FIELDS, + ) as (keyof typeof PATH_FIELDS)[]) { + const schema = listed.tools.find( + (tool) => tool.name === name, + )?.inputSchema; + expect(schema, name).toBeDefined(); + if (schema === undefined) throw new Error(`Missing schema ${name}`); + const validate = ajv.compile(schema as AnySchema); + expect(validate(validInput(name)), `${name} valid`).toBe(true); + for (const field of PATH_FIELDS[name]) { + expect( + validate(withRelativeField(name, field)), + `${name}.${field}`, + ).toBe(false); + checked.push(`${name}.${field}`); + } + } + } finally { + await client.close(); + await server.close(); + } + expect(checked).toEqual([ + "inspect_apple_ips.path", + "inspect_elf_notes.path", + "inspect_kernel_module.path", + "inspect_byte_entropy.path", + "export_web_scripts.capture_path", + "export_web_scripts.output_directory", + "recover_javascript_sources.path", + "recover_javascript_sources.output_directory", + "recover_javascript_sources.additional_paths", + ]); +}); From b31fc5d5fbc5dc379238c9b839220eb17fa1ef17 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:17:09 +0800 Subject: [PATCH 096/151] fix(cli): advertise domain constraints for selector and path options (#9181) * fix(cli): advertise domain constraints for selector and path options Android class, method, and mapping fields, ADB setting keys, and browser endpoints used bare strings in --schema. They now reuse the domain fields, so empty selectors, relative mapping paths, and malformed setting keys fail in the advertised schema. * fix(cli): keep selector limits when argument parsing rejects them Empty browser and Electron selectors now fail in the CLI parser, so their diagnostics are VALIDATION_ERROR with the one-character minimum. ADB setting keys stay on the selector repair path, which restores maximum 256 and the identifier pattern. * test(cli): assert empty-selector codes without frozen prose Empty browser and Electron selectors still fail as VALIDATION_ERROR too_small on the named field. The tests no longer copy Zod's sentence. * test(cli): compare endpoint diagnostics with the domain schema Browser and Inspector tests take custom issue text from the schema that owns it, and empty capture comparison must name both missing inspection fields. * fix(cli): remove duplicate merged schema import --------- Co-authored-by: Cursor Agent --- src/cli/adbCommands.ts | 6 +- src/cli/androidCommands.ts | 32 ++-- src/cli/argumentErrors.ts | 3 +- src/cli/browserAdvancedCommands.ts | 21 +- src/cli/browserCommands.ts | 37 ++-- src/cli/domainInput.ts | 9 + src/cli/electronCommands.ts | 14 +- src/cli/javascriptRuntimeCommands.ts | 15 +- src/cli/webRuntimeCommands.ts | 30 +-- tests/boundary/cli/adbCli.test.ts | 10 + .../cli/browserInputDiagnostics.test.ts | 181 +++++++++++++----- .../boundary/cli/cliDomainFieldSchema.test.ts | 165 ++++++++++++++++ tests/boundary/cli/electronCli.test.ts | 20 +- 13 files changed, 418 insertions(+), 125 deletions(-) create mode 100644 src/cli/domainInput.ts create mode 100644 tests/boundary/cli/cliDomainFieldSchema.test.ts diff --git a/src/cli/adbCommands.ts b/src/cli/adbCommands.ts index 1773750901..869ceeba55 100644 --- a/src/cli/adbCommands.ts +++ b/src/cli/adbCommands.ts @@ -237,10 +237,8 @@ export const registerAdbCommands = ( description: "Read one settings value from system, secure, or global", args: z.object({ serial, - namespace: z - .enum(["system", "secure", "global"]) - .describe("Settings namespace to read"), - key: z.string().min(1).describe("Setting key to read"), + namespace: adbInputSchemas.read_adb_setting.shape.namespace, + key: adbInputSchemas.read_adb_setting.shape.key, }), run: ({ args }) => execute(CLI_COMMANDS.readAdbSetting, "read_adb_setting", { diff --git a/src/cli/androidCommands.ts b/src/cli/androidCommands.ts index cb377fc6e5..b14264747e 100644 --- a/src/cli/androidCommands.ts +++ b/src/cli/androidCommands.ts @@ -21,6 +21,7 @@ import { AnalysisInputError } from "../domain/analysisErrorCore.js"; import { parseAnalysisInput } from "../application/AnalysisInput.js"; import { androidJniSelectionSchema } from "../domain/android/androidJniBindings.js"; import { androidJniDeliverySchema } from "../domain/android/androidJniQuery.js"; +import { androidInputSchemas } from "../domain/android/androidAnalysis.js"; import { cliIntegerArgument } from "./options.js"; /** Expose the same explicit APK requests through one-shot CLI commands. */ @@ -66,14 +67,10 @@ export const registerAndroidCommands = ( ).then(projectCliAnalysisResult); }), ); - const path = z.string().describe("Local standalone APK path"); - const className = z.string().describe("Exact fully qualified class name"); - const mappingPath = z - .string() - .optional() - .describe( - "Absolute local R8/ProGuard mapping.txt; names are annotated, never replaced", - ); + const path = androidInputSchemas.inspect_android_package.shape.path; + const className = androidInputSchemas.inspect_android_class.shape.class_name; + const mappingPath = + androidInputSchemas.inspect_android_class.shape.mapping_path; const mappingInput = (path: string | undefined) => path === undefined ? {} : { mapping_path: path }; cli.command(CLI_COMMANDS.inspectAndroidJniBindings, { @@ -195,11 +192,7 @@ export const registerAndroidCommands = ( description: "Search APK class names; empty query inventories all classes", args: z.object({ path, - query: z - .string() - .describe( - "Case-sensitive class-name substring; empty lists all classes", - ), + query: androidInputSchemas.search_android_classes.shape.query, }), run: ({ args }) => execute( @@ -231,7 +224,10 @@ export const registerAndroidCommands = ( args: z.object({ path, className, - methodName: z.string().describe("Exact declared method name"), + methodName: + androidInputSchemas.inspect_android_method.shape.method_name.describe( + "Exact declared method name", + ), }), options: z.object({ overloadIndex: cliIntegerArgument(0) @@ -264,12 +260,8 @@ export const registerAndroidCommands = ( "Trace APK using nodes for a defined class/method or an externally invoked DEX method", args: z.object({ path, className }), options: z.object({ - methodName: z - .string() - .optional() - .describe( - "Defined or directly referenced external method; omit for APK class references", - ), + methodName: + androidInputSchemas.trace_android_references.shape.method_name, overloadIndex: cliIntegerArgument(0) .optional() .describe( diff --git a/src/cli/argumentErrors.ts b/src/cli/argumentErrors.ts index f213b861a8..24d57d7469 100644 --- a/src/cli/argumentErrors.ts +++ b/src/cli/argumentErrors.ts @@ -25,7 +25,8 @@ const isAdbSelector = (command: string, field: string): boolean => (["pull-adb-file", "push-adb-file"].includes(command) && field === "device-path") || (command === "start-adb-activity" && - ["action", "component", "data-uri"].includes(field)); + ["action", "component", "data-uri"].includes(field)) || + (command === "read-adb-setting" && field === "key"); /** Preserve parser correction fields without embedding its serialized cause. */ export const presentCliArgumentErrors = (cli: CliInstance): void => { diff --git a/src/cli/browserAdvancedCommands.ts b/src/cli/browserAdvancedCommands.ts index 3bce3bd49a..230de44fcd 100644 --- a/src/cli/browserAdvancedCommands.ts +++ b/src/cli/browserAdvancedCommands.ts @@ -34,6 +34,7 @@ import { import type { JsonValue } from "../domain/jsonValue.js"; import { parseCliJsonInput } from "../cliJsonInput.js"; import type { Logger } from "pino"; +import { cliDomainInput } from "./domainInput.js"; import { cliDecimalField } from "./options.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { withCommandCancellation } from "./commandCancellation.js"; @@ -57,10 +58,12 @@ const registerWebMcp = ( cli.command(CLI_COMMANDS.discoverWebMcpTools, { description: "Passively discover page-declared WebMCP tools", args: z.object({ - endpoint: z - .string() - .describe("Loopback CDP HTTP endpoint or browser WebSocket URL"), - targetId: z.string().describe("Target ID from list-browser-targets"), + endpoint: cliDomainInput( + discoverWebMcpToolsInputSchema.shape.cdp_endpoint, + ), + targetId: discoverWebMcpToolsInputSchema.shape.target_id.describe( + "Target ID from list-browser-targets", + ), }), options: z.object({ ...browserScopeOptions, @@ -172,10 +175,12 @@ const registerScreenshot = ( description: "Capture one visible page viewport within the supplied origin scope", args: z.object({ - endpoint: z - .string() - .describe("Loopback CDP HTTP endpoint or browser WebSocket URL"), - targetId: z.string().describe("Target ID from list-browser-targets"), + endpoint: cliDomainInput( + captureWebScreenshotInputSchema.shape.cdp_endpoint, + ), + targetId: captureWebScreenshotInputSchema.shape.target_id.describe( + "Target ID from list-browser-targets", + ), }), options: z.object({ ...browserScopeOptions, diff --git a/src/cli/browserCommands.ts b/src/cli/browserCommands.ts index 32ba64a6ab..610c1484dc 100644 --- a/src/cli/browserCommands.ts +++ b/src/cli/browserCommands.ts @@ -25,6 +25,7 @@ import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js"; import { observeWebSessionInputSchema } from "../domain/browserSession.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; import type { Logger } from "pino"; +import { cliDomainInput } from "./domainInput.js"; import { cliDecimalField } from "./options.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { @@ -55,9 +56,9 @@ const registerTargetList = ( description: "List page targets from a selected loopback CDP browser, optionally filtered by origin", args: z.object({ - endpoint: z - .string() - .describe("Loopback CDP HTTP endpoint or browser WebSocket URL"), + endpoint: cliDomainInput( + listBrowserTargetsInputSchema.shape.cdp_endpoint, + ), }), options: z.object({ allowedOrigins: browserScopeOptions.allowedOrigins, @@ -103,10 +104,12 @@ const registerPageInspection = ( description: "Passively inspect one page through CDP, defaulting to its current origin", args: z.object({ - endpoint: z - .string() - .describe("Loopback CDP HTTP endpoint or browser WebSocket URL"), - targetId: z.string().describe("Target ID from list-browser-targets"), + endpoint: cliDomainInput( + listBrowserTargetsInputSchema.shape.cdp_endpoint, + ), + targetId: observeWebSessionInputSchema.shape.target_id.describe( + "Target ID from list-browser-targets", + ), }), options: browserPageInspectionOptions, run: ({ args, options }) => @@ -160,10 +163,12 @@ const registerBundleAnalysis = ( description: "Capture and statically analyze a page bundle, defaulting to the selected target origin", args: z.object({ - endpoint: z - .string() - .describe("Loopback CDP HTTP endpoint or browser WebSocket URL"), - targetId: z.string().describe("Target ID from list-browser-targets"), + endpoint: cliDomainInput( + listBrowserTargetsInputSchema.shape.cdp_endpoint, + ), + targetId: observeWebSessionInputSchema.shape.target_id.describe( + "Target ID from list-browser-targets", + ), }), options: z.object({ ...browserScopeOptions, @@ -250,10 +255,12 @@ const registerObservationSession = ( cli.command(CLI_COMMANDS.observeWebSession, { description: "Observe user-driven page navigation during a chosen window", args: z.object({ - endpoint: z - .string() - .describe("Loopback CDP HTTP endpoint or browser WebSocket URL"), - targetId: z.string().describe("Target ID from list-browser-targets"), + endpoint: cliDomainInput( + listBrowserTargetsInputSchema.shape.cdp_endpoint, + ), + targetId: observeWebSessionInputSchema.shape.target_id.describe( + "Target ID from list-browser-targets", + ), }), options: z.object({ ...browserScopeOptions, diff --git a/src/cli/domainInput.ts b/src/cli/domainInput.ts new file mode 100644 index 0000000000..9776383841 --- /dev/null +++ b/src/cli/domainInput.ts @@ -0,0 +1,9 @@ +import { z } from "incur"; + +/** + * CLI argv keeps the domain field's inbound text. A service transform still + * runs when that service parses the same field, and `--schema` matches the + * input view advertised by tools/list. + */ +export const cliDomainInput = (field: z.ZodType): z.ZodType => + field instanceof z.ZodPipe ? (field.def.in as z.ZodType) : field; diff --git a/src/cli/electronCommands.ts b/src/cli/electronCommands.ts index dd2604239a..3a86e6314b 100644 --- a/src/cli/electronCommands.ts +++ b/src/cli/electronCommands.ts @@ -16,6 +16,7 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { projectInputIssues } from "../domain/inputIssueProjection.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { Logger } from "pino"; +import { cliDomainInput } from "./domainInput.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { createElectronFuseService } from "../composition/electronFuses.js"; import { parseCliJsonInput, resolveCliJsonPaths } from "../cliJsonInput.js"; @@ -243,7 +244,9 @@ const registerElectronTargetList = ( description: "List local file pages exposed by the selected Electron CDP endpoint", args: z.object({ - endpoint: z.string().describe("Literal-loopback Electron CDP endpoint"), + endpoint: cliDomainInput( + listElectronTargetsInputSchema.shape.cdp_endpoint, + ).describe("Literal-loopback Electron CDP endpoint"), }), run: ({ args }) => withCommandCancellation((signal) => @@ -286,8 +289,13 @@ const registerElectronPageInspection = ( cli.command(CLI_COMMANDS.inspectElectronPage, { description: "Passively inspect one Electron file page", args: z.object({ - endpoint: z.string().describe("Literal-loopback Electron CDP endpoint"), - targetId: z.string().describe("Target ID from list-electron-targets"), + endpoint: cliDomainInput( + listElectronTargetsInputSchema.shape.cdp_endpoint, + ).describe("Literal-loopback Electron CDP endpoint"), + targetId: z + .string() + .min(1) + .describe("Target ID from list-electron-targets"), }), options: electronPageInspectionOptions, run: ({ args, options }) => diff --git a/src/cli/javascriptRuntimeCommands.ts b/src/cli/javascriptRuntimeCommands.ts index 7b3e949f93..f9831233c1 100644 --- a/src/cli/javascriptRuntimeCommands.ts +++ b/src/cli/javascriptRuntimeCommands.ts @@ -7,6 +7,7 @@ import { } from "../application/javascript/JavaScriptRuntimeObservationService.js"; import { createJavaScriptRuntimeObservationProvider } from "../composition/javascriptRuntimeObservation.js"; import { V8InspectorAdmission } from "../inspector/V8InspectorProvider.js"; +import { cliDomainInput } from "./domainInput.js"; import { cliDecimalField } from "./options.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; @@ -49,7 +50,9 @@ export const registerJavaScriptRuntimeObservationCommands = ( cli.command(CLI_COMMANDS.listJavaScriptRuntimeTargets, { description: "List Node/Electron V8 Inspector targets", args: z.object({ - endpoint: z.string().describe("Literal-loopback Inspector endpoint"), + endpoint: cliDomainInput( + listJavaScriptRuntimeTargetsInputSchema.shape.inspector_endpoint, + ).describe("Literal-loopback Inspector endpoint"), }), run: ({ args }) => withCommandCancellation((signal) => @@ -90,10 +93,12 @@ export const registerJavaScriptRuntimeObservationCommands = ( cli.command(CLI_COMMANDS.observeJavaScriptRuntime, { description: "Passively observe one exact Node/Electron Inspector target", args: z.object({ - endpoint: z.string().describe("Literal-loopback Inspector endpoint"), - targetId: z - .string() - .describe("Target from list-javascript-runtime-targets"), + endpoint: cliDomainInput( + listJavaScriptRuntimeTargetsInputSchema.shape.inspector_endpoint, + ).describe("Literal-loopback Inspector endpoint"), + targetId: observeJavaScriptRuntimeInputSchema.shape.target_id.describe( + "Target from list-javascript-runtime-targets", + ), }), options: observeOptionsSchema, run: ({ args, options }) => diff --git a/src/cli/webRuntimeCommands.ts b/src/cli/webRuntimeCommands.ts index b9278dd5ec..ef4a2020c7 100644 --- a/src/cli/webRuntimeCommands.ts +++ b/src/cli/webRuntimeCommands.ts @@ -1,6 +1,9 @@ import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { Cli, z } from "incur"; import { createWebRuntimeService } from "../composition/webRuntime.js"; +import { cliDomainInput } from "./domainInput.js"; +import { observeWebExecutionInputSchema } from "../domain/webExecution.js"; +import { inspectWebEventListenersInputSchema } from "../domain/webEventListeners.js"; import { browserCliError } from "./browserCliError.js"; import { browserScopeOptions, @@ -9,7 +12,6 @@ import { import { logCliCommand } from "../cliLogging.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { cliDecimalField } from "./options.js"; -import { observeWebExecutionInputSchema } from "../domain/webExecution.js"; import type { Logger } from "pino"; import type { ProgressReporter } from "../application/ProgressReporter.js"; import { withCommandCancellation } from "./commandCancellation.js"; @@ -31,10 +33,12 @@ export const registerWebRuntimeCommands = ( description: "Observe precise page execution; resets counters and temporarily disables optimization", args: z.object({ - endpoint: z.string().describe("Existing loopback browser CDP endpoint"), - targetId: z - .string() - .describe("Selected page target ID from list-browser-targets"), + endpoint: cliDomainInput( + observeWebExecutionInputSchema.shape.cdp_endpoint, + ).describe("Existing loopback browser CDP endpoint"), + targetId: observeWebExecutionInputSchema.shape.target_id.describe( + "Selected page target ID from list-browser-targets", + ), }), options: z.object({ ...browserScopeOptions, @@ -74,13 +78,15 @@ export const registerWebRuntimeCommands = ( description: "Inspect native listener source locations on the first main-document CSS match", args: z.object({ - endpoint: z.string().describe("Existing loopback browser CDP endpoint"), - targetId: z - .string() - .describe("Selected page target ID from list-browser-targets"), - selector: z - .string() - .describe("Native CSS selector; inspect the first main-document match"), + endpoint: cliDomainInput( + inspectWebEventListenersInputSchema.shape.cdp_endpoint, + ).describe("Existing loopback browser CDP endpoint"), + targetId: inspectWebEventListenersInputSchema.shape.target_id.describe( + "Selected page target ID from list-browser-targets", + ), + selector: inspectWebEventListenersInputSchema.shape.selector.describe( + "Native CSS selector; inspect the first main-document match", + ), }), options: z.object({ ...browserScopeOptions }), run: ({ args, options }) => diff --git a/tests/boundary/cli/adbCli.test.ts b/tests/boundary/cli/adbCli.test.ts index 01b77ff3b8..fc6fcfc2fc 100644 --- a/tests/boundary/cli/adbCli.test.ts +++ b/tests/boundary/cli/adbCli.test.ts @@ -409,6 +409,16 @@ it("retains safe structured constraints for ADB selectors", async () => { path: "data-uri", maximum: 2048, }, + { + argv: ["read-adb-setting", "S", "system", "k".repeat(257)], + path: "key", + maximum: 256, + }, + { + argv: ["read-adb-setting", "S", "system", "bad key"], + path: "key", + expected: "regex", + }, ]; for (const { argv, path, expected, maximum } of cases) { const result = await serve([...argv, "--json"], { diff --git a/tests/boundary/cli/browserInputDiagnostics.test.ts b/tests/boundary/cli/browserInputDiagnostics.test.ts index 16b1182fd2..39e5c1106b 100644 --- a/tests/boundary/cli/browserInputDiagnostics.test.ts +++ b/tests/boundary/cli/browserInputDiagnostics.test.ts @@ -1,11 +1,49 @@ import { describe, expect } from "vitest"; +import type { z } from "zod"; +import { + inspectWebPageInputSchema, + listBrowserTargetsInputSchema, +} from "../../../src/domain/browserObservation.js"; +import { listJavaScriptRuntimeTargetsInputSchema } from "../../../src/domain/javascript/javascriptRuntimeObservation.js"; +import { analyzeWebBundleInputSchema } from "../../../src/domain/webBundleAnalysis.js"; +import { discoverWebMcpToolsInputSchema } from "../../../src/domain/webMcpDiscovery.js"; import { workspaceCliTest as cliTest } from "../../support/cli/workspaceCliFixture.js"; const malformedEndpoint = "not a url"; const browserEndpoint = "http://127.0.0.1:9222"; const inspectorEndpoint = "http://127.0.0.1:9229"; +/** The domain schema owns custom issue text. Tests compare the CLI to that issue. */ +const schemaIssue = ( + schema: z.ZodType, + value: unknown, + path: readonly (string | number)[], +) => { + const parsed = schema.safeParse(value); + if (parsed.success) + throw new Error(`domain schema accepted ${path.join(".")}`); + const issue = parsed.error.issues.find( + (item) => item.path.join(".") === path.join("."), + ); + if (issue === undefined) + throw new Error( + `domain schema omitted ${path.join(".")}: ${JSON.stringify(parsed.error.issues)}`, + ); + return issue; +}; + +const malformedEndpointInput = (operation: string) => + operation === "list_browser_targets" + ? { cdp_endpoint: malformedEndpoint } + : { + cdp_endpoint: malformedEndpoint, + target_id: "target", + ...(operation === "analyze_web_bundle" + ? { include_script_sources: true } + : {}), + }; + const invalidInputs = [ { command: "list-browser-targets", @@ -15,15 +53,8 @@ const invalidInputs = [ issue: { path: ["cdp_endpoint"], reason: "invalid_value", - message: "Invalid CDP endpoint URL", }, - }, - { - command: "inspect-web-page", - arguments: [browserEndpoint, ""], - operation: "inspect_web_page", - error: "Browser observation failed", - issue: { path: ["target_id"], reason: "out_of_range", minimum: 1 }, + schema: listBrowserTargetsInputSchema, }, { command: "analyze-web-bundle", @@ -33,33 +64,8 @@ const invalidInputs = [ issue: { path: ["cdp_endpoint"], reason: "invalid_value", - message: "Invalid CDP endpoint URL", }, - }, - { - command: "observe-web-session", - arguments: [browserEndpoint, ""], - operation: "observe_web_session", - error: "Browser observation failed", - issue: { path: ["target_id"], reason: "out_of_range", minimum: 1 }, - }, - { - command: "list-javascript-runtime-targets", - arguments: [""], - operation: "list_javascript_runtime_targets", - error: "JavaScript runtime observation failed", - issue: { - path: ["inspector_endpoint"], - reason: "out_of_range", - minimum: 1, - }, - }, - { - command: "observe-javascript-runtime", - arguments: [inspectorEndpoint, ""], - operation: "observe_javascript_runtime", - error: "JavaScript runtime observation failed", - issue: { path: ["target_id"], reason: "out_of_range", minimum: 1 }, + schema: analyzeWebBundleInputSchema, }, { command: "discover-webmcp-tools", @@ -69,15 +75,36 @@ const invalidInputs = [ issue: { path: ["cdp_endpoint"], reason: "invalid_value", - message: "Invalid CDP endpoint URL", }, + schema: discoverWebMcpToolsInputSchema, + }, +]; + +const parserRejections = [ + { + command: "inspect-web-page", + arguments: [browserEndpoint, ""], + path: "targetId", + }, + { + command: "observe-web-session", + arguments: [browserEndpoint, ""], + path: "targetId", + }, + { + command: "list-javascript-runtime-targets", + arguments: [""], + path: "endpoint", + }, + { + command: "observe-javascript-runtime", + arguments: [inspectorEndpoint, ""], + path: "targetId", }, { command: "capture-web-screenshot", arguments: [browserEndpoint, ""], - operation: "capture_web_screenshot", - error: "Browser observation failed", - issue: { path: ["target_id"], reason: "out_of_range", minimum: 1 }, + path: "targetId", }, ] as const; @@ -97,6 +124,15 @@ describe("inspection branch diagnostics", () => { environment: { ...workspace.environment, REA_LOG_LEVEL: "silent" }, }); expect(result.exitCode).toBe(1); + const owner = schemaIssue( + inspectWebPageInputSchema, + { + cdp_endpoint: malformedEndpoint, + target_id: "target", + include_script_sources: flags.includes("--include-script-sources"), + }, + ["cdp_endpoint"], + ); expect(result.json).toMatchObject({ code: "invalid_request", details: { @@ -105,7 +141,7 @@ describe("inspection branch diagnostics", () => { { path: ["cdp_endpoint"], reason: "invalid_value", - message: "Invalid CDP endpoint URL", + message: owner.message, }, ], }, @@ -124,6 +160,11 @@ describe("browser and Inspector CLI input diagnostics", () => { environment: { ...workspace.environment, REA_LOG_LEVEL: "silent" }, }); + const owner = schemaIssue( + input.schema, + malformedEndpointInput(input.operation), + input.issue.path, + ); expect(result.exitCode).toBe(1); expect(result.json).toMatchObject({ error: input.error, @@ -141,7 +182,9 @@ describe("browser and Inspector CLI input diagnostics", () => { }, details: { operation: input.operation, - issues: expect.arrayContaining([input.issue]), + issues: expect.arrayContaining([ + { ...input.issue, message: owner.message }, + ]), }, }); expect(result.stdout).not.toContain(malformedEndpoint); @@ -163,6 +206,15 @@ describe("browser and Inspector CLI input diagnostics", () => { environment: { ...workspace.environment, REA_LOG_LEVEL: "debug" }, }); + const owner = schemaIssue( + command === "list-browser-targets" + ? listBrowserTargetsInputSchema + : listJavaScriptRuntimeTargetsInputSchema, + command === "list-browser-targets" + ? { cdp_endpoint: endpoint } + : { inspector_endpoint: endpoint }, + [field], + ); expect(result.exitCode).toBe(1); expect(result.json).toMatchObject({ code: "invalid_request", @@ -171,8 +223,7 @@ describe("browser and Inspector CLI input diagnostics", () => { { path: [field], reason: "invalid_value", - message: - "CDP endpoint must be an explicit-port HTTP URL on 127.0.0.1 or ::1", + message: owner.message, }, ], }, @@ -183,7 +234,9 @@ describe("browser and Inspector CLI input diagnostics", () => { expect(output).not.toContain("rea-diagnostic-secret"); }, ); +}); +describe("browser correction facts", () => { cliTest( "retains authored cross-field correction guidance", async ({ cli, workspace }) => { @@ -199,6 +252,16 @@ describe("browser and Inspector CLI input diagnostics", () => { environment: { ...workspace.environment, REA_LOG_LEVEL: "silent" }, }); + const owner = schemaIssue( + inspectWebPageInputSchema, + { + cdp_endpoint: browserEndpoint, + target_id: "target", + include_script_sources: false, + include_storage_fingerprints: true, + }, + ["include_storage_fingerprints"], + ); expect(result.exitCode).toBe(1); expect(result.json).toMatchObject({ code: "invalid_request", @@ -208,7 +271,7 @@ describe("browser and Inspector CLI input diagnostics", () => { { path: ["include_storage_fingerprints"], reason: "invalid_value", - message: "Storage fingerprints require storage key capture", + message: owner.message, }, ]), }, @@ -231,7 +294,14 @@ describe("browser and Inspector CLI input diagnostics", () => { details: { operation: "compare_web_captures", issues: expect.arrayContaining([ - expect.objectContaining({ reason: expect.any(String) }), + expect.objectContaining({ + path: ["before", "inspection"], + reason: "missing_argument", + }), + expect.objectContaining({ + path: ["after", "inspection"], + reason: "missing_argument", + }), ]), }, }); @@ -262,3 +332,26 @@ describe("browser and Inspector CLI input diagnostics", () => { }, ); }); + +describe("empty browser and Inspector selectors", () => { + cliTest.for(parserRejections)( + "$command rejects an empty selector in argument parsing", + async (input, { cli, workspace }) => { + const result = await cli.run({ + arguments: [input.command, ...input.arguments, "--json"], + cwd: workspace.root, + environment: { ...workspace.environment, REA_LOG_LEVEL: "silent" }, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: [ + { + code: "too_small", + path: input.path, + }, + ], + }); + }, + ); +}); diff --git a/tests/boundary/cli/cliDomainFieldSchema.test.ts b/tests/boundary/cli/cliDomainFieldSchema.test.ts new file mode 100644 index 0000000000..6f953dcd55 --- /dev/null +++ b/tests/boundary/cli/cliDomainFieldSchema.test.ts @@ -0,0 +1,165 @@ +import { Ajv2020 } from "ajv/dist/2020.js"; +import type { AnySchema } from "ajv"; +import { Cli, z } from "incur"; +import { expect, it } from "vitest"; + +import { createCli } from "../../../src/cli.js"; + +type CliInstance = ReturnType; +type CommandMap = NonNullable>; + +const registeredCommands: WeakMap = Cli.toCommands; + +const serve = async (cli: CliInstance, argv: readonly string[]) => { + let stdout = ""; + let exitCode = 0; + await cli.serve([...argv], { + env: {}, + exit: (code) => { + exitCode = code; + }, + stdout: (text) => { + stdout += text; + }, + }); + expect(exitCode, argv.join(" ")).toBe(0); + return z + .object({ + args: z + .object({ properties: z.record(z.string(), z.unknown()) }) + .optional(), + options: z + .object({ properties: z.record(z.string(), z.unknown()) }) + .optional(), + }) + .parse(JSON.parse(stdout)); +}; + +const absoluteMapping = + process.platform === "win32" ? "C:\\mapping.txt" : "/tmp/mapping.txt"; + +it("advertises the domain constraints for android, adb settings, and browser selectors", async () => { + const cli = createCli({}); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const validate = async ( + command: string, + group: "args" | "options", + value: Record, + ) => { + const advertised = await serve(cli, [command, "--schema", "--json"]); + const schema = advertised[group]; + expect(schema, command).toBeDefined(); + return ajv.compile(schema as AnySchema)(value); + }; + + expect( + await validate("inspect-android-class", "args", { + path: "", + className: "com.example.Main", + }), + ).toBe(false); + expect( + await validate("inspect-android-class", "args", { + path: "app.apk", + className: "", + }), + ).toBe(false); + expect( + await validate("inspect-android-class", "args", { + path: "app.apk", + className: "com.example.Main", + }), + ).toBe(true); + expect( + await validate("inspect-android-class", "options", { + mappingPath: "mapping.txt", + }), + ).toBe(false); + expect( + await validate("inspect-android-class", "options", { + mappingPath: absoluteMapping, + }), + ).toBe(true); + expect( + await validate("inspect-android-method", "args", { + path: "app.apk", + className: "com.example.Main", + methodName: "", + }), + ).toBe(false); + + const setting = { + serial: "emulator-5554", + namespace: "system", + }; + expect( + await validate("read-adb-setting", "args", { + ...setting, + key: "key with space", + }), + ).toBe(false); + expect( + await validate("read-adb-setting", "args", { + ...setting, + key: "k".repeat(257), + }), + ).toBe(false); + expect( + await validate("read-adb-setting", "args", { + ...setting, + key: "airplane_mode_on", + }), + ).toBe(true); + + const listener = { + targetId: "page", + selector: "button", + }; + expect( + await validate("inspect-web-event-listeners", "args", { + ...listener, + endpoint: "", + }), + ).toBe(false); + expect( + await validate("inspect-web-event-listeners", "args", { + endpoint: "http://127.0.0.1:9222", + targetId: "", + selector: "button", + }), + ).toBe(false); + expect( + await validate("inspect-web-event-listeners", "args", { + endpoint: "http://127.0.0.1:9222", + ...listener, + selector: "", + }), + ).toBe(false); + expect( + await validate("inspect-web-event-listeners", "args", { + endpoint: "http://127.0.0.1:9222", + ...listener, + }), + ).toBe(true); + expect( + await validate("capture-web-screenshot", "args", { + endpoint: "", + targetId: "page", + }), + ).toBe(false); + expect( + await validate("capture-web-screenshot", "args", { + endpoint: "http://127.0.0.1:9222", + targetId: "page", + }), + ).toBe(true); + + const parser = registeredCommands.get(cli)?.get("inspect-android-class"); + expect(parser && "args" in parser && parser.args instanceof z.ZodObject).toBe( + true, + ); + if (parser && "args" in parser && parser.args instanceof z.ZodObject) + expect( + parser.args.safeParse({ path: "app.apk", className: "" }).success, + ).toBe(false); +}); diff --git a/tests/boundary/cli/electronCli.test.ts b/tests/boundary/cli/electronCli.test.ts index d5e2e274a5..4b846b6485 100644 --- a/tests/boundary/cli/electronCli.test.ts +++ b/tests/boundary/cli/electronCli.test.ts @@ -148,19 +148,13 @@ it( expect(invalidPageInspect.signal).toBeNull(); expect(invalidPageInspect.exitCode).toBe(1); expect(invalidPageInspect.json).toMatchObject({ - error: "Electron analysis failed", - code: "invalid_request", - category: "invalid_input", - details: { - operation: "inspect_electron_page", - issues: [ - { - path: ["target_id"], - reason: "out_of_range", - minimum: 1, - }, - ], - }, + code: "VALIDATION_ERROR", + fieldErrors: [ + { + code: "too_small", + path: "targetId", + }, + ], }); }, INTEGRATION_TEST_TIMEOUT_MS, From 441ba94086e899f8e8bb159fea2bd8cee617a019 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:22:21 +0800 Subject: [PATCH 097/151] fix: preserve source closure during snapshot replay (#8870) * fix: preserve source closure during snapshot replay * fix: revalidate restored snapshot sources lazily * fix: bound and share source-closure checks --- src/application/DirectAnalysis.ts | 28 ++- .../binary/AnalysisSnapshotCache.test.ts | 151 ++++++++++++- .../binary/AnalysisSnapshotCache.ts | 88 +++++--- .../binary/AnalysisSnapshotReplayBinding.ts | 9 +- .../binary/BinarySessionRecords.ts | 6 +- .../InvestigationEvidence.test.ts | 52 ++++- .../investigation/InvestigationEvidence.ts | 76 +++++-- .../directAnalysisSnapshotReplay.test.ts | 105 ++++++++- .../mcp/workflowSnapshotReplayMcp.test.ts | 209 +++++++++++++++++- 9 files changed, 661 insertions(+), 63 deletions(-) diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index eb486c3f17..08325bcf97 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -26,6 +26,7 @@ import { type AnalysisResultWithSources, } from "./investigation/recordEvidenceSources.js"; import { resolveInvestigationEvidenceClosure } from "./investigation/InvestigationEvidence.js"; +import { createInvestigationEvidenceClosureChecker } from "./investigation/InvestigationEvidence.js"; import type { Evidence } from "../domain/evidence.js"; import type { NativeToolName } from "../contracts/native/nativeToolContracts.js"; import type { EnhancedToolName } from "../contracts/enhancedInputs.js"; @@ -500,15 +501,26 @@ const runAnalysis = async ( evidenceProfile, }); if (cached !== undefined) { - const evidence = withEvidenceCallerInputPath(cached, path); - const sources = captureSources( - evidence, - snapshot.evidence_bundle.records, + const records = new Map( + snapshot.evidence_bundle.records.map((record) => [ + record.evidence_id, + record, + ]), ); - if (!sources.ok) - return err(sources.error.retainPartialObservation(evidence)); - options.onEvidenceBundle?.(snapshot.evidence_bundle); - return ok(evidence); + const checkClosure = createInvestigationEvidenceClosureChecker({ + evidenceForAnalysis: (id) => records.get(id), + }); + if (checkClosure(cached.evidence_links)) { + const evidence = withEvidenceCallerInputPath(cached, path); + const sources = captureSources( + evidence, + snapshot.evidence_bundle.records, + ); + if (!sources.ok) + return err(sources.error.retainPartialObservation(evidence)); + options.onEvidenceBundle?.(snapshot.evidence_bundle); + return ok(evidence); + } } } } diff --git a/src/application/binary/AnalysisSnapshotCache.test.ts b/src/application/binary/AnalysisSnapshotCache.test.ts index 40e823c817..2858552cdf 100644 --- a/src/application/binary/AnalysisSnapshotCache.test.ts +++ b/src/application/binary/AnalysisSnapshotCache.test.ts @@ -7,7 +7,8 @@ import { ANALYSIS_SNAPSHOT_PROVIDER, ANALYSIS_SNAPSHOT_TARGET, } from "../../domain/analysisSnapshot.fixture.js"; -import { createEvidence } from "../../domain/evidence.js"; +import { createEvidence, immutableEvidence } from "../../domain/evidence.js"; +import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js"; import { createEvidenceBundle, evidenceBundleForTarget, @@ -1304,6 +1305,154 @@ it("declines source-bearing snapshot entries while replaying omitted and empty s expect(cache.entries()).toHaveLength(2); }); +it("replays source-bearing entries only when their complete retained closure exists", () => { + const target = ANALYSIS_SNAPSHOT_TARGET; + const profile = ANALYSIS_SNAPSHOT_PROFILE; + const leaf = immutableEvidence( + createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, { + operation: "leaf", + parameters: {}, + result: "leaf observation", + }), + ); + const source = immutableEvidence( + createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, { + operation: "source", + parameters: {}, + result: "source observation", + evidenceLinks: [leaf.evidence_id], + }), + ); + const parameters = { address: "0x1000", document: "fixture" }; + const execution = createAnalysisExecution( + "parent observation", + ANALYSIS_SNAPSHOT_PROVIDER, + { + subject: target, + analysisProfile: profile, + sourceEvidence: [source], + }, + ); + const parent = immutableEvidence( + createAnalysisExecutionEvidence( + "procedure_info", + parameters, + execution, + "observed", + ), + ); + const complete = new Map( + [parent, source, leaf].map((record) => [record.evidence_id, record]), + ); + const input = { + target, + profile, + operation: "procedure_info" as const, + parameters, + execution: { ...execution, evidenceId: parent.evidence_id }, + }; + const missingLeaf = new AnalysisSnapshotCache(); + missingLeaf.record(input, (id) => + id === leaf.evidence_id ? undefined : complete.get(id), + ); + expect( + missingLeaf.lookup(target, profile, "procedure_info", parameters), + ).toBeUndefined(); + const cache = new AnalysisSnapshotCache(); + cache.record(input, (id) => complete.get(id)); + expect( + cache + .lookup(target, profile, "procedure_info", parameters) + ?.sourceEvidence?.map((record) => record.evidence_id), + ).toEqual([source.evidence_id, parent.evidence_id]); + if (source.subject === null) throw new Error("Expected source subject"); + const ledger = new EvidenceLedger(); + for (const record of [parent, source, leaf]) + expect(ledger.record(record).ok).toBe(true); + const movedSource = { + ...source, + subject: { + ...source.subject, + local_path: "/relocated/source", + name: "source", + }, + }; + const replacement = ledger.import(createEvidenceBundle([movedSource])); + if (!replacement.ok) throw replacement.error; + cache.refreshEvidenceMetadata(replacement.value.replacements); + expect( + cache.lookup(target, profile, "procedure_info", parameters) + ?.sourceEvidence?.[0]?.subject?.local_path, + ).toBe("/relocated/source"); + const exported = cache.export(target, profile, ledger.export()); + if (!exported.ok) throw exported.error; + const imported = new AnalysisSnapshotCache(); + imported.stage(exported.value); + expect( + imported + .lookup(target, profile, "procedure_info", parameters) + ?.sourceEvidence?.map((record) => record.evidence_id), + ).toEqual([source.evidence_id, parent.evidence_id]); + expect( + imported.lookup(target, profile, "procedure_info", parameters) + ?.sourceEvidence?.[0]?.subject?.local_path, + ).toBe("/relocated/source"); + const pruned = parseAnalysisSnapshot({ + ...exported.value, + evidence_bundle: createEvidenceBundle([parent, movedSource]), + }); + const incomplete = new AnalysisSnapshotCache(); + incomplete.stage(pruned); + const partial = new Map( + [parent, movedSource].map((record) => [record.evidence_id, record]), + ); + expect( + incomplete.lookup(target, profile, "procedure_info", parameters, (id) => + partial.get(id), + ), + ).toBeUndefined(); + partial.set(leaf.evidence_id, leaf); + expect( + incomplete + .lookup(target, profile, "procedure_info", parameters, (id) => + partial.get(id), + ) + ?.sourceEvidence?.map((record) => record.evidence_id), + ).toEqual([source.evidence_id, parent.evidence_id]); + + const otherSource = immutableEvidence( + createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, { + operation: "other_source", + parameters: {}, + result: "different source", + }), + ); + const otherParent = immutableEvidence( + createAnalysisExecutionEvidence( + "procedure_info", + parameters, + { ...execution, sourceEvidence: [otherSource] }, + "observed", + ), + ); + const records = [parent, movedSource, leaf, otherParent, otherSource]; + const ambiguous = new AnalysisSnapshotCache(); + ambiguous.stage( + parseAnalysisSnapshot({ + ...exported.value, + evidence_bundle: createEvidenceBundle(records), + }), + ); + const allRecords = new Map( + records.map((record) => [record.evidence_id, record]), + ); + expect( + ambiguous.lookup(target, profile, "procedure_info", parameters, (id) => + allRecords.get(id), + ), + ).toBeUndefined(); +}); + describe("executions that report truncated references", () => { const record = (referencesTruncated: boolean | undefined) => { const cache = new AnalysisSnapshotCache(); diff --git a/src/application/binary/AnalysisSnapshotCache.ts b/src/application/binary/AnalysisSnapshotCache.ts index 49dabfbffc..d0b5e1883a 100644 --- a/src/application/binary/AnalysisSnapshotCache.ts +++ b/src/application/binary/AnalysisSnapshotCache.ts @@ -46,6 +46,7 @@ import { snapshotReplayBinding, type SnapshotReplayBinding, } from "./AnalysisSnapshotReplayBinding.js"; +import { createInvestigationEvidenceClosureChecker } from "../investigation/InvestigationEvidence.js"; const RELATIONSHIP_OPERATIONS: ReadonlySet = new Set([ "procedure_callers", @@ -162,9 +163,12 @@ export const SNAPSHOT_REPLAY_LIMITATION = /** Bounded in-memory cache for one immutable binary identity. */ export class AnalysisSnapshotCache { readonly #entries = new Map(); - // Null means imported provenance cannot be faithfully replayed. It is distinct - // from an entry recorded before an Evidence ID was assigned. - readonly #replayBindings = new Map(); + // Null is ambiguous provenance; a pending ID is an exact parent whose source + // closure was missing and can be checked again against the owning ledger. + readonly #replayBindings = new Map< + string, + SnapshotReplayBinding | { readonly pendingEvidenceId: string } | null + >(); readonly #workflowEntries = new Map(); #target: AnalysisSnapshotTarget | undefined; #binding: AnalysisSnapshotBinding | undefined; @@ -233,6 +237,10 @@ export class AnalysisSnapshotCache { record, ]), ); + const lookup = (id: string) => records.get(id); + const checkClosure = createInvestigationEvidenceClosureChecker({ + evidenceForAnalysis: lookup, + }); const ambiguous = new Set(); for (const evidence of snapshot.evidence_bundle.records) { const queryId = analysisQueryId( @@ -257,11 +265,15 @@ export class AnalysisSnapshotCache { else evidenceByQuery.set(queryId, evidence); } for (const [queryId, evidence] of evidenceByQuery) { + if (ambiguous.has(queryId)) { + this.#replayBindings.set(queryId, null); + continue; + } this.#replayBindings.set( queryId, - ambiguous.has(queryId) - ? null - : snapshotReplayBinding(evidence, records), + snapshotReplayBinding(evidence, lookup, checkClosure) ?? { + pendingEvidenceId: evidence.evidence_id, + }, ); } for (const entry of snapshot.workflow_entries) { @@ -287,7 +299,12 @@ export class AnalysisSnapshotCache { replacements.map(({ current }) => [current.evidence_id, current]), ); for (const [queryId, binding] of this.#replayBindings) { - if (binding === null || binding.sourceEvidence === undefined) continue; + if ( + binding === null || + "pendingEvidenceId" in binding || + binding.sourceEvidence === undefined + ) + continue; const sources = binding.sourceEvidence; if (!sources.some((source) => currentRecords.has(source.evidence_id))) continue; @@ -490,6 +507,7 @@ export class AnalysisSnapshotCache { profile: AnalysisProfileCommitment, operation: AnalysisOperation, parameters: Readonly>, + lookup?: (id: string) => Evidence | undefined, ): AnalysisExecution | undefined { const queryId = analysisQueryId( snapshotTarget(target), @@ -499,10 +517,18 @@ export class AnalysisSnapshotCache { ); const cached = this.#entries.get(queryId); if (cached === undefined) return undefined; - const binding = this.#replayBindings.get(queryId); + let binding = this.#replayBindings.get(queryId); // Do not turn a refused provenance selection into a fresh, unlinked record. // The provider must produce a new observation before this query can replay. if (binding === null) return undefined; + if (binding !== undefined && "pendingEvidenceId" in binding) { + const parent = lookup?.(binding.pendingEvidenceId); + if (parent === undefined || lookup === undefined) return undefined; + const restored = snapshotReplayBinding(parent, lookup); + if (restored === null) return undefined; + this.#replayBindings.set(queryId, restored); + binding = restored; + } const cachedSubject = cached.execution.subject; const subject = cachedSubject == null @@ -550,22 +576,31 @@ export class AnalysisSnapshotCache { } /** Record one successful immutable call unless the cache is full. */ - record(input: { - readonly target: BinaryTarget; - readonly profile: AnalysisProfileCommitment; - readonly operation: AnalysisOperation; - readonly parameters: Readonly>; - readonly execution: AnalysisExecution; - readonly evidence?: Evidence; - }): void { + record( + input: { + readonly target: BinaryTarget; + readonly profile: AnalysisProfileCommitment; + readonly operation: AnalysisOperation; + readonly parameters: Readonly>; + readonly execution: AnalysisExecution; + readonly evidence?: Evidence; + }, + lookup?: (id: string) => Evidence | undefined, + ): void { const { target, profile, operation, parameters, execution } = input; this.select(target, profile); // The snapshot format does not carry reference truncation; a replay would // present a capped read as complete, so it is not recorded. if (execution.referencesTruncated === true) return; - // Provider snapshot entries do not carry execution source records. Replay - // must not discard the source closure that supplies the parent's Evidence links. - if ((execution.sourceEvidence?.length ?? 0) > 0) return; + let sourceBinding: SnapshotReplayBinding | undefined; + if ((execution.sourceEvidence?.length ?? 0) > 0) { + if (execution.evidenceId === undefined || lookup === undefined) return; + const parent = lookup(execution.evidenceId); + if (parent === undefined) return; + const closed = snapshotReplayBinding(parent, lookup); + if (closed === null) return; + sourceBinding = closed; + } const queryId = analysisQueryId( snapshotTarget(target), snapshotBinding(profile), @@ -597,12 +632,15 @@ export class AnalysisSnapshotCache { if (execution.evidenceId === undefined) this.#replayBindings.delete(entry.query_id); else - this.#replayBindings.set(entry.query_id, { - evidenceId: execution.evidenceId, - ...(input.evidence === undefined - ? {} - : { sourceEvidence: Object.freeze([input.evidence]) }), - }); + this.#replayBindings.set( + entry.query_id, + sourceBinding ?? { + evidenceId: execution.evidenceId, + ...(input.evidence === undefined + ? {} + : { sourceEvidence: Object.freeze([input.evidence]) }), + }, + ); } /** Forget all target-bound entries. */ diff --git a/src/application/binary/AnalysisSnapshotReplayBinding.ts b/src/application/binary/AnalysisSnapshotReplayBinding.ts index 5d6dfc2027..c24d7549e3 100644 --- a/src/application/binary/AnalysisSnapshotReplayBinding.ts +++ b/src/application/binary/AnalysisSnapshotReplayBinding.ts @@ -1,4 +1,5 @@ import type { Evidence } from "../../domain/evidence.js"; +import { createInvestigationEvidenceClosureChecker } from "../investigation/InvestigationEvidence.js"; /** Authenticated observation identity and its immediate source records. */ export interface SnapshotReplayBinding { @@ -9,11 +10,15 @@ export interface SnapshotReplayBinding { /** Preserve direct links using the snapshot's authenticated immutable records. */ export const snapshotReplayBinding = ( evidence: Evidence, - records: ReadonlyMap, + lookup: (id: string) => Evidence | undefined, + checkClosure = createInvestigationEvidenceClosureChecker({ + evidenceForAnalysis: lookup, + }), ): SnapshotReplayBinding | null => { + if (!checkClosure(evidence.evidence_links)) return null; const sources: Evidence[] = []; for (const id of evidence.evidence_links) { - const source = records.get(id); + const source = lookup(id); // An external link without its source cannot be represented by sourceEvidence. if (source === undefined) return null; sources.push(source); diff --git a/src/application/binary/BinarySessionRecords.ts b/src/application/binary/BinarySessionRecords.ts index 3333bc37d6..4289861d37 100644 --- a/src/application/binary/BinarySessionRecords.ts +++ b/src/application/binary/BinarySessionRecords.ts @@ -323,14 +323,16 @@ export abstract class BinarySessionRecords { >, ): AnalysisExecution | undefined { if (this.#snapshotInvalidated) return undefined; - return this.#snapshot.lookup(target, profile, operation, parameters); + return this.#snapshot.lookup(target, profile, operation, parameters, (id) => + this.evidenceForAnalysis(id), + ); } protected recordSnapshot( input: Parameters[0], ): void { if (this.#snapshotInvalidated) return; - this.#snapshot.record(input); + this.#snapshot.record(input, (id) => this.evidenceForAnalysis(id)); this.#emitSnapshotChanged(); } diff --git a/src/application/investigation/InvestigationEvidence.test.ts b/src/application/investigation/InvestigationEvidence.test.ts index be2721fe9a..f62926e093 100644 --- a/src/application/investigation/InvestigationEvidence.test.ts +++ b/src/application/investigation/InvestigationEvidence.test.ts @@ -5,7 +5,10 @@ import { createEvidenceBundle } from "../../domain/evidenceBundle.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js"; import { InvestigationRecords } from "./InvestigationRecords.js"; -import { resolveInvestigationEvidenceClosure } from "./InvestigationEvidence.js"; +import { + createInvestigationEvidenceClosureChecker, + resolveInvestigationEvidenceClosure, +} from "./InvestigationEvidence.js"; const observation = (path: string, links: readonly string[] = []): Evidence => createEvidence( @@ -108,3 +111,50 @@ it("rejects a lookup that returns another semantic Evidence identity", () => { }, }); }); + +it("resolves an authenticated source with a wide repeated-link array", () => { + const leaf = observation("/wide-leaf"); + const source = observation( + "/wide-source", + Array(500_000).fill(leaf.evidence_id), + ); + expect(source.evidence_links).toHaveLength(500_000); + const records = new InvestigationRecords(); + for (const record of [leaf, source]) { + const added = records.recordEvidence(record); + if (!added.ok) throw added.error; + } + expect( + records.evidenceForAnalysis(source.evidence_id)?.evidence_links, + ).toHaveLength(500_000); + expect( + resolveInvestigationEvidenceClosure(records, [source.evidence_id]), + ).toEqual({ + ok: true, + value: [source.evidence_id, leaf.evidence_id].sort(), + }); +}); + +it("reuses a complete shared source graph without weakening missing-source checks", () => { + const leaf = observation("/shared-leaf"); + const source = observation("/shared-source", [leaf.evidence_id]); + const records = new Map( + [leaf, source].map((record) => [record.evidence_id, record]), + ); + let lookups = 0; + const check = createInvestigationEvidenceClosureChecker({ + evidenceForAnalysis: (id) => { + lookups++; + return records.get(id); + }, + }); + expect(check([source.evidence_id])).toBe(true); + expect(lookups).toBe(2); + expect(check([source.evidence_id, leaf.evidence_id])).toBe(true); + expect(lookups).toBe(2); + const missing = `ev_${"0".repeat(64)}`; + expect(check([missing])).toBe(false); + expect(check([missing])).toBe(false); + expect(lookups).toBe(3); + expect(check([source.evidence_id, missing])).toBe(false); +}); diff --git a/src/application/investigation/InvestigationEvidence.ts b/src/application/investigation/InvestigationEvidence.ts index d06175b4ca..819231b530 100644 --- a/src/application/investigation/InvestigationEvidence.ts +++ b/src/application/investigation/InvestigationEvidence.ts @@ -5,6 +5,37 @@ import type { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; import type { Evidence } from "../../domain/evidence.js"; import { ok, type Result } from "../../domain/result.js"; +type Lookup = (id: string) => Evidence | undefined; + +/** Traverse without expanding a wide link array into function arguments. */ +const visitClosure = ( + lookup: Lookup, + seedIds: readonly string[], + knownComplete?: ReadonlySet, +): Result, EvidenceIntegrityError> => { + const visited = new Set(); + const pending = [...seedIds]; + while (pending.length > 0) { + const evidenceId = pending.pop(); + if ( + evidenceId === undefined || + visited.has(evidenceId) || + knownComplete?.has(evidenceId) + ) + continue; + const evidence = resolveEvidenceInput( + { kind: "retained-evidence", evidence_id: evidenceId }, + lookup, + "retained-closure", + ); + if (!evidence.ok) return evidence; + visited.add(evidenceId); + for (const linkedId of evidence.value.evidence_links) + pending.push(linkedId); + } + return ok(visited); +}; + /** Follow exact session references without exporting unrelated records or Unknowns. */ export const resolveInvestigationEvidenceClosure = ( reader: Pick, @@ -16,21 +47,32 @@ export const resolveInvestigationEvidenceClosure = ( const supplied = new Map( sources.map((source) => [source.evidence_id, source]), ); - const lookup = (id: string) => - supplied.get(id) ?? reader.evidenceForAnalysis(id); - const visited = new Set(); - const pending = [...seedIds]; - while (pending.length > 0) { - const evidenceId = pending.pop(); - if (evidenceId === undefined || visited.has(evidenceId)) continue; - const evidence = resolveEvidenceInput( - { kind: "retained-evidence", evidence_id: evidenceId }, - lookup, - "retained-closure", - ); - if (!evidence.ok) return evidence; - visited.add(evidenceId); - for (const link of evidence.value.evidence_links) pending.push(link); - } - return ok(uniqueSorted([...visited])); + const visited = visitClosure( + (id) => supplied.get(id) ?? reader.evidenceForAnalysis(id), + seedIds, + ); + return visited.ok ? ok(uniqueSorted([...visited.value])) : visited; +}; + +/** Reuse closure decisions while the caller's lookup remains immutable. */ +export const createInvestigationEvidenceClosureChecker = ( + reader: Pick, +): ((seedIds: readonly string[]) => boolean) => { + const complete = new Set(); + const incomplete = new Set(); + return (seedIds) => { + const single = seedIds.length === 1 ? seedIds[0] : undefined; + if (single !== undefined && incomplete.has(single)) return false; + const visited = visitClosure( + (id) => reader.evidenceForAnalysis(id), + seedIds, + complete, + ); + if (!visited.ok) { + if (single !== undefined) incomplete.add(single); + return false; + } + for (const id of visited.value) complete.add(id); + return true; + }; }; diff --git a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts index 748fe72662..1479df1d3b 100644 --- a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts +++ b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts @@ -131,6 +131,19 @@ const expectWorkflowQuestionsRetained = ( ); }; +const expectSourceSnapshotRetained = (snapshot: AnalysisSnapshot) => { + expect(snapshot.entries).toHaveLength(1); + const parent = snapshot.evidence_bundle.records.find( + (record) => record.operation === "list_strings", + ); + expect(parent?.evidence_links).toHaveLength(1); + expect(snapshot.evidence_bundle.records).toEqual( + expect.arrayContaining([ + expect.objectContaining({ evidence_id: parent?.evidence_links[0] }), + ]), + ); +}; + interface SnapshotProviderTrace { readonly starts: string[]; readonly queries: Array<{ @@ -145,6 +158,7 @@ interface SnapshotProviderOptions { readonly identity?: ProviderIdentity; readonly supportedOperations?: readonly CapabilityDescriptor["operation"][]; readonly limitations?: readonly string[]; + readonly sourceEvidence?: boolean; } const makeProvider = ( @@ -181,7 +195,7 @@ const makeProvider = ( trace.resolutions.push(profile.digest); return ok({ profile }); }, - createClient: () => { + createClient: (target) => { trace.starts.push("start"); return { execute: async (operation, parameters) => { @@ -242,6 +256,17 @@ const makeProvider = ( analysisProfile: profile, rawResult: result, limitations: options.limitations ?? [], + ...(options.sourceEvidence + ? { + sourceEvidence: [ + createEvidence(target, profile.provider, { + operation: "health", + parameters: {}, + result: "fixture source", + }), + ], + } + : {}), }), ); }, @@ -550,13 +575,18 @@ const withEarlierHistoricalEvidence = ( }; describe("snapshot output cleanup lifecycle", () => { - it.each(["cleanup", "cancelled"] as const)( - "preserves the committed snapshot receipt after %s finalization and retry", - async (failureKind) => { + it.each([ + { failureKind: "cleanup", sourceEvidence: false }, + { failureKind: "cancelled", sourceEvidence: false }, + { failureKind: "cleanup", sourceEvidence: true }, + { failureKind: "cancelled", sourceEvidence: true }, + ] as const)( + "preserves a committed snapshot after $failureKind finalization with source=$sourceEvidence", + async ({ failureKind, sourceEvidence }) => { const fixture = await createSnapshotReplayFixture( "rea-snapshot-close-failure-", ); - const provider = fixture.createProvider(); + const provider = fixture.createProvider({ sourceEvidence }); const failure = failureKind === "cleanup" ? new ProviderCleanupError( @@ -587,7 +617,9 @@ describe("snapshot output cleanup lifecycle", () => { try { const opened = await session.open(fixture.path); if (!opened.ok) throw opened.error; - const analyzed = await session.execute("list_segments", {}); + const analyzed = sourceEvidence + ? await session.execute("list_strings", { document: "fixture" }) + : await session.execute("list_segments", {}); if (!analyzed.ok) throw analyzed.error; const closed = await session.closeWithSnapshot( fixture.snapshotPath, @@ -598,6 +630,7 @@ describe("snapshot output cleanup lifecycle", () => { const committed = await readFile(fixture.snapshotPath); const snapshot = await readAnalysisSnapshot(fixture.snapshotPath); if (!snapshot.ok) throw snapshot.error; + if (sourceEvidence) expectSourceSnapshotRetained(snapshot.value); const receipt = { path: fixture.snapshotPath, bytes: committed.byteLength, @@ -1837,6 +1870,66 @@ it("retains a native dispatch workflow snapshot without a deep provider profile" ); }); +it("replays source-bearing direct analysis only while snapshot source Evidence is present", async () => { + const fixture = await createSnapshotReplayFixture( + "rea-direct-source-replay-", + ); + const provider = fixture.createProvider({ + supportedOperations: ["procedure_pseudo_code"], + sourceEvidence: true, + }); + const dependencies = fixture.dependencies(provider); + const parameters = { procedure: "0x1000", document: "fixture" }; + const options = { snapshotPath: fixture.snapshotPath }; + const run = () => + runDirectAnalysis( + dependencies, + fixture.path, + "procedure_pseudo_code", + parameters, + options, + ).then(projectCliAnalysisResult); + const first = parseEvidence(await run()); + expect(first.evidence_links).toHaveLength(1); + const saved = await readAnalysisSnapshot(fixture.snapshotPath); + if (!saved.ok) throw saved.error; + expect(saved.value.entries).toHaveLength(1); + const sourceId = first.evidence_links[0]; + expect( + saved.value.evidence_bundle.records.some( + (record) => record.evidence_id === sourceId, + ), + ).toBe(true); + const initialStarts = fixture.starts.length; + expect(parseEvidence(await run()).evidence_links).toEqual( + first.evidence_links, + ); + expect(fixture.starts).toHaveLength(initialStarts); + + const withoutSource = { + ...saved.value, + evidence_bundle: createEvidenceBundle( + saved.value.evidence_bundle.records.filter( + (record) => record.evidence_id !== sourceId, + ), + ), + }; + const written = await withArtifactResourceScope((resources) => + writeAnalysisSnapshot(withoutSource, fixture.snapshotPath, true, resources), + ); + if (!written.ok) throw written.error; + const refreshed = parseEvidence(await run()); + expect(fixture.starts).toHaveLength(initialStarts + 1); + expect(refreshed.evidence_links).toEqual(first.evidence_links); + const repaired = await readAnalysisSnapshot(fixture.snapshotPath); + if (!repaired.ok) throw repaired.error; + expect( + repaired.value.evidence_bundle.records.some( + (record) => record.evidence_id === sourceId, + ), + ).toBe(true); +}); + it("runs decompile export against the provider after loading a pseudocode snapshot", async () => { const fixture = await createSnapshotReplayFixture("rea-directory-snapshot-"); const original = fixture.createProvider({ diff --git a/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts b/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts index 4cb6188fbc..64b460b021 100644 --- a/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts +++ b/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts @@ -22,9 +22,13 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; import { readAnalysisSnapshot } from "../../../src/application/binary/AnalysisSnapshotFiles.js"; import { ok } from "../../../src/domain/result.js"; -import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; +import { + createEvidenceBundle, + evidenceBundleSchema, +} from "../../../src/domain/evidenceBundle.js"; import { silentLogger } from "../../../src/logger.js"; import { createServer } from "../../../src/server/createServer.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; @@ -578,6 +582,209 @@ describe("MCP investigation history snapshot replay", () => { ); }); +it("replays a source-bearing observation through an exported MCP snapshot", async () => { + const { targetPaths, snapshotPath } = await createWorkflowFiles( + "rea-mcp-source-replay-", + ["fixture.hop"], + ); + const targetPath = targetPaths[0]; + if (targetPath === undefined) throw new Error("Expected a fixture target"); + const starts: string[] = []; + const calls: string[] = []; + const base = makeProvider(starts, calls); + const provider: AnalysisProvider = { + ...base, + createClient: (target, selectedProfile, context) => { + const client = base.createClient(target, selectedProfile, context); + return { + ...client, + execute: async (...args) => { + const result = await client.execute(...args); + if (!result.ok || args[0] !== "list_strings") return result; + return ok({ + ...result.value, + sourceEvidence: [ + createEvidence(target, identity, { + operation: "health", + parameters: {}, + result: "historical source", + }), + ], + }); + }, + }; + }, + }; + const { mcp } = await connectWorkflowMcp( + createWorkflowSession(provider), + "source-replay", + ); + await openWorkflowTarget(mcp, targetPath); + const read = (client: Client) => + client.callTool({ + name: "list_strings", + arguments: { document: "fixture" }, + }); + const first = await read(mcp); + const firstEvidence = toolContract("list_strings").outputSchema.parse( + first.structuredContent, + ); + expect(firstEvidence.evidence_links).toHaveLength(1); + const second = await read(mcp); + const secondEvidence = toolContract("list_strings").outputSchema.parse( + second.structuredContent, + ); + expect(secondEvidence.evidence_links).toEqual( + expect.arrayContaining([ + ...firstEvidence.evidence_links, + firstEvidence.evidence_id, + ]), + ); + expect(secondEvidence.evidence_links).toHaveLength(2); + expect(secondEvidence.limitations).toEqual( + expect.arrayContaining([ + expect.stringContaining("did not re-run the provider"), + ]), + ); + expect( + calls.filter((operation) => operation === "list_strings"), + ).toHaveLength(1); + expect( + ( + await mcp.callTool({ + name: "close_binary", + arguments: { snapshot_path: snapshotPath }, + }) + ).isError, + ).not.toBe(true); + const { mcp: reopened } = await connectWorkflowMcp( + createWorkflowSession(provider), + "source-replay-import", + ); + expect( + ( + await reopened.callTool({ + name: "open_binary", + arguments: { path: targetPath, snapshot_path: snapshotPath }, + }) + ).isError, + ).not.toBe(true); + const replayed = toolContract("list_strings").outputSchema.parse( + (await read(reopened)).structuredContent, + ); + expect(replayed.evidence_links).toEqual( + expect.arrayContaining([ + ...firstEvidence.evidence_links, + firstEvidence.evidence_id, + ]), + ); + expect(replayed.evidence_links).toHaveLength(2); + expect(replayed.limitations).toEqual( + expect.arrayContaining([ + expect.stringContaining("did not re-run the provider"), + ]), + ); + expect( + calls.filter((operation) => operation === "list_strings"), + ).toHaveLength(1); + const bundle = await reopened.callTool({ + name: "get_evidence_bundle", + arguments: { + detail: "records", + filters: { evidence_id: firstEvidence.evidence_links[0] }, + }, + }); + expect(bundle.isError).not.toBe(true); + expect( + toolContract("get_evidence_bundle").outputSchema.parse( + bundle.structuredContent, + ).result, + ).toMatchObject({ + records: [ + { + evidence_id: firstEvidence.evidence_links[0], + normalized_result: "historical source", + }, + ], + }); + await expectRestoredSourceReplay(provider, targetPath, snapshotPath, calls); +}); + +const expectRestoredSourceReplay = async ( + provider: AnalysisProvider, + targetPath: string, + snapshotPath: string, + calls: string[], +) => { + const saved = await readAnalysisSnapshot(snapshotPath); + if (!saved.ok) throw saved.error; + const source = saved.value.evidence_bundle.records.find( + (record) => record.operation === "health", + ); + if (source === undefined) throw new Error("Expected historical source"); + const directory = dirname(snapshotPath); + const prunedPath = join(directory, "missing-source.json"); + const sourcePath = join(directory, "source-bundle.json"); + await writeFile( + prunedPath, + JSON.stringify({ + ...saved.value, + evidence_bundle: createEvidenceBundle( + saved.value.evidence_bundle.records.filter( + (record) => record.evidence_id !== source.evidence_id, + ), + ), + }), + ); + await writeFile(sourcePath, JSON.stringify(createEvidenceBundle([source]))); + const { mcp } = await connectWorkflowMcp( + createWorkflowSession(provider), + "source-restored-after-open", + ); + expect( + ( + await mcp.callTool({ + name: "open_binary", + arguments: { path: targetPath, snapshot_path: prunedPath }, + }) + ).isError, + ).not.toBe(true); + expect( + ( + await mcp.callTool({ + name: "import_evidence_bundle", + arguments: { path: sourcePath }, + }) + ).isError, + ).not.toBe(true); + const replayed = toolContract("list_strings").outputSchema.parse( + ( + await mcp.callTool({ + name: "list_strings", + arguments: { document: "fixture" }, + }) + ).structuredContent, + ); + const parent = saved.value.evidence_bundle.records.find( + (record) => + record.operation === "list_strings" && + record.evidence_links.includes(source.evidence_id), + ); + if (parent === undefined) throw new Error("Expected original observation"); + expect(replayed.evidence_links).toEqual( + expect.arrayContaining([source.evidence_id, parent.evidence_id]), + ); + expect(replayed.evidence_links).toHaveLength(2); + expect(replayed.limitations).toEqual( + expect.arrayContaining([ + expect.stringContaining("did not re-run the provider"), + ]), + ); + expect( + calls.filter((operation) => operation === "list_strings"), + ).toHaveLength(1); +}; + const expectSnapshotImportGuidance = async (mcp: Client, path: string) => { const before = await readFile(path); const imported = await mcp.callTool({ From 8eff651270337a043389acda13e9b14e4d3777da Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:56:33 +0800 Subject: [PATCH 098/151] fix(ghidra): make checkpoint workflows discoverable (#9246) Expose the existing opt-in Ghidra checkpoint lifecycle in tool descriptions and summary capabilities. Distinguish confirmed initial analysis from restored Programs, preserving the checkpoint path and digest through shared CLI/MCP results. Remove prose-driven checks and their scaffolding; retain canonical effects and lifecycle/source-fact coverage. Validated with 445 focused tests, 13 real Ghidra CLI/MCP scenarios and three isolated local Codex CLI tasks covering disabled setup, saving edits and fresh-agent restoration. --- .../skills/reverse-engineer-anything/SKILL.md | 4 + .../references/native-and-artifacts.md | 26 +++- docs/installation.md | 7 + src/contracts/officialToolContracts.ts | 2 +- src/contracts/sessionStatusContract.ts | 2 +- src/contracts/sessionToolContracts.ts | 6 +- src/contracts/toolEffects.test.ts | 145 ------------------ src/ghidra/GhidraClient.ts | 8 +- src/ghidra/GhidraProvider.behavior.test.ts | 43 ------ src/ghidra/GhidraProviderCapabilities.ts | 6 +- src/ghidra/GhidraProviderClient.ts | 10 +- .../ghidra/ghidraCheckpointLifecycle.test.ts | 6 + 12 files changed, 61 insertions(+), 204 deletions(-) diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index b98687ef39..da90c287a6 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -406,6 +406,10 @@ describe possible providers, while top-level session capabilities describe the current route and `analysis_provider_binding` names the selected provider. Do not treat those scopes as interchangeable. +For editable Ghidra analysis that must survive a later agent process, select +the [owned Program checkpoint workflow](references/native-and-artifacts.md#continue-editable-ghidra-analysis-across-processes) +before making metadata edits. It is separate from Evidence/Unknown persistence. + For a Ghidra startup timeout on a large native target, follow [deadline recovery](references/native-and-artifacts.md#ghidra-startup-deadline-recovery) before abandoning deep analysis or repeating the same cold import. The default diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index 658222b9aa..6371fdab07 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -381,6 +381,28 @@ operation's full ordered list. Candidate capabilities, returned with complete detail, describe possible providers. Top-level session capabilities describe the current route, and `analysis_provider_binding` identifies the selected provider. +## Continue editable Ghidra analysis across processes + +For a task spanning agent sessions, check `binary_session` after selecting +Ghidra: the current provider's `capabilities[].shared_limitations` states whether +Program checkpoints are enabled or the session is ephemeral. Successful native +queries distinguish initial analysis from an actually restored packed Program; +do not infer that restoration happened merely because checkpoint mode is enabled. + +In ephemeral mode, choose a private absolute directory and set +`REA_GHIDRA_CHECKPOINT_DIR` in the environment starting REA. Restart the MCP server +after changing that launch configuration; no current tool call or runtime +analysis-setting update enables it. Stock ELF/PE/Mach-O imports on Linux/macOS +are supported; see the [checkpoint setup and limitations](https://github.com/morluto/rea/blob/main/docs/installation.md#automatically-checkpoint-an-owned-ghidra-program). + +With the setting enabled, normal opens restore compatible saved Programs +automatically. Initial completed analysis is saved, and `close_binary` saves +later metadata edits; no separate save/restore tool is needed. Repair a reported +save failure and retry close. Cancellation or process failure can lose edits +after the last successful save; unfinished initial analysis cannot resume. +Keep the same checkpoint configuration for later processes. Evidence/Unknown +history and immutable REA query snapshots have separate persistence workflows. + ## Reuse caller-selected Ghidra analysis When the caller supplies an expanded project, configure `REA_GHIDRA_PROJECT_PATH` @@ -404,7 +426,7 @@ When the error names `ghidra.startup_timeout_ms`, call `update_analysis_settings` with `action: "set"` and a larger value within the reported bounds. Resolve retained cleanup before the next startup attempt. No REA restart is needed; an already accepted startup keeps its original -budget. A new attempt repeats import. Preserve useful Evidence before closing +budget. Follow the error's session-specific restart guidance, and preserve useful Evidence before closing and reopening the target when remediation requires that lifecycle step. For a per-function timeout, update `ghidra.decompile_timeout_seconds` and retry @@ -660,7 +682,7 @@ numbering gaps leave coverage unknown; a DfuSe suffix CRC mismatch follows ### Function annotations in Ghidra Fresh-import sessions admit annotations; existing-project reuse is read-only. -Use `annotate_native_function` for one function name and/or entry comments, with at least one explicit change. Review its annotation readback and refreshed dossier inline. Changes are atomic and session-scoped; empty comments clear them, omitted fields preserve them. Later MCP calls observe edits until close. CLI `annotate-native-function` returns the updated analysis before discarding the session. Original executable bytes are unchanged; edits invalidate immutable snapshots. Windows P0 does not admit database mutations. For naming only, `set_address_name` / `set_addresses_names` also work with Ghidra: they name function entries or data addresses (primary labels) and return compact per-address success booleans instead of a dossier. Ghidra rejects batch addresses that request the same name or a name another selected address holds; `raw_result.failures` gives each failure's reason. +Use `annotate_native_function` for one function name and/or entry comments, with at least one explicit change. Review its annotation readback and refreshed dossier inline. Changes are atomic; empty comments clear them, omitted fields preserve them. Later MCP calls observe edits. Normal close saves them when owned Program checkpoint mode is enabled; ephemeral mode discards them. CLI `annotate-native-function` follows the same setting when closing after its updated result. Select the [checkpoint workflow](#continue-editable-ghidra-analysis-across-processes) before edits that must survive a later process. Original executable bytes are unchanged; edits invalidate immutable snapshots. Windows P0 does not admit database mutations. For naming only, `set_address_name` / `set_addresses_names` also work with Ghidra: they name function entries or data addresses (primary labels) and return compact per-address success booleans instead of a dossier. Ghidra rejects batch addresses that request the same name or a name another selected address holds; `raw_result.failures` gives each failure's reason. For `compare_functions`, obtain `analyze_function` Evidence before and after the edit while the MCP session is open; annotation Evidence is not a comparison diff --git a/docs/installation.md b/docs/installation.md index f3639edf59..c9193ae54a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1449,6 +1449,13 @@ and analysis into a private temporary project with `-noanalysis`. Normal close saves metadata changes; unchanged queries do not rewrite the checkpoint. The target executable is never executed by this workflow. +For agents, `open_binary` advertises this startup setting. After selecting Ghidra, +the default `binary_session` summary reports enabled or ephemeral mode in the +provider's `capabilities[].shared_limitations`. Successful native results state +whether initial analysis was created or a verified packed Program was restored. +No separate save/restore tool or runtime analysis-setting update is required; +enabling checkpoint mode requires the startup environment and an MCP restart. + Storage is separated by target SHA-256 and analysis-profile identity, including the supported Ghidra version. Identical executable bytes at another path can reuse the Program. One live REA session holds the checkpoint's socket lease; diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index 700f0bc8bd..eba7d24aee 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -50,7 +50,7 @@ const official = < export const OFFICIAL_TOOL_CONTRACTS = [ official( "annotate_native_function", - "Atomically update one function name and/or entry comments, read them back, and return a refreshed complete function dossier. Ghidra changes its ephemeral session analysis database only; original executable bytes remain unchanged. Empty comment text clears that comment. Edits remain available to subsequent MCP calls until close; CLI returns the updated dossier before discarding its session. Invalid edits roll back the entire operation. Windows P0 and providers without this capability are unsupported.", + "Atomically update one function name and/or entry comments, read them back, and return a refreshed complete function dossier. Ghidra edits owned Program metadata; original executable bytes remain unchanged. Empty comment text clears that comment. With REA_GHIDRA_CHECKPOINT_DIR configured at startup, normal close saves edits for the next process; ephemeral mode discards them. CLI follows the same setting after returning its updated dossier. Check binary_session capabilities for the current mode. Invalid edits roll back the entire operation. Windows P0 and providers without this capability are unsupported.", nativeFunctionAnnotationsInputSchema, ), official( diff --git a/src/contracts/sessionStatusContract.ts b/src/contracts/sessionStatusContract.ts index 20d188fca6..41659fec03 100644 --- a/src/contracts/sessionStatusContract.ts +++ b/src/contracts/sessionStatusContract.ts @@ -25,7 +25,7 @@ export const binarySessionInputSchema = z.strictObject({ .enum(BINARY_SESSION_DETAILS) .default("summary") .describe( - "summary (default) keeps open state, the binding, current-route capabilities, each provider candidate's availability and target-support status, code and reason, and every tool's availability, reason and remediation; omitted_fields lists what it leaves out. complete adds each unavailable candidate's remediation, provider diagnostics, candidate capabilities and static tool surface, effect and annotation flags.", + "Start with summary (default): it keeps open state, the binding, current-route capabilities (including selected Ghidra checkpoint mode), each provider candidate's availability and target-support status, code and reason, and every tool's availability, reason and remediation; omitted_fields lists what it leaves out. Request complete only when you need unavailable-candidate remediation, provider diagnostics, candidate capabilities or static tool surface, effect and annotation flags.", ), expected_package_version: z.string().min(1).optional(), expected_catalog_digest: z diff --git a/src/contracts/sessionToolContracts.ts b/src/contracts/sessionToolContracts.ts index 12401abc00..8796882105 100644 --- a/src/contracts/sessionToolContracts.ts +++ b/src/contracts/sessionToolContracts.ts @@ -68,7 +68,7 @@ export const exportEvidenceBundleInputSchema = z.strictObject({ export const SESSION_TOOL_CONTRACTS = [ session( "open_binary", - "Open a local executable, application bundle, archive, JavaScript, source map, plist, or analysis database after validation. format=dos-com explicitly interprets 1..65280 headerless bytes as a DOS COM analysis image. format=raw-binary interprets headerless bytes with a required architecture and entry_point; file offset 0 maps at that address. Omission preserves header-based detection. provider_id selects one deep provider or deterministic auto selection; the binding remains stable until close or an explicit switch, with no failure fallback. Reopening the same target and profile retains its active database and metadata edits. An optional analysis snapshot is imported atomically and must match the binary identity, concrete provider, and canonical analysis profile exactly; importing into a session with metadata mutations requires closing and reopening it first. Switching targets preserves investigation records. A failed admitted open reports error.details.selection_at_failure, target_at_open_completion, and provider_id_at_open_completion. The target summary and provider id are the selected binary and bound provider when that attempt settled, or null when absent. Later calls can change the selection, so use binary_session for current state. Call close_binary to release engine resources; reset_investigation explicitly clears investigation records.", + "Open a local executable, application bundle, archive, JavaScript, source map, plist, or analysis database after validation. format=dos-com explicitly interprets 1..65280 headerless bytes as a DOS COM analysis image. format=raw-binary interprets headerless bytes with a required architecture and entry_point; file offset 0 maps at that address. Omission preserves header-based detection. provider_id selects one deep provider or deterministic auto selection; the binding remains stable until close or an explicit switch, with no failure fallback. Reopening the same target and profile retains its active database and metadata edits. For editable Ghidra persistence across processes, set REA_GHIDRA_CHECKPOINT_DIR to a private absolute directory in the startup environment (stock ELF/PE/Mach-O on Linux/macOS; restart MCP). Opens and normal close then restore/save automatically; no separate persistence tool or runtime setting update is needed. An optional analysis snapshot is imported atomically and must match the binary identity, concrete provider, and canonical analysis profile exactly; importing into a session with metadata mutations requires closing and reopening it first. Switching targets preserves investigation records. A failed admitted open reports error.details.selection_at_failure, target_at_open_completion, and provider_id_at_open_completion. The target summary and provider id are the selected binary and bound provider when that attempt settled, or null when absent. Later calls can change the selection, so use binary_session for current state. Call close_binary to release engine resources; reset_investigation explicitly clears investigation records.", openBinaryInputSchema, ), session( @@ -78,7 +78,7 @@ export const SESSION_TOOL_CONTRACTS = [ ), session( "close_binary", - "Drain earlier requests and release the active engine, replay cache and owned resources while retaining investigation Evidence references and complete Unknown history in memory. Optionally save a target-bound analysis snapshot before closing; resource-only sessions have no binary snapshot binding. Later requests wait for this transition. Existing files require overwrite; a failed snapshot save keeps the engine open. Metadata edits prevent snapshot saves until the engine is recreated. Use reset_investigation to clear records explicitly, optionally saving a complete cross-domain Evidence bundle for import in a new process. Closing does not automatically persist records.", + "Drain earlier requests and release the active engine, replay cache and owned resources while retaining investigation Evidence references and complete Unknown history in memory. Optionally save a target-bound analysis snapshot before closing; resource-only sessions have no binary snapshot binding. Later requests wait for this transition. Existing files require overwrite; a failed snapshot save keeps the engine open. Metadata edits prevent snapshot saves until the engine is recreated. Use reset_investigation to clear records explicitly, optionally saving a complete cross-domain Evidence bundle for import in a new process. With Ghidra checkpoint mode enabled, normal close saves Program metadata; a failed save retains the live Program for close retry. Closing does not automatically persist investigation Evidence/Unknown records.", closeBinaryInputSchema, ), session( @@ -88,7 +88,7 @@ export const SESSION_TOOL_CONTRACTS = [ ), session( "binary_session", - 'Report the active binary target or resource-only app root, provider and capability availability when applicable, client-feature, analysis, and server-identity status without starting analysis. The default summary lists omitted_fields; detail: "complete" adds provider diagnostics, candidate capabilities and static tool flags.', + 'Report the active binary target or resource-only app root, provider and capability availability when applicable, client-feature, analysis, and server-identity status without starting analysis. Start with the default summary: after selecting Ghidra, capabilities[].shared_limitations already reports enabled checkpoint or ephemeral mode. Request detail: "complete" only for provider diagnostics, candidate capabilities or static tool flags; omitted_fields lists what summary leaves out.', binarySessionInputSchema, ), session( diff --git a/src/contracts/toolEffects.test.ts b/src/contracts/toolEffects.test.ts index aba192568e..345bc1d8d7 100644 --- a/src/contracts/toolEffects.test.ts +++ b/src/contracts/toolEffects.test.ts @@ -8,128 +8,6 @@ import { type ToolEffects, } from "./toolEffects.js"; -/** - * Descriptions that name running, launching, executing, starting, installing, - * or uninstalling something. A matching tool must declare foreign execution or - * device mutation unless it is listed below with the reason the wording does - * not describe that effect. - */ -const EXECUTION_WORDING = - /\b(?:run|runs|running|launch|launches|launching|execute|executes|executing|execution|start|starts|install|installs|uninstall|uninstalls)\b/iu; - -const EXECUTION_WORDING_EXCEPTIONS: Readonly> = { - inspect_artifact: "states that mapper execution remains unobserved", - guided_workflows: - "renders existing guidance and states that it does not start analysis", - inspect_analysis_settings: - "reports launch configuration without launching any process", - update_analysis_settings: - "resets defaults to launch configuration without launching any process", - inspect_with_objdump: - "runs a caller-selected inspection tool, not target code", - start_gdb_session: - "starts a debugger session; execution is attributed to gdb_console", - start_frida_session: - "spawned targets remain suspended until resume_frida_session", - inspect_native_load_image: "states that it does not execute the target", - resolve_native_call_targets: "states that it does not execute code", - inspect_byte_entropy: "states that it profiles the file without execution", - inspect_pe_resources: "states that it does not execute the image", - inspect_native_exports: "reads the export table without executing it", - inspect_devicetree: "states that it does not execute the image", - inspect_elf_notes: "states that it does not execute the image", - inspect_apple_ips: "states that it does not execute a target", - inspect_kernel_module: "states that it does not execute the module", - inspect_pe_load_config: "states that it does not execute the image", - inspect_binary_hardening: "states that it does not execute the image", - inspect_desktop_application: - "states that directory triage performs no target execution", - inspect_pe_certificates: "states that it does not execute the image", - inspect_pe_imports: "states that it does not execute the image", - inspect_go_binary: "reads embedded metadata without executing the target", - inspect_rust_binary: - "reads an embedded dependency list without executing the target", - inspect_python_pyc: - "states that it does not execute bytecode or invoke CPython", - inspect_erlang_beam: "states that it does not execute bytecode", - inspect_sqlite_database: - "uses a trusted inspector worker without executing application code or caller SQL", - inspect_binary_layout: - "parses the binary offline with operator-configured pwntools", - inspect_recorded_crash: "inspects a core file without launching its target", - inspect_macho: "runs host Mach-O inspection tools, not the target", - inspect_evm_interface: - "decodes bytecode in REA's bundled worker without executing it", - compare_wasm_interfaces: - "compares retained inspection Evidence without executing modules", - inspect_wasm_artifact: - "WABT validates and decompiles the module without executing it", - identify_flutter_build: - "identifies the build through execution-free APK parsing", - inspect_dart_aot: "inspects snapshot bytes without executing them", - inspect_analysis_view: - "reports retained WASM execution/network states without performing execution", - inspect_managed_artifact: "reads metadata without executing target code", - inspect_managed_members: "reads metadata without executing target code", - inspect_managed_native_boundaries: - "reads metadata without executing target code", - verify_managed_native_boundaries: "states it does not execute managed code", - project_managed_application_graph: "states it does not execute managed code", - inspect_android_package: "states it does not execute the APK", - search_android_classes: "states it does not execute the APK", - inspect_android_jni_bindings: - "reads DEX and ELF bytes without executing target code", - inspect_apktool_client: - "inspects the operator-selected apktool launcher; never installs it", - inspect_pcap_client: - "probes the operator-selected tshark and capinfos; never installs them", - inspect_jeb_client: "talks to a caller-started JEB; never launches it", - open_jeb_project: "the engine analyzes the file; REA does not execute it", - inspect_adb_client: - "inspects the operator-selected adb client; never starts its server", - list_adb_devices: "may start the local adb server, which REA does not own", - inspect_api_contract: - "states there is no target execution during offline contract parsing", - inspect_firmware_regions: "states there is no target execution", - inspect_gpu_code_objects: - "reads static declarations without executing GPU code", - inspect_model_artifact: - "states that model code and inference are never executed", - extract_firmware: "states that extracted code is never executed", - analyze_javascript_application: "reconstructs the graph without executing", - reconcile_javascript_runtime: "never invents execution claims", - observe_javascript_runtime: "never evaluates or resumes the target", - trace_application_feature: "never executes the application", - trace_javascript_semantics: "static reachability never claims execution", - compare_javascript_export_shapes: "states it does not execute JavaScript", - project_android_application_graph: "execution-free inventory", - project_apple_application_graph: "execution-free inventory", - project_harmony_application_graph: "execution-free inventory", - trace_web_source_location: "states there is no application execution", - trace_web_module_imports: - "runs only REA's resolver stub in operator-configured Chromium", - export_web_scripts: "states there is no refetch or execution", - observe_web_execution: - "instruments an externally driven page; it does not run code itself", - recover_javascript_sources: "states there is no application code execution", - goto_address: "start names an object boundary, not a launch", - prev_address: "start names an object boundary, not a launch", - trace_call_path: "start names the traversal origin", - observe_native_ui: "observes an already-running app without launching it", - inspect_adb_package: "reports install times without changing the device", - list_adb_processes: "lists running processes without changing the device", - inspect_web_event_listeners: "states it does not evaluate JavaScript", - inspect_apk_signature: "states that no Android code executes", -}; - -const declaresForeignAuthority = (name: string): boolean => { - const effects = TOOL_EFFECTS[name]; - return ( - effects !== undefined && - (effects.executesForeignCode || effects.mutatesDevice) - ); -}; - describe("tool effects", () => { it("declares the web bundle worker's private file and child process effects", () => { const contract = TOOL_CONTRACTS.find( @@ -150,29 +28,6 @@ describe("tool effects", () => { ); }); - it("keeps execution and device wording aligned with declared effects", () => { - const undeclared = TOOL_CONTRACTS.filter( - ({ name, description }) => - EXECUTION_WORDING.test(description) && - !declaresForeignAuthority(name) && - EXECUTION_WORDING_EXCEPTIONS[name] === undefined, - ).map(({ name }) => name); - expect(undeclared).toEqual([]); - const staleExceptions = Object.keys(EXECUTION_WORDING_EXCEPTIONS).filter( - (name) => { - const contract = TOOL_CONTRACTS.find( - (candidate) => candidate.name === name, - ); - return ( - contract === undefined || - !EXECUTION_WORDING.test(contract.description) || - declaresForeignAuthority(name) - ); - }, - ); - expect(staleExceptions).toEqual([]); - }); - it("marks foreign execution and device mutation as destructive", () => { const destructive = TOOL_CONTRACTS.filter( ({ effects }) => effects.executesForeignCode || effects.mutatesDevice, diff --git a/src/ghidra/GhidraClient.ts b/src/ghidra/GhidraClient.ts index 7e32230cae..4447ec0b26 100644 --- a/src/ghidra/GhidraClient.ts +++ b/src/ghidra/GhidraClient.ts @@ -739,9 +739,11 @@ export class GhidraClient { } } - /** Actual restore source, including a restart within the same REA session. */ - checkpointRestore(): GhidraPackedProgram | undefined { - return this.#checkpointRestore; + /** Verified source after startup: null for initial checkpointed analysis; undefined if disabled or unconfirmed. */ + checkpointRestore(): GhidraPackedProgram | null | undefined { + if (this.#checkpoint === undefined || !this.#startupSucceeded()) + return undefined; + return this.#checkpointRestore ?? null; } /** Read completion from the current attempt; no independent readiness flag. */ diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index df3f3a129c..f68d509e53 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -3,7 +3,6 @@ import { EXISTING_PROJECT_WINDOWS_REMEDIATION, EXISTING_PROJECT_WINDOWS_UNAVAILABLE, existingProjectCapabilities, - checkpointCapabilities, ghidraEmptyIncomingReferencesLimitation, ghidraEmptyDossierIncomingLimitation, ghidraEmptyExternalOutgoingLimitation, @@ -110,48 +109,6 @@ import { createEvidence } from "../domain/evidence.js"; import { createEvidenceBundle } from "../domain/evidenceBundle.js"; const INSTALL = "/opt/ghidra_12.1.4_PUBLIC"; -it.skipIf(process.platform === "win32")( - "health describes checkpoint recovery without ephemeral reimport advice", - async () => { - const directory = await mkdtemp(join(tmpdir(), "rea-checkpoint-health-")); - const ghidra = provider( - installationHost(), - () => ({ - start: async () => ok(sessionInfo()), - callTool: async () => ok([]), - close: async () => ok(null), - checkpointRestore: () => ({ - path: join(directory, "verified.gzf"), - sha256: "a".repeat(64), - }), - }), - { REA_GHIDRA_CHECKPOINT_DIR: directory }, - ); - const client = await createElfClient(ghidra); - try { - const health = await client.execute("health", {}); - if (!health.ok) throw health.error; - expect(health.value.limitations.join("\n")).toContain( - "last successfully saved owned Program", - ); - expect(health.value.limitations.join("\n")).toContain( - "auto-analysis was not rerun", - ); - expect(health.value.limitations.join("\n")).not.toMatch( - /imports the original artifact again|discards session annotations/, - ); - for (const capability of checkpointCapabilities()) { - expect(capability.limitations.join("\n")).not.toMatch( - /discarded on close|imports the original artifact again/, - ); - } - } finally { - await client.close(); - await ghidra.closeOwnedResources(); - await rm(directory, { recursive: true, force: true }); - } - }, -); afterEach(() => vi.restoreAllMocks()); const installationHost = ( platform: NodeJS.Platform = "linux", diff --git a/src/ghidra/GhidraProviderCapabilities.ts b/src/ghidra/GhidraProviderCapabilities.ts index ba89597ab4..2496d6fbdd 100644 --- a/src/ghidra/GhidraProviderCapabilities.ts +++ b/src/ghidra/GhidraProviderCapabilities.ts @@ -22,7 +22,7 @@ const providerOperations = Object.freeze([ /** Health limitations shared by every Ghidra-backed capability. */ export const healthLimitations = Object.freeze([ "The session serves operations only after Ghidra reports default auto-analysis complete; incomplete analysis does not expose partial results.", - "The imported Program and temporary project are ephemeral and deleted on close. Annotation operations edit session database metadata; original executable bytes are never written.", + "Ghidra Program checkpoint mode is disabled: closing discards metadata edits; original executable bytes are unchanged. For stock ELF/PE/Mach-O on Linux/macOS, set REA_GHIDRA_CHECKPOINT_DIR to a private absolute directory before starting REA (restart MCP after changing its startup environment).", "Cancelling an active Ghidra request terminates its ephemeral database and discards session annotations. The selected target remains open; the next provider query imports the original artifact again.", ]); @@ -50,8 +50,8 @@ export const ghidraHealthLimitations = ( ] : source === "owned-checkpoint" ? [ - "Opt-in Ghidra checkpoints retain the last successfully saved owned Program, including its names, comments and types. Restoring a checkpoint skips auto-analysis; its analysis is retained state, not a fresh discovery.", - "REA saves initial completed analysis and saves metadata changes on normal close. A failed save retains the live session for retry. Cancellation or process failure can lose changes after the last successful save; unfinished initial auto-analysis is not resumable.", + "Ghidra Program checkpoint mode is enabled (REA_GHIDRA_CHECKPOINT_DIR). Opening the same target/profile automatically restores saved analysis, names, comments and types; no separate save or restore tool is needed. Restored analysis is retained state, not a fresh discovery.", + "REA saves initial completed analysis and metadata changes on normal close (close_binary in MCP). A failed save retains the live session for close retry. Cancellation or process failure can lose changes after the last successful save; unfinished initial auto-analysis is not resumable.", "The live project is a temporary working copy. Checkpoints persist in REA_GHIDRA_CHECKPOINT_DIR; source executables and caller-selected source projects are not modified.", ] : healthLimitations; diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index e21c901510..f2612353dc 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -821,9 +821,13 @@ export const createGhidraProviderClient = (input: { return [ ...(restored === undefined ? [] - : [ - `Restored owned Program ${restored.path} (SHA-256 ${restored.sha256}); auto-analysis was not rerun.`, - ]), + : restored === null + ? [ + "Created the initial owned Ghidra Program from the executable and saved its analysis checkpoint; metadata edits are saved on normal close.", + ] + : [ + `Restored owned Program ${restored.path} (SHA-256 ${restored.sha256}); auto-analysis was not rerun.`, + ]), ...providerLimitations, ...targetLimitations, ...(psp ? GHIDRA_PSP_LIMITATIONS : []), diff --git a/tests/boundary/providers/ghidra/ghidraCheckpointLifecycle.test.ts b/tests/boundary/providers/ghidra/ghidraCheckpointLifecycle.test.ts index a650bdb775..b4d11c505a 100644 --- a/tests/boundary/providers/ghidra/ghidraCheckpointLifecycle.test.ts +++ b/tests/boundary/providers/ghidra/ghidraCheckpointLifecycle.test.ts @@ -106,10 +106,13 @@ it.skipIf(process.platform === "win32")( "saves completed initial analysis, leaves an unchanged checkpoint alone, and releases its lease", async () => { const f = await fixture(); + expect(f.client.checkpointRestore()).toBeUndefined(); expect((await f.client.start()).ok).toBe(true); + expect(f.client.checkpointRestore()).toBeNull(); const path = join(f.selection.directory, "current.json"), before = await readFile(path, "utf8"); expect((await f.client.close()).ok).toBe(true); + expect(f.client.checkpointRestore()).toBeUndefined(); expect(await readFile(path, "utf8")).toEqual(before); expect(f.launcher.requests).toContain("checkpoint_program"); const selection = await resolveGhidraCheckpoint( @@ -129,6 +132,9 @@ it.skipIf(process.platform === "win32")( }); clients.push(resumed); expect((await resumed.start()).ok).toBe(true); + expect(resumed.checkpointRestore()).toMatchObject({ + sha256: JSON.parse(await readFile(path, "utf8")).source.sha256, + }); expect(f.launcher.packedPrograms.at(-1)?.sha256).toBe( JSON.parse(await readFile(path, "utf8")).source.sha256, ); From c5301e6145a9c8e06719f27e83da55af8e5480fc Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sat, 7 Nov 2026 01:54:59 -0800 Subject: [PATCH 099/151] feat(desktop): capture, draft, export and witness a task inside a build computer (#9045) --- bridge/agentenv/capture.py | 262 +++++++ docs/.vitepress/config.ts | 4 + docs/desktop-application-capture.md | 14 +- docs/desktop-application-export.md | 16 +- docs/desktop-application-state.md | 6 +- docs/desktop-application-tasks.md | 10 +- docs/desktop-build-computer.md | 60 +- docs/desktop-build-witness.md | 175 +++++ docs/index.md | 1 + docs/specs/desktop-app-environments/README.md | 6 +- .../build-witness/plan.md | 32 + .../build-witness/spec.md | 113 +++ .../build-witness/tasks.md | 13 + .../build-witness/verification.md | 252 +++++++ package.json | 2 + scripts/pack-build-computer-runtime.mjs | 54 ++ .../desktop/DesktopApplicationCapture.test.ts | 75 +- .../desktop/DesktopApplicationCapture.ts | 25 +- .../desktop/DesktopApplicationExport.test.ts | 66 +- .../desktop/DesktopApplicationExport.ts | 152 ++-- .../desktop/DesktopBuildActionLog.ts | 20 +- .../desktop/DesktopBuildCapture.ts | 362 ++++++++++ .../desktop/DesktopBuildCommands.ts | 96 +++ .../desktop/DesktopBuildComputers.ts | 255 +++++-- src/application/desktop/DesktopBuildExport.ts | 199 +++++ src/application/desktop/DesktopBuildSteps.ts | 158 ++++ .../desktop/DesktopBuildSummary.ts | 179 +++++ .../desktop/DesktopBuildTurns.test.ts | 99 +++ src/application/desktop/DesktopBuildTurns.ts | 64 ++ .../desktop/DesktopBuildWitness.ts | 583 +++++++++++++++ .../desktop/DesktopBuildWitnessRecord.test.ts | 132 ++++ .../desktop/DesktopBuildWitnessRecord.ts | 323 +++++++++ src/application/desktop/DesktopOutputTree.ts | 98 +++ .../desktop/DesktopTaskExport.test.ts | 122 ++++ src/application/desktop/DesktopTaskExport.ts | 20 +- src/browser/PngVisualDiff.test.ts | 18 +- src/browser/PngVisualDiff.ts | 24 +- src/cli/desktopCaptureCommands.ts | 18 +- src/contracts/desktopBuildToolContracts.ts | 305 +++++++- src/contracts/toolEffects.test.ts | 1 + src/contracts/toolEffects.ts | 36 + src/desktop/AgentEnvComputer.ts | 262 ++++++- src/desktop/AgentEnvComputerHelper.ts | 8 +- src/desktop/BuildComputerController.test.ts | 89 +++ src/desktop/BuildComputerController.ts | 237 ++++++ src/desktop/DesktopApplicationSession.ts | 14 +- src/desktop/DrawnScreenshot.ts | 52 ++ src/desktop/HttpReply.ts | 36 + src/domain/desktop/appBuild.test.ts | 12 +- src/domain/desktop/appBuild.ts | 30 +- src/domain/desktop/appExport.test.ts | 25 +- src/domain/desktop/appExport.ts | 32 +- src/domain/desktop/appExportInstall.test.ts | 34 + src/domain/desktop/appExportInstall.ts | 75 +- src/domain/desktop/appTaskChecks.fixture.ts | 44 ++ src/domain/desktop/appTaskChecks.test.ts | 271 ++++++- src/domain/desktop/appTaskChecks.ts | 56 +- src/domain/desktop/appTaskPython.ts | 6 +- src/domain/desktop/buildCapture.ts | 19 + src/domain/desktop/buildWitness.test.ts | 386 ++++++++++ src/domain/desktop/buildWitness.ts | 249 +++++++ src/domain/desktop/computerActions.test.ts | 11 + src/domain/desktop/computerActions.ts | 19 +- src/domain/desktop/desktopCapture.ts | 57 ++ src/domain/desktop/desktopState.ts | 1 + src/domain/desktop/stateRoles.test.ts | 34 + src/domain/desktop/stateRoles.ts | 16 +- src/domain/desktop/zipView.ts | 15 +- src/server/registerDesktopBuildTools.ts | 182 +++-- tests/boundary/cli/cliToolCoverage.test.ts | 5 + tests/boundary/mcp/buildCaptureMcp.test.ts | 235 ++++++ .../boundary/mcp/buildCaptureRetryMcp.test.ts | 311 ++++++++ tests/boundary/mcp/buildComputerMcp.test.ts | 310 ++++++-- tests/boundary/mcp/buildWitnessMcp.test.ts | 677 ++++++++++++++++++ .../providers/buildComputerLifecycle.test.ts | 55 +- tests/fixtures/buildCaptureFakes.ts | 121 ++++ tests/fixtures/buildComputerMcp.ts | 107 +++ tests/fixtures/fakeAgentEnvComputer.ts | 280 +++++++- tests/fixtures/fakeBuildComputerRea.ts | 172 +++++ tests/fixtures/outputTreeCommit.ts | 25 + 80 files changed, 8541 insertions(+), 479 deletions(-) create mode 100644 bridge/agentenv/capture.py create mode 100644 docs/desktop-build-witness.md create mode 100644 docs/specs/desktop-app-environments/build-witness/plan.md create mode 100644 docs/specs/desktop-app-environments/build-witness/spec.md create mode 100644 docs/specs/desktop-app-environments/build-witness/tasks.md create mode 100644 docs/specs/desktop-app-environments/build-witness/verification.md create mode 100644 scripts/pack-build-computer-runtime.mjs create mode 100644 src/application/desktop/DesktopBuildCapture.ts create mode 100644 src/application/desktop/DesktopBuildCommands.ts create mode 100644 src/application/desktop/DesktopBuildExport.ts create mode 100644 src/application/desktop/DesktopBuildSteps.ts create mode 100644 src/application/desktop/DesktopBuildSummary.ts create mode 100644 src/application/desktop/DesktopBuildTurns.test.ts create mode 100644 src/application/desktop/DesktopBuildTurns.ts create mode 100644 src/application/desktop/DesktopBuildWitness.ts create mode 100644 src/application/desktop/DesktopBuildWitnessRecord.test.ts create mode 100644 src/application/desktop/DesktopBuildWitnessRecord.ts create mode 100644 src/application/desktop/DesktopOutputTree.ts create mode 100644 src/desktop/BuildComputerController.test.ts create mode 100644 src/desktop/BuildComputerController.ts create mode 100644 src/desktop/DrawnScreenshot.ts create mode 100644 src/desktop/HttpReply.ts create mode 100644 src/domain/desktop/buildCapture.ts create mode 100644 src/domain/desktop/buildWitness.test.ts create mode 100644 src/domain/desktop/buildWitness.ts create mode 100644 tests/boundary/mcp/buildCaptureMcp.test.ts create mode 100644 tests/boundary/mcp/buildCaptureRetryMcp.test.ts create mode 100644 tests/boundary/mcp/buildWitnessMcp.test.ts create mode 100644 tests/fixtures/buildCaptureFakes.ts create mode 100644 tests/fixtures/buildComputerMcp.ts create mode 100644 tests/fixtures/fakeBuildComputerRea.ts create mode 100644 tests/fixtures/outputTreeCommit.ts diff --git a/bridge/agentenv/capture.py b/bridge/agentenv/capture.py new file mode 100644 index 0000000000..b1cb06354e --- /dev/null +++ b/bridge/agentenv/capture.py @@ -0,0 +1,262 @@ +"""Run REA inside an agentenv build computer for REA on the host: capture the app, draft its task and export it. + +REA sends this file's source through the computer's ``computer-exec/v1`` route as +``python3 -I -c ``; each command prints one JSON line and exits. ``start`` +leaves the capture running on its own: its stdin is a FIFO it holds open for reading and writing, so it never +sees the end of its input, and ``mark`` and ``stop`` write their requests there and wait for the capture's own +acknowledgement on its progress log. A small shell around the capture writes its exit status to a file, the +mark a later command reads. REA runs as the session user in the home, with no ``AGENTENV_*`` variable, so +neither REA nor the app it launches holds the computer's control token, and with ``APT_CONFIG`` naming the +package lists ``start`` downloads as that user, so the capture finds the package's sources. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import time + +POLL_S = 0.2 +TAIL_BYTES = 4096 + + +def _say(value: dict) -> int: + print(json.dumps(value), flush=True) + return 0 + + +def _tail(path: str) -> str: + try: + with open(path, "rb") as handle: + handle.seek(max(0, os.path.getsize(path) - TAIL_BYTES)) + return handle.read().decode("utf-8", "replace") + except OSError: + return "" + + +def _environment(work: str) -> dict: + environment = {name: value for name, value in os.environ.items() if not name.startswith("AGENTENV_")} + environment["APT_CONFIG"] = os.path.join(work, "apt", "apt.conf") + return environment + + +def _apt(work: str, timeout: float) -> dict: + """Download the package lists into the work folder as the session user, until one update succeeds.""" + root = os.path.join(work, "apt") + lists = os.path.join(root, "lists") + updated = os.path.join(root, "updated") + os.makedirs(os.path.join(lists, "partial"), exist_ok=True) + os.makedirs(os.path.join(root, "cache", "archives", "partial"), exist_ok=True) + with open(os.path.join(root, "apt.conf"), "w", encoding="utf-8") as conf: + conf.write( + f'Dir::State::Lists "{lists}";\n' + f'Dir::Cache "{root}/cache";\n' + 'Debug::NoLocking "true";\n' + 'APT::Update::Error-Mode "any";\n' + ) + # apt keeps lists compressed as it fetched them, such as *_Packages.lz4 on Ubuntu. + count = lambda: len([name for name in os.listdir(lists) if re.search(r"_Packages(\.\w+)?$", name)]) # noqa: E731 + # A marker left by an update that fetched nothing is not a success; download again. + if os.path.exists(updated) and count() > 0: + return {"lists": count(), "updated": False} + log = os.path.join(root, "update.log") + try: + with open(log, "wb") as output: + code = subprocess.run(["apt-get", "update"], env=_environment(work), stdin=subprocess.DEVNULL, + stdout=output, stderr=subprocess.STDOUT, timeout=timeout).returncode + except (OSError, subprocess.TimeoutExpired) as error: + return {"lists": count(), "updated": True, "error": f"{type(error).__name__}: {error}"} + lists_fetched = count() + if code == 0 and lists_fetched > 0: + # Only a whole update that fetched lists is kept, so a failed or empty one runs again. + open(updated, "w").close() + return {"lists": lists_fetched, "updated": True, "exit_code": code} + if code == 0: + return {"lists": lists_fetched, "updated": True, "exit_code": code, + "error": "apt-get update exited 0 without a package list"} + return {"lists": lists_fetched, "updated": True, "exit_code": code, + "error": f"apt-get update exited {code}: {_tail(log)[-1500:].strip()}"} + + +def _exit_code(capture: str): + try: + with open(os.path.join(capture, "exit"), encoding="utf-8") as handle: + return int(handle.read().strip() or "-1") + except (OSError, ValueError): + return None + + +def _event_line(path: str, offset: int, kinds: set, name): + """The first progress line after ``offset`` whose event is of one of ``kinds``, for ``name`` when given.""" + try: + with open(path, "rb") as handle: + handle.seek(offset) + lines = handle.read().decode("utf-8", "replace").splitlines() + except OSError: + return None + for line in lines: + try: + event = json.loads(line)["rea_progress"]["event"] + except (ValueError, KeyError, TypeError): + continue + if isinstance(event, dict) and event.get("kind") in kinds and (name is None or event.get("name") == name): + return line + return None + + +def _wait(capture: str, offset: int, kinds: set, name, timeout: float) -> dict: + """Wait for the capture's event, or for it to exit, or for time to run out.""" + log = os.path.join(capture, "progress.log") + deadline = time.monotonic() + timeout + while True: + line = _event_line(log, offset, kinds, name) + if line is not None: + return {"line": line} + code = _exit_code(capture) + if code is not None: + return {"exited": code, "progress": _tail(log), "output": _tail(os.path.join(capture, "capture.out"))} + if time.monotonic() > deadline: + return {"timed_out": True, "progress": _tail(log)} + time.sleep(POLL_S) + + +def start(arguments: dict) -> int: + work, capture = os.path.abspath(arguments["work"]), os.path.abspath(arguments["capture"]) + rea = shutil.which("rea") + if rea is None: + return _say({"error": "REA is not installed in this computer; start the computer from a request, which installs it."}) + if os.path.exists(capture): + return _say({"error": f"the capture folder {capture} is already used"}) + apt = _apt(work, arguments.get("apt_timeout_s", 600)) + try: + os.makedirs(capture) + except FileExistsError: + return _say({"error": f"a stop claimed {capture} before the capture launched", "apt": apt}) + control = os.path.join(capture, "control") + os.mkfifo(control, 0o600) + argv = [rea, "capture-desktop-application", "--desktop-entry", arguments["desktop_entry"], + "--output", os.path.join(capture, "capture"), "--exclude", work, "--json"] + for value in arguments.get("arguments", []): + argv += ["--argument", value] + if arguments.get("package_file"): + argv += ["--package-file", arguments["package_file"]] + # Reading and writing, so the capture never reads the end of its input between requests. + reader = os.open(control, os.O_RDWR) + try: + with open(os.path.join(capture, "capture.out"), "wb") as out, \ + open(os.path.join(capture, "progress.log"), "wb") as progress: + process = subprocess.Popen( + ["sh", "-c", 'done=$1; shift; "$@"; code=$?; printf "%s" "$code" > "$done.tmp" && mv "$done.tmp" "$done"', + "sh", os.path.join(capture, "exit"), *argv], + cwd=os.path.expanduser("~"), env=_environment(work), stdin=reader, stdout=out, stderr=progress, + start_new_session=True) + finally: + os.close(reader) + waited = _wait(capture, 0, {"window_open"}, None, arguments.get("timeout_s", 120)) + return _say({"pid": process.pid, "apt": apt, **waited}) + + +def _request(capture: str, line: str) -> bool: + """Write one request to the running capture; False when nothing reads its FIFO any more.""" + try: + fd = os.open(os.path.join(capture, "control"), os.O_WRONLY | os.O_NONBLOCK) + except OSError: + return False + try: + os.write(fd, (line + "\n").encode("utf-8")) + finally: + os.close(fd) + return True + + +def mark(arguments: dict) -> int: + capture, name = os.path.abspath(arguments["capture"]), arguments["name"] + code = _exit_code(capture) + log = os.path.join(capture, "progress.log") + if code is not None: + return _say({"exited": code, "progress": _tail(log)}) + # A checkpoint already taken, whose answer was lost, is answered from the capture's own log. + taken = _event_line(log, 0, {"checkpoint"}, name) + if taken is not None: + return _say({"line": taken}) + offset = os.path.getsize(log) + if not _request(capture, f"checkpoint {name}"): + return _say({"error": "the capture is not reading its requests"}) + return _say(_wait(capture, offset, {"checkpoint", "checkpoint_refused"}, name, arguments.get("timeout_s", 600))) + + +def stop(arguments: dict) -> int: + capture = os.path.abspath(arguments["capture"]) + if _exit_code(capture) is None and not os.path.exists(os.path.join(capture, "control")): + # Never launched: claim the folder, so a start still on its way finds it used and launches nothing. + os.makedirs(capture, exist_ok=True) + return _say({"absent": True}) + if _exit_code(capture) is None: + _request(capture, "stop") + deadline = time.monotonic() + arguments.get("timeout_s", 600) + while (code := _exit_code(capture)) is None and time.monotonic() < deadline: + time.sleep(POLL_S) + log = os.path.join(capture, "progress.log") + if code is None: + return _say({"timed_out": True, "progress": _tail(log)}) + output = os.path.join(capture, "capture.out") + return _say({"exit_code": code, "progress": _tail(log), "output_bytes": os.path.getsize(output)}) + + +def run(arguments: dict) -> int: + """Run one REA command with its output in a file, after writing the files it reads.""" + rea = shutil.which("rea") + if rea is None: + return _say({"error": "REA is not installed in this computer"}) + for path, text in arguments.get("files", {}).items(): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + handle.write(text) + with open(arguments["stdout"], "wb") as out: + try: + done = subprocess.run([rea, *arguments["argv"]], cwd=os.path.expanduser("~"), + env=_environment(os.path.abspath(arguments["work"])), stdin=subprocess.DEVNULL, stdout=out, + stderr=subprocess.PIPE, timeout=arguments.get("timeout_s", 1800)) + except subprocess.TimeoutExpired: + return _say({"timed_out": True}) + return _say({"exit_code": done.returncode, "stdout_bytes": os.path.getsize(arguments["stdout"]), + "stderr": done.stderr[-TAIL_BYTES:].decode("utf-8", "replace")}) + + +def listing(arguments: dict) -> int: + """Every regular file under a folder, by its path relative to the home, with its size and SHA-256. + + The list goes to the file ``output`` names, outside the folder, and the answer gives its size: a large + bundle's list outgrows the 1 MiB of stdout a command answers with, so REA reads it through ``data/get``. + """ + home = os.path.expanduser("~") + files, skipped = [], [] + for directory, folders, names in os.walk(os.path.abspath(arguments["root"])): + folders.sort() + for name in sorted(names): + path = os.path.join(directory, name) + relative = os.path.relpath(path, home) + if os.path.islink(path) or not os.path.isfile(path): + skipped.append(relative) + continue + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + digest.update(chunk) + files.append({"path": relative, "size": os.path.getsize(path), "sha256": digest.hexdigest()}) + output = os.path.abspath(arguments["output"]) + os.makedirs(os.path.dirname(output), exist_ok=True) + with open(output, "w", encoding="utf-8") as handle: + json.dump({"files": files, "skipped": skipped}, handle) + return _say({"listing_bytes": os.path.getsize(output)}) + + +COMMANDS = {"start": start, "mark": mark, "stop": stop, "run": run, "list": listing} + +if __name__ == "__main__": + sys.exit(COMMANDS[sys.argv[1]](json.loads(sys.argv[2]))) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 304218e341..e7dcd1acbb 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -82,6 +82,10 @@ export default defineConfig({ text: "Desktop build computer", link: "/desktop-build-computer", }, + { + text: "Building and witnessing", + link: "/desktop-build-witness", + }, { text: ".NET assemblies", link: "/managed-code-analysis" }, { text: "Python packaged applications", diff --git a/docs/desktop-application-capture.md b/docs/desktop-application-capture.md index 6a84127eda..f68d8da4e8 100644 --- a/docs/desktop-application-capture.md +++ b/docs/desktop-application-capture.md @@ -84,8 +84,18 @@ gone, the capture looks for any process still carrying the run token, so a worker whose launcher exited before it was sampled keeps the session open. A sample whose window list could not be read is not taken as an empty one; the limitations say how many there were. One queued line is handled between -samples, and reading stdin pauses while 16 lines wait. Progress lines on stderr say when -the window has opened and when each checkpoint is taken. +samples, and reading stdin pauses while 16 lines wait. + +Progress lines on stderr are JSON, `{"rea_progress": {"phase": +"desktop_capture", "message": ...}}`. Those a driver waits on also carry an +`event`: `{"kind": "window_open"}` once the window opens; `{"kind": +"checkpoint", "name", "trigger", "at_ms", "entries", "changes", +"retained_files", "retained_bytes"}` once a checkpoint has been taken, the +baseline, `stop` and `exit` included, with the paths walked, the paths +changed since the checkpoint before and the files retained; and `{"kind": +"checkpoint_refused", "name", "reason"}` for a requested name it refused. A +driver that sends `checkpoint ` waits for the event naming it rather +than for a fixed time. When the session ends the capture sends SIGTERM to the launched process, its process group and every process that carries the run token, waits up to five diff --git a/docs/desktop-application-export.md b/docs/desktop-application-export.md index 0d641842df..f9a21e5b84 100644 --- a/docs/desktop-application-export.md +++ b/docs/desktop-application-export.md @@ -118,11 +118,12 @@ The class declares: taskbar or says it is a splash, and an app whose splash shows first would otherwise count as ready before its own window opens. Arguments that are the captured home or under it, and `