fix(session): require an open target for session-bound native and artifact tools

binary_session reported native and artifact operations available whenever
their provider declared them, although these operations run on the active
target: in a closed session every call failed with target_unavailable, and
Mach-O-only operations stayed available with a ZIP or ELF target open.

Report target_required for every session-bound contract kind in a closed
session, and target_unsupported for Mach-O-only operations unless the open
target is a Mach-O executable. A host limitation is reported before target
selection, since opening a target cannot fix it; composed operations keep
deciding from their requirements.

Fixes #1166

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822 2026-10-09 03:02:40 +00:00
parent de5bfec79f
commit cf77d073fc
3 changed files with 288 additions and 15 deletions

View File

@ -48,6 +48,7 @@ const status = (
options: {
readonly open?: boolean;
readonly kind?: "executable" | "database" | "archive" | "artifact";
readonly format?: string;
readonly capabilities?: readonly {
readonly operation: string;
readonly available: boolean;
@ -58,6 +59,7 @@ const status = (
) => ({
open: options.open ?? false,
...(options.kind === undefined ? {} : { kind: options.kind }),
...(options.format === undefined ? {} : { format: options.format }),
capabilities: (options.capabilities ?? []).map((capability) => ({
...capability,
availability_code: capability.available
@ -237,6 +239,129 @@ describe("capability inventory: provider status", () => {
});
});
describe("capability inventory: active-target operations", () => {
const ACTIVE_TARGET_TOOLS = [
"inspect_macho",
"inspect_signature",
"list_architectures",
"demangle_swift",
"inspect_artifact",
"extract_artifact",
] as const;
const declared = (
names: readonly string[],
availability: {
readonly available: boolean;
readonly reason: string | null;
} = {
available: true,
reason: null,
},
) => names.map((operation) => ({ operation, ...availability }));
const reasons = (
sessionStatus: ReturnType<typeof status>,
names: readonly string[],
policy = enabledPolicy,
) =>
Object.fromEntries(
names.map((name) => [name, entry(name, sessionStatus, policy).reason]),
);
it("requires an open target even when the provider declares the operation", () => {
const closed = status({ capabilities: declared(ACTIVE_TARGET_TOOLS) });
for (const name of ACTIVE_TARGET_TOOLS)
expect(entry(name, closed)).toMatchObject({
available: false,
reason: "target_required",
remediation: expect.stringContaining("open_binary"),
});
});
it("keeps explicit-path operations available without a target", () => {
const explicitPath = [
"inspect_managed_artifact",
"inspect_binary_layout",
"inspect_recorded_crash",
"inspect_evm_interface",
];
expect(
reasons(status(), explicitPath, {
...enabledPolicy,
binaryLayoutEnabled: true,
recordedCrashEnabled: true,
evmInterfaceEnabled: true,
}),
).toEqual(
Object.fromEntries(explicitPath.map((name) => [name, "available"])),
);
});
it("reserves Mach-O operations for an open Mach-O executable", () => {
const capabilities = declared(ACTIVE_TARGET_TOOLS);
const machO = status({
open: true,
kind: "executable",
format: "mach-o",
capabilities,
});
const zip = status({
open: true,
kind: "archive",
format: "zip",
capabilities,
});
const elf = status({
open: true,
kind: "executable",
format: "elf",
capabilities,
});
expect(reasons(machO, ACTIVE_TARGET_TOOLS)).toEqual(
Object.fromEntries(
ACTIVE_TARGET_TOOLS.map((name) => [name, "available"]),
),
);
for (const target of [zip, elf])
expect(reasons(target, ACTIVE_TARGET_TOOLS)).toEqual({
inspect_macho: "target_unsupported",
inspect_signature: "available",
list_architectures: "target_unsupported",
demangle_swift: "available",
inspect_artifact: "available",
extract_artifact: "available",
});
expect(entry("inspect_macho", zip).remediation).toContain("Mach-O");
});
it("reports an unsupported host before asking for a target", () => {
const capabilities = declared(["inspect_macho"], {
available: false,
reason: "Native macOS utilities require macOS.",
}).map((capability) => ({
...capability,
availability_code: "unsupported_host" as const,
}));
for (const sessionStatus of [
status({ capabilities }),
status({ open: true, kind: "archive", format: "zip", capabilities }),
])
expect(entry("inspect_macho", sessionStatus)).toMatchObject({
reason: "unsupported_host",
remediation: "Native macOS utilities require macOS.",
});
const composed = capabilities.map((capability) => ({
...capability,
operation: "inspect_native_dispatch_metadata",
}));
expect(
entry(
"inspect_native_dispatch_metadata",
status({ capabilities: composed }),
).reason,
).toBe("target_required");
});
});
describe("capability inventory: caller guidance", () => {
it("gives every unavailable public tool an actionable remediation", () => {
const inventory = buildCapabilityInventory(status(), {

View File

@ -78,6 +78,7 @@ type AvailabilityContext = {
| "archive"
| "artifact"
| undefined;
readonly targetFormat: string | undefined;
readonly descriptors: ReadonlyMap<string, ProviderDescriptor>;
readonly policy: AvailabilityPolicy;
};
@ -112,6 +113,23 @@ const ENHANCED_REQUIREMENTS: Readonly<Record<string, readonly string[]>> = {
],
};
/** Contract kinds whose operations run on the active session target. */
const ACTIVE_TARGET_KINDS: ReadonlySet<ToolKind> = new Set([
"official-proxy",
"enhanced",
"native-provider",
"artifact-provider",
]);
/** Native contracts defined only for an active Mach-O executable. */
const MACHO_TARGET_OPERATIONS: ReadonlySet<string> = new Set([
"inspect_macho",
"list_architectures",
"observe_native_calls",
"observe_native_ui",
"capture_native_ui_scenario",
]);
const NAVIGATION_CONTEXT_MODES = [
{
name: "current_selection",
@ -143,6 +161,7 @@ export const buildCapabilityInventory = (
kind: contract.kind,
targetOpen: status.open,
targetKind: status.kind,
targetFormat: status.format,
descriptors,
policy,
});
@ -211,6 +230,8 @@ const availabilityFor = (context: AvailabilityContext): Availability => {
};
const workflowAvailability = workflowAvailabilityFor(context);
if (workflowAvailability !== null) return workflowAvailability;
const hostDecision = hostAvailability(context);
if (hostDecision !== null) return hostDecision;
const targetDecision = targetAvailability(context);
if (targetDecision !== null) return targetDecision;
return providerAvailability(context);
@ -401,12 +422,32 @@ const browserProviderAvailability = (
};
};
/**
* Opening another target cannot make an operation run on an unsupported host.
* Composed operations are decided by their requirements, which another
* provider may satisfy.
*/
const hostAvailability = ({
descriptors,
kind,
name,
}: AvailabilityContext): Availability | null => {
if (kind === "enhanced") return null;
const descriptor = descriptors.get(name);
return descriptor?.available === false &&
descriptor.availability_code === "unsupported_host"
? { reason: "unsupported_host", remediation: descriptor.reason }
: null;
};
const targetAvailability = ({
name,
kind,
targetKind,
targetFormat,
targetOpen,
}: AvailabilityContext): Availability | null => {
if (!targetOpen && (kind === "official-proxy" || kind === "enhanced"))
if (!targetOpen && ACTIVE_TARGET_KINDS.has(kind))
return {
reason: "target_required",
remediation: "Call open_binary with a supported local target.",
@ -422,6 +463,16 @@ const targetAvailability = ({
remediation:
"Inventory or extract a native executable, then call open_binary on that executable.",
};
if (
targetOpen &&
MACHO_TARGET_OPERATIONS.has(name) &&
(targetKind !== "executable" || targetFormat !== "mach-o")
)
return {
reason: "target_unsupported",
remediation:
"Call open_binary with a Mach-O executable or an app bundle whose main executable is Mach-O.",
};
return null;
};
@ -445,14 +496,6 @@ const providerAvailability = ({
remediation:
"Install or configure a provider that declares this operation.",
};
if (
!descriptor.available &&
descriptor.availability_code === "unsupported_host"
)
return {
reason: "unsupported_host",
remediation: descriptor.reason,
};
return descriptor.available
? { reason: "available", remediation: null }
: {

View File

@ -1,9 +1,13 @@
import { readFile } from "node:fs/promises";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { machoImage } from "../../../src/artifacts/apple/MachoImage.fixture.js";
import {
CATALOG_IDENTITY,
CLI_COMMAND_NAMES,
@ -21,11 +25,17 @@ import type {
CapabilityDescriptor,
} from "../../../src/application/AnalysisProvider.js";
const availabilityProvider = (): AnalysisProvider => {
const availabilityProvider = (
operations: readonly CapabilityDescriptor["operation"][] = [
"current_address",
],
): AnalysisProvider => {
const identity = { id: "fixture", name: "Fixture", version: "1" };
const capability: CapabilityDescriptor = {
const capability = (
operation: CapabilityDescriptor["operation"],
): CapabilityDescriptor => ({
provider: identity,
operation: "current_address",
operation,
available: true,
reason: null,
effects: {
@ -38,10 +48,10 @@ const availabilityProvider = (): AnalysisProvider => {
requiresRoot: false,
},
limitations: [],
};
});
return {
identity: () => identity,
capabilities: () => [capability],
capabilities: () => operations.map(capability),
createClient: () => ({
health: () => Promise.resolve(),
execute: () => Promise.resolve(observed(null)),
@ -248,6 +258,101 @@ describe("live server identity over MCP", () => {
}, 10_000);
});
describe("active-target availability over MCP", () => {
it("advertises active-target tools only when a matching target is open", async () => {
const session = createTestBinarySession(
availabilityProvider(["inspect_macho", "inspect_artifact"]),
);
const server = createServer(session, session);
const client = new Client({ name: "availability-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
const directory = await createTestTempDirectory("rea-target-availability-");
const executable = join(directory, "tool");
const plist = join(directory, "Info.plist");
await writeFile(executable, machoImage({}));
await writeFile(plist, PLIST_XML);
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const listed = await toolNames(client);
expect(await activeTargetReasons(client)).toEqual({
inspect_macho: "target_required",
inspect_artifact: "target_required",
inspect_managed_artifact: "available",
});
const call = await client.callTool({
name: "inspect_macho",
arguments: {},
});
expect(call.structuredContent).toMatchObject({
error: { code: "target_unavailable" },
});
await openTarget(client, plist);
expect(await activeTargetReasons(client)).toEqual({
inspect_macho: "target_unsupported",
inspect_artifact: "available",
inspect_managed_artifact: "available",
});
await client.callTool({ name: "close_binary", arguments: {} });
await openTarget(client, executable);
expect(await activeTargetReasons(client)).toEqual({
inspect_macho: "available",
inspect_artifact: "available",
inspect_managed_artifact: "available",
});
await client.callTool({ name: "close_binary", arguments: {} });
expect((await activeTargetReasons(client)).inspect_macho).toBe(
"target_required",
);
expect(await toolNames(client)).toEqual(listed);
} finally {
await Promise.allSettled([
client.close(),
server.close(),
session.close(),
]);
}
}, 10_000);
});
const PLIST_XML =
'<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>CFBundleName</key><string>Fixture</string></dict></plist>';
const toolNames = async (client: Client): Promise<string[]> =>
(await client.listTools()).tools.map(({ name }) => name);
const openTarget = async (client: Client, path: string): Promise<void> => {
const opened = await client.callTool({
name: "open_binary",
arguments: { path },
});
expect(opened.isError ?? false).toBe(false);
};
const activeTargetReasons = async (
client: Client,
): Promise<Record<string, unknown>> => {
const status = await client.callTool({
name: "binary_session",
arguments: {},
});
const availability = z
.object({
result: z.object({
tool_availability: z.array(
z.object({ name: z.string(), reason: z.string() }).loose(),
),
}),
})
.parse(status.structuredContent).result.tool_availability;
return Object.fromEntries(
["inspect_macho", "inspect_artifact", "inspect_managed_artifact"].map(
(name) => [name, availability.find((tool) => tool.name === name)?.reason],
),
);
};
const assertLiveIdentity = async (client: Client): Promise<void> => {
const identity = await client.callTool({
name: "binary_session",