fix(session): align resource-only status and snapshot recovery (#7896)

* fix(session): align resource-only status and snapshot recovery

* fix(session): report independent workflows during resource sessions

* test(mcp): honor SQLite runtime availability

* test: compare resource snapshot guidance with its formatter

Assert projected error codes and the owning remediation action for
resource-session snapshot refusals, and type MCP error text as a closed
content item.

---------

Co-authored-by: Cursor Agent <[email protected]>
This commit is contained in:
morlutoandCursor Agent authored and GitHub committed 2026-10-30 00:03:19 +08:00
1 parent bf115be2cd
commit 845080aef6
8 files changed
+260 -23

No files matched your search

@@ -1,3 +1,4 @@
import { sqliteDatabaseCapability } from "./sqlite/SqliteDatabaseCapability.js";
import { persistentDebuggerHostSupported } from "../process/PersistentDebuggerHost.js";
import { pcapCaptureHostSupported } from "../domain/pcap/pcapAnalysis.js";
import { describe, expect, it } from "vitest";
@@ -100,6 +101,77 @@ const entry = (
return found;
};
it("reports the active resource session's dispatch policy without limiting independent tools", () => {
const resourceStatus = {
...status({ open: true }),
session_kind: "bundle_resources",
capabilities: [
{
provider: { id: "bundle-resources", name: "Resources", version: "1" },
shared_limitations: [],
groups: [
{
operations: [
"inspect_plist",
"decode_interface_builder",
"inspect_asset_catalog",
"inspect_keyed_archive",
],
effects: [],
limitations: [],
available: true,
reason: null,
availability_code: null,
},
],
},
],
};
const entries = buildCapabilityInventory(resourceStatus, enabledPolicy);
for (const name of [
"inspect_artifact",
"extract_artifact",
"trace_dylib_resolution",
"get_navigation_context",
])
expect(entries.find((item) => item.name === name)).toMatchObject({
available: false,
reason: "target_unsupported",
remediation: expect.stringContaining("open_binary"),
});
for (const name of [
"inspect_plist",
"decode_interface_builder",
"inspect_asset_catalog",
"inspect_keyed_archive",
"inspect_pe_imports",
"inspect_desktop_application",
"close_binary",
])
expect(entries.find((item) => item.name === name)).toMatchObject({
available: true,
});
expect(
entries.find((item) => item.name === "inspect_apk_signature"),
).toMatchObject({
available: true,
reason: "available",
remediation: null,
});
const sqlite = sqliteDatabaseCapability(process.versions.node);
expect(
entries.find((item) => item.name === "inspect_sqlite_database"),
).toMatchObject(
sqlite.available
? { available: true }
: {
available: false,
reason: "provider_unavailable",
remediation: sqlite.reason,
},
);
});
describe("ADB and Apktool host availability", () => {
it("uses only their selected owner probes", () => {
const ready = {
+18 -1
View File
@@ -42,6 +42,7 @@ import type { JebAnalysisAvailability } from "./jeb/JebAnalysisPort.js";
import { FRIDA_TOOL_CONTRACTS } from "../contracts/fridaToolContracts.js";
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { isBundleResourceOperation } from "./binary/BundleResourceSession.js";
const FRIDA_OPERATION_NAMES: ReadonlySet<string> = new Set(
FRIDA_TOOL_CONTRACTS.map(({ name }) => name),
@@ -49,6 +50,7 @@ const FRIDA_OPERATION_NAMES: ReadonlySet<string> = new Set(
const statusSchema = z.object({
open: z.boolean(),
session_kind: z.literal("bundle_resources").optional(),
kind: z.enum(["executable", "database", "archive", "artifact"]).optional(),
format: z.string().optional(),
capabilities: z
@@ -128,6 +130,7 @@ type AvailabilityContext = {
readonly name: string;
readonly kind: ToolKind;
readonly targetOpen: boolean;
readonly resourceSession: boolean;
readonly requiresActiveTarget: boolean;
readonly targetKind:
| "executable"
@@ -260,6 +263,7 @@ export const buildCapabilityInventory = (
name: contract.name,
kind: contract.kind,
targetOpen: status.open,
resourceSession: status.session_kind === "bundle_resources",
requiresActiveTarget:
"requiresActiveTarget" in contract &&
contract.requiresActiveTarget === true,
@@ -380,6 +384,19 @@ export function compactToolAvailability(
}
const availabilityFor = (context: AvailabilityContext): Availability => {
const workflowAvailability = workflowAvailabilityFor(context);
if (
context.targetOpen &&
context.resourceSession &&
context.requiresActiveTarget &&
!isBundleResourceOperation(context.name) &&
(workflowAvailability === null || context.kind === "session")
)
return {
reason: "target_unsupported",
remediation:
"This resource-only session cannot execute this operation. Call open_binary with an executable analysis target.",
};
// These built-in workflows accept a caller-selected endpoint; they do not load the optional passive provider.
if (
context.name === "inspect_byte_entropy" ||
@@ -403,7 +420,6 @@ const availabilityFor = (context: AvailabilityContext): Availability => {
reason: "provider_unavailable",
remediation: `Adapter ${failure.providerId} could not load: ${failure.reason}. Repair the adapter installation and restart REA; other capabilities remain available.`,
};
const workflowAvailability = workflowAvailabilityFor(context);
if (workflowAvailability !== null) return workflowAvailability;
const hostDecision = hostAvailability(context);
if (hostDecision !== null) return hostDecision;
@@ -453,6 +469,7 @@ const workflowAvailabilityFor = ({
"Run persistent debugger sessions on Linux with /proc descriptor readback.",
);
if (
name === "inspect_apk_signature" ||
name === "inspect_desktop_application" ||
name === "inspect_go_binary" ||
name === "inspect_il2cpp_metadata" ||
+3 -11
View File
@@ -64,7 +64,7 @@ import { analysisErrorWithCancellation } from "../../domain/analysisErrorCancell
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { profilelessNativeDispatchProfile } from "./ProfilelessNativeWorkflowSnapshot.js";
import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js";
import { AnalysisSnapshotMismatchError } from "../../domain/evidenceErrors.js";
import { ResourceSnapshotMismatchError } from "../../domain/evidenceErrors.js";
import {
parseBinaryTarget,
type BinaryTargetResolverOptions,
@@ -1149,21 +1149,13 @@ export class BinarySession
override exportAnalysisSnapshot() {
if (this.#activeBundleResources !== undefined)
return err(
new AnalysisSnapshotMismatchError(
"Analysis snapshots do not apply to a resource-only app session because it has no executable identity or replay binding.",
),
);
return err(new ResourceSnapshotMismatchError("export"));
return super.exportAnalysisSnapshot();
}
override importAnalysisSnapshot(snapshot: AnalysisSnapshot) {
if (this.#activeBundleResources !== undefined)
return err(
new AnalysisSnapshotMismatchError(
"Analysis snapshots do not apply to a resource-only app session because it has no executable identity or replay binding.",
),
);
return err(new ResourceSnapshotMismatchError("import"));
return super.importAnalysisSnapshot(snapshot);
}
@@ -14,7 +14,10 @@ import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js";
import { BinarySession } from "./BinarySession.js";
import { SessionProviderRouter } from "./SessionProviderRouter.js";
import { bundleResourceRootSchema } from "../../domain/apple/bundleResourceRoot.js";
import { AnalysisSnapshotMismatchError } from "../../domain/evidenceErrors.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { analysisErrorRemediationAction } from "../../domain/analysisErrorPresentation.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { ResourceSnapshotMismatchError } from "../../domain/evidenceErrors.js";
import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
@@ -36,6 +39,24 @@ const binaryIdentity: ProviderIdentity = {
};
const temporaryDirectories: string[] = [];
const expectResourceSnapshotRefusal = (
error: AnalysisError,
operation: "export" | "import",
): void => {
expect(error).toBeInstanceOf(ResourceSnapshotMismatchError);
expect(error).toMatchObject({
_tag: "EvidenceIntegrityError",
operation,
});
const projected = projectAnalysisError(error);
expect(projected.code).toBe("evidence_integrity_mismatch");
expect(projected.category).toBe("integrity_mismatch");
expect(projected.retryable).toBe(false);
expect(projected.remediation).toEqual({
action: analysisErrorRemediationAction(error),
});
};
afterEach(async () => {
await Promise.all(
temporaryDirectories
@@ -137,13 +158,17 @@ it("opens a resource-only bundle without executable identity and keeps snapshot
resource_root: { canonical_path: opened.value.canonicalPath },
});
const snapshot = session.exportAnalysisSnapshot();
expect(snapshot.ok).toBe(false);
if (!snapshot.ok)
expect(snapshot.error).toBeInstanceOf(AnalysisSnapshotMismatchError);
if (snapshot.ok)
throw new Error("resource session exported a binary snapshot");
expectResourceSnapshotRefusal(snapshot.error, "export");
const imported = session.importAnalysisSnapshot({} as AnalysisSnapshot);
expect(imported.ok).toBe(false);
if (!imported.ok)
expect(imported.error).toBeInstanceOf(AnalysisSnapshotMismatchError);
if (imported.ok)
throw new Error("resource session imported a binary snapshot");
expectResourceSnapshotRefusal(imported.error, "import");
expect(await session.status()).toMatchObject({
open: true,
session_kind: "bundle_resources",
});
const closedResult = await session.close();
expect(closedResult.ok).toBe(true);
expect(closed).toBe(true);
@@ -24,6 +24,13 @@ export const BUNDLE_RESOURCE_OPERATIONS = new Set<AnalysisOperation>([
...BUNDLE_ARTIFACT_RESOURCE_OPERATIONS,
]);
/** Share the dispatch owner's resource-session operation policy with discovery. */
export const isBundleResourceOperation = (name: string): boolean => {
for (const operation of BUNDLE_RESOURCE_OPERATIONS)
if (operation === name) return true;
return false;
};
/** Provider-specific owner of the four read-only app-resource operations. */
export interface BundleResourceClientFactory {
createClient(
+5
View File
@@ -21,6 +21,7 @@ import {
EvidenceIntegrityError,
EvidenceReferenceError,
AnalysisSnapshotMismatchError,
ResourceSnapshotMismatchError,
} from "./evidenceErrors.js";
import {
HopperRemoteError,
@@ -79,6 +80,10 @@ export const analysisErrorRemediationAction = (
error.remediationAction !== undefined
)
return error.remediationAction;
if (error instanceof ResourceSnapshotMismatchError)
return error.operation === "export"
? "Call close_binary without snapshot_path to close this resource-only session."
: "Open an executable analysis target before importing a binary analysis snapshot.";
if (error instanceof AnalysisSnapshotMismatchError)
return "Run analysis without this snapshot, then save a fresh snapshot using the intended artifact, provider, and analysis profile.";
if (error instanceof AnalysisUnsupportedTargetError)
+9
View File
@@ -33,6 +33,15 @@ export class EvidenceIntegrityError extends AnalysisError {
/** A valid snapshot belongs to a different target, provider, or analysis profile. */
export class AnalysisSnapshotMismatchError extends EvidenceIntegrityError {}
/** A resource session cannot import or export a binary analysis snapshot. */
export class ResourceSnapshotMismatchError extends AnalysisSnapshotMismatchError {
constructor(readonly operation: "import" | "export") {
super(
"Analysis snapshots do not apply to a resource-only app session because it has no executable identity or replay binding.",
);
}
}
/** A session Evidence reference is missing or has the wrong semantic identity. */
export class EvidenceReferenceError extends EvidenceIntegrityError {
constructor(
@@ -1,10 +1,13 @@
import { build } from "plist";
import { mkdir, writeFile } from "node:fs/promises";
import { lstat, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { sqliteDatabaseCapability } from "../../../src/application/sqlite/SqliteDatabaseCapability.js";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { analysisErrorRemediationAction } from "../../../src/domain/analysisErrorPresentation.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { ResourceSnapshotMismatchError } from "../../../src/domain/evidenceErrors.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { cliTest } from "../../support/cli/cliFixture.js";
@@ -16,6 +19,74 @@ const resourceOperations = [
"inspect_keyed_archive",
] as const;
const expectUnsupportedArtifactOperations = (content: unknown) => {
const parsed = toolContract("binary_session").outputSchema.parse(content);
for (const name of [
"inspect_artifact",
"extract_artifact",
"trace_dylib_resolution",
])
expect(
parsed.result.tool_availability.groups.find((group) =>
group.names.includes(name),
),
).toMatchObject({
available: false,
reason: "target_unsupported",
remediation: expect.stringContaining("open_binary"),
});
};
const expectIndependentResourceWorkflows = async (
call: Awaited<ReturnType<typeof connectLocalToolsMcp>>["call"],
root: string,
firstBundle: string,
): Promise<void> => {
for (const detail of ["summary", "complete"] as const) {
const status = await call("binary_session", { detail });
expectUnsupportedArtifactOperations(status.structuredContent);
const parsed = toolContract("binary_session").outputSchema.parse(
status.structuredContent,
);
for (const name of ["inspect_desktop_application"])
expect(
parsed.result.tool_availability.groups.find((group) =>
group.names.includes(name),
),
).toMatchObject({ available: true });
}
const capability = sqliteDatabaseCapability(process.versions.node);
const status = await call("binary_session", { detail: "complete" });
const parsed = toolContract("binary_session").outputSchema.parse(
status.structuredContent,
);
expect(
parsed.result.tool_availability.groups.find((group) =>
group.names.includes("inspect_sqlite_database"),
),
).toMatchObject(
capability.available
? { available: true }
: {
available: false,
reason: "provider_unavailable",
remediation: capability.reason,
},
);
const desktop = await call("inspect_desktop_application", {
path: firstBundle,
});
expect(desktop.isError, JSON.stringify(desktop)).not.toBe(true);
if (!capability.available) return;
const databasePath = join(root, "fixture.sqlite");
const { DatabaseSync } = await import("node:sqlite");
const database = new DatabaseSync(databasePath);
database.exec("CREATE TABLE records (id INTEGER PRIMARY KEY)");
database.close();
const result = await call("inspect_sqlite_database", { path: databasePath });
expect(result.isError, JSON.stringify(result)).not.toBe(true);
};
cliTest.skipIf(process.platform !== "darwin")(
"inspects a keyless app resource root through the compiled CLI",
async ({ cli }) => {
@@ -52,11 +123,11 @@ cliTest.skipIf(process.platform !== "darwin")(
cliTest.skipIf(process.platform !== "darwin")(
"retains and switches keyless resource sessions through stdio MCP",
async () => {
const temporaryRoot = await createTestTempDirectory(
const root = await createTestTempDirectory(
"rea-resource-session-acceptance-",
);
const firstBundle = await createKeylessBundle(temporaryRoot, "First");
const secondBundle = await createKeylessBundle(temporaryRoot, "Second");
const firstBundle = await createKeylessBundle(root, "First");
const secondBundle = await createKeylessBundle(root, "Second");
const { call } = await connectLocalToolsMcp();
const opened = await call("open_bundle_resources", { path: firstBundle });
expect(opened.isError).not.toBe(true);
@@ -91,6 +162,37 @@ cliTest.skipIf(process.platform !== "darwin")(
},
});
await expectIndependentResourceWorkflows(call, root, firstBundle);
for (const [name, input] of [
["inspect_artifact", {}],
["extract_artifact", { integrity_policy: "fail" }],
["trace_dylib_resolution", { roots: ["Absent"] }],
] as const) {
const refused = await call(name, input);
expect(refused.isError).toBe(true);
expect(errorBody(refused)).toMatchObject({
error: { code: "capability_unavailable" },
});
}
const afterRefusals = await call("binary_session", { detail: "complete" });
expectUnsupportedArtifactOperations(afterRefusals.structuredContent);
const refusedSnapshot = await call("close_binary", {
snapshot_path: join(root, "snapshot.json"),
});
expect(refusedSnapshot.isError).toBe(true);
expect(errorBody(refusedSnapshot)).toMatchObject({
error: {
code: "evidence_integrity_mismatch",
category: "integrity_mismatch",
retryable: false,
remediation: {
action: analysisErrorRemediationAction(
new ResourceSnapshotMismatchError("export"),
),
},
},
});
await expect(lstat(join(root, "snapshot.json"))).rejects.toThrow("ENOENT");
for (const operation of resourceOperations) {
const arguments_ =
operation === "inspect_keyed_archive"
@@ -164,6 +266,14 @@ cliTest.skipIf(process.platform !== "darwin")(
},
);
const errorBody = (response: {
content: ReadonlyArray<{ readonly type: string; readonly text?: string }>;
}): unknown => {
const text = response.content.find((item) => item.type === "text");
if (typeof text?.text !== "string") throw new Error("Expected error text");
return JSON.parse(text.text) as unknown;
};
const createKeylessBundle = async (
root: string,
name: string,