diff --git a/src/android/JadxAnalysis.ts b/src/android/JadxAnalysis.ts index 6b623d5955..73852df970 100644 --- a/src/android/JadxAnalysis.ts +++ b/src/android/JadxAnalysis.ts @@ -8,7 +8,7 @@ import { import type { JsonValue } from "../domain/jsonValue.js"; import { jadxAppSchema, - jadxClassPageSchema, + type JadxClassPage, jadxClassSchema, jadxLoadSchema, jadxMethodSchema, @@ -18,6 +18,11 @@ import { /** The adapter consumes tool payloads, never provider RPC details outside this layer. */ export interface JadxToolPort { + listClasses(input: { + readonly query: string; + readonly offset: number; + readonly limit: number; + }): Promise; json( name: string, input: Readonly>, @@ -138,13 +143,11 @@ const searchClasses = async ( const pageSize = 4096; let offset = 0; do { - const page = jadxClassPageSchema.parse( - await tools.json("list_classes", { - query: request.input.query, - offset, - limit: pageSize, - }), - ); + const page = await tools.listClasses({ + query: request.input.query, + offset, + limit: pageSize, + }); if ( page.total_classes !== loaded.class_count || page.offset !== offset || diff --git a/src/android/JadxProtocol.ts b/src/android/JadxProtocol.ts index ec7420c58b..68a689042b 100644 --- a/src/android/JadxProtocol.ts +++ b/src/android/JadxProtocol.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; -import { jsonValueSchema } from "../domain/jsonValue.js"; +import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import type { AndroidOperation } from "../domain/android/androidAnalysis.js"; import { dexDescriptorSchema } from "../domain/android/androidAnalysis.js"; @@ -61,14 +61,24 @@ export const jadxAppSchema = z.object({ target_sdk: z.string(), permissions: z.array(z.string()), }); -export const jadxClassPageSchema = z.object({ - scope: z.literal("top_level"), - total_classes: count, - next_offset: count, - offset: count, - limit: count, - items: z.array(z.string()), -}); +const jadxClassPageSchema = z + .object({ + scope: z.literal("top_level"), + total_classes: count, + next_offset: count, + offset: count, + limit: count, + items: z.array(z.string()), + }) + .catchall(jsonValueSchema); +export type JadxClassPage = z.infer; + +/** Validate a decoded page without dropping observed JSON members such as __proto__. */ +export const parseJadxClassPage = (value: JsonValue): JadxClassPage => { + jadxClassPageSchema.parse(value); + return value as JadxClassPage; +}; + export const jadxClassSchema = z.object({ full_name: z.string(), name: z.string(), @@ -131,28 +141,30 @@ export const jadxXrefsSchema = z.object({ }), ), }); -const toolResponse = z.object({ - content: z - .array(z.object({ type: z.literal("text"), text: z.string() })) - .length(1), - isError: z.boolean().optional(), -}); +const toolResponse = z + .object({ + content: z.tuple([ + z + .object({ type: z.literal("text"), text: z.string() }) + .catchall(jsonValueSchema), + ]), + isError: z.boolean().exactOptional(), + }) + .catchall(jsonValueSchema); /** Parse the upstream text-only envelope, preserving errors at the producing boundary. */ export const parseJadxEnvelope = ( - response: unknown, + response: JsonValue, operation: AndroidOperation, -): { text: string; failed: boolean } => { +): z.infer => { const parsed = toolResponse.safeParse(response); - if (!parsed.success || parsed.data.content[0] === undefined) + if (!parsed.success) throw new AnalysisOutputError( operation, "JADX returned an unsupported MCP content envelope", ); - return { - text: parsed.data.content[0].text, - failed: parsed.data.isError === true, - }; + // The schema establishes the shape; its object copy would omit __proto__. + return response as z.infer; }; /** Decode an upstream JSON text payload without trusting its contents. */ diff --git a/src/android/JadxSession.ts b/src/android/JadxSession.ts index f99557c9bf..2f2eee0794 100644 --- a/src/android/JadxSession.ts +++ b/src/android/JadxSession.ts @@ -1,9 +1,7 @@ import { throwIfJadxInterrupted } from "./JadxInterruption.js"; import { Client } from "@modelcontextprotocol/client"; -import { - createAnalysisExecution, - type AnalysisExecution, -} from "../application/AnalysisProvider.js"; +import { createAnalysisExecution } from "../application/AnalysisProvider.js"; +import type { AndroidAnalysisExecution } from "../application/android/AndroidAnalysisPort.js"; import type { AndroidRequest } from "../domain/android/androidAnalysis.js"; import type { AndroidPartialObservation } from "../domain/android/androidPartialObservation.js"; import { @@ -13,12 +11,17 @@ import { AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; -import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; +import { + jsonObjectSchema, + jsonValueSchema, + type JsonValue, +} from "../domain/jsonValue.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import type { OwnedProviderProcessSpawnOptions } from "../process/ProviderProcess.js"; import { analyzeJadxRequest, type JadxToolPort } from "./JadxAnalysis.js"; import { JadxMcpTransport, type JadxLauncher } from "./JadxMcpTransport.js"; import { + parseJadxClassPage, jadxLoadSchema, parseJadxFailure, jadxRuntimeSchema, @@ -32,6 +35,16 @@ import { JADX_LIMITATIONS, } from "./JadxRelease.js"; +type JadxCall = { + readonly operation: string; + readonly input: Readonly>; + readonly response: JsonValue; +}; +type JadxCallObservation = { + readonly complete: JadxCall; + inline?: JsonValue; +}; + /** One owned engine connection; its wire protocol stays inside the Android adapter. */ export class JadxSession { readonly transport: JadxMcpTransport; @@ -47,11 +60,7 @@ export class JadxSession { readonly bridgeHash: string; } | undefined; - #observedCalls: { - readonly operation: string; - readonly input: Readonly>; - readonly response: JsonValue; - }[] = []; + #observedCalls: JadxCallObservation[] = []; #serverName: string | null = null; #serverVersion: string | null = null; #engineVersion: string | null = null; @@ -73,7 +82,7 @@ export class JadxSession { readonly signal: AbortSignal; /** Selected whole-operation deadline; also bounds each upstream call. */ readonly timeoutMs: number; - }): Promise { + }): Promise { const { request, target, snapshot, jarHash, signal, timeoutMs } = context; this.#partialContext = { operation: request.operation, @@ -147,21 +156,39 @@ export class JadxSession { Math.floor(runtime.max_heap_bytes / (1024 * 1024)) || null, } as const; const result = await analyzeJadxRequest(tools, request, loaded, engine); - return createAnalysisExecution(result, JADX_PROVIDER_IDENTITY, { - rawResult: { - server, - bridge_sha256: context.bridgeHash, - jar_sha256: jarHash, - loaded, - calls: this.#observedCalls, - }, - subject: target, - limitations: JADX_LIMITATIONS, - locations: - request.operation === "inspect_android_package" - ? [{ kind: "artifact-path", path: "AndroidManifest.xml" }] - : [], + const raw = jsonObjectSchema.parse({ + server, + bridge_sha256: context.bridgeHash, + jar_sha256: jarHash, + loaded, }); + const execution = createAnalysisExecution( + result, + JADX_PROVIDER_IDENTITY, + { + rawResult: { + ...raw, + calls: this.#observedCalls.map(({ complete }) => complete), + }, + subject: target, + limitations: JADX_LIMITATIONS, + locations: + request.operation === "inspect_android_package" + ? [{ kind: "artifact-path", path: "AndroidManifest.xml" }] + : [], + }, + ); + return request.operation === "search_android_classes" + ? { + ...execution, + searchRawSummary: { + ...raw, + calls: this.#observedCalls.map( + ({ complete, inline }) => inline ?? complete, + ), + }, + } + : execution; } finally { signal.removeEventListener("abort", abort); } @@ -189,7 +216,7 @@ export class JadxSession { server_version: this.#serverVersion, engine_version: this.#engineVersion, }, - calls: this.#observedCalls, + calls: this.#observedCalls.map(({ complete }) => complete), }; } @@ -216,7 +243,7 @@ export class JadxSession { const call = async ( name: string, input: Readonly>, - ): Promise => { + ) => { throwIfJadxInterrupted(signal); // Keep an explicit bound: omitting it falls back to the SDK's 60-second default. const response = await this.#client.callTool( @@ -225,10 +252,12 @@ export class JadxSession { ); const observedResponse = jsonValueSchema.parse(response); const call = { operation: name, input, response: observedResponse }; - this.#observedCalls.push(call); - const envelope = parseJadxEnvelope(response, request.operation); - if (envelope.failed) { - const failure = parseJadxFailure(envelope.text); + const observation: JadxCallObservation = { complete: call }; + this.#observedCalls.push(observation); + const envelope = parseJadxEnvelope(observedResponse, request.operation); + const text = envelope.content[0].text; + if (envelope.isError === true) { + const failure = parseJadxFailure(text); if (failure !== undefined) { if (name === "load_apk" && failure.kind === "jvm-memory-failure") throw new AnalysisResourceConstraintError( @@ -266,18 +295,37 @@ export class JadxSession { throw new ProviderAdapterError("jadx", request.operation, { diagnostics: { upstream_operation: name, - reason: envelope.text, + reason: text, target_path: target.path, target_sha256: target.sha256, }, }); } - return envelope.text; + return { text, envelope, observation }; }; return { - text: call, + text: async (name, input) => (await call(name, input)).text, json: async (name, input) => - parseJadxJson(await call(name, input), request.operation), + parseJadxJson((await call(name, input)).text, request.operation), + listClasses: async (input) => { + const reply = await call("list_classes", input); + const page = parseJadxClassPage( + parseJadxJson(reply.text, request.operation), + ); + const { items, ...audit } = page; + reply.observation.inline = { + ...reply.observation.complete, + inventory_elided: true, + response: { + ...reply.envelope, + content: reply.envelope.content.map((entry) => ({ + ...entry, + text: JSON.stringify({ ...audit, items_omitted: items.length }), + })), + }, + }; + return page; + }, }; } } diff --git a/src/application/android/AndroidAnalysisPort.ts b/src/application/android/AndroidAnalysisPort.ts index aec02ac9dc..165ea000e1 100644 --- a/src/application/android/AndroidAnalysisPort.ts +++ b/src/application/android/AndroidAnalysisPort.ts @@ -2,12 +2,18 @@ import type { AnalysisExecution, ExecutionOptions, } from "../AnalysisProvider.js"; +import type { JsonValue } from "../../domain/jsonValue.js"; import type { AndroidRequest } from "../../domain/android/androidAnalysis.js"; import type { BinaryTarget } from "../../domain/binaryTargetTypes.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import type { Result } from "../../domain/result.js"; import type { ProviderAvailability } from "../AnalysisProvider.js"; +/** Provider-authored inline projection; complete raw observations stay in rawResult. */ +export interface AndroidAnalysisExecution extends AnalysisExecution { + readonly searchRawSummary?: Readonly>; +} + /** Static Android inspection boundary; implementation details belong to the provider. */ export interface AndroidAnalysisPort { /** Inspect the selected JAR and Java runtime without opening an APK. */ @@ -18,5 +24,5 @@ export interface AndroidAnalysisPort { target: BinaryTarget, request: AndroidRequest, options?: ExecutionOptions, - ): Promise>; + ): Promise>; } diff --git a/src/application/android/AndroidAnalysisService.ts b/src/application/android/AndroidAnalysisService.ts index 4e2e5841d4..1f91661415 100644 --- a/src/application/android/AndroidAnalysisService.ts +++ b/src/application/android/AndroidAnalysisService.ts @@ -1,3 +1,5 @@ +import type { EvidenceWriter } from "../investigation/InvestigationRecordPort.js"; +import { summarizeRetainedAndroidSearch } from "./AndroidSearchEvidenceSummary.js"; import type { AndroidAnalysisPort } from "./AndroidAnalysisPort.js"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import { parseBinaryTarget } from "../BinaryTargetResolver.js"; @@ -17,7 +19,10 @@ import { completeProviderEvidence } from "../ProviderOperationExecution.js"; /** Shared CLI/MCP admission, execution and Evidence composition for Android. */ export class AndroidAnalysisService { - constructor(readonly provider: AndroidAnalysisPort) {} + constructor( + readonly provider: AndroidAnalysisPort, + readonly recordEvidence?: EvidenceWriter["recordEvidence"], + ) {} /** Inspect a caller-selected APK and retain producing-provider provenance inline. */ async execute( @@ -44,7 +49,7 @@ export class AndroidAnalysisService { ); if (!executed.ok) return executed; const execution = executed.value; - return completeProviderEvidence( + const completed = completeProviderEvidence( createEvidence( execution.subject === undefined ? target.value @@ -63,5 +68,16 @@ export class AndroidAnalysisService { ), options?.signal, ); + if ( + !completed.ok || + operation !== "search_android_classes" || + execution.searchRawSummary === undefined + ) + return completed; + return summarizeRetainedAndroidSearch( + completed.value, + execution.searchRawSummary, + this.recordEvidence, + ); } } diff --git a/src/application/android/AndroidSearchEvidenceSummary.ts b/src/application/android/AndroidSearchEvidenceSummary.ts index 4db495ece1..9b0f3809f3 100644 --- a/src/application/android/AndroidSearchEvidenceSummary.ts +++ b/src/application/android/AndroidSearchEvidenceSummary.ts @@ -4,66 +4,6 @@ import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import { ok, type Result } from "../../domain/result.js"; -/** - * Class-search pages duplicate matching names already present in normalized - * results; an empty query also returns the complete top-level inventory. - * Keep the selection and pagination audit inline and link the retained - * record carrying every complete producer reply. - */ -const elideInventoryText = (text: string): string | undefined => { - let page: JsonValue; - try { - page = JSON.parse(text) as JsonValue; - } catch { - return undefined; - } - if ( - typeof page !== "object" || - page === null || - Array.isArray(page) || - !Array.isArray(page.items) - ) - return undefined; - const { items, ...audited } = page; - return JSON.stringify({ ...audited, items_omitted: items.length }); -}; - -/** Replace inventoried page bodies with their pagination audit. */ -const summarizeInventoryCall = (call: JsonValue): JsonValue => { - if ( - typeof call !== "object" || - call === null || - Array.isArray(call) || - call.operation !== "list_classes" - ) - return call; - const response = call.response; - if ( - typeof response !== "object" || - response === null || - Array.isArray(response) || - !Array.isArray(response.content) - ) - return call; - let elided = false; - const content = response.content.map((entry: JsonValue) => { - if ( - typeof entry !== "object" || - entry === null || - Array.isArray(entry) || - typeof entry.text !== "string" - ) - return entry; - const summarized = elideInventoryText(entry.text); - if (summarized === undefined) return entry; - elided = true; - return { ...entry, text: summarized }; - }); - return elided - ? { ...call, inventory_elided: true, response: { ...response, content } } - : call; -}; - /** * Retain the complete search Evidence — full raw provider calls included — * in the session record, then deliver a bounded summary Evidence inline. @@ -72,24 +12,13 @@ const summarizeInventoryCall = (call: JsonValue): JsonValue => { */ export const summarizeRetainedAndroidSearch = ( evidence: Evidence, + summary: Readonly>, recordEvidence: EvidenceWriter["recordEvidence"] | undefined, ): Result => { if (recordEvidence === undefined) return ok(evidence); const recorded = recordEvidence(evidence); if (!recorded.ok) return recorded; const subject = evidence.subject; - const raw = evidence.raw_result; - const summarized: JsonValue = - raw === null || - typeof raw !== "object" || - Array.isArray(raw) || - !Array.isArray(raw.calls) - ? raw - : { - ...raw, - complete_evidence_id: evidence.evidence_id, - calls: raw.calls.map(summarizeInventoryCall), - }; return ok( createEvidence( subject === null @@ -107,7 +36,7 @@ export const summarizeRetainedAndroidSearch = ( operation: evidence.operation, parameters: evidence.parameters, result: evidence.normalized_result, - rawResult: summarized, + rawResult: { ...summary, complete_evidence_id: evidence.evidence_id }, confidence: evidence.confidence, limitations: evidence.limitations, locations: evidence.locations, diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 6e647fbbd1..8b4e78f8de 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -892,7 +892,7 @@ const registerConfiguredAnalysisTools = ( ): void => { registerAndroidTools( server, - new AndroidAnalysisService(android), + new AndroidAnalysisService(android, recordEvidence), toolLogger, recordEvidence, withAdmittedAnalysis, diff --git a/src/server/registerAndroidTools.ts b/src/server/registerAndroidTools.ts index a8c29a1873..2caa4c292e 100644 --- a/src/server/registerAndroidTools.ts +++ b/src/server/registerAndroidTools.ts @@ -3,7 +3,6 @@ import type { EvidenceWriter } from "../application/investigation/InvestigationR import { AndroidAnalysisService } from "../application/android/AndroidAnalysisService.js"; import { inspectAndroidPackage } from "../application/android/AndroidPackageInspection.js"; import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; -import { summarizeRetainedAndroidSearch } from "../application/android/AndroidSearchEvidenceSummary.js"; import { toolContract } from "../contracts/toolContracts.js"; import type { Logger } from "pino"; import { @@ -29,23 +28,6 @@ export const registerAndroidTools = ( recordEvidence, withAdmittedAnalysis, ); - // Focused class searches keep complete raw inventory in the retained - // record and deliver a bounded summary inline; the generic handler then - // records and delivers the summary as the search contract's Evidence. - const searchService: ContractToolService<"search_android_classes"> = { - execute: async (operation, input, options) => { - const executed = await service.execute(operation, input, options); - if (!executed.ok) return executed; - return summarizeRetainedAndroidSearch(executed.value, recordEvidence); - }, - }; - const searchHandler = createContractToolHandler( - server, - searchService, - logger, - recordEvidence, - withAdmittedAnalysis, - ); const packageContract = toolContract("inspect_android_package"); const packageService: ContractToolService<"inspect_android_package"> = { execute: (_operation, input, options) => @@ -72,7 +54,7 @@ export const registerAndroidTools = ( server.registerTool( searchContract.name, toolRegistrationOptions(searchContract), - searchHandler(searchContract), + handler(searchContract), ); const classContract = toolContract("inspect_android_class"); server.registerTool( diff --git a/tests/boundary/android/JadxAnalysis.test.ts b/tests/boundary/android/JadxAnalysis.test.ts index 550e6a6526..ccd8bc95b2 100644 --- a/tests/boundary/android/JadxAnalysis.test.ts +++ b/tests/boundary/android/JadxAnalysis.test.ts @@ -50,12 +50,12 @@ it("pages filtered matches independently of the examined inventory and preserves names[4096] = "fixture.TargetDuplicate"; const calls: Record[] = []; const tools: JadxToolPort = { - json: (name, input) => { - expect(name).toBe("list_classes"); + json: () => Promise.reject(new Error("unexpected JSON call")), + listClasses: (input) => { calls.push({ ...input }); const offset = Number(input.offset); return Promise.resolve({ - scope: "top_level", + scope: "top_level" as const, total_classes: 10000, next_offset: offset === 0 ? 8191 : 10000, offset, @@ -94,7 +94,6 @@ it("pages filtered matches independently of the examined inventory and preserves }); it.each([ - { name: "inventory scope", query: "Target", page: { scope: "all_dex" } }, { name: "inventory total", query: "Target", page: { total_classes: 9 } }, { name: "out-of-range cursor", query: "Target", page: { next_offset: 11 } }, { @@ -116,9 +115,10 @@ it.each([ "refuses changed $name instead of reporting complete search coverage", async ({ query, page }) => { const tools: JadxToolPort = { - json: () => + json: () => Promise.reject(new Error("unexpected JSON call")), + listClasses: () => Promise.resolve({ - scope: "top_level", + scope: "top_level" as const, total_classes: 10, next_offset: 10, offset: 0, @@ -151,9 +151,10 @@ it.each([ it("accepts an examined empty inventory without requiring cursor progress", async () => { const tools: JadxToolPort = { - json: () => + json: () => Promise.reject(new Error("unexpected JSON call")), + listClasses: () => Promise.resolve({ - scope: "top_level", + scope: "top_level" as const, total_classes: 0, offset: 0, next_offset: 0, @@ -225,6 +226,8 @@ it("requires explicit direct invoke coverage state in result evidence", () => { it("publishes exact DEX prototypes instead of discarding bridge metadata", async () => { const tools: JadxToolPort = { + listClasses: () => + Promise.reject(new Error("unexpected class inventory call")), json: () => Promise.resolve(summary), text: () => Promise.reject(new Error("unexpected text call")), }; @@ -251,6 +254,8 @@ it("publishes exact DEX prototypes instead of discarding bridge metadata", async it("traces only the selected overloaded method and checks the producer identity", async () => { const calls: Record[] = []; const tools: JadxToolPort = { + listClasses: () => + Promise.reject(new Error("unexpected class inventory call")), json: (name, input) => { calls.push({ name, ...input }); if (name === "get_class_summary") return Promise.resolve(summary); @@ -334,6 +339,8 @@ it("rejects an ambiguous method and a producer response for a different overload items: [], }; const tools: JadxToolPort = { + listClasses: () => + Promise.reject(new Error("unexpected class inventory call")), json: (name) => Promise.resolve(name === "get_class_summary" ? summary : xrefs), text: () => Promise.reject(new Error("unexpected text call")), @@ -381,6 +388,8 @@ const invokeSite = { const traceWithReference = (fields: Record) => analyzeJadxRequest( { + listClasses: () => + Promise.reject(new Error("unexpected class inventory call")), json: (name) => Promise.resolve( name === "get_class_summary" @@ -482,6 +491,8 @@ it.each([ await expect( analyzeJadxRequest( { + listClasses: () => + Promise.reject(new Error("unexpected class inventory call")), json: () => Promise.resolve(invalidSummary), text: () => Promise.reject(new Error("unexpected text call")), }, diff --git a/tests/boundary/android/jadxProtocol.test.ts b/tests/boundary/android/jadxProtocol.test.ts index 70457bfc8c..c860dfc490 100644 --- a/tests/boundary/android/jadxProtocol.test.ts +++ b/tests/boundary/android/jadxProtocol.test.ts @@ -1,6 +1,7 @@ import { expect, it } from "vitest"; import { parseJadxEnvelope, + parseJadxClassPage, parseJadxJson, normalizeJadxText, parseJadxFailure, @@ -36,7 +37,10 @@ it("validates provider text envelopes and rejects unsupported content rather tha { content: [{ type: "text", text: "reported error" }], isError: true }, "inspect_android_package", ), - ).toEqual({ text: "reported error", failed: true }); + ).toEqual({ + content: [{ type: "text", text: "reported error" }], + isError: true, + }); for (const response of [ { content: [] }, { content: [{ type: "image", data: "AA==", mimeType: "image/png" }] }, @@ -109,3 +113,44 @@ it("rejects invalid APK selectors and accepts an explicit zero overload without }).success, ).toBe(true); }); + +it("preserves JSON page and envelope annotations while rejecting unsupported inventory scope", () => { + const page = { + scope: "top_level", + total_classes: 1, + offset: 0, + next_offset: 1, + limit: 4096, + items: ["fixture.Target"], + audit: { source: "雪", missing: null }, + }; + Object.defineProperty(page, "__proto__", { + value: { observed: "page" }, + enumerable: true, + }); + expect(parseJadxClassPage(page)).toEqual(page); + expect(Object.hasOwn(parseJadxClassPage(page), "__proto__")).toBe(true); + expect(() => parseJadxClassPage({ ...page, scope: "all_dex" })).toThrow(); + const response = { + content: [ + { + type: "text", + text: JSON.stringify(page), + annotations: { audience: ["assistant"] }, + }, + ], + _meta: { page: 1 }, + }; + Object.defineProperty(response, "__proto__", { + value: { observed: "envelope" }, + enumerable: true, + }); + Object.defineProperty(response.content[0], "__proto__", { + value: { observed: "content" }, + enumerable: true, + }); + const parsed = parseJadxEnvelope(response, "search_android_classes"); + expect(parsed).toEqual(response); + expect(Object.hasOwn(parsed, "__proto__")).toBe(true); + expect(Object.hasOwn(parsed.content[0], "__proto__")).toBe(true); +}); diff --git a/tests/boundary/filesystem/referenceSourceQueuedDirectories.test.ts b/tests/boundary/filesystem/referenceSourceQueuedDirectories.test.ts index 6bc4be7072..c5df498bc9 100644 --- a/tests/boundary/filesystem/referenceSourceQueuedDirectories.test.ts +++ b/tests/boundary/filesystem/referenceSourceQueuedDirectories.test.ts @@ -1,4 +1,3 @@ -import type { Dir, Dirent } from "node:fs"; import { mkdir, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -7,30 +6,42 @@ import { expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { importReferenceSource } from "../../support/referenceSourceResourceScope.js"; -const { opendirMock } = vi.hoisted(() => ({ opendirMock: vi.fn() })); - -vi.mock("node:fs/promises", async (importOriginal) => ({ - ...(await importOriginal()), - opendir: opendirMock, +const injectedReadFailure = vi.hoisted(() => ({ + path: undefined as string | undefined, + error: undefined as Error | undefined, })); -const directoryHandle = (items: readonly (string | Error | null)[]): Dir => { - let index = 0; +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); return { - async read() { - const item = items[index++]; - if (item instanceof Error) throw item; - return item === null || item === undefined - ? null - : ({ name: item } as Dirent); + ...actual, + opendir: async (...args: Parameters) => { + const handle = await actual.opendir(...args); + const [path] = args; + const { path: failingPath, error } = injectedReadFailure; + if (path !== failingPath || error === undefined) return handle; + + const read = handle.read.bind(handle); + let failureInjected = false; + Object.defineProperty(handle, "read", { + configurable: true, + value: async () => { + const entry = await read(); + if (entry === null && !failureInjected) { + failureInjected = true; + throw error; + } + return entry; + }, + }); + return handle; }, - async close() {}, - } as unknown as Dir; -}; + }; +}); it.skipIf(process.platform === "win32")( "traverses directories lstat-observed before their parent enumeration failed", - async () => { + async ({ onTestFinished }) => { const root = await createTestTempDirectory( "rea-queued-source-directories-", ); @@ -46,18 +57,11 @@ it.skipIf(process.platform === "win32")( new Error("injected directory read failure"), { code: "EIO" }, ); - opendirMock.mockImplementation(async (path: string) => { - if (path === root) return directoryHandle(["container", null]); - if (path === container) - return directoryHandle([ - "visible.txt", - "hidden", - "alias-file.txt", - "alias-directory", - readFailure, - ]); - if (path === hidden) return directoryHandle(["target.txt", null]); - throw new Error(`Unexpected directory traversal: ${path}`); + injectedReadFailure.path = container; + injectedReadFailure.error = readFailure; + onTestFinished(() => { + injectedReadFailure.path = undefined; + injectedReadFailure.error = undefined; }); const imported = await importReferenceSource({ @@ -73,6 +77,7 @@ it.skipIf(process.platform === "win32")( path: "container", kind: "directory", tree_state: "unreadable", + limitations: [expect.stringContaining(readFailure.message)], }), expect.objectContaining({ path: "container/hidden", diff --git a/tests/boundary/gpu/codeObjects.test.ts b/tests/boundary/gpu/codeObjects.test.ts index 3b5c01a208..cbe4ced8bd 100644 --- a/tests/boundary/gpu/codeObjects.test.ts +++ b/tests/boundary/gpu/codeObjects.test.ts @@ -429,7 +429,7 @@ it.each([ ]); }); it.each([ - ["parameter", ".u64 output", ".u64 .ptr .global output"], + ["parameter", ".u64 output", ".u64 .vendor_specific output"], ["shared", "scratch[32]", "scratch[]"], ] as const)( "marks unknown PTX %s declarations partial while retaining known facts", @@ -459,6 +459,24 @@ it.each([ } }, ); +it("keeps supported PTX pointer qualifiers complete through the provider", async () => { + const bytes = Buffer.from( + ptxFixture.replace(".u64 output", ".u64 .ptr .global output"), + ); + const result = await inspect(bytes); + if (!result.ok) throw result.error; + expect(result.value.objects[0]!.inspection).toBe("complete"); + expect(result.value.coverage.payloads).toBe("complete"); + expect(result.value.ptx_artifacts[0]!.text).toBe(bytes.toString()); + expect(result.value.kernels[0]).toMatchObject({ + parameter_count: 2, + parameter_bytes: 12, + parameters: [ + { name: "output", offset: 0, size: 8 }, + { name: "value", offset: 8, size: 4 }, + ], + }); +}); it.each(["whitespace", "comment"] as const)( "recognizes standalone PTX after more than 64 KiB of leading %s", async (kind) => { diff --git a/tests/boundary/mcp/gdbCloseDisposition.test.ts b/tests/boundary/mcp/gdbCloseDisposition.test.ts index f49455760d..4cff147d26 100644 --- a/tests/boundary/mcp/gdbCloseDisposition.test.ts +++ b/tests/boundary/mcp/gdbCloseDisposition.test.ts @@ -1,12 +1,12 @@ import { randomUUID } from "node:crypto"; -import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { chmod, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { McpServer } from "@modelcontextprotocol/server"; -import { expect, it } from "vitest"; +import { expect, it, onTestFinished } from "vitest"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; import { GdbSessionManager } from "../../../src/gdb/GdbSessionManager.js"; import { silentLogger } from "../../../src/logger.js"; @@ -16,7 +16,7 @@ import { createToolResultDelivery } from "../../../src/server/toolResult.js"; it.skipIf(process.platform === "win32").each(["kill", "detach"])( "retains a valid GDB session through disposition refusal and explicit %s recovery", async (disposition) => { - const directory = await mkdtemp(join(tmpdir(), "rea-gdb-disposition-")); + const directory = await createTestTempDirectory("rea-gdb-disposition-"); const provider = join(directory, "fake-gdb.mjs"); await writeFile( provider, @@ -33,6 +33,10 @@ createInterface({ input: process.stdin }).on('line', line => { process.stdout.write(token + '^running\\n*stopped,reason="breakpoint-hit",thread-id="1"\\n(gdb)\\n'); return; } + if (command === '-gdb-show mi-async') { + process.stdout.write(token + '^done,value="off"\\n(gdb)\\n'); + return; + } const disposition = command === '-interpreter-exec console "kill"' || command === '-interpreter-exec console "detach"'; if (disposition) live = false; const result = command === '-list-thread-groups --recurse 1' @@ -50,10 +54,12 @@ createInterface({ input: process.stdin }).on('line', line => { platform: "linux", environment: { REA_GDB_COMMAND: provider }, }); + onTestFinished(() => manager.closeAll()); const server = new McpServer({ name: "gdb-disposition-test", version: "1", }); + onTestFinished(() => server.close()); registerGdbTools( server, manager, @@ -61,80 +67,67 @@ createInterface({ input: process.stdin }).on('line', line => { createToolResultDelivery(undefined), ); const client = new Client({ name: "gdb-disposition-test", version: "1" }); + onTestFinished(() => client.close()); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - let sessionId: string | undefined; - try { - await server.connect(serverTransport); - await client.connect(clientTransport); - const started = await client.callTool({ - name: "start_gdb_session", - arguments: {}, - }); - sessionId = toolContract("start_gdb_session").outputSchema.parse( - started.structuredContent, - ).session_id; - const running = await client.callTool({ - name: "gdb_console", - arguments: { session_id: sessionId, command: "run" }, - }); - expect(running.isError).not.toBe(true); - for (let attempt = 0; attempt < 2; attempt += 1) { - const refusal = await client.callTool({ - name: "close_gdb_session", - arguments: { session_id: sessionId }, - }); - expect(refusal.isError).toBe(true); - expect(refusal.structuredContent).toMatchObject({ - error: { - code: "capability_unavailable", - category: "unavailable", - retryable: true, - remediation: { action: expect.stringContaining("gdb_console") }, - details: { - provider_id: "gnu.gdb", - operation: "close_gdb_session", - reason: expect.stringContaining("inferior"), - }, - }, - }); - expect(JSON.stringify(refusal.structuredContent)).not.toContain( - "invalid_value", - ); - expect(manager.status(sessionId)?.state).toBe("ready"); - } - const disposed = await client.callTool({ - name: "gdb_console", - arguments: { session_id: sessionId, command: disposition }, - }); - expect(disposed.isError).not.toBe(true); - const closed = await client.callTool({ + await server.connect(serverTransport); + await client.connect(clientTransport); + const started = await client.callTool({ + name: "start_gdb_session", + arguments: {}, + }); + const sessionId = toolContract("start_gdb_session").outputSchema.parse( + started.structuredContent, + ).session_id; + const running = await client.callTool({ + name: "gdb_console", + arguments: { session_id: sessionId, command: "run" }, + }); + expect(running.isError).not.toBe(true); + for (let attempt = 0; attempt < 2; attempt += 1) { + const refusal = await client.callTool({ name: "close_gdb_session", arguments: { session_id: sessionId }, }); - expect(closed.isError).not.toBe(true); - expect(closed.structuredContent).toMatchObject({ - target_state: "none_observed", + expect(refusal.isError).toBe(true); + expect(refusal.structuredContent).toMatchObject({ + error: { + code: "capability_unavailable", + category: "unavailable", + retryable: true, + remediation: { action: expect.stringContaining("gdb_console") }, + details: { + provider_id: "gnu.gdb", + operation: "close_gdb_session", + reason: expect.stringContaining("inferior"), + }, + }, }); - expect(manager.status(sessionId)).toBeUndefined(); - const unknown = await client.callTool({ - name: "close_gdb_session", - arguments: { session_id: randomUUID() }, - }); - expect(unknown.structuredContent).toMatchObject({ - error: { code: "invalid_request", category: "invalid_input" }, - }); - } finally { - if (sessionId !== undefined && manager.status(sessionId) !== undefined) { - const disposed = await manager.execute(sessionId, "kill"); - if (!disposed.ok) throw disposed.error; - const closed = await manager.close(sessionId); - if (!closed.ok) throw closed.error; - } - await client.close(); - await server.close(); - await manager.closeAll(); - await rm(directory, { recursive: true, force: true }); + expect(JSON.stringify(refusal.structuredContent)).not.toContain( + "invalid_value", + ); + expect(manager.status(sessionId)?.state).toBe("ready"); } + const disposed = await client.callTool({ + name: "gdb_console", + arguments: { session_id: sessionId, command: disposition }, + }); + expect(disposed.isError).not.toBe(true); + const closed = await client.callTool({ + name: "close_gdb_session", + arguments: { session_id: sessionId }, + }); + expect(closed.isError).not.toBe(true); + expect(closed.structuredContent).toMatchObject({ + target_state: "none_observed", + }); + expect(manager.status(sessionId)).toBeUndefined(); + const unknown = await client.callTool({ + name: "close_gdb_session", + arguments: { session_id: randomUUID() }, + }); + expect(unknown.structuredContent).toMatchObject({ + error: { code: "invalid_request", category: "invalid_input" }, + }); }, ); diff --git a/tests/boundary/process/androidSearchEvidenceBudget.test.ts b/tests/boundary/process/androidSearchEvidenceBudget.test.ts index 7f5be8c0ac..eac53ea19f 100644 --- a/tests/boundary/process/androidSearchEvidenceBudget.test.ts +++ b/tests/boundary/process/androidSearchEvidenceBudget.test.ts @@ -1,7 +1,10 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { expect, it as test, onTestFinished } from "vitest"; +import { AndroidAnalysisService } from "../../../src/application/android/AndroidAnalysisService.js"; import { androidResultSchemas } from "../../../src/domain/android/androidAnalysis.js"; -import { parseEvidence } from "../../../src/domain/evidence.js"; +import { EvidenceIntegrityError } from "../../../src/domain/evidenceErrors.js"; +import { parseEvidence, type Evidence } from "../../../src/domain/evidence.js"; +import { err } from "../../../src/domain/result.js"; import { createServer } from "../../../src/server/createServer.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { @@ -11,29 +14,54 @@ import { const INVENTORY_CLASS_COUNT = 7096; -/** Count inventoried names across raw call records: MCP pages ride in text content. */ -const rawInventoryNames = (raw: unknown): number => { - const calls = - ( - raw as { - calls?: { - operation: string; - response: { content?: { text?: string }[] }; - }[]; - } - ).calls ?? []; - return calls - .flatMap((call) => call.response.content ?? []) - .map((entry) => - entry.text === undefined ? {} : (JSON.parse(entry.text) as unknown), - ) - .filter((page) => Array.isArray((page as { items?: unknown[] }).items)) - .flatMap((page) => (page as { items: unknown[] }).items).length; +interface RawJadxCall { + readonly operation: string; + readonly input?: { readonly offset?: number; readonly query?: string }; + readonly response: { + readonly _meta?: Readonly>; + readonly content?: readonly { + readonly text?: string; + readonly annotations?: unknown; + readonly _meta?: Readonly>; + }[]; + }; +} + +const callsOf = (raw: unknown): readonly RawJadxCall[] => + (raw as { calls?: readonly RawJadxCall[] }).calls ?? []; + +const listClassCalls = (raw: unknown): readonly RawJadxCall[] => + callsOf(raw).filter(({ operation }) => operation === "list_classes"); + +const responsePage = (call: RawJadxCall): Record => { + const text = call.response.content?.[0]?.text; + if (text === undefined) throw new Error("JADX response omitted page text"); + const page: unknown = JSON.parse(text); + if (page === null || typeof page !== "object" || Array.isArray(page)) + throw new Error("JADX page text was not an object"); + return page as Record; }; +const callMetadata = (call: RawJadxCall) => ({ + offset: call.input?.offset, + envelope: call.response._meta, + content: call.response.content?.[0]?._meta, + annotations: call.response.content?.[0]?.annotations, + fixture: responsePage(call).fixture_metadata, +}); + +/** Count names in complete source pages using the producer's raw JSON. */ +const rawInventoryNames = (raw: unknown): number => + listClassCalls(raw).reduce((sum, call) => { + const items = responsePage(call).items; + if (!Array.isArray(items)) + throw new Error("Complete page omitted class names"); + return sum + items.length; + }, 0); + test("delivers bounded search_android_classes Evidence while retaining the complete inventory", async () => { const { provider, apk, launches } = await createJadxProtocolFixture( - "normal", + "search-audit-metadata", undefined, { classCount: INVENTORY_CLASS_COUNT - 1 }, ); @@ -111,6 +139,44 @@ test("delivers bounded search_android_classes Evidence while retaining the compl expect(rawInventoryNames(inventorySource?.raw_result)).toBe( INVENTORY_CLASS_COUNT, ); + const summaryPages = listClassCalls(inventory.raw_result); + const completePages = listClassCalls(inventorySource?.raw_result); + expect(summaryPages).toHaveLength(2); + expect(completePages).toHaveLength(2); + expect( + summaryPages.map((call) => { + const page = responsePage(call); + return { + offset: page.offset, + next_offset: page.next_offset, + total_classes: page.total_classes, + limit: page.limit, + items_omitted: page.items_omitted, + }; + }), + ).toEqual([ + { + offset: 0, + next_offset: 4096, + total_classes: INVENTORY_CLASS_COUNT, + limit: 4096, + items_omitted: 4096, + }, + { + offset: 4096, + next_offset: INVENTORY_CLASS_COUNT, + total_classes: INVENTORY_CLASS_COUNT, + limit: 4096, + items_omitted: INVENTORY_CLASS_COUNT - 4096, + }, + ]); + expect(summaryPages.map(callMetadata)).toEqual( + completePages.map(callMetadata), + ); + expect(completePages.map(responsePage).map((page) => page.items)).toEqual([ + expect.arrayContaining(["fixture.Target", "fixture.Class0"]), + expect.arrayContaining(["fixture.Class4095", "fixture.Class7094"]), + ]); const empty = await search("fixture.Missing"); expect( @@ -122,28 +188,125 @@ test("delivers bounded search_android_classes Evidence while retaining the compl coverage: "complete", }); expect(JSON.stringify(empty.raw_result)).not.toContain("fixture.Class"); + expect(listClassCalls(empty.raw_result).map(responsePage)).toMatchObject([ + { + offset: 0, + next_offset: INVENTORY_CLASS_COUNT, + total_classes: INVENTORY_CLASS_COUNT, + limit: 4096, + items_omitted: 0, + fixture_metadata: { + page_offset: 0, + retained: "unknown page field", + }, + }, + ]); await client.close(); await server.close(); await verifyJadxFixtureCleanup(launches); }); -test("keeps complete matching producer rows inline without a retaining session", async () => { +test("keeps the complete paged inventory inline without a retaining session", async () => { const { service, apk, launches, provider } = await createJadxProtocolFixture( - "normal", + "search-audit-metadata", undefined, { classCount: INVENTORY_CLASS_COUNT - 1 }, ); const result = await service.execute("search_android_classes", { path: apk, - query: "Target", + query: "", }); if (!result.ok) throw result.error; - // Complete CLI Evidence keeps every matching row, without unrelated names. - expect(rawInventoryNames(result.value.raw_result)).toBe(1); + // No retaining writer means the CLI receives the unmodified source pages. + expect(rawInventoryNames(result.value.raw_result)).toBe( + INVENTORY_CLASS_COUNT, + ); + const fullPages = listClassCalls(result.value.raw_result); + expect(fullPages).toHaveLength(2); + expect(fullPages.map(responsePage)).toMatchObject([ + { + offset: 0, + next_offset: 4096, + total_classes: INVENTORY_CLASS_COUNT, + limit: 4096, + items: expect.arrayContaining(["fixture.Target", "fixture.Class0"]), + }, + { + offset: 4096, + next_offset: INVENTORY_CLASS_COUNT, + total_classes: INVENTORY_CLASS_COUNT, + limit: 4096, + items: expect.arrayContaining(["fixture.Class4095", "fixture.Class7094"]), + }, + ]); + expect(responsePage(fullPages[0]!)).toMatchObject({ + fixture_metadata: { + page_offset: 0, + retained: "unknown page field", + }, + }); + for (const [index, call] of fullPages.entries()) { + expect(call.response._meta).toEqual({ + fixture_envelope_offset: index * 4096, + }); + expect(call.response.content?.[0]?._meta).toEqual({ + fixture_content_offset: index * 4096, + }); + expect(call.response.content?.[0]?.annotations).toEqual({ + audience: ["user"], + priority: 0.5, + }); + } expect(JSON.stringify(result.value.raw_result)).not.toContain( - "fixture.Class", + "inventory_elided", + ); + expect(JSON.stringify(result.value.raw_result)).not.toContain( + "items_omitted", ); await provider.close(); await verifyJadxFixtureCleanup(launches); }); + +test("withholds inline summary when retaining complete Evidence fails", async () => { + const { provider, apk, launches } = await createJadxProtocolFixture( + "search-audit-metadata", + undefined, + { classCount: INVENTORY_CLASS_COUNT - 1 }, + ); + const writeFailure = new EvidenceIntegrityError( + "The complete Android search Evidence could not be retained", + ); + const retained: Evidence[] = []; + const service = new AndroidAnalysisService(provider, (evidence) => { + retained.push(evidence); + return err(writeFailure); + }); + + const result = await service.execute("search_android_classes", { + path: apk, + query: "", + }); + + expect(result).toEqual({ ok: false, error: writeFailure }); + expect(retained).toHaveLength(1); + expect(retained[0]?.operation).toBe("search_android_classes"); + expect( + androidResultSchemas.search_android_classes.parse( + retained[0]?.normalized_result, + ), + ).toMatchObject({ + total_classes: INVENTORY_CLASS_COUNT, + matches: expect.arrayContaining(["fixture.Target", "fixture.Class7094"]), + coverage: "complete", + }); + expect(rawInventoryNames(retained[0]?.raw_result)).toBe( + INVENTORY_CLASS_COUNT, + ); + expect(JSON.stringify(retained[0]?.raw_result)).not.toContain( + "items_omitted", + ); + + await provider.close(); + await verifyJadxFixtureCleanup(launches); +}); diff --git a/tests/boundary/providers/android/androidLateCompletion.test.ts b/tests/boundary/providers/android/androidLateCompletion.test.ts index a4f22bb99b..0293586dad 100644 --- a/tests/boundary/providers/android/androidLateCompletion.test.ts +++ b/tests/boundary/providers/android/androidLateCompletion.test.ts @@ -2,6 +2,7 @@ import { expect, it } from "vitest"; import { AndroidAnalysisService } from "../../../../src/application/android/AndroidAnalysisService.js"; import { AnalysisCancelledError } from "../../../../src/domain/analysisErrorCore.js"; +import { ok } from "../../../../src/domain/result.js"; import { parseEvidence } from "../../../../src/domain/evidence.js"; import type { AnalysisExecution } from "../../../../src/application/AnalysisProvider.js"; import { createJadxProtocolFixture } from "../../../fixtures/android/jadx.js"; @@ -32,18 +33,25 @@ it.each([ if (!control.ok) throw control.error; const controller = new AbortController(); const observations: AnalysisExecution[] = []; - const service = new AndroidAnalysisService({ - inspectAvailability: fixture.provider.inspectAvailability.bind( - fixture.provider, - ), - close: fixture.provider.close.bind(fixture.provider), - execute: async (...args) => { - const completed = await fixture.provider.execute(...args); - if (completed.ok) observations.push(completed.value); - controller.abort(); - return completed; + let records = 0; + const service = new AndroidAnalysisService( + { + inspectAvailability: fixture.provider.inspectAvailability.bind( + fixture.provider, + ), + close: fixture.provider.close.bind(fixture.provider), + execute: async (...args) => { + const completed = await fixture.provider.execute(...args); + if (completed.ok) observations.push(completed.value); + controller.abort(); + return completed; + }, }, - }); + () => { + records += 1; + return ok("added" as const); + }, + ); const result = await service.execute(operation, input, { signal: controller.signal, }); @@ -66,6 +74,7 @@ it.each([ normalized_result: control.value.normalized_result, }); expect(observations).toHaveLength(1); + expect(records).toBe(0); expect(partial.raw_result).toEqual(observations[0]?.rawResult); expect(control.value.confidence).toBe( operation === "inspect_android_method" ? "derived" : "observed", diff --git a/tests/fixtures/android/jadx-mcp.mjs b/tests/fixtures/android/jadx-mcp.mjs index a2ac588406..470b57b1c8 100644 --- a/tests/fixtures/android/jadx-mcp.mjs +++ b/tests/fixtures/android/jadx-mcp.mjs @@ -127,14 +127,33 @@ register("list_classes", ({ offset, limit, query = "" }) => { const name = classes[nextOffset++]; if (name.includes(query)) matching.push(name); } - return json({ + const page = { scope: "top_level", total_classes: classes.length, next_offset: nextOffset, offset, limit, items: mode === "empty-page" ? [] : matching, - }); + ...(mode === "search-audit-metadata" + ? { + fixture_metadata: { + page_offset: offset, + retained: "unknown page field", + }, + } + : {}), + }; + const response = json(page); + if (mode !== "search-audit-metadata") return response; + return { + ...response, + _meta: { fixture_envelope_offset: offset }, + content: response.content.map((entry) => ({ + ...entry, + annotations: { audience: ["user"], priority: 0.5 }, + _meta: { fixture_content_offset: offset }, + })), + }; }); register("get_class_summary", () => json({