fix(ida): retain registration constraints and identity refusal guidance

This commit is contained in:
morluto committed 2026-10-24 04:23:56 +08:00
1 parent 6739b3cd80
commit 75dfd1c747
6 files changed
+206 -22

No files matched your search

+8
View File
@@ -195,3 +195,11 @@ an established provider failure. Cancellation retains the caller's original reas
and collected diagnostics; metadata timeouts name the requested analysis operation
while retaining the resource-read failure as their cause. This request cancellation
does not authorize saving or closing an attached GUI database.
Registration refusals retain schema-authored constraints, including the
native/attached lifecycle requirement, local credential-free endpoint requirement
and absolute workspace root. Configuration diagnostics omit registration values.
Target and private-worker identity refusals retain the failed check, its reason
and recovery guidance in the public error before any analysis replies exist.
These diagnostics do not relax identity admission or authorize closing an attached
GUI database.
+34 -10
View File
@@ -4,6 +4,7 @@ import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import type { RetainedResourceScope } from "../application/AnalysisProvider.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { projectInputIssues } from "../domain/inputIssueProjection.js";
import { err, ok, type Result } from "../domain/result.js";
import {
withOwnedRegularFile,
@@ -132,17 +133,40 @@ export const readIdaConfiguration = (
const envelope = z
.object({ mcpServers: z.object({ "ida-pro-mcp": z.unknown() }) })
.safeParse(input);
const parsed = registrationSchema.safeParse(
envelope.success ? envelope.data.mcpServers["ida-pro-mcp"] : input,
const registration = envelope.success
? envelope.data.mcpServers["ida-pro-mcp"]
: input;
const parsed = registrationSchema.safeParse(registration);
if (parsed.success) return ok(parsed.data);
// Only schema-authored correction text is admitted; Zod's aggregate
// message can contain registration values, including credentials.
const constraints = projectInputIssues(
parsed.error.issues,
registration,
).flatMap((issue) =>
issue.message === undefined
? []
: [`${issue.path.join(".") || "registration"}: ${issue.message}`],
);
if (constraints.length === 0)
return err(
idaConfigurationError(
"REA_IDA_MCP_CONFIG must contain an IDA MCP command/args or local url registration; see docs/ida-provider.md.",
parsed.error,
),
);
return err(
new ConfigurationError(
`REA_IDA_MCP_CONFIG: ${constraints.join("; ")}. See docs/ida-provider.md.`,
{
cause: parsed.error,
settings: constraints.map((constraint) => ({
setting: "REA_IDA_MCP_CONFIG",
constraint,
})),
},
),
);
return parsed.success
? ok(parsed.data)
: err(
idaConfigurationError(
"REA_IDA_MCP_CONFIG must contain an IDA MCP command/args or local url registration; see docs/ida-provider.md.",
parsed.error,
),
);
} catch (cause: unknown) {
if (cause instanceof ConfigurationError) return err(cause);
if (cause instanceof NonRegularFileReadError)
+16 -1
View File
@@ -620,7 +620,22 @@ describe("attached IDA function failure handling", () => {
producer.inputSha256 = "a".repeat(64);
const mismatch = await client.execute("health", {});
expect(mismatch.ok).toBe(false);
if (!mismatch.ok) expect(mismatch.error.message).toContain("SHA-256");
if (!mismatch.ok) {
expect(projectAnalysisError(mismatch.error)).toMatchObject({
code: "unreadable_output",
retryable: false,
message: expect.stringContaining("SHA-256"),
remediation: { action: expect.stringContaining("headless mode") },
details: {
operation: "health",
diagnostics: {
identity_check: "attached_sha256",
reason: expect.stringContaining(target.path),
},
},
});
}
expect(producer.calls.map(({ name }) => name)).toEqual(["get_metadata"]);
producer.inputSha256 = target.sha256;
producer.beforeCall = async (name) => {
if (name === "decompile_function") producer.imageBase = "0x2000";
+41 -9
View File
@@ -175,6 +175,17 @@ const isReleasedWorkerRow = (entry: {
entry.owned === false &&
entry.is_active === false;
/** Format-owner facts for a refused database/target binding, before any operation replies exist. */
class IdaIdentityRefusal extends AnalysisProtocolError {
constructor(
readonly reason: string,
readonly recovery: string,
check: string,
) {
super(reason, { diagnostics: { identity_check: check, reason, recovery } });
}
}
/** Serial read-only session whose lifecycle authority excludes an attached GUI. */
export class IdaSessionClient implements AnalysisClient {
#tail: Promise<void> = Promise.resolve();
@@ -432,7 +443,14 @@ export class IdaSessionClient implements AnalysisClient {
context: IdaOperationFailureContext,
) {
let failure: AnalysisError;
if (cause instanceof AnalysisCancelledError) {
if (cause instanceof IdaIdentityRefusal) {
failure = new AnalysisOutputError(operation, cause.reason, {
cause,
userMessage: cause.reason,
remediationAction: cause.recovery,
diagnostics: cause.diagnostics,
});
} else if (cause instanceof AnalysisCancelledError) {
failure = new AnalysisCancelledError(operation, {
cause:
cause.cause === undefined ? options?.signal?.reason : cause.cause,
@@ -680,12 +698,16 @@ export class IdaSessionClient implements AnalysisClient {
if (
!sameIdaHostPath(opened.session.input_path, this.#workspace.inputPath)
)
throw new AnalysisProtocolError(
throw new IdaIdentityRefusal(
"IDA opened a different input than the private admitted target copy.",
"Check the selected input and upstream worker binding. Retry only after retained private-worker cleanup is confirmed.",
"opened_input",
);
if (opened.session.session_id !== this.#workspace.sessionId)
throw new AnalysisProtocolError(
throw new IdaIdentityRefusal(
"IDA returned an adopted or unexpected session instead of the requested private worker.",
"Use an IDA supervisor that honors the requested private worker session ID. Confirm retained cleanup before reopening the target.",
"worker_session",
);
const inventory = databaseListSchema.parse(
await this.connection.call("idb_list", {}),
@@ -708,8 +730,10 @@ export class IdaSessionClient implements AnalysisClient {
entry.is_active !== true ||
!sameIdaHostPath(entry.input_path, this.#workspace.inputPath)
)
throw new AnalysisProtocolError(
throw new IdaIdentityRefusal(
"IDA did not return an active owned headless worker for the private input copy.",
"Configure the supervisor to return an active owned worker for the private input. Confirm retained cleanup before reopening the target.",
"owned_worker_binding",
);
this.#database = opened.session.session_id;
}
@@ -742,8 +766,10 @@ export class IdaSessionClient implements AnalysisClient {
this.#metadata = raw;
onObserved?.(raw);
if (metadata.sha256.toLowerCase() !== this.target.sha256.toLowerCase())
throw new AnalysisProtocolError(
throw new IdaIdentityRefusal(
`The attached IDA input SHA-256 does not match the selected target ${this.target.path}; open that target in IDA or use headless mode.`,
"Open the selected original input in IDA, or configure headless mode and reopen the target through REA.",
"attached_sha256",
);
identity = JSON.stringify([
metadata.path,
@@ -752,8 +778,10 @@ export class IdaSessionClient implements AnalysisClient {
]);
} else {
if (this.#database === undefined || this.#workspace === undefined)
throw new AnalysisProtocolError(
throw new IdaIdentityRefusal(
"IDA headless session has no database binding.",
"Close the REA analysis session and reopen the selected target using the configured headless supervisor.",
"database_binding",
);
const raw = await readSettledHealth(this.connection, {
database: this.#database,
@@ -768,8 +796,10 @@ export class IdaSessionClient implements AnalysisClient {
(await idaFileDigest(this.#workspace.inputPath)) !==
this.#workspace.inputSha256
)
throw new AnalysisProtocolError(
throw new IdaIdentityRefusal(
"IDA headless input identity changed after target admission.",
"Close the REA analysis session, confirm private-worker cleanup, then reopen the selected original target.",
"headless_input",
);
if (metadata.auto_analysis_ready !== true)
throw new AnalysisCapabilityUnavailableError(
@@ -798,8 +828,10 @@ export class IdaSessionClient implements AnalysisClient {
this.#initialIdentity !== undefined &&
identity !== this.#initialIdentity
)
throw new AnalysisProtocolError(
"The attached IDA target or image base changed during the REA session; reopen the target.",
throw new IdaIdentityRefusal(
"The IDA target or image base changed during the REA session; reopen the target.",
"Close the REA analysis session and reopen the selected target after confirming the intended input and image base in IDA.",
"session_identity",
);
this.#initialIdentity = identity;
return this.#metadata;
@@ -79,3 +79,69 @@ cliTest.skipIf(process.platform !== "darwin").for([true, false])(
expect(await readdir(parent)).toEqual([]);
},
);
cliTest.for([
{
name: "native headless lifecycle conflict",
registration: {
mode: "headless",
protocol: "native",
url: "http://127.0.0.1:13337/mcp",
},
constraint: "requires attached mode",
},
{
name: "nonlocal endpoint",
registration: {
url: "http://192.0.2.10:13337/mcp",
headers: { Authorization: "Bearer registration-secret" },
},
constraint: "local HTTP endpoint without URL credentials",
},
{
name: "relative workspace root",
registration: {
command: "provider-must-not-launch",
workspaceRoot: "relative",
},
constraint: "workspaceRoot must be absolute",
},
{
name: "valid registration",
registration: { command: "provider-must-not-launch", args: [] },
constraint: undefined,
},
] as const)(
"doctor retains the IDA registration constraint: $name",
async ({ registration, constraint }, { cli }) => {
const root = await createTestTempDirectory("rea-ida-doctor-constraint-");
const path = join(root, "registration.json");
await writeFile(path, JSON.stringify(registration));
const result = await cli.run({
arguments: ["doctor", "--provider", "ida", "--json"],
environment: { HOME: root, REA_IDA_MCP_CONFIG: path },
timeoutMs: 10_000,
});
expect(result.exitCode, result.stderr).toBe(
constraint === undefined ? 0 : 1,
);
expect(result.json).toMatchObject({
providerInspections: expect.arrayContaining([
expect.objectContaining({
id: "ida",
available: constraint === undefined,
checks: expect.arrayContaining([
expect.objectContaining({
name: "registration",
ok: constraint === undefined,
detail: expect.stringContaining(
constraint ?? "no IDA process was launched",
),
}),
]),
}),
]),
});
expect(result.stdout).not.toContain("registration-secret");
},
);
@@ -34,6 +34,8 @@ type StartupMode =
| "connect-timeout"
| "catalog-timeout"
| "catalog-refused"
| "headless-input-mismatch"
| "unexpected-worker"
| "success";
const createStartupSupervisor = async (mode: StartupMode) => {
@@ -98,7 +100,11 @@ const createStartupSupervisor = async (mode: StartupMode) => {
input = z.string().parse(args.input_path);
result = {
success: true,
session: { session_id: database, input_path: input },
session: {
session_id:
mode === "unexpected-worker" ? "unexpected-worker" : database,
input_path: input,
},
};
} else if (name === "idb_list")
result = {
@@ -127,7 +133,8 @@ const createStartupSupervisor = async (mode: StartupMode) => {
} else if (name === "server_health")
result = {
status: "ok",
input_path: input,
input_path:
mode === "headless-input-mismatch" ? input + ".other" : input,
idb_path: input + ".i64",
module: "target.elf",
imagebase: "0x1000",
@@ -165,6 +172,8 @@ workspaceCliTest.for([
"connect-timeout",
"catalog-timeout",
"catalog-refused",
"headless-input-mismatch",
"unexpected-worker",
"success",
] as const)(
"attributes selected IDA startup to the public analysis: %s",
@@ -208,6 +217,36 @@ workspaceCliTest.for([
return;
}
expect(result.exitCode).toBe(1);
if (mode === "headless-input-mismatch" || mode === "unexpected-worker") {
expect(calls).not.toContain("decompile");
expect(calls).toContain("idb_close");
expect(result.json).toMatchObject({
code: "unreadable_output",
retryable: false,
message: expect.stringContaining(
mode === "headless-input-mismatch"
? "input identity changed"
: "unexpected session",
),
remediation: {
action: expect.stringContaining(
mode === "headless-input-mismatch" ? "reopen" : "session ID",
),
},
details: {
operation: "procedure_pseudo_code",
diagnostics: {
identity_check:
mode === "headless-input-mismatch"
? "headless_input"
: "worker_session",
reason: expect.any(String),
recovery: expect.any(String),
},
},
});
return;
}
expect(calls).toEqual([]);
expect(result.json).toMatchObject({
code: mode.endsWith("timeout")