mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
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:
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 = {
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user