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:
parent
de5bfec79f
commit
cf77d073fc
|
|
@ -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(), {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue