Compare commits

...
3 changed files with 108 additions and 5 deletions

No files matched your search

@@ -76,6 +76,12 @@ export const prepareSessionExecution = (
active.route.binding?.identity.id ?? active.route.identity.id,
operation,
capability?.reason ?? "operation is not declared by this provider",
capability?.remediation === undefined
? undefined
: {
userMessage: capability.reason,
remediationAction: capability.remediation,
},
),
);
}
+10 -1
View File
@@ -221,6 +221,14 @@ export const EXISTING_PROJECT_WINDOWS_UNAVAILABLE =
export const EXISTING_PROJECT_WINDOWS_REMEDIATION =
"Unset REA_GHIDRA_PROJECT_PATH in the environment that starts REA to import targets into an ephemeral project, or open the existing project on a supported Linux or macOS host; restart REA after changing the environment.";
/** Existing-project observations cannot be edited through REA. */
export const EXISTING_PROJECT_READ_ONLY =
"Existing Ghidra project sessions are read-only.";
/** An editable fresh import is a separate analysis, not the saved project. */
export const EXISTING_PROJECT_READ_ONLY_REMEDIATION =
"To keep the saved project analysis, continue read-only; REA cannot edit it. To use a separate editable Ghidra session, unset REA_GHIDRA_PROJECT_PATH and REA_GHIDRA_PROGRAM_NAME in the environment that starts REA, restart REA, and reopen the target. A fresh import is ephemeral and does not carry the selected project's saved annotations.";
/** Read-only project mode retains precise unavailable mutation capabilities. */
export const existingProjectCapabilities = (
platform: NodeJS.Platform,
@@ -246,8 +254,9 @@ export const existingProjectCapabilities = (
...capability,
limitations,
available: false,
reason: "Existing Ghidra project sessions are read-only.",
reason: EXISTING_PROJECT_READ_ONLY,
availabilityCode: "open_options_invalid",
remediation: EXISTING_PROJECT_READ_ONLY_REMEDIATION,
};
return { ...capability, limitations };
});
@@ -21,6 +21,11 @@ import {
inferredUnavailabilityReason,
runtimeFailureReason,
} from "../../../src/application/binary/SessionRuntimeAvailability.js";
import {
EXISTING_PROJECT_READ_ONLY,
EXISTING_PROJECT_READ_ONLY_REMEDIATION,
existingProjectCapabilities,
} from "../../../src/ghidra/GhidraProviderCapabilities.js";
import { ProviderAdapterError } from "../../../src/domain/providerAdapterError.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { err, ok, type Result } from "../../../src/domain/result.js";
@@ -438,6 +443,7 @@ const connectScripted = async (options: {
readonly respondAuxiliary?: { current: Responder };
/** Operations the fixture declares unavailable, with their declared reason. */
readonly declaredRefusals?: Readonly<Record<string, string>>;
readonly declaredRemediation?: Readonly<Record<string, string>>;
readonly families?: string;
}) => {
const [path] = await createBinarySessionTargets();
@@ -448,13 +454,17 @@ const connectScripted = async (options: {
ok({ profile: createAnalysisProfile(FIXTURE, { fixture: true }) }),
),
capabilities: () =>
options.operations.map((operation) =>
liveCapability(
options.operations.map((operation) => {
const descriptor = liveCapability(
FIXTURE,
operation,
options.declaredRefusals?.[operation],
),
),
);
const remediation = options.declaredRemediation?.[operation];
return descriptor.available || remediation === undefined
? descriptor
: { ...descriptor, remediation };
}),
createClient: () => ({
execute: (operation) =>
operation === "health"
@@ -532,6 +542,84 @@ const DIRECT_OPERATIONS = [
const ADDRESS = { address: "0x1000", document: "fixture" };
const PROCEDURE = { procedure: "main" };
it("delivers a declared read-only recovery without changing the selected session", async () => {
const calls: string[] = [];
const descriptor = existingProjectCapabilities("darwin").find(
(capability) => capability.operation === "set_address_name",
);
if (
descriptor === undefined ||
descriptor.available ||
descriptor.remediation === undefined
)
throw new Error("Expected Ghidra's read-only descriptor and recovery");
const { client } = await connectScripted({
operations: ["address_name", "set_address_name"],
declaredRefusals: { set_address_name: descriptor.reason },
declaredRemediation: {
set_address_name: descriptor.remediation,
},
respond: {
current: (operation) => {
calls.push(operation);
return Promise.resolve(observed("original"));
},
},
});
const before = await client.callTool({
name: "binary_session",
arguments: {},
});
const refused = await client.callTool({
name: "set_address_name",
arguments: { address: "0x1000", name: "new_name" },
});
const error = parseMcpToolError(refused).error;
expect(error.code).toBe("capability_unavailable");
expect(error.details?.reason).toBe(EXISTING_PROJECT_READ_ONLY);
expect(error.message).toBe(EXISTING_PROJECT_READ_ONLY);
expect(error.remediation.action).toBe(EXISTING_PROJECT_READ_ONLY_REMEDIATION);
expect(error.remediation.action).toContain("REA_GHIDRA_PROJECT_PATH");
expect(error.remediation.action).toContain("REA_GHIDRA_PROGRAM_NAME");
expect(calls).not.toContain("set_address_name");
const after = await client.callTool({
name: "binary_session",
arguments: {},
});
const state = (reply: typeof before) =>
toolContract("binary_session").outputSchema.parse(reply.structuredContent)
.result.analysis_run;
expect(state(before)).toBeDefined();
expect(state(after)).toEqual(state(before));
});
it("preserves another provider's declared recovery without interpreting its wording", async () => {
const reason = "Fixture capability requires an explicit workspace.";
const remediation = "Set FIXTURE_WORKSPACE to a caller-selected directory.";
const calls: string[] = [];
const { client } = await connectScripted({
operations: ["set_address_name"],
declaredRefusals: { set_address_name: reason },
declaredRemediation: { set_address_name: remediation },
respond: {
current: (operation) => {
calls.push(operation);
return Promise.resolve(observed("unexpected"));
},
},
});
const reply = await client.callTool({
name: "set_address_name",
arguments: { address: "0x1000", name: "new_name" },
});
const error = parseMcpToolError(reply).error;
expect(error.code).toBe("capability_unavailable");
expect(error.message).toBe(reason);
expect(error.details?.reason).toBe(reason);
expect(error.remediation.action).toBe(remediation);
expect(calls).not.toContain("set_address_name");
});
it("returns provider-inferred siblings to declared availability after one success", async () => {
let broken = false;
const respond = {