mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(mcp): report target after failed open (#8613)
* fix(mcp): report target after failed open * test(session): cover failed open receipt ownership * test(session): verify restored target receipt * test(mcp): retain target on snapshot mismatch * test(mcp): retain oversized open failure receipts * test(cli): avoid continuing session receipt in one-shot errors * fix(mcp): report provider after failed switch * test(ghidra): verify failed open preserves annotations * test(mcp): join queued receipt request before cleanup * test(session): assert open failure receipts in lifecycle suites * test(session): keep open-failure receipt checks within function limits Move the provider-collision case and snapshot fixture setup out of the functions that grew when both failure receipts were asserted. --------- Co-authored-by: Cursor Agent <[email protected]>
This commit is contained in:
1 parent
3395c9e6b3
commit
56be61191e
17 files changed
+375
-43
No files matched your search
@@ -886,6 +886,13 @@ parent for another invocation.
|
||||
|
||||
## Identity and discovery
|
||||
|
||||
When an admitted `open_binary` attempt fails, its error details include
|
||||
`target_at_open_completion`: the selected binary target when that attempt
|
||||
settled, or `null` when none was selected. `provider_id_at_open_completion`
|
||||
names its bound deep provider, or `null` if none was bound. A later lifecycle
|
||||
operation can change either selection; call `binary_session` for current state. This field does
|
||||
not establish provider readiness or resource cleanup.
|
||||
|
||||
`binary_session` reports the active package, server, SDK, and negotiated
|
||||
protocol details. `rea doctor` separately inspects supported JSON/TOML client
|
||||
registrations, reports their command vectors as aligned, stale, missing, or
|
||||
|
||||
@@ -511,8 +511,9 @@ has been run on macOS; Linux has not been exercised.
|
||||
through the compiled CLI and stdio MCP server. With the same Ghidra/JDK
|
||||
prerequisites, it checks preparation and bridge startup, verified load mappings,
|
||||
instruction and byte readback, canonical-entry session reuse, replacement when
|
||||
the entry changes without `provider_id`, unchanged source bytes, and owned
|
||||
cleanup. Six-byte x86 and x86_64 controls also verify supported compiler
|
||||
the entry changes without `provider_id`, preservation of a session annotation
|
||||
and the target/provider receipt after a rejected open, unchanged source bytes,
|
||||
and owned cleanup. Six-byte x86 and x86_64 controls also verify supported compiler
|
||||
specifications, independent mappings, unchanged bytes, and decompilation of a
|
||||
known return value. AArch64 remains covered by deterministic tests rather than
|
||||
this real-provider lane. See [raw binary analysis](raw-binary.md).
|
||||
|
||||
@@ -185,6 +185,11 @@ try {
|
||||
assert.notEqual(profiles[1], profiles[2]);
|
||||
assert.equal(sessions[0], sessions[1]);
|
||||
assert.notEqual(sessions[1], sessions[2]);
|
||||
await call("annotate_native_function", {
|
||||
procedure: "0x9000000",
|
||||
name: "retained_after_failed_open",
|
||||
comment: "Owned session annotation",
|
||||
});
|
||||
const rejected = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: {
|
||||
@@ -196,9 +201,28 @@ try {
|
||||
});
|
||||
assert.equal(rejected.isError, true);
|
||||
assert.match(JSON.stringify(rejected.content), /must be 4-byte aligned/);
|
||||
const rejection = JSON.parse(rejected.content[0].text).error;
|
||||
assert.equal(
|
||||
rejection.details.target_at_open_completion.entry_point,
|
||||
"0x9000000",
|
||||
);
|
||||
assert.equal(rejection.details.provider_id_at_open_completion, "ghidra");
|
||||
const unchanged = await call("binary_session");
|
||||
assert.equal(unchanged.analysis_run.run_id, sessions[2]);
|
||||
assert.equal(unchanged.entry_point, "0x9000000");
|
||||
const retained = await call("analyze_function", {
|
||||
procedure: "0x9000000",
|
||||
detail: "complete",
|
||||
});
|
||||
assert.equal(
|
||||
retained.normalized_result.procedure.name,
|
||||
"retained_after_failed_open",
|
||||
);
|
||||
assert.ok(
|
||||
retained.normalized_result.comments.some(
|
||||
(comment) => comment.text === "Owned session annotation",
|
||||
),
|
||||
);
|
||||
await call("close_binary");
|
||||
|
||||
const { stdout } = await promisify(execFile)(
|
||||
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
OpenSelectionReceipt,
|
||||
SavedAnalysisSnapshot,
|
||||
} from "./BinarySessionPort.js";
|
||||
import { binaryTargetSummary } from "./BinaryTargetSummary.js";
|
||||
import {
|
||||
SessionProviderRouter,
|
||||
type SessionProviderRoute,
|
||||
@@ -537,9 +538,17 @@ export class BinarySession
|
||||
);
|
||||
};
|
||||
const result = await attempt();
|
||||
return result.ok
|
||||
? result
|
||||
: { ...result, selection: this.#openSelectionReceipt() };
|
||||
if (result.ok) return result;
|
||||
return {
|
||||
...result,
|
||||
selection: this.#openSelectionReceipt(),
|
||||
targetAtCompletion:
|
||||
this.#active === undefined
|
||||
? null
|
||||
: binaryTargetSummary(this.#active.target),
|
||||
providerIdAtCompletion:
|
||||
this.#active?.route.binding?.identity.id ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -137,6 +137,18 @@ it("opens a resource-only bundle without executable identity and keeps snapshot
|
||||
expect(session.allowsSnapshotReplay("inspect_plist")).toBe(false);
|
||||
expect(opened.value.executableSha256).toBeNull();
|
||||
expect(openedRoot).toBe(opened.value.canonicalPath);
|
||||
const rejectedBinary = await session.open(join(rootPath, "missing-binary"));
|
||||
if (rejectedBinary.ok) throw new Error("Expected a failed binary open");
|
||||
expect(rejectedBinary.targetAtCompletion).toBeNull();
|
||||
expect(rejectedBinary.providerIdAtCompletion).toBeNull();
|
||||
expect(rejectedBinary.selection).toEqual({
|
||||
kind: "resources",
|
||||
path: opened.value.canonicalPath,
|
||||
caller_path: opened.value.callerPath,
|
||||
});
|
||||
expect(session.activeBundleResourceRoot()?.canonicalPath).toBe(
|
||||
opened.value.canonicalPath,
|
||||
);
|
||||
expect(
|
||||
bundleResourceRootSchema.parse({
|
||||
caller_path: opened.value.callerPath,
|
||||
@@ -157,13 +169,6 @@ it("opens a resource-only bundle without executable identity and keeps snapshot
|
||||
session_kind: "bundle_resources",
|
||||
resource_root: { canonical_path: opened.value.canonicalPath },
|
||||
});
|
||||
const rejectedBinary = await session.open(join(rootPath, "missing-binary"));
|
||||
if (rejectedBinary.ok) throw new Error("Expected a missing binary refusal");
|
||||
expect(rejectedBinary.selection).toEqual({
|
||||
kind: "resources",
|
||||
path: opened.value.canonicalPath,
|
||||
caller_path: opened.value.callerPath,
|
||||
});
|
||||
const snapshot = session.exportAnalysisSnapshot();
|
||||
if (snapshot.ok)
|
||||
throw new Error("resource session exported a binary snapshot");
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
|
||||
import type { AnalysisSnapshotLoader } from "./BinarySessionOpen.js";
|
||||
import type { WorkflowSnapshotRecordInput } from "./BinarySessionRecords.js";
|
||||
import type { ResolvedAppBundleResourceRoot } from "../AppBundleExecutable.js";
|
||||
import type { binaryTargetSummary } from "./BinaryTargetSummary.js";
|
||||
|
||||
import type {
|
||||
AnalysisExecution,
|
||||
@@ -40,12 +41,17 @@ export type OpenSelectionReceipt =
|
||||
}
|
||||
| { readonly kind: "none" };
|
||||
|
||||
/** A failure retains its original error and both selection receipts. */
|
||||
export type BinaryOpenResult =
|
||||
| { readonly ok: true; readonly value: BinaryTarget }
|
||||
| {
|
||||
readonly ok: false;
|
||||
readonly error: AnalysisError;
|
||||
readonly selection: OpenSelectionReceipt;
|
||||
readonly targetAtCompletion: ReturnType<
|
||||
typeof binaryTargetSummary
|
||||
> | null;
|
||||
readonly providerIdAtCompletion: string | null;
|
||||
};
|
||||
|
||||
/** Target lifecycle used by CLI and MCP without exposing a concrete provider. */
|
||||
|
||||
@@ -10,11 +10,85 @@ import { err, ok } from "../../domain/result.js";
|
||||
import { createAnalysisExecution } from "../AnalysisProvider.js";
|
||||
import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js";
|
||||
import { BinarySession } from "./BinarySession.js";
|
||||
import { binaryTargetSummary } from "./BinaryTargetSummary.js";
|
||||
import {
|
||||
SessionProviderRouter,
|
||||
type SessionProviderRoute,
|
||||
} from "./SessionProviderRouter.js";
|
||||
|
||||
it("reports the previous selection after a failed switch restores its provider", async () => {
|
||||
const session = new BinarySession(
|
||||
SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []),
|
||||
);
|
||||
const identity = {
|
||||
id: "restoration-test",
|
||||
name: "Restoration test",
|
||||
version: "1",
|
||||
};
|
||||
const failure = new AnalysisOutputError(
|
||||
"health",
|
||||
"Selected target could not start",
|
||||
);
|
||||
let previousStarts = 0;
|
||||
let previousCloses = 0;
|
||||
let failedCloses = 0;
|
||||
const route: SessionProviderRoute = {
|
||||
identity,
|
||||
capabilities: new Map(),
|
||||
profile: createAnalysisProfile(identity, { fixture: "original" }),
|
||||
binding: null,
|
||||
selection: undefined,
|
||||
createClient: () => {
|
||||
previousStarts += 1;
|
||||
return {
|
||||
execute: async () => ok(createAnalysisExecution(null, identity)),
|
||||
close: async () => {
|
||||
previousCloses += 1;
|
||||
return ok(null);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
try {
|
||||
const target = await session.resolveTarget(process.execPath);
|
||||
if (!target.ok) throw target.error;
|
||||
expect(
|
||||
(await session.openResolvedTarget({ target: target.value, route })).ok,
|
||||
).toBe(true);
|
||||
const result = await session.openResolvedTarget({
|
||||
target: target.value,
|
||||
route: {
|
||||
...route,
|
||||
profile: createAnalysisProfile(identity, { fixture: "selected" }),
|
||||
createClient: () => ({
|
||||
execute: async () => err(failure),
|
||||
close: async () => {
|
||||
failedCloses += 1;
|
||||
return ok(null);
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
if (result.ok) throw new Error("Expected failed switch");
|
||||
expect(result.error).toBe(failure);
|
||||
expect(result.targetAtCompletion).toEqual(
|
||||
binaryTargetSummary(target.value),
|
||||
);
|
||||
expect(session.activeTarget()).toEqual(target.value);
|
||||
expect({ previousStarts, previousCloses, failedCloses }).toEqual({
|
||||
previousStarts: 2,
|
||||
previousCloses: 1,
|
||||
failedCloses: 1,
|
||||
});
|
||||
expect((await session.close()).ok).toBe(true);
|
||||
expect(result.targetAtCompletion).toEqual(
|
||||
binaryTargetSummary(target.value),
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ cancelled: false, cleanupFails: false },
|
||||
{ cancelled: true, cleanupFails: false },
|
||||
@@ -98,6 +172,8 @@ it.each([
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.targetAtCompletion).toBeNull();
|
||||
expect(result.providerIdAtCompletion).toBeNull();
|
||||
expect(result.selection).toEqual({ kind: "none" });
|
||||
expect(session.activeTarget()).toBeUndefined();
|
||||
expect(await session.status()).toMatchObject({
|
||||
@@ -113,6 +189,7 @@ it.each([
|
||||
});
|
||||
expect((await session.close()).ok).toBe(true);
|
||||
expect(restorationCloseCalls).toBe(cleanupFails ? 2 : 1);
|
||||
expect(result.targetAtCompletion).toBeNull();
|
||||
expect(await session.status()).toMatchObject({
|
||||
open: false,
|
||||
cleanup_pending: false,
|
||||
|
||||
@@ -68,7 +68,7 @@ export const exportEvidenceBundleInputSchema = z.strictObject({
|
||||
export const SESSION_TOOL_CONTRACTS = [
|
||||
session(
|
||||
"open_binary",
|
||||
"Open a local executable, application bundle, archive, JavaScript, source map, plist, or analysis database after validation. format=dos-com explicitly interprets 1..65280 headerless bytes as a DOS COM analysis image. format=raw-binary interprets headerless bytes with a required architecture and entry_point; file offset 0 maps at that address. Omission preserves header-based detection. provider_id selects one deep provider or deterministic auto selection; the binding remains stable until close or an explicit switch, with no failure fallback. Reopening the same target and profile retains its active database and metadata edits. An optional analysis snapshot is imported atomically and must match the binary identity, concrete provider, and canonical analysis profile exactly; importing into a session with metadata mutations requires closing and reopening it first. Switching targets preserves investigation records. A failed admitted open reports the selection at failure in error.details.selection_at_failure; later calls can change it, so use binary_session for current state. Call close_binary to release engine resources; reset_investigation explicitly clears investigation records.",
|
||||
"Open a local executable, application bundle, archive, JavaScript, source map, plist, or analysis database after validation. format=dos-com explicitly interprets 1..65280 headerless bytes as a DOS COM analysis image. format=raw-binary interprets headerless bytes with a required architecture and entry_point; file offset 0 maps at that address. Omission preserves header-based detection. provider_id selects one deep provider or deterministic auto selection; the binding remains stable until close or an explicit switch, with no failure fallback. Reopening the same target and profile retains its active database and metadata edits. An optional analysis snapshot is imported atomically and must match the binary identity, concrete provider, and canonical analysis profile exactly; importing into a session with metadata mutations requires closing and reopening it first. Switching targets preserves investigation records. A failed admitted open reports error.details.selection_at_failure, target_at_open_completion, and provider_id_at_open_completion. The target summary and provider id are the selected binary and bound provider when that attempt settled, or null when absent. Later calls can change the selection, so use binary_session for current state. Call close_binary to release engine resources; reset_investigation explicitly clears investigation records.",
|
||||
openBinaryInputSchema,
|
||||
),
|
||||
session(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server";
|
||||
|
||||
import type { ToolContract } from "../contracts/toolContractTypes.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import type { BinaryOpenResult } from "../application/binary/BinarySessionPort.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import { safeParseEvidence } from "../domain/evidenceValidation.js";
|
||||
import {
|
||||
@@ -216,6 +217,26 @@ export class ToolResultDelivery {
|
||||
return this.projectedErrorResult(projected, targetContent);
|
||||
}
|
||||
|
||||
/** Include the session owner's selection at the end of this open attempt. */
|
||||
toOpenFailureToolResult(
|
||||
failure: Extract<BinaryOpenResult, { ok: false }>,
|
||||
targetContent: boolean,
|
||||
): CallToolResult {
|
||||
const projected = projectAnalysisError(failure.error);
|
||||
return this.projectedErrorResult(
|
||||
{
|
||||
...projected,
|
||||
details: {
|
||||
...projected.details,
|
||||
selection_at_failure: failure.selection,
|
||||
target_at_open_completion: failure.targetAtCompletion,
|
||||
provider_id_at_open_completion: failure.providerIdAtCompletion,
|
||||
},
|
||||
},
|
||||
targetContent,
|
||||
);
|
||||
}
|
||||
|
||||
/** Retain valid partial Evidence and reference it only after recording succeeds. */
|
||||
toPartialErrorToolResult(
|
||||
error: AnalysisError,
|
||||
|
||||
@@ -237,12 +237,9 @@ const registerOpenLifecycleTool = ({
|
||||
},
|
||||
openContract,
|
||||
)
|
||||
: server.delivery.toErrorToolResult(
|
||||
opened.error,
|
||||
: server.delivery.toOpenFailureToolResult(
|
||||
opened,
|
||||
openContract.returnsTargetContent,
|
||||
{
|
||||
selection_at_failure: opened.selection,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -46,6 +46,10 @@ workspaceCliTest(
|
||||
]),
|
||||
},
|
||||
});
|
||||
expect(result.json).not.toHaveProperty("details.target_at_open_completion");
|
||||
expect(result.json).not.toHaveProperty(
|
||||
"details.provider_id_at_open_completion",
|
||||
);
|
||||
expect(owner.remediation).toContain("REA_IDA_MCP_CONFIG");
|
||||
},
|
||||
);
|
||||
@@ -159,6 +159,86 @@ it("retains the complete oversized diagnostic before removing its private struct
|
||||
expect(retained[0]?.raw_result).toEqual(oversizedFailure());
|
||||
});
|
||||
|
||||
it("retains an oversized failed-open receipt with its original error", async () => {
|
||||
const retained: Evidence[] = [];
|
||||
const limited = createToolResultDelivery(8192);
|
||||
const server = new EvidenceMcpServer(
|
||||
{ name: "oversized-open-test", version: "1" },
|
||||
{ capabilities: {} },
|
||||
(evidence) => {
|
||||
retained.push(evidence);
|
||||
return ok("added");
|
||||
},
|
||||
limited,
|
||||
);
|
||||
const selected = {
|
||||
path: "/owned/previous.elf",
|
||||
sha256: "1".repeat(64),
|
||||
kind: "executable" as const,
|
||||
format: "elf" as const,
|
||||
architecture: "x86_64" as const,
|
||||
};
|
||||
server.registerTool("open_failure", { inputSchema: z.object({}) }, async () =>
|
||||
limited.toOpenFailureToolResult(
|
||||
{
|
||||
ok: false,
|
||||
error: new AnalysisInputError("open_binary", undefined, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_value",
|
||||
message: "x".repeat(16384),
|
||||
},
|
||||
]),
|
||||
targetAtCompletion: selected,
|
||||
providerIdAtCompletion: "ghidra",
|
||||
selection: {
|
||||
kind: "binary",
|
||||
path: selected.path,
|
||||
sha256: selected.sha256,
|
||||
},
|
||||
},
|
||||
false,
|
||||
),
|
||||
);
|
||||
const client = new Client({ name: "oversized-open-client", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const response = await client.callTool({
|
||||
name: "open_failure",
|
||||
arguments: {},
|
||||
});
|
||||
expect(retained).toHaveLength(1);
|
||||
expect(parseMcpToolError(response)).toMatchObject({
|
||||
error: {
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
reported_limits: {
|
||||
original_error_code: "invalid_request",
|
||||
evidence_reference: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: retained[0]?.evidence_id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(retained[0]?.normalized_result).toMatchObject({
|
||||
error: {
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
target_at_open_completion: selected,
|
||||
provider_id_at_open_completion: "ghidra",
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await Promise.all([client.close(), server.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
it("delivers an error whose text fits although structured and text copies would not", async () => {
|
||||
const nameBytes = Math.ceil(STDIO_DEFAULT_MAX_BUFFER_SIZE / 2) + 64 * 1024;
|
||||
const retained: Evidence[] = [];
|
||||
|
||||
@@ -174,6 +174,8 @@ describe("provider selection over MCP", () => {
|
||||
selection_reason: "ambiguous",
|
||||
requested_provider_id: "auto",
|
||||
candidate_ids: ["alpha", "beta"],
|
||||
target_at_open_completion: null,
|
||||
provider_id_at_open_completion: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -226,8 +228,15 @@ describe("provider selection over MCP", () => {
|
||||
arguments: { path: target, provider_id: "missing" },
|
||||
});
|
||||
expect(unknown.isError).toBe(true);
|
||||
expect(structured(unknown)).toMatchObject({
|
||||
error: { details: { selection_reason: "unknown_provider" } },
|
||||
const refusal = structured(unknown);
|
||||
expect(refusal).toMatchObject({
|
||||
error: {
|
||||
details: {
|
||||
selection_reason: "unknown_provider",
|
||||
target_at_open_completion: structured(opened).result,
|
||||
provider_id_at_open_completion: "beta",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
structured(
|
||||
@@ -239,6 +248,14 @@ describe("provider selection over MCP", () => {
|
||||
).toMatchObject({
|
||||
analysis_provider_binding: { provider: { id: "beta" } },
|
||||
});
|
||||
await checkQueuedSwitchReceipt(
|
||||
mcp,
|
||||
session,
|
||||
target,
|
||||
directory,
|
||||
opened,
|
||||
refusal,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -615,7 +632,7 @@ const expectQueuedOpenReceipt = async (
|
||||
}
|
||||
};
|
||||
|
||||
it("reports confirmed snapshot binding differences and keeps the active session", async () => {
|
||||
const writeSnapshotMismatchTargets = async () => {
|
||||
const testDirectory = await createTestTempDirectory(
|
||||
"rea-snapshot-mismatch-mcp-",
|
||||
);
|
||||
@@ -624,6 +641,12 @@ it("reports confirmed snapshot binding differences and keeps the active session"
|
||||
const snapshotPath = join(testDirectory, "analysis.json");
|
||||
await writeFile(first, "first fixture");
|
||||
await writeFile(second, "second fixture");
|
||||
return { testDirectory, first, second, snapshotPath };
|
||||
};
|
||||
|
||||
it("reports confirmed snapshot binding differences and keeps the active session", async () => {
|
||||
const { testDirectory, first, second, snapshotPath } =
|
||||
await writeSnapshotMismatchTargets();
|
||||
const sha = (value: string) =>
|
||||
createHash("sha256").update(value).digest("hex");
|
||||
let revision = 1;
|
||||
@@ -671,7 +694,6 @@ it("reports confirmed snapshot binding differences and keeps the active session"
|
||||
expect(
|
||||
(await call("open_binary", { path: first, provider_id: "alpha" })).isError,
|
||||
).not.toBe(true);
|
||||
|
||||
const mismatch = async (path: string, provider: string) => {
|
||||
const result = await call("open_binary", {
|
||||
path,
|
||||
@@ -680,9 +702,10 @@ it("reports confirmed snapshot binding differences and keeps the active session"
|
||||
});
|
||||
const error = parseMcpToolError(result).error;
|
||||
expect(error.code).toBe("evidence_integrity_mismatch");
|
||||
expect(error.details?.selection_at_failure).toEqual(
|
||||
expectedOpenSelection(first, sha("first fixture")),
|
||||
);
|
||||
expect(error.details).toMatchObject({
|
||||
target_at_open_completion: { sha256: sha("first fixture") },
|
||||
selection_at_failure: expectedOpenSelection(first, sha("first fixture")),
|
||||
});
|
||||
expect(error.remediation.action).toBe(
|
||||
analysisErrorRemediationAction(new AnalysisSnapshotMismatchError("")),
|
||||
);
|
||||
@@ -852,6 +875,61 @@ const structured = (result: CallToolResult): Record<string, unknown> =>
|
||||
? parseMcpToolError(result)
|
||||
: z.record(z.string(), z.unknown()).parse(result.structuredContent);
|
||||
|
||||
const checkQueuedSwitchReceipt = async (
|
||||
mcp: Client,
|
||||
session: ReturnType<typeof composeBinarySession>,
|
||||
target: string,
|
||||
directory: string,
|
||||
opened: CallToolResult,
|
||||
refusal: Record<string, unknown>,
|
||||
): Promise<void> => {
|
||||
const other = join(directory, "other.hop");
|
||||
await writeFile(other, "other fixture");
|
||||
const settled = createDeferred<void>();
|
||||
const release = createDeferred<void>();
|
||||
const open = session.open.bind(session);
|
||||
const spy = vi
|
||||
.spyOn(session, "open")
|
||||
.mockImplementation(async (path, options) => {
|
||||
const result = await open(path, options);
|
||||
if (options?.providerId === "missing") {
|
||||
settled.resolve();
|
||||
await release.promise;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
const pending = mcp.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target, provider_id: "missing" },
|
||||
});
|
||||
try {
|
||||
await awaitAcknowledgement(settled.promise, pending, "failed-open receipt");
|
||||
const switched = await mcp.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: other, provider_id: "alpha" },
|
||||
});
|
||||
expect(switched.isError).not.toBe(true);
|
||||
expect(session.activeTarget()?.sha256).toBe(
|
||||
createHash("sha256").update("other fixture").digest("hex"),
|
||||
);
|
||||
release.resolve();
|
||||
const queuedRefusal = await pending;
|
||||
for (const result of [structured(queuedRefusal), refusal])
|
||||
expect(result).toMatchObject({
|
||||
error: {
|
||||
details: {
|
||||
target_at_open_completion: structured(opened).result,
|
||||
provider_id_at_open_completion: "beta",
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
release.resolve();
|
||||
await Promise.allSettled([pending]);
|
||||
spy.mockRestore();
|
||||
}
|
||||
};
|
||||
|
||||
it("preserves a selected Ghidra project's read-only guidance when an edit is requested", async () => {
|
||||
const directory = await createTestTempDirectory("rea-project-refusal-mcp-");
|
||||
const target = join(directory, "fixture.hop");
|
||||
@@ -961,6 +1039,8 @@ it("retains attributed mixed profile cleanup through registered MCP calls and pa
|
||||
expect(opening.error).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
target_at_open_completion: null,
|
||||
provider_id_at_open_completion: null,
|
||||
resources: ["alpha-file", "beta-file"],
|
||||
diagnostics: {
|
||||
profile_errors: expect.arrayContaining([
|
||||
|
||||
@@ -312,6 +312,8 @@ it("reports an external cleanup blocker until its exact owner confirms release",
|
||||
expect(await session.open(target)).toEqual({
|
||||
ok: false,
|
||||
error: failure,
|
||||
targetAtCompletion: null,
|
||||
providerIdAtCompletion: null,
|
||||
selection: { kind: "none" },
|
||||
});
|
||||
await session.close({ cleanupRetry: { operation: "close_binary" } });
|
||||
@@ -1869,6 +1871,8 @@ describe("no replacement start", () => {
|
||||
expect(await session.open(second)).toEqual({
|
||||
ok: false,
|
||||
error: cleanupError,
|
||||
targetAtCompletion: null,
|
||||
providerIdAtCompletion: null,
|
||||
selection: { kind: "none" },
|
||||
});
|
||||
expect(created).toBe(1);
|
||||
@@ -1932,6 +1936,8 @@ it("blocks analysis rebinding until the external writer cleanup settles", async
|
||||
expect(await session.open(target)).toEqual({
|
||||
ok: false,
|
||||
error: failure,
|
||||
targetAtCompletion: null,
|
||||
providerIdAtCompletion: null,
|
||||
selection: { kind: "none" },
|
||||
});
|
||||
const anotherMutation = vi.fn(async () => resultOk("unrelated command"));
|
||||
|
||||
@@ -130,7 +130,13 @@ it.each(["close", "switch", "open after close"] as const)(
|
||||
transition === "switch" ? await attempt() : await session.close();
|
||||
expect(initial).toEqual(
|
||||
transition === "switch"
|
||||
? { ...err(cleanupError), selection: { kind: "none" } }
|
||||
? {
|
||||
ok: false,
|
||||
error: cleanupError,
|
||||
targetAtCompletion: null,
|
||||
providerIdAtCompletion: null,
|
||||
selection: { kind: "none" },
|
||||
}
|
||||
: err(cleanupError),
|
||||
);
|
||||
expect(session.activeTarget()).toBeUndefined();
|
||||
@@ -156,7 +162,13 @@ it.each(["close", "switch", "open after close"] as const)(
|
||||
expect(await attempt()).toEqual(
|
||||
transition === "close"
|
||||
? err(cleanupError)
|
||||
: { ...err(cleanupError), selection: { kind: "none" } },
|
||||
: {
|
||||
ok: false,
|
||||
error: cleanupError,
|
||||
targetAtCompletion: null,
|
||||
providerIdAtCompletion: null,
|
||||
selection: { kind: "none" },
|
||||
},
|
||||
);
|
||||
expect(await session.status()).toMatchObject({
|
||||
analysis_run: { run_id: fixture.runIds[0] },
|
||||
|
||||
@@ -28,22 +28,22 @@ import { err, ok } from "../../../src/domain/result.js";
|
||||
|
||||
type DeclaredOperation = Exclude<AnalysisOperation, "health">;
|
||||
|
||||
describe("target-bound provider routing", () => {
|
||||
it("rejects provider identity and operation-family collisions at composition", () => {
|
||||
const alpha = deepProvider("alpha");
|
||||
const registry = new AnalysisProviderRegistry([alpha.provider]);
|
||||
expect(() =>
|
||||
SessionProviderRouter.selectable(registry, [auxiliaryProvider("alpha")]),
|
||||
).toThrow(/Duplicate configured provider ID: alpha/u);
|
||||
expect(() =>
|
||||
SessionProviderRouter.selectable(registry, [
|
||||
auxiliaryProvider("auxiliary", "address_name"),
|
||||
]),
|
||||
).toThrow(
|
||||
/Deep and auxiliary providers both declare operation address_name/u,
|
||||
);
|
||||
});
|
||||
it("rejects provider identity and operation-family collisions at composition", () => {
|
||||
const alpha = deepProvider("alpha");
|
||||
const registry = new AnalysisProviderRegistry([alpha.provider]);
|
||||
expect(() =>
|
||||
SessionProviderRouter.selectable(registry, [auxiliaryProvider("alpha")]),
|
||||
).toThrow(/Duplicate configured provider ID: alpha/u);
|
||||
expect(() =>
|
||||
SessionProviderRouter.selectable(registry, [
|
||||
auxiliaryProvider("auxiliary", "address_name"),
|
||||
]),
|
||||
).toThrow(
|
||||
/Deep and auxiliary providers both declare operation address_name/u,
|
||||
);
|
||||
});
|
||||
|
||||
describe("target-bound provider routing", () => {
|
||||
it("binds one overlapping provider, preserves provenance, and never falls back", async () => {
|
||||
const target = await databaseTarget();
|
||||
const alpha = deepProvider("alpha");
|
||||
@@ -172,9 +172,10 @@ describe("target-bound provider routing", () => {
|
||||
|
||||
expect(await running).toMatchObject({ ok: true });
|
||||
const cancelled = await switching;
|
||||
expect(cancelled).toEqual({
|
||||
expect(cancelled).toMatchObject({
|
||||
ok: false,
|
||||
error: new AnalysisCancelledError("open_binary", { cause: reason }),
|
||||
targetAtCompletion: expect.objectContaining({ path: target }),
|
||||
selection: { kind: "binary", path: target, sha256: expect.any(String) },
|
||||
});
|
||||
if (!cancelled.ok) expect(cancelled.error.cause).toBe(reason);
|
||||
|
||||
@@ -80,6 +80,8 @@ it.each([
|
||||
error: new AnalysisCancelledError("open_binary", {
|
||||
cause: signal.reason,
|
||||
}),
|
||||
targetAtCompletion: null,
|
||||
providerIdAtCompletion: null,
|
||||
selection: { kind: "none" },
|
||||
};
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user