fix(session): admit direct native dispatch metadata providers

Share named metadata source operations between execution and capability discovery. Preserve symbol fallback and typed runtime failure behavior, and cover availability through public MCP workflows.

Fixes #5070
This commit is contained in:
morluto committed 2026-10-19 03:19:33 +08:00
1 parent 1d5bf5ff08
commit 50b6975d86
5 files changed
+353 -5

No files matched your search

+6
View File
@@ -430,6 +430,12 @@ complete observation.
address is retained as `reported_address` in the symbol's evidence, whose
location also remains unknown. Nonempty provider address syntax is preserved.
Session discovery reports this workflow available when the bound provider
offers direct metadata inspection or a `list_names` symbol inventory. The
workflow tries direct metadata first and uses the symbol fallback only when
that operation is unavailable; runtime failures remain failures rather than
being replaced by symbol-only results.
## Native desktop observation
`observe_native_ui` captures one explicitly selected existing PID/window ID.
+1 -1
View File
@@ -177,7 +177,7 @@ describe("capability failure reasons", () => {
],
}),
),
).toMatchObject({ available: false, reason: "provider_missing" });
).toMatchObject({ available: true, reason: "available" });
});
it.each([
+4 -1
View File
@@ -1,5 +1,6 @@
import { DEFAULT_CALL_RELATIONSHIP_DIRECTION } from "../contracts/enhancedInputs.js";
import { CALL_RELATIONSHIP_TRAVERSALS } from "./CallRelationshipTraversal.js";
import { NATIVE_DISPATCH_METADATA_SOURCES } from "./native/NativeDispatchMetadataInspection.js";
import { z } from "zod";
import { sqliteDatabaseCapability } from "./sqlite/SqliteDatabaseCapability.js";
import type {
@@ -144,7 +145,9 @@ const ENHANCED_REQUIREMENTS: ReadonlyMap<string, ProviderComposition> = new Map(
"list_names",
"procedure_callees",
],
inspect_native_dispatch_metadata: ["list_names"],
inspect_native_dispatch_metadata: [
Object.values(NATIVE_DISPATCH_METADATA_SOURCES),
],
get_objc_classes: ["list_names"],
get_objc_protocols: ["list_names"],
batch_decompile: ["procedure_pseudo_code"],
@@ -9,6 +9,13 @@ import { jsonValueSchema } from "../../domain/jsonValue.js";
import { err, ok } from "../../domain/result.js";
import type { AnalysisOperationPort } from "../AnalysisProvider.js";
import type { EnhancedResult } from "../EnhancedToolTypes.js";
/** Source operations for this workflow, shared with capability discovery. */
export const NATIVE_DISPATCH_METADATA_SOURCES = {
binary: "inspect_native_dispatch_metadata",
symbols: "list_names",
} as const;
/** Prefer validated binary metadata, otherwise preserve the bound provider's symbol-only coverage. */
export const inspectNativeDispatch = async (
analysis: AnalysisOperationPort,
@@ -16,7 +23,7 @@ export const inspectNativeDispatch = async (
signal?: AbortSignal,
): EnhancedResult => {
const binaryMetadata = await analysis.execute(
"inspect_native_dispatch_metadata",
NATIVE_DISPATCH_METADATA_SOURCES.binary,
{ max_records: maxRecords },
signal === undefined ? {} : { signal },
);
@@ -29,7 +36,7 @@ export const inspectNativeDispatch = async (
if (binaryMetadata.error._tag !== "AnalysisCapabilityUnavailableError")
return err(binaryMetadata.error);
const execution = await analysis.execute(
"list_names",
NATIVE_DISPATCH_METADATA_SOURCES.symbols,
{},
signal === undefined ? {} : { signal },
);
@@ -48,7 +55,7 @@ export const inspectNativeDispatch = async (
if (!names.success)
return err(
new AnalysisOutputError(
"list_names",
NATIVE_DISPATCH_METADATA_SOURCES.symbols,
"provider returned an invalid inventory",
),
);
@@ -0,0 +1,332 @@
import { rm } from "node:fs/promises";
import type { Client } from "@modelcontextprotocol/client";
import type { CallToolResult } from "@modelcontextprotocol/server";
import { afterEach, describe, expect, it } from "vitest";
import type {
AnalysisClient,
AnalysisProvider,
CapabilityDescriptor,
} from "../../../src/application/AnalysisProvider.js";
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
import { AnalysisOutputError } from "../../../src/domain/analysisErrorCore.js";
import { inspectNativeDispatchMetadata } from "../../../src/domain/native/objcSwiftMetadata.js";
import { err, ok } from "../../../src/domain/result.js";
import type { ProviderRejectionCode } from "../../../src/contracts/providerSelection.js";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { expandToolAvailability } from "../../../src/contracts/toolOutputSchemaPrimitives.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { createSessionMcpHarness } from "./sessionMcpHarness.js";
const resources: Array<{ close(): Promise<unknown> }> = [];
let directory: string | undefined;
afterEach(async () => {
await Promise.all(resources.splice(0).map((resource) => resource.close()));
if (directory !== undefined)
await rm(directory, { recursive: true, force: true });
directory = undefined;
});
const identity = {
id: "fixture",
name: "Fixture analysis provider",
version: "1",
};
const dispatchMetadata = inspectNativeDispatchMetadata([
{
address: "0x1000",
name: "-[BuildViewController buildTapped:]",
},
]);
const symbolInventory = [
{ address: "0x1000", name: "-[BuildViewController buildTapped:]" },
];
type Operation = "inspect_native_dispatch_metadata" | "list_names";
type Scenario = {
readonly name: string;
readonly capabilities: readonly {
readonly operation: Operation;
readonly available: boolean;
readonly reason?: string;
readonly availabilityCode?: ProviderRejectionCode;
}[];
readonly directRuntimeFailure?: boolean;
readonly unavailableReason?: "provider_unavailable" | "unsupported_host";
};
const directOnly: Scenario = {
name: "direct metadata only",
capabilities: [
{ operation: "inspect_native_dispatch_metadata", available: true },
],
};
const scenarios: readonly Scenario[] = [
directOnly,
{
name: "symbol-only fallback",
capabilities: [{ operation: "list_names", available: true }],
},
{ name: "neither operation", capabilities: [] },
{
name: "direct operation unavailable with symbol fallback",
capabilities: [
{
operation: "inspect_native_dispatch_metadata",
available: false,
reason: "Direct metadata inspection is unavailable.",
},
{ operation: "list_names", available: true },
],
},
{
name: "direct runtime failure with symbol fallback",
capabilities: [
{ operation: "inspect_native_dispatch_metadata", available: true },
{ operation: "list_names", available: true },
],
directRuntimeFailure: true,
},
{
name: "direct host denial with usable symbol fallback",
capabilities: [
{
operation: "inspect_native_dispatch_metadata",
available: false,
reason: "Direct metadata inspection requires another host.",
availabilityCode: "unsupported_host",
},
{ operation: "list_names", available: true },
],
},
{
name: "unavailable direct operation without symbol fallback",
capabilities: [
{
operation: "inspect_native_dispatch_metadata",
available: false,
reason: "Direct metadata inspection is unavailable.",
},
],
unavailableReason: "provider_unavailable",
},
];
describe("native dispatch availability at the public MCP boundary", () => {
it.each(scenarios)(
"preserves $name capability semantics",
async (scenario) => {
directory = await createTestTempDirectory(
"rea-native-dispatch-availability-",
);
const calls: Operation[] = [];
const { mcp, first } = await createSessionMcpHarness(
directory,
() => provider(scenario, calls),
resources,
);
const opened = await mcp.callTool({
name: "open_binary",
arguments: { path: first },
});
expect(opened.isError).not.toBe(true);
try {
const inventory = await readInventory(mcp);
const availability = dispatchAvailability(inventory);
const result = await mcp.callTool({
name: "inspect_native_dispatch_metadata",
arguments: { max_records: 10 },
});
assertExecution(scenario, result, calls);
if (scenario.unavailableReason !== undefined) {
expect(availability).toMatchObject({
available: false,
reason: scenario.unavailableReason,
remediation: "Direct metadata inspection is unavailable.",
});
} else if (scenario === directOnly) {
expect(availability).toMatchObject({
available: true,
reason: "available",
remediation: null,
});
} else if (scenario.capabilities.length === 0) {
expect(availability).toMatchObject({
available: false,
reason: "provider_missing",
});
} else {
expect(availability).toMatchObject({
available: true,
reason: "available",
});
}
} finally {
const closed = await mcp.callTool({
name: "close_binary",
arguments: {},
});
expect(closed.isError).not.toBe(true);
}
},
);
it("reports a target requirement before probing provider operations", async () => {
directory = await createTestTempDirectory("rea-native-dispatch-no-target-");
const calls: Operation[] = [];
const { mcp } = await createSessionMcpHarness(
directory,
() => provider(directOnly, calls),
resources,
);
const inventory = await readInventory(mcp);
expect(inventory.result.open).toBe(false);
expect(dispatchAvailability(inventory)).toMatchObject({
available: false,
reason: "target_required",
});
expect(calls).toEqual([]);
});
});
const readInventory = async (mcp: Client) => {
const result = await mcp.callTool({ name: "binary_session", arguments: {} });
return toolContract("binary_session").outputSchema.parse(
result.structuredContent,
);
};
const dispatchAvailability = (
inventory: Awaited<ReturnType<typeof readInventory>>,
) =>
expandToolAvailability(inventory.result.tool_availability).find(
(entry) => entry.name === "inspect_native_dispatch_metadata",
);
const assertExecution = (
scenario: Scenario,
result: CallToolResult,
calls: readonly Operation[],
): void => {
if (scenario.directRuntimeFailure) {
expect(result.isError).toBe(true);
expect(parseMcpToolError(result)).toMatchObject({
error: { details: { operation: "inspect_native_dispatch_metadata" } },
});
expect(calls).toEqual(["inspect_native_dispatch_metadata"]);
return;
}
if (
scenario.capabilities.length === 0 ||
scenario.unavailableReason !== undefined
) {
expect(result.isError).toBe(true);
expect(parseMcpToolError(result)).toMatchObject({
error: { code: "capability_unavailable" },
});
expect(calls).toEqual([]);
return;
}
expect(result.isError).not.toBe(true);
expect(result.structuredContent).toMatchObject({
normalized_result: {
result: {
objc_dispatch_implementations: [
{
class_name: "BuildViewController",
selector: "buildTapped:",
implementation_address: "0x1000",
},
],
},
},
});
if (
scenario.capabilities.some(
({ operation, available }) =>
operation === "inspect_native_dispatch_metadata" && available,
)
)
expect(calls).toEqual(["inspect_native_dispatch_metadata"]);
else expect(calls).toEqual(["list_names"]);
};
const provider = (scenario: Scenario, calls: Operation[]): AnalysisProvider => {
const capabilities: CapabilityDescriptor[] = scenario.capabilities.map(
(capability) => {
const common = {
provider: identity,
operation: capability.operation,
effects: {
mutatesArtifact: false,
launchesProcess: false,
mayShowUi: false,
mayAccessNetwork: false,
mayWriteFilesystem: false,
changesPermissions: false,
requiresRoot: false,
},
limitations: [],
};
return capability.available
? { ...common, available: true as const, reason: null }
: {
...common,
available: false as const,
reason: capability.reason ?? "Fixture capability is unavailable.",
...(capability.availabilityCode === undefined
? {}
: { availabilityCode: capability.availabilityCode }),
};
},
);
return {
identity: () => identity,
capabilities: () => capabilities,
createClient: (target, profile): AnalysisClient => ({
execute: async (operation) => {
if (operation === "health")
return ok(createAnalysisExecution(null, identity));
if (
operation !== "inspect_native_dispatch_metadata" &&
operation !== "list_names"
)
throw new Error(`Unexpected provider operation: ${operation}`);
calls.push(operation);
if (operation === "inspect_native_dispatch_metadata") {
if (scenario.directRuntimeFailure)
return err(
new AnalysisOutputError(
operation,
"fixture direct metadata runtime failure",
),
);
return ok(
createAnalysisExecution(
{
target_sha256: target.sha256,
provider: identity,
analysis_profile_digest: profile?.digest ?? null,
result: dispatchMetadata,
},
identity,
{ subject: target },
),
);
}
return ok(
createAnalysisExecution(symbolInventory, identity, {
subject: target,
...(profile === undefined ? {} : { analysisProfile: profile }),
}),
);
},
close: async () => ok(null),
}),
};
};