From bafe3784cd6fd3f46dd472a913e97f08f4d7427f Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:17:55 +0800 Subject: [PATCH] fix(native): report dispatch target applicability (#8400) * fix(native): report dispatch target applicability * fix(native): clarify unavailable mode recovery * test(kuna): avoid overlapping verifier imports * docs(native): explain mode-specific discovery * fix(native): preserve unbound provider recovery --- docs/mcp-contracts.md | 7 + scripts/verify-real-kuna.mjs | 31 ++++ src/application/AnalysisProvider.ts | 3 + src/application/CapabilityInventory.ts | 16 +- .../binary/BinarySessionStatus.test.ts | 144 ++++++++++++++++++ src/application/binary/BinarySessionStatus.ts | 8 +- src/domain/native/objcSwiftMetadata.ts | 14 +- src/domain/targetApplicability.ts | 24 +++ src/native/NativeMacOSProviderMetadata.ts | 6 +- 9 files changed, 246 insertions(+), 7 deletions(-) create mode 100644 src/domain/targetApplicability.ts diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 66f1311869..46c47729d3 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -765,6 +765,13 @@ finish without reading relationships, even when neither traversal mode is available. Discovery does not block that direct invocation or replace its typed partial/error outcomes. +`trace_native_ui_action` reports authored-UI and native-seed modes separately. +Apple dispatch metadata requires a Mach-O target; another provider may supply +the symbol-only alternative on other formats. If neither mode is available, +read each mode's missing operations and remediation: a remedy for an authored +app-bundle route does not by itself describe the native-seed route. Availability +does not guarantee that every selector or provider operation will succeed. + With complete detail, the `effects` and `annotations` arrays list the true members of their fixed vocabularies. A known member omitted from either array is false. This applies only to these closed flag sets; it does not make an omitted tool, provider, mode, diff --git a/scripts/verify-real-kuna.mjs b/scripts/verify-real-kuna.mjs index b301013c6d..13659746b8 100644 --- a/scripts/verify-real-kuna.mjs +++ b/scripts/verify-real-kuna.mjs @@ -35,6 +35,7 @@ import { verifyKunaStrings, } from "./lib/kuna-string-verifier.mjs"; import { nativeKunaIncomingSites } from "./lib/kuna-native-reference-verifier.mjs"; +import { MULTI_MODE_REMEDIATION } from "../dist/application/CapabilityInventory.js"; import { assertVerifierCleanupComplete, completeVerifierRun, @@ -298,6 +299,36 @@ const verifyKuna = async ( const answers = []; let coldFailure; try { + const status = await cold.call("binary_session", { detail: "complete" }); + if (status.value.format === "elf") { + const availability = status.value.tool_availability; + const dispatch = availability.find( + ({ name }) => name === "inspect_native_dispatch_metadata", + ); + const trace = availability.find( + ({ name }) => name === "trace_native_ui_action", + ); + assert.equal(dispatch?.available, false); + assert.equal( + trace?.modes?.find(({ name }) => name === "native_seed_call_trace") + ?.available, + false, + ); + if (process.platform === "darwin") { + const authored = trace?.modes?.find( + ({ name }) => name === "authored_ui_dispatch", + ); + const nativeSeed = trace?.modes?.find( + ({ name }) => name === "native_seed_call_trace", + ); + assert.equal(trace?.remediation, MULTI_MODE_REMEDIATION); + assert.notEqual(authored?.remediation, nativeSeed?.remediation); + assert.ok( + authored?.missing_operations.includes("decode_interface_builder"), + ); + assert.ok(nativeSeed?.missing_operations.includes("list_names")); + } + } answers.push({ strings: await verifyKunaStrings({ call: cold.call, diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index ed926de452..391a9d5ec3 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -2,6 +2,7 @@ import type { AnalysisWorkScope } from "./AnalysisWorkScope.js"; import type { AnalysisObservation } from "../domain/analysisObservation.js"; import type { ProviderInspectionScope } from "./ProviderInspectionScope.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import type { TargetApplicability } from "../domain/targetApplicability.js"; import type { OfficialToolName } from "../contracts/officialToolContracts.js"; import type { EnhancedToolName } from "../contracts/enhancedInputs.js"; import type { NativeToolName } from "../contracts/native/nativeToolContracts.js"; @@ -260,6 +261,8 @@ type CapabilityAvailability = }; export type CapabilityDescriptor = CapabilityAvailability & { + /** Provider-owned static target requirements, evaluated for an open target. */ + readonly targetApplicability?: TargetApplicability; /** * Snapshot opts immutable, profile-bound reads into replay despite incidental * private filesystem writes. Live state must always be re-observed. Omitting diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index ca01a2b86d..69534371e3 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -126,6 +126,10 @@ type AvailabilityModeResult = | (Extract & { readonly reason: ToolUnavailabilityReason; }); + +/** Recovery when unavailable modes require different actions. */ +export const MULTI_MODE_REMEDIATION = + "No mode is currently advertised as available. Inspect each mode's missing operations and remediation."; type AvailabilityContext = { readonly name: string; readonly kind: ToolKind; @@ -782,6 +786,7 @@ const targetAvailability = ({ const descriptor = descriptors.get(name); if ( targetOpen && + kind !== "enhanced" && descriptor?.available === false && (descriptor.availability_code === "target_kind_unsupported" || descriptor.availability_code === "target_format_unsupported") @@ -979,6 +984,10 @@ const availabilityForModes = ( )[0]; const specificFailureRemediation = failure?.reason === "provider_missing" ? undefined : failure?.remediation; + const distinctModeRemediations = + defaultMode === undefined && + failures.length > 1 && + new Set(failures.map((mode) => mode.remediation)).size > 1; const facts = { ...(defaultMode === undefined ? {} @@ -996,9 +1005,10 @@ const availabilityForModes = ( return { ...facts, reason: failure?.reason ?? "provider_missing", - remediation: - specificFailureRemediation ?? - `Configure a provider for one of: ${missingOperations.join(", ")}.`, + remediation: distinctModeRemediations + ? MULTI_MODE_REMEDIATION + : (specificFailureRemediation ?? + `Configure a provider for one of: ${missingOperations.join(", ")}.`), }; }; diff --git a/src/application/binary/BinarySessionStatus.test.ts b/src/application/binary/BinarySessionStatus.test.ts index c1d97d8eda..e012a7139a 100644 --- a/src/application/binary/BinarySessionStatus.test.ts +++ b/src/application/binary/BinarySessionStatus.test.ts @@ -28,6 +28,10 @@ import { nativeMacOSCapabilities, } from "../../native/NativeMacOSProviderMetadata.js"; import { NATIVE_MACOS_PROVIDER_IDENTITY } from "../../native/NativeMacosProviderIdentity.js"; +import { + buildCapabilityInventory, + MULTI_MODE_REMEDIATION, +} from "../CapabilityInventory.js"; const projectedStatusSchema = z.object({ capabilities: sessionProviderFacts.shape.capabilities, @@ -517,6 +521,146 @@ it.each(["elf", "pe"] as const)( }, ); +const dispatchTarget = (format: "elf" | "mach-o"): BinaryTarget => ({ + kind: "executable", + format, + path: "/fixture", + sha256: "a".repeat(64), + architecture: "x86_64", + availableArchitectures: ["x86_64"], +}); + +it.each([ + { format: "elf", symbols: false, nativeAvailable: false, route: false }, + { format: "elf", symbols: true, nativeAvailable: false, route: true }, + { format: "mach-o", symbols: false, nativeAvailable: true, route: true }, +] as const)( + "projects $format dispatch applicability and symbol fallback ($symbols)", + async ({ format, symbols, nativeAvailable, route }) => { + const target = dispatchTarget(format); + const native = nativeMacOSCapabilities("darwin").find( + (item) => item.operation === "inspect_native_dispatch_metadata", + ); + if (native === undefined) throw new Error("Missing native dispatch reader"); + const capabilities = [ + native, + descriptor(providerA, "decode_interface_builder", []), + descriptor(providerA, "procedure_address", []), + descriptor(providerA, "procedure_callees", []), + ...(symbols ? [descriptor(providerA, "list_names", [])] : []), + ]; + const status = await projectStatus(capabilities, [], { target }); + const parsed = projectedStatusSchema.parse(status); + const projected = expandCanonicalCapabilities(parsed.capabilities).find( + (item) => item.operation === "inspect_native_dispatch_metadata", + ); + expect(projected).toMatchObject({ + available: nativeAvailable, + availability_code: nativeAvailable ? null : "target_format_unsupported", + }); + const inventory = buildCapabilityInventory(status, { + processCaptureEnabled: false, + }); + const dispatch = inventory.find( + (item) => item.name === "inspect_native_dispatch_metadata", + ); + expect(dispatch?.available).toBe(route); + const trace = inventory.find( + (item) => item.name === "trace_native_ui_action", + ); + expect( + trace?.modes?.find((mode) => mode.name === "native_seed_call_trace"), + ).toMatchObject({ + available: route, + missing_operations: route + ? [] + : ["inspect_native_dispatch_metadata", "list_names"], + }); + if (!route) { + expect(trace?.remediation).toBe(MULTI_MODE_REMEDIATION); + const authored = trace?.modes?.find( + (mode) => mode.name === "authored_ui_dispatch", + ); + const nativeSeed = trace?.modes?.find( + (mode) => mode.name === "native_seed_call_trace", + ); + expect(authored?.remediation).not.toBe(nativeSeed?.remediation); + expect(authored?.missing_operations).toContain( + "decode_interface_builder", + ); + } + }, +); + +it("does not impose the Apple reader's target rule on another provider", async () => { + const target = dispatchTarget("elf"); + const status = await projectStatus( + [descriptor(providerA, "inspect_native_dispatch_metadata", [])], + [], + { target }, + ); + const projected = expandCanonicalCapabilities( + projectedStatusSchema.parse(status).capabilities, + ); + expect(projected).toMatchObject([ + { operation: "inspect_native_dispatch_metadata", available: true }, + ]); + const inventory = buildCapabilityInventory(status, { + processCaptureEnabled: false, + }); + expect( + inventory.find((item) => item.name === "inspect_native_dispatch_metadata"), + ).toMatchObject({ available: true }); +}); + +it("keeps distinct recovery for an unbound non-bundle session", async () => { + const native = nativeMacOSCapabilities("darwin").find( + (item) => item.operation === "inspect_native_dispatch_metadata", + ); + if (native === undefined) throw new Error("Missing native dispatch reader"); + const status = await projectStatus( + [native, descriptor(providerA, "decode_interface_builder", [])], + [], + { target: dispatchTarget("mach-o") }, + ); + const inventory = buildCapabilityInventory(status, { + processCaptureEnabled: false, + }); + const trace = inventory.find( + (item) => item.name === "trace_native_ui_action", + ); + expect(trace).toMatchObject({ + available: false, + reason: "provider_unavailable", + remediation: MULTI_MODE_REMEDIATION, + }); + const authored = trace?.modes?.find( + (mode) => mode.name === "authored_ui_dispatch", + ); + const nativeSeed = trace?.modes?.find( + (mode) => mode.name === "native_seed_call_trace", + ); + expect(authored?.missing_operations).toContain("decode_interface_builder"); + expect(nativeSeed?.missing_operations).toContain("procedure_address"); + expect(authored?.remediation).not.toBe(nativeSeed?.remediation); +}); + +it("keeps host unavailability ahead of target applicability", async () => { + const native = nativeMacOSCapabilities("linux").find( + (item) => item.operation === "inspect_native_dispatch_metadata", + ); + if (native === undefined) throw new Error("Missing native dispatch reader"); + const target = dispatchTarget("elf"); + const projected = await project([native], [], new Map(), target); + expect(expandCanonicalCapabilities(projected.capabilities)).toMatchObject([ + { + operation: "inspect_native_dispatch_metadata", + available: false, + availability_code: "unsupported_host", + }, + ]); +}); + describe("binary session summary projection", () => { it("keeps each candidate's status, code and reason in summary without diagnostics or capability sets", async () => { const unavailable: AnalysisProviderCandidateStatus = { diff --git a/src/application/binary/BinarySessionStatus.ts b/src/application/binary/BinarySessionStatus.ts index 563b0637e1..91aa615cc5 100644 --- a/src/application/binary/BinarySessionStatus.ts +++ b/src/application/binary/BinarySessionStatus.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { InvestigationStatistics } from "../../domain/investigationLifecycle.js"; import { artifactTargetRefusal } from "../../domain/apple/artifactTargetApplicability.js"; +import { targetApplicabilityRefusal } from "../../domain/targetApplicability.js"; import type { BinaryTarget } from "../../domain/binaryTargetTypes.js"; import type { ResolvedAppBundleResourceRoot } from "../AppBundleExecutable.js"; import { binaryTargetSummary } from "./BinaryTargetSummary.js"; @@ -76,7 +77,12 @@ export const binarySessionStatus = async ({ const targetRefusal = target === undefined ? undefined - : artifactTargetRefusal(descriptor.operation, target); + : descriptor.targetApplicability === undefined + ? artifactTargetRefusal(descriptor.operation, target) + : targetApplicabilityRefusal( + descriptor.targetApplicability, + target, + ); if (targetRefusal !== undefined && descriptor.available) return { ...descriptor, diff --git a/src/domain/native/objcSwiftMetadata.ts b/src/domain/native/objcSwiftMetadata.ts index 02ebb2bb4b..75e1a15c5b 100644 --- a/src/domain/native/objcSwiftMetadata.ts +++ b/src/domain/native/objcSwiftMetadata.ts @@ -2,10 +2,13 @@ import { objcClassSymbol } from "../objcClassSymbol.js"; import { z } from "zod"; import { BINARY_ARCHITECTURES, - isMachOFormat, type BinaryTarget, } from "../binaryTargetTypes.js"; import { providerSchema } from "../evidence.js"; +import { + targetApplicabilityRefusal, + type TargetApplicability, +} from "../targetApplicability.js"; import { isSwiftSymbolName } from "../swiftSymbolClassification.js"; import { nativeCallReferenceSchema } from "./nativeCallReference.js"; import { machoTargetSelectionSchema } from "../apple/machoTargetSelection.js"; @@ -20,8 +23,15 @@ import { } from "../unicodeCodePointOrder.js"; /** Targets admitted to the Apple byte reader; the reader validates slice support. */ +export const APPLE_DISPATCH_TARGET_APPLICABILITY: TargetApplicability = { + kinds: ["executable"], + formats: ["mach-o", "mach-o-universal"], + reason: + "Binary Apple metadata requires Mach-O; other providers can report symbol-only metadata", +}; export const isAppleDispatchMetadataTarget = (target: BinaryTarget): boolean => - target.kind === "executable" && isMachOFormat(target.format); + targetApplicabilityRefusal(APPLE_DISPATCH_TARGET_APPLICABILITY, target) === + undefined; /** Objective-C method type. */ export const objcMethodTypeSchema = z.enum([ diff --git a/src/domain/targetApplicability.ts b/src/domain/targetApplicability.ts new file mode 100644 index 0000000000..e0dba63a58 --- /dev/null +++ b/src/domain/targetApplicability.ts @@ -0,0 +1,24 @@ +import type { BinaryTarget } from "./binaryTargetTypes.js"; + +/** Target facts required by an individual provider operation. */ +export interface TargetApplicability { + readonly kinds: readonly BinaryTarget["kind"][]; + readonly formats: readonly BinaryTarget["format"][]; + readonly reason: string; +} + +/** Evaluate provider-owned requirements against the admitted target. */ +export const targetApplicabilityRefusal = ( + applicability: TargetApplicability, + target: BinaryTarget, +): + | { + readonly code: "target_kind_unsupported" | "target_format_unsupported"; + readonly reason: string; + } + | undefined => + !applicability.kinds.includes(target.kind) + ? { code: "target_kind_unsupported", reason: applicability.reason } + : !applicability.formats.includes(target.format) + ? { code: "target_format_unsupported", reason: applicability.reason } + : undefined; diff --git a/src/native/NativeMacOSProviderMetadata.ts b/src/native/NativeMacOSProviderMetadata.ts index 6c125f25f5..f9a13c7447 100644 --- a/src/native/NativeMacOSProviderMetadata.ts +++ b/src/native/NativeMacOSProviderMetadata.ts @@ -2,6 +2,7 @@ import { NATIVE_MACOS_PROVIDER_IDENTITY } from "./NativeMacosProviderIdentity.js import type { CapabilityDescriptor } from "../application/AnalysisProvider.js"; import { createAnalysisProfile } from "../domain/analysisProfile.js"; import { NATIVE_TOOL_CONTRACTS } from "../contracts/native/nativeToolContracts.js"; +import { APPLE_DISPATCH_TARGET_APPLICABILITY } from "../domain/native/objcSwiftMetadata.js"; /** Source-owned commitment for the pure Apple dispatch metadata reader. */ export const APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE = createAnalysisProfile( @@ -43,7 +44,10 @@ export const nativeMacOSCapabilities = ( provider: NATIVE_MACOS_PROVIDER_IDENTITY, operation: contract.name, ...(contract.name === "inspect_native_dispatch_metadata" - ? { analysisProfile: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE } + ? { + analysisProfile: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE, + targetApplicability: APPLE_DISPATCH_TARGET_APPLICABILITY, + } : {}), ...availability, effects: Object.freeze({