mirror of
https://github.com/morluto/rea.git
synced 2026-10-18 20:10:13 -05:00
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:
1 parent
1d5bf5ff08
commit
50b6975d86
5 files changed
+353
-5
No files matched your search
@@ -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.
|
||||
|
||||
@@ -177,7 +177,7 @@ describe("capability failure reasons", () => {
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ available: false, reason: "provider_missing" });
|
||||
).toMatchObject({ available: true, reason: "available" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
};
|
||||
};
|
||||
Reference in new issue
Block a user