Compare commits

...
12 changed files with 515 additions and 8 deletions

No files matched your search

+14 -2
View File
@@ -1012,8 +1012,20 @@ limitations. An unavailable group carries `remediation` when its readiness
owner supplied a repair instruction; `tool_availability` uses that instruction
rather than the failure description.
`binary_session.analysis_provider_candidates` is authoritative for deep-engine
discovery. Each candidate's capability sets, returned with complete detail,
`binary_session.analysis_provider_candidates` reports deep-engine discovery.
`analysis_provider_candidates_observed` is `target_selection` when those facts
were retained from the active target's selection, `status_request` when the
status request sought candidate observations, and `not_applicable` for resource-only
sessions. Selection-time readiness may have changed since the target was opened.
Set `refresh_candidates: true` to inspect candidate readiness and target support
again without replacing the active binding or engine. That does not resolve a
new analysis profile or start a provider client. Later transitions and analysis
calls wait for the inspection to finish, including cleanup. Current-route capabilities
remain those of the bound session. Candidate statuses can still be unknown when
an inspection cannot complete.
`open_binary` with an explicit `provider_id` performs a new selection. Reopening
the same target without `provider_id` reuses its current route. A changed route
can retire the previous engine and its ephemeral edits. Each candidate's capability sets, returned with complete detail,
describe that possible provider;
they do not select it. The top-level `capabilities` describe the current route,
while `analysis_provider_binding` identifies the concrete selected route. These
+5
View File
@@ -277,6 +277,11 @@ const openServer = async (provider, kunaSettings = {}) => {
path: provider.path,
provider_id: provider.id,
});
const session = (await call("binary_session")).value;
assert.equal(
session.analysis_provider_candidates_observed,
"target_selection",
);
} catch (cause) {
// A rejected connection/target still owns its transport and workspace.
await close(cause);
+16 -2
View File
@@ -1304,8 +1304,20 @@ export class BinarySession
options: {
readonly signal?: AbortSignal;
readonly detail?: BinarySessionDetail;
readonly refreshCandidates?: boolean;
} = {},
): Promise<JsonValue> {
return options.refreshCandidates === true
? this.#serialize(() => this.#status(options), { abortPrompts: false })
: this.#status(options);
}
/** Keep an explicit candidate refresh ordered with target transitions. */
#status(options: {
readonly signal?: AbortSignal;
readonly detail?: BinarySessionDetail;
readonly refreshCandidates?: boolean;
}): Promise<JsonValue> {
const resourceRun = this.#activeBundleResources;
const run =
this.#active ??
@@ -1604,9 +1616,11 @@ export class BinarySession
#serialize<T>(
operation: (admittedThrough: number) => Promise<T>,
options: { readonly abortPrompts?: boolean } = {},
): Promise<T> {
for (const controller of this.#promptCompletionControllers)
controller.abort();
if (options.abortPrompts !== false)
for (const controller of this.#promptCompletionControllers)
controller.abort();
this.#transitionPending += 1;
// Later calls wait for this transition and must not be included in its drain.
const admittedThrough = this.#transitionGeneration++;
@@ -167,6 +167,7 @@ it("opens a resource-only bundle without executable identity and keeps snapshot
expect(await session.status()).toMatchObject({
open: true,
session_kind: "bundle_resources",
analysis_provider_candidates_observed: "not_applicable",
resource_root: { canonical_path: opened.value.canonicalPath },
});
const snapshot = session.exportAnalysisSnapshot();
@@ -140,6 +140,7 @@ export interface BinarySessionPort
status(options?: {
readonly signal?: AbortSignal;
readonly detail?: BinarySessionDetail;
readonly refreshCandidates?: boolean;
}): Promise<JsonValue>;
activeTarget(): BinaryTarget | undefined;
/** Read a snapshot with failed descriptors retained for session cleanup retry. */
+15 -1
View File
@@ -47,6 +47,7 @@ interface BinarySessionStatusInput {
readonly cleanupRetry?: AnalysisCleanupRetry;
readonly detail?: BinarySessionDetail;
readonly signal?: AbortSignal;
readonly refreshCandidates?: boolean;
}
/** Project internal provider routing state into the caller-visible session status. */
@@ -66,6 +67,7 @@ export const binarySessionStatus = async ({
cleanupRetry,
detail = "complete",
signal,
refreshCandidates = false,
}: BinarySessionStatusInput): Promise<JsonValue> => {
const routeCapabilities = (
bundleResourceRoot === undefined
@@ -112,7 +114,13 @@ export const binarySessionStatus = async ({
const analysisProviderCandidates =
bundleResourceRoot !== undefined
? []
: (await router.candidateStatuses(route, signal)).map((candidate) =>
: (
await router.candidateStatuses(route, {
...(signal === undefined ? {} : { signal }),
...(target === undefined ? {} : { target }),
refresh: refreshCandidates,
})
).map((candidate) =>
detail === "summary"
? {
provider: providerSummary(candidate.provider),
@@ -146,6 +154,12 @@ export const binarySessionStatus = async ({
capabilities,
analysis_provider_binding: analysisProviderBinding,
analysis_provider_candidates: analysisProviderCandidates,
analysis_provider_candidates_observed:
bundleResourceRoot !== undefined
? "not_applicable"
: route.selection === undefined || refreshCandidates
? "status_request"
: "target_selection",
analysis_run:
runId === undefined
? null
@@ -129,11 +129,26 @@ export class SessionProviderRouter {
);
}
/** Candidate status authoritative for the active or target-free route. */
/** Preserve selection observations unless a caller requests a fresh inspection. */
async candidateStatuses(
route: SessionProviderRoute,
signal?: AbortSignal,
options: {
readonly signal?: AbortSignal;
readonly target?: BinaryTarget;
readonly refresh?: boolean;
} = {},
): Promise<readonly AnalysisProviderCandidateStatus[]> {
const { signal, target, refresh = false } = options;
if (refresh && route.selection !== undefined) {
const current = await this.registry.candidates(
target,
signal === undefined ? {} : { signal },
);
return current.map((candidate) => ({
...candidate,
selected: candidate.provider.id === route.binding?.identity.id,
}));
}
return (
route.selection?.candidates ??
this.registry.candidates(
+6
View File
@@ -27,6 +27,12 @@ export const binarySessionInputSchema = z.strictObject({
.describe(
"Start with summary (default): it keeps open state, the binding, current-route capabilities (including selected Ghidra checkpoint mode), each provider candidate's availability and target-support status, code and reason, and every tool's availability, reason and remediation; omitted_fields lists what it leaves out. Request complete only when you need unavailable-candidate remediation, provider diagnostics, candidate capabilities or static tool surface, effect and annotation flags.",
),
refresh_candidates: z
.boolean()
.default(false)
.describe(
"Inspect current provider candidates for the active target without replacing or restarting the selected provider. Later transitions and analysis calls wait for inspection finalization. Omitted or false reports the retained selection observations.",
),
expected_package_version: z.string().min(1).optional(),
expected_catalog_digest: z
.string()
+1 -1
View File
@@ -88,7 +88,7 @@ export const SESSION_TOOL_CONTRACTS = [
),
session(
"binary_session",
'Report the active binary target or resource-only app root, provider and capability availability when applicable, client-feature, analysis, and server-identity status without starting analysis. Start with the default summary: after selecting Ghidra, capabilities[].shared_limitations already reports enabled checkpoint or ephemeral mode. Request detail: "complete" only for provider diagnostics, candidate capabilities or static tool flags; omitted_fields lists what summary leaves out.',
'Report the active binary target or resource-only app root, provider and capability availability when applicable, client-feature, analysis, and server-identity status without starting analysis. analysis_provider_candidates_observed distinguishes this status request from retained target-selection observations; selection-time readiness may have changed. Set refresh_candidates true to inspect current candidates without changing the active binding or engine. open_binary with an explicit provider_id reselects and may retire the previous engine and its ephemeral edits; reopening the same target without provider_id reuses its current route. Start with the default summary: after selecting Ghidra, capabilities[].shared_limitations already reports enabled checkpoint or ephemeral mode. Request detail: "complete" only for provider diagnostics, candidate capabilities or static tool flags; omitted_fields lists what summary leaves out.',
binarySessionInputSchema,
),
session(
@@ -556,6 +556,11 @@ export const sessionProviderFacts = z
analysis_run: analysisRun,
analysis_activity: analysisActivity,
provider_operation_health: providerOperationHealth,
analysis_provider_candidates_observed: z
.enum(["status_request", "target_selection", "not_applicable"])
.describe(
"When provider candidate observations were collected: this status request, the retained target selection, or not applicable to a resource-only session.",
),
analysis_provider_binding: z
.object({
provider: providerIdentity,
+1
View File
@@ -103,6 +103,7 @@ export const registerSessionStatusTool = (
status = await session.status({
signal: context.mcpReq.signal,
detail: input.detail,
refreshCandidates: input.refresh_candidates,
});
} catch (cause) {
if (!(cause instanceof AnalysisError)) throw cause;
@@ -994,6 +994,439 @@ it("preserves a selected Ghidra project's read-only guidance when an edit is req
expect(starts).toEqual([]);
});
it("labels unbound provider observations as selection-time after a repair", async () => {
const directory = await createTestTempDirectory("rea-candidate-observation-");
const target = join(directory, "fixture.hop");
await writeFile(target, "fixture");
let available = false;
const base = candidate("alpha", []);
const session = composeBinarySession(
new AnalysisProviderRegistry([
{
...base,
inspectAvailability: () =>
candidate("alpha", [], available).inspectAvailability(),
},
]),
);
const server = createServer(
{ kind: "session", session },
{ logger: silentLogger },
);
const mcp = new Client({ name: "candidate-scope", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(mcp, server, { close: () => session.close() });
await server.connect(serverTransport);
await mcp.connect(clientTransport);
expect(
(await mcp.callTool({ name: "open_binary", arguments: { path: target } }))
.isError,
).toBeFalsy();
available = true;
const stale = toolContract("binary_session").outputSchema.parse(
(await mcp.callTool({ name: "binary_session", arguments: {} }))
.structuredContent,
).result;
expect(stale.analysis_provider_binding).toBeNull();
expect(stale.analysis_provider_candidates_observed).toBe("target_selection");
expect(stale.analysis_provider_candidates[0]?.availability.status).toBe(
"unavailable",
);
const inspected = toolContract("binary_session").outputSchema.parse(
(
await mcp.callTool({
name: "binary_session",
arguments: { refresh_candidates: true },
})
).structuredContent,
).result;
expect(inspected.analysis_provider_binding).toBeNull();
expect(inspected.analysis_provider_candidates_observed).toBe(
"status_request",
);
expect(inspected.analysis_provider_candidates[0]?.availability.status).toBe(
"available",
);
const retained = toolContract("binary_session").outputSchema.parse(
(await mcp.callTool({ name: "binary_session", arguments: {} }))
.structuredContent,
).result;
expect(retained.analysis_provider_candidates[0]?.availability.status).toBe(
"unavailable",
);
expect(
(await mcp.callTool({ name: "open_binary", arguments: { path: target } }))
.isError,
).toBeFalsy();
const reused = toolContract("binary_session").outputSchema.parse(
(await mcp.callTool({ name: "binary_session", arguments: {} }))
.structuredContent,
).result;
expect(reused.analysis_provider_binding).toBeNull();
expect(reused.analysis_provider_candidates[0]?.availability.status).toBe(
"unavailable",
);
expect(
(
await mcp.callTool({
name: "open_binary",
arguments: { path: target, provider_id: "alpha" },
})
).isError,
).toBeFalsy();
const refreshed = toolContract("binary_session").outputSchema.parse(
(await mcp.callTool({ name: "binary_session", arguments: {} }))
.structuredContent,
).result;
expect(refreshed.analysis_provider_candidates_observed).toBe(
"target_selection",
);
expect(refreshed.analysis_provider_binding?.provider.id).toBe("alpha");
});
it("preserves the bound client when a candidate refresh is cancelled during finalization", async () => {
const directory = await createTestTempDirectory(
"rea-candidate-refresh-cancel-",
);
const target = join(directory, "fixture.hop");
await writeFile(target, "fixture");
const starts: string[] = [];
const base = candidate("alpha", starts);
let hold = false;
let enter!: () => void;
let finish!: () => void;
let finalized!: () => void;
const entered = new Promise<void>((resolve) => {
enter = resolve;
});
const gate = new Promise<void>((resolve) => {
finish = resolve;
});
const done = new Promise<void>((resolve) => {
finalized = resolve;
});
const session = composeBinarySession(
new AnalysisProviderRegistry([
{
...base,
inspectAvailability: async ({
signal,
}: AnalysisProviderInspectionOptions = {}) => {
const result = await base.inspectAvailability();
if (!hold) return result;
enter();
await gate;
finalized();
if (signal?.aborted)
throw new AnalysisCancelledError("binary_session", {
cause: signal.reason,
});
return result;
},
},
]),
);
const server = createServer(
{ kind: "session", session },
{ logger: silentLogger },
);
const mcp = new Client({ name: "candidate-refresh-cancel", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(mcp, server, { close: () => session.close() });
await server.connect(serverTransport);
await mcp.connect(clientTransport);
let refreshing: ReturnType<typeof mcp.callTool> | undefined;
try {
expect(
(
await mcp.callTool({
name: "open_binary",
arguments: { path: target, provider_id: "alpha" },
})
).isError,
).toBeFalsy();
expect(
(await mcp.callTool({ name: "address_name", arguments: {} })).isError,
).toBeFalsy();
expect(starts).toEqual(["alpha"]);
hold = true;
const controller = new AbortController();
refreshing = mcp.callTool(
{ name: "binary_session", arguments: { refresh_candidates: true } },
{ signal: controller.signal },
);
await awaitAcknowledgement(entered, refreshing, "candidate refresh");
controller.abort();
await expect(refreshing).rejects.toThrow();
finish();
await done;
hold = false;
expect(
structured(await mcp.callTool({ name: "binary_session", arguments: {} }))
.result,
).toMatchObject({
analysis_provider_binding: { provider: { id: "alpha" } },
analysis_provider_candidates_observed: "target_selection",
});
expect(
(await mcp.callTool({ name: "address_name", arguments: {} })).isError,
).toBeFalsy();
expect(starts).toEqual(["alpha"]);
} finally {
hold = false;
finish();
await Promise.allSettled(refreshing === undefined ? [] : [refreshing]);
}
});
it("orders a provider switch after an in-flight candidate refresh", async () => {
const directory = await createTestTempDirectory(
"rea-candidate-refresh-switch-",
);
const target = join(directory, "fixture.hop");
await writeFile(target, "fixture");
const starts: string[] = [];
const alpha = candidate("alpha", starts);
let hold = false;
let enter!: () => void;
let finish!: () => void;
const entered = new Promise<void>((resolve) => {
enter = resolve;
});
const gate = new Promise<void>((resolve) => {
finish = resolve;
});
const session = composeBinarySession(
new AnalysisProviderRegistry([
{
...alpha,
inspectAvailability: async () => {
if (hold) {
hold = false;
enter();
await gate;
}
return alpha.inspectAvailability();
},
},
candidate("beta", starts),
]),
);
const server = createServer(
{ kind: "session", session },
{ logger: silentLogger },
);
const mcp = new Client({ name: "refresh-switch-race", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(mcp, server, { close: () => session.close() });
await server.connect(serverTransport);
await mcp.connect(clientTransport);
let refreshing: ReturnType<typeof mcp.callTool> | undefined;
let switching: ReturnType<typeof mcp.callTool> | undefined;
try {
expect(
(
await mcp.callTool({
name: "open_binary",
arguments: { path: target, provider_id: "alpha" },
})
).isError,
).toBeFalsy();
hold = true;
refreshing = mcp.callTool({
name: "binary_session",
arguments: { refresh_candidates: true },
});
await awaitAcknowledgement(entered, refreshing, "candidate refresh");
let switched = false;
switching = mcp
.callTool({
name: "open_binary",
arguments: { path: target, provider_id: "beta" },
})
.then((result) => {
switched = true;
return result;
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(switched).toBe(false);
finish();
const result = await refreshing;
expect(result.isError).toBeFalsy();
expect(structured(result).result).toMatchObject({
analysis_provider_binding: { provider: { id: "alpha" } },
});
expect((await switching).isError).toBeFalsy();
expect(
structured(await mcp.callTool({ name: "binary_session", arguments: {} }))
.result,
).toMatchObject({
analysis_provider_binding: { provider: { id: "beta" } },
});
} finally {
finish();
await Promise.allSettled(
[refreshing, switching].filter((operation) => operation !== undefined),
);
}
});
it("does not cancel an already admitted prompt lookup for a candidate refresh", async () => {
const directory = await createTestTempDirectory("rea-refresh-prompt-");
const target = join(directory, "fixture.hop");
await writeFile(target, "fixture");
const base = candidate("alpha", []);
let enter!: () => void;
let finish!: () => void;
const entered = new Promise<void>((resolve) => {
enter = resolve;
});
const gate = new Promise<void>((resolve) => {
finish = resolve;
});
const session = composeBinarySession(
new AnalysisProviderRegistry([
{
...base,
createClient: (selected, profile, context) => {
const client = base.createClient(selected, profile, context);
return {
...client,
executeIfReady: async (operation, args, options) => {
enter();
await gate;
if (options?.signal?.aborted)
throw new Error("Prompt lookup was cancelled");
return client.execute(operation, args, options);
},
};
},
},
]),
);
const server = createServer(
{ kind: "session", session },
{ logger: silentLogger },
);
const mcp = new Client({ name: "refresh-prompt", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(mcp, server, { close: () => session.close() });
await server.connect(serverTransport);
await mcp.connect(clientTransport);
let lookup: ReturnType<typeof session.executeIfProviderReady> | undefined;
try {
expect(
(
await mcp.callTool({
name: "open_binary",
arguments: { path: target, provider_id: "alpha" },
})
).isError,
).toBeFalsy();
expect(
(await mcp.callTool({ name: "address_name", arguments: {} })).isError,
).toBeFalsy();
lookup = session.executeIfProviderReady("address_name", {});
await awaitAcknowledgement(entered, lookup, "prompt lookup");
expect(
(
await mcp.callTool({
name: "binary_session",
arguments: { refresh_candidates: true },
})
).isError,
).toBeFalsy();
finish();
expect((await lookup)?.ok).toBe(true);
} finally {
finish();
await Promise.allSettled(lookup === undefined ? [] : [lookup]);
}
});
it("retains failed candidate-refresh cleanup while preserving the selected binding", async () => {
const directory = await createTestTempDirectory(
"rea-candidate-refresh-cleanup-",
);
const target = join(directory, "fixture.hop");
await writeFile(target, "fixture");
const starts: string[] = [];
const beta = candidate("beta", starts);
let fail = false;
let release = false;
const failure = new ProviderCleanupError("beta", ["beta-refresh-owner"], {
reason: "controlled cleanup failure",
});
const session = composeBinarySession(
new AnalysisProviderRegistry([
candidate("alpha", starts),
{
...beta,
inspectAvailability: () => {
if (fail) throw failure;
return beta.inspectAvailability();
},
closeOwnedResources: async () => {
if (!fail) return ok(null);
if (!release) return err(failure);
failure.resolveCleanup();
return ok(null);
},
},
]),
);
const server = createServer(
{ kind: "session", session },
{ logger: silentLogger },
);
const mcp = new Client({ name: "candidate-refresh-cleanup", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(mcp, server, { close: () => session.close() });
await server.connect(serverTransport);
await mcp.connect(clientTransport);
try {
expect(
(
await mcp.callTool({
name: "open_binary",
arguments: { path: target, provider_id: "alpha" },
})
).isError,
).toBeFalsy();
fail = true;
const failed = parseMcpToolError(
await mcp.callTool({
name: "binary_session",
arguments: { refresh_candidates: true },
}),
);
expect(failed.error).toMatchObject({
code: "cleanup_incomplete",
details: { resources: ["beta-refresh-owner"] },
});
const status = structured(
await mcp.callTool({ name: "binary_session", arguments: {} }),
);
expect(status.result).toMatchObject({
cleanup_pending: true,
analysis_provider_binding: { provider: { id: "alpha" } },
});
release = true;
expect(
(await mcp.callTool({ name: "close_binary", arguments: {} })).isError,
).toBeFalsy();
expect(failure.cleanupIncomplete).toBe(false);
} finally {
release = true;
await session.close();
}
});
it("retains attributed mixed profile cleanup through registered MCP calls and partial retry", async () => {
directory = await createTestTempDirectory("rea-profile-completion-mcp-");
const path = join(directory, "fixture.hop");